Одна из проблем статического анализа в том, что его легко сделать умнее, чем надо. В результате он начинает выдавать предупреждения в таком коде, который человеку кажется нормальным. И так и хочется сказать анализатору «Отстань, зануда! Сильно умный что ли?»


Я в очередной раз почувствовал вкус всего этого, когда работал над поиском константных выражений для Kotlin. Ранее такой анализ был для Java, но для Kotlin он впервые появится только в следующей версии IntelliJ IDEA 2021.3. Инспекция базируется на анализе потока данных и находит в коде выражения, которые всегда равны одному и тому же. Изначально такая инспекция на Java сообщала только о логических выражениях, которые всегда равны true или false. Потом мы осторожно расширили её, и она стала сообщать ещё и о выражениях, которые всегда равны null или 0. Было решено проделать тот же путь для Kotlin.


Нам успешно удалось переиспользовать ядро Java-анализатора, так что во многом анализ на Kotlin получился таким же умным как на Java. Некоторые вещи ещё недоделаны, но это вопрос времени. Однако после того как анализатор начинает работать, выясняется, что он выдаёт много мусора.


Вот начнём с простого


val x = true // 'true' is always true

Анализатор такая умница, говорит, что выражение true всегда истинно. А то мы не догадались! Ладно, запрещаем предупреждение на литералах. Дальше видим такое:


val ignoreCase = true
if (command.startsWith("--data", ignoreCase)) { // 'ignoreCase' is always true
    //...
} else if (command.startsWith("--info", ignoreCase)) { // 'ignoreCase' is always true
    //...
}

Тоже супергениально. Ну захотелось нам вынести опцию в константу. Чтобы код был понятнее или мы хотим, чтобы можно было легко изменить во всех местах сразу. Мы и так знаем, что она истинна. Замолчи, анализатор.


if (x <= 0) {
    // ...
    return
}
assert(x > 0) // 'x > 0' is always true
// ...

Ай-яй-яй, говорит, анализатор, вы написали ассерт, который точно никогда не свалится. Но постойте! Ассерты ведь для того и нужны, чтобы никогда не свалиться. Если ассерт свалился, то это ошибка в программе. Как раз от ошибки и страхует ассерт. Мало ли, может я потом буду что-то менять и случайно удалю return в вышестоящем условии. Тогда ассерт быстро скажет мне, что я неправ. Окей, объяснили анализатору, что если условие в ассерте и вычисляется в true, то ругаться не надо (а вот если в false, то ещё как надо). Кстати, анализатор может вывести истинность только части выражения. Например, ассерт мог бы быть таким assert(x > 0 && somethingElse). В этом случае тоже ругаться не надо.


Продолжаем наше шоу. Посмотрим на оператор when:


when {
    a && b && c -> {}
    a && b && !c -> {} // '!c' is always true
    a && !b && c -> {} // '!b' is always true
    a && !b && !c -> {} // '!b' is always true, '!c' is always true
    // ...
}

Тут целый ворох предупреждений. Действительно, when исполняется сверху вниз и выбирается ровно одна ветка. Если мы не попали в первую, но a и b у нас истинны как и в первой, то c уже точно ложно. А если не попали ни в первую, ни во вторую, то после проверки a мы уже наверняка знаем, что b ложно. Этот же код можно переписать так:


when {
    a && b && c -> {}
    a && b -> {}
    a && c -> {}
    a -> {}
    // ...
}

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


Вот такой шаблон ещё обнаружился:


fun updatePriority(priority: String) {
    if (priority != "high" && priority != "medium" && priority != "low") return
    LOG.info("Updating priority to $priority")
    when (priority) {
        "high" -> setPriority(1)
        "medium" -> setPriority(2)
        "low" -> setPriority(3) // when condition is always true
        else -> throw AssertionError("Unreachable")
    }
}

Анализатор достаточно умён, чтобы отследить, что мы уже отсекли любую строку кроме трёх перечисленных, поэтому если мы не прошли в ветку "high" или "medium", то мы уж точно пройдём в ветку "low". Опять же можно сократить код до такого:


if (priority != "high" && priority != "medium" && priority != "low") return
LOG.info("Updating priority to $priority")
when (priority) {
    "high" -> setPriority(1)
    "medium" -> setPriority(2)
    else -> setPriority(3)
}

Опять же стало короче, но стало ли понятнее? Кажется, не всем такое понравится. Поэтому про всегда истинную ветку в конце when или перед else мы решили тоже не предупреждать.


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


fun process(): Boolean {
    doSomething()
    return true
}

Им обязательно выпендриваться:


fun process() = doSomething().let { true }

Здесь результат выражения doSomething().let { true } всегда истинный, о чём анализатор радостно сообщает. Извини, анализатор, но люди странные существа. Тебе придётся с этим мириться. Аналогичный вариант с also тоже пришлось подавлять:


