Звучит слишком громко? Давайте уточним, чтобы избежать обманутых ожиданий: этот пакет использует немного магии вне Хогвартса, и будет действительно полезен любителям строгой типизации в PHP.

  1. Введение

  2. Проблемы слабой типизации в PHP

  3. Стандартные подходы приведения к типу

  4. PHP Typed: утилита для приведения к типу

  5. PHP Typed: Примеры использования

  6. Заключение

1. Введение

Всем привет! С вами WPLake [ссылка удалена мод.], агенство по WordPress разработке.

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

В этом посте речь пойдёт о PHP Typed — Composer пакете (wplake/typed), который мы опубликовали на этой неделе. Давайте разбираться, что к чему.

2. Проблемы слабой типизации в PHP

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

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

К примеру:

function getUserData($id) {}

Что здесь происходит? Ожидает ли функция целое число в качестве $id, или, может быть, токен-строку? Что она возвращает? Объект? Массив? Это невозможно опеределить без ознакомления с реализацией.

Именно поэтому и появился PHPDoc:

/**  
 * @var int $id  
 * @return array  
 */  
function getUserData($id) {}  

Отлично, так гораздо лучше. PHPDoc стал фактическим стандартом для документирования кода в коммерческих проектах. Более того, начиная с PHP 7.4, сам язык сделал значительный шаг вперёд в поддержке строгой типизации.

Но несмотря на это, мы по-прежнему сталкиваемся с множеством проблем, связанных с типами. Давайте рассмотрим пару примеров распространённых громоздких конструкций, написанных при работе с нетипизированными переменными:

function getUserAge(array $userData): int
{
    return true === isset($userData['meta']['age']) &&
           true === is_numeric($userData['meta']['age'])
           ? (int)$userData['meta']['age']
           : 0;
}

function upgradeUserById($mixedUserId): void
{
    $userId = true === is_string($mixedUserId) || 
    true === is_numeric($mixedUserId)
        ? (string)$mixedUserId
        : '';
}

Хотя сами по себе эти функции бесполезны, они очевидно демонстрируют избыточность кода и потерю ясности, которые часто возникают при работе с нетипизированными переменными.

"Минутку. Почему просто не объявить тип для $mixedUserId? Почему не добавить PHPDoc-комментарии, чтобы описать ключи в $userData?" — глядя на эти примеры, возможно, думаете вы.

В идеальном мире, да, мы бы объявили строчный тип для $mixedUserId, а $userData был бы либо экземпляром класса, либо массивом с ключами, описанными в PHPDoc. Однако реальность такова, что программное обеспечение не пишется одним разработчиком и не ограничивается сотней строк кода. В проекте всегда есть сторонние компоненты, внешние библиотеки, поставщики и обширные легаси-кодовые базы.

Когда дело касается массивов, у нас есть ощущение, что эта проблема будет существовать ещё долгие годы. Почти невозможно гарантировать фиксированную структуру массива — достаточно вспомнить $_SERVER, где значения могут меняться в зависимости от окружения, не говоря уже о пользовательских данных из $_GET и $_POST.

Однако довольно о проблемах — давайте поговорим о решениях.

3. Стандартные подходы приведения к типу

Итак, как мы можем справится с задачей приведения к типу? Существует три распространённых подхода, доступных из коробки:

3.1) Использование isset и проверки типа

Как мы уж видели выше:

$age = true === isset($userData['meta']['age']) &&
           true === is_numeric($userData['meta']['age'])
           ? (int)$userData['meta']['age']
           : 0;

Этот подход создает избыточный и неясный код даже на таком простом примере.

3.2) Использование оператора объединения с null (??) и проверки типа

$number = $data['meta']['age'] ?? 10;
$number = true === is_numeric($number) ? (int)$number : 10;

Что же происходит здесь? В итоге мы получаем две строки кода, потому что оператор объединения с null не решает проблему проверки типа. Более того, при использовании своего значения по умолчанию с данным оператором мы вынуждены дублировать его значение.

3.3) Использование оператора объединения с null и явного приведения к типу

$number = (int) ($data['meta']['age'] ?? 10);

Вот это дело! Коротко и ясно! Возможно, кто-то из вас сейчас подумает: «Эх, зря я трачу время на чтение вашей статьи».

Тогда наш ответ будет: Подождите минутку. Давайте разберёмся, что здесь на самом деле происходит. Эта строка состоит из двух частей:

Часть 1: $data['meta']['age'] ?? 10

Мы безопасно проверяем наличие переменной и подставляем значение по умолчанию, если её нет. Отлично, идём дальше.

Часть 2: (int) {resultFromNullCoalescing}

Здесь мы просим PHP привести элемент массива (или значение по умолчанию) к целому числу. Это кажется очевидным, но давайте задумаемся, к какому типу переменной мы на самом деле применяем приведение к int.

Упс, но на самом деле мы не знаем тип.

Что? Да, при написании этого кода вы, вероятно, «ожидали», что элемент [meta][age] будет либо целым числом, либо хотя бы строкой с числовым значением. Но можете ли вы это гарантировать? В реальном мире ответ — нет. В больших приложений есть бесчисленное количество ветвлений логики, и одно из них может изменить тип этого значения.

Более того, даже если сейчас это целое число, кто знает, что будет завтра? Зависимости обновились, и теперь поле age — это объект с несколькими свойствами. Бум! Теперь данная строка вызывает фатальную ошибку, потому что PHP не может преобразовать объект в строку, если он явно не реализует метод __toString.

Еще один случай: преобразование массива в строку

$string = (string)($array['meta']['location'] ?? 'Default one');

Когда значение 'location' оказывается массивом вместо ожидаемой строки, скрипт:

  • Сгенерирует предупреждение (notice)

  • Продолжит выполнение, используя строку со значением 'Array'

Хотя очевидно, что данные используются некорректно, в этом случае правильная логика должна была бы воспользоваться значением по умолчанию — 'Default one'.

"Это не моя вина. Пусть будет фатальная ошибка, пусть будет поломанная логика."
(Скажет какой-нибудь беспечный разработчик.)

Но должна ли это действительно быть фатальная ошибка? Если это всего лишь небольшая ветка кода, отображающая пару незначительных описаний на экране, должна ли она приводить к падению всего приложения в продакшене?

Если вы работали с данными от внешних поставщиков, вы наверняка знаете, насколько непредсказуемыми могут быть их обновления.

Хороший программист не беспечен. По крайней мере, в глазах своего начальника, и в мире где есть best practices. Давайте же не будем «плохими парнями».

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

4. PHP Typed: утилита для приведения к типу

Как же можно улучшить эту ситуацию? По результатам нашего исследования, не существует готового известного пакета, решающего именно эту задачу. Однако есть несколько обёрток для массивов, упрощающих работу с ними, например, Laravel Collections.

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

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

Поскольку мы в основном работаем с массивами и улучшаем приведение типов, было бы здорово заменить конструкции вроде $data['meta']['location']['city'] на что-то более элегантное, например, meta.location.city — аналогично тому, как это делает Arr::get в Laravel.

Теперь давайте сформулируем логику для вспомогательной функции:

"Верни мне значение запрашиваемого типа из указанного источника по заданному пути или верни значение по умолчанию."

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

Изначальная конструкция, хоть и небезопасна, но довольно выразительна:

$number = (int)($data['meta']['age'] ?? 10);

Можем ли мы получить что-то похожее? После ряда раздумий, множества проверок и создания пакета — да! И вот результат:

$number = int($data, 'meta.age', 10);  

Выглядит очень похоже, верно? Но погодите — что это за int обертка? Разве это не зарезервированное ключевое слово в PHP? Это и есть то самое открытие, то самое «нарушение правил», о котором мы упомянули в заголовке статьи:

PHP позволяет использовать имена типов в качестве имён функций.

Вы были уверены, что это запрещено? Не совсем! Хотя определённые имена зарезервированы для классов, интерфейсов и трейтов, для функций таких ограничений нет:

«Эти имена нельзя использовать для названия классов, интерфейсов или трейтов»PHP Manual: Reserved Other Reserved Words

Это означает, что мы можем писать такие вещи как string($array, 'key'), что похоже на (string)$array['key'], но безопаснее и умнее — ведь оно обрабатывает вложенные ключи и значение по умолчанию.

Пакет поддерживает PHP 7.4+ и 8.0+, и распространяется через Composer, поэтому процесс установки стандартный:

composer require wplake/typed

// then in your app:
require __DIR__ . '/vendor/autoload.php';

