Scala实战之抽象成员
in Scala with 0 comment

Scala实战之抽象成员

in Scala with 0 comment

一、介绍

内容

本课程主要介绍 Scala 中的抽象成员用法。先前的基础课程中提到 Scala 中的类型成员也可以是抽象的,而抽象字段和抽象方法都只有字段或者方法的定义,没有字段或者方法的具体实现。在本课程中,你将可以学到如何使用 Type 成员,以及抽象 vals 变量的初始化方法等要点。

知识点

环境

适合人群

本课程难度为一般,属于初级级别课程,适合 Scala 编程基础的用户。

二、开发准备

为了使用交互式 Scala 解释器,你可以在打开的终端中输入命令:

su -l hadoop   #密码是hadoop
scala

当出现scala>开始的命令行提示符时,就说明你已经成功进入解释器了。如下图所示。

image-1655710564186

本实验的所有命令及语句均可在 Shell 中输入。

三、步骤

3.1 概述

当一个类或者 Trait 的成员没有定义完整的实现时,我们称其为抽象成员。抽象成员需要在其子类中完成实现。在 Scala 中,抽象成员除了成员函数外,也可以是成员变量或是抽象类型。

本实验将介绍四种抽象成员:valsvarsmethodstypes

下面的例子定义了几种抽象成员:

trait Abstract {
    type T
    def transform(x:T) :T
    val initial :T
    var current :T
}

image-1655710572492

这个例子定义了一个抽象类型 T 、一个抽象方法 transform 、一个抽象成员变量 var current 和一个抽象成员变量 val initial

派生的实类需要实现这些抽象成员,比如:

class Concrete extends Abstract {
    type T = String
    def transform(x:String) = x + x
    val initial = "hi"
    var current = initial
}

image-1655710579105

这个类实现,给抽象类型 T 赋予了真实的类型 String 。然后为其它抽象成员给出了具体的定义。

3.2 Type 成员

Scala 中的抽象类型,指的是在类或 Trait 中使用 type 定义的类型。 类或 Trait 本身也可以使用 abstract 来定义,但这种使用 abstract 定义的类和 Trait 在 Scala 中不称为抽象类型。

你可以把派生于抽象类型的 非抽象类型 看作给一个类型,并且起一个别名。例如上面例子 Concrete 中的类型 T , 可将 Concrete 类定义中 String 类型的别名取为 T ,于是在 Concrete 中所有出现 T 的地方都代表 String ,因此前面的 Concrete 其实也可以写成:

class Concrete extends Abstract {
    type T = String
    def transform(x:T) = x + x
    val initial = "hi"
    var current = initial
}

image-1655710588442

我们将在后面再详细介绍抽象类型的用法。

3.3 初始化抽象 vals

抽象定义的 vals 在某些时候,可以起到父类参数的角色。它们允许你在子类中提供在父类中省略的定义,这对于 Trait 来说尤其重要。因为 Trait 没有提供可供传入参数的构造函数。因此为 Trait 提供参数支持,通常是通过抽象 vals 来实现的。

例如我们之前例子中使用过的有理数类型(Rational),使用 Trait 方式定义如下:

trait RationalTrait{
    val numerArg:Int
    val denomArg:Int
}

image-1655710596101

我们之前使用的 Rational 类定义定义了两个参数 nd,分别代表分子和分母。这里我们使用 RationalTrait 定义了两个抽象的 vals 值。为了构造这个 Trait 的一个实例,你需要提供这些抽象成员的实现,这里可以使用匿名类实例的方法构造一个实例:

scala> val r = new RationalTrait {
     | val numerArg = 1
     | val denomArg = 2
     | }
r: RationalTrait = $anon$1@45c8e616

这种构造 RationalTrait 实例的方法,在形式上和之前的 new Rational(1,2) 有点相像,但还是有些细节上的差别——在表达式初始化的顺序上的差异。

当你使用如下代码:

new Rational(expr1,expr2)

其中的两个表达式 expr1,expr2 在初始化类 Rational 之前就计算好了,因此在初始化 Rational 时,这些表达式是可以用的。而对于 Trait 来说,情况却相反:

new RationalTrait{
    val numerArg = expr1
    val denomArg = expr2
}

计算表达式 expr1,expr2 是在处理匿名类实例的过程中进行的,而匿名类的处理是在 RationalTrait 之后进行的。因此,在初始化 RationalTrait 时,这两个值是不可用的(或者说这两个值是缺省值 0 )。这对于 RationalTrait 定义来说,不是个什么问题,因为 RationalTrait 的初始化没有使用到 numerArgdenomArg ,但对于下面的 RationalTrait 定义就存在问题了:

trait RationalTrait{
    val numerArg :Int
    val denomArg :Int

    require(denomArg !=0)
    private val g = gcd(numerArg,denomArg)

    val numer = numerArg/g
    val denom = denomArg/g

    private def gcd(a:Int,b:Int):Int =
        if(b==0) a else gcd(b, a % b)

    override def toString = numer + "/" + denom
}

image-1655710606597

如果此时,你使用某些表达式来构造这个 Trait 的实例,就会出问题了:

scala> val x =1
x: Int = 1

scala> new RationalTrait{
     | val numerArg = x
     | val denomArg = 2*x
     | }
java.lang.IllegalArgumentException: requirement failed
  at scala.Predef$.require(Predef.scala:212)
  at RationalTrait$class.$init$(<console>:15)
  ... 36 elided

scala> new RationalTrait{
     | val numerArg = 1
     | val denomArg = 2
     | }
java.lang.IllegalArgumentException: requirement failed
  at scala.Predef$.require(Predef.scala:212)
  at RationalTrait$class.$init$(<console>:15)
  ... 36 elided

这是因为在执行 RationalTrait 的初始化代码时, denomArg 的值还是 0,所以抛出了异常。

由此你可以知道,抽象 val 值和类参数之间存在不同。对于使用 Trait 的这个问题,Scala 提供了两个解决方案:域的预先初始化,还有 lazy vals 。我们在下个小节介绍它们。

3.4 预先初始化成员的值

为了解决上面的 RationalTrait 中的问题,有两个解决方案:第一种方案是使用预先初始化成员的值的方法,这种方法可以让你在调用父类构造函数之前首先初始化子类的成员。

这种方法,是把初始化成员变量的定义放在调用父构造函数之前。

下面是一个匿名实例化 Trait 的例子,尝试在 Shell 中输入这些语句:

val x = 1

new {
    val numerArg =1 * x
    val denomArg = 2 *x
} with RationalTrait

image-1655710617181

可以看到在这个例子中,我们把初始化成员的代码放在其父 Trait 之前。再看一个例子:

object twoThirds extends {
    val numerArg =1 * x
    val denomArg = 2 *x
} with RationalTrait

image-1655710624168

初始化成员部分也是防止其父 Trait 之前,两个方法都使用with

这种方法除了可以应用中匿名实例和对象外,也可以应用中类的定义说,比如我们可以定义如下的 RationalClass:

class RationalClass(n:Int,d:Int) extends {
    val numerArg =n
    val denomArg =d
} with RationalTrait {
    def + (that: RationalClass) = new RationalClass(
        numer * that.denom + that.numer * denom,
        denom * that.denom
    )
}

因为这些预先初始化的值发生在调用父类的构造函数之前,因此这些初始化这些值时,不可以引用正在构造的对象。正因为如此,如果在初始化的表达式中使用 this,这个 this 不是指正在构造的对象,而是指包含这个定义的对象。比如:

new {
    val numberArg =1
    val denomArg = this.numerArg *2
} with RationalTrait

image-1655710635687

这个例子无法编译,这是因为编译器无法找到 对象 iw 的 numerArg 成员,iwnumerArg成员,iw 为 Scala 命令行输入的当前对象。

3.5 延迟初始化(Lazy vals)

除了前面介绍的预先初始化成员值外,你还可以让系统自行决定何时初始化成员的初始值。这是通过在 val 定义前面添加 lazy(懒惰),也是说直到你第一次需要引用该成员时,系统才会去初始化,否则该成员就不初始化(这也是 lazy 的由来,可理解为懒加载)。

首先我们定义一个正常定义 val 的例子:

object Demo {
    val x = { println("initializing x"); "done"}
}

image-1655710648916

我们首先引用 Demo,然后 Demo.x

 Demo
 Demo.x

image-1655710655183

正如你所看到的,当引用 Demo 对象时,它的成员x也会初始化,初始化 x 伴随着初始化 Demo 的过程。然后,如果我们在 val x 前添加 lazy ,情况就有所不同了:

object Demo {
    lazy val x = { println("initializing x"); "done"}
}

image-1655710661550

 Demo
 Demo.x

image-1655710667385

在使用 lazy 之后,初始化 Demo 时,不会初始化 x ,只有在引用到 Demo.x 时,该初始化代码才会执行。

这有点类似定义了一个无参数的方法,但和 def 不同的是,lazy 变量初始化代码只会执行一次。

通过这个例子,我们可以看到诸如 Demo 的对象本身也像一个 lazy 变量,也是在第一次引用时才会初始化,这是正确的。实际上,一个 object 定义可以看成是使用了 lazy val 定义一个匿名类实例的简化方式。

使用 lazy val ,我们可以修改之前的 RationalTrait。在这个新的 Trait 定义中,所有的类成员变量的实现(非抽象成员)都使用 lazy 来修饰。

trait LazyRationalTrait{
    val numerArg :Int
    val denomArg :Int

    lazy val numer = numerArg/g
    lazy val denom = denomArg/g

    private lazy val g = {
        require(denomArg !=0)
        gcd(numerArg,denomArg)
    }
    private def gcd(a:Int,b:Int):Int =
        if(b==0) a else gcd(b, a % b)

    override def toString = numer + "/" + denom
}

image-1655710677211

同时我们把 require 移动到 g 里面,这样所有的 lazy val 初始化代码都移动到 val 定义的右边。我们不再需要预先初始化成员变量。测试如下:

val x = 2
new LazyRationalTrait{
val numerArg = x
val denomArg = 2 * x
}

image-1655710685017

我们来分析一下这段代码中命令行的执行顺序:

首先,创建了一个新的 LazyRationalTrait 的实例,执行 LazyRationalTrait 的初始化代码,这部分代码为空,LazyRationalTrait 所有成员变量都没有初始化。

其次,该 Trait 的匿名子类的主构造函数被执行,这部分初始化 numberArgdenomArg24

接下来,命令行需要调用该实例的 toString 方法来显示该实例的值。

接下来, toString 需要访问成员 number。这是第一次访问该成员,因此 lazy val 初始化代码被执行。初始化代码调用私有成员 g ,因此需要计算 g 的值,用到之前定义过的 numberArgdenomArg

接下来 toString 需要访问成员 denom,这是第一次访问该成员,因此 lazy val 初始化代码被执行。初始化代码调用私有成员 g ,因此需要计算 g 的值,因为 g 已经计算过,无需再计算。

最后,toString 的结果 1/2 构造出来并显示。

在这个例子中,我们在写代码时,g 定义在 numberdenom 的后面。然而,由于这三个变量都是使用 lazy 来定义的,因此它们在代码中出现的顺序并不重要。

3.6 抽象类型

在本实验的一开始,我们看到 “Type T” 的用法,这是声明一个抽象类型。本篇介绍这种声明的意义和用途。和其它类型的抽象类型一样,抽象类型定义也是定义了一个“占位符”类型,其具体定义由其子类声明。不同的子类可以定义不同 T 的具体类型。

下面我们通过一个例子来说明抽象类型的用法。假定你需要为动物的饮食习性建模, 你可能会定义如下的数据结构:

class Food
abstract class Animal {
    def eat(food: Food)
}

image-1655710694116

然后对于与牛和草,我们可能需要实现两个不同的实类 CowsGrass

class Grass extends Food
class Cow extends Animal {
    override def eat(food: Grass) {}
}

但是这段代码编译可能不通过:

image-1655710702291

怎么会出错呢?这是因为类 Cow 中的 eat 不能重载其父类。不能重载的原因是参数类型不一致,Animalfood 类型为 Food ,而 Cow 中类型为 Grass 。 仅管我们可能会说 GrassFood 的子类,编译器没有必要这么严格。但是如果编译器允许这种情况存在,那么很快就会出现新问题。

假定前面的编译没有问题,我们再定义一个 Fish 类:

class Fish extends Food
val bessy: Animal = new Cow
bessy eat (new Fish)

问题来了,我们给牛喂了鱼。如果前面的 Cow 可以编译通过的话,这段代码也是合法的,但结果却显然不对,因此编译器编译不通过 Cow 是有道理的。

对于这个问题,我们可以通过抽象类型来解决,哪种动物吃哪种食物由动物决定:

class Food
abstract class Animal {
    type SuitableFood <: Food
    def eat(food:SuitableFood)
}

image-1655710710441

当定义新的 Animal 子类时,动物只能吃合适的食物,而 Animal 类本身不能决定那种食物合适,因此我们使用抽象类型定义。 但这个抽象类型有类型上界限制,表示 Animal 子类中使用的 SuitableFood 必须是 Food 的子类。

我们重新定义 Cow 如下:

class Grass extends Food
class Cow extends Animal {
    type SuitableFood = Grass
    override def eat(food:Grass) {}
}

image-1655710724013

你现在再试试喂鱼给牛看看:

 class Fish extends Food
 val bessy :Animal = new Cow
 bessy eat (new Fish)

image-1655710730936

发现了什么呢?

四、总结

要注意不要与 Trait 的用法搞混淆了。我们来总结一下关于抽象类和抽象成员中主要被注意的几个要点: