Рекомендации по разработке на C++
Рекомендации по разработке на C++
Качественный код на C++ требует особого внимания к управлению ресурсами, работе с памятью и обеспечению безопасности. Язык предоставляет разработчику низкоуровневый контроль, но вместе с этим возлагает ответственность за корректное освобождение ресурсов, предотвращение утечек и защиту от неопределённого поведения. Следование рекомендациям помогает использовать мощь языка без излишнего риска.
Требования по именованию
Общие принципы именования
Имена сущностей в коде должны точно отражать их назначение и семантику. Хорошее имя позволяет понять поведение элемента без изучения его реализации. Имена формируются на английском языке, без транслитерации русских слов и без использования сокращений, кроме общепринятых в отрасли (например, id, url, http).
Каждый элемент языка использует соответствующую нотацию:
| Элемент языка | Нотация | Пример |
|---|---|---|
| Класс, структура, объединение | PascalCase | NetworkConnection |
| Шаблонный параметр | PascalCase | TContainer |
| Перечисление (тип) | PascalCase | ConnectionStatus |
| Перечисление (значение) | PascalCase | Connected |
| Пространство имен | snake_case | network_utils |
| Глобальная переменная | g_PascalCase | g_ApplicationState |
| Статическая переменная класса | s_PascalCase | s_InstanceCount |
| Член класса (поле) | snake_case | connection_timeout_ |
| Локальная переменная | snake_case | buffer_size |
| Константа (глобальная/класса) | kPascalCase | kMaxBufferSize |
| Макрос | SCREAMING_SNAKE_CASE | MAX_RETRIES |
| Функция, метод | snake_case | establish_connection |
| Функция-член (метод) | snake_case | get_status |
| Параметр функции | snake_case | timeout_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 группируются в следующем порядке с пустой строкой между группами:
- Соответствующий заголовочный файл компонента (для
.cppфайлов) - Заголовки стандартной библиотеки C++
- Заголовки стандартной библиотеки C
- Заголовки внешних библиотек (Boost, Qt и другие)
- Заголовки текущего проекта
Внутри каждой группы заголовки сортируются по алфавиту:
Код 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
Непрерывная интеграция
Конвейер непрерывной интеграции включает этапы:
- Сборка проекта с разными компиляторами (GCC, Clang, MSVC)
- Запуск статических анализаторов
- Выполнение модульных тестов
- Проверка покрытия кода тестами
- Сборка документации
Любой этап, завершившийся с ошибкой, блокирует слияние изменений в основную ветку разработки.