购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.4 并发

Scala有许多诱人之处,能够使用Akka API通过直观的actor模式构建健壮的并发应用便是其中之一(请参考http://akka.io)。

下面的示例有些激进,不过却能让我们体会到Scala的强大和优雅。将Scala与一套直观的并发API相结合,便能以如此简洁优雅的方式实现并发软件。你之前研究Scala的一个原因可能是寻求更好的并发之道,以便更好地利用多核CPU和集群中的服务器来实现并发。使用actor并发模型便是其中的一种方法。

在actor并发模型中,actor是独立的软件实体,它们之间并不共享任何可变状态信息。actor之间无须共享信息,通过交换消息的方式便可进行通信。通过消除同步访问那些共享可变状态,编写健壮的并发应用程序变得非常简单。尽管这些actor也许需要修改状态,但是假如这些可变状态对外不可访问,并且actor框架确保actor相关代码调用是线程安全的,开发者就无须再费力编写枯燥而又容易出错的 同步原语 (synchronization primitive)了。

在这个简单示例中,我们会将表示几何图形的一组类的实例发送给一个actor,该actor再将这组实例绘制到显示器上。你可以想象这样一个场景: 渲染工厂 (rendering farm)在为动画生成场景。一旦场景渲染完毕,构成场景的几何图形便会被发送给某一actor进行展示。

首先,我们将定义Shape类。

➊此处声明了一个表示二维点的类。

➋此处声明了一个表示几何形状的抽象类。

➌此处实现了一个“绘制”形状的draw方法,该方法中仅输出了一个格式化的字符串。

➍Circle类由圆心和半径组成。

➎位于左下角的点、高度和宽度这三个属性构成了矩形。为了简化问题,我们规定矩形的各条边分别与横坐标或纵坐标平行。

➏三角形由三个点所构成。

Point类名后列出的参数列表就是类构造函数参数列表。在Scala中,整个类主体便是这个类的构造函数,因此你能在类名之后、类主体之前列出主构造函数的参数。在本示例中,Point类并没有类主体。由于我们在Point类声明的前面输入了case关键字,因此每一个构造函数参数都自动转化为Point实例的某一只读(不可变)字段。也就是说,假如要实例化一个名为point的Point实例,你可以使用point.x和point.y读取point的字段,但无法修改它们的值。尝试运行point.y = 3.0 会触发编译错误。

你也可以设置参数默认值。每个参数定义后出现= 0.0 会把 0.0 设置为该参数的默认值。因此用户无须明确给出参数值,Scala便会推导出参数值。不过这些参数值会按照从左到右的顺序进行推导。下面我们运用SBT项目去进一步探索参数默认值:

因此,当我们不指定任何参数时,Scala会使用 0.0 作为参数值。当我们只设定了一个参数值时,Scala会把这个值赋予最左边的参数x,而剩下来的参数则使用默认值。我们还可以通过名字指定参数。对于p02 对象,当我们想使用x的默认值却为y赋值时,可以使用Point(y = 2.0)的语句。

由于Point类并没有类主体,case关键字的另一个特征便是让编译器自动为我们生成许多方法,其中包括了类似于Java语言中String、equals和hashCode方法。每个点显示的输出信息,如Point(2.0,0.0),其实是toString方法的输出。大多数开发者很难正确地实现equals方法和hashCode方法,因此自动生成这些方法具有实际的意义。

Scala调用生成的equals方法,以判断p00 == p20 和p20 == p20b是否成立。这与Java的做法不同,Java通过比较引用是否相同来判断==是否成立。在Java中如果希望执行一次逻辑比较,你需要明确地调用equals方法。

现在我们要谈论case类的最后一个特性,编译器同时会生成一个 伴生对象 (companionobject),伴生对象是一个与case类同名的单例对象(本示例中,Point对象便是一个伴生对象)。

你可以自己定义伴生对象。任何时候只要对象名和类名相同并且定义在同一个文件中,这些对象就能称作伴生对象。

随后可以看到,我们可以在伴生对象中添加方法。不过伴生对象中已经自动添加了不少方法,apply方法便是其中之一。该方法接受的参数列表与构造函数接受的参数列表一致。

任何时候只要你在输入对象后紧接着输入一个参数列表,Scala就会查找并调用该对象的apply方法,这也意味着下面两行代码是等价的。

如果对象中未定义apply方法,系统将抛出编译错误。与此同时,输入参数必须与预期输入相符。

Point.apply方法实际上是构建Point对象的工厂方法,它的行为很简单;调用该方法就好像是不通过new关键字调用Point的构造函数一样。伴生对象其实与下列代码生成的对象无异。

不过,伴生对象apply方法也可以用于决定相对复杂的类继承结构。父类对象需判断参数列表与哪个字类型最为吻合,并依此选择实例化的子类型。比方说,某一数据类型必须分别为元素数量少的情况和元素数量多的情况各提供一个不同的最佳实现,此时选用工厂方法可以屏蔽这一逻辑,为用户提供统一的接口。

紧挨着对象名输入参数列表时,Scala会查找并调用匹配该参数列表的apply方法。换句话说,Scala会猜想该对象定义了apply方法。从句法角度上说,任何包含了apply方法的对象的行为都很像函数。

在伴生对象中安置apply方法是Scala为相关类定义工厂方法的一个便利写法。在类中定义而不是在对象中定义的apply方法适用于该类的实例。例如,调用Seq.apply(index:Int)方法将获得序列中指定位置的元素(从 0开始计数)。

Shape是一个抽象类。在Java中我们无法实例化一个抽象类,即使该抽象类中没有抽象成员。该类定义了Shape.draw方法,不过我们只希望能够实例化具体的形状:圆形、矩阵或三角形。

请注意传给draw方法的参数,该参数是一个类型为String => Unit的函数。也就是说,函数f接受字符串参数输入并返回Unit类型。Unit是一个实际存在的类型,它的表现却与Java中的void类型相似。在函数式编程中,大家将void类型称为Unit类型.

具体做法是draw方法的调用者将传入一个函数,该函数会接受表示具体形状的字符串,并执行实际的绘图工作。

假如某函数返回Unit对象,那么该函数肯定是有副作用的。Unit对象没有任何作用,因此该函数只能对某些状态产生副作用。副作用可能会造成全局范围的影响,比如执行一次输入或输出操作(I/O),也可能只会影响某些局部对象。

通常在函数式编程中,人们更青睐于那些没有任何副作用的纯函数,这些纯函数的返回值便是它们的工作成果。纯函数容易阐述、易于测试,也很方便重用,而副作用往往是错误之源。不过最起码现实中的程序离不开I/O。

Shape.draw阐明了这样一个观点:与Strings、Ints、Points和其他对象无异,函数也是第一等级的值。和其他值一样,我们可以将函数赋给变量,将函数作为参数传递给其他函数,就好像draw方法一样。函数还能作为其他函数的返回值。我们将利用函数这一特性构建可组合并且灵活的软件。

假如某函数接受其他函数参数并返回函数,我们称之为 高阶函数 (higher-order function,HOF)。

我们可以认为draw方法定义了一个所有形状类都必须支持的协议,而用户可以自定义这个协议的实现。各个形状类可以通过toString方法决定如何将状态信息序列化为字符串。draw方法会调用f函数,而f函数通过Scala 2.10 引入的新特性 插值字符串 (interpolated string)构建了最终的字符串。

如果你忘了在“插值字符串”前输入s字符,draw: ${this.toString}将原封不动地返回给你。也就是说,字符串不会被窜改。

Circle、Rectangle和Triangle类都是Shape类的具体子类。这些类并没有类主体,这是因为case关键字为它们定义好了所有必须的方法,如Shape.draw所需要的toString方法。

为了简化问题,我们规定矩形的各条边平行于 x y 轴。因此,我们使用一个点(左侧最低点即可)、矩形的高度和宽度便能描述矩阵。而Triangle类(三角形)的构造函数则接受三个Pointer对象参数。

在简化后的程序中,传递给draw方法的f函数只会在控制台中输出一条字符串,不过你也许有机会构建一个真实的图形程序,该程序将使用f函数将图形绘制到显示器上。

既然已经定义好了形状类型,我们便可以回到actor上。其中,Typesafe(http://typesafe.com)贡献的Akka类库(http://akka.io)会被使用到。项目文件build.sbt中已经将该类库设定为项目依赖项。

下面列出ShapesDrawingActor类的实现代码:

➊此处声明了对象Messages,该对象定义了大多数actor之间进行通信的消息。这些消息就好像信号量一样,触发了彼此的行为。将这些消息封装在一个对象中是一个常见的封装方式。

➋Exit和Finished对象中不包含任何状态,它们起到了标志的作用。

➌当接收到发送者发送的消息后, 样板类 (case class)Response会随意构造字符串消息,并将消息返回给发送者。

➍ 导入akka.actor.Actor类型(http://doc.akka.io/api/akka/current/#akka.actor.Actor)。Actor类型是一个抽象基类,我们将继承该类定义actor。

➎此处定义了一个actor类,用于绘制图形。

➏ 此处导入了Messages对象中定义的三个消息。Scala支持 嵌套导入 (nesting import),嵌套导入会限定这些值的作用域。

➐此处实现了抽象方法Actor.receive。该方法是Actor的子类必须实现的方法,定义了如何处理接收到的消息。

包括Akka在内的大多数actor系统中,每一个actor都会有一个关联邮箱(mailbox)。关联邮箱中存储着大量消息,而这些消息只有经过actor处理后才会被提取。Akka确保了消息处理的顺序与接收顺序相同,而对于那些正在被处理的消息,Akka保证不会有其他线程抢占该消息。因此,使用Akka编写的消息处理代码天生具有线程安全的特性。

需要注意的是,Akka支持一种奇特的receive方法实现方式。该实现不接受任何参数,而实现体中也只包含了一组由case关键字开头的表达式。

偏函数(PartialFunction,http://www.scala-lang.org/api/current/#scala.PartialFunction)是一类较为特殊的函数,上述函数体所用的语法就是典型的偏函数语法。偏函数实际类型是PartialFunction[Any,Unit],这说明偏函数接受单一的Any类型参数并返回Unit值。Any是Scala类层次级别的根类,因此该函数可以接受任何参数。由于该函数返回Unit对象,因此函数体一定会产生副作用。由于actor系统采用了异步消息机制,它必须依靠副作用。通常情况下由于传递消息后无法返回任何信息,我们的代码块中便会发送一些其他消息,包括给发送者的返回信息。

偏函数中仅包含了一些case子句,这些子句会对传递给函数的消息执行模式匹配。代码中并没有任何表示消息的函数参数,内部实现需要处理这些消息。

当匹配上某一模式时,系统将执行从箭头符(=>)到下一个case子句(也有可能是函数结尾处)之间的表达式。由于箭头符和下一个case关键字能够无误地标识代码区间,因此无须使用大括号包住表达式。另外,假如case关键字后只有一句简短的表达式,可以不用换行,直接将表达式放在箭头后面。

尽管听上去挺复杂,实际上偏函数是一个简单的概念。单参数函数会接受某一类型的输入值并返回相同或不同类型的值。而选用偏函数相当于明确地告诉其他人:“我也许无法处理所有你输入给我的值。”除法x/y是数学上的一个经典偏函数例子,当分母y为 0 时,x/y的值是不确定的。因此,除法是一个偏函数。

receive方法会尝试将接收到的各条消息与这三个模式匹配表达式进行匹配,并执行最先被匹配上的表达式。接下来我们对receive方法进行分解。

➊ 如果收到的信息是Shape的一个实例,那说明该消息匹配了第一条case子句。我们也会将Shape对象引用赋给变量s。也就是说,虽然输入消息的类型为Any,但s类型却是Shape。

➋判断消息是否为Exit消息体。Exit消息用于标识已经完成。

➌这是一条“默认”子句,可以匹配任何输入。该子句等同于unexpected: Any子句,对于那些未能与前两个子句模式匹配的任何输入,该子句都会匹配。而变量unexpected会被赋予消息值。

最后一条匹配规则能匹配任何消息,因此该规则必须放到最后一位。假如你尝试将其放置到某些规则之前,你将看到unreachable code的错误信息。这是因为这些后续的case表达式不可访问。

值得注意的是,由于我们添加了“默认”子句,这个“偏”函数其实变成了“完整的”,这意味着该函数能正确处理任何输入。

下面让我们查看每个匹配点调用的表达式:

➊调用了形状s的draw方法并传入一个匿名函数,该匿名函数了解如何处理draw方法生成的字符串。在这段代码中,此匿名函数仅打印了生成的字符串。

➋向“发信方”回复了一个消息。

➌打印了一条表示正在退出的消息。

➍向“发信方”发送了一条结束信息。

➎根据错误信息生成Response对象,并打印错误信息。

➏向“发信方”回复了这条信息。

代码sender ! Response(s'ShapesDrawingActor: $s drawn')创建了回复信息,并将该信息发送给了shape对象的发送方。Actor.sender函数返回了actor发送消息接收方的对象引用,而!方法则用于发送异步消息。是的,!是一个方法名,使用!遵循了之前Erlang的消息发送规范,值得一提的是,Erlang是一门推广actor模型的语言。

我们也可以在Scala允许范围内使用一些语法糖。下面两行代码是等价的:

假如某一方法只接受单一参数,你可以省略掉对象后的点号和参数周边的括号。请注意,第一行代码看起来更清晰,这也是Scala支持这种语法的原因。表示法sender ! Response被称为中置表示法,这是因为操作符!位于对象和参数中间。

Scala的方法名可以是操作符。调用接受单一参数的方法时可以省略对象后的点号和参数周边的括号。不过有时候省略它们会导致解析二义性,这时你需要保留点号或保留括号,有时候两者都需要保留。

在进入最后一个actor之前还有最后一个值得注意的地方。使用面向对象编程时,有一条经常被人提及的原则:永远不要在case语句上进行类型匹配。这是因为如果继承层次结构发生了变化,case表达式也会失效。作为替代方案,你应该使用多态函数。这是不是意味着我们之前谈论的模式匹配代码只是一个反模式呢?

回顾一下,我们之前定义的Shape.draw方法调用了Shape类的toString方法,由于Shape类的那些子类是case类,因此这些子类中实现了toString方法。第一个case语句中的代码调用了多态的toString操作,而我们也没有与Shape的某一具体子类进行匹配。这意味着即便修改了Shape类层次结构,我们的代码也不会失效。其他的case子句所匹配的条件也与类层次无关,即便这些条件真会发生变化,变化也不会频繁。

由此,我们将面向对象编程中的多态与函数式编程中的劳模——模式匹配结合到了一起。这是Scala优雅地集成这两种编程范式的方式之一。

模式匹配与子类型多态

模式匹配在函数式编程中扮演了重要的角色,而子类型多态(即重写子类型中的方法)在面向对象编程的世界中同样不可或缺。函数式编程中的模式匹配的重要性和复杂度都要远超过大多数命令式语言中对应的swith/case语句。我们将在第4章深入探讨模式匹配。在此处的示例中,我们开始了解到函数风格的模式匹配和多态调度之间的结合会产生强大的组合效果,而这也是像Scala这样的混合范式语言能提供的一大益处。

最后,我将列出运行此示例的ShapesDrawingDriver对象的代码:

➊定义仅用于本文件的消息(私有消息),该消息用于启动。使用一个特殊的开始消息是一个普遍的做法。

➋定义“驱动”actor。

➌定义了用于驱动应用的主方法。主方法先后构建了一个akka.actor.ActorSystem对象(http://doc.akka.io/api/akka/current/#akka.actor.ActorSystem)和两个actor对象:我们之前讨论过的ShapesDrawingActor对象和即将讲解的ShapesDrawingDriver对象。我们暂时先不讨论设置Akka的方法,在 17.3 节将详细讲述。现在只需要知道我们把ShapesDrawingActor对象传递给了ShapesDrawingDriver即可,事实上我们向ShapesDrawingDriver对象传递的对象属于akka.actor.ActorRef类型(http://doc.akka.io/api/akka/current/#akka.actor.ActorRef,actor的引用类型,指向实际的actor实例)。

➍ 向驱动对象发送Start命令,启动应用!

➎定义了actor类:ShapesDrawingDriver。

➏当receive方法接收到Start消息时,它将向ShapesDrawingActor发送五个异步消息:包含了三个形状类对象,Pi值(将被视为错误信息)和Exit消息。从这能看出,这是一个生命周期很短的actor系统!

➐假如ShapesDrawingDriver发送Exit消息后接收到了返回的Finished消息(请回忆一下ShapesDrawingActor类处理Exit消息的逻辑),那么我们将访问Actor类提供的context字段来关闭actor系统。

➑简单地打印出其他错误的回复信息。

➒与之前所见的默认子句一样,该子句用于处理预料之外的消息。

让我们尝试运行该程序!在sbt提示符后输入run,sbt将按需编译代码并列出所有定义了main方法的代码示例程序:

输入数字 1,之后我们便能看到下列输出 (为了方便显示,已对输出内容进行排版):

由于所有的消息都是以异步的方式发送的,你可以看到驱动actor和绘图actor的消息交织在一起。不过处理消息的顺序与发送消息的顺序相同。运行多次应用程序,你会发现每次输出都会不同。

到现在为止,我们已经尝试了基于actor的并发编程,同时也掌握了一些很有威力的Scala特性。 jV08++yHh0ya7zEbt9dUHnieOrwynQXabOxTRwnrPMrUTeW9A18oeRhSpuUUlJYC

点击中间区域
呼出菜单
上一章
目录
下一章
×