C语言系列写到第3篇,很多人觉得变量和常量太基础,不值得单独开一篇。但我在带新手和看别人代码的过程中发现,恰恰是这两个“最基础”的东西,埋伏了最多问题。比如有人写了#define MAX 100,又有一次写const int MAX = 100;,遇到数组定义时报“表达式必须含有常量值”,压根不知道为什么。还有人分不清scanf里什么时候加&,把常量当成变量传进去,导致程序崩溃。这篇我们把变量和常量彻底捋清楚,先从什么是变量讲起,再聊作用域、存储、const和宏,最后附一份我实操中遇到的坑和排查速查表,希望能帮你省几次调试到崩溃的时间。
1. 变量的本质:类型、名字与内存的关系
1.1 变量到底是一个盒子,还是一张门牌号?
很多教程喜欢把变量比喻成一个“盒子”,说int a;就是申请了一个能放整数的盒子。这个比喻方便理解,但有一个误导:它让你觉得变量名和内存空间是一体的。实际上,真正的存储空间是内存中的某个地址,变量名只是编译器替你记住的门牌号。
你写int a = 10;时,编译器做的事情是:在栈上划分一段内存(通常4字节),然后维护一张映射表,把名字a映射到那片内存的起始地址。当你在代码里写a + 1,CPU并不是直接把寄存器里的a当作数据,而是通过这张映射找到地址,再访问内存单元。所以C语言里变量名本质是编译期的符号,运行期根本没有a这个名字,全是地址。
理解这点对后面指针非常有帮助。为什么int a和int *p不同?因为p作为一个指针变量,它本身也占内存,里面的值是一个地址,这个地址指向另一块内存。初学者老是晕,就是没分清楚“这门牌号指向的房间”和“门牌号所在的房间”是两个地方。你可以用一个printf("%p, %p\n", (void*)&p, (void*)p);看看,两者完全不同。
1.2 变量的类型决定了内存大小与解释方式
类型是C语言给内存的“规格说明”。我给学生打的一个比方:同样一间屋子,你可以当卧室,也可以当仓库,内存也一样,地址相同的一段字节,int按4字节解读,double按8字节解读,char按1字节解读。关键是这段内存大小和解释方式都必须由类型决定。
给出验证代码:
#include <stdio.h> int main(void) { int a = 0; double d = 0.0; char c = 0; printf("int size: %zu\n", sizeof(a)); printf("double size: %zu\n", sizeof(d)); printf("char size: %zu\n", sizeof(c)); return 0; }在不同的64位Linux平台上通常输出4、8、1。标准只要求sizeof(char) == 1,其他类型大小是“至少”,所以面试题经常说int不一定是4字节,在16位平台上就是2字节。真实开发中不要假设,用sizeof去算。
类型还决定了运算时的解释方式。比如unsigned char x = 0xFF;以无符号数看是255,以有符号char看是-1。同一个二进制,换个类型解释,结果完全不同。这就是为什么后来学到底层时,要反复强调“类型即解释”。
1.3 声明、定义、初始化之间的纠葛
C语言里“声明”和“定义”经常被混着说,但它们有严格区别:
- 声明:告诉编译器“有这么个变量”,不分配内存。
- 定义:既声明了变量,也为它分配内存。
比如:
extern int global; // 声明,不分配内存 int global = 0; // 定义,分配内存并初始化新手最容易踩的坑是:在头文件里写了变量定义,然后被多个.c文件包含,链接时报重复定义。正确做法是:在头文件只写extern声明,在一个.c文件中定义。这个我后面讲到extern会再展开。
初始化与赋值更是两回事。int a = 10;是初始化,变量在创建时就被赋予初始值;int a; a = 10;是先创建未初始化变量,再给已存在的内存写值。全局变量若未初始化,则自动置0;局部变量未初始化是一个不确定值。不确定值看起来很随机,但调试器里可能看到0,换台电脑或换个优化级别又变别的数字。我见过第二次跑同样的代码,一个局部未初始化变量导致程序一会正常一会崩溃,就在这种地方。
好的习惯是所有变量都显式初始化,哪怕初始化为0。如果不希望浪费赋值,至少要意识到初始化的时机,不要把“赋值”当成“初始化”。
2. 变量的作用域与生命周期:它不是在哪都能用的
2.1 局部变量与全局变量的生死
作用域是“代码中哪个范围能访问该变量”,生命周期是“变量在内存中存在的时间”。这两者有时候交叉,但方向不同。
局部变量定义在函数或块内,作用域从定义处到块结束,生命周期通常是从进入块到离开块。全局变量定义在函数外,作用域从定义处到文件末尾,生命周期是整个程序期。
我建议初学者用“围墙”来理解:全局变量是大门外的公共区域,大家都看得到;局部变量是屋子里的东西,外面的人拿不到。但如果屋子里的变量和外门外的变量同名,在屋里访问这个名字时,编译器会选择内层定义,这就是“遮蔽”。很多bug就是遮蔽导致的,比如:
int value = 100; void func(void) { int value = 5; printf("%d\n", value); // 打印5,不是100 }想用全局变量时怎么办?你需要确保函数内没有同名局部变量,或者换个名字。全局变量命名带上g_前缀就是为了减少这种冲突。
2.2 static关键字:延长生命周期但不扩大作用域
static可能是被误解最深的关键字之一。它国内最常见的用法之一是在函数内定义静态局部变量:
#include <stdio.h> void counter(void) { static int count = 0; count++; printf("%d\n", count); } int main(void) { counter(); counter(); return 0; }count虽然定义在函数内,但它的生命周期从程序开始到程序结束,而不是每次函数调用时创建销毁。输出会依次是1、2。这个特性常用于统计函数调用次数、缓存某些需要在函数调用间保留的值。但要注意:静态局部变量只能初始化一次,第二次调用到该行时,不会重新初始化。所以你不能指望用它在函数里保存“每次清零的中间状态”。
static的另一层作用是在文件作用域,限制变量只能在本文件访问。例如:
static int internalFlag = 0;这样其他.c文件即使有extern声明也无法访问internalFlag。这是封装和信息隐藏的初阶手段,比全局变量更安全。
有些同学分不清“外部链接”和“内部链接”,简单记:默认全局变量是External Linkage,可以被别的文件通过extern引用;加了static后是Internal Linkage,只在当前文件里有效。如果你写库文件,不想暴露内部状态,就加上static。
2.3 指针变量和结构体变量的作用域同样要小心
指针变量存放的是地址,它的值(地址)本身也遵守作用域规则。函数返回局部变量的地址是经典错误:
int *func(void) { int local = 42; return &local; // 危险指针 }因为local的生命周期在函数结束时结束,返回的地址指向一块已经失效的栈内存,但指针变量的值本身还在。你拿着这个“悬空指针”去读写,结果是未定义行为。编译器可能给警告,但不会帮你检查。正确做法是返回动态分配的内存(malloc)或把值传回来。这里可以看到,指针变量的生命周期只是一块指针类型的内存,但它指向的目标的生命周期需要单独管理,两者不一定同步。
结构体变量是另一个常见对象。结构体变量本身是一个“大变量”,内部有多个成员,成员在内存中是连续存储的,这也意味着结构体变量可以整体赋值。比如:
struct Point { int x; int y; }; struct Point p1 = {1, 2}; struct Point p2 = p1; // 成员逐个拷贝这和数组不同,数组名不能直接赋值给另一个数组。很多人学到结构体后才意识到变量不只是基本类型,自定义类型也是一种“类型模板”。结构体变量同样受作用域约束,局部结构体变量生命周期也很短暂,不能从函数中返回一个局部结构体变量的“指针”,道理和局部变量一样。
3. 常量家族:字面常量、const、宏常量与枚举常量
3.1 字面常量:直接写在代码里的值
字面常量是程序里出现的最直白的东西:5、3.14、'A'、"hello"。它们没有名字,类型由写法决定。很多人注意不到这点,导致一些诡异问题。
1是int,1L是long,1LL是long long,1U是unsigned int。浮点数默认double,1.0f是float。算术运算时,如果两边类型不同,C语言会缺省进行隐式转换,可能导致溢出。比如:
int big = 2000000000; long long bigger = big * 2; // 实际发生:int溢出后才转long longbig * 2会在int域中计算,结果回绕,最后赋值给long long也没救。正确写法是big * 2LL,先把其中一操作数提升为long long。
字符常量和字符串常量是另一个常被搞混的地方。'A'是整数类型的字符常量,值通常是65;"A"是字符串常量,类型是char[2](包含结尾的\0)。所以'A' == "A"是类型和值都不匹配的比较。初学者写判断时经常少了双引号,或把双引号打成了单引号,就会出现编译警告或逻辑错误。
3.2 const修饰符:安全的“只读变量”但别把它当编译期常量
const用于把变量“锁定”为不能被直接修改。它最大的价值是让代码自解释:某些值不希望后续被意外改变,就加上const。相比宏常量,它有类型,编译器能做类型检查。
但注意:在C语言里,const int MAX = 100;不是“编译期常量”。这意味着不能直接用MAX作为数组大小:
const int MAX = 100; int arr[MAX]; // 编译错误:表达式必须含有常量值原因在于普通const变量属于运行期实体,它的值在运行时初始化。虽然你写的MAX看起来是字面量,但编译器未必能把它当作编译期常量。很多人从C++转过来,或者习惯C++做法,会在C里栽跟头。解决办法是改用宏或枚举:
#define MAX 100 int arr[MAX]; // ok enum { MAX_EN = 100 }; int arr2[MAX_EN]; // ok至于热搜词里的“指针变量const修饰”也常让人头皮发麻。记住核心:const在*左边,表示指向的对象是只读的;const在*右边,表示指针本身是只读的。例如:
const int *p; // p可变,*p不可变 int * const p; // p不可变,*p可变 const int * const p; // 两者都不可变实在记不住,就在写代码时停下来想一想:我到底希望指针本身不能改,还是指针指向的值不能改。
3.3 宏常量:预处理阶段的替身
#define MAX 100是在预处理阶段做纯文本替换。它没有类型,没有作用域,没有调试符号。这是它的灵活,也是它的麻烦。
宏常量最大的坑是边界效应。比如:
#define SQUARE(x) x * x int y = SQUARE(2 + 1);预处理后变成2 + 1 * 2 + 1,结果不是9而是5。正确写法是给整个表达式和每个参数都加括号:
#define SQUARE(x) ((x) * (x))还有宏末尾不能加分号,否则写法很奇怪。#define MAX 100;会使得int a = MAX;展开成int a = 100;;,虽然多一个分号不会编译报错,但后续使用会埋雷。
宏常量的好处是可以成为数组大小,因为它只做文本替换,不会像const那样被认定运行期实体。代价是没有类型检查,如果你写成#define MAX "100",在数组大小处编译会错误难查。所以推荐策略:简单数字用const(有类型,易于调试),需要用编译期常量做数组大小时用宏或枚举,一组有关联的常量用枚举。
3.4 枚举常量:一组有名字的整数
很多初学者忽略enum,其实在C语言中它很常用,特别是定义一组互斥的状态值:
enum Color { RED, GREEN, BLUE };默认RED是0,GREEN是1,BLUE是2。你也可以自定义值:
enum Status { OK = 0, ERROR = -1 };枚举常量是编译期常量,可以做数组大小、case标签等。避免了宏的“无类型字符串替换”问题,编译器也能给出一定范围检查。比如用enum Color作参数,比用裸整数更好。在调试器中,枚举变量能看到可读名字,而不只是数字,这也是比宏好用的地方。
4. 类型转换与变量、常量的易错细节
4.1 隐式转换:结果不对的元凶
C语言算术运算时,常发生隐式转换。原则并不复杂,简单说:把“小类型”转换成“大类型”,避免精度损失。常见链条:char/short -> int -> unsigned -> long -> long long -> float -> double -> long double,但碰上unsigned会绑架有符号类型。
一个经典面试题:
int a = -1; unsigned int b = 1; if (a > b) { printf("Yes\n"); } else { printf("No\n"); }结果会是“Yes”,因为在a > b比较时,a被转换成unsigned int,变成了一个很大的正数,当然大于1。这种细节在真实项目中非常难查。解决方案是不要混用有符号和无符号,或者显式类型转换。
还有赋值转换:char c = 300;溢出后取截断。注意大多数时候编译器不会警告,只有开了-Wconversion这类选项才会提醒。新手学到数组后用int i来遍历char数组,结果不小心用unsigned char存了256,就莫名变成了0。
4.2 强制类型转换:能解决问题,也可能埋雷
强制转换是显式告诉编译器按某类型去解释或转换。例如浮点转整型,会截断小数部分:
double pi = 3.99; int n = (int)pi; // 3这里要注意:C语言的强制转换和C++风格不同,它不检查类型合法性。比如把int*通过(double*)强转,甚至把函数指针转成对象指针,这在语法上合法,但行为可能是未定义或可移植性问题。我见过有人为了“稍微快一点”把float*强转成int*去读二进制,结果在小端大端机器上结果完全不同,最后查得头都大了。
强制转换的正确用法是:只有当数据类型确实可以合理转换时使用。例如从size_t(无符号)转换成int前,要么你确认范围内的值不会溢出,要么加上判断。不要用强转“压掉”编译器警告——这么做只是把问题推迟到运行期。
4.3 scanf、printf 中的变量和常量
C语言输入输出也是变量和常量理解的试金石。printf("%d", a)里的a是有值的,可以传常量,如printf("%d", 10)。但scanf不一样,它需要把读到的数据写回变量,所以要传变量的地址:
int num; scanf("%d", &num); // 正确 scanf("%d", num); // 错误,虽然能编译,运行崩溃&是取址运算符,&num得到一个指向num的指针。而scanf内部无论“%d”还是“%f”,都期望获得一个指针参数。结构体变量也一样,比如scanf("%s", struct_ptr->name)中的数组名本身表示首地址,所以不用加&;但scanf("%d", &struct_ptr->age)需加&。记住一个口诀:scanf要“把结果放进去”,所以传变量地址;printf只看值,所以直接给变量。
常量不能被scanf写入,例如scanf("%d", &10),这是编译错误或未定义行为。写代码时要检查传给scanf的每一项是否都是可修改内存的地址。
5. 变量与常量的常用场景速查与避坑心得
5.1 一个速查表:常见截获与修正
| 常见错误 | 错误原因 | 改法 |
|---|---|---|
#define MAX 100; | 宏替换带入分号,后续使用可能产生多余分号 | 宏定义不写分号 |
const int MAX = 100; int arr[MAX]; | 普通const不是编译期常量 | 改用宏或枚举 |
| 局部变量未初始化后直接使用 | 未定义行为,值不确定 | 显式初始化 |
| 同值同名的局部变量遮蔽全局变量 | 编译器选择内层定义 | 避免同名,或全局加g_前缀 |
| 返回局部变量地址 | 栈内存失效,悬空指针 | 用malloc或传值返回 |
int a=-1; unsigned int b=1; if(a>b) | 有符号被隐式转换为无符号 | 要么显式转换,要么避免混用 |
scanf("%d", num) | 漏写& | 写&num |
这个表我在带小组项目时经常打印出来贴在工位旁边,确实能减少一半以上低级调试时间。
5.2 我的一点实操习惯
变量与常量部分是后面指针、数组、结构体所有内容的底盘,所以我在实际开发中养成几个固定习惯:
一是所有全局变量都加g_前缀,所有静态变量都加s_前缀,函数内变量用简单小写名字。这样在长文件中扫一眼就能知道作用域范围,减少遮蔽问题。
二是常量能用const就用const,能用枚举就用枚举,除非必须做数组大小才用宏。因为宏不进入符号表,调试时看不到它的定义,只能看到替换后的字面量,不利于排查。报错信息里经常出现一堆数字而不是MAX,会让人摸不着头脑。
三是编译时开-Wall -Wextra -Werror(或至少-Wall -Wextra)。虽然不能捕获所有未初始化问题,但很多有符号和无符号比较、类型转换问题,编译器会发出警告。先解决警告再谈性能,有时候高优化级别下的怪异行为,其实就是未定义行为的另一种表现。
四是每次定义变量时都问一句:它应该放在什么作用域?生命周期要多长?是只读还是可修改?如果需要只读,加const;如果希望离开块还能用,考虑static或提升作用域;如果只是临时值,别乱用全局。养成这种习惯后,很多内存问题会在写代码时就消失。
最后说一个我踩过的坑:有个项目里我用宏定义了一个平台相关的缓冲区大小,后来另一个文件中又用const int定义了同名常量,结果编译器一会儿说“重定义”,一会儿又说“表达式必须含有常量值”。原因是宏替换作用于预处理阶段,在文本层面改了名字,而const是编译阶段的标识符,两者冲突时提示谜之错误。从那以后,我严格区分宏和const的使用场景,不在同一变量名下混用。希望读者也能从这篇里避免这种前后折腾两小时的体验。