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


Сразу перейдём к делу и начнём разбирать фрагмент кода на языке C, взятый из проекта iSulad.


/* container state to disk */
int container_state_to_disk(const container_t *cont)
{
    int ret = 0;

    if (cont == NULL) {
        return -1;
    }

    ret = container_save_container_state_config(cont);
    if (ret != 0) {
        return ret;
    }

    return ret;
}

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


Я наткнулся на этот фрагмент кода благодаря предупреждению анализатора PVS-Studio:


V523 The 'then' statement is equivalent to the subsequent code fragment. container_unix.c 878


Речь идёт об этом месте:


if (ret != 0) {
  return ret;
}
return ret;

Действительно, вне зависимости от того, какое значение имеет переменная ret, выполняется одно и то же действие.


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


Для начала сократим нижнюю часть кода до одного return:


/* container state to disk */
int container_state_to_disk(const container_t *cont)
{
    int ret = 0;

    if (cont == NULL) {
        return -1;
    }

    ret = container_save_container_state_config(cont);
    return ret;
}

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


Рекомендуется объявлять переменную как можно ближе к месту её использования. Более того, лучше сразу её инициализировать в точке объявления.


Здесь переменная ret тоже инициализируется при объявлении. Но это "не та инициализация", она просто не имеет смысла. В теории, она может защитить от ошибки использования неинициализированных переменных. Однако лучше писать так, чтобы в принципе такой возможности не было. Для этого как раз и рекомендуют совместить объявление переменной с её инициализацией полезным значением.


В нашем случае это выглядит так:


/* container state to disk */
int container_state_to_disk(const container_t *cont)
{
    if (cont == NULL) {
        return -1;
    }

    int ret = container_save_container_state_config(cont);
    return ret;
}

При этом становится очевидно, что переменная ret вообще не нужна. Это повод избавиться от неё:


/* container state to disk */
int container_state_to_disk(const container_t *cont)
{
    if (cont == NULL) {
        return -1;
    }

    return container_save_container_state_config(cont);
}

Получилось упростить и сократить код. Good.


Здесь можно возразить, что предыдущий вариант кода был написан из расчёта его дальнейшего расширения. Ой, бросьте. Не надо писать код про запас. С большой вероятностью это расширение никогда не понадобится! А если такое произойдёт, то ничего сложного в добавлении кода нет.


Здесь уместна эта картинка:


Прочная основа в стартапе


Продолжим. Обратите внимание на комментарий к функции. В нём нет никакого смысла. Это калька с названия функции:


/* container state to disk */
int container_state_to_disk(const container_t *cont)

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


int container_state_to_disk(const container_t *cont)
{
    if (cont == NULL) {
        return -1;
    }

    return container_save_container_state_config(cont);
}

Перед нами простая, красивая функция-прослойка, которая проверяет указатель и передаёт управление другой функции.


На этом всё? Оказывается, нет. Заглянем в ту — другую — вызываемую функцию:


static int container_save_container_state_config(const container_t *cont)
{
    int ret = 0;
    parser_error err = NULL;
    char *json_container_state = NULL;

    if (cont == NULL) {
        return -1;
    }

    container_state_lock(cont->state);
   ....
}

Ха! Оказывается, эта функция и сама имеет в себе проверку указателя, и ей можно безопасно передать NULL.


Соответственно код упрощается ещё больше:


int container_state_to_disk(const container_t *cont)
{
    return container_save_container_state_config(cont);
}

Что это значит? Что две эти функции — синонимы.


Это стоило написать в комментарии и не создавать бессмысленные проверки и действия в функции. Как мы выяснили, всё это был мусорный код, который ничего не делает и только занимает место.


Чего не хватает, так это приблизительно такого комментария:


/* На данный момент функция container_state_to_disk является
   синонимом container_save_container_state_config.
   Это изменится, когда будет реализовываться функционал .......
*/
int container_state_to_disk(const container_t *cont)
{
    return container_save_container_state_config(cont);
}

А если нет никакого "когда будет реализовываться"? Тогда текст программы можно упростить и сократить ещё больше с помощью вот такого макроса:


#define container_state_to_disk container_save_container_state_config

К этому макросу даже комментарий не нужен. И так понятно, что два эти названия являются синонимами.


P.S. Вообще, я не очень люблю макросы. Но без них в C жить тяжело, и аккуратное их использование не портит и не усложняет код. Поэтому я посчитал вполне уместным всё в итоге свести к макросу.


Пишите простой код. Не пишите избыточный код про запас. Спасибо за внимание.


Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Andrey Karpov. Static analyzer nudges you to write clean code.

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


  1. datacompboy
    16.04.2024 14:43
    +3

    К этому макросу даже комментарий не нужен. И так понятно, что два эти названия являются синонимами.

    может, тогда стоит вообще -- массовое переименование и выбросить даже макрос?


    1. goldexer
      16.04.2024 14:43
      +1

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


      1. datacompboy
        16.04.2024 14:43

        А почему не использовать исходное имя?


        1. goldexer
          16.04.2024 14:43
          +2

          С радостью бы... но они ж (например ST), свой Hardware Abstraction Layer не именуют так, чтобы было понятно даже *кхм-кхм, не могу слово подобрать* чайнику, что это за функция и что за пять сокращений по две буквы в её названии за что отвечает и какие за какими их десять штук подряд надо вызывать, а потом некоторые вызывать, а некоторые нет, плюс в них такие же по стилю константы надо передавать. Получается код из шутки про очень широкий монитор и джаваскриптера. Вдобавок, сегодня они хотят коллбэки, а завтра предлагают weak функции, на, мол, сам переделывай. Но ладно бы только это, я таких три-пять строчек кода их функций (в которых так же миллион проверок, миллион уровней вложенности, блокировок и т.п.) навязанного стиля меняю на одну запись в регистр, так рядом сидит человечек, который «я тут за всё плачу, сделай мне вот чтобы вот такое название написал и оно работало, я этого ихнего не понимаю». Зажмут вот так с двух сторон и привет синонимы, обёртки, потенциальный функционал... Уж простите, что сумбурно объяснил, устал с работы


          1. datacompboy
            16.04.2024 14:43

            А, так HAL абстракции завернутые в макросы обычно идут сразу, ибо вариантов прослоек предлагается на выбор: плохо, неудобно, плохо и неудобно, или так, что вообще не разберёшь.

            ... и какой из них будет выбран при компиляции определяется волею рандома.

            Только я не знаю кто такой iSalud упомянутый в посте -- я не думал что это нечто из embedded.


            1. goldexer
              16.04.2024 14:43

              В яблочко! Плюс болезнь мелкомягких в виде невозможности расширить некоторые окна просмотра - длинные однокоренные названия различить в полевых условиях на 15'' монике в скрин-сплите дебага и стороннего GUI...

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


  1. kozlov_de
    16.04.2024 14:43

    Используйте Option<T> или Result<T>

    или хотя бы nullable int


    1. eptr
      16.04.2024 14:43
      +2

      Используйте Option<T> или Result<T>

      Придётся изрядно подождать, пока такое появится в C.


  1. Serpentine
    16.04.2024 14:43

    А если нет никакого "когда будет реализовываться"? Тогда текст программы можно упростить и сократить ещё больше с помощью вот такого макроса:

    #define container_state_to_disk container_save_container_state_config

    Я всего контекста не вижу, но, судя по всему, у функций в статье вот такие прототипы:

    int container_state_to_disk(const container_t *cont);
    static int container_save_container_state_config(const container_t *cont);

    Функция container_state_to_disk() имеет внешнее связывание, а container_save_container_state_config() - статическая с внутренним связыванием, т.е. этим макросом мы сменим область видимости container_state_to_disk() со всеми вытекающими.


    1. Andrey2008 Автор
      16.04.2024 14:43

      Тогда можно убрать static. Всё равно ведь есть интерфейс её дёргать из вне.


  1. alysnix
    16.04.2024 14:43

    какой смысл передавать гарантированно не null указатель, если это по сути ссылка. ее и надо делать параметром функции.

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

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


    1. Serpentine
      16.04.2024 14:43
      +1

      Это сишный код, в Си нет ни ссылок, ни nullptr.


      1. alysnix
        16.04.2024 14:43

        ааа.. это я проглядел. тогда совет - не проверять указатель, если он формально может быть нулом - безусловно вредный.

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


        1. ReadOnlySadUser
          16.04.2024 14:43
          +2

          It depends. И очень сильно.

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

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

          Правда на моей памяти примерно каждый второй программист ленится описывать контракты) И приходится гадать надо тут проверять на NULL или не надо (и человеку, и статическому анализатору).


          1. alysnix
            16.04.2024 14:43
            +3

            Ха! Оказывается, эта функция и сама имеет в себе проверку указателя, и ей можно безопасно передать NULL.

            я лишь говорю, что вот такая сентенция автора - есть бред сумасшедшего.

            1. он меняет вызывающую функцию, опираясь на ТЕКУЩУЮ!!! реализацию вызываемой. текущая реализация(без изменения прототипа функции) может быть изменена и это автоматически сделает невалидным код вызывающей функции. приехали. это называется - совет как писать "чистый код"

            2. автор не учитывает, что вызываемая функция должна не только проверять указатель, но и возвратить тот же результат (-1). а это вообще нигде не оговаривается. то есть если вызываемая будет проверять указатель, но возвращать не -1 , то опять невалидность.

            3. автор не учитывает, что есть хорошее правило - смотреть только прототипы, но не смотреть реализации.

            делать надо не так. поскольку у нас чистый си, а передаем мы ссылку, то надо просто ввести дефайн

            #define ref *

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

            и писать прототипы не так -

            void fun ( type *ptr )

            а так

            void fun ( type ref ptr)

            и хотя это одно и то же после препроцессирования, мы таким образом можем указывать, что параметр ptr не может быть невалидным, и внутри функции не проверяется на ноль... можно дефайн и not_null назвать.

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


            1. Serpentine
              16.04.2024 14:43
              +2

              делать надо не так. поскольку у нас чистый си, а передаем мы ссылку, то надо просто ввести дефайн

              #define ref *

              На мой взгляд, вы слишком перемудрили, и это наоборот ещё больше запутает. И так видно, что передаем адрес объекта без возможности модификации значения. Кроме того, если есть непреодолимое желание, вместо него всё равно ведь можно будет передать практически любой набор байт, это же Си :)

              есть хорошее правило - смотреть только прототипы, но не смотреть реализации.

              Некоторые могут быть не знакомы с таким правилом, это же не догма.

              Я сейчас личное мнение выскажу, но за что мне нравится Си, так это за свободу писать эффективный и аскетичный код без оглядки на авторитетное мнение (в отличие от C++ с его Core Guidelines и пр. от Страуструпа&Co, хотя и там как угодно можно писать). Руководствуемся только здравым смыслом и революционным чутьем, ну иногда стандартом и может K&R :) Понятное дело, если пишем в команде, то добавляем и другие договоренности.

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


            1. ReadOnlySadUser
              16.04.2024 14:43
              +1

              надо просто ввести дефайн

              Не надо так делать :) Под "так" - я имею тащить в язык чужеродные языку абстракции. В Си нет ссылок, и не нужно их там изобретать. Это только запутывает.

              В принципе, важ подход имеет место быть и более или менее правильный путь такой:

              Код

              Пример на godbolt

              Ссылка на SAL

              #if COMPILER_IS_GNU_COMPATIBLE
                  #define _Notnull_Args_(...) __attribute__((nonnull(__VA_ARGS__)));
                  #define _Notnull_
              #elif COMPILER_IS_MSVC
                  #include <sal.h>
                  #define _Notnull_Args_(...)
              #else
                  #define _Notnull_Args_(...)
                  #define _Notnull_
              #endif
              
              void swap(int* a, int* b) _Notnull_Args_(1, 2); 
              
              void swap(_Notnull_ int* a, _Notnull_ int* b)
              {
                  int t = *a;
                  *a = *b;
                  *b = t;
              }

              Всё уже придумано до нас :)

              Мне лично такой подход не нравится, т.к. SAL-аннотации слишком громоздкие. assert-ы проще, надёжнее, кроссплатформенны и кросскомпиляторны)


              1. alysnix
                16.04.2024 14:43

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

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


        1. kekoz
          16.04.2024 14:43
          +1

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

          Вопрос для уточнения позиции — могут ли указатели, передаваемые функциям из <string.h>, формально быть NULL?


      1. eptr
        16.04.2024 14:43

        Это сишный код, в Си нет ни ссылок, ни nullptr.

        Ссылок пока нет, а вот nullptr уже есть в C23, — вот пример.


        1. Serpentine
          16.04.2024 14:43

          nullptr уже есть в C23, — вот пример.

          Да есть там такое, только C23 ещё не принят, его проект сейчас на стадии рассмотрения, и не все компиляторы его поддерживают, понятное дело.

          Когда примут, тогда у нас будет C++ без классов :)


  1. Totoshka25
    16.04.2024 14:43

    Объявлять переменные в начале функции — это устаревший, вредный стиль написания кода.

    ...

    Рекомендуется объявлять переменную как можно ближе к месту её использования. 

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


    1. Melirius
      16.04.2024 14:43
      +4

      Уже давно нет, можно объявлять где угодно.


    1. eptr
      16.04.2024 14:43
      +1

      На сколько я помню, в языке C объявление переменных должно идти в начале функции.

      Это — для стандарта C89/C90, начиная же с C99, это — не обязательно.
      Стандарту C99 уже 25 лет, так что им уже, наверное, можно начать пользоваться.


  1. Playa
    16.04.2024 14:43

    Больше похоже на остатки отладочного кода. Так и вижу внутри какой-нибудь printf, который впоследствии удалили.


    1. MiyuHogosha
      16.04.2024 14:43

      Или наладочного стенда, когда стыковали две кучи кода от разных авторов


  1. gun_dose
    16.04.2024 14:43
    +1

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

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


    1. voldemar_d
      16.04.2024 14:43

      В MS Visual Studio 2019 и 2022 даже если ничего не настраивать, то встроенный статический анализатор уже есть. Не такой мощный, как PVS, но кое-что находит. Некоторые вещи прямо в синтаксисе подчёркивает, даже если явно запуск анализа не вызывать.


      1. gun_dose
        16.04.2024 14:43
        +1

        Так это есть почти в любой IDE, при условии, что она поддерживает тот язык, на котором пишешь. Но ведь есть отдельная каста разработчиков, которые считают, что IDE для дураков, а настоящие ниндзя кодят только в Vim. Но мы то знаем, что они используют Vim только потому, что не могут из него выйти :D


  1. MiyuHogosha
    16.04.2024 14:43

    Комментарий кпервой картинке... Не дай вам бог родиться при Генрихе VI... то есть брать госконтракты. Платить будут хотеть по ГОСТу, за строчки с корректирующимии поправками, а за несоблюдение правил отчетов о трудоемкости ПЗ может и шрафов добиться, если приспичит.