fun process() = true.also { doSomething() }

Обнаружилась ещё одна довольно удивительная вещь:


fun isOption(s: String?) : Boolean {
    return s?.let { return it.startsWith("--") } ?: false
}

Анализатор говорит, что всё выражение s?.let { return it.startsWith("--") } ?: false всегда ложно. Смысл в том, что если s == null, тело let не выполняется и благодаря elvis-оператору, оно вычисляется в false. Если же s != null, тело let выполняется, но мы выходим из выражения досрочно, так как return внутри let покидает всю функцию, а не только тело let. В итоге в этом случае выражение вообще не имеет значения. То есть если оно успешно вычислилось, то оно точно вычислилось в false.


Понятно, что это странный код и внутренний return хорошо бы убрать или в крайнем случае заменить на return@let (мы видели подобные конструкции со сложным телом let, в котором много раз сделано return). Тем не менее код работает так как задумано. Во всяком случае, если и предупреждать, то это должна быть другая инспекция, а не "condition is always false", что может быть совершенно непонятно пользователю.


Порой анализатор считает себя не только умнее пользователя, но и умнее компилятора Kotlin. Вот несколько искусственный пример, но подобное мы встречали нередко и в реальном коде:


fun processString(str: String?) {
    val empty = str?.isEmpty() ?: true
    if (!empty && str != null) { // 'str != null' is always true
        println("String: " + str.trim())
    }
}

Анализатор, конечно, соображает, что под проверкой !empty мы точно знаем, что str != null истинно. Однако этого не знает компилятор. А компилятору эта проверка нужна для смарт-каста. Если её убрать, код перестанет компилироваться. Подружить компилятор с анализатором оказалось весьма нетривиальной задачей.


Ладно, с true и false примерно разобрались. Что там с null и нулём?
Литерал null мы уже подавили, но оказалось, что люди иногда приводят null к другому типу, чтобы разрешить неоднозначность перегруженных функций:


fun process(x: Int?) {}
fun process(x: String?) {}

fun use() {
    process(null as Int?)
}

Анализатор, конечно, неглуп и радостно говорит нам, что выражение null as Int? всегда равно null. Снова пришлось объяснять ему, что тут лучше помолчать.


Иногда приходится затыкать анализатор, чтобы он не ругался несколько раз на одно и то же:


fun processString(left: String?, right: String?) {
    val empty = left == null && right == null
    if (empty) {
        //...
        if (left == null) { // 'left' is always null, 'left == null' is always true
            //...
        }
    }
}

Да, да, дорогой, ты уже сказал мне, что left == null всегда истинно в этом месте. Молодец, можно не выдавать второе предупреждение, что left всегда равно null, я и так уже понял.


Что касается целочисленного 0, мы обнаружили такой паттерн для формирования битовой маски:


var mask = 0
if (shouldUpdate()) mask = mask or SHOULD_UPDATE // 'mask' is always zero
if (shouldVerify()) mask = mask or SHOULD_VERIFY
if (shouldNotify()) mask = mask or SHOULD_NOTIFY
return mask

Ну да, в первом условии mask точно 0, можно было просто написать mask = SHOULD_UPDATE вместо mask = mask or SHOULD_UPDATE. Но ведь это будет некрасиво, потеряется аккуратная структура программы. Да и если захочется переупорядочить строки или добавить новую в самое начало, можно будет сломать программу. Анализатор хоть и умный, но ничего не понимает в программировании. Ладно, научили его не ругаться в этом случае.


Ещё мы поддержали вызов ordinal() для enum-типов и немного огребли от чрезмерно умного анализатора. Скажем, в таком коде предупреждение оказалось совершенно неуместно:


enum class Vitamin {
    A, B, C;
}

fun fromOrdinal(ord: Int) : Vitamin {
    when (ord) {
        Vitamin.A.ordinal -> Vitamin.A // 'Vitamin.A.ordinal' is always zero
        Vitamin.B.ordinal -> Vitamin.B
        Vitamin.C.ordinal -> Vitamin.C
    }
}

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


Описанная инспекция доступна в ранних сборках IntelliJ IDEA 2021.3 (только для Kotlin/JVM). Вы можете проверить свой код и поделиться результатами в комментариях. Удалось ли найти настоящие баги? Или может одни только ложные предупреждения? Расскажите, очень интересно!

