Chapter10 编译期if语句

通过使用语法if constexpr(...),编译器可以计算编译期的条件表达式来在编译期决定使用 一个if语句的 then 的部分还是 else 的部分。其余部分的代码将会被丢弃,这意味着 它们甚至不会被生成。然而这并不意味着被丢弃的部分完全被忽略, 这些部分中的代码也会像没使用的模板一样进行语法检查。

例如:

  1. #include <string>
  2. template <typename T>
  3. std::string asString(T x)
  4. {
  5. if constexpr(std::is_same_v<T, std::string>) {
  6. return x; // 如果T不能自动转换为string该语句将无效
  7. }
  8. else if constexpr(std::is_arithmetic_v<T>) {
  9. return std::to_string(x); // 如果T不是数字类型该语句将无效
  10. }
  11. else {
  12. return std::string(x); // 如果不能转换为string该语句将无效
  13. }
  14. }

通过使用if constexpr我们在编译期就可以决定我们是简单返回传入的字符串、 对传入的数字调用to_string()还是使用构造函数来把传入的参数转换为 std::string。无效的调用将被 丢弃 ,因此下面的代码能够通过编译 (如果使用运行时if语句则不能通过编译):

  1. #include "ifcomptime.hpp"
  2. #include <iostream>
  3. int main()
  4. {
  5. std::cout << asString(42) << '\n';
  6. std::cout << asString(std::string("hello")) << '\n';
  7. std::cout << asString("hello") << '\n';
  8. }

10.1 编译期if语句的动机

如果我们在上面的例子中使用运行时if,下面的代码将永远不能通过编译:

  1. #include <string>
  2. template <typename T>
  3. std::string asString(T x)
  4. {
  5. if (std::is_same_v<T, std::string>) {
  6. return x; // 如果不能自动转换为string会导致ERROR
  7. }
  8. else if (std::is_numeric_v<T>) {
  9. return std::to_string(x); // 如果不是数字将导致ERROR
  10. }
  11. else {
  12. return std::string(x); // 如果不能转换为string将导致ERROR
  13. }
  14. }

这是因为模板在实例化时整个模板会作为一个整体进行编译。 然而if语句的条件表达式的检查是运行时特性。 即使在编译期就能确定条件表达式的值一定是falsethen 的部分也必须能通过编译。 因此,当传递一个std::string或者字符串字面量时,会因为std::to_string()无效 而导致编译失败。此外,当传递一个数字值时,将会因为第一个和第三个返回语句无效而导致编译失败。

使用编译期if语句时, then 部分和 else 部分中不可能被用到的部分将成为 丢弃的语句

  • 当传递一个std::string时,第一个if语句的 else 部分将被丢弃。
  • 当传递一个数字时,第一个if语句的 then 部分和最后的 else 部分将被丢弃。
  • 当传递一个字符串字面量(类型为const char*)时,第一和第二个if语句 的 then 部分将被丢弃。

因此,在每一个实例化中,无效的分支都会在编译时被丢弃,所以代码能成功编译。

注意被丢弃的语句并不是被忽略了。即使是被忽略的语句也必须符合正确的语法, 并且所有和模板参数无关的调用也必须正确。 事实上,模板编译的第一个阶段( 定义期间 )将会检查语法和所有与模板无关的名称是否有效。 所有的static_asserts也必须有效,即使所在的分支没有被编译。

例如:

  1. template<typename T>
  2. void foo(T t)
  3. {
  4. if constexpr(std::is_integral_v<T>) {
  5. if (t > 0) {
  6. foo(t-1); // OK
  7. }
  8. }
  9. else {
  10. undeclared(t); // 如果未被声明且未被丢弃将导致错误
  11. undeclared(); // 如果未声明将导致错误(即使被丢弃也一样)
  12. static_assert(false, "no integral"); // 总是会进行断言(即使被丢弃也一样)
  13. }
  14. }

对于一个符合标准的编译器来说,上面的例子 永远 不能通过编译的原因有两个:

  • 即使T是一个整数类型,如下调用:
    1. undeclared(); // 如果未声明将导致错误(即使被丢弃也一样)
    如果该函数未定义时即使处于被丢弃的 else 部分也会导致错误,因为这个调用并不依赖于模板参数。
  • 如下断言
    1. static_assert(false, "no integral"); // 总是会进行断言(即使被丢弃也一样)
    即使处于被丢弃的 else 部分也总是会断言失败,因为它也不依赖于模板参数。 一个使用编译期条件的静态断言没有这个问题:
    1. static_assert(!std::is_integral_v<T>, "no integral");

注意有一些编译器(例如Visual C++ 2013和2015)并没有正确实现模板编译的两个阶段。 它们把第一个阶段( 定义期间 )的大部分工作推迟到了第二个阶段( 实例化期间 ), 因此有些无效的函数调用甚至一些错误的语法都可能通过编译。

10.2 使用编译期if语句

理论上讲,只要条件表达式是编译期的表达式你就可以像使用运行期if一样使用编译期if。 你也可以混合使用编译期和运行期的if

  1. if constexpr (std::is_integral_v<std::remove_reference_t<T>>) {
  2. if (val > 10) {
  3. if constexpr (std::numeric_limits<char>::is_signed) {
  4. ...
  5. }
  6. else {
  7. ...
  8. }
  9. }
  10. else {
  11. ...
  12. }
  13. }
  14. else {
  15. ...
  16. }

注意你不能在函数体之外使用if constexpr。 因此,你不能使用它来替换预处理器的条件编译。

10.2.1 编译期if的注意事项

使用编译期if时可能会导致一些并不明显的后果。这将在接下来的小节中讨论。

编译期if影响返回值类型

编译期if可能会影响函数的返回值类型。例如,下面的代码总能通过编译, 但返回值的类型可能会不同:

  1. auto foo()
  2. {
  3. if constexpr (sizeof(int) > 4) {
  4. return 42;
  5. }
  6. else [
  7. return 42u;
  8. }
  9. }

这里,因为我们使用了auto,返回值的类型将依赖于返回语句, 而执行哪条返回语句又依赖于int的字节数:

  • 如果大于4字节,返回42的返回语句将会生效,因此返回值类型是int
  • 否则,返回42u的返回语句将生效,因此返回值类型是unsigned int

这种情况下有if constexpr语句的函数可能返回完全不同的类型。 例如,如果我们不写 else 部分,返回值将会是int或者void

  1. auto foo() // 返回值类型可能是int或者void
  2. {
  3. if constexpr (sizeof(int) > 4) {
  4. return 42;
  5. }
  6. }

注意这里如果使用运行期if那么代码将永远不能通过编译, 因为推导返回值类型时会考虑到所有可能的返回值类型,因此推导会有歧义。

即使在 then 部分返回也要考虑 else 部分

运行期if有一个模式不能应用于编译期if: 如果代码在 thenelse 部分都会返回, 那么在运行期if中你可以跳过else部分。 也就是说,

  1. if (...) {
  2. return a;
  3. }
  4. else {
  5. return b;
  6. }

可以写成:

  1. if (...) {
  2. return a;
  3. }
  4. return b;

但这个模式不能应用于编译期if,因为在第二种写法里, 返回值类型将同时依赖于两个返回语句而不是依赖其中一个,这会导致行为发生改变。 例如,如果按照上面的示例修改代码,那么 也许能也许不能 通过编译:

  1. auto foo()
  2. {
  3. if constexpr (sizeof(int) > 4) {
  4. return 42;
  5. }
  6. return 42u;
  7. }

如果条件表达式为true(int大于4字节),编译器将会推导出两个不同的返回值类型, 这会导致错误。否则,将只会有一条有效的返回语句,因此代码能通过编译。

编译期短路求值

