Scala之:其它类

736 阅读8分钟

本章之前的内容,已经介绍了 Scala 的伴生对象和伴生类,还有用于设计项目整体架构的抽象类,特质等。

Scala 中有一些为了满足特定需要而设计的特殊类,比如内部类 (它还涉及到了 "路径依赖类型" 的概念) ,枚举类密封类,以及后续用于进行隐式转换的隐式类(使用 implicit 修饰);用于模式匹配的模板类(使用 case 修饰)...... 在该章中,主要介绍前三个类型,后两个类涉及到隐式转换,模式匹配的内容,我们在之后的相关章节中再去介绍它们。

内部类

Java 的内部类

Java 类内部存在五种内容:属性,方法,内部类,构造器,代码块

Java 类内部还可以嵌套另一种完整的类结构:这个类被称之为 内部类 inner class。嵌套该类的类称为外部类。

Java 内部类的一大特点就是可以直接访问外部类的私有属性,它体现了类和类之间的 has-a 关系,而非 is-a 关系(它靠继承来实现)。

Java 的内部类是和外部类的某个实例绑定的,这对于 Scala 来说也一样

Java 内部类的种类

Java 内部类可以分为四种:

  • 成员内部类,没有使用 static 关键字修饰,内部类的实例和外部类的某个实例绑定,不能访问外部类的静态内容。
  • 静态内部类,使用 static 关键字修饰,内部类的实例和外部类的实例无关,可以访问外部类的静态内容。
  • 局部内部类,在方法内定义使用,外部不可访问。
  • 匿名内部类,在 Java 8 之前,常用于一次性实现接口或抽象类,Lambda 表达式的诞生优化了 SAM 的声明方式。

比如列出一个 Java 代码块来表示外部类 Outer,成员内部类 Inner 和静态内部类 staticInner

public class Outer {

    //成员内部类,没有用static修饰。
    //成员内部类不能定义静态方法。
    class Inner{
        public void innerTest(Inner inner){
            System.out.println("print inner Test.");
        }
    }
    
    //静态内部类,使用了static修饰。
    //静态内部类可以定义静态方法。
    //静态内部类不能访问外部类的非静态成员。
    static class staticInner{
        public static void staticInnerTest(staticInner staticInner){
            System.out.println("print static inner Test.");
        }
    }

}

这三种类的构造方法分别如下:

//构建一个普通类
Outer outer = new Outer();

//构建一个普通类的一个内部类
//需要先持有一个Other的实例引用才能使用它,因为成员内部类和外部类的某个实例绑定。
Outer.Inner inner = outer.new Inner();

//构建一个普通类的一个静态内部类,它不需要先持有一个Outer的实例引用,只需要通过类名.静态内部类构造器就可以构造出来。
Outer.staticInner staticInner =new Outer.staticInner();

Outer.staticInner.staticInnerTest(staticInner);

在编译后,上述三个类最终会被拆分成三个 .class 文件:Outer$Inner.class, Outer$StaticInner.classOuter.class

创建内部类的意义

  1. 内部类可以自由访问外部类的 private 成员。
  2. 可以起到延时加载的作用:比如只有在使用到内部的内容时,才会新创建一个内部类对象。
  3. 描述类和类之间的 has-a 关系。

在 Scala 创建内部类

Scala 中不存在 static 关键字,因此 Scala 中的静态内部类声明在伴生对象内。

class Outer{
  //Scala 的成员内部类保存在伴生类中
  class InstanceInnerClass{}
}

object Outer{
   //Scala 的静态内部类保存在伴生对象中
  class StaticInnerClass{}
}

创建成员内部类

Scala 强化了成员内部类和某个外部类实例的绑定关系。它不支持这样的写法:

new Outer().new InstanceInnerClass()

在创建内部类的实例时,其外部类的引用必须有明确的变量名,而不能通过 new 关键字创建或者其它函数返回匿名外部类实例。

val outer  : Outer = new Outer
val instanceInner: outer.InstanceInnerClass = new outer.InstanceInnerClass

注意,instanceInner 的类型是 outer.InstanceInnerClass。它更加明确地表示:**该内部类实例不属于 Outer ,而是属于 Outer 的一个明确的,名为 outer 的实例 **。下面是 Java 的成员内部类的表示,它的类型属于Outer.Inner,而不是outer.Inner

Outer.Inner inner2 = new Outer().new Inner();

下面给出以下 Scala 代码块:

val outerClazz1 = new OuterClazz
val innerClazz1 : outerClazz1.InnerClazz= new outerClazz1.InnerClazz

val outerClazz2 = new OuterClazz
val innerClazz2 : outerClazz2.InnerClazz= new outerClazz2.InnerClazz

if(innerClazz1.getClass == innerClazz2.getClass){
    println("outClazz1.InnerClass == outer2Clazz2.InnerClass")
}

innerClazz1 , innerClazz2 的类型在语义上并不相同。因为它们类型的 "前缀" 一个是 outerClazz1 , 另一个是 outerClazz2。实际上,这样的类型属于:路径依赖类型 (path-dependent-type)

然而,通过执行 getClass 代码进行比较可以发现:innerClazz1.getClass 的类型和 innerClazz2.getClass 本质上是相同的:它们都属于 OuterClazz$InnerClazz 类型。

我们在后续会介绍类型投影来消除 "后缀相同,但前缀不同" 的路径依赖类型之间的区别。

创建静态内部类

Scala 中,创建静态内部类的过程和 Java 没有太大区别:

val staticInnerClass : Outer.StaticInnerClass = new Outer.StaticInnerClass

下面是 Java 的声明方式:

Outer.staticInner staticInner = new Outer.staticInner();

两种内部类的权限控制

其中,成员内部类和静态内部类是允许使用 private 关键字修饰为私有的,不过一旦这样做,我们将无法在外部类以外的地方创建实例。下面企图将私有成员内部类作为返回值传递出去的函数将无法通过编译:

class Outers {

  private class Inners{
    val value : Int = 1000
  }

  // 它企图让私有内部类从原有的定义域中 “逃逸” 出去。
  def returnInners = new Inners
}

在编译这段代码时,编译器会报错: private class XXX escapes its defining scope as part of type XXX 。对于静态内部类同理。

内部类的应用

成员内部类访问外部类实例的成员

Scala 的访问规则和 Java 保持一致:内部类可以随意读取外部类实例的所有成员(包括私有成员),但反过来不成立。

class Outer {
  
  def this(aValue1 : Int, aValue2  :Int){
    this()
    this.OuterValue1 = aValue1
    this.OuterValue2 = aValue2
  }

  var OuterValue1: Int =  _
  private var OuterValue2 : Int = _

  class InstanceInnerClass {

    //内部类可以直接访问外部类的成员
    def readValue1 : Int = OuterValue1
    def readValue2 : Int = OuterValue2
  }
}

在 Scala 中还常常使用 this 来表示与它绑定的外部类实例:

class Outer {
  //...
  class InstanceInnerClass {
    //内部类可以直接访问外部类的成员
    //Outer.this 表示了该内部类访问的是与其绑定的外部类实例的成员属性值。
    def readValue1 : Int = Outer.this.OuterValue1
    def readValue2 : Int = Outer.this.OuterValue2
  }
}

也可以起为这个 “被绑定的外部类实例” 起一个别名,来替换掉 Outer.this 。写法如下:

class Outer {
 //起别名的声明必须放在外部类的首行。 
  outerInstance=>
  class InstanceInnerClass {
    //内部类可以直接访问外部类的成员
    def readValue1 : Int = outerInst.OuterValue1
    def readValue2 : Int = outerInst.OuterValue2
  }
    
  //...
}

这里的 outerInstance 代指的是每一个被绑定的 Outer 类型实例。注意,需要将别名的声明放在外部类结构体内的第一行位置上

类型投影

设有一个外部类 Outer 的声明如下:

class Outer {

  class InstanceInnerClass {
    
    val innerValue: Int = 100

    def readInnerInst(instanceInnerClass: InstanceInnerClass): Int = instanceInnerClass.innerValue
  }
  
}

内部类定义了一个方法 readInnerInst,参数类型设定为是内部类,该方法的功能是读取内部类的 innerValue 值,随后在主函数中尝试调用该方法。为了说明问题,这里创建出绑定两个不同外部类实例的内部类实例:

val outer1 = new Outer
val outer2 = new Outer

val inner = new outer1.InstanceInnerClass
val inner2 = new outer2.InstanceInnerClass

在主程序中尝试着调用 inner 实例的readInnerInst方法,并分别将innerinner2 作为参数传入到方法内:

//available.
inner.readInnerInst(inner)
//Oop!
inner.readInnerInst(inner2)

第一行代码,当参数为 inner 时,程序可以正常运行。因为由于调用该方法的 inner 是属于 outer1 的内部类实例,因此该方法的参数也被绑定为了outer1.InstanceInnerClass,而传入的 inner 正是此类型。对于第二行代码,编译器则报出了错误:因为 inner2 属于 outer2.InstanceInnerClass ,而非 outer1.InstanceInnerClass 。Scala 在语义层面做了比 Java 严格的限定:它们是 “前缀” 不同的路径依赖类型。

类型投影机制保证了内部类的参数类型不再受到 “绑定的外部类实例是否一致” (或者称 “路径是否一致” )的限制。我们对参数 instanceInnerClass 的类型做以下的改动:

//外部类#内部类
def readInnerInst(instanceInnerClass: Outer#InstanceInnerClass): Int = instanceInnerClass.innerValue

这种Outer#Inner 形式的声明意味着这个内部类参数 instanceInnerClass 与它所属的外部类实例无关(或者说允许是 Outer 类型的任意一个实例),因此消除了编译器提示的编译错误。

内部类实战演示

为什么 Scala 这么执着于将内部类和外部类的实例绑定在一起?在这里用一个实例去说明:

假定现在有两个平台 Bili Bili 和 YouTube ,每个平台下的用户不互通,并且分开统计各自平台的用户人数。仅限于一个平台内的用户可以收发信息,但是也可以选择转发(forward) 的方式向其它平台的用户推送消息。下面给出 Platform 类及其内部类 User 的代码实现:

package Inner

object Platform {
  //统计 "全网" (所有平台)的用户人数之和。
  var totalUsers: Int = 0
}

//主构造器中需要传入平台的名字,比如 “斗鱼”,“虎牙”...
class Platform(val platName: String) {

  //为了避免混淆,这里用别名 plat 来代指每个不同的 Platform 实例。
  plat =>
  class User(private var name: String) {

    //有新用户注册时,该 plat 的用户数 +1。
    plat.users += 1

    //总用户数加 1。
    Platform.totalUsers += 1

    //获取用户名。
    def getName: String = name

    //向同平台的用户发送消息。
    def sendMsg(user: User, msg: String): Unit = {
      println(s"send to ${user.getName}:$msg")
    }

    //向不同平台的用户转发消息。这时需要用到类型投影来解决问题。
    def forwardMsg(user: Platform#User,msg : String) : Unit ={
      println(s"forward to ${user.getName}:$msg")
    }
  }
  //用于统计每个平台各自的用户流量。  
  var users: Int = 0
}

我们使用内部类表达出了 UserPlatform 之间的 has-a 关系,由此来区分是 “哪个平台下的哪个用户” 。在主函数中分别创立两个平台,而后在每个平台下再创建用户,试着去调用消息发送,消息转发的功能:

package Inner

object Net {

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

    //两大平台。
    val CNPlatform  = new Platform("BiliBili")
    val USPlatform = new Platform("YouTuBe")

    //这些用户是属于BiliBili旗下的用户。
    val CNUser1 = new CNPlatform.User("Xiao Mei")
    val CNUser2 = new CNPlatform.User("Lei Jun")


    //这些是海外的YouTuBe用户。
    val USUser = new USPlatform.User("Tim")
    val USUser1 = new USPlatform.User("Tim")

    println(s"BiliBili在线人数:${CNPlatform.users}")

    println(s"YouTuBe online users : ${USPlatform.users}")

    println(s"Platform.totalUsers = ${Platform.totalUsers}")

    //同一个平台的用户发送消息。
    CNUser1.sendMsg(CNUser2,"你好!")

    //不同平台的用户转发消息。
    USUser1.forwardMsg(CNUser1,"Hello!")
  }
}

小结

因为内部类和外部类实例绑定,我们才得以用更形象的方式表达 “不同平台下的不同用户” :有些用户属于BiliBili.User,而另外一部分用户属于YouTuBe.User。Scala 对内部类参数做了更严格的语义上的约束,但是也允许使用类型投影提供 “不同平台间的用户也可以相互访问” 的需求。

枚举类

一句话概括枚举的功能:贴标签。

type 关键字

Scala 允许为数据类型起别名,以方便我们用更容易理解的词汇描述一个数据类型,类似于 C 语言的 typedef 。比如将 Int 数据类型起别名:

//将Int数据类型更名为了Age。
type Age = Int

val studentAge : Age = 20

println(s"student's age = ${studentAge}")

为什么要这么做呢?比如我们现在的业务需要处理一些数据段:名字,专业,性别。

val major : String = "CS"
val name : String  = "Wang Fang"
val gender : String = "Male"

这些数据全都属于 String 类型,而在这里就可以使用 type 关键字为它们起别名,来让程序的可读性更强。

type Major = String
type Name = String
type Gender = String

val major: Major = "CS"
val name: Name = "Wang Fang"
val gender: Gender = "Male"

我们用type对 String 类型重命名为具有语义的 “标签” ,以便于更清晰地区分开各个变量的含义。

在 Scala 中声明枚举类

如果现在要用一组标签来表示 “男士” ,“女士” ,有些同学可能第一反应是用 0 , 1 两个 Int 类型数据。这种方案可以达到目的,但代码的可读性很差:因为对于其他人来说,在没有翻阅源代码的情况下很难直观地推测出 01 的具体含义。

//bad example
val gender : Int = 0
if(gender==0) println("It's a girl") else println("It's a boy")

另外,假使有人故意输入了一个01以外的数值,那么这段代码从逻辑上就出现了问题。然而,从编译器的角度来看,gender 仅仅是一个普通的 Int 类型,它理应当接收任何一个整数数值。

我们希望编译器能够帮助检查每个标签的类型,而不是通过大量的 if 或者是 match 语句人为地负责处理匹配工作。因此,这种 “贴标签” 的活,用枚举类来处理再合适不过了。在 Scala 中如何用枚举类呢?

Scala 的枚举类型需要继承 Enumeration 实现。Enumeration 有一个成员 ValueValue 内部有两个属性: Int 型的 id,以及 String 类型的 name 。我们使用这两个属性来区分同一个类别下的不同标签(Value)。

Value 提供了 apply 构造方法,因此我们可以通过 Value(id,name) 快速设定一个标签。

创建一个 Genders 类,并在该类下指定两个标签:FEMALE, MALE 来表示 "女士" , “男士” 。我们用一个成员变量表示一个标签,而不是用一个属性通过不同的赋值来表示多个标签,然后再用 if 机制去检测。

object Genders extends Enumeration {
    val MALE : Value = Value(0,"male")
    val FEMALE : Value = Value(1,"female")
}

这样,我们就可以通过 Genders.MALE 直观的表达 “男士” 的概念,当输出它的值时,就可以在屏幕前输出名字:male。(取决于你如何构造的 Value )

有时我们不希望它们的数据类型都用 “含糊” 地使用 Value 来表示,这时 type 关键字就派上用场了:

object Genders extends Enumeration {
    type Gender = Value
    //通过别名让数据类型的语义更加明确:
    val MALE : Gender = Value(0,"male")
    val FEMALE : Gender = Value(1,"female")
    
    //Enumeration提供values来保存标签。使用mkString方法对每个标签的name以符号作连接符,拼接成一个完整的字符串返回。
    override def toString(): String = this.values.mkString(",") 
}

type 所起的别名只适用于它所处的作用域内。如果要使用其它类中使用的别名,则需要使用 import 导入进来

//外部程序想要使用Genders类中的别名。但是它本质上仍然是Enumeration内的Value类。
import Enu.Genders.Gender

密封类

Scala 密封类使用 sealed 关键字来修饰,顾名思义,密封类就是将某个类封装在一个包内,允许在包外部进行调用(这一点其实取决于你是否将这个类声明为公开的,而与它是不是密封类无关),但是只允许在该包内声明对该类的拓展

//-------------package1----------------//
//它被 sealed 关键字修饰,因此它是密封类。
sealed class sealedObj

//在同一个包下,可以声明对该密封类的子类。
class subObj extends sealedObj 

//-------------package2----------------//
//在另外的包,不允许声明继承于 sealedObj 的子类,即便是引入了 package1.sealedObj 。
class subObj extends package1.sealedObj