從0開發3D引擎(五):函數語言程式設計及其在引擎中的應用
目錄
- 上一篇博文
- 函數語言程式設計的優點與缺點
- 優點
- 缺點
- 為什麼使用Reason語言
- 函數語言程式設計學習資料
- 引擎中相關的函數語言程式設計知識點
- 資料
- 不可變資料
- 可變資料
- 函式
- 純函式
- 高階函式
- 柯西化
- 型別
- 基本型別
- Discriminated Union型別
- 抽象型別
- 過程
- 組合
- 迭代和遞迴
- 模式匹配
- 容器
- 多型
- GADT
- Module Functor
- 資料
- 參考資料
大家好,本文介紹我們為什麼使用函數語言程式設計來開發引擎,以及它在引擎中的相關的知識點。
上一篇博文
從0開發3D引擎(四):搭建測試環境
函數語言程式設計的優點與缺點
優點
(1)粒度小
面向物件程式設計以類為單位,而函數語言程式設計以函式為單位,粒度更小。
我只想要一個香蕉,而面向物件卻給了我整個森林
(2)擅長處理資料,適合3D領域的程式設計
通過高階函式、柯西化、函式組合等工具,函數語言程式設計可以像流水線一樣對資料進行管道操作,非常方便。
而3D程式正好要處理大量的資料,從函數語言程式設計的角度來看:
3D程式=資料+邏輯
因此,我們可以這樣使用函數語言程式設計正規化來進行3D程式設計:
- 使用Immutable/Mutable資料結構、Data Oriented思想來表達資料
- 使用函式來表達邏輯
- 使用組合、柯西化等操作作為工具,把資料和邏輯關聯起來,進行管道操作
現代的3D引擎越來越傾向於面向資料進行設計,從而獲得更佳的效能,如Unity新版本有很多Data Oriented的思想;
缺點
(1)存在效能問題
Reduce、Map、Filter等操作需要遍歷多次,會增加時間開銷
我們可以通過下面的方法來優化:
a)減少不必要的Map、Reduce等操作;
b)使用transducer來合併這些操作。具體可以參考Understanding transducer in Javascript柯西化、組合等操作會增加時間開銷
每次操作Immutable資料,都需要複製它為新的資料,增加了時間和記憶體開銷
為什麼使用Reason語言
本系列使用Reason語言來實現函數語言程式設計。
Reason語言可以解決前面提到的效能問題:
Bucklescript編譯器在編譯時進行了很多優化,使柯西化、組合等操作和Immutable資料被編譯成了優化過的js程式碼,大幅減小了時間開銷和記憶體開銷
更多編譯器的優化以及與Typescript的比較可參考:
架構最快最好的To JS編譯器Reason支援Mutable變數、for/while進行迭代遍歷、非純函式
在效能熱點處可以使用它們來提高效能,而在其它地方則儘量使用Immutable資料、遞迴遍歷和純函式來提高程式碼的可讀性和健壯性。
另外,Reason屬於“非純函數語言程式設計語言”,為什麼不使用Haskell這種“純函數語言程式設計語言”呢?
因為以下幾點原因:
(1)獲得更高的效能
在效能熱點處使用非純操作(如使用Mutable變數),提高效能。
(2)更簡單易用
Reason允許非純函式,不需要像Haskell一樣使用各種Monad來隔離副作用,保持“純函式”;
Reason使用嚴格求值,相對於Haskell的惰性求值更簡單。
函數語言程式設計學習資料
JS 函數語言程式設計指南
這本書作為我學習函數語言程式設計的第一本書,講得很簡單易懂,非常容易上手,推薦~Awesome FP JS
收集了函數語言程式設計相關的資料。F# for fun and profit
這個部落格講了很多F#相關的函數語言程式設計的知識點,介紹了怎樣基於型別來設計、怎樣處理錯誤等,非常全面和通俗易懂,強力推薦~
Reason語言基於Ocaml語言,而Ocaml語言與F#語言都屬於ML語言類別的,很多概念和語法都類似,所以讀者在該部落格學到的內容,也可以直接應用到Reason。
引擎中相關的函數語言程式設計知識點
本文從以下幾個方面進行介紹:
資料
因為我們不使用全域性變數,而是通過形參傳入函式需要的變數,所以所有的變數都是函式的區域性變數。
我們把與引擎相關的需要持久化的資料,聚合在一起成為一個Record型別的資料,命名為“State”。該Record的一些成員是可變的(用來存放效能優先的資料),另外的成員是不可變的。
關於Record資料結構,可以參考Record。
不可變資料
介紹
不能直接修改不可變資料的值。
建立不可變資料之後,對其任何的操作,都會返回一個複製後的新資料。
示例
變數預設為不可變的(Immutable):
//a為immutable變數
let a = 1;
//導致編譯錯誤
a = 2;
Reason也有專門的不可變資料結構,如Tuple、List、Record。
其中,Record類似於Javascript中的Object,我們以它為例,來看下如何使用不可變資料結構:
首先定義Record的型別:
type person = {
age: int,
name: string
};
然後定義Record的值,它被編譯器推導為person型別:
let me = {
age: 5,
name: "Big Reason"
};
最後操作這個Record,如修改“age”的值:
let newMe = {
...me,
age: 10
};
Js.log(newMe === me); /* false */
newMe是從me複製而來的。任何對newMe的修改,都不會影響me。
(這裡Reason進行了優化,只複製了修改的age欄位,沒有複製name欄位 )
在引擎中的應用
大部分資料都是不可變的(是不可變變數,或者是Tuple,Record等資料結構),這樣的好處是:
1)不用關心資料之間的關聯關係,因為每個資料都是獨立的
2)不可變資料不能被修改
相關資料
Reason->Let Binding
Reason->Record
facebook immutable.js 意義何在,使用場景?
Introduction to Immutable.js and Functional Programming Concepts
可變資料
介紹
對可變資料的任何操作,都會直接修改原資料。
示例
Reason使用"ref"關鍵字定義Mutable變數:
let foo = ref(5);
//將foo的值取出來,設定到five這個Immutable變數中
let five = foo^;
//修改foo的值為6,five的值仍然為5
foo := 6;
Reason也可以通過"mutable"關鍵字,定義Record的欄位為Mutable欄位:
type person = {
name: string,
mutable age: int
};
let baby = {name: "Baby Reason", age: 5};
//修改原資料baby->age的值為6
baby.age = baby.age + 1;
在引擎中的應用
因為操作可變資料不需要拷貝,沒有垃圾回收的開銷,所以在效能熱點處常常使用可變資料。
相關資料
Reason->Mutable
函式
函式是第一等公民,函式即是資料。
相關資料:
如何理解在 JavaScript 中 "函式是第一等公民" 這句話?
Reason->Function
純函式
介紹
純函式是這樣一種函式,即相同的輸入,永遠會得到相同的輸出,而且沒有任何可觀察的副作用。
示例
let a = 1;
/* func2是純函式 */
let func2 = value => value;
/* func1是非純函式,因為引用了外部變數"a" */
let func1 = () => a;
在引擎中的應用
指令碼元件的鉤子函式(如init,update,dispose等函式,這些函式會在主迴圈的特定時間點被呼叫,從而執行函式中使用者的邏輯)屬於純函式,這樣是為了:
1)在匯入/匯出為Scene Graph檔案時,能夠正確序列化
當匯出為Scene Graph檔案時,序列化鉤子函式為字串,儲存在檔案中;
當匯入Scene Graph檔案時,反序列化字串為函式。如果鉤子函式不是純函式(如呼叫了外部變數),則在此時會報錯(因為外部變數並沒有定義在字串中,所以會找不到該變數)。
2)支援多執行緒
可以通過序列化的方式將鉤子函式傳到獨立於主執行緒的指令碼執行緒,從而在該執行緒中被執行,實現多執行緒執行指令碼,提高效能。
雖然純函式好處很多,但引擎中大多數的函式都是非純函式,這是因為:
1)為了提高效能
2)為了簡單,允許副作用,從而避免使用Monad
相關資料
第 3 章:純函式的好處
高階函式
介紹
高階函式的輸入或者輸出為函式。
示例
//func1是高階函式,因為它的引數是函式
let func1 = func => func(1);
let func2 = value => value * 2;
//a=2
let a = func1(func2);
在引擎中的應用
函式之間常常有一些重複或者類似的邏輯,可以通過提出一個私有的高階函式來消除重複。具體示例如下:
重構前:
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;
//傳入第一個引數,func2只有一個引數value2
let func2 = func1(1);
//a=3
let a = func2(2);
在引擎中的應用
應用的地方太多了,此處省略。
相關資料
第 4 章: 柯里化(curry)
Currying
型別
Reason是強型別語言,編譯時會檢查型別是否正確。
本系列希望通過儘可能強的型別約束,來達到“編譯通過即程式正確,減少大量的測試工作”的目的。
關於Reason型別帶來的好處,參考架構最快最好的To JS編譯器:
更好的型別安全: typescript是一個JS的超集,它存在很多歷史包袱。而微軟引入typescript更多的是作為一個工具來使用的比如IDE的程式碼補全,相對安全的程式碼重構。而這個型別的準確從第一天開始就不是它的設計初衷,以至於Facebook自己設計了一個相對更準確地型別系統Flow. 而OCaml的型別系統是已經被形式化的證明過正確的。也就是說從理論上BuckleScript 能夠保證一旦編譯通過是不會有執行時候型別錯誤的,而typescript遠遠做不到這點。
更多的型別推斷,更好的語言特性:用過typescript的人都知道,typescript的型別推斷很弱,基本上所有引數都需要顯示的標註型別。不光是這點,像對函數語言程式設計的支援,高階型別系統GADT的支援幾乎是沒有。而OCaml本身是一個比Elm,PureScript還要強大的多的語言,它自身有一個非常高階的module system,是為數不多的對dependent type提供支援的語言,polymorphic variant。而且pattern match的編譯器也是優化過的。
相關資料
The "Understanding F# types" series
基本型別
介紹
Reason包含int、float、string等基本型別。
示例
//定義a為string型別
type a = string;
//定義str變數的型別為a
let str:a = "zzz";
在引擎中的應用
應用廣泛,包括以下的使用場景:
1)型別驅動設計
2)領域建模
3)列舉
相關資料
Reason->Type
Algebraic type sizes and domain modelling
Discriminated Union型別
介紹
Discriminated Union型別可以接受引數,還可以組合其它的型別。
示例
//result為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!")
];
在引擎中的應用
作為本文後面講到的“容器”的實現,用於領域建模
相關資料
Reason->Type Argument
Reason->Null, Undefined & Option
Discriminated Unions
抽象型別
介紹
抽象型別只給出型別名字,沒有具體的定義。
示例
//value為抽象型別
type value;
在引擎中的應用
包括以下的使用場景:
1)如果不需要型別的具體定義,則將該型別定義為抽象型別
如在封裝WebGL API的FFI中(什麼是FFI?),因為不需要知道“WebGL的上下文”包含哪些方法和屬性,所以將其定義為抽象型別。
示例程式碼如下:
//抽象型別
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
//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)如果一個數據可能為多個型別,則定義一個抽象型別和它與這“多個型別”之間相互轉換的FFI,然後把該資料設為該抽象型別
如指令碼->屬性->value欄位可以為int或者float型別,因此將value設為抽象型別,並且定義抽象型別和int、float型別之間的轉換FFI。
示例程式碼如下:
type scriptAttributeType =
| Int
| Float;
//抽象型別
type scriptAttributeValue;
type scriptAttributeField = {
type_: scriptAttributeType,
//定義value欄位為該抽象型別
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的資料
let scriptAttributeField = {
type_: Int,
value:intToScriptAttributeValue(10)
};
//修改scriptAttributeField->value
let newScriptAttributeField = {
...scriptAttributeField,
value: (scriptAttributeValueToInt(scriptAttributeField.value) + 1) |> intToScriptAttributeValue
};
相關資料
抽象型別(Abstract Types)
過程
組合
介紹
多個函式可以組合起來,使前一個函式的返回值作為後一個函式的輸入,從而對資料進行管道處理。
示例
let func1 = value => value1 + 1;
let func2 = value => value1 + 2;
//13
10 |> func1 |> func2;
在引擎中的應用
把多個函式組合成job,再把多個job組合成一個管道操作,處理每幀的邏輯。
我們從組合的角度來分析下引擎的結構:
job = 多個函式的組合
引擎=初始化+主迴圈
//而初始化和主迴圈的每一幀,都是由多個job組合而成的管道操作:
初始化 = create_canvas |> create_gl |> ...
每一次迴圈 = tick |> dispose |> reallocate_cpu_memory |> update_transform |> ...
相關資料
第 5 章: 程式碼組合(compose)
迭代和遞迴
介紹
遍歷操作可以分成兩類:
迭代
遞迴
例如廣度優先遍歷是迭代操作,而深度優先遍歷是遞迴操作
Reason支援用for、while迴圈實現迭代操作,用“rec”關鍵字定義遞迴函式。
Reason支援尾遞迴優化,可將其編譯成迭代操作。所以我們應該在需要遍歷很多次的地方,用尾遞迴進行遍歷。
示例
//func1為尾遞迴函式
let rec func1 = (value, result) => {
value > 3 ? result : func1(value + 1, result + value);
};
//0+1+2+3=6
func1(1, 0);
在引擎中的應用
幾乎所有的遍歷都是尾遞迴遍歷(因為相對於迭代,程式碼更可讀),只有在少數使用Mutable和少數效能熱點的地方,使用迭代遍歷
相關資料
什麼是尾遞迴?
Reason->Recursive Functions
Reason->Imperative Loops
模式匹配
介紹
使用switch代替if/else來處理程式分支。
示例
let func1 = value => {
switch(value){
| 0 => 10
| _ => 100
}
};
//10
func1(0);
//100
func1(2);
在引擎中的應用
主要用在下面三種場景:
1)取出容器的值
type a =
| A(int)
| B(string);
let aValue = 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)領域建模示例
比如我們要開發一個圖書管理系統,需要對“書”進行建模。
書有書號、頁數這兩個資料,有小說書、技術書兩種型別。
建模為:
type bookId = int;
type pageNum = int;
//book為Discriminated Union Type
//book作為容器,定義了兩個Union Type:Novel、Technology
type book =
| Novel(bookId, pageNum)
| Technology(bookId, pageNum);
現在我們建立一本小說,一本技術書,以及它們的集合list:
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);
在引擎中的應用
包含以下使用場景:
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;
//true
isIntEqual(1, 1);
//true
isStringEqual("aaa", "aaa");
使用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
};
//true
isEqual(Int, 1, 1);
//true
isEqual(String, "aaa", "aaa");
在引擎中的應用
包含以下使用場景:
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 functor
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);
//list == [2]
let list = B.add(1, 2, []);
//list == [2]
let list = list |> B.add(1, 1);
在引擎中的應用
包含以下使用場景:
1)錯誤處理
錯誤資訊被包裝到容器Result中。
由於錯誤的型別不一樣,所以需要不同資料結構的容器(RelationResult、SameDataResult)來包裝。
這兩類容器有共同的模式,可以通過“Module Functor”來消除重複:
提出基module:Result;
增加MakeRelationResult、MakeSameDataResult這兩個module functor,它們將Result作為引數,返回新的module:RelationResult、SameDataResult。
相關資料
Reason->Module Functions
參考資料
用函數語言程式設計,從0開發3D引擎和編輯器(二):函數語言程式設計