Seguridad contra nulos en Kotlin: Despídete del error del billón de dólares

Introducción

Si has llegado hasta aquí, ya sabes cómo crear tus propias clases, agrupar objetos y organizar tu código de maravilla. ¡Enhorabuena! Pero hay un enemigo invisible que acecha a casi todos los programadores cuando empiezan a desarrollar aplicaciones reales: la ausencia de datos o, como se le conoce técnicamente, el valor null.

En lenguajes más antiguos como Java, si intentas acceder a una propiedad de un objeto (por ejemplo, el correo electrónico de un usuario) y resulta que ese usuario no existe en la base de datos (es null), tu programa se cuelga abruptamente lanzando un fatídico NullPointerException. Es un fallo tan común, molesto y destructivo que a su propio inventor le gusta llamarlo «el error del billón de dólares».

Aquí es donde Kotlin brilla con luz propia. Para evitar que tu aplicación explote en las manos de tus usuarios, Kotlin implementa en su núcleo la seguridad contra nulos (Null Safety). Este sistema es como un escudo protector que detecta los posibles problemas mientras escribes tu código (en tiempo de compilación), obligándote a manejarlos de forma elegante mucho antes de que el código llegue a ejecutarse. ¡Vamos a descubrir su magia!

¿Qué son los tipos anulables (Nullable types)?

Por defecto, en Kotlin está estrictamente prohibido que una variable normal guarde un valor nulo. El compilador, simplemente, no te dejará hacerlo. Y esto es fantástico para dormir tranquilos.

Sin embargo, a veces es necesario representar que algo «falta», está vacío o aún no se ha configurado (como un segundo apellido opcional en un formulario). Para decirle a Kotlin que una variable tiene permiso para ser nula, debes declarar un tipo anulable añadiendo explícitamente el símbolo de interrogación ? justo después de su tipo de dato.

fun main() {
    // neverNull es un String normal, jamás aceptará nulos
    var neverNull: String = "Esto no puede ser nulo"
    
    // Si intentas hacer esto, el compilador te lanzará un error
    // neverNull = null

    // nullable es un String? (String anulable)
    var nullable: String? = "Aquí puedes guardar texto"
    
    // Esto es perfectamente válido
    nullable = null
}

Si intentas acceder directamente a una propiedad de una variable que podría ser nula, por ejemplo nullable.length, Kotlin te detendrá y te dará un error. ¿Por qué? Porque podría ser peligroso. Necesitas utilizar herramientas seguras.

Comprobación tradicional de nulos

La forma más básica de manejar un tipo anulable es usar una clásica estructura condicional if para asegurarte de que hay algo ahí dentro antes de usarlo.

fun describirTexto(texto: String?): String {
    // Kotlin comprueba primero que la variable no esté vacía
    if (texto != null && texto.length > 0) {
        return "El texto tiene ${texto.length} caracteres"
    } else {
        return "El texto está vacío o es nulo"
    }
}

El dato: Cuando realizas una comprobación manual como if (texto != null), el compilador de Kotlin es lo bastante inteligente como para aplicar un «smart cast» (conversión inteligente). Dentro de ese bloque if, Kotlin tratará automáticamente a tu variable anulable como si fuera totalmente segura, permitiéndote acceder a .length sin que te salte ningún error.

Llamadas seguras (?.)

Si tuviéramos que escribir un if cada vez que usamos algo que puede ser nulo, nuestro código sería un tostón larguísimo y muy difícil de leer. Afortunadamente, a Kotlin le encanta el código conciso.

Para acceder de forma rápida a las propiedades de un objeto que podría contener un valor nulo, utilizamos el operador de llamada segura ?.

Este operador lo hace todo por detrás: intenta leer la propiedad que le pides, y si resulta que el objeto original es nulo, en lugar de crashear tu aplicación, simplemente devuelve null de forma pacífica y sigue ejecutando el resto de tu código.

fun longitudDelTexto(texto: String?): Int? = texto?.length

fun main() {
    val miTextoNulo: String? = null
    println(longitudDelTexto(miTextoNulo)) 
    // Salida por consola: null
}

Una de las características más potentes de este operador es que las llamadas seguras se pueden encadenar. Si quieres acceder a un dato que se encuentra escondido muy profundamente en tu estructura de clases, puedes hacerlo así:

val paisDelJefe = empleado.empresa?.departamento?.jefe?.pais

Si el empleado no tiene empresa, o la empresa no tiene departamento, el viaje se detiene ahí mismo y la variable paisDelJefe guardará simplemente un null, sin pánicos ni errores de ejecución.

El operador Elvis (?:)

Muchas veces no queremos que el resultado final de nuestra llamada sea null. Es muy común querer proporcionar un valor por defecto si las cosas fallan. Para esto tenemos al espectacular operador Elvis ?: (se llama así porque si giras la cabeza a la izquierda, el símbolo recuerda al mítico tupé de Elvis Presley).

La estructura es muy simple: escribes a la izquierda del Elvis lo que quieres intentar leer, y a la derecha escribes el valor de respaldo que quieres devolver en caso de que lo de la izquierda haya resultado ser nulo.

fun main() {
    val texto: String? = null
    
    // Si texto?.length es nulo, usamos el 0 como comodín
    val longitud = texto?.length ?: 0
    
    println("La longitud es: $longitud")
    // Salida por consola: La longitud es: 0
}

La ejecución condicionada con let (?.let)

Una herramienta de uso diario muy habitual en Kotlin. ¿Y si quieres ejecutar un bloque entero de código únicamente si tu variable no es nula? Combinando la llamada segura ?. con la función let, construimos un entorno hermético y totalmente blindado.

fun main() {
    val correo: String? = "[email protected]"
    
    // Todo lo que hay dentro de las llaves solo se ejecuta si hay correo
    correo?.let { correoSeguro ->
        println("Enviando mensaje de bienvenida a $correoSeguro")
    }
}

La regla de oro: Acostúmbrate a usar siempre llamadas seguras ?. o el operador Elvis ?: para lidiar con tipos anulables. Únicamente hay un escenario en el que podrías forzar la máquina: usando el operador de aserción !! (la doble exclamación). Este operador fuerza la lectura de la variable ignorando la seguridad; si el valor resulta ser nulo, tu aplicación se cerrará inmediatamente. Úsalo solo si pondrías la mano en el fuego de que ese dato existe, o mejor aún, ¡evítalo a toda costa en tu día a día!

Ejercicios

Aquí tienes varios ejercicios de menos a más dificultad (el primero es una traducción adaptada de la documentación oficial de Kotlin y el resto son extras para asentar los conceptos que hemos ampliado). Abre tu Kotlin Playground y vamos a mancharnos las manos.

El empleado sin sueldo

Tienes la función employeeById que te da acceso a la base de datos de los empleados de una compañía según su ID. Por desgracia, la función devuelve un tipo Employee?, por lo que el resultado puede ser null si el empleado no existe. Tu objetivo es escribir el código de la función salaryById(id: Int) para que devuelva el salario del empleado si lo encuentra, o el valor 0 si el empleado ha desaparecido de los registros.

data class Employee(val name: String, var salary: Int)

fun employeeById(id: Int) = when(id) {
    1 -> Employee("Mary", 20)
    2 -> null
    3 -> Employee("John", 21)
    4 -> Employee("Ann", 23)
    else -> null
}

fun salaryById(id: Int): Int {
    // Escribe tu código aquí (Pista: usa llamada segura + Elvis)
}

fun main() {
    // Esto debería imprimir la suma total de los salarios existentes (64)
    println((1..5).sumOf { id -> salaryById(id) })
}

El formulario de contacto

Imagina que estás programando un formulario de registro. Crea una función llamada obtenerLongitudTelefono que reciba un número de teléfono (String?) que puede ser nulo. Utiliza una llamada segura y el operador Elvis en una sola línea para devolver el número de caracteres del teléfono, o 0 si el usuario decidió no teclear ninguno.

// Escribe aquí tu función obtenerLongitudTelefono

fun main() {
    val telefono1: String? = "654321987"
    val telefono2: String? = null

    println(obtenerLongitudTelefono(telefono1)) // Debería imprimir 9
    println(obtenerLongitudTelefono(telefono2)) // Debería imprimir 0
}

La cadena de mando

A veces los objetos contienen a otros objetos que, a su vez, también pueden ser nulos. Declara tres data classes:

  1. Jefe (con una propiedad nombre de tipo String).
  2. Departamento (con una propiedad jefe de tipo Jefe?).
  3. Empresa (con una propiedad departamento de tipo Departamento?).

Luego, en tu función main(), crea una instancia de Empresa donde el departamento sea null. Usando llamadas seguras encadenadas, intenta recuperar el nombre del jefe. Si en algún momento la cadena se rompe por un nulo, haz que se asigne automáticamente el texto «Sin jefe asignado» usando el operador Elvis. Imprime el resultado final.

// Escribe aquí tus clases

fun main() {
    // 1. Crea una Empresa con departamento = null
    // 2. Encadena las llamadas e imprime el resultado
}

El sistema de alertas

Tienes un mensaje de alerta opcional. Usando la llamada segura ?. junto a la función let, haz que el sistema imprima en mayúsculas (usando el método .uppercase()) el texto de la alerta solo y exclusivamente si este no es nulo.

fun main() {
    val alertaActiva: String? = "Peligro de sobrecalentamiento en el núcleo"
    val alertaApagada: String? = null

    // Escribe aquí tu código usando let para procesar alertaActiva
    
    // Y luego repite exactamente lo mismo para alertaApagada 
    // (este segundo bloque no debería imprimir nada por consola)
}

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con tu editor de código y el compilador te haya gritado un par de veces.

El empleado sin sueldo

data class Employee(val name: String, var salary: Int)

fun employeeById(id: Int) = when(id) {
    1 -> Employee("Mary", 20)
    2 -> null
    3 -> Employee("John", 21)
    4 -> Employee("Ann", 23)
    else -> null
}

// Usamos la llamada segura '?.salary' acoplada al valor de respaldo '?: 0'
fun salaryById(id: Int): Int = employeeById(id)?.salary ?: 0

fun main() {
    println((1..5).sumOf { id -> salaryById(id) })
}

El formulario de contacto

fun obtenerLongitudTelefono(telefono: String?): Int {
    return telefono?.length ?: 0
}

