Skip to content

Lec 3 C语言的数组&字符串&结构体

数组与指针

指针是一种用于存放另外一个变量的地址的变量。 地址位宽为32bit,不同的数据类型有不同的字节大小,因此需要指定指针的类型,这样它就能知道如何读取 & 解释有多少字节。函数中常用来更改原始数据的值,函数默认是值拷贝。

指针的错误用法;

c
int *x = 0; // x 存储的内存地址为 0; 或者说x指向地址0; 或者x的值为0
*x = 5; // Segmentment fault ❌,地址 0 被保留用于表示空指针(null)并且不能被更改
c
int a = 1;
int *x; // 没有指向特别的地址
*x = a; // Segmentment fault ❌, x存储的地址不合法

C 里的数组(array)是一段连续的内存,长度固定,定义时需指定类型和长度。

声明数组会做两件事:为数组分配内存;创建一个指针,通过它访问这段内存。数组是一个固定指针,不能重新赋值。C 不做越界检查,用指针访问数组时可能读到内存中未知的部分,这是潜在的 bug 来源。

把数组传入函数时,函数实际看到的只是数组的首地址指针,无法从中得知数组长度,所以在函数内部没有内在办法知道数组有多大,长度通常需要额外作为参数传入。C 中所有函数参数都是按值传递(passed by value),传进去的是副本而非原件。

初始化方法:

  • int x[] = {1, 2, 3, 4}; 构成4个int长的长度数组,并取名为x
  • int y[10]; 创建一个10个int长度的数组,取名为y(但是没有声明其值)
  • int z[5] = {1}; 创建5个int长度的数组,取名为z;下标为0的元素是1,其余为0
  • char a[10] = {'t', 'h', 'e', ' ', 'c', 'a', 't', '.'}; 10个char长度的数组,结尾有两个null
  • char b[] = "the cat."; 9个长度为char的数组,结尾跟着null

指针与数组的关系十分密切。

c
int a[10];
int *pa;
pa = &a[0]; // 将pa指向a的第0个元素

*(pa+1)a[1]是等价的

sizeof 给出一个数据对象在内存中占用的字节数,它不是函数,而是一个运算符。在编译期就被替换成结果。因为它在编译期求值,所以当数组作为参数传入函数后,函数内对参数用 sizeof 得到的是指针的大小,而不是整个数组的大小。同样,没有任何内在方式能在运行期得知数组真实长度。

  1. 指针运算以元素为单位,而不是字节。
  2. 数组名就是首元素地址,因此 a&a[0] 等价。
  3. 指针加减整数时,编译器会根据所指类型自动进行地址缩放。
  4. 指针相减得到的是两个指针之间相隔的元素数量,而非字节数。
  5. 指针比较和相减仅在指向同一数组(或尾后位置)时才有定义。

指针与数组容易踩坑的点

  • 指针的大小取决于架构,不是数据类型。 指针存的是地址,在 32 位机器上占 4 字节,在 64 位机器上占 8 字节——和它指向 char 还是 double 无关。本讲开头说"地址位宽 32bit"是按 32 位机的约定;但例题 3 在 Apple M1(64 位)上 sizeof(指针) 得到的是 8,这正是同一段代码在不同位宽机器上结果不同的根源。写依赖指针大小的代码前一定要确认目标架构。

  • a&a 地址相同、类型不同。 数组名 a 退化成"指向首元素的指针",a + 1 跨过一个元素;而 &a 的类型是"指向整个数组的指针",&a + 1 跨过整个数组。两者打印出来的地址值一样,加 1 后却差了一整个数组的字节数——非常隐蔽。

  • 数组退化(array decay)会丢失长度信息。 数组一旦作为参数传入函数,就退化成裸指针,sizeof 只能得到指针大小(4 或 8),无法还原数组长度。所以长度必须额外作为参数传进去,没有任何运行期手段从指针反推数组有多大。

  • void * 不能做指针算术。 因为不知道元素大小、无法缩放(GCC 把它当作 1 字节是非标准扩展)。要算术先转成具体类型的指针。

  • 越界访问不会报错,但是 UB。 C 不做边界检查,越界读写可能"看起来正常",也可能悄悄破坏相邻变量,是最难复现的一类 bug。

例题1 下面程序会打印什么?
image-20260613093753037
例题2 下面程序会打印什么?

image-20260613094403367

例题3 下面程序会打印什么?
image-20260613094631775

