Шлифуя код своего пакета PHP MultiRunner с помощью статических анализаторов кода psalm и phpstan, столкнулся с интересной загадкой — а как в PHP узнать определена ли переменная или нет.

Казалось бы, есть хорошая функция (языковая конструкция) isset(mixed $var, ...$vars): bool. Но в ней есть подвох: если переменная определена значением null, то isset() вернёт для неё false.

                
<?php
// Только показываем ошибки скрипта,
// но не записываем их в журнал ошибок.
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', false);
error_reporting(E_ALL);

$a = null;

// var_dump(isset($a)) выведет bool(false),
// хотя мы переменная определенна и мы можем к ней обратиться
var_dump(isset($a));
var_dump($a);

// А для случая с неопределенной переменной
// var_dump(isset($a)) также выведет bool(false),
// но мы при обращении к ней получим PHP Warning:  Undefined variable $e in ...
var_dump(isset($e));
var_dump($e);

                
Вывод:
                
bool(false)
NULL
bool(false)

Notice: Undefined variable: e in ... on line 20
NULL

                
            

Для свойств класса ещё интересней. По умолчанию поле класса без указания типа PHP инициализирует значением NULL, а вот типизированные члены оставляет неинициализированными. Но понять определено ли свойство класса с помощью isset(), если его значение NULL, нельзя:

                
<?php
// Только показываем ошибки скрипта,
// но не записываем их в журнал ошибок.
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
ini_set('log_errors', false);
error_reporting(E_ALL);


class A
{
    public static $a;
    public static ?string $b;
    public $c;
    public ?string $d = null;
    public ?string $e;
}

// Преобразуем перехватываемые ошибки PHP в исключения
set_error_handler(
    function ($error_severity_num, $message, $file, $line)
    {
        $suppressedErrorLevel = 0;
        if (PHP_VERSION_ID >= 80000) {
            $suppressedErrorLevel = E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE;
        }
        if (error_reporting() !== $suppressedErrorLevel) {
            throw new \ErrorException($message, 0, $error_severity_num, $file, $line);
        }
    }
);

// Для нетипизированного статического свойства, инициализированного PHP
// var_dump(isset(A::$a)) выведет bool(false),
// хотя мы поле класса определенно и мы можем к нему обратиться
var_dump(isset(A::$a));
var_dump(A::$a);

// А при обращении к неопределенному статическому полю класса с типом
// получим PHP Fatal error:  Uncaught Error: Typed static property A::$b must not be accessed before initialization in ...
var_dump(isset(A::$b)); // false
try {
    var_dump(A::$b);
} catch (Throwable $t) {
    echo $t->getMessage() . PHP_EOL;
}

$A = new A();

// Для не типизированного свойства, инициализированного PHP
// var_dump(isset($A->c)) выведет bool(false),
// хотя мы поле клас��а определенно и мы можем к нему обратиться
var_dump(isset($A->c));
var_dump($A->c);

// var_dump(isset($A->d)) выведет bool(false),
// хотя мы поле класса определенно и мы можем к нему обратиться
var_dump(isset($A->d));
var_dump($A->d);

// При обращении к неопределенному полю класса
// получим PHP Fatal error:  Uncaught Error: Typed property A::$e must not be accessed before initialization in ...
var_dump(isset($A->e)); // false
try {
    var_dump($A->e);
} catch (Throwable $t) {
    echo $t->getMessage() . PHP_EOL;
}


                
Вывод:
                
bool(false)
NULL
bool(false)
Typed static property A::$b must not be accessed before initialization
bool(false)
NULL
bool(false)
NULL
bool(false)
Typed property A::$e must not be accessed before initialization

                
            

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

Поиск решения в интернете (в основном stackoverflow.com) показал, что по этой проблеме сложилось два диаметрально противоположных подхода. С одной стороны, высказывается мнение, что не надо проверять определённость переменной, что если у вас в коде возникла такая задача, то с ним что-то не так, что к переменным со значением NULL и неопределённым переменным следует относиться одинаково.

С другой стороны, приводятся веские аргументы в пользу того, что всё-таки надо различать неопределённую переменную и переменную со значением NULL. Основным, на мой взгляд, является довод о том, что сам язык PHP строго различает неопределённые переменные, выбрасывая ошибку при обращении к ним, и переменные со значением NULL.

