Любой класс, имеющий хотя бы один типовой параметр, вроде <T>
, называется шаблонным (generic). Такие примеры мы много раз встречали в библиотеке коллекций — например, ArrayList<E>
или HashMap<K, V>
. Шаблонные классы позволяют нам, в частности, создавать контейнеры из элементов заданного типа. Когда мы используем шаблонный класс для определения типа, нам необходимо указать его типовой аргумент.
-
List<String> list = …
— мы указываем конкретный аргумент, получая список именно из строк и это самый распространённый случай -
List list = …
— мы не указываем аргумент, создавая так называемый Raw type (сырой тип). Это legacy код (это означает, что он поддерживается исключительно для совместимости со старыми версиями Java, в которых шаблонных классов не было в принципе). Для использования в новом коде не рекомендуется. -
List<?> list = …
— мы используем джокер?
, создавая так называемый Wildcard type (подстановочный тип), соответствующий произвольному списку. На первый взгляд кажется, что по смыслу он близок к сырому типу, но на самом деле такой тип гораздо более безопасен.
-
и правила, с ним связанные. Возьмём пример:
public interface Animal {
void move();
}
public interface Horse extends Animal {
void run();
}
Здесь интерфейс Horse
наследует (расширяет) интерфейс Animal
. С точки зрения правил наследования это означает, что:
-
Horse is an Animal (лошадь является животным)
-
Лошадь может использоваться везде, где ожидается животное
В частности, допустим вот такой код
// ... in some class ...
public void test(Horse horse) {
horse.move() // Лошадь тоже умеет ходить
feed(horse) // Лошадь тоже можно кормить
Animal animal = horse; // Лошадь является животным
}
public void feed(Animal animal) {
// ...
}
Чтобы лучше понять, как все эти штуки работают, рассмотрим пример:
// ... in some class ...
public void test() {
// При вызове конструктора, мы просим компилятор вывести типовой аргумент ArrayList
List<Animal> animals = new ArrayList<>();
// Является ли список животных списком объектов?
List<Object> objects = animals;
// Можно ли в список объектов добавить строку?
objects.add("Some string");
// А здесь что происходит? Можно ли так?
animals.get(0).move();
}
Анализируя пример, заметим, что животное (Animal) является объектом — им является любой ссылочный тип в Java. А является ли список животных списком объектов? Если да, то присваивание objects = animals
возможно. Допустим пока, что это так.
Ответ на следующий вопрос очевидно положительный. В список объектов можно добавить объект. Строка — это объект. Значит, её можно использовать везде, где можно использовать объект.
Наконец, что происходит в последней строчке кода? Ссылочные переменные animals
и objects
указывают на один и тот же список. В этот список был добавлен один объект, его реальный тип — строка (String). Мы достаём его из списка с помощью get(0)
. Раз animals
является списком животных, значит, компилятор думает, что достанем мы животное. А животное может ходить (move). Теоретически этот код верен, практически же строка (String) не является животным (Animal) и не может ходить. Где же ошибка?
Если представить, что этот код компилируется верно, в последней его строчке очевидно должна произойти ошибка во время выполнения. Реально же ошибка в нём есть, и она заключается в том, что список животных не является списком объектов, точно так же как список лошадей не является списком животных. Вообще, любой список (при явном указании типа элементов) можно использовать вместо другого только в том случае, если тип элементов у них одинаковый. Это свойство списков (и шаблонных классов Java вообще) называется инвариантностью (invariant).
// ... in some class ...
public void test() {
List<Animal> animals = new ArrayList<>();
List<Object> objects = animals; // ERROR: incompatible types
objects.add("Some string");
animals.get(0).move();
}
Для сравнения, массивы в Java ведут себя не так:
// ... in some class ...
public void test() {
Animal[] animals = new Animal[10];
Object[] objects = animals; // OK: массивы ковариантны
objects[0] = "Some string"; // OK in compile-time: String is an Object
animals[0].move(); // OK in compile-time: Animal can move
}
Массивы, в отличие от шаблонных классов, в Java ковариантны (covariant). Это означает, что если лошадь является животным, то и массив лошадей является массивом животных. На практике (как в примере) это может приводить к проблемам при записи в массив: при выполнении строчки objects[0] = "Some string"
мы получим исключение ArrayStoreException
. Почему?
Вторым свойством массивов в Java является сохранение данных о реальном типе их элементов во время выполнения программы. По научному говорят так: Arrays in Java are reified (реифицированные, овеществлённые). Это означает, что массив objects
всё ещё знает о том, что хранимый тип элементов в нём — Animal
, и при попытке записать туда несовместимую строку происходит исключение.
А верно ли, что Lists (Generics) in Java are reified? Нет, это не так, и это второе отличие массивов от шаблонов. Про шаблонные классы в Java говорят: Generics in Java are erased (стираемые) или, более точно, Generics in Java have erased type parameters (типовые параметры шаблонных классов стираемые). Здесь получается, что слова reified и erased являются антонимами и относятся к типу элементов — в первом случае во время выполнения программы мы знаем его, во втором случае нет. Все списки в Java во время выполнения считают, что тип их хранимых элементов Object
, а контроль типов элементов выполняется исключительно при компиляции.
Тот факт, что типовые параметры стираются в Java, приводит к некоторым ограничениям при написании программы:
// ... in some class ...
// Функция использует Raw Type -- List
public void test(List list) {
// ERROR! Illegal generic type
if (list instanceof List<String>) {
String s = (String) list.get(0);
}
// OK
Object o = list.get(0);
if (o instanceof String) {
String s = (String) o;
}
}
public <T> T cast(Object o) {
// ERROR! class or array expected
if (o instanceof T) {
return (T) o;
}
// Здесь нет способа проверить тип
}
По факту, в Java программах возможны только проверки на типы без указания аргументов, например, instanceof String
или instanceof List
. Всё дело в том, что во время выполнения данные о типах элементов не сохраняются, а значит, проверки вида instanceof List<String>
оказываются невозможными.
Как же меняется поведение в Kotlin? Довольно существенно. Во-первых на нём тип Array<T>
(массив) — это тоже шаблон. И он инвариантен, как и все шаблоны — по умолчанию.
fun test(animals: Array<Animal>) {
val objects: Array<Any> = animals // ERROR: type mismatch
}
Во-вторых, неизменяемый список List<T>
в Kotlin ковариантен, а изменяемый MutableList<T>
инвариантен. Как этого добились в Kotlin? Дело в том, что заголовки списков выглядят так:
// out ~ covariant
interface List<out T> { ... }
// no modifier ~ invariant
interface MutableList<T> : List<T> { ... }
И действительно, в этом примере
// ... in some class ...
public void test() {
List<Animal> animals = new ArrayList<>();
List<Object> objects = animals; // ERROR: type mismatch
objects.add("Some string");
animals.get(0).move();
}
проблемы возникают только потому, что мы пытаемся что-то в список записать. Если же этого не делать, то всё в порядке:
fun test(animals: List<Animal>) {
val objects: List<Any> = animals // OK, List is covariant
objects[0].toString() // OK: у животного, как и у любого объекта, есть toString()
objects[0] = "Some string" // ERROR: List is immutable
}
В этом месте Kotlin весьма похож на Java. Типовые параметры в нём тоже стираются. За исключением…
fun test(animals: List<Animal>) {
val horses = animals.filterIsInstance<Horse>()
}
Здесь мы вызвали функцию фильтрации, которая сделает из списка животных другой список — лошадей. Все не-лошади во второй список не попадут. Как эта функция работает?
fun <R> Iterable<*>.filterIsInstance(): List<R> {
val result = mutableListOf<R>()
for (element in this) {
if (element is R) {
result += element
}
}
return result
}
Если написать эту функцию так, то мы ожидаемо получим ошибку из-за того, что R
— erased (стираемый). Попробуйте сообразить сами, в каком месте. Решение — явно объявить типовой параметр как reified
, а функцию — как inline
.
inline fun <reified R> Iterable<*>.filterIsInstance(): List<R> {
val result = mutableListOf<R>()
for (element in this) {
if (element is R) {
result += element
}
}
return result
}
Поскольку стираемость типовых параметров — особенность не только Java, но и JVM, то у Kotlin не было бы никакой возможности реализовать реификацию типовых параметров — если бы не inline
(подставляемые) функции. Подобные функции не компилируются в JVM-код сами по себе. Вместо этого везде, где в программе такая функция используется, вместо неё подставляется её тело. И код неявно превращается в примерно такой:
fun test(animals: List<Animal>) {
// Вместо этого вызова происходит подстановка
// val horses = animals.filterIsInstance<Horse>()
val result = mutableListOf<Horse>()
for (element in this) {
// Корректно, Horse -- это интерфейс
if (element is Horse) {
result += element
}
}
val horses: List<Horse> = result
}
Как я уже говорил, сырые типы — наследие (legacy) старых версий Java. Шаблонные классы появились в пятой (1.5) версии Java, до этого момента коллекции были обычными классами, типовые параметры и аргументы не указывались. Например:
// ... in some class ...
public void test(Horse horse) {
List animals = new ArrayList(); // Программист надеется, что по названию запомнит, что это животные...
animals.add(horse); // WARNING: unchecked call
((Animal) animals.get(0)).move(); // Приходится всюду писать преобразования типов...
use(animals); // OK (здесь список и там список)
((Animal) animals.get(1)).move(); // Внезапно, ClassCastException -- там оказалась строка...
}
public void use(List objects) {
objects.add("42"); // OOPS! Мы забыли, что тут были животные, а не просто объекты...
}
Подобный код будет компилироваться и в нынешней версии Java, однако вы получите ряд предупреждений об так называемых unchecked call — вызовах без проверок типов элементов. Минусы их должны быть видны из этого примера — лишние, а иногда и небезопасные, преобразования типов. Проблемы, которые шаблонные классы при явном указании типа элементов позволяют находить во время компиляции, здесь находятся только во время выполнения программы, что всегда хуже.
С точки зрения системы типов сырой список совместим с любым списком, что тоже отрицательно влияет на безопасность. Например:
// ... in some class ...
public void test() {
List<Animal> animals = new ArrayList<>();
List objects = animals;
List<String> strings = objects; // WARNING: unchecked assignment
}
В этом коде мы получим предупреждение, но он компилируется. В результате компилятор думает, что strings
содержит строки, хотя на самом деле это не так. Это легко позволяет вызвать ошибку во время выполнения (добейтесь это сами, пожалуйста).
Они же джокеры, они же wildcard types. Речь идёт о типах вида List<?>
— список чего угодно, или, в более сложном варианте, List<? extends Animal>
— список животных или какого-то вида животных. Они были добавлены для того, чтобы была возможность реализовывать операции над шаблонными классами в обобщённом виде. С первого взгляда может показаться, что List<?>
эквивалентен сырому типу List
, но это далеко не так. Подставляемый тип имеет гораздо больше ограничений.
// ... in some class ...
public void test(Animal animal) {
List<?> list0 = new ArrayList<?>(); // ERROR!
List<?> list = new ArrayList<Animal>(); // OK
list.add(animal); // ERROR!
list.add(new Object()); // ERROR!
list.get(0).move(); // ERROR! Object cannot move
list.get(0).toString(); // OK: Object has toString
var element = list.get(0); // OK: JVM 11, type = Object
}
Добавить в подобный список ничего не удаётся вообще — параметр метода add
у List<E>
имеет тип E
, а поскольку в данном коде компилятор не знает конкретный тип элементов списка, то эта операция в любом случае небезопасна. С функцией get
проще — у неё нет параметров, а тип E
относится к результату. В данном случае компилятор может безопасно предположить, что результат имеет тип Object
, а для List<? extends Animal>
— даже более точно Animal
.
-
Чем отличается инвариантность от ковариантности (invariant vs covariant)?
-
Чем отличается стираемость от овеществлённости (erased vs reified)?
-
Какие типы в языке, которым вы пользуетесь, инвариантны? Какие ковариантны?
Где в программе на Java ниже будут обнаружены ошибки при компиляции?
// ... in some class ...
public void test() {
List<Object> list1 = new ArrayList<String>();
List<String> list2 = new ArrayList<String>();
List<String> list3 = new ArrayList<Object>();
Object[] array1 = new String[10];
String[] array2 = new String[10];
String[] array3 = new Object[10];
}
Где в программе на Kotlin ниже будут обнаружены ошибки при компиляции?
fun test() {
val list1: List<Any> = listOf<String>()
val list2: List<String> = listOf<String>()
val list3: List<String> = listOf<Any>()
val list4: List<Any> = mutableListOf<String>()
val list5: List<String> = mutableListOf<String>()
val list6: List<String> = mutableListOf<Any>()
val mutableList1: MutableList<Any> = listOf<String>()
val mutableList2: MutableList<String> = listOf<String>()
val mutableList3: MutableList<String> = listOf<Any>()
val mutableList4: MutableList<Any> = mutableListOf<String>()
val mutableList5: MutableList<String> = mutableListOf<String>()
val mutableList6: MutableList<String> = mutableListOf<Any>()
}
Главы из Effective Java. Joshua Bloch (номера глав даю по второму изданию):
-
23. Don’t use raw types in new code.
-
25. Prefer lists to arrays.
-
26. Favor generic types.
-
28. Use bounded wildcards to increase API flexibility.
Выдержки из Kotlin reference: