Construir objetos grandes e imutáveis ​​sem usar construtores com longas listas de parâmetros

96

Eu tenho alguns objetos grandes (mais de 3 campos) que podem e devem ser imutáveis. Cada vez que encontro esse caso, tendo a criar abominações de construtor com longas listas de parâmetros.

Não parece certo, é difícil de usar e a legibilidade é prejudicada.

É ainda pior se os campos forem algum tipo de coleção, como listas. Um simples addSibling(S s)facilitaria muito a criação do objeto, mas tornaria o objeto mutável.

O que vocês usam nesses casos?

Estou usando Scala e Java, mas acho que o problema é agnóstico de linguagem, desde que a linguagem seja orientada a objetos.

Soluções em que posso pensar:

  1. "Abominações de construtor com longas listas de parâmetros"
  2. O Padrão Builder
Malax
fonte
5
Acho que o builder pattern é a melhor e mais padronizada solução para isso.
Zachary Wright
@Zachary: O que você está defendendo só funciona para uma forma especial do padrão de construtor, conforme explicado aqui por Joshua Bloch: drdobbs.com/java/208403883?pgno=2 Prefiro usar o termo relacionado "interface fluente" para chamar esta "forma especial do padrão do construtor" (veja minha resposta).
SyntaxT3rr0r

Respostas:

76

Bem, você quer um objeto mais fácil de ler e imutável depois de criado?

Acho que uma interface fluente FEITA CORRETAMENTE ajudaria você.

Ficaria assim (exemplo puramente composto):

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

Escrevi "CONCLUÍDO CORRETAMENTE" em negrito porque a maioria dos programadores de Java erram nas interfaces fluentes e poluem seus objetos com o método necessário para construir o objeto, o que é completamente errado.

O truque é que apenas o método build () realmente cria um Foo (portanto, seu Foo pode ser imutável).

FooFactory.create () , ondeXXX (..) e comXXX (..) todos criam "algo diferente".

Essa outra coisa pode ser uma FooFactory, aqui está uma maneira de fazer isso ....

Sua FooFactory se pareceria com isto:

// 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 );
}
SintaxeT3rr0r
fonte
11
@ all: por favor, nenhuma reclamação sobre o postfix "Impl" em "FooImpl": esta classe está escondida dentro de uma fábrica e ninguém a verá além da pessoa que está escrevendo a interface fluente. Tudo o que o usuário se preocupa é em obter um "Foo". Eu poderia ter chamado "FooImpl" "FooPointlessNitpick" também;)
SyntaxT3rr0r
5
Sentindo-se preventivo? ;) Você foi criticado no passado sobre isso. :)
Greg D
3
Eu acredito que o erro comum que ele está se referindo é que as pessoas adicionar os (etc) métodos "withXXX" para o Fooobjeto, em vez de ter um separado FooFactory.
Dean Harding
3
Você ainda tem o FooImplconstrutor com 8 parâmetros. Qual é a melhoria?
Mot
4
Eu não chamaria isso de código immutablee teria medo de que as pessoas reutilizassem objetos de fábrica porque pensam que é. Quer dizer: FooFactory people = FooFactory.create().withType("person"); Foo women = people.withGender("female").build(); Foo talls = people.tallerThan("180m").build();onde tallsagora conteria apenas mulheres. Isso não deveria acontecer com uma API imutável.
Thomas Ahle
60

No Scala 2.8, você pode usar parâmetros nomeados e padrão, bem como o copymétodo em uma classe de caso. Aqui está um exemplo de código:

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
fonte
31
1 para inventar a linguagem Scala. Sim, é um abuso do sistema de reputação, mas ... aww ... Eu amo o Scala tanto que tive que fazer isso. :)
Malax
1
Oh, cara ... Acabei de responder uma coisa quase idêntica! Bem, estou em boa companhia. :-) Mas eu me pergunto se não vi sua resposta antes ... <shrug>
Daniel C. Sobral
20

Bem, considere isso no Scala 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))

Isso tem sua cota de problemas, é claro. Por exemplo, tente fazer espousee Option[Person], e então casar duas pessoas entre si. Não consigo pensar em uma maneira de resolver isso sem recorrer a um private vare / ou um privateconstrutor mais uma fábrica.

Daniel C. Sobral
fonte
11

Aqui estão mais algumas opções:

Opção 1

Torne a própria implementação mutável, mas separe as interfaces que ela expõe como mutáveis ​​e imutáveis. Isso é retirado do design da biblioteca 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
  }
}

opção 2

Se o seu aplicativo contém um grande, mas predefinido conjunto de objetos imutáveis ​​(por exemplo, objetos de configuração), você pode considerar o uso da estrutura Spring .

Mesinhas de Bobby
fonte
3
A opção 1 é inteligente (mas não muito inteligente), então eu gosto dela.
Timothy
4
Eu fiz isso antes, mas na minha opinião está longe de ser uma boa solução porque o objeto ainda é mutável, apenas os métodos mutantes estão "ocultos". Talvez eu seja muito exigente com esse assunto ...
Malax
uma variante na opção 1 é ter classes separadas para variantes imutáveis ​​e mutáveis. Isso fornece melhor segurança do que a abordagem de interface. Provavelmente você está mentindo para o consumidor da API sempre que fornece a ele um objeto mutável denominado Immutable que simplesmente precisa ser lançado em sua interface mutável. A abordagem de classe separada requer métodos para converter de um lado para outro. A API JodaTime usa esse padrão. Consulte DateTime e MutableDateTime.
toolbear
6

Isso ajuda a lembrar que existem diferentes tipos de imutabilidade . Para o seu caso, acho que a imutabilidade do "picolé" funcionará muito bem:

Imutabilidade do picolé: é o que chamo caprichosamente de um leve enfraquecimento da imutabilidade de escrever uma vez. Pode-se imaginar um objeto ou campo que permaneceu mutável por um certo tempo durante sua inicialização e então ficou “congelado” para sempre. Este tipo de imutabilidade é particularmente útil para objetos imutáveis ​​que se referem circularmente uns aos outros, ou objetos imutáveis ​​que foram serializados em disco e após a desserialização precisam ser "fluidos" até que todo o processo de desserialização seja feito, ponto em que todos os objetos podem ser congeladas.

Portanto, você inicializa seu objeto e, em seguida, define um sinalizador de "congelamento" de algum tipo, indicando que não é mais gravável. De preferência, você ocultaria a mutação atrás de uma função para que a função ainda fosse pura para clientes que consomem sua API.

Julieta
fonte
1
Votos negativos? Alguém quer deixar um comentário sobre por que essa não é uma boa solução?
Julieta
+1. Talvez alguém tenha votado contra isso porque você está sugerindo o uso de clone()para derivar novas instâncias.
finnw
Ou talvez fosse porque pode comprometer a segurança do thread em Java: java.sun.com/docs/books/jls/third_edition/html/memory.html#17.5
finnw
Uma desvantagem é que isso requer que futuros desenvolvedores tenham cuidado ao lidar com o sinalizador "congelar". Se um método modificador for adicionado posteriormente e se esquecer de afirmar que o método foi descongelado, pode haver problemas. Da mesma forma, se um novo construtor for escrito que deveria, mas não chama, o freeze()método, as coisas podem ficar feias.
stalepretzel
5

Você também pode fazer com que os objetos imutáveis ​​exponham métodos que se parecem com modificadores (como addSibling), mas permite que eles retornem uma nova instância. É isso que as coleções imutáveis ​​do Scala fazem.

A desvantagem é que você pode criar mais instâncias do que o necessário. Também é aplicável apenas quando existem configurações válidas intermediárias (como algum nó sem irmãos, o que está ok na maioria dos casos), a menos que você não queira lidar com objetos parcialmente construídos.

Por exemplo, uma borda de gráfico que não tem destino ainda não é uma borda de gráfico válida.

ziggystar
fonte
A alegada desvantagem - criar mais instâncias do que o necessário - não é realmente um grande problema. A alocação de objetos é muito barata, assim como a coleta de lixo de objetos de curta duração. Quando a análise de escape está ativada por padrão, esse tipo de "objetos intermediários" provavelmente será alocado na pilha e não custará literalmente nada para criar.
gustafc
2
@gustafc: Sim. Cliff Click uma vez contou a história de como eles compararam a simulação Clojure Ant Colony de Rich Hickey em uma de suas grandes caixas (864 núcleos, 768 GB de RAM): 700 threads paralelos em execução em 700 núcleos, cada um a 100%, gerando mais de 20 GB de lixo efêmero por segundo . O GC nem começou a suar.
Jörg W Mittag
5

Considere quatro possibilidades:

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");

Para mim, cada um dos 2, 3 e 4 é adaptado a uma situação diferente. O primeiro é difícil de amar, pelos motivos citados pelo OP, e geralmente é um sintoma de um design que sofreu algum deslocamento e precisa de alguma refatoração.

O que estou listando como (2) é bom quando não há estado por trás da 'fábrica', enquanto (3) é o projeto escolhido quando há estado. Pego-me usando (2) em vez de (3) quando não quero me preocupar com threads e sincronização e não preciso me preocupar em amortizar algumas configurações caras na produção de muitos objetos. (3), por outro lado, é acionado quando o trabalho real vai para a construção da fábrica (configuração de um SPI, leitura de arquivos de configuração, etc).

Por fim, a resposta de outra pessoa mencionou a opção (4), onde você tem muitos pequenos objetos imutáveis ​​e o padrão preferível é obter notícias dos antigos.

Observe que não sou membro do 'fã-clube de padrões' - claro, algumas coisas valem a pena imitar, mas me parece que eles assumem uma vida inútil quando as pessoas lhes dão nomes e chapéus engraçados.

bmargulies
fonte
6
Este é o padrão Builder (opção 2)
Simon Nickerson
Não seria um objeto fábrica ('construtor') que emite seus objetos imutáveis?
bmargulies
essa distinção parece bastante semântica. Como o feijão é feito? Como isso difere de um construtor?
Carl
Você não quer o pacote de convenções Java Bean para um construtor (ou para muito mais).
Tom Hawtin - tackline
4

Outra opção potencial é refatorar para ter menos campos configuráveis. Se grupos de campos só funcionam (principalmente) uns com os outros, reúna-os em seus próprios pequenos objetos imutáveis. Os construtores / construtores desse objeto "pequeno" devem ser mais gerenciáveis, assim como o construtor / construtor desse objeto "grande".

Carl
fonte
1
Observação: a milhagem para esta resposta pode variar com o problema, a base de código e a habilidade do desenvolvedor.
Carl
2

Eu uso C # e essas são minhas abordagens. Considerar:

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.
}

Opção 1. Construtor com parâmetros opcionais

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

Usado como, por exemplo new Foo(5, b: new Bar(whatever)). Não para versões Java ou C # anteriores a 4.0. mas ainda vale a pena mostrar, pois é um exemplo de como nem todas as soluções são agnósticas de idioma.

Opção 2. Construtor tomando um único objeto de parâmetro

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() { }
}

Exemplo de uso:

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

C # do 3.0 em diante torna isso mais elegante com a sintaxe do inicializador de objeto (semanticamente equivalente ao exemplo anterior):

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

Opção 3:
redesenhe sua classe para não precisar de um número tão grande de parâmetros. Você pode dividir suas responsabilidades em várias classes. Ou passe parâmetros não para o construtor, mas apenas para métodos específicos, sob demanda. Nem sempre viável, mas quando for, vale a pena fazer.

Joren
fonte