Что такое статический анализ кода

Статические анализаторы кода просто читают код и пытаются найти в нём ошибки. Они могут выполнять как очень простые и очевидные проверки (например, на существование классов, методов и функций, так и более хитрые - искать несоответствие типов, race conditions или уязвимости в коде). Ключевым является то, что анализаторы не выполняют код - они анализируют текст программы и проверяют её на типичные (и не очень) ошибки.

Наиболее очевидным примером статического анализатора PHP-кода являются инспекции в PHPStorm: когда вы пишете код, он подсвечивает неправильные вызовы функций, методов, несоответствие типов параметров и т. п. При этом PHPStorm не запускает ваш PHP-код - он его только анализирует.

Замечу, что в данной статье речь идёт именно об анализаторах, которые ищут ошибки в коде. Есть и другой класс анализаторов - они проверяют стиль написания кода, цикломатическую сложность, размеры методов, длину строк и т. п.

Хотя не всё, что находят рассматриваемые нами анализаторы, является именно ошибкой. Под ошибкой я имею ввиду код, который создаст Fatal на продакшене. Очень часто, то, что находят анализаторы, - это скорее неточность. Например, в PHPDoc может быть указан неправильный тип параметра. На работу кода эта неточность не влияет, но впоследствии код будет эволюционировать - другой программист может допустить ошибку.

Существующие анализаторы PHP-кода

Существует три популярных анализатора PHP-кода:

  • PHPStan
  • Phan
  • Psalm

Со стороны пользователя все три анализатора одинаковы: вы устанавливаете их (скорее всего, через Composer), конфигурируете, после чего можно запустить анализ всего проекта или группы файлов. Как правило, анализатор умеет красиво выводить результаты в консоль. Также можно выводить результаты в формате JSON и использовать их в CI. Все три проекта сейчас активно развиваются. Их maintainer-ы очень активно отвечают на issues в GitHub. Зачастую в первые сутки после создания тикета на него как минимум реагируют (комментируют или ставят тег типа bug/enhancement).

Что умеют анализаторы

У анализаторов много общих возможностей, поэтому сначала рассмотрим, что умеют они все, а затем перейдём к особенностям каждого из них.

Стандартные проверки

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

  • код не содержит синтаксических ошибок;
  • все классы, методы, функции, константы существуют;
  • переменные существуют;
  • в PHPDoc подсказки соответствуют действительности.

Кроме того, анализаторы проверяют код на неиспользуемые аргументы и переменные. Многие из этих ошибок приводят к реальным fatal-ам в коде.

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

Проверки типов данных

Конечно же, статические анализаторы осуществляют и стандартные проверки, касающиеся типов данных. Если в коде написано, что функция принимает, скажем, int, то анализатор проверит, нет ли мест, где бы в эту функцию передавался объект. У большинства анализаторов можно настроить строгость проверки и имитировать strict_types: проверять, что в эту функцию не передаются строки или Boolean.

Кроме стандартных проверок, анализаторы ещё много чего умеют.

Union types

Во всех анализаторах поддерживается концепция Union types. Допустим, у вас есть функция типа:

/** 	
 * @var string|int|bool $yes_or_no
 */
function isYes($yes_or_no) :bool 
{
    if (\is_bool($yes_or_no)) {
        return $yes_or_no;
    } elseif (is_numeric($yes_or_no)) {
        return $yes_or_no > 0;
    } else {
        return strtoupper($yes_or_no) == 'YES';
    }
}

Её содержимое не очень важно - важен тип входящего параметра string|int|bool. То есть переменная $yes_or_no - либо строка, либо целое число, либо Boolean.

Средствами PHP такой тип параметра функции описать нельзя. Но в PHPDoc это возможно, и многие редакторы (например, PHPStorm) его понимают.

В статических анализаторах такой тип называется union type, и они очень хорошо умеют проверять такие типы данных. Например, если вышеупомянутую функцию мы написали бы так (без проверки на Boolean):

/** 	
 * @var string|int|bool $yes_or_no
 */
function isYes($yes_or_no) :bool 
{
    if (is_numeric($yes_or_no)) {
        return $yes_or_no > 0;
    } else {
        return strtoupper($yes_or_no) == 'YES';
    }
}

анализаторы бы увидели, что в strtoupper может прийти либо строка, либо Boolean, и вернули бы ошибку - в strtoupper нельзя передавать Boolean.

Этот тип проверок помогает программистам правильно обрабатывать ошибки или ситуации, когда функция не может вернуть данные. Мы ведь часто пишем функции, которые могут вернуть какие-то данные или null:

// load() возвращает null или объект \User
$User = UserLoader::load($user_id);
$User->getName();

В случае такого кода анализатор подскажет, что переменная $User здесь может быть равна null и этот код может привести к fatal-у.

Тип false

В самом языке PHP довольно много функций, которые могут вернуть либо какое-то значение, либо false. Если бы мы писали такую функцию, то как бы мы задокументировали её тип?

/** @return resource|bool */
function fopen(...) {
    ...
}

Формально здесь вроде бы всё верно: fopen возвращает либо resource, либо значение false (которое имеет тип Boolean). Но когда мы говорим, что функция возвращает какой-то тип данных, это значит, что она может вернуть любое значение из множества, принадлежащего этому типу данных. В нашем примере для анализатора это значит, что fopen() может вернуть и true. И, например, в случае такого кода:

$fp = fopen('some.file', 'r');
if ($fp === false) {
    return false;
}
fwrite($fp, "some string");

анализаторы бы жаловались, что fwrite принимает первым параметром resource, а мы ему передаём bool (потому что анализатор видит, что возможен вариант с true). По этой причине все анализаторы понимают такой "искусственный" тип данных как false, и в нашем примере мы можем написать @return false|resource. PHPStorm тоже понимает такое описание типа.

Array shapes

Очень часто массивы в PHP используются как тип record - структуру с чётким списком полей, где каждое поле имеет свой тип. Конечно, многие программисты уже используют для этого классы. Но бывает, что программисты ленятся заводить отдельный класс для какой-то разовой структуры, и в таких местах также часто используют массивы.

Проблема таких массивов заключается в том, что чёткого описания этой структуры (списка полей и их типов) в коде нет. Программисты могут делать ошибки, работая с такой структурой: забывать обязательные поля или добавлять "левые" ключи, ещё больше запутывая код.

Анализаторы позволяют заводить описание таким структурам:

/** @param array{scheme:string,host:string,path:string} $parsed_url */
function showUrl(array $parsed_url) { ... }

В данном примере мы описали массив с тремя строковыми полями: scheme, host и path. Если внутри функции мы обратимся к другому полю, анализатор покажет ошибку.

Если не описывать типы, то анализаторы будут пытаться "угадать" структуру массива. У этого подхода есть один недостаток. Допустим, у вас есть структура, которая активно используется в коде. Нельзя в одном месте объявить некоторый псевдотип и потом везде его использовать. Вам придётся везде в коде прописать PHPDoc с описанием массива, что очень неудобно, особенно если в массиве много полей. Также проблематично будет потом редактировать этот тип (добавлять и удалять поля).

Описание типов ключей массивов

В PHP ключами массива могут быть целые числа и строки. Иногда типы могут быть важны для статического анализа (да и для программистов). Статические анализаторы позволяют описывать ключи массива в PHPDoc:

/** @var array<int, \User> $users */
$users = UserLoaders::loadUsers($user_ids);

В данном примере мы с помощью PHPDoc добавили подсказку о том, что в массиве $users ключи - целочисленные int-ы, а значения - объекты класса \User. Мы могли бы описать тип как \User[]. Это сказало бы анализатору, что в массиве объекты класса \User, но ничего не сказало бы нам о типе ключей.

Своё пространство имён в PHPDoc

PHPStorm (да и другие редакторы) и статические анализаторы могут по-разному понимать PHPDoc. Например, анализаторы поддерживают вот такой формат:

/** @param array{scheme:string,host:string,path:string} $parsed_url */	
function showUrl($parsed_url) { ... }

А PHPStorm его не понимает. Но мы можем написать так:

/** 
 * @param array $parsed_url
 * @phan-param array{scheme:string,host:string,path:string} $parsed_url
 * @psalm-param array{scheme:string,host:string,path:string} $parsed_url  
*/
function showUrl($parsed_url) { ... }

В этом случае будут довольны и анализаторы, и PHPStorm. PHPStorm будет использовать @param, а анализаторы - свои PHPDoc-теги.

Проверки, связанные с особенностями PHP

Этот тип проверок лучше пояснить на примере.

Все ли мы знаем, что может вернуть функция explode()? Если бегло посмотреть документацию, кажется, что она возвращает array. Но если изучить более внимательно, то мы увидим, что она может вернуть ещё и false. На самом деле, она может вернуть и null и ошибку, если передать ей неправильные типы, но передача неправильного значения с неправильным типом данных - это уже ошибка, поэтому этот вариант нас сейчас не интересует.

Формально с точки зрения анализатора, если функция может вернуть false или массив, то, скорее всего, потом в коде должна быть проверка на false. Но функция explode() возвращает false, только если разделитель (первый параметр) равен пустой строке. Зачастую он явно прописан в коде, и анализаторы могут проверить, что он не пуст, а значит, в данном месте функция explode() точно возвращает массив и проверка на false не нужна.

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

Переходим к описанию конкретных анализаторов.


PHPStan

Разработка некоего Ondřej Mirtes из Чехии. Активно разрабатывается с конца 2016 года.

Чтобы начать использовать PHPStan, нужно:

  1. Установить его (проще всего это сделать через Composer).
  2. (опционально) Сконфигурировать.
  3. В простейшем случае достаточно запустить:
vendor/bin/phpstan analyse ./src

(вместо src может быть список конкретных файлов, которые вы хотите проверить).

PHPStan прочитает PHP-код из переданных файлов. Если ему встретятся неизвестные классы, он попробует подгрузить их автолоадом и через reflection понять их интерфейс. Вы можете также передать путь к Bootstrap-файлу, через который вы настроите автолоад, и подключить какие-то дополнительные файлы, чтобы упростить PHPStan анализ.

Ключевые особенности:

  1. Можно анализировать не всю кодовую базу, а только часть - неизвестные классы PHPStan попытается подгрузить автолоадом.
  2. Если по какой-то причине какие-то ваши классы не в автолоаде, PHPStan не сможет их найти и выдаст ошибку.
  3. Если у вас активно используется магические методы через __call / __get / __set, то вы можете написать плагин для PHPStan. Уже существуют плагины для Symfony, Doctrine, Laravel, Mockery и др.
  4. На самом деле, PHPStan выполняет автолоад не только для неизвестных классов, а вообще для всех.
  5. Конфиги в формате neon.
  6. Нет поддержки своих PHPDoc-тегов типа @phpstan-var, @phpstan-return и т. п.

Ещё одной особенностью является то, что у ошибок есть текст, но нет никакого типа. То есть вам возвращается текст ошибки, например:

  • Method \SomeClass::getAge() should return int but returns int|null
  • Method \SomeOtherClass::getName() should return string but returns string|null

В этом примере обе ошибки, в общем-то, об одном и том же: метод должен вернуть один тип, а в реальности возвращает другой. Но тексты у ошибок разные, хотя и похожие. Поэтому, если вы хотите отфильтровать какие-то ошибки в PHPStan, делайте это только через регулярные выражения.

Для сравнения, в других анализаторах у ошибок есть тип. Например, в Phan такая ошибка имеет тип PhanPossiblyNullTypeReturn, и можно в конфиге указать, что не требуется проверка на такие ошибки. Также, имея тип ошибки, можно, например легко собрать статистику по ошибкам.


Phan

Разработка компании Etsy. Первые коммиты от Расмуса Лердорфа.

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

Под капотом Phan использует расширение php-ast. По-видимому, это одна из причин того, что анализ всей кодовой базы проходит относительно быстро. Но php-ast показывает внутреннее представление AST-дерева так, как оно отображается в самом PHP. А в самом PHP AST-дерево не содержит информации о комментариях, которые расположены внутри функции. То есть, если вы написали что-то вроде:

/**
 * @param int $type
 */
function doSomething($type) {
    /** @var \My\Object $obj **/
    $obj = MyFactory::createObjectByType($type);
    ...
}

то внутри AST-дерева есть информация о внешнем PHPDoc для функции doSomething(), но нет информации PHPDoc-подсказки, которая внутри функции. И, соответственно, Phan тоже о ней ничего не знает. Это наиболее частая причина false-positive в Phan.

Вторая неприятная особенность состоит в том, что Phan плохо анализирует свойства объектов. Вот пример:

class A {
    /**
     * @var string|null
     */
    private $a;
	
    public function __construct(string $a = null)
    {
        $this->a = $a;
    }
	
    public function doSomething()
    {
        if ($this->a && strpos($this->a, 'a') === 0) {
            var_dump("test1");
        }
    }
}

В этом примере Phan скажет вам, что в strpos вы можете передать null.

Резюме. Несмотря на некоторые сложности, Phan - очень крутая и полезная разработка. Кроме этих двух типов false-positive, он почти не ошибается, либо ошибается, но на каком-то действительно сложном коде.


Psalm

Psalm - разработка компании Vimeo. Честно говоря, я даже не знал, что в Vimeo используется PHP, пока не увидел Psalm.

Этот анализатор - самый молодой из нашей тройки. Когда я прочитал новость о том, что Vimeo выпустила Psalm, был в недоумении: "Зачем вкладывать ресурсы в Psalm, если уже есть Phan и PHPStan?". Но выяснилось, что у Psalm есть свои полезные особенности.

Psalm пошёл по стопам PHPStan: ему тоже можно дать список файлов для анализа, и он проанализирует их, а ненайденные классы подключит автолоадом. При этом он подключает только ненайденные классы, а файлы, которые мы попросили проанализировать, не будут include-иться (в этом отличие от PHPStan). Конфиг хранится в XML-файле.

У Psalm есть сайт с "песочницей", где можно написать код на PHP и проанализировать его. Это очень удобно для баг-репортов: воспроизводишь ошибку на сайте и даёшь ссылку в GitHub. И, кстати, на сайте описаны все возможные типы ошибок. Для сравнения: в PHPStan у ошибок нет типов, а в Phan они есть, но нет единого списка, с которым можно было бы ознакомиться.

При выводе ошибок Psalm сразу показывает строки кода, где они были найдены. Это сильно упрощает чтение отчётов.

Но, пожалуй, самой интересной особенностью Psalm являются его кастомные PHPDoc-теги, которые позволяют улучшить анализ (особенно определение типов).


Отчёты анализаторов: мнение программистов

Нельзя сказать, что все программисты в восторге от статических анализаторов. Причин тут несколько.

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

Во-вторых, многое в отчётах анализаторов - просто неточности, например, неправильно указанные типы в PHPDoc. Некоторые программисты пренебрежительно относятся к таким ошибкам - код ведь работает.

В-третьих, у некоторых программистов завышенные ожидания. Они думали, что анализаторы будут находить какие-то хитрые баги, а вместо этого они вынуждают их добавлять проверки типов и исправлять PHPDoc.

Однако польза, принесённая анализаторами, перекрывает все эти незначительные недовольства. И как ни крути, это хорошая инвестиция в будущий код.