Wing 隐式作用域机制解析:从 `new_in_static.test.w` 快照看 preflight 对象作用域推导
Wing 隐式作用域机制解析:从 new_in_static.test.w 快照看 preflight 对象作用域推导
Wing 是一门面向云端的编程语言,其 preflight 阶段代码在编译期运行,负责定义应用的基础设施树(construct tree)。在 Wing 中,每个 preflight 对象都拥有一个 scope 和一个唯一 ID,而编译器会自动为对象推导隐式作用域(implicit scope)。本篇文章以仓库中 Hangar 测试套件针对 new_in_static.test.w 生成的 tf-aws 编译快照为实证样本,深入讲解「在静态方法、闭包、条件分支、构造函数与外部库中实例化 preflight 对象」时,编译器如何推导隐式作用域、如何把 Wing 语法改写为等价的 JS/JSII 调用,以及这些规则最终如何在 main.tf.json 中落地。
为什么需要「隐式作用域」:preflight 对象必须有归宿
在 Wing 的模型中,所有 preflight 对象(Resource 及其子类实例)都共同构成一棵应用树:顶层(全局作用域)实例化的对象是 root 节点的子节点,在某个类内部实例化的对象则是该类的子节点。文档 docs/docs/02-concepts/02-application-tree.md 明确说明:
- 每个实例在所属作用域内必须有唯一名称,默认名即类名,可用
as "custom-id"覆盖; - 可用
in scope显式指定作用域; - 实例化发生在静态方法内部、或发生在
super()调用参数等this尚不可用的位置时,默认使用调用方的作用域(scope of the caller)。
问题随之而来:当代码在静态方法中执行 new cloud.Bucket() 时,静态方法没有 this,编译器如何知道该 Bucket 该挂到树的哪个节点?这正是「隐式作用域」机制要解决的问题——它由编译器自动处理,无需开发者显式传递 scope 参数。
语言参考 docs/api/05-language-reference.md 中给出了 preflight 实例化语法的完整说明:
let <name>[: <type>] = new <Type>(<args>) [as <id>] [in <scope>];
- 默认 scope 为
this,即对象被定义(实例化)所在的上下文; - 若同一作用域内存在多个同类型 preflight 对象,则必须显式指定
id; - 顶层(entrypoint 的 block scope 根部)实例化的对象默认以 root app 作为 scope;
id必须是字符串,支持字符串插值;scope必须是能解析为 preflight 对象的表达式。
测试样本速览:new_in_static.test.w 覆盖的完整场景矩阵
Hangar 快照的原始输入是仓库中的 Wing 测试文件 tests/valid/new_in_static.test.w,它刻意编排了多个容易让编译器「找不到归宿」的实例化场景,逐一验证隐式作用域推导:
| 场景 | 源码位置 | 隐式作用域预期 |
|---|---|---|
| 静态方法创建 JSII 类实例 | MyClass.createBucket(scope) |
显式传入的 scope |
| 静态方法创建 Wing 类实例 | MyClass.createMyClass(scope) |
显式传入的 scope |
| 静态方法创建带隐式作用域的实例 | MyClass.createMyClassWithImplicitScope("from-root") |
调用方的 scope |
| 静态方法创建隐式作用域 Bucket | MyClass.createBucketWithImplicitScope() |
调用方的 scope |
| 静态方法互相调用 | MyClass.staticMehtodThatCallsAnotherStaticMethod() |
调用方的 scope |
| 闭包(箭头函数)中实例化 | let createBucket = () => { new cloud.Bucket() as "b1"; } |
闭包定义处的 scope |
条件分支(if true 块)中实例化 |
new cloud.Bucket() as "b2"; |
块所在作用域 |
| 实例方法调用静态方法 | my.instanceMethod() |
该实例自身 |
| 构造函数(ctor)中调用静态方法 | Foo 的 new() 里调用 |
该实例自身 |
| JSII 静态方法(无隐式作用域) | jsii_fixture.JsiiClass.staticMethod("foo") |
不传 scope |
| 外部库(lib)的静态工厂方法 | new_in_static_lib.LibClass.createFoo("lib-foo") |
调用方的 scope |
配套的库文件 tests/valid/new_in_static_lib.w 只定义了两个类:
pub class Foo {}
pub class LibClass {
pub static createFoo(id: str): Foo {
return new Foo() as id;
}
}
它验证的是另一个边界:从被 bring 引入的外部 .w 库中调用静态工厂方法创建 preflight 对象(对应 issue 6188 的修复),此时隐式作用域同样要落到调用方。
快照文件解读:编译器为每个产出物做了什么
4.1 快照的生成机制:Hangar 如何「编译并固化」测试
这份快照并非手写,而是由仓库的测试工具链自动生成的。核心流程在 tools/hangar/src/generated_test_targets.ts:
compileTest()对tests/valid/new_in_static.test.w执行wing compile -t tf-aws;- 从
target/new_in_static.test.tfaws/.wing目录收集preflight.*、inflight.*等文件内容; - 从
target/new_in_static.test.tfaws/main.tf.json读取 Terraform JSON(经由sanitize_json_paths清洗绝对路径、版本等不稳定字段); createMarkdownSnapshot()(见 tools/hangar/src/utils.ts)把文件按字典序写入<源码名>_compile_tf-aws.md,形成快照,之后用 vitest 的toMatchFileSnapshot进行回归比对。
也就是说,快照里的每一个 .cjs 代码块与 main.tf.json 都是从真实编译产物逐字固化下来的,具有极强的实证价值。
4.2 preflight.cjs:隐式作用域如何变成 $scope 参数
快照中的 preflight.cjs 部分 展示了 Wing 源码被降级为 JavaScript 后的完整形态,隐式作用域机制在此暴露无遗:
- Wing 静态方法被编译为带
$scope首参的 JS 静态方法。例如:
static createBucket($scope, scope) {
return globalThis.$ClassFactory.new("@winglang/sdk.cloud.Bucket", cloud.Bucket, scope, "Bucket");
}
static createBucketWithImplicitScope($scope) {
return globalThis.$ClassFactory.new("@winglang/sdk.cloud.Bucket", cloud.Bucket, $scope, "implicit-scope-bucket");
}
注意两个方法的差异:createBucket 把用户显式传入的 scope 原样用作构造 scope;createBucketWithImplicitScope 则把编译器注入的 $scope 参数用作了 scope——$scope 就是「隐式作用域」在 JS 层的载体。
-
在 JSII 实例化路径上,
globalThis.$ClassFactory.new负责按名称解析并创建资源:第一个字符串参数"@winglang/sdk.cloud.Bucket"是资源在 SDK 中的限定名,第二个参数是构造函数,第三个参数是 scope,第四个是 ID。这种「按名注册、工厂化创建」的间接层正是 Wing 支持多种平台(tf-aws、sim 等)的原因之一。 -
隐式 ID 带前缀与插值:
createMyClassWithImplicitScope编译后为:
static createMyClassWithImplicitScope($scope, id) {
return new MyClass($scope, String.raw({ raw: ["implicit-scope-myclass-", ""] }, id));
}
Wing 源码中的 new MyClass() as "implicit-scope-myclass-{id}" 被改写为 String.raw 模板拼接,{id} 插值在运行时求值。
- 静态方法内部互相调用时,
$scope沿调用链传递:
static staticMehtodThatCallsAnotherStaticMethod($scope) {
return (MyClass.createMyClassWithImplicitScope($scope, "from-outer-static-method"));
}
这正是 packages/@winglang/wingc/src/jsify.rs 第 852~873 行所实现的逻辑:当被调函数需要隐式作用域参数时,若当前函数本身也有隐式作用域参数,则直接传递 $scope(implicit_scope_arg_available 为真时用 SCOPE_PARAM,即 "$scope");否则回退为 this。同时,jsify.rs 第 1530~1549 行 负责在函数定义侧为带 implicit_scope_param 标记的方法补充 $scope 形参。这个标记本身在类型检查阶段被记录在 FunctionSignature::implicit_scope_param(见 packages/@winglang/wingc/src/type_check.rs),静态方法默认置位。
- 构造函数中调用静态方法:
Foo编译后的构造函数里直接调用MyClass.createMyClassWithImplicitScope(this, "from-ctor")——因为此时对象已在构造中,this已可用,所以 scope 就是this本身,Bucket 会挂在Foo实例之下。
4.3 main.tf.json:作用域最终落到云资源树
快照中的 main.tf.json 部分 揭示了隐式作用域推导的最终结果。共生成 5 个 aws_s3_bucket 资源与对应的 CORS 配置,其 path 元数据忠实反映了构造树层级:
"path": "root/Default/Default/b1/Default",
"path": "root/Default/Default/b2/Default",
"path": "root/Default/Default/implicit-scope-bucket/Default",
"path": "root/Default/Default/Construct/MyClass/implicit-scope-bucket/Default",
"path": "root/Default/Default/Construct/Bucket/Default"
逐条对应源码中的实例化点:
b1、b2:闭包与if true块内直接new cloud.Bucket(),其隐式作用域都是「定义处的顶层作用域」,因此都挂在root/Default/Default下;implicit-scope-bucket:MyClass.createBucketWithImplicitScope()从顶层被调用,$scope为顶层this,同样挂到root/Default/Default;Construct/MyClass/implicit-scope-bucket:my.instanceMethod()中调用createBucketWithImplicitScope(),作用域是该实例MyClass(其父级是显式Construct),路径前缀随之变成Construct/MyClass/...;Construct/Bucket:MyClass.createBucket(scope)中显式传入的scope即为Construct,作用域没有走隐式推导。
这 5 个桶的 path 恰好构成一个可对照的「对照组」:同一个 createBucketWithImplicitScope,从不同调用上下文出发,最终挂载点完全不同——这正是隐式作用域「调用方决定归宿」的直观证据。资源 ID 前缀(b1-c88fb896-、implicit-scope-bucket-c811b94a- 等)则是 Wing 为每个 bucket 生成的唯一物理名前缀。
此外,aws_s3_bucket_cors_configuration 块展示了 Wing 对 Bucket 默认附加的 CORS 配置(allowed_methods 覆盖 GET/POST/PUT/DELETE/HEAD,allowed_origins: ["*"]),这是 SDK 层 cloud.Bucket 的默认行为,与作用域机制无关,但可作为理解 main.tf.json 全貌的补充。
4.4 inflight.*.cjs 与库产物:lift 与外部模块的边界
快照中还包含若干 inflight 产物,它们演示了 Wing 的「lift」机制——preflight 对象进入 inflight 闭包时,编译器生成闭包工厂函数并通过 $helpers/$stdlib.core.liftObject 注入依赖。以 inflight.$Closure1-2.cjs 为例:
module.exports = function({ $bucket, $bucket3 }) {
class $Closure1 {
async handle() {
$helpers.assert($helpers.eq((await $bucket.list()).length, 0), "bucket.list().length == 0");
$helpers.assert($helpers.eq((await $bucket3.list()).length, 0), "bucket3.list().length == 0");
}
}
return $Closure1;
}
它对应 Wing 源码中的 test "play with buckets" 块(tests/valid/new_in_static.test.w 第 61~64 行):bucket 与 bucket3 被 lift 进闭包,_liftMap 声明了对它们 list 方法的访问权。而 inflight.Foo-1.cjs、inflight.LibClass-1.cjs、inflight.FooParent-2.cjs、inflight.MyClass-2.cjs 等则是对应 preflight 类的 inflight 形态(目前为空壳,因为测试类没有定义 inflight 成员),inflight.Foo-2.cjs 通过 module.exports = function({ $FooParent }) 展示了继承关系的 inflight 化——子类闭包工厂会注入父类的 inflight 形态。
preflight.newinstaticlib-1.cjs 是 new_in_static_lib.w 的独立编译产物,由主入口通过 $helpers.bringJs 加载。其中 LibClass.createFoo 被编译为:
static createFoo($scope, id) {
return globalThis.$ClassFactory.new("examples-valid.Foo", Foo, $scope, id);
}
值得注意的是其 scope 参数用的是 $scope 而非显式作用域——说明外部库中的静态工厂方法同样遵循隐式作用域规则,$scope 会由编译器在调用点注入调用方的上下文。这正好回应了源码注释中引用的 issue 6188(跨库 preflight 类创建的缺陷修复)。而 jsii_fixture.JsiiClass.staticMethod("foo") 这类 JSII 静态方法在导入时被标记为 implicit_scope_param: false(见 packages/@winglang/wingc/src/type_check/jsii_importer.rs),因此调用时不会被注入任何 scope 参数,快照中该调用就是普通函数调用,与资源无关。
从快照反推编译器实现:$scope 注入的两处关键代码
隐式作用域并非魔法,其实现集中在 Wing 编译器(wingc)的 JS 生成阶段。两个最关键的代码点是:
函数定义侧(packages/@winglang/wingc/src/jsify.rs):当类方法签名带有 implicit_scope_param 标记时,jsify_function 在参数列表最前面插入 $scope:
let parameters = if class_type.map_or(false, |t| {
t.as_class().unwrap()
.get_method(func_def.name.as_ref().unwrap()).unwrap()
.type_.as_function_sig().unwrap().implicit_scope_param
}) {
let mut res = CodeMaker::one_line(SCOPE_PARAM);
...
};
函数调用侧(packages/@winglang/wingc/src/jsify.rs):生成调用表达式时,若被调函数需要隐式作用域,则判断当前上下文是否可用 $scope(即当前也处于带隐式作用域的函数中),可用则传 $scope,否则传 this:
if function_sig.implicit_scope_param {
let implicit_scope_arg_available = ctx.visit_ctx.current_function_env().map_or(false, |e| {
if let SymbolEnvKind::Function { sig, .. } = e.kind {
sig.as_function_sig().expect("a function sig").implicit_scope_param
} else { false }
});
let prepend_scope_arg = if implicit_scope_arg_available {
SCOPE_PARAM.to_string()
} else {
"this".to_string()
};
...
}
这两段代码解释了快照中所有 $scope 的来源与去向:顶层调用(无 $scope 可用)注入 this;静态方法链式调用则把 $scope 一路透传;new 表达式则把 $scope/this 作为构造 scope 传给资源工厂。
实战要点与边界条件小结
基于这份快照及其源码,可以总结出 Wing 隐式作用域的实用规则:
- 静态方法天然携带隐式作用域:任何可能实例化 preflight 对象的静态方法,其 JS 形态都会被注入
$scope首参;从顶层调用时$scope即this(root),从其他静态方法调用时沿链传递,从实例方法/构造函数调用时指向该实例。 - JSII 静态方法不参与隐式作用域注入:导入时
implicit_scope_param被置为false,调用不会附加 scope 参数,因此不能用来实例化需要挂树的 Wing 资源——测试中它仅被用于纯函数式断言。 - 闭包与条件块中的实例化以「定义处作用域」为归宿:
b1、b2最终都挂在顶层root/Default/Default,不会因为闭包被延迟调用而改变挂载点。 - 跨库静态工厂方法同样支持隐式作用域:外部
.w库中的pub static方法编译产物同样接收$scope,由调用方编译器注入,这正是 issue 6188 修复后支持的模式。 - 构造树层级可从
main.tf.json的path元数据验证:每个资源节点的path就是隐式作用域推导的「最终答卷」,可用于调试资源归属。
延伸阅读
- 测试源码:tests/valid/new_in_static.test.w 与 tests/valid/new_in_static_lib.w
- 快照文件:tools/hangar/snapshots/test_corpus/valid/new_in_static.test.w_compile_tf-aws.md
- 快照生成机制:tools/hangar/src/generated_test_targets.ts、tools/hangar/src/utils.ts、tools/hangar/src/generate_tests.ts
- 编译器实现:packages/@winglang/wingc/src/jsify.rs、packages/@winglang/wingc/src/type_check.rs
- 语言语义文档:docs/api/05-language-reference.md、docs/docs/02-concepts/02-application-tree.md