用函數語言程式設計,從0開發3D引擎和編輯器(二):函數語言程式設計準備
大家好,本文介紹了本系列涉及到的函數語言程式設計的主要知識點,為正式開發做好了準備。
函數語言程式設計的優點
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等思想和資料結構表達資料;
使用函式表達邏輯;
使用組合、柯西化等工具,把資料和邏輯關聯起來。
更多討論
本系列使用的函數語言程式設計語言
我們使用Reason語言,它是從Ocaml而來的,屬於非純函數語言程式設計語言。
而我們熟知的Haskell,屬於純函數語言程式設計語言。
為什麼不用純函數語言程式設計語言
1.更高的效能
Reason支援Mutable、迭代操作,提高了效能
2.更簡單易用
1)允許非純操作,所以不需要使用Haskell中的各種Monad
2)嚴格求值相對於惰性求值更簡單。
搭建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結構)。
相關資料
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函式
函式是第一公民,函式是資料。
- 純函式
介紹
純函式是這樣一種函式,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
示例
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中的應用
應用的地方太多了,此處省略。
型別
- 基本型別
介紹
Reason是強型別語言,包含int、float、string等基本型別。
示例
type a = string;/* 定義a為string型別 */ let str:a = "zzz";/* 變數str為a型別 */
在Wonder中的應用
型別在wonder中應用廣泛,包括以下的使用場景:
1)型別驅動設計
2)領域建模
3)列舉
相關資料
- 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的基礎
- 抽象型別
介紹
有時候我們想定義一個型別,它不是某一個具體的型別,可以將其定義為抽象型別。
示例
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。
過程
- 組合
介紹
多個函式可以組合起來,使得前一個函式的返回值是後一個函式的輸入,從而對資料進行管道處理。
示例
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 |> ...
相關資料
- 遞迴
介紹
遍歷操作可以分成兩類:
迭代
遞迴
遞迴就是指函式呼叫自己,滿足終止條件時結束。如深度優先遍歷是遞迴操作,而廣度優先遍歷是迭代操作。
注意:
儘量寫成尾遞迴,這樣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命令)。
- 模式匹配
介紹
使用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 => ... }
非同步
- 函式反應式程式設計
介紹
處理非同步,主要有以下的方法:
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中的應用
凡是非同步操作,如事件處理、多執行緒等,都用流來處理。
容器
- 容器
介紹
為了領域建模,或者為了保證純函式而隔離副作用,需要把值封裝到容器中。外界只能操作容器,不直接操作值。
示例
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 包裝空值。
相關資料
多型
- 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等方法。
- 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,從而消除重複。
函數語言程式設計學習資料
這本書作為我學習函數語言程式設計的第一本書,非常容易上手,作者講得很簡單易懂,推薦~
收集了函數語言程式設計相關的資料。
這個部落格講了很多F#相關的函數語言程式設計的知識,非常推薦!
如果你正在使用Reason或者Ocaml或者F#語言,建議到該部落格中學習!
歡迎瀏覽上一篇博文:用函數語言程式設計,從0開發3D引擎和編輯器(一)