Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Перед вами обновлённая коллекция вредных советов для 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 активность.
Ссылки на полный текст:
- RU — 60 антипаттернов для С++ программиста
- EN — 60 terrible tips for a C++ developer
Подписывайтесь на ежемесячную рассылку, чтобы не пропустить другие публикации автора и его коллег.