C++11:constexpr & 编译期性质
常量表达式 constexpr
const expression
常量表达式,是C++11引入的新特性,用于将表达式提前到编译期进行计算,从而减少运行时的重复计算,提高运行时效率。该特性基于关键字constexpr
,可以用于修饰变量,函数等。
变量
其实在C++11之前,C++默认就会把 const int
进行编译期计算,从而优化运行时效率,但是这个过程是隐式的。
例如以下代码:
cpp
int c1 = 1 + 20 * 5;
const int a = 500;
int c2 = a + 1 * 2;
int c3 = c2 + 1;
此处对于c1
、a
、c2
都是在编译期就可以确认值的,但是c3
需要运行时才会确认值。
-
c1
:由于表达式中所有值都是字面量,因此在编译期就可以确定整个表达式的值,从而初始化c1
汇编代码如下:
cppC7 45 04 65 00 00 00 mov dword ptr [c1],65h
此处
65h
就是1 + 20 * 5
的十六进制值,汇编阶段直接计算出来并且赋值到变量c1
了。 -
a
:表达式直接赋值字面量500
,同理编译期就可以确定值汇编代码如下:
cppC7 45 24 F4 01 00 00 mov dword ptr [a],1F4h
与
c1
同理,1F4h
就是十六进制的500
。 -
c2
:表达式中a
是一个const int
变量,说明后续不会发生修改,又在编译期就已经确认初始值,所以整个表达式a + 1 * 2
也是可以在编译期确认值的。汇编代码如下:
cppC7 45 44 F6 01 00 00 mov dword ptr [c2],1F6h
这里也是直接计算了
500 + 1 * 2
的十六进制,赋值到c2
。 -
c3
:此处表达式中,c2
不是一个const
变量,因此就算编译期确定它的初始值,也无法保证后续不会被修改,因此需要运行时推断。汇编代码如下:
cpp8B 45 44 mov eax,dword ptr [c2] FF C0 inc eax 89 45 64 mov dword ptr [c3],eax
首先把
c2
变量的值加载到eax
寄存器里面,然后inc
指令对eax
里面的值加一,最后把eax
自增后的值赋值到c3
,整个过程是运行时完成的。
以上四行代码的解析,可以得出C++本身是会对表达式进行优化的,如果表达式在编译期就可以求出结果,编译器就有可能在编译阶段求出其具体值,而不是在运行时计算。
并且对于const int
(整形,枚举,且有初始值),编译器本身会尽可能让其在编译期进行计算,成为一个常量表达式,但是对于const
修饰的其他类型不做此要求,有可能在编译期也有可能在运行时。
在C++11,拓宽了对这个编译期计算特性的普适性,引入了constexpr
关键字,被其修饰的变量,一定会在编译期得到结果,如果无法得到结果,那么就会报错。
- 修饰变量语法:
cpp
constexpr type name = expression;
此处的expression
必须满足:
- 编译期可以确定值
- 确定后不会再修改
- 不为空,即变量必须初始化
例如:
cpp
constexpr int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // success
此处的c1
和c2
都是合法的常量表达式,在编译期可以进行求值。
此外,const int
也是可以初始化 constexpr int
的:
cpp
const int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // success
因为刚刚提到,const int
(整形家族,枚举)是会隐式在编译期就尽可能求值的,那么它满足 编译期求值
+ 后续不修改
两大特性,就可以初始化constexpr
。
但是以下代码又是错误的:
cpp
int a = 1;
const int c1 = a + 20 * 5;
constexpr int c2 = c1 + 500; // error
此处代码相比之前,就是c1
初始化的时候依赖了普通变量a
,但是a
无法在编译期求值,自然c1
也就无法在编译期求值,进而导致c2
无法在编译期求值,constexpr
报错。
刚才高亮语句也说明了,const int
只是尽可能在编译期进行求值,如果不行,那么const int
就无法进一步初始化constexpr int
。
但是对于整形家族和枚举以外的类型,const
是绝不能修饰constexpr
的,例如:
cpp
const double d1 = 3.14;
constexpr double d2 = d1 * 3; // error
此处就算d1
可以在编译期确定值,并且const
特性保证后续不会修改。但是C++标准并没有对此明确规定,d1
也有可能会在运行时得到值,这取决于编译器,所以const double
无法初始化constexpr double
。
最后,就算是const int
,如果被volatile
修饰,也不能用于初始化constexpr
,例如:
cpp
volatile const int c1 = 1 + 20 * 5;
constexpr int c2 = c1 + 500; // error
因为volatile const
表示,这个变量就算是const
,也是有可能被修改的,那么就不满足constexpr
的第二个要求。
前面这一大段,主要阐述了constexpr
初始化的几个场景,总结一下:
const int
(整型家族,枚举):在编译期可以确定值,并且不被volatile
修饰const 其它
:不论如何都不能拿来初始化constexpr
constexpr 任意
:都可以拿来初始化其他的constexpr
表达式,也是最佳实践
IiteralType
并不是所有的变量都可以被constexpr
修饰,并在编译期求值的,比如:
cpp
constexpr std::string str = "hello";
这行代码是会报错的,因为std::string
需要动态内存管理,无法编译期求值。
只有一个类型为 IiteralType
,才能被constexpr
修饰。
此处的IiteralType
称为字面量类型,包含五种情况:
void
类型(C++14后支持,C++11不允许返回void
)- 标量类型
- 引用类型
- 字面类型数组
- 类类型,且满足:
- 有一个平凡的
constexpr
析构函数- 所有非静态非变体数据成员和基类都是字面类型
- 满足以下之一:
lambda
类型(C++17后)- 至少有一个
constexpr
修饰的构造函数,且该构造不是拷贝构造或移动构造- 聚合
union
类型, 没有变体成员 或者 至少有一个非易失性字面类型的变体成员- 聚合
非union
类型,且如果有内置匿名联合成员,该联合必须满足上一条
这一大段着实让人看着头大了,引入了非常多从未接触过的定义。但这已经是我简化过的版本,可参考官网描述:C++ 命名要求: LiteralType (自 C++11 起),官网对这一部分的描述,可以说是非常晦涩。
简单来说,总共有五种情况满足IiteralType
,其中第五种是针对类类型。
对于类类型,要求满足三个要求,而第三条要求内部,又需要满足四个条件之一。
首先解释部分专有名词:
-
字面类型数组
:此处专指T arr[n]
这种形式,也就是C风格的数组,且数组的元素类型必须是字面类型 -
非静态
:也就是没有被static
修饰的类成员 -
非易失性
:被volatile
修饰的变量称为易失性变量,非易失性即没有被volatile
修饰 -
平凡析构函数
:一个析构函数被认为是平凡的,当它同时满足以下所有条件:- 隐式定义或默认定义 :没有用户提供的析构函数定义(即使用
= default
或完全不声明析构函数)。 - 非虚析构函数 :析构函数没有被声明为
virtual
虚函数。 - 所有基类和非静态数据成员都有平凡的析构函数:类的直接基类和所有非静态数据成员的析构函数也必须是平凡的。
- 隐式定义或默认定义 :没有用户提供的析构函数定义(即使用
-
聚合类型
:满足以下要求:-
无用户提供或继承的构造函数 :聚合类型可以有默认构造函数,但必须是编译器自动生成的,不能有用户显式定义的构造函数(除了
= default
形式显式要求编译器生成默认构造函数的情况 )。 -
无私有或保护的非静态数据成员:所有非静态数据成员都是公有的,可以直接访问。
-
无虚函数和虚基类:聚合类型不包含虚函数,因为虚函数涉及到运行时多态,会增加类型的复杂性。
可以说,当一个类属于聚合类型,几乎就退化到了C语言的
struct
的状态,不含复杂的构造,内存模型简单,可以在编译期完成求值。 -
-
变体成员
:引入一个概念,
类联合体类
,是联合体union
或 至少有一个匿名联合体作为成员的非联合体类。简而言之,就是一个联合体
union
本身算作类联合体类
,或者当一个类内部包含了匿名union
,也叫做类联合体类
。例如:
cppunion my_union { int x; int y; }; class my_class { union { int a; int b; } _u; };
代码中的联合体
my_union
和类my_class
都算类联合体类
。对于my_class
,他内部有一个成员_u
,是一个匿名联合体,因为union
关键字后面没有联合体的名称。对于类联合体类,内部的联合体的成员,叫做
变体成员
。即以上的x
、y
、a
、b
都叫做变体成员。
现在回看之前的IiteralType
要求,我做出一定修改:
void
类型 (C++14后)- 标量类型
- 引用类型
- C风格数组,且数组成员都是字面类型
- 类类型,且满足:
- 有一个平凡的
constexpr
析构函数- 所有 不被
static
和volatile
修饰的成员 和 基类 都是字面类型- 满足以下之一:
lambda
类型(C++17后)- 至少有一个
constexpr
修饰的构造函数,且该构造不是拷贝构造或移动构造- 它是聚合类型,且不是类联合体类
- 它是聚合类型,且是类联合体类:联合体内不存在变体成员,或者至少存在一个不被
volatile
修饰的变体成员
接下来,我再对部分条目做出一定解释。
回到 IiteralType
的本质,它就是一个constexpr
修饰类型的要求。既然要在编译期完成计算,那么这个类型要在编译期就可以确认,以上五条性质就是在要求IiteralType
是在编译期可计算的。
首先是C风格数组
,它在定义时确定了数组长度,以及每个元素类型,那么编译期就可以确定它的内存布局。
例如以下 constexpr
是合法的:
cpp
constexpr int arr[] = { 1, 2, 3 };
如果要用一个constexpr
修饰类类型:
- 首先要有一个平凡的析构函数,这个在之前讲了,也就是析构函数是默认的,并且不被
viture
修饰,所有成员和基类也都有平凡的析构。这一步保证了这个类没有复杂的内部实现,可以迅速销毁,也反映其内存模型简单。 - 所有的不被
static
和volatile
修饰的成员 和 基类 都是IteralType
这两个条件,基本限制了类的模型不会非常复杂,几乎不可能存在类似于动态内存管理这样的操作,在编译期可能完成求值。
- 满足四个条件之一
- 是
lambda
,其实不是所有lambda
都可以,这个不深入讲,属于C++17的特性 - 至少有一个
constexpr
修饰的构造,并且这个构造不是拷贝构造或移动构造
这个是下一段内会讲到的,constexpr
可以修饰函数,那么这个函数就可以在编译期完成调用得到结果。被constexpr
修饰的函数,内部会有很多限制,无法完成太过于复杂的操作。因此当constexpr
修饰构造,说明构造不会太复杂,可以在编译期完成构造函数的调用,那么也就可以在编译期初始化出对象。
但是这个构造不能是拷贝构造或者移动构造,这其实是一个先有鸡还是先有蛋的问题。想一想,如果某个类只有拷贝构造或者移动构造被constexpr
修饰。现在在编译期一个该类型的对象C
要完成求值,C
的拷贝/移动构造必须传入另外一个同类型的对象B
,但是B
从哪里来呢?要么是从一个普通的构造函数,但是刚才说了,普通的构造不被constexpr
修饰,无法在编译期求到值。另外一种就是也通过拷贝或者移动而来,那么B
又要再去依赖另外一个同类对象A
,以此类推,编译期根本不可能完成构造对象的任务。因此这个构造不能是拷贝构造或移动构造。
- 如果这个类型是一个聚合类型,且不是类联合体类
那么这个类本身就非常简单,满足聚合类型就说明内存模型不会太复杂了,在编译期可以完成求值。
- 如果这个类型是一个聚合类型,且是类联合体类,联合体内不存在变体成员,或者至少存在一个不被
volatile
修饰的变体成员
也就是说,它是一个union
或者类内包含匿名union
,此时要考虑变体成员的问题。
第一种是不存在变体成员
例如:
cpp
union my_union
{
};
class my_class
{
union
{
} _u;
};
my_union
和my_class::_u
都不含任何变体成员,那么编译期对于这两个内容,完全就不用考虑内存如何分配的问题,因此可以在编译期完成计算。
第二种是至少存在一个不被volatile
修饰的变体成员
例如:
cpp
union my_union
{
int x;
volatile int y;
};
此处,就算y
被volatile
修饰,有可能会在后续发生改变。但是x
是没有被修饰的变体成员,这个my_union
依然可以被constexpr
修饰。
但是以下情况就不行了:
cpp
union my_union
{
volatile int x;
volatile int y;
};
这个类联合体类内,所有变体成员都被volatile
修饰,那么编译期就算可以确认其值,但是无法保证其后续不会发生改变,不符合constexpr
的要求。
没想到一个小小的constexpr
能牵扯到这么多乱七八糟的知识点,也算是在C++庞大的知识图谱中拓展了一块未踏足版图。
函数
constexpr
也可以修饰函数,让一个函数在编译期进行计算得到结果。在C++11,这个函数只能包含一个return
语句,在C++14及C++17拓展后,允许支持复杂的逻辑以及递归。不过递归是有层数限制的,不同编译器取值不同,在256 ~ 2048
之间不等。
- 语法:
cpp
constexpr ret_type name(...)
{
// 函数体
}
只需要在函数返回值左侧加上一个constexpr
关键字,这个函数就变成了constexpr
函数,可以在编译期进行求值。
例如:
cpp
constexpr int fibonacci(int n)
{
if (n <= 0)
return 0;
else if (n == 1)
return 1;
else
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main()
{
constexpr int constexpr_ret = fibonacci(5);
return 0;
}
这个函数用于在编译期求出一个斐波那契的第n
个元素。函数内部有递归,分支语句,当使用一个constexpr
变量接受返回值,那么整个函数就会在编译期进行运算,并得到结果。
只有满足以下要求,一个函数才会有机会成为constexpr
:
- 返回值和参数都是
IiteralType
(字面量类型) - 函数内不能调用其他非
constexpr
函数 - 不抛异常
其中IiteralType
刚才讲过了,在C++11之前,constexpr
是不允许返回void
类型,也就是必须有返回值的。
但是在刚刚的IiteralType
的要求中,第一条就是C++14新增的,void
也算IiteralType
了,因此C++14后constexpr
函数可以没有返回值。
但是,一个constexpr
修饰的函数,不一定在编译期得到返回值。
还记得之前提到,constexpr
修饰一个变量,要求初始化这个变量,并且在编译期就可以拿到值。
cpp
int a = 2;
constexpr int b = a + 1;
以上代码就是错误的,因为b
依赖了一个运行时求值的a
。
对于函数也是同理的,函数的参数就算是IteralType
,也不能保证函数参数就在编译期可以求出值,这些参数的实参也必须是constexpr
。
例如:
cpp
constexpr int add(int x, int y)
{
return x + y;
}
以下是正确的调用:
cpp
constexpr int a = 1;
constexpr int ret1 = add(a, 10);
int b = 20;
int ret2 = add(b, b + 10);
在ret1
的调用中,add
的参数a
和10
都是编译期确定的,调用成功。在ret2
的调用中,尽管b
是运行时确定的,但是返回值ret2
也没有被constexpr
修饰,不要求其在编译期求值,那么此时就当做一个普通函数在运行时调用。
以下是错误的调用:
cpp
int a = 1;
constexpr int ret = add(a, 10);
此处a
是一个运行时求值的变量,但是ret
要求编译期求值。尽管add
确实是被constexpr
修饰的函数,函数参数也满足IteralType
,但是实参a
编译期拿不到值,最后代码就会报错。
自定义字面量
C++11 引入了用户自定义字面量,允许程序员为自定义类型或特殊语义赋予类似内置字面量的直观写法。通过自定义字面量后缀,可以让代码更具可读性和表达力。
自定义字面量的本质是重载 operator""
,即字面量运算符。其语法如下:
cpp
返回类型 operator "" _后缀(参数列表)
{
// 实现
}
_后缀
必须以下划线开头,且不能包含双下划线(__
),也不能以下划线+大写字母开头,如_A
,这些都被保留给标准库使用。- 参数类型由字面量的类型决定,常见有:
unsigned long long
:用于整数字面量long double
:用于浮点字面量const char*
:用于字符串字面量const char*, std::size_t
:字符串字面量 + 长度
例如:
cpp
using second = unsigned long long;
second operator""_h(unsigned long long h)
{
return h * 3600;
}
second operator""_m(unsigned long long m)
{
return m * 60;
}
second operator""_s(unsigned long long s)
{
return s;
}
void show_time(second s)
{
std::cout << "second: " << s << std::endl;
}
int main()
{
show_time(2_h + 30_m + 15_s);
}
通过自定义字面量,2_h
、30_m
、15_s
直观表达了时间单位,避免了数字和单位混淆。
参数匹配与重载规则
自定义字面量的参数类型由字面量本身决定,常见匹配规则如下:
参数列表 | 适用场景 | 示例 |
---|---|---|
(unsigned long long) |
整数字面量 | 123_后缀 |
(long double) |
浮点字面量 | 3.14_后缀 |
(const char*) |
字符串字面量 | "abc"_后缀 |
(const char*, std::size_t) |
字符串字面量 | "abc"_后缀 |
(char) |
字符字面量 | 'a'_后缀 |
首先,对于算数类型unsigned long long
和double
,直接数值_后缀
即可,对于字符串则需要加双引号"字符串"_后缀
,字符使用单引号'字符'_后缀
,这是调用字面量重载的语法。
对于字符串,有两种函数参数形式,两者调用的方法是相同的,只是两个参数的版本,可以operator ""
函数体内部拿到字符串的长度,更加方便。
例如:
cpp
const char* operator""_x(const char* str)
{ }
const char* operator""_x(const char* str, size_t len)
{ }
两个_x
的后缀重载都是针对字符串类型,但是如果两个都实现了,优先调用带len
的版本。
此外,后缀重载允许拼接字符串,例如:
cpp
"123""hello"_x;
这种调用方式是合法的,最后"123hello"
作为参数。
如果使用整形,比如123_x
,它的行为也比较特殊:
- 如果有
unsigned long long
,优先匹配 - 如果没有前者,整形会转化为字符串匹配
const char*
- 如果前两者都没有,不会匹配
const char*, size_t
和long double
,字面量报错
此外,像这种字面量的计算一般不会很复杂,后缀重载常常被声明为constexpr
函数。
静态断言
C++11 引入了 static_assert
,用于在编译期进行条件检查。与传统的C风格 assert
不同,static_assert
能在编译阶段捕获错误,提升类型安全和模板编程的健壮性。
在 C++11 之前,常用的静态断言方式是利用非法类型或非法表达式强制编译器报错。
例如:
cpp
#define STATIC_ASSERT(expr) \
do { enum { _assert_ = 1 / (expr) }; } while(0)
template<class T, class U>
void var_copy(T& a, const U& b)
{
STATIC_ASSERT(sizeof(a) == sizeof(b));
memcpy(&a, &b, sizeof(b));
}
以上代码实现两个不同类型之间的按字节拷贝。但是拷贝之前要保证两者类型的字节长度相同。此时使用自己定义的STATIC_ASSERT
宏函数,expr
表达式返回一个布尔值,如果为true
,那么1 / 1
合法,断言通过。但是如果expr
返回false
,那么1 / 0
,发生错误,直接终止程序。
这种方式确实可以实现断言,但是错误信息不明确,无法知道具体是哪一个错误。
C++11 提供了原生的 static_assert
关键字,语法如下:
cpp
static_assert(常量表达式, "错误信息");
- 第一个参数必须是编译期可确定的常量表达式。
- 第二个参数是自定义的错误信息,编译失败时会输出。
示例:
cpp
template<class T, class U>
void var_copy(T& a, const U& b)
{
static_assert(sizeof(a) == sizeof(b), "params must have same width.");
memcpy(&a, &b, sizeof(b));
}
如果 a
和 b
类型不同,编译器会在编译阶段报错,并输出自定义信息"params must have same width."
。
相比于C风格的assert
,static_assert
可以携带错误信息,并且可以在编译期就完成判断。