メインコンテンツへ移動

タイプシステムを深く探る_TypeScript ノート 8

無料2019-02-23#TypeScript#TypeScript bivariance#Function Parameter Bivariance#双向协变#TypeScript类型兼容性#TypeScript类型继承关系

タイプ推論機制からタイプ互換性まで

一.タイプ推論

代入推論

タイプ推論機制は強タイプによる構文負担を軽減します。例えば:

let x = 3;
// 等价于
let x: number = 3;

コンパイラは変数の初期値 3 に基づいて変数タイプが number であると推論できるため、多くのシナリオで明示的にタイプを宣言する必要はありません。它猜得到

P.S.すべてを事前に確定する必要がある [Haskell](/articles/类型-haskell 笔记 3/) でさえ、至る所にタイプ宣言が溢れているわけではなく、非常に簡潔です。これはコンパイラが強力なタイプ推論サポートを提供しているからです

代入に類似したシナリオでは、ターゲット値に基づいてタイプを確定できます。具体的には:

  • 変数または(クラス)メンバーの初期値

  • パラメータのデフォルト値

  • 関数の戻り値

これら 3 種類の値は直接的なタイプ情報を提供でき、それによってターゲットタイプを確定します。これ以外にも、あまり直接的でないシナリオもあります。例えば配列タイプ

配列タイプ

let x = [0, 1, null];

配列中の要素は numbernull のみで、numbernull を「互換」するため、x のタイプは number[] と推論されます

Null、Undefined および Never は他のタイプのサブタイプであるため、他の任意のタイプ変数に代入できます

