Повышаем свою стоимость: SOLID

Повышаем свою стоимость: SOLID

Больше вкусностей найдешь на моем канале - https://t.me/emotional_robot


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

SOLID


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

SOLID - это акроним, введенный Майклом Физерсом (автором книги "Эффективная работа с унаследованным кодом") для первых пяти принципов, названных Робертом Мартином в начале 2000-х. Расшифровывается следующим образом:

  • S: Single Responsibility Principle (Принцип единственной ответственности).
  • O: Open-Closed Principle (Принцип открытости-закрытости).
  • L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков).
  • I: Interface Segregation Principle (Принцип разделения интерфейса).
  • D: Dependency Inversion Principle (Принцип инверсии зависимостей).

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

Single Responsibility Principle


Как вы помните, в ООП главным на раёне является класс. С помощью классов мы описываем сущности реального мира, а также методы для работы с ними. Давайте возьмем из статьи об ООП упомянутый там класс бомжика и немного модернизируем его:

class Homeless {

private _nickname: String; // "кликуха" с типом данных "строка"

private _age: Number; // возраст с типом данных "число"

constructor(nickname: String, age: Number) { // конструктор класса

this._nickname = nickname; // инициализировать nickname

this._age = age; // инициализировать age

}

public getNickname() { return this.nickname; } // получить nickname

public getAge() { return this.age; } // получить age

public save() { /* код сохранения инфы о бомжике, например, в БД */ }

}

Во-первых, я воткнул тут кое-что для повторения такого понятия, как "инкапсуляция", а именно, модификаторы доступа "public" и "private". Первый открывает доступ к методам класса "getNickname" и "getAge" для всех, кто будет работать с экземплярами класса "Homeless", второй же, наоборот, закрывает доступ к полям "_nickname" и "_age" для всех внешних пользователей. К private частям доступ есть ТОЛЬКО ВНУТРИ КЛАССА. Таким образом, никто не сможет изменить значения полей извне. Но благодаря открытым методам (public), внешние потребители смогут хотя бы прочитать значения этих полей. А вот задать значения приватным полям мы можем только в конструкторе класса, то бишь, только во время создания объекта с помощью "new".

Во-вторых, такая структура класса нарушает принцип единственной ответственности, который звучит так:

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

Идея в том, что нам не стоит пихать кучу функционала в один класс, иначе мы сильно завязываемся на него, и все, кто будут использовать ОДИН класс для РАЗНЫХ задач, находятся в шатком положении. Любые изменения в таком классе затронут по принципу домино другие части, а это лишняя трата времени и ресурсов на переписывание кода.

Что не так с нашим бомжиком, помимо того, что он хлещет паленый портвейн и хочет самовыпилиться? Он решает две разные задачи: занимается работой с хранилищем данных в методе "save" и манипулирует свойствами объекта в конструкторе и в методах "getNickname" и "getAge". Правильнее в данном случае будет вынести работу с хранилищем данных в отдельный класс, например:

class HomelessDB {

public save(h: Homeless) { /* код сохранения инфы о бомжике в БД */ }

public get(h: Homeless) { /* код получения инфы о бомжике из БД */ }

}

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

Open-Closed Principle


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

Представим, что у нас есть магазин. Мы даём клиентам скидку в 20%, используя такой класс:

class Discount {

public getDiscount() {

return this.price * 0.2;

}

}

Теперь решено разделить клиентов на две группы. Любимым (fav) клиентам даётся скидка в 20%, а VIP-клиентам (vip) — удвоенная скидка, то есть — 40%. Для того, чтобы реализовать эту логику, было решено модифицировать класс следующим образом:

class Discount {

public getDiscount() {

if (this.customer == 'fav') {

return this.price * 0.2;

}

if (this.customer == 'vip') {

return this.price * 0.4;

}

}

}

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

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

class VIPDiscount: Discount {

public getDiscount() {

return super.getDiscount() * 2;

}

}

В этом коде используется второй принцип ООП - наследование. Мы создаем класс "VIPDiscount", который наследуется от класса "Discount", и в методе "getDiscount" вызывается родительский метод через служебное слово "super".

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

class GodDiscount: VIPDiscount {

public getDiscount() {

return super.getDiscount() * 2;

}

}

Liskov Substitution Principle


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

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

Представим, что нам нужно вернуть количество зубов во рту бомжика в зависимости от количества проведенных лет на улице. Пусть у нас будет junior (до 1 года), middle (1 - 3 года) и senior (4 - 6 лет). Тогда определятор зубов будет такой:

function HomelessToothCount(h: Homeless) {

if (typeof h == Junior) return JuniorToothCount(h);

if (typeof h == Middle) return MiddleToothCount(h);

if (typeof h == Senior) return SeniorToothCount(h);

}

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

function HomelessToothCount(h: Homeless) {

...

if (typeof h == TeamLead) return TeamLeadToothCount(h);

}

Чтобы не страдать херней, мы реализуем сначала базовый класс бомжика:

class Homeless {

getToothCount() { ... }

}

Далее мы создаем наследников. Например:

class JuniorHomeless: Homeless {

getToothCount() { ... }

}

В наследнике мы переопределяем родительский метод, записывая в него код, актуальный именно для "JuniorHomeless".

Теперь функции "HomelessToothCount" не нужно знать о том, объект какого именно подкласса класса "Homeless" она обрабатывает для того, чтобы узнать сведения о количестве зубов у бомжика, представленного этим объектом. Функция просто вызывает метод "getToothCount" класса "Homeless", так как подклассы этого класса должны реализовывать этот метод для того, чтобы их можно было бы использовать вместо него, не нарушая правильность работы программы:

function HomelessToothCount(h: Homeless) {

return h.getToothCount();

}

Interface Segregation Principle


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

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

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

interface IMicrowave {

heatUpMeal();

destroyAllWorld();

}

Тогда два абсолютно разных класса "Microwave" и "Terminator" при реализации этого интерфейса должны реализовать методы, которые им вообще не всрались:

class Microwave implements IMicrowave {

heatUpMeal() { ... }

destroyAllWorld() { ... }

}

class Terminator implements IMicrowave {

heatUpMeal() { ... }

destroyAllWorld() { ... }

}

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

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

interface IMicrowave {

heatUpMeal();

}

interface ITerminator {

destroyAllWorld();

}

Dependency Inversion Principle


Объектом зависимости должна быть абстракция, а не что-то конкретное.
  1. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

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

Например, у нас есть класс, который использует другой класс (например, внешнюю библиотеку для запросов):

class HttpService { ... }

class Main {

constructor(private httpService: HttpService) { ... }

public get(url: String, options: any) {

this.httpService.request(url, options);

}

}

Класс "Main" сильно связан с классом "HttpService". По хорошему, он вообще не должен знать, как именно делаются запросы (вдруг мы захотим воткнуть WebSockets, или вообще замокать этот сервис для тестов). Поэтому мы введем интерфейс "Connection":

interface Connection {

request(url: String, options: any);

}

Интерфейс "Connection" содержит описание метода "request", и мы передаём классу "Main" аргумент типа "Connection":

class Main {

constructor(private conn: Connection) { ... }

public get(url: String, options: any) {

this.conn.request(url, options);

}

}

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

Но как класс "Main" может использовать интерфейс напрямую, это ж абстракция? Никак, поэтому нам нужно реализовать интерфейс "Connection" нужным классом:

class HttpService implements Connection {

request(url: String, options: any) { ... }

}

Единственное, что здесь не получится показать - это как именно программа поймет при компиляции, где расположена реализация используемого интерфейса. Для разных языков программирования используются разные инструменты (inversifyJS для TypeScript, autofac для C# и т.д.). Поэтому, когда возьметесь за реализацию принципа инверсии зависимостей на выбранном ЯПе, сразу ищите нужную библиотеку. Копайте в сторону IoC (Inversion of Control) Container и Dependency Injection (DI).

Итого

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

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



Report Page