Статический анализ в PHP

Все мы привыкли к определению, что PHP – это язык со слабой динамической типизацией. В этом одновременно его преимущество и недостаток. Преимущества заключаются в том, что вы имеете больше возможностей по работе с динамическими данными: вызывать функции или объекты из строки, передавать и возвращать mixed типы и многое другое. В конце концов, только в PHP вы можете одновременно увидеть ужасные реализации паттерна ActiceRecord с динамическими свойствами (которые физически не определены в классе) и элегантные контейнеры внедрения зависимостей, которые дают значительно больше возможностей, чем аналогичные контейнеры в языках с сильной статической типизацией. Если говорить о недостатках, то это, разумеется, баги, связанные как раз с тем, что мы точно не знаем, что нам может вернуть, например, та же анонимная функция, каких типов параметры она принимает, какие ключи есть в массиве и так далее. По этой причине вам приходится обогащать свой код проверками в духе isset($data['key']) и тому подобное.

Преимущество компилируемых языков состоит в том, что еще на этапе компиляции происходит верификация всех типов вашей кодовой базы, что в большинстве случаев избавляет вас от неприятных багов в рантайме. В php в силу его природы такое невозможно... или все-таки возможно? На самом деле возможно, и реализуется это благодаря статическому анализу. В данном случае слово "статический" означает, что код анализируется еще до запуска: например, в CI (Continuous Integration). На данный момент существуют два самых популярных инструмента статического анализа: psalm и phpstan. В этой статье мы рассмотрим psalm, инструмент, которым я пользуюсь во всех своих проектах.

Так что же позволяет делать psalm, что поможет нам избавиться от багов в рантайме? Чтобы в этом разобраться, ниже я приведу многочисленные снипеты кода с правильным определением типов, а также прикреплю ссылку на онлайн-редактор psalm, где вы сможете убедиться в том, что этот инструмент действительно помогает находить ошибки.

Замыкания

Замыкания, или анонимные функции, или, как их еще часто называют, лямбды, – это функции без имени, которые удобны тем, что могут быть переданы в другие функции в качестве аргумента. Такие функции называются функциями высшего порядка. Например, представим, что у нас есть анонимная функция, которая принимает два числа и возвращает булево значение.

Как мы могли бы описать такое, например, в Kotlin:

fun highOrderedFunction(max: (Int, Int) -> Boolean): Boolean {
    return max(3, 2);
}

Или в go:

func highOrderedFunction(max func(int, int) bool) bool {
    return max(3, 2)
}

Удобно, согласитесь? Это не только безопасный код, но и самодокументируемый, так как нам не приходится гадать, каких типов аргументы мы должны передать и что оттуда вернется. Можно ли такое повторить в php? Можно, повторяем:

callable-max

Многие могут возразить, что это же всего лишь комментарии, а не код. Но пока для человека это комментарии, для псалма – это метаинформация, которая помогает ему следить за тем, чтобы в эту функцию не передали анонимную функцию с другими аргументами. Можете убедиться в этом сами, открыв редактор псалма. Сейчас вы увидете там ошибку, потому что я намеренно первым аргументом функции сделал string. Замените его на int, чтобы убедиться, что все работает правильно.

Если вы хотите вернуть nullable булев тип, вам необходимо добавить знак вопроса перед типом. Точно так же, как мы это делаем на уровне самого языка:
callable-max-nullable

После этого мы вольны вернуть null из функции, как это показано в примере. Тут я использовал аннотацию @psalm-param, а не просто @param, потому что phpdoc не поддерживает некоторые особенности синтаксиса псалма. Однако я советую всегда, даже для простых типов, использовать @psalm-param, это позволит в будущем подключить другой статический анализатор, например, phpstan, где есть такая же аннотация @phpstan-param. Так вы сможете избежать конфликта анализаторов, если вдруг phpstan не поймет синтаксис псалма, указанный в аннотациях @param, @return, и наоборот. Поехали дальше.

Что если сама анонимная функция должна быть nullable?

callable-max-nullable-func

В данном случае мы окружили скобками наш callable, чтобы null стал частью типа именно нашей переменной $max, а не частью возвращаемого типа анонимной функции.

Массивы

Массивы – один из самых мощных инструментов языка. Это одновременно и списки, и кортежи, и ассоциативные массивы. В то же время в php нет встроенных инструментов для описания типов массива. Но не с псалмом. Псалм позволяет вам описать ассоциативный массив любой вложенности и любых типов, а также проконтролирует, что вы правильно используете элементы массива. Давайте проверим:

assoc-array

Более того, в phpstorm не так давно была завезена поддержка автокомплита таких массивов, как показано на картинке. Еще одно преимущество описания типов в копилку. К сожалению, это пока не работает с вложенными типами.

Теперь зайдите в редактор псалма и убедитесь, что он действительно валидирует массив на соответствие типов и вхождений всех ключей. Поправьте ключи так, чтобы не было ошибок.

Если вы хотите передать список чисел, то можете это описать следующим образом:
int-list

Psalm это в том числе провалидирует правильно.

Вы также можете использовать кортежи – упорядоченные последовательности элементов.

tuple

Теперь вы можете безопасно обращаться к индексу 0 или 1 и осуществлять соответствующие типу операции. Обратите внимание на этот пример в редакторе псалма. Он не только валидирует операции, допустимые над типом, но и то, что вы не вышли за длину массива, так как элементов всего 2.

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

fixed-array

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

Кстати, для описания std объекта вы можете использовать аналогичный синтаксис, как для описания ассоциативного массива:

std-object

Разумеется, psalm провалидирует и такие случаи.

Примитивные типы

Для примитивных типов psalm также предоставляет дополнительные возможности.

Например, вы можете описать, что ждете только не пустую строку:
non-empty-string

Или только позитивное число:
positive-int

Или только конкретные числа (аналогично работает и со строками):
int-pool

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

constants-list

Если в качестве аргумента функция ждет название конкретного класса, это можно описать следующим образом:
class-string-type

Также допускается указать просто class-string, если вам не важен тип класса, а только то, что это fqcn от класса:
class-string

Дженерики

Дженерик-тип – это инструмент обобщенного программирования, который позволяет работать с различными типами.

Возьмем популярный пример – Stack – и на его примере посмотрим, как это работает:

stack-generic

Сначала мы вводим некоторый тип T, который нам пока неизвестен. Используем этот T как тип данных наших элементов в методах add() и last(). В момент создания объекта мы должны указать конкретный тип нашего T с помощью такого синтаксиса:

/** @psalm-var Stack<string> $stack */
$stack = new Stack();

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

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

Другой пример использования дженерика:

<?php

final class Service
{
    public function do(): void
    {
    }
}

/**
 * @psalm-template T
 */
final class Result
{
    /**
     * @psalm-var T|null
     */
    public mixed $inner;

    /**
     * @psalm-param T|null $inner
     */
    public function __construct($inner)
    {
        $this->inner = $inner;
    }

    /**
     * @psalm-assert !null $this->inner
     */
    public function isNotNull(): bool
    {
        return $this->inner !== null;
    }
}

final class Container
{
    /**
     * @psalm-var array<string, object>
     */
    private array $dependencies;

    /**
     * @psalm-param array<string, object> $dependencies
     */
    public function __construct(array $dependencies = [])
    {
        $this->dependencies = $dependencies;
    }

    /**
     * @psalm-template T of object
     *
     * @psalm-param class-string<T> $fqcn
     *
     * @psalm-return Result<T>
     */
    public function get(string $fqcn): Result
    {
        /** @psalm-var Result<T> */
        return new Result($this->dependencies[$fqcn] ?? throw new InvalidArgumentException());
    }
}

$container = new Container();

$result = $container->get(Service::class);

if ($result->isNotNull()) {
    $result->inner->do();
}

В данном случае у нас есть некоторый контейнер, который умеет по полному имени класса (который пока что обозначим как T) вернуть объект. Таким образом мы можем получить любой T, который контейнер по его имени найдет у себя в зависимостях. Чтобы оценить всю мощь дженериков, попрактикуйтесь на простых задачах, а также почитайте документацию псалма.

Также в этом коде есть одна важная особенность: нам не нужно указывать в аннотации к нашему $result тип дженерика, так как этот тип берется из аргумента метода get.

Другие возможности Psalm

Кроме того, что psalm может определять, что переменная не используется, что вы сделали бессмысленное условие (например, проверяете на null не null тип), что вы ошиблись в синтаксисе, у него есть и другие киллер-фичи, благодаря которым он отличается от других статических анализаторов.

Условные типы

Иногда ваш код может вернуть два абсолютно разных типа в зависимости от входящего типа аргумента. Было бы хорошо такое описать, правда? И psalm это умеет!

conditional-types

В данном случае если передать null в функцию, мы получим false и не можем обращаться к ответу как к массиву, и psalm закономерно будет ругаться.

Ассерты

Часто бывает такое, что у вас есть поле, которое может быть null, и метод, который проверяет, что то, что установлено в этом поле, не равно null. Если результат поля вы захотите передать в функцию/метод, которые null не принимают, вам необходимо убедиться в том, что null там нет. С помощью синтаксиса ассертов вы можете указать псалму, что вы уже проверили тип данного поля. Сделать это можно следующим образом:

/**
 * @psalm-template T
 */
final class Envelope
{
    /**
     * @psalm-var T|null
     */
    public mixed $value;

    /**
     * @psalm-param T|null $value
     */
    public function __construct($value = null)
    {
        $this->value = $value;
    }

    /**
     * @psalm-assert !null $this->value
     */
    public function isset(): bool
    {
        return $this->value !== null;
    }
}

function accept(string $value): void
{
    echo $value;
}
/** @psalm-var Envelope<string> $envelope */
$envelope = new Envelope();

if ($envelope->isset()) {
    accept($envelope->value);
}

Про остальные возможности советую прочитать в документации. Их там много.

А как этим пользоваться?

Для того, чтобы начать пользоваться psalm'ом, прочитайте секцию Get Started в их документации.

Надеюсь, данная статья убедила вас хотя бы попробовать этот инструмент. Поверьте, со статическом анализом вы избавитесь от глупых ошибок обращения к null как к объекту, передачи неверного типа, обращения к несуществующему ключу массива и многое другое.

loader
Комментарии
К этому посту больше нельзя оставлять новые комментарии
Логические задачи с собеседований