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

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

Для примера будет история об одной строке кода из довольно популярной библиотеки с открытым кодом для работы с XML. Контекст этой строки такой:
    fseek( fp, 0, SEEK_END );
    const long filelength = ftell( fp );
    fseek( fp, 0, SEEK_SET );
    if( filelength == -1L ) {
        return FILE_READ_ERROR;
    }
    if( filelength >= (size_t)-1 ) {// ВОТ ЭТА СТРОКА
        return FILE_READ_ERROR;
    }
    const size_t size = filelength;
    _charBuffer = new char[size+1];
    size_t read = fread( _charBuffer, 1, size, fp );
    if( read != size ) {
        return FILE_READ_ERROR;
    }

Этот фрагмент пытается прочитать файл на диске целиком. Для этого переводит файловый указатель на конец файла и получает текущую позицию в файле. Дальше делается попытка выделить память под данные всего файла. Перед выделением памяти стоит проверить, поместится ли требуемый размер в size_t, чтобы избежать труднодиагностируемых логических ошибок при дальшейшей работе.

Выполняется (по сути, а в коде стоит противоположная и возврат кода ошибки) проверка, что filelength строго меньше, чем максимальное значение size_t. Проверка выполняется на «строго меньше», потому что нужно выделить памяти на один элемент больше, чтобы записать завершающий нулевой символ перед дальнейшим разбором содержимого файла.

Все бы хорошо, но компилятор негодуэ — ведь с одной стороны сравнения знаковый тип long, а с другой стороны — беззнаковый size_t. ПРЕДУПРЕЖДЕНИЕ -Wsign-compare от gcc!!!

Что же делать, куда бежать??? Это же предупреждение!!!

Один из разработчиков исправляет проверку:
    if( (size_t)filelength >= (size_t)-1 ) {

… и компилятор УЗБАГОИЛСЯ доволен. Успех? Увы, изначально правильный код теперь работает уже не так, как было задумано. Размеры long и size_t не связаны друг с другом. Размер любого из этих типов может быть больше размера второго на конкретной платформе. Первоначальный код требовал в таком случае преобразования обоих значений к одному и тому же типу — достаточного для обоих размера. В «исправленном» коде произойдет потеря части значащих разрядов в случае, если размер long больше размера size_t.

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

Ошибка могла бы остаться надолго и еще многие годы радовать пользователей при работе с очень большими файлами на некотором подмножестве систем. Не осталась — код библиотеки открыт и даже лежит на Github, вскоре приходит пул-запрос с правкой этого кода. В исправленном коде проверка выглядит так:
     if( (unsigned long)filelength >= (size_t)-1 ) {

В исправленном коде сравниваемые значения оба беззнаковых типов, поэтому ужасная проблема(тм), о которой gcc сообщал предупреждением -Wsign-compare, здесь отсутствует. Приведение long к unsigned long здесь не приводит к неверной интерпретации данных, потому что единственно возможное отрицательное значение обработано ранее.

Успех? Нет, не так быстро.

Правку вливают в основную ветвь, после чего начинают поступать сообщения о новом предупреждении. При компиляции под платформы, где размер unsigned long меньше размера size_t, clang выдает предупреждение -Wtautological-constant-out-of-range-compare «ПРЕДУПРЕЖДЕНИЕ!!! Диапазон значений unsigned long так уныл и безнадежно ограничен, что сравнение всегда дает один и тот же результат и не имеет решительно никакого смысла».

Полностью переносимый код для любой платформы, говорите? Нет, когда явное приведение long к size_t могло повлечь потерю разрядов, это всех устраивало. А теперь clang выдает бесполезное предупреждение и пользователи библиотеки оставляют комментарии к правке и сообщения о дефектах. Это же предупреждение!!!

Что же делать… ведь нужно сделать так, чтобы на платформах, где размер unsigned long меньше размера size_t, сравнения не было, а на остальных платформах — было.

А, вот решение «проблемы»:
template <bool = (sizeof(long) >= sizeof(size_t))>
struct LongFitsIntoSizeTMinusOne {
    static bool Fits( long value )
    {
        return (unsigned long)value < (size_t)-1;
    }
};
template <> bool LongFitsIntoSizeTMinusOne<false>::Fits( long /*value*/ )
{
    return true;
}

— Что это, Бэрримор???
— Это шаблон Баскервилей, сэр!!!

Здесь можно было бы применить шаблон функции, но значения параметров шаблона по умолчанию для шаблонов функций поддерживаются только начиная с C++11, а библиотека — чтобы расширить аудиторию — старается без него обходиться. struct вместо class используется, чтобы не указывать видимость public явно.

Применяется так:
    if ( !LongFitsIntoSizeTMinusOne<>::Fits( filelength ) ) {

В зависимости от соотношения между размерами long и size_t компилятор выбирает либо специализацию, либо реализацию по умочанию. В первом случае бессмысленной проверки не будет, и компилятору не о чем будет ПРЕДУПРЕЖДАТЬ.

В основную ветвь вливают проверку с использованием этого шаблона — и «проблема» решена.

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

Теперь давайте порассуждаем…

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

Почему так произошло? Причин ровно две.

Первая причина — разработчики почти безоговорочно доверяют компилятору. Рядом с такой сложной программой многие разработчики ощущают себя как рядом со 107-метровым монументом «Покорителям космоса» на проспекте Мира в Москве. Современный компилятор — это очень сложная программа, и многие считают, что она не может ошибаться. Увы, это мнение неверно — чем программа сложнее, тем больше у нее возможностей ошибиться. Компилятор может выдать предупреждение на совершенно правильный код.

Вторая причина — разработчики статических анализаторов (особенно входящих в состав компиляторов) обычно считают, что ложное срабатывание — это не проблема и ничего страшного, главное ПРЕДУПРЕДИТЬ, а «разработчик должен разобраться». Разберется непременно, но далеко не факт, что в результате код станет лучше.

В обсуждениях этой проблемы проскакивают идеи, что компилятор никак не может разобраться, есть ли в коде проблема, и только «разработчик должен».
Действительно ли компилятор так безнадежно беспомощен и не может «понять» код? Самое время вспомнить об оптимизациях кода. Нетривиальные оптимизации кода — автоматическая векторизация и размотка циклов, вычисление инвариантов, удаление лишних проверок и другие полезные и умные преобразования — требуют от компилятора очень вдумчивого анализа кода.

Насколько вдумчивый анализ нужен в приведенном примере? Проверяемое значение получено от выполнения функции ftell(), ее поведение задокументировано. gcc, например, уже умеет оптимизировать — и «доламывать» не соответствующий Стандарту — код, используя требования Стандарта передавать в «строковые функции» — например, memcpy() — только ненулевые указатели, подробности есть в этой публикации. Как он это делает? Разработчики gcc добавили в него такую возможность — опознавать ряд функций как «строковые» и делать некоторые предположения о значениях передаваемых в эти функции указателей.

Точно так же разработчики компилятора могут добавить в него возможность использовать данные о поведении других функций стандартной библиотеки — вряд ли тривиально, но определенно выполнимо. Сторонние статические анализаторы также «знают» о функциях стандартной библиотеки и популярных сред, это позволяет им сообщать, например, о возможной логической ошибке в приведенном выше коде в случае, когда результат ftell() не проверяется на сообщающее об ошибке значение -1L.

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

Можно «научить» компилятор оптимизировать и «доламывать» не соответствующий Стандарту код с вызовом строковых функций, а можно «научить» его выдавать меньше бесполезных предупреждений.

Что на выходе? Многие средства статического анализа пока работают по принципам «главное предупредить» и «разработчик должен», этим они провоцируют разработчиков вносить в код ненужные правки, попутно иногда ломать работающий код. Можно, конечно, объявить этих разработчиков недостойными и криворукими, но не стоит забывать, что от их работы зависит судьба миллионов строк кода, используемых каждый день сотнями миллионов человек.

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

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

Дмитрий Мещеряков,
департамент продуктов для разработчиков

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


  1. Mixim333
    19.02.2016 18:49
    -3

    Как-то VS тоже выдала, но еще круче: метод состоит из 3 строчек кода: инициализировать переменную класса T, вызвать метод MyMethod и вернуть его результат; статический анализатор выдал предупреждение: «CSxxx: Возможен повторный вызов метода Dispose()» — какой Dispose, класс вообще не реализует интерфейс IDisposable?


  1. HUktoCode
    21.02.2016 08:24
    +1

    при написании кода на obj-c в Xcode — лично встречается 2 случая, когда рекомендуется мутить компилятор clang прагмами.

    В первом случае — когда объекту базового класса (вообще-то динамически типизированный язык) пытаюсь передать селектор (нечто вроде указателя на функцию) конкретного класса — он выдает варнинг, что не факт, что метод с таким селектором будет у конечного объекта. Приходится мутить, потому-что перед этим обычно выполняется ручная проверка на то, есть ли данный селектор в таблице селектора объекта (таблица указателей методов). И компилятор не понимает эту проверку!

    И еще довольно специфичная работа с памятью в iOS (что-то типа умных указателей со счетчиком ссылок), которые живут в retain-циклах (retain увеличивает число ссылок на 1). Существуют случаи, когда приходится мутить сообщения комилятора, связанные с его анализом этих циклов (особенно при использовании блоков). Блок — это что-то вроде функции, определенной внутри другой функции (функции второго порядка). Причем данного рода прагмы я встречал при анализе кода более, чем в нескольких разных фреймворках

    Так и живем (по поводу торжества компилятора на наших костях) ...


  1. kreon
    23.02.2016 10:54
    +1

    Верните gcc 2.95 !

    И да, в c/c++ сильно не хватает @suppresswarnings из java



  1. silkytail
    23.02.2016 12:37
    +2

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

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

    template <typename T1, typename T2>
    static bool Fits(T1 &, T2 &)
    {
        return sizeof(T1) >= sizeof(T2);
    }

    const size_t size = filelength;
    if( ! Fits(size, filelength) ) {
        return FILE_READ_ERROR;
    }


    1. DmitryMe
      24.02.2016 10:33
      +2

      Хорошо, возьмем систему, где long восемь байт, а size_t четыре байта (например, при компиляции gcc под 32-разрядную систему). Размер файла, допустим, пять килобайт. Предлагаемая вами функция Fits() вернет "false", хотя число около пяти тысяч вполне помещается в четырехбайтный size_t.


      1. silkytail
        24.02.2016 12:36
        +1

        О, я не сразу понял масштаб бедствия.
        Вы понимаете, насколько ужасен код, если посторонние люди даже с комментариями, не могут понять, что он должен делать? :)
        А в этом случае вас не будут смущать ворнинги типа "преобразование к меньшему типу" в этой строке?

        const size_t size = filelength;

        Но хорошо, так еще проще.

            const size_t size = filelength;
            if ((long)size != filelength ) {
                return FILE_READ_ERROR;
            }

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

        Но подождите, нужно еще прибавить единичку (оставим в стороне вопрос, зачем это нужно), и костыли, которые раньше неявно учитывали кейс с переполнением, теперь отсутствуют. (А что, если придется прибавить 2, а не 1?)
        Придется немного усовершенствовать.

            const size_t size = filelength;
            if ((long)size != filelength || (size + 1) == 0) ) {
                return FILE_READ_ERROR;
            }


        1. DmitryMe
          24.02.2016 13:22
          +1

          Предлагаете явное приведение беззнакового типа к знаковому. При преобразовании беззнакового типа в знаковый, если исходное значение не может быть представлено в результирующем типе, результат преобразования зависит от реализации (implementation-defined). Вы уверены, что стоит полагаться на зависящее от реализации поведение в коде для обработки довольно редких ситуаций, предназначенном для неизвестного заранее множества платформ и компиляторов?


          1. silkytail
            24.02.2016 13:57
            +1

            Теоретически, исходное значение не может быть представлено, если размер long меньше, чем значение в size_t, т.е. sizeof(size_t) > sizeof(long). Практически, в этом случае (с учетом того, что отрицательные значения в filelength — уже само по себе странно) значение в size_t никогда не может быть больше, чем, исходное значение в long filelength.

            3 If the destination type is signed, the value is unchanged if it can be represented in the destination type (and
            bit-field width); otherwise, the value is implementation-defined.


  1. seregamorph
    23.02.2016 12:51
    +3

    Из личного опыта смелой профессиональной молодости. Был backend-демон, который контролировал единственность запущенного инстанса аж сразу двумя способами — блокировка pid-файла и pid-порта (реализация была сделана вручную, без врапперов). IDEA подсветила обе static-переменные как "can be converted to local variables", а я взял и поддался. Телефонный звонок тимлида застал меня в отпуске и я на втором слове понял, какую дурацкую ошибку я совершил (java gc закрывал эти объекты спустя какое-то время после старта приложения).


    1. mayorovp
      23.02.2016 17:15
      +2

      … и снова ошибка на самом деле совсем не там, где ее видит IDE :) Если бы для этих объектов использовался try-with-resources (ну или просто вызывался метод close) — то gc бы такой объект не собрал раньше времени.