猪脚说第一期
什么是猪脚说
为了改善同学们的上机体验,减轻同学们的压力,集中回答编程中常见问题,继承上一辈助教的优良传统,我们为大家精心准备了猪脚说。
猪脚说,就是猪脚助教们想对大家说的话。每次上机后,我们会及时总结大家提问相对较多或比较重要的问题,在猪脚说中以详细的篇幅加以阐述,希望同学们或多或少得到一些启发。
猪脚说包括但不限于共性问题, 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
4char 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
6char 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
后输出。(不用担心数据范围,写代码实现此功能即可)。样例输入
1
2
3
4 3
abc123
BUAA goodddd 123 Good
godgoodgodgoooood goo? gooD good!样例输出
1
2
3 abc123
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.