關於C++ TpeScript系列的泛型

前言:

我在面試的時候,通常喜歡問候選人一些莫名其妙的問題。比如這樣的問題,假如你是某個庫的作者,你如何實現某個功能。這類問題一般沒有正確的答案,主要意圖是考察一下候選人對這個庫有沒有更深入的理解,次要意圖是覺得這樣挺好玩。玩歸玩,但該嚴肅的時候也要嚴肅起來。有一次,我面試到一位用過TypeScript的同學,這讓人眼前一亮(從我的經驗看,國內偶爾有大廠會用,小廠基本沒有)。隨後,我問瞭句,你是怎麼理解泛型的呢?問瞭之後,我就後悔瞭,因為我也不知道答案。但隨後的答案讓我沒有後悔,因為候選人回瞭我一句,我不知道什麼是泛型……

這件事對候選人的影響可大可小,但對我的影響挺大的。它致使我一定要寫出一篇關於泛型的文章。但自從種下這個種子後,我就開始後悔瞭。因為越接觸TS中的泛型,越覺得這個題材沒什麼好寫的。一來呢,TS中的泛型猶如空氣,經常使用卻難以描述。二者呢,它太過寬泛,難以面面俱到。

今天的這篇文章將不同於這個系列的以往。這篇文章將從C++模版要解決的問題出發,引出TS泛型要解決的問題,並簡答介紹一些稍微高級的使用場景。

一、模版

說起泛型,不得不提一下泛型的鼻祖,模版。C++中的模版以燒腦殼和強大著稱,並被各類大牛津津樂道多年。就現在而言,Java、.NET或TS中的泛型都可以被認為是實現瞭C++模版的子集。對於子集的說法,我不敢茍同。因為就存在的目的而言,TS和C++模版完全不一樣。

C++模版的出現是為瞭產生類型安全的通用容器。我們先來說一下通用容器,比如我寫瞭個鏈表或者數組,這個數據結構不太關心存在裡面的具體數據是什麼類型,它都可以實現對應的操作。但js本身不關註類型和大小,所以js中的數組本來就是通用容器。對於TS而言,泛型的出現就可以解決這個問題。另一個值得對比的是產生,C++模版最終產出的是對應的類或函數,但對於TS而言,TS無法產生任何東西。有的同學可能要問瞭,TS不是最終產生JS代碼嗎?這樣說有點不嚴謹,因為TS最終是分離出瞭JS代碼,而沒有對原有邏輯做任何處理。

C++模版的另一個目的就是元編程。這個元編程相當地強大,它主要通過編譯時的程序設計構造來優化程序的執行。就TS而言,目前它隻做瞭一處類似的優化,就是const enum可以內聯在執行的地方,僅此而已。關於這類優化,上篇結束的位置也提到瞭基於類型推導的優化,但目前而言,TS還沒有這個功能。倘若這類簡單的優化都不支持,那對於更為復雜的元編程而言,就更不可能瞭(元編程需要對泛型參數進行邏輯推導,並最終內聯到使用到的地方)。

關於C++模版,就說這麼多吧,畢竟這不是一篇關於模版元編程的文章,而且我也不是專傢,更多關於模版的問題,可以去問問輪子哥。說這麼多模版,主要還是想說,TS中的泛型和模版是非常不一樣的!如果你是從C++Java轉來做前端,仍然需要重新認識一下TS中的泛型。

二、泛型

我認為TS中的泛型主要有3個主要用途:

  • 聲明泛型容器或組件。比如:各種容器類MapArray、Set等;各種組件,比如React.Component
  • 對類型進行約束。比如:使用extends約束傳入參數符合某種特定結構。
  • 生成新的類型

關於第二、三點,因為之前文章已經很清楚地提到過,這裡不再贅述。關於第一點,我這裡舉兩個例子:

第一個例子是關於泛型容器,假如我想實現一個簡單的泛型鏈表,代碼如下:

class LinkedList<T> { // 泛型類
  value: T;
  next?: LinkedList<T>; // 可以使用自身進行類型聲明
  constructor(value: T, next?: LinkedList<T>) {
    this.value = value;
    this.next = next;
  }
  log() {
    if (this.next) {
      this.next.log();
    }
    console.log(this.value);
  }
}
let list: LinkedList<number>; // 泛型特化為number
[1, 2, 3].forEach(value => {
  list = new LinkedList(value, list);
});
list.log(); // 1 2 3

第二個是泛型組件,假如我想實現一個通用的表單組件,可以這樣寫:

function Form<T extends { [key: string]: any }>({ data }: { data: T }) {
  return (
    <form>
      {data.map((value, key) => <input name={key} value={value} />)}
    </form>
  )
}


這個例子不止演示瞭泛型組件,也演示瞭如何使用extends定義泛型約束。現實中的泛型表單組件可能比這個更為復雜,上面隻是演示一下思路。

到此為止,TS的泛型就講完瞭!但這個文章還沒完,下面我們來看一下泛型的一些高級使用技巧。

三、泛型遞歸

遞歸簡單來說就是函數的輸出可以繼續作為輸入來進行邏輯演算的一類解決問題的思路。舉個簡單的例子,比如我們要算加法,定義瞭一個add函數,它隻能求兩個數的和,但現在我們有1,2,3等三個數需要計算,那我們如何用現有的工具解決這個問題呢?答案很簡單,首先算add(1, 2)是3,然後add(3, 3)是6。這就是遞歸的思路。

在現實生活中,遞歸是如此的常見,以至於我們經常忽略它的存在。程序的世界也是如此。這裡舉個例子,並用這個例子來說明TS中的遞歸如何實現。比如,我現在有個泛型類型ReturnType<T>,它可以返回一個函數的返回類型。但我現在有個調用層級很深的函數,而且我不知道它的層級有多深,我該如何做呢?

思路一:

type DeepReturnType<T extends (...args: any) => any> = ReturnType<T> extends (
  ...args: any
) => any
  ? DeepReturnType<ReturnType<T>> // 這裡引用自身
  : ReturnType<T>;

上面代碼的說明:這裡定義瞭一個DeepReturnType的泛型類型,類型約束為接受任意參數、返回任意類型的函數。若它的返回類型是個函數,則繼續用返回類型調用自身,否則返回函數的返回類型。

任何直觀、簡潔的方案背後都有一個但是。但是,這個是無法通過編譯的。主要原因是,TS暫時不支持。以後支不支持我不知道,但,官方給的理由很明確:

  • 這個有著環形的意圖不可能構成對象圖,除非你以某種方式推遲(通過惰性或狀態)。
  • 真的沒有辦法知道類型推導是否結束。
  • 我們可以在編譯器中使用有限類型的遞歸,但問題不在於類型是否終止,而是計算密集程度和內存分配律如何。
  • 一個元問題:我們是否希望人們編寫這樣的代碼?這種使用場景是存在的,但這樣實現的類型不一定適合庫的消費者。
  • 結論:我們還沒有為這種件事做好準備。

所以,我們該如何實現這類需求呢?方法是有的,如官方給出的思路,我們可以使用有限次數的遞歸。下面給出我的思路:

// 兩層泛型類型
type ReturnType1<T extends (...args: any) => any> = ReturnType<T> extends (
  ...args: any
) => any
  ? ReturnType<ReturnType<T>>
  : ReturnType<T>;
// 三層泛型類型
type ReturnType2<T extends (...args: any) => any> = ReturnType<T> extends (
  ...args: any
) => any
  ? ReturnType1<ReturnType<T>>
  : ReturnType<T>;
// 四層泛型類型,可以滿足絕大多數情況
type DeepReturnType<T extends (...args: any) => any> = ReturnType<T> extends (
  ...args: any
) => any
  ? ReturnType2<ReturnType<T>>
  : ReturnType<T>;
  
