很多C语言教材在第二章附近都会放这样一道题:从键盘输入长方体的长、宽、高,计算体积并输出。我当年学C语言时,觉得例1.2简单得有点无聊,照着书敲一遍就翻页了。后来带新人、看别人写的代码,才发现这道"计算长方体的体积"的题,其实把C语言入门的四个关键概念全串起来了:头文件、变量类型、地址传递、格式控制。更扎心的是,有些写了两三年程序的人,在这道题上依然会犯scanf忘加&、%f和%lf混用的低级错误。
这篇博文就围绕这个经典例题展开,把每一行代码拆开揉碎。适合刚接触C语言的初学者,也适合想帮别人答疑的进阶者,甚至可以当作一份C语言基础复习清单。文中的所有代码我都用gcc实测过,你直接照着敲就能跑,下面开始。
1. 几乎所有教材都拿它开场:这个例子的真实教学分量
1.1 一份能直接运行的参考代码
先给出一份完整的实现,长这样:
#include <stdio.h> int main() { double length, width, height; double volume; printf("请输入长方体的长、宽、高,用空格隔开:\n"); scanf("%lf %lf %lf", &length, &width, &height); volume = length * width * height; printf("长方体的体积是:%.2f\n", volume); return 0; }如果你的环境已经装好了gcc,保存成volume.c,然后执行:
gcc -Wall -Wextra volume.c -o volume ./volume输入3 4 5,屏幕上会输出长方体的体积是:60.00。到这一步,程序就跑通了。
1.2 这段代码到底由哪几部分构成
很多人会照着敲,但敲完不知道自己敲了什么。其实这段代码只有四类零件:
#include <stdio.h>:把标准输入输出库引进来,printf和scanf的原型在这里声明。如果不写这一行,编译器根本不认识printf和scanf,会报"隐式声明"警告,甚至直接编译失败。int main():程序入口。C语言程序启动后,系统会去找main函数,从它的第一行开始执行。- 一对花括号里的语句:这是程序真正干活的区域。
return 0;:把"程序正常结束"这个状态交还给操作系统。
很多初学者觉得头文件和main是"死模板",背下来就行。我建议换个角度理解:程序像一个工厂生产线,头文件是"原料进货单",main是"车间入口",花括号里是每道工序,return 0是"质检合格出厂"。后面学函数、学模块化编程,本质还是在往这个框架里加工序。
1.3 顺序执行模型:程序不是魔法,是指令序列
这个例子最容易被忽略的教学价值,是它展示了C语言最基本的执行模型:从上到下、逐条执行。scanf那一行先执行,程序停在这里等用户输入;用户敲完回车后,数据被读入变量;紧接着执行乘法;最后把结果打印出来。
这个"输入→处理→输出"的三段式,是后面几乎所有程序的骨架。比如文件读写程序,本质是"从文件输入→处理→输出到屏幕或文件";网络程序,本质是"从socket输入→处理→输出到socket"。例1.2虽然只有三行核心逻辑,但它是一个可以用来类比所有IO程序的缩略模型。
2. 变量声明那一行,已经决定程序一半的命运:为什么不用int
2.1 数据类型的本质:给内存定房间规格
C语言里,声明变量就是在内存里"订房间"。int相当于订了一间4平米的小房间,只能存整数;double相当于订了一间8平米的大房间,能存小数,还能存特别大或特别小的数。
为什么会有这种差别?因为计算机内存里全是二进制位,类型决定了编译器怎么解释这一串01,以及给它分配多少空间。同样一段内存,用int解释可能是一个数,用double解释就是完全不同的数。这就是为什么类型选错,程序不会报错,但结果会莫名其妙地错。
2.2 用int算体积会翻什么车
大多数教材在讲这个例题前,刚讲过int。所以很多学生会写:
int length, width, height; int volume;如果长宽高都恰好是整数,比如3、4、5,结果60是对的。但只要任何一个尺寸带小数,比如2.5 3 4,问题就来了:
scanf用%d读取时,遇到小数点后面的内容直接读不进去。- 就算你用
scanf("%lf %lf %lf", &length, &width, &height)读进来了,赋值给int变量时,小数部分会被截断,2.5变成2,最后体积算出来是24而不是30。
更隐蔽的问题是溢出。int能表示的最大整数约21亿。假设长宽高是1万、1万、1万,体积是1万亿,远超int范围,结果是负数或者一个完全没意义的值。我见过有人用int算面积、算价格,调了半天发现是溢出,这种坑完全可以在声明变量时用double避开。
2.3 float和double怎么选
float也是浮点型,占4字节,double占8字节。光看书上那句"float精确到6~7位,double精确到15~17位",新手一般没感觉。我用一个实际对比说明:
| 类型 | 字节数 | 典型范围(约) | 有效十进制位数 | 适合场景 |
|---|---|---|---|---|
| int | 4 | -21亿到21亿 | 10位以内整数 | 计数器、编号、循环 |
| float | 4 | ±3.4E38 | 6~7位 | 图像、显示、对精度不敏感的场景 |
| double | 8 | ±1.7E308 | 15~17位 | 科学计算、面积体积、金额相关 |
对于长方体体积,尺寸可能是1.25 × 3.7 × 8.9,float的精度也够用。但我的建议是:入门阶段一律用double。原因有两个:一是double的精度余量更大,不容易积累误差;二是printf和scanf在处理double时格式清晰,不容易和float的隐式提升搅在一起。等你以后真正遇到性能瓶颈,再考虑用float不迟。
2.4 隐式类型转换的坑
就算声明了double,还有一个隐形问题:如果计算式中混入int,C语言会做隐式类型转换。比如:
double a = 1; double result = a / 3 * 3;a是double,3会被转换成double,这种没问题。但如果是:
int a = 1; double result = (a / 3) * 3;结果是0。因为a / 3在括号里是先按int除法执行的,1 / 3的整数结果是0,然后再转成double乘3,最终还是0。这就是经典的"整数除法截断"坑。在写体积计算时,如果某个中间量是int,务必留个心眼。
3. scanf里的&:让无数初学者崩溃的内存投递协议
3.1 scanf到底在做什么
scanf做的事,本质上是从键盘输入(更准确说是从标准输入缓冲区)里,按照格式控制符去解析数据,再把解析结果写到变量对应的内存地址里。
比如scanf("%lf %lf %lf", &length, &width, &height)的意思是:从输入流里读取三个double,分别放在变量length、width、height的内存位置上。
这里有个好玩的地方:printf不需要&,scanf需要&。原因是printf只需要"读"变量的值,而scanf需要"写"进变量。C语言函数传参默认是值传递,函数内部拿到的只是变量的副本,改副本不影响外部变量。想要在函数内部真正修改外部变量,必须把变量的地址传进去,这就是&的价值。
如果一个快递员要送包裹,你需要给他门牌号。如果你只告诉他"有一个叫length的收件人",他找不到地方,只能把包裹乱扔,甚至扔到邻居家。这就是scanf忘加&的后果。
3.2 忘加&会看到什么
忘加&的典型代码:
scanf("%lf %lf %lf", length, width, height);麻烦的是,很多编译器只会给你一个警告,不会直接报错。程序编译能通过,运行时行为完全不可预测:可能直接崩溃,提示段错误(Segmentation fault);可能系统恰好把某个随机内存地址当成了目标地址,写坏别的东西;最磨人的是,它平时好像能用,偶尔才崩一次。这种"随机崩溃"比"稳定崩溃"更难排查。
所以我的建议是:从一开始就把scanf的&当成呼吸一样自然,每次写完scanf立刻检查三个东西——有没有&、格式控制符和变量类型是否匹配、变量的地址是否合法。
3.3 %f和%lf:scanf和printf的规则居然不一样
这是初学者最容易记混的规则,我直接放一张对比表:
| 场景 | int | float | double |
|---|---|---|---|
| scanf | %d | %f | %lf |
| printf | %d | %f或%lf | %f或%lf |
也就是说,在scanf里,float必须用%f,double必须用%lf,混用会导致数据在内存中的读取长度和解释方式错乱。在printf里,由于C语言有"可变参数提升"机制,float传入时会自动转成double,所以你用%f输出float和double都没问题;C99标准之后,printf使用%lf输出double也合法。最稳妥的实践是:scanf严格区分,printf统一用%f(输出float和double都安全)。
如果scanf里对double用了%f,程序基本不会崩溃,但读进来的数据会是错乱值。比如输入3.14,你打印出来可能是-2.6E-99这类天文数字。这种错值很难猜,而且和代码逻辑没关系,白白浪费时间。
3.4 scanf的返回值:它其实会告诉你读没读对
scanf有一个返回值,表示成功读取的项数。比如:
int ret = scanf("%lf %lf %lf", &length, &width, &height); if (ret != 3) { printf("输入有问题,只成功读了 %d 项\n", ret); }如果用户输入的是3 4 hello,scanf读到第三个时失败,ret的值是2。这个信息非常有用,它是我们做输入校验的基础。不过很多初学者根本不知道scanf有返回值,也更不知道应该检查它。这里先记住有这个机制,下一节我会用它来写一个防手滑的输入循环。
4. printf的输出是程序的脸面:格式控制、精度和转义字符
4.1 格式控制符速查与常见错误
printf的第一个参数是格式字符串,里面的%开头的是占位符。占位符和后面的参数必须一一对应、类型匹配。最容易翻车的场景是:用%d去输出一个double。
例如:
double volume = 60.0; printf("体积是:%d\n", volume);这样不会把60打印出来,因为printf按int的规则去解释double在内存中的二进制位,你会看到一个毫无规律的大整数。C语言是"强类型"语言,但printf这种可变参数函数因为缺少编译期检查(现代编译器会做格式字符串检查,但必须开-Wall才会给警告),类型不匹配往往只产生运行时错误,初学者很难察觉。
4.2 保留小数和字段宽度:让输出更专业
默认情况下,%f会输出6位小数,比如60.000000。对于体积这个场景,两位小数更常见,写成%.2f即可:
printf("长方体的体积是:%.2f\n", volume);如果需要对齐多行数据,还可以指定最小宽度,比如%10.2f表示输出宽度至少10个字符,不足部分补空格,超过正常输出。宽度参数在输出表格、报表时非常有用,我后面做多组数据验证时会用:
printf("%8.2f %8.2f %8.2f %12.2f\n", length, width, height, volume);这里有个小细节:%.2f会做舍入,但要注意浮点数不是十进制精确存储的。比如0.1 + 0.2在double里其实是一个接近0.3但略有误差的数,printf显示时会按舍入规则输出0.30,大多数情况下没问题。但如果你要做银行、计费这类对精度极其敏感的计算,不要依赖double,应该换算成整数(分、厘)来做。
4.3 中文输出和换行:编码问题让新手抓狂
题目本身没有中文,但很多初学者会忍不住加上中文提示。在Windows控制台里,如果源码用UTF-8保存,而控制台默认用GBK编码,输出的中文就会变成乱码。这不是C语言的问题,是字符编码不一致。
处理方法有三种:
- 把源码保存成和系统一致的编码(Windows常见GBK,即GB2312)。
- 在程序开头调用
SetConsoleOutputCP(65001)设置控制台为UTF-8(Windows专属)。 - 直接用英文提示,避开编码问题,等学到文件编码相关知识再回来解决。
我个人建议学习阶段直接用英文提示,或者干脆不写中文,先保证逻辑正确。中文乱码问题很容易把新手的注意力从"算法逻辑"转移到"环境杂音"上,没必要一开始就硬碰。
另外别忘了\n。\n是换行转义字符,在格式字符串里占一个字符,但表示的是回车换行。很多初学者会把它和C语言的多个print语句刷屏混淆。比如:
printf("请输入长宽高:\n");如果不加\n,提示语和输入光标挤在同一行,用户体验很糟。printf里的转义字符不只是\n,还有\t(制表符)、\"(输出双引号)、\\(输出反斜杠)等,这些都属于格式层面的基本功。
5. 别让程序在非法输入前裸奔:给例1.2加上防护
5.1 用户的输入不会老实听话
教科书例题默认用户会规规矩矩输入三个正数,但现实中不是这样。用户可能输入负数、零、小数、字母、超长字符串,甚至什么都不输直接敲回车。一个没有校验的程序,遇到非法输入往往出现两种极端:要么是垃圾输出,要么是死循环。
比如输入abc时,scanf解析%lf失败,a中的值保持原来的样子(可能是垃圾值或初始值),程序还会继续算一个"体积"出来,用户根本看不出哪里错了。
5.2 用scanf返回值做第一道防线
最简单的防护是检查scanf返回值是否为3:
#include <stdio.h> int main() { double length, width, height; double volume; printf("请输入长方体的长、宽、高,用空格隔开:\n"); if (scanf("%lf %lf %lf", &length, &width, &height) != 3) { printf("输入格式错误,请确保输入三个数字。\n"); return 1; } volume = length * width * height; printf("长方体的体积是:%.2f\n", volume); return 0; }这段代码至少能把"格式错误"和"正常计算"区分开。返回1表示程序因输入问题终止,这种设计比写死return 0要负责任。
5.3 循环重试:让用户有机会重新输入
光退出还不够,更友好的是循环重试。这里有个经典陷阱:如果scanf读入失败,坏字符并没有被消费,它还在输入缓冲区里。下次scanf会继续读到同一个坏字符,再次失败,形成死循环。
解决方法是,在scanf失败后清空缓冲区,把残留字符全部吃掉:
while (getchar() != '\n');这行代码不断读取字符直到读完换行符。完整的重试版本:
#include <stdio.h> int main() { double length, width, height; double volume; while (1) { printf("请输入长方体的长、宽、高,用空格隔开:\n"); if (scanf("%lf %lf %lf", &length, &width, &height) == 3) { break; } while (getchar() != '\n'); printf("输入无效,请重新输入。\n"); } volume = length * width * height; printf("长方体的体积是:%.2f\n", volume); return 0; }实际调试中我发现,while (getchar() != '\n');在输入EOF(Ctrl+Z或Ctrl+D)时可能陷入死循环,因为getchar返回EOF而不是换行符。更安全一点的写法还要判断EOF,但对教材例题来说,上面的写法已经足够了。操作中注意语法分号别漏,不然就变成一个空循环体,整个程序行为都会改变。
5.4 负数、零和超大值的边界处理
即使格式正确,数据也可能在物理上不合理。长方体的边长不应该是负数或零。负的边长算出来体积是负的,这在数学上成立,在物理上没有意义。所以更完善的校验应该加上范围判断:
if (length <= 0 || width <= 0 || height <= 0) { printf("长、宽、高必须为正数。\n"); return 1; }另外,double虽然是浮点型里精度较高的,但也不是无限大。如果用户输入1e300 1e300 1e300,理论上体积是1e900,远超double上限,结果会是inf(无穷大)。printf打印出来是inf,这不算程序崩溃,但显然是无效结果。对这种极端输入,除非是专门的科学计算程序,一般不做额外处理,但作为程序员你要知道这个现象是怎么来的,不要被inf和nan吓住。
6. 从gcc编译到运行结果不对:我的排错顺序和经验
6.1 编译命令和警告开关
很多新人拿VC6或Dev-C++点一下运行,看到弹窗就以为万事大吉。我建议从第一天就用gcc,把警告全开:
gcc -Wall -Wextra volume.c -o volume-Wall开启大部分警告,-Wextra开启更多额外警告。上面的代码如果漏了scanf的&,开启警告后gcc会提示:format '%lf' expects argument of type 'double *', but argument 2 has type 'double'。这条提示能帮你省下大量调试时间。
如果你用VS Code,装好C/C++插件后,同样可以用terminal执行gcc命令,别只依赖"一键编译"。学会看编译器的原始输出,是程序员的基本功。
6.2 编译错误信息怎么读
初学者看到一屏英文错误就慌。其实只需要抓三样:文件名、行号、错误描述。比如:
volume.c:14:26: error: expected ';' before 'return'意思是volume.c第14行第26列附近,在return前面缺少分号。中文输入法把分号;输成全角;是超级常见的入门错误,编译器说的"expected ';'"十有八九是这个原因。
还有一种情况是错误信息很多,但真正的错只有一个,后面的错误是前面错误导致的连锁反应。比如少了头文件,printf和scanf的隐式声明会引发一大堆警告和错误。这时候改掉第一个错误,重新编译,往往后面全好了。
6.3 运行结果不对的逐层排查顺序
程序能编译,但结果不符合预期,我一般按这个顺序排查:
- 先检查格式控制符:printf里是%f还是%d?scanf里是%lf还是%f?这一项能解决一半问题。
- 再检查变量类型:声明的是double,scanf和printf是否都按double处理。
- 然后检查输入本身:在scanf后面加一行printf,把你读到的值原样打印出来。比如:
这一步能立刻确认是"读坏了"还是"算坏了"。printf("debug: %f %f %f\n", length, width, height); - 最后检查运算和括号:如果代码里有中间量,看看有没有整数除法截断、类型转换混用。
这种"由外向内"的排查法,比盯着代码发呆高效得多。不要觉得加临时printf很丢人,我在正经项目里也是这样定位问题的。
6.4 一点点代码风格建议
例1.2太短,风格问题体现不明显,但现在养成习惯,后面写几百行的代码会受益。我建议:
- 一行一句,别写成一堆分号连在一起。
- 变量名见名知义,
a、b、c不如length、width、height。 - 花括号的缩进统一,要么K&R风格(左花括号在行尾),要么Allman风格(左花括号独占一行),别混着用。
- 表达式里适当加空格,
volume=length*width*height;和volume = length * width * height;后者一眼能看懂。
还有,加注释不是为了凑字数,是给自己留线索。这个例子中的乘法,可以补一句"体积 = 长 × 宽 × 高",让自己三个月后再回来看时不用重新想。
最后分享一个我自己的小习惯:每次学新的C语法,我都会强迫自己写一个基于例1.2变体的测试程序,比如把"计算体积"改成"计算表面积",或者把固定长宽高改成从命令行参数读取。这样做,比反复抄教材例题有效得多。这个习惯从学C语言第一天保持到现在,帮我打下了很扎实的基础。你也试试。