第 4 章 · 变量、常量与可变性
解决什么问题:让程序说清楚哪些东西会变、哪些不会变,避免“以为复制了一份,其实改的是同一份”。
四种语言的态度:Python 和 JS/TS 的变量是指向对象的名字,不可变性靠数据类型;C# 和 Go 有值类型(value type),赋值就是复制,再加上编译期常量。
服务启动时有一份默认配置:timeout=30、retries=3。某个调用方要临时把 timeout 改成 5。要求生成一份新配置交给它,默认配置不能被改动,因为别的调用方还在用。程序打印默认配置、覆盖后的配置,最后再看一眼默认配置是否原样未动。
“变量”在不同语言里指两种不同的东西。一种是名字(name):变量本身不装数据,只是绑定(binding)到某个对象,b = a 让两个名字指向同一个对象。另一种是存储(storage):变量就是一块装着值的内存,b = a 把整块内容复制过去,从此两边互不相干。
看两栏里赋值之后发生了什么:左栏 custom 和 defaults 两个箭头指向同一个对象,改 custom.timeout 等于改 defaults;右栏赋值时整块复制,改 custom 只动它自己那一块。
由此要分清两件事。绑定不可变(immutable binding)是说这个名字不能再指向别的东西;值不可变(immutable value)是说对象本身的内容不能改。前者不保证后者:一个不能重新赋值的名字,照样可以指向一个内容随时被改的对象。
常量也分两种。编译期常量(compile-time constant)的值在编译时就确定,编译器可以直接把它写进用到它的每一处;运行期只读(read-only)的值要等程序跑起来才算出来,之后不许再改。前者只能是数字、字符串这类简单值,后者可以是任何对象。
Python 怎么做
Section titled “Python 怎么做”Python 没有常量关键字。全大写的名字只是约定,typing.Final 只给类型检查器看,运行时照样能重新赋值。真正挡住修改的是不可变类型:int、str、tuple、frozenset,以及加了 frozen=True 的 dataclass。
from dataclasses import FrozenInstanceError, dataclass, replacefrom typing import Final
@dataclass(frozen=True)class Config: timeout: int retries: int
DEFAULTS: Final = Config(timeout=30, retries=3) # Final 只给类型检查器看
def show(label: str, c: Config) -> None: print(f"{label}:timeout={c.timeout} retries={c.retries}")
custom = replace(DEFAULTS, timeout=5) # 新建一个对象,只换 timeoutshow("默认", DEFAULTS)show("覆盖后", custom)
try: DEFAULTS.timeout = 5 # type: ignore[misc]except FrozenInstanceError: # 冻结的 dataclass 在运行时拒绝赋值,原值不变 print(f"默认未被改动:timeout={DEFAULTS.timeout}")
# 输出:# 默认:timeout=30 retries=3# 覆盖后:timeout=5 retries=3# 默认未被改动:timeout=30运行:python config.py。dataclasses.replace 按原对象的字段值调用构造函数,再换掉指定的字段,得到一个新对象。如果用普通 dict 存配置,custom = DEFAULTS 只是多了一个名字,要写 {**DEFAULTS, "timeout": 5} 或 DEFAULTS.copy() 才是新对象。
底层怎么实现。 CPython 里每个变量都是一个指向 PyObject 的指针:局部变量存在帧(frame)的一个数组里,全局变量存在模块的字典里。赋值只是把指针写进去、给对象的引用计数加一,从不复制对象。tuple 不可变,是因为它的类型根本没提供修改元素的操作;冻结的 dataclass 则由装饰器生成 __setattr__ 和 __delattr__,一被调用就抛 FrozenInstanceError,用 object.__setattr__ 仍能绕过去。
- 可变默认参数:
def f(items=[])的列表在定义函数时只创建一次,每次调用共用同一个,第 7 章细讲。 - 不可变只到第一层:元组里装着列表,列表照样能改,这个元组也因此不能当字典的键。
replace和dict.copy()都是浅复制(shallow copy),嵌套的列表、字典仍是两份配置共用的。
JS/TS 怎么做
Section titled “JS/TS 怎么做”JS 的 let 和 const 都是原生的,但 const 只冻结绑定,不冻结对象。冻结对象要靠 Object.freeze,它只冻一层。TS 加了 readonly 和 as const,只在编译期检查,编译后就抹掉了。
type Config = { readonly timeout: number; readonly retries: number };
const DEFAULTS: Config = Object.freeze({ timeout: 30, retries: 3 });
function withTimeout(base: Config, timeout: number): Config { return { ...base, timeout }; // 展开成一个新对象,base 不动}
function show(label: string, c: Config): void { console.log(`${label}:timeout=${c.timeout} retries=${c.retries}`);}
const custom = withTimeout(DEFAULTS, 5);show("默认", DEFAULTS);show("覆盖后", custom);// DEFAULTS.timeout = 5; // 编译错误:timeout 是只读属性console.log(`默认未被改动:timeout=${DEFAULTS.timeout}`);
// 输出:// 默认:timeout=30 retries=3// 覆盖后:timeout=5 retries=3// 默认未被改动:timeout=30运行:tsx config.ts。对象展开 { ...base, timeout } 是 JS 里“复制一份改一处”的惯用写法,也是浅复制;要深复制可以用 structuredClone。
底层怎么实现。 const 是对绑定的约束:对它赋值在运行时抛 TypeError,TS 在编译时就报错。Object.freeze 把对象标记为不可扩展,把数据属性改成不可写,所有属性改成不可配置;访问器属性的 setter 照样能调用。V8 为此给对象换一个新的隐藏类(hidden class)。之后对数据属性的写入在严格模式(strict mode)下抛 TypeError,在非严格模式下静默忽略。属性值如果本身是对象,freeze 不会递归进去。
const obj = {}之后obj.timeout = 5完全合法,const管不到属性。readonly不参与赋值兼容性检查:Readonly<Config>可以直接赋给可变的Config变量,再通过它改掉,编译器不报错。- 冻结后的写入是否报错取决于严格模式:ES 模块和
class体默认严格;CommonJS 和老式脚本不写"use strict"就不严格,写入不报错,数据也没变。tsx x.ts算哪种,要看package.json的type字段。
C# 怎么做
Section titled “C# 怎么做”C# 的手段都是原生的:const 是编译期常量,readonly 字段只能在声明或构造函数里赋值,init 访问器让属性只能在创建对象时设置,record 的 with 表达式复制一份并改掉指定的属性。另外,struct 是值类型,赋值即复制。
using System;
var custom = Defaults.Value with { Timeout = 5 }; // 复制一份,只改 Timeout
Show("默认", Defaults.Value);Show("覆盖后", custom);// Defaults.Value.Timeout = 5; // 编译错误:init 属性只能在创建时赋值Console.WriteLine($"默认未被改动:timeout={Defaults.Value.Timeout}");
static void Show(string label, Config c) => Console.WriteLine($"{label}:timeout={c.Timeout} retries={c.Retries}");
record Config(int Timeout, int Retries); // 位置记录:属性只有 get 和 init
static class Defaults{ public const int Timeout = 30; // 编译期常量 public static readonly Config Value = new(Timeout, 3); // 运行期只读:字段不能再指向别的对象}
// 输出:// 默认:timeout=30 retries=3// 覆盖后:timeout=5 retries=3// 默认未被改动:timeout=30运行:dotnet run config.cs。readonly 冻结的是字段这个绑定:Value 不能再指向别的 Config,但如果 Config 是有可写属性的类,属性照样能改。这里不可变靠的是 record 的 init 属性。
底层怎么实现。 const 不占运行期存储:编译器把字面值直接写进每个用到它的地方,Defaults.Timeout 在生成的 IL 里就是一条压入 30 的指令,跨程序集(assembly)引用时也一样。readonly 字段在 IL 里带 initonly 标记,构造函数以外的赋值由编译器拒绝。init 是一个带特殊标记的 setter,编译器只允许在对象初始化时调用它。with 表达式被改写成:调用编译器生成的克隆方法(内部走复制构造函数,逐个字段复制),再对新对象调用 init setter。
- 库里改了
const的值,引用它的程序集不重新编译就仍然用旧值,因为旧值早已写进它们的 IL。会变的值用static readonly。 with是浅复制:record里有个List<string>属性,新旧两份共用同一个列表。readonly List<int>只是不能换成另一个列表,Add照样能调用;要让调用方改不了,用IReadOnlyList<T>或不可变集合。
Go 怎么做
Section titled “Go 怎么做”Go 有 var 和短变量声明 :=,const 只能是编译期就能算出的布尔值、数字和字符串。没有只读字段,也没有只读结构体;Go 靠两样东西隔离:结构体赋值和传参都是整块复制,不导出(小写开头)的字段包外改不了。
package main
import "fmt"
const defaultTimeout = 30 // 无类型常量,编译期就确定
type Config struct { Timeout int Retries int}
func withTimeout(base Config, timeout int) Config { base.Timeout = timeout // base 是调用方那份的拷贝,改它不影响原值 return base}
func show(label string, c Config) { fmt.Printf("%s:timeout=%d retries=%d\n", label, c.Timeout, c.Retries)}
func main() { defaults := Config{Timeout: defaultTimeout, Retries: 3} custom := withTimeout(defaults, 5) show("默认", defaults) show("覆盖后", custom) fmt.Printf("默认未被改动:timeout=%d\n", defaults.Timeout)}
// 输出:// 默认:timeout=30 retries=3// 覆盖后:timeout=5 retries=3// 默认未被改动:timeout=30运行:go run config.go。withTimeout 直接改参数再返回,在 Go 里是安全的惯用写法,因为参数本来就是一份拷贝。defaultTimeout 是无类型常量(untyped constant),用在 int、float64 的位置都行,到用的地方才确定类型。
底层怎么实现。 常量只存在于编译期,规范要求编译器至少用 256 位表示整数常量,所以 1 << 100 这样的中间结果算得出来,只要最终用到的值放得进目标类型。变量是真正的存储:编译器做逃逸分析(escape analysis),决定它放在栈上还是堆上;结构体赋值就是按字节复制整块内存。但切片、映射、指针这些字段复制的只是引用,底层数据仍然共用。
if或for里的:=会新建一个同名变量,遮蔽(shadow)外层的那个;在里面赋的值出了块就没了,默认的go vet也不报。- 结构体里有切片或映射时,复制结构体不等于复制数据,要用
slices.Clone、maps.Clone再复制一层。 - 方法的接收者写成值类型,方法里的修改只作用在拷贝上,调用方看不到;要修改就用指针接收者。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生不可变类型 + 标准库(dataclass、Final) |
原生(const、Object.freeze、readonly) |
原生 | 原生(const),无只读字段 |
| 实现机制 | 名字指向对象 + 不可变类型 | 名字指向对象 + 冻结对象 | 引用类型(reference type) + 值类型,常量内联 | 值语义的存储 |
| 绑定不可变 | 没有,Final 只供检查器 |
const |
readonly 字段、init |
只有 const |
| 值不可变 | tuple、frozenset、冻结的 dataclass |
Object.freeze(浅)、readonly(编译期) |
record、init、readonly struct |
没有,靠拷贝和不导出字段 |
| 编译期常量 | 没有 | 没有 | const,内联进调用方 |
const,可无类型 |
| 复制一份改一处 | replace(c, timeout=5) |
{ ...c, timeout: 5 } |
c with { Timeout = 5 } |
d := c; d.Timeout = 5 |
差别最大的是“实现机制”一行:前两种语言里对象赋值永远是共享,C# 看类型,Go 的结构体赋值永远是复制。
Python 和 JS 里几乎一切都是堆上的对象,变量只能是指向对象的名字,语言没法靠赋值规则隔离数据,只能把不可变做成数据类型的性质:str、tuple 天生不可变,Object.freeze 事后把对象锁住。两者都是动态语言,语言层面没有编译期常量;TS 补上的 readonly 只存在于类型层面,运行时看不见。
C# 和 Go 从静态编译和值类型出发。Go 的结构体、数组,C# 的 struct 都直接装着数据,赋值就是复制,隔离是语义的一部分,不需要额外的关键字。C# 同时有大量引用类型,所以又补了 readonly、init、record 一整套。Go 的设计者追求概念少,只留下编译期常量,其余交给拷贝和包级可见性。编译期常量能被内联,换来的是零运行期开销,代价就是上面说的“改了不重编还是旧值”。
后果之一在并发。不可变对象可以被多个线程随意共享,不需要锁;可变对象只要被两个线程同时访问就要加锁。Python 线程之间、JS 同一线程内的异步回调之间共享的是同一个对象,要靠不可变类型保平安;Go 把结构体按值传给 goroutine,各自拿到拷贝,可一旦里面有映射或切片,共享就又回来了。后果之二在 API:返回内部集合时,Python、JS、Go 交出去的都是同一份数据,调用方能改到你的内部状态,所以要么返回拷贝,要么返回元组、冻结数组;C# 可以把返回类型声明成 IReadOnlyList<T>,但调用方仍可能把它转回 List<T> 修改,真正防住要返回拷贝或不可变集合。
旁注:别的语言
Section titled “旁注:别的语言”Rust 把方向反了过来:变量默认不可变,要改必须写 let mut;而且同一时刻要么有多个只读引用,要么只有一个可写引用,编译器保证不会出现“一边读一边被别人改”。
let defaults = Config { timeout: 30, retries: 3 };let custom = Config { timeout: 5, ..defaults }; // 结构体更新语法// defaults.timeout = 5; // 编译错误:defaults 不是 mut- 配置、消息这类数据默认做成不可变:Python 用冻结的
dataclass,TS 用readonly加Object.freeze,C# 用record,Go 用不导出字段加只读方法。 - “复制一份改一处”用语言的惯用写法:
replace、对象展开、with、值拷贝,别先赋值再改。 - 记住四种惯用写法都是浅复制,嵌套的集合要单独复制。
- C# 里会随版本变化的值别用
const,用static readonly。
配置里再加一个主机列表 hosts,默认是 a、b。写一个函数,接收一份配置,返回在 hosts 末尾多加一个 c 的新配置,然后打印原配置和新配置的 hosts 两行,证明原配置没变。用 JS 和 Go 各实现一遍。
提示:对象展开和结构体赋值都只复制一层,hosts 要怎么处理?Go 里直接 append 到 base.Hosts 上,原配置打印出来照样是 a,b,为什么这还不能证明两份配置没有共用数据?
- 变量要么是指向对象的名字(Python、JS、C# 引用类型),要么是装着值的存储(Go 结构体、C# 值类型),赋值时前者共享、后者复制。
- 绑定不可变不等于值不可变:
const和readonly字段管的是名字,对象内容要靠不可变类型或冻结。 - 编译期常量会被写进用到它的地方,C# 的
const改了要重新编译调用方;Go 的无类型常量到使用处才定类型。 - 四种语言“复制一份改一处”的惯用写法都是浅复制,嵌套的集合要另外处理。