El constructor primario: la firma que declara el estado
El constructor primario declara y asigna propiedades en la misma linea, los bloques init son su cuerpo troceado, el orden de inicializacion sigue el orden textual del cuerpo de la clase, y los constructores secundarios solo aparecen cuando hay que delegar desde firmas ajenas.
En Java, construir un objeto obliga a escribir tres veces lo mismo: el campo, el parámetro y la asignación que los une. Kotlin borra esa triple repetición metiendo la declaración de las propiedades dentro de la propia firma de la clase, y al hacerlo cambia algo más profundo que la ergonomía. El constructor primario deja de ser un método más para convertirse en la declaración del contrato mínimo que hace falta para que una instancia exista. Si un dato aparece ahí, ninguna instancia de esa clase podrá existir sin él, y el compilador lo garantiza sin que tú escribas ni una comprobación. Esta lección desmonta la sintaxis y, sobre todo, el orden exacto en que ocurren las cosas.
- Declarar propiedades desde el constructor primario y distinguir un parámetro de una propiedad.
- Usar los bloques
initcomo cuerpo del constructor y entender por qué puede haber varios. - Predecir el orden exacto de inicialización de una clase, sus inicializadores y su superclase.
- Escribir constructores secundarios con delegación y reconocer cuándo no hacen falta.
La firma que declara el estado
El constructor primario va pegado al nombre de la clase, y sus parámetros pueden llevar val o var. Esa palabra clave es la que decide si estás declarando un parámetro pasajero o una propiedad permanente:
class Usuario(val nombre: String, var edad: Int)
val u = Usuario("Ada", 36)
u.edad = 37 // hay setter porque se declaro var
// u.nombre = "otro" // error de compilacion: val no genera setter
Una sola línea produce, en la JVM, dos campos privados, dos getters y un setter. Pero la diferencia importante no es cuánto texto ahorras, sino qué ocurre cuando omites el val o el var. Sin ellos, el identificador sigue siendo un parámetro del constructor, y su vida termina en cuanto la inicialización acaba: puedes leerlo en los inicializadores de propiedad y en los bloques init, pero no existe en el cuerpo de ninguna función miembro, porque no se guardó en ningún sitio.
class Circulo(radio: Double) { // parametro, no propiedad
val area = Math.PI * radio * radio // legal: la inicializacion aun corre
// fun radioActual() = radio // error: radio ya no existe aqui
}
Esa asimetría es una herramienta de diseño, no un detalle. Un parámetro sin val declara lo que hace falta para construir el objeto; una propiedad declara lo que el objeto recuerda. Cuando un dato solo sirve para calcular otra cosa —una semilla, una configuración que se traduce a otra representación, una fábrica que solo se usa una vez— dejarlo sin val impide que nadie lo retenga por accidente y reduce el estado que hay que razonar después.
init: el cuerpo troceado del constructor
El constructor primario no tiene cuerpo propio porque solo puede asignar. Todo lo demás —validar, derivar, registrar— vive en bloques init, que forman colectivamente el cuerpo de ese constructor:
class Cuenta(val titular: String, saldoInicial: Long) {
var saldo: Long = saldoInicial
private set
init {
require(titular.isNotBlank()) { "el titular no puede estar vacio" }
require(saldoInicial >= 0) { "el saldo inicial no puede ser negativo" }
}
}
Puede haber varios bloques init, y no es un capricho: permiten colocar cada validación junto a la propiedad que valida en vez de amontonarlas al final. El compilador los concatena en el orden en que aparecen, así que el efecto es idéntico al de un único bloque, pero el código se lee por temas y no por fases.
Aquí aparece la primera regla que separa a quien escribe clases de quien diseña tipos: si un constructor termina sin lanzar, el objeto queda válido por construcción. Toda comprobación que hagas en init es una comprobación que ninguna función miembro tendrá que repetir jamás, porque ya no existe forma de llegar a esas funciones con un objeto inválido. Trasladar validación del uso a la construcción no elimina el código: lo mueve a un sitio donde se ejecuta una vez y protege para siempre.
El orden de inicialización
El orden no es arbitrario ni depende de dónde declares las cosas visualmente por bloques: es estrictamente textual. Los inicializadores de propiedad y los bloques init se ejecutan intercalados, de arriba abajo, tal como aparecen en el cuerpo de la clase. Antes de todo eso corre el constructor de la superclase.
flowchart TD A[Se evaluan los argumentos del constructor primario] --> B[Corre el constructor de la superclase] B --> C[Inicializadores de propiedad y bloques init en orden textual] C --> D[Cuerpo del constructor secundario si se entro por uno] D --> E[Instancia lista y visible para el llamante] style A fill:#89b4fa,color:#11111b style C fill:#f9e2af,color:#11111b style E fill:#a6e3a1,color:#11111b
De ese diagrama sale una consecuencia incómoda que conviene interiorizar ahora. Cuando corre el constructor de la superclase, las propiedades de la subclase todavía no están inicializadas. Si la superclase llama en su constructor a un miembro open que la subclase sobrescribe, esa versión sobrescrita se ejecutará leyendo campos que aún valen cero o null, aunque el tipo diga que no pueden ser nulos.
open class Base(val etiqueta: String) {
init { mostrar() } // peligro: llamada virtual desde el constructor
open fun mostrar() = println(etiqueta)
}
class Derivada(etiqueta: String) : Base(etiqueta) {
private val sufijo: String = "!!" // aun no asignado cuando corre mostrar
override fun mostrar() = println(etiqueta + sufijo.length) // NullPointerException
}
Un init, un inicializador de propiedad y el cuerpo de un constructor son territorio donde this está a medio construir. Llamar desde ahí a cualquier miembro open entrega esa referencia incompleta a código que la subclase escribió asumiendo un objeto entero. El compilador de Kotlin avisa con Accessing non-final property in constructor, y ese aviso merece tratarse como error. Si necesitas comportamiento configurable durante la construcción, pásalo como parámetro o como lambda, no como método sobrescribible.
Constructores secundarios: cuándo hacen falta
Un constructor secundario se declara con constructor y está obligado a delegar, con this, en el primario o en otro secundario que acabe llegando a él. Esa obligación existe justamente para que el camino de inicialización dibujado arriba no tenga atajos:
class Punto(val x: Int, val y: Int) {
constructor(par: Pair<Int, Int>) : this(par.first, par.second)
constructor(escalar: Int) : this(escalar, escalar) {
// este cuerpo corre DESPUES de los init del primario
require(escalar != 0) { "el punto diagonal cero no se admite" }
}
}
La pregunta interesante no es cómo se escriben, sino por qué casi nunca hacen falta. Los parámetros con valor por defecto cubren el noventa por ciento de los casos por los que Java necesitaba sobrecargas, y lo hacen sin duplicar rutas de inicialización:
class Peticion(
val url: String,
val metodo: String = "GET",
val reintentos: Int = 3,
)
Peticion("https://ejemplo.dev")
Peticion("https://ejemplo.dev", reintentos = 0)
Quedan tres motivos legítimos para un secundario: adaptar una firma ajena que no puedes cambiar, como el constructor de tres argumentos que exige una API de la plataforma; construir a partir de un tipo distinto que hay que traducir antes de delegar; y ofrecer sobrecargas reales a llamantes de Java, donde los valores por defecto no existen salvo que anotes con @JvmOverloads. Fuera de esos tres casos, un secundario suele ser una ruta de inicialización paralela, es decir, un sitio más donde las invariantes pueden romperse.
Existe también el caso extremo de una clase sin constructor primario. Entonces cada secundario invoca directamente a super, y los bloques init siguen ejecutándose antes que el cuerpo de cualquiera de ellos. Es la forma que adoptan casi siempre las jerarquías de excepciones, donde hay que replicar las cuatro firmas de Throwable:
class ErrorDeDominio : RuntimeException {
constructor(mensaje: String) : super(mensaje)
constructor(mensaje: String, causa: Throwable) : super(mensaje, causa)
}
Un constructor solo tiene dos finales posibles: devolver una instancia válida o lanzar. Si el fallo es esperable —una cadena que puede no ser un correo, una configuración que puede faltar— lanzar convierte un caso de negocio normal en una excepción, y obliga a todo llamante a rodear la construcción con un bloque try. La alternativa idiomática es dejar el constructor privado y publicar una función en el companion object que devuelva un tipo capaz de representar el fallo, de modo que la validación viaje en el tipo de retorno y no en el flujo de excepciones.
Un constructor no es una ceremonia de asignación: es el único punto del programa donde se decide qué instancias pueden llegar a existir. Todo lo que dejes pasar por esa puerta tendrá que ser comprobado, tolerado o parcheado en cada función que reciba el objeto después, y toda comprobación que exijas ahí desaparece del resto del programa para siempre. Por eso la pregunta correcta al diseñar una clase no es qué campos necesita, sino qué es lo mínimo que hace falta para que una instancia sea indiscutiblemente válida. Si un objeto solo tiene sentido con un identificador, ese identificador va en el constructor primario y no como propiedad con valor por defecto que alguien rellenará más tarde. Si una combinación de valores es imposible, un require en init la convierte en inexistente en lugar de en un caso a documentar. Y si necesitas dos o tres pasos para dejar el objeto usable, no tienes un constructor: tienes un protocolo de inicialización, y ese protocolo es exactamente el sitio por donde entran los estados inconsistentes, las banderas estaListo y los lateinit que estallan en producción. La versión disciplinada consiste en empujar la validación hacia atrás hasta que sea imposible construir algo roto, y cuando el resultado sea demasiado rígido para el llamante, no relajar el constructor sino añadir una fábrica que devuelva un tipo de resultado con el fallo modelado. Un constructor que solo asigna admite objetos que mienten sobre su propio tipo; un constructor que valida convierte al sistema de tipos en la documentación ejecutable de tu dominio. La diferencia entre ambos estilos se paga siempre, y se paga en los sitios más lejanos del código donde se tomó la decisión.
- Escribe una clase con dos parámetros en el constructor primario, uno con
valy otro sin él, e intenta usar ambos desde una función miembro. Explica el error que aparece. - Añade dos bloques
initseparados, cada uno junto a la propiedad que valida, y comprueba con unprintlnque se ejecutan en orden textual junto a los inicializadores. - Reproduce el fallo del miembro
openllamado desdeinit, observa el aviso del compilador y arréglalo pasando el comportamiento como lambda. - Convierte una clase con tres constructores secundarios en una sola con parámetros por defecto y enumera qué rutas de inicialización has eliminado.
- Toma un tipo de tu código que tenga una bandera del estilo
estaInicializadoy rediséñalo para que esa bandera sea imposible: si no se puede, argumenta qué invariante te lo impide.