Java: Jak zaimplementować konstruktor kroków, dla którego kolejność seterów nie ma znaczenia?

10

Edycja: Chciałbym zaznaczyć, że to pytanie opisuje problem teoretyczny i jestem świadomy, że mogę użyć argumentów konstruktora dla parametrów obowiązkowych lub zgłosić wyjątek czasu wykonywania, jeśli interfejs API jest używany nieprawidłowo. Jednak szukam rozwiązania, które nie wymaga argumentów konstruktora ani sprawdzania czasu wykonywania.

Wyobraź sobie, że masz taki Carinterfejs:

public interface Car {
    public Engine getEngine(); // required
    public Transmission getTransmission(); // required
    public Stereo getStereo(); // optional
}

Jak sugerują komentarze, Carmusi mieć Enginei Transmissionale Stereojest opcjonalne. Oznacza to, że konstruktor, który może build()mieć Carinstancję, powinien mieć build()metodę tylko wtedy, gdy Enginei Transmissionoba zostały już przekazane instancji konstruktora. W ten sposób moduł sprawdzania typu odmówi skompilowania kodu, który próbuje utworzyć Carinstancję bez znaku Enginelub Transmission.

Wymaga to Kreatora kroków . Zazwyczaj implementujesz coś takiego:

public interface Car {
    public Engine getEngine(); // required
    public Transmission getTransmission(); // required
    public Stereo getStereo(); // optional

    public class Builder {
        public BuilderWithEngine engine(Engine engine) {
            return new BuilderWithEngine(engine);
        }
    }

    public class BuilderWithEngine {
        private Engine engine;
        private BuilderWithEngine(Engine engine) {
            this.engine = engine;
        }
        public BuilderWithEngine engine(Engine engine) {
            this.engine = engine;
            return this;
        }
        public CompleteBuilder transmission(Transmission transmission) {
            return new CompleteBuilder(engine, transmission);
        }
    }

    public class CompleteBuilder {
        private Engine engine;
        private Transmission transmission;
        private Stereo stereo = null;
        private CompleteBuilder(Engine engine, Transmission transmission) {
            this.engine = engine;
            this.transmission = transmission;
        }
        public CompleteBuilder engine(Engine engine) {
            this.engine = engine;
            return this;
        }
        public CompleteBuilder transmission(Transmission transmission) {
            this.transmission = transmission;
            return this;
        }
        public CompleteBuilder stereo(Stereo stereo) {
            this.stereo = stereo;
            return this;
        }
        public Car build() {
            return new Car() {
                @Override
                public Engine getEngine() {
                    return engine;
                }
                @Override
                public Transmission getTransmission() {
                    return transmission;
                }
                @Override
                public Stereo getStereo() {
                    return stereo;
                }
            };
        }
    }
}

Jest to sieć różnych klas wypełniaczy ( Builder, BuilderWithEngine, CompleteBuilder), że jeden dodatek wymagane metody setter po drugim, z ostatniej klasy zawierającej wszystkie opcjonalne metody setter, jak również.
Oznacza to, że użytkownicy tego konstruktora kroków ograniczają się do kolejności, w jakiej autor udostępnił obowiązkowe programy ustawiające . Oto przykład możliwych zastosowań (zwróć uwagę, że wszystkie są ściśle uporządkowane: engine(e)najpierw, następnie transmission(t), a na końcu opcjonalnie stereo(s)).

new Builder().engine(e).transmission(t).build();
new Builder().engine(e).transmission(t).stereo(s).build();
new Builder().engine(e).engine(e).transmission(t).stereo(s).build();
new Builder().engine(e).transmission(t).engine(e).stereo(s).build();
new Builder().engine(e).transmission(t).stereo(s).engine(e).build();
new Builder().engine(e).transmission(t).transmission(t).stereo(s).build();
new Builder().engine(e).transmission(t).stereo(s).transmission(t).build();
new Builder().engine(e).transmission(t).stereo(s).stereo(s).build();

Istnieje jednak wiele scenariuszy, w których nie jest to idealne rozwiązanie dla użytkownika konstruktora, szczególnie jeśli konstruktor ma nie tylko programy ustawiające, ale także addery lub jeśli użytkownik nie może kontrolować kolejności, w której niektóre właściwości konstruktora będą dostępne.

Jedyne rozwiązanie, o którym mogłem pomyśleć, jest bardzo skomplikowane: dla każdej kombinacji ustawionych lub jeszcze ustawionych właściwości obowiązkowych stworzyłem dedykowaną klasę konstruktora, która wie, które potencjalne inne elementy ustawiające obowiązkowe należy wywołać przed przybyciem do stan, w którym build()metoda powinna być dostępna, a każdy z tych ustawiających zwraca bardziej kompletny typ konstruktora, który jest o krok bliżej do zawarcia build()metody.
Dodałem poniższy kod, ale możesz powiedzieć, że używam systemu typów do utworzenia FSM, który pozwala ci utworzyć a Builder, który można przekształcić w a, BuilderWithEnginelub BuilderWithTransmissionktóry oba można następnie przekształcić w a CompleteBuilder, który implementujebuild()metoda. Opcjonalne elementy ustawiające można wywoływać w każdym z tych wystąpień programu budującego. wprowadź opis zdjęcia tutaj

public interface Car {
    public Engine getEngine(); // required
    public Transmission getTransmission(); // required
    public Stereo getStereo(); // optional

    public class Builder extends OptionalBuilder {
        public BuilderWithEngine engine(Engine engine) {
            return new BuilderWithEngine(engine, stereo);
        }
        public BuilderWithTransmission transmission(Transmission transmission) {
            return new BuilderWithTransmission(transmission, stereo);
        }
        @Override
        public Builder stereo(Stereo stereo) {
            super.stereo(stereo);
            return this;
        }
    }

    public class OptionalBuilder {
        protected Stereo stereo = null;
        private OptionalBuilder() {}
        public OptionalBuilder stereo(Stereo stereo) {
            this.stereo = stereo;
            return this;
        }
    }

    public class BuilderWithEngine extends OptionalBuilder {
        private Engine engine;
        private BuilderWithEngine(Engine engine, Stereo stereo) {
            this.engine = engine;
            this.stereo = stereo;
        }
        public CompleteBuilder transmission(Transmission transmission) {
            return new CompleteBuilder(engine, transmission, stereo);
        }
        public BuilderWithEngine engine(Engine engine) {
            this.engine = engine;
            return this;
        }
        @Override
        public BuilderWithEngine stereo(Stereo stereo) {
            super.stereo(stereo);
            return this;
        }
    }

    public class BuilderWithTransmission extends OptionalBuilder {
        private Transmission transmission;
        private BuilderWithTransmission(Transmission transmission, Stereo stereo) {
            this.transmission = transmission;
            this.stereo = stereo;
        }
        public CompleteBuilder engine(Engine engine) {
            return new CompleteBuilder(engine, transmission, stereo);
        }
        public BuilderWithTransmission transmission(Transmission transmission) {
            this.transmission = transmission;
            return this;
        }
        @Override
        public BuilderWithTransmission stereo(Stereo stereo) {
            super.stereo(stereo);
            return this;
        }
    }

    public class CompleteBuilder extends OptionalBuilder {
        private Engine engine;
        private Transmission transmission;
        private CompleteBuilder(Engine engine, Transmission transmission, Stereo stereo) {
            this.engine = engine;
            this.transmission = transmission;
            this.stereo = stereo;
        }
        public CompleteBuilder engine(Engine engine) {
            this.engine = engine;
            return this;
        }
        public CompleteBuilder transmission(Transmission transmission) {
            this.transmission = transmission;
            return this;
        }
        @Override
        public CompleteBuilder stereo(Stereo stereo) {
            super.stereo(stereo);
            return this;
        }
        public Car build() {
            return new Car() {
                @Override
                public Engine getEngine() {
                    return engine;
                }
                @Override
                public Transmission getTransmission() {
                    return transmission;
                }
                @Override
                public Stereo getStereo() {
                    return stereo;
                }
            };
        }
    }
}

Jak widać, nie skaluje się to dobrze, ponieważ wymagana liczba różnych klas konstruktorów wynosiłaby O (2 ^ n), gdzie n jest liczbą obowiązkowych ustawień.

Stąd moje pytanie: czy można to zrobić bardziej elegancko?

(Szukam odpowiedzi, która działa z Javą, chociaż Scala też byłaby do przyjęcia)

derabbink
źródło
1
Co powstrzymuje cię przed używaniem kontenera IoC do zniesienia wszystkich tych zależności? Nie jestem też jasne, dlaczego, jeśli porządek nie ma znaczenia, jak twierdziłeś, nie możesz po prostu użyć zwykłych metod ustawiających, które powracają this?
Robert Harvey
Co to znaczy wywołać .engine(e)dwa razy dla jednego konstruktora?
Erik Eidt,
3
Jeśli chcesz to zweryfikować statycznie bez ręcznego pisania klasy dla każdej kombinacji, prawdopodobnie musisz użyć rzeczy na poziomie szyi, takich jak makra lub metaprogramowanie szablonu. O ile wiem, Java nie jest wystarczająco ekspresyjna, a wysiłek prawdopodobnie nie byłby tego wart w porównaniu z dynamicznie weryfikowanymi rozwiązaniami w innych językach.
Karl Bielefeldt,
1
Robert: Celem jest, aby weryfikator typu egzekwował fakt, że zarówno silnik, jak i skrzynia biegów są obowiązkowe; W ten sposób nie można nawet nazwać build()jeśli nie nazwali engine(e)i transmission(t)wcześniej.
derabbink
Erik: Możesz zacząć od domyślnej Engineimplementacji, a później zastąpić ją bardziej szczegółową implementacją. Ale najprawdopodobniej będzie to więcej sensu, jeśli engine(e)nie był rozgrywający, ale żmija: addEngine(e). Byłoby to przydatne dla Carkonstruktora, który może produkować samochody hybrydowe z więcej niż jednym silnikiem / silnikiem. Ponieważ jest to wymyślony przykład, nie wdałem się w szczegóły, dlaczego warto to zrobić - dla zwięzłości.
derabbink

Odpowiedzi:

3

Wygląda na to, że masz dwa różne wymagania w zależności od podanych przez ciebie wywołań metod.

  1. Tylko jeden (wymagany) silnik, tylko jedna (wymagana) przekładnia i tylko jeden (opcjonalny) stereo.
  2. Co najmniej jeden (wymagany) silnik, jedna lub więcej (wymagana) transmisja i co najmniej jeden (opcjonalny) zestaw stereo.

Myślę, że pierwszym problemem jest to, że nie wiesz, co chcesz zrobić w klasie. Częściowo dlatego, że nie wiadomo, jak ma wyglądać zbudowany obiekt.

Samochód może mieć tylko jeden silnik i jedną skrzynię biegów. Nawet samochody hybrydowe mają tylko jeden silnik (być może a GasAndElectricEngine)

Zajmę się obiema implementacjami:

public class CarBuilder {

    public CarBuilder(Engine engine, Transmission transmission) {
        // ...
    }

    public CarBuilder setStereo(Stereo stereo) {
        // ...
        return this;
    }
}

i

public class CarBuilder {

    public CarBuilder(List<Engine> engines, List<Transmission> transmission) {
        // ...
    }

    public CarBuilder addStereo(Stereo stereo) {
        // ...
        return this;
    }
}

Jeśli wymagany jest silnik i przekładnia, powinny one znajdować się w konstruktorze.

Jeśli nie wiesz, jaki silnik lub skrzynia biegów jest wymagana, nie ustawiaj jej jeszcze; to znak, że tworzysz konstruktora zbyt daleko na stosie.

Zymus
źródło
2

Dlaczego nie użyć wzorca zerowego obiektu? Pozbądź się tego konstruktora, najbardziej eleganckim kodem, jaki możesz napisać, jest ten, którego tak naprawdę nie musisz pisać.

public final class CarImpl implements Car {
    private final Engine engine;
    private final Transmission transmission;
    private final Stereo stereo;

    public CarImpl(Engine engine, Transmission transmission) {
        this(engine, transmission, new DefaultStereo());
    }

    public CarImpl(Engine engine, Transmission transmission, Stereo stereo) {
        this.engine = engine;
        this.transmission = transmission;
        this.stereo = stereo;
    }

    //...

}
Cętkowany
źródło
Tak myślałem od razu. chociaż nie miałbym konstruktora z trzema parametrami, tylko konstruktor z dwoma parametrami, który ma elementy obowiązkowe, a następnie seter dla stereo, ponieważ jest on opcjonalny.
Encaitar
1
W prostym (wymyślonym) przykładzie Car, miałoby to sens, ponieważ liczba argumentów c'tor jest bardzo mała. Jednak gdy tylko masz do czynienia z czymś umiarkowanie złożonym (> = 4 obowiązkowe argumenty), cała sprawa staje się trudniejsza do obsługi / mniej czytelna („Czy silnik lub skrzynia biegów były najważniejsze?”). Dlatego użyjesz konstruktora: interfejs API zmusza cię do wyraźniejszego wyrażenia się na temat tego, co konstruujesz.
derabbink
1
@derabbink Dlaczego nie rozbicie swojej klasy na mniejsze w tym przypadku? Korzystanie z konstruktora po prostu ukryje fakt, że klasa robi za dużo i stała się niemożliwa do utrzymania.
Zauważył
1
Wyrazy uznania za zakończenie szaleństwa wzoru.
Robert Harvey
@ Zauważyłem, że niektóre klasy zawierają po prostu dużo danych. Na przykład, jeśli chcesz utworzyć klasę dziennika dostępu, która przechowuje wszystkie powiązane informacje o żądaniu HTTP i wysyła dane do formatu CSV lub JSON. Będzie dużo danych i jeśli chcesz wymusić obecność niektórych pól w czasie kompilacji, potrzebujesz wzorca konstruktora z bardzo długim konstruktorem listy arg, który nie wygląda dobrze.
ssgao
1

Po pierwsze, chyba że masz dużo więcej czasu niż jakikolwiek sklep, w którym pracowałem, prawdopodobnie nie warto pozwolić na żadną kolejność operacji lub po prostu żyć z faktem, że możesz podać więcej niż jedno radio. Zauważ, że mówisz o kodzie, a nie o danych wejściowych użytkownika, więc możesz mieć asercje, które zawiodą podczas testowania jednostki, a nie sekundę przed kompilacją.

Jednakże, jeśli twoim ograniczeniem jest, jak podano w komentarzach, że musisz mieć silnik i skrzynię biegów, wymusz to, umieszczając wszystkie obowiązkowe właściwości konstruktora konstruktora.

new Builder(e, t).build();                      // ok
new Builder(e, t).stereo(s).build();            // ok
new Builder(e, t).stereo(s).stereo(s).build();  // exception on second call to stereo as stereo is already set 

Jeśli tylko stereo jest opcjonalny, to wykonanie ostatniego kroku przy użyciu podklas konstruktorów jest możliwe, ale poza tym zysk z uzyskania błędu w czasie kompilacji zamiast w testowaniu prawdopodobnie nie jest wart wysiłku.

Pete Kirkham
źródło
0

wymagana liczba różnych klas konstruktorów wynosiłaby O (2 ^ n), gdzie n jest liczbą obowiązkowych ustawień.

Odgadłeś już właściwy kierunek dla tego pytania.

Jeśli chcesz uzyskać sprawdzanie czasu kompilacji, potrzebujesz (2^n)typów. Jeśli chcesz sprawdzać w czasie wykonywania, potrzebujesz zmiennej, która może przechowywać (2^n)stany; wystarczy n-bitowa liczba całkowita.


Ponieważ C ++ obsługuje parametr szablonu innego niż typ (np. Wartości całkowite) , możliwe jest utworzenie instancji szablonu klasy C ++ na O(2^n)różne typy przy użyciu schematu podobnego do tego .

Jednak w językach, które nie obsługują parametrów szablonów innych niż typy, nie można polegać na systemie typów w tworzeniu instancji O(2^n)różnych typów.


Następną możliwością są adnotacje Java (i atrybuty C #). Te dodatkowe metadane można wykorzystać do wyzwalania zachowania zdefiniowanego przez użytkownika w czasie kompilacji, gdy używane są procesory adnotacji . Jednak wdrożenie ich byłoby zbyt pracochłonne. Jeśli korzystasz z frameworków, które zapewniają tę funkcję, użyj jej. W przeciwnym razie sprawdź następną okazję.


Na koniec zauważ, że przechowywanie O(2^n)różnych stanów jako zmiennych w czasie wykonywania (dosłownie jako liczba całkowita o nszerokości co najmniej bitów) jest bardzo łatwe. Z tego powodu najbardziej uprzywilejowane odpowiedzi zalecają wykonanie tego sprawdzenia w czasie wykonywania, ponieważ wysiłek potrzebny do wdrożenia sprawdzania czasu kompilacji jest zbyt duży w porównaniu z potencjalnym zyskiem.

rwong
źródło