// 測試
const deep3Fn = () => () => () => () => "flag is win" as const; // 四層函數
type Returned = DeepReturnType<typeof deep3Fn>; // type Returned = "flag is win"
const deep1Fn = () => "flag is win" as const; // 一層函數
type Returned = DeepReturnType<typeof deep1Fn>; // type Returned = "flag is win"

這種技巧可以推廣到定義深層結構的ExcludeOptionalRequired等等。

四、默認泛型參數

有時候我們很喜歡泛型,但有時候我們又不希望類或函數的消費者每次都指定泛型的類型,這時候,我們可以使用默認的泛型參數。這個在很多第三方庫中廣泛使用,比如:

// 接收P S C的泛型組件
class Component<P,S,C> {
  props: P;
  state: S;
  context:C
  ....
}
// 需要這樣使用
class MyComponent extends Component<{}, {}, {}>{}
​
// 但如果我的組件是個很純粹的組件,並不需要props、state和context呢
// 可以這樣定義
class Component<P = {}, S = {}, C = {}> {
  props: P;
  state: S;
  context:C
  ....
}
// 然後可以這麼使用
class MyComponent extends Component {}

我覺得這個特性非常實用,它以一種js中很自然的方式實現瞭C++模版中的partial instantiation

五、泛型重載

泛型重載在官方文檔上提過幾嘴,這種重載依賴於函數重載的一些機制,因此,我們先來看一下TS中的函數重載吧。這裡,我用lodash裡面的map函數來舉例。map函數的第二個參數可以接受一個string或是function比如官網的例子:

const square = (n) => n * n;
​
// 接收函數的map
map({ 'a': 4, 'b': 8 }, square);
// => [16, 64] (iteration order is not guaranteed)
 
const users = [
  { 'user': 'barney' },
  { 'user': 'fred' }
];
​
// 接收string的map
map(users, 'user');
// => ['barney', 'fred']

那麼,這樣的類型聲明如何在TS中表達呢?我可以使用函數重載,比如這樣:

// 這裡隻做演示,不保證正確性。真實場景下這裡需要填充正確的類型,而不是any
interface MapFn {
  (obj: any, prop: string): any; // 當接收string時的情況,情景一
  (obj: any, fn: (value: any) => any): any; // 當接收函數時的情況,情景二
}
const map: MapFn = () => ({});
​
map(users, 'user'); // 重載情景一
map({ 'a': 4, 'b': 8 }, square); // 重載情景二

上面這段代碼使用瞭TS中比較奇特的一種機制,也就是函數、new等 類函數的定義可以寫在interface中。這個特性的出現主要是為瞭支持js中可調用的對象,比如,在jQuery中,我們可以直接執行$("#banner-message"),或者調用其方法 $.ajax()。

當然,也可以使用另一種更為傳統的做法,比如下面這樣:

function map(obj: any, prop: string): any;
function map(obj: any, fn: (value: any) => any): any;
function map(obj, secondary): any {}

這裡,基本講清楚瞭函數重載。推廣到泛型,基本上是一樣的。這裡舉一個知友提的問題的例子,對於這個問題,這裡不再贅述。解決思路大概是這樣的:

interface FN {
  (obj: { value: string; onChange: () => {} }): void;
  <T extends {[P in keyof T]: never}>(obj: T): void;
  //  ,對於obj的類型T而言,它始終不接收其它的key。
}
​
const fn: FN = () => {};
​
fn({}); // 正確
fn({ value: "Hi" }); // 錯誤
fn({ onChange: () => {} }); // 錯誤
fn({ value: "Hi", onChange: () => ({}) }); // 正確


對於React生態,這裡有一個比較值得閱讀的泛型重載的實例,那就是connect函數,大傢可以移步到它的源碼以便瞭解更多。

整體而言,我不太喜歡這篇文章。究其原因,TS中的泛型使用廣泛,因其設計初衷的原因,可玩性較差。但我對這種設計理念是支持的,首先,它能夠滿足我們定義類型的要求,其次,它做到瞭比C++模版更為簡單易用。

到此這篇關於關於C++ TpeScript系列的泛型的文章就介紹到這瞭,更多相關TypeScript泛型內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: