Simple Scala: OOP in Scala

Class and Object Basics

类使用关键字class声明,其单例对象使用object声明.

限制从一个类中派生新类时使用final关键字.

标识一个抽象类时使用abstract关键字,比如只包含 字段/方法或类型的声明而没有具体实现时.即使不包含任何未定义的成员,仍然可以声明为一个抽象类.

一个实例可以使用this关键字来引用自身.this在java中比较常见,而scala则使用稀少,原因是scala中缺少构造器模板.

一个java的实例:

public class JPerson { 
    private String name; 
    private int age;
    public JPerson(String name, int age) { 
        this.name = name;
        this.age = age;
    }
    publicvoid setName(Stringname) {this.name=name;}
    public String getName() { return this.name; }
    public void setAge(int age) { this.age = age; }
    public int getAge() { return this.age; } 
}

然后对比一下scala中的代码,所有的构造器模板都被隐藏了:

class Person(var name: String, var age: Int)

字段名以var前缀表示是一个可变字段,或叫做实例变量或属性.val则表示不可变字段.使用case 关键字声明类时默认指定为val并提供额外的方法:

case class ImmutablePerson(name: String, age: Int)

Scala支持overloaded methods,多个方法可以使用一个name,但是签名必须是唯一的,签名部分包含类型名,方法名,参数列表类型,参数名本身没有限制.在JVM中,不同的返回值类型不足够去区分一个方法.

术语member以泛型的方式被引用为field,method,type,与java不同的是,如果方法有一个参数列表,则方法可以和字段同名.

Scala中可以使用type声明类型成员,这些类型成员是类型参数化的一种补充机制,经常被用来作为复杂类型的别名,以提供可读性.

Type名必须唯一.

Scala没有静态成员,通常使用object存放一些跨实例的成员,比如常量.

如果一个object和一个class同名并在同一个文件,则该object作为该类的半生对象. 对于case类,编译器会自动生成一个伴生对象.

Reference Versus Value Types

Java中short, int, long, float, double, boolean, char, byte, void作为原始类型,他们被存储在堆栈中,或者为了更高的性能存储在CPU寄存器.除此之外均称为引用类型,他们的实例被分配在堆中,引用这些实例的变量实际上是指向了堆中的对应位置.

Scala遵循了这些规则,但是更清晰的分离了原始类型和引用类型.

引用类型都是AnyRef的子类型.AnyRef为Any的子类型,Scala类型系统的底层类型.所有值类型都是AnyVal的子类型,同时属于Any的子类型.而Java中根类型是object,更接近于AnyRef而不是Any.

引用类型的实例由new关键字创建,跟没有参数的方法一样,如果构造器为空则同样可以省略括号.

值类型没有构造器,所以像 val a = new Int(1) 这样的表达式将无法通过编译.

Short, Int, Long, Float, Double, Boolean, Char, Byte, and Unit被称为值类型,与java一致,这些值类型均是AnyVal的子类型.

Value Classes

Scala中经常引入包装类型来实现新类型,这被称为扩展方法,但是对值类型的包装,会将值类型编程引用类型,从而失去原始类型的性能.

Scala中的解决方案是 value class,和一个附带的特质,称为通用特质.这些类型限制了可声明范围,但也带来了好处: 避免封装分配在堆上.

定义一个value class:

class Dollar(val value: Float) extends AnyVal {     // 只有一个val型参数,继承自AnyVal
    override def toString = "$%.2f".format(value)
}
val benjamin = new Dollar(100)
// Result: benjamin: Dollar = $100.00

要称为一个value calss,必须遵循以下规则:

  1. 有且只有一个公共的val型参数
  2. 参数类型必须不为该value class本身
  3. 如果该类被参数话,不能使用@specialized注释
  4. 不可定义辅助构造器
  5. 只能定义方法,不能有其他的val或var
  6. 不能重写equals和hashCode
  7. 不能定义嵌套的raits, classes, or objects
  8. 不能被子类化
  9. 只能继承universal traits
  10. 必须是一个顶层类型或被引用的对象

上面的例子中,Dollar在编译时是一个外部类型,而在运行时,该类型是一个被包装过的类型,即float.

