Transformado Publisher parte 2

Andres Felipe Ocampo
12 min readJul 23, 2021

--

Using flatMap in Combine

El último de los tres tipos de mapeo que quería mostrarte es flatMap. Cuando me estaba familiarizando con conceptos como map y flatMap en la programación funcional, estaba algo confundido acerca de esta curiosa función. Y seamos honestos, la breve descripción de flatMap en la documentación no es fantástica:

Returns an array containing the concatenated results of calling the given transformation with each element of this sequence.

Afortunadamente, podemos encontrar una explicación algo mejor en la sección Discusión de la documentación:

Use this method to receive a single-level collection when your transformation produces a sequence or collection for each element.

Y para colmo, Apple proporciona un ejemplo fácil de seguir que he decidido copiar y pegar:

En este código de ejemplo, un array de números se transforma con map y flatMap respectivamente. La transformación que se aplica a cada elemento convierte el array de Int en un array de [Int], lo que significa que es un array que contiene otros array’s. En el map normal, obtenemos exactamente eso, una matriz de matrices. El ejemplo flatMap devuelve un array de Int. Ha “aplanado” los array’s anidados para asegurarse de que obtendríamos un array con un nivel de anidación eliminado.

Usar flatMap en un array es equivalente a usar map y luego llamar a join () en la colección resultante. Puedes probarlo tu mismo si lo deseas.

Hasta ahora, hemos podido pensar en los publishers de Combine como casi análogos a las colecciones de Swift. Para flatMap, esta misma analogía es válida. Si queremos aplicar un operador a la salida de un publisher que transformaría esa salida en un nuevo publisher, tendríamos un publisher que publica otros publishers. Veamos un ejemplo:

Este ejemplo utiliza un publisher de secuencias para publicar varias cadenas que apuntan a páginas de mi sitio web. Cada cadena se usa para crear una URL, y esta URL luego se usa para crear un nuevo DataTaskPublisher. Los valores que terminan en el .sink no son el resultado de las tareas de datos. En cambio, los propios publishers se entregan al .sink.

Esto no es particularmente útil y podemos usar flatMap para cambiar esto:

El código anterior no se compila en iOS 13, pero funciona bien para iOS 14. El tipo de error del editor de secuencias es Never, y DataTaskPublisher tiene URLError como tipo de error. Cuando utiliza flatMap en Combine, el tipo de error del nuevo publisher debe coincidir con el del publisher de origen (a menos que tenga iOS 14 y el tipo de error del editor de origen sea Never).

Para iOS 13, debemos asegurarnos de que el editor sobre el que flatMap tenga el mismo tipo de falla que el editor que creamos en flatMap. Eso significa que debemos asegurarnos de que todas las fallas de la tarea de datos que se crea en el mapa plano se reemplacen con un valor predeterminado para que su tipo de falla sea Never, o debemos cambiar el tipo de falla del editor de secuencias a URLError. Dado que no debemos ocultar ningún URLError emitido por las tareas de datos creadas en el mapa plano anterior, debemos cambiar el tipo de error del editor de la secuencia para que coincida con URLError.

Podemos hacer esto con el operador setFailureType. Este operador crea un nuevo publisher con una salida sin cambios, pero cambia la falla al tipo de error que usted proporciona. Deberías insertar este operador antes del operador flatMap y llamarlo de la siguiente manera para el ejemplo anterior: .setFailureType (to: URLError.self).
Este código se compila, pero si lo ejecuta en un playground, no sucede nada. Esto se debe a que este código se ejecuta de forma asincrónica, lo que significa que el AnyCancellable que obtenemos del método de .sink se desasigna tan pronto como se sale del alcance de ejecución actual, y el flujo de suscripción se elimina como expliqué en el artículo anterior. Para solucionar este problema, debe mantener AnyCancellable de esta manera:

Si ejecutas este código en un playground, verás que el .sink ahora recibe el resultado de cada tarea de datos. También encontrarás que no se llama a receiveCompletion hasta que finaliza la última tarea de datos.

Limpio, ¿verdad? Usamos flatMap para transformar un publisher que publica valores de cadena en un editor que publica editores de tareas de datos, y nivelamos esta jerarquía con flatMap, lo que hizo que los publishers de tareas de datos intermedios fueran invisibles para el .sink. Consulta el siguiente diagrama de canicas para ver cómo se ve este proceso cuando se visualiza.

En este caso, queríamos obtener todos los resultados de las tareas de datos. Pero, ¿qué pasa si nos encontramos en una situación en la que ese no es el caso?

Limitar el número de editores activos que produce flatMap

Hasta ahora te he mostrado cómo puedes usar flatMap de una manera bastante inmediata. Recibíamos valores, los cambiamos a un nuevo publisher y los resultados del publisher anidado se enviaban al receptor. Hay momentos en los que esto no es lo que quieres. Considera un escenario en el que realiza solicitudes de API en función de algo que hace un usuario. Si el usuario realizara esta acción lo suficientemente rápido, y nuestro código no lo maneja de manera adecuada, podríamos terminar con muchas llamadas API simultáneas que se ejecutan al mismo tiempo.

En el artículo anterior mencioné brevemente la contrapresión y continuaré mencionando este término a lo largo del tiempo. Expliqué brevemente que la contrapresión se relaciona con la forma en que Combine permite a los suscriptores comunicar cuántos valores desean recibir del publisher al que están suscritos. En otras palabras, pueden comunicar la cantidad de información que desean recibir de su publisher original. Resulta que los publishers también pueden limitar la información que reciben de su editor original. El operador flatMap admite esto a través de su argumento maxPublisher. Me gustaría recomendar encarecidamente que abras un playground en Xcode y ejecutes el siguiente código:

El siguiente código debería resultar familiar, pero hay un nuevo operador print(). Con el operador de print() de Combine, puedes echar un vistazo a lo que está sucediendo antes de ese operador de print(). Entonces, en este caso, podemos echar un vistazo a lo que hace el editor de secuencias. Este código produciría el siguiente resultado:

Quiero centrarme solo en las dos primeras líneas:

Estas dos líneas contienen un montón de información. Primero, nos dice que el publisher recibe una suscripción en algún momento y que luego recibe una solicitud ilimitada. Lo que esto significa es que se le pide al publisher que produzca tantos artículos como quiera, sin límites. El publisher trabaja al servicio del suscriptor por lo que inmediatamente atiende esta solicitud y comienza a enviar valores. Pero, ¿qué sucede si reemplazamos flatMap con flatMap (maxPublishers :)? Vamos a averiguar:

El código no ha cambiado mucho. La única diferencia es que al flatMap se le pasa un valor maxPublishers: .max(1). Podemos proporcionar cualquier valor Int para el número máximo de publishers. Las opciones alternativas son pasar .unlimited (el predeterminado) o .none, lo que significa que nunca recibiremos ningún valor.

Observa cómo, en lugar de solicitar ilimitado, la salida ahora muestra solicitud máxima: (1). Esto significa que flatMap le ha dicho al publisher ascendente que solo quiere recibir un valor único. Una vez que se recibe el valor, flatMap esperará a que el publisher creado se complete antes de solicitar un nuevo valor, nuevamente con max: (1). Continúa así hasta que el editor ascendente complete y envíe un evento de finalización.

Este ejemplo te muestra la gestión de la contrapresión en su máxima expresión. Permite que flatMap administre el número de publishers que produce. El publisher ascendente puede optar por almacenar en búfer eventos mientras flatMap no está listo para recibirlos, o el publisher puede decidir descartarlos. Ese es un detalle de implementación del publisher con el que estás trabajando.

Deberíamos seguir adelante y explorar algunos otros operadores. No te preocupes si estás un poco confundido acerca de flatMap y la gestión de la contrapresión en este momento. El operador flatMap es probablemente uno de los más poderosos y complejos que he visto, especialmente porque se integra con la contrapresión de manera muy estrecha. Todo se aclarará a medida que avancemos en nuestro viaje para aprender Combine.

Aplicación de operadores que podrían fallar

Todos los operadores que has utilizado hasta ahora no te permitieron arrojar errores del operador. Esto significa que cada transformación que aplicaste a los valores emitidos por un publisher debe tener éxito. A menudo, esto está perfectamente bien, no es común tener transformaciones que puedan fallar.
Esto no significa que tu transformación nunca pueda fallar, o que sea malo si lo hacen. Muchos de los operadores integrados de Combine vienen con versiones que tienen el prefijo de la palabra try. Por ejemplo, tryMap, tryCompactMap y otros. Los operadores con un prefijo try funcionan de manera idéntica a sus contrapartes habituales, con la única excepción de que puede arrojar errores desde las versiones try.
Veamos un ejemplo:

Este ejemplo usa tryMap para mapear un editor que emite valores enteros. Si encontramos un número entero que no es menor que tres, eso se considera un error y se produce un error. Tenga en cuenta que los publishers solo pueden completar o emitir un error una vez. Esto significa que después de que se produce un error, el publisher no puede emitir nuevos valores. Es importante considerar esto cuando arroja un error de un operador. Una vez que se lanza el error, no hay vuelta atrás.

El ejemplo que acabo de mostrarte podría no ser el mejor caso de uso de un tryMap. Su propósito no era mostrarle un caso de uso elaborado de tryMap. En cambio, quería mostrarte cómo puedes usarlo y darte algo con qué jugar.

Estoy seguro de que si te encuentra en una situación en la que arrojar un error de un operador tiene sentido, sabrás buscar el operador con el prefijo try que desea utilizar.

Creo que hay un detalle muy importante que señalar. Cuando te mostré cómo usar flatMap en la sección anterior, aprendiste que en iOS 13 tiene que usar el operador setFailureType para cambiar el Failure del editor de la secuencia de Never a URLError. En el ejemplo que acabo de mostrar, pudimos cambiar el error de Never a MyError sin hacerlo explícitamente. Incluso en iOS 13. La razón de esto es que cuando un operador influye directamente en el tipo de error como lo hace este ejemplo, Combine puede inferir y cambiar de manera segura el tipo de error que se expone en sentido descendente a otros operadores o suscriptores.

Definición de operadores personalizados

Combine tiene muchos operadores integrados, pero habrá casos en los que los operadores integrados no coincidan con lo que necesitas. Esto suele suceder si tienes un código largo o repetitivo dentro de un solo operador. Cuando esto sucede, definir un operador propio puede ayudar a hacer tu código más legible y más fácil de razonar. Echemos otro vistazo

Este código aplica dos operadores a un publisher que emite cadenas. Uno para establecer el tipo de error del publisher en URLError y otro para convertir la cadena en un editor de tareas de datos. Este código no es necesariamente difícil de leer, pero los dos operadores que aplicamos en este ejemplo están muy unidos. Y también está escribiendo código que solo se necesita en iOS 13 donde no es estrictamente necesario. Si bien no es un problema de rendimiento ni nada por el estilo, sería bueno limpiar un poco el código y combinar los operadores setFailureType y flatMap en un solo operador. Esto hace que el código sea más corto, más fácil de leer y de mantener porque puede ocultar el operador específico de iOS 13 del resto de su código. Veamos cómo se puede hacer esto:

Tip: estoy usando Swift’s #available para verificar si iOS 14 o más reciente está disponible. Si es así, sé que no necesito el operador .setFailureType (to :). Si iOS 14 o posterior no está disponible, esto significa que debemos aplicar setFailureType (a :).

Todos los operadores de Combinar se definen como una extensión en Publisher. Estas extensiones se pueden restringir para garantizar que un publisher tenga una salida determinada o un tipo de falla. En este caso, limité la extensión a los publishers que tienen String como salida y Never como error. Esto coincide con el resultado y el error del publisher de cadenas del código que estamos refactorizando. En esencia, este operador personalizado aplica los mismos dos operadores que se aplicaron en el código original.

Ten en cuenta que se aplica un tercer operador; eraseToAnyPublisher (). Este operador elimina toda la información de tipo del editor y la envuelve en un AnyPublisher. Esto es bueno porque el publisher con el que terminamos después de aplicar flatMap es Publishers.FlatMap <P, Publishers.SetFailureType <Self, URLError >>. Este no es el tipo de retorno más legible y útil. También es un detalle de implementación de nuestro operador. Al borrar este detalle de implementación, podemos devolver Any- Publisher <URLSession.DataTaskPublisher.Output, URLError>. Esto nos dice a los usuarios de nuestro operador personalizado todo lo que necesitan saber. Este operador personalizado se puede utilizar de la siguiente manera:

El código es un poco más corto, y también es más fácil de leer. Los lectores de este código comprenderán que cada cadena que publica el editor se convierte en una tarea de datos.

Si bien puedes acortar el código y hacerlo más legible con operadores personalizados, hacerlo tiene cierto costo. Las personas que están familiarizadas con Combine pero que son nuevas en su código base no estarán familiarizadas con sus operadores personalizados. Esto podría introducir una complejidad y fricción innecesarias para los desarrolladores de tu equipo.

Por otro lado, un par de operadores personalizados bien definidos y bien documentados pueden ser un gran activo para su base de código. Siempre que estés a punto de presentar un operador personalizado, considera las implicaciones y pregúntate si el patrón que está abstrayendo es lo suficientemente común como para justificar un operador personalizado.

En Resumen

En este artículo quería mostrar qué son los operadores en Combine y cómo puedes usarlos. Comencé en un artículo anterior dándote una descripción general de alto nivel y luego profundizamos más. Aprendiste cómo transformar la salida de un publisher con operadores como map, compactMap y flatMap. Viste cómo funcionan estos operadores, cuáles son sus similitudes y cuáles son sus diferencias.

En el camino, te presenté varios otros operadores como setFailureType, replaceNil y replaceError. Viste que todos los operadores de Combine funcionan de manera similar. Toman la salida y / o el error de un publisher y devuelven un nuevo publisher con una salida modificada y / o un error.

También aprendiste que los operadores no suelen arrojar errores y que la mayoría de los operadores Combine vienen con una versión separada que tiene un prefijo try que te permite lanzar errores cuando es apropiado.

Cuando utilice estos operadores, ten en cuenta que una cadena de editores solo puede emitir un error una vez. Cuando un publisher emite un error, la transmisión se considera completada y no puede emitir ningún valor nuevo.

Por último, te mostré cómo puede definir operadores personalizados extendiendo Publisher y restringiendo esta extensión según sea necesario. No es muy común necesitar o definir operadores personalizados, pero es bueno saber cómo hacerlo porque un operador personalizado bien ubicado puede mejorar drásticamente una base de código.

A picar un poco!! que es finde!!.

--

--

Andres Felipe Ocampo
Andres Felipe Ocampo

Written by Andres Felipe Ocampo

Digital Manager and Sr Lead iOS Engineer

No responses yet