Нулевой объект (Null Object)
Вместо
nullподставляет объект с тем же интерфейсом и нейтральным поведением, чтобы код не проверял наnullперед каждым вызовом.
Проблема
Импорт пользователей из файла пропускает строки с битым email и сообщает о них в журнал. Журнал нужен не всегда: в консольной команде он пишет в файл, в тесте и в фоновой задаче его нет. Если сделать зависимость необязательной (?Logger $logger = null), каждое обращение к ней обрастает проверкой:
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 знает только контракт и не различает реализации. Нулевой журнал подставляется по умолчанию прямо в конструкторе (в 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 - ключевое слово.
| Язык | Особенность |
|---|---|
| PHP | interface Logger, final class NullLogger; значение по умолчанию new NullLogger() прямо в параметре конструктора (PHP 8.1+) |
| Go | Пустая структура NullLogger struct{}; NewUserImporter заменяет nil нулевым журналом в одном месте |
| Python | Protocol вместо базового класса; 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.
Связанные темы
- Практика: гость, программа лояльности и аналитика - нулевые объекты в профиле покупателя и где они прячут ошибки
- Стратегия (Strategy) -
NoDiscountтам тоже нулевой объект: стратегия, которая ничего не меняет - Декоратор (Decorator) - нулевой объект удобен как «пустая» обёртка или конец цепочки
- Заместитель (Proxy) - тоже подставляет объект с тем же интерфейсом, но решает, пускать ли вызов к настоящему