开始学习不需要安装任何东西。下面每个例子都有“运行”按钮,会在浏览器里编译并运行这段 C 程序。前几课可以直接用它。
不过,你最终还是需要在自己的电脑上装一个真正的编译器。编译器是把你写的 C 文字变成电脑能运行的程序的工具。原因有两个。第一,你接下来要学的课程是在终端里编译程序的,所以要养成这个习惯。第二,浏览器里的 C 发现不了内存错误。一个读写了自己无权访问的内存的程序,在本页照样能运行,还可能打印出看起来正常的结果,其实它是错的。真正的编译器配上 sanitizer(运行时检查工具,第 8 课会讲)会让这种程序当场停下,并告诉你出错的那一行。安装大约需要十分钟。
完整的提示在 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 窗口。
双引号表示文字。单引号表示一个字符:'A' 是字母 A,而 "A" 是一段文字。第 3 课和第 6 课还会回到这一点。
新词
格式字符串
printf 的第一个实参,里面有文字和占位符。
格式说明符
以 % 开头的占位符,例如 %d 或 %.2f。
实参
交给函数的一个值,这里指格式字符串后面的那些值。
转义序列
反斜杠加一个字母,代表一个特殊字符,例如 \n 代表换行。
十六进制
用 16 进制计数。9 之后依次是 a、b、c、d、e、f,所以 255 写作 ff。
完整例子
C11
#include<stdio.h>intmain(void){intapples=3;doubleprice=2.5;chargrade='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");return0;}
先预测输出,再看结果
预期输出
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 \
逐步讲解
先声明三个变量。变量用来存放一个值(第 3 课会详细讲)。这里 apples 存 3,price 存 2.5,grade 存字母 A。
%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。
#include<stdio.h>#include<limits.h>intmain(void){intcount=7;doubleratio=7.0/2;charletter='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);return0;}
先预测输出,再看结果
预期输出
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
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>intmain(void){inttotal=0;for(inti=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);}}intcoins=1;intdays=0;while(coins<100){coins=coins*2;days++;}printf("coins reach %d after %d days\n",coins,days);intage=17;if(age>=13&&age<20){printf("age %d is a teenager\n",age);}return0;}
先预测输出,再看结果
预期输出
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
逐步讲解
for 循环先让 i 等于 1,只要 i <= 6 就继续,每轮结束后给 i 加 1。所以代码块运行六次。
i % 2 是除以 2 的余数。余数等于 0,这个数就是偶数。条件里用 == 来比较。
遇到偶数,if 分支把 i 加进 total 并打印出来:先是 2,然后 2 + 4 = 6,再然后 6 + 6 = 12。遇到奇数,else 分支打印一条信息。
while 循环在每一轮之前检查 coins < 100。每一轮把 coins 翻倍,并给 days 加 1。值依次是 2、4、8、16、32、64 和 128,一共七轮。第七轮之后条件为假,循环结束。
最后的 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。
在函数内部创建的变量,包括它的形参,都是局部变量。它们只在函数运行期间存在,别的函数看不到。一个名字能在程序的哪一部分里使用,这个范围叫作用域。两个函数可以各有一个叫 n 的变量,它们是两个不同的盒子。
第 7 课会讲怎样让函数有意地改变调用者的变量。
新词
函数
一组有名字的指令,可以从别处调用。
形参
函数定义里的一个名字,代表函数接收的一个值。
实参
调用时给出的值,它会被复制进形参。
返回值
函数用 return 交回的值。
函数原型
函数的第一行加分号。它在函数体之前先宣布这个函数。
局部变量
在函数内部创建的变量,只在函数运行期间存在。
按值传递
调用函数时把每个实参复制进形参,原来的变量不受影响。
完整例子
C11
#include<stdio.h>intsquare(intn);voidtry_to_reset(intn);intadd_one(intn);intmain(void){intside=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);return0;}intsquare(intn){returnn*n;}voidtry_to_reset(intn){n=0;printf("inside try_to_reset, n is %d\n",n);}intadd_one(intn){returnn+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
逐步讲解
main 前面的三行是函数原型。它们告诉编译器这些函数存在,所以即使函数体写在后面,main 也可以调用它们。
try_to_reset(side) 把 5 复制进它自己的 n。函数把这个 n 设为 0 并打印出 0。被改的只是副本。
回到 main,side 里仍然是 5。按值传递保护了它。
add_one(side) 返回 6。赋值 side = add_one(side) 把这个值存进 side。新的值就是这样从函数里出来的。
三个函数的函数体写在 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;
}
检查自己
预测输出:
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; }
要记住 'A' 和 "A" 的区别。'A' 是一个 char。"A" 是有两个盒子的字符串:字母 A 和结尾的零。
新词
数组
同一类型的一排盒子,在内存里并排放着。
下标
数组里盒子的位置编号。第一个盒子的下标是 0。
字符串
在 C 里,是一个 char 数组,里面是文字,后面跟着结尾的零。
结尾的零
值为 0 的字节,写作 '\0',标记字符串的结束,也叫空字符。
完整例子
C11
#include<stdio.h>#include<string.h>intmain(void){intscores[4]={70,85,90,65};intlength=(int)(sizeofscores/sizeofscores[0]);intsum=0;for(inti=0;i<length;i++){sum+=scores[i];}printf("length = %d, first = %d, last = %d, sum = %d\n",length,scores[0],scores[length-1],sum);charword[]="Leon";printf("sizeof word = %zu, strlen word = %zu\n",sizeofword,strlen(word));for(inti=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);return0;}
先预测输出,再看结果
预期输出
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
这一行 int *p = &age; 把 p 声明为指向 int 的指针。在声明里,要把星号读作“指向……的指针”。同时它让 p 得到 age 的地址。在表达式里,星号的意思是“顺着指针找过去”。有了这一行之后,*p 和 age 是同一个盒子。读 *p 得到 20。写 *p = 21 就改变了 age。
#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*/voidadd_one(int*p){*p=*p+1;}intmain(void){intage=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);intnums[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");}return0;}
先预测输出,再看结果
预期输出
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
逐步讲解
注释就是那张图。age 位于编出来的地址 100,里面放着 20。p 位于地址 200,里面放着 100,也就是 age 的地址。
int *p = &age; 让 p 成为指向 int 的指针,并把 age 的地址存进去。打印 *p 会顺着指针找过去,得到 20,和 age 一样。
p == &age 比较两个地址。它们相同,所以结果是表示真的 1。
*p = 21 通过指针写入。虽然没用到 age 这个名字,但叫 age 的盒子里现在是 21。
add_one(&age) 把地址交给函数。函数给它所指的盒子加 1,所以 age 变成 22。这正是第 5 课做不到的事。
本篇的提示信息来自 clang,也就是 Mac 上的编译器。Linux 和 WSL 上的 gcc 措辞稍有不同。对上面的例子,它的说法是:format '%d' expects argument of type 'int', but argument 2 has type 'double'。文件、行号、种类、说明和检查名称都有。
现在故意把它弄坏,每次改一处,再重新编译。第一处:删掉 sum += scores[i] 后面的分号。clang 会提示:error: expected ';' after expression。gcc 的说法是:expected ';' before '}' token。把分号改回来。
第二处:把 printf 里的 %.1f 改成 %d。clang 会提示:warning: format specifies type 'int' but the argument has type 'double' [-Wformat]。再改回来。
第三处:删掉整行 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;
}
#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]'