Вопросы и ответы с собеседований по Kotlin
Вопросы и ответы с собеседований Kotlin-разработчиков. Преимущества Kotlin перед Java, обработка исключений, статичные методы, коллекции, проверка на равенство, анонимные классы и объекты, ленивая инициализация свойств и многое другое.
Преимущества Kotlin перед Java
Код на Kotlin компактнее на 30-40%
Меньше кода = меньше ошибок, выше скорость разработки.
Безопасная работа с обнуляемыми переменными (Null Safety)
В отличие от Java, в Kotlin по умолчанию все типы являются non-nullable
, то есть не могут принимать значение null
. Присвоение или возврат null
приведет к ошибке компиляции. Чтобы присвоить переменной значение null
, в Kotlin необходимо явно пометить эту переменную как nullable
(добавив после типа знак вопроса). В Java же при использовании ссылки на объект с указанным значением null
, появляется исключение в виде «NullPointerExpertion!»
.
Функции-расширения (Extensions)
Kotlin позволяет расширять класс путём добавления нового функционала без необходимости наследования от такого класса. Это реализовано с помощью специальных выражений, называемых расширения. Например, вы можете написать новые функции для класса из сторонней библиотеки, которую вы не можете изменить. Такие функции можно вызывать обычным способом, как если бы они были методами исходного класса. Этот механизм называется функцией расширения.
Классы данных (data classes)
Разработчику на Java приходится писать много стандартного, но часто встречающегося кода (т.н. шаблонный код или boilerplate). В Kotlin же есть возможность создания специальных классов для определения полей для хранения данных, конструктора, функций сеттеров и геттеров для каждого поля, и функций Hashcode()
, toString()
и equals()
. Для этого достаточно добавить data
в определение класса, затем компилятор сделает все сам.
Синглтоны на уровне языка (Object)
В Java все должно объявляться внутри класса. Но в Kotlin все иначе. Компоненты могут объявляться за пределами класса, и это автоматически делает их статическими. Поэтому нам не требуется ключевое слово static
. В Java статические члены обрабатываются не так, как члены-объекты. Это означает, что для статических членов нам недоступны такие вещи, как реализация интерфейса, помещение экземпляра в ассоциативный список (map
) или передача его в качестве параметра методу, который принимает объект. В Kotlin static
не является ключевым словом и вместо статических членов используются объекты-компаньоны, позволяющие преодолеть вышеуказанные ограничения. В этом и заключается преимущество. Даже если члены объектов-компаньонов выглядят как статические члены в других языках, во время выполнения они все равно остаются членами экземпляров реальных объектов и могут, например, реализовывать интерфейсы.
Корутины
Kotlin предоставляет возможность создавать дополнительные потоки, однако в нем также существуют т.н. корутины (сопрограммы), которые позволяют использовать меньше памяти в сравнении с обычным потоком, т.к. реализованы они без стека. Корутины же в свою очередь способны выполнять интенсивные и длительные задачи методом приостановления выполнения без блокировки потока и его последующего восстановления. Что в дальнейшем позволяет сгенерировать асинхронный код без блокирования, который при его выполнении не отличить от синхронного. К тому же, они генерируют эффектные доп. стили например async
или await
.
Подход к исключениям (exception) в Java и Kotlin
Одним из ключевых отличий между Java и Kotlin является подход к исключениям. В Java есть два типа исключений: checked
и unchecked
.
Checked
исключения это те, которые должны быть обработаны в коде, иначе компилятор не позволит коду скомпилироваться. Unchecked
исключения не требуют обработки в коде.
С точки зрения исключений компилятор Kotlin отличается тем, что не различает checked
и unchecked
исключения. Все исключения — только unchecked
, поэтому нет необходимости отлавливать или объявлять какие-либо исключения (вы самостоятельно принимаете решение, стоит ли их отлавливать и обрабатывать).
Такой подход был выбран разработчиками Kotlin, чтобы упростить и ускорить процесс разработки, сократив количество бойлерплейта и улучшив читаемость кода. Однако, это может привести к тому, что некоторые ошибки могут быть упущены при компиляции и проявиться только во время выполнения программы.
Некоторые разработчики считают, что отказ от checked
исключений является недостатком Kotlin, поскольку это может привести к ошибкам, которые могут быть предотвращены на этапе компиляции в Java. Однако, другие разработчики утверждают, что этот подход снижает количество шаблонного кода и упрощает написание программ.
Как перенести статичный метод из Java в Kotlin?
В Kotlin нет статических методов, для этих целей обычно служит companion object
.
Для того чтобы метод из Java был представлен как статический используется аннотация @JvmStatic
. Эта аннотация говорит компилятору Kotlin создать статический метод в байт-коде, что позволяет использовать методы так же, как в Java.
Например, если у нас есть статический метод в Java:
public class MyClass {
public static int sum(int a, int b) {
return a + b;
}
}
Мы можем использовать этот метод в Kotlin, добавив аннотацию @JvmStatic
:
object MyClass {
fun sum(a: Int, b: Int): Int {
return a + b
}
}
Во что преобразуется internal в Java?
В Java нет эквивалента модификатору доступа internal
из Kotlin. При компиляции Kotlin-кода в Java-байткод, модификатор доступа internal
преобразуется в модификатор public
в Java.
Таким образом, все члены класса, отмеченные как internal
, будут видны из любого места в том же пакете, а также из любого другого модуля, которому был разрешен доступ к этому модулю.
Члены internal
классов проходят через искажение имен, чтобы усложнить случайное использование их из Java и позволить перегрузку для членов с одинаковыми сигнатурами, которые не видят друг друга в соответствии с правилами Kotlin.
Какие коллекции есть в Kotlin?
Коллекция — это объект, содержащий в себе набор значений одного или различных типов, а также позволяющий к этим значениям обращаться и извлекать. Другими словами — это контейнер, в который вы можете помещать то, что вам нужно, а затем каким-либо образом с ним взаимодействовать. В Kotlin есть три типа коллекций:
- List
(список). Упорядоченная коллекция, в которой к элементам можно обращаться по их индексам. Идентичные элементы (дубликаты) могут встречаться в списке более одного раза. Примером списка является предложение: это группа слов, их порядок важен, и они могут повторяться.
- Set
(множество/набор). Неупорядоченная коллекция без повторяющихся значений. Примером множества является алфавит.
- Map
(словарь/ассоциативный список). Набор из пар "ключ-значение". Ключи уникальны и каждый из них соответствует ровно одному значению. В коллекции могут присутствовать повторяющиеся значения, но не повторяющиеся ключи. Пример — ID сотрудников и их должностей. Map
не является наследником интерфейса Collection
.
Два типа интерфейсов, на основе которых создаются коллекции:
- Неизменяемый (read-only) — дают доступ только для чтения (Set
, List
, Map
, Collection
).
- Изменяемый (mutable) — расширяет предыдущий интерфейс и дополнительно даёт доступ к операциям добавления, удаления и обновления элементов коллекции (MutableSet
, MutableList
, MutableMap
, MutableCollection
).
List
Список — это упорядоченная коллекция. Каждое значение, помещённое в List
, называется элементом, к которому можно обращаться по индексу. Индексы начинаются с "0" и заканчиваются индексом последнего элемента в списке — (list.size - 1
). Список может содержать сколько угодно одинаковых элементов — дублей (в том числе null
).
val trees = listOf("Сосна", "Берёза", "Дуб") // неизменяемый список
trees.add("Ясень") // ошибка
val mutableTrees = mutableListOf("Сосна", "Берёза", "Дуб") // изменяемый список
mutableTrees.add("Ясень") // всё ок
По умолчанию в Kotlin реализацией List
является ArrayList
, его можно создать напрямую:
val mutableTrees = ArrayList<String>()
mutableTrees.add("Ясень")
Set
Множество — это коллекция уникальных элементов. Это означает, что Set
не может содержать дублей. Обратите внимание, что null
— это тоже уникальный элемент.
val trees = setOf("Сосна", "Берёза", "Дуб") // неизменяемый сет
trees.add("Ясень") // ошибка
val mutableTrees = mutableSetOf("Сосна", "Берёза", "Дуб") // изменяемый сет
mutableTrees.add("Сосна") // проигнорируется
В отличие от списка, множество не заботится о порядке элементов. Это означает, что при использовании функций, зависящих от порядка элементов, вы можете получить непредсказуемый результат. Но это зависит от реализации сета. Например, по умолчанию реализацией Set
является LinkedHashSet
, который сохраняет порядок вставки элементов.
val numbers = setOf(1, 2, 3, 4) // по умолчанию LinkedHashSet
val numbersBackwards = setOf(4, 3, 2, 1)
println(numbers.first() == numbersBackwards.first()) // false
println(numbers.first() == numbersBackwards.last()) // true
Но также существует HashSet
, который не сохраняет порядок вставки элементов. И LinkedHashSet
, и HashSet
можно создать напрямую.
val linkedHashSet = LinkedHashSet<String>()
linkedHashSet.add("Дуб")
val hashSet = HashSet<String>()
hashSet.add("Ясень")
Map
Ассоциативные списки с уникальными ключами и любыми значениями (дубликаты ключей не допускаются, значения могут быть одинаковыми). Связь между ключами и значениями происходит через специальную форму вызова метода (инфиксный вызов) to
.
// числа - это ключи, деревья - значения
val map = mapOf(1 to "Сосна", 2 to "Берёза", 3 to "Дуб") // неизменяемая мапа
map.put(4, "Ясень") // ошибка
val mutableMap = mutableMapOf(1 to "Сосна", 2 to "Берёза", 3 to "Дуб") // изменяемая мапа
mutableMap.put(4, "Ясень")
По умолчанию реализацией мапы является LinkedHashMap
, который сохраняет порядок вставки записей. Есть ещё HashMap
, которая не сохраняет порядок вставки записей. Обе реализации можно создать напрямую.
val linkedHashMap = LinkedHashMap<Int, String>()
linkedHashMap.put(1, "Дуб")
val hashMap = HashMap<Int, String>()
hashMap.put(1, "Ясень")
Какая из коллекций не является имплементацией Collection?
Интерфейс Map
не является наследником интерфейса Collection
.
Технически — это не коллекция, так как Map
не наследуется от Collection
. Но это также структура для хранения данных и ее всегда изучают и рассматривают вместе с коллекциями. В разговоре вполне нормально называть Map
коллекцией.
Что такое sequences и чем они отличаются от коллекций?
Sequences или последовательности — ещё один тип контейнера в Kotlin, но он не является коллекцией. Последовательности очень похожи на коллекции, они предоставляют те же функции. Ключевая разница в том, что они применяют другой подход с многоэтапной обработкой элементов (например, когда вы последовательно вызываете некую цепочку вызовов к коллекции).
Последовательность — это итерируемый тип, с которым можно работать, не создавая ненужных промежуточных коллекций, выполняя все применимые операции над каждым элементом перед переходом к следующему.
Отличия коллекции от последовательности:
Если обработка Iterable
состоит из нескольких шагов, то они выполняются немедленно: при завершении обработки каждый шаг возвращает свой результат — промежуточную коллекцию. Следующий шаг выполняется для этой промежуточной коллекции. Sequence же по возможности выполняет обработку "лениво" — фактически вычисления происходят только тогда, когда запрашивается результат выполнения всех шагов.
Iterable
завершает каждый шаг для всей коллекции, а затем переходит к следующему шагу. Sequence выполняет все шаги один за другим для каждого отдельного элемента.
Iterable
могут занимать больше памяти, чем последовательности, так как они вычисляют все элементы сразу и хранят их в памяти. Sequence вычисляют элементы при необходимости и не хранят все элементы в памяти.
Зачем вообще нужны Sequences?
Для оптимизации производительности в работе с большими коллекциями (от 1000). Фишка в том, что значения в таких коллекциях создаются только по мере необходимости, не инициализируя их заранее. Из-за этого нет доступа к содержимому по индексу, а также не контролируется размер.
Последовательности позволяют избежать создания промежуточных результатов для каждого шага, тем самым повышая производительность всей цепочки вызовов. Однако "ленивый" характер последовательностей добавляет некоторые накладные расходы, которые могут быть значительными при обработке небольших коллекций или при выполнении более простых вычислений. Следовательно, вы должны рассмотреть, а затем самостоятельно решить, что вам подходит больше — Sequence
или Iterable
.
Расскажите про проверку на равенство == и equals()
1. Проверка на равенство в Java
Структурное равенство (значение) — метод equals()
.
Ссылочное равенство — оператор ==
:
— примитивные типы данных: сравнивает значения переменных
— ссылочные типы данных (объекты, массивы): сравнивает ссылки
2. Проверка на равенство в Kotlin
Структурное равенство (значение) — оператор ==
(проверка через equals()
)
Ссылочное равенство — оператор ===
:
— примитивные типы данных: сравнивает значения переменных
— ссылочные типы данных (объекты, массивы): сравнивает ссылки
3. Разница == с Java
Структурное равенство (значение) — оператор ==
в Kotlin это equals()
в Java, т.е. в Kotlin строки можно всегда сравнивать через ==
.
Ссылочное равенство — оператор ===
в Kotlin это ==
в Java.
Что такое анонимные классы и объекты?
Анонимный класс — это класс, которые явно не объявлен с помощью class
, наследуется от заданного класса или реализует заданный интерфейс.
Анонимный класс не всегда является синглтоном. Анонимный класс создается каждый раз при вызове соответствующего конструктора и используется только в контексте, где был создан. При этом каждый экземпляр анонимного класса имеет свое уникальное состояние и может отличаться от других экземпляров того же анонимного класса. В Kotlin анонимный класс создается следующим образом:
val obj = object : SuperClassOrInterface() {
// implementation here
}
Объекты анонимных классов полезны для одноразового использования.
Экземпляры анонимных классов называют анонимными объектами, потому что они объявляются выражением, а не именем. Анонимный объект начинается с ключевого слова object
.
- можно задавать свойства, функции, блоки инициализации;
- можно наследоваться от других классов и реализовывать интерфейсы;
- нельзя создавать конструкторы (как основные, так и вторичные).
Ключевое слово object
позволяет одновременно объявить класс и создать его экземпляр (т.е. объект). При этом применять его можно по-разному:
- object Name
— это объявление объекта (оbject declaration), реализация паттерна Singleton;
- companion object
— это объект-компаньон внутри класса (также Singleton);
- object
— это объект-выражение (анонимный объект/object expression), не Singleton.
Расскажите про объявление объекта
Объявляется объект при помощи ключевого слова object
, после которого следует имя объекта.
Файл, содержащий только object
представляет из себя Singleton, т.е. будет создан только один экземпляр этого класса. Пример:
object One {
val cats = arrayListOf<Cat>()
fun callCat() {
for (cat in cats) {
...
}
}
}
Можно обращаться к методам и свойствам класса через имя объекта:
One.cats.add(Cat(...))
One.callCat()
Инициализация объявления объекта потокобезопасна и выполняется при первом доступе (лениво).
Что такое companion object?
Объекты можно объявлять внутри класса, при этом нет каких-либо ограничений по их количеству. Но только один объект можно пометить ключевым словом companion object
в рамках одного класса.
Синглтон-свойство companion object
достигается за счет того, что он создается внутри класса в качестве статического поля. Он будет инициализирован при первом обращении к нему или при создании первого экземпляра класса, в котором он объявлен.
Важно отметить, что companion object
будет инициализирован первым, а затем уже будет создан экземпляр класса:
class MyClass {
init {
// Выполняется всегда после инициализации companion object
}
companion object {
init {
// Выполняется всегда перед блоком init содержащего класса
}
}
}
val myClass = MyClass()
Такому объекту можно не указывать свое имя, и обращаться к методам и свойствам объекта через имя содержащего его класса без явного указания имени объекта.
class SomeClass {
companion object {
fun create()
}
}
val someClass = SomeClass.create()
Компилируется в public static final class
на Java. Работает подобно ключевому слову static
в Java.
Что такое объект-выражение?
Объект-выражение — это выражение, которое "на ходу" создает анонимный объект.
Для объекта-выражения не указывается имя!
Если же объекту всё-таки требуется имя, то его можно сохранить в переменной:
val tom = object {
val name = "Tom"
var age = 37
fun sayHello() {
println("Hi, my name is $name")
}
}
println("Name: ${tom.name} Age: ${tom.age}")
tom.sayHello()
Анонимные объекты не являются синглтонами!
Каждый раз при выполнении объекта-выражения создаётся новый объект.
Анонимный объект является заменой анонимным внутренним классам в Java.
В чем разница между анонимным и объявляемым объектом?
Различия следующие:
- анонимный объект (object
) инициализируется непосредственно при использовании;
- декларированный (объявляемый) объект (object Name
) инициализируется лениво, в момент первого к нему доступа;
- вспомогательный объект (companion object
) инициализируется в момент, когда класс, к которому он относится, загружен и семантически совпадает со статическим инициализатором Java.
Зачем нужна аннотация @JvmStatic?
С помощью аннотации @JvmStatic
есть возможность объявить методы по настоящему статическими, ее можно добавить как к методам object
, так и к методам companion object
.
object ObjectWithStatic {
fun staticFun(): Int {
return 5
}
}
В этом случае метод staticFun
будет действительно объявлен статическим:
public final class ObjectWithStatic {
public static final ObjectWithStatic INSTANCE;
public static final int staticFun() {
return 5;
}
private ObjectWithStatic() {
INSTANCE = (ObjectWithStatic)this;
}
static {
new ObjectWithStatic();
}
}
Null safety, nullable и non-nullable типы
Null safety — это концепция безопасности, которая предотвращает некоторые из наиболее распространенных ошибок в программировании, связанных с использованием null-значений. В Kotlin эта концепция реализуется за счет строгой типизации и системы Nullable
/Non-nullable
типов данных.
Nullable типы — это типы, которые могут содержать значение null
. Non-nullable
типы — это типы, которые не могут содержать значение null
и всегда должны иметь некоторое значение.
В Kotlin переменные по умолчанию являются non-nullable — это означает, что они не могут принимать значение null
. Если переменная может принимать значение null
, то ее нужно объявить с использованием знака вопроса (?). При использовании Nullable
переменной в коде Kotlin не допустит обращение к ней без предварительной проверки на null
-значение.
Также Kotlin предоставляет множество функций для безопасной работы с nullable
-значениями, таких как операторы elvis ?:
, безопасный вызов ?.
и другие.
В целом, концепция Null safety
помогает разработчикам избежать ошибок связанных с null
-значениями, уменьшает количество ошибок в работе приложения и упрощает разработку и поддержку кода.
Способы проверки значения на null
Kotlin разграничивает типы с поддержкой и без поддержки null-значений. Это означает, что при объявлении переменной, которая может хранить null
, нужно явно объявить ее как nullable
при помощи символа ?
.
val languageName: String? = null
Объявляя nullable
переменную вы берёте на себя ответственность по проверке её значения. Иначе компилятор будет запрещать вызов функций для таких значений, ведь это может привести к NullPointerException
.
Рассмотрим все доступные способы проверки значения на null
.
1. Проверка с помощью if-else
Пожалуй, это самый простой способ проверки значения на null
и скорее всего будет многим знаком.
if (languageName != null) {
print("Name is : $languageName")
} else {
print("Please enter a valid name")
}
Использование оператора безопасного вызова будет предпочтительнее, так как он позволяет решить проблему меньшим количеством кода. Однако, если со значением переменной производятся какие-то сложные вычисления и перед началом вычислений нужно проверить равно ли оно null
, то if-else
вполне подойдёт.
2. Оператор безопасного вызова ?.
Оператор безопасного вызова позволяет сказать компилятору, что значением данной переменной может быть null
и его стоит проверить перед дальнейшим использованием.
languageName?.length
То есть, если значение переменной languageName
равно null
, то компилятор не будет пытаться определить длину слова, а просто вернёт null
.
Если вы хотите вызвать функцию или каким-то другим способом обработать значение, отличное от null
, то совместно с оператором безопасного вызова используйте функцию let
. Всё, что будет указано в функции let
, выполнится только в том случае, если значение переменной отлично от null
.
languageName?.let { println(it) }
3. Оператор !!
Два восклицательных знака, стоящих после nullable
-значения, преобразуют его к типу без поддержки null
. При этом перед преобразованием никак не проверяется, что значение действительно не содержит null
. Поэтому, если в процессе выполнения программы окажется, что значение, которое пытается преобразовать оператор !!
, все-таки null
, то останется только один выход — выбросить исключение NullPointerException
. Если оно не обрабатывается кодом, программа аварийно завершится. Несмотря на удобство этого оператора, его следует использовать только там, где вы уверены, что null
быть не может.
Данный оператор понравится любителям NullPointerException
. Он как бы говорит компилятору, что если значение переменной — null
, то ТРЕБУЮ выбросить NullPointerException
.
val languageName: String? = null
val size = languageName!!.length
Использование данного оператора крайне не рекомендуется, потому что (очевидно) это один из немногих способов словить NPE. При его использовании вы должны быть уверены, что значение переменной ни при каких обстоятельствах не может быть null
. В противном случае лучше использовать оператор безопасного вызова.
4. Элвис оператор или оператор объединения по null
?:
Оператор элвис подобен проверке на null
в варианте if-else
. Элвис используется для замены null
каким-либо значением, принадлежащим обычно зауженному типу. В результате выражение с элвисом позволяет не увеличивать в программе количество nullable
-переменных.
Оператор указывается между двумя значениями. Если значение слева от оператора равно null
, то применяется значение справа.
val size: Int = languageName.length ?: 0
Если значение languageName
не равно null
, его длина будет присвоена переменной size
.
Если languageName
равно null
, тогда будет присвоено значение 0
.
Но в любом случае переменной size
будет присвоено значение типа Int
, а не Int?
, то есть non-null
тип.
Использование данного оператора с функцией let
может полностью заменить проверку с помощью оператора if-else
.
// с использованием if-else
if (languageName != null) {
print("Name is : $languageName")
} else {
print("Please enter a valid name")
}
// Элвис оператор и функция let
languageName?.let {
print("Name is : $languageName")
} ?: print("Please enter a valid name")
От какого класса унаследованы все классы в Kotlin?
Класс Any
находится на вершине иерархии — все классы в Kotlin являются наследниками Any
. Это стандартный родительский класс для всех классов, которые явно не унаследованы от другого класса. Именно в нем определены equals
, hashCode
и toString
.
Класс Any
по назначению похож на Object
в Java.
public open class Any {
public open operator fun equals(other: Any?): Boolean
public open fun hashCode(): Int
public open fun toString(): String
}
Чем Any в Kotlin отличается от Object в Java?
Any
не является полным аналогом java.lang.Object
.
В Object
11 методов в классе, в Any
только 3 метода: equals()
, hashCode()
и toString()
.
При импорте типов Java в Kotlin все ссылки типа java.lang.Object
преобразуются в Any
. Поскольку Any
не зависит от платформы, он объявляет только toString()
, hashCode()
и equals()
в качестве своих членов, поэтому, чтобы сделать другие члены java.lang.Object
доступными, Kotlin использует функции расширения.
Несмотря на то, что классы Object
и Any
имеют сходства (корневые классы иерархии классов), они также имеют и отличия, связанные с языковыми особенностями Kotlin и Java:
Класс Any
в Kotlin является не только базовым классом для пользовательских классов, но также и супертипом для всех не-nullable
типов данных, включая примитивные. В то время как в Java, класс Object
является базовым классом только для пользовательских классов.
Класс Any
в Kotlin также имеет nullable
версию Any?
, которая является супертипом для всех nullable
типов данных в Kotlin. В то время как в Java, класс Object
не имеет nullable
версии.
Что такое Unit?
Тип Unit
в Kotlin выполняет ту же функцию, что и void
в Java.
Возвращаемый тип можно не указывать, если функция ничего не возвращает. По умолчанию там будет Unit
:
fun knockKnock() {
println("Who’s there?")
}
// то же самое, но с указанным типом Unit
fun knockKnock(): Unit = println("Who’s there?")
Сколько существует instance Unit (1)?
В стандартной библиотеке Kotlin Unit
определён как объект, наследуемый от Any
и содержащий единственный метод, переопределяющий toString()
:
public object Unit {
override fun toString() = "kotlin.Unit"
}
Unit
является синглтоном (ключевое слово object
).
Unit
ничего не возвращает, а метод toString
всегда будет возвращать kotlin.Unit
.
При компиляции в java-код Unit
всегда будет превращаться в void
.
Что такое Nothing?
Nothing
является типом, который полезен при объявлении функции, которая ничего не возвращает и не завершается.
Примеры:
- функция, которая выбрасывает exception
или в которой запущен бесконечный цикл;
- функция TODO()
— public inline fun TODO(): Nothing = throw NotImplementedError()
;
- в тестах есть функция с именем fail
, которая выдает исключение с определенным сообщением:
fun fail(message: String): Nothing {
throw IllegalStateException(message)
}
Подтип всех типов в Kotlin
Nothing
в Kotlin — это так называемый bottom type
, то есть он является подтипом любого другого типа. Наличие Nothing
в системе типов позволяет типизировано выражать то, что без него принципиально невозможно.
Bottom type
— это тип, который не имеет значений и предназначен для обозначения невыполнимых ситуаций в программе.
Сколько существует instance Nothing (0)?
Nothing
— класс, который является наследником любого класса в Kotlin, даже класса с модификатором final
. При этом Nothing
нельзя создать — у него приватный конструктор. В коде он объявлен так:
public class Nothing private constructor()
Есть ли аналог Nothing в Java?
Тип Nothing
является особенным, поскольку в Java ему нет аналогов.
Действительно, каждый ссылочный тип Java, включая java.lang.Void
, принимает в качестве значения null
, а Nothing
не принимает даже этого. Таким образом, этот тип не может быть точно представлен в мире Java. Вот почему Kotlin генерирует необработанный тип, в котором используется аргумент типа Nothing
:
fun emptyList(): List<Nothing> = listOf()
// is translated to
// List emptyList() { ... }
Модификаторы доступа в Kotlin
Классы, объекты, интерфейсы, конструкторы, функции, свойства и их сеттеры могут иметь модификаторы доступа. Геттеры всегда имеют ту же видимость, что и свойства, к которым они относятся. Модификаторы доступа — это ключевые слова, с помощью которых можно задать область действия данных. Они позволяют регулировать уровень доступа к различным частям кода. Локальные переменные, функции и классы не могут иметь модификаторов доступа.
В Kotlin есть четыре модификатора доступа: private
, protected
, internal
и public
.
Если модификатор явно не указан, то присваивается значение по умолчанию — public
.
Private
— доступ к членам класса только в пределах самого класса. То есть, поля и методы с модификатором private недоступны из других классов и даже из наследников.
Protected
— доступ к членам класса только в пределах класса и его наследников. То есть, поля и методы с модификатором protected доступны из класса и его наследников, но не из других классов.
Internal
— доступ к членам модуля (module
). Модуль — это набор файлов, компилирующихся вместе, поэтому все классы, объявленные внутри модуля, могут иметь доступ к членам с модификатором internal.
Public
— не ограничивает доступ к членам класса. Поля и методы с модификатором public
доступны из любого места программы, включая другие модули.
Разница между var, val, const val
var
— это изменяемая переменная. После инициализации мы можем изменять данные, хранящиеся в переменной.
Переменные val
и const val
доступны только для чтения — это неизменяемые переменные.
val
— константа времени выполнения, т.е. значение можно назначить во время выполнения программы.
const val
— константа времени компиляции, т.к. значения константам присваивается при компиляции (в момент, когда программа компилируется).
В отличие от val
, значение const val
должно быть известно во время компиляции.
Особенности const val
:
- могут получать значение только базовых типов: Int
, Double
, Float
, Long
, Short
, Byte
, Char
, String
, Boolean
.
- объявляются в глобальной области видимости, то есть за пределами функции main()
или любой другой функции.
- нет пользовательского геттера.
Как стоит объявлять свои константы в Kotlin — при помощи companion object
или вне класса?
На самом деле оба эти подхода приемлемы. Однако, использование companion object
может быть излишним: компилятор Kotlin преобразует companion object
во вложенный класс. Слишком много кода для простой константы.
Если вам не требуется поведение, специфичное для companion object
, объявляйте константы вне класса, так как это будет способствовать более эффективному байт-коду. Да и сам синтаксис объявления констант вне класса более чистый и читабельный.
Расскажите про свойства, методы get и set
Свойства класса — это переменные, которые хранят состояние объекта класса. Как и любая переменная, свойство может иметь тип, имя и значение.
В классе можно объявить свойства с помощью ключевого слова var
или val
. Свойства, объявленные с var
, могут быть изменены после их инициализации, а свойства, объявленные с val
, только для чтения.
class Person {
var name: String = ""
val age: Int = 0
}
При создании своего класса мы хотим сами управлять его свойствами, контролируя то, какие данные могут быть предоставлены или перезаписаны. С этой целью создаются get
и set
методы (геттеры и сеттеры). Цель get
-метода — вернуть значение, а set
-метода — записать полученное значение в свойство класса.
var name: String = ""
get() = field.toUpperCase()
set(value) {
field = "Name: $value"
}
В данном примере свойство name
имеет тип String
и начальное значение пустой строки. Геттер возвращает значение свойства, преобразованное к верхнему регистру. Сеттер устанавливает значение свойства с добавлением префикса "Name: "
перед переданным значением. Слово field
используется для обращения к текущему значению свойства.
Если get
и set
методы не были созданы вручную, то для таких свойств Kotlin незаметно сам их генерирует. При этом для свойства, объявленного с val
, генерируется get
-метод, а для свойства, объявленного с var
— и get
, и set
методы.
В чем отличие field от property?
В Kotlin свойство (property
) — это абстракция над полями (fields
), которая позволяет обращаться к значению переменной через методы геттера и сеттера, вместо прямого доступа к полю.
Field
— это переменная, которая содержит значение и может быть доступна напрямую или через геттер/сеттер.
Пример определения свойства с геттером и сеттером в классе:
class Person {
var name: String = ""
get() = field.toUpperCase() // возвращает значение поля name в верхнем регистре
set(value) {
field = value.trim() // устанавливает значение поля name без начальных и конечных пробелов
}
}
В данном примере свойство name
содержит поле, которое может быть доступно напрямую только внутри класса, и методы геттера и сеттера, которые позволяют получать и изменять значение свойства через специальные методы.
Отложенная и ленивая инициализация свойств
Отложенная и ленивая инициализация свойств — это механизмы, которые позволяют отложить инициализацию переменных до момента их первого использования. Оба варианта позволяют экономить ресурсы, т.к. избегают необходимости создания объектов при инициализации класса.
1. lateinit
Модификатор lateinit
говорит о том, что данная переменная будет инициализирована позже. При этом инициализировать свойство можно из любого места, откуда она видна.
Правила использования модификатора lateinit
:
- lateinit
может использоваться только с var
свойствами класса;
- lateinit
может быть применен только к свойствам, объявленным внутри тела класса (но не в основном конструкторе), а также к переменным на верхнем уровне и локальным переменным;
- lateinit
свойства могут иметь любой тип, кроме примитивных типов (таких как Int
, Long
, Double
и т.д.);
- lateinit
свойства не могут быть nullable
(т.е. обязательно должно быть объявлены без знака вопроса);
- lateinit
свойства не могут быть проинициализированы сразу при их объявлении;
- lateinit
свойства должны быть инициализированы до первого обращения к ним, иначе будет выброшено исключение UninitializedPropertyAccessException
;
- Нельзя использовать lateinit
для переменных, определенных внутри локальных областей видимости (например, внутри функций);
- При использовании модификатора lateinit
у свойства не должно быть пользовательских геттеров и сеттеров.
Для проверки факта инициализации переменной вызывайте метод isInitialized()
. Функцию следует использовать экономно — не следует добавлять эту проверку к каждой переменной с отложенной инициализацией. Если вы используете isInitialized()
слишком часто, то скорее всего вам лучше использовать тип с поддержкой null
.
lateinit var catName: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
catName = "Barsik"
if (::catName.isInitialized) {
Log.d("Kot", "Hi, $catName")
}
}
2. by lazy
Ленивая инициализация (lazy initialization) — это подход, при котором объект инициализируется только при необходимости, а не сразу после создания. В Kotlin для ленивой инициализации свойств используется делегат lazy
.
Когда свойство объявляется с использованием делегата lazy
, оно не инициализируется сразу, а только тогда, когда к нему происходит первое обращение. При этом инициализация выполняется единожды, и в дальнейшем значение свойства сохраняется для всех последующих обращений к нему. Таким образом, ленивая инициализация позволяет оптимизировать использование ресурсов приложения, не инициализируя объекты, которые не понадобятся в ходе выполнения программы.
При использовании ленивой инициализации свойств с помощью by lazy в Kotlin, создается объект типа Lazy<T>
, где T
— это тип свойства, и этот объект используется для хранения значения свойства.
Когда код доходит до места, где используется свойство, вызывается метод getValue()
этого объекта Lazy<T>
. Если значение свойства еще не было проинициализировано, то вызывается лямбда-выражение, переданное в lazy { }
, и ее результат используется для инициализации свойства. Значение сохраняется в объекте Lazy<T>
и возвращается как результат метода getValue()
. Если значение уже было проинициализировано, то просто возвращается сохраненное значение. Например, если у нас есть свойство:
val myProperty: Int by lazy { computeValue() }
то при первом обращении к свойству myProperty
будет выполнена функция computeValue()
, а результат будет сохранен. При последующих обращениях к свойству будет возвращено сохраненное значение.
3. Сравнение ленивой и отложенной инициализации
- ленивая инициализация является одним из Delegate
- отложенная инициализация требует использования модификатора свойства
- ленивая инициализация применяется только к val
- отложенная инициализация применяется только к var
- у нас может быть ленивое свойство примитивного типа
- lateinit
применяется только к ссылочным типам
Самое главное, когда мы реализуем свойство как ленивый делегат, мы фактически присваиваем ему своего рода значение. Вместо фактического значения мы помещаем туда функцию для его вычисления, когда оно нам понадобится.
С другой стороны, когда мы объявляем свойство как lateinit
, мы просто отключаем одну из проверок компилятора, которая гарантирует, что программа не обращается ни к одной переменной до того, как она получит значение. Вместо этого мы обещаем сделать эту проверку сами.
Что такое делегированные свойства?
Делегированные свойства (Delegated properties) — это свойства, которые не хранят своё значение напрямую, а делегируют это значение другому объекту, который реализует интерфейс Delegate
. При доступе к свойству, его значение запрашивается у делегата, который может выполнить какую-то дополнительную логику, а затем вернуть требуемое значение. Пример:
class Example {
var p: String by Delegate()
}
Ключевое слово by
используется для обозначения свойств, методы чтения и записи которых реализованы другим объектом, который называют делегатом.
В Kotlin существуют несколько встроенных делегатов для работы с делегированными свойствами:
lazy()
— позволяет создавать лениво инициализированные свойства
observable()
— позволяет реагировать на изменения свойства
vetoable()
— позволяет отклонять изменения значения свойства на основе заданного условия
notNull()
— гарантирует, что свойство не будет иметь значение null
map()
— позволяет хранить значения свойств в словаре (Map
)
Кроме того, в Kotlin можно создавать свои собственные делегаты, реализуя интерфейс ReadOnlyProperty
или ReadWriteProperty
. Это дает возможность создавать кастомные поведения для свойств, например, кеширование значений или логирование операций чтения/записи.
Как реализовать кастомный делегат?
Чтобы написать кастомный делегат, нужно определить класс, который реализует интерфейс ReadOnlyProperty
для делегата val
или ReadWriteProperty
для делегата var
.
Классы, реализующие ReadOnlyProperty
и ReadWriteProperty
, содержат два метода:
- getValue(thisRef: T, property: KProperty<*>): R
, который должен возвращать значение свойства.
- setValue(thisRef: T, property: KProperty<*>, value: R)
, который должен устанавливать значение свойства.
Например, рассмотрим создание кастомного делегата для логирования изменения значения свойства:
class LoggingDelegate<T>(private var value: T) : ReadWriteProperty<Any?, T> {
override fun getValue(thisRef: Any?, property: KProperty<*>): T {
println("Getting value of ${property.name}: $value")
return value
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
println("Setting value of ${property.name} to $value")
this.value = value
}
}
Здесь мы определяем класс LoggingDelegate
, который реализует интерфейс ReadWriteProperty
. Метод getValue
выводит в консоль текущее значение свойства и возвращает его, а метод setValue
выводит новое значение свойства в консоль и сохраняет его в переменной value
.
Затем мы можем использовать наш кастомный делегат следующим образом:
class MyClass {
var myProperty: Int by LoggingDelegate(0)
}
fun main() {
val obj = MyClass()
obj.myProperty = 42 // Setting value of myProperty to 42
println(obj.myProperty) // Getting value of myProperty: 42
}
Здесь мы создаем экземпляр класса MyClass
, который содержит свойство myProperty
, использующее наш кастомный делегат LoggingDelegate
. При установке значения свойства или получении его значения будут вызываться соответствующие методы нашего делегата, и мы увидим соответствующие сообщения в консоли.
Типы конструкторов в Kotlin
Свойств у класса может быть столько, сколько ему нужно. Но все они должны быть инициализированы при создании экземпляра этого класса. Поэтому для удобства был придуман конструктор — специальный блок кода, который вызывается при создании экземпляра класса. Ему передаются необходимые значения, которые потом используются для инициализации свойств.
Класс в Kotlin может иметь основной конструктор и один или более вторичных конструкторов. У класса может и не быть конструктора, но Kotlin всё равно автоматически сгенерирует основной конструктор по умолчанию (без параметров).
1. Основной конструктор
Объявляется он сразу после имени класса и состоит из ключевого слова constructor
и круглых скобок:
class Person constructor(name: String, age: Int) {
}
Можно обойтись и без ключевого слова constructor
при условии, что нет аннотаций или модификаторов доступа.
class Person(name: String, age: Int)
Параметры, переданные в конструктор, можно использовать для инициализации свойств, объявленных в теле класса.
class Person(name: String, age: Int) {
val name = name
var age = age
}
А можно упростить еще больше и из параметров конструктора сделать свойства класса. Для этого перед именем параметра нужно указать ключевое слово val
(только для чтения) или var
(для чтения и редактирования).
class Person(val name: String, var age: Int)
При этом любому из свойств можно присвоить значение по умолчанию. Тогда при создании экземпляра класса для этого свойства значение можно либо не указывать, либо указать, если оно отличается от стандартного.
class Person(val name: String, var age: Int = 30)
...
val adam = Person("Adam")
val alice = Person("Alice", 25)
println("${adam.name}, ${adam.age}") // Adam, 30
println("${alice.name}, ${alice.age}") // Alice, 25
У класса может быть суперкласс. Тогда его основной конструктор должен инициализировать свойства, унаследованные от суперкласса.
open class Base(p: Int)
class Person(val name: String, var age: Int = 30, val p: Int) : Base(p)
...
val adam = Person("Adam", 30, 1000)
println(adam.p) // 1000
Конструктор можно сделать приватным. Тогда никто и ничто не сможет создать экземпляр этого класса.
class Person private constructor(val name: String, var age: Int)
...
val adam = Person("Adam", 30) // вылетит ошибка
2. Вторичный конструктор
Также известен как вспомогательный, дополнительный, secondary
конструктор. Вторичный конструктор используется в том случае, когда необходимо определить альтернативный способ создания класса. В Kotlin это применяется редко, так как обычно основного конструктора бывает достаточно благодаря возможности добавлять значения по умолчанию и использовать именованные аргументы.
Объявляется вторичный конструктор внутри тела класса при помощи ключевого слова constructor
.
class Person {
constructor(id: Int) {
...
}
}
При этом если у класса есть основной конструктор, то все вторичные конструкторы обязательно должны явно или косвенно его вызывать. Подразумевается, что либо вторичный конструктор сам вызывает основной конструктор, либо сначала вызывает другой вторичный конструктор, который в свою очередь обращается к основному конструктору. Обращение к основному конструктору осуществляется при помощи ключевого слова this
.
class Person(val name: String, var age: Int) {
constructor(name: String, age: Int, id: Int) : this(name, age) {
...
}
}
Если основного конструктора нет, то и обращаться к нему не надо.
Во вторичном конструкторе нельзя объявлять свойства класса. Все передаваемые ему параметры можно использовать либо для передачи основному конструктору, либо для инициализации свойств, объявленных в теле класса.
class Person(val name: String, var age: Int) {
var id: Int = 0
constructor(name: String, age: Int, id: Int) : this(name, age) {
this.id = id
}
}
Также во вторичный конструктор можно добавить какую-либо логику.
class Person(val name: String, var age: Int) {
var id: Int = 0
constructor(name: String, age: Int, id: Int) : this(name, age) {
if (id > 0) this.id = id * 2
}
}
Если у класса есть суперкласс, но нет основного конструктора, то каждый вторичный конструктор должен обращаться к конструктору суперкласса при помощи ключевого слова super
.
open class Base(val p: Int)
class Person : Base {
constructor(name: String, age: Int, p: Int) : super(p)
}
...
val adam = Person("Adam", 30, 1)
println(adam.p)
Блок инициализации (init блок)
Основной конструктор не может в себе содержать какую-либо логику по инициализации свойств (исполняемый код). Он предназначен исключительно для объявления свойств и присвоения им полученных значений. Поэтому вся логика может быть помещена в блок инициализации — блок кода, обязательно выполняемый при создании объекта независимо от того, с помощью какого конструктора этот объект создаётся. Помечается он словом init
.
class Person(val name: String, var age: Int) {
var id: Int = 0
// require выдает ошибку с указанным текстом, если условие в левой части false
init {
require(name.isNotBlank(), { "У человека должно быть имя!" })
require(age > -1, { "Возраст не может быть отрицательным." })
}
constructor(name: String, age: Int, id: Int) : this(name, age) {
if (id > 0) this.id = id * 2
}
}
По сути блок инициализации — это способ настроить переменные или значения, а также проверить, что были переданы допустимые параметры. Код в блоке инициализации выполняется сразу после создания экземпляра класса, т.е. сразу после вызова основного конструктора. В классе может быть один или несколько блоков инициализации и выполняться они будут последовательно.
class Person(val name: String, var age: Int) {
// сначала вызывается основной конструктор и создаются свойства класса
// далее вызывается первый блок инициализации
init {
...
}
// после первого вызывается второй блок инициализации
init {
...
}
// и т.д.
}
Блок инициализации может быть добавлен, даже если у класса нет основного конструктора. В этом случае его код будет выполнен раньше кода вторичных конструкторов.
Что такое Data классы?
Data
класс предназначен исключительно для хранения каких-либо данных.
Основное преимущество: для параметров, переданных в основном конструкторе автоматически будут переопределены методы toString()
, equals()
, hashCode()
, copy()
.
Также для каждой переменной, объявленной в основном конструкторе, автоматически генерируются функции componentN()
, где N
— номер позиции переменной в конструкторе.
Благодаря наличию вышеперечисленных функций внутри data
класса мы исключаем написание шаблонного кода.
Что такое мульти-декларации (destructuring declarations)?
Мульти-декларации (destructuring declarations или деструктуризирующее присваивание) — это способ извлечения значений из объекта и присвоения их сразу нескольким переменным. В Kotlin этот механизм поддерживается с помощью оператора распаковки (destructuring operator) — componentN()
, где N
— номер компонента.
При создании data
класса Kotlin автоматически создает функции componentN()
для каждого свойства класса, где N
— номер позиции переменной в конструкторе. Функции componentN()
возвращают значения свойств в порядке их объявления в конструкторе. Это позволяет использовать мульти-декларации для распаковки значений свойств и присваивания их отдельным переменным.
Например, если у нас есть data
класс Person
с двумя свойствами name
и age
, мы можем использовать мульти-декларации, чтобы извлечь эти свойства и присвоить их двум переменным:
data class Person(val name: String, val age: Int)
val person = Person("Alice", 29)
val (name, age) = person
println(name) // Alice
println(age) // 29
Также можно использовать мульти-декларации в циклах, чтобы итерироваться по спискам объектов и распаковывать значения свойств:
val people = listOf(Person("Alice", 30), Person("Bob", 40))
for ((name, age) in people) {
println("$name is $age years old")
}
// Alice is 30 years old
// Bob is 40 years old
Мульти-декларации также могут быть использованы с массивами и другими коллекциями:
val list = listOf("apple", "banana", "orange")
val (first, second, third) = list
println(first) // apple
println(second) // banana
println(third) // orange
Что делает функция componentN()?
Функция componentN()
возвращает значение переменной и позволяет обращаться к свойствам объекта класса по их порядковому номеру. Генерируется автоматически только для data
классов.
Также функцию componentN()
можно создать самому для класса, который не является data
классом.
class Person(val firstName: String, val lastName: String, val age: Int) {
operator fun component1() = firstName
operator fun component2() = lastName
operator fun component3() = age
}
Теперь можно использовать мульти-декларации для класса Person
:
val person = Person("John", "Doe", 30)
val (firstName, lastName, age) = person
println("$firstName $lastName is $age years old.")
В данном примере мы определили функции component1()
, component2()
и component3()
как операторы с ключевым словом operator
. Они возвращают значения свойств firstName
, lastName
и age
соответственно. После этого мы можем использовать мульти-декларации для разбивки объекта Person
на отдельные переменные.
Какие требования должны быть соблюдены для создания data класса?
- Класс должен иметь хотя бы одно свойство, объявленное в основном конструкторе.
- Все параметры основного конструктора должны быть отмечены val
или var
(рекомендуется val
).
- Классы данных не могут быть abstract
, open
, sealed
или inner
.
Можно ли наследоваться от data класса?
От data
класса нельзя наследоваться т.к. он является final
классом, но он может наследоваться от других классов.
Что такое абстрактные классы?
Абстрактный класс — это класс, представляющий из себя "заготовку" для целого семейства классов, который описывает для них общий шаблон поведения. Экземпляр такого класса не может быть создан. Абстрактному классу не нужен модификатор open
, потому что он "открыт" для наследования по умолчанию.
В теле класса можно объявлять абстрактные свойства и функции. Это полезно, когда часть поведения класса не имеет смысла без реализации в более конкретном подклассе.
abstract class Tree {
abstract val name: String
abstract val description: String
abstract fun info()
}
Каждый наследник обязан переопределять их все.
class Pine : Tree() {
override val name = "Сосна"
override val description = "Хвойное дерево с длинными иглами и округлыми шишками"
override fun info() = "$name - ${description.toLowerCase()}."
}
Свойства и функции необязательно должны быть абстрактными. У них может быть обобщенная реализация, которая будет с пользой наследоваться всеми подклассами. В этом случае для них в абстрактном классе объявляется конкретная реализация, к которой имеют доступ все наследники.
abstract class Tree {
abstract val name: String
abstract val description: String
fun info(): String = "$name - ${description.toLowerCase()}."
}
...
class Pine : Tree() {
override val name = "Сосна"
override val description = "Хвойное дерево с длинными иглами и округлыми шишками"
}
...
val pine = Pine()
println(pine.info())
Так как этот компонент класса уже не будет абстрактным, наследники не смогут его переопределить.
class Pine : Tree() {
override val name = "Сосна"
override val description = "Хвойное дерево с длинными иглами и округлыми шишками"
// ошибка: функция "info" является "final" и не может быть переопределена
override fun info() = description
}
Чтобы это исправить, нужно явно задать модификатор open
для функции с конкретной реализацией. Тогда у наследников появляется выбор: либо не переопределять функцию и использовать реализацию суперкласса, либо переопределить и указать свою собственную реализацию.
abstract class Tree {
abstract val name: String
abstract val description: String
open fun info(): String = "$name - ${description.toLowerCase()}."
}
У абстрактного класса может быть конструктор.
abstract class Tree(val name: String, val description: String) {
open fun info(): String = "$name - ${description.toLowerCase()}."
}
Тогда каждый наследник должен предоставить для него значения.
class Pine(name: String, description: String) : Tree(name, description)
...
val pine = Pine("Сосна", "Хвойное дерево с длинными иглами и округлыми шишками")
println(pine.info())
Что такое интерфейс?
Интерфейс — это совокупность методов и правил, которые определяют поведение класса или общее поведение для группы независимых друг от друга классов. Интерфейсы похожи на абстрактные классы тем, что нельзя создать их экземпляры и они могут определять абстрактные или конкретные функции и свойства. Отличие в том, что интерфейсу не важна связь "родитель-наследник", он задаёт лишь правила поведения.
Интерфейсы в Kotlin могут содержать объявления абстрактных методов, а также методы с реализацией. Главное отличие интерфейсов от абстрактных классов заключается в невозможности хранения переменных экземпляров. Они могут иметь свойства, но те должны быть либо абстрактными, либо предоставлять реализацию методов доступа.
В теле интерфейса можно определять абстрактные свойства и функции. Для этого не требуется использовать ключевое слово abstract
, так как Kotlin способен сам понять, что свойство и функция без реализации должны быть абстрактными. Также обратите внимание, что единственный способ определить свойство — это определить его в теле интерфейса, так как у интерфейса не бывает конструкторов.
interface Cultivable {
val bloom: Boolean
fun startPhotosynthesis()
}
Класс должен реализовывать все абстрактные свойства и функции, определённые в интерфейсе.
abstract class Tree : Cultivable {
abstract val name: String
abstract val description: String
open fun info(): String = "$name - ${description.toLowerCase()}."
override val bloom = false
override fun startPhotosynthesis() {
...
}
}
При этом если интерфейс реализовывается в абстрактном классе, то свойства и функции интерфейса могут быть в нём опущены. Тогда все наследники абстрактного класса должны будут их переопределять.
abstract class Tree : Cultivable {
abstract val name: String
abstract val description: String
open fun info(): String = "$name - ${description.toLowerCase()}."
override fun startPhotosynthesis() {
...
}
}
class Pine : Tree() {
override val name = "Сосна"
override val description = "Хвойное дерево с длинными иглами и округлыми шишками"
override val bloom = false
}
В интерфейсе можно определять свойства и функции с конкретной реализацией (по умолчанию). Классы, реализующие этот интерфейс, могут использовать реализацию по умолчанию или определить свою. При этом реализация свойств осуществляется с помощью метода доступа get()
.
interface Cultivable {
val bloom: Boolean
get() = false
fun startPhotosynthesis() {
...
}
}
Один интерфейс может реализовать другой интерфейс, при этом будет иметь доступ к его свойствам и функциям.
interface Fruitable {
val fruit: String
get() = "неплодоносный"
}
interface Cultivable : Fruitable {
...
fun isFruitable() : Boolean {
if(fruit == "неплодоносный") return false
return true
}
}
Каждый класс, реализующий интерфейс Cultivable
может использовать свойства и функции интерфейса Fruitable
, если в этом есть необходимость.
class AppleTree() : Tree() {
override val name = "Яблоня"
override val description = "Фруктовое дерево"
override val fruit = "яблоко"
}
...
val appleTree = AppleTree()
if(appleTree.isFruitable()) {
println("Плод - ${appleTree.fruit}.")
} else {
println("${appleTree.name} не плодоносит.")
}
Как выбрать, что применять — абстрактный класс или интерфейс?
1. У вас есть семейство классов, из которых можно выделить общую сущность? Определите эту сущность в качестве абстрактного класса и она будет “заготовкой” для всего семейства. 2. Вам нужно создать более конкретную версию класса? Создайте подкласс этого класса и добавьте недостающее поведение. 3. Требуется определить общее поведение для группы независимых друг от друга классов? Создайте интерфейс и реализуйте его теми классами, которым необходимо это поведение. Абстрактный класс — это "заготовка" для целого семейства классов. Нельзя создать экземпляр абстрактного класса. Абстрактный класс может содержать как абстрактные, так и конкретные реализации свойств и функций. Класс, который содержит абстрактное свойство или функцию, должен быть объявлен абстрактным. Абстрактный класс может быть без единого абстрактного свойства или функции. У класса может быть только один суперкласс. Наследники абстрактного класса должны переопределять все его абстрактные свойства и функции. Чтобы наследники могли переопределять конкретные реализации свойств и функций, для них в абстрактном классе должен быть явно указан модификатор open. У абстрактного класса может быть конструктор. Интерфейс определяет поведение класса или общее поведение для группы независимых друг от друга классов. Нельзя создать экземпляр интерфейса. Интерфейс может содержать как абстрактные, так и конкретные реализации функций. Свойства интерфейсов могут быть абстрактными, а могут иметь get() методы. Класс может реализовывать несколько интерфейсов. Класс должен реализовывать все абстрактные свойства и функции, определённые в интерфейсе. Если интерфейс реализовывается абстрактным классом, то переопределение его абстрактных свойств и функций может быть передано наследникам абстрактного класса. Интерфейс может реализовывать другой интерфейс.
Почему классы в Kotlin по умолчанию final?
Классы в Kotlin по умолчанию являются final
для того, чтобы избежать случайного наследования и переопределения методов. Это сделано для повышения безопасности кода и уменьшения сложности программы, так как ограничение наследования помогает избежать ошибок, связанных с неожиданным изменением поведения унаследованных методов.
В Kotlin рекомендуется использовать композицию вместо наследования для повторного использования кода и расширения функциональности.
Что нужно сделать, чтобы класс можно было наследовать?
По умолчанию, классы в Kotlin объявляются как final
, то есть их нельзя наследовать. Если мы всё же попытаемся наследоваться от такого класса, то получим ошибку: “This type is final, so it cannot be inherited from”
.
Чтобы класс можно было наследовать, его нужно объявить с модификатором open
.
open class Fraction {
...
}
Не только классы, но и функции в Kotlin по умолчанию имеют статус final
. Поэтому те функции, которые находятся в родительском классе и которые вы хотите переопределить в дочерних классах, также должны быть отмечены open
.
open class Fraction {
open fun toAttack() {
...
}
}
Свойства класса также по умолчанию являются final
. Для возможности переопределения таких свойств в дочерних классах, не забудьте и их отметить ключевым словом open
.
open class Fraction {
open val name: String = "default"
open fun toAttack() {
...
}
}
При этом, если в открытом классе будут присутствовать функции и свойства, которые не отмечены словом open
, то переопределяться они не будут. Но дочерний класс сможет к ним обращаться.
open class Fraction {
open val name: String = "default"
fun toAttack() {
...
}
}
class Horde : Fraction() {
override val name = "Horde"
}
class SomeClass() {
val horde = Horde()
horde.toAttack()
}
Как можно получить тип класса?
1. Получение типа класса через функцию ::class
Функция ::class
возвращает объект KClass
, который содержит информацию о типе класса во время выполнения.
class Person(val name: String, val age: Int)
fun main() {
val person = Person("John", 30)
println(person::class) // выводит "class Person"
}
2. Получение типа класса через функцию javaClass
Функция javaClass
возвращает объект Class
, который содержит информацию о типе класса во время выполнения.
class Person(val name: String, val age: Int)
fun main() {
val person = Person("John", 30)
println(person.javaClass) // выводит "class Person"
}
3. Получение типа класса через функцию ::class.java
Вызов функции ::class.java
на объекте типа KClass
возвращает объект Class
, который содержит информацию о типе класса во время выполнения.
class Person(val name: String, val age: Int)
fun main() {
val person = Person("John", 30)
println(person::class.java) // выводит "class Person"
}
Что такое enum класс?
Если в процессе разработки возникает ситуация, когда переменная должна иметь определённые (заранее известные) значения — константы, то вместо того, чтобы плодить список констант, их все можно перечислить в классе, который был придуман специально для этого — enum
(класс перечислений). Он позволяет создать набор значений, которые могут быть использованы как единственно допустимые значения переменной. Каждая константа в классе перечислений является экземпляром этого класса и отделяется от другой константы запятой.
enum class ColorType {
RED,
BLUE,
GREEN
}
Чтобы ограничить переменную одним из значений класса перечислений, нужно назначить ей тип объявленного класса перечислений.
var color: ColorType
color = ColorType.RED
Помимо самих констант в класс перечислений можно добавить свойства и функции. Их необходимо отделять от констант точкой с запятой. Это единственное место в Kotlin, где используется точка с запятой.
enum class ColorType {
RED,
BLUE,
GREEN;
fun names() = "Красный, Голубой, Зелёный"
val rgb = "0xFFFFFF"
}
При этом каждая константа сможет обращаться к этому свойству или функции.
var color: ColorType = ColorType.RED
println(color.names()) // выведет "Красный, Голубой, Зелёный"
println(color.rgb) // выведет "0xFFFFFF"
Классы перечислений как и обычные классы также могут иметь конструктор. Так как константы являются экземплярами enum-класса, они могут быть инициализированы.
enum class Color(val rgb: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF)
}
Enum
-константы также могут объявлять свои собственные анонимные классы как с их собственными методами, так и с перегруженными методами базового класса. Напоминаю, что при объявлении в enum
-классе каких-либо членов, необходимо отделять их от объявления констант точкой с запятой.
enum class ProtocolState {
WAITING {
override fun signal() = TALKING
},
TALKING {
override fun signal() = WAITING
};
abstract fun signal(): ProtocolState
}
Что такое sealed класс?
Sealed class (изолированный класс)
— это класс, который является абстрактным и используется в Kotlin для ограничения классов, которые могут наследоваться от него.
Основная идея заключается в том, что sealed class
позволяет определить ограниченный и известный заранее набор подклассов, которые могут быть использованы.
- Конструктор изолированного класса всегда приватен, и это нельзя изменить.
- У sealed
класса могут быть наследники, но все они должны находиться в одном пакете с изолированным классом. Изолированный класс "открыт" для наследования по умолчанию, указывать слово open
не требуется.
- Наследники sealed
класса могут быть классами любого типа: data class
, объектом, обычным классом, другим sealed
классом. Классы, которые расширяют наследников sealed класса, могут находиться где угодно.
- Изолированные классы абстрактны и могут содержать в себе абстрактные компоненты.
- Изолированные классы нельзя инициализировать.
- При использовании when
, все подклассы, которые не были проверены в конструкции, будут подсвечены IDE.
- Не объявляется с ключевым словом inner
.
Пример sealed класса:
sealed class Shape {
class Circle(val radius: Double) : Shape()
class Rectangle(val width: Double, val height: Double) : Shape()
class Triangle(val base: Double, val height: Double) : Shape()
}
fun calculateArea(shape: Shape): Double {
return when (shape) {
is Shape.Circle -> Math.PI * shape.radius * shape.radius
is Shape.Rectangle -> shape.width * shape.height
is Shape.Triangle -> 0.5 * shape.base * shape.height
}
}
fun main() {
val circle = Shape.Circle(5.0)
val rectangle = Shape.Rectangle(2.0, 3.0)
val triangle = Shape.Triangle(4.0, 5.0)
println(calculateArea(circle)) // Output: 78.53981633974483
println(calculateArea(rectangle)) // Output: 6.0
println(calculateArea(triangle)) // Output: 10.0
}
В этом примере мы определили sealed class Shape
, который содержит три класса: Circle
, Rectangle
и Triangle
. Эти классы наследуются от Shape
. Это означает, что мы можем создавать объекты этих классов и использовать их, как объекты типа Shape
.
В функции calculateArea
мы используем выражение when
, чтобы определить тип фигуры и вернуть ее площадь. Таким образом, если мы передадим Shape.Circle
в calculateArea
, то будет вычислена площадь круга.
В функции main
мы создали объекты Circle
, Rectangle
и Triangle
и передали их в calculateArea
, чтобы вычислить их площади.
Какая разница между sealed class и enum?
Sealed class
и Enum
это два разных концепта в Kotlin, хотя их часто используют для ограничения набора возможных значений. Основная разница между ними:
- enum
представляет собой конечный список значений, которые объявляются заранее в момент компиляции, и не могут быть расширены или изменены во время выполнения программы
- sealed class
позволяет определять ограниченный набор значений, но эти значения могут быть расширены в будущем
В общем, enum class
используется для представления конечного списка опций или состояний, тогда как sealed class
используется для определения ограниченного набора значений, которые могут быть произвольными объектами.
Что такое inner (внутренние) и nested (вложенные) классы?
В Kotlin можно объявить один класс внутри другого. Это может быть полезно в тех случаях, когда вам нужно организовать код и логически связать классы между собой. Подобные классы разделяются на внутренние (inner) и вложенные (nested).
1. Внутренние классы (inner classes) имеют доступ к членам внешнего класса, даже если они объявлены как private
. Внутренний класс является частью внешнего класса и имеет доступ к его свойствам и методам. В Kotlin внутренний класс объявляется с помощью ключевого слова inner. Например:
class Outer {
private val outerProperty = "Outer Property"
inner class Inner {
fun innerMethod() {
println("Accessing outer property: $outerProperty")
}
}
}
В этом примере Inner
является внутренним классом, а Outer
является внешним классом. Inner
имеет доступ к членам Outer
, в том числе к приватным свойствам и методам, таким как outerProperty
.
2. Вложенные классы (nested classes) не имеют доступа к членам внешнего класса по умолчанию. Они имеют свои собственные члены, которые могут быть использованы только внутри класса. Например:
class Outer {
private val outerProperty = "Outer Property"
class Nested {
fun nestedMethod() {
println("Accessing nested property")
}
}
}
Здесь Nested
является вложенным классом. Он не имеет доступа к свойству outerProperty
, но может использовать свои собственные члены, такие как nestedMethod
.
3. Ключевое отличие: внутренний (inner) класс — это вложенный (nested) класс, который может обращаться к компонентам внешнего класса.