ArrowSharp es una pequeña librería inspirada en Arrow-kt Core que ofrece algunas utilidades para ayudarte a desarrollar con un estilo más funcional usando C#. Lo mejor es verlo con un ejemplillo. Como ejemplo voy a basarme en el que muestra Massimo Carli en este post.
En él partiríamos de un código inicial (C# clásico) como el siguiente:
|
|
Este código funciona, pero veamos como podemos mejorarlo desde el punto de vista funcional. El primer tema a abordar está en el propio método Fetch, este método está declarado como que toma una Uri y devuelve una cadena, pero hay un efecto colateral que la firma no menciona: el método puede lanzar una excepción. En concreto una FetcherException. No hay manera de que yo pueda saber este efecto colateral si no es leyendo el código: la firma del método nos oculta información.
Una manera de lidiar con esto es seguir la filosofía de lenguajes como Go devolver tuplas (resultado, error):
|
|
Representando un resultado O un error: Either
Pero esta solución también nos miente. El método Fetch NO devuelve un par (string, FetcherException). Este método o bien devuelve una cadena o bien una excepción, pero nunca ambos. Aquí es donde podemos introducir el tipo Either que incorpora ArrowSharp. La clase Either<E,R> representa un resultado de tipo E o un resultado de tipo R pero nunca ambos:
|
|
Observa que he intercambiado el orden de los tipos. Eso es porque el tipo Either está sesgado hacia la derecha: el tipo de la derecha se considera el resultado “más probable” (o el “no error” si prefieres). Usando Either el código nos quedaría así:
|
|
Se usa
Either.RightoEither.Leftpara construir unEither. Lamentablemente C# no puede inferir todos los tipos genéricos, por lo que toca pasarlos. Es un poco fastidio, pero es lo que hay :(
Lo interesante, pero es el uso que hacemos de Fetch. Antes debíamos usar un try/catch para capturar la posible FetcherException pero ahora el resultado es siempre un Either. Así podemos pensar en un código como el siguiente:
|
|
¡Ojo! Ese código compila, pero no es correcto. Y es que nos estamos lanzando a la piscina! ¿Qué ocurre si no hay resultado porque ha habido un error? En este caso el Either contiene un valor de tipo FetcherException. Es por ello que dado un Either<E,R> las propiedades Left y Right no son de tipo E o R como uno puede presuponer rápidamente. En su lugar, la propiedad Left es de tipo Option<E> y la propiedad Right es de tipo Option<R>. ¿Y qué es Option?
Representando un valor opcional: Option
El tipo Option es otro tipo de ArrowSharp que representa un valor de un tipo T o la ausencia de él. Es como null pero sin los problemas de null. Así, la propiedad Right de Either nos devolverá un Option que contiene el resultado derecho o nada si no lo hay. Así, en lugar de usar either.Right directamente podríamos hacer lo siguiente:
|
|
Usamos el método GetOrElse de Option para obtener un valor si lo hubiera o un valor por defecto en caso de qué no. Por supuesto, también podemos usar pattern matching:
|
|
Este código usa la propiedad Type que nos devuelve un enum EitherType que nos dice si el Either tiene resultado izquierdo o derecho. En el caso que tenga resultado izquierdo usa el mçetodo FoldLeft que convierte el resultado izquierdo a otro resultado (del mismo u otro tipo). En nuestro caso pasamos de FetcherException a string, mientras que si el Either tiene resultado derecho se usa el método Fold.
Es lo que he comentado antes: la clase
Eitherestá sesgada a la derecha. Por esoFold(sin sufijo) actua sobre el resultado de la derecha y debemos usarFoldLeftpara actuar sobre el resultado izquierdo.
En este caso concreto, incluso podríamos haber simplificado el código, usando una sobrecarga de Fold que actúa sobre el resultado que exista:
|
|
Trabajando con Eithers y Options: Sequence
Sequence<T> es otro tipo de ArrowSharp que representa una lista de valores. De hecho, implementa IEnumerable<T> y no ofrece apenas ningún método adicional. La clave está en que Sequence entiende los tipos Either y Option y no agrega ningún Either que tenga resultado izquiero o ningún Option vacío.
Eso lo puedes ver fácilmente con ese código:
|
|
La variable results contiene una Sequence<string> que contiene dos elementos. Solo dos elementos en lugar de tres, porque hay una URL que es inválida y con la que el método Fetch devuelve un Either con resultado izquierdo. Ese Either es ignorado.
Para crear una Sequence se usa siempre Sequence.Of y puedes crear una Sequence de tipos T a partir de:
- Un
IEnumerable<T>, en este caso la sequencia contendrá los mismos valores, excepto losnullque son filtrados - Un
IEnumerable<Option<T>>, en este caso la sequencia contendrá los valores (de tipoT) de losOptionque tengan valor (losOptionvacíos se filtran). - Un
IEnumerable<Either<L,T>>, en este caso la secuencia contendrá los valores (de tipoT) de aquellosEitherque tengan valor derecho (los que tengan valor izquierdo son ignorados)
Sequencehace “unwrap” deEithery deOption. Eso significa que a partir de un enumerable deOption<T>lo que obtienes es unaSequence<T>(no unaSequence<Option<T>>) en la cual losOptionvacíos han sido filtrados. Recuerda queSecuencees la representación de una sequencia de elementos y losOptionvacíos no se consieran elementos válidos. Lo mismo ocurre conEither: dada una colección deEither<L, T>obtienes unaSequence<T>donde losEitherque tienen resultado izquierdo están filtrados.
Existen versiones asíncronas que trabajan con IEnumerable<Task> como la he usado en el ejemplo (que trabaja con IEnumerable<Task<Either<L, T>>>).
El problema con el código anterior es que tenemos solo los resultados correctos, pero hemos perdido la información de que hay una URL que ha generado un error. Si eso ya nos va bien, pues perfecto, pero… ¿como podemos mantener esa información? Para ello tenemos que combinar la lista de URLs que teníamos con los distintos Either que obtenemos para generar una lista de objetos (de otro tipo) que contenga la información necesaria. El método Fold de Either nos permite transformar el Either y el método Zip de LINQ hace la combinación entre la lista de URLs y la de Eithers:
|
|
En data tenemos una lista de objetos (de un tipo anónimo), donde:
- Si el
Eithertenía resultado derecho (de tipostring), el valor deOkserátrue, el deContentla propia cadena y el deUrlla Url. - Si el
Eithertenía resultado izquierdo (de tipoFetcherException), el valor seráfalse, el deContentel mensaje de error y como antes enUrltendremos la Url.
Option y Either son monads
Los tipos Option y Either ofrecidos por ArrowSharp se comportan como monads. Para describir lo qué es un monad hay dos maneras. La primera, matemáticamente impecable pero completamente inútil para que nadie la entienda (pero que puedes usar si quieres pecar de petulante) dice que un monad simplemente es un monoide en la categoría de los endofunctores. Como digo esa definición no sirve para nada, así que usaré otra mucho más práctica, completamente sui generis, pero que espero que entiendas a la primera:
Un monad es un envoltorio para tipos X que es capaz de transformarse al mismo tipo de envoltorio pero para tipos Y.
A grandes rasgos eso significa que Option<T> es un monad porque puedes transformar un Option<T> a un Option<T'> y lo mismo aplica a Either. El método que realiza esa transformación se llama Map:
|
|
El método Option.Some crea un Option con el valor indicado (en este caso un Option<int>) y el método Map lo transforma un Option<string>. En este caso el tipo de envoltorio es Option (no se modifica usando Map, pasamos de un Option a otro Option) pero el tipo de datos envuelto si que lo hace (pasamos de int a string). Esa transformación debe respetar las casuísticas del envoltorio a la que se aplica. P. ej. el siguiente código funciona correctamente y no genera error alguno:
|
|
La clave ahí es que el método Option.Some entiende que null no es un valor válido. Así que en lugar de un Some (así llamamos a los Option que tienen valores), obtenemos un None (un Option vacío). Cualquier transformación de un None a otro None es inocua, ya que no hay valor qué transformar (solo tipo). Así result es un Option<int> pero no tiene valor (su propiedad Type es OptionType.None y la propiedad IsNone vale true).
OptionyEitherhacen unwrap de si mismos, eso significa queOption.Some(Option.Some(10))no devuelve unOption<Option<int>>si no unOption<int>.
La forma “correcta” de crear un Option vacío es usando Option.None<T>(), pero que el método Option.Some entienda de null es para simplificar la interoperabilidad con código “clásico”:
|
|
El método GetCustomer envuelve el método LegacyGetCustomer para transformar el CustomerInfo a un Option<CustomerInfo> que estará vacío si el método ha devuelto null. Ahora podemos llamar a GetCustomer y olvidarnos de null:
Quieres obtener solo el nombre de todos los clientes? Sencillo:
|
|
El resultado es una Sequence<string> que contiene los nombres de los 10 clientes. Observa qué ha ocurrido paso a paso:
- Usando
Enumerable.Rangecreamos un enumerable de 1..20 - Transformamos cada valor al valor correspondiente de
GetCustomer, lo que tendríamos unIEnumerable<Option<CustomerInfo>> - Usamos
Mapsobre cadaOption<CustomerInfo>para transformarlo a unOption<string>que contenga solo el Nombre. En este punto siguen habiendo 20 Options en la lista, salvo que 10 de ellos son Nones. - Usamos
Sequence.Ofque nos filtra los Nones y además nos hace unwrap por lo que pasamos de una colección deOption<string>a una colección destring, que contiene solo los valores válidos.
¡Ya lo ves! ¡Sin necesidad de preocuparnos de null en ningún momento!
Quiero empezar a jugar con ArrowSharp
Vale… NO ESTÁ TERMINADA así que no hay NuGet por el momento. Espero que lo haya en breve, pero por el momento:
- El código fuente está en Github
- Debes usar el SDK de net5 para compilarla
- De momento no usa “nullables references”… veremos.
Por el momento ArrowSharp hace multi-target a netstandard2.1 y net5.0. Supongo que lo dejaré así pero está por ver.
Finalmente, todos los tipos de ArrowSharp son estructuras, no clases. Eso condiciona el diseño de la librería (p. ej. en Kotlin None y Some son tipos derivados de Option<T>, con lo que puedes hacer pattern matching por tipo en lugar de por una propiedad. No tengo claro que todas las relaciones de herencia que hay en Kotlin se puedan establecer en C# por la diferencia entre como funcionan los genéricos en ambos lenguajes).