Обобщения или generics (обобщенные типы и методы) позволяют нам уйти от жесткого определения используемых типов и определять классы/интерфейсы и методы, которые не привязаны к определенным типам. Благодаря обобщениям возможно написание кода, который можно повторно использовать для различных типов.
Чтобы разобраться в особенности данного явления, сначала посмотрим на проблему, которая могла возникнуть до появления обобщенных типов. Посмотрим на примере. Допустим, мы определяем класс для хранения данных пользователя:
class Person {
private int id;
private String name;
int getId(){ return id; }
String getName(){ return name; }
Person(int id, String name)
{
this.id = id;
this.name = name;
}
}
Класс Person определяет два поля: id - уникальный идентификатор пользователя и name - имя пользователя. Причем здесь идентификатор пользователя задан как числовое значение, то есть это будут значения 1, 2, 3, 4 и так далее.
Однако также нередко для идентификатора используются и строковые значения. И у числовых, и у строковых значений есть свои плюсы и минусы. И на момент написания класса мы можем точно не знать, что лучше выбрать для хранения идентификатора - строки или числа. Либо, возможно, этот класс будет использоваться другими разработчиками, которые могут иметь свое мнение по данной проблеме, например, они могут для представления идентификатора создать специальный класс.
И на первый взгляд, чтобы выйти из подобной ситуации, можно определить поле id как поле типа Object. Так как тип Object является
универсальным типом, от которого наследуется все типы, соответственно в подобном поле мы можем сохранить и строки, и числа:
class Person {
private Object id; // универсальный идентификатор - и числа, и строки
private String name;
Object getId(){ return id; }
String getName(){ return name; }
Person(Object id, String name)
{
this.id = id;
this.name = name;
}
}
Далее в программе мы можем использовать этот класс, передавая в его конструктор для id и числа, и строки:
class Program {
public static void main(String[] args){
var tom = new Person(546, "Tom");
tom.print(); // Person. Id: 546; Name: Tom
var bob = new Person("zpio9", "Bob");
bob.print(); // Person. Id: zpio9; Name: Bob
}
}
class Person {
private Object id; // универсальный идентификатор - и числа, и строки
private String name;
Object getId(){ return id; }
String getName(){ return name; }
Person(Object id, String name)
{
this.id = id;
this.name = name;
}
}
Все вроде замечательно работает, но такое решение является не очень оптимальным. Дело в том, что в данном случае мы сталкиваемся с преобразованиями.
Так, при передаче в конструктор значения типа int, происходит упаковка этого значения в тип Integer с последующим преобразованием в тип Object
(int -> Integer -> Object):
var tom = new Person(546, "Tom"); // преобразование значения int в тип Integer и Object
В случае со строкой также имеется восходящее преобразование (String -> Object), но оно менее критично:
var bob = new Person("zpio9", "Bob");
Но если восходящее преобразование происходит автоматически, то чтобы обратно получить данные в переменную типов int или String, нам необходимо явным образом выполнить преобразование типов (нисходящее преобразование):
int tomId = (int)tom.getId(); // Object -> int String bobId = (String) bob.getId(); // Object -> String
Другая проблема, с которой мы здесь можем столкнуться, - это проблема безопасности типов. Так, мы получим ошибку во время выполнения программы, если напишем следующим образом:
class Program {
public static void main(String[] args) {
var tom = new Person(546, "Tom");
String tomId = (String)tom.getId(); // !Ошибка - Исключение java.lang.ClassCastException
System.out.printf("tom id: %s\n", tomId);
}
}
Мы можем не знать, какой именно объект представляет id, и при попытке получить число в данном случае мы столкнемся с исключением java.lang.ClassCastException.
Причем с исключением мы столкнемся на этапе выполнения программы, то есть на этапе компиляции мы никак не сможем отследить эту проблему.
Проблема может показаться искуственной, так как в данном случае мы видим, что в конструктор передается число, поэтому мы вряд ли будем пытаться преобразовывать его в строку. Однако в процессе
разработки мы можем не знать, какой именно тип представляет значение в id, и при попытке получить значение в нужном нам типе мы можем столкнуться с исключением java.lang.ClassCastException.
Писать для каждого отдельного типа свою версию класса Person тоже не является хорошим решением, так как в этом случае мы вынуждены повторяться.
Для решения этих проблем в язык Java была добавлена поддержка обобщений - обобщенных типов и методов, которые также часто называют универсальными типами и методами, а по английский generics. Обобщения позволяют указать конкретный тип, который будет использоваться. Поэтому определим класс Person как обощенный:
class Person<T> {
private T id;
private String name;
T getId(){ return id; }
String getName(){ return name; }
Person(T id, String name)
{
this.id = id;
this.name = name;
}
}
Угловые скобки в описании class Person<T> указывают, что класс является обобщенным, а тип T, заключенный в угловые
скобки, будет использоваться этим классом. Необязательно использовать именно букву T, это может быть и любая другая буква или набор символов.
Причем сейчас на этапе написания кода нам неизвестно, что это будет за тип, это может быть любой тип. Поэтому параметр T в угловых скобках
еще называется универсальным параметром, так как вместо него можно подставить любой тип.
Метод getId() возвращает значение переменной id, но так как данная переменная представляет тип T, то данный метод также возвращает объект типа T: T getId().
Например, вместо параметра T можно использовать объект Integer, то есть число, представляющее номер пользователя. Это также может быть объект String, либо любой другой тип (класс или даже интерфейс):
class Program {
public static void main(String[] args){
Person<Integer> tom = new Person<Integer>(546, "Tom"); // преобразование типов не нужно
Person<String> bob = new Person<String>("abc123", "Bob"); // преобразование типов не нужно
Integer tomId = tom.getId(); // преобразование типов не нужно
String bobId = bob.getId(); // преобразование типов не нужно
System.out.println(tomId); // 546
System.out.println(bobId); // abc123
}
}
class Person<T> {
private T id;
private String name;
T getId(){ return id; }
String getName(){ return name; }
Person(T id, String name)
{
this.id = id;
this.name = name;
}
}
При определении переменной даннного класса и создании объекта после имени класса в угловых скобках нужно указать, какой именно тип будет использоваться
вместо универсального параметра. Например, первый объект будет использовать тип Integer, то есть вместо T будет подставляться Integer:
Person<Integer> tom = new Person<Integer>(546, "Tom");
И в данном случае фактически мы будем работать с классом
class Person {
private Integer id;
private String name;
Integer getId(){ return id; }
String getName(){ return name; }
Person(Integer id, String name)
{
this.id = id;
this.name = name;
}
}
При этом надо учитывать, что они работают только с объектами, но не работают с примитивными типами.
То есть мы можем написать Person<Integer>, но не можем использовать тип int или double, например, Person<int>.
Вместо примитивных типов надо использовать классы-обертки: Integer вместо int, Double вместо double и т.д.
А второй объект использует тип String:
Person<String> bob = new Person<String>("abc123", "Bob");
При попытке передать для параметра id значение другого типа мы получим ошибку компиляции:
Person<Integer> tom = new Person<Integer>("546", "Tom"); // ошибка компиляции
А при получении значения через getId() нам больше не потребуется операция приведения типов:
Integer tomId = tom.getId(); // преобразование типов не нужно
Тем самым мы избежим проблем с типобезопасностью и необходимости выполнять преобразования типов. Если же и возникнет проблема с преобразованиями, то компилятор выявит эти проблемы уже на этапе компиляции. Соответственно мы не получим падающую программу. Таким образом, используя обобщенный вариант класса, мы снижаем время на выполнение и количество потенциальных ошибок.
При этом универсальный параметр также может представлять обобщенный тип:
// класс компании
class Company<P>{
private P ceo; // президент компании
P getCEO(){ return ceo; }
Company(P ceo){
this.ceo = ceo;
}
}
class Person<T> {
private T id;
private String name;
T getId(){ return id; }
String getName(){ return name; }
Person(T id, String name)
{
this.id = id;
this.name = name;
}
}
Здесь класс компании определяет поле ceo, которое хранит президента компании. И мы можем передать для этого свойства значение типа Person, типизированного каким-нибудь типом:
class Program {
public static void main(String[] args){
Person<Integer> tom = new Person<Integer>(546, "Tom");
Company<Person<Integer>> melkosoft= new Company<Person<Integer>>(tom);
var ceo = melkosoft.getCEO();
System.out.println(ceo.getId()); // 546
System.out.println(ceo.getName()); // Tom
}
}
Обобщения могут использовать несколько универсальных параметров одновременно, которые могут представлять одинаковые или различные типы:
class Person<T, K> {
private T id;
private K password;
private String name;
T getId(){ return id; }
K getPassword(){ return password; }
String getName(){ return name; }
Person(T id, String name, K pass)
{
this.id = id;
this.name = name;
this.password = pass;
}
}
Здесь класс Person использует два универсальных параметра: один параметр для идентификатора id, другой параметр - для поля, которое представляет пароль (посколько пароль может существовать в различных формах - текстовый, голосовой и т.д.). Применим данный класс:
class Program {
public static void main(String[] args){
Person<Integer, String> tom = new Person<Integer, String>(546, "Tom", "qwerty12345");
System.out.println(tom.getId()); // 546
System.out.println(tom.getPassword()); // qwerty12345
}
}
Здесь объект Person типизируется типами Integer и String. То есть в качестве универсального параметра T используется
тип Integer, а для параметра K - тип String.
Интерфейсы, как и классы, также могут быть обобщенными. Например:
public class Program{
public static void main(String[] args) {
Accountable<String> acc1 = new Account("1235rwr", 5000);
Account acc2 = new Account("2373", 4300);
System.out.println(acc1.getId());
System.out.println(acc2.getId());
}
}
interface Accountable<T>{
T getId();
int getSum();
void setSum(int sum);
}
class Account implements Accountable<String>{
private String id;
private int sum;
Account(String id, int sum){
this.id = id;
this.sum = sum;
}
public String getId() { return id; }
public int getSum() { return sum; }
public void setSum(int sum) { this.sum = sum; }
}
В данном случае интерфейс Accountable представляет базовый функционал для банковского счета и объявляет методы для возвращения идентификатора и получения/установки суммы счета. А класс Account реализует его.
При реализации подобного интерфейса есть две стратегии. В данном случае реализована первая стратегия, когда при реализации для универсального параметра интерфейса задается конкретный тип, как например, в данном случае это тип String. Тогда класс, реализующий интерфейс, жестко привязан к этому типу.
Вторая стратегия представляет определение обобщенного класса, который также использует тот же универсальный параметр:
public class Program{
public static void main(String[] args) {
Account<String> acc1 = new Account<String>("1235rwr", 5000);
Account<String> acc2 = new Account<String>("2373", 4300);
System.out.println(acc1.getId());
System.out.println(acc2.getId());
}
}
interface Accountable<T>{
T getId();
int getSum();
void setSum(int sum);
}
class Account<T> implements Accountable<T>{
private T id;
private int sum;
Account(T id, int sum){
this.id = id;
this.sum = sum;
}
public T getId() { return id; }
public int getSum() { return sum; }
public void setSum(int sum) { this.sum = sum; }
}
Кроме обобщенных типов можно также создавать обобщенные методы, которые точно также будут использовать универсальные параметры. Например:
public class Program{
public static void main(String[] args) {
Printer printer = new Printer();
String[] people = {"Tom", "Alice", "Sam", "Kate", "Bob", "Helen"};
Integer[] numbers = {23, 4, 5, 2, 13, 456, 4};
printer.<String>print(people);
printer.<Integer>print(numbers);
}
}
class Printer{
public <T> void print(T[] items){
for(T item: items){
System.out.println(item);
}
}
}
Особенностью обобщенного метода является использование универсального параметра в объявлении метода после всех модификаторов и перед типом возвращаемого значения.
public <T> void print(T[] items)
Затем внутри метода все значения типа T будут представлять данный универсальный параметр.
При вызове подобного метода перед его именем в угловых скобках указывается, какой тип будет передаваться на место универсального параметра:
printer.<String>print(people); printer.<Integer>print(numbers);
Однако в этом случае (как и в большинстве случаев) при вызове метода также можно опустить параметр типа (<String> / <Integer>). У компилятора достаточно информации,
чтобы определить нужный вам метод. Он сопоставляет тип аргументов с обобщенным типом T и делает вывод, что T в первом случае должен быть String и во втором -
Integer. То есть, можно просто вызвать
Printer printer = new Printer();
String[] people = {"Tom", "Alice", "Sam", "Kate", "Bob", "Helen"};
Integer[] numbers = {23, 4, 5, 2, 13, 456, 4};
printer.print(people);
printer.print(numbers);
Конструкторы как и методы также могут быть обобщенными. В этом случае перед конструктором также указываются в угловых скобках универсальные параметры:
public class Program{
public static void main(String[] args) {
Person acc1 = new Person("cid2373", 5000);
Person acc2 = new Person(53757, 4000);
System.out.println(acc1.getId());
System.out.println(acc2.getId());
}
}
class Person{
private String id;
private int sum;
<T>Person(T id, int sum){
this.id = id.toString();
this.sum = sum;
}
public String getId() { return id; }
public int getSum() { return sum; }
public void setSum(int sum) { this.sum = sum; }
}
В данном случае конструктор принимает параметр id, который представляет тип T. В конструкторе его значение превращается в строку и сохраняется в локальную переменную.