Автор: Дуэйн Ривз

Дуэйн Ривз — старший инженер-программист в Facebook. Он открыто выступает за разнообразие и инклюзивность. Дуэйн был учредителем /dev/color. Дуэйн имеет степень бакалавра компьютерных наук и степень магистра инженерных наук в области компьютерных наук Массачусетского технологического института. Подпишитесь на него в Твиттере @dwaynereeves.

Первоначально опубликовано на www.hhvm.com.

«…используйте коллекции всегда и везде, где это возможно. Однако 100% использование коллекций, очевидно, нереалистично, учитывая, как много массивов используется в различных кодовых базах…просто могут быть законные варианты использования для массива».

Это руководство, данное разработчикам Hack по использованию массивов. Хотя мы по-прежнему советуем использовать коллекции, когда это возможно, в ретроспективе существует больше законных вариантов использования для массивов, чем мы думали вначале. Имея это в виду, команда планирует улучшить массивы, чтобы они лучше поддерживались в Hack. Мы открыли ряд вопросов на GitHub (#6451, #6452, #6453, #6454, #6455) с нашими первоначальными планами. Поскольку это будет значительным изменением языка, мы ждем отзывов от сообщества. Прежде чем погрузиться в то, что мы думаем построить, давайте уделим немного времени изучению проблемы, которую мы хотим решить.

Проблема с массивами в Hack

Массивы — это вездесущая структура данных в PHP, используемая для представления всего, от списков, связанных списков, наборов, кортежей или даже пакетов данных. Эта гибкость сама по себе усложняет для Hack понимание того, как будет использоваться массив. Рассмотрим пример ниже. Следует ли рассматривать $arr как массив, похожий на карту, который содержит как int, так и строки, или массив, подобный фигуре, поле id которого равно int, а name — строка? В настоящее время всякий раз, когда средство проверки типов сталкивается с подобной двусмысленностью, оно ошибается, не сообщая об ошибках и полагая, что программист знает, что он делает.

$arr = [
   'id' => 4,
   'name' => 'mark',
 ];
 $name = $arr['name'];
 $arr[$name] = ucfirst($name);

Если бы это была единственная проблема с массивами PHP, то решение было бы «простым»; сделать проверку типов умнее (над чем мы работаем). Однако есть ряд других семантических деталей вокруг массивов, которые практически невозможно проанализировать статически.

Индексация несуществующих ключей

Во многих языках попытка проиндексировать несуществующий ключ вызывает исключение и выполнение останавливается. Вместо того, чтобы останавливать выполнение, PHP вызовет E_NOTICE и вернет null. Средство проверки типов сталкивается с трудным решением: следует ли рассматривать любую операцию с индексом как производящую потенциально значение, допускающее значение NULL, или игнорировать эту возможность? Hack сегодня предпочитает игнорировать тот факт, что индексирование массива может дать нулевое значение, что позволяет потенциальным ошибкам оставаться незамеченными.

Ключевое принуждение

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

function expects_string(string $s): void {}
 function will_fail_at_runtime(): void {
   // Hack believes $arr is type array<string, int>
   $arr = array('123' => 123);
  
   foreach ($arr as $k => $_) {
     // The string '123' will be changed to int(123), triggering a
     // TypeHintException at runtime
     expects_string($k);
   }
 }

Массивы, содержащие ссылки

Более тонкой семантической деталью является поведение массивов при хранении ссылок. В частности, в некоторых случаях ссылка будет сведена к значению, если массив содержит единственную ссылку на это значение. Это поведение продемонстрировано ниже.

$x = 0;
 $arr1 = array(&$x);
 $arr2 = $arr1;
 $arr2[0]++;
 var_dump($arr1, $arr2);
 /* Changes in $arr2 reflected in $arr1
 array(1) {
   [0]=>
   &int(1)
 }
 array(1) {
   [0]=>
   &int(1)
 }
 */
 unset($x);
 $arr2 = $arr1;
 $arr2[0]++;
 var_dump($arr1, $arr2);
 /* Changes in $arr2 no longer reflected in $arr1
 array(1) {
   [0]=>
   int(1)
 }
 array(1) {
   [0]=>
   int(2)
 }
 */

Здесь мы начинаем с сохранения ссылки на $x внутри массива. Затем мы присваиваем массив другой переменной $arr2 и увеличиваем значение, хранящееся в массиве. Поскольку мы сохранили ссылку, изменение будет отражено как в $arr1, так и в $arr2. Но если мы запустим тот же код после сброса $x, мы получим другой результат. Когда мы переназначаем $arr1 на $arr2, счетчик ссылок $arr1[0] падает до единицы и сглаживается до значения. Теперь изменения в $arr2 больше не отражаются в $arr1. Это снова поведение, на которое средство проверки типов закрывает глаза при работе с массивами.

Законные варианты использования

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

Значения подходят для дополнительной оптимизации

Чтобы понять, почему это так, вы должны быть знакомы с моделью исполнения HHVM, унаследованной от PHP. PHP имеет модель без общего доступа, в которой данные не передаются между запросами. После каждого запроса все выделенные объекты сбрасываются, и следующий запрос начинается с чистого листа. Рассмотрим следующий код:

class MyConfig {
   private static Map<int, string> $collection = ImmMap {
     ... // statically map 100 ints to some string
   };
  
   private static array<int, string> $array = array(
     ... // same mapping as above
   );
 }

В большинстве языков статический инициализатор переменной запускается один раз при запуске программы. Однако, поскольку HHVM требуется для очистки мира для каждого запроса, для каждого запроса запускаются статические инициализаторы. HHVM создает ImmMap коллекции MyConfig::$ для каждого запроса, который может занимать нетривиальное количество ресурсов ЦП. Поскольку массивы являются типами значений, HHVM не нужно реконструировать MyConfig::$array для каждого запроса. Вместо этого HHVM может инициализировать массив один раз при запуске и повторно использовать один и тот же массив для всех запросов (при условии, что массив содержит только типы значений).

По тем же причинам хранение и извлечение коллекции из APC обходится дороже, чем то же самое для массива. Учитывая эти разветвления производительности, разумно, что кто-то предпочтет использовать массив вместо коллекции в этих случаях.

Значения менее подвержены ошибкам

Коллекции по умолчанию изменяемы. Есть случаи, когда такое поведение желательно, но в значительной степени изменчивость делает код более трудным для понимания. Рассмотрим следующий код:

abstract final class FriendFetcher {
  
   /* Memoize the list of friends we fetch for the user */
   <<__Memoize>>
   public static async function forUser(int $id): Awaitable<Set<int>> {
     $ids = await fetch_friend_ids_from_backend($id);
     return new Set($ids);
   }
  
   public static async function mutualFriends(
     int $id1,
     int $id2,
   ): Awaitable<Set<int>> {
     // fetch both set of friends
     list($friends1, $friends2) = await genva(
       self::forUser($id1),
       self::forUser($id2),
     );
    
     // Union the two friend sets and retain only those
     // that appear in both sets
     return $friends1
       ->addAll($friends2)
       ->retain(
         $friend ==>
           $friends1->contains($friend) &&
           $friends2->contains($friend)
       );
   }
 }

Этот код вычисляет набор общих друзей между пользователями. При этом используется простой алгоритм получения набора друзей обоих пользователей и их пересечения. Однако в этом коде есть небольшая ошибка. FriendFetcher::forUser имеет аннотацию ‹‹__Memoize›› . Это кэширует результат метода для данного идентификатора пользователя, экономя затраты на многократную выборку из бэкэнда для одного и того же результата. Это возвращает один и тот же объект Set для данного пользователя. FriendFetcher::mutualFriends использует Set::addAll для объединения двух наборов друзей вместе, изменяя Set вместо возврата нового объекта Set. Это тот же объект Set, который кэшируется с помощью аннотации ‹‹__Memoize››. В следующий раз, когда мы вызовем FriendFetcher::forUser для $id1, возвращаемый набор также будет содержать друзей $id2.

Существуют механизмы для обхода этого, такие как использование ConstSet для скрытия метода Set::addAll, использование ImmSet, чтобы сделать добавление чего-либо в набор ошибкой, или информирование средства проверки типов об этой потенциальной ошибке и предупреждение о ней. Хотя они решили бы эту конкретную проблему, это иллюстрирует опасность использования по умолчанию изменяемых коллекций. Мы рассматривали возможность замены Set на ImmSet и введения типа MutSet, но это сопряжено со своими сложностями. Когда программист хочет изменить набор, ему нужно скопировать неизменяемый набор в изменяемый набор, а затем не забыть снова сделать набор неизменяемым. В этих случаях желательно поведение копирования при записи массивов, поскольку оно позволяет писать код при работе с изменяемыми данными, но сохраняет его локальным для функции. Когда массив передается в функцию или возвращается из нее, у программиста есть гарантия, что он не будет изменен, если он явно не передан по ссылке.

О ценностях легче рассуждать

Еще одним следствием изменяемой ссылочной семантики коллекций является то, что их дженерики являются инвариантными. Что это значит? Рассмотрим следующий код:

function expects_vector_of_mixed(Vector<mixed> $vec): void {
  ...
 }
 function expects_vector_of_string(Vector<string> $vec): void {
   // Hack Error!!!
   expects_vector_of_mixed($vec);
 }

Передача Vector‹string› функции, ожидающей Vector‹mixed›, не разрешена проверкой типов. Это связано с тем, что expects_vector_of_mixed может модифицировать $vec, добавляя anint, и теперь наша Vector‹string› больше не содержит только строки. Хотя мы должны предотвратить эту ошибку, для многих программистов она кажется неинтуитивной.

Поскольку массивы являются значениями, мы можем избежать этой проблемы. Когда массив передается другой функции, мы знаем, что функция не может изменить массив, который мы ей передали (при условии, что вы не используете ссылки). Варианты параметров типа массива являются ковариантными, что означает, что можно безопасно передавать массив‹строку› функции, которая ожидает массив‹смешанный›.

Запланированные улучшения

Учитывая компромиссы между массивами и коллекциями, мы изучаем третий подход, который сочетает в себе достоинства массивов и коллекций. Это будет тип значения, подобный массивам, но с чистой семантикой коллекций. Подробности об этих новых типах массивов обсуждаются и конкретизируются в различных выпусках на GitHub. Вот схема того, что мы думаем построить.

  • #6451 — ввести тип массива vec‹T› для отражения класса коллекции Vector‹T›.
  • #6452 — ввести тип массива dict‹Tk, Tv› для отражения класса коллекции Map‹Tk, Tv›
  • #6453 — ввести тип массива набора ключей‹T›, чтобы отразить класс коллекции Set‹T›.
  • #6454 — более строгая семантика для индексации этих типов массивов, а также добавлена ​​поддержка безопасного доступа к потенциально несуществующим ключам.
  • #6455 — новый синтаксис сахара для исключения вложенных вызовов функций.

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

Глядя дальше

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

PHP-массивы для PHP API

Массивы Hack заменят массивы PHP в качестве предлагаемого контейнера типов значений в Hack, но массивы PHP по-прежнему будут полезны при интеграции с кодом PHP. Например, при создании файла .hhi для библиотеки PHP, которая ожидает массивы PHP.

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

  • Заставьте индексирование массива PHP создать тип, допускающий значение NULL
  • Заставьте сохранение строкового ключа изменить тип ключа массива PHP на arraykey
  • Сделать массивы PHP и Hack несовместимыми друг с другом

Перемещение коллекций из среды выполнения в библиотеку

Hack arrays освободит API коллекций от точной замены массивов. Это позволило бы нам лучше использовать тот факт, что это объекты, а не значения. Одна из идей состоит в том, чтобы перенести реализацию коллекций из встроенной HHVM в библиотеку, написанную на Hack. Это снизит барьер для добавления новых функций/возможностей в коллекции. Например, поддержка объектов в качестве ключей для Map или Set или создание специализированных коллекций, таких как IntMap, оптимизированных для хранения целочисленных ключей.

/dev/color – это сообщество чернокожих инженеров-программистов, которые помогают друг другу в достижении карьерных целей. Чтобы узнать больше, посетите наш веб-сайт и подпишитесь на наш блог и аккаунт в Твиттере.