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

5.06. Рекомендации по разработке на 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; // Бессмысленное имя

Структуры (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);

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

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

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

Заголовочные файлы используют расширение .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);

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

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

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

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

  • Открывающая скобка размещается на новой строке на уровне управляющей конструкции
  • Закрывающая скобка размещается на отдельной строке с тем же отступом, что и управляющая конструкция
  • Однострочные блоки всё равно оформляются фигурными скобками
// Корректный стиль
if (condition)
{
execute_action();
}
else
{
handle_failure();
}

class NetworkService
{
public:
void start();
void stop();
};

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

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

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

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

auto result = database
.query("SELECT * FROM users")
.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
);

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

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

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

void load_data(const std::string& path);
void process();
void save_results(const std::string& output_path);

private:
std::vector<DataRecord> records_;
size_t processed_count_;
std::mutex processing_mutex_;
};

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

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

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

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

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

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

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

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

#pragma once

#include <string>
#include <vector>

namespace network {

class Connection
{
public:
explicit Connection(const std::string& address);
~Connection();

bool connect();
void disconnect();
bool is_connected() const;

private:
std::string address_;
void* handle_; // Системный дескриптор соединения
};

} // namespace network

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

#include "network_connection.hpp"

#include <stdexcept>
#include <system_error>

#include "internal/socket_utils.hpp"

namespace network {

Connection::Connection(const std::string& address)
: address_(address), handle_(nullptr)
{
// Инициализация
}

// Реализация методов...

} // namespace network

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

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

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

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

#include "network_connection.hpp"

#include <chrono>
#include <memory>
#include <string>
#include <vector>

#include <arpa/inet.h>
#include <unistd.h>

#include <boost/asio.hpp>

#include "internal/socket_utils.hpp"
#include "logging/logger.hpp"

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

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

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

// Нарушение принципа
class UserAndDocumentManager
{
public:
void create_user();
void delete_user();
void save_document();
void load_document();
};

// Корректная декомпозиция
class UserManager
{
public:
void create(const User& user);
void remove(UserId id);
};

class DocumentStorage
{
public:
void save(const Document& doc);
Document load(DocumentId id);
};

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

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

class FileHandle 
{
public:
explicit FileHandle(const char* path, const char* mode)
: handle_(std::fopen(path, mode))
{
if (!handle_) {
throw std::runtime_error("Failed to open file");
}
}

~FileHandle()
{
if (handle_) {
std::fclose(handle_);
}
}

// Запрет копирования
FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;

// Разрешение перемещения
FileHandle(FileHandle&& other) noexcept
: handle_(other.handle_)
{
other.handle_ = nullptr;
}

FileHandle& operator=(FileHandle&& other) noexcept
{
if (this != &other) {
if (handle_) std::fclose(handle_);
handle_ = other.handle_;
other.handle_ = nullptr;
}
return *this;
}

std::FILE* get() const { return handle_; }

private:
std::FILE* handle_;
};

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

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

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

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

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

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

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

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

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

class BankAccount 
{
public:
explicit BankAccount(double initial_balance)
: balance_(initial_balance)
{
if (initial_balance < 0.0) {
throw std::invalid_argument("Initial balance cannot be negative");
}
}

void deposit(double amount)
{
if (amount <= 0.0) {
throw std::invalid_argument("Deposit amount must be positive");
}
balance_ += amount;
}

void withdraw(double amount)
{
if (amount <= 0.0) {
throw std::invalid_argument("Withdrawal amount must be positive");
}
if (amount > balance_) {
throw std::runtime_error("Insufficient funds");
}
balance_ -= amount;
}

double get_balance() const { return balance_; }

private:
double balance_;
};

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

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

class StorageInterface 
{
public:
virtual ~StorageInterface() = default;

virtual void store(const std::string& key, const std::string& value) = 0;
virtual std::string load(const std::string& key) = 0;
virtual bool exists(const std::string& key) = 0;
virtual void remove(const std::string& key) = 0;
};

class InMemoryStorage : public StorageInterface
{
public:
void store(const std::string& key, const std::string& value) override;
std::string load(const std::string& key) override;
bool exists(const std::string& key) override;
void remove(const std::string& key) override;

private:
std::unordered_map<std::string, std::string> data_;
};

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

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

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

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

  • std::unique_ptr для единственного владения ресурсом
  • std::shared_ptr для совместного владения с подсчётом ссылок
  • std::weak_ptr для разрыва циклических зависимостей при использовании shared_ptr
// Единственное владение
auto connection = std::make_unique<NetworkConnection>(endpoint);

// Совместное владение
std::shared_ptr<Document> shared_doc = std::make_shared<Document>(content);

// Избегание циклических ссылок
class Node
{
public:
void set_parent(std::weak_ptr<Node> parent) { parent_ = parent; }
std::shared_ptr<Node> get_parent() { return parent_.lock(); }

private:
std::weak_ptr<Node> parent_;
std::vector<std::shared_ptr<Node>> children_;
};

Функции, передающие владение ресурсом, принимают 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) на этапе тестирования.

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

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

class LargeDataBuffer 
{
public:
// Конструктор перемещения
LargeDataBuffer(LargeDataBuffer&& other) noexcept
: data_(other.data_), size_(other.size_)
{
other.data_ = nullptr;
other.size_ = 0;
}

// Оператор присваивания перемещением
LargeDataBuffer& operator=(LargeDataBuffer&& other) noexcept
{
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}

private:
char* data_;
size_t size_;
};

Стандартная библиотека предоставляет утилиты 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 для указателей
  • Проверку диапазонов значений
  • Проверку форматов строк и данных
  • Проверку состояния объекта (инварианты)
class DataProcessor 
{
public:
void process_chunk(const char* data, size_t size)
{
if (data == nullptr) {
throw std::invalid_argument("data pointer cannot be null");
}
if (size == 0) {
throw std::invalid_argument("size must be greater than zero");
}
if (size > kMaxChunkSize) {
throw std::invalid_argument("chunk size exceeds maximum allowed");
}

// Основная логика обработки
}
};

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

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

При перехвате исключений на уровне, где выполняется логирование, записывается полная информация об ошибке включая тип исключения, сообщение и стек вызовов. Для этого используется метод 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);
}

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

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

class MockStorage : public StorageInterface 
{
public:
MOCK_METHOD(void, store, (const std::string&, const std::string&), (override));
MOCK_METHOD(std::string, load, (const std::string&), (override));
MOCK_METHOD(bool, exists, (const std::string&), (override));
MOCK_METHOD(void, remove, (const std::string&), (override));
};

TEST(DocumentServiceTest, SavesDocumentToStorage)
{
auto mock_storage = std::make_unique<MockStorage>();
EXPECT_CALL(*mock_storage, store("doc1", "content"))
.Times(1);

DocumentService service(std::move(mock_storage));
service.save_document("doc1", "content");
}

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

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

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

  • 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. Сборка документации

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