猪脚说第一期

猪脚说第一期

Diandian funny boy

什么是猪脚说

为了改善同学们的上机体验,减轻同学们的压力,集中回答编程中常见问题,继承上一辈助教的优良传统,我们为大家精心准备了猪脚说

猪脚说,就是猪脚助教们想对大家说的话。每次上机后,我们会及时总结大家提问相对较多比较重要的问题,在猪脚说中以详细的篇幅加以阐述,希望同学们或多或少得到一些启发。

猪脚说包括但不限于共性问题, coding 小技巧, 课外习题

重要知识点

指针详详详解

指针与地址

考虑如下代码,发生了什么?

1
2
int a = 10;
int *p = &a;

要回答这个问题,我们要从最开始的地方说起。

你好,世界!
1
printf("Hello World!\n");

我们知道,数据总是存储在计算机里的。我们要打印的字符串,应该存在哪里呢?为了便于说明这个问题,我们把计算机内部存储数据的地方想象成一个大柜子,柜子有一个一个的抽屉;每个抽屉的容量是有限的,只能放得下一个字符,也就是一个char的内容。这里为了避免引入“字节”的概念,给出如下的大小关系:4个char的大小 = 1个int的大小8个char的大小 = 2个int的大小 = 1个double的大小 = 1个long long的大小。

上述语句中的字符串,用双引号括起来,称为“字符串字面值常量”。作为字符串,它由若干字符拼接而成,后来的故事我们都知道了,在它的最后还有一个看不见的'\0'作为结束的标志;作为常量,这类字符串的内容不能被修改。

这样的字符串常量,储存在大柜子里的一块特定区域,称为常量区

截屏2023-02-24 15.50.21
变量
1
2
char c = 'c';
int i = 10;

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

截屏2023-02-24 16.12.35

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

后来我们知道了,这个编号,就是指针。指针,就是地址。

回答一下前面的问题
截屏2023-02-24 16.22.14
  • 首先定义一个普通变量a,被装在 4 个抽屉里,抽屉的起始编号是 21,抽屉的内容是 10。
  • 然后定义了一个指针变量pp也需要 4 个抽屉存放(因为p的本质也是一个整数!!!)p也有自己的编号 44,p的内容是存放a的抽屉的起始编号,即 21。
  • p只存放了a的起始地址,p怎么知道a从 21 开始占了多少个抽屉呢?这由定义pint *中的int决定。换言之,
    • char *p表示p中存放一个整数,这个整数是一个地址,从那个地址开始的 1 个抽屉的内容是一个char变量,因为char只需要 1 个抽屉。
    • int *p表示p中存放一个整数,这个整数是一个地址,从那个地址开始的 4 个抽屉的内容是一个int变量,因为int需要 4 个抽屉。
    • double *p表示p中存放了一个整数,这个整数是一个地址,从那个地址开始的 8 个抽屉的内容是一个double变量,因为double需要 8 个抽屉。
    • ……
指针的使用

有了指针,在我们的程序里,要访问一个变量就有两种方法了。一方面,可以通过抽屉的标签,也就是变量名访问;另一方面,可以通过抽屉的编号 —— 指针,间接地访问。后者自然要加上**指针运算符(解引用)***。

显然,在多数情况下,偏要用指针间接访问一个变量是毫无意义的。但在某些情况下,我们只能通过指针访问。考察下面的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
void pass_by_value(int i)
{
i = 10;
}
void pass_by_pointer(int *p)
{
*p = 10;
}
int main()
{
int x = 0, y = 0;
pass_by_value(x);
pass_by_pointer(&y);
printf("x = %d, y = %d\n", x, y);
return 0;
}

稍有经验的同学不难看出,x的值不会被修改,y的值会被修改。为什么呢?

C 程序的函数参数都是值传递的。这句话的意思是,一个函数的运行,自然会涉及一些变量,其中的一部分是函数参数,另一部分是函数内部定义的变量;在函数运行期间,函数需要借用一些抽屉来存放这些变量的值。对于pass_by_value(x);中的x,函数只会把**x的值放在自己借用的抽屉里,而不会意识到x是某处的一个标签;对于pass_by_pointer(&y);中的&y,函数只会把&y的值** —— 这个值是一个普通整数,并且是一个地址 —— 放在自己借用的抽屉里。两者的不同之处在于,前者真的只是传了一个普通整数;而后者传入的整数同时也是地址,我们在函数内部确实访问了这个地址的内容,从而真的修改了y的值。

截屏2023-02-24 16.56.33

数组

为了绘图简便,我们考察short型的数组。一个short变量占两个抽屉。此处我们仅考虑数组和指针的关系,数组的定义、初始化、元素访问等不再赘述。

我们会说,数组名就是指针,这句话的意思是

  • 只要知道了数组的首地址,就可以访问数组的每个元素。假设p存放着数组的首地址,下标从 0 开始,我们要访问下标为index的元素,一种写法是p[index] —— 相当于从数组首元素往后数index个元素,然后访问那个元素 —— 等价于*(p + index),即将p偏移,从而使之指向欲访问的元素,然后解引用。
  • 系统手里有一张表,叫做符号表。数组名是符号表中的一项,它是一个不可修改的常量,指代数组的首地址。
截屏2023-02-24 17.30.56

当数组作为函数参数传递的时候,统一当成指针处理,所以以下三种函数声明等价:

1
2
3
void func(int *arr);
void func(int arr[]);
void func(int arr[999]); // 并不关心数组多大
  • 后面两种声明会被翻译成第一种声明,也就是指针的形式。
    • 数组可以通过首地址访问,所以传入首地址是可行的。
    • 通过指针,在main函数里定义的数组,可以在func中被修改,这与普通变量的值传递不同。
  • 前面说到,函数参数需要借一些抽屉临时存放。而函数能借到的抽屉是有限的,如果真的把一个长度为 999 的数组传入,则需要 999 × 4 个抽屉,这不太现实。只传入指针,则只需要 4 个抽屉即可 —— 通过指针间接访问数组。
  • 此外,上一点也提醒我们,函数内部并不知道数组有多大,它只知道数组的首地址。所以对数组操作的函数,一般需要再加上一个size参数,保证函数中不会出现数组越界的情况。

字符串

在最开始的地方谈到,用双引号扩起来的字符串常量,被存放在大柜子的一块特定区域,即常量区。事实上,不仅是我们想要输出的文本信息,C 程序中任何地方出现的字符串常量,都会被存在那里。

1
2
3
scanf("%d %d", &a, &b);
char *s = "hello world";
printf("%s\n", s);

这里的"%d %d" "hello world" "%s\n"都是字符串常量,都会被预先存在常量区。另一方面,这种字符串常量的最后都默认有一个看不见的'\0'作为结束的标志,这是系统自动加上的。

我们想象这样的画面,每个抽屉只能装一个字符,只要我们知道了字符串的第一个字符装在哪个抽屉,然后依次往后拉开抽屉,直到拉开了存放'\0'的那个抽屉为止,我们就获得了字符串的所有内容。于是,字符串的首地址就成为了确定一个字符串唯一所需要的信息。char *s = "hello world";的那个指针s,做的就是这件事。

截屏2023-02-25 11.57.03

另一方面,字符串也可以存在我们自定义的数组里,但是其初始化值得考察。假设我们要存入的是"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
    截屏2023-02-25 12.02.53
  • 有一种便捷手段,在初始化的时候,用字符串字面值常量为字符数组赋初值

    1
    2
    3
    4
    5
    6
    char s[10] = "abc"; // 开大点总是保险
    char s[] = "abc"; // 这么做默认 s 大小为 4

    // 严禁这么做!
    char s[10]; // 定义了一个数组,数组名是符号表中的常量
    s = "abc"; // 给一个符号常量赋值,是绝对不行的
    截屏2023-02-25 12.08.20

说在最后

指针是工具,是用来使用的。

对指针的本质进行解析,为的是让大家理解其使用方式。使用指针,需要的是在脑海中形成意识“我们就是这么做的”“这么做是合理的”。对于指针的基本理解包括但不限于以下几点

  • 指针是个变量,指针是个整数
  • 取变量的地址赋值给指针,我们就说指针指向了那个变量。
  • 指针“指向”,只是说指针中存了一个整数地址;要访问变量,需要一次解引用
  • 数组名是一个符号,等价于数组首地址。
  • 双引号扩起来的字符串是常量,只读不写。

我们需要培养一些基本的意识,要知道“我可以写什么,不可以写什么”。

1
2
3
4
int arr[] = {1, 2, 3, 4};
int *p = arr; // arr符号代表首地址,赋值给指针,当然可以
p[1] = 5; // 指针也可以像数组一样访问
*(p + 2) = 2; // 指针自己也具备了“偏移 + 解引用”操作
1
2
3
4
char *s  = "abc"; // 这是指针指向字符串常量
char t[] = "abc"; // 这是一个普通数组,并使用了便捷方式初始化
s[1] = 'x'; // 字符串常量不能修改!
t[1] = 'y'; // 数组当然可以修改

最后补充的是NULLconst指针。

NULL

指针是一个整型变量,它的取值无非有这么几种

  • 只定义但未初始化,是一个随机值。
  • 进行初始化或赋值,“指向了其他的变量”。
  • 初始化为 0。(编号为 0 的那个抽屉存了啥?)

NULL是一个宏,代表整数 0,用于指针的初始化:int *p = NULL; 当然也可以写 int *p = 0;

当一个指针未初始化时,它可能指向任何地方,但是那里究竟能不能访问是未知的,这就是野指针。在有些情况下,访问了不该访问的地方,可能导致系统崩溃。人们规定,编号为 0 的那个抽屉是一个无效的抽屉,一旦访问,程序运行就强制结束了(总比系统崩溃好)。所以在将指针指向某个变量之前,初始化为 0 或NULL,是有必要的。

const指针

我们知道字符串常量存在常量区,但其他常量,如const int,还是和普通变量放在一起的。

1
2
const int a = 10;
a = 100;

当编译器看到这两行代码,它会说:“a被定义为常量,你却要为a赋其他值,不可以!”于是报错。

1
2
3
const int a = 10;
int *p = &a;
*p = 100;

当编译器看到这两行代码,它会说:“a是常量,存在内存里了。p想要指向它,当然可以。对指针p解引用进行赋值,当然可以。”于是真的,一个const int的值通过指针被修改了。

所以我们有必要避免这种情况,手段就是“指向常量的指针”。前文说到,指针是统一的一种类型,就是整型。定义指针时前面的类型,只是告诉系统,“连续打开几个抽屉”。打开抽屉后,无非有两种操作:看一下里面是什么(操作)和修改一下内容(操作)。对于后者,如果抽屉里装的是常量,则应该避免。

在定义指针的最前面加上const修饰,如const int *p = &a;,就定义了指向常量的指针。这么做的好处是,p说:“我是指针,我指向a,但你无法通过我修改a的值,你有没有其他手段修改a的值,与我无关。

1
2
3
4
5
6
7
8
9
const int a = 10;
const int *p = &a; // 这是正确的,定义了指向常量的指针
*p = 100; // 通过 p 无法修改 a

int b = 10;
const int *q = &b; // 指向常量的指针指向了普通变量
int *r = &b; // 普通的指针指向了普通变量
*q = 100; // 无法通过 q 修改 b 了,哪怕 b 是一个普通变量
*r = 100; // 这么改当然可以

于是我们会在大量字符串处理函数的原型中,看到参数都定义为const char *p类型,这就是说,字符串通过p传入函数,保证在函数内部,不会修改字符串的内容。这么做是严谨的。

字符串

库函数功能介绍

size_t 为无符号整数类型,它是 sizeof 关键字的结果。

下列常用字符串处理函数均定义在头文件 <string.h> 中:

1
2
void *memchr(const void *str, int c, size_t n);
// 在参数 str 所指向的字符串的前 n 个字节中搜索第一次出现字符 c(一个无符号字符)的位置。
1
2
int memcmp(const void *str1, const void *str2, size_t n);
// 把 str1 和 str2 的前 n 个字节进行比较。
1
2
void *memcpy(void *dest, const void *src, size_t n);
// 从 src 复制 n 个字符到 dest。
1
2
3
4
5
6
7
8
void *memset(void *str, int c, size_t n);
/*
复制字符 c (一个无符号字符)到参数 str 所指向的字符串的前 n 个字符。
e.g.想要将一个 int 类型数组 a[50] 全部置为0:
memset(a, 0, sizeof(a));
等效于 for (i=0; i < 50; i++) a[i] = 0;
【特别注意】一般此函数仅用于全部归零,其他值不能随便设置!!
*/
1
2
3
char *strcat(char *dest, const char *src);
// 把 src 所指向的字符串(包括'\0')追加到 dest 所指向的字符串的结尾(删除 dest 原来末尾的'\0')。
// src 和 dest 所指内存区域不可以重叠且 dest 必须有足够的空间来容纳 src 的字符串。
1
2
3
char *strncat(char *dest, const char *src, size_t n);
// 把 src 所指向的字符串的前 n 个字符追加到 dest 所指向的字符串的结尾(删除 dest 原来末尾的'\0')。
// src 和 dest 所指内存区域不可以重叠且 dest 必须有足够的空间来容纳 src 的字符串。
1
2
3
4
5
6
7
char *strchr(const char *str, int c);
/*
在参数 str 所指向的字符串中搜索第一次出现字符 c(一个无符号字符)的位置。
返回值为该字符串中第一次出现的字符的指针,若不包含该字符则返回 NULL 空指针。
char *strrchr(const char *str, int c);
在参数 str 所指向的字符串中搜索最后一次出现字符 c(一个无符号字符)的位置。
*/
1
2
3
4
5
int strcmp(const char *str1, const char *str2);
/*
把 str1 所指向的字符串和 str2 所指向的字符串进行比较并返回整数。若两字符串相等,则返回零。
若 str1 < str2, 则返回负数; 若 str1 > str2, 则返回正数。
*/
1
2
3
4
5
char *strcpy(char *dest, const char *src);
// 把含有'\0'结束符的字符串 src 复制到以 dest 开始的地址空间。
// src 和 dest 所指内存区域不可以重叠且 dest 必须有足够的空间来容纳 src 字符串。
char *strncpy(char *dest, const char *src, size_t n);
// 把字符串 src 的前 n 个字符复制到以 dest 开始的地址空间。
1
2
size_t strlen(const char *str);
// 计算字符串 str 的长度,知道空结束字符但不包括空结束字符。返回值数据类型为无符号整型。
1
2
char *strstr(const char *dest, const char *src);
// 在字符串 dest 中查找第一次出现字符串 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
2
scanf("%s", str);
// 该函数更多被用来读取单词,而非整行字符串。它从一个非空白字符开始,读到下一个空白字符为止。
1
2
3
4
char buf[BUFSIZ] = {0};
fgets(buf, BUFSIZ, stdin);
// 该函数的第二个参数代表规定从标准输入读取字符上限的数量,这也是它优于 gets() 函数的地方。
// 我们更推荐大家使用此种方法来读取整行字符串。
  • BUFSIZ是宏定义在头文件里的常数,一般值为 512,对于大家完成上机作业已经够用了。

  • stdin为标准输入,也就是键盘输入。在之后的文件输入输出时,可以修改此参数为文件指针。

注意

fgets() 函数会读取 '\n' 并写进数组中, 因此使用strlen() 函数求取数组长度时, 得到的长度比实际可见字符数多 1,其中包含了最后一个换行符。

charint的转换

先来看第一次作业填空题第四题:

1
2
3
4
5
6
7
8
void invert(char str[]) {
int i, j, k;
for (i=0, j = strlen(str)-1; i < j; i++, j--) {
k = str[i];
str[i] = str[j];
str[j] = k;
}
}

不少同学都来提问: k不是int类型的变量吗,怎么能够和一个字符进行相应的运算关系?

需要指出的是:某个字符和它由 ASCII 码表所对应的整型值是等价的。即如果用整型值 48 赋值给某个字符,则其输出结果会是 ‘0’; 如果用字符常量 ‘0’ 赋值给某个整型变量,则其输出后为 48。

char型实际上就是 0 到 127 的整型数经过 ASCII 码表映射的结果,其与int型的转换需要代入映射后得到对应值。

如果还有同学有疑问或者想要了解更多例子,不妨看下面一些代码:

1
2
if (str[i] >= 'A' && str[i] <= 'Z') 
// do something

上述代码为最简单的两个字符比较大小,其本质上是以相应的ASCII码表值的大小作为字符比较的标准。

1
2
3
4
5
6
7
8
void exchange(char str[]){
int i;
for(i = 0; str[i] != '\0'; i++) {
if(str[i] >= 'A' && str[i] <= 'Z') {
str[i] += 32;
}
}
}

这段代码可以实现把一段字符串中的大写字母全部转化为小写字母,其中倒数第三行就是字符与整型量的运算。

第一次作业补充练习

此链接 可以收藏起来,若有需求可以去做一些练习。

1、 处理字符串,主要考察大家对常用字符串处理库函数的运用。

2、 单词统计,注意题干要求处理大小写字母。

3、 改良计算器,为第一次上机第二道编程的拓展。

4、 对于小数点后位数的处理。

5、高精度开根,第一次上机第四道编程的拓展。

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.
Comments