
Compartir:
Sina es promotora de desarrollo Java en Vonage. Procede del mundo académico y, en general, siente curiosidad por todo lo relacionado con los coches, los ordenadores, la programación, la tecnología y la naturaleza humana. En su tiempo libre, se le puede encontrar paseando o jugando a videojuegos de competición.
Patrón constructor con herencia en Java
Tiempo de lectura: 6 minutos
Este artículo se actualizó en abril de 2025
Introducción
La forma típica de instanciar objetos es a través de un constructor, pasando directamente los parámetros necesarios. Para abreviar, podemos agrupar los parámetros relacionados en una clase y construir una instancia de esa clase por separado, pasándosela al objeto principal como parámetro.
Esto tiene la ventaja de reducir el número de parámetros necesarios para la construcción de objetos al dividirla en varios pasos. Sin embargo, el uso de un constructor tiene varias desventajas. Por un lado, el orden en que se pasan los parámetros es significativo, lo que puede ser desalentador cuando hay muchos parámetros, y aún más propenso a errores cuando estos parámetros son del mismo tipo (por ejemplo, String).
También puede haber parámetros opcionales. Aunque podemos solucionar esto teniendo múltiples constructores con diferentes firmas, esto añade mucha burocracia y carga cognitiva para el usuario y el desarrollador. También se vuelve inviable cuando varios parámetros opcionales son del mismo tipo.
El patrón Constructor es un patrón de diseño muy conocido en los lenguajes orientados a objetos para controlar la construcción de objetos. Como muchos (quizás la mayoría) de los patrones de diseño, existe para solucionar una deficiencia del lenguaje. En lenguajes con parámetros con nombre (como Kotlin, Scala, Python, C#, Ruby y muchos otros), la necesidad de constructores disminuye en muchos casos.
Utilizar el patrón constructor es una recomendación en Java Eficaz, pero para lenguajes con argumentos con nombre, su valor se vuelve cuestionable (véase, por ejemplo este artículo que explora su utilidad en Kotlin).
Discutir hasta qué punto los parámetros opcionales dejan obsoleto el patrón constructor está fuera del alcance de este artículo. En su lugar, el propósito es explicar los fundamentos de lo que parece ser una complicada aplicación del patrón constructor en la recientemente publicada implementación de Messages API en el SDK Java de Vonage.
MessageRequest Clases
Para modelar los distintos tipos de mensajes que pueden enviarse a través de la Messages APIse utiliza un enfoque orientado a objetos, en el que se crea una clase para cada combinación válida de tipo de mensaje y servicio. Existe una jerarquía de herencia de tres niveles. Tomemos el siguiente ejemplo:
MessageRequestMmsRequestMmsVcardRequest
MessageRequest y MmsRequest son clases abstractas, y MmsVcardRequest es la clase que representa la combinación de envío de una vCard por MMS.
La clase base MessageRequest toma como argumentos el canal y el tipo de mensaje en su constructor, que son establecidos por las subclases. También toma un Builder como parámetro, que es donde se establecen los detalles principales del mensaje. Algunos parámetros son opcionales, como clientRefy todos los mensajes tienen un remitente y un destinatario, que se declaran en la clase base MessageRequest.
Sin embargo, esta jerarquía de herencia es transparente para el usuario cuando construye un archivo MmsVcardRequestque tiene este aspecto:
MmsVcardRequest message = MmsVcardRequest.builder()
.from("447900090000").to("447900090001")
.url("https://www.example.com/contact.vcf")
.clientRef("vCard-msg-#1")
.build();Los ejemplos completos están disponibles en repositorio de muestras de código.
Esto parece algo elegante desde la perspectiva del usuario, dada la ausencia de parámetros de método/constructor con nombre y opcionales en Java. Entre bastidores, sin embargo, hacer posible el código anterior con una jerarquía de herencia requiere un diseño que no es inmediatamente obvio o intuitivo desde la perspectiva del desarrollador del SDK.
Las complicaciones están relacionadas con las clases Builder anidadas asociadas a cada subclase de MessageRequest. Veamos MmsRequest como ejemplo. Sigue siendo abstracto, ya que sólo estamos definiendo la clase Channel.
Pero ¿qué pasa con esa intimidante Builder intimidante? El resto de este artículo intentará ayudarle a entenderlo:
protected abstract static class Builder<M extends MmsRequest, B extends Builder<? extends M, ? extends B>> extends MessageRequest.Builder<M, B> En<M> Parámetro
La clase Builder base en MessageRequest toma como parámetros el tipo de MessageRequest a construir, y el tipo de Builder.
La primera (M) es fácil de explicar: public abstract M build() en MessageRequest.Builder es lo que el usuario llama una vez que ha terminado de configurar los parámetros, devolviéndoles la subclase concreta MessageRequest concreta.
Por supuesto, esto podría omitirse ya que la herencia Java soporta tipos de retorno covariantes. Es decir, podríamos omitir M si quisiéramos y conseguir el mismo resultado desde la perspectiva del usuario. Entonces, MessageRequest.Builder se convierte en lo siguiente:
public abstract static class Builder<B extends Builder<? extends B>> {
protected String from, to, clientRef;
public B from(String from) {
this.from = from;
return (B) this;
}
public B to(String to) {
this.to = to;
return (B) this;
}
public B clientRef(String clientRef) {
this.clientRef = clientRef;
return (B) this;
}
public abstract MessageRequest build();
}**
Entonces tenemos que recordar anular esto en subclases, idealmente incluso en clases abstractas como MmsRequest.Builderasí:
protected abstract static class Builder<B extends Builder<? extends B>> extends MessageRequest.Builder<B> {
String url;
protected B url(String url) {
this.url = url;
return (B) this;
}
@Override
public abstract MmsRequest build();
}La clase concreta MmsVcardRequest.Builder tendría el mismo aspecto ya que es donde declaramos el tipo:
public static final class Builder extends MmsRequest.Builder<Builder> {
Builder() {}
public Builder url(String url) {
return super.url(url);
}
@Override
public MmsVcardRequest build() {
return new MmsVcardRequest(this);
}
}Entonces, ¿por qué añadimos este parámetro aparentemente redundante si podemos utilizar tipos de retorno covariantes? Simplemente para asegurarnos de que no olvidamos anular el tipo de retorno.
Al generar el tipo de retorno, el compilador se asegura de que el método build() tiene la firma correcta. En ausencia de esto, la siguiente versión de MmsVcardRequest.Builder también sería válida, pero inexacta:
public static final class Builder extends MmsRequest.Builder<Builder> {
Builder() {}
public Builder url(String url) {
return super.url(url);
}
@Override
public MmsRequest build() {
return new MmsVcardRequest(this);
}
}Como nos hemos acordado de anular el tipo de retorno de MmsRequest.Builder#build()el compilador detectará lo siguiente build() firma:
@Override
public MessageRequest build() {
return new MmsVcardRequest(this);
}con mensaje de error:
'build()' in 'com.vonage.client.messages.mms.MmsVcardRequest.Builder' clashes with 'build()' in 'com.vonage.client.messages.mms.MmsRequest.Builder'; attempting to use incompatible return type.
Esto es sólo porque nos acordamos de anular manualmente la firma del método en MmsRequest.Builder. Si no lo hubiéramos hecho, no habría ningún error. Al parametrizar el tipo de retorno, nos vemos obligados a declarar el tipo correcto, y no necesitamos sobrescribir el método build() en subclases de MessageRequest - el compilador se encarga de ello por nosotros.
En<B> Parámetro
Volvamos al último parámetro Constructor -. B. Si estás familiarizado con el patrón constructor, sabrás que cada llamada a un método sobre el constructor devuelve el propio constructor, de forma que puedes encadenar llamadas a métodos para establecer parámetros fácilmente.
Esto funciona bien cuando no hay herencia, pero queremos que el usuario pueda establecer los parámetros en cualquier orden - después de todo, ¿no es esa una de las principales razones para utilizar el patrón constructor? Por lo tanto, debemos asegurarnos de que se devuelve la clase constructora concreta más específica, independientemente de los métodos que se invoquen en primer lugar.
De lo contrario, perderíamos la capacidad de encadenar llamadas a métodos y tendríamos que recurrir a castear el valor de retorno cada vez - lo que arruina la fluidez que estamos tratando de lograr mediante el uso de un constructor.
Para que esto quede más claro, consideremos el caso en el que devolvemos ingenuamente el constructor actual:
public abstract static class Builder<M extends MessageRequest> {
protected String from, to clientRef;
public Builder<M> from(String from) {
this.from = from;
return this;
}
public Builder<M> to(String to) {
this.to = to;
return this;
}
public Builder<M> clientRef(String clientRef) {
this.clientRef = clientRef;
return this;
}
public abstract M build();
}Esto compila bien y es utilizable. Entonces MmsRequestse convierte en lo siguiente:
protected abstract static class Builder<M extends MmsRequest> extends MessageRequest.Builder<M> {
String url;
protected Builder<M> url(String url) {
this.url = url;
return this;
}
}Finalmente, la subclase concreta (siguiendo con el MmsVcardRequest ejemplo) es la siguiente:
public static final class Builder extends MmsRequest.Builder<MmsVcardRequest> {
Builder() {}
/**
* (REQUIRED)
* Sets the URL of the vCard attachment. Supports only <code>.vcf</code> file extension.
*
* @param url The URL as a string.
* @return This builder.
*/
public Builder url(String url) {
return (Builder) super.url(url);
}
@Override
public MmsVcardRequest build() {
return new MmsVcardRequest(this);
}
}Observe que hemos tenido que asignar el tipo de retorno de super.url(url) a este Builder, ya que el tipo de retorno del supermétodo es com.vonage.messages.mms.MmsRequest.Buildery no com.vonage.messages.mms.MmsVcardRequest.Builder.
Nótese que sólo hemos sobreescrito este método para añadirle Javadocs, no para cambiar su funcionalidad. Pero esto resalta perfectamente el problema que el tipo Builder parametrizado intenta resolver. Para ilustrarlo, intentemos utilizar este constructor:
MmsVcardRequest message = MmsVcardRequest.builder()
.from("447900090000").to("447900090001")
.url("https://www.example.com/path/to/contact.vcf")
.build();El compilador da un error: Cannot resolve method 'url' in 'Builder'. En cambio, funciona lo siguiente:
MmsVcardRequest message = MmsVcardRequest.builder()
.url("https://www.example.com/path/to/contact.vcf")
.from("447900090000").to("447900090001")
.build();¿Qué pasa? Es exactamente la misma información, pero los métodos son llamados en un orden diferente. ¿No es el objetivo de un constructor permitir flexibilidad en el orden en que se llaman los métodos? Para una buena experiencia de usuario, es necesario tener en cuenta estas situaciones.
El usuario no debería tener que preocuparse de qué clases contribuyen con qué propiedades - estos son detalles internos de implementación. Una solución verbosa es sobrescribir cada método en la subclase concreta de MessageRequest.Builder. Por ejemplo, en MmsVcardRequesttendríamos:
public static final class Builder extends MmsRequest.Builder<MmsVcardRequest> {
Builder() {}
public Builder from(String from) {
return (Builder) super.from(from);
}
public Builder to(String to) {
return (Builder) super.to(to);
}
public Builder clientRef(String clientRef) {
return (Builder) super.clientRef(clientRef);
}
public Builder url(String url) {
return (Builder) super.url(url);
}
@Override
public MmsVcardRequest build() {
return new MmsVcardRequest(this);
}
}Pero esto contradice el objetivo de la herencia, ya que estamos repitiendo información. Por eso parametrizamos el tipo constructor. El compilador se asegura de que siempre se devuelva el subtipo más concreto.
Sin embargo, como nuestras clases constructoras pueden ampliarse, necesitamos codificar esto también en las declaraciones de parámetros, de ahí el bound B extends Builder<? extends M, ? extends B> en lugar de B extends Builder<M, B>.
Desafortunadamente, todavía tenemos que convertir el tipo de retorno del constructor a B cada vez que llamemos a return thispero, por lo que sé, es una limitación del compilador. Afortunadamente el casting sólo tiene que ocurrir en las clases abstractas Builder, no en los tipos concretos.
Conclusión
Espero que este artículo te haya enseñado un patrón algo útil (aunque quizás aparentemente enrevesado) para utilizar el patrón Constructor cuando hay clases abstractas y herencia de por medio. Quizás algún día, estos patrones queden obsoletos cuando el lenguaje añada mejores formas de instanciar objetos. Hasta entonces, al menos tenemos los genéricos para ayudarnos, ¡a pesar de lo desalentador que puede ser trabajar con ellos a veces!
La participación de la comunidad es siempre bienvenida. No dudes en unirte a nosotros en el Slack de la comunidad de Vonage o envíanos un mensaje en Twitter. Si tienes alguna sugerencia de mejora o detectas un error, no dudes en plantear un problema en GitHub.
Compartir:
Sina es promotora de desarrollo Java en Vonage. Procede del mundo académico y, en general, siente curiosidad por todo lo relacionado con los coches, los ordenadores, la programación, la tecnología y la naturaleza humana. En su tiempo libre, se le puede encontrar paseando o jugando a videojuegos de competición.