第三章:Scala OOP

主要内容:

  1. 构建MongoDB驱动
  2. case模式匹配
  3. 命名和默认参数

上一章讲了Scala的基础部分,这章介绍Scala面向对象的特性。面向对象编程不是新鲜事物,但是Scala在面向对象基础上添加了一些新的特性。
本章将使用Scala构建MongoDB的驱动。MongoDB也是可伸缩的(scalable),基于文档的数据库。你将使用Scala面向对象构造器快速构建MongoDB驱动程序,并在此讲述每一步的概念。Scala使得面向对象得到革新,其中包括特征(trait)。特征相似与Java的半实现的抽象类(abstract classes)。本章将介绍特征在Scala程序中是如何工作的。同时,本章也将介绍容器类(case classes),容器类对于构建不可变类是非常有用的,另外也会介绍并发和数据-对象转换。容器类是函数式编程和面向对象编程的桥梁。

Building a Scala MongoDB driver: user stories

首先解析一下这个概念,顾名思义就是用户故事。这是敏捷开发中的一种模式,也叫用户案例(user case),就是站在用户价值立场实时分析情景,只对需求作描述,不对需求作规范。

我们思考一下,为什么要构建MongoDB的驱动?MongoDB不是已有现成的MongoDB驱动了吗?但考虑下面的user stories: As a developer, I want an easier way to connect to my MongoDB server and access document databases. As a developer, I want to query and manage documents.

MongoDB是一个用C++开发的数据库,具有可伸缩性、高并发、开源、模式自由的、面向文档的特性。MongoDB是一种用JSON(JavaScript Object Notation)数据格式存储的基于文档的数据库。这种模式自由的特性使得MongoDB可以存储任何类型的数据结构,因此你不需要定义你的数据库表和属性。你可以按需求任意删除属性。这种自由度是通过基于文档模式实现的。和关系型数据库不同,一个基于文档模式的记录(records)被作为一个文档存储,因此该记录可以存储任何长度、任何数量的字段。例如,一个集合(collection)中可以存储如下形式(集合在MongoDB中相当于传统RDBMS(Relational Database Management System)数据库中的表)

1
2
3
{ name : "Joe", x : 3.3, y : [1,2,3] }
{ name : "Kate", x : "abc" }
{ q : 456 }

在模式自由的环境中,这里的模式(schema)更多是指应用程序,而不是数据库。和其他工具一样,模式自由的数据库有利也有弊,关键取决于你选择的解决方案。

在MongoDB中文档的存储格式是BSON(Binary JSON),其他基于文档的数据库,如IBM的Lotus Notes和Amazon的SimpleDB则是使用XML结构存储。在基于Web应用程序中,JSON有一个突出的优势就是JSON文档可以非常容易地进行转换并使用。更多内容,参考http://try.mongodb.org.并下载安装到本地。开启MongoDB服务:

1
$ bin/mongod

开启服务之后,我们打开Shell进行数据库连接:

1
2
3
4
5
$ bin/mongo
MongoDB shell version: 1.2.4
url: test
connecting to: test
type "help" for help

搭建好数据库后,我们将进入MongoDB驱动开发阶段。

Classes and constructors

创建一个Mongo客户端连接到服务上:

1
<scala> class MongoClient(val host:String, val port:Int)

这看起来和Java、C#不同,不但声明了类,还声明了主构造器(primary constructor)。

当创建一个MongoClient类的实例时,主构造函数以重载的形式直接或间接地被调用。在Scala中,主构造器(primary constructor)是在类声明时行内出现。如本例中,构造器带有两个参数,host和port,即指定MongoDB的主机IP和端口。

因为所有构造器参数都是以val为前导,Scala会为每个参数创建不可变的实例。如:

1
2
3
4
5
6
scala> val client = new MongoClient("127.0.0.1", 123)
client: MongoClient = MongoClient@561279c8
scala> client.port
res0: Int = 123
scala> client.host
res1: String = 127.0.0.1

和Java、C#一样,Scala通过new关键字创建类的实例。不同的是,在Scala中,类的语句体是可选的,因此可以在声明类的不带语句体。如,要创建一个包含setter和getter的JavaBean,在Scala中就变得很简单了:

1
2
3
4
5
6
7
8
9
10
scala> class AddressBean(
var address1:String,
var address2:String,
var city:String,
var zipCode:Int)
defined class AddressBean
scala> var localAddress = new AddressBean("230 43rd street", "", "Columbus",
43233)
localAddress: (java.lang.String, java.lang.String, java.lang.String, Int) =
(230 43rd street,,Columbus,43233)

如果参数是以var为前导,Scala将创建可变的变量。val和var前导都是可选的。如果两个都不填,则参数将作为私有的实例值,不能在类外部进行访问:

1
2
3
4
5
6
scala> class MongoClient(host:String, port:Int)
defined class MongoClient
scala> val client = new MongoClient("localhost", 123)
client: MongoClient = MongoClient@4089f3e5
scala> client.host
<console>:7: error: value host is not a member of MongoClient client.host

注意,当Scala创建值或者变量时,同时也将为他们创建相应的访问者,在某种程度上直接访问值。下面定义的MongoClient和之前的是等价的:

1
2
3
4
class MongoClient(private val _host:String, private val _port:Int) {
def host = _host
def port = _port
}

使用private的原因是Scala编译器默认不会创建访问者。val和var做的是定义一个字段和getter,var则带setter。

如何为类添加setter方法
当被定义为private类型时,如果需要添加setter方法,需要在其后添加_=.方法,如下面例子包含了getter和setter:

1
2
3
4
5
class Person(var firstName:String, var lastName:String,
private var _age:Int) {
def age = _age
def age_=(newAge: Int) = _age = newAge
}

现在可以创建实例并进行操作:

1
2
val p = new Person("Nima", "Raychaudhuri", 2)
p.age = 3

赋值p.age = 3实际上相当于p.age_= (3)。当计数器遇到诸如x = e的赋值语句,它会检测是否有对应的x_=方法并进行调用。这种赋值推断(assignment interpretation)在Scala中显得很有趣,在不同的上下文中表示不同的操作。如对一个函数进行赋值f(args) = e则表示是f.update(args)。关于函数赋值,将在之后介绍。

大多数情况下,MongoDB的默认端口是27017,那在Scala中如何定义默认IP和端口呢?

1
2
3
class MongoClient(val host:String, val port:Int) {
def this() = this("127.0.0.1", 27017)
}

为了重载构造函数,通过this关键字来表示当前定义方法。你不能为其构造函数指定返回类型,因为重载函数中的第一个表达式必须调用其他重载构造函数或主构造函数,因此如下声明是错误的:

1
2
3
4
5
6
7
class MongoClient(val host:String, val port:Int) {
def this() = {
val defaultHost = "127.0.0.1"
val defaultPort = 27017
this(defaultHost, defaultPort)
}
}

编译时将报如下错误:

1
2
3
4
5
6
7
MongoClient.scala:3: error: 'this' expected but 'val' found.
val defaultHost = "127.0.0.1"
^
MongoClient.scala:4: error: '(' expected but ';' found.
val defaultPort = 27017
^
two errors found

如果要在调用其他构造函数前做出一些操作将会是一个挑战,后面会通过companion object来突破这种限制。

连接MongoDB,你需要使用MongoDB为Java提供的驱动类com.mongodb.Mongo

1
2
3
4
class MongoClient(val host:String, val port:Int) {
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
}

注意 本书使用的MongoDB驱动版本是1.10.1,更多版本信息请参考http://www.mongodb.org/display/DOCS/Java+Language+Center

因为Scala具有脚本的特性,因此你可以在类内编写像脚本一样的代码,并且会在实例创建时执行。如下面是一个打印类:

1
2
3
4
5
class MyScript(host:String) {
require(host != null, "Have to provide host name")
if(host == "127.0.0.1") println("host = localhost")
else println("host = " + host)
}

如果我们像检验构造器的参数,我们可以在类内部这样做(通常在最前面):

1
2
3
4
5
class MongoClient(val host:String, val port:Int) {
require(host != null, "You have to provide a host name")
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
}

和Java一样,Scala使用extends实现继承,如:

1
2
3
4
5
class MongoClientV2(val host:String, val port:Int)
extends Mongo(host, port){
require(host != null, "You have to provide a host name")
def this() = this("127.0.0.1", 27017)
}

你同样可以在超类(super class)中定义主构造器。一个不好的地方就是,你不能向父类传递参数验证。

注意 你不用显式继承任何类,但类默认继承了scala.AnyRef,并且scala.AnyRef是所有类型推断的基础类。

Packaging

包(package)在Scala中兼具有Java和C#两者的用法,因此,你可以这样定义:

1
2
3
4
5
6
7
8
9
10
11
12
package com {
package scalainaction {
package mongo {
import com.mongodb.Mongo
class MongoClient(val host:String, val port:Int) {
require(host != null, "You have to provide a host name")
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
}
}
}
}

这种写法和一下写法是等价的:

1
2
3
4
5
6
7
package com.scalainaction.mongo
import com.mongodb.Mongo
class MongoClient(val host:String, val port:Int) {
require(host != null, "You have to provide a host name")
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
}

当然,也可以使用大括号:

1
2
3
4
5
6
7
8
package com.scalainaction.mongo {
import com.mongodb.Mongo
class MongoClient(val host:String, val port:Int) {
require(host != null, "You have to provide a host name")
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
}
}

这的而且确会造成语法上的混乱,因此,一般都推荐使用顶部包名的方法。另外Scala的包不需要匹配目录结构,你可以任意声明多个包在同一个文件:

1
2
3
4
5
6
7
8
9
10
11
package com.persistence {
package mongo {
class MongoClient
}
package riak {
class RiakClient
}
package hadoop {
class HadoopClient
}
}

当Scala对此进行编译时,会自动将其匹配到相应的目录中,前提是你的类是使用JVM进行编译的。

Scala imports

Scala的import和Java的相似,但是用法更加简洁,如导入com.mongodb包中的所有类,

1
import com.mongodb._

在Scala中,你不仅可以在开头使用import,你可以在任何地方使用import导入,

1
2
3
4
scala> val randomValue = { import scala.util.Random
new Random().nextInt
}
randomValue: Int = 1453407425

因为import是在{}语句块内,所以Random()类只能在该语句块内使用。另外,由于Scala默认会自动导入scala包,所以,上述代码可以改为:

1
2
3
4
scala> val randomValue = { import util.Random
new Random().nextInt
}
randomValue: Int = 619602925

若要引入类的成员,也可以通过._引入:

1
2
3
4
scala> import java.lang.System._
import java.lang.System._
scala> nanoTime
res0: Long = 1268518636387441000

其中nanoTime是System的一个方法,因此可以直接使用,这和Java的static import相似(scala没有static关键字)。因为引入是相关联的,因此也可以这样:

1
2
3
4
5
6
scala> import java.lang._
import java.lang._
scala> import System._
import System._
scala> nanoTime
res0: Long = 1268519178151003000

The _root_ package in Scala
考虑如下两个包:

1
2
3
4
5
6
package monads { class IOMonad }
package io {
package monads {
class Console { val m = new monads.IOMonad }
}
}

这种情况,会发现编译报错,因为Console是在monads下,会报错没找到IOMonad类。为了声明一个顶层包,你可以使用_root_表示:

1
val m = new _root_.monads.IOMonad

另外需要说明的是,当你创建一个不带任何包的类或对象,他们属于一个空包。你不能导入空包,但是空包的成员是相互可见的。

import另外一个非常有用的特性是:你可以为你引入的类创建新的名称空间,即别名。这在某些情况下可以提高可读性。如在Java中,java.util.Datejava.sql.Date具有同样的名字,这容易造成混淆,但在Scala,可以通过映射解决这个问题:

1
2
3
4
5
6
7
import java.util.Date
import java.sql.{Date => SqlDate}
import RichConsole._
val now = new Date
p(now)
val sqlDate = new SqlDate(now.getTime)
p(sqlDate)

你甚至可以将导入的类通过占位符将其隐藏:

1
import java.sql.{Date => _ }

这样Date类将不可见。

回到用户故事(user story)中,我们需要构建完整的类并添加方法:

1
2
3
4
5
6
7
8
9
10
package com.scalainaction.mongo
class MongoClient(val host:String, val port:Int) {
require(host != null, "You have to provide a host name")
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
def version = underlying.getVersion
def dropDB(name:String) = underlying.dropDatabase(name)
def createDB(name:String) = DB(underlying.getDB(name))
def db(name:String) = DB(underlying.getDB(name))
}

Objects and companion objects

在给出DB类之前,让我们探索一下Scala的对象(objects)。Scala没有提供任何静态修改器,这样做是为了成为一门纯面向对象的编程语言的设计目标,即所有值都是对象,所有操作都是方法调用,所有变量都是对象的一个成员。如果包含static就无法成为一门纯粹的面向对象的语言,在代码中使用static会带来非常多的缺点。相反,Scala支持单例对象(singleton object)。一个单例对象限制你一个类只能有一个对象。简单实现一个单例模式可以通过如下表示:

1
2
3
object RichConsole {
def p(x: Any) = println(x)
}

这里RichConsole是一个单例对象。对象的声明和Java的class一样,只不过是使用关键字object。若要调用方法p,你需要加类名前缀,这个你在Java或C#中调用静态方法一样:

1
2
3
4
5
scala> :l RichConsole.scala
Loading RichConsole.scala...
defined module RichConsole
scala> RichConsole.p("rich console")
rich console

你可以导入并使用Object中的所有方法:

1
2
3
4
scala> import RichConsole._
import RichConsole._
scala> p("this is cool")
this is cool

在这里说到的DB实际上是一个对象(object),它是一个工厂用于创建MongoDB数据库实例:

1
2
3
object DB {
def apply(underlying: MongDB) = new DB(underlying)
}

有趣的是,当使用DB作为一个工厂对象使用时,你实际上是调用了他的一个方法,DB(underlying.getDB(name)),他实际上就是调用了DB.apply(underlying.getDB(name))。Scala提供了语法糖(syntactic sugar),它允许你将一个对象看作是一个方法进行调用。Scala中是通过转换调用apply方法实现的,apply将匹配类或者对象中的参数。如果没有任何参数匹配apply方法,则会发生编译报错。我们注意到,一个对象总是被延迟执行的,这意味着对象是在它的第一个成员被访问时才创建。在这个例子中,这个成员就是apply。所以,即使apply方法是正确的,也要看它所使用的场景。

Scala的工厂模式
当我们讨论构造函数的时候,会发现会有诸如进程或参数校验的限制,因为Scala的构造器的第一行必须是调用其他构造函数或者是主构造器。使用Scala的object则可以很简单地解决了这个问题,因为方法apply没有这样的限制。我们以在Scala中实现工厂模式为例:
你要创建多个Role类,基于这个类名你会常见合适的role实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(continued)
abstract class Role { def canAccess(page: String): Boolean }
class Root extends Role {
override def canAccess(page:String) = true
}
class SuperAnalyst extends Role {
override def canAccess(page:String) = page != "Admin"
}
class Analyst extends Role {
override def canAccess(page:String) = false }
object Role {
def apply(roleName:String) = roleName match {
case "root" => new Root
case "superAnalyst" => new SuperAnalyst
case "analyst" => new Analyst
}
}

这样,object Role就相当于一个工厂一样,并创建每个实例的变量角色:

1
2
val root = Role("root")
val analyst = Role("analyst")

在apply方法内创建了DB类的一个实例。在Scala中,class和object是可以同名的(share name),这种共享名字的类(class)称为伴生类(companion class),共享名字的对象(object)称为伴生对象(companion object)。

1
2
3
4
5
6
7
package com.scalainaction.mongo
import com.mongodb.{DB => MongoDB}
class DB private(val underlying: MongoDB) {
}
object DB {
def apply(underlying: MongoDB) = new DB(underlying)
}

首先,类DB的构造器被private修饰,表示不被其他伴生对象所访问。在Scala中,伴生对象(conpanion object)可以访问伴生类(companion class)的私有成员,该私有成员不能在类外部被访问。在这个例子中,通过伴生对象创建一个实例显得有点多余,但有时却非常有用,如上面提到的工厂模式。其次就是在导入MongoDB驱动类的时候,由于命名冲突的作用将MongoDB映射为DB类。提示: 伴生对象(companoin object)和伴生类(companion class)在使用时区别就是伴生对象是直接调用的,而伴生类是通过new关键字创建的。

Package object

在包中,只有类、特性和对象。但通过使用包对象,你可以在包中放置任何你定义的内容。例如,你在包对象中添加一个方法,该方法将能被包中的所有成员访问。一般地,包对象的创建为同级目录的对应包文件,即package.scala。你也可以使用在包嵌套语法中,但一般不推荐:

1
2
3
4
package object bar {
val minimumAge = 18
def verifyAge = {}
}

如上代码中,变量minimumAge和方法verifyAge将允许包bar下的所有成员访问。如下面为包内的类使用包对象的方法:

1
2
3
4
package bar
class BarTender {
def serveDrinks = { verifyAge; ... }
}

像这种包对象的适用情景是,你需要定义包内的成员变量,而包外则通过包的接口定义。

在MongoDB中,一个数据库是被分为多个集合(collection)和文档(document)。现在我们向DB类添加一个检索所有collection的方法:

1
2
3
4
5
6
7
package com.scalainaction.mongo
import com.mongodb.{DB => MongoDB}
import scala.collection.convert.Wrappers._
class DB private(val underlying: MongoDB) {
def collectionNames = for(name <- new
JSetWrapper(underlying.getCollectionNames)) yield name
}

