29-Scala-面向对象

发布时间 2024-01-11 09:26:34作者: tree6x7

1. 面向对象编程基础

1.1 定义类

基本语法:

[修饰符] class 类名 {
  // code
}
  • Scala 语法中,类并不声明为 public,所有这些类都具有公有可见性(即默认就是 public)
  • 一个 Scala 源文件可以包含多个类

1.2 成员变量

属性的定义语法同变量:

[访问修饰符] var 属性名称 [:类型] = 属性值
  • 属性的定义类型可以为任意类型,包含值类型或引用类型。
  • Scala 中声明一个属性,必须显示的初始化(根据初始化数据的类型自动推断,属性类型可以省)。
  • 如果赋值为 null,则一定要加类型,如果不加类型,那么该属性的类型就是 Null 类型。
  • 如果在定义属性时,暂时不赋值,也可以使用符号 _ 让系统分配默认值(这点和 Java 数据类型默认值一致)。

Scala 禁止在同一个类中使用相同的名称命名字段和方法。一般来说,Scala 只有两个命名空间用于定义,不同于 Java 的四个。Java 的四个命名空间分别是:字段、方法、类型和包,而 Scala 的两个命名空间分别是 ① 值(字段、方法、包和单例对象)② 类型(类和特质名)。

1.3 成员方法

Scala 中的方法其实就是函数,声明规则请参考函数式编程中的函数声明。

但是定义的地方不同,决定它是“函数”还是“方法”。作为某个对象的成员,这样的函数被称为方法。

没有参数列表,连空参数列表都没有,这样的无参方法(parameterless method)在 Scala 中很常见。与此对应,那些用空的圆括号定义的方法,比如 def height(): Int,被称作空圆括号方法(empty-paren method)。

Scala 对于混用无参方法和空括号方法的处理非常灵活。具体来说,可以用空括号方法重写无参方法,也可以反过来。还可以在调用某个不需要入参的方法时省去空括号。

从原理上讲,可以对 Scala 所有无参函数调用都去掉空括号。不过,我们仍建议在被调用的方法不仅只代表接收该调用的对象的某个属性时加上空括号。

Scala 鼓励我们将那些不接收参数也没有副作用的方法定义为无参方法(即省去空括号)。同时,对于有副作用的方法,不应该省去空括号,因为省掉括号以后这个方法调用看上去就像是字段选择,因此你的使用方可能会对其副作用感到意外。

同理,每当你调用某个有副作用的函数,请确保在写下调用代码时加上空括号。换一个角度来思考这个问题,如果你调用的这个函数执行了某个操作,就加上括号,而如果它仅仅是访问某个属性,则可以省去括号。

1.4 创建对象

  1. 如果我们不希望改变对象的引用(即内存地址),应该声明为 val 性质的,否则声明为 var。Scala 设计者推荐使用 val,因为一般来说,在程序中,我们只是改变对象属性的值,而不是改变对象的引用。
  2. Scala 在声明对象变量时,可以根据创建对象的类型自动推断,所以类型声明可以省略,但当声名类型和后面 new 的对象类型有继承关系即多态时,就必须写了。

1.5 构造器

构造器(constructor)又叫构造方法,是类的一种特殊的方法,它的主要作用是完成对新对象的初始化。

和 Java 一样,Scala 构造对象也需要调用构造方法,并且可以有任意多个构造方法(即 Scala 中构造器也支持重载)。 Scala 类的构造器包括「主构造器」和「辅助构造器」。

基本语法:

class 类名(形参列表) { // <= 主构造器,是的没看错,就他喵的在类声明上
  
  def this(形参列表) { // <= 辅助构造器 
    // code  
  }
  
  def this(形参列表) { // <= 辅助构造器可以有多个
    // code
  }
  
  // other code
}

示例:

object constructorTest {

  def main(args: Array[String]): Unit = {
    val p1: Person = new Person("ljq", 13)
    println(s"=> $p1")
    val p2: Person = new Person("ljq")
    println(s"=> $p2")
    val p3: Person = new Person(25)
    println(s"=> $p3")
  }

}


// 私有化主构造器:class Person private (inName: String, inAge: Int)
class Person(inName: String, inAge: Int) {
  var name: String = inName
  var age: Int = inAge

  age += 10

  println(s"~~~ $name ~~~")

  println(s"--- $age ---")

  def this(name: String) {
    // 辅助构造器必须在第一行显式调用主构造器
    this(name, 20)
    println("辅助构造器(name)")
  }

  def this(age: Int) {
    this("tree", age)
    println("辅助构造器(age)")
  }

  override def toString: String = s"name=$name, age=$age"
}

// ===== CONSOLE =====
~~~ ljq ~~~
--- 23 ---
=> name=ljq, age=23
~~~ ljq ~~~
--- 30 ---
辅助构造器(name)
=> name=ljq, age=30
~~~ tree ~~~
--- 35 ---
辅助构造器(age)
=> name=tree, age=35

注意点:

(1)Scala 构造器作用是完成对新对象的初始化,构造器没有返回值。

(2)主构造器的声明直接放置于类名之后,且主构造器会执行类定义中的所有语句。这里可以体会到 Scala 的函数式编程和面向对象编程融合在一起,即:构造器也是方法(函数),传递参数和使用方法和前面的函数部分内容没有区别。

(3)如果主构造器无参数,小括号 () 可省略,构建对象时调用的构造方法的小括号 () 也可以省略。

(4)辅助构造器名称为 this(这个和 Java 是不一样的),多个辅助构造器通过不同参数列表进行区分,在底层就是辅助构造器的重载。

(5)如果想让主构造器变成私有的,可以在类声明处,即类名和主构造器入参列表之前加上 private,这样用户就只能通过辅助构造器来构造对象了。

(6)辅助构造器的声明不能和主构造器的声明一致,会发生错误(即构造器名称重复)。

1.6 属性高级

(1)构造器参数

  1. Scala 类的主构造器的形参未用任何修饰符修饰,那么这个参数是局部变量;
  2. 如果参数使用 val 关键字声明,那么 Scala 会将参数作为类的私有的只读属性使用;
  3. 如果参数使用 var 关键字声明,那么那么 Scala 会将参数作为类的成员属性使用,并会提供属性对应的 xxx(类似 getter)/ xxx_$eq(类似 setter)方法,即这时的成员属性是私有的,但是可读写。

(2)Bean 属性

JavaBeans 规范定义了 Java 的属性是像 getXxx/setXxx 方法。许多 Java 工具(框架)都依赖这个命名习惯。为了 Java 的互操作性。将 Scala 字段加 @BeanProperty 时,这样会自动生成规范的 setXxx/getXxx 方法。这时可以使用 obj.setXxx() 和 obj.getXxx() 来调用属性。

给某个属性加入 @BeanPropetry 注解后,会生成 getXxx/setXxx 方法,并且对原来底层自动生成类似 xxx()、xxx_$eq() 方法没有冲突,二者可以共存。

1.7 补充

(1)类的继承关系

由于每个类都继承自 Any,Scala 程序中的每个对象都可以用 、!= 或 equals 来进行比较,用 ## 或 hashCode 做哈希,以及用 toString 做格式化。相等和不等方法( 和 !=)在 Any 类中声明为 final,所以它们不能被子类重写。

== 方法从本质上讲等同于 equals,而 != 一定是 equals 的反义。这样一来,子类可以通过重写 equals 方法来定制 == 或 != 的含义。

根类 Any 有两个子类:AnyVal 和 AnyRef。

AnyVal 是 Scala 中所有值类(value class)的父类。虽然你可以定义自己的值类,但 Scala 提供了九个内建的值类:Byte、Short、Char、Int、 Long、Float、Double、Boolean 和 Unit。

前八个对应 Java 的基本类型,它们的值在运行时是用 Java 的基本类型的值来表示的。这些类的实例在 Scala 中统统写作字面量。例如,42 是 Int 的实例,'x' 是 Char 的实例,而 false 是 Boolean 的实例。不能用 new 来创建这些类的实例。这 一点是通过将值类定义为抽象的同时,由 final 的这个“小技巧”来完成的。

另外的那个值类 Unit 粗略地对应到 Java 的 void 类型,它用来作为那些不返回有趣的结果的方法的结果类型。Unit 有且只有一个实例值,写作 ()。

值类以方法的形式支持通常的算术和布尔操作符。方法 min、max、until、to 和 abs 都定义在 scala.runtime.RichInt 类中,并且存在从 Int 类到 RichInt 类的隐式转换。只要对 Int 调用的方法没有在 Int 类中定义,而 RichInt 类中定义了 这样的方法,隐式转换就会被自动应用。其他值类也有类似的“助推类”和隐式转换。

在 Java 平台上 AnyRef 事实上只是 java.lang.Object 的一个别名。因此,Java 编写的类和 Scala 编写的类都继承自 AnyRef。因此,我们可以这样来看待 java.lang.Object:它是 AnyRef 在 Java 平台的实现。

(2)基本类型的实现机制

Scala 存放整数的方式跟 Java 一样,都是 32 位的词(word)。这对于 JVM 上的效率以及跟 Java 类库的互操作都很重要。标准操作比如加法和乘法被实现为基本操作。不过,Scala 在任何需要将整数当作(Java)对象时,都会启用“备选” 的 java.lang.Integer 类。例如,当我们对整数调用 toString 或将整数赋值给一个类型为 Any 的变量时,都会发生这种情况。类型为 Int 的整数在必要时都会透明地被转换成类型为 java.lang.Integer 的“装箱整数”。

Scala 的相等性操作 == 被设计为对于类型的实际呈现是透明的。对 于值类型而言,它表示的是自然(数值或布尔值)相等性。而对除 Java 装箱数值类型之外的引用类型,== 被处理成从 Object 继承的 equals 方法的别名。这个方法原本定义用于引用相等性,但很多子类都重写了这个方法来实现它们对于相等性更自然的理解和表示。这也意味着在 Scala 中不会陷入 Java 那个跟字符串对比相关的陷阱。

对需要引用相等性的情况,AnyRef 类定义了一个额外的 eq 方法,该方法不能被重写,实现为引用相等性(即它的行为跟 Java 中 == 对于引用类型的行为是一致的)。还有一个 eq 的反义方法 ne。

(3)底类型

在类继承关系的底部,你会看到两个类:scala.Null 和 scala.Nothing。它们是 Scala 面向对象的类型系统用于统一处理某些“极端情况”(corner case)的特殊类型。

Null 类是 null 引用的类型,它是每个引用类(也就是每个继承自 AnyRef 的类)的子类。Null 并不兼容于值类型,比如你并不能将 null 赋值给一个整数变量。

Nothing 位于 Scala 类继承关系的底部,它是每个其他类型的子类型。不过,并不存在这个类型的任何值。为什么需要这样一个没有值的类型呢?在说《异常》时讨论过,Nothing 的用途之一是给出非正常终止的信号。

举例来说,Scala 标准类库的 Predef 对象有一个 error 方法,其定义如下:

def error(msg: String): Nothing = throw new RuntimeException(msg)

error 的返回类型是 Nothing,这告诉使用方该方法并不会正常返回(它会抛出异常)。由于 Nothing 是每个其他类型的子类型,可以以非常灵活的方式来使用 error 这样的方法。

def divide(x: Int, y: Int): Int = if (y!=0) x / y else sys.error("can't divide by zero.")

这里 x / y 条件判断的 “then” 分支的类型为 Int,而 else 分支(即调用 error 的部分)类型为 Nothing。由于 Nothing 是 Int 的子类型,整个条件判断表达式的类型就是 Int,正如方法声明要求的那样。

(4)避免类型单一化

要想尽可能发挥 Scala 类继承关系的好处,请试着对每个领域概念定义一个新的类,哪怕复用相同的类做不同的用途是可行的。即便这样的一个类是所谓的细微类型(tiny type),既没有方法也没有字段,定义这样的一个额外的类有助于编译器在更多的地方帮到你。

2. Scala 包

回顾 Java 包的特点:包名和源码所在的系统文件目录结构要一致,并且编译后的字节码文件路径也和包名保持一致。

2.1 基本使用

  • 基本语法:package 包名
  • Scala 包的三大作用(和 Java 一样)
    • 区分相同名字的类
    • 当类很多时,可以很好的管理类
    • 控制访问范围
  • 命名规则
    • 只能包含数字、字母、下划线、小圆点 .
    • 不能用数字开头,也不要使用关键字

Scala 有两种包的管理风格:

  1. 和 Java 的包管理风格相同,每个源文件一个包,包名用 . 进行分隔以表示包的层级关系,如 io.tree6x7.scala。与 Java 不同的是:Scala 中包名和源码所在的系统文件目录结构可以不一致,但是编译后的字节码文件路径和包名会保持一致(这个工作由编译器完成)。
  2. 通过嵌套的风格表示层级关系。该风格有以下特点:(1)一个源文件中可以声明多个 package(2)子包中的类可以直接访问父包中的内容,无需导包(3)在子包和父包的类重名时,默认采用就近原则,如果希望指定使用某个类,带上包名即可。
    package io {
    
      // 父包访问子包需要导包
      import io.tree6x7.Inner
    
      object Outer {
        val out: String = "out"
    
        def main(args: Array[String]): Unit = {
          println(Inner.in)
        }
      }
      
      package tree6x7 {
        object Inner {
          val in: String = "in"
    
          def main(args: Array[String]): Unit = {
            // 子包访问父包无需导包
            println(Outer.out)
          }
        }
      }
    
    }
    
    package other {}
    

在 Scala 中,import 语句可以出现在任何地方,并不仅限于文件顶部,import 语句的作用一直延伸到包含该语句的块末尾。这种语法的好处是:在需要时再引入包,缩小 import 包的作用范围,提高效率。

Java 中如果想要导入包中所有的类,可以使用通配符 *,Scala 中采用下 _

导包说明:

  1. 和 Java 一样,可以在顶部使用 import 导入,在这个文件中的所有类都可以使用。
  2. 局部导入:什么时候使用,什么时候导入。在其作用范围内都可以使用
  3. 通配符导入:import java.util._
  4. 给类起名:import java.util.{ArrayList => JL}
  5. 导入相同包的多个类:import java.util.{HashSet, ArrayList}
  6. 屏蔽类:import java.util.{ArrayList => _,_}
  7. 导入包的绝对路径:new _root_.java.util.HashMap

Scala 默认自动引入的包:

  • import java.lang._
  • import scala._
  • import scala.Predef._

2.2 包对象

包可以包含类、对象和特质 trait,但不能包含函数/方法或变量的定义。这是 Java 虚拟机的局限。为了弥补这一点不足,Scala 提供了「包对象」的概念来解决这个问题。

在 Scala 中可以为每个包定义一个同名的包对象,定义在包对象中的成员,作为其对应包下所有 class 和 object 的共享变量,可以被直接访问。

package object com {
  val shareValue = "share"
  def shareMethod() = {}
}

说明:

(1)若使用 Java 的包管理风格,则包对象一般定义在其对应包下的 package.scala 文件中,包对象名与包名保持一致。

(2)如采用嵌套方式管理包,则包对象可与包定义在同一文件中,但是要保证包对象与包声明在同一作用域中。

package com {
  object Outer {
    val out: String = "out"
    def main(args: Array[String]): Unit = {
      println(name)
    }
  }
}

package object com {
  val name: String = "com"
}

2.3 包的可见性

在 Java 中,访问权限分为 public、private、protected 和默认。在 Scala 中,你可以通过类似的修饰符达到同样的效果。但是使用上有区别。

  1. 当属性访问权限为默认时,从底层看属性是 private 的,但是因为提供了 xxx_$eq() /xxx() 方法,因此从使用效果看是任何地方都可以访问;
  2. 当方法访问权限为默认时,默认为 public 访问权限;
  3. private 为私有权限,只在类的内部和伴生对象中可用;
  4. protected 为受保护权限,Scala 中受保护权限比 Java 中更严格,只能子类访问, 同包都无法访问(编译器层面做的控制,直接编译不通过);
  5. 在 Scala 中没有 public 关键字,即不能用 public 显式的修饰属性和方法;
  6. 包访问权限(表示属性有了限制,同时包也有了限制),这点和 Java 不一样, 体现出 Scala 包使用的灵活性。
package io.tree6x7.scala
class Person {
  // 增加包访问权限后:private同时起作用。不仅同类可以使用,同时 io.tree6x7.scala 中包下其他类也可以使用 
  private[scala] val pname="hello"
}
// 1. 也可以将可见度延展到上层包;2.private 也可以变化,比如 protected[atguigu]
// private[tree6x7] val description="world"

3. 面向对象三大特征

3.1 封装

Scala 中为了简化代码的开发,当声明属性时,本身就自动提供了对应 setter/getter 方法。

类中定义属性的类型分类(因为反编译后,属性都是 private 的修饰的,这些修饰符的体现也就是在各自的 get/set 方法上了):

  1. 啥修饰符都不加,就会生成 public 的 get/set 方法
  2. 加 protected 修饰符,也会生成 public 的 get/set 方法,但是只能子类访问(非子类访问直接编译都不通过)
  3. 加 private 修饰符,就会生成 private 的 get/set 方法,只能本类/伴生对象中访问。

因此我们如果只是对一个属性进行简单的 set/get ,只要声明一下该属性(属性使用默认访问修饰符)不用写专门的 get/set,默认会创建,访问时直接 对象.变量,其实底层仍然是调用的方法。这样也是为了保持访问一致性。

3.2 继承

class 子类名 extends 父类名 { 类体 }

(1)方法重写

Scala 明确规定,重写一个非抽象方法需要用 override 修饰符,调用超类的方法使用 super 关键字。

继承的意思是超类的所有成员也是子类的成员, 但是有两个例外:

  1. 超类的私有成员并不会被子类继承
  2. 如果子类里已经实现了相同名称和参数的成员,那么该成员不会被继承。

对后面这种情况我们也说子类的成员重写(override)了超类的成员。如果子类的成员是具体的而超类的成员是抽象的,我们也说这个具体的成员实现(implement)了那个抽象的成员。

(2)类型检查和转换

  • isInstanceOf[...] 测试某个对象是否属于某个给定的类
  • asInstanceOf[...] 将引用转换为子类的引用
  • classOf[...] 获取对象的类名
println(classOf[String])

val s = "ljq"
println(s.isInstanceOf[String])

var p = new Person
val e = new Emp
p = e
p.name = "xxx"
println (e.name)
p.asInstanceOf[Emp].sayHi()

(3)超类的构造

Scala 类可以有一个主构器和任意数量的辅助构造器,而每个辅助构造器都必须先调用主构造器(也可以是间接调用),这点前面已经说过了。

在 Java 中,创建子类对象时,子类的构造器总是去调用一个父类的构造器(显式或隐式调用)。在 Scala 里,只有主构造器可以调用父类的构造器。辅助构造器不能直接调用父类的构造器。在 Scala 的构造器中,你不能调用 super(params)

class Person100(name: String) {}

class Emp100(name: String, workId: String) extends Person100(name) {

  def this() {
    // super("abc") 首先,没有这种语法;其次,不能在辅助构造器中调用父类的构造器
    this("ljq", "0049003785")
  }
}

(4)覆写字段

在 Scala 中,子类改写父类的字段,我们称为覆写/重写字段。覆写字段需使用 override 修饰。

class Person200() {
  val age: Int = 14
}

class Emp200 extends Person200 {
  override val age: Int = 25
}

object Exercise1 {
  def main(args: Array[String]): Unit = {
    val p1: Person100 = new Emp100
    val p2: Emp100 = new Emp100
    // p1.age = 25, p2.age = 25
    println(s"p1.age = ${p1.age}, p2.age = ${p2.age}")
  }
}
  • def 只能重写另一个 def(即:方法只能重写另一个方法)
  • val 只能重写另一个 val 属性或重写不带参数的 def
  • var 只能重写另一个抽象的 var 属性
    • 一个属性没有初始化,那么这个属性就是抽象属性;
    • 抽象属性在编译成字节码文件时,并不会声明成类的一个属性,但是会自动生成关于此属性的抽象 get/set 方法,所以类必须声明为抽象类;
    • 如果是覆写一个父类的抽象属性,那么 override 关键字可省略。

(4)抽象类

在 Scala 中,通过 abstract 关键字标记不能被实例化的类。方法不用标记 abstract,只要省掉方法体即可。抽象类可以拥有抽象字段,抽象字段/属性就是没有初始值的字段。

// 抽象类
abstract class Person() {
  // 抽象字段, 没有初始化
  var name: String
  // 抽象方法, 没有方法体
  def printName
}
  • 抽象类不能被实例。
  • 抽象类中可以有实现的方法,也就是说,抽象类不一定要包含 abstract 方法。
  • 一旦类包含了抽象方法或者抽象属性,则这个类必须声明为 abstract。
  • 抽象方法不能有主体,不允许使用 abstract 修饰。
  • 如果一个类继承了抽象类,则它必须实现抽象类的所有抽象方法和抽象属性,除非它自己也声明为 abstract 类。
  • 抽象方法和抽象属性不能使用 private、final 来修饰,因为这些关键字都是和重写/实现相违背的。
  • 子类重写抽象方法不需要写 override,写上也不会错。
  • 和 Java 一样,可以通过包含带有定义或重写的代码块的方式创建一个匿名的子类。

(5)final

确保某个成员不能被子类继承。在 Scala 中,跟 Java 一样,可以通过在成员前面加上 final 修饰符来实现。确保整个类没有子类,可以简单地将类声明为 final 的,做法是在类声明之前添加 final 修饰符。

(6)回顾 Scala 类继承结构

  1. 在 Scala 中,所有其他类都是 AnyRef 的子类,类似 Java 的 Object。
  2. AnyVal 和 AnyRef 都扩展自 Any 类,Any 类是根。
  3. Any 中定义了 isInstanceOf、asInstanceOf 以及哈希方法等。
  4. Null 类型的唯一实例就是 null 对象。可以将 null 赋值给任何引用,但不能赋值给值类型的变量。
  5. Nothing 类型没有实例。它对于泛型结构是有用处的,例如:空列表 Nil 的类型是 List[Nothing],它是 List[T] 的子类型,T 可以是任何类。

3.3 多态

变量/常量声明成父类类型,然后引用子类对象。

4. 伴生对象

review:Java 中静态方法并不是通过对象调用的,而是通过类对象调用的,所以静态操作并不是面向对象的。

Scala 中静态的概念 —— 「伴生对象」

Scala 语言是完全面向对象(万物皆对象)的语言,所以并没有静态的操作(即在 Scala 中没有静态的概念)。但是为了能够和 Java 语言交互(因为 Java 中有静态概念),就产生了一种特殊的对象来模拟类对象,我们称之为「类的伴生对象」。这个类的所有静态内容都可以放置在它的伴生对象中声明和调用。

  • 伴生对象的声明应该和伴生类的声明在同一个源码文件中(如果不在同一个文件中会运行错误),但是如果没有伴生类,也就没有所谓的伴生对象了,所以放在哪里就无所谓了。
  • 如果 class A 独立存在,那么 A 就是一个类, 如果 object A 独立存在,那么 A 就是一个'静态'性质的对象(即类对象), 在 object A 中声明的属性和方法可以通过 A.属性A.方法 来实现调用。
object ObjAndClsTest {

  def main(args: Array[String]): Unit = {
    // 1. 伴生对象中的属性和方法都可以通过「伴生对象名」直接调用访问
    println(TestObjAndCls.varStatic)
    println(TestObjAndCls.valStatic)
    TestObjAndCls.hi

    // 2. apply 方式创建对象实例
    val t1 = TestObjAndCls()
    println(t1.name)
  }
}

/**
 * 伴生类
 * -
 * 非静态的内容写到该类中
 * 编译后底层生成 TestObjAndCls.class
 * 伴生对象对应的类称之为'伴生类',伴生对象的名称应该和伴生类名一致。
 */
class TestObjAndCls {
  val name: String = "abc"
}

