有趣也有用的現代類型系統

我們在項目中,經常會碰到需要選擇編程語言的情況,對比語言的語法,性能,生態等方面的優缺點。其中,語言是靜態還是動態類型的,也是一個經常會考慮的方面。

傳統的靜態類型語言比如Java,提供的類型系統非常笨重,讓寫類型標註變成一件非常痛苦和低效的事情,所以敏捷型的互聯網團隊,大都傾向於使用靈活的動態類型語言。

然而靜態類型語言在工業上的應用經過了多年的發展,已經取得了長足的進步,類型系統逐漸變得完善,讓我們在寫靜態類型語言的時候有著越來越接近動態類型語言的體驗。

類型推斷

一個例子是作為靜態類型的C#,在某個版本中加入了var關鍵字,這並不表明C#變成了動態類型語言,而是設計者想要把類型確定的任務從開發者手中轉移給類型推斷系統。回想在寫Java的時候,思路時常要中斷,去回憶某個變數的類型名是啥,確實是一件令人厭煩的事情。

比如在Java中,常常用到工廠方法:

AppleIPhoneX phone = ApplePhoneFactory.createX()

在寫這樣一行代碼的時候,是不是時常要考慮AppleIPhoneX這個類型的具體命名是什麼,到底是IPhoneX,AppleX,還是別的?當你從左往右編寫這行代碼,要敲下第一個字母的時候,自動提示也難以給出可靠的答案。

拿我們團隊使用的TypeScript舉例,TypeScript提供了下面這些形式的類型推斷。

// 定義時推斷let foo = 123let bar = Hellofoo = bar // Error: cannot assign `string` to a `number`// 返回值推斷function add(a: number, b: number) { return a + b}let foo = add(1, 2) // foo: number// 結構體推斷let foo = { a: 123, b: 456} // foo: {a: number; b: number;}let bar = foo.a // bar: number

可以看到上面幾個例子中,只有add函數的入參是我們手寫了類型標註的,其他都是自動推斷出來的。

類型兼容

還是拿Java舉例,在編寫程序時,我們常常需要定義不同的數據類型,比如表單,比如服務的參數Bean。我們時常會碰到這樣的情況,多個class定義,即使欄位完全一樣,但只要class的canonical name(包含包名的class name)不一樣,就需要重新構造:

class Point { int x; int y; Point(int x, int y){ this.x = x; this.y = y;}}class Point2D { Point2D(int x, int y){ this.x = x; this.y = y;} int x; int y;}public class PointHolder { takePoint(Point p){} public static void main(){ Point2D p1 = new Point2D(1,1) takePoint(new Point(p1.x, p1.y)) // convert Point2D to Point }}

而TypeScript 的對象是按屬性匹配的,任何包含了介面定義屬性的對象,都可以看作是介面的實現,這點和go語言是相同的,可以認為是現代工程語言的一個設計趨勢。

interface Point { x: number y: number}class Point2D { constructor(public x:number, public y:number){}}let p: Point = new Point2D(1,2)

方法屬性也是類似:

interface Point { x: number y: number getDistance(): number}class Point2D { constructor(public x:number, public y:number){} getDistance() { return Math.sqrt(Math.pow(this.x, 2) + Math.pow(this.y, 2)) }}let p: Point = new Point2D(1,2)p.getDistance()

類型演算

動態類型語言有一個明顯的好處是,有時候我們希望類型是動態的:到運行時再確定變數的類型,這個特性給我們提供了元編程的體驗,大大減少了代碼量。所以TypeScript也提供了一些高級類型標註語法,幫助我們寫出動態的類型標註。

這些語法除了基礎的Intersection,Union,還有一些高級玩法,這裡就介紹一些高級類型以體現類型系統的靈活性。

還是舉例子,TypeScript類型標註有這樣一個語法

Mapped Type:

{ [ P in K ] : T }

利用Mapped Type我們可以定義一個Pick類型(從一個對象中選出一部分屬性構造出的新對象類型)

// From T pick a set of properties Ktype Pick<T, K extends keyof T> = { [P in K]: T[P];}function pick<T, K extends keyof T>(obj: T, ...keys: K[]): Pick<T, K>;

這樣定義出來的pick函數能夠精確推導出返回值類型:

let foo = { a: 1, b: hello, c: { c1: 1, c2: c2}}let bar = pick(foo, b, c) // bar: { b: string; c: { c1: number; c2: string }}

我們再展開看看Pick類型的定義:

type Pick<T, K extends keyof T> = { [P in K]: T[P];}

首先Pick接收兩個泛型參數TK,其中K有一個約束K extends keyof T,這個keyof也是TypeScript類型演算的一個操作符,keyof T就是T類型的key組合,這裡對K的約束就是K必須從T類型的key裡面選。比如上面的例子中K就只能是a,b,c這三個字元串。

然後Pick類型利用泛型參數構造出了一個新的結構體類型,這個結構體類型用Mapped Type來表達,他包含的key是[P in K],就是我們選擇的原對象foo的子集,value是T[P],和原對象對應的鍵值對相同。

T[P]又是另外一個知識點了,不過按面意思很容易理解,和對象取欄位操作一樣,T[P]就是T結構體類型的P欄位的類型。

總結一下我們用到了兩個特性,一個是Index type,包含兩個操作符:keyof用於查詢類型key,是query operator,T[P]用於獲取具體類型,是access operator。還有一個就是Mapped Type了,用於遍歷結構體類型中的key。這兩個特性在TypeScript中經常用到,是靈活類型系統的支柱。

這樣pick這種很動態的函數定義就表達出來了,是不是很靈活?反觀在傳統靜態類型語言中,要達到同樣效果,只能費很大勁使用反射,而用了反射,就意味著放棄了類型檢查,還不如直接使用動態類型語言。

所以TypeScript的類型系統是一個很契合動態語言的系統,能夠很靈活的構造出新的類型而不要求事先定義。

協變,逆變和「雙變「

使用過有泛型的靜態類型語言的同學會對協變(covariant)逆變(contra-variant)比較了解。這裡先簡單回顧一下這兩個概念:對複合類型P<T>,如果P的繼承方向和T相同,則P是對T協變的,如果相反則是逆變的。看似很簡單的一句話,實際上由於T在P類型中出現的位置不同,P是協變還是逆變也會不同,就衍生出一些比較複雜的情況,即使經驗豐富的程序員也經常會判斷錯誤,類型系統也很難分析,所以大多數靜態類型語言不會完整的支持協變和逆變檢查。

比如一個基本的協變類型是Array<T>,很多語言是支持把Array<Cat>賦值給Array<Animal>的,(前提是CatAnimal的子類)。涉及到類型中包含泛型方法的情況,協變逆變就要複雜一些,比較常見的規則是如果類型參數T出現在P的方法返回值(out)中,那麼P對於T是協變的;如果T出現在P的方法參數(in)中,那麼P對於T是逆變的。

用下面兩張圖來說明:

圖一中,由於返回值(out)協變規則,ClassB繼承ClassA的時候,對於要覆蓋的方法method,其返回值T必須是父類型中返回值T的子類型:即方法返回值類型的繼承方向與class的繼承方向一致。

圖二中,由於入參(in)逆變規則,ClassB要覆蓋的方法method入參T必須是父類型中同名方法入參T的父類型:即方法參數類型與class繼承方向相反。

參數在返回值中:協變

出現在方法參數:逆變

更複雜的情況是:如果P同時在方法的返回值和參數中都使用到了T,那麼P對於T應該是協變和逆變?有時候甚至是不變:協變和逆變都不適用,把P<Cat>和P<Animal>看作完全不相關的類型。

而在JavaScript中,常常會有這樣的場景:

interface Event { timestamp: number; }interface MouseEvent extends Event { x: number; y: number }interface KeyEvent extends Event { keyCode: number }enum EventType { Mouse, Keyboard }function addEventListener(eventType: EventType, handler: (n: Event) => void) { /* ... */}addEventListener(EventType.Mouse, (e: MouseEvent) => console.log(e.x + "," + e.y))addEventListener(EventType.Mouse, (e: number) => console.log(e)) // error

在這個例子中,handler對於它的參數類型Event是逆變的,正常情況下MouseEvent=>void是不能賦值給Event=>void的,但是TypeScript的一個原則是方便,盡量讓我們有著寫動態JavaScript的體驗,所以造出了bivariant這個概念:在方法參數的協變逆變判斷這個場景中,子類型和父類型可以相互替換。

我不能說這是一個很好的設計,畢竟犧牲了一部分類型檢查的可靠性,但是也算是和開發效率之間的權衡了。

Gradual Typing

前面提了一些TypeScript的類型機制,這些機制讓寫靜態類型語言有著接近動態語言的體驗,同時也享受了靜態類型的好處。除此之外TypeScript還有一個殺手鐧,也是TypeScript的基本:它是Javascript的超集,也就是說,你只需要把.js文件後綴名改為.ts,然後可以選擇性的在JavaScript代碼中添加類型標註,TypeScript編譯器會儘可能的利用有限的類型標註做類型檢查和提供自動完成提示。這種做法不是TypeScript開創的,被稱為Gradual Typing。

當然,提供這種機制是為了方便我們從遺留的JavaScript項目轉換到TypeScript項目,破壞動態語言堅守者的最後一道心理防線。如果是新的項目,最好還是提供充分的類型標註。尤其是在大型團隊項目中,類型標註不僅幫助個人在編譯期提前發現錯誤,還起到給其他成員提供介面信息的作用,拿到一個團隊成員提供的介面,有了類型標註,減輕了很多理解負擔,也減少了溝通成本,這些好處就不用贅述了。

即刻後端在項目起始,由於各方面的原因,選擇了NodeJs作為主力開發語言,並在項目逐漸成長龐大之後,由動態類型的JavaScript逐漸遷移到了靜態類型的TypeScript。在團隊協作效率和工程質量上都取得了顯著性的提高,我們在實踐中也逐漸加強了這個觀點:有了強大靈活的類型系統,靜態類型語言也可以很高效的開發。

作者:我我(知乎&即刻)

參考:

Type Compatibility

Advanced Types · TypeScript

Covariance and contravariance (computer science)

What is Gradual Typing

推薦閱讀:

VS Code 1.14更新日誌
【RPU-A】TypeScript 引入了 Plugin 支持
現在 TypeScript 的生態如何?
如何看待 Angular 2.0 使用的 AtScript 是 TypeScript 的超集?
手把手教寫 TypeScript Transformer Plugin

TAG:Nodejs | TypeScript | 類型系統 |