Skip to content

Commit

Permalink
docs: note pointer update 3
Browse files Browse the repository at this point in the history
  • Loading branch information
SkyR0ver committed Nov 27, 2023
1 parent 11e7746 commit 9ee2493
Showing 1 changed file with 106 additions and 13 deletions.
119 changes: 106 additions & 13 deletions docs/programming/topic/pointers.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ type-specifier * qualifiers declarator
???+ example "案例:交换函数"

假设 `fake_swap` 函数定义如下:

```c
void fake_swap(int x, int y) {
int tmp = x;
Expand All @@ -203,6 +204,7 @@ type-specifier * qualifiers declarator
```

调用部分如下:

```c
int a = 1, b = 2;
fake_swap(a, b);
Expand All @@ -211,6 +213,7 @@ type-specifier * qualifiers declarator
在调用 `fake_swap` 函数后检查 `a`、`b` 的值,可发现它们的值并没有交换。这是因为,调用 `fake_swap` 函数时,实参 `a`、`b` 的值被复制给形参 `x`、`y`,函数仅修改了形参的值,并未影响函数外部的实参。

现在,我们将 `fake_swap` 函数修改为:

```c
void swap(int *x, int *y) {
int tmp = *x;
Expand All @@ -220,6 +223,7 @@ type-specifier * qualifiers declarator
```

调用部分如下:

```c
int a = 1, b = 2;
swap(&a, &b);
Expand Down Expand Up @@ -298,7 +302,7 @@ C 标准规定,表达式 `E1[E2]` 等价于 `(*((E1) + (E2)))`。这意味着
<!-- prettier-ignore-start -->
???+ example "案例:通过指针访问多维数组元素"

根据 C 标准规定,表达式 `a[i][j]` 与表达式 `*(*(a + i) + j)` 等价。不过,后者的求值过程相比前者更加抽象。下面,我们将以此为例逐步分析其求值过程。
根据 C 标准规定,不难推断出表达式 `a[i][j]` 与表达式 `*(*(a + i) + j)` 等价。不过,后者的求值过程相比前者更加抽象。下面,我们将以此为例逐步分析其求值过程。

1. 假设 `a` 的类型为 `int [2][3]`。我们知道,此处数组 `a` 会被隐式转换为 `int (*)[3]` 类型的指针。回忆一下指针与整数的加减法运算,`p + i` 并非简单地将指针 `p` 的值增加 `i` 字节,而是增加 `i` 个 `int [3]` 类型对象的长度,即 `i * sizeof(int [3])` 字节。

Expand Down Expand Up @@ -328,30 +332,77 @@ C 标准规定,表达式 `E1[E2]` 等价于 `(*((E1) + (E2)))`。这意味着

#### 字符串

我们知道,C 语言中的字符串实际上是一个空字符(称为**中止字符**)结尾的字符数组。因此,上一小节的讨论同样适用于这一小节。下面,我们将专注于字符串特有的部分。
我们知道,C 语言中的字符串实际上是一个空字符(称为**中止字符**)结尾的字符数组。因此,上一小节的讨论同样适用于这一小节。

与数组初始化不同的是,我们可以使用字符串字面量完成对字符数组的初始化。举例来说,语句 `char str[] = "abc";` 声明了一个长度为 4 的字符数组 `str`,并将其元素的值分别设为 `'a'``'b'``'c'``'\0'`。其中,`'\0` 代表 ASCII 码值为 0 的字符,也即空字符。
由于字符串同时具有数组的属性,讨论与内存空间有关的概念与操作时,为避免混淆,我们将数组对象占用的字节数称为其*大小*,将实际组成字符串的字符数称为其*长度*。一个直观理解是,字符串的大小计入中止字符,而其长度不计入。

在 C 语言中,我们可以使用**字符串字面量**表示一个字符串。字符串字面量是由一对 `"` 包围的任意长度的字符序列,其中的字符可以是一般字符或转义字符,如 `"abc"``"x y\nz"``""`。字符串字面量的末尾隐式包含中止字符。举例来说,`""` 代表大小为 1 的字符数组,它的唯一元素为 `'\0'`

<!-- prettier-ignore-start -->
!!! warning "使用字符串字面量初始化"
???- info "含空字符的字符串字面量"

若字符串字面量的字符序列内包含空字符,则它表示含有多于一条字符串的数组。举例来说,对于字符串字面量 `"abc\0"`,它包含 `"abc"` 和 `""` 两个字符串。

数组与指针均可通过字符串字面量进行初始化,但是它们的语义不同。初始化数组时,字符串字面量的内容会被复制到数组中,之后可修改数组中存放的字符串;而初始化指针时,它会指向通字符串字面量创建的字符串常量,此时尝试通过指针修改字符串会导致程序错误
你可以通过 `sizeof` 运算符和 `strlen` 函数验证程序行为
<!-- prettier-ignore-end -->

由于字符串操作十分常见却又相对复杂,我们往往将这些操作封装为函数,以便重复使用。作为函数参数传递时,数组会退化为指针,长度信息将会丢失。可以想到,字符串末尾的空字符正是为这种情形设计的。
字符串字面量的类型为 `char [N]`,其中 `N` 是字符串的大小,包括隐式的中止字符。显然,字符串字面量也遵守数组到指针的隐式转换规则。

<!-- prettier-ignore-start -->
!!! warning "尝试修改字符串字面量"

虽然字符串字面量的类型没有指定为 `const`,但它实际上也是常量。**尝试修改字符串字面量是未定义行为。**一般而言,字符串字面量的内容会被放入只读内存中,因此尝试修改会导致程序错误。
<!-- prettier-ignore-end -->

<!-- prettier-ignore-start -->
???+ example "字符串字面量的未退化情形"

考虑如下程序片段:

```c
char *str = "abc";
printf("%llu %llu\n", sizeof("abc"), sizeof(str));
```

它将会输出 `4 8`。这是因为,此时字符串字面量 `"abc"` 被作为 `sizeof` 的操作数,不会退化为指针,因此表达式 `sizeof("abc")` 的结果是字符串数组的大小。
<!-- prettier-ignore-end -->

不难想到,我们可以使用字符串字面量初始化数组,其效果相当于使用对应的字符数组常量初始化。与一般的数组初始化类似,数组长度可由初始化器隐式指定。举例来说,语句 `char str[] = "abc";` 等价于语句 `char str[] = {'a', 'b', 'c', '\0'};`,它声明并初始化了一个长度为 4 的字符数组 `str`

数组的大小可以比字符串字面量的大小少 1,此时**中止字符将被忽略**。举例来说,语句 `char str[3] = "abc";` 等价于语句 `char str[3] = {'a', 'b', 'c'};`

<!-- prettier-ignore-start -->
???+ example "案例:错误的字符数组初始化"

考虑如下程序片段:

```c
char str[3] = "abc";
char backdoor[] = "xyz";
printf("%s", str);
```

它将会输出 `abcxyz`。这是因为,字符串字面量 `"abc"` 的大小 `str` 的大小大 1,初始化时中止字符被忽略,`str` 最终被初始化为 `{'a', 'b', 'c'}`。将其视为字符串并输出时,由于 `str` 内不含中止字符,输出函数将会继续访问 `str` 后的内存地址,即出现数组访问越界问题。

由于 C 标准并未对字面量的内存排布作任何规定,在不同环境下字符串常量 `backdoor` 的内存地址可能位于 `str` 的内存地址之前。你可以尝试改变声明顺序以达到类似的输出效果。
<!-- prettier-ignore-end -->

根据隐式转换规则,在多数表达式中,字符串字面量将会隐式转换为指向它的指针。也就是说,该指针的值是字符串字面量的内存地址。类似地,被用于对指针的初始化时,字符串字面量也会退化为指针。

在函数内修改字符串时,传入的字符数组将会退化为指针,数组的大小信息因此丢失。可以想到,字符串末尾的空字符正是为这种情形设计的。

<!-- prettier-ignore-start -->
!!! tip "简化字符串操作函数"

关于利用指针编写字符串操作函数的技巧,你可以参考《C 程序设计语言》(也就是程设课程使用的课本)中给出的 `strcpy``strcmp` 函数的实现迭代。本文对此不再赘述。
<!-- prettier-ignore-end -->

C 语言标准库中提供了一系列字符串操作函数,它们大多被定义在 `string.h` 头文件中。
C 语言标准库中提供了一系列字符串操作函数,它们大多被定义在 `string.h` 头文件中,使用时均需保证提供的字符串格式符合约定

<!-- prettier-ignore-start -->
!!! warning "中止字符缺失"

标准库中的多数字符串操作函数通过检查中止字符判断是否到达字符串末尾。因此,在修改字符串时务必注意保留中止字符。**如果中止字符缺失,使用这些函数将会导致数组访问越界问题。**
标准库中的字符串操作函数通过检查中止字符判断是否到达字符串末尾。因此,在修改字符串时务必注意保留中止字符。**如果中止字符缺失,使用这些函数将会导致数组访问越界问题。**
<!-- prettier-ignore-end -->

### 函数指针
Expand Down Expand Up @@ -410,7 +461,7 @@ C 语言标准库中提供了一系列字符串操作函数,它们大多被定

释放申请得到的内存空间后,指向该内存空间的指针将会变成**悬垂指针**。尝试继续使用悬垂指针是未定义行为,**可能会导致程序崩溃。**

另外,尝试释放悬垂指针,即**二次释放**同一块内存地址,同样是未定义行为。**二次释放可能导致程序崩溃**。
另外,尝试释放悬垂指针,即**二次释放**同一块内存地址,同样是未定义行为。**二次释放可能导致程序崩溃。**
<!-- prettier-ignore-end -->

## 进阶技巧
Expand All @@ -421,10 +472,50 @@ C 语言标准库中提供了一系列字符串操作函数,它们大多被定
本节为拓展内容,不在程算课程要求范围内。如果你能够熟练使用指针,可以尝试阅读并理解以下内容。
<!-- prettier-ignore-end -->

<!-- prettier-ignore-start -->
???+ example "输出一系列空格分隔的数字,末尾无多余空格"

这种情形常见于 OJ 的输出要求中。

对于一个长度为 n 的数组 `a`,一个典型的实现程序如下。

```c
printf("%d", a[0]);
for (int i = 1; i < n; i++)
{
printf(" %d", a[i]);
}
```

不难看出,我们可以通过添加一次判断,将循环外的输出语句放入循环内。

```c
for (int i = 0; i < n; i++)
{
printf((!i) ? "%d" : " %d", a[i]);
}
```

这个版本的程序看起来更加整洁。同时,通过将表达式 `(!i)` 替换为其他标志,该程序可在需要时另起一行,并以相同格式输出剩余结果。这意味着输出的灵活性大大增强。

不过,我们还可以给出更加炫技的写法。

```c
for (int i = 0; i < n; i++)
{
printf(" %d" + !i, a[i]);
}
```

这个版本的程序通过指针运算,改变了字符串的起始位置,从而改变了输出格式。具体而言,`i` 为 0 时,`printf` 实际接收的格式字符串为 `"%d"`,即不在行首输出空格。

相比前一版本,这一版本只有减少字符串字面量数量这一意义不大的提升。(但用来炫技再好不过了 :P)
<!-- prettier-ignore-end -->

<!-- prettier-ignore-start -->
???+ example "输出浮点数的二进制表示"

一个典型的输出整数二进制表示的程序如下
一个典型的输出整数二进制表示的程序如下

```c
int a = 1;
Expand All @@ -449,7 +540,9 @@ C 语言标准库中提供了一系列字符串操作函数,它们大多被定
<!-- prettier-ignore-start -->
???+ example "使用二级指针删除链表节点"

此处仅讨论没有头节点的单向无环链表。链表节点定义如下。
此处仅讨论没有头节点的单向无环链表。

链表节点定义如下:

```c
typedef struct node
Expand All @@ -459,7 +552,7 @@ C 语言标准库中提供了一系列字符串操作函数,它们大多被定
} Node;
```

一个典型的删除链表节点的程序如下
一个典型的删除链表节点的程序如下

```c
Node *remove(Node *head, int val)
Expand All @@ -483,7 +576,7 @@ C 语言标准库中提供了一系列字符串操作函数,它们大多被定
}
```

但是,在 Linus 看来,这是不懂指针的人的做法。他推崇的做法是使用二级指针,避免对移除第一个节点的特判。一个典型程序如下。
但是,在 Linus 看来,这是不懂指针的人的做法。他推崇的做法是使用二级指针,避免对移除第一个节点的特判。

```c
void remove(Node *head, int val)
Expand Down

0 comments on commit 9ee2493

Please sign in to comment.