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)
<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