协变,逆变与不变

不知道你在看到这个标题的时候会不会想:这都是什么狗屁玩意?

至少我第一次看到这三个词的时候是这么想的。

这三个概念在计算机科学中和类型系统有关,主要应用在泛型参数中父类和子类之间的赋值约束之间。

如果你看不懂上一句话说什么,很正常,光说这个我也看不懂,所以我们还是用例子来解释吧。

为什么会接触到这个概念

最近在用 zustand 写一些东西,但是看到 zustand 里对于 TypeScript 的支持是这么写的:

如果使用 JS 的话,直接

create((set) => ({...}));
create(immer((set) => ({...})));   // 使用 immer 中间件

就可以,但是使用 TS 的话,就得写成这样:

create<State>()((set) => ({...}));
create<State>()(immer((set) => ({...})));   // 使用 immer 中间件

这是何苦?明明传一个参数的事情为啥要大费周章弄个 curry 化?

如果你不知道 curry 化是什么意思:

可以简单理解为,对一个函数做 curry 化,就是把一个函数改为“一次调用就是塞一个参数,塞完就运行”的形式。
比如说 add = (a, b) => a + b,本来 add(1, 2) === 3,
curry 化后的 add 为 curried_add,那么 curried_add(1)(2) === 3,
每一次调用相当于塞一个参数进去,塞满参数的那一次调用会执行并且返回结果。

翻译:

太长不看版:因为 T 是不变的(invariant)。在这里 T 既是协变的(covariant)又是逆变的(contravariant),所以它是不变的。

、、这是人类语言吗?

于是在请教过 Gemini 老师,Kimi 老师之后,我总算对这几个概念有大概的理解了。

协变和逆变

协变,英文叫 covariant;逆变,英文叫 contravariant。

那么什么时候该用上这个不像人话的词呢?

这里做一个比方。

比如这里有一个父类和子类,父类就叫 Animal 吧,子类叫 Cat。

在泛型中,如果参数 T 子类可以代替父类,那么 T 就是协变的。比如 Array<T> 是一个泛型,Array<Cat> 可以赋值给 Array<Animal>,所以 Array<T> 中的 T 是协变的。

type Get<T> = () => T;
let fn: Get<Animal>;
fn = (cat: Cat) => cat; // 相当于把 Get<Cat> 赋值给 Get<Animal>
// Get<Animal> 保证返回一个 Animal,而 Cat 也是一种 Animal,所以直觉上也是符合的

反过来,如果参数 T 父类可以代替子类,那么 T 就是逆变的。比如 Handler<T> 是一个泛型,写成代码是这样

type Handler<T> = (value: T) => void;
let fn: Handler<Cat>;
fn = (animal: Animal) => console.log(animal); // 相当于把 Handler<Animal> 赋值给了 Handler<Cat> 类型
// Handler<Cat> 可以处理 Cat 类型
// Handler<Animal> 可以处理 Animal 类型
// 因为 Cat 也是一种 Animal,既然可以处理 Animal 那肯定也可以处理 Cat,直觉上也是合理的

所以 Handler<T> 的 T 是逆变的。

不变

不变,英文叫 invariant。

在泛型中,如果参数 T 子类既代替父类,又不可以父类代替子类,T 只能是 T 自己,那么 T 就是不变。

比如说有这么一个类型 Identify<T>

type Identity<T> = (value: T) => T;
let animalIdentity: Identity<Animal> = (animal: Animal) => animal;
let catIdentity: Identity<Cat> = (cat: Cat) => cat;

在这里,Identity<Animal> 和 Identity<Cat> 都是不变的(Invariant)。animalIdentity 不能赋值给 catIdentity,catIdentity 也不能赋值给 animalIdentity。

如何分辨 T 是协变,逆变还是不变的?

T 可能出现在很多地方,要根据 T 出现的地方来判断是协变还是逆变。

AI 和百科都会告诉你:

如果 T 是生产出来的类型,那 T 就是协变的。

如果 T 是被消费的类型,那 T 就是逆变的。

如果 T 既是生产出来的类型又是被消费的类型,那 T 就是不变的。

比如,() => T 里 T 是生产出来的类型(返回值),所以 T 是协变的。

(value: T) => void 里 T 是被消费的类型(参数),所以 T 是逆变的。

(value: T) => T 里 T 既是生产者又是消费者,所以 T 是不变的。

是不是很简单呢?那接下来看看这个里面 T 是哪种吧!

(action: (arg: T) => void) => T;

相信聪明的你一定已经看出来了,这里的 T 是协变的!拍手拍手拍手!

什么你说你看不出来?没关系!我立马教你看出来!

判断方法

看函数签名。

  1. 所有的 T 一开始都是协变的
  2. 如果右边有一个箭头,则变成逆变,再遇到一个箭头则变成协变,以此类推。(本质像正负数乘法,负负得正)
  3. 如果所有位置的 T 都是协变的,则这个类型的 T 就是协变的。如果所有位置的 T 都是逆变的,则这个类型的 T 是逆变的。如果有些位置是协变有些位置是逆变,那么这个类型的 T 是不变的。

接下来分析上面的例子。

(arg: T) 里的 T 一开始是协变的,右边遇到一个箭头变逆变,再遇到一个箭头变协变,所以这个 T 是协变的。

(action: (arg: T) => void) => T; 最右边的 T 一开始是协变的,右边没有箭头,所以这个 T 是协变的。

所以,(action: (arg: T) => void) => T 这个类型的 T 是协变的。

type Co<T> = (action: (arg: T) => void) => T;
let animal: Co<Animal> = cat as Co<Cat>;  // 这样是 ok 的

背后的意义

这个方法很好理解,但是协变逆变背后的意义是什么呢?

逆变

class Animal {
  constructor(public name: string = 'Animal') { }
}
class Cat extends Animal {
  constructor(public character: string = 'naughty') {
    super('Cat');
  }
}
const cat = new Cat();
const animal = new Animal();

// 容易判断出 SkillFor<T> 的 T 是逆变的
type SkillFor<T> = (creature: T) => void;

// 这是针对猫的技能,可以判断猫的类型
const distinguishCatCharacter = (cat: Cat) => {
  console.log('This cat\'s character is', cat.character);
}

// 这是对所有动物都有用的技能,可以说出动物的名字
const callAnimalName = (animal: Animal) => {
  console.log('This living animal\'s name is', animal.name);
}

// catHandler 是一个函数,将会处理一只猫
let catHandler: SkillFor<Cat>;
// 这个赋值是有效的,符合逆变规则,父类可以赋值给子类
// 既然 callAnimalName 对所有动物都有用,那自然也对 Cat 有用
// 所以可以用 callAnimalName 来处理一只猫
catHandler = callAnimalName;

catHandler(cat); // This living animal's name is cat

协变

简单的协变很复合直觉,所以来个复杂一点的。

// JobNeedHandlerOf<T> 是一个工作岗位,这个岗位需要一个能处理 T 的技能
// 根据上面的方法容易看出 JobNeedHandlerOf<T> 的 T 是协变的,但是该怎么理解呢?
type JobNeedHandlerOf<T> = (handler: (arg: T) => void) => void;

// 一个需要应对猫的岗位
const jobNeedHandlerOfCat: JobNeedHandlerOf<Cat> = (catHandler: (cat: Cat) => void) => {
  console.log('Need to handle Cat:');
  catHandler(cat);
};

// 一个需要应对所有动物的岗位
const jobNeedHandlerOfAnimal: JobNeedHandlerOf<Animal> = (animalHandler: (animal: Animal) => void) => {
  console.log('Need to handle Animal:');
  animalHandler(animal);
}

// jobHandlingAnimal 一个需要应对动物的岗位
let jobHandlingAnimal: JobNeedHandlerOf<Animal>;
// 一个需要应对猫的岗位自然也是一个需要应对动物的岗位
// 因为猫也是一种动物
jobHandlingAnimal = jobNeedHandlerOfCat;
// 一个需要应对动物的岗位自然需要应对动物的方法
jobHandlingAnimal(callAnimalName);
// Need to handle Cat:
// This living animal's name is Cat

jobHandlingAnimal(distinguishCatCharacter); // 这一句会报错
// 如果没有报错,那么 tsconfig.json 可能没有 "strict": true,
// 没有这一项的 TypeScript 对于参数的约束并不是逆变

// 继续分析上面那一行
// JobNeedHandlerOf<T> 是协变的,但是它的参数
// (arg: T) => void 是逆变的
// (arg: Cat) => void 不能赋值给 (arg: Animal) => void


let jobHandlingCat: JobNeedHandlerOf<Cat>;
jobHandlingCat = jobNeedHandlerOfCat;
// 同理,(arg: T) => void 里 T 是逆变的
// (arg: Animal) => void 可以赋值给 (arg: Cat) => void
jobHandlingCat(callAnimalName);
// Need to handle Cat:
// This living animal's name is Cat


