跳转到内容

第 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 没有常量关键字。全大写的名字只是约定,typing.Final 只给类型检查器看,运行时照样能重新赋值。真正挡住修改的是不可变类型:int、str、tuple、frozenset,以及加了 frozen=True 的 dataclass。

from dataclasses import FrozenInstanceError, dataclass, replace
from 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) # 新建一个对象,只换 timeout
show("默认", 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 的 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# 的手段都是原生的: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 有 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> 修改,真正防住要返回拷贝或不可变集合。

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 的无类型常量到使用处才定类型。
  • 四种语言“复制一份改一处”的惯用写法都是浅复制,嵌套的集合要另外处理。