Плавная миграция от массива к объекту

Источник: «Smooth migration from array to object»
Как осуществить плавную миграцию от массива к объекту в PHP: используя динамический синтаксис PHP, общий для массивов и объектов

Мне по-прежнему нужна плавная миграция от массива к объекту. В моем исходном коде есть большое количество массивов, ведущих себя как объекты. Я читал (здесь, здесь) и писал о преимуществах замены массивов на объекты в PHP. Они значительны: более высокая производительность, меньшее потребление памяти и улучшенная читаемость.

Изменение синтаксиса

Одним из основных препятствий на пути миграции является изменение синтаксиса. PHP не любит обращаться к объекту с синтаксисом массива и выдаёт предупреждение, возвращая NULL. Верно и обратное: не используйте синтаксис массива для объекта, хотя он и вызывает Fatal Error.

<?php

$a = array('b' => 1, 'c' => 2);

echo $a['b'];
echo $a->c;
//Warning: Attempt to read property "c" on array

?>

PHP известен своим динамическим синтаксисом, поэтому в его арсенале должно быть много инструментов. И они есть: PHP умеет работать с объектами с помощью синтаксиса массива. Возможно, вы слышали о нативном PHP классе ArrayObject, заставляющем объект вести себя как массив.

<?php

$a = new ArrayObject(array('b' => 1, 'c' => 2));

echo $a['b'];
echo $a->c;
//Warning: Undefined property: ArrayObject::$c

?>

Чего не хватает, так это взаимодействия со свойствами. По умолчанию синтаксис свойства $object->property устанавливает свойство, а не запись в массиве. А синтаксис массива $object['property'] устанавливает запись в массив. Нам нужно, чтобы оба синтаксиса были ориентированы на массив, поэтому нам нужно небольшое расширение.

<?php

$a = new dualArrayObject(array('b' => 1, 'c' => 2));

class dualArrayObject extends ArrayObject {

function __get($name) {
return $this[$name] ?? null;
}

function __set($name, $value) {
return $this[$name] = $value;
}
}

echo $a['b'];
echo $a->c;
echo $a->d = 3;

?>

Обратите внимание, что ArrayObject хранит массив в свойстве storage, являющемся приватным (не показано в коде выше). Это запрещает взаимодействие с этим свойством. В то же время синтаксис массива уже доступен в $this, поэтому мы можем его использовать.

$this['index'] можно с удивлением обнаружить в исходном коде. Это старое поведение из PHP 4, которое было запрещено по умолчанию позже. И вот оно снова возвращается к нам, с более явным кодом. Это здорово.

Использование ArrayObject для миграции

ArrayObject делает доступными синтаксис массива и объекта для одних и тех же данных. По умолчанию он реализует интерфейсы IteratorAggregate, ArrayAccess, Serializable, Countable. Благодаря этому объект можно использовать в foreach(), синтаксисе массива (уже рассмотренном), serialize() и count().

Это самые распространённые варианты использования массивов. Такое простое преобразование охватывает множество случаев использования. Теперь это означает, что остальная часть кода может свободно переходить от одного синтаксиса к другому. Это открывает путь для периода миграции.

Как только код будет переведён на новый синтаксис, этот патч можно будет удалить. ArrayObject станет простым объектом, а все предыдущие синтаксисы массивов теперь недействительны. В случае возникновения каких-либо оставшихся проблем в логах будет сделана запись: к этому моменту они должны быть редкими. Они могут быть исправлены повторным внедрением кода миграции.

Закат синтаксиса массива

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

<?php

$a = new dualArrayObject(array('b' => 1, 'c' => 2));

class dualArrayObject extends ArrayObject {
// ArrayObject с предупреждением
function offsetGet(mixed $offset) {
trigger_error("Avoid using array syntax, and use the object one.", E_USER_DEPRECATED);
parent::offsetGet($offset);
}

function __get($name) {
// Триггер здесь не нужен, потому что это целевой синтаксис.
return $this[$name] ?? null;
}
}

echo $a['b'];
echo $a->c;
echo $a->d = 3;

?>

Уровень ошибки E_USER_DEPRECATED предназначен именно для таких миграций. Он должен отображаться в разрабатываемом коде, а затем логироваться в продакшн-системе. Явное сообщение даёт возможность любому человеку с правами редактирования модернизировать код. Помимо удаления сообщения об ошибке, изменение кода также ускорит его, так что это отличный стимул.

Используйте ArrayAccess вместо ArrayObject

ArrayObject удобен, поскольку предоставляет множество возможностей через реализованные интерфейсы. Когда код достаточно прост, рекомендуется реализовывать только необходимые функции.

Например, Exakat использует функцию token_get_all(), чтобы собрать все PHP-токены из токенизатора. Результатом работы функции является массив массивов или строк. Основной массив — это упорядоченный список токенов, а каждая запись — массив, описывающий токен. Иногда это строка.

<?php
$tokens = token_get_all('<?php echo; ?>');

foreach ($tokens as $token) {
print_r($token);
}

/*
Array
(
[0] => 394
[1] => <?php
[2] => 1
)
Array
(
[0] => 328
[1] => echo
[2] => 1
)
Array
(
[0] => 397
[1] =>
[2] => 1
)
ещё токены ...
*/


?>

Эти токены используются в качестве Value Object. Никаких других причудливых операций с ними, кроме доступа к индексу 0, 1 или 2, не существует. Поэтому здесь достаточно ArrayAccess. Вот упрощённая версия этого объекта:

<?php

