Стайл гайд и общий список советов при написании кода на C++ для студентов курса “Основы Программирования” кафедры ИУ5 МГТУ им. Н.Э. Баумана.
В отличие от многих других современных языков программирования, C++ не имеет универсальных указаний и правил по написанию и форматированию кода. Каждый индивидуальный разработчик, как правило, следует своим предпочтениям или придерживается указаниям, написаными другими организациями. Подобный свод различных инструкций называется стайл гайдом.
Для удобства разработки, при работе над проектом, в котором участвуют много (больше одного) разработчиков, следование стайл гайду является обязательным. Многие отдельные команды и компании имеют свои стайл гайды, например, Google C++ Style Guide.
Одной из целей курса является научить вас писать “красивый” и читаемый код. Часть описанных здесь правил являются объективными представлениями о современном написании кода (часто безотносительно языка программирования), часть субъективными.
Независимо от ваших предпочтений, следование данному конкретному стайл гайду абсолютно обязательно и отражает реалии работы над проектами в командах любых размеров.
Многие правила, описанные в данном документе, форсируются автоматически с применением clang-format и clang-tidy.
Clang-format - широко используемый форматтер для C и C++, с помощью которого можно обеспечить соблюдение единых правил форматирования кода во всем проекте (или проектах) разного уровня. Правила настраиваются через специальный конфигурационный файл.
Clang-tidy - статический анализатор, который помогает обнаруживать масштабный набор различных классов ошибок, возникающих при написании кода.
Использование данных инструментов обязательно при написании лабораторных работ, домашних заданий и практики.
Не используйте табы для создания отступов в коде. Вместо табов для отступов используется 4 пробела. Все современные IDE и редакторы кода позволяют настроить клавишу Tab на вставку нужного количества пробелов.
Для вставки отступов и обрамления фигурными скобками используется так называемый One True Brace style. Открывающая фигурная скобка ставится на той же строке, что и название функции/условие/цикл/структура/класс/…, а закрывающая на отдельной строке.
void func()
{
...
} // bad
void func() {
...
} // OK
Длина строки должна быть ограничена 150 символами. Это то количество, которое позволяет относительно комфортно уместить большую строку в открытом файле без необходимости горизонтального скроллинга.
Правила переноса установлены в конфиге для clang-format.
Все объекты языка, которые именуются разработчиком, будь то переменная, функция, тип и т.д., должны иметь осмысленное название, которое должно явно отражать область применения данного объекта.
Не следует применять необоснованные сокращения и ничего не значащие символы (за исключением распротраненных обозначений, например, i, j, k для итерации внутри цикла, T для обозначения шаблонного типа, lhs и rhs для левого и правого аргумента в подходящих контекстах и т.п.).
Не надо использовать так называемую венгерскую нотацию.
В названиях должен быть использован грамотный английский язык.
struct C {
int x;
int y;
int z;
}; // bad
struct Coordinates {
int x;
int y;
int z;
}; // OK
void InitMA(int n); // bad
void InitMemoryAllocator(int id); // OK
В современной разработке программист большую часть времени проводит за чтением кода, а не его написанием. Поэтому важно обеспечить хорошую читаемость кода путем разделения его на небольшие блоки, в каждом из которых описана какая-то логически отделимая часть приложения. Каждый такой блок разделяется отдельной пустой строкой. При этом необходимо находиться в рамках разумных пределов и не делить код слишком сильно.
bool IsPrime(int number) {
if (number <= 1) {
return false;
}
for (int i = 0; i * i < number; ++i) {
if (number % i == 0) {
return false;
}
}
return true;
} // bad
bool IsPrime(int number) {
if (number <= 1) {
return false;
}
for (int i = 0; i * i < number; ++i) {
if (number % i == 0) {
return false;
}
}
return true;
} // OK
bool IsPrime(int number) {
if (number <= 1) {
return false;
}
for (int i = 0; i * i < number; ++i) {
if (number % i == 0) {
return false;
}
}
return true;
} // also bad
Глобальные переменные в общем случае запрещены. Связано это прежде всего с тем, что наличие глобальных переменных усложняет понимание работы программы.
Любая функция может обратиться к переменной и поменять ее значение, что может привести к большим сложностям при необходимости отслеживании состояния приложения и исправления возникаемых ошибок.
Данное правило не относится к глобальным константам, которые, однако, все равно не следует объявлять в заголовочных файлах. Вместо этого их необходимо складывать в анонимный неймспейс в соответствующем .cpp файле.
int a; // bad
namespace {
const float pi = 3.14159265359;
} // OK
goto - оператор безусловного перехода к определенной точке программы, обозначенной именованной меткой.
Использование goto полностью запрещено. Связано это со значительным ухудшением читаемости кода как последовательности различных команд, выражений и вызовов функций.
Использование директивы using namespace std полностью запрещено. Как в заголовочных, так и в .cpp файлах.
Делается это для избежания потенциальной коллизии имен, так как стандартная библиотека предлагает огромный набор различных типов и функций, повсеместно встречающихся во время разработки.
Более подробно будет рассказано в лекциях.
Использование элементов ООП запрещено. Таким образом запрещено использование классов, методов, разных конструкторов, перегрузок операторов, модификаторов доступа и т.п.
Помимо этого запрещено использование STL: std::string, любые контейнеры (std::vector, std::map, std::set, std::unordered_map и т.д.), адаптеры над контейнерами (std::queue, std::stack) и большинство других классов стандартных библиотек, за некоторыми исключениями (например, классы, связанные с потоковым вводом и выводом и манипуляцией ввода/вывода, такие как std::istream, std::ostream, std::fstream и т.п.).
В общем случае использование макросов и макроконстант запрещено. Вместо этого следует использовать функции и обычные константы.
Связано это с тем, что активное использование препроцессора усложняет понимание кода и его отладку (например, макроконстанты не отображаются в дебаггере и т.п.).
Использование директив препроцессора следует также свести к разумному минимуму, в силу отсутствия необходимости их активного использования в рамках данного курса.
#define ARRAY_SIZE 500
// bad
#define SUM(LHS, RHS) ((X) + (Y))
// bad
Функция main должна обязательно возвращать int, а также принимать аргументы командной строки (int argc, char* argv*). Если аргументы командной строки не используются, то следует просто оставить их тип или пометить атрибутом [[maybe_unused]].
void main() {
...
} // bad
int main(int, char**) {
...
return 0;
} // OK
int main([[maybe_unused]] int argc, [[maybe_unused]] char** argv) {
...
return 0;
} // OK
Сама функция должна интерпретироваться прежде всего как точка входа, поэтому программная логика внутри main должна быть сведена к минимуму (чем больше сдается лабораторных работ, тем более строгое это требование). В идеале функция main должна только передавать управление потоком исполнения программы в другое место.
Если функция в ходе своей работы может отработать неправильно (например, идет обработка потенциально некорректного пользовательского ввода), то она должна явно указать это одним из возможных способов сигнализации об ошибке (возвращаемый код, исключение и т.п.).
Если функция может отработать с ошибкой, то вызывающая функция должна предусмотреть дальнейшее поведение тогда, когда есть такая возможность.
Если функция принимает аргументы, значения которых могут привести к некорректной работе этой функции, то в ней должны проверяться значения этих аргументов на принадлежность области допустимых значений.
double Divide(int lhs, int rhs) {
if (rhs == 0) {
... // err, OK, can't divide by zero
}
return lhs / rhs;
}
void PrintArg(int* arg) {
std::cout << *arg; // bad, arg might be nullptr
}
void PrintArg(int* arg) {
if (!arg) {
return;
}
std::cout << *arg; // OK
}
Любая компиляция исходного кода должна сопровождаться флагами -Wall, -Wextra, -Werror и -Wpedantic.
Благодаря этим флагам компилятор интерпретирует предупреждения как ошибки компиляции, что заставляет писать более аккуратный и безопасный код.
При сдаче лабораторной работы в файлах не должно быть неиспользуемого (закомментированого) кода.
В конце каждого файла с исходным кодом должна стоять пустая строка (распространенное правило для, например, улучшения читаемости диффа в репозитории, удобной конкатенации файлов и т.п.).
Обычные переменные и константы внутри функций именуются через camelCase.
int variableWithMultipleWords;
Глобальные константы также именуются через camelCase, но с префиксом k в начале.
const int kGlobalConstant;
constexpr int kAnotherGlobalConstant;
Магическая константа - это некий литерал, семантическое (смысловое) значение которого нельзя определить без контекста.
int main(int, char**) {
int number = 0;
std::cin >> number;
if (number >= 100) { // 100 - магическая константа
return -1;
}
return 0;
}
Значения магических констант должны быть сложены в именованные константы. При этом допускается прямое использование некоторых литералов, таких как 0, true, false, nullptr, “” и др.
namespace {
const int kMaxAvailableNumber = 100;
}
int main(int, char**) {
int number = 0;
std::cin >> number;
if (number >= kMaxAvailableNumber) {
return -1;
}
return 0;
}
Для улучшения читаемости на одной строке может быть объявлена только одна переменная.
int a = 0, b = 0, c = 0; // bad
int a = 0; // OK
int b = 0; // OK
int c = 0; // OK
Объявление переменной должно совпадать с явной инициализацией каким-либо значением. Неявную инициализацию следует избегать. Если переменная получает значение из какой-либо функции или выражения, то вызов этой функции или само выражение должна быть использованы во время объявления переменной.
int a; // bad, indeterminate value
int a = 0; // OK
int a{}; // OK, default value for int is 0
int a = 0;
int a = foo(); // bad
int b = foo(); // OK
При объявлении переменной ссылочного типа или указателя амперсанд (&) и звездочка (*) должны быть привязаны к типу, то есть выравнены по левой стороне.
int a;
int &r = a; // bad
int& r = a; // OK
int *p = nullptr; // bad
int* p = nullptr; // OK
Если переменная не предполагает дальнейшее изменение значения, то следует помечать ее как константу через const.
Если переменная необходима, но не используется или может не использоваться в некоторых случаях, ее следует пометить атрибутом [[maybe_unused]].
[[maybe_unused]] int a = 0;
Использование auto в общем случае запрещено.
Исключение составляют те случаи, когда без них не обойтись или же указать тип неоправдано сложно.
auto a = 5; // bad
auto b = foo(); // bad
struct S {
int a;
int b;
}
S foo();
auto [first, second] = foo(); // OK
auto l = [] { return 0; }; // OK
Функции именуются в PascalCase.
Функции, как правило, должны семантически отражать какое-то действие (то есть содержать глагол) или отвечать на некоторый вопрос.
void Directory(const char* name); // bad
void CreateDirectory(const char* name); // OK
bool IsEven(int number); // OK
Следует избегать написания слишком больших функций. В идеале, каждая конкретная функция должна выполнять какое-то одно действие, которое выполняется за относительно небольшое количество строк кода (желательно в пределах ~50, но без фанатизма).
Божественная функция - это такая функция, которая отвечает за много действий разом. Например, принимает пользовательский ввод, обрабатывает его и выводит какой-то результат.
Следует проектировать приложение таким образом, чтобы каждая отдельная функция имела одну конкретную зону ответственности.
Например, функция может отвечать за считывание файла в строку. Другая функция за обработку этих данных. И т.д.
Правильная декомпозиция кода на множество функций улучшает читаемость, поддерживаемость и переиспользуемость кода.
void CreateOrRemoveFile(const char* fileName, bool createOrRemove); // bad
void CreateFile(const char* fileName); // OK
void RemoveFile(const char* fileName); // OK
Аргументы по умолчанию следует использовать тогда, когда передаваемый в функцию аргумент должен обязательно использоваться внутри функции и его отсутствие приведет к невозможности выполнения этой функции. При этом предполагается что использование такой функции с аргументом по умолчанию будет соответствовать поведению, которое ожидает разработчик от этой функции.
В других случаях следует предпочитать использование перегрузки функций.
void CreateFile(const char* fileName, const char* directory = "");
// OK, "" is current working directory
double Pow(double number, double power = 0); // bad, unintuitive
Если функция возвращает значение, которое нельзя игнорировать и необходимо использовать после вызова функции, то такую функцию следует помечать атрибутом [[nodiscard]] рядом с объявлением функции.
В следующем примере ожидается, что результат выполнения функции Sum должен быть каким-либо образом использован (иначе вызов функции не имеет смысла). Неиспользование возвращаемого результата функции с использованием атрибута [[nodiscard]] приведет к ошибке компиляции.
int Sum(int lhs, int rhs); // bad, the return value must be used
[[nodiscard]] Sum(int lhs, int rhs); // OK
В данном примере функция CreateUser возвращает код ошибки, которую, пусть и крайне желательно проверять, но при этом все же допустимо не использовать.
int CreateUser(const char* name); // OK
Если функция перехватывает поток исполнения и не отдает его назад в вызывающую функцию ни при каких обстоятельствах (например, внутри находится бесконечный цикл или системный вызов завершения приложения), то такую функцию следует помечать атрибутом [[noreturn]].
Если функция не используется, но ее удаление нежелательно, то она должна быть помечена атрибутом [[maybe_unused]].
Использование auto для определения типа возвращаемого значения допустимо только при работе с метапрограммированием.
auto Sum(int lhs, int rhs); // bad
template<typename T, typename U>
auto Sum(T lhs, U rhs); // OK
Trailing return type - особый вид нотации для отображения типа возвращаемого значения. Его использование не регламентировано и остается на усмотрение студента.
Важно, что в рамках одного приложения использовался только один тип нотации.
int Sum(int lhs, int rhs); // OK
auto Sum(int lhs, int rhs) -> int; // OK
int Sum(int lhs, int rhs);
auto Multiply(int lhs, int rhs) -> int; // bad, don't mix styles
Структуры именуются в PascalCase.
struct S;
Поля структуры именуются в camelCase.
struct S {
int firstField;
int secondField;
};
Следует избегать названий вида My[Something] или Custom[Something].
Вид инициализации переменной структуры следует выбирать в зависимости от контекста.
Для инициализации полей структуры дефолтными (нулевыми) значениями достаточно использовать следующую конструкцию:
struct S {
int a;
char b;
float c;
double d;
};
S s = {}; // a == 0, b == 0, c == 0.f, d == 0.
В общем случае следует предпочитать использование designated initializers:
S s = {
.a = 1,
.b = 2,
.c = 3,
.d = 4, // !!! comma is necessary if you to keep this formatting
};
Или:
S s = {.a = 1, .b = 2, .c = 3, .d = 4};
Если структура имеет интуитивно понятную форму, то допустимо использовать обычный список инициализации:
struct Coordinates {
int x;
int y;
int z;
};
Coordinates coords = {1, 2, 3}; // OK
Перечисления именуются в PascalCase. Возможные значения перечисления должны также именоваться в PascalCase.
enum class TrafficLight {
Red,
Yellow,
Green,
};
Перечисления следует использовать в тех случаях, когда переменная должна принимать ограниченный набор значений, которые должны иметь явную семантическую окраску.
Например, идиоматически некорректно использовать bool для обозначения пола. Вместо этого следует использовать enum.
bool gender = true; // male, bad
bool gender = false; // female, bad
enum class Gender {
Male,
Female,
};
Gender gender = Gender::Female; // OK
В подавляющем большинстве случае следует использовать enum class вместо обычного enum для ограничения области видимости значений соответствующего перечисления и предотвращения неявной конвертации.
При необходимости задавать числовые значения значениям перечисления следует также явно указать низлежащий тип.
enum class E {
A = 0,
B = 1,
}; // bad
enum class E : int {
A = 0,
B = 1,
}; // OK
Между оператором и операндом не должно быть пробела.
int a = 0;
++a; // OK
++ a; // bad
int a = 0;
int b = &a; // OK
int b = & a; // bad
Между оператором и его операндами должен стоять пробел.
5+5; // bad
5 + 5; // OK
Следует избегать неоднозначности при написании больших выражений с использованием бинарных и унарных операторов. При необходимости лучше складывать отдельные части выражения в круглые скобки. Это помогает четко определить с каким приоритетом будут выполняться все операции.
int a = 0;
int b = a++ + ++a * a % 10 / 20; // really bad
int c = 5 + 5 / 2; // OK
int c = 5 % 5 / 2; // bad
int c = (5 % 5) / 2; // OK
Единственный тернарный оператор в C++ - оператор ветвления ?:. Его можно использовать в тех случаях, когда нужно определить исполняемое действие на основе небольшого логического выражения. Если логика достаточно сложная и чтение такой конструкции оказывается затруднено, то следует использовать обычные условные конструкции (if/else).
int a = 10 > 5 ? 0 : 1; // OK
int a = 10 > 5 ? 0 : 7 < 3 ? 2 : 1; // bad, hard to read
Так как каждый тривиальный тип можно неявно привести к bool, где 0 - это false, а любое другое значение - это true, то ко всем таким переменным можно применить оператор логического отрицания (!). Это приводит к возможности использования некоторых синтаксических конструкций, которые будут разобраны ниже.
При сравнении числовых типов следует явно указывать оба операнда, даже если сравнение идет с нулем.
int a = 0;
if (a == 5) {
...
} // OK
if (a == 0) {
...
} // OK
if (!a) {
...
} // bad
Не следует явно сравнивать переменную с типом bool с булевыми литералами true и false. Достаточно просто указывать переменную в условии.
bool b = true;
if (b == true) {
...
} // bad
if (b) { // if b is true
...
} // OK
if (!b) { // if b is false
...
} // OK
Также как в случае с bool, проверка указателя на nullptr должна осуществляться без прямого сравнения с ним.
char* p = nullptr;
if (p == nullptr) {
...
} // bad
if (!p) {
...
} // OK
Любые однострочные условия должны оборачиваться в фигурные скобки.
if (true)
std::cout << "true" << std::endl // bad
if (true) {
std::cout << "true" << std::endl
} // OK
Следует избегать использование else там, где это возможно.
void foo(int a) {
if (a == 5) {
std::cout << "a == 5" << std::endl;
} else {
std::cout << "a != 5" << std::endl;
}
} // bad
void foo(int a) {
if (a == 5) {
std::cout << "a == 5" << std::endl;
return;
}
std::cout << "a != 5" << std::endl;
} // OK
Следует максимально избегать увеличения вложенности условий, так как это значительно затрудняет чтение кода. Одна из самых распространненых для этого техники - раннее возвращение из тела функции.
void foo(int* a, int* b, int* c) {
if (a) {
if (b) {
if (c) {
...
}
}
}
} // OK
void foo(int* a, int* b, int* c) {
if (!a || !b || !c) {
return;
}
...
} // OK
Нотация Йоды - способ записи выражений сравнения, при котором константная часть выражения находится слева.
Запрещена к использованию, так как затрудняет чтение. Используемые на курсе инструменты (в частности, компилятор) уже предотвращают возможность возникновения этой ошибки.
Использования атрибутов указания компилятору вероятности захода в тело условия [[likely]] и [[unlikely]] не запрещено напрямую, однако, не рекомендуется к использованию за отсутствием необходимости в рамках выполнения заданий на курсе.
Однострочные циклы должны быть обернуты в фигурные скобки.
while (true)
... // bad
while (true) {
...
} // OK
Следует использовать корректные виды циклов в зависимости от стоящей задачи.
Если предполагается какое-то пошаговое изменение с каждой итерацией цикла, то следует предпочитать цикл for. Например, если нужно проитерироваться по массиву или обработать какую-то генерируемую последовательность.
Если логика продолжения цикла завязана на статус значения какой-нибудь переменной (своего рода продолжающееся условие), то следует использовать цикл while.
Если нужно обеспечить как минимум одну итерацию цикла, то можно обратиться к конструкции do/while.
Если нужно проитерироваться по значениям массива, размер которого известен на стадии компиляции, то стоит использовать range-based for loop.
Switch-case конструкцию следует использовать в тех случаях, когда нужно проверить одну и ту же целочисленную переменную с разными значениями. Тогда множество условных конструкций if/else заменяется на switch-case.
int a = 0;
if (a == 0) {
...
} else if (a == 1) {
...
} else if (a == 2) {
...
} // bad
switch (a) {
case 1:
...
break;
case 2:
...
break;
case 3:
...
break;
default:
...
break;
} // OK
Switch-case предпочитаемый способ работы с переменными с типом enum.
Почти всегда следует предусматривать поведение программы для тех случаев, когда switch-case не покрывает все возможные значения. Для этого следует использовать метку default.
В конце каждого case по умолчанию должен стоять break. Его отсутствие приведет к ошибке компиляции.
Если “падение” в следующий case является желаемым поведением, то следует использовать атрибут [[fallthrough]].
int a = 0;
switch (a) {
case 1:
...
case 2:
...
break;
} // bad, compilation error
switch (a) {
case 1:
...
[[fallthrough]];
case 2:
...
break;
} // OK
Неймспейсы именуются в PascalCase. Неймспейсы должны указывать на область применения набора функций и типов. Это может быть название библиотеки или приложения, указание на часть архитектуры и т.п.
Как в случае и с типами, следует избегать названий начинающихся с My, Custom и т.п.
Анонимные неймспейсы должны находиться только в .cpp файлах. В них могут находиться вспомогательные функции, константы и типы, присутствие которых нежелательно в заголовочном файле (например, они не являются частью интерфейса).
Допустимо использовать псевдонимы для неймспейсов для уменьшения нагруженности кода и улучшения читаемости. При этом следует обеспечить сохранение семантической нагрузки, а также убедиться, что псевдоним не находится в глобальной области видимости в заголовочном файле.
// file main.cpp
namespace fs = std::filesystem; // OK
namespace a = std::filesystem; // bad
// file SomeHeader.h
namespace fs = std::filesystem; // bad
Использование using::тип разрешено для типов не из стандартной библиотеки. Не следует использовать данную конструкцию в заголовочном файле.
// file main.cpp
namespace Vec {
struct Vector {
...
};
}
using Vec::Vector; // OK
Минимизируйте использование комментариев в коде. Код должен быть достаточно самодокументируемым, чтобы избежать использование поясняющих комментариев.
Используйте комментарии только в тех случаях, когда нужно описать сложные участки кода (какие-нибудь специфические алгоритмы и т.п.).
Заголовочные файлы и файлы с исходным кодом именуются попарно. В заголовочном файле должен быть описан программный интерфейс какой-то части кода (объявления функций, типы и т.п.), в файле с исходным кодом (.cpp файле) - реализация.
Название файла должно быть в PascalCase и отображать семантически то, что описано в данном файле.
Vector.h - описан тип вектора и функции над работой с ним
Vector.cpp - реализация
Vector.h - только заголовочный файл, в случае если реализуемый вектор - шаблонный тип
Для заголовочных файлов используется либо расширение .h, либо .hpp (в зависимости от предпочтений). Смешивать вместе разные расширения запрещено.
Файлы с исходным кодом должны иметь расширение .cpp.
Заголовочные файлы делятся на 4 типа:
Каждый блок должен отделяться пустой строкой. Внутри блока файлы должны быть отсортированы в алфавитном порядке.
Библиотечные файлы должны подключаться в угловых скобках. Файлы проекта должны подключаться в кавычках.
// file Vector.cpp
#include "Vector.h" // парный заголовочный файл
#include "HeaderOne.h"
#include "MyHeader.h" // заголовочные файлы проекта
#include <QObject>
#include <QString> // заголовочные файлы сторонних библиотек
#include <cmath>
#include <iomanip>
#include <iostream> // заголовочные файлы стандартной библиотеки
Заголовочные файлы обязательно должны содержать либо header guards, либо #pragma once для предотвращения повторного включения заголовочных файлов.
Макроконстанта для header guards должна быть полностю уникальной для каждого файла. Например, можно использовать относительный путь из корня репозитория.
// file include/foo/bar.hpp
#ifndef INCLUDE_FOO_BAR_HPP
#define INCLUDE_FOO_BAR_HPP
// file content
#endif // INCLUDE_FOO_BAR_HPP
Заголовочные файлы из библиотеки для языка C подключаются как <c’name’>, а не <name.h>.
#include <math.h> // bad
#include <cmath> // OK
DRY - Don’t Repeat Yourself. Принцип разработки, согласно которому следует избегать дублирование кода в приложении.
Старайтесь грамотно декомпозировать код на отдельные функции, которые затем можно переиспользовать в разных местах.
Если какая-то часть функциональности повторяется в разных местах - это повод вынести ее в отдельную функцию. В таком случае, если понадобится внести изменения, то их достаточно будет применить в одном месте, вместо нескольких. Это уменьшает вероятность появления ошибки и упрощает разработку.
Следует внимательно относится к данному принципу и не следовать ему слишком фанатично, так как это может привести к усилению зацепления.
KISS - Keep It Simple, Stupid. Принцип разработки, согласно которому следует предпочитать простые конструкции сложным.
По возможности избегайте неоправданного усложнения кода и пишите такой код, который будет понятен постороннему человеку, находящемуся вне контекста.