Ch15.8: if constexpr and Conditional Error Contracts
1. The Branch That Is Not Compiled
Ch7.3 introduced
if constexpr as a compile-time if. Inside a template it does
something extra: the branch that is not taken is discarded, not
compiled, so it may be illegal for the current template arguments.
That is the whole difference from an ordinary if. Both branches of a
runtime if must compile for every type; with if constexpr
only the taken branch has to. It is how a generic function says “if the type is
an integer do this, otherwise do that” without writing one function per
category:
template <typename T>
[[nodiscard]] inline constexpr ::std::string_view describe() noexcept
{
if constexpr (::std::integral<T>)
{
return "integral";
}
else if constexpr (::std::floating_point<T>)
{
return "floating point";
}
else
{
return "something else";
}
}
describe<no_size>() returns "something else" and the
first two branches do not exist in that instantiation — they are not compiled,
not emitted, and not reachable.
2. requires Inside if constexpr
The condition can be an ad-hoc requires-expression
(Ch15.3), which asks whether an
expression is valid for this type. That is how generic code discovers an optional
member function without constraining the caller:
template <typename T>
[[nodiscard]] inline constexpr ::std::size_t element_count() noexcept
{
if constexpr (requires(T const &t) { t.size(); })
{
return 8u;
}
else
{
return 1u;
}
}
::std::string_view has size() and takes the first branch;
no_size does not and takes the second. Nothing is written twice, and a
type that gains a size() tomorrow starts taking the first branch with no
edit to this function.
3. A Contract That Depends on the Instantiation
Ch6.16 said every function is
noexcept or throws. For a template that is a problem: an
operation that cannot fail for int may fail for a user type. The answer
is a conditional exception specification:
template <typename T>
inline void check_nonzero(T value) throws(::std::is_integral_v<T>)
{
if constexpr (::std::is_integral_v<T>)
{
if (value == 0) [[unlikely]]
{
throw throws ::std::errc::invalid_argument;
}
}
else
{
static_cast<void>(value);
}
}
throws(condition) means: when the condition is true the
function may throw herbceptions; when it is false the function is
noexcept and the compiler enforces it. The two instantiations are
genuinely different types of function:
noexcept(check_nonzero<::std::int_least32_t>(7)) // false
noexcept(check_nonzero<::std::float64_t>(1.0)) // true
Why bother? Because noexcept is what makes an operation usable inside
another noexcept one, and because
::std::is_nothrow_move_constructible_v and friends
(Ch15.1) are how containers
choose between moving and copying. A template that is unconditionally
throws is a template that forces every container holding it to copy on
growth.
4. Inheriting the Wrapped Call's Contract
The commonest conditional specification is not about the type at all; it is about the
call being forwarded. noexcept(expression) is an operator that yields
true if that expression cannot throw, so a perfect-forwarding wrapper can
promise exactly as much as the call it makes:
template <typename output, typename T>
inline void print_once(output &&out, T const &value) throws(!noexcept(::fast_io::io::print(out, value)))
{
::fast_io::io::print(out, value);
}
throws(!noexcept(...)) reads backwards but means exactly what it says:
“may throw unless the wrapped call cannot”. Write it as
noexcept(noexcept(...)) if you prefer — on a herbceptions
toolchain the two spellings agree.
This is the same pattern fast_io uses everywhere in its own headers, hidden behind macros so the library builds against both exception models:
// fast_io_dsal/impl/misc/push_macros.h, herbceptions branch
#define FAST_IO_HERBCEPTIONS_THROWS_IF(...) throws(__VA_ARGS__)
#define FAST_IO_HERBCEPTIONS_THROWS_IF_NOT_NOEXCEPT(...) throws(!noexcept(__VA_ARGS__))
// legacy branch
#define FAST_IO_HERBCEPTIONS_THROWS_IF(...) noexcept(!(__VA_ARGS__))
#define FAST_IO_HERBCEPTIONS_THROWS_IF_NOT_NOEXCEPT(...) noexcept(noexcept(__VA_ARGS__))
::fast_io::native_global_allocator uses
FAST_IO_HERBCEPTIONS_THROWS_IF(allocator::throws_on_allocation_failure)
on every allocate function, which is why
::fast_io::vector<T> is throws while a
vector over a no-fail allocator is noexcept
(Ch15.9 shows that code).
5. What to Put in the Branch
| Condition | Use it for |
|---|---|
::std::integral<T>, ::std::floating_point<T> |
type categories, one algorithm per category |
::std::is_pointer_v<T>, sizeof(T) == N |
layout decisions, choosing an intrinsic |
requires { t.size(); } |
optional members, capability detection |
::std::is_constant_evaluated() |
a different algorithm at compile time (Ch7.1) |
!noexcept(expr) |
an error contract that mirrors a call |
Two rules keep if constexpr from becoming the new
#ifdef spaghetti. First, an unreachable branch is dead code: it is not
compiled, so a typo in it is not found until someone instantiates with the matching
type. Second, a chain longer than three branches is a design smell — either the
categories should be concepts with overloads (which order themselves by subsumption),
or the type should provide the operation itself.
6. The Whole Program
#include <fast_io.h>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <stdfloat>
#include <type_traits>
struct no_size
{
int value{};
};
template <typename T>
[[nodiscard]] inline constexpr ::std::string_view describe() noexcept
{
if constexpr (::std::integral<T>)
{
return "integral";
}
else if constexpr (::std::floating_point<T>)
{
return "floating point";
}
else
{
return "something else";
}
}
template <typename T>
[[nodiscard]] inline constexpr ::std::size_t element_count() noexcept
{
if constexpr (requires(T const &t) { t.size(); })
{
return 8u;
}
else
{
return 1u;
}
}
// the error contract depends on the instantiation
template <typename T>
inline void check_nonzero(T value) throws(::std::is_integral_v<T>)
{
if constexpr (::std::is_integral_v<T>)
{
if (value == 0) [[unlikely]]
{
throw throws ::std::errc::invalid_argument;
}
}
else
{
static_cast<void>(value);
}
}
int main()
{
try
{
::fast_io::io::println(describe<int>());
::fast_io::io::println(describe<::std::float64_t>());
::fast_io::io::println(describe<no_size>());
::fast_io::io::println("element_count<no_size>=", element_count<no_size>());
::fast_io::io::println("element_count<string_view>=", element_count<::std::string_view>());
::fast_io::io::println("noexcept(check_nonzero<int>)=", noexcept(check_nonzero<::std::int_least32_t>(1)));
::fast_io::io::println("noexcept(check_nonzero<float64_t>)=", noexcept(check_nonzero<::std::float64_t>(1.0)));
check_nonzero<::std::int_least32_t>(7);
::fast_io::io::println("check_nonzero<int>(7) returned");
try
{
check_nonzero<::std::int_least32_t>(0);
}
catch throws (::std::error)
{
::fast_io::io::println("zero rejected");
}
}
catch throws (::std::error e)
{
::fast_io::io::perrln(e);
}
}
Output:
integral
floating point
something else
element_count<no_size>=1
element_count<string_view>=8
noexcept(check_nonzero<int>)=0
noexcept(check_nonzero<float64_t>)=1
check_nonzero<int>(7) returned
zero rejected
The handler received [posix](22)Invalid argument, the standard's text for
::std::errc::invalid_argument; the program prints only its own message so
that the transcript above stays one stream.
7. Rules of Thumb
- Use
if constexprwhen the branch depends on the type and the untaken branch would not compile; use an ordinaryifwhen it depends on a value. - Never write
if constexpr (true)as an#ifdefsubstitute; it makes dead code look alive. - Express a per-instantiation error contract with
throws(condition), not by splitting the function in two. Thethrowsform documents the condition and letsnoexcept(...)answer it. - Mirror the wrapped call's contract with
throws(!noexcept(...))in forwarding wrappers; do not claimnoexceptyou cannot prove. - Prefer concepts and overloads to a long
if constexprchain: overloads are selected by subsumption and produce better diagnostics (Ch15.3).