第五章 C++中如何使用 const 限定符
# 第五章 C++中如何使用 const 限定符
在本章接下来的几个问题中,我们将学习const
限定符及其正确用法。
# 问题32:下面这段代码的输出是什么,为什么?
#include <iostream>
class A {
public:
int value() { return 1; }
int value() const { return 2; }
};
int main() {
A a;
const auto b = a.value();
std::cout << b << '\n';
}
2
3
4
5
6
7
8
9
10
11
12
13
输出将是1。如果a
被声明为const
,而b
不是,结果将是2。实际上,结果与b
是否为const
无关。
为什么会这样呢?
如果一个函数有两个重载版本,一个是const
版本,另一个不是,编译器会根据对象是否为const
来选择调用哪个版本。
另一方面,这与返回变量的const
属性无关。
这只是对后续内容的一个简单介绍,接下来我们将讨论const
属性和const
正确性。在C++中,这是一个重要的话题,就像使用const
关键字一样。
# 问题33:使用 const 局部变量有哪些优点?
通过将局部变量声明为const
,你就表明它是不可变的,其值永远不应改变。如果你之后仍试图修改它,将会得到一个编译错误。对于全局变量来说,这非常有用。否则,你根本不知道谁可能会修改它们的值。当然,我们应该避免使用全局变量,你还记得静态初始化顺序混乱的问题吗?
此外,将变量声明为const
还有助于编译器进行一些优化。除非你显式地将变量标记为const
,否则编译器不会知道(至少不能确定)给定的变量不应被更改。同样,只要有可能,我们就应该使用const
。
在实际生活中,我发现我们常常忘记将变量声明为const
,尽管在会议演讲中有很多很好的示例⁷²。将变量声明为const
对代码及其可维护性并没有负面影响。
这是一个非常重要的概念,在Rust语言中,所有变量默认都是const
的,除非你声明它们是可变的。
我们没有理由不遵循类似的做法。
如果你不打算修改局部变量,就将它们声明为const
。至于全局变量,最好避免使用,但如果你非要使用,也尽可能将它们声明为const
。
# 问题34:在类中使用 const 成员是个好主意吗?
首先,为什么要在类中使用const
成员呢?
因为你可能想表明这些成员是不可变的,永远不应改变。
遗憾的是,这会带来一些影响。
第一个影响是,包含const
成员的类是不可赋值的:
class MyClassWithConstMember {
public:
MyClassWithConstMember(int a) : m_a(a) {}
private:
const int m_a;
};
int main() {
MyClassWithConstMember o1{666};
MyClassWithConstMember o2{42};
o1 = o2;
}
/*
main.cpp: In function 'int main()':
main.cpp:12:8: error: use of deleted function 'MyClassWit\
hConstMember& MyClassWithConstMember::operator=(const MyC\
lassWithConstMember&)'
12 | o1 = o2;
| ^~
main.cpp:1:7: note: 'MyClassWithConstMember& MyClassWithC\
onstMember::operator=(const MyClassWithConstMember&)' is \
implicitly deleted because the default definition would b\
e ill-formed:
1 | class MyClassWithConstMember {
| ^~~~~~~~~~~~~~~~~~~~~~
main.cpp:1:7: error: non-static const member 'const int M\
yClassWithConstMember::m_a', cannot use default assignmen\
t operator
*/
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
仔细想想,这是完全合理的。一个变量在初始化后就不能再被更改。而当你想给一个对象(也就是它的成员)赋新值时,这就不再可能了。
同样的原因,这也使得使用移动语义变得不可能。
从错误信息中可以看到,相应的特殊函数,如赋值运算符或移动赋值运算符被删除了。这意味着我们必须手动实现它们。别忘了“五法则”⁷⁵。如果我们实现了其中一个,就必须实现全部5个。 以赋值运算符为例,我们该怎么做呢?
我们是不是应该跳过对const
成员的赋值?这也不太好,因为我们可能在某个地方依赖这个值,或者我们根本就不应该存储这个值。
如果我们真的想实现赋值运算符,就必须使用const_cast
作为变通方法。由于不能直接去掉值的const
属性,你必须将成员值转换为临时的非const
指针。
#include <utility>
#include <iostream>
class MyClassWithConstMember {
public:
MyClassWithConstMember(int a) : m_a(a) {}
MyClassWithConstMember& operator=(const MyClassWithCons\
tMember& other) {
int* tmp = const_cast<int*>(&m_a);
*tmp = other.m_a;
std::cout << "copy assignment\n";
return *this ;
}
int getA() {return m_a;}
private:
const int m_a;
};
int main() {
MyClassWithConstMember o1{666};
MyClassWithConstMember o2{42};
std::cout << "o1.a: " << o1.getA() << std::endl;
std::cout << "o2.a: " << o2.getA() << std::endl;
o1 = o2;
std::cout << "o1.a: " << o1.getA() << std::endl;
}
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
这值得吗?
你有了const
成员,这没问题。赋值操作也能正常工作,这也没问题。但如果之后有人在特殊函数之外想要做同样的“操作”,在代码审查中这肯定会被视为一个问题。
我们不要这么草率地去做。
在特殊函数中使用这种“操作”本身就有问题!const_cast
可能会导致未定义行为:
注意:根据对象的类型,通过
const_cast
去掉const
限定符后得到的指针、左值或指向数据成员的指针进行写操作,可能会产生未定义行为(7.1.5.1)”。
我们刚刚看了拷贝赋值操作,如果不冒着未定义行为的风险,它就无法正常工作。
这不值得!
# 问题35:按值返回 const对象有意义吗?
很可能没什么意义。为什么呢?
在某个地方使用const
是为了向读者(当然还有编译器)表明某些内容不应被修改。当我们按值返回某个对象时,意味着会为调用者创建一个副本。没错,你可能听说过拷贝省略⁷⁸及其特殊形式——返回值优化(RVO),但本质上情况还是一样的。调用者得到的是属于自己的副本。
将这个副本设为const
有意义吗?
想象一下,你买了一栋房子却不能对它进行任何修改?虽然可能存在特殊情况,但一般来说,你肯定希望自己的房子能按照自己的意愿改造。同样,你希望自己得到的副本是真正属于自己的对象,并且作为所有者,你能够随意使用它。
按值返回const
对象既没有意义,还容易让人误解。
它不仅容易让人误解,甚至可能对你的代码产生负面影响。负面影响?怎么会呢? 假设你有这样一段代码:
class SgWithMove {
// . . .
};
SgWithMove foo() {
// . . .
}
int main() {
SgWithMove o;
o = foo();
}
2
3
4
5
6
7
8
9
10
11
12
通过使用调试器或者在特殊函数中添加一些日志,你会发现RVO得到了完美应用,当foo()
的返回值赋给o
时,发生了一次移动操作。
现在,我们在返回类型前加上那个备受争议的const
。
class SgWithMove {
// . . .
};
const SgWithMove bar() {
// . . .
}
int main() {
SgWithMove o;
o = bar();
}
2
3
4
5
6
7
8
9
10
11
12
使用调试器跟踪后会发现,我们没有从移动操作中受益,实际上发生的是一次拷贝操作。
我们返回的是一个const SgWithMove
对象,它不能作为SgWithMove&&
传递,因为这会去掉const
限定符(移动操作会改变被移动的对象)。相反,调用的是拷贝赋值(const SgWithMove&
),于是我们又创建了一个副本。
请注意,有一些重要的书籍提倡按const
值返回用户自定义类型。在它们成书的那个年代,这种建议是正确的,但从那以后,C++发生了很大的变化,这条建议已经过时了。
# 问题36:如何从函数中返回常量指针(const pointer)?
指针和引用类似,被指向的对象至少要在调用者想要使用它的期间内保持有效。如果你知道在调用者需要返回的地址期间,对象不会被销毁,那么你可以返回成员变量的地址。需要再次强调的是,我们绝不能返回指向局部初始化变量的指针。
但即便如此,也并非完全显而易见。我们不妨稍微退一步思考。当我们返回一个指针时,返回的到底是什么呢?
我们返回的是一个内存地址。这个地址可以指向任何东西。从技术上讲,它可以是一个随机地址,可以是一个空指针,也可以是一个对象的地址。(当然,随机地址有可能是一个有效对象的地址,但也可能只是垃圾数据。毕竟它是随机的。)
即使我们讨论的是在包含函数的作用域内声明的对象,该对象也可能是在栈上或堆上声明的。
如果它是在栈上声明的(没有使用new
),这意味着当我们离开包含函数时,它会被自动销毁。
如果对象是在堆上创建的(使用new
),那就没有这个问题了,对象会保持有效,但你必须管理它的生命周期。除非你返回一个智能指针(smart pointer),不过这超出了本文的讨论范围。
所以我们必须确保不返回悬空指针(dangling pointer),但在此之后,返回一个常量指针有意义吗?
int * const func () const
这个函数是常量函数,返回的指针是常量指针,但我们指向的数据可以被修改。然而,我认为返回这样的常量指针没有意义,因为最终的函数调用会产生一个右值(rvalue),而非类类型的右值不能是常量,这意味着const
关键字无论如何都会被忽略。
const int* func () const
这是有用的。被指向的数据不能被修改。
const int * const func() const
从语义上讲,这和前一种情况几乎一样。被指向的数据不能被修改。另一方面,指针本身的常量属性会被忽略。
那么返回常量指针有意义吗?这取决于const
修饰的是什么。如果const
修饰的是被指向的对象,那么有意义。如果你试图让指针本身成为常量,那就没有意义了,因为它会被忽略。
# 问题37:函数应该返回常量引用(const reference)吗?
这要视情况而定,你可能会遇到问题。也许会出现悬空引用(dangling reference)。返回常量引用的问题在于,返回的对象的生命周期必须长于调用者,或者至少要和调用者的生命周期一样长。
void f() {
MyObject o;
const auto& aRef = o.getSomethingConstRef();
aRef.doSomething();
}
2
3
4
5
这个调用会成功吗?不一定。如果MyObject::getSomethingConstRef()
返回的是一个局部变量的常量引用,那么就不会成功。这是因为一旦我们离开函数作用域,这个局部变量就会立即被销毁。
const T& MyObject::getSomethingConstRef() {
T ret;
// . . .
return ret;
}
2
3
4
5
这就是所谓的悬空引用。
另一方面,如果我们返回的是对MyObject
成员的引用,在上面的例子中就没有问题。
class MyObject {
public :
// . . .
const T& getSomethingConstRef() {
// . . .
return m_t;
}
private :
T m_t;
};
2
3
4
5
6
7
8
9
10
11
值得注意的是,在f()
函数外部,我们无法使用aRef
,因为MyObject
的实例在f()
函数结束时会被销毁。
那么我们应该返回常量引用吗?
通常答案是视情况而定。绝对不要自动习惯性地这么做。只有在我们确定被引用的对象在我们想要引用它的时候仍然可用时,才应该返回常量引用。
同时:
永远不要通过引用返回局部初始化的变量!
# 问题38:函数参数应该使用常量引用(const reference)来传递普通旧数据类型(plain old data types)吗?
普通旧数据类型不应该通过常量引用或指针来传递,这样效率很低。如果通过值传递这些数据类型,一次内存读取就可以访问到它们的值。另一方面,如果通过引用/指针传递,首先要读取变量的地址,然后通过解引用才能获取值,这样就需要两次内存读取,而不是一次。
我们不应该使用const&
来传递基本数据类型。但是我们应该简单地使用const
来传递它们吗?
一如既往,这取决于情况。
如果我们不打算修改它们的值,那么应该使用const
。这样做是为了提高代码的可读性,也方便编译器处理,同时为未来的代码维护考虑。
void setFoo(const int foo) {
this->m_foo = foo;
}
2
3
我知道这看起来可能有些多余,但它没有坏处,而且很明确。你不知道这个方法将来会如何扩展,也许会有一些额外的检查、异常处理等等。
如果没有将其标记为const
,可能有人会不小心修改它的值,从而导致一些难以察觉的错误。
如果你将foo
标记为const
,就可以避免这种情况。
最坏的情况会怎样呢?你可能确实需要去掉const
限定符,但那也是有意为之。
另一方面,如果你需要修改参数,就不要将其标记为const
。
你时不时会看到以下这种模式:
void doSomething(const int foo) {
// . . .
int foo2 = foo;
foo2++;
// . . .
}
2
3
4
5
6
不要这么做。如果你打算修改一个值,就没有理由将其作为const
值来传递。这会在栈上无端多一个变量,还会无缘无故多一次赋值操作。直接按值传递就好。
void doSomething(int foo) {
// . . .
foo++;
// . . .
}
2
3
4
5
所以我们不使用const&
来传递基本数据类型,只有在不想修改它们的值时才将其标记为const
。
# 问题39:函数参数应该使用常量引用(const reference)来传递对象吗?
如果我们按值传递一个类对象作为参数,这意味着我们会创建一个该对象的副本。一般来说,复制一个对象比仅仅传递一个引用的开销更大。
所以一般的经验法则是,不要按值传递对象,而是使用const&
来传递,以避免复制。
显然,如果你想修改原始对象,那么就只通过引用传递,并且不要使用const
。
如果你知道必须创建对象的副本,那么可以按值传递对象。
void doSomething(const ClassA& foo) {
// . . .
ClassA foo2 = foo;
foo2.modify();
// . . .
}
2
3
4
5
6
在这种情况下,直接按值传递就好。这样可以省去传递引用的开销,也不用再费心思声明另一个变量并调用复制构造函数。
不过值得注意的是,如果你习惯使用const&
来传递对象,可能会多花些心思去判断按值传递是有意为之还是出于失误。
所以这种额外的思考是否值得,还存在疑问。
void doSomething(ClassA foo) {
// . . .
foo.modify();
// . . .
}
2
3
4
5
你还应该注意,有些对象的复制开销比传递引用的开销小,或者二者开销相近。小字符串优化(Small String Optimization)或std::string_view
就是这种情况。但这超出了本文的讨论范围。
对于对象,我们可以说默认情况下应该使用常量引用传递,如果计划在局部对其进行修改,那么可以考虑按值传递。但绝对不要按常量值(const value)传递,因为这会强制进行复制,同时又不允许我们修改对象。
# 问题40:函数声明的签名必须与函数定义的签名匹配吗?
不一定,至少并非总是如此。参数的const
限定符(const qualifier)对函数定义而言并非总是重要,但对函数声明来说很重要。
例如,以下代码是有效的:
#include <iostream>
class MyClass {
public:
void f(const int);
};
void MyClass::f(int a) {
a = 42;
std::cout << a << std::endl;
}
int main() {
int a=5;
MyClass c;
c.f(a);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
函数声明中的const
意味着它不会修改传入的内容。但实际上它可以修改。同时,这也不是什么大问题,因为它只会修改自身的副本,而不会修改原始变量。
请注意,一旦在声明和定义中都将参数改为引用,代码就无法编译。
#include <iostream>
class MyClass {
public:
void f(const int&);
};
void MyClass::f(int& a) {
a = 42;
std::cout << a << std::endl;
}
int main() {
int a=5;
MyClass c;
c.f(a);
std::cout << a << std::endl;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
main.cpp:8:6: 错误:没有与'void MyClass::f(int&)'匹配的声明
8 | void MyClass::f(int& a) {
| ^~~~~~~
main.cpp:5:8: 注意:候选声明是:'void MyClass::f(const int&)'
5 | void f(const int&);
| ^
main.cpp:3:7: 注意:'class MyClass'在此处定义
3 | class MyClass {
| ^~~~~~~
2
3
4
5
6
7
8
9
但即使是我们最初的用例,clang-tidy
也会发出警告:
函数声明中参数1有const
限定;参数的const
限定仅在函数定义中起作用
# 问题41:解释consteval
和constinit
为C++带来了什么?
C++11引入了constexpr
表达式,这些表达式可能在编译时求值。
C++20引入了两个新的相关关键字:consteval
和constinit
。
consteval
可用于函数:
consteval int sqr(int n) {
return n * n;
}
2
3
consteval
函数保证在编译时执行,因此它们会创建编译时常量。它们不能分配或释放数据,也不能与静态变量或线程局部变量交互。
constinit
可应用于具有静态存储期(static storage duration)或线程存储期(thread storage duration)的变量。所以局部变量或成员变量不能用constinit
修饰。它保证变量的初始化在编译时进行。
必须注意的是,虽然constexpr
和const
变量一旦赋值就不能更改,但constinit
变量不是常量,其值可以改变。