/**
 * 伴生对象
 * -
 * 静态的内容写到该对象(类)中
 * 编译后底层生成 TestObjAndCls$.class
 * 伴生对象采用 object 关键字声明,伴生对象中声明的全是'静态'内容,可以通过伴生对象名称直接调用。
 */
object TestObjAndCls {
  var varStatic: Int = 25
  val valStatic: Int = 14

  def hi(): Unit = {
    println(s"hi! varStatic = $varStatic, valStatic = $valStatic")
  }
}

通过反编译查看伴生类和伴生对象:

  • 从语法角度来讲,所谓的伴生对象其实就是类的静态方法和成员的集合;
  • 从技术角度来讲,Scala 还是没有生成静态的内容,只不过是将伴生对象生成了一个新的类,实现属性和方法的调用。伴生对象实现静态特性是依赖于 MODULE$ 对象实现的。

伴生对象 · apply 方法:在伴生对象中定义 apply 方法,可以实现 类名(参数) 方式创建对象实例。

  1. 通过伴生对象的 apply 方法,实现不使用 new 方法创建对象。
  2. 如果想让主构造器变成私有的,可以在 () 之前加上 private。
  3. apply 方法可以重载。
  4. Scala 中 obj(arg) 的语句实际是在调用该对象的 apply 方法,即 obj.apply(arg)。用以统一面向对象编程和函数式编程的风格。当使用 new 关键字构建对象时,调用的其实是类的构造方法,当直接使用类名构建对象时,调用的其实时伴生对象的 apply 方法。

5. 特质

从面向对象来看,接口并不属于面向对象的范畴,Scala 是纯面向对象的语言,所以在 Scala 中没有接口。

特质是 Scala 代码复用的基础单元。特质将方法和字段定义封装起来,然后通过将它们混入(mix in)类的方式来实现复用。它不同于类继承,类继承要求每个类都继承自一个(明确的)超类,而类可以同时混入任意数量的特质。

跟类一样,特质有一个默认的超类 AnyRef。

一旦特质被定义好,我们就可以用 extends 或 with 关键字将它混入到类中。可以用 extends 关键字来混入特质,在这种情况下隐式地继承了特质的超类。从特质继承的方法跟从超类继承的方法用起来一样。

特质同时也定义了一个类型。特质类型可被用做变/常量的类型。

如果想要将特质混入一个显式继承自某个超类的类,可以用 extends 来给出这个超类,并用 with 来混入特质。

5.1 基本语法

Scala 中的 trait 中即可以有抽象属性和方法,也可以有具体的属性和方法,一个类可以混入(mixin)多个特质。这种感觉类似于 Java 中的抽象类。

定义语法:

trait 特质名 {
  // trait body
}

// =============

trait PersonTrait {
  // 声明属性
  var name: String = "ljq"

  // 声明方法
  def eat(): Unit = {
    println("eat sth")
  }

  // 抽象属性
  var age: Int

  // 抽象方法
  def say(): Unit
}

(1)在特质定义中可以做任何在类定义中做的事,语法也完全相同,除了以下两种情况:

首先,特质不能有任何“类”参数(即那些传入类的主构造方法的参数)。

另一个类和特质的区别在于类中的 super 调用是静态绑定的,而在特质中 super 是动态绑定的。

如果在类中编写 super.toString 这样的代码,你会确切地知道实际调用的是哪一个实现。在你定义特质的时候并没有被定义。具体是哪个实现被调用,在每次该特质被混入到某个具体的类时,都会重新判定。这里 super 看上去有些奇特的行为是特质能实现可叠加修改(stackable modification)的关键,我们将在之后介绍这个概念。

(2)特质的一个主要用途是自动给类添加基于已有方法的新方法。也就是说,特质可以丰富一个瘦接口,让它成为富接口。

瘦接口和富接口代表了我们在面向对象设计中经常面临的取舍,在接口实现者和使用者之间的权衡。富接口有很多方法,对调用方而言十分方便。使用者可以选择完全匹配他们需求的功能的方法。而瘦接口的方法较少,因而实现起来更容易。不过瘦接口的使用方需要编写更多的代码。由于可供选择的方法较少,他们可能被迫选择一个不那么匹配需求的方法,然后编写额外的代码来使用它。

要用特质来丰富某个接口,只需定义一个拥有为数不多的抽象方法(接口中瘦的部分)和可能数量很多的具体方法(这些具体方法基于那些抽象方法编写)的特质。然后,你就可以将这个增值(enrichment)特质混入到某个类,在类中实现接口中瘦的部分,最终得到一个拥有完整富接口实现的类。

a. 类声明时混入

一个类具有某种特质(特征),就意味着这个类满足了这个特质(特征)的所有要素,所以在使用时,也采用了 extends 关键字,如果有多个特质或存在父类,那么需要采用 with 关键字连接。

类和特质的关系:使用「继承」的关系

  • 无父类:class 类名 extends 特质1 with 特质2 with 特质3 ...
  • 有父类:class 类名 extends 父类 with 特质1 with 特质2 with 特质3 ...

说明:

  1. 当一个类去继承特质时,第一个连接词是 extends,后面是 with。
  2. 如果一个类在同时继承特质和父类时,应当把父类写在 extends 后。

b. 动态混入

除了可以在类声明时继承特质以外,还可以在构建对象时混入特质,扩展目标类的功能。

此种方式也可以应用于对抽象类功能进行扩展。

动态混入是 Scala 特有的方式,可在不修改类声明/定义的情况下,扩展类的功能,非常的灵活,耦合性低。动态混入可以在不影响原有的继承关系的基础上,给指定的类扩展功能。

val p = new Person with Trait1 with Trait2 with ...

5.2 叠加特质

a. 线性化

特质是一种从多个像类一样的结构继承的方式,不过它们跟许多其他语言中的多重继承有着重大的区别。其中一个区别尤为重要:对 super 的解读。在多重继承中,super 调用的方法在调用发生的地方就已经确定了。而特质中的 super 调用的方法取决于类和混入该类的特质的线性化(linearization)。正是这个差别让上图介绍的可叠加修改变为可能。

当你用 new 实例化一个类的时候,Scala 会将类及它所有继承的类和特质都拿出来,将它们线性地排列在一起。然后,当你在某一个类中调用 super 时,被调用的方法是这个链条中向上最近的那一个。如果除了最后一个方法,所有的方法都调用了 super,那么最终的结果就是叠加在一起的行为。

在任何线性化中,类总是位于所有它的超类和混入的特质之前。因此,当你写下调用 super 的方法时,那个方法绝对是在修改超类和混入特质的行为,而不是反过来。

b. 对象的构建顺序

分析可叠加的(stackable)特质,主要就是分析「对象的构建顺序」和「执行方法的顺序」。

特质也是有构造器的,构造器中的内容由“字段的初始化”和一些其他语句构成:

(1)声明类的同时混入特质

  1. 调用当前类的超类构造器
  2. 第一个特质的父特质构造器
  3. 第一个特质构造器
  4. 第二个特质的父特质构造器(如果已经执行过,就不再执行)
  5. 第二个特质构造器
  6. 如果有第 3 个、第 4。个特质,则重复 4,5 的步骤
  7. 当前类构造器

(2)在构建对象时,动态混入特质

  1. 调用当前类的超类构造器
  2. 当前类构造器
  3. 第一个特质构造器的父特质构造器
  4. 第一个特质构造器
  5. 第二个特质构造器的父特质构造器(如果已经执行过,就不再执行)
  6. 第二个特质构造器
  7. 如果有第 3 个、第 4 个特质,则重复 5,6 的步骤

分析两种方式对构造顺序的影响:

  1. 实际是构建类对象,在混入特质时,该对象还没有创建。
  2. 实际是构造匿名子类,可以理解成在混入特质时,对象已经创建了。

示例说明:

object ClsTraitTest {

  def main(args: Array[String]): Unit = {
    // E...A...B...C...D...F...
    val f1 = new FF
    println()
    // E...K...A...B...C...D...
    val f2 = new KK with CC with DD
  }

}

trait AA {
  print("A...")
}

trait BB extends AA {
  print("B...")
}

trait CC extends BB {
  print("C...")
}

trait DD extends BB {
  print("D...")
}

class EE {
  print("E...")
}

class FF extends EE with CC with DD {
  print("F...")
}

class KK extends EE {
  print("K...")
}

b. 执行方法的顺序

由于一个类可以混入(mixin)多个 trait,且 trait 中可以有具体的属性和方法,若混入的特质中具有相同的方法(方法名、参数列表、返回值 均相同),必然会出现继承冲突问题。

冲突分为以下两种:

(1)一个类(Sub)混入的两个 trait(TraitA、TraitB)中具有相同的具体方法,且两个 trait 之间没有任何关系,解决这类冲突问题,直接在类(Sub)中重写冲突方法。

(2)一个类(Sub)混入的两个 trait(TraitA、TraitB)中具有相同的具体方法,且两个 trait 继承自相同的 trait(TraitC),即所谓的“钻石问题”,解决这类冲突问题,Scala 采用了特质叠加的策略。

所谓的特质叠加,就是将混入的多个 trait 中的冲突方法叠加起来。

object MultiTraitTest {
  def main(args: Array[String]): Unit = {
    /*
     * > 1. new MySQL with MultiTrait3 with MultiTrait4
     * 对象叠加特质的过程,可以理解为特质的入|栈|过程,即从左向右混入'声明顺序'
     * > 2. insert 执行顺序
     * 执行一个动态混入对象的方法,理解成特质的出|栈|顺序,即从右向左执行'声明顺序'
     * a. Scala在执行叠加对象的方法时,会首先从后面的特质(从右向左)开始执行
     * b. Scala中特质中如果调用super,并不是表示调用父特质的方法,而是向前面(左边)继续查找特质,如果找不到,才会去父特质查找。
     * c. 如果就是想要调用父特质的,不是左边那个特质的,可以指定 super[直接超类].xxx(...)。注意,泛型只能是该特质的'直接超类'型。
     */
    val mysql = new MySQL with MultiTrait3 with MultiTrait4
    mysql.insert(1101)
    // init MySQL connect...
    // MultiTrait1
    // MultiTrait2
    // MultiTrait3
    // MultiTrait4
    // [MultiTrait4] insert 1101
    // [MultiTrait3] insert 1101
    // [MultiTrait2] insert 1101
    println("============================")
    val mysql2 = new MySQL with MultiTrait4 with MultiTrait3
    mysql2.insert(13)
    // init MySQL connect...
    // MultiTrait1
    // MultiTrait2
    // MultiTrait4
    // MultiTrait3
    // [MultiTrait3] insert 13
    // [MultiTrait4] insert 13
    // [MultiTrait2] insert 13
  }
}

class MySQL {
  println("init MySQL connect...")
}

trait MultiTrait1 {
  println("MultiTrait1")

  def insert(id: Int)
}

trait MultiTrait2 extends MultiTrait1 {
  println("MultiTrait2")

  override def insert(id: Int): Unit = {
    println(s"[MultiTrait2] insert $id")
  }
}

trait MultiTrait3 extends MultiTrait2 {
  println("MultiTrait3")

  override def insert(id: Int): Unit = {
    println(s"[MultiTrait3] insert $id")
    super.insert(id)
  }
}

trait MultiTrait4 extends MultiTrait2 {
  println("MultiTrait4")

  override def insert(id: Int): Unit = {
    println(s"[MultiTrait4] insert $id")
    super.insert(id) // super[MultiTrait2].insert(id)
  }
}

上述案例中的 insert() 调用的是父 trait 中的方法吗?

当一个类混入多个特质的时候,Scala 会对所有的特质及其父特质按照一定的顺序进行排序,而此案例中的 super.insert() 调用的实际上是排好序后的下一个特质中的 insert() 方法。案例中的 super,不是表示其父特质对象,而是表示上述叠加顺序中的下一个特质。

5.3 特质中重写抽象方法

运行如下代码:

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

trait AnimalAct {
  def breath(): Unit
}

trait PersonTrait extends AnimalAct {
  var name: String = "ljq"

  abstract override def breath(): Unit = {
    println("PersonTrait#breath...")
    super.breath()
  }
}

// method breath in trait AnimalAct is accessed from super. 
// It may not be abstract unless it is overridden by a member declared `abstract' and `override'
//     super.breath()

按照错误提示,给方法加上 abstract override 关键字。可以这样理解,当我们给某个方法增加了 abstract override 后,就是明确的告诉编译器,该方法确实是重写了父特质的抽象方法,但是重写后,该方法仍然是一个抽象方法。因为没有完全的实现,所以还需要其它特质继续实现(通过特质混入顺序)。

修改示例代码:

object TraitTest {
  def main(args: Array[String]): Unit = {
    val p2 = new Person1101 with AnimalTrait with PersonTrait
    p2.breath()
  }
}

trait AnimalAct {
  def breath(): Unit
}

trait PersonTrait extends AnimalAct {
  var name: String = "ljq"

  abstract override def breath(): Unit = {
    println("PersonTrait#breath...")
    super.breath()
  }
}

trait AnimalTrait extends AnimalAct {
  def breath(): Unit = {
    println("AnimalTrait#breath...")
  }
}

class Person1101 {}

// ===== 控制台打印 =====
// PersonTrait#breath...
// AnimalTrait#breath...

5.4 特质中的字段

  • 特质中可以定义抽象字段,特质中未被初始化的字段在具体的子类中必须被重写。
  • 特质中可以定义具体字段,如果初始化了就是具体字段,如果不初始化就是抽象字段。混入该特质的类就具有了该字段,字段不是继承,而是直接加入类,成为自己的字段。

5.5 特质继承某类

特质可以继承类,以用来拓展该类的一些功能。

trait LoggedException extends Exception {
  def log(): Unit = {
    println(getMessage())
  }
}

所有混入该特质的类,会自动成为那个特质所继承的超类的子类。

如果混入该特质的类,已经继承了另一个类(A 类),则要求 A 类是特质超类的子类,否则就会出现了多继承现象,发生错误。

5.5 特质自身类型

主要是为了解决特质的循环依赖问题,同时可以确保特质在不扩展某个类的情况下,依然可以做到限制混入该特质的类的类型

// Logger就是自身类型特质
trait Logger {
  // 明确告诉编译器,我就是Exception,如果没有这句话,下面的 getMessage 不能调用
  this: Exception =>
  def log(): Unit = {
    // 既然我就是 Exception,那么就可以调用其中的方法
    println(getMessage)
  }
}

6. 嵌套类

6.1 基本语法

在 Scala 中,你几乎可以在任何语法结构中内嵌任何语法结构。如在类中可以再定义一个类,这样的类是嵌套类,其他语法结构也是一样。

定义内部类:成员内部类、静态内部类

object InnerClsTest {
  def main(args: Array[String]): Unit = {
    // 创建成员内部类(使用'对象.内部类'格式)
    val outer1: ScalaOuterClass = new ScalaOuterClass();
    val outer2: ScalaOuterClass = new ScalaOuterClass()
    val inner1 = new outer1.ScalaInnerClass()
    val inner2 = new outer2.ScalaInnerClass()
    // 创建静态内部类对象
    val staticInner = new ScalaOuterClass.ScalaStaticInnerClass()
  }
}

class ScalaOuterClass {
  class ScalaInnerClass {}            // 成员内部类
}

object ScalaOuterClass {
  class ScalaStaticInnerClass {}      // 静态内部类
}

6.2 内使外属性

在内部类中访问外部类的属性

(1)语法格式:外部类名.this.属性名

class ScalaOuterClass {
  var name: String = "tree"
  private var sal: Double = 9.5

  class ScalaInnerClass {
    def info() = {
      // ScalaOuterClass.this 就相当于是 ScalaOuterClass 这个外部类的一个实例,然后通过外部类实例对象去访问 name 属性
      println(s"name = ${ScalaOuterClass.this.name}, age = ${ScalaOuterClass.this.sal}")
    }
  }
}

(2)外部类别名访问:外部类名别名.属性名

class ScalaOuterClass {
  myOuter => // 这里可以理解成外部类的一个实例名
  class ScalaInnerClass {
    def info() = {
      println(s"name = ${myOuter.name}, age = ${myOuter.sal}")
    }
  }
  // 当给外部指定别名时,需要将外部类的属性放到别名后。
  var name: String = "tree"
  private var sal: Double = 9.5
}

6.3 类型投影

在如下示例中,Java 中的内部类从属于外部类,因此在 Java 中 inner.test(inner2) 是可以的,因为是按类型来匹配的;而 Scala 中内部类从属于外部类的对象,所以外部类的对象不一样,创建出来的内部类也不一样,无法互换使用。

在方法声明上,如果使用 外部类#内部类 的方式,表示忽略内部类的对象关系,等同于 Java 中内部类的语法操作,我们将这种方式称之为「类型投影」,即:忽略对象的创建方式,只考虑类型。

class ScalaOuterClass {
  myOuter =>
  class ScalaInnerClass {
    
    // 下面 ScalaOuterClass#ScalaInnerClass 类型投影的作用就是屏蔽外部对象对内部类对象的影响
    def test(c: ScalaOuterClass#ScalaInnerClass) = {
      println(c.hashCode())
    }
    
  }

  var name: String = "tree"
  private var sal: Double = 9.5
}

7. 枚举类和应用类

说明:

  • 枚举类:需要继承 Enumeration
  • 应用类:需要继承 App

示例:

// 枚举类
object Color extends Enumeration {
  val RED = Value(1, "red")
  val YELLOW = Value(2, "yellow")
  val BLUE = Value(3, "blue")
}

// 应用类
object TestMain extends App {
  println("run...");
}

8. 隐式转换

当编译器第一次编译失败的时候,会在当前的环境中查找能让代码编译通过的方法,用于将类型进行转换,实现二次编译。

隐式转换可以在不需改任何代码的情况下,扩展某个类的功能。

8.1 隐式函数

隐式转换函数是以 implicit 关键字声明的带有单个参数的函数。这种函数将会自动应用,将值从一种类型转换为另一种类型。

【案例一】通过隐式转化为 Int 类型增加方法

object ImplicitTest {

  // 使用 implicit 关键字声明的函数称之为隐式函数
  implicit def convert(arg: Double): Int = arg.toInt
  implicit def convert2(arg: Double): Int = arg.toInt

  def main(args: Array[String]): Unit = {
    // 当想调用对象功能时,如果编译错误,那么编译器会尝试在当前作用域范围内查找能调用对应功能的转换规则
    // 这个调用过程是由编译器完成的,所以称之为隐式转换。也称之为自动转换
    val num: Int = 6.7
    println(num)
  }

}

反编译:

注意事项和细节:

  1. 隐式转换函数的函数名可以是任意的,隐式转换与函数名称无关,只与函数签名(函数参数类型和返回值类型)有关;
  2. 隐式函数可以有多个,但是需要保证在当前环境下,只有一个隐式函数能被识别使用
    // 在当前环境中,不能存在满足条件的多个隐式函数
    implicit def a(d: Double) = d.toInt
    implicit def b(d: Double) = d.toInt 
    val num: Int = 6.7 // [报错] 在转换时,编译器识别出有两个方法可以被使用,不知道调用哪一个
    println(num)
    

【案例二】隐式转换丰富类库

如果需要为一个类增加一个方法,可以通过隐式转换来实现(动态增加功能,比如想为 A 类增加一个 delete 方法)。

在程序开发中,如果想要给 A 类增加功能是非常简单的,但是在实际项目中,如果想要增加新的功能就会需要改变源代码,这是很难接受的,而且违背了软件开发的 OCP 原则(闭合原则)。在这种情况下,可以通过隐式转换函数给类动态添加功能。

object ImplicitTest2 {
  
  implicit def convert2(arg: A11): B11 = new B11

  def main(args: Array[String]): Unit = {
    val a = new A11
    a.delete
  }
}

class A11 {}

class B11 {
  def delete = println("B#delete")
}

反编译:

8.2 隐式值

隐式值也叫隐式变量,将某个入参变量标记为 implicit,所以编译器会在方法省略隐式参数的情况下去搜索作用域内的隐式值作为缺省参数。

object ImplicitValTest {

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

    implicit val name = 1101
    
    // 不能有二义性
    // implicit val name1 = "ljq"
    // implicit val name2 = "13"

    //【优先级】传值 > 隐式值 > 默认值
    def hello(implicit content: String = "tree") = {
      println(s"Hello! $content")
    }

    hello
  }

}

形参的默认值的优先级是低于使用 implicit 声明的变量的,如果声明了一个隐式值,同时也在形参上给了默认值,那么最终的值就是优先级高的隐式值。

8.3 隐式类

在 Scala 2.10 后提供了隐式类,可以使用 implicit 声明类,隐式类的非常强大,同样可以扩展类的功能,比前面使用隐式转换丰富类库功能更加的方便,在集合中隐式类会发挥重要的作用。

示例说明:

object ImplicitClsTest {

  implicit class testImplicitCls(val p: Person101) {
    def addSuffix(): String = {
      p + " - Hello"
    }
  }

  def main(args: Array[String]): Unit = {
    val p = new Person101
    println(p.addSuffix())
  }
}

class Person101 {}

反编译:

上面我们写了一个 object ImplicitClsTest 伴生对象,对应反编译之后的两个文件 ImplicitClsTest 和 ImplicitClsTest$,在 Scala 中当我们只写一个伴生对象时,编译器自动会帮我们生成对应的伴生类,即 ImplicitClsTest 就是编译器生成的伴生类,而 ImplicitClsTest$ 则是我们写的伴生对象,我们在 object 中写的所有代码最终都是在 ImplicitClsTest$ 中,而调用是在 ImplicitClsTest 伴生类的 main 方法中,伴生类和伴生对象之间使用 MODULE$ 进行调用。

当 main 方法代码执行到 println(p.addSuffix()) 之前,编译器则会调用 ImplicitClsTest$ 伴生对象中的 testImplicitCls(Person101) 方法,将 val p = new Person101 的 p 对象传入 testImplicitCls 方法,而这个 testImplicitCls() 方法中则会调用隐式类 ImplicitClsTest.testImplicitCls 的构造器,返回一个隐式类的对象,所以我们在不知不觉的情况下就进行了隐式类型转换,Person101 的对象就可以调用自己没有的方法。

隐式类使用特点:

  1. 隐式类所带的构造参数有且只能有一个
  2. 隐式类必须被定义在「类」或「伴生对象」或「包对象」里,即隐式类不能是顶级的
  3. 隐式类不能是 case class 样例类
  4. 作用域内不能有与之相同名称的标识符
  5. 隐式转换不能存在二义性
  6. 隐式操作不能嵌套使用

8.4 隐式解析机制

隐式转换的时机:

  1. 当方法中的参数的类型与目标类型不一致时;
  2. 当对象调用所在类中不存在的方法或成员时,编译器会根据类型自动将对象进行隐式转换。

隐式解析机制(即编译器是如何查找到缺失信息的,解析具有以下两种规则):

(1)首先会在当前代码作用域下查找隐式实体(隐式方法、隐式类、隐式对象、隐式值)

(2)如果按照第一条规则查找隐式实体失败,则会继续在隐式参数的类型的作用域里查找。类型的作用域是指与该类型相关联的全部伴生对象以及该类型所在包的包对象。一个隐式实体的类型 T 的查找范围如下(第二种情况范围广且复杂在使用时,应当尽量避免出现):

  1. 如果 T 被定义为 T with A with B with C,那么 A、B、C 都是 T 的部分,在 T 的隐式解析过程中,它们的伴生对象都会被搜索;
  2. 如果 T 是参数化类型,那么类型参数和与类型参数相关联的部分都算作T的部分,比如 List[String] 的隐式搜索会搜索 List 的伴生对象和 String 的伴生对象;
  3. 如果 T 是一个单例类型 p.T,即 T 是属于某个 p 对象内,那么这个 p 对象也会被搜索;
  4. 如果 T 是个类型注入 S#T,那么 S 和 T 都会被搜索。