Комментарии (34)


  1. vladimirsitnikov
    02.11.2021 14:23

    А зачем в updatePriority примере нужен был if? Если его убрать, то станет проще читаться и будет проще поддерживать (не придётся дублировать литералы). Правильно оно ругалось на код, по делу.

    command.startsWith("--data", ignoreCase) -- тоже по делу ругалось. Если хочется пояснить ignoreCase, то почему бы не использовать для этого именованные параметры? Зачем переменную объявлять? Вот так хорошо: command.startsWith("--data", ignoreCase = true)


    1. tagir_valeev Автор
      02.11.2021 14:58
      +6

      А зачем в updatePriority примере нужен был if?

      Проблема уменьшенных примеров во всей красе. Например, перед when могло быть общее действие, которое надо сделать для всех трёх вариантов. Давай что-нибудь добавлю.

      command.startsWith("--data", ignoreCase) -- тоже по делу ругалось

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


      1. vladimirsitnikov
        02.11.2021 19:06
        +1

        Например, перед when могло быть общее действие, которое надо сделать для всех трёх вариантов. Давай что-нибудь добавлю

        По-моему, всё равно if лишний. Убрать бы его. Получается, кто-то там может слать недопустимые значения, а мы их игнорируем. Конечно, так и до enum недалеко додуматься, но вопросы остаются.

        Если уже 25 вызовов с одним значением ignoreCase, то пора extract method делать.

        Разумеется, я поддерживаю, что ругаться должна инспекция "вы тут зря переменную завели, воспользуйтесь лучше именованными параметрами", а не "expression is always true", просто исходно код выглядит стрёмно, и зачастую ожидаемо, что анализатор будет что-то там бурчать, особенно, когда код стрёмный.


        1. tagir_valeev Автор
          02.11.2021 19:09

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

          Ну ты зануда. Ну представь, что там исключение кидается. Лучше будет?

          просто исходно код выглядит стрёмно

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


          1. vladimirsitnikov
            02.11.2021 19:31
            +1

            Ну ты зануда

            Ты меня раскусил

            Ну представь, что там исключение кидается. Лучше будет?

            Исключение прекрасно кинется в when else ветке. Ну, тут я полностью соглашусь, что корень проблемы не в "expression is always true", а в том, что тут возникло дублирование логики, и, вполне возможно, это дублирование следует убрать, чтобы перечни статусов не разошлись (ну, вдруг добавят ветку в when, а if забудут поправить?). Но, если бы у меня в том when "low" возникло предупреждение "condition is always true", то я бы порадовался и сказал: "а, действительно, как так получилось, что always true" и пошёл бы и убрал if или что там.


          1. Nashev
            03.11.2021 00:02

            А есть режимчик, чтоб все эти левые таки отображались?


  1. vladimirsitnikov
    02.11.2021 14:28

    Пример про isOption, конечно, нужно вообще без return записывать. Там ни return ни let не нужно: `isOption(s: String?) : Boolean = s?.startsWith("--") ?: false`. Ну или на String.isOption переделать (но это уже от проекта зависит)


    1. tagir_valeev Автор
      02.11.2021 15:01
      +1

      Твой встроенный оптимизатор отлично работает! А теперь придумай мне короткий пример, чтобы let был оправдан :-)


      1. vladimirsitnikov
        02.11.2021 19:24

        Возможно, что 99.42% всех таких примеров сведутся к тому, что незачем там return внутри let использовать, и оно должно ругаться словами "убирайте return" или "убирайте let"


        1. tagir_valeev Автор
          02.11.2021 19:27

          Ну ещё раз говорю - это должна быть отдельная паттерновая инспекция, а не датафлоу.

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


  1. anonymous
    00.00.0000 00:00


    1. vladimirsitnikov
      02.11.2021 19:25

      А смысл? Предупреждения должны нести смысл. Если над каждым придётся думать, то смысла в них никакого не будет. Так-то в каждой строке можно предупреждение придумать: "вы точно уверены, что тут переменная x, а не y?"


  1. vladimirsitnikov
    02.11.2021 19:14

    val empty = str?.isEmpty() ?: true if (!empty && str != null) { // 'str != null' is always true

    Что только люди не напишут, лишь бы не делать по-простому (штатный механизм в stdlib): if (str.isNullOrEmpty()) { ... }


    1. tagir_valeev Автор
      02.11.2021 19:18

      Так и знал, что ты к этому придерёшься. Даже написал, что пример искусственный, но не помогло :-) isNullOrEmpty анализатор не распознаёт (по крайней мере пока).


      1. vladimirsitnikov
        02.11.2021 19:38

        Тема статьи какая? "Вот нормальный код, анализатор пытался его обругать, но не надо было". А тут смотришь -- и между строк написано "тут должно было быть isNullOrEmpty".

        Я другое спрошу (213.4631.20, 6 October). Почему на if (false) ругается, что "condition is always false", а на val x = false; if (x) уже нет? Бага получается? Я бы на if (false) и if (true) не ругался.


        1. vladimirsitnikov
          02.11.2021 19:40

          Хотя посмотрел на Java -- там if (false) ругается (почему бы?). Наверное, если уж делать, то однообразно.


          1. tagir_valeev Автор
            02.11.2021 19:48

            Не понял. Ругается и в джаве, и в Котлине? В чём тогда проблема?


            1. vladimirsitnikov
              02.11.2021 19:49
              +1

              Думаю, ни там ни там не надо. Разумеется, "правильного ответа" тут нет, и как партия решит, так пусть и будет. Но я всё равно думаю, что подкрашивать условие if(false) это странно. Вполне нормально взять и написать while(false), if(false) и т.п. для отладки. Если оно более блеклым цветом отметит "никогда не выполняющиеся ветки кода", то это другой разговор. А подкрашивать "if(false) is always false" -- ну, зачем?


              1. Maccimo
                02.11.2021 21:24

                Тут вам не Си, мёртвый код выпиливать надо, а не ифами отключать. Для отладки желтизна наоборот плюс — будет бросаться в глаза и не проскочит случайно в коммит.


                1. vladimirsitnikov
                  02.11.2021 21:34
                  +1

                  В Java language specification отдельно описано, что if (false) это годный код, и компилятор не должен ругаться на его недостижимость: https://docs.oracle.com/javase/specs/jls/se7/html/jls-14.html#jls-14.21


                  1. Maccimo
                    02.11.2021 22:01
                    +1

                    В статье речь про Котлин, странно приводить JLS в качестве аргумента.


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


                    На мой взгляд static final boolean FLAG = ... if (FLAG) ... это нормально, а вот if (false) выглядит грязно. Лучше уж закомментировать.


                    1. vladimirsitnikov
                      02.11.2021 22:11

                      Если Котлин, то при чём тут Си?

                      На самом деле, статья и начинается со слов, что в Java анализатор уже написан и отлажен, поэтому с Java сравнивать логично.

                      Если код закомментировать, то он может перестать компилироваться, поэтому if false зачастую лучше, чем комментирование кода


                  1. tagir_valeev Автор
                    03.11.2021 04:28

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


        1. tagir_valeev Автор
          02.11.2021 19:40

          Кажется, это другая инспекция, которая конкретно разворачивает if(true)/if(false) и больше ничего


          1. vladimirsitnikov
            02.11.2021 19:43

            "condition is always false". И quick-fix там -- "delete expression"


            1. tagir_valeev Автор
              02.11.2021 19:45

              Да. У моей инспекции фикса пока вообще нет.


        1. tagir_valeev Автор
          02.11.2021 19:43
          +1

          Вообще это тонкий момент. Люди любят параметризовать свой код чем-нибудь типа val debug = true и потом бранчеваться по этому условию. И ты опять же скажешь, что это плохо. Но нам надо поддерживать все популярные стили написания кода, а не только твой. Поэтому выдавать тут кучу предупреждений не представляется возможным.


          1. vladimirsitnikov
            02.11.2021 19:47
            +1

            Не, объявить val debug=false и бранчеваться -- самая тема. Поэтому, да, соглашусь, что ругаться на if (debug) не стоит. Поэтому же и считаю, что ругаться на if (false) не должно. Тут же по-человечески написано false. Зачем лишний раз сообщать, что "condition is always false"? :)


    1. tagir_valeev Автор
      03.11.2021 07:07

      Вообще чем критиковать искусственные примеры, лучше бы проанализировал какой-нибудь реальный код и рассказал, что нашлось!


  1. ialexander
    03.11.2021 11:40

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

    Например, const int kDefaultCacheSize = 5 * 1024 * 1024, чтобы выразить в читаемом виде 5 мегабайт. Или, например, 2 * kMillisecondsInMinute.


    1. tagir_valeev Автор
      03.11.2021 14:22

      Опыт подсказывает, что такие предупреждения могут выявить (в дополнение к тривиальным) исключительно нетривиальные баги в программе. Для хорошего примера могу сослаться на наших коллег из PVS-Studio: 31 февраля. Или вот моя статья Статический анализ → уязвимость → профит. Хорошо написанный анализатор не выдаёт предупреждения на константах, которые введены для понятности кода, а выдаёт только на подозрительных константах.


  1. WASD1
    08.11.2021 18:42

    if (x <= 0) { // ... return } assert(x > 0) // 'x > 0' is always true


    Простите, а разве тут не case-выражение (или что там применяется для exhaustive перечислений в Kotlin) нужно использовать?


    1. Maccimo
      10.11.2021 08:34

      Тут if ... return вставлен исключительно для того, чтобы анализ потока данных на строке с assert(x > 0); точно знал, что это выражение всегда истинно. Это же не реальный код, а пример для иллюстрации.


  1. AlexanderAlexandrovich
    20.11.2021 18:24
    -1

    Да, иногда статический анализатор борщит