Casos reales: leer la stdlib y aplicarla a tus APIs
La librería estándar de Kotlin es el mejor manual de estilo sobre inserción que existe, porque cada decisión está tomada por alguien que medía. Esta lección lee el código real de las funciones de ámbito, de los operadores de colección y de las utilidades de recursos y bloqueo, señala dónde aparece cada modificador y por qué, contrasta las operaciones de colección con las de secuencia para mostrar la frontera exacta del mecanismo, y traduce todo ello a un conjunto de reglas aplicables a las APIs propias.
Después de tres lecciones de mecánica y una de prudencia, queda el ejercicio que de verdad consolida el nivel: abrir el código de la librería estándar y comprobar que cada una de las reglas anteriores aparece allí, aplicada con una consistencia que no es casual. Las funciones de ámbito están marcadas, tienen cuerpos de una línea y declaran contratos; los operadores de colección están marcados y delegan el volumen en funciones auxiliares; los mismos operadores sobre secuencias no están marcados en absoluto, y esa diferencia tiene una explicación exacta; una función de ordenación por criterio lleva crossinline y otra muy parecida no lo lleva. Nada de esto es arbitrario, y leerlo con las herramientas de este nivel convierte una biblioteca que se usaba a ciegas en un catálogo de decisiones justificadas que se pueden imitar.
- Reconocer en el código real de la stdlib el patrón de función insertable mínima con delegación del volumen.
- Explicar por qué las operaciones de colección están marcadas y las de secuencia no, apelando al destino de la lambda.
- Localizar los usos de
crossinliney de parámetros conservados en la librería y justificar cada uno. - Trasladar esas decisiones a un conjunto de reglas concretas para diseñar APIs propias.
Las funciones de ámbito: cuerpo mínimo y contrato
Las cinco funciones de ámbito son el ejemplo canónico de todo lo dicho. Su cuerpo se reduce a invocar el bloque y devolver algo, la marca de inserción está presente en todas, y desde la introducción de los contratos incluyen además una declaración que informa al analizador de flujo de cuántas veces se invocará el bloque.
public inline fun <T, R> T.let(block: (T) -> R): R {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
return block(this)
}
public inline fun <T> T.also(block: (T) -> Unit): T {
contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) }
block(this)
return this
}
La segunda línea de cada una es la que más enseña. El contrato afirma que el bloque se ejecuta exactamente una vez en el lugar de la llamada, y de esa afirmación el compilador deduce dos cosas valiosas: que una variable inmutable declarada fuera y asignada dentro del bloque queda inicializada con seguridad, y que las conversiones inteligentes calculadas antes siguen siendo válidas después. Ese razonamiento solo es posible porque el bloque se inserta; una lambda que pudiera guardarse y ejecutarse más tarde no permitiría ninguna de las dos deducciones.
val configuracion: Configuracion
leerFichero().let { texto ->
configuracion = analizar(texto) // valido: el contrato garantiza una unica ejecucion
}
Es tentador resumir las funciones de ámbito diciendo que están marcadas para no crear objetos, y es cierto pero es lo menos importante. Están marcadas porque sin inserción no podrían prometer nada sobre cuándo se ejecuta el bloque, y sin esa promesa no habría contratos, no habría inicialización diferida de valores inmutables dentro del bloque, no habría conversiones inteligentes que sobrevivieran y no habría retorno no local. El ahorro de asignaciones es el efecto secundario más visible de una decisión que se tomó por motivos semánticos.
Colecciones frente a secuencias: la frontera del mecanismo
Los operadores de colección repiten el patrón recomendado en la lección anterior. La función que el programador escribe está marcada y es diminuta; el trabajo real vive en una auxiliar que también está marcada pero que es la única que contiene el bucle, de modo que la copia que llega a cada punto de llamada es corta.
public inline fun <T, R> Iterable<T>.map(transform: (T) -> R): List<R> {
return mapTo(ArrayList<R>(collectionSizeOrDefault(10)), transform)
}
public inline fun <T, R, C : MutableCollection<in R>> Iterable<T>.mapTo(
destination: C, transform: (T) -> R
): C {
for (item in this) destination.add(transform(item))
return destination
}
El contraste llega al mirar la operación con el mismo nombre sobre secuencias, que no lleva la marca por ninguna parte. La razón es la propiedad que define a las secuencias: la transformación no se ejecuta al llamarla, sino que queda registrada para aplicarse más tarde, elemento a elemento, cuando alguien consuma el resultado. Para poder aplicarla más tarde hay que guardarla, y guardar una lambda exige que exista como objeto.
public fun <T, R> Sequence<T>.map(transform: (T) -> R): Sequence<R> {
return TransformingSequence(this, transform)
}
flowchart TD A[Operacion sobre una coleccion] --> B[La lambda se invoca dentro del bucle inmediato] B --> C[Se marca inline: cero objetos por elemento] D[Operacion sobre una secuencia] --> E[La lambda se guarda en el objeto de la cadena] E --> F[No se puede insertar: existe como objeto] F --> G[Una instancia por eslabon y no por elemento]
De esa comparación se extrae la regla más útil del nivel, que no habla de rendimiento sino de destino: si la lambda se consume durante la llamada, se puede insertar; si sobrevive a la llamada, no. Lo demás son consecuencias. Y conviene añadir el matiz que evita conclusiones erróneas sobre las secuencias: el objeto por eslabón se crea una sola vez para toda la cadena, no una vez por elemento, de modo que la comparación de asignaciones entre ambos mundos es mucho menos desfavorable de lo que sugiere la ausencia de la marca.
Los modificadores en su hábitat natural
La librería contiene ejemplos de manual de los dos modificadores de la segunda lección, y verlos en contexto los fija mejor que cualquier explicación. La ordenación por un criterio construye un comparador, que es un objeto, y el criterio acaba viviendo dentro de él; como el destino de la copia es ese objeto, el parámetro lleva crossinline.
public inline fun <T, R : Comparable<R>> Iterable<T>.sortedBy(
crossinline selector: (T) -> R?
): List<T> = sortedWith(compareBy { selector(it) })
// En cambio aqui el selector se invoca en el bucle, sin modificador
public inline fun <T, R : Comparable<R>> Iterable<T>.maxByOrNull(selector: (T) -> R): T? {
// recorre y compara en el sitio
return null
}
Las utilidades de recursos y de sincronización muestran el otro extremo del catálogo: cuerpos que no son triviales pero siguen siendo pequeños, con la marca puesta para que el bloque se ejecute en el sitio y para que el retorno no local funcione como en una estructura de control del lenguaje. Es lo que permite escribir un return desde dentro de un bloque de recurso o de bloqueo y que abandone la función entera con el recurso correctamente liberado.
public inline fun <T : Closeable?, R> T.use(block: (T) -> R): R { /* try, finally, close */ }
public inline fun <R> synchronized(lock: Any, block: () -> R): R { /* monitor y bloque */ }
public inline fun <T> Lock.withLock(action: () -> T): T {
lock()
try { return action() } finally { unlock() }
}
Sin modificador
El bloque se invoca directamente en el cuerpo. Es el caso mayoritario: funciones de ámbito, use, withLock, repeat y casi todos los operadores de colección.
Con crossinline
El bloque acaba dentro de otro objeto, como un comparador o un envoltorio. Se conserva la inserción y se prohíbe el retorno no local.
Con reified
El cuerpo necesita el tipo en tiempo de ejecución. Es el caso de filterIsInstance, de arrayOf sobre tipos genéricos y de las conversiones seguras genéricas.
Merece nombrarse también una anotación que aparece por toda la librería y que suele desconcertar. Muchas de estas funciones llevan una marca adicional que indica que solo existen para ser insertadas y que no deben generar un método invocable desde otros lenguajes de la plataforma. Sirve para no publicar en la interfaz binaria un método que nadie va a llamar nunca, y es la respuesta de la librería al problema de fragilidad binaria descrito en la lección anterior.
Trasladarlo a tus propias APIs
Con el catálogo delante, las reglas para el código propio se escriben sin ambigüedad. Una función que reciba un bloque y lo ejecute en el sitio se marca; si el cuerpo crece, se parte en una parte mínima marcada y una parte corriente. Un bloque que haya que guardar lleva el modificador que lo devuelve a ser objeto; un bloque que se ejecute dentro de otro contexto lleva el que conserva la inserción y retira el retorno no local. Y un parámetro de tipo se conserva solo cuando el cuerpo necesita preguntar por él.
inline fun <T> reintentar(veces: Int, bloque: (Int) -> T): T {
var ultimo: Throwable? = null
repeat(veces) { intento ->
try { return bloque(intento) } catch (e: Throwable) { ultimo = e }
}
throw ultimo!!
}
inline fun <reified T : Any> Any?.comoOSiNo(): T? = this as? T
Queda un último consejo que la librería aplica de forma sistemática y que rara vez se enuncia. Las funciones insertables públicas de la stdlib son estables porque su cuerpo es tan pequeño que casi no admite cambios: son una línea, dos como mucho, y la lógica sustancial vive detrás de una función normal que sí puede evolucionar. Ese es el modo de convivir con la fragilidad binaria sin renunciar al mecanismo, y es imitable en cualquier biblioteca propia sin más disciplina que decidir dónde termina la parte que se congela.
Si se lee la librería estándar entera con este nivel en la mano aparece una tesis que ninguna de sus funciones enuncia por separado y que sin embargo las explica a todas. Kotlin es un lenguaje deliberadamente pequeño en sintaxis y deliberadamente grande en biblioteca, y esa proporción solo es sostenible si lo escrito en la biblioteca puede comportarse con los mismos privilegios que lo escrito en el compilador. La gestión de recursos podría haber sido una construcción sintáctica dedicada, como lo es en Java; en Kotlin es una función corriente de tres líneas, y funciona igual de bien porque la inserción le permite ejecutar el bloque en el sitio, liberar en un bloque final y dejar que un retorno escrito dentro abandone la función de fuera. La sincronización podría haber sido una palabra clave; es una función. La repetición de un bloque podría haber sido un bucle especial; es una función con un contrato que informa al analizador de flujo. La comprobación de tipo genérica podría haber requerido genéricos conservados en la máquina virtual; se resuelve especializando la copia en el punto de llamada. Cada una de esas decisiones traslada complejidad desde el compilador, donde solo la puede tocar el equipo del lenguaje, hasta la biblioteca, donde la puede tocar cualquiera, y esa transferencia es la razón de que en Kotlin sea normal escribir construcciones de control propias sin que parezcan un injerto. Quien termine este nivel debería salir con esa lectura y no con la anterior: la palabra clave no está ahí para que tu bucle vaya un cinco por ciento más rápido, sino para que puedas escribir en tu propio código el mismo tipo de abstracciones que la librería estándar escribe en el suyo, con los mismos privilegios y con el mismo coste, que ahora ya sabes calcular.
- Abre el código de tres funciones de ámbito y explica qué aporta el contrato de cada una además de la ausencia de objetos.
- Compara la implementación de la transformación sobre colección y sobre secuencia, y justifica la ausencia de la marca en la segunda.
- Localiza en la librería dos funciones muy parecidas donde una lleve
crossinliney la otra no. Explica la diferencia por el destino de la copia. - Escribe una utilidad propia de reintento con inserción y decide, con argumentos, si algún parámetro necesita modificador.
- Diseña una API pública tuya aplicando la disciplina de cuerpo mínimo insertable más función corriente, y señala qué parte queda congelada.