从我的macbook m1 64位程序来说,得到72。 因为,sizeof(ai) 得到的是8,因此循环计算了8次。

如果是32位机器,下面打印如图

image-20260613095622406

函数指针

普通指针保存的是变量的地址, 而函数指针保存的是函数的指针。

c
int (*comp)(void *, void *);

很多初学者都会觉得难懂,其实一步一步读就行。

  1. 先找到变量名,外面有 (*comp),说明comp是一个指针
  2. 继续往右看,它指向的是一个函数,这个函数有两个参数。
  3. 最后最左边,它返回int

comp 是一个指向函数的指针,这个函数接受两个 void* 参数,返回 int。

c
(*pfa[])()
  1. 找到变量名
  2. []: 说明pfa是数组
  3. *: 说明数组里面的元素是指针
  4. 右边,()说明这些指针指向函数,

结论: pfa是一个向函数指针数组

字符串

字符串在 C 里是字符数组(char array),用一个常量指针表示,采用 ASCII 编码,以空字符 '\0'(null char)标记结尾。ASCII 用 8 位,共 256 种可能,实际只用了前 128 个。注意:'A'(单引号)是字符 65,"A"(双引号)是字符串。函数读取字符串时会一直读到遇见 '\0' 为止。

字符串函数

string.h的函数

  • strlen 返回字符串长度(不含 '\0',与 sizeof 不同)。

  • strcpy 把源串复制到目标串直到遇到 '\0',若目标空间小于源串会溢出(overflow)并破坏相邻内存;

    • strncpy 最多复制 n 个字符,控制更精确。
  • strcat 把源串拼接到目标串末尾(找到末尾\0开始写入),同样可能溢出;

    • strncat 最多拼接到第 n 个字符。
  • strcmp 比较两个字符串:相等返回 0;在第一个不同的字符处,若 s1 的字符 ASCII 大于 s2 则返回正数,小于则返回负数。

    • strncmp 最多比较 n 个字符。
  • strchr 查找某字符在串中第一次出现的位置,返回指向该处的指针,找不到返回 NULL。

    • strrchr 查找最后一次出现。前者找到就停,后者必须扫描完整串
  • sprintf 类似 printf,但把格式化结果写入一个字符串而非终端。返回值是本次写入的字符数

  • strstr:在字符串中查找子串。 strstr(haystack, needle) 在字符串 haystack 里查找子串 needle 第一次出现的位置,找到就返回指向该位置的指针,找不到返回 NULL。

    • strrchr:从后往前找字符,意味着找最后一次出现的位置
  • strtok: 把字符串按分隔符切成多段。它的用法比较特殊:第一次调用时传入要切割的字符串;后续每次调用传 NULL,表示继续切割同一个字符串,直到切完返回 NULL。strtok 有两个需要特别注意的地方。

    c
    // strtok(str, delimiters)
    
    char str[] = "Hello,World,Foo";
    char *token = strtok(str, "");     // 第一次调用,传入 str
    while (token != NULL) {
        printf("%s\n", token);
        token = strtok(NULL"");      // 后续调用传 NULL
    }
    // 依次输出:
    // Hello
    // World
    // Foo
    • 第一,它会修改原字符串:每次切割时,strtok 会把找到的分隔符直接替换成 '\0',所以原始字符串会被破坏;如果你之后还要用原串,应该先拷贝一份再传给 strtok。
    • 第二,它内部维护了一个静态指针(static pointer)来记录上次切割到了哪个位置,这就是为什么后续调用传 NULL 它还能接着往下切;但这也意味着 strtok 不是线程安全的——如果两个地方同时在用 strtok 切不同的字符串,它们会互相干扰。
  • atoi: 将字符串转换成 int 类型

字符串容易踩坑的点

  • 字符串字面量是只读的。 char *s = "hello";s 指向只读区,s[0] = 'H'; 是未定义行为(多半段错误)。要修改内容必须用数组:char s[] = "hello";(这会把字面量拷贝到栈上的可写数组)。char *char [] 看起来都能存字符串,可写性却天差地别。

  • strncpy 不保证以 '\0' 结尾。 当源串长度 ≥ n 时,strncpy 只复制 n 个字符、不补结尾的 '\0'。之后把目标当字符串用就会越界读到内存里的垃圾。"安全版本"反而藏着这个坑,通常要手动补一句 dst[n-1] = '\0';

  • 比较字符串内容必须用 strcmp,不能用 == s1 == s2 比较的是两个指针(地址)是否相同,而不是内容是否相等。内容比较一律用 strcmp(s1, s2) == 0

  • 缓冲区大小要算上 '\0' 存放 n 个字符的字符串需要 n + 1 字节空间。少留一个字节,'\0' 就会写到相邻内存,是经典的差一错误(off-by-one)。

  • strlen 是 O(n) 的。 它每次都从头扫到 '\0'。在循环条件里反复调用 strlen(或反复 strcat 找结尾)会让算法退化成 O(n2)——这正是下面例题 4 第一版慢的原因。


例题4 把 0 到 999 这一千个数字,用空格分隔,拼接成一个很长的字符串,存进全局字符数组 totes 里,最后打印出来

image-20260613103221202

分析: 第一个版本性能弱,因为每次调用 strcat 都重复需要从头开始扫描找到null的位置。

第二个版本更好,利用sprintf的返回值是每次写入的字符数的特性,将tally作为游标。


结构体

结构体类似 Python 里的类,但只能包含变量(成员),不包含函数。用 . 访问成员;为避免直接复制大型结构体(参数按值传递会产生大量拷贝),通常传指针;通过指针访问成员时先解引用再取成员,可用 (*ptr).member 或更常用的 ptr->member

C 中所有函数参数都是按值传递,结构体也不例外。这意味着把结构体直接传进函数时,整个结构体会被完整复制一份,函数内部操作的是副本,对原件没有任何影响。如果想让修改生效,一种办法是让函数返回修改后的结构体,再覆盖原来的变量:

c
our_course = make_subject2(our_course);

但这种做法有两个代价:传入时要复制一遍,返回时又要复制一遍,既浪费时间又浪费内存,结构体越大开销越明显。

更好的做法是传指针。和其它类型一样,结构体也可以有指向它的指针。通过指针,函数拿到的是结构体在内存中的地址,可以直接修改原件,不需要任何复制,相当于间接实现了按引用传递。

c
void make_subject3(struct Subject *s);
make_subject3(&our_course);  // 传入 our_course 的地址

这样 make_subject3 内部通过指针 s 修改的就是 our_course 本身,函数返回后 our_course 的值已经被改好了,不需要再用返回值覆盖

通过指针访问结构体成员时,需要先解引用(dereference)再取成员。写法上有两种等价形式

  • (*s).units = 12; // 因为.的优先级比*
  • s->units = 12; // 但写起来更简洁,实际代码中几乎都用这种形式

结构体的初始化

c
// 声明但没有初始化
struct Subject our_course;
// 声明并且初始化
struct Subject other_course = {9, 12, "9.01: Intro Brain Stuff", 100};
// 声明并初始化
struct Subject other_course = {.dept=9, .units=12, .name="9.01: Intro Brain Stuff", .num_students=100};

Null

在C语言中, Null的语义是 none,

结构体容易踩坑的点

  • sizeof(结构体) 通常大于成员大小之和——因为内存对齐(padding)。 编译器会在成员之间插入填充字节,让每个成员落在它对齐要求的地址上(如 8 字节的 double 要落在 8 的倍数地址)。例如:

    c
    struct A { char c; int i; char d; };   // 很可能 sizeof == 12,而不是 6
    struct B { int i; char c; char d; };   // 重排后很可能 sizeof == 8

    成员声明顺序会影响结构体大小:把大的、对齐要求高的成员放前面、小的归拢在一起,能减少填充。这点在算内存、做序列化、和硬件/文件格式打交道时极易被忽视。

  • 结构体不能用 == 比较。 s1 == s2 编译报错;即使能比,padding 里的垃圾字节也会让逐字节比较不可靠。要比较得逐个成员比,或在确保无 padding 时用 memcmp

  • 结构体赋值是逐成员浅拷贝。 s1 = s2 会复制每个成员的值;如果成员里有指针,复制的是指针本身(两个结构体指向同一块内存),不是它指向的数据。

  • 按值传结构体会复制整个结构体。 大结构体按值传参开销很大(前面已说要传指针);同理,按值返回结构体也会整体拷贝。

例题

Example 1 int[10] vals = {0, 3, 6, 9,12, 15, 18, 21, 24, 27}

Sol: *(&vals[7]-2) = val[7]这个地址往回退两个元素,得到值为 15

Example 2 int* x = (int*) 0; x = x + 3; 问 x的值是多少

Sol:每个int类型元素需要4字节,因此, x = x + 3 = x + 3(4) = 12