这次我们来看一个C语言里绕不开的经典问题:指针常量和常量指针。这俩概念名字相似,但含义和用法天差地别,是很多初学者甚至有一定经验的开发者容易混淆的“拦路虎”。搞不清楚,轻则编译报错,重则程序行为诡异,内存访问出错。
这篇文章不跟你绕弯子,直接说重点:指针常量(Pointer to Constant)和常量指针(Constant Pointer)的核心区别在于“谁不能变”。一个是“指针指向的值不能变”,另一个是“指针本身(存储的地址)不能变”。理解了这个,再复杂的声明也能拆解。本文会带你从语法、内存、使用场景三个维度彻底搞懂它们,并通过大量代码示例和对比表格,让你不仅能区分,更能正确、安全地在项目中使用。无论你是正在准备考试、面试,还是想在嵌入式、系统编程中写出更健壮的代码,这篇文章都值得你仔细阅读并动手验证。
1. 核心概念速览:谁不能变?
在深入细节前,我们先通过一个表格快速抓住两者的本质区别,这是后续所有讨论的基础。
| 特性 | 指针常量 (Pointer to Constant) | 常量指针 (Constant Pointer) |
|---|---|---|
| 核心约束 | 指向的值是常量,不能通过该指针修改。 | 指针本身是常量,其存储的地址初始化后不能改变。 |
| 声明语法 | const int *p;或int const *p; | int * const p; |
| “*”的位置 | const在*左边(或类型左边)。 | const在*右边。 |
| 可变部分 | 指针变量p本身可以指向别的地址。 | 指针p指向的值(*p)可以被修改。 |
| 初始化要求 | 声明时可暂不初始化,后续再赋值。 | 必须在声明时初始化,因为之后不能再改地址。 |
| 类比理解 | 一个“只读”视角的窗口。你可以换一扇窗看别处,但透过这扇窗看到的东西不能动。 | 一个“固定”的窗口。这扇窗焊死了,不能移动,但你可以改变窗外展示的东西。 |
| 常见用途 | 函数参数,防止函数内部意外修改传入的数据。 | 指向固定硬件寄存器、共享内存区,或需要固定绑定某个对象的场景。 |
一句话口诀:左定值,右定址。看const在*的左边还是右边。左边修饰值,右边修饰指针本身。
2. 指针常量:指向常量的指针
指针常量,更准确的叫法是“指向常量的指针”。它的重点是:你不能通过这个指针去修改它指向的那个内存单元的值。但指针变量本身存储的地址是可以改变的,即它可以指向另一个地方。
2.1 语法与声明
有两种等价的声明方式,const的位置不同,但含义完全相同:
const int *p; // 方式一:const 修饰 int int const *p; // 方式二:const 修饰 *p (推荐,更清晰)对于编译器而言,int const *p和const int *p是完全一样的。但int const *p的读法更符合“从右向左”的规则:p是一个指针,指向一个const int(整型常量)。我个人更推荐第二种写法,因为它直接体现了“指向常量的指针”这一本质。
2.2 代码示例与行为分析
#include <stdio.h> int main() { int a = 10; int b = 20; // 声明一个指向常量的指针 p,并让它指向 a int const *p = &a; // 1. 读取值:完全没问题 printf("*p = %d\n", *p); // 输出: *p = 10 // 2. 试图通过 p 修改 a 的值:编译错误! // *p = 30; // 错误:assignment of read-only location ‘*p’ // 3. 但是,直接修改 a 本身是允许的 a = 30; printf("a = %d, *p = %d\n", a, *p); // 输出: a = 30, *p = 30 // 注意:p 指向 a,a 变了,*p 读到的值自然也变了。 // 约束是“不能通过 p 改”,而不是“a 必须是常量”。 // 4. 让 p 指向另一个变量 b:完全允许! p = &b; printf("*p = %d (now points to b)\n", *p); // 输出: *p = 20 // 5. 同样,不能通过 p 修改 b // *p = 40; // 错误 b = 40; // 直接改 b 可以 printf("b = %d, *p = %d\n", b, *p); // 输出: b = 40, *p = 40 return 0; }关键点分析:
- 保护性:
int const *p提供了一种保护机制。当你把一个变量的地址交给这样一个指针时,相当于承诺“我不会通过这个指针去改你的数据”。这常用于函数传参,防止函数内部误操作。 - 灵活性:指针本身是变量,可以重新指向其他符合类型要求的地址。
- 底层非const:它指向的目标(如
a,b)本身可以是非常量。指针只是提供了一个“只读视图”。
2.3 典型应用场景:函数参数
这是指针常量最常用、最重要的场景。用于传递“只读”数据,提高代码的安全性和可读性。
#include <stdio.h> #include <string.h> // 不良设计:参数是普通指针,函数内部可能意外修改 src void bad_print(const char *str) { // 虽然我们没改,但函数签名没有约束,调用者会担心。 printf("%s\n", str); } // 良好设计:参数是指向常量字符的指针,明确告知“我不会修改你的字符串” void good_print(const char *str) { // str[0] = 'A'; // 如果尝试修改,编译器会直接报错,防止bug。 printf("%s\n", str); } // 计算字符串长度(标准库strlen的原型) size_t my_strlen(const char *str) { size_t len = 0; while (str[len] != '\0') { // 只读访问,安全 len++; } return len; } int main() { char msg[] = "Hello, World"; good_print(msg); printf("Length: %zu\n", my_strlen(msg)); return 0; }为什么这很重要?
- 安全性:防止函数内部的代码意外修改调用者的数据,这类 Bug 很难追踪。
- 可读性:函数原型
void func(const T *ptr)清晰地表达了设计意图:“这是一个输入参数,我不会改它”。 - 兼容性:它可以接受常量数据(如字符串字面量)和非常量数据的地址,而反过来(非常量指针指向常量数据)是不允许的。
const char *literal = "immutable"; // 正确:指向常量字符串的指针 // char *p = "immutable"; // 危险!在C++中错误,在C中不推荐(可能引发未定义行为)
3. 常量指针:指针本身是常量
常量指针,意思是指针变量本身是常量。一旦它被初始化指向某个地址,这个地址值就不能再改变了。但是,通过这个指针去修改它所指向的内存的值,是允许的(前提是那个内存区域本身可写)。
3.1 语法与声明
声明时,const必须放在*的右边,并且必须在声明的同时进行初始化。
int * const p = &some_variable; // 正确:声明并初始化 // int * const p; // 错误:未初始化的常量 ‘p’3.2 代码示例与行为分析
#include <stdio.h> int main() { int a = 10; int b = 20; // 声明一个常量指针 p,并让它指向 a。必须初始化! int * const p = &a; // 1. 通过 p 读取和修改 a 的值:允许! printf("*p = %d\n", *p); // 输出: 10 *p = 30; // 正确:修改了 a 的值 printf("a = %d\n", a); // 输出: a = 30 // 2. 试图让 p 指向 b:编译错误! // p = &b; // 错误:assignment of read-only variable ‘p’ // 3. 直接修改 a 当然也可以 a = 40; printf("*p = %d\n", *p); // 输出: 40 // 4. 如果指向的目标是 const 呢? const int c = 100; // int * const p2 = &c; // 错误:初始化丢弃了‘const’限定符 // 因为 p2 是一个“指向int的常量指针”,它承诺可以通过自己修改目标。 // 但 c 是一个 const int,不允许修改。类型不兼容,危险! const int * const p3 = &c; // 正确:指向常量的常量指针(双重const) // *p3 = 200; // 错误:不能通过 p3 改值 // p3 = &a; // 错误:也不能改 p3 本身的地址 return 0; }关键点分析:
- 固定绑定:常量指针像一个“固定挂钩”,一旦挂上某个对象,就不能再换对象了。这常用于指向硬件寄存器、内存映射的I/O地址,或者需要在整个生命周期内与某个特定对象关联的场景。
- 必须初始化:因为之后无法赋值,所以声明时就必须给出初始值。
- 可修改目标:只要目标对象本身不是
const,就可以通过这个指针修改它。
3.3 典型应用场景:固定资源访问
// 假设有一个固定的硬件状态寄存器地址 #define STATUS_REG ((volatile unsigned int *)0x40021000) void hardware_init() { // 定义一个常量指针,固定指向这个硬件地址 volatile unsigned int * const status_ptr = STATUS_REG; // 在整个函数或模块生命周期中,status_ptr 永远指向 STATUS_REG // 我们可以通过它读写寄存器 unsigned int current_status = *status_ptr; // 读 *status_ptr = current_status | 0x01; // 写(设置某一位) // status_ptr = (volatile unsigned int *)0x40022000; // 错误!指针地址不可变。 }在嵌入式开发中,这种用法非常普遍,确保了指向关键硬件资源的指针不会被意外修改。
4. 双重const:指向常量的常量指针
把前两种约束结合起来,就得到了“指向常量的常量指针”。它既不能修改指向的值,也不能修改自身的地址。这是最严格的约束。
4.1 语法与声明
const同时出现在*的两边。
const int * const p = &some_constant_variable; // 推荐 int const * const p = &some_constant_variable; // 等价,同样推荐读法:p是一个常量指针,指向一个整型常量。
4.2 代码示例与行为分析
#include <stdio.h> int main() { const int immutable_value = 42; int normal_value = 100; // 指向常量的常量指针,通常用它来指向真正的常量 const int * const p1 = &immutable_value; // 以下操作全部非法: // *p1 = 50; // 错误:不能修改指向的值 // p1 = &normal_value; // 错误:不能修改指针本身 // 它可以指向非常量吗?语法上可以,但逻辑上要小心。 const int * const p2 = &normal_value; // 合法 // *p2 = 200; // 错误:通过 p2 不能修改 normal_value // 但是 normal_value 本身可以通过其他方式修改 normal_value = 200; printf("normal_value = %d, *p2 = %d\n", normal_value, *p2); // 输出: 200, 200 // p2 提供了一个对 normal_value 的只读、固定视图。 return 0; }应用场景:通常用于定义模块内部的全局只读数据访问句柄,或者作为函数参数传递一个既不允许修改内容,也不允许改变指向的完整“只读输入对象”。
5. 深入辨析:从声明理解与“从右向左”规则
面对复杂的指针声明,比如const int * const * pp,如何快速理解?使用“从右向左”的阅读规则是最有效的方法。
规则:从变量名开始,向右看,遇到()或[]先解析(它们是右结合),然后向左看,依次解释每个符号。
让我们拆解几个例子:
int const *p;- 从
p开始。 - 向右:没有
()或[]。 - 向左:遇到
*,所以p是一个指针。 - 继续向左:遇到
const,所以这个指针指向的是常量。 - 继续向左:遇到
int,这个常量是int类型。 - 结论:
pis a pointer to a constant integer. (指针常量)
- 从
int * const p;- 从
p开始。 - 向右:没有。
- 向左:遇到
const,所以p本身是常量。 - 继续向左:遇到
*,所以这个常量是一个指针。 - 继续向左:遇到
int,这个指针指向int。 - 结论:
pis a constant pointer to an integer. (常量指针)
- 从
const int * const p;- 从
p开始。 - 向右:没有。
- 向左:遇到
const,所以p是常量。 - 继续向左:遇到
*,所以这个常量是一个指针。 - 继续向左:遇到
int,指针指向int。 - 继续向左:遇到
const,这个int是常量。 - 结论:
pis a constant pointer to a constant integer. (指向常量的常量指针)
- 从
更复杂的
const int * const * pp;- 从
pp开始。 - 向右:没有。
- 向左:遇到
*,所以pp是一个指针。 - 继续向左:遇到
const,所以这个指针指向的是常量。 - 继续向左:遇到
*,所以这个常量本身又是一个指针。 - 继续向左:遇到
int,这第二个指针指向int。 - 继续向左:遇到
const,这第二个指针指向的int是常量。 - 结论:
ppis a pointer to a constant pointer to a constant integer. pp本身可以指向别的const int * const类型的指针。*pp是一个常量指针,你不能修改*pp的值(即它存储的地址)。**pp是一个整型常量,你不能修改**pp的值。
- 从
多练习几次“从右向左”读法,再复杂的声明也能迎刃而解。
6. 常见混淆点与陷阱排查
在实际编码和面试中,以下几个点是高频出错区。
6.1 陷阱一:字符串字面量与指针
char *str = "Hello"; // 在C中合法但不安全,在C++中错误 str[0] = 'h'; // 未定义行为!试图修改只读存储区的字符串字面量。字符串字面量(如"Hello")通常存储在程序的只读数据段(如.rodata)。用一个非常量指针指向它,然后试图修改,会导致运行时错误(如段错误)。正确做法:始终使用const char*来指向字符串字面量。
const char *str = "Hello"; // 安全,明确表示只读 printf("%c\n", str[0]); // 可以读 // str[0] = 'h'; // 编译错误,保护了程序6.2 陷阱二:const 在函数返回值中的意义
const int *get_value(void);这个函数返回一个指向常量int的指针。调用者不能通过返回的指针修改目标数据。这常用于返回指向内部静态数据或常量数据的指针,防止调用者修改。
int * const get_value(void); // 不常见这个函数返回一个指向int的常量指针。返回值本身(指针)是常量,不能指向别处,但可以通过它修改目标数据。这种用法较少。
6.3 陷阱三:类型转换与 const 丢弃
将const T*强制转换为T*(去掉 const 限定)是极其危险的操作,违反了语言的约定,可能导致未定义行为。
const int a = 10; const int *p = &a; int *q = (int *)p; // 强制类型转换,去掉了const *q = 20; // 未定义行为!试图修改一个const对象。 printf("a = %d\n", a); // 输出可能是10,也可能是20,取决于编译器优化。最佳实践:除非你百分之百确定底层对象本身是非 const 的(例如,你正在实现一个像strchr这样的标准库函数,它接收const char*但返回char*),否则绝对不要去掉 const 限定符。
6.4 陷阱四:与数组名的混淆
数组名在大多数表达式中会退化为指向其首元素的指针。但这个指针是“常量指针”。
int arr[5] = {1,2,3,4,5}; // arr 的类型可以看作是 int * const // arr = &some_other_int; // 错误!不能给数组名赋值。 // arr++; // 错误! int *p = arr; // 正确:p 是一个普通的指针变量,可以指向别处 p++; // 正确:p 现在指向 arr[1]7. 综合测试与代码验证
最好的学习方式是动手。下面提供一个综合测试程序,你可以复制到本地编译运行,并尝试取消注释那些错误的行,观察编译器的报错信息。
#include <stdio.h> void test_pointer_to_constant() { printf("=== 测试1:指针常量 (Pointer to Constant) ===\n"); int x = 5, y = 10; const int *p = &x; // 或 int const *p printf("初始: x=%d, *p=%d\n", x, *p); // *p = 6; // 取消注释将导致编译错误:read-only location x = 6; // 直接修改 x 是允许的 printf("修改x后: x=%d, *p=%d\n", x, *p); p = &y; // 指针本身可以指向别处 printf("指向y后: y=%d, *p=%d\n", y, *p); printf("\n"); } void test_constant_pointer() { printf("=== 测试2:常量指针 (Constant Pointer) ===\n"); int a = 1, b = 2; int * const p = &a; // 必须初始化 printf("初始: a=%d, *p=%d\n", a, *p); *p = 3; // 可以通过指针修改值 printf("通过*p修改后: a=%d, *p=%d\n", a, *p); // p = &b; // 取消注释将导致编译错误:read-only variable printf("\n"); } void test_constant_pointer_to_constant() { printf("=== 测试3:指向常量的常量指针 ===\n"); const int immutable = 100; const int * const p = &immutable; printf("*p = %d\n", *p); // *p = 101; // 错误:不能修改值 // p = NULL; // 错误:不能修改指针 printf("双重const,两者皆不可变。\n"); printf("\n"); } void test_function_parameter(const int *input) { printf("=== 测试4:函数参数中的指针常量 ===\n"); // input 是指向常量的指针,函数内部不能通过它修改数据 printf("传入的值: %d\n", *input); // *input = 999; // 错误:保护了外部数据 printf("函数内部无法修改*input。\n"); printf("\n"); } int main() { test_pointer_to_constant(); test_constant_pointer(); test_constant_pointer_to_constant(); int val = 42; test_function_parameter(&val); // 额外测试:数组名作为常量指针 printf("=== 测试5:数组名作为常量指针 ===\n"); int arr[3] = {10, 20, 30}; // arr 可以看作 int * const int *q = arr; // 正确:普通指针可以接收数组名 q++; // q 可以移动 printf("*q (arr[1]) = %d\n", *q); // arr++; // 错误:数组名不能作为左值 return 0; }编译与运行建议:
- 将代码保存为
pointer_const.c。 - 使用
gcc -Wall -Wextra -o pointer_test pointer_const.c编译。-Wall -Wextra会打开更多警告,帮助你发现不严谨的代码。 - 运行
./pointer_test查看输出。 - 尝试有选择地取消注释那些标记为“错误”的行,重新编译,仔细阅读编译器给出的错误信息。这是理解规则最直接的方式。
8. 在项目中的最佳实践与选择建议
理解了语法,关键是要在正确的地方使用它们。以下是一些实战建议:
函数参数传递:优先使用
const T*- 对于输入参数:如果函数只需要读取数据,而不修改它,一律使用指向常量的指针。这是最重要的习惯。
- 好处:
- 安全:防止函数内误操作。
- 清晰:函数原型就是文档,明确表达了参数用途。
- 灵活:可以接受常量或非常量实参。
- 示例:
void print_array(const int *arr, size_t len);size_t find_item(const char *str, char target);
固定资源绑定:使用
T* const- 当某个指针在初始化后,其指向在整个作用域或对象生命周期内都不应改变时使用。
- 示例:嵌入式中的硬件寄存器指针、单例模式中获取的实例指针、类成员中指向某个固定缓冲区的指针。
- 注意:如果指向的目标也不该被修改,则应结合使用
const T* const。
定义全局或静态常量数据:使用
const T* const- 用于定义模块内部的查找表、配置数据等,同时防止指针被修改和指针指向的数据被修改。
- 示例:
static const char * const days_of_week[] = {"Mon", "Tue", ...};
区分“底层const”与“顶层const”
- 这是C++标准中的概念,但有助于理解。顶层const表示对象本身是常量(如
int * const p)。底层const表示指针指向的对象是常量(如const int *p)。 - 在拷贝赋值时,顶层const不影响,但底层const必须匹配。非常量可以转为常量,反之则不行。
- 简单记:你可以给一个“指向常量的指针”赋予一个“普通指针”的值(增加const),但不能反过来(去掉const)。
- 这是C++标准中的概念,但有助于理解。顶层const表示对象本身是常量(如
使用
typedef简化复杂声明- 对于复杂的常量指针类型,使用
typedef可以大大提高代码可读性。
typedef const char * ReadOnlyString; // 指向常量字符的指针 typedef char * const FixedString; // 指向字符的常量指针 void process(ReadOnlyString input) { // 一目了然,输入字符串 // ... } FixedString config_path = "/etc/app.conf"; // 路径固定不可变- 对于复杂的常量指针类型,使用
9. 总结与下一步
回到最初的问题:如何区分指针常量和常量指针?记住这句口诀:“左定值,右定址”。看const相对于*的位置。
const *(const在左):指向的值是常量(指针常量,Pointer to Constant)。你有一个只读的视角。* const(const在右):指针本身是常量(常量指针,Constant Pointer)。你有一个固定的挂钩。
彻底掌握它们,不仅仅是应付考试,更是为了写出更安全、更清晰、更专业的C语言代码。它关乎你对程序数据访问权限的精确控制,是理解更高级主题(如函数指针、复杂数据结构)的基石。
下一步你可以:
- 结合函数指针:尝试理解
const在函数指针声明中的应用,例如int (* const fp)(int)和int (*fp)(const int)的区别。 - 探索C++中的引用:C++的引用(
&)在行为上很像一个自动解引用的常量指针,并且天然具有const属性(不能重新绑定)。 - 阅读优秀源码:查看Linux内核、标准C库(glibc)或一些著名开源项目的代码,观察他们如何大量使用
const来保证安全。 - 在项目中实践:从今天起,在编写函数时,有意识地为所有“输入”参数加上
const限定。编译器会成为你最好的代码审查伙伴。
理解并善用const,是区分C语言新手和熟练者的重要标志之一。建议收藏本文,并在下次遇到指针相关的编译警告或困惑时,回来对照表格和示例进行排查。