跳转至

第六章 · 指针和引用

本页最后更新于 ,内容可能过时

《C语言程序设计(第3版)》第 6 章课后习题,共 20 题。

1 · 指针是什么、怎么指向一个变量

举例说明什么是指针?如何让指针指向一个变量?

答案

分两点说。

一、指针是“存放地址的变量”。

每个变量在内存里占若干字节,其中第一个字节的编号就是它的地址;把某个变量的地址存进另一个变量,这个装着地址的变量就叫指针,也叫指针变量。

int a = 5;      /* 普通变量 */
int *p;         /* 定义指针 p:类型是“指向 int 的指针” */

二、让指针指向一个变量,就是把那个变量的地址赋给它,地址用取地址运算符 & 取。

p = &a;         /* &a 是 a 的地址;赋给 p 之后,说“p 指向 a” */
*p = 8;         /* *p 就是 a 本身,改 *p 就是改 a,a 变成 8 */

* 在这里有两个身份:定义 int *p; 时它表示“p 是指针”,在表达式 *p 里它是解引用,表示 p 所指的那个变量。

解析

指针只要抓住两句话:p = &a; 是“让 p 指向 a”,改的是 p 里存的地址;*p 是“p 所指的那个变量”,改的是 a 里的值。

两者互不干涉,改指向不影响 a 的值,改 *p 也不影响 p 的指向。

边界情况:

  • 指针的类型要和所指对象的类型对上,int *p 只能指向 int。把 double 的地址赋给 int *,编译时会报类型不兼容。
  • 取地址运算符 & 只能作用在对象上(变量、数组元素),不能作用在字面量或表达式的计算结果上。
  • 没赋过值的指针里存的是一个不确定的地址,解引用它是未定义行为;定义时顺手初始化成 NULL。
  • 类型正好反过来:&a 的类型是“指向 int 的指针”,*p 的类型是 int。

易错:

  1. 写成 p = a;,那是把 a 的值当地址塞进 p,不是取地址
  2. 定义时漏掉 *,int p; 就成了普通整型变量
  3. 把 *p 里的 * 当成乘号

2 · 指针的定义、引用指针、引用所指变量

举例说明指针的定义和引用指针及引用指针所指变量的方法?

答案

三件事分别对应三行代码。

int a = 5, b = 9;
int *p;                     /* 定义:p 的类型是“指向 int 的指针” */

p = &a;                     /* 引用指针本身:把 a 的地址存进 p */
p = &b;                     /* 还是引用指针本身:改成指向 b */

printf("%d\n", *p);         /* 引用 p 所指的变量:读出 b 的值 */
*p = 7;                     /* 也写作 *p:把 b 改成 7 */

三点小结:

  • 定义指针的写法是 类型 *指针名;,* 贴着变量名,表示“这是个指针”。
  • 引用指针(指针变量本身)就是直接用它的名字 p,这时 p 的值是一个地址;对它赋值只改指向,a、b 里的数一个都不动。
  • 引用指针所指变量要加 *,写作 *p;它既可以读(当作值用),也可以写(放在赋值号左边)。
解析

这里说的“引用”是“使用、访问”的意思,和 C++ 里的引用类型不是一回事(本章题 18、19 里那个“引用”才指 C++ 的引用类型)。

同一个 *p,出现在赋值号左边就是所指变量的别名,出现在右边就是所指变量的值;这是 C 里“一个写法两种用法”的典型例子。

为什么读和写都能用同一个 *p:*p 这个表达式本身是一个左值,左值既能读也能写;决定读写的是它出现在什么位置,跟写法无关。

边界情况:

  • printf("%d", p); 是把地址当整数打,格式符对不上(要打地址用 %p 并把实参转成 void *),打出来的也不是 a 的值。
  • *p = 7; 之前 p 必须已经指向某个有效的 int,否则写的是内存里一个随机的地址。
  • p 与 *p 是两个不同层次的东西:p 的类型是 int *,*p 的类型是 int。

易错:

  1. 把“引用指针”写成 *p = &a;(那样写是先解引用再赋地址,类型对不上)
  2. 忘了 * 只影响紧跟它的那一层
  3. 以为 p = &b; 之后 *p 还是 a(已经变成 b 了)

3 · 指针与数组名的联系与区别

举例说明指针与数组名之间的联系与区别。

答案

联系(数组名在表达式里会退化成首元素指针):

  • a 与 &a[0] 的值相同,都指向数组头一个元素。
  • 下标写法与指针写法等价:a[i] 就是 *(a + i),&a[i] 就是 a + i。
  • 数组名可以赋给同类型的指针:p = a; 之后,p[i]、*(p + i)、a[i] 三者完全等价。
int a[5] = {1, 2, 3, 4, 5};
int *p = a;                 /* p 与 a 指向同一处 */
p[2] = 30;                  /* 等价于 a[2] = 30 */

区别(数组名不是指针变量):

  • 数组名不能被赋值,也不能自增自减。a = p;、a++; 都编译不过。
  • sizeof 的结果不同:sizeof(a) 是整个数组的字节数,sizeof(p) 只是指针自身的大小。
  • &a 与 &a[0] 的类型不同:&a 的类型是“指向整个数组的指针”,&a[0] 是“指向 int 的指针”。两者数值相同,但 &a + 1 跳过整个数组,&a[0] + 1 只跳过一个 int。
解析

要理解这组关系,先记住一条规则:数组名出现在表达式里时,会被转换成“指向首元素的指针”,只有两个例外——sizeof(数组名) 和 &数组名,这两处数组名保持“整个数组”的身份。

上面每一条联系与区别都能从这条规则推出来。

对照表:

比较项 数组名 a 指针变量 p
身份 数组的名字,不是变量 变量,里面存一个地址
能否赋值 不能(a = p; 编译不过) 能(p = a; 可以)
能否自增 不能 能
sizeof 整个数组的字节数 指针自身的大小(64 位平台上是 8)
& 的结果 指向整个数组的指针 指向“这个指针变量”的指针

两个容易混的地方:

  • p = a; 是让 p 指向数组的第一个元素,不是把数组复制给 p。之后 p 和 a 是同一处内存的两个入口。
  • sizeof(a) / sizeof(a[0]) 可以求元素个数,sizeof(p) / sizeof(p[0]) 求不出来——p 是指针,那样算出来是一个固定的小数字。这是“数组名传进函数就退化成指针”之后容易犯的一类错误。

易错:

  1. 以为 a 和 p 完全等价,写出 a = p;
  2. 用 sizeof(p) 去数数组元素个数
  3. 把 &a 和 a 当成一回事(数值相同,类型和“加一之后跳到哪”都不同)

4 · 字符指针指向字符串与数组存字符串

试指出让一个字符指针指向字符串的首字符与用数组存储一个字符串的区别。

答案

两种写法各有一个“存放”和一个“能改”的问题,逐条说:

  • 让字符指针指向字符串的首字符:指针变量是单独的一个对象,字符串本身是另一份带结尾 '\0' 的常量,存在别处;指针里只存了它的首地址。所以“改指向”可以(让它指到别的字符串上去),“改内容”不行。
  • 用数组存储字符串:字符串是被复制进这个数组里的,字符就住在数组里。所以“改内容”可以(a[0] = 'X'; 随便改),“改指向”不行(数组名不能被赋值)。
char *p = "abc";    /* p 指向常量 "abc" 的首字符,字符串本身在别处 */
char a[] = "abc";   /* a 是 4 个字符的数组,内容是 'a','b','c','\0' */

p = "xyz";          /* 改指向:可以 */
a[0] = 'X';         /* 改内容:可以 */
解析

两者的根本差别在于“字符串本身住在哪”和“名字是不是变量”,前面列的四条区别都是这两点的推论。

对照表(三列,做对照用):

比较项 char *p = "abc"; char a[] = "abc";
名字的性质 指针变量 数组名
字符串住在哪 另一处(字面量存放区) 就在这个数组里
sizeof 的结果 指针自身大小(64 位平台上 8) 4(含结尾 '\0')
能改内容吗 不能,是未定义行为 能
能改指向吗 能 不能
字符串本身占几个字节 4(3 个字符加结尾的 '\0') 4(3 个字符加结尾的 '\0')

两个边界:

  • p 指向字符串字面量时,通过 p 写字符是未定义行为;能不能写取决于那份字面量放在哪一段内存,编译器通常把它放在只读区,一写就崩溃。要改内容就先复制一份到数组里。
  • char a[] = "abc"; 的 sizeof(a) 是 4 不是 3,结尾的 '\0' 也占一个字节;用 strlen(a) 得到的是 3(strlen 数到 '\0' 为止,不把它算进去)。

易错:

  1. 用 *p = 'X'; 去改 char *p = "abc"; 指向的字符(未定义行为)
  2. 以为 char a[] = "abc"; 的 sizeof(a) 是 3
  3. 对数组名做 a = p; 或 a++;

5 · 指针赋值要注意的问题

指针赋值应注意哪些问题?

答案

按“赋值之前、赋值当时、赋值之后”分三层。

赋值之前,指针要有确定的来源:

  • 未初始化的指针里存的是不确定的地址,解引用它是未定义行为。定义时就初始化成 NULL,用之前先判空。
  • 合法的地址只有几个来源:&变量、数组名、malloc 之类的分配函数返回的地址、另一个已经有效的指针。

赋值当时,两边的类型要对上:

  • int *p 只应赋 int 的地址;把别的类型的地址赋给它,编译会报类型不兼容,真要转必须写显式类型转换。
  • 不要把普通整数当地址用。int *p = 100; 是把 100 塞进指针,编译给警告,解引用几乎必然出错。空指针写 NULL(或 0),不写别的魔数。

赋值之后,别让指针悬空、别越界:

  • 悬空:指针所指的对象已经不存在了(函数返回之后的局部变量、free 之后的内存块),指针里那个地址就失效了,再用是未定义行为。
  • 越界:指向数组元素的指针只允许在 a 到 a + n 之间移动(a + n 是最后一个元素再往后一格);越过这个范围,即使不解引用也是未定义行为。

还有一条容易忽略的:赋值只改“指向”,不改“所指”。

p = q;      /* p 改指向 q 指的地方;q 指的那个变量本身没变 */
*p = *q;    /* 这才是把 q 指的内容抄到 p 指的地方 */

两句话差一个 *,意思完全不同。

解析

为什么会出这些问题,根源是 C 的指针里只有“一个地址”,没有“这个地址还有效吗”“它是什么类型的对象”这类附加信息。

类型信息只存在于编译期的指针类型里,有效性完全靠程序员自己保证,所以上面每一条都是“编译器帮不了你”的那类问题。

逐条看看不守住会怎样:

  • 类型不匹配:int *p; double d; p = &d; 编译报不兼容,这是编译器能拦住的一种;一旦用显式转换强行赋过去,解引用时按 int 的宽度去读 double 的字节,结果没有意义。
  • 整数当地址:int *p = 100; 编译时会给一条“整数转指针”的警告,但编译能过,运行时一解引用就出事。
  • 悬空指针:局部变量住在线程栈上,函数一返回,那块栈空间就可能被别的调用覆盖,原来写的值不一定还在。
  • 越界指针:a + n 这种“指向末尾之后一格”的指针是允许产生、允许比较的,但不能再往后,也不能解引用。

一条自检经验:拿到一个指针,先问自己“它指的对象在这一刻还存在吗”,再问“我有没有走出这个数组的范围”。

易错:

  1. 定义完指针就解引用,忘了先让它指向某个对象
  2. 只改 p 就以为改了它所指的变量(p = q; 与 *p = *q; 混用)
  3. 函数里返回局部变量的地址,调用者拿到的是悬空指针
  4. 用 NULL 以外的魔数当空指针

6 · 什么时候需要指针形参

什么情况下需要为函数设置指针形参?

答案

C 的实参传递是值传递,形参是实参的副本。凡是希望函数改到调用者那边的变量,就必须把那个变量的地址传进去——这就是指针形参的用场。

三类典型场景:

  1. 函数要修改调用者的变量。void swap(int *a, int *b) 里换的是 *a、*b,调用者的两个变量跟着变;写成 void swap(int a, int b) 只换了两个副本,调用者的变量一点没动。
  2. 要带出多个结果。return 只能带回一个值,其余的结果通过指针形参写回。例如题 15 的 f(a, &c) 就是通过 *x 改到调用者的 c 上。
  3. 传递数组或字符串。数组名作实参时本来就退化成首元素指针,形参写成 int a[] 或 int *a 是一回事;结构体这类大对象也可以用指针传,省掉整个复制一遍的开销。
void swap(int *a, int *b)       /* 改到调用者 */
{
    int t = *a;
    *a = *b;
    *b = t;
}

void min_max(int *a, int n, int *lo, int *hi)   /* 带出两个结果 */
{
    int i;
    *lo = *hi = a[0];
    for (i = 1; i < n; i++) {
        if (a[i] < *lo) *lo = a[i];
        if (a[i] > *hi) *hi = a[i];
    }
}
解析

判断标准只有一条:函数要不要“从外面改到里面”,或者要不要“把多于一个结果送出去”。要,就用指针形参;不要,就传值。

反过来看,有几种情况其实不需要指针:

  • 函数只读不写调用者的数据。这时传值更安全——函数里怎么改都不影响调用者。
  • 只读的大对象,可以传“指向 const 的指针”,既省掉复制又表明不会改。形参写成 const int *a,函数里对 *a 赋值编译就过不了,相当于让编译器帮你把关。
  • 想把数组当实参传、又想知道它有多大,要么额外传一个长度形参,要么用“数组的引用”之类的 C++ 手段;单靠指针形参 int *a 在函数里用 sizeof(a) 是量不出数组长度的(退化成指针了)。

易错:

  1. 想改调用者的变量却写成传值形参,函数里改的是副本,外面没有变化
  2. 交换函数里只交换了形参 a、b(应该交换 *a、*b)
  3. 在指针形参上用 sizeof 求数组长度

7 · 函数指针的作用

函数指针有何作用?

答案

函数指针把“函数”当成一个值来用,由此带来三种用途:

  1. 运行期选择要执行哪段代码。同一个函数指针变量,在不同时刻指向不同的函数,就不必写一长串 switch/if 去分支调用。题 13 的菜单就是这么做:把选项对应的函数赋给 fp,再统一调用。
  2. 把函数作为实参传给另一个函数(回调)。被调用的那个函数不需要知道具体是哪个函数,只要按约定的原型调用即可。排序时传比较函数、qsort 的第四个参数都是这个用途。
  3. 用数组装一组函数指针,按编号取用。这样“按菜单号找函数”就是一次下标运算,比一串条件判断更整齐。
int add(int a, int b) { return a + b; }
int (*fp)(int, int);        /* 函数指针变量 */
fp = add;                   /* 指向 add */
解析

为什么函数指针能存在:在 C 里,函数名出现在表达式中时会被转换成“指向该函数的指针”,所以“函数的地址”和“变量的地址”一样是一个可以存在变量里的值。既然能存、能赋值,也就能当实参传、能放进数组。

它和普通数据指针的差别:函数指针不能做加减法(函数不在数组里连续排列),能做的是赋值、比较相等、解引用和调用。另外 sizeof 一个函数指针得到的是指针自身的大小,跟函数体多大没关系。

什么时候值得用:分支多于两三个、或者分支将来还会增加时,用“函数指针 + 表”比写一长串 if 更容易改;反之,只有两个分支就直接写 if,用函数指针反而绕。

易错:

  1. 把函数指针当成“返回指针的函数”来声明(漏掉括号,见题 9 的(5)(6))
  2. 以为函数指针能做加减运算
  3. 调用时写成 *fp(a, b),那是先调用再把返回值解引用

8 · 怎么使用函数指针

如何使用函数指针?

答案

四步:定义、赋值、调用、传参。

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }

/* 形参就是一个函数指针 */
int apply(int (*op)(int, int), int a, int b)
{
    return op(a, b);
}

int main(void)
{
    int (*fp)(int, int);        /* 1 定义:fp 是指向“int(int,int)”的指针 */

    fp = add;                   /* 2 赋值:函数名就是地址 */
    printf("%d\n", fp(3, 5));   /* 3 调用:得 8 */

    fp = sub;
    printf("%d\n", apply(fp, 3, 5));    /* 4 传参:得 -2 */
    return 0;
}

四点说明:

  1. 定义的括号不能少。int (*fp)(int, int); 是“指向函数的指针”;少写括号就成了 int *fp(int, int);,那是“带两个 int 形参、返回 int * 的函数”的声明,完全是另一回事。
  2. 赋值时 fp = add; 与 fp = &add; 等价,函数名本身就表示函数的地址。
  3. 调用时 fp(a, b) 与 (*fp)(a, b) 也等价,两种写法都对。
  4. 传参时把 fp 当普通实参交给形参即可,形参要写成函数指针类型,如 int (*op)(int, int)。
解析

这一题是题 9 的(5)(6)两个声明在用法上的展开。读声明的方法一样:从名字出发,先看右边再往左看。

(*fp)(int,int) 里 fp 先被括号和 * 绑住,说明 fp 是指针;剩下的 (int,int) 和 int 分别是“所指的是函数”和“函数返回 int”。

两处“等价写法”的道理:

  • fp = add; 与 fp = &add;:和数组名一样,函数名在表达式里会转换成函数指针,& 只是把这个转换写明。
  • fp(a, b) 与 (*fp)(a, b):函数指针被解引用后仍是一个函数指示符,调用它又会被转回指针,所以两种写法结果相同。

边界情况:

  • 调用之前必须让 fp 指向一个真实的函数。fp 没赋值就调用是未定义行为,常见做法是定义时初始化成 NULL,用之前判空。
  • 赋给同一个 fp 的函数原型必须一致:形参个数、形参类型、返回类型都要对得上,否则编译器会报不兼容。
  • 想“先调用再解引用”的写法 *fp(a, b) 是错的:() 的优先级高于 *,它被解析成 *(fp(a, b)),即先调用 fp,再把返回的整数当地址解引用。

易错:

  1. 定义时漏括号
  2. *fp(a, b)
  3. 把 fp = &add; 或 fp(a, b) 当成错的(两种等价写法都对)
  4. 函数指针还没指向任何函数就调用

9 · 七个声明的意义

试指出以下代码的意义:

(1)int a[10];
(2)int b[][3]={{1},{},{},{4,2}};
(3)int *p[];
(4)int (*q)[];
(5)int (*u)(int,int);
(6)int *v(int,int);
(7)int *(*w)(int,int);
答案
序号 答案
(1) 定义数组 a,10 个 int 元素
(2) 定义二维数组 b,每行 3 个 int;行数由初始化列表推出,共 4 行,等价于 int b[4][3]
(3) 定义数组 p,元素是“指向 int 的指针”;元素个数省略没写
(4) 定义指针 q,指向“元素个数未知的 int 数组”
(5) 定义函数指针 u,指向“两个 int 形参、返回 int”的函数
(6) 声明函数 v,两个 int 形参、返回 int *
(7) 定义函数指针 w,指向“两个 int 形参、返回 int *”的函数
解析

读这类声明的固定方法:从名字出发,先看它右边的符号,再看左边,遇到括号就按括号的层次往外剥。逐条说:

  • (1)int a[10];:a 右边是 [10],所以 a 是数组;再看左边 int,元素是 int。10 个元素共 40 字节。
  • (2)int b[][3]={{1},{},{},{4,2}};:b 是二维数组,每行 3 个 int。外层维度省略时由初始化列表的行数补齐:列表里 4 组 {},就是 4 行。没写到的元素补 0,所以 b[0] 是 {1,0,0},b[1]、b[2] 全是 0,b[3] 是 {4,2,0}。
  • (3)int *p[];:p 右边是 [],所以 p 是数组;再看左边 int *,元素类型是“指向 int 的指针”。这是一条容易看反的声明——它定义的是数组,不是指针。元素个数省略之后,两处的合法性完全不同,见下面那一小段。
  • (4)int (*q)[];:括号把 * 和 q 绑在一起,q 先是指针;再看 [],它指向一个“元素个数没写出的 int 数组”。这种“元素个数未知的数组”是不完整类型,指针本身可以定义(指针的大小是固定的),但 sizeof(*q) 不行。要让它真能用,得指向一个已经定好长度的数组,比如 int b[4][3]; q = b;。
  • (5)int (*u)(int,int);:u 是指针,指向函数;被指的函数要两个 int、返回 int。它就是题 8 里用的那种函数指针。
  • (6)int *v(int,int);:v 的名字右边直接是 (int,int),所以 v 是函数,不是指针;返回类型是 int *。这是函数声明(原型),不是定义。
  • (7)int *(*w)(int,int);:w 先和 * 结合,是指针;指向的函数要两个 int、返回 int *。它是(5)和(6)的叠加。

把(5)和(6)并排看:只差一对括号。这也是(3)和(4)的对照关系——(3)是数组,(4)是指针。

关于(3),文件作用域和块作用域不一样,这个差别值得单独记:

  • 文件作用域下,int *p[]; 被当作“暂定定义”处理,gcc 顺便假定它只有一个元素。编译只给一条警告,p[0] 能写能读,但类型始终是不完整类型,sizeof(p) 直接报错。
  • 块作用域下没有“暂定定义”这回事,数组的元素个数必须写出来,漏写就是硬错误。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):7 个声明按文件作用域逐个 -fsyntax-only 试过,(1)(2)(4)(5)(6)(7)通过,(3)给一条警告 array 'p' assumed to have one element;换到块作用域后(3)变成硬错误 error: array size missing in 'p',其余 6 个仍然通过((6)在块内也合法,它就是一条函数声明)。文件作用域下另外试了 sizeof(p),报 error: invalid application of 'sizeof' to incomplete type 'int *[]',而 p[0] = &x; 能正常写能正常读。把(2)写进程序跑了一遍:sizeof(b) 是 48、sizeof(b[0]) 是 12、b[3] 是 {4,2,0}。

易错:

  1. 把(3)int *p[]; 读成“指向数组的指针”(它是数组,元素才是指针)
  2. 把(5)(6)看成一回事(一个是指针,一个是函数)
  3. 以为(4)里 q 指向的数组长度随时能算出来(是不完整类型,不能 sizeof)
  4. 在块作用域里照着(3)写 int *p[];,直接编译不过

10 · 学生成绩表:查表与输出两个函数

有若干个学生,每人考 4 门课程,设用二维数组存储学生的成绩,二维数组的一行对应一个学生的成绩,每行的第一个数是学生的学号。

试以此数据结构为基础,编写两个函数:一个是已知成绩表和学号,返回该生成绩表的函数;另一个是已知某个学生表,输出学生学号和成绩的函数。要求两个函数采用指针编写。

答案
#include <stdio.h>

#define M 4     /* 每个学生的数据个数:学号 + 3 门课 */
#define N 5     /* 学生人数 */

/* 已知成绩表和学号,返回该生成绩表(指向该行的指针);找不到返回 NULL */
int *find_stu(int (*score)[M], int n, int id)
{
    int i;

    for (i = 0; i < n; i++)
        if (*(*(score + i) + 0) == id)
            return *(score + i);
    return NULL;
}

/* 已知某个学生表,输出学生学号和成绩 */
void print_stu(int *row, int m)
{
    int i;

    printf("学号: %d    成绩:", row[0]);
    for (i = 1; i < m; i++)
        printf(" %d", row[i]);
    printf("\n");
}

int main(void)
{
    int score[N][M] = {
        {101, 78, 85, 90},
        {102, 88, 92, 76},
        {103, 60, 70, 80},
        {104, 95, 89, 93},
        {105, 72, 68, 75}
    };
    int *p;

    p = find_stu(score, N, 103);
    if (p != NULL)
        print_stu(p, M);
    else
        printf("学号 103 不存在\n");

    p = find_stu(score, N, 999);
    if (p != NULL)
        print_stu(p, M);
    else
        printf("学号 999 不存在\n");
    return 0;
}
解析

思路:二维数组的“一行”本身就是一个一维数组,所以“返回该生成绩表”就是返回指向那一行的指针,类型正好是“指向 int 的指针”。

关键点:

  1. 二维数组作形参要写成 int (*score)[M],它表示“指向 M 个 int 的数组”的指针。写成 int score[][M] 是同一个意思;写成 int **score 就不对了——二维数组不是“指针的指针”,它的每一行是连续的 M 个 int,不是各自独立的指针。
  2. 行内第一个元素是学号,所以比的是 *(*(score + i) + 0)。*(score + i) 就是第 i 行的数组名,它在表达式里再退化成“指向该行首元素的指针”,也就是 int *。
  3. 返回 *(score + i),类型是 int *,与函数返回类型对上。这里不能写 return &score[i];——&score[i] 的类型是 int (*)[M],是“指向整行的指针”,不是“指向行内单个元素的指针”。
  4. 找不到要有交代,返回 NULL,调用处判空再打印。
  5. 第二个函数的形参就是“某个学生表”:一个 int * 加一个个数。拿到行首指针以后,row[0] 是学号,row[1] 起是成绩,个数 m 由调用者传进来。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序编译运行,输出两行 学号: 103 成绩: 60 70 80 和 学号 999 不存在。

易错:

  1. 把二维数组形参写成 int **score
  2. 返回时写 &score[i](类型是“指向整行的指针”)
  3. 忘了 NULL 这条分支,学号不存在时拿一个空指针去解引用
  4. 输出成绩时从 row[0] 开始打,把学号也当成一门成绩打了两遍

11 · 在数组中查找指定值的指针

用指针编写在数组中查找指定值指针的函数。

答案
#include <stdio.h>

/* 在数组 a 的前 n 个元素中查找值 x,返回指向它的指针;找不到返回 NULL */
int *find(int *a, int n, int x)
{
    int *p;

    for (p = a; p < a + n; p++)
        if (*p == x)
            return p;
    return NULL;
}

int main(void)
{
    int a[8] = {3, 8, 5, 9, 2, 7, 5, 1};
    int x;
    int *p;

    printf("数组:");
    for (p = a; p < a + 8; p++)
        printf(" %d", *p);
    printf("\n请输入要查找的值: ");
    if (scanf("%d", &x) != 1)
        return 0;

    p = find(a, 8, x);
    if (p != NULL)
        printf("找到 %d,下标 %d\n", x, (int)(p - a));
    else
        printf("没找到 %d\n", x);
    return 0;
}
解析

思路:让指针从数组头一路走到数组尾,沿路比较 *p,命中就返回 p(返回的是指针,不是下标);走完还没命中就返回 NULL。

关键点:

  1. 循环写成 for (p = a; p < a + n; p++)。上界 a + n 是“最后一个元素再往后一格”,指针停在它上面是允许的,只是不能解引用;用 < 而不是 <=,正好不多读一格。
  2. 返回类型是 int *,所以 return p; 和 return NULL; 两种出口的类型都对得上。
  3. 调用者如果想知道下标,用 p - a:两个指向同一数组元素的指针相减,结果是元素个数,类型是 ptrdiff_t,打的时候转成 int。
  4. 数组里有重复值时,函数返回的是第一次出现的那个位置——因为循环从前往后走,遇到就返回了。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):数组取 {3, 8, 5, 9, 2, 7, 5, 1},喂 9 打印 找到 9,下标 3;喂 5 打印 找到 5,下标 2(5 出现两次,返回前面那个);喂 4 打印 没找到 4。

易错:

  1. 返回下标而不是指针(题干要的是指针)
  2. 循环写成 p <= a + n,多走一格会读到数组外面
  3. p - a 直接配 %d 打(类型是 ptrdiff_t,转成 int 更稳)
  4. 用 while (*p) 之类“遇到 0 就停”的写法当循环条件——数组里本来就可能有 0,那是元素值不是结尾标志

12 · 删除字符串中的重复字符

用指针编写删除字符串中重复字符的函数。

答案
#include <stdio.h>

/* 删除字符串中的重复字符:每个字符只保留第一次出现的那个 */
void del_dup(char *s)
{
    char *p = s;    /* p 指向下一个要写入的位置 */
    char *q, *t;

    for (q = s; *q; q++) {
        for (t = s; t < p; t++)
            if (*t == *q)
                break;
        if (t == p)         /* q 指的字符在前面没出现过 */
            *p++ = *q;
    }
    *p = '\0';
}

int main(void)
{
    char s1[] = "aabbcc";
    char s2[] = "abcabcabc";
    char s3[] = "abcd";
    char s4[] = "";

    del_dup(s1); printf("[%s]\n", s1);
    del_dup(s2); printf("[%s]\n", s2);
    del_dup(s3); printf("[%s]\n", s3);
    del_dup(s4); printf("[%s]\n", s4);
    return 0;
}
解析

思路:一趟扫描,两个指针分工——q 负责读原串,p 负责往前面写新串。为了让“已经保留下来”的字符排在前面,判定重复只需要在 s 到 p 这一段里找,不用管后面还没处理的部分。

关键点:

  1. p 从 s 出发、只增不减,永远不会超过 q,所以可以原地改写,不需要额外数组。
  2. 内层 for (t = s; t < p; t++) 扫的是“已保留”的那一段。扫完 t == p 说明这个字符在已保留部分里没出现过,是第一次见,写下去;扫到中途 break 说明见过,跳过。
  3. 循环结束后补 *p = '\0';。这一步不能省:不补的话后面还留着原串的尾巴,printf("%s") 会把没清干净的内容一起打出来。
  4. 空串也能处理:外层循环一次都不进,p 还是 s,直接写一个 '\0' 收尾,结果还是空串。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):四个串依次是 "aabbcc"、"abcabcabc"、"abcd"、"",调用后打印成 [abc]、[abc]、[abcd]、[]。

易错:

  1. 只跟相邻字符比较("abcabc" 里第二个 a 不是挨着第一个 a,这么写会漏掉)
  2. 忘了补 '\0'
  3. 判定时把比较范围写成整个原串,第二个 a 会和它自己比出“重复”来,结果是每个字符都留不下
  4. 先把串删短了再继续用原来的长度

13 · 菜单选择运算与函数指针形参

分别编写 4 个函数,计算两个整数的和、差、积、商。主函数提供一个菜单,能让使用者指定运算,主函数根据用户的指定选取函数的指针,并以函数指针为实参调用计算函数 execute() 完成计算。

答案
#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int divide(int a, int b) { return a / b; }

/* 以函数指针为形参调用具体的运算函数 */
int execute(int (*op)(int, int), int a, int b)
{
    return op(a, b);
}

int main(void)
{
    int (*fp)(int, int);
    int a, b, choice;

    printf("1-加  2-减  3-乘  4-除\n请选择: ");
    if (scanf("%d", &choice) != 1)
        return 0;
    printf("请输入两个整数: ");
    if (scanf("%d%d", &a, &b) != 2)
        return 0;

    switch (choice) {
    case 1: fp = add;    break;
    case 2: fp = sub;    break;
    case 3: fp = mul;    break;
    case 4: fp = divide; break;
    default:
        printf("无效的选择\n");
        return 0;
    }

    printf("结果: %d\n", execute(fp, a, b));
    return 0;
}
解析

思路:四个运算函数的原型必须完全一样(都是“两个 int、返回 int”),才能赋给同一个函数指针;菜单只负责把 fp 指向对应的那个函数,真正的计算交给 execute。

关键点:

  1. 四个函数的原型一致。题面说“两个整数的商”,所以用 a / b 的整数除法。
  2. 函数指针的声明 int (*fp)(int, int); 括号必须写。漏掉括号写成 int *fp(int, int); 就从“函数指针变量”变成了“函数声明”,是题 9 里(5)(6)那一对的区别。
  3. 菜单用 switch 选中函数,赋值写 fp = add; 就行,函数名本身就表示函数的地址;写 fp = &add; 也对。
  4. execute 的形参就是一个函数指针,execute(fp, a, b) 把 fp 当普通实参传进去;execute 内部 op(a, b) 就是调用被指的那个函数。
  5. 除数为 0 时整数除法是未定义行为,实际运行通常直接崩溃,用的时候要避开。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):这份程序编译运行,喂入 1 与 3 5 打印 结果: 8,喂 2 与 10 4 打印 结果: 6,喂 3 与 6 7 打印 结果: 42,喂 4 与 20 6 打印 结果: 3,喂 9 与 1 2 打印 无效的选择。

易错:

  1. 声明函数指针时漏括号
  2. 四个运算函数的原型不一致(有一个写成 double 之类),赋给同一个 fp 编译就报不兼容
  3. 在 execute 里写成 *op(a, b)——() 优先级高,这句会先调用 op、再把返回的整数当地址解引用,正确写法是 op(a, b) 或 (*op)(a, b)
  4. 传参时写成 execute(fp(a, b), ...),那样是当场把结果算出来了,没有“以函数指针为实参”这层意思

14 · strpos 函数的功能

写出以下函数的功能。

char *strpos(char *s,char *t)
{ char *j,*k;
  for(;*s;s++)
  { for(j=s,k=t;*k&&*j==*k;j++,k++);
    if(k!=t&&*k=='\0')  return s;
  }
  return NULL;}
答案

功能:在字符串 s 中查找子串 t 第一次出现的位置,返回指向这个位置起点的指针;没有找到就返回 NULL。

s = "hello world", t = "world"  →  指向 'w'("world" 的起点)
s = "hello world", t = "abc"    →  NULL
解析

这是自己实现的子串查找,等价于库函数 strstr 的功能。外层把 s 的每个位置依次当起点,内层从起点开始逐字符和 t 比。

逐段看:

  • for(;*s;s++):外层循环,s 从头走到尾,每个位置都试一次当起点。
  • for(j=s,k=t;*k&&*j==*k;j++,k++);:内层比较,循环体是空语句。循环条件 *k && *j == *k 的意思是“t 还没比完,而且当前这一对字符相等”,任一不成立就退出。退出有三种可能:t 比完了(*k == '\0')、这一对不等、或者 s 已经到头(*j == '\0')。
  • if(k!=t&&*k=='\0') return s;:两个条件同时成立才算匹配成功。*k == '\0' 说明 t 整个比完了;k != t 说明至少比过一个字符,用来排除“一个字符都没比就退出”的情况。两个都满足就返回当前起点 s。
  • 走到 return NULL; 说明所有起点都试过,没找到。

三处边界:

  • t 是空串。内层一进循环 *k 就是 '\0',循环体一次都不执行,k 仍等于 t,k != t 为假,所以永远不会匹配,函数返回 NULL。也就是说这个函数把空串当作“找不到”处理。库函数 strstr 的做法不同——strstr(s, "") 返回 s 本身,因为它规定空串在任意位置都能匹配。两者对空串的口径不一致,用的时候要注意。
  • t 比 s 长。内层会因为 *j 先到 '\0' 而退出,此时 *k != '\0',不算匹配,继续试下一个起点,最后返回 NULL。
  • s 是空串。外层一次都不进,直接 return NULL;。

题干代码按原样在 gcc 15.2.0(-O2 -std=gnu23 -Wall)下不能编译:.cache/homework-book/verify/ch6/q14_orig.c:7:10: error: 'NULL' undeclared (first use in this function),gcc 顺手提示 'NULL' is defined in header '<stddef.h>'; this is probably fixable by adding '#include <stddef.h>'。

NULL 定义在 <stddef.h> 里,题干这一小段作为片段省略了头文件。

核实输出时只做了两处最小修改:① 补 #include <stddef.h>(NULL 的定义);② 补一个 main 驱动用于喂输入,strpos 函数体一字未动。

补上头文件以后编译通过,另有一条与本题逻辑无关的警告:warning: this 'for' clause does not guard... [-Wmisleading-indentation]——内层 for 的循环体是那个空的分号,紧跟其后的 if 其实不在循环里,只是缩进看着像在循环里。

gcc 提醒的正是这一点,逻辑本身没错。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):喂 9 组输入,strpos("hello world", "world") 得 world,strpos("hello world", "abc") 得 NULL,strpos("hello world", "hello") 得 hello world,strpos("hello world", "worlds") 得 NULL,strpos("hello world", "") 得 NULL,strpos("", "a") 得 NULL,strpos("", "") 得 NULL,strpos("abcabc", "abc") 得 abcabc,strpos("abcabc", "bca") 得 bcabc。

易错:

  1. 以为空串会匹配到位置 0(这个函数返回 NULL,跟库函数 strstr 不同)
  2. 把 k != t 当成多余的判断删掉,那样空串就会“匹配”到位置 0
  3. 认为 return s 返回的是下标(返回的是指针)
  4. 把内层那个空语句的分号去掉,整个逻辑就变了

15 · 全局、局部、静态同名变量的输出

写出以下程序的输出结果。

#include<stdio.h>
int a=2,c=4;
f(int a,int *x)
{ int b=10;
  static int c=20;
  b+=a++;   c+=a+b;   *x=c+2;
}
void main()
{ f(a,&c);    printf("In main(1): a=%d,c=%d\n",a,c);
  f(3+c,&a);  printf("In main(2): a=%d,c=%d\n",a,c);
}
答案
In main(1): a=2,c=37
In main(2): a=128,c=37
解析

这道题的全部机关在同名遮蔽和 static 上。先把名字理清楚:

  • 全局 a = 2、全局 c = 4,main 里打印的就是这两个。
  • f 的形参 a 是值传递,和全局 a 同名但是另一个对象;形参 x 是指针。
  • f 里的 b = 10 是自动变量,每次调用都重新初始化。
  • f 里的 static int c = 20; 是函数内部的静态变量,只初始化一次,后面的调用沿用上一次留下来的值;它在 f 里遮蔽了全局 c,所以 f 里写的 c 都是指它。

第一次调用 f(a, &c):

  • 实参 a 是全局 a 的值 2,形参 a 得 2;x 拿到全局 c 的地址。
  • b += a++;:后缀自增先用原值 2,b = 10 + 2 = 12,同时局部 a 自增成 3。
  • c += a + b;:静态 c = 20 + 3 + 12 = 35。这里的 a 已经是 3。
  • *x = c + 2;:x 指着全局 c,所以全局 c = 35 + 2 = 37。
  • 回到 main,打印的 a、c 是全局那两个:a 还是 2,c 是 37。得到 In main(1): a=2,c=37。

第二次调用 f(3+c, &a):

  • 实参表达式 3+c 用的是全局 c = 37,算出 40;x 这次拿到全局 a 的地址。
  • 进入 f:局部 b 重新初始化成 10;静态 c 沿用上次的 35,不再初始化。
  • b += a++;:b = 10 + 40 = 50,局部 a 变成 41。
  • c += a + b;:静态 c = 35 + 41 + 50 = 126。
  • *x = c + 2;:x 指着全局 a,所以全局 a = 128。
  • 回到 main:全局 a 已经是 128,全局 c 仍是 37——两次调用写的都是静态的那个 c,全局 c 只在第一次被改过一次。得到 In main(2): a=128,c=37。

题干代码按原样在 gcc 15.2.0(-O2 -std=gnu23 -Wall)下不能编译:.cache/homework-book/verify/ch6/q15_orig.c:3:1: error: return type defaults to 'int' [-Wimplicit-int]——教材按 C89 的写法,定义函数时不写返回类型就默认是 int,C23 已经把“隐式 int”删掉了,这是硬错误;另外 gcc 对 void main() 给出 warning: return type of 'main' is not 'int' [-Wmain],标准从来只认 int main。

核实输出时只做了三处最小修改:① f 补上返回类型,写成 int f(int a,int *x);② f 末尾补 return 0;(不补的话 gcc 会给 control reaches end of non-void function);③ void main() 改成 int main(void) 并补 return 0;。其余一字未动。

修改后的完整程序:

