«Нулевой» ад и как из него выбраться

в 8:33, , рубрики: null, null hell, nullpointerexception, php, Блог компании FunCorp, Программирование, Проектирование и рефакторинг, Совершенный код

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

«Нулевой» ад и как из него выбраться - 1

Значения по умолчанию

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

public function insertDiscount(
  string $name,
  int $amountInCents,
  bool $isActive = true,
  string $description = '',
  int $productIdConstraint = null,
  DateTimeImmutable $startDateConstraint = null,
  DateTimeImmutable $endDateConstraint = null,
  int $paymentMethodConstraint = null
): int

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

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

insertDiscount('Discount name', 100, true, '', null, null, null, 5);

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

Давайте разберём этот пример аргумент за аргументом.

Что такое валидная скидка?

Мы уже выяснили, что скидка без ограничений применяется везде. Таким образом, валидная скидка содержит всё, кроме ограничений, которые мы можем добавить позже. Аргумент isActive имеет значение по умолчанию true. Следовательно, метод может быть вызван следующим образом:

insertDiscount('Discount name', 100);

Просто прочитав код, я не знаю, что скидка будет активна сразу. Чтобы узнать это, мне пришлось бы бы проверить, есть ли у сигнатуры метода значения по умолчанию.

Теперь представьте, что вам нужно прочитать 200 строк кода. Вы действительно хотите проверять каждую сигнатуру вызываемого метода на наличие скрытой информации? Я бы предпочла просто прочитать код без необходимости что-либо искать.

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

Я бы переписала этот метод так:

public function insertDiscount(
  string $name,
  string $description,
  int $amountInCents,
  bool $isActive
): int

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

insertDiscount(
  'Discount name',
  'Discount description',
  100,
  Discount::STATUS_ACTIVE
);

Я также использовала константы для статуса активности скидки. Теперь не нужно смотреть на сигнатуру метода, чтобы узнать, что означает true для этого аргумента: становится очевидно, что мы создаём именно активную скидку. В дальнейшем мы сможем ещё улучшить этот метод (спойлер: с помощью объектов-значений).

Добавление ограничений

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

public function addProductConstraint(
  Discount $discount,
  int $productId
): Discount;

public function addDateConstraint(
  Discount $discount,
  DateTimeImmutable $startDate,
  DateTimeImmutable $endDate
): Discount;

public function addPaymentMethodConstraint(
  Discount $discount,
  int $paymentMethod
): Discount;

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

$discountId = insertDiscount(
  'Discount name',
  'Discount description',
  100,
  Discount::STATUS_ACTIVE
);

addPaymentMethodConstraint(
  $discountId,
  PaymentMethod::CREDIT_CARD
);

Теперь сравните это с первоначальным вызовом. Вы увидите, насколько удобнее стало читать.

Null в свойствах объектов

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

$currencyCode = strtolower(
  $record->currencyCode
);

Бууум! «Не могу передать значение null в strtolower». Это произошло потому, что разработчик забыл, что currencyCode может быть null. Поскольку многие разработчики до сих пор не используют IDE или подавляют предупреждения в них, это может оставаться незамеченным в течение многих лет. Ошибка продолжит валяться в каком-то непрочитанном логе, а клиенты будут сообщать о периодически возникающих проблемах, которые, по-видимому, не связаны с этим, поэтому никто не потрудится взглянуть на эту строку кода.

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

if ($record->currencyCode === null) {
  throw new RuntimeException('Currency code cannot be null');
}

if ($record->amount === null) {
  throw new RuntimeException('Amount cannot be null');
}

if ($record->amount > 0) {
  throw new RuntimeException('Amount must be a positive value');
}

Но, как вы уже поняли, это не лучшее решение. Помимо того, что вы загромождаете свой метод, теперь вы должны повторять эту проверку везде. И каждый раз, когда добавляете другое null-свойство, не забывайте делать ещё одну такую проверку! К счастью, есть простое решение: объекты-значения.

Объекты-значения

Объекты-значения (value objects) — это мощные, но простые вещи. Проблема, которую мы пытались решить, заключалась в том, что необходимо постоянно валидировать все наши свойства. А делаем мы это потому, что не знаем, можно ли доверять свойствам объекта, валидны ли они. А что если бы мы могли?

Чтобы доверять значениям, от них нужны два признака: они должны быть валидированы и не должны были изменяться с момента валидации. Взгляните на этот класс:

final class Amount
{
  private $amountInCents;
  private $currencyCode;

  public function __construct(int $amountInCents, string $currencyCode): self
  {
    Assert::that($amountInCents)->greaterThan(0);

    $this->amountInCents = $amountInCents;
    $this->currencyCode = $currencyCode;
  }

  public function getAmountInCents(): int
  {
    return $this->amountInCents;
  }

  public function getCurrencyCode(): string
  {
    return $this->currencyCode;
  }
}

Я использую пакет beberlei/assert. Он выбрасывает исключение всякий раз, когда проверка завершается неудачно. Это то же самое, что и исключение для null в исходном коде, если только мы не переместили проверку в этот конструктор.

Поскольку мы используем объявления типов, то гарантируем, что тип также является правильным. Таким образом, мы не можем передать int в strtolower. Если вы используете более старую версию PHP, которая не поддерживает объявления типов, то можете использовать этот пакет для проверки типов с помощью ->integer() и ->string().

После создания объекта значения нельзя изменить, потому что у нас есть только геттеры, но нет сеттеров. Это называется иммутабельность. Добавление final не позволяет расширять этот класс для добавления сеттеров или магических методов. Если вы видите Amount $amount в параметрах метода, то можете быть на 100% уверены, что все его свойства были валидированы и объект безопасен для использования. Если бы значения были не валидными, мы бы не смогли создать объект.

Теперь с помощью объектов-значений мы можем ещё улучшить наш пример:

$discount = new Discount(
  'Discount name',
  'Discount description',
  new Amount(100, 'CAD'),
  Discount::STATUS_ACTIVE
)

insertDiscount($discount);

Обратите внимание, что мы сначала создаём Discount, а внутри используем Amount в качестве аргумента. Это гарантирует, что метод insertDiscount получает валидный объект скидки в дополнение к тому, что весь этот блок кода теперь намного проще понять.

«Нулевая» история ужасов

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

$collection = $this->findBy(['key' => 'value']);
$result = $this->filter($collection, $someFilterMethod);

if ($result === null) {
   $result = $collection;
}

Если результат null, то использовать изначальную коллекцию в качестве результата? Это проблематично, так как метод фильтрации возвращает null, если не нашёл подходящие значения. Таким образом, если всё отфильтровано, мы проигнорируем фильтр и вернём все значения. Это полностью ломает логику.

Почему изначальная коллекция используется в качестве результата? Мы никогда этого не узнаем. Я подозреваю, что у разработчика было определённое предположение о том, что означает null в этом контексте, но оно оказалось неверным.

Это и есть проблема со значениями null. В большинстве случаев непонятно, что они значат, и поэтому нам остаётся только гадать, как на них реагировать.  Очень легко ошибиться. Исключение, с другой стороны, очень ясно:

try {
  $result = $this->filter($collection, $someFilterMethod);
} catch (CollectionCannotBeEmpty $e) {
  // ...
}

Этот код однозначен. Разработчик вряд ли сможет неправильно истолковать его.

Стоит ли это таких усилий?

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

Вот и наступил конец моей null-тирады. Я надеюсь, это поможет вам писать более понятный и поддерживаемый код.

Автор: Oleg Zolotarev

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js