All problems in computer science can be solved by another level of indirection

– David Wheeler

I tego się trzymajmy. Jednak nie byłbym sobą, gdybym nie pokazał zastosowania tej reguły na przykłądzie z życia wziętym.

Problem

Mamy niezmienialną (w sensie – nie możemy zmienić kodu) klasę reprezentującą jakąś informację. W naszym przypadku była to encja reprezentująca naruszenie spójności dla instrumentu finansowego. Otrzymywaliśmy listę tego typu encji, a naszym zadaniem było jej przefiltrowanie pod kątem unikalności wpisów. Wpis był unikalny, czyli encje były równe, jak ich atrybuty były równe. Rzecz w tym, że hashCode i equlas nie były zaimplementowane więc encje zawsze były różne niezależnie od tego co miały w bebechach. Wykluczało to zastosowanie najprostszej metody na redukcję za pomocą Seta.

Listing 1. Redukcja za pomocą Set

public Collection<MyEntity> reduceToUnique(Collection<MyEntity> notUnique){
   return new HashSet<MyEntity>(notUnique);
}

Jak by sobie z tym można było poradzić…

Wprowadzamy Wrapper

Uwaga, kod już jest generyczny. W moim przypadku pierwsza wersja była niegeneryczna i przygotowana pod konkretna encję. Omówię ją w dużym skrócie, ale jest ona przykładem implementacji „starupowej” – byle jak byle działało.

By rozwiązać ten problem wprowadziłem dodatkową warstwę pomiędzy obiektami otrzymywanymi z zewnątrz, a naszą logiką. Jego zadanie polega na dostarczeniu naszej własnej implementacji dla metod hashCode i equlas. W tym miejscu natknąłem się na pewien problem w implementacji, który spowodował, że pierwsze, niegeneryczne, podejście do implementacji choć zakończyło się sukcesem to zdecydowałem się na powtórą implementację całego rozwiązania.

W takim niegenerycznym przypadku tworzymy jedeną klasę wrappera na każdą klasę, którą chcemy obudować. Powoduje to, że pojawia się masa zduplikowanego kodu. Przykładowo dla kasy Person z biblioteki JFairy, która to klasa zachowuje się jak nasza encja, będziemy mieli:

Listing 2. Niegeneryczny Wrapper

class PersonWrapper{
    private final Person person;

    public PersonWrapper(Person person) {
        this.person = person;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof PersonWrapper)) {
            return false;
        }

        PersonWrapper that = (PersonWrapper) o;

        if (person != null ? !person.firstName().equals(that.person.firstName()) : that.person != null) {
            return false;
        }

        return true;
    }

    @Override
    public int hashCode() {
        return person != null ? person.firstName().hashCode() : 0;
    }
}

Dodatkowo jeżeli chcemy zmienić implementację warunków to musimy stworzyć nową klasę. Czyli jest źle. Znaczy się jeżeli chcemy to zrobić punktowo dla jakiejś jednej klasy i zestawu warunków to będzie ok. Jednak jeżeli będziemy tego używać w wielu miejscach to warto zaimplementować to porządnie.

Jak widać w niegenerycznej wersji problemem było sztywne zaszycie w kodzie klasy zarunków równości. By wyciągnąć je na zewnątrz będziemy potrzebować dwóch interfejsów. Po jednym na każdą metodę. Dlaczego dwóch? Ponieważ dzięki temu możemy zaimplementować je później jako lambdy.

Listing 3. Generyczny Wrapper i dodatkowe interfejsy

class Wrapper<T> {

    public final T bean;
    private final Eq<T> eq;
    private final Hc<T> hc;

    Wrapper(T bean, Eq<T> eq, Hc<T> hc) {
        this.bean = bean;
        this.eq = eq;
        this.hc = hc;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof Wrapper)) {
            return false;
        }

        Wrapper wrapper = (Wrapper) o;

        Object right = wrapper.bean;
        if (right != null && right.getClass().isAssignableFrom(bean.getClass()))
            return eq.eq(this.bean, (T) right);

        return false;
    }

    @Override
    public int hashCode() {
        return bean != null ? hc.hashCode(bean) : 0;
    }

}
interface Eq<T> {

    boolean eq(T left, T right);
}

interface Hc<T> {

    int hashCode(T t);
}

Jak łatwo zauważyć zarówno hashCode jak i equals zostały zaimplementowane w ten sposób by po dokonaniu wstępnej weryfikacji obudowanego obiektu oddelegować konkretne wyliczenia do implementacji interfejsów.

Czas na przykłado wykorzystania. W tym celu stworzymy losową listę obiektów Person i wyciągniemy unikalny zestaw. Warunkiem równości będzie płeć (jest to warunek w miarę rozsądny, bo zbiór wartości jest ograniczony, a możliwość wylosowania tylko osób o takiej samej płci przy dużej liczbie losowań jest znikoma).

Listing 4. Program przykładowy

public class WrapperExample {

    public static void main(String[] args) {
        Fairy fairy = Fairy.create(Locale.forLanguageTag("PL"));
        List<Person> persons = IntStream.range(0, 1000)
                .collect(ArrayList::new, (c, i) -> c.add(i), ArrayList::addAll)
                .stream()
                .map(i -> fairy.person())
                .collect(Collectors.toList());
        System.out.println("Raw data " + persons.size());

        Set<Person> personSet = persons.stream().collect(Collectors.toSet());
        System.out.println("Set default " + personSet.size());

        List<Person> personWrappersSex = persons.stream()
                .map(p -> new Wrapper<Person>(p,
                        (left, right) -> (left.isMale() && right.isMale()) || (left.isFemale() && right.isFemale()),
                        g -> g.isMale() ? 1 : 0
        )).collect(Collectors.toSet()).stream()
                .map(w -> w.bean).collect(Collectors.toList());
        System.out.println("Set from wrapper sex " + personWrappersSex.size());
    }

}

Najpierw tworzymy listę tysiąca osób. Następnie próbujemy wrzucić je do Seta i otrzymujemy zbiór o tej samej wielkości. W kolejnym kroku najpierw mapujemy wszystkie obiekty na Wrapper z odpowiednimi warunkami równości. Następnie zbieramy to do Seta, co powoduje wywołanie logiki z interfejsów Eq i Hc po czym znowu mapujemy wyłuskując Person i zbieramy do listy.

Podsumowanie

Jak widać po wprowadzeniu dodatkowej warstwy problem stał się trywialny i ograniczył się do napisania implementacji dla odpowiednich metod.

Nasza oryginalna implementacja jest jeszcze troche inna ponieważ mamy Javę 1.6 i Guavę co powoduje, że kod jest bardziej rozwlekły w wykorzystaniu.