expect/actual — це механізм Kotlin Multiplatform, який дозволяє оголошувати платформозалежні API в спільному коді. Ключове слово expect створює контракт функції, класу або властивості в commonMain, а ключове слово actual надає конкретну реалізацію для кожної платформи. Компілятор перевіряє, що кожному оголошенню expect відповідає реалізація actual на всіх цільових платформах. За даними JetBrains, 2025, цей механізм використовується в 80% KMM-проєктів для реалізації платформенної бізнес-логіки.
Головне
expect/actual — це декларативний механізм Kotlin Multiplatform для реалізації платформоорієнтованого програмування. Він дозволяє описати API одного разу в спільному модулі (expect) та реалізувати її окремо для кожної платформи (actual). На відміну від інтерфейсів, expect/actual не створює віртуальних викликів — компілятор пов’язує оголошення expect та actual на етапі компіляції, що усуває накладні витрати на динамічну диспетчеризацію.
Історія expect/actual почалася з появою Kotlin Multiplatform у 2017 році. Спочатку механізм називався expect/actual declarations і був експериментальним. У Kotlin 1.2 були додані анотації expect, а в Kotlin 1.3 expect/actual став стабільним для класів та функцій. З часом механізм розширювався: Kotlin 1.6 додав підтримку expect/actual для companion-об’єктів, Kotlin 1.7 — для enum-класів, а Kotlin 2.0 — для typealias.
Ключова особливість expect/actual — це безпека на рівні компіляції. Якщо розробник додає оголошення expect в commonMain, але забуває надати реалізацію actual для iOS, компілятор створить помилку. Це запобігає помилкам часу виконання, характерним для підходів з рефлексією або динамічним завантаженням платформенного коду.
Механізм expect/actual працює на рівні source set — системи модулів Kotlin Multiplatform. Спільний код, доступний для всіх платформ, знаходиться в source set commonMain. Платформозалежний код знаходиться в iosMain, androidMain, macosMain тощо. Ключове слово expect в commonMain оголошує API, а ключове слово actual в платформенному source set надає реалізацію. Компілятор пов’язує їх на етапі генерації коду, замінюючи виклик функції expect на відповідну реалізацію actual для цільової платформи.
Єрархія source set у типовому KMM-проєкті виглядає так: commonMain містить оголошення expect, iosMain та androidMain містять реалізації actual. При компіляції для iOS використовується actual з iosMain, при компіляції для Android — actual з androidMain. Source set можуть бути проміжними (наприклад, iosArm64Main для конкретної архітектури), що дозволяє уточнювати реалізації для різних пристроїв.
// commonMain — expect declaration
expect fun getPlatformName(): String
// androidMain — actual for Android
actual fun getPlatformName(): String = "Android"
// iosMain — actual for iOS
actual fun getPlatformName(): String = "iOS"
Компілятор Kotlin перевіряє кілька умов при роботі з expect/actual. Кожне оголошення expect повинно мати actual-реалізацію для кожної активної платформи. Сигнатура actual-оголошення повинна збігатися з сигнатурою expect (анотація @OptionalExpectation може пом’якшувати цю вимогу). Модифікатори доступу, тип повернення та параметри повинні бути ідентичними. Компілятор також перевіряє відсутність циклічних залежностей між оголошеннями expect та actual.
expect/actual підтримує кілька типів оголошень. Найчастіше використовуються функції expect/actual для платформенних операцій, класи expect/actual для об’єктів, що потребують нативної реалізації, та властивості expect/actual для констант та налаштувань. Кожен тип має свої правила використання та обмеження.
Функції expect/actual — найпростіший та найпоширеніший тип. Вони використовуються для виклику платформенних API, таких як отримання часу, читання файлів або надсилання HTTP-запитів. Класи expect/actual використовуються для створення об’єктів, які безпосередньо взаємодіють з нативним кодом (наприклад, для доступу до камери, геолокації або сховища ключів). Властивості expect/actual (val) підходять для платформенних констант — назва OS, версія SDK або шлях до системної директорії.
| Тип оголошення | Ключові слова | Приклад використання |
|---|---|---|
| Функція | expect fun / actual fun | Отримання унікального ідентифікатора пристрою |
| Клас | expect class / actual class | Доступ до SecureStorage (Keychain / EncryptedSharedPreferences) |
| Властивість | expect val / actual val | Поточна платформа (iOS / Android) |
| Enum-клас | expect enum / actual enum | Список доступних дозволів додатка |
| Typealias | expect typealias / actual typealias | Тип відповіді мережі, що залежить від платформи |
Не всі конструкції Kotlin можуть бути використані з expect/actual. Оголошення expect не може містити тіло — лише сигнатуру. Клас expect не може мати конструктор з параметрами (повиннен мати порожній первинний конструктор). Для enum expect/actual всі константи повинні бути однаковими в expect та actual. Властивості expect повинні бути val (не var), оскільки зберігання стану в спільному модулі для платформенних властивостей не має сенсу.
Розглянемо практичні приклади expect/actual від простих функцій до повноцінних класів. Базовий випадок — отримання назви платформи для використання в інтерфейсі користувача. Складніші приклади включають доступ до нативного сховища та роботу з платформенними потоками.
// commonMain — expect class for secure storage
expect class PlatformStorage {
fun save(key: String, value: String)
fun get(key: String): String?
fun remove(key: String)
}
// androidMain — actual on Android
actual class PlatformStorage {
private val prefs = AppContext.getSharedPreferences("secure", 0)
actual fun save(key: String, value: String) { prefs.edit().putString(key, value).apply() }
actual fun get(key: String): String? = prefs.getString(key, null)
actual fun remove(key: String) { prefs.edit().remove(key).apply() }
}
У цьому прикладі клас expect PlatformStorage визначає контракт простого сховища ключ-значення. На Android реалізація використовує SharedPreferences, а на iOS — Keychain або NSUserDefaults. Завдяки expect/actual, бізнес-логіка в commonMain викликає save/get/remove, не знаючи про платформенну реалізацію.
// iosMain — actual on iOS with Keychain
actual class PlatformStorage {
actual fun save(key: String, value: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecValueData to value.encodeToByteArray()
)
SecItemAdd(query, null)
}
actual fun get(key: String): String? {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key,
kSecReturnData to true
)
val result = mutableMapOf<String, Any>()
return if (SecItemCopyMatching(query, result) == errSecSuccess)
result[kSecValueData]?.toString()
else null
}
actual fun remove(key: String) {
val query = mapOf<String, Any>(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to key
)
SecItemDelete(query)
}
}
При проектуванні API expect/actual слід дотримуватися кількох принципів. Мінімізуйте кількість оголошень expect — чим більше спільного коду, тим простіше обслуговування. Використовуйте expect/actual лише для API, які дійсно відрізняються на різних платформах. Для іншого коду використовуйте інтерфейси з фабриками або ін’єкцією залежностей, що спрощує тестування.
Рекомендується групувати оголошення expect за тематичними модулями, а не змішувати їх в одному файлі. Наприклад, Storage.kt для оголошень expect щодо сховища, Platform.kt для функцій expect для роботи з OS та Analytics.kt для класів expect аналітики. Це спрощує навігацію та розуміння платформенної поверхні KMM-проєкту. Кожен actual-файл повинен знаходитися в відповідному source set: androidMain, iosMain, desktopMain тощо.
Типові реалізації через expect fun з actual fun, де actual використовує спільний код, — це поширений антипатерн. Якщо платформенна реалізація не відрізняється від типової, expect/actual не потрібен. У таких випадках використовуйте просту функцію в commonMain. Також уникайте expect/actual для тривіальних гетерів — використовуйте expect val з константами.
Правильна структура коду expect/actual критична для читабельності проєкту. Кожен модуль expect/actual повинен мати єдину точку входу. Приклад організації: commonMain/kotlin/com/project/platform містить оголошення expect, androidMain/kotlin/com/project/platform містить actual для Android, iosMain/kotlin/com/project/platform містить actual для iOS. Імена файлів та пакетів повинні збігатися для expect та actual, щоб розробник міг швидко знайти відповідну реалізацію.
Інтерфейси з платформенною фабрикою — основна альтернатива expect/actual. Замість класу expect ви можете оголосити інтерфейс в commonMain та створити конкретні класи в платформенних модулях. Фабрика або контейнер ін’єкції залежностей надає правильну реалізацію під час виконання. Цей підхід краще підходить для тестування, оскільки інтерфейс можна замокати.
Ін’єкція залежностей (Koin, Kodein) — більш гнучкий, але менш ефективний підхід. Контейнер DI налаштовується окремо для кожної платформи та надає платформенні залежності спільному коду. На відміну від expect/actual, ін’єкція відбувається під час виконання, що дозволяє замінювати реалізації для тестування. З іншого боку, помилки конфігурації DI виявляються лише під час виконання, а не на етапі компіляції.
| Підхід | Перевірка на компіляції | Гнучкість тестування | Накладні витрати на виконанні |
|---|---|---|---|
| expect/actual | Повна | Низька (actual не можна замокати) | Нульові (компіляційний зв’язок) |
| Інтерфейси + Фабрика | Часткова | Висока (можна замокати) | Мінімальні (віртуальний виклик) |
| Ін’єкція залежностей | Ні (виконання) | Висока | Середні (DI-проксі) |
Вибір між expect/actual та альтернативами залежить від контексту. Для критичного для продуктивності коду (ігрові рушії, обробка в реальному часі) expect/actual краще через нульові накладні витрати. Для бізнес-логіки (репозиторії, use-case) краще використовувати інтерфейси з DI, щоб спростити тестування. Комбінований підхід — expect/actual для низькорівневих платформенних операцій та інтерфейси для рівня бізнес-логіки — використовується в більшості виробничих KMM-проєктів.
Часті запитання
expect/actual пов’язує реалізацію на етапі компіляції без віртуальних викликів, а інтерфейси — під час виконання. expect/actual гарантує наявність реалізації для всіх платформ, інтерфейси потребують перевірок під час виконання.
Так, expect enum підтримується починаючи з Kotlin 1.7. Всі константи в expect та actual enum повинні збігатися. Різні значення констант на різних платформах — помилка компіляції.
Компілятор створить помилку для кожної платформи, де відсутня actual-реалізація. Проєкт не збереться, доки для всіх оголошень expect не будуть додані відповідні actual-реалізації.
Ні, expect та actual повинні знаходитися в різних source setах. expect в commonMain або проміжному source set, actual в платформенному source set. Розміщення expect та actual в одному source set — помилка компіляції.
Для тестування expect/actual використовуйте commonTest з платформенними тестовими source setами. Напишіть тести expect в commonTest та тести actual для кожної платформи. Інтеграційні тести запускаються окремо на кожній цільовій платформі.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.