前面講 泛型 的時候,提到了接口。和泛型一樣,接口也是目前 JavaScript 中并不存在的語法。
由于泛型語法總是附加在類或函數語法中,所以從 TypeScript 轉譯成 JavaScript 之后,至少還存在類和函數(只是去掉了泛型定義,類似 Java 泛型的類型擦除)。然而,如果在某個 .ts
文件中只定義了接口,轉譯后的 .js
文件將是一個空文件——接口被完全“擦除”了。
那么,TypeScript 中為什么要出現接口語法?而對于沒接觸過強類型語法的 JSer 來說,接口到底是個什么東西?
現實生活中我們會遇到這么一個問題:出國旅游之前,往往需要了解目的地的電源插座的情況:
大家都知道,國內的電源插頭常見的有兩種,三平插(比如多數筆記本電腦電源插頭)和雙平插(比如多數手機電源插頭),家用電壓都是 220V。但是近年來電子產品與國際接軌,電源適配器和充電器一般都支持 100~220V 電壓。
那么上面就出現了兩類標準,一類是插座的標準,另一類是插頭的標準。如果這兩類標準一樣,我們就可以提包上路,不用擔心到地方后手機充不上電,電腦找不到合適電源的問題。但是,如果標準不一樣,就必須去買個轉換插頭,甚至是帶變壓功能的轉換插頭。
這里提到的轉換插頭在軟件開發(fā)中屬于“適配器模式”,這里不深研。我們要研究的是插座和插頭的標準。插座就是留在墻上的接口,它有自身的標準,而插頭為了能使用這個插座,就必須符合它的標準,換句話說,得匹配接口。工業(yè)上這像插座這樣的標準必須成文、審批、公布并執(zhí)行,而編程上的接口也類似,需要定義接口、類型檢查(編譯器)、公布文檔,實現接口。
所以回到 TypeScript,我們以關鍵字 interface
,用類似于 class
聲明的語法在定義接口 (還記得聲明類型一文中提到的類成員聲明嗎)。所以一個接口看起來可能是這樣的
interface INamedLogable {
name: string;
log(...args: any[]);
}
假設我們的業(yè)務中有這樣一部分 JavaScript 代碼
function doWith(logger) {
console.log(`[Logger] ${logger.name}`);
logger.log("begin to do");
// ...
logger.log("all done");
}
doWith({
name: "jsLogger",
log(...args) {
console.log(...args);
}
})
我們還不懂接口,所以先定義一個類,包含 name
屬性和 log()
方法。有了這個類就可以在 doWith()
和其它定義中使用它來進行類型約束(檢查)。
class JsLogger {
name: string;
constructor(name: string) {
this.name = name;
}
log(...args: any[]) {
console.log(...args);
}
}
然后定義 doWith
:
function doWith(logger: JsLogger) {
console.log(`[Logger] ${logger.name}`);
logger.log("begin to do");
// ...
logger.log("all done");
}
調用示例:
const logger = new JsLogger("jsLogger");
doWith(logger);
上面的示例中,輸出的日志只有日志內容本身,但是我們希望能在日志信息每行前面綴上日志名稱,比如像這樣的輸出
[jsLogger] begin to do
所以我們從 JsLogger
繼承出來一個 PoweredJsLogger
來用:
class PoweredJsLogger extends JsLogger {
log(...args: any[]) {
console.log(`[${this.name}]`, ...args);
}
}
const logger = new PoweredJsLogger("jsLogger");
doWith(logger);
甚至我們可以換個第三方 Logger,與 JsLogger
毫無關系,但成員定義相同
function doWith(logger: JsLogger) {
console.log(`[Logger] ${logger.name}`);
logger.log("begin to do");
// ...
logger.log("all done");
}
const logger = new AnotherLogger("oops");
doWith(logger);
你以為它會報錯?沒有,它轉譯正常,運行正常,輸出
[Logger] oops
[Another(oops)] begin to do
[Another(oops)] all done
看到這個結果,Java 和 C# 程序員要抓狂了。不過 JSer 覺得這沒什么啊,我們平時經常這么干。
理論上來說,接口是一個抽象概念,類是一個更具體的抽象概念——是的,類不是實體 (instance),從類產生的對象才是實體。一般情況下,我們的設計過程是從具體到抽象,但開發(fā)(編程)過程正好相反,是從抽象到具體。所以一般在開發(fā)過程中都是先定義接口,再定義實現這個接口的類。
當然有例外,我相信多數開發(fā)者會有相反的體驗,尤其是一邊設計一邊開發(fā)的時候:先根據業(yè)務需要定義類,再從這個類抽象出接口,定義接口并聲明之前的類實現這個接口。如果接口元素(比如:方法)發(fā)生變化,往往也是先在類中實現,再進行抽象補充到接口定義中。這種情況下我們多么希望能直接從類生成接口……當然有工具可以實現這個過程,但多數語言本身并不支持——別再問我原因,剛才已經講過了。
不過 TypeScript 帶來了不一樣的體驗,我們可以從類聲明接口,比如這樣
interface ILogger extends JsLogger {
// 還可以補充其它接口元素
}
這里定義的 ILogger
和最前面定義的 INamedLogable
具有相同的接口元素,是一樣的效果。
為什么 TypeScript 支持這種反向的定義……也許真的只是為了方便。但是對于大型應用開發(fā)來說,這并不見得是件好事。如果以后因為某些原因需要為 JsLogger
添加公共方法,那就悲劇了——所有實現了 ILogger
接口的類都得實現這個新加的方法。也許以后某個版本的 TypeScript 會處理這個問題,至少現在 Java 已經找到辦法了,這就是 Java 8 帶來的默認方法,而且 C# 馬上也要實現這一特性了 。
現在回到上面的問題,為什么向 doWith()
傳入 AnotherLogger
對象毫不違和,甚至連個警告都沒有。
前面我們已經提到了“鴨子辨型法”,對于 doWith(logger: JsLogger)
來說,它需要的并不真的是 JsLogger
,而是 interface extends JsLogger {}
。只要傳入的這參數符合這個接口約束,方法體內的任何語句都不會產生語法錯誤,語法上絕對沒有問題。因此,傳入 AnotherLogger
不會有問題,它所隱含的接口定義完全符合 ILogger
接口的定義。
然而,語義上也許會有些問題,這也是我作為一個十多年經驗的靜態(tài)語言使用者所不能完全理解的。有可能這是 TypeScript 為了適應動態(tài)的 JavaScript 所做出的讓步,也有可能這是 TypeScript 特意引入的特性。我對多數動態(tài)語言和函數式語言并不了解,但我相信,這肯定不是 TypeScript 首創(chuàng)。
上面大量的內容只是為了將大家通過 class
的定義引入到對 interface
的了解。但是接口到底該怎么定義?
常規(guī)接口的定義和類的定義幾乎沒有區(qū)別,上面已經存在例子,歸納起來需要注意幾點:
interface
關鍵字;I
;
而對接口的實現可以通過 implemnets
關鍵字,比如
class MyLogger implements INamedLogable {
name: string;
log(...args: any[]) {
console.log(...args);
}
}
這是顯式地實現,還有隱式的。
const myLogger: INamedLogable = {
name: "my-loader",
log(...args: any[]) {
console.log(...args);
}
};
另外,在所有聲明接口類型的地方傳值或賦值,TypeScript 會通過對接口元素一一對比來對傳入的對象進行檢查。
曾經我們定義一個函數類型,是使用 type
關鍵字,以類似 Lambda 的語法來定義。比如需要定義一個參數是 number
,返回值是 string
的函數類型:
// 聲明類型
type NumberToStringFunc = (n: number) => string;
// 定義符合這個類型的 hex
const hex: NumberToStringFunc = n => n.toString(16);
現在可以用接口語法來定義
// tslint:disable-next-line:interface-name
interface NumberToStringFunc {
(n: number): string;
}
const hex: NumberToStringFunc = n => n.toString(16);
這種定義方式和 Java 8 的函數式接口語法類似,而且由于它表示一個函數類型,所以一般不會前綴 I
,而是后綴 Func
(有參) 或者 Action
(無參)。不過 TSLint 可不吃這一套,所以這里通過注釋關閉了 TSLint 對該接口的命名檢查。
這樣的接口不能由類實現。上例中的 hex
是直接通過一個 Lambda 實現的。它還可以通過函數、函數表達式來實現。另外,它可以擴展為混合類型的接口。
JSer 們應該經常會用到一種技巧,定義一個函數,再為這個函數賦值某些屬性——這沒毛病,JavaScript 的函數本身就是對象,而 JavaScript 的對象可以動態(tài)修改。最常見的例子應該就是 jQuery 和 Lodash 了。
這樣的類型在 TypeScript 中就通過混合類型接口來定義,這次直接引用官方文檔的示例:
interface Counter {
(start: number): string;
interval: number;
reset(): void;
}
function getCounter(): Counter {
let counter = <Counter>function (start: number) { };
counter.interval = 123;
counter.reset = function () { };
return counter;
}
let c = getCounter();
c(10);
c.reset();
c.interval = 5.0;
前面我們提到可以從類聲明接口,其語法采用 extends
關鍵字,所以說成是繼承也并無不可。
另外,接口還可以繼承自其它接口,比如
interface INewLogger: ILogger {
suplier: string;
}
接口還允許從多個接口繼承,比如上面提到的 INamedLogable
可以拆分一下
interface INamed {
name: string;
}
interface ILogable {
log(...args: any[]);
}
interface INamedLogable extends INamed, ILogable {}
這樣定義 INamedLogable
是不是更合理一些?
不管什么語言,接口的主要目的是為了在供應者和消費者之前創(chuàng)建一個契約,其意義更傾向于設計而非程序本身,所以接口在各種設計模式中應用非常廣泛。不要為了接口而接口,在設計需要的時候使用它。對復雜的應用來說,定義一套好的接口很有必要,但是對于一些小程序來說,似乎并無必要。
相關閱讀
此文首發(fā)于 SegmentFault - 邊城客棧 專欄
歡迎關注作者的公眾號“邊城客?!?,閱讀邊城原創(chuàng)開發(fā)技術類博文 ?
更多建議: