阅读 272

Scala之:伴生对象与静态概念

在 Java 中,静态成员并不是通过实例去调用的,而是通过类名调用的,关键字是 static 。严格意义上来说,静态成员并不满足 OOP 的思想:它本质上和这个类并没有任何关联。或者说,它的存在更像是一个 PO 的全局变量。

由于当时所处的时代限制, Java 语言不得不兼顾一些 PO 思想的内容。而对于 Scala 这门多范式语言,它除了实现 FP 之外,还将 OOP 发挥到了极致。Scala 之父马丁·奥德斯基在设计该语言时,便将 static 这个被 OOP 视为“眼中钉” 的概念移除了。

然而,有时候我们确实需要脱离于某个具体对象的,一个静态的全局变量。为了弥补消除 static 所带来的缺陷(或者说让它看起来更 OOP 一点),马丁·奥德斯基引入了伴生对象的概念:在 Scala 中,类的“动静分明”。一切非静态内容,保存在 class 中,而静态内容则保存在 object 中。

这也是将 Scala 的主函数声明在一个 object 内的原因:因为主函数是 “静态” 的,单例的。另外,在有些资料中,也称伴生对象是单例对象。

如何声明一个伴生对象

Scala 的世界里没有 static 关键字,也没有和静态有关的概念。不过鉴于我们学习 Java 的经验,笔者在下文仍然会使用 “静态” 一词来阐述一些概念。

首先给出一个伴生对象的例子,我们再去叙述其细节。

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

class Associated_Object{}
复制代码

当同一个文件内同时存在object xclass x的声明时:

  • 我们称class x称作object x伴生类

  • object x称作class x伴生对象

伴生类和伴生对象是相对的概念。

在编译时,伴生类object x被编译成了x.class,而伴生对象object x被编译成了x$.class

对于声明在伴生对象的成员,可以直接通过类名来调用。比如在伴生对象内声明了一个属性:

object Associated_Object {
  val publicKey: String = "Associated value"
}
复制代码

那么在主函数中,可以直接用类名去调用publicKey的值:

println(s"args = ${Associated_Object.publicKey}")
复制代码

同样的,伴生对象也可以声明 private 的成员。这样,这个被修饰的成员仅可以被伴生类的实例所访问,并且所有的伴生类实例共享这一个变量

伴生类的apply方法

查看下面的代码。new关键字哪去了?

 val cat : Cat = Cat()
复制代码

这种写法是隐式地调用了其伴生对象的apply方法。下面给出Cat类的完整声明:

class Cat(){}

object Cat{
  //该方法内置了构造方法。
  def apply() : Cat = new Cat()
}
复制代码

你也可以按照简单工厂模式的思路去理解它:调用该 Cat 伴生对象的 apply 方法 ,并返回一个实例。

注意:

  • 只有在伴生对象内声明 apply 方法之后,后续可以不带 new 关键字直接创建实例,这相当于是 Scala 的语法糖。
  • apply方法允许携带参数,返回值绝大部分情况下都应该是本类的一个实例,但是 Scala 对此并没有在编译角度上做严格规定。

笔者极力建议遵守第二条规则,以避免下面的这种混乱情况:

object Cat{
    //这样的代码从编译角度来看没有任何问题。
    def apply() : Dog = new Dog()
}

//------Main-----------//
//但是,会让代码的调用者陷入混乱。
//在99.9999% 的情况下,apply 方法返回的都应该是其对应类的实例。
val dog : Dog = Cat 
复制代码

另外,Scala 还有一个 unapply 方法,或称之为提取器。我们在后续的模式匹配章节会正式提到它。

可以只声明伴生对象,而不声明伴生类吗?

当然!实际上我们之前写案例时,大部分时间都是只声明一个 object ,然后直接里面声明主函数逻辑。

从编译的角度看,会出现这样一个有趣的现象:

凡是用 object 修饰的伴生对象 x,编译后一定会生成两个文件:一个是x.class,另一个文件是x$.class文件。即便没有使用 class 声明伴生类,编译器在底层仍然会生成内容为空的 x.class 文件。

只使用一个 class 修饰的类,在编译时只会生成一个 x.class 文件。后文给出了如何用 Java 代码实现 Scala 的伴生对象和伴生类,这个实例有助于你理解为什么 Scala 会编译出两个文件。

我们是否可以只依赖单独的 object ?

伴生对象本身实现了单例模式。因此有人又称伴生对象是单例对象,也是有规可循的。比如:

object Single {
  def fun():Unit =  println("this is a singleton.")
}
复制代码

这样,我们在程序中的 Single 符号都指代这个单例对象,并且可以使用. 运算符直接调用内部公开的属性和方法。

经过笔者的验证,可以在单例对象上直接声明继承关系。为了通过编译,我们要把下面的 ParentSingle 写在一个 .scala 文件内部。

class Parent {
 
  def greet() : Unit = println("hello")

}

object Single extends Parent {

  def fun():Unit =  println("this is a singleton.")

}
复制代码

这样,我们可以直接通过 Single.greet() 方法来调用它从 Parent 继承来的方法。但显然,在object 单例对象中声明继承关系显得不伦不类。为什么?因为这本来不是伴生对象原本的用途。

然而,编译器却 “放行” 了这样不规范的代码,因为从编译的角度来看确实没有任何问题。但是它会令初学者(笔者)混乱,比如:

  1. 继承关系和构造器写在 object 和写在 class 有没有区别?
  2. 如果有,那它们之间会存在哪些区别?
  3. 如果同时在一对 objectclass 声明不同的继承关系,这会不会是一个多重继承?

笔者在这里通过实际上手代码的方式来一一验证。

单例对象没有带参构造器

