В каждом взрослом C++ проекте живёт код, написанный так, будто C++11 не выходил. malloc и free, указатели без владельца, коды возврата вместо ошибок, printf, макросы вместо констант. Ревьюер предлагает переписать всё на концепты, std::expected и std::print, и по свежим меркам такой код правда выглядит архаично. Но под ним лежат C-библиотеки, границы бинарных модулей и ограничения встраиваемых целей, которые никуда не денутся. Ниже разбор того, где C-стиль физически необходим, где он остался по инерции, чем его заменяют возможности C++20 и C++23 и как модернизировать базу, не остановив разработку.
Откуда в C++ проекте берётся код на C и почему он не уходит сам
История у каждого проекта своя, но мотивы повторяются из года в год. Первый, проект вырос из C-исходников: движок начинался как библиотека на C, потом оброс классами, а ядро осталось в старом стиле, потому что оно работает и его боятся трогать. Второй, общие исходники: ядро продукта компилируется и C-компилятором, и C++-компилятором, поэтому файлы обязаны держаться на общем подмножестве двух языков. Третий, зависимости: SQLite, OpenSSL, zlib, curl и графические API остаются библиотеками с C-интерфейсами, и обвязка вокруг них естественно пишется в том же стиле.
Четвёртый мотив, правила кодирования из начала двухтысячных. Тогда исключения и стандартная библиотека считались роскошью, которой не выдерживали компиляторы тех лет, и стайлгайды запрещали и то и другое. Правила пережили компиляторы, ради которых писались, и до сих пор кочуют из одного внутреннего документа в другой.
Пятый мотив, самый практичный, сборка без исключений. Встраиваемые цели, модули ядра и отдельные игровые движки собираются с флагами -fno-exceptions и -fno-rtti, и заметная часть идиоматичного C++ там просто недоступна. Код приспосабливается к среде, в которой живёт, и обвинять его в отсталости это примерно как упрекать зимнюю резину за шум.
В одном проекте оба мира спокойно уживаются на уровне сборки. CMake собирает C-файлы C-компилятором, а C++-файлы C++-компилятором, и цель объединяет их без лишних усложнений:
project(backend LANGUAGES C CXX)
add_library(core STATIC src/core.c)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE core)
Ядро не знает про классы, приложение знает про ядро через заголовок с extern "C", и переписывать работающий фундамент не нужно никому.
Настоящая работа C прослойки в библиотеках плагинах и встраиваемых системах
Скепсис в сторону, у C-слоя есть работы, которые никто другой не выполнит. Первая, общий знаменатель для линковки. Бинарный интерфейс C++ не стабилен между рантаймами и мажорными версиями, а C ABI стабилен десятилетиями. Плагины, драйверы и модули с динамической загрузкой общаются через extern "C", непрозрачные указатели и простые структуры, и это единственный способ собрать модуль одним компилятором, а хост другим.
Вторая, колбэки из C-библиотек. Библиотека вызывает функцию по указателю, и подпись обязана быть C-совместимой:
extern "C" void on_log(int level, const char* msg, void* user) {
static_cast<LogSink*>(user)->write(level, msg);
}
Никакого std::function в указателе на функцию, никакого захвата контекста, пользовательский указатель для этого и существует.
Третья, обработчики сигналов и прерываний. Внутри них доступны только lock-free атомики и переменные типа volatile sig_atomic_t, никаких исключений, аллокаций и блокировок. Код на чистом C здесь не стиль, а требование среды.
Четвёртая, встраиваемые цели. Ограниченная куча, собственные аллокаторы, запрет исключений из-за размера кода. Там C-стиль не легаси, а инженерный расчёт, подтверждённый метриками размера прошивки.
Чем C стиль наказывает большую кодовую базу на конкретных ошибках
Про необходимость сказано, теперь о цене. Стиль C перекладывает ответственность за корректность с компилятора на человека, и человек ошибается предсказуемым образом. Классика жанра, функция с несколькими путями выхода:
char* buf = (char*)malloc(len);
if (!buf) return -1;
if (read(fd, buf, len) < 0) {
return -2;
}
process(buf);
free(buf);
return 0;
Путь с ошибкой чтения уходит без освобождения, и утекает ровно столько, сколько вызовов в минуту переживает сервис. Сколько таких функций в зрелой базе, знает каждый, кто искал утечку в пятницу вечером.
Буферы без длины добавляют второй слой риска:
char name[64];
strncpy(name, src, sizeof(name));
log_message("%s", name);
Если исходная строка длиннее 64 символов, терминатор в name не попадает, и чтение уезжает за границу массива там, где вызывающий уже забыл про этот буфер. Правило простое: там, где остаётся char-массив, длина обязана ездить рядом с ним.
Дальше больше. Память, выделенная malloc и освобождённая delete, или наоборот, это неопределённое поведение, а не мелкая формальность. Владелец не прописан в типе: указатель из чужой фабрики надо уничтожать через special_free, и узнать это можно только из документации, если она есть. Коды возврата молча игнорируются, вызов без проверки результата не порождает ни одного предупреждения. Приведение типов в круглых скобках отключает проверку типов целиком, и опечатка в аргументе живёт до первого падения в продакшене. Каждая из этих ловушек по отдельности выглядит мелочью, а в сумме они составляют разницу между проектом, который чинится за час, и проектом, который боятся трогать.
Отдельно стоит учёт границ массивов. Функция получает указатель и размер отдельными аргументами, и ничто не мешает вызвать её с чужой парой: указатель из одного буфера, длина из другого. Сигнатура не защищает ни от одного из этих путей, вся защита остаётся на честном слове каждого колл-сайта, а колл-сайтов в зрелом проекте сотни.
Замена ручной памяти printf и кодов возврата возможностями стандартов
Замены зрели долго, и к C++23 набор закрылся почти полностью. Память. std::make_unique из C++14 и make_unique_for_overwrite из C++20 закрывают ручное управление ресурсами:
auto buf = std::make_unique_for_overwrite<char[]>(len);
Владелец прописан в типе, освобождение автоматическое, исключение на любом пути выхода не оставляет мусора. Буферы. Пары из указателя и длины заменяет std::span из C++20:
void encode(std::span<std::byte> out);
Границы видны в сигнатуре, запись за пределы ловится проверками в отладочной сборке, а не в ночном падении. Вывод. printf без проверки типов заменяют std::format из C++20 и std::print из C++23:
std::print("{} bytes written", n);
Несоответствие типов в форматной строке обнаруживается при компиляции, когда строка задана литералом, а не превращается в мусор в рантайме.
Ошибки. Коды возврата заменяет std::expected из C++23:
std::expected<Payload, Status> decode(std::span<const std::byte> raw);
auto r = decode(input);
if (!r) {
return std::unexpected(r.error());
}
consume(*r);
Проигнорировать статус уже не выйдет, значение и ошибка живут в одном типе, а цепочки вызовов читаются сверху вниз без лесенки флагов. К перечисленному стоит добавить std::string_view из C++17 против передачи char*, std::bit_cast из C++20 вместо разыменования памяти через указатель на чужой тип и std::to_underlying из C++23 для работы с enum.
Для структур с ветвлением по типу поля C-стиль десятилетиями использует union, и чтение неактивного члена там остаётся неопределённым поведением, о котором компилятор молчит. C++17 приносит std::variant с std::visit: активный вариант отслеживается сам, а обращение к пустому превращается в проверяемое исключение bad_variant_access вместо тихой порчи соседней памяти. Список замен растёт медленно, а вот классы ошибок, которые ловит компилятор вместо ночных падений, окупаются быстро.
Обёртки RAII вокруг C библиотек и правило границ между мирами
Идиоматичный C++ не отменяет C-библиотеки, он их упаковывает. Классический способ, класс-обёртка с деструктором:
class Db {
public:
explicit Db(const char* path) {
if (sqlite3_open(path, &handle_) != SQLITE_OK) {
throw std::runtime_error("db open failed");
}
}
~Db() { sqlite3_close(handle_); }
Db(const Db&) = delete;
Db& operator=(const Db&) = delete;
private:
sqlite3* handle_ = nullptr;
};
Одно место, где живёт очистка, одно место, где живёт проверка кода возврата. Исключение между получением и освобождением ресурса больше его не роняет. Для файлов из C стандартной библиотеки достаточно unique_ptr с пользовательским удалителем:
struct FileCloser {
void operator()(std::FILE* f) const { std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
Обратное правило для границ: исключения не пересекают extern "C". Экспортная функция плагина ловит всё подряд и переводит в код возврата:
extern "C" int plugin_process(const char* input) {
try {
return Process(input);
} catch (const std::exception&) {
return -1;
}
}
Схема симметрична: наружу к C-миру выходит только C-дисциплина, внутрь проекта входит только идиоматичный C++. Ни одна сторона не проникает на территорию другой дальше одного тонкого слоя.
Порядок постепенной модернизации легаси файлов без остановки разработки
Большая замена одним махом обычно заканчивается откатом и недельным лечением регрессий. Рабочий цикл выглядит так:
- обернуть каждый внешний ресурс, файл, сокет, соединение, блок памяти, в RAII-тип с деструктором и удалённым копированием;
- закрыть границы с C библиотеками тонкими обёртками, чтобы проверка ошибок жила в одном месте, а не в каждом вызове;
- заменить макросы-константы на inline constexpr и убрать явные приведения типов там, где тип выводится сам;
- перевести функции с кодами возврата на std::expected или на исключения по единым правилам проекта;
- прогнать статический анализ clang-tidy с группой проверок modernize и править только безопасные замечания из отчёта;
- покрыть модуль тестами до переписывания и менять по одному файлу за ревью, не смешивая новую логику с заменой стиля.
Из ящика статического анализа достаются готовые проверенные имена, modernize-make-unique подскажет умные указатели, modernize-use-nullptr выловит макросы NULL, группа cppcoreguidelines свяжет код с сводом правил Core Guidelines, где про владение памятью и границы массивов сказано прямым текстом. Автоматика не перепишет проект, но честно перечислит кандидатов.
Хороший временной ориентир для команды, один файл в день. За квартал модуль из семидесяти файлов переезжает целиком, каждое ревью остаётся обозримым, смотрится только перевод стиля без новой логики, и откат любого шага стоит минут, а не недель.
Где C стиль остаётся навсегда и честный вердикт по выбору подхода
Есть территории, где C-стиль не переходный этап, а постоянное состояние. Встраиваемые системы с ограниченной кучей, модули ядра, обработчики сигналов, интерфейсы плагинов, которые переживают смену компилятора и рантайма. Тут стремление к идиоматичности разбивается о физику среды, и грамотная реакция не борьба, а явная граница.
Стандарты сближаются с обеих сторон. GCC 14 уже понимает -std=c++26 в экспериментальном режиме, MSVC набирает новое через переключатель /std:c++latest, а стандарт C23, вышедший в 2024 году, добавил в язык C nullptr, constexpr и вывод типов auto. C сам перенимает находки C++, и граница между стилями размывается не потому, что кто-то проиграл спор, а потому что языки идут навстречу друг другу.
Вердикт по старому спору формулируется без компромиссов и без погромов. Идиоматика свежих стандартов оправдана там, где живёт логика приложения: короткие функции, понятные типы, автоматическое владение, ошибки, которые нельзя игнорировать. Поддержка C-слоя оправдана там, где командует среда: бинарные границы, сигналы, встраиваемые цели, чужие библиотеки. Проблемы начинаются там, где граница размазана по всему проекту и RAII соседствует с утечками в соседней строке. Ответом будет не лозунг "переписать всё", а проведённая явно линия: внутри идиоматичный C++ до последнего стандарта, на периметре строгий C-слой с extern "C" и без исключений. Тогда необходимость поддержки и стремление к идиоматичности перестают спорить, потому что каждая из них занимает своё место.