Hello,
We need a language mechanism for customization points (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)