JiaoYuan's Blog

C 语言速查手册

原版来自 《C 语言教程》,此处仅作修正与补充。

1 占位符

2 关键字

auto, break, case, char, const, continue, default, do, double, else, enum, extern, float, for, goto, if, inline, int, long, register, restrict, return, short, signed, sizeof, static, struct, switch, typedef, union, unsigned, void, volatile, while

3 变量

一般把建立存储空间的声明称为定义,不需要建立存储空间的声明称为声明。

4 运算符

4.1 算术运算符

4.2 关系运算符

4.3 逻辑运算符

对于逻辑运算符来说,任何非零值都表示真,零值表示伪。逻辑运算符总是先对左侧的表达式求值,再对右边的表达式求值,这个顺序是保证的。

4.4 位运算符

4.5 逗号运算符

逗号运算符用于将多个表达式写在一起,从左到右依次运行每个表达式。

4.6 运算优先级

5 流程控制

5.1 if else

如果有多个ifelse,可以记住这样一条规则,else总是跟最接近的if匹配,这样很容易出错,为了提供代码的可读性,建议使用大括号,明确else匹配哪一个if

5.3 三元运算符

<expression1>?<expression2>:<expression3>
int a = 10;
int b = 20;
int max = (a > b) ? a : b;

上面的代码等同于下面的 if 语句。

int a = 10;
int b = 20;
int max;

if (a > b) {
        max = a;
} else {
        max = b;
}

可以用作if...else的简写形式。表达式expression1如果为true(非 0 值),就执行expression2,否则执行expression3

5.3switch 语句

switch语句是一种特殊形式的if...else结构,用于判断条件有多个结果的情况。它把多重的 else if 改成更易用、可读性更好的形式。

switch (grade) {
    case 0:
        printf("False");
        break;
    case 1:
        printf("True");
        break;
    default:
        printf("Illegal");
}

根据变量grade不同的值,会执行不同的case分支。如果等于 0,执行case 0的部分;如果等于 1,执行case 1的部分;否则,执行default的部分。default表示处理以上所有case都不匹配的情况。每个case语句体的结尾,都应该有一个break语句,作用是跳出整个switch结构,不再往下执行。如果缺少break,就会导致继续执行下一个casedefault分支。

5.4 循环结构

while语句用于循环结构,满足条件时,不断执行循环体。

do...while结构是while的变体,它会先执行一次循环体,然后再判断是否满足条件。如果满足的话,就继续执行循环体,否则跳出循环。

for语句是最常用的循环结构,通常用于精确控制循环次数。for的三个表达式都不是必需的,甚至可以全部省略,这会形成无限循环。

for (initialization; continuation; action)
    statement;

5.5break 语句

break语句有两种用法。一种是与switch语句配套使用,用来中断某个分支的执行,这种用法前面已经介绍过了。另一种用法是在循环体内部跳出循环,不再进行后面的循环了。

5.6continue 语句

continue语句用于在循环体内部终止本轮循环,进入下一轮循环。只要遇到continue语句,循环体内部后面的语句就不执行了,回到循环体的头部,开始执行下一轮循环。

5.7goto 语句

goto语句用于跳到指定的标签名。这会破坏结构化编程,建议不要轻易使用

char ch;

top: ch=getchar();

if (ch=='q')
    goto top;

上面示例中,top是一个标签名,可以放在正常语句的前面,相当于为这行语句做了一个标记。程序执行到goto语句,就会跳转到它指定的标签名。goto的一个主要用法是跳出多层循环,另一个用途是提早结束多重判断。

6 类型

转义的写法,主要用来表示 ASCII 码定义的一些无法打印的控制字符,它们也属于字符类型的值。

6.1 整数类型

不同计算机的int类型的大小是不一样的。比较常见的是使用 4 个字节(32 位)存储一个int类型的值,但是 2 个字节(16 位)或 8 个字节(64 位)也有可能使用。它们可以表示的整数范围如下。

C 语言使用signed关键字,表示一个类型带有正负号,包含负值;使用unsigned关键字,表示该类型不带有正负号,只能表示零和正整数。

对于int类型,默认是带有正负号的,也就是说int等同于signed int。由于这是默认情况,关键字signed一般都省略不写,但是写了也不算错。

int类型也可以不带正负号,只表示非负整数。这时就必须使用关键字unsigned声明变量。

整数变量声明为unsigned的好处是,同样长度的内存能够表示的最大整数值,增大了一倍。比如,16 位的signed int最大值为2^15-1,而unsigned int的最大值增大到了2^15+1

unsigned int里面的int可以省略,所以上面的变量声明也可以写成下面这样。

字符类型char也可以设置signedunsigned

注意,C 语言规定char类型默认是否带有正负号,由当前系统决定。这就是说,char不等同于signed char,它有可能是signed char,也有可能是unsigned char。这一点与int不同,int就是等同于signed int

6.1.1 整型的子类型

short int(简写为short):占用空间不多于int,一般占用 2 个字节(整数范围为-32768~32767)。 long int(简写为long):占用空间不少于int,至少为 4 个字节。 long long int(简写为long long):占用空间多于long,至少为 8 个字节。

默认情况下,shortlonglong long都是带符号的(signed),即signed关键字省略了。它们也可以声明为不带符号(unsigned),使得能够表示的最大值扩大一倍。

6.1.2 整数类型的极限值

有时候需要查看,当前系统不同整数类型的最大值和最小值,C 语言的头文件limits.h提供了相应的常量,比如SCHAR_MIN代表 signed char类型的最小值-128SCHAR_MAX代表signed char类型的最大值127

为了代码的可移植性,需要知道某种整数类型的极限值时,应该尽量使用这些常量。

6.1.3 整数的进制

printf() 的进制相关占位符如下。

6.2 浮点数

任何有小数点的数值,都会被编译器解释为浮点数。所谓“浮点数”就是使用m * b的 e 次方 的形式,存储一个数值,m 是小数部分,b 是基数(通常是 2),e 是指数部分。这种形式是精度和数值范围的一种结合,可以表示非常大或者非常小的数。

float类型占用 4 个字节(32 位),其中 8 位存放指数的值和符号,剩下 24 位存放小数的值和符号。float 类型至少能够提供(十进制的)6 位有效数字,指数部分的范围为(十进制的)-3737,即数值范围为 10-37 到 1037。

有时候,32 位浮点数提供的精度或者数值范围还不够,C 语言又提供了另外两种更大的浮点数类型。

double:占用 8 个字节(64 位),至少提供 13 位有效数字。 long double:通常占用 16 个字节。 注意,由于存在精度限制,浮点数只是一个近似值,它的计算是不精确的,比如 C 语言里面 0.1+0.2 并不等于 0.3,而是有一个很小的误差。

C 语言允许使用科学计数法表示浮点数,使用字母 e 来分隔小数部分和指数部分

上面示例中,e 后面如果是加号+,加号可以省略。注意,科学计数法里面 e 的前后,不能存在空格。

另外,科学计数法的小数部分如果是 0.x 或 x.0 的形式,那么 0 可以省略。

0.3E6
// 等同于
.3E6

3.0E6
// 等同于
3.E6

6.3 布尔类型

C 语言原来并没有为布尔值单独设置一个类型,而是使用整数 0 表示伪,所有非零值表示真。

C99 标准添加了类型_Bool,表示布尔值。但是,这个类型其实只是整数类型的别名,还是使用 0 表示伪,1 表示真,下面是一个示例。

_Bool isNormal;

isNormal=1;
if (isNormal)
    printf("Everything is OK.\n");

头文件stdbool.h定义了另一个类型别名bool,并且定义了true代表 1、false代表 0。只要加载这个头文件,就可以使用这几个关键字。

#include <stdbool.h>

6.4 字面量(常量)

字面量(literal)指的是代码里面直接出现的值。

int x=123;

上面代码中,x是变量,123 就是字面量。

编译时,字面量也会写入内存,因此编译器必须为字面量指定数据类型,就像必须为变量指定数据类型一样。

一般情况下,十进制整数字面量(比如 123)会被编译器指定为int类型。如果一个数值比较大,超出了int能够表示的范围,编译器会将其指定为long int。如果数值超过了long int,会被指定为unsigned long。如果还不够大,就指定为long longunsigned long long

小数(比如 3.14)会被指定为 double 类型

字面量后缀:

u 还可以与其他整数后缀结合,放在前面或后面都可以,比如 10UL、10ULL 和 10LLU 都是合法的。

6.5 溢出

每一种数据类型都有数值范围,如果存放的数值超出了这个范围(小于最小值或大于最大值),需要更多的二进制位存储,就会发生溢出。大于最大值,叫做向上溢出(overflow);小于最小值,叫做向下溢出(underflow)。

一般来说,编译器不会对溢出报错,会正常执行代码,但是会忽略多出来的二进制位,只保留剩下的位,这样往往会得到意想不到的结果。所以,应该避免溢出。

6.6sizeof 运算符

sizeof是 C 语言提供的一个运算符,返回某种数据类型或某个值占用的字节数量。它的参数可以是数据类型的关键字,也可以是变量名或某个具体的值。

6.6 类型的自动转换

6.6.1 赋值运算

6.6.2 混合类型的运算

不同类型的值进行混合计算时,必须先转成同一个类型,才能进行计算。

6.6.3 整数类型的运算

两个相同类型的整数运算时,或者单个整数的运算,一般来说,运算结果也属于同一类型。但是有一个例外,宽度小于 int 的类型,运算结果会自动提升为 int。

6.6.4 函数

函数的参数和返回值,会自动转成函数定义里指定的类型。参数变量不管原来的类型是什么,都会转成函数定义的参数类型。

6.7 类型的显式转换

原则上,应该避免类型的自动转换,防止出现意料之外的结果。C 语言提供了类型的显式转换,允许手动转换类型。

只要在一个值或变量的前面,使用圆括号指定类型 (type),就可以将这个值或变量转为指定的类型,这叫做“类型指定”(casting)。

(unsigned char) ch
long int y=(long int) 10+12;

6.8 可移植类型

C 语言的整数类型(shortintlong)在不同计算机上,占用的字节宽度可能是不一样的,无法提前知道它们到底占用多少个字节。

程序员有时控制准确的字节宽度,这样的话,代码可以有更好的可移植性,头文件stdint.h创造了一些新的类型别名。

6.8.1 精确宽度类型 (exact-width integer type)

保证某个整数类型的宽度是确定的。

上面这些都是类型别名,编译器会指定它们指向的底层类型。比如,某个系统中,如果int类型为 32 位,int32_t就会指向int;如果long类型为 32 位,int32_t则会指向long

#include <stdio.h>
#include <stdint.h>

int main(void) {
    int32_t x32=45933945;
    printf("x32=%d\n", x32);
    return 0;
}

上面示例中,变量x32声明为int32_t类型,可以保证是 32 位的宽度。

6.8.2 最小宽度类型(minimum width type)

保证某个整数类型的最小长度。

上面这些类型,可以保证占据的字节不少于指定宽度。比如,int_least8_t表示可以容纳 8 位有符号整数的最小宽度的类型。

6.8.3 最快的最小宽度类型(fast minimum width type)

可以使整数计算达到最快的类型。

上面这些类型是保证字节宽度的同时,追求最快的运算速度,比如int_fast8_t表示对于 8 位有符号整数,运算速度最快的类型。这是因为某些机器对于特定宽度的数据,运算速度最快,举例来说,32 位计算机对于 32 位数据的运算速度,会快于 16 位数据。

6.8.4 可以保存指针的整数类型。

6.8.5 最大宽度整数类型,用于存放最大的整数。

上面的这两个类型的宽度比long longunsigned long更大。

7 指针

指针是一个值,这个值代表一个内存地址,指针相当于指向某个内存地址的路标。

字符*表示指针,通常跟在类型关键字的后面,表示指针指向的是什么类型的值。比如,char *表示一个指向字符的指针,float *表示一个指向 float 类型的值的指针。星号可以放在变量名与类型关键字之间的任何地方,下面的写法都是有效的。

int* intPtr;

如果同一行声明两个指针变量,那么需要写成下面这样:

// 正确
int * foo, * bar;

// 错误
int* foo, bar;

一个指针指向的可能还是指针,这时就要用两个星号**表示。

int** foo;

7.1 *运算符

*这个符号除了表示指针以外,还可以作为运算符,用来取出指针变量所指向的内存地址里面的值。

void increment(int* p) {
    *p=*p+1;
}

上面示例中,函数 increment() 的参数是一个整数指针p。函数体里面,*p就表示指针p所指向的那个值。对*p赋值,就表示改变指针所指向的那个地址里面的值。

7.2 &运算符

&运算符用来取出一个变量所在的内存地址。

int x=1;
printf("x's address is %p\n", &x);

上面示例中,x是一个整数变量,&x就是x的值所在的内存地址。printf()%p是内存地址的占位符,可以打印出内存地址。

#include <stdio.h>
int main()
{
        void increment();
        int x=1;
        printf("%d\n",x);
        printf("%p\n",&x);
        increment(&x);
        printf("%d\n",x);
        printf("%p\n",&x);
}

void increment(int* p) {
    *p=*p+1;
}

输出:

1
0x7ffe7d9d786c
2
0x7ffe7d9d786c

调用 increment() 函数以后,变量 x 的值就增加了 1,原因就在于传入函数的是变量 x 的地址&x,但是它的指针指向的地址不变,只是指针指向的变量变了。

&运算符获取变量的地址,*运算符:通过地址获取变量的值,所以&运算符与*运算符互为逆运算

#include <stdio.h>
int main()
{
        int i=5;
        if (i==*(&i)) printf("&==*\n");
        return 0;
}

7.3 指针变量的初始化

声明指针变量之后,编译器会为指针变量本身分配一个内存空间,但是这个内存空间里面的值是随机的,也就是说,指针变量指向的值是随机的。这时一定不能去读写指针变量指向的地址,因为那个地址是随机地址,很可能会导致严重后果。正确做法是指针变量声明后,先让它指向一个分配好的地址,然后再进行读写,这叫做指针变量的初始化。

#include <stdio.h>
int main()
{
        int* p;
        int i;

        p=&i;
        *p=13;
        printf("p is %d\n",p);    //输出一个随机地址
        printf("p is %d\n",*p);    //输出 p is 13
        return 0;
}

p是指针变量,声明这个变量后,p会指向一个随机的内存地址。这时要将它指向一个已经分配好的内存地址,上例就是再声明一个整数变量i,编译器会为i分配内存地址,然后让p指向i的内存地址 (p=&i;)。完成初始化之后,就可以对p指向的内存地址进行赋值了 (*p=13;)。

为了防止读写未初始化的指针变量,可以养成习惯,将未初始化的指针变量设为NULL

int* p=NULL;

NULL在 C 语言中是一个常量,表示地址为 0 的内存空间,这个地址是无法使用的,读写该地址会报错。

7.4 指针的运算

指针本质上就是一个无符号整数,代表了内存地址。它可以进行运算,但是规则并不是整数运算的规则。

7.4.1 指针与整数值的加减运算

指针与整数值的运算,表示指针的移动。

short* j;
j=(short*)0x1234;
j=j+1; // 0x1236

上面示例中,j是一个指针,指向内存地址0x1234。由于0x1234本身是整数类型(int),跟j的类型(short*)并不兼容,所以强制使用类型投射,将0x1234转成short*。你可能以为j+1等于0x1235,但正确答案是0x1236。原因是j+1表示指针向内存地址的高位移动一个单位,指针的算术运算是基于指针所指向类型的大小的。当你执行j=j+1时,j会增加 一个sizeof(*j)=sizeof(short)的大小,而一个单位的short类型占据两个字节的宽度,所以相当于向高位移动两个字节。同样的,j-1得到的结果是0x1232

指针移动的单位,与指针指向的数据类型有关。数据类型占据多少个字节,每单位就移动多少个字节。

7.4.2 指针与指针的加法运算

指针只能与整数值进行加减运算,两个指针进行加法是非法的。

unsigned short* j;
unsigned short* k;
x=j+k; // 非法

7.4.3 指针与指针的减法

相同类型的指针允许进行减法运算,返回它们之间的距离,即相隔多少个数据单位。高位地址减去低位地址,返回的是正值;低位地址减去高位地址,返回的是负值。

这时,减法返回的值属于ptrdiff_t类型,这是一个带符号的整数类型别名,具体类型根据系统不同而不同。这个类型的原型定义在头文件stddef.h里面。

short* j1;
short* j2;

j1=(short*)0x1234;
j2=(short*)0x1236;

ptrdiff_t dist=j2-j1;
printf("%td\n", dist); // 1

上面示例中,j1j2是两个指向short类型的指针,变量 dist 是它们之间的距离,类型为ptrdiff_t,值为 1,因为相差 2 个字节正好存放一个short类型的值。

7.4.4 指针与指针的比较运算

指针之间的比较运算,比较的是各自的内存地址哪一个更大,返回值是整数1(true)或0(false)。

8 函数

函数是一段可以重复执行的代码。它可以接受不同的参数,完成对应的操作。函数是 C 程序的基本结构,一个 C 程序由一个或多个函数组成,一个 C 函数由若干条 C 语句构成,一条 C 语句由若干基本单词组成。

函数分为两类:

还可以根据函数是否能被其他源文件调用分为:

示例函数:

int plus_one(int n) {
    return n+1;
}

函数由以下几部分组成:

调用函数时,只要在函数名后面加上圆括号就可以了,实参放在圆括号里面:

int a=plus_one(13);
// a 等于 14

函数调用时,参数个数必须与定义里面的参数个数一致,参数过多或过少都会报错。

int plus_one(int n) {
    return n+1;
}

plus_one(2, 2); // 报错
plus_one();    // 报错

函数必须声明后使用,否则会报错。

C 语言标准规定,函数只能声明在源码文件的顶层,不能声明在其他函数内部。

不返回值的函数,使用 void 关键字表示返回值的类型。没有参数的函数,声明时要用 void 关键字表示参数类型。

void myFunc(void) {
    // ...
}

