Por que precisamos de uma classe Builder ao implementar um padrão Builder?


31

Eu já vi muitas implementações do padrão Builder (principalmente em Java). Todos eles têm uma classe de entidade (digamos, uma Personclasse) e uma classe de construtor PersonBuilder. O construtor "empilha" uma variedade de campos e retorna a new Personcom os argumentos passados. Por que precisamos explicitamente de uma classe de construtor, em vez de colocar todos os métodos de construtor na Personprópria classe?

Por exemplo:

class Person {

  private String name;
  private Integer age;

  public Person() {
  }

  Person withName(String name) {
    this.name = name;
    return this;
  }

  Person withAge(int age) {
    this.age = age;
    return this;
  }
}

Eu posso simplesmente dizer Person john = new Person().withName("John");

Por que a necessidade de uma PersonBuilderaula?

O único benefício que vejo é que podemos declarar os Personcampos como final, garantindo assim a imutabilidade.


4
Nota: O nome normal para esse padrão é chainable setters: D
Mooing Duck

4
Princípio da responsabilidade única. Se você colocar a lógica na classe Pessoa, então ele tem mais do que um trabalho a fazer
reggaeguitar

1
Seu benefício pode ser obtido de qualquer maneira: posso withNamedevolver uma cópia da Pessoa com apenas o campo de nome alterado. Em outras palavras, Person john = new Person().withName("John");poderia funcionar mesmo que Personseja imutável (e esse é um padrão comum na programação funcional).
Brian McCutchon

9
@MooingDuck: outro termo comum para isso é uma interface fluente .
Mac

2
@ Mac Eu acho que a interface fluente é um pouco mais geral - você evita ter voidmétodos. Portanto, por exemplo, se Personhouver um método que imprima o nome deles, você ainda poderá encadear uma interface fluente person.setName("Alice").sayName().setName("Bob").sayName(). A propósito, eu anoto aqueles no JavaDoc exatamente com a sua sugestão @return Fluent interface- é genérico e claro o suficiente quando se aplica a qualquer método que faça return thisno final de sua execução e é bastante claro. Portanto, um construtor também fará uma interface fluente.
VLAZ

Respostas:


27

É para que você possa ser imutável E simular parâmetros nomeados ao mesmo tempo.

Person p = personBuilder
    .name("Arthur Dent")
    .age(42)
    .build()
;

Isso mantém suas luvas longe da pessoa até que seu estado seja definido e, uma vez definido, não permitirá que você o altere, mas todos os campos são claramente rotulados. Você não pode fazer isso com apenas uma classe em Java.

Parece que você está falando sobre Josh Blochs Builder Pattern . Isso não deve ser confundido com o padrão do grupo dos quatro construtores . Estes são animais diferentes. Ambos resolvem problemas de construção, mas de maneiras bastante diferentes.

Claro que você pode construir seu objeto sem usar outra classe. Mas então você tem que escolher. Você perde a capacidade de simular parâmetros nomeados em linguagens que não os possuem (como Java) ou a capacidade de permanecer imutável durante toda a vida útil dos objetos.

Exemplo imutável, não tem nomes para parâmetros

Person p = new Person("Arthur Dent", 42);

Aqui você está construindo tudo com um único construtor simples. Isso permitirá que você permaneça imutável, mas você perde a simulação dos parâmetros nomeados. Isso fica difícil de ler com muitos parâmetros. Os computadores não se importam, mas é difícil para os humanos.

Exemplo de parâmetro nomeado simulado com setters tradicionais. Não é imutável.

Person p = new Person();
p.name("Arthur Dent");
p.age(42);

Aqui você cria tudo com setters e simula parâmetros nomeados, mas não é mais imutável. Cada uso de um setter altera o estado do objeto.

Então, o que você obtém adicionando a classe é que você pode fazer as duas coisas.

A validação pode ser realizada build()se um erro de tempo de execução para um campo de idade ausente for suficiente para você. Você pode atualizar isso e aplicar isso age()chamado com um erro do compilador. Apenas não com o padrão do construtor Josh Bloch.

Para isso, você precisa de uma linguagem específica de domínio interno (iDSL).

Isso permite exigir que eles liguem age()e name()antes de ligar build(). Mas você não pode fazer isso apenas retornando a thiscada vez. Cada coisa que retorna retorna uma coisa diferente que força você a chamar a próxima coisa.

O uso pode ficar assim:

Person p = personBuilder
    .name("Arthur Dent")
    .age(42)
    .build()
;

Mas isso:

Person p = personBuilder
    .age(42)
    .build()
;

causa um erro do compilador porque age()só é válido chamar o tipo retornado por name().

Esses iDSLs são extremamente poderosos ( JOOQ ou Java8 Streams, por exemplo) e são muito bons de usar, especialmente se você usar um IDE com conclusão de código, mas eles são um bom trabalho de configuração. Eu recomendo salvá-los para coisas que terão um bom código-fonte escrito contra eles.


3
E então alguém pergunta por que você tem de fornecer o nome antes a idade, ea única resposta é "porque explosão combinatória". Tenho certeza de que alguém pode evitar isso na fonte se tiver modelos adequados como C ++ ou voltar a usar um tipo diferente para tudo.
Deduplicator

1
Uma abstração com vazamento requer conhecimento de uma complexidade subjacente para poder saber como usá-la. É o que eu vejo aqui. Qualquer classe que não saiba como se construir é necessariamente acoplada ao Construtor que possui dependências adicionais (como é o objetivo e a natureza do conceito do Construtor). Uma vez (não no acampamento da banda), uma simples necessidade de instanciar um objeto exigia 3 meses para desfazer exatamente esse frack de cluster. Eu não estou brincando com você.
Radarbob # 23/18

Uma das vantagens das classes que não conhecem nenhuma das regras de construção é que, quando essas regras mudam, elas não se importam. Posso apenas adicionar um AnonymousPersonBuilderque tenha seu próprio conjunto de regras.
Candied_orange # 23/18

O projeto de classe / sistema raramente mostra uma aplicação profunda de "maximizar a coesão e minimizar o acoplamento". Legiões de design e tempo de execução são extensíveis e falhas corrigíveis apenas se as máximas e diretrizes de OO forem aplicadas com a sensação de serem fractal, ou "são tartarugas, até o fim".
Radarbob # 23/18

1
@radarbob A classe que está sendo construída ainda sabe como se construir. Seu construtor é responsável por garantir a integridade do objeto. A classe do construtor é apenas um auxiliar para o construtor, porque o construtor geralmente usa um grande número de parâmetros, o que não resulta em uma API muito agradável.
No U

57

Por que usar / fornecer uma classe de construtor:

  • Para criar objetos imutáveis ​​- o benefício que você já identificou. Útil se a construção executar várias etapas. FWIW, a imutabilidade deve ser vista como uma ferramenta significativa em nossa busca para escrever programas de manutenção e sem erros.
  • Se a representação em tempo de execução do objeto final (possivelmente imutável) for otimizada para leitura e / ou uso de espaço, mas não para atualização. String e StringBuilder são bons exemplos aqui. Concatenar seqüências repetidamente não é muito eficiente; portanto, o StringBuilder usa uma representação interna diferente que é boa para anexar - mas não tão boa no uso de espaço, nem tão boa para ler e usar quanto a classe String regular.
  • Separar claramente objetos construídos de objetos em construção. Essa abordagem requer uma transição clara de sub-construção para construída. Para o consumidor, não há como confundir um objeto em construção com um objeto construído: o sistema de tipos aplicará isso. Isso significa que, às vezes, podemos usar essa abordagem para "cair no poço do sucesso", por assim dizer, e, ao fazer abstrações para que outras pessoas (ou nós mesmos) usem (como uma API ou uma camada), isso pode ser muito bom coisa.

4
Em relação ao último marcador. Portanto, a ideia é que a PersonBuildernão tem getters, e a única maneira de inspecionar os valores atuais é chamar .Build()para retornar a Person. Dessa forma, o .Build pode validar um objeto corretamente construído, correto? Ou seja, o mecanismo destinado a impedir o uso de um objeto "em construção"?
AaronLS

@AaronLS, sim, certamente; validação complexa pode ser colocada em uma Buildoperação para evitar objetos ruins e / ou subconstruídos.
Erik Eidt

2
Você também pode validar seu objeto dentro do construtor, a preferência pode ser pessoal ou depende da complexidade. O que quer que seja escolhido, o ponto principal é que, depois de ter seu objeto imutável, você sabe que ele é construído corretamente e não tem valor inesperado. Um objeto imutável com um estado incorreto não deve acontecer.
Walfrat 23/10/19

2
@AaronLS um construtor pode ter getters; essa não é a questão. Não é necessário, mas se um construtor tiver getters, você deve estar ciente de que a chamada getAgeao construtor poderá retornar nullquando essa propriedade ainda não tiver sido especificada. Por outro lado, a Personclasse pode impor o invariante de que a idade nunca pode ser null, o que é impossível quando o construtor e o objeto real são misturados, como no new Person() .withName("John")exemplo do OP , que cria felizmente um Personsem idade. Isso se aplica mesmo a classes mutáveis, pois os setters podem impor invariantes, mas não podem impor valores iniciais.
Holger

1
@Holger seus pontos são verdadeiros, mas principalmente apoiam o argumento de que um Construtor deve evitar getters.
user949300

21

Um motivo seria garantir que todos os dados passados ​​sigam as regras de negócios.

Seu exemplo não leva isso em consideração, mas digamos que alguém tenha passado uma string vazia ou uma string composta por caracteres especiais. Você gostaria de fazer algum tipo de lógica baseada em garantir que o nome deles seja realmente um nome válido (que é realmente uma tarefa muito difícil).

Você pode colocar tudo isso na sua classe Person, especialmente se a lógica for muito pequena (por exemplo, apenas garantir que uma idade não seja negativa), mas à medida que a lógica cresce, faz sentido separá-la.


7
O exemplo na questão fornece uma simples violação de regra: não existe uma idade determinada parajohn
Caleth

6
+1 E é muito difícil fazer regras para dois campos simultaneamente sem a classe do construtor (os campos X + Y não podem ser maiores que 10).
Reginald Blue

7
@ReginaldBlue é ainda mais difícil se eles estão vinculados por "x + y é par". Nossa condição inicial com (0,0) está OK. O estado (1,1) também está OK, mas não há uma sequência de atualizações de variáveis ​​únicas que mantenham o estado válido e nos levem de (0,0) a (1). 1).
Michael Anderson

Eu acredito que esta é a maior vantagem. Todos os outros são bons.
Giacomo Alzetta 24/10

5

Um ângulo um pouco diferente do que vejo em outras respostas.

A withFooabordagem aqui é problemática porque eles se comportam como setters, mas são definidos de uma maneira que faz parecer que a classe suporta a imutabilidade. Nas classes Java, se um método modifica uma propriedade, é habitual iniciar o método com 'set'. Eu nunca amei isso como um padrão, mas se você fizer outra coisa, vai surpreender as pessoas e isso não é bom. Há outra maneira de oferecer suporte à imutabilidade com a API básica que você tem aqui. Por exemplo:

class Person {
  private final String name;
  private final Integer age;

  private Person(String name, String age) {
    this.name = name;
    this.age = age;
  }  

  public Person() {
    this.name = null;
    this.age = null;
  }

  Person withName(String name) {
    return new Person(name, this.age);
  }

  Person withAge(int age) {
    return new Person(this.name, age);
  }
}

Ele não fornece muita maneira de impedir objetos inadequadamente construídos parcialmente, mas evita alterações em qualquer objeto existente. Provavelmente é bobagem para esse tipo de coisa (e o JB Builder também). Sim, você criará mais objetos, mas isso não é tão caro quanto você imagina.

Você verá principalmente esse tipo de abordagem usada com estruturas de dados simultâneas, como CopyOnWriteArrayList . E isso sugere por que a imutabilidade é importante. Se você deseja tornar seu código seguro, a imutabilidade quase sempre deve ser considerada. Em Java, cada encadeamento tem permissão para manter um cache local de estado variável. Para que um thread veja as alterações feitas em outros threads, um bloco sincronizado ou outro recurso de simultaneidade precisa ser empregado. Qualquer um desses itens adicionará alguma sobrecarga ao código. Mas se suas variáveis ​​são finais, não há nada a fazer. O valor sempre será o que foi inicializado e, portanto, todos os threads verão a mesma coisa, não importa o quê.


2

Outro motivo que ainda não foi explicitamente mencionado aqui é que o build()método pode verificar se todos os campos são 'contêm valores válidos (definidos diretamente ou derivados de outros valores de outros campos), que provavelmente é o modo de falha mais provável isso ocorreria de outra forma.

Outro benefício é que seu Personobjeto acabaria tendo uma vida útil mais simples e um conjunto simples de invariantes. Você sabe que depois de ter um Person p, você tem um p.namee um válido p.age. Nenhum dos seus métodos precisa ser projetado para lidar com situações como "bem, se a idade for definida, mas não o nome, ou se o nome for definido, mas não a idade?" Isso reduz a complexidade geral da classe.


"[...] pode verificar se todos os campos estão definidos [...]" O ponto do padrão Builder é que você não precisa definir todos os campos e pode voltar a padrões sensíveis. Se você precisar definir todos os campos de qualquer maneira, talvez não deva usar esse padrão!
Vincent Savard 22/10

@VincentSavard De fato, mas na minha opinião, esse é o uso mais comum. Para validar se algum conjunto "básico" de valores está definido e fazer um tratamento sofisticado em torno de outros opcionais (definir padrões, derivar seu valor de outros valores etc.).
Alexander - Restabelece Monica

Em vez de dizer 'verificar se todos os campos estão definidos', eu mudaria para 'verificar se todos os campos contêm valores válidos [às vezes dependem dos valores de outros campos]' (ou seja, um campo pode permitir apenas determinados valores, dependendo do valor de outro campo). Isso pode ser feito em cada levantador, mas é mais fácil para alguém configurar o objeto sem ter exceções lançadas até que o objeto esteja completo e pronto para ser construído.
yitzih

O construtor da classe que está sendo construída é, em última instância, responsável pela validade de seus argumentos. A validação no construtor é redundante, na melhor das hipóteses, e pode facilmente ficar fora de sincronia com os requisitos do construtor.
No U

@TKK De fato. Mas eles validam coisas diferentes. Em muitos dos casos em que usei o Builder's, o trabalho do construtor era construir as entradas que eventualmente são dadas ao construtor. Por exemplo, o construtor é configurado com um URI, um File, ou uma FileInputStream, e usos o que estava previsto para obter uma FileInputStreamque, finalmente, vai para a chamada do construtor como um argumento
Alexander - Reintegrar Monica

2

Um construtor também pode ser definido para retornar uma interface ou uma classe abstrata. Você pode usar o construtor para definir o objeto, e o construtor pode determinar qual subclasse concreta retornar com base em quais propriedades estão definidas ou em que estão definidas, por exemplo.


2

O padrão Builder é usado para construir / criar o objeto passo a passo , definindo as propriedades e, quando todos os campos obrigatórios estiverem definidos, retorne o objeto final usando o método build. O objeto recém-criado é imutável. O ponto principal a ser observado aqui é que o objeto só é retornado quando o método de construção final é chamado. Isso garante que todas as propriedades sejam configuradas para o objeto e, portanto, o objeto não estará em estado inconsistente quando retornado pela classe do construtor.

Se não usarmos a classe construtor e colocarmos diretamente todos os métodos da classe construtor na classe Person, primeiro precisamos criar o Object e, em seguida, chamar os métodos setter no objeto criado, o que levará ao estado inconsistente do objeto entre a criação. do objeto e definindo as propriedades.

Assim, usando a classe builder (ou seja, alguma entidade externa que não seja a própria classe Person), garantimos que o objeto nunca estará no estado inconsistente.


@DawidO você entendeu meu ponto. A principal ênfase que estou tentando colocar aqui é no estado inconsistente. E essa é uma das principais vantagens do uso do padrão Builder.
Shubham Bondarde 24/10/19

2

Reutilize o objeto construtor

Como outros já mencionaram, a imutabilidade e a verificação da lógica de negócios de todos os campos para validar o objeto são os principais motivos para um objeto construtor separado.

No entanto, a reutilização é outro benefício. Se eu quiser instanciar muitos objetos muito semelhantes, posso fazer pequenas alterações no objeto do construtor e continuar instanciando. Não há necessidade de recriar o objeto construtor. Essa reutilização permite que o construtor atue como um modelo para criar muitos objetos imutáveis. É um pequeno benefício, mas poderia ser útil.


1

Na verdade, você pode ter os métodos do construtor em sua própria classe e ainda ter imutabilidade. Significa apenas que os métodos do construtor retornarão novos objetos, em vez de modificar o existente.

Isso funciona apenas se houver uma maneira de obter um objeto inicial (válido / útil) (por exemplo, de um construtor que defina todos os campos obrigatórios ou um método de fábrica que defina valores padrão), e os métodos adicionais do construtor retornem objetos modificados com base no existente. Esses métodos do construtor precisam garantir que você não receba objetos inválidos / inconsistentes no caminho.

Obviamente, isso significa que você terá muitos objetos novos e não deve fazer isso se seus objetos forem caros para criar.

Eu usei isso no código de teste para criar correspondências Hamcrest para um dos meus objetos de negócios. Não me lembro do código exato, mas parecia algo assim (simplificado):

public class CustomerMatcher extends TypeSafeMatcher<Customer> {
    private final Matcher<? super String> nameMatcher;
    private final Matcher<? super LocalDate> birthdayMatcher;

    @Override
    protected boolean matchesSafely(Customer c) {
        return nameMatcher.matches(c.getName()) &&
               birthdayMatcher.matches(c.getBirthday());
    }

    private CustomerMatcher(Matcher<? super String> nameMatcher,
                            Matcher<? super LocalDate> birthdayMatcher) {
        this.nameMatcher = nameMatcher;
        this.birthdayMatcher = birthdayMatcher;
    }

    // builder methods from here on

    public static CustomerMatcher isCustomer() {
        // I could return a static instance here instead
        return new CustomerMatcher(Matchers.anything(), Matchers.anything());
    }

    public CustomerMatcher withBirthday(Matcher<? super LocalDate> birthdayMatcher) {
        return new CustomerMatcher(this.nameMatcher, birthdayMatcher);
    }

    public CustomerMatcher withName(Matcher<? super String> nameMatcher) {
        return new CustomerMatcher(nameMatcher, this.birthdayMatcher);
    }
}

Eu o usaria assim nos meus testes de unidade (com importações estáticas adequadas):

assertThat(result, is(customer().withName(startsWith("Paŭlo"))));

1
Isso é verdade - e acho que é um ponto muito importante, mas não resolve o último problema - não permite validar que o objeto está em um estado consistente quando é construído. Uma vez construído, você precisará de alguns truques, como chamar um validador antes de qualquer um dos seus métodos de negócios, para garantir que o chamador defina todos os métodos (o que seria uma prática terrível). Eu acho que é bom argumentar sobre esse tipo de coisa para ver por que usamos o padrão Builder da maneira que fazemos.
Bill K

@BillK true - um objeto com setters essencialmente imutáveis ​​(que retornam um novo objeto toda vez) significa que cada objeto retornado deve ser válido. Se você estiver retornando objetos inválidos (por exemplo, pessoa com namemas não age), o conceito não funcionará. Bem, você pode realmente estar retornando algo como PartiallyBuiltPersonenquanto não é válido, mas parece um hack para mascarar o Builder.
VLAZ 24/1018

@ BillK, é o que eu quis dizer com "se existe uma maneira de obter um objeto inicial (válido / útil)" - presumo que o objeto inicial e o objeto retornado de cada função do construtor sejam válidos / consistentes. Sua tarefa como criador dessa classe é fornecer apenas métodos de construtor que realizem essas alterações consistentes.
Paŭlo Ebermann 24/10
Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.