Background
Our original problem was that including boost::logic::tribool
caused an expression of the form referenceToClass != nullptr
to compile happily. We ultimately tracked this down to the implicit conversion chain ClassType => AnEnum (user-defined) => bool
on the left side, and nullptr_t => boost::logic::indeterminate
on the right side causing the operator!=(bool, tribool)
to match.
The question
Our investigation of the problem was slowed down by a phenomenon I still cannot account for. We thought the implicit conversion to the enum could be the culprit, so we tried compiling an expression of the the form AnEnum != nullptr
and that failed to compile. This caused us to conclude that this implicit conversion could not be the cause. After all, if using the enum directly doesn't compile, how could a conversion to the enum cause it to compile? But after exploring other possibilities we were able to determine that this in fact is the case. The conversion chain class => enum => bool
matches the operator, but enum => bool
does not, despite being a suffix of the former.
I boiled down the strangeness to this example code:
enum LegacyEnum {
One,
Two
};
class Convertible {
public:
// The operator only matches when the left operand has
// an implicit conversion to an enum.
operator LegacyEnum() const {
return One;
}
};
// At least one of the operator operands must have a class or enumeration type.
// This class is just to make the operator valid.
class ImplicitCreatable {
public:
// Allow implicit creation from nullptr.
ImplicitCreatable(decltype(nullptr)) {}
};
// This operator somehow matches (Convertible, nullptr),
// but does not match (LegacyEnum, nullptr).
bool operator!=(bool, ImplicitCreatable)
{ return false; }
int main()
{
Convertible conv;
// This compiles because Convertible can be user-converted to LegacyEnum,
// which can be standard-converted to bool.
if( conv != nullptr ) {
return 1;
}
// But somehow an expression that already has type LegacyEnum
// does not match the operator.
LegacyEnum enumerable = One;
if( enumerable != nullptr ) { // error : invalid operands to binary expression ('LegacyEnum' and 'std::nullptr_t')
return 3;
}
return 0;
}
Both MSVC and clang give a similar error. I received a suggestion that enums would not trigger the operator overload lookup machinery at all. The machinery, once triggered by the presence of a class, could then follow the chain all the way to the bool
, since there is only one user-defined conversion in the chain. But when there is no class, none of the logic would be run. But I did not find any support for this in any reference material. For example microsoft's operator overloading documentation states: "Overloaded operators must either be a nonstatic class member function or a global function. A global function that needs access to private or protected class members must be declared as a friend of that class. A global function must take at least one argument that is of class or enumerated type or that is a reference to a class or enumerated type."
The phrasing "class or enumerated type" seems to be consistently used. I cannot find any reference to there being a difference between them wrt. the triggering of all this. Can anyone account for what is going on?
Disclaimer: you didn't specify which standard you are referring to in the question, but from the compiler flags in your snippet I concluded that it's C++17, so in my answer i'll be using draft n4659.
Let's first discuss the second comparison expression enumerable != nullptr
. The operands here are of type LegacyEnum
(an enum
) and nullptr
(std::nullptr_t
). In order to conclude how this should be overloaded, let's consult over.match.oper/2:
If either operand has a type that is a class or an enumeration, a user-defined operator function might be declared that implements this operator or a user-defined conversion can be necessary to convert the operand to a type that is appropriate for a built-in operator. In this case, overload resolution is used to determine which operator function or built-in operator is to be invoked to implement the operator.
The over.match.oper/3 then continues:
...for a binary operator
@
with a left operand of a type whose cv-unqualified version isT1
and a right operand of a type whose cv-unqualified version isT2
, three sets of candidate functions, designated member candidates, non-member candidates and built-in candidates, are constructed as follows:
Next, you can ignore member candidates set, because it requires T1
to be a complete class type (in our case it's LegacyEnum
, emphasis mine):
If
T1
is a complete class type or a class currently being defined, the set of member candidates is the result of the qualified lookup ofT1::operator@
([over.call.func]); otherwise, the set of member candidates is empty.
For the second set, the only non-member candidate you provided (bool operator!=(bool, ImplicitCreatable)
) is also not considered, because the standard requires it to have LegacyEnum
among its operands:
...However, if no operand has a class type, only those non-member functions in the lookup set that have a first parameter of type
T1
or “reference to cvT1
”, whenT1
is an enumeration type, or (if there is a right operand) a second parameter of typeT2
or “reference to cvT2
”, whenT2
is an enumeration type, are candidate functions
So only built-in candidates are considered in this case. This, however is not the case, when you use an operand of class type Convertible
, thanks to over.match.oper/3.2:
The set of non-member candidates is the result of the unqualified lookup of
operator@
in the context of the expression according to the usual rules for name lookup in unqualified function calls ([basic.lookup.argdep]) except that all member functions are ignored