Перейти к основному содержимому

Рекомендации по разработке на C++

Разработчику Архитектору

Рекомендации по разработке на C++​

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


Требования по именованию​

Общие принципы именования​

Имена сущностей в коде должны точно отражать их назначение и семантику. Хорошее имя позволяет понять поведение элемента без изучения его реализации. Имена формируются на английском языке, без транслитерации русских слов и без использования сокращений, кроме общепринятых в отрасли (например, id, url, http).

Каждый элемент языка использует соответствующую нотацию:

Элемент языкаНотацияПример
Класс, структура, объединениеPascalCaseNetworkConnection
Шаблонный параметрPascalCaseTContainer
Перечисление (тип)PascalCaseConnectionStatus
Перечисление (значение)PascalCaseConnected
Пространство именsnake_casenetwork_utils
Глобальная переменнаяg_PascalCaseg_ApplicationState
Статическая переменная классаs_PascalCases_InstanceCount
Член класса (поле)snake_caseconnection_timeout_
Локальная переменнаяsnake_casebuffer_size
Константа (глобальная/класса)kPascalCasekMaxBufferSize
МакросSCREAMING_SNAKE_CASEMAX_RETRIES
Функция, методsnake_caseestablish_connection
Функция-член (метод)snake_caseget_status
Параметр функцииsnake_casetimeout_ms

Приватные поля класса завершаются символом подчёркивания. Этот приём визуально отделяет поля от локальных переменных и параметров методов, упрощая чтение кода.


Именование классов и структур​

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

// Хорошие примеры
class DocumentParser;
class PaymentGateway;
class UserSession;

// Плохие примеры
class Parser; // Слишком абстрактно
class DocumentHandler; // Неясная ответственность
class MyClass; // Бессмысленное имя

Разбор:

  • Этот фрагмент сравнивает качество нейминга для классов: сверху имена отражают предметную область, снизу создают двусмысленность.
  • class DocumentParser; показывает "класс-парсер документа", то есть роль читается без открытия реализации.
  • class Parser; слишком общий идентификатор: непонятно, что именно парсится и где границы ответственности.
  • class DocumentHandler; намекает сразу на много действий ("обработчик"), что обычно маскирует размытый дизайн.
  • Имена вроде MyClass не несут бизнес-смысла и усложняют ревью, поиск и сопровождение кода.

Структуры (struct) используются для агрегации данных без инвариантов или с минимальной логикой. Их именование следует тем же правилам, что и для классов.


Именование функций и методов​

Функции и методы именуются глаголами или глагольными словосочетаниями, описывающими выполняемое действие. Для методов, возвращающих логическое значение, применяются префиксы is_, has_, can_, should_.

// Примеры корректных имён
void load_configuration(const std::string& path);
bool is_connection_active() const;
size_t get_buffer_size() const;
void set_timeout(std::chrono::milliseconds timeout);

Разбор:

  • Все имена функций сформулированы как действия (load_, get_, set_), поэтому вызов сразу читается как команда.
  • const std::string& path передаёт путь по константной ссылке: без лишнего копирования и без возможности изменить аргумент внутри функции.
  • Суффикс const у is_connection_active() const фиксирует контракт "метод не меняет состояние объекта".
  • size_t get_buffer_size() const подчёркивает, что размер — беззнаковое целое, подходящее для контейнеров и буферов.
  • std::chrono::milliseconds делает единицы измерения таймаута явными и защищает от путаницы "миллисекунды/секунды".

Асинхронные операции получают суффикс _async в названии:

std::future<ConnectionResult> connect_async(const Endpoint& endpoint);

Разбор:

  • std::future<ConnectionResult> сообщает, что функция возвращает "обещание результата", который будет готов позже.
  • Суффикс _async прямо отражает асинхронную природу вызова и снижает риск неправильного ожидания мгновенного результата.
  • ConnectionResult как отдельный тип позволяет явно описать успешный/ошибочный итог подключения.
  • const Endpoint& endpoint передаёт точку подключения без копирования, сохраняя неизменяемость входных данных.
  • Такой API хорошо сочетается с wait(), get() и композициями асинхронных задач на уровне вызывающего кода.

Именование файлов​

