Propiedades de extensión y receptores nulables
Una propiedad de extensión no tiene campo de respaldo: es solo un accesor con otro nombre. Cuándo esa sintaxis aclara una API, cuándo miente sobre el coste, y por qué una extensión sobre un tipo nulable puede llamarse sobre null sin explotar.
Kotlin permite escribir val List<T>.penultimo: T y usarlo como si la lista guardara ese dato en algún sitio. No lo guarda, ni podría: la instancia ya está construida, su tamaño en memoria es fijo y ninguna función estática declarada fuera va a hacerla crecer. Una propiedad de extensión es exactamente un get() con la sintaxis de un campo, y toda la discusión sobre cuándo usarlas gira en torno a esa mentira consentida. La sintaxis de propiedad promete al lector algo muy concreto —esto ya está ahí, leerlo es barato y leerlo dos veces da lo mismo— y quien escribe la extensión decide si esa promesa se cumple. Cuando se cumple, el código gana un sustantivo donde antes había un verbo. Cuando no, se convierte en una trampa que ni el compilador ni el perfilador señalan.
- Explicar por qué una propiedad de extensión no admite inicializador ni campo de respaldo.
- Distinguir los casos donde la forma de propiedad aclara una API de aquellos donde oculta un coste.
- Escribir extensiones sobre tipos nulables y razonar sobre el valor de
thisdentro del cuerpo. - Predecir cuándo una llamada sobre
nulles legal sin operador de llamada segura y por qué.
Sin campo de respaldo: solo accesores
La declaración obliga a dar un get(), y por eso la forma de una sola línea con = no vale. El compilador lo dice sin rodeos.
val <T> List<T>.penultimo: T
get() = this[size - 2]
val String.primeraLetra: Char
get() = this[0]
// Error: extension property cannot be initialized because it has no backing field
val String.contador: Int = 0
Lo que se genera es un método estático getPenultimo(List), sin ningún campo asociado. Las propiedades de extensión mutables son posibles, pero solo cuando el estado vive ya en otro sitio y el accesor se limita a redirigirlo:
var StringBuilder.ultimaLetra: Char
get() = this[length - 1]
set(valor) { this.setCharAt(length - 1, valor) }
Aquí no hay estado nuevo: el set traduce una asignación a una llamada sobre la API pública del receptor. Esa es la única forma legítima de un var de extensión. Si alguna vez necesitas asociar datos nuevos a instancias ajenas, la extensión no es el mecanismo; hará falta un mapa externo con claves débiles, un envoltorio o un delegado, y las tres opciones tienen un coste que conviene decidir a la vista.
flowchart LR prop[val String.primeraLetra] --> gen[metodo estatico getPrimeraLetra] gen --> arg[recibe el receptor como parametro] gen --> nofield[no existe ningun campo asociado] nofield --> conc[cada lectura ejecuta el cuerpo entero]
Cuándo aporta y cuándo confunde
La regla útil no es sintáctica sino semántica, y se puede formular como tres condiciones que deben cumplirse a la vez. La propiedad debe ser barata, del orden de unas pocas operaciones y sin trabajo proporcional al tamaño del receptor. Debe ser idempotente, de modo que dos lecturas seguidas devuelvan lo mismo si nadie ha tocado el objeto. Y debe ser libre de efectos: leerla no puede abrir un archivo, tocar la red, escribir en un registro ni mutar nada.
Aporta cuando es un sustantivo
texto.primeraLetra, lista.indices, fecha.esFinDeSemana. Son atributos derivados que el lector ya piensa como propiedades del objeto, y la forma de propiedad reduce el ruido de los paréntesis en cadenas largas.
Confunde cuando cuesta
Una propiedad que recorre la colección entera, ordena, compila una expresión regular o consulta un recurso engaña al lector, que la leerá dentro de un bucle sin pensarlo dos veces. Ahí la forma correcta es una función.
Miente cuando cambia sola
Si dos lecturas consecutivas pueden diferir sin que nadie haya modificado el receptor, no es una propiedad. Un val Any.identificador que devuelva un valor aleatorio o una marca de tiempo rompe la intuición de campo.
Traiciona cuando lanza
lista.penultimo sobre una lista de un elemento lanza una excepción de índice. Una propiedad que puede fallar debería llamarse en consecuencia o devolver un tipo nulable, porque nadie envuelve la lectura de un campo en un bloque de control de errores.
La biblioteca estándar es coherente con este criterio y sirve de calibrador. CharSequence.indices y CharSequence.lastIndex son propiedades porque son aritmética sobre length. String.reversed() y Iterable.sorted() son funciones porque construyen una estructura nueva. Iterable.count() es función aunque a veces sea constante, precisamente porque en el caso general no lo es.
Un contraste que se ve muy bien con Sequence, donde la pereza vuelve el error especialmente caro:
// Mal: forma de propiedad, coste lineal y consumo de la secuencia
val <T> Sequence<T>.tamano: Int
get() = count()
// Bien: la forma de funcion avisa de que ahi se hace trabajo
fun <T> Sequence<T>.tamano(): Int = count()
Leer secuencia.tamano dos veces no solo cuesta dos recorridos: si la secuencia es de un solo uso, la segunda lectura lanza una excepción. Ninguna de esas dos cosas encaja con lo que un lector espera al ver la sintaxis de campo.
Tampoco se puede escribir val String.algo by lazy { ... } en una extensión. La delegación necesita un campo donde guardar el objeto delegado, y ese campo tendría que vivir en la instancia receptora. Como no existe, el compilador lo rechaza. Es el mismo límite de siempre visto desde otro ángulo: nada de lo que escribas fuera de la clase puede hacerla crecer.
Receptores nulables: el truco de la llamada sobre null
Como el receptor es un parámetro, nada impide declararlo con tipo nulable. Y como es un parámetro, pasarle null no produce ninguna desreferencia: dentro del cuerpo, this simplemente vale null y hay que comprobarlo.
fun String?.oVacio(): String = this ?: ""
val String?.estaVacioONulo: Boolean
get() = this == null || this.isEmpty()
fun main() {
val nombre: String? = null
println(nombre.oVacio()) // cadena vacia, sin llamada segura
println(nombre.estaVacioONulo) // true
}
Esa llamada sin ?. sobre una variable nulable es legal y no lanza nada, porque se compila como oVacio(nombre). Es el mismo mecanismo por el que null.toString() devuelve la cadena null en lugar de fallar: la biblioteca estándar declara Any?.toString() con receptor nulable. También es de donde vienen isNullOrBlank e isNullOrEmpty, cuya gracia entera está en absorber el caso nulo dentro del contrato.
Dentro del cuerpo, el compilador trata this como un valor nulable normal, con todo el aparato de contracción de tipos disponible. Después de una comprobación, el receptor queda contraído al tipo no nulable y se puede usar sin ceremonia:
fun String?.longitudSegura(): Int {
if (this == null) return 0
return length // aqui this ya es String, no String?
}
Y con @OptIn a los contratos de la biblioteca estándar, una función como isNullOrEmpty propaga esa información al llamador: tras un if (texto.isNullOrEmpty()) return, el compilador sabe que texto no es nulo en el resto del bloque. Esa cooperación entre extensión y análisis de flujo es una de las cosas mejor resueltas del lenguaje, y solo es posible porque el receptor era un parámetro desde el principio.
Con fun String.enMayusculas(), receptor no nulable, la expresión nombre?.enMayusculas() devuelve null si nombre es nulo y nunca ejecuta el cuerpo. Con fun String?.enMayusculas(), receptor nulable, la expresión nombre.enMayusculas() siempre ejecuta el cuerpo, también con null, y la decisión de qué devolver es tuya. Elegir la segunda forma es aceptar la responsabilidad de tratar el caso nulo; elegirla por error es apagar la comprobación del compilador en el punto exacto donde servía.
Solo cuando el resultado para null tenga un significado natural y único: una cadena vacía, un false, una lista vacía, un cero. Si tienes que inventarte qué devolver, o si la respuesta correcta depende del contexto de cada llamada, deja el receptor no nulable y que quien llame decida con ?., ?: o un if. Absorber el nulo en la extensión reparte una decisión que a menudo no te toca a ti.
Detrás de la elección entre propiedad y función hay un principio que en la literatura se conoce como el principio de acceso uniforme, enunciado por Bertrand Meyer para Eiffel: quien usa un servicio no debería tener que saber si está implementado por almacenamiento o por cálculo. La idea es liberadora, porque permite empezar guardando un campo y pasar más tarde a calcularlo sin romper a ningún cliente, y Kotlin la abraza por completo: un val puede ser un campo, un accesor, un delegado o una extensión, y en el punto de llamada todo se escribe igual. Pero el principio tiene un supuesto tácito que casi nunca se enuncia, y es que el coste no debe cambiar al cruzar esa frontera. Meyer hablaba de ocultar la implementación, no de ocultar la complejidad algorítmica, y una lectura que pasa de constante a lineal no es un detalle de implementación: es un cambio de contrato que ningún tipo captura. Ahí está el nervio del asunto. Kotlin comprueba con enorme rigor la nulabilidad, la varianza y la mutabilidad, y no comprueba absolutamente nada sobre coste, pureza o terminación. El sistema de tipos no distingue una propiedad que suma dos enteros de una que abre un socket. Todo lo que la sintaxis de propiedad transmite —esto es barato, esto es estable, esto no tiene efectos— viaja fuera del sistema formal, en el canal de las convenciones, sostenido únicamente por la disciplina de quien escribe y por lo que el lector ha aprendido a esperar de la biblioteca estándar. Las propiedades de extensión son el punto donde ese canal informal soporta más carga, porque a la ausencia de comprobación de coste se le suma la ausencia de encapsulación: quien escribe la extensión está fuera del tipo, no controla su evolución, no ve sus invariantes internos y aun así está emitiendo una promesa sobre él que el lector leerá como si viniera del autor de la clase. De ahí la regla práctica que conviene interiorizar por encima de cualquier lista: una propiedad de extensión no es una función con menos paréntesis, es una afirmación sobre el objeto. Escribe texto.primeraLetra cuando quieras decir que la primera letra es algo que el texto tiene; escribe texto.contarPalabras() cuando quieras decir que contar es algo que hay que hacer. Y ante la duda, prefiere la función: los paréntesis sobran en un caso y salvan en el otro, y la asimetría de daños no está ni de lejos equilibrada.
Una propiedad de extensión es solo un accesor: no admite inicializador ni campo de respaldo, y un var de extensión únicamente puede redirigir estado que ya existe. Úsala cuando la lectura sea barata, idempotente y sin efectos; en cualquier otro caso, una función. Un receptor nulable convierte la llamada sobre null en algo legal y sin excepción, y solo merece la pena cuando existe una respuesta natural para el caso nulo.
- Escribe
val <T> List<T>.penultimo: Ty llámala sobre una lista de un solo elemento. Decide si el error resultante es aceptable para algo con forma de propiedad y reescríbela como nulable. - Define una propiedad de extensión que recorra la colección entera. Léela dentro de un bucle de mil iteraciones, mide, y después conviértela en función y compara el código de quien la llama.
- Implementa
var StringBuilder.ultimaLetracongetyset, y explica en una frase dónde vive el estado. - Declara la misma operación dos veces, con receptor
Stringy con receptorString?, y compara qué obliga a escribir cada una en el punto de llamada cuando el valor es nulable. - Busca en tu proyecto tres propiedades de extensión y clasifícalas con las tres condiciones de barata, idempotente y sin efectos. Convierte en funciones las que fallen alguna.