Принципы SOLID

Столпы

SOLID — под такой меткой аббревиатурой Майкла Физерса мир знает пять основных архитектурных принципов, предложенных Робертом «Дядя Боб» Мартином.

Считается, что их использование делает дизайн вашего ПО менее жестким и хрупким, с более высоким потенциалом роста и повторного использования. Не доверять этому причин нет. Практически все критики стараются избегать прямой атаки самих принципов и больше говорят о примерах их чрезмерного использования. Во многом они правы. SOLID — как чистый спирт. Химически он безупречен, но многие из нас скорее предпочтут водку, виски или вино. Так что мы бы посоветовали «разбавлять» эти принципы в рамках разумного.

SSingle responsibility principle (SRP)

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

Здесь и далее мы будем позволять себе отклоняться от «буквы закона», но стараться сохранять ее дух. Дословно Роберт Мартин говорил об этом принципе как: «Не должно быть более одной причины для изменения класса». Нам кажется, что хотя всякая новая обязанность является причиной для изменений, не всякая причина является обязанностью. Мы можем захотеть модернизировать класс из чувства прекрасного, проведя рефакторинг. Или в рамках работы над производительностью. Таких причин может быть много, а обязанность останется одна.

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

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

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

Простым экземпляром несоблюдения данного принципа является класс System.Object в Microsoft .NET Framework. Сигнатуры его публичных методов выглядят так:

public virtual bool Equals(object obj); public virtual int GetHashCode(); public virtual string ToString(); public extern Type GetType();

Из чего следует, что он берет на себя обязанности сравнения объектов, вычисления их хеш-суммы и конвертации в строку. Тенденция плохая, но поскольку объект легковесный, мы не замечаем серьезных неудобств. Другое дело, когда доведется столкнуться с чем-то более крупным. Скажем, классом System.Data.DataTable. Его основная обязанность — управление реляционными таблицами в памяти. Но когда смотришь на добрых семь с лишним тысяч строк одного этого класса, которые, помимо прочего, берут на себя обязанности по копированию таблиц, клонированию, слиянию, (де)сериализации, работе со схемами и много другое, то мечтаешь никогда в жизни не столкнуться с задачей по его модернизации.

Видимо, того же мнения придерживаются и инженеры Microsoft. За множество последних версий такие новшества как обобщенные классы, лямбда-выражения, расширяющие методы вкупе с LINQ привели к пересмотру большинства подсистем. Казалось бы, DataTable мог вобрать фактически все перечисленное. Оперировать типизированными кортежами на базе обобщенных типов, использовать лямбда-выражения для описания внешних ключей, индексов, выражений запросов. Не ограничиваться примитивной конвертацией в итератор для LINQ. Но практически с первых своих версий его дизайн не изменился никак.

Помимо того, что несоблюдение принципа SRP завело DataTable в тупик, надо с сожалением отметить, что все эти семь с лишним тысяч строк кода не несут никакой пользы всей остальной системе. Казалось бы, реализованная обязанность по сохранению табличных данных в XML-файл может быть использована и для классов, реализующих интерфейс IDataReader. Ведь это те же самые табличные данные, получаемые из реляционной СУБД. Но увы, данная функциональность является неотъемлемой частью класса DataTable и использоваться отдельно от него не может.

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

OOpen/closed principle (OCP)

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

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

Возьмем в качестве примера все тот же System.Object, упомянутый в принципе SRP. Он содержит метод GetHashCode, вычисляющий хеш-сумму некоторого объекта. И им же пользуется класс System.Collections.Hashtable для организации хеш-таблицы из объектов.

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

Но System.Object создан в Microsoft, а значит он закрыт для наших изменений по факту. И не имей мы способов расширения класса Hashtable, его пришлось бы переписывать для решения нашей задачи...

К счастью, нам достаточно реализовать нашу хеш-функцию с интерфейсом System.Collections.IHashCodeProvider и передать объект с ней в качестве аргумента конструктору Hashtable.

public class MyCustomHashProvider : System.Collections.IHashCodeProvider { int System.Collections.IHashCodeProvider.GetHashCode(object obj) { ...вычисление новой хеш-функции... } } ... var provider = new MyCustomHashProvider(); Hashtable hashtable = new Hashtable(provider, ...); ...

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

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

LLiskov substitution principle (LSP)

Принцип подстановки Лисков. Барбара Лисков и Жанетта Винг сформулировали этот принцип так: «Пусть q(x) является свойством, верным для объектов x типа T. Тогда q(y) также должно быть верным для объектов y типа S, где S является подтипом типа T».

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

Предположим у нас существует такой метод, который безупречно экспортировал данные в любые списки с интерфейсом System.Collections.IList.

public void ExportToList(IList list) { foreach (var row in this.Rows) { list.Add(row); } }

При его реализации мы полагались на документацию, которая убеждала, что метод IList.Add добавляет заданный объект в список, что прекрасно работало для таких классов как System.Collections.ArrayList, System.Collections.Generic.List и многих других. Но в команде Microsoft решили, что класс System.Array тоже должен реализовывать интерфейс IList. Причины такого решения не понятны, поскольку между массивами и списками есть существенные отличия. Одно из них — невозможность добавить элемент. На что ими было найдено «оригинальное» решение — выбрасывать исключение NotSupportedException при попытке вызвать IList.Add. Как результат, наш экспорт в объекты Array прекрасно компилируется, но вызывает ошибку во время исполнения.

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

public abstract class Bird { public virtual double Altitude { get; set; } public virtual void Fly(double altitude) { ... this.Altitude = altitude; } }

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

Отдельно стоит сказать о причинах отступления от интерпретации Мартина, которое звучит как «Функции, которые используют указатели или ссылки на базовые классы должны уметь использовать и объекты унаследованных классов, даже не подозревая об их существовании». Нам эта формулировка кажется не совсем корректной. Мелочами вроде тех, что речь идет о ссылках на классы, а не объекты и область применения сужена только до классов, можно было бы пренебречь. Но формулировка такова, что обязанность соблюдать принцип Лисков возложена не на те объекты. Функции, как в примере с методом ExportToList выше, полагаются на документированное поведение. Они не могут проконтролировать все реализации IList на планете. Поэтому обязанность не должна возлагаться на потребителей.

IInterface segregation principle (ISP)

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

Возьмем в качестве примера классы уже знакомые нам по принципу OCP — IHashProvider и Hashtable. Примечательно то, что в последних версиях .NET Framework, Microsoft пометила интерфейс IHashProvider как устаревший, предлагая вместо него использовать интерфейс System.Collections.IEqualityComparer, который выглядит как:

public interface IEqualityComparer { bool Equals(object x, object y); int GetHashCode(object obj); }

Теперь именно его надо передавать в конструктор класса Hashtable. Хотя сам Hashtable использует только метод GetHashCode. Теперь, если какие-то изменения потребуется внести в метод Equals, то класс Hashtable столкнется с проблемами, хотя он этот метод вообще не использует. Если бы мы разделили этот интерфейс на два других, также существующих в .NET Framework:

public interface IEqualityComparer : IHashCodeProvider, IComparer { ... }

А в класс Hashtable передавали только IHashCodeProvider, то такой проблемы бы не было.

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

DDependency inversion principle (DIP)

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

Представьте себе на минуту мир без вилок и розеток, компьютеры без разъемов PCI, SATA, USB и прочих. Купили новый графический ускоритель? Аккуратно отпаяйте старый и, внимательно прочитав спецификацию к новому, соедините его с нужными точками на материнской плате.

Принцип DIP предлагает избавиться от необходимости материнской плате знать что-либо о конкретных устройствах. Она просто предоставляет ранее упомянутые разъемы, тем самым декларируя свою готовность работать с одноименными интерфейсами. Обязанность реализовывать эти интерфейсы ложится на конечные детали вашего компьютера. Таким образом мы разворачиваем зависимость в обратном направлении. Теперь материнская плата ничего не должна знать о вашей клавиатуре. Но она знает о такой «абстракции» как USB-интерфейс. Сама клавиатура знает как реализовывать USB-интерфейс и зависит от его поддержки материнской платой.

Пример инверсии зависимости мы уже видели ранее, при изучении принципа OCP. Класс Hashtable — это была наша материнская плата. Мы не хотели вводить прямую зависимость от разных реализаций вычисления хеш-функции и использовали абстракцию IHashCodeProvider. Это — аналог USB-интерфейса. В роли клавиатуры выступил наш класс MyCustomHashProvider, который реализовал эту абстракцию. И как провод USB-клавиатуры вставляется в USB-разъем, мы «вставили» наш провайдер в конструктор Hashtable.