C++ Overload Resolution: Why a Template Conversion Operator Can Beat a Non-Template
Understanding Overload Resolution with Conversion Operators
In modern C++, user-defined conversion functions provide convenient syntactic sugar for type conversions. However, combining conversion operators, function templates, and const qualifiers can lead to surprising behavior and inconsistent compiler diagnostics.
A classic dilemma arises when choosing between a non-template conversion operator marked const and a templated conversion operator without const. Let's explore the exact mechanics defined by the ISO C++ standard to understand which candidate should win and why.
The Problem Scenario
Consider the following snippet:
struct S {
template<typename T>
constexpr operator T() {
return 10;
}
constexpr operator int() const {
return 4;
}
};
int main() {
S s;
// static_assert(s == 4); // #1
static_assert(s.operator int() == 4); // #2
}
At first glance, many developers assume that the non-template function operator int() const should naturally be preferred over the template specialization operator T<int>() because of the general rule: "non-templates are preferred over templates."
However, running this code produces inconsistent results across MSVC, GCC, and Clang. Why does this happen, and what does the standard actually dictate?
The Core Mechanism: Argument Matching Precedes Tie-Breakers
The standard tie-breaker rule that prefers a non-template over a function template specialization only applies when all argument conversion sequences are equally good (per [over.match.best]).
Before reaching any tie-breaker, the compiler must rank the Standard Conversion Sequences (SCS) for all function parameters, including the implicit object parameter (the *this pointer or reference).
1. Evaluating the Implicit Object Argument
In main(), the object s is a non-const lvalue of type S:
- Candidate A (Template):
operator T<int>()is a non-const member function. Its implicit object parameter is of typeS&. Bindings(anSlvalue) toS&is an Exact Match (Identity) conversion. - Candidate B (Non-Template):
operator int() constis aconstmember function. Its implicit object parameter is of typeconst S&. Bindingstoconst S&requires a Qualification Conversion (addingconst).
According to [over.ics.rank], an identity conversion is strictly better than a qualification conversion. Therefore:
Candidate A has a strictly better conversion sequence for the implicit object parameter than Candidate B.
Because Candidate A has a better parameter match, overload resolution terminates immediately. The compiler selects the templated conversion function operator T<int>(). The non-template vs. template tie-breaker is never reached.
Analyzing the Statements
Expression #1: s == 4
When comparing s == 4:
- Candidate conversions are ranked to find a viable builtin comparison operator (or rewritten operator in C++20).
sis converted tointusing the best-matching conversion operator, which isoperator T<int>()returning10.- The comparison evaluates to
10 == 4, which yieldsfalse. - Correct Behavior:
static_assert(s == 4)must fail at compile-time.
Expression #2: s.operator int() == 4
When performing explicit member lookup for operator int:
- Both the non-template
operator int() constand the template specializationoperator T<int>()are valid candidates. - The same argument ranking rules apply to the implicit object argument.
- The non-const template
operator T<int>()is selected, returning10. - Correct Behavior:
static_assert(s.operator int() == 4)must also fail.
Why Do Compilers Disagree?
Historically, compilers have had subtle bugs regarding template conversion operators and implicit object parameter conversion sequences:
- Clang: Consistently follows the standard rules. It selects the template conversion operator in both cases, causing assertions for
== 4to fail as expected. - GCC: In older versions, GCC had issues in explicit member lookup (
s.operator int()), erroneously discarding template candidates prematurely. - MSVC: Historically applied the non-template preference prior to fully evaluating qualification conversions on the implicit object argument.
Best Practices to Avoid Ambiguities
To prevent unintended conversion resolution bugs in production code:
- Keep
constqualifiers consistent: Ensure all conversion operators match the intended cv-qualification. If an operator should be accessible onconstinstances, mark both the template and non-template overloadsconst. - Mark conversion operators
explicit: Useexplicit operator T()to prevent implicit conversions during equality checks and assignment. - Constrain templates with Concepts (C++20): Use
requiresclauses orstd::enable_ifto prevent the templated conversion operator from participating in overload resolution for types that already have dedicated overloads.
struct S {
// Constrained so it never clashes with int conversion
template<typename T> requires (!std::same_as<T, int>)
constexpr operator T() const {
return 10;
}
constexpr operator int() const {
return 4;
}
};