¿Por qué necesitamos una clase de generador al implementar un patrón de generador?

31

He visto muchas implementaciones del patrón Builder (principalmente en Java). Todos ellos tienen una clase de entidad (digamos una Personclase) y una clase de generador PersonBuilder. El constructor "apila" una variedad de campos y devuelve un new Personcon los argumentos pasados. ¿Por qué necesitamos explícitamente una clase de generador, en lugar de poner todos los métodos de generador en la Personclase misma?

Por ejemplo:

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

Simplemente puedo decir Person john = new Person().withName("John");

¿Por qué la necesidad de una PersonBuilderclase?

El único beneficio que veo es que podemos declarar los Personcampos como final, asegurando así la inmutabilidad.

Boyan Kushlev
fuente
44
Nota: El nombre normal para este patrón es chainable setters: D
Mooing Duck
44
Principio de responsabilidad única. Si pones la lógica en la clase Persona, entonces tiene más de un trabajo que hacer
reggaeguitar el
1
Su beneficio se puede obtener de cualquier manera: puedo withNamedevolver una copia de la Persona con solo el campo de nombre cambiado. En otras palabras, Person john = new Person().withName("John");podría funcionar incluso si Persones inmutable (y este es un patrón común en la programación funcional).
Brian McCutchon el
99
@MooingDuck: otro término común para ello es una interfaz fluida .
Mac
2
@ Mac Creo que la interfaz fluida es un poco más general: evitas tener voidmétodos. Entonces, por ejemplo, si Persontiene un método que imprime su nombre, aún puede encadenarlo con una interfaz fluida person.setName("Alice").sayName().setName("Bob").sayName(). Por cierto, anoto aquellos en JavaDoc con exactamente su sugerencia @return Fluent interface: es lo suficientemente genérico y claro cuando se aplica a cualquier método que lo hace return thisal final de su ejecución y es bastante claro. Por lo tanto, un generador también estará haciendo una interfaz fluida.
VLAZ

Respuestas:

27

Es para que pueda ser inmutable Y simular parámetros con nombre al mismo tiempo.

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

Eso mantiene a tus guantes alejados de la persona hasta que se establece su estado y, una vez configurado, no te permite cambiarlo, sin embargo, cada campo está claramente etiquetado. No puede hacer esto con solo una clase en Java.

Parece que estás hablando de Josh Blochs Builder Pattern . Esto no debe confundirse con el Patrón de Constructor de la Banda de los Cuatro . Estas son diferentes bestias. Ambos resuelven problemas de construcción, pero de maneras bastante diferentes.

Por supuesto, puedes construir tu objeto sin usar otra clase. Pero luego tienes que elegir. Pierde la capacidad de simular parámetros con nombre en lenguajes que no los tienen (como Java) o pierde la capacidad de permanecer inmutable durante toda la vida útil de los objetos.

Ejemplo inmutable, no tiene nombres para parámetros

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

Aquí estás construyendo todo con un solo constructor simple. Esto le permitirá permanecer inmutable, pero perderá la simulación de parámetros con nombre. Eso se vuelve difícil de leer con muchos parámetros. A las computadoras no les importa, pero es difícil para los humanos.

Ejemplo de parámetro con nombre simulado con setters tradicionales. No inmutable

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

Aquí está creando todo con setters y simulando parámetros con nombre, pero ya no es inmutable. Cada uso de un setter cambia el estado del objeto.

Entonces, lo que obtienes al agregar la clase es que puedes hacer ambas cosas.

La validación se puede realizar build()si un error de tiempo de ejecución para un campo de edad faltante es suficiente para usted. Puede actualizar eso y aplicar eso que age()se llama con un error del compilador. Simplemente no con el patrón de construcción Josh Bloch.

Para eso necesita un lenguaje interno específico de dominio (iDSL).

Esto le permite exigir que llamen age()y name()antes de llamar build(). Pero no puede hacerlo simplemente volviendo thiscada vez. Cada cosa que regresa devuelve una cosa diferente que te obliga a llamar a la siguiente.

El uso podría verse así:

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

Pero esto:

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

provoca un error del compilador porque age()solo es válido llamar al tipo devuelto por name().

Estos iDSL son extremadamente potentes ( JOOQ o Java8 Streams, por ejemplo) y son muy agradables de usar, especialmente si usa un IDE con finalización de código, pero su configuración es bastante complicada . Recomiendo guardarlos para cosas que tengan un poco de código fuente escrito en su contra.

naranja confitada
fuente
3
Y luego alguien pregunta por qué tiene que proporcionar el nombre antes de la edad, y la única respuesta es "debido a la explosión combinatoria". Estoy bastante seguro de que uno puede evitar eso en la fuente si tiene plantillas adecuadas como C ++, o si vuelve a usar un tipo diferente para todo.
Deduplicador
1
Una abstracción con fugas requiere el conocimiento de una complejidad subyacente para poder saber cómo usarla. Eso es lo que veo aquí. Cualquier clase que no sepa cómo construirse está necesariamente unida al Generador que tiene más dependencias (tal es el propósito y la naturaleza del concepto de Generador). Una vez (no en el campamento de la banda), una simple necesidad de crear una instancia de un objeto requirió 3 meses para deshacer solo un grupo de fracturas. No es broma.
radarbob
Una de las ventajas de las clases que no conocen ninguna de sus reglas de construcción es que cuando esas reglas cambian, no les importa. Solo puedo agregar un AnonymousPersonBuilderque tiene su propio conjunto de reglas.
candied_orange
El diseño de clase / sistema rara vez muestra una aplicación profunda de "maximizar la cohesión y minimizar el acoplamiento". Las legiones de diseño y tiempo de ejecución son extensibles, y los defectos se pueden corregir solo si las máximas OO y las pautas se aplican con la sensación de ser fractales, o "son tortugas, todo el camino hacia abajo".
radarbob
1
@radarbob La clase que se está construyendo aún sabe cómo construirse. Su constructor es responsable de garantizar la integridad del objeto. La clase de constructor es simplemente una ayuda para el constructor, porque el constructor a menudo toma una gran cantidad de parámetros, lo que no es una API muy agradable.
ReinstaleMonicaSackTheStaff
57

Por qué usar / proporcionar una clase de constructor:

  • Para hacer objetos inmutables: el beneficio que ya ha identificado. Útil si la construcción toma varios pasos. FWIW, la inmutabilidad debería verse como una herramienta importante en nuestra búsqueda para escribir programas mantenibles y libres de errores.
  • Si la representación en tiempo de ejecución del objeto final (posiblemente inmutable) está optimizada para lectura y / o uso de espacio, pero no para actualización. String y StringBuilder son buenos ejemplos aquí. Concatenar cadenas repetidamente no es muy eficiente, por lo que StringBuilder utiliza una representación interna diferente que es buena para agregar, pero no tan buena en el uso del espacio, y no tan buena para leer y usar como la clase de cadena regular.
  • Para separar claramente los objetos construidos de los objetos en construcción. Este enfoque requiere una transición clara de la subconstrucción a la construcción. Para el consumidor, no hay forma de confundir un objeto en construcción con un objeto construido: el sistema de tipos hará cumplir esto. Eso significa que a veces podemos usar este enfoque para "caer en el pozo del éxito", por así decirlo, y, al hacer abstracción para que otros (o nosotros mismos) lo usemos (como una API o una capa), esto puede ser muy bueno cosa.
Erik Eidt
fuente
44
Sobre el último punto de viñeta. Entonces, la idea es que a PersonBuilderno tiene captadores, y la única forma de inspeccionar los valores actuales es llamar .Build()para devolver a Person. De esta manera .Build puede validar un objeto construido correctamente, ¿correcto? ¿Es este el mecanismo destinado a evitar el uso de un objeto "en construcción"?
AaronLS
@AaronLS, sí, ciertamente; La validación compleja se puede poner en una Buildoperación para evitar objetos defectuosos y / o poco construidos
Erik Eidt
2
También puede validar su objeto dentro de su constructor, la preferencia puede ser personal o depender de la complejidad. Independientemente de lo que se elija, el punto principal es que una vez que tenga su objeto inmutable, sabrá que está construido correctamente y no tiene un valor inesperado. Un objeto inmutable con un estado incorrecto no debería suceder.
Walfrat
2
@AaronLS un constructor puede tener captadores; ese no es el punto. No es necesario, pero si un constructor tiene captadores, debe tener en cuenta que llamar getAgeal generador puede volver nullcuando esta propiedad aún no se haya especificado. Por el contrario, la Personclase puede imponer la invariante que la edad nunca puede ser null, lo que es imposible cuando el constructor y el objeto real se mezclan, como con el new Person() .withName("John")ejemplo del OP , que felizmente crea un Personsin edad. Esto se aplica incluso a clases mutables, ya que los establecedores pueden imponer invariantes, pero no pueden imponer valores iniciales.
Holger
1
@Holger sus puntos son verdaderos, pero respaldan principalmente el argumento de que un generador debe evitar los captadores.
user949300 el
21

Una razón sería asegurar que todos los datos pasados ​​sigan las reglas de negocio.

Su ejemplo no toma esto en consideración, pero digamos que alguien pasó una cadena vacía o una cadena que consiste en caracteres especiales. Desea hacer algún tipo de lógica basada en asegurarse de que su nombre sea realmente un nombre válido (que en realidad es una tarea muy difícil).

