Как стать автором
Обновить
VK
Технологии, которые объединяют

«Божественный» код (GOD'S code)

Время на прочтение 7 мин
Количество просмотров 24K


«Божественный» код — громкий термин, который может показаться желтым заголовком, но всё же именно о таком коде будет идти речь: из каких частей он состоит и как его писать. Это история о моих стараниях сделать так, чтобы задачи не возвращались с code review с пометкой: «Всё хе*ня — переделать».

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

GOD’S code — акроним из акронимов — код, написанный в соответствии с принципами Grasp, Object calisthenics, Demeter’s law и Solid. Кому-то они знакомы все, кто-то встречался лишь с некоторыми, но мы рассмотрим каждую составляющую акронима. Я не ставлю своей целью детально погрузиться в каждую группу правил, так как на просторах интернета они уже много раз освещались. Вместо этого предлагаю выжимку из собственного опыта.

GRASP


Девять шаблонов для назначения ответственностей классам и объектам. Для удобства запоминания я разделяю их на две подгруппы:

  1. В первую подгруппу можно выделить правила, позволяющие писать атомарные модули, которые хорошо тестируются и изменяются. Эти правила не столько про ответственность, а, скажем так, про свойства модулей: слабая связанность, сильное сцепление, полиморфизм, устойчивость к изменениям. Для себя я перекрываю эти правила SOLID’ом, об этом подробнее — в соответствующей части.
  2. Вторая подгруппа — это уже более чёткие шаблоны, которые говорят нам о том, кто создаёт объекты («создатель» — собирательное название для фабричных паттернов), каким образом понизить связанность между модулями (используя паттерны «контроллер» и «посредник»), кому делегировать отдельные обязанности (информационный эксперт) и что делать, если я люблю DDD и одновременно low coupling (чистая выдумка).

Подробнее можно почитать здесь.

Объектная калистеника


Набор правил оформления кода, очень похожих на свод законов codeStyle. Их также девять. Я расскажу о трёх, которые стараюсь соблюдать в повседневной работе (немного видоизменённых), об остальных можно почитать в первоисточнике.

  1. Длина метода — не более 15 LOC, количество методов в классе — не более 15, количество классов в одном пространстве имён — не более 15. Суть в том, что длинные простыни кода очень сложно читать и понимать. К тому же, длинные классы и методы являются сигналом о нарушении SRP (об этом ниже).
  2. Максимум один уровень вложенности на метод.

    public function processItems(array items)
    {
         // 0
         foreach (items as item) {
              // 1
              for (i = 0; i < 5; i++) {
                   // 2
                   … process item 5 times …
              }
         }
    }

    В этом примере пятикратную обработку item уместно вынести в отдельный метод.

    public function processItems(array items)
    {
         // 0
         foreach (items as item) {
              // 1
              this.processItem(item);
         }
    }
     
    public function processItem(Item item)
    {
         // 0
         for (i = 0; i < 5; i++) {
              // 1
              … process item 5 times …
         }
    }

    Опять-таки, цель — иметь возможность понять, что делает метод, кинув на него один взгляд и не компилируя код в голове.
  3. Не использовать else там, где он не нужен.

    public function processSomeDto(SomeDtoClass dto)
    {
         if (predicat) {
              throw new Exception(‘predicat is failed’);
         } else {
              return this.dtoProcessor.process(dto);
         }
    }

    И брюки превращаются:

    public function processSomeDto(SomeDtoClass dto)
    {
         if (predicat) {
              throw new Exception(‘predicat is failed’);
         }
     
         return this.dtoProcessor.process(dto);
    }

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

Закон Деметры


Жёсткая версия слабой связанности из GRASP’a. Накладывает ограничения на то, с кем может взаимодействовать текущий модуль.



Возьмём три объекта: А содержит B, В содержит С. Рассмотрим объект А. Метод А объекта А может иметь доступ только к методам и свойствам:

  1. Самого объекта А.
  2. Объекта, который передан в качестве параметра методу А.
  3. Объекта В.
  4. Объектов, которые непосредственно созданы внутри метода А.

И всё. Проще говоря, объект А взаимодействует только с непосредственными соседями. Взаимодействие типа this.objectB.objectC.getSomeStuff() является нарушением Закона Деметры, потому что из объекта А мы обращаемся к методу объекта С, который не является непосредственным соседом объекта А.

Есть пара интересных следствий. Во-первых, использование фабрик приводит к нарушению Закона Деметры. Посудите сами:

public function methodA()
{
     spawnedObject = this.factory.spawn();
     spawnedObject.performSomeStuff();
}

Равносильно:

public function methodA()
{
     this.factory.spawn().performSomeStuff();
}

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

public function methodA()
{
     this.processor.process(this.factory.spawn());
}

Второе интересное следствие: DTO/Entity. Многим довольно часто приходится собирать или использовать контейнеры данных.

public function methodA(SomeDtoClass dto)
{
     dto.getAddress().getCity();
}

Если следовать букве Закона Деметры, это будет нарушением, так как мы обратились к соседу соседа. Но на практике с контейнером данных допускают послабление, считая его как единое целое, и, соответственно, обращение к методу getCity DTO Address в нашем случае считается обращением к части контейнера dto.

Принципы SOLID


SRP, OCP, LSP, ISP, DIP — я лишь вкратце коснусь их, потому как в Википедии и на Хабре они описаны довольно подробно.

SRP — принцип единственной ответственности. Один программный модуль — одна задача, одна причина для изменения. Перекликается с High Cohesion из GRASP’a.

Пример: у нас есть контроллер, задача которого — быть связующим звеном между бизнес-логикой и представлением (MVC). Если мы засунем какую-либо часть бизнес-логики в контроллер, то автоматически нарушим SRP.

public function indexAction(RequestInterface request): ResponseInterface
{
     requestDto = this.requestTransformer.transform(request);
     responseDto = this.requestProcessor.process(requestDto);
 
     return this.responseTransformer.transform(responseDto);
}

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

OCP — принцип открытости-закрытости. Пишем код так, чтобы не приходилось его изменять, а лишь расширять.

Если в выполнении кода нужно сделать какую-либо развилку, то обычно используется if/switch. А если нужно добавить ещё одну ветку, то текущий код изменяется. И это нехорошо. Работает — не трогай. Добавляй новое и тестируй новое, а старое пусть себе работает.

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

final сlass Resolver implements ResolverInterface
{
     private mapping;
 
     public function Resolver(array mapping)
     {
          this.mapping = mapping;
     }
 
     public function resolve(Item item)
     {
          return this.mapping[item.getType()].perform(item);
     }
}

Не забудьте про обработку ошибок, её я опустил. Дам совет: если принять за правило, что все классы могут быть либо final, либо abstract, то следовать этому принципу будет гораздо проще.

LSP — принцип подстановки Барбары Лисков. Корнями уходит в контрактное программирование и говорит о том, как нам правильно строить наследование.

Постулаты таковы:

  1. Производные классы не должны усиливать предусловия (не должны требовать большего от своих клиентов).
  2. Производные классы не должны ослаблять постусловия (должны гарантировать, как минимум, то же, что и базовый класс).
  3. Производные классы не должны нарушать инварианты базового класса (инварианты базового класса и наследников суммируются).
  4. Производные классы не должны генерировать исключения, не описанные базовым классом (методы подкласса не могут генерировать никаких дополнительных исключений, кроме тех, которые сами являются подклассами исключений, генерируемых методами надкласса).

    class ParentClass
    {
         public function someMethod(string param1)
         {
              // some logic
         }
    }
    
    class ChildClass extends ParentClass
    {
         public function someMethod(string param1, string param2)
         {
              if (param1 == '') {
                   throw new ExtraException();
              }
    
              // some logic
         }
    }


В данном примере someMethod класса ChildClass требует большего от клиентов (param2), и к тому же предъявляет определённые требования к param1, которых не было в родительском классе. То есть нарушаются правила наследования, в результате мы не сможем без дополнительных ухищрений заменять использование ParentClass на ChildClass.

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

interface DuckInterface
{
     public function swim(): void;

     public function fly(): void;
}

class MallardDuck implements DuckInterface
{
     public function swim(): void
     {
          // some logic
     }

     public function fly(): void
     {
          // some logic
     }
}

class RubberDuck implements DuckInterface
{
     public function swim(): void
     {
          // some logic
     }

     public function fly(): void
     {
          // can't fly :(
     }
}

RubberDuck реализует интерфейс DuckInterface. Но, насколько мне известно, резиновые утки не летают, поэтому интерфейс DuckInterface имеет смысл разделить на два интерфейса FlyableInterface и SwimableInterface, и реализовывать уже их.

DIP — принцип инверсии зависимостей. Суть его в том, чтобы вынести все внешние зависимости за пределы модуля и завязаться на абстракции, а не подробности (то бишь на интерфейсы и абстрактные классы, а не конкретные классы). Одним из признаков нарушения этого принципа является наличие ключевого слова new в классе.

class DIPViolation
{
     public function badMethod()
     {
          someService = new SomeService(445, 'second params');
          // operations with someService
     }
}

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

class DIP
{
     private $service;

     public function DIP(SomeServiceInterface $someService)
     {
          $this->someService = $someService;
     }

     public function goodMethod()
     {
          // operations with someService
     }
}

Итак, в данной статье, я постарался собрать воедино правила, которыми руководствуюсь, чтобы писать более удобочитаемый и «ремонтопригодный» код. Надеюсь, вам понравилось это маленькое приключение в принципы хорошего кода. И когда в следующий раз вы услышите, что код, который вы написали, просто «божественен», вы будете знать, что именно это значит :)
Теги:
Хабы:
+30
Комментарии 205
Комментарии Комментарии 205

Публикации

Информация

Сайт
team.vk.company
Дата регистрации
Дата основания
Численность
свыше 10 000 человек
Местоположение
Россия
Представитель
Руслан Дзасохов