«Божественный» код (GOD'S code)
«Божественный» код — громкий термин, который может показаться желтым заголовком, но всё же именно о таком коде будет идти речь: из каких частей он состоит и как его писать. Это история о моих стараниях сделать так, чтобы задачи не возвращались с code review с пометкой: «Всё хе*ня — переделать».
У меня нет профильного образования, и учиться программированию приходилось на практике, через ошибки, ссадины и ушибы. Непрерывно работая над улучшением качества написанного кода, я вырабатывал некоторые правила, которым он должен соответствовать. Хочу ими поделиться.
GOD«S code — акроним из акронимов — код, написанный в соответствии с принципами Grasp, Object calisthenics, Demeter«s law и Solid. Кому-то они знакомы все, кто-то встречался лишь с некоторыми, но мы рассмотрим каждую составляющую акронима. Я не ставлю своей целью детально погрузиться в каждую группу правил, так как на просторах интернета они уже много раз освещались. Вместо этого предлагаю выжимку из собственного опыта.
GRASP
Девять шаблонов для назначения ответственностей классам и объектам. Для удобства запоминания я разделяю их на две подгруппы:
- В первую подгруппу можно выделить правила, позволяющие писать атомарные модули, которые хорошо тестируются и изменяются. Эти правила не столько про ответственность, а, скажем так, про свойства модулей: слабая связанность, сильное сцепление, полиморфизм, устойчивость к изменениям. Для себя я перекрываю эти правила SOLID«ом, об этом подробнее — в соответствующей части.
- Вторая подгруппа — это уже более чёткие шаблоны, которые говорят нам о том, кто создаёт объекты («создатель» — собирательное название для фабричных паттернов), каким образом понизить связанность между модулями (используя паттерны «контроллер» и «посредник»), кому делегировать отдельные обязанности (информационный эксперт) и что делать, если я люблю DDD и одновременно low coupling (чистая выдумка).
Подробнее можно почитать здесь.
Объектная калистеника
Набор правил оформления кода, очень похожих на свод законов codeStyle. Их также девять. Я расскажу о трёх, которые стараюсь соблюдать в повседневной работе (немного видоизменённых), об остальных можно почитать в первоисточнике.
- Длина метода — не более 15 LOC, количество методов в классе — не более 15, количество классов в одном пространстве имён — не более 15. Суть в том, что длинные простыни кода очень сложно читать и понимать. К тому же, длинные классы и методы являются сигналом о нарушении SRP (об этом ниже).
- Максимум один уровень вложенности на метод.
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 … } }
Опять-таки, цель — иметь возможность понять, что делает метод, кинув на него один взгляд и не компилируя код в голове. - Не использовать
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, В содержит С. Рассмотрим объект А. Метод А объекта А может иметь доступ только к методам и свойствам:
- Самого объекта А.
- Объекта, который передан в качестве параметра методу А.
- Объекта В.
- Объектов, которые непосредственно созданы внутри метода А.
И всё. Проще говоря, объект А взаимодействует только с непосредственными соседями. Взаимодействие типа 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 — принцип подстановки Барбары Лисков. Корнями уходит в контрактное программирование и говорит о том, как нам правильно строить наследование.
Постулаты таковы:
- Производные классы не должны усиливать предусловия (не должны требовать большего от своих клиентов).
- Производные классы не должны ослаблять постусловия (должны гарантировать, как минимум, то же, что и базовый класс).
- Производные классы не должны нарушать инварианты базового класса (инварианты базового класса и наследников суммируются).
- Производные классы не должны генерировать исключения, не описанные базовым классом (методы подкласса не могут генерировать никаких дополнительных исключений, кроме тех, которые сами являются подклассами исключений, генерируемых методами надкласса).
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
}
}
Итак, в данной статье, я постарался собрать воедино правила, которыми руководствуюсь, чтобы писать более удобочитаемый и «ремонтопригодный» код. Надеюсь, вам понравилось это маленькое приключение в принципы хорошего кода. И когда в следующий раз вы услышите, что код, который вы написали, просто «божественен», вы будете знать, что именно это значит :)