1053_60_cpp_antipatterns_ru/image2.png


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


Я буду публиковать советы по 5 штук, чтобы не утомить вас, так как мини-книга содержит много интересных отсылок на другие статьи, видео и т. д. Однако, если вам не терпится, здесь вы можете сразу перейти к её полному варианту: "60 антипаттернов для С++ программиста". В любом случае желаю приятного чтения.


Вредный совет N31. Всё в h-файлах


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


В эпоху моды на header-only библиотеки этот совет не кажется таким уж и вредным. В конце концов, существует даже "A curated list of awesome header-only C++ libraries".


Но одно дело – маленькие библиотеки. А другое – большой проект, в который вовлечены десятки людей и который развивается многие годы. В какой-то момент время компиляции вырастет с минут до часов, а сделать что-то с этим уже будет сложно. Не рефакторить же сотни и тысячи файлов, перенося реализацию функций из *.h в cpp-файлы? А если рефакторить, то не проще ли сразу было делать нормально? :)


Самым плохим следствием размещения реализации функций в заголовочных файлах является то, что минимальная правка приводит к необходимости перекомпиляции большого количества файлов в проекте. Есть важное отличие между кодом в header-only библиотеках и кодом вашего проекта. Код в библиотеках вы не трогаете, а свой код вы постоянно правите!


Дополнительная полезная ссылка: Pimpl.


Вредный совет N32. Оператор goto


Злые языки говорят, что goto считается вредным оператором, но это чушь. Этот оператор очень мощен и даже позволяет отказаться от for, while, do. Да здравствует goto и аскетизм!


1053_60_cpp_antipatterns_ru/image21.png


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


Есть даже специальный термин: спагетти-код. Цитата из Wikipedia:


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

Спагетти-код назван так, потому что ход выполнения программы похож на миску спагетти, то есть извилистый и запутанный. Иногда называется "кенгуру-код" (kangaroo code) из-за множества инструкций "jump".

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

Естественно, виноват не сам по себе оператор goto, а его необдуманное использование. Если он не виноват, почему существует рекомендация вообще его не использовать? Ведь в C++ почти любая конструкция опасна :).


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


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


Вредный совет N33. enum'ы не нужны


Никогда не используйте enum'ы, они все равно неявно приводятся к int. Используйте int напрямую!


Язык C++ идёт в сторону более сильной типизации. Поэтому, например, появился enum class. См. дискуссию "Why is enum class preferred over plain enum?".


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


Даже использование обыкновенных enum вместо безликого int позволяет анализатору PVS-Studio выявлять вот такие аномалии в коде.


Вредный совет N34. Везде нужен некий константный экземпляр класса? Для удобства объявите его в заголовочном файле


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


В итоге получается что-то такое:


// MySuperString.h
class MySuperString {
  char *m_buf;
  size_t m_size, m_capacity;
public:
  MySuperString(const char *str);
  ....
};
const MySuperString GlobalEmptyString("");

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


При включении такого заголовочного файла через #include произойдёт создание множественных копий объекта. Это приведёт к пустой трате памяти и времени для создания множества пустых строк.


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


Чтобы избежать множественного создания объектов, можно объявить переменную как inline (начиная с C++17) или extern. В этом случае инициализация и вызов конструктора произойдёт один раз. Исправленный вариант:


inline const MySuperString GlobalEmptyString("");

Более подробно данная тема рассмотрена в статье "What Every C++ Developer Should Know to (Correctly) Define Global Constants".


P.S. Анализатор PVS-Studio предостережёт вас от описанной ошибки с помощью диагностики V1043.


Вредный совет N35. Объявление переменных в начале функции


Проявите немного уважения к программистам прошлого – объявляйте все переменные в начале функций. Это традиция!


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


  1. Сразу видно, какой тип имеет переменная, что облегчает понимание программы;
  2. Если переменная "тяжёлая" и используется только при выполнении какого-то условия, то можно улучшить производительность, создавая её только в случае необходимости. См. также V821;
  3. Сложнее опечататься и использовать не то имя переменной.

Конечно, нужно действовать осмысленно. Например, в случае циклов иногда для производительности лучше создавать и инициализировать переменную вне цикла. Примеры: V814, V819.


Об этой мини-книге


Автор: Карпов Андрей Николаевич. E-Mail: karpov [@] viva64.com.


Более 15 лет занимается темой статического анализа кода и качества программного обеспечения. Автор большого количества статей, посвящённых написанию качественного кода на языке C++. С 2011 по 2021 год удостаивался награды Microsoft MVP в номинации Developer Technologies. Один из основателей проекта PVS-Studio. Долгое время являлся CTO компании и занимался разработкой С++ ядра анализатора. Основная деятельность на данный момент — управление командами, обучение сотрудников и DevRel активность.


