C# ref out 在 JavaScript 中的协议模拟
C# 没有 ref 和 out 的 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 没有 ref 和 out。所有参数传递都是按值传递——对象参数传递的是引用的副本,但参数本身不能被重新赋值后影响调用方。
所以跨语言编译器面对这个问题只有三条路:
- 不支持
ref/out,编译报错 - 把
ref/out参数包装成数组/对象,用 caller/callee 协议模拟 - 模拟 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 的模拟方式是:
- Caller 端:在调用前声明一个包装数组
[value],把参数装进去 - Callee 端:从数组
arr[0]读取参数值,修改时写回arr[0] - 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 的特别处理
out 和 ref 的主要区别在于: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 属性,值是 None、Ref、Out 或 In。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 的参数在调用前不需要有值,包装数组里放 undefined;RefKind.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 输出的全链路是:
- Roslyn 绑定出
RefKind;当前 caller/callee 包装协议针对Ref和Out SemanticWalker在翻译调用时识别需要协议包装的参数- Caller 端生成包装数组 + 调用后解包
- Callee 端把
arr[0]读写当作参数操作
同时,Jazor 在这一点上用"明确拒绝"画了清晰的边界:构造函数的 ref/out/in/params dispatch、字段引用和返回值引用不支持;普通构造函数重载本身由 Roslyn 绑定的 selector 精确选择 $ctor_<hash> helper,不依赖参数个数猜测。

浙公网安备 33010602011771号