Scala 之:继承与抽象类

2,485 阅读10分钟

本章主要介绍如何实现 Scala 类的继承,以及由继承引申出的父类抽象类重写上转型对象等概念。为了弄清 Scala 的重写机制,读者认为有必要先去复习Java的动态绑定和静态绑定机制

另外,随着继承的介绍,我们在本章开始要额外注意类的控制权限问题:哪些成员(属性和方法)允许被外部调用,哪些成员只允许子类调用,或者说只允许本类使用。

Java 中为了弥补单继承的缺陷而引入另外一个重要概念:接口 interface 。Scala 提供了功能更强大的特质 trait 来实现,笔者会在后续单独更新出一片文章介绍它。

Scala 中的继承与重写

Scala 的继承与 Java 大同小异,同样都是使用 extends 做关键字。在 Scala 中,子类能够访问父类的公开属性,其本质上是因为继承了父类公开的 xxxxxx_eq$ 方法。

注意甄别:子类不能访问父类的 private 修饰的成员,不是因为没有将父类的私有成员继承过来,而是父类没有提供公开的访问方法

重写是基于继承延申出来的概念:即根据实际需求,对继承自父类的方法进行改进,使其能够用在更具有针对性的业务中。

在 Java 中,可以使用 @Override 注解来检查对父类/接口的重写方法(或属性)。而在 Scala 中,它作为一个关键字出现,当重写方法时必须显式地加上 override 关键字。如果在子类中声明了与父类重名的方法(或者属性),却没有加上该关键字,则会报错。

class Person{
  protected val age : Int = 100
  def run = "slow"
}

class Athlete extends Person {
  override val age : Int = 100
  override def run ="fast"
}

如果要明确调用父类的重名方法/属性,可以像 Java 一样借助super.{}来调用。另外注意!子类只能覆盖父类中被声明为val的属性,因为被var修饰的属性可任意更改,覆写就无从谈起。

Scala 构造超类

在 Java 中,各个构造器之间都是平级的。并且在调用自身的构造器之前,都会首先显式或隐式地使用super关键字调用超类构造器。即便是没有继承关系的类,它的构造函数也总会默认调用 super() 超类构造器,因为 Java 规定,所有的类都继承自 Object。

在 Scala 中,调用父类构造器的渠道只有子类的主构造器。辅助构造器不能通过 super 的方式调用父类的任何构造器。

想要对父类的属性进行初始化,我们要通过子类的主构造器把参数传递给父类的主构造器或辅助构造器

下面是代码演示:

class Person(var age:Int){}

//使用子类的主构造器中的参数为父构造器赋值。
class Student(age :Int) extends Person(age = age){}

Scala 中的控制权限

在 Java 开发中,对类成员(包括属性以及方法)的控制权限绝大部分都是public,或者private。很少会使用到default或者protected两种访问权限,尤其是 default 。因此 Scala 干脆移除了这个相对“含糊不清”的 default 修饰符。

额外的,还要注意以下几点:

  • Scala 没有 public 关键字。不加任何修饰符即表明该成员是公开的。
  • Scala 的 protected 关键字更加严格,它仅仅对子类开放,不对同一个包下的其它类开放。
  • Scala 的类内属性在底层编译为 .class 文件时通通都是 private 的。编译器通过这个判断属性在 Scala 层面上是公开的还是被 private 修饰的,来决定是否在底层提供 xxx / xxx_eq$ 方法。
  • 编译器通过判断某个方法在 Scala 层面上是否声明为了 private ,来决定在底层声明该方法是 public 还是 private
  • 编译器通过判断某个属性在 Scala 层面上是否为 val 变量,来决定在底层是否为此属性加上 final 关键字修饰。
修饰符对子类可见对外部可见
缺省
protected×
private××

有关于 protected 关键字的疑惑

如果使用 jd-gui 查看字节码文件,我们可以发现:Scala 中被protected 修饰的属性在 .class 文件中看起来和那些公有属性没有任何的区别。 看来马丁大神是对编译器本身做了一点手脚,导致我们即使在一个包下,也无法访问被 protected 修饰的属性。

class Clazz(  protected val value : Int)
//----------------主程序-------------------//
val clazz = new Clazz(10)
//IDEA的确会提示可以调用clazz.value,但是一旦写上就会报错。
println(clazz.value)

但是要注意,在编写代码时,IDEA 可能会给出错误的代码提示,允许我们在另一个无关的类当中访问被 protected 保护的成员(但是一写上就会报错,这个应该是 IDEA 的一个小 bug )。

属性重写

在深入了解 Scala 的属性重写之前,首先应该回顾 Java 的动态绑定机制。由于 Java 中的属性是静态绑定的(或者从属性隐藏的角度考虑),因此属性重写在 Java 的视角看来没有任何意义。

笔者在另一篇文章中提到了绑定机制:小插曲:Java的动态与静态绑定机制

Scala 的 override 关键字可以用来重写父类的属性。但是属性重写有两个条件:

  1. 该属性是不可改写的。即使用 val 修饰。
  2. 该属性至少对子类可读(即公开的,或者是 protected 修饰)
class Person{  
 protected val age:Int = 20
}

class Student(InAge :Int) extends Person{
  //传进来的Int参数覆盖了父类的age关键字。  
  override val age : Int = InAge
  
}

属性重写不过是 Scala 的一个 trick

Scala 的属性重写实质上仍然是对方法的重写,因为 Scala 的属性访问本质上是调用了其公开的 xxx(get) 和 xxx_eq$(set) 方法

为什么不能用 val 重写 var ?

我们刚才提到,子类不能使用 val 属性去重写父类的 var 属性。假设下面的代码是合理的:

class Person {
  var salary: Int = 100
}

class Athlete extends Person {
  override val salary: Int = 500
}

在底层编译时,父类具有两个方法:salary(相当于 Get)和salary_eq$(相当于 Set)方法,而子类仅可以重写 salary 方法。当为salary赋值时,调用的是父类的 salary_eq$ 方法(因为子类没有该方法的重写),改写的是父类的 salary 值;而取值时,由于方法是动态绑定的,程序优先选择子类的 salary 方法。显然,我们读和写的 salary 属性并不是同一个。

属性和方法相互重写的情形

在 Scala 中,其实属性和方法的界限变得稍微模糊了。这里对上述代码做如下改动:

class Person {def salary: Int = 100}

注意!这里的 salary 是一个被def修饰的方法。它是一个结构非常简单的 get 函数(这个函数声明没有小括号(),它属于无参数函数 parameterless method)。由于上述的 salary 方法内部没有任何其它多余的代码块(或称没有副作用),因此它和下面的声明完全等价

val salary : Int = 100  

以至于我们在后续的子类中可以使用一个同名的 salary 属性去重写 salary 方法 ( 记得加上 override 关键字 ) 。类似的逻辑,方法也可以去重写父类当中用 val 修饰的属性。

class Teacher(override val salary : Int) extends Person

将视角重回 Person 类那里。而对于 Scala 程序的调用者而言,无论是上面哪种声明方式,他都仅需要通过 .salary 就可以获取到属性值:

val person = new Person()
person.salary

因此,我们也可以这么说:在 Scala 中,即可以通过成员变量的方式定义属性,也可以通过定义 get 型的无参函数定义属性。这么做的意义是什么呢?笔者在后续的 Scala 函数高级部分会详细地介绍统一访问原则

var 可以重写父类抽象的 var 属性

这又抛出了一个问题:在 Scala 中何为抽象属性呢?我们之前在 Scala 的数据类型中提到了一点:类的成员属性必须要赋值,或者用 null_ 为其赋一个默认值。

除非有一种情况:类本身是一个抽象类(我们马上就会说到),则未被初始化值的属性被视作是抽象属性

//若存在抽象属性,必须要把类也声明为抽象的。
abstract class Person{
  //没有被初始化的值,被视作是抽象属性。
  var value : Salary
}

在编译字节码文件时,其 xxxxxx_eq$ 方法也对应成为了抽象方法。注意:抽象的成员属性必须是被var修饰的。被 val 修饰的属性在底层会添加 final 关键字保护,导致我们不能再去重写它。

//忽略了一些不相干的代码
public abstract class Person
{
  public abstract int salary();

  public abstract void salary_$eq(int paramInt);
}

我们再声明一个继承 Person 类的 Student 类,则编译器要求我们必须要对抽象的salary属性进行重写(因为本质上是对抽象的salarysalary_eq$方法进行重写,除非将 Student 也声明为一个抽象类,将抽象方法抛给下一个继承于 Student 的子类去实现)。

class Student extends Person{

  override var salary: Int = 10000
  
}

与其说是重写了父类抽象的 salary 属性,倒不如说是实现了父类留下来的抽象方法。因此在该情况下,即使不写 override 关键字,也不会报错

Scala 的属性是动态绑定的

现在已经有种种迹象表明 Scala 的属性和 Java 的属性存在着本质不同,因此是时候聊聊更深入的话题了。首先,从 Java 代码的角度,以一个简单的例子开始:

public class BindingStaticInJava {
​
    private static class Father{
​
        // 1.
        public int i = 20;
​
        // 2. 由于属性是静态绑定的,因此调用的 i 是注释 1. 的那个属性。
        public int getI() {
            return i;
        }
    }
​
    private static class Son extends Father{
        public int i = 10;
​
    }
​
    public static void main(String[] args) {
        Father o = new Son();
​
        // 3. 调用的代码见注释 2. 部分。
        System.out.println(o.getI());
    }
}

这段代码的逻辑无需赘述,很显然这个 getI() 方法返回的就是父类 Father 中定义的那个 i 的值。最终的返回值是 20。下面的例子则综合考虑了动态绑定和静态绑定的情形:

public class BindingDynamicInJava {
    private static class Father{
​
        public int i = 20;
        public int getI() {
            return i;
        }
    }
​
    private static class Son extends Father{
​
        // 1.
        public int i = 10;
​
        // 2. 由于属性是静态绑定的,因此调用的 i 是注释 1. 的那个属性。
        public int getI(){
            return i;
        }
    }
​
    public static void main(String[] args) {
        Father o = new Son();
​
        // 3. 由于方法是动态绑定的,因此调用的代码见注释 2. 部分。
        System.out.println(o.getI());
    }
}

和之前的例子相比,这段代码的子类 Son 具备了自己重写的 getI() 方法。由于 o 是一个上转型对象,主程序通过动态绑定定位到了这个方法。那么,这个方法中的 i 指代谁呢?由于静态绑定的机制,Java 早在编译期就确定了此处的 i 就是 Son 内部定义的那个 i。因此,这段代码的最终运行结果为 10。

现在我们以 Scala 的视角复刻 BindingStaticInJava 类中实现的逻辑:

object BindingStaticInScala {
​
  sealed class Father{
​
    val i : Int = 20
    // 1. 注意,这处代码的实际逻辑是:
    // def returnI : Int = getI();
    // 在 Scala 的底层逻辑当中,i 是方法,因此 JVM 对此是动态调用的。
    // 参见注释 2 .部分。
    def returnI : Int = i
  }
​
  sealed class Son extends Father {
​
    // 2. 子类声明的这段属性实际上是对父类 getI() 方法的重写。
    // 这也导致了 JVM 最终在 "返回i的属性值时" 被动态引导到此处并返回了 10,而非 20。
    override val i: Int = 10
  }
​
​
  def main(args: Array[String]): Unit = {
​
    val o : Father = new Son
    // 3. 调用的代码见注释 1. 部分
    println(o.returnI)
  }
}

要知道,在刚才的 Java 实验中,我们得知这样的逻辑最终应该返回 20。但是在 Scala 的版本中,这个运行结果是 10,程序虽然定位到了 Father 那里调用 returnI 方法,但显然它最终是去 Son 那里返回了 i 的值。

要解释这个现象发生的原因也十分简单:Scala 属性在底层等价于 Java 的方法,而 JVM 对方法调用的处理总是动态的。所以,如果我们将 i 视作是方法,那么一且都解释的通了。下面这段 Java 代码才是其 Scala 版本的等价版本:

public class HowScalaBinds {
    private static class Father{
​
        // 相当于 Scala 定义的那个 val i 。
        // 不可变属性不会提供 set 方法。
        public int getI() {return 20;}
        public int returnI(){
            // 注意,到这里仍然是一个动态调用。
            return getI();
        }
    }
​
    private static class Son extends Father {
        // JVM 最终通过动态调用定位到了这里,这是 Scala 程序最终返回 10 而非 20 的原因。
        public int getI() {return 10;}
    }
​
    public static void main(String[] args) {
        Father o = new Son();
        System.out.println(o.returnI());
    }
}

抽象类

抽象类的价值更多在于设计,在高层给出一个抽象的规范,而具体实现则交给子类实现。

在 Scala 中, abstract 关键字仅仅用于标记类,表示该类具备抽象的属性或者方法。在 Scala 中,如果想表示一个抽象的方法或者属性,仅仅需要这样做:

  • 不对 var 属性赋值。
  • 不声明成员方法的结构体。

因此,我们没有必要在属性或者方法前再使用 abstract 修饰符声明它们是抽象的了。

