Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第二十章 C语言语法和概念常见问题逐一剖析

本章要点

C 语言里有几组概念,因为名字像、用法像、或者干脆就是中文翻译搞出来的文字游戏,初学者几乎一定会搞混。这一章把它们集中拆一遍——不是让你背,是让你以后遇到时能自己判断。

具体涵盖:

  • 三组“词序颠倒“的声明辨析:函数指针/指针函数、数组指针/指针数组、常量指针/指针常量
  • 六组容易混淆的概念对strlen vs sizeofconst vs static、值传递 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 *pp 是指针,指向 const int——数据只读。
  • int * const ppconst,类型是 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

两个都能告诉你“大小“,但说的不是同一件事。

sizeofstrlen
是什么运算符(多数结果编译期确定)运行时函数
量的对象类型/变量占的内存字节数字符串\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 的值拷贝给 xx 和外部的 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 语言里两个不同维度的机制,不是二选一的关系。搞清楚各自管什么维度,自然就不会搞混。