Принципы программирования - Часть 1: Уменьшение сложности

Часть 1: Уменьшение сложности
Часть 2: Избавляемся от NULL
Часть 3: Жизненный цикл объектов
Часть 4: Сообщения

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

Снижение сложности функций

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

Уменьшайте количество ветвей выполнения в теле функции

По возможности избавьтесь от конструкций if, elseif, else и switch. Каждая из них создает дополнительную логическую ветвь выполнения. Это усложняет не только понимание кода, но и тестирование. Ведь каждая ветвь должна быть покрыта тестами. Есть несколько советов, которые помогут избавиться от этих конструкций.

Делегируйте принятие решений ("Tell, don't ask")

Иногда принятие решения (конструкция if и подобные ей) могут быть инкапсулированы (перемещены) внутрь другого объекта. Например:

if ($a->somethingIsTrue()) {
    $a->doSomething();
}

можно заменить на:

$a->doSomething();

Здесь принятие решения было вынесено в метод doSomething() самого объекта $a. Теперь нам не придется постоянно принимать это решение, мы можем смело вызывать doSomething(). Здесь мы следуем принципу "Tell, don't ask". Я рекомендую вам ознакомиться с этим принципом и использовать его всякий раз, когда вы запрашиваете у объекта какую-нибудь информацию и на ее основании принимаете решение.

Используйте карты

В некоторых случаях количество конструкций if, elseif и else может быть снижено путем введения карты. Например, этот код:

if ($type === 'json') {
    return $jsonDecoder->decode($body);
} elseif ($type === 'xml') {
    return $xmlDecoder->decode($body);
} else {
    throw new \LogicException(
        'Type "' . $type . '" is not supported'
    );
}

может быть заменен на этот:

$decoders = ...; // карта отображения типов (строка) на соответствующие объекты Decoder

if (!isset($decoders[$type])) {
    throw new \LogicException(
        'Type "' . $type . '" is not supported'
    );
}

return $decoders[$type]->decode($body);

Использование подобных карт делает ваш код открытым для расширения и закрытым для модификации.

Контролируйте типы

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

if ($a instanceof A) {
    // happy path
    return $a->someInformation();
} elseif ($a === null) {
    // alternative path
    return 'default information';
}

может быть сильно упрощен если $a всегда будет объектом класса A:

return $a->someInformation();

Необходимость обработки случаев, когда $a равно null, все равно остается. О том, как это лучше делать мы поговорим в следующей статье.

Ранний возврат

Часто логическая ветвь в теле функции не является полноценной ветвью выполнения. Иногда это просто предусловие (реже постусловие), как здесь:

// предусловие
if (!$a instanceof A) {
    throw new \InvalidArgumentException(...);
}

// основная часть
return $a->someInformation();

В этом коде конструкция if не порождает полноценную ветвь выполнения. Это всего лишь проверка предусловия. В некоторых случаях мы можем возложить подобные проверки на сам PHP (используя type hinting). Конечно, PHP не сможет автоматически проверить все возможные условия, и их нам придется проверять самим в нашем коде. Чтобы уменьшить сложность мы должны выйти из функции (или вернуть значение) как можно раньше. Например, если не прошла проверка предусловия или мы не знаем, как обработать входные данные или если мы уже знаем ответ.

Так же благодаря раннему возврату мы избавимся от лишнего уровня вложенности у основной части кода:

// проверка предусловия
if (...) {
    throw new ...();
}

// ранний возврат
if (...) {
    return ...;
}

// основная часть
...
return ...;

Следование этому принципу поможет упростить чтение и понимание наших функций.

Разбивайте код на небольшие логические части

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

Используйте вспомогательные функции

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

Как правило, вспомогательные методы объявляются как private. Часто им не требуется доступ к переменным экземпляра. В таком случае их объявляют как static. Распространенной практикой является вынесение вспомогательных методов в отдельный класс (и объявление их как public). Так же это облегчает их тестирование.

Уменьшайте количество временных переменных

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

Использование вспомогательных функций помогает избавиться от временных значений:

public function capitalizeAndReverse(array $names) {
    $capitalized = array_map('ucfirst', $names);

    $capitalizedAndReversed = array_map('strrev', $capitalized);

    return $capitalizedAndReversed;
}

Используя вспомогательные методы мы не будем нуждаться во временных переменных:

public function capitalizeAndReverse(array $names) {
    return self::reverse(
        self::capitalize($names)
    );
}

private static function reverse(array $names) {
    return array_map('strrev', $names);
}

private static function capitalize(array $names) {
    return array_map('ucfirst', $names);
}

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

Поскольку для многих алгоритмов необходимо проитерироваться по коллекции чего-либо, получив в результате новую коллекцию или значение, имеет смысл сделать коллекцию "объектом первого класса" и поместить связанную функциональность в него:


class Names
{
    private $names;

    public function __construct(array $names)
    {
        $this->names = $names;
    }

    public function reverse()
    {
        return new self(
            array_map('strrev', $this->names)
        );
    }

    public function capitalize()
    {
        return new self(
            array_map('ucfirst', $this->names)
        );
    }
}

$result = (new Names([...]))->capitalize()->reverse();

Это еще больше упростит добавление новой функциональности.

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

Используйте одинаковые типы

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

Массивы, содержащие значения только одного типа

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

foreach ($collection as $value) {
    // Нет необходимости делать какие-либо проверки, если мы знаем тип $value
}

Для улучшения читабельности кода, а так же улучшения обработки его вашей IDE, используйте type hinting:

/**
 * @param DateTime[] $collection
 */
public function doSomething(array $collection) {
    foreach ($collection as $value) {
        // $value является экземпляром DateTime
    }
}

В случаях когда вы не можете быть абсолютно уверенны, что $value будет именно экземпляромDateTime, добавьте предусловие в само тело функции. Библиотека beberlei/assert поможет сделать это очень просто:


use Assert\Assertion

public function doSomething(array $collection) {
    Assertion::allIsInstanceOf($collection, \DateTime::class);
    ...
}

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

Единый тип возвращаемого значения

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

$result = someFunction();
if ($result === false) {
    ...
} elseif (is_int($result)) {
    ...
}

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

Более реальный пример смешанного типа возвращаемого значения:

/**
 * @param int $id
 * @return User|null
 */
public function findById($id)
{
    ...
}

Эта функция вернет либо экземпляр User, либо null. Из-за этого мы не можем передать результат это функции в другую функцию без предварительной проверки на то, что это действительно экземпляр User.

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

Читабельные выражения

Мы обсудили множество способов снизить общую сложность вашего кода. Тем не менее есть еще несколько советов, которые помогут нам сделать код еще проще.

Скрывайте сложную логику

Часто вы можете вынести сложную часть выражения во вспомогательный метод. Вместо

if (($a || $b) && $c) {
    ...
}

вы можете написать лучше, вот так:

if (somethingIsTheCase($a, $b, $c)) {
    ...
}

Для читателя теперь понятно, что решение зависит от $a, $b и $c, а имя функции описывает логику принятия решения.

Используйте логические выражения

Выражение, используемое в if, должно возвращать значение типа boolean. Однако, PHP не заставляет вас указывать только логические выражения:

$a = new \DateTime();
...
if ($a) {
    ...
}

Здесь $a будет автоматически приведено к типу boolean. Приведение типов - это основной источник ошибок. Так же оно повышает сложность кода т.к. читателю приходится производить эти преобразования в уме и запоминать их. Вместо того, чтобы полагаться на автоматическое приведение $a к логическому типу, мы должны сделать это явно. Например:

if ($a instanceof DateTime) {
    ...
}

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

if ($b === false) {
    ...
}

Вместо этого используйте оператор !:

if (!$b) {
    ...
}

Не используйте Yoda-style

Yoda-style выражения выглядят так:

if ('hello' === $result) {
    ...
}

Это делает для предотвращения ошибок вроде этой:

if ($result = 'hello') {
    ...
}

Здесь переменной $result присваивается значение hello, которое становится результатом для всего выражения присваивания. Затем оно автоматически приведется к типу boolean (в данном случае true). Следовательно, всегда будет выполняться только одна логическая ветвь кода.

Использование Yoda-style должен помочь вам предотвратить такие ошибки:

if ('hello' = $result) {
    ...
}

Я думаю, никто не допускает таких ошибок, если он не совсем зеленый новичок, который только знакомится с синтаксисом языка. В то же время Yoda-style имеют плохую читабельность. Эти выражения сложнее читать и понимать т.к. они не похожи на естественное построение предложений.

Дополнительная литература

Если вы хотите узнать больше о том, как сделать ваш код лучше, я советую вам прочитать следующие книги:

  • Совершенный код (Code Complete). Автор: Стив Макконнелл
  • Совершенный код (Clean Code). Автор: Роберт К. Мартин
  • Рефакторинг. Улучшение существующего кода (Refactoring). Автор: Мартин Фаулер

А в следующей статье мы рассмотрим одну конкретную причину сложности кода - null значения.

Часть 1: Уменьшение сложности
Часть 2: Избавляемся от NULL
Часть 3: Жизненный цикл объектов
Часть 4: Сообщения

Это вольный перевод статьи "Programming guidelines - Part 1: Reducing complexity", которую написал Matthias Noback.

Комментарии

Simon

в классе Names в методах надо исправить, во вспомогательных методах, свойство: array_map('strrev', $names) => array_map('strrev', $this->names) ну и во втором методе тоже.

Спасибо за статью

Haru Atari

Simon, спасибо большое. Исправил.

Используйте Markdown
Спасибо за коментарий!
Ваше сообщение будет доступно после проверки.