В этой статье автор высказывает личное мнение, сформулированное на основе его собственного опыта и вкуса. Поэтому просьба не обижаться, если вы с ним не согласны. Конечно, оно может быть ошибочным – но это и стоит обсудить.
Что такое SOLID ?
SOLID – это набор принципов объектно-ориентированного программирования, которые задуманы, чтобы помочь вам писать отличный софт. Под «отличный» здесь понимается: удобный в поддержке, без багов, быстрый… SOLID – это аббревиатура, каждая буква в которой соответствует одному принципу. Как подсказывает мой опыт, нужно не следовать этим принципам слепо, а обдумывать их, понимать и определять, где они могут дать добавленную ценность. В данной статье я разберу каждый из этих принципов, объясню их и выскажусь, какую добавленную ценность я вижу в каждом из них.
SRP
SRP означает «Принцип единственной ответственности». Типичное объяснение этого принципа – «должна существовать всего одна причина, по которой может быть изменен данный класс». На первый взгляд, приятная идея, но… если в классе 2000 строк, то что в данном случае означает «всего одна причина для изменения»? Рассмотрим очень простой класс:
public class Foo{
public int GetResult(){
return 1;
}
}
Можно представить немало причин, по которым он изменится:
-
При вычислении результата мы хотим принимать во внимание дополнительные сущности (добавить параметры)
-
Мы хотим изменить результат, его значение и даже тип
-
Мы хотим метод GetResults, который возвращал бы историю результатов
А это класс с 1 методом и всего 1 строкой кода. В выражении «принцип единственной ответственности» меня смущает именно слово «ответственность», так как его определение – по-настоящему субъективное и зыбкое. У меня может быть единственный класс, в котором 100k строк кода, ответственность которого – «обеспечивать онлайн-бронирование номера в отеле». С другой стороны, может быть класс из 2 методов, ответственность которого «возвращать цену» и «сохранять цену» — но, отвечая за две эти операции, он не будет удовлетворять принципу SRP.
Определение этого принципа настолько зыбкое, что его даже принципом назвать нельзя. Его практически невозможно кому-либо объяснить, всякий раз вас спросят: «а что вы понимаете под ‘ответственностью’ или ‘причиной для изменения’»? Если довести следование этому принципу до абсурда, то можно получить тысячу очень маленьких классов, а по моему опыту – поддерживать их куда сложнее, чем крупные.
Что нужно взять от SRP?
SRP означает, что класс не должен делать слишком много разных вещей. Вот несколько примеров:
-
Управлять состоянием Booking (бронирования) и обращаться с базовым API электронной почты => добавляем фасад, который позволит этому классу абстрагировать отправку электронных писем. А еще лучше пользоваться событиями, чтобы он даже не знал об отправке каких-то электронных писем.
-
Создавать HTML и SQL-запросы => создаем отдельный класс для HTML и отдельный класс для SQL-запросов, а также еще один класс, который обеспечит их качественное взаимодействие (своего рода MVC)
-
Аутентификация и авторизация => создать по классу на каждую из этих ролей
Также можно пользоваться следующими метриками, которые помогут обнаружить места, где вы, возможно, нарушаете SRP:
-
Подсчет строк кода
-
Подсчет методов
-
Подсчет полей …
OCP
OCP означает «принцип открытости/закрытости». Обычно его объясняют так: «класс должен быть закрыт для модификации, но открыт для расширения». Это означает, что поведение класса следует менять не на уровне его исходного кода, а путем расширения (например, еще раз реализовать все тот же интерфейс). Во-первых, это в некотором роде противоречит SRP, поскольку теперь нет ни единой причины, по которой можно было бы изменить класс. Кроме того, он абсолютно бесполезен. Возьмем, к примеру, этот класс.
public interface IFoo{
int GetResult();
}
public class Foo : IFoo{
public virtual int GetResult(){
return 1;
}
}
По OCP, если бы я хотел изменить результат (например, разделить его на 2), то должен был бы реализовать паттерн «декоратор» примерно так:
public class FooDivideBy2 : IFoo{
private IFoo decorated;
public FooDivideBy2(IFoo decorated){
this.decorated = decorated;
}
public virtual int GetResult(){
return decorated.GetResult() / 2 ;
}
}
Вместо 1 класса с 5 строками кода, у меня теперь 2 класса, 1 интерфейс и 17 строк кода. Количество строк кода увеличилось более чем втрое и, если применить этот принцип к более крупной базе кода, где больше интерфейсов, больше реализации, то по-настоящему важно учитывать, как такие изменения скажутся на поддержке кода.
Еще одна деталь: допустим, теперь я хочу возвращать 2 вместо 1. Зачем же мне создавать новый класс, если я могу изменить уже имеющийся?
Что следует оставить от OCP?
Когда вы создаете библиотеку (публичную или для внутреннего использования в большой корпорации), то ваши классы по определению закрыты. Пользователь не может (запросто) изменить их содержимое. Поэтому данный момент нужно продумать и оставить точку для расширения, чтобы пользователи могли кастомизировать вашу библиотеку. В иных ситуациях – забудьте об этом :)
LSP
LSP означает «Принцип подстановки Лисков». Обычно его объясняют так: объект можно заменить любым экземпляром его подтипа, и корректность программы от этого не изменится». Это означает, что, продолжая вышеприведенный пример, мы могли бы использовать FooDivideBy2 или Foo всякий раз, когда нам требуется IFoo – и программа при этом все равно останется корректной. В данном случае, как я понимаю, при использовании интерфейса вас не должна заботить реализация, которая будет при этом использоваться.
Этот принцип действительно красив, и, если идти против него, то в коде появятся «дырявые абстракции». Именно такой случай произошел с интерфейсом IQueryable в .NET: используя его, вы должны знать, с какой именно реализацией работаете, поскольку не все они ведут себя одинаково.
С этим принципом я вижу 2 проблемы:
-
Некоторые интерфейсы/абстрактные классы используются для предоставления общей входной точки, и в определении интерфейса ничего не сказано о том, что он должен делать, как в случае IObservable
-
Не все реализации интерфейсов совместно учитывают одни и те же пограничные случаи и ошибки. Рассмотрим, например, этот интерфейс:
public class IDataSave{
void Save(Data data);
}
Если я реализую его, сохраняя данные в файле json, то мне придется выполнять некоторые специфические вещи в классе Data (например, поставить там публичные геттеры и сеттеры или непараметризованный конструктор) – либо он откажет, выбросив исключение IOException, если возникнет некоторая проблема в файловой системе, или некоторые из полей окажутся рекурсивными… Все это детали, специфичные для реализации, которые, однако, может потребоваться знать пользователю.
Что следует оставить от LSP? Следует стремиться уважать LSP: пользователь интерфейса должен игнорировать детали реализации и использовать интерфейс единообразно при любой реализации. Но необходимо знать, что в некоторых случаях это сделать невозможно, а в других – слишком дорого.
ISP
ISP означает «Принцип разделения интерфейсов». Он гласит, что лучше иметь много специализированных интерфейсов, чем один универсальный.
Я не вижу каких-либо проблем с этим интерфейсом, и скажу, что, если у вас получится такой интерфейс:
public interface IBookingManagement{
void SaveBooking(Booking aBooking);
void DeleteBooking(int aBookingId);
void ValidateBooking(int aBookingId);
void DenyBooking(int aBookingId);
}
то окажется, что никакой интерфейс вам вообще не требовался! Этот интерфейс никак не повлиял ни на степень связанности кода, ни на его качество, но читать код с ним стало сложнее. Поэтому, если вы хотите использовать те или иные интерфейсы, убедитесь, что они дают добавочную ценность. Когда это происходит? Я вижу 2 случая:
-
У вас множество реализаций данного интерфейса, и выбранная реализация будет меняться во время выполнения
-
Для тестирования. Все вещи, которых я не контролирую, я всегда ставлю за интерфейс, чтобы можно было сымитировать их во время тестирования как мок или фейк: это часы, случайные числа, внешние API, зависимости операционной системы…
Что следует оставить от ISP?
Почти все, если у одного интерфейса много методов, и не все они используются в одинаковом контексте. Тогда вам может понадобиться интерфейс. Либо интерфейс может делать слишком много дел сразу, и тогда вам следует его разделить.
DIP
DIP означает «Принцип инверсии зависимостей». Идея его в том, что между реализациями не должно быть зависимостей; они должны быть между реализацией и уровнем абстрагирования (интерфейсом).
Как было сказано выше, интерфейс ни на йоту не меняет ситуацию со связанностью кода. 2 класса можно отвязать друг от друга, но при этом все равно сохранить между ними зависимость. При этом вы должны только убедиться, что они игнорируют внутреннее поведение, и не менять порядок их вызова со стороны друг друга, исходя из этих знаний. Есть заблуждение, будто, применив рефакторинг с «введением интерфейса», можно уменьшить степень связанности между классами. Рассмотрим пример:
public class Foo{
private IBar _bar;// каким-то образом внедрили
public void DoStuff(){
_bar.SaveData("a;b;c;d")
}
}
public interface IBar{
void SaveData(string str);
}
public class Bar : IBar{
public void SaveData(string str){
if(str?.Length > 30){
throw new InvalidOperationException(str);
}
var data = str.Split(';');
// вставка данных в DB
}
}
Здесь введение интерфейса ничего не меняет в степени связности между Foo и Bar: если мы изменим ожидаемый строковый формат в Bar, то нам придется внести изменения и в Foo. В данном случае мы стремимся добиться снижения связанности кода, продумав интерфейс для нашего класса (под «интерфейсом» я понимаю всего его публичные методы, а не тот «интерфейс» C#, который он реализует).
Что следует оставить от DIP?
Проектируйте ваши модули так, чтобы они не знали, как работают их зависимости, сверх того, что указано в сигнатуре публичного метода и в документации.
В каких случаях стоит подкрепляться SOLID
Расскажу вам кое-что из собственного опыта – думаю, подобная история случалась не только со мной. Я был техлидом/архитектором/ментором в команде (на тот момент – менее 20 разработчиков), писавшей небольшой текстовый редактор. Именно тогда я начал читать о SOLID. И я взял эту практику на вооружение, ожидая найти в ней решение всех наших проблем с поддержкой кода (напоминавшего спагетти/лазанью/кальцоне), с бесчисленными багами, т.д. Итак, я пытался привить эти принципы команде и сам придерживаться их при работе. Но ничего от этого не изменилось.
-
Коллегам по команде все равно было сложно читать мой код
-
Коллеги по команде ничего не поняли в этих правилах и даже не пытались их соблюдать.
На этом опыте я усвоил несколько вещей:
-
Обучайте команду лишь прагматичным вещам. Догма – хороший способ получить проблемы, но никогда никаких проблем не решает.
-
Если вы находите какие-либо правила, которые кажутся вам совершенными, попытайтесь определить, до какой степени их использовать. Составьте о них собственное мнение.
-
Не думайте, что разослать команде ссылку на статью о «хороших практиках» - это действенный способ изменить что-либо
-
Оцените, как пойдет миграция, спланируйте ее и проводите миграцию вплоть до завершения ее на 100%. Нет, она не сработает, если вы станете применять ее «только к новым разработкам». Если у вас достаточно большая база кода, то в течение 5 лет 90% кода в ней будет лежать нетронутым – но этот код все равно нуждается в поддержке и оптимизации.
-
Пытаясь привить что-либо своей команде, найдите для людей конкретный пример и предложите подробные правила, которые позволят ему следовать.
Отдельные правила, которые предпочитаю я
Теперь расскажу вам о некоторых правилах, которые сам предпочитаю применять при работе, поскольку они не усложняют ситуацию, их легче понимать и воплощать на практике:
Закон Деметры
Закон Деметры объяснить довольно легко: в методе Do из класса Foo можно делать лишь следующие вещи:
-
Изменять состояние Foo (приватные поля)
-
Вызывать метод с любым параметром Do
-
Вызывать метод с любой переменной, инстанцированной внутри Do
-
Вызывать метод с любыми полями Foo
Применить подобное в C# довольно легко: перестаньте делать сеттеры свойств публичными!
Вместо:
public class Foo{
public void Do(Bar b){
b.Enabled = true;
}
}
public class Bar{
public bool Enabled{get;set;}
}
Сделайте:
public class Foo{
public void Do(Bar b){
b.Enable();
}
}
public class Bar{
// это могло бы быть полностью приватным
public bool Enabled{get; private set;}
public void Enable(){
this.Enabled = true;
}
}
Именно так у вас будет получаться красивое ООП: каждый объект отвечает за свое состояние. Класс не только содержит данные, но и привносит поведения. И вы увидите, что такой код проще в поддержке, поскольку все в нем выражается более явно: в первом примере глагол “Enable” так и не был написан. Здесь рассмотрен простой пример, но, если все до одного состояния выражать глаголами, то код получится яснее и очевиднее.
YAGNI
YAGNI означает «вам это не понадобится». И это означает, что вы должны делать только ту работу, которую вам было сказано сделать. Не готовьте почву для воображаемых изменений, которые могут произойти когда-нибудь в будущем, и даже не программируйте скрытых возможностей, которые позволят вам потом почувствовать себя героем и заявить на планерке: «а это уже сделано».
Рассмотрим пример: вам поручено создать CRUD-приложение, которое будет сохранять данные в MSSQL. Что вы делаете: создаете 5 проектов в Visual Studio. Один для UI, один для бизнес-логики, один для DAO и 2 абстракции, которые прокладываете между слоями. Настраивать этот проект, разрешать зависимости, отображать объекты с уровня на уровень… на все это потребуется 40% трудозатрат по проекту. Эта работа могла бы быть ценна в светлом будущем, действительно, но платят вам не за нее, и вы даже не уверены, что она когда-нибудь пригодится.
Все равно как решить купить семейный автомобиль, когда вам слегка за 20, просто потому, что вы уже планируете детей. Что может произойти? Возможно, когда вам стукнет 30, у вас уже будут дети, но ваша машина к тому времени износится и сломается, так что придется покупать новую.
Можно работать с прицелом на будущее, только если это не удорожает ваш проект. Будущего. Никто. Не. Знает.
Делайте неявное явным
Это правило я люблю твердить про себя, когда пишу код. Допустим, у вас есть два фрагмента кода:
public class Foo{
// вернуть -1, если значение недействительное
public int Validate(int id){
if(id > 10000)
return -1;
return 0;
}
Тот, кто делает вызов, должен прочитать документацию, а затем сравнить результат с -1, проверив таким образом, является ли значение корректным. Это несложно, но я все равно предпочел бы написать вот так:
public class Foo{
public bool IsValid(int id){
if(id > 10000)
return false;
return true;
}
}
Никакой документации нам не требуется, поведение явное, мы интегрируем этот метод в код гораздо быстрее, чем предыдущий.
Действительно, документация показательна. Если по методу требуется почитать документацию, это может означать, что метод не слишком явный. Если вы не можете сделать его более явным, так, чтобы удобочитаемость не пострадала (например, ценой придумывания 200-символьного имени для этого метода), то лучше добавьте комментарий.
KISS
KISS означает: «Пусть будет тупо просто». Это не правило, а очень субъективный принцип, поэтому легко ввязаться с коллегой в спор о том, прост данный код или нет. Но, на мой взгляд, KISS также означает «Не гонись за правилами, гонись за ценностью». Давайте скопируем пример из предыдущего раздела:
public class Foo{
private IBar _bar;// каким-то образом внедрили
public void DoStuff(){
_bar.SaveData("a;b;c;d")
}
}
public interface IBar{
void SaveData(string str);
}
public class Bar : IBar{
public void SaveData(string str){
if(str?.Length > 30){
throw new InvalidOperationException(str);
}
var data = str.Split(';');
// вставить данные в DB
}
}
В чем ценность IBar, если у меня всего одна реализация? А стоит он дорого:
-
Всякий раз, переходя между Foo и Bar, я окажусь в интерфейсе
-
Всякий раз, когда мне захочется добавить параметр к SaveData, мне придется изменить класс и интерфейс
-
Мне придется настроить внедрение зависимостей, чтобы сделать инъекцию Bar …
Если добавочной стоимости не появляется, удаляйте избыточное. Если это просто, вставить данные прямо в ваш контроллер – СДЕЛАЙТЕ ЭТО, забудьте о SOLID, игнорируйте все, действуйте так, как проще.
То же касается многоуровневой архитектуры. Для каждого уровня требуется оценить издержки его сложности, прикинув, какова будет их добавочная ценность. Не добавляйте уровень просто потому, что кто-то сказал вам его добавить.
Прекратите переименовывать Shit (SRS, хочу запатентовать)
Это мое собственное правило: даже если с самого начала что-то называлось shit, то либо сохраните это название, либо переименуйте повсюду. По-настоящему сложно прослеживать код, если то, что называлось “product model”, вдруг становится “product type” на другом уровне, а затем превращается в “item kind” на новом уровне и, в конце концов, принимает имя “buyable thing category”. У каждого разработчика – свое мнение об именовании, не думайте, что вам виднее. Придерживайтесь выбора, сделанного коллегой, либо придите к выбору, который устроит вас обоих – и все унифицируйте.
Заключение
Тяжесть работы программиста в том, какую сложность при этой работе приходится осмысливать. Иногда кажется, что несколько правил помогут решить наши проблемы как по волшебству, но так никогда не бывало и не будет.
Автор:
ph_piter