Why we probably shouldn’t have constexpr conditional operator


The idea

I had a great idea. We have constexpr if, but no constexpr conditional operator. Time for a proposal?

Since we can do stuff like this:

if constexpr(cond) { foo; } else { bar;}

Wouldn’t it be cool if we could also do

cond ? constexpr foo : bar;

My motivation was that I had a std::variant visitor that was identical for all types except one. So instead of writing an overload set for std::visit, it was simpler to have one common lambda with a conditional inside. Something like this, which returns “int” for int and “other” for all other types in the variant:

std::visit([]<typename T>(T value) {
        if constexpr(std::is_same_v<int, T>)
        {
            return "int";
        }
        else
        {
            return "other";
        }
    },
    my_variant);

It would be nicer to write it like this with a conditional operator, but now we can’t use constexpr.

std::visit([]<typename T>(T value) {
        return std::is_same_v<int, T> ? "int" : "other";
    },
    my_variant);

So I had the idea of constexpr conditional operator, so I could write my lambda something like this:

std::visit([]<typename T>(T value) {
         return std::is_same_v<int, T> ? constexpr "int" : "other";
    },
    my_variant);

In this case, constexpr doesn’t actually make much of a difference. std::is_same_v is a constant expression no matter if you use the constexpr keyword or not, so the compiler optimises it equally well in either case. But at least we verify that the condition is actually a constant expression. If we mess this up, we get a compiler error.

But the most important advantage of constexpr if is that each branch only need to compile if that branch is taken at compile time. So you can do for instance

template<typename T>
int f(T t) {
    if constexpr(std::is_same_v<T, std::string>)
        return t.size();
    else
        return t;
}

and this will work both for int and std::string, even if the first branch wouldn’t compile for an int and the second wouldn’t compile for std::string. Remove constexpr above, and you’re in trouble.

As it turns out, this is exactly why constexpr conditional operator might not be such a good idea! Thanks to Daniela Engert who pointed this problem out to me.

The problem

if is a statement, it doesn’t have a type. The conditional operator however is an expression, and has a type!

You can’t assign the result of an if statement to something, it doesn’t have a type or result in a value. The conditional operator does however. And the type of the conditional operator is determined by a set of rules which find a common type for the two branches. For instance:

auto result = false ? std::optional<int>{2} : 0;

The two branches have the types std::optional<int> and int, respectively. The compiler now has to figure out what the type of the expression should be, by trying to form implicit conversion sequences from the first to the other, and vice versa. See [expr.cond] for details. Since one can implicitly convert an int to a std::optional<int>, but not vice versa, the type of the full conditional expression (and thus the type of result) is std::optional<int>.

If we introduced something like ? constexpr here, with the same semantics as if constexpr, suddenly one of the branches would be discarded. And we’d have to do that, since the whole point is that the branch not taken usually doesn’t even compile. So in the case above, the first branch would be discarded, and we’d only be left with the literal 0 which has type int. Left with only the int to deduce a type from, the type of the full conditional expression would now be int instead of std::optional<int>. And Daniela’s argument, which I agree with, is that it could be surprising if the type of an expression changed just by introducing or removing constexpr.

In comparison, remember that an if statement doesn’t result in a value, and doesn’t even have a type. If you want to do the same with an if, you first have to define the result variable, and there’s no way to do that upfront without explicitly deciding on its type:

std::optional<int> result;
if constexpr (false)
    result = std::optional<int>{2};
else
    result = 0;

Notice here that the type of the value we assign to result is still different based on the constexpr condition, but now there’s no surprise, the resulting type is always the same. Both branches have to result in a type implicitly convertible to std::optional<int>, if they’re ever instantiated.

A counter argument?

There is one final point that needs to be mentioned, where the types of two if constexpr branches actually do influence type deduction. This can happen when you have a function with an auto return type, and you return from inside the if constexpr. Here’s a demonstration with a function template, but it can also happen for regular functions:

template<bool b>
auto f()
{
    if constexpr (b)
        return std::optional<int>{2};
    else
        return 0;
}

The return type of f<true> is std::optional<int>, and the return type of f<false> is int. Isn’t this the same problem we just used to argue against constexpr conditional operator? It’s similar, but not the same. The big difference is that removing constexpr in this example doesn’t change the deduced type, it rather causes a compilation error. This is due to dcl.spec.auto#8, which is very strict about all non-discarded return statements having the same type, not just types that can be implicitly converted to a common type:

If a function with a declared return type that contains a placeholder type has multiple non-discarded return statements, the return type is deduced for each such return statement. If the type deduced is not the same in each deduction, the program is ill-formed.

dcl.spec.auto#8

Conclusion

For constexpr conditional operator, adding/removing constexpr could change a deduced type, which could be surprising. For constexpr if, this doesn’t happen.

What do you think? Should we have constexpr conditional operator or not?

7 thoughts on “Why we probably shouldn’t have constexpr conditional operator

  1. Minor comment: conditional does not necessarily have a type.

    void f() {}

    void g() {}

    bool cond() { … }

    cond() ? f() : g();

    This works (well, except with old Sun compilers), yet both f() and g() evaluates to void. (And I think a proper constexpr ternary would simplify some of my codes.)

  2. “And Daniela’s argument, which I agree with, is that it could be surprising if the type of an expression changed just by introducing or removing constexpr.?”

    No it would not be surprising to change the type of an expression. That’s a great use case.
    Right now we’re limited to using verbose helper functions similar to your example:

    template<bool b, typename T>

    static auto f(){

            if constexpr(b)

            {

                return T;

            }

            else

            {

                return Container<T>;

            }

    };

    template<bool b, typename T>

    using gimieType = decltype(f<b,T>());

    using desired_type = gimieType<std::is_integral_v<T>, T>;

    When Instead we could just use a constexpr conditional operator like so:

    using desired_type = std::is_integral_v<T> constexpr ? T : Container<T>;

    Hopefully it would discard the branch that’s not needed so that it’s still useful when one branch doesn’t compile.

    Using conditional_t like so was really close:

    using desired_type = std::conditional_t<std::is_integral_v<T>, T, Container<T>>;

    But it doesn’t work when either branch doesn’t compile.

    f() does work when one of the branches doesn’t compile but f() can’t be made generic, in this case it was made specifically to return type T or type Container<T>

    I wish I could template f() to generically return template types T or U. Then I could use something like:

    using desired_type = gimieType<std::is_integral_v<T>, T, Container<T>>;

    But it has the same problem as using conditional_t when one of the branches doesn’t compile.

    Yes constexpr conditional operators would be useful and not confusing. The example that you presented as confusion is actually a very useful use case

    Also if the argument is that we should avoid surprises it’s more surprising that

    auto result = false ? std::optional<int>{2} : 0;

    results in an optional than that it results in an int. I don’t know anyone relying on that “feature” of conditional operators. Programmers usually ensure both branches return the same type, they don’t expect a conversion.

    1. That’s an interesting perspective.

      The using desired_type = std::is_integral_v<T> constexpr ? T : Container<T>; use case is a bit different from what this blog post is about though, since here you want to be able to use a conditional operator to select between types rather than selecting between values.

      As for the auto result = false ? std::optional{2} : 0; example, I don’t think it’s that confusing that it results in a std::optional<int>, when you know how keen C++ is on implicit conversions. It can actually be useful sometimes too. For example: size_t result = use_size ? get_size() : 0. Then you don’t need to manually make the 0 a size_t. Things like that.

      1. The case you posed with size_t result works fine even if the conditional operator was to return values of different types. That’s because you declared result to be a size_t explicitly. So the conversion from 0 to size_t can happen automatically without manually converting.

        If you wanted to modify you example it would make more sense to write auto instead of size_t. But then again your argument would break down because you would have explicitly declared result‘s type as unimportant and therefore it doesn’t matter whether it’s a size_t or int or long long for that matter

        ====

        Also here’s another case that I just came across. Assume val is a float and you want to print it. But if it contains a whole number you don’t want to print all those trailing zeros that to_string writes. Also if val contains a rational number you do want to print the less significant digits.

        std::string sentense = "Value = " + std::to_string(round(val) == val ? (int)val : val);

        The method above fails, you must use if statements or use some other string formatter that doesn’t print so many trailing zeros.

  3. Your argument that `if constexpr` maintains types relies on scope forcing you not to use `auto` more than anything else. You could just as easily have `std::optional<int> result = false ? std::optional<int>{2} : 0;` or declare `std::optional<int> result;` before setting `result = false ? std::optional<int>{2} : 0;`. The compiler could (perhaps via optional flag) complain about different return types from the constexpr conditional operator, but sometimes it’s useful to have type fluidity in template code.

Leave a comment