Ruby Deep Dive: controllo di flusso
L’episodio 1 ha coperto le variabili e i tipi base, incluso la regola truthy/falsy di Ruby: solo nil e false sono falsy, tutto il resto — incluso 0 e "" — è truthy. Quella regola è la base di tutto questo episodio, perché ogni ramo condizionale e ogni loop in Ruby si riduce, alla fine, a “questo è truthy o falsy?”
Il codice è nel repo ruby-deep-dive, taggato episode-2. Stesso formato dell’episodio 1: leggi il post, poi completa 02-control-flow/exercises.rb finché 02-control-flow/exercises_test.rb non diventa tutto verde.
if / elsif / else
def grade_for(score)
if score >= 90
"A"
elsif score >= 80
"B"
else
"F"
end
end
Niente di sorprendente in superficie — ma c’è una cosa su cui vale la pena essere precisi: if è un’espressione, non un’istruzione. Restituisce il valore del ramo che è stato eseguito, e quel valore è ciò che il metodo restituisce (non c’è nessun return qui — l’ultima espressione valutata è il valore di ritorno del metodo, un’altra cosa vera in tutto Ruby, non specifica di if). Se nessun ramo corrisponde e non c’è else, l’intero if restituisce nil:
result = if false
"non succede mai"
end
result #=> nil
Questo è anche il motivo per cui Ruby ha una forma modificatore — una singola riga che si legge quasi come inglese, usata continuamente per le guard clause:
puts "adulto" if age >= 18
return nil unless user
Usa la forma modificatore quando il corpo è una riga breve e non c’è elsif/else; usa la forma multi-riga nel momento in cui compare un secondo ramo o il corpo richiede più di un’istruzione.
unless
unless condizione è esattamente if !condizione, niente di più:
def bouncer_message(age)
unless age < 18
"Come on in!"
else
"Sorry, 18+ only."
end
end
unless ... else è Ruby legale, ma la maggior parte delle style guide (e la maggior parte dei rubysti) lo scoraggia, per una ragione di leggibilità che vale la pena interiorizzare invece di accettare come regola fine a se stessa: con unless, il ramo else viene eseguito quando la condizione è vera, il che significa che ogni lettore deve negare la condizione nella propria testa prima che l’else abbia senso. if cond ... else ... si legge nell’ordine in cui il cervello lo elabora; unless cond ... else ... si legge al contrario. Riserva unless a ciò per cui è davvero indicato: una singola condizione di guardia senza alcun else — unless brilla esattamente dove brilla la forma modificatore, es. return unless valid?.
L’operatore ternario
def parity_word(n)
n.even? ? "even" : "odd"
end
condizione ? se_vero : se_falso non è un costrutto di controllo di flusso a sé — è if/else come espressione, scritto su una riga. Usalo solo quando entrambi i rami sono brevi e sono esattamente due; un ternario con un altro ternario annidato dentro è un code smell, non uno stile compatto. Se serve un terzo ramo, quello è case/when o un if/elsif/else completo, non una catena di ?:.
case/when — e l’operatore che lo fa funzionare
def parking_fee(hours)
case hours
when 0..1 then 0
when 2..4 then 5
when 5..8 then 10
else 15
end
end
Sembra uno switch di altri linguaggi, ma sotto il cofano sta facendo qualcosa di significativamente diverso, e capirlo cambia ciò per cui useresti case/when. Ogni clausola when non testa hours == 0..1 — chiama (0..1) === hours, l’operatore di uguaglianza case, con il valore di when a sinistra. Range#=== è definito come “questo range copre il valore” (include?), ed è esattamente per questo che i range funzionano come clausole when. Non è sintassi speciale per i range — è === che si comporta diversamente a seconda di quale classe lo definisce:
(0..1) === 1 #=> true (Range#===: appartenenza)
String === "hi" #=> true (Module#===: is_a?)
/^h/ === "hi" #=> true (Regexp#===: corrispondenza)
42 === 42 #=> true (Object#===: ricade su ==)
Questo significa che case/when non è limitato a valori e range — un ramo when String corrisponde a qualunque cosa sia una String, e un ramo when /^h/ corrisponde a qualunque cosa la regex faccia match. È uno strumento di dispatch molto più generale di “switch su un valore”, una volta capito cosa viene davvero chiamato.
L’altro dettaglio da sapere: una clausola when può elencare più candidati separati da virgola, verificati in OR:
def day_type(day)
case day
when :saturday, :sunday
"Weekend"
when :monday, :tuesday, :wednesday, :thursday, :friday
"Weekday"
else
"Not a day"
end
end
when :saturday, :sunday esegue il ramo se :saturday === day oppure :sunday === day — un ramo solo, più valori accettati, senza dover ripetere il corpo per ogni giorno.
E un case non ha bisogno di un soggetto. Scritto senza, ogni when è una condizione booleana completa, valutata dall’alto in basso come una catena if/elsif — utile davvero quando le condizioni non testano tutte la stessa variabile:
case
when age < 13 then "bambino"
when age < 20 then "teenager"
else "adulto"
end
while e until
def sum_up_to(n)
total = 0
i = 1
while i <= n
total += i
i += 1
end
total
end
while condizione ... end esegue il corpo finché la condizione resta truthy, controllandola prima di ogni iterazione — inclusa la primissima, quindi un while la cui condizione parte falsa non gira mai nemmeno una volta (è esattamente per questo che sum_up_to(0) restituisce 0: i parte da 1, 1 <= 0 è falso subito, il corpo del loop non viene mai eseguito). until condizione è while !condizione — stesso loop, controllo invertito:
def countdown(n)
result = []
i = n
until i < 0
result << i
i -= 1
end
result
end
Anche entrambi hanno una forma modificatore, stessa idea di if/unless:
i += 1 while i < 10
Fai attenzione con la forma modificatore in particolare intorno a begin...end while — ha una particolarità (il corpo viene eseguito una volta prima del primo controllo, a differenza di ogni altro while) abbastanza rara nella pratica da non valere la pena memorizzarla adesso, ma vale la pena sapere che esiste così da non sorprenderti più avanti.
loop e break con un valore
while/until hanno bisogno di una condizione all’inizio. A volte il punto di uscita naturale è nel mezzo del corpo, non qualcosa esprimibile come condizione pre-loop. È a questo che serve loop:
def first_power_of_two_above(limit)
power = 1
loop do
break power if power > limit
power *= 2
end
end
loop è un metodo normale (Kernel#loop) che prende un blocco e lo esegue per sempre, finché qualcosa al suo interno non lo ferma. break ferma il loop — ed ecco il dettaglio facile da perdere venendo da altri linguaggi: break valore non si limita a uscire dal loop, fa sì che l’intera espressione loop do...end restituisca valore. Un loop while restituisce sempre nil, comunque finisca; loop restituisce qualunque cosa break gli abbia passato (o nil se non fa mai break con un valore). Ecco perché first_power_of_two_above non ha bisogno di una variabile dichiarata fuori dal loop per “ricordare” la risposta — il loop stesso è la risposta, una volta che ci fai break con essa.
next
Dentro qualunque loop, next salta direttamente all’iterazione successiva senza eseguire il resto del corpo:
def sum_of_evens(numbers)
total = 0
numbers.each do |n|
next if n.odd?
total += n
end
total
end
next if n.odd? si legge come una guard clause in testa al blocco: “se questo non è idoneo, passa oltre” — la stessa forma di un return anticipato dentro un metodo, solo per una singola iterazione del loop invece che per l’intera chiamata. (numbers.each è tecnicamente un metodo Enumerable più che una parola chiave di controllo di flusso come while/until/loop — each e i suoi parenti avranno un intero episodio tutto loro più avanti in questa serie — ma next/break funzionano dentro il suo blocco esattamente come dentro while, until o loop.)
for — e perché lo vedrai raramente
Ruby ha davvero un loop for:
for i in 1..3
puts i
end
Viene menzionato qui soprattutto perché tu lo riconosca se ci incappi, non perché tu debba usarlo. Il motivo per cui il Ruby idiomatico evita for a favore di each/times/ecc. è una differenza di scoping, e vale la pena vederla una volta direttamente:
for i in 1..3
end
i #=> 3, ancora accessibile qui
[1, 2, 3].each do |i|
end
i #=> NameError: undefined local variable or method 'i'
for non introduce un nuovo scope — la variabile del loop finisce nello scope circostante, esattamente come farebbe il contatore di un normale loop while. Un blocco passato a each introduce invece un proprio scope, quindi i dentro il blocco è una variabile locale nuova che smette di esistere nel momento in cui il blocco finisce. Quel contenimento è esattamente il comportamento che vuoi quasi sempre, ed è la vera ragione per cui each ha vinto su for come idioma Ruby — non “for è deprecato”, solo “lo scoping di each è più sicuro di default”.
Truthy e falsy, un livello più a fondo
L’episodio 1 ha stabilito la regola: solo nil e false sono falsy. Ora che if/while/unless sono tutti a posto, ecco l’idioma che quella regola rende possibile, e che compare continuamente nel codice Ruby reale:
name = nil
display_name = name || "Guest"
|| non produce solo true/false — valuta il lato sinistro, e se è truthy, restituisce proprio quel valore; solo se il lato sinistro è falsy valuta e restituisce il lato destro. && funziona allo stesso modo ma al contrario: restituisce il lato sinistro se è falsy (interrompendo la valutazione prima ancora di toccare il lato destro), altrimenti restituisce il lato destro. Questo è il vero meccanismo dietro il pattern x || default, estremamente comune — non è una sintassi speciale per “valore predefinito”, è || che fa esattamente ciò che fa sempre, applicato a un caso in cui quel comportamento è proprio quello che serve.
Un’altra cosa che vale la pena segnalare ora piuttosto che dopo che ti abbia morso: Ruby ha anche and/or/not come equivalenti in parole inglesi di &&/||/!. Non sono intercambiabili con gli operatori simbolici, perché legano a una precedenza molto più bassa — più bassa di =:
x = false or true
x #=> false
Sembra che dovrebbe impostare x a true, ma = lega più stretto di or, quindi viene in realtà interpretato come (x = false) or true — x prende false, e l’or true non fa nulla di utile. Questa è una trappola ben nota, non un rischio stilistico improbabile: attieniti a &&/||/! per la logica vera e propria, e considera and/or/not di fatto inutilizzati nel Ruby idiomatico.
Gli esercizi
Dieci metodi in 02-control-flow/exercises.rb, uno per ogni concetto trattato sopra: if/elsif/else, unless, l’operatore ternario, case/when con range, case/when con valori separati da virgola, while, until, loop/break con un valore, next, e la truthiness nativa di Ruby via !!.
git clone https://github.com/AntoninoScaffidi/ruby-deep-dive.git
cd ruby-deep-dive
bundle install
ruby 02-control-flow/exercises_test.rb
Cosa viene dopo
L’episodio 3 copre i metodi: definizioni, argomenti (posizionali, keyword, con default, splat), e cosa restituisce davvero un metodo.