Protobuf Editions 设计解析:枚举字段开闭性(Enum Field Closedness)的遗留问题、legacy_closed_enum 特性与跨语言落地
本文基于 Protobuf 官方设计文档 edition-zero-feature-enum-field-closedness.md(作者 @mcy,2023-02-13 批准)展开。读完后,你将理解 proto2/proto3 混用时枚举“开闭性(openness/closedness)”判定中那个难以察觉的角落案例,掌握 Editions 提案中 legacy_treat_enum_as_closed 特性的设计动机与语义,并能从当前仓库源码中验证该设计最终如何以 C++ 语言专属特性 pb.cpp.legacy_closed_enum 和反射 API FieldDescriptor::legacy_enum_field_treated_as_closed() 的形式落地。
1. 背景:一个由“删除函数”引发的角落案例
2023-02-10,一个删除 google::protobuf::Reflection::SupportsUnknownEnumValue() 的变更被提交。令人意外的是,这个函数在判断一个枚举是“开(open)”还是“闭(closed)”时,使用的竟然是所在 message 的 syntax,而不是枚举字段本身的定义语法。
文档用下面两个 proto 文件演示了由此暴露出的关键角落案例:
// enum.proto
syntax = "proto3";
package oh.no;
enum Enum {
A = 0;
B = 1;
}
// message.proto
syntax = "proto2";
package oh.no;
import "enum.proto";
message Msg {
optional Enum enum = 1;
}
如果将 wire 值 1: 2(即字段 1、VARINT 值 2)解析为 oh.no.Msg,再查看 oh.no.Msg.enum 的值,会发现:
- 该字段不存在(not present);
- 值 2 被存进了
UnknownFieldSet,以 VARINT 形式保留。
原因是:Protobuf 有时按枚举的“使用场景(usage)”而非“定义(definition)”来实现其开闭性。这里枚举定义在 proto3 文件里(本应是 open),但字段声明在 proto2 文件里(按 usage 语义则按 closed 处理),于是非法值 2 被当作未知字段丢弃而非接受。
这个案例之所以难以被发现,是因为反向组合无法构造:proto 编译器会拒绝在 proto3 message 中使用 proto2 枚举类型的字段,因为 proto2 枚举可以带非零默认值,而 proto3 由于隐式 presence(implicit presence)并不支持这一点。换句话说,只有“proto2 字段 + proto3 枚举”这一个方向能触发该歧义。
2. 各语言现状:开闭性判定行为矩阵
设计文档对主要运行时逐一做了盘点,结论是没有任何一种统一行为:
| 语言 / 运行时 | Open/Closed 判定方式 |
|---|---|
| C++ | 由使用枚举的字段所在文件决定(按 usage) |
| Java | 由使用枚举的字段所在文件决定(按 usage) |
| UPB(非 Ruby 绑定) | 由枚举定义文件决定(按 definition) |
| UPB(Ruby 绑定) | 所有枚举一律视为 open |
| C# | 所有枚举一律视为 open |
| Obj-C | 22.x 之前按 usage;22.x 起按 definition。文档解释:原本按 usage 处理,但在 11 月的 syntax 清理中改为不再看 syntax、直接在枚举定义上捕获,因此现在由枚举定义决定 |
| Swift | 由枚举定义文件决定。Swift 利用枚举关联值(associated values)能力,proto3 语法文件定义的枚举会有一个专门存放所有未知值的关联值;因此 proto2 语法定义的 message 最终也会让枚举用该机制承载未知值 |
| Go | 所有枚举一律视为 open |
| Apps JSPB | 所有枚举一律视为 open |
| ImmutableJs | 所有枚举一律视为 open |
| JsProto | 所有枚举一律视为 open |
这份矩阵直接决定了后续设计取向:一个全局开关无法满足所有语言的收敛需求。
影响面统计
文档给出了(基于内部语料库的)量化影响评估:
- 约 2.99% 的枚举字段存在跨 syntax 的枚举导入;
- 1.77% 的枚举被跨 syntax 导入;
- 6.14% 的字段是枚举字段;
- 综合下来,受受影响语言影响时,约 0.18% 的字段实际会被波及。
这也佐证了文档的判断:这是一个真实存在但相当罕见的遗留行为(“a bad legacy behavior that we believe is rare and want to stamp out”)。
3. 原始提案:legacy_treat_enum_as_closed 特性
文档的 Overview 部分提出,向 Edition Zero Features 增加一个特性,原始 .proto 片段如下:
message Features {
// ...
optional bool legacy_treat_enum_as_closed = ??? [
retention = RUNTIME,
target = FILE,
target = FIELD
];
}
关键语义要点:
- 命名即立场:字段名特意包含 “legacy”,表达“这是一种坏的遗留行为,我们认为它罕见,并打算消灭它”的意图。
- 默认值:Edition 2023 默认设为
false;proto2文件视为隐式true(保持 legacy 行为不变,保证迁移 no-op)。 - 单向性限制:不允许反向操作——你不能用它把一个字段强制变 open,因为当前根本做不到,作者不想再引入更多特例。
- 迁移工具的特别豁免:从 proto2 迁移到 editions 时,该特性不应被无条件设置,而应只打在“类型为 proto3 枚举的 proto2 字段”上;并且应当像处理
required一样为它建立一份 allowlist。 - 辅助 closed → open 渐进迁移:该特性还能用于把枚举从 closed 迁到 open——在一个 CL 里把枚举标为 open、同时把它所有使用点标为 treat-as-closed,之后逐个删除 treat-as-closed 注解。
- 一个开放问题:是否应该把
is_closed从EnumDescriptor移到FieldDescriptor。从当前源码结构看,这个问题至今保持原样:is_closed()仍定义在EnumDescriptor上(见 descriptor.h 中该方法的完整声明及警告注释)。
4. 推荐方案:定义官方行为 + 按语言特性收敛
文档的 Recommendation 给出了明确结论:采用“Define official behavior”替代方案。理由是在各语言行为如此多样的现状下,一个全局配置必然让某些语言掉队;因此用每语言特性(per language features),让每种语言自行控制演进节奏,同时定义出“正确行为”作为收敛目标。
官方行为被定义为:枚举的开闭性应由枚举的定义决定(definition),而非使用点。这与 proto2/proto3 中几乎所有其他属性的模型一致(属性归类型定义所有),并计划为此添加 conformance 测试。
以 C++ 为例,文档给出的 API 定义为:
// Determines if the given enum field is treated as closed based on legacy
// non-conformant behavior.
//
// Conformant behavior determines closedness based on the enum and
// can be queried using EnumDescriptor::is_closed().
//
// Some runtimes currently have a quirk where non-closed enums are
// treated as closed when used as the type of fields defined in a
// `syntax = proto2;` file. This quirk is not present in all runtimes; as of
// writing, we know that:
//
// - C++, Java, and C++-based Python share this quirk.
// - UPB and UPB-based Python do not.
// - PHP and Ruby treat all enums as open regardless of declaration.
//
// Care should be taken when using this function to respect the target
// runtime's enum handling quirks.
bool FieldDescriptor::legacy_enum_field_treated_as_closed() const {
return type() == TYPE_ENUM && file().syntax() == FileDescriptor::SYNTAX_PROTO2;
}
在 Java 侧,对应的 FileDescriptor.supportsUnknownEnumValue() 需要被弃用并由上述 API 取代。
5. 仓库源码中的最终落地形态
设计文档提出的是“全局 bool 特性”草案,而当前仓库展示的是按推荐方案演进后的实际实现:它最终以一个 C++ 语言专属的 FeatureSet 扩展形式存在,并已进入弃用流程。
5.1 pb.cpp.legacy_closed_enum 特性定义
在 cpp_features.proto 中可以看到该特性的完整定义:
message CppFeatures {
// Whether or not to treat an enum field as closed. This option is only
// applicable to enum fields, and will be removed in the future. It is
// consistent with the legacy behavior of using proto3 enum types for proto2
// fields.
optional bool legacy_closed_enum = 1 [
retention = RETENTION_RUNTIME,
targets = TARGET_TYPE_FIELD,
targets = TARGET_TYPE_FILE,
feature_support = {
edition_introduced: EDITION_2023,
edition_deprecated: EDITION_2023,
deprecation_warning: "The legacy closed enum behavior in C++ is "
"deprecated and is scheduled to be removed in "
"edition 2025. See http://protobuf.dev/programming-guides/enum/#cpp for "
"more information",
},
edition_defaults = { edition: EDITION_LEGACY, value: "true" },
edition_defaults = { edition: EDITION_PROTO3, value: "false" }
];
// ...
}
与原文档草案逐项对照:
- 保留了草案中的
retention = RUNTIME与FILE+FIELD双 target; edition_defaults精确兑现了文档承诺:EDITION_LEGACY(即 proto2/proto3 旧语法)下默认"true",EDITION_PROTO3下默认"false";- 新增的生命周期信息印证了“stamp out(消灭)”立场:该特性在 EDITION_2023 引入的同时即被标记弃用,并计划于 edition 2025 移除。
5.2 反射 API 的实现
在 descriptor.h 中,FieldDescriptor::legacy_enum_field_treated_as_closed() 的声明逐字保留了设计文档 Recommendation 段落中的警告注释(包括 C++/Java/C++-based Python 共享该 quirk、UPB 与 UPB-based Python 没有、PHP 与 Ruby 一律视为 open 的运行时差异说明),这为使用者划清了使用边界。
descriptor.cc 中的实现则是文档草案的一个自然演进——从“只看字段所在文件 syntax”改为“特性值 OR 枚举定义”:
bool FieldDescriptor::legacy_enum_field_treated_as_closed() const {
return type() == TYPE_ENUM &&
(features().GetExtension(pb::cpp).legacy_closed_enum() ||
enum_type()->is_closed());
}
即:一个枚举字段在当前 C++ 运行时被当作 closed,当且仅当它是枚举类型,且(语言特性 legacy_closed_enum 为真,或者枚举定义本身按官方语义就是 closed 的)。
而官方语义的权威来源在 EnumDescriptor::is_closed():
bool EnumDescriptor::is_closed() const {
return features().enum_type() == FeatureSet::CLOSED;
}
这正体现了推荐方案中“官方行为由枚举定义决定、conformant 行为可用 EnumDescriptor::is_closed() 查询”的分工:is_closed() 是收敛目标,legacy_enum_field_treated_as_closed() 是兼容现状的过渡桥梁。
5.3 迁移工具链的佐证
editions/golden/ 目录下的迁移金标准文件(如 edition2023_transform/proto2.proto 与 edition2024_transform/proto2.proto)中出现了 legacy_closed_enum 注解,印证了文档对迁移工具(Prototiller)的要求:从 proto2 迁移到 editions 时,只对受影响的使用点打上该注解,且该迁移逻辑在 C++/Java 等语言的 prototiller 需求文档 中也有体现。Java 侧同样存在对应特性(见 java_features.proto 中对 legacy_closed_enum 的处理),与文档“Java 的 supportsUnknownEnumValue() 需被弃用并替换”的结论一致。
6. 备选方案对比(Alternatives)
文档在 Alternatives 一节完整记录了被否决的其他路线及其取舍,理解它们有助于把握最终方案的边界:
| 方案 | 要点 | 主要优点 | 主要缺点 |
|---|---|---|---|
| 定义官方行为(被采纳) | 官方行为 = 开闭性由枚举定义决定;加 conformance 测试;用按语言特性让不合规实现逐步收敛 | 澄清期望行为;已有实现可用 editions 增量修改;不把全局特性复杂化去解决本质上是单语言的问题 | proto2 → editions 迁移时,Prototiller 需要知道所有使用的语言才能让升级成为 trivial change(其他 editions 升级本就如此) |
把 Features.enum 做成字段级特性 |
不新增 legacy_treat_enum_as_closed,而把 closedness 变成字段(而非枚举)的正经属性 |
符合 C++/Java(最大两种语言)的现状;消除反射代码误查 EnumDescriptor::is_closed() 而非 FieldDescriptor 的陷阱 |
无法覆盖 C++/Java 以外的语言;单个枚举迁 open 更困难,因为属性不受类型所有者控制;语义上不愉快——IsValid 的局部含义变得模糊,除非把 IsValid 理解为“该值有已知名字” |
允许 Features.enum 同时作用于枚举和字段 |
让枚举所有者获得一定控制权,且不需要“legacy do not use”特性 | 不引入“legacy 禁用”选项,无需 allowlist 博弈 | 需要支持“closed 枚举按 open 处理”,这是 Protobuf 目前不具备的能力 |
命名为 Features.treat_as_closed_for_migration |
纯命名审美选择,突出其临时性 | 不引入“legacy 禁用”选项,无需 allowlist;明确指向唯一期望方向(closed → open) | 用户可能出于偏好 closed 枚举而滥用它,而未充分理解后果 |
| 什么都不做 | 维持 editions 现有枚举语义 | 没有额外工作量 | proto2 → editions 在某些情况下不再是 no-op,削弱 editions 的核心卖点(尽管 no-op 可预先检测);会立即成为 syntax 反射大规模变更的 blocker |
7. 小结
围绕“枚举字段开闭性”这一遗留歧义,本次设计完成了一次典型的行为治理:先用反例(proto2 字段使用 proto3 枚举时非法值进入 UnknownFieldSet)钉死问题,用跨语言矩阵证明全局方案不可行,再以“官方行为 = 由枚举定义决定”树立收敛目标,最后通过语言专属特性(pb.cpp.legacy_closed_enum)与过渡反射 API(legacy_enum_field_treated_as_closed())保证旧语法迁移是 no-op。当前仓库源码显示该特性已带上 2025 年移除的时间表,说明整个方案仍在按“定义官方行为、逐语言收敛、最终消灭遗留开关”的路线执行。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00