Construcción de aplicaciones móviles

Universidad del Quindío
Programa de Ingeniería de Sistemas y Computación
Título: Programación funcional en Kotlin
Docente: Carlos Andrés Florez V.

Programación Funcional en Kotlin

Introducción

Existen varios paradigmas de programación, cada uno con sus propias filosofías y enfoques para resolver problemas. Podemos clasificar los paradigmas de programación en dos grandes categorías: imperativos y declarativos.

Los paradigmas imperativos, como la programación estructurada y la programación orientada a objetos, se centran en describir cómo se debe realizar una tarea mediante una serie de instrucciones que modifican el estado del programa. En contraste, los paradigmas declarativos, como la programación funcional y la programación lógica, se enfocan en describir qué se quiere lograr sin especificar los pasos exactos para hacerlo.

La programación de aplicaciones móviles modernas usan el paradigma declarativo y funcional para mejorar la legibilidad, mantenibilidad y escalabilidad del código. Kotlin, el lenguaje oficial para el desarrollo de Android, soporta plenamente la programación funcional, permitiendo a los desarrolladores escribir código más limpio y eficiente.

Pilares de la Programación Funcional

La programación funcional se basa en varios pilares fundamentales que la diferencian de otros paradigmas como la programación imperativa u orientada a objetos. A continuación, se describen estos pilares con ejemplos prácticos en Kotlin.

1. Inmutabilidad

Los datos no cambian una vez que se crean. En lugar de modificar estructuras existentes, se crean nuevas versiones con los cambios aplicados. Esto reduce errores relacionados con efectos secundarios y hace el código más predecible.

Ejemplo básico:

// Inmutable - usando val
val lista = listOf(1, 2, 3)
val nuevaLista = lista + 4 // Crea una nueva lista

// Mutable
var listaMutable = mutableListOf(1, 2, 3)
listaMutable.add(4) // Modifica la lista original

Data classes inmutables:

data class Usuario(val nombre: String, val edad: Int)
val usuario = Usuario("Juan", 25)
val usuarioActualizado = usuario.copy(edad = 26) // Nueva instancia

2. Funciones Puras

Una función es pura si:

Esto facilita la depuración y las pruebas unitarias.

Función pura:

fun sumar(a: Int, b: Int): Int = a + b // Siempre devuelve lo mismo
fun calcularArea(radio: Double): Double = Math.PI * radio * radio

Función impura (evitar):

var contador = 0
fun incrementarContador(): Int {
    contador++ // Efecto secundario
    return contador
}

El ejemplo anterior muestra una función que modifica una variable externa, lo que la hace impura, a esta característica se le conoce como efecto secundario.

Un efecto secundario es cualquier cambio de estado que ocurre fuera del ámbito de una función. Esto incluye modificar variables globales, interactuar con bases de datos, escribir en archivos o imprimir en la consola.

Ejemplos de efectos secundarios:

Un lenguaje funcional busca minimizar o eliminar los efectos secundarios para mejorar la predictibilidad y la facilidad de razonamiento sobre el código.

3. Expresiones Lambda

Similar a las lambdas de Java 8+, pero con una sintaxis más concisa. La diferencia principal es que en Kotlin las lambdas van entre llaves {}, usan -> para separar parámetros del cuerpo, y cuando hay un solo parámetro se puede usar la referencia implícita it:

val lambda = { x: Int, y: Int -> x + y }
val resultado = lambda(5, 3) // 8

// Lambda con un solo parámetro (it implícito)
val numeros = listOf(1, 2, 3, 4, 5)
val pares = numeros.filter { it % 2 == 0 }

4. Funciones de Orden Superior

Las funciones pueden recibir otras funciones como parámetros o devolver funciones como resultado. Esto permite crear abstracciones más poderosas y reutilizables.

Recibir función como parámetro:

fun aplicarOperacion(a: Int, b: Int, operacion: (Int, Int) -> Int): Int {
    return operacion(a, b)
}

val suma = aplicarOperacion(5, 3) { x, y -> x + y }
val multiplicacion = aplicarOperacion(5, 3) { x, y -> x * y }

Devolver una función:

fun crearMultiplicador(factor: Int): (Int) -> Int {
    return { numero -> numero * factor }
}

val duplicar = crearMultiplicador(2)
val triplicar = crearMultiplicador(3)

println(duplicar(5)) // 10
println(triplicar(5)) // 15

5. Evaluación Perezosa (Lazy Evaluation)

Los valores y expresiones solo se calculan cuando realmente se necesitan. Permite trabajar con estructuras potencialmente infinitas y optimiza el rendimiento.

Lazy property:

class ExpensiveResource {
    val data: String by lazy {
        println("Calculando datos...")
        "Datos calculados" // Solo se ejecuta la primera vez
    }
}

Sequences para evaluación perezosa:

