1. 程式人生 > >用函數式編程,從0開發3D引擎和編輯器(二):函數式編程準備

用函數式編程,從0開發3D引擎和編輯器(二):函數式編程準備

cat null 存在 處理程序 字符串 優勢 attr 互轉 defined

大家好,本文介紹了本系列涉及到的函數式編程的主要知識點,為正式開發做好了準備。

函數式編程的優點

1.粒度小

相比面向對象編程以類為單位,函數式編程以函數為單位,粒度更小。

正所謂:

我只想要一個香蕉,而面向對象卻給了我整個森林

2.性能好

大部分人認為函數式編程差,主要基於下面的理由(參考 JavaScript 函數式編程存在性能問題麽?):
1)柯西化、函數組合等操作增加時間開銷
2)map、reduce等操作,會進行多次遍歷,增加時間開銷
3)Immutable數據每次操作都會被拷貝為新的數據,增加時間和內存開銷

而我說性能好,是指通過“Reason的編譯優化+Immutable/Mutable結合使用+遞歸/叠代結合使用”,可以解決這些問題:

1)由於Bucklescript編譯器在編譯時的優化,柯西化等操作和Immutable數據被編譯成了優化過的js代碼,大幅減小了時間開銷
2)由於Reason支持Mutable和for,while叠代操作,所以可以在性能熱點使用它們,提高性能。

3.擅長處理數據,適合3D領域編程

通過高階函數、柯西化、組合等工具,函數式編程可以像流水線一樣對數據進行管道操作,非常方便。

3D程序有大量的數據要操作,從函數式編程的角度來看:

3D程序=數據+邏輯

因此,我們可以:
使用Immutable/Mutable、Data Oriented等思想和數據結構表達數據;
使用函數表達邏輯;
使用組合、柯西化等工具,把數據和邏輯關聯起來。

更多討論

FP之優點
函數式編程(Functional Programming)相比面向對象編程(Object-oriented Programming)有哪些優缺點?

本系列使用的函數式編程語言

我們使用Reason語言,它是從Ocaml而來的,屬於非純函數式編程語言。

而我們熟知的Haskell,屬於純函數式編程語言。

Reason學習文檔

為什麽不用純函數式編程語言

1.更高的性能
Reason支持Mutable、叠代操作,提高了性能

2.更簡單易用
1)允許非純操作,所以不需要使用Haskell中的各種Monad
2)嚴格求值相對於惰性求值更簡單。

搭建Reason開發環境

詳見Reason的介紹和搭建Reason開發環境

本系列涉及的函數式編程知識點

數據

  • Immutable

介紹
創建不可變數據之後,對其任何的操作,都會返回一個拷貝後的新數據。

示例
Reason的變量默認為immutable:

let a = 1;

/* a為immutable */

Reason也有專門的不可變數據結構,如Tuple,List,Record。

這裏以Record為例,它類似於Javascript中的Object:
首先定義Record的類型:

type person = {
  age: int,
  name: string
};

然後定義Record的值:

let me = {
  age: 5,
  name: "Big Reason"
};

使用這個Record,如修改"age"的值:

let newMe = {
    ...me,
    age: 10
};

Js.log(newMe === me); /* false */

newMe是從me拷貝而來,任何對newMe的修改,都不會影響me。

在Wonder中的應用

在編輯器中的應用
編輯器的所有數據都是Immutable的,這樣的好處是:
1)不用關心數據之間的關聯關系,因為每個數據都是獨立的
2)不用擔心狀態被修改,減少了很多bug
3)實現Redo/Undo功能時非常簡單,直接把Immutable的數據壓入History的棧裏即可,不用深拷貝/恢復數據。

在引擎中的應用
大部分函數的局部變量都是Immutable的(如使用tuple,record結構)。

相關資料
Reason->Let Binding
Reason->Record
facebook immutable.js 意義何在,使用場景?
Introduction to Immutable.js and Functional Programming Concepts

  • Mutable

介紹
對可變數據的任何操作,都會直接修改原數據。

示例
Reason通過"ref"關鍵字,標誌變量為Mutable。

let foo = ref(5);

let five = foo^; 

foo := 6;   //foo===five===6

Reason也可以通過"mutable"關鍵字,標誌Record的字段為Mutable。

type person = {
  name: string,
  mutable age: int
};
let baby = {name: "Baby Reason", age: 5};
baby.age = baby.age + 1; /* 修改原數據baby的age為6 */

在Wonder中的應用

因為操作Mutable數據不會造成拷貝,沒有垃圾回收cg的開銷,所以在性能熱點處,常常使用Mutable數據。

相關資料
Reason->Mutable

函數

函數是第一公民,函數是數據。

相關資料:
如何理解在 JavaScript 中 "函數是第一等公民" 這句話?
Reason->Function

  • 純函數

介紹

純函數是這樣一種函數,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。

示例

let a = 1;


/* func2是純函數 */
let func2 = value => value;

/* func1是非純函數,因為使用了外部變量"a" */
let func1 = () => a;

在Wonder中的應用

腳本的鉤子函數(如init,update,dispose等函數)屬於純函數(但不能算嚴格的純函數),這樣是為了:
1)能夠正確序列化
腳本會先序列化為字符串,保存在文件中(如編輯器導出的包中);
然後在導入該文件時(如編輯器導入包),將腳本字符串反序列化為函數(執行:eval(‘(‘ + funcStr + ‘)‘))。如果腳本的鉤子函數不是純函數(如調用了外部變量),則會報錯。

2)支持多線程
目前腳本是在主線程執行的,但因為它是純函數,所以未來可以放在單獨的腳本線程中執行,提高性能。

註意
雖然純函數好處很多,但Wonder中大多數的函數都是非純函數,這是因為:
1)為了性能
2)為了簡單易用,所以允許副作用,很少使用容器

相關資料
第 3 章:純函數的好處

  • 高階函數

介紹
函數能夠作為數據,成為高階函數的參數或者返回值。

示例

let func1 = func => func(1);

let func2 = value => value * 2;

func1(func2);   /* func1是高階函數,因為func2是func1的參數 */

在Wonder中的應用

多個函數中常常有一些共同的邏輯,需要消除重復,可以通過提出一個私有的高階函數來解決。具體示例如下:
重構前:

let add1 = value => value + 2;

let add2 = value => value + 10;

let minus1 = value => value - 10;

let minus2 = value => value - 200;

let compute1 = value => value |> add1 |> minus1;

let compute2 = value => value |> add2 |> minus2;

/* compute1,compute2有重復邏輯 */

重構後:

...

let _compute = (value, (addFunc, minusFunc)) =>
  value |> addFunc |> minusFunc;

let compute1 = value => _compute(value, (add1, minus1));

let compute2 = value => _compute(value, (add2, minus2));

相關資料
理解 JavaScript 中的高階函數

  • 柯西化

介紹

只傳遞給函數一部分參數來調用它,讓它返回一個函數去處理剩下的參數。
你可以一次性地調用 curry 函數,也可以每次只傳一個參數分多次調用。

示例

let func1 = (value1, value2) => value1 + value2;

let func2 = func1(1);

func2(2);   /* 3 */

在Wonder中的應用

應用的地方太多了,此處省略。

相關資料
第 4 章: 柯裏化(curry)
Currying

類型

相關資料
The "Understanding F# types" series

  • 基本類型

介紹
Reason是強類型語言,包含int、float、string等基本類型。

示例

type a = string;   /* 定義a為string類型 */

let str:a = "zzz";   /* 變量str為a類型 */

在Wonder中的應用

類型在wonder中應用廣泛,包括以下的使用場景:
1)類型驅動設計
2)領域建模
3)枚舉

相關資料

Reason->Type
Algebraic type sizes and domain modelling

  • Discriminated Union Type

介紹
類型可以接受參數,還可以組合其它的類型。

示例

type result('a, 'b) =
  | Ok('a)
  | Error('b);

type myPayload = {data: string};

let payloadResults: list(result(myPayload, string)) = [
  Ok({data: "hi"}),
  Ok({data: "bye"}),
  Error("Something wrong happened!")
];

在Wonder中的應用

1)作為容器的實現
2)是實現本文後面的Recursive Type的基礎

相關資料
Reason->Type Argument
Reason->Null, Undefined & Option
Discriminated Unions

  • 抽象類型

介紹
有時候我們想定義一個類型,它不是某一個具體的類型,可以將其定義為抽象類型。

示例

type value;

type a = value; /* a為value類型 */

在Wonder中的應用

包括以下的使用案例:
1)在封裝WebGL api的FFI中(什麽是FFI?),把WebGL的上下文定義為抽象類型。

示例代碼如下:

/* FFI */


/* 抽象類型 */
type webgl1Context;

[@bs.send]
external getWebgl1Context : ('canvas, [@bs.as "webgl"] _) => webgl1Context = "getContext";

[@bs.send.pipe: webgl1Context]
external viewport : (int, int, int, int) => unit = "";




/* client code */

/* canvasDom是canvas的dom,此處省略了獲取它的代碼 */
/* gl是webgl1Context類型 */
/* 編譯後的js代碼為:var gl = canvasDom.getContext("webgl"); */
let gl = getWebgl1Context(canvasDom);   

/* 編譯後的js代碼為:gl.viewport(0,0,100,100); */
gl |> viewport(0,0,100,100);

2)腳本->屬性->value可以為int或者float類型,因此將value設為抽象類型,並且定義抽象類型和int、float類型之間的轉換FFI。

示例代碼如下:


type scriptAttributeType =
  | Int
  | Float;


/* 抽象類型 */
type scriptAttributeValue;

type scriptAttributeField = {
  type_: scriptAttributeType,
  value: scriptAttributeValue
};

/* 定義scriptAttributeValue和int,float類型相互轉換的FFI */

external intToScriptAttributeValue: int => scriptAttributeValue = "%identity";

external floatToScriptAttributeValue: float => scriptAttributeValue =
  "%identity";

external scriptAttributeValueToInt: scriptAttributeValue => int = "%identity";

external scriptAttributeValueToFloat: scriptAttributeValue => float =
  "%identity";
  
  
/* client code */

/* 創建scriptAttributeField,設置value的數據(int類型) */

let scriptAttributeField = {
    type_: Int,
    value:intToScriptAttributeValue(10) 
};



/* 修改scriptAttributeField->value */

let newScriptAttributeField = {
    ...scriptAttributeField,
    value: (scriptAttributeValueToInt(scriptAttributeField.value) + 1) |> intToScriptAttributeValue
};

相關資料
抽象類型(Abstract Types)

  • Recursive Type

介紹
從類型定義上看,可以看成是Discriminated Union Type,只是其中至少有一個union type為自身類型,即遞歸地指向自己。

示例
還是看代碼好理解點,具體示例如下:

type nodeId = int;

/* tree是Recursive Type,它的文件夾節點包含了子節點,而子節點的類型為自身 */
type tree =
  | LeafNode(nodeId)
  | FolderNode(
      nodeId,
      array(tree),
    );

在Wonder中的應用

在編輯器中的應用

Recursive Type常用在樹中,如編輯器的資產樹的類型就是Recursive Type。

相關資料
The "Recursive types and folds" series
Map as a Recursion Scheme in OCaml

過程

  • 組合

介紹
多個函數可以組合起來,使得前一個函數的返回值是後一個函數的輸入,從而對數據進行管道處理。

示例

let func1 = value => value1 + 1;

let func2 = value => value1 + 2;

10 |> func1 |> func2;   /* 13 */

在Wonder中的應用

在引擎中的應用

組合可以應用在多個層面,如函數層面和job層面。

job = 多個函數的組合

我們來看下job組合的應用示例:

從時間序列上來看:

引擎=初始化+主循環

而初始化和每一次循環,都是多個job組合而成的管道操作:

初始化 = create_canvas |> create_gl |> ...


每一次循環 = tick |> dispose |> reallocate_cpu_memory |> update_transform |> ...

相關資料

第 5 章: 代碼組合(compose)

  • 遞歸

介紹

遍歷操作可以分成兩類:
叠代
遞歸

遞歸就是指函數調用自己,滿足終止條件時結束。如深度優先遍歷是遞歸操作,而廣度優先遍歷是叠代操作。

註意:
盡量寫成尾遞歸,這樣Reason會將其編譯成叠代操作。

示例

let rec func1 = (value, result) => {
    value > 3 ? result : func1(value + 1, result + value);
};

func1(1, 0);   /* 0+1+2+3=6; */

在Wonder中的應用

幾乎所有的遍歷都是尾遞歸,只有在少數使用Mutable和少數性能熱點的地方,使用叠代操作(使用for或while命令)。

相關資料
什麽是尾遞歸?
Reason->Recursive Functions

  • 模式匹配

介紹
使用switch結構代替if else處理程序分支。

示例

let func1 = value => {
    switch(value){
        | 0 => 10 
        | _ => 100
    }
};

func1(0);   /* 10 */
func1(2);   /* 100 */

在Wonder中的應用

主要用在下面三種場景:

1)取出容器的值

type a = 
    | A(int)
    | B(string);
    
switch(a){
    | A(value) => value
    | B(value) => value
};

2)處理Option

let a = Some(1);

switch(a){
    | None => ...
    | Some(value) => ...
}

3)處理枚舉類型

type a = 
    | A
    | B;
    
switch(a){
    | A => ...
    | B => ...
}

相關資料
Reason->Pattern Matching!
模式匹配

異步

  • 函數反應式編程

介紹
處理異步,主要有以下的方法:
1)回調函數
缺點:過多的回調導致嵌套層次太深,容易陷入回調地獄,不易維護。
2)Promise
3)await,aync
4)使用函數反應式編程的流
優點:能夠使用組合,像管道處理一樣處理各種流,符合函數式編程的思維。

Wonder使用流來處理異步,其中也用到了Promise,不過都被封裝成了流。

示例
使用most庫實現FRP,因為它的性能比Rxjs更好。

/* 
輸出:
next:2
next:4
next:6
complete
*/
let subscription =
  Most.from([|1, 2, 3|])
  |> Most.map(value => value * 2)
  |> Most.subscribe({
       "next": value => Js.log2("next:", value),
       "error": e => Js.log2("error:", e##message),
       "complete": () => Js.log("complete"),
     });

在Wonder中的應用

凡是異步操作,如事件處理、多線程等,都用流來處理。

相關資料
你一直都錯過的反應型編程
函數式反應型編程 (FRP) —— 實時互動應用開發的新思路
函數式響應型編程(Functional Reactive Programming)會在什麽問題上有優勢?

容器

  • 容器

介紹

為了領域建模,或者為了保證純函數而隔離副作用,需要把值封裝到容器中。外界只能操作容器,不直接操作值。

示例

1)領域建模示例

比如我們要開發一個圖書管理系統,需要對“書”進行建模。
書有書號、頁數這兩個數據,有小說書、技術書兩種類型。
建模為:

type bookId = int;

type pageNum = int;

type book = 
    | Novel(bookId, pageNum)
    | Technology(bookId, pageNum);
    

現在我們創建一本小說,一本技術書,以及它們的集合:

let novel = Novel(0, 100);

let technology = Technology(1, 200);

let bookList = [
    novel,
    technology
];

對“書”這個容器進行操作:

let getPage = (book) => 
switch(book){
    | Novel(_, page) => page
    | Technology(_, page) => page
};


let setPage = (page, book) => 
switch(book){
    | Novel(bookId, _) => Novel(bookId, page)
    | Technology(bookId, _) => Technology(bookId, page)
};

/* client code */

/* 將技術書的頁數設置為集合中所有書的總頁數 */
let newTechnology =
bookList
|> List.fold_left((totalPage, book) => totalPage + getPage(book), 0)
|> setPage(_, technology);

在Wonder中的應用

包含以下使用場景:
1)領域建模
2)錯誤處理
3)處理空值
使用Option包裝空值。

相關資料

Railway Oriented Programming
The "Map and Bind and Apply, Oh my!" series
強大的容器
Monad
Applicative Functor

多態

  • GADT

介紹
全稱為Generalized algebraic data type,可以用來實現函數參數多態。

示例
重構前,需要對應每種類型,定義一個isXXXEqual函數:

let isIntEqual = (source: int, target: int) => source == target;

let isStringEqual = (source: string, target: string) => source == target;
  
  
isIntEqual(1, 1); /*true*/

isStringEqual("aaa", "aaa"); /*true*/

使用GADT重構後,對應多個類型,只有一個isEqual函數:

type isEqual(_) =
  | Int: isEqual(int)
  | Float: isEqual(float)
  | String: isEqual(string);

let isEqual = (type g, kind: isEqual(g), source: g, target: g) =>
  switch (kind) {
  | _ => source == target
  };

isEqual(Int, 1, 1); /*true*/

isEqual(String, "aaa", "aaa"); /*true*/

在Wonder中的應用

1)契約檢查
如需要判斷兩個變量是否相等,則使用GADT,定義一個assertEqual方法替換assertStringEqual,assertIntEqual等方法。

相關資料
Why GADTs matter for performance(需要FQ)
維基百科->Generalized algebraic data type

  • Module Functor

介紹

module可以作為參數,傳遞給functor,返回一個新的module。

類似於面向對象的“繼承”,可以使用函子functor,在基module上擴展出新的module。

示例

module type Comparable = {
  type t;

  let equal: (t, t) => bool;
};

module MakeAdd = (Item: Comparable) => {
  let add = (x: Item.t, newItem: Item.t, list: list(Item.t)) =>
    Item.equal(x, newItem) ? list : [newItem, ...list];
};

module A = {
  type t = int;
  let equal = (x1, x2) => x1 == x2;
};

/* module B有add函數,該方法調用了A.equal函數 */
module B = MakeAdd(A);

let list = B.add(1, 2, []);    /* list == [2] */
let list = list |> B.add(1, 1);    /* list == [2] */

在Wonder中的應用

在編輯器中的應用

1)錯誤處理
錯誤被包裝為容器Result;
由於容器Result中的值的類型不一樣,所以將Result分成RelationResult、SameDataResult。

這兩類Result有共同的模式,因此可以提出基module:Result,然後增加MakeRelationResult、MakeSameDataResult這兩個module functor。它們將Result作為參數,返回新的module:RelationResult、SameDataResult,從而消除重復。

相關資料
Reason->Module Functions

函數式編程學習資料

JS 函數式編程指南
這本書作為我學習函數式編程的第一本書,非常容易上手,作者講得很簡單易懂,推薦~

Awesome FP JS
收集了函數式編程相關的資料。

F# for fun and profit
這個博客講了很多F#相關的函數式編程的知識,非常推薦!
如果你正在使用Reason或者Ocaml或者F#語言,建議到該博客中學習!

歡迎瀏覽上一篇博文:用函數式編程,從0開發3D引擎和編輯器(一)

用函數式編程,從0開發3D引擎和編輯器(二):函數式編程準備