Funciones y excepciones
En esta guía (18)
En esta lección vas a ver las funciones en profundidad, que es donde Scala más se diferencia de Java. Primero, las ideas de la programación funcional: funciones puras, inmutabilidad y funciones de orden superior. Después, todas las formas de definir y combinar funciones: argumentos por defecto, con nombre y variables, recursión, lambdas, aplicación parcial, currying y valores perezosos. Al final vas a ver cómo manejar errores con excepciones, y vas a aplicar todo en dos ejercicios bancarios: transferencias entre cuentas y gestión de préstamos.
Programación funcional
Las funciones son la herramienta básica para organizar el código, en cualquier lenguaje. La programación funcional es, en esencia, usar las funciones de la mejor manera posible para escribir software limpio y fácil de mantener. Más concretamente, es un conjunto de prácticas de programación, lo que se suele llamar un paradigma.
A veces se la presenta como lo opuesto a la programación orientada a objetos o a la programación procedimental. Es una idea engañosa: los enfoques no se excluyen, y la mayoría de los sistemas reales usan los tres. Scala está diseñado justamente para combinarlos.
Tres ideas sostienen la programación funcional:
Funciones puras
Una función pura es aquella cuyo resultado depende únicamente de sus parámetros y que no produce ningún efecto secundario: no modifica nada afuera, no escribe en un archivo, no imprime, no cambia una variable global. Lo único que hace es devolver un valor.
// Pura: el resultado depende solo de los argumentos
def interes(capital: Double, tasa: Double): Double = capital * tasa
// No pura: modifica algo que está afuera de la función
var totalCobrado = 0.0
def cobrar(monto: Double): Unit = {
totalCobrado += monto
}
Una función pura se reduce a sus argumentos y su valor de retorno. Es un "callejón sin salida" para la complejidad: su única interacción con el resto del sistema es a través de esa interfaz. Por eso es fácil de entender, de probar y de reusar.
Inmutabilidad
Otro principio es no modificar los datos que están fuera de la función, empezando por sus propios argumentos. En lugar de cambiar lo que recibe, la función devuelve un valor nuevo que refleja el trabajo hecho. Así se evitan los efectos secundarios y es más fácil razonar sobre qué hace cada parte del programa. Es la misma idea detrás de preferir val sobre var.
Funciones de orden superior
Una función que recibe otra función como argumento, o que devuelve una función, se llama función de orden superior: es una función que opera sobre otras funciones. Las vas a ver en detalle más abajo.
Funciones
Las funciones se definen con def. Tienen parámetros, un tipo de retorno y un cuerpo. En Scala, las funciones son ciudadanos de primera clase: las podés pasar como argumentos y devolver como resultado, igual que un número o un string.
def suma(a: Int, b: Int): Int = {
a + b
}
val resultado = suma(3, 4)
println(resultado) // 7
Argumentos por defecto y con nombre
Podés darle un valor por defecto a un parámetro: si no lo pasás al llamar, se usa ese valor. Y podés pasar los argumentos por nombre, en cualquier orden:
def saludar(nombre: String, saludo: String = "Hola"): Unit = {
println(s"$saludo, $nombre")
}
saludar("Juan") // Hola, Juan
saludar("Ana", saludo = "Hola de nuevo") // Hola de nuevo, Ana
saludar(saludo = "Buen día", nombre = "Luis") // Buen día, Luis
Los argumentos con nombre hacen mucho más legible una llamada con varios parámetros del mismo tipo: transferir(origen = a, destino = b, monto = 100) no deja dudas sobre qué es cada cosa.
Argumentos variables
Con * después del tipo, un parámetro acepta cualquier cantidad de argumentos de ese tipo. Adentro de la función se usa como una secuencia:
def promedio(numeros: Double*): Double = {
if (numeros.isEmpty) 0.0
else numeros.sum / numeros.length
}
println(promedio(2.0, 4.0, 6.0)) // 4.0
println(promedio()) // 0.0
Si ya tenés los valores en una colección, la pasás agregando : _*, que la "desarma" en argumentos sueltos:
// continúa del bloque anterior
val saldos = List(1000.0, 2500.0, 1500.0)
println(promedio(saldos: _*)) // 1666.6666666666667
Procedimientos
Una función que no devuelve un valor útil y se usa solo por su efecto (por ejemplo, imprimir) tiene tipo de retorno Unit. A estas funciones se las llama procedimientos:
def saludar(nombre: String): Unit = {
println(s"Hola, $nombre")
}
saludar("Luis")
En código viejo de Scala 2 vas a ver procedimientos escritos sin : Unit =, directamente def saludar(nombre: String) { ... }. Esa sintaxis se eliminó en Scala 3: escribí siempre : Unit =.
Recursión
Una función recursiva se llama a sí misma para resolver un problema, en lugar de usar un bucle. Scala, como lenguaje funcional, la fomenta.
def factorial(n: BigInt): BigInt = {
if (n <= 1) 1
else n * factorial(n - 1)
}
println(factorial(3)) // 6
println(factorial(20)) // 2432902008176640000
Usamos BigInt porque el factorial crece muy rápido: con Int, factorial(13) ya desborda.
Hay que tener en cuenta la eficiencia y el riesgo de desbordamiento de la pila (StackOverflowError): cada llamada recursiva ocupa memoria hasta que termina. Scala optimiza las funciones recursivas de cola (tail recursion), en las que la llamada recursiva es lo último que se hace. El factorial de arriba no lo es, porque después de la llamada todavía tiene que multiplicar por n. Se puede reescribir con un acumulador:
import scala.annotation.tailrec
def factorial(n: BigInt): BigInt = {
@tailrec
def loop(n: BigInt, acumulado: BigInt): BigInt = {
if (n <= 1) acumulado
else loop(n - 1, n * acumulado)
}
loop(n, 1)
}
println(factorial(10000).toString.length) // 35660 dígitos, sin desbordar la pila
La anotación @tailrec le pide al compilador que verifique que la función sea recursiva de cola: si no lo es, da error de compilación en lugar de fallar en ejecución.
Funciones de orden superior
Como las funciones son valores, una función puede recibir otra como parámetro. El tipo de una función se escribe con =>: Int => Int es "una función que recibe un Int y devuelve un Int".
// Recibe una función y la aplica a un valor
def aplicarFuncion(funcion: Int => Int, valor: Int): Int = {
funcion(valor)
}
def incrementar(x: Int): Int = x + 1
def duplicar(x: Int): Int = x * 2
val resultado1 = aplicarFuncion(incrementar, 5) // 6
val resultado2 = aplicarFuncion(duplicar, 3) // 6
println((resultado1, resultado2))
Otro ejemplo, con una función genérica que encierra cualquier valor entre corchetes:
def aplicar(f: Int => String, v: Int): String = f(v)
def layout[A](x: A): String = "[" + x.toString + "]"
println(aplicar(layout, 10)) // [10]
layout[A] es genérica: el [A] indica que acepta un valor de cualquier tipo A. Al pasarla donde se espera una Int => String, Scala la usa con A = Int.
Una función también puede devolver otra función:
def multiplicarPor(factor: Double): Double => Double = {
(x: Double) => x * factor
}
val conIva = multiplicarPor(1.21)
println(conIva(1000.0)) // 1210.0
Funciones anónimas
Las funciones anónimas, también llamadas lambdas, son funciones sin nombre que se definen justo donde se necesitan. Se escriben con =>:
val cuadrado = (x: Int) => x * x
println(cuadrado(5)) // 25
Brillan combinadas con funciones de orden superior como map, filter o reduce, que vas a usar todo el tiempo con colecciones:
val numeros = List(1, 2, 3, 4, 5)
val cuadrados = numeros.map((x: Int) => x * x)
println(cuadrados) // List(1, 4, 9, 16, 25)
Como el compilador ya sabe que numeros tiene Int, podés omitir el tipo. Y si el parámetro se usa una sola vez, podés reemplazarlo por un guion bajo:
val numeros = List(1, 2, 3, 4, 5)
println(numeros.map(x => x * x)) // List(1, 4, 9, 16, 25)
println(numeros.map(_ * 2)) // List(2, 4, 6, 8, 10)
println(numeros.filter(_ % 2 == 0)) // List(2, 4)
println(numeros.reduce(_ + _)) // 15
En _ + _, cada guion bajo es un parámetro distinto: equivale a (a, b) => a + b.
Funciones parcialmente aplicadas
Una función parcialmente aplicada se obtiene pasando solo algunos de los argumentos de una función y dejando los demás pendientes. Sirve para crear funciones especializadas a partir de una general:
def suma(a: Int, b: Int): Int = a + b
val suma5 = suma(5, _: Int) // fija a = 5, deja b pendiente
println(suma5(3)) // 8
suma5 es una función de tipo Int => Int. El _: Int marca el argumento que queda para después.
Currying
El currying transforma una función de varios argumentos en una secuencia de funciones de un argumento. En Scala se escribe con varias listas de parámetros:
def suma(a: Int)(b: Int): Int = a + b
println(suma(5)(3)) // 8
val suma5: Int => Int = suma(5)
println(suma5(3)) // 8
def strcat(s1: String)(s2: String): String = s1 + s2
println(strcat("foo")("bar")) // foobar
Pasando solo la primera lista, suma(5), obtenés una función que espera el resto.
En Scala 2 había que agregar un guion bajo para pedir esa conversión explícitamente: val suma5 = suma(5) _. Scala 3 la hace sola (se llama eta-expansion: convertir un método en un valor función), así que alcanza con suma(5). La forma con _ sigue compilando en Scala 3.3, pero ya no hace falta y en versiones posteriores se marca como obsoleta.
Valores lazy
Un lazy val no se calcula cuando se declara, sino la primera vez que se usa. Es útil para retrasar cálculos costosos que quizás nunca se necesiten:
lazy val resultado = {
println("Inicialización perezosa")
42
}
println("Antes de acceder a resultado")
println(resultado) // acá se calcula
println(resultado) // acá ya no: se reusa el valor
Antes de acceder a resultado
Inicialización perezosa
42
42
El bloque se ejecuta una sola vez: la segunda vez, resultado ya tiene su valor guardado.
Excepciones
Scala maneja las excepciones de forma parecida a Java, con try, catch y finally. La diferencia es que el catch usa la misma sintaxis de patrones que match:
try {
val resultado = 10 / 0
println(resultado)
} catch {
case e: ArithmeticException => println(s"Error aritmético: ${e.getMessage}")
} finally {
println("Finalización")
}
Error aritmético: / by zero
Finalización
- El bloque
trycontiene el código que puede fallar. - Cada
casedelcatchatrapa un tipo de excepción. - El
finallyse ejecuta siempre, haya error o no. Se usa para liberar recursos, como cerrar un archivo.
Como casi todo en Scala, try es una expresión y devuelve un valor:
def dividir(a: Int, b: Int): Int =
try {
a / b
} catch {
case _: ArithmeticException => 0
}
println(dividir(10, 2)) // 5
println(dividir(10, 0)) // 0
Para lanzar una excepción se usa throw:
def validarMonto(monto: Double): Double = {
if (monto <= 0) throw new IllegalArgumentException("El monto tiene que ser positivo.")
monto
}
try {
validarMonto(-50)
} catch {
case e: IllegalArgumentException => println(s"Error: ${e.getMessage}")
}
// Error: El monto tiene que ser positivo.
Para el caso más común, validar argumentos, Scala trae un atajo: require(condicion, mensaje) lanza una IllegalArgumentException si la condición es falsa.
def validarMonto(monto: Double): Double = {
require(monto > 0, "El monto tiene que ser positivo.")
monto
}
Las excepciones más comunes:
NullPointerException: se intenta usar un objeto que esnull. Se evita no usandonully prefiriendo tipos opcionales comoOption(lección 7).NoSuchElementException: se pide un elemento que no existe, como el primero de una lista vacía.IndexOutOfBoundsException: se accede a un índice fuera de rango en una lista o un array.ArithmeticException: una operación aritmética inválida, como dividir un entero por cero.ClassCastException: se intenta convertir un objeto a un tipo incompatible en tiempo de ejecución.IllegalArgumentException: se le pasó a una función un argumento no válido.UnsupportedOperationException: la operación no está soportada o no está implementada.FileNotFoundException: se intenta abrir un archivo que no existe.IOException: la excepción base para errores de entrada y salida.SecurityException: un error de seguridad, como un acceso no autorizado.
Practicá
En estos ejercicios vas a usar case classes para representar cuentas y transacciones. Las vas a ver en detalle en la lección 8; por ahora alcanza con saber que case class Transaccion(cantidad: Double, fecha: LocalDateTime) define un tipo con esos campos, que se crea con Transaccion(100.0, LocalDateTime.now()) y cuyos campos se leen con .cantidad y .fecha. Un campo marcado con var se puede modificar.
-
Transferencias entre cuentas bancarias. Partí de este código:
scalaimport java.time.LocalDateTime import scala.collection.mutable.ArrayBuffer case class Transaccion(cantidad: Double, fecha: LocalDateTime) case class CuentaBancaria( nombre: String, var saldo: Double, transacciones: ArrayBuffer[Transaccion] = ArrayBuffer() ) val cuenta1 = CuentaBancaria("Cuenta A", 1000) val cuenta2 = CuentaBancaria("Cuenta B", 500)Y escribí lo siguiente:
- Una función
realizarTransferenciaque reciba la cuenta de origen, la de destino y la cantidad. Llamala con argumentos con nombre. - Si la cuenta de origen no tiene fondos suficientes, tiene que lanzar una excepción con un mensaje claro.
- Usá una función de orden superior para la verificación de saldo: que
realizarTransferenciareciba, con un valor por defecto, la función que valida si se puede transferir. - Registrá la transacción en las dos cuentas, con la cantidad (negativa en el origen) y la fecha. Para agregar un elemento a un
ArrayBufferse usa+=. - Escribí una función para consultar el saldo de una cuenta.
- Usá un
lazy valpara calcular el resumen de movimientos de una cuenta solo cuando se pida.
- Una función
-
Gestión de préstamos y pagos.
- Escribí una función
solicitarPrestamoque reciba el monto, la tasa de interés total y la cantidad de cuotas, con valores por defecto para la tasa (5%) y las cuotas (12). - El préstamo tiene que registrar el saldo pendiente (monto más intereses) y el historial de pagos.
- Escribí una función de orden superior que calcule la cuota mensual recibiendo la fórmula de cálculo como parámetro.
- Escribí una función para realizar un pago, que reduzca el saldo pendiente y lo registre en el historial.
- Usá una función parcialmente aplicada para crear una función especializada, por ejemplo, una que pague siempre 200.
- Escribí una función
Soluciones
// Ejercicio 1
import java.time.LocalDateTime
import scala.collection.mutable.ArrayBuffer
case class Transaccion(cantidad: Double, fecha: LocalDateTime)
case class CuentaBancaria(
nombre: String,
var saldo: Double,
transacciones: ArrayBuffer[Transaccion] = ArrayBuffer()
)
def tieneFondos(cuenta: CuentaBancaria, cantidad: Double): Boolean =
cuenta.saldo >= cantidad
def realizarTransferencia(
origen: CuentaBancaria,
destino: CuentaBancaria,
cantidad: Double,
puedeTransferir: (CuentaBancaria, Double) => Boolean = tieneFondos
): Unit = {
require(cantidad > 0, "La cantidad tiene que ser mayor que cero.")
if (!puedeTransferir(origen, cantidad))
throw new IllegalArgumentException(s"Saldo insuficiente en ${origen.nombre}")
val fecha = LocalDateTime.now()
origen.saldo -= cantidad
destino.saldo += cantidad
origen.transacciones += Transaccion(-cantidad, fecha)
destino.transacciones += Transaccion(cantidad, fecha)
}
def consultarSaldo(cuenta: CuentaBancaria): Double = cuenta.saldo
val cuenta1 = CuentaBancaria("Cuenta A", 1000)
val cuenta2 = CuentaBancaria("Cuenta B", 500)
realizarTransferencia(origen = cuenta1, destino = cuenta2, cantidad = 200)
try {
realizarTransferencia(origen = cuenta1, destino = cuenta2, cantidad = 5000)
} catch {
case e: IllegalArgumentException => println(s"Error: ${e.getMessage}")
}
lazy val resumenCuenta1 = {
println("Calculando resumen...")
s"${cuenta1.nombre}: ${cuenta1.transacciones.length} movimiento(s), saldo ${consultarSaldo(cuenta1)}"
}
println(s"Saldo actual en ${cuenta1.nombre}: ${consultarSaldo(cuenta1)}")
println(s"Saldo actual en ${cuenta2.nombre}: ${consultarSaldo(cuenta2)}")
println(resumenCuenta1)
Error: Saldo insuficiente en Cuenta A
Saldo actual en Cuenta A: 800.0
Saldo actual en Cuenta B: 700.0
Calculando resumen...
Cuenta A: 1 movimiento(s), saldo 800.0
La transferencia de 5000 falla antes de tocar los saldos: primero se valida y recién después se modifica algo. Si validaras después de restar, una transferencia fallida dejaría las cuentas en un estado inconsistente. El parámetro puedeTransferir tiene un valor por defecto, así que normalmente no se pasa; pero podés pasar otra regla, por ejemplo una que permita un descubierto: puedeTransferir = (c, x) => c.saldo + 300 >= x.
// Ejercicio 2
import java.time.LocalDateTime
import scala.collection.mutable.ListBuffer
case class Pago(monto: Double, fecha: LocalDateTime)
case class Prestamo(monto: Double, tasaInteres: Double, cuotas: Int) {
var saldoPendiente: Double = monto * (1 + tasaInteres)
val pagos: ListBuffer[Pago] = ListBuffer()
}
def solicitarPrestamo(monto: Double, tasaInteres: Double = 0.05, cuotas: Int = 12): Prestamo =
Prestamo(monto, tasaInteres, cuotas)
// Función de orden superior: la fórmula llega como parámetro
def calcularCuotaMensual(prestamo: Prestamo, formula: (Double, Double, Int) => Double): Double =
formula(prestamo.monto, prestamo.tasaInteres, prestamo.cuotas)
val cuotaFija: (Double, Double, Int) => Double =
(monto, tasa, cuotas) => monto * (1 + tasa) / cuotas
def realizarPago(prestamo: Prestamo, cantidad: Double): Unit = {
require(cantidad > 0, "El pago tiene que ser mayor que cero.")
val pago = math.min(cantidad, prestamo.saldoPendiente) // no se paga de más
prestamo.saldoPendiente -= pago
prestamo.pagos += Pago(pago, LocalDateTime.now())
}
// Función parcialmente aplicada: siempre paga 200
val pagar200 = realizarPago(_: Prestamo, 200.0)
val miPrestamo = solicitarPrestamo(1000)
println(s"Saldo pendiente antes del pago: ${miPrestamo.saldoPendiente}")
val cuotaMensual = calcularCuotaMensual(miPrestamo, cuotaFija)
println(s"Cuota mensual calculada: $cuotaMensual")
pagar200(miPrestamo)
realizarPago(miPrestamo, cuotaMensual)
println(s"Saldo pendiente después de los pagos: ${miPrestamo.saldoPendiente}")
println(s"Pagos realizados: ${miPrestamo.pagos.map(_.monto)}")
Saldo pendiente antes del pago: 1050.0
Cuota mensual calculada: 87.5
Saldo pendiente después de los pagos: 762.5
Pagos realizados: ListBuffer(200.0, 87.5)
La fórmula de la cuota es intencionalmente simple: el total con intereses dividido en cuotas iguales. Como calcularCuotaMensual la recibe por parámetro, podés pasarle otra fórmula, como la del sistema francés, sin tocar el resto del código.
Resumen
- La programación funcional se apoya en funciones puras (sin efectos secundarios), inmutabilidad y funciones de orden superior.
- Los parámetros pueden tener valores por defecto, se pueden pasar por nombre y pueden ser variables (
Tipo*). - Las funciones que solo producen efectos devuelven
Unit. - La recursión de cola (
@tailrec) evita desbordar la pila. - Las funciones son valores: se pasan, se devuelven y se crean al vuelo como lambdas (
x => x * 2o_ * 2). - La aplicación parcial (
suma(5, _: Int)) y el currying (suma(5)condef suma(a: Int)(b: Int)) crean funciones especializadas. En Scala 3 ya no hace falta el_final. - Un
lazy valse calcula la primera vez que se usa. - Las excepciones se manejan con
try/catch/finallyy se lanzan conthrow;requirevalida argumentos.