KotlinのObject、Companion Objectおよびシングルトン
1. 概要
Kotlinにはstaticキーワードがありません。代わりに、言語はobjectキーワードを提供し、3つの使い方があります:object declaration、object expression、companion objectです。
この記事では、各使い方、Kotlinがそれらをどのようにバイトコードにコンパイルするか、Javaから呼び出す方法、そしていつ使うべきでないかについて説明します。読み終わると、スレッドセーフなシングルトンの作成方法、すっきりしたファクトリメソッドの書き方、テスト時によくあるエラーを避ける方法が分かるようになります。
2. セットアップ
例ではKotlin 2.xをJVM 21で使用します。テストを実行するには、JUnit 5とAssertJをpom.xmlに追加してください:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
</dependencies>
プロジェクトはKotlinコードをコンパイルするためにkotlin-maven-pluginを備えている必要があります。
3. Object declaration — 言語に組み込まれたシングルトン
トップレベルまたはクラス内でobjectを宣言すると、シングルトンが得られます。Kotlinは自動的に正確に1つのインスタンスを作成します。プライベートコンストラクタ、getInstance()メソッド、またはdouble-checked lockingが必要ありません。
import java.util.concurrent.atomic.AtomicLong
object OrderIdGenerator {
private val counter = AtomicLong(0)
fun next(): String = "ORD-${counter.incrementAndGet()}"
}
fun main() {
println(OrderIdGenerator.next())
println(OrderIdGenerator.next())
}
objectの名前を通じて関数を直接呼び出します:OrderIdGenerator.next()。ここにnewキーワードもコンストラクタ呼び出しもありません。
変数counterはAtomicLongを使用しています。複数のスレッドが同時にnext()を呼び出す可能性があるためです。シングルトンは1つのインスタンスの存在だけを保証します。内部状態を自動的にスレッドセーフにするわけではありません。
ORD-1
ORD-2
3.1. 遅延初期化とスレッドセーフ性
Objectは初回アクセス時に初期化されます。プログラム起動時ではなく:
object AppConfig {
init {
println("AppConfig が初期化されました")
}
val currency = "VND"
}
fun main() {
println("開始")
println(AppConfig.currency)
println(AppConfig.currency)
}
開始
AppConfig が初期化されました
VND
VND
initブロックは1回だけ実行され、currencyを最初に読む直前に実行されます。バイトコードレベルでは、KotlinはAppConfigをpublic static final AppConfig INSTANCEフィールドを持つfinalクラスにコンパイルします。このフィールドはstatic initializerで割り当てられます。
JVMは、クラスのstatic initializerが1回だけ実行され、複数のスレッドが同時にアクセスしても安全であることを保証します。そのため、Kotlinのシングルトンは遅延初期化かつスレッドセーフですが、追加のコードを書く必要がありません。
3.2. Objectはインターフェースを実装できます
Objectは実在の型です。インターフェースを実装でき、他の値のように渡すことができます:
interface DiscountPolicy {
fun apply(amount: Long): Long
}
object NoDiscount : DiscountPolicy {
override fun apply(amount: Long): Long = amount
}
class TenPercentOff : DiscountPolicy {
override fun apply(amount: Long): Long = amount * 90 / 100
}
fun checkout(total: Long, policy: DiscountPolicy): Long = policy.apply(total)
fun main() {
println(checkout(200_000, NoDiscount))
println(checkout(200_000, TenPercentOff()))
}
200000
180000
NoDiscountは状態がないため、複数のインスタンスを作成するのは無駄です。それをobjectとして宣言することは意図を明確に示します:正確に1つのインスタンスだけが必要です。
4. Object expression — 匿名オブジェクト
objectを式の位置に置くと、匿名オブジェクトが作成されます。この使い方はJavaの匿名クラスに似ています:
data class Order(val id: String, val total: Long)
fun main() {
val byTotalDesc = object : Comparator<Order> {
override fun compare(a: Order, b: Order): Int = b.total.compareTo(a.total)
}
val orders = listOf(Order("A", 50), Order("B", 300), Order("C", 120))
println(orders.sortedWith(byTotalDesc).map { it.id })
}
[B, C, A]
重要な違い:object expressionはシングルトンではありません。式が実行されるたびに、新しいインスタンスが作成されます。また、object expressionはその場で初期化され、object declarationのように遅延されません。
supertype がないobjectを作成して、スコープ内に一時的にいくつかの値を集めることもできます:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("平均: ${stats.sum / stats.count}")
}
statsの型は関数内でのみアクセスできるか、private宣言の中でのみアクセスできます。それをpublic関数を通じて外側に返すと、型はAnyにキャストされ、プロパティへのアクセスが失われます。その場合はdata classを使用してください。
注記: 抽象メソッドが1つだけのインターフェースの場合、object expressionの代わりにlambda(SAM変換)を使用してください。例:
Comparator<Order> { a, b -> b.total.compareTo(a.total) }。複数のメソッドをオーバーライドするか、状態を保持する必要がある場合にのみobject expressionを使用してください。
5. Companion object — staticの代わり
時々、インスタンスではなくクラスに関連付けられた関数が必要です。例えばファクトリメソッドです。Javaではstaticを使用します。Kotlinではcompanion objectを使用します:
class Customer private constructor(val email: String, val name: String) {
companion object {
private val EMAIL_REGEX = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$")
fun of(email: String, name: String): Customer {
require(EMAIL_REGEX.matches(email)) { "無効なメール: $email" }
return Customer(email.trim().lowercase(), name.trim())
}
}
}
fun main() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
println("${customer.email} | ${customer.name}")
}
an.nguyen@example.com | Nguyễn An
コードは次のように機能します:
- コンストラクタは
privateであるため、外部ではCustomer.of(...)経由でのみCustomerを作成する必要があります。 - Companion objectは、それを含むクラスのプライベートコンストラクタにアクセスできます。
EMAIL_REGEXは1回だけコンパイルされ、すべての呼び出しで共有されます。
Companion objectは依然として実在のobjectです:クラス内にネストされたシングルトンで、デフォルトの名前はCompanionです。それを含むクラスが読み込まれるときに初期化され、Javaのstatic initializerに相当します。各クラスは最大1つのcompanion objectを持つことができます。
5.1. 命名とインターフェース実装
Objectであるため、companionは独自の名前を持ち、インターフェースを実装できます。これはJavaのstaticではできないことです:
interface Parser<T> {
fun parse(raw: String): T
}
data class Money(val amount: Long, val currency: String) {
companion object Factory : Parser<Money> {
override fun parse(raw: String): Money {
val (amount, currency) = raw.trim().split(" ")
return Money(amount.toLong(), currency)
}
}
}
fun <T> parseAll(parser: Parser<T>, lines: List<String>): List<T> = lines.map(parser::parse)
fun main() {
println(parseAll(Money, listOf("150000 VND", "20 USD")))
}
[Money(amount=150000, currency=VND), Money(amount=20, currency=USD)]
呼び出しparseAll(Money, ...)に注目してください。クラス名Moneyを値として使用すると、Kotlinはそれのcompanion objectだと理解します。こうして、クラスMoney自体がParser<Money>の役割を果たします。
5.2. Companionへのextension関数
そのクラスにcompanionが宣言されていれば、ソースコードを修正することなく、クラスに「static型」の関数を追加できます:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
Companionに名前がない場合、fun Money.Companion.zero(...)と書きます。
6. Javaからobjectとcompanionにアクセスする
デフォルトでは、Javaはobjectとcompanionを通常のインスタンスとして見ます:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
この呼び出し方は冗長で読みにくいです。Kotlinは、Javaコードをより自然に見せるための3つのツールを提供しています:
class PaymentService {
companion object {
const val MAX_RETRY = 3
@JvmField
val SUPPORTED_CURRENCIES = listOf("VND", "USD")
@JvmStatic
fun create(): PaymentService = PaymentService()
fun legacyCreate(): PaymentService = PaymentService()
}
}
Java側は次のように呼び出します:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
各ツールの意味:
const valはプリミティブ型とStringでのみ使用でき、コンパイル時に値が既知である必要があります。定数は使用箇所にインライン化され、public static finalフィールドになります。@JvmFieldはgetterなしでpublic staticフィールドを生成します。このアノテーションはコンパイル時定数ではない値(Listなど)に適しています。@JvmStaticはクラスPaymentService上に追加のstatic メソッドを生成します。このアノテーションのない関数は依然としてCompanion経由で呼び出す必要があります。
プロジェクトがKotlinコードのみで構成されている場合、これらのアノテーションは必要ありません。例外はconst valです:コンパイル時定数はアノテーション内で使用できるため、有用です。例:@Retry(maxAttempts = MAX_RETRY)。
7. data object — ステートレスな状態用
Kotlin 1.9以降、data objectを宣言できます。この使い方はsealedの階層構造でよく見られます:
sealed interface PaymentResult {
data class Success(val transactionId: String) : PaymentResult
data class Failed(val reason: String) : PaymentResult
data object Pending : PaymentResult
}
fun main() {
println(PaymentResult.Pending)
println(PaymentResult.Success("TX-42"))
}
Pending
Success(transactionId=TX-42)
dataを省くと、toString()はPaymentResult$Pending@1b6d3586のような形式を出力し、ログでは読みにくいです。data objectは一貫性のあるequals/hashCodeも生成します。これはobjectが異なるインスタンスにデシリアライズされるときに重要です。
8. テストの作成
Companion objectのファクトリメソッドのテスト:
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
class CustomerTest {
@Test
fun `of がメールと名前を正規化する`() {
val customer = Customer.of("An.Nguyen@Example.com", " Nguyễn An ")
assertThat(customer.email).isEqualTo("an.nguyen@example.com")
assertThat(customer.name).isEqualTo("Nguyễn An")
}
@Test
fun `of がメール無効時に例外をスロー`() {
assertThatThrownBy { Customer.of("not-an-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("not-an-email")
}
@Test
fun `companion が複数行をパース`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object declaration は常に同じインスタンスを返す`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
これらのテストは独立して実行されます。Customer.ofとMoney.parseは状態を保持しないためです。OrderIdGeneratorは異なります。カウンターはJVMのライフサイクル全体にわたって存在します。next() == "ORD-1"をチェックするテストは、テストの実行順序によって成功したり失敗したりします。これが次のセクションで最初に説明するエラーです。
9. よくあるエラーと使用すべきでない場合
Objectが可変状態を保持します。 グローバル状態はテストを相互依存させ、並列実行を困難にします。Objectが状態を必要とする場合は、classに分割し、必要な場所にインスタンスを渡してください(依存性注入)。
注記: Object declarationには、パラメータ付きコンストラクタがありません。シングルトンが設定または別のサービス(例:
DataSourceや支払いゲートウェイのURL)に依存する場合、objectは適切な選択ではありません。
Springアプリケーションでobjectを使用します。 SpringのbeanはデフォルトでApplicationContextスコープ内のシングルトンです。object PaymentServiceと書くと、コンストラクタインジェクション、テストでのmocking、および2つの並行ライフサイクル管理メカニズムが失われます。この場合、@Service class PaymentService(...)を使用してください。純粋な部分、つまり依存関係がない部分(定数、ユーティリティ関数、ステートレスポリシー)にのみobjectを使用してください。
Companion objectをstaticと混同します。 Companionは実在のインスタンスです。Customer.of(...)を呼び出すことは実質的にCustomer.Companion.of(...)を呼び出すことです。このコストはわずかですが、Javaが@JvmStaticを必要とする理由を説明しています。
すべてのユーティリティ関数をcompanionに詰め込みます。 クラスと直接関連のない関数の場合、fun formatVnd(amount: Long): Stringのようなトップレベル関数の方がコンパクトで見つけやすいです。
初期化中の循環依存。 Object Aのinitがobject Bを読み、Bが逆にAを読む場合、non-nullとして宣言されたプロパティについてnullを受け取る可能性があります。Objectの初期化ブロックは非常にシンプルに保ちます。
10. 結論
Kotlinのobjectキーワードには3つの役割があります:
- Object declarationはJVMのクラス初期化メカニズムのおかげで、遅延初期化でスレッドセーフなシングルトンを作成します。
- Object expressionは匿名オブジェクトを作成します。式が実行されるたびに、新しいインスタンスが作成されます。
- Companion objectは
staticを置き換えます。名前を付けたり、インターフェースを実装したり、extension関数を受け取ったりできます。
Javaを扱う場合、const val、@JvmField、@JvmStaticを使用してAPIを自然に見せます。最も重要なルールは、objectを可変状態がない状態に保つことです。依存関係が必要な場合やテストでmockが必要な場合、通常のclassまたはSpring beanの方がいつもより良い選択です。