对刚才的 Single 稍作修改后,笔者发现:不能在单例对象上声明任何构造器。下面的写法并不能通过:

object Single(val int : Int) {

  def fun():Unit =  println("this is a singleton.")

}
复制代码

编辑器会反馈上述的代码存在语法错误。这说明,Scala 的设计者马丁·奥德斯基对伴生对象做了一些编译层面的限制。Single 类如果需要自定义的构造器,它必须要依赖其 class 修饰的伴生类来实现:

object Single {def fun():Unit =  println("this is a singleton.")}
class Single(val int : Int)
复制代码

警惕 Scala 的障眼法

能不能在 classobject 分别声明继承关系,以此实现一个多重继承呢?笔者给出了下方的"问题代码":(为了通过编译,这些类声明要写在一个 .scala 文件中)

class Father

object Single extends Father {

  def fun():Unit =  println("this is a singleton.")
}

class Mother

class Single(val int : Int) extends Mother 
复制代码

编译是成功的!这一看似乎是 Single 同时继承了 Father 和 Mother 。真相是如此吗?

尽管伴生对象和伴生类这一对概念用于修饰一个类的 “静态” 和 "动态" 部分,但是 Scala 在编译时,是将伴生对象和伴生类分开编译的(即前文提到的 x.classx$.class)。

因此两者实际上只是共享了一个类名,而伴生对象替伴生类保存着一些 ”静态“ 变量和方法,充当着 “仓库” 的作用。

为了避免不必要的混乱,我们在同时使用伴生对象和伴生类去描述一个 “具备静态内容的类” 时,仅仅会将 ”静态“ 的成员放到 object 上,而继承关系,和构造器等内容的声明全部放到 class 上。

使用 Java 程序模拟伴生对象实现

为了直观理解编译器在底层是如何编译伴生对象和伴生类的,我们直接使用 Java 代码来模拟一次。在这里假设一个伴生类 Associated_Object,它有一个 Integer 类型的静态属性:publicKey。该成员声明在了它的伴生对象 Associated_Object$ 中。

  1. Associated_Object$ 在静态域中构造了一个实例 MODULE$ ,这个实例使用final关键字来保护它不会被更改内存地址

  2. Associated_Object 类同样有一个静态方法 getPublicKey :它永远都指向 MODULE$ 内的 publicKey

经过上述的两个步骤,这相当于是构造了一个单例模式:即任意一个 Associated_Object 对象需要访问 publicKey 时,都是从静态域里的 MODULE$ 那里获取。

下面给出代码实现。

public final class Associated_Object$ {

    private Integer publicKey;

    //在静态域中直接指定MODULE$保存Associated_Object的静态属性。
    static {
        MODULE$ = new Associated_Object$();
    }
    
    public static final Associated_Object$ MODULE$;

    public Integer getPublicKey() {
        return MODULE$.publicKey;
    }

    public void setPublicKey(Integer publicKey) {
        MODULE$.publicKey = publicKey;
    }

    //在初始化中,对publicKey进行赋值。
    //Associated_Object的静态属性会随着初始化保存到MODULE$当中。
    Associated_Object$() {
        this.publicKey = 100;
    }
}
//--------------------------------------------------------------------//
public class Associated_Object {

    private Integer InstanceKey;
    //非静态的属性保存在Associated_Object类本身,正常调用即可。
    public Integer getInstanceKey() {
        return InstanceKey;
    }

    public void setInstanceKey(Integer instanceKey) {
        InstanceKey = instanceKey;
    }
    
    //Associate_Object类本身没有静态的"publicKey"属性。
    //因此需要委托Associated_Object$的实例从MODULE$那获取相应的属性。
    public static Integer getPublicKey() {
        return Associated_Object$.MODULE$.getPublicKey();
    }
    //原理同 getPublicKey 方法。
    public static void setPublicKey(Integer salary) {
        Associated_Object$.MODULE$.setPublicKey(salary);
    }

}
复制代码

小结

在本章节,我们了解了 Scala 伴生对象和伴生类的关系:

  1. Scala 中,类的 "静态" 成员,本质上都是编译在一个独立的 .class 中。
  2. 伴生对象和伴生类名字要保持一致,声明时要在一个 .scala 文件中 ,这样才能正确编译。

通过 Java 代码的实现可知,Scala 世界中的 ”静态“ 不过是障眼法罢了。当我们去调用所谓的 "静态" 内容时,实际上程序调用的是 object 单例对象(或称伴生对象,但是这里叫单例对象更加合适),和 class 伴生类没有什么联系。

只不过由于伴生对象和伴生类保持同一个名字,使得我们通过大写的 ”类名“ 进行调用的时候,根据学习 Java 的思维习惯,理所当然地将它理解成了 Single 类的”静态“成员。

然而,Scala 并没有规定伴生对象和伴生类一定要成对出现,我们可以仅定义 class ,也可以只定义 object 。当你仅需要实现一个简单的单例模式时,仅使用一个 object 最好不过了:比如说一个主函数的入口。

小试牛刀

首先,定义一个 Counter 类,它有一个静态属性 count 。当主函数启动时,每实例化一次 Counter 实例,就对 count 进行一次自增操作。

下列代码是 Java 实现。

public class Counters{

    private static int count = 0;

    public Counters(){
        count++;
    }
}
复制代码

而这个需求在 Scala 中的实现方式是这样的:

class Counter {
  Counter.count += 1
}

object Counter {
  var count : Int = 0
}
复制代码

不要忘记一件事情:Scala 的伴生对象和伴生类,从编译的角度看是独立的。因此我们不能直接在伴生类中访问 count ,而是带上伴生对象的名字(尽管它们都叫一个名字): Counter.count