理解 C/C++ 常量与常量表达式
博主之前有篇文章讨论了 C/C++ 程序在被 gnu 链接器 ld 链接后的内存布局,内存通常被分为几个主要区域:
- 代码段(Text/Code Segment):存储程序的可执行指令。
- 数据段(Data Segment):
- 初始化数据段:存储已初始化的全局变量和静态变量。
- 未初始化数据段(BSS):存储未初始化的全局变量和静态变量。
- 堆(Heap):用于动态内存分配。
- 栈(Stack):用于存储栈帧。
以下面这段代码为例,在 main 函数内部定义一个局部常量:
int main()
{
const int x = 3;
return 0;
}
其中 x 作为局部常量变量它在栈上分配内存,并且由于 const 限定符的修饰,编译器会确保这个值在它的生命周期(main函数的执行周期)内不会被修改,其实呢,x 它也写了一下,它是在什么时候写的呢?它是在进行初始化的时候完成了写操作。
它初始化完成之后,它就再也不能进行写操作了。那么在这里呢,博主想说的就是实际上来讲,常量这个东西它是一个编译时期的概念,就是说比如我们去定义声明一个变量。计算机基本上来讲就是在执行到这个变量的构造的时候在内存中分配一块空间,
接下来对这个变量的读和写实际上是对这个内存本身进行读和写,换句话说,变量这个概念实际上是有底层的硬件支持的,就是会有一个相对的内存和这个变量实现的一种映射(汇编中称为symbol,有符号表进行维护)。但是常量这个概念实际上并没有一个底层的硬件支持。通常来讲计算机不能构造一块只读的内存,这件事情是不 work 的。那么,常量和只读的数据是由谁来维护的呢?实际上,常量它一定是由编译器维护的一个编译概念,它是由编译器来保证的。
int main()
{
const int x = 3;
return 0;
}
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 3
mov eax, 0
pop rbp
ret
int main()
{
int x = 3;
return 0;
}
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 3
mov eax, 0
pop rbp
ret
可以看到在汇编层面上二者是没有区别的,所以换句话说,如果我们定义了一个常量之后,接下来要去对这个常量写的话,它一定不是链接器来报告错误,也一定不会在程序运行时报告错误。它只能是在编译期报告,因为编译器去分析到这个定义的常量,并对它进行写这个操作是非法的,因此它会报相应的错误。
从本质上来讲可以理解为如果我们不去考虑编译器所引入的这样一个防止写操作的检测之外,变量和常量它没有什么区别,从逻辑上理解可以认为一个常量它也开辟一块内存然后把这个值写到这个内存当中,那么接下来在读的时候就是从这块内存当中读。
当然,有常量这个概念,而且是编译期概念,一定有它的原因,诞生的目的就是为后续会谈到编译器会根据常量做一些优化和防止非法操作,对一些特殊的常量,特别是常量表达式(C++为此引入的constexpr),但是如果不考虑优化的情况下一个常量和一个变量,它在底层实现上没有区别。
谈论为什么要引入常量,等于变相为变量增加约束呢?
首先从非法操作的角度来看,比如我要写如下代码:
int main()
{
int x = 4;
if(x == 3)
{
...
}
}
但是如果我不小心写成:
int main()
{
int x = 4;
if(x = 3)
{
...
}
}
这对我来讲是不合逻辑的操作,但是确是编译器认为合法的操作,因此会产生 bug。解决这种编写时的错误,不至于合法的编译,可以使用一些技巧
int main()
{
int x = 4;
if(3 == x)
{
...
}
}
这种写法可以解决赋值时 3 作为表达式不能作为左值,因此产生编译时错误。
或者我们将 x 定义为 const
int main()
{
const int x = 4;
if(x = 3)
{
...
}
}
在编译期检查到对 x 的写操作,而报告编译错误,通过我们将一个对象由变量变成了常量,防止写操作,可能在一定程度上防止程序当中的一些不小心所引入的我们不希望的操作。
第二个好处是程序优化,我们看下面这两段代码
int main() {
const int x = 3;
int y = x + 1;
return 0;
}
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 3
mov DWORD PTR [rbp-8], 4
mov eax, 0
pop rbp
ret
int main() {
int x = 3;
int y = x + 1;
return 0;
}
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 3
mov eax, DWORD PTR [rbp-4]
add eax, 1
mov DWORD PTR [rbp-8], eax
mov eax, 0
pop rbp
ret
仅从汇编行数上就可以看到程序优化的差异。
有兴趣的读者可以了解一下什么是常量传播(Constant propagation)或者叫常量折叠,编译器通过优化会直接用字面值3替换代码中所有对 x 的引用,从而减少对内存的读操作。
因此声明常量它实际上是有很多好处的,编译器可以使用这个常量防止一些我们可能无意中引入的错误操作,或者它能够去优化程序从而提高性能,鉴于常量这样的优点,通常来讲,如果我们能够确定一个对象在它的生命周期内不会进行任何的修改,那么,我们应该把它声明成常量。
基于常量的优点,我们理解一下 C++ 中常量引用的设计思想。博主不会讨论常量引用如何定义如何使用,博主仅仅讨论常量引用在 C++ 中实现的优点。
那为什么要引入这样一个常量引用的概念呢?实际上我们说常量引用它是一个非常有用的设计。它主要的一个用途是用于函数的形参,比如说我们在这里定义一个函数叫做 fun,需要一个形参 Str 的结构体:
struct Str
{
char[1000];
};
int fun(Str param){}
int main()
{
Str x;
fun(x);
return 0;
}
熟悉函数值传递机制的读者肯定知道 fun 函数如此设计是很不合理的,它要花费很多的资源把 x 里面的所有内容拷贝给 param,才能实现 fun 的调用。整个过程非常耗时耗力,但是这也是符合语法规范的。那么,实际上对这个结构体进行一些限制呢?结构体本身不支持拷贝,它就不能够进行拷贝,有很多种情况,比如说我们这个结构体可能表示了一个独占的文件,我们不希望它拷贝两份,或者这个结构体可能表示了一个网络通信的链路,可能也是不支持复制的。如果结构体不支持复制的话,整个这个代码根本是无法通过编译的。
一种解决方案是可以传这个 param 的指针,
struct Str
{
char[1000];
};
int fun(Str *param){}
int main()
{
Str x;
fun(&x);
return 0;
}
但是传指针其实有一个问题,就是说如果我们传指针,在 fun 函数内部的话就要判断这个指针的有效性,很有可能在调用 fun 的时候传一个 nullptr,这本身就是一个非法的。那这种情况下就要对这个 fan 内部处理相对来讲麻烦一些,我们必须要在 fun 里面写一个代码,判断一下 param 是否有效,如果 param 不有效就要进行相应的处理,比如说异常,或者打印一个错误信息终止程序等,这就不够友好。
struct Str
{
char[1000];
};
int fun(Str& param){
param = ...
}
int main()
{
Str x;
fun(x);
return 0;
}
如果传入的是一个引用,至少,不需要担心这个空指针的问题,因为引用在初始时一定绑定了一个对象,而且博主之前有篇文章讲过引用和指针在底层是相同的实现方式,因此引用使用起来相对来讲比较友好。而且也不会出现不能拷贝的情况,但是,如果去传引用的话,它会有另外的一个问题是它是可以修改的,如果我们在 fun 中对 param 进行修改,都会影响到原来的值 x。
我们说引用它实际上可以看作是 *(&x),我们无论从概念上来讲,还是从它的实际底层实现上来讲,本质上这种修改都会直接影响到 x 的值。
因此我们就引入了常量引用的概念,如果我写成下面的形式:
struct Str
{
char[1000];
};
int fun(const Str& param){
param = ...
}
int main()
{
Str x;
fun(x);
return 0;
}
这在编译期就会直接报错,避免我们在 fun 中对 x 的误修改。
基于常量的优点,在 C++11 开始,所有编译期可确定的对象都可以被声明为 constexpr,相较于运行时才能确定的常量(运行时常量),constexpr 关键字在编译期就可以告诉编译器说我的对象值是确定的,可以直接进行程序优化。
const
保证一个值不会被改变,而 constexpr
保证一个值可以在编译时确定。
当我们考虑编译期与运行时的区别时,这一区别变得尤为重要。
当在 C++ 中声明某物为 const
时:
const int x = 10;
在告诉编译器"这个值在初始化后不能被修改"。然而,编译器并不保证这个值在编译时就已知。考虑以下例子:
int getValue() { return 42; }
void foo() {
const int x = getValue(); // 这是可以的,但 x 是在运行时确定的
}
这里,x
是 const
,但它的值来自一个在程序执行期间发生的函数调用。在程序运行之前,编译器不知道 x
将会是什么。
现在,如果我们使用 constexpr
代替:
constexpr int x = getValue(); // 这将导致编译错误
这段代码无法编译,因为 getValue()
没有被标记为 constexpr
,所以编译器无法在编译期间对其求值。
constexpr
允许计算在编译时而非运行时发生:
constexpr int factorial(int n) {
return (n <= 1) ? 1 : (n * factorial(n-1));
}
constexpr int result = factorial(5); // 在编译期间计算
从 C++11 开始,并在 C++14/17/20 中不断扩展,constexpr
函数变得越来越强大,允许将复杂计算移至编译时:
// C++14 及更高版本
constexpr int fibonacci(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int temp = a + b;
a = b;
b = temp;
}
return b;
}
另一个重要区别是 constexpr
变量或函数可以在编译时和运行时上下文中使用:
constexpr int square(int x) {
return x * x;
}
// 编译时使用
constexpr int compiledResult = square(5);
// 运行时使用
int userInput;
std::cin >> userInput;
int runtimeResult = square(userInput);
这两种用法都是有效的。constexpr
函数在可能的情况下在编译时运行,但也可以在需要时在运行时运行。