суббота, 28 июня 2025 г.

[prog.c++.dreams] В очередной раз выяснил, что в C++ using это не strong typedef :(

В С++ есть отличная штука под названием using. Позволяет вводить удобные псевдонимы для сложночитаемых имен типов или же прикрывать тонкой завесой детали реализации.

Но, к сожалению, using не может сделать совершенно новый тип. Поэтому если у вас есть что-то вроде:

using type_a = ...;
using type_b = ...;

void do_something(type_a v) {...}
void do_something(type_b v) {...}

То вы внезапно можете обнаружить, что ваш код перестал компилироваться потому, что type_a и type_b оказались синонимами для одного и того же типа.

Например, раньше было:

using small_data = std::map<some_key, some_value>;
using large_data = std::unordered_map<special_key, some_value>;

using type_a = small_data::iterator;
using type_b = large_data::iterator;

А в один прекрасный момент стало:

using special_key = some_key;

using small_data = std::map<some_key, some_value>;
using large_data = std::map<special_key, some_value>;

И все 🙁
Типы type_a и type_b оказались одинаковыми.

Недавно в очередной раз наступил на подобные грабли, но немного в другом контексте. Было что-то вроде:

namespace processing
{

class processor {...};

template<typename Data>
void
handle(const processor & how, const Data & what)
{
  // Должна быть найдена подходящая функция за счет ADL.
  apply(what, how);
}

// namespace processing

namespace data_type_a
{

struct data {...};

void apply(const data & what, const processing::processor & how) {...}

// namespace data_type_a

namespace data_type_b
{

struct data {...};

void apply(const data & what, const processing::processor & how) {...}

// namespace data_type_b

И т.д.

Т.е. смысл в том, что в конкретном пространстве имен data_type_X должна быть функция apply, который компилятор посредством ADL находит для вызова внутри processing::process.

Все шло хорошо до момента, пока не появились data_type_i и data_type_j, в которых тип data был определен через using:

namespace data_type_i
{

using data = std::map<...>;

void apply(const data & what, const processing::processor & how) {...}

// namespace data_type_i

namespace data_type_j
{

using data = std::vector<...>;

void apply(const data & what, const processing::processor & how) {...}

// namespace data_type_j

И вот когда эти типы начали отдавать в processing::process, то код перестал компилироваться. Причем далеко не сразу удалось понять, почему ни одна из определенных в правильных пространствах имен apply не выбиралась компилятором как подходящая.

А дело в том, что если заменить псевдонимы, то получались функции вида:

void apply(const std::map<...> &, const processing::processor&);
void apply(const std::vector<...> &, const processing::processor&);

И естественно, что ADL не мог их найти ни в пространстве имен std, ни в processing.

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

В очередной раз захотелось, чтобы using в С++ мог работать как strong typedef. Чтобы можно было написать что-то вроде:

namespace data_type_i
{

using(new) data = std::map<int, std::string>;

// namespace data_type_i

И чтобы компилятор начал считать, что data и std::map<int, std::string> теперь разные типы. И что тип data теперь принадлежит пространству имен data_type_i, а не std.

PS. В свете добавления в C++ рефлексии может оказаться, что наколхозить какой-то нестандартый strong_typedef_for, типа:

namespace data_type_i
{

using data = my::strong_typedef_for< std::map<int, std::string> >;

// namespace data_type_i

через рефлексию будет быстрее и проще, чем дождаться появления using(new) в стандарте. Обычная традиция C++: если что-то можно собрать своими руками дендро-фекальным методом, то включать в стандарт удобный и нормальный вариант никто не будет.

понедельник, 23 июня 2025 г.

[prog.c++] Есть ли теперь смысл при разработке C++ библиотек придерживаться не самых свежих стандартов?

В заголовок поста вынесен вопрос, который занимает меня в последние пару-тройку лет. A последний год так точно.

Поясню в чем дело.

В C++ на протяжении десятков лет есть специфическая картина: существует официальный С++ на "бумаге", т.е. описанный в соответствующем стандарте, будь то С++98, С++11 или C++23, и есть реальный C++, доступный конкретному разработчику в конкретном компиляторе. И, как правило, в имеющихся в нашем распоряжении компиляторах далеко не все фичи из самого последнего официального стандарта реализованы.

Эта картина особенно важна при разработке кросс-платформенных библиотек. Если ты хочешь, чтобы твою библиотеку использовали разные люди в разных проектах и на разных платформах, то ты вынужден занижать версию стандарта. Например, вместо C++23 (который вроде как уже два года нам доступен) делать библиотеку на C++17 или даже C++14.

При разработке софта для конечного пользователя ситуация, зачастую, гораздо проще: очень часто софт пишется под конкретную платформу и вполне можно заложиться на конкретный компилятор. Но с разработкой библиотек не так. В проекте X библиотека может работать уже в режиме C++23, тогда как в проекте Y -- все еще в C++17.

До недавнего времени я воспринимал такую ситуацию как данность. Типа в разработке прикладного софта для конечного пользователя своя специфика, а при разработке библиотек -- своя. Нужно смириться и получать удовольствие.

Но после C++20 смиряться все сложнее.

C++11 стал совсем другим языком в сравнении с C++98. Два следующих стандарта, C++14 и С++17, инкрементально улучшали C++, но не могу сказать, что они переводили язык на какой-то принципиально другой уровень (даже не смотря на такие крутые фичи C++17 как structured binding и if constexpr). А вот начиная с C++20 все принципиально поменялось:

  • C++20 добавил концепты и operator spaceship (про модули промолчу ибо не пробовал);
  • С++23 добавил deducing this. Не смотря на то, что (на мой взгляд) сделали это через одно место (и можно было бы по-другому), но таки важную реальную проблему эта фича решает;
  • С++26 добавляет compile-time рефексию и, я очень надеюсь, контракты.

Все это вместе, не побоюсь этого слова, делает из C++ совсем другой язык в такой же степени, как C++11 после C++98. Если не в большей.

И вот глядя на все это великолепие в свежих стандартах С++ я не могу найти для самого себя ответ на вопрос: а зачем на при разработке своих библиотек оставаться на C++17/14/11?

Вот реально.

Ладно бы нам за наши OpenSource проекты платили. Но этого нет. И RESTinio, и SObjectizer приносят нам деньги не напрямую, а приводя к нам клиентов через репутацию. Зачастую новые фичи в тот же SObjectizer добавляются "just for fun" -- просто что-то выглядит интересным или представляет из себя вызов, поэтому делаешь это ради получения удовольствия.

И если уж удовольствие от разработки выходит на передний план, то зачем лишаться концептов, контрактов или рефлексии? Ради чего?

Поэтому чем дальше, тем больше мыслей о том, чтобы в следующем году перевести SObjectizer сразу на C++26. На счет RESTinio ситуация посложнее, но если представиться возможность плотно поработать над RESTinio, то и там тоже можно будет сразу же брать С++26.

PS. Простите, что поминаю Rust в суе, но после добавления в C++ рефлексии тема выбора между Rust и C++ становится еще более сложной, а аргументов в пользу Rust-а -- еще меньше. При этом, если бы мне пришлось программировать не на C++, а на чистом Си, то я бы однозначно предпочел бы Rust.