TypeScript中的類型兼容是基於結構歸類的。在普通分類的相比之下,結構歸類是一種純粹用於將其成員的類型進行關聯的方法。思考下面的代碼:
interface Named { name: string; } class Person { name: string; } var p: Named; // 正確, 因為這里編譯器自動進行結構歸類 p = new Person();
如C#、Java這些表面上的類型語言(這里指的“表面上的類型語言”,指C#和Java需要使用“implements”關鍵字明確指出類實現某個接口才能對應得上其類型),以上的代碼便會被當作錯誤的,因為沒有明確指出Person類實現(implements)Named接口。
TypeScript的結構類型系統就是基於JavaScript代碼典型的寫法設計的。因為JavaScript廣泛使用匿名對象如函數表達式和字面量對象,使用結構類型系統代替表面上處理將使JavaScript中這些關系體現的更自然。
TypeScript類型系統允許執行某些在編譯階段無法確定安全性的操作。當一個類型系統有這個屬性的時候,我們視之為“不健全的”。TypeScript中允許執行這些操作這一機制是經過仔細考慮的,通過文檔我們將解釋什么情況下會發生這種事和允許這些操作后所帶來的好的一面。
跟着代碼出發(老司機,帶帶我...)
TypeScript結構類型系統的基本規則如:如果x是兼容y的,那么y至少具有和x相同的屬性成員。例如:
interface Named { name: string; } var x: Named; // 推斷出y的類似是{ name: string; location: string; } var y = { name: 'Alice', location: 'Seattle' }; x = y;
為了檢查y是否能夠賦值給x,編譯器需要檢查x的每個屬性,並且在y中找到對應的兼容屬性。在這種情況下,y必須有個名為name並且值是字符串的屬性。而y滿足了這條件,所以能夠賦值給x。
同樣的規則也適用在檢查函數調用參數時:
// 接着上面的代碼 function greet(n: Named) { alert('Hello, ' + n.name); } greet(y); // ok
注意,y有一個額外的"location'屬性,但這並未產生錯誤。只有目標類型的成員(這里的目標類型指“Named”)會被檢查是否兼容。
比較的過程是遞歸進行的,檢查每個成員及其子成員的類型。
兩個函數之間的比較
原始類型和對象類型的比較是相對簡單的,但問題是被認為是兼容的函數是怎么樣的呢。讓我們從一個最基本的例子開始吧,以下兩個函數的不同之處僅僅在於他們的參數列表:
var x = (a: number) => 0; var y = (b: number, s: string) => 0; y = x; // ok x = y; // 錯誤
若要檢查x是否可以賦值給y,首先看參數列表。y中的每個參數必須在x中都有相應並且類型兼容的參數。主意,參數名可不考慮,只要類型能夠對應上。在這個案例中,x的每個參數在y中都有相應的參數,所以是允許賦值的。第二個賦值是錯誤的,因為y的第二個屬性是必須的,但是x沒這個屬性,所以不被允許賦值。
你可能對為什么在y=x中允許第二個參數而感到疑惑。賦值的過程允許忽略函數額外的參數,這在JavaScript中實際上很常見。例如Array的forEach函數為他的回調函數提供了三個參數:數組元素、索引、包含它的數列。然而,大多用到的也就第一個參數。
var items = [1, 2, 3]; // 這些參數不是強制要求的 items.forEach((item, index, array) => console.log(item)); // 這樣也可以 items.forEach((item) => console.log(item));
現在讓我們來看看返回值類型是如何處理的,使用兩個僅返回值類型不同的函數:
var x = () => ({name: 'Alice'}); var y = () => ({name: 'Alice', location: 'Seattle'}); x = y; // ok y = x; // 錯誤,因為x()缺少一個屬性
類型機制強制要求源函數的返回值類型是目標函數返回值類型的子類型。
可選參數及剩余參數
當對函數的兼容進行比較時,可選和必須的參數是可以互換的。源類型有額外的可選參數不會造成錯誤,目標類型的可選參數中不存在對應參數也不會產生錯誤。
當函數有其余的參數,將會被當作無限的可選參數一樣來處理。
從類型機制來看這是不健全的,但從代碼運行的角度看,可選參數不是強制要求的,因為它相當於在函數參數的對應位置傳入一個"undefined"。
下面的例子是個普遍模式的函數,該函數需要傳入個回調函數,並且在調用的時候傳入可預知(對於開發者而言)但是未知數量(對於類型機制)的參數。
function invokeLater(args: any[], callback: (...args: any[]) => void):void { callback.apply(null,args); } // invokeLater"可能"任何數量的參數 invokeLater([1, 2], (x, y) => console.log(x , y)); invokeLater([3], (x?, y?) => console.log(x , y)); invokeLater([4,5,6,7], (x?, y?) => console.log(x , y));
重載的函數
當一個函數具有重載情況時,源類型的每次重載必須在目標類型上可找到匹配的簽名。這確保了目標函數可以在所有源函數可調用的地方調用。當做兼容性檢查時,帶有特殊簽名的函數重載(那些重載時使用字符串)將不會使用他們特殊簽名。(詳情可見:TypeScript Declaration Merging(聲明合並)中的接口合並第二個案例)
枚舉
枚舉和number相互兼容。不同枚舉類型的枚舉值之間是不兼容的。例如:
enum Status { Ready, Waiting }; enum Color { Red, Blue, Green }; var status = Status.Ready; status = Color.Green; // 錯誤
類
類的兼容和對象字面量類型還有接口的兼容相似,只是有一個不同:它具有靜態類型和實例類型。比較兩個類類型的對象時,只比較實例部分的成員。靜態部分的成員和構造函數不影響兼容性。
class Animal { feet: number; constructor(name: string, numFeet: number) { } } class Size { feet: number; constructor(numFeet: number) { } } var a: Animal; var s: Size; a = s; // ok s = a; // ok
類的私有成員
類中的私有成員會影響其兼容性。當一個類的實例進行兼容性檢查時,如果它包含一個私有成員,那么目標類型必須也包含一個來源與同一個類的私有成員(詳情可參閱:TypeScript Class(類)中的理解Private(私有))。這也造成了一個類可以被賦值為其父類的實例,但是卻不能被賦值成另一個繼承其父類的類(雖然他們是同一個類型)。
泛型
因為TypeScript是一個結構類型系統,參數類型只影響將其作為部分成員類型的目標類型(比如有個函數fn(a:string),然后函數中有個變量的某屬性值是a,那么a對這個目標類型將產生影響)。
案例:
interface Empty<T> { } var x: Empty<number>; var y: Empty<string>; x = y; // ok, y可以和x的結構相匹配
在上述例子中,x和y是兼容的,因為他們的結構在使用類型參數並沒什么不同之處。改變這個例子,給Empty<T>加個成員,看看會是什么情況:
interface NotEmpty<T> { data: T; } var x: NotEmpty<number>; var y: NotEmpty<string>; x = y; // 錯誤,x和y不兼容
對於那些沒有指定其參數類型的泛型類型,兼容性的檢查是通過為所有沒指定參數類型的參數使用"any"代替的。然后目標類型檢查兼容性,就和非泛型的案例一般。
案例:
var identity = function<T>(x: T): T { // ... } var reverse = function<U>(y: U): U { // ... } identity = reverse; // ok,因為(x: any)=>any和(y: any)=>any可以匹配
深層探討
子類VS賦值
至此為止,我們了解了兼容性,它並未在語言規范里定義。在TypeScript中有兩種兼容:子類和賦值。它們的不同點在於,賦值擴展了子類型的兼容性並且可對"any"進行取值和賦值、對枚舉進行取對應數值。
根據情況的不同,TypeScript語言會在不同的地方使用兩種兼容機制中的一種。對於實際運用而言,類型的兼容性是由賦值時的兼容檢查或者implements和extends關鍵字來控制的。更多詳情,請參照TypeScript spec (友情提醒,是doc文件下載)