C++关于常量提供了很多形如const*的关键词,在语义上涉及到运行期的常量约束编译期的初始化这两个性质,这几个关键词的含义相当类似,有必要整理一下。

概述

C++陆续引入了下面几个关键词:

  • 第一代:const,从C语言中继承,在C++中一直都存在
  • 第二代:constexpr,C++11引入
  • 第三代:constevalconstinit,C++20引入

其中const继承自C语言的,其它几个则是C++独有的关键词,关于它们的用法仍然在迅速发展中,因为C++很想发掘出编译期计算的巨大潜力,但是历史包袱导致它一直干不好。

我们重点关注的是编译期计算,可以使用如下的两种方式来检查:

  • 第一种方式利用在数组的定义,数组长度需要提供一个编译期的值,因为普通变量的初始化始终发生在运行期,直接使用一个变量作为数组长度是无法通过编译的。(有时确实可以通过编译,但那属于编译器的扩展功能,不是语法标准规定的用法)
  • 第二种方式是使用C++专门提供的static_assert关键字来进行静态断言(将其称为编译期断言更合适,与C语言中assert所代表的运行期断言相对应),它会在编译期判定一个编译期表达式是否为真,如果表达式不可在编译期求值或者求值结果为假,就会触发编译错误,可以用第二个参数附带说明信息。(静态断言不会导致运行期的任何行为,因此不会影响运行期的效率)
    1
    2
    static_assert(1 < 2);
    static_assert(1 > 2, "msg"); // compile error

const 类型

const关键词修饰的类型称为常量类型(称为只读类型其实更合适),常量类型的变量在运行期是不允许被修改的,尝试直接修改一个常量类型的变量会导致编译期报错,例如

1
2
const int a = 10;
a = 20; // compile error

然而事实上,所谓的“运行期常量约束”在运行期实际是不存在的,这只是编译期进行的一种语法检查。 只要我们能骗过编译期的语法检查,就可以做到对常量类型的变量的修改, 例如可以通过强制类型转换绕过const限制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main(int argc, char *argv[]) {
const int a = 10;

std::cout << "a = " << a << std::endl;

int *p = const_cast<int *>(&a);
*p = 100;

std::cout << "a = " << *p << std::endl;
std::cout << "a = " << a << std::endl;

return 0;
}

这个程序的输出为

1
2
3
a = 10
a = 100
a = 10

看起来有些奇怪:第二个输出表明&p在内存中确实被修改了,但是第三个输出仍然是原来的值,这是因为编译器在编译时直接用10替换了a,这并不是编译器的错误,因为这种替换符合const的语义,而我们的欺骗做法与const的语义相违背。

注:

  • 除了强制类型转换,我们还可以根据栈上变量的排序,通过与之相邻的数组进行越界访问,来达到类似的欺骗目的;(数组越界在Debug模式下可能会导致程序崩溃)
  • 对于这一类看起来非常显然的合法优化,即使是Debug模式下编译器也会自动执行,我们可以通过添加volatile修饰来阻止优化,此时会看到最后一个输出也是a = 100

const的语义其实是不清晰的:它只是规定了变量在运行期不可被修改,但是并没有规定变量的初始化发生在编译期还是运行期?! 这导致的实际结果是:

  • 非常量类型的变量:初始化一定发生在运行期,即使明确赋值的是一个字面值常量;
  • 常量类型的变量:初始化可能发生在运行期,也可能发生在编译期,例如在定义时赋值一个字面值常量(或常量表达式)就会导致编译期的初始化。

非常量类型的变量初始化必然发生在运行期,例如下面的代码无法通过编译

1
2
3
4
5
6
int main(int argc, char *argv[]) {
int N = 10;
static_assert(N == 10); // compile error

return 0;
}

常量类型的变量的初始化可能发生在编译期,也可能发生在运行期,例如下面的代码无法通过编译

1
2
3
4
5
6
7
8
9
int func() { return 20; }

int main() {
const int a1 = 10; // 编译期初始化
static_assert(a1 == 10);

const int a2 = func(); // 运行期初始化
static_assert(a2 == 20); // compile error
}

编译期初始化的特性具有传递性:一个编译期初始化的变量可以用来在编译期初始化另一个常量

1
2
const int a1 = 10;      // 编译期初始化
const int a2 = a1 + 10; // 编译期初始化

但是任何的函数调用都会阻止编译期初始化,因为函数的执行都是在运行期进行的,即使这个函数足够简单并且具有确定的值

1
2
3
int func(){ return 10; }

const int a = func(); // 运行期初始化

我们应该只使用const所代表的在运行期不可被修改的语义,对于是否在编译期初始化的语义,则拆分给其它的关键词。

constexpr 变量

constexpr修饰变量时,语义相对于const的加强版:

  • constexpr变量自动变为常量类型,不需要额外添加const(对于指针变量,代表指针自身是常量),这保证了在运行期不允许修改,即运行期常量。除此之外,对于静态变量,编译器还会自动加上inline修饰词。
  • 不允许对constexpr变量进行单独的声明,在定义时必须进行初始化,保证初始化必须发生在编译期,即编译期常量。

例如下面的定义得到的a的类型是const int,并且明确了变量a必须在编译期初始化

1
constexpr int a = 10; // const int a

constexpr用于定义指针时,不允许修改是针对指针自身而言的

1
2
3
4
5
6
7
int s1 = 10;
int main() {
constexpr int *p1 = &s1; // int * const p1

int s2 = 10;
constexpr int *p2 = &s2; // compile error
}

并且这里不能使用局部变量的地址对指针进行初始化,因为栈上的地址不是编译期常量;可以使用全局变量的地址对指针初始化,因为它是编译期常量。

我们还需要明确的是,constexpr相比于const只是在编译期为其赋予了更强的语义:它可以作为常量被编译器使用, 这种约束在运行期其实仍然是不存在的,我们还是可以悄悄修改掉它,技巧完全类似于前面对const变量的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>

int main(int argc, char *argv[]) {
constexpr int a = 10;

std::cout << "a = " << a << std::endl;

int *p = const_cast<int *>(&a);
*p = 100;

std::cout << "a = " << *p << std::endl;
std::cout << "a = " << a << std::endl;

return 0;
}

constinit 变量

前面的constexpr变量既是运行期常量(类型必然为常量类型),也是编译期常量(初始化必然发生在编译期)。 但是有时我们有一个弱一点的需求:只希望保证某个变量的初始化发生在编译期,希望得到的变量在运行期仍然保留可变性,C++20提供了constinit解决这种需求。

constinit修饰变量的语义为:(定义和声明都需要使用constinit修饰)

  • constinit变量在运行期仍然可变;
  • constinit变量的初始化必然发生在编译期,这也要求变量自身必须具有全局生命周期(否则在哪里进行初始化?),例如允许全局变量或静态变量,不允许修饰非静态的局部变量。

例如

1
constinit int a = 100; // int a

constinit的典型应用情景是对全局变量保证在编译期初始化,这可以在一定程度上解决全局变量的初始化顺序问题。

对于单个编译单元,全局变量的初始化由它们的定义顺序决定,但是不同编译单元之间的初始化顺序是无法确定的,假如不同编译单元中的全局变量产生了依赖,情况就会很麻烦。通常的解决办法是将全局变量使用函数打包,将其变成函数内部的静态变量,全局变量之间的依赖关系转换为函数的调用关系。如果所有全局变量都是constinit的,那么也可以避免依赖顺序的问题。

constexpr 函数

constexpr修饰函数时,表明函数支持在编译期求值:(定义和声明都需要使用constexpr修饰)

  • 如果输入参数是编译期常量,那么函数的调用执行就可以在编译期直接完成,并且函数的返回值可以用来初始化一个编译期常量。对于constexpr修饰的函数,编译器会自动对其加上inline修饰词。
  • 如果传入的不是编译期常量,那么函数的调用执行仍然发生在运行期,和普通的函数没有区别。

例如

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

constexpr int func(int n) { return n * n; }

int main() {
constexpr int a1 = func(10); // 编译期执行
int array[a1];
std::cout << array[0] << "\n";

int a2 = 10;
int tmp = func(a2); // 运行期执行
std::cout << tmp << "\n";
}

为了让constexpr函数支持编译期计算,在语法上对函数自身以及函数体内部的语句会有一些特殊要求,要求比较繁琐这里不作详细讨论,显然能通过编译的就是满足要求的,编译报错就代表不满足。

随着C++的逐渐发展,编译期的计算能力仍然在被不断地强化,在新标准中这些特殊要求正在被不断地削弱:

  • C++11只允许包括一条return语句
  • C++14放松了限制,允许更多的语句
  • C++17支持constexpr版本的if语句
  • C++20支持constexpr版本的new语句,并且还支持constexpr的虚函数(对虚函数的编译期求值,实际上将其从运行期多态变成了编译期多态)

补充:

  • 如果使用constexpr修饰类的成员函数,并不代表这个方法是const的成员函数。
  • constexpr(和下面的consteval)都可以用来修饰Lambda表达式,并且即使Lambda表达式没有被明确标注为constexpr,只要它本身符合要求,编译器也会自动将其标注为constexpr

consteval 函数

前面的constexpr函数只是支持在编译期求值,但是函数调用到底在编译期执行还是在运行期执行仍然是不确定的,这取决于调用时传入的参数是不是编译期常量。 consteval函数则具有更强的语义:consteval函数支持并且必须在编译期执行,因此要求传入的参数也必须是编译期常量,并且函数的每一次调用都会返回一个编译期常量。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>

consteval int func(int n) { return n * n; }

int main() {
constexpr int a1 = func(10); // 编译期执行
int array[a1];
std::cout << array[0] << "\n";

int a2 = 10;
int tmp = func(a2); // compile error
std::cout << tmp << "\n";
}

小结

这几个关键词的主要语义如下:

  • const类型
    • 可能在编译期初始化,也可能在运行期初始化
    • 保证在运行期不可变
  • constexpr变量
    • 保证在编译期初始化,不允许单独的声明语句
    • 保证在运行期不可变
    • 变量的类型自动具有const限定,对静态变量自动添加inline修饰
  • constinit变量
    • 保证在编译期初始化
    • 要求变量必须具有全局生命周期
  • constexpr函数
    • 支持在编译期调用求值
      • 如果传入参数为编译期常量,调用求值发生在编译期,返回编译期常量
      • 否则,调用求值发生在运行期,与普通函数没有区别
    • 自动添加inline修饰
  • consteval函数
    • 保证在编译期调用求值,要求传入的参数必须都是编译期常量,返回编译期常量
    • 自动添加inline修饰

补充

const成员函数

const除了表示一个变量在运行期不可变的语义,在C++面向对象的语法中还可以用来修饰类的成员函数,表明这个成员函数不会修改当前变量自身的信息。

考虑下面的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

struct Demo {
int get_data() {
std::cout << "call get_data()\n";
return m_data;
}

int get_data() const {
std::cout << "call get_data() const\n";
return m_data;
}

int m_data;
};

int main() {
Demo a{1};
std::cout << "a data :\n" << a.get_data() << "\n";

const Demo b{3};
std::cout << "b data :\n" << b.get_data() << "\n";
}

运行结果如下

1
2
3
4
5
6
a data :
call get_data()
1
b data :
call get_data() const
3

这里非const的对象和const的对象分别自动调用了非constconst版本的方法。

注意到这里两个同名的成员函数的形参列表是一样的,只是后者含有const修饰,相同的名称并没有导致编译错误,因为它们本质上构成函数重载的关系,实际的区别是this指针的类型:

  • 普通的非静态成员函数,this指针类型的Demo *
  • 使用const修饰的非静态成员函数,this指针类型的const Demo *

C++完全隐藏非静态成员函数的this指针的传递过程,因此只能让const出现在形参列表之后。相对于Python显式出现的self参数。

我们也可以将其改写为下面这种更本质的形式(this是C++的关键词,因此这里改用this_

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

struct Demo {
int m_data;
};

int get_data(Demo *const this_) {
std::cout << "call get_data()\n";
return this_->m_data;
}

int get_data(const Demo *const this_) {
std::cout << "call get_data() const\n";
return this_->m_data;
}

int main() {
Demo a{1};
std::cout << "a data :\n" << get_data(&a) << "\n";

const Demo b{3};
std::cout << "b data :\n" << get_data(&b) << "\n";
}

对于const Demo对象或者对应的指针,在语法上要求:(这里不考虑权限问题)

  • 可以调用标记为const的非静态成员函数,this指针类型为const Demo *const
  • 不能调用普通的非静态成员函数,因为const Demo *const无法转换为Demo *const
  • 可以调用静态成员函数,因为不涉及到this指针的传递

对于非const修饰的Demo对象或对应的指针则各种成员函数都可以调用,因为Demo *const转换为const Demo *const是允许的。

需要注意的是,const方法虽然不允许修改成员变量的值,但是如果成员变量是指针或者引用类型,那么对于指向内容的修改还是被允许的,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

struct Demo {
Demo(int data, int &data_ref, int *data_ptr)
: m_data(data), m_data_ref(data_ref), m_data_ptr(data_ptr) {}

void update() const {
// m_data++; // compile error
m_data_ref++;
(*m_data_ptr)++;
}

int m_data;
int &m_data_ref;
int *m_data_ptr;
};

int main() {
int a1 = 10;
int &b = a1;

int a2 = 20;
int *c = &a2;

Demo demo(0, b, c);
demo.update();

std::cout << a1 << std::endl; // 11
std::cout << a2 << std::endl; // 21
}

这个问题本身还是可以从this指针具有的const修饰来理解,const只会影响自己的值成员的修改,对于引用和指针成员的间接修改没有约束,下面的改写形式也是一样的结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

struct Demo {
int m_data;
int &m_data_ref;
int *m_data_ptr;
};

void update(const Demo *const this_) {
// (this_->m_data)++; // compile error
(this_->m_data_ref)++;
(*(this_->m_data_ptr))++;
}

int main() {
int a1 = 10;
int &b = a1;

int a2 = 20;
int *c = &a2;

Demo demo{.m_data = 1, .m_data_ref = b, .m_data_ptr = c};
update(&demo);

std::cout << a1 << std::endl; // 11
std::cout << a2 << std::endl; // 21
}

对于成员函数中this的修饰,除了加上constvolatile这种最基础的,还可以使用引用或右值引用等,这里不作讨论。

mutable成员变量

在面向对象的编程实践中,人们发现const的要求还是太强了,有时确实需要对一个const对象或者在对象的const成员函数中修改某些成员变量,我们可以使用mutable修饰特定的成员变量,此时对它的修改将不再受到const的约束。

前文中已经提到了,const约束在运行期实际是不存在的,只是编译期由编译器提供的一种语法检查,标记为mutable相当于让编译器开绿灯,对这个成员变量不需要进行此项检查。

考虑一个例子:我们设计了一个支持查询功能的字典类,显然查询过程不会改变内部状态,使用const对象以及const版本的查询方法在语义上是非常合理的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>
#include <string>
#include <unordered_map>

class Dict {
public:
std::string get(const std::string &key) const {
return lookup(key);
}

private:
std::string lookup(const std::string &key) const {
auto it = m_data.find(key);
if (it != m_data.end()) { return it->second; }
return "not found";
}

std::unordered_map<std::string, std::string> m_data{
{"key1", "value1"}, {"key2", "value2"}, {"key3", "value3"}};
};

int main() {
const Dict a;
std::cout << a.get("key1") << std::endl;
std::cout << a.get("key2") << std::endl;
std::cout << a.get("key1") << std::endl;
return 0;
}

但是实践中的查询操作可能非常费时,而且大概率会重复多次查询同一个结果, 我们考虑加上缓存:将最近一次的查询信息记录一下,如果再次查询则直接返回,从而可以优化查询速度。

当然这种升级不能影响对外的接口,我们不能移除任何的const,这种需求通过mutable就可以实现,示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <string>
#include <unordered_map>

class Dict {
public:
std::string get(const std::string &key) const {
if (key == m_lastKey) { return m_lastValue; }

std::string value = lookup(key);
m_lastKey = key;
m_lastValue = value;
return value;
}

private:
std::string lookup(const std::string &key) const {
auto it = m_data.find(key);
if (it != m_data.end()) { return it->second; }
return "not found";
}

std::unordered_map<std::string, std::string> m_data{
{"key1", "value1"}, {"key2", "value2"}, {"key3", "value3"}};

mutable std::string m_lastKey;
mutable std::string m_lastValue;
};

int main() {
const Dict a;
std::cout << a.get("key1") << std::endl;
std::cout << a.get("key2") << std::endl;
std::cout << a.get("key1") << std::endl;
return 0;
}

这里我们使用内部成员变量记录了上一次查询的键和值,都使用mutable修饰,从而可以顺利通过编译。

除了上面的例子,还有一种使用mutable的常见情景:针对多线程问题的互斥锁作为成员变量时,必须将其修饰为mutable才能对const操作发挥作用。

示例代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ThreadSafeCounter {
public:
ThreadSafeCounter() : m_count(0) {}

void increment() {
std::lock_guard<std::mutex> lock(m_mutex);
++m_count;
}

int get_count() const {
std::lock_guard<std::mutex> lock(m_mutex);
return m_count;
}

private:
mutable std::mutex m_mutex;
mutable int m_count;
};

这里的get_count()方法在语义上是不会影响内部状态的,所以将其设置为const方法。 但是为了线程安全,我们还是为读操作也加上了互斥锁,这个互斥锁必须作为成员变量存在,并且必须是可修改的,因此必须使用mutable修饰来通过编译。

constexpr 元编程

constexpr(以及consteval)在元编程中也发挥了巨大作用,具有非常大的潜力。

我们可以使用constexpr将原本的模板元编程代码进行简化,移除不必要的模板类型包装, 例如我们希望在编译期判断两个类型是否相同,通过模板元编程可以如下实现

1
2
3
4
5
6
7
8
9
template <class, class>
struct is_same {
static const bool value = false;
};

template <class T>
struct is_same<T, T> {
static const bool value = true;
};

使用方法例如

1
bool flag = is_same<T1,T2>::value;

通过constexpr可以将实现和使用简化

1
2
3
4
5
template <class, class>
constexpr bool is_same_v = false;

template <class T>
constexpr bool is_same_v<T, T> = true;

使用方法例如

1
bool flag = is_same_v<T1,T2>;

我们还可以完全移除非类型的模板参数,例如原本通过模板元编程实现Fibonacci数列时, 需要提供一个类型并将N作为非类型的模板参数,每次访问类的const静态成员value

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

template <size_t N>
struct Fibonacci {
static const size_t value = Fibonacci<N - 1>::value + Fibonacci<N - 2>::value;
};

template <>
struct Fibonacci<1> {
static const size_t value = 1;
};

template <>
struct Fibonacci<0> {
static const size_t value = 0;
};

int main() {
static_assert(Fibonacci<10>::value == 55);

std::cout << "Fibonacci<10>::value = " << Fibonacci<10>::value << std::endl;
return 0;
}

使用constexpr变量可以简化一下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

template <int N>
constexpr int fibonacci = fibonacci<N - 1> + fibonacci<N - 2>;

template <>
constexpr int fibonacci<1> = 1;

template <>
constexpr int fibonacci<0> = 0;

int main() {
static_assert(fibonacci<10> == 55);

std::cout << "Fibonacci<10>::value = " << fibonacci<10> << std::endl;
return 0;
}

这还不是最简单的方案,使用constexpr函数,我们可以直接将n作为函数参数进行编译期计算

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>

constexpr int fibonacci(int n) {
return (n <= 1) ? n : (fibonacci(n - 1) + fibonacci(n - 2));
}

int main() {
static_assert(fibonacci(10) == 55, "Fibonacci<10> should be 55");

std::cout << "Fibonacci<10> = " << fibonacci(10) << std::endl;
return 0;
}

在最后的方案中,我们完全移除了模板类,使用constexpr函数完成了编译期计算。 有的人对C++不经意间产生的模板元编程一直抱有的看法是:屠龙之术+逆练九阴真经, 模板元编程利用模板的匹配规则以非常晦涩的语法完成了编译期计算,与之相对的,以constexpr为代表的语法看上去就特别的自然。