Factorías e invoke: constructores que no son constructores
Constructores privados con fábricas en el compañero para validar, cachear o devolver subtipos, y el operador invoke que hace que una llamada parezca una construcción. Qué gana el diseño de API y qué pierde la honestidad del sitio de llamada.
Un constructor es la operación más rígida que ofrece un lenguaje orientado a objetos: no tiene nombre propio, no puede fallar devolviendo nada, no puede elegir qué subtipo entrega, no puede reutilizar una instancia previa y no puede sobrecargarse por tipo de retorno. Cada una de esas rigideces es una restricción de diseño que, tarde o temprano, se convierte en un problema de API. El compañero de Kotlin ofrece la salida canónica: cierras el constructor, abres una fábrica y recuperas todos los grados de libertad que el constructor te negaba. Y si te duele que el sitio de llamada pierda el aspecto de una construcción, el operador invoke te lo devuelve intacto. La pregunta interesante no es cómo se escribe, que es trivial, sino qué le estás ocultando a quien lee tu código y si ese ocultamiento está justificado.
- Enumerar las cuatro libertades que una fábrica recupera frente a un constructor y cuándo cada una importa.
- Escribir constructores privados con fábricas de validación total en lugar de excepciones en el
init. - Implementar
invokeen el compañero y entender cómo se resuelve la llamada resultante. - Comparar fábrica en compañero, función de nivel superior con nombre de tipo y constructor, con criterios de compatibilidad binaria.
Cerrar el constructor para abrir opciones
El patrón base consiste en marcar el constructor primario como privado y dejar que el compañero sea la única puerta de entrada. Como el compañero está dentro de la clase, ve el constructor privado sin problemas.
@JvmInline
value class Correo private constructor(val valor: String) {
companion object {
private val PATRON = Regex("""^[^@\s]+@[^@\s]+\.[a-z]{2,}$""")
fun crear(bruto: String): Correo? {
val limpio = bruto.trim().lowercase()
return if (PATRON.matches(limpio)) Correo(limpio) else null
}
fun exigir(bruto: String): Correo =
crear(bruto) ?: throw IllegalArgumentException("correo invalido")
}
}
La diferencia con validar dentro de un bloque init y lanzar una excepción no es estilística, es de teoría de tipos. Un constructor que lanza es una función parcial disfrazada de total: su firma promete devolver siempre un valor y en realidad no lo hace, de modo que el error queda fuera del sistema de tipos y solo se descubre leyendo la documentación o el código. Una fábrica que devuelve un tipo anulable o un Result convierte esa parcialidad en información que el compilador puede exigir que trates. Es la misma idea que sostiene toda la nulabilidad de Kotlin, aplicada a la construcción.
Hay un matiz de diseño en la pareja de fábricas del ejemplo que conviene no perder. Ofrecer a la vez una versión que devuelve nulo y otra que lanza no es redundancia: son dos contratos para dos situaciones distintas. La primera sirve cuando el dato viene de fuera y el fallo es esperable, como al validar un formulario; la segunda sirve cuando el dato viene de una constante del propio código y un fallo indicaría un error de programación. Nombrarlas de forma distinta obliga a quien llama a elegir conscientemente, que es exactamente lo que un constructor único impide.
Las otras tres libertades aparecen en cuanto miras qué más puede hacer un método que un constructor no.
Reutilizar instancias
Una fábrica puede consultar una caché y devolver un objeto ya existente. Es lo que hace el envoltorio de enteros de la plataforma con los valores pequeños, y lo que permite comparar por identidad valores internados.
Elegir el subtipo
El tipo declarado en el retorno puede ser la raíz de una jerarquía sellada mientras la fábrica decide qué caso concreto construir. Un constructor siempre devuelve exactamente su propia clase.
Tener un nombre
Dos formas de construir con la misma lista de parámetros son indistinguibles como constructores y triviales como fábricas: desdeSegundos y desdeMilisegundos se leen solas.
Evolucionar sin romper
Añadir un parámetro con valor por defecto a un constructor público cambia su firma en el artefacto compilado. Una fábrica te deja añadir sobrecargas y marcar las viejas como obsoletas sin romper a nadie.
El compañero que se hace pasar por constructor
El coste de todo lo anterior es un sitio de llamada que cambia de aspecto: se pasa de Correo("a@b.co") a Correo.crear("a@b.co"). Cuando esa diferencia molesta, el operador invoke la borra. Si el compañero define operator fun invoke, entonces escribir el nombre de la clase seguido de paréntesis se resuelve como una llamada a ese operador sobre el compañero.
class Duracion private constructor(val nanos: Long) {
companion object {
private val CACHE = Array(1024) { Duracion(it.toLong()) }
operator fun invoke(nanos: Long): Duracion =
if (nanos in 0 until 1024) CACHE[nanos.toInt()] else Duracion(nanos)
}
}
val d = Duracion(42) // parece un constructor, es Duracion.Companion.invoke
El mecanismo conviene tenerlo claro para no llevarse sorpresas. La expresión con paréntesis busca primero un constructor aplicable y, si lo encuentra, gana; solo cuando no hay constructor accesible que encaje entra en juego el invoke del compañero. Por eso el patrón exige de verdad que el constructor sea privado: si dejas uno público con la misma firma, tu invoke quedará como código muerto y la caché nunca se usará. Y por eso mismo mezclar ambas cosas con firmas parecidas es una fuente sólida de confusión en revisiones de código.
flowchart TD L[Sitio de llamada con nombre de tipo y parentesis] --> R[Resolucion de sobrecarga] R --> C[Existe constructor accesible y aplicable] R --> I[No hay constructor aplicable] C --> C2[Se invoca el constructor y el invoke queda ignorado] I --> I2[Se busca operator invoke en el companion] I2 --> I3[Se ejecuta la fabrica con validacion o cache o subtipo] style C2 fill:#f38ba8,color:#11111b style I3 fill:#a6e3a1,color:#11111b
Hay una alternativa que la biblioteca estándar usa más que el invoke y que conviene poner en la balanza: la función de nivel superior con el mismo nombre que el tipo, escrita con mayúscula inicial. Construcciones muy usadas del ecosistema se crean así, y el efecto visual es idéntico al de un constructor. Sus ventajas son que no obliga a existir un compañero, que se importa y se descubre como cualquier función, y que desde Java se ve como un método estático del fichero. Su desventaja es que no se hereda: si diseñas una interfaz cuyos implementadores deben aportar una fábrica, solo el compañero que implementa esa interfaz te da ese contrato.
Tres formas de construir y cómo se decide
Puestas en fila, las tres opciones se distinguen por lo que prometen y por lo que cuesta cambiarlas más adelante.
// 1. Constructor publico: maxima transparencia, minima libertad futura
class Punto(val x: Int, val y: Int)
// 2. Fabrica con nombre en el companion: libertad total, sitio de llamada explicito
class Color private constructor(val rgb: Int) {
companion object {
fun deHex(s: String): Color? = s.removePrefix("#").toIntOrNull(16)?.let(::Color)
}
}
// 3. Funcion de nivel superior con nombre de tipo: aspecto de constructor sin companion
fun Temporizador(ms: Long): Temporizador = TemporizadorReal(ms)
El criterio que mejor aguanta el paso del tiempo es el de compatibilidad. Un constructor público forma parte del artefacto compilado con una firma exacta, y cualquier cambio en su lista de parámetros rompe a los consumidores ya compilados aunque el código fuente siga compilando. Añadir un parámetro con valor por defecto, que en Kotlin parece inocuo, genera una firma sintética adicional y deja obsoleta la anterior. Una fábrica con nombre no tiene ese problema porque puedes añadir una nueva y marcar la vieja como obsoleta durante una o dos versiones, que es la única forma civilizada de evolucionar una biblioteca con usuarios.
El segundo criterio es la herencia del contrato. Si diseñas una interfaz cuyos implementadores deben ofrecer una forma de construirse, la única de las tres opciones que lo expresa es el compañero que implementa esa interfaz de fábrica, porque solo él puede aparecer como valor y recibirse por parámetro. Ni un constructor ni una función libre pueden cumplir un contrato declarado.
El tercero es puramente ergonómico y es donde entra invoke: si el sitio de llamada ideal es el nombre del tipo seguido de argumentos, tienes dos caminos, el operador en el compañero y la función de nivel superior con mayúscula. La función libre gana en simplicidad y en interoperabilidad; el operador gana cuando además necesitas que esa fábrica sea heredable o accesible de forma genérica a través del compañero.
Queda un cuarto criterio, más silencioso, que aparece cuando la clase es una clase de valor en línea. Al cerrar el constructor y validar en la fábrica obtienes el patrón del tipo refinado: un envoltorio que no cuesta nada en tiempo de ejecución y del que, sin embargo, no puede existir ninguna instancia inválida en todo el programa. Esa combinación es la razón principal por la que el patrón merece la pena en dominios donde una cadena mal formada llega hasta la base de datos.
@JvmInline
value class Identificador private constructor(private val v: String) {
companion object {
fun de(bruto: String): Identificador? =
bruto.takeIf { it.length == 26 && it.all(Char::isLetterOrDigit) }
?.let(::Identificador)
}
override fun toString(): String = v
}
A partir de ahí, cualquier función que reciba un Identificador puede prescindir de validaciones defensivas, porque la única puerta de entrada al tipo ya las hizo. Ese es el rendimiento real de cerrar un constructor: no ahorra líneas, elimina una clase entera de comprobaciones repetidas y el estado de programa que las hacía necesarias.
La ilusión de constructor es exclusiva de Kotlin. Un consumidor Java verá Clase.Companion.invoke(...), que es la peor firma posible de leer. Si tu biblioteca tiene consumidores Java, anota el invoke con @JvmStatic y considera exponer además una fábrica con nombre explícito pensada para ellos.
El argumento habitual a favor de las fábricas es el encapsulamiento, y ese argumento es cierto pero superficial. Lo que de verdad cambia al cerrar un constructor no es cuánto se esconde, sino quién decide. Un constructor público es una promesa muy fuerte y muy poco reversible: prometes que para cualquier combinación de argumentos que respete los tipos existe un objeto válido, prometes que ese objeto será exactamente de esa clase, prometes que será uno nuevo, y prometes que esa correspondencia entre argumentos e instancia forma parte de tu interfaz para siempre. Son cuatro promesas independientes que la sintaxis te obliga a hacer todas a la vez, sin que nadie te pregunte si querías hacerlas. Una fábrica desagrega esas cuatro promesas y te deja firmarlas una a una. Puedes retirar la primera devolviendo un tipo anulable o un Result, y con eso trasladas al sistema de tipos una parcialidad que antes vivía en un párrafo de documentación. Puedes retirar la segunda declarando como retorno la raíz de una jerarquía sellada, y con eso ganas libertad para cambiar la representación interna sin tocar a ningún llamador. Puedes retirar la tercera con una caché, y entonces la igualdad por identidad empieza a coincidir con la igualdad estructural para los valores internados, con todo lo que eso implica en tablas de dispersión y en comparaciones. Puedes retirar la cuarta simplemente por tener un nombre, porque un nombre se puede marcar obsoleto y un constructor no. El operador invoke entra al final de este razonamiento y hay que evaluarlo con severidad, porque su función es exactamente devolverte la apariencia de aquello que acabas de desmontar. Eso es legítimo cuando la apariencia de construcción sigue siendo cierta en lo esencial, es decir, cuando el llamador obtiene un valor de ese tipo y no le importa si es nuevo o reutilizado: piensa en valores inmutables internados. Deja de ser legítimo en cuanto la fábrica hace algo que un constructor no haría y que al llamador sí le importa, como abrir un recurso, consultar una red, registrar el objeto en un sitio global o devolver un caso concreto de una jerarquía que el llamador querría distinguir. La regla, entonces, es de honestidad y no de estética: usa invoke cuando quieras conservar una promesa que sigues cumpliendo, y usa un nombre explícito cuando la promesa haya cambiado. Escoger invoke para disimular un cambio de contrato es la manera más elegante que ofrece Kotlin de mentirle a quien lee tu código.
- Coge una clase tuya con validación en el bloque
initque lanza excepciones. Cierra el constructor y ofrece dos fábricas: una que devuelva un tipo anulable y otra que devuelvaResult. Compara los sitios de llamada. - Añade una caché en el compañero para los valores más frecuentes y comprueba con
===que dos llamadas con el mismo argumento devuelven la misma instancia. Razona qué invariante debe cumplir la clase para que eso sea seguro. - Declara el retorno de la fábrica como una interfaz sellada y devuelve dos implementaciones distintas según los argumentos. Verifica que ningún llamador necesita cambiar.
- Implementa
operator fun invokeen el compañero y deja también un constructor público con la misma firma. Comprueba experimentalmente cuál gana y explica el resultado con las reglas de resolución. - Consume tu API desde Java con y sin
@JvmStaticsobre elinvoke. Decide si expondrías el operador o una fábrica con nombre, y justifica la decisión en dos frases.