понедельник, 11 июля 2022 г.

[prog.c++] Расстроен одним из нововведений в fmtlib-9.0.0

Намедни вышла очередная версия замечательной библиотеки fmtlib. Одно из нововведений версии 9.0, а именно отказ от поддержки типов для которых определен оператор сдвига в std::ostream, сильно меня расстроило. И заставило потратить около четырех часов на выходных на то, чтобы адаптировать под fmtlib-9.0 наши OpenSource проекты arataga и RESTinio.

Тратить время на то, чтобы исправить сломанный другими людьми код мне не понравилось. Поэтому выскажу несколько своих незлых тихих слов.

Итак, что случилось в fmtlib-9.0.0?

В предыдущих версиях fmtlib можно было легко использовать типы, для которых определен оператор сдвига в std::ostream. Например:

... 
#include <fmt/ostream.h>

namespace some::my::name::space {

struct compound_key {
  int low_;
  int high_;
};

std::ostream & operator<<(std::ostream & to, const compound_key & v) {
  return (to << '[' << v.low_ << ':' << v.high_ << ']') << std::endl;
}

...
void some_function() {
  ...
  compound_key key = ...;
  fmt::print(std::cout, "key is {}", key);
}

/* some::my::name::space */

Начиная с версии 9.0.0 это уже не прокатывает. Теперь нужно либо специфицировать fmt::formatter для собственного типа, либо же использовать функцию fmt::streamed.

Т.е. либо мы поступаем вот так:

... 
#include <fmt/ostream.h>

namespace some::my::name::space {

struct compound_key {
  int low_;
  int high_;
};

std::ostream & operator<<(std::ostream & to, const compound_key & v) {
  return (to << '[' << v.low_ << ':' << v.high_ << ']') << std::endl;
}

// Пространство имен временно закрываем.
/* some::my::name::space */

// Специализация fmt::formatter для своего типа.
template<> struct fmt::formatter<some::my::name::space::compound_key>
  : fmt::ostream_formatter
{};

// Опять открываем пространство имен.
namespace some::my::name::space {

...
void some_function() {
  ...
  compound_key key = ...;
  fmt::print(std::cout, "key is {}", key);
}

/* some::my::name::space */

Либо же вот так:

... 
#include <fmt/ostream.h>

namespace some::my::name::space {

struct compound_key {
  int low_;
  int high_;
};

std::ostream & operator<<(std::ostream & to, const compound_key & v) {
  return (to << '[' << v.low_ << ':' << v.high_ << ']') << std::endl;
}

...
void some_function() {
  ...
  compound_key key = ...;
  fmt::print(std::cout, "key is {}", fmt::streamed(key));
}

/* some::my::name::space */

В общем, и то, и другое выглядит как известная субстанция и, не удивлюсь, если этим и является.

Попробую пояснить почему все это плохо.

Во-первых, до fmtlib-9.0 не было проблем с печатью чужих типов, для которых был operator<< (вроде asio::ip::address). Теперь же мы не можем просто так напечатать чужие типы в своем коде посредством fmtlib.

Нам остается воспользоваться fmt::streamed.

Но что, если мы пишем шаблонную функцию, которая параметризуется неким типом T и мы не знаем, чей это будет тип: наш, для которого мы можем определить fmt::formatter, или же это чужой тип, для которого есть только operator<<?

А ничего хорошего. Пихай повсюду fmt::streamed. Сразу плюс +100500 к читабельности кода.

Идем далее.

Раньше для своего типа было достаточно определить только operator<< и этот тип можно было использовать хоть с ostream (в том числе и со специализированными реализациями), хоть с fmtlib.

Теперь же просто так свой тип с fmtlib использовать не получится. Либо засирай свой код fmt::streamed, либо делай специализацию fmt::formatter.

Особенно здорово это когда у тебя уже давным-давно куча типов с перегруженным operator<< и туча кода, в котором эти типы давным-давно используются с fmtlib. Придется приложить некоторые усилия, чтобы это все заработало с новым fmtlib. А то неправильно это как-то: работающий код, написанный хз сколько лет назад, и без рефакторинга. Непорядок. Но ничего, fmtlib-9.0 исправит это досадное недоразумение.

Что мне особенно нравится, так это удобство написания специализации fmt::formatter. На примере выше это уже можно увидеть: если мы определяем свой тип в каком-то своем пространстве имен и в этом же пространстве имен прямо рядышком с типом можем определить и operator<<, то вот с fmt::formatter так не получится. Если мы хотим определить специализацию fmt::formatter прямо рядом с типом, то придется свое пространство имен закрыть, а затем открыть заново. Ну или же делать специализации fmt::formatter где-то в отдельном месте.

Отдельные приключения получат мейнтейнеры библиотек (вроде нашей RESTinio), для которых fmtlib -- это зависимость. И версию этой зависимости могут определять пользователи. Ведь теперь нужно заботиться о том, чтобы библиотека компилировалась как с fmtlib-8, так и с fmtlib-9.


Как по мне, то так поднасрать пользователям fmtlib нужно было сильно постараться. И кто-то постарался.

Как включить в fmtlib функцию println в дополнение к print, так нет.

А вот как сломать совместимость в месте, где ломать ну вот вообще не следовало, так да.

Уверен, что найдется множество тех, кто считает, что все сделано в fmtlib-9.0 правильно. Уверен потому, что уже нашлось множество тех, кто считает, что в println нет необходимости. Ну что уж тут поделать, человеческая глупость не знает границ. Достаточно посмотреть на CMake и на количество тех, кто этим кусом известно чего пользуется и не жужжит.

Так и живем.


Чтобы добавить ярких красок, вот пример сообщения об ошибке при попытке перевести работающий код на fmtlib-9.0. Вот просто сразу же видно что к чему и где именно и что именно нужно поменять:

Compiling arataga/authentificator/a_authentificator.cpp ...
In file included from spdlog/include/spdlog/fmt/fmt.h:27,
                 from spdlog/include/spdlog/common.h:45,
                 from spdlog/include/spdlog/spdlog.h:12,
                 from ./arataga/config.hpp:12,
                 from ./arataga/user_list_processor/notifications.hpp:8,
                 from ./arataga/authentificator/a_authentificator.hpp:10,
                 from arataga/authentificator/a_authentificator.cpp:6:
./fmt/include/fmt/core.h: In instantiation of ‘constexpr fmt::v9::detail::value fmt::v9::detail::make_value(T&&) [with Context = fmt::v9::basic_format_context; T = const arataga::utils::acl_req_id_t&]’:
./fmt/include/fmt/core.h:1753:29: required from ‘constexpr fmt::v9::detail::value fmt::v9::detail::make_arg(T&&) [with bool IS_PACKED = true; Context = fmt::v9::basic_format_context; fmt::v9::detail::type = fmt::v9::detail::type::custom_type; T = const arataga::utils::acl_req_id_t&; typename std::enable_if::type = 0]’
./fmt/include/fmt/core.h:1877:77: required from ‘constexpr fmt::v9::format_arg_store::format_arg_store(T&& ...) [with T = {const std::__cxx11::basic_string, std::allocator >&, const arataga::utils::acl_req_id_t&, fmt::v9::detail::streamed_view, const short unsigned int&, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, const std::__cxx11::basic_string, std::allocator >&, const short unsigned int&}; Context = fmt::v9::basic_format_context; Args = {std::__cxx11::basic_string, std::allocator >, arataga::utils::acl_req_id_t, fmt::v9::detail::streamed_view, short unsigned int, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, std::__cxx11::basic_string, std::allocator >, short unsigned int}]’
spdlog/include/spdlog/logger.h:376:5: required from ‘void spdlog::logger::log_(spdlog::source_loc, spdlog::level::level_enum, spdlog::string_view_t, Args&& ...) [with Args = {const std::__cxx11::basic_string, std::allocator >&, const arataga::utils::acl_req_id_t&, fmt::v9::detail::streamed_view, const short unsigned int&, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, const std::__cxx11::basic_string, std::allocator >&, const short unsigned int&}; spdlog::string_view_t = fmt::v9::basic_string_view]’
spdlog/include/spdlog/logger.h:90:9: required from ‘void spdlog::logger::log(spdlog::source_loc, spdlog::level::level_enum, fmt::v9::format_string, Args&& ...) [with Args = {const std::__cxx11::basic_string, std::allocator >&, const arataga::utils::acl_req_id_t&, fmt::v9::detail::streamed_view, const short unsigned int&, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, const std::__cxx11::basic_string, std::allocator >&, const short unsigned int&}; fmt::v9::format_string = fmt::v9::basic_format_string, std::allocator >&, const arataga::utils::acl_req_id_t&, fmt::v9::detail::streamed_view, const short unsigned int&, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, const std::__cxx11::basic_string, std::allocator >&, const short unsigned int&>]’
spdlog/include/spdlog/logger.h:96:9: required from ‘void spdlog::logger::log(spdlog::level::level_enum, fmt::v9::format_string, Args&& ...) [with Args = {const std::__cxx11::basic_string, std::allocator >&, const arataga::utils::acl_req_id_t&, fmt::v9::detail::streamed_view, const short unsigned int&, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, const std::__cxx11::basic_string, std::allocator >&, const short unsigned int&}; fmt::v9::format_string = fmt::v9::basic_format_string, std::allocator >&, const arataga::utils::acl_req_id_t&, fmt::v9::detail::streamed_view, const short unsigned int&, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, fmt::v9::detail::streamed_view, const std::__cxx11::basic_string, std::allocator >&, const short unsigned int&>]’
arataga/authentificator/a_authentificator.cpp:104:5: required from ‘arataga::authentificator::a_authentificator_t::on_auth_request(so_5::agent_t::mhood_t):: [with auto:30 = spdlog::logger; auto:31 = arataga::logging::processed_log_level_t]’
./arataga/logging/wrap_logging.hpp:173:9: required from ‘void arataga::logging::wrap_logging(arataga::logging::direct_logging_marker_t, spdlog::level::level_enum, Logging_Action&&) [with Logging_Action = arataga::authentificator::a_authentificator_t::on_auth_request(so_5::agent_t::mhood_t)::]’
arataga/authentificator/a_authentificator.cpp:119:6: required from here
spdlog/include/spdlog/logger.h:370:68: in ‘constexpr’ expansion of ‘fmt::v9::make_format_args<>(std::forward&>(args#0), std::forward(args#1), std::forward >(((std::remove_reference >::type&)args#2)), std::forward(args#3), std::forward >(((std::remove_reference >::type&)args#4)), std::forward >(((std::remove_reference >::type&)args#5)), std::forward >(((std::remove_reference >::type&)args#6)), std::forward&>(args#7), std::forward(args#8))’
./fmt/include/fmt/core.h:1733:7: error: static assertion failed: Cannot format an argument. To make type T formattable provide a formatter specialization: https://fmt.dev/latest/api.html#udt
1733 |       formattable,
     |       ^~~~~~~~~~~

6 комментариев:

NN​ комментирует...

Может просто нужен fmt/ostream.h ?

https://fmt.dev/dev/api.html#ostream-api

eao197 комментирует...

В примерах же он фигурирует :)

NN​ комментирует...

У них документация не обновилась.
Ещё лучше :)
Кстати а нельзя ли сделать как-нибудь обобщённый адаптер чтобы не писать fmt::streamed?

eao197 комментирует...

> Кстати а нельзя ли сделать как-нибудь обобщённый адаптер чтобы не писать fmt::streamed?

Лучшее, что я смог придумать пока, это вот это :(

phprus комментирует...

Старое поведение было отключено не просто так, а из-за того, что оно приводило к большим проблемам.
Подробнее в issue: https://github.com/fmtlib/fmt/issues/2357

Фактически, до версии fmt-9 безопасно использовать "fmt/ostream.h" можно было либо нигде, либо абсолютно везде (в том числе во всех статически линькуемых зависимостях).

Если в проекте в одном cpp подключался заголовок "fmt/format.h", а в другом - "fmt/ostream.h" и в проекте были типы с оператором вывода в поток, то это закладывало почву под возможный ODR violation, который другими путями к большому сожалению не устранялся.

eao197 комментирует...

@phprus

Я подозревал, что все это сделано не просто так. Спасибо за ссылку с объяснением.

Но даже знание причин не отменяет моего отношения к произошедшему. Ну и я бы, ради сохранения совместимости, был бы более склонен к тому, чтобы fmt/format.h сам бы подключал fmt/ostream.h. Поскольку a) для меня одной из основных фишек fmtlib была поддержка типов с operator<< без заморочек и b) уже написана туча кода, менять который -- это непроизводительные расходы (причем не только у меня, тут нужно умножать не количество пользователей fmtlib).

В общем, авторы библиотеки могли допустить просчет при проектировании библиотеки. Но если от текущего поведения зависит чужой код, то вот просто так взять и "все исправить" не самый дешевый вариант. Может быть дешевле (для пользователей) сохранять поведение настолько долго, насколько можно.