C 学习笔记
C语言
C 语言是一种通用的、面向过程式的计算机程序设计语言。
美国国家标准协会 ANSI:American National Standard Institute
C 语言标准
C 程序主要包括以下部分:
- 预处理器指令
- 函数
- 变量
- 语句 & 表达式
- 注释
C 数据类型
常用基本数据类型占用空间(64位机器为例)
- char : 1个字节
- int :4个字节
- float:4个字节
- double:8个字节
类型转换
- 隐式类型转换
- 显式类型转换
整数
- 默认为10进制 ,10 ,20。
- 以0开头为8进制,045,021。
- 以0b开头为2进制,0b11101101。
- 以0x开头为16进制,0x21458adf。
小数
单精度常量:2.3f 。
双精度常量:2.3,默认为双精度。
字符型常量
用英文单引号括起来,只保存一个字符’a’、’b’ 、’*’ ,还有转义字符 ‘\n’ 、’\t’。
字符串常量
用英文的双引号引起来 可以保存多个字符:”abc”。
如何确定声明一个变量时使用哪种类型进行声明?
在 C 语言中,正确选择变量类型是编写高效、可读且可维护代码的关键。下面是一些建议,可以帮助你在声明变量时选择合适的类型:
使用最小的适当类型: 选择能够容纳数据的最小类型。例如,如果你知道一个整数永远不会超过 100,可以使用
char
或short
类型而不是int
。1
char smallNumber = 50;
使用有符号或无符号类型: 如果你知道变量的值始终为正,可以使用无符号类型。否则,使用有符号类型。例如,如果存储年龄,不应该使用无符号整数,因为年龄不应该是负数。
1
unsigned int positiveValue = 42;
使用
int
作为默认整数类型: 在一般情况下,int
是最自然的整数类型,因为它通常与目标硬件架构相匹配。1
int count = 10;
使用
float
或double
作为默认浮点数类型: 对于浮点数,float
和double
是常用的类型。选择float
或double
取决于你对精度的需求。1
float price = 3.14f;
使用
size_t
用于数组索引和长度:size_t
是用于表示对象大小的无符号整数类型。在数组索引和长度上使用size_t
是一种良好的习惯。1
size_t arraySize = 20;
使用
stdint.h
中的精确宽度类型: 如果你需要确切的宽度,可以使用stdint.h
中的类型,如int32_t
、uint64_t
等。1
2
3
int32_t signedInt = -123;使用
const
和volatile
修饰符: 使用const
表示常量,volatile
表示易变性。这可以增加代码的可读性,并在编译器优化中发挥作用。1
2const int constantValue = 42;
volatile int sensorReading;在C语言中,
volatile
是一个关键字,用于告诉编译器不要优化某个变量的读取和写入操作。它主要用于两个方面:防止编译器优化:编译器在优化代码时会对变量进行一些假设,比如认为某个变量的值不会在未被代码直接修改的情况下改变。但是有些变量的值实际上可以被程序以外的因素修改,比如并发编程中的多线程、硬件中断、内存映射IO等。使用
volatile
告诉编译器不要对这个变量的访问进行优化,以避免出现不可预测的行为。保证内存访问顺序:当涉及到硬件寄存器或者并发访问时,使用
volatile
可以确保编译器不会对变量的读取和写入进行重排序,保证了访问的顺序性。
1
2
3
4
5volatile int counter = 0;
while (counter < 10) {
// 可能被外部因素改变 counter 的值
}在多线程编程或者嵌入式系统开发中,
volatile
经常用于确保对共享变量的正确访问。然而,需要注意的是,volatile
不能解决所有的并发访问问题,它只是告诉编译器不要对这个变量的访问进行优化,但并不提供线程安全性。在多线程环境下,还需要使用互斥锁(mutex)、原子操作或者其他同步机制来保证线程安全性。选择适当的指针类型: 使用
int*
表示整数指针,char*
表示字符指针等。指针类型的选择取决于你要处理的数据类型。1
2int data = 100;
int *pointerToInt = &data;根据需求选择
enum
或typedef
: 使用enum
枚举类型来定义命名的整数常量,使用typedef
创建自定义类型别名。1
2enum { RED, GREEN, BLUE };
typedef unsigned int uint;
总的来说,选择变量类型时要考虑数据的特性、范围、精度和处理需求。遵循这些准则有助于编写清晰、高效且易于维护的 C 代码。
C 变量
变量初始化:
- 声明时初始化,或声明后初始化。
- 局部变量未初始化,初始值是未定义的。
- 未初始化,当变量是 static 变量或函数外定义的全局变量时,变量默认初始化值如下:
- 整型变量(int、short、long等):默认值为0。
- 浮点型变量(float、double等):默认值为0.0。
- 字符型变量(char):默认值为’\0’,即空字符。
- 指针变量:默认值为NULL,表示指针不指向任何有效的内存地址。
- 数组、结构体、联合等复合类型的变量:它们的元素或成员将按照相应的规则进行默认初始化,这可能包括对元素递归应用默认规则。
extern:用于在别的文件中声明,不分配存储空间。声明后的变量可以在别的文件中定义,也可以调用别的文件中已定义的变量,也可以在函数中声明使用全局变量。
左值(Lvalues)和右值(Rvalues)
C 中有两种类型的表达式:
- 左值(lvalue):指向内存位置的表达式被称为左值(lvalue)表达式。左值可以出现在赋值号的左边或右边。
- 右值(rvalue):术语右值(rvalue)指的是存储在内存中某些地址的数值。右值是不能对其进行赋值的表达式,也就是说,右值可以出现在赋值号的右边,但不能出现在赋值号的左边。
变量
常量是固定值,在程序执行期间不会改变。这些固定的值,又叫做 字面量。
整数常量的后缀:U 表示无符号整数(unsigned int),L 表示长整数(long)。可组合使用。
浮点常量的后缀:f。浮点常量可使用 E 或 e 来进行指数形式的小数表示。一般情况下 double 类型的占位符可以用 %lf。**%.10lf** 就输出 10 位小数。
C 语言中 printf 输出 double 和 float 都可以用 %f 占位符 可以混用,而 double 可以额外用 %lf。
而 scanf 输入情况下 double 必须用 %lf,float 必须用 %f 不能混用。
字符串常量在内存中以 null 终止符 \0 结尾。
1 | char myString[] = "Hello, world!"; //系统对字符串常量自动加一个 '\0' |
常量的定义方式
- #define 宏定义
- const
区别是:
#define
是进行简单的文本替换,而const
是声明一个具有类型的常量。- 使用
const
编译器会进行类型检查。 #define
没有作用域限制,在之后的代码都生效。const
只在当前定义的块级作用域有效。
C 存储类(存储类别)
C 语言中有四种主要的存储类别,它们控制变量的存储位置、生命周期和作用域。
- auto 存储类:所有局部变量的默认存储类,不需要显式写出 auto。
- register 存储类:尽量将变量存储在寄存器,取决于硬件等限制。若被存储在寄存器,则不能取地址,因为不在内存 RAM 中。常用于需要频繁访问的变量,可提高程序运行速度。
- static 存储类:
- 指示编译器在程序的生命周期内保持局部变量的存在,而不需要在每次它进入和离开作用域时进行创建和销毁。
- 当 static 修饰全局变量时,会使变量的作用域限制在声明它的文件内。
- 全局声明的一个 static 变量或方法可以被任何函数或方法调用,只要这些方法出现在跟 static 变量或方法同一个文件中。
- extern 存储类:
- 用于定义在其他文件中声明的全局变量或函数。当使用 extern 关键字时,不会为变量分配任何存储空间,而只是指示编译器该变量在其他文件中定义(相当于当 extern 声明后,当前文件就可以引用该变量,如第三点所说)。
- 用于提供一个全局变量的引用,全局变量对所有的程序文件都是可见的。当您使用 extern 时,对于无法初始化的变量,会把变量名指向一个之前定义过的存储位置。
- 当您有多个文件且定义了一个可以在其他文件中使用的全局变量或函数(非 static)时,可以在其他文件中使用 extern 来得到已定义的变量或函数的引用。可以这么理解,extern 是用来在另一个文件中声明一个全局变量或函数。
判断语句
if … elseif … else
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16if(boolean_expression 1)
{
/* 当布尔表达式 1 为真时执行 */
}
else if( boolean_expression 2)
{
/* 当布尔表达式 2 为真时执行 */
}
else if( boolean_expression 3)
{
/* 当布尔表达式 3 为真时执行 */
}
else
{
/* 当上面条件都不为真时执行 */
}switch
1
2
3
4
5
6
7
8
9
10
11
12switch(expression){
case constant-expression :
statement(s);
break; /* 可选的 */
case constant-expression :
statement(s);
break; /* 可选的 */
/* 您可以有任意数量的 case 语句 */
default : /* 可选的 */
statement(s);
}三目运算符
1
Exp1 ? Exp2 : Exp3;
循环语句
while
1
2
3
4while(condition)
{
statement(s);
}for
1
2
3
4for ( init; condition; increment )
{
statement(s);
}do while
1
2
3
4do
{
statement(s);
}while( condition );
循环控制语句:
控制语句 | 描述 |
---|---|
break 语句 | 终止循环或 switch 语句,程序流将继续执行紧接着循环或 switch 的下一条语句。 |
continue 语句 | 告诉一个循环体立刻停止本次循环迭代,重新开始下次循环迭代。 |
goto 语句 | 将控制转移到被标记的语句。但是不建议在程序中使用 goto 语句。 |
函数
参数形式:
调用类型 | 描述 |
---|---|
传值调用 | 该方法把参数的实际值复制给函数的形式参数。在这种情况下,修改函数内的形式参数不会影响实际参数。 |
引用调用 | 通过指针传递方式,形参为指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行的操作。 |
根据函数能否被其他源文件调用,将函数区分为内部函数和外部函数。
内部函数:用 static 修饰,作用域局限于当前文件。
外部函数:在函数最左侧以 extern 修饰,该函数则为外部函数,可供其他文件调用。定义函数时省略 extern,则默认为外部函数。
在需要调用此函数的其他文件中,需要对此函数作声明(不要忘记,即使在本文件中调用一个函数,也要用函数原型来声明)。
在对此函数作声明时,要加关键字 extern,表示该函数是在其他文件中定义的外部函数。
inline 内联函数
inline
关键字只是一个建议,编译器不一定会采纳它。编译器可能会根据其内部优化策略和其他因素决定是否真正内联函数(可能仍为普通函数)。内联函数是一种编译器特性,它用于告诉编译器在调用函数时将函数体的代码插入到调用的地方,而不是像普通函数那样通过函数调用的方式进行执行。
内联扩展是用来消除函数调用时的时间开销。
递归函数不能定义为内联函数
内联函数一般适合于不存在while和switch等复杂的结构且只有1~5条语句的小函数上,否则编译系统将该函数视为普通函数。
内联函数只能先定义后使用,否则编译系统也会把它认为是普通函数。
对内联函数不能进行异常的接口声明。
1 |
|
普通函数的调用方式:
普通函数通过两种方式进行调用:函数调用和函数返回。
函数调用:
当程序执行到函数调用语句时,会将控制权转移到被调用函数的入口处,同时将函数调用时传递的参数值传递给被调用函数的形参。
函数调用过程包括保存当前函数的返回地址和相关状态(比如局部变量)等,以便在函数执行完毕后返回到调用点继续执行。
被调用函数执行完毕后,将返回值(如果有的话)传递给调用函数,并恢复调用函数的状态,继续执行下一条语句。函数返回:
在函数执行完毕后,会返回到调用函数的位置继续执行。
如果函数有返回值,则将其返回给调用函数;如果没有返回值,则直接返回到调用点。
同时,函数内的局部变量、函数参数等数据也会随着函数调用的结束而被销毁。
这个过程涉及到栈帧的管理,函数调用时会在内存中创建一个栈帧(stack frame)来保存函数的局部变量、参数和返回地址等信息。函数返回时,栈帧被销毁,控制权交还给调用函数。
这种函数调用方式是实现程序流程控制的基础,在函数调用和返回的过程中,程序可以在不同函数之间传递参数和返回值,实现模块化和结构化编程。
作用域
作用域是程序中定义的变量所存在的区域,超过该区域变量就不能被访问。
在函数或块内部的局部变量
在所有函数外部的全局变量
全局变量在整个程序生命周期内都是有效的。
局部变量和全局变量的名称可以相同,但是在函数内,如果两个名字相同,会使用局部变量值,全局变量不会被使用。
在形式参数的函数参数定义中
全局变量与局部变量在内存中的区别:
- 全局变量保存在内存的全局存储区中,占用静态的存储单元;
- 局部变量保存在栈中,只有在所在函数被调用时才动态地为变量分配存储单元。
数组
数组都是由连续的内存位置组成。最低的地址对应第一个元素,最高的地址对应最后一个元素。
1 | double balance[5] = {1000.0, 2.0, 3.4, 7.0, 50.0}; |
函数传入一维数组参数的三种方式
在传入数组作为参数(实际上传入的只是一个指针,即数组的起始地址,因此无法取数组大小),同时也要传入数组的大小,以便在函数内遍历数组元素时知道何时停止。
形式参数是一个指针
1
2void myFunction(int *param)
{}形式参数是一个已定义大小的数组
1
2void myFunction(int param[10])
{}形式参数是一个未定义大小的数组
1
2void myFunction(int param[])
{}
如何将二维数组作为实参传递给函数?
需要定义一个最高维度。
1 | double * MatrixMultiple(double a[][], double b[][]); // 错误 |
方法1: 第一维的长度可以不指定,但必须指定第二维的长度:
1 | void print_a(int a[][5], int n, int m) |
方法2: 指向一个有5个元素一维数组的指针:
1 | void print_b(int (*a)[5], int n, int m) |
方法3: 利用数组是顺序存储的特性,通过降维来访问原数组!
1 | void print_c(int *a, int n, int m) |
如何从函数返回数组?
C 不支持在函数外返回局部变量的地址,除非定义局部变量为 static 变量。
1 |
|
静态数组和动态数组
静态数组(即普通创建数组):
静态数组是在编译时声明并分配内存空间的数组。
静态数组具有固定的大小,在声明数组时需要指定数组的长度。
- 内存分配:在程序编译时,静态数组的内存空间就被分配好了,存储在栈上或者全局数据区。
- 大小固定:静态数组的大小在声明时确定,并且无法在运行时改变。
- 生命周期:静态数组的生命周期与其作用域相关。如果在函数内部声明静态数组,其生命周期为整个函数执行期间;如果在函数外部声明静态数组,其生命周期为整个程序的执行期间。
动态数组(手动进行内存分配):
动态数组是在运行时通过动态内存分配函数(如 malloc 和 calloc)手动分配内存的数组。
- 内存分配:动态数组的内存空间在运行时通过动态内存分配函数手动分配,并存储在堆上。需要使用
malloc
、calloc
等函数来申请内存,并使用free
函数来释放内存。 - 大小可变:动态数组的大小在运行时可以根据需要进行调整。可以使用
realloc
函数来重新分配内存,并改变数组的大小。 - 生命周期:动态数组的生命周期由程序员控制。需要在使用完数组后手动释放内存,以避免内存泄漏。
1 | int size = 5; |
数组名不可以自增,但是可以通过指向该数组的指针进行遍历
在 C 语言中,数组名是数组第一个元素的地址,但是数组名本身是一个指针常量(int * const p;
)(指向数组的首元素),因此不能进行自增或自减操作。
以下是一个例子:
1 |
|
在这个例子中,numbers
是一个数组名,尝试对其进行自增操作将导致编译错误,因为数组名是一个常量指针,不允许修改其值。如果你想移动到数组中的下一个元素,应该使用指向数组的指针来完成,而不是直接尝试对数组名进行自增。
1 | int *ptr = numbers; // 使用指针指向数组的首元素 |
这样,通过使用指针,你可以实现类似自增的操作,而不能直接对数组名进行自增。
指针常量和常量指针
指针常量
- 形式为:
int * const p;
- 指针指向的值可以修改,而指针本身的值一旦初始化则不可再更改。
常量指针
const int *p;
和int const* p;
- 指针指向的值不可以修改,指针本身的值,即指向可以修改。
枚举
用于定义一组具有离散值的常量。
默认从 0 开始递增。
声明枚举类型
1 | enum Day |
枚举变量定义方式
- 先定义枚举类型,再定义枚举变量
1 | enum DAY |
- 定义枚举类型的同时定义枚举变量
1 | enum DAY |
- 省略枚举名称,直接定义枚举变量
1 | enum |
在对整数类型转换为枚举类型时,需要进行显式类型转换。
1 |
|
指针
指针是一个变量,其值为另一个变量的地址,即,内存位置的直接地址。就像其他变量或常量一样,您必须在使用指针存储其他变量地址之前,对其进行声明。
在声明一个指针变量时,若未进行初始化,最好为其赋值为 NULL。即设置为空指针。通过 %p 查看就是 0x0。内存地址 0 有特别重要的意义,它表明该指针不指向一个可访问的内存位置。
指针的算数运算
可以对指针进行四种算术运算:++、–、+、-
- 指针的每一次递增,它其实会指向下一个元素的存储单元(如 int,一开始在1000,递增其数据类型的字节数后为1004)。
- 指针的每一次递减,它都会指向前一个元素的存储单元。
- 指针在递增和递减时跳跃的字节数取决于指针所指向变量数据类型长度,比如 int 就是 4 个字节。
指针的比较
可用于递增指针,判断是否到最后一个数组的指针位置。即从首地址到尾地址。
指针可以用关系运算符进行比较,如 ==、< 和 >。如果 p1 和 p2 指向两个相关的变量,比如同一个数组中的不同元素,则可对 p1 和 p2 进行大小比较。
下面的程序修改了上面的实例,只要变量指针所指向的地址小于或等于数组的最后一个元素的地址 &var[MAX - 1]
,则把变量指针进行递增:
1 |
|
指针数组
指向字符的指针数组存储一个字符串列表
代码中使用 const 修饰数组,是为了避免修改数组中字符串中的字符。但是我们可以修改数组中元素的指向,即
names[0] = "hello"
是可以修改的。
const char *names[]
声明了一个包含指向常量字符串的指针的数组。这种使用 const
的目的是为了确保字符串字面量是不可修改的。字符串字面量在 C 语言中是常量,试图修改它们的值是非法的。
使用 const
有两个主要好处:
- 防止修改字符串字面量: 字符串字面量是常量,它们的值在程序运行期间不能被修改。通过使用
const
,你告诉编译器这些指针指向的是常量数据,防止在代码中通过这些指针来修改字符串的尝试。 - 允许编译器进行优化: 使用
const
也允许编译器进行更好的优化。它可以知道这些字符串是不可变的,从而可以进行更多的优化,例如将它们放在只读存储区域。
因此,虽然你可以不使用 const
,但是添加 const
是一种良好的编程实践,可以帮助确保代码的正确性,并允许编译器进行更多的优化。如果你尝试修改指向字符串字面量的指针,编译器将产生警告,这有助于捕获潜在的错误。
1 |
|
二维指针
指向指针的指针是一种多级间接寻址的形式,或者说是一个指针链。通常,一个指针包含一个变量的地址。当我们定义一个指向指针的指针时,第一个指针包含了第二个指针的地址,第二个指针指向包含实际值的位置。
指针更像是一个找地址开门取物品的操作。其中 ***** 就是这个动作重复的次数,ptr 是取东西的门牌号也就是地址值:
- *ptr 是完成一次开门取东西操作最终取出来的东西。
- **ptr 是完成两次开门取物.需要注意的是第一次取得的是第二次要开的门的门牌号或者说地址,然后根据门牌号继续开门取物。 所以 *ptr 或者 **ptr 一定是取出来的东西,即为数值。而 ptr 一定是门牌号,即为地址值。
传递指针给函数
能接受指针作为参数的函数,也能接受数组作为参数
1 |
|
从函数返回指针
C 语言不支持在调用函数时返回局部变量的地址,除非定义局部变量为 static 变量。
函数指针
即指向函数的指针。
C语言中的函数名会被自动转换为该函数的指针,也就是 f = test; 中 test 被自动转换为 &test,而 f = &test; 中已经显示使用了 &test,所以 test 就不会再发生转换了。因此直接引用函数名等效于在函数名上应用 & 运算符,两种方法都会得到指向该函数的指针。
指向函数的指针必须初始化,或者具有 0 值,才能在函数调用中使用。
与数组一样:
- 禁止对指向函数的指针进行自增运算++
- 禁止对函数名赋值,函数名也不能用于进行算术运算。
函数指针变量的声明:
1 | typedef int (*fun_ptr)(int,int); // 声明一个指向同样参数、返回值的函数指针类型 |
这行代码使用 typedef
关键字定义了一个名为 fun_ptr
的新类型,该类型是一个指向函数的指针。具体来说,这个函数接受两个 int
类型的参数并返回一个 int
类型的值。
让我们逐步解释这个 typedef
语句:
int (\*fun_ptr)(int, int)
: 这部分定义了一个指向函数的指针类型。在括号中,(int, int)
表示函数的参数列表,而int
表示函数的返回类型。所以int (*fun_ptr)(int, int)
表示fun_ptr
是一个指向接受两个int
参数并返回int
值的函数的指针。typedef
:typedef
是 C 语言中用于为已有类型创建别名的关键字。在这里,typedef
被用来为之前定义的函数指针类型创建一个新的名称。fun_ptr
: 这是我们为函数指针类型定义的别名,现在我们可以使用fun_ptr
代替int (*fun_ptr)(int, int)
。
因此,通过这个 typedef
,你可以以更简洁的方式声明一个函数指针,如下所示:
1 | typedef int (*fun_ptr)(int, int); |
这样,你可以使用 my_function_pointer
来声明、定义和使用指向接受两个 int
参数并返回 int
值的函数的指针,而不必每次都写出完整的函数指针类型。这提高了代码的可读性和可维护性。
使用函数指针的例子
1 |
|
指针函数是指返回指针的函数。如从函数中返回数组。
回调函数
函数指针变量可以作为某个函数的参数来使用的,回调函数就是一个通过函数指针调用的函数。
回调函数是一种在程序执行过程中作为参数传递给其他函数的函数。回调函数允许你将一段代码传递给另一个函数,在特定的事件发生时执行。在 C 语言中,回调函数通常通过函数指针来实现。
简单讲:回调函数是由别人的函数执行时调用你实现的函数。
以下是来自知乎作者常溪玲的解说:
你到一个商店买东西,刚好你要的东西没有货,于是你在店员那里留下了你的电话,过了几天店里有货了,店员就打了你的电话,然后你接到电话后就到店里去取了货。在这个例子里,你的电话号码就叫回调函数,你把电话留给店员就叫登记回调函数,店里后来有货了叫做触发了回调关联的事件,店员给你打电话叫做调用回调函数,你到店里去取货叫做响应回调事件。
将函数指针作为参数传递给函数的例子,也是回调函数的例子
1 |
|
C 字符串
C 语言中,字符串实际上是使用空字符 \0 结尾的一维字符数组。因此,\0 是用于标记字符串的结束。
空字符(Null character)又称结束符,缩写 NUL,是一个数值为 0 的控制字符,\0 是转义字符,意思是告诉编译器,这不是字符 0,而是空字符。
下列两种定义字符串方式等价:
‘’ 里面只能放一个字符;
“” 里面表示是字符串系统自动会在串末尾补一个 0。
1 | char site[7] = {'R', 'U', 'N', 'O', 'O', 'B', '\0'}; |
头文件 string.h
中有大量操作字符串的函数:
序号 | 函数 | 目的 |
---|---|---|
1 | strcpy(s1, s2); | 复制字符串 s2 到字符串 s1。 |
2 | strcat(s1, s2); | 连接字符串 s2 到字符串 s1 的末尾。 |
3 | strlen(s1); | 返回字符串 s1 的长度,不包含 \0 。 |
4 | strcmp(s1, s2); | 如果 s1 和 s2 是相同的,则返回 0;如果 s1<s2 则返回小于 0;如果 s1>s2 则返回大于 0。 |
5 | strchr(s1, ch); | 返回一个指针,指向字符串 s1 中字符 ch 的第一次出现的位置。 |
6 | strstr(s1, s2); | 返回一个指针,指向字符串 s1 中字符串 s2 的第一次出现的位置。 |
strlen 和 sizeof 得到的结果类型为 unsigned int 类型,使用 %ld 占位符。
sizeof 计算的是变量的大小,不受字符 \0 影响;
而 strlen 计算的是字符串的长度,以 \0 作为长度判定依据,也就是不将 \0 算在长度中。
1 | char str[] = "hello"; |
结构体
C 数组允许定义可存储相同类型数据项的变量,结构体是 C 编程中另一种用户自定义的可用的数据类型,它允许您存储不同类型的数据项。
访问结构体成员用
.
运算符,若是结构体指针,则用->
运算符访问。
结构体指针也可以作为函数参数。结构体大小的计算使用
sizeof
。考虑大小端对齐。
1 | struct tag { |
tag 是结构体标签。
member-list 是标准的变量定义,比如 int i; 或者 **float f;**,或者其他有效的变量定义。
variable-list 结构变量,定义在结构的末尾,最后一个分号之前,您可以指定一个或多个结构变量。
结构体的三种声明方式
- 使用结构体直接声明变量,不给出结构体标签
- 给出结构体标签及其完整定义,另起一条语句进行结构体变量声明。与第一种方式不同,不能进行相互赋值。
- 使用 typedef 创建结构体类型,然后用新类型进行声明。
1 | //此声明声明了拥有3个成员的结构体,分别为整型的a,字符型的b和双精度的c |
结构体变量初始化
对结构体变量可以在定义时指定初始值。
1 |
|
如果两个结构体互相包含,则需要对其中一个结构体进行不完整声明
1 | struct B; //对结构体B进行不完整声明 |
共用体
共用体是一种特殊的数据类型,允许您在相同的内存位置存储不同的数据类型。您可以定义一个带有多成员的共用体,但是任何时候只能有一个成员带有值。共用体提供了一种使用相同的内存位置的有效方式。
这意味着一个变量(相同的内存位置)可以存储多个多种类型的数据,最后赋给变量的值占用了内存位置。
共用体占用的内存应足够存储共用体中最大的成员。
union 语句格式:
1 | union [union tag] |
依旧可以使用 .
运算符访问共用体成员。
共用体作用
节省内存,有两个很长的数据结构,不会同时使用,比如一个表示老师,一个表示学生,如果要统计教师和学生的情况用结构体的话就有点浪费了!用共用体的话,只占用最长的那个数据结构所占用的空间,就足够了!
共用体应用场景
通信中的数据包会用到共用体:因为不知道对方会发一个什么包过来,用共用体的话就很简单了,定义几种格式的包,收到包之后就可以直接根据包的格式取出数据。
位域
C 语言的位域(bit-field)是一种特殊的结构体成员,允许我们按位对成员进行定义,指定其占用的位数。
位域的成员变量同结构体类似,也可以使用
.
或->
访问。位域的宽度不能超过它所依附的数据类型的长度,成员变量都是有类型的,这个类型限制了成员变量的最大长度,**:** 后面的数字不能超过这个长度。
位域可以是无名位域,这时它只用来作填充或调整位置。无名的位域是不能使用的。
如下代码,将每个成员变量能够使用的位数设置为1位,因此每个成员变量只能存储0或1,如果存2,那么编译器则会警告,并输出变量的值为0。
1 | struct |
上面的结构中,status 变量将占用 4 个字节的内存空间,但是只有 2 位被用来存储值。如果您用了 32 个变量,每一个变量宽度为 1 位,那么 status 结构将使用 4 个字节,但只要您再多用一个变量,如果使用了 33 个变量,那么它将分配内存的下一段来存储第 33 个变量,这个时候就开始使用 8 个字节。
一个位域存储在同一个字节中,如一个字节所剩空间不够存放另一位域时,则会从下一单元起存放该位域。也可以有意使某位域从下一单元开始。例如:
在这个位域定义中,a 占第一字节的 4 位,后 4 位填 0 表示不使用,b 从第二字节开始,占用 4 位,c 占用 4 位。
1 | struct bs{ |
typedef
C 语言提供了 typedef 关键字,您可以使用它来为类型取一个新的名字。下面的实例为单字节数字定义了一个术语 BYTE:
1 | typedef unsigned char BYTE; |
typedef 与 #define 的区别
typedef 仅限于为类型定义符号名称,**#define** 不仅可以为类型定义别名,也能为数值定义别名,比如您可以定义 1 为 ONE。
typedef 是由编译器执行解释的,**#define** 语句是由预编译器进行处理的。
(1)#define可以使用其他类型说明符对宏类型名进行扩展,但对 typedef 所定义的类型名却不能这样做。例如:
1 |
|
(2) 在连续定义几个变量的时候,typedef 能够保证定义的所有变量均为同一类型,而 #define 则无法保证。例如:
1 |
|
typedef 在语法上是一个存储类的关键字(如 auto、extern、mutable、static、register 等一样),虽然它并不真正影响对象的存储特性,如:
1 | typedef static int INT2; // 不可行 |
编译将失败,会提示“指定了一个以上的存储类”。
输入和输出
C 语言把所有的设备都当作文件。所以设备(比如显示器)被处理的方式与文件相同。以下三个文件会在程序执行时自动打开,以便访问键盘和屏幕。
标准文件 | 文件指针 | 设备 |
---|---|---|
标准输入 | stdin | 键盘 |
标准输出 | stdout | 屏幕 |
标准错误 | stderr | 您的屏幕 |
getchar() & putchar() 函数
int getchar(void)
函数从屏幕读取下一个可用的字符,并把它返回为一个整数。这个函数在同一个时间内只会读取一个单一的字符。您可以在循环内使用这个方法,以便从屏幕上读取多个字符。int putchar(int c)
函数把字符输出到屏幕上,并返回相同的字符。这个函数在同一个时间内只会输出一个单一的字符。您可以在循环内使用这个方法,以便在屏幕上输出多个字符。
1 |
|
gets() & puts() 函数
gets()
不安全的函数,编译器会警告。
char *gets(char *s) 函数从 stdin 读取一行到 s 所指向的缓冲区,直到一个终止符或 EOF。
puts()
参数是字符串,并且字符串后有个 \n
int puts(const char *s) 函数把字符串 s 和一个尾随的换行符写入到 stdout。
linux 中使用 fgets 和 fputs 替代 gets 和 puts
fgets
fgets
用于从文件中读取一行字符串(包括换行符\n
),并将其存储到指定的字符数组中。它读取最多size - 1
个字符,然后在读取到换行符或文件结束符时停止。
1 |
|
fputs
fputs
用于将字符串写入文件流中,直到遇到 null 字符 ('\0'
) 结束。它不会自动添加换行符。
1 |
|
scanf() 和 printf() 函数
- scanf()
int scanf(const char *format, …) 函数从标准输入流 stdin 读取输入,并根据提供的 format 来浏览输入。 - printf()
int printf(const char *format, …) 函数把输出写入到标准输出流 stdout ,并根据提供的格式产生输出。
1 |
|
文件读写
一个文件,无论它是文本文件还是二进制文件,都是代表了一系列的字节。C 语言不仅提供了访问顶层的函数,也提供了底层(OS)调用来处理存储设备上的文件。
打开文件
fopen:
1 | FILE *fopen( const char *filename, const char *mode ); |
模式 | 描述 |
---|---|
r | 打开一个已有的文本文件,允许读取文件。 |
w | 打开一个文本文件,允许写入文件。如果文件不存在,则会创建一个新文件。在这里,您的程序会从文件的开头写入内容。如果文件存在,则该会被截断为零长度,重新写入。 |
a | 打开一个文本文件,以追加模式写入文件。如果文件不存在,则会创建一个新文件。在这里,您的程序会在已有的文件内容中追加内容。 |
r+ | 打开一个文本文件,允许读写文件。 |
w+ | 打开一个文本文件,允许读写文件。如果文件已存在,则文件会被截断为零长度,如果文件不存在,则会创建一个新文件。 |
a+ | 打开一个文本文件,允许读写文件。如果文件不存在,则会创建一个新文件。读取会从文件的开头开始,写入则只能是追加模式。 |
如果处理的是二进制文件,则需使用下面的访问模式来取代上面的访问模式:
1 | "rb", "wb", "ab", "rb+", "r+b", "wb+", "w+b", "ab+", "a+b" |
关闭文件
成功关闭文件返回 0,关闭文件发生错误返回 EOF,即stdio.h中的宏定义的 -1。
1 | int fclose( FILE *fp ); |
写入文件
fputc() 把参数 c 的字符值写入到 fp 所指向的输出流中。如果写入成功,它会返回写入的字符,如果发生错误,则会返回 EOF。
1 | int fputc( int c, FILE *fp ); |
fputs() 把字符串 s 写入到 fp 所指向的输出流中。如果写入成功,它会返回一个非负值,如果发生错误,则会返回 EOF。
1 | int fputs( const char *s, FILE *fp ); |
int fprintf(FILE *fp,const char *format, …) 函数把一个字符串写入到文件中。
1 | fprintf(fp, "This is testing for fprintf...\n"); |
读取文件
fgetc() 函数从 fp 所指向的输入文件中读取一个字符。返回值是读取的字符,如果发生错误则返回 EOF。
1 | int fgetc( FILE * fp ); |
fgets() 从 fp 所指向的输入流中读取 n - 1 个字符。它会把读取的字符串复制到缓冲区 buf,并在最后追加一个 null 字符来终止字符串。
如果这个函数在读取最后一个字符之前就遇到一个换行符 ‘\n’ 或文件的末尾 EOF,则只会返回读取到的字符,包括换行符。
1 | char *fgets( char *buf, int n, FILE *fp ); |
int fscanf(FILE *fp, const char *format, …) 函数从文件中读取字符串,在遇到第一个空格和换行符时,它会停止读取。
rewind()
函数将文件指针重新定位到文件开头。
*void rewind (FILE __stream);
1 |
|
预处理器
C 预处理器不是编译器的组成部分,但是它是编译过程中一个单独的步骤。简言之,C 预处理器只不过是一个文本替换工具而已,它们会指示编译器在实际编译之前完成所需的预处理。我们将把 C 预处理器(C Preprocessor)简写为 CPP。
重要的预处理器指令
指令 | 描述 |
---|---|
#define | 定义宏 |
#include | 包含一个源代码文件 |
#undef | 取消已定义的宏 |
#ifdef | 如果宏已经定义,则返回真 |
#ifndef | 如果宏没有定义,则返回真 |
#if | 如果给定条件为真,则编译下面代码 |
#else | #if 的替代方案 |
#elif | 如果前面的 #if 给定条件不为真,当前条件为真,则编译下面代码 |
#endif | 结束一个 #if……#else 条件编译块 |
#error | 当遇到标准错误时,输出错误消息 |
#pragma | 使用标准化方法,向编译器发布特殊的命令到编译器中 |
如下:这个指令告诉 CPP 只有当 MESSAGE 未定义时,才定义 MESSAGE。
1 |
预定义宏
ANSI C 定义了许多宏。在编程中您可以使用这些宏,但是不能直接修改这些预定义的宏。
宏 | 描述 |
---|---|
DATE | 当前日期,一个以 “MMM DD YYYY” 格式表示的字符常量。 |
TIME | 当前时间,一个以 “HH:MM:SS” 格式表示的字符常量。 |
FILE | 这会包含当前文件名,一个字符串常量。 |
LINE | 这会包含当前行号,一个十进制常量。 |
STDC | 当编译器以 ANSI 标准编译时,则定义为 1。 |
预处理器运算符
宏延续运算符:\
1 |
字符串常量化运算符: #
在宏定义中,当需要把一个宏的参数转换为字符串常量时,则使用字符串常量化运算符(#)。在宏中使用的该运算符有一个特定的参数或参数列表。
1 |
|
标记粘贴运算符:##
宏定义内的标记粘贴运算符(##)会合并两个参数。它允许在宏定义中两个独立的标记被合并为一个标记。
1 |
|
defined()
运算符
与 #ifndef 没有区别,可以交换使用。
预处理器 defined 运算符是用在常量表达式中的,用来确定一个标识符是否已经使用 #define 定义过。如果指定的标识符已定义,则值为真(非零)。如果指定的标识符未定义,则值为假(零)。
1 |
|
参数化的宏
在使用带有参数的宏之前,必须使用 #define 指令定义。参数列表是括在圆括号内,且必须紧跟在宏名称的后边。宏名称和左圆括号之间不允许有空格。
1 |
|
使用#define含参时,参数括号很重要,如上例中省略括号会导致运算错误:
1 |
|
输出结果为:
1 | square 5+4 is 81 |
原因:
1 | square 等价于 (5+4)*(5+4)=81 |
头文件
引用系统头文件
1 |
引用用户头文件
1 |
防止头文件重复包含
如果一个头文件被引用两次,编译器会处理两次头文件的内容,这将产生错误。为了防止这种情况,标准的做法是把文件的整个内容放在条件编译语句中,如下:
1 |
|
这种结构就是通常所说的包装器 #ifndef。当再次引用头文件时,条件为假,因为 HEADER_FILE 已定义。此时,预处理器会跳过文件的整个内容,编译器会忽略它。
有条件引用
有时需要从多个不同的头文件中选择一个引用到程序中。例如,需要指定在不同的操作系统上使用的配置参数。您可以通过一系列条件来实现这点,如下:
1 |
|
但是如果头文件比较多的时候,这么做是很不妥当的,预处理器使用宏来定义头文件的名称。这就是所谓的有条件引用。它不是用头文件的名称作为 #include 的直接参数,您只需要使用宏名称代替即可:
1 |
|
SYSTEM_H 会扩展,预处理器会查找 system_1.h,就像 #include 最初编写的那样。SYSTEM_H 可通过 -D 选项被您的 Makefile 定义。
在 C 语言中,通过宏定义头文件名称的方法可以在预处理阶段实现条件引用,从而根据预定义的宏选择不同的头文件进行引用。这种技术在以下场景中特别有用:
跨平台开发: 当你需要编写在不同操作系统或平台上运行的代码时,可能需要根据不同的平台选择不同的头文件。通过宏定义头文件名称,可以使代码更具可移植性。
1
2
3
4
5
6
7
上述代码根据不同的操作系统选择不同的头文件,而不需要在源代码中直接写明头文件的名称。
配置文件切换: 当你有多个配置文件,例如不同的设置、特性或模块,可以通过宏定义选择性地引用不同的配置文件。
1
2
3在这里,通过修改
CONFIG_FILE
的定义,可以轻松地切换到不同的配置文件,而不需要修改源代码。功能开关: 在一些大型项目中,可能会有多个版本或变体。通过宏定义头文件名称,可以方便地开启或关闭特定功能。
1
2
3
4
5
6
7
8
9
通过定义或取消定义
FEATURE_ENABLED
宏,可以选择性地包含不同的功能头文件。
总的来说,宏定义头文件名称的使用区域主要是在预处理阶段,通过宏的定义来决定代码中需要包含哪个头文件,从而实现根据条件引用不同的头文件。这提供了一种在编译时根据不同条件定制代码行为的手段。
强制类型转换
在编程时,有需要类型转换的时候都用上强制类型转换运算符,是一种良好的编程习惯。
1 |
|
整数提升
整数提升是指把小于 int 或 unsigned int 的整数类型转换为 int 或 unsigned int 的过程。
1 |
|
常用的算数转换
常用的算术转换是隐式地把值强制转换为相同的类型。编译器首先执行整数提升,如果操作数类型不同,则它们会被转换为下列层次中出现的最高层次的类型:
常用的算术转换不适用于赋值运算符、逻辑运算符 && 和 ||。在这些情况下,系统不会进行自动的算术转换。相反,操作数的类型必须明确匹配,否则会导致编译错误。
当涉及到赋值运算符(=)、逻辑与运算符(&&)和逻辑或运算符(||)时,类型必须匹配,否则可能会导致编译错误。以下是一些例子:
赋值运算符 (=)
1 | int integerVar = 10; |
在这个例子中,试图将一个 double
类型的值赋给一个 int
类型的变量,这将导致编译错误。
逻辑与运算符(&&)和逻辑或运算符(||)
1 | int num1 = 5; |
在这个例子中,试图使用逻辑与运算符连接一个整数和一个双精度浮点数,这将导致编译错误。逻辑运算符的操作数必须是布尔类型。
1 | int x = 10; |
在这个例子中,试图使用逻辑或运算符连接一个整数和一个字符,同样会导致编译错误。逻辑运算符的操作数也必须是布尔类型。
在这些情况下,要避免错误,程序员需要显式地进行类型转换,确保操作数的类型匹配。例如,可以使用强制类型转换来将 double
转换为 int
,或者将字符转换为布尔值。
错误处理
C 语言不提供对错误处理的直接支持,但是作为一种系统编程语言,它以返回值的形式允许您访问底层数据。在发生错误时,大多数的 C 或 UNIX 函数调用返回 1 或 NULL,同时会设置一个错误代码 errno
,该错误代码是全局变量,表示在函数调用期间发生了错误。您可以在 errno.h 头文件中找到各种各样的错误代码。
开发人员应该在程序初始化时,把 errno 设置为 0,这是一种良好的编程习惯。
在使用 fprintf() 进行输出时,在对于错误消息的输出,应使用
stderrr
流进行输出,避免错误信息被重定向到标准输出的信息。如果使用
stdout
输出错误信息,那么当程序的标准输出被重定向到文件或其他地方时,错误信息也会被一并重定向,这可能导致错误信息被掩盖或混淆在正常输出中。使用stderr
可以确保错误信息更容易被注意到。
errno、perror() 和 strerror()
C 语言提供了 perror() 和 strerror() 函数来显示与 errno 相关的文本消息。
- perror() 函数显示您传给它的字符串,后跟一个冒号、一个空格和当前 errno 值的文本表示形式。
- strerror() 函数,返回一个指针,指针指向当前 errno 值的文本表示形式。入参为错误码
errno
号。(该函数头文件为string.h
)
1 |
|
程序退出状态
通常情况下,程序成功执行完一个操作正常退出的时候会带有值 EXIT_SUCCESS
。在这里,EXIT_SUCCESS
是宏,它被定义为 0
。
如果程序中存在一种错误情况,当您退出程序时,会带有状态值 EXIT_FAILURE
,被定义为 -1
。
1 |
|
递归
C 语言支持递归,即一个函数可以调用其自身。但在使用递归时,程序员需要注意定义一个从函数退出的条件,否则会进入死循环。
与直接的语句(如while循环)相比,递归函数会耗费更多的运行时间,并且要占用大量的栈空间。递归函数每次调用自身时,都需要把它的状态存到栈中,以便在它调用完自身后,程序可以返回到它原来的状态。未经精心设计的递归函数总是会带来麻烦。
可变参数
希望函数带有可变数量的参数,而不是预定义数量的参数。
使用
stdarg.h
头文件,通过va_list
声明可变参数列表、va_start()
初始化可变参数列表、va_arg()
获取当前可变参数列表位置的下一个可变参数、va_end()
清理赋予va_list
变量的内存。va
在这里是 variable-argument(可变参数) 的意思。
声明方式为:
省略号 … 表示可变参数列表。
1 | int func_name(int arg1, ...); |
- 定义一个函数,最后一个参数为省略号,省略号前面可以设置自定义参数。
- 在函数定义中创建一个 va_list 类型变量,该类型是在 stdarg.h 头文件中定义的。
- 使用 int 参数和 va_start() 宏来初始化 va_list 变量为一个参数列表。宏 va_start() 是在 stdarg.h 头文件中定义的。
- 使用 va_arg() 宏和 va_list 变量来访问参数列表中的每个项。
- 使用宏 va_end() 来清理赋予 va_list 变量的内存。
常用的宏有:
va_start(ap, last_arg)
:初始化可变参数列表。ap
是一个va_list
类型的变量,last_arg
是最后一个固定参数的名称(也就是可变参数列表之前的参数)。该宏将ap
指向可变参数列表中的第一个参数。va_arg(ap, type)
:获取可变参数列表中的下一个参数。ap
是一个va_list
类型的变量,type
是下一个参数的类型。该宏返回类型为type
的值,并将ap
指向下一个参数。va_end(ap)
:结束可变参数列表的访问。ap
是一个va_list
类型的变量。该宏将ap
置为NULL
。
代码示例:
1 |
|
内存管理
C 语言为内存的分配和管理提供了几个函数,主要包含在头文件
stdlib.h
中。内存是通过指针变量来管理的。指针是一个变量,它存储了一个内存地址,这个内存地址可以指向任何数据类型的变量,包括整数、浮点数、字符和数组等。C 语言提供了一些函数和运算符,使得程序员可以对内存进行操作,包括分配、释放、移动和复制等。
序号 | 函数和描述 | |
---|---|---|
1 | void *calloc(int num, int size); | 在内存中动态地分配 num 个长度为 size 的连续空间,并将每一个字节都初始化为 0。所以它的结果是分配了 num*size 个字节长度的内存空间,并且每个字节的值都是 0。 |
2 | void free(void *address); | 该函数释放 address 所指向的内存块,释放的是动态分配的内存空间。 |
3 | void *malloc(int num); | 在堆区分配一块指定大小的内存空间,用来存放数据。这块内存空间在函数执行完成后不会被初始化,它们的值是未知的。 |
4 | void *realloc(void *address, int newsize); | 该函数重新分配内存,把内存扩展到 newsize。如果调整成功,它将返回一个指向重新分配内存的指针,否则返回一个空指针。 |
void * 类型表示未确定类型的指针。C、C++ 规定 void * 类型可以通过类型转换强制转换为任何其它类型的指针。
直接使用原来的指针变量接收 realloc 的返回值是可能存在内存泄漏的。
1 | description = (char *) realloc( description, 100 * sizeof(char) ); |
若 realloc 函数执行失败,description 原先所指向的空间不变,realloc 函数返回 NULL。
此时 description 的值被赋为 NULL, 但原先指向的空间未被释放,造成了内存泄漏。
malloc() 和 calloc() 的区别:
- 主要的不同是malloc不初始化分配的内存,calloc初始化已分配的内存为0。
- 次要的不同是calloc返回的是一个数组,而malloc返回的是一个对象。
- calloc等于malloc后再memset,所以malloc比calloc更高效。
1 |
|
命令行参数
执行程序时,可以从命令行传值给 C 程序。这些值被称为命令行参数。
命令行参数是使用 main() 函数参数来处理的,其中,argc 是指传入参数的个数,argv[] 是一个指针数组,指向传递给程序的每个参数。
当不传入任何参数时,argc的值为1,因为默认argv的第一个值是程序名称(文件名及路径)。
argc 和 argv 这两个名字不是唯一的,只是约定俗成。也可以写成
int main( int test_argc, char *test_argv[] )
。
1 |
|