考虑如下代码:

  1. template<typename T>
  2. constexpr auto foo(const T& val)
  3. {
  4. if constexpr(std::is_integral<T>::value) {
  5. if constexpr (T{} < 10) {
  6. return val * 2;
  7. }
  8. }
  9. return val;
  10. }

这里我们使用了两个编译期条件来决定是直接返回传入的值还是返回传入值的两倍。

下面的代码都能编译:

  1. constexpr auto x1 = foo(42); // 返回84
  2. constexpr auto x2 = foo("hi"); // OK,返回"hi"

运行时if的条件表达式会进行短路求值(当&&左侧为false时停止求值, 当||左侧为true时停止求值)。 这可能会导致你希望编译期if也会短路求值:

  1. template<typename T>
  2. constexpr auto bar(const T& val)
  3. {
  4. if constexpr (std::is_integral<T>::value && T{} < 10) {
  5. return val * 2;
  6. }
  7. return val;
  8. }

然而,编译期if的条件表达式总是作为整体实例化并且必须整体有效, 这意味着如果传递一个不能进行<10运算的类型将不能通过编译:

  1. constexpr auto x2 = bar("hi"); // 编译期ERROR

因此,编译期if在实例化时并不短路求值。 如果后边的条件的有效性依赖于前边的条件,那你需要把条件进行嵌套。 例如,你必须写成如下形式:

  1. if constexpr (std::is_same_v<MyType, T>) {
  2. if constepxr (T::i == 42) {
  3. ...
  4. }
  5. }

而不是写成:

  1. if constexpr (std::is_same_v<MyType, T> && T::i == 42) {
  2. ...
  3. }

10.2.2 其他编译期if的示例

完美返回泛型值

编译期if的一个应用就是先对返回值进行一些处理,再进行完美转发。 因为decltype(auto)不能推导为void(因为void是不完全类型), 所以你必须像下面这么写:

  1. #include <functional> // for std::forward()
  2. #include <type_traits> // for std::is_same<> and std::invoke_result<>
  3. template<typename Callable, typename... Args>
  4. decltype(auto) call(Callable op, Args&&... args)
  5. {
  6. if constexpr(std::is_void_v<std::invoke_result_t<Callable, Args...>>) {
  7. // 返回值类型是void:
  8. op(std::forward<Args>(args)...);
  9. ... // 在返回前进行一些处理
  10. return;
  11. }
  12. else {
  13. // 返回值类型不是void:
  14. decltype(auto) ret{op(std::forward<Args>(args)...)};
  15. ... // 在返回前用ret进行一些处理
  16. return ret;
  17. }
  18. }

函数的返回值类型可以推导为void, 但ret的声明不能推导为void, 因此必须把op返回void的情况单独处理。

使用编译期if进行类型分发

编译期if的一个典型应用是类型分发。在C++17之前, 你必须为每一个想处理的类型重载一个单独的函数。现在,有了编译期if, 你可以把所有的逻辑放在一个函数里。

例如,如下的重载版本的std::advance()算法:

  1. template<typename Iterator, typename Distance>
  2. void advance(Iterator& pos, Distance n) {
  3. using cat = std::iterator_traits<Iterator>::iterator_category;
  4. advanceImpl(pos, n, cat{}); // 根据迭代器类型进行分发
  5. }
  6. template<typename Iterator, typename Distance>
  7. void advanceImpl(Iterator& pos, Distance n, std::random_access_iterator_tag) {
  8. pos += n;
  9. }
  10. template<typename Iterator, typename Distance>
  11. void advanceImpl(Iterator& pos, Distance n, std::bidirectional_iterator_tag) {
  12. if (n >= 0) {
  13. while (n--) {
  14. ++pos;
  15. }
  16. }
  17. else {
  18. while (n++) {
  19. --pos;
  20. }
  21. }
  22. }
  23. template<typename Iterator, typename Distance>
  24. void advanceImpl(Iterator& pos, Distance n, std::input_iterator_tag) {
  25. while (n--) {
  26. ++pos;
  27. }
  28. }

