Object, Companion Object und Singleton in Kotlin
1. Übersicht
Kotlin hat kein Schlüsselwort static. Stattdessen bietet die Sprache das Schlüsselwort object mit drei Verwendungsarten: Object Declaration, Object Expression und Companion Object.
Dieser Artikel erklärt jede Verwendungsart, wie Kotlin sie in JVM-Bytecode übersetzt, wie du sie von Java aus aufrufen kannst und wann du sie nicht verwenden solltest. Am Ende wirst du wissen, wie du threadsichere Singletons erstellst, elegante Factory-Methoden schreibst und häufige Fehler beim Testen vermeidest.
2. Einrichtung
Die Beispiele verwenden Kotlin 2.x auf JVM 21. Um Tests auszuführen, fügst du JUnit 5 und AssertJ zu pom.xml hinzu:
<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>
Das Projekt muss bereits das kotlin-maven-plugin haben, um Kotlin-Code zu kompilieren.
3. Object Declaration – Singletons, die in der Sprache eingebaut sind
Wenn du ein object auf Top-Level-Ebene oder verschachtelt in einer Klasse deklarierst, erhältst du einen Singleton. Kotlin erstellt automatisch genau eine Instanz davon. Du brauchst keinen privaten Konstruktor, keine getInstance()-Methode und kein 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())
}
Du rufst die Funktion direkt über den Namen des Objects auf: OrderIdGenerator.next(). Hier gibt es kein new-Schlüsselwort und auch keinen Konstruktor-Aufruf.
Die Variable counter verwendet AtomicLong, weil mehrere Threads next() gleichzeitig aufrufen können. Das Singleton garantiert nur, dass es genau eine Instanz gibt. Es macht den inneren Zustand nicht automatisch threadsicher.
ORD-1
ORD-2
3.1. Lazy Initialization und Thread-Sicherheit
Das Object wird beim ersten Zugriff initialisiert, nicht beim Programmstart:
object AppConfig {
init {
println("AppConfig wurde initialisiert")
}
val currency = "VND"
}
fun main() {
println("Start")
println(AppConfig.currency)
println(AppConfig.currency)
}
Start
AppConfig wurde initialisiert
VND
VND
Der init-Block wird nur einmal ausgeführt, kurz vor dem ersten Zugriff auf currency. In Bezug auf Bytecode kompiliert Kotlin AppConfig in eine final-Klasse mit einem Feld public static final AppConfig INSTANCE. Dieses Feld wird im static Initializer zugewiesen.
Die JVM garantiert, dass der static Initializer einer Klasse nur einmal läuft und threadsicher ist, wenn mehrere Threads gleichzeitig darauf zugreifen. Deshalb ist das Singleton von Kotlin sowohl lazy als auch threadsicher, ohne dass du zusätzliche Zeilen schreiben musst.
3.2. Objects können erben und Interfaces implementieren
Ein Object ist ein echter Typ. Es kann Interfaces implementieren und wird wie jeder andere Wert übergeben:
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 hat keinen Zustand, daher ist es verschwenderisch, mehrere Instanzen zu erstellen. Die Deklaration als object macht die Absicht klar: Es wird genau eine Instanz benötigt.
4. Object Expression – anonyme Objekte
Wenn du object an der Stelle eines Ausdrucks platzierst, erstellt du ein anonymes Objekt. Diese Verwendung ähnelt anonymen Klassen in 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]
Wichtiger Unterschied: Object Expressions sind keine Singletons. Jedes Mal, wenn der Ausdruck ausgeführt wird, wird eine neue Instanz erstellt. Außerdem wird die Object Expression sofort initialisiert, nicht lazy wie eine Object Declaration.
Du kannst auch ein Object ohne Supertyp erstellen, um vorübergehend ein paar Werte in lokalem Scope zu sammeln:
fun main() {
val stats = object {
val count = 3
val sum = 470L
}
println("Durchschnitt: ${stats.sum / stats.count}")
}
Der Typ von stats ist nur innerhalb der Funktion oder in einer private-Deklaration zugänglich. Wenn du es über eine public-Funktion zurückgibst, wird der Typ zu Any gecastet und du verlierst Zugriff auf die Properties. In diesem Fall solltest du data class verwenden.
Hinweis: Für ein Interface mit nur einer abstrakten Methode solltest du stattdessen Lambda (SAM-Konvertierung) verwenden, z. B.
Comparator<Order> { a, b -> b.total.compareTo(a.total) }. Nutze Object Expressions nur, wenn du mehrere Methoden überschreiben musst oder Zustand halten möchtest.
5. Companion Object – der Ersatz für static
Manchmal brauchst du eine Funktion, die an die Klasse gebunden ist, nicht an eine Instanz, zum Beispiel eine Factory-Methode. In Java würdest du static verwenden. In Kotlin verwendest du 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)) { "E-Mail ungültig: $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
Der obige Code funktioniert wie folgt:
- Der Konstruktor ist
private, daher mussCustomervon außen überCustomer.of(...)erstellt werden. - Das Companion Object kann auf den privaten Konstruktor der Klasse zugreifen, die es enthält.
EMAIL_REGEXwird nur einmal kompiliert und für alle Aufrufe geteilt.
Das Companion Object ist immer noch ein echtes Object: ein Singleton, das in der Klasse verschachtelt ist und standardmäßig Companion heißt. Es wird initialisiert, wenn die Klasse geladen wird, äquivalent zu einem static Initializer in Java. Jede Klasse kann maximal ein Companion Object haben.
5.1. Benennung und Interface-Implementierung
Da es ein Object ist, kann das Companion Object benannt werden und Interfaces implementieren. Das ist etwas, das static in Java nicht kann:
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)]
Beachte den Aufruf parseAll(Money, ...). Wenn du den Klassennamen Money als Wert verwendest, versteht Kotlin, dass es das Companion Object davon ist. Dadurch spielt die Klasse Money selbst die Rolle eines Parser<Money>.
5.2. Extension-Funktionen auf Companion
Du kannst eine „statische” Funktion zu einer Klasse hinzufügen, ohne deren Quellcode zu ändern, solange die Klasse ein Companion Object deklariert:
fun Money.Factory.zero(currency: String): Money = Money(0, currency)
fun main() {
println(Money.zero("VND"))
}
Money(amount=0, currency=VND)
Wenn das Companion Object nicht benannt ist, schreibst du fun Money.Companion.zero(...).
6. Objects und Companion Objects von Java aus aufrufen
Standardmäßig sieht Java Objects und Companion Objects als normale Instanzen:
String id = OrderIdGenerator.INSTANCE.next();
Customer c = Customer.Companion.of("a@b.com", "An");
Diese Aufrufe sind wortreich und schwer lesbar. Kotlin bietet drei Werkzeuge, um Java-Code natürlicher aussehen zu lassen:
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()
}
}
Die Java-Seite ruft es wie folgt auf:
int retry = PaymentService.MAX_RETRY;
List<String> currencies = PaymentService.SUPPORTED_CURRENCIES;
PaymentService a = PaymentService.create();
PaymentService b = PaymentService.Companion.legacyCreate();
Die Bedeutung jedes Werkzeugs:
const valfunktioniert nur mit primitiven Typen undStringmit Werten, die zur Compile-Zeit bekannt sind. Die Konstante wird an der Verwendungsstelle eingebettet und wird zu einempublic static final-Feld.@JvmFielderzeugt ein öffentliches static-Feld, keinen Getter. Diese Annotation passt zu Werten, die keine Compile-Zeit-Konstanten sind, wieList.@JvmStaticerzeugt zusätzlich eine static-Methode auf der KlassePaymentService. Funktionen ohne diese Annotation müssen überCompanionaufgerufen werden.
Wenn das Projekt nur Kotlin-Code hat, brauchst du diese Annotationen nicht. Ausnahme ist const val: Es ist immer noch nützlich, weil der Wert in Annotationen verwendet werden kann, z. B. @Retry(maxAttempts = MAX_RETRY).
7. data object für Zustände ohne Daten
Ab Kotlin 1.9 kannst du data object deklarieren. Dieses Konstrukt sieht man häufig in Sealed Hierarchies:
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)
Wenn du data weglässt, gibt toString() PaymentResult$Pending@1b6d3586 aus, was in Logs sehr schwer lesbar ist. data object erzeugt auch konsistente equals/hashCode-Implementierungen. Das ist wichtig, wenn das Object zu einer anderen Instanz deserialisiert wird.
8. Schreiben von Tests
Test für eine Factory-Methode in einem 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 normalisiert E-Mail und Namen`() {
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 wirft Fehler bei ungültiger E-Mail`() {
assertThatThrownBy { Customer.of("khong-phai-email", "An") }
.isInstanceOf(IllegalArgumentException::class.java)
.hasMessageContaining("khong-phai-email")
}
@Test
fun `companion parsed mehrere Zeilen`() {
val result = parseAll(Money, listOf("100 VND"))
assertThat(result).containsExactly(Money(100, "VND"))
}
@Test
fun `object declaration gibt immer die gleiche Instanz zurück`() {
val first: DiscountPolicy = NoDiscount
val second: DiscountPolicy = NoDiscount
assertThat(first).isSameAs(second)
}
}
Diese Tests laufen unabhängig, da Customer.of und Money.parse keinen Zustand halten. Bei OrderIdGenerator ist es anders: der Zähler lebt über die gesamte JVM-Lebensdauer. Ein Test, der next() == "ORD-1" prüft, ist wahr oder falsch, je nachdem in welcher Reihenfolge die Tests ausgeführt werden. Dies ist genau der erste Fehler im nächsten Abschnitt.
9. Häufige Fehler und wann du Objects nicht verwenden solltest
Objects halten veränderlichen Zustand. Ein globaler Zustand macht Tests voneinander abhängig und erschwert die parallele Ausführung. Wenn das Object Zustand benötigt, teile es in eine class auf und übergebe die Instanz an die Orte, wo sie gebraucht wird (Dependency Injection).
Hinweis: Object Declarations haben keine parametrisierten Konstruktoren. Wenn der Singleton von einer Konfiguration oder einem anderen Service abhängt, wie z. B.
DataSourceoder der URL eines Payment Gateways, istobjectkeine geeignete Wahl.
Verwendung von object in einer Spring-Anwendung. Beans in Spring sind standardmäßig bereits Singletons im ApplicationContext-Scope. Das Schreiben von object PaymentService kostet dich Constructor Injection, erschwert das Mocking in Tests und erzeugt zwei parallele Lebenszyklusverwaltungsmechanismen. Verwende in diesem Fall @Service class PaymentService(...). Verwende object nur für reine, unabhängige Teile: Konstanten, Hilfsfunktionen, zustandslose Policies.
Companion Object mit static verwechseln. Companion Objects sind echte Instanzen. Der Aufruf Customer.of(...) ist tatsächlich ein Aufruf von Customer.Companion.of(...). Der Aufwand dafür ist vernachlässigbar, aber es erklärt, warum Java @JvmStatic benötigt.
Alle Hilfsfunktionen in Companion packen. Für Funktionen, die nicht direkt mit der Klasse zusammenhängen, ist eine Top-Level-Funktion wie fun formatVnd(amount: Long): String prägnanter und einfacher zu finden.
Zirkuläre Abhängigkeiten bei der Initialisierung. Wenn der init von Object A Object B liest, während B A liest, könntest du null für eine als nicht nullable deklarierte Property erhalten. Halte den Initialisierungsblock des Objects ganz einfach.
10. Fazit
Das Schlüsselwort object in Kotlin hat drei Rollen:
- Object Declaration erstellt ein lazy und threadsicheres Singleton dank des Klassinitialisierungsmechanismus der JVM.
- Object Expression erstellt ein anonymes Object. Jede Ausführung des Ausdrucks ist eine neue Instanz.
- Companion Object ersetzt
static. Es kann benannt werden, Interfaces implementieren und Extension-Funktionen erhalten.
Wenn du mit Java arbeitest, verwendest du const val, @JvmField und @JvmStatic, um die API natürlich aussehen zu lassen. Die wichtigste Regel ist, dass Objects keinen veränderlichen Zustand haben. Wenn du Dependencies benötigst oder wenn du mocken möchtest, ist eine normale class oder ein Spring Bean immer die bessere Wahl.