1053_60_cpp_antipatterns_ru/image2.png


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


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


Вредный совет N6. Невидимые символы


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


Существуют Unicode-символы, которые не отображаются или изменяют видимое представление кода в среде разработки. Комбинации таких символов могут привести к тому, что человек и компилятор будут интерпретировать код по-разному. Это может быть сделано специально. Такой вид атаки называется Trojan Source.


1053_60_cpp_antipatterns_ru/image4.png


Подробнее ознакомиться с этой темой вы можете в статье "Атака Trojan Source для внедрения в код изменений, незаметных для разработчика". Настоящее хоррор-чтиво для программистов :). Рекомендую.


Более детальный разбор здесь. К счастью, анализатор PVS-Studio уже умеет обнаруживать подозрительные невидимые символы.


И заодно ещё один вредный совет. Может пригодиться для розыгрыша на 1 апреля. Оказывается, существует греческий знак вопроса U+037E, который выглядит, как точка с запятой (;).


1053_60_cpp_antipatterns_ru/image5.png


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


1053_60_cpp_antipatterns_ru/image6.png


Вредный совет N7. Магические числа


Используйте странные числа. Так ваша программа будет выглядеть умнее и солиднее. Согласитесь, что такие строки смотрятся хардкорно: qw = ty / 65 — 29 * s;


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


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


К сожалению, невозможно в одной главе описать множество подходов, позволяющих писать понятный красивый код. Поэтому я отправляю читателя к такому обстоятельному труду, как "Совершенный код" С. Макконнелла (ISBN 978-5-7502-0064-1).


Плюс есть отличная дискуссия на сайте Stack Overflow: What is a magic number, and why is it bad?


Вредный совет N8. Везде int


Во всех старых книгах для хранения размеров массивов и для организации циклов использовались переменные типа int. Так и делайте. Не стоит нарушать традиции.


Долгое время на распространённых платформах, где использовался язык C++, массив не мог на практике содержать более INT_MAX элементов.


Например, 32-битной программе на Windows доступно максимум 2 GB памяти (на самом деле ещё меньше). Поэтому 32-битного типа int было более чем достаточно для хранения размера массивов или для их индексации.


Раньше программисты и авторы книг не заморачивались — смело использовали в циклах счётчики типа int. И всё было хорошо.


Однако на самом деле размер таких типов, как int, unsigned и даже long, может быть недостаточен. В этот момент Linux-программисты могут удивиться: почему long недостаточно? А дело в том, что, например, компилятор MSVC при сборке приложений для платформы Windows x64 использует модель данных LLP64, в которой тип long остался 32-битным.


1053_60_cpp_antipatterns_ru/image8.png


А какие же тогда типы использовать? Безопасными для хранения размеров массивов или индексов являются memsize-типы, такие как ptrdiff_t, size_t, intptr_t, uintptr_t.


Рассмотрим простейший пример, когда использование 32-битного счётчика приведёт к ошибке при обработке большого массива в 64-битной программе:


std::vector<char> &bigArray = get();
size_t n = bigArray.size();
for (int i = 0; i < n; i++)
  bigArray[i] = 0;

Если контейнер содержит более INT_MAX элементов, то произойдёт переполнение знаковой переменной int, а это неопределённое поведение. Причём, как оно себя проявит, предсказать не так просто, как может показаться. Вот здесь я разбирал один интересный случай: "Undefined behavior ближе, чем вы думаете".


Правильным вариантом будет написать, например, так:


size_t n = bigArray.size();
for (size_t i = 0; i < n; i++)
  bigArray[i] = 0;

Ещё более правильным будет такой вариант:


std::vector<char>::size_type n = bigArray.size();
for (std::vector<char>::size_type i = 0; i < n; i++)
  bigArray[i] = 0;

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


auto n = bigArray.size();
for (auto i = 0; i < n; i++)    // :-(
  bigArray[i] = 0;

Переменная n будет иметь правильный тип, а вот счётчик i – нет. Константа 0 имеет тип int, а значит, переменная i тоже будет иметь тип int. И мы возвращаемся к тому, с чего начали.


Так как же правильно перебрать элементы и при этом написать короткий код? Во-первых, можно использовать итераторы:


for (auto it = bigArray.begin(); it != bigArray.end(); ++it)
  *it = 0;

Во-вторых, можно использовать range-based for loop:


for (auto &a : bigArray)
  a = 0;

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


Первая причина. Такой подход потенциально опасен для будущего. То, что сейчас программа не работает с большими массивами, не означает, что так будет всегда. Ещё один сценарий — код может быть заимствован в другое приложение, где обработка больших массивов – обычное дело. В конце концов, одной из причин падения ракеты Ariane 5 стало как раз использование старого кода, не рассчитанного на новые величины "горизонтальной скорости". См. статью "Космическая ошибка: 370.000.000 $ за Integer overflow".


Вторая причина. При использовании смешанной арифметики можно получить проблемы, работая даже с маленькими массивами. Рассмотрим пример кода, который работоспособен в 32-битном варианте и неработоспособен в 64-битном:


int A = -2;
unsigned B = 1;
int array[5] = { 1, 2, 3, 4, 5 };
int *ptr = array + 3;
ptr = ptr + (A + B);   // Invalid pointer value on 64-bit platform
printf("%i\n", *ptr);  // Access violation on 64-bit platform

Давайте проследим, как происходит вычисление выражения ptr + (A + B):


  1. Согласно правилам языка C++, переменная A типа int приводится к типу unsigned;
  2. Происходит сложение A и B. В результате мы получаем значение 0xFFFFFFFF типа unsigned;
  3. Вычисляется выражение ptr + 0xFFFFFFFFu.

Что из этого выйдет, будет зависеть от размера указателя на данной архитектуре. Если сложение будет происходить в 32-битной программе, то данное выражение будет эквивалентно ptr — 1, и мы успешно распечатаем число "3". В 64-битной программе к указателю честным образом прибавится значение 0xFFFFFFFFu. Указатель окажется далеко за пределами массива, и при доступе к элементу по данному указателю нас ждут неприятности.


Если вас заинтересовала эта тема и вы хотите лучше разобраться в ней, то рекомендую следующие материалы:


  1. 64-битные уроки. Урок 13. Паттерн 5. Адресная арифметика;
  2. 64-битные уроки. Урок 17. Паттерн 9. Смешанная арифметика;
  3. Что такое size_t и ptrdiff_t.

Вредный совет N9. Глобальные переменные


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


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


Глобальные константные переменные не в счёт. Собственно, они никакие не "переменные", а просто константы :).


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


  1. Stack Overflow. Are global variables bad?
  2. Global Variables Are Bad.
  3. Глобальные состояния: зачем и как их избегать.
  4. Why (non-const) global variables are evil.
  5. The Problems with Global Variables.

Ну и для того, чтобы было понятно, что всё это серьезно, предлагаю познакомиться со статьёй "Toyota: 81 514 нарушений в коде". Одна из причин, что код получился запутанным и забагованным, — это использование 9000 глобальных переменных.


Вредный совет N10. abort в библиотеках


Совет для разработчиков библиотек: в любой непонятной ситуации сразу завершай программу, используя функцию abort или terminate.


Иногда в программах можно встретить очень простую обработку ошибок: завершение работы программы. Чуть что-то не получилось, например открыть файл или выделить память, как тут же вызывается функция abort, exit или terminate. Для некоторых утилит и простых программ это вполне приемлемое поведение. Да и вообще, автор программы сам вправе решить, что делать в случае сбоя в работе приложения.


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


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


А что если библиотекой захочет воспользоваться embedded-разработчик? Такие руководства для разработчиков встраиваемых систем, как MISRA и AUTOSAR, вообще запрещают вызывать функции abort и exit (MISRA-C-21.8, MISRA-CPP-18.0.3, AUTOSAR-M18.0.3).


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


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


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


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



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

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


  1. Indemsys
    07.06.2023 07:01

    Ссылки на отчеты по проблемам Тойоты в вашей презентации не работают. Часть введет на коммерческие источники.
    Тут интереснее было бы услышать почему все же разработчики Тойоты используют несмотря ни на что глобальные переменные в таком количестве. Может там аудиторы считали каждый элемент глобальных массивов в ОЗУ?




    1. Andrey2008 Автор
      07.06.2023 07:01

      Дело было давно. К сожалению, ссылки постепенно умирают.


  1. eao197
    07.06.2023 07:01

    В связи с советом №10 (abort в библиотеках) хочется задать ехидный вопрос: так что, и noexcept в библиотеках использовать нельзя? ;)


    1. datacompboy
      07.06.2023 07:01
      +6

      "Деление на на ноль это как заниматься сексом. В школе нельзя, а в универе расскажут как это делать правильно"


    1. WQS100
      07.06.2023 07:01

      "Кодекс Различные практики — это всего лишь свод указаний, а не жёстких законов"


    1. Kelbon
      07.06.2023 07:01
      +1

      если исключение в реальности может вылететь, то noexcept не нужно ставить, вот и всё


      1. eao197
        07.06.2023 07:01
        +4

        В реальности писать код правильно совсем не сложно: просто не нужно делать ошибок.

        Это сарказм, да.


  1. adeshere
    07.06.2023 07:01

    for (auto i = 0; i < n; i++) // :-(

    Извиняюсь за дурацкий вопрос, а разве в Си нельзя задать разрядность константы явно? Например, вот так:

    for (auto i = 0_8; i < n; i++) // :-)

    Я настолько привык к этому синтаксису, принятому в древних языках программирования, что он мне кажется простым и удобным. Просто "17" - это стандартный integer, "1913_4" - 32-битный, "42_16" - 128-битный, и т.д.. Если пока нельзя, то почему бы это не сделать? Ведь речь идет только о константах, двусмысленностей вроде не возникает? Всего-то придется пару строчек в компилятор добавить и еще одну - в стандарт языка.... И аналогично для всяких float?

    А в мультиязычных программах в нашем заповеднике сейчас можно писать вот так (правда, это не все компиляторы поддерживают):

    ...
    real(8) :: my_real=13. ! Чтобы использовать в моей программе
    real(c_float) :: true_real=0. ! Для вызова Си-функций

    Длинновато, зато прозрачно


    1. eao197
      07.06.2023 07:01

      Есть суффиксы для литералов: https://en.cppreference.com/w/cpp/language/integer_literal


    1. domix32
      07.06.2023 07:01
      +1

      Указывать размер типа в таком виде как вы показали несколько странно. Плюс в C++ в новых стандартах вроде хотят разрешить десятичные разделители и будет совешенно непонятно это число такое или размер: 1000_8 == 10008 // True

      Есть литералы, которые автоматом кастят и валидируют числа.

      30         /* int */
      30u        /* unsigned int */
      30l        /* long */
      30ul       /* unsigned long */
      3.14159       /* Legal */
      314159E-5L    /* Legal */
      510E          /* Illegal: incomplete exponent */
      210f          /* Illegal: no decimal or exponent */
      .e55          /* Illegal: missing integer or fraction */


      1. me21
        07.06.2023 07:01
        +1

        Но long на разных платформах может иметь разный размер.

        Вот предложение для литералов фиксированного размера: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1280r0.html


        1. domix32
          07.06.2023 07:01
          +1

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


      1. adeshere
        07.06.2023 07:01

        в новых стандартах вроде хотят разрешить десятичные разделители и будет совершенно непонятно это число такое или размер: 1000_8 == 10008 // True

        @domix32, спасибо за ликбез! Другие причины тогда можно уже не перечислять (с) ;-)

        Но

        если я не уверен в том, что разрядность всяких float и double на разных машинах одинаковая, то написать переносимую программу (которая гарантирует одинаковую точность вычислений) будет заметно сложнее. Я почему-то до сих пор думал, что на уровне базовых математических библиотек фортран и Си идентичны, и что при решении расчетных задач выбор того или иного языка не имеет значения. Получается, что на самом деле это не совсем так?


        1. domix32
          07.06.2023 07:01
          +1

          что разрядность всяких float и double на разных машинах одинаковая

          С как в общем-то и С++ написаны в виде абстрактной машины и размеры всех базовых типов по-умолчанию определяются настройками компилятора, а в рамках языка они просто int, long, word, dword и т.д. собственно поэтому дополнительно имеются sized определяния вроде int32_t, uint32_t, uint8_t, int64_t и прочие, которые явным образом определяют финальный размер для текущей архитектуры машины. Но литералов по-умолчанию для таких типов нет (хотя в новых стандартах плюсов можно их определить).

          Я слабо представляю как устроена система типов фортрана, но как минимум интероперабельность между си/си++ и фортранам должна иметь те же свойства с отвязкой абстрактных типов от конечного размера. Ну, а библиотеки так и вовсе implementation specific, т.к. каждый компилятор имеет свою собственную имплементацию стандартной библиотеки. Какой-нибудь gcc наверняка использует одни и те же мат. функции как для фортрана, так и для C/C++ т.к. живут в условно единой кодовой базе, а если сравнивать уже с какими-нибудь фронтендами для llvm - почти наверняка найдутся расхождения, т.к. разные фронты для разных языков могут быть не связаны.


    1. Andrey2008 Автор
      07.06.2023 07:01
      +1

      Возможность такой записи ничего не решает. Ибо неизвестно, сколько бит нужно выбрать, чтобы оно совпало с размерностью size_t. Т.е. непонятно, сколько бит выбрать, чтобы счётчик мог перебрать все элементы любого массива. Как раз вектор развития, всячески избегать указания конкретной размерности.


  1. geher
    07.06.2023 07:01
    +2

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

    Для человека, который уже наступал на грабли с символами с и c, это не проблема.