原文:http://2ality.com/2018/01/lists-arrays-reasonml.html
翻译:ppp
系列文章目录详见: “什么是ReasonML?”
在这篇文章中,我们看看ReasonML的两个数据结构 - 列表和数组:
下表比较了两种数据结构。 | | Lists | Arrays | |------------|-------------| -------| | Size | small–medium | small–large | | Resizable? | flexible | fixed | | Mutability | immutable | mutable | | Elem types | same | same | | Access via | destructuring | index | | Fastest | prepend/remove first | read/write elems |
在这篇文章中,你会看到许多类型签名,例如:
let map: ('a => 'b, list('a)) => list('b);
这个类型签名对你来说似乎仍然很神秘。但是通过学习如何读懂它们,你将会有不少收获。让我们来看看map的类型签名告诉了我们什么 - 当我们还不知道map是什么,也不知道它有什么功能时(这将在后面解释)。一步一步理解:
let map: («parameters») => «result»;
map 是一个函数。'a => 'b
map第一个参数是一个函数,传入类型'a并返回类型'b。撇号表示'a并且'b是类型变量:它们接受任何类型。但是一旦接受了一个特定的类型,它就只能接受这种类型了。list('a)
map第二个参数是一个元素类型为'a的列表。list('b)
map返回值是一个元素类型是'b的列表。ReasonML目前有两个用于列表和数组的标准模块:
let map: ('a => 'b, list('a)) => list('b);
let map: ('a => 'b, array('a)) => array('b);
这两个模块里的每个功能都有参数别名的版本,对应下面的这两个模块:
let map: (~f: 'a => 'b, list('a)) => list('b);
let map: (~f: 'a => 'b, array('a)) => array('b);
有个小技巧,你可以通过open StdLabels来将标准模块替换为标准模块的参数别名版本:
open StdLabels;
该模块还有全局ArrayLabels的别名:Array,Bytes,List和String等等。因此,如果你open了这个模块,你会覆盖当前的Array等的实现。
ReasonML中的列表是一种典型的功能性数据结构,因为它们的类型是递归定义的,并且列表不可变。让我们看看这是什么意思。
list是一个自递归参数化变体。如果你要自己定义它,这就是它的样子:
type mylist('a) =
| Nil;
| Cons('a, mylist('a))
取名Nil和Cons(“构造”)是有历史渊源的,它源于Lisp这种编程语言。通过嵌套Cons创建列表:
# let abc = Cons("a", Cons("b", Cons("c", Nil)));
let abc: mylist(string) = Cons("a", Cons("b", Cons("c", Nil)));
mylist有一个类型的参数'a。它将和mylist递归的传递给Cons。这意味着两件事:首先,mylist的元素可以是任意类型。其次,它们都必须具有相同的类型。通过之前的学习,你可以看到ReasonML自动推断出abc的类型,'a是string。
abc的是实现是一个单向链表。在内存中,它可能看起来像这样:
一个cons对的两部分被称为:
ReasonML的列表有特殊的语法。有两种构造方式:
因此,模式匹配就像这样:
switch myList {
| [] => ···
| [head, ...tail] => ···
}
这是用另一种方式重新创建上一节中abc的方法:
# let abc = ["a", ...["b", ...["c", ...[]]]];
let abc: list(string) = ["a", "b", "c"];
你可以看到rtop给出了一个等价的,但更紧凑的语法:
# let abc = ["a", "b", "c"];
let abc: list(string) = ["a", "b", "c"];
我们用模式匹配来计算任何列表的长度myList:
let rec len = (myList: list('a)) =>
switch myList {
| [] => 0
| [_, ...tail] => 1 + len(tail)
};
我们回顾这两个构造函数:
类型参数'a使函数len变得通用。但是在列表这种数据结构中,我们从来不关心这些元素的类型。下面是各种类型的列表使用len函数的示例:
# len([]);
- : int = 0
# len(["a", "b"]);
- : int = 2
# len([1, 2, 3, 4]);
- : int = 4
ReasonML内置不支持打印复杂数据结构。但是BuckleScript可以让你使用JavaScript的console.log()。最好像这样调用:
Js.log(Array.of_list(myList));
在我们打印列表myList之前,我们将它转换为一个数组。但为什么要这样做呢?因为这可以让输出的效果更好。ReasonML列表在JavaScript中表示为嵌套的有两个元素的数组。
三点构造函数也被称为展开运算符。该运算符允许你在现有列表之前添加零个或多个元素:
# [...["a", "b"]];
- : list(string) = ["a", "b"]
# ["a", ...["b", "c"]];
- : list(string) = ["a", "b", "c"]
# ["a", "b", ...["c", "d"]];
- : list(string) = ["a", "b", "c", "d"]
可惜的是,在ReasonML中,你只能用在表达式的最后使用,不像JavaScript中,可以在任何地方使用。
# [...["a", "b"], ...["c", "d"]];
Error: Syntax error
ReasonML有自己的连接列表的运算符:
# ["a", "b"] @ ["c", "d"];
- : list(string) = ["a", "b", "c", "d"]
请注意,连接列表操作相对较慢,因为你必须将第一个列表的每个元素添加到第二个列表中:
let rec append = (l1: list('a), l2: list('a)) =>
switch l1 {
| [] => l2
| [head, ...tail] => [head, ...append(tail, l2)]
};
(这个实现可以被改进,我们将在即将发布的博客文章中看到。)
这是你如何使用append:
# append([1,2,3], [4,5]);
- : list(int) = [1, 2, 3, 4, 5]
这是再次用到了类型推断:
# [1,2,3];
- : list(int) = [1, 2, 3]
range() 创建一个int列表
/**
* Compute a list of integers starting with `start`,
* up to and excluding `end_`.
*/
let rec range = (start: int, end_: int) =>
if (start >= end_) {
[];
} else {
[start, ...range(start + 1, end_)];
};
在ReasonML中end是一个关键词,因此不是一个合法的变量名。所以给end_加了一个下划线。让我们试试执行range():
# range(0, 0);
- : list(int) = []
# range(0, 1);
- : list(int) = [0]
# range(0, 5);
- : list(int) = [0, 1, 2, 3, 4]
fill()创建一个填充值为~element的列表:
/**
* Create a list of length `~length` where each
* element is `~element`.
*/
let rec fill = (~element: 'a, ~length: int) =>
if (length <= 0) {
[];
} else {
[element, ...fill(~element, ~length=length-1)];
};
ReasonML使用~element的类型来推断出结果的类型:
# fill("x", 4);
- : list(string) = ["x", "x", "x", "x"]
# fill(0, 3);
- : list(int) = [0, 0, 0]
summarize() 计算列表中所有整数的总和:
/**
* Compute the sum of all the ints in the list `l`.
*/
let rec summarize = (l: list(int)) =>
switch l {
| [] => 0
| [head, ...tail] => head + summarize(tail)
};
summarize([]); /* 0 */
summarize([3]); /* 3 */
summarize([1, 2, 3]); /* 6 */
getElementAt() 通过索引检索列表元素:
/**
* Get the list element at index `~index`.
* The head of a list has the index 0,
* the head of its tail the index 1, etc.
*/
let rec getElementAt = (~index: int, l: list('a)) =>
switch l {
| [] => None
| [head, ...tail] =>
if (index <= 0) {
Some(head);
} else {
getElementAt(~index=index-1, tail);
}
};
我们可以用when字句和一个额外的case来取代switch中的if-else,下面的实现更扁平,更易读:
let rec getElementAt = (~index: int, l: list('a)) =>
switch l {
| [] => None
| [head, ..._] when index <= 0 => Some(head)
| [head, ...tail] => getElementAt(~index=index-1, tail)
};
代码中有一些值得注意的:
标准库中ListLabels.nth()的功能和getElementAt()类似,但它有可能会抛出非法索引的异常,因为它没有使用option。
由于列表是不可修改的 - 你如何改变它们?为了找到答案,我们看看目前为止我们已经看到了两类算法:
要更改列表,我们需要将两种方法结合起来:根据需要,我们创建一个全新的列表,并且把数据从已有的列表或数据源中导入。
以下是更改现有列表的函数的第一个示例。
/**
* Remove all elements from the list `l` that are
* equal to `~value`.
*/
let rec removeAll = (~value: 'a, l: list('a)) =>
switch l {
| [] => []
| [head, ...tail] when head == value => removeAll(~value, tail)
| [head, ...tail] => [head, ...removeAll(~value, tail)]
};
第一种情况意味着我们完成了操作。第三种情况是复制现有列表。第二种情况则是删除等于~value的元素。
这是removeAll()在实际中的使用:
# removeAll(~value=9, [1,9,2,9,3]);
- : list(int) = [1, 2, 3]
replaceAll() 用于替换值:
/**
* Inside the list `l`, remove all occurrences of the value `~value`
* with the value `~with_`.
*/
let rec replaceAll = (~value: 'a, ~with_: 'a, l: list('a)) =>
switch l {
| [] => []
| [head, ...tail] when head == value =>
[with_, ...replaceAll(~value, ~with_, tail)]
| [head, ...tail] =>
[head, ...replaceAll(~value, ~with_, tail)]
};
第一种情况意味着我们完成了操作。第三种情况是复制现有列表。第二种情况则是执行替换。
我们可以通过一个内联辅助函数replaceOne(),让replaceAll()变得更加紧凑:
let rec replaceAll = (~value: 'a, ~with_: 'a, l: list('a)) => {
let replaceOne = (x) => if (x == value) with_ else x;
switch l {
| [] => []
| [head, ...tail] =>
[replaceOne(head), ...replaceAll(~value, ~with_, tail)]
};
};
replaceAll()实际场景中的使用:
# replaceAll(~value=1, ~with_=11, [1, 2, 1, 3]);
- : list(int) = [11, 2, 11, 3]
drop() 功能是删除列表元素
/**
* Remove the first `~count` elements of `theList`.
*/
let rec drop = (~count, theList: list('a)) =>
switch theList {
| [] => []
| l when count <= 0 => l
| [_, ...tail] => drop(~count=count-1, tail)
};
让我们来调用drop():
# drop(~count=0, ["a", "b", "c", "d"]);
- : list(string) = ["a", "b", "c", "d"]
# drop(~count=2, ["a", "b", "c", "d"]);
- : list(string) = ["c", "d"]
# drop(~count=2, ["a", "b"]);
- : list(string) = []
# drop(~count=2, ["a"]);
- : list(string) = []
# drop(~count=2, []);
- : list('a) = []
对于最后一个drop()调用的结果,ReasonML无法推断出元素的类型,因此给出一个未对类型绑定的'a类型。
ReasonML的标准库仍在不断变化。因此,我们只在这里看一些重要的。你可以阅读ListLabels的文档来了解其他的功能(如当前存在的内容)。
类型签名:
let map: (~f: 'a => 'b, list('a)) => list('b);
map()接收类型元素为'a的列表,将函数~f应用于列表的每个元素,并将结果保存在另一个列表中返回。
# ListLabels.map(~f=x => int_of_string(x), ["7", "15", "6"]);
- : list(int) = [7, 15, 6]
该功能是处理数据列表的常用方法。
mapi()是一个map()的另一个版本,它会把元素值和索引都传递给~f,我们可以用mapi()来实现对列表的非破坏性更新:
/**
* Create a copy of `theList` whose element at index `~index`
* is `~value`.
*/
let setElementAt = (~index: int, ~value: 'a, theList: list('a)) =>
ListLabels.mapi(
~f=(i,x) => if (i==index) value else x,
theList
);
~f函数只对索引为~index的元素做了修改。
setElementAt()的实际使用:
# setElementAt(~index=1, ~value="|", ["a", "b", "c"]);
- : list(string) = ["a", "|", "c"]
这是该函数的类型签名:
let filter: (~f: 'a => bool, list('a)) => list('a);
filter()把列表的每个元素传递给~f。如果~f返回true,该元素被将包含在返回结果中,如果是false,则不包含。用法如下:
# ListLabels.filter(~f=x=>x>5, [8, 4, 9, 7, 2]);
- : list(int) = [8, 9, 7]
类型签名:
let for_all: (~f: 'a => bool, list('a)) => bool;
如果列表中的每个元素在函数~f都返回true,for_all()返回就true。例如:
# ListLabels.for_all(~f=x=>x>3, [4,5,6]);
- : bool = true
# ListLabels.for_all(~f=x=>x>3, [3,4,5,6]);
- : bool = false
如果~f返回false,for_all就回立刻返回。而且结果一定为false。for_all是以数学运算符∀来命名的。
ListLabels.exists()和for_all()类似:只要列表中有一个元素使回调函数的返回值为true,则返回true。exists以数学运算符∃命名。
类型签名:
let flatten: list(list('a)) => list('a);
flatten()通过连接元素的方式把多个列表转化为一个列表。也就是说,以下三个表达式是等价的:
flatten([l1, l2, l3])
ListLabels.append(l1, ListLabels.append(l2, l3))
l1 @ l2 @ l3
这是flatten()的用法:
# ListLabels.flatten([[1,2], [], [3,4,5]]);
- : list(int) = [1, 2, 3, 4, 5]
如果你想使用任意嵌套的列表,请回想一下,在ReasonML中,所有元素必须具有相同的类型。因此,如果一个列表元素本身就是一个列表,那么所有元素都必须是列表:
# ListLabels.flatten([[1,[2]], [], [3]]);
Error: This expression has type list('a)
but an expression was expected of type int
# ListLabels.flatten([[[1],[2]], [], [[3]]]);
- : list(list(int)) = [[1], [2], [3]]
让我们继续查看flatten()的用例。
flatten()让你可以同时过滤和映射。例子,考虑这种情况:尝试提取存储在一个列表中的多个列表的第一个元素。你可以:
或者你可以使用flatten同时执行这两种操作:
module L = ListLabels;
let listFromHead = (l: list('a)) =>
switch (l) {
| [] => []
| [head, ..._] => [head]
};
let heads = (l: list(list('a))) =>
L.flatten(L.map(~f=listFromHead, l));
首先,我们将每个非空列表通过它的头映射到另一个列表中,每个空列表映射到一个空列表。然后我们把结果扁平化。看起来像这样:
# let l0 = [[1, 2], [], [3,4,5]];
let l0: list(list(int)) = [[1, 2], [], [3, 4, 5]];
# L.map(~f=listFromHead, l0);
- : list(list(int)) = [[1], [], [3]]
# let l1 = L.map(~f=listFromHead, l0);
let l1: list(list(int)) = [[1], [], [3]];
# L.flatten(l1);
- : list(int) = [1, 3]
这些步骤等价于:
# heads([[1, 2], [], [3,4,5]]);
- : list(int) = [1, 3]
把listFromHead和getHead做一个对比,这对我们有帮助。
let getHead = (l: list('a)) =>
switch (l) {
| [] => None
| [head, ..._] => Some(head)
};
也就是说,None表示“l没有头”:
# getHead(["a", "b"]);
- : option(string) = Some("a")
# getHead([1, 2, 3]);
- : option(int) = Some(1)
# getHead([]);
- : option('a) = None
listFromHead中,我们使用空列表而不是None,以及用只有一个元素的列表取代Some。
假设我们已经创建了一个person的列表和节点:
type person = Person(string, list(string));
let persons = [
Person("Daisy", []),
Person("Della", ["Huey", "Dewey", "Louie"]),
Person("Marcus", ["Minnie"])
];
如果我们想获取列表的节点,ListLabels.map()几乎满足了我们的需求,但不是很完美:
# ListLabels.map(~f=(Person(_, ch)) => ch, persons);
- : list(list(string)) = [[], ["Huey", "Dewey", "Louie"], ["Minnie"]]
可惜的是,这是字符串列表的列表,而不是字符串列表。我们可以通过ListLabels.flatten()来解决这个嵌套列表的问题:
let collectChildren = (persons: list(person)) =>
ListLabels.flatten(
ListLabels.map(
~f=(Person(_, children)) => children,
persons));
collectChildren(persons);
/* ["Huey", "Dewey", "Louie", "Minnie"] */
现在我们得到了期望的结果:
# collectChildren(persons);
- : list(string) = ["Huey", "Dewey", "Louie", "Minnie"]
有时,你会根据条件创建列表,其中添加或省略了一些元素(下示例中的cond):
let func = (cond: bool) => ListLabels.flatten([
if (cond) ["a"] else [],
[
"b",
"c"
]
]);
这是如何使用func():
# func(true);
- : list(string) = ["a", "b", "c"]
# func(false);
- : list(string) = ["b", "c"]
类型签名:
let fold_left: (~f: ('a, 'b) => 'a, ~init: 'a, list('b)) => 'a;
fold_left() 工作如下:
fold_left()的计算结果取决于它的参数:函数~f。输入参数的每个元素都会传入~f:
let nextIntermediateResult = f(intermediateResult, elem);
intermediateResult是求得值。第一个中间结果是~init。最后的nextIntermediateResult就是fold_left()的结果。
我们来看一个具体的例子。
我们已经见过summarize()函数,用于计算整数列表的和。让我们通过fold_left()实现该功能:
let rec summarize = (l: list(int)) =>
ListLabels.fold_left(~f=(r, elem) => r + elem, ~init=0, l);
要理解summarize()是如何工作的,请先看看以下表达式:
summarize([1,2,3]) /* 6 */
为了得到结果6,将采取以下步骤:
/* Parameter */
let f = (r, elem) => r + elem;
let init = 0;
/* Steps */
let result0 = f(init, 1); /* 1 */
let result1 = f(result0, 2); /* 3 */
let result2 = f(result1, 3); /* 6 */
result2就是fold_left()的结果。
fold_left()的另一种理解是采用二元运算符~f并将其变为列表的n元运算符。在数学中也有从二元运算到n元运算的一个例子:二元运算符+的n元版本是运算符Σ。summarize()就相当于从+变成了Σ。它也可以这样写:
# ListLabels.fold_left(~f=(+), ~init=0, [1, 2, 3]);
- : int = 6
我发现fold_left最容易理解的是它是工作在这种模式下 - ~f参数是一个和位置无关的参数(参数顺序无关紧要)。你可以用它做很多事情 - 继续看下个例子。
该函数contains()使用它在列表中查找值:
let contains = (~value: 'a, theList: list('a)) => {
let f = (found, elem) => found || elem == value;
fold_left(~f, ~init=false, theList);
};
类型签名:
let iteri: (~f: (int, 'a) => unit, list('a)) => unit;
iteri()将遍历列表的每个元素,并传给~f,参数是元素的值和索引,它的返回为unit。这意味着iteri并没有实际的功能,具体的操作都是通过函数~f来实现的。
以下函数使用iteri()来填充一个数组。通过逐个写入数组的元素来实现:
let arrayFromList = (~init: 'a, l: list('a)) => {
let arr = ArrayLabels.make(ListLabels.length(l), init);
ListLabels.iteri(~f=(i, x) => arr[i]=x, l);
arr;
};
~init是必选参数,因为make()需要它(为什么稍后解释)。
arrayFromList()的实际用例:
# arrayFromList(~init=0, [1,2,3]);
- : array(int) = [|1, 2, 3|]
数组很像列表:它们的所有元素都具有相同的类型,并且可以按位置访问它们。但他们也不尽相同:
以下小节介绍创建数组的三种常用方法。
# [| "a", "b", "c" |];
- : array(string) = [|"a", "b", "c"|]
类型签名:
let make: (int, 'a) => array('a);
第一个参数指定数组的长度。第二个参数指定了要填充的值。为什么第二个参数是必选的的?因为make()返回的数组必须只包含类型'a的值。ReasonML没有null,所以你必须手动的为'a指定一个类型。
这是make()如何工作的:
# ArrayLabels.make(3, "x");
- : array(string) = [|"x", "x", "x"|]
# ArrayLabels.make(3, true);
- : array(bool) = [|true, true, true|]
类型签名:
let init: (int, ~f: int => 'a) => array('a);
第一个参数指定数组的长度。函数~f将根据索引映射出对应的初始值。例如:
# ArrayLabels.init(~f=i=>i, 3);
- : array(int) = [|0, 1, 2|]
# ArrayLabels.init(~f=i=>"abc".[i], 3);
- : array(char) = [|'a', 'b', 'c'|]
ListLabels.length()返回数组的长度:
# ArrayLabels.length([| "a", "b", "c" |]);
- : int = 3
这就是读取和写入数组元素的方式:
# let arr = [| "a", "b", "c" |];
let arr: array(string) = [|"a", "b", "c"|];
# arr[1]; /* read */
- : string = "b"
# arr[1] = "x"; /* write */
- : unit = ()
# arr;
- : array(string) = [|"a", "x", "c"|]
模式匹配数组类似于匹配元组,而不是匹配列表。让我们从元组和列表开始(我们可以忽略exhaustiveness警告,因为我们正在处理固定数据):
# let (a, b) = (1, 2);
let a: int = 1;
let b: int = 2;
# let [a, ...b] = [1, 2, 3];
Warning: this pattern-matching is not exhaustive.
let a: int = 1;
let b: list(int) = [2, 3];
接下来我们将解构一个数组:
# let [| a, b |] = [| 1, 2 |];
Warning: this pattern-matching is not exhaustive.
let a: int = 1;
let b: int = 2;
与元组类似,模式必须与数据具有相同的长度(这是例外情况):
# let [| a, b |] = [| 1, 2, 3 |];
Warning: this pattern-matching is not exhaustive.
Exception: Match_failure
这就是如何在列表和数组之间进行转换:
let to_list: array('a) => list('a);
let of_list: list('a) => array('a);
有时候使用数组处理数据会比列表更方便,然后你又可以再把它转换为列表(如果需要的话,再转换回数组)。
标准库仍处于不断变化之中。因此,这里只会演示一些重要的方法。
map()对于数组的处理和列表是类似的:
# ArrayLabels.map(s => s ++ "x", [| "a", "b" |]);
- : array(string) = [|"ax", "bx"|]
fold_left()也与列表的类似:
let maxOfArray = (arr) =>
ArrayLabels.fold_left(~f=max, ~init=min_int, arr);
这是maxOfArray()的用法:
# maxOfArray([||]);
- : int = -4611686018427387904
# maxOfArray([|3, -1, 5|]);
- : int = 5
这里我又一次见到了从二元到n元的变化 max() -> maxOfArray()。对max()而言,我们还使用了整数常量min_int。两者都属于Pervasives模块。
max 是一个适用于大多数类型的二元函数:
# max(1.0, 1.1);
- : float = 1.1
# max(None, Some(1));
- : option(int) = Some(1)
# max("a", "b");
- : string = "b"
# max(4, -3);
- : int = 4
min_int 是int的最小取值(它的确切值和当前的平台有关):
# min_int;
- : int = -4611686018427387904
fold_right()的原理和fold_left()类似,但它是从最后一个元素开始的。它的类型签名是:
let fold_right: (~f: ('b, 'a) => 'a, array('b), ~init: 'a) => 'a;
此功能的一个使用场景是将数组转换为列表。该列表必须按照以下方式构建(即,你必须从最后一个数组元素开始):
[··· [x_2nd_last, ...[x_last, ...[]]]]
像这样:
let listFromArray = (arr: array('a)) =>
ArrayLabels.fold_right(~f=(ele, l) => [ele, ...l], arr, ~init=[]);
这是listFromArray()的实际用例:
# listFromArray([||]);
- : list('a) = []
# listFromArray([| 1, 2, 3 |]);
- : list(int) = [1, 2, 3]
# listFromArray([| "a", "b", "c" |]);
- : list(string) = ["a", "b", "c"]
所有数组的处理函数都会返回与输入数组长度相同的数组。因此,如果你想删除数组的元素,你必须借助列表:
let filterArray = (~f, arr) =>
arr
|> ArrayLabels.to_list
|> ListLabels.filter(~f)
|> ArrayLabels.of_list;
filterArray() 实例:
# filterArray(~f=x=>x>0, [|-2, 3, -4, 1|]);
- : array(int) = [|3, 1|]
扫码关注w3ctech微信公众号
共收到0条回复