PHP Typed: Маленький Composer пакет, который нарушает PHP правила ради вас

от автора

Звучит слишком громко? Давайте уточним, чтобы избежать обманутых ожиданий: этот пакет использует немного магии вне Хогвартса, и будет действительно полезен любителям строгой типизации в 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-разработчикам. На следующей неделе мы опубликуем пост о нём, но если вам не терпится взглянуть на него прямо сейчас, пакет уже доступен, и вот ссылка на него.


ссылка на оригинал статьи https://habr.com/ru/articles/868640/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *