Concept Based Multiple Dispatch for std::variant
When dispatching on a std::variant, generally we reach for a std::visit, and then perform independent handling per held type.
Say we are implementing a handler for a UI system:
struct Click { int x, y; Modifiers mods; bool cancelled = false; static constexpr bool loggable = true; };
struct KeyPress { int keyCode; Modifiers mods; bool cancelled = false; static constexpr bool loggable = true; };
struct Scroll { int x, y; float delta; };
struct Hover { int x, y; };
using InputEvent = std::variant<Click, KeyPress, Scroll, Hover>;
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
std::visit(overloaded{
[&](Click& e) {
updateCursor(e);
applyModifiers(e);
checkCancel(e);
log(e);
},
[&](KeyPress& e) {
applyModifiers(e);
checkCancel(e);
log(e);
},
[&](Scroll& e) { updateCursor(e); },
[&](Hover& e) { updateCursor(e); },
}, ev);
There are functions that appear in multiple type handlers. This is brittle because when adding a new type there’s the potential to miss a call in the new handler for it.
Each of those calls really depends on a property of the type, which we can express as a concept:
template<class T>
concept HasPosition = requires(const T& e) { e.x; e.y; };
template<class T>
concept HasModifiers = requires(const T& e) { e.mods; };
template<class T>
concept Cancellable = requires(T& e) { e.cancelled = true; };
template<class T>
concept Loggable = requires { requires T::loggable; }; // opt-in tag
The action we want depends on which concepts a type models. Hypothetically, we’d like a match that runs every matching handler:
match(
ev,
[](HasPosition auto& e) { updateCursor(e); },
[](HasModifiers auto& e) { applyModifiers(e); },
[](Cancellable auto& e) { checkCancel(e); },
[](Loggable auto& e) { log(e); }
);
std::visit doesn’t support this 1. We can try a visit with the concepts as the overload constraints:
std::visit(
overloaded{
[](HasPosition auto& e) { updateCursor(e); },
[](HasModifiers auto& e) { applyModifiers(e); },
[](Cancellable auto& e) { checkCancel(e); },
[](Loggable auto& e) { log(e); },
},
ev
);
This looks very expressive, but fails to compile. Because each type matches multiple concepts, the call to the overloaded functor is ambiguous.
But we do want the behavior the compiler is rejecting, we do want to invoke multiple matching actions per held type.
Rather than letting overload resolution choose one handler, we can check each handler ourselves. std::is_invocable_v tells us whether a handler can be called with the held type, so we can test every handler inside a single std::visit callback.
template<typename VariantT, typename... Handlers>
void match_all(VariantT&& v, Handlers&&... handlers)
{
std::visit([&](auto&& x) {
auto try_invoke = [&](auto& handler) {
if constexpr (std::is_invocable_v<decltype(handler), decltype(x)>) {
handler(x);
}
};
(try_invoke(handlers), ...);
}, std::forward<VariantT>(v));
}
match_all(
ev,
[](HasPosition auto& e) { updateCursor(e); },
[](HasModifiers auto& e) { applyModifiers(e); },
[](Cancellable auto& e) { checkCancel(e); },
[](Loggable auto& e) { log(e); }
);
The resulting syntax is clear and expressive of intent.
A large upside to this is if a new type is added to the variant which matches one (or more) of the existing concepts, the visit code doesn’t need to be updated, and the right handlers are called. It “just works”:
struct TouchTap { int x, y; Modifiers mods; bool cancelled = false; static constexpr bool loggable = true; };
using InputEvent = std::variant<Click, KeyPress, Scroll, Hover, TouchTap>;
// no changes needed to the match call
There are some downsides to this though:
- Because the match is determined by
std::is_invocable, if a type is modified it can suddenly match a new handler without the user intending to. - A type that doesn’t match any of the supplied handlers is a silent miss (this can be mitigated though).
We can extend this pattern as well, to add:
- Exhaustiveness check: if there’s a type that doesn’t match any of the supplied handlers we can make a
static_assertfailure. However if we also want a catch-all handler to do some common behavior on all types this check will be defeated. - Short circuiting extension:
match_allinvokes all supplied handlers if they match, we can also implement amatch_firstthat short circuits and only invokes the first handler that matches a type.