28 октября 2013 в 11:51

Расширение возможностей массива в PHP

Уровень статьи: начальный/средний

Массив в PHP — один из самых мощных типов данных. Он может работать как линейный массив (список), как ассоциативный (словарь) и как смешанный. В качестве ключа массива может использоваться либо целое число, либо строка, причем строка, если она представляет собой целое число (например, «5»), будет конвертирована в целое. Остальные типы, за исключением массивов и объектов, так же конвертируются в целое или строку — подробнее можно прочитать в документации.

Несмотря на мощные возможности базового типа array иногда хочется их расширить. Например, подобный кусок кода можно встретить, наверное, в большинстве php-проектов:

$foo = isset($array['foo']) ? $array['foo'] : null;
$bar = isset($array['bar']) ? $array['bar'] : null;


Один из способов сделать этот код короче и элегантней — использовать короткую запись тернарного оператора:

$foo = $array['foo'] ? : null;
$bar = $array['bar'] ? : null;


Но такой код выкинет PHP Notice в случе, когда ключ не определен, а я стараюсь писать максимально чистый код — на сервере разработки выставлено error_reporting = E_ALL. И именно в подобных случаях на помощь приходит ArrayObject — класс, к объектам которого можно обращаться используя синтаксис массивов и позволяющий изменять поведение используя механизм наследования.

Рассмотрим несколько примеров изменения поведения.



В проекте, над которым я сейчас работаю, мы используем следующие базовые наследники ArrayObject:
  • DefaultingArrayObject — возвращает значение по умолчанию, если ключ не определен в массиве;
  • ExceptionArrayObject — бросает исключение, если ключ не определен в массиве;
  • CallbackArrayObject — значения массива являются функциями (замыканиями), которые возвращают некое значение.


DefaultingArrayObject



Этот тип массива ведет себя примерно как словарь в Python при вызове dict.get(key, default) — если ключ не определен в массиве — возвращается значение по умолчанию. Это отлично работает в случае, когда значения по умолчанию у всех элементов, к которым мы обращаемся одинаковые, и не так элегантно, когда мы хотим получать разные значения в случае отсутствия ключа. Полный листинг этого класса выглядит следующим образом:

Листинг класса DefaultingArrayObject
class DefaultingArrayObject extends \ArrayObject  
{
    protected $default = null;

    public function offsetGet($index)
    {
        if ($this->offsetExists($index)) {
            return parent::offsetGet($index);
        } else {
            return $this->getDefault();
        }
    }

    /**
     * @param mixed $default
     * @return $this
     */
    public function setDefault($default)
    {
        $this->default = $default;
        return $this;
    }

    /**
     * @return mixed
     */
    public function getDefault()
    {
        return $this->default;
    }
}



Используя этот класс, можно переписать код, который я использовал в качестве примера, следующим образом:

$array = new DefaultingArrayObject($array);
$foo = $array['foo'];
$bar = $array['bar'];


В случае разных значений по-умолчанию будет выглядеть не так красиво, и далеко не факт что эта запись лучше использования полной тернарной записи — просто покажу как это можно сделать (PHP 5.4+):

$array = new DefaultingArrayObject($array);
$foo = $array->setDefault('default for foo')['foo'];
$bar = $array->setDefault('default for bar')['bar'];


ExceptionArrayObject



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