通常情况下,被包装过的类型是AnyVal的子类型之一,但并不必须这样,如果换成引用类型,仍然可以收益于内存不再堆上分配的优势.

下面使用String而不是原始类型来实现一个value class:

class USPhoneNumber(val s: String) extends AnyVal {

override def toString = { 
    val digs = digits(s) 
    val areaCode = digs.substring(0,3) 
    val exchange = digs.substring(3,6) 
    val subnumber = digs.substring(6,10)        // 客户编号
    s"($areaCode) $exchange-$subnumber" }

    private def digits(str: String): String = str.replaceAll("""\D""", "")
}

val number = new USPhoneNumber("987-654-3210") 
// 结果: number: USPhoneNumber = (987) 654-3210

同样特质的特性:

  1. 可以从Any派生(而不能从其他同样特质派生)
  2. 只能定义方法
  3. 没有对自身做初始化

下面是一个改进版的USPhoneNumber,混入了两个通用特质:

trait Digitizer extends Any {                   // 1
    def digits(s: String): String = s.replaceAll("""\D""", "") 
}

trait Formatter extends Any {                   // 2
    def format(areaCode: String, exchange: String, subnumber: String): String = 
        s "($areaCode) $exchange-$subnumber"
}

class USPhoneNumber(val s: String) extends AnyVal with Digitizer with Formatter {

    override def toString = { 
        val digs = digits(s)                    // 3
        val areaCode = digs.substring(0,3) 
        val exchange = digs.substring(3,6) 
        val subnumber = digs.substring(6,10) 
        format(areaCode, exchange, subnumber)   // 4
    }
}

val number = new USPhoneNumber("987-654-3210") 
// 结果: number: USPhoneNumber = (987) 654-3210
  1. 定义了一个通用特质,以实现digits方法
  2. 定义一个通用特质,实现format方法
  3. 分别调用特质中的方法

Formatter解决了一个设计问题,因为USPhoneNumber中只能传入一个参数,但是如果想要传入别的参数来实现可配置的格式化时,同过特质传入参数能解决value class的参数限制问题.

但是通用特质有时会触发实例化(即将实例的内存分配到堆中),下面是会触发实例化的情况:

  1. 当value class的实例被传递给函数作为参数,而该函数预期参数为通用特质且需要被实例实现.不过,如果函数的参数是value class本身,则不需要实例化.
  2. value class 的实例被赋值给数组
  3. value class 的类型被用于类型参数

比如,当用USPhoneNumber调用一下方法时,会创建一个USPhoneNumber实例:

def toDigits(d: Digitizer, str: String) = d.digits(str) 
... 
val digs = toDigits(new USPhoneNumber("987-654-3210"), "123-Hello!-456") 
// 结果: digs: String = 123456

或者用USPhoneNumber调用一下参数化类型的方法时,也会产生USPhoneNumber的实例:

def print[T](t: T) = println(t.toString) 
print(new USPhoneNumber("987-654-3210")) 
// 结果: (987) 654-3210

总结: value class 提供了一个低开销的技术,用于定义扩展方法,并为类型定义有意义的类型名称,这利用了被包装值的类型安全性.

Parent Types

与java一样,scala只支持但继承,不支持多继承,一个子类只能有一个父类.

abstract class BulkReader { 
    type In                 // 类型成员,子类中药实现该类型,即指定一个类型
    val source: In
    def read: String        // Read source and return a String 
}

class StringBulkReader(val source: String) extends BulkReader { 
    type In = String        // 指定类型成员In为String类型
    def read: String = source
}
class FileBulkReader(val source: java.io.File) extends BulkReader { 
    type In = java.io.File
    def read: String = {...}
}

Constructors in Scala

Scala通常有一个主构造器,0个或多个辅助构造器. 主构造器即整个class的主体部分,所有构造器需要的参数都列在类名后.

case class Address(street: String, city: String, state: String, zip: String) {
    def this(zip: String) =                                                         // 1
        this("[unknown]", Address.zipToCity(zip), Address.zipToState(zip), zip)
}
object Address {
    def zipToCity(zip: String) = "Anytown"                                          // 2
    def zipToState(zip: String) = "CA" }
case class Person(name: String, age: Option[Int], address: Option[Address]) {       // 3
    def this(name: String) = this(name, None, None)                                 // 4
    def this(name: String, age: Int) = this(name, Some(age), None)
    def this(name: String, age: Int, address: Address) = this(name, Some(age), Some(address))
    def this(name: String, address: Address) = this(name, None, Some(address)) 
}
  1. 一个仅接受zip参数的辅助构造器,它调用辅助方法来推断city和state,但是不能推断street.
  2. 辅助方法从zip参数查找对应的city和state(假设实现)
  3. 指定age和address参数可选
  4. 提供多种构造器以便用户提供一个或多个参数

辅助构造器均被命名为this,它的第一个构造器必须调用主构造器或其他辅助构造器.并且编译器要求被调用的构造器必须先于当前构造器出现,因此必须小心排列构造器顺序.

通过强制让所有构造器最终都调用主构造器,可以将代码冗余最小化,并确保新实例的初始化逻辑一致.

在使用中会发现,Person有很多参数类似的次级构造器.这时可以对可选参数指定默认值,而在创建实例时可以指定参数名来提供具体参数:

case class Person2( name: String, age: Option[Int] = None, address: Option[Address] = None)

但是在创建实例时,除非是调用主构造器,否则必须使用 new 关键字,因为编译器不会自动为case类的次级构造器创建apply方法.

调用实例:

import progscala2.basicoop.{Address, Person}

val a1 = new Address("1 Scala Lane", "Anytown", "CA", "98765")
// Result: Address(1 Scala Lane,Anytown,CA,98765)
val a2 = new Address("98765")
// Result: Address([unknown],Anytown,CA,98765)
new Person("Buck Trends1")
// Result: Person(Buck Trends1,None,None)
new Person("Buck Trends2", Some(20), Some(a1))
// Result: Person(Buck Trends2,Some(20),
// Some(Address(1 Scala Lane,Anytown,CA,98765)))
new Person("Buck Trends3", 20, a2)
// Result: Person(Buck Trends3,Some(20),
// Some(Address([unknown],Anytown,CA,98765)))
new Person("Buck Trends4", 20)
// Result: Person(Buck Trends4,Some(20),None)

这时我们可以在伴生对象中(case类的object)重载apply方法,以获得便利的构造器,而不需要再使用new关键字.

case class Person3( name: String, age: Option[Int] = None, address: Option[Address] = None)
object Person3 {
    // 由于我们重载的是普通方法,而不是构造器, 所以必须明确地指定返回类型(因为如果通过使用this来重载构造器,返回的仍然是该类型的实例),在这里返回类型是Person3。 
    def apply(name: String): Person3 = new Person3(name)

    def apply(name: String, age: Int): Person3 = new Person3(name, Some(age))

    def apply(name: String, age: Int, address: Address): Person3 = new Person3(name, Some(age), Some(address))

    def apply(name: String, address: Address): Person3 = new Person3(name, address = Some(address))
}

注意,与构造器不同,apply这样的重载方法必须指定返回类型.

在使用时,如果提供了与主构造器签名一致的参数,则会调用case类自动生成的apply方法,否则会依次去匹配其他的辅助构造器.

Fields in Classes

class Name(var value: String)

被展开:

class Name(s: String) {
    private var _value: String = s                          // 1
    def value: String = _value                              // 2
    def value_=(newValue: String): Unit = _value = newValue // 3
}
  1. 内部可变字段
  2. getter
  3. setter

上面setter中的 value_= 是一个完整的方法名,(newValue: String) 是其参数列表.当编译器看到这样一个方法名时,会允许去掉_,使用中缀表达式,就好像我们是在设置一个对象的字段一样:

name.value_=("Bubba")   // 默认的调用方法
name.value = "Hank"     // 使用中缀表达式的方法

如果参数声明为val,则不会生成setter方法,因为其不可变.

同时,对于非case类,如果我们向构造器传递参数时不希望该参数称为类的一个字段,可以在构造器中省略val或var.但是该值仍然在类体的作用范围内,仍然指向构造实例时所用的参数,只是没有被声明为类的字段,在类体中都是可见的,只能被类的成员在类内部使用,比如类的成员方法,但对类的客户端不可见.

Scala并没有采用Java风格的getter和setter方法,而是支持统一访问原则.在统一访问原则中,读写”裸”字段的语法和通过间接调用方法读写的语法是一样的.

一元方法

case class Complex(real: Double, imag: Double) { 
    def unary_- : Complex = Complex(-real, imag)        // 1 
    def -(other: Complex) = Complex(real - other.real, imag - other.imag) 
}

val c1 = Complex(1.1, 2.2) 
val c2 = -c1                        // Complex(-1.1, 2.2)
val c3 = c1.unary_-                 // Complex(-1.1, 2.2)
val c4 = c1 - Complex(0.5, 1.0)     // Complex(0.6, 1.2)
  1. 方法名 unary_X, X就是我们想要的操作符,上例中为 -,注意在 : 之前有一个空格,以告诉编译器方法名是以 - 结尾而不是 :.

一旦定义了一元操作符,就可以将操作符放在实例之前进行调用.

验证输入

如果需要验证输入的参数,以确保创建有效的实例,可以使用Predef中定义的require方法.

比如对一个邮编进行验证:

case class ZipCode(zip: Int, extension: Option[Int] = None) { 
    require(valid(zip, extension), s"Invalid Zip+4 specified: $toString")       // 1

    protected def valid(z: Int, e: Option[Int]): Boolean = { 
        if (0 < z && z <= 99999) e match { 
            case None => validUSPS(z, 0) 
            case Some(e) => 0 < e && e <= 9999 && validUSPS(z, e) 
        }
        else false
    }

    /**这是个有效的美国邮政编码吗? */ 
    protected def validUSPS(i: Int, e: Int): Boolean = true                     // 2

    override def toString =                                                     // 3 
        if (extension != None) s"$zip-${extension.get}" else zip.toString 
}

object ZipCode {                                                                // 4
    def apply (zip: Int, extension: Int): ZipCode = new ZipCode(zip, Some(extension)) 
}
  1. 使用 require 方法验证参数输入
  2. 真正的方法实现是查询USPS认可的数据库来验证编码是否真的存在
  3. 重写toString方法,返回标准的邮编格式
  4. 同时提供了一个apply方法,以提供便利的实例化方法,在提供可选参数extension时不需要再提供Some()

同时还可以使用Predef中的ensuring和assume实现类似功能.

调用父类构造器

子类的构造器必须调用父类的构造器,可以是父类的主构造器或辅助构造器.

case class Person(name: String, age: Option[Int] = None, address: Option[Address] = None)

class Employee( name: String, age: Option[Int] = None, address: Option[Address] = None, val title: String = "[unknown]", val manager: Option[Employee] = None) 
    extends Person(name, age, address) {
    override def toString = s"Employee($name, $age, $address, $title, $manager)"
}
val a1 = new Address("1 Scala Lane", "Anytown", "CA", "98765") 
val a2 = new Address("98765")

Person是一个case类,而Employee被声明为一个普通的类.子类中新增的两个字段title和manager需要使用val关键字声明,因为Employee不是case类.同时调用了Person的主构造器,调用时只需要提供参数名即可.重写了toString方法,如果不重写则会调用父类的toString.

在Java中需要定义构造方法,并用super调用父类的初始化逻辑,但是在Scala中,使用 ChildClass(...) extends ParentClass(...) 这样的语法隐式的调用父类构造器.

良好的面向对象设计

可以从一个case类中派生一个普通类,或者反过来,但是不能从一个case类派生另一个case类,因为,自动生成的toString,equals,hashCode方法在子类中无法正常工作.

case类为方便简单的域类型提供了模式匹配和实例分解,但支持继承结构并不是他们的目的.

当使用继承时,可以遵循以下规则:

  1. 一个抽象的基类或者trait,只被下一层的具体的类继承,包括case类
  2. 具体类的子类永远不会再次被继承,除了两种情况: 类中混入了定义于trait中的其他行为,理想情况下,这些行为是不重叠的.只用于自动化单元测试的类.
  3. 当使用子类继承似乎是争取的做法时,考虑将行为分离到trait中,然后在类里面混入这些trait
  4. 切勿将逻辑状态跨越子类和父类,比如一些私有的专门实现的状态,这种状态不影响外部可见性,相等,散列逻辑.