#include<stdio.h>
int a=2,c=4;
int f(int a,int *x)
{ int b=10;
  static int c=20;
  b+=a++;   c+=a+b;   *x=c+2;
  return 0;
}
int main(void)
{ f(a,&c);    printf("In main(1): a=%d,c=%d\n",a,c);
  f(3+c,&a);  printf("In main(2): a=%d,c=%d\n",a,c);
  return 0;
}

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份修改后的程序编译运行,输出 In main(1): a=2,c=37 和 In main(2): a=128,c=37。

易错:

  1. 把 f 里的 c 当成全局 c(它是 static 局部变量,把全局的同名变量遮住了)
  2. 忘了 static 只初始化一次,以为第二次进 f 时 c 又从 20 开始
  3. 以为第二次调用 f 改的还是全局 c(第二次 x 指向的是全局 a)
  4. 把 b += a++ 读成“先自增再相加”(后缀是先取原值)
  5. 认为两次打印里 c 都是 37 是巧合——它两次指的都是同一个全局 c,只是第二次没被改到

16 · 输入、输出、排序三个函数

编写输入,输出和排序 3 个函数。3 个函数都以指针数组和数组元素个数为形参,分别实现将一组离散的变量顺序输入、输出和从小到大排序。

答案
#include <stdio.h>

#define N 6

/* 输入:指针数组和元素个数 */
void input(int *pa[], int n)
{
    int i;
    for (i = 0; i < n; i++) {
        printf("请输入第 %d 个数: ", i + 1);
        if (scanf("%d", pa[i]) != 1)
            return;
    }
}

/* 输出:指针数组和元素个数 */
void output(int *pa[], int n)
{
    int i;
    for (i = 0; i < n; i++)
        printf("%d ", *pa[i]);
    printf("\n");
}

/* 从小到大排序:只交换指针的指向,离散变量本身不动 */
void sort(int *pa[], int n)
{
    int *t;
    int i, j;

    for (i = 0; i < n - 1; i++)
        for (j = 0; j < n - 1 - i; j++)
            if (*pa[j] > *pa[j + 1]) {
                t = pa[j];
                pa[j] = pa[j + 1];
                pa[j + 1] = t;
            }
}

int main(void)
{
    int a = 0, b = 0, c = 0, d = 0, e = 0, f = 0;   /* 一组离散变量 */
    int *pa[N] = {&a, &b, &c, &d, &e, &f};

    input(pa, N);
    printf("排序前: ");
    output(pa, N);
    sort(pa, N);
    printf("排序后: ");
    output(pa, N);
    return 0;
}
解析

思路:题面说的“一组离散的变量”就是 a、b、c 这样各自独立的变量,它们地址不连续,没法像数组那样用下标遍历。

要让三个函数用同一套代码处理它们,就得先把它们的地址收集到一个指针数组里,再把指针数组交给函数。

关键点:

  1. 指针数组 int *pa[N] = {&a, &b, &c, &d, &e, &f}; 是这道题的枢纽。有了它,pa[i] 就是一个“指向第 i 个离散变量”的指针,三个函数都按 pa[i] 来处理。
  2. 形参写 int *pa[],它和 int **pa 等价,都是“指向指针的指针”;数组名作实参传进来时同样退化成首元素指针。
  3. 输入时写 scanf("%d", pa[i])——pa[i] 本身就是地址,不能再加 &。写成 &pa[i] 是把“指针的地址”交给了 %d,读进来的整数会写进指针数组本身,把 pa 弄坏。
  4. 排序排的是指针数组:三元交换换的是 pa[j] 和 pa[j + 1] 这两个指针,a、b 这些变量本身一个都没动。排完之后 pa[0] 已经指向最小的那个变量,但 a 里的值还是它原来的值。如果交换写成 *pa[j] 和 *pa[j + 1],那样改的是变量里的值,打印顺序同样会从小到大,但那已经是“用指针给变量排序”,不是“排指针数组”了。
  5. 输出时解引用:*pa[i]。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):喂入 3 7 1 9 5 2,排序前打印 3 7 1 9 5 2,排序后打印 1 2 3 5 7 9。(排序前 那一行末尾本来有一个空格,是 %d 打最后一个数留下的,这里按判题口径剥掉。)

易错:

  1. scanf("%d", &pa[i]),多写一个 &
  2. 以为“排完序 a 里就是最小的数”(交换的是指针,离散变量本身的值没变)
  3. 指针数组忘了初始化成各个变量的地址
  4. 形参写成 int pa[],那样 pa[i] 是 int 不是指针,解引用就错了

17 · 用指针描述六种计算

用指针描述以下计算:

(1)将方阵的上三角元素与下三角对应元素交换,使方阵沿着主对角线转 180°。

(2)字符串中的字符个数。

(3)将字符串 s2 中的前 n 个字符复制到字符数组 s1[]。

(4)将字符串 s2 中的字符复制连接在字符数组 s1[] 中字符串之后,使它存有更长的字符串。

(5)将字符串 s 中的小写英文字母改写成大写英文字母。

(6)整理字符串,将字符串中前导和后随的空白符删除,字符串中间连续的多个空白符只保留一个,去掉多余的空白符。

答案
#include <stdio.h>

#define N 4

/* (1)将方阵的上三角元素与下三角对应元素交换 */
void swap_triangle(int (*a)[N], int n)
{
    int i, j, t;

    for (i = 0; i < n; i++)
        for (j = i + 1; j < n; j++) {
            t = *(*(a + i) + j);
            *(*(a + i) + j) = *(*(a + j) + i);
            *(*(a + j) + i) = t;
        }
}

/* (2)字符串中的字符个数 */
int str_len(char *s)
{
    char *p = s;

    while (*p)
        p++;
    return (int)(p - s);
}

/* (3)将 s2 中的前 n 个字符复制到 s1 中 */
char *str_ncpy(char *s1, char *s2, int n)
{
    char *p = s1;

    while (n-- > 0 && *s2)
        *p++ = *s2++;
    *p = '\0';
    return s1;
}

/* (4)将 s2 接在 s1 中字符串的后面 */
char *str_cat(char *s1, char *s2)
{
    char *p = s1;

    while (*p)
        p++;                 /* 先走到 s1 的末尾 */
    while (*s2)
        *p++ = *s2++;
    *p = '\0';
    return s1;
}

/* (5)把小写英文字母改写成大写 */
char *to_upper(char *s)
{
    char *p = s;

    while (*p) {
        if (*p >= 'a' && *p <= 'z')
            *p = *p - 'a' + 'A';
        p++;
    }
    return s;
}

/* (6)删除前导、后随空白,中间连续多个空白只留一个 */
int is_blank(char c)
{
    return c == ' ' || c == '\t' || c == '\n';
}

char *squeeze(char *s)
{
    char *p = s;    /* 读的位置 */
    char *q = s;    /* 写的位置 */

    while (is_blank(*p))        /* 跳过前导空白 */
        p++;
    while (*p) {
        if (is_blank(*p)) {
            *q++ = ' ';
            while (is_blank(*p))
                p++;
        } else {
            *q++ = *p++;
        }
    }
    while (q > s && *(q - 1) == ' ')   /* 去掉末尾那个空白 */
        q--;
    *q = '\0';
    return s;
}

int main(void)
{
    int m[N][N] = {
        { 1,  2,  3,  4},
        { 5,  6,  7,  8},
        { 9, 10, 11, 12},
        {13, 14, 15, 16}
    };
    char s1[40] = "abc";
    char s2[40] = "  hello   world  ";
    char s3[] = "Hello, World!";
    int i, j;

    printf("(1) 交换前:\n");
    for (i = 0; i < N; i++) {
        for (j = 0; j < N; j++)
            printf("%3d", *(*(m + i) + j));
        printf("\n");
    }
    swap_triangle(m, N);
    printf("(1) 交换后:\n");
    for (i = 0; i < N; i++) {
        for (j = 0; j < N; j++)
            printf("%3d", *(*(m + i) + j));
        printf("\n");
    }

    printf("(2) \"%s\" 的长度 = %d\n", s3, str_len(s3));

    str_ncpy(s1, "0123456789", 5);
    printf("(3) s1 = [%s]\n", s1);

    str_cat(s1, "XYZ");
    printf("(4) s1 = [%s]\n", s1);

    printf("(5) s3 = [%s]\n", to_upper(s3));

    printf("(6) 整理前 = [%s]\n", s2);
    printf("(6) 整理后 = [%s]\n", squeeze(s2));
    return 0;
}
解析

六个小题各自独立,共同点是把“用指针”落在三件事上:让指针从一头走到另一头、用 *(p + i) 或 p[i] 代替下标、让两个指针分工(一个读一个写)。逐题说:

  • (1)题干把这一步描述为“沿着主对角线转 180°”,实现上就是把上三角元素与下三角对应元素互换,即 a[i][j] 与 a[j][i] 对调,主对角线上的元素不动。四阶方阵做完之后,原来的第 1 行 1 2 3 4 变成第 1 列 1 5 9 13,这就是沿主对角线的翻折。“用指针描述”体现在 *(*(a + i) + j) 这种写法上:a 的类型是 int (*)[N],a + i 指向第 i 行,*(a + i) 是第 i 行的首元素指针,再 + j 解引用就取到元素。
  • (2)让指针 p 从首字符一路走到 '\0',停下时 p - s 就是字符个数。'\0' 本身不算在内,所以它等价于 strlen。
  • (3)n 的次上限和 s2 的结束同时把关:while (n-- > 0 && *s2) 只要其中一个不成立就停,最后补一个 '\0'。这里与库函数 strncpy 的口径不同——strncpy 在 s2 短于 n 时会把剩下的位置全部补 '\0',而这里只补一个。要的是“复制前 n 个字符”,这样写更直白。
  • (4)先让 p 走到 s1 的末尾(*p == '\0' 处),再从那里接着写,这就是 strcat 的做法。调用者必须自己保证 s1 后面还有足够空间,函数没有长度参数可以拦。
  • (5)判 *p >= 'a' && *p <= 'z' 再减 'a' 加 'A',只有在区间里才改,其他字符原样保留。ctype.h 的 toupper 也能用,但它还按本地化规则处理别的字符;本题只要求 26 个小写字母,自己判更清楚。
  • (6)读指针 p 和写指针 q 分工:先跳掉前导空白;中间遇到连续空白只写一个空格并把这一段空白跳完;其他字符原样写过去。最后把末尾多写的那个空格退掉,再补 '\0'。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份程序编译运行:方阵 1..16 交换后变成 1 5 9 13 / 2 6 10 14 / 3 7 11 15 / 4 8 12 16;"Hello, World!" 的长度算出 13;str_ncpy(s1, "0123456789", 5) 后 s1 是 01234;再 str_cat(s1, "XYZ") 后是 01234XYZ;to_upper 后是 HELLO, WORLD!;" hello world " 整理前是 [ hello world ],整理后是 [hello world]。

易错:

  1. (1)把“交换上三角和下三角”写成交换相邻两行之类的别的操作
  2. (2)把结尾的 '\0' 也算进长度
  3. (3)漏掉 *s2 这一半判断,当 n 比 s2 长时会一直读到字符串外面
  4. (4)忘了先走到 s1 的末尾,直接从 s1[0] 开始覆写,把原来的内容冲掉
  5. (5)用 *p - 32 硬编码大小写之差(写 'a'、'A' 更稳)
  6. (6)只跳前导空白忘了末尾那个空格,或者中间连续空白写了一大串

18 · 六段引用代码的正确性

试分别指出以下代码的正确性,如果正确,说明其意义;如果不正确,说明其原因。

(1)int x; int &rx=x;
(2)int y;  int &ry=y;
     int &*p=&ry;
(3)int *p;
     int *&q=p;
     int u=9;
     q=&u;
(4)void &r=3;
(5)int b[10];
     int &rb[10]=b;
(6)int &f(int a[],int index)
     {  int r=a[index]
        return r;
     }
答案
序号 答案
(1) 正确。rx 是 x 的别名
(2) 不正确。没有“指针指向引用”这种类型
(3) 正确。q 是指针 p 的别名,q=&u; 就是 p=&u;
(4) 不正确。不能声明 void 的引用;而且引用不能绑到字面量 3 上
(5) 不正确。不能定义“引用的数组”
(6) 不正确。int r=a[index] 缺分号;而且返回的是对局部变量 r 的引用
解析

先说明一句:这六段是 C++ 的引用语法,本题混排了 C/C++。

本站判题只收 C,六段代码用 C 编译器一段都过不了,报的都是同一条:error: expected identifier or '(' before '&' token(第(3)段还连带一条 error: 'q' undeclared,因为 int *&q=p; 整行没被识别成声明)。

引用是 C++ 的语法,下面的正确性按 C++ 判。

逐项说明:

  • (1)正确 ✅。rx 是 x 的引用,也就是 x 的别名:此后写 rx 就是写 x,读 rx 就是读 x,引用本身不占额外的对象空间。把 rx 改成 7,x 也就成了 7。
  • (2)不正确。ry 的类型是“int 的引用”,即 int &;再在前面加一个 *,就成了“指向 int & 的指针”。C++ 里没有这种类型——引用不是一个对象,没有自己的地址,也就没法有指针指向它。g++ 报 error: cannot declare pointer to 'int&'。顺带说一句,题干这里是 &*,与(3)的 *& 正好相反,不是抄写错误,两者的差别正是本题的考点。
  • (3)正确 ✅。从名字读起:q 先和 & 结合,说明 q 是引用;引用的类型是 int *。所以 q 是“指针 p 的引用”,q = &u; 就是 p = &u;。之后 *p 和 *q 都是 u 的值。
  • (4)不正确,两处都不行。一是 void 表示“没有对象”,谈不上给它起别名,g++ 报 error: cannot declare reference to 'void';二是即使把类型改成 int,3 是字面量(右值),非 const 的左值引用绑不上去,g++ 报 error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'。
  • (5)不正确。数组的元素必须是对象,引用不是对象,所以“引用的数组”不允许。g++ 报 error: declaration of 'rb' as array of references。反过来的“数组的引用”是允许的,要写成 int (&rb)[10] = b;——括号的位置正好和题面相反。
  • (6)不正确,两处。一是 int r=a[index] 后面漏了分号,g++ 先报 error: expected ',' or ';' before 'return';二是即便补上分号,r 是函数里的自动变量,函数一返回它就不存在了,返回对它的引用就是悬垂引用。补上分号后在 -std=gnu++23 下 g++ 直接报 error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'(return 里的局部变量按右值处理,绑不到 int& 上),在 -std=gnu++98 下则只给 warning: reference to local variable 'r' returned [-Wreturn-local-addr]。教材本章小结那句话说的正是这一条:“因为当函数返回时,自动变量 r 已不存在,不能返回对 r 的引用。”

(6)还牵着本题的一个延伸:要把函数里的结果以引用形式送出去,被引用的对象必须比函数活得长,例如用 static 局部变量、全局变量,或者由调用者传进来的对象。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):六段代码用 C 编译器逐段试过,报错原文都是 error: expected identifier or '(' before '&' token,位置分别在 int &rx=x;、int &ry=y; 与 int &*p=&ry;、int *&q=p;、void &r=3;、int &rb[10]=b;、int &f(int a[],int index) 这几行的 & 上,(3)还连带 error: 'q' undeclared (first use in this function)。把(1)和(3)分别补成能跑的 main 送给 g++ 15.2.0(-O2 -std=gnu++23 -Wall),(1)输出 x=7 rx=7,(3)输出 *p=9 *q=9,与上面说的意义一致;六段里的其余四段在 g++ 下也都不通过,报错原文见上面逐项说明。

易错:

  1. 把(2)的 int &*p 当成(3)的 int *&q(一个是“指向引用的指针”,不合法;一个是“指针的引用”,合法)
  2. 以为(4)只是“不能引用字面量”这一条原因(void 本身就不可引用)
  3. 把(5)和 int (&rb)[10] 混为一谈
  4. 以为(6)只是缺个分号,补上就对了(返回局部变量的引用同样不合法)

