ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

TS协变与逆变:函数参数与strictFunctionTypes解析

TS协变与逆变:函数参数与strictFunctionTypes解析 你是不是也遇到过这种场景明明Dog是Animal的子类但一个接收Dog作为参数的回调变量却不能被赋值为一个接收Animal的回调反过来接收Animal的函数却能顺利塞进接收Dog的地方。头一回见这事的 TypeScript 开发者基本都是一脸懵甚至会怀疑自己是不是把面向对象里的“继承”概念记反了。别慌这就是类型系统里的协变和逆变在作怪。Dog作为子类型兼容Animal这是“协变方向”的直觉但函数参数位置恰好是“逆变方向”——参数类型要求更宽泛而不是更具体。本文从原理到实操把 TypeScript 里协变与逆变、函数参数为什么逆变、以及strictFunctionTypes到底修正了什么这些问题一次讲透。这个话题也是 TypeScript 面试里的高频考点搞懂了它再遇到类型兼容性的问题你基本就不会踩坑了。这里先声明一下本文讨论的“逆变”是类型系统理论里的概念不是电力电子里“三相光伏逆变器”“单相逆变电路”那种逆变。两者没有关系别混为一谈。1. 先把协变、逆变这两个词翻译成人话1.1 一切从“子类型兼容”开始在 TypeScript 这种结构化类型系统里判断一个类型能不能赋值给另一个类型核心标准就是“所有需要的属性/方法是否都存在”。Dog extends Animal意味着Dog拥有Animal的全部成员所以Dog可以赋值给Animal。interface Animal { name: string; } interface Dog extends Animal { bark(): void; } const dog: Dog { name: 旺财, bark() {} }; const animal: Animal dog; // 没问题Dog 是 Animal 的子类型这个方向是我们熟悉的子类型可以出现在父类型的位置上。但一旦我们把这些基础类型塞进某些“复合类型”里情况就开始变得复杂。最常见的一大类复合类型就是泛型容器比如数组、对象以及函数。1.2 复合类型里还保不保留“子类型方向”假设我们有Dog是Animal的子类型。现在考你一个问题Dog[]是不是Animal[]的子类型答案是在 TypeScript 中“是”。Dog[]可以赋值给Animal[]因为数组是协变的——它在泛型类型参数上保持了子类型方向。const dogs: Dog[] []; const animals: Animal[] dogs; // 允许数组协变那如果套在函数类型里呢(x: Dog) void是不是(x: Animal) void的子类型这次就不是了。从类型安全角度看(x: Animal) void反而可以赋值给(x: Dog) void因为这个能处理所有Animal的函数处理传入的Dog当然也没问题。注意看方向这里复合类型的兼容方向和原有子类型方向是相反的。这种“反着来”的关系就是逆变。1.3 用一张表理清方向感为了不晕我们约定一个说法当Sub是Super的子类型时复合类型类型参数方向结论名称Sub[]可赋给Super[]保持Sub → Super子类型方向不变协变(x: Super) void可赋给(x: Sub) void与Sub → Super相反子类型方向反转逆变PromiseSub可赋给PromiseSuper保持协变协变某些同时兼具读写功能的结构都可能不安全双向都不稳定不变从这个表里能看出一个规律返回位置通常协变参数位置通常逆变。为什么会这样下一节我们从函数调用的真实场景一步步推导。2. 为什么函数参数必须逆变一个安全推演2.1 如果参数协变马上就会出事故先假设一个错误的世界如果函数参数也允许协变那(x: Dog) void就可以被当作(x: Animal) void使用。然后我们这样写function handleDog(dog: Dog) { dog.bark(); } const animals: Animal[] [{ name: 猫 }, { name: 鸟 }]; // 如果这里不报错实际在严格模式下会报错 const handleAnimal: (x: Animal) void handleDog; animals.forEach(handleAnimal);上面这段代码里handleAnimal表面上是接受任意Animal但底层实际调用的是handleDog。当animals数组里混入一只没有bark方法的猫时运行时就会炸出“dog.bark is not a function”。这不是假设而是不安全的参数协变一定会导致的后果。所以 TypeScript 不能允许(x: Dog) void直接赋值给(x: Animal) void除非你想在运行时天天接“类型错误”。2.2 站在调用点的视角重新推导我们换一个更严谨的思路。假设有两个函数类型type HandlerAnimal (x: Animal) void; type HandlerDog (x: Dog) void;现在有一个变量它的类型被声明为HandlerDog也就是说调用者只能传Dog进去。这时如果把一个HandlerAnimal类型的函数塞给它会发生什么调用方传入的是Dog而HandlerAnimal能处理所有AnimalDog属于Animal所以一切安全。反过来就不行了const handlerDog: HandlerDog (dog: Dog) { dog.bark(); }; // 这段代码在严格模式下无法通过 const handlerAnimal: HandlerAnimal handlerDog;为什么因为HandlerAnimal类型的调用方可能传入任意Animal但底层实际函数只认Dog一旦传入Cat或者Bird就会找不到bark方法。结论很明显在参数位置上函数能够接受的类型必须“更宽泛”而不是“更具体”。这种让宽泛类型兼容到具体类型位置上的方向就叫逆变。2.3 返回类型为什么反而是协变如果你理解了参数逆变返回类型协变就好懂了。函数返回值是“生产”给调用方的结果调用方期望拿到Animal时你给它一个Dog完全没问题——因为Dog具备Animal的全部能力这是典型的“子类可以出现在父类位置”的场景。反过来调用方期望Dog你实际返回Animal它想调用bark()时就会失败。所以返回类型和参数类型的兼容方向天然相反返回位置协变参数位置逆变。这是由“调用方对数据流的控制权”决定的。你提供的参数由外部传入你必须能处理所有可能传入的情况返回结果由你产出你要保证它能满足调用方对更具体类型的要求。2.4 一个万能插座类比我常用“插座”来解释逆变。假设一个函数就是一个插座接收Animal参数的函数是“万能插座”几乎什么设备都能插接收Dog参数的函数是“专用插座”只能插特定设备。当某个接口承诺“我只需要一个 Dog 的处理器”时你提供一个万能插座当然没问题反正只会拿 Dog 来插它。但当接口承诺“我需要一个能处理所有 Animal 的处理器”时你塞给它一个专用插座它未必插得进去。所以专用插座不能伪装成万能插座但万能插座可以降级给专用插座用——这就是逆变的方向感。3. TypeScript 的真实行为默认双变strictFunctionTypes 修正3.1 为什么 TypeScript 默认没有严格逆变理论上参数应该严格逆变但实际情况更复杂。如果你在旧版 TypeScript 或未开启strictFunctionTypes的项目里跑刚才的代码可能会发现(x: Dog) void赋值给(x: Animal) void居然没报错。这听起来很反安全但在 TypeScript 早期是“有意”的宽松设计。原因很实际TypeScript 要兼容大量现存的 JavaScript 代码和 DOM API很多场景下如果严格执行参数逆变会遇到一堆类型报错。而且 TypeScript 本身是结构化类型系统又带很强的“实用主义”基因——它优先保证“能用”而不是从理论模型里推导每一个安全边界。所以在历史上的默认配置里函数参数采用的是双变既允许接收更宽泛的参数类型也允许接收更具体的参数类型。3.2 严格模式修正了什么strictFunctionTypesstrictFunctionTypes是 TypeScript 在 2.6 版本引入的编译选项。开启后函数独立的函数类型type 声明的函数类型、函数属性在参数位置会强制实施逆变检查。我们用具体例子看效果interface Animal { name: string; } interface Dog extends Animal { bark(): void; } type HandlerAnimal (x: Animal) void; type HandlerDog (x: Dog) void; let handlerAnimal: HandlerAnimal (animal: Animal) { console.log(animal.name); }; let handlerDog: HandlerDog handlerAnimal; // 允许参数逆变HandlerAnimal 兼容 HandlerDog let handlerDog2: HandlerDog (dog: Dog) { dog.bark(); }; // 开启 strictFunctionTypes 后这里会报错 // Type HandlerDog is not assignable to type HandlerAnimal let handlerAnimal2: HandlerAnimal handlerDog2;在tsconfig.json中开启方式很简单{ compilerOptions: { strict: true } }strict为true时strictFunctionTypes也会被打开。如果你想单独控制它可以显式写{ compilerOptions: { strict: false, strictFunctionTypes: true } }但一般不建议把strict整个关掉因为你同时会失去strictNullChecks、noImplicitAny等一大票保护。3.3 方法参数双变一个必须知道的例外如果你在项目里开了strictFunctionTypes然后信心满满地以为所有“参数更具体的函数”都会被拦下来那就会遇到下一个坑方法声明不受这个限制。也就是说接口里用method(x: Dog): void语法声明的方法参数检查依然走双变。interface Foo { handle: (x: Dog) void; // 函数属性严格模式下逆变检查 } interface Bar { handle(x: Dog): void; // 方法语法双变宽松 }把Bar类型的对象赋值给一个期望handle: (x: Animal) void的变量时TypeScript 可能不会报错。这是有意为之的TypeScript 团队发现如果对数组、类、接口里的方法也实施严格的参数逆变会破坏大量现有的类继承结构。比如ArrayT的很多方法在子类型关系中会变得极其难用。所以最终保留了这个“漏网之鱼”。理解这个例外很重要。面试中面试官经常拿出methodvsproperty function来问能回答清楚的人并不多。3.4 开启严格模式后的收益和迁移成本收益非常直接编译期拦截掉一批真的会在运行时炸掉的类型问题。尤其是写事件处理器、回调函数、异步函数、策略模式这类代码时严格逆变能帮你发现“参数写窄了”的错误。很多看起来能跑、一上线就偶发的 bug根源就是回调参数被错误地替换成了子类型然后传入了超类型实例。代价也很明确历史项目里会有大量已有代码过不了编译。尤其是一些定义不够严谨的库或是在代码里大量使用“方法语法”和“宽泛 any”的地方。迁移时通常需要先开启严格模式然后逐个文件修正类型——这个过程有点痛苦但值得。我的经验是先从新增代码开始用严格模式再分批改造存量模块而不是指望一次全量切换。4. 实操经验在项目里写出类型安全的函数类型4.1 利用逆变思想设计事件回调假设你正在设计一个订阅系统回调会接收到具体事件。从安全角度来说回调的参数应该声明成更宽泛的基类而派发事件时可以通过类型收窄得到具体事件interface BaseEvent { timestamp: number; } interface ClickEvent extends BaseEvent { x: number; y: number; } // 回调参数使用 BaseEvent利用逆变可以安全接收更具体的 ClickEvent type EventHandler (event: BaseEvent) void; function dispatchClick(handler: EventHandler) { const event: ClickEvent { timestamp: Date.now(), x: 1, y: 2 }; handler(event); } dispatchClick((e: BaseEvent) { console.log(e.timestamp); });这里dispatchClick只接收EventHandler类型也就是参数为BaseEvent的回调。如果你传入一个参数声明为ClickEvent的回调严格模式下会报错。为什么不建议这么做因为dispatchClick可能不止发送ClickEvent未来还发送KeyEvent一个只认ClickEvent的回调在收到其他事件时就会崩溃。把回调参数声明得越宽泛越能应对未来变化。4.2 当你需要“反向推导”参数类型时怎么办有一种场景让我经常感叹逆变好用写一个“接收处理器并立即调用”的工具函数并用参数类型约束输入。function withDog(handler: (dog: Dog) void): void { const dog { name: 旺财, bark() {} }; handler(dog); } // 可以传一个能处理所有 Animal 的函数 withDog((animal: Animal) { console.log(动物名字 animal.name); }); // 但不能传一个只处理“宠物店里的特殊狗”的函数 withDog((superDog: Dog { special: boolean }) { // 报错参数类型必须能处理 Dog });这其实就是“最小化你的依赖最大化你的接受范围”的另一种表达函数参数写得更宽泛你就能接受更多其他函数反之你写得太窄就会把自己锁死。4.3 关键避坑任何类型都是双边的“作弊码”有读者可能想既然逆变这么严格我直接把参数改成any不就好了确实any在 TypeScript 里会绕过几乎所有类型检查包括逆变检查。但使用any就像吃止痛药它掩盖了问题而不是解决了问题。如果项目里有大量any严格模式的价值就大打折扣。更推荐的替代方案是unknown。unknown是所有类型的“超类型”用在参数位置时非常安全因为任何值都能赋给它。但要注意unknown虽然宽泛却会限制你直接访问属性。比如(x: unknown) void的函数内部不能直接访问x.name需要先做类型收窄。这是有意为之的安全护栏而不是bug。4.4 在 tsconfig 中一步到位地开启严格检查我建议在新项目里直接strict: true。如果因为历史代码需要逐步放宽可以考虑这样配{ compilerOptions: { strict: true, strictFunctionTypes: true } }注意strictFunctionTypes默认就是跟着strict走的所以写不写都行。真正要留意的是不要把strictFunctionTypes: false和strict: true同时出现除非你明确知道自己在干什么。有些团队为了兼容老的第三方类型声明会狡猾地只关掉这一个选项但长期看是给自己埋雷。5. 常见问题与排查技巧实录5.1 为什么我的代码里函数参数赋值没报错这是最常被问到的问题。一段看起来“参数更宽泛”或“参数更具体”的赋值居然编译器毫无反应。排查顺序如下先确认编译器配置在tsconfig.json里看strictFunctionTypes是否为true。如果你没开参数就是双变的什么方向都能过。再看这个成员是用方法语法还是函数属性声明的。接口里的handle(x: Dog): void属于方法声明不受严格函数类型检查约束。检查操作对象有没有在中间被隐式转换成any或unknown。任何any都会让类型检查失效。如果使用了泛型并且泛型约束里带了any或索引类型也有可能绕过检查。很多“明明该报错为什么不报错”的案例最终都落在这四个原因里。5.2 什么时候需要主动放宽逆变有一种场景是合理的你写了一个底层通用调度函数它只负责“接收一个函数在合适的时机调用”并且你完全控制调用时传入的参数值同时上游也保证不会传错。这时可以临时使用类型断言或方法语法来绕过严格逆变。但我会建议把这种“绕过”封装在一个很小的、有注释的边界函数里不要把问题散播到整个项目。type Handler (x: Animal) void; // 这里使用断言绕过了检查但调用方必须保证传入的一定是 Dog 类型 const handler: Handler ((x: Dog) { x.bark(); }) as Handler;这种代码本质上是在和编译器签“免责协议”我知道潜在风险并且会严格管理调用点。如果不加注释三个月后的你一定会忘记这里埋了什么。5.3 数组协变和函数逆变是一对矛盾面试里常被问到既然函数参数要逆变为什么数组却协变这不是 TypeScript 的逻辑矛盾而是“理想模型”和“实用主义”的取舍。纯理论集合上可变数组应该是不变的你既能读又能写一旦允许子类型数组赋值给父类型数组随时可能把其他子类型写进去破坏类型安全。TypeScript 选择允许数组协变是因为如果完全禁止像ArrayDog赋值给ArrayAnimal这种极其常见的操作会处处报错体验崩塌。为了避免出现这种“不可用”它选择让数组协变同时你知道 Array 的push方法其实存在运行时的写风险。这是一种权衡不是逻辑错误。5.4 一句话解释给面试官如果面试官让你用一句话解释“为什么函数参数是逆变的”可以这么说函数能被调用时参数都是由调用方传入的。调用方只会按它声明的参数类型去传值如果这个函数实际只能处理一个更窄的类型就可能收到自己处理不了的值。所以参数方向必须“反过来”函数声明的参数类型要能覆盖调用方可能传入的所有情形于是宽泛的参数类型可以兼容到更窄的调用位置上去。再加上一句返回类型不受调用方控制由函数自己产出所以它天然是协变的。这两句话讲完面试官基本就知道你懂原理而不是背概念。5.5 实战中的小习惯最后分享一个我在代码 review 里的习惯看到函数参数写得特别窄比如把Animal收窄成Dog我一定会多问一句“这个回调在别的场景会不会被复用”很多组件间通信的 bug都是因为回调参数写得太具体调用方无法传入更通用的数据最后被迫用any绕过。反过来如果一开始就主动把回调参数声明得宽泛一点整个系统会稳得多。踩过几次坑之后我现在写公共函数时默认都会把回调参数设计成“刚好能覆盖调用方全部可能输入”的类型而不是恰好等于当前唯一传参类型。这样既符合逆变的安全要求也让接口未来更有弹性。这个习惯从一个简单的类型理论话题延伸出来但真正改变的是你对“函数契约”的理解。
返回列表