Кстати, импорт этих функций не мешает использовать родное приведение типов в PHP. Поэтому, хотя это и бесполезно на практике, следующий код будет работать:

echo (string)string('hello');  

Пакет Typed предоставляет набор вспомогательных функций в пространстве имён WPLake/Typed, поэтому вам не нужно беспокоиться о потенциальных глобальных конфликтах. Код с использованием импортов выглядит так:

use function WPLake\Typed\string;  

$string = string($array, 'first.second', 'default value');  

Конечно, ваш IDE автоматически добавит строку use за вас.

Для тех, кто не переносит функции и предпочитает статические методы, пакет предлагает и альтернативный вариант:

use WPLake\Typed\Typed;  

Typed::int($data, 'key');  

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

5. PHP Typed: Примеры использования

Давайте возьмём одну функцию из пакета Typed и рассмотрим примеры её использования. Пусть это будет упомянутая ранее функция string. Вот её декларация:

namespace WPLake\Typed;

/**
 * @param mixed $source
 * @param int|string|array<int,int|string>|null $keys
 */
function string($source, $keys = null, string $default = ''): string;

Сценарии использования

5.1) Извлечение строки из переменной смешанного типа

По умолчанию возвращается пустая строка, если переменную нельзя преобразовать в строку:

$userName = string($unknownVar);
// you can customize the fallback:
$userName = string($unknownVar, null, 'custom fallback value');

5.2) Получение строки из массива

Включая вложенные структуры (с использованием точечной нотации или массива ключей):

$userName = string($array, 'user.name');
// Alternatively:
$userName = string($array, ['user', 'name']);
// custom fallback:
$userName = string($array, 'user.name', 'Guest');

5.3) Доступ к строке из объекта

Включая вложенные свойства:

$userName = string($companyObject, 'user.name');
// Alternatively:
$userName = string($companyObject, ['user', 'name']);
// custom fallback:
$userName = string($companyObject, 'user.name', 'Guest');

5.4) Работа со смешанными структурами

(Например, object->arrayProperty['key']->anotherProperty или ['key' => $object])

$userName = string($companyObject, 'users.john.name');
// Alternatively:
$userName = string($companyObject, ['users', 'john', 'name']);
// custom fallback:
$userName = string($companyObject, 'users.john.name', 'Guest');

Во всех случаях значение по умолчанию — это «пустое» значение для конкретного типа (например, 0, false, "" и так далее), но вы всегда можете передать своё значение по умолчанию в качестве третьего аргумента:

$userName = string($companyObject, 'users.john.name', 'Guest');

Пакет включает функции для следующих типов:

  • string

  • int

  • float

  • bool

  • object

  • dateTime

  • arr (stands for array, because it's a keyword)

  • any (allows to use short dot-keys usage for unknowns)

Дополнительно:

  • boolExtended (true,1,"1", "on" are treated as true, false,0,"0", "off" as false)

  • stringExtended (supports objects with __toString)

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

6. Заключение

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

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

Спасибо, что нашли время прочитать этот пост! Желаем вам отличного Нового года, полного успехов и роста во всех областях разработки.

P.S. У нас есть ещё одна новость: на этой неделе мы выпустили ещё один пакет, который может быть также полезен PHP-разработчикам. На следующей неделе мы опубликуем пост о нём, но если вам не терпится взглянуть на него прямо сейчас, пакет уже доступен, и вот ссылка на него.

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


  1. posledam
    21.12.2024 17:49

    У нас была ситуация, из приложения на PHP по запросу получали словарь типа { "key1": "value1"...}. И всё было хорошо, пока не случилась крайне неприятная ситуация с пустым словарём. Потому что возвращаться стало вот это [], а не {}. Запросы выполнялись из приложения на типизированном языке, где подобное в принципе не мыслимо, словарь это всегда словарь, массив это массив (с конкретным типом элементов), а int это всегда int. Исправили костылём, но осадочек остался. Подобные решения приветствую, главное чтобы они работали :)


    1. positroid
      21.12.2024 17:49

      Это прям классический кейс, о который спотыкаются все json API на php при интеграции с приложением под iOS. Ещё ни разу мимо не удалось проскочить, хотя фронт на js и даже андроид достаточно терпимо к такому относятся.

      Правда обычно пустой справочник / объект отдаём все же как null, а не {}