Kotlin proporciona la clase Sequence que permite trabajar con colecciones de manera perezosa. Las operaciones sobre secuencias no se ejecutan hasta que se necesita el resultado final, lo que puede mejorar el rendimiento y reducir el uso de memoria.

val numeros = generateSequence(1) { it + 1 } // Secuencia infinita
val primerosCinco = numeros.take(5).toList() // Solo calcula los primeros 5

// Vs eager evaluation con listas
val numerosEager = (1..1000000).map { it * 2 }.filter { it > 100 } // Calcula todo de una vez
val numerosLazy = (1..1000000).asSequence().map { it * 2 }.filter { it > 100 }.take(10).toList() // Calcula solo lo necesario

println(numerosEager.size) // Procesa toda la lista antes de poder usarla
println(numerosLazy)       // Solo calcula los primeros 10 elementos que cumplan la condición

6. Composición de Funciones

En lugar de usar estructuras de control tradicionales, se combinan funciones pequeñas para crear funciones más complejas.

Composición con funciones de extensión:

fun String.esPalindromo(): Boolean = this == this.reversed()
fun String.limpiar(): String = this.trim().lowercase()

fun validarPalabra(palabra: String): Boolean {
    return palabra.limpiar().esPalindromo()
}

Composición con operadores:

En el siguiente ejemplo, se muestra cómo componer varias funciones lambda para procesar un texto:

val procesarTexto = { texto: String ->
    texto.trim()
        .lowercase()
        .split(" ")
        .filter { it.isNotEmpty() }
        .map { it.replaceFirstChar { c -> c.uppercase() } }
        .joinToString(" ")
}

La función procesarTexto toma una cadena de texto, la limpia, la convierte a minúsculas, la divide en palabras, filtra las palabras vacías, capitaliza cada palabra y finalmente las une en una sola cadena.


Operaciones funcionales sobre colecciones

Son métodos que aplican los principios de programación funcional para trabajar con colecciones (listas, arrays, etc.) de manera más elegante y eficiente.

Características principales:

Las más importantes:

map - Transformación

val numeros = listOf(1, 2, 3, 4, 5)
val cuadrados = numeros.map { it * it } // [1, 4, 9, 16, 25]

filter - Filtrado

val pares = numeros.filter { it % 2 == 0 } // [2, 4]

reduce y fold - Reducción

val suma = numeros.reduce { acc, n -> acc + n } // 15
val sumaConInicial = numeros.fold(10) { acc, n -> acc + n } // 25

flatMap - Aplanamiento

val palabras = listOf("hola mundo", "kotlin funcional")
val todasLasPalabras = palabras.flatMap { it.split(" ") }
// ["hola", "mundo", "kotlin", "funcional"]

groupBy - Agrupamiento

data class Estudiante(val nombre: String, val carrera: String)

val estudiantes = listOf(
    Estudiante("Ana", "Sistemas"),
    Estudiante("Luis", "Civil"),
    Estudiante("María", "Sistemas"),
    Estudiante("Pedro", "Civil")
)
val porCarrera = estudiantes.groupBy { it.carrera }
// {Sistemas=[Ana, María], Civil=[Luis, Pedro]}

sortedBy y sortedByDescending - Ordenamiento

val nombres = listOf("Carlos", "Ana", "Beatriz", "David")
val ordenados = nombres.sortedBy { it.length }       // [Ana, David, Carlos, Beatriz]
val descendente = nombres.sortedByDescending { it }   // [David, Carlos, Beatriz, Ana]

any, all, none - Verificación

val numeros = listOf(1, 2, 3, 4, 5)
numeros.any { it > 3 }   // true - ¿alguno es mayor a 3?
numeros.all { it > 0 }   // true - ¿todos son mayores a 0?
numeros.none { it < 0 }  // true - ¿ninguno es menor a 0?

zip - Combinar dos listas

val nombres2 = listOf("Ana", "Luis", "María")
val edades = listOf(20, 22, 21)
val parejas = nombres2.zip(edades)
// [(Ana, 20), (Luis, 22), (María, 21)]

val presentaciones = nombres2.zip(edades) { nombre, edad -> "$nombre tiene $edad años" }
// [Ana tiene 20 años, Luis tiene 22 años, María tiene 21 años]

takeWhile y dropWhile

val numeros2 = listOf(1, 2, 3, 4, 5, 1, 2)
val tomados = numeros2.takeWhile { it < 4 }  // [1, 2, 3] - toma mientras se cumpla
val restantes = numeros2.dropWhile { it < 4 } // [4, 5, 1, 2] - descarta mientras se cumpla

Encadenamiento de operaciones

En la práctica, las operaciones funcionales se encadenan para resolver problemas complejos de forma declarativa:

data class Venta(val vendedor: String, val producto: String, val monto: Double)