注意到JSetWrapper对象,它是一个工具类,用于将java.util.Set转换为Scala的set,因此这里使用了for-comprehension。Wrappers提供了Java集合到Scala集合之间的转换。下面尝试调用一下该方法:

1
2
3
4
import com.scalainaction.mongo._
def client = new MongoClient
def db = client.createDB("mydb")
for(name <- db.collectionNames) println(name)

默认地,MongoDB会提供test和system.indexes这两个集合,因此上面mydb打印出这两个集合。

下面开始探讨一下MongoDB的CRUD(Create,Read,Update,Delete)操作。下面为完整的MongoClient.scala

1
2
3
4
5
6
7
8
9
10
11
package com.scalainaction.mongo
import com.mongodb._
class MongoClient(val host:String, val port:Int) {
require(host != null, "You have to provide a host name")
private val underlying = new Mongo(host, port)
def this() = this("127.0.0.1", 27017)
def version = underlying.getVersion
def dropDB(name:String) = underlying.dropDatabase(name)
def createDB(name:String) = DB(underlying.getDB(name))
def db(name:String) = DB(underlying.getDB(name))
}

现在,你可以通过MongoClient类连接数据库了,我们还要为DB类添加相应的dropDB、createDB等方法。下面定义DB类的伴生对象:

1
2
3
4
5
6
7
8
9
10
package com.scalainaction.mongo
import com.mongodb.{DB => MongoDB}
import scala.collection.convert.Wrappers._
class DB private(val underlying: MongoDB) {
def collectionNames = for(name <- new
JSetWrapper(underlying.getCollectionNames)) yield name
}
object DB {
def apply(underlying: MongoDB) = new DB(underlying)
}

之后,我们将为其添加更多的函数和方法。

Mixin with Scala traits

在面向对象编程里面,一个mixin组合表示一个类可以被其他类使用并提供确定的函数。因此,在Scala中特性(traits)相当于一个添加到其他类中的抽象类,或者看作是实现了方法的接口。

注意 在Scala中,特性(traits)和抽象类不同的是,抽象类(abstract class)可以带构造参数,但是特性(traits)不可以带任何参数。但两者都可以带类型参数。

第二个用户故事的需求是,你需要为你的MongoDB添加创建、删除和查询的功能。MongoDB将文档存储在集合(collection)里面,一个数据库又可以包含多个集合。因此,你需要创建一个组件用来表示集合。这个组件应该包括:从集合中检索文档,具有创建和删除文档的功能。Java的MongoDB驱动提供了DBCollection类用于操作集合,但你需要将其分为多个视图。在Scala,你可以使用特性(traits)实现。你将使用不同的特性(traits)处理不同的任务。

在这里,将扩展DBCollection实现三个特性: ReadOnly、Administrable和Updatable。如:

1
2
3
4
5
6
7
8
9
10
11
import com.mongodb.{DBCollection => MongoDBCollection }
import com.mongodb.DBObject
trait ReadOnly {
val underlying: MongoDBCollection
def name = underlying getName
def fullName = underlying getFullName
def find(doc: DBObject) = underlying find doc
def findOne(doc: DBObject) = underlying findOne doc
def findOne = underlying findOne
def getCount(doc: DBObject) = underlying getCount doc
}

完整的三个特性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package com.scalainaction.mongo
import com.mongodb.{DBCollection => MongoDBCollection }
import com.mongodb.DBObject
class DBCollection(override val underlying: MongoDBCollection)
extends ReadOnly
trait ReadOnly {
val underlying: MongoDBCollection
def name = underlying getName
def fullName = underlying getFullName
def find(doc: DBObject) = underlying find doc
def findOne(doc: DBObject) = underlying findOne doc
def findOne = underlying findOne
def getCount(doc: DBObject) = underlying getCount doc
}
trait Administrable extends ReadOnly {
def drop: Unit = underlying drop
def dropIndexes: Unit = underlying dropIndexes
}
trait Updatable extends ReadOnly {
def -=(doc: DBObject): Unit = underlying remove doc
def +=(doc: DBObject): Unit = underlying save doc
}

如果你使用过Ruby,你会发现它和Ruby的模组十分相似。和模组相比,特性的优点是模块化的mixin组合方式只在编译时进行校验,堆栈中发生错误将在编译是抛出。

下面尝试测试一下这个驱动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import com.scalainaction.mongo._
import com.mongodb.BasicDBObject
def client = new MongoClient
def db = client.db("mydb")
for(name <- db.collectionNames) println(name)
val col = db.readOnlyCollection("test")
println(col.name)
val adminCol = db.administrableCollection("test")
adminCol.drop
val updatableCol = db.updatableCollection("test")
val doc = new BasicDBObject()
doc.put("name", "MongoDB")
doc.put("type", "database")
doc.put("count", 1)
val info = new BasicDBObject()
info.put("x", 203)
info.put("y", 102)
doc.put("info", info)
updatableCol += doc
println(updatableCol.findOne)
updatableCol -= doc
println(updatableCol.findOne)
for(i <- 1 to 100) updatableCol += new BasicDBObject("i", i)
val query = new BasicDBObject
query.put("i", 71);
val cursor = col.find(query)
while(cursor.hasNext()) {
println(cursor.next());
}

BasicDBObject对象是一个MongoDB的map转换器,由于MongoDB是模式自由的,因此,你可以通过BasicDBObject类插入任何键值对。基本的增删改查操作是满足不了业务的需求,例如我们要提高每次访问的性能,这里使用一种称为记忆(Memoization)的技术。这里,他会记住之前的find方法,并避免重复去数据库执行。实现的方式是通过mixin组合traits。因为在Scala中traits是堆栈的,这意味着可以被其继承的traits修改或装饰。

1
2
3
4
5
6
7
8
9
trait Memoizer extends ReadOnly {
val history = scala.collection.mutable.Map[Int, DBObject]()
override def findOne = {
history.getOrElseUpdate(-1, { super.findOne })
}
override def findOne(doc: DBObject) = {
history.getOrElseUpdate(doc.hashCode, { super.findOne(doc) })
}
}

方法getOrElseUpdate将返回对应键的值,如果对应键不存在,它将调用第二个参数提供的方法。在这个例子中,由于不带参数,使用-1作为key。

Class Linearization

如果你学习过C++或Common Lisp,你就会发现特性混入类(mixin of traits)看起来和多继承一样。但问题是Scala应该如何处理多重继承的钻石问题(或称菱形缺陷,diamond problem)。

  • 什么是混入(mixin)
    混入是一种组合的抽象类,主要用于多继承上下文中为一个类添加多个服务,多重继承将多个 mixin 组合成你的类。例如,如果你有一个类表示“马”,你可以实例化这个类来创建一个“马”的实例,然后通过继承像“车库”和“花园”来扩展它,使用 Scala 的写法就是:

    1
    val myHouse = new House with Garage with Garden

    从 mixin 继承并不是一个特定的规范,这只是用来将各种功能添加到已有类的方法。在 OOP 中,有了mixin,你就有通过它来提升类的可读性。

    1
    2
    3
    4
    5
    6
    7
    8
    object Test {
    def main(args: Array[String]): unit = {
    class Iter extends StringIterator(args(0))
    with RichIterator[char]
    val iter = new Iter
    iter foreach System.out.println
    }
    }

    如Iter类通过RichIterator和StringIterator这两个父类混入构成,第一个父类仍然称为超类(superclass),第二个父类则称为混入类(mixin)。

  • 多重继承的钻石问题
    又叫菱形问题(有时叫做“致命的死钻石”deadly diamond of death),描述的是B和C继承自A,D继承自B和C,如果A有一个方法被B和C重载,而D不对其重载,那么D应该实现谁的方法,B还是C?

    1
    2
    3
    4
    5
       A
    ↗ ↖
    B C
    ↖ ↗
    D

Scala是通过类的全序化(Class Linearization),或称作类的线性化。线性化指出一个类的祖先类是一条线性路径的,包括超类(superclass)和特性(traits)。它通过两步来处理方法调用的问题:
- 使用右孩子优先的深度优先遍历搜索(right-first,depth-first search)算法进行搜索。
- 遍历得到的结构层次中,保留最后一个元素,其余删除。

  • JVM上的trait类
    Scala编译器上生成的class文件取决于你如何定义。当你定义一个只包含方法声明而不包含方法体的trait类,他会编译成一个Java接口。你可以使用javap –c <class file name>查看。例如,trait Empty{def e:Int}会产生如下的类:

    1
    2
    3
    public interface Empty{
    public abstract int e();
    }

    如果trait声明了具体的方法或代码,Scala会生成两个类:一个接口类和一个包含代码的新类。当一个类继承这个trait时,trait中声明的变量将被复制到这个类文件中,而定义在trait中的方法作为这个继承类的外观模式的方法。这个类调用这个方法时,将调用新类中的对应方法。

Stackable traits

现在回顾一下,通过ReadOnly特性作为接口实现,通过Updatable和Administrable实现扩展,通过Memoizer实现方法重载,trait这种可堆叠的特征使得在组件重构和行为修改上显得非常方便。现在来看看另外一个特征。

1
2
3
4
5
6
7
8
9
10
trait LocaleAware extends ReadOnly {
override def findOne(doc: DBObject) = {
doc.put("locale", java.util.Locale.getDefault.getLanguage)
super.findOne(doc)
}
override def find(doc: DBObject) = {
doc.put("locale", java.util.Locale.getDefault.getLanguage)
super.find(doc)
}
}

LocaleAware重载中实现了super的findOne方法,这意味着下面两个表达式是等价的:

1
2
new DBCollection(collection(name)) with Memoizer with LocaleAware
new DBCollection(collection(name)) with LocaleAware with Memoizer

因为Memoizer和LocaleAware的findOne都实现了super方法,由于super是动态调用的,这意味着,Memoizer和LocaleAware中的super.findOne方法并没有马上执行,而是先根据有孩子优先的深度遍历顺序先执行findOne里面的方法,最后才执行ReadOnly里面的方法。

  • ScalaObject trait
    在讨论类的全序列化时,并没有给出完整的图例。实际上,Scala会在最后一个混入类中插入scala.ScalaObject。UpdatableCollection的完整序列化顺序是:
    UpdatableCollection -> Memoizer -> Updatable -> DBCollection -> ReadOnly -> ScalaObject -> AnyRef -> Any
    在Scala2.8之前,ScalaObject用于向$tag的正则匹配提供方法,但从Scala2.8开始ScalaObject将作为一个空的标记trait。

Case class

case class 是一种使用case关键创建的特殊类。当Scala编译器看到一个case class时,它会自动生成样板代码而不用自己手动编写。如我们要创建一个Person类:

1
2
scala> case class Person(firstName:String, lastName:String)
defined class Person

在这个例子中,你创建了一个Person 样本类并包含参数。如果一个类的前缀是case关键字,则它会自动完成下面的工作:

  • 所有参数前缀用val修饰,使它们作为public成员。但仍然不能对其进行直接方法,若要访问,需要通过访问器(accessors)。
  • 根据参数自动实现equals和hashCode方法。
  • 编译器会自动实现toString方法,并返回类名和参数。
  • 所有case class都有一个copy方法用于该类实例的复制。
  • 伴生对象(companion object)被创建并提供apply方法,并且该方法参数和该case class参数一致。
  • 编译器会添加一个unapply方法,该方法将用于正则匹配中的类名提取器中。
  • 默认的实现将提供了序列化。
1
2
3
4
5
6
7
8
9
10
scala> val me = Person("Nilanjan", "Raychaudhuri")
me: Person = Person(Nilanjan,Raychaudhuri)
scala> val myself = Person("Nilanjan", "Raychaudhuri")
myself: Person = Person(Nilanjan,Raychaudhuri)
scala> me.equals(myself)
res1: Boolean = true
scala> me.hashCode
res2: Int = 1688656232
scala> myself.hashCode
res4: Int = 1688656232

在Java中,想想我们创建了多少个DTO(Data transfer object),而这些对象仅仅是数据转换的访问器!Scala中的样本类使得对象的访问变得容易了,并且自动为我们提供了equals和hasCode方法,这样再也不用写包含setter和getter的JavaBean了。
注意 如果你想要同时实现访问器和修改器,你可以在样本类参数前面使用var关键字修饰。Scala中默认是通过val修饰参数的,并且推荐这样做。

和其他类一样,样本类(case class)可以继承其他类,包括trait和case class自身。当你声明一个abstract case class时,Scala不会在伴生对象(companion object)中生成apply方法。这意味着你不能创建一个抽象类的实例,但你可以创建单例的、序列化的case object,如:

1
2
3
trait Boolean
case object Yes extends Boolean
case object No extends Boolean

在网络传输中,Scala的case class和case object是非常容易进行消息传送的。这将在Scala actors中介绍讲到。
注意 从Scala2.8开始,不带参数列表的case class是不赞成的(deprecated)。如果需要,你可以声明你的case class不带任何参数,但要使用()代替。

下面讲述case class在MongoDB中如何使用。前面主要实现了find方法,MongoDB支持多条件查询选项如Sort、Skip和Limt下面通过case class和模式匹配实现。首先定义选项查询支持:

1
2
3
4
5
6
7
8
sealed trait QueryOption
case object NoOption extends QueryOption
case class Sort(sorting: DBObject, anotherOption: QueryOption)
extends QueryOption
case class Skip(number: Int, anotherOption: QueryOption)
extends QueryOption
case class Limit(limit: Int, anotherOption: QueryOption)
extends QueryOption

这里你创建了四个选项: Sort、Skip、Limit和NoOption,每个查询选项可以包含其他查询选项。注意到,所有查询选项都继承了QueryOption并使用sealed修饰。关于修改器的内容将在后面阐述。sealed修改器会阻止除在同一文件内的其他所有对象的继承。
下面实现Query查询类:

1
2
3
4
5
case class Query(q: DBObject, option: QueryOption = NoOption) {
def sort(sorting: DBObject) = Query(q, Sort(sorting, option))
def skip(skip: Int) = Query(q, Skip(skip, option))
def limit(limit: Int) = Query(q, Limit(limit, option))
}

其中,QueryOption默认是NoOption,则可以通过以下形式调用

1
2
var rangeQuery = new BasicDBObject("i", new BasicDBObject("$gt", 20))
var richQuery = Query(rangeQuery).skip(20).limit(10)

这里当第二个参数未指定时,它将使用一个默认的参数值。当我们创建一个case class对象实例时,我们不需要通过new关键字创建,因为他会自动创建伴生对象。现在可以通过下面的形式调用Query对象。

1
def find (query: Query) = { "..." }

下面讲解case class如何实现模式匹配功能:

1
2
3
4
5
6
7
8
scala> case class Person(firstName:String, lastName: String)
defined class Person
scala> val p = Person("Matt", "vanvleet")
p: Person = Person(Matt,vanvleet)
scala> p match {
case Person(first, last) => println(">>>> " + first + ", " + last)
}
>>>> Matt, vanvleet

看看模式匹配是如何提取Person中的first和last的。这里通过变量值的匹配来获取first和last的值。在后台,Scala处理这种匹配模式是通过调用一个unapply方法。如果你手动输入伴生对象的代码,它实际上是如下形式:

1
2
3
4
5
6
7
object Person {
def apply(firstName:String, lastName:String) = {
new Person(firstName, lastName)
}
def unapply(p:Person): Option[(String, String)] =
Some((p.firstName, p.lastName))
}

其中,apply方法用于case class创建一个实例时进行调用。方法unapply只有在使用模式匹配中被调用。典型地,方法unapply用于解封case实例并返回case class的元素。Option类型将在下一章详细阐述。
注意 取代unapply方法的另一个方法unapplySeq用于生成case class中参数重复的情况,这将在下一章讨论。

在第二章中没有提及到for-comprehensions如何使用模式匹配,下面讨论一下如何在for表达式中使用模式匹配:

1
2
3
4
5
6
7
8
9
10
scala> val people = List(
| Person("Simon", "kish"),
| Person("Phil", "Marzullo"),
| Person("Eric", "Weimer")
| )
people: List[Person] = List(Person(Simon,kish), Person(Phil,Marzullo),
Person(Eric,Weimer))
scala> for(Person(first, last) <- people) yield first + "," + last
res12: List[java.lang.String] =
List(Simon,kish, Phil,Marzullo, Eric,Weimer)

你将在本书多处看到模式匹配和提取器的例子。

  • 公共参数和模式匹配
    模式匹配存在于函数式编程语言中,但不在OOP编程中。从面向对象的角度看模式匹配相当于观察者模式,模式匹配不能扩展,并打破了封装。
    首先,相比观察者模式,模式匹配减少了大量的样板代码,其次模式匹配不仅可以匹配基本数据类型,还可以匹配到更多复杂的类型。另外,case class的匹配仅仅是通过构造器的参数提供匹配,这样,你就不需要暴露隐藏的字段,并确保封装。

Named and default arguments and copy constructors

主要是三部分:命名参数,默认参数和构造器复制

  • 命名参数
    首先观看一下代码:
1
2
3
4
scala> case class Person(firstName:String, lastName:String)
defined class Person
scala> val p = Person("lastname", "firstname")
p: Person = Person(lastname,firstname)

不幸的是,两个参数都有相同的参数类型,即都是String类型,并且Scala编译器在编译期间不会检测错误。不过,你可以通过指定命名类型来避免这个问题:

1
2
scala> val p = Person(lastName = "lastname", firstName = "firstname")
p: Person = Person(firstname,lastname)

这样,使用名称参数后就不用顾虑参数的顺序问题,这有利不避免不定参数和trait重构的情况,因此,如下写法等价:

1
2
scala> val p = Person(firstName = "firstname", "lastname")
p: Person = Person(firstname,lastname)

