Dans l’article précédent nous avons vu un acteur qui démarre, exécute une fonction et s’arrête automatiquement. Personne ne s’occupait de lui et tout le monde continuait son travail sans se soucier de sa réussite.
Objectif
Nous allons créer un acteur qui va produire un résultat et le renvoyer à son caller. On se rapproche encore plus d’un appel de fonction classique. L’exemple que j’ai choisi est le calcul du Nième élément de la suite de Fibonacci.
C’est un classique, il prend un peu de temps à calculer et il introduit aussi un nouveau concept de la programmation fonctionnelle : la récursion.
La récursion
La récursion c’est quand une fonction s’appelle elle-même. On l’utilise dans la programmation fonctionnelle car il n’y a pas de boucles (for, while, etc.) dans ces langages. Par exemple pour faire 2 tours de boucle on fait :
defmodule Loop do
def twice(fun), do: loop(fun, 2)
# base case
defp loop(_fun, 0), do: :ok
# general case
defp loop(fun, count) do
fun.()
loop(fun, count - 1)
end
end
Loop.twice(fn -> IO.puts("go go gadget copter") end)
go go gadget copter
go go gadget copter
:ok
Info : On appelle un
base caseune branche dans laquelle la fonction ne se rappelle pas elle-même. C’est une branche de sortie. Sans base case, elle bouclerait indéfiniment. Il peut y avoir de multiplesbase casecomme on va le voir très vite.
La fonction fib
Si vous ne connaissez pas encore la suite de Fibonacci, l’article wikipédia pourra vous expliquer son fonctionnement. TL;DR: la valeur N est la somme de la valeur N-1 et N-2.
Pour rappel voici le début de celle-ci: 1, 1, 2, 3, 5, 8, 13, 21, 34… Voici une implémentation naïve :
defmodule Math do
# base case: la 1ère valeur de la chaîne est 0
# base case: la 2ème valeur de la chaîne est 1
# general case: somme des 2 valeurs précédentes
def fib(0), do: 0
def fib(1), do: 1
def fib(n), do: fib(n - 1) + fib(n - 2)
end
Curieux : Jetez un œil à CodeHannah qui explique comment l’optimiser en ajoutant un
accumulateur.
L’acteur fib
Vous me direz, mais pourquoi voudrait-on transformer la fonction fib/1 en acteur ? Je vois 2 raisons potentielles :
- Vu que ça peut être long, je peux créer un timeout si elle prend trop de temps
- Si elle plante car elle prend trop de mémoire, elle ne plante pas le caller
defmodule Math do
require Logger
# [..]
def fib_async(n) do
# self() retourne le PID de l'acteur qui appelle la fonction
# dans notre cas ce sera le PID du shell iex
caller = self()
# créer un acteur qui exécute une fonction anonyme
# cette fonction anonyme appelle fib/1 et envoie un message au caller
spawn(fn ->
Logger.info("#{inspect(self())} is calculating fib(#{n})...")
value = fib(n)
Logger.info("#{inspect(self())} is done calculating fib(#{n}) and will send result to #{inspect(caller)}.")
send(caller, {:fib, n, value})
end)
# le caller attend de recevoir un message avec le pattern donné
# s'il le reçoit en moins de 5s, il retourne {:ok, value}
# sinon {:error, :timeout}
receive do
{:fib, ^n, value} ->
Logger.info("#{inspect(self())} received the reply.")
{:ok, value}
after 5_000 ->
{:error, :timeout}
end
end
end
---
config:
theme: dark
---
sequenceDiagram
participant iex@{ "type": "actor" }
participant fib_async/1
iex->>fib_async/1: fib_async(42)
activate fib_async/1
create participant 0.45.0@{ "type" : "actor" }
fib_async/1->>0.45.0: spawn
0.45.0-->>fib_async/1: {:ok, <0.45.0>}
fib_async/1->>fib_async/1: receive
participant fib/1
0.45.0->>fib/1: fib(42)
activate fib/1
activate 0.45.0
fib/1-->>0.45.0: 267914296
deactivate fib/1
0.45.0-->>fib_async/1: {:fib, 42, 267914296}
deactivate 0.45.0
fib_async/1-->>iex: {:ok, 267914296}
destroy 0.45.0
0.45.0-x0.45.0:
deactivate fib_async/1
iex(3)> Math.fib_async(42)
10:34:20.564 [info] #PID<0.128.0> is calculating fib(42)...
10:34:22.528 [info] #PID<0.128.0> is done calculating fib(42) and will send result to #PID<0.105.0>.
10:34:22.528 [info] #PID<0.105.0> received the reply.
{:ok, 267914296}
Le contenu des messages peut être n’importe quoi. J’ai choisi d’utiliser une triplette {:fib, n, value} qui permet au caller de pattern match afin d’être sûr que le message est bien la réponse qu’il attend (et non un autre message d’un autre acteur). On pourrait aussi inclure le pid de l’acteur par exemple.
La fonction retourne des paires {:ok, value} ou {:error, reason}. C’est une convention très utilisée qui permet au caller de réagir en cas d’erreur. Par exemple, on peut envisager de réessayer quand ça a du sens. En général, quand une fonction retourne ce pattern, c’est qu’elle ne peut pas planter autrement (throw, raise ou exit).
Ici même si l’acteur plante, on retournera {:error, :timeout}. Par exemple avec Math.fib_async(-1) 🙄.
iex(2)> Math.fib_async(-1)
11:02:11.871 [info] #PID<0.129.0> is calculating fib(-1)...
{:error, :timeout}
Il pourrait être intéressant de gérer ce cas et de retourner immédiatement un {:error, :invalid} sans attendre le timeout. Et s’il s’agit carrément de l’implémentation qui plante, un Process.monitor/1 permettrait de détecter l’arrêt prématuré de l’acteur et de retourner une autre erreur avant le timeout. On fera ce genre de chose dans un article sur la supervision.
C’est très verbose, mais on verra plus tard des conventions voire même des frameworks qui nous permettront d’abstraire un maximum de choses répétitives. Pour l’instant, j’estime qu’il vaut mieux être verbose et simple que de rentrer dans des abstractions opaques.
Conclusion
La leçon ici est encore qu’on utilise des acteurs pour isoler les erreurs. C’est la philosophie même d’Erlang : le fameux Let it crash qui argue qu’il vaut mieux avoir des crashs isolés et ainsi ne perdre qu’une partie du système plutôt que de perdre le système entier.
Dans le prochain article on prendra le même exemple mais on l’optimisera pour le parallélisme.
Image 1 : akingdom1