Variance in Kotlin verstehen
Variance erklärt, wie generische Typen sicher zusammenpassen. Du lernst in und out für Android-APIs und Dependency-Grenzen.
Variance ist ein Kotlin-Konzept für generische Typen. Es beantwortet eine Frage, die in Android-Projekten ständig auftaucht: Darf ein Typ wie List<User> dort verwendet werden, wo List<Person> erwartet wird? Die Antwort hängt davon ab, ob aus einem Typ vor allem gelesen oder in ihn hineingeschrieben wird. Wenn du dieses Prinzip verstehst, werden Collections, Repository-Schnittstellen und Dependency-Grenzen deutlich nachvollziehbarer.
Was ist das?
Variance beschreibt, wie sich generische Typen bei Vererbung verhalten. Stell dir eine einfache Klassenhierarchie vor: User ist eine Unterklasse von Person. Dann ist ein einzelner User auch eine Person. Bei generischen Typen ist das aber nicht automatisch so. Ein Box<User> ist nicht immer sicher als Box<Person> verwendbar, weil die Box vielleicht auch neue Person-Objekte aufnehmen kann, die keine User sind.
Kotlin zwingt dich deshalb, genauer über die Richtung deiner API nachzudenken. Produziert ein Typ Werte, die du nur herausliest? Dann ist er ein Producer. Konsumiert ein Typ Werte, die du hineinreichst? Dann ist er ein Consumer. Die Schlüsselwörter out und in drücken genau diese Richtung aus.
out bedeutet: Dieser generische Typ liefert Werte eines Typs nach außen. Du darfst ihn sicher allgemeiner lesen. Das bekannteste Beispiel ist List<out T> im Denken, auch wenn die Standardbibliothek die Details intern sauber definiert. Eine Liste von User kann als Liste von Person gelesen werden, weil jedes Element garantiert mindestens eine Person ist.
in bedeutet: Dieser generische Typ nimmt Werte eines Typs entgegen. Du darfst ihn sicher mit spezielleren Werten füttern, wenn die Schnittstelle einen allgemeineren Typ akzeptiert. Das wirkt am Anfang ungewohnt, ist aber bei Callbacks, Mappern, Validatoren und Schreibern sehr nützlich.
Für Android ist Variance kein akademisches Detail. Kotlin ist die Standardsprache für moderne Android-Entwicklung, und du triffst generische Typen überall: bei List, Flow, Result, Repository-Interfaces, Use Cases, Compose-State und Test-Doubles. Variance hilft dir, APIs so zu entwerfen, dass sie flexibel, aber trotzdem typsicher bleiben.
Wie funktioniert es?
Das mentale Modell ist kurz: Producer verwenden out, Consumer verwenden in. Ein Producer gibt dir Daten. Ein Consumer nimmt Daten an. Wenn ein Typ beides macht, also Werte eines generischen Typs sowohl herausgibt als auch entgegennimmt, ist Variance meist nicht möglich oder muss sehr vorsichtig begrenzt werden.
Ein Producer-Beispiel ist eine Datenquelle:
interface DataSource<out T> {
suspend fun load(): T
}
DataSource<out T> sagt: Diese Schnittstelle produziert Werte vom Typ T. Sie gibt T zurück, nimmt aber kein T als Parameter entgegen. Wenn RemoteUserSource eine DataSource<User> ist, kannst du sie an Code übergeben, der eine DataSource<Person> erwartet. Das ist sicher, denn ein geladener User kann als Person behandelt werden.
Würdest du in diese Schnittstelle eine Funktion wie fun save(value: T) einbauen, wäre out T nicht mehr erlaubt. Warum? Weil die Schnittstelle dann nicht nur produziert, sondern auch konsumiert. Ein Code, der eine DataSource<Person> erwartet, dürfte dann theoretisch eine beliebige Person speichern. Wenn die konkrete Implementierung aber eigentlich nur User versteht, wäre das unsicher. Kotlin verhindert diesen Fehler schon beim Kompilieren.
Ein Consumer-Beispiel sieht anders aus:
interface Sink<in T> {
fun accept(value: T)
}
Sink<in T> sagt: Diese Schnittstelle konsumiert Werte vom Typ T. Sie nimmt T entgegen, gibt aber kein konkretes T zurück. Ein Sink<Person> kann dort verwendet werden, wo ein Sink<User> gebraucht wird. Das ist sicher, weil ein Consumer, der jede Person akzeptiert, auch einen User akzeptieren kann.
Diese Richtung ist für Anfänger oft der schwierigste Teil. Bei out geht die Vererbung gefühlt mit: Producer<User> passt zu Producer<Person>. Bei in läuft sie entgegengesetzt: Consumer<Person> passt zu Consumer<User>. Der Grund ist nicht Syntax, sondern Sicherheit. Es zählt, was die API mit dem Typ macht.
Kotlin kennt außerdem Use-Site Variance. Du kannst also auch an einer konkreten Stelle sagen, wie ein Typ verwendet werden soll:
fun printNames(people: List<out Person>) {
people.forEach { println(it.name) }
}
Bei List brauchst du das in der Praxis selten explizit, weil Kotlin-Collections bereits entsprechend modelliert sind. Trotzdem ist die Idee wichtig: Wenn du aus einer Struktur nur liest, kannst du sie oft flexibler typisieren. Wenn du in eine Struktur schreibst, brauchst du engere Regeln.
Ein weiteres wichtiges Stichwort ist Invarianz. Ein generischer Typ ohne in oder out ist invariant. Das bedeutet: MutableList<User> ist nicht automatisch eine MutableList<Person>. Das schützt dich vor Fehlern. Wenn das erlaubt wäre, könntest du in eine MutableList<User> plötzlich eine Person einfügen, die kein User ist. Später würde Code aus der Liste ein User erwarten und bekäme einen falschen Typ. Kotlin blockiert diesen Weg.
In Android begegnet dir dieser Mechanismus besonders an Architekturgrenzen. Die Data Layer liefert häufig Datenmodelle, etwa aus einer Datenbank, einem Netzwerk oder einem Cache. Ein Repository produziert Domain-Daten für ViewModels. Ein Logger, Analytics-Tracker oder Validator konsumiert dagegen Ereignisse oder Modelle. Je klarer du erkennst, ob eine Schnittstelle produziert oder konsumiert, desto klarer wird ihr generischer Typ.
In der Praxis
Nimm ein kleines Beispiel aus einer Android-App. Du hast Personen im Domain-Modell und eine spezielle Benutzerklasse:
open class Person(
val id: String,
val name: String
)
class User(
id: String,
name: String,
val premium: Boolean
) : Person(id, name)
Ein Repository, das Daten an ein ViewModel liefert, ist ein Producer. Es sollte Werte bereitstellen, nicht beliebige Werte von außen entgegennehmen:
interface Repository<out T> {
suspend fun getById(id: String): T
fun observeAll(): kotlinx.coroutines.flow.Flow<List<T>>
}
class UserRepository : Repository<User> {
override suspend fun getById(id: String): User {
return User(id = id, name = "Mina", premium = true)
}
override fun observeAll(): kotlinx.coroutines.flow.Flow<List<User>> {
return kotlinx.coroutines.flow.flowOf(
listOf(User("1", "Mina", true))
)
}
}
class PeopleViewModel(
private val repository: Repository<Person>
) {
suspend fun loadName(id: String): String {
return repository.getById(id).name
}
}
Durch Repository<out T> kann UserRepository dort eingesetzt werden, wo Repository<Person> erwartet wird. Das ViewModel braucht nur die Eigenschaften von Person. Es muss nicht wissen, ob die konkrete Datenquelle User, AdminUser oder eine andere Unterklasse liefert. So entsteht eine saubere Dependency-Grenze: Das ViewModel hängt an einer allgemeineren Sicht, während die Data Layer konkrete Typen produzieren darf.
Die typische Stolperfalle kommt, sobald du Schreiben und Lesen in dasselbe Interface packst:
interface BadRepository<out T> {
suspend fun getById(id: String): T
// Kompiliert nicht mit out T:
// suspend fun save(value: T)
}
Das ist kein störender Compiler-Zwang, sondern ein Design-Hinweis. Dein Interface hat zwei Verantwortungen vermischt. Ein lesendes Repository ist ein Producer. Ein speichernder Typ ist ein Consumer. Trenne sie, wenn du Variance nutzen willst:
interface Reader<out T> {
suspend fun getById(id: String): T
}
interface Writer<in T> {
suspend fun save(value: T)
}
class PersonWriter : Writer<Person> {
override suspend fun save(value: Person) {
println("Speichere ${value.name}")
}
}
suspend fun saveUser(writer: Writer<User>, user: User) {
writer.save(user)
}
Hier kann ein Writer<Person> als Writer<User> verwendet werden, weil ein Writer für Personen auch Benutzer speichern kann. Das ist der Kern von in.
In Compose und ViewModels zeigt sich dasselbe Prinzip oft indirekt. Ein StateFlow<List<User>> produziert Zustände für die UI. Die UI liest diese Werte und rendert sie. Du solltest diesen Datenstrom nicht als allgemeine, schreibbare Struktur behandeln. In der Regel hält das ViewModel intern eine veränderbare Quelle und gibt nach außen nur eine lesende Sicht frei:
class UsersViewModel : androidx.lifecycle.ViewModel() {
private val _users = kotlinx.coroutines.flow.MutableStateFlow<List<User>>(emptyList())
val users: kotlinx.coroutines.flow.StateFlow<List<User>> = _users
fun replaceUsers(newUsers: List<User>) {
_users.value = newUsers
}
}
Auch wenn dieses Beispiel nicht direkt in oder out in der Signatur zeigt, ist die Denkweise identisch: Nach außen soll die UI Zustände konsumieren, aber nicht die interne Quelle verändern. Variance und API-Design treffen sich hier. Du definierst bewusst, wer produzieren darf und wer nur lesen soll.
Eine praktische Entscheidungsregel lautet: Wenn deine generische Schnittstelle T nur als Rückgabetyp nutzt, prüfe out T. Wenn sie T nur als Parametertyp annimmt, prüfe in T. Wenn sie beides macht, bleibe invariant oder trenne die Schnittstelle in zwei kleinere Rollen. Diese Regel hilft besonders bei Repository-Abstraktionen, Mappern, Test-Fakes und Dependency Injection.
Ein Mapper ist ein gutes Beispiel für gemischte Rollen:
interface Mapper<in Input, out Output> {
fun map(value: Input): Output
}
class UserToNameMapper : Mapper<User, String> {
override fun map(value: User): String = value.name
}
Der Input ist ein Consumer-Typ, deshalb in. Der Output ist ein Producer-Typ, deshalb out. Diese Signatur sagt sehr präzise, wie der Mapper verwendet werden darf. In größeren Android-Projekten reduziert das unnötige Casts und macht Tests leichter, weil Test-Doubles mit allgemeineren oder spezielleren Typen besser einsetzbar sind.
Beim Debuggen hilft dir der Compiler. Wenn du eine Fehlermeldung zu out-projected type, in-projected type oder einem verbotenen Zugriff bekommst, lies sie nicht nur als Syntaxproblem. Frage zuerst: Versuche ich gerade, in einen Producer zu schreiben? Oder versuche ich, aus einem Consumer einen konkreten Wert herauszulesen? Genau dort liegt meist der Denkfehler.
In Code-Reviews lohnt sich eine kurze Prüfung: Hat ein Interface wirklich die richtige Richtung? Ein Provider, Source, Reader oder Repository ist oft ein Producer. Ein Logger, Tracker, Writer, Validator oder Sink ist oft ein Consumer. Namen sind nicht beweisend, aber sie zeigen dir, welche Rolle die Schnittstelle wahrscheinlich haben sollte.
Fazit
Variance macht generische Kotlin-APIs sicherer und flexibler, wenn du die Rollen sauber trennst: out für Producer, in für Consumer, keine Varianz bei gemischter Verantwortung. Übe das an kleinen Interfaces in deinem Android-Projekt: Markiere lesende Schnittstellen mit out, schreibende mit in, und beobachte, welche Compilerfehler verschwinden oder neu auftauchen. Prüfe anschließend in Tests oder im Code-Review, ob deine ViewModels, Repositories und Mapper wirklich nur die Richtung erlauben, die ihre Aufgabe braucht.