Switch over Object Type in Java

by Paweł Widera
2 komentarze

Zastanawiałeś się kiedyś czy da się użyć instrukcji switch podając do niej obiekt dowolnej klasy? Wraz z pojawieniem się 7 wersji javy programiści dostali możliwość wykonywania instrukcji switch podając jako argument obiekt typu String.

Sekcje w tym poście:

  1. Testy
  2. Implementacja
  3. Podsumowanie

Przykładowy sposób użycia mógłby zatem wyglądać następująco…

package ninja.programista.typeswitch;

import org.junit.Assert;
import org.junit.Test;

public class StringSwitch {

    private String getCountryCapitol(String country) {
        String capitol;
        switch (country) {
            case "Poland" : capitol = "Warsaw"; break;
            case "France" : capitol = "Paris"; break;
            default: capitol = "unknown"; break;
        }
        return capitol;
    }

    @Test
    public void stringTest() {
        Assert.assertEquals(getCountryCapitol("Poland"), "Warsaw");
        Assert.assertEquals(getCountryCapitol("France"), "Paris");
        Assert.assertEquals(getCountryCapitol("Uganda"), "unknown");
    }
}

Jak widać rozwiązanie jest bardzo szybkie i przyjemne. Co jednak jeśli chcielibyśmy wykorzystać tą instrukcje do sprawdzania czegoś więcej? np. typów samych obiektów? Byłoby to bardzo pomocne w aplikacjach, które posługują się np. eventami w komunikacji.
Załóżmy zatem, że mamy 3 klasy reprezentujące różne eventy. Dla uproszczenia zdeklarujemy je wszystkie w tej samej klasie.

package ninja.programista.typeswitch;

public class Communication {

    static class AddCustomerEvent{}
    static class RemoveCustomerEvent{}
    static class ModifyCustomerEvent{}
}

Co więcej chcielibyśmy również mieć 3 metody onEvent() przyjmujące wybrany typ eventu do obsługi każdego z nich. Czyli zmodyfikujmy jeszcze naszą klasę następująco.

package ninja.programista.typeswitch;

public class Communication {

    static class AddCustomerEvent{}
    static class RemoveCustomerEvent{}
    static class ModifyCustomerEvent{}

    public void onEvent(AddCustomerEvent event) {
        // do stuff
    }

    public void onEvent(ModifyCustomerEvent event) {
        // do stuff
    }

    public void onEvent(RemoveCustomerEvent event) {
        // do stuff
    }

}

Wtedy w wybranym miejscu aplikacji w zależności od typu eventu wykonujemy inne instrukcje.

package ninja.programista.typeswitch;

import org.junit.Test;

public class TypeSwitchTest{

    @Mock
    private Communication c;

    @Test
    public void switchOverClassTest() {
        processEvent(new AddCustomerEvent());
        processEvent(new ModifyCustomerEvent());
        processEvent(new RemoveCustomerEvent());

        verify(c).onEvent(any(AddCustomerEvent.class));
        verify(c).onEvent(any(ModifyCustomerEvent.class));
        verify(c).onEvent(any(RemoveCustomerEvent.class));
    }

    private void processEvent(Object event) {
        switch (event.getClass().getSimpleName()) {
            case "AddCustomerEvent": c.onEvent((AddCustomerEvent)event);
                break;
            case "ModifyCustomerEvent": c.onEvent((ModifyCustomerEvent) event);
                break;
            case "RemoveCustomerEvent": c.onEvent((RemoveCustomerEvent)event);
                break;
            default:
                // throw unknown event exception
        }
    }
}

Ostatecznie jednak chcielibyśmy uniknąć zbędnego rzutowania i pozwolić Javie zadbać o weryfikacje typów, zamiast konwertować je do Stringa. Przejdźmy zatem do naszego własnego rozwiązania.

Testy

Zacznijmy pisanie modyfikacji od testów zamiast od kodu. Pierwszy test jaki byśmy napisali, który zobrazowałby to co chcemy osiągnąć wyglądałby tak..

@Test
    public void matchTest() {
        myTypeSwitch = TypeSwitchBuilder.getInstance()
                .with(AddCustomerEvent.class, c::onEvent)
                .with(ModifyCustomerEvent.class, c::onEvent)
                .with(RemoveCustomerEvent.class, c::onEvent)
                .build();

        myTypeSwitch.handle(new AddCustomerEvent());
        myTypeSwitch.handle(new ModifyCustomerEvent());
        myTypeSwitch.handle(new RemoveCustomerEvent());

        verify(c).onEvent(any(AddCustomerEvent.class));
        verify(c).onEvent(any(ModifyCustomerEvent.class));
        verify(c).onEvent(any(RemoveCustomerEvent.class));
    }

Idąc typ tropem, określmy kolejne zachowania, które nasz „mechanizm” powinien implementować. Na pewno fajnie byłoby rzucić jakiś wyjątek w przypadku pojawienia się nowego nieznanego eventu.

@Test(expected = IllegalArgumentException.class)
    public void missingMatch() {
        myTypeSwitch = TypeSwitchBuilder.getInstance()
                .onMismath(this::throwException);

        myTypeSwitch.handle(new String());
    }

    private void throwException(Object o) {
        throw new IllegalArgumentException("missing match for event " + c.getClass());
    }

Dodatkowo jeżeli w naszym kodzie obsługi eventu wystąpi jakiś błąd, to może chcielibyśmy go przechwycić i zrobić jakąś wspólna obsługę wyjątków np. zamienić je na typ bardziej spersonalizowany.

@Test(expected = CustomException.class)
    public void onError() {
        myTypeSwitch = TypeSwitchBuilder.getInstance()
                .with(RemoveCustomerEvent.class, c::onEvent)
                .onError(TypeSwitchTest::replaceWithCustomError);

        Mockito.doThrow(new IllegalStateException()).when(c).onEvent(any(RemoveCustomerEvent.class));

// methoda rzuca IllegalStateException /|\, a my zamieniamy go na CustomException 
        myTypeSwitch.handle(new RemoveCustomerEvent());
    }

    private static void replaceWithCustomError(Exception e) {
        throw new CustomException(e);
    }

W niektórych przypadkach przydałoby się też jakieś wspólne logowanie.

 @Test
    public void withLog() {
        myTypeSwitch = TypeSwitchBuilder.getInstance()
                .with(AddCustomerEvent.class, c::onEvent)
                .with(ModifyCustomerEvent.class, c::onEvent)
                .with(RemoveCustomerEvent.class, c::onEvent)
                .withPerfLog((o, duration) ->
                        logger.info(String.format("Processed event %s in time %d", o, duration)));


        myTypeSwitch.handle(new AddCustomerEvent());
        myTypeSwitch.handle(new ModifyCustomerEvent());
        myTypeSwitch.handle(new RemoveCustomerEvent());

        verify(logger).info(contains("Processed event ninja.programista.typeswitch.Communication$AddCustomerEvent"));
        verify(logger).info(contains("Processed event ninja.programista.typeswitch.Communication$ModifyCustomerEvent"));
        verify(logger).info(contains("Processed event ninja.programista.typeswitch.Communication$RemoveCustomerEvent"));
    }

Implementacja

I tak po zdefiniowaniu wszystkich testów, możemy przystąpić do kodowania.

package ninja.programista.typeswitch;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;

public class TypeSwitchBuilder implements TypeSwitch {

    private class TypeMatch {
        private Class aClass;
        private Consumer aConsumer;

        public TypeMatch(Class aClass, Consumer aConsumer) {
            this.aClass = aClass;
            this.aConsumer = aConsumer;
        }
    }

    // ten obiekt moze byc lista, setem lub mapa w zaleznosci od zachowania ktore chcemy uzyskac
    private List matchersList = new ArrayList<>();
    private Optional onMismath = Optional.empty();
    private Optional> onError = Optional.empty();
    private Optional> onLog = Optional.empty();

    public static TypeSwitchBuilder getInstance() {
        return new TypeSwitchBuilder();
    }

    public  TypeSwitchBuilder with(final Class targetClass, final Consumer consumer) {
        matchersList.add(new TypeMatch(targetClass, consumer));
        return this;
    }

    public  TypeSwitchBuilder withPerfLog(final BiConsumer consumer) {
        onLog = Optional.of(consumer);
        return this;
    }

    public TypeSwitchBuilder onError(final Consumer consumer){
        this.onError = Optional.of(consumer);
        return this;
    }

    public TypeSwitchBuilder onMismath(final Consumer< Object> consumer) {
        this.onMismath = Optional.of(consumer);
        return this;
    }

    public TypeSwitch build() {
        return this;
    }

    public void handle(Object o) {
        List collect = matchersList.stream().filter(match -> match.aClass.equals(o.getClass())).collect(Collectors.toList());
        if(collect.size()==0) {
            onMismath.ifPresent( mis -> mis.accept(o));
        } else {
            try {
                collect.forEach(match -> {
                    long start = System.currentTimeMillis();
                    match.aConsumer.accept(o);
                    onLog.ifPresent(log -> log.accept(o, System.currentTimeMillis()-start));
                });
            } catch (Exception e) {
                onError.ifPresent( err -> err.accept(e));
            }
        }
    }
}

interface TypeSwitch {
    void handle(Object o);
}

Klasa działa w oparciu o standardowy obiekt Consumer-a z javy 8. Mamy zatem listę consumerów matchersList, która przechowuje akcje w oparciu o konkretny typ eventu. W zależności od zachowania, które chcemy uzyskać może on być zamieniony na Map lub Set. Dodatkowo znajdziemy w klasie obiekty Consumer dla operacji onMismatch, onError i onLog. Całość została opakowania w interfejs TypeSwitch w celu zamknięcia dostępu do metod budujących. Zachęcam do ściągnięcia sobie repo, pobawienia się testami i modyfikowania mechanizmu wedle własnych potrzeb.

Kod źródłowy:

Kod źródłowy wraz z testami można znaleźć na githubie: TypeSwitch repo

Podsumowanie

W dzisiejszych czasach takie pojęcia jak DDD, Event Sourcing czy event-driven systems szturmują nasze programistyczne zmysły, a eventy staja się podstawowym nośnikiem informacji. Posiadanie zatem klas pomocniczych, które uporządkują nasz kod jest na wagę złotą. Dobry kod to prosty kod, niestety taki najtrudniej napisać. Nic nie stoi zatem na przeszkodzie, żeby co bardziej zawirowane kawałki schować w klasach pomocniczych i skupić się na wystawieniu czytelnego i naturalnego api zarówno dla juniora jak i seniora. Remeber! KISS

2 komentarze

Avatar
Tomek 3 grudnia 2019 - 20:18

Jaki jest dokładny usecase takiego podejścia ? Pytam, bo w większości przypadków jakąkolwiek instrukcję switch da się zastąpić duża prostszą strategią.

Odpowiedz
Paweł Widera
Paweł Widera 3 grudnia 2019 - 22:30

Np w przypadku gdy nasłuchujesz zdarzeń z topica, na który mogą wpadać eventy różnych typów. Zazwyczaj w takiej sytuacji bazuje się na mapie handlerów z kluczem w postaci typu klasy. To rozwiązanie oferuje kompleksowe i generyczne podejście do tematu.

Odpowiedz

Zostaw komentarz