Nervous programmer

Personal blog about programming

2026-02-18

Mypy vs Phpstan: битва стат анализаторов

Дисклеймер

Все сказанное ниже валидно для mypy 1.19.1 (дефолтный конфиг) и phpstan 2.1.37 (level: 10).

Что происходит?

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

Однако беда этого сравнения в том, что оно априори некорректно, потому что эти два инструмента используются в двух разных языках программирования: mypy в python, а phpstan в php. И, получается, они даже не конкуренты. Но давайте я все же попробую оправдаться, и пояснить за этот наброс.

Во-первых, это оба си-подобные языки, у которых схожая система примитивных типов. И они даже оба null-safety.

Во-вторых, с точки зрения типизации python и php также очень схожи. Оба языка с динамической типизацией. Да, php уже во многом язык со строгой типизации, но от этого она не перестает быть динамической. У python, кстати, строгая типизация тоже присутствует. Ее можно увидеть при взаимодействии со стандартной библиотекой. Легко получить TypeError при вызове pow("2", 3). Но, тем не менее, в обоих языках, код вида

a = "cool"
len(a)
a = 5
pow(a, 4)

валидный. Хотя тип переменной меняется в рантайме.

В-третьих, оба языка используют специальные синтаксические конструкции для уточнения типов переменных, которые исчезают в рантайме (для php это phpdoc). Они позволяют статическому анализатору и IDE разобраться, в чем вы неправы. Да, в python вообще все объявления типов исчезающее. В этом смысле, у php типизация гораздо больше проявляется в рантайме. Но php вполне удовлетворит array в качестве типа, что для стат анализа несет мало пользы.

И все вместе это приводит к тому, что с точки зрения статического анализа Development Experience (DX) очень похож. Но его качество несколько отличается.

Чего здесь не будет?

Но почему не сравнить с pyright???

Действительно, pyright — еще один стат анализатор для python. И по слухам некоторые описанные здесь проблемы там решены. Но у меня нет опыта использования pyright. А проводить специальное исследование и сравнение двух инструментов ради статьи мне лень. Здесь же приведена концентрация моего опыта и моей боли при переключении между php и python. И я бы даже не взялся писать подобное, если бы не пользовался каким-то из этих инструментов. Если кому-то интересно сравнение именно mypy и pyright, то можно ознакомиться с ним на сайте pyright или еще где-то. Здесь же у меня опыт использования двух самых популярных стат анализаторов (по звездочкам на гитхабе) для каждого из языков.

Теперь, когда сова полностью на глобусе, можно переходить к сути.

Енумы и литералы

Допустим у нас в php коде есть енум.

enum FooBarBaz: string
{
    case Foo = 'foo';
    case Bar = 'bar';
    case Baz = 'baz';
}

Как типизировать функцию, которая принимает на вход этот самый енум, а возвращает литерал-значение этого енума? Ну вот так

/**
 * @return value-of<FooBarBaz>
 */
function acceptEnumReturnLiteral(FooBarBaz $enum): string
{
    return $enum->value;
}

Думаю, комментировать тут особо нечего. value-of говорит сам за себя.

Теперь обращаемся к python. Вот енум

class FooBarBaz(Enum):
    FOO= "foo"
    BAR= "bar"
    BAZ= "baz"

И все тот же вопрос, как вернуть литерал? Оказывается, что только так.

FooBarBazValue = Literal["foo", "bar", "baz"]

def accept_enum_return_literal(enum: FooBarBaz) -> FooBarBazValue:
    return enum.value

И тут внезапно обнаруживается проблема, что мир енумов и литералов просто не пересекается друг с другом. И мы видим дублирование значений. Кажется, что это не такая большая проблема. Ну подумаешь, поприседать придется, если решил переименовать значение. Задачу-то мы решили.

Теперь внезапно выясняется, что в другом месте, у нас новое требование: нам надо вернуть подмножество литералов: foo и bar. И там точно не должен быть получен baz. Для php у нас получается так

/**
 * @return value-of<FooBarBaz::Foo|FooBarBaz::Bar>
 */
function acceptEnumReturnFooBar(FooBarBaz $enum): string
{
    if ($enum === FooBarBaz::Baz) {
        throw new UnexpectedValueException('Unexpected value');
    }
    return $enum->value;
}

Ну а теперь обратно к python

FooBarValue = Literal["foo", "bar"]

def accept_enum_return_foo_bar(enum: FooBarBaz) -> FooBarValue:
    if enum == FooBarBaz.BAZ:
        raise ValueError("Unexpected value")
    return enum.value

Встречайте нашего нового гостя FooBarValue. Он похож на FooBarBazValue, но все же немного другой тип.

Таким образом, для каждого подмножества значений енумов нам придется отдельно прописывать новый тип с литералами, что не улучшает настроение. Конечно я не один такой страдающий тут. Фича-реквест висит уже 5 лет. Но увы.

Literal Widening

Напишем еще более бессмысленный код.

def accept_enum_return_foo_bar() -> FooBarValue:
    return "foo"

Будет ли ругаться mypy? Да конечно нет. Тут все ж корректно. "foo" — один из ожидаемых литералов.

А так?

def accept_enum_return_foo_bar() -> FooBarValue:
    res = "foo"
    return res

А так увидим ошибку.

error: Incompatible return value type (got "str", expected "Literal['foo', 'bar']")

Все дело в том, что mypy не особо хочет связываться с литералами, и при первой возможности конвертирует их в близкий дженерик тип. В данном случае это string. Решением будет уточнить тип res: FooBarValue = "foo".

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

Join vs Union

Теперь давайте отвлечемся от литералов и перейдем к более общим проблемам. Вернемся к пыхе. Какую ошибку покажет phpstan?

function returnIntOrString(): int|string
{
    $res = ['foo', 1];
    return $res[0];
}

Ответ: Function returnIntOrString() never returns int so it can be removed from the return type.

И это очень круто, потому что phpstan даже работает “в обратку”, и подсказывает, когда ты нафигачил лишних типов. Конечно проблема уйдет, если убрать int из ожидаемого типа. Но я хотел показать другое. Поэтому я исправлю ошибку так.

function returnIntOrString(): int|string
{
    $res = ['foo', 1];
    shuffle($res);
    return $res[0];
}

Мы перемешали список, и поэтому вернуться может как строка, так и число. phpstan доволен.

А вот аналогичный питонячий код

def return_int_or_string() -> str | int:
    res = ["foo", 1]
    return res[0]

И ошибка будет совсем другой: Incompatible return value type (got "object", expected "str | int"). И с такой ошибкой даже фикс для phpstan не поможет.

И дело тут в фундаментальной проблеме mypy, которая носит название join-vs-union. Целый топик на гитхабе посвящен этой проблеме. И там куча issues.

Суть в том, что когда mypy надо вывести тип, состоящий из нескольких подтипов, то он пытается сделать это с помощью поиска общего предка. Как, например, и случилось выше. Mypy увидел, что список состоит из int и str, но вместо того, чтоб вывести тип list[int | str], сохранив при это оригинальные типы в виде юниона, получилось list[object]. Потому что общий предок int и strobject.

Конечно на стэковерфлоу вам пояснят, что это просто особенности дизайна. И что у обоих подходов есть свои плюсы и минусы. На практике, однако, join подход — это сплошные минусы. Вывод типов часто получается неточным, что приводит к куче ложно-положительных ошибок.

Решение — явная аннотация типа res: list[int | str] = [1, "foo"]. Как итог, там, где phpstan просто “знает” тип из декларации, в mypy пишешь кучу “исчезающего” кода, чисто чтоб проверки прошли.

Инвариантность

Немного модифицируем предыдущий пример на php.

function returnIntOrString(): int|string
{
    $res = ['foo'];
    $res[] = 1;
    shuffle($res);
    return $res[0];
}

Теперь мы мутируем список, добавляя строковый элемент. И здесь все в порядке. С точки зрения phpstan все выглядит также, как в предыдущем примере.

Теперь аналогичный код на python

def return_int_or_string(second: bool) -> str | int:
    res = ['foo']
    res.append(1)
    return res[0]

И если мы так сделаем, то получим ошибку Argument 1 to "append" of "list" has incompatible type "int"; expected "str". mypy видит, что при объявлении res список состоял из одного элемента ‘foo’, поэтому тип res был выведен, как list[str]. И попытка добавить элемент типа int в такой список, выглядит как преступление. Решение, как обычно, явная декларация типа res: list[int | str] = ['foo'].

Такое поведение – следствие того, что mypy воспринимает все мутируемые коллекции как инварианты. phpstan же, напротив, отслеживает возможное изменение типов в каждой строчке скоупа.

function returnIntOrString(): int|string
{
    $res = ['foo', 'bar', 'baz'];
    shuffle($res);
    acceptString($res[0]);
    $res[] = 1;
    shuffle($res);
//    acceptString($res[0]);
    return $res[0];
}

function acceptString(string $str): void {}

В данный момент, никаких ошибок phpstan не покажет. Однако, если раскомментировать второй вызов acceptString, то именно там мы увидим ошибку.

Переопределения переменных

И следующая проблема в чем-то похожа на предыдущую, но это мой топ-1 раздражителей mypy.

Вот вполне корректный с точки зрения phpstan код

function test(): void
{
    $var = 5;
    acceptInt($var);
    $var = 'str';
    acceptString($var);
}

function acceptInt(int $str): void {}
function acceptString(string $str): void {}

Переприсвоение не является какой-то проблемой для phpstan. И с точки зрения типизации этот код корректен.

Теперь аналогичный код в python

def test() -> None:
    var = 5
    accept_int(5)
    var = "str"
    accept_str(var)


def accept_int(i: int) -> None: ...
def accept_str(s: str) -> None: ...

Сразу же целых две ошибки:

На самом деле за этими двумя ошибками скрывается одна проблема — mypy не переносит редекларацию переменной другим типом по-умолчанию.

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

for s_cfg in config.snapshots:
    ...
for p_cfg in config.periods:
    ...

Вместо того, чтоб назвать переменную цикла одинаково, например cfg, приходится городить всякие s_cfg и p_cfg.

И тут многие могут возразить: “вон в typescript вообще у тебя никакие переприсвоения не разрешены если const используешь”. Ну, во-первых, в python то переприсвоения разрешены, просто это должен быть тот же тип. Так что это даже никакая не защита. Во-вторых, const и let переменные всегда привязаны к текущему блоку кода. Поэтому той же проблемы с циклами for … of и for … in нет, так как каждая итерация — отдельный контекст, а за пределеами цикла переменной уже нет.

Пофиксить это можно, если использовать флаг --allow-redefinition-new. С этим флагом поведение mypy в плане переопределения переменных становится схожим с phpstan. Проблема только в том, что он экспериментальный. В будущем его обещают сделать включенным по-умолчанию. Справедливости ради отмечу, что я не нашел каких-то особых проблем с этим флагом, несмотря на его экспериментальный статус. Однако особо сложные случаи типа такого

def problem_closure() -> int:
    x = "string"
    def inner() -> int:
        return len(x)

    x = 42
    return inner()

все еще вызывают проблемы. Но с таким же примером и phpstan ничего сделать не может

function problemClosure(): int
{
    $x = 'string';
    $inner = function () use (&$x) {
        return strlen($x);
    };
    $x = 5;
    return $inner();
}

Правда у php здесь фора, потому что вот так передавать &$x — это еще надо постараться. А без & значение переменной просто копируется в момент определения функции.

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

Мемоизация вызовов

Рассмотрим пример, где phpstan явно лажает.

function returnOptInt(): ?int
{
    return rand(0, 100) > 50 ? 5 : null;
}

function returnInt(): int
{
    if (returnOptInt() === null) {
        throw new UnexpectedValueException();
    }
    return returnOptInt();
}

phpstan не покажет здесь ошибки, хотя она, очевидно, есть. Дело в том, что по-умолчанию phpstan использует “запоминание” результатов функций. В данном случае, проблема в том, что функция returnOptInt не чистая — ее значение может измениться при двух и более вызовах подряд.

mypy же, напротив, строг как всегда

def return_opt_int() -> int | None:
    ...

def return_int() -> int:
    if return_opt_int() is None:
        raise ValueError()
    return return_opt_int()

сразу покажет любимую ошибку Incompatible return value type (got "int | None", expected "int"). Решение — ввести промежуточную переменную.

def return_int() -> int:
    res = return_opt_int()
    if res is None:
        raise ValueError()
    return res

Но не будем спешить кидаться тапками в phpstan. Дело в том, что мемоизация результатов функций, это не баг, а фича. Подробнее об этом можно прочесть тут. В большинстве случаев разработчик ожидает именно запоминания результатов вызовов. Об этом написано прямо вот так

Why remember these calls at all? Because developers expect it, mostly when calling getters, even when there’s a risk the function will return a different value after the second call.

И мой субъективный опыт согласуется с этим утверждением. В 90% случаев подобные проверки у меня связаны со всякими геттероподобными штуками, которые всегда чистые. Но phpstan дает возможность отметить функцию грязной, когда это надо.

/**
 * @phpstan-impure
 */
function returnOptInt(): ?int
{
    return rand(0, 100) > 50 ? 5 : null;
}

Вот тут уже будет показана ошибка. Более того, можно поменять дефолтное поведение, выставив параметр rememberPossiblyImpureFunctionValues равным false, и тогда все функции по умолчанию будут грязными. Получим поведение аналогичное mypy.

В отличие от phpstan разработчики mypy на просьбу добавить возможность маркировать функции чистыми ответили, что это сложно. Может и так. Но от этого не легче.

Вывод

Невозможно переоценить значимость статического анализатора для языков вроде php и python. В наши дни написать код, лишенный ошибок, без строгой типизации — что-то из разряда чудес. По крайней мере, в относительно крупных проектах. Поэтому за последние 10 лет статические анализаторы так прочно вошли в тулсет всех разработчиков.

Но опыт использования phpstan и mypy сильно отличается. phpstan не отвергает динамическую природу типизацию языка php. Он старается выполнить все проверки так, чтоб те изменения, которые ты сделал в коде, действительно исправили потенциальные ошибки. Не более. При этом php был и остается языком с динамической типизацией. Правда теперь она строгая.

mypy же довольно сильно меняет то, как ты пишешь код. python становится языком со статической типизацией. Обратите внимание, что большая часть проблем, показанных выше, решается через явную декларацию типов. Код становится более вербозным. В какой-то степени это напоминает golang в плане возни с типами.

И кто-то может сказать, что это не так уж и плохо, раз напоминает golang:) Но, извините, я уже “заплатил” за динамическую типизацию скоростью выполнения, и мне хотелось бы и дальше оставаться в этой парадигме. А тут у меня ее отбирает статический анализатор. И это, пожалуй, моя главная претензия к mypy.

Но лучше уж так, чем совсем никак!

tags: python - php - mypy - phpstan