Всем привет, меня зовут Илья Шишков, я пишу на С++ с 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, можно почитать в сообществе команды, там же иногда появляются наши вакансии, присоединяйтесь!