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