Звучит слишком громко? Давайте уточним, чтобы избежать обманутых ожиданий: этот пакет использует немного магии вне Хогвартса, и будет действительно полезен любителям строгой типизации в 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 isset($userData['meta']['age']) &&
is_numeric($userData['meta']['age'])
? (int) $userData['meta']['age']
: 0;
}
function upgradeUserById($mixedUserId): void
{
$userId = is_string($mixedUserId) ||
is_numeric($mixedUserId)
? (string) $mixedUserId
: '';
}
Хотя сами по себе эти функции бесполезны, они очевидно демонстрируют избыточность кода и потерю ясности, которые часто возникают при работе с нетипизированными переменными.
"Минутку. Почему просто не объявить тип для
$mixedUserId
? Почему не добавить PHPDoc-комментарии, чтобы описать ключи в$userData
?" — глядя на эти примеры, возможно, думаете вы.
В идеальном мире, да, мы бы объявили строчный тип для $mixedUserId
, а $userData
был бы либо экземпляром класса, либо массивом с ключами, описанными в PHPDoc. Однако реальность такова, что программное обеспечение не пишется одним разработчиком и не ограничивается сотней строк кода. В проекте всегда есть сторонние компоненты, внешние библиотеки, поставщики и обширные легаси-кодовые базы.
Когда дело касается массивов, у нас есть ощущение, что эта проблема будет существовать ещё долгие годы. Почти невозможно гарантировать фиксированную структуру массива — достаточно вспомнить $_SERVER
, где значения могут меняться в зависимости от окружения, не говоря уже о пользовательских данных из $_GET
и $_POST
.
Однако довольно о проблемах — давайте поговорим о решениях.
3. Стандартные подходы приведения к типу
Итак, как мы можем справится с задачей приведения к типу? Существует три распространённых подхода, доступных из коробки:
3.1) Использование isset и проверки типа
Как мы уж видели выше:
$age = isset($userData['meta']['age']) &&
is_numeric($userData['meta']['age'])
? (int) $userData['meta']['age']
: 0;
Этот подход создает избыточный и неясный код даже на таком простом примере.
3.2) Использование оператора объединения с null (??) и проверки типа
$number = $data['meta']['age'] ?? 10;
$number = 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-разработчикам. На следующей неделе мы опубликуем пост о нём, но если вам не терпится взглянуть на него прямо сейчас, пакет уже доступен, и вот ссылка на него.
Комментарии (24)
bolk
21.12.2024 17:49А зачем везде
true ===
пишете? is_numeric и isset, согласно доке, уже bool возвращают.gun_dose
21.12.2024 17:49А вам IDE не подчёркивает нестрогие сравнения? Само по себе нестрогое сравнение - потенциальный источник ошибок, поэтому лучше избавляться от привычки в принципе его использовать. Кроме того, считается, что строгое соавнение срабатывает быстрее, т.к. не пытается приводить типы. И хотя в конкретно данном случае ни ошибки, ни разницы в скорости не будет, но вы подумайте логически: вы предлагаете использовать сравнение с приведением типов там, где это приведение 100% не пригодится.
bolk
21.12.2024 17:49А причём тут это?
По-вашему «isset(…) === true ? A : B» строже, чем «isset(…) ? A : B» что ли? За счёт чего?gun_dose
21.12.2024 17:49Сорри, я не сразу понял, к какому именно куску кода вы писали комментарий. true === isset в левой части тернарника конечно же не имеет смысла. Кстати, IDE такое тоже подчёркивает, но иногда создаётся впечатление, что многие считают, что все эти подчёркивания там для красоты))
m5xim Автор
21.12.2024 17:49Дельное замечание, спасибо, убрал. Видимо заигрался с PHPStan strict types
Dremkin
21.12.2024 17:49Мысли в сторону: Подключу ка я ещё одну библиотечку, чтобы было удобно. Подождите, надо проверить совместимость с другими 15 библиотеками, с которыми мне очень удобно. Завтра выходит новый программер, надо ему ещё поставить пару библиотек, чтобы было ему удобно, потом обсудим за пивом как нам удобно работать ) эй, начальник, нам нужен нужна должность менеджера по удобствам и желательно сервак новый, а то этот тормозит что-то
MihaOo
21.12.2024 17:49Предлагаете всё писать руками?
randvell
21.12.2024 17:49Откровенно говоря, аналогичные велосипеды есть на любом мало-мальски крупном проекте. Любая раздражающая проверка или обработчик, встречающиеся больше одного раза выносятся в хелпер или ещё куда, после чего забываешь про то, что там понаписано и просто вызываешь X::parseNumeric($abc), который вернёт либо число, либо null / default. Да и то такие вещи обычно приходится костылить не от хорошей жизни, на нормальных проектах с хорошей архитектурой за такие вещи по рукам дадут на кодревью. У нас лично самый полезный из таких велосипедов - это DotAccess для массивов, аля JS. Судя по описанию, что-то похожее как раз реализовано и здесь.
ddruganov
21.12.2024 17:49Опять вротпрессеры придумали себе проблем и их решают
Вся эта ересь решается использованием дто со строго типизированными полями, а не всяких всратых array $userData
MihaOo
21.12.2024 17:49Типизированный DTO надо же тоже где-то создать, разве нет? Потому что из query string вроде приходят только строки. Как вы решаете эту проблему? (при этом я не говорю что решение из статьи мне нравится)
ddruganov
21.12.2024 17:49В условной симфе в экшен контроллера можно заинжектить уже готовую дтошку, собранную из пришедшего запроса. Так же и в ларке
В остальных фреймах да, возможно придется создавать дтошку самому, но при этом после валидации ты точно можешь быть уверен, что все пришедшие данные верного типа. Это делается один раз в экшене, зачем делать для этого отдельные функции, тем более отдельную библиотеку и тем более зачем тащить такую библиотеку в проект - я не понимаю
SerafimArts
21.12.2024 17:49Не, в ларке нет таких инструментов из коробки. Даже все пакеты от каких-либо "шпателей" предоставляют не DTO, а полноценные монструозные VO, хотя называются DTO.
Это надо тащить условные symfony/serializer, jms, type-lang/mapper, valinor, etc. Но экосистемы нормальной в ларе нет под это дело, только недавно завезли атрибуты с костылями (ContextualAttribute интерфейс) для эмуляции аргумент велью резолверов, но всё равно это такое себе.
В ларе это решается для простых случаев через form request, они гарантируют более-менее типы, но никаких преобразований не производят.
ddruganov
21.12.2024 17:49не совсем понял; "в ларке нет инструментов из коробки" и "решается через form request" как будто друг другу противоречат
и да, я говорил как раз про form request, в котором для любого поля запроса можно указать желаемый тип и плеваться ошибками валидации при несоответствии; из этого потом влегкую делаются дто через встроенные методы типа integer() или boolean(), потому что ты уже можешь быть уверен, что у тебя все данные верного типа; ну или по крайней мере каст сделается за тебя
SerafimArts
21.12.2024 17:49не совсем понял; "в ларке нет инструментов из коробки" и "решается через form request" как будто друг другу противоречат
Изначально цитата была следующей:
В условной симфе в экшен контроллера можно заинжектить уже готовую дтошку, собранную из пришедшего запроса. Так же и в ларке
Мой комментарий: Нет, в ларке не так же. Там как в запросе пришла нетипизированная мусорка из запроса, так с ней дальше и работаешь. Просто с некоторым шансом на корректность данных. Никаких реальных операций над типами и тем паче работы с DTO там нет.
bondeg
21.12.2024 17:49Плюс ключи массивов напрямую гонять в принципе плохо, т.к. усложняет поиск мест использования данных.
SbWereWolf
21.12.2024 17:49Подравляю вы изобрели еще один велосипед. Первый такой велосипед изобрел я сам, пятььлет назад.
Я решал аналогичную проблему, написал для этого пару классов, потом код оформил в пакет.
composer require sbwerewolf/language-specific
И статью на Хабр написал.
https://habr.com/ru/articles/522348/
У меня не такое лаконичное решение, мое решение более ООП, в силу мои религиозных убеждений. Статичные методы для меня это прямо крайний случай, процедуры (в php это function) - за гранью добра и зла.
SerafimArts
21.12.2024 17:49Статичные методы для меня это прямо крайний случай, процедуры (в php это function) - за гранью добра и зла.
Это не процедуры, а функции. Любая процедура всё же содержит сайд-эффекты и не идемпотентна (ну кроме случаев возврата по ссылке, но такое бы я тоже функциями считал, хотя терминологически не знаю как правильно). А функции всё же привычно считать чистыми, как и в данном случае, например.
Так что никаких минусов у такого кода я не вижу, кроме забивания таблицы символов языка. Но даже наличие 100 функций вряд ли вообще скажется на производительности поиска в хешмапе в executor_globals. Всё же коллизии хешей - довольно редкое явление.
Какие разумные (потому что пока что вижу только догматизм) аргументы против вы имеете?
ivashkevitch
21.12.2024 17:49Почему бы не использовать объекты с типизированными свойствами, вместо мап?
SerafimArts
21.12.2024 17:49Если что, то функции в пыхе принято (в спецификации этого нет, однако 99.9% стандартной библиотеки именно так написана) именовать через нижнее подчёркивание:
string_or_null
, а неstringOrNull
. В камел-кейсе методы только ;)
posledam
У нас была ситуация, из приложения на PHP по запросу получали словарь типа
{ "key1": "value1"...}
. И всё было хорошо, пока не случилась крайне неприятная ситуация с пустым словарём. Потому что возвращаться стало вот это[]
, а не{}
. Запросы выполнялись из приложения на типизированном языке, где подобное в принципе не мыслимо, словарь это всегда словарь, массив это массив (с конкретным типом элементов), аint
это всегдаint
. Исправили костылём, но осадочек остался. Подобные решения приветствую, главное чтобы они работали :)positroid
Это прям классический кейс, о который спотыкаются все json API на php при интеграции с приложением под iOS. Ещё ни разу мимо не удалось проскочить, хотя фронт на js и даже андроид достаточно терпимо к такому относятся.
Правда обычно пустой справочник / объект отдаём все же как null, а не {}
MihaOo
Так в этом не PHP виноват, а разработчики, которые не знают что ассоциативный массив кастится в
[]
, если он пустой. Я сталкивался с такой проблемой когда только начинал знакомиться с Yii, отдавал ему данные и думал будет всё нормально. В итоге пришлось делать что-то вродеreturn $data === [] ? new \stdObject : $data;
, что, безусловно, тоже костыль. Хорошо было бы указывать тип возвращаемого значения в аннотациях или PHPDoctrawl
Можно объявлять не как массив, а как ArrayObject, дальше работать как с массивом, но сериализовываться будет как нужно
GrustniyNos
Через JSON_FORCE_OBJECT?