Принципы программирования - Часть 2: Избавляемся от NULL

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

Что такое NULL

Дело в том, что нет одного четкого определения того, что означает null. Именно поэтому его использование так усложняет наш код. Например, возврат null из функции может означать:

  • "Я не знаю, что я должен вернуть, поэтому я верну null."
  • "То, что я хочу вернуть вам, в любом случае не имеет смысла. Поэтому я верну null."
  • "То, что вы просил найти, не существует. Поэтому я верну null."
  • "Я никогда не возвращаю никакого значения (потому чтя я void функция). Поэтому я верну null."

Я уверен, что вы можете вспомнить еще много подобных ситуаций.

Так же передача null в качестве аргумента в функцию может означать:

  • "К сожалению, я не знаю, что передавать. Поэтому я передам null."
  • "Этот аргумент не обязательный. Так что я передам null."
  • "Я получил это значение откуда-то еще (оно равно null). Я просто передам его."

Это может показаться смешным. Но только не в том случае, когда подобное встречается в вашем коде. Хорошей идеей будет избавиться от неопределенности и неясности, которые возникают из-за использования null. Кроме того, в большинстве случаев, когда вы сталкиваетесь с null в ваших программах, вы не ожидаете его. Вы просто работаете с переменной, думая, что это объект. И PHP вполне справедливо выбрасывает ошибку.

Пути решения

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

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

В случае примитивных типов данных (таких как int, string, boolean и т.п.) подумайте, можете ли вы использовать их "альтернативы NULL (null equivalents)" вместо null значений:

  • Если ваша функция возвращает массив, верните пустой массив вместо null.
  • Если ваша функция возвращает int, верните 0 вместо null.
  • ...

Думаю, вы уловили суть. Всегда смотрите по ситуации (возможно 0 не подойдет, но имеет смысл использовать -1). Если функция всегда возвращает значения ожидаемого типа, то вы можете использовать для него одинаковые операторы, функции и т.д. без дополнительных проверок. Например, вместо:

if (is_array($values)) {
    foreach ($values as $value) {
        ...
    }
}

вы сможете писать так:

foreach ($values as $value) {
    ...
}

PHP (до 7 версии) позволяет указать, что аргумент функции должен быть массивом или объектом конкретного класса. Если в качестве такого аргумента передать null, программа не будет работать. Это означает, что аргументы примитивных типов все равно надо проверять вручную в теле функции:

function (ClassName $a, array $b, $c) {
    // $a и $b не могут быть null, а $c может:
    if (!is_int($c)) {
        throw new \InvalidArgumentException(...);
    }
}

Это приводит к написанию дополнительного (дублирующегося) кода. Поэтому я еще раз рекомендую использовать библиотеки ассертов, такие как beberlei/assert:

function (ClassName $a, array $b, $c) {
    Assertion::integer($c);
}

Это позволит избежать многих ошибок из-за неявных преобразований типов.

Используйте NULL-объекты

Если вы рассчитываете, что получите массив, то получение вместо него null будет нарушением контракта. Ведь null не может быть использован таким же образом, как массив. Это справедливо и для объектов. Если ожидается, что переменная содержит экземпляр определенного класса, а на самом деле там null - контракт нарушен. Вы обнаружите это нарушение, когда при использовании этой переменной там, где ожидается объект, PHP упадет с Fatal error.

Во многих случаях вы можете предотвратить эту проблему используя так называемые null-объекты (null objects). Там, где вы возвращаете null вместо объекта определенного типа, возвращайте null объект этого же типа. Часто (но не всегда) это требует добавления интерфейса, который будет объединять реальные и null-объекты.

NULL-сервисы

Наглядный пример - опциональная инъекция зависимостей (например сервиса логирования):

function doSomething(Logger $logger = null) {
    if ($logger !== null) {
        $logger->debug('Start doing something');
    }
    ...
}

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

class NullLogger implements Logger
{
    public function debug($message)
    {
        // Ничего не делать
    }
}

Такой подход работает до тех пор, пока все методы класса могут быть пустыми. Это возможно только для т.н. командных методов, которые ничего не возвращают (имеет тип возврата void).

NULL-объекты данных

Немного сложнее избавиться от null в объектах, которые содержат в себе данные (например доменные модели или модели представления). Такие объекты почти не имеют методов-команд, только методы-запросы, которые возвращают данные. В таком случае вам нужно предоставить null-реализацию возвращаемых данных, которая поддерживает тот же контракт, что и нормальные данные. Рассмотрим следующий пример, где мы получаем от фабрики модель текущего пользователя и печатаем его имя:

$userViewModel = $userViewModelFactory->createUserViewModel();
if ($userViewModel === null) {
    echo 'Anonymous user';
} else {
    echo $userViewModel->getDisplayName();
}

Мы можем избавиться от null, если гарантируем, что метод createUserViewModel() всегда будет возвращать данные, поддерживающие контракт модели авторизованного пользователя:

// Общий интерфейс
interface UserViewModel
{
    /**
     * @return string
     */
    public function getDisplayName();
}

// Модель представления для авторизованного пользователя
class LoggedInUser implements UserViewModel
{
    public function getDisplayName()
    {
        return $this->displayName;
    }
}

// null-реализация
class AnonymousUser implements UserViewModel
{
    public function getDisplayName()
    {
        return 'Anonymous user';
    }
}

class UserViewModelFactory
{
    public function createUserViewModel()
    {
        if (/* если пользователь авторизован */) {
            return new LoggedInUser(...);
        }
        return new AnonymousUser();
    }
}

Теперь метод UserViewModelFactory::createUserViewModel() всегда будет возвращать экземпляр UserViewModel и клиентскому коду не надо будет "отлавливать" null.

$userViewModel = $userViewModelFactory->createUserViewModel();
echo $userViewModel->getDisplayName();

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

Бросайте исключения

В некоторых случаях возвращение null-объектов неприемлемо т.к. это будет некоректно с точки зрения клиентского кода:

class UserRepository
{
    public function getById($id) {
        if (/* пользователь не найден */) {
            return null;
        }
        return new User(...);
    }
}

Здесь возврат null подразумевает "Я не смог найти то, что вы искали". Тем не менее, вызывая getById() клиентский код ожидает получить объект User. Ситуация, когда мы не смогли найти пользователя является исключительной, и мы должны сообщить об этом. Другими словами, мы должны выбросить исключение:

class UserRepository
{
    public function getById($id) {
        if (/* пользователь не найден */) {
            throw UserNotFound::withId($id);
        }
        return new User(...);
    }
}

class UserNotFound extends \RuntimeException
{
    public static function withId($id)
    {
        return new self(
            sprintf(
                'User with id "%s" was not found', $id
            )
        );
    }
}

Инкапсулируйте NULL

Я не думаю, что возможно полностью избавиться от null в коде. Нужно избавляться от null везде, где это возможно. Но нужно знать меру.Иногда вам придется использовать null, например для инициализации полей объекта без установки начального значения. В таких случаях убедитесь, что вы спрятали его наличие за публичным интерфейсом. Другими словами: инкапсулируйте. Например преобразовывайте бессмысленный null в логическое выражение:

class Value
{
    private $value;

    public function isDefined() {
        return $this->value !== null;
    }
}

Или убедитесь, что клиенту не придется беспокоится о null-значении поля. ```phpclass Name { private $firstName = 'Matthias'; private $middleName = null; private $lastName = 'Noback';

public function fullName()
{
    return implode(' ', array_filter([
        $this->firstName,
        $this->middleName,
        $this->lastName
    ]));
}

}


Если вы хотите оставить возможность создания объекта без указания значений его полей, вы можете скрыть факт использования `null`, используя альтернативные конструкторы:
```php
class Person
{
    private $name;

    public static function fromName(Name $name)
    {
        $person = new self();
        $person->name = $name;
        return $person;
    }

    public static function anonymous()
    {
        $person = new self();
        // $person->name равно null
        return $person;
    }
}

Заключение

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

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

Это вольный перевод статьи "Programming guidelines - Part 2: Getting rid of null", которую написал Matthias Noback.

Коментарии

Используйте Markdown

Thank you for comment!
Ваше сообщение будет доступно после проверки.