CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥交易系统开发岗位求职与面试指南统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • Go系统接口编程
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • C++语言面试问题集锦 目录与说明
  • 第一章 auto与类型推导
  • 第二章 关键字static及其不同用法
  • 第三章 多态、继承和虚函数
  • 第四章 Lambda函数
  • 第五章 C++中如何使用 const 限定符
    • 问题32:下面这段代码的输出是什么,为什么?
    • 问题33:使用 const 局部变量有哪些优点?
    • 问题34:在类中使用 const 成员是个好主意吗?
    • 问题35:按值返回 const对象有意义吗?
    • 问题36:如何从函数中返回常量指针(const pointer)?
    • 问题37:函数应该返回常量引用(const reference)吗?
    • 问题38:函数参数应该使用常量引用(const reference)来传递普通旧数据类型(plain old data types)吗?
    • 问题39:函数参数应该使用常量引用(const reference)来传递对象吗?
    • 问题40:函数声明的签名必须与函数定义的签名匹配吗?
    • 问题41:解释consteval和constinit为C++带来了什么?
  • 第六章 Modern C++的一些最佳实践
  • 第七章 智能指针
  • 第八章 引用、万能引用等
  • 第九章 C++20相关问题
  • 第十章 特殊函数及数量规则
  • 第十一章 C++面向对象设计
  • 第十二章 程序质量
  • 第十三章 标准模板库
  • 第十四章 杂项
  • cppinterviewmostaskedquestions
zhangxf
2025-03-27
目录

第五章 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';
}
1
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
*/
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

仔细想想,这是完全合理的。一个变量在初始化后就不能再被更改。而当你想给一个对象(也就是它的成员)赋新值时,这就不再可能了。

同样的原因,这也使得使用移动语义变得不可能。

从错误信息中可以看到,相应的特殊函数,如赋值运算符或移动赋值运算符被删除了。这意味着我们必须手动实现它们。别忘了“五法则”⁷⁵。如果我们实现了其中一个,就必须实现全部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;
}
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

这值得吗?

你有了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();
}
1
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();
}
1
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
1

这个函数是常量函数,返回的指针是常量指针,但我们指向的数据可以被修改。然而,我认为返回这样的常量指针没有意义,因为最终的函数调用会产生一个右值(rvalue),而非类类型的右值不能是常量,这意味着const关键字无论如何都会被忽略。

const  int*  func  () const
1

这是有用的。被指向的数据不能被修改。

const  int  * const  func() const
1

从语义上讲,这和前一种情况几乎一样。被指向的数据不能被修改。另一方面,指针本身的常量属性会被忽略。

那么返回常量指针有意义吗?这取决于const修饰的是什么。如果const修饰的是被指向的对象,那么有意义。如果你试图让指针本身成为常量,那就没有意义了,因为它会被忽略。

# 问题37:函数应该返回常量引用(const reference)吗?

这要视情况而定,你可能会遇到问题。也许会出现悬空引用(dangling reference)。返回常量引用的问题在于,返回的对象的生命周期必须长于调用者,或者至少要和调用者的生命周期一样长。

void  f() {
    MyObject o;
    const  auto& aRef = o.getSomethingConstRef();
    aRef.doSomething();
}
1
2
3
4
5

这个调用会成功吗?不一定。如果MyObject::getSomethingConstRef()返回的是一个局部变量的常量引用,那么就不会成功。这是因为一旦我们离开函数作用域,这个局部变量就会立即被销毁。

const  T& MyObject::getSomethingConstRef() {
    T ret;
    //  . . .
    return  ret; 
}
1
2
3
4
5

这就是所谓的悬空引用。

另一方面,如果我们返回的是对MyObject成员的引用,在上面的例子中就没有问题。

class  MyObject  {
public :
    //  . . .

    const  T& getSomethingConstRef() {
        //  . . .
        return  m_t; 
    }
private :
    T m_t;
};
1
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;
}
1
2
3

我知道这看起来可能有些多余,但它没有坏处,而且很明确。你不知道这个方法将来会如何扩展,也许会有一些额外的检查、异常处理等等。

如果没有将其标记为const,可能有人会不小心修改它的值,从而导致一些难以察觉的错误。

如果你将foo标记为const,就可以避免这种情况。

最坏的情况会怎样呢?你可能确实需要去掉const限定符,但那也是有意为之。

另一方面,如果你需要修改参数,就不要将其标记为const。

你时不时会看到以下这种模式:

void  doSomething(const  int  foo) {
    //  . . .
    int  foo2 = foo;
    foo2++;
    //  . . .
}
1
2
3
4
5
6

不要这么做。如果你打算修改一个值,就没有理由将其作为const值来传递。这会在栈上无端多一个变量,还会无缘无故多一次赋值操作。直接按值传递就好。

void  doSomething(int  foo) {
    //  . . .
    foo++;
    //  . . .
}
1
2
3
4
5

所以我们不使用const&来传递基本数据类型,只有在不想修改它们的值时才将其标记为const。

# 问题39:函数参数应该使用常量引用(const reference)来传递对象吗?

如果我们按值传递一个类对象作为参数,这意味着我们会创建一个该对象的副本。一般来说,复制一个对象比仅仅传递一个引用的开销更大。

所以一般的经验法则是,不要按值传递对象,而是使用const&来传递,以避免复制。

显然,如果你想修改原始对象,那么就只通过引用传递,并且不要使用const。

如果你知道必须创建对象的副本,那么可以按值传递对象。

void  doSomething(const  ClassA& foo) {
    //  . . .
    ClassA foo2 = foo;
    foo2.modify();
    //  . . .
}
1
2
3
4
5
6

在这种情况下,直接按值传递就好。这样可以省去传递引用的开销,也不用再费心思声明另一个变量并调用复制构造函数。

不过值得注意的是,如果你习惯使用const&来传递对象,可能会多花些心思去判断按值传递是有意为之还是出于失误。

所以这种额外的思考是否值得,还存在疑问。

void  doSomething(ClassA foo) {
    //  . . .
    foo.modify();
    //  . . .
}
1
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);
}
1
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;
}
1
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  {
        |              ^~~~~~~
1
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;
}
1
2
3

consteval函数保证在编译时执行,因此它们会创建编译时常量。它们不能分配或释放数据,也不能与静态变量或线程局部变量交互。

constinit可应用于具有静态存储期(static storage duration)或线程存储期(thread storage duration)的变量。所以局部变量或成员变量不能用constinit修饰。它保证变量的初始化在编译时进行。

必须注意的是,虽然constexpr和const变量一旦赋值就不能更改,但constinit变量不是常量,其值可以改变。

上次更新: 2025/04/01, 13:21:34
第四章 Lambda函数
第六章 Modern C++的一些最佳实践

← 第四章 Lambda函数 第六章 Modern C++的一些最佳实践→

最近更新
01
第一章 auto与类型推导
03-27
02
第二章 关键字static及其不同用法
03-27
03
C++语言面试问题集锦 目录与说明
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式