
猪脚说第一期
什么是猪脚说
为了改善同学们的上机体验,减轻同学们的压力,集中回答编程中常见问题,继承上一辈助教的优良传统,我们为大家精心准备了猪脚说。
猪脚说,就是猪脚助教们想对大家说的话。每次上机后,我们会及时总结大家提问相对较多或比较重要的问题,在猪脚说中以详细的篇幅加以阐述,希望同学们或多或少得到一些启发。
猪脚说包括但不限于共性问题, coding 小技巧, 课外习题。
重要知识点
指针详详详解
指针与地址
考虑如下代码,发生了什么?
| 1 | int a = 10; | 
要回答这个问题,我们要从最开始的地方说起。
你好,世界!
| 1 | printf("Hello World!\n"); | 
我们知道,数据总是存储在计算机里的。我们要打印的字符串,应该存在哪里呢?为了便于说明这个问题,我们把计算机内部存储数据的地方想象成一个大柜子,柜子有一个一个的抽屉;每个抽屉的容量是有限的,只能放得下一个字符,也就是一个char的内容。这里为了避免引入“字节”的概念,给出如下的大小关系:4个char的大小 = 1个int的大小,8个char的大小 = 2个int的大小 = 1个double的大小 = 1个long long的大小。
上述语句中的字符串,用双引号括起来,称为“字符串字面值常量”。作为字符串,它由若干字符拼接而成,后来的故事我们都知道了,在它的最后还有一个看不见的'\0'作为结束的标志;作为常量,这类字符串的内容不能被修改。
这样的字符串常量,储存在大柜子里的一块特定区域,称为常量区。

变量
| 1 | char c = 'c'; | 
后来我们学了变量,它们当然也装在这个大柜子里。如果这只是个有很多抽屉的柜子,那么数据的存取将变得异常困难,一个很显然的做法是,为每个抽屉编号。但另一个问题又来了,计算机自然可以通过编号访问数据;但作为编程者,我们并不知道每个变量存在哪个编号的抽屉里。于是另一个很显然的做法是,我们可以为存有变量的抽屉贴上标签,这就是标识符,例如c和i。

特别要注意的是,int变量i占用了 4 个抽屉。有了这个模型,我们就能知道int i = 10;中,i表示的是存放了数字 10 的那 4 个抽屉的标签;它的编号是 21;从编号 21 开始之所以放了 4 个抽屉,是类型int决定的。
后来我们知道了,这个编号,就是指针。指针,就是地址。
回答一下前面的问题

- 首先定义一个普通变量a,被装在 4 个抽屉里,抽屉的起始编号是 21,抽屉的内容是 10。
- 然后定义了一个指针变量p,p也需要 4 个抽屉存放(因为p的本质也是一个整数!!!),p也有自己的编号 44,p的内容是存放a的抽屉的起始编号,即 21。
- p只存放了- a的起始地址,- p怎么知道- a从 21 开始占了多少个抽屉呢?这由定义- p的- int *中的- int决定。换言之,- char *p表示- p中存放一个整数,这个整数是一个地址,从那个地址开始的 1 个抽屉的内容是一个- char变量,因为- char只需要 1 个抽屉。
- int *p表示- p中存放一个整数,这个整数是一个地址,从那个地址开始的 4 个抽屉的内容是一个- int变量,因为- int需要 4 个抽屉。
- double *p表示- p中存放了一个整数,这个整数是一个地址,从那个地址开始的 8 个抽屉的内容是一个- double变量,因为- double需要 8 个抽屉。
- ……
 
指针的使用
有了指针,在我们的程序里,要访问一个变量就有两种方法了。一方面,可以通过抽屉的标签,也就是变量名访问;另一方面,可以通过抽屉的编号 —— 指针,间接地访问。后者自然要加上**指针运算符(解引用)***。
显然,在多数情况下,偏要用指针间接访问一个变量是毫无意义的。但在某些情况下,我们只能通过指针访问。考察下面的程序
| 1 | 
 | 
稍有经验的同学不难看出,x的值不会被修改,y的值会被修改。为什么呢?
C 程序的函数参数都是值传递的。这句话的意思是,一个函数的运行,自然会涉及一些变量,其中的一部分是函数参数,另一部分是函数内部定义的变量;在函数运行期间,函数需要借用一些抽屉来存放这些变量的值。对于pass_by_value(x);中的x,函数只会把**x的值放在自己借用的抽屉里,而不会意识到x是某处的一个标签;对于pass_by_pointer(&y);中的&y,函数只会把&y的值** —— 这个值是一个普通整数,并且是一个地址 —— 放在自己借用的抽屉里。两者的不同之处在于,前者真的只是传了一个普通整数;而后者传入的整数同时也是地址,我们在函数内部确实访问了这个地址的内容,从而真的修改了y的值。

数组
为了绘图简便,我们考察
short型的数组。一个short变量占两个抽屉。此处我们仅考虑数组和指针的关系,数组的定义、初始化、元素访问等不再赘述。
我们会说,数组名就是指针,这句话的意思是
- 只要知道了数组的首地址,就可以访问数组的每个元素。假设p存放着数组的首地址,下标从 0 开始,我们要访问下标为index的元素,一种写法是p[index]—— 相当于从数组首元素往后数index个元素,然后访问那个元素 —— 等价于*(p + index),即将p偏移,从而使之指向欲访问的元素,然后解引用。
- 系统手里有一张表,叫做符号表。数组名是符号表中的一项,它是一个不可修改的常量,指代数组的首地址。

当数组作为函数参数传递的时候,统一当成指针处理,所以以下三种函数声明等价:
| 1 | void func(int *arr); | 
- 后面两种声明会被翻译成第一种声明,也就是指针的形式。- 数组可以通过首地址访问,所以传入首地址是可行的。
- 通过指针,在main函数里定义的数组,可以在func中被修改,这与普通变量的值传递不同。
 
- 前面说到,函数参数需要借一些抽屉临时存放。而函数能借到的抽屉是有限的,如果真的把一个长度为 999 的数组传入,则需要 999 × 4 个抽屉,这不太现实。只传入指针,则只需要 4 个抽屉即可 —— 通过指针间接访问数组。
- 此外,上一点也提醒我们,函数内部并不知道数组有多大,它只知道数组的首地址。所以对数组操作的函数,一般需要再加上一个size参数,保证函数中不会出现数组越界的情况。
字符串
在最开始的地方谈到,用双引号扩起来的字符串常量,被存放在大柜子的一块特定区域,即常量区。事实上,不仅是我们想要输出的文本信息,C 程序中任何地方出现的字符串常量,都会被存在那里。
| 1 | scanf("%d %d", &a, &b); | 
这里的"%d %d" "hello world" "%s\n"都是字符串常量,都会被预先存在常量区。另一方面,这种字符串常量的最后都默认有一个看不见的'\0'作为结束的标志,这是系统自动加上的。
我们想象这样的画面,每个抽屉只能装一个字符,只要我们知道了字符串的第一个字符装在哪个抽屉,然后依次往后拉开抽屉,直到拉开了存放'\0'的那个抽屉为止,我们就获得了字符串的所有内容。于是,字符串的首地址就成为了确定一个字符串唯一所需要的信息。char *s = "hello world";的那个指针s,做的就是这件事。

另一方面,字符串也可以存在我们自定义的数组里,但是其初始化值得考察。假设我们要存入的是"abc"。
- 数组大小应该开够,因为需要有 - '\0'作为结束标志- 1 
 2
 3
 4- char s[10] = {'a', 'b', 'c', '\0'}; // 开得足够大,并且别忘了 \0 
 char s[] = {'a', 'b', 'c', '\0'}; // 不写大小,由系统自动判断,此处 s 大小即为 4
 char s[4]; s[0] = 'a'; s[1] = 'b'; s[2] = 'c' = s[3] = '\0'; // 更加麻烦的逐一赋值,但 OK 
- 有一种便捷手段,在初始化的时候,用字符串字面值常量为字符数组赋初值 - 1 
 2
 3
 4
 5
 6- char s[10] = "abc"; // 开大点总是保险 
 char s[] = "abc"; // 这么做默认 s 大小为 4
 // 严禁这么做!
 char s[10]; // 定义了一个数组,数组名是符号表中的常量
 s = "abc"; // 给一个符号常量赋值,是绝对不行的 
