方法能自带类型参数之后,接口方法集要单独想清楚|洹拓科技

一、文法变的是什么:一个方法同时带着两个来源的类型参数
此前这门语言的写法是受限的:类型参数只能声明在类型上,方法只能复用接收者那一组,自己不能另开一组。文法更新之后,接收者之后、方法名之后可以再跟一个类型参数列表,然后才是形参列表与函数体。也就是说,一个方法现在可能同时挂着两组类型参数,而且这两组是正交的——彼此独立,互不约束。
正交这件事的读法要先记住一句话:两组的确定时机不一样。
| 部位 | 写在哪里 | 由什么确定 | 什么时候定下来 |
|---|---|---|---|
| 接收者的类型参数 | 类型定义处,随实例出现 | 调用者手里那个实例的具体类型 | 构造容器时就已固定 |
| 方法的类型参数 | 方法名之后、形参列表之前 | 调用现场,通常由传入函数的返回类型推出来 | 每一次调用各自推导 |
规范给的示例正是这个结构:一个带类型参数的切片类型,其上定义一个接受函数并返回新类型切片的方法。接收者侧的那个参数由调用时的具体元素类型确定,方法侧的那个由传入函数的返回类型推导。写对了的话,这类容器操作能短一大截,原来需要包一层独立函数的映射、筛选、聚合,现在挂在类型自己身上就够了。
但短的另一面是读的人要多做一次推断。反例很常见:一个方法同时确定两组类型参数,两组都靠推导,调用行上看不到任何一个具体类型名,返回类型是“容器的元素类型经过某个函数变换之后的结果类型”。要读出这一行到底产出什么,得先在脑子里把传入函数的返回类型算一遍,再回来看接收者是什么。这类写法功能上没问题,读起来是负担,评审要拦的是这种,不是特性本身。什么时候必须显式写出来,判据只有一条:如果一行代码里需要推断的类型参数超过一个,或者推断结果会决定后续一段链路的元素类型,就把它写出来,别留给下一位读者。
二、推导优先级:不要赌顺序,把中间结果钉住
三大陷阱里最容易出事的是推导优先级。当一个形参的类型既能从实参本身确定、又能从上下文期望类型确定的时候,谁赢这件事不应该由调用者去猜。规范的写法是允许显式给出类型实参,工程上的规则是我们不给“看起来一定能推出来”留活口:能不赌就不赌,赌错的表现通常是编译期在很远的位置报一个类型不匹配,或者更糟——推导出一个合法但不是你想要的具体类型。
实操上两条固定动作。第一,用显式类型实参:任何出现在跨包公开方法上的类型参数,调用处一律写全,哪怕编译器不要求。内部的、生命周期只有几行的代码可以让它推。第二,用一个小变量把中间结果钉住:把一个链式调用的中间一步先赋给带显式类型的局部变量,再从这个变量继续。这样做的收益不只是可读,更重要的是把一个可能随上游类型变化而漂移的推导,变成了一个有名字、有类型、可被编译器检查的锚点。这条纪律在重构时尤其值钱——上游类型一改,钉住的地方立刻报错,而不是把错误推到下游几层之外。
三、类型推断边界的三种失败与各自的改写
第二类陷阱是类型推断边界。规范列出的失败面,我们对照现场写成三种,每种都有对应改写。
- 返回类型需要装箱。传入的函数在语义上返回的是接口或某个父类型,而方法侧的参数希望是一个具体类型,推出来的一致类型和期望不一致。改写:把返回类型显式写全,让推断只发生在接收者那一侧。
- 传入的是无返回的函数字面量。字面量本身没有可推出的返回类型,方法侧的类型参数没有信息来源。改写:先把它提升为一个有名字的函数或有显式类型的变量,再传进去;如果这个方法注定常接受过程式字面量,就说明它不该带方法侧的类型参数。
- 链式调用中间步骤的类型不可见。每一环都靠上一环推出来的类型继续推,任何一环推错,报错点落在最后。改写:链不许超过两环,超过就拆成带显式类型的局部变量。
这三种失败都不是语法错误,是“读不出来”。把改写规则写进代码规范,比在评审里逐条争论有效。
四、接口装不下泛型方法:四种替代设计与判据
第三类陷阱影响面最大:接口无法把泛型方法作为约束。也就是说,抽象层不能声明“任何实现我的类型都要提供一个自带类型参数的方法”。这不是一个可以靠写法绕开的小坑,它意味着抽象要换形态。洹拓内部的做法是把替代设计收敛成四种,按判据选。
需要说明,这四条只是我们内部的选型口径,不是对外的通用结论。
| 替代设计 | 什么时候选 | 付出什么 |
|---|---|---|
| 把行为做成包级的泛型函数,而不是方法 | 行为是纯粹的输入到输出变换,不需要跟随对象状态 | 调用形态从对象方法退回函数,可读性略降,链式写法没有了 |
| 用具体类型参数的窄接口 | 抽象点上的类型组合是有限且可枚举的 | 每种类型组合一份接口,实现数量上升 |
| 按类型做代码生成 | 类型组合多但形状一致,且构建流程可承载生成 | 多一道生成物与一份模板,评审要看生成配置 |
| 用无约束类型加运行时校验 | 需要真正的可替换性,且能接受失败在运行期暴露 | 编译期保障消失,必须自带校验与用例覆盖 |
判据是四条,按顺序问:要不要可替换(同一位置换不同实现)、要不要可测(测试能不能对着抽象写)、是不是跨包暴露(对外接口一旦定型难以收回)、会不会出现在依赖注入容器里(容器通常按接口注册,装不下泛型方法就直接注册不了)。前两条指向窄接口或代码生成,第三条把选项推向包级函数——因为跨包暴露的东西要能被对方用一句话说明白,方法链带两组类型参数做不到,第四条则几乎必然把方案推到“具体类型参数的接口”这一侧,因为容器里登记的必须是一个可命名的抽象。
五、方法集这一列变了:接口实现判定、包装器与依赖注入
方法集兼容性是最容易被漏掉的一条,因为它的表现不在编译出错的那一行,而在“看起来实现了但没实现”。带类型参数的方法不进方法集。这直接改变两件事。
第一是接口实现判定:一个类型可能看起来提供了某个能力,但那个能力因为是泛型方法而没进方法集,于是它并不满足接口。第二是包装器写法:嵌入一个对象来转发能力的包装类型,原来靠方法集自动提升就能满足接口,现在那一项要手写。手写就意味着它会随上游演进而漏更新。
因此代码评审要专门加一列,问一个具体问题:这个类型声称实现了哪个接口,依据的方法集里有没有因为带类型参数而被排除的项。我们把这个检查做成了一个很小的动作——每个包装器和每个声称实现接口的类型,在改动时把接口方法逐条抄出来,对每条写一行“由哪个具体方法满足”,写不出来的那一条就是漏项。这一列同时喂给依赖注入:容器按接口注册时,实现方少一项能力不会在注册时报错,只会在运行到那条分支时才暴露。
六、可读性纪律、按实例化展开用例、上公共 API 前先量工具链
最后收三件维护侧的事。
可读性与维护纪律,我们定三条并且按条检查:一个方法的类型参数不超过两个,超了就说明这个方法在做多件事,拆;命名要表达来源,接收者那一组用容器侧的字母、方法那一组用变换结果的字母,比如前者写作 E 表示元素、后者写作 F 表示变换之后的结果,读到名字就知道它从哪儿来,不许两组都用无意义单字母;跨包暴露的接口上慎用连续的泛型方法链,链越长,读代码的人需要维护的推断就越多,而对外接口不能要求使用者具备这种耐心。
测试侧的调整容易被低估。同一个泛型方法,不同实例化可能走不同的推断路径,甚至触发不同的行为,所以只测一种实例化不能算覆盖。做法是从调用点反查:把这个方法实际用到的类型组合列出来,逐组补一条实例化用例,并让这张表进代码库跟着调用点更新——新增一组调用,就先新增一条用例。评审时对照“用例表里的组合数量是否等于调用点的组合数量”,这一条比讨论测试是否充分更好判。
给做架构的人一条前置动作:新语法进入公共 API 之前,先评估工具链支持面。静态检查规则认不认这种文法、格式化工具会不会把它改坏、编辑器补全和跳转是否跟得上、以及最现实的一条——依赖这个接口的下游,它们所用的语言版本吃不吃这套写法。语言特性在不同支持度之间切换,会让同一份代码在不同环境编译不过,而这类事故通常发生在下游的构建机上,不在你的开发机上。这一条我们记在选型结论里,特性带来的收益可以谈,工具链和支持面必须先量完;本篇刻意不对性能做任何比较,那需要另一套测量前提。