Skip to content

Vavr 1.0: estructuras inmutables y colecciones funcionales

Posted on:21 de julio de 2026 at 02:00

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:

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:

NecesidadColección sugerida
Añadir al principio y recorrer secuencialmenteList
Acceder frecuentemente por índiceVector
Evitar duplicados y consultar pertenenciaHashSet
Mantener elementos ordenadosTreeSet
Asociar claves y valoresHashMap o TreeMap
Procesar elementos en orden FIFOQueue
Representar una secuencia perezosa o infinitaStream

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:

Evítala si:

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:

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ónOrdenComplejidad contains/add/remove
HashSet<T>NingunoO(1) promedio
TreeSet<T>Natural (Comparable)O(log n)
LinkedHashSet<T>InserciónMayor 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:

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:

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:

Structural sharing: el coste real de la inmutabilidad

La promesa de las colecciones persistentes es que “modificar” no copia todo. Veamos qué ocurre internamente:

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 inmutableNecesitas máximo rendimiento raw (hot path numérico)
Compartes estado entre hilosLa API externa exige java.util.List mutable y no quieres convertir
Valoras corrección y legibilidadEl equipo no conoce Vavr y no hay tiempo para curva de aprendizaje
Necesitas operaciones funcionales ricasSerialización con librerías que no soportan Vavr (Jackson necesita módulo)
Quieres pattern matching sobre estructuraUsas 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ónListVectorHashSet/MapTreeSet/MapQueue
head / firstO(1)O(1)O(log n)O(1)
get(index)O(n)O(log32 n)
update(index)O(n)O(log32 n)
prependO(1)O(log32 n)
appendO(n)O(log32 n)O(1) amortizado
contains(value)O(n)O(n)O(1) promedioO(log n)O(n)
put / addO(1) promedioO(log n)O(1) amortizado
remove(value)O(n)O(n)O(1) promedioO(log n)O(n)
dequeueO(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.