Перед вами обновлённая коллекция вредных советов для C++ программистов, которая превратилась в целую электронную книгу. Всего их 60, и каждый сопровождается пояснением, почему на самом деле ему не стоит следовать. Всё будет одновременно и в шутку, и серьёзно. Как бы глупо ни смотрелся вредный совет, он не выдуман, а подсмотрен в реальном мире программирования.
Я буду публиковать советы по 5 штук, чтобы не утомить вас, так как мини-книга содержит много интересных отсылок на другие статьи, видео и т. д. Однако, если вам не терпится, здесь вы можете сразу перейти к её полному варианту: "60 антипаттернов для С++ программиста". В любом случае желаю приятного чтения.
Вредный совет N6. Невидимые символы
Используйте при написании кода невидимые символы. Пусть ваш код работает магическим образом. Это прикольно.
Существуют Unicode-символы, которые не отображаются или изменяют видимое представление кода в среде разработки. Комбинации таких символов могут привести к тому, что человек и компилятор будут интерпретировать код по-разному. Это может быть сделано специально. Такой вид атаки называется Trojan Source.
Подробнее ознакомиться с этой темой вы можете в статье "Атака Trojan Source для внедрения в код изменений, незаметных для разработчика". Настоящее хоррор-чтиво для программистов :). Рекомендую.
Более детальный разбор здесь. К счастью, анализатор PVS-Studio уже умеет обнаруживать подозрительные невидимые символы.
И заодно ещё один вредный совет. Может пригодиться для розыгрыша на 1 апреля. Оказывается, существует греческий знак вопроса U+037E, который выглядит, как точка с запятой (;).
Когда коллега отвлечётся, поменяйте в его коде какую-нибудь точку с запятой на этот символ. И сидите, наблюдайте, наслаждайтесь :). Код не будет компилироваться, хотя вроде всё хорошо.
Вредный совет 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-битным.
А какие же тогда типы использовать? Безопасными для хранения размеров массивов или индексов являются 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):
- Согласно правилам языка C++, переменная A типа int приводится к типу unsigned;
- Происходит сложение A и B. В результате мы получаем значение 0xFFFFFFFF типа unsigned;
- Вычисляется выражение ptr + 0xFFFFFFFFu.
Что из этого выйдет, будет зависеть от размера указателя на данной архитектуре. Если сложение будет происходить в 32-битной программе, то данное выражение будет эквивалентно ptr — 1, и мы успешно распечатаем число "3". В 64-битной программе к указателю честным образом прибавится значение 0xFFFFFFFFu. Указатель окажется далеко за пределами массива, и при доступе к элементу по данному указателю нас ждут неприятности.
Если вас заинтересовала эта тема и вы хотите лучше разобраться в ней, то рекомендую следующие материалы:
- 64-битные уроки. Урок 13. Паттерн 5. Адресная арифметика;
- 64-битные уроки. Урок 17. Паттерн 9. Смешанная арифметика;
- Что такое size_t и ptrdiff_t.
Вредный совет N9. Глобальные переменные
Глобальные переменные очень удобны, т. к. к ним можно обращаться отовсюду.
Из-за того что можно обращаться отовсюду, непонятно, откуда и когда к ним обращаются. Это делает логику программы запутанной, сложной для понимания и провоцирует ошибки, которые сложно искать с помощью отладки. Тестировать юнит-тестами функции, использующие глобальные переменные, также затруднительно, так как разные функции связаны между собой.
Глобальные константные переменные не в счёт. Собственно, они никакие не "переменные", а просто константы :).
Перечислять проблемы из-за глобальных переменных можно долго, и это уже сделано во многих публикациях и книгах. Некоторые ссылки по этой теме:
- Stack Overflow. Are global variables bad?
- Global Variables Are Bad.
- Глобальные состояния: зачем и как их избегать.
- Why (non-const) global variables are evil.
- 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)
eao197
07.06.2023 07:01В связи с советом №10 (abort в библиотеках) хочется задать ехидный вопрос: так что, и noexcept в библиотеках использовать нельзя? ;)
datacompboy
07.06.2023 07:01+6"Деление на на ноль это как заниматься сексом. В школе нельзя, а в универе расскажут как это делать правильно"
WQS100
07.06.2023 07:01"
КодексРазличные практики — это всего лишь свод указаний, а не жёстких законов"
adeshere
07.06.2023 07:01for (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. ! Для вызова Си-функцийДлинновато, зато прозрачно
eao197
07.06.2023 07:01Есть суффиксы для литералов: https://en.cppreference.com/w/cpp/language/integer_literal
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 */
me21
07.06.2023 07:01+1Но long на разных платформах может иметь разный размер.
Вот предложение для литералов фиксированного размера: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1280r0.html
domix32
07.06.2023 07:01+1Вощемта, все абстрактные типы могут иметь разный размер, собственно для этого и изобретался Си - чтобы можно было без боли сказать компилятору, сколько бит в его интах и всё бы завелось. Приветы из эры PDP
adeshere
07.06.2023 07:01в новых стандартах вроде хотят разрешить десятичные разделители и будет совершенно непонятно это число такое или размер:
1000_8 == 10008 // True
@domix32, спасибо за ликбез! Другие причины тогда можно уже не перечислять (с) ;-)
Но
если я не уверен в том, что разрядность всяких float и double на разных машинах одинаковая, то написать переносимую программу (которая гарантирует одинаковую точность вычислений) будет заметно сложнее. Я почему-то до сих пор думал, что на уровне базовых математических библиотек фортран и Си идентичны, и что при решении расчетных задач выбор того или иного языка не имеет значения. Получается, что на самом деле это не совсем так?
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 - почти наверняка найдутся расхождения, т.к. разные фронты для разных языков могут быть не связаны.
Andrey2008 Автор
07.06.2023 07:01+1Возможность такой записи ничего не решает. Ибо неизвестно, сколько бит нужно выбрать, чтобы оно совпало с размерностью size_t. Т.е. непонятно, сколько бит выбрать, чтобы счётчик мог перебрать все элементы любого массива. Как раз вектор развития, всячески избегать указания конкретной размерности.
geher
07.06.2023 07:01+2Когда коллега отвлечётся, поменяйте в его коде какую-нибудь точку с запятой на этот символ.
Для человека, который уже наступал на грабли с символами с и c, это не проблема.
Indemsys
Ссылки на отчеты по проблемам Тойоты в вашей презентации не работают. Часть введет на коммерческие источники.
Тут интереснее было бы услышать почему все же разработчики Тойоты используют несмотря ни на что глобальные переменные в таком количестве. Может там аудиторы считали каждый элемент глобальных массивов в ОЗУ?
Andrey2008 Автор
Дело было давно. К сожалению, ссылки постепенно умирают.