Заголовочные файлы используют расширение .hpp, файлы реализации — .cpp. Имена файлов соответствуют основному классу или компоненту, определённому в файле, и используют стиль snake_case.

network_connection.hpp
network_connection.cpp
http_client.hpp
http_client.cpp

Для шаблонных классов и функций, требующих определения в заголовочном файле, допускается использование расширения .h или размещение реализации в том же .hpp файле.


Требования по оформлению​

Отступы и пробелы​

Для отступов используются четыре пробела. Табуляция запрещена. Пробелы размещаются:

  • После ключевых слов if, for, while, switch, catch
  • Вокруг бинарных операторов (+, -, *, /, =, ==, !=)
  • После запятых в списках параметров и аргументов
  • Перед открывающей фигурной скобкой управляющих конструкций
// Корректное оформление
if (value > threshold) {
process_item(item);
}

for (size_t i = 0; i < container.size(); ++i) {
handle_element(container[i]);
}

int result = (a + b) * (c - d);

Разбор:

  • Фрагмент показывает единый стиль форматирования: отступы, пробелы и скобки делают структуру выражений читаемой с первого взгляда.
  • if (value > threshold) демонстрирует пробел после ключевого слова и явный блок даже для короткой логики.
  • Цикл for (size_t i = 0; i < container.size(); ++i) использует безопасный тип индекса и префиксный инкремент.
  • Вызов handle_element(container[i]); визуально отделяет индексацию и вызов функции, уменьшая шум при ревью.
  • Выражение (a + b) * (c - d) подчёркивает приоритет через скобки, чтобы избежать неоднозначного чтения.

Пробелы не ставятся:

  • После открывающей круглой скобки и перед закрывающей
  • Перед запятой
  • Внутри пустых скобок

Фигурные скобки​

Фигурные скобки размещаются согласно стилю Allman (K&R):

  • Открывающая скобка размещается на новой строке на уровне управляющей конструкции
  • Закрывающая скобка размещается на отдельной строке с тем же отступом, что и управляющая конструкция
  • Однострочные блоки всё равно оформляются фигурными скобками

Код ITЗагрузка примера кода…

Разбор:

  • Пример фиксирует стиль скобок: открывающая и закрывающая фигурные скобки стоят на отдельных строках.
  • Ветвление if/else оформлено симметрично, что упрощает визуальную проверку структуры блока.
  • Блок класса показывает те же правила для декларативного кода: тело класса отделяется скобками с ровным выравниванием.
  • Секция public: явно делит интерфейс и внутренние детали класса.
  • Унифицированный стиль скобок снижает число ошибок при будущих вставках строк в условные блоки.

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


Длина строк и переносы​

Максимальная длина строки составляет 100 символов. При превышении этого лимита выражение разбивается на несколько строк с дополнительным отступом в четыре пробела.

Цепочки методов разносятся по строкам с точкой в начале каждой новой строки:

auto result = database
.query("SELECT * FROM users")
.where("active = true")
.limit(100)
.execute();

Разбор:

  • Цепочка вызовов оформлена в "флюентном" стиле: каждый следующий шаг начинается с новой строки и точки.
  • query(...) формирует базовый запрос, после чего к нему последовательно применяются фильтры и ограничения.
  • where("active = true") добавляет предикат отбора, не разрывая общий конвейер обработки запроса.
  • limit(100) задаёт верхнюю границу выборки и защищает от избыточной загрузки данных.
  • execute() завершает построение и запускает фактическое выполнение запроса.

Параметры функций с большим количеством аргументов переносятся по одному на строку:

void configure_connection(
const std::string& host,
uint16_t port,
std::chrono::milliseconds timeout,
bool use_encryption,
const CertificateChain& certificates
);

Разбор:

  • Такой перенос показывает каждый параметр отдельной строкой, поэтому сигнатура читается как список контрактов функции.
  • const std::string& host передаёт адрес хоста эффективно и без копирования строки.
  • uint16_t port фиксирует диапазон номера порта на уровне типа.
  • std::chrono::milliseconds timeout явно кодирует единицы времени и убирает "магические числа".
  • Финальный параметр с сертификатами подчёркивает, что безопасность соединения настраивается явно через структуру данных.

Пустые строки​

