Kto śledzi mojego bloga, ten czytał o tym, jak można wykorzystać typy i mechanizmy generyczne, by już na poziomie kompilacji wyłapać potencjalne błędy w kodzie.

W ostatnim czasie na przykład tu:

Jako że tym tygodniu bawimy się z Lotto, to pobawmy się jeszcze przez chwilę z typami. Trochę inaczej, bo postaram się pokazać, jak typy pozwalają na zrozumienie kodu. Wczoraj stworzyliśmy sobie naszą magiczną maszynę losującą. Dziś zbudujemy podstawową strukturę klas, które pozwolą nam na grę w Totolotka oraz przyjrzymy się wymaganiom co do stworzenia statystyk dla kul z naszego pierwotnego programu.

Typ niesie informację

Nie tylko poprzez swoje bebechy, pola i metody, ale też poprzez nazwę. Wspominałem wczoraj, że uniwersalna i elastyczna maszyna umożliwi nam tworzenie kodu, który będzie znacznie prostszy do testowania. To o czym nie powiedziałem to łatwość, z jaką ten kod sam siebie dokumentuje.

Utwórzmy na początek dwie klasy. Pierwsza reprezentująca kulę w maszynie losującej i druga, która reprezentuje wyniki losowania:

Listing 1. Klasa Ball

public class Ball {

	private final Integer value;

	public Ball(Integer value) {
		this.value = value;
	}

}

Listing 2. Klasa ResultsOfDraw

public class ResultsOfDraw {

	private final Collection<Ball> balls;

	public ResultsOfDraw(Collection<Ball> balls) {
		this.balls = balls;
	}
}

Na obecnym etapie te klasy nie posiadają żadnych innych metod ani ograniczeń (dobra, Ball w kodzie ma jeszcze hashCode i equals, a tak w praktyce to powinna być kotlinowa data class, ale to rozpraszający szczegół). Nie są one nam potrzebne, a ich implementacja będzie w razie czego możliwa w np. podklasach. Zasada YAGNI w praktyce. Oczywiście z tyłu głowy pozostają nam informacje co do charakteru gry, z jaką będziemy mieli do czynienia.

Mamy już wejście i wyjście losowania, a zatem zaimplementujmy naszą maszynę losującą do totolotka 🙂 Oczywiście zaczniemy od napisania testów. Tylko jakich? Przecież metody, do zaimplementowania są protected i nie możemy ich jawnie wywołać. To jest OK. Jednak przyjrzyjmy się naszej maszynie raz jeszcze i odkryjemy, że to, co ona zwraca z tych metod to tak naprawdę jej „części”. I podobnie jak w przypadku np. maszyn możemy przetestować samą część. Będą zatem dwie części do osobnego przetestowania i jedno zachowanie (związane z ilością losowanych kul). Zacznijmy od testowania części. Pierwszą z nich jest generator danych. Jest on funkcją, która nie ma parametrów i coś zwraca. To coś to zbiór 49 unikalnych kul.

Listing 3. Test generatora Balls

public class BallsTest {

	private Balls sut = new Balls();

	@Test
	public void shouldBallsCreate49UniqueBalls() throws Exception {
		Collection<Ball> balls = sut.apply();

		assertThat(balls)
				.hasSize(49)
				.doesNotHaveDuplicates();
	}
}

A czy nie powinniśmy sprawdzić numeracji? Nie 🙂 Totolotek opiera się o zasadę unikalności kul. To jakimi symbolami są oznaczone, będzie miało znaczenie tylko w przypadku mechanizmy prezentacji. I to pod warunkiem, że chcemy pokazać kule jako liczby, a nie np. dyscypliny sportowe (a to też przerabialiśmy w historii). Implementacja, która spełnia założenia testu, może wyglądać w następujący sposób:

Listing 4. Implementacja generatora Balls

public static class Balls implements Function0<Collection<Ball>> {

	private static final Set<Ball> BALL_SET = IntStream.rangeClosed(1, 49).boxed().map(Ball::new).collect(Collectors
			.toSet());

	@Override
	public Collection<Ball> apply() {
		return BALL_SET;
	}
}

