第27章 常量表达式(C++11~C++20)
27.1 常量的不确定性
在C++11标准以前,我们没有一种方法能够有效地要求一个变量或者函数在编译阶段就计算出结果。
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语句以及数组长度等语句中使用。
稍微修改一下上面的代码:
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);
编译这段代码时会发现已经无法通过编译
27.2 constexpr值
constexpr值即常量表达式值,是一个用constexpr说明符声明的变量或者数据成员,它要求该值必须在编译期计算。
constexpr int x = 42;
char buffer[x] = { 0 };
在使用常量表达式初始化的情况下constexpr和const拥有相同的作用。但是const并没有确保编译期常量的特性
int x1 = 42;
const int x2 = x1; // 定义和初始化成功
char buffer[x2] = { 0 }; // 编译失败,x2无法作为数组长度
如果把const替换为constexpr,会有不同的情况发生:
int x1 = 42;
constexpr int x2 = x1; // 编译失败,x2无法用x1初始化
char buffer[x2] = { 0 };
27.3 constexpr函数
constexpr不仅能用来定义常量表达式值,还能定义一个常量表达式函数,即constexpr函数,常量表达式函数的返回值可以在编译阶段就计算出来。
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 };
}
需要注意的是square和abs两个函数,它们接受一个形参x,当x确定为一个常量时(这里分别是5和−8),其常量表达式函数也就成立了。
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函数都会编译失败。
虽然常量表达式函数的返回值可以在编译期计算出来,但是这个行为并不是确定的。当带形参的常量表达
式函数接受了一个非常量实参时,常量表达式函数可能会退化为普通函数:
constexpr int square(int x)
{
return x * x;
}
int x = 5;
std::cout << square(x);
这里由于x不是一个常量,因此square的返回值也可能无法在编译期确定,但是它依然能成功编译运行,因为该函数退化成了一个普通函数。
C++标准对STL也做了一些改进,比如在< limits >中增加了constexpr声明
char buffer[std::numeric_limits<unsigned char>::max()] = { 0 };
27.4 constexpr构造函数
onstexpr还能够声明用户自定义类型
struct X {
int x1;
};
constexpr X x = { 1 };
char buffer[x.x1] = { 0 };
有时候我们并不希望成员变量被暴露出来,于是修改了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声明X类的构造函数。
1.构造函数必须用constexpr声明。
2.构造函数初始化列表中必须是常量表达式。
3.构造函数的函数体必须为空(这一点基于构造函数没有返回值,所以不存在return expr)。
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 };
常量表达式构造函数拥有和常量表达式函数相同的退化特性,当它的实参不是常量表达式的时候,构造函数可以退化为普通构造函数
int i = 8;
constexpr X x(i); // 编译失败,不能使用constexpr声明
X y(i); // 编译成功
平凡析构函数必须满足下面3个条件。
1.自定义类型中不能有用户自定义的析构函数。
2.析构函数不能是虚函数。
3.基类和成员的析构函数必须都是平凡的。
27.5 对浮点的支持
constexpr说明符支持声明浮点类型的常量表达式值,而且标准还规定其精度必须至少和运行时的精度相同。
constexpr double sum(double x)
{
return x > 0 ? x + sum(x - 1) : 0;
}
constexpr double x = sum(5);
27.6 C++14标准对常量表达式函数的增强
C++14标准对常量表达式函数的改进如下。
1.函数体允许声明变量,除了没有初始化、static和thread_local变量。
2.函数允许出现if和switch语句,不能使用go语句。
3.函数允许所有的循环语句,包括for、while、do-while。
4.函数可以修改生命周期和常量表达式相同的对象。
5.函数的返回值可以声明为void。
6.constexpr声明的成员函数不再具有const属性。
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 };
原来由于++x不是常量表达式,因此无法编译通过的问题也消失了
constexpr int next(int x)
{
return ++x;
}
char buffer[next(5)] = { 0 };
对于常量表达式函数的增强同样也会影响常量表达式构造函数:
#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;
}
类型为void的set函数也被声明为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.x1、x2.x1、a1、a2进行了赋值,并没有运行时的计算操作。
27.7 constexpr lambdas表达式
从C++17开始,lambda表达式在条件允许的情况下都会隐式声明为constexpr。
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表达式不满足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();
我们也可以强制要求lambda表达式是一个常量表达式,用constexpr去声明它即可。
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();
27.8 constexpr的内联属性
在C++17标准中,constexpr声明静态成员变量时,也被赋予了该变量的内联属性
class X {
public:
static constexpr int num{ 5 };
};
等同于:
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 };
};
如果将输出语句修改为std::cout << &X::num << std::endl,那么链接器会明确报告X::num缺少定义。从C++17开始情况发生了变化,static constexpr int num{5}既是声明也是定义,所以在C++17标准中std::cout << &X::num << std::endl可以顺利编译链接,并且输出正确的结果。
27.9 if constexpr
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;
}
}
当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
如果使用ifconstexpr表达式,代码会简化很多而且更加容易理解
#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;
}
}
需要注意这样一种陷阱:
#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;
}
如果修改一下上面的代码,结果可能就很难预料了:
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);
}
这种写法有可能导致编译失败,因为它可能会导致函数有多个不同的返回类型。当实参为整型时一切正常,编译器会忽略if的代码块,直接编译return static_cast< int>(a − b),这样返回类型只有int一种。但是当实参类型为double的时候,情况发生了变化。if的代码块会被正常地编译,代码块内部的返回结果类型为double,而代码块外部的return static_cast< int>(a − b)同样会照常编译,这次的返回类型为int。编译器遇到了两个不同的返回类型,只能报错。
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;
}
当函数实参为int时,std::is_same<T, std::string>::value和T::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;
}
}
27.10 允许constexpr虚函数
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;
}
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无法用于虚函数的错误提示。
27.11 允许在constexpr函数中出现Try-catch
C++20标准允许Try-catch存在于constexpr函数,但是throw语句依旧是被禁止的,所以try语句是不能抛出异常的,这也就意味着catch永远不会执行。实际上,当函数被评估为常量表达式的时候Try-catch是没有任何作用的。
27.12 允许在constexpr中进行平凡的默认初始化
从C++20开始,标准允许在constexpr中进行平凡的默认初始化,这样进一步减少constexpr的特殊性。
struct X {
bool val;
};
void f() {
X x;
}
f();
将函数f改为:
constexpr void f() {
X x;
}
那么在C++17标准的编译环境就会报错,提示x没有初始化,它需要用户提供一个构造函数。
它在C++20标准的编译器上是能够顺利编译的。值得一提的是,虽然标准放松了对constexpr上下文对象默认初始化的要求,但是我们依然应该养成声明对象时随手初始化的习惯,避免让代码出现未定义的行为。
27.13 允许在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;
}
改变有效成员为f,这就违反了标准中关于不能更改联合类型的有效成员的规则,所以导致编译失败。现在C++20标准已经删除了这条规则,以上代码可以编译成功。
27.14 使用consteval声明立即函数
有时候,我们希望确保函数在编译期就执行计算,对于无法在编译期执行计算的情况则让编译器直接报错。于是在C++20标准中出现了一个新的概念——立即函数,该函数需要使用consteval说明符来声明:
consteval int sqr(int n) {
return n*n;
}
constexpr int r = sqr(100); // 编译成功
int x = 100;
int r2 = sqr(x); // 编译失败
如果一个立即函数在另外一个立即函数中被调用,则函数定义时的上下文环境不必是一个常量表达式
consteval int sqrsqr(int n) {
return sqr(sqr(n));
}
lambda表达式也可以使用consteval说明符:
auto sqr = [](int n) consteval { return n * n; };
int r = sqr(100);
auto f = sqr; // 编译失败,尝试获取立即函数的函数地址
27.15 使用constinit检查常量初始化
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说明符一直在强调常量初始化,但是初始化的对象并不要求具有常量属性。
27.16 判断常量求值环境
std::is_constant_evaluated是C++20新加入标准库的函数,它用于检查当前表达式是否是一个常量求值环境,如果在一个明显常量求值的表达式中,则返回true;否则返回false。
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;
}
让我们通过中间代码看一看编译器具体做了什么:
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)>>>
}
}
明显常量求值在标准文档中列举了下面几个类别。
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()== true时p一定是一个恒定值,它是明显常量求值,所以p的结果是26。相反,std::is_constant_ evaluated() == true时,q的结果会依赖p,所以明显常量求值的结论显然不成立,需要采用std::is_constant_evaluated() == false的方案,于是f()函数中的m为17,最终q的求值结果是56。另外,如果这里的p初始化改变为const int p = f();,那么f()函数中的m为13,q的求值结果也会改变为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,于是直接在编译时完成初始化。