Overly interoperable libraries, part 2
I’ve received a few proposed solutions re “Overly interoperable libraries” (2021-05-19). It’s become clear that I set the initial goalposts a little too close to the kicker. :)
My post asked for a solution to “the general problem,” but provided only a relatively trivial example with two client libraries:
-
Foolib
providesclass Foo
, and wantsoperator^
to exist whenever the left-hand operand isFoo
and the right-hand operandU
definesU::foolib_compatible
. -
Barlib
providesclass Bar
, and wantsoperator^
to exist whenever the right-hand operand isBar
and the left-hand operandT
definesT::barlib_compatible
.
Easy goal number 1
Peter Dimov and Piotr Nycz presented solutions based on the fact that my sample Foolib
cared mainly about the left-hand operand, and my sample Barlib
cared mainly about the
right-hand operand (Godbolt):
template<class T, class U>
struct enable_xor : std::disjunction<enable_xor_lhs<T, U>,
enable_xor_rhs<T, U>> {};
~~~
template<class U> // Foo can be xor'ed with anything foolib-compatible.
struct enable_xor_lhs<Foolib::Foo, U, std::enable_if_t<U::foolib_compatible>>
: std::true_type {};
~~~
template<class T> // Bar can be xor'ed with anything barlib-compatible.
struct enable_xor_rhs<T, Barlib::Bar, std::enable_if_t<T::barlib_compatible>>
: std::true_type {};
This approach immediately breaks down if both Foolib
and Barlib
care about the
left-hand operand; for example if we rewrite the requirements to (Godbolt):
-
Foolib
providesclass Foo
, and wantsoperator^
to exist whenever the left-hand operand is derived fromFoo
and the right-hand operandU
definesU::foolib_compatible
. -
Barlib
providesclass Bar
, and wantsoperator^
to exist whenever the left-hand operand is derived fromBar
and the right-hand operandU
definesU::barlib_compatible
.
Thus:
struct X : Foolib::Foo, Barlib::Bar { int value() const; } x;
int main() {
return (x ^ x); // Collision again!
}
Easy goal number 2: The orphan rule
Chase Albert and Jonathan Müller pointed out that this problem is related to what Rust calls the
“orphan rule.”
Our “operator^
-enablement” is what Rust would call a “trait” — a common behavior that types
can opt-into.
I don’t know much Rust, but as I understand it, the essence of the orphan rule is that only two
people have the authority to “opt a type in” to a trait: the type author, and the trait author.
Other components should butt out.
This is a great principle for practical code — so great that my given example adhered to it without
consciously intending to. Foolib
’s author opted-into operator^
only when at least one of the operands
was in fact a type associated with namespace Foolib
. Barlib
’s author opted-into operator^
only
when at least one of the operands was in fact a type associated with namespace Barlib
.
C++’s argument-dependent lookup (ADL) takes the same approach:
an unqualified fight(you, him)
will look up functions named fight
in namespaces
associated with the types of you
and/or him
, but a completely unrelated namespace wimpy
cannot butt in with its own suggestion.
Jonathan Müller worked up a more or less complete solution based on this approach — basically, recapitulating the ADL associated-namespace rules in partial-specialization form. I’ve massaged it a bit for presentation here. (Godbolt.)
template<class T> constexpr auto libs_of = tl<T>{};
template<class T> constexpr auto libs_of<const T> = libs_of<T>;
template<class T> constexpr auto libs_of<volatile T> = libs_of<T>;
template<class T> constexpr auto libs_of<T*> = libs_of<T>;
template<class T> constexpr auto libs_of<T&> = libs_of<T>;
template<class T> constexpr auto libs_of<T&&> = libs_of<T>;
template<template<class...> class C, class... As> constexpr auto libs_of<C<As...>> = (tl<>{} + ... + libs_of<As>);
template<class R, class... As> constexpr auto libs_of<R(As...)> = (libs_of<R> + ... + libs_of<As>);
template<class T, class U, class LibList>
struct enable_if_xor_is_enabled;
template<class T, class U, class... Libs>
struct enable_if_xor_is_enabled<T, U, tl<Libs...>>
: std::enable_if<(enable_xor<Libs, T, U>::value || ...), int> {};
template<class T, class U,
typename enable_if_xor_is_enabled<T, U, decltype(libs_of<T> + libs_of<U>)>::type = 0>
int operator^(T t, U u) {
return t.value() ^ u.value();
}
~~~
template<class U> // Foo can be xor'ed with anything foolib-compatible.
struct enable_xor<Foolib::Foo, Foolib::Foo, U, std::enable_if_t<U::foolib_compatible>>
: std::true_type {};
Unfortunately this approach cannot handle the “derived from Foo
” example above,
because the generic libs_of
has no way to enumerate the public base classes of
X
and discover that it should be looking for enable_xor
specializations associated
with Foo
and Bar
. In other words, our libs_of
metaprogramming cannot ever fully
recapitulate the rules of ADL.
Alternatively, we could require each type X
to specialize libs_of<X>
and explicitly
name the libraries with which it wants to be associated. But I claim that that idea
(1) doesn’t scale, and (2) doesn’t satisfy the problem statement. (We said Foolib
wants operator^
to exist whenever the left-hand operand is derived from Foo
. We
didn’t say “whenever the left-hand operand is derived from Foo
and also specializes
libs_of
.”)
Analogy to smart pointers
The intent of my metaprogramming puzzle is essentially to find a solution that implements
“shared ownership” of operator^
, in a sense that is exactly analogous
to shared_ptr
’s “shared ownership” of a heap allocation. We have arbitrarily many
entities here, each of which has an equal stake in the existence of operator^
; just as
with shared_ptr
we have arbitrarily many entities, each with an equal stake
in the existence of some heap-allocated object.
Rust’s “orphan rule” is analogous to replacing shared_ptr
with unique_ptr
. The orphan rule
highlights that truly “shared” ownership is rarely necessary. We can usually identify
a single privileged “owner” for our heap-allocation, and then we can use unique_ptr
instead
of shared_ptr
, and then our code will be easier to reason about.
This is absolutely true! Especially in production code! And usually it turns out to be pretty easy to refactor shared ownership into unique ownership, as Jonathan did in his solution.
However, for the purposes of this puzzle, that’s “cheating.” Even if the libs_of
approach
were able to fully recapitulate all of C++’s “associated namespace” rules, it still wouldn’t
be able to deal with a namespace wimpy
that wants to enable operator^
for types not
concretely associated with it, e.g.
Wimpylib
wantsoperator^
to exist whenever either the left-hand operandT
definesT::wimpy_compatible
or the right-hand operandU
definesU::wimpy_compatible
(or both).
My GUID-based toy solution handles this case fine (Godbolt):
template<class T, class U, std::enable_if_t<T::wimpy_compatible, int> = 0>
int enable_xor(priority_tag<22>); // Wimpy needs two GUIDs; one is "22"
template<class T, class U, std::enable_if_t<U::wimpy_compatible, int> = 0>
int enable_xor(priority_tag<23>); // and the other is "23"
Perf improvements to the GUID solution
Jonathan Müller offered an alternative GUID solution: Use C++17 fold-expressions to “iterate instead of recurse.” Instead of (Godbolt)
template<class, class>
void enable_xor(priority_tag<0>);
template<class T, class U,
decltype(enable_xor<T, U>(priority_tag<99>{})) = 0>
int operator^(T t, U u) {
return t.value() ^ u.value();
}
we can use a fold-expression (Godbolt):
template<size_t K> struct index_tag {};
template<class, class> void enable_xor(index_tag<0>) = delete;
template<size_t I, class T, class U, class = void>
constexpr bool has_enabler = false;
template<size_t I, class T, class U>
constexpr bool has_enabler<I, T, U, decltype(enable_xor<T,U>(index_tag<I>()), void())> = true;
template<class T, class U, size_t... Is>
constexpr bool should_enable_xor(std::index_sequence<Is...>) {
return (has_enabler<Is, T, U> || ...);
};
template<class T, class U,
std::enable_if_t<should_enable_xor<T, U>(std::make_index_sequence<99>()), int> = 0>
int operator^(T t, U u) {
return t.value() ^ u.value();
}
Confusingly, both libc++ and libstdc++ fail to handle this exact solution when
index_tag
is rewritten as (Godbolt)template<size_t K> using index_tag = std::integral_constant<size_t, K>;
I don’t know what’s going wrong there. MSVC (and/or MSVC STL) handles it just fine.
Switching to fold-expressions is unfortunately not a strict win, because of Clang. Clang’s implementation-defined limit on the size of a fold-expression is far less than its implementation-defined limit on recursive template instantiations. GCC and ICC, meanwhile, seem not to limit the size of fold-expressions.
But this reminded me of another classic technique for implementing conjunction
and disjunction
:
Use is_same
to do the heavy lifting. To test whether Bs...
are all true
, we simply
compare the type int(bool_constant<Bs>...)
against the type int(bool_constant<true>...)
.
Now we have no recursive templates and no large expressions; we simply have a type with a large parameter list.
(Godbolt.)
template<bool... Bs>
using And = std::is_same<int(index_tag<Bs>...),
int(index_tag<Bs||true>...)>;
template<class T, class U, size_t... Is>
constexpr bool should_enable_xor(std::index_sequence<Is...>) {
return !And<!has_enabler<Is, T, U> ...>::value;
};
This is far and away the winning strategy on today’s compilers; no compiler bothers to set a detectable limit on “number of function parameters in a function type.”
Technique | GCC | Clang | MSVC | ICC |
---|---|---|---|---|
priority_tag |
900 | 1025 | 497 | 500 |
index_tag + fold |
5000+ | 256 | 597 | 33000+ |
index_tag + same |
20000+ | 50000+ | 35000+ | 30000+ |
So now we’ve got a GUID-based solution permitting four-digit GUIDs. Can we do any better?
See also:
- “Overly interoperable libraries, part 3” (2021-05-26)