class Token implements ArrayAccess {
public int $token;
public string $code;
public int $line;

private const OFFSETS = array(
0 => 'token',
1 => 'code',
2 => 'line',
);

function __construct($token, $code, $line) {
$this->token = $token;
$this->code = $code;
$this->line = $line;
}

public function offsetExists(mixed $offset): bool {
return in_array($offset, array_keys(self::OFFSETS));
}

public function offsetGet(mixed $offset): mixed {
if (!isset(self::OFFSETS[$offset])) {
debug_print_backtrace();
die('No such offset as '.$offset);
}
$property = self::OFFSETS[$offset];

return $this->$property;
}

public function offsetSet(mixed $offset, mixed $value): void {
die(__METHOD__);
}

public function offsetUnset(mixed $offset): void {
die(__METHOD__);
}
}
?>

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

Два метода ArrayAccess не используются, поэтому они реализованы с помощью die(). offsetUnset и offsetSet никогда не вызываются, так как exakat только считывает информацию о токенах, но не присваивает и не изменяет их. Если die слишком суров для вашего стиля кодирования, вы также можете запускать или логировать такое использование для последующей обработки.

Иногда стоит оставить эти методы реализованными: они могут обнаружить особые и редкие варианты использования, которые действительно нуждаются в рефакторинге. Это хорошая система проверки.

Другие распространённые подводные камни

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

Помимо простой замены $array['index'] на $object->property, есть и другие побочные эффекты, о которых стоит упомянуть.

ArrayObject не совместим с типом массива

Все, что было типизировано с помощью array, теперь должно быть обновлено. Как минимум, это должно быть array|MyNewObject. Это означает, что для этого необходим PHP 8.0.

<?php

function foo(array|MyNewObject $array) {
return $array[0];
}
?>

Конечно, всегда можно отбросить типизацию во время миграции, но потом придётся потрудиться, чтобы вернуть её обратно.

С другой стороны, можно заменить array на iterable, в случае если объект просматривается с помощью foreach(). iterable эквивалентен array|Traversable, поэтому, когда новый класс реализует этот интерфейс, можно смело заменять array на iterable.

Проверки типов с помощью is_array() должны быть обновлены

Помимо типов, учитывайте также, что проверка с помощью is_array() — это стоппер в коде. А вызов (array) может нарушить переход к объектам. Первое можно заменить на is_iterable() или is_array($x) или $x instanceof myNewObject, а второе требует переписывания.

Теперь функции массива необходимо избегать

Наконец, функции массивов больше не используются, по крайней мере напрямую.

В ArrayObject некоторые функции все ещё можно использовать, например, семейство *sort(). Они были перенесены как методы в класс ArrayObject. Только не ищите самих методов sort() и rsort(), их не существует (видимо, они слишком сильно влияют на индексы). Зато есть другие: asort(), uksort(), ksort(), natcasesort() и т. д.

<?php

$ao = new ArrayObject(['a', 'z', 4=> 'f']);
$ao[] = 'd';
$ao->asort();
print_r($ao);

?>

В противном случае отображается ошибка: Fatal error: Uncaught TypeError: sort(): Argument #1 ($array) must be of type array, ArrayObject given

Получение массива для любых вызовов функций массива

С другой стороны, array_keys() или array_column() больше не будут работать, по крайней мере, напрямую. Есть обходные пути.

Получить версию массива нового объекта можно с помощью таких функций, как iterator_to_array(), метод ArrayObject::getArrayCopy() или оператор преобразования (array). По сути, они преобразуют объект обратно в массив.

<?php

$ao = new ArrayObject(['a', 'b', 4=> 'c']);
$ao[] = 'd';
print_r(array_keys((array) $ao));

/*
Array
(
[0] => 0
[1] => 1
[2] => 4
[3] => 5
)
*/


print_r(array_values(iterator_to_array($ao)));
/*
Array
(
[0] => a
[1] => b
[2] => c
[3] => d)
*/


print_r(implode('.', $ao->getArrayCopy($ao)));
//a.b.c.d

?>

Заимствование функций массива

При переходе на синтаксис ООП, если в вашем коде отсутствует какая-либо функция массива, следует подумать о том, чтобы сделать её дополнительным методом.

При использовании расширения ArrayObject вам придётся получить массив из родительского класса с помощью вызова ArrayObject::getArrayCopy, поскольку хранилище является приватным свойством. С настраиваемым объектом такой доступ может быть проще, поскольку вы контролируете видимость.

<?php

class myNewObject extends ArrayObject {
function array_column(string $column) : array {
return array_column($this->getArrayCopy(), $column);
}
}


?>

Постепенный переход

Гибкий синтаксис PHP позволяет использовать объект вместе с синтаксисом массива. С помощью класса ArrayObject и некоторых других интерфейсов, таких как ArrayAccess или Traversable, можно плавно перейти от использования массива к новому объекту.

Такой подход излишен, если миграция может быть выполнена как однократный рефакторинг. Например, если вы контролируете код от начала до конца, то использование динамического синтаксиса PHP в качестве инструмента миграции удлинит время переписывания.

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

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

Плавная миграция от массива к объекту

В последних версиях PHP представление объектов стало более быстрым и эффективным, и это делает код более читабельным, чем массивы. Есть возможности для модернизации своего кода.

Не все массивы предназначены для превращения в объекты. Наиболее интересными являются массивы массивов, как в случае с token_get_all() или preg_match(). Многие родные PHP функции все ещё создают массивы, и было бы неплохо иметь больше методов, подобных mysqli_fetch_object, позволяющих напрямую получить пользовательский объект из вызова базы данных.

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

Дополнительные материалы

Предыдущая Статья

Противодействие Login CSRF с помощью Symfony

Следующая Статья

Laravel без .env файлов