Принципы объектно-ориентированного дизайна в Java

Введение Принципы дизайна - это обобщенные советы или проверенные хорошие практики кодирования, которые используются в качестве практических правил при выборе дизайна. Они аналогичны шаблонам проектирования [/ design-patterns-in-java], главное отличие состоит в том, что принципы проектирования более абстрактны и обобщены. Это советы высокого уровня, часто применимые ко многим различным языкам программирования или даже к разным парадигмам. Паттерны проектирования - это тоже абстракции или обобщенный хороший продукт.

Вступление

Принципы дизайна - это обобщенные советы или проверенные хорошие практики кодирования, которые используются в качестве практических правил при выборе дизайна.

Они аналогичны шаблонам проектирования , главное отличие состоит в том, что принципы проектирования более абстрактны и обобщены. Это советы высокого уровня , часто применимые ко многим различным языкам программирования или даже к разным парадигмам.

Шаблоны проектирования также являются абстракциями или обобщенными передовыми практиками, но они предоставляют гораздо более конкретные и практические советы низкого уровня и относятся к целым классам проблем, а не только к обобщенным практикам кодирования.

Некоторые из наиболее важных принципов проектирования в объектно-ориентированной парадигме перечислены в этой статье, но это ни в коем случае не исчерпывающий список.

Принципы SRP, LSP, Open / Closed и DIP часто объединяются вместе и называются принципами SOLID.

Принцип «не повторяйся» (СУХОЙ)

Принцип « Не повторяйся» (DRY) является общим принципом во всех парадигмах программирования, но особенно важен в ООП. По принципу:

Каждая часть знания или логики должна иметь единственное недвусмысленное представление в системе .

Когда дело доходит до ООП, это означает использование абстрактных классов, интерфейсов и общедоступных констант. Всякий раз, когда есть функциональность, общая для классов, может иметь смысл либо абстрагировать их в общий родительский класс, либо использовать интерфейсы для объединения их функций:

 public class Animal { 
 public void eatFood() { 
 System.out.println("Eating food..."); 
 } 
 } 
 
 public class Cat extends Animal { 
 public void meow() { 
 System.out.println("Meow! *purrs*"); 
 } 
 } 
 
 public class Dog extends Animal { 
 public void woof() { 
 System.out.println("Woof! *wags tail*"); 
 } 
 } 

И Cat и Dog нужно есть, но они говорят по-разному. Поскольку поедание еды является для них общей функцией, мы можем абстрагировать ее до родительского класса, такого как Animal а затем заставить их расширить класс.

Теперь вместо того, чтобы оба класса реализуют одну и ту же функциональность приема пищи, каждый может сосредоточиться на своей уникальной логике.

 Cat cat = new Cat(); 
 cat.eatFood(); 
 cat.meow(); 
 
 Dog dog = new Dog(); 
 dog.eatFood(); 
 dog.woof(); 

Результатом будет:

 Eating food... 
 Meow! *purrs* 
 Eating food... 
 Woof! *wags tail* 

Когда есть константа, которая используется несколько раз, рекомендуется определить ее как общедоступную константу:

 static final int GENERATION_SIZE = 5000; 
 static final int REPRODUCTION_SIZE = 200; 
 static final int MAX_ITERATIONS = 1000; 
 static final float MUTATION_SIZE = 0.1f; 
 static final int TOURNAMENT_SIZE = 40; 

Например, мы будем использовать эти константы несколько раз и в конечном итоге будем изменять их значения вручную, чтобы оптимизировать генетический алгоритм. Было бы легко ошибиться, если бы нам пришлось обновить каждое из этих значений в нескольких местах.

Кроме того, мы не хотим ошибаться и программно изменять эти значения во время выполнения, поэтому мы также вводим модификатор final

Примечание. В соответствии с соглашением об именах в Java они должны начинаться с заглавной буквы, а слова разделяются знаком подчеркивания («_»).

