hydrakecat’s blog

Walking like a cat

companion object vs. top-level

JavaにはあるけれどKotlinにないものの1つに、クラスメソッドやクラス変数があります。 この記事では、そのクラスメソッドとクラス変数をKotlinではどう定義すべきかという話をします。

より詳細に言えば、メソッドや定数*1をtop-levelで宣言するのとcompanion objectに宣言するのとどちらが良いかという話です。

Java -> Kotlin 自動変換

JavaからKotlinへの自動変換を使ったことのある人は、Javaのクラスメソッドや定数がcompanion objectに変換されることに気付いたでしょう。

こういうJavaクラスがあったとして、自動変換をすると、

package shape;

public class Circle {
  public static final double PI = 3.14159265358979323846;
  private final double r;

  private Circle(double r) {
    this.r = r;
  }

  public static Circle newCircle(double r) {
    return new Circle(r);
  }

  public double area() {
    return PI * r * r;
  }
}

つぎのようになります*2

package shape

class Circle(private val r: Double) {
  fun area(): Double {
    return PI * r * r
  }

  companion object {
    const val PI = 3.14159265358979323846

    fun newCircle(r: Double): Circle {
      return Circle(r)
    }
  }
}

これはKotlinコードとしては(多少不自然さはあるものの)まったく正しいコードです。 呼び出し方も Circle.newCircle() もしくは Circle.PI とすればよいのでJavaに馴れた目にも違和感がありません。

そのせいか、自分が目にするKotlinコードの多くは、Javaのクラスメソッドやクラス変数に相当するものをcompanion objectで実装する傾向があるように思います。

ただ、個人的な感覚からすると、このcompanion objectは余計な印象があります。この印象を分析すると、2つの理由があります。

  1. 作らなくてもいいオブジェクトを生成している
  2. このcompanion objectがどういう振る舞いを期待されているか分からない

2について補足すると、ファクトリーメソッドを持ちつつ、そのインスタンス生成に関係ないPIという変数を持っているのがやや気持ち悪く感じます。 またPIという定数の性質も相まってCircleクラスに関連付いているというのも微妙な気分になります。

top-level関数および定数

一方、Kotlinではtop-levelで関数およびプロパティを宣言することができます。 これは特定のクラスに属さない関数や定数を定義するのに自然な選択です。

さきほどの例だと、こうなるでしょう。

package shape

const val PI = 3.14159265358979323846

fun newCircle(r: Double): Circle {
  return Circle(r)
}

class Circle(private val r: Double) {
  fun area() = PI * r * r
}

いかがでしょうか?クラス設計という点からは、Circleクラスのみになり、理解しやすい気がします。ただ後述するように使う側からすると使いにくさがありそうです。

なお、この例ではCircleクラスのコンストラクタがpublicですが、こういったnewCircleメソッドのようなファクトリーメソッドがある場合は、コンストラクタをprivateにするケースが大半です(さもないとせっかくのファクトリーメソッドを迂回されてしまいます)。

その場合、このtop-level版のコードはコンパイルできません。なぜならnewCircleCircleクラスのprivateコンストラクタにアクセスできないからです。

しかし、ここでは、どちらがいいか、という議論はやめておきましょう。

かわりに、両者を比較する上での材料を集めてみます。

バイトコードを見てみる

まずは、両者の書き方によって、バイトコードに差が出るか見てみましょう。

まずはcompanion object版です(関係のなさそうなところは端折っています)。

public final class shape/Circle {
  // access flags 0x1A
  public final static D PI = 3.141592653589793

  // access flags 0x19
  public final static Lshape/Circle$Companion; Companion
}

public final class shape/Circle$Companion {
  // access flags 0x11
  public final newCircle(D)Lshape/Circle;
  @Lorg/jetbrains/annotations/NotNull;() // invisible
   L0
    LINENUMBER 12 L0
    NEW shape/Circle
    DUP
    DLOAD 1
    INVOKESPECIAL shape/Circle.<init> (D)V
    ARETURN
   L1
    LOCALVARIABLE this Lshape/Circle$Companion; L0 L1 0
    LOCALVARIABLE r D L0 L1 1
    MAXSTACK = 4
    MAXLOCALS = 3
}

CircleクラスがCircle$Companion型のクラス変数Companionを持ち、newCircle()はその変数経由でアクセスします。 Companionという名前がクラス名と変数名両方に使われていてちょっと紛らわしいですね。

とはいえ、おおむね予想通りだったのではないかと思います。

ではtop-level版はどうなるでしょうか。

public final class shape/Circle {
  ...
}

public final class shape/CircleKt {
  // access flags 0x1A
  public final static D PI = 3.141592653589793

  // access flags 0x19
  public final static newCircle(D)Lshape/Circle;
  @Lorg/jetbrains/annotations/NotNull;() // invisible
   L0
    LINENUMBER 6 L0
    NEW shape/Circle
    DUP
    DLOAD 0
    INVOKESPECIAL shape/Circle.<init> (D)V
    ARETURN
   L1
    LOCALVARIABLE r D L0 L1 0
    MAXSTACK = 4
    MAXLOCALS = 2
}

CompanionObjectがなくなったかわりにCircleKtクラスが登場し、そのクラス変数およびクラスメソッドになっています。 こちらも公式ドキュメントにある通りなのでびっくりはないと思います。 バイトコード的には、companion object版にあったCompanion変数がなくなったので、すこしだけオーバーヘッドはなくなったかもしれません。

結論として、両者をバイトコードから比較したときに、どちらが良い、とは言えなさそうです。わずかにtop-level版の方がオーバーヘッドは小さい程度でしょうか。

使うときのメリット・デメリット

では使う側からはどういう違いがあるでしょうか。 それぞれのバージョンを呼び出すKotlinコードはつぎのようになります。

companion object版はこうなります。

import shape.Circle

fun main(args: Array<String>) {
  val c = Circle.newCircle(1.0)
  println("PI = $Circle.PI")
  println(c.area())
}

つぎにtop-level版です。

import shape.PI
import shape.newCircle

fun main(args: Array<String>) {
  val c = newCircle(1.0)
  println("PI = $PI")
  println(c.area())
}

これは好みが分かれそうです。おそらく、Javaに馴れた目から見るとcompanion object版の方が自然でしょう。 とくにCircleというクラス名で修飾するのに比べてshapeパッケージでしか名前空間を定義できないのは心許なく感じるかもしれません。

ここで、自分の好みを言うと、自分は実はtop-level版でも大して気になりません。 ただ、AndroidのIntent生成メソッドのnewIntentやFragment生成メソッドのnewFragmentのように慣習的に同名のメソッドが大量にあると、それらに別名を付けるのは面倒そうです。

使う側の観点からは好み次第といったところでしょうか。

教科書やブログを調べてみる

それでは、ここで教科書やブログはどう言っているか見てみましょう。

まずはKotlin in Actionです。つい最近邦訳が出ましたが、残念ながら英語版しかないので、そこからの引用です。ちょっと長いですが、まさにドンピシャな部分があったので全パラグラフを載せます。後ろに拙訳を載せました(繰り返しになりますが日本語版を持っていないのです 🙇🏻)

Classes in Kotlin can’t have static members; Java’s static keyword isn’t part of the Kotlin language. As a replacement, Kotlin relies on package-level functions (which can replace Java’s static methods in many situations) and object declarations (which replace Java static methods in other cases, as well as static fields). In most cases, it’s recommended that you use top-level functions. But top-level functions can’t access private members of a class, as illustrated by figure 4.5. Thus, if you need to write a function that can be called without having a class instance but needs access to the internals of a class, you can write it as a member of an object declaration inside that class. An example of such a function would be a factory method.

Kotlinのクラスはスタティックメンバーを持てません。JavaのstaticキーワードはKotlinの言語仕様にないのです。代わりに、Kotlinはpackageレベルの関数(Javaの一般的なスタティックメソッドの代替)とobject宣言(Javaの特殊なスタティックメソッドとスタティックフィールドの代替)があります。 一般に、top-levelの関数を使うことをおすすめします。しかしtop-level関数は図4.5に示すようにクラスのプライベートメンバーにアクセスできません。そのため、クラスインスタンスは持つ必要はないけれど、クラス内部にアクセスしたい場合には、そのクラスのobjectを宣言し、関数をそのobject内に宣言します。そのような関数の典型例はファクトリーメソッドでしょう。

4.4.2. Companion objects: a place for factory methods and static members, Kotlin in Actions

いかがでしょうか?関数については明示的にtop-levelにすべきとありますね。一方でクラス変数(定数)については明言はされていません。どちらかというとobjectに定義することを想定しているようにも読めます。また、objectにメソッドを定義するのはクラス内部にアクセスしたい場合すなわちファクトリーメソッドの場合であると書いてあります。

巷間のブログではどうでしょうか?1つ参考になるブログ記事にWhere Should I Keep My Constants in Kotlin?という記事があります。 これは余計なオブジェクトを生成しないという観点からobjectに定数を定義する方法とtop-levelに定義する方法を比較し、後者の方がよいとしています。

一方、コメントを見るとobjectに名前を付けることで定数のグルーピングが出来て便利という意見もあるようです。

標準ライブラリをgrepしてみる

最後にやや飛び道具的ですが、標準ライブラリの実装を見てみます。 標準ライブラリに従わなければならないという法はありませんが、それらがcompanion objectをどう使っているか調べれば、きっとヒントが得られるでしょう。

ここでは https://github.com/JetBrains/kotlin の現時点での最新のコード*3でつぎのコマンドを実行してみます。

$ find ./libraries/stdlib -name '*.kt' | xargs grep -l 'companion object'

結果は15件と意外と少ないことに気付きます。テストを除いた .kt ファイルは131ファイルあるので、それと比べても少なめなことが分かります。

一方、top-level関数が定義されているファイルは適当に egrep -l '^public inline fun' とやっても52件見つかります。ただし、これは標準ライブラリという性質および拡張関数も含まれることを考えると公平な比較ではありません。参考程度にしておきます。

さて、ではcompanion objectは実際にどういう箇所で使っているのでしょうか。

たとえば、このへんなどは参考になりそうです。一種のファクトリーメソッドですが、キャッシュ用のプロパティを持っています。

public enum class CharCategory(public val value: Int, public val code: String) {
   ...
   public companion object {
        private val categoryMap by lazy { CharCategory.values().associateBy { it.value } }

        public fun valueOf(category: Int): CharCategory = categoryMap[category] ?: throw IllegalArgumentException("Category #$category is not defined.")
    }
}

https://github.com/JetBrains/kotlin/blob/0b37c9e83cd09008db5908fd47583cd62e9fc17b/libraries/stdlib/src/kotlin/text/CharCategory.kt#L164

あるいは、ちょっと変わったものだとこのあたりでしょうか。

expect class Regex {
    ...
    
    companion object {
        fun fromLiteral(literal: String): Regex
        fun escape(literal: String): String
        fun escapeReplacement(literal: String): String
    }
}

https://github.com/JetBrains/kotlin/blob/a39f2f82718dd278eba9a82df4a5632abb1f4044/libraries/stdlib/common/src/kotlin/TextH.kt#L61

public interface ContinuationInterceptor : CoroutineContext.Element {
    companion object Key : CoroutineContext.Key<ContinuationInterceptor>
    ...
}

https://github.com/JetBrains/kotlin/blob/a39f2f82718dd278eba9a82df4a5632abb1f4044/libraries/stdlib/src/kotlin/coroutines/experimental/ContinuationInterceptor.kt#L29

前者は、Regexクラスのインタフェース(expect class)を定義しているものですが、companion objectにファクトリーメソッドだけでなくescapeメソッドも定義しています。 これは、実装をこの場で提供できないがためにこうなっているのかもしれません。

後者は、coroutineの実装です。 詳細は述べませんが、CoroutineContext.Elementを継承しているインタフェースはどれもcompanion objectがCoroutineContext.Keyを継承しており、 クラス名をキーにしてContextからそのクラスのシングルトンを取得できるようになっています。

一方で、publicな定数はあまりcompanion objectに定義されていないようでした。せいぜいKotlinVersionVersion.ktに定義されていたMAX_COMPONENT_VALUEくらいでしょうか。これはその直後の変数CURRENTのために必要だったようですが。

むしろ、const valgrepしてみるとtop-levelに多くの定数が定義されています。あるいは、Typographyのように名前のあるobject内に定数を定義してグルーピングしているものはありました。

まとめ

以上の調査結果をもとにpublic/internalなクラスメソッドや定数をどう定義するのがよいか考えてみましょう。

まず、バイトコード的には大差ありませんでした。しかし、使う側からすると、companion object版の方が名前の衝突に気を遣わなくてよい分、有利そうです。

一方、Kotlin in Actionや巷のブログ記事を読むと、ファクトリーメソッドはcompanion object、それ以外はtop-levelというのが主流のようです。定数はtop-levelがやや有利、グルーピングしたいならobjectに定義、という感じでしょうか。

最後に標準ライブラリの実装を調べてみたところ、そもそもcompanion objectを使っているファイルが少なかったものの、companion objectに定義されたpublic/internalなメソッドと定数は、top-levelのそれに比較してあまり多くない印象でした。これは標準ライブラリという性格も影響しているかもしれませんが、興味深い結果です。

結論としては、メソッドはクラス内部にアクセスするファクトリーメソッドはcompanion object、それ以外はtop-level、定数はグルーピングが必要ならobject、そうでないならtop-level、というあたりが落としどころのように思います。

なにかこれ以外の観点や見落としがあったら、ぜひご指摘ください。

最後になりましたが、companion objectの定数についてヒントを下さり、kotlinalang-jp Slackで議論の相手をして下さった @shaunkawanoさんに感謝いたします。

*1:なお、この記事ではpublicもしくはinternalなメソッドや定数について考えます。privateなものについてはどちらも大差ないという考えからです。

*2:http://shaunkawano.hatenablog.com/entry/2017/11/07/101611にもあるように、通常の自動変換ではconstが付かないのですが、ここでは付けています。これによって無駄なメソッド呼び出しが減ります

*3:https://github.com/JetBrains/kotlin/commit/a39f2f82718dd278eba9a82df4a5632abb1f4044