
Всем привет, меня зовут Илья Шишков, я пишу на С++ с 2006 года. Много лет я был разработчиком в больших C++-кодовых базах, но в 2024 году жизнь меня занесла в PostgreSQL. А именно в RnD-разработку СУБД Pangolin, это реляционная СУБД от СберТеха, PostgreSQL с нашими доработками под требования к усиленной безопасности, производительности и так далее. PostgreSQL, как известно, написан на чистом С. Так я поработал с этим языком несколько месяцев и… стал внедрять C++.
В этой статье я расскажу, зачем так сделал и почему это оказалось очень удобно. Например, некоторые технологии из C++ есть в PostgreSQL, при том, что их нет в C. Разберу практические примеры, как мне кажется, хороших абстракций на C++, которые упрощают программирование на C. И немного времени уделю разбору цены, которую нам приходится платить, когда мы используем в коде различные абстракции.
Расскажу о своём опыте чуть подробнее. Как уже сказал, я программирую на С++ почти 20 лет. 11 лет был разработчиком в Яндексе. Немного поработал в HFT. Ещё я разработал курсы «Пояса по С++», может быть, кто-нибудь из читателей статьи их проходил, буду рад поздороваться в комментариях.
Чуть больше года назад я пришёл в СберТех разрабатывать СУБД Pangolin, вот здесь и сошлись C++ и PostgreSQL.
Что представляет из себя PostgreSQL? История Postgres началась в 1986 году. С 1996 года его переименовали в PostgreSQL, потому что он стал поддерживать SQL, и началась его открытая разработка. То есть это расширяемая СУБД, в которую можно добавлять разную функциональность.
PostgreSQL поддерживается на всех популярных операционных системах, и из этого вытекают некоторые последствия. К примеру, основной язык программирования PostgreSQL — это С99, то есть это даже не С11, а язык C стандарта 1999 года. Ещё там есть Perl — на нём написаны тесты. Кстати, по-моему, очень даже хорошо написаны.
Разберёмся поподробнее, что можно отнести к достоинствам кодовой базы PostgreSQL.
Достоинства кодовой базы PostgreSQL
Вкратце:
Легко отлаживать по шагам.
Легко находить код через индекс в IDE.
Быстрая компиляция: наш Pangolin компилируется с нуля за 7-8 минут. Редко, когда большая плюсовая кодовая база компилируется так быстро. Это меня сильно порадовало, когда я стал работать с PostgreSQL.
Покажу на примерах.
char* filter; if (port->hba->ldapsearchfilter) filter = FormatSearchFilter(port->hba->ldapsearchfilter, port->user_name); else if (port->hba->ldapsearchattribute) filter = psprintf("(%s=%s)", port->hba->ldapsearchattribute, port->user_name); else filter = psprintf("(uid=%s)", port->user_name); LDAPMessage* search_message = NULL; r = ldap_search_s(ldap, port->hba->ldapbasedn, port->hba->ldapscope, filter, attributes, 0, &search_message); if (r != LDAP_SUCCESS) { ereport(LOG, (errmsg("could not search LDAP for filter \"%s\" on " "server \"%s\": %s", filter, server_name, ldap_err2string(r)), errdetail_for_ldap(ldap))); if (search_message != NULL) ldap_msgfree(search_message); ldap_unbind(ldap); pfree(passwd); pfree(filter); return STATUS_ERROR; }
У нас есть переменная filter типа char* и три способа её инициализировать. Если немного повариться в кодовой базе, то вы увидите, что функция psprintf динамически выделяет память и возвращает на неё указатель. Ещё у нас есть LDAPMessage *search_message. Когда мы в нашей функции хотим вернуть ошибку (return STATUS_ERROR), то важно не забыть почистить ресурсы. Если у нас всё хорошо, то мы делаем ещё какое-то действие. Итого: как я говорил, мы всегда видим всё, что делает наша функция, в том числе всё, что она выводит в журнал:
ereport(LOG, (errmsg("LDAP user \"%s\" is not unique", port->user_name), errdetail_plural( "LDAP search for filter \"%s\" on " "server \"%s\" returned %d entry.", "LDAP search for filter \"%s\" on " "server \"%s\" returned %d entries.", count, filter, server_name, count)));
Всё очень подробно и понятно. Но есть и обратная сторона.
Недостатки кодовой базы PostgreSQL
Когда видишь всё, что делает функция, то понять её суть бывает сложно. Каждый раз приходится пробираться через технические подробности: вот тут память выделили, тут освободили. А разобраться, какая логика заложена в очередную функцию на 800 строк, — тяжело.
С точки зрения когнитивной нагрузки расстраивает то, что простые вещи пишутся таким большим количеством кода. Мозг устаёт, а значит, легко допустить ошибку.
В Postgres есть своя хеш-таблица, но если в C++ мы просто пишем unordered_map и пользуемся, то здесь нужно написать 17–20 строк кода, чтобы начать её использовать. И так каждый раз. А ещё из-за того, что это C99 и Postgres должен компилироваться, как у нас говорят, на любом утюге, все переменные приходится объявлять в начале блока, прямо как в Pascal. Сначала объявил вверху, потом где-то внизу используешь.
Что есть в PostgreSQL, но нет в С
Во-первых, в PostgreSQL есть исключения. Не буду в них углубляться здесь, но вкратце: там есть прямые аналоги try, catch и finally, они реализованы на макросах, и это очень похоже на исключения.
Во-вторых, автоматическое управление памятью. Когда мы выделяем ресурс, нужно не забыть его очистить в каждом месте. В PostgreSQL придумали MemoryContext — инструмент, который упрощает работу с памятью.
Рассмотрим пример кода:
MemoryContext function_ctx = AllocSetContextCreate( CurrentMemoryContext, "name", ALLOCSET_DEFAULT_SIZES); MemoryContext old_ctx = MemoryContextSwitchTo(function_ctx); char* buf = palloc0(101); List* items; items = lappend(items, buf); MemoryContextSwitchTo(old_ctx); MemoryContextDelete(function_ctx);
Допустим, есть функция, в которой я выделяю память в разных местах для разных целей. В конце функции я хочу освободить всю эту память разом, не беспокоясь, что где-то забыл вызвать free. Для этого я создаю MemoryContext (переменная function_ctx) и с помощью вызова MemoryContextSwitchTo() указываю, что все следующие выделения памяти должны происходить в нём.
Дальше я, например, выделяю 101 символ для char*. Обычно в C для этого используют malloc, а в Postgres — palloc. Эта функция выделяет память в текущем MemoryContext. Другой пример — List в Postgres, аналог std::vector в C++. Это непрерывный кусок памяти, который динамически растёт. При добавлении элемента тоже происходит аллокация в текущем контексте.
Чтобы всё очистить, я переключаю контекст обратно, вызывая MemoryContextSwitchTo() с сохранённым старым контекстом, а затем удаляю контекст функции через MemoryContextDelete(). Это удобно, потому что память не утекает.
Поначалу кажется, что всё просто, но при работе с контекстами есть нюансы. Первый: код переключения контекста получается громоздким: нужно объявить переменную вверху блока, не забыть переключиться обратно. Второй нюанс: контекст может освободить память слишком рано, поэтому иногда проще явно вызвать очистку, чем полагаться на автоматику.
Как и зачем я внедрил С++
Вот мы и подошли к самому интересному. Сначала отвечу на вопрос «Зачем?».
В работе с кодовой базой PostgreSQL мне крайне не хватало RAII — это главная возможность C++, которая отличает его от многих языков и позволяет единообразно освобождать любые ресурсы. В PostgreSQL ресурсов много: не только память, но и различные блокировки, непрозрачные структуры, которые нужно освобождать разными функциями.
Хотелось пользоваться стандартной библиотекой. Иногда достаточно написать unordered_map и работать с ним, а в Postgres же для создания хеш-таблицы нужно 17 строк кода. Также мне не хватало удобных абстракций.
Теперь перейдём к конкретным примерам. Приведу три ситуации, в которых применение C++ позволило сделать код проще, удобоваримее и безопаснее. Сразу скажу, зубодробительно сложных шаблонов не будет. Когда я показывал первые два примера в своём канале, мне писали: «Илья, ну это очень просто». Действительно, просто, зато решает много типовых болей разработчиков.
Итак, первый пример. Возьмём уже знакомый код с переменной filter, которая инициализируется тремя способами, и search_message, который инициализируется в функции ldap_search_s и затем очищается. Функция содержит семь вызовов return, и в каждом месте нужно не забыть очистку. Я несколько раз забывал это сделать.
char* filter; if (port->hba->ldapsearchfilter) filter = FormatSearchFilter(port->hba->ldapsearchfilter, port->user_name); else if (port->hba->ldapsearchattribute) filter = psprintf("(%s=%s)", port->hba->ldapsearchattribute, port->user_name); else filter = psprintf("(uid=%s)", port->user_name); LDAPMessage* search_message = NULL; r = ldap_search_s(ldap, port->hba->ldapbasedn, port->hba->ldapscope, filter, attributes, 0, &search_message); if (r != LDAP_SUCCESS) { ereport(LOG, (errmsg("could not search LDAP for filter \"%s\" on " "server \"%s\": %s", filter, server_name, ldap_err2string(r)), errdetail_for_ldap(ldap))); if (search_message != NULL) ldap_msgfree(search_message); ldap_unbind(ldap); pfree(passwd); pfree(filter); return STATUS_ERROR; }
Теперь напишем достаточно простой шаблон guard_by_deleter (если не считать скобки, всего пять строк). Он принимает указатель и Deleter, возвращая unique_ptr с этим указателем и Deleter.
template <typename T, typename Deleter> std::unique_ptr<T, Deleter> guard_by_deleter( T* p, Deleter d, std::enable_if_t<std::is_invocable_v<Deleter, T*>, void*> = nullptr) { return {p, d}; }
Применим к нашему коду guard_by_deleter. Ничего сложного тут нет, но как меняется код!
auto build_filter = [&] { if (port->hba->ldapsearchfilter) return FormatSearchFilter(port->hba->ldapsearchfilter, port->user_name); else if (port->hba->ldapsearchattribute) return psprintf("(%s=%s)", port->hba->ldapsearchattribute, port->user_name); else return psprintf("(uid=%s)", port->user_name); }; std::unique_ptr filter = guard_by_deleter(build_filter(), pfree); LDAPMessage* search_message = NULL; r = ldap_search_s(ldap, port->hba->ldapbasedn, port->hba->ldapscope, filter, attributes, 0, &search_message); std::unique_ptr search_message_guard = guard_by_deleter(search_message, ldap_msgfree); if (r != LDAP_SUCCESS) { return STATUS_ERROR; } count = ldap_count_entries(ldap, search_message); if (count != 1) { return STATUS_ERROR; } ldap_memfree(dn);
Переменная filter теперь инициализируется в одном месте, а код, который её создаёт, обёрнут в лямбду и передан в unique_ptr с Deleter pfree. Теперь можно не беспокоиться об очистке — все вызовы pfree можно удалить. То же самое с search_message: оборачиваем в unique_ptr и передаём Deleter ldap_msgfree. Проблема API языка С в том, что для каждого ресурса своя функция очистки, которую нужно помнить. unique_ptr сильно сокращает код и гарантирует очистку.
Когда я показалэто нашим разработчикам, они попросили добавить такую возможность в общую библиотеку. То есть люди, которые годами пишут только на С и привыкли к этим трудностям, оценили то, как плюсовый код упростил разработку.
Второй пример: работа с MemoryContext.
MemoryContext function_ctx = AllocSetContextCreate( CurrentMemoryContext, "name", ALLOCSET_DEFAULT_SIZES); MemoryContext old_ctx = MemoryContextSwitchTo(function_ctx); char* buf = palloc0(101); List* items; items = lappend(items, buf); // Хочу сохранить после функции MemoryContextSwitchTo(old_ctx); MemoryContextDelete(function_ctx);
Я хочу, чтобы список items прожил дольше, чем функция. Сейчас память выделяется в контексте функции и будет очищена, а мне нужно, чтобы она не очищалась. Для этого выделения памяти в этом списке нужно выполнять в другом MemoryContext. Приходится добавлять переменную, переключать контекст на TopMemoryContext (глобальный контекст, живущий весь процесс), выделять память и не забывать переключиться обратно. Когда таких мест много, подход становится громоздким, и рано или поздно забудешь переключиться назад.
MemoryContext function_ctx = AllocSetContextCreate( CurrentMemoryContext, "name", ALLOCSET_DEFAULT_SIZES); MemoryContext old_ctx = MemoryContextSwitchTo(function_ctx); char* buf = palloc0(101); List* items; { MemoryContext tmp = MemoryContextSwitchTo(TopMemoryContext); items = lappend(items, buf); // Хочу сохранить после функции MemoryContextSwitchTo(tmp); } MemoryContextSwitchTo(old_ctx); MemoryContextDelete(function_ctx);
На C++ это решается довольно просто. Рядом с guard_by_deleter пишем макрос UNDER_MEMORY_CONTEXT. Он устроен просто: в unique_ptr сохраняется результат MemoryContextSwitchTo (старый контекст), а текущий выставляется переданным аргументом. В качестве Deleter передаётся MemoryContextSwitchTo, чтобы при разрешении unique_ptr восстанавливался старый контекст. И всё это мы оформляем в виде if, чтобы у нас создавался блок кода, в рамках которого действует тот MemoryContext, который мы передали.
template <typename T, typename Deleter> std::unique_ptr<T, Deleter> guard_by_deleter(T* p, Deleter d); #define UNDER_MEMORY_CONTEXT(ctx) \ if (auto g = guard_by_deleter(MemoryContextSwitchTo(ctx), \ MemoryContextSwitchTo); \ true)
Соответственно, если я теперь хочу в TopMemoryContext что-то сделать, то просто пишу UNDER_MEMORY_CONTEXT, имя контекста, и в фигурных скобках перечисляю действия, которыми это надо сделать. Читается лучше, писать нужно меньше, невозможно забыть переключить обратно. Получилось гораздо удобнее и безопаснее:
MemoryContext function_ctx = AllocSetContextCreate( CurrentMemoryContext, "name", ALLOCSET_DEFAULT_SIZES); MemoryContext old_ctx = MemoryContextSwitchTo(function_ctx); char* buf = palloc0(101); List* items; UNDER_MEMORY_CONTEXT(TopMemoryContext) { items = lappend(items, buf); } MemoryContextSwitchTo(old_ctx); MemoryContextDelete(function_ctx);
И третий пример. Мне надо было написать такой код:

Есть функция LdapInteroperate, которая устанавливает сетевое соединение, читает и пишет данные и разрывает соединение. Но логика должна отличаться в зависимости от пути вызова: если мы пришли из ConnectOnce, то соединение нужно создать и разорвать; если из LoopOverSharedConnection — соединение нужно переиспользовать.
Как это можно сделать на C? Через глобальные переменные: should_reuse_connection и ldap_connection. Они никак не связаны на уровне языка, мы можем случайно изменить одну, не затронув другую. Функции ConnectOnce и LoopOverSharedConnection манипулируют этими переменными, задавая поведение для LdapInteroperate.
bool should_reuse_connection = false; LDAP* ldap_connection = NULL; void LdapInteroperate() { if (!ldap_connection) { if (!connect_to_ldap(&ldap_connection)) { /* Handle error*/ return; } } else if (should_reuse_connection) { puts("Using cached connection."); } else { puts("ERROR: LDAP connection leak"); } // Perform LDAP operations if (!should_reuse_connection) { ldap_unbind(ldap_connection); ldap_connection = NULL; } } void ConnectOnce() { should_reuse_connection = false; LdapInteroperate(); } void LoopOverSharedConnection() { should_reuse_connection = true; for (int i = 0; i < 5; ++i) { LdapInteroperate(); } ldap_unbind(ldap_connection); }
Код LdapInteroperate усложняется: нужно проверять, есть ли соединение, можно ли его переиспользовать, обрабатывать нештатные ситуации (например, соединение есть, но переиспользовать нельзя). В конце функции — ещё одна проверка: если соединение не нужно переиспользовать, то разорвать его. Получается, что логика разрыва соединения размазана по нескольким местам, и чтобы понять, как работает код, нужно держать в голове все эти точки.
Как сделать то же самое на C++? Можно воспользоваться паттерном «Фабрика» (наверняка, кто-то читал «Паттерны программирования» Банды Четырёх). Делаем фабрику, которая отвечает за создание соединений, инкапсулируя логику внутри. У нас будет две фабрики: одна всегда создаёт новые подключения, другая переиспользует существующие.
Давайте посмотрим, как выглядит код:
void LdapInteroperate(LdapConnectionFactory& factory) { auto ldap = factory.GetConnection(); if (!ldap) { /* Handle error */ return; } // Perform LDAP operations }
Функция LdapInteroperate теперь принимает фабрику и устроена просто: запрашивает у неё подключение, и если оно получено, то выполняет бизнес-логику. Вся работа с соединением скрыта внутри фабрик. И здесь нет манипуляций с подключением, они не забивают нам голову.
Интересно, что должна возвращать factory.GetConnection()? В одном сценарии, когда функция заканчивается, мы должны разорвать соединение. Во втором — когда функция заканчивается, мы не должны разрывать соединение. Какой тип удобно использовать для переменной ldap, чтобы так работало? Сюда идеально подходит std::shared_ptr. Ниже я объясню, почему.
Как я сказал, у нас будет две фабрики. Первая называется AlwaysCreateNewConnection — само название говорит за себя, устроена очень просто: если смогли подключиться, то создаём std::shared_ptr. У каждого ресурса свой deleter, поэтому здесь мы вызываем ldap_unbind и возвращаем указатель. За счёт того, что здесь копии нет, соединение будет разорвано.
struct LdapConnectionFactory { virtual ~LdapConnectionFactory() = default; virtual std::shared_ptr<LDAP> GetConnection() = 0; }; struct AlwaysCreateNewConnection : public LdapConnectionFactory { std::shared_ptr<LDAP> GetConnection() override { LDAP* ldap = nullptr; if (connect_to_ldap(&ldap)) { return std::shared_ptr<LDAP>(ldap, ldap_unbind); } return nullptr; } };
Вторая фабрика в качестве конструктора принимает другую фабрику, как бы оборачивает её. Устроена тоже довольно просто: если у меня не закешировано подключение, я создаю его; если закешировано — говорю: «Всё, я переиспользовал подключение» и возвращаю std::shared_ptr:
class ConnectionCache : public LdapConnectionFactory { public: ConnectionCache(LdapConnectionFactory& factory) : factory_(factory) {} std::shared_ptr<LDAP> GetConnection() override { if (!cached_connection_) { cached_connection_ = factory_.GetConnection(); } else { std::cout << "Using cached connection." << std::endl; } return cached_connection_; } private: LdapConnectionFactory& factory_; std::shared_ptr<LDAP> cached_connection_; };
И наши функции ConnectOnce и LoopOverSharedConnection начинают выглядеть так:
void ConnectOnce() { AlwaysCreateNewConnection factory; LdapInteroperate(factory); } void LoopOverSharedConnection() { AlwaysCreateNewConnection factory; ConnectionCache cache(factory); for (int i = 0; i < 10; ++i) { LdapInteroperate(cache); } }
Первая создаёт фабрику, передаёт её — и всё работает. Вторая создаёт фабрику, надевает на неё сверху кеш и вызывает LdapInteroperate.
Это решение, на мой взгляд, обладает важным преимуществом: у меня есть только одно место, где разрывается подключение. Ну ладно, формально, два: либо в деструкторе ConnectionCache, либо в самой фабрике. Но суть в том, что это происходит в фабриках. И у меня одна часть кода управляет подключением, а другая часть с этим подключением работает. Получается бо̀льшая модульность.
Я не стану говорить, что это суперкрутое решение и всегда надо делать так, а глобальные переменные — это ерунда. С ними кода меньше получилось, а когда кода в целом мало, можно быстро разобраться. Но когда у тебя здоровенная кодовая база, получается гораздо проще для восприятия.
Давайте подведём некоторые итоги внедрения C++. Код упростился, на мой взгляд. Если не согласны, обсудим в комментариях. Стало проще понимать его смысл за счёт внедрения вот этих простых штук.
Я это всё переписал в нужных мне частях и показал ревьюерам. Они сказали: «Да, правда, здорово! Проще понимать, что здесь происходит».
За счёт RAII код меньше подвержен ошибкам. Для меня этот пункт крайне важен. Я очень боялся, что сейчас всё перепишу, а мне скажут: «Илья, ну что такое? У нас всё быстро работало, ты со своими C++ пришёл — всё тормозит». Померили — не тормозит.
Подытожим
Мы с вами рассмотрели две крайности: кодовую базу, где всё очень конкретно и подробно описано, и плюсовую кодовую базу, которая устроена иначе. Мне кажется, что в таком небольшом шаге — внедрении C++ в Postgres — мне удалось оказаться где-то между этими крайностями. Код стал понятнее, мы абстрагировали не слишком важные детали, но при этом ещё не перешли на ту сторону, где без помощника не разберёшься.
В итоге за счёт точечного внедрения С++ получилось сделать так, чтобы классные технологии внутри PostgreSQL можно было использовать удобнее.
И в завершение хочется добавить: каждый слой абстракции в коде имеет свою стоимость. Да, C++ — язык zero-cost abstractions, но у абстракции есть ещё и когнитивная нагрузка на того, кто этот код читает. Предлагаю об этом всегда помнить и учитывать, когда вы что-то добавляете, — оценивать, насколько это действительно имеет смысл.
Благодарю за интерес к статье. Больше о том, как мы делаем СУБД Pangolin, можно почитать в сообществе команды, там же иногда появляются наши вакансии, присоединяйтесь!