Пустые строки разделяют логические блоки кода:

  • Между методами и функциями
  • Между группами переменных в классе (поля, конструкторы, методы)
  • После открывающей скобки пространства имен
  • Перед и после директив #ifdef, #endif
  • Между логическими секциями внутри метода (инициализация, основная логика, финализация)

Код ITЗагрузка примера кода…


Структура проекта​

Организация каталогов​

Проект организуется по функциональному принципу с выделением следующих основных каталогов:

project/
├── include/ # Публичные заголовочные файлы
├── src/ # Исходный код реализации
│ ├── core/ # Ядро приложения
│ ├── network/ # Сетевые компоненты
│ ├── storage/ # Работа с хранилищами
│ └── utils/ # Вспомогательные утилиты
├── tests/ # Модульные и интеграционные тесты
├── third_party/ # Сторонние зависимости
└── cmake/ # CMake скрипты и конфигурации

Каждый компонент размещается в отдельном подкаталоге с собственной структурой include и src при необходимости экспорта публичного API.


Заголовочные файлы и файлы реализации​

Заголовочный файл содержит только объявления, необходимые для использования компонента. Внутренние детали реализации скрываются с помощью идиомы PIMPL или размещаются исключительно в .cpp файлах.

Каждый заголовочный файл защищается от повторного включения с помощью #pragma once:

Код ITЗагрузка примера кода…

Разбор:

  • #pragma once защищает заголовок от повторного включения и устраняет дублирующиеся объявления.
  • namespace network изолирует API компонента и снижает риск конфликтов имён в крупном проекте.
  • explicit Connection(...) запрещает неявные преобразования в объект соединения.
  • Поле void* handle_ указывает на непрозрачный системный дескриптор, скрывая платформенные детали от пользователя API.
  • Публичные методы connect/disconnect/is_connected формируют минимальный и понятный контракт жизненного цикла соединения.

Файл реализации включает соответствующий заголовок первым для проверки самодостаточности заголовка:

Код ITЗагрузка примера кода…

Разбор:

  • Первый include собственного заголовка помогает сразу выявлять недостающие зависимости в интерфейсе.
  • Блок стандартных include (<stdexcept>, <system_error>) отделён от проектных заголовков для поддержания порядка.
  • namespace network в .cpp должен совпадать с заголовком, чтобы определения методов нашли объявленные символы.
  • Конструктор Connection::Connection(...) : address_(...), handle_(nullptr) инициализирует поля через список инициализации.
  • Комментарий // Реализация методов... подсказывает, что дальше идёт конкретная логика, скрытая от внешнего интерфейса.

Правила включения заголовков​

Директивы #include группируются в следующем порядке с пустой строкой между группами:

  1. Соответствующий заголовочный файл компонента (для .cpp файлов)
  2. Заголовки стандартной библиотеки C++
  3. Заголовки стандартной библиотеки C
  4. Заголовки внешних библиотек (Boost, Qt и другие)
  5. Заголовки текущего проекта

Внутри каждой группы заголовки сортируются по алфавиту:

Код ITЗагрузка примера кода…


Проектирование классов и интерфейсов​

Принцип единственной ответственности​

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

Код ITЗагрузка примера кода…


RAII и управление ресурсами​

Классы, управляющие ресурсами (память, файловые дескрипторы, сетевые соединения, мьютексы), должны следовать идиоме RAII (Resource Acquisition Is Initialization). Ресурс захватывается в конструкторе и освобождается в деструкторе. Это гарантирует корректное освобождение ресурсов даже при возникновении исключений.

Код ITЗагрузка примера кода…

Разбор:

  • Класс инкапсулирует сырой FILE* и реализует RAII: ресурс открывается в конструкторе и закрывается в деструкторе.
  • Проверка if (!handle_) throw ... переводит ошибку открытия в исключение и не оставляет объект в частично валидном состоянии.
  • Деструктор закрывает файл только если дескриптор валиден, что исключает вызов fclose по nullptr.
  • Копирование запрещено через = delete, чтобы два объекта не пытались владеть одним и тем же дескриптором.
  • Перемещающие операции передают владение и обнуляют источник, предотвращая двойное освобождение ресурса.

Правило пяти и правило нуля​

Если класс требует пользовательского определения одного из следующих специальных функций-членов, вероятно потребуется определить все пять:

  • Деструктор
  • Конструктор копирования
  • Оператор присваивания копированием
  • Конструктор перемещения
  • Оператор присваивания перемещением

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

// Правило нуля в действии
class ConnectionManager
{
public:
// Нет необходимости определять специальные функции-члены
// std::vector и std::unique_ptr уже реализуют их корректно

private:
std::vector<std::unique_ptr<Connection>> connections_;
std::mutex connections_mutex_;
};

Инкапсуляция и сокрытие реализации​

Данные класса объявляются как private. Доступ к состоянию предоставляется через методы с чётко определённой семантикой. Публичные поля допустимы только в простых структурах данных без инвариантов.

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

Код ITЗагрузка примера кода…


Интерфейсы и абстрактные базовые классы​

Для определения контрактов используются абстрактные базовые классы с чисто виртуальными функциями. Интерфейсы именуются с суффиксом Interface или префиксом I в зависимости от принятого в проекте стиля.

Код ITЗагрузка примера кода…

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


Работа с памятью​

Умные указатели​

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

  • std::unique_ptr для единственного владения ресурсом
  • std::shared_ptr для совместного владения с подсчётом ссылок
  • std::weak_ptr для разрыва циклических зависимостей при использовании shared_ptr

Код ITЗагрузка примера кода…

Функции, передающие владение ресурсом, принимают unique_ptr по значению или через std::move. Функции, требующие наблюдения за объектом без владения, принимают сырые указатели или ссылки.

// Передача владения
void set_handler(std::unique_ptr<RequestHandler> handler);

// Наблюдение без владения
void process_request(const Request& request, ResponseHandler* handler);

Избегание утечек памяти​

Каждое выделение памяти через new должно иметь соответствующее освобождение через delete. Использование умных указателей автоматизирует этот процесс и предотвращает утечки при возникновении исключений.

Проверка на утечки памяти выполняется с помощью инструментов статического анализа (Clang Static Analyzer, PVS-Studio) и динамических анализаторов (AddressSanitizer, Valgrind) на этапе тестирования.


Перемещение и копирование​

Для тяжеловесных объектов реализуется семантика перемещения через конструктор перемещения и оператор присваивания перемещением. Это позволяет избежать излишнего копирования данных при передаче объектов по значению.

Код ITЗагрузка примера кода…

Стандартная библиотека предоставляет утилиты std::move и std::forward для корректной передачи объектов с сохранением их категории (lvalue/rvalue).


Обработка ошибок​

Исключения как основной механизм​

Исключения используются для сигнализации об ошибках, нарушающих нормальный поток выполнения программы. Код генерирует исключения стандартных типов (std::runtime_error, std::invalid_argument, std::logic_error) или их наследников с осмысленными сообщениями об ошибке.

void validate_user_input(const std::string& input)
{
if (input.empty()) {
throw std::invalid_argument("Input cannot be empty");
}
if (input.length() > kMaxInputLength) {
throw std::invalid_argument(
"Input exceeds maximum length of " + std::to_string(kMaxInputLength)
);
}
}

Исключения перехватываются только в тех местах, где возможно осмысленное восстановление после ошибки или логирование контекста. Перехват общего типа catch (...) допускается только на самых верхних уровнях приложения для предотвращения аварийного завершения.


Проверка входных параметров​

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

  • Проверку на nullptr для указателей
  • Проверку диапазонов значений
  • Проверку форматов строк и данных
  • Проверку состояния объекта (инварианты)

Код ITЗагрузка примера кода…

Для внутренних (приватных) методов проверки могут опускаться, если корректность параметров гарантируется вызывающим кодом.


Логирование ошибок​

При перехвате исключений на уровне, где выполняется логирование, записывается полная информация об ошибке включая тип исключения, сообщение и стек вызовов. Для этого используется метод what() у объекта исключения и дополнительные средства получения стека вызовов (зависят от платформы).

try
{
processor.process(data);
}
catch (const std::exception& ex)
{
logger.error("Processing failed: {}", ex.what());
// Дополнительно может записываться стек вызовов
throw; // Проброс исключения выше после логирования
}

Логирование выполняется через единый интерфейс логгера с поддержкой уровней важности (debug, info, warning, error, critical) и контекстной информации (имя модуля, идентификатор операции).


Требования к комментариям и документации​

Комментарии к интерфейсам​

Все публичные классы, функции и методы сопровождаются комментариями в формате Doxygen. Комментарии описывают назначение элемента, семантику параметров, возвращаемых значений и возможные исключения.

/**
* Establishes a TCP connection to the specified endpoint.
*
* @param endpoint Remote address and port to connect to
* @param timeout Maximum time to wait for connection establishment
* @return true if connection was successfully established, false otherwise
* @throws std::system_error if low-level socket operation fails
* @throws std::invalid_argument if endpoint address is malformed
*/
bool connect(const NetworkEndpoint& endpoint, std::chrono::milliseconds timeout);

Комментарии размещаются непосредственно перед объявлением элемента. Для коротких пояснений допускаются однострочные комментарии в конце строки.


Внутренние комментарии​

Комментарии внутри методов объясняют нетривиальную логику, алгоритмические решения или обходные пути для работы с ограничениями внешних систем. Комментарии не дублируют очевидное поведение кода.

// Apply exponential backoff: wait time doubles after each failure
// up to maximum of 30 seconds
std::chrono::milliseconds current_delay = initial_delay;
for (int attempt = 0; attempt < max_attempts; ++attempt) {
if (try_operation()) {
return true;
}
std::this_thread::sleep_for(current_delay);
current_delay = std::min(current_delay * 2, max_delay);
}

Комментарии начинаются с заглавной буквы и завершаются точкой. Между // и текстом комментария размещается один пробел.


Избегание устаревших комментариев​

Закомментированный код удаляется из репозитория. История изменений сохраняется системой контроля версий (Git). Комментарии с пометками TODO, FIXME, HACK допускаются временно при разработке, но должны удаляться перед слиянием в основную ветку.


Тестирование​

Модульные тесты​

Каждый нетривиальный класс и функция покрываются модульными тестами. Тесты проверяют:

  • Корректное поведение при валидных входных данных
  • Обработку граничных условий
  • Реакцию на ошибочные входные данные (исключения, коды возврата)
  • Соблюдение инвариантов объекта после операций

Тесты размещаются в каталоге tests/ с сохранением структуры исходного кода. Для написания тестов используется фреймворк Google Test или Catch2.

TEST(BankAccountTest, DepositIncreasesBalance)
{
BankAccount account(100.0);
account.deposit(50.0);
EXPECT_DOUBLE_EQ(account.get_balance(), 150.0);
}

TEST(BankAccountTest, WithdrawThrowsOnInsufficientFunds)
{
BankAccount account(100.0);
EXPECT_THROW(account.withdraw(150.0), std::runtime_error);
}

Тестирование взаимодействия с внешними системами​

Для компонентов, взаимодействующих с внешними системами (базы данных, сетевые сервисы), применяется подмена зависимостей через интерфейсы. Это позволяет тестировать логику без реального подключения к внешним ресурсам.

Код ITЗагрузка примера кода…


Инструменты и анализаторы кода​

Статический анализ​

Проект настраивается на использование статических анализаторов:

  • Clang-Tidy для проверки стиля кода и обнаружения потенциальных ошибок
  • Cppcheck для поиска утечек памяти, неинициализированных переменных и других проблем
  • IWYU (Include What You Use) для оптимизации зависимостей заголовочных файлов

Конфигурация анализаторов сохраняется в корне проекта в файлах .clang-tidy, .clang-format, compile_commands.json.


Форматирование кода​

Для автоматического форматирования используется clang-format с конфигурацией, соответствующей принятым в проекте правилам. Форматирование применяется перед каждым коммитом через предварительные хуки Git (pre-commit hooks).

Пример конфигурации .clang-format:

BasedOnStyle: LLVM
IndentWidth: 4
UseTab: Never
BreakBeforeBraces: Allman
AllowShortFunctionsOnASingleLine: None
PointerAlignment: Left
SortIncludes: true

Непрерывная интеграция​

Конвейер непрерывной интеграции включает этапы:

  1. Сборка проекта с разными компиляторами (GCC, Clang, MSVC)
  2. Запуск статических анализаторов
  3. Выполнение модульных тестов
  4. Проверка покрытия кода тестами
  5. Сборка документации

Любой этап, завершившийся с ошибкой, блокирует слияние изменений в основную ветку разработки.