trubidata
Lección 7 de 14·20 min

Listas, mapas, tuplas y sets

En esta guía (15)

En la lección anterior trabajaste con arrays, que son mutables. En esta vas a conocer las colecciones que más se usan en Scala, que son inmutables por defecto: listas, mapas, tuplas y sets. Vas a ver cómo crearlas, consultarlas y "actualizarlas" (creando versiones nuevas), y cómo transformarlas con map, filter, groupBy y compañía. Al final vas a aplicar todo sobre listas de transacciones y cuentas.

Listas, mapas y tuplas, un primer vistazo

scala
// Una lista de Strings
val frutas: List[String] = List("manzanas", "naranjas", "peras")

// Un mapa: pares clave -> valor
val miMapa = Map("nombre" -> "Juan", "ciudad" -> "Mendoza")
val nombre = miMapa("nombre")

// Una tupla: varios valores, de tipos distintos, agrupados
val miTupla = ("Manzana", 3)
val fruta = miTupla._1 // primer elemento
  • Una lista es una secuencia ordenada de elementos del mismo tipo.
  • Un mapa es una colección de pares clave-valor: buscás un valor a partir de su clave.
  • Una tupla agrupa unos pocos valores, que pueden ser de tipos distintos.

Listas

Una List es inmutable: una vez creada, no cambia. Las operaciones que "modifican" una lista devuelven una lista nueva.

scala
val numeros = List(10, 20, 30)

println(numeros.head)     // 10: el primer elemento
println(numeros.tail)     // List(20, 30): todos menos el primero
println(numeros.isEmpty)  // false
println(numeros.length)   // 3
println(numeros(1))       // 20: acceso por índice

val conCero = 0 :: numeros      // agrega al principio: List(0, 10, 20, 30)
val conCuarenta = numeros :+ 40 // agrega al final: List(10, 20, 30, 40)
val juntas = numeros ++ List(40, 50)

println(numeros) // List(10, 20, 30): el original no cambió

Agregar al principio con :: es inmediato; agregar al final con :+ tiene que recorrer toda la lista. Si vas a acceder mucho por índice o agregar al final, conviene un Vector, que se usa igual pero es eficiente para esas operaciones.

Ojo con head en una lista vacía: lanza una NoSuchElementException. Si la lista puede estar vacía, usá headOption, que devuelve un Option (más abajo vas a ver qué es).

Mapas

Construir un mapa

Los mapas se crean con Map(...) y la flecha ->, que arma un par clave-valor. Son inmutables por defecto.

scala
val saldos = Map("Ana" -> 1500.0, "Bruno" -> 300.0, "Carla" -> 2200.0)

Tratá de que todos los valores sean del mismo tipo. Si mezclás tipos, como en Map("nombre" -> "Ana", "edad" -> 25), el compilador elige un tipo genérico para los valores (Any en Scala 2, Matchable en Scala 3), y perdés la ayuda de los tipos: miMapa("edad") + 1 no compila. Para datos con campos de distinto tipo, lo correcto es una case class (lección 8).

Acceder a los valores

scala
val saldos = Map("Ana" -> 1500.0, "Bruno" -> 300.0, "Carla" -> 2200.0)

println(saldos("Ana"))                // 1500.0
println(saldos.contains("Zoe"))       // false
println(saldos.get("Ana"))            // Some(1500.0)
println(saldos.get("Zoe"))            // None
println(saldos.getOrElse("Zoe", 0.0)) // 0.0

println(saldos.keys)    // Set(Ana, Bruno, Carla)
println(saldos.values)  // Iterable(1500.0, 300.0, 2200.0)
println(saldos.isEmpty) // false

Si pedís una clave que no existe con saldos("Zoe"), se lanza una NoSuchElementException. Por eso existen get y getOrElse:

  • get devuelve un Option: Some(valor) si la clave existe, o None si no. Option es la forma de Scala de decir "puede haber un valor o no" sin usar null.
  • getOrElse devuelve el valor o, si no está, el valor por defecto que le pasás.

Actualizar un mapa

Como el mapa es inmutable, "actualizarlo" significa crear uno nuevo:

scala
val saldos = Map("Ana" -> 1500.0, "Bruno" -> 300.0)

val conCarla = saldos + ("Carla" -> 2200.0)     // agrega
val anaActualizada = saldos + ("Ana" -> 1600.0) // si la clave existe, la reemplaza
val conUpdated = saldos.updated("Dario", 100.0) // lo mismo, con un método
val sinBruno = saldos - "Bruno"                  // quita

println(anaActualizada) // Map(Ana -> 1600.0, Bruno -> 300.0)
println(saldos)         // Map(Ana -> 1500.0, Bruno -> 300.0): el original no cambió

Recorrer un mapa

scala
val saldos = Map("Ana" -> 1500.0, "Bruno" -> 300.0)

for ((clave, valor) <- saldos) {
  println(s"Clave: $clave, Valor: $valor")
}

saldos.foreach { case (cliente, saldo) => println(s"$cliente tiene $saldo") }

El for desarma cada par en (clave, valor). En foreach, el { case (cliente, saldo) => ... } hace lo mismo con pattern matching.

Mapas ordenados

Un Map común no garantiza ningún orden. Si lo necesitás, hay mapas especializados: SortedMap mantiene las claves ordenadas, y ListMap (inmutable) o LinkedHashMap (mutable) mantienen el orden de inserción.

scala
import scala.collection.immutable.SortedMap

val mapaOrdenado = SortedMap("b" -> 2, "a" -> 1, "c" -> 3)
println(mapaOrdenado) // TreeMap(a -> 1, b -> 2, c -> 3)

Tuplas

Una tupla agrupa valores de tipos distintos sin necesidad de definir una clase. Es útil para devolver varios resultados de una función o para pares temporales.

scala
val miTupla = ("Manzana", 3, 1.5)

println(miTupla._1) // Manzana
println(miTupla._3) // 1.5

val (fruta, cantidad, precio) = miTupla // desarmar la tupla en tres valores
println(s"$cantidad $fruta a $precio") // 3 Manzana a 1.5

def minYMax(xs: List[Int]): (Int, Int) = (xs.min, xs.max)
println(minYMax(List(5, 2, 8))) // (2,8)

Los ._1, ._2 se vuelven difíciles de leer rápido. Si una tupla empieza a viajar por todo el programa, es momento de convertirla en una case class con nombres de campo.

zip: combinar dos secuencias

zip combina dos secuencias en una secuencia de pares, emparejando elemento a elemento:

scala
val nombres = List("Juan", "Ana", "Luis")
val edades = List(30, 25, 35)

val nombresYEdades = nombres.zip(edades)
println(nombresYEdades) // List((Juan,30), (Ana,25), (Luis,35))

val (soloNombres, soloEdades) = nombresYEdades.unzip // la operación inversa
println(nombresYEdades.toMap) // Map(Juan -> 30, Ana -> 25, Luis -> 35)

Si las listas tienen distinto largo, zip corta en la más corta.

Sets

Un Set (conjunto) es una colección sin duplicados y sin un orden garantizado. Sirve para guardar elementos únicos y para las operaciones de conjuntos: unión, intersección, diferencia. Por defecto es inmutable, pero también existe una versión mutable.

scala
import scala.collection.mutable

// Inmutable (el Set por defecto)
val conjuntoInmutable = Set(1, 2, 3, 4, 3, 2)
println(conjuntoInmutable)              // Set(1, 2, 3, 4): sin duplicados
println(conjuntoInmutable.contains(3))  // true
println(conjuntoInmutable.isEmpty)      // false

// Mutable
val conjuntoMutable = mutable.Set(1, 2, 3)
conjuntoMutable += 4
println(conjuntoMutable.size) // 4

// Operaciones de conjuntos
val a = Set(1, 2, 3)
val b = Set(2, 3, 4)
println(a union b)     // Set(1, 2, 3, 4)
println(a intersect b) // Set(2, 3)
println(a diff b)      // Set(1)

for (elemento <- a) println(elemento)

Un detalle sobre el import: si escribís import scala.collection.mutable.Set, a partir de ahí todo Set del archivo pasa a ser mutable, incluido el que creías inmutable. Importando el paquete scala.collection.mutable y escribiendo mutable.Set, queda claro cuál es cuál.

Transformar colecciones

Las funciones de orden superior que viste con arrays funcionan igual con todas estas colecciones. Son las herramientas con las que se escribe casi cualquier transformación de datos:

scala
val transacciones = List(("ana", 1500.0), ("bruno", -300.0), ("ana", -200.0), ("carla", 800.0))

val montos = transacciones.map(_._2)                 // List(1500.0, -300.0, -200.0, 800.0)
val creditos = transacciones.filter(_._2 > 0)        // solo los montos positivos
val total = transacciones.map(_._2).sum              // 1800.0
val total2 = transacciones.foldLeft(0.0)((acumulado, t) => acumulado + t._2) // 1800.0
val porCliente = transacciones.groupBy(_._1)         // Map(cliente -> List(transacciones))
val ordenadas = transacciones.sortBy(_._2)           // de menor a mayor monto
val hayDebitos = transacciones.exists(_._2 < 0)      // true

println(porCliente("ana")) // List((ana,1500.0), (ana,-200.0))
  • map transforma cada elemento.
  • filter se queda con los que cumplen una condición.
  • foldLeft recorre la colección acumulando un resultado: arranca con un valor inicial (0.0) y, en cada paso, combina lo acumulado con el elemento actual. sum, max o count son casos particulares de esta idea.
  • groupBy arma un Map cuya clave es el resultado de la función y cuyo valor es la lista de elementos con esa clave.
  • sortBy ordena según un campo; exists y forall preguntan si alguno o todos cumplen una condición.

Practicá

  1. Listas. Partiendo de esta lista de transacciones (fecha, monto):

    scala
    val transacciones = List(
      ("2023-10-01", 100.0),
      ("2023-10-15", -50.0),
      ("2023-10-20", 200.0),
      ("2023-11-05", -30.0)
    )
    
    • Calculá la suma de todas las transacciones con sum.
    • Obtené la fecha de la primera transacción con head.
    • Obtené las transacciones de un mes específico con filter.
  2. Tuplas. Dada esta lista de cuentas (titular, cuenta, saldo), usá unzip3 para separarla en tres listas: titulares, números de cuenta y saldos.

    scala
    val cuentas = List(
      ("Titular1", "Cuenta1", 1000.0),
      ("Titular2", "Cuenta2", 1500.0),
      ("Titular3", "Cuenta3", 2000.0)
    )
    
  3. Mapas. Dado este mapa de titulares y números de cuenta, agregá un titular nuevo con updated y verificá con contains si un titular existe.

    scala
    val mapaCuentas = Map(
      "Titular1" -> "Cuenta1",
      "Titular2" -> "Cuenta2",
      "Titular3" -> "Cuenta3"
    )
    
  4. Sets. Dado val numerosDeCuenta = Set(111, 222, 333), agregá una cuenta con +, eliminá otra con - y verificá con contains si un número existe.

  5. Combinación de tipos. Dada esta lista de transacciones (titular, cuenta, monto), agrupalas por titular con groupBy y calculá el saldo total de cada titular con map y sum (o reduce).

    scala
    val transaccionesBancarias = List(
      ("Titular1", "Cuenta1", 100.0),
      ("Titular2", "Cuenta2", -50.0),
      ("Titular1", "Cuenta1", 200.0),
      ("Titular3", "Cuenta3", -30.0)
    )
    

Soluciones

scala
// Ejercicio 1
val transacciones = List(
  ("2023-10-01", 100.0),
  ("2023-10-15", -50.0),
  ("2023-10-20", 200.0),
  ("2023-11-05", -30.0)
)

val suma = transacciones.map(_._2).sum
println(s"Suma: $suma") // Suma: 220.0

val primeraFecha = transacciones.head._1
println(s"Fecha de la primera transacción: $primeraFecha") // 2023-10-01

val mes = "2023-10"
val delMes = transacciones.filter(_._1.startsWith(mes))
println(delMes) // List((2023-10-01,100.0), (2023-10-15,-50.0), (2023-10-20,200.0))
scala
// Ejercicio 2
val cuentas = List(
  ("Titular1", "Cuenta1", 1000.0),
  ("Titular2", "Cuenta2", 1500.0),
  ("Titular3", "Cuenta3", 2000.0)
)

val (titulares, numerosCuenta, saldos) = cuentas.unzip3

println("Titulares: " + titulares.mkString(", "))
println("Números de cuenta: " + numerosCuenta.mkString(", "))
println("Saldos: " + saldos.mkString(", "))
// Titulares: Titular1, Titular2, Titular3
// Números de cuenta: Cuenta1, Cuenta2, Cuenta3
// Saldos: 1000.0, 1500.0, 2000.0

unzip separa pares en dos listas; para tríos se usa unzip3.

scala
// Ejercicio 3
val mapaCuentas = Map(
  "Titular1" -> "Cuenta1",
  "Titular2" -> "Cuenta2",
  "Titular3" -> "Cuenta3"
)

val nuevoMapaCuentas = mapaCuentas.updated("NuevoTitular", "NuevaCuenta")

for (titular <- List("Titular2", "TitularNoExistente")) {
  if (nuevoMapaCuentas.contains(titular)) println(s"$titular existe en el mapa.")
  else println(s"$titular no existe en el mapa.")
}

println("Nuevo mapa de cuentas: " + nuevoMapaCuentas)
println("Mapa original: " + mapaCuentas)
text
Titular2 existe en el mapa.
TitularNoExistente no existe en el mapa.
Nuevo mapa de cuentas: Map(Titular1 -> Cuenta1, Titular2 -> Cuenta2, Titular3 -> Cuenta3, NuevoTitular -> NuevaCuenta)
Mapa original: Map(Titular1 -> Cuenta1, Titular2 -> Cuenta2, Titular3 -> Cuenta3)
scala
// Ejercicio 4
val numerosDeCuenta = Set(111, 222, 333)

val conNueva = numerosDeCuenta + 444
val sin222 = numerosDeCuenta - 222

for (cuenta <- List(111, 555)) {
  if (numerosDeCuenta.contains(cuenta)) println(s"La cuenta $cuenta existe.")
  else println(s"La cuenta $cuenta no existe.")
}

println("Con la nueva cuenta: " + conNueva)  // Set(111, 222, 333, 444)
println("Sin la 222: " + sin222)             // Set(111, 333)
scala
// Ejercicio 5
val transaccionesBancarias = List(
  ("Titular1", "Cuenta1", 100.0),
  ("Titular2", "Cuenta2", -50.0),
  ("Titular1", "Cuenta1", 200.0),
  ("Titular3", "Cuenta3", -30.0)
)

val porTitular = transaccionesBancarias.groupBy(_._1)

val saldoPorTitular = porTitular.map {
  case (titular, transacciones) => (titular, transacciones.map(_._3).sum)
}

// Otra forma, sin pattern matching:
val saldoPorTitular2 = porTitular.map(e => (e._1, e._2.map(_._3).reduce(_ + _)))

saldoPorTitular.toList.sortBy(_._1).foreach { case (titular, saldo) =>
  println(s"$titular: $saldo")
}
text
Titular1: 300.0
Titular2: -50.0
Titular3: -30.0

Como un Map no garantiza el orden, para mostrar el resultado lo convertimos en lista y lo ordenamos por titular. Fijate también la diferencia entre sum y reduce(_ + _): dan lo mismo, pero reduce falla con una lista vacía, mientras que sum devuelve 0.

Resumen

  • List, Map, Set y las tuplas son inmutables: cada "cambio" devuelve una colección nueva.
  • Listas: head, tail, isEmpty, :: para agregar al principio, :+ al final.
  • Mapas: se arman con ->; se consultan con m(clave), get (devuelve Option), getOrElse y contains; se actualizan con +, updated y -. SortedMap mantiene las claves ordenadas.
  • Tuplas: ._1, ._2 o desarmándolas con val (a, b) = tupla. zip, unzip y unzip3 combinan y separan.
  • Sets: sin duplicados, con union, intersect y diff. Para el mutable, usá mutable.Set.
  • map, filter, foldLeft, groupBy, sortBy y exists son la base de cualquier transformación de datos.

← Volver al temario