Цель этого принципа - обеспечить простое обслуживание кода, потому что при изменении функциональности или константы вы должны редактировать код только в одном месте. Это не только облегчает работу, но и гарантирует, что ошибок не случится в будущем. Вы можете забыть отредактировать код в нескольких местах, или кто-то другой, кто не так хорошо знаком с вашим проектом, может не знать, что вы повторяете код, и может в конечном итоге отредактировать его только в одном месте.

Однако при использовании этого принципа важно руководствоваться здравым смыслом. Если вы изначально используете один и тот же фрагмент кода для двух разных вещей, это не значит, что с этими двумя вещами всегда нужно будет обращаться одинаково.

Обычно это происходит, если структуры на самом деле не похожи, несмотря на то, что для их обработки используется один и тот же код. Код также может быть «пересушенным», что делает его практически нечитаемым, потому что методы, вызываемые из несвязанных, непонятных мест.

Хорошая архитектура может амортизировать это, но, тем не менее, проблема может возникнуть на практике.

Нарушения принципа DRY

Нарушения принципа DRY часто называют растворами WET . WET может быть сокращением для нескольких вещей:

  • Нам нравится печатать
  • Тратить впустую время каждого
  • Пишите каждый раз
  • Напишите все дважды

Решения WET не всегда плохи, поскольку иногда рекомендуется повторение в изначально несходных классах или для того, чтобы сделать код более читаемым, менее взаимозависимым и т. Д.

Принцип Keep It Simple and Stupid (KISS)

Принцип « Держи это просто и глупо» (KISS) - это напоминание о том, что код должен быть простым и читаемым для людей. Если ваш метод обрабатывает несколько вариантов использования, разделите их на более мелкие функции. Если он выполняет несколько функций, вместо этого создайте несколько методов.

Суть этого принципа заключается в том, что в большинстве случаев, если эффективность не является чрезвычайно важной, другой вызов стека не будет сильно влиять на производительность вашей программы. Фактически, некоторые компиляторы или среды выполнения даже упростят вызов метода во встроенное выполнение.

С другой стороны, нечитабельные и длинные методы будет очень трудно поддерживать программистам, ошибки будет труднее найти, и вы также можете обнаружить, что нарушаете DRY, потому что если функция выполняет две вещи, вы не можете вызвать ее сделайте только один из них, так что вы сделаете другой метод.

В общем, если вы запутались в собственном коде и не знаете, что делает каждая часть, пришло время для переоценки.

Почти наверняка дизайн можно изменить, чтобы сделать его более читабельным. И если у вас возникли проблемы как у того, кто его разработал, пока все еще свежо в вашей голове, подумайте о том, как выступит тот, кто впервые увидит это в будущем.

Принцип единой ответственности (SRP)

Принцип единой ответственности (SRP) гласит, что никогда не должно быть двух функций в одном классе. Иногда это перефразируют так:

«У класса должна быть только одна и только одна причина для изменения».

Если «причина для изменения» - это ответственность класса. Если существует более одной ответственности, есть больше причин изменить этот класс в какой-то момент.

Это означает, что в случае функциональности, требующей обновления, не должно быть нескольких отдельных функций в том же классе, которые могут быть затронуты.

Этот принцип упрощает работу с ошибками, реализацию изменений без запутанных взаимозависимостей и наследование от класса без необходимости реализовывать или наследовать методы, которые не нужны вашему классу.

Хотя может показаться, что это побуждает вас во многом полагаться на зависимости, такая модульность гораздо важнее. Определенный уровень зависимости между классами неизбежен, поэтому у нас также есть принципы и шаблоны для решения этой проблемы.

Например, предположим, что наше приложение должно получить некоторую информацию о продукте из базы данных, затем обработать ее и, наконец, отобразить ее конечному пользователю.

Мы могли бы использовать один класс для обработки вызова базы данных, обработки информации и передачи информации на уровень представления. Однако объединение этих функций делает наш код нечитаемым и нелогичным.

Вместо этого мы бы определили класс, например ProductService который будет извлекать продукт из базы данных, ProductController для обработки информации, а затем отображать его на уровне представления - либо на HTML-странице, либо на другом классе / графическом интерфейсе пользователя. .

Принцип открытого / закрытого

Принцип Open / Closed гласит, что классы или объекты и методы должны быть открыты для расширения, но закрыты для модификаций.