函数可以调用自身,这就叫做`递归(recursion)。下面是斐波那契数列的例子。

unsigned long Fibonacci(unsigned n) {
    if (n>2)
        return Fibonacci(n-1)+Fibonacci(n-2);
    else
        return 1;
}

8.1 主函数

C 语言规定,main()是程序的入口函数,即所有的程序一定要包含一个main()函数。程序总是从这个函数开始执行,如果没有该函数,程序就无法启动。其他函数都是通过它引入程序的。main()的写法与其他函数一样,要给出返回值的类型和参数的类型,就像下面这样。

int main(void) {
    printf("Hello World\n");
    return 0;
}

上面示例中,最后的return 0;表示函数结束运行,返回 0。

C 语言约定,返回值 0 表示函数运行成功,如果返回其他非零整数,就表示运行失败,代码出了问题。系统根据 main() 的返回值,作为整个程序的返回值,确定程序是否运行成功。

正常情况下,如果main()里面省略return 0这一行,编译器会自动加上,即main()的默认返回值为 0。由于 C 语言只会对 main() 函数默认添加返回值,对其他函数不会这样做,所以建议总是保留 return 语句,以便形成统一的代码风格。

8.2 参数的传值引用

如果函数的参数是一个变量,那么调用时,传入的是这个变量的值的拷贝,而不是变量本身。

void increment(int a) {
    a++;
}

int i=10;
increment(i);

printf("%d\n", i); // 10

上面示例中,调用increment(i)以后,变量 i 本身不会发生变化,还是等于 10。因为传入函数的是i的拷贝,而不是i本身,拷贝的变化,影响不到原始变量。这就叫做“传值引用”。

所以,如果参数变量发生变化,最好把它作为返回值传出来。

int increment(int a) {
    a++;
    return a;
}

int i=10;
i=increment(i);

printf("%d\n", i); // 11

再看下面的例子,Swap()函数用来交换两个变量的值,由于传值引用,下面的写法不会生效。

void Swap(int x, int y) {
    int temp;
    temp=x;
    x=y;
    y=temp;
}

int a=1;
int b=2;
Swap(a, b); // 无效

上面的写法不会产生交换变量值的效果,因为传入的变量是原始变量ab的拷贝,不管函数内部怎么操作,都影响不了原始变量。

如果想要传入变量本身,只有一个办法,就是传入变量的地址。

void Swap(int* x, int* y) {
    int temp;
    temp=*x;
    *x=*y;
    *y=temp;
}

int a=1;
int b=2;
Swap(&a, &b);

上面示例中,通过传入变量xy的地址,函数内部就可以直接操作该地址,从而实现交换两个变量的值。

虽然跟传参无关,这里特别提一下,函数不要返回内部变量的指针。

int* f(void) {
    int i;
    // ...
    return &i;
}

上面示例中,函数返回内部变量i的指针,这种写法是错的。因为当函数结束运行时,内部变量就消失了,这时指向内部变量i的内存地址就是无效的,再去使用这个地址是非常危险的。

8.3 函数指针

函数本身就是一段内存里面的代码,C 语言允许通过指针获取函数。

void print(int a) {
    printf("%d\n", a);
}

void (*print_ptr)(int)=&print;

上面示例中,变量 print_ptr 是一个函数指针,它指向函数 print() 的地址。函数 print() 的地址可以用&print 获得。注意,(print_ptr) 一定要写在圆括号里面,否则函数参数 (int) 的优先级高于,整个式子就会变成 void* print_ptr(int)。

有了函数指针,通过它也可以调用函数。

(*print_ptr)(10);
// 等同于
print(10);

比较特殊的是,C 语言还规定,函数名本身就是指向函数代码的指针,通过函数名就能获取函数地址。也就是说,print&print是一回事。

因此,上面代码的print_ptr等同于print

void (*print_ptr)(int)=&print;
// 或
void (*print_ptr)(int)=print;

if (print_ptr==print) // true

所以,对于任意函数,都有五种调用函数的写法。

// 写法一
print(10)

// 写法二
(*print)(10)

// 写法三
(&print)(10)

// 写法四
(*print_ptr)(10)

// 写法五
print_ptr(10)

为了简洁易读,一般情况下,函数名前面都不加*&

这种特性的一个应用是,如果一个函数的参数或返回值,也是一个函数,那么函数原型可以写成下面这样。

int compute(int (*myfunc)(int), int, int);

上面示例可以清晰地表明,函数compute()的第一个参数也是一个函数。

8.4 函数原型(函数的声明)

函数必须先声明,后使用。由于程序总是先运行main()函数,导致所有其他函数都必须在main()函数之前声明。

但是,main()是整个程序的入口,也是主要逻辑,放在最前面比较好。另一方面,对于函数较多的程序,保证每个函数的顺序正确,会变得很麻烦。

C 语言提供的解决方法是,只要在程序开头处给出函数原型,函数就可以先使用、后声明。所谓函数原型,就是提前告诉编译器,每个函数的返回类型和参数类型。其他信息都不需要,也不用包括函数体,具体的函数实现可以后面再补上。(谭浩强 C++第五版里是直接定义在main()函数内,这是不标准用法)

int twice(int);

int main() {
    return twice(num);
}

int twice(int num) {
    return 2 * num;
}

上面示例中,函数twice()的实现是放在main()后面,但是代码头部先给出了函数原型,所以可以正确编译。只要提前给出函数原型,函数具体的实现放在哪里,就不重要了。

函数原型包括参数名也可以,虽然这样对于编译器是多余的,但是阅读代码的时候,可能有助于理解函数的意图。

int twice(int);

// 等同于
int twice(int num);

上面示例中,twice 函数的参数名 num,无论是否出现在原型里面,都是可以的。

注意,函数原型必须以分号结尾。

一般来说,每个源码文件的头部,都会给出当前脚本使用的所有函数的原型。

8.5 exit()

exit()函数用来终止整个程序的运行。一旦执行到该函数,程序就会立即结束。该函数的原型定义在头文件stdlib.h里面。

exit()可以向程序外部返回一个值,它的参数就是程序的返回值。一般来说,使用两个常量作为它的参数:EXIT_SUCCESS(相当于 0)表示程序运行成功,EXIT_FAILURE(相当于 1)表示程序异常中止。这两个常数也是定义在stdlib.h里面。

// 程序运行成功
// 等同于 exit(0);
exit(EXIT_SUCCESS);

// 程序异常中止
// 等同于 exit(1);
exit(EXIT_FAILURE);

main()函数里面,exit()等价于使用return语句。其他函数使用exit(),就是终止整个程序的运行,没有其他作用。

C 语言还提供了一个atexit()函数,用来登记exit()执行时额外执行的函数,用来做一些退出程序时的收尾工作。该函数的原型也是定义在头文件stdlib.h

int atexit(void (*func)(void));

atexit()的参数是一个函数指针。注意,它的参数函数(下例的print)不能接受参数,也不能有返回值。

void print(void) {
    printf("something wrong!\n");
}

atexit(print);
exit(EXIT_FAILURE);

上面示例中,exit()执行时会先自动调用atexit()注册的print()函数,然后再终止程序。

8.6 函数说明符

C 语言提供了一些函数说明符,让函数用法更加明确。

8.6.1 extern 说明符

对于多文件的项目,源码文件会用到其他文件声明的函数。这时,当前文件里面,需要给出外部函数的原型,并用extern说明该函数的定义来自其他文件。

extern int foo(int arg1, char arg2);
//等价于 int foo(int arg1, char arg2);

int main(void) {
    int a=foo(2, 3);
    // ...
    return 0;
}

上面示例中,函数foo()定义在其他文件,extern告诉编译器当前文件不包含该函数的定义。

不过,由于函数原型默认就是extern,所以这里不加extern,效果是一样的。

8.6.2 static 说明符

默认情况下,每次调用函数时,函数的内部变量都会重新初始化,不会保留上一次运行的值。static说明符可以改变这种行为。

static用于函数内部声明变量时,表示该变量只需要初始化一次,不需要在每次调用时都进行初始化。也就是说,它的值在两次调用之间保持不变。

#include <stdio.h>

void counter(void) {
    static int count=1;    // 只初始化一次
    printf("%d\n", count);
    count++;
}

int main(void) {
    counter();    // 1
    counter();    // 2
    counter();    // 3
    counter();    // 4
}

上面示例中,函数counter()的内部变量count,使用static说明符修饰,表明这个变量只初始化一次,以后每次调用时都会使用上一次的值,造成递增的效果。

注意,static修饰的变量初始化时,只能赋值为常量,不能赋值为变量。

int i=3;
static int j=i; // 错误

上面示例中,j属于静态变量,初始化时不能赋值为另一个变量 i。

另外,在块作用域中,static声明的变量有默认值 0。

static int foo;
// 等同于
static int foo=0;
static 可以用来修饰函数本身。

static int Twice(int num) {
    int result=num * 2;
    return(result);
}

上面示例中,static关键字表示该函数只能在当前文件里使用,如果没有这个关键字,其他文件也可以使用这个函数(通过声明函数原型)。

static也可以用在参数里面,修饰参数数组。

int sum_array(int a[static 3], int n) {
    // ...
}

上面示例中,static对程序行为不会有任何影响,只是用来告诉编译器,该数组长度至少为 3,某些情况下可以加快程序运行速度。另外,需要注意的是,对于多维数组的参数,static仅可用于第一维的说明。

8.6.3 const 说明符

函数参数里面的const说明符,表示函数内部不得修改该参数变量。

在声明函数时,在指针参数前面加上const说明符,告诉编译器,函数内部不能修改该参数所指向的值。

void f(const int* p) {
    *p=0; // 该行报错
}

上面示例中,声明函数时,const指定不能修改指针 p 指向的值,所以*p=0就会报错。

但是上面这种写法,只限制修改 p 所指向的值,而 p 本身的地址是可以修改的。

void f(const int* p) {
    int x=13;
    p=&x; // 允许修改
}

上面示例中,p本身是可以修改,const只限定*p不能修改。

如果想限制修改p,可以把const放在p前面。

void f(int* const p) {
    int x=13;
    p=&x; // 该行报错
}

如果想同时限制修改p*p,需要使用两个const

void f(const int* const p) {
    // ...
}

8.6.4 可变参数

有些函数的参数数量是不确定的,声明函数的时候,可以使用省略号...表示可变数量的参数。

int printf(const char* format, ...);

上面示例是printf()函数的原型,除了第一个参数,其他参数的数量是可变的,与格式字符串里面的占位符数量有关。这时,就可以用...表示可变数量的参数。

注意,...符号必须放在参数序列的结尾,否则会报错。

头文件stdarg.h定义了一些宏,可以操作可变参数。

下面是一个例子。

double average(int i, ...) {
    double total=0;
    va_list ap;
    va_start(ap, i);
    for (int j=1; j <= i; ++j) {
        total += va_arg(ap, double);
    }
    va_end(ap);
    return total / i;
}

上面示例中,va_list ap定义ap为可变参数对象,va_start(ap, i)将参数i后面的参数统一放入ap,va_arg(ap, double)用来从ap依次取出一个参数,并且指定该参数为double类型,va_end(ap)用来清理可变参数对象。

9 数组

数组是一组相同类型的值,按照顺序储存在一起。数组通过变量名后加方括号表示,方括号里面是数组的成员数量。

int scores[100];

上面示例声明了一个数组scores,里面包含 100 个成员,每个成员都是int类型。

注意,声明数组时,必须给出数组的大小。

数组的成员从 0 开始编号,所以数组scores[100]就是从第 0 号成员一直到第 99 号成员,最后一个成员的编号会比数组长度小 1。

数组名后面使用方括号指定编号,就可以引用该成员。也可以通过该方式,对该位置进行赋值。

scores[0]=13;
scores[99]=42;

上面示例对数组scores的第一个位置和最后一个位置,进行了赋值。

注意,如果引用不存在的数组成员(即越界访问数组),并不会报错,所以必须非常小心。

int scores[100];

scores[100]=51;

上面示例中,数组scores只有 100 个成员,因此scores[100]这个位置是不存在的。但是,引用这个位置并不会报错,会正常运行,使得紧跟在scores后面的那块内存区域被赋值,而那实际上是其他变量的区域,因此不知不觉就更改了其他变量的值。这很容易引发错误,而且难以发现。

数组也可以在声明时,使用大括号,同时对每一个成员赋值。

int a[5]={22, 37, 3490, 18, 95};

注意,使用大括号赋值时,必须在数组声明时赋值,否则编译时会报错。

int a[5];
a={22, 37, 3490, 18, 95}; // 报错

上面代码中,数组a声明之后再进行大括号赋值,导致报错。

报错的原因是,C 语言规定,数组变量一旦声明,就不得修改变量指向的地址,具体会在后文解释。由于同样的原因,数组赋值之后,再用大括号修改值,也是不允许的。

int a[5]={1, 2, 3, 4, 5};
a={22, 37, 3490, 18, 95}; // 报错

上面代码中,数组a赋值后,再用大括号重新赋值也是不允许的。

使用大括号赋值时,大括号里面的值不能多于数组的长度,否则编译时会报错。

如果大括号里面的值,少于数组的成员数量,那么未赋值的成员自动初始化为 0。

int a[5]={22, 37, 3490};
// 等同于
int a[5]={22, 37, 3490, 0, 0};

如果要将整个数组的每一个成员都设置为零,最简单的写法就是下面这样。

int a[100]={0};

数组初始化时,可以指定为哪些位置的成员赋值。

int a[15]={[2]=29, [9]=7, [14]=48};

上面示例中,数组的 2 号、9 号、14 号位置被赋值,其他位置的值都自动设为 0。

指定位置的赋值可以不按照顺序,下面的写法与上面的例子是等价的。

int a[15]={[9]=7, [14]=48, [2]=29};

指定位置的赋值与顺序赋值,可以结合使用。

int a[15]={1, [5]=10, 11, [10]=20, 21}

上面示例中,0 号、5 号、6 号、10 号、11 号被赋值。

C 语言允许省略方括号里面的数组成员数量,这时将根据大括号里面的值的数量,自动确定数组的长度。

int a[]={22, 37, 3490};
// 等同于
int a[3]={22, 37, 3490};

上面示例中,数组a的长度,将根据大括号里面的值的数量,确定为 3。

省略成员数量时,如果同时采用指定位置的赋值,那么数组长度将是最大的指定位置再加 1。

int a[]={[2]=6, [9]=12};

上面示例中,数组a的最大指定位置是 9,所以数组的长度是 10。

9.1 数组长度

sizeof 运算符会返回整个数组的字节长度。

int a[]={22, 37, 3490};
int arrLen=sizeof(a); // 12

上面示例中,sizeof返回数组a的字节长度是12

由于数组成员都是同一个类型,每个成员的字节长度都是一样的,所以数组整体的字节长度除以某个数组成员的字节长度,就可以得到数组的成员数量。

sizeof(a) / sizeof(a[0])

上面示例中,sizeof(a)是整个数组的字节长度,sizeof(a[0])是数组成员的字节长度,相除就是数组的成员数量。

注意,sizeof返回值的数据类型是size_t,所以sizeof(a) / sizeof(a[0])的数据类型也是size_t。在printf()里面的占位符,要用%zd%zu

int x[12];

printf("%zu\n", sizeof(x));         // 48
printf("%zu\n", sizeof(int));    // 4
printf("%zu\n", sizeof(x) / sizeof(int)); // 12

上面示例中,sizeof(x) / sizeof(int) 就可以得到数组成员数量 12。

9.2 多维数组

C 语言允许声明多个维度的数组,有多少个维度,就用多少个方括号,比如二维数组就使用两个方括号。

int board[10][10];

上面示例声明了一个二维数组,第一个维度有 10 个成员,第二个维度也有 10 个成员。

多维数组可以理解成,上层维度的每个成员本身就是一个数组。比如上例中,第一个维度的每个成员本身就是一个有 10 个成员的数组,因此整个二维数组共有 100 个成员(10 x 10=100)。

三维数组就使用三个方括号声明,以此类推。

int c[4][5][6];

引用二维数组的每个成员时,需要使用两个方括号,同时指定两个维度。

board[0][0]=13;
board[9][9]=13;

注意,board[0][0]不能写成board[0, 0],因为 0,0 是一个逗号表达式,返回第二个值,所以board[0, 0]等同于board[0]

跟一维数组一样,多维数组每个维度的第一个成员也是从 0 开始编号。

多维数组也可以使用大括号,一次性对所有成员赋值。

int a[2][5]={
    {0, 1, 2, 3, 4},
    {5, 6, 7, 8, 9}
};

上面示例中,a是一个二维数组,这种赋值写法相当于将第一维的每个成员写成一个数组。这种写法不用为每个成员都赋值,缺少的成员会自动设置为 0。

多维数组也可以指定位置,进行初始化赋值。

int a[2][2]={[0][0]=1, [1][1]=2};

上面示例中,指定了[0][0][1][1]位置的值,其他位置就自动设为 0。

不管数组有多少维度,在内存里面都是线性存储,a[0][0]的后面是a[0][1]a[0][1]的后面是a[1][0],以此类推。因此,多维数组也可以使用单层大括号赋值,下面的语句与上面的赋值语句是完全等同的。

int a[2][2]={1, 0, 0, 2};

9.3 变长数组

数组声明的时候,数组长度除了使用常量,也可以使用变量。这叫做变长数组(variable-length array,简称VLA)。

int n=x+y;
int arr[n];

上面示例中,数组arr就是变长数组,因为它的长度取决于变量n的值,编译器没法事先确定,只有运行时才能知道n是多少。

变长数组的根本特征,就是数组长度只有运行时才能确定。它的好处是程序员不必在开发时,随意为数组指定一个估计的长度,程序可以在运行时为数组分配精确的长度。

任何长度需要运行时才能确定的数组,都是变长数组。

int i=10;

int a1[i];
int a2[i+5];
int a3[i+k];

上面示例中,三个数组的长度都需要运行代码才能知道,编译器并不知道它们的长度,所以它们都是变长数组。

变长数组也可以用于多维数组。

int m=4;
int n=5;
int c[m][n];

上面示例中,c[m][n]就是二维变长数组。

9.4 数组的地址

数组是一连串连续储存的同类型值,只要获得起始地址(首个成员的内存地址),就能推算出其他成员的地址。请看下面的例子。

int a[5]={11, 22, 33, 44, 55};
int* p;

p=&a[0];

printf("%d\n", *p);    // Prints "11"

上面示例中,&a[0]就是数组a的首个成员11的内存地址,也是整个数组的起始地址。反过来,从这个地址 (*p),可以获得首个成员的值11

由于数组的起始地址是常用操作,&array[0]的写法有点麻烦,C 语言提供了便利写法,数组名等同于起始地址,也就是说,数组名就是指向第一个成员 (array[0]) 的指针。

int a[5]={11, 22, 33, 44, 55};

int* p=&a[0];
// 等同于
int* p=a;

上面示例中,&a[0]和数组名a是等价的。

这样的话,如果把数组名传入一个函数,就等同于传入一个指针变量。在函数内部,就可以通过这个指针变量获得整个数组。

函数接受数组作为参数,函数原型可以写成下面这样。

// 写法一
int sum(int arr[], int len);
// 写法二
int sum(int* arr, int len);

上面示例中,传入一个整数数组,与传入一个整数指针是同一回事,数组符号 [] 与指针符号*是可以互换的。下一个例子是通过数组指针对成员求和。

int sum(int* arr, int len) {
    int i;
    int total=0;

    // 假定数组有 10 个成员
    for (i=0; i<len; i++) {
        total += arr[i];
    }
    return total;
}

上面示例中,传入函数的是一个指针`arr(也是数组名)和数组长度,通过指针获取数组的每个成员,从而求和。

*&运算符也可以用于多维数组。

int a[4][2];

// 取出 a[0][0] 的值
*(a[0]);
// 等同于
**a

上面示例中,由于a[0]本身是一个指针,指向第二维数组的第一个成员a[0][0]。所以,*(a[0])取出的是a[0][0]的值。至于** a,就是对a进行两次*运算,第一次取出的是a[0],第二次取出的是a[0][0]。同理,二维数组的&a[0][0]等同于* a

注意,数组名指向的地址是不能更改的。声明数组时,编译器自动为数组分配了内存地址,这个地址与数组名是绑定的,不可更改,下面的代码会报错。

int ints[100];
ints=NULL; // 报错

上面示例中,重新为数组名赋值,改变原来的内存地址,就会报错。

这也导致不能将一个数组名赋值给另外一个数组名。

int a[5]={1, 2, 3, 4, 5};

// 写法一
int b[5]=a; // 报错

// 写法二
int b[5];
b=a; // 报错

上面两种写法都会更改数组 b 的地址,导致报错。

9.5 数组指针的加减法

C 语言里面,数组名可以进行加法和减法运算,等同于在数组成员之间前后移动,即从一个成员的内存地址移动到另一个成员的内存地址。比如,a+1返回下一个成员的地址,a-1返回上一个成员的地址。

int a[5]={11, 22, 33, 44, 55};

for (int i=0; i<5; i++) {
    printf("%d\n", *(a+i));
}

上面示例中,通过指针的移动遍历数组,a+i的每轮循环每次都会指向下一个成员的地址,*(a+i)取出该地址的值,等同于a[i]。对于数组的第一个成员,*(a+0)(即* a)等同于a[0]

由于数组名与指针是等价的,所以下面的等式总是成立。

a[b]==*(a+b)

上面代码给出了数组成员的两种访问方式,一种是使用方括号a[b],另一种是使用指针* (a+b)

如果指针变量p指向数组的一个成员,那么p++就相当于指向下一个成员,这种方法常用来遍历数组。

int a[]={11, 22, 33, 44, 55, 999};

int* p=a;

while (*p != 999) {
    printf("%d\n", *p);
    p++;
}

上面示例中,通过p++让变量p指向下一个成员。

注意,数组名指向的地址是不能变的,所以上例中,不能直接对a进行自增,即a++的写法是错的,必须将a的地址赋值给指针变量p,然后对p进行自增。

遍历数组一般都是通过数组长度的比较来实现,但也可以通过数组起始地址和结束地址的比较来实现。

int sum(int* start, int* end) {
    int total=0;

    while (start<end) {
        total += *start;
        start++;
    }

    return total;
}

int arr[5]={20, 10, 5, 39, 4};
printf("%i\n", sum(arr, arr+5));

上面示例中,arr是数组的起始地址,arr+5是结束地址。只要起始地址小于结束地址,就表示还没有到达数组尾部。

反过来,通过数组的减法,可以知道两个地址之间有多少个数组成员,请看下面的例子,自己实现一个计算数组长度的函数。

int arr[5]={20, 10, 5, 39, 88};
int* p=arr;

while (*p != 88)
    p++;

printf("%i\n", p-arr); // 4

上面示例中,将某个数组成员的地址,减去数组起始地址,就可以知道,当前成员与起始地址之间有多少个成员。

对于多维数组,数组指针的加减法对于不同维度,含义是不一样的。

#include <stdio.h>
int main(){
    int a[4][2]={
        {3,4},
        {5,6},
        {7,8},
        {9,0}
    };
    printf("a+1 =%p\n",*(a+1));    //指向第二维数组的指针
    printf("a[0]+1 =%d\n",*(a[0]+1)); //输出 4
    printf("a[1]+1 =%d\n",*(a[1]+1)); //输出 6
    printf("a[2]+1 =%d\n",*(a[2]+1)); //输出 8
    printf("a[3]+1 =%d\n",*(a[3]+1)); //输出 0
    return 0;
}

输出:

a+1 =0x7ffe656db818
a[0]+1 =4
a[1]+1 =6
a[2]+1 =8
a[3]+1 =0

上面示例中,a是一个二维数组,a+1是将指针移动到第二维数组。由于每个第一维的成员,本身都包含另一个数组,即a[0]是一个指向第一维数组第一个成员的指针,所以a[0]+1的含义是将指针移动到第一维数组的下一个成员,即a[0][1]。而a[1]+1就是a[1][1]

同一个数组的两个成员的指针相减时,返回它们之间的距离。

int* p=&a[5];
int* q=&a[1];

printf("%d\n", p-q); // 4
printf("%d\n", q-p); // -4

上面示例中,变量pq分别是数组 5 号位置和 1 号位置的指针,它们相减等于 4 或-4

需要注意如下用法:

9.6 数组的复制

由于数组名是指针,所以复制数组不能简单地复制数组名。

int* a;
int b[3]={1, 2, 3};

a=b;

上面的写法,结果不是将数组 b 复制给数组a,而是让ab指向同一个数组。

复制数组最简单的方法,还是使用循环,将数组元素逐个进行复制。

for (i=0; i<N; i++)
    a[i]=b[i];

上面示例中,通过将数组b的成员逐个复制给数组a,从而实现数组的赋值。

另一种方法是使用memcpy()函数(定义在头文件string.h),直接把数组所在的那一段内存,再复制一份。

memcpy(a, b, sizeof(b));

上面示例中,将数组b所在的那段内存,复制给数组a。这种方法要比循环复制数组成员要快。

9.7 作为函数的参数

9.7.1 声明参数数组

数组作为函数的参数,一般会同时传入数组名和数组长度。

在 C 语言中,数组的长度通常需要显式地传递给函数,因为数组在传递给函数时会退化为指针。这意味着函数无法直接知道数组的长度,除非通过其他方式(如传递数组长度)来告知函数。当数组作为参数传递给函数时,数组会退化为指向其第一个元素的指针,而数组的长度作为额外的参数传递给函数。

int sum_array(int a[], int n) {
    // ...
}

int a[]={3, 5, 7, 3};
int sum=sum_array(a, 4);

上面示例中,函数sum_array()的第一个参数是数组本身,也就是数组名,第二个参数是数组长度。

由于数组名就是一个指针,如果只传数组名,那么函数只知道数组开始的地址,不知道结束的地址,所以才需要把数组长度也一起传入。

如果函数的参数是多维数组,那么除了第一维的长度可以当作参数传入函数,其他维的长度需要写入函数的定义。

int sum_array(int a[][4], int n) {
    // ...
}

int a[2][4]={
    {1, 2, 3, 4},
    {8, 9, 10, 11}
};
int sum=sum_array(a, 2);

上面示例中,函数sum_array()的参数是一个二维数组。第一个参数是数组本身(a[][4]),这时可以不写第一维的长度,因为它作为第二个参数,会传入函数,但是一定要写第二维的长度 4。这是因为函数内部拿到的,只是数组的起始地址a,以及第一维的成员数量 2。如果要正确计算数组的结束地址,还必须知道第一维每个成员的字节长度。写成int a[][4],编译器就知道了,第一维每个成员本身也是一个数组,里面包含了 4 个整数,所以每个成员的字节长度就是4 * sizeof(int)

9.7.2 变长数组作为参数

变长数组作为函数参数时,写法略有不同。

int sum_array(int n, int a[n]) {
    // ...
}

int a[]={3, 5, 7, 3};
int sum=sum_array(4, a);

上面示例中,数组a[n]是一个变长数组,它的长度取决于变量n的值,只有运行时才能知道。所以,变量n作为参数时,顺序一定要在变长数组前面,这样运行时才能确定数组a[n]的长度,否则就会报错。

因为函数原型可以省略参数名,所以变长数组的原型中,可以使用*代替变量名,也可以省略变量名。

int sum_array(int, int [*]);
int sum_array(int, int []);

上面两种变长函数的原型写法,都是合法的。

变长数组作为函数参数有一个好处,就是多维数组的参数声明,可以把后面的维度省掉了。

// 原来的写法
int sum_array(int a[][4], int n);

// 变长数组的写法
int sum_array(int n, int m, int a[n][m]);

上面示例中,函数sum_array()的参数是一个多维数组,按照原来的写法,一定要声明第二维的长度。但是使用变长数组的写法,就不用声明第二维长度了,因为它可以作为参数传入函数。

9.7.3 数组字面量作为参数

C 语言允许将数组字面量作为参数,传入函数。

// 数组变量作为参数
int a[]={2, 3, 4, 5};
int sum=sum_array(a, 4);

// 数组字面量作为参数
int sum=sum_array((int []){2, 3, 4, 5}, 4);

上面示例中,两种写法是等价的。第二种写法省掉了数组变量的声明,直接将数组字面量传入函数。{2, 3, 4, 5}是数组值的字面量,(int []) 类似于强制的类型转换,告诉编译器怎么理解这组值。

10 字符串

C 语言没有单独的字符串类型,字符串被当作·,即·类型的数组。比如,字符串“Hello”是当作数组·处理的。

编译器会给数组分配一段连续内存,所有字符储存在相邻的内存单元之中。在字符串结尾,C 语言会自动添加一个全是二进制 0 的字节,写作\0字符,表示字符串结束。字符\0不同于字符 0,前者的 ASCII 码是 0(二进制形式 00000000),后者的 ASCII 码是 48(二进制形式 00110000)。所以,字符串“Hello”实际储存的数组是{'H', 'e', 'l', 'l', 'o', '\0'}

所有字符串的最后一个字符,都是\0。这样做的好处是,C 语言不需要知道字符串的长度,就可以读取内存里面的字符串,只要发现有一个字符是\0,那么就知道字符串结束了。

char localString[10];

上面示例声明了一个 10 个成员的字符数组,可以当作字符串。由于必须留一个位置给\0,所以最多只能容纳 9 个字符的字符串。

字符串写成数组的形式,是非常麻烦的。C 语言提供了一种简写法,双引号之中的字符,会被自动视为字符数组。

{'H', 'e', 'l', 'l', 'o', '\0'}

// 等价于
"Hello"

上面两种字符串的写法是等价的,内部存储方式都是一样的。双引号里面的字符串,不用自己添加结尾字符、0,C 语言会自动添加。

注意,双引号里面是字符串,单引号里面是字符,两者不能互换。如果把 Hello 放在单引号里面,编译器会报错。

// 报错
'Hello'

另一方面,即使双引号里面只有一个字符(比如"a"),也依然被处理成字符串(存储为 2 个字节),而不是字符'a'(存储为 1 个字节)。

如果字符串内部包含双引号,则该双引号需要使用反斜杠转义。

"She replied, \"It does.\""

反斜杠还可以表示其他特殊字符,比如换行符(\n)、制表符(\t)等。

"Hello, world!\n"

如果字符串过长,可以在需要折行的地方,使用反斜杠(\)结尾,将一行拆成多行。

"hello \
world"

上面示例中,第一行尾部的反斜杠,将字符串拆成两行。

上面这种写法有一个缺点,就是第二行必须顶格书写,如果想包含缩进,那么缩进也会被计入字符串。为了解决这个问题,C 语言允许合并多个字符串字面量,只要这些字符串之间没有间隔,或者只有空格,C 语言会将它们自动合并。

char greeting[50]="Hello, ""how are you ""today!";
// 等同于
char greeting[50]="Hello, how are you today!";

这种新写法支持多行字符串的合并。

char greeting[50]="Hello, "
    "how are you "
    "today!";

printf()使用占位符%s输出字符串。

printf("%s\n", "hello world")

10.1 字符串变量的声明

字符串变量可以声明成一个字符数组,也可以声明成一个指针,指向字符数组。

// 写法一
char s[14]="Hello, world!";

// 写法二
char* s="Hello, world!";

上面两种写法都声明了一个字符串变量 s。如果采用第一种写法,由于字符数组的长度可以让编译器自动计算,所以声明时可以省略字符数组的长度。

char s[]="Hello, world!";

上面示例中,编译器会将数组 s 的长度指定为 14,正好容纳后面的字符串。

字符数组的长度,可以大于字符串的实际长度。

char s[50]="hello";

上面示例中,字符数组s的长度是50,但是字符串“hello”的实际长度只有 6(包含结尾符号\0),所以后面空出来的 44 个位置,都会被初始化为\0

字符数组的长度,不能小于字符串的实际长度。

char s[5]="hello";

上面示例中,字符串数组s的长度是 5,小于字符串“hello”的实际长度 6,这时编译器会报错。因为如果只将前 5 个字符写入,而省略最后的结尾符号\0,这很可能导致后面的字符串相关代码出错。

原有的gets()函数已经被弃用了,现在可以使用fgets()方法来通过键盘输入字符串,例如:

char string[80]

fgets(string,sizeof(string),stdin);

stdin表示从标准输入流(通常是键盘输入)中读取字符串。

