首页
/ Protobuf Editions 设计解析:枚举字段开闭性(Enum Field Closedness)的遗留问题、legacy_closed_enum 特性与跨语言落地

Protobuf Editions 设计解析:枚举字段开闭性(Enum Field Closedness)的遗留问题、legacy_closed_enum 特性与跨语言落地

2026-09-05 11:29:28作者:申梦珏Efrain

本文基于 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
  ];
}

关键语义要点:

  1. 命名即立场:字段名特意包含 “legacy”,表达“这是一种坏的遗留行为,我们认为它罕见,并打算消灭它”的意图。
  2. 默认值:Edition 2023 默认设为 falseproto2 文件视为隐式 true(保持 legacy 行为不变,保证迁移 no-op)。
  3. 单向性限制:不允许反向操作——你不能用它把一个字段强制变 open,因为当前根本做不到,作者不想再引入更多特例。
  4. 迁移工具的特别豁免:从 proto2 迁移到 editions 时,该特性不应被无条件设置,而应只打在“类型为 proto3 枚举的 proto2 字段”上;并且应当像处理 required 一样为它建立一份 allowlist。
  5. 辅助 closed → open 渐进迁移:该特性还能用于把枚举从 closed 迁到 open——在一个 CL 里把枚举标为 open、同时把它所有使用点标为 treat-as-closed,之后逐个删除 treat-as-closed 注解。
  6. 一个开放问题:是否应该把 is_closedEnumDescriptor 移到 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 = RUNTIMEFILE + 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.protoedition2024_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 年移除的时间表,说明整个方案仍在按“定义官方行为、逐语言收敛、最终消灭遗留开关”的路线执行。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384