По сути, это означает, что вы должны проектировать свои классы и модули с учетом возможных будущих обновлений, поэтому они должны иметь общий дизайн, и вам не нужно будет изменять сам класс, чтобы расширить их поведение.

Вы можете добавить больше полей или методов, но таким образом, чтобы вам не нужно было переписывать старые методы, удалять старые поля и изменять старый код, чтобы он снова заработал. Забегая вперед, вы сможете писать стабильный код до и после обновления требований.

Этот принцип важен для обеспечения обратной совместимости и предотвращениярегрессов

  • ошибки, которая возникает, когда функции вашей программы или ее эффективность снижается после обновления.

Принцип замещения Лискова (LSP)

Согласно принципу подстановки Лискова (LSP) производные классы должны иметь возможность заменять свои базовые классы без изменения поведения вашего кода.

Этот принцип тесно связан с принципом разделения интерфейсов и принципом единой ответственности , а это означает, что нарушение любого из них, вероятно, также будет (или станет) нарушением LSP. Это связано с тем, что, если класс выполняет более одного действия, подклассы, расширяющие его, с меньшей вероятностью будут осмысленно реализовать эти две или более функциональных возможностей.

Обычно люди думают об объектных отношениях (что иногда может вводить в заблуждение), что между классами должна быть связь is.

Например:

  • Car - это Vehicle
  • TeachingAssistaint - сотрудник CollegeEmployee

Важно отметить, что эти отношения не идут в обоих направлениях. Тот факт, что Car является Vehicle может не означать, что Vehicle является Car - это может быть Motorcycle , Bicycle , Truck ...

Причина, по которой это может вводить в заблуждение, - это распространенная ошибка, которую люди допускают, когда думают об этом на естественном языке. Например, если бы я спросил вас, есть ли у Square «взаимосвязь» с Rectangle , вы могли бы автоматически ответить «да».

В конце концов, мы знаем из геометрии, что квадрат - это частный случай прямоугольника. Но в зависимости от того, как реализованы ваши структуры, это может быть не так:

 public class Rectangle { 
 protected double a; 
 protected double b; 
 
 public Rectangle(double a, double b) { 
 this.a = a; 
 this.b = b; 
 } 
 
 public void setA(double a) { 
 this.a = a; 
 } 
 
 public void setB(double b) { 
 this.b = b; 
 } 
 
 public double calculateArea() { 
 return a*b; 
 } 
 } 

Теперь попробуем наследовать от него для нашего Square в том же пакете:

 public class Square extends Rectangle { 
 public Square(double a) { 
 super(a, a); 
 } 
 
 @Override 
 public void setA(double a) { 
 this.a = a; 
 this.b = a; 
 } 
 
 @Override 
 public void setB(double b) { 
 this.a = b; 
 this.b = b; 
 } 
 } 

Вы заметите, что сеттеры здесь на самом деле устанавливают и a и b . Некоторые из вас, возможно, уже догадались о проблеме. Допустим, мы инициализировали наш Square и применили полиморфизм, чтобы поместить его в переменную Rectangle

 Rectangle rec = new Square(5); 

И предположим, что когда-нибудь позже в программе, возможно, в совершенно отдельной функции, другой программист, не имеющий ничего общего с реализацией этих классов, решает, что они хотят изменить размер своего прямоугольника. Они могут попробовать что-то вроде этого:

 rec.setA(6); 
 rec.setB(3); 

Они будут вести себя совершенно неожиданно, и может быть сложно отследить, в чем проблема.

Если они попытаются использовать rec.calculateArea() результат будет не 18 как можно было бы ожидать от прямоугольника со сторонами длиной 6 и 3 .

Вместо этого результатом будет 9 потому что их прямоугольник на самом деле является квадратом и имеет две равные стороны - длиной 3 .

Вы можете сказать, что это именно то поведение, которое вы хотели, потому что именно так работает квадрат, но, тем не менее, это не ожидаемое поведение от прямоугольника.

Поэтому, когда мы наследуем, мы должны помнить о поведении наших классов и о том, действительно ли они функционально взаимозаменяемы в коде, а не просто о сходстве концепций вне контекста их использования в программе.

