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

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

Понимание концепции и проблем, которые она решает

ОТКАЗ ОТ ОТВЕТСТВЕННОСТИ: эта статья является первой частью сериала по объектно-ориентированному программированию и шаблонам проектирования.

Вступление

Среди всех подходов к разработке программного обеспечения объектно-ориентированная парадигма всегда казалась мне наиболее естественной и логичной. Чтобы поделиться своим мнением о том, почему я так считаю, я пишу серию статей, посвященных ООП-дизайну и шаблонам разработки.

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

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

Мы начнем с краткого введения, а затем перейдем к описанию каждого принципа и дополним описания иллюстративными примерами.

Чтобы абстрагироваться от ненужных деталей, примеры в этой статье проиллюстрированы с помощью UML (Unified Modeling Language). Примеры просты и универсальны, чтобы их можно было легко применить к любому языку программирования.

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

Фон

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

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

Одноразовый код

Без лишних слов, вот подача: цель принципов SOLID - решить критические проблемы, связанные с одноразовым кодом.

Основные идеи были объединены в одну концепцию Робертом К. Мартином (сама аббревиатура SOLID была позже предложена Майклом Фезерсом) с целью устранения следующих характеристик «плохого» дизайна программного обеспечения:

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

Как утверждает сам Роберт Мартин, идея, лежащая в основе принципов SOLID, состоит в том, чтобы «сделать проекты программного обеспечения более понятными, гибкими и удобными в обслуживании».

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

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

SRP: принцип единой ответственности

По словам Роберта Мартина, из всех принципов SOLID «Принцип единой ответственности (SRP) может быть наименее понятным». Я сам неправильно истолковал этот принцип и даже несколько раз неверно процитировал его, поэтому я лично могу понять это мнение.

В частности, я всегда предполагал, что все дело в части «единой ответственности» - и поэтому каждый класс должен выполнять только одну задачу или отвечать за одну вещь.

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

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

Сначала это может показаться немного запутанным, поэтому попробуйте думать об этом следующим образом: программное обеспечение существует для удовлетворения потребностей пользователей и связанных с ними заинтересованных сторон. В нашем конкретном примере нас интересуют заинтересованные стороны, которые могут тем или иным образом влиять / вносить изменения в программное обеспечение. Роберт Мартин называет этих влиятельных лиц «Актерами».

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

В своей книге «Чистая архитектура» Мартин приводит отличный пример, иллюстрирующий эту мысль:

Представьте, что у нас есть класс «Сотрудник», выходные данные которого используются тремя различными отделами компании: Техническим, Финансовым и HR. У класса есть три метода:

  • calculate_pay (): используется финансовым отделом для определения заработной платы сотрудников.
  • report_hours (): используется отделом кадров для расчета рабочего времени.
  • save (): используется администраторами базы данных, работающими в техническом отделе, для сохранения результатов в базе данных.

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

Представьте, что отдел кадров просит нас внести изменения в метод report_hours (). Чтобы удовлетворить их запрос, мы меняем соответствующую служебную функцию, которая также используется в алгоритме расчета времени метода calculate_pay (). Такое изменение могло привести к неожиданным последствиям для обоих отделов и даже «сломать» класс.

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

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

OCP: принцип открытости / закрытости

Впервые сформулированная Бертраном Мейером в 1988 году, в своем классическом определении OCP известна как:

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

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

Чтобы лучше представить себе эту идею, давайте взглянем на следующий пример, который нарушает OCP:

Мы создали три класса:

  • Класс Rectangle, который описывает соответствующую форму и определяет два атрибута прямоугольника - длину и ширину.
  • Класс CalculateArea, который вычисляет площадь прямоугольника на основе его длины и ширины.
  • Класс DisplayResult, который создает экземпляры двух других классов и отображает результат вычисления площади.

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

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

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

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

Добавление новой формы будет таким же простым, как наследование от класса Shape и определение метода расчета настраиваемой области на уровне подкласса.

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

На фундаментальном уровне вы можете думать об OCP как об использовании наследования и подклассов для расширения кода. Очевидное преимущество состоит в том, что наш код будет лучше масштабироваться, и нам не придется беспокоиться об изменении устаревшей кодовой базы.

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

Барбара Лисков представила LSP в конце 80-х через определение подтипов:

«Здесь требуется что-то вроде следующего свойства подстановки: если для каждого объекта o1 типа S существует объект o2 типа T, такой, что для всех программ P, определенных в терминах T, поведение P не меняется, когда o1 равно заменяется на o2, тогда S является подтипом T ».

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

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

Случай 1: соответствие LSP

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

У родительского класса License есть два подкласса: PersonalLicense и BusinessLicense. Оба они также рассчитывают лицензионный сбор, но используют разные методы расчета.

Можно сказать, что этот вариант использования соответствует LSP, поскольку приложение Billing - помимо вызова класса License - теоретически может также использовать любой из его подклассов. Расчет может быть другим, но сам процесс расчета комиссии похож.

Случай 2: Нарушение LSP

Следующий пример - классический вариант использования для иллюстрации нарушения LSP, и я видел, как он неоднократно упоминался в нескольких ресурсах.

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

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

И вот, на мой взгляд, ключ к пониманию LSP: он больше касается поведения класса, а не его структуры или организации.

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

Интернет-провайдер: принцип разделения интерфейсов

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

Интерфейс

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

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

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

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

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

Если вы хотите узнать больше о концепции - я рекомендую взять книгу Мэтта Вейсфельда Объектно-ориентированный мыслительный процесс для более подробного ответа и эту публикацию StackOverflow для краткого обзора: https: // stackoverflow .com / questions / 2866987 / что-то-определение-интерфейса-в-объектно-ориентированном-программировании

Вернемся к принципу разделения интерфейсов.

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

Как разработчики игр, мы определяем класс Entity, который является интерфейсом, с помощью трех методов:

  • двигаться()
  • Прыжок()
  • атака()

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

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

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

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

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

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

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

Как системные архитекторы, мы отвечаем за систему A и решили интегрировать Framework B в нашу системную архитектуру. Как оказалось, Framework B зависит от базы данных C.

Для аргументации представим, что база данных C имеет некоторые функции, которые не используются платформой B и, в свою очередь, не представляют особого интереса для системы A.

Любые изменения этих функций в базе данных C могут привести к изменениям в Framework B и, как следствие, к непредвиденным и нежелательным изменениям в системе A - как системные архитекторы, мы хотели бы избежать этой ненужной зависимости.

Эта цитата Роберта Мартина является прекрасным обобщением предыдущего примера: «Урок здесь в том, что зависимость от того, что несет багаж, в котором вы не нуждаетесь, может вызвать неприятности, которых вы не ожидали».

DIP: принцип инверсии зависимостей

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

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

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

Представьте себя генеральным директором DHL (я знаю, звучит уже здорово). Как бы выглядели ваши типичные обязанности в качестве генерального директора? Бьюсь об заклад, вас в основном будут интересовать стратегические темы, такие как расширение бизнеса, выход на новые рынки, логистика и цифровизация и т. Д.

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

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

Этот пример из реальной жизни - хороший способ проиллюстрировать ключевую идею DIP:

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

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

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

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

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

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

Суть в том, что дизайн - в частности, высокоуровневые и низкоуровневые модули - должен зависеть от абстракций и интерфейсов, а не наоборот.

Итак, вместо такой архитектуры:

Мы должны стремиться к достижению этой целевой картины:

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

Заключение

Целью данной статьи было представить читателю краткий обзор принципов SOLID: их основы, теоретические основы и практическое применение.

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

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

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

Полезная литература и учебные материалы

  1. К., Мартин Роберт. Чистая архитектура (серия Роберта К. Мартина)
  2. Вайсфельд, Мэтт. Процесс объектно-ориентированного мышления (пятое издание)
  3. Https://www.baeldung.com/solid-principles
  4. Https://medium.com/backticks-tildes/the-s-o-l-i-d-principles-in-pictures-b34ce2f1e898
  5. Https://stackoverflow.com/questions/2866987/what-is-the-definition-of-interface-in-object-oriated-programming