下面看看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
scala> trait Person { def grade(years: Int): String }
defined trait Person
scala> class SalesPerson extends Person { def grade(yrs: Int) = "Senior" }
defined class SalesPerson
scala> val s = new SalesPerson
s: SalesPerson = SalesPerson@42a6cdf5
scala> s.grade(yrs=1)
res17: java.lang.String = Senior
scala> s.grade(years=1)
<console>:12: error: not found: value years
s.grade(years=1)
^

编译不通过,因为years不是SalesPerson的实例,如果你强制转换为Person,你可以使用命名参数。

1
2
3
4
scala> val s: Person = new SalesPerson
s: Person = SalesPerson@5418f143
scala> s.grade(years=1)
res19: String = Senior

这里的命名参数实际上相当于一个表达式或者是一个方法或代码块,每次该方法被调用时,这个表达式就被执行:

1
2
scala> s.grade(years={val x = 10; x + 1})
res20: String = Senior
  • 默认参数
    默认参数使用arg:Type=expression形式表示,当使用默认参数时,expression将被调用执行。

  • copy方法
    从Scala2.8开始,所有case class将附加提供一个copy方法用于修改类的实例。在对象成员同名或者父级对象同名的情况下不会生成copy方法,它用于覆盖指定构造器参数的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
scala> val skipOption = Skip(10, NoOption)
skipOption: Skip = Skip(10,NoOption())
scala> val skipWithLimit = skipOption.copy(anotherOption = Limit(10,
NoOption))
skipWithLimit: Skip = Skip(10,Limit(10,NoOption))
实际上,方法copy自动生成代码为如下形式:
case class Skip(number: Int, anotherOption: QueryOption)
extends QueryOption {
def copy(number: Int = number,
anotherOption: QueryOption = anotherOption) = {
Skip(number, anotherOption)
}
}

当copy方法不指定参数时,它将等同于自身:

1
2
scala> Skip(10, NoOption) == Skip(10, NoOption).copy()
res22: Boolean = true

在Scala中,==和equals方法是等价的。

Modifiers

修饰器,常见的就是private和protected,和Java一样,用于修饰访问范围,Scala不限于这些修饰器,并带来新的特性。
private修改器可以用于任何定义中,使用private意味着访问只能在闭包、伴生类或伴生对象中。在Scala中,你可以通过包名或者一个类进行限定。如:

1
2
3
4
5
6
7
8
package outerpkg.innerpkg
class Outer {
class Inner {
private[Outer] def f() = "This is f"
private[innerpkg] def g() = "This is g"
private[outerpkg] def h() = "This is h"
}
}

这里方法f可以出现在Outer类的任何地方;方法g可以在outerpkg.innerpkg任何地方访问;方法h可以出现在outerpkg以及它的子包的任何地方。
在Scala中可以通过this:private[this]进行限制,在这里表示object private。对象私有表示它只能在对象内部进行访问。当成员用private修饰时,这称为类私有化(class-private)。

类似地,protected表示伴生类和子类访问权限,也可以修饰package、class和this。默认地,如果你不是使用任何修改器,则表示它是public的。但Scala没有public这个修改器。
和Java一样,Scala中提供了override重构修改器,不同的是Scala中的override是强制性的,这意味着,被重构的父类对象的方法应该是具体的方法,如下面写法会发生编译错误:

1
2
3
4
5
6
7
trait DogMood{def greet()}
trait AngryMood extends DogMood{
override def greet() = {
println("bark")
super.greet()
}
}

因为DogMood中的greet是抽象的,在AngryMood中通过super.greet()调用了抽象的父类方法,但是override中的greet()方法是具体的,因此会发生编译错误。因此,这里的override需要和abstract组合使用:

1
2
3
4
5
6
trait AngryMood extends DogMood{
abstract override def greet() = {
println("bark")
super.greet()
}
}

Scala中还提供了一个新的仅用于类定义的修改器sealed(密封的),它和final有点不同;final中定义的类不能被子类重载,但是sealed定义的类,只要是在同一个文件就可以实现重构,因此可以这样定义:

1
sealed trait QueryOption

可以可以达到QueryOption只能被其子类继承而不能被其他对象继承。

Value classes: objects on a diet

从Scala2.10版本开始,Scala允许一种继承于AnyVal的值类(value class),当然也可以是case class类,它是一个新的机制用于避免运行时分配对象。创建一个值类,你需要遵循某些规则:

  • 这个类至少要有一个val参数(vars不允许)。
  • 参数类型不应该是值类。
  • 不能有任何附加的构造器。
  • 只能用def定义对象的成员,不能有val或var。
  • 该类不能继承任何trait,只能是全局的trait(如AnyVal)。
    Scala对于该类有如此多的限制,为什么还需要值类(value class)?它允许你在运行时添加方法但又不创建实例,如:
1
2
3
class Wrapper(val name: String) extends AnyVal{
def up = name.toUpperCase
}

类Wrapper有一个自定义参数name,并暴露了up()方法,下面创建一个Wrapper实例并调用这个方法

1
2
val w = new Wrapper("hey")
w.up

上述调用只有在编译时才生效。而在运行时这个表达式实际上等效于调用了一个对象的静态方法:Wrapper.upextension方法。方法"extension中的内容和up()方法体完全一模一样,因此,Wrapper值类的等效伴生对象为:

1
2
3
object Wrapper {
def up$extension(_name: String) = _name.toUpperCase
}

规定!一个值类只能继承一个通用特质(universal trait),并且该特质是继承自Any的(特质默认是继承自AnyDef的)。通用特质只能有方法定义不能包含初始代码。

1
2
3
4
5
6
7
8
9
trait Printable extends Any {
def p() = println(this)
}
case class Wrapper(val name: String) extends AnyVal with Printable {
def up() = name.toUpperCase
}
...
val w = Wrapper("Hey")
w.p()

无论如何,通过值类为存在的类型添加附加的方法是一个非常好的方法。我们会在本书后面看到关于这方面更多的例子。

Implicit conversion with implicit classes

隐式转换是一种传入一个类型的参数返回另外一种类型参数的方法。例如下面例子将Double类转换为Int:

1
2
3
4
5
6
7
8
9
10
scala> val someInt: Int = 2.3
<console>:7: error: type mismatch;
found : Double(2.3)
required: Int
val someInt: Int = 2.3
^
scala> def double2Int(d: Double): Int = d.toInt
double2Int: (d: Double)Int
scala> val someInt: Int = double2Int(2.3)
someInt: Int = 2

通常你不能显式地将Double类转换为Int类型,但这里我们可以使用double2Int方法明确地将Double转换为Int类型。我们可以使用implicit关键字进行隐式转换:

1
implicit double2Int(d: Double): Int = d.toInt

隐式转换的特点是编译器会找到合适的转换器并进行调用:

1
2
3
4
5
6
7
8
9
10
11
scala> val someInt: Int = 2.3
<console>:7: error: type mismatch;
found : Double(2.3)
required: Int
val someInt: Int = 2.3
^
scala> implicit def double2Int(d: Double): Int = d.toInt
warning: there were 1 feature warnings; re-run with -feature for details
double2Int: (d: Double)Int
scala> val someInt: Int = 2.3
someInt: Int = 2

在Scala中,当编译器解析到一个类型错误的时候,它并没有马上退出;实际上,它会查找任何满足这个错误的隐式转换。因此,在这个例子中,double2Int是用于将Double转换为Int的,表达式会被编译器重写:

1
val someInt: Int = double2Int(2.3)

这个转换发生在编译期间,如果没有找到合适的转换方法,编译器则会抛出一个编译异常。另外,如果转换方法冲突也会抛出一个异常。例如,有多于一个隐式转换器被匹配到,相比其他动态语言Scala的隐式转换只在编译期抛出错误信息,因此它是安全的。下面讨论一下隐式转换器的用法:
隐式转换器一个普遍的用法就是对存在的数据类型添加扩展方法,如,我们知道可以用如下方法创建一个范围:

1
val oneTo10 = 1 to 10

但是,如果我们要创建一个-->方法来表示1到10的范围怎么实现?

1
val oneTo10 = 1 --> 10

我们可以通过两步进行

①创建一个类型实现了Int类型的-->方法
②提供一个隐式转换器

1
2
3
4
5
6
scala> class RangeMaker(left: Int) {
| def -->(right: Int) = left to right
| }
defined class RangeMaker
scala> val range: Range = new RangeMaker(1).-->(10)
range: Range = Range(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

现在只需要再定义一个隐式转换器即可

1
scala> implicit def int2RangeMaker(left: Int) = new RangeMaker(left)

当我们使用val oneTo10 = 1 --> 10时,它实际上隐式调用了new RangeMaker(1).-->(10)方法进行操作
另外一种比较便捷的方法是使用implicit class修改器(或称作修饰符)实现

1
2
3
implicit class RangeMaker(left: Int) {
def -->(right: Int): Range = left to right
}

实际上,它和上面的实现形式是一样的,Scala编译器会将implicit class进行“拖糖”,即将其分解为一般类和隐式转换方法。注意隐式类必须要有一个带一个参数的主构造器(那是当然的,不然如何实现转换)。
我们注意到,上面的例子中,隐式转换是在编译期发生的,这意味着在运行期每实现一个隐式转换就会创建一个隐式转换类的一个实例,这是我们不希望看到的,幸运的是,前面介绍到,我们可以创建值类:

1
2
3
implicit class RangeMaker(val left: Int) extends AnyVal {
def -->(right: Int): Range = left to right
}

这样,隐式转换就变得非常强大,但过度使用会减少代码的可读性和基础代码的维护性。

Scala class hierarchy

在3.3小节介绍过Scala的类层次结构。层次结构中的根类为scala.Any,其他所有类直接或间接继承该类。该类定义了两个子类,分别是AnyVal和AnyDef,在主系统(JVM或CLR)中,一个对象的所有值都是AnyDef的子类。其中,所有用户定义的Scala类继承自特质scala.ScalaObject,AnyDef是java.lang.Object(Java)和system.Object(C#)的映射。
子类AnyVal在主系统中不作为一个对象表示。但是在Scala中,Everything is Object,这不是矛盾?的确在Scala中所有事物都是对象,但不在主系统范围(JVM或CLR)。当Scala编译成Java字节码时,它会使用Java的基础类行,而不是封装类型,因为这样更加高效,而当需要被Scala使用时则转换为对象。
实际上Scala有个views隐式类型,它是一个转换器用于将Char,Int,Long进行相互转换。在之后将会介绍到隐式函数。
Scala.Null是所有参考类型的子类,也是null唯一的实例参考。所以创建一个null实例的唯一方法就是指定它的类型:

1
2
scala> val x: Null = null
x: Null = null

因为Null对象是AnyRef的子类,所以不能将其标识为值类型,否则将抛出异常

1
2
3
4
5
scala> val x: Int = null
<console>:8: error: type mismatch;
found : Null(null)
required: Int
val x: Int = null

另外,scala.Nothing是Scala层次结构的最后一个子类,它是所有类的子类,但是你不能创建scala.Nothing的实例,在Scala中没有该类型的实例。在第四章将介绍到更多关于Nothing的解决复杂的问题。
Class hierarchy of Scala with subtypes and views

Summary

本章介绍了比较多的内容,包括Scala增强的OOP技术,并介绍了命名和默认参数;介绍了混入和类组合结构;介绍了case class和value class,以及如何通过case class实现可变参数,你将在后面章节的Actors和concurrency使用到更多case class,同时,你也学习了单例对象和伴生对象如何在Scala中使用。
在本章最后,我们探索了Scala的类层次结构和一些重要的类,这部分内容将有助你更容易入手Scala文档并库。
本章只介绍了一些基础,你会在第7章重新开始面向对象的概念,并接触到更多抽象的技术。
记住,一个是你熟悉的好方法就是进入到REPL环境并尝试所有的特性和功能。行动才是学习Scala最快捷的方法。下一章将开始介绍Scala多样的函数式接口。