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?
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.)
While void is not like other types, it is still a type.
E.g. https://timsong-cpp.github.io/cppwp/std20/basic.types#general-10 says that void is a literal type.
“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.
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 astd::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 the0
asize_t
. Things like that.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 asize_t
explicitly. So the conversion from0
tosize_t
can happen automatically without manually converting.If you wanted to modify you example it would make more sense to write
auto
instead ofsize_t
. But then again your argument would break down because you would have explicitly declaredresult
‘s type as unimportant and therefore it doesn’t matter whether it’s asize_t
orint
orlong long
for that matter====
Also here’s another case that I just came across. Assume
val
is afloat
and you want to print it. But if it contains a whole number you don’t want to print all those trailing zeros thatto_string
writes. Also ifval
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.
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.