#Scala/Chapter3#
Chapter3 Functional data structures (函数式数据结构)
我们讲函数型程序并不更新 变量或者修改 可变的数据结构 那我们会提出疑问:
- 在函数式编程里面 什么样的(what sort of )数据结构 我们可以用
- 我们如何在 Scala 中定义他们
- 我们如何操作(operate)这些数据结构
在这章我们将会学习函数式 数据结构 (functional data structures )的概念和如何使用他们。我们将借此机会介绍 在函数式编程里 数据类型(data type)是如何定义的,学习 模式匹配(pattern matching)相关的技术,练习编写和归纳 纯粹的函数(pure functions)。
3.1 Defining functional data structures (定义 函数式数据结构)
一个函数式数据结构只有在使用 纯粹的函数(pure functions)的时候使用。记住,一个纯粹函数必须不能直接改变data 或者 执行其他的 side effect (附加的东西 比如在一个买咖啡的函数里面,执行连接信用卡server的代码 这个就属于 side effect)。
所以 函数式数据结构定义的内容是不可改变的。
例如:在Scala里的 List() 和 Nil 就是不可变的。在进行List 操作的时候 比如 计算 3 + 4 我们并不改变 3或者4本身 而是新生成一个变量 7. 两个input 都不改变。连接两个链表操作也是啊, append 链表A 和 链表B 他并不改变输入本身,而是新建立一个链表。
这是不是意味着我们做了很多额外的复制工作呢?其实不是的。首先我们看一下最普遍的函数数据结构,单链表 (Singly linked list)。
|
|
sealed trait 是什么意思?
trait 是一个抽象的接口(abstract interface) 可以选择性的包含一些方法的实现。在这里声明一个 trait 叫做 List ,不含有任何方法。添加sealed在前面意味着 这个 trait 的所有的实现必须在这个文件里面声明。
这里面有两种实现 或者说 List 的 数据 构造(data constructors) 来代表 List 可以使用的的两种形式。
一个List 可以为空,由 Nil 来指示,不为空的时候由 Cons来指示。一个不为空的List 由 | Head | Tail | 来组成。Tail 是 表示剩余元素的一个List 。
正因为函数可以是多态的,数据类型也一样,通过添加 类型参数(type parameter) [+A] 在sealed trait List 后面 然后在 Cons data constructor 里面使用这个 A 类型的参数,我们声明 这个 List 数据类型为多态的意味着 我们可以使用相同的定义 对一个 Int 元素 List[Int], Double 元素(List[Double]), String 元素(List[String]) 等等。“+” 意味着 类型参数 A 是 协变的,共变的(Covariant). 意味着 A 是一个List 的 共变,积极参数。(个人理解这句话的意思是:List 和 参数 A 是共同, A 是Int,List 类型就是 List[Int], 就怎么说呢 他俩一起变化,一起等价这种概念吧)
例如:
Covariant: For all types x and y .
- if x <: y, then List[x] <: List[y] (<:表示继承关系中的辈分高低)
- If not annotated, parameter is invariant, meaning there is no subtype relationship List[x] and List[y]
就是如果 参数有父子关系的话,那么List 也具有 父子关系。(个人理解)
|
|
一个数据构造函数声明 给我们一个方法去构造 这个数据类型的形式。例如:上面的三个👆式子。
Case object Nil 让我们写一个Nil去构造一个空的List , case class Cons 让我们写 Cons(“a”, Cons(“b”, Nil)) 去构造一个任意长度的单链表。
注意:因为List 是参数化的,A, 对A,这里有 可以被不同的类型实例化的多态函数。 ex2 实例化 A 类型的参数到 Int , ex3 实例化为 String,ex1 很有趣,Nil 是被实例化为 List[Double]类型了,这是被允许的,因为空的List 没有元素,可以被看做任意类型。
“老艺术家”说 Nil 定义的时候是 继承了 List[Nothing] 这是所有List 类的 最小辈分的一种,可以是任何类的子类。顺便拓展一下 :
继承关系: Animal类 <- Cat类 <- Kitty类 ————————
Animal
————————
^
————————
Cat
————————
^
————————
Kitty
————————
返回类型问题和参数类型的问题:
先说 ~返回类型~ 哦,
亲子关系: Animal类 <- Cat类 <- Kitty类 ,
那么在内存里的空间状态是:[ ] < [ ] < [ ] 顺序是相反的,因为子类要保证父母能有的成员变量,成员方法自己都持有,但是子类持有的 变量和方法,父母类就不一定有了。所以返回的时候你要返回Kitty类型,ok,没有问题,因为他的空间包含了Cat 所需要的空间大小,但是返回 Animal 惨了,Animal的比 Cat 小哎,调用的时候 会有问题。换个人性化思维思考:父类里面的foo是猫,那么子类的 foo 只可能是 猫这个大类里面的小类别:波斯猫,kitty猫,你返回一个爬行哺乳动物就不对了。
~参数类型~ :参数是用来做 operation 的,那么父类的参数是Cat ,显然他需要用猫的某些技能或者特性,比如喵喵叫,舔自己,闻屁股,那么Bar 中的参数必然也要最起码满足这个要求,但是不许超出这个范围,你input 一个kitty特有的粉红色是猫这个类不具有的性质,那么就有问题了。
要保证在这里调用的东西,父类方法里面最起码都有。
综上所述:返回类型可以使本身,或者子类 往下走;参数类型可以是自己或者父类 往上走。好像延展的有点多了。。。
所以~~ Nil 是 List[ Nothing ], 在 Scala 里面,Nothing 是所有类型的子类,那么Nil 就是所有 List 类型的子类,所以 Nil 可以赋给任何类型的List 值。
添加补充一下:
Scala Convariance and Contravariance(逆变与协变)
Function范式里面定义了函数的”入参”和”出参” 分别是“逆变”(Contravariance)和”协变”(Convariance)的。
所以A=>B这样的函数类型,也可以有继承关系的。
我们做个测试,先简单些,只看出参类型的(协变容易理解些),A=>B和A=>C两个函数类型;
如果C extends B 则A=>C 是 A=>B的子类型
再看看入参类型,这个是逆变,继承关系正好相反。
假设: B=>A, C=>A 如果 C extends B 则 B=>A 是 C=>A 的子类型
协变和逆变的场景与java泛型的”PECS原则”一致
PECS 是Joshua Bloch在《Effictive Java》里提出的一个原则。
当参数(容器)是一个生产者(producer)提供元素给你的代码来用(即容器只读),那么容器的泛型应该使用:Collection< ? extends T >
当参数(容器)作为一个消费者(consumer)来消费你提供的数据(即容器可写),那么容器的泛型应该使用:Collection< ? super T >
|
|
每一个 数据构造器 同样引入 模式(pattern) 可以被用于 模式设计,就像在 sum 和 product 方法里一样
3.2 Pattern matching (模式匹配)
仔细的看 sum 和 product 的细节,我们放入了 object List 里面,有时候叫做 Object List 的 伴生对象 (companion object)。
伴生对象: 我们除了声明 ~数据类型~ 和 ~数据构造函数~ 之外常常声明 ~伴生对象~。 这是和数据类型有着一样的名字(这里是 List ),我们放入多种方便的函数 到对象中 为了创建或者使用这种数据类型的值。(这句话怪哦)
例如我们希望一个函数 def fill[A](n:Int, a:A):List[A] 创建 一个List 里面有n个元素a,这个List 的伴生对象将会是一个好的地方。伴生对象不仅是在Scala里面的一种约定(Convention)。我们可以叫 这个 模块 Foo 如果我们希望,但是叫他 List 会很明确的显示 这个模块包含着关于 list 的一些方法。
下面的定义都使用了 模式匹配(pattern matching):
|
|
这些是递归定义,写方法的时候 Scala等函数式编程很喜欢递归。Pattern matching 有点像 switch 文, 首先有一个 expression 像 ds 在 match 前面,然后每个 case 会有各自相应的 pattern 像 Cons(x,xs) 然后 => 后面跟的是 结果。如果 target expression 满足某个case 里的 pattern 那么就实现pattern 对应的 结果。如果 有 满足多个case 的情况,选择最先匹配到的 pattern 来执行。
看一下更多的例子:
List(1,2,3) match {case _ => 42}输出值为42,因为 ‘_’ 意味着无论是什么值,都可以匹配,任意一种情况都会执行这个pattern。List(1,2,3) match {case Cons(h,_) => h}这种情况会输出列表中的头,也就是说1,这种模式把List 中的 Head 和 Tail 分开了,值得我们学习哦。List(1,2,3) match {case Cons(_,t) => t}这段代码则是不管Head 是什么,我只要Tail 并且输出Tail 所以输出结果是 List(2,3)List(1,2,3) match {case Nil => 42}会导致 MatchError错误发生。
List(1,2,3) = Cons(1,Cons(2,Cons(3, Nil )))
那是什么决定了是否一个 pattern 匹配一个 expression 呢? 一个 pattern 可能包含 ~常量~ 像 3 或者 “hi” ; ~变量~ 像 x 和 xs,匹配任何事情,由一个小写字母或者下划线开始的标识符标识; ~数据构造函数~ 像 Cons(x,xs) or Nil这种 只匹配相对应格式的值。这些模式的元素们可以被任意嵌套 — Cons(x1, Cons( x2, Nil)) , Cons(y1, Cons(y2, Cons(y3, _))) 是合法的模式。
一个模式匹配一个目标(如果在模式中存在一个变量的分配)到 这个目标的子表达式 (使其结构上和目标相等的 子表达式 ),对于一个匹配的 case , 结果得到的表达式 接下来将会 在他的本地范围里面 access 这些变量的赋值 。(哇塞 英文好难直译啊 T.T)
总结一下这段话:
问题:什么决定了是否一个 模式 匹配一个表达式呢?
答案:一个模式 可能包含 :常量,变量,数据构造器(怪翻译)。模式中的这些元素们可以互相组合嵌套。
尚未总结完毕
Scala 中的可变函数(Variadic functions in Scala)
List 对象中的 apply 函数 就是一个 ~可变函数~ 意味着它允许或者说接受 0 或者更多A类型的参数:
对于 数据类型,这是一个常见的俗话去有一个可变的apply 方法 在伴生对象中方便的构建数据类型的实例。通过调用这个方法 apply 然后在伴生对象中替换他,我们可以和语法一样调用它,就像 List(1,2,3,4) 或者 List(“Hi”,”bye”), 和许多我们希望用逗号分开一样。
如上图所示,调用 apply函数会把用逗号分开的值变成列表。
Variadic functions(可变函数)就是为 明确的创建和passing 一个元素序列( Seq of elements)提供一个语法糖🍬(syntactic sugar)。序列(Seq)是一个接口,在Scala库中,像 list, queue, vector 一样的一种数据结构。在 apply 里面 参数 as 将会被绑定成 Seq[A], 这个对象有着 head 方法 和 tail 方法。
特别的 _* 类型标注 允许我们 通过(pass) 一个 Seq 到一个可变成员方法。
3.3 Data sharing in functional data structures (函数式数据结构中的数据共享)
当数据是不可变的时候,我们如何写函数,例如 从一个 List 中 add 或者 remove 元素?答案很简单,当我们添加一个元素1 到 一个列表的前头的时候,比如xs, 我们返回一个新的列表 Cons(1,xs)。 因为列表是不可改变的,我们不需要实际上的复制xs,只需要重新利用它,创建新的列表。我们叫它数据共享( Data sharing)。共享不可改变的数据常常使我们实现函数更加的高效;我们总可以返回不可改变的数据结构而不需要担心后面的数据修改我们的数据。并不需要悲观的通过复制不可变数据结构去避免修改和变形。
用同样的方式,从一个List mylist = Cons(x,xs)中移除头一个元素,我们只需要复制他的 tail就可以了。而 mylist 依旧存在和可用。我们说函数式数据结构是可持续的(persistent), 意味着既有的参照关系绝对不会因为在数据结构上的操作而改变。
数据共享的效率(The efficiency of data sharing)
数据分享的一个非常令人吃惊的例子就是 函数添加一个列表所有的元素到另一个列表的尾部:
|
|
请注意这个定义仅仅是复制值 直到第一个链表的尾部,所以他的运行时间和内存利用是仅仅取决于 a1 的长度的。剩下的列表仅仅是指向 a2。如果我们要用两个数列(array)执行同样的操作的话,我们需要拷贝两个array 的所有的元素到输出上。所以在这种情况下,不可变链表是比数列更加高效的。
因为单链表的结构,任意时刻我们想要替换一个Cons的tail的时候,即使他是列表中的最后一个Cons,我们需要拷贝前面所有的Cons对象。写支持不同的高效操作的纯粹函数式数据结构需要我们找到一种聪明的方法去使用数据共享。现在我们并不准备自己亲自做所有的部分,我们可以很开心的使用Scala标准库,这里有定义好的纯粹函数式序列(pure functional sequence)的实现, ~Vector~ 。有着在常数时间下,随机的access(访问), updates, head,tail,init这些成员方法,常数时间下添加序列的头和尾。
提高高阶函数的类型推断
高阶函数像 dropWhile 常常会被 匿名函数(anonymous functions) pass掉。看一个典型的例子:(dropWhile 函数式 当条件式子为假那么开始 不在看元素是否符合条件了,开始迭代序列 ,
即:符合条件的时候继续扫描下一个元素,直到条件不符合,返回剩下的元素的列表)
|
|
|
|
设计思路:match的时候可以 case Cons(x,y) if(f) 满足条件的时候要同时满足条件。
当我们用 匿名函数 f 调用这个方法的时候,我们必须区分 f 的参数 这里叫 x:
|
|
这里如果不注释 x 的类型为 Int 的话会报错。
非常不幸的是我们需要规定 x 的类型是 Int。dropWhile的第一个参数是一个List[Int], 所以函数的第二个参数必须符合Int。这是之前声明的时候定义好的。Scala 可以推断这个事实,如果我们把 dropwhile 归入到两个参数的链表中:
|
|
调用这个版本 dropWhile 函数的语法像 dropWhile(xs)(f)。其实就是啦,dropWhile(xs) 返回一个函数,我们利用 f 来作为参数 调用这个函数,其实就是dropWhile 被curry化了。这样grouping 参数的原因是为了帮助 类型推断。我们现在可以不用注释的使用dropWhile函数了:
|
|
这里的 x 就没有注释 x 的类型。
更普遍的,当一个函数定义里面包含着多种参数 group,类型信息从左到右的贯穿这些参数 groups。这里第一个参数group 固定了 parameterA 为Int,所以他右边的都是Int,x就不需要注释了。
我们通常将我们函数的参数 group 和 排序到 多重参数列表中来最大化 类型推论。
3.4 Recursion over lists and generalizing to higher-order functions (通过链表的递归调用和概述高阶函数)
让我们重新看一下 sum 和 product 的实现。我们非常 轻盈的实现了 product 从而 没有包含了检查 “ 0.0 逻辑 “的问题。
|
|
有没有注意到这两个函数是多么的相似,处理逻辑几乎一样。只是 处理数据的类型 一个是 Double 一个是 Int,撇开这个来看 不同点一个是 Nil 情况 返回值不同( 0 and 1.0),操作符号不同(+ and * )。
无论何时,遇到这种重叠的情况,你都想把这种重叠 抽象出来,把子表达式拽出来作为参数。如果一个子表达式关联任何本地变量( + 关联 本地变量 x 和 xs ,product也一样),把这个子表达式 放入采用这些变量作为参数的函数中。
|
|
- 把 f 放入他自己的参数 group 在 as 和 z 之后,让 参数推断决定f 输入类型。
- * 是 (x, y) => x * y 更简明的一种注释。
你看我们在编程的时候很多方法都有共同点,他们可能都有一个可以归纳的核心逻辑,然后把这个核心逻辑抽离出来就变成foldright了。
foldRight (右折叠)没有指定任何一种元素的参数类型,我们发现 当概括/一般化之后,返回值并不一定是元素一个类型的。一种描述 foldRight 做了什么就是: 他替换了List 的 构造器,用 z 和 f 替换了 Nil 和 Cons :
|
|
foldRight 遍历所有的路径,直到list 末尾。
为什么说foldRight 可能会有 栈溢出,因为他是 一层一层的迭代,在扫描到最后一个数之前所有的数都要留着,从最后一个数开始计算然后在往回一层一层计算,所以会有栈溢出的可能性。
再分析一下foldRight
两个参数()(), 所以前面foldRight[A][B], 看参数里面(List[A],z:B)这个没啥说的
(f: (A,B)=>B ) 这个指的是 里面的参数是一个函数:参数为A 和 B,A为List中的元素类型,B就是z的类型,最后得出结果为类型B。然后里面的body也是很棒,返回 Cons(x,xs) => f( x, foldRight(xs,z)(f) ) 返回的是列表的第一个元素和后面的迭代的值经过 f 操作。
下面看看foldLeft
|
|
你在看看这个左折叠的 z 的值是每次迭代都会更新一次的,然后每次的这个更新后的值在带入下一个函数里面,所以这个就不会有栈溢出的问题。
很神奇哦,就是明明是顺序运算但是却 写成这个样子。。
在这里他用List 的第一个的元素和参数z 进行 f 运算然后放入第二次迭代的参数进行第二次迭代。所以可以看做是一边运算一遍进行迭代。而foldright 则是一直迭代到底然后在返回来。
两者有啥区别??
(考点!!)List.reverse 教授好像说会考 List 的 Reverse ~~
分别利用 dropLeft 和 dropRight 来编写 reverse函数:
|
|
|
|
拆开 foldLeft和foldRight 版本的reverse
|
|
思路:
利用foldRight和foldLeft的 原理和性质。
Left呢就是利用第一个元素和初始值进行计算然后带入到第二个元素计算中的初始值中,迭代计算。
Right呢就是 先不进行运算,先迭代到最底层然后从最后一个元素和初始值进行运算在返回来的这么一个过程。
匿名函数的下划线注释(Underscore notation for anonymous functions)
匿名函数(x,y) => x+ y 可以写成 + ,在 x 和 y 的类型可以被Scala 推测出的环境下。 这个非常实用,在case里面速写的时候,条件:在函数body中 参数只被提到一次的时候。在匿名函数中的每个下划线 像 + 引入了一种新的函数参数和参照它。参数以 从左至右的顺序被介绍。例如 x, y都只用了一次,顺序是 x, y 所以 可以 , 来替代
下面有一些例子:
|
|
请明智的使用这个语法。在表达式中这个语法的意义像 foo( , g(List( + 1), _ ))可能不会很清楚。关于这些 基于下划线的 匿名函数的 scope 有严格的 规则,在Scala 细则里面,除非你必须使用它,我们建议使用普通的参数命名规范。
(考点!!)List.flatten
flatten函数就是把List(List(1,2,3), List(“a”,”b”,”c”),List(a,b,c))摊开了变成一个LIst(1,2,3,”a”,”b”,”c”,a,b,c)……
(考点!!)List.join
|
|
(考点!!)Map
引子:想要在list 中的每个元素都➕1 怎么写?
|
|
implicit class 是啥?
List[Double] -> List[String]
仔细观察这两个函数 发没发现 共同点:他们都没有改变原有List 的结构,只是单纯的改变了List 中 每一个元素的 值或者属性。这个跟foldright或者foldleft不太一样,fold系列改变了 List 的结构,拆开表格之后进行了这那那这的操作。
|
|
|
|
这里再说一嘴,foldRight 和 foldLeft是 scala里面 很重要的两个函数,它俩啥都能做。会考哦~~
那究竟什么是 map ,而map 为什么在scala中这么重要呢?
map 有两个输入,一个是 List 一个是 函数 f , 他把list 中的每一个元素进行了f函数的处理之后 返回 这个原来的List。但是要说的是,原有的List 大结构可能不变,但是多少会变,比如
这个例子输入的List类型是List[Int] 但是出来之后变成List[Any]了,只是结构还是三个元素这个不变而已。
|
|
yield是什么呢?yield 是跟在for 循环后面常常使用的一个关键字,然后他会把for中的 项记载下来在循环结束之后返回,类型和输入的类型是一样的。
|
|
其实哦,flatMap 和 Map 基本一样,唯一的不一样就是 它把Map 给 flatten了,也就是把里面的嵌套List结构都打散,变成了一个List。
在试图用 foldRight 和 foldLeft重写的时候 你要知道的是,这里面类型最最重要。
思路:
- 首先 目标是:把元素里的每个元素都经过 f 函数变换,然后用append函数 将 List 变为一个,而没有嵌套List结构。所以 foldRight 和 foldLeft函数都符合范围。
- 其次我们思考格式匹配:123def flatMap2[A,B](as:List[A])(f: A => List[B]): List[B] =def foldRight[A,B](as: List[A], z: B)(f: (A,B) => B) :Bdef foldLeft[A,B](as:List[A],z:B)(f :(B,A) => B):B
因为我们要使用这两个函数,那么必须知道这两个函数的 参数类型和返回值类型,哇塞,能想到这个就已经算是具备一定编程思想的银了哎。
那不论foldRight还是foldLeft 第一个参数是不需要思考的 一样的,都是as,而且初始值 是 Nil 也没有错。 所以 :foldRight(as,List.empty[B]) 或者:foldLeft(as,List.empty[B])
然后,我们思考 f 操作,foldRight的 f : (A,B) =>B 那么我们希望append(f(第一个元素),后面的List ) 是结果变成:append(1,append(2,append(3,Nil)))这种。所以要foldRight(as,List.empty[B])((a,acc)=>append(f(a),acc))
接下来思考 foldLeft的 f 操作 : f : (B,A)=>B 那么就是 Nil 作为 f 的第一参数,和 f (List 中的 第一个元素) 做 append操作,foldLeft(as,List.empty[B])((acc,a)=>append(acc,f(a)))
但是要保持元素和Nil 的顺序,但是append 有着这么一个特性,append(Nil,a2) = a2,所以这里就算 append (Nil, f(a))也没有关系,还是f(a),
但是编程的时候 想的好复杂,left right 的内部逻辑还思考了很多,但是其实不需要思考,调用函数的时候,不许要考虑调用的函数的内部逻辑,只要参数类型对了,你选择的函数对了,就不要考虑太多了。
|
|
在利用 foldRight 和 foldLeft 编写 map 和 flatMap的时候又多了一些感悟,这种感悟是一种感觉,不是简单地看规则就有的感觉。
f 操作
先看 map 和 flatMap的 f 操作,map是 A=>B 也就是 list中的A类型元素经过 f 操作 生成 B类型的结果,注意这里不是生成了List 类型 而是B类型。
而flatMap的 f 操作生成的是 A=>List[B] 元素类型为B的List类型,他们两个产物不太一样。
这一不同直接影响到了 body 的编写,因为 foldRight 和 foldLeft 要求的f 操作的类型分别是 (A,B)=>B 和 (B,A)=>B
那么看好了,
map: foldRight(as,List.empty[B])((a,acc)=> f(a)::acc )
flatMap: foldRight(as,List.empty[B])((a,acc)=>append(f(a),acc))
这里面 (a,acc) 分别代表的是 (List中的每一个元素(A类型), Nil 和 迭代返回的B类型的 结果)。所以(a,acc)=> f(a)::acc 的 类型检测就是 (A,B)=> B::B 而从List.empty[B]或者Nil:List[B]这段代码系统就可以检测到 foldRight 的B类型是 List[B],所以 B::B 返回一个List[B] 类型没啥问题,或者 B::List[B] 这种也是 返回 LIst[B]那么这有回到了第一节课的时候,有点脑子混乱,B类型和List[B]类性有点乱。而flatMap则不可以直接 f(a)::acc而是必须 append(f(a),acc)因为多了flaten的过程。
map: foldLeft(as,Nil:List[B])((acc,a)=> append(acc,f(a)::Nil) )
flatMap: foldLeft(as,List.empty[B])((acc,a)=>append(acc,f(a)))
foldLeft的 f 操作的类型是 (B,A)=>B , 所以 后面的那个a 才是 list中的元素。所以要 f(a)。 然后为什么要 f(a)::Nil 因为append要求 添加List[A]和 List[B]。而 f(a)返回的是 B类型的参数,必须加上NIl 编程 list[B]类型的才可以运行append。
要切记的是 Cons(List(a),List(b)) 和 append(List(a),List(b))是不一样的哦。
莫纳德 拥有 flatmap 和map 。
那什么是 莫纳德?
学C++ 和 C 学 指针的时候最难
函数式方法中 莫纳德最难。。。
berrito???
|
|
flatmap就是先mapping 然后在flatting ~~
List.pure
|
|