Budowanie dużych, niezmiennych obiektów bez używania konstruktorów z długimi listami parametrów

96

Mam kilka dużych (więcej niż 3 pola) obiektów, które mogą i powinny być niezmienne. Za każdym razem, gdy napotykam taki przypadek, zwykle tworzę abominacje konstruktorów z długimi listami parametrów.

Wydaje się, że nie jest w porządku, jest trudny w użyciu, a czytelność cierpi.

Jeszcze gorzej jest, jeśli pola są pewnego rodzaju zbiorami, takimi jak listy. Prosty addSibling(S s)bardzo ułatwiłby tworzenie obiektu, ale czyni obiekt zmiennym.

Czego używacie w takich przypadkach?

Jestem na Scali i Javie, ale myślę, że problem leży po stronie języka, o ile jest zorientowany obiektowo.

Rozwiązania, które przychodzą mi do głowy:

  1. „Obrzydliwości konstruktorów z długimi listami parametrów”
  2. Wzorzec konstruktora
Malax
źródło
5
Myślę, że wzorzec konstruktora jest najlepszym i najbardziej standardowym rozwiązaniem tego problemu.
Zachary Wright
@Zachary: To, za czym się opowiadasz, działa tylko w przypadku specjalnej formy wzorca konstruktora, jak wyjaśnił tutaj Joshua Bloch: drdobbs.com/java/208403883?pgno=2 Wolę używać terminu „płynny interfejs” do wywołania ta „specjalna forma wzorca budowniczego” (zobacz moją odpowiedź).
SyntaxT3rr0r

Odpowiedzi:

76

Czy chcesz, aby po utworzeniu obiekt był zarówno łatwiejszy do odczytania, jak i niezmienny?

Myślę, że płynny interfejs POPRAWNIE WYKONANY byłby pomocny.

Wyglądałoby to tak (przykład czysto zmyślony):

final Foo immutable = FooFactory.create()
    .whereRangeConstraintsAre(100,300)
    .withColor(Color.BLUE)
    .withArea(234)
    .withInterspacing(12)
    .build();

Napisałem „PRAWIDŁOWO ZROBIONE” pogrubioną czcionką, ponieważ większość programistów Javy źle rozumie płynne interfejsy i zanieczyszcza swój obiekt metodą potrzebną do zbudowania obiektu, co jest oczywiście całkowicie błędne.

Sztuczka polega na tym, że tylko metoda build () faktycznie tworzy Foo (stąd Foo może być niezmienne).

FooFactory.create () , gdzieXXX (..) i withXXX (..) tworzą „coś innego”.

To coś innego może być FooFactory, oto jeden sposób, aby to zrobić ....

You FooFactory wyglądałby tak:

// Notice the private FooFactory constructor
private FooFactory() {
}

public static FooFactory create() {
    return new FooFactory();
}

public FooFactory withColor( final Color col ) {
    this.color = color;
    return this;
}

public Foo build() {
    return new FooImpl( color, and, all, the, other, parameters, go, here );
}
Składnia T3rr0r
źródło
11
@ all: proszę nie narzekać na postfix "Impl" w "FooImpl": ta klasa jest ukryta w fabryce i nikt jej nigdy nie zobaczy poza osobą piszącą płynny interfejs. Użytkownikowi zależy tylko na tym, aby otrzymał „Foo”. Mógłbym również nazwać „FooImpl” „FooPointlessNitpick”;)
SyntaxT3rr0r
5
Czujesz się wyprzedzający? ;) W przeszłości byłeś podstępem na ten temat. :)
Greg D
3
Wierzę, że częstym błędem on odnosi się do osoby, która jest dodanie (ETC) metody „withXXX” na tym Fooobiekcie, zamiast oddzielnego FooFactory.
Dean Harding
3
Nadal masz FooImplkonstruktora z 8 parametrami. Jaka jest poprawa?
Mot
4
Nie nazwałbym tego kodu immutablei bałbym się, że ludzie będą ponownie używać obiektów fabrycznych, ponieważ tak myślą. Mam na myśli: FooFactory people = FooFactory.create().withType("person"); Foo women = people.withGender("female").build(); Foo talls = people.tallerThan("180m").build();gdzie tallsteraz będą tylko kobiety. Nie powinno to mieć miejsca w przypadku niezmiennego interfejsu API.
Thomas Ahle
60

