上一篇我们写了第一个 C 语言程序,认识了 main 函数、printf、ASCII 编码这些基础概念。今天我们要进入一个更"实在"的话题:数据在 C 语言里是怎么被存储和操作的。
想象一下你在搬家。你有书、衣服、厨具——不同的东西需要不同的箱子和不同的存放方式。书可以用小纸箱,衣服用大编织袋,易碎的碗碟需要用泡沫箱。C 语言里的"数据类型"就好比这些不同的箱子规格——数据类型决定了数据占用多少内存、能表示多大的范围。而"变量"就是给这个箱子贴上的标签,让你以后能通过名字找到它。
如果你是从 Python 或 JavaScript 转过来的,有一个思维需要马上转换:在 C 语言里,每个变量必须先声明类型才能使用。你今天把它当整数用,明天就不能把它当字符串用。这是 C 语言"静态类型"的特点——类型在编译时就确定了,永远不会改变。这种限制看起来很死板,但它让编译器能检查出大量错误,也让程序跑得更快。
另外有个基础概念需要先了解:计算机内存的最小单位是字节(Byte),1 字节 = 8 位(bit)。每位只能是 0 或 1,所以 1 个字节能表示 2^8 = 256 种不同的值。不同类型占用的字节数不同——char 只占 1 字节,int 通常占 4 字节。占的字节越多,能表示的范围就越大。本文基于 C89/C99 标准,代码示例在 VS2022 x64 环境下测试。
数据类型介绍
C 语言的数据类型分为三大类:字符型、整型、浮点型。每种类型又有带符号(signed)和无符号(unsigned)两种变体。我们先做一个全景浏览。
字符型
char /* 字符类型,占 1 字节 */
[signed] char /* 有符号字符,范围 -128 ~ 127 */
unsigned char /* 无符号字符,范围 0 ~ 255 */有一个很容易忽略的细节:C 语言标准规定 char 默认是有符号还是无符号,取决于具体编译器。这意味着 char 不一定等同于 signed char——在有的平台上它可能是 unsigned char。如果你需要确定的符号行为,显式写 signed char 或 unsigned char 更安全。
char 在内存里存的其实不是"字符",而是一个整数(ASCII 码值)。字符 'A' 存进去的是 65,'a' 是 97。之所以"显示成字母",是因为你用 %c 打印时,printf 把那个整数翻译成了对应的字符。这一点上一篇已经验证过,现在你应该能理解:char 就是一个小号整数,只是我们通常用它来装字符而已。
整型
整型家族的成员最多,按占用空间从小到大排列:
short [int] /* 短整型,通常 2 字节 */
int /* 基本整型,通常 4 字节 */
long [int] /* 长整型,通常 4 或 8 字节 */
long long [int] /* 长长整型,C99 引入,通常 8 字节 */每种前面都可以加 signed(默认)或 unsigned。unsigned 的好处是:同样大小的内存,能表示的正整数范围翻了一倍。比如 16 位的 signed short 范围是 -3276832767,而 65535。unsigned short 是 0
各整型类型的具体大小与范围(VS2022 x64)
| 类型 | 大小 | signed 范围 | unsigned 范围 |
|---|---|---|---|
signed char | 1 字节 | -128 ~ 127 | 0 ~ 255 |
short | 2 字节 | -32768 ~ 32767 | 0 ~ 65535 |
int | 4 字节 | -2147483648 ~ 2147483647 | 0 ~ 4294967295 |
long | 4 字节(Windows)/ 8 字节(Linux) | 同 int / 更大 | 同 unsigned int / 更大 |
long long | 8 字节 | 约 -9.2×10^18 ~ 9.2×10^18 | 0 ~ 约 1.8×10^19 |
要特别提醒的是 long 这个"墙头草":Windows 上 long 是 4 字节,64 位 Linux 上 long 是 8 字节。这是历史原因——Windows 沿用了 32 位时代的 LP64/LLP64 分歧。所以你写代码时,如果依赖 long 的具体大小,跨平台就可能出 bug。C 标准只保证 short <= int <= long <= long long,具体大小由实现决定。
整型字面量——写数字时的类型后缀
在代码里直接写出的数字(比如 100、3.14)叫字面量。整型字面量默认是 int 类型,但你可以加后缀改变它的类型:
100 /* int */
100L /* long */
100LL /* long long */
100U /* unsigned int */
100UL /* unsigned long */
100ULL /* unsigned long long */什么时候需要后缀?最典型的场景是"表达式溢出"。看这个例子:
#include <stdio.h>
int main()
{
/* 1 和 2 都是 int,1 * 2 是 int 运算 */
/* 但如果数字大:*/
long long a = 1000000 * 1000000; /* 两个 int 相乘,结果是 int,先溢出! */
/* 1000000 * 1000000 = 10^12,远超 int 上限,得到负数或乱值,再赋给 long long 也晚了 */
long long b = 1000000LL * 1000000LL; /* 加 LL 后缀,按 long long 运算,结果正确 */
printf("a = %lld\n", a); /* 错误结果 */
printf("b = %lld\n", b); /* 1000000000000 */
return 0;
}乘法/加法前先想想操作数的类型——这是防止"静默溢出"的第一道防线。C 语言里溢出不是报错,而是"回绕"(wraparound),结果错得毫无征兆,这正是 C 语言危险的地方。
浮点型
float /* 单精度浮点,通常 4 字节 */
double /* 双精度浮点,通常 8 字节 */
long double /* 扩展精度浮点,通常 8 或 16 字节 */double 比 float 精度更高、范围更大,代价是多占一倍内存。实际开发中,double 用得比 float 更普遍——除非你在做嵌入式开发、内存极其紧张。
浮点数的精度问题——为什么 0.1 + 0.2 != 0.3?
这是每一个学 C 的人迟早会撞上的墙。试试运行:
#include <stdio.h>
int main()
{
float a = 0.1f; /* f 后缀表示 float 字面量 */
float b = 0.2f;
float c = a + b;
printf("%.10f\n", c); /* 输出 0.3000000119,不是 0.3! */
if (c == 0.3f)
printf("相等\n");
else
printf("不相等\n"); /* 输出:不相等 */
return 0;
}原因要从浮点数的存储方式说起。浮点数用"符号位 + 指数 + 尾数"的方式存储(IEEE 754 标准),本质上是用有限位数的二进制去逼近一个十进制小数。而 0.1 的二进制展开是无限循环小数(就像 1/3 在十进制里是无限循环小数一样),只能截断存储——误差由此产生。float 只有 23 位尾数,误差更大;double 有 52 位尾数,误差更小但仍然存在。
这带来三条铁律:
- 不要直接用
==比较浮点数,应该比较"差的绝对值是否小于某个很小的阈值"; - 不要用浮点数做精确计算(比如金额、银行账目),那种场景要用定点数或整数(以"分"为单位);
- float 的精度大约是 6
7 位有效数字,double 约 1516 位——超过这个位数的数字是不可信的。
#include <stdio.h>
#include <math.h> /* fabs 函数:求绝对值 */
int main()
{
float a = 0.1f;
float b = 0.2f;
float c = a + b;
/* 正确的浮点数比较方式:看差的绝对值是否足够小 */
if (fabs(c - 0.3f) < 1e-6f) /* 1e-6 就是 0.000001 */
printf("可以认为相等\n");
else
printf("不相等\n");
return 0;
}布尔类型(C99)
C89 没有专门的布尔类型。C 语言习惯用 0 表示假,非 0 表示真——这种"0/非0"的设计后来也影响了 C 语言的条件判断体系。C99 引入了 _Bool 类型,配合 <stdbool.h> 头文件使用:
#include <stdbool.h> /* 引入布尔类型支持 */
int main()
{
_Bool flag = 1; /* _Bool 类型,1 表示 true */
/* 或者用宏定义的形式 */
bool isReady = true; /* true 就是 1,false 就是 0 */
if (isReady)
printf("准备好了!\n");
return 0;
}<stdbool.h> 做的事其实很简单——它只是定义了三个宏:
#define bool _Bool
#define false 0
#define true 1所以写 bool 和写 _Bool 本质上是一回事,只是前者更好读一些。
还有一个重要特性:任何非 0 值赋给 _Bool 都会被转成 1。
#include <stdio.h>
#include <stdbool.h>
int main()
{
_Bool flag = 100; /* 赋 100?存进去的是 1 */
printf("%d\n", flag); /* 输出 1 */
flag = 0;
printf("%d\n", flag); /* 输出 0 */
return 0;
}这解释了为什么 C 语言条件判断里"非 0 即真"——因为 C 语言底层就是这么归一化的。
sizeof 操作符
知道了有哪些类型,接下来你肯定想问:每种类型到底占多少字节?答案在 sizeof 操作符。
sizeof 是 C 语言的一个关键字,用来计算类型或变量的大小,单位是字节。它有几个特点值得记住:
sizeof后面跟类型时必须加括号(sizeof(int)),跟变量名时可以省略括号(sizeof a)sizeof里的表达式不会真正执行——它在编译阶段就被替换成常量了sizeof的返回值类型是size_t(无符号整数),打印时用%zd占位符
来看看各种类型在 VS2022 x64 下的大小:
#include <stdio.h>
int main()
{
/* 用 sizeof 打印各种数据类型的大小 */
printf("char: %zd 字节\n", sizeof(char)); /* 1 */
printf("_Bool: %zd 字节\n", sizeof(_Bool)); /* 1 */
printf("short: %zd 字节\n", sizeof(short)); /* 2 */
printf("int: %zd 字节\n", sizeof(int)); /* 4 */
printf("long: %zd 字节\n", sizeof(long)); /* 4 */
printf("long long: %zd 字节\n", sizeof(long long)); /* 8 */
printf("float: %zd 字节\n", sizeof(float)); /* 4 */
printf("double: %zd 字节\n", sizeof(double)); /* 8 */
printf("long double: %zd 字节\n", sizeof(long double));/* 8 */
/* sizeof 操作变量时,可以省略括号 */
int a = 10;
printf("sizeof a = %zd\n", sizeof a); /* 等同于 sizeof(a) */
return 0;
}注意上面的输出是在 VS2022 x64 下的结果——你的环境可能不同。特别是 long 类型:在 64 位 Linux 上它通常是 8 字节,在 64 位 Windows 上它仍然是 4 字节(历史原因)。
sizeof 的表达式不计算——经典反直觉例子
关于"sizeof 中的表达式不计算",有一个经典例子:
#include <stdio.h>
int main()
{
short s = 2;
int b = 10;
/* sizeof 里的赋值操作不会真的执行! */
printf("%d\n", sizeof(s = b + 1)); /* 输出 2(short 的大小) */
printf("s = %d\n", s); /* 输出 2,s 还是 2,没被改变 */
return 0;
}s = b + 1 这行代码在 sizeof 里根本不会执行——编译器在编译期间就根据表达式类型(short)确定了大小,所以 s 的值没有任何变化。这是一个写代码时需要注意的"反直觉"行为。
为什么标准要这么规定?因为 sizeof 的结果必须是编译期常量(VLA 是例外),编译器只需要看表达式的类型就能确定大小,根本不需要真正求值——求值反而浪费运行时间。这个设计还有实际用途:可以安全地对"不存在的对象"取 sizeof。比如 sizeof(int) 并没有真的创建一个 int 变量。
signed 和 unsigned——带不带符号,差别大了
signed 和 unsigned 只能修饰字符型和整型(浮点型本来就带符号)。它们的核心区别是最高位是否用来表示符号。
以 8 位的 signed char 为例:8 个 bit 中,最高位是符号位(0 正 1 负),剩下 7 位表示数值,所以范围是 -128 到 127。而 unsigned char 所有 8 位都用来表示数值,范围是 0 到 255。
来算一遍:8 个 bit 总共能表示 2^8 = 256 种状态。signed 把这 256 种状态"劈"成两半:一半给正数和 0(0127),一半给负数(-1-128);unsigned 则全部给非负数(0~255)。所以相同字节数下,unsigned 能表示的最大值约是 signed 的两倍——这是 unsigned 存在的意义。
要查看当前系统上各种类型的极限值,可以引入 <limits.h>(整型)和 <float.h>(浮点型)头文件:
#include <stdio.h>
#include <limits.h> /* 整型极限值 */
#include <float.h> /* 浮点型极限值 */
int main()
{
/* 有符号整型的范围 */
printf("int 最小值: %d\n", INT_MIN); /* -2147483648 */
printf("int 最大值: %d\n", INT_MAX); /* 2147483647 */
/* 无符号整型的范围 */
printf("unsigned int 最大值: %u\n", UINT_MAX); /* 4294967295 */
/* short 的范围 */
printf("short 最小值: %d\n", SHRT_MIN); /* -32768 */
printf("short 最大值: %d\n", SHRT_MAX); /* 32767 */
return 0;
}注意 unsigned int 里的 int 可以省略不写——unsigned a; 就等价于 unsigned int a;。
有符号和无符号混用的坑
当你把 signed 和 unsigned 放在同一个表达式里时,C 语言会悄悄把 signed 转成 unsigned——这叫"整型提升规则"。这可能导致非常反直觉的结果:
#include <stdio.h>
int main()
{
int a = -1; /* signed int,-1 */
unsigned int b = 1; /* unsigned int,1 */
/* 直觉:-1 < 1,应该打印 "小于" */
if (a < b)
printf("a 小于 b\n");
else
printf("a 不小于 b\n"); /* 实际输出这个! */
/* 原因:比较时 a 被转成 unsigned,-1 变成 4294967295,反而大于 1 */
return 0;
}为什么 -1 会变成 4294967295?因为 -1 在内存里的二进制是"全 1"(补码表示),按 unsigned 解读就是 2^32 - 1 = 4294967295。同一个二进制,signed 和 unsigned 解读出来的数值完全不同——这个"按位解读"的概念以后学原码反码补码时会彻底讲透。
实际建议:避免在同一个表达式里混用 signed 和 unsigned。如果一定要比,先把它们转成同一种类型,或者用显式强制转换。
变量——给内存空间取个名字
变量,顾名思义,就是可以变化的量。在 C 语言中,变化的值叫变量,不变的值叫常量。
创建变量的语法很简单:数据类型 变量名;
int age; /* 创建一个可以存放整数的变量,名叫 age */
char initial; /* 创建一个可以存放字符的变量,名叫 initial */
double salary; /* 创建一个可以存放双精度浮点的变量 */这行代码背后发生了什么?编译器在内存里划出一块指定大小的空间(int age 就划 4 字节),并且建立了一张"名字 → 地址"的映射表。以后你写 age,编译器就知道是指那块内存。用调试器(VS 的调试功能)查看变量时,你能同时看到变量的值和它的内存地址——强烈建议你自己调试一次,亲眼看看"变量 = 名字 + 内存"这件事。
变量创建时可以顺便给一个初始值,这叫初始化:
int age = 18; /* 创建并初始化为 18 */
char initial = 'Z'; /* 创建并初始化为 'Z' */
double pi = 3.14159; /* 创建并初始化为 3.14159 */
unsigned int height = 175; /* 无符号整型 */不初始化会怎样?——垃圾值
#include <stdio.h>
int main()
{
int a; /* 没初始化 */
printf("%d\n", a); /* 输出什么?不知道!可能是一个随机数 */
return 0;
}局部变量如果不初始化,它里面存的是那块内存原来的残留值——可能是上上次程序留下的数据,可能是任何东西。这个"垃圾值"在不同电脑、不同运行时刻都可能不同,程序行为变得不可预测。所以有一条铁律:局部变量用之前必须初始化。要么定义时给初值,要么在使用前赋值。
局部变量 vs 全局变量
根据定义位置不同,变量分为两类:
- 局部变量:在大括号
{}内部定义的变量,只能在自己所在的{}范围内使用 - 全局变量:在大括号外部定义的变量,整个工程都可以用
#include <stdio.h>
int global = 2023; /* 全局变量,定义在所有大括号外面 */
int main()
{
int local = 2018; /* 局部变量,定义在 main 函数的 {} 里面 */
printf("局部变量 local = %d\n", local); /* 2018 */
printf("全局变量 global = %d\n", global); /* 2023 */
return 0;
}如果局部变量和全局变量同名会怎样?
#include <stdio.h>
int n = 1000; /* 全局变量 n */
int main()
{
int n = 10; /* 局部变量 n,和全局变量同名 */
printf("%d\n", n); /* 输出 10,局部变量优先! */
return 0;
}规则很简单:当局部变量和全局变量同名时,局部变量优先。 这就像你在教室里叫"小明"——如果班里有个小明,你指的是班里的,而不是隔壁班的。编译器查找变量名时遵循"就近原则":先在当前代码块里找,找不到再去外层找,最后才去全局找。
变量的生命周期与内存区域
变量存放的位置也不同:
- 局部变量放在内存的栈区——进入作用域时创建,离开时自动销毁
- 全局变量放在内存的静态区——程序启动时就存在,程序结束时才销毁
- 堆区是用来做动态内存分配的(后面会专门讲)
了解内存布局非常有用。比如你可能会遇到一个问题:为什么不能在函数外面访问函数内部的局部变量?答案就是——那个局部变量在函数结束时就已经被销毁了。下次调用函数,它会重新分配内存、重新初始化,上一次的值消失得无影无踪(除非你把它声明成 static,这个以后讲)。
变量的作用域(scope)
作用域就是"名字能用的范围"。C 语言的规则很简单:变量从定义处开始,到它所在的那个最近的大括号 } 结束为止。
#include <stdio.h>
int main()
{
int a = 1; /* a 的作用域从这一行开始 */
{
int b = 2; /* b 的作用域从这里开始 */
printf("a=%d b=%d\n", a, b); /* 内层可以看到外层变量 */
} /* b 在这里销毁 */
printf("a=%d\n", a); /* 可以,a 还在作用域内 */
/* printf("b=%d\n", b); */ /* 错误!b 已不在作用域内,编译报"未声明的标识符" */
return 0;
}记住两点:外层不能访问内层的变量;内层可以使用外层的变量。这像套娃——外层看不见里面的,里面看得见外面的。
算术操作符——加减乘除取余
C 语言的算术操作符有五个:+、-、*、/、%。它们都是双目操作符(需要两个操作数)。
加法和减法很直观:
#include <stdio.h>
int main()
{
int x = 4 + 22; /* x = 26 */
int y = 61 - 23; /* y = 38 */
printf("x=%d, y=%d\n", x, y);
return 0;
}乘法也简单:
#include <stdio.h>
int main()
{
int num = 5;
printf("%d\n", num * num); /* 输出 25 */
return 0;
}但除法 / 是新手最容易踩的坑。C 语言的除法规则:如果除号两端都是整数,执行的是整数除法,小数部分会被直接丢弃。
#include <stdio.h>
int main()
{
float x = 6 / 4; /* 整数除法:6/4 = 1,然后赋值给 x 变成 1.0 */
int y = 6 / 4; /* 整数除法:6/4 = 1 */
printf("x = %f\n", x); /* 输出 1.000000,不是 1.500000! */
printf("y = %d\n", y); /* 输出 1 */
return 0;
}你以为结果是 1.5?抱歉,C 语言先把 6/4 算成 1(整数除法),再把 1 转成 1.0 给 x。想要 1.5,至少让一个操作数是浮点数:
#include <stdio.h>
int main()
{
float x = 6.0 / 4; /* 浮点除法,结果 1.5 */
/* 也可以写成 float x = 6 / 4.0; */
printf("x = %f\n", x); /* 输出 1.500000 */
return 0;
}这个坑在实际编程中经常出现。比如你写一个百分比计算:
#include <stdio.h>
int main()
{
int score = 5;
score = (score / 20) * 100; /* 你以为 score 是 25? */
printf("score = %d\n", score); /* 实际输出 0! */
/* 因为 score/20 = 5/20 = 0(整数除法),0*100 = 0 */
/* 正确做法:让除法变成浮点运算 */
score = 5;
score = (score / 20.0) * 100;
printf("score = %d\n", score); /* 输出 25 */
return 0;
}再补充一个细节:整数除法的结果是向零取整(截断),不是四舍五入,也不是向下取整。7 / 2 = 3,-7 / 2 = -3(不是 -4)。这在负数除法时最容易搞混。
除数为 0 会怎样?
整数除以 0 是未定义行为——程序可能崩溃(浮点异常),也可能返回一个垃圾值。浮点数除以 0 不会崩溃,但会得到特殊值 inf(无穷大)或 nan(不是数字)。所以写代码时,凡是除数来自用户输入的地方,都应该先判断:
if (b != 0)
printf("%d\n", a / b);
else
printf("除数不能为 0\n");求模(取余)运算符 % 返回两个整数相除的余数,只能用于整数:
#include <stdio.h>
int main()
{
int x = 6 % 4; /* 6÷4=1...2,所以结果是 2 */
printf("6 %% 4 = %d\n", x); /* 输出 2 */
/* 注意:printf 里要打印 % 本身,需要写成 %% */
return 0;
}负数取模的规则:结果的正负号由第一个操作数决定。
#include <stdio.h>
int main()
{
printf("%d\n", 11 % -5); /* 输出 1 */
printf("%d\n", -11 % -5); /* 输出 -1 */
printf("%d\n", -11 % 5); /* 输出 -1 */
return 0;
}11 % -5:11 除以 -5,商 -2,余 1(结果跟第一个操作数 11 的正负号) -11 % -5:-11 除以 -5,商 2,余 -1(结果跟 -11 的正负号) -11 % 5:-11 除以 5,商 -2,余 -1(结果跟 -11 的正负号)
这个规则在 C99 标准里有明确约定:*a % b 的结果符号与 a 相同,且 (a/b)b + a%b 恒等于 a。记口诀:"余数跟被除数走"。
% 的经典应用场景
- 判断奇偶:
n % 2 == 0是偶数,否则是奇数 - 限制范围:
n % 100把任何数压到 0~99 之间(以后生成随机数会用到) - 分离数字:
n % 10得到个位,n / 10去掉个位 - 循环调度:环形队列、轮询调度都要用取模
算术操作符的优先级
乘除取余的优先级高于加减,同一优先级从左到右:
int a = 2 + 3 * 4; /* 先乘后加:14 */
int b = (2 + 3) * 4; /* 括号改变顺序:20 */
int c = 8 / 4 * 2; /* 从左到右:(8/4)*2 = 4,不是 8/(4*2)=1 */优先级记不全没关系,多用括号永远是好习惯——既避免了记错,也方便别人读你的代码。
赋值操作符与自增自减
变量创建时给值叫初始化,创建后再给值叫赋值:
int a = 100; /* 初始化 */
a = 200; /* 赋值 */C 语言支持连续赋值,但可读性差,建议拆开写:
int a = 3;
int b = 5;
int c = 0;
c = b = a + 3; /* 从右向左:b = 6,然后 c = 6——不推荐 */C 语言提供了复合赋值符来简化代码:
int a = 10;
a += 3; /* 等价于 a = a + 3; */
a -= 2; /* 等价于 a = a - 2; */
a *= 5; /* 等价于 a = a * 5; */
a /= 4; /* 等价于 a = a / 4; */
a %= 3; /* 等价于 a = a % 3; */完整的复合赋值符:+=、-=、*=、/=、%=、>>=、<<=、&=、|=、^=。
复合赋值不仅省字符,还有一个微妙的好处:左边的表达式只计算一次。对于 a[i] += 1 这种写法,a[i] 的地址只算一次;而 a[i] = a[i] + 1 里 a[i] 要计算两次。虽然现在的编译器基本都会优化掉这个差异,但理解这个原理对以后分析性能有好处。
++ 和 -- 是 C 语言最有"性格"的运算符,也是面试笔试题的常客。它们是单目运算符,分为前置和后置两种形式。
前置 ++:先加 1,后使用
#include <stdio.h>
int main()
{
int a = 10;
int b = ++a; /* 前置++:a 先加 1 变成 11,再赋值给 b */
printf("a=%d, b=%d\n", a, b); /* a=11, b=11 */
return 0;
}等价于:a = a + 1; int b = a;
后置 ++:先使用,后加 1
#include <stdio.h>
int main()
{
int a = 10;
int b = a++; /* 后置++:先把 a(10) 赋值给 b,然后 a 再加 1 */
printf("a=%d, b=%d\n", a, b); /* a=11, b=10 */
return 0;
}等价于:int b = a; a = a + 1;
-- 的逻辑完全一样,只是把加 1 换成减 1:
#include <stdio.h>
int main()
{
int a = 10;
int b = --a; /* 前置--:a 先减 1 变成 9,再赋值给 b */
printf("a=%d, b=%d\n", a, b); /* a=9, b=9 */
a = 10;
b = a--; /* 后置--:先把 a(10) 赋值给 b,然后 a 减 1 */
printf("a=%d, b=%d\n", a, b); /* a=9, b=10 */
return 0;
}记忆口诀:前置先加减后使用,后置先使用后加减。
++/-- 的未定义行为警告
有一个重要警告:不要把 ++/-- 和同一变量的其他操作混在一个表达式里。比如 printf("%d %d", a, a++) ——这种代码的行为是未定义的,不同编译器可能给出不同结果。写代码请保持清晰,不要炫技。
为什么是未定义行为?因为 C 标准规定:同一个对象在两个序列点之间只能被修改一次。a++ + a++ 里 a 被修改了两次,编译器可以自由决定先算哪边,甚至做一些你想象不到的优化——不同编译器、不同优化级别,结果都可能不同。所以这条规则记牢:一个表达式里,同一个变量最多被修改一次。
单目 + 和 -(正号、负号)
除了 ++、--,C 语言还有两个单目操作符:一元 + 和一元 -(只有一个操作数,注意和做加法的双目 +、做减法的双目 - 区分开)。
一元 + 对正负没有影响,写不写都一样:int a = +10; 完全等价于 int a = 10;。一元 - 用来改变值的正负号——正数加 - 变负数,负数前面加 - 变回正数:
#include <stdio.h>
int main()
{
int a = 10;
int b = -a; /* b = -10:对 a 取相反数 */
int c = -10; /* c = -10 */
printf("b=%d, c=%d\n", b, c); /* b=-10, c=-10 */
a = -10;
b = -a; /* 负负得正:b = 10 */
printf("b=%d\n", b); /* b=10 */
return 0;
}强制类型转换
当你要把一种类型的数据赋给另一种类型的变量时,编译器可能会报警告:
int a = 3.14; /* 警告:double 赋给 int,小数部分会丢失 */消除警告的办法是强制类型转换:
int a = (int)3.14; /* 显式转换,只取整数部分 3,不会四舍五入 */语法是在值前面加 (目标类型)。虽然叫"强制",但它只是取整数部分,不会四舍五入。
不过我要提醒你:强制类型转换是不得已才用的手段。如果代码设计合理,大部分情况下不需要手动转换。滥用强制转换会掩盖真正的逻辑问题——它像是一块贴在代码上的"创可贴",掩盖了底层的类型不匹配。
C 语言还有一个特性叫隐式类型转换:当不同类型的值出现在同一个表达式中,低精度会自动向高精度看齐。
int a = 10;
double b = 3.14;
double c = a + b; /* a 被自动提升为 double,结果也是 double */这种隐式转换有利有弊:利在方便——你不用每次都写 (double)a;弊在有时会产生意料外的精度损失或性能开销。
转换规则:谁的精度高,听谁的
隐式转换遵循一条"往上靠"的规则:int 遇到 long 转 long,整型遇到浮点转浮点,float 遇到 double 转 double。下面是 C 语言的"类型阶梯"(从低到高):
char < short < int < long < long long < float < double < long double
注意一个特殊点:char 和 short 参与运算时,会先自动提升为 int(这叫"整型提升")。比如 char c = 'A'; printf("%d", c + 1);,c 先变成 int 65,再加 1 得 66。提升的原因很实际——CPU 的算术运算单元大多以 int(或更大)为单位工作,直接用小类型运算反而慢。
强制转换的典型场景
- 把 double 转 int 取整数部分:
(int)3.99得到 3 - 把浮点数除法改成想要的精度:
(double)a / b - 在 scanf 中
%d对应int*,但你想读 long 时必须%ld+(long*)之类——不过这种情况一般直接改用正确的占位符 - 生成随机数、位运算等需要明确类型宽度的场景
printf
上一篇我们简单用了 printf,这里系统讲一下它的格式化能力。
先记住一个容易忽略的细节:printf 名字里的 f 代表 format(格式化),而且它不会在行尾自动换行——输出结束后光标就停在原地。所以代码里常看到 \n,它的作用就是把光标移到下一行的开头。想要输出多行,要么在文本内部插 \n,要么多次调用 printf。
printf 的完整语法
printf 的完整格式是:printf("格式字符串", 参数列表);。格式字符串里除了普通文本和占位符,还可以有各种修饰符:
%[标志][宽度][.精度][长度修饰符]转换字符
比如 %-6.2f 就是:- 是左对齐标志,6 是最小宽度,.2 是两位小数,f 是浮点转换字符。下面逐一拆解。
占位符速查表
| 占位符 | 对应类型 |
|---|---|
%d | int(十进制整数) |
%i | 整数,基本等同于 %d |
%hd | short int |
%ld | long int |
%lld | long long int |
%u | unsigned int |
%c | char(字符) |
%s | 字符串 |
%f | float |
%lf | double |
%Lf | long double |
%Le | 科学计数法表示的 long double |
%o | 八进制整数 |
%x / %X | 十六进制整数(小写/大写) |
%a / %A | 十六进制浮点数(小写/大写字母) |
%e / %E | 科学计数法(e 小写/大写) |
%g / %G | 自动选择普通或科学计数法(整数位超过 6 位转科学计数法) |
%n | 已输出的字符个数(不输出内容,把值存到变量里) |
%p | 指针(地址) |
%zd | size_t 类型 |
%% | 百分号本身 |
这个表格里还有不少变体,比如 %lu(unsigned long)、%llu(unsigned long long)、%hx(short 的十六进制)等,它们的规律都是"字母前缀 + 基本占位符",用到的时候查表即可。这里列出的已经覆盖了日常写代码最常见的类型。
printf 中 float 和 double 的占位符真相
有一个细节值得单独强调:在 printf 里,%f 和 %lf 都可以打印 double。因为 C 语言规定,float 作为可变参数传给 printf 时,会自动提升为 double——所以 printf 收到的其实是 double,%f 和 %lf 效果一样。但 scanf 不一样!scanf 接收的是指针,float 和 double 的指针类型必须严格区分:float 用 %f,double 用 %lf。搞混了,scanf 会按错误的大小往内存里写数据,程序表现诡异甚至崩溃。这个对比务必记牢:
#include <stdio.h>
int main()
{
float f = 0.0f;
double d = 0.0;
/* printf:%f 和 %lf 都能打 double(float 自动提升) */
printf("%f %lf\n", f, d); /* 都可以 */
/* scanf:必须严格对应! */
scanf("%f", &f); /* float 用 %f */
scanf("%lf", &d); /* double 用 %lf!写 %f 就是 bug */
return 0;
}限定宽度
#include <stdio.h>
int main()
{
printf("%5d\n", 123); /* 输出 " 123",宽度至少 5,右对齐 */
printf("%-5d\n", 123); /* 输出 "123 ",左对齐 */
printf("%12f\n", 123.45);
/* 输出 " 123.450000",总宽度至少 12 */
return 0;
}宽度修饰的实际用途是对齐表格——比如打印成绩单,%5d 让所有数字右对齐排成整齐的列。左对齐 %-5d 则常用于中文文本,因为中文占 2 个字符宽度。
总是显示正负号
默认情况下,printf 只对负数显示 - 号,正数前面什么都不加。如果想让正数也显示 + 号,可以在 % 后面加一个 +:
#include <stdio.h>
int main()
{
printf("%+d\n", 12); /* 输出 +12 */
printf("%+d\n", -12); /* 输出 -12 */
return 0;
}这在打印坐标、温度差等"带方向"的数据时很有用。
限定小数位数
#include <stdio.h>
int main()
{
printf("%.2f\n", 0.5); /* 输出 "0.50",保留两位小数 */
printf("%6.2f\n", 0.5); /* 输出 " 0.50",宽度 6,两位小数 */
/* 动态指定宽度和精度 */
printf("%*.*f\n", 6, 2, 0.5); /* 等同于 %6.2f */
return 0;
}注意:%.2f 是四舍五入到两位小数,不是截断。printf("%.2f", 0.005) 输出 0.01(虽然由于浮点误差,某些值可能显示得让人意外)。
输出部分字符串
#include <stdio.h>
int main()
{
printf("%.5s\n", "hello world"); /* 输出 "hello",只取前 5 个字符 */
return 0;
}printf 的返回值
printf 其实也有返回值:它返回成功输出的字符个数。这个返回值平时被忽略了,但在链式调用里很关键(后面讲函数时会用到,比如 printf("%d", printf("abc")) 输出 3)。
scanf——从键盘读取数据
printf 负责输出,scanf 负责输入。它的基本用法:
#include <stdio.h>
int main()
{
int score = 0;
printf("请输入成绩:");
scanf("%d", &score); /* & 取地址,不能漏! */
printf("成绩是:%d\n", score);
return 0;
}有三个要点:
第一,& 不能漏。 scanf 需要的是变量的地址,而不是变量的值。它要把用户输入的数据写到变量所在的内存位置。忘记 & 是最常见的 scanf 错误——程序可能直接崩溃。
原理是这样的:scanf 是一个函数,函数参数是"按值传递"的(以后讲函数会细说)。如果你传 score(值),scanf 只能拿到一个拷贝,改了也没用;你必须传 &score(地址),scanf 才能顺着地址找到那块内存,把输入写进去。这就是为什么 scanf 和 printf 的用法看起来那么不对称——一个是"给值",一个是"给地址"。
第二,scanf 的返回值。 scanf 返回成功读取的变量个数。如果遇到输入结束或匹配失败,返回 EOF(通常是 -1)。
#include <stdio.h>
int main()
{
int a = 0, b = 0;
float f = 0.0f;
int r = scanf("%d %d %f", &a, &b, &f);
/* 如果用户输入了 3 个数,r = 3 */
/* 如果只输入了 2 个数就按 Ctrl+Z,r = 2 */
/* 如果一个都没输入就按 Ctrl+Z,r = -1 (EOF) */
printf("a=%d b=%d f=%f\n", a, b, f);
printf("成功读取了 %d 个值\n", r);
return 0;
}在 Windows 命令行里,Ctrl+Z 表示"输入结束",Ctrl+C 是强制终止。学会用返回值判断读取是否成功,是写健壮输入代码的第一步——比如用户让你输数字却敲了字母,scanf 返回 0,你就知道出问题了。
第三,%s 的安全问题。 %s 读取字符串时不会检查数组长度,可能溢出——这是很多安全漏洞的来源。你应该指定最大读取长度:
#include <stdio.h>
int main()
{
char name[11]; /* 最多存放 10 个字符 + \0 */
scanf("%10s", name); /* 最多读取 10 个字符,防止溢出 */
printf("你好,%s\n", name);
return 0;
}注意 %10s 的语义:最多读 10 个字符,然后自动补 \0。这样即使输入 50 个字符,也只读前 10 个,不会写爆数组。这里 name 是数组名,本身就是地址,所以不需要也不能写 &name——scanf("%s", name) 才是对的。这一点和 int 变量完全不同。
第四,%c 是个例外——它不跳过空白字符。 %d、%f、%s 这些占位符在读取前都会自动跳过空格、换行、制表符等空白字符,唯独 %c 不会——它总是返回当前遇到的第一个字符,哪怕是空格或回车。这会导致一个经典 bug:用 scanf("%d %c", &n, &ch) 时,你输入数字后按下的回车键,会被 %c 当成字符读走,程序表现"诡异"。解决办法是在 %c 前面加一个空格:scanf(" %c", &ch),这个空格的意思是"先跳过所有空白字符再读"。
#include <stdio.h>
int main()
{
int n = 0;
char ch = 0;
/* 错误写法:%c 会把输入完数字后的回车读走 */
/* scanf("%d %c", &n, &ch); */
/* 正确写法:%c 前面加空格,跳过空白字符 */
scanf("%d %c", &n, &ch);
printf("n=%d, ch=%c\n", n, ch);
return 0;
}第五,scanf 的读取原理。 用户的输入会先进入一个缓冲区,按下回车后 scanf 才按占位符的顺序从缓冲区里解读数据,而且解读会从上一次停下的位置继续,直到读完缓冲区,或遇到第一个不符合条件的字符。比如用户输入 -13.45e12# 0,%d 会从 - 开始读,读到 -13 就停(. 不是整数的合法字符);下一次 %f 就从 .45e12 接着读(这是合法的科学计数法写法),到 # 停下(不是浮点数的合法字符)。
缓冲区机制还解释了另一个常见困惑:为什么 scanf 在循环里会"卡住"或跳过输入?如果上一次输入残留了非法字符(比如要求数字却输了字母),这些字符留在缓冲区里,下一次 scanf 一读就失败返回 0,循环就死转了。这种情况需要清空缓冲区或做输入校验——以后讲到"输入健壮性"会专门处理。
赋值忽略符 *
有时用户输入的分隔符格式不统一,比如日期可能是 2020-01-01 也可能是 2020/01/01。用 %*c 可以跳过任意分隔符:
#include <stdio.h>
int main()
{
int year = 0, month = 0, day = 0;
/* 无论用户输入 2020-01-01 还是 2020/01/01,都能正确解析 */
scanf("%d%*c%d%*c%d", &year, &month, &day);
/* %*c 匹配分隔符,但不赋值给任何变量 */
printf("年=%d 月=%d 日=%d\n", year, month, day);
return 0;
}* 的含义是"读走但丢弃"——匹配到了输入,但不存到任何变量里。这在解析固定格式文本(日志、配置文件)时非常实用。
%s 的局限与 %[] 字符集
再补充两个 scanf 的占位符细节,都是实际开发中会用到的。
第一,%s 只能读"一个单词",不能读带空格的字符串。 %s 的读取规则是:从当前第一个非空白字符开始,遇到空白字符(空格、制表符、换行)就停下来。所以你想用 %s 读一个书名或歌曲名(比如 "harry potter"),它只能读到 "harry";想要一次读多个单词,只能连续用多个 %s,但它们之间仍然会被空白隔开。所以在处理"可能包含空格"的输入(书名、整行文字)时,%s 并不合适。
第二,%[] 可以指定"字符集合",只读取集合内的字符。 用法举例:%[0-9](只读数字)、%[a-z](只读小写字母)、%[^\n](读一整行,这里的 ^ 表示"除...之外",^\n 的意思就是"读到换行才停")。它比 %s 更能精准地控制读什么、不读什么:
#include <stdio.h>
int main()
{
char phone[20] = {0}; /* 初始化为全 0,即使读取失败也有 \0 兜底 */
char line[100] = {0}; /* 同上 */
scanf("%[0-9]", phone); /* 只读数字,遇到第一个非数字字符就停 */
scanf("%[^\n]", line); /* 读一整行,直到遇到换行符才停 */
printf("phone=%s\nline=%s\n", phone, line);
return 0;
}scanf 与 printf 占位符对照表(务必收藏)
| 类型 | printf | scanf |
|---|---|---|
| int | %d | %d |
| unsigned int | %u | %u |
| long | %ld | %ld |
| long long | %lld | %lld |
| float | %f(自动提升为 double) | %f |
| double | %f 或 %lf | %lf |
| char(字符) | %c | %c(要地址,不跳过空白) |
| 字符串(char 数组) | %s | %s(不用 &,要限制长度) |
这张表是"输入输出调试器"——以后程序输出乱码、输入不对,先回来对表。
本篇思考题
printf("%d\n", sizeof(char))输出什么?sizeof的返回值用什么占位符打印最规范?- 为什么
float x = 6 / 4;结果是 1.0 而不是 1.5?怎么改才能得到 1.5? char c = 'A'; printf("%d", c + 1);输出什么?解释"整型提升"在这句话里的作用。- 如果用户输入
abc而程序写的是scanf("%d", &n),scanf返回什么?n的值会变吗? int a = -1; unsigned int b = 1;比较a < b输出什么?为什么?- 用 printf 打印一个右对齐、宽度 8、保留 3 位小数的浮点数,怎么写?
思考题参考答案与详解
第 1 题:sizeof(char) 的值是 1(C 语言规定 char 一定是 1 字节)。所以 printf("%d\n", sizeof(char)) 输出 1。sizeof 的返回值类型是 size_t(无符号整数),最规范、可移植的占位符是 %zd;用 %d 打印小数值虽然也能出结果,但类型上不严谨,遇到大型的 size_t 值就可能产生错误输出。
第 2 题:因为 6 / 4 两端的操作数都是整数,C 语言先执行的是整数除法——结果直接丢弃小数部分得到 1,然后才把这个整数 1 赋给 float x,于是变成 1.0。要得到 1.5,只需让除号两端至少有一个是浮点数:6.0 / 4、6 / 4.0,或写成 (float)6 / 4,这样走的是浮点数除法,结果就是 1.5。
第 3 题:输出 66。字符 'A' 的 ASCII 值为 65,而 c 参与算术运算时会发生整型提升——char 自动提升为 int,于是表达式等价于 65 + 1,结果是整数 66,%d 打印出 66。
第 4 题:scanf 返回 0(一个变量都没读成功),并且 n 的值不会变化——因为 %d 在输入流里遇到一个非数字字符 a,立即匹配失败,不会把数据写进 n(n 保持它原来的值;若它从未初始化过,则是垃圾值)。正因为这样,我们才要利用 scanf 的返回值判断读取是否成功。
第 5 题:输出 a 不小于 b。原因是在比较 a < b 时 C 语言做了隐式类型转换:把有符号的 a 转成 unsigned int 再比较。-1 在内存里是全 1 的补码,按 unsigned 解读就是 4294967295,比 1 大,所以 a < b 为假。这也提醒我们:不要在同一表达式里混用 signed 和 unsigned。
第 6 题:
printf("%8.3f\n", num); /* 宽度 8、右对齐(默认就是右对齐)、保留 3 位小数 */小结
这篇文章我们走完了一条完整的"数据处理"链路:从认识 C 语言的三大数据类型家族开始,用 sizeof 量出每种类型所占的内存大小,理解 signed 和 unsigned 的区别以及为什么 char 的符号行为不确定,再学会创建变量(包括局部变量和全局变量的规则),最后用一整套运算符——算术运算符(注意整数除法陷阱!)、赋值运算符、自增自减——来操作这些变量,并用 printf 和 scanf 完成数据的输入和输出。
这里有几个关键点值得反复提醒自己:整数除法丢弃小数、scanf 必须加 &、前置和后置 ++ 的区别、printf 占位符要匹配类型、float 和 double 在 scanf 里的占位符不同。这些看似基础的知识点,会成为你后续学循环、数组、指针时的"肌肉记忆"——地基没打好,后面每一层都会动摇。
下一篇我们将让程序真正"活"起来——学习 if、switch、逻辑运算符,让程序能根据不同的条件做出不同的反应。欢迎进入流程控制的世界。
还没有评论 — 第一条由你来留。