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 type S&. Binding s (an S lvalue) to S& is an Exact Match (Identity) conversion.
  • Candidate B (Non-Template): operator int() const is a const member function. Its implicit object parameter is of type const S&. Binding s to const S& requires a Qualification Conversion (adding const).

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).
  • s is converted to int using the best-matching conversion operator, which is operator T<int>() returning 10.
  • The comparison evaluates to 10 == 4, which yields false.
  • 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() const and the template specialization operator 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, returning 10.
  • 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 == 4 to 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:

  1. Keep const qualifiers consistent: Ensure all conversion operators match the intended cv-qualification. If an operator should be accessible on const instances, mark both the template and non-template overloads const.
  2. Mark conversion operators explicit: Use explicit operator T() to prevent implicit conversions during equality checks and assignment.
  3. Constrain templates with Concepts (C++20): Use requires clauses or std::enable_if to 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;
    }
};