C++ Logo

std-proposals

Advanced search

[std-proposals] Revisiting Class Template Specialization case in P2279

From: Михаил Найденов <mihailnajdenov_at_[hidden]>
Date: Mon, 8 May 2023 19:40:44 +0300
Hello, We need a language mechanism for customization points
<https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/p2279r0.html> *(*
P2279*)*
examines the use of Class Template Specialization, used in fmt library
(also std::format)
to investigate pros and cons of this approach.

However, I don't think the use of specialization in that library is an
example that can be used as a reference point. Digging in the code, the
specialized Formatter class in question is used as a normal templated
variable. As we have discussed here some time ago, this is not enough to be
a "modern" extension point as modern extension points need remapping on the
call site.

This is a simplified version of what fmt does:

template <typename T, typename Formatter>
void format_custom_arg(void* arg,
                              parse_context_type& parse_ctx,
                              Context& ctx) {
  auto f = Formatter();
  parse_ctx.advance_to(f.parse(parse_ctx));
  ctx.advance_to(f.format(*static_cast<T*>(arg), ctx));
}

As you can see, Formatter is used as a concrete class.
This is ergonomic, but suboptimal if we talk about customization
possibility using CTS.

What we could to instead is:

template <typename T, typename Formatter>
void format_custom_arg(void* arg,
                              parse_context_type& parse_ctx,
                              Context& ctx) {
  auto f = Formatter();
  parse_ctx.advance_to(Formatter::parse(f, parse_ctx));
  ctx.advance_to(Formatter::format(f, *static_cast<T*>(arg), ctx));
}

Notice, we no longer call the methods directly, we call them via the
Formatter class. This changes things considerably.

First and foremost *we can now have default implementation - *one of the
downsides of CTS, named in P2279!

template <class T>
struct Formatter {
  static int parse( Formatter<T>&, parse_context_type& c) {
std::puts("parse base"); return 0; }

 template <typename FormatContext>
  static int format(const Formatter<T>&, const T& , FormatContext& ) {
std::puts("format base"); return 0; }
};
(NB, simplified interface!)

If the user does not specialize a Formatter, the base impl will be called.

Second, *the API is now visible*, Formatter is no longer an empty class,
another downside named in P2279!

Not only the base is no longer empty, but with a few tweaks we can make it
usable to specializations, to call the base if they want.

template <class T=void>
struct Formatter {
  static int parse( Formatter<T>&, parse_context_type& c) {
std::puts("parse base"); return 0; }

 template <typename FormatContext, class U>
  static int format(const Formatter<T>&, const U& , FormatContext& ) {
std::puts("format base"); return 0; }
};

Now a specialization of T, in its impl of parse, can call
Formatter<> bs; return Formatter<>::parse(bs, c);
Getting back the base implementation.
OR
the specialization can subclass Formatter<> and get parse automatically!


The above can further be improved in one last major way.
It is suboptimal (from customization POV) to have Formatter be created as a
concrete class

auto f = Formatter();

It is much more flexible to have Formatter be just *an interface* to the
concrete class

auto f = typename Formatter::State();

By introducing this indirection we gain yet another powerful feature - *we
can use any existing class as a Formatter* (after explicit opt in), because
we no longer need to save state inside the customization point itself.

template <class T=void>
struct Formatter {
  using State = Formatter<T>;

  static int parse(State&, parse_context_type& c) { std::puts("parse
default"); return 0; }

  template <typename FormatContext, class U>
  static int format(const State& , const U& , FormatContext& ) {
std::puts("format base"); return 0; }
};

template<class T>
struct MyExistingFormatter {
 int my_state{};
 void my_parse_func() { std::cout << "parse!" << my_state++ << '\n'; }

 void operator()(const T& ) { std::cout << "format!" << my_state << '\n'; }
};


template <>
struct Formatter<T> {
  using State = MyExistingFormatter<T>;

  static int parse(State& s, parse_context_type& c) { s.my_parse_func();
return 0; }

  template <typename FormatContext, class U>
  static int format(State& s, const U& a, FormatContext& ) { s(a); return
0; }
};


As you can see, the completely unrelated MyExistingFormatter can be
used *without
any change to it*. We only "implement an interface" for it separately. If
we want, we can also call the base impl.


Now, I am not saying the above model is a great experience, I just wanted
to point out how more powerful it is, ticking a few more boxes.
 In fact I believe, if we can move the remapping of calls away from the
call site and improve the "implementation of an interface" safety and
ergonomics, we can have the "the best of both worlds" - usage as
convenient as a concrete class (no visible remapping) and the
flexibility of "modern" extension points (impl outside the class, default
impl, etc)

Received on 2023-05-08 16:40:57