map的key可以试一个数组吗?_TypeScript类型元编程:实现一个脚本语言(黑魔法篇)...

本文探讨了在TypeScript中遇到类型实例化深度限制的问题,通过黑魔法般的类型元编程技巧,如提前计算类型、展开递归循环等方法,实现了在有限的类型实例化深度下解析更多语句。文中详细介绍了如何优化解析脚本语言的parser,以处理更复杂的语法结构,包括多条语句和嵌套代码块。
摘要由CSDN通过智能技术生成

这段时间有些颓废,这一篇鸽了好久甚至不想往下写了,太奇技淫巧的东西可能也没人会用到,不过还是编完吧。

上一篇里我们用类型实现了一个脚本语言的 parser,其实试一下就会发现,如果多写几条语句就会出现类型实例化过深的错误:

// ts2589: Type instantiation is excessively deep and possibly infinite.
type manyStatements = Parse<[
    1, 2, 3, 4, 5, 6
]>;

// ts2589: Type instantiation is excessively deep and possibly infinite.
type deepDepth = Parse<[
    If, true, [
        If, true, [
            If, true, [
            ]
        ]
    ]
]>

这是因为 TS 有类型实例化深度最大 50 层的限制,而我们的 parser 里很多地方都用了递归的类型。上面的代码才几条表达式语句或者三层语句块就报错了,这太不 scale 了,是时候使用黑魔法了。

先来看第一种情况,只有连续的多条语句,我们现在分析多条语句是这样实现的:

// 分析多条语句
type ParseStatements<Context> =
    Switch<PeekToken<Context>, {
        // 如果发生错误或输入结束,退出循环
        EOF: Context;
        ERROR: Context;
        default:
        // 每次迭代返回的是已经分析的语句数组
        GetReturn<Context> extends infer StatementList
        // 分析下一条语句
        ? ParseStatement<Context> extends infer Context
        ? GetReturn<Context> extends infer Statement
        // 将语句 Push 进数组进入下一次迭代
        ? ParseStatements<
            SetReturn<Context, Push<StatementList, Statement>>
        >
        : never : never : never;
    }>;

可以看到,每次分析一条语句然后递归,在这个过程中 Switch 和其第二个参数的对象类型,以及分支中的 条件类型 都是类型实例。类型实例化时会递增深度计数,返回时递减回去。在上面的代码中,整个类型都依赖递归的部分,递归的部分返回整个类型才能确定,所以每次递归都会增加几次实例化计数,难怪递归了几次就超出上限了。

其实仔细看一下会发现,之前这段代码的写法有点问题,我把对 ParseStatements 的递归调用放在了 条件类型 里,其实在递归之前这些类型都是可以确定的,可以改下一下让它们提前返回:

// 分析多条语句
type ParseStatements<Context> =
    Switch<PeekToken<Context>, {
        // 如果发生错误或输入结束,退出循环
        EOF: Context;
        ERROR: Context;
        default:
        ParseStatements<
            // 每次迭代返回的是已经分析的语句数组
            GetReturn<Context> extends infer StatementList
            // 分析下一条语句
            ? ParseStatement<Context> extends infer Context
            ? GetReturn<Context> extends infer Statement
            // 将语句 Push 进数组
            ? SetReturn<Context, Push<StatementList, Statement>>
            : never : never : never
        >;
    }>;

这样改写后之前的例子 manyStatements 就不会报错了。但是继续增加语句发现到 12 个就又报错了:

// ts2589: Type instantiation is excessively deep and possibly infinite.
type manyStatements = Parse<[
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    1, 2,
]>;

不过我们了解到,只要能更进一步地提前确定一部分类型,就可以减少实例化深度,获得更多的迭代次数。虽然这个类型已经没有什么部分可以提取到递归之前了,但是,我们可以把递归(或者说循环)进行展开。如果我们在一次迭代中分析多条语句,同样的迭代次数就可以分析更多的语句了:

type ParseStatements<Context> =
    Switch<PeekToken<Context>, {
        EOF: Context;
        ERROR: Context;
        default: ParseStatements<
            ParseStatements2<Context>
        >
    }>;

type ParseStatements1<Context> =
    GetReturn<Context> extends infer StatementList
    // 如果上一次返回值是错误,停止分析
    ? StatementList extends SyntaxError ? Context
    // 分析一条语句
    : ParseStatement<Context> extends infer Context
    ? GetReturn<Context> extends infer Statement
    // push 进语句数组并返回
    ? SetReturn<Context, Push<StatementList, Statement>>
    : never : never : never;

type ParseStatements2<Context> =
    // 连续分析两条语句
    ParseStatements1<ParseStatements1<Context>>

因为要展开循环所以在每次分析语句前都进行终止条件判断,把这一部分提取出来(ParseStatements1),连续调用两次就可以一次解析最多两条语句。再试一下,可以分析最多 20 条语句了:

type manyStatements = Parse<[
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
    1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
]>;

那么是不是只要不断增加调用的次数就可以分析无限多的语句了呢?可惜并不能,因为泛型参数也要实例化,而且是惰性实例化的(具体可以看 TS 源码中 getTypeAliasInstantiation 的实现),这导致连续调用带泛型类型会递归地实例化参数,所以实例化深度会随调用次数增加。如果尝试这么做,最多也只能提高到分析 60 多条语句。

如果只是做到这种程度的话就不叫黑魔法了,其实还可以继续 hack。我们现在知道,连续调用两次 ParseStatements1 深度增加两层,可以分析两条语句。如果调用两次 ParseStatements2 呢?不出意外的话深度应该是 2 + 2 = 4 层,可以解析 2 x 2 = 4 条语句。以此类推,如果我们继续这样嵌套下去,可以在实例化深度为 2n 的情况下一次性分析 2 的 n 次方 条语句, 2 的 n 次方 增长速度是比 2n 快的,所以我们可以用较少的实例化深度迭代更多次。

但如果实际写一下会发现,即使我们写成多个泛型类型,实例化的时候最终都是惰性(递归地)计算的,也就是说深度也是按 2 的 n 次方 增长的。好在我们可以用 infer 语法来提前计算出类型:

type ParseStatements2<Context> =
    ParseStatements1<Context> extends infer Context
    ? ParseStatements1<Context> : never;

type ParseStatements4<Context> =
    ParseStatements2<Context> extends infer Context
    ? ParseStatements2<Context> : never;

type ParseStatements8<Context> = ...

由于 infer 提前计算了类型,第二次调用时参数使用 infer 出的类型变量,避免了递归实例化类型,所以两次调用都只会增加一层实例化深度。但是由于 条件类型 本身也要实例化,所以总体仍然是两层,和改写前其实一样,但是互相调用时不会递归了。

实际上在 leetcode 上有一个整数拆分问题,题解中证明了将整数拆分成最接近常数 e 也就是 3 时,乘积最大。但实际上上面的类型不止会产生 2 层类型,这么写已经是最接近 3 了。

试了一下,如果一次迭代分析 32 条语句,上面的例子可以提高到分析 160 多条语句,没法继续 hack 了。

再来看 deepDepth 的例子,上面的奇技淫巧之所以管用是因为类型计算是向量化的,在分析时并不关心具体是什么语句。但是代码块存在于各种语句中,不能简单的组合到一起连续分析,所以上面的黑魔法就不管用了。

然而还是有办法的。因为我们的语法是上下文无关的,分析语句块时也不需要关心前后上下文,所以我们可以把整个输入中所有语句块分别分析,技巧就是利用 映射类型

按照我们之前的设计,输入的代码是一个 token 数组,其中嵌套的数组表示代码块:

// 当前的实现直接从输入分析连续的语句
type Parse<Tokens extends any[]> =
    GetReturn<ParseStatements<
        ParserContext<Tokens, []>
    >>;

// 示例代码
type ast = Parse<[
    If, "y", gt, "x", [
        Return, "y"
    ],
    Return, "x"
]>;

我们把 ParseParseBlock 的逻辑修改一下:

// 从输入的 token 分析语法树
type Parse<Tokens extends any[]> =
    GetReturn<FullParse<Tokens>>;

// 完全分析一段输入
type FullParse<Tokens> =
    PreParse<Tokens> extends infer R ? PostParse<R> : never;

// 预先分析输入中的语句块
type PreParse<Tokens> = {
    [P in keyof Tokens]: Tokens[P] extends any[]
        // 如果当前 token 是数组,分析语句块
        ? FullParse<Tokens[P]>
        // 其他 token 不做处理
        : Tokens[P];
}

// 后续分析其余的语句
type PostParse<Tokens> =
    ParseStatements<ParserContext<Tokens, []>>;

// 修改一下语句块的实现
type ParseBlock<Context> =
    // 输入的语句块经过预先分析应该变成了包含结果的上下文对象
    Scan<Context, any> extends infer Context
    ? GetReturn<Context> extends infer BlockContext
    ? BlockContext extends ParserContext
    // 返回预先分析出的语句块的结果
    ? SetReturn<Context, GetReturn<BlockContext>>
    : ThrowSyntaxError<Context, "code block">
    : never : never;

我们把 Parse 拆成了 PreParsePostParse,再组合成 FullParsePreParse 利用 映射类型 对输入中的语句块(数组)先行分析,PostParse 继续将其余的输入分析成语句。其实到这里我也没法解释了,因为其实有一些怪异的现象,不过现在我们可以分析更长、更多嵌套(虽然不是很多)的语句了。其实还有个问题是表达式不能过长,可能得稍微修改一下语法,先就这样吧……

仿照之前的内容,可以很容易地实现更多语法,不过如果尝试解释执行又会遇到限制不能执行较长的代码,要想用上面的技巧解决这个问题就得使代码向量化,也就是编译成中间表示。然而下一篇又要鸽了,以上都是我胡编的。

最后放上 Playground。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值