C# ref out 在 JavaScript 中的协议模拟

C# 没有 refout 的 JavaScript 里,怎么模拟它们

协议模拟,而不是地址模拟——这是 Jazor 在 ref/out 问题上最核心的定位。

一、问题:JavaScript 没有引用参数

C# 的方法签名可以这样写:

void Swap(ref int a, ref int b)
{
    var tmp = a;
    a = b;
    b = tmp;
}

bool TryParse(string s, out int result)
{
    // ...
}

JavaScript 没有 refout。所有参数传递都是按值传递——对象参数传递的是引用的副本,但参数本身不能被重新赋值后影响调用方。

所以跨语言编译器面对这个问题只有三条路:

  1. 不支持 ref/out,编译报错
  2. ref/out 参数包装成数组/对象,用 caller/callee 协议模拟
  3. 模拟 CLR 的地址模型(ByReference、托管指针等)

Jazor 选了第 2 条路,但给了一个精确的定义:这是协议模拟,不是地址复刻。

二、协议的定义

Jazor 的 ref/out 协议是 caller 和 callee 双方共同遵守的一个约定:

参数类型 协议
ref T x caller 把 x 的值传入,callee 可以读也可以写;caller 在调用后获得 callee 写回的值
out T x caller 不需要初始化 x,callee 必须写入;caller 在调用后获得 callee 写入的值
in T x caller 把 x 的值传入,callee 只能读,不能写

在 JS 端,因为没法"写回调用方的变量",Jazor 的模拟方式是:

  1. Caller 端:在调用前声明一个包装数组 [value],把参数装进去
  2. Callee 端:从数组 arr[0] 读取参数值,修改时写回 arr[0]
  3. Caller 端(调用后):从数组 arr[0] 取出 callee 写入的值
// C#:
int a = 5, b = 10;
Swap(ref a, ref b);
// a 现在是 10, b 现在是 5
// JS 模拟:
let a = [5], b = [10];   // 包装
swap(a, b);               // callee 操作 a[0], b[0]
a = a[0];                 // 解包
b = b[0];

这看起来笨重,但它是唯一不需要 JS 运行时支持就能正确模拟 ref 语义的方式。如果引入运行时代码(比如定义一个 Ref<T> wrapper 类),就违背了 Jazor 的零运行时目标。

三、out 的特别处理

outref 的主要区别在于:out 参数在传入前不需要初始化。 C# 编译器会强制 callee 在返回前给 out 参数赋值。

在 Jazor 的协议里,这意味着:

  • ref 参数的包装数组在调用前需要有初始值(caller 提供的值)
  • out 参数的包装数组初始值不重要(callee 必定会覆写)
// C#:
int result;
if (TryParse("42", out result))
    Console.Log(result);
// JS:
let result = [undefined];      // out 不需要初始值
let success = tryParse("42", result);
if (success) console.log(result[0]);

out 参数的包装数组里放什么不重要——callee 保证会写进去。这是 C# 编译器保证的,Jazor 不需要做额外的运行时检查。

这里有一个微妙的地方:C# 的 out 变量支持内联声明(out int result)。在 IOperation 里,这是一个 IDeclarationExpressionOperation,Jazor 需要先在 lowering 时把它拆成"变量声明 + out 包装数组 + 调用 + 解包"。拆解顺序必须和 C# 保证的求值顺序一致。

四、IOperation 级别的识别:RefKind

在 Roslyn 的 IOperation 里,每个参数带有一个 RefKind 属性,值是 NoneRefOutIn。Jazor 在 SemanticWalker.cs.Reference.cs 翻译方法调用时,遍历参数列表,按 RefKind 决定包装策略:

// SemanticWalker 中方法调用参数翻译的核心路径:
foreach (var arg in operation.Arguments)
{
    var translatedValue = Translate<Expression>(arg.Value, argument);
    
    if (arg.Parameter?.RefKind is RefKind.Out or RefKind.Ref)
    {
        // 这个参数需要 ref/out 协议:包装 + 后续解包
        // 记录到 writeback 列表,调用后统一解包
    }
    else
    {
        // 普通参数:直接传值
    }
}

RefKind.Out 的参数在调用前不需要有值,包装数组里放 undefinedRefKind.Ref 的参数需要用调用方变量的当前值做初始化。这个区分在 lowering 阶段完成——Jazor 完全信任 Roslyn 已经给出了正确的 RefKind,不再在 JS 端做二次判断。

五、求值顺序:一条不能碰的红线

ref/out 协议的 lowering 和模式匹配 lowering 共享同一条原则:求值顺序不能变。

假设有这样的调用:

Method(GetA(), ref GetB(), out GetC());

C# 保证参数从左到右求值:先 GetA(),再 GetB()(ref),再 GetC()(out)。在 JS 翻译里,也必须保持这个顺序:

let __b_ref = [getB()];
let __c_out = [undefined];
method(getA(), __b_ref, __c_out);
let __c = __c_out[0];  // out 解包

注意 getB()getC() 之前执行——如果在 lowering 时把包装和解包的顺序搞反了,out 参数的求值点就会错位。

Jazor 在 SemanticWalker.cs.Reference.cs 里对方法调用的参数翻译有一条精确的规则:按 C# 参数声明顺序遍历,遇到 ref/out 参数时,在传参前先生成包装表达式、调用后再插入解包代码。 包装和传参的顺序不能打乱。所有 out 参数的解包都在方法调用完成后,按参数声明顺序(逆序)执行,保证 C# 里"所有 out 参数在 callee 返回前已被写入"的语义。

六、callee 端的协议遵守

协议不只是调用方的责任。被调方也需要遵守同一条规则——收到数组参数后,从 arr[0] 读写:

// C#:
void Swap(ref int a, ref int b)
{
    var tmp = a;
    a = b;
    b = tmp;
}
// JS:
function swap(a, b) {
    let tmp = a[0];    // 读:a[0] 而不是 a
    a[0] = b[0];       // 写:a[0] = ... 而不是 a = ...
    b[0] = tmp;
}

每次对 ref 参数的读写都走 arr[0] 这一层。在 C# 源码里看起来是直接操作变量 a,在 IOperation 里它是一个 IParameterReferenceOperation,Jazor 看到 RefKind.Ref 后就加上 [0] 的成员访问。

七、不支持什么,以及为什么

Jazor 在 ref/out 问题上划定了几条明确的"不":

场景 策略 原因
ref 字段 不支持 JS 没有托管指针,不能把字段地址传出去
ref 返回值 不支持 JS 没有引用返回的语义
ref 局部变量重新绑定 不支持 JS 变量不能变成另一个变量的别名
构造函数 dispatch 中的 ref/out/in/params 显式报错 当前构造函数 selector 协议不接受这些参数驱动的分派
构造函数中的 in 参数 显式报错 当前构造函数 selector 不支持由 in 参数驱动的 dispatch

这些不是"还没做",而是"选择不做"。每一条都在编译器或分析器里被显式拦截,报错信息说明原因。

八、结合构造函数重载的冲突

这是 ref/out 与 Jazor 另一个特性的交叉点。Jazor 的构造函数分派器通过签名哈希做精确匹配——调用方把构造函数的 helper 名("$ctor_<hash>")作为第一个参数传入,分派器读 arguments[0] 识别重载,再从 arguments[1]arguments[2]……依次解出真实参数:

constructor() {
    let $args = arguments;
    let $ctor = $args[0];
    if ($ctor === "$ctor_3a2f") { /* 绑定 $args[1].. */ return this.$ctor_3a2f(...); }
    if ($ctor === "$ctor_7b1e") { /* 绑定 $args[1].. */ return this.$ctor_7b1e(...); }
    throw new Error("No matching constructor overload.");
}

如果构造函数带有 ref/out 参数,这个机制就会破裂。因为 BuildConstructorDispatcherParameterBindings 依赖"每个参数恰好对应 arguments[n+1] 的一个位置"——let name = $args[1]let age = $args[2]。而 ref/out 参数需要包装成数组([value]),这就把"一个参数占一个位置"的前提打破了。params 同理——它会吞掉可变数量的参数槽位。

所以 Jazor 的策略是:AstConverter 里直接拒绝。

// AstConverter.cs 中的拦截逻辑
throw new NotSupportedException(
    "Jazor member class does not support constructor overload dispatch " +
    "with ref/out/in/params parameters.");

这是 fail-fast 策略:不在分派器里硬塞更复杂的参数解包逻辑,而是在编译早期就明确告诉用户这个限制。

九、小结

Jazor 对 ref/out 的 lowering 策略可以总结为一句话:用 caller/callee 双端协议模拟引用语义,不模拟 CLR 地址模型。

这个策略的选择依据是 Jazor 的核心目标——零运行时 JavaScript 输出。一旦选择了"不引入运行时",ref/out 就只能通过协议来模拟。协议虽然比原生支持笨重(多了数组包装和解包),但它保持了输出代码的自包含性和可理解性。

从 IOperation 到 JS 输出的全链路是:

  1. Roslyn 绑定出 RefKind;当前 caller/callee 包装协议针对 RefOut
  2. SemanticWalker 在翻译调用时识别需要协议包装的参数
  3. Caller 端生成包装数组 + 调用后解包
  4. Callee 端把 arr[0] 读写当作参数操作

同时,Jazor 在这一点上用"明确拒绝"画了清晰的边界:构造函数的 ref/out/in/params dispatch、字段引用和返回值引用不支持;普通构造函数重载本身由 Roslyn 绑定的 selector 精确选择 $ctor_<hash> helper,不依赖参数个数猜测。

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

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