Ссылки на полный текст:



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

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


  1. Pppnnn
    19.06.2023 11:40
    +6

    Эта религия, про "goto не нужен" уже как то достала.


    1. iBuilder
      19.06.2023 11:40
      +2

      Разве тут написали что не нужен? Вроде подразумевают по контексту, что "если много использовать, то это плохо".


      1. KyHTEP
        19.06.2023 11:40

        Однако дальше показано, что ещё лучше использовать умные указатели. Получается, что goto опять-таки не нужен.

        Ага, написали, что не нужен.


    1. eao197
      19.06.2023 11:40
      +1

      А можно пару-тройку примеров оправданного использования goto в C++ном коде (именно C++ном, а не чисто Сишном)? За исключением автоматически сгенерированных конечных автоматов.


      1. iBuilder
        19.06.2023 11:40
        +1

        Ну, выскочить из глубоких циклов на С++ тоже удобно, особенно для встраиваемых систем на микроконтроллерах, где всякие рекомендуемые "throw… catch…" редко используются в принципе.


        1. eao197
          19.06.2023 11:40
          +1

          throw+catch для выхода из вложенных циклов -- это дорого даже если исключения разрешены.
          Т.е. данный пример относится к категории оптимизации, когда нужно такты сэкономить и не хочется вводить флаги для циклов, а так же у нас не получается (реально?) вынести эти самые глубокие циклы в отдельные функции (где можно делать return).

          Какие еще примеры?


          1. iBuilder
            19.06.2023 11:40

            Про "throw+catch - дорого" - знаю, я так не делаю, но иногда именно для С++ пишут как рекомендацию, так заменять goto выход из циклов без введения флагов.

            У меня нет больше примеров, я больше нигде не использую. Могу ещё придумать гипотетический случай: перенос программы с ASM в С/С++ без переделки алгоритма, "в лоб". Но это сильно экзотика.


            1. eao197
              19.06.2023 11:40

              У меня нет больше примеров, я больше нигде не использую

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


      1. SIISII
        19.06.2023 11:40

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

        int  Ret_Code;
        
        if (проверка1)
        {
            return Error1;
        }
        
        действие1;
        
        if (проверка2)
        {
            Ret_Code = Error2;
            goto Err1;
        }
        
        действие2;
        
        if (проверка3)
        {
            Ret_Code = Error3;
            goto Err2;
        }
        
        ....
        
            return Success;
        
        ErrN:
            откатN;
        
        ...
        
        Err2:
            откат2;
        
        Err1:
            откат1;
            return Ret_Code;

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


        1. eao197
          19.06.2023 11:40
          +2

          Вы это делаете в C++? Не в теплой ламповой Сишечке, а именно в C++?


          1. SIISII
            19.06.2023 11:40
            +1

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


            1. eao197
              19.06.2023 11:40
              +1

              И RAII там тоже ни в каком виде?


              1. SIISII
                19.06.2023 11:40

                Требует использования классов, динамического выделения-освобождения памяти и всё такое. Когда ООП само по себе уместно -- скажем, нужно наследование с полиморфизмом, которые реализовывать средствами чистого Си глупо, если есть Си++, -- это нормально, но в простом низкоуровневом коде... Это чисто его раздувание без какой-либо нужды, да и читабельность снижает: разрывает фактически единую функцию между конструктором и деструктором, а последний заставляет ещё и анализировать условия завершения, чтобы определить, что именно откатывать, а что -- нет. Ну или делать кучу вспомогательных классов с той же целью. В общем, громоздко, нечитаемо и неэффективно. Ну а конструкции типа try-finally в языке нет.


                1. eao197
                  19.06.2023 11:40
                  +3

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

                  Да блин, откуда вы все такие беретесь? Пардон май френч.
                  Попробуйте сосчитать количество аллокаций, например, здесь


                  1. SIISII
                    19.06.2023 11:40
                    -1

                    Динамическое выделение/освбождение памяти -- это одна из возможных проблем, а не единственная проблема. Использование "левых" классов "размазывает" программу и сильно усложняет её восприятие -- и ради чего? Только чтоб goto не было? Это уже фанатизм, и приводит он к прямо противоположным результатам (ухудшению читабельности и лёгкости сопровождения кода вместо улучшения).


                    1. eao197
                      19.06.2023 11:40
                      +2

                      Если вы в подходе с RAII умудрились увидеть динамическое выделение памяти (как одну из проблем), то я вынужден ваши суждения отправлять прямиком в /dev/null.

                      Код с goto хрупок и его надежность базируется только на скрупулезности и внимательности программиста. А этим факторам доверять нельзя от слова совсем.


                      1. SIISII
                        19.06.2023 11:40

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


                      1. eao197
                        19.06.2023 11:40
                        +1

                        Но, замечу, что код без goto, но с классами, не менее хрупок и столь же базируется на скрупулёзности и внимательности программиста

                        "Отучаемся говорить за всех" (с)

                        но надёжность кода всегда базируется именно на них

                        Нет, не всегда.


                    1. mayorovp
                      19.06.2023 11:40
                      +5

                      Ну и где вы видите размазывание вот в таком коде?


                      bool success = false;
                      
                      if (проверка1) return Error1;
                      действие1;
                      auto fin1 = finally([&]() {
                          if (!success) {
                              откат1;
                          }
                      });
                      
                      if (проверка2) return Error2;
                      действие2;
                      auto fin2 = finally([&]() {
                          if (!success) {
                              откат2;
                          }
                      });
                      
                      if (проверка3) return Error3;
                      действие3;
                      auto fin3 = finally([&]() {
                          if (!success) {
                              откат3;
                          }
                      });
                      
                      success = true;
                      return Success;

                      Как по мне — от одной только группировки действий и откатов уже стало проще воспринимать код. И это ещё я даже не начинал ничего упрощать...


                      1. SIISII
                        19.06.2023 11:40
                        +1

                        Здесь размазывания уже нет. Правда, некоторое разбухание исходника (объявление переменных finN, тела лямбд с if'ами и обрамляющие сие дело скобки), но в разумных пределах и вполне читабельное. А можете упростить? Интересно посмотреть, что получится.

                        Из недостатков -- выделение дополнительной памяти. Да, знаю, не динамической, а в стеке -- но, тем не менее. Когда у тебя всего ОЗУ, скажем, 6 Кбайт на всё про всё, это может быть критичным (а может и не быть -- от задачи зависит). Плюс, некоторое снижением производительности, но это даже на слабых МК крайне редко проблемой бывает (а где бывает, лично я бы просто перешёл на ассемблер -- на нём я всяко напишу и более быстрый, и более компактный код, чем самый наилучший компилятор).