By zachować funkcjonalność pierwotnej wersji Lotto, musimy teraz stworzyć jakiś mechanizm, który:

  • Wykona N losowań.
  • Zsumuje (jakoś) ile razy w wypadła dana kula.
  • Wyświetli wyniki.

Pierwszy jest stosunkowo prosty, a trzeci też nie odstaje od reszty. Drugi wymaga jednak dużej uwagi. We wczorajszym wpisie wspomniałem, że jednym z problemów w testowaniu oryginalnego kodu jest, sprawdzenie mechanizmu sumowania wyników.

Funkcja łącząca mapy

Zaczniemy od stworzenia ogólnej, generycznej funkcji, która połączy dwie mapy w nową. API języka udostępnia metodę merge, ale nie końca spełnia ona nasze wymagania. Największym problemem jest mutowanie jednej z map. Oczywiście wykorzystamy ją, ale nie bezpośrednio. Zaczniemy tradycyjnie od serii testów:

Listing 1. Testowanie łączenia map

public class MapMergeTest {

	private MapMerge<Integer, Integer> sut;
	private Map<Integer, Integer> map1;
	private Map<Integer, Integer> map2;

	@Before
	public void setUp() throws Exception {
		sut = new MapMerge<>((l, r) -> l + r);
		map1 = Maps.newHashMap();
		map2 = Maps.newHashMap();
	}

	@Test
	public void shouldMergeEmptyEmptyGivesEmpty() throws Exception {

		Map<Integer, Integer> result = sut.apply(map1, map2);

		assertThat(result).isEmpty();

	}

	@Test
	public void shouldMergeEmptyAndNotEmptyGivesNotEmpty() throws Exception {

		map1.put(1, 1);

		Map<Integer, Integer> result = sut.apply(map1, map2);

		assertThat(result)
				.isNotEmpty()
				.hasSize(1)
				.includes(entry(1, 1));
	}

	@Test
	public void shouldMergeNotEmptyAndEmptyGivesNotEmpty() throws Exception {

		map2.put(1, 1);

		Map<Integer, Integer> result = sut.apply(map1, map2);

		assertThat(result)
				.isNotEmpty()
				.hasSize(1)
				.includes(entry(1, 1));
	}

	@Test
	public void shouldMergeNotEmptyAndNotEmptyGivesSum() throws Exception {

		map1.put(1, 1);
		map2.put(2, 2);

		Map<Integer, Integer> result = sut.apply(map1, map2);

		assertThat(result)
				.isNotEmpty()
				.hasSize(2)
				.includes(entry(1, 1), entry(2, 2));
	}


	@Test
	public void shouldMergeNotEmptyAndNotEmptyGivesSumOnDuplicates() throws Exception {

		map1.put(1, 1);
		map2.put(1, 1);

		Map<Integer, Integer> spy1 = spy(map1);
		Map<Integer, Integer> spy2 = spy(map2);
		Map<Integer, Integer> result = sut.apply(map1, map2);

		assertThat(result)
				.isNotEmpty()
				.hasSize(1)
				.includes(entry(1, 2));
		verify(spy1, never()).put(anyInt(), anyInt());
		verify(spy2, never()).put(anyInt(), anyInt());
	}

}

Klasa MapMerge przy tworzeniu wymaga BiFunction, które będzie odpowiedzialne za łączenie kluczy. Samo użycie jest proste. Podajemy dwie mapy, otrzymujemy jedną. Mapy wejściowe nie zostały zmienione. Sama implementacja jest oczywista, jeżeli mamy do dyspozycji taki zestaw testów:

Listing 2. Implementacja MapMerge

public class MapMerge<K, V> implements BiFunction<Map<K, V>, Map<K, V>, Map<K, V>> {

	private final BiFunction<? super V, ? super V, ? extends V> merge;

	public MapMerge(BiFunction<? super V, ? super V, ? extends V> merge) {
		this.merge = merge;
	}

	@Override
	public Map<K, V> apply(Map<K, V> left, Map<K, V> right) {
		if (left.isEmpty())
			return new HashMap<>(right);
		if (right.isEmpty())
			return new HashMap<>(left);
		HashMap<K, V> newMap = new HashMap<>(right);
		left.entrySet().stream()
				.forEach(e -> newMap.merge(e.getKey(), e.getValue(), merge));

		return newMap;
	}
}

Szybka weryfikacja raportów z testów też pokazuje, że na obecnym etapie nie stało się nic złego z naszym kodem. Jednak jak popatrzymy na kod, to rodzi się pytanie – Nie powinniśmy mutować stanu, a w metodzie apply używamy mutującej metody merge. Dlaczego?

Stan wewnętrzny i stan globalny

Możemy wyróżnić dwa rodzaje stanu. Stan wewnętrzny funkcji to wszystkie te elementy, które są widoczne tylko dla niej. Zatem zmienna lokalna, jaką jest newMap należy do stanu wewnętrznego funkcji. Gdy funkcja upubliczni tą zmienną, zwracając ją, to jej stan pozostanie już niezmienny. Oczywiście pod warunkiem, że ktoś tej zmiany explicite nie spowoduje. Jednak taka zmiana będzie prowadzić do zmiany stanu globalnego, w którego elementach nie powinny zachodzić zmiany. Jeżeli chcemy coś zmienić tro zmieniamy stan jako całość, a nie jego poszczególne fragmenty.

Statystyka gry

Czas wrócić do naszego programu. Przyjrzyjmy się raz jeszcze oryginalnemu kodowi.

Listing 3. Pierwotny kod Lotto

public class Lotto {

	public static final int NBR = 100_000_000;

	public static void main(String[] args) {
		Lotto lotto = new Lotto();

		Map<Integer, Integer> stat = lotto.stat(NBR);
		System.out.println(stat);

		stat.entrySet().stream().sorted(Comparator.comparing(e -> e.getValue(), (l, r) -> l.compareTo(r))).forEach(e -> {
			System.out.println(e.getKey() + "=" + ((e.getValue() * 100.) / NBR) + "%");
		});


	}

	private Set<Integer> draw() {
		List<Integer> all = IntStream.rangeClosed(1, 49).boxed().collect(Collectors.toList());
		Collections.shuffle(all);
		return all.stream().limit(6).collect(Collectors.toSet());
	}

	public Map<Integer, Integer> stat(int param) {
		Map<Integer, List<Integer>> ll = new HashMap<>();
		IntStream.rangeClosed(1, 49).boxed().forEach(i -§gt; ll.put(i, new ArrayList<>()));
		for (int c = 0; c < param; c++) {
			Set<Integer> draw = draw();
			Map<Integer, List<Integer>> collect = draw.stream().collect(Collectors.groupingBy(i -> i));
			merge(collect, ll);
		}
		return count(ll);

	}

	private Map<Integer, Integer> count(Map<Integer, List<Integer>> l) {

		return l.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue().size()));
	}

	private Map<Integer, List<Integer>> merge(Map<Integer, List<Integer>> l, Map<Integer, List<Integer>> r) {
		r.entrySet().forEach(e ->

				e.getValue().addAll(l.getOrDefault(e.getKey(), Collections.emptyList()))

		);

		return r;

	}
}

Jaki jest przebieg tego programu? Kroków jest w sumie niewiele:

  • Wykonaj losowanie 6 z 49.
  • Wynik losowania umieść w mapie gdzie kluczem jest liczba od 1 do 49, a wartością lista liczb.
  • Powtórz NBR razy.
  • Zlicz mapę – wartość z listy zamienia się na wielkość listy,
  • a przy okazji wyświetl statystykę.

Jak można to przekuć na nową wersję programu? Potraktujmy każdy z tych kroków jako osobny niezależny byt. Losowanie już mamy, bo tym zajmuje się nasza klasa TotolotekMachine. Drugi krok składa się tak naprawdę z dwóch operacji. Najpierw musimy zamienić TotolotekResultOfDraw na mapę Ball→Integer. Ułatwi nam to działanie w kolejnej operacji, czyli łączeniu dotychczasowej mapy rezultatów z rezultatami losowania. Trzeci krok to pętla, czyli też coś prostego. Czwarty krok będziemy mogli zignorować 🙂 Jak słusznie zauważył Daniel to żre pamięć aż miło. I co ciekawe nikt inny na to uwagi nie zwrócił. Kolejny plusik na rzecz dekompozycji – widać babole. Ostatni krok to wyświetlenie statystyki. To załatwimy w znany nam już sposób.

W praktyce pozostaje nam implementacja klasy Statistic, która będzie obudowywać mapę rezultatów.

Wstępna implementacja klasy Statistic

Tu do problemu podejdziemy troszkę inaczej. Stwórzmy szablon naszej klasy, który będzie „zwykłym” Value Object. Dopiszmy do tego podstawowe testy dla klasy RawStatisticView i dopiero wtedy zastanowimy się nad działaniem naszej statystyki.

Listing 4. Test RawStatisticView

public class RawStatisticViewTest {

	@Test
	public void shouldAcceptPassStatisticMapToConsumer() throws Exception {
		Statistic machine = new Statistic(new HashMap<>());

		RawStatisticView sut = machine.new RawStatisticView();

		Consumer<Map<Ball, Integer>> consumer = mock(Consumer.class);
		doAnswer(invocation ->
				assertThat((Map<Ball, Integer>) invocation.getArguments()[0])
						.hasSize(0)
		)
				.when(consumer).accept(anyMap());

		sut.accept(consumer);
		verify(consumer, atLeastOnce()).accept(anyMap());

	}
}

A sama klasa Statistic jest bardzo prosta:

Listing 5. Klasa Statistic

public class Statistic {

	private final Map<Ball, Integer> results;

	public Statistic(Map<Ball, Integer> results) {
		this.results = results;
	}

	public class RawStatisticView{

		public void accept(Consumer<Map<Ball, Integer>> consumer) {
			consumer.accept(Statistic.this.results);
		}
	}

}

Jeszcze raz drugi krok gry. Bierzemy wynik losowania i zamieniamy go na mapę. Potrzebujemy funkcji, która z TotolotekResultOfDraw zrobi nam mapę Ball→Integer. Napiszmy test:

Listing 6. Test funkcji mapującej wynik na mapę

public class TotolotekResultToMapTest {

	@Test
	public void shouldCreateMapFromResults() throws Exception {
		Ball ball1 = new Ball(1);
		Ball ball2 = new Ball(2);
		Ball ball3 = new Ball(3);
		Ball ball4 = new Ball(4);
		Ball ball5 = new Ball(5);
		Ball ball6 = new Ball(6);
		Collection<Ball> balls = Lists
				.newArrayList(
						ball1, ball2, ball3, ball4, ball5, ball6
				);
		TotolotekResultOfDraw resultOfDraw = new TotolotekResultOfDraw(balls);

		TotolotekResultToMap sut = new TotolotekResultToMap();

		Map<Ball, Integer> result = sut.apply(resultOfDraw);

		Assertions.assertThat(result)
				.hasSize(6)
				.includes(
						entry(ball1, 1),
						entry(ball2, 1),
						entry(ball3, 1),
						entry(ball4, 1),
						entry(ball5, 1),
						entry(ball6, 1)
				);

	}
}

Oraz implementację:

Listing 7. Implementacja funkcji mapującej wynik na mapę

public class TotolotekResultToMap implements Function<TotolotekResultOfDraw, Map<Ball, Integer>> {
	@Override
	public Map<Ball, Integer> apply(TotolotekResultOfDraw draw) {
		RawTotolotekResultOfDrawView drawView = draw.new RawTotolotekResultOfDrawView();
		HashMap<Ball, Integer> map = new HashMap<>(6);
		drawView.accept(c -> c.forEach(b -> map.put(b, 1)));
		return map;
	}
}

Pracując z TDD oraz z tego typu podejściem do kodu (tworzenie typów na poziomie jedna operacja jeden typ, kompozycja operacji itp.) można szybko zauważyć, że dla wielu operacji istnieje tylko jedna rozsądna implementacja. Jeżeli istnieje kilka, to różnią się szczegółami np. tu można się spierać, jaką mapę wybrać.

Drugą operacją w drugim kroku jest scalanie wyniku pojedynczego losowania ze statystyką. Jest to typowa operacja redukcji, choć nie widać tego na pierwszy rzut oka. Na listingu 5, klasa Statistic posiada pole results, które przechowuje aktualną statystykę gry. Na początku rozgrywki statystyka powinna zawierać mapę wszystkich możliwych rezultatów i każdy z nich powinien mieć wartość 0. Każda kolejna gra powinna być dopisywana do statystyki, ale… Dopisanie gry będzie w praktyce oznaczało zmianę stanu statystyki. Tego chcemy uniknąć. W dodatku funkcja MapMerge tworzy nową mapę, a results jest finalne. Jest ciekawie…

Nie bójmy się nowych obiektów

Dodajmy do naszej klasy Statistic metodę append, która będzie dopisywać dane do statystyki i zarazem tworzyć nowy obiekt. Ten nowy obiekt reprezentuje nowy stan statystyki. Zacznijmy od testu:

Listing 8. Test dopisywania do statystyki

public class StatisticTest {

	@Test
	public void shouldAppendCreateNewStatistic() throws Exception {
		Ball ball1 = new Ball(1);
		Ball ball2 = new Ball(2);
		Ball ball3 = new Ball(3);
		Ball ball4 = new Ball(4);
		Ball ball5 = new Ball(5);
		Ball ball6 = new Ball(6);
		Collection<Ball> balls = Lists
				.newArrayList(
						ball1, ball2, ball3, ball4, ball5, ball6
				);
		TotolotekResultOfDraw resultOfDraw = new TotolotekResultOfDraw(balls);

		Statistic in = new Statistic(Maps.newHashMap());

		Statistic out = in.append(resultOfDraw);

		assertThat(out).isNotNull().isNotEqualTo(in);
		in.new RawStatisticView().accept(map -> assertThat(map).hasSize(0));
		out.new RawStatisticView().accept(map -> assertThat(map).hasSize(6).includes(
				entry(ball1, 1),
				entry(ball2, 1),
				entry(ball3, 1),
				entry(ball4, 1),
				entry(ball5, 1),
				entry(ball6, 1)
		));
	}
}

W teście tym sprawdzamy, czy jest tworzona nowa statystyka, która zawiera nowe wpisy, a jednocześnie stara nie została naruszona. Implementacja samej metody będzie oczywiście bardzo prosta, bo mamy już gotowe potrzebne elementy:

Listing 9. Dołączanie danych do statystyki

public class Statistic {

	private final TotolotekResultToMap map = new TotolotekResultToMap();

	private final MapMerge<Ball, Integer> merge = new MapMerge<>((l, r) -> l + r);

	public Statistic append(TotolotekResultOfDraw resultOfDraw) {
		return new Statistic(merge.apply(map.apply(resultOfDraw), results));
	}
        //... reszta kodu pominięta
}

W ten sam sposób dodajemy łączenie dwóch statystyk.

Zróbmy jeszcze jedną małą „optymalizację” w naszej statystyce. Zamieńmy konstruktor na prywatny, a w to miejsce wprowadźmy budowniczego. W efekcie otrzymamy klasę Statistic:

Listing 10. ostateczna wersja Statistic

public class Statistic {

	private final Map<Ball, Integer> results;

	private final TotolotekResultToMap map = new TotolotekResultToMap();

	private final MapMerge<Ball, Integer> merge = new MapMerge<>((l, r) -> l + r);

	private Statistic(Map<Ball, Integer> results) {
		this.results = results;
	}

	public Statistic append(TotolotekResultOfDraw resultOfDraw) {
		return new Statistic(merge.apply(map.apply(resultOfDraw), results));
	}

	public class RawStatisticView {

		public void accept(Consumer<Map<Ball, Integer>> consumer) {
			consumer.accept(Statistic.this.results);
		}
	}

	public static class StatisticBuilder {

		private Map<Ball, Integer> results;

		private StatisticBuilder(){}

		public StatisticBuilder withResults(Map<Ball, Integer> results) {
			this.results = results;
			return this;
		}

		public static StatisticBuilder statistic() {
			return new StatisticBuilder();
		}

		public Statistic build() {
			return new Statistic(results);
		}
	}
}

Maszyna ma w sobie też klasę widokową, która udostępnia listę możliwych wyników. Budowniczy pozwoli nam na wykorzystanie tego faktu w końcowej wersji programu. Oczywiście trzeba poprawić inicjację zmiennych w testach, ale to nie jest warte listingu 😉

Podsumowanie

W tej części znaczna część pracy przypadła na przygotowanie statystyki gry. Jednak mając już gotową tę klasę w praktyce możemy przystąpić do implementacji głównego programu. Jedyną rzeczą, której nam tu brakuje to wyświetlanie wyników. Funkcjonalność tą można zaimplementować na wiele różnych sposobów i w ostatniej części przyjrzymy się właśnie temu zagadnieniu. Pobawimy się też w modelowanie stanu za pomocą typu, co pozwoli nam na napisanie programu „którego nie da się zepsuć”.