大家好,本文介绍我们为什么使用函数式编程来开发引擎,以及它在引擎中的相关的知识点。

上一篇博文

从0开发3D引擎(四):搭建测试环境

下一篇博文

从0开发3D引擎(六):函数式反应式编程及其在引擎中的应用

函数式编程的优点与缺点

优点

(1)粒度小

面向对象编程以类为单位,而函数式编程以函数为单位,粒度更小。

我只想要一个香蕉,而面向对象却给了我整个森林

(2)擅长处理数据,适合3D领域的编程

通过高阶函数、柯西化、函数组合等工具,函数式编程可以像流水线一样对数据进行管道操作,非常方便。

而3D程序正好要处理大量的数据,从函数式编程的角度来看:

3D程序=数据+逻辑

因此,我们可以这样使用函数式编程范式来进行3D编程:

  • 使用Immutable/Mutable数据结构、Data Oriented思想来表达数据
  • 使用函数来表达逻辑
  • 使用组合、柯西化等操作作为工具,把数据和逻辑关联起来,进行管道操作

现代的3D引擎越来越倾向于面向数据进行设计,从而获得更佳的性能,如Unity新版本有很多Data Oriented的思想;

也越来越倾向于使用函数式编程范式,如Frostbite使用Frame Graph来封装现代图形API(DX12),而Frame Graph是面向数据的,有函数式风格的编码框架。

缺点

(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):

  1. //a为immutable变量
  2. let a = 1;
  3. //导致编译错误
  4. a = 2;

Reason也有专门的不可变数据结构,如Tuple、List、Record。

其中,Record类似于Javascript中的Object,我们以它为例,来看下如何使用不可变数据结构:

首先定义Record的类型:

  1. type person = {
  2. age: int,
  3. name: string
  4. };

然后定义Record的值,它被编译器推导为person类型:

  1. let me = {
  2. age: 5,
  3. name: "Big Reason"
  4. };

最后操作这个Record,如修改“age”的值:

  1. let newMe = {
  2. ...me,
  3. age: 10
  4. };
  5. 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变量:

  1. let foo = ref(5);
  2. //将foo的值取出来,设置到five这个Immutable变量中
  3. let five = foo^;
  4. //修改foo的值为6,five的值仍然为5
  5. foo := 6;

Reason也可以通过"mutable"关键字,定义Record的字段为Mutable字段:

  1. type person = {
  2. name: string,
  3. mutable age: int
  4. };
  5. let baby = {name: "Baby Reason", age: 5};
  6. //修改原数据baby->age的值为6
  7. baby.age = baby.age + 1;

在引擎中的应用

因为操作可变数据不需要拷贝,没有垃圾回收的开销,所以在性能热点处常常使用可变数据。

相关资料

Reason->Mutable

函数

函数是第一等公民,函数即是数据。

相关资料:

如何理解在 JavaScript 中 "函数是第一等公民" 这句话?

Reason->Function

纯函数

介绍

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

示例

  1. let a = 1;
  2. /* func2是纯函数 */
  3. let func2 = value => value;
  4. /* func1是非纯函数,因为引用了外部变量"a" */
  5. let func1 = () => a;

在引擎中的应用

脚本组件的钩子函数(如init,update,dispose等函数,这些函数会在主循环的特定时间点被调用,从而执行函数中用户的逻辑)属于纯函数,这样是为了:

1)在导入/导出为Scene Graph文件时,能够正确序列化

当导出为Scene Graph文件时,序列化钩子函数为字符串,保存在文件中;

当导入Scene Graph文件时,反序列化字符串为函数。如果钩子函数不是纯函数(如调用了外部变量),则在此时会报错(因为外部变量并没有定义在字符串中,所以会找不到该变量)。

2)支持多线程

可以通过序列化的方式将钩子函数传到独立于主线程的脚本线程,从而在该线程中被执行,实现多线程执行脚本,提高性能。

虽然纯函数优点很多,但引擎中大多数的函数都是非纯函数,这是因为:

1)为了提高性能

2)为了简单,允许副作用,从而避免使用Monad

相关资料

第 3 章:纯函数的好处

高阶函数

介绍

高阶函数的输入或者输出为函数。

示例

  1. //func1是高阶函数,因为它的参数是函数
  2. let func1 = func => func(1);
  3. let func2 = value => value * 2;
  4. //a=2
  5. let a = func1(func2);

在引擎中的应用

函数之间常常有一些重复或者类似的逻辑,可以通过提出一个私有的高阶函数来消除重复。具体示例如下:

重构前:

  1. let add1 = value => value + 2;
  2. let add2 = value => value + 10;
  3. let minus1 = value => value - 10;
  4. let minus2 = value => value - 200;
  5. let compute1 = value => value |> add1 |> minus1;
  6. let compute2 = value => value |> add2 |> minus2;
  7. //compute1,compute2有重复逻辑

重构后:

  1. ...
  2. let _compute = (value, (addFunc, minusFunc)) =>
  3. value |> addFunc |> minusFunc;
  4. let compute1 = value => _compute(value, (add1, minus1));
  5. let compute2 = value => _compute(value, (add2, minus2));

相关资料

理解 JavaScript 中的高阶函数

柯西化

介绍

只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

你可以一次性地调用curry 函数,也可以每次只传一个参数分多次调用。

示例

  1. let func1 = (value1, value2) => value1 + value2;
  2. //传入第一个参数,func2只有一个参数value2
  3. let func2 = func1(1);
  4. //a=3
  5. let a = func2(2);

在引擎中的应用

应用的地方太多了,此处省略。

相关资料

第 4 章: 柯里化(curry)

Currying

公有/私有函数

介绍

模块Module中的函数,有些是暴露给外部访问的,我们称其为“公有函数”;另外的函数是内部私有的,我们称其为“私有函数”。

可以通过创建Module对应的.rei文件,来定义要暴露的公有函数。

我们没有使用这种方法,而是通过约定函数的名称:

以下划线“_”开头的函数是私有函数,其它函数是公有函数。

示例

  1. module Test = {
  2. //私有函数
  3. let _func1 = v => v;
  4. //公有函数
  5. let func2 = v => v * 2;
  6. };

在引擎中的应用

引擎中的函数都是用这种命名约定,来区分公有函数和私有函数。

相关资料

Module -> “Every .rei file is a signature”

类型

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等基本类型。

示例

  1. //定义a为string类型
  2. type a = string;
  3. //定义str变量的类型为a
  4. let str:a = "zzz";

在引擎中的应用

应用广泛,包括以下的使用场景:

1)类型驱动设计

2)领域建模

3)枚举

相关资料

Reason->Type

Algebraic type sizes and domain modelling

Discriminated Union类型

介绍

Discriminated Union类型可以接受参数,还可以组合其它的类型。

示例

  1. //result为Discriminated Union Type
  2. type result('a, 'b) =
  3. | Ok('a)
  4. | Error('b);
  5. type myPayload = {data: string};
  6. let payloadResults: list(result(myPayload, string)) = [
  7. Ok({data: "hi"}),
  8. Ok({data: "bye"}),
  9. Error("Something wrong happened!")
  10. ];

在引擎中的应用

作为本文后面讲到的“容器”的实现,用于领域建模

相关资料

Reason->Type Argument

Reason->Null, Undefined & Option

Discriminated Unions

抽象类型

介绍

抽象类型只给出类型名字,没有具体的定义。

示例

  1. //value为抽象类型
  2. type value;

在引擎中的应用

包括以下的使用场景:

1)如果不需要类型的具体定义,则将该类型定义为抽象类型

如在封装WebGL API的FFI中(什么是FFI?),因为不需要知道“WebGL的上下文”包含哪些方法和属性,所以将其定义为抽象类型。

示例代码如下:

  1. //抽象类型
  2. type webgl1Context;
  3. [@bs.send]
  4. external getWebgl1Context : ('canvas, [@bs.as "webgl"] _) => webgl1Context = "getContext";
  5. [@bs.send.pipe: webgl1Context]
  6. external viewport : (int, int, int, int) => unit = "";
  7. //client code
  8. //gl是webgl1Context类型
  9. //编译后的js代码为:var gl = canvasDom.getContext("webgl");
  10. let gl = getWebgl1Context(canvasDom);
  11. //编译后的js代码为:gl.viewport(0,0,100,100);
  12. gl |> viewport(0,0,100,100);

2)如果一个数据可能为多个类型,则定义一个抽象类型和它与这“多个类型”之间相互转换的FFI,然后把该数据设为该抽象类型

如脚本->属性->value字段可以为int或者float类型,因此将value设为抽象类型,并且定义抽象类型和int、float类型之间的转换FFI。

示例代码如下:


  1. type scriptAttributeType =
  2. | Int
  3. | Float;
  4. //抽象类型
  5. type scriptAttributeValue;
  6. type scriptAttributeField = {
  7. type_: scriptAttributeType,
  8. //定义value字段为该抽象类型
  9. value: scriptAttributeValue
  10. };
  11. //定义抽象类型scriptAttributeValue和int,float类型相互转换的FFI
  12. external intToScriptAttributeValue: int => scriptAttributeValue = "%identity";
  13. external floatToScriptAttributeValue: float => scriptAttributeValue =
  14. "%identity";
  15. external scriptAttributeValueToInt: scriptAttributeValue => int = "%identity";
  16. external scriptAttributeValueToFloat: scriptAttributeValue => float =
  17. "%identity";
  18. //client code
  19. //创建scriptAttributeField,设置value的数据
  20. let scriptAttributeField = {
  21. type_: Int,
  22. value:intToScriptAttributeValue(10)
  23. };
  24. //修改scriptAttributeField->value
  25. let newScriptAttributeField = {
  26. ...scriptAttributeField,
  27. value: (scriptAttributeValueToInt(scriptAttributeField.value) + 1) |> intToScriptAttributeValue
  28. };

相关资料

抽象类型(Abstract Types)

过程

组合

介绍

多个函数可以组合起来,使前一个函数的返回值作为后一个函数的输入,从而对数据进行管道处理。

示例

  1. let func1 = value => value1 + 1;
  2. let func2 = value => value1 + 2;
  3. //13
  4. 10 |> func1 |> func2;

在引擎中的应用

把多个函数组合成job,再把多个job组合成一个管道操作,处理每帧的逻辑。

我们从组合的角度来分析下引擎的结构:

  1. job = 多个函数的组合
  2. 引擎=初始化+主循环
  3. //而初始化和主循环的每一帧,都是由多个job组合而成的管道操作:
  4. 初始化 = create_canvas |> create_gl |> ...
  5. 每一次循环 = tick |> dispose |> reallocate_cpu_memory |> update_transform |> ...

相关资料

第 5 章: 代码组合(compose)

迭代和递归

介绍

遍历操作可以分成两类:

迭代

递归

例如广度优先遍历是迭代操作,而深度优先遍历是递归操作

Reason支持用for、while循环实现迭代操作,用“rec”关键字定义递归函数。

Reason支持尾递归优化,可将其编译成迭代操作。所以我们应该在需要遍历很多次的地方,用尾递归进行遍历。

示例

  1. //func1为尾递归函数
  2. let rec func1 = (value, result) => {
  3. value > 3 ? result : func1(value + 1, result + value);
  4. };
  5. //0+1+2+3=6
  6. func1(1, 0);

在引擎中的应用

几乎所有的遍历都是尾递归遍历(因为相对于迭代,代码更可读),只有在少数使用Mutable和少数性能热点的地方,使用迭代遍历

相关资料

什么是尾递归?

Reason->Recursive Functions

Reason->Imperative Loops

模式匹配

介绍

使用switch代替if/else来处理程序分支。

示例

  1. let func1 = value => {
  2. switch(value){
  3. | 0 => 10
  4. | _ => 100
  5. }
  6. };
  7. //10
  8. func1(0);
  9. //100
  10. func1(2);

在引擎中的应用

主要用在下面三种场景:

1)取出容器的值

  1. type a =
  2. | A(int)
  3. | B(string);
  4. let aValue = switch(a){
  5. | A(value) => value
  6. | B(value) => value
  7. };

2)处理Option

  1. let a = Some(1);
  2. switch(a){
  3. | None => ...
  4. | Some(value) => ...
  5. }

3)处理枚举类型

  1. type a =
  2. | A
  3. | B;
  4. switch(a){
  5. | A => ...
  6. | B => ...
  7. }

相关资料

Reason->Pattern Matching!

模式匹配

容器

介绍

为了领域建模,或者为了隔离副作用来保证纯函数,需要把值封装到容器中,使外界只能操作容器,不能直接操作值。

示例

1)领域建模示例

比如我们要开发一个图书管理系统,需要对“书”进行建模。

书有书号、页数这两个数据,有小说书、技术书两种类型。

建模为:

  1. type bookId = int;
  2. type pageNum = int;
  3. //book为Discriminated Union Type
  4. //book作为容器,定义了两个Union Type:Novel、Technology
  5. type book =
  6. | Novel(bookId, pageNum)
  7. | Technology(bookId, pageNum);

现在我们创建一本小说,一本技术书,以及它们的集合list:

  1. let novel = Novel(0, 100);
  2. let technology = Technology(1, 200);
  3. let bookList = [
  4. novel,
  5. technology
  6. ];

对“书”这个容器进行操作:

  1. let getPage = (book) =>
  2. switch(book){
  3. | Novel(_, page) => page
  4. | Technology(_, page) => page
  5. };
  6. let setPage = (page, book) =>
  7. switch(book){
  8. | Novel(bookId, _) => Novel(bookId, page)
  9. | Technology(bookId, _) => Technology(bookId, page)
  10. };
  11. //client code
  12. //得到新的技术书,它的页数为集合中所有书的总页数
  13. let newTechnology =
  14. bookList
  15. |> List.fold_left((totalPage, book) => totalPage + getPage(book), 0)
  16. |> 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函数来处理每种类型:

  1. let isIntEqual = (source: int, target: int) => source == target;
  2. let isStringEqual = (source: string, target: string) => source == target;
  3. //true
  4. isIntEqual(1, 1);
  5. //true
  6. isStringEqual("aaa", "aaa");

使用GADT重构后,只需要一个isEqual函数来处理所有的类型:

  1. type isEqual(_) =
  2. | Int: isEqual(int)
  3. | Float: isEqual(float)
  4. | String: isEqual(string);
  5. let isEqual = (type g, kind: isEqual(g), source: g, target: g) =>
  6. switch (kind) {
  7. | _ => source == target
  8. };
  9. //true
  10. isEqual(Int, 1, 1);
  11. //true
  12. isEqual(String, "aaa", "aaa");

在引擎中的应用

包含以下使用场景:

1)契约检查

使用GADT定义一个assertEqual方法来判断两个任意类型的变量是否相等,从而不需要assertStringEqual,assertIntEqual等方法。

相关资料

Why GADTs matter for performance(需要翻墙)

维基百科->Generalized algebraic data type

Module Functor

介绍

module作为参数,传递给functor,得到一个新的module。

它类似于面向对象的“继承”,可以通过函子functor,在基module上扩展出新的module。

示例

  1. module type Comparable = {
  2. type t;
  3. let equal: (t, t) => bool;
  4. };
  5. //module functor
  6. module MakeAdd = (Item: Comparable) => {
  7. let add = (x: Item.t, newItem: Item.t, list: list(Item.t)) =>
  8. Item.equal(x, newItem) ? list : [newItem, ...list];
  9. };
  10. module A = {
  11. type t = int;
  12. let equal = (x1, x2) => x1 == x2;
  13. };
  14. //module B增加了add函数,该方法调用了A.equal函数
  15. module B = MakeAdd(A);
  16. //list == [2]
  17. let list = B.add(1, 2, []);
  18. //list == [2]
  19. let list = list |> B.add(1, 1);

在引擎中的应用

引擎中有些module有相同的模式,可把它们放到提出的基module中,然后通过functor复用基module。

相关资料

Reason->Module Functions

类型搭桥

背景

想象有两个世界:“普通世界”和“提升世界”,“提升世界”与“普通世界”非常像,“普通世界”的所有类型在“提升世界”中都有对应的类型。

例如,“普通世界”有int和string类型,对应于“提升世界”就是e(int)和e(string)类型:



(图来自Understanding map and apply

(TODO 把图中的“<>”改为“()”,“Int”改为“int”,“E”改为“e”)

同样的,“普通世界”有类型签名为“int=>string”的函数,对应于“提升世界”就是类型签名为“e(int)=>e(string)”的函数:



(图来自Understanding map and apply

什么是“提升世界”?

一个Discriminated Union类型对应一个“提升世界”。

如Discriminated Union类型result:

  1. type result('a, 'b) =
  2. | Ok('a)
  3. | Error('b);

它对应一个“提升世界”:“Result世界”。

“普通世界”的类型int对应于“Result世界”的类型为result(int, 'b)。

又如option对应一个“提升世界”:“Option世界”。

“普通世界”的类型Int对应于“Option世界”的类型为option(int)。

又如list对应一个“提升世界”:“List世界”。

“普通世界”的类型int对应于“List世界”的类型为list(int)。

什么是类型搭桥

两个函数组合时,第一个函数的返回会作为第二个函数的输入。如果它们的类型处于不同的世界(如一个是option(t)类型,另一个是t类型),那么需要升降类型到同一个世界,这样才能组合。对于这个类型升降的过程,我称之为“类型搭桥”。

相关资料

The "Map and Bind and Apply, Oh my!" series

有下面几种操作来升降类型:

return

常用名:return, pure, unit, yield, point

它做了什么:提升类型到“提升世界”

类型签名: a => e(a)

介绍

“return”把类型从“普通世界”提升到“提升世界”:



(图来自Understanding map and apply

示例

  1. //option增加return函数
  2. module Option = {
  3. ...
  4. let return = x => Some(x);
  5. };
  6. //client code
  7. let func = (opt) => {
  8. switch(opt){
  9. | Some(x) => x * 2;
  10. | None => 0
  11. }
  12. };
  13. //a=2
  14. let a = 1 |> Option.return |> func;

在引擎中的应用

处理错误的Result模块实现了succeed和fail函数,它们属于“return”操作:

Result.re

  1. type t('a, 'b) =
  2. | Success('a)
  3. | Fail('b);
  4. let succeed = x => Success(x);
  5. let fail = x => Fail(x);

如果一个函数没有发生错误,则调用Result.succeed,把返回值包装在Success中;否则调用Result.fail,把错误信息包装在Fail中。

相关资料

Understanding map and apply

map

常用名:map, fmap, lift, Select

它做了什么:提升函数到“提升世界”

类型签名: (a=>b) => e(a) => e(b)

介绍

“map”把函数从“普通世界”提升到“提升世界”:



(图来自Understanding map and apply

示例

  1. //option增加map函数
  2. module Option = {
  3. ...
  4. let map = (func, option) => {
  5. switch(option){
  6. | Some(x) => Some(x |> func)
  7. | None => None
  8. }
  9. };
  10. };
  11. //client code
  12. //定义在“普通世界”的函数
  13. //类型签名 : int => int
  14. let add1 = x => x + 1;
  15. //使用map,提升函数到“Option世界”
  16. let add1IfSomething = Option.map(add1)
  17. //a=Some(3)
  18. let a = Some(2) |> add1IfSomething;

在引擎中的应用

处理错误的Result模块实现了map函数:

Result.re

  1. type t('a, 'b) =
  2. | Success('a)
  3. | Fail('b);
  4. let either = (successFunc, failureFunc, twoTrackInput) =>
  5. switch (twoTrackInput) {
  6. | Success(s) => successFunc(s)
  7. | Fail(f) => failureFunc(f)
  8. };
  9. let map = (oneTrackFunc, twoTrackInput) =>
  10. either(result => result |> oneTrackFunc |> succeed, fail, twoTrackInput);

引擎用到Result.map的地方很多,例如可以使用Result.map来处理Success中包含的值,将其转换为另一个值。

示例代码如下:

  1. let func1 = x => x |> Result.succeed;
  2. let func2 = result => result |> Result.map(x => x * 2);
  3. //a=Success(2)
  4. let a = 1 |> func1 |> func2;

相关资料

Understanding map and apply

bind

常用名:bind, flatMap, andThen, collect, SelectMany

它做了什么:使穿越世界(manadic)的函数可以被组合

类型签名: (a=>e(b)) => e(a) => e(b)

介绍

我们经常要处理在“普通世界”和“提升世界”穿越的函数。

例如:一个把字符串解析成int的函数,可能会返回option(int)而不是int类型,因此它穿越了“普通世界”和“Option世界”;一个读取文件的函数可能会返回ienumerable(string)类型;一个接收网页的函数可能返回promise(string)类型。

这种穿越世界的函数,它们的类型签名可以被识别为:a => e(b)。

它们的输入是“普通世界”的类型,而输出则是“提升世界”的类型。

不幸的是,这意味着不能直接组合这些函数:



(图来自Understanding bind

“bind”把穿越世界的函数(通常称为“monadic”函数)转换为“提升世界”的函数:e(a) => e(b):



(图来自Understanding bind

这么做的好处是,转换后的函数完全在“提升世界”,因此可以被直接组合。

例如,一个类型签名为“a => e(b)”的函数不能直接与类型签名为“b => e(c)”的函数组合。但当bind后者以后,后者的类型签名变为“e(b) => e(c)”,这样就可以与前者进行组合了:



(图来自Understanding bind

通过bind,能让多个mondadic函数组合在一起。

示例

  1. //option增加bind方法
  2. module Option = {
  3. ...
  4. let bind = (func, option) => {
  5. switch(option){
  6. | Some(x) => x |> func
  7. | None => None
  8. }
  9. };
  10. };
  11. //client code
  12. //类型签名:string => option(int)
  13. let parseStr = str => {
  14. switch(str){
  15. | "-1" => Some(-1)
  16. | "0" => Some(0)
  17. | "1" => Some(1)
  18. | "2" => Some(2)
  19. // etc
  20. | _ => None
  21. };
  22. }
  23. type orderQty =
  24. | OrderQty(int);
  25. //类型签名:int => option(orderQty)
  26. let toOrderQty = (qty) =>
  27. if (qty >= 1) {
  28. Some(OrderQty(qty));
  29. } else {
  30. None;
  31. };
  32. //使用bind转换toOrderQty函数,使它能与parseInt函数组合
  33. //类型签名:string => option(orderQty)
  34. let parseOrderQty = str => str |> parseStr |> Option.bind(toOrderQty);

在引擎中的应用

处理错误的Result模块实现了bind函数:

Result.re

  1. type t('a, 'b) =
  2. | Success('a)
  3. | Fail('b);
  4. let either = (successFunc, failureFunc, twoTrackInput) =>
  5. switch (twoTrackInput) {
  6. | Success(s) => successFunc(s)
  7. | Fail(f) => failureFunc(f)
  8. };
  9. let bind = (switchFunc, twoTrackInput) =>
  10. either(switchFunc, fail, twoTrackInput);

引擎用到Result.bind的地方很多,例如:

在主循环中,获得数据state的函数unsafeGetState的类型签名为:unit => Result.t(state, Js.Exn.t),处理主循环逻辑的loopBody函数的类型签名为:state => Result.t(state, Js.Exn.t)。

需要对loopBody函数进行Result.bind,从而使它们能够组合。

相关代码为:

  1. let rec _loop = () =>
  2. DomExtend.requestAnimationFrame((time: float) => {
  3. StateData.unsafeGetState()
  4. |> Result.bind(Director.loopBody)
  5. ...
  6. });

相关资料

Understanding bind

traverse

常用名:mapM, traverse, for

它做了什么:转换一个穿越世界的函数为另一个穿越世界的函数,转换后的函数用来在遍历集合时处理每个元素

类型签名:(a=>e(b)) => list(a) => e(list(b))

介绍

我们来看看一个经常会遇到的场景:处理一个集合(如list),该集合元素为“提升世界”的类型。

举例来说:

我们有一个类型签名为“string => option(int)”的函数parseStr和一个集合:list(string),我们想要解析该集合所有的字符串。我们可以使用“map”:list |> List.map(parseStr),将list(string)转换为list(option(int))。

但我们真正想要的不是“list(option(int))”,而是“option(list(int))”,即用option来包装解析后的列表list。

这可以通过traverse来做到,我们把list对应的“提升世界”称为“Traversable世界”(因为可以使用traverse来遍历它),把option对应的“提升世界”称为“Applicative世界”。使用“traverse”操作遍历list,得到option(list('b))类型,如下图所示:

示例

介绍中的示例的相关代码如下:

  1. module List = {
  2. ...
  3. //注:可以将该函数作为模版。如果Applicative世界变为其它世界,如Result世界,只需要把returnFunc替换为Result.succeed,Js.Option.andThen替换为Result.bind
  4. let traverseOptionM = (traverseFunc, list) => {
  5. let returnFunc = Js.Option.some;
  6. list
  7. |> List.fold_left(
  8. (resultArr, value) =>
  9. //andThen相当于bind
  10. Js.Option.andThen(
  11. (. h) =>
  12. Js.Option.andThen((. t) => returnFunc(t @ [h]), resultArr),
  13. traverseFunc(value),
  14. ),
  15. returnFunc([]),
  16. );
  17. };
  18. };
  19. //client code
  20. //类型签名:string => option(int)
  21. let parseStr = str => {
  22. switch (str) {
  23. | "" => None
  24. | str => Some(str |> Js.String.length)
  25. };
  26. };
  27. //a=None
  28. let a = ["", "aaa", "bbbb"] |> List.traverseOptionM(parseStr);
  29. //b=Some([3,4])
  30. let b = ["aaa", "bbbb"] |> List.traverseOptionM(parseStr);

关于traverse具体实现的分析,详见Understanding traverse and sequence

在引擎中的应用

引擎的List、Array实现了traverseResultM函数,用来遍历处理集合中Result类型(存在错误的函数会返回Result类型)的元素。

相关资料

Understanding traverse and sequence

sequence

常用名:sequence

它做了什么:交换Traversable世界和Applicative世界的位置

类型签名:list(e(a)) => e(list(a))

介绍

sequence交换Traversable世界和Applicative世界的位置:

Traversable世界下降

Applicative世界上升

如下图所示:

示例

如果已经实现了traverseResultM,那么可以从中很简单地导出sequence。

示例代码为:

  1. module List = {
  2. ...
  3. let id = x => x;
  4. let sequenceResultM = x => traverseResultM(id, x);
  5. };
  6. //client code
  7. //与traverse示例中的parseStr函数一样
  8. let parseStr = ...
  9. let a =
  10. ["1", "22", "333"]
  11. //得到[Some(1), Some(2), Some(3)]
  12. |> List.map(parseStr)
  13. //得到Some([1,2,3])
  14. |> List.sequenceResultM;
  15. let b =
  16. ["", "22", "333"]
  17. //得到[None, Some(2), Some(3)]
  18. |> List.map(parseStr)
  19. //得到None
  20. |> List.sequenceResultM;

在引擎中的应用

引擎对元组Tuple实现了sequenceResultM函数,用来交换Tuple和Result的位置。

相关资料

Understanding traverse and sequence

错误处理

使用Result

介绍

我们使用Result来处理错误,它相对于“抛出异常”的错误处理方式,有下面的优点:

1、分离“发生错误”和“处理错误”的逻辑,延迟到后面来统一处理错误

2、对流程更加可控,如可以在第一个错误发生后继续执行后面的程序

3、显示地标示出错误

通过函数返回Result类型,在类型的编译检查时可确保使用错误得到了处理

示例

首先增加Result.re这个模块,在其中定义一个Discriminated Union类型,使其包含Success和Fail的数据(设置为泛型参数'a和'b,使其可为任意类型,增加通用型):

  1. type t('a, 'b) =
  2. | Success('a)
  3. | Fail('b);

接着定义return(此处为succeed和fail函数)、map、bind等函数:

  1. let succeed = x => Success(x);
  2. let fail = x => Fail(x);
  3. let either = (successFunc, failureFunc, twoTrackInput) =>
  4. switch (twoTrackInput) {
  5. | Success(s) => successFunc(s)
  6. | Fail(f) => failureFunc(f)
  7. };
  8. let bind = (switchFunc, twoTrackInput) =>
  9. either(switchFunc, fail, twoTrackInput);
  10. let map = (oneTrackFunc, twoTrackInput) =>
  11. either(result => result |> oneTrackFunc |> succeed, fail, twoTrackInput);

最后定义处理Result的函数:

  1. let getSuccessValue = (handleFailFunc: 'f => unit, result: t('s, 'f)): 's =>
  2. switch (result) {
  3. | Success(s) => s
  4. | Fail(f) => handleFailFunc(f)
  5. };

我们来看下如何使用Result处理错误:

  1. //func使用Result包装错误
  2. let func = (x) => {
  3. x > 0 ? Result.succeed(x) : Result.fail(Js.Exn.raiseError("x should > 0"));
  4. };
  5. //处理错误的函数:抛出异常
  6. let throwError: Js.Exn.t => unit = [%raw err => {|
  7. throw err;
  8. |}];
  9. //抛出异常
  10. let a = func(-1) |> Result.getSuccessValue(throwError);
  11. //正常执行,b=1
  12. let b = func(1) |> Result.getSuccessValue(throwError);

在引擎中的应用

引擎使用Result来处理错误。

相关资料

Railway oriented programming

参考资料

用函数式编程,从0开发3D引擎和编辑器(二):函数式编程准备

从0开发3D引擎(五):函数式编程及其在引擎中的应用的更多相关文章

  1. 从0开发3D引擎(六):函数式反应式编程及其在引擎中的应用

    目录 上一篇博文 介绍函数式反应式编程 函数式反应式编程学习资料 函数式反应式编程的优点与缺点 优点 缺点 异步处理的其它方法 为什么使用Most库 引擎中相关的函数式反应式编程知识点 参考资料 大家 ...

  2. 用函数式编程,从0开发3D引擎和编辑器(一)

    介绍 大家好,欢迎你踏上3D编程之旅- 本系列的素材来自我们的产品:Wonder-WebGL 3D引擎和编辑器 的整个开发过程,探讨了在从0开始构建3D引擎和编辑器的过程中,每一个重要的功能点.设计方 ...

  3. 用函数式编程,从0开发3D引擎和编辑器(三):初步需求分析

    大家好,本文介绍了Wonder的高层需求和本系列对应的具体功能点. 确定Wonder高层需求 业务目标 Wonder是web端3D开发的解决方案,包括引擎.编辑器,致力于打造开放.分享.互助的生态. ...

  4. 用函数式编程,从0开发3D引擎和编辑器(二):函数式编程准备

    大家好,本文介绍了本系列涉及到的函数式编程的主要知识点,为正式开发做好了准备. 函数式编程的优点 1.粒度小 相比面向对象编程以类为单位,函数式编程以函数为单位,粒度更小. 正所谓: 我只想要一个香蕉 ...

  5. 从0开发3D引擎(一):开篇

    介绍 大家好,本系列带你踏上Web 3D编程之旅- 本系列是实战类型,从0开始带领读者写出"良好架构.良好扩展性.最小功能集合(MVP)" 的3D引擎. 本系列的素材来自我们的产品 ...

  6. 从0开发3D引擎(四):搭建测试环境

    目录 上一篇博文 了解自动化测试 单元测试 集成测试 端对端测试 通过打印日志来调试 了解运行测试 断点调试 通过Spector.js测试WebGL 通过log调试Shader 移动端测试 了解性能测 ...

  7. 从0开发3D引擎:目录

    介绍 大家好,本系列带你踏上Web 3D编程之旅- 本系列是实战类型,从0开始带领读者写出"良好架构.良好扩展性.优秀的性能.最小功能集合(MVP)" 的3D引擎. 本系列的素材来 ...

  8. 从0开发3D引擎(十):使用领域驱动设计,从最小3D程序中提炼引擎(上)

    目录 上一篇博文 下一篇博文 前置知识 回顾上文 最小3D程序完整代码地址 通用语言 将会在本文解决的不足之处 本文流程 解释本文使用的领域驱动设计的一些概念 本文的领域驱动设计选型 设计 引擎名 识 ...

  9. 从0开发3D引擎(十二):使用领域驱动设计,从最小3D程序中提炼引擎(第三部分)

    目录 上一篇博文 继续实现 实现"DirectorJsAPI.init" 实现"保存WebGL上下文"限界上下文 实现"初始化所有Shader&quo ...

随机推荐

  1. C++继承方式

    C++的继承方式有三种,分别为: 公有继承:public 私有继承:private 保护继承:protected 定义格式为: class<派生类名>:<继承方式><基类 ...

  2. JPA 一对多、多对一注解

    1. @OneToMany @OneToMany 是属性或方法级别的注解,用于定义源实体与目标实体是一对多的关系. 参数 类型 描述 targetEntity Class 源实体关联的目标实体类型,默 ...

  3. H3C 什么是OSPF

  4. 【p083】传球游戏

    Time Limit: 1 second Memory Limit: 50 MB [问题描述] 上体育课的时候,小蛮的老师经常带着同学们一起做游戏.这次,老师带着同学们一起做传球游戏. 游戏规则是这样 ...

  5. js基础——变量、作用域、内存

    1.new关键字创建的是引用类型: eg. var box = new Object();      box.name = "Linda";//引用类型添加属性没问题     al ...

  6. childNodes和children

    childNodes 返回指定元素的子节点集合,包括HTML节点,所有文本(元素之间的空格换行childNodes会看作文本节点). 通过nodeType来判断节点的类型: 元素 1 属性 2 文本 ...

  7. Linux 内核 动态设备

    术语"热插拔"最普遍使用的意义产生于当讨论这样的事实时, 几乎所有的计算机系统现在 能够处理当系统有电时设备的出现或消失. 这非常不同于只是几年前的计算机系统, 那时 程序员知道他 ...

  8. koa2--08.koa-session的使用

    首先安装 koa-session中间件 //koa-session的使用 const koa = require('koa'); var router = require('koa-router')( ...

  9. 2018-2-13-win10-uwp-csdn-博客阅读器

    title author date CreateTime categories win10 uwp csdn 博客阅读器 lindexi 2018-2-13 17:23:3 +0800 2018-2- ...

  10. [板子]Kruskal

    众所周知求最小生成树的两种方法: 1.Kruskal 2.Prim 这里只挂第一种,因为noip掌握第一种就够了. 两种做法的区别可以参考这个博客:http://blog.csdn.net/molln ...