说在最后
指针是工具,是用来使用的。
对指针的本质进行解析,为的是让大家理解其使用方式。使用指针,需要的是在脑海中形成意识“我们就是这么做的”“这么做是合理的”。对于指针的基本理解包括但不限于以下几点
- 指针是个变量,指针是个整数。
- 取变量的地址赋值给指针,我们就说指针指向了那个变量。
- 指针“指向”,只是说指针中存了一个整数地址;要访问变量,需要一次解引用。
- 数组名是一个符号,等价于数组首地址。
- 双引号扩起来的字符串是常量,只读不写。
我们需要培养一些基本的意识,要知道“我可以写什么,不可以写什么”。
| 1 | int arr[] = {1, 2, 3, 4}; | 
| 1 | char *s = "abc"; // 这是指针指向字符串常量 | 
最后补充的是NULL和const指针。
NULL
指针是一个整型变量,它的取值无非有这么几种
- 只定义但未初始化,是一个随机值。
- 进行初始化或赋值,“指向了其他的变量”。
- 初始化为 0。(编号为 0 的那个抽屉存了啥?)
NULL是一个宏,代表整数 0,用于指针的初始化:int *p = NULL; 当然也可以写 int *p = 0;。
当一个指针未初始化时,它可能指向任何地方,但是那里究竟能不能访问是未知的,这就是野指针。在有些情况下,访问了不该访问的地方,可能导致系统崩溃。人们规定,编号为 0 的那个抽屉是一个无效的抽屉,一旦访问,程序运行就强制结束了(总比系统崩溃好)。所以在将指针指向某个变量之前,初始化为 0 或NULL,是有必要的。
const指针
我们知道字符串常量存在常量区,但其他常量,如const int,还是和普通变量放在一起的。
| 1 | const int a = 10; | 
当编译器看到这两行代码,它会说:“a被定义为常量,你却要为a赋其他值,不可以!”于是报错。
| 1 | const int a = 10; | 
当编译器看到这两行代码,它会说:“a是常量,存在内存里了。p想要指向它,当然可以。对指针p解引用进行赋值,当然可以。”于是真的,一个const int的值通过指针被修改了。
所以我们有必要避免这种情况,手段就是“指向常量的指针”。前文说到,指针是统一的一种类型,就是整型。定义指针时前面的类型,只是告诉系统,“连续打开几个抽屉”。打开抽屉后,无非有两种操作:看一下里面是什么(读操作)和修改一下内容(写操作)。对于后者,如果抽屉里装的是常量,则应该避免。
在定义指针的最前面加上const修饰,如const int *p = &a;,就定义了指向常量的指针。这么做的好处是,p说:“我是指针,我指向a,但你无法通过我修改a的值,你有没有其他手段修改a的值,与我无关。”
| 1 | const int a = 10; | 
于是我们会在大量字符串处理函数的原型中,看到参数都定义为const char *p类型,这就是说,字符串通过p传入函数,保证在函数内部,不会修改字符串的内容。这么做是严谨的。
字符串
库函数功能介绍
size_t 为无符号整数类型,它是 sizeof 关键字的结果。
下列常用字符串处理函数均定义在头文件 <string.h> 中:
| 1 | void *memchr(const void *str, int c, size_t n); | 
| 1 | int memcmp(const void *str1, const void *str2, size_t n); | 
| 1 | void *memcpy(void *dest, const void *src, size_t n); | 
| 1 | void *memset(void *str, int c, size_t n); | 
| 1 | char *strcat(char *dest, const char *src); | 
| 1 | char *strncat(char *dest, const char *src, size_t n); | 
| 1 | char *strchr(const char *str, int c); | 
| 1 | int strcmp(const char *str1, const char *str2); | 
| 1 | char *strcpy(char *dest, const char *src); | 
| 1 | size_t strlen(const char *str); | 
| 1 | char *strstr(const char *dest, const char *src); | 
补充练习:字符串处理函数与指针的使用。此题将在下周上机详细讲评。
输入 n 个字符串,将每个字符串中的
good子串全部替换为perfect后输出。(不用担心数据范围,写代码实现此功能即可)。样例输入
2
3
4
abc123
BUAA goodddd 123 Good
godgoodgodgoooood goo? gooD good!样例输出
2
3
BUAA perfectddd 123 Good
godperfectgodgoooood goo? gooD perfect!
读取字符串
很多同学在使用 gets() 函数读取字符串时,可能在 judge 平台的编译器上收到这样的提示:
Warning: the ‘gets’ function is dangerous and should not be used.
原因在于:**gets()函数不做地址越界检查!**若输入的字符串大于既定数组的长度,程序运行会出现难以预期的错误。有兴趣的同学可以从这篇文章中作详细了解 。
在此,我们建议使用如下两种方式读取字符串:
| 1 | scanf("%s", str); | 
| 1 | char buf[BUFSIZ] = {0}; | 
- BUFSIZ是宏定义在头文件里的常数,一般值为 512,对于大家完成上机作业已经够用了。
- stdin为标准输入,也就是键盘输入。在之后的文件输入输出时,可以修改此参数为文件指针。
注意
fgets()函数会读取'\n'并写进数组中, 因此使用strlen()函数求取数组长度时, 得到的长度比实际可见字符数多 1,其中包含了最后一个换行符。
char与int的转换
先来看第一次作业填空题第四题:
| 1 | void invert(char str[]) { | 
不少同学都来提问: k不是int类型的变量吗,怎么能够和一个字符进行相应的运算关系?
需要指出的是:某个字符和它由 ASCII 码表所对应的整型值是等价的。即如果用整型值 48 赋值给某个字符,则其输出结果会是 ‘0’; 如果用字符常量 ‘0’ 赋值给某个整型变量,则其输出后为 48。
char型实际上就是 0 到 127 的整型数经过 ASCII 码表映射的结果,其与int型的转换需要代入映射后得到对应值。
如果还有同学有疑问或者想要了解更多例子,不妨看下面一些代码:
| 1 | if (str[i] >= 'A' && str[i] <= 'Z') | 
上述代码为最简单的两个字符比较大小,其本质上是以相应的ASCII码表值的大小作为字符比较的标准。
| 1 | void exchange(char str[]){ | 
这段代码可以实现把一段字符串中的大写字母全部转化为小写字母,其中倒数第三行就是字符与整型量的运算。
第一次作业补充练习
此链接 可以收藏起来,若有需求可以去做一些练习。
1、 处理字符串,主要考察大家对常用字符串处理库函数的运用。
4、 对于小数点后位数的处理。
Author: diandian, Riccardo
- Title: 猪脚说第一期
- Author: Diandian
- Created at : 2023-07-14 16:00:47
- Updated at : 2023-07-14 17:20:57
- Link: https://cutedian.github.io/2023/07/14/猪脚说第一期/
- License: This work is licensed under CC BY-NC-SA 4.0.

