为什么实现接口的 class 中的泛型成员函数不能采用 class 类型的参数(而不是接口)?

Why can't a generic member function in a class implementing an interface take an argument of the type of the class (instead of the interface)?

考虑使用方法 likes<T extends IDog>( other: T ) 的接口 IDog。该方法采用一个参数,其类型扩展了接口。为什么不允许在派生的 class Dog 中使用 class 作为参数类型而不是接口来实现该方法?

interface IDog
{
    likes<T extends IDog>( other: T ): boolean;
}

class Dog implements IDog
{
    private name = "Buddy";
    public likes<T extends Dog>( other: T )
        // ^^^^^
        // error: Property 'likes' in type 'Dog' is not 
        // assignable to the same property in base type 'IDog' [...]
        // Property 'name' is missing in type 'IDog' but required in type 'Dog'
    {
        return true;
    }
}

删除 private 属性 name 会使错误消失,但这不是我现实世界问题的解决方案。奇怪的是,没有泛型的同一个例子工作得很好:

interface ICat
{
    likes( other: ICat ): boolean;
}

class Cat implements ICat
{
    private name = "Simba";
    public likes( other: Cat )  // no error using Cat here (instead of ICat)
    {
        return true;
    }
}

我在这里错过了什么?

想象这样一种情况:你有

const myDog: Dog
const someOtherDog: IDog

还有这样一个函数:

function seeIfLikes(dog: IDog, anotherDog: IDog) {
  return dog.likes(anotherDog)
}

这个函数看起来没问题,IDog.likes() 想要扩展 IDog 的东西作为参数。

但是当你调用 seeIfLikes(myDog, someOtherDog) 时,意想不到的事情发生了:myDog 被转换为 IDog,所以 TypeScript 会忘记它的 likes() 方法需要扩展的东西Dog,不是 IDog!

所以这个函数调用将通过类型检查,即使 someOtherDog 实际上没有扩展 Dog - 如果你的 Dog.likes() 包含一些特定于 Dog 的代码class,而不是 IDog,你会得到一个运行时 kaboom。

这就是为什么我们不能在子类型中添加新的泛型参数限制:它们可能会被强制转换为它们的超类型,并且该限制将会消失。希望这是足够清楚的理解。


是的,那个 Cat 示例会遇到完全相同的问题,但是 tsc 让它通过了,原因不明。也许这是类型系统的限制,或者是更好报告的错误。

让我们假设编译器对您的实现方式没有问题IDog。那么以下就可以了:

class Dog implements IDog {
  private name = "Buddy";
  public likes<T extends Dog>(other: T) {
    return other.name.toUpperCase() === "FRIEND";
  }
}

const myDog: IDog = new Dog(); // should be okay if Dog implements IDog

但这会导致编译器无法捕获的运行时错误:

const eyeDog: IDog = {
  likes(other) {
    return true;
  }
}
console.log(myDog.likes(eyeDog)) // okay for the compiler, but RUNTIME ERROR

所以编译器是正确的 Dog 没有正确实现 IDog。允许这将是 "unsound". If you have a function type you want to extend (make more specific), you cannot make its parameters more specific and be sound; you need to make them more general. This means that function parameters should be checked contravariantly(也就是说,它们以与函数类型相反的方式变化……它们反变化……逆变)。


当然这会引出您关于 Cat 的问题。那里不是完全相同的论点吗?

class Cat implements ICat {
  private name = "Simba";
  public likes(other: Cat) { // no error
    return other.name.toUpperCase() === "FRIEND";
  }
}
const myCat: ICat = new Cat(); // no error

const eyeCat: ICat = {
  likes(other) { return true; }
}

console.log(myCat.likes(eyeCat)) // no compiler error, but RUNTIME ERROR

确实如此!编译器允许 ICatCat 的不合理扩展。给出了什么?

这是明显的故意行为;方法 parameters are checked bivariantly,这意味着编译器将接受更宽的参数类型(安全)和更窄的参数类型(不安全)。这显然是因为,在实践中,人们很少使用 myCat(或 myDog)编写上述那种不安全的代码,而这种不安全性正是允许存在许多有用的类型层次结构的原因(例如,TypeScript 允许 Array<string> 成为 Array<string | number>).

的子类型

所以,等等,为什么编译器关心泛型类型参数的健全性而不关心方法参数的健全性?好问题;我不知道对此有任何“官方”答案(尽管我可能会查看 GitHub 问题以查看 TS 团队中是否有人对此发表过评论)。总的来说,TypeScript 中的健全性违规是根据启发式方法和实际代码仔细考虑的。

我的猜测是人们通常希望他们的泛型具有类型安全性(正如 microsoft/TypeScript#16368 对它们实施更严格检查所证明的那样),特别是添加额外的代码以允许方法参数双变会更麻烦比它的价值。

您可以通过启用 the --noStrictGenericChecks compiler option 来禁用对泛型的严格检查,但我不建议故意降低编译器的类型安全性,因为它影响的远不止您的 Dog 问题,当您依赖不寻常的编译器标志时,很难找到帮助资源。


请注意,您可能正在寻找这样的模式,其中每个子 class 或实现 class 只能 likes() 它自己类型的参数,而不是每个可能的子类型。如果是这样,那么您可以考虑改用 the polymorphic this type。当您使用 this 作为类型时,它就像一个泛型类型,意思是“调用此方法的 subclass 是什么类型”。但它是专门为允许您似乎正在做的事情而设计的:

interface IGoldfish {
  likes(other: this): boolean;
}

class Goldfish implements IGoldfish {
  private name = "Bubbles";
  public likes(other: this) {
    return other.name.toUpperCase() === "FRIEND";
  }
}
const myFish: IGoldfish = new Goldfish();

这当然和其他两个例子有同样的问题:

const eyeFish: IGoldfish = { likes(other) { return true; } }
console.log(myFish.likes(eyeFish)) // RUNTIME ERROR

所以它不是治疗不健全的灵丹妙药。但是和没有泛型参数警告的泛型版本很像。

Playground link to code

如果你想实现IDog接口你需要确保likes方法可以分配给IDog接口的likes方法,对吗?

考虑这个例子:

declare var dog: (arg: {}) => boolean

declare var idog: (arg: { name: string }) => boolean

dog = idog // error

idog = dog // ok

您无法将 idog 函数分配给 dog 函数,因为 idog 实现允许使用 name 参数,而 dog 实现则不允许。

您可能会想,这很奇怪,因为这是按预期工作的:

declare var iarg: { name: string }
declare var arg: {}

iarg = arg // error, because iarg uses `name` and arg does not have this property
arg = iarg

属性多的对象可以赋值给属性少的对象,很直观。

在您的示例中,问题发生了,因为函数类型与其参数类型是逆变的。有关更多上下文,请参阅此

现在,尝试禁用 strictFunctionTypes 标记。您将看到此代码将编译:

let dog = (arg: {}) => true

let idog = (arg: { name: string }) => {
    console.log(arg.name)
    return true
}

dog = idog // ok

让我们回到最初的泛型问题:


let dog = <T extends {}>(arg: T) => true

let idog = <T extends { name: string }>(arg: T) => true

dog = idog // error
idog = dog

即使没有 strictFunctionTypes 标志,此代码仍会产生错误。尽管函数参数位置是双变处理的(没有严格的函数类型),我认为泛型仍然是逆变处理的。我可能是错的,所以如果有人能纠正我,我会很高兴。

这意味着 typescript 将尝试检查 T extends {} 是否已分配给 T extends { name: string },尽管我们正在尝试将 T extends { name: string } 分配给 T extends {}