fun main() {
    val telefono1: String? = "654321987"
    val telefono2: String? = null

    println(obtenerLongitudTelefono(telefono1)) 
    println(obtenerLongitudTelefono(telefono2)) 
}

La cadena de mando

data class Jefe(val nombre: String)
data class Departamento(val jefe: Jefe?)
data class Empresa(val departamento: Departamento?)

fun main() {
    // Creamos nuestra empresa pasándole un departamento nulo
    val miEmpresa = Empresa(departamento = null)

    // Encadenamos con '?.'. Como el departamento es null, salta directamente al Elvis
    val nombreDelJefe = miEmpresa.departamento?.jefe?.nombre ?: "Sin jefe asignado"
    
    println(nombreDelJefe)
}

El sistema de alertas

fun main() {
    val alertaActiva: String? = "Peligro de sobrecalentamiento en el núcleo"
    val alertaApagada: String? = null

    alertaActiva?.let { mensaje ->
        println(mensaje.uppercase())
    }

    // Como esta variable es null, la ejecución ignorará todo el bloque let
    alertaApagada?.let { mensaje ->
        println(mensaje.uppercase())
    }
}

Clases y objetos en Kotlin: Modela tus datos con elegancia

Introducción

Si has llegado hasta aquí, ya sabes cómo manejar variables, colecciones, controlar el flujo de tu programa y crear funciones o lambdas para organizar tus acciones. ¡Enhorabuena! Tienes unas bases de programación fabulosas. Pero ahora toca dar el siguiente paso lógico y entrar de lleno en la Programación Orientada a Objetos (POO).

Imagina que en tu aplicación tienes que gestionar la información de decenas de usuarios: su nombre, su identificador, su correo y su edad. ¿Vas a usar variables sueltas por todo el código para cada cosita de cada usuario? Sería un absoluto caos y muy propenso a errores. Para eso están las clases y los objetos. En esta unidad, vamos a ver cómo Kotlin hace que crear y agrupar estructuras de datos sea un proceso muy limpio, y descubriremos la magia de las data classes (clases de datos), una de las características estrella del lenguaje que te ahorrará horas de escribir código.

¿Qué son las clases y cómo se declaran?

En Kotlin, la programación orientada a objetos es muy directa. Una clase te permite declarar un conjunto de características compartidas para un concepto. Piensa en la clase como si fuera el «molde» (por ejemplo, el concepto de Cliente) y en los objetos como las «galletas» reales que salen de ese molde (el cliente Juan, la clienta María).

Para declarar una clase, simplemente utilizamos la palabra reservada class:

class Cliente

Propiedades de una clase

Las características concretas de ese molde se llaman propiedades. Puedes declarar las propiedades de una clase directamente entre paréntesis () justo después del nombre de la misma. A esta parte se le conoce como el encabezado de la clase (class header).

class Contacto(val id: Int, var email: String)

También puedes definir propiedades adicionales dentro del cuerpo de la clase, abriendo unas llaves {}:

class Contacto(val id: Int, var email: String) {
    val categoria: String = "trabajo"
}

La regla de oro: Te recomendamos encarecidamente que declares tus propiedades como de solo lectura (val) siempre que sea posible. Usa variables mutables (var) únicamente si tienes la absoluta certeza de que ese dato necesitará cambiar después de haber creado el objeto.

Al igual que ocurría con las funciones, las propiedades de una clase pueden tener valores por defecto:

class Contacto(val id: Int, var email: String = "[email protected]") {
    val categoria: String = "trabajo"
}

El dato: Si pones parámetros en el encabezado sin escribir val o var delante, Kotlin los tratará como simples parámetros de configuración y no como propiedades reales de la clase; por lo tanto, no podrás acceder a ellos después. ¡Acuérdate de poner siempre val o var!

Nota extra: Kotlin te permite poner una coma al final de la última propiedad (conocido como trailing comma) para que te sea más fácil reordenar el código copiando y pegando líneas.

Crear instancias (objetos)

Para poder usar esa clase en la vida real, necesitas instanciarla (crear un objeto) a través de un constructor. En lenguajes más antiguos como Java tendrías que usar la palabra new a la fuerza, pero a Kotlin le gusta el código conciso, así que no hace falta.

Por defecto, el lenguaje te crea un constructor automáticamente exigiendo los parámetros que hayas puesto en el encabezado.

class Contacto(val id: Int, var email: String)

fun main() {
    // Creamos la instancia y la guardamos en una variable
    val contacto1 = Contacto(1, "[email protected]")
}

En este ejemplo:

  • Contacto es la clase (el molde).
  • contacto1 es la instancia (el objeto real en la memoria).

Acceder a las propiedades

Para leer o modificar una propiedad de un objeto que ya has creado, la sintaxis es facilísima: solo tienes que escribir el nombre de la instancia, seguido de un punto ., y el nombre de la propiedad.

fun main() {
    val contacto = Contacto(1, "[email protected]")
    
    // Imprimimos el valor de la propiedad
    println(contacto.email) 
    // Salida: [email protected]

    // Actualizamos el valor (es posible porque lo definimos como 'var')
    contacto.email = "[email protected]"
    
    // Imprimimos el nuevo valor
    println(contacto.email) 
    // Salida: [email protected]
}

Si necesitas concatenar el valor de esa propiedad dentro de un texto, recuerda usar las plantillas de cadenas con el símbolo del dólar. Como estás accediendo a una propiedad y no a una variable simple, es obligatorio envolverlo todo entre llaves ${}:

println("Su correo electrónico de empresa es: ${contacto.email}")

Funciones miembro

Las clases no son simples «bolsas» para guardar datos estáticos. También pueden tener comportamiento propio. A las funciones que declaramos dentro del cuerpo de una clase se las denomina funciones miembro (o métodos).

Para llamar a estas funciones, volvemos a usar la sintaxis del punto .:

class Contacto(val id: Int, var email: String) {
    // Esta es una función miembro
    fun imprimirId() {
        println("El identificador secreto es: $id")
    }
}

fun main() {
    val contacto = Contacto(1, "[email protected]")
    // Llamamos a la función
    contacto.imprimirId() 
    // Salida: El identificador secreto es: 1
}

El superpoder de Kotlin: data classes

Aquí es donde Kotlin brilla y demuestra por qué es un lenguaje tan popular. En el día a día, muchas veces vas a crear clases cuyo único propósito sea almacenar información de forma pasiva. Para estos casos concretos, Kotlin inventó las data classes (clases de datos).

Tienen exactamente la misma funcionalidad que las clases normales que acabamos de ver, pero el compilador de Kotlin les inyecta automáticamente por detrás un montón de funciones extra para que no tengas que escribir código repetitivo o boilerplate.

Para declararlas, solo tienes que poner la palabra data antes del class:

data class Usuario(val nombre: String, val id: Int)

Al hacer esto, ganas acceso automático a tres funciones vitales que analizamos en los siguientes puntos.

Imprimir como texto (toString)

Si imprimes una clase normal de Kotlin por la consola, te escupirá un nombre incomprensible con su referencia en memoria (algo tipo Usuario@2f92e0f4). Pero las data classes sobrescriben la función toString() de forma nativa para que la salida sea humana y legible.

fun main() {
    val usuario = Usuario("Alex", 1)
    
    println(usuario) 
    // Salida automática y bonita: Usuario(nombre=Alex, id=1)
}

Esto es indispensable cuando estás haciendo pruebas o guardando logs en tu servidor.

Comparar instancias (==)

Si tú comparas dos instancias distintas de una clase normal usando ==, Kotlin te dirá que son diferentes false, porque mirará si ocupan el mismo espacio físico en la memoria del ordenador.

Sin embargo, en las data classes, el operador == (que llama a la función equals()) es inteligente: compara los datos reales.

fun main() {
    val user1 = Usuario("Alex", 1)
    val user2 = Usuario("Alex", 1)
    val user3 = Usuario("Max", 2)

    println("user1 y user2: ${user1 == user2}") // true (¡Tienen los mismos datos!)
    println("user1 y user3: ${user1 == user3}") // false
}

Copiar instancias (copy)

Imagina que quieres crear un objeto nuevo partiendo de uno existente, pero alterando únicamente uno de sus datos. Modificar el objeto original usando un var puede causar fallos encadenados si otra parte de tu código estaba usando a ese mismo usuario.

La función copy() te clona el objeto a la perfección, y como parámetros opcionales le puedes pasar el nuevo valor para las propiedades que quieras alterar:

fun main() {
    val usuario = Usuario("Alex", 1)

    // Clonación exacta
    println(usuario.copy()) 
    // Usuario(nombre=Alex, id=1)

    // Clonación con el nombre alterado
    println(usuario.copy(nombre = "Max")) 
    // Usuario(nombre=Max, id=1)
}

Ejercicios

Aquí tienes varios ejercicios de menos a más dificultad (los tres primeros son traducciones de la documentación oficial de Kotlin y los dos últimos son extras para asentar los conceptos). Abre tu Kotlin Playground y vamos a mancharnos las manos.

El empleado

Define una data class llamada Employee con dos propiedades: name (para el nombre en formato texto) y salary (para el salario en enteros). Asegúrate de que la propiedad del salario sea mutable, ¡o de lo contrario el empleado jamás podrá recibir un aumento a final de año!

El main a continuación te muestra cómo lo vamos a usar.

// Escribe tu código aquí

fun main() {
    val emp = Employee("Mary", 20)
    println(emp)
    emp.salary += 10
    println(emp)
}

Estructuras anidadas

A veces los objetos contienen a otros objetos. Declara las data classes adicionales que se necesitan para que el siguiente código compile sin errores. Fíjate bien en la llamada dentro de main() para adivinar qué propiedades y de qué tipo necesita cada clase.

data class Person(val name: Name, val address: Address, val ownsAPet: Boolean = true)
// Escribe tu código aquí:
// data class Name(...)

fun main() {
    val person = Person(
        Name("John", "Smith"),
        Address("123 Fake Street", City("Springfield", "US")),
        ownsAPet = false
    )
    println(person)
}

Generador aleatorio de empleados

Para probar tu código en el futuro, vas a necesitar un generador que cree empleados al azar. Define una clase normal RandomEmployeeGenerator.

El constructor de la clase debe recibir el minSalary y el maxSalary (ambos enteros y mutables).

