Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scalaのobjectで、finalなvalと普通のvalとではコンパイル結果がどう違うか検証する

ふと気になっていたので、素早く検証することにした。

ちなみに逆コンパイルなどではjavapコマンドを使うのが良いと教えてもらった。

検証方法

以下の方法で検証を行う。

  • objectが1つだけ入っているScalaファイルを2つ用意する
    • 片方にはvalを、もう片方にはfinal valで定数を定義する
  • scalacコマンドで2つのファイルをコンパイルする
  • javapの出力結果を比較する

バージョン

コンパイラは最新のScala 3.3.0でコンパイルしてもらう。

scalac -version
Scala compiler version 3.3.0 -- Copyright 2002-2023, LAMP/EPFL

ソースコード

まず、finalがない版。

// foonofinal.scala
package foo
object FooNoFinal {
  val bar: Int = 42
}

finalをつけた版。

// foofinal.scala
package foo
object FooFinal {
  final val bar: Int = 42
}

コンパイル

普通にscalacでコンパイルする。

scalac -source 3.3 foofinal.scala foonofinal.scala

シグネチャの差分を見る

javapした結果をdiffで比較する。

diff -u <(javap foo.FooNoFinal) <(javap foo.FooFinal)
--- /proc/self/fd/11    2023-06-21 19:59:24.945413620 +0900
+++ /proc/self/fd/12    2023-06-21 19:59:24.945413620 +0900
@@ -1,4 +1,4 @@
-Compiled from "foonofinal.scala"
-public final class foo.FooNoFinal {
+Compiled from "foofinal.scala"
+public final class foo.FooFinal {
   public static int bar();
 }

シグネチャに一切変化はなし、public static int bar()が出力されていることがわかる。

コンパニオンオブジェクトのほうでも確認しておく。

diff -u <(javap foo.FooNoFinal$) <(javap foo.FooFinal$)
--- /proc/self/fd/11    2023-06-21 20:01:19.101334006 +0900
+++ /proc/self/fd/12    2023-06-21 20:01:19.101334006 +0900
@@ -1,6 +1,6 @@
-Compiled from "foonofinal.scala"
-public final class foo.FooNoFinal$ implements java.io.Serializable {
-  public static final foo.FooNoFinal$ MODULE$;
+Compiled from "foofinal.scala"
+public final class foo.FooFinal$ implements java.io.Serializable {
+  public static final foo.FooFinal$ MODULE$;
   public static {};
-  public int bar();
+  public final int bar();
 }

こちらではfinalをつけるとコンパイル結果でも差分が出ることがわかった。

コードの差分を見る

せっかくなのでリバースアセンブルしてみよう。javapでリバースアセンブルするには、-cオプションを使えばよい:

diff -u <(javap -c foo.FooNoFinal) <(javap -c foo.FooFinal)
--- /proc/self/fd/11    2023-06-21 20:06:57.093187752 +0900
+++ /proc/self/fd/12    2023-06-21 20:06:57.093187752 +0900
@@ -1,8 +1,8 @@
-Compiled from "foonofinal.scala"
-public final class foo.FooNoFinal {
+Compiled from "foofinal.scala"
+public final class foo.FooFinal {
   public static int bar();
     Code:
-       0: getstatic     #13                 // Field foo/FooNoFinal$.MODULE$:Lfoo/FooNoFinal$;
-       3: invokevirtual #15                 // Method foo/FooNoFinal$.bar:()I
+       0: getstatic     #13                 // Field foo/FooFinal$.MODULE$:Lfoo/FooFinal$;
+       3: invokevirtual #15                 // Method foo/FooFinal$.bar:()I
        6: ireturn
 }

いずれも、FooNoFinal$FooFinal$といったコンパニオンオブジェクトを呼び出しているだけだ。

ではコンパニオンオブジェクトはどうなっているだろう?

diff -u <(javap -c foo.FooNoFinal$) <(javap -c foo.FooFinal$)
--- /proc/self/fd/11    2023-06-21 20:09:44.589148518 +0900
+++ /proc/self/fd/12    2023-06-21 20:09:44.589148518 +0900
@@ -1,19 +1,17 @@
-Compiled from "foonofinal.scala"
-public final class foo.FooNoFinal$ implements java.io.Serializable {
-  public static final foo.FooNoFinal$ MODULE$;
+Compiled from "foofinal.scala"
+public final class foo.FooFinal$ implements java.io.Serializable {
+  public static final foo.FooFinal$ MODULE$;
 
   public static {};
     Code:
-       0: new           #2                  // class foo/FooNoFinal$
+       0: new           #2                  // class foo/FooFinal$
        3: dup
-       4: invokespecial #18                 // Method "<init>":()V
-       7: putstatic     #20                 // Field MODULE$:Lfoo/FooNoFinal$;
-      10: bipush        42
-      12: putstatic     #22                 // Field bar:I
-      15: return
+       4: invokespecial #16                 // Method "<init>":()V
+       7: putstatic     #18                 // Field MODULE$:Lfoo/FooFinal$;
+      10: return
 
-  public int bar();
+  public final int bar();
     Code:
-       0: getstatic     #22                 // Field bar:I
-       3: ireturn
+       0: bipush        42
+       2: ireturn
 }

見たところ、finalを使わない版ではobjectの初期化時(≒プログラムの起動時)にstaticな値を設定しているが、finalを使うとそれが遅延され、barが呼ばれたときに直接即値として値を返している。

Scala 2.13の言語仕様では、以下のように述べられているので、3でもこれが踏襲されているとしたら、実質的には同じものとみなしてよいはずだ:

Members of final classes or objects are implicitly also final, so the final modifier is generally redundant for them

scala-lang.org

Scalaにおけるobjectの実装

ところで、Scala(ここではScala 3.3)ではobjectはどのように実装されているのだろう?

Scalaでは、object Fooを定義すると、FooクラスとFoo$クラスとの2つにコンパイルされる。これは、ScalaのobjectもJavaからは一般のクラスのように見えてほしい、という都合が絡んでいる。

stackoverflow.com

もしFooが通常のクラスとコンパニオンオブジェクトとで構成されていれば、それぞれがFooFoo$にコンパイルされるのだが、Javaとの相互運用性のために、JavaからもFooという名前でobject Fooにもアクセスできたい。そこで、コンパイルしたクラスファイルには、Fooの中にFoo$を呼ぶだけの転送用のメソッドが定義される(最初に示したpublic static int bar()がそれだ)。

そして、コンパニオンオブジェクトとしてではなく単体でobjectのみが定義されるというケースは、コンパニオンオブジェクトの特殊ケースとして処理される。すなわち、実際の処理を全てFoo$に転送するクラスFooと、静的メソッドのみからなるクラスFoo$が生成されるのだ。つまり、以下のコードをコンパイルするのと(ほぼ)等価に扱われる:

class Foo {}
object Foo {
  val bar: Int = 42
}

いちおう、確かめてみよう:

diff -u <(javap -c foo.FooFinal) <(javap -c foo.FooFinalScalaWithClass)
--- /proc/self/fd/11    2023-06-21 20:30:13.324688394 +0900
+++ /proc/self/fd/12    2023-06-21 20:30:13.328688405 +0900
@@ -1,8 +1,14 @@
-Compiled from "foofinal.scala"
-public final class foo.FooFinal {
+Compiled from "foofinalwithclass.scala"
+public class foo.FooFinalScalaWithClass {
   public static int bar();
     Code:
-       0: getstatic     #13                 // Field foo/FooFinal$.MODULE$:Lfoo/FooFinal$;
-       3: invokevirtual #15                 // Method foo/FooFinal$.bar:()I
+       0: getstatic     #13                 // Field foo/FooFinalScalaWithClass$.MODULE$:Lfoo/FooFinalScalaWithClass$;
+       3: invokevirtual #15                 // Method foo/FooFinalScalaWithClass$.bar:()I
        6: ireturn
+
+  public foo.FooFinalScalaWithClass();
+    Code:
+       0: aload_0
+       1: invokespecial #19                 // Method java/lang/Object."<init>":()V
+       4: return
 }

クラスがある版は、initする用のメソッドが用意されたが、内容が空なので単にsuperの初期化メソッドを呼出して終わりだ。

final object

ちなみに、objectにfinalを付けるのはredundant、つまり無意味で冗長であるとScala 2.13の言語仕様で既に定められている(Scala 3の言語仕様はドキュメントになっていないのか、見付けられなかった)。

scala-lang.org

stackoverflow.com

昔はobjectをoverride可能にするコンパイラオプションがあったようだが、Scala 3.3でも同様に使えるかは不明だ(呼び出せるかもわからない)。

まとめと感想

  • コンパイル結果は異なるが、動作としては同じであるし、パフォーマンス上の差異はなさそうに見える。
  • 今回はがんばって手でscalacを動かしたが、scala-cliとかから素早くリバースアセンブルできると嬉しい
  • もっと欲を言うと、LSPを経由して逆アセンしたコードにエディタからアクセスできると最高だと思う
★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?