Принцип разделения интерфейса (ISP)

Принцип разделения интерфейса (ISP) гласит, что клиента никогда не следует заставлять зависеть от интерфейса, который он не использует полностью. Это означает, что интерфейс должен иметь минимальный набор методов, необходимых для обеспечения функциональности, которую он обеспечивает, и должен быть ограничен только одной функцией.

Например, Pizza не должен требовать реализации addPepperoni() , потому что он не должен быть доступен для каждого типа пиццы. Ради этого урока предположим, что все пиццы имеют соус и их нужно запекать, и нет ни одного исключения.

Вот когда мы можем определить интерфейс:

 public interface Pizza { 
 void addSauce(); 
 void bake(); 
 } 

А затем давайте реализуем это с помощью пары классов:

 public class VegetarianPizza implements Pizza { 
 public void addMushrooms() {System.out.println("Adding mushrooms");} 
 
 @Override 
 public void addSauce() {System.out.println("Adding sauce");} 
 
 @Override 
 public void bake() {System.out.println("Baking the vegetarian pizza");} 
 } 
 
 public class PepperoniPizza implements Pizza { 
 public void addPepperoni() {System.out.println("Adding pepperoni");} 
 
 @Override 
 public void addSauce() {System.out.println("Adding sauce");} 
 
 @Override 
 public void bake() {System.out.println("Baking the pepperoni pizza");} 
 } 

В VegetarianPizza есть грибы, а в PepperoniPizza - пепперони. Оба, конечно, нуждаются в соусе и должны быть запеченными, что также определено в интерфейсе.

Если бы addMushrooms() или addPepperoni() были расположены в интерфейсе, оба класса должны были бы реализовать их, даже если им не нужны оба, а только по одному.

Мы должны лишить интерфейсы всех функций, кроме абсолютно необходимых.

Принцип инверсии зависимостей (DIP)

Согласно принципу инверсии зависимостей (DIP), высокоуровневые и низкоуровневые модули должны быть разделены таким образом, чтобы изменение (или даже замена) низкоуровневых модулей не требовало (большой) доработки высокоуровневых модулей. При этом и низкоуровневые, и высокоуровневые модули не должны зависеть друг от друга, а должны зависеть от абстракций, таких как интерфейсы.

Еще одна важная вещь, которую заявляет DIP:

Абстракции не должны зависеть от деталей. Детали (конкретные реализации) должны зависеть от абстракций.

Этот принцип важен, поскольку он разделяет модули, делая систему менее сложной, более простой в обслуживании и обновлении, более простой для тестирования и более удобной для повторного использования. Я не могу не подчеркнуть, насколько это меняет правила игры, особенно в отношении модульного тестирования и повторного использования. Если код написан достаточно обобщенно, он может легко найти применение в другом проекте, в то время как код, который слишком специфичен и взаимозависим с другими модулями исходного проекта, будет трудно отделить от него.

Этот принцип тесно связан с внедрением зависимостей , что практически является реализацией или, скорее, целью DIP. DI сводится к тому, что если два класса зависимы, их функции должны быть абстрагированы, и они оба должны зависеть от абстракции, а не друг от друга. По сути, это должно позволить нам изменять детали реализации, сохраняя при этом ее функциональность.

Принцип инверсии зависимостей и инверсия управления (IoC) взаимозаменяемы некоторыми людьми, хотя технически это не так.

Инверсия зависимостей ведет нас к разделению с помощью внедрения зависимостей через инверсию контейнера управления . Другое название контейнеров IoC вполне может быть Контейнеры внедрения зависимостей , хотя старое название остается.

Принцип композиции над наследованием

При проектировании систем часто следует предпочесть композицию наследованию. В Java это означает, что мы должны чаще определять интерфейсы и реализовывать их, а не определять классы и расширять их.

Мы уже упоминали, что Car - это Vehicle как общий руководящий принцип, который люди используют, чтобы определить, должны ли классы наследовать друг друга или нет.

Несмотря на то, что принцип подстановки Лискова сложно представить и имеет тенденцию нарушать, этот способ мышления чрезвычайно проблематичен, когда дело доходит до повторного использования и перепрофилирования кода на более поздних этапах разработки.

Проблема здесь иллюстрируется следующим примером:

диаграмма отношенийобъектов{.ezlazyload}

Spaceship и Airplane расширяют абстрактный класс FlyingVehicle , а Car и Truck расширяют GroundVehicle . У каждого есть свои соответствующие методы, которые имеют смысл для типа транспортного средства, и мы, естественно, сгруппируем их вместе с помощью абстракции, когда будем думать о них в этих терминах.

Эта структура наследования основана на размышлении об объектах с точки зрения того, что они есть, а не того, что они делают .

Проблема в том, что новые требования могут вывести из равновесия всю иерархию. В этом примере, что, если бы ваш босс ворвался и сообщил вам, что клиент хочет летающую машину прямо сейчас? Если вы унаследовали от FlyingVehicle , вам придется снова реализовать drive() даже если такая же функциональность уже существует, тем самым нарушая принцип DRY, и наоборот:

 public class FlyingVehicle { 
 public void fly() {} 
 public void land() {} 
 } 
 
 public class GroundVehicle { 
 public void drive() {} 
 } 
 
 public class FlyingCar extends FlyingVehicle { 
 
 @Override 
 public void fly() {} 
 
 @Override 
 public void land() {} 
 
 public void drive() {} 
 } 
 
 public class FlyingCar2 extends GroundVehicle { 
 
 @Override 
 public void drive() {} 
 
 public void fly() {} 
 public void land() {} 
 } 

Поскольку большинство языков, включая Java, не допускают множественного наследования, мы можем расширить любой из этих классов. Хотя в обоих случаях мы не можем унаследовать функциональность другого и должны его переписать.

Вы можете найти способ изменить всю архитектуру, чтобы она соответствовала новому FlyingCar , но в зависимости от того, насколько глубоко вы находитесь в разработке, это может оказаться дорогостоящим процессом.

Учитывая эту проблему, мы могли бы попытаться избежать всего этого беспорядка, опираясь на общие функции, а не на внутреннее сходство . Так было разработано множество встроенных механизмов Java.

Если ваш класс будет реализовывать все функции, а ваш дочерний класс может использоваться вместо родительского класса, используйте наследование .

Если ваш класс будет реализовывать некоторые специфические функции, используйте композицию .

Мы используем Runnable , Comparable и т. Д. Вместо использования некоторых абстрактных классов, реализующих свои методы, потому что он чище, делает код более пригодным для повторного использования и упрощает создание нового класса, который соответствует тому, что нам нужно, чтобы использовать ранее созданные функции. .

Это также решает проблему зависимостей, разрушающих важные функции и вызывающих цепную реакцию во всем нашем коде. Вместо того чтобы иметь большую проблему, когда нам нужно заставить наш код работать для нового типа вещей, мы можем просто заставить эту новую вещь соответствовать ранее установленным стандартам и работать так же хорошо, как и старая вещь.

В нашем примере с транспортным средством мы могли бы просто реализовать интерфейсы Flyable и Drivable вместо того, чтобы вводить абстракцию и наследование.

Наши Airplane и Spaceship Flyable могли реализовать Flyable, наши Car и Truck могли реализовать Drivable , а наш новый FlyingCar мог реализовать и то, и другое .

Никаких изменений в структуре классов, никаких серьезных нарушений DRY, никакого замешательства коллег. Если вам понадобится одна и та же функциональность в нескольких классах, вы можете реализовать ее, используя метод по умолчанию в вашем интерфейсе, чтобы избежать нарушения DRY.

Заключение

Принципы дизайна - важная часть набора инструментов разработчика, и принятие более осознанного выбора при разработке программного обеспечения поможет вам уловить нюансы тщательного, перспективного дизайна.

Большинство разработчиков действительно изучают их на собственном опыте, а не на теории, но теория может помочь, дав вам новую точку зрения и направив вас на более продуманные дизайнерские привычки, особенно на том собеседовании в той компании, которая построила все свои системы на этих принципах.

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus