CodeGym /Blog Java /Poland /Generyczne w Javie: jak używać nawiasów ostrych w praktyc...
Autor
Artem Divertitto
Senior Android Developer at United Tech

Generyczne w Javie: jak używać nawiasów ostrych w praktyce

Opublikowano w grupie Poland

Wstęp

Począwszy od JSE 5.0, do arsenału języka Java dodano typy generyczne.

Czym są typy generyczne w Javie?

Typy generyczne to specjalny mechanizm Javy służący do implementacji programowania uogólnionego — sposób opisywania danych i algorytmów, który umożliwia pracę z różnymi typami danych bez zmiany opisu algorytmów. Strona Oracle zawiera osobny samouczek poświęcony typom generycznym: "Lekcja". Aby zrozumieć typy generyczne, musisz najpierw dowiedzieć się, dlaczego są potrzebne i co dają. Sekcja samouczka "Dlaczego używać generycznych?" mówi, że niektóre z celów to silniejsze sprawdzanie typów w czasie kompilacji i eliminacja potrzeby jawnych rzutowań. Przygotujmy się do kilku testów w naszym ukochanym kompilatorze Java online Tutorialspoint. Załóżmy, że masz następujący kod:

import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List list = new ArrayList();
		list.add("Hello");
		String text = list.get(0) + ", world!";
		System.out.print(text);
	}
}
Ten kod działa doskonale. Ale co, jeśli szef przyjdzie do nas i powie: "Witaj świecie!" jest nadużywanym zwrotem i że musisz zwracać tylko "Witaj"? Usuniemy kod doklejający " świecie!" To wydaje się nieszkodliwe, prawda? Ale w rzeczywistości otrzymamy błąd W CZASIE KOMPILACJI:

error: incompatible types: Object cannot be converted to String
Problem polega na tym, że w naszej liście (List) przechowujemy obiekty. String jest potomkiem Object (ponieważ wszystkie klasy Java domyślnie dziedziczą Object), co oznacza, że potrzebujemy jawnego rzutowania, ale go nie dodaliśmy. Podczas operacji konkatenacji zostanie wywołana statyczna metoda String.valueOf(obj) przy użyciu obiektu. Ostatecznie, wywołana zostanie metoda toString klasy Object. Innymi słowy, List zawiera Object. Oznacza to, że wszędzie tam, gdzie potrzebujemy określonego typu (nie Object), musimy sami wykonać konwersję typu:

import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List list = new ArrayList();
		list.add("Hello!");
		list.add(123);
		for (Object str : list) {
		    System.out.println("-" + (String)str);
		}
	}
}
Jednak w tym przypadku, ponieważ List przyjmuje obiekty, może przechowywać nie tylko elementy String, ale także Integer. Ale najgorsze jest to, że kompilator nie widzi w tym nic złego. A teraz otrzymamy błąd W CZASIE WYKONYWANIA (znany jako "runtime error"). Błąd będzie wyglądał następująco:

java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.String
Musisz przyznać, że nie wygląda to dobrze. A wszystko to dlatego, że kompilator nie jest sztuczną inteligencją zdolną zawsze poprawnie odgadnąć intencje programisty. Java SE 5 wprowadziła typy generyczne, które pozwalają nam poinformować kompilator o naszych intencjach — o tym, jakich typów będziemy używać. Naprawimy nasz kod, mówiąc kompilatorowi, czego oczekujemy:

import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List<String> list = new ArrayList<>();
		list.add("Hello!");
		list.add(123);
		for (Object str : list) {
		    System.out.println("-" + str);
		}
	}
}
Jak widać, nie potrzebujemy już rzutowania na String. Ponadto mamy ostre nawiasy otaczające argument typu. Teraz kompilator nie pozwoli nam skompilować klasy, dopóki nie usuniemy wiersza, który dodaje 123 do listy, ponieważ jest to Integer. I powie nam o tym. Wiele osób nazywa typy generyczne "lukrem składniowym". I mają rację, ponieważ po kompilacji typy generyczne naprawdę stają się konwersjami tego samego typu. Spójrzmy na kod bajtowy skompilowanych klas — jednej, która używa jawnego rzutowania i jednej, która używa typów generycznych: Typy Generyczne w Javie: jak używać nawiasów ostrych w praktyce - 1Po kompilacji wszystkie typy generyczne są usuwane. Nazywa się to "wymazywaniem typu". Wymazywanie typów i typy generyczne są zaprojektowane tak, aby były wstecznie kompatybilne ze starszymi wersjami JDK, jednocześnie umożliwiając kompilatorowi pomoc w definiowaniu typów w nowych wersjach Javy.

Typy surowe

Mówiąc o typach generycznych, zawsze mamy dwie kategorie: typy sparametryzowane i typy surowe. Typy surowe to te, które pomijają "wyjaśnienie typu" w nawiasach ostrych: Typy Generyczne w Javie: jak używać nawiasów ostrych w praktyce - 2Z drugiej strony typy sparametryzowane zawierają "wyjaśnienie": Typy Generyczne w Javie: jak używać nawiasów ostrych w praktyce - 3Jak widać zastosowaliśmy nietypową konstrukcję, zaznaczoną na ilustracji strzałką. To specjalna składnia, która została dodana w Java SE 7. Jest ona nazywana "diamond operator". Dlaczego? Nawiasy ostre tworzą diament: <>. Warto również wiedzieć, że operator <> jest powiązany z koncepcją "wnioskowania typu". W końcu kompilator widząc <> po prawej stronie, patrzy na lewą stronę operatora przypisania, gdzie znajduje typ zmiennej, której wartość jest przypisywana. Na podstawie tego, co znajdzie w tej części, rozumie typ wartości po prawej stronie. W rzeczywistości, jeśli typ generyczny jest podany po lewej stronie, ale nie po prawej, kompilator może wywnioskować typ:

import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List<String> list = new ArrayList();
		list.add("Hello, World");
		String data = list.get(0);
		System.out.println(data);
	}
}
Ale to miesza nowy styl z typami generycznymi i stary styl bez nich. A to jest wysoce niepożądane. Podczas kompilacji powyższego kodu otrzymamy następujący komunikat:

Note: HelloWorld.java uses unchecked or unsafe operations
W rzeczywistości powód, dla którego w ogóle trzeba tu dodać operator <>, wydaje się niezrozumiały. Ale oto przykład:

import java.util.*;
public class HelloWorld {
	public static void main(String []args) {
		List<String> list = Arrays.asList("Hello", "World");
		List<Integer> data = new ArrayList(list);
		Integer intNumber = data.get(0);
		System.out.println(data);
	}
}
Przypomnij sobie, że ArrayList posiada drugi konstruktor, który przyjmuje kolekcję jako argument. I tutaj kryje się coś złowrogiego. Bez składni operatora <> kompilator nie rozumie, że jest oszukiwany. Użycie składni z diamentem, pozwoli mu to stwierdzić. Tak więc zasada nr 1 brzmi: zawsze używaj operatora <> z typami sparametryzowanymi. W przeciwnym razie ryzykujemy, że przegapimy miejsce, w którym używamy typów surowych. Aby wyeliminować ostrzeżenia "uses unchecked or unsafe operations", możemy użyć adnotacji @SuppressWarnings("unchecked") w metodzie lub klasie. Ale zastanów się, dlaczego zdecydowaliśmy się użyć tego operatora. Pamiętaj o zasadzie numer jeden. Może trzeba dodać argument typu.

Metody generyczne Java

Typy generyczne umożliwiają tworzenie metod, w których typy parametrów i typ zwracany są sparametryzowane. W samouczku Oracle tej możliwości poświęcona jest osobna sekcja: "Metody generyczne". Ważne jest, aby zapamiętać składnię przedstawioną w tym samouczku:
  • zawiera listę parametrów typu w nawiasach ostrych;
  • lista parametrów typu znajduje się przed typem zwracanym przez metodę.
Spójrzmy na poniższy przykład:

import java.util.*;
public class HelloWorld {
	
    public static class Util {
        public static <T> T getValue(Object obj, Class<T> clazz) {
            return (T) obj;
        }
        public static <T> T getValue(Object obj) {
            return (T) obj;
        }
    }

    public static void main(String []args) {
		List list = Arrays.asList("Author", "Book");
		for (Object element : list) {
		    String data = Util.getValue(element, String.class);
		    System.out.println(data);
		    System.out.println(Util.<String>getValue(element));
		}
    }
}
Jeśli spojrzysz na klasę Util, zobaczysz, że ma ona dwie metody generyczne. Dzięki możliwości wnioskowania typu możemy albo bezpośrednio wskazać typ kompilatorowi, albo sami go określić. W przykładzie przedstawiono obie opcje. Nawiasem mówiąc, składnia ma wiele sensu, jeśli się nad tym zastanowić. Deklarując metodę generyczną, określamy parametr typu PRZED metodą, ponieważ jeśli zadeklarujemy parametr typu po metodzie, JVM nie będzie w stanie określić, jakiego typu użyć. W związku z tym najpierw deklarujemy, że użyjemy parametru typu T, a następnie mówimy, że zwrócimy ten typ. Naturalnie Util.<Integer>getValue(element, String.class) zwróci błąd: incompatible types: Class<String> cannot be converted to Class<Integer>. Korzystając z metod generycznych, należy zawsze pamiętać o wymazywaniu typów. Spójrzmy na poniższy przykład:

import java.util.*;
public class HelloWorld {
	
    public static class Util {
        public static <T> T getValue(Object obj) {
            return (T) obj;
        }
    }

    public static void main(String []args) {
		List list = Arrays.asList(2, 3);
		for (Object element : list) {
		    System.out.println(Util.<Integer>getValue(element) + 1);
		}
    }
}
To będzie działać bez problemu. Ale tylko tak długo, jak kompilator rozumie, że typem zwracanym wywoływanej metody jest Integer. Zastąp instrukcję wyświetlania w konsoli następującą linią:

System.out.println(Util.getValue(element) + 1);
Otrzymamy błąd:

bad operand types for binary operator '+', first type: Object, second type: int.
Innymi słowy, nastąpiło wymazanie typu. Kompilator widzi, że nikt nie określił typu, więc typ jest oznaczony jako Object, a metoda zwraca błąd.

Klasy generyczne

Parametryzować można nie tylko metody. Można to robić również dla klas. W samouczku Oracle poświęcona jest temu sekcja "Typy generyczne". Rozważmy taki przykład:

public static class SomeType<T> {
	public <E> void test(Collection<E> collection) {
		for (E element : collection) {
			System.out.println(element);
		}
	}
	public void test(List<Integer> collection) {
		for (Integer element : collection) {
			System.out.println(element);
		}
	}
}
Tutaj wszystko jest proste. Jeśli używamy klasy generycznej, parametr typu jest umieszczony po nazwie klasy. Teraz stwórzmy instancję tej klasy w metodzie main:

public static void main(String []args) {
	SomeType<String> st = new SomeType<>();
	List<String> list = Arrays.asList("test");
	st.test(list);
}
Ten kod zadziała prawidłowo. Kompilator widzi, że istnieje Lista liczb i Kolekcja elementów String. Ale co się stanie, jeśli usuniemy parametr typu i zrobimy to:

SomeType st = new SomeType();
List<String> list = Arrays.asList("test");
st.test(list);
Otrzymamy błąd:

java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.Integer
Ponownie, jest to wymazywanie typu. Ponieważ klasa nie używa już parametru typu, kompilator decyduje, że skoro przekazaliśmy List, najbardziej odpowiednia jest metoda z List<Integer>. I otrzymamy błąd. Dlatego mamy zasadę nr 2: Jeśli używasz klasy generycznej, zawsze stosuj parametry typu.

Ograniczenia

Możemy ograniczać typy używane w metodach i klasach generycznych. Załóżmy na przykład, że chcemy, aby kontener akceptował tylko Number jako argument typu. Ta funkcja jest opisana w sekcji Ograniczone parametry typów samouczka Oracle. Spójrzmy na poniższy przykład:

import java.util.*;
public class HelloWorld {
	
    public static class NumberContainer<T extends Number> {
        private T number;
    
        public NumberContainer(T number) { this.number = number; }
    
        public void print() {
            System.out.println(number);
        }
    }

    public static void main(String []args) {
		NumberContainer number1 = new NumberContainer(2L);
		NumberContainer number2 = new NumberContainer(1);
		NumberContainer number3 = new NumberContainer("f");
    }
}
Jak widać, ograniczyliśmy parametr typu do klasy/interfejsu Number i jego potomków. Zauważ, że możesz podać nie tylko klasę, ale także interfejsy. Na przykład:

public static class NumberContainer<T extends Number & Comparable> {
Typy generyczne obsługują również symbole wieloznaczne. Dzielą się one na trzy rodzaje:>
  • Symbole wieloznaczne z górnym ograniczeniem — <? extends Number>
  • Symbole wieloznaczne bez ograniczeń — <?>
  • Symbole wieloznaczne z dolnym ograniczeniem — <? super Integer>
Używanie symboli wieloznacznych powinno być zgodne z zasadą Get-Put. Można ją wyrazić w następujący sposób:
  • Użyj symbolu wieloznacznego extend, gdy tylko pobierasz wartości ze struktury (Get).
  • Użyj symbolu wieloznacznego super, gdy tylko umieszczasz wartości w strukturze (Put).
  • I nie używaj symboli wieloznacznych, gdy chcesz pobierać dane ze struktury i umieszczać dane w strukturze.
Zasada ta jest również nazywana zasadą PECS (Producer Extends, Consumer Super). Oto mały przykład z kodu źródłowego metody Javy Collections.copy: Typy Generyczne w Javie: jak używać nawiasów ostrych w praktyce - 4A oto mały przykład tego, co NIE DZIAŁA:

public static class TestClass {
	public static void print(List<? extends String> list) {
		list.add("Hello, World!");
		System.out.println(list.get(0));
	}
}

public static void main(String []args) {
	List<String> list = new ArrayList<>();
	TestClass.print(list);
}
Ale jeśli zamienisz extends na super, wtedy wszystko będzie poprawnie. Ponieważ wypełniamy list wartością przed wyświetleniem jej zawartości, jesteśmy tu konsumentem. W związku z tym używamy super.

Dziedziczenie

Typy generyczne mają jeszcze jedną ciekawą cechę: dziedziczenie. Sposób, w jaki działa dziedziczenie typów generycznych, jest opisany w samouczku Oracle w sekcji "Typy generyczne, dziedziczenie i podtypy". Ważne jest, aby zapamiętać i umieć rozpoznać poniższe mechanizmy. Nie możemy zrobić tak:

List<CharSequence> list1 = new ArrayList<String>();
Ponieważ dziedziczenie działa inaczej w przypadku typów generycznych: Typy Generyczne w Javie: jak używać nawiasów ostrych w praktyce - 6A oto kolejny dobry przykład, który zwróci błąd:

List<String> list1 = new ArrayList<>();
List<Object> list2 = list1;
Ponownie, wszystko jest tu proste. List<String> nie jest potomkiem List<Object>, mimo że String jest potomkiem Object. Typy Generyczne w Javie: jak używać nawiasów ostrych w praktyce - 6

Wnioski

Odświeżyliśmy więc naszą wiedzę na temat typów generycznych. Jeśli rzadko wykorzystujesz ich możliwości, niektóre szczegóły stają się niejasne. Mam nadzieję, że to krótkie omówienie pomogło ci odświeżyć pamięć.
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION