Yet another С++ coding guide, but, IMHO, having good enough explanations.
Revision 2. 2024-??-??
Revision 1. 2023-04-25
TODO: English translation.
- Уменьшение когнитивной нагрузки.
- Увеличение скорости работы с кодом.
- Предотвращение типичных ошибок.
- НЕ являться всеохватывающим документом. Если вы не видите здесь ответы на какие-то вопросы, попробуйте поискать их в CppCoreGuidelines.
↑ 2.1. Переменные и константы
Главная метаинформация переменных и констант - это вариант их хранения и единицы измерения. По-этому, чтобы облегчить с ними работу, настоятельно рекомендуется придерживаться следующего формата именования:
prefix_camelCase_suffix
Таким образом, название состоит из 3-х частей, разделённых символом "_"
. Левая и правая часть могут отсутствовать в зависимости от контекста.
Префикс указывает на вариант хранения, в порядке уменьшения приоритета:
- Для глобальных:
g_
- Для thread-local:
t_
- Для статических:
s_
- Для приватных и защищённых полей структур и классов:
m_
- Для публичных полей структур и классов: не используется.
- Для локальных: не используется.
Существуют разные варианты работы с единицами измерения, в порядке уменьшения полезной нагрузки:
- Type Driven Development - целая методология.
time_ms duration;
- отдельный тип данных, проверки на этапе компиляции.int64_t duration_ms;
- суффикс, видно в любом месте работы с переменной.int64_t duration; // ms
- комментарий, видно лишь в месте декларации, или если IDE умеет выводить подсказки.int64_t duration;
- метаинформация о единицах измерения отсутствует.
Следовательно:
- 1 и 2 - использовать по ситуации.
- 3 - рекомендуется, так как это оптимальный вариант между затратами времени и количеством полезной информации.
- 4 и 5 - не использовать.
Следовательно, суффикс указывает на единицы измерения если они применимы. Например: _ms
.
Некоторые нюансы по единицам измерения:
- Проценты
%
можно сократить как_prc
. - something PER something -> something/something -> something IN something
m/s -> _mIs
Возможные примеры исключений:_fps
,_mph
, так как они привычны.
Некоторые нюансы по названиям переменных:
idx
vsit
. Для переменных-итераторов полезно понимать вариант доступа:
itemIt
(iterator), последовательный доступ.
itemIdx
(index), смещение от 0, случайный доступ.
Возможный пример исключения:for (... y ...) { for (... x ...)
.
↑ 2.2. Типы данных
Рекомендуется: PascalCase
.
↑ 2.3. Функции и методы
Рекомендуется: camelCase_suffix
.
Для функций, возвращающих значения, которые имеют единицы измерения, можно применить ту же логику суффикса, как для переменных и констант.
В то время как сеттеры можно перегрузить для разных единиц разными типами, геттеры нельзя. По-этому, для консистентности, а также для снижения когнитивной нагрузки, рекомендуется явно описывать единицы измерения в суффиксе и для геттеров и для сеттеров.
При нескольких параметрах функции, проблемы не возникнет, так как для всех параметров применяется та же логика с суффиксами.
↑ 2.4. Пространства имён
Рекомендуется: snake_case
.
↑ 2.5. Макросы
Рекомендуется: UPPER_CASE_suffix
.
Допускаются обоснованные исключения, например, при попытке мимикрировать под языковую конструкцию.
UPPER_CASE используется только для макросов.
↑ 3.1. Целочисленные типы
Использовать только типы с фиксированным размером (<cstdint>
, int8_t..int64_t
, uint8_t..uint64_t
), так как это минимизирует количество проблем. При этом, std::
писать не следует, так как эти типы находятся также и в глобальном пространстве имён, и указание пространства std::
функционально ничего не меняет, а лишь добавляет символы в коде.
При желании, можно добавить, например, int8v2_t
, uint32v4_t
для определения векторных типов на 2 и 4 составляющие соответственно.
При адресации в циклах следует использовать size_t
, чтобы не было лишних неявных преобразований при прямой адресации по памяти. Для случаев со знаковой арифметикой, можно использовать intptr_t
или ssize_t
.
Для написания типизированных констант, рекомендуется использовать макросы (U)INT8_C..(U)INT64_C
, (U)INT8_MIN..(U)INT64_MIN
, (U)INT8_MAX..(U)INT64_MAX
, так как это наиболее кроссплатформенные варианты.
Если код шаблонный, тогда конечно используется
std::numeric_limits
.
Основная логика метаинформации здесь - нижний регистр для специальных разделителей (0x0 0b0 0.0f 0.0e+0
), верхний регистр для цифр (0xABCD
). Перфекционизм.
Для плавающей точки, всегда пишется целая и дробная часть, например: 0.0f
.
↑ 5.1. Иммутабельность
Стараться везде где это возможно добавлять ключевые слова constexpr
или const
.
Это добавляет полезную метаинформацию при чтении и работе с кодом, и это позволяет компилятору найти больше оптимизаций.
↑ 5.2. Ширина строк
Нормальная: до 80 символов. Рекомендованная: до 100 символов.
Другими словами, стремиться помещаться в 80 символов, но можно превышать до 100 символов, и можно превышать больше 100 если это однотипный декларативный код (например, инициализация сложного массива через {}
в виде таблицы).
Компактный по горизонтали код значительно быстрее читать.
↑ 5.3. Комментарии
Однострочные //
для постоянных комментариев, многострочные /**/
для быстрого временного отключения участков кода. Комментарии /**/
не должны попадать в репозиторий, чтобы в будущем при быстром отключении участков кода между /**/
не оказалось ещё одних /**/
. Плюс комментарии вида //
легко читаются без подсветки синтаксиса.
↑ 5.4. Отступы и скобки
Для отступов использовать 4 пробела, так как 2 слишком мало для императивного кода, а 8 слишком много. Табуляция создаёт известные проблемы.
Открывающие скобки ({
, <
, (
, [
) НЕ располагать на отдельной строке для всех языковых конструкций. Далее будут приведены разные примеры.
Компактный по вертикали код значительно быстрее читать.
Для параметров не влезших в первую строку, добавляется дополнительный отступ, чтобы не сливалось с телом:
int32_t foobar(int32_t aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
int32_t bbbbbbbbbbbbbbbb, int32_t cccccccccccccccccccccccccc,
int32_t dddddddddddddddddddddddddddddddddddddddddd) {
body();
}
Подвыражения нужно явно выделять строками и отступами:
if ((conditionA & conditionB)
| (conditionC & conditionD)
| (conditionE & conditionF)) {
...
}
Количество закрывающих скобок в одной строке должно быть равно количеству открывающих на этом же уровне отступа:
action(std::make_unique<Class>( // +2
argumentA, argumentB, argumentC,
calculate( // +1
argumentD, argumentE
) // -1
)); // -2
Исключение: если код простой, можно закрывать круглую скобку в конце строки с кодом, чтобы не раздувать код по вертикали.
Комплексный пример:
namespace some {
// class Foo
// : public Bar, public Baz {
class Foo : public Bar, public Baz {
public:
Foo(const int32_t a, const int32_t b, const int32_t c, const int32_t d)
: m_a(a), m_b(b)
, m_c(c), m_d(d) {
...
}
const Object* getObject() const {
return condition
? &s_someDefault
: condition
? m_someExist.get()
: nullptr;
}
void setObject(const Object* object);
signals:
// Qt-variant
void sigSome(); // Название функций-сигналов должно начинаться на "sig"
// Another variant
utils::Signal sigSome;
public slots:
void onSome() { // Название функций-слотов должно начинаться на "on"
if (condition) {
...
}
else {
...
}
switch (variable) {
case variantA:
if (condition) {
break;
}
break;
case variantB: {
break;
}
default:
break;
}
}
void onProcess10Hz(); // Если период вызова фиксированный, пишите его в названии
}; // class Foo
} // namespace some
↑ 5.5. Пустые разделяющие строки
Группировка связанных строк кода написанием их слитно, и разделение менее связанных пустыми строками - значительно облегчает чтение и работу с кодом. Другими словами, можно легко увидеть более связанные строки, не вынося их в отдельные функции или не добавляя очевидные разделяющие комментарии.
↑ 5.6. Логические проверки
При чтении и понимании работы кода логических проверок, полезно видеть метаинформацию о типе сравниваемых элементов. Для этого, вводится разделение на три группы:
if (boolean) if (!boolean)
if (number != 0) if (number == 0)
if (pointer != nullptr) if (pointer == nullptr)
Если значения можно расположить в одной системе координат, то их проверки следует размещать по порядку горизонтальной оси:
// good
if (button.x_px <= mouse.x_px and mouse.x_px <= button.x_px + button.w_px)
// bad
if (mouse.x_px >= button.x_px and button.x_px + button.w_px >= mouse.x_px)
Из этого следует, что для таких значений не рекомендуется использовать операторы >
и >=
, так как координаты растут слева направо.
↑ 5.7. Порядок включения файлов
#include "implementation.h" // The header for this implementation.cpp is always topmost
#include <iostream> // std libs are first
#include <vector>
#include <random>
#ifdef _WIN32
# define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers
# define NOMINMAX // Fixes the conflicts with STL
# include <Windows.h>
#else // deps are in the middle
# define _LARGEFILE_SOURCE
# include <posix.h>
#endif
#include "deps/utils.h" // locals are last
#include "logic.h"
...
...
↑ 5.8. Передача больших нетривиально-копируемых объектов
Это объекты больше чем 2 * sizeof(void*)
, или объекты со сложной логикой копирования.
Если только перемещение:
function(type&& value)
Если только копирование:
function(type value)
Если только чтение:
function(const type& value)
Если универсальная передача:
Если публичный интерфейс с одним параметром:
function(type&& value)
function(const type& value)
Иначе, для упрощения, можно:
function(type value)
Вариант исключения в соответствии с критериями выше:
std::string_view
лучше передавать по значению, так как передача таких объектов оптимизируется через два регистра.
↑ 5.9. Передача указателей
Функции должны принимать умные указатели только если они участвуют во времени жизни соответствующих ресурсов. Иначе, они должны принимать сырые указатели или ссылки.
↑ 5.10. Виртуальные функции
- Когда функция в базовом классе -
virtual
. - Когда функция в производном классе, но дальнейшее наследование не запрещается -
override
. - Когда функция в производном классе, и дальнейшее наследование запрещается -
final
.
↑ 5.11. Макросы
Макросы не зависят от структуры кода (вложенность), по этому, символ #
только вначале строки отражает эту суть. Также, символ #
лучше располагать только вначале строки, потому что некоторые IDE, по-умолчанию, при автоматическом форматировании, удаляют пробелы между началом строки и символом #
.
Дополнительный отступ между символом #
и ключевым словом макроса, позволяет дополнительно показать контекстную связь между этим макросом и участком кода, в котором он расположен. Ещё одним преимуществом такого отступа является то, что участок кода визуально не разбивается текстом, который начинается с начала строки.
Следует выравнивать ключевое слово макроса посередине между линией отступа тела кода в котором он располагается, и этой линией-минус-1-отступ. То есть, получается минус 2 пробела от отступа тела кода.
void function() {
while (condition) {
body
# ifdef A
...
# endif
...
}
}
В случае, когда места мало, а вложенности много, можно шагать по одному пробелу:
class Class() {
public:
...
private:
# if sizeof(void*) == sizeof(uint64_t)
# ifdef NDEBUG
utils::FastPimpl<128> m_impl;
# else
utils::FastPimpl<160> m_impl;
# endif // NDEBUG
# else
# ifdef NDEBUG
utils::FastPimpl<96> m_impl;
# else
utils::FastPimpl<112> m_impl;
# endif // NDEBUG
# endif
};
↑ 6.1. DRY - Don't Repeat Yourself
Одно из самых весомых и известных правил - "не повторяйте себя". В интернете много написано про это правило, по этому, оно здесь не будет поясняться. Но его можно прокомментировать так, что это базовое правило качественного кода, и много других правил так или иначе связаны с ним.
↑ 6.2. Вложенность
Одно из самых весомых правил - стараться везде использовать Return Early Pattern. Большое количество уровней вложенности заставляет держать в уме контексты каждого уровня. Тогда как, при использовании данного паттерна, можно работать со следующими контекстами, зная что предыдущие обработаны выше по коду. Также, это способствует лучшей оптимизации, так как одна из стратегий обработки ветвлений в процессоре - выполнение наперёд самой длинной ветки кода, которая, в случая применения данного паттерана, содержит полезный код, тогда как множество маленьких веток содержат редко выполняемый код.
bool updateState(const State newState) {
if (!device.isReady()) {
return false;
}
if (m_prevState == newState) {
return false;
}
switch (newState) {
case State::Undefined:
default:
return false;
case ...:
...
}
std::cout << "state changed: " << str(m_prevState) << " -> " << str(newState) << "\n";
m_prevState = newState;
return true;
}
↑ 6.3. Комбинаторика
Одно из многих правил, которое следует из правила DRY:
При написании кода содержащего много вариантов, предпочитайте O(m + n)
вместо O(m * n)
:
// O(m + n), good
switch (message_part0) {
case aa: message += "aa"; break;
case bb: message += "bb"; break;
}
switch (message_part1) {
case cc: message += "cc"; break;
case dd: message += "dd"; break;
}
switch (message_part2) {
case ee: message += "ee"; break;
case ff: message += "ff"; break;
case gg: message += "gg"; break;
}
// O(m * n), bad
switch (message_code) {
case aa_cc_ee: message = "aa_cc_ee"; break;
case aa_cc_ff: message = "aa_cc_ff"; break;
case aa_cc_gg: message = "aa_cc_gg"; break;
case aa_dd_ee: message = "aa_dd_ee"; break;
case aa_dd_ff: message = "aa_dd_ff"; break;
case aa_dd_gg: message = "aa_dd_gg"; break;
case bb_cc_ee: message = "bb_cc_ee"; break;
case bb_cc_ff: message = "bb_cc_ff"; break;
case bb_cc_gg: message = "bb_cc_gg"; break;
case bb_dd_ee: message = "bb_dd_ee"; break;
case bb_dd_ff: message = "bb_dd_ff"; break;
case bb_dd_gg: message = "bb_dd_gg"; break;
}
Яркий пример - токены в ресурсах локализации. Каждая строка в интерфейсе может иметь свой токен, и тогда ресурсы сильно растут в размере. Лучше, когда частые названия и фразы выделяются в отдельные токены для частого переиспользования.
↑ 6.4. Обработка ошибок
Хорошая программа не та, которая завершается при любой ошибке, а та, которая старается продолжать работать не смотря на некритичные ошибки.
В общем случае, это значит, что нужно игнорировать невалидные данные и ресурсы, иметь запасные варианты при отсутствии необходимых ресурсов, иметь запасные варианты работающего поведения при возникновении логических ошибок. При этом, лучше избегать использование исключений, так как они потенциально прервут бОльшую часть цепочки вызовов. Вместо них, лучше на каждом уровне цепочки вызовов делать проверки на успех выполнения вызовов, логируя ошибки в значимых местах, и задействуя запасные обходные варианты где это необходимо. В некоторых случаях, для такого хорошо подходит std::error_condition
.
↑ 6.5. Ускорение компиляции
Старайтесь использовать техники ускорения компиляции, в порядке уменьшения приоритета:
- forward declaration
- precompiled header
- fast pimpl
- pimpl
Для поиска файлов содержащих наибольшее количество включений других файлов, можно использовать утилиту cppinclude.
TODO: C++20 modules