原版来自 《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>
上面的代码等同于下面的 if 语句。
if (i>j)
return i;
else
return j;
可以用作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 语句由若干基本单词组成。
函数分为两类:
示例函数:
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
。
由于数组名是指针,所以复制数组不能简单地复制数组名。
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]);
}