第19章 static_assert声明(C++11 C++17)

在静态断言出现以前,我们使用的是运行时断言,只有程序运行起来之后才有可能触发它。通常情况下运行时断言只会在Debug模式下使用,因为断言的行为比较粗暴,它会直接显示错误信息并终止程序。在Release版本中,我们通常会忽略断言(头文件cassert已经通过宏NDEBUG对Debug和Release版本做了区分处理,我们可以直接使用assert)。还有一点需要注意,断言不能代替程序中的错误检查,它只应该出现在需要表达式返回true的位置,例如:算术表达式的除数不能为0,分配内存的大小必须大于0等。相反,如果表达式中涉及外部输入,则不应该依赖断言,例如客户输入、服务端返回等:

void* resize_buffer(void* buffer, int new_size)
{
    assert(buffer != nullptr);           // OK,用assert检查函数参数
    assert(new_size > 0);
    assert(new_size <= MAX_BUFFER_SIZE);
    …
}

bool get_user_input(char c)
{
    assert(c == '\0x0d');                // 不合适,assert不应该用于检查外部输入
    …
}

在上面这段代码中,我们对函数resize_buffer的形参buffernew_size进行了断言,显然作为一个重新分配内存的函数,这两个参数必须是合法的。建议一个断言处理一个判别式,这样一来当断言发生的时候能迅速定位到问题所在。如果写成assert((buffer != nullptr) && (new_size > 0) && (new_size <= MAX_BUFFER_SIZE)),则当断言发生的时候,我们还是无法马上确定问题。而函数get_user_input就不应该使用断言检查参数了,因为用户输入的字符可能是各种各样的。

虽然运行时断言可以满足一部分需求,但是它有一个缺点就是必须让程序运行到断言代码的位置才会触发断言。如果想在模板实例化的时候对模板实参进行约束,这种断言是无法办到的。我们需要一个能在编译阶段就给出断言的方法。可惜在C++11标准之前,没有一个标准方法来达到这个目的,我们需要利用其他特性来模拟。下面给出几个可行的方案:

#define STATIC_ASSERT_CONCAT_IMP(x, y) x ## y
#define STATIC_ASSERT_CONCAT(x, y) \
    STATIC_ASSERT_CONCAT_IMP(x, y)

// 方案1
#define STATIC_ASSERT(expr)                 \
    do {                                    \
        char STATIC_ASSERT_CONCAT(          \
            static_assert_var, __COUNTER__) \
            [(expr) != 0 ? 1 : -1];         \
    } while (0)

template<bool>
struct static_assert_st;
template<>
struct static_assert_st<true> {};

// 方案2
#define STATIC_ASSERT2(expr)    \
    static_assert_st<(expr) != 0>()

// 方案3
#define STATIC_ASSERT3(expr)        \
    static_assert_st<(expr) != 0>   \
    STATIC_ASSERT_CONCAT(           \
    static_assert_var, __COUNTER__)

以上代码的方案1,利用的技巧是数组的大小不能为负值,当expr表达式返回结果为false的时候,条件表达式求值为−1,这样就导致数组大小为−1,自然就会引发编译失败。方案2和方案3则是利用了C++模板特化的特性,当模板实参为true的时候,编译器能找到特化版本的定义。但当模板参数为false的时候,编译器无法找到相应的特化定义,从而编译失败。方案2和方案3的区别在于,方案2会构造临时对象,这让它无法出现在类和结构体的定义当中。而方案3则声明了一个变量,可以出现在结构体和类的定义中,但是它最大的问题是会改变结构体和类的内存布局。总而言之,虽然我们可以在一定程度上模拟静态断言,但是这些方案并不完美。

static_assert声明是C++11标准引入的特性,用于在程序编译阶段评估常量表达式并对返回false的表达式断言,我们称这种断言为静态断言。它基本上满足我们对静态断言的要求。

1.所有处理必须在编译期间执行,不允许有空间或时间上的运行时成本。

2.它必须具有简单的语法。

3.断言失败可以显示丰富的错误诊断信息。

4.它可以在命名空间、类或代码块内使用。

5.失败的断言会在编译阶段报错。

C++11标准规定,使用static_assert需要传入两个实参:常量表达式和诊断消息字符串。请注意,第一个实参必须是常量表达式,因为编译器无法计算运行时才能确定结果的表达式:

#include <type_traits>

class A {
};

class B : public A {
};

class C {
};

template<class T>
class E {
  static_assert(std::is_base_of<A, T>::value, "T is not base of A");
};

int main(int argc, char *argv[])
{
    static_assert(argc > 0, "argc > 0");  // 使用错误,argc>0不是常量表达式
    E<C> x;                         // 使用正确,但由于A不是C的基类,所以触发断言
    static_assert(sizeof(int) >= 4, // 使用正确,表达式返回真,不会触发失败断言
        "sizeof(int) >= 4");
    E<B> y;                         // 使用正确,A是B的基类,不会触发失败断言
}

在上面的代码中,argc > 0依赖于用户输入的参数,显然不是一个常量表达式。在这种情况下,编译器会报错,符合上面的第5条要求。类模板Estatic_ assert的使用是正确的,根据第1条和第4条要求,static_assert可以在类定义里使用并且不会改变类的内部状态。只不过在实例化类模板E<C>的时候,因为A不是C的基类,所以会触发静态断言,导致编译中断。

不知道读者是否和我有同样的想法,在大多数情况下使用static_assert的时候输入的诊断信息字符串就是常量表达式本身,所以让常量表达式作为诊断信息字符串参数的默认值是非常理想的。为了达到这个目的,我们可以定义一个宏:

#define LAZY_STATIC_ASSERT(B) static_assert(B, #B)

可能是该需求比较普遍的原因,2014年2月C++标准委员会就提出升级static_ assert的想法,希望让其支持单参数版本,即常量表达式,而断言输出的诊断信息为常量表达式本身。这个观点提出后得到了大多数人的认同,但是由于2014年2月C++14标准已经发布了,因此该特性不得不顺延到C++17标准中。在支持C++17标准的环境中,我们可以忽略第二个参数:

#include <type_traits>

class A {
};

class B : public A {
};

class C {
};

template<class T>
class E {
  static_assert(std::is_base_of<A, T>::value);
};

int main(int argc, char *argv[])
{
  E<C> x;                         // 使用正确,但由于A不是C的基类,会触发失败断言
  static_assert(sizeof(int) < 4); // 使用正确,但表达式返回false,会触发失败断言
}

不过在GCC上,即使指定使用C++11标准,GCC依然支持单参数的static_assert。MSVC则不同,要使用单参数的static_assert需要指定C++17标准。

静态断言并不是一个新鲜的概念,早在C++11标准出现之前,boostloki等代码库就已经采用很多变通的办法实现了静态断言的部分功能。之所以这些代码库都会实现静态断言,主要是因为该特性可以将错误排查的工作前置到编译时,这对于程序员来说是非常友好的。C++11以及后来的C++17标准引入的static_assert完美地满足了静态断言的各种需求,当断言表达式是常量表达式的时候,我们应该优先使用static_assert静态断言。