Українська
Проєктування та перевірка API
Проєктування API: від операцій до варіантності
Розглянемо сервіс, який переносить одне значення між двома контейнерами. Наївне рішення приймає два MutableBox<T>. Воно типобезпечне, але надто обмежує клієнта: джерело котів не можна поєднати з контейнером усіх тварин, хоча конкретна операція лише читає кота й записує його як тварину.
Краща сигнатура виражає напрям доступу: джерело має проєкцію out T, а приймач – in T. Операція не змінює джерело й не читає конкретний T із приймача. Вона отримує саме ті можливості, які потрібні алгоритму, і не більше.
kotlin
class Cell<T>(var value: T)
fun <T> moveValue(from: Cell<out T>, to: Cell<in T>) {
to.value = from.value
}
fun <T> Cell<T>.readValue(): T = value
fun main() {
val source = Cell("Kotlin")
val target = Cell<Any>(0)
moveValue(source, target)
println(target.value)
println(source.readValue().length)
val unknown: Cell<*> = source
println(unknown.value)
// unknown.value = 5 // Навмисно не компілюється.
}text
Kotlin
6
KotlinПроєкція обмежує лише це посилання, а не змінює клас Cell. Код, який усе ще має source: Cell<String>, може змінити рядок через setter. Код із unknown: Cell<*> не знає, який запис був би допустимим. Відсутність права запису не означає, що об’єкт став незмінним для всіх учасників програми.
У великій системі окремі інтерфейси Source і Sink часто кращі за численні проєкції контейнерів. Вони приховують факт зберігання: джерело може отримувати значення з мережі, обчислювати його або читати файл. Проєкція доречна, коли алгоритм справді працює з уже відомим інваріантним типом.
| Контракт | Напрям | Доступна операція |
|---|---|---|
Cell<T> | Інваріантний | Читати й записувати T |
Cell<out T> | Лише виробництво | Читати T |
Cell<in T> | Лише споживання | Записувати T |
Cell<*> | Тип невідомий | Читати як верхню межу |
Таблиця описує спостережувані можливості саме прикладу з var. У складніших типах вкладення функціональних параметрів може змінювати позицію типу. Не намагайтеся вивести правило лише з того, де літера T розташована в рядку: потрібно врахувати сигнатуру кожного вкладеного контракту.
Перевірка контрактів без випадкових приведень
Для стека перевірка двох типів означає повторення однієї поведінкової специфікації: додані A, потім B; вилучені B, потім A; розмір повернувся до нуля; невдала операція не змінила стан. Різні рядки фактичного виводу менш важливі, ніж виконання цих властивостей для кожного параметризованого варіанта.
Для діапазону важливо перевірити рівні межі. Інтервал від5 до5 не є порожнім: він містить5. Для календарних дат аналогічно одна дата може утворити правильний замкнений діапазон. Якщо контракт напіввідкритий, правила вже інші й повинні мати іншу реалізацію або явно названу операцію.
Негативний тест компіляції оформлюють як невеликий окремий файл, що містить одну очікувану помилку. У ньому не повинно бути невідомих імпортів чи синтаксичних помилок, які завадять дійти до перевірки потрібної властивості. Записують команду компіляції, ненульовий код завершення та суть діагностики.
Приклад Cell<String> не можна присвоїти Cell<Any> через інваріантність. Приклад Source<Cat> можна присвоїти Source<Animal> завдяки out. Ці два тести разом доводять, що обмеження не просто забороняє все підряд, а дозволяє саме безпечну взаємодію.
У проєкті не слід залишати закоментовані приведення як «запасний спосіб виправити типи». Якщо компілятор вимагає as, спершу з’ясуйте, яку інформацію загублено й на якій межі. Іноді потрібно зберегти параметр типу в сигнатурі, іноді – додати sealed-результат, а іноді – перевірити зовнішню структуру даних.
Використання JDK 27 не змінює правила стирання аргументів JVM. Версія runtime і модель типів мови – різні частини інструментарію. Приклади курсу компілюються Kotlin 2.4.20 із ціллю JVM 26 і виконуються JDK 27; це потрібно враховувати, відтворюючи команди без IDE.
Узагальнення та предметна тотожність
Параметр типу може позначати не лише тип збереженого значення, а й одиницю вимірювання або іншу предметну категорію. Наприклад, Quantity<Meter> і Quantity<Second> можуть обидва зберігати Double, але залишатися різними статичними типами. Такий параметр часто називають фантомним, якщо його значення не зберігається безпосередньо в об’єкті.
Типобезпечність тут залежить від доступних операцій. Якщо метод додавання приймає Quantity<U>, він не дозволяє змішати різні одиниці. Якщо конструктор і довільне приведення відкриті всюди, клієнт усе ще може неправильно позначити початкові дані. Тому типи допомагають зберегти вже встановлений зміст, але не можуть вгадати, в яких одиницях користувач увів число.
kotlin
sealed interface UnitTag
object Meter : UnitTag
object Second : UnitTag
data class Quantity<U : UnitTag>(val value: Double) {
init { require(value.isFinite()) }
operator fun plus(other: Quantity<U>): Quantity<U> =
Quantity(value + other.value)
}
fun main() {
val first = Quantity<Meter>(2.0)
val second = Quantity<Meter>(3.0)
println((first + second).value)
val time = Quantity<Second>(4.0)
println(time.value)
// first + time // Навмисна помилка різних одиниць.
}text
5.0
4.0Конструктор результату перевіряє скінченність, тому переповнена сума відхиляється тим самим інваріантом. При цьому клас не розв’язує всі задачі фізичних величин: множення довжини на час потребує іншого типу результату, а перетворення одиниць – явного коефіцієнта й політики числової похибки.
Слід також обережно трактувати згенеровану рівність data-класу. На JVM аргументи узагальнення стерті, тому не варто будувати предметну перевірку одиниць на equals двох об’єктів, уже перетворених на Any. Якщо одиницю треба розрізняти під час виконання, вона повинна мати явне runtime-подання в моделі.
Цей приклад показує межу відповідальності статичної системи типів. Вона забороняє неправильне додавання у типізованому коді, але зовнішній текст, файл або загальний реєстр Any потребує окремого відновлення предметного змісту. На цій межі доречні конструктори, фабрики та перевірені декодери.