К счастью, кроме простого высказывания своего мнения, коллеги выкладывали и решения: Check if a variable is undefined in PHPHow to check in PHP if a variable is set and is_nullCheck if value isset and nullHow check if object property is explicitly set (even to NULL!). С удовольствием делюсь найденным.

Чтобы проверить определена ли обычная переменная, можно использовать результат выполнения выражения array_key_exists($variableName, get_defined_vars()) в нужном контексте:

                
<?php
$a = null;

// Теперь мы точно знаем определена ли переменная в текущем контексте
// и нам всё-равно какое у неё значение
var_dump(array_key_exists('a', get_defined_vars()));    // true
var_dump(array_key_exists('z', get_defined_vars()));    // false

                
Вывод:
                
bool(true)
bool(false)

                
            

Здесь мы при помощи встроенной функции get_defined_vars() получаем многомерный массив всех определённых переменных в той области видимости, в которой функция была вызвана. При этом индексом элементов верхнего уровня служат имена инициализированных переменных. И чтобы узнать определена ли переменная, нам надо проверить существует ли индекс с её именем в этом массиве.

К сожалению, выражение array_key_exists('a', get_defined_vars()) нельзя обернуть в именованную функцию, т.к. метод get_defined_vars() внутри функции выдаст список переменных, доступных только в контексте тела функции. Поэтому использовать это выражение нужно как есть в том контексте, где необходимо проверить определённость ли переменной.

Понятно, что с помощью функции array_key_exists() узнаём определён ли элемент массива, даже если его значение Null.

Важно заметить, что использование функции get_defined_vars() может увеличивать потребление памяти и в худшем случае удваивать его.

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

Для того, чтобы узнать определено ли поле объекта с указанным типом, можно воспользоваться свойством isInitialized объекта ReflectionProperty, примерно вот так (new ReflectionProperty($obj, $propertyName))->isInitialized($obj), где переменная $obj — ссылка на проверяемый объект, а $propertyName — строка с именем поля:

                
<?php
/**
 * @throws ReflectionException If the $object doesn't exist.
 */
function isObjectPropertyDefined(Object $obj, string $propertyName): bool
{
    return (new ReflectionProperty($obj, $propertyName))->isInitialized($obj);
}

class B
{
    public ?string $c = null;
    public ?string $d;
}

$b = new B();

// Теперь мы точно знаем, что опеделе��о ли свойство объекта
// и нам всё-равно какое у неё значение
var_dump(isObjectPropertyDefined($b, 'c')); // true
var_dump(isObjectPropertyDefined($b, 'd')); // false
$b->d = null;
var_dump(isObjectPropertyDefined($b, 'd')); // true

                
Вывод:
                
bool(true)
bool(false)
bool(true)

                
            

Этот же функционал PHP можно использовать для проверки на определённость типизированного статического свойства класса. Но так как проверяем не объект, а класс, то нужно делать так (new ReflectionProperty($className, $propertyName))->isInitialized(), где переменная $className — строка имени класса.

                
<?php
/**
 * @throws ReflectionException If class doesn't exist.
 */
function isClassPropertyDefined(string $className, string $propertyName): bool
{
    return (new ReflectionProperty($className, $propertyName))->isInitialized();
}

class B
{
    public static ?string $e;
    public static ?string $f = null;
}

// Статическое свойства
var_dump(isClassPropertyDefined('B', 'e')); // false
var_dump(isClassPropertyDefined('B', 'f')); // true

B::$e = null;
var_dump(isClassPropertyDefined('B', 'e')); // true
                
Вывод:
                
bool(false)
bool(true)
bool(true)

                
            

Надо отметить, что объекты ReflectionProperty работают довольно медленно. Поэтому попытался найти другие варианты. И для полей объекта это удалось. Можно воспользоваться функцией get_object_vars, которая возвращает массив свойств объекта, при этом неинициализированные типизированные свойства в него не включаются. Т.е. для проверки определённости поля объекта пользуемся результатом выражения array_key_exists($propertyName, get_object_vars($obj)). Работает примерно в два раза быстрее, чем вариант с ReflectionProperty, но, в свою очередь, может увеличить потребление памяти пропорционально числу проверенных объектов (у меня в небольших тестовых скриптах не выявилось). Также обратите внимание, что возвращаемый get_object_vars массив включает в себя и приватные поля объекта.

                
<?php
function isObjectPropertyDefined(Object $obj, string $propertyName): bool
{
    return array_key_exists($propertyName, get_object_vars($obj));
}

