Finalna wersja Lottomatu
Lottomat, który przez ostatni tydzień tworzymy, jest już praktycznie gotowy. Pozostało nam jedynie napisanie wyświetlania statystyk oraz złożenie wszystkich elementów w całość. We wczorajszym wpisie stworzyliśmy klasę Statistic, która zawiera widok pozwalający nam na pracę z mapą, zawierającą informacje ile razy dana kula została wylosowana.
Wyświetlanie informacji
Specyfikacja w postaci pierwszej wersji programu narzuca nam pewien sposób wyświetlania informacji. Na konsoli ma pojawić się lista kul, reprezentowanych przez sam numer wraz z informacją, w jakiej ilości losowań w stosunku do wszystkich losowań (procentowo) dana kula wypadła. Lista ma być posortowana rosnąco. Najczęściej wylosowane kule na samym końcu.
Mamy tu kilka elementów. Pierwszym jest sortowanie mapy statystyki po wartości. Możemy to rozwiązać za pomocą generycznej funkcji, która będzie działać dla dowolnych map. Napiszmy na początek testy:
Listing 1. Test sortowania mapy po wartościach
public class MapByValueSortComparatorTest {
@Test
public void shouldCompareReturnValidValues() throws Exception {
Map.Entry<Integer, Integer> e1 = new HashMap.SimpleEntry(1, 1);
Map.Entry<Integer, Integer> e2 = new HashMap.SimpleEntry(1, 2);
MapByValueSortComparator<Integer, Integer> cmp = new MapByValueSortComparator<>(Integer::compare);
assertThat(cmp.compare(e1, e2)).isLessThan(0);
assertThat(cmp.compare(e2, e1)).isGreaterThan(0);
assertThat(cmp.compare(e1, e1)).isEqualTo(0);
}
}
Mamy dwa obiekty Entity i chcemy by zostały uszeregowane wg wartości. Obiekty te mogą być dowolne. Dlatego potrzebujemy komparatora, który będzie przekazany do naszej maszynki. Implementacja opera się o delegację:
Listing 2. Sortowanie mapy po wartościach
public class MapByValueSortComparator<K, V> implements Comparator<Entry<K, V>> {
private final Comparator<V> valueComparator;
public MapByValueSortComparator(Comparator<V> valueComparator) {
this.valueComparator = valueComparator;
}
@Override
public int compare(Entry<K, V> o1, Entry<K, V> o2) {
return valueComparator.compare(o1.getValue(), o2.getValue());
}
}
Kolejnym elementem jest wyświetlanie tak posortowanej kolekcji. Tu pomocna okaże się interfejs LogEntry, który będzie reprezentować pojedynczą linię logu. Takie rozwiązanie pozwala nam na odseparowanie danych od sposobu ich wyświetlania. Będzie on generyczny i będzie zawierać jedną metodę, zwracającą linię logu. Sama linia może być czymkolwiek. Począwszy od prostego Stringa, poprzez specyficzny obiekt, na mapie kończąc. Wszystko zależy od tego, jak działa mechanizm logujący i czy po prostu zapisuje dane, czy też dokonuje jakiś dodatkowych operacji.
Proste wyświetlanie danych
Najprostsze wyświetlanie danych oprzemy o klasę BallCardinalityLogEntry. Będzie wyświetlać pary numer kuli → liczność w formacie nr=l. Test i implementacja:
Listing 3. Prosta reprezentacja danych – test
public class BallCardinalityLogEntryTest {
@Test
public void shouldCreateProperLogLine() throws Exception {
Ball b1 = new Ball(1);
BallCardinalityLogEntry entry = new BallCardinalityLogEntry(b1, 1);
assertThat(entry.logLine()).isEqualTo("1=1");
}
}
Listing 4. Prosta reprezentacja danych – klasa
public class BallCardinalityLogEntry implements LogEntry<String> {
private final String logLine;
public BallCardinalityLogEntry(Ball ball, Integer cardinality) {
StringBuilder sb = new StringBuilder();
ball.new RawBallView().accept(sb::append);
sb.append("=").append(cardinality);
logLine = sb.toString();
}
@Override
public String logLine() {
return logLine;
}
}
Możemy w końcu napisać kod odpowiedzialny za wyświetlanie statystyki. Klasa StatisticLogger jest implementacją konsumenta, który pasuje do klasy RawStatisticView. Jej zadaniem jest transformacja statystyki na poszczególne linie logu, a następnie oddelegowanie zapisu do kolejnego konsumenta. Ten będzie już dokonywał zapisu danych we wskazanym miejscu. Przyjrzyjmy się testom:
Listing 5. Test dla StatisticLogger
public class StatisticLoggerTest {
@Test
public void shouldApplyTransformStatisticToLogCall() 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
);
Map<Ball, Integer> ballsMap = Maps.asMap(new HashSet<>(balls), b -> 1);
Consumer<String> logger = mock(Consumer.class);
StatisticLogger sut = new StatisticLogger(logger);
sut.accept(ballsMap);
verify(logger, atLeast(6)).accept(anyString());
}
}
Sama klasa też nie jest skomplikowana:
Listing 6. StatisticLogger
public class StatisticLogger implements Consumer<Map<Ball, Integer>> {
private final Consumer<String> logger;
public StatisticLogger(Consumer<String> logger) {
this.logger = logger;
}
@Override
public void accept(Map<Ball, Integer> ballIntegerMap) {
ballIntegerMap.entrySet()
.stream()
.sorted(new MapByValueSortComparator<>(Integer::compare))
.map(e -> new BallCardinalityLogEntry(e.getKey(), e.getValue()).logLine())
.forEach(logger);
}
}
Choć łatwo dostrzeżemy tu pewien problem. Operacja logowania zamknięta w metodzie accept robi kilka rzeczy naraz. W ogólności o to, mniej więcej, nam chodziło. Mamy jakąś projekcję statystyki, transformujemy ją (sortowanie to też transformacja) i zapisujemy do logów. W kodzie jednak zaszyliśmy na sztywno pewne operacje. Nie chcemy tego. Zatem dokonajmy małej refaktoryzacji:
Listing 7. Zmieniony test StatisticLogger
public class StatisticLoggerTest {
@Test
public void shouldApplyTransformStatisticToLogCall() 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
);
Map<Ball, Integer> ballsMap = Maps.asMap(new HashSet<>(balls), b -> 1);
Consumer<String> logger = mock(Consumer.class);
Function<Stream<Entry<Ball, Integer>>, Stream<LogEntry<String>>> transformer = mock(Function.class);
LogEntry<String> logEntry = mock(LogEntry.class);
Stream<LogEntry<String>> stream = Stream.generate(() -> logEntry).limit(6);
when(transformer.apply(anyObject())).thenReturn(stream);
when(logEntry.logLine()).thenReturn("");
StatisticLogger sut = new StatisticLogger(logger, transformer);
sut.accept(ballsMap);
verify(transformer, atLeastOnce()).apply(anyObject());
verify(logEntry, atLeast(6)).logLine();
verify(logger, atLeast(6)).accept("");
}
}
Test optycznie się skomplikował. Jednak jego logika nadal jest bardzo prosta. Tym, co gmatwa, to sposób, w jaki tworzymy funkcję transformującą mapę statystyki na linie logu. Sama klasa jednak znacząco się uprościła:
Listing 8. Zmieniona StatisticLogger
public class StatisticLogger<T> implements Consumer<Map<Ball, Integer>> {
private final Consumer<T> logger;
private final Function<Stream<Entry<Ball, Integer>>, Stream<LogEntry<T>>> transformer;
public StatisticLogger(Consumer<T> logger,
Function<Stream<Entry<Ball, Integer>>, Stream<LogEntry<T>>> transformer) {
this.logger = logger;
this.transformer = transformer;
}
@Override
public void accept(Map<Ball, Integer> ballIntegerMap) {
transformer.apply(ballIntegerMap.entrySet()
.stream())
.map(LogEntry::logLine)
.forEach(logger);
}
}
Usunęliśmy tu trochę kodu, który pełnił ważną funkcję. Jednak jak zaraz zobaczymy, nie jest to problem.
Program prawie końcowy
Mamy już wszystkie elementy naszego programu czas złożyć go w całość. By wyrównać szanse pomiędzy starym i nowym programem w zakresie pokrycia testami podobnie jak w poprzednim przypadku będzie tylko jedna metoda publiczna stat oraz wypisywanie wyników będzie działo się w metodzie main.
Listing 9. Program NewLotto
public class NewLotto {
public static final int NBR = Lotto.NBR;
public static void main(String[] args) {
NewLotto newLotto = new NewLotto();
Statistic reduce = newLotto.stat(NBR);
reduce.new RawStatisticView().accept(new StatisticLogger<String>(
System.out::println,
es -> es.sorted(new MapByValueSortComparator<>(Integer::compare))
.map(e -> new BallCardinalityLogEntry(e.getKey(), e.getValue()))
));
}
private final TotolotekMachine machine;
public Statistic stat(int nbr) {
Statistic initialStatistic = buildInitialStatistic();
return generate(machine::pick)
.limit(nbr)
.reduce(initialStatistic, Statistic::append, Statistic::append);
}
private Statistic buildInitialStatistic() {
StatisticBuilder builder = statistic();
machine.new MachineContentView().accept(balls ->
builder.withResults(balls.stream()
.collect(toMap(identity(), b -> 0)))
);
return builder.build();
}
public NewLotto() {
machine = new TotolotekMachine();
}
}
Jest prawie idealnie… prawie. Wypisywane wartości nie są wartościami procentowymi, a liczbowymi. Jednak to nie jest problemem. Wystarczy zmienić BallCardinalityLogEntry na inną implementację. I nadal MAMY PEWNOŚĆ, że program będzie działać poprawnie. Zmianę tą znajdziesz w kodzie źródłowym na githubie.
Podsumowanie
Stworzony tu program jest znacznie bardziej elastyczny i odporniejszy na błędy niż jego pierwotna wersja. Choć ilość kodu, która powstała jest większa, to jednak jego jakość jest też zdecydowanie lepsza. Wymuszenie w wielu miejscach użycia konkretnego typu albo techniki programistycznej spowodowało, że kod ma lepszą strukturę. Co więcej, jest też znacznie odporniejszy na przypadkowe błędy spowodowane przez zmiany. Potwierdzają to raporty z testów mutacyjnych. Znacznie łatwiej jest nam też operować mniejszymi jednostkami kodu, którymi są wyspecjalizowane klasy.
Podejście, które zaprezentowałem w kilku ostatnich wpisach, ma duży potencjał, jeśli chodzi o tworzenie dobrego kodu. Jednocześnie wymaga od nas więcej pracy i przemyślenia tego, jak ma wyglądać nasz kod. Dzięki zastosowaniu bardzo głębokiej dekompozycji przy tworzeniu poszczególnych elementów wszystkie one są łatwe do wymiany. Tym samym można założyć, że nawet osoba, która pierwszy raz ma do czynienia z tym kodem, nie zrobi nic głupiego. Najzwyczajniej w świecie kompilator jej na to nie pozwoli.