fets() 函数的原型为:

char *fgets(char *str, int n, FILE *stream);

字符指针和字符数组,这两种声明字符串变量的写法基本是等价的,但是有两个差异。

第一个差异是,指针指向的字符串,在 C 语言内部被当作常量,不能修改字符串本身。

char* s="Hello, world!";
s[0]='z'; // 错误

上面代码使用指针,声明了一个字符串变量,然后修改了字符串的第一个字符。这种写法是错的,会导致难以预测的后果,执行时很可能会报错。

如果使用数组声明字符串变量,就没有这个问题,可以修改数组的任意成员。

char s[]="Hello, world!";
s[0]='z';

为什么字符串声明为指针时不能修改,声明为数组时就可以修改?原因是系统会将字符串的字面量保存在内存的常量区,这个区是不允许用户修改的。声明为指针时,指针变量存储的值是一个指向常量区的内存地址,因此用户不能通过这个地址去修改常量区。但是,声明为数组时,编译器会给数组单独分配一段内存,字符串字面量会被编译器解释成字符数组,逐个字符写入这段新分配的内存之中,而这段新内存是允许修改的。

为了提醒用户,字符串声明为指针后不得修改,可以在声明时使用const说明符,保证该字符串是只读的。

const char* s="Hello, world!";

上面字符串声明为指针时,使用了const说明符,就保证了该字符串无法修改。一旦修改,编译器肯定会报错。

第二个差异是,指针变量可以指向其它字符串。

char* s="hello";
s="world";

上面示例中,字符指针可以指向另一个字符串。

但是,字符数组变量不能指向另一个字符串。

char s[]="hello";
s="world"; // 报错

上面示例中,字符数组的数组名,总是指向初始化时的字符串地址,不能修改。

同样的原因,声明字符数组后,不能直接用字符串赋值。

char s[10];
s="abc"; // 错误

上面示例中,不能直接把字符串赋值给字符数组变量,会报错。原因是字符数组的变量名,跟所指向的数组是绑定的,不能指向另一个地址。

为什么数组变量不能赋值为另一个数组?原因是数组变量所在的地址无法改变,或者说,编译器一旦为数组变量分配地址后,这个地址就绑定这个数组变量了,这种绑定关系是不变的。C 语言也因此规定,数组变量是一个不可修改的左值,即不能用赋值运算符为它重新赋值。

想要重新赋值,必须使用 C 语言原生提供的strcpy()函数,通过字符串拷贝完成赋值。这样做以后,数组变量的地址还是不变的,即strcpy()只是在原地址写入新的字符串,而不是让数组变量指向新的地址。

char s[10];
strcpy(s, "abc");

上面示例中,strcpy()函数把字符串abc拷贝给变量s

10.2 strlen()

strlen()函数返回字符串的字节长度,不包括末尾的空字符\0。该函数的原型如下。

// string.h
size_t strlen(const char* s);

它的参数是字符串变量,返回的是size_t类型的无符号整数,除非是极长的字符串,一般情况下当作int类型处理即可。下面是一个用法实例。

char* str="hello";
int len=strlen(str); // 5

strlen()的原型在标准库的string.h文件中定义,使用时需要加载头文件string.h

#include <stdio.h>
#include <string.h>

int main(void) {
    char* s="Hello, world!";
    printf("The string is %zd characters long.\n", strlen(s));
}

注意,字符串长度(strlen())与字符串变量长度(sizeof()),是两个不同的概念。

char s[50]="hello";
printf("%d\n", strlen(s));    // 5
printf("%d\n", sizeof(s));    // 50

上面示例中,字符串长度是 5,字符串变量长度是 50。

如果不使用这个函数,可以通过判断字符串末尾的\0,自己计算字符串长度。

int my_strlen(char *s) {
    int count=0;
    while (s[count] != '\0')
        count++;
    return count;
}

10.3 strcpy()

字符串的复制,不能使用赋值运算符,直接将一个字符串赋值给字符数组变量。

char str1[10];
char str2[10];

str1="abc"; // 报错
str2=str1;    // 报错

上面两种字符串的复制写法,都是错的。因为数组的变量名是一个固定的地址,不能修改,使其指向另一个地址。

如果是字符指针,赋值运算符(=)只是将一个指针的地址复制给另一个指针,而不是复制字符串。

char* s1;
char* s2;

s1="abc";
s2=s1;

上面代码可以运行,结果是两个指针变量s1s2指向同一字符串,而不是将字符串s1的内容复制给s2

C 语言提供了strcpy()函数,用于将一个字符串的内容复制到另一个字符串,相当于字符串赋值。该函数的原型定义在string.h头文件里面。

strcpy(char dest[], const char source[])

strcpy()接受两个参数,第一个参数是目的字符串数组,第二个参数是源字符串数组。复制字符串之前,必须要保证第一个参数的长度不小于第二个参数,否则虽然不会报错,但会溢出第一个字符串变量的边界,发生难以预料的结果。第二个参数的const说明符,表示这个函数不会修改第二个字符串。

#include <stdio.h>
#include <string.h>

int main(void) {
    char s[]="Hello, world!";
    char t[100];

    strcpy(t, s);

    t[0]='z';
    printf("%s\n", s);    // "Hello, world!"
    printf("%s\n", t);    // "zello, world!"
}

上面示例将变量s的值,拷贝一份放到变量t,变成两个不同的字符串,修改一个不会影响到另一个。另外,变量t的长度大于s,复制后多余的位置(结束标志\0后面的位置)都为随机值。

strcpy()也可以用于字符数组的赋值。

char str[10];
strcpy(str, "abcd");

上面示例将字符数组变量,赋值为字符串“abcd”。

strcpy()的返回值是一个字符串指针(即char*),指向第一个参数。

char* s1="beast";
char s2[40]="Be the best that you can be.";
char* ps;

ps=strcpy(s2+7, s1);    //把 s1 复制到 s2 的第 7 个字符的位置,即和'b'替换

puts(s2); // Be the beast
puts(ps); // beast

上面示例中,从s2的第 7 个位置开始拷贝字符串 beast,前面的位置不变。这导致s2后面的内容都被截去了,因为会连 beast 结尾的空字符一起拷贝。strcpy()返回的是一个指针,指向拷贝开始的位置。

strcpy()返回值的另一个用途,是连续为多个字符数组赋值。

strcpy(str1, strcpy(str2, "abcd"));

上面示例调用两次strcpy(),完成两个字符串变量的赋值。

另外,strcpy()的第一个参数最好是一个已经声明的数组,而不是声明后没有进行初始化的字符指针。

char* str;
strcpy(str, "hello world"); // 错误

上面的代码是有问题的。strcpy()将字符串分配给指针变量str,但是str并没有进行初始化,指向的是一个随机的位置,因此字符串可能被复制到任意地方。

如果不用strcpy(),自己实现字符串的拷贝,可以用下面的代码。

char* strcpy(char* dest, const char* source) {
    char* ptr=dest;
    while (*dest++=*source++);
    return ptr;
}

int main(void) {
    char str[25];
    strcpy(str, "hello world");
    printf("%s\n", str);
    return 0;
}

上面代码中,关键的一行是while (*dest++=*source++),这是一个循环,依次将source的每个字符赋值给dest,然后移向下一个位置,直到遇到\0,循环判断条件不再为真,从而跳出循环。其中,*dest++这个表达式等同于*(dest++),即先返回dest这个地址,再进行自增运算移向下一个位置,而*dest可以对当前位置赋值。

strcpy()函数有安全风险,因为它并不检查目标字符串的长度,是否足够容纳源字符串的副本,可能导致写入溢出。如果不能保证不会发生溢出,建议使用strncpy()函数代替。

10.4 strncpy()

strncpy()strcpy()的用法完全一样,只是多了第 3 个参数,用来指定复制的最大字符数,防止溢出目标字符串变量的边界。

char* strncpy(
    char* dest, 
    char* src, 
    size_t n
);

上面原型中,第三个参数n定义了复制的最大字符数。如果达到最大字符数以后,源字符串仍然没有复制完,就会停止复制,这时目的字符串结尾将没有终止符\0,这一点务必注意。如果源字符串的字符数小于n,则strncpy()的行为与strcpy()完全一致。

strncpy(str1, str2, sizeof(str1)-1);
str1[sizeof(str1)-1]='\0';

上面示例中,字符串str2复制给str1,但是复制长度最多为str1的长度减去 1,str1剩下的最后一位用于写入字符串的结尾标志\0。这是因为strncpy()不会自己添加\0,如果复制的字符串片段不包含结尾标志,就需要手动添加。

strncpy()也可以用来拷贝部分字符串。

char s1[40];
char s2[12]="hello world";

strncpy(s1, s2, 5);
s1[5]='\0';

printf("%s\n", s1); // hello

上面示例中,指定只拷贝前 5 个字符。

10.5 strcat()

strcat()函数用于连接字符串。它接受两个字符串作为参数,把第二个字符串的副本添加到第一个字符串的末尾。这个函数会改变第一个字符串,但是第二个字符串不变。

该函数的原型定义在string.h头文件里面。

char* strcat(char* s1, const char* s2);

strcat()的返回值是一个字符串指针,指向第一个参数。

char s1[12]="hello";
char s2[6]="world";

strcat(s1, s2);
puts(s1); // "helloworld"

上面示例中,调用strcat()以后,可以看到字符串s1的值变了。

注意,strcat()的第一个参数的长度,必须足以容纳添加第二个参数字符串。否则,拼接后的字符串会溢出第一个字符串的边界,写入相邻的内存单元,这是很危险的,建议使用下面的strncat()代替。

10.6 strncat()

strncat()用于连接两个字符串,用法与strcat()完全一致,只是增加了第三个参数,指定最大添加的字符数。在添加过程中,一旦达到指定的字符数,或者在源字符串中遇到空字符\0,就不再添加了。它的原型定义在string.h头文件里面。

char* strncat(
    const char* dest,
    const char* src,
    size_t n
);

strncat()返回第一个参数,即目标字符串指针。

为了保证连接后的字符串,不超过目标字符串的长度,strncat()通常会写成下面这样。

strncat(
    str1, 
    str2, 
    sizeof(str1)-strlen(str1)-1
);

strncat()总是会在拼接结果的结尾,自动添加空字符\0,所以第三个参数的最大值,应该是str1的变量长度减去str1的字符串长度,再减去 1。下面是一个用法实例。

char s1[10]="Monday";
char s2[8]="Tuesday";

strncat(s1, s2, 3);
puts(s1); // "MondayTue"

上面示例中,s1的变量长度是 10,字符长度是 6,两者相减后再减去 1,得到 3,表明s1最多可以再添加三个字符,所以得到的结果是 MondayTue。

10.7 strcmp()

如果要比较两个字符串,无法直接比较,只能一个个字符进行比较,C 语言提供了strcmp()函数。

strcmp()函数用于比较两个字符串的内容。该函数的原型如下,定义在string.h头文件里面。

int strcmp(const char* s1, const char* s2);

按照字典顺序,如果两个字符串相同,返回值为 0;如果s1小于s2strcmp()返回值小于 0;如果s1大于s2,返回值大于 0。

下面是一个用法示例。

// s1=Happy New Year
// s2=Happy New Year
// s3=Happy Holidays

strcmp(s1, s2) // 0
strcmp(s1, s3) // 大于 0
strcmp(s3, s1) // 小于 0

注意,strcmp()只用来比较字符串,不用来比较字符。因为字符就是小整数,直接用相等运算符(==)就能比较。所以,不要把字符类型(char)的值,放入strcmp()当作参数。

strcmp()函数的比较规则:如果两个字符串的所有字符都相同,并且同时到达字符串的结束符(\0),则返回 0,表示两个字符串相等。如果遇到不同的字符,则返回这两个字符的差值(即str1[i]-str2[i])。这个差值是一个整数,表示两个字符在字符集中的顺序差异。

10.8 strncmp()

由于strcmp()比较的是整个字符串,C 语言又提供了strncmp()函数,只比较到指定的位置。

该函数增加了第三个参数,指定了比较的字符数。它的原型定义在string.h头文件里面。

int strncmp(
    const char* s1,
    const char* s2, 
    size_t n
);

它的返回值与strcmp()一样。如果两个字符串相同,返回值为 0;如果s1小于s2strcmp()返回值小于 0;如果s1大于s2,返回值大于 0。

下面是一个例子。

char s1[12]="hello world";
char s2[12]="hello C";

if (strncmp(s1, s2, 5)==0) {
    printf("They all have hello.\n");
}

上面示例只比较两个字符串的前 5 个字符。

10.9 sprintf() 和 snprintf()

sprintf()函数跟printf()类似,但是用于将数据写入字符串,而不是输出到显示器。该函数的原型定义在stdio.h头文件里面。

int sprintf(char* s, const char* format, ...);

sprintf()的第一个参数是字符串指针变量,其余参数和printf()相同,即第二个参数是格式字符串,后面的参数是待写入的变量列表。

char first[6]="hello";
char last[6]="world";
char s[40];

sprintf(s, "%s %s", first, last);

printf("%s\n", s); // hello world

上面示例中,sprintf()将输出内容组合成“hello world”,然后放入了变量s

sprintf()的返回值是写入变量的字符数量(不计入尾部的空字符\0)。如果遇到错误,返回负值。

sprintf()有严重的安全风险,如果写入的字符串过长,超过了目标字符串的长度,sprintf()依然会将其写入,导致发生溢出。为了控制写入的字符串的长度,C 语言又提供了另一个函数snprintf()

snprintf()只比sprintf()多了一个参数 n,用来控制写入变量的字符串不超过n-1个字符,剩下一个位置写入空字符\0。下面是它的原型。

int snprintf(char*s, size_t n, const char* format, ...);

snprintf()总是会自动写入字符串结尾的空字符。如果你尝试写入的字符数超过指定的最大字符数,snprintf()会写入n-1个字符,留出最后一个位置写入空字符。

下面是一个例子。

snprintf(s, 12, "%s %s", "hello", "world");

上面的例子中,snprintf()的第二个参数是 12,表示写入字符串的最大长度不超过 12(包括尾部的空字符)。

snprintf()的返回值是写入格式字符串的字符数量(不计入尾部的空字符、0)。如果n足够大,返回值应该小于n,但是有时候格式字符串的长度可能大于n,那么这时返回值会大于n,但实际上真正写入变量的还是n-1个字符。如果遇到错误,返回一个负值。因此,返回值只有在非负并且小于n时,才能确认完整的格式字符串写入了变量。

10.10 字符串数组

如果一个数组的每个成员都是一个字符串,需要通过二维的字符数组实现。每个字符串本身是一个字符数组,多个字符串再组成一个数组。

char weekdays[7][10]={
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
    "Sunday"
};

上面示例就是一个字符串数组,一共包含 7 个字符串,所以第一维的长度是 7。其中,最长的字符串的长度是 10(含结尾的终止符\0),所以第二维的长度统一设为 10。

因为第一维的长度,编译器可以自动计算,所以可以省略。

char weekdays[][10]={
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
    "Sunday"
};

上面示例中,二维数组第一维的长度,可以由编译器根据后面的赋值,自动计算,所以可以不写。

数组的第二维,长度统一定为 10,有点浪费空间,因为大多数成员的长度都小于 10。解决方法就是把数组的第二维,从字符数组改成字符指针。

char* weekdays[]={
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
    "Sunday"
};

上面的字符串数组,其实是一个一维数组,成员就是 7 个字符指针,每个指针指向一个字符串(字符数组)。

遍历字符串数组的写法如下。

for (int i=0; i<7; i++) {
    printf("%s\n", weekdays[i]);
}

11 struct 结构

C 语言内置的数据类型,除了最基本的几种原始类型,只有数组属于复合类型,可以同时包含多个值,但是只能包含相同类型的数据,实际使用中并不够用。

实际使用中,主要有下面两种情况,需要更灵活强大的复合类型。

为了解决这些问题,C 语言提供了struct关键字,允许自定义复合数据类型,将不同类型的值组合在一起。这样不仅为编程提供方便,也有利于增强代码的可读性。C 语言没有其他语言的对象(object)和类(class)的概念,struct 结构很大程度上提供了对象和类的功能。

下面是struct自定义数据类型的一个例子。

struct fraction {
    int numerator;
    int denominator;
};

上面示例定义了一个分数的数据类型struct fraction,包含两个属性numeratordenominator

注意,作为一个自定义的数据类型,它的类型名要包括struct关键字,比如上例是struct fraction,单独的fraction没有任何意义,甚至脚本还可以另外定义名为fraction的变量,虽然这样很容易造成混淆。另外,struct语句结尾的分号不能省略,否则很容易产生错误。

定义了新的数据类型以后,就可以声明该类型的变量,这与声明其他类型变量的写法是一样的。

struct fraction f1;

f1.numerator = 22;
f1.denominator = 7;

上面示例中,先声明了一个struct fraction类型的变量f1,这时编译器就会为f1分配内存,接着就可以为f1的不同属性赋值。可以看到,struct 结构的属性通过点(.)来表示,比如numerator属性要写成f1.numerator

再提醒一下,声明自定义类型的变量时,类型名前面,不要忘记加上struct关键字。也就是说,必须使用struct fraction f1声明变量,不能写成fraction f1

除了逐一对属性赋值,也可以使用大括号,一次性对 struct 结构的所有属性赋值。

struct car {
    char* name;
    float price;
    int speed;
};

struct car saturn = {"Saturn SL/2", 16000.99, 175};

上面示例中,变量saturnstruct car类型,大括号里面同时对它的三个属性赋值。如果大括号里面的值的数量,少于属性的数量,那么缺失的属性自动初始化为0

注意,大括号里面的值的顺序,必须与 struct 类型声明时属性的顺序一致。否则,必须为每个值指定属性名。

struct car saturn = {.speed=172, .name="Saturn SL/2"};

上面示例中,初始化的属性少于声明时的属性,这时剩下的那些属性都会初始化为0

声明变量以后,可以修改某个属性的值。

struct car saturn = {.speed=172, .name="Saturn SL/2"};
saturn.speed = 168;

上面示例将speed属性的值改成168

struct 的数据类型声明语句与变量的声明语句,可以合并为一个语句。

struct book {
    char title[500];
    char author[100];
    float value;
} b1;

上面的语句同时声明了数据类型book和该类型的变量b1。如果类型标识符book只用在这一个地方,后面不再用到,这里可以将类型名省略。

struct {
    char title[500];
    char author[100];
    float value;
} b1;

上面示例中,struct声明了一个匿名数据类型,然后又声明了这个类型的变量b1

与其他变量声明语句一样,可以在声明变量的同时,对变量赋值。

struct {
    char title[500];
    char author[100];
    float value;
} b1 = {"Harry Potter", "J. K. Rowling", 10.0},
    b2 = {"Cancer Ward", "Aleksandr Solzhenitsyn", 7.85};

上面示例中,在声明变量b1b2的同时,为它们赋值。

typedef命令可以为 struct 结构指定一个别名,这样使用起来更简洁。

typedef struct cell_phone {
    int cell_no;
    float minutes_of_charge;
} phone;

phone p = {5551234, 5};

上面示例中,phone就是struct cell_phone的别名。

指针变量也可以指向struct结构。

struct book {
    char title[500];
    char author[100];
    float value;
}* b1;

// 或者写成两个语句
struct book {
    char title[500];
    char author[100];
    float value;
};
struct book* b1;

上面示例中,变量b1是一个指针,指向的数据是struct book类型的实例。

struct 结构也可以作为数组成员。

struct fraction numbers[1000];

numbers[0].numerator = 22;
numbers[0].denominator = 7;

上面示例声明了一个有 1000 个成员的数组numbers,每个成员都是自定义类型fraction的实例。

struct 结构占用的存储空间,不是各个属性存储空间的总和,而是最大内存占用属性的存储空间的倍数,其他属性会添加空位与之对齐。这样可以提高读写效率。

struct foo {
    int a;
    char* b;
    char c;
};
printf("%d\n", sizeof(struct foo)); // 24

