# 语法
# 语法特点
- 用 C 语言编写的源代码,一般保存为两种文件:
- 源文件(source file):扩展名为
.c
- 头文件(header file):扩展名为
.h
- 源文件(source file):扩展名为
- C 语言是编译型语言。源代码不能直接作为程序运行,需要用 gcc 等工具,编译成可执行文件,才能运行。
- C 语言是强类型语言。所有变量,都必须声明数据类型,才能使用。
# 主函数
下例是一个 C 语言源文件 test.c :
#include <stdio.h> // 导入头文件 stdio.h ,从而可以使用 printf()、scanf() 等函数
int main(void){ // 定义主函数
int x = 0; // 创建一个变量
printf("Please enter an integer: ");// 在终端显示一行字符串,给用户看
scanf("%d", &x); // 从终端读取一个值,保存到变量中
printf("You entered: %d\n ", x); // 将变量的值打印到终端
return 0; // 终止函数,并返回一个数值
}
用 C 语言编程时,需要定义有且仅有一个
main()
函数,又称为主函数,作为程序运行的入口。- 每个函数包含一组语句。编译器会按从上往下的顺序,依次执行这些语句。
- 主函数执行的最后一条语句,通常是
return 0
,表示终止函数并返回数值 0 。而主函数一旦终止,该程序也运行结束,又称为程序退出。- 退出码通常为 0 ,表示该程序正常终止。
- 如果退出码不为 0 ,则表示该程序异常终止。
定义主函数时,有几种格式:
- 没有返回值,也没有形参:
void main(void) // 括号内的 void 可以省略,也表示没有形参
- 返回值为 int 类型,但没有形参:
int main(void)
- 返回值为 int 类型,形参用于接收命令行参数:假设将该源文件编译为
int main(int argc, char *argv[])
test
,然后在终端执行test a b 1 2
。则操作系统会运行该程序,并传入参数:- 将 argv 数组的长度,传给 argc 。本例取值为 5 。
- 将所有命令行参数(包括程序名称),保存为一个
char *
型指针数组,传给 argv 。本例argv[0]
取值为test
。
- 没有返回值,也没有形参:
# 语句
语句(statement),是 C 语言代码的基本组成单位。
- 每条语句必须以分号
;
结尾。否则编译器不能识别每条语句到第几个字符结束。 - 通常在每条语句末尾,加一个换行符。因为如果将多条语句写在同一行,则比较拥挤,不方便人类阅读。
- 每条语句必须以分号
C 语言的语句分为多种:
- 空语句
- :仅有一个分号,不执行任何操作。
- 声明语句
- :比如声明某个变量的数据类型、声明某个函数头。
- 执行语句
- :比如给某个变量赋值、调用某个函数、执行一个表达式。
- 复合语句
- :用花括号
{ }
包住的多条语句,称为一个语句块。
- :用花括号
- 空语句
缩进
- 一个语句块,应该在每行开头增加一层缩进,从而方便人类阅读。
- 如果不加缩进,则代码难以阅读,但并不影响程序的运行。
- 每层缩进,建议使用两个 Tab ,相当于 8 个空格的长度。如下:
if (x == 1) { return x; }
- 如果一个语句块,只包含一行语句,则可以省略花括号。如下:
if (x == 1) return x;
- 同时最多使用三层缩进,避免代码太复杂。
可以将 C 语言代码中的某些字符,声明为注释,从而被编译器忽略。
- 单行注释:可以在某行插入
//
,将该符号右侧的所有字符,声明为注释。 - 多行注释:可以在第 m 行插入
/*
,在第 n≥m 行插入*/
,从而将这两个符号之间的所有字符,声明为注释。 - 注释通常用于介绍一段代码的用途、希望达到的效果。而代码本身是怎么写的,应该让人一看就懂,不需要讲解。
- 单行注释:可以在某行插入
# 变量
# 标识符
变量名、函数名等名称,统称为标识符。
- 每个标识符的命名格式:
- 可以由阿拉伯数字、英文字母、下划线几种字符组成。
- 不能以数字开头。
- 字母区分大小写。
int
、char
等名称,是 C 语言的关键字,用户不能创建与它们同名的标识符。
- 每个标识符的命名格式:
C/C++ 中,标识符包含英文字母时,应该大写还是小写?
- 变量名、函数名采用下划线命名法,所有字母都小写。例如:
my_name
- 结构体名、类名采用大驼峰命名法,每个单词的首字母大写。例如
MyName
- 常量名采用下划线命名法,所有字母都大写。例如:
MY_NAME
- 变量名、函数名采用下划线命名法,所有字母都小写。例如:
# 创建
变量,是一个有名字的容器,可以存储一个值,然后读取该值。如下:
int x; // 声明一个 int 类型的变量,命名为 x x = 1; // 给变量 x 赋值为整数 1 printf("%d\n", x); // 读取变量 x 的值,打印到终端
使用变量时,应该遵守该流程:声明变量 -> 赋值 -> 读值
- 如果不声明变量的数据类型,C 语言编译器就不知道,应该给该变量分配多大容量的内存空间,作为容器来存储值。
- 例如 char 类型的变量,占用 1 byte 大小的内存空间,或者说容量为 1 byte 。
- 例如 int 类型的变量,占用 4 bytes 大小的内存空间,或者说容量为 4 bytes 。
- 虽然变量的声明语句,决定了分配多大容量的内存空间。但编译器执行到变量的赋值语句时,才会实际分配内存空间。
- 可以在声明变量数据类型的同时,进行赋值,这称为变量的定义语句。例:
int x = 1;
- 变量的定义(definition)语句,相当于声明(declaration)+赋值(assignment)。
- 同一个变量,只能声明一次数据类型,但可以多次声明 extern 。不过声明 extern 的同时,不能进行赋值。
- 换句话说,同一个变量,只能存在一个定义语句,可以存在多个声明语句。
- 可以在一个语句中,同时声明、赋值多个变量:
int x, y; int a = 1, b =2;
- 语法错误:
x = 1; // 这里会报错变量 x undeclared ,不能赋值或读值 int x;
- 语法错误:
int x; // 第一次声明变量时,会在当前作用域创建该变量 int x; // 创建一个变量之后,不能再次声明它的数据类型
- 如果用户声明了一个变量,尚未进行第一次赋值(称为变量的初始化),就读取该变量的值,如下。则会读取到一个不可预测的值,可能导致程序出错。
int x; printf("%d\n", x);
- 为了避免这种情况,某些编译器,会在创建变量时,自动赋一个默认值。比如执行
int x;
时,自动给变量 x 赋值为 0 。 - 为了避免这种情况,某些编译器,会检查代码中的各个变量,如果一个变量尚未赋值就被读取,则抛出 warning 报错。
- 为了避免这种情况,用户可以养成习惯,总是在声明变量的同时,赋一个默认值,比如
int x = 1;
。不过这样增加了一次主动赋值操作,会略微增加一点程序运行耗时。
- 为了避免这种情况,某些编译器,会在创建变量时,自动赋一个默认值。比如执行
- 如果不声明变量的数据类型,C 语言编译器就不知道,应该给该变量分配多大容量的内存空间,作为容器来存储值。
声明变量的数据类型时,还可以添加 const、extern、static 等关键字,声明变量的特殊属性。
# 作用域
- 创建一个变量之后,它就会一直占用内存空间。什么时候才会删除该变量,回收其内存空间呢?
- C 语言不支持用户主动删除一个变量。而是等到当前代码块执行结束时,自动删除变量。
- 每个变量在创建之后、删除之前,可以在程序的某些区域访问该变量(读值、赋值)。这些区域,称为该变量的作用域(scope)。
根据作用域的不同,将变量分为几类:
局部变量
- 在
{}
代码块中声明的变量,属于局部变量。 - 当该代码块执行结束时,会自动删除其中所有局部变量。
- 例:
{ int x = 0; // 创建局部变量 x printf("%d\n", x); // 可以在当前代码块中,访问局部变量 x { printf("%d\n", x); // 可以在嵌套内层代码块中,访问局部变量 x } } printf("%d\n", x); // 在外层代码块中,访问不到局部变量 x 。这里会报错变量 x undeclared
- 访问变量 x 时,会先检查当前代码块中,是否存在名为 x 的变量。如果不存在,则检查外层代码块中,是否存在名为 x 的变量,存在的话就使用它。
int x; { printf("%d\n", x); // 当前代码块不存在名为 x 的变量,因此使用外部代码块的 int x 变量 char x = 'A'; printf("%c\n", x); // 当前代码块存在名为 x 的变量,因此使用它 }
- 在
静态变量
- 声明变量时,如果添加关键字 static ,则会将它声明为静态变量。
- 静态变量的特点是,在其作用域结束之后,不会被编译器自动销毁。等到下次执行当前代码块时,会继续使用该变量。
- 例如,循环执行以下代码块,每次执行,都会重新创建局部变量 x ,并赋值为 0 :如果将变量 x 声明为静态变量,则它只会被创建一次,随后每次循环,取值因为
while(1){ int x = 0; x++; printf("%d\n", x); }
x++;
而不断递增:while(1){ static int x = 0; // C 语言程序在编译时,会事先创建静态变量并初始化。等到正式执行该代码块时,会忽略静态变量的定义语句 x++; printf("%d\n", x); }
- 优点:
- 可以保留变量的值,方便下次使用。
- 缺点:
- 让变量一直存活,一直占用内存空间。
全局变量
- 在函数之外声明的变量,属于全局变量。
- 全局变量的作用域,从定义该变量的那一行语句开始,到当前源文件的最后一行结束。
- C 语言不允许在一个函数内嵌套定义另一个函数,因此所有函数、全局变量,都是存在于全局代码块中的标识符。
- 优点:
- 全局变量,可以在所有函数内访问,方便这些函数之间分享信息。
- 缺点:
- 全局变量会一直存活,一直占用栈区内存空间。因此,如果用户只需要短期使用一个变量,应该创建局部变量。
- 不方便知道哪些函数使用了全局变量。因此不敢随便修改全局变量,不知道会影响哪些函数。换句话说,增加了代码的耦合度。
- 例:
int a = 0; // 全局变量 extern int b; // 外部变量 int main(void){ int a = 0; // 局部变量 return 0; }
外部变量
- 声明变量时,如果添加关键字 extern ,则会将它声明为外部变量。表示该变量的定义语句不在当前代码块,请编译器到外部代码块(通常是 include 的其它文件),寻找该变量的定义语句。
- 例:
#include <stdio.h> int x = 0; int main(){ extern int x; // 此时编译器会到外部代码块,寻找变量 int x ,于是发现全局变量 int x // extern int x = 0; // 语法错误。用 extern 声明变量的同时,不能进行赋值 printf("%d\n", x); return 0; }
- 如果声明了一个外部变量,但从未使用它(读取、赋值),则编译器不会寻找该变量的定义语句。因此,即使不存在该变量的定义语句,也不会报错。
#include <stdio.h> int main(){ extern int x; return 0; }
- 所有函数,默认都为外部函数,不必再用 extern 声明。
- 这看起来挺方便,但用户可能在几个源文件中,定义同名的函数,发生冲突。
- 为了避免冲突,可以将函数用 static 声明,只允许从当前文件访问,不允许从其它文件访问。
根据存储方式的不同,将变量分为几类:
- auto 类型
- 用关键字 auto 声明的变量,属于自动类型。
- 创建这种变量时,编译器会自动分配内存空间给它。作用域结束时,编译器会自动回收它的内存空间。
- 比如局部变量、函数的形参,它们默认属于 auto 变量。
int main(){ int x = 0; // 相当于 auto int x = 0; return 0; }
- static 类型
- extern 类型
- register 类型
- 用关键字 register 声明的变量,会存储在 CPU 芯片的寄存器中。
- 普通的变量,存储在计算机内存中。程序每次读写该变量,需要将变量从内存拷贝到寄存器中,才能被 CPU 直接读写。
- 如果将变量一直存储在 CPU 寄存器中,就能被 CPU 更快地读写。不过一般程序没必要这么追求速度。
- register 只能用于声明局部变量,不能声明全局变量。
- register 类型的变量,不支持通过 & 获取内存地址。
# 数据类型
每个变量,有且仅有一种数据类型,决定了该变量占用多少内存空间、允许存储什么类型的值。
C 语言中,变量的数据类型分为几类:
- 空类型(void)
- 表示不存在值。
- 例如很多函数的参数、返回值为 void 类型。
- 基本数据类型
- 整型 short、int、long
- 浮点型 float、double
- 字符型 char
- 数组
- 指针
- 结构体
- 共用体
- 枚举
- 空类型(void)
每个变量在声明数据类型之后,不允许修改数据类型。但表达式的返回值,可以修改数据类型。分为两种情况:
- 自动类型转换
- 不同数据类型的几个值,在混合运算时,会自动转换成其中容量最大的那个数据类型。
- 转换顺序为:
char,short -> int -> unsigned int -> long -> unsigned long -> double
- float 参与运算时,一律转换为 double 再运算。甚至两个 float 之间的运算,都要转换为 double 再运算。这是为了让浮点数的运算结果,尽量精确,虽然这会增加几个字节的内存开销。
- 例:
printf("%d\n", 1+'A'); // 运算结果为 int 类型,输出为:66 printf("%lf\n", 1+3.14); // 运算结果为 double 类型,输出为:4.140000 float x = 1+3.14; // 1+3.14 的运算结果为 double 类型,赋值给变量 x 时,会强制转换为 float 类型
- 强制类型转换
- 进行赋值时,= 右侧的表达式返回值,会强制转换成 = 左侧的变量数据类型。
float x = 1; // 此时整数 1 会被转换成浮点数 printf("%f\n", x); // 输出为:1.000000 printf("%f\n", 1); // 如果直接打印整数 1 ,则不兼容 %f 格式控制符,输出为:0.000000
- 用
(数据类型名)(表达式)
的格式,可以强制转换表达式返回值的数据类型。printf("%d\n", (int)3.14); // 浮点型转换成整型时,会丢掉小数部分。输出为:3 printf("%lf\n", 1/3); // 运算结果为 int 类型,输出为:0.000000 printf("%lf\n", (float)1/3); // 运算结果为 float 类型,输出为:0.333333
- 进行赋值时,= 右侧的表达式返回值,会强制转换成 = 左侧的变量数据类型。
- 自动类型转换
用
sizeof(变量名)
或sizeof(数据类型名)
的格式,可以查询一个变量或数据类型,占用的内存大小,单位为 bytes 。- sizeof 不是函数,而是一个关键字,称为容量运算符。
- sizeof 的返回值,在编译期间就会确定。
- 例:
printf("%d\n", sizeof(int)); // 输出为:4
- 字节(byte)是计算机常用的存储单位,而位(bit)是最小的存储单位。
- 每个字节,由 8 个 bit 组成。
用关键字
typedef
,可以给某个数据类型,创建一个别名。- 例:
typedef unsigned int u16; // 给 unsigned int 数据类型,创建 u16 别名。最后一个字段就是别名 u16 x; typedef struct{...} Horse; // 将结构体命名为 Horse ,像定义一个类 Horse h1;
- 使用
#define
定义宏,能实现与typedef
相似的效果,但有时效果不同。#define int_ptr int * int_ptr x,y; // 会被替换为 int *x,y; ,考虑到运算符的优先级,这相当于 int *x; 和 int y;
typedef int * int_ptr; int_ptr x,y; // 相当于 int *x; 和 int *y;
- 例:
# 整数
在数学中,整数是指不含小数点的数字。
在 C 语言中,整数通常存储在以下数据类型的变量中:
short
- 称为短整型(short integer)
- 占用内存空间的容量为 2 字节。
int
- 称为整型(integer)
- 容量通常为 4 字节。
- 在 16 位 CPU 上,容量为 2 字节。
- 例:
int x; x = 3; // 3 是 int 型常量,可以被赋值给 int 型变量 x = 3.14; // 3.14 是 double 型常量,如果赋值给 int 型变量,则会丢掉小数部分,只剩整数部分 printf("%d\n", x);
long
- 称为长整型(long integer)
- 在 32 位 CPU 上,long 的容量为 4 字节。在 64 位 CPU 上,long 的容量为 8 字节。
long long
- 称为双长整型。
- 容量为 8 字节。
- 32 位 CPU 不支持使用该数据类型,64 位 CPU 才支持。
short 的容量为 2 字节,也就是使用 16 bits 来存储数字。那么能存储多大的数字?
- 将这 16 个二进制位,从 0 开始编号。第 0 位称为最低位,第 15 位称为最高位。
- 默认将最高位,用于存储正负号,称为符号位。取值 0 表示正号,取值 1 表示负号。
- 除了最高位之外,其它位可以自由赋值。
- 绝对值最大的正数,二进制值为
0111 1111 1111 1111
,等于十进制的2^15 - 1 = 32767
。 - 绝对值最大的负数,二进制值为
1111 1111 1111 1111
,等于十进制的-32767
。 - 另外,
0000 0000 0000 0000
转换成十进制是 0 ,1000 0000 0000 0000
转换成十进制是 -0 。而 -0 没有用处,通常将这个数值用于表示 -32768 。
- 绝对值最大的正数,二进制值为
- 因此,如果用 short 型变量存储一个整数,则取值范围为 ``-(2^15)
至
+(2^15-1),也就是
-32768至
+32767` 。 - 用户给 short 型变量赋值时,如果超过正常取值范围,则会导致二进制的最高位被修改,正负号反转,该现象称为数值溢出。如下:
short x = 32767; printf("%d\n", x); // 输出为:32767 ,正常 x = 32768; printf("%d\n", x); // 输出为:-32768 x++; printf("%d\n", x); // 输出为:-32767 x = -32768; printf("%d\n", x); // 输出为:-32768 ,正常 x--; printf("%d\n", x); // 输出为:32767
- 同理,如果 int 的容量为 4 字节,则取值范围为
-(2^31)
至+(2^31-1)
。 - 为了扩大变量的取值范围,可以不记录正负号,将最高位也用于存储数值。这样的变量,称为无符号型变量(unsigned)。例如:
unsigned char // 取值范围为 0 至 2^8 -1 = 255 unsigned short // 取值范围为 0 至 2^16 -1 = 65535 unsigned int unsigned long
- 与之相对,默认数据类型的变量,称为有符号型变量(signed)。
- 浮点数 float、double 必须记录正负号。因为浮点数按照 IEEE754 标准进行存储,而且浮点数的取值范围本就很大,一般不需要扩大范围。
# 浮点数
十进制的整数,转换成二进制形式,比较简单,只需要反复除以 2 。但十进制的小数,转换成二进制形式,比较麻烦。
- 大部分十进制的小数,都不能准确转换成二进制,存在误差。 (opens new window) 如下:
printf("%.18lf\n", 0.1+0.2); 0.300000000000000044
- 大部分十进制的小数,都不能准确转换成二进制,存在误差。 (opens new window) 如下:
C 语言按照 IEEE754 标准来存储包含小数的数字。以
float x = 3.14;
为例,存储流程如下:- 将十进制的
3.14
,转换成二进制,得到11.0010 0011 1101 0111 0000 1010 ...
,是一个无限长度的小数。 - 计算机只能存储有限长度的数字,多余的位数会被丢弃,导致误差。这里假设上述二进制值,被截断为
11.0010 0011 1101
然后存储。 - 为了记录小数点的位置,将上述二进制值,改写为
1.10010 0011 1101 << 1
的形式。表示将1.10010 0011 1101
按位左移 1 次,就会得到11.0010 0011 1101
。- 此时,小数点的位置可以左右浮动,因此称为浮点数(floating point number)。
- 在二进制中,左数第一个数字总是 1 ,因此可以省略不记录。最终只需要记录
10010 0011 1101
,称为尾数。再记录<< 1
,称为指数。 - float 的容量为 32 bits ,这些 bit 的用途如下:
- 第 31 个 bit ,用作符号位。
- 第 23 至 30 个 bit ,总共 8 位,用于存储指数。
- 因此指数的取值范围为
-2^7 = -128
至+2^7 = +128
。换算成十进制,float 的取值范围为2^-128
至2^+128
。
- 因此指数的取值范围为
- 第 0 至 22 个 bit ,总共 23 位,用于存储尾数。
- 考虑到,尾数左端还有一个二进制 1 被省略了,因此尾数能存储的十进制值最大为
2**24 - 1 = 16777215
,总共 8 位长度。但这样不能存储大于16777215
的 8 位十进制数。 - 保险起见,建议用户使用 float 时,最多存储 7 位十进制数字(不包括小数点)。超出长度的部分,取值随机不可控,不会四舍五入。如下:
float x = 1234567.89; printf("%f\n", x); // 输出为:1234567.875000 float x = 12345670000.0; printf("%f\n", x); // 输出为:12345669632.000000
- 考虑到,尾数左端还有一个二进制 1 被省略了,因此尾数能存储的十进制值最大为
- 综上,float 的取值范围很大,超过大部分用户的需求。但 float 的精度有限,不一定满足用户的需求。
- 将十进制的
在 C 语言中,浮点数通常存储在以下数据类型的变量中:
float
- 称为单精度浮点型。
- 容量为 4 字节。
- 最多保留 7 位十进制有效数字。
double
- 称为双精度浮点型。
- 容量为 8 字节。
- 最多保留 15 位十进制有效数字。
long double
- 称为长双精度浮点型。
- 容量为 16 字节。
# 字符
- 在人类世界,除了数字,还有英文字母、汉字等文字符号,统称为字符。
- 在 C 语言中,
- ASCII 编码的字符(比如阿拉伯数字、英文字母),可以存储在 char 类型的变量中。
char a = 'A'; // 将单个字符 'A' 存储到 char 型变量中,实际上存储的是 'A' 的 ASCII 码值 65 char b = 65; // 将整数 65 转换成 char 类型,然后存储到 char 型变量中 printf("%d\n", 'A' == 66); // 这个表达式为真,输出为:1
- 非 ASCII 编码的字符(比如汉字),需要转换成十六进制值,以字符串的形式存储。
- ASCII 编码的字符(比如阿拉伯数字、英文字母),可以存储在 char 类型的变量中。
# 常量
常量有多种类型,它们的特点是取值不会变化。
- 以下常量,由用户写在源代码中。每次使用它们,用户就需要再写一次它们的值,挺麻烦。
printf("%d\n", 1); // 整数常量 printf("%f\n", 3.14); // 浮点数常量 printf("%c\n", 'A'); // 字符常量 printf("%s\n", "Hello"); // 字符串常量
- 以下常量,称为符号常量。它们绑定了一个标识符作为名称,用户可通过标识符引用它们的值。
#define PI 3.14 // 通过宏定义,创建一个常量 printf("%f\n", PI); const float pi = 3.14f; // 通过关键字 const 将一个变量声明为常量,禁止赋值 printf("%f\n", pi); pi = 3.0; // 对 const 类型的变量赋值,会报错:read-only variable
- 以下常量,由用户写在源代码中。每次使用它们,用户就需要再写一次它们的值,挺麻烦。
整数常量,细分为几种:
1 // 如果用户写入一串数字,不包含小数点,则会被编译器视作整数常量,默认采用 int 数据类型存储在内存中 1L // 如果以字母 L 或 l 结尾,则采用 long 类型 1U // 如果以字母 U 或 u 结尾,则采用 unsigned int 类型
浮点数常量,细分为几种:
3.14 // 如果用户写入一串数字,包含小数点,则会被编译器视作浮点数常量,默认采用 double 数据类型存储在内存中 3.14F // 如果以字母 F 或 f 结尾,则采用 float 类型 3.14L // 如果以字母 L 或 l 结尾,则采用 long double 类型 printf("%f\n", -31.4e-1f); // 用户还可按照科学计数法,写入一个浮点数。这里输出为:-3.140000
例:
printf("%f\n", 3); // 3 是 int 型常量,不兼容 %f 格式控制符,输出为:0.000000 printf("%f\n", 3.); // 3. 是 double 型常量,兼容 %f 格式控制符,输出为:3.000000
float x; x = 3; // 3 是 int 型常量,可以被赋值给 float 型变量,此时会发生强制类型转换 x = 3.14; // 3.14 是 double 型常量,可以被赋值给 float 型变量,此时会发生强制类型转换 x = 3.14f; // 3.14f 是 float 型常量,可以被赋值给 float 型变量 x = 3f; // 语法错误。3 是 int 型常量,不能声明为 float 类型
# 数组
int、float、char 等数据类型的变量,每个变量只能存储一个值。
数组(array)类型的变量,每个变量可以存储多个值,称为多个元素(element)。
- 创建一个数组时,必须确定该数组的内存容量,即包含多少个元素、这些元素是什么数据类型。
- 这是为了告诉编译器,分配多少内存空间来存储该数组。
- 数组中的元素总数,称为该数组的长度。数组的内存容量,等于元素总数 * 单个元素的容量。
- 同一数组的各个元素,必须是同一数据类型。
- 例:
int array[10]; // 创建一个数组,可以存储 10 个元素,这些元素必须都是 int 数据类型 printf("%d\n", sizeof(array)); // 输出为 40 int array[]; // 语法错误,没有定义数组长度 int array[] = {0,1,2,3}; // 定义数组时,可以不指定数组长度,而是赋值一个数组常量,自动采用其长度 printf("%d\n", sizeof(array)); // 输出为 16 int array[10] = {0,1}; // 定义数组时,可以指定数组长度,同时赋值一部分元素 printf("%d\n", sizeof(array)); // 输出为 40
- 数组中的各个元素,从 0 开始编号,又称为下标。通过
array[n]
的格式,可以访问数组中第 n 个元素。int array[10]; array[0] = 4; // 给数组中第 0 个元素赋值 printf("%d\n", array[0]); // 读取数组中第 0 个元素
- 创建一个数组时,必须确定该数组的内存容量,即包含多少个元素、这些元素是什么数据类型。
数组中的元素,可以是 int、float、char 等基本数据类型,也可以是数组类型。
- 例:创建一个二维数组
int array[2][3]; // 定义一个二维数组。第一维长度为 2 ,第二维长度为 3 ,就像数学中 2 行 3 列的矩阵 int array[2][3] = {0,1,2,3}; // 按顺序对前 4 个元素赋值,这相当于赋值为 {{0,1,2}, {3}} int array[2][3] = {{0,1,2}, {3}}; int array[][3] = {{0,1,2}, {3}}; // 定义数组时,可以不指定数组长度,而是赋值一个数组常量,自动采用其长度
- 例:用 for 语句输入、输出一个二维数组
int main(void){ int array[2][3]; int i = 0, j = 0; for (i; i < 2; i++){ for (j = 0; j < 3; j++){ printf("Please enter an integer: "); scanf("%d", &array[i][j]); } } int num = sizeof(array) / sizeof(int); // 计算数组的总长度 int len2 = sizeof(array[0]) / sizeof(int); // 计算数组的第二维长度 int len1 = num / len2; // 计算数组的第一维长度 printf("The array is:\n"); for (i = 0; i < len1; i++) { for (j = 0; j < len2; j++) printf("%d ", array[i][j]); printf("\n"); } return 0; }
- 例:创建一个二维数组
# 指针
指针(pointer)是一种特殊的数据类型。
- int、float、char 等数据类型的变量,是存储一个数字或字符,用户不关心这个值存储在内存的哪个地址。
- 指针类型的变量,是存储一个内存地址,或者说指向一个内存地址。
- 用户知道了内存地址,就可以读取从该地址开始的任意个 bytes 内存空间的数据,或者写入数据到这块内存空间。
- 优点:使用指针,用户可以自由访问任意内存地址。
- 缺点:风险大。比如用户访问一个不存在的内存地址,可能导致 C 语言程序崩溃。
指针变量可以存储任意内存地址,常见的用途是,存储普通变量的内存地址。
- 例:
int x = 1; int *p = &x; // 创建一个 int * 类型的指针变量,指向变量 x 的内存地址
- 创建指针变量时,格式为
数据类型名 * 变量名
。 - 指针变量,必须与目标变量的数据类型相同。
- 比如
int *
类型的指针,只能存储int
类型变量的内存地址。 - 这是因为,只知道一个内存地址,却不知道数据类型,就不知道应该读取从该地址开始的多少个 bytes 的数据,也不知道解析成整数还是浮点数。
- 比如
&
称为取地址运算符,用于获取一个变量的内存地址。- 使用
* 指针变量名
的格式,可以取消引用指针,变为引用指针指向的那个内存地址中的数据。printf("%p\n", p); // 读取指针 p 存储的内存地址,输出为:0x7fff2477fb74 printf("%d\n", *p); // 读取指针 p 指向的那个内存地址中的数据,输出为:1 scanf("%d", p); // 读取一个整数,存储到指针 p 所指的内存地址中。这里 p 本身就是一个地址,不需要使用 &p 取地址 p = &x; // 给指针 p 赋值,存储变量 x 的内存地址 *p = 1; // 给指针 p 所指的内存地址赋值,存储整数 1 (*p)++; // 让变量 x 的值自增 printf("%d\n", x); // 输出为:2
- 例:
自增
++
、自减--
与取消引用运算符*
的优先级相同,并且都是右结合性。(*p)++
是先引用 p 指向的那个内存地址中的数据,然后让该数据递增。*p++
等价于*(p++)
,是先让 p 所存储的内存地址递增,然后引用该地址中的数据。p++
并不是让内存地址增加 1 byte ,而是增加一倍的sizeof(*p)
。
一个指针变量,本身占用多少内存空间?
- 在 32 位 CPU 上,每个内存地址的长度为 4 bytes ,因此每个指针变量,占用 4 bytes 内存空间,与 long 变量的容量相同。
- 在 64 位 CPU 上,每个内存地址的长度为 8 bytes ,因此每个指针变量,占用 8 bytes 内存空间,与 long 变量的容量相同。
- 例如
int *
类型的指针变量、char *
类型的指针变量,都是存储一个内存地址。这里的int
、char
只是表示如何解读内存地址中的数据,并不表示指针本身的容量。 - 例:
int x = 1; int *a = &x; char y = 'A'; char *b = &y; printf("%d, %d\n", sizeof(x), sizeof(y)); // 输出为:4, 1 printf("%d, %d\n", sizeof(a), sizeof(b)); // 输出为:8, 8
指针变量存储的内存地址,是一个十六进制数字。如果转换成字符串形式,则方便在终端输入、输出。例:
char str[] = "Hello"; // 创建一个指针 printf("%p\n", str); // 输出为:0x7ffcd1cf33d0 long addr = (long)str; // 将任意类型的指针,强制转换成 long 型变量 printf("%lx\n", addr); // 输出为:7ffcd1cf33d0 char *p = (char *)addr; // 将 long 型变量,强制转换成任意类型的指针 printf("%s\n", p); // 输出为:Hello char buffer[20]; sprintf(buffer, "%lx", addr); // 将 long 型变量,打印到字符串中 printf("%s\n", buffer); // 输出为:7ffcd1cf33d0 long addr2 = 0; sscanf(buffer, "%lx", &addr2); // 从字符串中读取内存地址,保存为 long 型变量 printf("%lx\n", addr2); // 输出为:7ffcd1cf33d0
上述指针是指向一个基本数据类型的变量,用法简单。下面介绍一些用法复杂的指针:
- 空指针
- :这种指针的取值为
NULL
,即没有存储内存地址。 NULL
是一个宏,取值为(void *) 0
。- 例:
int *p = NULL; // 创建一个 int * 类型的指针变量,同时初始化为空指针 printf("%p\n", p); // 输出为:nil printf("%d\n", p); // 输出为:0 printf("%d\n", NULL==0); // 输出为:1 printf("%d\n", *p); // 程序报错 Segmentation fault
- :这种指针的取值为
- 野指针
- :这种指针的取值是未知的,不知道指向哪个内存地址。
- 新建一个指针变量时,它的取值是未知的,属于野指针。
int *p; printf("%p\n", p); // 输出是一个未知的地址
- 用户应该避免使用野指针,因为访问未知的内存地址,可能导致程序崩溃。为了避免用户不小心使用野指针,可以在创建指针时,将它初始化为空指针。
int *p = NULL; printf("%p\n", p); // 输出为:nil printf("%d\n", *p); // 报错 Segmentation fault ,因为 p 存储的地址无效,根据 %d 从该内存地址读取一个整数值,会导致程序崩溃
- 通用指针
- :即
void *
类型的指针,又称为无类型指针,它可以指向任意数据类型的对象。 - 例:
int x = 1; void *vp = &x; int *p = vp; // 通用指针,可以赋值给明确数据类型的指针 printf("%d\n", *p); printf("%d\n", *vp); // 语法错误。通用指针不能取消引用,因为不知道对象的数据类型
- :即
- 常量指针
- :这种指针所指的变量,会被编译器视作常量(不管它是否用 const 声明),禁止修改。
- 例:
int x = 1; const int *p = &x; // 也可写作 int const ,只要写在 * 的左侧 *p = 2; // *p 是只读的,禁止修改。编译时会报错:assignment of read-only location p = NULL; // p 的取值可以修改
- 如果想避免一个变量被直接修改,建议创建 const 变量。如果想避免一个变量被指针修改,建议创建 const 指针。
const int x = 0; // 关键字 const 并不会修改变量的存储方式,只是禁止用户对该变量赋值 x = 1; // 编译时会报错:assignment of read-only variable int *p = &x; // 编译时会警告:initialization discards ‘const’ qualifier from pointer target type *p = 1; // 用户可以通过指针,访问常量的内存地址,强行修改其值 printf("%d\n", *p); const int *p = &x; // 为了禁止用户,通过指针修改一个变量,可以将变量的内存地址,保存在常量指针中 *p = 1; // *p 是只读的,禁止修改。编译时会报错:assignment of read-only location
- 如果用户将一个常量指针,赋值给普通指针,则编译时会发出警告,以免用户通过指针,修改所指变量。
const char *x = "Hello"; char *y = "Hi"; x = y; // 普通指针,可以赋值给常量指针,但反之会发出警告 y = x; // 编译时会警告:assignment discards ‘const’ qualifier from pointer target type
- 指针常量
- :这种指针本身是常量,不能修改其存储的内存地址。
- 例:
int x = 1; int *const p = &x; // 注意 const 写在 * 的右侧,修饰变量 p ,而不是修饰 int *p = 2; // *p 的取值可以修改 p = NULL; // 语法错误。p 的取值是只读的,禁止修改
- 例:
const int *const p = &x; // 一个指向常量的,指针常量
- 数组指针
- C 语言中的数组,底层是基于指针进行工作的。
- 数组名,本身是一个指针,指向数组第一个元素的内存地址。
- 当用户通过
array[n]
访问数组中第 n 个元素时,相当于访问指针array+n
。 array+n
是在 array 首地址上,向后偏移 n 倍的sizeof(*array)
。array+n
是数组第 n 个元素的内存地址。*(array+n)
是数组第 n 个元素的值,等价于array[n]
。- 数组中第一个元素,是
array[0]
,这里 0 表示偏移量。而 C 语言初学者大多会误以为,数组中第一个元素,是array[1]
。
- 例:以指针的方式,访问一维数组
int array[10] = {0,1,2,3}; printf("%p\n", array); // 输出为:0x7fff7185d120 。因为 array 等价于 &array[0] printf("%d\n", *array); // 输出为:0 。因为 *array 等价于 array[0] printf("%d\n", *(array+1)); // 输出为:1 。因为 *(array+1) 等价于 array[1] printf("%d\n", *(array++)); // 语法错误。因为 array 存储的地址是只读的,不允许修改
- 例:以指针的方式,访问二维数组
int array[5][5]; array == array[0] array[0] == &array[0] // array[0] 表示数组第一维的起始地址。对它 & 取地址,依然会得到该地址 array[0] == &array[0][0] array[1] == &array[1][0] *array == array[0] **array == array[0][0] *(*array + 1) == array[0][1] **(array + 1) == array[1][0] *(*(array + 1) + 1) == array[1][1]
- 例:以数组的方式,访问指针所指内存
char *pstr = malloc(10*sizeof(char)); pstr[0] = 'H'; pstr[1] = 'i'; pstr[2] = '\0'; printf("%s\n", pstr); // 输出为:Hi // 可以用 realloc() 多次调整内存容量,然后以数组的方式访问该内存空间,从而实现一个动态容量的数组
- C 语言中的数组,底层是基于指针进行工作的。
- 指针数组
- :这是创建一个数组,每个元素都是一个指针。
- 例:
int *array[5], x=1; array[0] = &x; printf("%d\n", *array[0]);
- 函数指针
- 函数名本身也是一个指针,指向函数代码的内存地址。因此可以创建一个指针,存储函数的地址,然后通过该指针调用该函数。
- 例:
#include <stdio.h> int sum(int a, int b){ // 定义一个函数 return a + b; } int main(){ printf("%p\n", sum); // 输出为:0x400420 int (*psum)(int, int) = sum; // 定义一个函数指针 // 开头的 int 表示函数返回值的数据类型 // (*psum) 表示这是一个函数指针。如果不加括号,就变成了指针函数 // (int, int) 表示函数各个形参的数据类型 printf("%d\n", psum(1, 2)); // 输出为:3 。此时 psum 可以像函数名 sum 一样调用 return 0; }
- 例:
int (*array[5])(int, int); // 定义一个数组,每个元素是一个函数指针 array[0] = sum; printf("%d\n", array[0](1, 2));
- 指针函数
- :这种函数的返回值,是一个指针。
- 例:
#include <stdio.h> int c = 0; int *psum(int a, int b){ c = a + b; return &c; // 这里变量 c 不能是局部变量,否则函数运行结束之后会自动销毁局部变量,导致在函数外读取 &c 内存地址时出错 } int main(){ printf("%p\n", psum(1, 2)); // 输出为:0x601038 printf("%d\n", *psum(1, 2)); // 输出为:3 return 0; }
- 空指针
# 字符串
C 语言中,可以创建 char 类型的变量、常量,包含一个字符。
printf("%c\n", 'A'); // 'A' 是一个字符常量。有且仅有一个字符,用单引号包住 char x = 'A'; // 将 'A' 这个字符常量,存储到变量 x 中
C 语言中,可以创建字符串常量,包含任意个字符串。
printf("%s\n", "Hello"); // 字符串常量。可以包含零个、一个或多个字符,用双引号包住
- 如果两个字符串常量,被任意个空字符分隔,则会自动拼接为一个字符串常量。
printf("%s\n", "Hello" "World"); // 输出为:HelloWorld printf("%s\n", "Hello" // 输出为:HelloWorld "World");
- 如果两个字符串常量,被任意个空字符分隔,则会自动拼接为一个字符串常量。
C 语言中,没有字符串型变量,如何用变量存储一个字符串?可采取以下几种方案:
- 用数组存储字符串:
char str[] = "Hello"; // 创建一个 char 数组,用于存储字符串 printf("%d\n", sizeof(str)); // 输出为:6 printf("%s\n", str); // 输出为:Hello
"Hello"
属于字符串常量。字符串常量可以有零个、一个或多个字符,用双引号包住。- 缺点:需要事先知道字符串的长度,据此创建足够长度的数组。
- 缺点:字符串用完之后,不能立即销毁数组,该数组会继续占用内存空间。
- 存储字符串常量时,编译器会自动在末尾添加一个空字符,用于表示字符串的结束。
char str[] = ""; printf("%d\n", sizeof(str)); // 输出为:1
- 空字符,是 ASCII 码表中定义的第一个字符,码值为 0 ,符号为 NUL ,用转义字符表示为
\0
。 - 因此,一个字符串,占用的内存字节数 = 字符总数 + 1 。
- 空字符,是 ASCII 码表中定义的第一个字符,码值为 0 ,符号为 NUL ,用转义字符表示为
char *
类型的指针,可以存储一个 char 型变量的内存地址:char c = 'A'; char *p = &c; printf("%c\n", *p); // 输出为:A
char *
类型的指针,也可以存储一个字符数组的首地址,或者一个字符串的首地址:char str[] = "Hello"; // 这会将字符串常量中的每个字符,保存为字符数组中的一个元素 printf("%s\n", str); // 输出为:Hello
char *pstr = "Hello"; // 将字符串常量(的首地址),赋值给 char * 型指针 printf("%s\n", pstr); // 输出为:Hello printf("%s\n", *pstr); // *pstr 等于第一个字符的值,此时不属于字符串,按照 %s 格式输出会导致程序出错 printf("%c\n", *pstr); // 输出为:H 。等价于 pstr[0] printf("%c\n", *(pstr + 1)); // 输出为:e 。等价于 pstr[1] printf("%s\n", pstr + 1); // 输出为:ello 。这是从第二个字符开始的字符串
char **
类型的指针,可以存储多个字符串:char array1[][10] = {"Hello", "World"}; // 创建二维字符数组,第一维的每个元素可以存储一个字符串 char *array2[] = {"Hello", "World"}; // 创建 char * 型指针数组,每个元素可以存储一个字符串的首地址 array2[0] = array1[0]; printf("%s\n", array2[0]); // 输出为:Hello char **ppstr = array2; // 创建 char ** 型指针。它属于二级指针,可以被 * 取消引用两次 printf("%s\n", *ppstr); // 输出为:Hello 。相当于 ppstr[0] printf("%c\n", **ppstr); // 输出为:H 。相当于 ppstr[0][0]
char **ppstr = malloc(sizeof(char *)); // 动态分配一块内存空间,用于存储 char ** 型指针 *ppstr = malloc(sizeof(char *) * 5); // 动态分配 5 块内存空间,用于存储 5 个 char * 型指针 *ppstr = "Hello"; // 使用第一个 char * 指针,存储一个字符串常量的首地址 *(ppstr + 1) = "World"; // 使用第二个 char * 指针 int i; for (i = 0; i < 5; i++){ printf("%s\n", *(ppstr + i)); }
- C++ 原生提供了 String 数据类型,用于存储字符串:C 语言可以用指针,模仿 String 数据类型:
#include <string> string str = "Hello";
typedef char * String; String str = "Hello"; printf("%s\n", str);
- 用数组存储字符串:
如何拷贝字符串?
- 不能将一个字符串,赋值给一个已经初始化字符数组:
char str[] = "Hello"; str = "Hello"; // 语法错误。数组在创建之后,就不能同时给多个元素赋值 char str2[10]; str2 = str; // 语法错误。str2 是一个数组名,不能给它赋值
- 可以将一个字符串(的首地址),赋值给一个指针:
char *pstr = "Hello"; printf("%s\n", pstr); // 输出为:Hello char str[] = "World"; pstr = str; printf("%s\n", pstr); // 输出为:World
- 可以调用
<string.h>
的 strcpy() 等函数。
- 不能将一个字符串,赋值给一个已经初始化字符数组:
# 结构体
结构体,这种数据类型与数组有些相似。对比两者:
- 数组可以存储多个值,称为多个元素。结构体也可以存储多个值,称为多个成员。
- 数组存储的各个值,必须是同一数据类型。而结构体存储的各个值,可以是不同数据类型。
- 数组存储的各个值,只有从 0 开始的编号。而结构体存储的各个值,像变量一样定义,拥有一个名称标识符。
结构体要用关键字 struct 定义。
- 例:
struct Horse{ // 定义一个结构体,名为 Horse char name; // 定义该结构体的一个成员,名为 name ,类型为 char int age; // int age = 0; // 语法错误。在定义结构体时不允许给成员赋值 }; // 定义结构体的末尾,记得加分号,将这多行视作一条定义语句
- 定义一种结构体之后,就像在 C 语言中创建了一种新的数据类型,用户可通过
struct 结构体名
的格式,使用该数据类型创建变量。struct Horse h1; // 声明一个 struct Horse 类型的变量 struct Horse h2 = {'J', 5}; // 声明结构体变量时,可以按顺序给各个成员赋值
- 可以在定义结构体的同时,使用该数据类型创建变量。
struct Horse{ char name; int age; } h1, h2 = {'J', 5};
- 通过
结构体变量名.成员名
的格式,可以访问结构体中指定一个成员,其中.
称为成员运算符。h1.name = 'J'; // 访问结构体的 name 成员,对其赋值。标识符 h1 记录了该结构体的内存地址,而 .name 是访问结构体中的一个偏移地址,该地址存储了 name 变量 h1.age = 5; h1 = h2; // 同种结构体的两个变量之间,可以相互赋值 h1 = {'J', 5}; // 语法错误。创建结构体变量之后,禁止同时给多个成员赋值
- 例:创建结构体类型的数组,其中每个元素都是一个结构体
struct Horse array[5]; array[0].age = 5; array[1] = array[0];
- 例:创建结构体类型的指针
struct Horse *p = &array[0]; (*p).age = 5; // 相当于 array[0].age p->age = 5; // (*p).age 可改写为 p->age
- 考虑到运算符的优先级,
*p.age
相当于*(p.age)
,而不是*(p).age
。为了避免误解,建议改写为p->age
。
- 考虑到运算符的优先级,
- 例:
一个结构体,占用多少内存?
- 定义一种结构体之后,它的内存容量就固定了,理论上等于各个成员的内存容量之和,实际上编译器可能自动添加几个字节来对齐内存地址。
struct Horse{ char name; int age; }; printf("%d\n", sizeof(struct Horse)); // 输出为:8
- 为什么要对齐内存地址?
- 32 位 CPU 读写内存时,会将内存地址每 4 字节分为一行,批量访问。比如地址 0x0000~0x0003 是一行,地址 0x0004~0x0007 是下一行。
- 如果一个变量存储时,横跨两行内存,则 CPU 需要读取两行内存,耗时增加。
- 因此,编译器存储一个内存容量为 N>1 的变量时,会分配一个取值为 N 的整数倍的内存地址,从而让该变量只存储在一行内存中,避免耗时增加。
- 同理,64 位 CPU 读写内存时,会将内存地址每 8 字节分为一行。
- 本例中,name 变量的容量为 1 字节,age 变量的容量为 4 字节。
- 如果在 name 变量之后,紧跟着存储 age 变量,则 age 变量的地址不能对齐到 4 的整数倍。
- 因此,编译器会在 name 变量之后偏移 3 字节,再存储 age 变量。这 3 字节的内存空间,没有用于存储数据,被浪费了。
- 为了减少因为对齐而浪费的内存空间,建议将容量较大的成员变量,越放在前面定义。
- 定义一种结构体之后,它的内存容量就固定了,理论上等于各个成员的内存容量之和,实际上编译器可能自动添加几个字节来对齐内存地址。
对比结构体与 C++ 的类:
- 结构体只是把一些变量封装在一起。而类的功能更多,比如封装、继承、多态,更适合面向对象编程。
- 结构体是一种值类型,存储的是变量的实际值。而类是引用类型,存储的是对象的地址。
- 结构体不能在定义时,初始化成员。而类可以。
# 共用体
共用体,这种数据类型与结构体相似,但要求各个成员共用一个内存地址。
- 一个结构体的各个成员,存储在不同内存地址中,只是地址相邻。
- 一个共用体中的各个成员,存储地址都是共用体的首地址。因此,如果先后给多个成员赋值,则最后存储的那个成员,会覆盖之前存储的成员。
例:
union Horse { // 共用体要用关键字 union 定义,定义格式与结构体相同 char name; int age; }; union Horse h1; h1.name = 'J'; printf("%c\n", h1.name); // 输出为:J printf("%c\n", h1); // 输出为:J 。通过共用体变量的名称,也能读取成员的值,因为所有成员共用一块内存空间 h1.age = 5; // 此时会覆盖 h1.name 的值 printf("%c\n", h1.name); // 输出为空 printf("%d\n", h1.age); // 输出为:5 printf("%d\n", sizeof(h1)); // 输出为:4 。因为一个共用体变量的内存容量,取决于容量最大的那个成员
例:假设用户想存储一段数据,但它的数据类型可能变化。直接的解决方案是,给该数据创建不同数据类型的多个变量:
int data_i; long data_l; float data_f;
但这样同时只有一个变量会被使用,其它变量浪费内存空间。不如创建一个共用体,节省内存空间:
union Data { int i; long l; float f; } data;
# 枚举
枚举,是用关键字 enum ,定义一种数据类型,并声明可能出现的各种取值。
enum Day{Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}; // 创建一个名为 Day 的枚举类型,并声明它可能出现的各种取值 enum Day d1; // 创建一个 Day 枚举类型的变量 d1 d1 = Sunday; // 给枚举变量赋值
枚举的每项取值,称为一个枚举元素。
- 枚举元素,属于符号常量,像宏定义。
Monday = 0; // 语法错误。符号常量是只读的,不能赋值 enum Day{0, 1}; // 语法错误。数字常量不能用作符号常量 enum Day{"Monday", "Tuesday"}; // 语法错误。字符串常量不能用作符号常量
- 枚举元素,在底层默认以整数的形式存储,从 0 开始编号。
printf("%d\n", Sunday); // 输出为:6 printf("%d\n", d1); // 输出为:6 printf("%d\n", d1 == Sunday); // 输出为:1 d1 = 6; // 可以给枚举变量赋值一个整数,这里相当于赋值为 Sunday d1 = 9; // 如果赋值一个超出枚举范围的整数,编译器并不会报错
- 一个枚举变量,虽然声明了可能出现的各种取值,但用户的赋值超出枚举范围时,编译器并不会报错。因此用户需要自己检查,赋值是否合理。
enum Tips{Hello, World}; d1 = Hello; // Hello 属于 enum Tips 的枚举值,不属于 enum Day 的枚举值。但编译器依然允许给 d1 赋值 Hello d1 = hello; // 此时编译器会报错 hello 是一个未被声明的标识符
- 在定义枚举类型时,用户可以指定各个元素的底层存储值(可以为 int、char 类型)。
enum Day{Monday='M', Tuesday='T', Wednesday, Thursday, Friday, Saturday, Sunday='S'};
- 枚举元素,属于符号常量,像宏定义。
例:通过枚举,定义 bool 数据类型
enum Bool{false=0, true=1}; enum Bool b1 = true;
# 运算符
很多语句包含一个表达式。表达式用于对变量、常量进行某种运算,得到一个值。
一个表达式,由运算符(又称为操作符)和操作数组成。
- 运算符,表示运算的类型,分为多种。
- 操作数,是运算符的操作对象。
- 例如
x = 1 + 2
中,=
、+
是运算符,x
、1
、2
是操作数。
- 例如
赋值运算符:
a = 表达式 // 将表达式的值,存储到变量 a 中
- 赋值运算符两侧的内容,分别称为左值、右值。
- 左值只能填一个变量名。
- 右值可以填一个表达式。
- 可以在 = 的左侧加一个算术运算符,从而在赋值之前,先执行算术运算。例:
int a = 10; a += 1+2; printf("%d\n", a); // 输出为 13
a = 表达式
整体也是一个表达式,称为赋值表达式。这个表达式的返回值,是当前的赋值。因此,可以在一个语句中,使用多个赋值运算符,从右到左依次赋值。例:int x, y; x = y = 1; // 相当于 x = ( y = 1)
- 不能在创建变量的同时,多重赋值:
int x = y = 1; // 这里会报错变量 y 未定义
- 赋值运算符两侧的内容,分别称为左值、右值。
算术运算符:
a + b // 加法 a - b // 减法 a * b // 乘法 a / b // 除法 a % b // 取模
- 两个数字做除法时,
- 如果两个数字,都是整型,则返回值会是整型。此时采用商作为返回值,而余数会被丢弃。
- 如果任一数字,是浮点型,则返回值会是浮点型。此时余数以小数的形式保留。
- 例:
printf("%d\n", 1/2); // 输出为:0 printf("%f\n", 1/2.0); // 输出为:0.500000 printf("%f\n", 2/2.0); // 输出为:1.000000 ,这里即使能整除,没有余数,返回值也是浮点型
- 取模运算,是对两个整型数字,做除法,然后返回余数。例:
printf("%d\n", 0%2); // 输出为:0 printf("%d\n", 1%2); // 输出为:1
- 两个数字做除法时,
关系运算符:
a > b // 大于 a < b // 小于 a >= b // 大于等于 a <= b // 小于等于 a == b // 等于 a != b // 不等于
- 关系运算符,又称为比较运算符,用于比较两个值的大小。
- 这些比较运算,用汉语描述,有两种结果:真、假。
- 在 C 语言中,这两种结果分别用整型 1、0 表示。如下:
printf("%d\n", 1>2); // 1>2 这个表达式,输出为:0
- 在 C++ 语言中,这两种结果分别用布尔值 true、false 表示。
- 在 C 语言中,这两种结果分别用整型 1、0 表示。如下:
逻辑运算符:
condition1 && condition2 // 逻辑与。如果两个条件表达式都为 true ,则逻辑运算的结果为 true ,否则结果为 false condition1 || condition2 // 逻辑或。如果两个条件表达式,至少一个为 true ,则逻辑运算的结果为 true 。如果两个都为 false ,则结果为 false ! condition1 // 逻辑非。如果表达式取值为 true ,则f反转为 false 。如果表达式取值为 false ,则反转为 true
- 逻辑运算符,用于组合两个条件表达式。例:
printf("%d\n", (1>2) && (3>2)); // 输出为:0 printf("%d\n", (1>2) || (3>2)); // 输出为:1 printf("%d\n", !(1>2)); // 输出为:1
位运算符:
a & b // 按位与。检查两个操作数的二进制位(bit),如果第 n 位都为 1 ,则结果的第 n 位为 1 ,否则结果的第 n 位为 0 a | b // 按位或。如果 a 或 b 的第 n 位为 1 ,则结果的第 n 位为 1 。如果两者都为 0 ,则结果的第 n 位为 0 a ^ b // 按位异或。如果 a 与 b 的第 n 位不相同,则结果的第 n 位为 1 ,否则结果的第 n 位为 0 a << 3 // 按位左移。将操作数的二进制值左移几位,然后在右边空位补上 0 a >> 3 // 按位右移。 ~a // 按位取反。将操作数每一位的值,取反
- 位运算用于修改操作数的二进制值,也就是修改某些位(bit)。要求操作数必须是整型。
上述运算符,书写格式大多为
操作数1 运算符 操作数2
,存在两个操作数,因此属于二元运算符。以下是自增、自减运算符,属于一元运算符:
++ // 自增 -- // 自减
- 使用
++
,可以在读取一个变量的取值的同时,将该变量的取值加 1 。 - 如果
++
放在变量的左侧,则称为前缀自增。会先将该变量的值加 1 ,然后读取该变量的值。例如b=++a
相当于a=a+1; b=a;
。 - 如果
++
放在变量的右侧,则称为后缀自增。会先读取该变量的值,将该变量的值加 1 。例如b=a++
相当于b=a; a=a+1;
。 - 例:
int a=0; printf("%d\n", ++a); // 输出为:1 printf("%d\n", a); // 输出为:1 a=0; printf("%d\n", a++); // 输出为:0 printf("%d\n", a); // 输出为:1
--
可以在读取一个变量的取值的同时,将该变量的取值减 1 ,用法与++
同理。
- 使用
以下是条件运算符,它属于三元运算符:
表达式1 ? 表达式2 : 表达式3
- 语法为:若表达式 1 结果为 true ,则执行表达式 2 并返回运算结果。若表达式 1 结果为 false ,则执行表达式 3 并返回运算结果。
- 例:
int a; a = 'A'==65 ? 1 : 0;
如果一个表达式中,包含多个运算符,则需要考虑各个运算符的优先级、结合性。
- 如果几个运算符的优先级不同,则找到优先级最高的那个运算符,优先执行它。
- 如果几个运算符的优先级相同,则按照从左到右的顺序,逐个执行这些运算符,称为左结合性。但某些运算符是右结合性。
- 例如
printf("%d\n", 1<3<2);
的输出为 1 ,因为先计算最左侧的<
运算符,发现1<3
的运算结果为 1 。然后计算1<2
,结果为 1。
- 例如
- 各种运算符,按优先级从大到小排列如下:
()
括号、[]
下标- 例如
if(x-- > 2)
等价于if((x--) > 2)
- 例如
if(!x || y--)
等价于if((!x) || (y--))
- 例如
!
逻辑非、++
自增、--
自减、+
正号、-
负号、&
取地址、*
取消引用- 这些运算符都是右结合性。
- 自增、自减运算符只能作用于变量。例如
++x++
按右结合性来看,等价于++(x++)
,左边的++
作用于表达式(x++)
,因此语法错误。
*
乘法、/
除法、%
取模+
加法、-
减法<<
按位左移、>>
按位右移- 关系运算符
&&
逻辑与、||
逻辑或- 条件运算符
=
赋值,
逗号- 逗号的优先级最低。
- 由逗号连接的多个表达式,称为逗号表达式。C 语言会按左结合性,依次计算各个表达式,然后返回最后一个表达式的值,作为整个逗号表达式的结果。
- 例:
int a = 0; printf("%d\n", (a++, a++, a)); // 输出为 2
- 上面介绍了多种运算符的优先级、结合性,实际应用中,不建议全部记忆它们。如果一个表达式包含多个运算符,则建议插入多个
( )
,显示区分优先级,更容易阅读。- 例如将
if(!x || y--)
改写为if((!x) || (y--))
- 例如将
# 流程控制
- C 语言提供了以下几种语法,来控制程序的运行流程:
- 选择结构
- if
- match-case
- 循环结构
- for
- while
- do-while
- 选择结构
# if
if 语句用于判断条件、选择分支,最简单的格式如下:
if(condition){ statement_block }
- 如果 condition 条件表达式结果为 true ,则会执行 statement_block 语句块。否则,跳过 statement_block 。
- 例:
if(1 > 0) printf("yes\n");
可选加上 else 语句,表示条件表达式为 false 时,执行什么操作。如下:
if(condition){ statement_block } else{ statement_block }
可选加上任意个 else if 语句,从而依次判断多个条件表达式。如下:
if(condition){ statement_block } else if(condition){ statement_block } else if(condition){ statement_block }
condition 怎么写?
- 通常是一个表达式,使用比较运算符,或者逻辑运算符。使得表达式的运算结果为整型 1 或 0 ,表示条件为真或假。
- 也可以是其它类型的表达式,使得运算结果为其它数字、字符、字符串,此时都视作条件为真。如下:
if(1 + 2) // 结果为 3 if(' ') // 结果为一个空格字符 if("") // 结果为一个空字符串
# switch
如果一个表达式,存在多个可能的取值,需要分情况处理。则可以用多层 else if ,但用 switch 语句更简洁。格式如下:
switch(表达式){ case value1: // case 语句的结尾有冒号 语句块 break; case value2: // 多个 case 可以共用一个语句块 case value3: 语句块 break; default: 语句块 }
运行流程如下:
- 首先执行 switch 表达式,得到一个值。然后从上到下检查各个 case ,如果 switch 值与某个 case 值相等,则执行该 case 的语句块。
- switch 表达式的运算结果,必须是数字或字符、字符串类型。
- case 的值,必须是常量,不能是变量。
- 执行每个 case 语句块时,
- 如果遇到了 break 语句,则会结束整个 switch 语句。
- 如果该 case 不存在 break 语句,则会继续检查下一个 case 。
- 实际应用中,程序员通常会在每个 case 语句块中写一个 break 语句,很少不写 break 。因此,Python 语言简化了语法,执行一个 case 语句块之后,总是立即结束 switch 语句。
- 如果没有执行任何 case 语句块,则执行 default 语句块。不过,default 语句块可以省略不写。
- 首先执行 switch 表达式,得到一个值。然后从上到下检查各个 case ,如果 switch 值与某个 case 值相等,则执行该 case 的语句块。
例:
int x=0; switch(a+1){ case 0: printf("hello\n"); break; case 1: case '1': printf("world\n"); break; default: printf("default\n"); }
# for
- for 语句用于在某个条件为真时,循环执行某个语句块。格式如下:运行流程如下:
for(表达式1; 表达式2; 表达式3){ 循环体语句块 }
- 首先执行一次表达式 1 。
- 然后判断表达式 2 (属于条件表达式)的结果是真是假。
- 如果表达式 2 为真,则执行作为循环体的语句块。然后执行一次表达式 3 ,再次判断表达式 2 的真假。
- 如果表达式 2 为假,则退出 for 循环。
- 执行循环体时,
- 如果遇到 break 语句,则会退出该层循环结构。
- 如果遇到 continue 语句,则会结束本次循环,跳到开始下一次循环。
- 例:打印一连串数字
int i; for(i=0; i<5; i++) printf("%d\n", i);
# while
- while 语句相当于简化版的 for 语句。格式如下:运行流程如下:
while(条件表达式){ 循环体; }
- 判断条件表达式的结果是真是假。
- 如果为真,则执行作为循环体的语句块。然后再次判断条件表达式。
- 如果为假,则退出 while 循环。
- 判断条件表达式的结果是真是假。
# do-while
- do-while 语句与 while 语句类似,但是先执行一次循环体,然后判断条件表达式。格式如下:
do{ 循环体; } while(条件表达式); // 注意这句结尾有分号
# goto
- goto 用于跳转到指定一个标签,执行从这里开始的代码。
- 优点:可以随意改变程序的运行流程,灵活性大。
- 缺点:灵活性太大,导致源代码难以被人类阅读。因此尽量不用 goto 。
- 例:
#include <stdio.h> int main(){ step1: printf("hello\n"); step2: printf("world\n"); goto step1; // 此时跳转到 step1 处执行代码,而 step1 下面又遇到这个 goto ,导致死循环 return 0; }
# 函数
函数,是将一个语句块封装起来,可以被其它语句调用。
如何定义一个函数?
- 语法格式如下:
返回值类型 函数名 (参数列表) { 语句块 }
- 第一行称为函数头,它定义了返回值类型、函数名、参数列表。
- 不能在函数语句块内,嵌套定义另一个函数。
- 例:
#include <stdio.h> void hello(){ // 定义一个 hello() 函数。它的返回值类型为 void ,表示没有返回值。参数列表为空,表示该函数没有参数 printf("Hello\n"); printf("World\n"); } int main(){ hello(); // 调用 hello() 函数 return 0; }
- 例:
#include <stdio.h> int sum(int x, int y){ // 定义一个 sum() 函数。它的返回值类型为 int ,参数列表为 (int x, int y) return x + y; } int main(){ sum(1, 2); // 调用 sum() 函数,并且不接收函数返回值 printf("%d\n", sum(1, 2)); // 调用 sum() 函数,并且将函数返回值传给 printf 函数 return 0; }
- 定义 sum() 函数时,参数列表为
(int x, int y)
,表示用户调用该函数时,必须输入两个 int 类型的值。 - 因此执行
sum(1, 2)
可以调用该函数,但不能执行以下语句:sum(1) // 语法错误。需要 2 个值,实际输入了 1 个值 sum(1, 2, 3) // 语法错误。需要 2 个值,实际输入了 3 个值 sum(1, 3.14) // 3.14 是 float 类型的值,可以被赋值给 int y ,此时发生强制类型转换 sum(1, 'A') // 'A' 是 char 类型的值,可以被赋值给 int y ,此时发生强制类型转换
- 定义 sum() 函数时,参数列表为
- 语法格式如下:
如何调用一个函数?
- 编写
函数名(参数列表)
即可调用一个函数。 - 调用一个函数之前,必须先声明该函数,否则编译器找不到该函数的存在。
- 因此,通常将 main() 函数,放在源文件的末尾,放在其它函数之后。
- C 语言规定,调用函数的语句,只能放在某个函数内部,不能放在函数之外的全局代码块。
#include <stdio.h> printf("%s\n", "Hello"); // 语法错误。不能在全局代码块中调用函数 int main(){ return 0; }
- 在一个函数中,可以调用其它任意个函数。前者称为"主调函数",后者称为"被调函数"。
- 调用一个函数时,编译器会按以下流程工作:
- 创建该函数的形参。
- 执行该函数的语句块。
- 函数执行结束,返回一个值给主调函数。
- 编写
定义函数时,声明的参数,又称为形参。用户调用函数时,传入的参数,又称为实参。
- 调用
sum(1, 2)
时,相当于执行以下代码:int x = 1; // 创建形参 x ,赋值为 1 int y = 2; // 创建形参 y ,赋值为 2
- 形参由实参赋值,也就是拷贝了一份实参的值。因此,即使被调函数修改形参的值,也不会影响主调函数中实参的值。除非实参是一个指针,被调函数可以修改该指针所指对象的值。
- 当函数执行结束时,所有形参会被自动销毁,因为它们属于函数内临时创建的局部变量。
- 调用
函数的返回值。
- 编译器执行函数的语句块时,会从上到下执行其中的各条语句,
- 如果遇到 return 语句,则会立即返回一个值,并结束该函数。
- 如果不存在 return 语句,则执行了该语句块的最后一行语句,就结束该函数。
- 当函数运行结束之后,应该返回一个值,表示该函数的运行结果,让函数外部的代码知晓。
- 例如有的函数,负责执行一些数学运算,然后将运算结果 return 到函数外部。
- 例如有的函数,负责修改一些磁盘文件,然后将修改结果 return 到函数外部。
- 函数运行结束时,局部变量会被自动销毁。因此函数应该 return 一个值告诉外部,否则外部难以知道该函数的运行结果。
- 除了 return 语句,函数还可以通过以下方式,将运行结果告诉外部:
- 修改全局变量
- 获得外部变量的内存地址,通过指针来修改其值
- 函数的返回值,可以是任意数据类型,由用户自己决定。
- 如果返回值类型为 void ,则表示没有返回值,此时不能使用 return 语句,或者只能使用
return;
。
- 如果返回值类型为 void ,则表示没有返回值,此时不能使用 return 语句,或者只能使用
- 编译器执行函数的语句块时,会从上到下执行其中的各条语句,
# 预处理指令
如果某行源代码以
#
开头,则会被编译器视作预处理指令。- 预处理指令,可以写在源代码的任意位置,会作用于全局代码块。
- 预处理指令,会被编译器在编译时执行、保存结果,而不是等到程序启动时才执行。
- C 语言的可执行语句,会被编译为机器代码,等到程序启动时才执行。而预处理指令,不属于可执行语句,末尾不需要加分号
;
。
#include 头文件名
是一种预处理指令,用于将一个文件的源代码,导入当前文件。- 例:
#include <stdio.h> // 如果文件名用尖括号包住,则编译器会到系统默认目录(即环境变量 INCLUDE 指定的目录)及其子目录中,寻找名为 stdio.h 的文件 #include "myheader.h" // 如果文件名用双引号包住,则编译器会到源文件所在目录及其子目录中,寻找该文件名。如果没找到,再到系统默认目录下寻找。 #include "/root/test.h" // 也可以指定文件的绝对路径。但这样麻烦,每次文件路径变化,就需要修改 include 指令
- 例:
#define 表达式1 表达式2
是一种预处理指令,用于定义一个宏(macro)。- 编译器发现了宏,则会将源代码中所有
表达式1
,替换成表达式2
,然后再开始编译源代码。 - 例:
#define PI 3.14 // 定义一个宏,作为符号常量 printf("%f\n", PI); // 这行代码在编译之前,会被替换成 printf("%f\n", 3.14); #undef PI // 取消一个宏
#define sum(x,y) x+y // 定义一个包含参数的宏 printf("%d\n", sum(1,2)); // 这行代码在编译之前,会被替换成 printf("%d\n", 1+2);
- 编译器内置了几个宏,可供用户调用:
printf("%s\n", __FILE__); // 这行代码,所在的源文件名称 printf("%d\n", __LINE__); // 这行代码,所在的源文件行号
- 宏定义的原理是字符串替换,与函数传参并不同,需要注意运算符的优先级。
#define mul(x,y) x*y printf("%d\n", mul(1+1, 2)); // 会被替换成 printf("%d\n", 1+1*2);
#define mul(x,y) (x)*(y) printf("%d\n", mul(1+1, 2)); // 会被替换成 printf("%d\n", (1+1)*(2));
- 给宏定义中的参数加上井号 # 前缀,就会嵌入该变量的标识符名称,作为一个字符串。
#define DEBUG(x) printf("This variable is: "#x"=%d \n", x) int i = 0; DEBUG(i); // 输出为:This variable is: i=0
- 某些程序员,会使用宏,替换 C 语言的语法结构。这样编写的代码,与 C 语言的语法差异大,难以被其他人阅读。
#define IF if( #define THEN ){ #define ELSE }else( #define FI ;}
- 编译器发现了宏,则会将源代码中所有
#if ... #else ... #endif
是一种预处理指令,用于根据条件,判断是否编译某些源代码。- 例:
#if 1 > 2 // 如果条件为真,则编译以下源代码 printf("Hello\n"); #else // 如果条件为假,则编译以下源代码 printf("%f\n", 3); #endif
- 例:
#if defined PI // 检查是否定义了名为 PI 的宏 #undef PI // 取消定义 PI #define PI 3 // 重新定义 PI #endif
- 例:
#if ... ... #elif ... // 多路选择结构 ... #else ... #endif
- 例:
# 头文件
假设用户需要在一个源文件中,调用另一个源文件中的变量、函数等标识符,则可以这样做:
- 在
test1.c
文件中,编写以下代码:int x = 1; int sum(int a, int b){ return a + b; }
- 在
test2.c
文件中,调用test1.c
中的标识符:#include <stdio.h> int main(){ extern x; // 将 x 声明为外部变量,请编译器到外部代码块,寻找该变量的定义语句 printf("%d\n", x); printf("%d\n", sum(1,2)); // 所有函数,默认都为外部函数,不必再用 extern 声明 return 0; }
- 执行
gcc test2.c test1.c
编译。- 此时需要指定所有源文件,将它们的源代码合并在一起,编译得到一个程序。
- 如果执行
gcc test2.c
编译,则会报错找不到 x、sum 标识符,它们的定义语句不在test2.c
文件中。
- 在
使用
#include
指令,可以将一个文件的源代码,导入当前文件。因此上述示例,可以改进为以下做法:- 编写
test1.c
文件。 - 在
test2.c
文件中,导入test1.c
的源代码:#include <stdio.h> #include "test1.c" // include 指令会将 test1.c 的源代码复制到此处 int main(){ printf("%d\n", x); // 访问变量 x ,编译器会自动找到该变量的定义语句,来自 test1.c 源代码 printf("%d\n", sum(1,2)); return 0; }
- 执行
gcc test2.c
编译。- 此时只需编译
test2.c
文件,因为它已经包含了test1.c
的内容。
- 此时只需编译
- 编写
#include
指令可以导入任意文件,只要文件内容是 C 语言源代码。- 用户编写大量源代码时,为了分类管理,一般将源代码保存为两种文件:
- 头文件(header file):扩展名为
.h
,用于存放一些适合共享的源代码,准备被其它文件导入。 - 源文件(source file):扩展名为
.c
,用于存放头文件以外的源代码。
- 头文件(header file):扩展名为
- 用户编写大量源代码时,为了分类管理,一般将源代码保存为两种文件:
因此上述示例,可以改为导入头文件:
编写
test1.c
文件。编写
test1.h
文件:int x; // 声明变量,也可以赋值 int sum(int a, int b); // 声明函数原型,也就是写一遍函数头,用分号 ; 结尾
- 头文件中,可以定义一个完整的函数。也可以只声明函数原型,此时需要在编译时,指定所有源文件,编译器才能找到该函数的定义语句。
- 函数原型,又称为函数签名(function signiture),它代表了一个函数的外部特征。
- 声明函数原型时,可以省略形参的名称,只写形参的数据类型。但不推荐这样做,降低了代码的可读性。
在
test2.c
文件中,导入test1.h
的源代码:#include <stdio.h> #include "test1.h" int main(){ printf("%d\n", x); printf("%d\n", sum(1,2)); return 0; }
执行
gcc test2.c test1.c
编译。
一个头文件可能被某个源文件重复导入。
- 假设 test1.h 被导入了 test2.h 。如果 test3.h 再导入 test1.h 和 test2.h ,则会重复导入 test1.h 。
- 重复导入的情况下,可能重复定义同一个标识符,导致编译器报错。
- 为了避免重复导入,通常将头文件的全部内容,用以下指令包住:
#ifndef _TEST1_H #define _TEST1_H // 这里创建一个宏,用于表示该头文件是否已被导入。通常将每个字母大写,并加上下划线 ... // 这里放置 test1.h 的原本内容 #endif