LW Leon 学习

C 语言入门:从第一个程序到指针

写给从没编过程序的人的八节短课:写出并运行第一个程序,打印数值,学会变量、判断与循环、函数、数组与字符串、指针,并读懂编译器的提示。每个例子都能直接在本页运行。

开始前需要什么

  • 不需要任何编程经验。如果你从没写过代码,就从第 1 课开始,本篇不假设你已经懂什么。
  • 中小学算术:加、乘、除,以及余数(7 除以 2 等于 3,余 1)。
  • 知道文件和文件夹是什么。需要用到的几条终端命令会在第 1 课教。
  • 每课大约 30 分钟。请亲手输入例子,只读是不够的。
  • 开始时不需要安装任何东西:每个例子在本页都有“运行”按钮。想在自己的电脑上练习时,再按下一节安装真正的编译器。

准备你的电脑

一开始可以跳过:下面的例子在这个页面上就能运行。

开始学习不需要安装任何东西。下面每个例子都有“运行”按钮,会在浏览器里编译并运行这段 C 程序。前几课可以直接用它。

不过,你最终还是需要在自己的电脑上装一个真正的编译器。编译器是把你写的 C 文字变成电脑能运行的程序的工具。原因有两个。第一,你接下来要学的课程是在终端里编译程序的,所以要养成这个习惯。第二,浏览器里的 C 发现不了内存错误。一个读写了自己无权访问的内存的程序,在本页照样能运行,还可能打印出看起来正常的结果,其实它是错的。真正的编译器配上 sanitizer(运行时检查工具,第 8 课会讲)会让这种程序当场停下,并告诉你出错的那一行。安装大约需要十分钟。

选择你的系统,按步骤操作,然后运行检查命令。终端是一个窗口:你在里面输入命令,按回车键运行。

macOS
  1. 打开“终端”应用:同时按下 Command 和空格键,输入 Terminal,按回车键。

  2. 输入下面这一行,按回车键:
    xcode-select --install

  3. 会弹出一个窗口,询问是否安装命令行开发者工具。点“安装”,并同意许可协议。选“安装”,不要选“获取 Xcode”:你不需要完整的 Xcode。下载需要几分钟。如果终端提示这些工具已经安装,说明你已经有了。

  4. 安装结束后,关闭终端窗口,再重新打开一个,然后运行下面的检查命令。

检查是否成功

cc --version

你应该看到 会出现几行文字。第一行以 Apple clang version 和一个版本号开头。以 Target 开头的一行写着你的芯片,例如 arm64-apple-darwin 或 x86_64-apple-darwin。不同电脑上的数字不同。

Linux(Ubuntu、Debian 及类似系统)
  1. 打开终端。在很多桌面系统里,快捷键是同时按 Ctrl、Alt 和 T。

  2. 更新可安装软件的列表。输入下面这一行,按回车键:
    sudo apt update
    sudo 的意思是“以管理员身份运行”。它会要求输入密码,而且输入时屏幕上什么都不显示,这是正常的。

  3. 安装编译器和配套工具:
    sudo apt install build-essential
    如果它问是否继续,输入 Y。build-essential 这个软件包会带来 gcc、标准 C 库和 make 工具。

  4. 如果你用的是 Fedora,请改用这一行:
    sudo dnf install gcc make

检查是否成功

cc --version

你应该看到 会出现几行文字。第一行写着编译器的名字和版本号,例如 cc (Ubuntu 13.2.0-23ubuntu4) 13.2.0。你的版本号会不一样。

Windows(通过 WSL)
  1. WSL 是 Windows Subsystem for Linux(适用于 Linux 的 Windows 子系统)的缩写。它在 Windows 里给你一个真正的 Linux 终端,之后按 Linux 的步骤操作即可。你需要 Windows 10 2004 或更新的版本,或 Windows 11。

  2. 点开始菜单,输入 PowerShell,右键点“Windows PowerShell”,选择“以管理员身份运行”。

  3. 输入下面这一行,按回车键:
    wsl --install
    它会安装 WSL 和默认的 Linux 系统 Ubuntu。如果它只打印出一串选项,说明 WSL 已经装好了,请改为运行:
    wsl --install -d Ubuntu

  4. 提示重启时,重启电脑。

  5. 重启后,Ubuntu 窗口会自己打开。如果没有,就打开开始菜单,输入 Ubuntu 并打开。第一次启动需要几分钟,之后它会让你设置用户名和密码。输入密码时屏幕上不显示任何内容。

  6. 在 Ubuntu 窗口里,按 Linux 的步骤操作:先运行 sudo apt update,再运行 sudo apt install build-essential。

  7. 从现在起,所有 C 语言的操作都在 Ubuntu 窗口里做,不要在 PowerShell 里做。Ubuntu 启动时所在的文件夹就可以直接用。

检查是否成功

cc --version

你应该看到 请在 Ubuntu 窗口里运行。你应该看到和 Linux 上同样的输出:第一行里有 cc、Ubuntu 和版本号。

如果看到报错

  • implicit declaration of function 'printf'

    编译器遇到了一个没有人告诉过它的函数名。几乎总是因为你忘了在文件开头写 #include <stdio.h>,printf 的说明就在这个头文件里。其他原因有:函数名拼错了,或者在定义自己的函数之前就调用它(第 5 课会讲解决办法)。

    在 Mac 上,同样的错误显示为:call to undeclared library function 'printf'。旧版本的 gcc 在这里只给出警告,仍然生成程序,而这个程序的行为会很奇怪。无论哪种情况,都把它当作错误来处理。

  • undefined reference to `main'

    编译器读了你的文件,但没有找到名叫 main 的函数。C 程序总是从 main 开始运行,没有它就无处可运行。请检查拼写:man、Main 和 mian 是不同的名字。请确认编译的是你想编译的那个文件。还要确认 main 没有被写进另一个函数的花括号里。

    在 Mac 上,同样的问题显示为:Undefined symbols for architecture arm64,后面跟着 "_main"。

  • command not found

    终端找不到这个名字的程序。常见原因有三个。

    一:你想运行 ./hello,却输入了 hello。终端不会自动到当前文件夹里找程序,除非你在名字前面写上 ./。二:编译器没有装好,所以不认识 cc。请重新做一遍你所用系统的安装步骤。三:你刚装好。请关掉终端窗口,重新打开一个。

    完整的提示在 Mac 上类似 zsh: command not found: cc,在 Linux 上类似 bash: cc: command not found。在 Windows 上,如果你看到 "cc is not recognized as the name of a cmdlet",说明你在 PowerShell 里。请改用 Ubuntu 窗口。

  • Segmentation fault

    程序访问了不属于它的内存地址,被系统终止了。典型原因有:指针指向了无效的地方(第 7 课),数组下标超出了数组范围(第 6 课),以及在 scanf 里忘了变量前面的 &。

    在 Linux 上提示是 Segmentation fault (core dumped),在 Mac 上是 zsh: segmentation fault。有些系统显示的是 Bus error,处理方法相同。

    要找到出错的那一行,用 -g -fsanitize=address,undefined 重新编译,再运行一次。sanitizer 会打印出文件名和行号(第 8 课)。

  • No such file or directory

    文件名或文件夹和你以为的不一样。输入下面这条命令,看看你现在在哪个文件夹:
    pwd
    再输入这条,列出这个文件夹里的文件:
    ls
    如果列表里没有你的文件,就用 cd 切换到正确的文件夹。还要检查文件名拼写和 .c 结尾。

01

源代码、编译、运行:第一个程序

学完你能 学完这课,你能写出一个小的 C 程序,编译并运行它,并说清楚源代码、编译和运行各是什么意思。

电脑会按指令办事。编程语言是让人能读懂的写指令的方式。C 就是其中一种。它历史悠久、小巧而且很快,很多操作系统都是用它写的。

让一个 C 程序跑起来有三步,每一步都有名字。

源代码是你写的文字。你把它保存在一个纯文本文件里,文件名以 .c 结尾,例如 hello.c。电脑不能直接照着这段文字去做。

编译是把源代码翻译成机器码,也就是处理器能执行的数字。负责翻译的工具叫编译器。它读入 hello.c,写出一个新文件,这个文件叫可执行文件,是电脑可以运行的文件。编译时,编译器还会检查你的文字,发现严重的错误就会停下来。

运行是让电脑去执行这个可执行文件。程序打印出结果,然后结束。

这个循环你会重复很多次:修改源代码,编译,运行。如果你改了源代码却忘了编译,运行的就还是旧程序。

每个 C 程序都从一个叫 main 的函数开始。函数是一组有名字的指令。下面例子的第一行是 #include,它引入 printf 的说明,printf 是用来打印文字的函数。花括号 { } 标出 main 从哪里开始、到哪里结束。里面每一条指令叫语句,语句以分号结尾。

在自己的电脑上,你要在终端里做这些事。现在只需要四条命令。想看看你现在在哪个文件夹,输入:
pwd
想列出这个文件夹里的文件,输入:
ls
想新建一个叫 c-practice 的文件夹,输入:
mkdir c-practice
想进入这个文件夹,输入:
cd c-practice

每条命令输入后按回车键。接着用 nano 创建文件,nano 是在终端里运行的编辑器:
nano hello.c
输入程序,同时按 Ctrl 和 O 再按回车键来保存,同时按 Ctrl 和 X 退出。任何纯文本编辑器都可以用。不要用文字处理软件,因为它会加入看不见的格式。然后编译并运行:
cc -std=c11 -Wall -Wextra hello.c -o hello
./hello

这里 cc 是编译器,hello.c 是源代码,-o hello 的意思是“把可执行文件命名为 hello”。请特别小心:-o 后面的名字是编译器要创建的文件,同名文件会被它覆盖。千万不要把你的 .c 文件写在那里。选项 -Wall 和 -Wextra 让编译器指出可疑的代码,第 8 课会解释。./hello 的意思是“当前文件夹里叫 hello 的程序”。如果编译这一行没有打印任何东西,就说明成功了。

你也可以直接点下面例子的“运行”按钮,它会在页面里帮你完成编译和运行。

新词

源代码
程序的文字形式,保存在 hello.c 这样的文件里。
编译器
把源代码翻译成可执行文件,并报告它发现的错误的程序。
可执行文件
编译器写出的文件,电脑可以直接运行它。
终端
一个窗口,你在里面输入命令并阅读结果。
函数
一组有名字的指令。每个 C 程序都从函数 main 开始。
语句
一条指令。在 C 里以分号结尾。

完整例子

C11
// hello.c: my first program
#include <stdio.h>

int main(void) {
    printf("Hello, Leon!\n");
    printf("This program ran.\n");
    return 0;
}
先预测输出,再看结果

预期输出

Hello, Leon!
This program ran.

逐步讲解

  1. 第一行以 // 开头。一行里 // 后面的内容都是注释:写给人看的说明,编译器会忽略它。

  2. #include <stdio.h> 引入 printf 的说明。stdio 是 standard input and output(标准输入输出)的缩写。没有这一行,编译器就不知道 printf 是什么。

  3. int main(void) 是函数 main 的开头。单词 int 表示 main 结束时会交回一个整数。单词 void 表示 main 不接收任何东西。

  4. { 打开 main 的函数体,与它配对的 } 把函数体关上。两者之间的内容都属于 main。

  5. printf("Hello, Leon!\n"); 打印引号之间的文字。两个字符 \n 表示“换到新的一行”。分号结束这条语句。

  6. 第二条 printf 打印第二行。语句从上到下,一条接一条地执行。

  7. return 0; 结束 main。0 告诉系统程序运行成功。

常见错误

错误写法
printf("Hello, Leon!\n")
return 0;

printf 这一行末尾没有分号。编译器会报错停下,提示类似:expected ';' after expression。有些编译器(包括 gcc)可能把错误报在下一行,因为它们是在那里才发现这条语句没有结束。

正确写法
printf("Hello, Leon!\n");
return 0;
错误写法
hello

终端回答 command not found(找不到命令)。除非你指明,否则它不会去当前文件夹里找。要在名字前面加上 ./。

正确写法
./hello
错误写法
printf("Hello, Leon!");

没有 \n,文字就不会换行,终端的提示符会紧挨着它出现:Hello, Leon!$,光标跟在后面。请在文字末尾加上 \n。

正确写法
printf("Hello, Leon!\n");
错误写法
(edit hello.c, save it)
./hello

你改了源代码,却运行了旧的可执行文件。不重新编译,输出就不会变。先编译,编译成功后再运行。

正确写法
cc -std=c11 -Wall -Wextra hello.c -o hello && ./hello

检查自己

  1. 写一个程序:第一行打印你的名字,第二行打印 Welcome to C。

    先自己做,再看答案

    一个答案,名字用 Mia:

    #include <stdio.h>

    int main(void) {
    printf("Mia\n");
    printf("Welcome to C\n");
    return 0;
    }

    每个 \n 结束一行。没有第一个 \n 的话,两段文字会挤在同一行。

  2. 预测输出:printf("A"); printf("B\n"); printf("C\n");

    先自己做,再看答案

    两行。第一行是 AB,因为第一条 printf 没有 \n,第二条接在同一行后面。第二行是 C。

  3. 编译器提示:implicit declaration of function 'printf'。程序缺了哪一行?应该放在哪里?

    先自己做,再看答案

    缺的是 #include <stdio.h> 这一行。它放在文件开头、main 之前,因为 printf 的说明就在这个头文件里。

02

printf 与格式说明符:打印数值

学完你能 学完这课,你能用 printf 打印数字、字母和文字,并为每种值选对格式说明符。

printf 用来打印文字。你给它的第一样东西是格式字符串,也就是双引号里的一段文字。其中大部分内容会按原样打印。但是百分号后面跟一个字母,是一个占位符,叫格式说明符。printf 会按顺序,把每个占位符换成格式字符串后面列出的下一个值。

例如,下面这行会打印 Leon is 20 years old:
printf("Leon is %d years old\n", 20);
其中 %d 是整数的占位符,20 是填进去的值。格式字符串后面的这些值叫实参。

下面这些说明符能满足大部分需求:
%d 整数(类型 int)
%f 带小数点的数(类型 double)。写成 %.2f 会精确显示小数点后两位。
%c 一个字符,例如 A
%s 字符串,也就是 Mia 这样的文字
%x 十六进制的整数。十六进制是 16 进制,数字用 0 到 9 和 a 到 f。数字 255 会打印成 ff。
%zu 一个大小,也就是 sizeof 产生的那种数(第 3 课)
%% 百分号本身

你可以在 % 和字母之间加一个宽度。%5d 会用空格把数字补到至少 5 个字符宽,%-5d 效果相同,但数字靠左。

说明符必须与值匹配。printf 无法检查值的类型,它只会相信你。如果你写了 %d,却传入带小数的数,输出就是无意义的结果,只有编译器的警告(第 8 课)才能发现。占位符的个数也必须等于值的个数。

引号里的特殊字符以反斜杠开头,这叫转义序列。\n 是换行,\t 是制表符,\" 是引号本身,\\ 是反斜杠本身。

双引号表示文字。单引号表示一个字符:'A' 是字母 A,而 "A" 是一段文字。第 3 课和第 6 课还会回到这一点。

新词

格式字符串
printf 的第一个实参,里面有文字和占位符。
格式说明符
以 % 开头的占位符,例如 %d 或 %.2f。
实参
交给函数的一个值,这里指格式字符串后面的那些值。
转义序列
反斜杠加一个字母,代表一个特殊字符,例如 \n 代表换行。
十六进制
用 16 进制计数。9 之后依次是 a、b、c、d、e、f,所以 255 写作 ff。

完整例子

C11
#include <stdio.h>

int main(void) {
    int apples = 3;
    double price = 2.5;
    char grade = 'A';

    printf("Leon bought %d apples.\n", apples);
    printf("Each costs %.2f, so the total is %.2f.\n", price, apples * price);
    printf("Grade: %c\n", grade);
    printf("Name: %s\n", "Mia");
    printf("Hex: %x, character code: %d\n", 255, grade);
    printf("Padded: [%5d] [%-5d]\n", 42, 42);
    printf("Ratio: 50%%\n");
    printf("A quote \" and a backslash \\\n");
    return 0;
}
先预测输出,再看结果

预期输出

Leon bought 3 apples.
Each costs 2.50, so the total is 7.50.
Grade: A
Name: Mia
Hex: ff, character code: 65
Padded: [   42] [42   ]
Ratio: 50%
A quote " and a backslash \

逐步讲解

  1. 先声明三个变量。变量用来存放一个值(第 3 课会详细讲)。这里 apples 存 3,price 存 2.5,grade 存字母 A。

  2. %d 由 apples 填上,所以这一行是 Leon bought 3 apples.

  3. %.2f 出现了两次。第一个取 price,打印 2.50。第二个取 apples * price,这个式子先被算出来(3 乘 2.5 等于 7.5),打印成 7.50。

  4. %c 打印单个字符 A。%s 打印文字 Mia,不带引号。

  5. 255 的十六进制是 ff。第二个值 grade 用 %d 打印,所以你看到的是 65:电脑用这个数字来存字母 A。

  6. %5d 把 42 放在 5 个字符宽的位置里,空格补在左边。%-5d 把空格补在右边。方括号让宽度看得见。

  7. %% 打印一个百分号。在文字里,\" 打印一个引号,\\ 打印一个反斜杠。

常见错误

错误写法
printf("%d\n", 2.5);

%d 需要整数,而 2.5 带小数点。输出是无意义的结果,而且不同机器上不一样。加上 -Wall,编译器会警告:format specifies type 'int' but the argument has type 'double' [-Wformat]。gcc 的说法是:format '%d' expects argument of type 'int', but argument 2 has type 'double'。

正确写法
printf("%f\n", 2.5);
错误写法
printf("%d and %d\n", 7);

有两个占位符,却只有一个值。第二个 %d 会打印碰巧留在那里的任何内容。编译器会警告:more '%' conversions than data arguments。

正确写法
printf("%d and %d\n", 7, 8);
错误写法
printf('Hello\n');

单引号只用于一个字符。文字必须用双引号。编译器会拒绝这一行,或者对它发出警告,程序也无法打印出这段文字。

正确写法
printf("Hello\n");
错误写法
printf("Sale: 50% off\n");

单独一个 % 会被当作占位符的开头。这里的 "% o" 被读成一个需要值的说明符,所以输出是坏的。要打印百分号,就写两个。

正确写法
printf("Sale: 50%% off\n");

检查自己

  1. 写一条 printf,打印 Iris scored 93.5% :名字用 %s 取,分数保留一位小数,末尾要真的打印出百分号。

    先自己做,再看答案

    printf("%s scored %.1f%%\n", "Iris", 93.5);

    %s 取 "Iris",%.1f 取 93.5 并打印 93.5,%% 打印百分号。

  2. 预测输出:printf("[%4d][%-4d][%c]\n", 7, 7, 66); 字母 B 的编码是 66。

    先自己做,再看答案

    [ 7][7 ][B]。第一个字段宽 4,7 靠右。第二个字段里 7 靠左。%c 把编码 66 变成字符 B。

  3. 修改下面这行,让它打印出 Total: 4.75 : printf("Total: %d\n", 4.75);

    先自己做,再看答案

    带小数点的数要用 %f:printf("Total: %.2f\n", 4.75); 其中 .2 表示小数点后保留两位。

03

变量、类型与大小

学完你能 学完这课,你能声明变量,在 int、double 和 char 中选择合适的类型,读懂简单的算术表达式,并查出一个类型占几个字节。

变量是电脑内存里一个有名字的盒子,里面放一个值。你通过声明来创建变量:先写类型,再写名字,通常还要写一个初始值。
int count = 7;

类型说明盒子里能放哪种值,以及盒子有多大。现在你需要三种类型。int 是整数,例如 -3、0 或 42。double 是带小数点的数,例如 3.14。char 是一个字符,例如 'A',用单引号写。

符号 = 的意思是赋值。它先算出右边,再把结果存进左边的盒子里。它不是数学里的“等于”。语句 count = count + 1 的意思是“取出 count 里的值,加一,再存回去”。语句 count += 2 是 count = count + 2 的简写。

变量一定要给初始值。一个从没被填过的盒子里是遗留的位,读取它会得到不可预测的结果。

表达式是一段能算出一个值的代码,例如 2 + 3 * 4。读表达式时,先找出哪一部分最先被计算。括号最先。然后是 *、/ 和 %(乘、除、取余),再然后是 + 和 -。同一级的运算符从左到右算。所以 2 + 3 * 4 是 14,而 (2 + 3) * 4 是 20。拿不准时,就加括号。

有两个陷阱。第一,int 除以 int 得到 int,小数部分会被丢掉。所以 7 / 2 是 3。% 运算符给出余数,所以 7 % 2 是 1。如果你想要 3.5,就让其中一边是 double,写成 7 / 2.0。第二,int 的范围有限,大约是负 20 亿到正 20 亿。头文件 limits.h 提供 INT_MAX,也就是最大的 int。超过它属于未定义行为,意思是语言对会发生什么完全不作保证。

内存以字节为单位计算。位是单个的 0 或 1,字节是一组位,在普通电脑上是 8 位。运算符 sizeof 给出一个类型或变量占多少字节。在普通电脑上,char 占 1,int 占 4,double 占 8。limits.h 里的常量 CHAR_BIT 就是一个字节有多少位。

char 是以一个小整数的形式保存的:'A' 是 65。所以 'A' + 1 是 66,也就是 'B' 的编码。

新词

变量
内存里一个有名字的盒子,存放一个某种类型的值。
声明
创建变量的那一行:类型、名字,通常还有初始值。
赋值
用 = 把一个值存进变量。先计算右边。
表达式
能算出一个值的一段代码,例如 2 + 3 * 4。
字节
内存计量的单位。它含有 CHAR_BIT 个位,在普通电脑上是 8 位。
未定义行为
语言对其结果不作任何保证的代码。程序可能崩溃、打印无意义的内容,或者看起来正常。

完整例子

C11
#include <stdio.h>
#include <limits.h>

int main(void) {
    int count = 7;
    double ratio = 7.0 / 2;
    char letter = 'A';

    printf("count = %d, ratio = %.1f\n", count, ratio);
    printf("7 / 2 = %d, 7 %% 2 = %d, 7 / 2.0 = %.1f\n", 7 / 2, 7 % 2, 7 / 2.0);
    printf("2 + 3 * 4 = %d, (2 + 3) * 4 = %d\n", 2 + 3 * 4, (2 + 3) * 4);

    count = count + 1;
    count += 2;
    printf("count is now %d\n", count);

    printf("letter %c has code %d, and letter + 1 is %c\n", letter, letter, letter + 1);
    printf("sizes: char %zu, int %zu, double %zu\n", sizeof(char), sizeof(int), sizeof(double));
    printf("bits in a byte: %d, largest int: %d\n", CHAR_BIT, INT_MAX);
    return 0;
}
先预测输出,再看结果

预期输出

count = 7, ratio = 3.5
7 / 2 = 3, 7 % 2 = 1, 7 / 2.0 = 3.5
2 + 3 * 4 = 14, (2 + 3) * 4 = 20
count is now 10
letter A has code 65, and letter + 1 is B
sizes: char 1, int 4, double 8
bits in a byte: 8, largest int: 2147483647

逐步讲解

  1. 声明了三个带初始值的变量。7.0 / 2 的左边是 double,所以除法保留小数,ratio 里存的是 3.5。

  2. 7 / 2 是两个 int 相除,结果是 3。7 % 2 是余数 1。7 / 2.0 的右边是 double,所以结果是 3.5。文字里的 %% 打印一个百分号。

  3. 乘法先于加法,所以 2 + 3 * 4 是 2 + 12,等于 14。(2 + 3) * 4 里的括号强制先做加法,得到 20。

  4. count = count + 1 把 7 变成 8。count += 2 把 8 变成 10。

  5. letter 被打印了两次:%c 显示字符 A,%d 显示它的编码 65。表达式 letter + 1 是数字 66,%c 把它显示成 B。

  6. sizeof(char)、sizeof(int) 和 sizeof(double) 分别给出 1、4 和 8。它们用 %zu 打印,这是用于大小的说明符。

  7. CHAR_BIT 是一个字节的位数。INT_MAX 是在这台电脑上 int 能存放的最大值。两者都来自 limits.h。

常见错误

错误写法
double half = 1 / 2;

/ 的两边都是 int,所以先按整数除法计算,得到 0,然后才把 0 存成 0.0。把结果放进 double 变量并没有用。要在除法之前,让其中一边变成 double。

正确写法
double half = 1 / 2.0;
错误写法
int total;
total = total + 5;

total 从来没有被赋过值,所以 5 加在了遗留的位上,结果不可预测。只有你要求,编译器才会警告:加上 -Wall,clang 会提示 variable 'total' is uninitialized when used here。gcc 可能还要再加上 -O1 才会显示这条警告。

正确写法
int total = 0;
total = total + 5;
错误写法
char letter = "A";

双引号表示文字,那是一排字符,而不是一个字符。编译器会抱怨把指针转换成 char。单个字符要用单引号。

正确写法
char letter = 'A';
错误写法
int big = 2147483647;
big = big + 1;

在普通电脑上,2147483647 就是 INT_MAX。再加 1 就超出了范围,属于未定义行为。你可能看到 -2147483648,也可能看到别的任何结果。要在加之前检查,或者换用更宽的类型。

正确写法
int big = 2147483647;
if (big < INT_MAX) {
    big = big + 1;
}

检查自己

  1. 预测输出:int a = 17; printf("%d %d %.1f\n", a / 5, a % 5, a / 5.0);

    先自己做,再看答案

    3 2 3.4。int 相除得 3,17 除以 5 的余数是 2,17 除以 5.0 是 3.4。

  2. 写出一段程序主体:声明 double 变量 price 初值为 4.5,int 变量 qty 初值为 3,然后打印总价,保留两位小数。

    先自己做,再看答案

    double price = 4.5;
    int qty = 3;
    printf("Total: %.2f\n", price * qty);

    它打印 Total: 13.50。做乘法时,int 类型的 qty 会被转换成 double。

  3. 预测输出:printf("%c %zu\n", 'A' + 2, sizeof(int) * 3);

    先自己做,再看答案

    C 12。'A' + 2 是数字 67,%c 把它显示成 C。sizeof(int) 是 4,4 乘 3 是 12。

04

判断与重复:if 和循环

学完你能 学完这课,你能用 if 让程序做选择,用 for 和 while 重复工作,并读懂控制它们的条件。

程序通常按从上到下的顺序执行语句。if 语句和循环语句会改变这个顺序。

条件是一个要么真要么假的表达式。C 里的条件没有单独的“真”和“假”:数字 0 表示假,任何其他数字都表示真。比较运算符的结果,真是 1,假是 0。它们是 <(小于)、<=(小于或等于)、>(大于)、>=(大于或等于)、==(等于)和 !=(不等于)。请注意:== 是比较两个值,而单独一个 = 是存入一个值。

条件可以用 &&(并且:两边都为真)、||(或者:至少一边为真)和 !(非)组合起来。&& 和 || 都会提前停止:如果左边已经能决定答案,右边就不会被检查。这叫短路求值。

if 语句只在条件为真时才运行一个代码块。代码块是花括号里的一组语句。你可以用 else 处理另一种情况,用 else if 处理更多情况。

循环会重复一个代码块。while 循环先检查条件,只要条件为真就一直重复。for 循环的括号里有三个部分:初始设置、条件和步进。
for (int i = 0; i < 3; i++)
它的意思是:让 i 从 0 开始,只要 i 小于 3 就继续,每轮结束后把 i 加 1。表达式 i++ 的意思是 i = i + 1。所以代码块在 i 等于 0、1 和 2 时各运行一次,一共三次。在 C 里从 0 开始计数是常态。代码块每运行一遍叫一次迭代。

循环里必须有某个因素最终让条件变成假。如果没有,它就永远不会结束。这时在终端里同时按 Ctrl 和 C 来停止它。break 语句立即离开循环。continue 语句跳到下一次迭代。

每个代码块都要用花括号括起来,即使里面只有一条语句。这样能避免一整类错误。

新词

条件
被当作是非题来用的表达式。0 表示否,其他数表示是。
代码块
花括号里的一组语句。
循环
在条件成立时重复执行代码块的语句。
迭代
循环体运行的一遍。
短路求值
当左边已经能决定答案时,&& 和 || 会跳过右边。

完整例子

C11
#include <stdio.h>

int main(void) {
    int total = 0;
    for (int i = 1; i <= 6; i++) {
        if (i % 2 == 0) {
            total += i;
            printf("%d is even, total is %d\n", i, total);
        } else {
            printf("%d is odd, skipped\n", i);
        }
    }

    int coins = 1;
    int days = 0;
    while (coins < 100) {
        coins = coins * 2;
        days++;
    }
    printf("coins reach %d after %d days\n", coins, days);

    int age = 17;
    if (age >= 13 && age < 20) {
        printf("age %d is a teenager\n", age);
    }
    return 0;
}
先预测输出,再看结果

预期输出

1 is odd, skipped
2 is even, total is 2
3 is odd, skipped
4 is even, total is 6
5 is odd, skipped
6 is even, total is 12
coins reach 128 after 7 days
age 17 is a teenager

逐步讲解

  1. for 循环先让 i 等于 1,只要 i <= 6 就继续,每轮结束后给 i 加 1。所以代码块运行六次。

  2. i % 2 是除以 2 的余数。余数等于 0,这个数就是偶数。条件里用 == 来比较。

  3. 遇到偶数,if 分支把 i 加进 total 并打印出来:先是 2,然后 2 + 4 = 6,再然后 6 + 6 = 12。遇到奇数,else 分支打印一条信息。

  4. while 循环在每一轮之前检查 coins < 100。每一轮把 coins 翻倍,并给 days 加 1。值依次是 2、4、8、16、32、64 和 128,一共七轮。第七轮之后条件为假,循环结束。

  5. 最后的 if 用 && 把两个比较组合起来,两个都必须为真:17 不小于 13,同时也小于 20,所以信息被打印出来。

常见错误

错误写法
int x = 3;
if (x = 5) {
    printf("five\n");
}

单个 = 是赋值。这里把 5 存进 x,条件的值是 5,算作真。所以这个代码块总会运行,而且 x 被改坏了。加上 -Wall,编译器会警告:using the result of an assignment as a condition without parentheses。要比较,就用 ==。

正确写法
if (x == 5) {
    printf("five\n");
}
错误写法
for (int i = 0; i < 3; i++);
{
    printf("hi\n");
}

括号后面的分号让循环以空循环体结束。循环数到 3,什么也没做。然后后面的代码块只运行一次。编译器会警告:for loop has empty body。

正确写法
for (int i = 0; i < 3; i++) {
    printf("hi\n");
}
错误写法
if (1 < x < 5) {
    printf("between\n");
}

C 把它从左到右读成 (1 < x) < 5。第一个比较的结果是 0 或 1,而 0 和 1 都小于 5,所以条件永远为真。有些编译器会对此警告。要写成两个比较,再用 && 连起来。

正确写法
if (1 < x && x < 5) {
    printf("between\n");
}
错误写法
int i = 0;
while (i < 3) {
    printf("%d\n", i);
}

没有任何语句改变 i,所以 i < 3 一直为真,循环会永远打印 0。在终端里按 Ctrl 和 C 来停止它。每个循环都需要一个朝着结束前进的步骤。

正确写法
int i = 0;
while (i < 3) {
    printf("%d\n", i);
    i++;
}

检查自己

  1. 预测输出:for (int i = 1; i <= 9; i += 4) printf("%d ", i);

    先自己做,再看答案

    1 5 9。i 从 1 开始,每次增加 4:1、5、9。下一个值 13 不满足条件 i <= 9。

  2. 写一个循环,把 1 到 100 的数加到名为 sum 的 int 变量里,然后打印 sum。

    先自己做,再看答案

    int sum = 0;
    for (int i = 1; i <= 100; i++) {
    sum += i;
    }
    printf("%d\n", sum);

    它打印 5050。sum 必须从 0 开始。

  3. 修改这个条件,让它判断 n 是否为偶数:if (n % 2 = 0)

    先自己做,再看答案

    比较要用 ==:if (n % 2 == 0)。单个 = 是想给 n % 2 赋值,编译器会报错拒绝,提示类似:expression is not assignable。

05

函数与按值传递

学完你能 学完这课,你能写出自己的函数并调用它,还能解释为什么函数改不了你传进去的那个变量。

函数是一组有名字的指令,你可以反复使用它。你已经用过两个了:main 和 printf。现在你来写自己的函数。

下面是一个函数定义:
int square(int n) { return n * n; }
它有四个部分。返回类型 int,是函数交回的值的类型。单词 void 表示什么也不交回。名字是 square。括号里是形参,也就是函数给收到的值起的名字,每个形参都有类型。花括号里是函数体,放着指令。return 语句交回一个值,并立即结束函数。

调用函数时,写出它的名字,并在括号里放上值:square(5)。你给出的这些值叫实参。调用是一个表达式,它的值就是函数返回的值。所以 square(5) 是 25,你可以打印它,也可以把它存进变量。

编译器从上到下读文件。在你调用一个函数之前,它必须先知道这个函数。通常的办法是写函数原型:把函数的第一行加上分号,放在文件靠前的位置。它的意思是“这个函数存在,类型是这样,函数体稍后给出”。

现在讲关键规则。调用函数时,C 会把每个实参复制到对应的形参里。这叫按值传递。函数处理的是它自己的副本。想象你把一张表格的复印件交给别人。他可以在上面乱涂,而你的原件不会变。所以,如果函数把它的形参设成 0,调用者那边的变量仍然是旧值。想把新值带出来,就用返回值,再赋给变量:side = add_one(side)。

在函数内部创建的变量,包括它的形参,都是局部变量。它们只在函数运行期间存在,别的函数看不到。一个名字能在程序的哪一部分里使用,这个范围叫作用域。两个函数可以各有一个叫 n 的变量,它们是两个不同的盒子。

第 7 课会讲怎样让函数有意地改变调用者的变量。

新词

函数
一组有名字的指令,可以从别处调用。
形参
函数定义里的一个名字,代表函数接收的一个值。
实参
调用时给出的值,它会被复制进形参。
返回值
函数用 return 交回的值。
函数原型
函数的第一行加分号。它在函数体之前先宣布这个函数。
局部变量
在函数内部创建的变量,只在函数运行期间存在。
按值传递
调用函数时把每个实参复制进形参,原来的变量不受影响。

完整例子

C11
#include <stdio.h>

int square(int n);
void try_to_reset(int n);
int add_one(int n);

int main(void) {
    int side = 5;
    printf("square(%d) = %d\n", side, square(side));

    try_to_reset(side);
    printf("back in main, side is still %d\n", side);

    side = add_one(side);
    printf("after side = add_one(side), side is %d\n", side);
    return 0;
}

int square(int n) {
    return n * n;
}

void try_to_reset(int n) {
    n = 0;
    printf("inside try_to_reset, n is %d\n", n);
}

int add_one(int n) {
    return n + 1;
}
先预测输出,再看结果

预期输出

square(5) = 25
inside try_to_reset, n is 0
back in main, side is still 5
after side = add_one(side), side is 6

逐步讲解

  1. main 前面的三行是函数原型。它们告诉编译器这些函数存在,所以即使函数体写在后面,main 也可以调用它们。

  2. square(side) 把 5 复制进形参 n。函数返回 5 * 5,也就是 25,printf 把它打印出来。

  3. try_to_reset(side) 把 5 复制进它自己的 n。函数把这个 n 设为 0 并打印出 0。被改的只是副本。

  4. 回到 main,side 里仍然是 5。按值传递保护了它。

  5. add_one(side) 返回 6。赋值 side = add_one(side) 把这个值存进 side。新的值就是这样从函数里出来的。

  6. 三个函数的函数体写在 main 的后面。try_to_reset 里的 void 表示它不返回任何东西,所以没有 return 语句。

常见错误

错误写法
int main(void) {
    int n = square(3);
    printf("%d\n", n);
    return 0;
}

int square(int v) { return v * v; }

main 调用 square 的时候,编译器还没有见过它。编译器会停下并提示:call to undeclared function 'square'(gcc 的说法是:implicit declaration of function 'square')。要在 main 上面写函数原型,或者把 square 的定义放到 main 上面。

正确写法
int square(int v);

int main(void) {
    int n = square(3);
    printf("%d\n", n);
    return 0;
}

int square(int v) { return v * v; }
错误写法
void double_it(int n) { n = n * 2; }
/* in main: */
double_it(x);

函数把它自己的副本翻了倍,main 里的变量 x 没有变。这是按值传递最常让人意外的地方。

正确写法
int double_it(int n) { return n * 2; }
/* in main: */
x = double_it(x);
错误写法
int twice(int n) {
    n * 2;
}

n * 2; 这一行算出一个值,又把它丢掉了。函数从来没有返回任何东西。编译器会警告:expression result unused,以及 non-void function does not return a value。使用这样一个调用的结果属于未定义行为。

正确写法
int twice(int n) {
    return n * 2;
}
错误写法
int helper(void) {
    int inner = 5;
    return 1;
}

int main(void) {
    helper();
    printf("%d\n", inner);
    return 0;
}

inner 是 helper 的局部变量,main 看不到它,编译器会停下并提示:use of undeclared identifier 'inner'。要把值传出来,就用返回值。

正确写法
int helper(void) {
    int inner = 5;
    return inner;
}

int main(void) {
    int got = helper();
    printf("%d\n", got);
    return 0;
}

检查自己

  1. 预测输出: int f(int n) { n = n + 1; return n * 2; } int main(void) { int x = 4; int y = f(x); printf("%d %d\n", x, y); return 0; }

    先自己做,再看答案

    4 10。函数把它的副本加 1,变成 5,并返回 5 * 2 = 10。main 里的 x 仍然是 4。

  2. 写一个函数 max_of,接收两个 int,返回较大的那个。再写出打印 3 和 9 中较大者的调用。

    先自己做,再看答案

    int max_of(int a, int b) {
    if (a > b) {
    return a;
    }
    return b;
    }

    printf("%d\n", max_of(3, 9));

    它打印 9。if 分支一旦 return,函数就立刻结束,所以只有 a 不大于 b 时才会执行第二个 return。

  3. 这个函数本来想交换调用者的两个变量,但没有成功。说说为什么。 void swap(int a, int b) { int t = a; a = b; b = t; }

    先自己做,再看答案

    形参 a 和 b 是副本。函数交换的是副本,随后副本就消失了,调用者的变量没有被动过。函数只能返回一个值,所以修复需要指针,第 7 课会教。

06

数组与 C 字符串:结尾的零

学完你能 学完这课,你能用数组保存一排值,安全地遍历它,并解释为什么 C 字符串需要多一个盒子放结尾的零。

数组是同一类型的一排盒子,在内存里并排放着。int scores[4]; 这一行创建了四个 int 盒子。方括号里的数字是长度。

你通过下标来访问其中一个盒子,下标就是它的位置编号。下标从 0 开始数。scores 的盒子是 scores[0]、scores[1]、scores[2] 和 scores[3]。最后一个有效的下标是长度减 1。没有 scores[4]。但是 C 不会阻止你写它,程序会读写那个位置上碰巧存在的任何内存。这属于未定义行为(第 3 课),会引起很多 bug 和安全漏洞。编译器和浏览器页面通常不会提醒你。第 8 课会介绍一个能提醒你的工具。

你可以用花括号设置初始值:int scores[4] = {70, 85, 90, 65};

数组不会记住自己的长度。表达式 sizeof scores 给出总字节数,四个 int 是 16。再除以 sizeof scores[0],也就是 4,就得到长度。用这个办法,或者用一个有名字的数,这样长度只写在一个地方。

文字是以 char 数组的形式保存的。C 字符串是一个 char 数组,文字后面跟着一个值为 0 的字节,写作 '\0'。它叫结尾的零,也叫空字符。零标记文字的结束。printf 和 strlen 这样的函数会一直读字符,直到遇到它。

char word[] = "Leon";
编译器让这个数组有五个盒子:L、e、o、n,以及结尾的零。所以 sizeof word 是 5。string.h 里的函数 strlen 数的是零之前的字符个数,所以 strlen(word) 是 4。字符串需要的盒子总是比看得见的长度多一个。如果缺了零,函数会一直读过数组的末尾。

你可以修改其中一个字符:word[0] = 'N'。但是你不能用 = 或 == 去复制或比较整个数组。要比较字符串,用 string.h 里的 strcmp。两个字符串相等时,它返回 0。

要记住 'A' 和 "A" 的区别。'A' 是一个 char。"A" 是有两个盒子的字符串:字母 A 和结尾的零。

新词

数组
同一类型的一排盒子,在内存里并排放着。
下标
数组里盒子的位置编号。第一个盒子的下标是 0。
字符串
在 C 里,是一个 char 数组,里面是文字,后面跟着结尾的零。
结尾的零
值为 0 的字节,写作 '\0',标记字符串的结束,也叫空字符。

完整例子

C11
#include <stdio.h>
#include <string.h>

int main(void) {
    int scores[4] = {70, 85, 90, 65};
    int length = (int)(sizeof scores / sizeof scores[0]);
    int sum = 0;
    for (int i = 0; i < length; i++) {
        sum += scores[i];
    }
    printf("length = %d, first = %d, last = %d, sum = %d\n", length, scores[0], scores[length - 1], sum);

    char word[] = "Leon";
    printf("sizeof word = %zu, strlen word = %zu\n", sizeof word, strlen(word));
    for (int i = 0; i < 5; i++) {
        printf("word[%d] = %d\n", i, word[i]);
    }

    word[0] = 'N';
    printf("%s\n", word);
    printf("equal to Neon: %d\n", strcmp(word, "Neon") == 0);
    return 0;
}
先预测输出,再看结果

预期输出

length = 4, first = 70, last = 65, sum = 310
sizeof word = 5, strlen word = 4
word[0] = 76
word[1] = 101
word[2] = 111
word[3] = 110
word[4] = 0
Neon
equal to Neon: 1

逐步讲解

  1. sizeof scores 是 16 字节,sizeof scores[0] 是 4 字节。两者相除得 4,就是长度。前面的 (int) 把这个大小变成 int,这样才能放进 int 变量。

  2. 循环用的是 i < length,而不是 i <= length。最后一个下标是 length - 1,也就是 3。总和是 70 + 85 + 90 + 65 = 310。

  3. "Leon" 有四个字母,但数组 word 有五个盒子。sizeof word 是 5,strlen(word) 是 4。差的那一个就是结尾的零。

  4. 第二个循环把五个盒子都按数字打印出来:76、101、111 和 110 是 L、e、o、n 的编码。第五个盒子 word[4] 里是 0。

  5. word[0] = 'N' 替换了第一个字符,所以 %s 现在打印 Neon。%s 会一直打印字符,直到遇到零。

  6. 文字相等时 strcmp(word, "Neon") 返回 0。比较 == 0 把它变成表示真的 1,再由 %d 打印出来。

常见错误

错误写法
int a[3] = {1, 2, 3};
int sum = 0;
for (int i = 0; i <= 3; i++) {
    sum += a[i];
}

循环访问到了 a[3],而它并不存在。程序从相邻的盒子里读到一个无关的值,然后继续运行。程序能跑,还打印出看起来正常的和,没有任何报错。应该用 i < 3。

正确写法
for (int i = 0; i < 3; i++) {
    sum += a[i];
}
错误写法
char s[4] = "Leon";

"Leon" 加上结尾的零需要五个盒子,而这个数组只有四个,零被省掉了。之后 printf("%s", s) 或 strlen(s) 会继续读到后面碰巧存在的内存。

正确写法
char s[5] = "Leon";
/* or let the compiler count: */
char t[] = "Leon";
错误写法
if (name == "Leon") {
    printf("hello\n");
}

== 比较的是两段文字存放在哪里,而不是它们写的是什么。编译器会警告:result of comparison against a string literal is unspecified。要用 strcmp,并包含 string.h。

正确写法
if (strcmp(name, "Leon") == 0) {
    printf("hello\n");
}
错误写法
char name[] = "Leon";
name = "Mia";

数组不能整体赋值。编译器会停下并提示:array type 'char[5]' is not assignable。可以用 string.h 里的 strcpy 复制字符,但前提是新文字加上结尾的零放得下。

正确写法
strcpy(name, "Mia");  /* "Mia" and its zero need 4 boxes; name has 5 */

检查自己

  1. 预测输出:char t[] = "Hi"; printf("%zu %zu\n", sizeof t, strlen(t));

    先自己做,再看答案

    3 2。数组里有 H、i 和结尾的零,所以 sizeof 是 3。strlen 只数那两个字母。

  2. 写一个循环,找出 int v[4] = {4, 9, 2, 7}; 里最大的值并打印出来。

    先自己做,再看答案

    int v[4] = {4, 9, 2, 7};
    int largest = v[0];
    for (int i = 1; i < 4; i++) {
    if (v[i] > largest) {
    largest = v[i];
    }
    }
    printf("%d\n", largest);

    它打印 9。从 v[0] 开始,就不需要编一个起始值。

  3. 写出一个能存放字符串 Mia 的 char 数组的声明,并说出它需要几个盒子。

    先自己做,再看答案

    char name[] = "Mia"; 它需要 4 个盒子:M、i、a 和结尾的零。写成 char name[4] = "Mia"; 也可以,但 char name[3] 会把零省掉。

07

内存是编了号的盒子:地址与指针

学完你能 学完这课,你能把内存画成编了号的盒子,说清楚地址和指针是什么,并用 & 和 * 通过指针读取和修改变量。

先用文字说。把电脑的内存想象成一条很长的街,街上排着许多小盒子。每个盒子放一个字节。每个盒子的门上都漆着一个号码,从街头开始数。这个号码就是盒子的地址。

一个变量住在一个盒子里,或者住在连续的几个盒子里。一个 int 通常需要并排的四个盒子,所以一个 int 占四个地址,它的地址是第一个盒子的号码。三个 int 的数组是连续的十二个盒子。

现在讲新概念。地址只是一个数,所以它也可以存进变量。存放地址的变量叫指针。指针就像一张写着门牌号的卡片。卡片不是房子。但是你可以按号码走到那所房子,往里看,或者改变里面的东西。顺着卡片找过去,这个动作叫解引用。

画一张小图,号码是编的。变量 age 在地址 100,里面放着 20。变量 p 在地址 200,里面放着数字 100。我们说 p 指向 age。下面的例子在注释里画了同样的图。

再用 C 的符号。有两个符号在起作用。
表达式 &x 的意思是“x 的地址”。
表达式 *p 的意思是“p 所指向的那个东西”。

这一行 int *p = &age; 把 p 声明为指向 int 的指针。在声明里,要把星号读作“指向……的指针”。同时它让 p 得到 age 的地址。在表达式里,星号的意思是“顺着指针找过去”。有了这一行之后,*p 和 age 是同一个盒子。读 *p 得到 20。写 *p = 21 就改变了 age。

这解决了第 5 课的问题。函数拿到的是副本,但如果副本是一个地址,函数就可以顺着它找到调用者的变量。调用 add_one(&age) 传入的是 age 的地址。在函数里,*p = *p + 1 改变了调用者的 age。scanf 函数也是这样工作的。在 scanf("%d", &n) 里,& 把 n 的地址交给 scanf,scanf 才能把读到的数存到那里。

指针和数组是近亲。数组的名字代表它第一个盒子的地址。如果 q 指向 nums[0],那么 q + 1 就指向 nums[1]。给指针加 1,是向前移动一个完整的 int,也就是 4 个字节,而不是一个字节。表达式 *(q + 1) 和 q[1] 的意思相同。

不指向任何东西的指针里放的是 NULL。永远不要顺着 NULL 指针找过去,也不要用从没设置过的指针。程序要么因为段错误而崩溃,要么悄悄破坏数据。

如果你加上 printf("%p\n", (void *)p);,会看到一个真实的地址,例如 0x16d8a3a5c。你的结果会不同,而且每次运行都可能变化。

新词

地址
内存里一个盒子的号码。变量的地址是它第一个盒子的号码。
指针
存放地址的变量。
&x
“x 的地址”。
*p
“p 所指向的东西”。顺着指针找过去叫解引用。
空指针 NULL
不指向任何东西的指针的值。绝不能解引用它。

完整例子

C11
#include <stdio.h>

/* Memory drawn as numbered boxes (the numbers are made up):

     address   contents      name
     -------   -----------   ----
       100     20            age
       200     100           p      <- p holds the address of age
*/

void add_one(int *p) {
    *p = *p + 1;
}

int main(void) {
    int age = 20;
    int *p = &age;

    printf("age = %d\n", age);
    printf("*p = %d\n", *p);
    printf("p points at age: %d\n", p == &age);

    *p = 21;
    printf("after *p = 21, age = %d\n", age);

    add_one(&age);
    printf("after add_one(&age), age = %d\n", age);

    int nums[3] = {10, 20, 30};
    int *q = nums;
    printf("*q = %d, *(q + 1) = %d, q[2] = %d\n", *q, *(q + 1), q[2]);
    printf("bytes between nums[0] and nums[1]: %d\n", (int)((char *)&nums[1] - (char *)&nums[0]));

    int *nothing = NULL;
    if (nothing == NULL) {
        printf("nothing points at no object\n");
    }
    return 0;
}
先预测输出,再看结果

预期输出

age = 20
*p = 20
p points at age: 1
after *p = 21, age = 21
after add_one(&age), age = 22
*q = 10, *(q + 1) = 20, q[2] = 30
bytes between nums[0] and nums[1]: 4
nothing points at no object

逐步讲解

  1. 注释就是那张图。age 位于编出来的地址 100,里面放着 20。p 位于地址 200,里面放着 100,也就是 age 的地址。

  2. int *p = &age; 让 p 成为指向 int 的指针,并把 age 的地址存进去。打印 *p 会顺着指针找过去,得到 20,和 age 一样。

  3. p == &age 比较两个地址。它们相同,所以结果是表示真的 1。

  4. *p = 21 通过指针写入。虽然没用到 age 这个名字,但叫 age 的盒子里现在是 21。

  5. add_one(&age) 把地址交给函数。函数给它所指的盒子加 1,所以 age 变成 22。这正是第 5 课做不到的事。

  6. q = nums 让 q 指向 nums[0]。*q 是 10,*(q + 1) 是 20,q[2] 是 30。nums[0] 和 nums[1] 这两个盒子相隔 4 字节,因为一个 int 占 4 字节。(char *) 转换让减法按字节计数。

  7. nothing 里放的是 NULL。程序把它和 NULL 比较,但从不顺着它找过去。

常见错误

错误写法
int *p;
*p = 5;

p 从来没有被设置过,里面是遗留的位,指向的是一个说不清的地方。通过它写入,要么因为段错误崩溃,要么悄悄破坏别的变量。加上 -Wall,clang 会警告:variable 'p' is uninitialized when used here。

正确写法
int x = 0;
int *p = &x;
*p = 5;
错误写法
int x = 4;
int *p = &x;
p = 7;

没有星号,这一行是把数字 7 存进指针本身,好像 7 是一个地址。编译器会抱怨:incompatible integer to pointer conversion assigning to 'int *' from 'int'。要改变 p 所指的东西,就在前面加上星号。

正确写法
*p = 7;
错误写法
int *make(void) {
    int local = 5;
    return &local;
}

local 是 make 里面的一个盒子。make 结束时,这个盒子就被收回了,这个地址指向的地方不再属于你。编译器会警告:address of stack memory associated with local variable 'local' returned。应该改成返回值本身。

正确写法
int make(void) {
    return 5;
}
错误写法
int *p = NULL;
*p = 5;

NULL 不指向任何对象。通过它写入会让程序以 Segmentation fault 终止。顺着指针找过去之前,先检查它。

正确写法
if (p != NULL) {
    *p = 5;
}

检查自己

  1. 预测输出:int x = 5; int *p = &x; *p = *p * 2; printf("%d\n", x);

    先自己做,再看答案

    10。*p 和 x 是同一个盒子。这条语句把盒子里的值翻倍,所以 x 里是 10。

  2. 写一个函数 swap,真正交换调用者的两个 int,再写出交换变量 x 和 y 的调用。

    先自己做,再看答案

    void swap(int *a, int *b) {
    int t = *a;
    *a = *b;
    *b = t;
    }

    swap(&x, &y);

    函数收到的是两个地址,直接操作它们指向的盒子,所以调用者的 x 和 y 都改变了。

  3. 预测输出:int nums[3] = {10, 20, 30}; int *q = nums; q = q + 2; printf("%d\n", *q);

    先自己做,再看答案

    30。加 2 让 q 向前移动两个 int,从 nums[0] 移到 nums[2]。

08

读懂编译器提示,并使用 -Wall

学完你能 学完这课,你能读懂警告或错误的各个部分,先改第一个错误,并打开编译器的检查,让错误在程序运行之前就暴露出来。

编译器通过提示信息和你对话。如果你读得懂,它们就是你最快的老师。一条提示信息长这样:
hello.c:5:20: warning: format specifies type 'int' but the argument has type 'double' [-Wformat]

从左到右读。hello.c 是文件。5 是行号。20 是列号,也就是这一行里字符的位置。单词 warning 是提示的种类。那句话说的是编译器认为哪里不对。方括号里的名字 -Wformat 是触发提示的那项检查,你可以拿它去搜索。提示下面,编译器通常会显示你写的那一行,并在出问题的地方下面标一个 ^。以 note: 开头的一行,是对上一条提示的补充说明。

提示有三种。错误表示编译器没能生成程序,所以不会产生可执行文件。警告表示程序能生成,但有些地方看起来不对。在你弄明白之前,把每条警告都当作 bug。提示(note)是补充说明。

本篇的提示信息来自 clang,也就是 Mac 上的编译器。Linux 和 WSL 上的 gcc 措辞稍有不同。对上面的例子,它的说法是:format '%d' expects argument of type 'int', but argument 2 has type 'double'。文件、行号、种类、说明和检查名称都有。

默认情况下,编译器对很多有风险的写法是不吭声的。你可以用选项要求更多检查,选项写在 cc 后面。
-Wall 打开常用的警告。尽管名字叫 all,它并没有打开全部警告。
-Wextra 打开更多警告。
-Werror 把每条警告都当作错误,这样你就没法忽略任何一条。
-std=c11 选择 C11 版本的语言。
-g 保留行号信息,工具才能显示你代码的行号。
-fsanitize=address,undefined 生成一个在运行时自我检查的程序。它在遇到第一个错误的内存访问或未定义操作时停下,并指出所在的行。

一条日常好用的命令是:
cc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined main.c -o main

有几个习惯会有帮助。先改第一条提示:一个错误可能引出后面一连串提示,所以每改一处就重新编译一次。记住行号是编译器发现问题的地方,原因可能在上一行。凡是你看不懂的警告,都不要忽略。

sanitizer 很重要,因为本页的“运行”按钮做不到这件事。如果程序读到了数组末尾之外,它在本页照样运行,还会打印出看起来正常的结果,没有任何检查。用 sanitizer 编译,同样的程序会停下,并给出以 ERROR: AddressSanitizer 或 runtime error 开头的报告,指出所在的行。这就是真正的编译器值得你花十分钟安装的原因。

现在你已经能读懂课程前几章里的 C 了:类型与大小、条件与循环、接收副本的函数、带结尾零的数组与字符串,以及指针。课程会把每一项讲得更深,还会加入结构体、文件、用 malloc 分配内存等更多内容。

新词

错误
表示编译器无法生成程序的提示。
警告
表示程序能生成,但有些地方看起来不对的提示。
补充说明
以 note: 开头的一行,是对上一条提示的补充。
编译选项
写在 cc 后面、改变编译器行为的附加文字,例如 -Wall。
sanitizer(运行时检查工具)
内置在程序里的检查。它在遇到第一个错误的内存访问或未定义操作时让程序停下,并指出所在的行。

完整例子

C11
#include <stdio.h>

int main(void) {
    int scores[3] = {80, 90, 85};
    int sum = 0;
    for (int i = 0; i < 3; i++) {
        sum += scores[i];
    }
    double average = (double)sum / 3;
    printf("sum = %d, average = %.1f\n", sum, average);
    return 0;
}
先预测输出,再看结果

预期输出

sum = 255, average = 85.0

逐步讲解

  1. 用常用的检查来编译:cc -std=c11 -Wall -Wextra average.c -o average。如果编译器什么也没打印,说明它没有发现可抱怨的地方。然后运行 ./average。

  2. (double)sum / 3 里有一个强制类型转换。(double) 在这一次计算中把 sum 变成 double,所以除法会保留小数。没有它,255 / 3 就是整数除法。

  3. 现在故意把它弄坏,每次改一处,再重新编译。第一处:删掉 sum += scores[i] 后面的分号。clang 会提示:error: expected ';' after expression。gcc 的说法是:expected ';' before '}' token。把分号改回来。

  4. 第二处:把 printf 里的 %.1f 改成 %d。clang 会提示:warning: format specifies type 'int' but the argument has type 'double' [-Wformat]。再改回来。

  5. 第三处:删掉整行 int sum = 0;。clang 会提示:error: use of undeclared identifier 'sum'。gcc 的说法是:'sum' undeclared (first use in this function)。每处用到 sum 的地方都会报错,而它们都源于缺了那一行。把这一行加回去。

常见错误

错误写法
#include <stdio.h>
int main(void) {
    itn count = 0;
    count = count + 1;
    printf("%d\n", count);
    return 0;
}

$ cc demo.c -o demo
demo.c:3:5: error: use of undeclared identifier 'itn'
demo.c:4:5: error: use of undeclared identifier 'count'
demo.c:4:13: error: use of undeclared identifier 'count'
demo.c:5:20: error: use of undeclared identifier 'count'
4 errors generated.

一个拼写错误(把 int 写成 itn)引出了四个错误。编译器不知道 count 是什么,因为声明它的那一行失败了。改好第一个错误再编译,另外三个就消失了。不要从最后一个开始往上改。(gcc 对这些错误的措辞不同,可能打印得更少。)

正确写法
int count = 0;
错误写法
#include <stdio.h>
int main(void) {
    int total = 3
    printf("%d\n", total);
    return 0;
}

第 3 行末尾缺了分号。有些编译器(包括 gcc)可能指向第 4 行,因为它们是在那里才发现语句没有结束。这时真正的原因在被指出的那一行的上一行。当被指出的那一行看起来没问题时,就检查它前面的一行。

正确写法
int total = 3;
错误写法
#include <stdio.h>
int main(void) {
    int total;
    total = total + 5;
    printf("%d\n", total);
    return 0;
}

$ cc demo.c -o demo
(no output: it builds)

不加任何选项时,编译器对没有设值的变量 total 一声不吭,程序照样生成,给出不可预测的结果。每次都要要求编译器做检查,并且读每一条警告。

正确写法
$ cc -std=c11 -Wall -Wextra demo.c -o demo
demo.c:4:13: warning: variable 'total' is uninitialized when used here [-Wuninitialized]
错误写法
#include <stdio.h>
int main(void) {
    int a[3] = {1, 2, 3};
    int sum = 0;
    for (int i = 0; i <= 3; i++) {
        sum += a[i];
    }
    printf("%d\n", sum);
    return 0;
}

(Run button on this page, or a plain cc build: prints a normal-looking sum)

循环读取了 a[3],它在数组之外。浏览器里没有任何东西检查这件事,普通的编译运行也没有。用 -g -fsanitize=address,undefined 编译后,程序会停下,并报告 runtime error: index 3 out of bounds,还有 ERROR: AddressSanitizer: stack-buffer-overflow,同时指出所在的行。本页的“运行”按钮做不到这一点。这就是你该安装真正的编译器的原因。

正确写法
$ cc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined demo.c -o demo
$ ./demo
demo.c:6:16: runtime error: index 3 out of bounds for type 'int[3]'

检查自己

  1. 读这条提示,说出文件、行号、种类和检查名称: demo.c:12:9: warning: unused variable 'count' [-Wunused-variable]

    先自己做,再看答案

    文件是 demo.c,行号是 12(第 9 列),种类是警告,检查名称是 -Wunused-variable。意思是声明了一个叫 count 的变量,却从没用过它。把它删掉,或者用起来。

  2. 编译器在第 8 行报告 error: expected ';' after expression,但第 8 行看起来是对的。你接下来看哪里?为什么?

    先自己做,再看答案

    看第 7 行,也就是前一行。编译器报告的是它发现语句没有结束的位置,而缺失的分号通常在上一行的末尾。

  3. 写出一条命令:用 C11、常用警告和 sanitizer,把 main.c 编译成名为 main 的程序。

    先自己做,再看答案

    cc -std=c11 -Wall -Wextra -g -fsanitize=address,undefined main.c -o main
    -o 后面是要创建的程序名。千万不要把 main.c 写在那里。

接下来

继续学习 COMP2017 课程笔记,从第 1 章开始 →

独立整理的学习笔记,不是官方课程材料。