Mało jak mało. Zaraz się zlecą różni spece, którzy rzekną „ale to oczywista oczywistość”… tyle, że przez ponad 10 lat zabawy z Javą ani razu nie widziałem tego w użyciu, a pracowałem w najróżniejszych projektach.

Typy generyczne dają nam pewne możliwości, dzięki którym na etapie kompilacji możemy sprawdzić czy nasz kod ma sens. Najprostszym przykładem są typowane kolekcje. Przed Javą 5 było tak, że iterując przez kolekcję należało samodzielnie sprawdzać czy wyciągnęliśmy z niej obiekt, który ma odpowiedni typ. Podobnie miała się sprawa z dodawaniem elementów. Mogliśmy dodać wszystko, a weryfikację należało robić ręcznie. Potem przyszła java 5 i mechanizm generyków. Wraz z nim pojawiły się pojęcia kowariancja i kontrawariancja, ale to osobny temat. Nas z mechanizmu generyków dziś będzie interesować coś innego.

Klasyczny przypadek z komparatorem

Mamy sobie trzy encje biznesowe

Listing 1. Encje biznesowe

public class User {

	private Long id;

	private String name;

        // gettery, settery inne pola
}

public class Report {

	private Long id;

	private String name;

        // gettery, settery inne pola
}

public class Category {

	private Long id;

	private String name;

        // gettery, settery inne pola
}

I teraz chcemy by w aplikacji wygenerować widok, który będzie zawierał specyficznie posortowane obiekty. Oczywiście nic trudnego:

Listing 2. Użycie anonimowego Comparator

public class App {

	public static void main(String[] args) {
		User u1 = User.UserBuilder.user().withId(1L).withName("hAdam").build();
		User u2 = User.UserBuilder.user().withId(2L).withName("Eva").build();

		List<User> users = Lists.newArrayList(u1, u2);

		Comparator<User> userComparator = (o1, o2) -> o1.getName().compareTo(o2.getName());
			
		Collections.sort(users, userComparator.reversed());

		System.out.println(users);

	}
}

Ten kod jest całkiem ok, gdyby nie to, że userComparator nie za bardzo do czegoś więcej się nadaje. Jeżeli chcielibyśmy użyć go wyświetlenia obiektów category to dostaniemy błąd pomimo, że obie klasy posiadają metodę getName. Jest to zdroworozsądkowe zachowanie. User i Category współdzielą metodę getName, ale tylko na poziomie abstrakcji biznesowej, która jest zrozumiała dla człowieka. Podobna sytuacja ma miejsce w przypadku metody getId.

Proste rozwiązanie

Najprostszym rozwiązaniem jest oczywiście wyciągnięcie interfejsu. Przykładowo, i to akurat jest w miarę często spotykane, możemy stworzyć interfejs HasId (nazewnictwo w stylu GWT, alternatywą jest Identifiable bardziej w stylu JPA):

Listing 3. Interfejs HasId

public interface HasId<ID> {

	ID getId();

}

To samo dla getName:

Listing 4. Interfejs HasId

public interface HasName<NAME> {

	NAME getName();

}

I tak oto możemy nasze klasy zapisać jako:

Listing 5. Encje biznesowe wzbogacone o interfejsy

public class User implements HasId<Long>, HasName<String>{

	private Long id;

	private String name;

        // gettery, settery inne pola
}

public class Report implements HasId<Long>, HasName<String>{

	private Long id;

	private String name;

        // gettery, settery inne pola
}

public class Category implements HasId<Long>, HasName<String>{

	private Long id;

	private String name;

        // gettery, settery inne pola
}

Co pozwala na napisanie kodu naszej aplikacji w następujący sposób:

Listing 6. Comparator z interfejsem

public class App {

	public static void main(String[] args) {
		User u1 = User.UserBuilder.user().withId(1L).withName("hAdam").build();
		User u2 = User.UserBuilder.user().withId(2L).withName("Eva").build();

		Report r1 = ReportBuilder.report().withId(1L).withName("Monthly").build();
		Report r2 = ReportBuilder.report().withId(2L).withName("Sums").build();

		List<User> users = Lists.newArrayList(u1, u2);
		List<Report> reports = Lists.newArrayList(r1, r2);

		Comparator<HasName<? extends Comparable>> nameComparator = (o1, o2) -> o1.getName().compareTo(o2.getName());
			
		Collections.sort(users, nameComparator.reversed());
		Collections.sort(reports, nameComparator.reversed());

		System.out.println(users);
		System.out.println(reports);
	}
}

Jedyne co musimy zrobić ekstra to powiedzieć, że name jest porównywalne, implementuje Comparable.

Budowa komparatora złożonego

Co jednak w przypadku, gdy chcemy wyświetlić listę, która będzie posortowana według kilku kryteriów. Oczywiście możemy użyć metody thenComparing, ale nie do końca jest ona bezpieczna jeżeli składamy sobie typy. By to pokazać przeanalizujmy poniższy program:

Listing 7. Użycie thenComparing powodujące CCE

public class App {

	public static void main(String[] args) {
		User u1 = UserBuilder.user().withId(1L).withName("hAdam").build();
		User u2 = UserBuilder.user().withId(2L).withName("Eva").build();

		Report r1 = ReportBuilder.report().withId(1L).withName("Monthly").build();
		Report r11 = ReportBuilder.report().withId(1L).withName("Monthly 2").build();
		Report r2 = ReportBuilder.report().withId(2L).withName("Sums").build();

		List<User> users = Lists.newArrayList(u1, u2);
		List<Report> reports = Lists.newArrayList(r1, r11, r2);

		Comparator<HasId<? extends Comparable>> userComparator = (o1, o2) -> o1.getId().compareTo(o2.getId());
		Function<? super HasId<? extends Comparable>, ? extends HasName<? extends Comparable>> toName =
				p -> (HasName<? extends Comparable>) p;
		Comparator<HasId<? extends Comparable>> nameComparator = userComparator.thenComparing(toName, (l, r) -> l.getName().compareTo(r.getName()));
		Collections.sort(users, nameComparator);
		Collections.sort(reports, nameComparator);

		System.out.println(users);
		System.out.println(reports);

	}
}

To pójdzie, ale usuńcie HasName z Report i otrzymacie ClassCastException. Dlaczego? Zwróćcie uwagę, że thenComparing zwróci komparator zgodny z typem obiektu, na którym wywołujemy ta metodę. Innymi słowy dodatkowe porównanie nie wpływa na typ oryginalnego komparatora i co więcej nie jest sprawdzana poprawność typów na etapie kompilacji. Skoro tu mamy tego typu problem to może wprowadźmy sobie interfejs grupujący:

Listing 8. Interfejs HasIdAndName

public interface HasIdAndName<ID, NAME> extends HasId<ID>, HasName<NAME> {
}

I wtedy zamieńmy kod w naszych klasach biznesowych na taki z wykorzystaniem tego interfejsu…

Listing 9. Encje biznesowe z zamienionym interfejsem

public class User implements HasIdAndName<Long, String>{

	private Long id;

	private String name;

        // gettery, settery inne pola
}

public class Report implements HasIdAndName<Long, String>{

	private Long id;

	private String name;

        // gettery, settery inne pola
}

public class Category implements HasIdAndName<Long, String>{

	private Long id;

	private String name;

        // gettery, settery inne pola
}

Co upraszcza nasz program do:

Listing 10. Uproszczony program

public class App {

	public static void main(String[] args) {
		User u1 = UserBuilder.user().withId(1L).withName("hAdam").build();
		User u2 = UserBuilder.user().withId(2L).withName("Eva").build();

		Report r1 = ReportBuilder.report().withId(1L).withName("Monthly").build();
		Report r11 = ReportBuilder.report().withId(1L).withName("Monthly 2").build();
		Report r2 = ReportBuilder.report().withId(2L).withName("Sums").build();

		List<User> users = Lists.newArrayList(u1, u2);
		List<Report> reports = Lists.newArrayList(r1, r11, r2);
		
		Comparator<HasIdAndName<? extends Comparable, ? extends Comparable>> nameComparator = (l, r) -> {
			int n = l.getName().compareTo(r.getName());
			if (n == 0)
				return l.getId().compareTo(r.getId());
			return n;
		};
		Collections.sort(users, nameComparator);
		Collections.sort(reports, nameComparator);

		System.out.println(users);
		System.out.println(reports);

	}
}

Wszystko ładnie pięknie, gra i śpiewa. Stan taki potrwa do momentu gdy nie okaże się, że nie możemy z jakiegoś powodu stworzyć tego dodatkowego interfejsu. Chociażby dlatego, że korzystamy z zewnętrznego modułu, albo nie chcemy tworzyć dodatkowego kodu (hehehe).

Łączenie typów generycznych

Możemy to obejść łącząc typy na poziomie generycznym. Jest to bardzo prosta forma algebraicznych typów danych. Pozwala ona na określenie, że w danym miejscu kodu oczekujemy czegoś co jest kilku różnych typów na raz. Wait… jak kilku typów na raz w Javie? Ano technicznie sprowadza się to do stwierdzenia, że oczekujemy obiektu, który implementuje kilka interfejsów.

Przepiszmy zatem nasz kod z listingu 10 tak by mając typy z listingu 5 nie musieć tworzyć czegoś w rodzaju kodu z listingu 7 i tym samym uniknąć CCE:

Listing 11. Uproszczony program bez dodatkowego „zbierającego” interfejsu

public class App {

	public static void main(String[] args) {
		User u1 = UserBuilder.user().withId(1L).withName("hAdam").build();
		User u2 = UserBuilder.user().withId(2L).withName("Eva").build();

		Report r1 = ReportBuilder.report().withId(1L).withName("Monthly").build();
		Report r11 = ReportBuilder.report().withId(1L).withName("Monthly 2").build();
		Report r2 = ReportBuilder.report().withId(2L).withName("Sums").build();

		List<User> users = Lists.newArrayList(u1, u2);
		List<Report> reports = Lists.newArrayList(r1, r11, r2);
		
		Collections.sort(users, new IdAndNameComparator<>());
		Collections.sort(reports, new IdAndNameComparator<>());

		System.out.println(users);
		System.out.println(reports);

	}

	static class IdAndNameComparator<T extends HasId<? extends Comparable> & HasName<? extends Comparable>> implements Comparator<T> {
		@Override
		public int compare(T l, T r) {
			int n = l.getName().compareTo(r.getName());
			if (n == 0)
				return l.getId().compareTo(r.getId());
			return n;
		}

	}

}

Nie ma interfejsu, jest w zamian klasa, która pełni rolę „zbierającą”. Można ją upchnąć nawet trochę głębiej jako klasę lokalną w metodzie. I robimy to tylko dlatego, że nie da się w deklaracji zmiennej użyć znaku & do połączenia dwóch typów. Czym jest ten znak &? Mówi on kompilatorowi, że w tym miejscu spodziewamy się czegoś co jest jednocześnie HasId i HasName. Może być to dowolna klasa, byle by implementowała te dwa interfejsy.

Kluczem do sukcesu jest deklaracja w postaci IdAndNameComparator<>, a nie IdAndNameComparator. Ta pierwsza wymusi sprawdzenie poprawności typów.

Podsumowanie

Dzięki użyciu „sztuczki” łączenia typów w deklaracji generycznej uzyskaliśmy kod, który już na poziomie kompilacji może zostać sprawdzony pod kątem poprawności. Dzięki temu możemy uniknąć problemów takich jak w kodzie na listingu 7, gdzie dopiero na etapie uruchomienia programu otrzymujemy błąd. Dodatkowo to rozwiązanie można stosować tam gdzie nie możemy modyfikować kodu części klas, z którymi pracujemy.