学编程的第一个分水岭,往往不是语法背得有多熟,而是能不能把"常量、变量、表达式"这三个词从概念变成直觉。前阵子帮一个刚转行的朋友排查代码,他写了一段初始化逻辑,编译器一直报错,他自己看了半天没发现问题。我过去一瞧,代码里明明定义了一个常量 MAX_SIZE = 1024,后面却试图再给它赋一次 2048,还嘀咕"我改个值怎么就不行了"。这个场景特别典型,很多人觉得"变量嘛,不就是能变的量"、"常量嘛,就是不能变的量",可真到实际项目里,涉及编译期常量、运行期常量、指针变量、环境变量、表达式求值,乃至于各种"表达式"变体(Lambda、正则、Cron、SpEL),立刻就会乱套。
这篇文章不打算按教科书顺序念定义,而是把它们放在真实开发里讲清楚:这三样东西到底在计算机里是什么形态,为什么这么设计,实际用的时候有哪些坑。无论你是写 Java、C、Python,还是在配置 Kettle 数据抽取、调西门子博图、接触 Freeswitch 通道变量,或者折腾各种"表达式引擎",底层逻辑都是一套。看完你会有一种感受:原来绕来绕去,核心就那么点事。
1. 常量与变量的本质:内存视角的理解
1.1 变量:一块有名字的内存空间
如果你问我变量是什么,我不会说"可以改变的量",我更倾向于说:变量是编译器帮你管理的一块内存,你给它起了一个名字,之后通过这个名字去读写这块内存。比如在 C 语言里写:
c复制int age = 25;
这行代码干了三件事:在栈上划出 4 个字节(int 通常占 4 字节)的空间,给这块空间起名叫 age,往里面写入数值 25。之后你写 age = 30,本质上是告诉 CPU:"找到 age 对应的那个内存地址,把里面的数据改成 30"。
很多人初学时不理解指针变量,其实指针变量也没有多玄乎。普通变量存的是"值",指针变量存的是"另一个变量的地址"。就像你手机上存了一个联系人电话,电话号码本身也是一个"值",只不过这个值指向另一个人的联系方式。C 语言里写 int *p = &age,意思是定义一个指针变量 p,它存的内容是 age 的地址。后面操作 *p 就是在间接操作 age 本身。理解了这一层,很多"数组变量类型转换""结构体变量"的问题就迎刃而解了。
结构体变量稍微特殊一点,它不是单个值,而是多个数据的打包。比如定义一个学生结构体:
c复制struct Student {
char name[20];
int score;
};
struct Student stu1;
strcpy(stu1.name, "Tom");
stu1.score = 90;
这里的 stu1 是一块连续的内存,里面既有 name 的字节,又有 score 的字节。访问成员用 . 运算符,相当于在内存块里按偏移量取数据。这在实际工程中太常见了,尤其是做嵌入式、做数据通信时,结构体定义得好,数据解析能省一半功夫。
1.2 常量:编译期和运行期的边界
常量这个概念的坑,比很多人想象的多。首先要区分"字面量"和"常量"。"字面量"就是直接写在代码里的值,比如 1024、'A'、"Hello";"常量"则是给字面量起了一个名字,并且告诉编译器这个名字指向的值不可以被修改。在 C 里是 const,在 Java 里是 final,在 Python 里虽然没有强制常量,但约定俗成用全大写变量名表示"你不要动我"。
为什么要区分编译期常量和运行期常量?因为这件事直接关系到程序能不能跑、跑得快不快。像 C 语言里定义数组时,int arr[10] 这个 10 必须是编译期就知道的值,不能写 int n = 10; int arr[n];,除非你这个编译器支持变长数组。很多人遇到"表达式必须含有常量值"这个报错,就是因为试图用变量或者带变量的表达式来指定数组长度。编译器的逻辑很简单:数组占多大内存,必须在编译阶段就确定好,才能在生成的机器码里预留空间。你要是给一个运行时才知道的数,CPU 拿到机器码都不知道该预留多少栈空间。
Java 里有个更典型的例子,字符串常量池。你写 String a = "Hello" 和 String b = "Hello",两个引用其实指向同一块字符串常量。这个机制的前提是字符串内容不可变,JVM 才能放心地共享。如果字符串是可变的,A 改了 Hello,B 也跟着变,那不乱套了?所以设计成 final class 是有道理的。而 new String("Hello") 为什么会产生新对象?因为 new 明确要求"给我一块新的堆内存",这时候不单纯走常量池了。常量池这种设计,本质上就是"常量传播"思想在 JVM 里的一个体现——大家都能安全地复用同一个值。
"常量传播"这个词在编译优化里很常见。比如你写:
c复制const int WIDTH = 100;
int area = WIDTH * 50;
编译器在优化时会把 WIDTH 直接替换成 100,算出 area = 5000,根本不需要在运行时去读一次常量的值。这就是常量传播。它之所以能放心大胆地传播,是因为常量在生命周期内不可能改变,编译器怎么替换都不会出错。
1.3 变量定义的各种形态:别被"变量"两个字带偏
"变量"这个词在不同语言和不同场景下的具体形态差异很大,容易让人困惑。盘点一下常见的几种:
- 局部变量:函数内部定义,函数一结束,内存就回收。
- 全局变量:程序启动时分配,程序终止才释放。
- 静态变量(static):在 C 里,static 局部变量会提升生命周期,函数调用结束后依然保留值;在 Java 里,static 成员变量属于类而不是对象。
- 成员变量(实例变量):属于某个对象,对象销毁,变量跟着销毁。
- 环境变量:操作系统级别的"全局变量",任何进程启动时都能读到。
- 系统变量 / 用户变量:Windows 下分两种,系统变量对所有用户生效,用户变量只对当前用户生效。
比如你在 win10/win11 系统里配置 Maven,最常见的问题就是"配了 JAVA_HOME 怎么还是找不到命令"。原因多半是你把环境变量加到了用户变量里,而命令行窗口是管理员权限打开的,或者新开的终端没有重新加载环境变量。还有一个坑:如果"用
