第27章 常量表达式(C++11~C++20)

在C++11标准以前,我们没有一种方法能够有效地要求一个变量或者函数在编译阶段就计算出结果。由于无法确保在编译阶段得出结果,导致很多看起来合理的代码却引来编译错误。这些场景主要集中在需要编译阶段就确定的值语法中,比如case语句、数组长度、枚举成员的值以及非类型的模板参数。让我们先看一看这些场景的代码:

const int index0 = 0;
#define index1 1

// case语句
switch (argc)
{
case  index0:
    std::cout << "index0" << std::endl;
    break;
case index1:
    std::cout << "index1" << std::endl;
    break;
default:
    std::cout << "none" << std::endl;
}

const int x_size = 5 + 8;
#define y_size 6 + 7
// 数组长度
char buffer[x_size][y_size] = { 0 };

// 枚举成员
enum {
    enum_index0 = index0,
    enum_index1 = index1,
};

std::tuple<int, char> tp = std::make_tuple(4, '3');
// 非类型的模板参数
int x1 = std::get<index0>(tp);
char x2 = std::get<index1>(tp);

在上面的代码中,const定义的常量和宏都能在要求编译阶段确定值的语句中使用。其中宏在编译之前的预处理阶段就被替换为定义的文字。而对于const定义的常量,上面这种情况下编译器能在编译阶段确定它们的值,并在case语句以及数组长度等语句中使用。让人遗憾的是上面这些方法并不可靠。首先,C++程序员应该尽量少使用宏,因为预处理器对于宏只是简单的字符替换,完全没有类型检查,而且宏使用不当出现的错误难以排查。其次,对const定义的常量可能是一个运行时常量,这种情况下是无法在case语句以及数组长度等语句中使用的。让我们稍微修改一下上面的代码:

int get_index0()
{
    return 0;
}

int get_index1()
{
    return 1;
}

int get_x_size()
{
    return 5 + 8;
}

int get_y_size()
{
    return 6 + 7;
}

const int index0 = get_index0();
#define index1 get_index1()

switch (argc)
{
case  index0:
    std::cout << "index0" << std::endl;
    break;
case index1:
    std::cout << "index1" << std::endl;
    break;
default:
    std::cout << "none" << std::endl;
}

const int x_size = get_x_size();
#define y_size get_y_size()
char buffer[x_size][y_size] = { 0 };

enum {
    enum_index0 = index0,
    enum_index1 = index1,
};

std::tuple<int, char> tp = std::make_tuple(4, '3');
int x1 = std::get<index0>(tp);
char x2 = std::get<index1>(tp);

我们这里做的修改仅仅是将宏定义为一个函数调用以及用一个函数将const变量进行初始化,但是编译这段代码时会发现已经无法通过编译了。因为,无论是宏定义的函数调用,还是通过函数返回值初始化const变量都是在运行时确定的。

像上面这种尴尬的情况不仅可能出现在我们的代码中,实际上标准库中也有这样的情况,其中<limits>就是一个典型的例子。在C语言中存在头文件<limits.h>,在这个头文件中用宏定义了各种整型类型的最大值和最小值,比如:

#define UCHAR_MAX     0xff  // unsigned char类型的最大值

我们可以用这些宏代替数字,让代码有更好的可读性。这其中就包括要求编译阶段必须确定值的语句,例如定义一个数组:

char buffer[UCHAR_MAX] = { 0 };

代码编译起来没有任何障碍。但是正如上文中提到的,C++程序员应该尽量避开宏。标准库为我们提供了一个<limits>,使用它同样能获得unsigned char类型的最大值:

std::numeric_limits<unsigned char>::max()

但是,如果想用它来声明数组的大小是无法编译成功的:

char buffer[std::numeric_limits<unsigned char>::max()] = {0};

原因和之前讨论过的一样,std::numeric_limits<unsigned char>:: max()函数的返回值必须在运行时计算。

为了解决以上常量无法确定的问题,C++标准委员会决定在C++11标准中定义一个新的关键字constexpr,它能够有效地定义常量表达式,并且达到类型安全、可移植、方便库和嵌入式系统开发的目的。

constexpr值即常量表达式值,是一个用constexpr说明符声明的变量或者数据成员,它要求该值必须在编译期计算。另外,常量表达式值必须被常量表达式初始化。定义常量表达式值的方法非常简单,例如:

constexpr int x = 42;
char buffer[x] = { 0 };

以上代码定义了一个常量表达式值x,并将其初始化为42,然后用x作为数组长度定义了数组buffer。从这段代码来看,constexprconst是没有区别的,我们将关键字替换为const同样能达到目的:

const int x = 42;
char buffer[x] = { 0 };

从结果来看确实如此,在使用常量表达式初始化的情况下constexprconst拥有相同的作用。但是const并没有确保编译期常量的特性,所以在下面的代码中,它们会有不同的表现:

int x1 = 42;
const int x2 = x1;            // 定义和初始化成功
char buffer[x2] = { 0 };      // 编译失败,x2无法作为数组长度

在上面这段代码中,虽然x2初始化编译成功,但是编译器并不一定把它作为一个编译期需要确定的值,所以在声明buffer的时候会编译错误。注意,这里我说的是不一定,因为并没有人规定编译期应该怎么处理这种情况。比如在GCC中,这段代码可以编译成功,但是MSVC和CLang则会编译失败。如果把const替换为constexpr,会有不同的情况发生:

int x1 = 42;
constexpr int x2 = x1;        // 编译失败,x2无法用x1初始化
char buffer[x2] = { 0 };

修改后,编译器编译第二句代码的时候就会报错,因为常量表达式值必须由常量表达式初始化,而x1并不是常量,明确地违反了constexpr的规则,编译器自然就会报错。可以看出,constexpr是一个加强版的const,它不仅要求常量表达式是常量,并且要求是一个编译阶段就能够确定其值的常量。

constexpr不仅能用来定义常量表达式值,还能定义一个常量表达式函数,即constexpr函数,常量表达式函数的返回值可以在编译阶段就计算出来。不过在定义常量表示函数的时候,我们会遇到更多的约束规则(在C++14和后续的标准中对这些规则有所放宽)。

1.函数必须返回一个值,所以它的返回值类型不能是void

2.函数体必须只有一条语句:return expr,其中expr必须也是一个常量表达式。如果函数有形参,则将形参替换到expr中后,expr仍然必须是一个常量表达式。

3.函数使用之前必须有定义。

4.函数必须用constexpr声明。

让我们来看一看下面这个例子:

constexpr int max_unsigned_char()
{
  return 0xff;
}

constexpr int square(int x)
{
  return x * x;
}

constexpr int abs(int x)
{
  return x > 0 ? x : -x;
}

int main()
{
  char buffer1[max_unsigned_char()] = { 0 };
  char buffer2[square(5)] = { 0 };
  char buffer3[abs(-8)] = { 0 };
}

上面的代码定义了3个常量表达式函数,由于它们的返回值能够在编译期计算出来,因此可以直接将这些函数的返回值使用在数组长度的定义上。需要注意的是squareabs两个函数,它们接受一个形参x,当x确定为一个常量时(这里分别是5−8),其常量表达式函数也就成立了。我们通过abs可以发现一个小技巧,由于标准规定函数体中只能有一个表达式return expr,因此是无法使用if语句的,幸运的是用条件表达式也能完成类似的效果。

接着让我们看一看反例:

constexpr void foo()
{
}

constexpr int next(int x)
{
  return ++x;
}

int g()
{
  return 42;
}

constexpr int f()
{
  return g();
}

constexpr int max_unsigned_char2();
enum {
  max_uchar = max_unsigned_char2()
}

constexpr int abs2(int x)
{
  if (x > 0) {
       return x;
  } else {
       return -x;
  }
}

constexpr int sum(int x)
{
  int result = 0;
  while (x > 0)
  {
       result += x--;
  }
  return result;
}

以上constexpr函数都会编译失败。其中函数foo的返回值不能为voidnext函数体中的++xf中的g()都不是一个常量表达式,函数max_unsigned_ char2只有声明没有定义,函数abs2sum不能有多条语句。我们注意到abs2if语句可以用条件表达式替换,可是sum函数这样的循环结构有办法替换为单语句吗?答案是可以的,我们可以使用递归来完成循环的操作,现在就来重写sum函数:

constexpr int sum(int x)
{
  return x > 0 ? x + sum(x - 1) : 0;
}

以上函数比较容易理解,当x大于0时,将xsum(x−1)相加,直到sum的参数为0。由于这里sum本身被声明为常量表达式函数,因此整个返回语句也是一个常量表达式,遵守了常量表达式的规则。于是我们能通过递归调用sum函数完成循环计算的任务。有趣的是,在刚开始提出常量表达式函数的时候,有些C++专家认为这种函数不应该支持递归调用,但是最终标准还是确定支持了递归调用。

需要强调一点的是,虽然常量表达式函数的返回值可以在编译期计算出来,但是这个行为并不是确定的。例如,当带形参的常量表达式函数接受了一个非常量实参时,常量表达式函数可能会退化为普通函数:

constexpr int square(int x)
{
  return x * x;
}

int x = 5;
std::cout << square(x);

这里由于x不是一个常量,因此square的返回值也可能无法在编译期确定,但是它依然能成功编译运行,因为该函数退化成了一个普通函数。这种退化机制对于程序员来说是非常友好的,它意味着我们不用为了同时满足编译期和运行期计算而定义两个相似的函数。另外,这里也存在着不确定性,因为GCC依然能在编译阶段计算square的结果,但是MSVC和CLang则不行。

有了常量表达式函数的支持,C++标准对STL也做了一些改进,比如在<limits>中增加了constexpr声明,正因如此下面的代码也可以顺利编译成功了:

char buffer[std::numeric_limits<unsigned char>::max()] = { 0 };

constexpr可以声明基础类型从而获得常量表达式值,除此之外constexpr还能够声明用户自定义类型,例如:

struct X {
  int x1;
};

constexpr X x = { 1 };
char buffer[x.x1] = { 0 };

以上代码自定义了一个结构体X,并且使用constexpr声明和初始化了变量x。到目前为止一切顺利,不过有时候我们并不希望成员变量被暴露出来,于是修改了X的结构:

class X {
public:
  X() : x1(5) {}
  int get() const
  {
       return x1;
  }
private:
  int x1;
};

constexpr X x;                    // 编译失败,X不是字面类型
char buffer[x.get()] = { 0 };     // 编译失败,x.get()无法在编译阶段计算

经过修改的代码不能通过编译了,因为constexpr说明符不能用来声明这样的自定义类型。解决上述问题的方法很简单,只需要用constexpr声明X类的构造函数,也就是声明一个常量表达式构造函数,当然这个构造函数也有一些规则需要遵循。

1.构造函数必须用constexpr声明。

2.构造函数初始化列表中必须是常量表达式。

3.构造函数的函数体必须为空(这一点基于构造函数没有返回值,所以不存在return expr)。

根据以上规则让我们改写类X

class X {
public:
  constexpr X() : x1(5) {}
  constexpr X(int i) : x1(i) {}
  constexpr int get() const
  {
       return x1;
  }
private:
  int x1;
};

constexpr X x;
char buffer[x.get()] = { 0 };

上面这段代码只是简单地给构造函数和get函数添加了constexpr说明符就可以编译成功,因为它们本身都符合常量表达式构造函数和常量表达式函数的要求,我们称这样的类为字面量类类型(literal class type)。其实代码中constexpr int get()constconst有点多余,因为在C++11中,constexpr会自动给函数带上const属性。请注意,常量表达式构造函数拥有和常量表达式函数相同的退化特性,当它的实参不是常量表达式的时候,构造函数可以退化为普通构造函数,当然,这么做的前提是类型的声明对象不能为常量表达式值:

int i = 8;
constexpr X x(i);     // 编译失败,不能使用constexpr声明
X y(i);               // 编译成功

由于i不是一个常量,因此X的常量表达式构造函数退化为普通构造函数,这时对象x不能用constexpr声明,否则编译失败。

最后需要强调的是,使用constexpr声明自定义类型的变量,必须确保这个自定义类型的析构函数是平凡的,否则也是无法通过编译的。平凡析构函数必须满足下面3个条件。

1.自定义类型中不能有用户自定义的析构函数。

2.析构函数不能是虚函数。

3.基类和成员的析构函数必须都是平凡的。

constexpr说明符被引入之前,C++程序员经常使用enum hack来促使编译器在编译阶段计算常量表达式的值。但是因为enum只能操作整型,所以一直无法完成对于浮点类型的编译期计算。constexpr说明符则不同,它支持声明浮点类型的常量表达式值,而且标准还规定其精度必须至少和运行时的精度相同,例如:

constexpr double sum(double x)
{
  return x > 0 ? x + sum(x - 1) : 0;
}

constexpr double x = sum(5);

C++11标准对常量表达式函数的要求可以说是非常的严格,这一点影响该特性的实用性。幸好这个问题在C++14中得到了非常巨大的改善,C++14标准对常量表达式函数的改进如下。

1.函数体允许声明变量,除了没有初始化、staticthread_local变量。

2.函数允许出现ifswitch语句,不能使用go语句。

3.函数允许所有的循环语句,包括forwhiledo-while

4.函数可以修改生命周期和常量表达式相同的对象。

5.函数的返回值可以声明为void

6.constexpr声明的成员函数不再具有const属性。

因为这些改进的发布,在C++11中无法成功编译的常量表达式函数,在C++14中可以编译成功了:

constexpr int abs(int x)
{
  if (x > 0) {
      return x;
  } else {
      return -x;
  }
}

constexpr int sum(int x)
{
  int result = 0;
  while (x > 0)
  {
      result += x--;
  }
  return result;
}

char buffer1[sum(5)] = { 0 };
char buffer2[abs(-5)] = { 0 };

以上代码中的abssum函数相比于前面使用条件表达式和递归方法实现的函数更加容易阅读和理解了。看到这里读者是否会有一些兴奋,但是别急,后面还有好戏:

constexpr int next(int x)
{
  return ++x;
}

char buffer[next(5)] = { 0 };

这里我们惊喜地发现,原来由于++x不是常量表达式,因此无法编译通过的问题也消失了,这就是基于第4点规则。需要强调的是,对于常量表达式函数的增强同样也会影响常量表达式构造函数:

#include <iostream>

class X {
public:
  constexpr X() : x1(5) {}
  constexpr X(int i) : x1(0)
  {
      if (i > 0) {
          x1 = 5;
      }
      else {
          x1 = 8;
      }
  }
  constexpr void set(int i)
  {
      x1 = i;
  }
  constexpr int get() const
  {
      return x1;
  }
private:
  int x1;
};

constexpr X make_x()
{
  X x;
  x.set(42);
  return x;
}

int main()
{
  constexpr X x1(-1);
  constexpr X x2 = make_x();
  constexpr int a1 = x1.get();
  constexpr int a2 = x2.get();
  std::cout << a1 << std::endl;
  std::cout << a2 << std::endl;
}

请注意,main函数里的4个变量x1x2a1a2都有constexpr声明,也就是说它们都是编译期必须确定的值。有了这个前提条件,我们再来分析这段代码的神奇之处。首先对于常量表达式构造函数,我们发现可以在其函数体内使用if语句并且对x1进行赋值操作了。可以看到返回类型为voidset函数也被声明为constexpr了,这也意味着该函数能够运用在constexpr声明的函数体内,make_x函数就是利用了这个特性。根据规则4和规则6,set函数也能成功地修改x1的值了。让我们来看一看GCC生成的中间代码:

main ()
{
  int D.39319;

  {
    const struct X x1;
    const struct X x2;
    const int a1;
    const int a2;

    try
      {
        x1.x1 = 8;
        x2.x1 = 42;
        a1 = 8;
        a2 = 42;
        _1 = std::basic_ostream<char>::operator<< (&cout, 8);
        std::basic_ostream<char>::operator<< (_1, endl);
        _2 = std::basic_ostream<char>::operator<< (&cout, 42);
        std::basic_ostream<char>::operator<< (_2, endl);
      }
    finally
      {
        x1 = {CLOBBER};
        x2 = {CLOBBER};
      }
  }
  D.39319 = 0;
  return D.39319;
}

从上面的中间代码可以清楚地看到,编译器直接给x1.x1x2.x1a1a2进行了赋值,并没有运行时的计算操作。

最后需要指出的是,C++14标准除了在常量表达式函数特性方面做了增强,也在标准库方面做了增强,包括<complex><chrono><array><initializer_list><utility><tuple>。对于标准库的增强细节这里就不做介绍了,大家可以直接参阅STL源代码。

从C++17开始,lambda表达式在条件允许的情况下都会隐式声明为constexpr。这里所说的条件,即是上一节中提到的常量表达式函数的规则,本节里就不再重复论述。结合lambda的这个新特性,先看一个简单的例子:

constexpr int foo()
{
  return []() { return 58; }();
}

auto get_size = [](int i) { return i * 2; };
char buffer1[foo()] = { 0 };
char buffer2[get_size(5)] = { 0 };

可以看到,以上代码定义的是一个“普通”的lambda表达式,但是在C++17标准中,这些“普通”的lambda表达式却可以用在常量表达式函数和数组长度中,可见该lambda表达式的结果在编译阶段已经计算出来了。实际上这里的[](int i) { return i * 2; }相当于:

class GetSize {
public:
  constexpr int operator() (int i) const {
       return i * 2;
  }
};

lambda表达式不满足constexpr的条件时,lambda表达式也不会出现编译错误,它会作为运行时lambda表达式存在:

// 情况1
int i = 5;
auto get_size = [](int i) { return i * 2; };
char buffer1[get_size(i)] = { 0 };          // 编译失败,get_size需要运行时调用
int a1 = get_size(i);

// 情况2
auto get_count = []() {
  static int x = 5;
  return x;
};
int a2 = get_count();

以上代码中情况1和常量表达式函数相同,get_size可能会退化为运行时lambda表达式对象。当这种情况发生的时候,get_size的返回值不再具有作为数组长度的能力,但是运行时调用get_size对象还是没有问题的。GCC在这种情况下依然能够在编译阶段求出get_size的值,MSVC和CLang则不行。对于情况2,由于static变量的存在,lambda表达式对象get_count不可能在编译期运算,因此它最终会在运行时计算。

值得注意的是,我们也可以强制要求lambda表达式是一个常量表达式,用constexpr去声明它即可。这样做的好处是可以检查lambda表达式是否有可能是一个常量表达式,如果不能则会编译报错,例如:

auto get_size = [](int i) constexpr -> int { return i * 2; };
char buffer2[get_size(5)] = { 0 };

auto get_count = []() constexpr -> int {
  static int x = 5;                   // 编译失败,x是一个static变量
  return x;
};
int a2 = get_count();

在C++17标准中,constexpr声明静态成员变量时,也被赋予了该变量的内联属性,例如:

class X {
public:
  static constexpr int num{ 5 };
};

以上代码在C++17中等同于:

class X {
public:
  inline static constexpr int num{ 5 };
};

那么问题来了,自C++11标准推行以来static constexpr int num{ 5 }这种用法就一直存在了,那么同样的代码在C++11和C++17中究竟又有什么区别呢?

class X {
public:
  static constexpr int num{ 5 };
};

代码中,num是只有声明没有定义的,虽然我们可以通过std::cout << X::num << std::endl输出其结果,但这实际上是编译器的一个小把戏,它将X::num直接替换为了5。如果将输出语句修改为std::cout << &X::num << std::endl,那么链接器会明确报告X::num缺少定义。但是从C++17开始情况发生了变化,static constexpr int num{5}既是声明也是定义,所以在C++17标准中std::cout << &X::num << std::endl可以顺利编译链接,并且输出正确的结果。值得注意的是,对于编译器而言为X::num产生定义并不是必需的,如果代码只是引用了X::num的值,那么编译器完全可以使用直接替换为值的技巧。只有当代码中引用到变量指针的时候,编译器才会为其生成定义。

if constexpr是C++17标准提出的一个非常有用的特性,可以用于编写紧凑的模板代码,让代码能够根据编译时的条件进行实例化。这里有两点需要特别注意。

1.if constexpr的条件必须是编译期能确定结果的常量表达式。

2.条件结果一旦确定,编译器将只编译符合条件的代码块。

由此可见,该特性只有在使用模板的时候才具有实际意义,若是用在普通函数上,效果会非常尴尬,比如:

void check1(int i)
{
  if constexpr (i > 0) {                        // 编译失败,不是常量表达式
       std::cout << "i > 0" << std::endl;
  }
  else {
       std::cout << "i <= 0" << std::endl;
  }
}

void check2()
{
  if constexpr (sizeof(int) > sizeof(char)) {
       std::cout << "sizeof(int) > sizeof(char)" << std::endl;
  }
  else {
       std::cout << "sizeof(int) <= sizeof(char)" << std::endl;
  }
}

对于函数check1,由于if constexpr的条件不是一个常量表达式,因此无法编译通过。而对于函数check2,这里的代码最后会被编译器省略为:

void check2()
{
  std::cout << "sizeof(int) > sizeof(char)" << std::endl;
}

但是当if constexpr运用于模板时,情况将非常不同。来看下面的例子:

#include <iostream>

template<class T> bool is_same_value(T a, T b)
{
  return a == b;
}

template<> bool is_same_value<double>(double a, double b)
{
  if (std::abs(a - b) < 0.0001) {
       return true;
  }
  else {
       return false;
  }
}

int main()
{
  double x = 0.1 + 0.1 + 0.1 - 0.3;
  std::cout << std::boolalpha;
  std::cout << "is_same_value(5, 5)  : " << is_same_value(5, 5) << std::endl;
  std::cout << "x == 0.0               : " << (x == 0.) << std::endl;
  std::cout << "is_same_value(x, 0.) : " << is_same_value(x, 0.) << std::endl;
}

计算结果如下:

is_same_value(5, 5)    : true
x == 0.0               : false
is_same_value(x, 0.)   : true

我们知道浮点数的比较和整数是不同的,通常情况下它们的差小于某个阈值就认为两个浮点数相等。我们把is_same_value写成函数模板,并且对double类型进行特化。这里如果使用if constexpr表达式,代码会简化很多而且更加容易理解,让我们看一看简化后的代码:

#include <type_traits>
template<class T> bool is_same_value(T a, T b)
{
  if constexpr (std::is_same<T, double>::value) {
       if (std::abs(a - b) < 0.0001) {
            return true;
       }
       else {
            return false;
       }
  }
  else {
       return a == b;
  }
}

在上面这段代码中,直接使用if constexpr判断模板参数是否为double,如果条件成立,则使用double的比较方式;否则使用普通的比较方式,代码变得简单明了。再次强调,这里的选择是编译期做出的,一旦确定了条件,那么就只有被选择的代码块才会被编译;另外的代码块则会被忽略。说到这里,需要提醒读者注意这样一种陷阱:

#include <iostream>
#include <type_traits>
template<class T> auto minus(T a, T b)
{
  if constexpr (std::is_same<T, double>::value) {
       if (std::abs(a - b) < 0.0001) {
            return 0.;
       }
       else {
            return a - b;
       }
  }
  else {
       return static_cast<int>(a - b);
  }
}

int main()
{
  std::cout << minus(5.6, 5.11) << std::endl;
  std::cout << minus(5.60002, 5.600011) << std::endl;
  std::cout << minus(6, 5) << std::endl;
}

以上是一个带精度限制的减法函数,当参数类型为double且计算结果小于0.0001的时候,我们就可以认为计算结果为0。当参数类型为整型时,则不用对精度做任何限制。上面的代码编译运行没有任何问题,因为编译器根据不同的类型选择不同的分支进行编译。但是如果修改一下上面的代码,结果可能就很难预料了:

template<class T> auto minus(T a, T b)
{
  if constexpr (std::is_same<T, double>::value) {
       if (std::abs(a - b) < 0.0001) {
            return 0.;
       }
       else {
            return a - b;
       }
  }
  return static_cast<int>(a - b);
}

上面的代码删除了else关键词而直接将else代码块提取出来,不过根据以往运行时if的经验,它并不会影响代码运行的逻辑。遗憾的是,这种写法有可能导致编译失败,因为它可能会导致函数有多个不同的返回类型。当实参为整型时一切正常,编译器会忽略if的代码块,直接编译return static_cast<int>(a − b),这样返回类型只有int一种。但是当实参类型为double的时候,情况发生了变化。if的代码块会被正常地编译,代码块内部的返回结果类型为double,而代码块外部的return static_cast<int>(a − b)同样会照常编译,这次的返回类型为int。编译器遇到了两个不同的返回类型,只能报错。

和运行时if的另一个不同点:if constexpr不支持短路规则。这在程序编写时往往也能成为一个陷阱:

#include <iostream>
#include <string>
#include <type_traits>

template<class T> auto any2i(T t)
{
  if constexpr (std::is_same<T, std::string>::value && T::npos == -1) {
       return atoi(t.c_str());
  }
  else {
       return t;
  }
}

int main()
{
  std::cout << any2i(std::string("6")) << std::endl;
  std::cout << any2i(6) << std::endl;
}

上面的代码很好理解,函数模板any2i的实参如果是一个std::string,那么它肯定满足std::is_same<T, std::string>::value && T::npos == −1的条件,所以编译器会编译if分支的代码。如果实参类型是一个int,那么std::is_same<T, std::string>::value会返回false,根据短路规则,if代码块不会被编译,而是编译else代码块的内容。一切看起来是那么简单直接,但是编译过后会发现,代码std::cout << any2i(std:: string("6")) << std::endl顺利地编译成功,std::cout << any2i(6) << std::endl则会编译失败,因为if constexpr不支持短路规则。当函数实参为int时,std::is_same<T, std::string>::valueT::npos == −1都会被编译,由于int::npos显然是一个非法的表达式,因此会造成编译失败。这里正确的写法是通过嵌套if constexpr来替换上面的操作:

template<class T> auto any2i(T t)
{
  if constexpr (std::is_same<T, std::string>::value) {
       if  constexpr(T::npos == -1) {
            return atoi(t.c_str());
       }
  }
  else {
       return t;
  }
}

在C++20标准之前,虚函数是不允许声明为constexpr的。看似有道理的规则其实并不合理,因为虚函数很多时候可能是无状态的,这种情况下它是有条件作为常量表达式被优化的,比如下面这个函数:

struct X
{
    virtual int f() const { return 1; }
};

 int main() {
    X x;
    int i = x.f();
 }

上面的代码会先执行X::f函数,然后将结果赋值给i,它的GIMPLE中间的代码如下:

main ()
{
  int D.2137;

  {
    struct X x;
    int i;

    try
      {
        _1 = &_ZTV1X + 16;
        x._vptr.X = _1;
        i = X::f (&x); // 注意此处赋值
      }
    finally
      {
        x = {CLOBBER};
      }
  }
  D.2137 = 0;
  return D.2137;
}

X::f (const struct X * const this)
{
  int D.2139;

  D.2139 = 1;
  return D.2139;
}

观察上面的两份代码,虽然X::f是一个虚函数,但是它非常适合作为常量表达式进行优化。这样一来,int i = x.f();可以被优化为int i = 1;,减少一次函数的调用过程。可惜在C++17标准中不允许我们这么做,直到C++20标准明确允许在常量表达式中使用虚函数,所以上面的代码可以修改为:

struct X
{
  constexpr virtual int f() const { return 1; }
};

int main() {
  constexpr X x;
  int i = x.f();
}

它的中间代码也会优化为:

main ()
{
  int D.2138;

  {
    const struct X x;
    int i;

    try
      {
        _1 = &_ZTV1X + 16;
        x._vptr.X = _1;
        i = 1; // 注意此处赋值
      }
    finally
      {
        x = {CLOBBER};
      }
  }
  D.2138 = 0;
  return D.2138;
}

从中间代码中可以看到,i被直接赋值为1,在此之前并没有调用X::f函数。另外值得一提的是,constexpr的虚函数在继承重写上并没有其他特殊的要求,constexpr的虚函数可以覆盖重写普通虚函数,普通虚函数也可以覆盖重写constexpr的虚函数,例如:

struct X1
{
    virtual int f() const = 0;
};

struct X2: public X1
{
    constexpr virtual int f() const { return 2; }
};

struct X3: public X2
{
    virtual int f() const { return 3; }
};

struct X4: public X3
{
    constexpr virtual int f() const { return 4; }
};

constexpr int (X1::*pf)() const = &X1::f;

constexpr X2 x2;
static_assert( x2.f() == 2 );
static_assert( (x2.*pf)() == 2 );

constexpr X1 const& r2 = x2;
static_assert( r2.f() == 2 );
static_assert( (r2.*pf)() == 2 );

constexpr X1 const* p2 = &x2;
static_assert( p2->f() == 2 );
static_assert( (p2->*pf)() == 2 );

constexpr X4 x4;
static_assert( x4.f() == 4 );
static_assert( (x4.*pf)() == 4 );

constexpr X1 const& r4 = x4;
static_assert( r4.f() == 4 );
static_assert( (r4.*pf)() == 4 );

constexpr X1 const* p4 = &x4;
static_assert( p4->f() == 4 );
static_assert( (p4->*pf)() == 4 );

最后要说明的是,我在验证这条规则时,GCC无论在C++17还是C++20标准中都可以顺利编译通过,而CLang在C++17中会给出constexpr无法用于虚函数的错误提示。

在C++20标准以前Try-catch是不能出现在constexpr函数中的,例如:

constexpr int f(int x)
{
  try { return x + 1; }
  catch (…) { return 0; }
}

不过似乎编译器对此规则的态度都十分友好,当我们用C++17标准去编译这份代码时,编译器会编译成功并给出一个友好的警告,说明这条特性需要使用C++20标准。C++20标准允许Try-catch存在于constexpr函数,但是throw语句依旧是被禁止的,所以try语句是不能抛出异常的,这也就意味着catch永远不会执行。实际上,当函数被评估为常量表达式的时候Try-catch是没有任何作用的。

从C++20开始,标准允许在constexpr中进行平凡的默认初始化,这样进一步减少constexpr的特殊性。例如:

struct X { 
  bool val;
};

void f() {
  X x;
}

f();

上面的代码非常简单,在任何环境下都可以顺利编译。不过如果将函数f改为:

constexpr void f() {
  X x;
}

那么在C++17标准的编译环境就会报错,提示x没有初始化,它需要用户提供一个构造函数。当然这个问题在C++17标准中也很容易解决,例如修改X为:

struct X { 
  bool val = false;
};

回头来看原始代码,它在C++20标准的编译器上是能够顺利编译的。值得一提的是,虽然标准放松了对constexpr上下文对象默认初始化的要求,但是我们依然应该养成声明对象时随手初始化的习惯,避免让代码出现未定义的行为。

在C++20标准之前对constexpr的另外一个限制就是禁止更改联合类型的有效成员,例如:

union Foo {
  int i;
  float f;
};
constexpr int use() {
  Foo foo{};
  foo.i = 3;
  foo.f = 1.2f; // C++20之前编译失败
  return 1;
}

在上面的代码中,foo是一个联合类型对象,foo.i = 3;首次确定了有效成员为i,这没有问题,接下来代码foo.f = 1.2f;改变有效成员为f,这就违反了标准中关于不能更改联合类型的有效成员的规则,所以导致编译失败。现在C++20标准已经删除了这条规则,以上代码可以编译成功。实际编译过程中,只有CLang会在C++17标准中对以上代码报错,而GCC和MSVC均能用C++17和C++20标准编译成功。

C++20标准对constexpr做了很多修改,除了上面提到的修改以外,还修改了一些并不常用的地方,包括允许dynamic_casttypeid出现在常量表达式中;允许在constexpr函数使用未经评估的内联汇编。这些修改都没有需要详细介绍的特别之处,有兴趣的读者可以自己写点实验代码测试一下。

前面我们曾提到过,constexpr声明函数时并不依赖常量表达式上下文环境,在非常量表达式的环境中,函数可以表现为普通函数。不过有时候,我们希望确保函数在编译期就执行计算,对于无法在编译期执行计算的情况则让编译器直接报错。于是在C++20标准中出现了一个新的概念——立即函数,该函数需要使用consteval说明符来声明:

consteval int sqr(int n) {
  return n*n;
}
constexpr int r = sqr(100);  // 编译成功
int x = 100;
int r2 = sqr(x);             // 编译失败

在上面的代码中sqr(100);是一个常量表达式上下文环境,可以编译成功。相反,因为sqr(x);中的x是可变量,不能作为常量表达式,所以编译器抛出错误。要让代码成功编译,只需要给x加上const即可。需要注意的是,如果一个立即函数在另外一个立即函数中被调用,则函数定义时的上下文环境不必是一个常量表达式,例如:

consteval int sqrsqr(int n) {
  return sqr(sqr(n));
}

sqrsqr是否能编译成功取决于如何调用,如果调用时处于一个常量表达式环境,那么就能通过编译:

int y = sqrsqr(100);

反之则编译失败:

int y = sqrsqr(x);

lambda表达式也可以使用consteval说明符:

auto sqr = [](int n) consteval { return n * n; };
int r = sqr(100);
auto f = sqr; // 编译失败,尝试获取立即函数的函数地址

在C++中有一种典型的错误叫作“Static Initialization Order Fiasco”,指的是因为静态初始化顺序错误导致的问题。因为这种错误往往发生在main函数之前,所以比较难以排查。举一个典型的例子,假设有两个静态对象xy分别存在于两个不同的源文件中。其中一个对象x的构造函数依赖于对象y。没错,就是这样,现在我们有50%的可能性会出错,因为我们没有办法控制哪个对象先构造。如果对象xy之前构造,那么就会引发一个未定义的结果。为了避免这种问题的发生,我们通常希望使用常量初始化程序去初始化静态变量。不幸的是,常量初始化的规则很复杂,需要一种方法帮助我们完成检查工作,当不符合常量初始化程序的时候可以在编译阶段报错。于是在C++20标准中引入了新的constinit说明符。

正如上文所描述的constinit说明符主要用于具有静态存储持续时间的变量声明上,它要求变量具有常量初始化程序。首先,constinit说明符作用的对象是必须具有静态存储持续时间的,比如:

constinit int x = 11;              // 编译成功,全局变量具有静态存储持续
int main() {
  constinit static int y = 42;     // 编译成功,静态变量具有静态存储持续
  constinit int z = 7;             // 编译失败,局部变量是动态分配的
}

其次,constinit要求变量具有常量初始化程序:

const char* f() { return "hello"; }
constexpr const char* g() { return "cpp"; }
constinit const char* str1 = f(); // 编译错误,f()不是一个常量初始化程序
constinit const char* str2 = g(); // 编译成功

constinit还能用于非初始化声明,以告知编译器thread_local变量已被初始化:

extern thread_local constinit int x;
int f() { return x; }

最后值得一提的是,虽然constinit说明符一直在强调常量初始化,但是初始化的对象并不要求具有常量属性。

std::is_constant_evaluated是C++20新加入标准库的函数,它用于检查当前表达式是否是一个常量求值环境,如果在一个明显常量求值的表达式中,则返回true;否则返回false。该函数包含在<type_traits>头文件中,虽然看上去像是一个标准库实现的函数,但实际上调用的是编译器内置函数:

constexpr inline bool is_constant_evaluated() noexcept
{ 
    return __builtin_is_constant_evaluated(); 
}

该函数通常会用于代码优化中,比如在确定为常量求值的环境时,使用constexpr能够接受的算法,让数值在编译阶段就得出结果。而对于其他环境则采用运行时计算结果的方法。提案文档中提供了一个很好的例子:

#include <cmath>
#include <type_traits>
constexpr double power(double b, int x) {
  if (std::is_constant_evaluated() && x >= 0) {
    double r = 1.0, p = b;
    unsigned u = (unsigned)x;
    while (u != 0) {
      if (u & 1) r *= p;
      u /= 2;
      p *= p;
    }
    return r;
  } else {
    return std::pow(b, (double)x);
  }
}

int main() 
{
  constexpr double kilo = power(10.0, 3);  // 常量求值
  int n = 3;
  double mucho = power(10.0, n);           // 非常量求值
  return 0;
}

