第二十章 C语言语法和概念常见问题逐一剖析
本章要点
C 语言里有几组概念,因为名字像、用法像、或者干脆就是中文翻译搞出来的文字游戏,初学者几乎一定会搞混。这一章把它们集中拆一遍——不是让你背,是让你以后遇到时能自己判断。
具体涵盖:
- 三组“词序颠倒“的声明辨析:函数指针/指针函数、数组指针/指针数组、常量指针/指针常量
- 六组容易混淆的概念对:
strlenvssizeof、constvsstatic、值传递 vs 指针传递、宏 vs 函数、声明 vs 定义、数组名 vs 指针
碰到不清楚的翻到对应小节就行,不用通读。
一、三组“词序颠倒“的声明辨析
面试和笔试题里经常出现这一类问题:“请说明函数指针和指针函数的区别”“数组指针和指针数组有什么区别”“常量指针和指针常量有什么不同”。回答这些问题其实没什么意义——它们在概念上根本不存在什么可比较的“区别“,纯粹是写法上长得太像了:同样的关键字、同样的符号,只差一对括号或者 const 换了个位置,扫一眼容易看错。
这里对这三组东西做讲解,目的不是搞文字游戏,也不是去分析它们概念上有什么不同——因为它们之间本来就不存在概念层面的对立。讲它们的唯一原因是:初学者在语法层面确实容易搞混。所以下面的内容不讲“区别“,只讲语法上怎么区分——看括号、看优先级、看 const 的位置。把这些看熟了,以后碰到这种题直接写就行。
1.1 函数指针 vs 指针函数
int (*p)(int, int); // 函数指针
int *p(int, int); // 返回指针的函数(所谓的"指针函数")
两行代码用到的字符完全一样——int、*、p、(int, int)——唯一的区别是一对括号加在了哪里。加在 *p 上,p 是指针;不加,p 是函数。
int (*p)(int, int):(*p) 的括号让 * 先和 p 结合,所以 p 是一个指针。指向一个接收两个 int、返回 int 的函数。叫函数指针——它是指针。
int *p(int, int):没有括号,(int, int) 先和 p 结合,所以 p 是一个函数,返回 int *。叫“指针函数“——它是函数,只是返回值碰巧是指针。指针函数从来不是一个独立的语法概念——它就是函数,返回值是指针类型。你不会把返回 double 的函数叫 “double 函数”,自然也没必要给返回指针的函数单独取一个名字。
怎么看:看最后一个词。指针→函数指针(是指针);函数→返回指针的函数(是函数)。
// 实际用法
int add(int a, int b) { return a + b; }
int (*op)(int, int) = add; // 函数指针,指向 add
int result = op(3, 5); // 通过指针调用
1.2 数组指针 vs 指针数组
int (*p)[5]; // 数组指针:指针,指向长度为5的int数组
int *p[5]; // 指针数组:长度为5的数组,元素是int*
int (*p)[5]:(*p) 先结合,p 是指针;[5] 说明它指向一个长度为 5 的数组。是指针。
int *p[5]:[] 优先级高于 *,所以 p 先和 [5] 结合成数组;int * 是元素类型。是数组。
指针数组从来不是一个独立的语法概念,它就是一个数组,只不过元素碰巧是指针类型。你不会把 double arr[5] 叫 “double 数组” 然后拿去和别的什么概念做辨析——它就是声明了一个数组,类型是 double。指针数组同理,就是声明了一个数组,元素类型是 int *。
优先级:[] > *。*p[5] 等价于 *(p[5])——先数组再指针。要打破顺序就加括号:(*p)[5]——先指针再数组。
int arr[3][5];
int (*row)[5] = arr; // row 指向 arr 的第一行(长度为5的数组)
row[1][2] = 100; // 修改 arr[1][2]
int a = 1, b = 2, c = 3;
int *ptrs[3] = {&a, &b, &c}; // 指针数组
1.3 常量指针 vs 指针常量
const int *p; // 常量指针:指向的数据不能通过 p 修改
int * const p; // 指针常量:p 本身不能改(指向不能变)
从右往左读:
const int *p:p是指针,指向const int——数据只读。int * const p:p是const,类型是int *——指针本身只读。
指针常量也不是什么独立概念——它就是一个 const 变量,类型碰巧是指针。和 const int x = 10 一样,只是这个 x 的类型是 int * 而已。常量指针同理,就是一个指针,指向的东西被 const 修饰了。
int a = 10, b = 20;
const int *p1 = &a;
p1 = &b; // ✅ 可以改指向
*p1 = 30; // ❌ 不能改数据
int * const p2 = &a;
p2 = &b; // ❌ 不能改指向
*p2 = 30; // ✅ 可以改数据
const int * const p3 = &a; // 两个都不能改
这组也是一样的文字游戏。常量指针——中心词是指针,它“持有“的是一个常量,所以指向的数据不能改;指针常量——中心词是常量,它是一个指针类型的常量,所以指针本身不能改。
二、六组容易混淆的概念对
2.1 strlen vs sizeof
两个都能告诉你“大小“,但说的不是同一件事。
sizeof | strlen | |
|---|---|---|
| 是什么 | 运算符(多数结果编译期确定) | 运行时函数 |
| 量的对象 | 类型/变量占的内存字节数 | 字符串\0 之前的字符数 |
计不计\0 | 计 | 不计 |
| 对指针 | 返回指针变量本身大小(通常 8) | 从指针指向处开始数到\0 |
| 需要头文件 | 不需要 | <string.h> |
char str[] = "Hello"; // 6 字节:H e l l o \0
sizeof(str); // 6
strlen(str); // 5
char *p = str;
sizeof(p); // 8(64位)—— 指针本身
strlen(p); // 5 —— 指向的字符串长度
最容易踩的坑:数组作为函数参数后会退化为指针,在函数里 sizeof 拿到的是指针大小,不是数组大小。
2.2 const vs static
两个关键字经常同时出现(static const),但改的是完全不同的东西。
const 改变的是变量的权限。 一个变量声明之后,默认是可读可写的——你可以读它的值,也可以给它赋新值。加了 const,编译器会在编译期检查:这个变量初始化之后,任何试图修改它的操作都会报错。仅此而已——const 不改变这个变量存在哪里、不改变它什么时候创建什么时候销毁、不改变谁能看见它。它只做一件事:把“可写“权限关掉。
const int x = 10;
x = 20; // ❌ 编译错误:权限不够
用于块作用域对象时,static 把对象的存储期改为静态存储期,但不扩大块作用域。普通自动局部对象每次执行进入相应块时形成新的生命周期;static 局部对象则贯穿整个程序执行,并在多次调用之间保留值。用于文件作用域声明时,static 影响的是链接属性,使名称只在当前翻译单元内链接。具体放入哪个内存段由实现决定。
void counter(void) {
static int count = 0; // 存在静态区,只初始化一次
count++;
printf("%d\n", count);
}
// 每次调用 counter(),count 都会在上次值的基础上 +1
把两件事对照看:
const→ 改权限:能读不能写static→ 改位置和寿命:从栈搬到静态区,活到程序结束;同时限制可见范围
它们改的不是同一个维度,所以可以组合:static const int MAX = 100;——放在静态区、整个程序生命周期都在、但不可修改。
另外,static 有三种上下文,效果不同:
| 用在 | 效果 |
|---|---|
| 局部变量 | 从栈搬到静态区,生命周期变整个程序运行期,但只在定义它的函数内可见 |
| 全局变量 | 可见范围从“所有文件可见“缩到“仅当前文件可见“ |
| 函数 | 同上,函数仅当前文件可见 |
void counter(void) {
static int count = 0; // 只初始化一次,值跨调用保留
count++;
printf("%d\n", count);
}
static const int MAX = 100; // 两者可以一起用:文件内可见、不可修改
它们不是二选一的关系,各管各的。
2.3 值传递 vs 指针传递
C 语言只有一种参数传递方式——值传递。所谓“指针传递“不是另一种方式,它还是值传递,只是传的那个值恰好是地址。
void by_value(int x) { x = 100; } // x 是实参的拷贝
void by_pointer(int *x) { *x = 100; } // x 是地址的拷贝
by_value(a):把 a 的值拷贝给 x,x 和外部的 a 没关系。
by_pointer(&a):把 a 的地址拷贝给 x。改 x 本身(比如 x = NULL)不影响外部。改 *x——顺着地址找到外部的 a 再修改——才影响。
void func(int *p) {
p = NULL; // 不影响外部,改的是 p 这个局部拷贝
*p = 100; // 影响外部,顺着 p 里的地址找到了外面的变量
}
C 没有 C++ 那种传引用(int &x)。所有参数传递都是拷贝——区别只在于你拷贝的是数据,还是数据的地址。
2.4 宏函数 vs 函数
#define SQUARE(x) ((x) * (x)) 和 int square(int x) { return x * x; } 看起来能做同一件事,但它们的执行方式完全不同。
| 宏 | 函数 | |
|---|---|---|
| 什么时候发生 | 预处理阶段(编译前) | 运行时 |
| 做了什么 | 文本替换 | 跳转→执行→返回 |
| 参数怎么处理 | 不先求值,直接粘贴文本 | 先求值,再把值传进去 |
| 有无类型检查 | 无 | 有 |
| 调试可见 | 不可见(宏名已被替换) | 可见 |
副作用——这是宏最大的坑:
#define SQUARE(x) ((x) * (x))
int a = 5;
SQUARE(a++); // 展开为 ((a++) * (a++))
// a 被递增两次,结果不可预测
int square(int x) { return x * x; }
int b = 5;
square(b++); // b++ 先求值得 5,传入函数,b 变成 6,返回 25
函数是先求值再传参,宏是先粘贴再求值。参数里有 ++、--、赋值这类副作用操作,宏会把它们复制多次。
什么时候用宏:定义编译时常量、减少样板代码。其他情况能写函数就写函数。
2.5 声明 vs 定义
int calc(int a, int b); // 声明:告诉编译器有这个东西
int calc(int a, int b) { // 定义:给完整的实现
return a * a + b * b;
}
- 声明:告诉编译器“这个名字对应什么东西、什么类型“。不分配存储、不生成代码。
- 定义:给名字配上实际的存储空间或函数体。一个东西只能有一份定义,但声明可以有多次。
为什么要分开?单文件里你把定义写调用者前面就行了,不需要声明。多文件项目里,声明放 .h,定义放 .c,其他文件通过 #include 拿到声明就能用。
变量的情况:
int x = 10; // 定义(分配空间并初始化)
extern int x; // 声明(x 在别处定义,这里只引用)
2.6 数组名 vs 指针
这是 C 语言里误解最深的问题。
数组名不是指针。 数组名代表整个数组对象——sizeof(arr) 返回整个数组的大小,正是因为编译器知道你声明的是一个数组。
但数组名在绝大多数表达式中会退化成指向首元素的指针。退化之后它的行为和一个指针没区别——指向 arr[0],arr + i 指向 arr[i],*arr 就是 arr[0]。
int arr[5] = {1, 2, 3, 4, 5};
// 退化:arr 变成 &arr[0]
int *p = arr;
func(arr);
arr + 1;
*arr; // arr[0]
// 不退化——这两个是例外
sizeof(arr); // 整个数组的大小(20 字节)
&arr; // 类型是 int (*)[5],不是 int**
&arr + 1 跳过的是整整一个数组,不是一个元素。
记住两条:sizeof(arr) 不退化,&arr 不退化。其他所有场景,arr 的行为等同于 &arr[0]。
三、总结
九组问题,分两类:
文字游戏类(前三组):函数指针/指针函数、数组指针/指针数组、常量指针/指针常量。它们之间的混淆主要是中文翻译搞出来的。看最后一个词、记住优先级和 const 的修饰规则,就够了。
正交概念类(后六组):每一对都是 C 语言里两个不同维度的机制,不是二选一的关系。搞清楚各自管什么维度,自然就不会搞混。