DEV Community

David Goyes
David Goyes

Posted on

Combine #3: Operadores de Transformación

Un Operator es un método que ejecuta cierta operación sobre los valores emitidos por un Publisher. Todo Operator retorna un Publisher.

Acumulando valores

El operador collect() acumula un flujo de valores individuales de un Publisher hasta que recibe un evento de fin (.success.failure), y luego emite un arreglo a su suscriptor. Si no se establece un límite puede ser peligroso porque utilizará toda la memoria que necesite hasta que reciba un evento de fin.

let array = ["A", "B", "C", "D", "E"]
// Imprime cada letra en una línea:
array.publisher
  .sink(...) 
// Acumula todas las letras hasta que recibe un fin y luego imprime
array.publisher
    .collect()
    .sink(...)
// Acumulando en grupos con .collect(#)
array.publisher
    .collect(2)
    .sink(...)
Enter fullscreen mode Exit fullscreen mode

Transformando valores

El operador map(_:), cada vez que recibe un valor de un Publisher, opera sobre el valor recibido y emite un nuevo valor.

let input = [1, 2, 3]
input.publisher
    .map { $0 * 2 } // Reemite el doble del valor recibido del Publisher
    .sink ( ... )
Enter fullscreen mode Exit fullscreen mode

Debido a la orientación declarativa de Combine, cuando tengamos una estructura de datos compleja, conviene extraer algunos de sus valores (hasta tres) en una tupla, que pueda ser procesada por otro operador. Para ello se usa map<T>(_:), map<T0, T1>(_:_:), map<T0, T1, T2>(_:_:_:).

let publisher = PassthroughSubject<Coordinate, Never>()
publisher
    .map(\.x, \.y)
    .sink(receiveValue: { x, y in
        /// ...
    })
Enter fullscreen mode Exit fullscreen mode

Con tryMap(:), al tratar de efectuar una operación sobre un valor recibido del Publisher es posible ocurra error que se puede arrojar (throw) para ser procesado como .failure en el Subscriber.

Just("Invalid-directory-name")
  .tryMap { try FileManager.default.contentsOfDirectory(atPath: $0) }
  .sink(...)
Enter fullscreen mode Exit fullscreen mode

Aplanando publishers

El operador flatMap(maxPublishers:_:) en Combine sirve para transformar cada valor emitido por un Publisher en otro Publisher, y luego "aplana" (flat) esos Publishers internos en un único flujo de salida. flatMap(maxPublishers:_:) recibe un maxPublishers opcional que limita cuántos Publishers internos pueden estar activos al mismo tiempo. También recibe un closure que devuelve otro Publisher.

// 1
let userIDs = [1, 2, 3].publisher
userIDs
    // 2
    .flatMap(maxPublishers: .max(2)) { id in
        // 2
        URLSession.shared.dataTaskPublisher(for: URL(string: "https://api.example.com/users/\(id)")!)
            .map(\.data)
            .decode(type: User.self, decoder: JSONDecoder())
            .replaceError(with: User.placeholder)
    }
    .sink { user in
        // 3
        print("Usuario recibido: \(user.name)")
    }
Enter fullscreen mode Exit fullscreen mode

En el ejemplo anterior:

  1. Tenemos un arreglo de IDs de usuarios que vamos a descargar, que se convierte en Publisher.
  2. Cada valor emitido (o sea cada ID) se convierte en otro Publisher al crear una petición web de URLSession. No obstante, aunque se tenían 3 IDs no se van a devolver 3 Publishers. En su lugar, se retorna un solo Publisher que va a emitir el valor que emita cada uno de esos tres Publishers.
  3. sink se suscribe al Publisher retornado, así que espera recibir la respuesta a las 3 peticiones web construidas con los 3 IDs.
  4. .max(2) controla cuántos Publishers internos pueden estar activos al mismo tiempo. Esto significa que Combine solo permite dos peticiones web simultáneas. Cuando una de esas dos termina, se libera un "espacio" y empieza la siguiente. Esto quiere decir que usando flatMap, al recibir el 3, Combine espera hasta que alguna de las descargas anteriores termine, antes de crear el tercer Publisher.

Esto sirve para:

  • Control de concurrencia: evita saturar la red o el servidor con demasiadas peticiones simultáneas.
  • Control de memoria: limita la cantidad de Publishers activos a la vez.
  • Rendimiento más predecible: mantiene la aplicación más estable si hay muchos valores que generarían Publishers.

Imaginemos que tenemos que descargar 100 usuarios: Con .unlimited, Combine haría las 100 peticiones al tiempo, que sería demasiado. Con .max(2), solo haría 2 al tiempo. De este modo, cuando una termina, otra empieza.

Esto es el equivalente a una cola de concurrencia limitada.

Filtrando nulos de un flujo de datos

En el escenario donde tengamos un Publisher que emita valores opcionales (e.g Int?), y necesitemos emitir valores requeridos siempre (e.g. Int), podemos usar el operador replaceNil(with:) que trabaja como el operador ??; es decir: si el valor recibido es distinto de nulo, lo vuelve a emitir, en caso contrario retorna el valor por defecto.

["A", nil, "C"].publisher
  .eraseToAnyPublisher() // 1
  .replaceNil(with: "-") // 2
  .sink(receiveValue: { print($0) }) // 3
Enter fullscreen mode Exit fullscreen mode

El código anterior produce la salida "A", "-", "C". El operador eraseToAnyPublisher() se utiliza porque replaceNil(with:) confunde la inferencia de tipos de Swift y hace que se produzca un String? incluso aunque haya total certeza de que siempre habrá un valor.

Emitir al menos un valor antes de terminar

Si un Publisher termina sin emitir ningún valor, se puede usar el operador replaceEmpty(with:) para emitir un valor antes de terminar.

// 1
let empty = Empty<Int, Never>() // (completeImmediately: true)
empty
    .replaceEmpty(with: 1) // 2
    .sink(receiveCompletion: { print($0) },
          receiveValue: { print($0) })
    .store(in: &subscriptions)
Enter fullscreen mode Exit fullscreen mode

En el código anterior:

  1. Se crea una instancia de tipo Empty que simplemente emite un evento de fin.
  2. El operador replaceEmpty(with:) modifica el Publisher, emitiendo un 1 cuando recibe el evento de fin y luego emite un evento de fin.

Transformando la salida de forma incremental

Combine tiene una operación muy parecida al reduce imperativo que se llama scan(_:_:), cuyos dos parámetros son un valor inicial (initialResult) y un closure. El closure, a su vez, tiene dos parámetros, hace una operación y retorna un resultado. Cuando scan(_:_:) recibe un evento ejecuta el closure pasando el valor retornado por él mismo en la iteración anterior (latest), seguido del nuevo valor recibido por scan(_:_:) (current). Si es la primera vez que se ejecuta el closure, entonces latest es initialResult.

var subject = PassthroughSubject<Int, Never>()

subject
  .scan(1) { latest, current in
      let result = latest + current
      print("🧮 SCAN:", "latest", latest, "current", current, "result", result)
      return result
  }
  .sink(receiveValue: { print("📤 SINK:", $0) })
  .store(in: &subscriptions)

subject.send(1)
// 🧮 SCAN: latest 1 current 1 result 2
// 📤 SINK: 2
subject.send(2)
// 🧮 SCAN: latest 2 current 2 result 4
// 📤 SINK: 4
subject.send(completion: .finished)
Enter fullscreen mode Exit fullscreen mode

scan(_:_:) tiene un primo llamado tryScann(_:_:) que funciona muy parecido. La diferencia radica en que si el closure arroja una excepción, el Publisher de scan(_:_:) emitirá un fin con error (.failure).


Cuestionario

1. Explica la diferencia entre collect() y collect(_:)

(Menciona cómo afecta al comportamiento del flujo de valores y a la memoria).

2. ¿Qué hace el operador flatMap(maxPublishers:_:) y por qué se dice que "aplana" los Publishers

(Incluye un ejemplo de cuándo sería útil limitar maxPublishers.)

3. ¿En qué se diferencia map(_:) de tryMap(_:) dentro de Combine? 

(Explica en qué escenarios usarías uno u otro.)

4. Describe una situación práctica donde usarías replaceNil(with:) o replaceEmpty(with:). (Por ejemplo, al procesar datos opcionales o flujos vacíos.)

5. ¿Cómo funciona el operador scan(_:_:) y en qué se parece al método reduce de Swift? (Comenta qué tipo de transformaciones se pueden lograr con él.)

6. El operador collect():

  • [ ] Emite cada valor tan pronto lo recibe.
  • [ ] Acumula los valores hasta recibir un evento de finalización y luego emite un arreglo.
  • [ ] Descarta todos los valores que no sean del mismo tipo.
  • [ ] Solo puede usarse con Publishers de tipo Int. ### 7. El operador flatMap(maxPublishers:_:) permite:
  • [ ] Convertir un Publisher en varios Publishers independientes.
  • [ ] Combinar varios Publishers en uno solo, controlando la cantidad de Publishers activos simultáneamente.
  • [ ] Filtrar valores nulos antes de emitirlos.
  • [ ] Sustituir valores vacíos por un valor predeterminado. ### 8. En el siguiente código:
["A", nil, "C"].publisher
  .eraseToAnyPublisher()
  .replaceNil(with: "-")
  .sink { print($0) }
Enter fullscreen mode Exit fullscreen mode

¿Por qué se usa eraseToAnyPublisher()?

  • [ ] Para eliminar los valores nulos del arreglo.
  • [ ] Para resolver un problema de inferencia de tipos con replaceNil(with:).
  • [ ] Para hacer que el Publisher sea perezoso ("lazy").
  • [ ] Porque replaceNil no puede usarse directamente con Array.publisher. ### 9. ¿Qué hace el operador replaceEmpty(with:)?
  • [ ] Reemplaza todos los valores del Publisher por el valor indicado.
  • [ ] Emite un valor si el Publisher termina sin emitir ninguno.
  • [ ] Evita que el Publisher se complete automáticamente.
  • [ ] Reintenta la suscripción hasta recibir un valor. ### 10. El operador scan(_:_:):
  • [ ] Requiere que el Publisher sea de tipo Int.
  • [ ] Permite acumular resultados parciales en cada emisión.
  • [ ] Solo se ejecuta al final del flujo, como reduce.
  • [ ] Emite únicamente el valor acumulado final.

Solución

1. Explica la diferencia entre collect() y collect(_:).

 (Menciona cómo afecta al comportamiento del flujo de valores y a la memoria).

collect() acumula todos los valores y emite un solo arreglo al final, mientras que collect(_:) agrupa los valores en lotes del tamaño indicado y emite varios arreglos parciales.

2. ¿Qué hace el operador flatMap(maxPublishers:_:) y por qué se dice que "aplana" los Publishers?

 (Incluye un ejemplo de cuándo sería útil limitar maxPublishers.)
flatMap(maxPublishers:_:) crea un Publisher por cada valor recibido y retorna un solo Publisher que reemite los valores emitidos por los Publishers creados. maxPublishers limita el uso de memoria.

3. ¿En qué se diferencia map(:) de tryMap(:) dentro de Combine?

 (Explica en qué escenarios usarías uno u otro.)
Ambos sirven para transformar streams de datos. La diferencia radica en que el closure de tryMap(_:) puede arrojar un error y, cuando lo hace, el stream de salida de detiene con un evento de falla (.failure).

4. Describe una situación práctica donde usarías replaceNil(with:) o replaceEmpty(with:).

 (Por ejemplo, al procesar datos opcionales o flujos vacíos.)
replaceNil(with:) se usa cuando tengo un stream de datos opcionales pero siempre necesito un valor. Además, lo uso en conjunto con eraseToAnyPublisher. Por otro lado, replaceEmpty(with:) lo uso si necesito que mi Subscriber reciba al menos un valor en el stream de entrada.

5. ¿Cómo funciona el operador scan(_:_:) y en qué se parece al método reduce de Swift?

 (Comenta qué tipo de transformaciones se pueden lograr con él.)
scan(_:_:) ejecuta un closure que retorna un valor, y recibe el valor anterior retornado por el closure y el nuevo valor recibido en el stream por scan. En la primera iteración se usa un initialValue.

6. El operador collect():

  • [✅] Acumula los valores hasta recibir un evento de finalización y luego emite un arreglo.

7. El operador flatMap(maxPublishers:_:) permite:

  • [✅] Combinar varios Publishers en uno solo, controlando la cantidad de Publishers activos simultáneamente.

8. En el siguiente código:

["A", nil, "C"].publisher
  .eraseToAnyPublisher()
  .replaceNil(with: "-")
  .sink { print($0) }
Enter fullscreen mode Exit fullscreen mode

¿Por qué se usa eraseToAnyPublisher()?

  • [✅] Para resolver un problema de inferencia de tipos con replaceNil(with:).

9. ¿Qué hace el operador replaceEmpty(with:)?

  • [✅] Emite un valor si el Publisher termina sin emitir ninguno.

10. El operador scan(_:_:):

  • [✅] Permite acumular resultados parciales en cada emisión.

Top comments (0)