回到 zustand 的例子

既然我们已经搞懂了协变、逆变和不变是什么东西,那么和我们一开始遇到的问题有什么关系呢?

gemini老师如是说

您提出了一个非常好的问题,这正好触及了 TypeScript 类型推断机制中一个非常精妙且关键的点。您的逻辑是完全合理的:如果返回值是 string,那么 T 就应该是 string,因此参数 t 也应该是 string

然而,TypeScript 的类型推断并不是单向的。它不是仅仅根据返回值来确定 T,然后将这个结果反向应用到参数上。相反,编译器会从所有使用了 T 的地方收集“候选类型”(Type Candidates),然后试图在这些候选类型中找到一个“最佳通用类型”(Best Common Type)

让我们扮演一次 TypeScript 编译器,来分析 createFoo((_) => 'hello') 这个调用。

编译器的“思考”过程

目标: 确定泛型 T 的具体类型。

依据:

  1. 泛型约束:T 必须满足 <T>(f: (t: T) => T) => T 这个签名。
  2. 传入的实参:(_) => 'hello'

步骤 1:收集 T 的候选类型

编译器会检查所有 T 出现的位置,并从传入的函数 (_) => 'hello' 中寻找对应的线索。

  • 线索来源 A:参数位置
    • 签名要求:参数是 t: T
    • 传入的实参:参数是 _
    • 分析:这个 _ 没有被提供任何类型信息。对于编译器来说,这个参数可以是任何类型 (any/unknown),因为它被忽略了。从这个位置,编译器无法为 T 找到一个明确的候选类型。它从参数这里得到的关于 T 的信息是 “无限制” 或 “未知”。所以,从参数位置,T 的候选类型是 unknown
  • 线索来源 B:返回值位置
    • 签名要求:返回值是 T
    • 传入的实参:返回值是 'hello'
    • 分析:表达式 'hello' 的类型是 string。这是一个非常强烈的信号。从这个位置,编译器为 T 找到了一个明确的候选类型:string

步骤 2:统一所有候选类型

现在,编译器手上有两个从不同来源收集到的关于 T 的候选类型:

  • 来自参数位置的候选:unknown
  • 来自返回值的候选:string

签名的核心要求是 (t: T) => T,这意味着参数的类型和返回值的类型必须是同一个 T。因此,编译器必须找到一个单一的类型,这个类型既能满足参数候选 unknown,也能满足返回值候选 string

什么是 unknownstring 的“最佳通用类型”?

在 TypeScript 的类型系统中,unknown 是所有类型的“祖先”(Top Type)。任何类型都可以被赋给 unknown,但 unknown 不能被赋给除了 any 和它自己以外的任何类型。

当编译器试图统一 unknownstring 时,它会寻找一个能同时容纳这两者的最具体的类型。这个类型就是 unknownstringunknown 的子类型,但反过来不成立。为了保证类型安全,编译器只能选择更宽泛、更通用的那个,也就是 unknown

结论

因为参数 _ 没有提供类型信息,导致该位置的候选类型为 unknown。这就“污染”了整个类型推断过程。即使返回值给出了 string 的强提示,编译器也无法忽略参数位置的模糊性。为了统一 unknownstring 这两个候选,它只能选择 unknown 作为最终推断出的 T 的类型。

打个比方

(t: T) => T 想象成一个严格的合同模板: “甲方(类型 T)和乙方(类型 T)必须是同一个实体。”

现在你提供了一份填写好的合同: “甲方是一个不在乎身份的匿名人士 (_),乙方是张三 ('hello')。”

法官(编译器)看到这份合同就会说:“等等,合同要求甲乙双方是同一个人。但你提供的这份文件里,乙方明确是‘张三’,而甲方身份不明。我无法确认这个‘匿名人士’就是‘张三’。由于甲方身份未知(unknown),我不能批准这份合同,我只能认定当事人的共同身份是‘未知’(unknown)。”

这就是为什么编译器无法安全地断定 T 就是 string。因为你没有在参数这边提供足够的证据来支持这个断言。一旦你像下面这样提供了证据,问题就解决了:

TypeScript

// 你明确告诉编译器:“甲方是‘张三’,乙方也是‘张三’。”
// 编译器:“完美,甲乙双方都是 string,合同成立!”
const x = createFoo((t: string) => 'hello'); // x 是 string

作者: 梁小顺

脑子不太好用的普通人。 顺带一提性格也有点古怪。 在老妈子和厌世肥宅中来回切换。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理