class B
{
    public ?string $c = null;
    public ?string $d;
    private ?string $e;
}

$b = new B();

// Теперь мы точно знаем, что опеделено ли свойство объекта
// и нам всё-равно какое у неё значение
var_dump(isObjectPropertyDefined($b, 'c')); // true
var_dump(isObjectPropertyDefined($b, 'd')); // false
var_dump(isObjectPropertyDefined($b, 'e')); // false
$b->d = null;
var_dump(isObjectPropertyDefined($b, 'd')); // true
var_dump(isObjectPropertyDefined($b, 'e')); // false
                
Вывод:
                
bool(true)
bool(false)
bool(false)
bool(true)
bool(false)

                
            

Тестирование скорости работы с ReflectionProperty:

                
<?php

function isObjectPropertyDefined(Object $obj, string $propertyName) {
	return (new ReflectionProperty($obj, $propertyName))->isInitialized($obj);
}


class B
{
    public ?string $c = null;
    public ?string $d;
}

$b = new B();

echo 'Используем память: ' . memory_get_usage() . PHP_EOL;

$start_time = microtime(true);

for($i = 0; $i < 1000000; $i++) {
    $varBool = isObjectPropertyDefined($b, 'd');
}

$execution_time = microtime(true) - $start_time;

echo 'Используем память: ' . memory_get_usage() . PHP_EOL;
echo 'Время выполнения: ' . round($execution_time, 15, PHP_ROUND_HALF_UP)  . PHP_EOL;



                
Вывод:
                
Используем память: 375944
Используем память: 375976
Время выполнения: 0.4037139415741

                
            

Тестирование скорости работы с get_object_vars:

                
<?php

function isObjectPropertyDefined(Object $obj, string $propertyName) {
    return array_key_exists($propertyName, get_object_vars($obj));
}


class B
{
    public ?string $c = null;
    public ?string $d;
}

$b = new B();

echo 'Используем память: ' . memory_get_usage() . PHP_EOL;

$start_time = microtime(true);

for($i = 0; $i < 1000000; $i++) {
    $varBool = isObjectPropertyDefined($b, 'd');
}

$execution_time = microtime(true) - $start_time;

echo 'Используем память: ' . memory_get_usage() . PHP_EOL;
echo 'Время выполнения: ' . round($execution_time, 15, PHP_ROUND_HALF_UP)  . PHP_EOL;



                
Вывод:
                
Используем память: 375784
Используем память: 376120
Время выполнения: 0.2145459651947

                
            

Итак, чтобы узнать, установлено ли значение:

  • Обычной переменной надо воспользоваться выражением array_key_exists($variableName, get_defined_vars())

  • Элемента массива используем array_key_exists($itemKey, $array)

  • Свойства класса запускаем код (new ReflectionProperty($className, $propertyName))->isInitialized(), можно обернуть в функцию:

                            
    /**
     * @throws ReflectionException If class doesn't exist.
     */
    function isClassPropertyDefined(string $className, string $propertyName): bool
    {
        return (new ReflectionProperty($className, $propertyName))->isInitialized();
    }
                            
                        
  • Поля объекта можно воспользоваться кодом (new ReflectionProperty($object, $propertyName))->isInitialized($object), можно обернуть в функцию:

                            
    /**
     * @throws ReflectionException If the $object doesn't exist.
     */
    function isObjectPropertyDefined(Object $obj, string $propertyName): bool
    {
        return (new ReflectionProperty($obj, $propertyName))->isInitialized($obj);
    }
                            
                        

    или более быстрым вариантом array_key_exists($propertyName, get_object_vars($obj))

                            
    function isObjectPropertyDefined(Object $obj, string $propertyName): bool
    {
        return array_key_exists($propertyName, get_object_vars($obj));
    }