trubidata
Lección 5 de 14·24 min

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.

scala
// 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.

scala
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:

scala
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:

scala
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:

scala
// 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:

scala
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.

scala
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:

scala
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".

scala
// 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:

scala
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:

scala
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 =>:

scala
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:

scala
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:

scala
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:

scala
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:

scala
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:

scala
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
text
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:

scala
try {
  val resultado = 10 / 0
  println(resultado)
} catch {
  case e: ArithmeticException => println(s"Error aritmético: ${e.getMessage}")
} finally {
  println("Finalización")
}
text
Error aritmético: / by zero
Finalización
  • El bloque try contiene el código que puede fallar.
  • Cada case del catch atrapa un tipo de excepción.
  • El finally se 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:

scala
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:

scala
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.

scala
def validarMonto(monto: Double): Double = {
  require(monto > 0, "El monto tiene que ser positivo.")
  monto
}

Las excepciones más comunes:

  1. NullPointerException: se intenta usar un objeto que es null. Se evita no usando null y prefiriendo tipos opcionales como Option (lección 7).
  2. NoSuchElementException: se pide un elemento que no existe, como el primero de una lista vacía.
  3. IndexOutOfBoundsException: se accede a un índice fuera de rango en una lista o un array.
  4. ArithmeticException: una operación aritmética inválida, como dividir un entero por cero.
  5. ClassCastException: se intenta convertir un objeto a un tipo incompatible en tiempo de ejecución.
  6. IllegalArgumentException: se le pasó a una función un argumento no válido.
  7. UnsupportedOperationException: la operación no está soportada o no está implementada.
  8. FileNotFoundException: se intenta abrir un archivo que no existe.
  9. IOException: la excepción base para errores de entrada y salida.
  10. 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.

  1. Transferencias entre cuentas bancarias. Partí de este código:

    scala
    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()
    )
    
    val cuenta1 = CuentaBancaria("Cuenta A", 1000)
    val cuenta2 = CuentaBancaria("Cuenta B", 500)
    

    Y escribí lo siguiente:

    • Una función realizarTransferencia que 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 realizarTransferencia reciba, 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 ArrayBuffer se usa +=.
    • Escribí una función para consultar el saldo de una cuenta.
    • Usá un lazy val para calcular el resumen de movimientos de una cuenta solo cuando se pida.
  2. Gestión de préstamos y pagos.

    • Escribí una función solicitarPrestamo que 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.

Soluciones

scala
// 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)
text
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.

scala
// 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)}")
text
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 * 2 o _ * 2).
  • La aplicación parcial (suma(5, _: Int)) y el currying (suma(5) con def suma(a: Int)(b: Int)) crean funciones especializadas. En Scala 3 ya no hace falta el _ final.
  • Un lazy val se calcula la primera vez que se usa.
  • Las excepciones se manejan con try/catch/finally y se lanzan con throw; require valida argumentos.

← Volver al temario