chapter10_组合和继承
第6章介绍了Scala面向对象的一些基础概念。本章将接着第6章,更详细地介绍Scala对于面向对象编程的支持。
我们将对比类之间的两个最基本的关系:组合和继承。组合的意思是一个类可以包含对另一个类的引用,并利用这个被引用的类来帮助它完成任务。而继承是超类/子类的关系。
除此之外,我们还会探讨抽象类、无参方法、类的扩展、重写方法和字段、参数化字段、调用超类的构造方法、多态和动态绑定、不可重写(final)的成员和类,以及工厂对象和方法。
一个二维的布局类库
我们将创建一个用于构建和渲染二维布局元素的类库,以此作为本章的示例。每个元素表示一个用文本填充的长方形。为方便起见,类库将提供名称为“elem”的工厂方法,根据传入的数据构造新的元素。例如,可以用下面这个签名的工厂方法创建一个包含字符串的布局元素:
elem(s: String): Element
就像你看到的,我们用一个名称为Element的类型对元素建模。可以对一个元素调用above或beside,传入另一个元素,以获取一个将两个元素结合在一起的新元素。例如,下面这个表达式将创建一个由两列组成的更大的元素,每一列的高度都为2:
val column1 = elem("hello") above elem("***")
val column2 = elem("***") above elem("world")
column1 beside column2
打印上述表达式的结果如下:
hello ***
*** world
布局元素很好地展示了这样一个系统:在这个系统中,对象可以通过组合操作符的帮助由简单的部件构建出来。本章将定义那些可以根据向量、线和矩形构造出元素对象的类。这些基础的元素对象就是我们说的简单的部件。我们还会定义组合操作符above和beside。这样的组合操作符通常也被称作组合子(combinator),因为它们会将某个领域内的元素组合成新的元素。
用组合子来思考通常是一个设计类库的好办法。例如,对于某个特定应用领域中的对象,它有哪些基本的构造方式,这样的思考是很有意义的。简单的对象如何构造出更有趣的对象?如何将组合子有机地结合在一起?最通用的组合有哪些?它们是否满足某种有趣的法则?如果针对这些问题你都有很好的答案,那么你的类库设计就走在正轨上。
抽象类
我们的第一个任务是定义Element类型,用来表示布局元素。由于元素是一个由字符组成的二维矩形,用一个成员contents来表示某个布局元素的内容是合情合理的。内容可以用字符串的向量表示,每个字符串代表一行。因此,由contents返回的结果类型将会是Vector[String]。示例10.1给出了相应的代码。
abstract class Element:
def contents: Vector[String]
在这个类中,contents被声明为一个没有实现的方法。换句话说,这个方法是Element类的抽象成员(abstract member)。
一个包含抽象成员的类本身也要被声明为抽象的,具体做法是在class关键字之前写上abstract修饰符:abstract class Element ...
abstract修饰符表明该类可以拥有那些没有实现的抽象成员。因此,我们不能直接实例化一个抽象类,尝试这样做将遇到编译错误:
在本章稍后的内容中,你将看到如何创建Element类的子类。这些子类可以被实例化,因为它们填充了Element抽象类中缺少的contents定义。
注意,Element类中的content方法并没有标上abstract修饰符。一个方法只要没有实现(即没有等号或方法体),它就是抽象的。与Java不同,我们并不需要(也不能)对方法加上abstract修饰符。那些给出了实现的方法叫作具体方法。另一组需要区分的术语是声明(declaration)和定义(definition)。Element类“声明”了content这个抽象方法,但目前并没有“定义”具体的方法。不过下一节将通过定义一些具体方法来增强Element类。
定义无参方法
接下来,我们将给Element类添加方法来获取它的宽度和高度,如示例10.2所示。height方法用于返回contents中的行数;而width方法用于返回第一行的长度,如果完全没有内容,则返回0。(这意味着你不能定义一个高度为0但宽度不为0的元素。)
- abstract class Element:
- def contents: Vector[String] def height: Int = contents.length
- def width: Int = if height == 0 then 0 else contents(0).length
示例10.2 定义无参方法width和height
需要注意的是,Element类的3个方法无一例外都没有参数列表,连空参数列表都没有。举例来说,我们并没有写:def width(): Int
而是不带圆括号来定义这个方法:def width: Int
这样的无参方法(parameterless method)在Scala中很常见。与此对应,那些用空的圆括号定义的方法,如def height(): Int,被称作空圆括号方法(empty-paren method)。推荐的做法是对于没有参数且只通过读取所在对象字段的方式访问其状态(确切地说,并不改变状态)的情况,尽量使用无参方法。这样的做法支持所谓的统一访问原则(uniform access principle):使用方代码不应受到某个属性是用字段还是用方法实现的影响。
举例来说,我们完全可以把width和height实现成字段,而不是方法,只要将定义中的def替换成val即可:
- abstract class Element:
- def contents: Vector[String]
- val height = contents.length
- val width = if height== 0 then 0 else contents(0).length
从使用方代码来看,这组定义完全是等价的。唯一的区别是字段访问可能比方法调用的速度快一些,因为字段值在类初始化时就被预先计算好,而不是在每次方法调用时都重新计算。另一方面,字段需要每个Element对象为其分配额外的内存空间。因此属性实现为字段好还是方法好,这个问题取决于类的用法,而用法是可以随着时间变化而变化的。核心点在于Element类的使用方不应该被内部实现的变化所影响。
具体来说,当Element类的某个字段被改写成访问函数时,Element类的使用方代码并不需要被重新编写,只要这个访问函数是纯粹的(即它并没有副作用也不依赖于可变状态)。使用方代码并不需要关心究竟是哪一种实现。
到目前为止都还好。不过仍然有一个小麻烦,这与Java和Scala 2的处理细节有关。问题在于Java并没有实现统一访问原则,而Scala 2也没有完整地推行这个原则。因此,在Java中,对于字符串,要写string.length()而不是string.length;而对于数组,要写array.length而不是array. length()。无须赘言,这很让人困扰。
为了更好地桥接这两种写法,Scala 3对于混用无参方法和空括号方法的处理非常灵活。具体来说,可以用空括号方法重写无参方法,也可以反过来,只要父类是Java或Scala 2编写的就行。还可以在调用某个由Java或Scala 2定义的不需要入参的方法时省去空括号。例如,如下两行代码在Scala 3中都是合法的:
- Array(1, 2, 3).toString
- "abc".length
从原理上讲,可以对Java或Scala 2的所有无参函数调用都去掉空括号。不过,我们仍建议在被调用的方法不仅只代表接收该调用对象的某个属性时加上空括号。举例来说,空括号的适用场景包括该方法执行I/O、写入可重新赋值的变量(var)、读取接收该调用对象字段之外的var(无论是直接还是间接地使用了可变对象)。这样一来,参数列表就可以作为一个视觉上的线索,告诉我们该调用触发了某个有趣的计算。例如:
"hello".length // 不写(),因为没有副作用
println() // 最好不要省去()
总结下来就是,Scala鼓励我们将那些不接收参数也没有副作用的方法定义为无参方法(即省去空括号)。同时,对于有副作用的方法,不应该省去空括号,因为在省去括号以后,这个方法调用看上去就像是字段选择,因此你的使用方可能会对其副作用感到意外。
同理,当你调用某个有副作用的函数时,就算编译器没有强制要求,也请确保在写下调用代码时加上空括号。换一个角度来思考这个问题,如果你调用的这个函数用于执行某个操作,就加上括号,而如果它仅用于访问某个属性,就去掉括号。