Skip to content

协变与逆变

协变(Covariance)和逆变(Contravariance)描述的是:当类型之间存在子类型关系时,包含这些类型的复合类型(如函数、数组)的子类型关系如何变化

这是理解函数类型兼容性报错的关键概念。

子类型是什么

先建立一个贯穿全文的例子。GIS 里的地理要素有明确的继承关系:

ts
class Geometry {
  wkt: string = ''         // Well-Known Text 表示
}

class Point extends Geometry {
  x: number = 0
  y: number = 0
}

class LineString extends Geometry {
  points: Point[] = []
  length(): number { return 0 }
}

PointGeometry 的子类型。这意味着任何需要 Geometry 的地方,传入 Point 都是安全的(里氏替换原则)。

Point  <:  Geometry       (读作:Point 是 Geometry 的子类型)

协变(Covariant):方向一致

函数返回值是协变的:子类型关系与原始类型方向一致。

Point  <:  Geometry
                ↓ 方向一致
() => Point  <:  () => Geometry

直觉上很好理解:一个"返回具体点位"的函数,可以放在"返回任意几何体"的位置用——调用方只需要一个 Geometry,你给了一个更具体的 Point,完全没问题。

ts
type GetGeometry = () => Geometry
type GetPoint = () => Point

// ✅ 协变:可以把 GetPoint 赋给 GetGeometry
const getPoint: GetPoint = () => new Point()
const getGeometry: GetGeometry = getPoint

数组也是协变的(在 TS 中):

ts
const points: Point[] = [new Point()]
const geometries: Geometry[] = points   // ✅ Point[] 可赋给 Geometry[]

GIS 场景

渲染引擎接受一批几何体并渲染:

ts
function renderAll(items: Geometry[]): void {
  items.forEach(g => console.log(g.wkt))
}

const pointLayer: Point[] = [new Point(), new Point()]
renderAll(pointLayer)  // ✅ 协变,Point[] 兼容 Geometry[]

逆变(Contravariant):方向相反

函数参数是逆变的:子类型关系与原始类型方向相反。

Point  <:  Geometry
                ↓ 方向相反
(g: Geometry) => void  <:  (p: Point) => void

这里比较反直觉,需要从"安全性"角度思考:

  • (p: Point) => void:调用时会传入一个 Point,函数内部可能用到 p.xp.y
  • (g: Geometry) => void:调用时会传入任意 Geometry,函数内部只能用 g.wkt

如果把一个"接受 Geometry"的函数放在"接受 Point"的位置,调用方传来 Point,函数只访问了通用属性,不会出问题

反过来不行:把"接受 Point"的函数放在"接受 Geometry"的位置,调用方可能传来 LineString,而函数内部尝试访问 p.x就崩了

ts
type HandleGeometry = (g: Geometry) => void
type HandlePoint = (p: Point) => void

// ✅ 逆变:可以把 HandleGeometry 赋给 HandlePoint
const handleGeometry: HandleGeometry = (g) => console.log(g.wkt)
const handlePoint: HandlePoint = handleGeometry

// ❌ 反方向不行
const handlePointOnly: HandlePoint = (p) => console.log(p.x, p.y)
const handleGeometryWrong: HandleGeometry = handlePointOnly  // Error(开启 strictFunctionTypes 后)

GIS 场景

地图事件监听器的类型兼容性:

ts
type ClickHandler<T extends Geometry> = (feature: T) => void

// 注册点击事件,期望一个处理 Point 的回调
function onPointClick(handler: ClickHandler<Point>) {
  handler(new Point())
}

// 一个能处理任意 Geometry 的通用回调
const logGeometry: ClickHandler<Geometry> = (g) => console.log(g.wkt)

// ✅ 逆变:处理 Geometry 的函数,可以安全地处理 Point
onPointClick(logGeometry)

双变(Bivariant)

TS 在默认配置下,方法(method syntax)的参数是双变的——既可以协变也可以逆变,即宽松的兼容检查。

开启 strictFunctionTypes: truestrict: true 已包含)后,函数类型字面量(p: T) => void 形式)的参数会严格按逆变处理,但 method 语法method(p: T): void)仍保持双变。

ts
interface Processor {
  // method 语法:双变(宽松)
  processMethod(g: Geometry): void

  // 属性函数语法:严格逆变(开启 strictFunctionTypes 后)
  processFunc: (g: Geometry) => void
}

不变(Invariant)

如果一个类型在某个位置既不能协变也不能逆变,则称为不变。泛型类型在读写都允许的场景下通常是不变的。

ts
// 假设有一个可读写的容器
interface Box<T> {
  get(): T      // 返回值位置:协变
  set(val: T): void  // 参数位置:逆变
}

// Box<Point> 既不是 Box<Geometry> 的子类型,也不是父类型
// 因为同时有协变和逆变的需求,两个方向都无法兼容

总结

位置变性子类型方向
函数返回值协变与原始类型一致
函数参数逆变(严格模式)与原始类型相反
可读写容器不变两个方向都不兼容
method 语法参数双变两个方向都兼容

记忆口诀:产出(return)协变,消费(param)逆变。产出时给更具体的没问题,消费时需要能处理更宽泛的才安全。