在上面的代码中,power函数根据std::is_constant_evaluated()x >= 0的结果选择不同的实现方式。其中,kilo = power(10.0, 3);是一个常量求值,所以std::is_ constant_evaluated() && x >= 0返回true,编译器在编译阶段求出结果。反之,mucho = power(10.0, n)则需要调用std::pow在运行时求值。让我们通过中间代码看一看编译器具体做了什么:

main ()
{
  int D.25691;

  {
    const double kilo;
    int n;
    double mucho;

    kilo = 1.0e+3;             // 直接赋值
    n = 3;
    mucho = power (1.0e+1, n); // 运行时计算
    D.25691 = 0;
    return D.25691;
  }
  D.25691 = 0;
  return D.25691;
}

power (double b, int x)
{
  bool retval.0;
  bool iftmp.1;
  double D.25706;

  {
    _1 = std::is_constant_evaluated ();
    if (_1 != 0) goto <D.25697>; else goto <D.25695>;
    <D.25697>:
    if (x >= 0) goto <D.25698>; else goto <D.25695>;
    <D.25698>:
    iftmp.1 = 1;
    goto <D.25696>;
    <D.25695>:
    iftmp.1 = 0;
    <D.25696>:
    retval.0 = iftmp.1;
    if (retval.0 != 0) goto <D.25699>; else goto <D.25700>;
    <D.25699>:
    {
      // … 这里省略power函数的相关算法,虽然算法生成代码了,但是并没有调用到
      return D.25706;
    }
    <D.25700>:
    _3 = (double) x;
    D.25706 = pow (b, _3);
    return D.25706;
  }
}

std::is_constant_evaluated ()
{
  bool D.25708;

  try
    {
      D.25708 = 0;
      return D.25708;
    }
  catch
    {
      <<<eh_must_not_throw (terminate)>>>
    }
}

观察上面的中间代码,首先让我们注意到的就是main函数中kilomucho赋值形式的不同。正如我们刚才讨论的那样,对于kilo的结果编译器在编译期已经计算完成,所以这里是直接为1.0e+3,而对于mucho则需要调用std::power函数。接着,我们可以观察std::is_constant_evaluated()这个函数的实现,很明显编译器让它直接返回0(也就是false),在代码中实现的power函数虽然有std::is_constant_ evaluated()结果为true时的算法实现,但是却永远不会被调用。因为当std::is_ constant_evaluated()true时,编译器计算了函数结果;反之函数会交给std::power计算结果。

在了解了std::is_constant_evaluated()的用途之后,我们还需要弄清楚何为明显常量求值。只有弄清楚这个概念,才可能合理运用std::is_constant_ evaluated()函数。明显常量求值在标准文档中列举了下面几个类别。

1.常量表达式,这个类别包括很多种情况,比如数组长度、case表达式、非类型模板实参等。

2.if constexpr语句中的条件。

3.constexpr变量的初始化程序。

4.立即函数调用。

5.约束概念表达式。

6.可在常量表达式中使用或具有常量初始化的变量初始化程序。

下面我们通过几个标准文档中的例子来体会以上规则:

template<bool> struct X {};
X<std::is_constant_evaluated()> x; // 非类型模板实参,函数返回true,最终类型为
                                   // X<true>
int y;

constexpr int f() {
  const int n = std::is_constant_evaluated() ? 13 : 17; // n是13
  int m = std::is_constant_evaluated() ? 13 : 17;   // m可能是13或者17,取决
                                                    // 于函数环境
  char arr[n] = {}; // char[13]
  return m + sizeof(arr);
}
int p = f();        // m是13;p结果如下26
int q = p + f();    // m是17;q结果如下56

上面的代码中需要解释的是int p = f();int q = p + f();的区别,对于前者,std::is_ constant_evaluated() == truep一定是一个恒定值,它是明显常量求值,所以p的结果是26。相反,std::is_constant_ evaluated() == true时,q的结果会依赖p,所以明显常量求值的结论显然不成立,需要采用std::is_constant_evaluated() == false的方案,于是f()函数中的m17,最终q的求值结果是56。另外,如果这里的p初始化改变为const int p = f();,那么f()函数中的m13q的求值结果也会改变为52

最后需要注意的是,如果当判断是否为明显常量求值时存在多个条件,那么编译器会试探std::is_constant_evaluated()两种情况求值,比如:

int y;
const int a = std::is_constant_evaluated() ? y : 1; // 函数返回false,a运行时
                                                    // 初始化为1
const int b = std::is_constant_evaluated() ? 2 : y; // 函数返回true,b编译时
                                                    // 初始化为2

当对a求值时,编译器试探std::is_constant_evaluated() == true的情况,发现y会改变a的值,所以最后选择std::is_constant_evaluated() == false;当对b求值时,编译器同样试探std::is_constant_evaluated() == true的情况,发现b的结果恒定为2,于是直接在编译时完成初始化。

本章重点介绍了常量表达式,我们可以通过constexpr说明符声明常量表达式函数以及常量表达式值,它们让程序在编译期做了更多的事情,从而提高程序的运行效率。特别是在C++14以后,常量表达式函数的定义更加自由,具有极高的实用性。除此之外,立即函数以及检查常量初始化方法的加入也进一步完善了常量表达式体系。

虽然常量表达式有着非常不错的特性,并且对于追求程序运行效率的程序员来说有着非常大的吸引力,但是我们依旧需要小心谨慎地对待它,因为一旦将函数或者变量原本带有的constexpr说明符删除,可能就会导致大量代码的编译失败。