([基本タイプ_TypeScript ノート 2](/articles/基本类型-typescript 笔记 2/#articleHeader3) より引用)

つまり、配列タイプを確定するには、まず各要素のタイプを確定し、その後その互換関係を考慮し、最終的に最も「広い」タイプ(配列中の他のすべてのタイプを包容し、best common type と呼ばれる)を配列タイプとして確定します

配列要素中に他のすべてのタイプを互換できるタイプがない場合(つまり best common type が見つからない場合)、ユニオンタイプを使用します。例えば:

// 推断 mixin: (string | number | boolean)[]
let mixin = [1, '2', true];

class Animal {}
class Elephant extends Animal {}
class Snake extends Animal {}
// 推断 zoo: (Elephant | Snake)[]
let zoo: Animal[] = [new Elephant(), new Snake()];

コンテキスト推論

代入推論と比較して、コンテキスト推論は異なる思路です:

    推断
值 ------> 变量类型
       查找             匹配(推断)
上下文 -----> 上下文类型 -----------> 变量类型

前者は値からタイプへ、後者はタイプからタイプへです。コンテキストに基づいてタイプ情報を得て、その後位置に基づいて変数にマッピングします。例えば:

// 推断 mouseEvent: MouseEvent
window.onmousedown = function(mouseEvent) {
  // ...
};

右側の匿名関数は mousedown イベントハンドラとして、DOM API のタイプ制約に従い、それによってパラメータタイプが得られます:

interface MouseEvent extends UIEvent {
  readonly clientX: number;
  readonly clientY: number;
  //...等等很多属性
}
interface GlobalEventHandlers {
  onmousedown: ((this: GlobalEventHandlers, ev: MouseEvent) => any) | null;
}
interface Window extends GlobalEventHandlers {/*...*/}

declare var window: Window;

TypeScript/lib/lib.dom.d.ts より引用)

もし mousedown イベントハンドラというコンテキストから離脱すれば、パラメータタイプを推論できません:

// 推断 mouseEvent: any
function handler(mouseEvent) {
  console.log(mouseEvent.clickTime);
}

多くのシナリオでコンテキストに基づいてタイプを推論します。例えば:

  • 関数呼び出し中のパラメータ

  • 代入文の右側

  • タイプアサーション

  • オブジェクトメンバーと配列リテラル

  • return

二.サブタイプ互換性

[TypeScript の 13 種類の基本タイプ](/articles/基本类型-typescript 笔记 2/) 中、タイプ階層関係は以下の通り:

[caption id="attachment_1899" align="alignnone" width="625"]TypeScript タイプ関係 TypeScript タイプ関係[/caption]

簡単にまとめると:

  • Any は最も「広い」。他のすべてのタイプを互換(言い換えれば、他のタイプはすべて Any のサブタイプ)

  • Never は最も「狭い」。他の任意のタイプを互換しない

  • Void は Undefined と Null を互換

  • 他のタイプはすべて Never と Void を互換

P.S.互換は簡単に代入可能かどうかと理解できます(文末に厳密な記述あり)。例えば:

let x: any;
let y: number;
let z: null;

// Any 兼容 Number
x = y;
// Number 兼容 Null
y = z;
// Null 不兼容 Number
// 错误 Type 'number' is not assignable to type 'null'.
z = y;

基本タイプだけでなく、関数、クラス、ジェネリックなどの複雑なタイプ間にもこのような互換関係があります

三.関数

互換性判定

タイプシステムにとって、ある関数タイプが別の関数タイプを互換するかどうかを正確に判断する必要があります。例えば代入のシナリオ:

let log: (msg: string) => void
let writeToFile: (msg: any, encode: string) => void

// 类型兼容吗?该不该报错
log = writeToFile;
writeToFile = log;

タイプ安全の角度から見ると、logwriteToFile に換えるのは安全ではありません(encode パラメータが不足し、writeToFile は正常に動作できない可能性があります)。逆の場合は安全です。戻り値タイプが同じで、パラメータが十分有余で、msg のタイプも互換(stringany のサブタイプ)だからです

具体的には、TypeScript タイプシステムの関数タイプの互換性判定ルールは以下の通り:

  • パラメータ:対応パラメータのタイプ互換を要求し、数量は余剰を許可

      let x = (a: number) => 0;
      let y = (b: number, s: string) => 0;
    
      y = x; // OK
      x = y; // Error
    
  • 戻り値:戻り値タイプの互換を要求

      let x = () => ({name: "Alice"});
      let y = () => ({name: "Alice", location: "Seattle"});
    
      x = y; // OK
      y = x; // Error, because x() lacks a location property
    
  • 関数タイプ:双変性制約を満たすことを要求

関数タイプの双変性(bivariance)

双変とは同時に共変と逆変を満たすことで、簡単に言うと:

  • 共変(covariant):親タイプが現れる場所を許可すれば、サブタイプも現れることを許可。つまり リスコフの置換原則

  • 逆変(contravariant):共変の逆。つまりサブタイプが現れる場所を許可すれば、親タイプも現れることを許可

  • 双変(bivariant):同時に共変と逆変を満たす

  • 不変(invariant または nonvariant):共変も逆変も満たさない

共変は理解しやすく、サブタイプは親タイプを互換し、さらにいくつかの(親タイプが持たない)拡張属性またはメソッドを持つため、サブタイプで親タイプを置き換えた後でも、正常に動作できます(タイプ安全)

一方、逆変はあまり直観的ではありません。どのようなシナリオで、親タイプでサブタイプを置き換えた後でも、タイプ安全を保証できるのでしょうか?

継承関係中のメンバー関数のオーバーライドは、逆変の典型的な例です:

class Example {
  foo(maybe: number | undefined) { }
  str(str: string) { }
  compare(ex: Example) { }
}

class Override extends Example {
  foo(maybe: number) { } // Bad: should have error.
  str(str: 'override') { } // Bad: should have error.
  compare(ex: Override) { } // Bad: should have error.
}

Overridden method parameters are not checked for parameter contravariance より引用)

オーバーライド前後の関数タイプを比較:

// foo
(maybe: number | undefined) => any
(maybe: number) => any
// str
(str: string) => any
(str: 'override') => any
// compare
(ex: Example) => any
(ex: Override) => any

P.S.str(str: 'override')str(str: string) より undefined が 1 つ「狭く」、デフォルト値によりパラメータ値セットから undefined が減少

パラメータはすべて「広い」タイプからより「狭い」タイプになり、つまり親タイプからサブタイプになります。明らかに、このようにするのは安全ではありません。例えば:

function callFoo(example: Example) {
  return example.foo(undefined);
}

callFoo(new Example());   // 没问题
callFoo(new Override());  // 可能会出错,因为子类的 foo 不接受 undefined

逆に、サブクラスがオーバーライド後のパラメータタイプがより「広い」場合、安全です。例えば:

class Example {
  foo(maybe: number | undefined) { }
}

class Override extends Example {
  foo(maybe: number) { }  // Sound
}

これがいわゆる逆変で、メンバー関数のパラメータにとって、親タイプでサブタイプを置き換えるのは安全です。つまり:

允许出现子类型的地方,也允许出现父类型

タイプの角度から見ると、サブタイプはタイプ間に階層(継承)関係を許可し、広範なタイプから特殊なタイプへ、そして共変、逆変などの関係はこのタイプ階層の上に構築されます:

  • 共変:単純なタイプの階層関係が複雑なタイプに保留され、この複雑なタイプは共変です。例えば AnimalCat の親タイプで、配列 Animal[]Cat[] の親タイプなので、配列タイプは共変です

  • 逆変:単純なタイプの階層関係が複雑なタイ��中で逆になり、この複雑なタイプは逆変です。例えば関数タイプ Animal => stringCat => string のサブタイプ(後者が受け入れるパラメータがより「狭い」ため)、単純なタイプ AnimalCat の親タイプなので、関数タイプは逆変です

P.S.私たちが見るように、逆変は直観的ではないため、タイプシステムを単純に保つために、一部の言語はタイプコンストラクタが不変であると考えることもあります。ただし、これはタイプ安全に違反する可能性があります

特別に、TypeScript 中の関数タイプは双変です。例えば:

interface Comparer<T> {
  compare(a: T, b: T): number;
}

declare let animalComparer: Comparer<Animal>;
declare let dogComparer: Comparer<Dog>;

animalComparer = dogComparer;  // Ok because of bivariance
dogComparer = animalComparer;  // Ok

Strict function types より引用)

理論的には関数パラメータの逆変を要求し、タイプ安全を確保する必要があります。そのため:

// 把父类型赋值给子类型,在逆变的场景中是安全的
dogComparer = animalComparer;  // Ok
// 把子类型赋值给父类型,在逆变的场景(函数类型)中是不安全的
animalComparer = dogComparer;  // Ok because of bivariance

後者は安全ではありませんが、JavaScript 世界では非常に一般的です:

This is unsound because a caller might end up being given a function that takes a more specialized type, but invokes the function with a less specialized type. In practice, this sort of error is rare, and allowing this enables many common JavaScript patterns.

そのため TypeScript は関数タイプの逆変を強制制約せず、双変を許可しています。さらに、2 つの関数タイプを比較する際、一方のパラメータが他方のパラメータを互換すればよいです。上記の例で dogCompareranimalComparer が相互に代入できるのと同じです

オプションパラメータと残りのパラメータ

パラメータ互換性を比較する際、オプションパラメータのマッチングを要求しません。例えば元タイプが追加のオプションパラメータを持つのは合法で、ターゲットタイプが対応するオプションパラメータを欠くのも合法です

残りのパラメータについては、無限個のオプションパラメータとして扱い、厳密なマッチングも要求しません。タイプシステムの角度から見れば安全ではありませんが、実際の応用では非常に一般的な「パターン」です。例えば不確定のパラメータでコールバック関数を呼び出す:

function invokeLater(args: any[], callback: (...args: any[]) => void) {
  /* ... Invoke callback with 'args' ... */
}

// Unsound - invokeLater "might" provide any number of arguments
invokeLater([1, 2], (x, y) => console.log(x + ", " + y));

関数オーバーロード

複数のオーバーロードが存在する関数の場合、ソース関数の各オーバーロードバージョンがターゲット関上に対応するバージョンを持つことを要求し、ターゲット関数がすべてのソース関数が呼び出せる場所で呼び出せることを保証します。例えば:

interface sum {
  (a: number, b: number): number;
  (a: number[]): number;
}

// Sum 要求的两个版本
function add(a: any, b: any);
function add(a: any[], b?: any): any;
// 额外的版本
function add(a: any[], b: any, c: number): any;
function add(a, b) { return a; }
let sum: sum = add;

sum 関数には 2 つのオーバーロードバージョンがあるため、ターゲット関数は少なくともこれら 2 つのバージョンを互換する必要があります

四.列挙

まず、異なる列挙タイプからの列挙値は互換しません。例えば:

enum Status { Ready, Waiting };
enum Color { Red, Blue, Green };

let s = Status.Ready;
// Type 'Color.Green' is not assignable to type 'Status'.
s = Color.Green;  // Error

特別に、数値列挙 は数値と相互にタイプ互換します。例えば:

enum Status { Ready, Waiting };
// 数值兼容枚举值
let ready: number = Status.Ready;
// 枚举值兼容数值
let waiting: Status = 1;

しかし 文字列列挙 は文字列タイプと相互に互換しません

enum Status { Ready = '1', Waiting = '0' };
let ready: string = Status.Ready;
// 报错 Type '"0"' is not assignable to type 'Status'.
let waiting: Status = '0';

P.S.実際のタイプから見れば、上記の代入は合法ですが、タイプシステム中では二者は互換しないと見なされるため、エラーが報告されます

五.クラス

クラスはオブジェクトリテラルタイプとインターフェースに類似していますが、違いは、クラスは同時にインスタンスタイプとスタティックタイプを持つことです。2 つのクラスインスタンスを比較する際、インスタンスメンバーのみを比較します

したがって、スタティックメンバーとコンストラクタは互換性に影響しません:

class Animal {
  static id: string = 'Kitty';
  feet: number;
  constructor(name: string, numFeet: number) { }
}

class Size {
  feet: number;
  constructor(numFeet: number) { }
}

let a: Animal;
let s: Size;

a = s;  // OK
s = a;  // OK

プライベートメンバーとプロテクトメンバー

メンバー修飾子 privateprotected もタイプ互換性に影響します。具体的には、これらのメンバーが同じクラスから由来することを要求し、これによって親クラスがサブクラスを互換することを保証します:

class Animal {
  private feet: number;
  constructor() { }
}

class Cat extends Animal { }

// 和 Animal 长得完全一样的 Tree
class Tree {
  private feet: number;
  constructor() { }
}

// 正确 父类兼容子类
let animal: Animal = new Cat();
// 错误 Type 'Tree' is not assignable to type 'Animal'.
animal = new Tree();
// 正确 二者“形状”完全一样
let kitty: Cat = new Animal();

Tree インスタンスを Animal タイプに代入するとエラーが報告されます。二者は完全に同じように見えますが、プライベート属性 feet が異なるクラスから由来するためです:

Types have separate declarations of a private property 'feet'.

同様に、上記の例で Animal インスタンスを Cat タイプに代入してもエラーが報告されないのは、二者のメンバーリストが同じで、プライベート属性 feet も同じ Animal クラスから由来するためです

六.ジェネリック

interface Empty<T> {
}
let x: Empty<number>;
let y: Empty<string>;

x = y;  // OK, because y matches structure of x
y = x;  // OK, because x matches structure of y

Empty<number>Empty<string> は大きく異なりますが、ジェネリック定義中ではタイプパラメータ T を使用していないため(unused variable に類似し、あまり意味がない)、相互に互��します

interface NotEmpty<T> {
    data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;

// 错误 Type 'Empty<string>' is not assignable to type 'Empty<number>'.
x = y;

この時、タイプパラメータを指定したジェネリックは一般的な具体タイプと同じように厳密に比較し、タイプパラメータを指定しないジェネリックについては、タイプパラメータを any と見なし、その後比較します。例えば:

let identity = function<T>(x: T): T {
  //...
  return x;
}
let reverse = function<U>(y: U): U {
  //...
  return y;
}

// 正确 等价于把 (y: any) => any 赋值给 (x: any) => any
identity = reverse;

七.タイプ互換性

実際には、TypeScript 規範中では 2 種類の互換性のみを定義しています。サブタイプ互換性と代入互換性で、二者には微妙な違いがあります:

Assignment extends subtype compatibility with rules to allow assignment to and from any, and to and from enum with corresponding numeric values.

代入互換性はサブタイプ互換性を拡張し、any の相互代入、および enum と対応する数値の相互代入を許可します

タイプ互換性については、規範中ではこの概念を定義していません。多くの文脈では、いわゆるタイプ互換性は代入互換性に従い、implementsextends 句も例外ではありません

参考資料

コメント

コメントはまだありません

コメントを書く