val y var: la referencia frente al objeto
Qué congela realmente val y por qué no es sinónimo de inmutable, cómo funciona la inferencia local de tipos en Kotlin, dónde termina y en qué casos conviene anotar el tipo aunque el compilador no lo exija.
La primera lección de Kotlin que todo el mundo recibe es un eslogan: val es inmutable y var no. El eslogan es cómodo y es falso. val congela una cosa muy concreta —el enlace entre un nombre y una referencia— y no dice absolutamente nada sobre el objeto al que esa referencia apunta. Distinguir esas dos inmutabilidades es la frontera entre escribir Kotlin y escribir Java con otra sintaxis.
- Separar la inmutabilidad de la referencia de la del objeto referenciado.
- Entender cómo infiere Kotlin el tipo y dónde termina esa inferencia.
- Saber cuándo anotar el tipo aporta corrección y no solo ruido.
- Distinguir
val,const valy unvalcon getter propio.
La referencia frente al objeto
Un val declara un enlace que solo se escribe una vez. Un var declara un enlace reasignable. Eso es todo lo que deciden. El objeto que hay al otro lado tiene su propia historia:
val nombres = mutableListOf("Ada", "Grace")
nombres.add("Barbara") // legal: el objeto muta felizmente
// nombres = mutableListOf() // error: la referencia es definitiva
var otros = listOf("Ada")
otros = listOf("Ada", "Grace") // legal: reasignas el nombre
// otros.add("Barbara") // error: List no expone add
Las dos declaraciones son casi opuestas. La primera tiene un nombre fijo apuntando a un estado que cambia bajo tus pies; la segunda tiene un nombre móvil apuntando a valores que nunca cambian. Solo la segunda te da las propiedades que buscas cuando hablas de código inmutable: poder compartir el objeto entre hilos, cachear su hashCode, razonar sobre él sin miedo.
De ahí sale la matriz que conviene tener presente. val más tipo mutable es la combinación por defecto de casi todo el mundo y la más engañosa. val más tipo inmutable es una constante real. var más tipo inmutable es el patrón de estado funcional: el nombre avanza por una sucesión de instantáneas congeladas.
En la JVM la traducción es literal. Un val local ocupa una ranura del marco de pila que no se vuelve a escribir; una propiedad val genera un campo private final con getter y sin setter; una propiedad var añade el setter. Nada de eso toca al objeto apuntado, que solo será inmutable si su propio tipo lo garantiza.
flowchart LR V[val nombre] --> R[Enlace fijo] W[var nombre] --> R2[Enlace reasignable] R --> O[Objeto en el heap] R2 --> O O --> N[Inmutable solo si el tipo lo garantiza] style N fill:#f9e2af,color:#11111b
Inferencia local y sus fronteras
Kotlin infiere tipos, pero no hace inferencia global al estilo Hindley-Milner: resuelve cada declaración a partir de su inicializador y del tipo esperado por el contexto. Es inferencia local, y esa limitación es deliberada porque mantiene los mensajes de error cerca del sitio donde está el problema.
val n = 42 // Int
val l = 42L // Long
val grande = 3_000_000_000 // Long: el literal no cabe en Int
val d = 1.0 // Double, nunca Float
val b: Byte = 1 // el tipo esperado guía al literal
val c = 'K' // Char, que en Kotlin no es un número
La misma regla explica la asimetría entre cuerpos de función. Un cuerpo de expresión se infiere; un cuerpo de bloque exige declarar el tipo salvo que devuelva Unit, porque inferir a través de todos los return de un bloque arbitrario sería inferencia no local:
fun doble(x: Int) = x * 2 // Int inferido del cuerpo
fun triple(x: Int): Int { return x * 3 } // bloque: hay que declararlo
La inferencia también sabe combinar ramas, y ahí aparece la primera sorpresa. Cuando dos ramas devuelven tipos sin relación, el compilador calcula el supertipo común, que rara vez es lo que querías:
val raro = if (System.nanoTime() > 0) 1 else "uno"
// no es Int ni String: es un supertipo común incómodo de usar
La segunda sorpresa llega desde Java. Un valor que cruza esa frontera sin anotaciones de nulabilidad recibe un tipo de plataforma: el compilador suspende el juicio y te deja pasar sin comprobación, trasladando el riesgo a tiempo de ejecución.
val padre = java.io.File("/tmp/x").parent // tipo de plataforma: sin contrato
val seguro: String? = java.io.File("/tmp/x").parent // ahora sí hay contrato
La primera línea compila y te deja llamar a length sin comprobar nada; la segunda te obliga a decidir qué haces si falta. Es el único caso en que renunciar a la inferencia no es cuestión de estilo, sino de corrección: la anotación es lo que convierte un fallo de tiempo de ejecución en un error de compilación.
Anotar el tipo aunque el compilador no lo pida
Hay tres situaciones en las que la anotación deja de ser ruido y pasa a ser parte del diseño.
Superficie pública
En una API el tipo inferido se convierte en el contrato binario publicado. Si lo dejas al azar, cualquier refactor del cuerpo puede romper a tus consumidores sin que te enteres.
Restringir la interfaz
Inferir desde mutableListOf publica MutableList. Anotar List te deja cambiar la implementación mañana sin romper nada.
Frontera con Java
Anotar convierte un tipo de plataforma en un contrato explícito y traslada el fallo de tiempo de ejecución a tiempo de compilación.
El caso de la interfaz restringida merece verse escrito, porque es el error más común en código real:
// El tipo publicado es MutableList y ya no puedes retirarlo
val historial = mutableListOf<String>()
// Publicas List: conservas libertad de implementación y de sincronización
val historialSeguro: List<String> = mutableListOf()
Para la superficie pública, Kotlin ofrece una red de seguridad: el modo de API explícita, que se activa con explicitApi() en el bloque kotlin de Gradle. Con él, toda declaración pública debe llevar visibilidad y tipo de retorno explícitos. En una biblioteca es prácticamente obligatorio; en el módulo de una aplicación suele ser exagerado.
Dentro de una función, infiere siempre: el tipo es evidente y la lectura gana. Fuera de la función, si otro módulo puede verlo, anótalo. La inferencia es una comodidad para el autor; la anotación es una promesa para el lector.
Ni constante ni estable
val tampoco significa constante en el sentido del compilador. Para eso existe const val, restringido a declaraciones de nivel superior o miembros de un object, con tipos primitivos o String e inicializador evaluable en compilación. Su valor se copia en cada punto de uso, lo cual tiene una consecuencia que sorprende a quien publica bibliotecas: cambiar la constante no basta, hay que recompilar a todos los clientes.
Y en la otra dirección, un val con getter propio no tiene campo de respaldo y se recalcula en cada lectura:
const val VERSION = "2.3" // se inlinea en cada punto de uso
class Reloj {
val ahora: Long
get() = System.nanoTime() // val, pero distinto en cada lectura
}
Ese detalle explica una restricción del análisis de flujo: el compilador solo aplica smart cast sobre un val cuando puede demostrar que dos lecturas consecutivas dan lo mismo. Un val con getter personalizado, uno open o uno declarado en otro módulo no ofrecen esa garantía, y el smart cast se rechaza.
Queda un tercer eje que completa el cuadro: cuándo se produce el valor. Un val normal se inicializa en el constructor; by lazy retrasa el cálculo hasta la primera lectura y lo memoriza, conservando la semántica de solo lectura; lateinit var renuncia a la inicialización temprana y a la comprobación de nulos a cambio de una excepción si lees antes de escribir.
val conexion by lazy { abrirConexion() } // val de verdad, calculado tarde
lateinit var contexto: Contexto // var sin valor inicial ni nulo
La regla de elección es directa: by lazy cuando el valor es derivable por ti; lateinit var solo cuando alguien externo —un marco de trabajo, un inyector— te lo va a entregar más tarde y no puedes hacer nada al respecto.
La confusión entre las dos inmutabilidades no es un despiste de principiante: es el reflejo de que casi ningún lenguaje mayoritario distingue en su sintaxis el enlace del valor. Kotlin hereda esa distinción de la tradición ML, donde un enlace es inmutable por defecto y la mutabilidad es una propiedad del tipo del dato, no de la declaración. Por eso val es solo la mitad del trabajo: la otra mitad la hace elegir List en vez de MutableList, data class con propiedades val en vez de campos reasignables, y copy en vez de mutación in situ. Cuando esas dos mitades encajan obtienes la propiedad que de verdad querías: un valor que puedes compartir entre corrutinas sin sincronizar, cachear sin invalidar y comparar por estructura sin miedo. Cuando solo tienes la primera mitad, tienes un nombre fijo apuntando a un estado compartido que muta: exactamente el problema que creías haber evitado, ahora con una palabra clave tranquilizadora encima.
- Declara un
valconmutableListOf, muta su contenido y comprueba que compila; después intenta reasignarlo y lee el error exacto. - Escribe la misma lógica con
varylistOf, usando reasignación en vez de mutación, y compara ambos estilos. - Crea una propiedad
valcon getter propio que devuelva un valor distinto en cada lectura y razona por qué el compilador no le aplicasmart cast. - Declara
val cache = mutableMapOf<String, Int>()en un objeto público y luego anótalo comoMap. Observa qué métodos desaparecen de la superficie. - Investiga: activa
explicitApi()en un módulo Gradle y cuenta cuántos avisos aparecen en tu código actual.