if (isset($array['foo']) && isset($array['bar'] && isset($array['baz'])) {  
    // logic that uses foo, bar and baz array values
} else {
    // logic that does not use foo, bar and baz array values
}


Можно переписать следующим образом:

$array = new ExceptionArrayObject($array);
try {
    // logic that uses foo, bar and baz array values
} catch (UndefinedIndexException $e) {
    // logic that does not use foo, bar and baz array values
}


Листинг класса ExceptionArrayObject
class ExceptionArrayObject extends \ArrayObject  
{
    public function offsetGet($index)
    {
        if ($this->offsetExists($index)) {
            return parent::offsetGet($index);
        } else {
            throw new UndefinedIndexException($index);
        }
    }
}


class UndefinedIndexException extends \Exception  
{
    protected $index;

    public function __construct($index)
    {
        $this->index = $index;
        parent::__construct('Undefined index "' . $index . '"');
    }

    /**
     * @return string
     */
    public function getIndex()
    {
        return $this->index;
    }
}



CallbackArrayObject



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

$array = new CallbackArrayObject([
    'foo' => function() {
        return 'foo ' . uniqid();
    },
    'bar' => function() {
        return 'bar ' . time();
    },
]);

$foo = $array['foo']; // "foo 526afed12969d"
$bar = $array['bar']; //  "bar 1382743789" 


Листинг класса CallbackArrayObject
class CallbackArrayObject extends \ArrayObject  
{
    protected $initialized = array();

    public function __construct(array $values)
    {
        foreach ($values as $key => $value) {
            if (!($value instanceof \Closure)) {
                throw new \RuntimeException('Value for CallbackArrayObject must be callback for key ' . $key);
            }
        }
        parent::__construct($values);
    }

    public function offsetGet($index)
    {
        if (!isset($this->initialized[$index])) {
            $this->initialized[$index] = $this->getCallbackResult(parent::offsetGet($index));
        }
        return $this->initialized[$index];
    }

    protected function getCallbackResult(\Closure $callback)
    {
        return call_user_func($callback);
    }
}



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

$array = new ConfigurableCallbackArrayObject([
    'foo' => function($config) {
        return 'foo ' . $config['foo'];
    },
    'bar' => function($config) {
        return 'bar ' . $config['bar'];
    },
]);
$array->setConfig(['foo' => 123, 'bar' => 321]);

$foo = $array['foo']; // "foo 123"
$bar = $array['bar']; //  "bar 321" 


Листинг класса ConfigurableCallbackArrayObject
class ConfigurableCallbackArrayObject extends CallbackArrayObject
{
    protected $config;

    protected function getCallbackResult(\Closure $callback)
    {
        return call_user_func($callback, $this->getConfig());
    }

    public function setConfig($config)
    {
        $this->config = $config;
    }

    public function getConfig()
    {
        return $this->config;
    }
}



Это все, что я хотел рассказать о примерах использовании ArrayObject. Думаю необходимо упомянуть, что как и во всем, при использовании ArrayObject нужно знать меру и понимать, когда использование изменяющих поведение массива классов оправдано, а когда проще просто вставить дополнительную проверку или пару лишних строк логики непосредственно в основной алгоритм, а не инкапсулировать их во вспомогательные классы. Иными словами — не плодить дополнительные сущности без необходимости.
Автор: @krig
Lamoda.ru
рейтинг 23,87
Компания прекратила активность на сайте

Комментарии (30)

  • 0
    $array = new CallbackArrayObject([
        'foo' => function() {
            return 'foo ' . uniqid();
        },
        'bar' => function() {
            return 'bar ' . time();
        },
    ]);
    
    $foo = $array['foo']; // "foo 526afed12969d"
    $bar = $array['bar']; //  "bar 1382743789" 
    

    Очень страшно!) Никак не ожидаешь что простой вывод данных из массива может потянуть за собой кучу действий, о которых я могу только гадать (запросы в БД, запись/удаление файлов и т.д.).

    public function offsetGet($index)
        {
            if (!isset($this->initialized[$index])) {
                $this->initialized[$index] = $this->getCallbackResult(parent::offsetGet($index));
            }
            return $this->initialized[$index];
        }
    


    Здесь если метод getCallbackResult вернет null, то повторно будет выполнен код, isset()
    Лучше использовать array_key_exists(), так точно отработает один раз.
    • +3
      Количество дополнительных проверок уже зивисит от конкретной задачи, в которой планируется применять эти структуры. В каких-то случаях может понадобиться объединить CallbackArrayObject и DefaultingArrayObject/ExceptionArrayObject. В статье показаны только возможности.
  • +8
    Если такое встретить в коде то совсем непонятно что тут происходит
    $array = new DefaultingArrayObject($array);
    $foo = $array->setDefault('default for foo')['foo'];
    

    Мне куда понятней было бы увидеть:
    $array = new DefaultingArrayObject($array);
    $foo = $array->get('foo', 'default for foo');
    
    • +2
      Согласен, и я указал что это не самая лучшая форма записи. То, что так можно делать, совсем не значит что так делать нужно. Насчет вашего варианта — это уже не работа с массивом, а работа с объектом и это уже немного другая история — такое можно организовать и не на базе ArrayObject, а просто классом, инкапсулирующим массив.
    • –1
      Каждый под себя что-то делает

      Я, например, использую что-то вроде:
      <?php
      
          function valueFromArray(array $data, $map, $default = '')
              {
              if(!is_array($map))
                  {
                  $map = array($map);
                  }
      
              foreach($map as $m)
                  {
                  if(is_array($data))
                      {
                      if(array_key_exists($data[$m]))
                          {
                          $data = $data[$m];
      
                          continue;
                          }
                      else
                          {
                          $data = $default;
      
                          break;
                          }
                      }
                  break;
                  }
      
              return $data;
              }
      
      
      echo valueFromArray($array, 'name');
      
      echo valueFromArray($array, 'email', 'empty');
      
      echo valueFromArray($array, array('settings', 'auth'), 'default');
      ?>
      
  • 0
    Отличная статья! Ничего кроме первых двух примеров не знал. Очень полезно, спасибо.
  • +2
    думаю лучше будет так

    protected function getCallbackResult(\Closure $callback)
    {
        return $callback();
    }
    


    или так

    protected function getCallbackResult(callable $callback)
    {
        return call_user_func($callback);
    }
    
    • 0
      Чем лучше?
      • +2
        Если использовать тайп хинт Closure, то лаконичнее запись $callback().
        Если использовать тайп хинт callable, то можно передавать не только анонимные функции, но и такие штуки:
        call_user_func('my_callback_function');
        call_user_func(array('MyClass', 'myCallbackMethod')); 
        call_user_func(array($obj, 'myCallbackMethod'));
        call_user_func('MyClass::myCallbackMethod');
        
        • 0
          Ок, согласен. Спасибо.
    • 0
      Хинт callable можно использовать только с 5.4+, а поскольку 5.3 будет официально поддерживаться еще до 2014-07, то, возможно, рановато отказываться от его поддержки в приложениях. Плюс к этому неплохо было бы проверить на то, а валидный ли колбек передали. С учетом сказанного, я бы переписал это вот так:

      protected function getCallbackResult($callback)
      {
      if (is_callable($callback)) {
      return call_user_func($callback);
      }
      throw new \Exception('Invalid callable provided');
      }

      Разумеется, что можно изменить Exception — на свой тип, или же просто возвращать null.

      Кстати, этот метод имеет один, возможно, неприятный побочный эффект. А именно: внутри класса is_callable() будет true даже для private и protected методов. Соответственно существует возможность передать туда array($classInstance, 'privateOrProtectedMethod') и оно будет успешно выполнено. Так что нужно иметь ввиду данную особенность.
  • +1
    Очень спорная реализация, но идея наследования от базовых классов для заточки их под себя оправдывает написание статьи.
  • +2
    К моему стыду крайнему удивлению, все мои эксперименты попытки внедрить SPL/ArrayObject в реальный проект заканчивались примерно таким вот образом:

    <?php
    $a = new ArrayObject();
    echo (int)is_array($a);
    

    0
    • +2
      Да, есть такое, но оно и не удивительно — массив это отдельный тип данных.

      Насчет внедрения — я считаю что переход от массивов к ArrayObject дело, в общем случае, не то чтобы не полезное, но даже вредное. Использовать объекты имеет смысл только в отдельных конкретных случаях, когда есть понимание, что с ними будет проще, чем без них.
  • +1
    Тут еще подводный камень в том, что стандартные array_XXXXX функции не будут работать с этим объектом. Нужно кастать к array, но и на выходе получим array, а не arrayObject. Так что если использовать arrayObject, то нужно использовать его везде.
    • +1
      Скопирую свой комментарий чуть выше:

      Насчет внедрения — я считаю что переход от массивов к ArrayObject дело, в общем случае, не то чтобы не полезное, но даже вредное. Использовать объекты имеет смысл только в отдельных конкретных случаях, когда есть понимание, что с ними будет проще, чем без них.
    • +1
      Тут чем дальше под воду, тем толще камни.
      is_array("scalar") // false;
      is_array((array)"scalar") // true; счастливой отладки
      

      В результате — с SPL/ArrayObject приходится четко мониторить, чтобы порожденные объекты не выходили за пределы «своего» кода и не пытались использоваться каким-либо другим образом.
      • 0
        а что собственно в вашем примере «камни»?
        (array)«scalar» в данном случае преобразуется в array(0 => 'scalar')
        • +1
          Предположим, что переменная не объявлена парой строчек выше, а приходит извне (и мы ожидаем массив), и нам ее нужно передать в функцию array_foo().

          function foo1($bar){
            return array_merge(array(1), $bar);
          }
          

          До приведения:
          * если $bar — массив, все ок
          * если $bar is ArrayObject — получаем варнинг
          * иначе — получаем варнинг

          После приведения:
          * если $bar — массив, все ок
          * если $bar is ArrayObject — все ок
          * иначе — счастливой отладки

          В итоге код начинает пестрить костылями типа ($foo instanceof ArrayObject) || is_array($foo)
    • 0
      <?php
          public function __call($func, $argv)
          {
              if (!is_callable($func) || substr($func, 0, 6) !== 'array_')
              {
                  throw new BadMethodCallException(__CLASS__.'->'.$func);
              }
              return call_user_func_array($func, array_merge(array($this->getArrayCopy()), $argv));
          }
      ?>
      

      это по поводу нерабочих array_* функций. Не панацея, но всё же)
      • 0
        Написать свои методы не проблема. Я про то, что array_values($obj) по прежнему не будет работать.
        Нужно понимать, что в данный момент мы работаем с объектом, а не с массивом. Я это хотел сказать.
        • 0
          Если применить шаблон проектирования «Костылинг», то можно воспользоваться override_function и переопределить все нужные функции.
          • 0
            При этом нужно учеть, что оно "(PECL apd >= 0.2)".
  • 0
    Что нужно чтобы ArrayObject использовать в реальных проектах?

    1. Нужно чтобы функции работы с массивами работали с ArrayObject также, как и с массивами
    2. Обычный массив должен иметь поведение идентичное простейшей реализации ArrayObject (http://www.php.net/manual/ru/class.arrayobject.php ). Вплоть до
    ([] instanceof ArrayObject) === true;
    

    3. Было бы классно, если бы это не повлияло на производительность.
    4. Единственный способ отличить обычнй массив от кастомизированного: $array instanceof MyArrayObject

    Но меня терзают смутные сомнения по поводу совместимости таких нововведений с существующими расширениями, т.ч. это будет не скоро, если вообще будет.
    • +1
      Этот код (не прм в таком виде, в расширенном) используется в реальном проекте. Но я, пожалуй, процитирую еще раз свою мысль:

      Насчет внедрения — я считаю что переход от массивов к ArrayObject дело, в общем случае, не то чтобы не полезное, но даже вредное. Использовать объекты имеет смысл только в отдельных конкретных случаях, когда есть понимание, что с ними будет проще, чем без них.
      • +1
        Полностью согласен. Мой комментарий не столько про код из статьи, сколько про ArrayObject в общем.
        А вы бенчмарки делали? Насколько медленнее работает?
  • 0
    В perl есть похожий механизм — tying variables, позволяющий сделать поведение простых типов неочевидным. Механизм этот старый и за долгие годы нигде особо не прижился, а все потому что отлавливать последствия операции простого добавления в массив дело очень неблагодарное. Да почти в каждом языке слаботипизированном есть подобный механизм и нигде он широко не используется, за редким исключением.

    И вообще это крайне неудобно с точки зрения поддержки кода — каждый новый программист вводимый в проект будет заново проходить по одним и тем же костылям на одних и тех же базовых операциях.

    Впрочем каждый решает для себя с чем он сможет совладать.
  • +1
    Рекомендую не писать кучу иссетов, когда они в условии собираются через &&. Проще так:
    if (isset($array['foo'], $array['bar'], $array['baz'])) {
    }
    
    • 0
      Подумалось… а операторы сравнения можно как-то сократить?
      Вроде такого: if ($val=='one' || $val=='two' || $val=='three') {… }?
      • 0
        Не всегда короче получается, но явно нагляднее:
        in_array($val, array('one', 'two', 'three'))
        

        или
        switch ($val) {
        case 'one': 
        case 'two':
        case 'three':
        // ...
        break;
        default:
        // ...
        break;
        }
        

        в зависимости от того, есть ли elseif или else.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка