Contattaci

Programmazione Funzionale: gestione dei side-effect

  • Data: 13 Marzo 2019
  • Autore: Federico Teotini
  • Categorie

  • Giuneco Tech

    Il nostro settore richiede uno studio continuo e una forte attenzione all’innovazione. Incentiviamo quindi dei programmi formativi annuali per tutti i componenti del team, con ore dedicate (durante l’orario di lavoro) e serate formative sia online che in presenza. Sponsorizziamo eventi, sia come partner che semplicemente come partecipanti, e scriviamo articoli su quello che abbiamo imparato per essere, a nostra volta, dei divulgatori.

    Vai alla sezione Team
  • Durante la serata formativa sulla Programmazione Funzionale tenutasi in Giuneco (trovate i materiali utilizzati qui) abbiamo affrontato temi importanti della programmazione funzionale, come:

    • Funzioni pure
    • Immutabilità
    • Funzioni di Ordine Superiore (Higher-Order functions)
    • Currying
    • Composizione

    Tuttavia ci eravamo lasciati con una domanda fondamentale: come gestire i side-effects.
    In generale, i side-effects sono tutto ciò che rende o potrebbe rendere impredicibile il flusso del nostro codice, come accessi al DB o al File System, ma anche le Exception ne fanno parte.
    In questo articolo cercheremo di capire come gestire questi aspetti in maniera funzionale.

    Container

    In un mondo utopico, tutto il software scritto in un linguaggio funzionale dovrebbe essere puro. Poiché questo è impossibile, si cercano di segregare tutte quelle parti di codice considerate impure.
    Per farlo usiamo dei container che non faranno altro che custodire i nostri dati e gestirli nel modo più corretto.
    Iniziamo con il Container più semplice:

    class Container:
    
      def __init__(self, value):
        self._value = value
    
      @staticmethod  def of(value):
        return Container(value)
    
      def __str__(self):
        return "Container({})".format(self._value)
    
    Container.of(3)
     # Container(3)
    
    Container.of("hello")
     # Container("hello")
    
    Container.of(Container.of({"name": "solo"}))
     # Container(Container({"name": "solo"}))

    Questo container presenta delle proprietà comuni a tutti gli altri container:

    1. Ogni container contiene un solo attributo, ovvero il valore che wrappa
    2. L’attributo _value non può essere tipizzato (ovvio in Python, ma importante se usato in altri linguaggi)
    3. Una volta che i dati entrano in un container, non ne possono più uscire

    Functor

    Il container appena definito è solo un esempio. Infatti così com’è non è molto utile; abbiamo bisogno di accedere al valore che contiene per poterlo usare, ovvero un modo di eseguire delle funzioni:

    class Container:
      def __init__(self, value):
        self._value = value
    
      @staticmethod  def of(value):
        return Container(value)
    
      def map(self, fn):
        return Container.of(fn(self._value))
    
      def __str__(self):
        return "Container({})".format(self._value)
    

    Questo è finalmente un container usabile grazie al metodo map. Questo metodo permette di applicare una qualsiasi funzione al valore custodito dal nostro container.

    Container.of(2).map(lambda x: x + 3)
     # Container(5)
    
    Container.of("Hello").map(append(" world")).map(len)
     # Container(11)
    

    Possiamo lavorare sui dati senza dover mai lasciare il container. Questo permette anche di poter concatenare le varie map come in una composizione. Un container in cui è definito un metodo che opera in questa maniera è detto Functor.
    La domanda sorge spontanea: cosa ci guadagniamo a wrappare i nostri dati all’interno di un functor? Il metodo map permette di astrarre l’applicazione di una funzione; stiamo chiedendo al container stesso di occuparsi di eseguirla.

    Maybe

    Anche come functor, il nostro container non è molto utile. Introduciamo quindi un container che useremo molto spesso:

    class Maybe:
      @staticmethod
      def of(value):
        return Just(value)
    
      @property
      def isNothing(self):
        return self._value is None
    
      def map(self, fn):
        return Nothing() if self.isNothing else Maybe.of(fn(self._value))
    
      def __init__(self, value):
        self._value = value
    
    class Just(Maybe):
    
      def __str__(self):
        return "Just({})".format(self._value)
    
    class Nothing(Maybe):
      def map(self, fn):
        return self
    
      @property
      def isNothing(self):
        return True
    
      def __str__(self):
        return "Nothing"
    

    L’unica differenza rispetto a Container è che Maybe, prima di applicare una funzione, controlla di avere un valore in pancia. Questo ci permette di non preoccuparci di possibili null values!

    Maybe.of("Hello World").map(contains("r"))
     # Just(True)
    
    Maybe.of(None).map(contains("r"))
     # Nothing
    
    Maybe.of({ "name": "john" }).map(get("age")).map(add(10))
     # Nothing
    
    Maybe.of({ "name": "john", "age": 30 }).map(get("age")).map(add(10))
    # Just(40)
    

    L’uso di Maybe permette al nostro programma di non esplodere quando applichiamo funzioni a dati non validi.

    Per semplificarci la vita definiamo la funzione dotfree della funzione map

    map = curry(lambda fn, functor: functor.map(fn))

    Si noti che nei linguaggi funzionali gli iterabili sono functors quindi questa è l’unica funzione map necessaria e non importa differenziare questa da quella definita nella parte 1.

    Esempio

    safeHead = compose(Maybe.of, head) firstThreadTitle = compose(map(get("title")), safeHead, get("threads"))
    
    firstThreadTitle({ "threads": [] })
     # Nothing
    
    firstThreadTitle({ "threads": [{ "title": "awesome 3d" }] })
     # Just("awesome 3d")
    

    La funzione safeHead semplicemente prende il primo elemento di un array con l’aggiunta di un safe check. Inoltre, dato che restituisce un functor, siamo costretti ad usare map per lavorare con i dati al suo interno e questo garantisce che da quel momento in avanti saremo protetti da possibili errori provenienti da null values.

    A volte una funzione può esplicitamente restituire un Nothing per segnalare un errore

    applyDiscount = curry(lambda discount, cart:
                          Maybe.of(
                            None if cart["amount"] <= discount
                            else { "amount": cart["amount"] - discount }
                          ))
     cartAmountStr = lambda cart: "€ {}".format(cart["amount"])
    
    # setUsedDiscount defined somewhere else
     tenDiscount = compose(map(cartAmountStr), map(setUsedDiscount), applyDiscount(10))
    
    tenDiscount({ "amount": 20 })
     # Just(€ 10)
    
    tenDiscount({ "amount": 9 })
     # Nothing
    

    applyDiscount è molto rigida nel suo comportamento e restituisce Nothing se non è possibile applicare il discount desiderato, non eseguendo, di fatto, tutte le seguenti funzioni wrappate da map. Questo
    comportamento è quello desiderato: infatti non vogliamo che venga eseguita setUsedDiscount se non è stato possibile applicarlo.

    Error handling

    Come detto a inizio articolo, anche le Exceptions possono essere considerate come side-effects. Infatti, queste interrompono il normale flusso del programma, rendendo ogni funzione potenzialmente impura in quanto, in caso di eccezione, non restituirebbe alcun valore.

    Usando il contaniner Either possiamo gestire queste situazioni in maniera funzionale

    class Either:
      @staticmethod
      def of(value):
        return Right(value)
    
      def __init__(self, value):
        self._value = value
    
    class Right(Either):
      def map(self, fn):
        return Either.of(fn(self._value))
    
      def __str__(self):
        return "Right({})".format(self._value)
    
    class Left(Either):
    
      @staticmethod
      def of(value):
        return Left(value)
    
      def map(self, fn):
        return self
    
      def __str__(self):
        return "Left({})".format(self._value)
    

    Qui abbiamo definito un tipo “astratto” Either e due sotto-tipi molto semplici. Vediamo come funzionano

    Either.of(3).map(lambda x: x + 2)
     # Right(5)
    
    Left.of("hello").map(append(" world"))
     # Left("hello")

    Left semplicemente ignora qualsiasi richiesta di map mentre Right si comporta esattamente come il nostro caro vecchio Container. Ciò che rende utili questi containers è la possibilità di inserire un errore all’interno di Left.

    Supponiamo di avere una funzione che può fallire. Si potrebbe segnalare l’errore ritornando Nothing, ma sarebbe più utile restituire un errore contente le specifiche di ciò che è fallito e perché. Vediamo in che modo ci può essere utile Either

    def calcTaxes(user):
      if isWorking(user):
        return Either.of(user.income * 0.4)
      return Left.of("The user has no job, cannot calculate tax")
    
    calcTaxes({ "hasJob": True, "income": 20 })
     # Right(8)
    
    calcTaxes({ "hasJob": False, "income": 3 })
     # Left("The user has no job, cannot calculate tax")
    

    Come Nothing, Left ci assicura che, dopo essere comparso nel flusso, non verrà mai eseguita alcuna funzione, con l’aggiunta di poter segnalare l’errore (usando una stringa o una struttura più complessa).

    Nota bene

    Abbiamo introdotto Either come un modo per segnalare gli errori. In verità è molto più di questo. Either, infatti, cattura l’operazione di or logico all’interno di un container, permettendo di gestire condizioni quali valori di default o logiche di if branching molto più espressive.

    I/O Container

    Nella lista dei side-effects compare tutto ciò che ha a che fare con il mondo esterno al nostro sw, ovvero tutto quello che può essere categorizzato come I/O.

    Una funzione pura non può usare niente che sia un I/O, in quanto questo non garantirebbe più la sua natura deterministica. Potremmo però creare un artefatto che garantisca purezza indipendentemente dall’operazione di I/O:

    # Impure
     def getFromDb(query, dbConnection):
      return executeQuery(dbConnection,query)
    
    # Pure
     def getFromDb(query,dbConnection):
      return lambda: executeQuery(dbConnection,query)

    La prima definizione della funzione getFromDb non garantisce il suo output a causa di fattori esterni. La seconda definizione, invece, è pura: a parità di input ritornerà sempre lo stesso output, ovvero una nuova funzione che, una volta chiamata, interrogherà il DB con la query di input.
    Questa è l’idea che sta alla base di quello che è chiamato I/O Container

    class IO:
      @staticmethod
      def of(fn):
        return IO(lambda: fn)
    
      def map(self, fn):
        return IO(compose(fn, self.unsafePerformIO))
    
      def __init__(self, value):
        self.unsafePerformIO = value
    

    IO si differenzia dagli altri functor in quanto il valore che wrappa è sempre una funzione. Infatti, IO si comporta esattamente come l’esempio visto per getFromDb: ritarda l’esecuzione della parte impura racchiudendola in una nuova funzione. Come tale, è meglio immaginare che IO contenga il valore di ritorno della funzione impura piuttosto che la funzione stessa. Questo, è particolarmente chiaro se esaminiamo il metodo of: l’operazione di wrapping è un mero dettaglio interno del container.

    Nota bene

    Per essere più chiari negli esempi, mostrerò l’ipotetico valore di ritorno della funzione anche se nella realtà questo non si conoscerebbe fino al momento in cui l’esecuzione della parte di codice impuro termini.

    ioDB = IO.of(connectToDB)
    
    ioDB.map(executeQuery(getUserAdminQuery)).map(get("fullname")).map(split(" ")) # IO(["john", "doe"])
    

    ioDB non è altro che un container IO a cui possiamo applicare delle map senza doverci preoccupare delle implicazioni impure. Come ho detto, per essere più chiari, ho fatto vedere il valore di ritorno concettuale ma in realtà vedrete sempre qualcosa del tipo # IO( at 0x7f6c1b35ac80>).

    Quando chiamiamo map non stiamo facendo altro che aggiungere una funzione alla fine di una coda di composizioni potenzialmente tutte impure. Le funzioni mappate, quindi, non vengono eseguite ma vengono impilate una sull’altra come se stessimo orchestrando una computazione che non osiamo ancora eseguire. È come creare una torre a Jenga, prima di buttarla giù intenzionalmente!

    Se ignoriamo un’attimo dei dettagli implementativi di IO, ci rendiamo conto che possiamo interagirci come un normale functor. Futuri valori impuri sono gestiti senza timore e senza sacrificare la purezza del nostro codice!

    Quindi, abbiamo segregato il nostro codice impuro ma, prima o poi, lo dovremmo eseguire per fare qualcosa di utile. Ma è possibile togliere la spoletta alla nostra granata senza distruggere il nostro castello della purezza? La risposta è sì se affidiamo l’onere al chiamante: il nostro bel codice rimane puro ed è responsabilità del chiamante gestire i possibili brutti effetti derivati dall’esecuzione di qualcosa di impuro.

    ioDB = IO.of(connectToDB)
     getAllUsersFromDB = executeQuery(getUsersQuery)
    
    getFirstUser = compose(map(safeHead), map(getAllUsersFromDB), ioDB) isAdmin = lambda user: user.isAdmin
    
    isFirstUserAdmin = compose(map(map(isAdmin)), getFirstUser)
    
    class Application:
      @staticmethod
      def run():
        # unleash the beast
       isFirstUserAdmin.unsafePerformIO()
    

    La nostra piccola libreria rimane pura wrappando la chiamata al DB in un IO e passando l’onere al chiamante. Avrete sicuramente notato che abbiamo annidato 2 containers differenti: è perfettamente normale ritrovarsi containers nested come IO(Maybe(X)). Purtroppo questo porta a dover mappare in cascata, cosa non molto comoda. Esistono, però, dei costrutti chiamati Monadi che permettono di gestire questo potenziale incubo!

    Conclusioni

    Abbiamo visto la potenza derivante dall’uso di certi specifici containers che wrappano i nostri dati, permettendoci anche di addomesticare i side-effects.
    Per concludere appieno l’argomento containers dovremmo ancora parlare di:

    • Altri tipi di container, per esempio Task capace di gestire il codice asincrono
    • Monadi, per gestire il nesting di container
    • Functor Applicativi

    Se siete interessati a questi argomenti, commentate e sarò felice di rispondervi!