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