上面示例中,struct foo有三个属性,在 64 位计算机上占用的存储空间分别是:int a占 4 个字节,指针char* b占 8 个字节,char c占 1 个字节。它们加起来,一共是 13 个字节(4 + 8 + 1)。但是实际上,struct foo会占用 24 个字节,原因是它最大的内存占用属性是char* b的 8 个字节,导致其他属性的存储空间也是 8 个字节,这样才可以对齐,导致整个struct foo就是 24 个字节(8 * 3)。

多出来的存储空间,都采用空位填充,所以上面的struct foo真实的结构其实是下面这样。

struct foo {
    int a;                // 4
    char pad1[4]; // 填充 4 字节
    char *b;            // 8
    char c;             // 1
    char pad2[7]; // 填充 7 字节
};
printf("%d\n", sizeof(struct foo)); // 24

为什么浪费这么多空间进行内存对齐呢?这是为了加快读写速度,把内存占用划分成等长的区块,就可以快速在 struct 结构体中定位到每个属性的起始地址。

由于这个特性,在有必要的情况下,定义 Struct 结构体时,可以采用存储空间递增的顺序,定义每个属性,这样就能节省一些空间。

struct foo {
    char c;
    int a;
    char* b;
};
printf("%d\n", sizeof(struct foo)); // 16

上面示例中,占用空间最小的char c排在第一位,其次是int a,占用空间最大的char* b排在最后。整个strct foo的内存占用就从 24 字节下降到 16 字节。

11.1 struct 的复制

struct 变量可以使用赋值运算符(=),复制给另一个变量,这时会生成一个全新的副本。系统会分配一块新的内存空间,大小与原来的变量相同,把每个属性都复制过去,即原样生成了一份数据。这一点跟数组的复制不一样,务必小心。

struct cat { char name[30]; short age; } a, b;

strcpy(a.name, "Hula");
a.age = 3;

b = a;
b.name[0] = 'M';

printf("%s\n", a.name); // Hula
printf("%s\n", b.name); // Mula

上面示例中,变量b是变量a的副本,两个变量的值是各自独立的,修改掉b.name不影响a.name

上面这个示例是有前提的,就是 struct 结构的属性必须定义成字符数组,才能复制数据。如果稍作修改,属性定义成字符指针,结果就不一样。

struct cat { char* name; short age; } a, b;

a.name = "Hula";
a.age = 3;

b = a;

上面示例中,name属性变成了一个字符指针,这时a赋值给b,导致b.name也是同样的字符指针,指向同一个地址,也就是说两个属性共享同一个地址。因为这时,struct 结构内部保存的是一个指针,而不是上一个例子的数组,这时复制的就不是字符串本身,而是它的指针。并且,这个时候也没法修改字符串,因为字符指针指向的字符串是不能修改的。

总结一下,赋值运算符(=)可以将 struct 结构每个属性的值,一模一样复制一份,拷贝给另一个 struct 变量。这一点跟数组完全不同,使用赋值运算符复制数组,不会复制数据,只会共享地址。

注意,这种赋值要求两个变量是同一个类型,不同类型的 struct 变量无法互相赋值。

另外,C 语言没有提供比较两个自定义数据结构是否相等的方法,无法用比较运算符(比如==!=)比较两个数据结构是否相等或不等。

11.2 struct 指针

如果将 struct 变量传入函数,函数内部得到的是一个原始值的副本。

#include <stdio.h>

struct turtle {
    char* name;
    char* species;
    int age;
};

void happy(struct turtle t) {
    t.age = t.age + 1;
}

int main() {
    struct turtle myTurtle = {"MyTurtle", "sea turtle", 99};
    happy(myTurtle);
    printf("Age is %i\n", myTurtle.age); // 输出 99
    return 0;
}

上面示例中,函数happy()传入的是一个 struct 变量myTurtle,函数内部有一个自增操作。但是,执行完happy()以后,函数外部的age属性值根本没变。原因就是函数内部得到的是 struct 变量的副本,改变副本影响不到函数外部的原始数据。

通常情况下,开发者希望传入函数的是同一份数据,函数内部修改数据以后,会反映在函数外部。而且,传入的是同一份数据,也有利于提高程序性能。这时就需要将 struct 变量的指针传入函数,通过指针来修改 struct 属性,就可以影响到函数外部。

struct 指针传入函数的写法如下。

void happy(struct turtle* t) {
}

happy(&myTurtle);

上面代码中,t是 struct 结构的指针,调用函数时传入的是指针。struct 类型跟数组不一样,类型标识符本身并不是指针,所以传入时,指针必须写成&myTurtle

函数内部也必须使用(*t).age的写法,从指针拿到 struct 结构本身。

void happy(struct turtle* t) {
    (*t).age = (*t).age + 1;
}

上面示例中,(*t).age不能写成*t.age,因为点运算符.的优先级高于**t.age这种写法会将t.age看成一个指针,然后取它对应的值,会出现无法预料的结果。

现在,重新编译执行上面的整个示例,happy()内部对 struct 结构的操作,就会反映到函数外部。

(*t).age这样的写法很麻烦。C 语言就引入了一个新的箭头运算符(->),可以从 struct 指针上直接获取属性,大大增强了代码的可读性。

void happy(struct turtle* t) {
    t->age = t->age + 1;
}

总结一下,对于 struct 变量名,使用点运算符(.)获取属性;对于 struct 变量指针,使用箭头运算符(->)获取属性。以变量myStruct为例,假设ptr是它的指针,那么下面三种写法是同一回事。

// ptr == &myStruct
myStruct.prop == (*ptr).prop == ptr->prop

11.3 struct 的嵌套

struct 结构的成员可以是另一个 struct 结构。

struct species {
    char* name;
    int kinds;
};

struct fish {
    char* name;
    int age;
    struct species breed;
};

上面示例中,fish的属性breed是另一个 struct 结构species

赋值的时候有多种写法。

// 写法一
struct fish shark = {"shark", 9, {"Selachimorpha", 500}};

// 写法二
struct species myBreed = {"Selachimorpha", 500};
struct fish shark = {"shark", 9, myBreed};

// 写法三
struct fish shark = {
    .name="shark",
    .age=9,
    .breed={"Selachimorpha", 500}
};

// 写法四
struct fish shark = {
    .name="shark",
    .age=9,
    .breed.name="Selachimorpha",
    .breed.kinds=500
};

printf("Shark's species is %s", shark.breed.name);

上面示例展示了嵌套 Struct 结构的四种赋值写法。另外,引用breed属性的内部属性,要使用两次点运算符(shark.breed.name)。

下面是另一个嵌套 struct 的例子。

struct name {
    char first[50];
    char last[50];
};

struct student {
    struct name name;
    short age;
    char sex;
} student1;

strcpy(student1.name.first, "Harry");
strcpy(student1.name.last, "Potter");

// or
struct name myname = {"Harry", "Potter"};
student1.name = myname;

上面示例中,自定义类型studentname属性是另一个自定义类型,如果要引用后者的属性,就必须使用两个.运算符,比如student1.name.first。另外,对字符数组属性赋值,要使用strcpy()函数,不能直接赋值,因为直接改掉字符数组名的地址会报错。

struct 结构内部不仅可以引用其他结构,还可以自我引用,即结构内部引用当前结构。比如,链表结构的节点就可以写成下面这样。

struct node {
    int data;
    struct node* next;
};

上面示例中,node结构的next属性,就是指向另一个node实例的指针。下面,使用这个结构自定义一个数据链表。

struct node {
    int data;
    struct node* next;
};

struct node* head;

// 生成一个三个节点的列表 (11)->(22)->(33)
head = malloc(sizeof(struct node));

head->data = 11;
head->next = malloc(sizeof(struct node));

head->next->data = 22;
head->next->next = malloc(sizeof(struct node));

head->next->next->data = 33;
head->next->next->next = NULL;

// 遍历这个列表
for (struct node *cur = head; cur != NULL; cur = cur->next) {
    printf("%d\n", cur->data);
}

上面示例是链表结构的最简单实现,通过for循环可以对其进行遍历。

11.4 位字段

struct 还可以用来定义二进制位组成的数据结构,称为“位字段”(bit field),这对于操作底层的二进制数据非常有用。

struct {
    unsigned int ab:1;
    unsigned int cd:1;
    unsigned int ef:1;
    unsigned int gh:1;
} synth;

synth.ab = 0;
synth.cd = 1;

上面示例中,每个属性后面的:1,表示指定这些属性只占用一个二进制位,所以这个数据结构一共是 4 个二进制位。

注意,定义二进制位时,结构内部的各个属性只能是整数类型。

实际存储的时候,C 语言会按照int类型占用的字节数,存储一个位字段结构。如果有剩余的二进制位,可以使用未命名属性,填满那些位。也可以使用宽度为 0 的属性,表示占满当前字节剩余的二进制位,迫使下一个属性存储在下一个字节。

struct {
    unsigned int field1 : 1;
    unsigned int                : 2;
    unsigned int field2 : 1;
    unsigned int                : 0;
    unsigned int field3 : 1;
} stuff;

上面示例中,stuff.field1stuff.field2之间,有一个宽度为两个二进制位的未命名属性。stuff.field3将存储在下一个字节。

11.5 弹性数组成员

很多时候,不能事先确定数组到底有多少个成员。如果声明数组的时候,事先给出一个很大的成员数,就会很浪费空间。C 语言提供了一个解决方法,叫做弹性数组成员(flexible array member)。

如果不能事先确定数组成员的数量时,可以定义一个 struct 结构。

struct vstring {
    int len;
    char chars[];
};

上面示例中,struct vstring结构有两个属性。len属性用来记录数组chars的长度,chars属性是一个数组,但是没有给出成员数量。

chars数组到底有多少个成员,可以在为vstring分配内存时确定。

struct vstring* str = malloc(sizeof(struct vstring) + n * sizeof(char));
str->len = n;

上面示例中,假定chars数组的成员数量是n,只有在运行时才能知道n到底是多少。然后,就为struct vstring分配它需要的内存:它本身占用的内存长度,再加上n个数组成员占用的内存长度。最后,len属性记录一下n是多少。

这样就可以让数组charsn个成员,不用事先确定,可以跟运行时的需要保持一致。

弹性数组成员有一些专门的规则。首先,弹性成员的数组,必须是 struct 结构的最后一个属性。另外,除了弹性数组成员,struct 结构必须至少还有一个其他属性。