原版来自 《C 语言教程》,此处仅作修正与补充。
%a
:十六进制浮点数,字母输出为小写。%A
:十六进制浮点数,字母输出为大写。%c
:字符。%d
:十进制整数。%e
:使用科学计数法的浮点数,指数部分的 e 为小写。%E
:使用科学计数法的浮点数,指数部分的 E 为大写。%i
:整数,基本等同于%d。%f
:小数(包含 float 类型和 double 类型)。%g
:6 个有效数字的浮点数。整数部分一旦超过 6 位,就会自动转为科学计数法,指数部分的 e 为小写。%G
:等同于%g,唯一的区别是指数部分的 E 为大写。%hd
:十进制 short int 类型。%ho
:八进制 short int 类型。%hx
:十六进制 short int 类型。%hu
:unsigned short int 类型。%ld
:十进制 long int 类型。%lo
:八进制 long int 类型。%lx
:十六进制 long int 类型。%lu
:unsigned long int 类型。%lld
:十进制 long long int 类型。%llo
:八进制 long long int 类型。%llx
:十六进制 long long int 类型。%llu
:unsigned long long int 类型。%Le
:科学计数法表示的 long double 类型浮点数。%Lf
:long double 类型浮点数。%n
:已输出的字符串数量。该占位符本身不输出,只将值存储在指定变量之中。%o
:八进制整数。%p
:指针。%s
:字符串。%u
:无符号整数(unsigned int
)。%x
:十六进制整数。%zd
:size_t 类型。%%
:输出一个百分号。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
extern
)auto
):使用时临时分配内存,结束后释放static
):使用后不释放register
):存储于寄存器,速度快一般把建立存储空间的声明称为定义,不需要建立存储空间的声明称为声明。
+
:正值运算符(一元运算符)-
:负值运算符(一元运算符)+
:加法运算符(二元运算符)-
:减法运算符(二元运算符)*
:乘法运算符/
:除法运算符 整数除法是整除,只会返回整数部分,丢弃小数部分。如果希望得到浮点数的结果,两个运算数必须至少有一个浮点数%
:余值运算符 只能用于整数,不能用于浮点数++
:自增运算符--
:自减运算符>
:大于运算符<
:小于运算符>=
:大于等于运算符<=
:小于等于运算符==
:相等运算符!=
:不相等运算符!
:否运算符(改变单个表达式的真伪)。&&
:与运算符(两侧的表达式都为真,则为真,否则为伪)。||
:或运算符(两侧至少有一个表达式为真,则为真,否则为伪)。对于逻辑运算符来说,任何非零值都表示真,零值表示伪。逻辑运算符总是先对左侧的表达式求值,再对右边的表达式求值,这个顺序是保证的。
~
:取反运算符&
:与运算符|
:或运算符^
:异或运算符<<
:左移运算符>>
:右移运算符逗号运算符用于将多个表达式写在一起,从左到右依次运行每个表达式。
()
)++
),自减运算符(--
)+
和-
)*
),除法(/
)+
),减法(-
)<
和>
等)=
)如果有多个if
和else
,可以记住这样一条规则,else
总是跟最接近的if
匹配,这样很容易出错,为了提供代码的可读性,建议使用大括号,明确else
匹配哪一个if
<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
。
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
,就会导致继续执行下一个case
或default
分支。
while
语句用于循环结构,满足条件时,不断执行循环体。
do...while
结构是while
的变体,它会先执行一次循环体,然后再判断是否满足条件。如果满足的话,就继续执行循环体,否则跳出循环。
for
语句是最常用的循环结构,通常用于精确控制循环次数。for
的三个表达式都不是必需的,甚至可以全部省略,这会形成无限循环。
for (initialization; continuation; action)
statement;
initialization
:初始化表达式,用于初始化循环变量,只执行一次。continuation
:判断表达式,只要为 true,就会不断执行循环体。action
:循环变量处理表达式,每轮循环结束后执行,使得循环变量发生变化。break
语句有两种用法。一种是与switch
语句配套使用,用来中断某个分支的执行,这种用法前面已经介绍过了。另一种用法是在循环体内部跳出循环,不再进行后面的循环了。
continue
语句用于在循环体内部终止本轮循环,进入下一轮循环。只要遇到continue
语句,循环体内部后面的语句就不执行了,回到循环体的头部,开始执行下一轮循环。
goto
语句用于跳到指定的标签名。这会破坏结构化编程,建议不要轻易使用
char ch;
top: ch=getchar();
if (ch=='q')
goto top;
上面示例中,top
是一个标签名,可以放在正常语句的前面,相当于为这行语句做了一个标记。程序执行到goto
语句,就会跳转到它指定的标签名。goto
的一个主要用法是跳出多层循环,另一个用途是提早结束多重判断。
char
关键字。字符常量必须放在单引号里面。字符类型在不同计算机的默认范围是不一样的。一些系统默认为-128
到127
,另一些系统默认为 0 到255
。这两种范围正好都能覆盖 0 到127
的 ASCII 字符范围。只要在字符类型的范围之内,整数与字符是可以互换的,都可以赋值给字符类型的变量。转义的写法,主要用来表示 ASCII 码定义的一些无法打印的控制字符,它们也属于字符类型的值。
\a
:警报,这会使得终端发出警报声或出现闪烁,或者两者同时发生。\b
:退格键,光标回退一个字符,但不删除字符。\f
:换页符,光标移到下一页。在现代系统上,这已经反映不出来了,行为改成类似于、v。\n
:换行符。\r
:回车符,光标移到同一行的开头。\t
:制表符,光标移到下一个水平制表位,通常是下一个 8 的倍数。\v
:垂直分隔符,光标移到下一个垂直制表位,通常是下一行的同一列。\0
:null
字符,代表没有内容。注意,这个值不等于数字 0。
转义写法还能使用八进制和十六进制表示一个字符。\nn
:字符的八进制写法,nn
为八进制值。\xnn
:字符的十六进制写法,nn
为十六进制值。不同计算机的int
类型的大小是不一样的。比较常见的是使用 4 个字节(32 位)存储一个int
类型的值,但是 2 个字节(16 位)或 8 个字节(64 位)也有可能使用。它们可以表示的整数范围如下。
-2^15
到2^15-1
,即-32,768
到32,767
。-2^31
到2^31-1
,即-2,147,483,648
到2,147,483,647
。-2^63
到2^63-1
,即-9,223,372,036,854,775,808
到9,223,372,036,854,775,807
。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
也可以设置signed
和unsigned
。
注意,C 语言规定char
类型默认是否带有正负号,由当前系统决定。这就是说,char
不等同于signed char
,它有可能是signed char
,也有可能是unsigned char
。这一点与int
不同,int
就是等同于signed int
。
short int
(简写为short
):占用空间不多于int
,一般占用 2 个字节(整数范围为-32768~32767
)。
long int
(简写为long
):占用空间不少于int
,至少为 4 个字节。
long long int
(简写为long long
):占用空间多于long
,至少为 8 个字节。
默认情况下,short
、long
、long long
都是带符号的(signed
),即signed
关键字省略了。它们也可以声明为不带符号(unsigned
),使得能够表示的最大值扩大一倍。
有时候需要查看,当前系统不同整数类型的最大值和最小值,C 语言的头文件limits.h
提供了相应的常量,比如SCHAR_MIN
代表 signed char
类型的最小值-128
,SCHAR_MAX
代表signed char
类型的最大值127
。
为了代码的可移植性,需要知道某种整数类型的极限值时,应该尽量使用这些常量。
SCHAR_MIN,SCHAR_MAX
:signed char
的最小值和最大值。SHRT_MIN
,SHRT_MAX:short 的最小值和最大值。INT_MIN
,INT_MAX:int 的最小值和最大值。LONG_MIN
,LONG_MAX:long 的最小值和最大值。LLONG_MIN
,LLONG_MAX:long long 的最小值和最大值。UCHAR_MAX
:unsigned char 的最大值。USHRT_MAX
:unsigned short 的最大值。UINT_MAX
:unsigned int 的最大值。ULONG_MAX
:unsigned long 的最大值。ULLONG_MAX
:unsigned long long 的最大值。printf() 的进制相关占位符如下。
任何有小数点的数值,都会被编译器解释为浮点数。所谓“浮点数”就是使用m * b
的 e 次方 的形式,存储一个数值,m 是小数部分,b 是基数(通常是 2),e 是指数部分。这种形式是精度和数值范围的一种结合,可以表示非常大或者非常小的数。
float
类型占用 4 个字节(32 位),其中 8 位存放指数的值和符号,剩下 24 位存放小数的值和符号。float 类型至少能够提供(十进制的)6 位有效数字,指数部分的范围为(十进制的)-37
到37
,即数值范围为 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
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>
字面量(literal)指的是代码里面直接出现的值。
int x=123;
上面代码中,x
是变量,123 就是字面量。
编译时,字面量也会写入内存,因此编译器必须为字面量指定数据类型,就像必须为变量指定数据类型一样。
一般情况下,十进制整数字面量(比如 123)会被编译器指定为int
类型。如果一个数值比较大,超出了int
能够表示的范围,编译器会将其指定为long int
。如果数值超过了long int
,会被指定为unsigned long
。如果还不够大,就指定为long long
或unsigned long long
。
小数(比如 3.14)会被指定为 double 类型
字面量后缀:
float
类型。long int
类型,对于小数是long double
类型。LL
:Long Long
类型,比如3LL
。unsigned int
,比如15U
、0377U
。u 还可以与其他整数后缀结合,放在前面或后面都可以,比如 10UL、10ULL 和 10LLU 都是合法的。
每一种数据类型都有数值范围,如果存放的数值超出了这个范围(小于最小值或大于最大值),需要更多的二进制位存储,就会发生溢出。大于最大值,叫做向上溢出(overflow
);小于最小值,叫做向下溢出(underflow
)。
一般来说,编译器不会对溢出报错,会正常执行代码,但是会忽略多出来的二进制位,只保留剩下的位,这样往往会得到意想不到的结果。所以,应该避免溢出。
sizeof
是 C 语言提供的一个运算符,返回某种数据类型或某个值占用的字节数量。它的参数可以是数据类型的关键字,也可以是变量名或某个具体的值。
不同类型的值进行混合计算时,必须先转成同一个类型,才能进行计算。
float
转为double
,double
转为long double
。short
转为int,int
转为 long 等,有时还会将带符号的类型signed
转为无符号unsigned
。最好避免无符号整数与有符号整数的混合运算。因为这时 C 语言会自动将signed int
转为unsigned int
,可能不会得到预期的结果。两个相同类型的整数运算时,或者单个整数的运算,一般来说,运算结果也属于同一类型。但是有一个例外,宽度小于 int 的类型,运算结果会自动提升为 int。
函数的参数和返回值,会自动转成函数定义里指定的类型。参数变量不管原来的类型是什么,都会转成函数定义的参数类型。
原则上,应该避免类型的自动转换,防止出现意料之外的结果。C 语言提供了类型的显式转换,允许手动转换类型。
只要在一个值或变量的前面,使用圆括号指定类型 (type),就可以将这个值或变量转为指定的类型,这叫做“类型指定”(casting)。
(unsigned char) ch
long int y=(long int) 10+12;
C 语言的整数类型(short
、int
、long
)在不同计算机上,占用的字节宽度可能是不一样的,无法提前知道它们到底占用多少个字节。
程序员有时控制准确的字节宽度,这样的话,代码可以有更好的可移植性,头文件stdint.h
创造了一些新的类型别名。
保证某个整数类型的宽度是确定的。
上面这些都是类型别名,编译器会指定它们指向的底层类型。比如,某个系统中,如果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 位的宽度。
保证某个整数类型的最小长度。
上面这些类型,可以保证占据的字节不少于指定宽度。比如,int_least8_t
表示可以容纳 8 位有符号整数的最小宽度的类型。
可以使整数计算达到最快的类型。
上面这些类型是保证字节宽度的同时,追求最快的运算速度,比如int_fast8_t
表示对于 8 位有符号整数,运算速度最快的类型。这是因为某些机器对于特定宽度的数据,运算速度最快,举例来说,32 位计算机对于 32 位数据的运算速度,会快于 16 位数据。
intmax_t
:可以存储任何有效的有符号整数的类型。uintmax_t
:可以存放任何有效的无符号整数的类型。上面的这两个类型的宽度比long long
和unsigned long
更大。
指针是一个值,这个值代表一个内存地址,指针相当于指向某个内存地址的路标。
字符*
表示指针,通常跟在类型关键字的后面,表示指针指向的是什么类型的值。比如,char *
表示一个指向字符的指针,float *
表示一个指向 float 类型的值的指针。星号可以放在变量名与类型关键字之间的任何地方,下面的写法都是有效的。
int* intPtr;
如果同一行声明两个指针变量,那么需要写成下面这样:
// 正确
int * foo, * bar;
// 错误
int* foo, bar;
一个指针指向的可能还是指针,这时就要用两个星号**表示。
int** foo;
*
这个符号除了表示指针以外,还可以作为运算符,用来取出指针变量所指向的内存地址里面的值。
void increment(int* p) {
*p=*p+1;
}
上面示例中,函数 increment() 的参数是一个整数指针p
。函数体里面,*p
就表示指针p
所指向的那个值。对*p
赋值,就表示改变指针所指向的那个地址里面的值。
&运算符用来取出一个变量所在的内存地址。
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;
}
声明指针变量之后,编译器会为指针变量本身分配一个内存空间,但是这个内存空间里面的值是随机的,也就是说,指针变量指向的值是随机的。这时一定不能去读写指针变量指向的地址,因为那个地址是随机地址,很可能会导致严重后果。正确做法是指针变量声明后,先让它指向一个分配好的地址,然后再进行读写,这叫做指针变量的初始化。
#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 的内存空间,这个地址是无法使用的,读写该地址会报错。
指针本质上就是一个无符号整数,代表了内存地址。它可以进行运算,但是规则并不是整数运算的规则。
指针与整数值的运算,表示指针的移动。
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
。
指针移动的单位,与指针指向的数据类型有关。数据类型占据多少个字节,每单位就移动多少个字节。
指针只能与整数值进行加减运算,两个指针进行加法是非法的。
unsigned short* j;
unsigned short* k;
x=j+k; // 非法
相同类型的指针允许进行减法运算,返回它们之间的距离,即相隔多少个数据单位。高位地址减去低位地址,返回的是正值;低位地址减去高位地址,返回的是负值。
这时,减法返回的值属于ptrdiff_t
类型,这是一个带符号的整数类型别名,具体类型根据系统不同而不同。这个类型的原型定义在头文件stddef.h
里面。
short* j1;
short* j2;
j1=(short*)0x1234;
j2=(short*)0x1236;
ptrdiff_t dist=j2-j1;
printf("%td\n", dist); // 1
上面示例中,j1
和j2
是两个指向short
类型的指针,变量 dist 是它们之间的距离,类型为ptrdiff_t
,值为 1,因为相差 2 个字节正好存放一个short
类型的值。
指针之间的比较运算,比较的是各自的内存地址哪一个更大,返回值是整数1(
true)或
0(false
)。
函数是一段可以重复执行的代码。它可以接受不同的参数,完成对应的操作。函数是 C 程序的基本结构,一个 C 程序由一个或多个函数组成,一个 C 函数由若干条 C 语句构成,一条 C 语句由若干基本单词组成。
函数分为两类:
还可以根据函数是否能被其他源文件调用分为:
static 类型名 函数名(形参表)
extern 类型名 函数名(形参表)
示例函数:
int plus_one(int n) {
return n+1;
}
函数由以下几部分组成:
int
,表示函数plus_one()
返回一个整数。plus_one(int n)
表示这个函数有一个整数参数 n。return
语句。return
语句给出函数的返回值,程序运行到这一行,就会跳出函数体,结束函数的调用。如果函数没有返回值,可以省略return
语句,或者写成return;
。return
语句内可以是表达式,例如return(x>y?x:y)
。调用函数时,只要在函数名后面加上圆括号就可以了,实参放在圆括号里面:
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;
}
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 语句,以便形成统一的代码风格。
如果函数的参数是一个变量,那么调用时,传入的是这个变量的值的拷贝,而不是变量本身。
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); // 无效
上面的写法不会产生交换变量值的效果,因为传入的变量是原始变量a
和b
的拷贝,不管函数内部怎么操作,都影响不了原始变量。
如果想要传入变量本身,只有一个办法,就是传入变量的地址。
void Swap(int* x, int* y) {
int temp;
temp=*x;
*x=*y;
*y=temp;
}
int a=1;
int b=2;
Swap(&a, &b);
上面示例中,通过传入变量x
和y
的地址,函数内部就可以直接操作该地址,从而实现交换两个变量的值。
虽然跟传参无关,这里特别提一下,函数不要返回内部变量的指针。
int* f(void) {
int i;
// ...
return &i;
}
上面示例中,函数返回内部变量i
的指针,这种写法是错的。因为当函数结束运行时,内部变量就消失了,这时指向内部变量i
的内存地址就是无效的,再去使用这个地址是非常危险的。
函数本身就是一段内存里面的代码,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()
的第一个参数也是一个函数。
函数必须先声明,后使用。由于程序总是先运行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,无论是否出现在原型里面,都是可以的。
注意,函数原型必须以分号结尾。
一般来说,每个源码文件的头部,都会给出当前脚本使用的所有函数的原型。
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()
函数,然后再终止程序。
C 语言提供了一些函数说明符,让函数用法更加明确。
对于多文件的项目,源码文件会用到其他文件声明的函数。这时,当前文件里面,需要给出外部函数的原型,并用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
,效果是一样的。
默认情况下,每次调用函数时,函数的内部变量都会重新初始化,不会保留上一次运行的值。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
仅可用于第一维的说明。
函数参数里面的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) {
// ...
}
有些函数的参数数量是不确定的,声明函数的时候,可以使用省略号...
表示可变数量的参数。
int printf(const char* format, ...);
上面示例是printf()
函数的原型,除了第一个参数,其他参数的数量是可变的,与格式字符串里面的占位符数量有关。这时,就可以用...
表示可变数量的参数。
注意,...
符号必须放在参数序列的结尾,否则会报错。
头文件stdarg.h
定义了一些宏,可以操作可变参数。
va_list
:一个数据类型,用来定义一个可变参数对象。它必须在操作可变参数时,首先使用。va_start
:一个函数,用来初始化可变参数对象。它接受两个参数,第一个参数是可变参数对象,第二个参数是原始函数里面,可变参数之前的那个参数,用来为可变参数定位。va_arg
:一个函数,用来取出当前那个可变参数,每次调用后,内部指针就会指向下一个可变参数。它接受两个参数,第一个是可变参数对象,第二个是当前可变参数的类型。va_end
:一个函数,用来清理可变参数对象。下面是一个例子。
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)
用来清理可变参数对象。
数组是一组相同类型的值,按照顺序
储存在一起。数组通过变量名后加方括号表示,方括号里面是数组的成员数量。
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。
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。
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};
数组声明的时候,数组长度除了使用常量,也可以使用变量。这叫做变长数组(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]
就是二维变长数组。
数组是一连串连续储存的同类型值,只要获得起始地址(首个成员的内存地址),就能推算出其他成员的地址。请看下面的例子。
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 的地址,导致报错。
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
上面示例中,变量p
和q
分别是数组 5 号位置和 1 号位置的指针,它们相减等于 4 或-4
。
需要注意如下用法:
*p++
等价于*(p++)
*(p++)
是先取*p 的值
再加 1*(++p)
是先加 1 再取*p
++(*p)
是给*p
指向的变量的值加 1*(p--)
是由于数组名是指针,所以复制数组不能简单地复制数组名。
int* a;
int b[3]={1, 2, 3};
a=b;
上面的写法,结果不是将数组 b 复制给数组a
,而是让a
和b
指向同一个数组。
复制数组最简单的方法,还是使用循环,将数组元素逐个进行复制。
for (i=0; i<N; i++)
a[i]=b[i];
上面示例中,通过将数组b
的成员逐个复制给数组a
,从而实现数组的赋值。
另一种方法是使用memcpy()
函数(定义在头文件string.h
),直接把数组所在的那一段内存,再复制一份。
memcpy(a, b, sizeof(b));
上面示例中,将数组b
所在的那段内存,复制给数组a
。这种方法要比循环复制数组成员要快。
数组作为函数的参数,一般会同时传入数组名和数组长度。
在 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)
。
变长数组作为函数参数时,写法略有不同。
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()
的参数是一个多维数组,按照原来的写法,一定要声明第二维的长度。但是使用变长数组的写法,就不用声明第二维长度了,因为它可以作为参数传入函数。
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 []
) 类似于强制的类型转换,告诉编译器怎么理解这组值。
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")
字符串变量可以声明成一个字符数组,也可以声明成一个指针,指向字符数组。
// 写法一
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
。
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;
}
字符串的复制,不能使用赋值运算符,直接将一个字符串赋值给字符数组变量。
char str1[10];
char str2[10];
str1="abc"; // 报错
str2=str1; // 报错
上面两种字符串的复制写法,都是错的。因为数组的变量名是一个固定的地址,不能修改,使其指向另一个地址。
如果是字符指针,赋值运算符(=
)只是将一个指针的地址复制给另一个指针,而不是复制字符串。
char* s1;
char* s2;
s1="abc";
s2=s1;
上面代码可以运行,结果是两个指针变量s1
和s2
指向同一字符串,而不是将字符串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()
函数代替。
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 个字符。
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()
代替。
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。
如果要比较两个字符串,无法直接比较,只能一个个字符进行比较,C 语言提供了strcmp()
函数。
strcmp()
函数用于比较两个字符串的内容。该函数的原型如下,定义在string.h
头文件里面。
int strcmp(const char* s1, const char* s2);
按照字典顺序,如果两个字符串相同,返回值为 0;如果s1
小于s2
,strcmp()
返回值小于 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]
)。这个差值是一个整数,表示两个字符在字符集中的顺序差异。
由于strcmp()
比较的是整个字符串,C 语言又提供了strncmp()
函数,只比较到指定的位置。
该函数增加了第三个参数,指定了比较的字符数。它的原型定义在string.h
头文件里面。
int strncmp(
const char* s1,
const char* s2,
size_t n
);
它的返回值与strcmp()
一样。如果两个字符串相同,返回值为 0;如果s1
小于s2
,strcmp()
返回值小于 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 个字符。
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
时,才能确认完整的格式字符串写入了变量。
如果一个数组的每个成员都是一个字符串,需要通过二维的字符数组实现。每个字符串本身是一个字符数组,多个字符串再组成一个数组。
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]);
}
C 语言内置的数据类型,除了最基本的几种原始类型,只有数组属于复合类型,可以同时包含多个值,但是只能包含相同类型的数据,实际使用中并不够用。
实际使用中,主要有下面两种情况,需要更灵活强大的复合类型。
为了解决这些问题,C 语言提供了struct
关键字,允许自定义复合数据类型,将不同类型的值组合在一起。这样不仅为编程提供方便,也有利于增强代码的可读性。C 语言没有其他语言的对象(object)和类(class)的概念,struct 结构很大程度上提供了对象和类的功能。
下面是struct
自定义数据类型的一个例子。
struct fraction {
int numerator;
int denominator;
};
上面示例定义了一个分数的数据类型struct fraction
,包含两个属性numerator
和denominator
。
注意,作为一个自定义的数据类型,它的类型名要包括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};
上面示例中,变量saturn
是struct 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};
上面示例中,在声明变量b1
和b2
的同时,为它们赋值。
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 字节。
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 语言没有提供比较两个自定义数据结构是否相等的方法,无法用比较运算符(比如==
和!=
)比较两个数据结构是否相等或不等。
如果将 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
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;
上面示例中,自定义类型student
的name
属性是另一个自定义类型,如果要引用后者的属性,就必须使用两个.
运算符,比如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
循环可以对其进行遍历。
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.field1
与stuff.field2
之间,有一个宽度为两个二进制位的未命名属性。stuff.field3
将存储在下一个字节。
很多时候,不能事先确定数组到底有多少个成员。如果声明数组的时候,事先给出一个很大的成员数,就会很浪费空间。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
是多少。
这样就可以让数组chars
有n
个成员,不用事先确定,可以跟运行时的需要保持一致。
弹性数组成员有一些专门的规则。首先,弹性成员的数组,必须是 struct 结构的最后一个属性。另外,除了弹性数组成员,struct 结构必须至少还有一个其他属性。