abstract class Person {
  //抽象属性
  var salary: Int
  //抽象方法
  def action : Any
}

抽象类的一些细节

为了方便叙述,在这里将抽象的属性和方法统称为抽象成员

  1. 抽象类不可以被实例化,除非使用匿名类实现了所有的抽象成员,这一点和 Java 是一样的。
  2. 抽象类可以有具体的成员,但一定会有抽象成员。甚至说没有抽象成员,编译器也不会报错......但是这样的话,我们何必定义它为抽象类呢?
  3. 如果一个类继承了抽象类,则它必须实现所有的抽象成员,或者将自己也声明为抽象类,抛给下一个继承该类的子类去实现剩下的抽象成员。
  4. 抽象成员不可以使用 private 或者 final 关键字修饰,因为它们与继承和重写的理念相悖。
  5. 根据第 4 条,我们无法使用 val 去修饰抽象属性。
  6. 在实现抽象成员时,可以不使用 override 关键字修饰,因为这本身是实现,而非重写

使用匿名子类实现抽象类

Scala 的匿名类和 Java 如出一辙。声明时需要再紧跟上一个代码块来实现所有的抽象成员。在这里举个例子,比如实现一个前文声明的抽象类 Person :

val person : Person = new Person {
    
    override def action: Any ={
		println("Hello!")
	}
    
	override var salary: Int = 1000
}

Scala 类型检查与转换

在检查一个上转型对象的具体类型时,我们需要一种机制来检测一个对象实际的类型。我们以和 Java 代码相对比的方式来介绍 Scala 的类型转换:

classOF[T],isInstanceOf[T],asInstanceOf[T]

阐述Java StyleScala Style
通过类反射类名String.class;classOf[String]
判断某个实例是否属于某类"java" instanceof String;"Scala".isInstanceOf[String]
强制转换为某类(Apple)new Fruit();person.asInstanceOf[Athlete]
查看某个实例的类名apple.getClass().getName()person.getClass.getName

T 的 ClassName 一定是 T 吗?

在以下情况下,此断言不成立。即:

  • P是一个接口,而S实现了该接口。
  • P是一个(抽象)类,而S继承了该类。

当一个 P 的引用指向了一个 S 的实例时(上转型对象),执行 getClass() 方法,得到的类型就是 S

P parent = new S();
//输出的className是S,而不是P。
System.out.println(parent.getClass().getName());

Java 中的 Collection 接口实现就是一个很好的例子:

List list = new LinkedList();
//打印的是 List 还是 LinkedList ?
System.out.println(list.getClass().getName());

所以当我们想知道一个上转型对象的真正类型时,常常使用isInstanceOf[T], getClass()等方式来判断。

基于类型转换在 Scala 中实现参数多态

我们想实现以下功能:

  • 现在同时有 Student 和 Teacher 继承自 Person 类。
  • Student 具备 learn 方法,Teacher 具备 teach 方法。
  • 有一个功能 action ,接收的参数限定为 Person 的子类。
  • 现在要设定如下的功能:若传入的参数为 Student 类,则执行其 learn 方法;反之执行 teach 方法。

程序图如下:

想要完成这个案例,我们就要想办法知道传入 action 函数的上转型对象的实际类型,然后再根据判断结果执行不同的方法

需要注意一点:使用asInstanceOf[T]方法对某个对象进行强制转换时,将会返回一个新的引用,原来的对象仍保持原类型不变。

首先给出三个简单类: Student, Teacher, Person。

class Person {
  def behave = "taking action"
}

class Teacher extends Person {
  def teach = "Teaching Scala"
}

class Student extends Person {
  def learn = "Learning Scala"
}

main 方法内定义一个 action 函数:根据参数 p 的实际类型反馈对应的行为。

def main(args: Array[String]): Unit = {

  //传入的引用p可能是Teacher, Student, 也有可能是其它继承了Person的类。
  def action(p: Person): String = {
    // 在掌握模式匹配后,我们可以用更优雅的方式来实现这个逻辑。
    if (p.isInstanceOf[Teacher]) {
      p.asInstanceOf[Teacher].teach
    } else if (p.isInstanceOf[Student]) {
      p.asInstanceOf[Student].learn
    } else {
      p.behave
    }
  }

  val teacher: Teacher = new Teacher
  val student: Student = new Student

  printf(action(teacher))

}

在后续的模式匹配中,我们会用一种更加高效的方法来解决这种问题。