C# native union 降级实录:从 wrapper record 到类型擦除

C# native union 降级实录:从类型包装到类型擦除

.NET 11 SDK 11.0.100-preview.6.26359.118 已经把 C# native union 带进了真实的编译器工作流。Jazor 的做法不是把所有 union 都改写成一个万能包装器,而是根据分支是否互斥,在原生 union 和 tagged fallback 之间作出确定选择。

一、Union 是什么:代数数据类型的“或”

编程语言里的类型可以看成值的集合。structclass 组合多个字段,是积类型;union 则表示一个值来自若干候选类型中的一个,是和类型:

union IntOrString = int | string

它适合表达 API 的有限值域。例如一个 Web API 参数可能是一个标量,也可能是一个配置对象;一个 DOM API 参数可能接受字符串、元素或数组。把参数声明成 object 虽然省事,却把合法分支、赋值检查和调用点提示都交给了运行时。

二、C# 过去的替代方案

在 native union 之前,C# 常见的做法是继承树、OneOf<T1, T2>、枚举加 payload,或者直接使用 object

继承树适合 AST 这类本来就有稳定层次的模型,但为“字符串或数组”建立一组类型会产生不必要的名义类型。OneOf 一类泛型包装器可以复用实现,却不是 C# 编译器认识的原生 union,switch 收窄、隐式赋值和 Razor 参数绑定仍然需要额外约定。object 则直接放弃了 authored API 的类型边界。

这也是 native union 有价值的地方:它把候选分支写进声明,让普通赋值、隐式构造、重载解析和生成器都能看到同一个公共合同。

三、Preview 6 的 native union 语法

当前项目固定到 .NET 11 Preview 6 / C# 15 preview SDK。最基本的声明形态是:

[ECMAScript]
[System.Runtime.CompilerServices.Union]
public readonly union NumberOrString(double, string)
{
    public double? AsDouble => Value is double value ? value : default;
    public string? AsString => Value is string value ? value : default;

    public static implicit operator NumberOrString(double value)
        => new(value);

    public static implicit operator NumberOrString(string value)
        => new(value);
}

使用端不需要手写 From(...)

NumberOrString number = 3.14;
NumberOrString text = "hello";

if (number.AsDouble is double value)
    Console.WriteLine(value * 2);

这里有几个 Preview 6 语境下必须说清楚的事实:

  1. union 是 C# 的声明语法,不是“写一个 record 再加一个 Attribute”的别名。
  2. 分支类型写在 union Name(T1, T2, ...) 中,由语言和 SDK 提供 union 合同。
  3. native union 对外仍通过 System.Runtime.CompilerServices.UnionAttributeIUnion 被识别;IUnion.Value 是编译器/运行时合同,不应据此把公共 API 退化成 object
  4. AsXxx 是 authoring 侧的强类型投影。Jazor 在 JavaScript 输出侧会擦除 union 容器,但不会因此删除 C# 侧的分支类型和投影属性。

Preview 6 的重点不是“所有 union 都自动变成无包装裸值”,而是语言原生 union 已经可以作为稳定的首选表达。能否使用它,还要看分支之间的可赋值关系。

四、为什么分支必须互不可赋值

erased-value union 通常依靠值的实际类型来判断 AsXxx 投影。如果一个分支可以赋值给另一个分支,类型检查就不再能唯一确定原始分支:

public sealed class ScrollPositionCoordinates;
public sealed class ScrollPositionElement : ScrollPositionCoordinates;

// 这两个分支存在继承关系,不适合直接使用 native erased union
public readonly union ScrollPosition(
    ScrollPositionElement,
    ScrollPositionCoordinates);

当值是 ScrollPositionElement 时,value is ScrollPositionCoordinates 同时为真。于是 AsScrollPositionCoordinates 无法表达“它实际选择了哪个分支”,投影会被基类匹配扩大。

因此当前规则是:

  • doublestring 这类互不可赋值分支,优先使用 native union。
  • 分支存在继承关系时,使用 tagged fallback。
  • 分支是 object、宽接口或 delegate,且需要精确区分具体投影时,也使用 tagged fallback。

这不是绕过 Preview 6 的临时方案,而是 erased union 必须遵守的语义边界。仓库测试会检查 fallback union 的分支不可赋值条件,避免生成一个看似合法、投影却不精确的 native union。

五、Jazor 的两种 union 产物

Jazor 使用三个条件识别一个可以进入 host lowering 的 union:

[System.Runtime.CompilerServices.Union]
    + [ECMAScript]
    + System.Runtime.CompilerServices.IUnion

Jazor.Compiler 在 Roslyn symbol 上检查这些合同,而不是重新解析 public readonly union 的语法文本。识别结果只决定“这个类型是否属于 Jazor 的 erased union host domain”,不改变 CLR API 的 authored 形状。

5.1 原生 union:互斥分支

例如 WebIDL 的 CSSNumberish 当前生成成:

[ECMAScript]
[System.Runtime.CompilerServices.Union]
[Description("@#")]
public readonly union CSSNumberish(double, CSSNumericValue)
{
    public double? AsDouble => Value is double value ? value : default;
    public CSSNumericValue? AsCSSNumericValue
        => Value is CSSNumericValue value ? value : default;

    public static implicit operator CSSNumberish(double value) => new(value);
    public static implicit operator CSSNumberish(CSSNumericValue value) => new(value);
}

生成器保留了隐式赋值和 AsXxx 投影。对于带数组分支的 union,还会生成 CollectionBuilderIEnumerable<T> 适配,使 collection expression 仍然可以绑定到强类型 API。

5.2 tagged fallback:需要精确标签

当 native union 不能安全表达分支关系时,WebIDL 生成器生成显式的只读 struct:

[ECMAScript]
[System.Runtime.CompilerServices.Union]
public readonly struct BufferSource : System.Runtime.CompilerServices.IUnion
{
    private readonly byte _kind;
    private readonly IArrayBufferView? _value1;
    private readonly ArrayBuffer? _value2;

    public IArrayBufferView? AsIArrayBufferView
        => _kind == 1 ? _value1 : default;

    public ArrayBuffer? AsArrayBuffer
        => _kind == 2 ? _value2 : default;

    public object? Value => _kind switch
    {
        1 => _value1,
        2 => _value2,
        _ => default
    };
}

fallback 不是旧式的泛型 wrapper record,也不是把所有输入都收进 object 参数。它是一个带明确分支标签的公共值合同。对于接口或 object 分支,生成器会避免产生不安全的 implicit operator,必要时提供强类型 FromXxx 入口;具体分支仍会保留可以安全提供的隐式转换。

例如 object 分支不能提供 implicit operator Union(object value),因为任何值都能匹配它,会吞掉其他分支的重载解析。这个限制是有意的,避免用一个宽转换破坏 C# 的类型安全。

六、WebIDL 生成器为什么不能只生成一种形态

当前生成器会先把 WebIDL union 分支映射为 C# 类型,再分析:

  1. 删除 void-like 分支并合并重复类型。
  2. 计算命名 union 和嵌套 union 的稳定名称。
  3. 识别数组分支以及 CollectionBuilder 需求。
  4. 判断是否满足 native union 的分支约束。
  5. 满足约束时生成 public readonly union;否则生成 [Union] + IUnion tagged struct。

这条规则也覆盖嵌套 union。例如 Promise 的 union 结果会先得到命名的内部 union,再把它作为 Promise 的类型参数,而不是把匿名 object 塞进外层 API。生成器测试覆盖了 typedef、dictionary、constructor、operation、callback interface、数组分支和嵌套 union。

七、Jazor 如何把 union 擦除到 JavaScript

Jazor 的 compiler 工作在 Roslyn IOperation 上。对于已经识别为 host erased union 的类型,union 容器本身不需要在 JavaScript 中生成一套 CLR 类层次。

CSSNumberish width = 100;
if (width.AsDouble is double value)
    Console.WriteLine(value);

在 lowering 后,width 仍然是 JavaScript 的实际值,AsDouble 这个 C# 投影不会变成一个运行时 wrapper 属性。编译器根据具体投影和模式检查生成对应的值判断,同时保留单次求值和使用点语义。

这个规则只作用于 Jazor 已确认的 host union。一个普通类型即使自己实现了同名接口,或者只有 [Union] 没有 [ECMAScript],都不能因此被静默当成 erased union;不受支持的 union 投影会在 compiler 使用点明确失败。

default(union) 也有明确语义:erased union 没有一个可以代表“已选择分支”的 CLR default 对象,Jazor 在 JavaScript 侧使用 null 表示未初始化值,而不是生成一个伪造的 union 实例。

八、Preview 6 下的公共 API 规则

native union 的引入并不意味着宿主 API 可以重新使用 object 或开放泛型。当前规则是:

  • 已知的 JavaScript 值域优先用命名 native union 表达。
  • native union 分支互不可赋值时才直接使用。
  • 需要精确 tagged projection 时使用 [Union] + IUnion fallback。
  • 保留强类型隐式赋值、必要的 AsXxx 投影、collection builder 和 Razor Source Generator 可绑定的参数形状。
  • 不为“JavaScript 可以接收任何值”增加 object? catch-all 参数。
  • 不把已经退休的旧泛型 union wrapper 重新接回 compiler recognition path。

这条规则同时约束 C# 项目、WebIDL 生成器、Vue/DOM binding、Razor SG 参数绑定和 Jazor compiler。只有普通 C# 编译通过、Razor Source Generator 能生成最终 Compilation、Jazor 又能在使用点正确 lowering,才算一个完整的 union 合同。

九、小结

Preview 6 带来的真正变化可以概括为:

互不可赋值的分支
    -> public readonly union Name(T1, T2, ...)

存在继承、object/interface/delegate 精确投影需求
    -> [Union] + IUnion tagged fallback

两者都属于强类型 C# host contract
    -> Jazor compiler 在使用点擦除 union 容器
    -> JavaScript 保留实际分支值和可观察行为

所以这不是“wrapper record 终于被一个神奇语法替代”的故事。Preview 6 提供了更好的原生表达,Jazor 仍然必须处理分支关系、Razor 绑定、集合构造、投影精度和 JavaScript lowering。原生 union 是默认答案,但不是无条件答案;tagged fallback 也不是失败,而是当类型关系要求标签时更准确的答案。

项目地址:https://github.com/devhxj/Jazor

posted @ 2026-05-12 18:19  形而下晓寒  阅读(41)  评论(0)    收藏  举报