1.抽象类:定义一个抽象类:
abstract class Element{
def contents:Array[String]
}
在这个类中,contents被声明为一个没有实现的方法。换句话说,这个方法是Element类的抽象成员。一个包含抽象成员的类本身要声明为抽象的,在class前写上abstract修饰符。修饰符abstract表明该类可以拥有那些没有实现的抽象成员。因此,不能实例化一个抽象类:
new Element
这样将遇到编译错误。继承Element类的子类可以被实例化,因为它们填充了缺少的contents定义。注意:Element类中的contents方法并没有标上abstract修饰符。一个方法只要没有实现(即没有等号和方法体),那么它就是抽象的。跟Java不同,我们并不需要(也不能)对方法加上abstract修饰符。叫法上的区分是声明和定义。Element类声明了contents这个抽象方法,但目前并没有定义具体的方法。
2.定义无参方法:给Element添加方法来获取它的宽度和高度:
abstract class Element{
def contents:Array[String]
def height:Int=contents.length
def width:Int=if (height==0) 0 else contents(0).length
}
Element三个方法都没有参数列表,连空参数列表都没有。这样的无参方法在Scala中很常见。与此对应,那些用空括号定义的方法,如:
def height():Int
被称作空圆括号方法。推荐的做法是对于没有参数且只通过读取所在对象字段的方式访问可变状态的情况下尽量使用无参方法。这样的做法支持所谓的统一访问原则:使用方代码不应受到某个属性是用字段还是用方法实现的影响。如,完全可以把width和height实现成字段,而不是方法,只要将定义中的def换成val即可:
abstract class Element{
def contents:Array[String]
val height:Int=contents.length
val width:Int=if (height==0) 0 else contents(0).length
}
从使用方代码来看,这组定义完全是等价的。唯一的区别是字段访问可能比方法略快,因为字段值在类初始化时就被预先计算好,而不是每次方法调用时都重新计算。另一方面,字段需要每个Element对象为其分配额外的内存空间。因此属性实现为字段好还是方法好,这个问题取决于类的用法。
Java没有实现统一访问原则,因此Java中要写string.length()而不是string.length。为了更好地桥接这两种写法,Scala对于混用无参方法和空括号方法的处理非常灵活。具体来说,可以用空括号方法重写无参方法,也可以反过来。还可以在调用某个不需要入参的方法时省去空括号。如:
Array(1,2,3).toString
"abc".length
都是合法的。从原理上讲,可以对Scala所有无参函数调用都去掉空括号。不过,我们仍建议在被调用的方法不仅只代表接收该调用的对象的某个属性时加上空括号。Scala鼓励我们将那些不接收参数也没有副作用的方法定义为无参方法(即省去空括号)。对于有副作用的方法,不应省去空括号,因为省掉括号以后这个方法调用看上去就像是字段选择,因此你的使用方可能会对其副作用感到意外。
3.扩展类:要实例化一个元素,需要创建一个扩展自Element的子类,并实现contents这个抽象方法:
class ArrayElement(conts:Array[String]) extends Element{
def contents:Array[String]=conts
}
这样的extends子句有两个作用:它使得ArrayElement类从Element类继承所有非私有的成员,并且它也让ArrayElement的类型成为Element的子类型。ArrayElement是Element的子类,Element是ArrayElement的超类。如果去掉extends子句,Scala编译器会默认假定你的类扩展自scala.AnyRef,这对应到Java跟java.lang.Object相同。因此,Element类默认也扩展自AnyRef类。
继承的意思是超类的所有成员也是子类的成员,但是有两个例外。一是超类的私有成员并不会被子类继承;二是子类里已经实现了相同名称和参数的成员。对后面这种情况我们也说子类的成员重写了超类的成员。如果子类是具体的而超类是抽象的,也说这个具体的成员实现了那个抽象的成员。ArrayElement和Array[String]之间存在组合关系。
4.重写方法和字段:统一访问原则只是Scala比Java在处理字段和方法上更加统一的一个方面。另一个区别是Scala中字段和方法属于同一个命名空间。这使得用字段重写无参方法变为可能。如:
class ArrayElement(conts:Array[String]) extends Element{
val contents:Array[String]=conts
}
用val是一个没有问题的好实现。另一方面,Scala也禁止在同一个类中使用相同的名称命名字段和方法,在Java中这是允许的。如:
//这是Java
class CompilesFine{
private int f=0;
public int f(){
return 1;
}
}
一般来说,Scala只有两个命名空间用于定义,不同于Java的四个。Java的四个命名空间分别是:字段、方法、类型和包,而Scala的两个命名空间分别是:值(字段、方法、类型、包和单例对象)、类型(类和特质名)。Scala将字段和方法放在同一个命名空间正是为了让可以用val来重写无参方法,这在Java中是不允许的。注意:Scala中包也跟字段和方法共用命名空间的原因是让你能引入包及单例对象的字段和方法。这同样是Java不允许的。
5.定义参数化字段:ArrayElement类有一个conts参数,这个参数的唯一目的就是被拷贝到contents字段上,是代码可能存在不必要的冗余和重复的一种信号。可以通过将参数和字段合并成参数化字段定义的方式来避免:
class ArrayElement(val contents:Array[String]) extends Element
现在contents参数前面放了一个val。这是同时定义参数和同名字段的简写方式,这个字段可以被外界访问到。也可以写var,这样字段就可以被重新赋值,还可以给这些参数化字段添加修饰符,如:private、protected或者override,就像对其他类成员做的那样。
6.调用超类构造方法:
class LineElement(s:String) extends ArrayElement(Array(s)){
override def width=s.length
override def height=1
}
要调用超类的构造方法,只需将你打算传入的入参放在超类名称后面的圆括号里即可。
7.使用override修饰符:Scala要求我们在所有重写了父类具体成员的成员之前加上这个修饰符。如果并不重写,这个修饰符是被禁用的。由于LineElement的height和width的确是重写了Element类中的具体定义,override这个修饰符是必需的。
这样的规则为编译器提供了有用的信息,帮助我们避免某些难以捕获的错误,让系统得以更加安全的进化。如:当你碰巧拼错方法或给出了错误的参数列表,编译器将报错。
这个override的规约对于系统进化来说更为重要。如果你在超类中定义了一个新方法,类的使用者可能之前已经定义了同名方法,由于存在重写的关系,你的方法会达不到想要的结果!
这些“不小心出现的重写”是所谓的“脆弱基类”问题最常见的表现形式。这个问题之所以存在,原因是如果你在某个类继承关系中对基类(我们通常叫作超类)添加新的成员,你将面临破坏使用方代码的风险。Scala并不能完全解决脆弱基类的问题,但相比Java有所改善。因为使用方代码的同名方法没有override修饰符,重新编译将会报错。
8.多态和动态绑定:
val e:Element=new ArrayElement(Array("hello"))
类型为Element的变量可以指向一个类型为ArrayElement的对象。这个现象的名称叫作多态,意思是“多个形状”或“多种形式”。故事的另一面是对变量和表达式的方法调用的是动态绑定的。意思是说实际被调用的方法实现是在运行时基于对象的类来决定的,而不是变量或表达式的类型决定的。可以重写超类打印方法,通过多态来测试。
9.声明final成员:有时,在设计类继承关系的过程中,你想确保某个成员不能被子类继承。在Scala中,跟Java一样,可以通过在成员前面加上final修饰符来实现。如:
class ArrayElement extends Element{
final override def demo()={
println("ArrayElement's implementation invoked")
}
}
有了这个版本的ArrayElement,在其子类LineElement中尝试重写demo会报错。有时还想要确保整个类没有子类,可以简单地将类声明为final的,做法是在类声明之前添加final修饰符:
final class ArrayElement extends Element{
override def demo()={
println("ArrayElement's implementation invoked")
}
}
有了这样的ArrayElement定义,任何想要定义其子类的尝试都无法通过编译。
10.使用组合和继承:组合和继承是两种用其他已有的类来定义新类的方式。如果你主要追求的是代码复用,一般来说你应当优先选择组合而不是继承。只有继承才会受到脆弱基类问题的困扰,会在修改超类时不小心破坏了子类的代码。
我们将LineElement定义为ArrayElement的子类的主要目的是复用ArrayElement的contents定义。因此,也许更好的做法是将LineElement定义为Element的直接子类,如:
class LineElement(s:String) {
val contents=Array(s)
override def width=s.length
override def height=1
}
现在LineElement有一个跟Array的组合关系:它包含了一个从自己的contents字段指向一个字符串数组的引用。
11.定义工厂对象:工厂对象包含创建其他对象的方法。使用方用这些工厂方法来构建对象,而不是直接用new构建对象。这样既可以让你的类库更容易被使用方理解,因为暴露的细节更少,同时还提供了更多的机会让你在未来在不破坏使用方代码的前提下改变类库的实现。直接的方案是创建一个Element类的伴生对象,作为布局元素的工厂对象:
object Element {
def elem(contents:Array[String]):Element=
new ArrayElement(contents)
def elem(Line:String):Element=
new LineElement(line)
}
我们就可以引入Element.elem,这样就可以用它们的简单名字,即elem,来调用工厂方法了:
import Element.elem
... ...