Dentro del cuerpo de la clase, guarda una lista fija de nombres potenciales. Y, por último, crea una función miembro llamada generateEmployee() que devuelva una nueva instancia de tu data class Employee usando un nombre aleatorio de tu lista y un salario aleatorio dentro de los márgenes dados.

Pista: Las listas de Kotlin tienen una extensión muy útil llamada .random() que te devuelve un ítem al azar. Usa Random.nextInt(from = minSalary, until = maxSalary) para generar el número.

import kotlin.random.Random

data class Employee(val name: String, var salary: Int)

// Escribe tu clase RandomEmployeeGenerator aquí

fun main() {
    val empGen = RandomEmployeeGenerator(10, 30)
    println(empGen.generateEmployee())
    println(empGen.generateEmployee())
    
    empGen.minSalary = 50
    empGen.maxSalary = 100
    println(empGen.generateEmployee())
}

El inventario de la tienda

Crea una clase (¡normal, no data class!) llamada Producto que reciba en su constructor un nombre de tipo String y un precio de tipo Double (ambos inmutables).

Añade una propiedad dentro del cuerpo de la clase llamada stock de tipo entero, inicializada en 0, que por supuesto sea mutable.

Crea una función miembro llamada añadirStock(cantidad: Int) que le sume esa cantidad al stock actual.

En tu función main, crea un producto, añádele 50 unidades de stock, e imprime un texto final diciendo: «Tenemos [stock] unidades de [nombre]».

Clonando clientes

Tienes una data class Cliente(val id: Int, val nombre: String, val email: String). Fíjate en que todo está blindado y es inmutable (val).

Crea una instancia para «Ana» con ID 1 y correo «[email protected]». De repente, Ana te avisa de que ha cambiado su correo corporativo a «[email protected]».

Como no puedes hacer ana.email = ... porque daría error, usa la función de las data classes que hemos aprendido para crear un nuevo objeto llamado anaActualizada basado en Ana, pero con su correo modificado. Imprime por consola a anaActualizada.

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con tu editor de código y el compilador te haya gritado un par de veces.

El empleado

data class Employee(val name: String, var salary: Int)

fun main() {
    val emp = Employee("Mary", 20)
    println(emp)
    emp.salary += 10
    println(emp)
}

Estructuras anidadas

data class Person(val name: Name, val address: Address, val ownsAPet: Boolean = true)
data class Name(val first: String, val last: String)
data class Address(val street: String, val city: City)
data class City(val name: String, val countryCode: String)

fun main() {
    val person = Person(
        Name("John", "Smith"),
        Address("123 Fake Street", City("Springfield", "US")),
        ownsAPet = false
    )
    println(person)
}

Generador aleatorio de empleados

import kotlin.random.Random

data class Employee(val name: String, var salary: Int)

class RandomEmployeeGenerator(var minSalary: Int, var maxSalary: Int) {
    // Definimos la lista en el cuerpo de la clase
    val names = listOf("John", "Mary", "Ann", "Paul", "Jack", "Elizabeth")
    
    // Función miembro que devuelve un Employee
    fun generateEmployee() = Employee(
        names.random(), 
        Random.nextInt(from = minSalary, until = maxSalary)
    )
}

fun main() {
    val empGen = RandomEmployeeGenerator(10, 30)
    println(empGen.generateEmployee())
    println(empGen.generateEmployee())
    
    empGen.minSalary = 50
    empGen.maxSalary = 100
    println(empGen.generateEmployee())
}

El inventario de la tienda

class Producto(val nombre: String, val precio: Double) {
    // Propiedad en el cuerpo inicializada a 0
    var stock: Int = 0
    
    fun añadirStock(cantidad: Int) {
        stock += cantidad
    }
}

fun main() {
    val miProducto = Producto("Teclado Mecánico", 45.99)
    miProducto.añadirStock(50)
    
    println("Tenemos ${miProducto.stock} unidades de ${miProducto.nombre}")
}

Clonando clientes

data class Cliente(val id: Int, val nombre: String, val email: String)

fun main() {
    // Creamos nuestra instancia original
    val ana = Cliente(1, "Ana", "[email protected]")
    
    // Clonamos el objeto modificando únicamente la propiedad email
    val anaActualizada = ana.copy(email = "[email protected]")
    
    // Comprobamos la magia
    println(anaActualizada)
    // Salida: Cliente(id=1, nombre=Ana, [email protected])
}

Funciones y lambdas en Kotlin: Crea código reutilizable y elegante

Introducción

Si has llegado hasta aquí, ya sabes cómo crear variables, manejar distintos tipos de datos, organizar información en colecciones y controlar el flujo de tu programa con condicionales y bucles. ¡Enhorabuena! Tienes las bases. Pero ahora toca dar el siguiente paso lógico en la programación: las funciones.

Imagina que en tu código tienes que hacer un cálculo complejo o mostrar un mensaje específico docenas de veces a lo largo del programa. ¿Vas a copiar y pegar el mismo bloque de código una y otra vez? Para eso están las funciones. En esta unidad, vamos a ver cómo Kotlin hace que declarar y usar funciones sea un proceso muy limpio, adentrándonos por último en el fascinante mundo de las expresiones lambda, una de las características más apreciadas por los desarrolladores modernos.

¿Qué son las funciones y cómo se declaran?

En Kotlin, puedes declarar tus propias funciones utilizando la palabra reservada fun (muy apropiado, ¿verdad? ¡Programar en Kotlin es divertido!). Una función no es más que un bloque de código al que le damos un nombre y que realiza una tarea concreta.

fun sumar(x: Int, y: Int): Int {
    return x + y
}

fun main() {
    println(sumar(1, 2))
    // Salida: 3
}

Analizando el código paso a paso:

  • fun: La palabra clave mágica para decirle a Kotlin que vamos a crear una función.
  • Los parámetros: Se escriben siempre entre paréntesis (). Cada parámetro debe tener un tipo de dato explícito (en este caso x e y son de tipo Int), y si hay varios, se separan por comas. Son los datos que la función necesita para poder trabajar.
  • El tipo de retorno: Después de los paréntesis de los parámetros y separado por dos puntos :, indicamos qué tipo de dato va a devolver la función como resultado final. En este caso, devolverá un número entero (Int).
  • El cuerpo de la función: Es todo lo que va dentro de las llaves {}.
  • return: Usamos esta palabra para escupir o devolver el resultado de nuestro cálculo y dar por terminada la ejecución de la función.

El dato: Las convenciones de código oficiales de Kotlin recomiendan nombrar las funciones empezando con minúscula y usando camelCase (por ejemplo, calcularPrecioTotal, imprimirUsuario), sin usar guiones bajos.

Parámetros nombrados

A veces, cuando llamas a una función que tiene muchísimos parámetros, el código puede volverse un poco lioso a simple vista. ¿Qué era ese true o ese 5 que le pasábamos a la función en la tercera posición? Kotlin nos permite usar parámetros nombrados.

Si incluyes el nombre del parámetro explícitamente al llamar a la función, no solo haces que tu código sea muchísimo más fácil de leer, sino que además puedes poner los parámetros en el orden que quieras.

fun imprimirMensaje(mensaje: String, prefijo: String) {
    println("[$prefijo] $mensaje")
}

fun main() {
    // Usamos argumentos nombrados y además invertimos el orden
    imprimirMensaje(prefijo = "LOG", mensaje = "Sistema iniciado correctamente")
    // Resultado: [LOG] Sistema iniciado correctamente
}

Valores por defecto

En lenguajes más antiguos, si a veces querías llamar a una función pasándole tres parámetros y otras veces solo dos, tenías que crear varias versiones de la misma función (esto se conoce como sobrecarga de métodos).

En Kotlin, te ahorras todo ese trabajo. Puedes definir valores por defecto para los parámetros utilizando el operador de asignación =. Si al llamar a la función omites ese parámetro, Kotlin utilizará automáticamente el valor por defecto que configuraste.

fun imprimirMensaje(mensaje: String, prefijo: String = "INFO") {
    println("[$prefijo] $mensaje")
}

fun main() {
    // Llamamos a la función pasándole ambos parámetros
    imprimirMensaje("Usuario conectado", "LOG") 
    // Resultado: [LOG] Usuario conectado
    
    // Llamamos a la función pasándole SOLO el mensaje. ¡Usa el prefijo por defecto!
    imprimirMensaje("El proceso ha finalizado") 
    // Resultado: [INFO] El proceso ha finalizado
}

La regla de oro: Puedes saltarte parámetros concretos que tengan valores por defecto, pero si omites uno y quieres pasar el siguiente, tendrás que usar obligatoriamente parámetros nombrados para los que pongas a continuación.

Funciones que no devuelven nada

Si tu función simplemente realiza una acción (como imprimir un texto por la consola, reproducir un sonido o guardar un dato en la base de datos) pero no necesita devolverte ningún resultado matemático o lógico útil, su tipo de retorno en Kotlin es Unit (el equivalente al void en Java o C).

La maravilla de Kotlin es que no hace falta que escribas Unit ni que pongas un return. El compilador ya lo sabe.

fun saludar(nombre: String) {
    println("¡Hola, $nombre!")
    // Escribir `: Unit` arriba junto a los paréntesis o `return Unit` aquí abajo es totalmente opcional (y casi nadie lo hace).
}

Funciones de una sola expresión

Para hacer tu código aún más conciso y espectacular, si tu función consta de una única instrucción que devuelve un valor, puedes ahorrarte de un plumazo las llaves {} y la palabra return. Solo tienes que usar el signo igual =.

Tomemos la función sumar del principio:

fun sumar(x: Int, y: Int): Int {
    return x + y
}

Se puede transformar en esta maravilla de una sola línea:

fun sumar(x: Int, y: Int) = x + y

Fíjate bien, ¡ni siquiera hemos puesto : Int! Como Kotlin cuenta con una característica llamada inferencia de tipos, deduce inmediatamente que si sumas dos Int, el resultado será forzosamente un Int.

(No obstante, si estás trabajando en equipo y quieres que otros programadores lean tu código rápidamente, nunca está de más indicar explícitamente el tipo de retorno: fun sumar(x: Int, y: Int): Int = x + y).

Retornos tempranos en funciones

En ocasiones, quieres salir de una función inmediatamente si se cumple (o no) una condición, sin necesidad de seguir ejecutando y procesando el resto del código. Para eso usamos la palabra return dentro de un condicional if.

val usuariosRegistrados = mutableListOf("juan_perez", "maria_lopez")

fun registrarUsuario(usuario: String): String {
    // Retorno temprano si el usuario ya existe en nuestra base de datos (lista)
    if (usuario in usuariosRegistrados) {
        return "Error: El nombre de usuario ya está pillado. Elige otro."
    }

    // Si no hemos entrado en el if anterior, la función continúa de forma normal
    usuariosRegistrados.add(usuario)
    return "¡Usuario $usuario registrado con éxito!"
}

fun main() {
    println(registrarUsuario("juan_perez"))
    // Salida: Error: El nombre de usuario ya está pillado. Elige otro.
    
    println(registrarUsuario("nuevo_user"))
    // Salida: ¡Usuario nuevo_user registrado con éxito!
}

Expresiones lambda

Kotlin te permite escribir funciones de forma todavía más compacta usando lo que conocemos como expresiones lambda. Básicamente, una lambda es una función anónima (sin nombre) que puedes guardar directamente en una variable o pasarla como si fuera un parámetro más a otras funciones.

Mira esta función normal:

fun aMayusculas(texto: String): String {
    return texto.uppercase()
}

Podemos escribir exactamente lo mismo como una expresión lambda y guardarla en una variable:

val aMayusculasLambda = { texto: String -> texto.uppercase() }

fun main() {
    println(aMayusculasLambda("hola"))
    // Resultado: HOLA
}

Estructura de una lambda:

  • Se encapsula siempre entre llaves { }.
  • Primero van los parámetros (texto: String).
  • Luego una flecha ->.
  • Y finalmente el cuerpo de la función (lo que hace y lo que se devuelve de forma automática: texto.uppercase()).

Pasando lambdas a otras funciones

Esto resulta muy útil cuando trabajamos con colecciones (listas, sets, mapas…). Multitud de funciones nativas de colecciones reciben una lambda para saber exactamente qué deben hacer con cada uno de los elementos.

El mejor ejemplo es usar .filter() (para filtrar elementos en base a una condición) o .map() (para transformar los elementos de una lista en otros):

fun main() {
    val numeros = listOf(1, -2, 3, -4, 5, -6)
    
    // Quedarnos solo con los positivos. La lambda comprueba si el número es mayor que 0.
    val positivos = numeros.filter({ x -> x > 0 })
    println(positivos) // [1, 3, 5]
    
    // Multiplicar todos los elementos de la lista por 2 usando map
    val dobles = numeros.map({ x -> x * 2 })
    println(dobles) // [2, -4, 6, -8, 10, -12]
}

Si la lambda es el último o el único parámetro que le pasas a una función (como pasa arriba con filter y map), Kotlin te permite sacarla fuera de los paréntesis (). E incluso puedes omitir los paréntesis por completo si es el único argumento.

Reescribiendo lo anterior de forma idiomática:

val positivos = numeros.filter { x -> x > 0 }

Tipos de funciones

Al igual que una variable puede ser de tipo String o Int, ¡las funciones también tienen su propio tipo! La sintaxis para definir el tipo de una función consiste en poner los parámetros de entrada entre paréntesis y, tras una flecha ->, el tipo que va a devolver.

  • (String) -> String (recibe un texto, devuelve un texto)
  • (Int, Int) -> Int (recibe dos enteros, devuelve un entero)
  • () -> Unit (no recibe absolutamente nada, no devuelve nada útil)

Ejemplo declarando el tipo de forma explícita en una variable:

val sumarLambda: (Int, Int) -> Int = { a, b -> a + b }

Ejercicios

Aquí tienes varios ejercicios de menos a más dificultad (algunos adaptados de la documentación oficial y otros extra para asentar todo lo aprendido). Abre tu Kotlin Playground y vamos a practicar lo aprendido.

El área del círculo

Escribe una función normal llamada areaCirculo que reciba el radio de un círculo en formato entero (Int) y devuelva el área de ese círculo (con decimales).

Pista: Vas a necesitar importar la constante Pi desde kotlin.math.PI. La fórmula es PI * radio * radio.

import kotlin.math.PI
// Escribe tu función aquí

fun main() {
    println(areaCirculo(2)) // Debería dar aprox 12.566...
}

El área del círculo en una sola línea

Reescribe la función areaCirculo del ejercicio anterior pero simplifícala para convertirla en una función de una sola expresión (Single-expression function), eliminando las llaves y el return.

Pasando el tiempo a segundos

Tienes una función que traduce un intervalo de tiempo (horas, minutos y segundos) a segundos totales. La inmensa mayoría de las veces, solo vas a querer pasarle un parámetro y que el resto sean cero por defecto.

Mejora la función y las llamadas en el main utilizando valores por defecto y argumentos nombrados para que el código quede impecable y súper legible.

// Mejora esta función
fun intervaloEnSegundos(horas: Int, minutos: Int, segundos: Int) =
    ((horas * 60) + minutos) * 60 + segundos

fun main() {
    // Mejora estas llamadas
    println(intervaloEnSegundos(1, 20, 15))
    println(intervaloEnSegundos(0, 1, 25))
    println(intervaloEnSegundos(2, 0, 0))
    println(intervaloEnSegundos(0, 10, 0))
    println(intervaloEnSegundos(1, 0, 1))
}

Generador de URLs

Tienes una lista de acciones de una API, un prefijo de una web y el ID de un recurso. Utilizando la función de colección .map() y una expresión lambda, crea una nueva lista de URLs que combinen los tres elementos para formar rutas completas (ejemplo esperado: https://ejemplo.com/libro/5/titulo).

fun main() {
    val acciones = listOf("titulo", "anyo", "autor")
    val prefijo = "https://ejemplo.com/libro"
    val id = 5
    
    val urls = // ¡Escribe aquí tu map con lambda!
    
    println(urls)
}

Repetidor de acciones

Escribe una función llamada repetirN que reciba un número entero n y una acción (una función de tipo () -> Unit). La función debe repetir esa acción el número de veces indicado utilizando un bucle clásico. Luego, en el main, úsala (usando una trailing lambda) para imprimir "¡Me encanta Kotlin!" 5 veces.

El saludo personalizado

Crea una función llamada saludoPersonalizado que reciba un nombre (String), una edad (Int) y una ciudad (String). El parámetro ciudad debe tener el valor por defecto "Madrid".

La función debe usar un println() para mostrar: "Hola, me llamo [nombre], tengo [edad] años y vivo en [ciudad].".

Luego, en el main, llama a la función de dos formas distintas:

  1. Pasando solo nombre y edad.
  2. Pasando nombre, edad y ciudad, pero utilizando argumentos nombrados en un orden distinto al original.

Filtrando palabras largas

Dada la siguiente lista de palabras: val palabras = listOf("sol", "murciélago", "pan", "ornitorrinco", "luz", "desarrollador")

Utiliza la función .filter() y una trailing lambda para crear una nueva lista que solo contenga las palabras que tengan estrictamente más de 5 letras. (Pista: Los textos o Strings en Kotlin tienen una propiedad llamada .length que te dice lo largos que son). Imprime el resultado final.

Soluciones a los ejercicios

No hagas trampas. Échale un vistazo a las soluciones únicamente cuando te hayas peleado un buen rato con el código.

El área del círculo

import kotlin.math.PI

fun areaCirculo(radio: Int): Double {
    return PI * radio * radio
}

fun main() {
    println(areaCirculo(2)) 
    // 12.566370614359172
}

El área del círculo en una sola línea

import kotlin.math.PI

// Quitamos llaves y return, y usamos el signo '='. Mantener el ': Double' es buena práctica.
fun areaCirculo(radio: Int): Double = PI * radio * radio

fun main() {
    println(areaCirculo(2))
}

Pasando el tiempo a segundos

// Asignamos '= 0' a todos los parámetros para darles un valor por defecto
fun intervaloEnSegundos(horas: Int = 0, minutos: Int = 0, segundos: Int = 0) =
    ((horas * 60) + minutos) * 60 + segundos

fun main() {
    println(intervaloEnSegundos(1, 20, 15)) // Este lo pasamos normal
    println(intervaloEnSegundos(minutos = 1, segundos = 25)) // Omitimos las horas
    println(intervaloEnSegundos(horas = 2)) // Omitimos minutos y segundos
    println(intervaloEnSegundos(minutos = 10))
    println(intervaloEnSegundos(horas = 1, segundos = 1))
}

Generador de URLs

fun main() {
    val acciones = listOf("titulo", "anyo", "autor")
    val prefijo = "https://ejemplo.com/libro"
    val id = 5
    
    // Usamos map para transformar cada elemento. Recuerda usar plantillas de texto ($) para fusionarlo todo.
    val urls = acciones.map { accion -> "$prefijo/$id/$accion" }
    
    println(urls)
    // [https://ejemplo.com/libro/5/titulo, https://ejemplo.com/libro/5/anyo, https://ejemplo.com/libro/5/autor]
}

Repetidor de acciones

// El tipo de la acción es () -> Unit porque es un bloque de código que no recibe nada y no devuelve nada útil.
fun repetirN(n: Int, accion: () -> Unit) {
    for (i in 1..n) {
        accion()
    }
}

fun main() {
    // Como la acción es el último parámetro, podemos sacar las llaves (trailing lambda)
    repetirN(5) {
        println("¡Me encanta Kotlin!")
    }
}

El saludo personalizado

fun saludoPersonalizado(nombre: String, edad: Int, ciudad: String = "Madrid") {
    println("Hola, me llamo $nombre, tengo $edad años y vivo en $ciudad.")
}

fun main() {
    // Llamada 1: usamos el valor por defecto para ciudad
    saludoPersonalizado("Carlos", 28)
    
    // Llamada 2: usamos argumentos nombrados alterando el orden natural
    saludoPersonalizado(ciudad = "Valencia", edad = 32, nombre = "Laura")
}

Filtrando palabras largas

fun main() {
    val palabras = listOf("sol", "murciélago", "pan", "ornitorrinco", "luz", "desarrollador")
    
    // filter con trailing lambda. Solo conservamos la palabra si su longitud es mayor a 5.
    val palabrasLargas = palabras.filter { palabra -> palabra.length > 5 }
    
    println(palabrasLargas)
    // [murciélago, ornitorrinco, desarrollador]
}