W Scali 2.8 można było używać nazwanych i domyślnych parametrów, a także copymetody na klasie case. Oto przykładowy kod:

case class Person(name: String, age: Int, children: List[Person] = List()) {
  def addChild(p: Person) = copy(children = p :: this.children)
}

val parent = Person(name = "Bob", age = 55)
  .addChild(Person("Lisa", 23))
  .addChild(Person("Peter", 16))
Martin Odersky
źródło
31
+1 za wynalezienie języka Scala. Tak, to nadużycie systemu reputacji, ale ... aww ... Tak bardzo kocham Scalę, że musiałem to zrobić. :)
Malax
1
O rany ... Właśnie odpowiedziałem na coś prawie identycznego! Cóż, jestem w dobrym towarzystwie. :-) Ciekawe, że wcześniej nie widziałem Twojej odpowiedzi ... <wzruszając ramionami>
Daniel C. Sobral
20

Cóż, rozważ to w Scali 2.8:

case class Person(name: String, 
                  married: Boolean = false, 
                  espouse: Option[String] = None, 
                  children: Set[String] = Set.empty) {
  def marriedTo(whom: String) = this.copy(married = true, espouse = Some(whom))
  def addChild(whom: String) = this.copy(children = children + whom)
}

scala> Person("Joseph").marriedTo("Mary").addChild("Jesus")
res1: Person = Person(Joseph,true,Some(Mary),Set(Jesus))

To oczywiście ma swój udział w problemach. Na przykład, spróbuj espousei Option[Person], a następnie uzyskanie dwóch małżonków do siebie. Nie mogę wymyślić sposobu rozwiązania tego problemu bez uciekania się do pomocy private vari / lub privatekonstruktora oraz fabryki.

Daniel C. Sobral
źródło
11

Oto kilka innych opcji:

opcja 1

Spraw, aby implementacja była zmienna, ale oddziel interfejsy, które uwidacznia, jako mutowalne i niezmienne. Jest to zaczerpnięte z projektu biblioteki Swing.

public interface Foo {
  X getX();
  Y getY();
}

public interface MutableFoo extends Foo {
  void setX(X x);
  void setY(Y y);
}

public class FooImpl implements MutableFoo {...}

public SomeClassThatUsesFoo {
  public Foo makeFoo(...) {
    MutableFoo ret = new MutableFoo...
    ret.setX(...);
    ret.setY(...);
    return ret; // As Foo, not MutableFoo
  }
}

Opcja 2

Jeśli Twoja aplikacja zawiera duży, ale wstępnie zdefiniowany zestaw niezmiennych obiektów (np. Obiekty konfiguracyjne), możesz rozważyć użycie frameworka Spring .

Stoły Little Bobby
źródło
3
Wariant 1 jest mądry (ale nie zbyt mądry), tak mi się podoba.
Tymoteusza
4
Zrobiłem to wcześniej, ale moim zdaniem jest to dalekie od bycia dobrym rozwiązaniem, ponieważ obiekt jest nadal zmienny, tylko metody mutujące są „ukryte”. Może jestem zbyt wybredna w tym temacie ...
Malax
wariant opcji 1 ma mieć oddzielne klasy dla wariantów Immutable i Mutable. Zapewnia to lepsze bezpieczeństwo niż podejście oparte na interfejsie. Prawdopodobnie okłamujesz konsumenta interfejsu API za każdym razem, gdy dajesz mu zmienny obiekt o nazwie Immutable, który po prostu musi zostać rzutowany na jego zmienny interfejs. Podejście z oddzielną klasą wymaga metod do konwersji w przód iw tył. Interfejs API JodaTime używa tego wzorca. Zobacz DateTime i MutableDateTime.
zestaw narzędzi,
6

Warto pamiętać, że istnieją różne rodzaje niezmienności . W twoim przypadku myślę, że niezmienność "popsicle" będzie działać naprawdę dobrze:

Niezmienność lodów: to coś, co żartobliwie nazywam niewielkim osłabieniem niezmienności jednorazowego zapisu. Można sobie wyobrazić obiekt lub pole, które pozostawało zmienne przez chwilę podczas inicjalizacji, a następnie zostało „zamrożone” na zawsze. Ten rodzaj niezmienności jest szczególnie przydatny w przypadku niezmiennych obiektów, które cyklicznie odwołują się do siebie, lub niezmiennych obiektów, które zostały serializowane na dysk i po deserializacji muszą być „płynne” aż do zakończenia całego procesu deserializacji, w którym to momencie wszystkie obiekty mogą być mrożony.

Więc inicjalizujesz swój obiekt, a następnie ustawiasz jakąś flagę "zamrożenie", wskazującą, że nie jest już zapisywalny. Najlepiej jest ukryć mutację za funkcją, aby funkcja była nadal czysta dla klientów korzystających z twojego API.

Julia
źródło
1
Głosy przeciw? Czy ktoś chciałby skomentować, dlaczego to nie jest dobre rozwiązanie?
Julia
+1. Może ktoś odrzucił to, ponieważ sugerujesz użycie clone()do tworzenia nowych instancji.
finnw
A może dlatego, że może to zagrozić bezpieczeństwu wątków w Javie: java.sun.com/docs/books/jls/third_edition/html/memory.html#17.5
finnw
Wadą jest to, że przyszli deweloperzy będą musieli uważać na flagę „zamrożenie”. Jeśli później zostanie dodana metoda mutatora, która zapomina zapewnić, że metoda jest odmrożona, mogą wystąpić problemy. Podobnie, jeśli zostanie napisany nowy konstruktor, który powinien wywołać freeze()metodę, ale tego nie robi , sytuacja może się pogorszyć.
stalepretzel
5

Możesz także sprawić, by niezmienne obiekty uwidaczniały metody, które wyglądają jak mutatory (takie jak addSibling), ale pozwolą im zwrócić nową instancję. To właśnie robią niezmienne kolekcje Scala.

Wadą jest to, że możesz utworzyć więcej instancji niż to konieczne. Ma to również zastosowanie tylko wtedy, gdy istnieją pośrednie prawidłowe konfiguracje (jak niektóre węzły bez rodzeństwa, co jest w porządku w większości przypadków), chyba że nie chcesz zajmować się częściowo zbudowanymi obiektami.

Na przykład krawędź grafu, która nie ma celu, ale nie jest prawidłową krawędzią grafu.

ziggystar
źródło
Domniemana wada - tworzenie większej liczby instancji niż to konieczne - nie jest tak naprawdę dużym problemem. Alokacja obiektów jest bardzo tania, podobnie jak zbieranie śmieci krótkotrwałych obiektów. Gdy analiza ucieczki jest domyślnie włączona, tego rodzaju „obiekty pośrednie” prawdopodobnie będą przydzielane w stosie, a ich utworzenie dosłownie nic nie kosztuje.
gustafc
2
@gustafc: Tak. Cliff Click opowiedział kiedyś historię o tym, jak porównali symulację Rich Hickey's Clojure Ant Colony na jednym ze swoich dużych pudełek (864 rdzenie, 768 GB pamięci RAM): 700 równoległych wątków działających na 700 rdzeniach, każdy na 100%, generując ponad 20 GB efemeryczne śmieci na sekundę . GC nawet się nie spociła.
Jörg W Mittag
5

Rozważ cztery możliwości:

new Immutable(one, fish, two, fish, red, fish, blue, fish); /*1 */

params = new ImmutableParameters(); /*2 */
params.setType("fowl");
new Immutable(params);

factory = new ImmutableFactory(); /*3 */
factory.setType("fish");
factory.getInstance();

Immutable boringImmutable = new Immutable(); /* 4 */
Immutable lessBoring = boringImmutable.setType("vegetable");

Dla mnie każdy z 2, 3 i 4 jest dostosowany do innej sytuacji. Pierwszy z nich jest trudny do pokochania z powodów przytoczonych przez OP i ogólnie jest objawem projektu, który przeszedł pewne pełzanie i wymaga refaktoryzacji.

To, co wymieniam jako (2), jest dobre, gdy za „fabryką” nie ma stanu, podczas gdy (3) jest projektem z wyboru, gdy istnieje stan. Używam (2) zamiast (3), gdy nie chcę martwić się wątkami i synchronizacją, i nie muszę martwić się o amortyzację kosztownej konfiguracji podczas produkcji wielu obiektów. (3), z drugiej strony, jest wywoływane, gdy prawdziwa praca idzie do budowy fabryki (ustawienie z SPI, odczyt plików konfiguracyjnych itp.).

Wreszcie, czyjaś odpowiedź wspomniała o opcji (4), w której masz wiele małych niezmiennych obiektów, a preferowanym wzorcem jest pobieranie wiadomości ze starych.

Zwróć uwagę, że nie jestem członkiem „fanklubu wzorców” - oczywiście, niektóre rzeczy są warte naśladowania, ale wydaje mi się, że gdy ludzie nadadzą im imiona i zabawne kapelusze, zaczynają żyć własnym życiem.

bmargulies
źródło
6
To jest wzorzec konstruktora (opcja 2)
Simon Nickerson
Czy nie byłby to obiekt fabryczny („budowniczy”), który emituje swoje niezmienne obiekty?
bmargulies
to rozróżnienie wydaje się dość semantyczne. Jak powstaje fasola? Czym to się różni od budowniczego?
Carl
Nie chcesz pakietu konwencji Java Bean dla konstruktora (lub dla wielu innych).
Tom Hawtin - tackline
4

Inną potencjalną opcją jest refaktoryzacja w celu uzyskania mniejszej liczby konfigurowalnych pól. Jeśli grupy pól działają (głównie) ze sobą, zbierz je w swój własny, niezmienny obiekt. Konstruktory / konstruktory tego „małego” obiektu powinny być łatwiejsze w zarządzaniu, podobnie jak konstruktor / konstruktor dla tego „dużego” obiektu.

Carl
źródło
1
Uwaga: przebieg tej odpowiedzi może się różnić w zależności od problemu, podstawy kodu i umiejętności programisty.
Carl
2

Używam C # i to są moje podejścia. Rozważać:

class Foo
{
    // private fields only to be written inside a constructor
    private readonly int i;
    private readonly string s;
    private readonly Bar b;

    // public getter properties
    public int I { get { return i; } }
    // etc.
}

Opcja 1. Konstruktor z opcjonalnymi parametrami

public Foo(int i = 0, string s = "bla", Bar b = null)
{
    this.i = i;
    this.s = s;
    this.b = b;
}

Używany jako np new Foo(5, b: new Bar(whatever)). Nie dla wersji Java lub C # starszych niż 4.0. ale nadal warto pokazać, ponieważ jest to przykład, jak nie wszystkie rozwiązania są agnostykami językowymi.

Opcja 2. Konstruktor pobierający pojedynczy obiekt parametru

public Foo(FooParameters parameters)
{
    this.i = parameters.I;
    // etc.
}

class FooParameters
{
    // public properties with automatically generated private backing fields
    public int I { get; set; }
    public string S { get; set; }
    public Bar B { get; set; }

    // All properties are public, so we don't need a full constructor.
    // For convenience, you could include some commonly used initialization
    // patterns as additional constructors.
    public FooParameters() { }
}

Przykład użycia:

FooParameters fp = new FooParameters();
fp.I = 5;
fp.S = "bla";
fp.B = new Bar();
Foo f = new Foo(fp);`

C # od 3.0 sprawia, że ​​jest to bardziej eleganckie dzięki składni inicjatora obiektu (semantycznie równoważne z poprzednim przykładem):

FooParameters fp = new FooParameters { I = 5, S = "bla", B = new Bar() };
Foo f = new Foo(fp);

Opcja 3:
Przeprojektuj swoją klasę, aby nie potrzebowała tak dużej liczby parametrów. Możesz podzielić jego obowiązki na wiele klas. Lub przekaż parametry nie do konstruktora, ale tylko do określonych metod na żądanie. Nie zawsze jest to opłacalne, ale kiedy jest, warto to zrobić.

Joren
źródło