From 0b58b903bbb92a5e0d253ab190e9f83c361e1692 Mon Sep 17 00:00:00 2001 From: ivanstrygin Date: Sat, 7 Feb 2026 01:40:26 +0300 Subject: [PATCH 1/2] mypy change --- _posts/2026-02-05-mypy-vs-phpstan-ru.markdown | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 _posts/2026-02-05-mypy-vs-phpstan-ru.markdown diff --git a/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown b/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown new file mode 100644 index 0000000..3cb32cd --- /dev/null +++ b/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown @@ -0,0 +1,178 @@ +--- +layout: post +title: "Mypy vs Phpstan: битва стат анализаторов" +date: 2026-02-05 +categories: ru +tags: "python php mypy phpstan" +--- + +## Дисклеймер +Все сказанное ниже валидно для mypy 1.19.1 и phpstan 2.1.37. + +## Что происходит? +Я собираюсь сравнить два статических анализатора, mypy и phpstan, с точки зрения качества выполнения +их основной функции --- проверки типо-безопасности кода. Если говорить более честно, +то это будет неприкрытая критика mypy, а phpstan здесь просто для того, чтоб помочь подсветить +проблемы и показать, что мои ожидания в целом реалистичны. + +Однако беда этого сравнения в том, что оно +априори некорректно, потому что эти два инструмента используются в двух разных языках +программирования: mypy в python, а phpstan в php. И соответственно, они даже не конкуренты. +Но давайте я все же попробую оправдаться, и пояснить за эту дерзость. + +Во-первых, это оба си-подобные языки, у которых схожая система примитивных типов. И они даже оба +null-safety. + +Во-вторых, с точки зрения типизации python и php также очень схожи. Оба языка с динамической +типизацией. Да, php уже во многом язык со строгой типизации, +но от этого она не перестает быть динамической. У python, кстати, строгая типизация тоже присутствует. +Ее можно увидеть при взаимодействии со стандартной библиотекой. Легко получить TypeError при вызове +`pow("2", 3)`. Но, тем не менее, в обоих языках, код вида +```python +a = "cool" +len(a) +a = 5 +pow(a, 4) +``` +валидный, хоть тип переменной и меняется в рантайме. + +В-третьих, оба языка используют специальные синтаксические конструкции для уточнения типов переменных, +которые исчезают в рантайме. Они позволяют статическому анализатору и IDE разобраться, в чем +вы неправы. Да, в python вообще все объявления типов исчезающее. В этом смысле, у php +типизация гораздо больше проявляется в рантайме. Но php вполне удовлетворит `array` в качестве +типа, что для стат анализа несет мало пользы. + +И все вместе это приводит к тому, что с точки зрения статического анализа Development Experience (DX) +очень похож. Но его качество несколько отличается. + +Чего здесь не будет? + +* Не будет докапывания до багов в стат анализаторах --- все баги временны +* Не будет сравнения синтаксических конструкций для объявления типов --- это вкусовщина, в обоих +языках есть к чему придраться +* Не будет жести вроде дженериков --- меня интересует экспириенс буквально в каждодневных задачах + +Теперь, когда сова полностью на глобусе, можно переходить к сути. И я буду двигаться от менее +важных, на мой взгляд, кейсов к более фундаментальным. + +## Енумы и литералы + +Допустим у нас в php коде есть енум. +```php +enum FooBarBaz: string +{ + case Foo = 'foo'; + case Bar = 'bar'; + case Baz = 'baz'; +} +``` +Как типизировать функцию, которая принимает на вход этот самый енум, а возвращает литерал-значение +этого енума? Ну вот так +```php +/** + * @return value-of + */ +function acceptEnumReturnLiteral(FooBarBaz $enum): string +{ + return $enum->value; +} +``` +Думаю, комментировать тут особо нечего. `value-of` говорит сам за себя. + +Теперь обращаемся к python. Вот енум +```python +class FooBarBaz(Enum): + FOO= "foo" + BAR= "bar" + BAZ= "baz" +``` + +И все тот же вопрос, как вернуть литерал? Внезапно оказывается, что только так. +```python +FooBarBazValue = Literal["foo", "bar", "baz"] + +def accept_enum_return_literal(enum: FooBarBaz) -> FooBarBazValue: + return enum.value +``` +И тут внезапно обнаруживается проблема, что мир енумов и литералов просто не пересекается +друг с другом. И мы видим дублирование значений. Кажется, что это не такая большая проблема. +Ну подумаешь, поприседать придется, если решил переименовать значение. Задачу-то мы решили. + +Теперь внезапно выясняется, что в другом месте, у нас новое требование: нам надо вернуть +подмножество литералов: `foo` и `bar`. И там точно не должен быть получен `baz`. Для php +у нас получается так +```php +/** + * @return value-of + */ +function acceptEnumReturnFooBar(FooBarBaz $enum): string +{ + if ($enum === FooBarBaz::Baz) { + throw new UnexpectedValueException('Unexpected value'); + } + return $enum->value; +} +``` + +Ну а теперь обратно к python +```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`, но все же немного +другой тип. + +Таким образом, для каждого подмножества значений енумов нам придется отдельно прописывать новый +тип с литералами, что не улучшает настроение. Конечно я не один такой страдающий тут. +Фича [реквест](https://github.com/python/typing/issues/781) висит уже 5 лет. Но увы. + +## Mypy --- доверчивый парнишка + +В этот раз предлагаю начать с python. Задача у нас в этот раз стоит нетривиальная. Давайте +мы на вход примем enum, а на выходе получим список с единственным элементом --- значением +этого енума. + +```python +def accept_enum_return_value_list(enum: FooBarBaz) -> list[FooBarBazValue]: + return [enum.value] +``` + +И здесь все в порядке. Но эту идилию очень легко сломать, просто добавив ненужный шаг. + +```python +def accept_enum_return_value_list(enum: FooBarBaz) -> list[FooBarBazValue]: + res = [enum.value] + return res +``` + +Получаем ошибку +``` +error: Incompatible return value type (got "list[str]", expected "list[Literal['foo', 'bar', 'baz']]") +``` +Ну, то есть, mypy как бы заботится о вас, подсказывая, что не надо делать ненужные шаги. + +На самом деле, как видно из ошибки, причина этого в том, что mypy вывел тип `res` как `list[str]`. +И конечно, если так широко подходить к задаче, то все правильно, ведь `list[str]` уж точно более +широкий тип, чем `list[Literal['foo', 'bar', 'baz']]`. ~~Но какого рожна!~~ + +Давайте поможем mypy справиться с этой проблемой. + +```python +def accept_enum_return_value_list(enum: FooBarBaz) -> list[FooBarBazValue]: + res: list[FooBarBazValue] = [enum.value] + return res +``` + +Мы уточнили тип `res`, и теперь mypy больше не ругается. Но он конечно же справедливо ругнется, если +сделать так + +```python +def accept_enum_return_value_list(enum: FooBarBaz) -> list[FooBarBazValue]: + res: list[FooBarBazValue] = [enum.value] + res.append("some shit") + return res +``` \ No newline at end of file From 00ba6dcdd926700e737c62c2f9bf8c08e381a1a3 Mon Sep 17 00:00:00 2001 From: ivanstrygin Date: Sun, 8 Feb 2026 01:12:22 +0300 Subject: [PATCH 2/2] mypy change --- _posts/2026-02-05-mypy-vs-phpstan-ru.markdown | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown b/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown index 3cb32cd..8c24e3a 100644 --- a/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown +++ b/_posts/2026-02-05-mypy-vs-phpstan-ru.markdown @@ -130,6 +130,78 @@ def accept_enum_return_foo_bar(enum: FooBarBaz) -> FooBarValue: тип с литералами, что не улучшает настроение. Конечно я не один такой страдающий тут. Фича [реквест](https://github.com/python/typing/issues/781) висит уже 5 лет. Но увы. +## Literal Widening + +Напишем еще более бессмысленный код. + +```python +def accept_enum_return_foo_bar() -> FooBarValue: + return "foo" +``` + +Будет ли ругаться mypy? Да конечно нет. Тут все ж корректно. `"foo"` --- один из ожидаемых литералов. + +А так? +```python +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? +```php +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` из ожидаемого типа. Но я хотел показать +другое. Поэтому я исправлю ошибку так. +```php +function returnIntOrString(bool $second): int|string +{ + $res = ['foo', 1]; + if ($second) { + return $res[1]; + } + return $res[0]; +} +``` +Теперь все в порядке. + +А вот аналогичный питонячий код +```python +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`. Целый [топик](https://github.com/python/mypy/labels/topic-join-v-union?page=1) +на гитхабе посвящен этой проблеме. И там куча issues. + + ## Mypy --- доверчивый парнишка В этот раз предлагаю начать с python. Задача у нас в этот раз стоит нетривиальная. Давайте