19 · 用引用形参交换两个字符串

编写用引用形参实现两个字符串交换的函数。例如,调用函数前有:

char *s1="Shanghai";    char *s2="Beijing";

以 s1 和 s2 为实参调用函数后,有:

s1 指向字符串"Beijing";    s2 指向字符串"Shanghai";
答案

引用形参版(C++,题干要求):

#include <cstdio>

/* 引用形参:a、b 分别是实参指针的别名 */
void swap_str(char *&a, char *&b)
{
    char *t = a;
    a = b;
    b = t;
}

int main()
{
    char *s1 = "Shanghai";
    char *s2 = "Beijing";

    printf("交换前: s1 -> %s, s2 -> %s\n", s1, s2);
    swap_str(s1, s2);
    printf("交换后: s1 -> %s, s2 -> %s\n", s1, s2);
    return 0;
}

同一件事用 C 写(本站判题只收 C,C 里没有引用,等价写法是二级指针):

#include <stdio.h>

void swap_str(char **a, char **b)
{
    char *t = *a;
    *a = *b;
    *b = t;
}

int main(void)
{
    char *s1 = "Shanghai";
    char *s2 = "Beijing";

    printf("交换前: s1 -> %s, s2 -> %s\n", s1, s2);
    swap_str(&s1, &s2);
    printf("交换后: s1 -> %s, s2 -> %s\n", s1, s2);
    return 0;
}
解析

思路:要换的是“s1 指向谁、s2 指向谁”,也就是两个指针变量里存的地址。

函数要改到调用者的 s1、s2 本身,形参就必须是这两个变量的别名——引用形参正好干这件事:函数体里的 a 就是 s1 本人,a = b; 直接写进 s1。

关键点:

  1. 引用形参的写法是 char *&a。读法:a 先和 & 结合,a 是引用;引用的类型是 char *。所以 a 是“字符指针的引用”,这就是题 18 第(3)段那个 int *&q 的同一种类型。
  2. 函数体里交换三行用的是 char *t 这个中间量。不能省掉它直接写 a = b; b = a;,那样第二步拿到的是已经被改过的 a,两个指针会指到同一处。
  3. 没有引用时,同一种效果用二级指针:形参是 char **a,函数体里写 *a,调用时写成 swap_str(&s1, &s2)——多一层取地址。C 语言里要“改到调用者的指针”,走的就是这条路。
  4. 形参写成 char *a, char *b 做不到本题要求:那样换的只是两个副本的指向,s1、s2 一点没变。(要用一级指针改内容、不改指向,那是另一回事,比如把 s1 指向的字符全改成大写。)
  5. 题干里 char *s1="Shanghai"; 这种写法在 C++ 里从 C++11 起就有问题:把字符串常量转成 char *,g++ 默认给 warning: ISO C++ forbids converting a string constant to 'char*'。规范写法是 const char *s1,但本题要保持“交换指针”的语义,照题干写成 char * 更贴合。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面那份 C 版(二级指针)编译运行,输出 交换前: s1 -> Shanghai, s2 -> Beijing 与 交换后: s1 -> Beijing, s2 -> Shanghai。同一份用例送 g++ 15.2.0(-O2 -std=gnu++23 -Wall)编译引用版,两行输出一模一样,编译时只对上面第 5 条那两个字符串常量给出 -Wwrite-strings 警告。

易错:

  1. 形参写成 char *a, char *b,函数里换的是两个副本,s1、s2 没变
  2. C 版忘了调用时加 &(引用版反过来,多写了 & 是错的)
  3. 中间量那三行写成两行
  4. 在函数体里写 *a = *b——那是把一份字符串的内容抄到另一份上,不是换指向,而且字符串常量不可写

20 · 全排列递归的输出

写出以下程序的输出结果。

#include<stdio.h>
#include<string.h>
char str[]="ABC";
pp(char *s,int k)
{ int i;char c;
  if(k==0)printf("%s\n",s);
  else for(i=k-1;i>=0;i--)
  { c=s[k-1];s[k-1]=s[i];s[i]=c;
    pp(s,k-1);
    c=s[k-1];s[k-1]=s[i];s[i]=c;
  }
}
void main()
{ pp(str,strlen(str));
}
答案
ABC
BAC
ACB
CAB
CBA
BCA
解析

这是“交换法生成全排列”的经典递归:pp(s, k) 生成 s 前 k 个字符的全部排列,做法是把第 k 个字符(s[k-1])依次与前面的每一个(含它自己)交换,递归处理前 k-1 个,回来再换回去。

  • if (k == 0) 是递归出口:前 0 个字符没什么可排的,当前这一串就是一个排列,直接打印。
  • else 的 for (i = k-1; i >= 0; i--) 让 s[k-1] 依次与 s[k-1]、s[k-2]……s[0] 交换,共 k 种选择。第一次 i = k-1 是自己和自己换,等于不动。
  • 每次交换后递归 pp(s, k-1),回来后用同样的三行把 s[k-1] 与 s[i] 换回去。这一步是回溯,保证下一个 i 面对的仍是原来那个串;少了它,输出会缺项乱序。
  • str 是全局数组(可写),所以原地交换没问题;strlen(str) 得 3,第一次调用是 pp(str, 3)。

按这三条推一遍(记 s 当前是 "ABC"):

  • 第一层 k = 3。
  • i = 2:换 s[2] 与 s[2],不变,仍是 ABC,递归 pp(s, 2):
    • k = 2,i = 1:换 s[1] 与 s[1],仍是 ABC,递归 pp(s, 1);k = 1,i = 0:换 s[0] 与 s[0],仍是 ABC,递归 pp(s, 0) 打印 ABC。
    • k = 2,i = 0:换 s[1] 与 s[0],得 BAC,递归 pp(s, 1) → 打印 BAC;换回来仍是 ABC。
  • i = 1:换 s[2] 与 s[1],得 ACB,递归 pp(s, 2):
    • i = 1:不变,ACB,递归打印 ACB。
    • i = 0:换 s[1] 与 s[0],得 CAB,递归打印 CAB;换回来仍是 ACB。
      再把 s[2] 与 s[1] 换回来,恢复 ABC。
  • i = 0:换 s[2] 与 s[0],得 CBA,递归 pp(s, 2):
    • i = 1:不变,CBA,递归打印 CBA。
    • i = 0:换 s[1] 与 s[0],得 BCA,递归打印 BCA;换回来仍是 CBA。
      再把 s[2] 与 s[0] 换回来,恢复 ABC。

打印顺序就是 ABC、BAC、ACB、CAB、CBA、BCA,6 行,正好是 3! 个排列。注意它不是按字典序输出的。

题干代码按原样在 gcc 15.2.0(-O2 -std=gnu23 -Wall)下不能编译:.cache/homework-book/verify/ch6/q20_orig.c:4:1: error: return type defaults to 'int' [-Wimplicit-int]——教材按 C89 的写法,pp 没写返回类型就默认是 int,C23 已经把“隐式 int”删掉了,这是硬错误;另外 gcc 对 void main() 给出 warning: return type of 'main' is not 'int' [-Wmain]。

核实输出时只做了三处最小修改:① pp 补上返回类型,写成 int pp(char *s,int k);② pp 末尾补 return 0;;③ void main() 改成 int main(void) 并补 return 0;。其余一字未动。

真机实测(gcc 15.2.0,-O2 -std=gnu23 -Wall):上面这份修改后的程序编译运行,输出 6 行 ABC、BAC、ACB、CAB、CBA、BCA,与逐层推演的顺序逐行吻合;把 str 换成 "ABCD" 再跑一遍是 24 行(4! = 24),确证它生成的确实是全排列。

易错:

  1. 以为会按字典序打印(BAC 排在 ACB 前面)
  2. 忽略“换回来”那三行,不回溯的话输出会少项或重复
  3. 把 k 当数组下标直接读 s[k](循环里用的是 s[k-1])
  4. 以为打印的是下标或长度而不是字符串本身

习题自测登记:完成本章习题后在此登记