Автор оригинала: Darinka Zobenica.
Вступление
Принципы проектирования – это обобщенные советы или проверенные эффективные методы кодирования, которые используются в качестве эмпирических правил при выборе дизайна.
Они аналогичны концепции шаблонов проектирования , основное отличие заключается в том, что принципы проектирования более абстрактны и обобщены. Это высокоуровневые советы, часто применимые ко многим различным языкам программирования или даже различным парадигмам.
Шаблоны проектирования также являются абстракциями или обобщенными передовыми практиками, но они предоставляют гораздо более конкретные и практические низкоуровневые рекомендации и связаны с целыми классами проблем, а не просто с обобщенными методами кодирования.
Некоторые из наиболее важных принципов проектирования в объектно-ориентированной парадигме перечислены в этой статье, но это ни в коем случае не исчерпывающий список.
- Не повторяйся (СУХОЙ) Принцип
- Соблюдайте Простой и Глупый (ПОЦЕЛУЙ) Принцип
- Принцип единой ответственности (SRP)
- Принцип “Открыто/Закрыто”
- Принцип замещения Лискова (LSP)
- Принцип разделения интерфейсов (ISP)
- Принцип инверсии зависимостей (DIP)
- Принцип Композиции По Наследованию
Принципы SRP, LSP, Open/Closed и DIP часто объединяются вместе и называются принципами SOLID .
Не повторяйся (СУХОЙ) Принцип
Принцип Не повторяйся (СУХОЙ) является общим принципом для всех парадигм программирования, но он особенно важен в ООП. В соответствии с принципом:
Каждая часть знаний или логики должна иметь единое, однозначное представление в системе .
Когда дело доходит до ООП, это означает использование абстрактных классов, интерфейсов и общедоступных констант. Всякий раз, когда есть функциональность, общая для всех классов, может иметь смысл либо абстрагировать их в общий родительский класс, либо использовать интерфейсы для объединения их функциональности:
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*"); } }
И Кошка
, и Собака
нуждаются в еде, но они говорят по-разному. Поскольку употребление пищи является для них общей функциональностью, мы можем абстрагировать ее в родительский класс, такой как 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, они должны быть заглавными со словами, разделенными подчеркиванием (“_”).
Цель этого принципа состоит в том, чтобы обеспечить простоту обслуживания кода, потому что при изменении функциональности или константы вы должны редактировать код только в одном месте. Это не только облегчает работу, но и гарантирует, что в будущем ошибок не произойдет. Вы можете забыть отредактировать код в нескольких местах, или кто-то другой, кто не так хорошо знаком с вашим проектом, может не знать, что вы повторили код, и может в конечном итоге отредактировать его только в одном месте.
Однако при использовании этого принципа важно руководствоваться здравым смыслом. Если вы изначально используете один и тот же фрагмент кода для выполнения двух разных действий, это не означает, что с этими двумя вещами всегда нужно будет обращаться одинаково.
Обычно это происходит, если структуры на самом деле не похожи, несмотря на то, что для их обработки используется один и тот же код. Код также может быть “пересушен”, что делает его по существу нечитабельным, поскольку методы вызываются из несвязанных, непонятных мест.
Хорошая архитектура может амортизировать это, но, тем не менее, проблема может возникнуть на практике.
Нарушения СУХОГО принципа
Нарушения СУХОГО принципа часто называют ВЛАЖНЫМИ растворами . МОКРЫЙ может быть аббревиатурой для нескольких вещей:
- Нам Нравится Печатать
- Отнимите у всех Время
- Пишите Каждый Раз
- Напиши Все Дважды
ВЛАЖНЫЕ решения не всегда плохи, так как иногда рекомендуется повторение в изначально непохожих классах или для того, чтобы сделать код более читаемым, менее взаимозависимым и т. Д.
Соблюдайте Простой и Глупый (ПОЦЕЛУЙ) Принцип
Принцип Будь простым и глупым (ПОЦЕЛУЙ) – это напоминание о том, чтобы твой код был простым и понятным для людей. Если ваш метод обрабатывает несколько вариантов использования, разделите их на более мелкие функции. Если он выполняет несколько функций, вместо этого создайте несколько методов.
Суть этого принципа заключается в том, что в большинстве случаев, если только эффективность не является чрезвычайно важной, другой вызов стека не сильно повлияет на производительность вашей программы. Фактически, некоторые компиляторы или среды выполнения даже упрощают вызов метода до встроенного выполнения.
С другой стороны, нечитаемые и длинные методы будет очень трудно поддерживать для программистов-людей, ошибки будет сложнее найти, и вы также можете обнаружить, что нарушаете правила, потому что, если функция выполняет две вещи, вы не можете вызвать ее для выполнения только одной из них, поэтому вы создадите другой метод.
В общем, если вы запутались в своем собственном коде и не знаете, что делает каждая часть, пришло время для переоценки.
Почти наверняка дизайн можно было бы изменить, чтобы сделать его более читабельным. И если у вас возникли проблемы как у того, кто его разработал, пока все это еще свежо в вашей памяти, подумайте о том, как будет работать тот, кто увидит его впервые в будущем.
Принцип единой ответственности (SRP)
Принцип Единой ответственности (SRP) гласит, что в одном классе никогда не должно быть двух функций. Иногда это перефразируется как:
“У класса должна быть только одна и только одна причина для изменения.”
Где “причина для изменения” является ответственностью класса. Если существует более одной ответственности, то в какой-то момент есть больше причин изменить этот класс.
Это означает, что в случае необходимости обновления функциональности в одном и том же классе не должно быть нескольких отдельных функций, которые могут быть затронуты.
Этот принцип облегчает устранение ошибок, реализацию изменений без путаницы в взаимозависимостях и наследование от класса без необходимости реализации или наследования методов, которые вашему классу не нужны.
Хотя может показаться, что это побуждает вас во многом полагаться на зависимости, такого рода модульность гораздо важнее. Некоторый уровень зависимости между классами неизбежен, поэтому у нас также есть принципы и шаблоны для решения этой проблемы.
Например, предположим, что ваше приложение должно извлечь некоторую информацию о продукте из базы данных, затем обработать ее и, наконец, отобразить конечному пользователю.
Мы могли бы использовать один класс для обработки вызова базы данных, обработки информации и передачи информации на уровень представления. Благодаря объединению этих функций наш код становится нечитаемым и нелогичным.
Вместо этого мы бы определили класс, такой как ProductService
, который будет извлекать продукт из базы данных, ProductController
для обработки информации, а затем мы отобразили бы его на уровне представления – либо на HTML-странице, либо в другом классе/графическом интерфейсе.
Принцип “Открыто/Закрыто”
Принцип Открыть/Закрыть гласит, что классы или объекты и методы должны быть открыты для расширения, но закрыты для изменений.
По сути, это означает, что вы должны разрабатывать свои классы и модули с учетом возможных будущих обновлений, поэтому они должны иметь общий дизайн, в котором вам не нужно будет изменять сам класс, чтобы расширить их поведение.
Вы можете добавить больше полей или методов, но таким образом, чтобы вам не нужно было переписывать старые методы, удалять старые поля и изменять старый код, чтобы он снова работал. Продумывание будущего поможет вам написать стабильный код до и после обновления требований.
Этот принцип важен для обеспечения обратной совместимости и предотвращения регрессий – ошибки, которая возникает, когда функции или эффективность ваших программ нарушаются после обновления.
Принцип замещения Лискова (LSP)
В соответствии с принципом подстановки Лискова (LSP) производные классы должны иметь возможность заменять свои базовые классы без изменения поведения вашего кода.
Этот принцип тесно связан с Принцип разделения интерфейса и Принцип единой ответственности , означающий, что нарушение любого из них, вероятно, также будет (или станет) нарушением LSP. Это связано с тем, что если класс выполняет более одной задачи, подклассы, расширяющие его, с меньшей вероятностью будут осмысленно реализовывать эти две или более функций.
Распространенный способ, которым люди думают об отношениях объектов (что иногда может немного вводить в заблуждение), заключается в том, что между классами должна быть связь is/.
Например:
Git Essentials
Ознакомьтесь с этим практическим руководством по изучению Git, содержащим лучшие практики и принятые в отрасли стандарты. Прекратите гуглить команды Git и на самом деле изучите это!
Автомобиль
являетсяТранспортным средством
Ассистент преподавателя
являетсяСотрудником колледжа
Важно отметить, что эти отношения не идут в обоих направлениях. Тот факт, что Автомобиль
является Транспортным средством
может не означать, что Транспортное средство
является Автомобилем
– это может быть Мотоцикл
, Велосипед
, Грузовик
…
Причина, по которой это может вводить в заблуждение, заключается в распространенной ошибке, которую люди совершают, думая об этом на естественном языке. Например, если бы я спросил вас, имеет ли Квадрат
“связь” с Прямоугольником
, вы могли бы автоматически ответить “да”.
В конце концов, мы знаем из геометрии, что квадрат является частным случаем прямоугольника. Но в зависимости от того, как реализованы ваши структуры, это может быть не так:
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; } }
Теперь давайте попробуем унаследовать от него наш Квадрат
в одном пакете:
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
. Некоторые из вас, возможно, уже догадываются о проблеме. Допустим, мы инициализировали наш Квадрат
и применили полиморфизм, чтобы поместить его в переменную Прямоугольник
:
Rectangle rec = new Square(5);
И давайте предположим, что когда-нибудь позже в программе, возможно, в совершенно отдельной функции, другой программист, который не имел никакого отношения к реализации этих классов, решает, что они хотят изменить размер своего прямоугольника. Они могут попробовать что-то вроде этого:
rec.setA(6); rec.setB(3);
Они получат совершенно неожиданное поведение, и может быть трудно проследить, в чем проблема.
Если они попытаются использовать rec.вычислить площадь()
результат не будет 18
как и следовало ожидать от прямоугольника со сторонами разной длины 6
и 3
.
Вместо этого результатом будет 9
потому что их прямоугольник на самом деле является квадратом и имеет две равные стороны – длины 3
.
Вы можете сказать, что это именно то поведение, которое вы хотели, потому что именно так работает квадрат, но, тем не менее, это не ожидаемое поведение от прямоугольника.
Поэтому, когда мы наследуем, мы должны иметь в виду поведение наших классов и действительно ли они функционально взаимозаменяемы в коде, а не просто понятия, схожие вне контекста их использования в программе.
Принцип разделения интерфейсов (ISP)
Принцип разделения интерфейса (ISP) гласит, что клиент никогда не должен зависеть от интерфейса, который он не использует полностью. Это означает, что интерфейс должен иметь минимальный набор методов, необходимых для обеспечиваемой им функциональности, и должен быть ограничен только одной функциональностью.
Например, интерфейс Pizza
не должен требоваться для реализации метода add Pepperoni ()
, поскольку он не обязательно должен быть доступен для каждого типа пиццы. Ради этого урока давайте предположим, что все пиццы имеют соус и должны быть испечены, и нет ни одного исключения.
Это когда мы можем определить интерфейс:
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");} }
В Вегетарианской пицце
есть грибы, тогда как в Пицце с пепперони
есть пепперони. Оба, конечно, нуждаются в соусе и должны быть запечены, что также определено в интерфейсе.
Если бы методы добавить грибы()
или добавить Пепперони()
находились в интерфейсе, оба класса должны были бы реализовать их, даже если им не нужны оба, а скорее только по одному каждому.
Мы должны лишить интерфейсы всех, кроме абсолютно необходимых функций.
Принцип инверсии зависимостей (DIP)
В соответствии с принципом инверсии зависимостей (DIP) модули высокого и низкого уровней должны быть разделены таким образом, чтобы изменение (или даже замена) модулей низкого уровня не требовало (большой) переработки модулей высокого уровня. Учитывая это, как низкоуровневые, так и высокоуровневые модули не должны зависеть друг от друга, а скорее они должны зависеть от абстракций, таких как интерфейсы.
Еще одна важная вещь, о которой говорится, – это:
Абстракции не должны зависеть от деталей. Детали (конкретные реализации) должны зависеть от абстракций.
Этот принцип важен, поскольку он разделяет модули, делая систему менее сложной, более простой в обслуживании и обновлении, более простой в тестировании и более многоразовой. Я не могу не подчеркнуть, насколько это меняет правила игры, особенно для модульного тестирования и возможности повторного использования. Если код написан достаточно обобщенно, он может легко найти применение в другом проекте, в то время как код, который слишком специфичен и взаимозависим с другими модулями исходного проекта, будет трудно отделить от него.
Этот принцип тесно связан с внедрением зависимостей , которое практически является реализацией или, скорее, целью DIP. DI сводится к следующему: если два класса зависят, их функции должны быть абстрагированы, и они оба должны зависеть от абстракции, а не друг от друга. Это, по сути, должно позволить нам изменять детали реализации, сохраняя при этом ее функциональность.
Принцип инверсии зависимостей и Инверсия управления (IoC) используются некоторыми людьми взаимозаменяемо, хотя технически это неверно.
Инверсия зависимостей ведет нас к развязке с помощью инъекции зависимостей через Инверсию контейнера управления . Другим именем контейнеров IoC вполне может быть Контейнеры для инъекций зависимостей , хотя старое название остается неизменным.
Принцип Композиции По Наследованию
При проектировании своих систем часто следует отдавать предпочтение композиции над наследованию . В Java это означает, что мы должны чаще определять интерфейсы и реализовывать их, а не определять классы и расширять их.
Мы уже упоминали, что Автомобиль
является Транспортным средством
в качестве общего руководящего принципа, который люди используют для определения того, должны ли классы наследовать друг друга или нет.
Несмотря на то, что о нем сложно думать и он имеет тенденцию нарушать Принцип подстановки Лискова, этот способ мышления чрезвычайно проблематичен, когда дело доходит до повторного использования и повторного использования кода на более поздних этапах разработки.
Проблема здесь проиллюстрирована следующим примером:
Космический корабль
и Самолет
расширяют абстрактный класс Летательный аппарат
, в то время как Автомобиль
и Грузовик
расширяют Наземный транспорт
. У каждого из них есть свои соответствующие методы, которые имеют смысл для типа транспортного средства, и мы, естественно, группируем их вместе с абстракцией, когда думаем о них в этих терминах.
Эта структура наследования основана на мышлении об объектах с точки зрения того, чем они являются , а не того, что они делают .
Проблема в том, что новые требования могут нарушить всю иерархию равновесия. В этом примере, что, если бы ваш босс вошел и сообщил вам, что клиент хочет летающую машину прямо сейчас? Если вы унаследуете Летательный аппарат
, вам придется снова реализовать 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, не допускают множественного наследования, мы можем расширить любой из этих классов. Хотя в обоих случаях мы не можем унаследовать функциональность другого и должны переписать его.
Вы можете найти способ изменить всю архитектуру, чтобы она соответствовала этой новой Летающий автомобиль
класса, но в зависимости от того, насколько глубоко вы находитесь в разработке, это может быть дорогостоящим процессом.
Учитывая эту проблему, мы могли бы попытаться избежать всего этого беспорядка, основывая наши обобщения на общей функциональности вместо присущего сходства . Именно таким образом было разработано множество встроенных механизмов Java.
Если ваш класс собирается реализовать все функциональные возможности, и ваш дочерний класс может быть использован в качестве замены вашего родительского класса, используйте наследование .
Если ваш класс собирается реализовать некоторые конкретные функции, используйте композицию .
Мы используем Запускаемые
, Сопоставимые
и т.д. Вместо использования некоторых абстрактных классов , реализующих свои методы, потому что это чище, это делает код более многоразовым и упрощает создание нового класса, соответствующего тому, что нам нужно для использования ранее созданных функций.
Это также решает проблему зависимостей, разрушающих важные функциональные возможности и вызывающих цепную реакцию во всем нашем коде. Вместо того, чтобы испытывать большие проблемы, когда нам нужно заставить наш код работать для нового типа вещей, мы можем просто заставить эту новую вещь соответствовать ранее установленным стандартам и работать так же хорошо, как и старую вещь.
В нашем примере с автомобилем мы могли бы просто реализовать интерфейсы Управляемые
и Управляемые
вместо введения абстракции и наследования.
Наш Самолет
и Космический корабль
могли бы реализовать Летающий
, наш Автомобиль
и Грузовик
могли бы реализовать Управляемый
, и наш новый Летающий автомобиль
мог бы реализовать и то, и другое .
Никаких изменений в структуре класса не требовалось, никаких серьезных нарушений правил, никакого замешательства коллег. Если вам понадобится точно такая же функциональность в нескольких классах, вы можете реализовать ее с помощью метода по умолчанию в своем интерфейсе, чтобы избежать нарушения DRY.
Вывод
Принципы проектирования являются важной частью инструментария разработчика, и более осознанный выбор при разработке программного обеспечения поможет вам разобраться в нюансах тщательного, перспективного дизайна.
Большинство разработчиков действительно изучают их на собственном опыте, а не в теории, но теория может помочь, предоставив вам новую точку зрения и ориентировав вас на более продуманные дизайнерские привычки, особенно на том интервью в той компании, которая построила все свои системы на этих принципах.