CTO Vetmanager, PHP Developer, Ironman 70.3

Война с NULL

Война с NULL

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

Одно из таких слов — NULL.

С виду это очень удобная штука. Не строка. Не число. Не объект. Не false. Не ошибка. Просто вежливый способ сказать: «Тут ничего нет, но ты там сам разберись».

И вот это «сам разберись» потом размазывается по всей системе.

Почему NULL кажется безобидным

Потому что в маленьком примере он выглядит нормально.

function getDiscount(?Client $client): ?int
{
    if ($client === null) {
        return null;
    }

    if (!$client->isVip()) {
        return null;
    }

    return 15;
}

Смотришь и думаешь: красота. Всё честно. Клиента может не быть. Скидки может не быть. Возвращаем null и живём дальше.

А дальше начинается обычная жизнь.

Где-то это забыли проверить. Где-то решили, что null это то же самое, что 0. Где-то сделали ?? []. Где-то сунули в шаблон. Где-то передали дальше ещё в три слоя. И через неделю уже никто не понимает: null здесь означает «данных нет», «не загрузили», «ошибка», «скидка не положена» или «мне было лень придумать нормальный контракт».

NULL это не значение. Это капитуляция

Вот что меня в NULL бесит больше всего.

Он почти никогда не передаёт смысл.

Если у нас есть клиент, у клиента может быть:

  • email;
  • телефон;
  • скидка;
  • дата последнего визита;
  • лечащий врач;
  • бонусный баланс.

И когда вместо этого мы говорим null, мы не объясняем ничего.

null для email и null для скидки — это вообще разные истории.

В одном случае человек не заполнил email. В другом случае скидка не применяется. В третьем случае скидка ещё не посчитана. В четвёртом интеграция вернула мусор. В пятом мы просто не загрузили связанную сущность.

Но в коде это выглядит одинаково.

Какая мощь.

Как NULL размазывает ответственность

Допустим, у нас есть такой код:

$doctorName = $appointment?->doctor?->name;

Очень современно. Очень безопасно. Очень аккуратно.

Только что именно мы хотели этим сказать?

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

То есть одна строчка скрыла сразу пачку решений.

И чем больше такого кода, тем меньше у нас бизнес-логики и тем больше у нас болотца из «ну тут может быть null, если что».

Потом появляется ещё одна строчка:

$doctorName = $appointment?->doctor?->name ?? 'Не назначен';

И вот здесь уже совсем хорошо.

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

Пользователю соврали. Систему успокоили. Все довольны.

NULL отлично дружит с лапшекодом

Особенно весело становится, когда null начинает ходить через несколько слоёв.

public function getClientCity(int $clientId): ?string
{
    $client = $this->clientRepository->find($clientId);

    if ($client === null) {
        return null;
    }

    $address = $client->getAddress();

    if ($address === null) {
        return null;
    }

    return $address->getCity();
}

Вроде всё культурно.

Но по факту мы написали не бизнес-логику, а экскурсию по неуверенности:

  • клиента может не быть;
  • адреса может не быть;
  • города может не быть;
  • и никого это не удивляет.

Почему?

Потому что система уже привыкла, что ничего не гарантировано.

В этом месте NULL уже не симптом. Он уже архитектурный стиль.

Самое мерзкое — NULL заражает всё вокруг

Вот был у вас один nullable field.

Потом внезапно nullable стал return type. Потом nullable стал аргумент конструктора. Потом nullable стал свойством DTO. Потом nullable стал в шаблоне. Потом nullable стал в тестах. Потом все проверки превратились в if ($x !== null). Потом по проекту поползли ?->, ??, empty(), isset() и прочие костыли первой помощи.

И всё.

Система проиграла.

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

А что вместо NULL

Вот здесь обычно начинается любимый спор.

Мне говорят:

«Ну а что делать? Всегда объект-заглушку городить? Enum? Exception? Optional? Ты совсем что ли?»

Нет, не всегда.

Но почти всегда можно передать смысл лучше, чем через NULL.

1. Отдельное значение

Если скидки нет, может быть 0.

function getDiscount(Client $client): int
{
    if (!$client->isVip()) {
        return 0;
    }

    return 15;
}

Если для домена 0 — нормальное значение, то это лучше, чем null.

2. Null Object

Если доктор не назначен, можно вернуть не null, а специальный объект.

interface Doctor
{
    public function name(): string;
}

final class RealDoctor implements Doctor
{
    public function __construct(private string $name) {}

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

final class UnknownDoctor implements Doctor
{
    public function name(): string
    {
        return 'Не назначен';
    }
}

Да, кто-то сейчас закричит, что это оверинжиниринг.

Иногда да.

Но зато у вас исчезает половина if ($doctor === null) по проекту.

А это уже очень лечебная история.

3. Явный Result

Если операция может не удаться, лучше так и сказать.

Не ?Client, а что-нибудь вроде:

  • ClientFound
  • ClientNotFound
  • ImportFailed
  • DiscountNotApplicable

Пусть даже через enum, пусть через value object, пусть через маленький result-класс. Главное — чтобы в коде был смысл, а не пустота.

4. Exception, если это реально ошибка

Если null означает не «данных нет», а «мы попали в невозможное состояние», то надо не null возвращать, а орать.

Потому что «невозможное состояние тихо вернуло null» — это любимый способ сделать баг невидимым.

Где NULL всё-таки нормальный

Иногда нормальный.

Я не фанатик секты «убьём null везде».

Есть места, где nullable оправдан:

  • внешний API, который реально присылает null;
  • сырые DTO на границе системы;
  • поля формы, которые пользователь ещё не заполнил;
  • миграционный слой, где вы постепенно разгребаете старую базу.

Но есть важное правило.

NULL можно терпеть на границе системы.

В доменную логику его лучше не тащить.

Пусть он умирает на входе.

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

Иначе внешний хаос просто расползётся по вашему коду.

Почему это особенно важно сейчас

Раньше можно было ещё как-то жить с NULL.

Человек опытный. Глаз намётан. Где надо, догадается. Где не надо, дебажнет.

А теперь рядом с нами сидит агент, который смотрит на этот nullable зоопарк и делает из него выводы.

Какие выводы он делает?

Очень простые.

Если тут везде null, значит так можно. Если тут везде неопределённость, значит можно добавить ещё чуть-чуть неопределённости. Если тут домен ничего не гарантирует, значит и я не буду.

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

Поэтому чем яснее контракты, тем умнее ведёт себя агент.

И наоборот.

Что делать практически

Если хочется начать войну с NULL, не надо переписывать весь проект за выходные.

Можно начать с малого.

  • Перестать бездумно ставить ?string, ?int, ?array в новых методах.
  • В каждом nullable return type задавать себе вопрос: что именно означает null?
  • Если ответов больше одного — значит контракт уже плохой.
  • Собирать null на границе системы и не пускать глубже.
  • Заменять часть null на явные значения, result-объекты и null object там, где это окупается.
  • Смотреть на длинные цепочки ?-> как на запах дизайна, а не как на победу безопасности.
  • Писать тесты не только на happy path, но и на смысл отсутствия данных.

Вывод

NULL удобен.

Именно поэтому он так опасен.

Это очень дешёвый способ отложить решение. Не определить контракт. Не назвать состояние. Не взять ответственность за смысл.

Один NULL в системе — это мелочь.

Сотня NULL — это уже архитектурная философия под названием «разберёмся потом».

Обычно не разбираемся.

Обычно просто привыкаем жить в этом тумане.

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

Он и не мог понять.

Вы сами вместо смысла дали ему пустоту.