Шлифуя код своего пакета 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 PHP, How to check in PHP if a variable is set and is_null, Check if value isset and null, How 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)); }
