Kolekcja nie pusta, czyli zabawa z typami

Upraszczanie kodu do znudzenia będzie grane na tym blogu. Więc dziś kolejna porcja usuwania zbędnego kodu z naszego kodu i przenoszenia go do bibliotek narzędziowych.

Przypadek patologiczny

Rozważmy sobie taki oto kawałek kodu, który odpowiada za przygotowanie informacji o błędach walidacji dla użytkownika.

Listing 1. Typowy kod

public class ValidationPresenter {

	public String showMessages(Collection<ConstraintViolation> errors) {
		return errors.stream()
				.map(cv -> cv.getMessage())
				.reduce("", (l, r) -> String.format("%s\n%s", l, r));
	}
}

Pytanie co zwróci ten kod? Odpowiedź oczywista jest zwróci String. Jednak możemy mieć pewne wątpliwości. Po pierwsze co będzie jeżeli metoda zostanie nakarmiona pustą kolekcją jak na przykładzie poniżej:

Listing 2. Wywołanie z pustą kolekcją

@Test
public void whatShouldShowErrorsDoOnEmptyCollection() throws Exception {
	String errorMessage = sut.showMessages(Collections.emptyList());
	assertThat(errorMessage).isEqualTo("");
}

I takie zachowanie to błąd na poziomie UX-owym. Coś się popsuło, bo wywołaliśmy metodę showMessages, ale nie wiadomo co, bo lista błędów jest pusta. Rozwiążmy ten problem w sposób powiedzmy tradycyjny

Listing 3. Poprawiony kod

public class ValidationPresenter {

	public String showMessages(Collection<ConstraintViolation> errors) {
		Preconditions.checkArgument(!errors.isEmpty(), "Error list cannot be empty");
		return errors.stream()
				.map(cv -> cv.getMessage())
				.reduce("", (l, r) -> String.format("%s\n%s", l, r));
	}
}

Wygląda dobrze. Co prawda znam ludzi, którzy by powiedzieli, że ten Preconditions to zbędna warstwa i trzeba ifa oraz oczywiście zwykłą pętlę, bo lambdy są powolne… Można oczywiście polecieć w asemblerze.
Sama implementacja jest nie do końca poprawna, ponieważ możemy wywołać ta metodę przekazując jej specyficzny parametr:

Listing 4. Wywołanie ze specyficznym parametrem

@Test
public void whatShouldShowErrorsDoOnSpecificCollection() throws Exception {
	Collection<ConstraintViolation> errors = new ArrayList<n>();
	errors.add(new CV<String>());
	String errorMessage = sut.showMessages(errors);
	assertThat(errorMessage).isEqualTo("\n");
}

//...

class CV<T> implements ConstraintViolation<T>{
	@Override
	public String getMessage() {
		return "";
	}

//...
}

Mamy tu przykład błędu w rodzaju człowiek enter. Nadal nic nie wiadomo o tym co się wydarzyło, a zatem trzeba nałożyć walidację na wyjście:

Listing 5. Powtórnie poprawiony kod

public class ValidationPresenter {

	public String showMessages(Collection<ConstraintViolation> errors) {
		Preconditions.checkArgument(!errors.isEmpty(), "Error list cannot be empty");
		String errorMessage = errors.stream()
				.map(cv -> cv.getMessage())
				.reduce("", (l, r) -> String.format("%s\n%s", l, r));
		Preconditions.checkArgument(!errorMessage.trim().isEmpty(), "Error message cannot be empty");
		return errorMessage;
	}
}

I co ciekawe nadal nie jest dobrze ponieważ możemy przekazać kolekcję, która będzie zawierać null i całość się wysypie z NPE. Oczywiście wtedy należy dodać kolejny poziom sprawdzania w postaci filtrowania i już na tym etapie jesteśmy w dupie ciemnej, a mrocznej. Dlaczego? Ponieważ tak naprawdę musimy naklepać się kodu zarówno po stronie naszej metody jak i po stronie testów. No trochę chujowo jak powiedział Kubuś Puchatek. Podejdźmy więc do problemu inaczej. Co jeżeli zmusimy użytkownika za pomocą kompilatora by przekazał nam poprawną kolekcję?

NotEmptyCollection i spółka

W klasie Collections mamy metody empty* oraz singleton*. Pierwsza grupa tworzy puste niemodyfikowalne kolekcje, a druga kolekcje jednoelementowe. Co jeżeli stworzylibyśmy własną implementację, na przykład List, która zawierała by co najmniej jeden element? Dzięki temu moglibyśmy uprościć nasz kod.

Hierarchia kolekcji w Javie – skrót

API Javy ma stosunkowo prostą hierarchię kolekcji. Na górze jest Colletion z którego wychodzą List, Set i Queue. Te znowuż mają własne podinterfejsy np. SortedSet albo Deque, klasy abstrakcyjne np. AbstractList i dalej konkretne implementacje w rodzaju ArrayList czy HashSet.
Do tego dochodzą jeszcze lokalne i anonimowe implementacje w postaci wspomnianych Empty* i Singleton*.
Osobną kategorię stanowi interfejs Map, który nie jest kolekcją i ma to swoje uzasadnienie. Tym nie będę się tu zajmować.

Plan

W tym poście skupimy się na interfejsach Collection i List, ale implementacja dla Set działa na tej samej zasadzie. Nie ma sensu tworzenie implementacji dal Queue ponieważ kolejka ze swej natury może być pusta. Choć oczywiście można wyszukać zastosowania gdzie potrzeba nam niepustej kolejki.
Sprawa trochę się komplikuje gdy odejdziemy od interfejsów i przejrzymy implementacje. Jest ich trochę i dlatego też nie będę tu omawiać wszystkich wersji. Zrobimy implementację NotEmptyArrayList, a reszta i tak będzie wyglądać tak samo.

Przyjrzyjmy się raz jeszcze naszemu kodowi, ale tym razem po zdefiniowaniu trochę innej sygnatury

Listing 6. Kod po zmianie sygnatury

public class ValidationPresenter {

	public String showMessages(NotEmptyCollection<ConstraintViolation> errors) {
		String errorMessage = errors.stream()
				.map(cv -> cv.getMessage())
				.reduce("", (l, r) -> String.format("%s\n%s", l, r));
		Preconditions.checkArgument(!errorMessage.trim().isEmpty(), "Error message cannot be empty");
		return errorMessage;
	}
}

Wygląda to bardzo fajnie. Usunęliśmy pierwszy warunek na rzecz sprawdzenia na poziomie typu. Innymi słowy użytkownik musi nam podać kolekcję która coś zawiera. Zatem stwórzmy taką kolekcję.

Implementacja NotEmptyCollection i NotEmptyList

Zaczniemy od stworzenia interfejsu NotEmptyCollection. Będzie to rozszerzeni Collection z zaimplementowanymi dwoma podstawowymi operacjami:

Listing 7. Implementacja NotEmptyCollection

public interface NotEmptyCollection<T> extends Collection<T> {

	@Override
	default boolean isEmpty() {
		return false;
	}

	@Override
	default void clear(){
		throw new UnsupportedOperationException("Cannot remove all elements from NotEmptyCollection");
	}
}

To samo dla List

Listing 8. Implementacja NotEmptyList

public interface NotEmptyList<T> extends List<T>, NotEmptyCollection<T> {

	@Override
	default boolean isEmpty() {
		return false;
	}

	@Override
	default void clear(){
		throw new UnsupportedOperationException("Cannot remove all elements from NotEmptyList");
	}
}

Dwa razy to samo. Zrobione na jedno kopyto. W praktyce obie implementacje dostarczają kodu, który jest taki sam w całej hierarchii poniżej. isEmpty zawsze zwróci false, bo kolekcje nie są puste z definicji. Z tego samego powodu próba usunięcia wszystkich elementów za pomocą clear powinna zakończyć się czymś bolesnym.

Warstwa abstrakcji

Collection Framework z javy ma warstwę abstrakcji w postaci klas typu AbstractCollection, czy AbstractList. Nie będziemy jej wykorzystywać w naszej implementacji ponieważ domyślne implementacje działają inaczej niż byśmy tego oczekiwali w tym przypadku i trzeba by było naklepać się znacznie więcej kodu.

Klasa NonEmptyArrayList

Jako przykładową implementację wybrałem ArrayList. Jest to klasa będąca opakowaniem na tablicę, które potrafi manipulować tablicą (przez mniej lub bardziej skomplikowane kopiowanie) tak by zachowywać spójność z interfejsem List. Oczywiście nie będziemy z palucha klepać całego potrzebnego kodu. Rozszerzenie nie będzie jednak najlepszym pomysłem. Dlaczego? Ponieważ zmienilibyśmy zachowanie naszej klasy w stosunku do zwykłego ArrayList. Tym samym złamalibyśmy zasady OCP i LSP. Zatem trzeba zrobić to inaczej. W tym celu wykorzystamy mechanizm delegacji. Zaletą jest prostota, całą niewdzięczną pracę zrobi za nas ktoś inny. My skupimy się za to na tym co nas tak na prawdę interesuje. Wadą jest „ucieczka” typu. W naszym przypadku oznacza to iż nasza klasa nie będzie mogła zostać podstawiona pod zwykły ArrayList. Jak wspomniałem to nawet lepiej, bo robiąc to złamalibyśmy kontrakt.

Listing 9. Implementacja NotEmptyArrayList

public class NotEmptyArrayList<T> implements NotEmptyList<T> {

	private final ArrayList<T> internal = new ArrayList<>();

	public NotEmptyArrayList(T element) {
		super();
		internal.add(element);
	}

	@Override
	public boolean remove(Object o) {
		return false;
	}

	@Override
	public T remove(int index) {
		return null;
	}

	@Override
	public boolean removeAll(Collection<?> c) {
		return false;
	}

	@Override
	public boolean retainAll(Collection<?> c) {
		return false;
	}


	@Override
	public T get(int index) {
		return internal.get(index);
	}

	@Override
	public T set(int index, T element) {
		return internal.set(index, element);
	}

	@Override
	public void add(int index, T element) {
		internal.add(index, element);
	}

	@Override
	public int indexOf(Object o) {
		return internal.indexOf(o);
	}

	@Override
	public int lastIndexOf(Object o) {
		return internal.lastIndexOf(o);
	}

	@Override
	public ListIterator<T> listIterator() {
		return internal.listIterator();
	}

	@Override
	public ListIterator<T> listIterator(int index) {
		return internal.listIterator(index);
	}

	@Override
	public List<T> subList(int fromIndex, int toIndex) {
		return internal.subList(fromIndex, toIndex);
	}

	@Override
	public int size() {
		return internal.size();
	}

	@Override
	public boolean contains(Object o) {
		return internal.contains(o);
	}

	@Override
	public Iterator<T> iterator() {
		return internal.iterator();
	}

	@Override
	public Object[] toArray() {
		return internal.toArray();
	}

	@Override
	public <T1> T1[] toArray(T1[] a) {
		return internal.toArray(a);
	}

	@Override
	public boolean add(T t) {
		return internal.add(t);
	}

	@Override
	public boolean containsAll(Collection<?> c) {
		return internal.containsAll(c);
	}

	@Override
	public boolean addAll(Collection<? extends T> c) {
		return internal.addAll(c);
	}

	@Override
	public boolean addAll(int index, Collection<? extends T> c) {
		return internal.addAll(index, c);
	}

}

Strasznie to długie, ale na nasze potrzeby będzie OK. Na początek konstruktor, który przyjmuje jeden parameter. Założeniem jest, że lista coś zawiera. Jako, że lista może zawierać elementy null zatem wszystko jest ok. Następnie są cztery metody, których nie delegowaliśmy, bo będą wymagać naszej interwencji. Na pierwszy ogień metoda removeAll, która będzie nakładką na remove:

Listing 10. Implemantacja removeAll

@Override
public boolean removeAll(Collection c) {
	return c.stream().map(e -> this.remove(e)).reduce(false, (l, r) -> l || r);
}

Tak, wiem, mało wydajne, powolne itepe… Serio, używacie tej metody? Znacznie ciekawsza jest metoda remove

Listing 11. Implemantacja remove

@Override
public boolean remove(Object o) {
	if (internal.contains(o) && size() == 1)
		throw new IllegalStateException("Cannot remove last object from NotEmptyList");
	return internal.remove(o);
}

@Override
public T remove(int index) {
	if (size() == 1 && index == 0)
		throw new IllegalStateException("Cannot remove last object from NotEmptyList");
	return internal.remove(index);
}

W pierwszej wersji wyrzucimy wyjątkiem jeżeli użytkownik będzie chciał usunąć jedyny obiekt z listy pod warunkiem, że on istnieje. W drugiej rzucimy wyjątek jeżeli nastąpi próba usunięcia elementu z pierwszej pozycji w przypadku listy zawierającej tylko jeden element. Obsługę wszelkich innych przypadków przerzucamy na oryginalną kolekcję. Pozostała nam metoda retainAll, która usuwa wszystkie elementy poza wymienionymi.

Listing 12. Implemantacja retainAll

@Override
public boolean retainAll(Collection<?> c) {
	List<T> toDrop = stream().filter(e -> !c.contains(e)).collect(Collectors.toList());
	return removeAll(toDrop);
}

Ta implementacja też jest powierzchowna. Na podstawie kolekcji tworzymy jej przeciwieństwo i wywołujemy removeAll.

I na tym można by zakończyć zabawę, ale…

Iteratory

Nasza klasa ma dwa iteratory, które posiadają na pokładzie metodę remove. Oznacza to, że trzeba będzie obudować je w odpowiedni sposób. Na przykładzie metody iterator:

Listing 13. Implemantacja metody iterator

@Override
@Override
public Iterator<T> iterator() {
	Iterator<T> iterator = internal.iterator();
	return new Iterator<T>() {
		@Override
		public boolean hasNext() {
			return iterator.hasNext();
		}

		@Override
		public T next() {
			return iterator.next();
		}

		@Override
		public void remove() {
			if(NotEmptyArrayList.this.size()==1)
				throw new IllegalStateException("Cannot remove last object from NotEmptyList");
			iterator.remove();
		}
	};
}

Ok. Jakoś poszło. A teraz smaczek.

Warstwa abstrakcji raz jeszcze

Jak wspomniałem inne implementacje będą robione w ten sam sposób. Zatem możemy pokusić się o wprowadzenie warstwy abstrakcji. Zmieńmy klasę z listingu 9 w następujący sposób:

Listing 14. Klasa abstrakcyjna NotEmptyAbstractList

public abstract class NotEmptyAbstractList<T> implements NotEmptyList<T> {

	public NotEmptyAbstractList(T element) {
		super();
		internal().add(element);
	}

	protected abstract List<T> internal();

//  reszta taka sama
}

I zamiast internal używamy internal(). Pozwoli nam to na znaczące uproszczenie implementacji:

Listing 15. Uproszczona klasa NotEmptyArrayList

public class NotEmptyArrayList<T> extends NotEmptyAbstractList<T> {

	private ArrayList<T> internal;

	public NotEmptyArrayList(T element) {
		super(element);
	}

	@Override
	protected List<T> internal() {
		if (internal == null)
			internal = new ArrayList<>();
		return internal;
	}
}

// edit: mała poprawka – jeżeli zrobimy w ten sposób to musimy zdjąć final z pola internal i inicjować je w metodzie. Inaczej się wysypie z NPE w konstruktorze nadklasy.

Podsumowanie

Naszym celem było przygotowanie klasy, która będzie mogła zostać przekazana jako parametr do kodu z listingu 6. Tego typu podejście pozwala na usunięcie części warunków w rodzaju if(collection.isEmpty())//rób nic/rzuć wyjątek na rzecz przekazywania obiektów, które na pewno spełniają warunek nie bycia pustą kolekcją. W wyniku czego przesuwamy część walidacji na etap kompilacji. Usunięcie pozostałych warunków też jest możliwe za pomocą odpowiednio dobranego typu, ale to temat na osobny wpis.
Jeżeli użytkownik naszego API będzie chciał użyć go w sposób niedozwolony albo nieprawidłowy z biznesowego punktu widzenia to otrzyma w czasie kompilacji komunikat, że przekazuje nieprawidłową wartość. Takie rozwiązanie może wydawać się „męczące” ponieważ ma pewien narzut, ale tylko na początku. Dość szybko odkryjemy, że dzięki zastosowaniu odpowiednich typów, które niosą ze sobą pewne dodatkowe informacje możemy całkowicie zrezygnować z części testów w naszym kodzie. Nie będą one miały sensu i w dodatku nie skompilują się 🙂 Czego i wam życzę.

// edit poprawiona numeracja listingów

Napisz odpowiedź

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax