ふと気になっていたので、素早く検証することにした。
ちなみに逆コンパイルなどではjavap
コマンドを使うのが良いと教えてもらった。
javap コマンド?
— がくぞ (@gakuzzzz) 2023年6月19日
検証方法
以下の方法で検証を行う。
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におけるobjectの実装
ところで、Scala(ここではScala 3.3)ではobjectはどのように実装されているのだろう?
Scalaでは、object Foo
を定義すると、Foo
クラスとFoo$
クラスとの2つにコンパイルされる。これは、ScalaのobjectもJavaからは一般のクラスのように見えてほしい、という都合が絡んでいる。
もしFoo
が通常のクラスとコンパニオンオブジェクトとで構成されていれば、それぞれがFoo
とFoo$
にコンパイルされるのだが、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の言語仕様はドキュメントになっていないのか、見付けられなかった)。
昔はobjectをoverride可能にするコンパイラオプションがあったようだが、Scala 3.3でも同様に使えるかは不明だ(呼び出せるかもわからない)。
まとめと感想
- コンパイル結果は異なるが、動作としては同じであるし、パフォーマンス上の差異はなさそうに見える。
- 今回はがんばって手でscalacを動かしたが、
scala-cli
とかから素早くリバースアセンブルできると嬉しい - もっと欲を言うと、LSPを経由して逆アセンしたコードにエディタからアクセスできると最高だと思う