【问题标题】:Why are TypeScript arrays covariant?为什么 TypeScript 数组是协变的?
【发布时间】:2020-03-28 18:56:51
【问题描述】:

TypeScript 允许我们使用超类型变量为数组类型变量起别名(TypeScript 数组是协变的):

const nums: number[] = [];
const things: (number | string)[] = nums;
things.push("foo");
nums[0] *= 3;
console.log(nums[0]); // `NaN` !!

为什么?这似乎是保护我们免受运行时错误的好地方。鉴于 Java 因具有协变数组而被嘲笑,这似乎是一个有意的 TS 功能。

这是其他人在 stale TypeScript issue 上提出的问题,但我没有看到任何答案。

【问题讨论】:

  • 您使用的是“逆变”这个词,但您的意思是“协变”。
  • 感谢@jcalz!我纠正了这个问题。知道为什么这个问题被否决了吗?
  • 不确定;我很少投反对票。

标签: typescript


【解决方案1】:

正如您所指出的,数组协方差不可靠,可能会导致运行时出错。 TypeScript's Design Non-Goals 之一是

  1. 应用健全或“可证明正确”类型的系统。相反,应在正确性和生产力之间取得平衡。

这意味着,如果某些不健全的语言功能非常有用,并且如果要求健全性会使该语言使用起来非常困难或令人讨厌,那么尽管存在潜在的陷阱,但它很可能会留下来。

显然,"a fool's errand" 试图保证以描述 JavaScript 为主要目的的语言的健全性。


我想说的是,这里的根本问题是 TypeScript 想要支持一些非常有用的功能,不幸的是,这些功能不能很好地配合使用。

第一个是subtyping,其中类型形成层次结构,单个值可以是多种类型。如果S 类型是T 类型的子类型,那么S 类型的值s也是T 类型的值。例如,如果您有一个string 类型的值,那么您也可以将它用作string | number 类型的值(因为对于任何Xstringstring | X 的子类型)。 TypeScript 中接口和类层次结构的整个大厦都是建立在子类型的概念之上的。当S extends TS implements T 时,表示ST 的子类型。如果没有子类型,TypeScript 会更难使用。

第二个是aliasing,您可以使用多个名称引用相同的数据,而不必复制它。 JavaScript 允许这样做:const a = {x: ""}; const b = a; b.x = 1;。除了原始数据类型,JavaScript 值都是引用。如果您尝试在不传递引用的情况下编写 JavaScript,那将是一种非常不同的语言。如果 TypeScript 强制要求为了将对象从一个命名变量传递到另一个变量,您必须复制其所有数据,那么使用起来会更加困难。

第三个是mutability。 JavaScript 中的变量和对象通常是可变的;您可以重新分配变量和对象属性。不可变语言更容易推理/更简洁/更优雅,但对事物进行变异很有用。 JavaScript 不是不可变的,因此 TypeScript 允许它。如果我有一个值const a: {x: string} = {x: "a"};,我可以跟进a.x = "b"; 而不会出错。如果 TypeScript 要求所有别名都是不可变的,那就更难使用了。

但是将这些功能放在一起,事情可能会变糟:

let a: { x: string } = { x: "" }; // subtype
let b: { x: string | number }; // supertype 
b = a; // aliasing
b.x = 1; // mutation
a.x.toUpperCase(); // ?? explosion

Playground link to code

一些语言通过要求variance 标记来解决这个问题。 Java 的 wildcards 用于此目的,但它们的正确使用相当复杂,而且(据说)被认为令人讨厌和困难。

TypeScript 决定在这里不做任何事情,并将所有属性类型视为协变,尽管 suggestions to the contrary。在这方面,生产力比正确性更重要。


出于类似的原因,在 TypeScript 2.6 引入 --strictFunctionTypes 编译器选项之前,函数和方法参数被检查 bivariantly,此时仍然始终只检查方法参数。

双变量类型检查是不健全的。但它很有用,因为它允许突变、别名和子类型化(不会因为要求开发人员跳过障碍而损害生产力)。 TypeScript 中的方法参数双方差导致数组协方差


好的,希望对您有所帮助;祝你好运!

【讨论】:

  • number | string union 的例子对我特别有帮助!我没有意识到方差问题如此之深。
  • fool's errand 链接很有趣。我从来没有考虑过计算属性会影响像toString 这样的函数的语义。有趣的东西。
【解决方案2】:

这不是协方差问题;这是一个别名问题。禁止数组协方差非常令人沮丧,以至于即使是几乎完全不变的语言(Swift)也包含数组的例外情况。 (Swift 通过防止别名来避免您在此处显示的问题,因此这个错误在 Swift 中是不可能的。)

想象一个接受可选数字列表的函数:

function sum(values: (number|undefined)[]): number {
  return values.reduce((s: number, x?: number) => s + (x ?? 0), 0)
}

想象一下,如果你不能将number[] 传递给这个函数。不难理解他们为什么不强加这一点。

令人沮丧的是,JavaScript 使得改变别名变得太容易了,但这是一个比协方差情况大得多的广泛问题。 IMO,以下是 TypeScript 应该帮助我们避免的主要问题(考虑到 TS 的工作方式,这可能很困难)。如果此代码会引发错误,它会将协方差问题作为一种特殊情况解决:

const nums: number[] = [];
const things: number[] = nums;
things.push(1);
nums[0] *= 3;
console.log(things[0]); // `3` !!

【讨论】:

  • 您能详细说明这里的“别名”是什么意思吗?它只是对同一个数组有不同的引用吗?如果是这样,那么如果我调用sum(nums),函数实现中的valuesnums 的别名吗?如果不是,有什么区别?
  • 是的,正如您所想。当您使用别名时,您有两个对同一事物的引用(至少其中一个是可写的;如果两者都不可写,则别名并不重要)。数组是可以从多个点修改的可变对象这一事实是 JavaScript 中的一个主要问题。大约 5 分钟前,我才发现这是我当前项目中一个非常令人困惑的错误的根源;今天早上我在 JS 中遇到了另一个别名错误,本周早些时候还有更多。不小心共享对象太容易了。
  • 当我写let x = 4; let y = x; x++ 时,我不希望y 增加(而且它不会增加)。为什么当我写let x = [4]; let y = x; x.push(1) 时突然改变了?我们已经习惯了,但这是语言中的一个主要问题,这个问题只是其中的一小部分。 @jcalz
  • @RobNapier 没有 readonly number[] 做你想要的?
  • @MaxHeiber 我可能不希望数组是只读的。我故意修改things,我故意修改nums。令人惊讶的是它们是同一个东西。 (我意识到,经过多年和许多错误,惊喜消失了。但如果 let x = 4; let y = x; x++ 修改 y,我怀疑你会感到惊讶。为什么它会因为它是一组值而有所不同?)
猜你喜欢
  • 2011-10-04
  • 1970-01-01
  • 2015-09-08
  • 2017-08-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多