Podría poner todo eso en su clase de Persona, especialmente si la lógica es muy pequeña (por ejemplo, solo asegurándose de que una edad no sea negativa), pero a medida que la lógica crece, tiene sentido separarla.

Diácono
fuente
77
El ejemplo en la pregunta proporciona una violación simple de la regla: no se da una edad parajohn
Caleth
66
+1 Y es muy difícil hacer reglas para dos campos simultáneamente sin la clase de generador (los campos X + Y no pueden ser mayores que 10).
Reginald Blue
77
@ReginaldBlue es aún más difícil si están obligados por "x + y es par". Nuestra condición inicial con (0,0) está bien, el estado (1,1) también está bien, pero no hay una secuencia de actualizaciones de una sola variable que mantenga el estado válido y nos lleve de (0,0) a (1) 1)
Michael Anderson el
Creo que esta es la mayor ventaja. Todos los demás son agradables.
Giacomo Alzetta
5

Un pequeño ángulo diferente sobre esto de lo que veo en otras respuestas.

El withFooenfoque aquí es problemático porque se comportan como setters pero se definen de una manera que hace que parezca que la clase admite la inmutabilidad. En las clases de Java, si un método modifica una propiedad, es costumbre comenzar el método con 'set'. Nunca me ha encantado como estándar, pero si haces algo más, sorprenderá a la gente y eso no es bueno. Hay otra forma en que puede admitir la inmutabilidad con la API básica que tiene aquí. Por ejemplo:

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

No proporciona mucho en cuanto a la prevención de objetos construidos parcialmente de manera inadecuada, pero evita cambios en cualquier objeto existente. Probablemente sea una tontería para este tipo de cosas (y también lo es el JB Builder). Sí, creará más objetos, pero esto no es tan costoso como podría pensar.

En su mayoría, verá este tipo de enfoque utilizado con estructuras de datos concurrentes, como CopyOnWriteArrayList . Y esto sugiere por qué la inmutabilidad es importante. Si desea que su código sea seguro, casi siempre debe considerarse la inmutabilidad. En Java, cada subproceso puede mantener un caché local de estado variable. Para que un subproceso vea los cambios realizados en otros subprocesos, debe emplearse un bloque sincronizado u otra característica de concurrencia. Cualquiera de estos agregará algo de sobrecarga al código. Pero si sus variables son finales, no hay nada que hacer. El valor siempre será el que se inicializó y, por lo tanto, todos los subprocesos ven lo mismo sin importar qué.

JimmyJames
fuente
2

Otra razón que aún no se ha mencionado explícitamente aquí, es que el build()método puede verificar que todos los campos son 'campos que contienen valores válidos (ya sea establecidos directamente o derivados de otros valores de otros campos), que probablemente sea el modo de falla más probable eso ocurriría de otra manera.

Otro beneficio es que su Personobjeto terminaría teniendo una vida más simple y un conjunto simple de invariante. Sabes que una vez que tienes un Person p, tienes un p.namey un válido p.age. Ninguno de sus métodos tiene que estar diseñado para manejar situaciones como "bueno, ¿qué pasa si se establece la edad pero no el nombre, o qué pasa si se establece el nombre pero no la edad? Esto reduce la complejidad general de la clase.

Alexander - Restablece a Monica
fuente
"[...] puede verificar que todos los campos estén configurados [...]" El punto del patrón Builder es que no tiene que configurar todos los campos usted mismo y puede recurrir a valores predeterminados razonables. Si necesita configurar todos los campos de todos modos, ¡tal vez no debería usar ese patrón!
Vincent Savard el
@VincentSavard De hecho, pero en mi opinión, ese es el uso más común. Para validar que se establezca un conjunto de valores "básicos" y hacer un tratamiento sofisticado en torno a algunos opcionales (establecer valores predeterminados, derivar su valor de otros valores, etc.).
Alexander - Restablece a Monica el
En lugar de decir 'verificar que todos los campos estén configurados', cambiaría esto a 'verificar que todos los campos contienen valores válidos [a veces dependientes de los valores de otros campos]' (es decir, un campo solo puede permitir ciertos valores dependiendo del valor de otro campo). Esto podría hacerse en cada setter, pero es más fácil para alguien configurar el objeto sin tener excepciones hasta que el objeto esté completo y listo para ser construido.
yitzih
El constructor de la clase que se está creando es el responsable final de la validez de sus argumentos. La validación en el constructor es redundante en el mejor de los casos, y podría desincronizarse fácilmente con los requisitos del constructor.
ReinstaleMonicaSackTheStaff
@TKK De hecho. Pero validan cosas diferentes. En muchos de los casos en los que he usado Builder's, el trabajo del constructor era construir las entradas que eventualmente se le dan al constructor. Por ejemplo, el constructor se configura ya sea con un URI, una File, o una FileInputStream, y los usos lo que fuera capaz de efectuar una FileInputStreamque en última instancia va a la llamada al constructor como arg
Alexander - Restablecer Mónica
2

También se puede definir un generador para devolver una interfaz o una clase abstracta. Puede usar el constructor para definir el objeto, y el constructor puede determinar qué subclase concreta devolver en función de qué propiedades están establecidas o en qué están configuradas, por ejemplo.

Ed Marty
fuente
2

El patrón de construcción se usa para construir / crear el objeto paso a paso configurando las propiedades y cuando se configuran todos los campos requeridos, devuelve el objeto final utilizando el método de construcción. El objeto recién creado es inmutable. El punto principal a tener en cuenta aquí es que el objeto solo se devuelve cuando se invoca el método de compilación final. Esto asegura que todas las propiedades se establecen en el objeto y, por lo tanto, el objeto no está en estado inconsistente cuando la clase de constructor lo devuelve.

Si no usamos la clase de constructor y colocamos directamente todos los métodos de la clase de constructor en la clase Persona en sí, primero tenemos que crear el Objeto y luego invocar los métodos de establecimiento en el objeto creado, lo que conducirá al estado inconsistente del objeto entre la creación. de objeto y establecer las propiedades.

Por lo tanto, al usar la clase de constructor (es decir, alguna entidad externa que no sea la clase Person), nos aseguramos de que el objeto nunca estará en un estado inconsistente.

Shubham Bondarde
fuente
@DawidO tienes mi punto. El énfasis principal que estoy tratando de poner aquí está en el estado inconsistente. Y esa es una de las principales ventajas de usar el patrón Builder.
Shubham Bondarde
2

Reutilizar objeto constructor

Como otros han mencionado, la inmutabilidad y la verificación de la lógica empresarial de todos los campos para validar el objeto son las principales razones para un objeto generador separado.

Sin embargo, la reutilización es otro beneficio. Si quiero crear una instancia de muchos objetos que son muy similares, puedo hacer pequeños cambios en el objeto generador y continuar creando instancias. No es necesario volver a crear el objeto generador. Esta reutilización permite al constructor actuar como plantilla para crear muchos objetos inmutables. Es un pequeño beneficio, pero podría ser útil.

yitzih
fuente
1

En realidad, puede tener los métodos de construcción en su propia clase y aún así tener inmutabilidad. Simplemente significa que los métodos del generador devolverán nuevos objetos, en lugar de modificar el existente.

Esto solo funciona si hay una manera de obtener un objeto inicial (válido / útil) (por ejemplo, de un constructor que establece todos los campos requeridos, o un método de fábrica que establece los valores predeterminados), y los métodos de construcción adicionales devuelven objetos modificados basados en el existente. Esos métodos de creación deben asegurarse de no obtener objetos inválidos / inconsistentes en el camino.

Por supuesto, esto significa que tendrá muchos objetos nuevos, y no debe hacerlo si sus objetos son caros de crear.

Lo utilicé en el código de prueba para crear emparejadores de Hamcrest para uno de mis objetos comerciales. No recuerdo el código exacto, pero se parecía a esto (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);
    }
}

Luego lo usaría así en mis pruebas unitarias (con importaciones estáticas adecuadas):

assertThat(result, is(customer().withName(startsWith("Paŭlo"))));
Paŭlo Ebermann
fuente
1
Esto es cierto, y creo que es un punto muy importante, pero no resuelve el último problema. No le da la oportunidad de validar que el objeto está en un estado consistente cuando se construye. Una vez construido, necesitará algunos trucos como llamar a un validador antes de cualquiera de sus métodos comerciales para asegurarse de que la persona que llama establezca todos los métodos (lo cual sería una práctica terrible). Creo que es bueno razonar a través de este tipo de cosas para ver por qué usamos el patrón Builder de la manera en que lo hacemos.
Bill K
@BillK true: un objeto con, esencialmente, setters inmutables (que devuelven un objeto nuevo cada vez) significa que cada objeto devuelto debe ser válido. Si devuelve objetos no válidos (por ejemplo, persona con namepero no age), entonces el concepto no funciona. Bueno, en realidad podrías estar devolviendo algo así como PartiallyBuiltPersonque no es válido, pero parece un truco para enmascarar al Constructor.
VLAZ
@BillK esto es lo que quise decir con "si hay una manera de obtener un objeto inicial (válido / útil)" : supongo que tanto el objeto inicial como el objeto devuelto por cada función de constructor son válidos / consistentes. Su tarea como creador de dicha clase es proporcionar solo métodos de creación que realicen esos cambios consistentes.
Paŭlo Ebermann