跳转到内容

第 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 原生支持:装饰器是语法,typing.Annotated 能把任意对象附在类型标注上,typing.get_type_hints 和 dataclasses.fields 把它们读出来。

from dataclasses import dataclass, fields
from 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} 之间"
@dataclass
class 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 时才生效。

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# 原生支持:特性(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 没有注解,也没有装饰器语法。它给的是结构标签(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 推源生成器的原因。

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 的过程宏把同样的事挪到编译期。