C# 元组到 JavaScript:身份擦除与边界重映射

C# 元组 → JavaScript 对象:身份擦除与边界重映射

不保留 ValueTuple 运行时身份是一个刻意的设计决策,不是遗漏。

一、"元组看起来像对象,为什么不直接映射?"

C# 的元组和 JavaScript 的对象在表观上极其相似:

var t = (1, "hello", true);       // C# 元组
const t = { Item1: 1, Item2: "hello", Item3: true };  // JS 对象

直觉上,把 (1, "hello", true) 编译成 { Item1: 1, Item2: "hello", Item3: true } 这种普通对象就行了。Jazor 也确实这么做了。

但问题在名字上。C# 支持命名元组:

(double Sum, int Count) stats = (4.5, 3);
var total = stats.Sum;  // C# 里用名字访问

而命名元组的名字是编译期信息——在 IL 层面,它们被编码为 TupleElementNamesAttribute,不影响运行时类型身份。两个 (double Sum, int Count)(double Average, int Total) 在 CLR 里是同一个 ValueTuple<double, int> 类型。

Jazor 面临的选择是:要不要在 JS 输出里保留这些名字?保留的话,边界上的名字不一致怎么处理?

Jazor 的策略是:位置负责语义等价,名字负责运行时协议,remap 负责把两者衔接起来。

二、基础 lowering:命名元组的对象字面量

VisitTuple 方法处理 ITupleOperation。核心逻辑是用 C# 里的字段名作为 JS 对象的 key:

public override Node? VisitTuple(ITupleOperation operation, SenseArgument argument)
{
    var tupleType = (INamedTypeSymbol)operation.NaturalType!;
    for (var index = 0; index < operation.Elements.Length; index++)
    {
        var fieldName = GetTupleRuntimeFieldName(tupleType.TupleElements[index]);
        var element = operation.Elements[index];
        // fieldName → JS 对象的属性名
        // element   → JS 对象的属性值
    }
    return new ObjectExpression(NodeList.From(nodes));
}

所以:

var t = (4.5, 3);  // 默认名字 Item1, Item2
// JS: { Item1: 4.5, Item2: 3 }
(double Sum, int Count) t = (4.5, 3);
// JS: { Sum: 4.5, Count: 3 }

命名元组的名字原样保留到 JS 输出中。对于同一个模块内的元组传递,这是最自然的方式。

三、边界重映射:当名字不一致时

问题出在赋值边界。两个元组可能类型相同但名字不同:

(double Sum, int Count) source = (4.5, 3);
(double Average, int Total) target = source;

在 C# 里,这是合法的——名字只影响编译器的 IntelliSense,不影响运行时。但在 Jazor 里,源元组的 JS 对象是 { Sum: 4.5, Count: 3 },目标端期望的是 { Average: 4.5, Total: 3 }。如果直接赋值 target = source,JS 对象里的 key 是 SumCount,但接收方期望的是 AverageTotal——语义上没有问题(位置 0 的值是 4.5,位置 1 的值是 3),但 key 名字不一致。

Jazor 的处理是:在赋值边界上做显式的对象重映射。

// C#: (double Average, int Total) target = source;
// JS:  target = { Average: source.Sum, Total: source.Count };

TranslateTupleForTarget 方法负责检查源元组和目标元组的名字是否一致。如果一致,直接传递;如果不一致,生成一个新的对象字面量,按目标名字重新组织字段。

private Expression TranslateTupleForTarget(IOperation value, ITypeSymbol? targetType, SenseArgument argument)
{
    // 如果目标类型也是 tuple 且名字不同 → 生成 remap
    // 否则 → 正常翻译
}

这套机制也用在 return 语句、方法调用的参数传递、对象初始化器等所有跨边界的地方。Jazor 把"边界 remap"作为一个统一入口,所有 tuple 值在跨越类型边界时都经过同一个路由——不会出现某些边界被漏掉的情况。

四、Roslyn 的 Conversion 优化:信任 IOperation 里的显式转换

ROSlyn 在 IOperation 里,如果检测到跨名字元组的赋值,有时会显式插入 IConversionOperation。Jazor 的 TryTranslateTupleConversion 方法会识别这种场景——如果 Roslyn 已经插入了转换节点,就直接在 lowering 时处理 remap:

private Expression? TryTranslateTupleConversion(IConversionOperation operation, SenseArgument argument)
{
    // 源和目标都是 tuple,且名字 shape 不同 → 生成 boundary remap
    if (HasSameTupleRuntimeShape(sourceTupleType, targetTupleType))
        return null;  // 名字一致,不需要 remap
    
    return BuildTupleProjection(operation.Operand, sourceTupleType, targetTupleType, argument);
}

如果 Roslyn 没有插入转换(某些场景下编译器不生成 Conversion),Jazor 在 TranslateTupleForTarget 里主动检测并补上 remap。双保险策略保证不会漏掉任何边界。

五、元组比较:逐元素对比

C# 的元组支持 ==!= 比较。比较的是值相等——两个元组的对应位置元素都相等时,元组相等。

在 JS 里,对象比较走的是引用相等。{ Item1: 1, Item2: 2 } === { Item1: 1, Item2: 2 } 永远是 false,因为它们是不同的对象引用。

所以 Jazor 对元组比较的 lowering 是:逐元素展开成 === 链。

var eq = t1 == t2;

编译成:

var eq = t1.Item1 === t2.Item1 && t1.Item2 === t2.Item2;

如果元组元素本身也是元组(嵌套),比较逻辑递归展开:

var eq = ((1, "a"), true) == ((1, "a"), true);

变成:

var eq = (t1.Item1.Item1 === t2.Item1.Item1 && t1.Item1.Item2 === t2.Item1.Item2) && t1.Item2 === t2.Item2;

这里的关键是,不能直接比较元组对象本身。如果你比对的是引用,两个在语义上相等的元组会得到 false。必须拆到标量层面。

六、为什么主动丢掉 ValueTuple 运行时身份?

这是一个值得单独展开的设计决策。

如果保留 ValueTuple 的运行时身份,意味着 Jazor 需要在 JavaScript 里实现一个完整的 ValueTuple 类,包含 EqualsGetHashCodeIComparableIStructuralComparable 等一堆接口。这跟 Jazor 的核心目标——零运行时——直接冲突。

所以 Jazor 的策略是刻意擦除:

C# 概念 JS 实现
(1, "a") { Item1: 1, Item2: "a" }
t.Item1 t.Item1 或按视图名字访问
(x, y) = t 解构为 x = t.Item1; y = t.Item2
t1 == t2 逐元素 === 比较
默认名字 Item1..ItemN 同名保留
显式命名 Sum/Count 同名保留,跨边界 remap

擦除的代价是:在 JS 端不能用 instanceof 检查一个对象是不是元组(因为已经没有"元组类型"了)。但考虑到 Jazor 的编译目标是声明式前端逻辑(Vue 组件、Pinia store),这个代价可以接受——你在 C# 端静态知道元组的存在,不需要在 JS 运行时再去反射。

七、解构赋值:按位置展开

C# 的元组解构:

var (name, age) = GetPerson();

在 IOperation 里,这是一个 IDeconstructionAssignmentOperation,左侧是 (name, age),右侧是 GetPerson()

翻译到 JS 时,Jazor 把它展开成逐字段赋值:

var __temp = getPerson();
var name = __temp.Item1;
var age = __temp.Item2;

注意先缓存到 __temp——如果右侧是方法调用,你不能在解构时调用两次。C# 保证方法只调用一次。

对于带自定义 Deconstruct 方法的类型(比如 record),Jazor 会走不同的路径——调用 Deconstruct 方法拿 out 参数,而不是直接访问字段。这是 ref/out 协议模拟的另一条支线——Deconstruct 的每个 out 参数都被包装成数组,解构赋值变成"调用 Deconstruct + 从 out 数组解包"。

八、record 类型与 tuple 的异同

Jazor 对 record 的 lowering 和 tuple 的策略有相似之处——二者都是"结构化值对象",都不保留 nominal runtime identity。但有一个关键区别:

  • tuple:没有声明式语法(在 JS 层面),只是散落在各处的对象字面量。字段名来自 C# 的命名。
  • record:按固定的结构化 lowering 处理创建、属性访问、with、模式和解构,生成对象形状,不生成 JS class,也不保留 nominal runtime identity。

两者共享一个设计原则:保结构(投影、解构、值比较),不保类型身份。

九、小结

C# 元组的 lowering 在 Jazor 里被抽象成了三个清晰的概念:

  • 位置负责语义等价:Item1 是第 0 位,Item2 是第 1 位。这个顺序是跨边界比较、解构和 == 的语义基础。
  • 名字负责运行时协议:命名元组的字段名进入 JS 输出,成为 JS 对象的 key。同一模块内的元组传递保持名字一致。
  • remap 负责边界对接:当两个命名元组名字不同但在同一个位置类型上传递时,编译器在赋值/参数/返回边界插入对象重映射。双保险(Roslyn Conversion + 主动检测)保证不遗漏任何边界。

这比"直接把元组编译成数组"要灵活(保留了命名元组的可读性),比"引入 ValueTuple 运行时"要轻量(零依赖)。在当前 Jazor 的应用场景——用 C# 写 Vue 组件、Pinia store、前端业务逻辑——这恰好是正确的位置。

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

posted @ 2026-05-11 13:38  形而下晓寒  阅读(10)  评论(0)    收藏  举报