Saltar al contenido

Blog técnico | Scala: Cats Effect Ref

Como recién llegado a la programación funcional, pensé que la mutabilidad estaba prohibida. Aunque descubrí cómo usar la inmutabilidad en la mayoría de las situaciones, encontré que había casos obvios en los que no funcionaba, como el modelado y la actualización del estado en memoria (por ejemplo, cachés y contadores en memoria). Me equivoqué al pensar que la programación funcional exigía usar sólo valores inmutables. La programación funcional requiere transparencia referencial, pero mientras eso se mantenga, la mutabilidad está bien.

En algunos casos, el estado en memoria puede ser modelado usando la mónada de estado. Proporciona una forma de representar el estado que se actualiza secuencialmente. Si se da el caso de que un estado se mueve en serie de un valor a otro tomando el estado anterior como entrada, entonces se debe mirar la mónada de estado. Sin embargo, la referencia de Cats Effect será la más adecuada si necesita actualizar el estado de forma concurrente en lugar de secuencial. (Zio contiene otra Refimplementación si lo prefieres sobre el Efecto Gato).

Blog técnico | Scala: Cats Effect Ref
Blog técnico | Scala: Cats Effect Ref

Ref: Una visión general

Ref es una referencia mutable que:

  1. Es no bloqueante
  2. Se accede y se modifica simultáneamente
  3. Se modifica atómicamente
  4. Se trata de utilizar sólo funciones puras

Ref obtiene su atomicidad de AtomicReference de Java y su transparencia referencial usando la clase de tipo Cats Effect$0027s Sync. Para mostrar cómo estos bloques de construcción se combinan para crear Ref, voy a caminar a través de cada una de las principales operaciones que están disponibles en Ref:

  1. de
  2. obtener
  3. set
  4. actualización
  5. modificar

Nota: He cambiado algunas de las implementaciones de abajo para simplificarlas para este post, pero en su mayoría son las mismas. Si desea ver la verdadera implementación de Ref, compruébelo aquí.

Ref#of

El método de of es un constructor para el Ref.

Aplicación

defof[F[_], A](a:A)(implicitF:Sync[F]):F[Ref[F, A]]=F.delay(newRef[F, A](newAtomicReference[A](a)))

Este constructor crea una Referencia Atómica que contiene un valor inicial a. Lo hace en el contexto de la F para mantener la pureza.

Esta operación es referencialmente transparente ya que está compuesta en F usando el retardo de la clase de tipo Sync. No se ejecutan efectos al llamar a esta función. Simplemente devuelve un efecto F con la capacidad de ejecutarse en algún momento futuro y crear el Ref. Lo mismo ocurre con todas las funciones dentro de Ref. Son puras porque aprovechan el comportamiento de retardo que proporciona la clase de tipo Sync.

Ejemplo

valref:IO[Ref[IO, Int]]=Ref.of[IO, Int](42)

Nota: Esto equivale a Ref[IO].de(42) porque Ref se aprovecha de los tipos parcialmente aplicados.

Ref#get

Esta operación es la más simple de todas las operaciones en el Ref. Obtiene el valor que la Referencia Atómica contiene actualmente.

Aquí está la referencia atómica suministrada por el constructor.

Aplicación

defget:F[A]=F.delay(ar.get)

La operación de obtención de una Referencia Atómica se realiza en una sola instrucción de máquina. Esto significa que cualquier número de hilos pueden estar realizando un geton en una Referencia Atómica al mismo tiempo sin ningún problema. Como el nombre ReferenciaAtomica implica, la seguridad de los hilos se mantiene para todas sus operaciones. Esto se debe a que AtomicReference realiza inteligentemente cada una de sus operaciones en una sola instrucción de máquina.

Otra cosa clave a tener en cuenta sobre AtomicReference es que está respaldada por una variable volátil. Las variables volátiles se leerán y escribirán en la memoria principal del ordenador central en lugar de en la caché de la CPU. Esto significa que incluso si nuestro programa se ejecuta en diferentes CPU se comportará como si se ejecutara en una sola CPU. Para obtener más información sobre las variables volátiles, vea aquí.

Ejemplo

valprintRef=for{ref<-Ref[IO].of(42)contents<-ref.get}yieldprintln(contents)printRef.unsafeRunSync()

El ejemplo anterior hace lo siguiente:

  1. Crear un Ref usando Ref[IO].of(42)
  2. Adquirir el contenido del Ref. usando ref.get
  3. Imprime el contenido a la consola usando println(contents)

En este ejemplo, printRef devuelve un IO[Unidad] que es un efecto que representa un cálculo que puede ser ejecutado en algún momento futuro. En este caso (y en todos los ejemplos de este post), el efecto se ejecuta inmediatamente llamando a .unsafeRunSync(). Típicamente, no se llama esto explícitamente dentro de sus programas, sino que se usa IOApp para ejecutar sus programas.

Ref#set

Esta operación está estructurada de la misma manera que get, excepto que esta vez estamos realizando una operación fija. Esta operación se basa en la misma naturaleza volátil y segura de la Referencia Atómica.

Aplicación

defset(a:A):F[Unidad]=F.delay(ar.set(a))

Ejemplo

valprintRef=for{ref<-Ref[IO].of(42)_<-ref.set(21)contents<-ref.get}yieldprintln(contents)printRef.unsafeRunSync()

Ref#actualización

Tener operaciones de get y set es genial, pero por sí solo no nos permite actualizar atómicamente el valor contenido dentro de la referencia. Por ejemplo, si quisiéramos tomar cualquier valor almacenado en nuestra referencia e incrementarlo en uno, podríamos estar tentados de hacer algo como:

defgetThenSet(ref:Ref[IO, Int]):IO[Unit]={ref.get.flatMap{contents={contenidos={contenidos+1)}}valprintRef=for{ref<-Ref[IO]. of(42)_<-NonEmptyList.of(getThenSet(ref),getThenSet(ref)).parSequencecontents<-ref.get}yieldprintln(contents)

La cuestión aquí es que vamos a obtener salidas inconsistentes al ejecutar este código. Tenemos dos procesos separados trabajando en paralelo para realizar una operación de getThenSet. Ambos procesos pueden obtener el valor en el Ref antes de que el otro proceso tenga la oportunidad de incrementar el valor usando la operación getThenSet. A continuación se muestra un ejemplo de la ejecución de este programa en el que cada uno de los procesos obtiene el valor 42 al llamar a Ref#get.

proceso1: Ref#get -------; devuelve 42proceso2: Ref#get -------; devuelve 42proceso1: Ref#set -------; se establece en 43proceso2: Ref#set -------; se establece en 43

Habríamos esperado que el resultado final de este programa fuera de 44, pero debido a que el get and set se realiza en dos operaciones diferentes, terminamos potencialmente aumentando al mismo valor dos veces.

Aquí es donde la actualización entra en juego.

Aplicación

defupdate(f:A===;A):F[Unit]=modify(a=§;(f(a),()))

Esta operación es lógicamente más simple que modificar, por lo que la he incluido antes de modificar en este post. No se preocupe demasiado por la implementación aquí hasta que haya leído la sección sobre la modificación. Por ahora lo importante es saber qué hace la actualización.

update toma una función f como argumento que será invocado en cualquier valor que esté actualmente contenido dentro del Ref. Entonces cualquier valor que la función f devuelva será el nuevo valor dentro del Ref.

Cómo funciona la actualización tendrá más sentido después de que entendamos cómo funciona la función de modificación en Ref. Por ahora la clave para entenderlo es que la actualización proporciona una forma de realizar atómicamente una operación de “get then set”.

Ejemplo

valprintRef=for{ref<-Ref[IO].of(42)_<-ref.update(_+1)_<-ref.update(_+1)contents<-ref.get}yieldprintln(contents)printRef.unsafeRunSync()

Ref#modificar

Hemos demostrado que la actualización puede realizar una operación de “get and set” (aunque todavía tenemos que explicar cómo), pero todavía no hemos visto cómo realizar una operación de “get and set and get”. Aunque puede parecer poco probable que se necesite una operación de este tipo, es una operación común. Tomemos el ejemplo anterior de utilizar la actualización para incrementar un contador. Este método funciona a menos que necesitemos acceder atómicamente al valor después de haberlo incrementado. Por ejemplo, si queremos hacer un seguimiento del número de peticiones que un servidor HTTP ha visto a lo largo del tiempo, podríamos utilizar un Ref con la función de actualización para añadir uno a un contador por cada petición. Sin embargo, la función de actualización no funcionará si queremos devolver el nuevo número de peticiones después de actualizarlo. Podríamos usar la actualización seguida de un get, pero esto no es atómico porque el get se realiza como una instrucción de máquina separada. Esto podría llevar a que dos peticiones obtengan el mismo número del contador.

Este es el problema que la modificación resuelve.

Aplicación

defmodify[B](f:A=>(A,B)):F[B]={@tailrecdefspin:B={valc=ar.getval(u,b)=f(c)if(!ar.compareAndSet(c,u))spinelseb}F.delay(spin)}

Antes de que me sumerja en la explicación del método de modificación, tenemos que entender lo que hace el método AtomicReference#compareAndSet. Comparar y fijar (CAS) toma dos parámetros: 1) el valor c que esperamos que contenga la Referencia Atómica y 2) el valor u al que queremos actualizar la Referencia Atómica. El CAS actualizará la Referencia Atómica al valor de u si y sólo si el valor que actualmente está contenido en la Referencia Atómica es igual al valor de c. Si el valor de c no es igual al valor que actualmente está contenido en la Referencia Atómica, entonces compareAndSet no actualiza la referencia y devuelve false para indicarlo. La operación CAS es atómica porque tanto la funcionalidad de compare como la del set ocurren en una sola instrucción de máquina.

Ahora veamos el método de Ref#modificar. Este método toma una función f como parámetro. Esta función acepta el valor que está almacenado actualmente en la Ref y devuelve una tupla que contiene: 1) el valor con el que se actualiza la Ref y 2) el valor que se devuelve del método de modificación.

El método de modificación contiene un método interno recursivo de la cola (seguro para la pila) llamado spin. El método de spin forma lo que se llama un bucle de comparación y ajuste. La razón por la que este bucle es necesario es que la modificación requiere dos operaciones para funcionar: Referencia Atómica#get y Referencia Atómica#compareAndSet. Cada una de estas operaciones por sí sola es atómica, pero cuando las llamamos a ambas en secuencia la operación resultante no es atómica. Por lo tanto, la Referencia Atómica puede actualizarse entre la llamada a get y la llamada a compareAndSet causando que la operación compareAndSet falle (retorno falso). Por esta razón, necesitamos reintentar esta operación repetidamente hasta que finalmente tenga éxito. Tendrá éxito cuando no haya actualizaciones de la Referencia Atómica subyacente entre las llamadas a get y compareAndSet.

Ejemplo

valprintRef=for{ref<-Ref[IO].of(42)contents<-ref.modify(current=§;(current+20,current+20))}yieldprintln(contents)printRef.unsafeRunSync()

Ejemplo de referencia completa

Aquí hay un ejemplo completo usando Ref para gestionar el estado de un conjunto de cuentas bancarias.

importarCuentasBancarias._finalclassCuentasBancarias(ref:Ref[IO, Map[String, BankAccount]]){defalterAmount(accountNumber:String,amount:Int):IO[Option[Balance]]={ref.modify{allBankAccounts=§;valmaybeBankAccount=allBankAccounts.get(accountNumber).map{bankAccount=§;bankAccount. copy(balance=cuentaBancaria.balance+importe)}valnewBankAccounts=allBankAccounts++quizásBankAccount.map(m=>(m.number,m))valmaybeNewBalance=quizásBankAccount.map(_.balance)(newBankAccounts,quizásNewBalance)}}defgetBalance(accountNumber:String):IO[Option[Balance]]=ref.get. map(_.get(accountNumber).map(_.balance))defaddAccount(account:BankAccount):IO[Unit]=ref.update(_+(account.number->account))}objectBankAccounts{typeBalance=IntfinalcaseclassBankAccount(number:String,balance:Balance)}valexample=for{ref<-Ref[IO].of(Map. empty[String,CuentaBancaria])bankAccounts=nuevaCuentaBancaria(ref)_<-cuentaBancaria.addAccount(CuentaBancaria("1",0))_<-cuentaBancaria. alterAmount("1",50)_<-bankAccounts.alterAmount("1",-25)endingBalance<-bankAccounts.getBalance("1")}yieldprintln(endingBalance)example.unsafeRunSync()

Este ejemplo sigue siendo bastante básico y ciertamente no pretende mostrar cómo se implementaría un software bancario real. Sin embargo, le da una idea de lo que es posible usando el Ref.

Regiones de reparto

Aquí hay una nota final sobre algo que puede ser confuso cuando se trabaja por primera vez con el árbitro. Siempre que estamos trabajando con Ref o sus operaciones, estamos usando una for-comprensión. Como sabrán, las for-comprensiones son azúcar sintáctico que representan una serie de llamadas de mapa plano seguidas de un mapa al final. Debido a que las operaciones de Ref están contenidas dentro del contexto del efecto F, necesitamos mapear o flatMap sobre los resultados de esas operaciones para acceder a los valores. Esto se conoce como una región de intercambio.

Ref[IO].of(42).flatMap{i==========Esta es la región de compartir}//No puede acceder al valor de los contenidos de los arrecifes desde el exterior

Las regiones de intercambio son convenientes porque nos dan la capacidad de razonar sobre nuestro código localmente. En otras palabras, podemos ver todas las operaciones posibles que podrían estar modificando nuestro Ref desde dentro de su región de compartición. No tenemos que mirar nada fuera de esa región ya que no puede hacer nada para acceder al Ref.

Conclusión

Espero que esta visión general le haya dado una mejor intuición de lo que es el Ref y cómo puede usarlo dentro de su código. Ref es una construcción súper poderosa que puede darle una manera fácil de manejar el estado de la aplicación de una manera que es tanto atómica como pura. Para más información sobre Ref, te recomiendo que consultes estos recursos:

Categorías: técnicoTags: scala, cats-effect, functional-programming