val ventas = listOf(
    Venta("Ana", "Laptop", 2500.0),
    Venta("Luis", "Mouse", 50.0),
    Venta("Ana", "Teclado", 150.0),
    Venta("Luis", "Monitor", 800.0),
    Venta("Ana", "USB", 20.0)
)

val resumen = ventas
    .filter { it.monto > 100 }
    .groupBy { it.vendedor }
    .map { (vendedor, ventas) ->
        "$vendedor: ${ventas.size} ventas por $${ventas.sumOf { it.monto }}"
    }
    .sortedBy { it }

// [Ana: 2 ventas por $2650.0, Luis: 1 ventas por $800.0]

Funciones de Ámbito en Kotlin

Kotlin incluye varias funciones de alcance: apply, let, also, with, run que simplifican la escritura de código más conciso, legible y expresivo.

Estas funciones permiten ejecutar bloques de código en un contexto determinado, evitando variables temporales innecesarias, facilitando la inicialización de objetos y promoviendo un estilo de programación más funcional.

Por ejemplo, considere el siguiente código para inicializar un objeto Persona:

data class Persona(val nombre: String, val edad: Int)

apply

Se usa para configurar e inicializar objetos. El objeto receptor es this dentro del bloque. Retorna el mismo objeto, lo que permite encadenar configuraciones. Es especialmente útil con objetos mutables:

val configuracion = StringBuilder().apply {
    append("Nombre: Juan")
    append(", Edad: 30")
}
println(configuracion) // Nombre: Juan, Edad: 30

let

Se usa para ejecutar un bloque de código con un objeto no nulo. El objeto receptor es it dentro del bloque.

val nombre: String? = "Carlos"
nombre?.let {
    println("El nombre es $it") // Solo se ejecuta si nombre no es nulo
}

also

Se usa para realizar acciones adicionales con un objeto sin modificarlo. El objeto receptor es it dentro del bloque. Retorna el mismo objeto original.

val numeros = mutableListOf(1, 2, 3).also {
    println("Lista creada con ${it.size} elementos: $it")
}
// Imprime: Lista creada con 3 elementos: [1, 2, 3]

with

Se usa para ejecutar un bloque de código con un objeto específico. El objeto receptor es this dentro del bloque.

val persona = Persona("Ana", 25)
with(persona) {
    println("Nombre: $nombre, Edad: $edad") // Sin necesidad de usar 'persona.'
}

run

Se usa para ejecutar un bloque de código y devolver un resultado. El objeto receptor es this dentro del bloque.

val resultado = persona.run {
    "Nombre: $nombre, Edad: $edad"
}
println(resultado) // Nombre: Ana, Edad: 25

Tabla resumen de funciones de ámbito

Función Referencia al objeto Valor de retorno Uso principal
apply this El mismo objeto Configurar propiedades de un objeto
let it Resultado del lambda Ejecutar código con objetos no nulos
also it El mismo objeto Acciones adicionales (logging, validación)
with this Resultado del lambda Agrupar llamadas sobre un mismo objeto
run this Resultado del lambda Configurar objeto y calcular un resultado

Actividad práctica

1. Refactorización de código imperativo a funcional

Refactorice los siguientes fragmentos de código imperativo a un estilo funcional usando las operaciones sobre colecciones aprendidas (map, filter, groupBy, reduce, etc.):

Fragmento A:

val nombres = listOf("ana", "CARLOS", " beatriz ", "DAVID", "elena")
val resultado = mutableListOf<String>()
for (nombre in nombres) {
    val limpio = nombre.trim().lowercase()
    if (limpio.length > 4) {
        resultado.add(limpio.replaceFirstChar { it.uppercase() })
    }
}
println(resultado)

Fragmento B:

data class Estudiante(val nombre: String, val nota: Double, val materia: String)

val estudiantes = listOf(
    Estudiante("Ana", 4.5, "Matemáticas"),
    Estudiante("Luis", 3.2, "Programación"),
    Estudiante("María", 4.8, "Matemáticas"),
    Estudiante("Pedro", 2.9, "Programación"),
    Estudiante("Sofía", 3.7, "Matemáticas")
)

// Calcular el promedio de notas por materia (solo de estudiantes aprobados con nota >= 3.0)
var sumaMatematicas = 0.0
var contadorMatematicas = 0
var sumaProgramacion = 0.0
var contadorProgramacion = 0

for (e in estudiantes) {
    if (e.nota >= 3.0) {
        if (e.materia == "Matemáticas") {
            sumaMatematicas += e.nota
            contadorMatematicas++
        } else {
            sumaProgramacion += e.nota
            contadorProgramacion++
        }
    }
}

2. Uso de funciones de ámbito

Cree un ejemplo práctico que use las cinco funciones de ámbito (apply, let, also, with, run) en un solo programa que modele una aplicación de gestión de usuarios.


Recursos Adicionales