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?