不少刚入门的开发者在学习常量、变量和表达式时,都觉得这是最没有技术含量的一课,背完定义就完事了。但这些年我泡在技术社区里见过太多奇怪的问题,绕到最后,根子全在这几个基础概念上:Java Bean里一个首字母大写的属性名,JSON序列化后为什么会悄悄变小写?C语言里一个数组参数传进函数之后,sizeof的结果为什么不是原来的长度?C#里明明定义了一个常量字符串,传给某个特性时报错“表达式必须含有常量值”,这又是什么道理?
这些问题的解题思路,翻来覆去还是得回到最基础的三个词:常量、变量和表达式。这篇文章我就从这三个概念出发,把它们的底层逻辑和常见误用讲透,同时串起几个真实场景:序列化命名、环境变量、cron表达式、ETL参数替换、PLC端口赋值、C#属性变更通知等。如果你是刚学编程的初学者,能借此把地基打牢;如果你已经工作两三年,应该也能从里面找到一些“当年百思不得其解”的答案。
1. 先搞清楚:常量、变量和表达式到底在“学什么”
1.1 用内存视角看,三者的边界一目了然
学编程的人基本都背过定义:变量是程序运行过程中值可以改变的量,常量是值不可改变的量,表达式是用运算符把变量和常量连接起来形成的式子。背归背,真正理解它们,得从内存视角来看。
变量本质上是内存里一块“有名字”的存储空间。定义一个变量,相当于向编译器申请一块区域,并给它贴上一个标签。后续代码通过这个名字往里面写数据、读数据。常量则是一块“不允许改写”的数据区,或者说在语言语义上不允许对它做赋值操作。
我们写三段代码来对比:
c复制const int MAX_SIZE = 100; // C语言:const 修饰的常量,不可修改
int count = 0; // 普通变量
int result = count * 2 + MAX_SIZE; // 表达式
python复制MAX_SIZE = 100 # Python 约定:全大写表示“别改它”,但语法上可改
count = 0 # 变量
result = count * 2 + MAX_SIZE # 表达式
java复制final int MAX_SIZE = 100; // Java:final 修饰的常量
int count = 0;
int result = count * 2 + MAX_SIZE;
如果你去反汇编编译后的代码,会发现很多“常量”并不一定存储在内存里,而是被编译器直接内联到指令里了。比如上面三份代码里,当编译器看到 count * 2 + MAX_SIZE,且 MAX_SIZE 是编译期能确定的常量,它可能直接翻译成“先做 count 乘以 2,再加上 100”的机器指令,根本不用去内存加载。
这也是一个非常重要的初学认知:从语义上区分常量变量,不代表它们在底层的存储位置一定不同。真正决定它们区别的,是“能不能被程序逻辑修改”这一语义约束。
1.2 “都是常量”,为什么有 final、const、readonly、宏这么多说法
很多初学者会疑惑:怎么一门语言里就有好几种“常量”写法?这其实是不同语言在不同场景下对“不可变”语义做的细化。我用一张表把常见写法拎出来对比:
| 写法 | 代表语言 | 常量性质 | 典型问题 |
|---|---|---|---|
| final 修饰字段 | Java | 初始化后不可再赋值 | 引用类型只是引用不可变,对象内部状态仍可变 |
| const 修饰变量 | C/C++ | 编译期约定期限不可通过该变量修改 | #define 宏在预处理阶段做文本替换,没有类型检查 |
| const 修饰字段 | C# | 编译期常量,必须编译时可确定值 | 无法把 const 变量传给运行时才确定值的场景 |
| static readonly | C# | 运行时只读变量 | 赋值时机更灵活,但语义上不是编译期常量 |
| const / final | Dart、JS等 | 不可重新赋值 | 细节各异,别跨语言套用 |
| 模块级全大写 | Python | 只是约定,不强制 | 任何人 import 后仍可改,只靠自觉 |
这里专门提一个高发错误区——C语言的宏。很多人把宏当成常量来理解:
c复制#define MAX_SIZE 1 + 2
int result = MAX_SIZE * 2; // 你以为等于 6,实际是 1 + 2 * 2 = 5
原因很简单,宏是在预处理阶段做纯文本替换。MAX_SIZE * 2 在预处理后展开成 1 + 2 * 2,运算符优先级决定了它是 5 而不是 6。遇到这种场景,正确写法是给宏加括号:#define MAX_SIZE (1 + 2)。对比之下,C语言里 const int MAX_SIZE = 3; 由于有类型,有作用域,就不会出现这类文本替换引发的翻车。
编译器在做优化时,还会做一件与常量相关的事:常量传播。比如代码里给变量赋了一个固定值,并且在后续流程中都没有修改它,编译器可能把这个变量直接当成常量处理,代入后续计算。这也是为什么很多优化选项会建议你尽量写“只读”语义,帮助编译器做更激进的优化。
1.3 字符串常量池、运行期常量与内联机制
热搜里经常看到“java类存字符串常量”“表达式必须含有常量值”这类问题,它们都涉及Java和C#的常量机制差异。
Java里,一个字符串字面量会进入字符串常量池(String Pool)。例如:
java复制String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); // 大多数情况下输出 true,因为来自常量池同一对象
这里的底层逻辑是:字符串常量池把编译期可以确定的字符串实例缓存起来,避免重复创建对象。但要注意,这不是说只要字符串内容相同,就一定会复用,只有通过字面量或编译期常量表达式拼接出来的字符串才进池子。new String("abc") 这种写法就会在堆上新建一个对象,比较时用 == 就可能是 false。所以比较字符串值,永远推荐 equals(),别拿 == 去赌底层实现。
再来看C#里“表达式必须含有常量值”的报错。这个错误在给Attribute传参时特别常见。先记住一个关键差异:
csharp复制public const string A = "abc";
