從0開發(fā)3D引擎(五):函數(shù)式編程及其在引擎中的應(yīng)用
大家好,本文介紹我們?yōu)槭裁词褂煤瘮?shù)式編程來開發(fā)引擎,以及它在引擎中的相關(guān)的知識點(diǎn)。
上一篇博文
下一篇博文
從0開發(fā)3D引擎(六):函數(shù)式反應(yīng)式編程及其在引擎中的應(yīng)用
函數(shù)式編程的優(yōu)點(diǎn)與缺點(diǎn)
優(yōu)點(diǎn)
(1)粒度小
面向?qū)ο缶幊桃灶悶閱挝唬瘮?shù)式編程以函數(shù)為單位,粒度更小。
我只想要一個(gè)香蕉,而面向?qū)ο髤s給了我整個(gè)森林
(2)擅長處理數(shù)據(jù),適合3D領(lǐng)域的編程
通過高階函數(shù)、柯西化、函數(shù)組合等工具,函數(shù)式編程可以像流水線一樣對數(shù)據(jù)進(jìn)行管道操作,非常方便。
而3D程序正好要處理大量的數(shù)據(jù),從函數(shù)式編程的角度來看:
3D程序=數(shù)據(jù)+邏輯
因此,我們可以這樣使用函數(shù)式編程范式來進(jìn)行3D編程:
- 使用Immutable/Mutable數(shù)據(jù)結(jié)構(gòu)、Data Oriented思想來表達(dá)數(shù)據(jù)
- 使用函數(shù)來表達(dá)邏輯
- 使用組合、柯西化等操作作為工具,把數(shù)據(jù)和邏輯關(guān)聯(lián)起來,進(jìn)行管道操作
現(xiàn)代的3D引擎越來越傾向于面向數(shù)據(jù)進(jìn)行設(shè)計(jì),從而獲得更佳的性能,如Unity新版本有很多Data Oriented的思想;
也越來越傾向于使用函數(shù)式編程范式,如Frostbite使用Frame Graph來封裝現(xiàn)代圖形API(DX12),而Frame Graph是面向數(shù)據(jù)的,有函數(shù)式風(fēng)格的編碼框架。
缺點(diǎn)
(1)存在性能問題
-
Reduce、Map、Filter等操作需要遍歷多次,會增加時(shí)間開銷
我們可以通過下面的方法來優(yōu)化:
a)減少不必要的Map、Reduce等操作;
b)使用transducer來合并這些操作。具體可以參考Understanding transducer in Javascript -
柯西化、組合等操作會增加時(shí)間開銷
-
每次操作Immutable數(shù)據(jù),都需要復(fù)制它為新的數(shù)據(jù),增加了時(shí)間和內(nèi)存開銷
為什么使用Reason語言
本系列使用Reason語言來實(shí)現(xiàn)函數(shù)式編程。
Reason語言可以解決前面提到的性能問題:
-
Bucklescript編譯器在編譯時(shí)進(jìn)行了很多優(yōu)化,使柯西化、組合等操作和Immutable數(shù)據(jù)被編譯成了優(yōu)化過的js代碼,大幅減小了時(shí)間開銷和內(nèi)存開銷
更多編譯器的優(yōu)化以及與Typescript的比較可參考:
架構(gòu)最快最好的To JS編譯器 -
Reason支持Mutable變量、for/while進(jìn)行迭代遍歷、非純函數(shù)
在性能熱點(diǎn)處可以使用它們來提高性能,而在其它地方則盡量使用Immutable數(shù)據(jù)、遞歸遍歷和純函數(shù)來提高代碼的可讀性和健壯性。
另外,Reason屬于“非純函數(shù)式編程語言”,為什么不使用Haskell這種“純函數(shù)式編程語言”呢?
因?yàn)橐韵聨c(diǎn)原因:
(1)獲得更高的性能
在性能熱點(diǎn)處使用非純操作(如使用Mutable變量),提高性能。
(2)更簡單易用
Reason允許非純函數(shù),不需要像Haskell一樣使用各種Monad來隔離副作用,保持“純函數(shù)”;
Reason使用嚴(yán)格求值,相對于Haskell的惰性求值更簡單。
函數(shù)式編程學(xué)習(xí)資料
-
JS 函數(shù)式編程指南
這本書作為我學(xué)習(xí)函數(shù)式編程的第一本書,講得很簡單易懂,非常容易上手,推薦~ -
Awesome FP JS
收集了函數(shù)式編程相關(guān)的資料。 -
F# for fun and profit
這個(gè)博客講了很多F#相關(guān)的函數(shù)式編程的知識點(diǎn),介紹了怎樣基于類型來設(shè)計(jì)、怎樣處理錯(cuò)誤等,非常全面和通俗易懂,強(qiáng)力推薦~
Reason語言基于Ocaml語言,而Ocaml語言與F#語言都屬于ML語言類別的,很多概念和語法都類似,所以讀者在該博客學(xué)到的內(nèi)容,也可以直接應(yīng)用到Reason。
引擎中相關(guān)的函數(shù)式編程知識點(diǎn)
本文從以下幾個(gè)方面進(jìn)行介紹:
數(shù)據(jù)
因?yàn)槲覀儾皇褂萌肿兞浚峭ㄟ^形參傳入函數(shù)需要的變量,所以所有的變量都是函數(shù)的局部變量。
我們把與引擎相關(guān)的需要持久化的數(shù)據(jù),聚合在一起成為一個(gè)Record類型的數(shù)據(jù),命名為“State”。該Record的一些成員是可變的(用來存放性能優(yōu)先的數(shù)據(jù)),另外的成員是不可變的。
關(guān)于Record數(shù)據(jù)結(jié)構(gòu),可以參考Record。
不可變數(shù)據(jù)
介紹
不能直接修改不可變數(shù)據(jù)的值。
創(chuàng)建不可變數(shù)據(jù)之后,對其任何的操作,都會返回一個(gè)復(fù)制后的新數(shù)據(jù)。
示例
變量默認(rèn)為不可變的(Immutable):
//a為immutable變量
let a = 1;
//導(dǎo)致編譯錯(cuò)誤
a = 2;
Reason也有專門的不可變數(shù)據(jù)結(jié)構(gòu),如Tuple、List、Record。
其中,Record類似于Javascript中的Object,我們以它為例,來看下如何使用不可變數(shù)據(jù)結(jié)構(gòu):
首先定義Record的類型:
type person = {
age: int,
name: string
};
然后定義Record的值,它被編譯器推導(dǎo)為person類型:
let me = {
age: 5,
name: "Big Reason"
};
最后操作這個(gè)Record,如修改“age”的值:
let newMe = {
...me,
age: 10
};
Js.log(newMe === me); /* false */
newMe是從me復(fù)制而來的。任何對newMe的修改,都不會影響me。
(這里Reason進(jìn)行了優(yōu)化,只復(fù)制了修改的age字段,沒有復(fù)制name字段 )
在引擎中的應(yīng)用
大部分?jǐn)?shù)據(jù)都是不可變的(是不可變變量,或者是Tuple,Record等數(shù)據(jù)結(jié)構(gòu)),這樣的優(yōu)點(diǎn)是:
1)不用關(guān)心數(shù)據(jù)之間的關(guān)聯(lián)關(guān)系,因?yàn)槊總€(gè)數(shù)據(jù)都是獨(dú)立的
2)不可變數(shù)據(jù)不能被修改
相關(guān)資料
Reason->Let Binding
Reason->Record
facebook immutable.js 意義何在,使用場景?
Introduction to Immutable.js and Functional Programming Concepts
可變數(shù)據(jù)
介紹
對可變數(shù)據(jù)的任何操作,都會直接修改原數(shù)據(jù)。
示例
Reason使用"ref"關(guān)鍵字定義Mutable變量:
let foo = ref(5);
//將foo的值取出來,設(shè)置到five這個(gè)Immutable變量中
let five = foo^;
//修改foo的值為6,five的值仍然為5
foo := 6;
Reason也可以通過"mutable"關(guān)鍵字,定義Record的字段為Mutable字段:
type person = {
name: string,
mutable age: int
};
let baby = {name: "Baby Reason", age: 5};
//修改原數(shù)據(jù)baby->age的值為6
baby.age = baby.age + 1;
在引擎中的應(yīng)用
因?yàn)椴僮骺勺償?shù)據(jù)不需要拷貝,沒有垃圾回收的開銷,所以在性能熱點(diǎn)處常常使用可變數(shù)據(jù)。
相關(guān)資料
Reason->Mutable
函數(shù)
函數(shù)是第一等公民,函數(shù)即是數(shù)據(jù)。
相關(guān)資料:
如何理解在 JavaScript 中 "函數(shù)是第一等公民" 這句話?
Reason->Function
純函數(shù)
介紹
純函數(shù)是這樣一種函數(shù),即相同的輸入,永遠(yuǎn)會得到相同的輸出,而且沒有任何可觀察的副作用。
示例
let a = 1;
/* func2是純函數(shù) */
let func2 = value => value;
/* func1是非純函數(shù),因?yàn)橐昧送獠孔兞?a" */
let func1 = () => a;
在引擎中的應(yīng)用
腳本組件的鉤子函數(shù)(如init,update,dispose等函數(shù),這些函數(shù)會在主循環(huán)的特定時(shí)間點(diǎn)被調(diào)用,從而執(zhí)行函數(shù)中用戶的邏輯)屬于純函數(shù),這樣是為了:
1)在導(dǎo)入/導(dǎo)出為Scene Graph文件時(shí),能夠正確序列化
當(dāng)導(dǎo)出為Scene Graph文件時(shí),序列化鉤子函數(shù)為字符串,保存在文件中;
當(dāng)導(dǎo)入Scene Graph文件時(shí),反序列化字符串為函數(shù)。如果鉤子函數(shù)不是純函數(shù)(如調(diào)用了外部變量),則在此時(shí)會報(bào)錯(cuò)(因?yàn)橥獠孔兞坎]有定義在字符串中,所以會找不到該變量)。
2)支持多線程
可以通過序列化的方式將鉤子函數(shù)傳到獨(dú)立于主線程的腳本線程,從而在該線程中被執(zhí)行,實(shí)現(xiàn)多線程執(zhí)行腳本,提高性能。
雖然純函數(shù)優(yōu)點(diǎn)很多,但引擎中大多數(shù)的函數(shù)都是非純函數(shù),這是因?yàn)椋?br>
1)為了提高性能
2)為了簡單,允許副作用,從而避免使用Monad
相關(guān)資料
第 3 章:純函數(shù)的好處
高階函數(shù)
介紹
高階函數(shù)的輸入或者輸出為函數(shù)。
示例
//func1是高階函數(shù),因?yàn)樗膮?shù)是函數(shù)
let func1 = func => func(1);
let func2 = value => value * 2;
//a=2
let a = func1(func2);
在引擎中的應(yīng)用
函數(shù)之間常常有一些重復(fù)或者類似的邏輯,可以通過提出一個(gè)私有的高階函數(shù)來消除重復(fù)。具體示例如下:
重構(gòu)前:
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有重復(fù)邏輯
重構(gòu)后:
...
let _compute = (value, (addFunc, minusFunc)) =>
value |> addFunc |> minusFunc;
let compute1 = value => _compute(value, (add1, minus1));
let compute2 = value => _compute(value, (add2, minus2));
相關(guān)資料
理解 JavaScript 中的高階函數(shù)
柯西化
介紹
只傳遞給函數(shù)一部分參數(shù)來調(diào)用它,讓它返回一個(gè)函數(shù)去處理剩下的參數(shù)。
你可以一次性地調(diào)用curry 函數(shù),也可以每次只傳一個(gè)參數(shù)分多次調(diào)用。
示例
let func1 = (value1, value2) => value1 + value2;
//傳入第一個(gè)參數(shù),func2只有一個(gè)參數(shù)value2
let func2 = func1(1);
//a=3
let a = func2(2);
在引擎中的應(yīng)用
應(yīng)用的地方太多了,此處省略。
相關(guān)資料
第 4 章: 柯里化(curry)
Currying
公有/私有函數(shù)
介紹
模塊Module中的函數(shù),有些是暴露給外部訪問的,我們稱其為“公有函數(shù)”;另外的函數(shù)是內(nèi)部私有的,我們稱其為“私有函數(shù)”。
可以通過創(chuàng)建Module對應(yīng)的.rei文件,來定義要暴露的公有函數(shù)。
我們沒有使用這種方法,而是通過約定函數(shù)的名稱:
以下劃線“_”開頭的函數(shù)是私有函數(shù),其它函數(shù)是公有函數(shù)。
示例
module Test = {
//私有函數(shù)
let _func1 = v => v;
//公有函數(shù)
let func2 = v => v * 2;
};
在引擎中的應(yīng)用
引擎中的函數(shù)都是用這種命名約定,來區(qū)分公有函數(shù)和私有函數(shù)。
相關(guān)資料
Module -> “Every .rei file is a signature”
類型
Reason是強(qiáng)類型語言,編譯時(shí)會檢查類型是否正確。
本系列希望通過盡可能強(qiáng)的類型約束,來達(dá)到“編譯通過即程序正確,減少大量的測試工作”的目的。
關(guān)于Reason類型帶來的好處,參考架構(gòu)最快最好的To JS編譯器:
更好的類型安全: typescript是一個(gè)JS的超集,它存在很多歷史包袱。而微軟引入typescript更多的是作為一個(gè)工具來使用的比如IDE的代碼補(bǔ)全,相對安全的代碼重構(gòu)。而這個(gè)類型的準(zhǔn)確從第一天開始就不是它的設(shè)計(jì)初衷,以至于Facebook自己設(shè)計(jì)了一個(gè)相對更準(zhǔn)確地類型系統(tǒng)Flow. 而OCaml的類型系統(tǒng)是已經(jīng)被形式化的證明過正確的。也就是說從理論上BuckleScript 能夠保證一旦編譯通過是不會有運(yùn)行時(shí)候類型錯(cuò)誤的,而typescript遠(yuǎn)遠(yuǎn)做不到這點(diǎn)。
更多的類型推斷,更好的語言特性:用過typescript的人都知道,typescript的類型推斷很弱,基本上所有參數(shù)都需要顯示的標(biāo)注類型。不光是這點(diǎn),像對函數(shù)式編程的支持,高階類型系統(tǒng)GADT的支持幾乎是沒有。而OCaml本身是一個(gè)比Elm,PureScript還要強(qiáng)大的多的語言,它自身有一個(gè)非常高階的module system,是為數(shù)不多的對dependent type提供支持的語言,polymorphic variant。而且pattern match的編譯器也是優(yōu)化過的。
相關(guān)資料
The "Understanding F# types" series
基本類型
介紹
Reason包含int、float、string等基本類型。
示例
//定義a為string類型
type a = string;
//定義str變量的類型為a
let str:a = "zzz";
在引擎中的應(yīng)用
應(yīng)用廣泛,包括以下的使用場景:
1)類型驅(qū)動設(shè)計(jì)
2)領(lǐng)域建模
3)枚舉
相關(guān)資料
Reason->Type
Algebraic type sizes and domain modelling
Discriminated Union類型
介紹
Discriminated Union類型可以接受參數(shù),還可以組合其它的類型。
示例
//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!")
];
在引擎中的應(yīng)用
作為本文后面講到的“容器”的實(shí)現(xiàn),用于領(lǐng)域建模
相關(guān)資料
Reason->Type Argument
Reason->Null, Undefined & Option
Discriminated Unions
抽象類型
介紹
抽象類型只給出類型名字,沒有具體的定義。
示例
//value為抽象類型
type value;
在引擎中的應(yīng)用
包括以下的使用場景:
1)如果不需要類型的具體定義,則將該類型定義為抽象類型
如在封裝WebGL API的FFI中(什么是FFI?),因?yàn)椴恍枰馈癢ebGL的上下文”包含哪些方法和屬性,所以將其定義為抽象類型。
示例代碼如下:
//抽象類型
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)如果一個(gè)數(shù)據(jù)可能為多個(gè)類型,則定義一個(gè)抽象類型和它與這“多個(gè)類型”之間相互轉(zhuǎn)換的FFI,然后把該數(shù)據(jù)設(shè)為該抽象類型
如腳本->屬性->value字段可以為int或者float類型,因此將value設(shè)為抽象類型,并且定義抽象類型和int、float類型之間的轉(zhuǎn)換FFI。
示例代碼如下:
type scriptAttributeType =
| Int
| Float;
//抽象類型
type scriptAttributeValue;
type scriptAttributeField = {
type_: scriptAttributeType,
//定義value字段為該抽象類型
value: scriptAttributeValue
};
//定義抽象類型scriptAttributeValue和int,float類型相互轉(zhuǎn)換的FFI
external intToScriptAttributeValue: int => scriptAttributeValue = "%identity";
external floatToScriptAttributeValue: float => scriptAttributeValue =
"%identity";
external scriptAttributeValueToInt: scriptAttributeValue => int = "%identity";
external scriptAttributeValueToFloat: scriptAttributeValue => float =
"%identity";
//client code
//創(chuàng)建scriptAttributeField,設(shè)置value的數(shù)據(jù)
let scriptAttributeField = {
type_: Int,
value:intToScriptAttributeValue(10)
};
//修改scriptAttributeField->value
let newScriptAttributeField = {
...scriptAttributeField,
value: (scriptAttributeValueToInt(scriptAttributeField.value) + 1) |> intToScriptAttributeValue
};
相關(guān)資料
抽象類型(Abstract Types)
過程
組合
介紹
多個(gè)函數(shù)可以組合起來,使前一個(gè)函數(shù)的返回值作為后一個(gè)函數(shù)的輸入,從而對數(shù)據(jù)進(jìn)行管道處理。
示例
let func1 = value => value1 + 1;
let func2 = value => value1 + 2;
//13
10 |> func1 |> func2;
在引擎中的應(yīng)用
把多個(gè)函數(shù)組合成job,再把多個(gè)job組合成一個(gè)管道操作,處理每幀的邏輯。
我們從組合的角度來分析下引擎的結(jié)構(gòu):
job = 多個(gè)函數(shù)的組合
引擎=初始化+主循環(huán)
//而初始化和主循環(huán)的每一幀,都是由多個(gè)job組合而成的管道操作:
初始化 = create_canvas |> create_gl |> ...
每一次循環(huán) = tick |> dispose |> reallocate_cpu_memory |> update_transform |> ...
相關(guān)資料
迭代和遞歸
介紹
遍歷操作可以分成兩類:
迭代
遞歸
例如廣度優(yōu)先遍歷是迭代操作,而深度優(yōu)先遍歷是遞歸操作
Reason支持用for、while循環(huán)實(shí)現(xiàn)迭代操作,用“rec”關(guān)鍵字定義遞歸函數(shù)。
Reason支持尾遞歸優(yōu)化,可將其編譯成迭代操作。所以我們應(yīng)該在需要遍歷很多次的地方,用尾遞歸進(jìn)行遍歷。
示例
//func1為尾遞歸函數(shù)
let rec func1 = (value, result) => {
value > 3 ? result : func1(value + 1, result + value);
};
//0+1+2+3=6
func1(1, 0);
在引擎中的應(yīng)用
幾乎所有的遍歷都是尾遞歸遍歷(因?yàn)橄鄬τ诘a更可讀),只有在少數(shù)使用Mutable和少數(shù)性能熱點(diǎn)的地方,使用迭代遍歷
相關(guān)資料
什么是尾遞歸?
Reason->Recursive Functions
Reason->Imperative Loops
模式匹配
介紹
使用switch代替if/else來處理程序分支。
示例
let func1 = value => {
switch(value){
| 0 => 10
| _ => 100
}
};
//10
func1(0);
//100
func1(2);
在引擎中的應(yīng)用
主要用在下面三種場景:
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 => ...
}
相關(guān)資料
Reason->Pattern Matching!
模式匹配
容器
介紹
為了領(lǐng)域建模,或者為了隔離副作用來保證純函數(shù),需要把值封裝到容器中,使外界只能操作容器,不能直接操作值。
示例
1)領(lǐng)域建模示例
比如我們要開發(fā)一個(gè)圖書管理系統(tǒng),需要對“書”進(jìn)行建模。
書有書號、頁數(shù)這兩個(gè)數(shù)據(jù),有小說書、技術(shù)書兩種類型。
建模為:
type bookId = int;
type pageNum = int;
//book為Discriminated Union Type
//book作為容器,定義了兩個(gè)Union Type:Novel、Technology
type book =
| Novel(bookId, pageNum)
| Technology(bookId, pageNum);
現(xiàn)在我們創(chuàng)建一本小說,一本技術(shù)書,以及它們的集合list:
let novel = Novel(0, 100);
let technology = Technology(1, 200);
let bookList = [
novel,
technology
];
對“書”這個(gè)容器進(jìn)行操作:
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
//得到新的技術(shù)書,它的頁數(shù)為集合中所有書的總頁數(shù)
let newTechnology =
bookList
|> List.fold_left((totalPage, book) => totalPage + getPage(book), 0)
|> setPage(_, technology);
在引擎中的應(yīng)用
包含以下使用場景:
1)領(lǐng)域建模
2)錯(cuò)誤處理
3)處理空值
使用Option這個(gè)容器包裝空值。
相關(guān)資料
Railway Oriented Programming
The "Map and Bind and Apply, Oh my!" series
強(qiáng)大的容器
Monad
Applicative Functor
多態(tài)
GADT
介紹
全稱為Generalized algebraic data type,可以用來實(shí)現(xiàn)函數(shù)參數(shù)多態(tài)。
示例
重構(gòu)前,需要定義多個(gè)isXXXEqual函數(shù)來處理每種類型:
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重構(gòu)后,只需要一個(gè)isEqual函數(shù)來處理所有的類型:
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");
在引擎中的應(yīng)用
包含以下使用場景:
1)契約檢查
使用GADT定義一個(gè)assertEqual方法來判斷兩個(gè)任意類型的變量是否相等,從而不需要assertStringEqual,assertIntEqual等方法。
相關(guān)資料
Why GADTs matter for performance(需要FQ)
維基百科->Generalized algebraic data type
Module Functor
介紹
module作為參數(shù),傳遞給functor,得到一個(gè)新的module。
它類似于面向?qū)ο蟮摹袄^承”,可以通過函子functor,在基module上擴(kuò)展出新的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函數(shù),該方法調(diào)用了A.equal函數(shù)
module B = MakeAdd(A);
//list == [2]
let list = B.add(1, 2, []);
//list == [2]
let list = list |> B.add(1, 1);
在引擎中的應(yīng)用
引擎中有些module有相同的模式,可把它們放到提出的基module中,然后通過functor復(fù)用基module。
相關(guān)資料
Reason->Module Functions
類型搭橋
背景
想象有兩個(gè)世界:“普通世界”和“提升世界”,“提升世界”與“普通世界”非常像,“普通世界”的所有類型在“提升世界”中都有對應(yīng)的類型。
例如,“普通世界”有int和string類型,對應(yīng)于“提升世界”就是e(int)和e(string)類型:

(圖來自Understanding map and apply)
(TODO 把圖中的“<>”改為“()”,“Int”改為“int”,“E”改為“e”)
同樣的,“普通世界”有類型簽名為“int=>string”的函數(shù),對應(yīng)于“提升世界”就是類型簽名為“e(int)=>e(string)”的函數(shù):

(圖來自Understanding map and apply)
什么是“提升世界”?
一個(gè)Discriminated Union類型對應(yīng)一個(gè)“提升世界”。
如Discriminated Union類型result:
type result('a, 'b) =
| Ok('a)
| Error('b);
它對應(yīng)一個(gè)“提升世界”:“Result世界”。
“普通世界”的類型int對應(yīng)于“Result世界”的類型為result(int, 'b)。
又如option對應(yīng)一個(gè)“提升世界”:“Option世界”。
“普通世界”的類型Int對應(yīng)于“Option世界”的類型為option(int)。
又如list對應(yīng)一個(gè)“提升世界”:“List世界”。
“普通世界”的類型int對應(yīng)于“List世界”的類型為list(int)。
什么是類型搭橋
兩個(gè)函數(shù)組合時(shí),第一個(gè)函數(shù)的返回會作為第二個(gè)函數(shù)的輸入。如果它們的類型處于不同的世界(如一個(gè)是option(t)類型,另一個(gè)是t類型),那么需要升降類型到同一個(gè)世界,這樣才能組合。對于這個(gè)類型升降的過程,我稱之為“類型搭橋”。
相關(guān)資料
The "Map and Bind and Apply, Oh my!" series
有下面幾種操作來升降類型:
return
常用名:return, pure, unit, yield, point
它做了什么:提升類型到“提升世界”
類型簽名: a => e(a)
介紹
“return”把類型從“普通世界”提升到“提升世界”:

(圖來自Understanding map and apply)
示例
//option增加return函數(shù)
module Option = {
...
let return = x => Some(x);
};
//client code
let func = (opt) => {
switch(opt){
| Some(x) => x * 2;
| None => 0
}
};
//a=2
let a = 1 |> Option.return |> func;
在引擎中的應(yīng)用
處理錯(cuò)誤的Result模塊實(shí)現(xiàn)了succeed和fail函數(shù),它們屬于“return”操作:
Result.re
type t('a, 'b) =
| Success('a)
| Fail('b);
let succeed = x => Success(x);
let fail = x => Fail(x);
如果一個(gè)函數(shù)沒有發(fā)生錯(cuò)誤,則調(diào)用Result.succeed,把返回值包裝在Success中;否則調(diào)用Result.fail,把錯(cuò)誤信息包裝在Fail中。
相關(guān)資料
Understanding map and apply
map
常用名:map, fmap, lift, Select
它做了什么:提升函數(shù)到“提升世界”
類型簽名: (a=>b) => e(a) => e(b)
介紹
“map”把函數(shù)從“普通世界”提升到“提升世界”:

(圖來自Understanding map and apply)
示例
//option增加map函數(shù)
module Option = {
...
let map = (func, option) => {
switch(option){
| Some(x) => Some(x |> func)
| None => None
}
};
};
//client code
//定義在“普通世界”的函數(shù)
//類型簽名 : int => int
let add1 = x => x + 1;
//使用map,提升函數(shù)到“Option世界”
let add1IfSomething = Option.map(add1)
//a=Some(3)
let a = Some(2) |> add1IfSomething;
在引擎中的應(yīng)用
處理錯(cuò)誤的Result模塊實(shí)現(xiàn)了map函數(shù):
Result.re
type t('a, 'b) =
| Success('a)
| Fail('b);
let either = (successFunc, failureFunc, twoTrackInput) =>
switch (twoTrackInput) {
| Success(s) => successFunc(s)
| Fail(f) => failureFunc(f)
};
let map = (oneTrackFunc, twoTrackInput) =>
either(result => result |> oneTrackFunc |> succeed, fail, twoTrackInput);
引擎用到Result.map的地方很多,例如可以使用Result.map來處理Success中包含的值,將其轉(zhuǎn)換為另一個(gè)值。
示例代碼如下:
let func1 = x => x |> Result.succeed;
let func2 = result => result |> Result.map(x => x * 2);
//a=Success(2)
let a = 1 |> func1 |> func2;
相關(guān)資料
Understanding map and apply
bind
常用名:bind, flatMap, andThen, collect, SelectMany
它做了什么:使穿越世界(manadic)的函數(shù)可以被組合
類型簽名: (a=>e(b)) => e(a) => e(b)
介紹
我們經(jīng)常要處理在“普通世界”和“提升世界”穿越的函數(shù)。
例如:一個(gè)把字符串解析成int的函數(shù),可能會返回option(int)而不是int類型,因此它穿越了“普通世界”和“Option世界”;一個(gè)讀取文件的函數(shù)可能會返回ienumerable(string)類型;一個(gè)接收網(wǎng)頁的函數(shù)可能返回promise(string)類型。
這種穿越世界的函數(shù),它們的類型簽名可以被識別為:a => e(b)。
它們的輸入是“普通世界”的類型,而輸出則是“提升世界”的類型。
不幸的是,這意味著不能直接組合這些函數(shù):

“bind”把穿越世界的函數(shù)(通常稱為“monadic”函數(shù))轉(zhuǎn)換為“提升世界”的函數(shù):e(a) => e(b):

這么做的好處是,轉(zhuǎn)換后的函數(shù)完全在“提升世界”,因此可以被直接組合。
例如,一個(gè)類型簽名為“a => e(b)”的函數(shù)不能直接與類型簽名為“b => e(c)”的函數(shù)組合。但當(dāng)bind后者以后,后者的類型簽名變?yōu)椤癳(b) => e(c)”,這樣就可以與前者進(jìn)行組合了:

(圖來自Understanding bind)
通過bind,能讓多個(gè)mondadic函數(shù)組合在一起。
示例
//option增加bind方法
module Option = {
...
let bind = (func, option) => {
switch(option){
| Some(x) => x |> func
| None => None
}
};
};
//client code
//類型簽名:string => option(int)
let parseStr = str => {
switch(str){
| "-1" => Some(-1)
| "0" => Some(0)
| "1" => Some(1)
| "2" => Some(2)
// etc
| _ => None
};
}
type orderQty =
| OrderQty(int);
//類型簽名:int => option(orderQty)
let toOrderQty = (qty) =>
if (qty >= 1) {
Some(OrderQty(qty));
} else {
None;
};
//使用bind轉(zhuǎn)換toOrderQty函數(shù),使它能與parseInt函數(shù)組合
//類型簽名:string => option(orderQty)
let parseOrderQty = str => str |> parseStr |> Option.bind(toOrderQty);
在引擎中的應(yīng)用
處理錯(cuò)誤的Result模塊實(shí)現(xiàn)了bind函數(shù):
Result.re
type t('a, 'b) =
| Success('a)
| Fail('b);
let either = (successFunc, failureFunc, twoTrackInput) =>
switch (twoTrackInput) {
| Success(s) => successFunc(s)
| Fail(f) => failureFunc(f)
};
let bind = (switchFunc, twoTrackInput) =>
either(switchFunc, fail, twoTrackInput);
引擎用到Result.bind的地方很多,例如:
在主循環(huán)中,獲得數(shù)據(jù)state的函數(shù)unsafeGetState的類型簽名為:unit => Result.t(state, Js.Exn.t),處理主循環(huán)邏輯的loopBody函數(shù)的類型簽名為:state => Result.t(state, Js.Exn.t)。
需要對loopBody函數(shù)進(jìn)行Result.bind,從而使它們能夠組合。
相關(guān)代碼為:
let rec _loop = () =>
DomExtend.requestAnimationFrame((time: float) => {
StateData.unsafeGetState()
|> Result.bind(Director.loopBody)
...
});
相關(guān)資料
Understanding bind
traverse
常用名:mapM, traverse, for
它做了什么:轉(zhuǎn)換一個(gè)穿越世界的函數(shù)為另一個(gè)穿越世界的函數(shù),轉(zhuǎn)換后的函數(shù)用來在遍歷集合時(shí)處理每個(gè)元素
類型簽名:(a=>e(b)) => list(a) => e(list(b))
介紹
我們來看看一個(gè)經(jīng)常會遇到的場景:處理一個(gè)集合(如list),該集合元素為“提升世界”的類型。
舉例來說:
我們有一個(gè)類型簽名為“string => option(int)”的函數(shù)parseStr和一個(gè)集合:list(string),我們想要解析該集合所有的字符串。我們可以使用“map”:list |> List.map(parseStr),將list(string)轉(zhuǎn)換為list(option(int))。
但我們真正想要的不是“l(fā)ist(option(int))”,而是“option(list(int))”,即用option來包裝解析后的列表list。
這可以通過traverse來做到,我們把list對應(yīng)的“提升世界”稱為“Traversable世界”(因?yàn)榭梢允褂胻raverse來遍歷它),把option對應(yīng)的“提升世界”稱為“Applicative世界”。使用“traverse”操作遍歷list,得到option(list('b))類型,如下圖所示:

示例
介紹中的示例的相關(guān)代碼如下:
module List = {
...
//注:可以將該函數(shù)作為模版。如果Applicative世界變?yōu)槠渌澜纾鏡esult世界,只需要把returnFunc替換為Result.succeed,Js.Option.andThen替換為Result.bind
let traverseOptionM = (traverseFunc, list) => {
let returnFunc = Js.Option.some;
list
|> List.fold_left(
(resultArr, value) =>
//andThen相當(dāng)于bind
Js.Option.andThen(
(. h) =>
Js.Option.andThen((. t) => returnFunc(t @ [h]), resultArr),
traverseFunc(value),
),
returnFunc([]),
);
};
};
//client code
//類型簽名:string => option(int)
let parseStr = str => {
switch (str) {
| "" => None
| str => Some(str |> Js.String.length)
};
};
//a=None
let a = ["", "aaa", "bbbb"] |> List.traverseOptionM(parseStr);
//b=Some([3,4])
let b = ["aaa", "bbbb"] |> List.traverseOptionM(parseStr);
關(guān)于traverse具體實(shí)現(xiàn)的分析,詳見Understanding traverse and sequence
在引擎中的應(yīng)用
引擎的List、Array實(shí)現(xiàn)了traverseResultM函數(shù),用來遍歷處理集合中Result類型(存在錯(cuò)誤的函數(shù)會返回Result類型)的元素。
相關(guān)資料
Understanding traverse and sequence
sequence
常用名:sequence
它做了什么:交換Traversable世界和Applicative世界的位置
類型簽名:list(e(a)) => e(list(a))
介紹
sequence交換Traversable世界和Applicative世界的位置:
Traversable世界下降
Applicative世界上升
如下圖所示:

示例
如果已經(jīng)實(shí)現(xiàn)了traverseResultM,那么可以從中很簡單地導(dǎo)出sequence。
示例代碼為:
module List = {
...
let id = x => x;
let sequenceResultM = x => traverseResultM(id, x);
};
//client code
//與traverse示例中的parseStr函數(shù)一樣
let parseStr = ...
let a =
["1", "22", "333"]
//得到[Some(1), Some(2), Some(3)]
|> List.map(parseStr)
//得到Some([1,2,3])
|> List.sequenceResultM;
let b =
["", "22", "333"]
//得到[None, Some(2), Some(3)]
|> List.map(parseStr)
//得到None
|> List.sequenceResultM;
在引擎中的應(yīng)用
引擎對元組Tuple實(shí)現(xiàn)了sequenceResultM函數(shù),用來交換Tuple和Result的位置。
相關(guān)資料
Understanding traverse and sequence
錯(cuò)誤處理
使用Result
介紹
我們使用Result來處理錯(cuò)誤,它相對于“拋出異常”的錯(cuò)誤處理方式,有下面的優(yōu)點(diǎn):
1、分離“發(fā)生錯(cuò)誤”和“處理錯(cuò)誤”的邏輯,延遲到后面來統(tǒng)一處理錯(cuò)誤
2、對流程更加可控,如可以在第一個(gè)錯(cuò)誤發(fā)生后繼續(xù)執(zhí)行后面的程序
3、顯示地標(biāo)示出錯(cuò)誤
通過函數(shù)返回Result類型,在類型的編譯檢查時(shí)可確保使用錯(cuò)誤得到了處理
示例
首先增加Result.re這個(gè)模塊,在其中定義一個(gè)Discriminated Union類型,使其包含Success和Fail的數(shù)據(jù)(設(shè)置為泛型參數(shù)'a和'b,使其可為任意類型,增加通用型):
type t('a, 'b) =
| Success('a)
| Fail('b);
接著定義return(此處為succeed和fail函數(shù))、map、bind等函數(shù):
let succeed = x => Success(x);
let fail = x => Fail(x);
let either = (successFunc, failureFunc, twoTrackInput) =>
switch (twoTrackInput) {
| Success(s) => successFunc(s)
| Fail(f) => failureFunc(f)
};
let bind = (switchFunc, twoTrackInput) =>
either(switchFunc, fail, twoTrackInput);
let map = (oneTrackFunc, twoTrackInput) =>
either(result => result |> oneTrackFunc |> succeed, fail, twoTrackInput);
最后定義處理Result的函數(shù):
let getSuccessValue = (handleFailFunc: 'f => unit, result: t('s, 'f)): 's =>
switch (result) {
| Success(s) => s
| Fail(f) => handleFailFunc(f)
};
我們來看下如何使用Result處理錯(cuò)誤:
//func使用Result包裝錯(cuò)誤
let func = (x) => {
x > 0 ? Result.succeed(x) : Result.fail(Js.Exn.raiseError("x should > 0"));
};
//處理錯(cuò)誤的函數(shù):拋出異常
let throwError: Js.Exn.t => unit = [%raw err => {|
throw err;
|}];
//拋出異常
let a = func(-1) |> Result.getSuccessValue(throwError);
//正常執(zhí)行,b=1
let b = func(1) |> Result.getSuccessValue(throwError);
在引擎中的應(yīng)用
引擎使用Result來處理錯(cuò)誤。
相關(guān)資料
Railway oriented programming
浙公網(wǎng)安備 33010602011771號