返回介绍

数学基础

统计学习

深度学习

工具

Scala

九、抽象成员

发布于 2023-07-17 23:38:23 字数 22665 浏览 0 评论 0 收藏 0

  1. 如果类或者特质的某个成员在当前类中没有完整的定义,则它就是抽象的。抽象的本意是为了让声明该成员的类的子类来实现。不过 Scala 走的更远,它将这个概念完全泛化:除了可以声明抽象方法之外,还可以声明抽象字段甚至抽象类型作为类或特质的成员。

    下面这个特质声明了四种抽象成员:抽象类型 T、抽象方法 transform、抽象 val 字段 initial、抽象var 字段 current

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

    因此 Abstract 特质的具体实现需要填充每个抽象成员的定义。

    
    
    xxxxxxxxxx
    class MyCls extends Abstract{ type T = String def transform(x: String) = x + x val initial = "hi" val current = initial }
  2. 抽象类型abstract type 成员:用 type 关键字声明为某个类或者特质的成员、但是未给出定义的类型。

    注意:抽象类不等于抽象类型,抽象类型永远是类或者特质的成员。而抽象类指的是通过修饰符abstract 定义的类。

    
    
    xxxxxxxxxx
    trait Abstract{ type T // 抽象类型 ... } abstract class Element { // 抽象类 def contents: Array[String] }

    与抽象类型成员对应的是具体类型成员,具体类型成员可以认为是某个具体类型的别名。

    
    
    xxxxxxxxxx
    class MyCls extends Abstract{ type T = String // T 是 String 的别名 }

    使用类型成员的原因之一是:给名字冗长的类型、或者含义不清晰的类型一个简短的、含义明确的别名。

    另一个原因是:声明子类必须定义的抽象类型(如这里的 type T )。

  3. 抽象的 val 成员:

    
    
    xxxxxxxxxx
    val initial : String

    该声明给出了 val 的名称和类型,但是未给出具体值。这个值必须由子类中具体的 val 定义提供。

    这种抽象成员的应用场景:不知道变量具体的值,但是明确的知道它在当前类的每个实例中都不可变时,可以采用抽象的 val 成员。

    • 采用抽象 val 成员可以有如下保证:每次对 .initial 的引用都会交出相同的值。

      如果采用抽象的无参方法成员:

      
      
      xxxxxxxxxx
      def initial : String

      则每次方法调用无法给出这个保证。

    • 因此抽象val 限制了它的合法实现:每个子类的实现必须是由一个 val 定义,而不能是 def 或者 var

      与此对比,抽象方法成员可以用具体的方法定义或者具体的 val 定义来实现。

    
    
    xxxxxxxxxx
    abstract class Fruit{ val v : String def m : String } ​ class Apple extends Fruit{ val v : String = "apple" val m : String = "example" // 用 val 重写 def 是 OK 的 } ​ class Orange extends Fruit{ def v : String = "apple" // 用 def 覆盖 val 是不允许的 val m : String = "example" }
  4. 跟抽象的 val 成员类似,抽象的 var 成员也只是声明了名称和类型,但是并不给出初始值。

    • 声明为类成员的 var 都默认带上了 getter 方法和 setter 方法,这对于抽象的 var 成员也成立。

      
      
      xxxxxxxxxx
      trait AbstractTime { var hour: Int var minute: Int }

      这里等价于:

      
      
      xxxxxxxxxx
      trait AbstractTime{ def hour: Int // hour 的 getter 方法 def hour_=(x: Int) // hour 的 setter 方法 def minute: Int // minute 的 getter 方法 def minute_=(x: Int) // minute 的 setter 方法 }

9.1 初始化抽象的 val

  1. 抽象val 成员有时候会承担超类的参数化功能:它允许我们在子类中提供那些在超类中缺失的细节。这对于特质尤其重要,因为特质并没有让我们传入参数的构造方法。

    因此,通常对于特质的参数化是通过在子类中实现抽象 val 成员来实现的。

    如:

    
    
    xxxxxxxxxx
    trait RationalTrait{ // 有理数 val numerArg: Int // 分子 val denomArg: Int // 分母 }

    要实例化该特质的一个具体实例,需要实现抽象的 val 定义:

    
    
    xxxxxxxxxx
    new RationalTrait{ val numerArg = 1 val denomArg = 2 }

    这个表达式交出的是一个混入了特质,并且由定义体 (由 {} 提供) 定义的匿名类anonymous class 的实例。它和以下代码效果类似:

    
    
    xxxxxxxxxx
    class Rational(n: Int, d: Int){ val numer: Int = n val denom: Int = d } new Rational(1,2)

    但是二者有细微区别:

    • new Rational(expr1, expr2) 中,expr1expr2 这两个表达式会在 Rational 类初始化之前被求值,因此 expr1expr2 的值在 Rational 类初始化过程中是可见的、已知的。

    • 对于特质来讲,情况不同。

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

      expr1expr2 这两个表达式作为匿名类初始化过程的一部分被求值的,但是匿名类是在 RationalTrait 特质之后被初始化的。因此在 RationalTrait 初始化过程中,numerArg, demoArg 的值不可用。更准确的说,对于这两个值当中任何一个的选取都会交出类型 Int 的默认值 0 。

      对于这里的例子,似乎并没有什么问题,因为特质的初始化过程并没有用到 numerArgdenomArg 。但是对于下面的示例,却很致命:

      
      
      xxxxxxxxxx
      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 // 打印 } ​ val x = 2 new RationalTrait{ val numerArg = 1 * x val denomArg = 2 * x }

      在初始化匿名类之前,编译器先初始化特质,此时 1*x2*x 尚未求值,因此 numerArg,denomArg 为默认值 0 ,因此特质的 require 前置条件不满足。

      这个例子说明:类参数和抽象字段的初始化顺序并不相同。类参数在传入类构造方法之前被求值(传名参数除外),而在子类中实现的 val 抽象成员则在超类初始化之后被求值。

      为解决后者的问题,scala 提供了两种方案:预初始化字段pre-initialized fieldlazy 惰性的 val

  2. 预初始化字段 pre-initialized field:在超类被调用之前就初始化子类的字段。只需要在把字段定义放在超类的构造方法之前的花括号中即可。

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

    初始化代码段出现在超类特质 RationalTrait 之前,用 with 隔开。

    预初始化字段不仅局限于匿名类,也可以用于对象或者具名子类中。

    
    
    xxxxxxxxxx
    object ABC extends{ // 用于对象中 val numerArg = 2 val denomArg = 3 } with RationalTrait ​ object MyClass(n: Int, d: Int) extends{ // 用于具名子类 val numerArg = n val denomArg = d } with RationalTrait{ def + (that: MyClass) = new MyClass( .... ) }

    由于预初始化字段在超类的构造方法被调用之前初始化,因此它们的代码不能引用那个正在被构造的对象。因此,如果这样的初始化代码使用了 this,那么这个引用将指向包含当前被构造的类或对象的对象,而不是被构造的对象本身。

    
    
    xxxxxxxxxx
    new { val numerArg = 2 val denomArg = 3 * this.numerArg // 错误, this 指向的不是当前对象,因此没有 numerArg 属性 } with RationalTrait

    这个例子无法编译,因为 this.numerArg 引用的是包含了 new 的对象,在解释器中对应于名为 $iw 的合成对象。

  3. 预初始化字段这方面的行为类似于类构造方法的入参行为。我们可以通过预初始化字段来精确模拟类构造方法入参的初始化行为,但是有时候我们希望系统能自己搞定初始化顺序。

    可以将 val 定义为惰性的来实现。如果在 val 定义之前添加 lazy 修饰符,则右侧的初始化表达式只会在 val 第一次被用到时求值。

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

    这里 Demo 的初始化不涉及对 x 的初始化。对 x 的初始化延迟到第一次访问 x 的时候。

    这和将 xdef 定义成无参方法的情况类似,区别在于:

    • 不同于 def,惰性val 永远不会被求值多次,只会被求值一次。

      事实上对惰性 val 首次求值之后其结果会被保存起来,在后续的使用中都会复用该值。

    • def 每次使用时都会被求值。

    因此一种修改方案为:

    
    
    xxxxxxxxxx
    trait RationalTrait{ // 有理数 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 // 打印 } ​ val x = 2 val r = new RationalTrait{ val numerArg = 1 * x val denomArg = 2 * x } println(r)

    RationalTrait 匿名类的一个对象被创建时,RationalTrait 的初始化代码被执行。此时RationalTrait 的初始化代码仅初始化 numerArgdenomArg

    一旦后续调用 println(r),则调用对象的 toString 方法。该方法用到 denom 这个 lazy val 于是 denom被求值。而 denom 用到了 lazy val g,于是 g 被求值。

    尽管 g 出现在 numer, denom 之后,但是它在 numer, denom 初始化之前初始化。因此对于惰性 val,其初始化顺序和定义顺序无关。这个优势仅在惰性的 val 的初始化既不产生副作用、也不依赖副作用时有效。

    在有副作用参与时,初始化顺序就变得相当重要。这时候跟踪惰性val 的初始化顺序非常困难。因此惰性 val 在函数式编程的使用场景比较合适,因为函数式对象的初始化顺序不重要。

9.2 抽象类型

  1. 跟其它所有抽象声明一样,抽象类型声明是某种将会在子类中具体定义的东西的占位符:在类继承关系的下游中将被定义的类型,不同的子类可以提供不同的类型实现。

  2. 下面的例子并不能编译通过:

    
    
    xxxxxxxxxx
    class Food abstract class Animal{ def eat(food: Food) } class Grass extends Food class Cow extends Animal{ override def eat(food: Grass) ={} // 编译不通过 }

    实际上 Cow 类的 eat 方法并没有重写 Animal 类的 eat 方法,因为它们的参数类型不同:一个是 Food 另一个是 Grass,虽然 GrassFood 子类。

    事实上如果上述代码能给通过编译,则容易写出下面的代码:

    
    
    xxxxxxxxxx
    class Fish extends Food val abc: Animal = new Cow abc.eat(new Fish)

    这明显是不合理的。

    可以通过抽象类型来实现这段逻辑:

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

    这里 Animal 只能吃那些适合它吃的食物。至于什么食物是合适的,并不能在 Animal 类这个层级确定。这就是为什么 T 定义为一个抽象类型。这个抽象类型有个上界 Food,以 <: Food 表示。这意味着 Animal 每个子类对于 T 的实例化都必须是 Food 的子类。

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

9.3 路径依赖类型

  1. 路径依赖类型的语法跟 Java 的内部类类型相似,不过有个重要区别:路径依赖类型用的是外部对象的名称,内部类用的是外部类的名称。在 Scala 中,内部类的寻址是通过 Outer#Inner 这样的表达式而不是 JavaOuter.InnerScala. 语法只为对象保留。

    
    
    xxxxxxxxxx
    class Outer{ class Inner } val o1 = new Outer val o2 = new Outer

    o1.Inner 这样的类型称作路径依赖类型 path-dependent type 。这里的路径指的是对对象的引用,它可以是一个简单的名称,比如 o1,也可以是更长的访问路径。

    一般不同的路径(这里是不同的对象)催生出不同的类型,如 o1.Innero2.Inner 是两个路径依赖的类型(它们是不同的类型),这两个类型都是 Outer#Inner 类的子类型。

    • o1.Inner 指的是特定外部对象(即 o1 引用的那个对象)的 Inner 类。
    • o2.Inner 指的是特定外部对象(即 o2 引用的那个对象)的 Inner 类。
  2. Java 一样,Scala 的内部类的实例会保存一个到外部类实例的引用。这允许内部类访问外部类的成员。因此我们在实例化内部类的时候必须以某种方式给出外部类实例。

    • 一种方式是在外部类的定义体中实例化内部类。此时通过 this 来访问外部类实例本身。

    • 另一种方式是采用路径依赖类型。如o1.Inner 这个类型是一个特定于外部对象,我们可以实例化它:

      
      
      xxxxxxxxxx
      new o1.Inner

      得到的内部对象将包含一个指向其外部对象(即 o1 )的引用。

      于此对应,由于 Outer#Inner 并没有指明 Outer 的特定实例,因此不能创建它的实例。

9.4 改良类型

  1. 当类从另一个类继承时,将前者称为后者的名义nominal 子类。之所以称作nomial,是因为每个类型都有一个名称,而这些名称被显式声明为存在子类关系。

    除此之外Scala 还支持结构structural 子类,即:只要两个类型有兼容的成员就可以说它们之间存在子类关系。Scala 实现结构子类的方式是改良类型refinement type

  2. 名义子类通常更方便使用,因此应该在任何新的设计中优先尝试名义子类。

    但是结构子类灵活性更高。比如,希望定义一个包含食草动物的 ”牧场“类:

    
    
    xxxxxxxxxx
    class Pasture { var animals: List[Animal {type SuitableFood = Grass}] = Nil }

    这里的改良类型只需要写基类型 Animal,然后加上一系列用花括号括起来的成员即可。花括号中的成员进一步指定(或者说改良)了基类中的成员类型。

9.5 枚举

  1. 别的语言,如 Java,C# 都有内建的语法结构来支持枚举类型,而 Scala 不需要特殊的语法来支持枚举。这是通过路径依赖类型来实现的。

    Scala 在标准库中提供了一个类 scala.Enumeration,可以通过定义一个扩展自该类的对象来创建新枚举。

    
    
    xxxxxxxxxx
    object Color extends Enumeration { val Red = Value val Green = Value val Blue = Value }

    Scala 还允许我们用同一个右侧表达式来简化多个连续的 val 或者 var 定义,上述定义等价于:

    
    
    xxxxxxxxxx
    object Color extends Enumeration { val Red,Green,Blue = Value }

    这个对象定义了三个值:Color.Red,Color.Green,Color.Blue

  2. Enumeration 定义了一个名为 Value 的内部类,跟这个内部类同名的、不带参数的 Value 方法每次都返回该类的全新实例。因此类似 Color.Red 的类型为 Color.Value ,而 Color.Value 是所有定义在 Color 对象中的 Value 的类型。

    这里面的关键点在于:这是一个完全的新类型,不同于其它所有类型。

    因此如果我们定义了另外一个枚举类型:

    
    
    xxxxxxxxxx
    object Direction extends Enumeration { val North, East, South, West = Value }

    那么 Direction.Value 将不同于 Color.Value ,因为这两个类型的路径部分是不同的。

  3. ScalaEnumeration 类型还提供了其它编程语言的枚举不支持的其它功能。

    • 可以用一个重载的 Value 方法给枚举值关联特定的名称:

      
      
      xxxxxxxxxx
      object Color extends Enumeration { val Red = Value("Red") val Green = Value("Green") val Blue = Value("Blue") }
    • 可以通过枚举的 values 方法返回的Set 来遍历枚举的 Value

      
      
      xxxxxxxxxx
      for (c <- Color.values) print(c + " ") // 打印结果: Red Green Blue
    • 枚举的值从 0 开始编号,可以通过枚举Valueid 方法获取编号:

      
      
      xxxxxxxxxx
      println(Color.Red.id) // 打印结果: 0
    • 可以从一个非负整数编号获取对应的枚举值:

      
      
      xxxxxxxxxx
      val c = Color(1) // c 为: Color.Green

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文