Рекомендации по обратной совместимости для авторов библиотек
Чаще всего библиотеку создают, чтобы предоставить функциональность более широкому сообществу. Это сообщество может состоять из одной команды, компании, представителей определённой отрасли или участников технологической платформы. В любом случае важно учитывать обратную совместимость. Чем шире сообщество, тем важнее обратная совместимость, поскольку вы будете хуже представлять, кто ваши пользователи и с какими ограничениями они работают.
Обратная совместимость — это не одно понятие: её можно определять на бинарном уровне, на уровне исходного кода и на поведенческом уровне. Подробнее об этих видах совместимости рассказывается в этом разделе.
Обратите внимание:
Нарушить бинарную совместимость можно, не нарушив совместимость исходного кода, и наоборот.
Желательно гарантировать совместимость исходного кода, но сделать это очень сложно. Как автор библиотеки, вы должны учитывать все возможные способы вызова функции или создания экземпляра типа пользователем библиотеки. Обычно совместимость исходного кода — это цель, а не обещание.
В остальной части раздела описаны действия и инструменты, которые помогут обеспечить различные виды совместимости.
Виды совместимости
Бинарная совместимость означает, что новую версию библиотеки можно использовать вместо ранее скомпилированной версии. Любое программное обеспечение, скомпилированное с предыдущей версией библиотеки, должно продолжить работать правильно.
Совместимость исходного кода означает, что новую версию библиотеки можно использовать вместо предыдущей, не изменяя исходный код, который использует библиотеку. Однако результаты компиляции этого клиентского кода могут перестать быть совместимыми с результатами компиляции библиотеки, поэтому для гарантии совместимости клиентский код необходимо пересобрать с новой версией библиотеки.
Поведенческая совместимость означает, что новая версия библиотеки не изменяет существующую функциональность, за исключением исправления ошибок. Используются те же возможности, и их семантика остаётся неизменной.
Выбирайте совместимые версии языка и API
При публикации библиотеки учитывайте совместимость как во время компиляции, так и во время выполнения:
Версия языка определяет, какие версии компилятора Kotlin могут компилировать код, напрямую использующий вашу библиотеку.
Версия API определяет минимальную версию стандартной библиотеки Kotlin, необходимую во время выполнения.
В большинстве случаев используйте одинаковые версии языка и API.
Если указать для библиотеки более новую версию языка, потребителям потребуется использовать более новую версию компилятора Kotlin:
На JVM потребители могут использовать любую версию компилятора начиная с предыдущей версии языка. Например, если библиотека использует версию языка 2.2, потребители могут использовать версию компилятора 2.1.x, 2.2.x или более позднюю.
На других платформах потребители могут использовать версию компилятора, совпадающую с настроенной для библиотеки версией языка, или более позднюю. Это требование может помешать потребителям перейти на новые версии библиотеки, если они не могут сразу обновить компилятор.
Потребителям также потребуется версия стандартной библиотеки Kotlin не ниже настроенной для вашей библиотеки версии API. Это может затруднить обновление, если среда выполнения находится вне их контроля и новую версию стандартной библиотеки сложно предоставить, например в плагинах Gradle или IDE.
Выбирайте версии языка и API, которые лучше всего подходят вашей библиотеке. Более новые версии позволяют использовать новейшие возможности Kotlin, а более старые помогают большему числу потребителей пользоваться вашей библиотекой. Оптимальный выбор зависит от сценария использования библиотеки и числа её потребителей.
Используйте Binary compatibility validator
JetBrains предоставляет инструмент Binary compatibility validator, который помогает обеспечить бинарную совместимость разных версий вашего API.
Этот инструмент реализован как плагин Gradle и добавляет в вашу сборку две задачи:
Задача
apiDumpсоздаёт понятный человеку файл.apiс описанием вашего API.Задача
apiCheckсравнивает сохранённое описание API с классами, скомпилированными в текущей сборке.
Задача apiCheck вызывается во время сборки стандартной задачей Gradle check. Если совместимость нарушена, сборка завершается с ошибкой. В этом случае следует вручную запустить задачу apiDump и сравнить различия между старой и новой версиями. Если вас устраивают изменения, можно обновить существующий файл .api, хранящийся в вашей VCS.
Инструмент поддерживает экспериментальную проверку KLib-файлов, создаваемых мультиплатформенными библиотеками.
Проверка бинарной совместимости в плагине Kotlin Gradle
Начиная с версии 2.2.0 плагин Kotlin Gradle поддерживает проверку бинарной совместимости. Подробнее см. в разделе Проверка бинарной совместимости в плагине Kotlin Gradle.
Явно указывайте типы возвращаемых значений
Как указано в рекомендациях по написанию кода на Kotlin, в API всегда следует явно указывать типы возвращаемых значений функций и типы свойств. См. также раздел о режиме явного API.
Рассмотрим следующий пример: автор библиотеки создаёт JsonDeserializer и для удобства использует функцию-расширение, чтобы связать его с типом Int:
class JsonDeserializer<T>(private val fromJson: (String) -> T) {
fun deserialize(input: String): T {
...
}
}
fun Int.defaultDeserializer() = JsonDeserializer { ... }
Предположим, автор заменяет эту реализацию на JsonOrXmlDeserializer:
class JsonOrXmlDeserializer<T>(
private val fromJson: (String) -> T,
private val fromXML: (String) -> T
) {
fun deserialize(input: String): T {
...
}
}
fun Int.defaultDeserializer() = JsonOrXmlDeserializer({ ... }, { ... })
Существующая функциональность продолжит работать, а также появится возможность десериализовать XML. Однако это нарушает бинарную совместимость.
Не добавляйте аргументы в существующие функции API
Добавление в публичный API параметров без значений по умолчанию нарушает бинарную совместимость и совместимость исходного кода, поскольку при вызове пользователям приходится указывать больше информации, чем раньше. Однако даже добавление аргументов по умолчанию может нарушить совместимость.
Представьте, например, что в lib.kt у вас есть следующая функция:
fun fib() = … // Returns zero
А в client.kt — следующая функция:
fun main() {
println(fib()) // Prints zero
}
Компиляция этих двух файлов на JVM создаст файлы LibKt.class и ClientKt.class.
Предположим, вы заново реализуете и компилируете функцию fib, чтобы она представляла последовательность Фибоначчи, так что fib(3) возвращает 2, fib(4) — 3 и так далее. Вы добавляете параметр, но задаёте ему значение по умолчанию, равное нулю, чтобы сохранить существующее поведение:
fun fib(input: Int = 0) = … // Returns Fibonacci member
Теперь необходимо заново скомпилировать файл lib.kt. Можно предположить, что файл client.kt пересобирать не нужно и что соответствующий файл класса можно вызвать следующим образом:
$ kotlin ClientKt.class
Но при попытке сделать это возникает NoSuchMethodError:
Exception in thread "main" java.lang.NoSuchMethodError: 'int LibKt.fib()'
at LibKt.main(fib.kt:2)
at LibKt.main(fib.kt)
…
Это происходит потому, что сигнатура метода изменилась в байт-коде, сгенерированном компилятором Kotlin/JVM, что нарушило бинарную совместимость.
Однако совместимость исходного кода сохраняется. Если пересобрать оба файла, программа будет работать как прежде.
Используйте перегрузки для сохранения бинарной совместимости
Добавляя необязательные параметры в опубликованный API, можно использовать аннотацию экспериментального уровня @IntroducedAt, чтобы сохранить бинарную совместимость.
Добавьте аннотацию к каждому новому необязательному параметру, указав версию, в которой он был добавлен. Например:
@OptIn(ExperimentalVersionOverloading::class)
fun fib(@IntroducedAt("1.1") input: Int = 0) = …
Компилятор использует эту информацию для создания соответствующих скрытых перегрузок.
При написании кода Kotlin для JVM можно также использовать аннотацию @JvmOverloads для функций с аргументами по умолчанию, чтобы создавать перегрузки.
Вместо одной функции с аргументами по умолчанию можно также создать перегрузки вручную. Например, если нужно, чтобы функция fib() принимала параметр Int, создайте отдельную перегрузку:
fun fib() = … fun fib(input: Int) = …
Не расширяйте и не сужайте типы возвращаемых значений
При развитии API часто возникает необходимость расширить или сузить тип возвращаемого значения функции. Например, в следующей версии API может потребоваться заменить тип возвращаемого значения List на Collection или Collection на List.
Возможно, вам потребуется сузить тип до List, чтобы учесть запросы пользователей на поддержку индексирования. И наоборот, может потребоваться расширить тип до Collection, если окажется, что у обрабатываемых данных нет естественного порядка.
Нетрудно понять, почему расширение типа возвращаемого значения нарушает совместимость. Например, замена List на Collection нарушает работу всего кода, использующего индексирование.
Можно подумать, что сужение типа возвращаемого значения, например с Collection до List, сохранит совместимость. К сожалению, хотя совместимость исходного кода сохраняется, бинарная совместимость нарушается.
Предположим, в файле Library.kt у вас есть демонстрационная функция:
public fun demo(): Number = 3
И клиент этой функции в файле Client.kt:
fun main() {
println(demo()) // Prints 3
}
Представим ситуацию, в которой вы изменяете тип возвращаемого значения функции demo и пересобираете только Library.kt:
fun demo(): Int = 3
При повторном запуске клиента на JVM возникнет следующая ошибка:
Exception in thread "main" java.lang.NoSuchMethodError: 'java.lang.Number Library.demo()'
at ClientKt.main(call.kt:2)
at ClientKt.main(call.kt)
…
Это происходит из-за следующей инструкции в байт-коде, сгенерированном из метода main:
0: invokestatic #12 // Method Library.demo:()Ljava/lang/Number;
JVM пытается вызвать статический метод с именем demo, возвращающий Number. Однако этого метода больше нет, поэтому бинарная совместимость нарушена.
Не используйте классы данных в API
В обычной разработке главное преимущество классов данных — автоматически генерируемые дополнительные функции. При проектировании API это преимущество становится недостатком.
Например, предположим, что в API используется следующий класс данных:
data class User(
val name: String,
val email: String
)
Позже может потребоваться добавить свойство с именем active:
data class User(
val name: String,
val email: String,
val active: Boolean = true
)
Это нарушит бинарную совместимость двумя способами. Во-первых, изменится сигнатура сгенерированного конструктора. Кроме того, изменится сигнатура сгенерированного метода copy.
Исходная сигнатура (для Kotlin/JVM) будет такой:
public final User copy(java.lang.String, java.lang.String)
После добавления свойства active сигнатура будет выглядеть так:
public final User copy(java.lang.String, java.lang.String, boolean)
Как и в случае с конструктором, это нарушает бинарную совместимость.
Этих проблем можно избежать, написав дополнительный конструктор вручную и переопределив метод copy. Однако затраченные усилия сводят на нет удобство использования класса данных.
Ещё одна проблема классов данных заключается в том, что изменение порядка аргументов конструктора влияет на генерируемые методы componentX, используемые для деструктуризации. Даже если это не нарушит бинарную совместимость, изменение порядка определённо нарушит поведенческую совместимость.
Не изменяйте цели аннотаций
Если вы предоставляете аннотацию, не меняйте допустимые для неё цели после публикации библиотеки. Их изменение может повлиять на то, как применяется та же аннотация при повторной компиляции существующего кода пользователями.
Например, если для аннотации объявлена только цель AnnotationTarget.FIELD, то аннотация без указания цели, применённая к свойству, назначается его полю для хранения значения:
@Target(AnnotationTarget.FIELD)
annotation class Example
class User {
@Example
val name: String = ""
}
Если позже добавить AnnotationTarget.PROPERTY, та же аннотация без указания цели будет применена к самому свойству:
@Target(AnnotationTarget.PROPERTY, AnnotationTarget.FIELD)
annotation class Example
class User {
@Example
val name: String = ""
}
Это происходит потому, что компилятор Kotlin выбирает цель property раньше цели field. Цель field используется только в том случае, если property неприменима.
Это может нарушить совместимость с инструментами и фреймворками, которые ожидают увидеть аннотацию на определённом сгенерированном элементе. В частности, цель property не видна из Java. Если для обнаружения аннотации на поле хранения значения требуются рефлексия Java или процессоры аннотаций Java, пользователям необходимо явно указать цель применения field:
class User {
@field:Example
val name: String = ""
}
Рекомендации по использованию аннотации PublishedApi
В Kotlin встроенные функции могут быть частью API библиотеки. Вызовы этих функций встраиваются в клиентский код пользователей. Это может привести к проблемам совместимости, поэтому таким функциям запрещено вызывать объявления, не относящиеся к публичному API.
Если из встроенной публичной функции нужно вызвать внутренний API библиотеки, это можно сделать, пометив его аннотацией @PublishedApi. Это фактически делает внутреннее объявление публичным, поскольку ссылки на него окажутся в скомпилированном клиентском коде. Поэтому при внесении изменений с ним следует обращаться так же, как с публичными объявлениями: изменения могут повлиять на бинарную совместимость.
Развивайте API прагматично
Иногда со временем необходимо вносить в API библиотеки несовместимые изменения, удаляя или изменяя существующие объявления. В этом разделе мы обсудим прагматичный подход к таким ситуациям.
При переходе на новую версию библиотеки пользователи не должны сталкиваться с неразрешёнными ссылками на API библиотеки в исходном коде своих проектов. Вместо того чтобы сразу удалять что-либо из публичного API библиотеки, следует пройти цикл устаревания. Так пользователи получат время для перехода на альтернативное решение.
Пометьте старое объявление аннотацией @Deprecated, чтобы указать, что оно заменяется. Параметры этой аннотации содержат важные сведения об устаревании:
В параметре
messageследует объяснить, что и почему меняется.По возможности используйте параметр
replaceWith, чтобы обеспечить автоматическую миграцию на новый API.Уровень устаревания следует использовать для постепенного вывода API из употребления. Подробнее см. на странице Deprecated в документации Kotlin.
Как правило, сначала устаревшее объявление должно вызывать предупреждение, затем ошибку и, наконец, скрываться. Этот процесс должен проходить в течение нескольких минорных выпусков, чтобы пользователи успели внести необходимые изменения в свои проекты. Несовместимые изменения, например удаление API, следует вносить только в мажорных выпусках. Библиотека может использовать другие стратегии версионирования и устаревания, но о них нужно сообщить пользователям, чтобы сформировать правильные ожидания.
Подробнее читайте в документе Принципы развития Kotlin или посмотрите выступление Как безболезненно развивать API Kotlin для клиентов Леонида Старцева на KotlinConf 2023.
Используйте механизм RequiresOptIn
Стандартная библиотека Kotlin предоставляет механизм opt-in, который требует явного согласия пользователей перед использованием части вашего API. Он основан на создании маркерных аннотаций, которые сами помечаются аннотацией @RequiresOptIn. Используйте этот механизм для управления ожиданиями относительно совместимости исходного кода и поведенческой совместимости, особенно при добавлении новых API в библиотеку.
Если вы решили использовать этот механизм, рекомендуем придерживаться следующих рекомендаций:
Используйте механизм opt-in, чтобы предоставлять разные гарантии для разных частей API. Например, можно обозначить возможности как Предварительная версия, Экспериментальная и Ненадёжная. Каждую категорию следует чётко описать в документации и в комментариях KDoc, добавив соответствующие предупреждения.
Если ваша библиотека использует экспериментальный API, распространите аннотацию на собственных пользователей. Так они будут знать, что у вас есть зависимости, которые продолжают развиваться.
Не используйте механизм opt-in для вывода из употребления уже существующих объявлений в библиотеке. Вместо этого используйте
@Deprecated, как описано в разделе «Развивайте API прагматично».
Что дальше
Если вы ещё не сделали этого, ознакомьтесь со следующими страницами:
Изучите стратегии уменьшения умственной нагрузки на странице Как уменьшить умственную нагрузку.
Подробный обзор эффективных практик документирования см. на странице Информативная документация.
© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/api-guidelines-backward-compatibility.html