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.Right
oEither.Left
para 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
Either
está sesgada a la derecha. Por esoFold
(sin sufijo) actua sobre el resultado de la derecha y debemos usarFoldLeft
para 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 losnull
que son filtrados - Un
IEnumerable<Option<T>>
, en este caso la sequencia contendrá los valores (de tipoT
) de losOption
que tengan valor (losOption
vacíos se filtran). - Un
IEnumerable<Either<L,T>>
, en este caso la secuencia contendrá los valores (de tipoT
) de aquellosEither
que tengan valor derecho (los que tengan valor izquierdo son ignorados)
Sequence
hace “unwrap” deEither
y deOption
. Eso significa que a partir de un enumerable deOption<T>
lo que obtienes es unaSequence<T>
(no unaSequence<Option<T>>
) en la cual losOption
vacíos han sido filtrados. Recuerda queSecuence
es la representación de una sequencia de elementos y losOption
vacíos no se consieran elementos válidos. Lo mismo ocurre conEither
: dada una colección deEither<L, T>
obtienes unaSequence<T>
donde losEither
que 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
Either
tenía resultado derecho (de tipostring
), el valor deOk
serátrue
, el deContent
la propia cadena y el deUrl
la Url. - Si el
Either
tenía resultado izquierdo (de tipoFetcherException
), el valor seráfalse
, el deContent
el mensaje de error y como antes enUrl
tendremos 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
).
Option
yEither
hacen 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.Range
creamos un enumerable de 1..20 - Transformamos cada valor al valor correspondiente de
GetCustomer
, lo que tendríamos unIEnumerable<Option<CustomerInfo>>
- Usamos
Map
sobre 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.Of
que 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).