Звучит слишком громко? Давайте уточним, чтобы избежать обманутых ожиданий: этот пакет использует немного магии вне Хогвартса, и будет действительно полезен любителям строгой типизации в PHP.
Введение
Проблемы слабой типизации в PHP
Стандартные подходы приведения к типу
PHP Typed: утилита для приведения к типу
PHP Typed: Примеры использования
Заключение
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-разработчикам. На следующей неделе мы опубликуем пост о нём, но если вам не терпится взглянуть на него прямо сейчас, пакет уже доступен, и вот ссылка на него.
posledam
У нас была ситуация, из приложения на PHP по запросу получали словарь типа
{ "key1": "value1"...}
. И всё было хорошо, пока не случилась крайне неприятная ситуация с пустым словарём. Потому что возвращаться стало вот это[]
, а не{}
. Запросы выполнялись из приложения на типизированном языке, где подобное в принципе не мыслимо, словарь это всегда словарь, массив это массив (с конкретным типом элементов), аint
это всегдаint
. Исправили костылём, но осадочек остался. Подобные решения приветствую, главное чтобы они работали :)positroid
Это прям классический кейс, о который спотыкаются все json API на php при интеграции с приложением под iOS. Ещё ни разу мимо не удалось проскочить, хотя фронт на js и даже андроид достаточно терпимо к такому относятся.
Правда обычно пустой справочник / объект отдаём все же как null, а не {}