原文:
zh.annas-archive.org/md5/483c7d8caeecdab1dabbbc736910bbe2译者:飞龙
协议:*** BY-NC-SA 4.0
第九章:行为设计模式 – 第二部分
行为设计模式组相对较大。在前一章中,我们研究了行为设计模式的第一部分,并了解了它们的目的。正如我们已经知道的,这些模式用于处理计算机程序中的行为和对象通信的建模。
在本章中,我们将继续从 Scala 的角度研究不同的行为设计模式。我们将探讨以下主题:
-
迭代器
-
中介者
-
备忘录
-
观察者
-
状态
-
模板方法
-
访问者
本章我们将要讨论的设计模式可能不像我们之前看到的一些模式那样与函数式编程相关。它们可能看起来像是 Java 设计模式的 Scala 实现,实际上也是如此。然而,这并不意味着它们是不必要的,由于 Scala 的混合特性,它们仍然很重要。
如前几章一样,我们将遵循相同的结构,给出模式定义,展示类图和代码示例,并讨论特定设计模式的优缺点。
迭代器设计模式
我们在软件项目中经常使用迭代器。当我们遍历列表或遍历集合或映射的项目时,我们使用迭代器。
迭代器设计模式提供了一种以顺序方式访问聚合对象(集合)元素的方法,而不暴露项目底层的表示。
当使用迭代器设计模式时,开发者不需要知道底层是链表、数组、树还是哈希表。
示例类图
使用迭代器设计模式,我们可以创建自己的对象,使其充当集合,并在循环中使用它们。在 Java 中,有一个名为Iterator的接口,我们可以为此目的实现它。在 Scala 中,我们可以混入Iterator特质并实现其hasNext和next方法。
对于类图和示例,让我们有一个ClassRoom类,它将支持遍历所有学生的 foreach 循环。以下图表显示了我们的类图:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/2c7a71c4-8afa-4b1a-8caf-e6f2ee9dd195.png
我们决定让我们的ClassRoom类实现Iterable,它应该返回一个迭代器,并在方法调用时返回迭代器的新实例。迭代器设计模式在图表的右侧表示。图表的其余部分是我们为了使与我们的类一起工作更简单而做的事情。
代码示例
让我们看看实现前面图表的代码。首先,Student类只是一个看起来如下所示的 case 类:
case class Student(name: String, age: Int)
我们已经在StudentIterator类中实现了标准的 Scala Iterator特质。以下是实现代码:
class StudentIterator(students: Array[Student]) extends Iterator[Student] {
var currentPos = 0
override def hasNext: Boolean = currentPos < students.size
override def next(): Student = {
val result = students(currentPos)
currentPos = currentPos + 1
result
}
}
关于迭代器,有一件事需要知道,它们只能单向工作,你不能回退。这就是为什么我们简单地使用一个currentPos变量来记住我们在迭代中的位置。在这里我们使用了一个可变变量,这与 Scala 的原则相悖;然而,这只是一个例子,并不太关键。在实践中,你可能会结合数据结构使用迭代器设计模式,而不是这种形式。我们选择迭代器的底层结构为Array的原因是数组的索引访问是常数,这将提高大型集合的性能并使我们的实现简单。
前面的代码足以展示迭代器设计模式。其余的代码在这里是为了帮助我们展示它如何被使用。让我们看看ClassRoom类:
import scala.collection.mutable.ListBuffer
class ClassRoom extends Iterable[Student] {
val students: ListBuffer[Student] = ListBuffer[Student]()
def add(student: Student): Unit = {
student +=: students
}
override def iterator: Iterator[Student] = new StudentIterator(students.toArray)
}
在前面的代码中,我们混入了Iterable特质并实现了它的iterator方法。我们返回我们的StudentIterator。
我们创建了一个自定义迭代器仅作为一个例子。然而,在现实中,你只需在ClassRoom类中实现Iterable并返回底层集合(在这种情况下是学生)的迭代器。
让我们看看一个使用我们的ClassRoom类的例子:
object ClassRoomExample {
def main(args: Array[String]): Unit = {
val classRoom = new ClassRoom
classRoom.add(Student("Ivan", 26))
classRoom.add(Student("Maria", 26))
classRoom.add(Student("John", 25))
classRoom.foreach(println)
}
}
我们混入Iterable特质的事实使我们能够在ClassRoom类型的对象上使用foreach、map、flatMap等许多其他操作。以下截图显示了我们的示例输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/2f0de080-e9ce-4cea-9d7a-39600bc53d73.png
正如你在本例中可以看到的,我们的ClassRoom类的用户对持有我们的Student对象的数据结构一无所知。我们可以在任何时候替换它(我们甚至可以从数据库中获取学生的数据),只要我们的类中还有Iterable特质,整个代码就会继续工作。
它有什么好处
迭代器设计模式在软件工程中经常被使用。它可能是最常用的设计模式之一,每个人都听说过它。它几乎与所有可以想到的集合一起使用,它很简单,并允许我们隐藏复合对象内部组织的细节。
它有什么不好
我们实现的一个明显的缺点,这显示了迭代器设计模式可能存在的问题,是在并行代码中的使用。如果另一个线程向原始集合中添加或删除对象会发生什么?我们的迭代器将不会反映这一点,并且可能由于缺乏同步而导致问题。使迭代器能够处理多线程环境不是一个简单的任务。
中介设计模式
现实世界的软件项目通常包含大量不同的类。这有助于分配复杂性和逻辑,使得每个类只做一件特定的事情,简单而不是许多复杂任务。然而,这要求类以某种方式相互通信,以实现某些特定功能,但保持松散耦合原则可能成为一个挑战。
中介设计模式的目的在于定义一个对象,该对象封装了一组其他对象如何相互交互,以促进松散耦合,并允许我们独立地改变类交互。
中介设计模式定义了一个特定的对象,称为 中介,它使其他对象能够相互通信,而不是直接这样做。这减少了它们之间的依赖性,使得程序在未来易于更改和维护,并且可以正确地进行测试。
示例类图
让我们想象我们正在为学校构建一个系统,其中每个学生可以参加多个课程,每个课程由多个学生参加。我们可能希望有一个功能,可以通知特定课程的所有学生该课程已被取消,或者我们可能希望轻松地添加或从课程中删除用户。我们可以冲动地开始编写我们的代码,并将课程列表作为 student 类的一部分,以及 group 类中的学生列表。然而,这样我们的对象将变得相互关联,并且实际上不可重用。这正是中介模式的好用例。
让我们看看我们的类图:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/5b14b59f-2fc7-40fe-9160-bbf7c61ed3cb.png
如您从前面的图中可以看到,学校是中介,它包含有关用户到组和组到用户的信息。它管理这些实体之间的交互,并允许我们使我们的 学生 和 组 类可重用,并且彼此独立。
我们已经给出了学生和课程的示例;然而,这可以很容易地应用于任何多对多关系——软件中的权限组、出租车系统、空中交通管制系统等等。
代码示例
现在我们已经展示了我们的类图,让我们来看看示例的源代码。首先,让我们看看我们有的模型类:
trait Notifiable {
def notify(message: String)
}
case class Student(name: String, age: Int) extends Notifiable {
override def notify(message: String): Unit = {
System.out.println(s"Student $name was notified with message:
'$message'.")
}
}
case class Group(name: String)
在前面的代码中,Notifiable 特性在当前示例中不是必需的;然而,例如,如果我们添加教师,那么在需要向同一组中的所有人发送通知的情况下,它将是有用的。前一个代码中的类可以有自己的独立功能。
我们的 Mediator 特性有以下定义:
trait Mediator {
def addStudentToGroup(student: Student, group: Group)
def isStudentInGroup(student: Student, group: Group): Boolean
def removeStudentFromGroup(student: Student, group: Group)
def getStudentsInGroup(group: Group): List[Student]
def getGroupsForStudent(student: Student): List[Group]
def notifyStudentsInGroup(group: Group, message: String)
}
如您所见,前面的代码定义了允许学生和组之间交互的方法。这些方法的实现如下:
import scala.collection.mutable.Map
import scala.collection.mutable.Set
class School extends Mediator {
val studentsToGroups: Map[Student, Set[Group]] = Map()
val groupsToStudents: Map[Group, Set[Student]] = Map()
override def addStudentToGroup(student: Student, group: Group): Unit = {
studentsToGroups.getOrElseUpdate(student, Set()) += group
groupsToStudents.getOrElseUpdate(group, Set()) += student
}
override def isStudentInGroup(student: Student, group: Group): Boolean =
groupsToStudents.getOrElse(group, Set()).contains(student) &&
studentsToGroups.getOrElse(student, Set()).contains(group)
override def getStudentsInGroup(group: Group): List[Student] =
groupsToStudents.getOrElse(group, Set()).toList
override def getGroupsForStudent(student: Student): List[Group] = studentsToGroups.getOrElse(student, Set()).toList
override def notifyStudentsInGroup(group: Group, message: String): Unit = {
groupsToStudents.getOrElse(group, Set()).foreach(_.notify(message))
}
override def removeStudentFromGroup(student: Student, group: Group): Unit = {
studentsToGroups.getOrElse(student, Set()) -= group
groupsToStudents.getOrElse(group, Set()) -= student
}
}
School是我们应用程序将使用的事实上的中介者。正如你所看到的,它确实做了中介者设计模式应该做的事情——防止对象直接相互引用,并在内部定义它们的交互。以下代码展示了使用我们的School类的应用程序:
object SchoolExample {
def main(args: Array[String]): Unit = {
val school = new School
// create students
val student1 = Student("Ivan", 26)
val student2 = Student("Maria", 26)
val student3 = Student("John", 25)
// create groups
val group1 = Group("Scala design patterns")
val group2 = Group("Databases")
val group3 = Group("Cloud ***puting")
school.addStudentToGroup(student1, group1)
school.addStudentToGroup(student1, group2)
school.addStudentToGroup(student1, group3)
school.addStudentToGroup(student2, group1)
school.addStudentToGroup(student2, group3)
school.addStudentToGroup(student3, group1)
school.addStudentToGroup(student3, group2)
// notify
school.notifyStudentsInGroup(group1, "Design patterns in Scala
are amazing!")
// see groups
System.out.println(s"$student3 is in groups:
${school.getGroupsForStudent(student3)}")
// remove from group
school.removeStudentFromGroup(student3, group2)
System.out.println(s"$student3 is in groups:
${school.getGroupsForStudent(student3)}")
// see students in group
System.out.println(s"Students in $group1 are
${school.getStudentsInGroup(group1)}")
}
}
上述示例应用程序非常简单——它创建了Student和Group类型的对象,并使用中介者对象将它们连接起来,使它们能够交互。示例的输出如下:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/901246e8-42dc-40***-b003-72274b151a20.png
如输出所示,我们的代码确实做了预期的事情,并且成功地将应用程序中的概念保持为松散耦合。
它的好处
中介者设计模式对于在应用程序中保持类之间的耦合松散很有用。它有助于实现简单性和可维护性,同时仍然允许我们模拟应用程序中对象之间的复杂交互。
它不是那么好的地方
在使用中介者设计模式时可能的一个陷阱是将许多不同的交互功能放在一个类中。随着时间的发展,中介者往往会变得更加复杂,这将使得改变或理解我们的应用程序能做什么变得很困难。此外,如果我们实际上有更多必须相互交互的类,这也会立即影响中介者。
记忆体设计模式
根据我们正在编写的软件,我们可能需要能够将对象的状态恢复到其先前的状态。
记忆体设计模式的目的是为了提供执行撤销操作的能力,以便将对象恢复到先前的状态。
原始的记忆体设计模式是通过三个主要对象实现的:
-
Originator: 我们希望能够恢复其状态的对象 -
Caretaker: 触发对originator对象进行更改的对象,并在需要时使用memento对象进行回滚 -
Memento: 带有原始对象实际状态的对象,可以用来恢复到先前的某个状态
重要的是要知道,memento对象只能由原始对象处理。看护者和所有其他类只能存储它,不能做其他事情。
示例类图
记忆体设计模式的一个经典例子是文本编辑器。我们可以随时撤销所做的任何更改。我们将在类图和示例中展示类似的内容。
以下是一个类图:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/149e***52-915e-4584-bbc3-aab6c184eafc.png
正如您在前面的图中可以看到,我们的保管者是TextEditorManipulator。它在每次操作时都会自动将状态保存在状态栈中。TextEditor实现了Originator,并创建了一个memento对象,并从其中恢复。最后,TextEditorMemento是我们文本编辑器将用来保存状态的具象memento对象。我们的状态只是编辑器中当前文本的字符串表示。
代码示例
在本小节中,我们将逐行分析文本编辑器代码,并看看备忘录设计模式如何在 Scala 中实现。
首先,让我们看看Caretaker、Memento和Originator特质:
trait Memento[T] {
protected val state: T
def getState(): T = state
}
trait Caretaker[T] {
val states: mutable.Stack[Memento[T]] = mutable.Stack[Memento[T]]()
}
trait Originator[T] {
def createMemento: Memento[T]
def restore(memento: Memento[T])
}
我们使用了泛型,这使得我们可以在需要实现备忘录设计模式时多次重用这些特质。现在,让我们看看我们应用中必要的特质的特定实现:
class TextEditor extends Originator[String] {
private var builder: StringBuilder = new StringBuilder
def append(text: String): Unit = {
builder.append(text)
}
def delete(): Unit = {
if (builder.nonEmpty) {
builder.deleteCharAt(builder.length - 1)
}
}
override def createMemento: Memento[String] = new TextEditorMemento(builder.toString)
override def restore(memento: Memento[String]): Unit = {
this.builder = new StringBuilder(memento.getState())
}
def text(): String = builder.toString
private class TextEditorMemento(val state: String) extends Memento[String]
}
前面的代码显示了实际的Originator实现以及Memento实现。通常,将备忘录类创建为私有于创建和从类中恢复的对象,这就是我们为什么这样做的原因。这样做的原因是,原始者应该是唯一知道如何创建和从memento对象中恢复,以及如何读取其状态的人。
最后,让我们来看看Caretaker的实现:
class TextEditorManipulator extends Caretaker[String] {
private val textEditor = new TextEditor
def save(): Unit = {
states.push(textEditor.createMemento)
}
def undo(): Unit = {
if (states.nonEmpty) {
textEditor.restore(states.pop())
}
}
def append(text: String): Unit = {
save()
textEditor.append(text)
}
def delete(): Unit = {
save()
textEditor.delete()
}
def readText(): String = textEditor.text()
}
在我们的实现中,保管者公开了用于操作originator对象的方法。在每次操作之前,我们将状态保存到栈中,以便在将来需要时能够回滚。
现在我们已经看到了我们示例的所有代码,让我们看看一个使用它的应用:
object TextEditorExample {
def main(args: Array[String]): Unit = {
val textEditorManipulator = new TextEditorManipulator
textEditorManipulator.append("This is a chapter about memento.")
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
// delete 2 characters
System.out.println("Deleting 2 characters...")
textEditorManipulator.delete()
textEditorManipulator.delete()
// see the text
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
// undo
System.out.println("Undoing...")
textEditorManipulator.undo()
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
// undo again
System.out.println("Undoing...")
textEditorManipulator.undo()
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
}
}
在前面的代码中,我们只是手动向我们的文本编辑器添加了一些文本,删除了一些字符,然后撤销了删除操作。下面的截图显示了此示例的输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/6e153bbc-6ed5-4353-a2f8-c4d2bc73f28b.png
我们应用程序设计中可能需要改进的一个可能问题是states栈——我们没有绝对的限制,如果有很多更改,它可能会变得很大。在真实的文本编辑器中,我们不能无限回退,这个栈限制在一定的操作数内。另一个性能问题可能是我们在每次操作中都调用内部StringBuilder的toString方法。然而,传递实际的StringBuilder可能会对应用程序产生不良影响,因为更改将影响所有构建器的引用。
它的优点
备忘录设计模式对于想要支持可撤销状态的应用程序非常有用。在我们的例子中,我们使用了一个状态栈;然而,这并不是必需的——某些应用程序可能只需要保存最后一次操作。
它的缺点
开发者在使用备忘录设计模式时应小心。如果可能,他们应该尝试将状态保存在值对象中,因为如果传递了一个可变类型,它将通过引用被更改,这会导致不希望的结果。开发者还应小心允许更改可撤销的时间跨度,因为保存的操作越多,所需的内存就越多。最后,Scala 是不可变的,备忘录设计模式并不总是与语言哲学相一致。
观察者设计模式
有时,某些对象对另一个对象的状态变化感兴趣,并希望在发生这种情况时执行一些特定的操作。一个常见的例子是,每当你在应用程序中点击一个按钮时;其他对象订阅点击事件并执行一些操作。观察者设计模式帮助我们实现这一点。
观察者设计模式的目的是有这样一个对象(称为subject),它通过调用它们的方法之一自动通知所有观察者任何状态变化。
观察者设计模式在大多数 GUI 工具包中都有应用。它也是 MVC 架构模式的一部分,其中视图是一个观察者。Java 甚至自带了Observable类和Observer接口。
示例类图
对于类图,让我们关注以下示例——我们有一个网站,有帖子,人们可以订阅以在添加新评论时收到通知。以下图表显示了如何使用观察者模式表示类似的东西:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/8a5ebb64-ca4e-4f47-91c7-c15754f0b16f.png
Post类是我们的可观察对象,它具有User类型的观察者,每当帖子发生变化时(在我们的例子中,当添加评论时)都会收到通知。
注意,前面的场景只是一个例子。在现实中,订阅可以在数据库中完成,人们会收到电子邮件通知。然而,如果我们谈论的是你在网站上的一些通知,那么这个例子是有效的。
在示例中,观察者模式可以被(并且可能应该被)Scala 中使用 Akka 和 actor 的响应式编程所取代。这样,我们可以实现更好的可伸缩性,并实现一个适当的异步发布-订阅系统。
在以下子节中,我们将查看代表前面图表的代码。
代码示例
现在,让我们通过所有代表前面图表的代码。首先,让我们看看Observer接口。我们决定将其作为一个可以混合到任何类中的特质:
trait Observer[T] {
def handleUpdate(subject: T)
}
这非常简单。接下来,我们将看看Observable类。它是一个可以混合使用的特质,可以使类变得可观察:
trait Observable[T] {
this: T =>
private val observers = ListBuffer[Observer[T]]()
def addObserver(observer: Observer[T]): Unit = {
observers.+=:(observer)
}
def notifyObservers(): Unit = {
observers.foreach(_.handleUpdate(this))
}
}
在前面的代码中,我们使用了自类型以确保我们限制Observable特质的混合方式。这确保了参数化类型将与我们要混合的对象的类型相同。
我们对Observer接口的实现将是我们的User类。它有以下代码:
case class User(name: String) extends Observer[Post] {
override def handleUpdate(subject: Post): Unit = {
System.out.println(s"Hey, I'm ${name}. The post got some new ***ments: ${subject.***ments}")
}
}
它就像实现一个方法并与更改的Post主题进行交互一样简单。
***ment类只是一个简单的模型类,没有特别之处:
case class ***ment(user: User, text: String)
Post类将是Observable。每当添加评论时,这个类将通知所有已注册的观察者。代码如下:
case class Post(user: User, text: String) extends Observable[Post] {
val ***ments = ListBuffer[***ment]()
def add***ment(***ment: ***ment): Unit = {
***ments.+=:(***ment)
notifyObservers()
}
}
所有的前述代码片段实现了我们的观察者设计模式。在示例中看到它是如何工作的很有趣。以下代码块展示了我们的类如何一起使用:
object PostExample extends LazyLogging {
def main(args: Array[String]): Unit = {
val userIvan = User("Ivan")
val userMaria = User("Maria")
val userJohn = User("John")
logger.info("Create a post")
val post = Post(userIvan, "This is a post about the observer
design pattern")
logger.info("Add a ***ment")
post.add***ment(***ment(userIvan, "I hope you like the post!"))
logger.info("John and Maria subscribe to the ***ments.")
post.addObserver(userJohn)
post.addObserver(userMaria)
logger.info("Add a ***ment")
post.add***ment(***ment(userIvan, "Why are you so quiet? Do you
like it?"))
logger.info("Add a ***ment")
post.add***ment(***ment(userMaria, "It is amazing! Thanks!"))
}
}
我们应用程序的输出如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/8da265bf-706e-4497-9c27-6814a9f64a41.png
正如您在前面的屏幕截图中看到的,观察者设计模式很容易实现。正如我们之前提到的,更好的方法是将响应式编程用于使事物异步和更具可扩展性。它也将更加函数式。我们将在本书的后续章节中看到一个如何使用 Akka 实现的例子。
它适用于什么
观察者设计模式易于实现,并允许我们在运行时添加新的观察者或删除旧的观察者。它有助于解耦逻辑和通信,从而产生一些只有一个责任的优秀类。
它不适用于什么
在使用 Scala 的函数式编程中,人们可能会更倾向于使用 Akka 并创建一个发布-订阅设计。此外,在观察者设计模式中,对象引用被保存在主题的观察者集合中,这可能导致在应用程序或主题对象的生命周期中发生内存泄漏或不必要的分配。最后,就像任何其他设计模式一样,观察者设计模式应该仅在必要时使用。否则,我们可能会无端地使我们的应用程序变得复杂。
状态设计模式
状态设计模式实际上与我们之前章节中看到的策略设计模式非常相似。
状态设计模式的目的允许我们根据对象的内部状态选择不同的行为。
基本上,状态设计模式和策略设计模式之间的区别来自以下两个点:
-
策略设计模式是关于如何执行一个动作的。它通常是一个算法,它产生的结果与其他算法相同。
-
状态设计模式是关于什么动作被执行的。根据状态的不同,一个对象可能执行不同的操作。
实现状态设计模式也与策略设计模式的实现非常相似。
示例类图
想象一个媒体播放器。大多数媒体播放器都有一个播放按钮——当我们激活它时,它通常会改变其外观并变成暂停按钮。现在点击暂停按钮也会执行不同的操作——它暂停播放并恢复为播放按钮。这是一个很好的状态设计模式候选,其中根据播放器所处的状态,会发生不同的操作。
以下类图显示了实现播放和暂停按钮所需的功能的类:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/d6e6c64d-73fd-4078-88fe-dd5b1a99e71d.png
我们的播放和暂停实现将状态设置为相反的状态,并使我们的播放器功能正常。使用状态设计模式也使我们的代码更加优雅——我们当然可以使用 if 语句,并根据值执行不同的操作。然而,当有多个状态时,它很容易失控。
代码示例
让我们看看我们之前展示的类图的代码。首先,让我们看看State特质:
trait State[T] {
def press(context: T)
}
它非常简单,允许扩展类实现press方法。根据我们的类图,我们有两种实现:
class Playing extends State[MediaPlayer] {
override def press(context: MediaPlayer): Unit = {
System.out.println("Pressing pause.")
context.setState(new Paused)
}
}
class Paused extends State[MediaPlayer] {
override def press(context: MediaPlayer): Unit = {
System.out.println("Pressing play.")
context.setState(new Playing)
}
}
我们使它们变得简单,它们只打印一条相关消息,然后改变当前状态为相反的状态。
我们定义了一个MediaPlayer类,其外观如下:
case class MediaPlayer() {
private var state: State[MediaPlayer] = new Paused
def pressPlayOrPauseButton(): Unit = {
state.press(this)
}
def setState(state: State[MediaPlayer]): Unit = {
this.state = state
}
}
这真的是我们需要的所有东西。现在,我们可以在以下应用中使用我们的媒体播放器:
object MediaPlayerExample {
def main(args: Array[String]): Unit = {
val player = MediaPlayer()
player.pressPlayOrPauseButton()
player.pressPlayOrPauseButton()
player.pressPlayOrPauseButton()
player.pressPlayOrPauseButton()
}
}
如果我们运行前面的代码,我们将看到以下输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/a14359fd-0bc3-4a61-b240-a059bb6e8acd.png
如示例输出所示,每次按钮按下时都会改变状态,并执行不同的操作,我们使用不同的打印消息来展示这一点。
对我们应用程序的一个可能的改进是使状态对象成为单例。正如你所看到的,它们总是相同的,所以实际上没有必要每次都创建新的。
它适用于什么
状态设计模式对于使代码可读和消除条件语句非常有用。
它不适用于什么
状态设计模式没有重大缺点。开发者应该注意的一点是对象状态变化引起的副作用。
模板方法设计模式
有时候当我们实现一些算法或算法族时,我们定义一个共同的骨架。然后,不同的实现处理骨架中每个方法的特定细节。模板方法设计模式使我们能够实现我们之前提到的。
模板方法设计模式的目的是通过模板方法将算法步骤推迟到子类。
模板方法设计模式在面向对象编程中似乎非常自然。每当使用多态时,这实际上代表了设计模式本身。通常,模板方法是通过抽象方法实现的。
示例类图
模板方法设计模式适合实现框架。这里典型的事情是算法通常执行相同的步骤集合,然后不同的客户端以不同的方式实现这些步骤。你可以想出各种可能的使用案例。
对于我们的示例,让我们假设我们想要编写一个应用程序,该程序将从数据源读取一些数据,解析它,并查找是否存在满足某些条件的对象并返回它。如果我们仔细想想,我们有以下主要操作:
-
读取数据
-
解析数据
-
搜索满足条件的项目
-
如果需要,请清理任何资源
以下图表显示了我们的代码的类图:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/8daa82f0-913c-41f8-b80f-4de4a9c2e8a4.png
我们使用了一个之前展示过的示例——从文件中读取关于人的数据。然而,这里我们使用它来查找满足过滤函数的人的数据。使用模板方法设计模式,我们可以从服务器、数据库或任何想到的地方读取不同格式的文件中的人的列表。通过多态,我们的应用程序确保调用正确的方法,并且一切运行正常。
代码示例
让我们查看代表前面图表的代码,并看看它做了什么。首先,我们的Person模型类:
case class Person(name: String, age: Int, address: String)
这没有什么特别的。现在,让我们继续到有趣的部分——DataFinder类:
abstract class DataFinder[T, Y] {
def find(f: T => Option[Y]): Option[Y] =
try {
val data = readData()
val parsed = parse(data)
f(parsed)
} finally {
cleanup()
}
def readData(): Array[Byte]
def parse(data: Array[Byte]): T
def cleanup()
}
我们使用了泛型,以便使这个类适用于各种类型。正如您在前面的代码中所看到的,DataFinder类的三个方法没有实现,但它们仍然在find方法中被引用。后者是实际的模板方法,而抽象方法将在扩展DataFinder的不同类中实现。
对于我们的示例,我们提供了两种不同的实现,一个是针对 JSON 的,另一个是针对 CSV 文件的。JSON 查找器如下所示:
import org.json4s.{StringInput, DefaultFormats}
import org.json4s.jackson.JsonMethods
class JsonDataFinder extends DataFinder[List[Person], Person] {
implicit val formats = DefaultFormats
override def readData(): Array[Byte] = {
val stream = this.getClass.getResourceAsStream("people.json")
Stream.continually(stream.read).takeWhile(_ != -1).map(_.toByte).toArray
}
override def cleanup(): Unit = {
System.out.println("Reading json: nothing to do.")
}
override def parse(data: Array[Byte]): List[Person] =
JsonMethods.parse(StringInput(new String(data, "UTF-8"))).extract[List[Person]]
}
CSV 查找器有以下代码:
import ***.github.tototoshi.csv.CSVReader
class CSVDataFinder extends DataFinder[List[Person], Person] {
override def readData(): Array[Byte] = {
val stream = this.getClass.getResourceAsStream("people.csv")
Stream.continually(stream.read).takeWhile(_ != -1).map(_.toByte).toArray
}
override def cleanup(): Unit = {
System.out.println("Reading csv: nothing to do.")
}
override def parse(data: Array[Byte]): List[Person] =
CSVReader.open(new InputStreamReader(new ByteArrayInputStream(data))).all().map {
case List(name, age, address) => Person(name, age.toInt, address)
}
}
无论何时我们使用它,根据我们拥有的特定实例,find方法将通过多态调用正确的实现。通过扩展DataFinder类,可以添加新的格式和数据源。
使用我们的数据查找器现在很简单:
object DataFinderExample {
def main(args: Array[String]): Unit = {
val jsonDataFinder: DataFinder[List[Person], Person] = new JsonDataFinder
val csvDataFinder: DataFinder[List[Person], Person] = new CSVDataFinder
System.out.println(s"Find a person with name Ivan in the json:
${jsonDataFinder.find(_.find(_.name == "Ivan"))}")
System.out.println(s"Find a person with name James in the json:
${jsonDataFinder.find(_.find(_.name == "James"))}")
System.out.println(s"Find a person with name Maria in the csv:
${csvDataFinder.find(_.find(_.name == "Maria"))}")
System.out.println(s"Find a person with name Alice in the csv:
${csvDataFinder.find(_.find(_.name == "Alice"))}")
}
}
我们提供了一些示例数据文件。CSV 文件的内容如下:
Ivan,26,London
Maria,23,Edinburgh
John,36,New York
Anna,24,Moscow
以下数据是针对 JSON 文件的:
[
{
"name": "Ivan",
"age": 26,
"address": "London"
},
{
"name": "Maria",
"age": 23,
"address": "Edinburgh"
},
{
"name": "John",
"age": 36,
"address": "New York"
},
{
"name": "Anna",
"age": 24,
"address": "Moscow"
}
]
将前面的示例运行在这些数据集上会产生以下输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/b306141c-93fd-4717-8962-23606b69910f.png
我们示例中的代码使用了一个抽象类。这在某种程度上使其有些限制,因为我们只能扩展一个类。然而,将抽象类更改为特性和将其混合到类中是非常直接的。
它的优点是什么
正如你所见,每当我们在算法结构相同且提供不同实现的情况下,我们都可以使用模板方法设计模式。这对于创建框架来说是一个非常合适的匹配。
它不适用的情况
当我们使用模板方法设计模式实现的框架变得很大时,简单地扩展一个巨大的类并实现其中的一些方法会变得更加困难。在这些情况下,将接口传递给构造函数并在骨架中使用它可能是一个更好的主意(策略设计模式)。
访问者设计模式
有些应用程序在设计时,并不是所有可能的使用案例都是已知的。可能会有新的应用程序功能时不时地出现,为了实现它们,可能需要进行一些重构。
访问者设计模式帮助我们在不修改现有对象结构的情况下添加新的操作。
这有助于我们分别设计我们的结构,然后使用访问者设计模式在顶部添加功能。
另一个可以使用访问者模式的情况是,如果我们正在构建一个包含许多不同类型节点的大对象结构,这些节点支持不同的操作。而不是创建一个具有所有操作的基础节点,只有少数具体节点实现了这些操作,或者使用类型转换,我们可以创建访问者,在需要的地方添加我们需要的功能。
示例类图
初始时,当开发者看到访问者设计模式时,似乎它可以很容易地通过多态来替换,并且可以依赖于类的动态类型。然而,如果我们有一个庞大的类型层次结构呢?在这种情况下,每一个变化都将不得不改变一个接口,这将导致一大堆类的改变,等等。
对于我们的类图和示例,让我们假设我们正在编写一个文本编辑器,并且我们有文档。我们希望能够以至少两种数据格式保存每个文档,但可能会有新的格式出现。以下图显示了使用访问者设计模式的我们的应用程序的类图:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/d50c3004-14aa-4773-913b-260d42cede9b.png
正如前一个图所示,我们有两个看似不相关的层次结构。左侧代表我们的文档——每个文档只是一个不同元素的列表。所有这些元素都是Element抽象类的子类,它有一个a***ept方法,用于接受一个Visitor。右侧,我们有访问者层次结构——我们的每个访问者都将混入Visitor特质,其中包含为我们的文档元素每个都重写的visit方法。
访问者模式的工作方式是,根据需要执行的操作创建一个Visitor实例,然后将其传递给Document的a***ept方法。这样,我们可以非常容易地添加额外的功能(在我们的例子中是不同的格式),并且额外的功能不会涉及对模型的任何更改。
代码示例
让我们逐步查看实现前一个示例的访问者设计模式的代码。首先,我们有文档的模型以及所有可以构建它的元素:
abstract class Element(val text: String) {
def a***ept(visitor: Visitor)
}
class Title(text: String) extends Element(text) {
override def a***ept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Text(text: String) extends Element(text) {
override def a***ept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Hyperlink(text: String, val url: String) extends Element(text) {
override def a***ept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Document(parts: List[Element]) {
def a***ept(visitor: Visitor): Unit = {
parts.foreach(p => p.a***ept(visitor))
}
}
上述代码没有什么特别之处,只是对不同文档元素进行简单子类化,以及Document类及其包含的元素进行组合。这里的重要方法是a***ept。它接受一个访问者,由于特质的类型已知,我们可以传递不同的访问者实现。在所有情况下,它都会调用访问者的visit方法,并将当前实例作为参数传递。
现在,让我们看看另一边——Visitor特质及其实现。Visitor特质看起来就像这样简单:
trait Visitor {
def visit(title: Title)
def visit(text: Text)
def visit(hyperlink: Hyperlink)
}
在这种情况下,它具有具有不同具体元素类型的visit方法的重载。在上述代码中,访问者和元素允许我们使用双重分派来确定哪些调用将被执行。
现在,让我们看看具体的Visitor实现。第一个是HtmlExporterVisitor:
class HtmlExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getHtml(): String = builder.toString
override def visit(title: Title): Unit = {
builder.append(s"<h1>${title.text}</h1>").append(line)
}
override def visit(text: Text): Unit = {
builder.append(s"<p>${text.text}</p>").append(line)
}
override def visit(hyperlink: Hyperlink): Unit = {
builder.append(s"""<a href=\"${hyperlink.url}\">${hyperlink.text}</a>""").append(line)
}
}
它简单地根据获取到的Element类型提供不同的实现。没有条件语句,只有重载。
如果我们想以纯文本格式保存我们拥有的文档,我们可以使用PlainTextExporterVisitor:
class PlainTextExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getText(): String = builder.toString
override def visit(title: Title): Unit = {
builder.append(title.text).append(line)
}
override def visit(text: Text): Unit = {
builder.append(text.text).append(line)
}
override def visit(hyperlink: Hyperlink): Unit = {
builder.append(s"${hyperlink.text} (${hyperlink.url})").append(line)
}
}
在有了访问者和文档结构之后,将一切连接起来相当直接:
object VisitorExample {
def main(args: Array[String]): Unit = {
val document = new Document(
List(
new Title("The Visitor Pattern Example"),
new Text("The visitor pattern helps us add extra functionality
without changing the classes."),
new Hyperlink("Go check it online!", "https://www.google.***/"),
new Text("Thanks!")
)
)
val htmlExporter = new HtmlExporterVisitor
val plainTextExporter = new PlainTextExporterVisitor
System.out.println(s"Export to html:")
document.a***ept(htmlExporter)
System.out.println(htmlExporter.getHtml())
System.out.println(s"Export to plain:")
document.a***ept(plainTextExporter)
System.out.println(plainTextExporter.getText())
}
}
上述示例展示了如何使用我们实现的两个访问者。我们的程序输出如下截图所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/8869ffc0-c488-49cd-90c5-aba15deaeb2e.png
如您所见,使用访问者很简单。在我们的例子中,添加新的访问者和新的格式甚至更容易。我们只需要创建一个实现了所有访问者方法的类,并使用它。
Scala 风格的访问者设计模式
就像我们之前看到的许多其他设计模式一样,访问者设计模式可以用一种更简洁、更接近 Scala 的方式表示。在 Scala 中实现访问者的方式与策略设计模式相同——将函数传递给a***ept方法。此外,我们还可以使用模式匹配而不是在Visitor特质中拥有多个不同的visit方法。
在本小节中,我们将展示改进步骤。让我们从后者开始。
首先,我们需要将模型类转换为 case 类,以便能够在模式匹配中使用它们:
abstract class Element(text: String) {
def a***ept(visitor: Visitor)
}
case class Title(text: String) extends Element(text) {
override def a***ept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
case class Text(text: String) extends Element(text) {
override def a***ept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
case class Hyperlink(text: String, val url: String) extends Element(text) {
override def a***ept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Document(parts: List[Element]) {
def a***ept(visitor: Visitor): Unit = {
parts.foreach(p => p.a***ept(visitor))
}
}
然后,我们将我们的Visitor特质更改为以下内容:
trait Visitor {
def visit(element: Element)
}
由于我们将使用模式匹配,我们只需要一个方法来实现它。最后,我们可以将我们的访问者实现如下:
class HtmlExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getHtml(): String = builder.toString
override def visit(element: Element): Unit = {
element match {
case Title(text) =>
builder.append(s"<h1>${text}</h1>").append(line)
case Text(text) =>
builder.append(s"<p>${text}</p>").append(line)
case Hyperlink(text, url) =>
builder.append(s"""<a href=\"${url}\">${text}</a>""").append(line)
}
}
}
class PlainTextExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getText(): String = builder.toString
override def visit(element: Element): Unit = {
element match {
case Title(text) =>
builder.append(text).append(line)
case Text(text) =>
builder.append(text).append(line)
case Hyperlink(text, url) =>
builder.append(s"${text} (${url})").append(line)
}
}
}
模式匹配类似于 Java 中的 instanceOf 检查;然而,它是 Scala 的一个强大特性,并且相当常用。因此,我们的示例无需任何更改,输出将与之前相同。
接下来,我们将展示如何我们可以传递函数而不是访问者对象。我们将传递函数的事实意味着现在,我们可以将我们的模型更改为以下形式:
abstract class Element(text: String) {
def a***ept(visitor: Element => Unit): Unit = {
visitor(this)
}
}
case class Title(text: String) extends Element(text)
case class Text(text: String) extends Element(text)
case class Hyperlink(text: String, val url: String) extends Element(text)
class Document(parts: List[Element]) {
def a***ept(visitor: Element => Unit): Unit = {
parts.foreach(p => p.a***ept(visitor))
}
}
我们将 a***ept 方法实现移至基类 Element(也可以表示为特质)中,并在其中简单地调用了作为参数传递的函数。由于我们将传递函数,我们可以去掉 Visitor 特质及其实现。我们现在所拥有的就是以下示例:
object VisitorExample {
val line = System.getProperty("line.separator")
def htmlExporterVisitor(builder: StringBuilder): Element => Unit = {
case Title(text) =>
builder.append(s"<h1>${text}</h1>").append(line)
case Text(text) =>
builder.append(s"<p>${text}</p>").append(line)
case Hyperlink(text, url) => builder.append(s"""<a href=\"${url}\">${text}</a>""").append(line)
}
def plainTextExporterVisitor(builder: StringBuilder): Element => Unit = {
case Title(text) => builder.append(text).append(line)
case Text(text) => builder.append(text).append(line)
case Hyperlink(text, url) => builder.append(s"${text} (${url})").append(line)
}
def main(args: Array[String]): Unit = {
val document = new Document(
List(
Title("The Visitor Pattern Example"),
Text("The visitor pattern helps us add extra functionality
without changing the classes."),
Hyperlink("Go check it online!", "https://www.google.***/"),
Text("Thanks!")
)
)
val html = new StringBuilder
System.out.println(s"Export to html:")
document.a***ept(htmlExporterVisitor(html))
System.out.println(html.toString())
val plain = new StringBuilder
System.out.println(s"Export to plain:")
document.a***ept(plainTextExporterVisitor(plain))
System.out.println(plain.toString())
}
}
我们将访问者功能移至 VisitorExample 对象的组成部分中。在初始示例中,我们有一个 StringBuilder 作为访问者类的一部分。我们使用了柯里化函数以便能够在这里传递。将这些函数传递给 Document 结构是直截了当的。再次强调,这里的输出将与示例的先前版本完全相同。然而,我们可以看到我们节省了多少代码和样板类。
它擅长什么
访问者设计模式非常适合具有大型对象层次结构的应用程序,其中添加新功能将涉及大量重构。每当我们需要能够对对象层次结构执行多种不同操作,并且更改对象类可能有问题时,访问者设计模式是一个有用的替代方案。
它不擅长什么
正如你在我们示例的初始版本中所见,访问者设计模式可能会很庞大,包含相当多的样板代码。此外,如果某些组件未设计为支持该模式,如果我们不允许更改原始代码,我们实际上无法使用它。
摘要
在本章中,我们介绍了行为设计模式的第二组。你现在熟悉了迭代器、调解者、备忘录、观察者、状态、模板方法和访问者设计模式。你可能觉得这些纯粹是面向对象的设计模式,与函数式编程关系不大,你是对的。然而,由于 Scala 的混合性质,它们仍然与 Scala 相关,了解它们以及何时使用它们是很重要的。
本章中的一些设计模式相当常用,可以在许多项目中看到,而其他一些则相对较少见,且特定于某些用例。这些模式,结合你在前几章中学到的所有其他模式,可以一起使用,以构建优雅且强大的现实世界问题的解决方案。
在下一章中,我们将深入探讨函数式编程理论。我们将介绍一些高级概念,这些概念将展示 Scala 以及一般函数式编程语言是多么强大。
第十章:函数式设计模式 – 深入理论
Scala 编程语言是函数式和面向对象语言的混合体。大多数面向对象的设计模式仍然适用。然而,为了充分发挥 Scala 的威力,你还需要了解其纯函数式方面。当使用该语言和阅读教程或最佳实践时,开发者很可能会注意到,随着问题的复杂度增加或需要更优雅的解决方案时,单例、单子和函子等术语出现的频率更高。在本章中,我们将重点关注以下函数式设计模式:
-
单例
-
函子
-
单子
互联网上关于前面主题的资源很多。问题是许多内容都非常理论化,对于不熟悉数学,尤其是范畴论的人来说很难理解。实际上,许多开发者缺乏掌握这些主题所需的深厚数学背景,完全避免这些概念在代码中并不罕见。
根据我的经验,我所认识的绝大多数 Scala 开发者都尝试阅读本章涵盖主题的教程,并且他们发现这些主题很难理解,并最终放弃了。专家数学家似乎觉得这些概念更容易理解。然而,尽管反复尝试理解,大多数人承认他们对深入函数式编程理论并不完全适应。在本章中,我们将尝试以一种易于理解的方式呈现这一理论,并给出如何以及何时应用这一理论的想法。
抽象和词汇
编程的一大部分是抽象。我们找到常见的功能、法则和行为,并将它们封装到类、接口、函数中等,这些是抽象的,允许代码重用。然后,我们引用并重用它们以最小化代码重复和错误的可能性。其中一些抽象比其他更常见,并在不同的项目中观察到,被更多的人使用。这些抽象导致了一个共同词汇表的形成,这还额外有助于沟通和理解。每个人都知道某些数据结构,如树和哈希表,因此没有必要深入了解它们,因为它们的行为和需求是众所周知的。同样,当某人在设计模式方面有足够的经验时,他们可以很容易地看到它们,并将这些模式应用到他们试图解决的问题上。
在本章中,我们将尝试从一种将教会我们如何识别它们以及何时使用它们的角度来看待单例、单子(monads)和函子(functors)。
单例
所有幺半群、单子(monads)和函子(functors)都源自数学。关于这个主题的一个特点是,与编程类似,它试图寻找抽象。如果我们试图将数学映射到编程,我们可以考虑我们拥有的不同数据类型——Int、Double、Long或自定义数据类型。每个类型都可以通过它支持的运算和这些运算的法则来表征,这被称为类型的代数。
现在,如果我们仔细思考,我们可以识别出多个类型共有的运算,例如,加法、乘法、减法等等。不同的类型可以共享相同的运算,并且它们可以完全遵循相同的法则。我们可以利用这一点,因为这允许我们编写适用于遵循某些特定规则的不同类型的通用程序。
什么是幺半群?
在对幺半群进行前面的简要介绍之后,让我们直接进入正题,看看幺半群的正式定义:
幺半群是一个纯代数结构,这意味着它仅由其代数来定义。所有幺半群都必须遵循所谓的幺半群公理。
前面的定义绝对不足以对幺半群有一个好的理解,所以让我们在本节中将它分解成几个部分,并尝试给出一个更好的定义。
首先,让我们明确一下术语代数结构:
代数性:它仅由其代数来定义,例如,它支持的运算和它遵循的法则。
现在我们知道,幺半群仅由它们支持的运算来定义,那么让我们来看看幺半群公理:
-
幺半群包含一个
T类型。 -
幺半群包含一个结合二元运算。这意味着对于
T类型的任何x、y和z,以下都是正确的:op(op(x, y), z) == op(x, op(y, z))。 -
一个结构必须有一个单位元——
零。这个元素的特点是前一个运算总是返回另一个元素——op(x, zero) == x和op(zero, x) == x。
除了前面的法则之外,不同的幺半群可能根本没有任何关系——它们可以是任何类型。现在让我们看看一个更好的幺半群的定义,这个定义对你作为开发者来说实际上更有意义:
幺半群是一个具有结合二元运算的类型,它还有一个单位元。
幺半群规则非常简单,但它们给了我们极大的能力,仅基于幺半群总是遵循相同的规则这一事实来编写多态函数。使用幺半群,我们可以轻松地促进并行计算,并从小块构建复杂的计算。
生活中的幺半群
我们经常使用幺半群而没意识到——字符串连接、整数求和、乘积、布尔运算、列表等等,它们都是幺半群的例子。让我们看看整数加法:
-
我们的类型:
Int。 -
我们的结合运算:
add。它确实是结合的,因为((1 + 2) + 3) == (1 + (2 + 3))。 -
我们的单位元素:
0。当它被添加到另一个整数时,什么也不做。
我们可以轻松地找到类似的例子,例如字符串连接,其中单位元素将是一个空字符串,或者列表连接,其中单位元素将是一个空列表,以及其他许多例子。类似的例子可以在任何地方找到。
我们之前提到的所有内容都引出了以下 Scala 单例表示:
trait Monoid[T] {
def op(l: T, r: T): T
def zero: T
}
从这个基础特质开始,我们可以定义我们想要的任何单例。以下是一些整数加法单例、整数乘法单例和字符串连接单例的实现:
package object monoids {
val intAddition: Monoid[Int] = new Monoid[Int] {
val zero: Int = 0
override def op(l: Int, r: Int): Int = l + r
}
val intMultiplication: Monoid[Int] = new Monoid[Int] {
val zero: Int = 1
override def op(l: Int, r: Int): Int = l * r
}
val stringConcatenation: Monoid[String] = new Monoid[String] {
val zero: String = ""
override def op(l: String, r: String): String = l + r
}
}
使用之前展示的相同框架,我们可以为尽可能多的不同类型定义单例,只要它们始终满足规则。然而,你应该注意,并非每个操作都遵循单例规则。例如,整数除法—(6/3)/2 != 6/(3/2)。
我们看到了如何编写单例。但我们是怎样使用它们的?它们有什么用,我们能否仅基于我们知道的规则编写通用函数?当然可以,我们将在以下小节中看到这一点。
使用单例
在前面的章节中,我们已经提到单例可以用于并行计算,以及使用小块和简单的计算构建复杂计算。单例也可以与列表和集合自然地结合使用。
在本小节中,我们将通过示例查看单例的不同用例。
单例和可折叠集合
为了展示单例与支持foldLeft和foldRight函数的集合的有用性,让我们看看标准的 Scala 列表和这两个函数的声明:
def foldLeftB(f: (B, A) => B): B
def foldRightB(f: (A, B) => B): B
通常,这两个函数中的z参数被称为zero值,所以如果A和B是同一类型,我们最终会得到以下结果:
def foldLeftA(f: (A, A) => A): A
def foldRightA(f: (A, A) => A): A
现在看看这些函数,我们可以看到这些正是单例规则。这意味着我们可以编写一个示例,如下面的代码所示,该代码使用了我们之前创建的单例:
object MonoidFolding {
def main(args: Array[String]): Unit = {
val strings = List("This is\n", "a list of\n", "strings!")
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"Left folded:\n${strings.foldLeft(stringConcatenation.zero)(stringConcatenation.op)}")
System.out.println(s"Right folded:\n${strings.foldRight(stringConcatenation.zero)(stringConcatenation.op)}")
System.out.println(s"6! is: ${numbers.foldLeft(intMultiplication.zero)(intMultiplication.op)}")
}
}
在前面的代码中,还有一点需要注意,即对于最终结果来说,我们使用foldLeft还是foldRight并不重要,因为我们的单例具有结合操作。然而,在性能方面,这确实很重要。
前面示例的输出如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/7686cb64-3b96-44cb-849d-1d0da21aad98.png
看看前面的例子,你可以看到我们可以编写一个通用函数,该函数将使用单例折叠列表,并根据单例操作执行不同的操作。以下是该函数的代码:
object MonoidOperations {
def foldT: T = list.foldLeft(m.zero)(m.op)
}
现在,我们可以重写我们的示例并使用我们的通用函数如下:
object MonoidFoldingGeneric {
def main(args: Array[String]): Unit = {
val strings = List("This is\n", "a list of\n", "strings!")
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"Left folded:\n${MonoidOperations.fold(strings,
stringConcatenation)}")
System.out.println(s"Right folded:\n${MonoidOperations.fold(strings,
stringConcatenation)}")
System.out.println(s"6! is: ${MonoidOperations.fold(numbers,
intMultiplication)}")
}
}
当然,输出将完全相同。然而,现在事情要整洁得多,这就是当与列表一起使用时,单例可以变得有用的原因。
在前面的例子中,我们在foldLeft和foldRight函数中将A和B类型设为相同。然而,我们可能使用不同的类型构建不同的数据结构,或者我们的算法可能依赖于具有不同单例的类型,而不是我们拥有的列表类型。为了支持这种场景,我们必须添加将原始列表类型映射到不同类型的可能性:
object MonoidOperations {
def foldT: T = foldMap(list, m)(identity)
def foldMapT, Y(f: T => Y): Y =
list.foldLeft(m.zero) {
case (t, y) => m.op(t, f(y))
}
}
上述代码展示了我们的折叠函数将如何改变。这将给我们提供在列表上使用不同类型的单例实现更复杂操作的可能性。
单例和并行计算
单例操作的结合性意味着如果我们必须链式多个操作,我们可能可以在并行中进行。例如,如果我们有数字1、2、3和4,并且想要找到4!,我们可以使用之前使用的方法,这将最终被评估为以下内容:
op(op(op(1, 2), 3), 4)
然而,结合性将允许我们做以下事情:
op(op(1, 2), op(3, 4))
在这里,嵌套操作可以独立且并行地进行。这也被称为平衡折叠。一个平衡折叠的实现可能如下所示:
def balancedFoldT, Y(f: T => Y): Y =
if (list.length == 0) {
m.zero
} else if (list.length == 1) {
f(list(0))
} else {
val (left, right) = list.splitAt(list.length / 2)
m.op(balancedFold(left, m)(f), balancedFold(right, m)(f))
}
值得注意的是,在这里我们使用了IndexedSeq,因为它将保证通过索引获取元素将是高效的。此外,这段代码不是并行的,但我们已经按照之前提到的顺序改变了操作顺序。对于整数来说,这可能不会有太大的区别,但对于其他类型,如字符串,这将提高性能。原因是字符串是不可变的,每次连接都会通过分配新的空间来创建一个新的字符串。因此,如果我们只是从左侧到右侧进行操作,我们将不断分配更多的空间,并且总是丢弃中间结果。
下面的代码示例展示了如何使用我们的balancedFold函数:
object MonoidBalancedFold {
def main(args: Array[String]): Unit = {
val numbers = Array(1, 2, 3, 4)
System.out.println(s"4! is: ${MonoidOperations.balancedFold(numbers, intMultiplication)(identity)}")
}
}
结果将如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/5334da2c-125d-4bd3-a363-e82e973f12a4.png
使此代码并行化有几种方法。困难的方法将涉及编写大量的额外代码来管理线程,这非常高级。这可能值得一个章节(如果不是整本书),我们只是为更好奇的读者提及——纯函数式并行性。GitHub 上(github.***/fpinscala/fpinscala/wiki/Chapter-7:-Purely-functional-parallelism)有一些材料,通过示例很好地介绍了这个概念。
我们还可以使用大多数 Scala 集合都有的par方法。由于单例遵守的定律,我们可以保证无论底层集合如何并行化,我们总能得到正确的结果。下面的列表展示了我们折叠方法的示例实现:
def foldParT: T =
foldMapPar(list, m)(identity)
def foldMapParT, Y(f: T => Y): Y =
list.par.foldLeft(m.zero) {
case (t, y) => m.op(t, f(y))
}
与我们之前的方法相比,这两种方法的唯一区别是在使用foldLeft之前调用了par。使用这些方法与之前的方法完全相同:
object MonoidFoldingGenericPar {
def main(args: Array[String]): Unit = {
val strings = List("This is\n", "a list of\n", "strings!")
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"Left folded:\n${MonoidOperations.foldPar(strings,
stringConcatenation)}")
System.out.println(s"Right folded:\n${MonoidOperations.foldPar(strings,
stringConcatenation)}")
System.out.println(s"6! is: ${MonoidOperations.foldPar(numbers,
intMultiplication)}")
}
}
如你所预期,这里的输出将与顺序示例中的输出完全相同。
幺半群和组合
到目前为止,我们已经看到了一些例子,其中幺半群被用来提高效率并编写通用函数。然而,它们的功能更强大。原因在于它们遵循另一个有用的规则:
幺半群支持组合;如果A和B是幺半群,那么它们的乘积(A, B)也是一个幺半群。
这究竟意味着什么?我们如何利用这一点?让我们看看以下函数:
def ***poseT, Y: Monoid[(T, Y)] =
new Monoid[(T, Y)] {
val zero: (T, Y) = (a.zero, b.zero)
override def op(l: (T, Y), r: (T, Y)): (T, Y) =
(a.op(l._1, r._1), b.op(l._2, r._2))
}
在前面的代码中,我们展示了如何按照我们的定义应用组合函数。这将现在允许我们同时使用幺半群应用多个操作,我们可以组合更多,并应用更多操作。让我们看看以下示例,它将计算给定数字的和与阶乘:
object ***posedMonoid {
def main(args: Array[String]): Unit = {
val numbers = Array(1, 2, 3, 4, 5, 6)
val sumAndProduct = ***pose(intAddition, intMultiplication)
System.out.println(s"The sum and product is: ${MonoidOperations.balancedFold(numbers, sumAndProduct)(i => (i, i))}")
}
}
在前面的例子中,我们利用了map函数,因为我们的新幺半群期望一个整数元组,而不是我们数组中只有一个整数。运行这个例子将产生以下结果:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/4e95b3b7-ec3c-4c42-989c-5ca1612402d5.png
前面的***pose函数功能非常强大,我们可以用它做很多事情。我们还可以高效地计算列表中所有项的平均值——我们只需要使用intAddition幺半群两次,并将数字映射到(number, 1),以便将计数和总和一起考虑。
到目前为止,我们已经看到了如何通过操作来组合幺半群。然而,幺半群在构建数据结构方面也非常有用。只要它们的值也形成幺半群,数据结构也可以形成幺半群。
让我们通过一个例子来讲解。在机器学习中,我们可能需要从某些文本中提取特征。然后,每个特征将使用一个系数和出现次数的数值进行加权。让我们尝试找到一个可以折叠集合并给出所需结果的幺半群——即每个特征的计数。
首先,很明显我们将计算每个特征出现的次数。构建一个要计数的特征的映射听起来是个好主意!每次我们看到一个特征时,我们都会增加其计数。所以,如果我们想象我们的特征列表中的每个元素都变成一个包含一个元素的映射,我们就必须折叠这些映射,并使用我们的整数求和幺半群来对相同键的值进行求和。
让我们构建一个函数,它可以返回一个幺半群,该幺半群可以用于将项目折叠到映射中,并将任何幺半群应用于映射中相同键的值:
def mapMergeK, V: Monoid[Map[K, V]] =
new Monoid[Map[K, V]] {
override def zero: Map[K, V] = Map()
override def op(l: Map[K, V], r: Map[K, V]): Map[K, V] =
(l.keySet ++ r.keySet).foldLeft(zero) {
case (res, key) => res.updated(key, a.op(l.getOrElse(key,
a.zero), r.getOrElse(key, a.zero)))
}
}
现在我们可以使用这个幺半群来进行不同的聚合操作——求和、乘法、连接等。对于我们的特征计数,我们将不得不使用求和,以下是我们的实现方法:
object FeatureCounting {
def main(args: Array[String]): Unit = {
val features = Array("hello", "features", "for", "ml", "hello",
"for", "features")
val counterMonoid: Monoid[Map[String, Int]] = mapMerge(intAddition)
System.out.println(s"The features are: ${MonoidOperations.balancedFold(features, counterMonoid)(i => Map(i -> 1))}")
}
}
前面程序的输出将如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/97ebe6ad-1973-4819-956a-5c689477ea5a.png
我们之前定义的mapMerge函数现在可以接受任何单子,我们甚至可以轻松地创建映射的映射等,而无需额外的代码编写。
何时使用单子
在前面的例子中,我们展示了如何使用单子来实现某些功能。然而,如果我们看看前面的例子,我们可以以以下方式简化它:
object FeatureCountingOneOff {
def main(args: Array[String]): Unit = {
val features = Array("hello", "features", "for", "ml", "hello",
"for", "features")
System.out.println(s"The features are: ${
features.foldLeft(Map[String, Int]()) {
case (res, feature) => res.updated(feature,
res.getOrElse(feature, 0) + 1)
}
}")
}
}
实际上,每个例子都可以重写为类似于前面代码的表示形式。
虽然有人可能会倾向于这样做,但这可能并不总是可扩展的。正如我们之前提到的,单子的目的是实际上允许我们编写通用和可重用的代码。借助单子,我们可以专注于简单的操作,然后只需将它们组合在一起,而不是为所有我们想要的每一件事都构建具体的实现。对于一次性函数来说,这可能不值得,但使用单子肯定会在我们重用功能时产生积极的影响。此外,正如你之前看到的,这里的组合非常简单,随着时间的推移,它将帮助我们避免编写大量的代码(减少代码重复和引入错误的可能性)。
函子
函子是那些来自数学范畴论术语之一,对于数学背景较少的开发者在接触函数式编程时可能会造成很多困扰。它是单子的一个要求,在这里我们将尝试以一种易于理解的方式解释它。
什么是函子?在前一节中,我们研究了单子作为抽象某些计算的方法,然后以不同的方式使用它们进行优化或创建更复杂的计算。尽管有些人可能不同意这种方法的正确性,但让我们从相同的角度来看待函子——它将抽象某些特定的计算。
在 Scala 中,一个函子是一个具有map方法并符合几条公理的类。我们可以称它们为函子公理。
F[T]类型的函子的map方法接受一个从T到Y的函数作为参数,并返回一个F[Y]作为结果。这将在下一小节中变得更加清晰,我们将展示一些实际的代码。
函子也遵循一些函子公理:
-
恒等性:当
identity函数映射到某些数据上时,它不会改变它,换句话说,map(x)(i => i) == x。 -
组合性:多个映射必须组合在一起。如果我们这样做操作:
map(map(x)(i => y(i)))(i => z(i))或map(x)(i => z(y(i))),结果应该没有区别。 -
map函数保留数据的结构,例如,它不会添加或删除元素,改变它们的顺序等。它只是改变表示形式。
前面的法则为开发者提供了在进行不同计算时假设某些事情的基础。例如,我们现在可以安全地推迟对数据的不同映射,或者一次性完成它们,并确信最终的结果将是相同的。
从我们之前提到的内容中,我们可以得出结论,函子为其操作(在这种情况下是map)设定了一组特定的法则,这些法则必须就位,并允许我们自动推理其结果和效果。
现在我们已经为函子定义了一个概念,并展示了它们应遵循的法则,在下一小节中,我们可以创建一个所有函子都可以扩展的基本特质。
生活中的函子
在我们展示基于前一小节中展示的法则的示例函子特质之前,您可以得出结论,标准 Scala 类型如List、Option等定义了map方法的类型都是函子。
内置 Scala 类型如List中的map方法与我们这里展示的示例有不同的签名。在我们的示例中,第一个参数是函子,第二个参数是我们应用到的转换函数。在标准 Scala 类型中,第一个参数不需要传递,因为它是我们实际调用的对象(this)。
如果我们想要创建遵循函子法则的自定义类型,我们可以创建一个基本特质并确保实现它:
trait Functor[F[_]] {
def mapT, Y(f: T => Y): F[Y]
}
现在,让我们创建一个简单的列表函子,它将简单地调用 Scala List的map函数:
package object functors {
val listFunctor = new Functor[List] {
override def mapT, Y(f: (T) => Y): List[Y] = l.map(f)
}
}
在前面的代码中,一个对象是函子的这一事实仅仅允许我们假设某些法则已经就位。
使用我们的函子
使用我们之前定义的listFunctor的一个简单例子如下:
object FunctorsExample {
def main(args: Array[String]): Unit = {
val numbers = List(1, 2, 3, 4, 5, 6)
val mapping = Map(
1 -> "one",
2 -> "two",
3 -> "three",
4 -> "four",
5 -> "five",
6 -> "six"
)
System.out.println(s"The numbers doubled are:
${listFunctor.map(numbers)(_ * 2)}")
System.out.println(s"The numbers with strings are:
${listFunctor.map(numbers)(i => (i, mapping(i)))}")
}
}
前一个示例的输出显示在下述屏幕截图:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/2fcaad83-9593-40b8-bebd-d780b83c09f3.png
如您所见,函子本身并不真正做很多事情。它们一点也不令人兴奋。然而,它们设定了一些特定的规则,帮助我们理解特定操作的结果。这意味着我们可以在Functor特质内部的抽象map方法上定义方法,这些方法依赖于我们之前声明的规则。
函子是一个重要的概念,对于单子(monads)来说是必需的,我们将在下一小节中探讨。
单子
在前一小节中,我们定义了函子。通过它们的map方法,标准的 Scala 集合似乎是函子的好例子。然而,我们再次强调,函子并不意味着集合——它可以是容器和任何自定义类。基于一个抽象的map方法和它遵循的规则,我们可以定义其他函数,这些函数将帮助我们减少代码重复。然而,基于映射本身,我们并不能做很多令人兴奋的事情。在我们的程序中,我们将有不同的操作,其中一些不仅会转换集合或对象,还会以某种方式修改它们。
单子是来自范畴论的那些令人畏惧的术语之一,我们将尝试以一种你能够轻松理解、识别并在作为开发者的日常工作中使用的方式解释它。
什么是单子?
我们在本章前面已经讨论过法律了。单子是基于它遵循的一些法律来定义的,这些法律允许我们以确定性实现通用功能,仅仅因为我们期望某些条件成立。如果法律被违反,我们就无法确定地知道在某种行为方面可以期待什么。在这种情况下,事情很可能会以错误的结果结束。
与本章中我们已经看到的其他概念类似,单子是在它们遵循的法律的术语中定义的。为了使一个结构被认为是单子,它必须满足所有规则。让我们从一个简短的定义开始,我们稍后会对其进行扩展:
单子是具有 unit 和 flatMap 方法并遵循 单子规则 的函子。
那么,前面的定义意味着什么呢?首先,这意味着单子遵循我们之前定义的所有关于函子的规则。此外,它们更进一步,并添加了对两个更多方法的支持。
flatMap 方法
在我们正式定义规则之前,让我们先简要讨论一下 flatMap。我们假设你已经熟悉 Scala 集合,并且知道存在 flatten 方法。所以,flatMap 的名字本身告诉我们它会先映射然后扁平化,如下所示:
def flatMapT : Monad[T] = flatten(map(f))
我们还没有在前面代码中提到的单子定义,但那没关系。我们很快就会到达那里。现在,让我们把它看作另一个通用参数。你还应该知道 flatten 有以下声明:
def flattenT: M[T]
例如,如果 F 实际上是一个 List,flatten 将将列表的列表转换成一个简单的列表,其类型与内部列表的类型相同。如果 F 是一个 Option,那么嵌套选项中的 None 值将消失,其余的将保留。这两个例子表明,flatten 的结果实际上取决于被扁平化的类型的特定情况,但在任何情况下,它如何转换我们的数据都是清晰的。
单子的 unit 方法
我们之前提到的另一个方法是 unit。实际上,这个方法叫什么并不重要,它可能根据不同语言的标准而不同。重要的是它的功能。unit 的签名可以写成以下方式:
def unitT: Monad[T]
前面的行是什么意思?这很简单——它将T类型的值转换为T类型的单子。这不过是一个单参数构造函数或只是一个工厂方法。在 Scala 中,这可以通过具有apply方法的伴随对象来表示。只要它做正确的事情,实现并不真正重要。在 Scala 中,我们有许多集合类型作为例子——List、Array、Seq——它们都有支持以下内容的apply方法:
List(x)
Array(x)
Seq(x)
map、flatMap 和 unit 之间的联系
在前面的章节中,我们展示了如何使用map和flatten来定义flatMap。然而,我们可以采取不同的方法,并使用flatMap来定义map。以下是我们伪代码中的定义:
def mapT: Monad[T] = flatMap { x => unit(f(x)) }
前面的定义很重要,因为它描绘了所有map、flatMap和unit方法之间的关系。
根据我们实现哪种类型的单子,有时先实现map可能更容易(通常如果我们构建类似集合的单子),然后基于它和flatten实现flatMap,而有时先实现
flatMap。只要满足单子法则,我们采取的方法就不重要。
方法的名称
在前面的章节中,我们提到实际上调用unit方法的方式并不重要。虽然这对于unit来说是正确的,并且这可以传播到任何其他方法,但建议map和flatMap实际上保持这种方式。这并不意味着不可能让事情工作,但遵循通用约定会使事情变得简单得多。此外,map和flatMap给我们带来了额外的功能——使用我们的类在for 推导式中的可能性。考虑以下示例,它只是为了说明具有此类名称的方法如何帮助:
case class ListWrapper(list: List[Int]) {
// just wrap
def mapB: List[B] = list.map(f)
// just wrap
def flatMapB: List[B] =
list.flatMap(f)
}
在前面的例子中,我们只是在一个对象中包装了一个列表,并定义了map和flatMap方法。如果我们没有前面的对象,我们可以写点像这样的事情:
object For***prehensionWithLists {
def main(args: Array[String]): Unit = {
val l1 = List(1, 2, 3, 4)
val l2 = List(5, 6, 7, 8)
val result = for {
x <- l1
y <- l2
} yield x * y
// same as
// val result = l1.flatMap(i => l2.map(_ * i))
System.out.println(s"The result is: ${result}")
}
}
使用我们的包装对象,我们可以这样做:
object For***prehensionWithObjects {
def main(args: Array[String]): Unit = {
val wrapper1 = ListWrapper(List(1, 2, 3, 4))
val wrapper2 = ListWrapper(List(5, 6, 7, 8))
val result = for {
x <- wrapper1
y <- wrapper2
} yield x * y
System.out.println(s"The result is: ${result}")
}
}
两次应用都做同样的事情,并将产生完全相同的输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/d4083777-2904-4438-b4d5-495ac7548c79.png
然而,第二次应用使用的是我们的包装类包含具有map和flatMap等名称的特定方法的事实。如果我们重命名任何一个,我们就会得到一个编译错误——我们仍然可以写出相同的代码,但将无法在 Scala 中使用语法糖。另一个要点是,for 推导式将正确地在两个方法实际上遵循map和flatMap规则的情况下工作。
单子法则
在对一元组应该支持的方法进行了一些了解之后,现在我们可以正式定义一元组的定律。你已经看到一元组是函子,并且遵循函子定律。明确总是更好的,所以在这里我们将混合这些定律:
-
恒等定律:对恒等函数进行
map操作不会改变数据——map(x)(i => i) == x。对unit函数进行扁平映射也会保持数据不变——x.flatMap(i => unit(i)) == x。后者基本上说flatMap是unit的逆操作。使用我们之前定义的map、flatMap和unit之间的联系,我们可以从其中一个规则推导出另一个规则,反之亦然。unit方法可以被认为是幺半群中的零元素。 -
单位定律:从
unit的定义中,我们也可以说:unit(x).flatMap { y => f(y) } == f(x)。从这个定义中,我们将得到unit(x).map { y => f(x) } == unit(f(x))。这给我们提供了一些有趣的方法之间的联系。 -
组合:多个映射必须组合在一起。如果我们做
x.map(i => y(i)).map(i => z(i))或x.map(i => z(y(i))),应该没有区别。此外,多个flatMap调用也必须组合,使得以下成立:x.flatMap(i => y(i)).flatMap(i => z(i)) == x.flatMap(i => y(i).flatMap(j => z(j)))。
类似于幺半群,一元组也有一个零元素。一些实际的零一元组例子有 Scala 列表中的Nil和None选项。然而,我们也可以有多个零元素,这些元素由一个代数数据类型表示,该类型有一个构造函数参数,我们可以向它传递不同的值。为了完整,如果我们所建模的一元组没有这样的概念,我们可能根本不会有零元素。无论如何,零一元组代表某种空缺,并遵循一些额外的定律:
-
零恒等律:这一点相当直接。它说无论我们应用什么函数到零一元组,它仍然将是零——
zero.flatMap(i => f(i)) == zero和zero.map(i => f(i)) == zero。Zero不应该与unit混淆,因为它们是不同的,后者不表示空缺。 -
逆零:这一点也很直接。基本上,如果我们用零替换一切,我们的最终结果也将是零——
x.flatMap(i => zero) == zero。 -
交换律:一元组可以有一个加法概念,无论是连接还是其他什么。无论如何,这种操作与零一元组一起进行时将是交换的,例如,
x plus zero == zero plus x == x。
一元组和副作用
当展示组合定律时,我们假设操作没有副作用。我们说了以下内容:
x.map(i => y(i)).map(i => z(i)) == x.map(i => z(y(i)))。
然而,现在让我们思考一下,如果 y 或 z 导致了一些副作用会发生什么。在左侧,我们首先运行所有的 y,然后运行所有的 z。然而,在右侧,我们却是交错进行,一直进行 y 和 z。现在,如果一个操作导致了副作用,这意味着两者最终可能会产生不同的结果。这就是为什么开发者应该更喜欢使用左侧版本,尤其是在可能存在诸如 IO 之类的副作用的情况下。
我们已经讨论了单子的定律。对于那些有更多 Scala 经验的人来说,单子可能看起来非常接近集合类,我们之前定义的规则可能看起来很合理。然而,我们再次指出,单子不一定是集合,遵循这些规则对于能够将代数数据结构称为单子来说非常重要。
生活中的单子
在学习了许多关于单子(monads)的理论之后,现在去了解一些代码示例,这些示例展示了如何实现和使用这些理论概念,以及它们在现实世界中的应用情况,将会非常有用。
现在我们来做类似的事情,展示在 Scala 中单子特质的样子。然而,在这样做之前,让我们稍微改变一下我们的函子定义:
trait Functor[T] {
def mapY: Functor[Y]
}
在前面的代码中,我们不是传递将要映射的元素,而是假设混合了 Functor 的类型将有一种方法可以将它传递给 map 实现。我们还改变了返回类型,以便我们可以使用 map 连接多个函子。完成这些后,我们可以展示我们的 Monad 特质:
trait Monad[T] extends Functor[T] {
def unitY: Monad[Y]
def flatMapY: Monad[Y]
override def mapY: Monad[Y] =
flatMap(i => unit(f(i)))
}
上述代码遵循了与我们用于单子的约定相似的惯例。单子拥有的方法与我们已经在本章的理论部分提到的方法完全相同。签名可能略有不同,但将它们映射到易于理解的代码上,不应该引起任何问题。
如你所见,单子扩展了函子。现在,每当我们想要编写单子时,我们只需要扩展前面的特质并实现方法。
使用单子
简单地拥有一个单子特质就使我们处于一个可以遵循的框架中。我们已经了解了单子的理论和它们遵循的定律。然而,为了理解单子是如何工作的以及它们有什么用,查看一个实际的例子是无价的。
然而,如果我们不知道单子的用途是什么,我们该如何使用它们呢?让我们称它们为计算构建器,因为这正是它们被用于的地方。这使普通开发者对何时何地以某种方式使用单子的计算构建器链操作有了更深入的理解,这些操作随后被执行。
选项单子
我们已经多次提到,标准的 Scala Option 是一个单子。在本小节中,我们将提供我们自己的单子实现,并展示单子的多种可能用途之一。
为了展示选项的有用性,我们将看到如果没有它会发生什么。让我们想象我们有以下类:
case class Doer() {
def getAlgorithm(isFail: Boolean) =
if (isFail) {
null
} else {
Algorithm()
}
}
case class Algorithm() {
def getImplementation(isFail: Boolean, left: Int, right: Int): Implementation =
if (isFail) {
null
} else {
Implementation(left, right)
}
}
case class Implementation(left: Int, right: Int) {
def ***pute: Int = left + right
}
为了测试,我们添加了一个Boolean标志,它将成功或失败地获取所需的对象。实际上,这可能是一个复杂的函数,它可能根据参数或其他因素在某些特定情况下返回null。以下代码片段展示了如何使用前面的类来完全避免失败:
object NoMonadExample {
def main(args: Array[String]): Unit = {
System.out.println(s"The result is: ${***pute(Doer(), 10, 16)}")
}
def ***pute(doer: Doer, left: Int, right: Int): Int =
if (doer != null) {
val algorithm = doer.getAlgorithm(false)
if (algorithm != null) {
val implementation = algorithm.getImplementation(false,
left, right)
if (implementation != null) {
implementation.***pute
} else {
-1
}
} else {
-1
}
} else {
-1
}
}
NoMonadExample对象中的***pute方法看起来真的很糟糕,难以阅读。我们不应该编写这样的代码。
观察前面的代码,我们可以看到我们实际上正在尝试构建一个操作链,这些操作可以单独失败。单子可以帮助我们并抽象这种保护逻辑。现在,让我们展示一个更好的解决方案。
首先,让我们定义我们自己的Option单子:
sealed trait Option[A] extends Monad[A]
case class SomeA extends Option[A] {
override def unitY: Monad[Y] = Some(value)
override def flatMapY => Monad[Y]): Monad[Y] = f(a)
}
case class None[A]() extends Option[A] {
override def unitY: Monad[Y] = None()
override def flatMapY => Monad[Y]): Monad[Y] = None()
}
在前面的代码中,我们有两个具体的例子——一个是可以获取值的情况,另一个是结果将为空的情况。现在,让我们重新编写我们的计算类,以便它们使用我们刚刚创建的新单子:
case class Doer_v2() {
def getAlgorithm(isFail: Boolean): Option[Algorithm_v2] =
if (isFail) {
None()
} else {
Some(Algorithm_v2())
}
}
case class Algorithm_v2() {
def getImplementation(isFail: Boolean, left: Int, right: Int): Option[Implementation] =
if (isFail) {
None()
} else {
Some(Implementation(left, right))
}
}
最后,我们可以用以下方式使用它们:
object MonadExample {
def main(args: Array[String]): Unit = {
System.out.println(s"The result is: ${***pute(Some(Doer_v2()), 10, 16)}")
}
def ***pute(doer: Option[Doer_v2], left: Int, right: Int) =
for {
d <- doer
a <- d.getAlgorithm(false)
i <- a.getImplementation(false, left, right)
} yield i.***pute
// OR THIS WAY:
// doer.flatMap {
// d =>
// d.getAlgorithm(false).flatMap {
// a =>
// a.getImplementation(false, left, right).map {
// i => i.***pute
// }
// }
// }
}
在前面的代码中,我们展示了我们的单子的for推导用法,但注释掉的部分也是有效的。第一个更受欢迎,因为它使事情看起来非常简单,一些完全不同的计算最终看起来相同,这对理解和修改代码是有益的。
当然,我们示例中展示的所有内容都可以使用标准的 Scala Option来实现。几乎可以肯定,您之前已经见过并使用过这个类,这意味着您实际上已经使用过单子,可能没有意识到这一点。
更高级的单子示例
之前的例子相当简单,展示了单子的强大用途。我们使代码更加直接,并在单子内部抽象了一些逻辑。此外,我们的代码比之前更容易阅读。
在本小节中,我们将探讨 monads 的另一种更高级的使用方法。每当我们在软件中添加 I/O 操作时,所有我们编写的软件都会变得更加具有挑战性和趣味性。这包括读取和写入文件、与用户通信、发起网络请求等等。Monads 可以被用来以纯函数式的方式编写 I/O 应用程序。这里有一个非常重要的特性:I/O 必须处理副作用,操作通常按顺序执行,结果取决于状态。这个状态可以是任何东西——如果我们询问用户他们喜欢什么车,他们的回答会因用户而异;如果我们询问他们早餐吃了什么,或者天气如何,对这些问题的回答也会因用户而异。即使我们尝试两次读取同一个文件,也可能会有差异——我们可能会失败,文件可能会被更改等等。我们迄今为止所描述的一切都是状态。Monads 帮助我们隐藏这个状态,只向用户展示重要的部分,以及抽象我们处理错误的方式等等。
关于我们将要使用的状态有几个重要的方面:
-
状态在不同 I/O 操作之间发生变化
-
状态只有一个,我们不能随意创建一个新的
-
在任何时刻,只能有一个状态
所有的前述陈述都非常合理,但它们实际上将指导我们实现状态和 monads 的方式。
我们将编写一个示例,它将读取文件中的行,然后遍历它们,并将它们写入一个新文件,所有字母都大写。这可以用 Scala 非常简单直接地完成,但一旦某些操作变得更为复杂,或者我们试图正确处理错误,这可能会变得相当困难。
在整个示例中,我们将尝试展示我们为确保关于我们状态的前述陈述正确所采取的步骤。
我们将要展示的以下示例实际上并不需要使用状态。它只是以 monadic 方式执行文件读取和写入。读者现在应该有足够的知识,如果需要,可以从代码中移除状态。
我们决定展示一个非常简单的状态使用示例,我们只是增加一个数字。这可以让读者了解状态如何在可能需要它的应用程序中使用和连接。此外,状态的使用实际上可以修改我们程序的行为,并触发不同的动作,例如,自动售货机和用户尝试请求缺货的商品。
让我们从状态开始。对于当前示例,我们实际上并不需要一个特殊的状态,但我们仍然使用了它。只是为了展示在确实需要时如何处理这种情况:
sealed trait State {
def next: State
}
上述特质有一个 next 方法,当我们在不同操作之间移动时,它将返回下一个状态。只需在传递状态时调用它,我们就可以确保不同的操作会导致状态的变化。
我们需要确保我们的应用程序只有一个状态,并且没有人可以在任何时候创建状态。事实是,特质的密封性帮助我们确保没有人可以在我们定义它的文件之外扩展我们的状态。尽管密封是必要的,但我们还需要确保所有状态实现都是隐藏的:
abstract class FileIO {
// this makes sure nobody can create a state
private class FileIOState(id: Int) extends State {
override def next: State = new FileIOState(id + 1)
}
def run(args: Array[String]): Unit = {
val action = runIO(args(0), args(1))
action(new FileIOState(0))
}
def runIO(readPath: String, writePath: String): IOAction[_]
}
上述代码将状态定义为私有类,这意味着没有人能够创建它。现在我们先忽略其他方法,因为我们稍后会回到它们。
我们之前为状态定义的第三条规则要复杂得多。我们采取了多个步骤以确保状态的行为正确。首先,正如前一个列表所示,用户无法获取任何关于状态的信息,除了一个无人能实例化的私有类。我们不是让用户承担执行任务和传递状态的负担,而是只向他们暴露一个 IOAction,其定义如下:
sealed abstract class IOAction[T] extends ((State) => (State, T)) {
// START: we don't have to extend. We could also do this...
def unitY: IOAction[Y] = IOAction(value)
def flatMapY => IOAction[Y]): IOAction[Y] = {
val self = this
new IOAction[Y] {
override def apply(state: State): (State, Y) = {
val (state2, res) = self(state)
val action2 = f(res)
action2(state2)
}
}
}
def mapY: IOAction[Y] =
flatMap(i => unit(f(i)))
// END: we don't have to extend. We could also do this...
}
首先,让我们只关注 IOAction 的签名。它将一个函数从一个旧状态扩展到新状态和操作结果的元组。因此,结果是我们在某种方式上仍然以类形式向用户暴露了状态。然而,我们已经看到,通过创建一个无人能实例化的私有类,隐藏状态是非常直接的。我们的用户将使用 IOAction 类,因此我们需要确保他们不必自己处理状态。我们已经定义了 IOAction 为密封的。此外,我们可以创建一个工厂对象,这将帮助我们创建新的实例:
object IOAction {
def applyT: IOAction[T] =
new SimpleActionT
private class SimpleActionT extends IOAction[T] {
override def apply(state: State): (State, T) =
(state.next, result)
}
}
上述代码在后续的连接中非常重要。首先,我们有一个 IOAction 的私有实现。它只接受一个按名称传递的参数,这意味着它只会在调用 apply 方法时被评估——这非常重要。此外,在上述代码中,我们有一个 apply 方法用于 IOAction 对象,它允许用户实例化操作。在这里,值也是按名称传递的。
上述代码基本上使我们能够定义操作,并且只有在有状态可用时才执行它们。
如果我们现在思考一下,你可以看到我们已经满足了我们对状态的所有三个要求。的确,通过将状态隐藏在我们控制的类后面,我们成功地保护了状态,以确保我们不会同时拥有多个状态。
现在我们已经一切就绪,我们可以确保我们的 IOAction 是一个单子。它需要满足单子法则并定义所需的方法。我们已经展示了它们,但让我们再次仔细看看这些方法:
// START: we don't have to extend. We could also do this...
def unitY: IOAction[Y] = IOAction(value)
def flatMapY => IOAction[Y]): IOAction[Y] = {
val self = this
new IOAction[Y] {
override def apply(state: State): (State, Y) = {
val (state2, res) = self(state)
val action2 = f(res)
action2(state2)
}
}
}
def mapY: IOAction[Y] =
flatMap(i => unit(f(i)))
// END: we don't have to extend. We could also do this...
我们没有具体扩展我们的Monad特质,而是在这里定义了方法。我们已经知道map可以使用flatMap和unit来定义。对于后者,我们使用了SimpleAction的工厂方法。我们前者的实现相当有趣——它首先执行当前操作,然后根据结果状态,顺序执行第二个操作。这允许我们将多个 I/O 操作链接在一起。
让我们再次看看我们的IOAction类。它是否满足单子规则?答案是:不,但有一个非常简单的修复方法。问题在于,如果我们深入研究,我们的unit方法会改变状态,因为它使用了SimpleAction。但是它不应该这样做。我们必须做的是创建另一个不改变状态的IOAction实现,并用于unit:
private class EmptyActionT extends IOAction[T] {
override def apply(state: State): (State, T) =
(state, value)
}
然后,我们的IOAction对象将获得一个额外的函数:
def unitT: IOAction[T] = new EmptyActionT
我们还必须更改IOAction抽象类中的unit方法:
def unitY: IOAction[Y] = IOAction.unit(value)
到目前为止,我们已经定义了我们的单子,确保状态得到适当的处理,并且用户可以以受控的方式创建操作。我们现在需要做的就是添加一些有用的方法并尝试它们:
package object io {
def readFile(path: String) =
IOAction(Source.fromFile(path).getLines())
def writeFile(path: String, lines: Iterator[String]) =
IOAction({
val file = new File(path)
printToFile(file) { p => lines.foreach(p.println) }
})
private def printToFile(file: File)(writeOp: PrintWriter => Unit): Unit = {
val writer = new PrintWriter(file)
try {
writeOp(writer)
} finally {
writer.close()
}
}
}
上述是读取和写入文件并返回IOAction实例(在当前情况下,使用IOAction的apply方法创建SimpleAction)的包对象的代码。现在我们有了这些方法和我们的单子,我们可以使用我们定义的框架,并将一切连接起来:
abstract class FileIO {
// this makes sure nobody can create a state
private class FileIOState(id: Int) extends State {
override def next: State = new FileIOState(id + 1)
}
def run(args: Array[String]): Unit = {
val action = runIO(args(0), args(1))
action(new FileIOState(0))
}
def runIO(readPath: String, writePath: String): IOAction[_]
}
上述代码定义了一个框架,我们的库的用户将遵循;他们必须扩展FileIO,实现runIO,并在他们准备好使用我们的应用程序时调用run方法。到目前为止,你应该足够熟悉单子,看到高亮代码唯一要做的事情是构建计算。它可以被认为是一个必须执行的操作的图。它不会执行任何操作,直到下一行,在那里它实际上接收传递给它的状态:
object FileIOExample extends FileIO {
def main(args: Array[String]): Unit = {
run(args)
}
override def runIO(readPath: String, writePath: String): IOAction[_] =
for {
lines <- readFile(readPath)
_ <- writeFile(writePath, lines.map(_.toUpperCase))
} yield ()
}
上述代码展示了我们创建的FileIO库的一个示例用法。现在我们可以用以下输入文件运行它:
this is a file, which
will be ***pletely capitalized
in a monadic way.
Enjoy!
我们需要使用的命令如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/76ddb10f-b600-4719-b4a2-3942a6fbf7f2.png
如预期的那样,输出文件将包含所有大写字母的相同文本。当然,你可以尝试不同的输入,看看代码的表现如何。
单子直觉
在本节中,我们讨论了一些关于单子的理论和实际例子。希望我们已经成功地给出了易于理解的解释,说明了什么是什么是,以及它是如何和为什么工作的。单子并不像它们最初看起来那么可怕,花一些时间与它们相处将更好地理解为什么事情以某种方式工作。
最后的例子可能看起来相当复杂,但花一些额外的时间在 IDE 中使用它,会使它变得清晰易懂,让你能够清楚地意识到一切是如何连接起来的。然后,你将能够轻松地发现并在自己的代码中使用单子。
当然,开发者可能可以不使用单子(monads)而逃脱,但使用它们可以帮助隐藏关于异常处理、特定操作等方面的细节。单子之所以好,实际上是因为它们内部发生的额外工作,并且它们可以用来实现我们在本书前面看到的一些设计模式。我们可以实现更好的状态、回滚以及许多其他功能。还值得一提的是,我们可能很多次在使用单子时甚至都没有意识到。
摘要
本章专门介绍了一些似乎让很多人对纯函数式编程望而却步的函数式编程理论。因为大多数解释都需要强大的数学背景,所以我们看到人们避免本章中涵盖的概念。
我们讨论了单子、单子和函子,并展示了如何使用它们的示例以及有它们和无它们之间的区别。结果证明,我们比我们想象的更经常使用这些概念,但我们只是没有意识到。
我们看到单子、函子和单子可以用作各种目的——性能优化、抽象和代码重复的移除。正确理解这些概念并感到舒适可能最初需要一些时间,但经过一些实践,开发者往往会获得更好的理解,并且比以前更频繁地使用它们。希望这一章使单子、单子和函子看起来比你可能想象的要简单得多,你将更频繁地将它们作为生产代码的一部分。
在下一章中,我们将介绍一些特定于 Scala 的函数式编程设计模式,这得益于其表达性。其中一些将是新的且之前未见过的,而其他一些我们已经遇到过,但我们将从不同的角度来审视它们。
第十一章:应用所学知识
在 Scala 和关于该语言的各种设计模式的学习中,我们已经走了很长的路。现在,你应该已经到了一个可以自信地使用特定设计模式并避免它们的时候了。你看到了 Scala 的一些具体和优秀的特性,这些特性导致了它的表现力。我们探讨了四人帮设计模式以及一些重要的函数式编程概念,如单子。在整个书中,我们尽量将数学理论保持在最基本水平,并尽量避免在公式中使用一些难以理解的希腊字母,这些公式对于非数学家来说很难理解,他们可能也希望充分发挥函数式编程语言的最大潜力。
本章和下一章的目的是从更实际的角度来看 Scala。了解一种语言和一些设计模式并不总是足以让开发者看到整个画面和语言可能性的潜力。在本章中,我们将展示我们之前提出的一些概念如何结合在一起,以编写更强大、更干净的程序。我们将探讨以下主题:
-
镜头设计模式
-
蛋糕设计模式
-
优化我的图书馆设计模式
-
可堆叠特质设计模式
-
类型类设计模式
-
惰性评估
-
部分函数
-
隐式注入
-
鸭式类型
-
缓存
本章的一些部分将展示我们之前没有见过的概念。其他部分将结合 Scala 的一些特性和我们迄今为止学到的设计模式,以实现其他目的。然而,在所有情况下,这些概念都将涉及特定的语言特性或我们已经看到的限制,或者有助于在实际的软件工程项目中实现常见的事情。
镜头设计模式
我们已经提到,在 Scala 中,对象是不可变的。当然,你可以确保一个特定的类的字段被声明为vars,但这是不被推荐的,并被认为是坏做法。毕竟,不可变性是好的,我们应该努力追求它。
镜头设计模式是为了这个目的而创建的,它使我们能够克服不可变性的限制,同时保持代码的可读性。在接下来的小节中,我们将从一些没有使用镜头设计模式的代码开始,一步一步地展示如何使用它以及它是如何改进我们的应用的。
镜头示例
为了在实践中展示镜头设计模式,我们将创建一个通常在企业应用程序中看到的类层次结构。让我们想象我们正在为一家图书馆构建一个系统,该系统可以被不同公司的员工使用。我们可能会得到以下类:
case class Country(name: String, code: String)
case class City(name: String, country: Country)
case class Address(number: Int, street: String, city: City)
case class ***pany(name: String, address: Address)
case class User(name: String, ***pany: ***pany, address: Address)
这些类的表示作为类图将看起来如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/9aa271bd-7711-4ae2-9d7f-8e63019437ee.png
该图非常清晰,不需要太多解释。我们基本上有一个User类,其中包含有关用户的其他信息。其他类包含其他类,依此类推。如果我们不想修改任何内容,使用我们的类绝对没有任何挑战。然而,一旦我们开始修改某些内容,事情就会变得复杂。
没有透镜设计模式
在本节中,我们将看到如果我们要修改它们的某些属性,如何使用我们的类。
不可变和冗长
不深入细节,让我们看看一个示例应用程序将是什么样子:
object UserVerboseExample {
def main(args: Array[String]): Unit = {
val uk = Country("United Kingdom", "uk")
val london = City("London", uk)
val buckinghamPalace = Address(1, "Buckingham Palace Road", london)
val castleBuilders = ***pany("Castle Builders", buckinghamPalace)
val switzerland = Country("Switzerland", "CH")
val geneva = City("geneva", switzerland)
val genevaAddress = Address(1, "Geneva Lake", geneva)
val ivan = User("Ivan", castleBuilders, genevaAddress)
System.out.println(ivan)
System.out.println("Capitalize UK code...")
val ivanFixed = ivan.copy(
***pany = ivan.***pany.copy(
address = ivan.***pany.address.copy(
city = ivan.***pany.address.city.copy(
country = ivan.***pany.address.city.country.copy(
code = ivan.***pany.address.city.country.code.toUpperCase
)
)
)
)
)
System.out.println(ivanFixed)
}
}
之前的应用程序为我们的库创建了一个用户,然后决定更改公司国家代码,就像我们最初用小写字母创建的那样。应用程序的输出如下:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/c6b7b500-1d33-4ee4-bc5b-e7132d736473.png
我们的应用程序运行正确,但正如你在高亮代码中所看到的,它非常冗长且容易出错。我们不希望编写这样的代码,因为这将是难以维护和未来更改的。
使用可变属性
可能首先出现在你脑海中的想法是更改类并使属性可变。以下是我们的案例类将如何改变:
case class Country(var name: String, var code: String)
case class City(var name: String, var country: Country)
case class Address(var number: Int, var street: String, var city: City)
case class ***pany(var name: String, var address: Address)
case class User(var name: String, var ***pany: ***pany, var address: Address)
在此之后,使用这些类将像这样简单:
object UserBadExample {
def main(args: Array[String]): Unit = {
val uk = Country("United Kingdom", "uk")
val london = City("London", uk)
val buckinghamPalace = Address(1, "Buckingham Palace Road", london)
val castleBuilders = ***pany("Castle Builders", buckinghamPalace)
val switzerland = Country("Switzerland", "CH")
val geneva = City("geneva", switzerland)
val genevaAddress = Address(1, "Geneva Lake", geneva)
val ivan = User("Ivan", castleBuilders, genevaAddress)
System.out.println(ivan)
System.out.println("Capitalize UK code...")
ivan.***pany.address.city.country.code = ivan.***pany.address.city.country.code.toUpperCase
System.out.println(ivan)
}
}
在前面的代码示例中,我们也可以使用这种方式更改国家代码——uk.code = uk.code.toUpperCase。这将有效,因为我们使用User对象中的国家引用。
之前的示例将产生完全相同的输出。然而,在这里我们打破了 Scala 中一切都是不可变的规则。在当前示例中,这可能看起来不是什么大问题,但事实上,这与 Scala 的原则相悖。这被认为是糟糕的代码,我们应该尽量避免。
使用透镜设计模式
在前面的子节中,我们看到了改变嵌套类的一个属性会变得多么复杂。我们追求的是漂亮、干净和正确的代码,而且我们也不想违反 Scala 的原则。
幸运的是,我们之前提到的那些情况正是透镜设计模式被创造出来的原因。在本章中,我们将第一次在本书中介绍 Scalaz 库。它为我们定义了许多函数式编程抽象,我们可以轻松地直接使用它们,而不用担心它们是否遵循某些特定的规则。
那么,透镜究竟是什么呢?在这里,我们不会深入探讨理论方面,因为这超出了本书的范围。我们只需要知道它们是用来做什么的,如果你想要了解更多,网上有大量关于透镜、存储和单子的材料,这些材料可以使这些概念更加清晰。表示透镜的一个简单方法是以下内容:
case class LensX, Y => X)
这基本上让我们能够获取和设置 X 类型对象的不同的属性。这意味着在我们的情况下,我们将不得不为想要设置的每个属性定义不同的镜头:
import scalaz.Lens
object User {
val user***pany = Lens.lensuUser, ***pany => u.copy(***pany = ***pany), _.***pany
)
val userAddress = Lens.lensuUser, Address => u.copy(address = address), _.address
)
val ***panyAddress = Lens.lensu***pany, Address => c.copy(address = address), _.address
)
val addressCity = Lens.lensuAddress, City => a.copy(city = city), _.city
)
val cityCountry = Lens.lensuCity, Country => c.copy(country = country), _.country
)
val countryCode = Lens.lensuCountry, String => c.copy(code = code), _.code
)
val user***panyCountryCode = user***pany >=> ***panyAddress >=> addressCity >=> cityCountry >=> countryCode
}
前面的代码是我们 User 类的伴随对象。这里有很多事情在进行中,所以我们将解释这一点。你可以看到对 Lens.lensu[A, B] 的调用。它们创建实际的镜头,以便对于 A 类型的对象,调用获取和设置 B 类型的值。实际上它们并没有什么特别之处,看起来就像模板代码。这里有趣的部分是高亮显示的代码——它使用了 >=> 操作符,这是 andThen 的别名。这允许我们组合镜头,这正是我们将要做的。我们将定义一个组合,允许我们从 User 对象通过链路设置 ***pany 的国家代码。我们也可以使用 ***pose,它的别名为 <=<,因为 andThen 内部调用 ***pose,它看起来如下:
val user***panyCountryCode***pose = countryCode <=< cityCountry <=< addressCity <=< ***panyAddress <=< user***pany
然而,后者并不像前者那样直观。
现在使用我们的镜头非常简单。我们需要确保导入我们的伴随对象,然后我们可以简单地使用以下代码来将国家代码转换为大写:
val ivanFixed = user***panyCountryCode.mod(_.toUpperCase, ivan)
你看到了如何通过镜头设计模式,我们可以干净地设置我们的案例类的属性,而不违反不可变性规则。我们只需要定义正确的镜头,然后使用它们。
最小化模板代码
前面的例子显示了大量的模板代码。它并不复杂,但需要我们编写相当多的额外内容,并且任何重构都可能影响这些手动定义的镜头。已经有人努力创建库来自动为所有用户定义的类生成镜头,这样就可以轻松使用。一个似乎维护得很好的库示例是 Monocle:github.***/julien-truffaut/Monocle。它有很好的文档,可以用来确保我们不需要编写任何模板代码。尽管如此,它也有其局限性,用户应该确保他们接受库提供的内容。它还提供了其他可能有用的光学概念。
蛋糕设计模式
实际的软件项目通常会结合多个组件,这些组件必须一起使用。大多数时候,这些组件将依赖于其他组件,而这些组件又依赖于其他组件,依此类推。这使得在应用程序中创建对象变得困难,因为我们还需要创建它们依赖的对象,依此类推。这就是依赖注入派上用场的地方。
依赖注入
那么,依赖注入到底是什么呢?实际上它非常简单——任何在其构造函数中有一个对象作为参数的类实际上都是依赖注入的一个例子。原因是依赖被注入到类中,而不是在类内部实例化。开发者实际上应该尝试使用这种类型的做法,而不是在构造函数中创建对象。这样做有很多原因,但其中最重要的一个原因是组件可能会变得紧密耦合,实际上难以测试。
然而,如果使用构造函数参数来实现依赖注入,可能会降低代码质量。这将使构造函数包含大量的参数,因此使用构造函数将变得非常困难。当然,使用工厂设计模式可能会有所帮助,但还有其他在企业应用程序中更为常见的方法。在接下来的小节中,我们将简要介绍这些替代方案,并展示如何仅使用 Scala 的特性轻松实现依赖注入。
依赖注入库和 Scala
许多拥有 Java 背景的开发者可能已经熟悉一些著名的依赖注入库。一些流行的例子包括 Spring (spring.io/) 和 Guice (github.***/google/guice)。在 Spring 中,依赖通常在 XML 文件中管理,其中描述了依赖项,并且文件告诉框架如何创建实例以及将对象注入到类中。其中使用的术语之一是 bean。
另一方面,Guice 使用注解,然后这些注解会被评估并替换为正确的对象。这些框架相当流行,并且它们也可以很容易地在 Scala 中使用。那些熟悉 Play Framework 的人会知道,它正是使用 Guice 来连接事物的。
然而,使用外部库会增加项目的依赖项,增加 jar 文件的大小等等。如今,这并不是真正的问题。然而,正如我们已经看到的,Scala 是一种相当表达性的语言,我们可以不使用任何额外的库本地实现依赖注入。我们将在接下来的小节中看到如何实现这一点。
Scala 中的依赖注入
为了在 Scala 中实现依赖注入,我们可以使用一种特殊的设计模式。它被称为蛋糕设计模式。不深入细节,让我们创建一个应用程序。我们创建的应用程序将需要有一系列相互依赖的类,这样我们就可以展示注入是如何工作的。
编写我们的代码
我们将创建一个应用程序,可以从数据库中读取有关人员、班级以及谁报名了哪些班级的数据。我们将有一个用户服务,它将使用数据实现一些简单的业务逻辑,并且还有一个将访问数据的服 务。这将会是一个小型应用程序,但它将清楚地展示依赖注入是如何工作的。
让我们从简单的事情开始。我们需要有一个模型来表示我们将要表示的对象:
case class Class(id: Int, name: String)
case class Person(id: Int, name: String, age: Int)
在前面的代码中,我们有两个将在我们的应用程序中使用的类。它们没有什么特别之处,所以让我们继续前进。
我们说过,我们希望我们的应用程序能够从数据库中读取数据。有不同类型的数据库——MySQL、PostgreSQL、Oracle 等。如果我们想使用这些数据库中的任何一个,那么你需要安装一些额外的软件,这将需要额外的知识,并且可能会很棘手。幸运的是,有一个内存数据库引擎叫做 H2 (www.h2database.***/html/main.html),我们可以用它来代替。使用这个引擎就像在我们的 pom.xml 或 build.sbt 文件中添加一个依赖项,然后使用数据库一样。我们很快就会看到这一切是如何工作的。
此外,让我们让事情更有趣,并确保可以轻松地插入不同的数据库引擎。为了实现这一点,我们需要某种类型的接口,该接口将由不同的数据库服务实现:
trait DatabaseService {
val dbDriver: String
val connectionString: String
val username: String
val password: String
val ds = {
Jdb***onnectionPool.create(connectionString, username, password)
}
def getConnection: Connection = ds.getConnection
}
在前面的代码中,我们使用了一个特质,并且每当我们要创建一个 H2 数据库服务、Oracle 数据库服务等时,都会扩展这个特质。前面的代码中的所有内容似乎都很直接,不需要额外的解释。
vals 的顺序
在前面的代码中,变量定义的顺序很重要。这意味着如果我们首先声明了 ds,然后是其他所有内容,我们就会遇到一个 NullPointerException。这可以通过使用 lazy val 来轻松克服。
在我们的例子中,我们将实现一个针对 H2 数据库引擎的服务,如下所示:
trait Database***ponent {
val databaseService: DatabaseService
class H2DatabaseService(val connectionString: String, val username: String, val password: String) extends DatabaseService {
val dbDriver = "org.h2.Driver"
}
}
数据库服务的实际实现是在嵌套的 H2DatabaseService 类中。它没有什么特别之处。但是,关于 Database***ponent 特质呢?很简单——我们希望有一个数据库组件,我们可以将其混合到我们的类中,并提供连接到数据库的功能。databaseService 变量被留为抽象的,并且当组件被混合时必须实现。
仅有一个数据库组件本身并没有什么用处。我们需要以某种方式使用它。让我们创建另一个组件,它将创建我们的数据库及其表,并用数据填充它们。显然,它将依赖于前面提到的数据库组件:
trait Migration***ponent {
this: Database***ponent =>
val migrationService: MigrationService
class MigrationService() {
def runMigrations(): Unit = {
val connection = databaseService.getConnection
try {
// create the database
createPeopleTable(connection)
createClassesTable(connection)
createPeopleToClassesTable(connection)
// populate
insertPeople(
connection,
List(Person(1, "Ivan", 26), Person(2, "Maria", 25),
Person(3, "John", 27))
)
insertClasses(
connection,
List(Class(1, "Scala Design Patterns"), Class(2,
"JavaProgramming"), Class(3, "Mountain Biking"))
)
signPeopleToClasses(
connection,
List((1, 1), (1, 2), (1, 3), (2, 1), (3, 1), (3, 3))
)
} finally {
connection.close()
}
}
private def createPeopleTable(connection: Connection): Unit = {
// implementation
}
private def createClassesTable(connection: Connection): Unit = {
// implementation
}
private def createPeopleToClassesTable(connection: Connection):
Unit = {
// implementation
}
private def insertPeople(connection: Connection, people: List[Person]): Unit = {
// implementation
}
// Other methods
}
}
现在代码确实很多!但这并不令人害怕。让我们逐行分析,并尝试理解它。首先,我们遵循了之前的模式——创建了一个具有抽象变量的组件特质,在这个例子中,它被称为migrationService。我们不需要有多个不同的迁移,所以我们只需在组件特质内部创建一个类。
这里有趣的部分是我们突出显示的第一行——this: Database***ponent =>。这是什么意思?幸运的是,我们在书中已经见过这种语法了——它不过是一个self 类型注解。然而,它所做的确实很有趣——它告诉编译器,每次我们将Migration***ponent混入时,我们还需要将Database***ponent混入。这正是 Scala 知道迁移组件将依赖于数据库组件的拼图的一部分。因此,我们现在能够在第二行突出显示的代码中运行。如果我们仔细观察,它实际上访问了databaseService,这是Database***ponent的一部分。
在上一段代码中,我们跳过了大多数其他实现,但它们都很直接,与蛋糕设计模式无关。让我们看看其中的两个:
private def createPeopleTable(connection: Connection): Unit = {
val statement = connection.prepareStatement(
"""
|CREATE TABLE people(
| id INT PRIMARY KEY,
| name VARCHAR(255) NOT NULL,
| age INT NOT NULL
|)
""".stripMargin
)
try {
statement.executeUpdate()
} finally {
statement.close()
}
}
private def insertPeople(connection: Connection, people: List[Person]): Unit = {
val statement = connection.prepareStatement(
"INSERT INTO people(id, name, age) VALUES (?, ?, ?)"
)
try {
people.foreach {
case person =>
statement.setInt(1, person.id)
statement.setString(2, person.name)
statement.setInt(3, person.age)
statement.addBatch()
}
statement.executeBatch()
} finally {
statement.close()
}
}
上一段代码只是创建表并将数据插入到表中的数据库代码。类中的其余方法类似,但它们在表定义和插入内容上有所不同。完整的代码可以在本书提供的示例中看到。在这里,我们只提取创建数据库模型的语句,以便您了解数据库的结构以及我们可以用它做什么:
CREATE TABLE people(
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT NOT NULL
)
CREATE TABLE classes(
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
)
CREATE TABLE people_classes(
person_id INT NOT NULL,
class_id INT NOT NULL,
PRIMARY KEY(person_id, class_id),
FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY(class_id) REFERENCES classes(id) ON DELETE CASCADE ON UPDATE CASCADE
)
上一段代码中的迁移服务简单地创建数据库中的表,并将一些信息插入到这些表中,以便我们随后可以使用该服务。我们看到了这个迁移服务依赖于数据库服务,同时也看到了这种依赖是如何实现的。
仅通过这些课程,我们的应用程序将不会那么有用。我们需要能够与数据交互并对它进行一些有趣的操作。我们可以这样说,迁移组件只是确保我们拥有数据。在现实世界的场景中,我们可能已经有一个预先填充的数据库,我们需要与数据库中的内容进行工作。无论哪种情况,我们都需要有一个数据访问层来检索所需的数据。我们已经创建了以下组件:
trait Dao***ponent {
this: Database***ponent =>
val dao: Dao
class Dao() {
def getPeople: List[Person] = {
// skipped
}
def getClasses: List[Class] = {
// skipped
}
def getPeopleInClass(className: String): List[Person] = {
val connection = databaseService.getConnection
try {
val statement = connection.prepareStatement(
"""
|SELECT p.id, p.name, p.age
|FROM people p
| JOIN people_classes pc ON p.id = pc.person_id
| JOIN classes c ON c.id = pc.class_id
|WHERE c.name = ?
""".stripMargin
)
statement.setString(1, className)
executeSelect(statement) {
rs =>
readResultSet(rs) {
row =>
Person(row.getInt(1), row.getString(2), row.getInt(3))
}
}
} finally {
connection.close()
}
}
private def executeSelectT(f: (ResultSet) => List[T]): List[T] =
try {
f(preparedStatement.executeQuery())
} finally {
preparedStatement.close()
}
private def readResultSetT(f: ResultSet => T): List[T] =
Iterator.continually((rs.next(), rs)).takeWhile(_._1).map {
case (_, row) => f(rs)
}.toList
}
}
这个Dao***ponent在依赖方面与Database***ponent类似。它只是定义了用于检索数据的查询。我们跳过了简单的select语句。当然,它也可以定义更多用于插入、更新和删除的方法。它很好地隐藏了处理数据库数据的复杂性,现在我们实际上可以在我们的应用程序中创建一些有用的东西。
在企业应用程序中常见的是不同的服务可以访问数据库中的数据,对它执行一些业务逻辑,返回结果,并将其写回数据库。我们创建了一个简单的处理用户的服务:
trait User***ponent {
this: Dao***ponent =>
val userService: UserService
class UserService {
def getAverageAgeOfUsersInClass(className: String): Double = {
val (ageSum, peopleCount) = dao.getPeopleInClass(className).foldLeft((0, 0)) {
case ((sum, count), person) =>
(sum + person.age, count + 1)
}
if (peopleCount != 0) {
ageSum.toDouble / peopleCount.toDouble
} else {
0.0
}
}
}
}
在我们的User***ponent中,我们遵循我们已知的相同模式,但这次我们的依赖是Dao***ponent。然后我们可以有其他依赖此组件和其他组件的组件。我们没有在这里展示任何组件同时依赖多个组件的例子,但这并不难做到。我们只需使用以下方法:
this: ***ponent1 with ***ponent2 with ***ponent3 … =>
我们可以依赖尽可能多的组件,这就是蛋糕设计模式开始发光并显示其优势的地方。
连接所有组件
在前面的代码中,我们看到了一些组件及其实现,它们声明了对其他组件的依赖。我们还没有看到所有这些组件是如何一起使用的。通过将我们的组件定义为特质,我们只需将它们混合在一起,它们就会对我们可用。这就是我们这样做的方式:
object Application***ponentRegistry
extends User***ponent
with Dao***ponent
with Database***ponent
with Migration***ponent {
override val dao: Application***ponentRegistry.Dao = new Dao
override val databaseService: DatabaseService = new H2DatabaseService("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "", "")
override val migrationService: Application***ponentRegistry.MigrationService = new MigrationService
override val userService: Application***ponentRegistry.UserService = new UserService
}
在前面的代码中,Application***ponentRegistry也可以是一个类,而不是 Scala 对象。它将组件混合在一起,由于每个组件都有一个抽象变量,它迫使我们为它们分配实际值。最棒的部分是,如果我们知道我们的应用程序需要User***ponent,编译器会告诉我们还需要Dao***ponent,依此类推,直到链的末端。编译器基本上会确保我们在编译期间有完整的依赖链可用,并且它不会让我们运行应用程序,直到我们正确地完成了这些事情。这非常实用。在其他库中,情况并非如此,我们经常发现我们的依赖图在运行时没有正确构建。此外,这种方式连接组件确保我们只有一个实例。
如果我们用类而不是对象来表示Application***ponentRegistry,关于每个组件只有一个实例的声明不会自动成立。我们需要格外小心,否则注册表的每个实例可能会有不同的组件实例。
在我们创建组件注册表之后,我们可以轻松地在我们的应用程序中使用所有内容:
object Application {
import Application***ponentRegistry._
def main(args: Array[String]): Unit = {
migrationService.runMigrations()
System.out.println(dao.getPeople)
System.out.println(dao.getClasses)
System.out.println(dao.getPeopleInClass("Scala Design Patterns"))
System.out.println(dao.getPeopleInClass("Mountain Biking"))
System.out.println(s"Average age of everyone in Scala Design Patterns: ${userService.getAverageAgeOfUsersInClass("Scala Design Patterns")}")
}
}
在前面的代码中,我们简单地从注册表中导入了所有内容,然后使用了它。该应用程序的输出如下截图所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/222041ae-860d-4500-9e09-cac4f3ac0dc7.png
这就是使用 Scala 中的蛋糕设计模式有多简单。
对我们的应用程序进行单元测试
测试是每个应用程序的重要部分。我们需要确保我们添加的更改不会对我们的系统其他部分产生负面影响,并且每个单元都表现正确。使用蛋糕设计模式进行测试也非常简单。
蛋糕设计模式允许我们轻松地创建不同的环境。这就是为什么我们可以创建以下测试环境:
trait TestEnvironment
extends User***ponent
with Dao***ponent
with Database***ponent
with Migration***ponent
with MockitoSugar {
override val dao: Dao = mock[Dao]
override val databaseService: DatabaseService = mock[DatabaseService]
override val migrationService: MigrationService = mock[MigrationService]
override val userService: UserService = mock[UserService]
}
上述代码简单地包含了每个组件,并使用 Mockito 模拟了每个服务。让我们使用我们的新测试环境为我们的User***ponent编写一个测试类:
class User***ponentTest extends FlatSpec with Matchers with MockitoSugar with TestEnvironment {
val className = "A"
val emptyClassName = "B"
val people = List(
Person(1, "a", 10),
Person(2, "b", 15),
Person(3, "c", 20)
)
override val userService = new UserService
when(dao.getPeopleInClass(className)).thenReturn(people)
when(dao.getPeopleInClass(emptyClassName)).thenReturn(List())
"getAverageAgeOfUsersInClass" should "properly calculate the average of all ages." in {
userService.getAverageAgeOfUsersInClass(className) should equal(15.0)
}
it should "properly handle an empty result." in {
userService.getAverageAgeOfUsersInClass(emptyClassName) should equal(0.0)
}
}
在上述代码中,我们覆盖了userService以使用实际实现,然后我们使用它进行测试。我们使用 Mockito 来模拟我们的数据库访问,然后我们简单地编写一个测试来检查一切是否正常工作。我们已经决定模拟我们的数据库访问。然而,在某些情况下,人们有测试数据库或使用 H2 进行测试。使用我们的测试环境,我们有灵活性去做我们决定的事情。
运行我们之前编写的测试可以通过mvn clean test或sbt test命令实现。
我们的测试环境允许我们在测试中启用我们想要的任何组件。我们可以在我们的测试类中简单地覆盖多个这样的组件。
其他依赖注入替代方案
关于我们之前提出的蛋糕设计模式的一个问题是我们需要编写的样板代码量,以便正确地连接一切。在大型应用程序中,这可能会成为一个问题,因此有其他替代方案可以用来处理这个问题。我们在这里简要讨论一下。
依赖注入的隐式参数
使用隐式参数是消除蛋糕设计模式中组件特性和 self 类型注解要求的一种方法。然而,隐式参数会迅速使方法定义复杂化,因为每个方法都必须声明它所依赖的任何组件的隐式参数。
依赖注入的 Reader monad
Reader monad 在 Scalaz 库中可用。依赖注入与它的结合方式是,我们让每个方法返回一个被Reader monad 包装的函数,例如:
def getAverageAgeOfUsersInClass(className: String) =
Reader((userService: UserService) => userService.getAverageAgeOfUsersInClass(className))
在上述代码中,我们只向用户公开了getAverageAgeOfUsersInClass(className: String)。通常,对于 monads,计算在这里构建,但直到最后一刻才执行。我们可以构建复杂的操作,使用map、flatMap和 for ***prehensions。我们推迟注入依赖,直到最后一刻,那时我们可以在需要实际组件或组件的 reader 上简单地调用apply。前面的解释可能听起来有点抽象,但事情实际上非常简单,可以在网上许多地方看到。
在某些情况下,这种方法与蛋糕设计模式一起使用。
修改我的库设计模式
在我们作为开发者的日常工作中,我们经常使用不同的库。然而,它们通常被设计成通用的,允许许多人使用它们,因此有时我们需要做一些额外的工作,以适应我们的特定用例,以便使事情能够正常工作。我们无法真正修改原始库代码的事实意味着我们必须采取不同的方法。我们已经探讨了装饰器和适配器设计模式。好吧,改进我的库模式实现了类似的功能,但它以 Scala 的方式实现,并且一些额外的工作交由编译器处理。
改进我的库设计模式在 C#中的扩展方法非常相似。我们将在以下小节中看到一些示例。
使用改进我的库
改进我的库设计模式非常容易使用。让我们看看一个例子,我们想在标准String类中添加一些有用的方法。当然,我们无法修改其代码,因此我们需要做些其他事情:
package object pimp {
implicit class StringExtensions(val s: String) extends AnyVal {
def isAllUpperCase: Boolean =
!(0 until s.length).exists {
case index =>
s.charAt(index).isLower
}
}
}
在上述代码中,我们有一个包对象。它为我们提供了便利,使我们能够不进行任何额外操作就能从同一包中的类访问其成员。它可以是简单的对象,但那时我们将不得不import ObjectName._以获得对成员的访问。
上述对象只是一个细节,与设计模式无关。改进我的库代码是内部类。关于这一点有几个重要的事项:
-
它是隐式的
-
它扩展了
AnyVal
这些特性使我们能够编写以下应用程序:
object PimpExample {
def main(args: Array[String]): Unit = {
System.out.println(s"Is 'test' all upper case:
${"test".isAllUpperCase}")
System.out.println(s"Is 'Tes' all upper case:
${"Test".isAllUpperCase}")
System.out.println(s"Is 'TESt' all upper case:
${"TESt".isAllUpperCase}")
System.out.println(s"Is 'TEST' all upper case:
${"TEST".isAllUpperCase}")
}
}
我们基本上向标准字符串添加了一个扩展方法,用于检查整个字符串是否为大写。我们唯一需要做的是确保隐式类在我们想要使用其定义的方法的作用域内可用。
上述应用程序的输出如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/7cdb1e83-bc57-468a-8b3f-aa8428a5d2f3.png
在我们的例子中,我们不需要编写在扩展类中包装字符串的代码。我们的代码将类型显示为普通字符串;然而,我们可以对其进行额外的操作。此外,装饰器设计模式在尝试装饰的类是 final 的情况下会受到影响。在这里,没有问题。再次强调,所有魔法都发生因为我们有一个隐式类,Scala 编译器会自动确定它可以根据我们调用的方法来包装和展开字符串。
我们当然可以向StringExtensions类添加更多方法,并且它们将对所有可用的隐式类中的字符串可用。我们还可以添加其他类:
implicit class PersonSeqExtensions(val seq: Iterable[Person]) extends AnyVal {
def saveToDatabase(): Unit = {
seq.foreach {
case person =>
System.out.println(s"Saved: ${person} to the database.")
}
}
}
上述代码能够将整个Person类型的集合保存到数据库中(尽管在示例中我们只是将集合打印到标准输出)。为了完整性,我们的Person模型类定义如下:
case class Person(name: String, age: Int)
使用新的扩展方法与早期的扩展方法类似:
object PimpExample2 {
def main(args: Array[String]): Unit = {
val people = List(
Person("Ivan", 26),
Person("Maria", 26),
Person("John", 25)
)
people.saveToDatabase()
}
}
上述示例将产生预期的结果,如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/56be3880-216f-49c3-9aba-80be954171a9.png
如果需要并且合理,我们也可以将改进我的库设计模式应用于我们的自定义类。
真实生活中的改进我的库
如前所述,改进我的库设计模式极其容易使用。这很常见,尤其是在需要装饰器或适配器设计模式时。我们当然可以找出处理问题的方法,但事实上,它帮助我们避免样板代码。它也真正有助于使我们的代码更易于阅读。最后但同样重要的是,它可以用来简化特定库的使用。
可堆叠特性设计模式
有时候,我们希望能够为类的某个方法提供不同的实现。我们甚至可能不知道在编写时所有可能存在的可能性,但我们可以在以后添加它们,并将它们组合起来,或者我们可以允许其他人来做这件事。这是装饰器设计模式的一个用例,为了这个目的,它可以与可堆叠特性设计模式一起实现。我们在这本书的第七章中已经看到了这个模式,结构型设计模式,但我们用它来读取数据,这在那里增加了一个非常重要的限制。在这里,我们将看到另一个例子,以确保一切完全清楚。
使用可堆叠特性
可堆叠特性设计模式基于混入组合——这是我们在这本书的前几章中熟悉的。我们通常有一个定义接口、基本实现和扩展抽象类以在其上堆叠修改的抽象类或特性。
对于我们的例子,让我们实现以下图示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/db03b0a7-ae6c-431a-9ab9-cde8c7c50ef3.png
上述图示是一个非常简单的应用程序。我们有一个基本的StringWriter类,它有一个基本实现(BasicStringWriter),它只是返回一个包含字符串的消息。在右侧,我们有可以添加可堆叠修改的StringWriter特性的特性。
让我们看看以下代码:
abstract class StringWriter {
def write(data: String): String
}
class BasicStringWriter extends StringWriter {
override def write(data: String): String =
s"Writing the following data: ${data}"
}
上述代码是抽象类和基本实现。这些没有什么特别之处。现在,让我们看看可堆叠特性:
trait CapitalizingStringWriter extends StringWriter {
abstract override def write(data: String): String = {
super.write(data.split("\\s+").map(_.capitalize).mkString(""))
}
}
trait UppercasingStringWriter extends StringWriter {
abstract override def write(data: String): String = {
super.write(data.toUpperCase)
}
}
trait LowercasingStringWriter extends StringWriter {
abstract override def write(data: String): String = {
super.write(data.toLowerCase)
}
}
上述代码中的全部魔法都是因为方法上的abstract override修饰符。它允许我们在super类的抽象方法上调用super。否则这将失败,但在这里,它只需要我们将特性与一个实现了write的类或特性混合。如果我们不这样做,我们就无法编译我们的代码。
让我们看看我们特性的一个示例用法:
object Example {
def main(args: Array[String]): Unit = {
val writer1 = new BasicStringWriter
with UppercasingStringWriter
with CapitalizingStringWriter
val writer2 = new BasicStringWriter
with CapitalizingStringWriter
with LowercasingStringWriter
val writer3 = new BasicStringWriter
with CapitalizingStringWriter
with UppercasingStringWriter
with LowercasingStringWriter
val writer4 = new BasicStringWriter
with CapitalizingStringWriter
with LowercasingStringWriter
with UppercasingStringWriter
System.out.println(s"Writer 1: '${writer1.write("we like learning
scala!")}'")
System.out.println(s"Writer 2: '${writer2.write("we like learning
scala!")}'")
System.out.println(s"Writer 3: '${writer3.write("we like learning
scala!")}'")
System.out.println(s"Writer 4: '${writer4.write("we like learning
scala!")}'")
}
}
在前面的代码中,我们只是通过混入组合将修改堆叠在一起。在当前示例中,它们只是说明性的,并没有做任何智能的事情,但现实中我们可以有提供强大修改的变体。以下图显示了我们的示例输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/7da9e5ef-1e63-44bc-b81b-571e6a49a340.png
我们代码中的修改将取决于它们应用的顺序。例如,如果我们首先将所有内容转换为大写,那么大写化将没有任何效果。让我们看看代码和相关的输出,并尝试找出修改是如何应用的。如果你查看所有示例和输出,你会发现修改是按照我们混合特性的顺序从右到左应用的。
如果我们回顾第七章中的示例,结构设计模式,然而,我们会看到实际的修改是相反的。原因是每个特性都会调用super.readLines然后映射。嗯,这实际上意味着我们将调用堆栈上的调用,直到我们到达基本实现,然后我们将返回去做所有的映射。所以,在第七章中,结构设计模式,修改也是从右到左应用的,但由于我们只是获取输出而不传递任何东西,所以事情是按照从左到右的顺序应用的。
可堆叠特性执行顺序
可堆叠特性总是从右边的混入到左边执行。然而,有时如果我们只获取输出并且它不依赖于传递给方法的内容,我们最终会在堆栈上得到方法调用,然后这些调用将被评估,看起来就像是从左到右应用的一样。
理解前面的解释对于使用可堆叠特性非常重要。它实际上完美地匹配我们在第二章中关于线性化的观察,特性和混入组合。
类型类设计模式
在我们编写软件的许多时候,我们会遇到不同实现之间的相似性。良好的代码设计的一个重要原则是避免重复,这被称为不要重复自己(DRY)。有多种方法可以帮助我们避免重复——继承、泛型等等。
确保我们不重复自己的一个方法是通过类型类。
类型类的目的是通过类型必须支持的操作来定义一些行为,以便被认为是类型类的成员。
一个具体的例子是Numeric。我们可以这样说,它是一个类型类,并为Int、Double以及其他类似类定义了操作——加法、减法、乘法等等。实际上,我们已经在本书的第四章中遇到过类型类,抽象和自类型。类型类是允许我们实现特定多态的。
类型类示例
让我们看看一个实际例子,这个例子对开发者来说也有些有用。在机器学习中,开发者往往在他们的工作中经常使用一些统计函数。有统计库,如果我们尝试它们,我们会看到这些函数对不同数值类型——Int、Double等等——都是存在的。现在,我们可以想出一个简单的方法,为所有我们认为的数值类型实现这些函数。然而,这是不可行的,并使得我们的库无法扩展。此外,统计函数的定义对于任何类型都是相同的,所以我们不希望像数值类型那么多地重复我们的代码。
所以让我们首先定义我们的类型类:
trait Number[T] {
def plus(x: T, y: T): T
def minus(x: T, y: T): T
def divide(x: T, y: Int): T
def multiply(x: T, y: T): T
def sqrt(x: T): T
}
前面只是一个定义了一些需要数字支持的操作的特质的例子。
Scala 中的 Numeric
Scala 编程语言有一个Numeric特质,它定义了许多前面提到的操作。
如果我们在前面的代码中使用了Numeric特质,我们可以节省一些代码编写,但为了这个例子,让我们使用我们的自定义类型。
在我们定义了一个数字的特质之后,我们现在可以按照以下方式编写我们的库:
object Stats {
// same as
// def meanT(implicit ev: Number[T]): T =
// ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
def meanT: Number: T =
implicitly[Number[T]].divide(
xs.reduce(implicitly[Number[T]].plus(_, _)),
xs.size
)
// assumes the vector is sorted
def medianT: Number: T =
xs(xs.size / 2)
def varianceT: Number: T = {
val simpleMean = mean(xs)
val sqDiff = xs.map {
case x =>
val diff = implicitly[Number[T]].minus(x, simpleMean)
implicitly[Number[T]].multiply(diff, diff)
}
mean(sqDiff)
}
def stddevT: Number: T =
implicitly[Number[T]].sqrt(variance(xs))
}
在前面的例子中有很多代码。定义函数相当直接。然而,让我们解释一下implicitly关键字的作用。它使用 Scala 中的所谓上下文界限,这是允许我们实现类型类设计模式的关键部分。为了使用前面的方法,它需要一个类型类成员Number对于T类型是隐式可用的。正如你在mean上面的注释中可以看到的,我们还可以为方法提供一个隐式参数。
现在,让我们编写一些示例代码,这些代码将使用前面提到的方法:
import Stats._
object StatsExample {
def main(args: Array[String]): Unit = {
val intVector = Vector(1, 3, 5, 6, 10, 12, 17, 18, 19, 30, 36, 40, 42, 66)
val doubleVector = Vector(1.5, 3.6, 5.0, 6.6, 10.9, 12.1, 17.3, 18.4, 19.2, 30.9, 36.6, 40.2, 42.3, 66.0)
System.out.println(s"Mean (int): ${mean(intVector)}")
System.out.println(s"Median (int): ${median(intVector)}")
System.out.println(s"Std dev (int): ${stddev(intVector)}")
System.out.println(s"Mean (double): ${mean(doubleVector)}")
System.out.println(s"Median (double): ${median(doubleVector)}")
System.out.println(s"Std dev (double): ${stddev(doubleVector)}")
}
}
编译前面的代码现在将不会成功,我们会看到类似于以下错误的错误:
Error:(9, 44) could not find implicit value for evidence parameter of type ***.ivan.nikolov.type_classes.Number[Int]
System.out.println(s"Mean (int): ${mean(intVector)}")
^
原因是我们还没有为Int和Double定义任何隐式可用的Number成员。让我们在Number特质的伴随对象中定义它们:
import Math.round
object Number {
implicit object DoubleNumber extends Number[Double] {
override def plus(x: Double, y: Double): Double = x + y
override def divide(x: Double, y: Int): Double = x / y
override def multiply(x: Double, y: Double): Double = x * y
override def minus(x: Double, y: Double): Double = x - y
override def sqrt(x: Double): Double = Math.sqrt(x)
}
implicit object IntNumber extends Number[Int] {
override def plus(x: Int, y: Int): Int = x + y
override def divide(x: Int, y: Int): Int = round(x.toDouble / y.toDouble).toInt
override def multiply(x: Int, y: Int): Int = x * y
override def minus(x: Int, y: Int): Int = x - y
override def sqrt(x: Int): Int = round(Math.sqrt(x)).toInt
}
}
现在,我们的代码将成功编译。但是当我们刚刚在一个完全不同的文件中的伴随对象中定义了这些隐式值时,整个事情是如何工作的呢?首先,我们的嵌套对象是隐式的,其次,它们在伴随对象中是可用的。
在伴随对象中定义你的默认类型类成员
隐式类型类参数的伴随对象是编译器最后查找隐式值的地方。这意味着不需要做任何额外的事情,用户可以轻松地覆盖我们的实现。
我们现在可以轻松地运行我们的代码:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/c4c8d624-3ba1-4544-90eb-7f0cafa4931b.png
当然,我们可以将我们的隐式值放在我们想要的地方。然而,如果它们不在伴随对象中,我们就必须进行额外的导入,以便使它们可用。
类型类设计模式替代方案
当然,类型类设计模式有替代方案。我们可以使用适配器设计模式。然而,这将使我们的代码难以阅读,因为事物将始终被包装,并且将更加冗长。类型类设计模式利用了 Scala 类型系统的良好特性。
看看我们前面的代码,我们还可以看到有很多样板代码。在更大的项目或尝试定义更复杂的类型类时,这可能会成为问题。一个专门编写来处理这些问题的库可以在github.***/mpilquist/simulacrum/找到。
懒加载
编写高效的代码是软件工程的重要组成部分。很多时候,我们会看到由于不同的可能原因,表达式评估成本高昂的情况——数据库访问、复杂计算等等。在某些情况下,我们甚至可以在不评估这些昂贵表达式的情况下退出应用程序。这就是懒加载变得有帮助的地方。
懒加载确保表达式仅在真正需要时才被评估一次。
Scala 支持几种懒加载方式——懒变量和按名参数。在这本书中,我们已经看到了这两种方式:前者是在我们查看第六章的创建型设计模式时看到的,即创建型设计模式,特别是懒初始化。后者我们在几个地方都看到了,但第一次是在第八章行为设计模式 - 第一部分中遇到的,我们向您展示了如何以更接近 Scala 的方式实现命令设计模式。
懒变量和按名参数之间存在一个重要的区别。懒变量只计算一次,而按名参数每次在方法中引用时都会计算。这里有一个非常简单的技巧我们将展示,这将解决这个问题。
只计算一次按名参数
让我们设想我们有一个从数据库中获取人员数据的程序。读取操作是一种昂贵的操作,是懒加载的良好候选者。在这个例子中,我们将简单地模拟从数据库中读取。首先,我们的模型将尽可能简单,如下所示:
case class Person(name: String, age: Int)
现在,让我们创建一个伴随对象,它将有一个模拟从数据库获取人员数据的方法:
object Person {
def getFromDatabase(): List[Person] = {
// simulate we're getting people from database by sleeping
System.out.println("Retrieving people...")
Thread.sleep(3000)
List(
Person("Ivan", 26),
Person("Maria", 26),
Person("John", 25)
)
}
}
之前的代码只是让当前线程休眠三秒钟并返回一个静态结果。多次调用getFromDatabase方法会使我们的应用程序变慢,因此我们应该考虑惰性评估。现在,让我们向我们的伴随对象添加以下方法:
def printPeopleBad(people: => List[Person]): Unit = {
System.out.println(s"Print first time: ${people}")
System.out.println(s"Print second time: ${people}")
}
如您所见,我们简单地打印了两次关于人员的数据列表,并且两次访问了按名称参数。这是不好的,因为它将评估函数两次,我们不得不等待两倍的时间。让我们写另一个版本来解决这个问题:
def printPeopleGood(people: => List[Person]): Unit = {
lazy val peopleCopy = people
System.out.println(s"Print first time: ${peopleCopy}")
System.out.println(s"Print second time: ${peopleCopy}")
}
这次,我们将按名称参数分配给lazy val,然后使用它。这将只评估一次按名称参数,而且,如果我们最终没有使用它,它将根本不会评估。
让我们看看一个例子:
object Example {
import Person._
def main(args: Array[String]): Unit = {
System.out.println("Now printing bad.")
printPeopleBad(getFromDatabase())
System.out.println("Now printing good.")
printPeopleGood(getFromDatabase())
}
}
如果我们运行这个应用程序,我们将看到以下输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/347d0943-321f-48a9-8fef-e09c7287b163.png
如您从程序输出中可以看到,我们方法的第一个版本检索了按名称参数值两次,而第二个版本只检索了一次。在第二个方法中使用lazy val的事实也意味着如果我们实际上没有使用它,我们可能根本不会评估我们的昂贵表达式。
替代惰性评估
在 Scala 中实现惰性求值还有另一种方法。这是通过使用匿名函数并利用函数是 Scala 中统一的一部分以及我们可以轻松地将它们作为参数传递的事实来实现的。这样做的方式如下——一个值被表示为() => value而不是仅仅是值本身。然而,这有点没有意义,尤其是因为我们已经有了两种可以做到很多事情的机制。使用匿名函数进行惰性求值是不推荐的。
将一个函数传递给一个方法也可以被认为是一种惰性评估一些数据的方式。然而,这可能是有用的,不应该与我们在匿名函数中提到的内容混淆。
部分函数
在数学中,以及作为结果在编程中,有一些函数并不是对所有可能的输入都定义的。一个简单的例子是平方根函数——它只对非负实数有效。在本节中,我们将探讨部分函数以及我们如何使用它们。
部分函数不是部分应用函数
关于部分函数是什么以及不是什么似乎存在一些混淆。重要的是你要明白,这些函数不是部分应用函数。部分应用函数只是可能接受多个参数的函数,我们指定了一些参数,然后它们返回具有较少参数的函数,我们可以指定这些参数。还有一个与部分应用函数相关的术语——柯里化函数。在功能方面,它们提供相同的功能。让我们快速看一个例子:
/**
* Note that these are not partially defined functions!
*/
object PartiallyAppliedFunctions {
val greaterOrEqual = (a: Int, b: Int) => a >= b
val lessOrEqual = (a: Int, b: Int) => a <= b
def greaterOrEqualCurried(b: Int)(a: Int) = a >= b
def lessOrEqualCurried(b: Int)(a: Int) = a <= b
val greaterOrEqualCurriedVal: (Int) => (Int) => Boolean = b => a => a >= b
val lessOrEqualCurriedVal: (Int) => (Int) => Boolean = b => a => a <= b
}
在前面的代码中,我们对大于和小于或等于函数有不同的定义。首先,我们将它们作为普通函数。第二种版本是带有多个参数列表的,最后一个是实际的柯里化函数。以下是它们的用法:
object PartiallyAppliedExample {
import PartiallyAppliedFunctions._
val MAX = 20
val MIN = 5
def main(args: Array[String]): Unit = {
val numbers = List(1, 5, 6, 11, 18, 19, 20, 21, 25, 30)
// partially applied
val ge = greaterOrEqual(_: Int, MIN)
val le = lessOrEqual(_: Int, MAX)
// curried
val geCurried = greaterOrEqualCurried(MIN) _
val leCurried = lessOrEqualCurried(MAX) _
// won't work because of the argument order
// val geCurried = greaterOrEqual.curried(MIN)
// val leCurried = lessOrEqual.curried(MAX)
// will work normally
// val geCurried = greaterOrEqualCurriedVal(MIN)
// val leCurried = lessOrEqualCurriedVal(MAX)
System.out.println(s"Filtered list: ${numbers.filter(i => ge(i) && le(i))}")
System.out.println(s"Filtered list: ${numbers.filter(i => geCurried(i) && leCurried(i))}")
}
}
我们使用部分应用函数的方式如下:
greaterOrEqual(_: Int, MIN)
这将返回一个从 Int 到 Boolean 的函数,我们可以用它来检查参数是否大于或等于 MIN 值。这是一个部分应用函数。
对于这些函数的柯里化版本,正如你所见,我们已经交换了参数。原因是柯里化函数只是一系列的单参数函数,参数按照我们看到的顺序应用。greaterOrEqualCurried(MIN) 这行代码部分应用了函数,并返回了一个我们可以像上面一样使用的柯里化函数。正如代码注释中所示,我们可以将任何多参数函数转换为柯里化函数。greaterOrEqual 和 lessOrEqual 在我们的例子中不工作是因为参数是按照它们出现的顺序应用的。最后,我们在 greaterOrEqualCurriedVal 和 lessOrEqualCurriedVal 中有一个纯柯里化版本。当我们部分应用具有多个参数列表的函数时,返回这种类型的函数。
如果我们运行前面的例子,我们将看到以下输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/b16ef7c8-238b-46e3-b9a9-48ce1f2df8bb.png
选择是否使用部分应用函数或柯里化函数取决于许多因素,包括个人偏好。在两种情况下,我们可以用稍微不同的语法达到相同的目标。正如你所见,我们可以使用 .curried 从普通函数到柯里化函数转换。我们也可以使用 Function.uncurried 调用并传递函数来实现相反的操作。当柯里化函数链中包含多个函数时,这个调用是有意义的。
使用部分应用函数进行依赖注入
由于部分应用函数和柯里化函数的工作方式,我们可以将它们用于依赖注入。我们基本上可以将依赖项应用到函数上,然后得到另一个函数,我们可以在之后使用它。
部分定义的函数
我们已经说过,部分函数只为函数可能得到的所有可能值的一个特定子集定义。这非常有用,因为我们基本上可以同时执行filter和map。这意味着更少的 CPU 周期和更易读的代码。让我们看看一个例子:
object PartiallyDefinedFunctions {
val squareRoot: PartialFunction[Int, Double] = {
case a if a >= 0 => Math.sqrt(a)
}
}
我们定义了一个从Int到Double的部分函数。它检查一个数字是否为非负数,并返回该数字的平方根。这个部分函数可以这样使用:
object PartiallyDefinedExample {
import PartiallyDefinedFunctions._
def main(args: Array[String]): Unit = {
val items = List(-1, 10, 11, -36, 36, -49, 49, 81)
System.out.println(s"Can we calculate a root for -10:
${squareRoot.isDefinedAt(-10)}")
System.out.println(s"Square roots: ${items.collect(squareRoot)}")
}
}
我们使用了接受部分函数的collect方法。我们还展示了部分函数的一个方法——isDefinedAt,其名称确切地告诉我们它做什么。我们程序的输出将是这样的:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/872cb315-f536-4e25-bfb6-485a1951abcb.png
我们的部分函数过滤掉了负数,并返回了其余数的平方根。
部分函数也可以用来链式操作,或者在某个操作不可行时执行不同的操作。它们有orElse、andThen、runWith等这样的方法。从它们的名字就可以清楚地知道前两种方法的作用。第三种方法使用部分应用函数的结果并执行可能产生副作用的行为。让我们看看orElse的一个例子:
val square: PartialFunction[Int, Double] = {
case a if a < 0 => Math.pow(a, 2)
}
首先,我们定义另一个部分函数,它对负数进行平方。然后,我们可以在我们的例子中添加一些额外的代码:
object PartiallyDefinedExample {
import PartiallyDefinedFunctions._
def main(args: Array[String]): Unit = {
val items = List(-1, 10, 11, -36, 36, -49, 49, 81)
System.out.println(s"Can we calculate a root for -10:
${squareRoot.isDefinedAt(-10)}")
System.out.println(s"Square roots: ${items.collect(squareRoot)}")
System.out.println(s"Square roots or squares:
${items.collect(squareRoot.orElse(square))}")
}
}
这将产生以下输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/38030e60-b8e4-4760-8624-1dec1a8e53f4.png
我们基本上会对负数进行平方,对正数进行平方根。从我们在本例中进行的操作的角度来看,这可能没有太多意义,但它展示了我们如何链式使用部分函数。如果我们结合不同的部分函数后,最终覆盖了整个可能的输入空间,那么使用模式匹配和普通函数可能更有意义。然而,如果我们没有匹配所有可能的值,我们可能会得到运行时异常。
隐式注入
我们已经在本书的几个地方看到了隐式转换。我们在类型类设计模式和“改进我的库”设计模式中使用了它们,我们还提到它们可以用于依赖注入。隐式转换也用于从一种类型到另一种类型的无声转换。
它们只是编译器所知的某些对象、值或方法,编译器会为我们将它们注入到需要它们的方法或位置。我们需要确保的是,使这些隐式转换对将使用它们的方法的作用域可用。
隐式转换
我们已经提到,隐式转换可以用于无声转换。有时,可能有用能够将Double赋值给Int而不出错。在其他时候,我们可能想要将一个类型的对象包装到另一个类型中,并利用新类型提供的方法:
package object implicits {
implicit def doubleToInt(a: Double): Int = Math.round(a).toInt
}
在前面的代码列表中,我们有一个包对象定义了一个方法,该方法将Double转换为Int。这将允许我们编写并成功编译以下代码:
object ImplicitExamples {
def main(args: Array[String]): Unit = {
val number: Int = 7.6
System.out.println(s"The integer value for 7.6 is ${number}")
}
}
只要ImplicitExamples对象与我们的包对象在同一个包中,我们就不需要做任何额外的事情。另一种选择是在对象内部定义我们的隐式转换,并在我们需要它的作用域中导入该对象。
我们甚至可以将类型包裹在新的对象中。Scala 中的LowPriorityImplicits类中有一些示例,可以将字符串转换为序列等。现在,让我们添加一个将Int列表转换为String的隐式转换:
implicit def intsToString(ints: List[Int]): String = ints.map(_.toChar).mkString
现在,我们可以使用我们的隐式转换来打印一个 ASCII 字符码列表作为String:
object ImplicitExamples {
def main(args: Array[String]): Unit = {
val number: Int = 7.6
System.out.println(s"The integer value for 7.6 is ${number}")
// prints HELLO!
printAsciiString(List(72, 69, 76, 76, 79, 33))
}
def printAsciiString(s: String): Unit = {
System.out.println(s)
}
}
运行这个示例将产生以下输出:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/45d6f69e-a5de-431e-bb58-973850dcfa49.png
我们可能需要隐式转换的有很多有用的东西。它们可以帮助我们很好地分离代码,但我们应该小心不要过度使用它们,因为调试可能会变得困难,代码的可读性可能会受到影响。
使用隐式转换进行依赖注入
当我们展示了使用蛋糕设计模式的依赖注入时,我们还提到可以使用隐式转换来实现它。想法是服务在一个地方创建,然后我们可以编写需要服务的隐式方法。到现在为止,你应该已经获得了足够的知识,能够独立找到正确的解决方案,所以这里我们只展示之前的大例子的一部分:
case class Person(name: String, age: Int)
在我们定义了一个模型之后,我们可以创建一个DatabaseService,如下所示:
trait DatabaseService {
def getPeople(): List[Person]
}
class DatabaseServiceImpl extends DatabaseService {
override def getPeople(): List[Person] = List(
Person("Ivan", 26),
Person("Maria", 26),
Person("John", 25)
)
}
我们的数据库服务不依赖于任何东西。它只是模拟从数据库中读取某些内容。现在,让我们创建一个UserService,它将依赖于DatabaseService:
trait UserService {
def getAverageAgeOfPeople()(implicit ds: DatabaseService): Double
}
class UserServiceImpl extends UserService {
override def getAverageAgeOfPeople()(implicit ds: DatabaseService): Double = {
val (s, c) = ds.getPeople().foldLeft((0, 0)) {
case ((sum, count), person) =>
(sum + person.age, count + 1)
}
s.toDouble / c.toDouble
}
}
如您从用户服务提供的唯一方法签名中看到的那样,它需要一个DatabaseService实例隐式可用。我们也可以显式传递一个,并覆盖我们用于测试的目的的现有实例。现在我们有了这些服务,我们可以将它们连接起来:
package object di {
implicit val databaseService = new DatabaseServiceImpl
implicit val userService = new UserServiceImpl
}
我们选择使用包对象,但任何对象或类都可以,只要我们可以在需要对象的地方导入它。现在,我们应用程序的使用很简单:
object ImplicitDIExample {
def main(args: Array[String]): Unit = {
System.out.println(s"The average age of the people is:
${userService.getAverageAgeOfPeople()}")
}
}
输出将是以下内容:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/1aa5069e-846c-494f-9179-4787d6ec14a3.png
如您所见,现在我们使用的样板代码比蛋糕设计模式少。这种方法的缺点是方法签名,当有更多依赖项时可能会变得更加复杂。在现实世界的应用中,可能会有大量的依赖项,而且由于隐式变量,代码可读性也会受到影响。可能的解决方案是将依赖项包装在对象中,并隐式传递它们。最后,关于使用哪种依赖注入策略,这主要是一个个人偏好的问题,因为两者都可以实现相同的事情。
使用隐式依赖注入进行测试
使用隐式依赖注入进行测试与使用蛋糕设计模式进行测试相似。我们可以有一个新对象,它创建服务的模拟并使它们对测试类可用。当我们想要使用服务的具体实现时,我们只需覆盖它。我们也可以在这里显式传递一个依赖项。
Duck typing
开发者的工作很大一部分是尽量减少代码重复。有多种不同的方法可以做到这一点,包括继承、抽象、泛型、类型类等等。然而,在某些情况下,强类型语言将需要一些额外的工作来最小化一些重复。让我们想象我们有一个可以读取并打印文件内容的方法。如果我们有两个不同的库允许我们读取文件,为了使用我们的方法,我们必须确保读取文件的方法以某种方式变得相同。一种方法是通过将它们包装在实现特定接口的类中来实现。假设在两个库中读取方法都有相同的签名,这很容易发生,Scala 可以使用鸭子类型,这样就可以最小化我们不得不做的额外工作。
Duck typing 是一个来自动态语言的术语,它允许我们根据它们共有的一个方法以相似的方式处理不同类型的对象。
Duck typing 的另一个名称是结构化类型。
Duck typing 示例
通过一个例子,一切都会变得清晰。让我们想象我们想要一个可以接受一个解析器并打印出解析器检测到的每个单词的方法。我们的解析器将有一个以下签名的方法:
def parse(sentence: String): Array[String]
做这件事的一个好方法是拥有一个公共接口,并让所有解析器实现它。然而,让我们设定一个条件,我们不能这样做。解析器可能来自两个不同的库,我们无法以任何方式修改或连接。
我们为这个例子定义了两种不同的解析器实现。第一个如下所示:
import java.util.StringTokenizer
class SentenceParserTokenize {
def parse(sentence: String): Array[String] = {
val tokenizer = new StringTokenizer(sentence)
Iterator.continually({
val hasMore = tokenizer.hasMoreTokens
if (hasMore) {
(hasMore, tokenizer.nextToken())
} else {
(hasMore, null)
}
}).takeWhile(_._1).map(_._2).toArray
}
}
这个解析器使用了StringTokenizer类,并返回一个由空格分隔的所有单词组成的数组。另一个实现方式如下所示:
class SentenceParserSplit {
def parse(sentence: String): Array[String] = sentence.split("\\s")
}
在这里,我们只是使用正则表达式按空格分割句子。
如您所见,这两个类都有一个具有相同签名的解析方法,但它们之间没有关联。然而,我们希望能够在方法中使用它们并避免代码重复。以下是我们可以这样做的方法:
object DuckTypingExample {
def printSentenceParts(sentence: String, parser: {
def parse(sentence: String): Array[String]
}) = parser.parse(sentence).foreach(println)
def main(args: Array[String]): Unit = {
val tokenizerParser = new SentenceParserTokenize
val splitParser = new SentenceParserSplit
val sentence = "This is the sentence we will be splitting."
System.out.println("Using the tokenize parser: ")
printSentenceParts(sentence, tokenizerParser)
System.out.println("Using the split parser: ")
printSentenceParts(sentence, splitParser)
}
}
在前面的代码中,我们将两个解析器都传递给了printSentenceParts方法,并且一切编译和运行正常。事情之所以能正常工作,是因为鸭子类型,这可以在我们示例的高亮部分中看到。我们应用程序的输出如下:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/7ce57804-9bc9-4b25-9adc-03c7968c93c3.png
我们可以通过扩展参数签名来使用鸭子类型(duck typing)来要求对象有更多的方法可用。
鸭子类型的替代方案
如您从前面的代码中看到的那样,鸭子类型使我们免去了编写额外代码和定义通用接口的需要。实现相同目的的其他方法可能包括创建实现通用接口的包装器。
何时使用鸭子类型
过度使用鸭子类型可能会对代码质量和应用程序性能产生负面影响。你不应该为了避免创建通用接口而使用鸭子类型。它应该真正只在无法在不同类型之间实现通用接口的情况下使用。关于限制鸭子类型使用的论点,还得到了这样一个事实的进一步强化,即它们在底层使用反射,这较慢且对性能产生负面影响。
记忆化
编写高性能程序通常是将良好的算法与计算机处理能力的智能使用相结合。缓存是我们可以帮助的一种机制,尤其是在方法需要花费时间计算或在我们的应用程序中被频繁调用时。
记忆化是一种基于函数的参数记录其结果以减少连续调用中计算的方法。
除了节省 CPU 周期外,记忆化还可以通过只保留每个结果的单个实例来最小化应用程序的内存占用。当然,为了使整个机制正常工作,我们需要一个函数,当传递相同的参数时,它总是返回相同的结果。
记忆化示例
实现记忆化(memoization)的方式有很多。其中一些使用命令式编程风格,并且获取它们相对直接。在这里,我们将展示一个更适合 Scala 的方法。
让我们想象一下,我们将需要多次对字符串进行哈希处理。每次哈希处理都需要一些时间,这取决于底层算法,但如果我们存储一些结果并重复使用它们来处理重复的字符串,我们就可以在牺牲结果表的情况下节省一些计算。
我们将从以下简单内容开始:
import org.apache.***mons.codec.binary.Hex
class Hasher extends Memoizer {
def md5(input: String) = {
System.out.println(s"Calling md5 for $input.")
new String(Hex.encodeHex(MessageDigest.getInstance("MD5").digest(input.getBytes)))
}
}
前面的代码是一个具有名为md5的方法的类,该方法返回我们传递给它的字符串的哈希值。我们混合了一个名为Memoizer的特质,其表示如下:
import scala.collection.mutable.Map
trait Memoizer {
def memoX, Y: (X => Y) = {
val cache = Map[X, Y]()
(x: X) => cache.getOrElseUpdate(x, f(x))
}
}
之前的特质有一个名为 memo 的方法,该方法使用可变映射根据其输入参数检索函数的结果,或者如果结果不在映射中,则调用传递给它的实际函数。此方法返回一个新的函数,实际上使用上述映射并对其结果进行记忆化。
之前提到的记忆化示例可能不是线程安全的。多个线程可能并行访问映射并导致函数被执行两次。如果需要,确保线程安全是开发者的责任。
我们使用了泛型的事实意味着我们可以实际上使用这种方法来创建任何单参数函数的记忆化版本。现在,我们可以回到我们的 Hasher 类并添加以下行:
val memoMd5 = memo(md5)
这使得 memoMd5 函数确实与 md5 函数做相同的事情,但内部使用映射尝试检索我们已计算的结果。现在,我们可以用以下方式使用我们的 Hasher:
object MemoizationExample {
def main(args: Array[String]): Unit = {
val hasher = new Hasher
System.out.println(s"MD5 for 'hello' is '${hasher.memoMd5("hello")}'.")
System.out.println(s"MD5 for 'bye' is '${hasher.memoMd5("bye")}'.")
System.out.println(s"MD5 for 'hello' is '${hasher.memoMd5("hello")}'.")
System.out.println(s"MD5 for 'bye1' is '${hasher.memoMd5("bye1")}'.")
System.out.println(s"MD5 for 'bye' is '${hasher.memoMd5("bye")}'.")
}
}
此示例的输出将如下所示:
https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-dsn-ptn/img/b9f1df58-2794-4348-b385-3a9c71711af9.png
之前的输出证明,对于相同的输入调用我们的记忆化函数实际上是从映射中检索结果,而不是再次调用处理结果的代码部分。
记忆化替代方案
我们之前展示的 memo 方法相当简洁且易于使用,但它有限制。我们只能获取具有一个参数的函数的记忆化版本(或者我们必须将多个参数表示为元组)。然而,Scalaz 库已经通过 Memo 对象支持记忆化。我们可以简单地做以下操作:
val memoMd5Scalaz: String => String = Memo.immutableHashMapMemo {
md5
}
之前的代码可以放入我们的 Hasher 类中,然后我们可以在示例中调用 memoMd5Scalaz 而不是编写额外的 Memoizer 特质。这将不需要我们编写额外的 Memoizer 特质,并且会产生与之前展示完全相同的结果。此外,Scalaz 版本在缓存方式等方面给我们提供了更多的灵活性。
摘要
在本章中,我们了解了如何将 Scala 编程语言的某些高级概念应用于解决实际软件项目中常见的问题。我们探讨了透镜设计模式,在那里我们也首次接触到了卓越的 Scalaz 库。我们看到了如何在 Scala 中实现依赖注入而无需任何额外库,以及它的用途。我们还学习了如何为我们没有修改权限的库编写扩展。最后但同样重要的是,我们研究了类型类设计模式、Scala 中的懒加载、部分函数(也称为函数柯里化)、鸭子类型、记忆化和隐式注入。到目前为止,你应该对 Scala 的语言可能性以及设计模式有了相当广泛的知识,这些可以一起用来编写出色的软件。
在本书的下一章和最后一章中,我们将更加关注 Scalaz 库,并展示其对我们已经看到的一些概念的支持。我们还将完成一个最终项目,将我们的知识整合成可以用于生产代码的东西。最后,我们将简要总结本书涵盖的内容,并提供有用的指导。