Programmazione funzionale in Ruby – Stato

Ruby è, per natura, un linguaggio orientato agli oggetti. Ci vogliono anche molti suggerimenti da linguaggi funzionali come Lisp.

Contrariamente all’opinione popolare, la programmazione funzionale non è un polo opposto sullo spettro. È un altro modo di pensare agli stessi problemi che possono essere molto utili per i programmatori Ruby.

A dire il vero, probabilmente stai già utilizzando molti concetti funzionali. Non è necessario arrivare fino a Haskell, Scala o altre lingue per ottenere i benefici.

Lo scopo di questa serie è quello di coprire la programmazione funzionale in una luce più pragmatica per quanto riguarda i programmatori Ruby. Ciò significa che alcuni concetti non saranno prove rigorose o FP puramente avorio, e va bene.

Ci concentreremo su esempi che puoi usare per migliorare i tuoi programmi oggi.

Detto questo, diamo un’occhiata al nostro primo argomento: Stato.

Uno dei concetti principali della programmazione funzionale è lo stato immutabile. In Ruby potrebbe non essere del tutto pratico rinunciarlo del tutto, ma il concetto è ancora eccezionalmente prezioso per noi.

Rinunciando allo stato, rendiamo le nostre applicazioni più facili da ragionare e testare. Il segreto è che non è necessario rinunciarvi completamente per ottenere alcuni di questi benefici.

Quindi cos’è esattamente lo stato? Lo stato è il dato che scorre attraverso il tuo programma e il concetto di stato immutabile significa che una volta impostato è impostato. Non cambiarlo.

  x = 5 
x + = 2 # Mutazione di stato!

Ciò vale soprattutto per i metodi:

  def remove (array, item) 
array.reject! {| v | v == item}
endarray = [1,2,3]
rimuovi (array, 1)
# => [2, 3] array
# => [2, 3]

Eseguendo tale azione, abbiamo mutato l’array in cui siamo passati. Ora immagina di avere altre due o tre funzioni che mutano anche l’array e ci imbattiamo in un piccolo problema.

Una funzione pura è quella che non muta i suoi input.

  def remove (array, item) 
array.reject {| v | v == item}
endarray = [1,2,3]
rimuovi (array, 1)
# => [2, 3] array
# => [1, 2, 3]

È più lento, ma è molto più facile prevedere che questo ci restituirà un nuovo array. Ogni volta che gli do l’ingresso A, mi restituisce il risultato B.

Il problema è che uno può predicare tutto il giorno nel merito di funzioni pure, ma fino a quando non ti trovi in ​​una situazione in cui ti morde, i benefici potrebbero non essere prontamente evidenti.

C’era una volta in Javascript in cui avevo usato il reverse per testare l’output di una scheda di gioco. Sembrerebbe perfetto, ma quando ho aggiunto un altro reverse ad esso tutti i miei test si sono interrotti!

Cosa dà?

Bene, come si è scoperto, la funzione inversa stava mutando la mia tavola.

Mi ci è voluto più tempo di quanto voglia ammettere qui quanto tempo mi sono reso conto che ciò stava accadendo, ma la mutazione può avere sottili effetti a cascata sul tuo programma a meno che non lo tieni sotto controllo.

Questo è il segreto però, non devi evitarlo esclusivamente, devi solo gestirlo in modo che sia molto chiaro quando e dove avvengono le mutazioni.

In Ruby, spesso le mutazioni di stato sono indicate con ! come suffisso. Non sempre, però, perché metodi come concat infrangono quelle regole, quindi tieni d’occhio !.

Un metodo per gestire lo stato è tenerlo nella scatola. Una funzione pura potrebbe assomigliare a questa:

  def aggiungi (a, b) 
a + b
fine

Quando vengono dati gli stessi input, ci restituiranno sempre gli stessi output. È utile, ma ci sono modi per nasconderlo.

  def count_by (array, & fn) 
array.each_with_object (Hash.new (0)) {| v, h |
h [fn.call (v)] + = 1
}
endcount_by ([1,2,3], &: even?)
# => {false => 2, true => 1}

A rigor di termini, stiamo mutando quell’hash per ogni valore dell’array. Non molto rigorosamente parlando, quando viene dato lo stesso input otteniamo esattamente lo stesso output.

Questo lo rende funzionalmente puro? No. Quello che abbiamo fatto qui è stato creato isolato che è presente solo all’interno della nostra funzione. Niente all’esterno sa cosa stiamo facendo per l’hash all’interno della funzione, e in Ruby questo è un compromesso accettabile.

Il problema è però che lo stato di isolamento richiede ancora che le funzioni facciano una sola cosa.

Le funzioni dovrebbero fare una sola cosa.

Ho visto questo tipo di modello molto comunemente nel codice più recente dei programmatori:

  classe RubyClub 
attr_reader: i membri def inizializzano
@members = []
fine

def add_member
stampa "Nome membro:"
member = gets.chomp
@members << membro
mette "Membro aggiunto!"
fine
fine

Il problema qui è che stiamo fondendo l’idea di aggiungere un membro con la restituzione di un messaggio all’utente. Non è questo il problema della nostra classe, deve solo sapere come aggiungere un membro.

All’inizio sembra innocuo, dato che alla fine si ottengono solo input e output. I problemi che incontriamo sono che gets in pausa dal test, in attesa di input, e dopo restituisce nil .

Come testeremmo una cosa del genere?

  descrivere '#add_member' do 
prima di fare
$ stdin = StringIO.new ("Havenwood \ n")
fine

dopo
$ stdin = STDIN
fine

"aggiunge un membro"
ruby_club = RubyClub.new
ruby_club.add_member
prevedono (ruby_club.members) .to eq (['Havenwood'])
fine
fine

È un sacco di codice. Dobbiamo intercettare STDIN (input standard) per farlo funzionare, il che rende il nostro codice di test molto più difficile da leggere.

Dai un’occhiata a un’implementazione più mirata, l’unica preoccupazione che ha è che ottiene un nuovo membro come input e restituisce tutti i membri come output.

  classe RubyClub 
attr_reader: i membri def inizializzano
@members = []
fine

def add_member (membro)
@members << membro
fine
fine

Tutto ciò che dobbiamo testare ora è questo:

  descrivere '#add_member' do 
"aggiunge un membro"
ruby_club = RubyClub.new
aspetta (ruby_club.add_member ('Havenwood')). a eq (['Havenwood'])
fine
fine

È sottratto alla preoccupazione di trattare l’IO (mette, ottiene), un’altra forma di stato.

Ora diciamo che il tuo Ruby Club deve anche funzionare con una CLI, o forse caricare risultati da un file. Come lo rifatti per funzionare? La tua classe attuale è radicata nell’idea che deve ottenere input e gestire output.

Questo si aggiunge a test e codici molto fragili che ti daranno problemi nel tempo.

Un altro modello comune è quello di astrarre i dati in costanti. Questa da sola non è una cattiva idea, ma può far sì che le tue classi e i tuoi metodi siano effettivamente hardcoded per funzionare in un modo.

Considera quanto segue:

  class SampleLoader 
SAMPLES_DIR = inizializzazione def '/ samples / ruby_samples'
@loaded_samples = {}
end def load_sample (nome)
@loaded_samples [nome] || = File.read ("# {SAMPLES_DIR} / # {name}")
fine
fine

È fantastico fintanto che ti occupi solo di quella directory specifica, ma cosa succede se dobbiamo creare un caricatore di campioni per elixir_samples o rust_samples ? Abbiamo un problema. La nostra costante è diventata un pezzo di stato statico che non possiamo cambiare.

La soluzione è usare un’idea chiamata iniezione. Inseriamo la conoscenza dei prerequisiti nella classe invece di codificare il valore in una costante:

  class SampleLoader 
def initialize (base_path)
@base_path = base_path
@loaded_samples = {}
end def load_sample (nome)
@loaded_samples [nome] || = File.read ("# {@ base_path} / # {name}")
fine
fine

Ora al nostro caricatore di campioni non importa davvero da dove vengono prelevati i campioni, purché quel file esista da qualche parte sul disco. Concesso ci sono anche potenziali rischi con la memorizzazione nella cache, ma questo è un esercizio lasciato al lettore.

Un modo per imbrogliare è usare valori predefiniti, impostati su una costante, ma per alcuni questo potrebbe essere un po ‘implicito. Usa saggiamente:

  class SampleLoader 
SAMPLES_DIR = '/ samples / ruby_samples' def inizializza (base_path = SAMPLES_DIR)
@base_path = base_path
@loaded_samples = {}
end def load_sample (nome)
@loaded_samples [nome] || = File.read ("# {@ base_path} / # {name}")
fine
fine

Supponiamo che il tuo Ruby Club abbia un’idea sul caricamento dei membri. Questa volta abbiamo ricordato di non codificare staticamente i percorsi:

  classe RubyClub 
def inizializza
@members = []
end def add_member (membro)
@members << membro
end def load_members (percorso)
JSON.parse (File.read (percorso)). Ciascuno eseguono | m |
@members << m
fine
fine
fine

Il problema in questo round è che ci affidiamo al fatto che il file members non è solo un file, ma anche in un formato JSON. Rende il nostro caricatore molto poco flessibile.

Siamo rimasti impigliati in un altro tipo di stato IO: siamo troppo preoccupati di come cariciamo i dati nel nostro club.

Supponiamo che tu voglia cambiarlo con un database come SQLite, o magari usare semplicemente YAML. È un compito molto difficile con il codice così com’è.

Alcune soluzioni a questo problema che vedo dai nuovi sviluppatori sono di creare più “caricatori” per gestire diversi tipi di input. E se non fosse in primo luogo una delle preoccupazioni del nostro club?

Se estraiamo l’intero concetto di caricamento dei membri, potremmo invece avere un codice come questo:

  classe RubyClub 
attr_reader: inizializzazione def dei membri (membri = [])
@members = membri
end def add_members (* membri)
@ members.concat (membri)
fine
endnew_members = YAML.load (File.read ('data.yml'))
RubyClub.new (new_members)

La cosa divertente di OO e FP è che possono essere applicati molti degli stessi concetti, tendono solo ad avere nomi diversi. Potrebbero non essere esatte sovrapposizioni, ma molto di ciò che impari da un linguaggio funzionale può sembrare molto familiare dalle migliori pratiche in un linguaggio più imperativo.

In molti modi, mantenere lo stato sotto controllo è un esercizio di separazione delle preoccupazioni. Le funzioni pure abbinate a questo possono rendere il codice eccezionalmente flessibile e robusto che è più facile da testare, ragionare ed estendere.

State in Ruby potrebbe non essere del tutto puro, ma tenendolo sotto controllo i tuoi programmi saranno sostanzialmente più facili da utilizzare in seguito. In programmazione, questo è tutto.

Leggerai e aggiornerai il codice molto più di quello che stai scrivendo, quindi più fai per scriverlo in modo flessibile dall’inizio, più facile sarà leggere e lavorare in seguito.

Come ho accennato in precedenza, questo corso si concentrerà maggiormente sugli usi pragmatici della programmazione funzionale in relazione a Ruby. Potremmo concentrarci su un intero schema di calcolo Lambda derivato e creare un programma veramente puro, ma sarebbe lento e incredibilmente noioso.

Detto questo, è anche divertente giocare con l’occasione solo per vedere come funziona. Se questo è interessante, questo è un grande libro sull’argomento:

Comprensione del calcolo

Infine, puoi imparare la teoria del calcolo e la progettazione del linguaggio di programmazione in un modo pratico e coinvolgente. Comprensione…

shop.oreilly.com

Se vuoi continuare ad esplorare quella tana del coniglio, Raganwald fa molto piacere qui:

Gheppi, uccelli bizzarri ed egocentrismo senza speranza

Kestrels… di Reg “raganwald” Braithwaite [PDF / iPad / Kindle]

leanpub.com

Come sempre, buon divertimento!

I prossimi sono chiusure:

Programmazione funzionale in Ruby – Chiusure

Una delle caratteristiche più potenti della programmazione funzionale che possiamo sfruttare in Ruby è il concetto di chiusura.

medium.com