第 22 章 · 装饰器、特性、结构标签与反射
解决什么问题:把规则直接写在类型定义上,让一段通用代码在运行时读出规则,处理任意对象。
四种语言的态度:Python 和 C# 原生支持注解和反射,框架大量依赖它们;TS 的装饰器靠转译,运行时看不到类型;Go 只给字符串标签和
reflect,更鼓励代码生成。
用户注册要校验两个字段:name 不能为空,age 必须在 0 到 150 之间。规则不写在一堆 if 里,而是直接标在类型定义的字段上。再写一个通用的 validate 函数,它事先不知道要检查哪种类型,运行时把字段和规则读出来逐个检查。检查两个用户:name=""、age=200 和 name="ann"、age=30。错误按字段名排序后用”;“连接,不依赖反射返回字段的顺序。
元编程(metaprogramming)是“程序处理程序”:代码的处理对象不是业务数据,而是类型、函数、字段这些程序本身的组成部分。校验、序列化、依赖注入、Web 路由的库都面对同一个问题:写库的人不知道用户会定义什么类型,又要对每个字段做事。办法是让用户在源码里附上元数据(metadata),即注解;库再在某个时刻把元数据读出来。
关键在两个问题:元数据写在哪里,什么时候被读。编译期读,由编译器或代码生成器直接产出代码;定义时读,类或函数一创建就执行附在上面的代码;运行时读,靠反射(reflection)查看一个值属于什么类型、有哪些成员、成员上挂着什么注解。
每一行从左往右是同一条 0 到 150 的范围规则的一生,Go 写成标签字符串:四行都把规则带进了运行时,只有 TS 那行的类型信息在编译时丢了。
“装饰器”一词有两种含义,不要混为一谈。语法层面的装饰器是把函数或类“包一层”的写法,@d 写在定义上方;设计模式里的装饰器模式(Decorator pattern)是一种对象结构:包装对象和被包装对象实现同一接口,运行时可以层层叠加。前者是语言特性,后者是设计手法,可以用前者实现,也完全可以不用。
反射要在运行时查元数据、做动态调用,比直接写出的代码慢。另一条路是编译期代码生成:照着注解生成普通代码,运行时不再反射。
Python 怎么做
Section titled “Python 怎么做”Python 原生支持:装饰器是语法,typing.Annotated 能把任意对象附在类型标注上,typing.get_type_hints 和 dataclasses.fields 把它们读出来。
from dataclasses import dataclass, fieldsfrom typing import Annotated, get_type_hints
@dataclass(frozen=True)class NotEmpty: def check(self, value: object) -> str | None: return "不能为空" if value == "" else None
@dataclass(frozen=True)class Range: min: int max: int
def check(self, value: object) -> str | None: ok = isinstance(value, int) and self.min <= value <= self.max return None if ok else f"必须在 {self.min} 到 {self.max} 之间"
@dataclassclass User: name: Annotated[str, NotEmpty()] age: Annotated[int, Range(0, 150)]
def validate(obj: object) -> list[str]: hints = get_type_hints(type(obj), include_extras=True) # 不加 include_extras,元数据会被剥掉 errors = [] for f in fields(obj): for rule in getattr(hints[f.name], "__metadata__", ()): if (msg := rule.check(getattr(obj, f.name))) is not None: errors.append(f"{f.name} {msg}") return sorted(errors)
for i, user in enumerate([User("", 200), User("ann", 30)], start=1): errors = validate(user) print(f"第 {i} 个用户:{';'.join(errors) if errors else '通过'}")
# 输出:# 第 1 个用户:age 必须在 0 到 150 之间;name 不能为空# 第 2 个用户:通过运行:python validate.py。Annotated[int, Range(0, 150)] 对类型检查器来说就是 int,Range 对象原样存着,由 get_type_hints(..., include_extras=True) 取出放进 __metadata__。@dataclass 本身是个类装饰器:它读类上的标注,生成 __init__、__repr__ 等方法后返回同一个类;fields 按定义顺序列出字段。
底层怎么实现。 装饰器就是定义时执行的高阶函数:@d 加 def f 等于先定义 f,再执行 f = d(f),结果可以是原函数,也可以是一个包装函数。包装函数会丢掉原函数的 __name__ 和文档,所以写包装时要加 functools.wraps。类标注到 3.13 为止在类体执行时求值,3.14 起(PEP 649/749)推迟到第一次被读取时,本例里就是 @dataclass 读它的时候。类、函数、标注都是运行时对象,反射就是读它们的属性。
- 多个装饰器从下往上套:
@a叠在@b上等于f = a(b(f)),b先套上,调用时a的包装先执行。@timer写在@cache上面,计时包住的是缓存,命中时测到的几乎为零;反过来写,命中时根本不计时。 - 标注里的元数据没人读就不起作用:
User("", 200)照样构造成功,规则只在调用validate时才生效。
JS/TS 怎么做
Section titled “JS/TS 怎么做”TS 5.0 起实现了 TC39 的装饰器提案(TS 称为标准装饰器),可以加在类、方法、访问器和字段上。这个提案目前处于第 2.7 阶段,还不是 ECMAScript 的一部分,主流引擎都还没有原生实现,Node 22 直接运行会报 SyntaxError: Invalid or unexpected token;本书用的 tsx 底层是 esbuild,会把装饰器转译成普通函数调用。
type Check = (value: unknown) => string | null; // 不通过时返回错误说明const rules = new WeakMap<object, Map<string, Check[]>>();
function rule(check: Check) { return function <T>(_: undefined, context: ClassFieldDecoratorContext<T>) { context.addInitializer(function (this: T) { // 每次 new 都为这个实例登记一次 const byField = rules.get(this as object) ?? new Map<string, Check[]>(); const name = String(context.name); byField.set(name, [...(byField.get(name) ?? []), check]); rules.set(this as object, byField); }); };}const notEmpty = rule((v) => (v === "" ? "不能为空" : null));const range = (min: number, max: number) => rule((v) => (typeof v === "number" && v >= min && v <= max ? null : `必须在 ${min} 到 ${max} 之间`));
class User { @notEmpty name: string; @range(0, 150) age: number; constructor(name: string, age: number) { this.name = name; this.age = age; }}
function validate(obj: object): string[] { const errors: string[] = []; for (const [field, checks] of rules.get(obj) ?? []) { const value = (obj as Record<string, unknown>)[field]; for (const check of checks) { const msg = check(value); if (msg) errors.push(`${field} ${msg}`); } } return errors.sort();}
[new User("", 200), new User("ann", 30)].forEach((u, i) => { const errors = validate(u); console.log(`第 ${i + 1} 个用户:${errors.length ? errors.join(";") : "通过"}`);});
// 输出:// 第 1 个用户:age 必须在 0 到 150 之间;name 不能为空// 第 2 个用户:通过运行:tsx validate.ts。字段装饰器拿不到类,只拿到一个 context,里面有字段名;代码用 addInitializer 在每个实例构造时把规则登记进 WeakMap。提案里的 context.metadata 能把规则挂在类上,但要靠 Symbol.metadata,Node 22 没有它,tsc 在 es2022 库下也不认,所以本例不用。
底层怎么实现。 转译后,装饰器就是定义类时按顺序调用的函数,@range(0, 150) 先求值得到装饰器,再以 undefined 和上下文对象(里面有字段名)为参数调用它。类型在编译时全部擦除,运行时的反射只能看值:Object.keys 列出自有属性,Reflect 提供属性读写的统一接口,age: number 里的 number 在 JS 里根本不存在,这也是规则必须写成装饰器而不能从类型推出来的原因。
- 两套装饰器语义不同:打开
experimentalDecorators后 TS 用旧语义,字段装饰器收到的是原型和属性名,没有context;同一个装饰器库只能按其中一套写,不能混用。 - 以为能在运行时读到 TS 类型:旧方案靠
emitDecoratorMetadata加第三方的reflect-metadata才拿到一点类型名,标准装饰器不支持这个选项。
C# 怎么做
Section titled “C# 怎么做”C# 原生支持:特性(attribute)从 1.0 起就有,反射由 System.Reflection 提供;System.ComponentModel.DataAnnotations 里有现成的 Required 和 Range,单文件 dotnet run 不加任何包引用就能用。
using System;using System.Collections.Generic;using System.ComponentModel.DataAnnotations;using System.Diagnostics.CodeAnalysis;using System.Linq;using System.Reflection;
User[] users = [new("", 200), new("ann", 30)];for (var i = 0; i < users.Length; i++){ var errors = Validate(users[i]); Console.WriteLine($"第 {i + 1} 个用户:{(errors.Count > 0 ? string.Join(";", errors) : "通过")}");}
// 标注告诉裁剪器:T 的公共属性要留着,反射会用到static List<string> Validate<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(T obj){ var errors = new List<string>(); foreach (var prop in typeof(T).GetProperties()) // 反射:运行时列出属性 { var field = prop.Name.ToLowerInvariant(); foreach (var rule in prop.GetCustomAttributes<ValidationAttribute>()) // 读出编译进元数据的特性 { if (!rule.IsValid(prop.GetValue(obj))) errors.Add(rule.FormatErrorMessage(field)); } } return errors.Order(StringComparer.Ordinal).ToList();}
class User(string name, int age){ [Required(ErrorMessage = "{0} 不能为空")] public string Name { get; } = name;
[Range(0, 150, ErrorMessage = "{0} 必须在 {1} 到 {2} 之间")] public int Age { get; } = age;}
// 输出:// 第 1 个用户:age 必须在 0 到 150 之间;name 不能为空// 第 2 个用户:通过运行:dotnet run validate.cs。特性就是派生自 System.Attribute 的类,[Range(0, 150, ...)] 是对它构造函数和属性的一次“预约”。GetCustomAttributes<T> 读出所有特性,GetCustomAttribute<T> 读一个。Required 默认不接受空串,所以 "" 不通过。
底层怎么实现。 编译器把特性的类型和构造参数序列化进程序集的元数据表,和类型、方法的定义放在一起。运行时调用 GetCustomAttributes 时,CLR 才反序列化参数、真正构造出特性对象,每次调用都构造新的。编译期的替代是源生成器(source generator,C# 9 起):编译时分析带特性的类型,直接生成校验代码,运行时不再反射。
- 反射慢:每次
GetProperties、GetCustomAttributes都要查元数据、分配对象,放在请求热路径上要按类型缓存结果。 - 裁剪(trimming)和 Native AOT 会删掉看起来没用的成员,反射就找不到它们。.NET 10 的单文件程序默认按 AOT 发布做分析:把
typeof(T)换成obj.GetType(),dotnet run就会打出 IL2075 警告;代码里的DynamicallyAccessedMembers正是告诉裁剪器要保留什么。
Go 怎么做
Section titled “Go 怎么做”Go 没有注解,也没有装饰器语法。它给的是结构标签(struct tag):附在字段上的一段字符串,由 reflect 包在运行时读取,encoding/json 用的就是它。
package main
import ( "fmt" "reflect" "sort" "strings")
type User struct { Name string `validate:"nonempty" json:"name"` Age int `validate:"range=0..150" json:"age"`}
// validate 用反射遍历任意结构体的字段,按 validate 标签检查func validate(obj any) []string { var errors []string v := reflect.ValueOf(obj) t := v.Type() for i := range t.NumField() { field := t.Field(i) name := field.Tag.Get("json") // 报错用 json 标签里的小写名字 rule := field.Tag.Get("validate") switch value := v.Field(i); { case rule == "nonempty" && value.String() == "": errors = append(errors, name+" 不能为空") case strings.HasPrefix(rule, "range="): var lo, hi int64 fmt.Sscanf(rule, "range=%d..%d", &lo, &hi) if n := value.Int(); n < lo || n > hi { errors = append(errors, fmt.Sprintf("%s 必须在 %d 到 %d 之间", name, lo, hi)) } } } sort.Strings(errors) return errors}
func main() { for i, u := range []User{{"", 200}, {"ann", 30}} { result := "通过" if errors := validate(u); len(errors) > 0 { result = strings.Join(errors, ";") } fmt.Printf("第 %d 个用户:%s\n", i+1, result) }}
// 输出:// 第 1 个用户:age 必须在 0 到 150 之间;name 不能为空// 第 2 个用户:通过运行:go run validate.go。标签的约定格式是 key:"value" 用空格隔开,reflect.StructTag.Get 按这个格式取值;值里写什么完全由读它的库决定,range=0..150 要自己解析。需要“包一层”函数时,Go 写一个接收函数、返回函数的高阶函数,HTTP 中间件就是这样串起来的。
底层怎么实现。 编译器为每个类型生成一份运行时类型描述,标签字符串原样存在字段描述里,reflect.TypeOf 返回的就是指向这份描述的接口。不想在运行时反射的项目用 go generate:它执行源码里 //go:generate 注释写的命令,生成普通 Go 代码。它不会在 go build 时自动运行,生成的文件要提交进仓库,和其余代码一起编译。
- 标签写错时静默忽略:把
validate:"nonempty"写成validate:nonempty,Get返回空串,规则等于没写,程序照常运行;只有go vet的 structtag 检查会报bad syntax for struct tag value。 - 未导出字段受限:字段名小写时,
String()、Int()仍能读出值,但Interface()直接 panic,CanSet()为假、Set会 panic,encoding/json因此跳过小写字段。
| 维度 | Python | JS/TS | C# | Go |
|---|---|---|---|---|
| 支持程度 | 原生 | TS 语法支持;JS 尚在提案,需转译 | 原生 | 没有注解,有标签和反射 |
| 实现机制 | 定义时执行的高阶函数 + 运行时对象 | 转译成函数调用 | 程序集元数据 + CLR 反射 | 类型描述里的字符串 + reflect |
| 元数据写在哪 | Annotated[...]、@d |
@d |
[Attr] |
字段后的标签字符串 |
| 什么时候读 | 定义时、运行时 | 定义类时、构造时 | 运行时 | 运行时 |
| 函数装饰器 | @d |
只能装饰类和类成员 | 没有,用特性加框架 | 没有,用高阶函数 |
| 运行时能看到类型吗 | 能 | 不能,类型已擦除 | 能 | 能 |
| 编译期替代方案 | 定义时生成代码(@dataclass) |
转译器插件或代码生成脚本 | 源生成器 | go generate |
差别最大的是“运行时能看到类型吗”一行:TS 只能把规则写成装饰器里的值,另外三种语言都能从类型本身出发找到规则。
Python 里一切都是运行时对象:类、函数、标注在定义时被创建、被求值,装饰器只是“拿到对象、换一个对象”,自然成立,反射也就是读属性。C# 的元数据是 CLR 的一等公民:程序集从一开始就带着完整的类型和特性表,反射和特性从 1.0 就在,框架顺势把路由、校验、序列化都建在特性上。
TS 的类型只活在编译期。它的设计目标是编译成普通 JS,运行时不留任何类型信息,装饰器能拿到的只有值和名字。装饰器本身至今仍是提案,TS 先按旧草案做了 experimentalDecorators,提案大改后又按新版另做一套,于是出现两套语义。
Go 刻意保持简单:没有注解,不让一行声明在背后触发任意代码,只给字符串标签和 reflect。复杂的元编程交给代码生成,生成出来的是看得见、能调试的普通代码。
后果之一是框架风格:Python 和 C# 的框架大量用注解,写起来短,行为却藏在框架读注解的代码里;Go 的框架靠代码生成和显式注册,代码多,流程直白。之二是启动速度和 AOT 友好程度:反射要在启动时扫描类型、构造特性对象,裁剪又看不见反射会用到什么;编译期生成的代码没有这两个问题,这是 .NET 推源生成器的原因。
旁注:别的语言
Section titled “旁注:别的语言”Rust 的过程宏(procedural macro)在编译期运行:它拿到一段源码的语法树,输出新的源码。序列化库 serde 的 #[derive(Serialize)] 就是过程宏,为每个类型生成专门的代码,运行时没有反射。
use serde::Serialize;
#[derive(Serialize)] // 编译期由 serde 的过程宏为 User 生成序列化实现struct User { name: String, age: u32 }- 反射结果按类型缓存;C# 要做 Native AOT 时选基于源生成器的库,Go 里规则多了改用
go generate。 - TS 只用标准装饰器,不再新开
experimentalDecorators;需要类型信息时把它写成装饰器参数,别指望运行时读类型。 - 语法上的装饰器不等于装饰器模式(区别见“概念本身”),装饰器模式见设计模式书的装饰器一章。
写一个“调用计数”:每次调用 greet 都记一次数,调用三次后打印 greet 被调用了 3 次。用 Python 和 Go 各实现一遍:Python 写装饰器 count_calls,Go 写一个包一层的高阶函数。
提示:Python 里计数存在哪里,包装函数的属性还是闭包变量?不加 functools.wraps,greet.__name__ 会变成什么?Go 里高阶函数怎样同时交出包装后的函数和计数?
- 元编程是“程序处理程序”:元数据写在源码里,在编译期、定义时或运行时被读出来。
- Python 的装饰器是定义时执行的高阶函数,
Annotated加get_type_hints(include_extras=True)读出标注里的规则。 - TS 的装饰器靠转译运行,类型在运行时已擦除;C# 的特性编译进程序集元数据,运行时反射读出;Go 只有字符串标签和
reflect。 - 反射要付运行时代价,还和裁剪、AOT 冲突;源生成器、
go generate、Rust 的过程宏把同样的事挪到编译期。