Druga część to element mapujący wylosowane kule na rezultat. Tu nie za bardzo jest co testować 🙂 Jedynie to, czy funkcja zwraca obiekt, a nie null. Przy czym to przetestujemy przy okazji testowania naszej maszyny. Implementacja tej funkcji jest już, swoją drogą gotowa i zostało jedynie wpiąć ją w odpowiednim miejscu 🙂 Czas na przetestowanie zachowania naszej maszyny. Chcemy mieć pewność, że wylosowane zostanie dokładnie 6 kul. Czas zatem nałożyć ograniczenie na naszą klasę ResultOfDraw tak by sprawdzała wielkość wylosowanego zbioru. Można zrobić to na kilka sposobów. Najprościej będzie rozszerzyć naszą klasę tworząc specyficzną klasę do przechowywania rezultatów losowania tolototka. Tu do gry wchodzi TDD. Warunków brzegowych dla kolekcji, która jest parametrem konstruktora, jest kilka, zatem trzeba napisać kilka testów.

Taki testowy pingpong związany z przeskakiwaniem pomiędzy plikami opisałem w tekście Jak zacząć zabawę z TDD od strony praktycznej.

Poniżej zestaw testów, które sprawdzają nam proces tworzenia obiektu klasy TotolotekResultOfDraw

Listing 5. Testy tworzenia rezultatu losowania

public class TotolotekResultOfDrawTest {

	@Test(expected = NullPointerException.class)
	public void shouldThrowsOnNullResult() throws Exception {
		TotolotekResultOfDraw sut = new TotolotekResultOfDraw(null);
	}

	@Test(expected = IllegalArgumentException.class)
	public void shouldThrowsOnEmptyResult() throws Exception {
		TotolotekResultOfDraw sut = new TotolotekResultOfDraw(Collections.emptyList());
	}

	@Test(expected = IllegalArgumentException.class)
	public void shouldThrowsOnLT6Result() throws Exception {
		Collection<Ball> elementsOf5 = Lists.newArrayList(
				new Ball(1),
				new Ball(2),
				new Ball(3),
				new Ball(4),
				new Ball(5)
		);
		TotolotekResultOfDraw sut = new TotolotekResultOfDraw(elementsOf5);
	}

	@Test(expected = IllegalArgumentException.class)
	public void shouldThrowsOnGT6Result() throws Exception {
		Collection<Ball> elementsOf7 = Lists.newArrayList(
				new Ball(1),
				new Ball(2),
				new Ball(3),
				new Ball(4),
				new Ball(5),
				new Ball(6),
				new Ball(7)
		);
		TotolotekResultOfDraw sut = new TotolotekResultOfDraw(elementsOf7);
	}

	@Test
	public void shouldCreateWhen6InResult() throws Exception {
		Collection<Ball> elementsOf6 = Lists.newArrayList(
				new Ball(1),
				new Ball(2),
				new Ball(3),
				new Ball(4),
				new Ball(5),
				new Ball(6)
		);
		TotolotekResultOfDraw sut = new TotolotekResultOfDraw(elementsOf6);
	}
}

Implementacja spełniająca powyższe warunki będzie wyglądać tak:

Listing 6. Klasa reprezentująca wyniki losowania totolotka

public static class TotolotekResultOfDraw extends ResultsOfDraw{

	public TotolotekResultOfDraw(Collection<Ball> balls) {
		super(balls);
		Preconditions.checkArgument(balls.size() == 6, format("Invalid size of result set! Is %d but should be", balls.size()));
	}
}

Wygląda to trochę smutno i można się doczepić kilku rzeczy m.in. rzucania wyjątkami czy też komunikatu. Pozostawmy jednak ten kod tak, jak jest. Na chwilę obecną jak walnie wyjątkiem to nie będziemy płakać.

Innym sposobem na wprowadzenie walidacji jest refaktoryzacja klasy ResultOfDraw i wprowadzenie do niej warunku, który będą dostarczać klasy dziedziczące. Znowuż, na razie mamy mało kodu i to rozwiązanie było, by zbyt rozbudowane.

W naszej dotychczasowej pracy skupiliśmy się na stworzeniu kilku klas wraz z testami do nich. Jeżeli porównamy to rozwiązanie z pierwotnym to nie do końca widać przewagę tego rozwiązania. Jednak wprowadzając typy, zamiast korzystać z „gołych” Integerów udokumentowaliśmy nasz kod. Gdy ktoś trafi na niego za pół roku, to już lektura samych nazw klas pozwoli mu na jako takie skojarzenie co tam się w bebechach dzieje. Jeżeli do tego przejrzy testy, to będzie mógł w praktyce, bez wnikania w kod opisać co on robi. W dodatku posługując się językiem, w którym będą bardzo konkretne pojęcia, a nie abstrakcyjne byty w rodzaju „kolekcja intów”, „funkcja zamieniająca kolekcję na kolekcję” itp.

Maszyna losująca jest pusta, następuje zwolnienie blokady…

… i przystępujemy do losowania 6 z 49 liczb. Był taki czas gdy to zdanie oznaczało, że zaraz będzie podwieczorek (losowanie w Jedynce około 17:30 w niedzielę czy jakoś tak) 😉

Czas na implementację naszej maszyny losującej. Przetestowaliśmy ją już na wiele sposobów, a zatem pozostaje jedynie odpalić smoke test, który sprawdzi, czy gdzieś czegoś w kodzie nie pominęliśmy:

Listing 7. Testy maszyny do totolotka

public class TotolotekMachineTest {

	@Test
	public void shouldSmokeTest() throws Exception {
		TotolotekMachine sut = new TotolotekMachine();

		TotolotekResultOfDraw pick = sut.pick();

		assertThat(pick).isNotNull();

	}
}

Zobaczcie, jak prosty jest ten test. Klasa TotolotekMachine nie zawiera własnej logiki poza jednym elementem, czyli określeniem wielości wylosowanego zbioru. Ten warunek testujemy jednak gdzieś indziej. Typ TotolotekResultOfDraw gwarantuje nam, że źle skonfigurowana maszyna nie zadziała. Walnie błędem po konsoli. Osobie czytającej kod też będzie łatwiej, zrozumieć co się dzieje, ponieważ dostanie jasną informację o danych wyjściowych. Porównując to z klasą Lotto mamy znacznie jaśniejszy przekaz. Widząc funkcję F:Collection\[int\] → Collection\[int\] tak naprawdę nic nie wiemy o tym, jakie są dane wejściowe, a jakie wyjściowe i czym te dwa zbiory się tak naprawdę różnią.

Jak wygląda kod maszyny?

Listing 8. Kod maszyny do totolotka

public class TotolotekMachine extends Machine<Ball, TotolotekResultOfDraw> {

	private static final Balls BALLS = new Balls();

	@Override
	protected Function0<Collection<Ball>> getData() {
		return BALLS;
	}

	@Override
	protected Function<Collection<Ball>, TotolotekResultOfDraw> mapToResult() {
		return TotolotekResultOfDraw::new;
	}

	@Override
	protected Integer limit() {
		return 6;
	}

	public static class TotolotekResultOfDraw extends ResultsOfDraw{

		public TotolotekResultOfDraw(Collection<Ball> balls) {
			super(balls);
			Preconditions.checkArgument(balls.size() == 6, format("Invalid size of result set! Is %d but should be", balls.size()));
		}
	}

	public static class Balls implements Function0<Collection<Ball>> {

		private static final Set<Ball> BALL_SET = IntStream.rangeClosed(1, 49).boxed().map(Ball::new).collect(Collectors
				.toSet());

		@Override
		public Collection<Ball> apply() {
			return BALL_SET;
		}
	}

}

Kod jest zwięzły i prosty. Łatwy do przetestowania i co najważniejsze dzięki zastosowaniu typów, tak by opakować wartości prymitywne jak i kolekcje praktycznie nie wymaga pisania dodatkowej dokumentacji.

Podsumowanie

Gdy tworzyłem cykl postów o ekstremalnej obiektowości, kilkukrotnie spotkałem się z opinią, że „tak się nie da pisać”. Rzeczywiście, ścisłe stosowanie się do tych zasad jest bardzo trudne. Jednak w praktyce okazuje się, że dużo z tych zasad „tak jakoś” samo wychodzi, jeżeli tylko będziemy chcieli chwilę pomyśleć, co my tak właściwie piszemy. W dodatku kod tak napisany jakoś tak „dziwnie” łatwiej się czyta.

Powyższe rozwiązanie oczywiście nie jest doskonałe. Nadal wali wyjątkami, a nie zamyka je w jakieś ładne klasy stanu i nie ma jeszcze elementu prezentacji kodu. Jednak po kolei na wszystko przyjdzie pora.