[知识点] 1.2 C 语言进阶
总目录 > 1 语言基础 > 1.2 C 语言进阶
前言
从 1.1 C 语言基础 剥离出来的一部分,专门介绍数组、结构体、函数等内容,同时大幅丰富了函数的知识。
更新日志
20211031 - 配合考研复习,重新整理了一遍结构,优化缩进排版,1.2 C 语言数据类型 更改为 1.2 C 语言进阶。
子目录列表
1.2.1 数组
1.2.2 结构体
1.2.3 函数
1.2.4 作用域与存储类别
1.2.5 文件操作
1.2.6 预处理器与 typedef
1.2.7 格式与缩进
1.2 C 语言进阶
1.2.1 数组
① 定义
1.1 C 语言基础 已经介绍了变量,变量是独立的,即两个不同变量之间没有任何关系,而如果我们需要存储一系列有关联的变量,就要用到数组;
形如 a[x] 的变量声明为数组。a 是数组的名字,x 表示数组中元素的个数。可以和数学中的数列相联系;
数组的类型可以为任何基本数据类型,比如一个 int 类型的数组变量 a[x],表示有 x 个连续 int 类型的变量,而不同于独立的变量,这 x 个变量并没有自己独一无二的变量名,所以在访问的时候是通过其所属数组的变量名 + 编号来访问;
对于数组 a[x],其编号为 [0, x),即数组中第 i 个元素的访问方式为 a[i - 1],i - 1 被称作该元素在该数组的下标;
也就是说,第 1 个元素是存储在 a[0],这在一些情况下可能会使用不便,从日常习惯来看,一般 1 才是表示一切的开始,而 0 表示不存在,所以我们通常可以将数组 x 稍微定义较大一点,比如需要 100 个元素时,x 定义为 101(x + 1, x + 5, x + 10 均可,一般越大越能更好避免越界情况,但同时也会带来占用更多空间的问题),然后从 a[1] 开始存储 / 访问数据,方便理解;
声明的同时可以进行初始化,用花括号表示一个组。
下面给出一些数组的定义:
int a[10]; char c[5] = {'a', 'b', 'c', 'd', 'e'}; const double fk[105]; bool w[3] = {1, 1};
并不需要将所有元素均进行初始化,但也不能略过前面的元素而初始化后面的元素。
② 多维数组
上面介绍的数组属于一维数组,即只有一个下标,元素之间只有线性关系。那么,如果我们需要表示一个面甚至一个体的话,则需要用到多维数组;
二维数组由两个下标组成,形如 a[x][y];
数组总共包含 x * y 个元素,我们可以类比平面几何,将第一维 x 视作行,第二维 y 视作列,那么对于数组中的任意元素 a[i][j],可以理解为矩阵中第 i 行第 j 列元素的值,比如:
char b[100][100]; int e[2][3] = {{1, 2, 3}, {4, 5, 6}}; double be[4][4] = {{1.0}, {0, 2.0}, {0, 0, 3.4}}; bool bie[3][2] = {{}, {}, {1}};
三维数组同理,类比立体几何。当然,超越现实的更高维数组同样存在。
③ 字符数组
请参见 1.4.2 字符数组与 string。
1.2.2 结构体
① 定义
相较于数组,结构体(struct)是一种更为灵活的高级数据类型。数组只能用来存储访问若干个相同数据类型的元素,而结构体,则可以将各类元素进行组合。先举个例子:
struct s { int b; double c; char d[10]; }; s a;
表示声明了一个名为 s 的结构体,这个结构体由一个 int 类型变量 b,一个 double 类型变量 c,一个 char 类型变量数组 d[10] 组成。接着,定义了一个名为 a 的结构体 s 变量;
弄清楚 a 和 s 的区别:
s 是结构体的名称,本身只是一种声明而不占用任何存储空间,是自定义的一种数据类型;
a 是数据类型为 s 的一个变量,每定义一个这样的变量,就会同时生成一组 b, c, d[10]。
对于任何存在的基本数据类型,都可以在结构体中进行定义;
同样地,结构体也可以是一组结构体,即结构体数组。比如:
struct s2 { int b; double c; char d[10]; } a[100];
a[100] 表示创建了一个数据类型为 s2,名为 a 的结构体数组,其中包含 100 个 s2 类型的结构体;
这里的定义方式和上面代码有一定区别,即直接在结构体作用域后写上变量名,再以分号结束,两种方式是等价的。
② 访问 / 修改元素
访问结构体中的各个变量有两种方式:
> 结构体变量名.成员元素名
比如上述代码,则可以使用诸如 a[1].b, a[2].c, a[3].d[5] 这种格式来访问元素。
> 指针名 -> 成员元素名 / (*指针名).成员元素名
修改的话同理,直接赋值即可。
③ 初始化
如果结构体变量定义为局部变量,其值同样是不会被赋初值 0 的;
可以在定义时赋初值,比如:
struct Student { int a; char b; double c[3]; } a = {1, '2', {3.4, 5.67, 8.999}};
还可以指定某一个成员赋初值,比如:
struct Student { int a; char b; double c[3]; } a = {.b = 'A'};
这时候,把 'A' 赋给 a.b 的同时,其他成员也会被系统初始化为 0(字符型是 '\0',指针型是 NULL);
有意思的是,在 C 中乱序初始化是允许的,而 C++ 不支持,所以上述代码在 C++ 中是编译不通过的;
④ 结构体与链表
struct Student { int num; double score; struct Student *next; };
在结构体 Student 中定义一个 Student 指针变量,用于指向下一个 Student 元素,以此类推,就可以构建出一条链表。
⑤ 结构体与类
注意,这里描述的结构体是 C 语言中狭义的结构体,不包含 C++ 赋予的新特性。因为在 C++ 中,结构体和 C++ 中独有的类(class)极其相似,且 C++ 作为面向对象语言,其对类 / 结构体赋予的意义要更为生动,所以这一部分单独在 1.5.1 类与对象 中进行介绍。
⑥ 关于数组与结构体的声明位置
注意,数组和结构体往往占用空间大,所以一般情况下尽量定义为全局变量,因为如果定义为局部变量,则可能导致爆栈进而 RE;
加上上述的“局部变量不会赋初值 0”,我的 OI 生涯中几乎无一例外地将所有可以定义为全局变量的全部定义为全局了,是一种图方便的做法,可以很大程度上避免这种类型的 RE。其实从逻辑上看不符合代码规范,也不利于对程序的阅读和理解,所以在将来项目开发时则要符合逻辑地定义各种变量。
⑦ 共用体
共用体(union)相较于结构体,使用频率要低很多;
它和结构体的形式类似,不同的是它的成员是共用同一个内存地址的,也就是说是不能分别使用的,比如:
union u { int x; char y; } a; a.x = 50; printf("%c", a.y); a.y = 'A'; printf(" %d", a.x);
输出结果为:2 65
当 a.x = 50 时,a.y 的值也是 50 了,对应的 ASCII 码为 50 的字符为 '2';
当 a.y = 'A' 时,a.x 值也是 'A' 的 ASCII 码即 65 了。
⑧ 枚举类型
枚举类型(enumeration,简写为 enum)是个很神奇又很鸡肋的类型。举例:
enum Weekday { sun, mon, tue, wed, thu, fri, sat } a, b;
Weekday 是枚举类型,a, b 是枚举变量,sum, mon, ... 是枚举常量;
枚举变量可以且尽可以为枚举常量中的其中之一,比如 a = sun, b = thu, ...;
枚举常量可以自行乱序赋初值,所有没有被赋初值的系统会按序逐一递增设置,直到下一个被赋初值的,比如:
enum Alphabet{a, b = 3, c, d, e, f = 10, g};
则 a, b, ..., g 的值分别是 0, 3, 4, 5, 6, 10, 11;
而上面的 Weekday 的常量的值分别是 0, 1, 2, ..., 6。
1.2.3 函数
(原网站这么重要的部分竟然是空的,,所以我也时隔许久才更新这一块)
① 概念
最开始我们提到,每一个 C / C++ 程序都有个必不可少的部分:int main(),我们称之为主函数。那么,函数到底是什么?数学里我们其实已经接触许多,其实在计算机语言里也大同小异,从数学角度出发来介绍的话 —— 函数一般由自变量和因变量组成,最基础的函数表达式 y = f(x) 中,x 为自变量,y 为因变量,y 随着 x 的值变化而变化。而这个式子转化成 C 语言中是什么形式呢?举个例子:
int function(int x) { ... return y; }
这是一个名为 function 的 int 类型的函数。x 类似于自变量,称为参数,y 类似于因变量,称为返回值,其数据类型和函数的数据类型是一致的,即 int 类型,该类型称为函数的返回类型。返回类型可以是任何基本与高级数据类型。举个例子:
int max(int a, int b) { if (a > b) return a; else return b; }
这是求最大值函数。参数有两个 a, b,函数为 int 类型,很好理解。
② 函数原型与格式
上述函数是声明与定义同时进行,同样也可以先声明后定义。其中,函数声明部分又称为函数原型,由函数返回类型、函数名和形式参数表三部分构成,一般格式如下:
函数返回类型 函数名(形式参数 1, 形式参数 2, ...);
与形式参数(形参)相对应的概念是实际参数(实参),指在调用函数时写在括号里面的参数名;
拿上面的求最大值函数来说,int 为返回类型,max 为函数名,两个 int 变量 a, b 为形式参数,之间由逗号隔开。如果使用先声明后定义的方式,声明一般放在预处理器后,主函数前,而定义放在主函数后。
在这三部分中:
> 返回类型可以为空,用 void 来表示,不需要返回值;
> 形式参数可以缺省,即没有任何传递进去的实际参数,但不影响函数正常工作,这种函数为无参函数。
举个例子:
1 void add1() { 2 for (int i = 1; i <= n; i++) 3 ans += i; 4 } 5 6 int add2() { 7 for (int i = 1; i <= n; i++) 8 ans += i; 9 return ans; 10 } 11 12 int add3(int n) { 13 for (int i = 1; i <= n; i++) 14 ans += i; 15 return ans; 16 } 17 18 int main() { 19 int o = n; 20 add1(); 21 int a2 = add2(); 22 int a3 = add3(o); 23 return 0; 24 }
add1 函数没有返回值,也没有参数;
这样的函数一般是用来对程序段进行划分,使程序逻辑更加清晰;
对于上述程序,由于 n 和 ans 都是全局变量,那么 add1 函数这样的结构已经能够完成任务了。
add2 函数有返回值而没有参数;
add3 函数有返回值,也有参数;
我们将定义在主函数的局部变量 o 传递给 add3,且该参数命名为 n;
这个 n 并不同于全局变量 n(尽管在这里它们值是相同的),也就是说在函数内访问 n 时,是默认访问函数的参数而非全局变量,这是个优先级的问题;
关于这一点,请参见 1.4.4 命名空间与作用域 中的 全局变量和局部变量 部分;
然后我们将最后得到的 ans 返回到主函数定义的 a3 中。
③ 函数参数调用
函数参数的调用有三种方式:传值调用(值传递),指针调用(指针传递),引用调用(引用传递)。
> 传值调用
传值调用指用实际参数的值来初始化形式参数,即将实参的值复制到对应形参中,复制完成后,实参与形参也就没有关系了,也就是说,形参的任何变化不会影响实参。函数执行结束后,用来保存形参的内存空间将被释放。以“交换函数”举个例子:
void swap1(int a, int b) { int t = a; a = b; b = t; } int x = 10, y = 5; swap1(x, y);
简单地说,这是个没有任何意义的函数。将实参 x, y 传递给 swap1() 的形参 a, b,函数对 a, b 进行值的交换,完全没有影响到实参 x, y。
> 指针调用
关于指针,请参见 1.4.3 指针与引用。
指针调用指用指针作为参数,即将实参的地址复制到对应形参中,复制完成后,形参的变化直接改变实参对应地址的值,也就是说,形参的所有变化会直接影响到实参。同样是交换函数举个例子:
void swap2(int *a, int *b) { int t = *a; *a = *b; *b = t; } int x = 10, y = 5; swap2(&x, &y);
调用 swap2() 后,x 的地址被复制到 a 对应的内存,y 的地址被复制到 b 对应的内存,再将两者地址指向的变量所保存的值对调,完成 x, y 的值的交换。
函数本身并不支持传递数组,但可以将数组作为参数传递给函数,而编译器会直接将其转换为该数组首元素的指针,因此,如下三种函数原型本质是相同的:
int a[100]; void sort(int *a); void sort(int a[]); void sort(int a[100]);
其中第三种虽然带上了元素个数,但并无法起到指定数组大小的作用,一般可同时传入一个表示数组大小的参数。
> 引用调用
引用是 C++ 的新特性,所以引用调用同样仅适用于 C++,但实在没必要单独开个专栏介绍引用调用了,所以这里一并提了。关于引用,请参见 1.4.3 指针与引用 中的 什么是引用 部分。
因为引用本质上也是一种指针,所以引用调用和指针调用区别同样不大,使用起来更方便,如交换函数:
void swap3(int &a, int &b) { int t = a; a = b; b = t; } int x = 10, y = 5; swap3(x, y);
引用作为参数传递的是实参变量本身(引用是变量左值,即实参的地址),但和指针调用一样会改变实参的值。
④ 默认参数
参数还可以指定默认值,即缺省参数。指定了默认值的参数不需要在调用时传值,但如果传值了,则默认值无效。
需要注意的是:在具有多个参数的函数中指定默认值时,所有默认参数都必须出现在非默认参数右侧,即一旦某个参数指定了默认值,其右侧所有参数全部需要指定。
举个例子:
int work(int o = 10, int p = 5) { ... } work(); work(1); work(1, 2);
第一次调用 work(),o = 10, p = 5;
第二次调用 work(1),o = 1, p = 5;
第三次调用 work(1, 2),o = 1, p = 2。
如果 o 设定了默认值 10,则 p 必须设定默认值,应该是出于传值会很麻烦的考虑。
⑤ 函数返回值
除了返回类型为空 void 的函数,所有函数都具有返回值,且函数都是通过返回语句结束函数调用的,也就是说,就算是 void 函数,系统也就在后面隐式执行返回语句。
返回语句一般格式为:
return 返回值;
在前面的求最大值函数中就已经很好体现了返回值的意义和使用方法。
⑥ 函数重载
函数重载指允许在同一作用域中定义多个形参列表不同的同名函数,以支持不同形式的函数调用。举个例子:
int abs(int o) { return o > 0 ? o : -o; } double abs(double o) { return o > 0 ? o : -o; }
系统会自动调用与实参类型最为匹配的那个重载函数。比如:
abs(9),9 最为匹配的是 int 类型,则调用第一个;
abs(-8.8),-8.8 最为匹配的是 double 类型,则调用第二个。
那么是根据什么原则自动调用的?
> 精准匹配:实参与函数形参类型完全一致,则直接调用,如上述两种;
> 提升匹配:实参需要从窄类型提升到宽类型才能有完全对应的重载函数,且不会出现精度损失,诸如 bool 到 char 到 int,float 到 double;
> 标准转换匹配:实参需要从窄类型提升到宽类型才能有完全对应的重载函数,但可能出现精度损失,诸如 double 到 int,double 到 long double 等;
上述两种即属于之前介绍的隐式类型转换的第三类。
> 自定义类型转换。
调用顺序优先级从前到后。
注意:重载函数必须具有不同参数表,而非不同返回类型即可;同时要避免出现二义性,即在调用时不满足精准匹配和提升匹配而只能标准转换匹配时,存在两个不同重载函数均可转换,比如:
int f(int o) { ... } long double f(long double o) { ... } double a = 2.33; f(a);
double 到 int 和 long double 均属于标准转换匹配,系统无法确定调用哪一个函数,即产生二义性。
⑦ 内联函数
在函数声明或定义时,将 inline 加到返回类型之前的函数是内联函数。其声明、定义和调用方法与非内联函数完全一致,区别在于编译器的执行方式。比如:
inline int max(int a, int b) { return a > b ? a : b; } int m1 = max(2, 3); int m2 = max(4, 5); int m3 = max(m1, m2);
对于内联函数,编译器会把函数调用语句替换成函数内的代码。听起来很抽象,以上述代码为例,其实际执行起来等价于:
int m1 = 2 > 3 ? 2 : 3; int m2 = 4 > 5 ? 4 : 5; int m3 = m1 > m2 ? m1 : m2;
也就是说,内联函数运行时不会进行参数传递,可以理解为更为高级的 define,所以执行效率更高;但与此同时程序代码会增加,存储空间占用更多。
注意,一般情况下只有非常简短且被经常调用的函数才适合作为内联函数,并且 inline 标识本身只相当于给编译器的一个建议,建议将该函数作为内联函数处理,而编译器会自行作出决定,所以并非加上 inline 就是内联函数。对于被递归调用的函数(关于递归,请参见 2.2 递归与分治),存在循环的函数或者代码量大的函数,不能作为内联函数。
⑧ 主函数
回过头来说主函数。这样是一种为了符合程序规范,自圆其说的设计,将整个程序打包成一个函数,并且最后需要写明其返回值为 0,所以程序在运行结束后可以看到终端会写上一句 “... with return value 0”(程序返回值为 0)。而如果它不是 0,则说明程序出现了问题,一般是 RE。
1.2.4 作用域与存储类别
① 作用域
作用域是指标识符在程序中的有效范围,按照范围从大到小划分为:
> 程序作用域:标识符在整个程序范围内有效。如果一个程序由多个文件组成,具有这种作用域的标识符在该程序的各个文件中均可使用,只能在其中一个文件中定义一次,其他文件用 extern 声明;
> 文件作用域:指一个文件中所有函数定义以外定义的名字,有效范围为整个文件范围;
> 类作用域:在类范围内有效的标志符;
> 函数作用域:在函数范围内有效的标志符;
> 块作用域:使用 '{' 和 '}' 可以构成一个语句块,在其中定义的标识符只能在块中使用,注意:
类与函数本质上也是一个语句块,所以类作用域、函数作用域本质上也是块作用域。
根据变量的作用域范围,变量可以划分为全局变量和局部变量:
> 在某个语句块(包括类,函数等)中内部定义的变量和函数参数是局部变量,只能在定义它的块中使用;
> 而不存在于任何块中的变量,则是全局变量,其有效范围从文件中定义位置开始到文件结束。
平时编写代码时,关于全局变量和局部变量我们要注意什么?
> 尽量避免全局变量与局部变量同名。如果同名,局部变量优先级高于全局变量,全局变量会被屏蔽,不过依旧能正常运行,但会降低程序的可读性;
> 只有全局变量会被系统自动初始化为 0。声明局部变量时如果没有赋值,变量会拥有一个不确定的值,容易导致结果错误或运行错误;
> 为了区分两种变量,可以将全局变量名的首字母以大写表示;
> 非必要时,减少使用全局变量,它不仅占用存储单元的持续时间长,且使函数的通用性与程序的逻辑性降低。
② 存储类别
在 C 语言中,每一个变量和函数都有两个属性:数据类型和存储类别;
变量与函数的标准声明格式一般为:
[存储类别] 数据类型 变量/函数名
数据类型在 1.1 C 语言基础 已经有很详细的介绍;存储类别一般被划分为四大类,以变量举例:
> 自动(auto)变量
存储类别缺省时,局部变量默认为自动变量;
自动变量脱离作用域后分配给它们的存储空间会被释放,即变量不再可以被使用;
注意,这个 auto 不同于 C++11 新标准中的 auto,C++11 中的 auto 可以自动推断数据类型。
> 静态(static)变量
静态变量分为静态外部变量和静态局部变量:
> 静态外部变量
存储类别缺省时,全局变量默认为非静态外部变量;
如果存储类别为 static,则是静态外部变量,可以防止该变量被 extern 引用(见下);
函数也可以通过这种方式防止引用,这类函数被称作静态(内部)函数,否则默认为外部函数。
> 静态局部变量
静态局部变量虽然作用域局限于定义它的语句块,但它的空间和全局变量一样在程序结束才会被释放;
这样,我们可以使用使用静态局部变量,以使数据在函数调用结束后仍能被使用。
> 寄存器(register)变量
一般情况下,变量的值是存放在内存中的,而如果存储类别为 register,则变量会被存储在寄存器中;
寄存器的存取速度远高于内存,所以可以利用这点将读取频繁的变量定义为 register 类型以提高运行效率;
但是,迭代至今的编译系统已经能识别这类使用频繁的变量而自动将其存储在寄存器中,所以没什么必要了。
> 外部(extern)变量
局部变量的作用域是从声明开始的,如果我们需要在声明前就能使用,可以将其存储类别设定为 extern;
如果我们需要使某个变量在整个程序中被使用,可以使用 extern 来引用,比如:
文件 1:
1 #include <stdio.h> 2 3 int A; 4 int main() { 5 scanf("%d", &A); 6 work(A, A); 7 return 0; 8 }
文件 2:
1 extern A; 2 int work(int a, int b) { 3 return a * b; 4 }
引用时不用再写出数据类型;
而关于这一点,有必要提及一件事情:可能有许多人(包括我)压根分不清变量的声明和定义,尽管很多时候无伤大雅,但两者确实有区别:
> 定义性声明(defining declaration):简称定义(definition),指需要建立存储空间的声明;
> 引用性声明(referencing declaration):简称声明(declaration),指不需要建立存储空间的声明,比如上述的 "extern A"。
也就是说,“声明”一词有广义狭义之分,定义是广义的声明的子集。
③ 生命期
生命期是指标识符在程序中的生存周期。变量的生命期指变量在内存中存在的时间,除了取决于定义位置,还取决于它的变量类型,不同变量类型,所占用的内存区域也是不同的;
一个程序在运行期间,程序代码和数据会被分别存储在 4 个不同的内存区域中:
> 程序代码区:存储程序所有代码,包括主函数和子函数;
> 全局数据区(静态存储区):存储程序的全局数据(非静态外部变量、静态外部变量)和静态数据(静态局部变量、静态外部变量),所有未初始化变量会被系统自动初始化为 0;
> 栈区(动态存储区):存储程序的局部数据(自动变量),只有当其对应语句块被调用时,系统才会为其建立堆栈并分配空间,且不会进行初始化;
> 堆区:存储程序的动态数据(new, malloc 等动态分配内存的方式)。
上述几种概念本身不难,但关系较繁杂,可以归纳如下:
其中,auto 与 register 只有在使用时才会分配存储空间。
1.2.5 文件操作
(这一部分没时间整理结构了,有空再改)
① 文件简介
对于程序设计,我们只需要关注两类文件:程序文件(源程序文件、目标文件、可执行文件)、数据文件;
数据文件可以分为 ASCII 文件(文本文件)和二进制文件(映像文件);
二进制文件只能储存数值型数据,但空间/时间效率均优于 ASCII 文件;ASCII 文件还可以存储字符数据;
ANSI C 标准采用缓冲文件系统处理数据文件,程序数据区与磁盘之间有输入/输出文件缓冲区;
每个被使用的文件在内存中开辟一个相应的文件信息区,用一个结构体变量存放文件的有关信息,<stdio.h> 中已经提供了对应的类型声明:
1 typedef struct { 2 short level; // 缓冲区“满”的程度 3 unsigned flags; // 文件状态标志 4 char fd; // 文件描述符 5 unsigned char hold; // 如缓冲区无内容则不读取 6 short bsize; // 缓冲区大小 7 unsigned char *buffer; // 数据缓冲区的位置 8 unsigned char *curp; // 指针当前的指向 9 unsigned istemp; // 临时文件指示器 10 short token; // 用于有效性检查 11 } FILE;
不涉及文件操作的时候,程序的输入输出都是直接在终端中进行的,引入文件操作后,可以使我们的程序从指定文件中读入数据,再将数据输出到指定文件。
② freopen 函数
一般格式:
freopen(文件名, 模式, 流);
模式可以为:
(在上述模式中加上 'b',比如 'rb', 'ab', 'wb+',表示文件为二进制文件)
比如:
freopen("data.in", "r", stdin); freopen("data.out", "w", stdout)
关闭文件(如果不写一般情况不会出现问题):
fclose(stdin);
fclose(stdout);
③ fopen 函数
格式和 freopen 类似:
fopen(文件名, 模式);
区别在于 freopen 使用的是标准输入输出流,而 fopen 需要定义 FILE 文件指针,比如:
FILE *in, *out; in = fopen("data.in", "r"); out = fopen("data.out", "w");
与此同时,输入输出的函数需要进行修改,比如:
fscanf(in, "%d", &a); fprintf(out, "%d", a);
同样使用 fclose 关闭文件,将 stdin, stdout 替换成文件指针名 in, out;
相对比较麻烦,平时用的不太多。
④ 读写数据文件
freopen 相比 fopen 最大的优势在于,无需修改原代码的输入输出而直接改变输入输出环境;
而使用 fopen 的好处在于,可以一边在终端读写,一边在文件读写,因为他们使用不同的函数,比如:
(设文件指针定义为 FILE *fp)
> fgetc / fputc
含义:从文件读取/写入一个字符;
格式:fgetc(fp); fputc(ch, fp);
其中 ch 表示字符;
两个函数名的 'f' 可以省略;
> fgets / fputs
含义:从文件读取/写入一个字符串;
格式:fgets(str, n, fp); fputp(str, fp);
其中 str 表示字符串,n 表示读取长度为 n - 1(并在最后加上 '\0');
'f' 不能省略,省略后即变成从终端读写字符串了。
> fscanf / fprintf
含义:格式化读写文件;
格式:fscanf(fp, 格式字符串,输入表列); fprintf(fp, 格式字符串,输入表列);
除了需要写出文件指针,其他和 scanf, printf 基本一致。
> fread / fwrite
含义:二进制读写文件;
格式:fread(buffer, size, count, fp); fwrite(buffer, size, count, fp);
其中 buffer 是地址,size 是读写字节数,count 是数据项个数;
举例:fread(f, 4, 10, fp),表示从 fp 所指向的文件读入 10 个 4 个字节的数据,存储到 f 中;
> fseek / rewind
上述所有函数都是顺序读写,如果需要随机读写,则可以使用 fseek 函数;
含义:改变文件位置标记;
格式:fseek(fp, offset, fromwhere);
其中 offset 是偏移量,即以起始点为基点向前移动的字节数;fromwhere 是起始点,有三种类型:
> SEEK_SET:文件开始位置,值为 0;
> SEEK_CUR:文件当前位置,值为 1;
> SEEK_END:文件结束位置,值为 2;
同时可以搭配:
> rewind 函数,用以将文件位置标记指向开头;
> ftell 函数,用以返回当时文件位置标记的位置。
> feof
含义:判定文件是否结束;
一般格式:while (!feof(fp)) { ... }
⑤ fstream 函数
C++ 特有,名为 “文件输入输出流”,格式为:
fstream 流名(文件名);
比如:
fstream file("data.txt");
其中,fstream 可以更改为 ifstream 或 ofstream,表示只支持读 / 写;
中间的读写函数从 cin / cout 改成 file 即可。
关闭文件:流名.close();
⑥ 使用文件基本步骤
> 包含头文件 <stdio.h>
> 定义文件指针 FILE *fp
> fopen 打开文件
> fscanf / fprintf / fread / fwrite 读写文件
> fclose 关闭文件
1.2.6 预处理器与 typedef
① 预处理器
预处理器是在真正的编译开始之前由编译器调用的独立程序;
C 语言预处理器中提供了一些预处理命令,如 #define, #else, #elif, #endif, #error, #if, #ifdef, #ifndef, #include, #pragma, #undef 等,下面选取其中一些常用预处理介绍。
> #include 头文件声明
对于 C 语言程序,头文件基本上不可或缺,其作用一般有:
> 调用库功能。在很多场合,源代码不便或不准对外公布,而只向用户提供头文件或二进制库,用户只需按照头文件中的接口声明来调用,而不用关心接口的实现,编译器会从库中提取对应的代码;
> 加强类型安全检查。如果某个接口被使用,其方式与头文件声明不一致,编译器就会指出错误,以减少用户调试负担;
其一般格式为 #include <> 或 include "",其中:
<> 适用于工程或标准头文件,查找过程会检查与定义的目录,比如 #include <stdio.h>;
"" 适用于用户提供的自定义头文件,查找该文件时从当前文件目录开始;
一般情况下,头文件声明均书写在每个文件的起始位置。
> #define 宏定义
> #define
#define 用于定义一个标识符常量或带参的宏,本质上是一种文本替换,格式为:【P41 补充】
#define A B
可以使程序中所有独立出现的 A 全部视作 B,使用起来非常灵活,比如:
#define N 2020 printf("%d", N); // 将输出 2020
还可以带参数,比如:
#define printf("%d", &a) PRINT(a) PRINT(5); // 将输出 5
也正是因为使用很灵活,有时容易出现一些不可预见的问题,比如:
#define sum(A, B) A + B int c = 2 * sum(3, 4);
看起来是先求 3 + 4 = 7 再 7 * 2 = 14,但实际上会被理解为:
int c = 2 * 3 + 4;
故最后结果为 10 而非 14。
所以建议一般只在进行简单的替换时才使用 #define,比如:
int a[20000], b[20000], c[20000], d[20000];
输出这么多次 20000 又麻烦又不美观,这时可以:
#define MAXN 20000
> #undef
#undef 用于删除由 #define 定义的宏,比如:
#undef N
那么,N 就不再会被替换。
> #ifdef, #endif 条件编译
一般格式为:
#ifdef 标识符
语句组 1
[#else
语句组 2]
#endif
[] 中内容表示可以缺省。这段预处理表示如果已经用 #define 定义了某标识符,就编译语句组 1,否则编译语句组 2(如果没有 #else 则没有否则部分)。
前面提过对于 long long 类型,使用 scanf / printf 输入输出时,其格式说明符在 Windows 和 Linux 系统下是不同的,分别是 %I64d 和 %lld;
因为一般个人计算机都是使用 Windows,但绝大多数评测系统都是 Linux 平台上搭建,所以在自己的电脑上测试过之后上交之前,又需要把说明符进行一些修改,麻烦而容易出错;
这时候,我们可以使用如下条件编译:
#ifdef WIN32 #define lld "%I64d" #else #define lld "%lld" #endif
问题完美解决。WIN32 / Linux 是内置的标识符,分别表示当前操作系统为 Windows 和 Linux。
② typedef 类型声明
typedef 的全称为 type define,它不属于预处理语句,但提到 #define 往往就会一起提 typedef,因为使用起来很相似;
typedef 用来为一个已有的数据类型定义一个别名,格式为:
typedef 原类型名 新类型名;
可以用于原类型名太长而需要简化的情况,比如:
typedef long long ll;
可以用于掩饰复合类型,比如:
typedef int Arr[100]; Arr a;
这样可以更便捷地定义 int 类型的数组,比如上述 a 的实际数据类型为 int[100];
再比如:
typedef struct { int month, day, year; } Date; Date ndbd; Date *p;
还可以用于隐藏指针语法。
相比 #define 宏定义,typedef 功能较弱,只能用于数据类型的代换,但是更为规范,会进行正确性检查。
1.2.7 格式与缩进
C / C++ 对书写格式和缩进并无任何规定,理论上只要语法正确,想怎么写怎么写。但语言是什么?是人类进行沟通表达和传递信息的工具,不要满足于计算机能理解你的行为,所以尽可能地保持代码的格式统一和结构清晰,能形成自己的代码风格更好。当年在 cj 搞 OI 那么久,早期风格变化大,后来慢慢定型了,基本上是师承 bebe 的,过了若干年再次看到他的代码的时候发现还是那么像(
对于格式,大多是空格位置、换行位置、大括号位置等方面有一定差异;
对于缩进,一般有四格缩进和两格缩进的区别;
见仁见智。
以前我是不怎么打空格的,后来看 bebe 和 zed 的代码,逐渐被同化,成了一个空格王;同时比较喜欢压行,用逗号,还有各种能简短代码的方式;
粘贴一份 KMP 算法的代码体现一下代码风格。。(C++ 代码)
1 int main() { 2 cin >> a + 1 >> b + 1; 3 la = strlen(a + 1), lb = strlen(b + 1); 4 fail[0] = -1; 5 for (int i = 1, x = -1; i <= lb; i++) { 6 while (x >= 0 && b[x + 1] != b[i]) x = fail[x]; 7 fail[i] = ++x; 8 } 9 for (int i = 1, x = 0; i <= la; i++) { 10 while (x >= 0 && b[x + 1] != a[i]) x = fail[x]; 11 if (++x == lb) cout << i - lb + 1, exit(0); 12 } 13 cout << "N/A"; 14 return 0; 15 }
以前我更不喜欢换行,现在可能好点了(