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

即刻技術團隊發表於2018-02-19

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

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

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

型別推斷

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

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

AppleIPhoneX phone = ApplePhoneFactory.createX()
複製程式碼

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

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

// 定義時推斷
let foo = 123
let bar = 'Hello'
foo = 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 K
type 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,很多語言是支援把Array<Cat>賦值給Array<Animal>的。涉及到方法的協變逆變就要複雜一些,比較常見的規則是如果型別引數T出現在P的方法返回值(out)中,那麼P對於T是協變的;如果T出現在P的方法引數(in)中,那麼P對於T是逆變的。

用下面兩張圖來說明:

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

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

引數在返回值中:協變
圖1. 引數在返回值中:協變

出現在方法引數:逆變
圖2. 出現在方法引數:逆變

更復雜的情況是:如果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。在團隊協作效率和工程質量上都取得了顯著性的提高,我們在實踐中也逐漸加強了這個觀點:有了強大靈活的型別系統,靜態型別語言也可以很高效的開發。

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

相關文章