面试的时候,"请你讲一下 C++ 里 const 关键字的用法"几乎和 static 一样高频。
很多人的第一反应是"const 就是定义常量嘛"。没错,但这只是冰山一角。const 在 C++ 里渗透到了变量、指针、函数参数、函数返回值、类成员函数等几乎所有角落,每个场景下的含义和规则都不太一样。
我们从最简单的地方开始,一层一层往深了走。
定义常量:const 最基本的用法
在 C 语言里,你可能习惯用 #define 来定义常量:
#define MAX_SIZE 100
这行代码在预处理阶段会被简单地做文本替换——编译器根本看不到 MAX_SIZE 这个名字,它只看到一个数字 100。这意味着如果你不小心写了 #define MAX_SIZE "hello",编译器不会在定义的地方报错,而是在使用的地方报出一堆莫名其妙的类型错误,排查起来很痛苦。
C++ 提供了更好的方式:
const int maxSize = 100;
maxSize 是一个有类型的常量。编译器知道它是 int,如果你试图把它当字符串用,编译器会在使用的地方给出清晰的类型错误提示。
而且 const 不限于 int,任何类型都可以:
const double pi = 3.14159265358979;
const std::string greeting = "hello world";
const char newline = '\n';
定义之后,这些值就不能再被修改了:
const int maxSize = 100;
maxSize = 200; // 编译错误:assignment of read-only variable
还有一个细节:const 变量必须在定义时初始化。因为定义之后就不能改了,如果不给初始值,这个变量就永远没有值,毫无意义。
const int x; // 编译错误:uninitialized const 'x'
const 与 #define 的区别
面试里经常会追问这两者的区别,值得展开说说。
#define 是预处理器指令,在编译之前就完成了文本替换。编译器完全不知道 MAX_SIZE 的存在,调试的时候你在调试器里也看不到这个名字,只能看到一个裸的数字 100。
const 变量是编译器层面的东西,有类型、有名字、有作用域。调试时你能看到变量名,编译器能帮你做类型检查。
从内存的角度看也有区别。#define 做的是文本替换,每个使用的地方都会产生一份字面量。而 const 变量在内存中只有一份,所有使用的地方都引用同一个地址。当然,对于简单的整型常量,编译器通常会优化成立即数,两者的实际开销差不多。但对于字符串这样的复杂类型,const 变量只有一份拷贝的优势就很明显了。
还有一个容易忽略的点:#define 没有作用域的概念,一旦定义就在整个编译单元内有效(除非你手动 #undef)。而 const 变量遵循正常的作用域规则:
void foo() {
const int limit = 10;
// limit 只在 foo() 内部可见
}
void bar() {
// 这里访问不到 limit
}
所以在 C++ 里,除非你需要做条件编译之类的预处理操作,否则应该优先用 const(或者 constexpr)来定义常量,而不是 #define。
const 对象默认为文件局部变量
这是一个很多人不知道的规则,但面试偶尔会考。
在 C++ 里,一个普通的全局变量默认具有外部链接(external linkage),其他文件可以通过 extern 来访问它:
// counter.cpp
int totalCount = 0;
// main.cpp
extern int totalCount;
int main() {
std::cout << totalCount << "\n"; // 正常输出 0
}
这没问题。但如果给 totalCount 加上 const:
// counter.cpp
const int totalCount = 0;
// main.cpp
extern const int totalCount;
int main() {
std::cout << totalCount << "\n"; // 链接错误!
}
链接器会报 undefined reference to 'totalCount'。
为什么?因为 C++ 规定:const 全局变量默认具有内部链接(internal linkage),相当于自动加了 static。它只在定义它的那个文件里可见。
这个设计是有道理的。const 常量经常被定义在头文件里,如果它默认是外部链接,那么多个 .cpp 文件 include 同一个头文件时,链接器就会看到多个同名定义,违反 One Definition Rule。默认内部链接就避免了这个问题——每个编译单元各自有一份,互不冲突。
如果你确实需要一个 const 全局变量在多个文件之间共享,必须在定义时显式加上 extern:
// counter.cpp
extern const int totalCount = 0; // 注意:定义时加 extern
// main.cpp
extern const int totalCount; // 声明
int main() {
std::cout << totalCount << "\n"; // 正常输出 0
}
定义时的 extern 告诉编译器:"这个 const 变量要用外部链接,别给我默认成内部的。"
指针与 const:四种组合
这是 const 最让人头疼的部分,也是面试的重灾区。指针和 const 组合在一起,一共有四种写法,每种含义不同。
我们先不急着看四种,从一个具体的场景出发。假设你有一个字符串,用指针指向它:
char greeting[] = "hello";
char* p = greeting;
现在 p 指向 greeting 的第一个字符。你既可以修改指针的指向(让 p 指向别的地方),也可以通过指针修改内容(改变 greeting 里的字符):
*p = 'H'; // 修改内容:greeting 变成 "Hello"
p = nullptr; // 修改指向:p 不再指向 greeting
const 的作用就是锁住其中一个或两个能力。
指向常量的指针(const 在 * 左边)
const char* p = greeting;
这里 const 修饰的是 char,也就是 p 所指向的内容。意思是:你不能通过 p 来修改它指向的内容,但 p 本身可以改变指向。
*p = 'H'; // 编译错误!不能通过 p 修改内容
char other[] = "world";
p = other; // 没问题,p 可以改变指向
顺便说一句,const char* 和 char const* 是完全一样的写法,只是 const 的位置不同:
const char* p1 = greeting; // const 在 char 前面
char const* p2 = greeting; // const 在 char 后面,含义完全相同
两种写法在实际项目中都能见到,不用纠结哪种"更正确"。
这里有一个重要的规则:你可以把非 const 对象的地址赋给指向 const 的指针,但反过来不行。
int value = 42;
const int* p = &value; // 没问题:非 const 地址 → 指向 const 的指针
这很合理——你有一个普通变量,你选择用一个"只读视角"去看它,这不会造成任何问题。value 本身还是可以被修改的,只是不能通过 p 来改:
value = 100; // 没问题,直接修改 value
std::cout << *p << "\n"; // 输出 100,p 看到了变化
*p = 200; // 编译错误!不能通过 p 修改
但反过来,把 const 对象的地址赋给普通指针,编译器会拒绝:
const int limit = 10;
int* q = &limit; // 编译错误!const int* → int* 不允许
为什么?如果允许的话,你就能通过 q 去修改 limit 的值,这就破坏了 const 的承诺。
还有一个相关的细节:const 对象的地址不能用 void* 保存,必须用 const void*:
const int limit = 10;
const void* vp = &limit; // 没问题
void* vp2 = &limit; // 编译错误!
道理是一样的——void* 没有 const 限定,如果允许的话,就可能通过类型转换绕过 const 保护。
常指针(const 在 * 右边)
char* const p = greeting;
这里 const 修饰的是 p 本身,也就是指针变量。意思是:p 的指向不能改变,但可以通过 p 修改它指向的内容。
*p = 'H'; // 没问题,可以修改内容
char other[] = "world";
p = other; // 编译错误!p 的指向不能改变
因为 p 本身是 const 的,所以它必须在定义时初始化——跟 const int 必须初始化是一个道理:
char* const p; // 编译错误!const 指针必须初始化
还有一个容易踩的坑:常指针只承诺"指向不变",但对指向的内容没有任何限制。所以你不能把一个 const 对象的地址赋给常指针(除非指针本身也指向 const):
const int limit = 10;
int* const p = &limit; // 编译错误!const int* → int* 不允许
这里的问题不在于 p 是不是 const 指针,而在于 p 指向的类型是 int(非 const),而 limit 是 const int。如果允许的话,你就能通过 *p = 20 来修改 limit。
要修复这个问题,需要让指针指向的类型也是 const:
const int* const p = &limit; // 没问题
指向常量的常指针(const 在 * 两边)
const char* const p = greeting;
这是最严格的组合:指向不能改,内容也不能通过 p 修改。
*p = 'H'; // 编译错误!
p = nullptr; // 编译错误!
一个好用的阅读技巧:从右往左读
面对复杂的指针声明,有一个简单的技巧:用英文从右往左读,遇到 * 读作 "pointer to"。
const char* p; // p is a pointer to const char
char const* p; // p is a pointer to const char(同上)
char* const p; // p is a const pointer to char
const char* const p; // p is a const pointer to const char
"pointer to" 之前的部分描述指针本身的特性(是不是 const),之后的部分描述指向目标的特性(是不是 const)。
用中文来说就是:* 左边的 const 管内容,* 右边的 const 管指针。记住这一条规则,四种组合就不会搞混了。
函数参数中的 const:值传递
const 在函数参数里的用法,面试也经常问。我们按参数传递方式来分别讨论。
先看值传递:
void printValue(const int x) {
// x 在函数内不能被修改
x = 10; // 编译错误!
}
这种写法语法上没问题,但实际意义不大。因为值传递本身就会复制一份参数,函数内部拿到的是副本,你改不改这个副本都不影响调用者。所以给值传递的参数加 const 更多是一种"自我约束"——防止你在函数体内不小心改了这个参数,导致逻辑错误。
对于 int、double 这样的内置类型,大多数人不会加 const,因为复制成本极低,改了也无所谓。但有些团队的编码规范会要求加上,这属于风格偏好。
函数参数中的 const:指针传递
指针参数和 const 的组合就有实际意义了。来看一个经典的例子——字符串复制函数:
void stringCopy(char* dst, const char* src) {
while (*src) {
*dst = *src;
dst++;
src++;
}
*dst = '\0';
}
src 是输入参数,我们只需要读取它,不应该修改它。加上 const 之后,如果函数体内不小心写了 *src = 'x',编译器会立刻报错。这就是 const 作为"契约"的体现——它向调用者承诺"我不会动你传进来的数据"。
dst 是输出参数,我们需要往里面写数据,所以不能加 const。
如果你把 const 加在 * 右边:
void foo(int* const p) {
*p = 10; // 没问题,可以修改指向的内容
p = nullptr; // 编译错误!不能修改指针本身
}
这种写法的意义也不大,因为指针本身是值传递的——你在函数内改变 p 的指向,不影响调用者。和 const int x 一样,属于自我约束。
函数参数中的 const:引用传递
这是 const 在函数参数中最重要的用法,面试必考。
先看一个没有 const 的引用参数:
void process(std::string& s) {
// 可以读取 s,也可以修改 s
s += " processed";
}
引用传递不会复制对象,效率很高。但问题是,调用者把自己的字符串传进来,函数可能会修改它。如果调用者不希望自己的数据被改动,就会很不安。
加上 const 就解决了这个问题:
void process(const std::string& s) {
std::cout << s << "\n"; // 可以读取
s += " processed"; // 编译错误!不能修改
}
const std::string& 的含义是:我借用你的对象来读,但我承诺不会修改它。
这种写法同时获得了两个好处:引用传递避免了复制的开销,const 保证了数据的安全。所以在 C++ 里有一条广为人知的最佳实践:
对于非内置类型的输入参数,优先使用 const 引用传递。
来对比一下三种写法:
void display(std::string s); // 值传递:会复制整个字符串
void display(std::string& s); // 引用传递:不复制,但可能被修改
void display(const std::string& s); // const 引用:不复制,也不会被修改
第三种是最佳选择。它既高效(不复制),又安全(不修改)。
那对于 int、double 这样的内置类型呢?需不需要写成 const int&?
void calculate(const int& x); // 没必要
void calculate(int x); // 直接值传递就好
完全没必要。内置类型本身就很小(通常 4 或 8 字节),复制的成本和传引用差不多,甚至更快(因为引用本质上是指针,多了一次间接寻址)。写成 const int& 反而让代码变复杂了,没有任何收益。
所以规则很简单:内置类型用值传递,自定义类型(类、结构体、容器等)用 const 引用传递。
函数返回值中的 const
const 也可以修饰函数的返回值,但不同情况下意义差别很大。
返回 const 值:
const int getCode() {
return 42;
}
这基本没有意义。返回值本身就是一个临时量,调用者拿到之后赋给自己的变量,加不加 const 都不影响调用者怎么使用这个值。
返回 const 指针:
const char* getMessage() {
return "server error";
}
这个就有意义了。返回的指针指向一个字符串字面量,字面量是只读的,用 const char* 返回能防止调用者试图通过指针修改它。
返回 const 引用:
class Database {
std::string connectionString;
public:
const std::string& getConnectionString() const {
return connectionString;
}
};
返回 const 引用既避免了复制,又防止调用者通过引用修改对象内部的数据。这在类的 getter 方法中非常常见。
类中的 const 成员函数
这是 const 在类里面最核心的用法。
先看一个简单的类:
class Rectangle {
double width;
double height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() {
return width * height;
}
void scale(double factor) {
width *= factor;
height *= factor;
}
};
area() 只是读取数据做计算,不修改任何成员变量。scale() 会修改 width 和 height。
现在的问题是:如果你有一个 const 的 Rectangle 对象,能调用哪些方法?
const Rectangle r(3.0, 4.0);
r.area(); // 编译错误!
r.scale(2); // 编译错误!
两个都不能调用。因为编译器不知道 area() 到底会不会修改对象——它的声明里没有任何承诺。
解决办法是给 area() 加上 const:
double area() const {
return width * height;
}
函数签名末尾的 const 告诉编译器:"这个函数不会修改对象的任何成员变量。"有了这个承诺,const 对象就可以调用它了:
const Rectangle r(3.0, 4.0);
r.area(); // 没问题,area() 是 const 成员函数
r.scale(2); // 编译错误!scale() 不是 const 成员函数
如果你在 const 成员函数里不小心修改了成员变量,编译器会报错:
double area() const {
width = 0; // 编译错误!const 成员函数不能修改成员变量
return width * height;
}
const 成员函数里也不能调用非 const 的成员函数:
double area() const {
scale(1.0); // 编译错误!const 成员函数不能调用非 const 成员函数
return width * height;
}
道理很直接:scale() 可能会修改成员变量,而 const 成员函数承诺了不修改,所以不能调用可能违反承诺的函数。
反过来,非 const 成员函数可以调用 const 成员函数,没有任何问题:
void scale(double factor) {
double oldArea = area(); // 没问题,非 const 可以调用 const
width *= factor;
height *= factor;
}
一个好的编程习惯是:任何不修改成员变量的函数都应该声明为 const。这不仅让 const 对象能调用它,更重要的是向读代码的人传达了意图——"这个函数是只读的,不会有副作用"。
const 对象与成员函数的调用规则
把上面的规则总结成一张表:
| 调用者 | 能调用 const 成员函数? | 能调用非 const 成员函数? |
|---|---|---|
| 非 const 对象 | 能 | 能 |
| const 对象 | 能 | 不能 |
非 const 对象什么都能调用,const 对象只能调用 const 成员函数。
来看一个完整的例子验证:
class Circle {
double radius;
public:
Circle(double r) : radius(r) {}
double getRadius() const {
return radius;
}
void setRadius(double r) {
radius = r;
}
};
Circle c1(5.0);
c1.getRadius(); // 没问题
c1.setRadius(10.0); // 没问题
const Circle c2(5.0);
c2.getRadius(); // 没问题,getRadius() 是 const
c2.setRadius(10.0); // 编译错误!setRadius() 不是 const
类中的 const 成员变量
类的成员变量也可以是 const 的,表示这个成员在对象创建之后就不能再修改。
class Student {
const int id;
std::string name;
public:
Student(int i, const std::string& n) : id(i), name(n) {}
int getId() const { return id; }
std::string getName() const { return name; }
};
id 是 const 成员,每个学生的学号在创建时确定,之后不能更改。
这里有一个关键的规则:const 成员变量必须通过初始化列表来初始化,不能在构造函数体内赋值。
// 正确:用初始化列表
Student(int i, const std::string& n) : id(i), name(n) {}
// 错误:在构造函数体内赋值
Student(int i, const std::string& n) {
id = i; // 编译错误!id 是 const,不能赋值
name = n; // 这行没问题,name 不是 const
}
为什么?因为初始化列表是在对象创建时直接初始化成员,而构造函数体内的 = 是赋值操作。const 变量可以被初始化,但不能被赋值——初始化是"出生时就有值",赋值是"出生后再改值"。
如果你用的是 C++11 或更新的标准,const 成员变量也可以在声明时给默认值:
class Config {
const int maxRetry = 3; // C++11 起支持
const std::string version = "1.0";
public:
Config() {} // maxRetry 和 version 使用默认值
Config(int retry) : maxRetry(retry) {} // 覆盖默认值
};
const 与 static 在类中的组合
面试里有时会把 const 和 static 放在一起考。
普通的 static 成员变量不能在类内初始化(C++17 之前),需要在类外定义:
class Counter {
static int total; // 声明
};
int Counter::total = 0; // 定义
但 static const 整型成员是个例外——它可以直接在类内初始化:
class Buffer {
static const int defaultSize = 1024; // 没问题!
char data[defaultSize]; // 可以直接用
};
这是因为 static const 整型在编译期就能确定值,编译器可以把它当作编译期常量来使用(比如作为数组大小)。
但如果是非整型的 static const,在 C++11 之前仍然需要类外定义:
class Config {
static const double pi; // 只能声明
};
const double Config::pi = 3.14159; // 类外定义
C++17 引入了 inline static 之后,这些限制就都不存在了:
class Config {
inline static const double pi = 3.14159; // C++17 起没问题
inline static const std::string name = "app"; // 也没问题
};
面试常见的 const 综合题
最后来看一个面试中经常出现的综合场景,把前面讲的知识串起来:
class Engine {
int horsepower;
static int engineCount;
public:
Engine(int hp) : horsepower(hp) {
engineCount++;
}
int getHorsepower() const {
return horsepower;
}
void tune(int hp) {
horsepower = hp;
}
static int getCount() {
return engineCount;
}
};
int Engine::engineCount = 0;
现在来判断下面哪些调用合法:
Engine e1(200);
const Engine e2(300);
e1.getHorsepower(); // 合法:非 const 对象调用 const 函数
e1.tune(250); // 合法:非 const 对象调用非 const 函数
e2.getHorsepower(); // 合法:const 对象调用 const 函数
e2.tune(350); // 非法:const 对象不能调用非 const 函数
Engine::getCount(); // 合法:static 函数跟对象的 const 无关
e2.getCount(); // 合法:通过对象调用 static 函数也可以(但不推荐)
注意最后一行:static 成员函数没有 this 指针,所以它不受对象是否 const 的影响。const 限制的是"通过 this 指针修改成员",而 static 函数根本没有 this,自然不受约束。
面试的时候能把 const 在变量、指针、函数参数、类成员函数这几个场景下的含义和规则讲清楚,基本就过关了。如果还能提到 const 对象默认内部链接、const 引用传递的最佳实践、以及 const 成员函数与 static 成员函数的区别,那就是加分项。