C语言入门¶
本章默认大家此前没有任何编程基础。
所谓“编程”,就是让计算机按照我们想要的方式工作。计算机本身并不会思考,它只能听从我们的指令去做事。因此,我们需要用一种计算机能够理解的语言来告诉它我们想要它做什么,这种语言就叫做“编程语言”。
C语言是最早的编程语言之一,在上世纪70年代被发明出来。它是一种结构化的、过程式的编程语言,具有高效、灵活和可移植等特点。C语言广泛应用于系统软件开发、嵌入式系统、游戏开发等领域,著名软件如Linux内核、Git、GCC、Vim等简单但强大的软件都是用C语言编写的;Python的官方实现也是C语言(Cpython),很多高性能的Python库(如NumPy、Pandas、sklearn的大部分等)也是用C语言写的。
安装C编译器的方法见第sec:c-install-on-windows节,这里不再赘述。我们要写C,首先应该创建一个以 .c 结尾的文本文件,例如 hello.c 。然后,在这个文件中写入C代码,最后使用C编译器将其编译成可执行文件。而对于初学者而言,编译这种脏活累活全部丢给VS Code的C/C++扩展来做就好了。
C语言的基本语法¶
你的第一个C程序¶
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
把这些内容敲到你的C文件中,保存,编译并运行(如果你按照我所推荐的方式安装并配置好了,那么按下 F5 就可以编译并运行了),你就会看到终端上输出了 Hello, World! 。
上述程序就算是一个最简单的C程序了。
第一次写C的时候,记住以下事项:
- 程序有入口;
- 先声明,再计算;
- 算完告诉外面。
剩下的内容和说话一样,只不过是用C的语法来表达。我们说话的句号在C中是分号。
逐行拆解上述示例代码:
- #include <stdio.h> :告诉编译器,我要用输入输出工具。
- int main() :程序的入口函数,告诉电脑:程序从这里开始执行。 int 表示这个函数返回一个整数值。
- printf("Hello, World!\n"); :把东西一股脑全送到屏幕上。 \n 表示换行。
- return 0; :返回0,告诉操作系统:一切OK。除非你知道你在做什么,否则这里不要改成其他数字。本行就是“算完告诉外面”。
在C中,有两种代码:一种是以 # 开头的预处理指令,另一种是常规语句。预处理指令指的是在编译之前进行的一些操作,例如包含头文件、定义宏等,详见sec:macro。常规语句则指的是程序的主要逻辑。常规语句应以分号结尾,且在结尾之后应换行(除非写注释)。压行是不好的行为,会影响代码的可读性,尽量自然地换行。
变量及其运算¶
编程的本质是对数据进行操作,而经过操作的数据可能会变化。对于会变化的数据,我们称之为“变量”。而这些量也有不同的类型,例如“人数”肯定是整数,而“身高”则可能是小数。
在C中,变量要先声明,再使用。声明的方法是:先写类型,再写名字。这个“名字”是我们之后用来使用这个变量的标识符,类似别人提到“张三”就能对应到这个人的头上。这个使用在编程上被叫做调用。
取名有一定的规则:不能用已经用过的名字,这个名字包括C保留的关键字和你自己已经定义过的名字;名字只能包含字母、数字和下划线,不能包括其他符号,且不能以数字开头;名字区分大小写,例如 age 和 Age 是两个不同的名字。
int age = 18; // 声明一个整数变量age,并初始化为18
double pi = 3.14; // 声明一个双精度浮点数变量pi,并初始化为3.14
char grade = 'A'; // 声明一个字符变量grade,并初始化为'A'
age = 19; // 把age的值改为19
这些语言本质上都可以用自然语言解释为:我有个xx叫xx,它的值是xx。例如第一行代码:我有个整数叫age,它的值是18。如果之后我想用age这个变量,就可以直接写 age ,不需要再次声明“我有个整数叫age”了。
上述 int 等四个排在第一个的关键字是变量的类型,分别表示整数、双精度浮点数、字符和布尔类型。在C中,变量类型不能在运行时改变,一旦声明则类型固定,因此C也被归类为“静态类型”语言。
上述声明中的等号和数学中的等号不相同。在这里,等号的意思是“赋值”,指的是让等号左边的值变成等号右边的值。也就是说,等号右边的值会被计算出来,然后存储到等号左边的变量中。
而变量的运算则和数学差不多,比方说
int a = 10;
int b = 20;
int c = a + b;
c = a * 2;
c += 5;
第三行中, int c = a + b 的意思是“我要创建一个变量c,把a+b的结果放进去”。可以看到,从这一行以后再提到c,就不需要再写 int 了,因为电脑已经知道c是个什么东西了;就像我们告诉李四“有个人叫张三”,之后再提到张三的时候就不需要再说“有个人叫张三”了。
下一行 c = a * 2 的意思是“我要把a乘以2的结果放到c里面,c以前不管是什么我都不要了”,而再下一行 c += 5 的意思是“我要把c加上5”。在上述代码中,我们发现变量c的值会随着每一行代码的执行而变化,例如第三行代码执行后,c的值变成了30;第四行代码执行后,c的值变成了20;第五行代码执行后,c的值变成了25。所以说c是一个变量。
变量的值也可以在声明时不确定(初始化),例如 int a; 这样也是可以的。如果在声明的时候不初始化局部变量的值,那么这个变量的初始值将会是一个未定义行为,这个值取决于内存中该位置之前存储的内容。我们不能依赖于这个,因此最好在声明变量的时候就给它赋初始值,例如 int a = 0; 。对于全局变量,如果不初始化,编译器会自动将其0初始化。
让我们看看常见的运算符:
- 四则运算: + (加)、 - (减)、 * (乘)、 / (除)。注意,除法运算中,如果两个整数相除,结果仍然是整数,余数会被舍弃。
- 取模: % ,表示取余数。例如 5 % 2 的结果是1,因为5除以2的余数是1。
- 自增和自减: ++ (自增)和 -- (自减)。例如, a++ 表示将a的值加1, b-- 表示将b的值减1。
不要过分纠结 i++ 和 ++i 的区别,初学者完全可以认为这两个和 i += 1 没有区别。
Warning
尽量单独使用 ++ 和 -- ,不要把它们和其他运算混在一起使用,更不要在同一个表达式中对同一个变量使用多次 ++ 或 -- 。例如, a = b++ 虽然不推荐但还勉强可以,但是 a = b++ + b++ 和 i = i++ 都是未定义行为。一个饱受诟病的题目“ i = 3, i++ + i++ = ? ”答:这个题目是错误的,至少是不良定义的。不同的编译器对上述代码的处理方式不同。
笔者个人从工程的眼光上看来,非常不建议弄出 a = b++ 这类的代码,尽管这类代码在竞赛中会让很多OIer感到Tricky,但是在工程中会让人无比恼火。如果想先用b的值再加1,可以写成 a = b; b++; ;如果想先加1再用b的值,可以写成 b++; a = b; 。上述写法一般只有非常约定俗成的场合才会使用,例如 while(T--) 或者 stk[++top]=x ——不过即使是我,也更习惯于写成for(;T>0;T--) 和 stack<int> stk; stk.push(x);。
注释¶
注释是代码中的说明文字。它们会被编译器忽略,因此注释完全是给编写者和阅读者看的。
在C中,注释有两种方法来写:
- 单行注释:使用 // ,例如 // 这是一个单行注释 。注释符号后面的内容会被编译器忽略,直到行尾为止。
- 多行注释:使用 /* ... */ ,例如 /* 这是一个多行注释 */ 。两个注释符号之间的内容会被编译器忽略,可以跨越多行。
在阻止部分代码执行的时候,我们一般不习惯于直接删除这些代码,而是使用注释。这样做的好处是可以留痕,便于以后的恢复(解注释);这就是程序员们常说的“注释掉”代码。在VS Code等编辑器中,常用的一键注释是 Ctrl + / ,它会自动将光标所在的一行或多行代码注释掉。
输入、输出及其格式化¶
输入和输出是程序与外界进行交互的方式。在C中,常用的输入输出函数有 printf 和 scanf 。这两个函数都定义在 stdio.h 头文件中,因此在使用它们之前需要包含该头文件。
printf 用于输出数据到屏幕上,其基本语法如下:
printf("格式字符串", 参数1, 参数2, ...);
而 scanf 用于从键盘读取输入,其基本语法如下:
scanf("格式字符串", &变量1, &变量2, ...);
&a 的形式。
那这个“格式字符串”是什么东西呢?它是一个字符串,其中包含了文本和格式说明符。格式说明符用于指定要输出或输入的数据类型和格式。常见的格式说明符有:
- %d :表示整数类型。
- %f :表示浮点数类型。
- %c :表示字符类型。
- %s :表示字符串类型。
例如,下面的代码演示了如何使用 printf 和 scanf 进行输入输出:
#include <stdio.h>
int main() {
int age;
printf("请输入你的年龄:");
scanf("%d", &age); // 这里的输入是1个整数
printf("你输入的年龄是:%d\n", age); // 这里你看到的的输出是:“你输入的年龄是:xx”,xx是age的值
return 0;
}
在字符串中,除了上述格式说明符,还可以有控制字符,也就是类似于上文\n 这样的东西。上述代码中的 \ 是“转义符号”,表示后面的字符有特殊含义,而不是其本身的含义。
常见的控制字符有:
- \n :换行符。
- \t :制表符(Tab)。
- \r :回车符。
- \" :双引号字符。
- \' :单引号字符。
- \\ :反斜杠字符。
- % :百分号字符。
- \0 :字符串结束符。
练习
加减运算
写一个程序,接受两个整数输入,然后输出它们的和、差。
程序输入:两个整数a和b,用空格分割。
程序输出:两行,第一行输出\(a+b\),第二行输出\(a-b\)。
答案
由于这是第一个题,我们就给出一个参考答案吧。这个题目的答案是显然的,但需要让同学们知道这种题目应该以一种什么形式去写。
在OJ等平台上,我们一般需要提交一个完整的程序。
首先,我们要处理问题的核心逻辑:加法和减法。
int a = 0;
int b = 0;
int sum = a + b; // 计算和
int diff = a - b; // 计算差
scanf("%d %d", &a, &b); // 读取输入
printf("%d\n", sum); // 输出和
printf("%d\n", diff); // 输出差
#include <stdio.h>
int main() {
int a = 0;
int b = 0;
scanf("%d %d", &a, &b); // 读取输入
int sum = a + b; // 计算和
int diff = a - b; // 计算差
printf("%d\n", sum); // 输出和
printf("%d\n", diff); // 输出差
return 0;
}
OJ等自动评测平台会根据题目的输出格式来验证程序的正确性,因此我们必须严格按照题目要求来编写程序,不要输出其他内容,例如“请输入两个整数:”之类的提示语句,这样会导致判错。而在实际生活中,我们可以添加这些提示语句来提高程序的用户体验。
常变量¶
常变量(也叫不可变变量、只读变量)是指在程序运行过程中其值不能被修改的变量。在C中,可以使用 const 关键字来声明常变量。例如:
const int MAX_VALUE = 100;
// MAX_VALUE = 200; // 这行代码编译不通过,因此要注释掉
也就是在常规的声明前面加上 const 关键字。上述代码的意思是:我要创建一个常变量MAX_VALUE,它的值是100。
我们发现,任何对常变量的修改操作都会使得编译不通过。因此,常变量的值一旦确定就不会在程序运行时改变。常变量的名字通常使用全部大写字母来表示,以便于和变量区分。
条件判断¶
有时候我们想要设计一个网站,给不同的人显示不同的内容。这个时候,我们就需要用到条件判断。条件判断可以让程序根据不同的条件执行不同的代码块。在C中,常用的条件判断语句有 if 语句和 switch 语句,以及三元运算符。
条件表达式¶
一个条件表达式,最简单的情况肯定是“真”或“假”。C语言规定:true等价于1,false等价于0;但非0的数值还是什么别的非空的东西全部视作true。因此, if (1) 和 if (-42) 甚至 if (3.14) 和 if ("hello")都肯定会执行,而 if (0) 和 if ("") 则肯定不会执行。
但是实际上情况肯定没这么简单,所以需要用比较运算符和逻辑运算符来构造更复杂的条件表达式。常见的比较运算符有:
- == :等于。
- != :不等于。
- > :大于。
- < :小于。
- >= :大于等于。
- <= :小于等于。
- && :逻辑与(AND):前后两个条件都为真时,结果才为真。
- || :逻辑或(OR):前后两个条件有一个为真时,结果就为真。
- ! :逻辑非(NOT):反转后面条件的真假。
- () :括号,用于改变运算优先级。
也就是说:3+2==5 是true, 3+2!=5 是false, 3 > 2 和 3 >= 2 也都是true。而 (3 > 2) && (2 > 1) 是true, (3 > 2) || (2 < 1) 也是true,而 !(3 > 2) 则是false。于是,借助这些比较运算符和逻辑运算符,我们就可以构造出复杂的条件表达式了。
Tip
在C++中,不能使用类似 1 <= x <= 2 这样的连续记号来表示区间。正确的写法是 (1 <= x) && (x <= 2) ,即把每个比较都单独写出来,然后用逻辑与运算符连接起来。
在C++中,与或非运算符是有一定的运算顺序的。一般情况下,逻辑非运算符的优先级最高,其次是逻辑与运算符,最后是逻辑或运算符。不过笔者非常不建议同学们背诵这个顺序;实际在工程上不仅不建议大量嵌套使用这些运算符,而且遇事不决可以加括号——括号可比记运算顺序靠谱得多了!
if语句¶
if 语句的基本语法如下:
if (cond1){
// codes...
}
else if (cond2){
// codes...
}
else {
// codes...
}
cond1 和 cond2 是条件表达式,它们的结果是布尔值(真或假)。如果 cond1 为真,则执行第一个代码块;否则,如果 cond2 为真,则执行第二个代码块;否则,执行最后一个代码块。在实际操作中,可以没有任何else if 或 else 分支。
例子:
if (age < 18) {
cout << "未成年";
}
else if (age < 60) {
cout << "成年人";
}
else {
cout << "老年人";
}
switch语句¶
switch 语句的基本语法如下:
switch (expression) {
case value1:
// codes...
break;
case value2:
// codes...
break;
...
default:
// codes...
}
expression 是一个表达式,其结果将与各个 case 后面的值进行比较。如果结果与某个 case 后面的值相等,则执行对应的代码块,直到遇到 break 语句为止。如果没有任何 case 匹配,则执行 default 代码块(如果有的话)。注意, break 语句用于跳出 switch 语句,否则程序会继续执行后续的代码块。在实际操作中,也可以没有 default 分支。
例子:
switch (day) {
case 1:
cout << "星期一";
break;
case 2:
cout << "星期二";
break;
case 3:
cout << "星期三";
break;
// ......其他的,基本一个写法
}
三元表达式¶
三元表达式也是一种条件表达式,只不过它可以在一行代码中完成条件判断和结果返回,因此显得更简洁。它通常用于简单的条件判断和赋值操作。它的基本格式如下:
条件 ? 真值 : 假值
比方说,我们可以用它来判断一个数是奇数还是偶数:
int n = 5;
const char* result = (n % 2 == 0) ? "even" : "odd";
如果使用if语句来实现同样的功能,可以写成:
int n = 5;
string result;
if (n % 2 == 0) {
result = "偶数";
} else {
result = "奇数";
}
练习
闰年判断
写一个程序,接受一个年份输入,然后判断这一年有多少天(365或366)。提示:闰年的判断规则是:四年一闰,百年不闰,四百年再闰。
程序输入:一个整数year,表示年份。
程序输出:一个整数,表示该年份的天数(365或366)。
练习
天数判断
写一个程序,接受一个月份输入,然后输出该月份有多少天。假设输入的月份是1到12之间的整数,且不考虑闰年。
程序输入:一个整数month,表示月份。
程序输出:一个整数,表示该月份的天数。
循环¶
循环是一种重复执行某段代码的结构,直到满足某个条件为止。有的同学可能会问:为什么不直接把代码写多几遍就好了?这是因为有时候我们并不知道需要重复多少次,或者需要根据某个条件来决定是否继续循环,因此这时候就需要用到循环结构。
在C中,常用的循环语句有 for 循环、 while 循环和 do-while 循环。
for循环¶
for 循环的基本语法如下:
for (初始化; 条件; 更新) {
// 循环体代码
}
初始化 用于设置循环变量的初始值, 条件 是一个布尔表达式,用于判断是否继续循环, 更新 用于更新循环变量的值。循环体代码会在每次循环中执行。例如,下面的代码演示了如何使用 for 循环打印1到10的数字:
for (int i = 1; i <= 10; i++) {
printf("%d\n", i);
}
while循环¶
while 循环的基本语法如下:
while (条件) {
// 循环体代码
}
条件 是一个布尔表达式,用于判断是否继续循环。循环体代码会在每次循环中执行,直到条件为假为止。例如,下面的代码演示了如何使用 while 循环打印1到10的数字:
int i = 1;
while (i <= 10) {
printf("%d\n", i);
i++;
}
实际上,while循环可以和for循环互相转换。上面的for循环可以改写成while循环,反之亦然。
do-while循环¶
do-while 循环的基本语法如下:
do {
// 循环体代码
} while (条件);
条件 是一个布尔表达式,用于判断是否继续循环。循环体代码会先执行一次,然后再判断条件是否为真,如果为真则继续循环,直到条件为假为止。例如,下面的代码演示了如何使用 do-while 循环打印1到10的数字:
int i = 1;
do {
printf("%d\n", i);
i++;
} while (i <= 10);
do-while 循环至少会执行一次循环体代码,而 while 循环则可能一次都不执行。
循环控制语句¶
有些时候,我们希望在循环中跳过某些迭代,或者提前结束循环。为此,C提供了两种循环控制语句: break 和 continue 。
break 可以立刻跳出整个循环,不再执行后续的迭代。例如:
for (int i = 1; i <= 10; i++) {
if (i == 5) {
break; // 当i等于5时,跳出循环
}
printf("%d\n", i);
}
而 continue 则是跳过当前迭代的剩余所有代码,直接进入下一次迭代。例如:
for (int i = 1; i <= 10; i++) {
if (i == 5) {
continue; // 当i是偶数时,跳过当前迭代
}
printf("%d\n", i);
}
练习
日期差
写一个程序,接受两个日期输入,计算两者之间差了多少天。不考虑闰年问题。
程序输入:四个整数month1、day1、month2、day2,分别表示第一个日期的月份和天数,以及第二个日期的月份和天数。
程序输出:一个整数,表示两个日期之间的天数差。
提示:把上一节的“天数判断”题目作为子任务来完成,也就是说可以试着复用这些代码。
数组¶
数组,顾名思义,也就是“一组数据”。这组数据的类型是相同的,可以是整数、浮点数、字符等。数组中的每个数据都有一个索引(下标),用于标识它在数组中的位置。数组的索引从0开始。
例如,下面的代码声明了一个包含5个整数的数组:
int numbers[5] = {10, 20, 30, 40, 50};
// 访问数组元素
int firstNumber = numbers[0]; // 访问第一个元素,值为10
int thirdNumber = numbers[2]; // 访问第三个元素,值为30
int bad = numbers[5]; // 错误,数组越界访问,可能导致段错误或返回不可知的值
int bad2 = numbers[-1]; // 错误,数组越界访问
numbers[1] = 25; // 修改第二个元素的值为25
有的同学可能会问:我为什么不能用
firstNumber = 25;
numbers[0] 的值呢?这是因为 firstNumber 和 numbers[0] 是两个不同的变量,前者是一个独立的变量,而后者是数组中的一个元素。上述初始化语句只是将 numbers[0] 的值复制给了 firstNumber ,它们之间没有任何关联。因此,修改 firstNumber 的值不会影响 numbers[0] 的值,反之亦然。
在C中,我们无法直接打印整个数组,而是需要通过循环来逐个打印数组中的元素。例如:
for (int i = 0; i < 5; i++) {
printf("%d\n", numbers[i]);
}
上述代码使用了一个 for 循环来遍历数组 numbers 中的每个元素,并将其打印出来。while 循环也可以实现同样的功能,读者可以自行尝试。
在C语言中,数组的大小必须是一个能够在编译时确定的常量(如字面值)。
Warning
变长数组(VLA)是C99标准引入的特性,允许数组的大小在运行时确定,但它在C11中被变为可选特性。容易引起误会的是,GCC 和 Clang++ 编译器提供了包含 VLA 的GNU 扩展语法,并且默认引入这些扩展,因此,VLA (例如 int n; int a[n]; )在这些编译器下可行。反之,如果关闭这些扩展(通过添加 --pedantic-errors 选项)或者非 GNU 兼容的编译器(如 MSVC),则 VLA 不可用。在实际操作中,我们不要去写VLA,它们可能会导致代码在不同编译器下的表现不一致。C中,我们需要使用数组但是长度不确定的时候,可以将数组开得大一些,例如题目有1000个元素,那么就开1000个元素或者稍多元素的数组。
练习
计算求和
写一个程序,该程序接受一些非零整数的输入,直到输入0为止,然后输出这些正整数的和。
程序输入:一系列整数,每个整数占一行,最后一个整数为0,表示输入结束。
程序输出:一个整数,表示输入的非零整数的和。
思考:本题用数组和不用数组分别怎么写?哪种方法更好?如果本题改为“计算平均值”,用数组和不用数组分别怎么写?哪种方法更好?
字符串¶
C风格的字符串是以字符数组的形式存储的,并以空字符( \0 )结尾。字符串可以通过字符数组来表示,例如:
char str[] = "Hello, World!";
str ,并初始化为字符串 "Hello, World!" 。注意,字符串的长度包括了结尾的空字符。
也就是说:
char str[3] = "Hi"; // 字符串"Hi"占用3个字符:'H'、'i'和'\0'
char str2[2] = "Hi"; // 错误,数组大小不足以存储字符串及其结尾的空字符
在做题和实际工程中,很容易遗忘C风格字符串的结尾空字符,因此在操作字符串时要特别小心,确保有足够的空间来存储字符串及其结尾的空字符。
练习
字符串长度
写一个程序,接受一个字符串输入,然后输出该字符串的长度(不包括结尾的空字符)。
程序输入:一个字符串,长度不超过100个字符。
程序输出:一个整数,表示字符串的长度。
提示:可以使用循环来计算字符串的长度,或者使用标准库函数 strlen 。体会标准库函数在实际编程中的便利性。
声明、定义、变量的作用域与存储类¶
刚才,我们学习了C中最基本的一些概念,例如变量、数据类型、运算符、条件判断、循环、数组和字符串等。这些概念构成了C语言的基础,使我们能够编写简单的程序来解决各种问题。为了进一步深化理解这些概念,我们需要明确“声明”(declaration)和“定义”(definition)的区别,以及变量的作用域(scope)和存储类(storage class)的概念,这样才能面对复杂程序设计时的挑战。
为了理解这些内容,我们首先要知道:我们写的东西,本质上最终都会被编译器转换成机器码,而机器码是需要内存空间来存储数据的。
声明和定义¶
我们说过,变量需要“先声明”才能使用。我们可以通过日常中的例子来理解声明和定义的区别:假设你在学校里听说了一个叫“张三”的人,你知道他是谁(声明),但是你并不知道他的具体信息(定义)。当你见到张三并了解他的详细信息时,你就完成了对张三的定义。实际上在C中,声明和定义的区别就在这里。 - 声明:告诉编译器指定对象的名称和类型,但不为其分配内存空间。 - 定义:为声明过的对象分配内存空间,并提供实现或提供一个初始值。
在大多数情况下,变量的声明和定义是同时进行的,例如:
int a; // 声明并定义变量a
a ,也为其分配了内存空间。有的同学可能会疑惑:这哪里分配内存空间了?实际上,编译器在看到上述代码时,会为变量 a 分配4个字节的内存空间(假设 int 类型占4个字节),并将其初始化为一个值(虽然这个数值可能是垃圾值)。
只有在少数情况下,对于特定变量的声明才能分开。这种情况大多见于多文件编程中,例如:
extern int b; // 仅声明变量b,不定义
b ,并没有为其分配内存空间。变量 b 的定义需要在其他地方进行,例如:
int b; // 定义变量b,分配内存空间
b 分配了内存空间,并将其初始化为一个值。
C标准严格禁止对对象进行重定义。也就是说,不能在同一个作用域内多次定义同一个变量,否则会导致编译错误。例如:
int a = 2;
printf("%d\n", a);
int a = 3; // 重定义错误
printf("%d\n", a);
a 被定义了两次,导致编译失败。
C语言确实允许多次声明同一变量,但这些多次声明必须保持一致性,即类型和名称必须相同。例如:
int a = 2;
printf("%d\n", a);
double a = 3.5; // 冲突声明错误
printf("%f\n", a);
a 的类型不一致,出现了“冲突声明”(conflicting declaration)。
变量的作用域与生命周期¶
变量的作用域是指变量在程序中可见和可访问的范围。在C语言中,变量的作用域向下延伸至其定义所在的代码块(block)。代码块是由一对大括号包围的代码区域。一般的,人为地将变量分为“全局”(不在任何块内声明)和“局部”(在某个块内声明)两种。一般情况下,全局变量的作用域是整个文件,甚至可以跨文件访问(通过 extern 关键字)。局部变量作用域仅限于该代码块内部。
也就是说:
int a = 2; // 全局变量a
{
printf("%d\n", a); // 可以访问全局变量a,输出2
int b = 3; // 局部变量b
printf("%d\n", b); // 可以访问局部变量b,输出3
}
printf("%d\n", a); // 可以访问全局变量a,输出2
printf("%d\n", b); // 错误,无法访问局部变量b,超出作用域
但下列代码出乎意料的合法:
int a = 2; // 作用域更大的变量a
{
int a = 3; // 作用域更小的变量a,和外面的变量a不同,在该作用域下提及的a都是这个
printf("%d\n", a); // 输出3
}
printf("%d\n", a); // 输出2
而变量的生命周期(lifetime)是指变量在内存中存在的时间段。全局变量的生命周期从程序开始运行到程序结束,而局部变量的生命周期仅限于其所在的代码块执行期间。一旦代码块执行完毕,局部变量就会被销毁,其内存空间被释放。对于大多数变量来说,生命周期与作用域是相同的;唯一的例外是静态变量(见下文),其生命周期是整个程序运行期间,但作用域仍然受限于其定义所在的代码块。
变量的存储类¶
变量的存储类则决定了变量的存储位置、生命周期和可见性。C语言中常见的存储类有以下几种:
- auto :默认的存储类,表示局部变量,生命周期为代码块执行期间。换句话说,我们常见的 int a; 实际上等价于 auto int a;。
- register :建议将变量存储在CPU寄存器中,以提高访问速度,但编译器可以忽略此建议。常用于寄存器优化,但除非写内核或嵌入式,否则一般没有人写这个,而是让编译器自己决定。
- static :表示静态变量,生命周期为整个程序运行期间,但作用域为定义所在的代码块。常用于需要在多次函数调用之间保持值的变量。
- global :表示全局变量,生命周期为整个程序运行期间,作用域为整个文件,甚至可以跨文件访问(通过 extern 关键字)。实际上和全局定义的int a;是等价的。
- extern :表示外部变量,声明在其他文件中定义的变量,用于跨文件访问。
- const :表示常变量,值不可修改。
结构体和对齐¶
结构体¶
结构体( struct )是一种用户自定义的数据类型,用于将多个相关的数据组合在一起。结构体可以包含不同类型的成员变量,从而形成一个复杂的数据结构。在C中,结构体的定义和使用方法如下:
// 定义结构体
struct Person {
char name[50]; // 姓名
int age; // 年龄
double height; // 身高
};
// 使用结构体
struct Person person1; // 声明一个结构体变量person1
// 访问和修改结构体成员
strcpy(person1.name, "Alice");
person1.age = 25;
person1.height = 165.5;
容易看出,结构体可以使得代码更加清晰和有组织,尤其是在处理复杂数据时非常有用。
有的读者可能会好奇:为什么上文是“先定义再声明”?实际上此声明非彼声明。在C中,结构体本身的定义(实现)实际上就是其声明;而上文的“声明一个结构体变量”指的是即为这个对象分配内存空间。也就是说,结构体类型的定义和结构体变量的声明是两个不同的东西,所以“先定义再声明”是合理的。
一个更常见的写法是使用 typedef 关键字为结构体定义一个别名,这样在声明结构体变量时就不需要再写 struct 了。例如:
// 定义结构体并使用typedef为其定义别名
typedef struct {
char name[50]; // 姓名
int age; // 年龄
double height; // 身高
} Person;
// 使用结构体
Person person1; // 直接使用别名Person来声明结构体变量person1
在特殊情况下甚至允许不具名结构体,例如:
struct {
char name[50]; // 姓名
int age; // 年龄
double height; // 身高
} person; // 直接声明一个不具名结构体变量person
// 下文可以使用person.name等访问成员
结构体的内存对齐¶
假设写了这样一个结构体:
struct Example {
char a; // 1字节
int b; // 4字节
}
struct Example example; // 声明一个结构体变量example
int size = sizeof(example); // 获取结构体变量example的大小。
printf("结构体大小: %d\n", size); // 会输出多少?
首先,我们先要理解“变量”在内存上究竟是什么东西。
想象内存是一条无限长的一维停车场。停车场被划分为许多连续编号的地块,这被叫做“地址”(address)。现在开来了一辆车,这辆车需要占用若干个连续的地块,这就相当于我们声明了一个变量。不同类型的变量需要占用不同数量的地块,例如int需要4个地块,char需要1个地块。抽象理解一下,变量在内存上其实就是一块连续的字节区域,例如int占4字节,那么它在内存上就是4个连续的字节;char占1字节,那么它在内存上就是1个字节。不同类型的变量在内存上占用的字节数是不同的。按理说,将所有变量密铺在内存上即可,而这确实是朴素的处理方式。
但是在实践中,我们发现,如果将变量密铺在内存上,CPU在访问这些变量时会变得非常慢。原因在于,现代CPU通常以“字”(word)为单位来访问内存,而字的大小通常是4字节或8字节。如果一个变量跨越了两个字,那么CPU在访问这个变量时就需要进行两次内存访问,这样就大大降低了访问速度。
于是,编译器厂家等灵光一闪:为什么不能人为规定,让变量在内存上的起始地址必须是其大小的整数倍呢?这样一来,变量就不会跨越多个字,CPU在访问这些变量时就可以一次性完成,从而提高访问速度。这个过程就叫做“内存对齐”(memory alignment)。故而,int的起始地址必须是4的倍数,char的起始地址可以是任意地址,double的起始地址必须是8的倍数,依此类推。
回到停车场比喻上:假设我们来了一辆拖车,上面载着两个小车,一个是int,一个是char。停车场管理员(CPU)登记拖车的同时,还要把小车一起登记(改变其在拖车上的位置);又规定,int必须停在4的倍数号的地块上,而char可以停在任意地块上。那么,如果这个拖车(结构体)从地块0开始停放,那么int就必须停在地块4上,而char只能停在地块0上;中间的地块1、2、3就只能空着,作为填充(padding)字节。这样一来,拖车就占用了地块0到7,共8个地块,而不是5个地块。这个道理和结构体的内存对齐一致,因此上述结构体的大小是8字节。
有了这个理论,我们就可以对结构体的内存布局进行分析了。上述结构体的内存布局如下:
地址偏移 内容
0 char a (1字节)
1-3 填充字节 (3字节)
4-7 int b (4字节)
那么,我们来思考一下,怎样才能多快好省的利用内存呢?答案是:调整结构体成员的顺序,使得内存对齐时的填充字节最少。上述两个成员的顺序掉不掉换其实是一样的,都是8字节。但是如果结构体成员更多,顺序就会影响最终的大小。例如:
struct Example2 {
char a; // 1字节
double b; // 8字节
int c; // 4字节
};
地址偏移 内容
0 char a (1字节)
1-7 填充字节 (7字节)
8-15 double b (8字节)
16-19 int c (4字节)
20-23 填充字节 (4字节)
# 这里填充的原因是:double要求8字节对齐,而结构体整体大小也要是最大成员大小的整数倍。这应该是比较容易证明的数论事实。
struct Example3 {
double b; // 8字节
int c; // 4字节
char a; // 1字节
};
地址偏移 内容
0-7 double b (8字节)
8-11 int c (4字节)
12 char a (1字节)
13-15 填充字节 (3字节)
练习
学生信息管理
写一个程序用于管理学生的高考信息(仅包括学号、姓名、分数)。学号从0开始连续编号,姓名不超过20个字符,分数为整数,在0到750之间。
程序输入:首先输入一个整数n,表示学生人数。接下来输入n行,每行包含一个学生的姓名和分数,姓名和分数之间用空格分隔。然后输入一个整数m,表示查询次数。接下来输入m行,每行包含一个学生的学号。
程序输出:m行。每一行用空格分隔输出三个整数,分别对应查询学号的学生的学号、姓名和分数。如果未能查询到,输出“Not Found”。
提示:本题使用结构体、不使用结构体分别怎么写?哪种方法更好?体会结构体在组织复杂数据时的优势。
联合体¶
联合体( union )是一种特殊的数据类型,它允许在同一内存位置存储不同类型的数据。联合体的所有成员共享同一块内存,因此在任何时候只能使用其中的一个成员。联合体的定义和使用方法如下:
union Data
{
int intValue; // 整数值
float floatValue; // 浮点值
};
// 使用联合体
union Data data; // 声明一个联合体变量data
// 访问和修改联合体成员
data.floatValue = 10.0; // 设置值
printf("浮点值: %f\n", data.floatValue); // 访问浮点值
printf("整数值: %d\n", data.intValue); // 访问整数值(未定义行为)
在上述代码中,联合体 Data 包含两个成员: intValue 和 floatValue 。当我们设置 floatValue 的值时, intValue 的值会被覆盖,反之亦然。因此,在使用联合体时需要特别小心,确保只访问当前有效的成员。
联合体和结构体实际上是类似的,所以“定义联合体结构”和“声明联合体变量”自然也是分开的;同理,也可以用 typedef 为联合体定义别名,或使用不具名联合体。
联合体的内存模型极为简单,其大小等于其最大成员的大小,这是所有成员共享同一块内存的结果。例如,上述联合体 Data 的大小为4字节,因为 int 和 float 都占用4字节。而下列联合体
union Example {
char a; // 1字节
double b; // 8字节
int c; // 4字节
};
double 是最大的成员,占用8字节。
函数¶
函数是程序中的一个独立模块,用于执行特定的任务。函数可以接受输入参数,执行一些操作,并返回一个结果。使用函数可以提高代码的可读性和可维护性。
函数的声明、定义和调用如下实例所示:
// 函数声明
int add(int, int); // 这里可以省略参数名
// 函数定义
int add (int a, int b) { // 需要保证和定义时的参数类型一致,且不能省略参数名、不能改变顺序
return a + b;
}
// 函数调用
int sum = add(3, 5);
printf("和是: %d\n", sum);
上述代码定义了一个名为 add 的函数,它接受两个整数参数,并返回它们的和。然后,我们调用该函数并将结果存储在变量 sum 中,最后打印出结果。
我们一般把上述a和b叫做“形参”(parameter),而把3和5叫做“实参”(argument)。形参是在函数定义时使用的变量名,用于表示函数接受的输入参数;实参是在函数调用时传递给函数的具体值。
我们不可以在一个函数内部定义另一个函数(即不支持嵌套函数)。main函数也是一个函数,只不过它是程序的入口点,因此也不能在main里面定义另一个函数。
我们发现,在定义上述 add 函数时,使用了两个参数 a 和 b 。这两个参数在函数内部是可以使用的,但是在函数外部是无法访问的。这是因为函数参数的作用域仅限于函数内部。换句话说,函数参数在函数外部是不可见的,无法被访问或修改。
函数的内存模型是相当复杂的,涉及到函数调用栈、参数传递方式(按值传递或按引用传递)、返回值处理等多个方面。一般来说,函数调用时会在栈上分配一块内存空间,用于存储函数的参数、局部变量和返回地址等信息。当函数执行完毕后,这块内存空间会被释放。而大多数情况下,我们也不会去纠结一个函数执行一次需要吃掉多少内存,但必须知道“会吃掉内存”这个事实。
练习
日期差加强版
写一个程序,接受两个日期输入,计算两者之间差了多少天。
程序输入:空格分隔的6个整数year1、month1、day1、year2、month2、day2,分别表示第一个日期的年份、月份和天数,以及第二个日期的年份、月份和天数。
程序输出:一个整数,表示两个日期之间的天数差。
提示:把前面“天数判断”“闰年判断”题目作为子任务来完成,也就是说可以试着复用这些代码。考虑使用函数来组织代码,提高代码的可读性和可维护性。
函数的递归调用¶
函数可以调用自己,这种调用方式叫做递归。递归函数通常用于解决一些具有重复结构的问题,例如计算阶乘、斐波那契数列等。 递归函数的基本格式如下:
int foo(){
if (base_case) {
return base_value; // 基础情况,直接返回结果
} else {
return foo(); // 递归调用
}
}
以上代码:在执行第一个foo的时候,会判断是不是基本情况,如果是则直接结束;如果不是,则会调用foo函数本身。这个过程会一直重复,直到满足基本情况为止。某种程度上,递归也是一种循环的形式。
需要注意的是,递归需要一个基础情况来跳出递归,否则则会产生无限递归错误。例如,我们都知道计算阶乘可以使用\(n!=n\times(n-1)!\),但是只有这一个公式是不够的,不停地递归下去没有尽头。这时候,我们需要一个基础情况来结束递归:\(0!=1\)。因此,我们可以写出递归公式:\(factorial(n) = n \times factorial(n-1)\),其中\(factorial(0) = 1\)。然后,我们就可以用程序语言来描述这个数学语言:
int factorial(int n) {
if (n == 0) {
return 1; // 基础情况
} else {
return n * factorial(n - 1); // 递归调用
}
}
建立递归思维是非常困难的,但也是非常重要的。在实际生活中,很多问题都可以通过“分治-递归”的思路来解决:把大问题分成相似的小问题,解决这些小问题,然后把小问题的解合并成大问题的解。递归函数正是实现这种思路的有力工具。
练习
小明爬楼梯
小明在爬楼梯。他一次可以爬1个或2个台阶。假设楼梯有n个台阶,问小明有多少种不同的爬法?
程序输入:一个整数n,表示楼梯的台阶数。
程序输出:一个整数,表示小明爬楼梯的不同方法数。
提示:考虑:假设小明爬到x级台阶时的爬法有\(f(x)\)种,那么\(f(x)\)能不能被它前面的某些项表示出来?基础情况又是什么?这个递推关系就是大名鼎鼎的状态转移方程,是很多复杂问题的核心。
递归函数虽然很有效,但是开销非常庞大。我们知道,函数只有在执行完毕时才会销毁其栈帧,而递归函数在每次递归调用时,都会创建一个新的栈帧(但旧的栈帧因为没有执行完毕而不会销毁)。积少成多之下,递归函数在执行过程中会占用大量的内存空间,尤其是递归层数很深时,可能会导致栈溢出错误(stack overflow)。此外,递归函数的调用和返回也会带来额外的时间开销,因为每次调用都需要保存和恢复上下文信息。故而,在允许的情况下,应尽量避免使用递归函数,转而使用迭代(iteration)或其他更高效的算法。
int facts[100]; // 假设最大计算到99的阶乘
facts[0] = 1; // 基础情况
for (int i = 1; i < 100; i++) {
facts[i] = i * facts[i - 1]; // 迭代计算
}
类型强转¶
类型强转(type casting)是将一种数据类型转换为另一种数据类型的过程,毕竟大家都不想让5除以2得2。
用括号就可以实现类型强转。例如:
int a = 5;
int b = 2;
double result = (double)a / (double)b; // 强制将a和b转换为double类型
printf("结果是: %f\n", result); // 输出结果
a 和 b 强制转换为 double 类型,然后进行除法运算。如果不进行类型强转,整数除法会导致结果被截断为整数部分,得到2;而通过类型强转,我们可以得到正确的浮点数结果2.5。
类型强转在处理不同数据类型之间的运算时非常有用,可以确保运算结果符合预期。
练习
求平均数
写一个程序,接受一系列整数输入,直到输入0为止,然后输出这些整数的平均值(不包括结尾的0)。
程序输入:一系列整数,每个整数占一行,最后一个整数为0,表示输入结束。
程序输出:一个浮点数,表示输入整数的平均值,保留两位小数。
提示:虽然把输入的整数定义为浮点数是可以避免类型强转的,但在金融上这会产生误差,是不可接受的。因此不得将输入的整数定义为浮点数,而是要定义为整数类型、加和,再通过类型强转来计算平均值。
宏和预处理指令¶
宏是一种预处理指令,它可以在编译之前对代码进行替换和扩展。宏的基本格式如下:
#define 宏名 替换内容
我们可能会看到,诸如 #define 、 #include 等均以符号 # 开头,这些都是预处理指令,有时候也叫做编译指令。预处理指令和常规代码的行为有区别:它们实际上并非代码的一部分,而是在编译器对代码进行预处理的时候进行处理的。预处理指令通常用于定义宏、包含头文件、条件编译等。常用的预处理指令还有 #pragma 、 #ifdef 等。活用编译指令可以让代码更灵活、更高效。
Warning
严格禁止使用所谓的“火车头”预处理指令!
所谓的火车头预处理指令,指的是在代码的开头使用大量的 #pragma 来指定编译器的行为。这种做法显著地导致了代码的可移植性和可维护性变差。因为不同的编译器对 #pragma 的支持程度不同,甚至同一编译器的不同版本对某些 #pragma 的支持也可能不同。而且你辛辛苦苦打一大堆 #pragma ,实际上优化效果还不如一个简单的 -O3 。这种完全属于歪门邪道的做法,严重违反了代码简洁和可维护的原则。
指针和内存操作¶
指针是C语言的最重要特性,没有之一。该特性彻底奠定了C语言在系统编程领域的统治地位。但对于新手而言,要理解指针难度还是比较大的,因此我们会尽量用通俗易懂的语言来解释指针的概念和使用方法;读者一定要确保理解该内容,而不是背“八股”式的语法,否则后续内容将会变得非常困难。
当然,如果我们先前讲的内存模型都理解了的话,理解指针其实并不难。
什么是指针¶
所有教材(甚至包括C标准)中,对指针的定义实际上都是“一个变量,它存储了另一个变量的内存地址”。但是,这个定义对于初学者来说过于抽象,难以理解。因此,我们可以用一个更形象的比喻来解释指针的概念。
想象内存是一条很长很长的一维停车场,停车场被划分为许多连续编号的地块,也就是“地址”(address)。
现在我们 int a = 42; 。于是,编译器给a分配了4个连续的地块(假设 int 类型占4字节),并把这4个地块的编号(地址)记为0x1000、0x1001、0x1002、0x1003。于是,我们可以说变量a的“地址”是0x1000,告诉我们变量a停放在内存的哪个位置;而它的值是42,告诉我们变量a里面存放的东西是什么。
上述内容可以记作:
int* p = &a;
int *p = &a; // 或者这样,但实际没有任何区别
Note
星号写在哪里都无所谓,甚至
int*p = &a;
int * p = &a;
C语言程序员大多习惯int *p = &a;,认为这样更符合语言的习惯;而C++程序员大多习惯int* p = &a;,认为这样更能体现指针类型的本质(但“指针类型”这个概念实际上是C++引入的,C语言并没有这个概念)。实际的代码应符合团队的代码风格规范。为了便于理解,我们在本书中统一使用int* p = &a;这种写法。
可以看到,上述 &a 就是“取地址”,也就是获取变量a的地址。而 int* p 则是声明了一个指针变量p,它的类型是“指向int类型变量的指针”,也就是说,p只能存储int类型变量的地址。于是,我们就可以说,p存储了变量a的地址0x1000。
那么怎么用这个指针呢?我们可以通过指针来访问和修改变量的值。例如:
*p = 100; //
printf("%d\n", p); // 输出指针p的值(地址)
printf("%d\n", a); // 输出变量a的值
*p被称作“解引用”(dereference),表示“通过指针p访问它所指向的变量”,也就是“通过地址0x1000访问地块里的东西”。因此 *p = 100; 的意思就是“把p指向的地块里的东西改成100”,也就是把变量a的值改成100。
那么如果这样呢?
p = 100;
printf("%d\n", p); // 输出指针p的值(地址)
printf("%d\n", a); // 输出变量a的值
printf("%d\n", *p); // 试图通过指针p访问它所指向的变量
但是当我们试图通过指针p访问它所指向的变量时,程序可能会崩溃!这是因为p现在指向的是地址100,而这个地址并没有被分配给任何变量,因此访问这个地址会导致未定义行为!这被叫做“悬空指针”(dangling pointer),俗称“野指针”。因此,在使用指针时,一定要确保指针指向的是一个有效的变量。
因此,在指针中,两个运算符不要弄反:
- & :取地址运算符,用于获取变量的地址,或“门牌号”。
- * :解引用运算符,用于通过指针访问变量的值,或“门牌号对应房间里的东西”。
有一种特殊的指针被称为“空指针”(null pointer),可以理解为“该指针没有指向任何地块”,常用作为指针的初始值或者表示指针不指向任何有效变量。在C中,可以使用宏 NULL 来表示空指针。例如:
free(p); // 释放内存,此时p指向的内存不再有效
p = NULL; // 释放内存后,立刻将指针设置为NULL,避免悬空指针
指针的三条铁律¶
在使用指针时,有三条铁律需要牢记于心:
- 指针存储的是地址(门牌号),类型必须匹配;int* 类型的指针只能存储 int 类型变量的地址,char* 类型的指针只能存储 char 类型变量的地址,依此类推。至于原因,看到下文就明白了。
- 指针必须初始化!直接 int* p; 会得到一个野指针,里面是一个垃圾数值,千万不要使用它,用了大概率段错误。要是真想这么干,声明空指针即可。
- 用完的内存要还。这个后面讲到动态内存分配时会讲到为什么。
指针和数组、函数的配合¶
指针和数组¶
在C中,数组名实际上是一个指向数组第一个元素的指针。因此,我们可以使用指针来访问和操作数组元素。而指针的运算也往往无法脱离数组来理解。
例如:
int numbers[] = {10, 20, 30, 40, 50};
int* p = numbers; // 数组名作为指针,指向第一个元素
for (int i = 0; i < 5; i++) {
printf("%d\n", *(p + i)); // 通过指针访问数组元素
}
numbers 是一个数组名,它在表达式(和函数传参)中,会退化成首元素的地址,因此 int* p = numbers; 实际上等价于 int* p = &numbers[0]; 。
而上述代码中的 *(p + i) 则是通过指针运算来访问数组元素。这里, p + i 表示指针p向后移动i个元素的位置,而 * 则用于解引用该位置,从而获取对应的数组元素的值。实际上上述计算的意思是,“从地址 p 开始,向后移动 i 个 int 类型的字节数,然后访问该地址对应的值”。 *(p + i) 事实上等价于 numbers[i] 。
与之类似的,++p 表示指针p向后移动一个元素的位置,而 p+1 则表示指针p向后移动一个元素的位置,但并不改变指针p本身。
这就解释了为什么指针类型必须匹配的问题:如果指针类型不匹配,那么指针运算时移动的字节数就会出错,从而导致访问错误的内存地址,进而引发未定义行为。
指针和函数¶
指针和函数的配合主要体现在函数参数传递上。
我们可以写一段代码来说明这个问题:
void swap(int x, int y){
int temp = x;
x = y;
y = temp;
}
swap(a, b);
printf("a = %d, b = %d\n", a, b); // 输出结果
swap(a, b); 时,实际上是将a和b的值复制了一份传递给函数 swap 的参数x和y。因此,在函数内部对x和y的修改并不会影响到外部的a和b。
那么怎么才能真正去影响a和b呢?这时就需要用到指针了。我们可以将a和b的地址传递给函数,然后在函数内部通过指针来修改它们的值。例如:
void swap(int* x, int* y){
int temp = *x;
*x = *y;
*y = temp;
}
swap(&a, &b);
printf("a = %d, b = %d\n", a, b); // 输出结果
swap 的参数x和y,然后在函数内部通过解引用指针来修改它们所指向的变量的值,而非仅仅复制一份值。
函数指针¶
函数指针则是指向函数的指针变量。通过函数指针,我们可以动态地调用不同的函数,从而实现更灵活的代码结构。例如:
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
int (*funcPtr)(int, int);
// 将函数指针指向add函数
funcPtr = add;
printf("5 + 3 = %d\n", funcPtr(5, 3)); // 调用add函数
// 将函数指针指向multiply函数
funcPtr = multiply;
printf("5 * 3 = %d\n", funcPtr(5, 3)); // 调用multiply函数
这样能够让我们在运行时选择要调用的函数,从而实现更灵活的代码结构。
动态内存分配¶
动态内存分配是指在程序编译时不知道用多少内存,于是在运行时根据需要动态地分配和释放内存空间。
在C中,动态内存分配主要通过以下三个函数来实现:
- malloc(size_t size) :用于分配指定大小的内存块,返回一个指向该内存块的指针。如果分配失败,返回 NULL 。
- calloc(size_t num, size_t size) :用于分配指定数量的内存块,并将其初始化为零。返回一个指向该内存块的指针。如果分配失败,返回 NULL 。
- free(void* ptr) :用于释放之前分配的内存块。参数 ptr 是指向要释放的内存块的指针。
例如:
int n;
scanf("%d", &n); // 读取数组大小
// 动态分配一个包含n个整数的数组
int* arr = (int*)malloc(n * sizeof(int));
if (arr == NULL) {
perror("内存分配失败");
exit(EXIT_FAILURE);
}
// 使用数组
for (int i = 0; i < n; i++) {
arr[i] = i * 2; // 初始化数组元素
}
// 释放内存
free(arr);
arr = NULL; // 好的实践,立即置空,防止悬空指针
malloc 函数动态分配了一个包含n个整数的数组。接着,我们使用该数组进行了一些操作,最后使用 free 函数释放了之前分配的内存。如果不释放这个内存,那么程序常驻时会把内存吃光,导致系统崩溃,这被称为“内存泄漏”;如果不小心释放了两次同一块内存,程序也会崩溃,这被称为“双重释放”。这两个都是非常严重的错误,必须避免。
需要说明的是,malloc返回的是无类型指针( void* ),C允许直接赋值给任何其他指针类型(例如 int* ),这是C特有的,而C++就不允许这么写。而在C中,我也推荐在赋值前进行强制类型转换。
静态变量和const指针¶
如果试图想在函数中保存一些状态信息,可以考虑使用静态变量。例如:
int* foo(){
int x = 42; // 自动变量
return &x; // 错误,返回局部变量地址,x作为局部变量在函数结束后被销毁
}
int* foo_fixed(){
static int x = 42; // 静态变量
return &x; // 正确,返回静态变量地址,x在程序运行期间始终存在
}
至于const指针,则很特殊:
int a = 10;
const int* p1 = &a; // 指向常量的指针,不能通过p1修改a的值
int* const p2 = &a; // 常量指针,不能修改p2的值,但可以通过p2修改a的值
const int* const p3 = &a; // 谁都别想动我
初级泛型编程:void指针¶
在C语言中, void* 指针是一种特殊的指针类型,它可以指向任何类型的数据。由于 void* 指针没有具体的类型信息,因此在使用时需要进行类型转换(type casting)才能访问其指向的数据。但这给了程序员极大的自由度,很多C程序员都是 void* 大神。
首先必须说明:void* 指针不能进行解引用操作,因为编译器无法确定其指向的数据类型和大小;也不能对其进行加减运算,因为编译器无法确定每次移动的字节数。故而,实际上只能把 void* 指针作为一种通用的指针类型来传递数据,而不能直接操作数据。
例如:
void printValue(void* ptr, char type) {
if (type == 'i') {
int* intPtr = (int*)ptr; // 将void指针转换为int指针
printf("整数值: %d\n", *intPtr);
} else if (type == 'f') {
float* floatPtr = (float*)ptr; // 将void指针转换为float指针
printf("浮点值: %f\n", *floatPtr);
}
}
int main() {
int a = 42;
float b = 3.14f;
printValue(&a, 'i'); // 传递整数
printValue(&b, 'f'); // 传递浮点数
return 0;
}
printValue 接受一个 void* 指针和一个类型标识符作为参数。根据类型标识符的值,函数将 void* 指针转换为相应类型的指针,并通过解引用操作访问其指向的数据。这是很初级的“泛型”编程:通过 void* 指针,我们可以编写能够处理不同数据类型的通用函数,从而提高代码的复用性和灵活性。
但这种操作也是极为令人头疼的,因为你必须自己保证传递的类型标识符和实际数据类型一致,否则就会导致未定义行为;而有些代码库中,甚至调用某个函数还会返回 void* 指针,不深入理解的情况下甚至不知道这是个什么东西,令人并不愉快。所以一定要谨慎使用 void* 指针。
指针常见错误¶
指针是C语言中非常强大但也非常容易出错的特性。以下是一些常见的指针错误(其实我大多都提到过了): - 没初始化:出现这种情况应该自罚三杯。 - 数组越界:一不小心访问了数组之外的内存地址,可能会导致程序崩溃或数据损坏。解决方法是确保访问的索引在数组的有效范围内。 - 返回局部变量地址:函数中的局部变量会随着函数的结束而销毁,因此试着返回它们的地址(或在函数外使用它们的地址)会导致悬空指针。解决方法是将变量声明为静态变量。 - free以后忘了,接着用:释放内存后继续使用该内存地址会导致未定义行为。解决方法是,free之后,立刻把指针置为NULL,防止悬空指针。 - 把int强转成指针乱玩:除非你知道你在做什么,否则不要这么做。
练习
指针练习题
编写一个函数,接受一个整数数组和它的大小作为参数,返回数组中的最大值和最小值。
程序输入:一个整数n,表示数组的大小,接着是n个整数,表示数组的元素。
程序输出:两个整数,分别表示数组中的最大值和最小值。
提示:试着使用指针来遍历数组,并在函数中返回最大值和最小值。另,试着使用动态的内存分配来创建实际上的动态数组,而不是写VLA或预先写一个巨大的静态数组。
文件操作¶
文件读写¶
C的文件操作也是基于指针的。
在C中,文件操作主要通过标准库中的 FILE 结构体和相关函数来实现。 FILE 结构体表示一个文件流,它包含了文件的状态信息和缓冲区等数据。我们可以使用指向 FILE 结构体的指针来操作文件。为了使用文件操作功能,我们需要包含头文件 stdio.h 。
例如,我们需要使用以下手段来进行文件操作:
- 打开文件:使用 fopen 函数打开一个文件,返回一个指向 FILE 结构体的指针。
- 读取文件:使用 fread 或 fgets 等函数从文件中读取数据。
- 写入文件:使用 fwrite 或 fputs 等函数向文件中写入数据。
- 关闭文件:使用 fclose 函数关闭文件,释放资源。
具体而言,下面是一个简单的文件操作示例:
#include <stdio.h>
int main() {
// 打开文件
FILE* file = fopen("example.txt", "w");
if (file == NULL) {
perror("无法打开文件");
return 1;
}
// 写入数据
const char* text = "Hello, World!\n";
fwrite(text, sizeof(char), strlen(text), file);
// 关闭文件
fclose(file);
return 0;
}
fopen 函数接受两个参数:文件名(实际上是文件相对可执行文件的路径)和模式。模式可以是r(只读)、w(只写,文件不存在则创建,存在则清空)、a(追加写入,文件不存在则创建)等。函数返回一个指向 FILE 结构体的指针,如果打开失败则返回 NULL 。
文件操作¶
除了文件的读写外,我们有时候还希望删除、重命名文件等操作。C标准库提供了一些函数来实现这些功能,例如:
- remove(filename) :删除指定的文件。
- rename(old_filename, new_filename) :重命名文件。
- fseek(file_ptr, offset, whence) :设置文件指针的位置。
- ftell(file_ptr) :获取文件指针的当前位置。
- rewind(file_ptr) :将文件指针重新定位到文件的开头。
- feof(file_ptr) :检查是否到达文件末尾。
- ferror(file_ptr) :检查文件操作是否出错。
例如,我们想删除一个文件,可以使用 remove 函数:
if (remove("example.txt") == 0) {
printf("文件删除成功\n");
} else {
perror("文件删除失败");
}
perror 函数用于打印最近一次系统调用失败的错误信息。
多文件编程¶
头文件和源文件¶
在实际工程中,如果把所有的代码都写在一个文件中,代码量会非常庞大,难以维护和管理;如果遇到多人协作开发,问题会更加严重。因此,我们需要将代码拆分成多个文件,每个文件负责不同的功能模块,从而提高代码的可维护性和可读性。在C中,代码被分为“头文件”和“源文件”两种类型。
头文件(header files)通常以 .h 作为文件扩展名,包含函数声明、宏定义、结构体定义等内容。源文件(source files)通常以 .c 作为文件扩展名,包含函数的具体实现和程序的入口点( main 函数)。通过将代码拆分成头文件和源文件,我们可以实现代码的模块化和重用。我们可以在多个源文件中包含同一个头文件,从而共享函数声明和数据结构定义。例如:
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int subtract(int a, int b);
#endif // MATH_UTILS_H
// math_utils.c
#include "math_utils.h" // 包含头文件
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
// main.c
#include <stdio.h>
#include "math_utils.h" // 包含头文件
int main() {
int x = 10;
int y = 5;
printf("x + y = %d\n", add(x, y));
printf("x - y = %d\n", subtract(x, y));
return 0;
}
实际编译时,可以使用以下命令将多个源文件编译成一个可执行文件:
gcc main.c math_utils.c -o my_program
math_utils.h 中,将函数的实现放在源文件 math_utils.c 中,然后在主程序 main.c 中包含头文件并调用这些函数。
头文件中的 #ifndef 、 #define 和 #endif 是一种常见的防止重复包含的技术,称为“包含保护”(include guard),也叫“编译守卫”。它确保头文件只被包含一次,避免了重复定义的问题。
include原理¶
我们可能会疑惑:为什么在上述编译指令中,只需要指定源文件,而不需要指定头文件呢?编译守卫又是怎么起作用的呢?这是因为头文件实际上并不是独立的编译单元,而是通过预处理指令 #include 被包含到源文件中的。为了理解这个,我们先简要理解一下编译的过程。
编译大致分为以下几个阶段:
- 预处理(Preprocessing):处理预处理指令,如 #include 、 #define 等,除去注释,生成纯净的源代码。这一步也叫“预编译”。
- 编译(Compilation):将预处理后的源代码转换为汇编代码。
- 汇编(Assembly):将汇编代码转换为机器码,生成目标文件(通常以 .o 或 .obj 作为扩展名)。
- 链接(Linking):将多个目标文件和库文件链接成一个可执行文件。
#include 是一种预编译指令,其意思可以理解为“把指定的头文件的内容直接插入到当前位置”。因此,当我们编译源文件时,编译器会先处理所有的 #include 指令,将头文件的内容插入到源文件中,然后再进行编译。换句话说,在预编译这一步结束后,上述的 math_utils.c (实际上不会叫这个名字了)会变成这样:
int add(int a, int b);
int subtract(int a, int b);
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}
而编译守卫解决的是诸如这种问题:假设我们写了两个头文件,姑且分别叫做甲、乙,其中乙头文件包含了甲头文件。这时,我们写一个源文件丙,其中include了乙头文件。那么在预编译时,丙会先包含乙,然后乙又包含甲,这是正确无误、不会报错的;但如果丙又直接包含了甲头文件,那么甲头文件就会被包含两次,导致一些函数和变量被重复定义,从而引发编译错误。编译守卫通过定义一个宏来防止头文件被重复包含,从而避免了这个问题。
所以说,如果没有这个头文件,那么编译器在编译 main.c 时就会报错,提示找不到 add 和 subtract 的声明。
链接原理¶
在编译过程中,编译器会把多个源文件分别处理成多个目标文件。而把多个“目标文件”真正合并成一个可执行文件的过程,叫做“链接”(linking)。链接器会把各个目标文件中的符号(函数和变量)进行解析和合并,从而生成最终的可执行文件。
具体的合并过程大致是这样的。如果我们直接编译上述 main.c,并查看其汇编,会发现:
call add 0x00401000 <add> # 这是胡编的地址,仅作示例
call subtract 0x00401010 <subtract> # 同上
add 和 subtract 函数的具体实现插入到 main 函数中,而是生成了对这两个函数的调用指令,并且使用了占位符地址。这是因为编译器确实看到了这两个函数的声明,但并不知道这两个函数的具体实现在哪里,故而知道它们是外部函数。故而,编译器会在该源文件的目标文件中,生成对这两个外部函数的“标记”(也就是符号),表示“这里要使用add函数,地址待定”。
而真正定义这两个东西的是 math_utils.c ,它会被编译成另一个目标文件,其中包含了这两个函数的具体实现和地址信息,即符号定义。
链接器的核心职责就是把这堆东西合并成一个,其最重要的任务就是“符号解析”:
- 扫描所有的目标文件,收集每一个文件提供的符号定义和需要的符号引用。
- 对每一个符号引用,查找对应的符号定义,并将引用地址替换为定义地址。
- 处理未定义符号,如果有符号引用找不到对应的定义,链接器会报错。
以上述代码为例,链接器在处理 main.o 时,发现它引用了 add 和 subtract 这两个符号;然后它扫描 math_utils.o ,发现它定义了这两个符号,并且知道它们的具体地址;最后,链接器将 main.o 中对这两个符号的引用地址替换为它们在 math_utils.o 中的实际地址,从而完成链接过程。
标准库常用头文件¶
C标准库头文件按照C17标准一共29个,其中有一些方法是我们经常会用到的。下面列出一些常用的头文件及其主要功能,基本上覆盖了C代码八成以上的需求。剩余的头文件,读者可以根据需要自行查阅相关资料。
stdio.h¶
该库主要负责输入输出操作。除了scanf和printf,还包括文件操作等功能。
- fopen(filename, mode) :打开文件,返回一个文件指针。
- fclose(file_ptr) :关闭文件。
- fread(buffer, size, count, file_ptr) :从文件中读取数据到缓冲区。
- fwrite(buffer, size, count, file_ptr) :将缓冲区的数据写入文件。
- fprintf(file_ptr, format, ...) :格式化输出到文件。
- fscanf(file_ptr, format, ...) :格式化从文件读取数据。
stdbool.h¶
该库主要负责布尔类型的定义和操作。它定义了一个名为 bool 的数据类型,以及两个宏 true 和 false ,分别表示布尔值的真和假。
string.h¶
该库主要负责字符串操作,顺带一些内存操作。常用函数包括:
- strlen(str) :返回字符串的长度(不包括结尾的空字符)。
- strcpy(dest, src) :将源字符串 src 复制到目标字符串 dest 中。
- strcat(dest, src) :将源字符串 src 连接到目标字符串 dest 的末尾。
- strcmp(str1, str2) :比较两个字符串 str1 和 str2 的大小关系。
- strchr(str, ch) :在字符串 str 中查找字符 ch 的第一次出现位置。
- strstr(str1, str2) :在字符串 str1 中查找子字符串 str2 的第一次出现位置。
- memcpy(dest, src, n) :将源内存块 src 的前 n 个字节复制到目标内存块 dest 中。
- memset(dest, val, n) :将目标内存块 dest 的前 n 个字节设置为值 val 。该方法用来清理数组非常方便。
stdlib.h¶
该库主要负责内存分配、程序控制和数值转换等功能。常用函数包括:
- malloc(size_t size) :分配指定大小的内存块。
- calloc(size_t num, size_t size) :分配指定数量的内存块,并将其初始化为零。
- free(void* ptr) :释放之前分配的内存块。
- atoi(str)、 atof(str) 、strtol(str, endptr, base) 等:将字符串转换为整数或浮点数。
- qsort(base, nmemb, size, compar) :对数组进行快速排序。
- bsearch(key, base, nmemb, size, compar) :在已排序的数组中进行二分查找。
- realloc(ptr, size) :重新分配内存块的大小。
- exit(status) :终止程序的执行,并返回状态码。
math.h¶
该库主要负责一些数学运算函数。常用函数包括:
- sqrt(x) 、pow(x, y) 、sin(x) 、cos(x) 、tan(x) 、log(x) 、exp(x) 等:各种数学函数,一目了然。
- abs(x) 、fabs(x) :计算整数或浮点数的绝对值。
- ceil(x) 、floor(x) :向上取整和向下取整函数。
- round(x) :四舍五入函数。
- fmod(x, y) :计算浮点数的余数。
练习
改错练习
以下代码均有错误或未定义行为或不良实践,请指出并改正。
#include <stdio.h>
int main(){
int n;
int arr[n];
scanf("%d", &n);
for (int i = 0; i <= n; i++) {
scanf("%d", &arr[i]);
}
return 0;
}
#include <stdio.h>
int main(){
int arr[5];
for(int i = 0; i <= 5; i++){
scanf("%d", &arr[i]);
}
return 0;
}
#include <stdio.h>
int main(){
int *p;
*p = 10;
printf("%d\n", *p);
return 0;
}
#include <stdio.h>
int main(){
char str[5];
scanf("%s", str); // input: Hello
printf("%s\n", str);
return 0;
}
#include <stdio.h>
int main(){
int x = 5;
if (x = 0){
printf("x is zero\n");
}
return 0;
}
#include <stdio.h>
int* getNumber(){
int a = 42;
return &a;
}
int main(){
int *p = getNumber();
printf("%d\n", *p);
return 0;
}
#include <stdio.h>
void printSize(int arr[]){
printf("%zu\n", sizeof(arr));
}
int main(){
int arr[10];
printSize(arr);
return 0;
}
#include <stdio.h>
int main(){
int a = 1;
int b = a++ + a++;
printf("%d %d\n", a, b);
return 0;
}
答案
以上八个题目(以左栏第一个为第一题)分别错在:
1. n未初始化就使用。应先读入n,再定义数组。另,VLA不是C标准的一部分,建议使用动态内存分配malloc。
1. 数组越界。应改为i < 5。
1. 指针未初始化就使用。
1. 数组长度不足以存储输入的字符串,是忘记\0导致的。应改为char str[6];。
1. 误用赋值运算符。应改为if (x == 0)。
1. 返回局部变量地址,导致悬空指针。应改为静态变量或动态分配内存。
1. 数组作为函数参数时退化为指针,sizeof结果是指针大小。应传入数组大小作为额外参数。
1. 未定义行为,因为a++的副作用未定义顺序。应改为两行分别处理。
改正代码略,读者可自行完成。