En el capítulo anterior configuramos el proyecto, vimos Option, funciones de orden superior y una primera pincelada de colecciones inmutables. Ahora vamos a profundizar en las principales colecciones de Vavr: sus operaciones, sus costes y cómo decidir cuál usar en cada situación.
Si todavía no has leído la introducción a la serie, te recomiendo echarle un vistazo para conocer el temario completo.
Por qué inmutabilidad y persistencia
En Java, las colecciones estándar (ArrayList, HashMap, HashSet) son mutables: cualquier operación que “modifica” la colección altera la instancia original. Esto obliga a copiar manualmente cuando se comparte una colección entre hilos o capas de la aplicación, o a confiar en que nadie la mutará inesperadamente.
Las colecciones de Vavr son inmutables (no cambian tras su creación) y persistentes (una versión modificada comparte estructura con la original). Esto permite:
- Compartir colecciones entre hilos sin sincronización.
- Mantener historial de versiones sin copiar todo el contenido.
- Razonar sobre el código sin efectos laterales ocultos.
El secreto no es copiar todo al modificar, sino compartir estructura (structural sharing). Una List en Vavr es una lista enlazada persistente; añadir un elemento al frente crea un nodo nuevo que apunta a la lista original —O(1) tiempo y memoria adicional. Estructuras como Vector, HashMap y HashSet aplican el mismo principio con representaciones internas diferentes.
La colección es inmutable, pero sus elementos no lo son automáticamente. Una List<Cliente> puede contener objetos Cliente mutables; Vavr solo garantiza que no cambia la estructura de la lista.
Cómo elegir una colección
Vavr agrupa sus colecciones bajo io.vavr.collection. Para empezar, esta guía práctica resulta más útil que memorizar toda la jerarquía de tipos:
| Necesidad | Colección sugerida |
|---|---|
| Añadir al principio y recorrer secuencialmente | List |
| Acceder frecuentemente por índice | Vector |
| Evitar duplicados y consultar pertenencia | HashSet |
| Mantener elementos ordenados | TreeSet |
| Asociar claves y valores | HashMap o TreeMap |
| Procesar elementos en orden FIFO | Queue |
| Representar una secuencia perezosa o infinita | Stream |
Comparten muchas operaciones funcionales, como map, filter, flatMap, foldLeft, find, groupBy, take y drop. El tipo devuelto conserva, cuando tiene sentido, las propiedades de la colección original.
List: lista enlazada persistente
io.vavr.collection.List es la estructura más básica: una lista simplemente enlazada inmutable. Operaciones en la cabeza son O(1); acceso por índice o cola son O(n).
package net.javabackend.vavr;
import io.vavr.collection.List;
public class ListDemo {
public static void main(String[] args) {
List<String> lenguajes = List.of("Java", "Kotlin");
List<String> masLenguajes = lenguajes.prepend("Scala").append("Groovy");
System.out.println(lenguajes); // List(Java, Kotlin)
System.out.println(masLenguajes); // List(Scala, Java, Kotlin, Groovy)
// Operaciones funcionales encadenadas
List<Integer> numeros = List.rangeClosed(1, 10);
List<Integer> paresCuadrados = numeros
.filter(n -> n % 2 == 0)
.map(n -> n * n);
System.out.println(paresCuadrados); // List(4, 16, 36, 64, 100)
String descripcion = numeros
.headOption()
.map(cabeza -> "cabeza=" + cabeza + ", cola=" + numeros.tail().length())
.getOrElse("vacía");
System.out.println(descripcion); // cabeza=1, cola=9
}
}
Salida:
List(Java, Kotlin)
List(Scala, Java, Kotlin, Groovy)
List(4, 16, 36, 64, 100)
cabeza=1, cola=9
Cuándo usar List:
- Recursión sobre cabeza/cola (
head/tail). - Prepend frecuente (
prepend,::). - Tamaño pequeño o moderado donde
O(n)en cola no penaliza.
Evítala si:
- Necesitas acceso aleatorio por índice frecuente → usa
Vector. - Necesitas añadir al final eficientemente → usa
VectoroQueue.
Vector: acceso aleatorio eficiente
io.vavr.collection.Vector usa un árbol con factor de ramificación 32 (similar al persistent vector de Clojure/Scala). El acceso y la actualización tienen coste O(log32 n): técnicamente logarítmico, pero con una profundidad pequeña incluso para colecciones grandes.
package net.javabackend.vavr;
import io.vavr.collection.Vector;
public class VectorDemo {
public static void main(String[] args) {
Vector<String> tareas = Vector.of("Diseñar", "Implementar");
Vector<String> conTest = tareas.append("Testear");
Vector<String> conDeploy = conTest.prepend("Deploy");
System.out.println(tareas); // Vector(Diseñar, Implementar)
System.out.println(conTest); // Vector(Diseñar, Implementar, Testear)
System.out.println(conDeploy); // Vector(Deploy, Diseñar, Implementar, Testear)
// Acceso por índice eficiente
System.out.println(conDeploy.get(2)); // Implementar
// Actualización funcional (devuelve nuevo Vector)
Vector<String> corregido = conDeploy.update(0, "Desplegar");
System.out.println(corregido); // Vector(Desplegar, Diseñar, Implementar, Testear)
}
}
Salida:
Vector(Diseñar, Implementar)
Vector(Diseñar, Implementar, Testear)
Vector(Deploy, Diseñar, Implementar, Testear)
Implementar
Vector(Desplegar, Diseñar, Implementar, Testear)
Cuándo usar Vector:
- Acceso por índice frecuente.
- Añadir/quitar al final y al principio.
- Tamaño grande donde
ListseríaO(n)en cola.
El acceso eficiente por índice no acelera todas las operaciones: buscar un valor con contains sigue requiriendo recorrer elementos y cuesta O(n).
Set: conjuntos sin duplicados
Vavr ofrece tres implementaciones principales:
| Implementación | Orden | Complejidad contains/add/remove |
|---|---|---|
HashSet<T> | Ninguno | O(1) promedio |
TreeSet<T> | Natural (Comparable) | O(log n) |
LinkedHashSet<T> | Inserción | Mayor coste que HashSet |
package net.javabackend.vavr;
import io.vavr.collection.HashSet;
import io.vavr.collection.LinkedHashSet;
import io.vavr.collection.TreeSet;
public class SetDemo {
public static void main(String[] args) {
HashSet<String> habilidades = HashSet.of("Java", "SQL", "Java"); // duplicado ignorado
System.out.println(habilidades.size()); // 2
// Orden natural
TreeSet<Integer> numeros = TreeSet.of(3, 1, 4, 1, 5);
System.out.println(numeros); // TreeSet(1, 3, 4, 5)
// Orden de inserción
LinkedHashSet<String> pasos = LinkedHashSet.of("Paso 3", "Paso 1", "Paso 2");
System.out.println(pasos); // LinkedHashSet(Paso 3, Paso 1, Paso 2)
// Operaciones de conjunto
HashSet<Integer> a = HashSet.of(1, 2, 3);
HashSet<Integer> b = HashSet.of(3, 4, 5);
System.out.println(a.union(b).size()); // 5
System.out.println(a.intersect(b).size()); // 1
System.out.println(a.diff(b).size()); // 2
}
}
Salida:
2
TreeSet(1, 3, 4, 5)
LinkedHashSet(Paso 3, Paso 1, Paso 2)
5
1
2
Cuándo usar cada uno:
HashSet: pertenencia rápida, sin orden.TreeSet: necesitas orden natural o rangos (subset,from,until).LinkedHashSet: iterar en orden de inserción cuando ese orden sea parte del requisito.
El orden textual de un HashSet no forma parte de su contrato. Evita escribir pruebas o lógica que dependan del orden mostrado por toString().
Map: asociaciones clave-valor inmutables
Análogos a Set, Vavr provee HashMap, TreeMap y LinkedHashMap. La estructura del mapa es inmutable: put y remove devuelven otra versión. Sin embargo, Vavr no vuelve inmutables los objetos usados como claves o valores; esa responsabilidad sigue siendo del modelo de dominio.
package net.javabackend.vavr;
import io.vavr.collection.HashMap;
import io.vavr.collection.Map;
public class MapDemo {
public static void main(String[] args) {
Map<String, Integer> edades = HashMap.of(
"Ada", 36,
"Alan", 41
);
// Añadir / actualizar -> nuevo Map
Map<String, Integer> conGrace = edades.put("Grace", 85);
Map<String, Integer> adaActualizada = conGrace.put("Ada", 37);
String mensaje = adaActualizada.get("Ada")
.map(e -> "Edad de Ada: " + e)
.getOrElse("No encontrada");
System.out.println(mensaje); // Edad de Ada: 37
System.out.println(edades.size()); // 2
System.out.println(conGrace.size()); // 3
System.out.println(adaActualizada.size()); // 3
}
}
Salida:
Edad de Ada: 37
2
3
3
Operaciones útiles de Map:
mapValues(f),mapKeys(f)— transformar valores o claves.filterKeys(p),filterValues(p)— filtrar por clave o valor.merge(other, (v1, v2) -> ...)— combinar dos mapas resolviendo colisiones.toMap(entries)— construir desdeSeq<Tuple2<K,V>>.
Queue: colas FIFO y por prioridad
package net.javabackend.vavr;
import io.vavr.collection.Queue;
import io.vavr.collection.PriorityQueue;
import io.vavr.Tuple2;
public class QueueDemo {
public static void main(String[] args) {
// Cola FIFO inmutable
Queue<String> cola = Queue.of("tarea-1", "tarea-2");
Queue<String> encolada = cola.enqueue("tarea-3");
Tuple2<String, Queue<String>> desencolada = encolada.dequeue();
System.out.println(desencolada._1); // tarea-1
System.out.println(desencolada._2); // Queue(tarea-2, tarea-3)
// PriorityQueue (orden natural o Comparator)
PriorityQueue<Integer> pq = PriorityQueue.of(5, 1, 3);
Tuple2<Integer, PriorityQueue<Integer>> primero = pq.dequeue();
Tuple2<Integer, PriorityQueue<Integer>> segundo = primero._2.dequeue();
System.out.println(primero._1); // 1
System.out.println(segundo._1); // 3
}
}
Salida:
tarea-1
Queue(tarea-2, tarea-3)
1
3
dequeue() falla si la cola está vacía. Cuando no puedes garantizar que contiene elementos, utiliza dequeueOption(), que devuelve un Option<Tuple2<T, Queue<T>>>.
Stream: secuencias perezosas
io.vavr.collection.Stream es una secuencia perezosa (evalúa bajo demanda) y memoriza valores ya calculados. A diferencia de java.util.Stream, se puede recorrer múltiples veces y soporta operaciones infinitas.
package net.javabackend.vavr;
import io.vavr.collection.Stream;
public class StreamDemo {
public static void main(String[] args) {
// Stream infinito de números naturales
Stream<Integer> naturales = Stream.from(1);
// Tomamos los primeros 5 pares al cuadrado
Stream<Integer> resultado = naturales
.filter(n -> n % 2 == 0)
.map(n -> n * n)
.take(5);
System.out.println(resultado); // Stream(4, 16, 36, 64, 100)
// El stream original sigue utilizable
System.out.println(naturales.take(3)); // Stream(1, 2, 3)
}
}
Cuándo usar Stream:
- Secuencias potencialmente infinitas.
- Pipelines donde no quieres materializar toda la colección.
- Cuando encadenas muchas operaciones y la evaluación perezosa ahorra trabajo.
Structural sharing: el coste real de la inmutabilidad
La promesa de las colecciones persistentes es que “modificar” no copia todo. Veamos qué ocurre internamente:
- List: prepend = nuevo nodo apuntando a la cabeza anterior (
O(1)). Tail comparte toda la cola. - Vector: árbol de 32 vías. Actualizar un índice copia el camino desde la raíz hasta la hoja (
O(log32 n)≈ 5-6 nodos para millones de elementos). El resto del árbol se comparte. - HashMap/HashSet: árbol hash array mapped trie (HAMT). Igual que Vector:
O(log32 n)nodos nuevos. - TreeMap/TreeSet: árbol rojo-negro persistente.
O(log n)nodos nuevos.
Esto significa que crear una versión modificada puede reutilizar gran parte de la estructura anterior. El coste exacto depende de la colección y de la operación: no todas las actualizaciones son constantes ni todas comparten la misma representación interna.
Ejemplo conceptual (Vector de 1000 elementos, actualizar índice 500):
Raíz (nivel 0)
├─ Nodo A (nivel 1) ← se copia
│ ├─ Nodo B (nivel 2) ← se copia
│ │ └─ ... hoja con índice 500 ← se copia
│ └─ Nodo C (sin cambios) ← COMPARTIDO
└─ Nodo D (sin cambios) ← COMPARTIDO
En este ejemplo conceptual solo se copian los nodos del camino hacia la posición actualizada; las demás ramas se reutilizan.
Interoperabilidad con Java
Vavr provee métodos de instancia para convertir sus colecciones a tipos de Java y fábricas para realizar la conversión inversa:
package net.javabackend.vavr;
import io.vavr.collection.List;
import java.util.ArrayList;
public class InteropDemo {
public static void main(String[] args) {
// Java -> Vavr
java.util.List<String> javaList = new ArrayList<>(
java.util.List.of("a", "b", "c")
);
List<String> vavrList = List.ofAll(javaList);
// Vavr -> Java
java.util.List<String> deVuelta = vavrList.toJavaList();
// Stream de Java
long cantidad = vavrList
.toJavaStream()
.filter(s -> s.compareTo("a") > 0)
.count();
System.out.println(vavrList); // List(a, b, c)
System.out.println(deVuelta); // [a, b, c]
System.out.println(cantidad); // 2
}
}
La conversión crea una colección Java separada. Modificar deVuelta no cambia vavrList. Usa métodos como toJavaList(), toJavaSet(), toJavaMap() y toJavaStream() en límites de API como JPA, Jackson o JDBC.
Ejemplo de dominio: modelo de pedido con colecciones inmutables
El siguiente ejemplo combina List, Map, Set y Option en un caso de dominio. Los importes se representan en centavos para no introducir errores de redondeo con double.
package net.javabackend.vavr.domain;
import io.vavr.collection.List;
import io.vavr.collection.Map;
import io.vavr.collection.Set;
import io.vavr.collection.HashMap;
import io.vavr.collection.HashSet;
import io.vavr.control.Option;
record LineaPedido(
String sku,
String descripcion,
int cantidad,
int precioUnitarioCentavos
) {
public int subtotal() {
return cantidad * precioUnitarioCentavos;
}
}
record Pedido(
String id,
List<LineaPedido> lineas,
Map<String, Integer> descuentosPorSku, // SKU -> % descuento
Set<String> etiquetas
) {
public int totalBruto() {
return lineas.map(LineaPedido::subtotal).sum().intValue();
}
public int totalConDescuentos() {
return lineas.map(linea -> {
int descuento = descuentosPorSku.get(linea.sku()).getOrElse(0);
return linea.subtotal() * (100 - descuento) / 100;
}).sum().intValue();
}
public Option<LineaPedido> lineaConSku(String sku) {
return lineas.find(l -> l.sku().equals(sku));
}
public Pedido agregarLinea(LineaPedido nueva) {
return new Pedido(id, lineas.append(nueva), descuentosPorSku, etiquetas);
}
public Pedido conEtiqueta(String etiqueta) {
return new Pedido(id, lineas, descuentosPorSku, etiquetas.add(etiqueta));
}
}
// Uso
public class PedidoDemo {
public static void main(String[] args) {
Pedido pedido = new Pedido(
"PED-001",
List.of(
new LineaPedido("SKU-001", "Teclado", 2, 8_000),
new LineaPedido("SKU-002", "Mouse", 1, 4_000)
),
HashMap.of("SKU-001", 10), // 10% dto en teclados
HashSet.of("web", "urgente")
);
System.out.println("Bruto: " + pedido.totalBruto()); // 20000
System.out.println("Con dto: " + pedido.totalConDescuentos()); // 18400
Pedido conMonitor = pedido.agregarLinea(
new LineaPedido("SKU-003", "Monitor", 1, 20_000)
);
System.out.println("Líneas: " + conMonitor.lineas().size()); // 3
pedido.lineaConSku("SKU-001")
.map(LineaPedido::descripcion)
.peek(System.out::println); // Teclado
}
}
Salida:
Bruto: 20000
Con dto: 18400
Líneas: 3
Teclado
Observa cómo agregarLinea y conEtiqueta devuelven nuevos Pedido inmutables, reutilizando las colecciones internas gracias a structural sharing. El Pedido original permanece intacto.
En un sistema real también habría que validar cantidades, porcentajes y posibles desbordamientos. La inmutabilidad no sustituye las reglas del dominio.
Cuándo (no) usar colecciones Vavr
| ✅ Úsalas cuando… | ❌ Evítalas si… |
|---|---|
| Modelas dominio inmutable | Necesitas máximo rendimiento raw (hot path numérico) |
| Compartes estado entre hilos | La API externa exige java.util.List mutable y no quieres convertir |
| Valoras corrección y legibilidad | El equipo no conoce Vavr y no hay tiempo para curva de aprendizaje |
| Necesitas operaciones funcionales ricas | Serialización con librerías que no soportan Vavr (Jackson necesita módulo) |
| Quieres pattern matching sobre estructura | Usas JPA/Hibernate directamente sobre entidades (mejor mapear a DTOs Vavr en capa de repositorio) |
Serialización: Para Jackson, añade módulo vavr-jackson. Para bases de datos, mapea a/desde tipos JPA en la capa de repositorio.
Resumen de complejidades
| Operación | List | Vector | HashSet/Map | TreeSet/Map | Queue |
|---|---|---|---|---|---|
| head / first | O(1) | O(1) | — | O(log n) | O(1) |
| get(index) | O(n) | O(log32 n) | — | — | — |
| update(index) | O(n) | O(log32 n) | — | — | — |
| prepend | O(1) | O(log32 n) | — | — | — |
| append | O(n) | O(log32 n) | — | — | O(1) amortizado |
| contains(value) | O(n) | O(n) | O(1) promedio | O(log n) | O(n) |
| put / add | — | — | O(1) promedio | O(log n) | O(1) amortizado |
| remove(value) | O(n) | O(n) | O(1) promedio | O(log n) | O(n) |
| dequeue | — | — | — | — | O(1) amortizado |
La tabla resume el comportamiento esperado, no una garantía de rendimiento para cualquier carga. Distribución de hashes, tamaño, memoria y versión de la JVM pueden cambiar los resultados. En PriorityQueue, insertar o retirar el elemento prioritario cuesta O(log n).
Siguiente capítulo
Ya dominamos las colecciones inmutables y su structural sharing. En el capítulo 3 abordaremos el manejo de errores con Try, Either y Validation: cómo modelar fallos como valores, componer operaciones que pueden fallar y validar entradas de forma acumulativa.
Consulta la documentación oficial de Vavr y el repositorio del proyecto para ampliar los ejemplos.