现在可以把所有实现都放在同一个函数中:

  1. template<typename Iterator, typename Distance>
  2. void advance(Iterator& pos, Distance n) {
  3. using cat = std::iterator_traits<Iterator>::iterator_category;
  4. if constexpr (std::is_convertible_v<cat, std::random_access_iterator_tag>) {
  5. pos += n;
  6. }
  7. else if constexpr (std::is_convertible_v<cat, std::bidirectional_access_iterator_tag>) {
  8. if (n >= 0) {
  9. while (n--) {
  10. ++pos;
  11. }
  12. }
  13. else {
  14. while (n++) {
  15. --pos;
  16. }
  17. }
  18. }
  19. else { // input_iterator_tag
  20. while (n--) {
  21. ++pos;
  22. }
  23. }
  24. }

这里我们就像是有了一个编译期switch,每一个if constexpr语句就像是一个 case。然而,注意例子中的两种实现还是有一处不同的:

  • 重载函数的版本遵循 最佳匹配 语义。
  • 编译期if的版本遵循 最先匹配 语义。

另一个类型分发的例子是使用编译期if实现get<>()重载 来实现结构化绑定接口。

第三个例子是在用作std::variant<>访问器 的泛型lambda中处理不同的类型。

10.3 带初始化的编译期if语句

注意编译期if语句也可以使用新的带初始化的形式。 例如,如果有一个constexpr函数foo(),你可以这样写:

  1. template<typename T>
  2. void bar(const T x)
  3. {
  4. if constexpr (auto obj = foo(x); std::is_same_v<decltype(obj), T>) {
  5. std::cout << "foo(x) yields same type\n";
  6. ...
  7. }
  8. else {
  9. std::cout << "foo(x) yields different type\n";
  10. ...
  11. }
  12. }

如果有一个参数类型也为Tconstexpr函数foo(), 你就可以根据foo(x)是否返回与x相同的类型来进行不同的处理。

如果要根据foo(x)返回的值来进行判定,那么可以写:

  1. constexpr auto c = ...;
  2. if constexpr (constexpr auto obj = foo(c); obj == 0) {
  3. std::cout << "foo() == 0\n";
  4. ...
  5. }

注意如果想在条件语句中使用obj的值, 那么obj必须要声明为constexpr

10.4 在模板之外使用编译期if

if constexpr可以在任何函数中使用,而并非仅限于模板。 只要条件表达式是编译期的,并且可以转换成bool类型。 然而,在普通函数里使用时 thenelse 部分的所有语句都必须有效, 即使有可能被丢弃。

例如,下面的代码不能通过编译,因为undeclared()的调用必须是有效的, 即使char是有符号数导致 else 部分被丢弃也一样:

  1. #include <limits>
  2. template<typename T>
  3. void foo(T t);
  4. int main()
  5. {
  6. if constexpr(std::numeric_limits<char>::is_signed) {
  7. foo(42); // OK
  8. }
  9. else {
  10. undeclared(42); // 未声明时总是ERROR(即使被丢弃)
  11. }
  12. }

下面的代码也永远不能成功编译,因为总有一个静态断言会失败:

  1. if constexpr(std::numeric_limits<char>::is_signed) {
  2. static_assert(std::numeric_limits<char>::is_signed);
  3. }
  4. else {
  5. static_assert(!std::numeric_limits<char>::is_signed);
  6. }

在泛型代码之外使用编译期if的唯一好处是被丢弃的部分不会成为最终程序的一部分, 这将减小生成的可执行程序的大小。例如,在如下程序中:

  1. #include <limits>
  2. #include <string>
  3. #include <array>
  4. int main()
  5. {
  6. if (!std::numeric_limits<char>::is_signed) {
  7. static std::array<std::string, 1000> arr1;
  8. ...
  9. }
  10. else {
  11. static std::array<std::string, 1000> arr2;
  12. ...
  13. }
  14. }

要么arr1要么arr2会成为最终可执行程序的一部分, 但不可能两者都是。