handbookНаписать

patterns/enterprise/null-object/README.md · тема · 4 языка · 19 тестов

+ практика: гость, лояльность и аналитика

Нулевой объект (Null Object) ​

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

Проблема ​

Импорт пользователей из файла пропускает строки с битым email и сообщает о них в журнал. Журнал нужен не всегда: в консольной команде он пишет в файл, в тесте и в фоновой задаче его нет. Если сделать зависимость необязательной (?Logger $logger = null), каждое обращение к ней обрастает проверкой:

php
if ($this->logger !== null) {
    $this->logger->log("skipped invalid email: {$email}");
}

Таких мест становится десять, одно забывают, и в проде падает Call to a member function log() on null. Сама проверка ничего не говорит о предметной области: это шум, который повторяется у каждого клиента Logger.

Решение ​

Отсутствие поведения тоже можно сделать объектом. NullLogger реализует тот же контракт Logger, что и настоящий журнал, но его метод log ничего не делает. UserImporter получает журнал всегда, по умолчанию нулевой, и вызывает его без проверок.

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

Структура ​

UserImporter

-logger: Logger

+import(emails) : int

«interface»

Logger

+log(message)

MemoryLogger

-lines: string[]

+lines() : string[]

NullLogger

UserImporter знает только контракт и не различает реализации. Нулевой журнал подставляется по умолчанию прямо в конструкторе (в Go - в NewUserImporter), поэтому в методе import нет ни одной проверки на null: пропущенная строка всегда уходит в log, а что с ней будет дальше, решает реализация.

Как это собирается ​

Журнал выбирает тот, кто запускает импорт: команда в консоли или задача в cron. В примере это флаг --verbose: с ним UserImporter получает настоящий журнал, который потом печатается в консоль, без него - NullLogger. Решение принимается одной строкой при сборке, а import в обоих запусках выполняется одинаково и считает те же 2 из 4 адресов.

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

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

MemoryLogger копит строки в памяти: в проекте на его месте файловый или сетевой журнал, здесь память нужна тесту. NullLogger - пустое тело метода, без состояния, поэтому один экземпляр можно делить между всеми клиентами (в TypeScript это просто константа nullLogger). В Python метод называется import_emails, потому что import - ключевое слово.

ЯзыкОсобенность
PHPinterface Logger, final class NullLogger; значение по умолчанию new NullLogger() прямо в параметре конструктора (PHP 8.1+)
GoПустая структура NullLogger struct{}; NewUserImporter заменяет nil нулевым журналом в одном месте
PythonProtocol вместо базового класса; field(default_factory=NullLogger) у dataclass
TypeScriptНулевой журнал - объектный литерал nullLogger, класс не нужен; он же значение параметра по умолчанию

Запуск: make test T=patterns/enterprise/null-object

Плюсы и минусы ​

Тесты одинаковы во всех языках. Первый прогоняет импорт трёх строк с нулевым и настоящим журналом: результат один и тот же, две записи, то есть поведение импорта от журнала не зависит. Второй проверяет, что настоящий журнал получил ровно одну строку про bob. Третий создаёт импорт без журнала вовсе (в Go - передаёт nil) и убеждается, что подставился нулевой.

  • ✅ Клиент не проверяет на null: меньше ветвлений, нельзя забыть проверку.
  • ✅ Решение «поведения нет» принимается в одном месте, при сборке объекта.
  • ✅ Удобно в тестах: нулевой объект - готовая заглушка, которая ничего не требует.
  • ❌ Ещё один класс на каждый контракт.
  • ❌ Ошибка может стать невидимой: если отсутствие значения - это сбой, нулевой объект его спрячет.
  • ❌ Нейтральное поведение не всегда очевидно: что должен вернуть нулевой «курс валют» или «адрес доставки»? Если ответа нет, нулевой объект не подходит.

Когда не применять ​

  • Отсутствие - это ошибка или важная ветка для вызывающего: ненайденный заказ, неуказанный адрес оплаты. Здесь нужно исключение или явный Optional/T | None, а не тихая заглушка.
  • Нейтрального поведения нет: метод должен вернуть осмысленное значение, а «ноль» исказит расчёт.
  • Клиенту всё равно нужно знать, настоящий ли объект (показать «Войти» гостю): тогда метод isGuest() возвращает ту же проверку, только под другим именем. Иногда это честнее, иногда - признак, что приём не подходит.
  • Вызов один и в одном месте - ?-> или if проще отдельного класса.

Отличия между языками ​

  • PHP - ?-> (nullsafe) сокращает проверку, но не убирает её: каждый клиент всё ещё помнит, что журнала может не быть. Нулевой объект с new NullLogger() в значении по умолчанию снимает этот вопрос совсем. Готовая реализация есть в PSR-3: Psr\Log\NullLogger.
  • Go - нулевое значение интерфейса - nil, и вызов метода на нём паникует, поэтому проверка нужна, но одна, в конструкторе. Стандартная библиотека сама пользуется приёмом: io.Discard - нулевой io.Writer, slog.DiscardHandler - нулевой обработчик журнала. Отдельный вариант Go - метод на nil-указателе, он разобран в практике.
  • Python - достаточно класса с подходящими методами: Protocol проверяет форму статически, наследовать ничего не нужно. В стандартной библиотеке тот же приём - logging.NullHandler.
  • TypeScript - logger?.log() (optional chaining) так же сокращает проверку, как ?-> в PHP. Нулевой объект - обычный литерал { log: () => {} }, структурная типизация принимает его как Logger без класса и implements.

Практика ​

Учебный пример обходит главные вопросы: откуда берётся нулевой объект и где он вреден. Практика: гость, программа лояльности и аналитика показывает профиль покупателя в магазине: гость вместо null-пользователя, отсутствующая программа лояльности и отключённая аналитика, которые выбираются в одном месте. Там же - граница: ненайденный заказ при оплате остаётся ошибкой, а не пустым заказом, и сравнение с Optional и методом на nil-указателе в Go.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS