Voi siete qui: Inizio ‣ Programmare in Scala ‣
Un linguaggio domain-specific è un linguaggio di programmazione che imita i termini, gli idiomi e le espressioni usate dagli esperti di un particolare dominio. Il codice scritto in un DSL si legge come prosa strutturata per il dominio. Idealmente, un esperto di dominio con poca esperienza di programmazione può leggere, capire e convalidare questo codice. A volte, un esperto di dominio potrebbe essere in grado di scrivere codice in un DSL anche se non è un programmatore di professione.
I DSL sono un argomento vasto. Sfioreremo solo la superficie dei DSL e dell’impressionante supporto fornito per loro da Scala. Per maggiori informazioni sui DSL in generale, si vedano [Fowler2009], [Ford2009] e [Deursen]. Lo strumento sake, usato per assemblare gli esempi del libro, adopera un DSL simile al venerabile make e al suo cugino Ruby rake. (Si veda il file README nell’archivio di codice scaricabile per i dettagli.) Per altri esempi di DSL “interni” ed “esterni” in Scala, si vedano [Ghosh2008a] e [Ghosh2008b]. Per quanto riguarda il materiale avanzato sui DSL che usano Scala, [Hofer2008] esamina la sostituzione polimorfica di implementazioni alternative per le astrazioni di un DSL, utilizzabile per attività di analisi, ottimizzazione, composizione, &c.
I DSL ben progettati offrono diversi vantaggi.
Tuttavia, i DSL hanno anche diversi svantaggi.
Tuttavia, quando l’impiego di un DSL in un’applicazione è appropriato, come nel caso in cui venga usato frequentemente per implementare e modificare funzionalità, un DSL ben progettato può rivelarsi uno strumento potente per costruire applicazioni flessibili e robuste.
Dal punto di vista dell’implementazione, i DSL vengono spesso classificati come interni ed esterni.
Un DSL interno (a volte detto integrato) è uno stile idiomatico di scrittura del codice in un linguaggio di programmazione generico come Scala. I DSL interni non necessitano di alcun riconoscitore specifico, ma vengono riconosciuti proprio come qualsiasi altro programma scritto nel linguaggio generico. Al contrario, un DSL esterno è un linguaggio personalizzato con la propria grammatica e il proprio riconoscitore.
I DSL interni sono più facili da creare perché non richiedono un riconoscitore speciale. D’altra parte, i vincoli del linguaggio sottostante limitano le modalità con cui esprimere i concetti del dominio. I DSL esterni rimuovono questo vincolo: potete progettare il linguaggio come preferite, purché siate in grado di scriverne un riconoscitore affidabile. Lo svantaggio di un DSL esterno è la necessità di creare e usare un riconoscitore personalizzato.
I DSL esistono da molto tempo: per esempio, i DSL interni scritti in Lisp sono vecchi quanto lo stesso Lisp. L’interesse per i DSL si è recentemente intensificato, in parte grazie alla comunità Ruby perché i DSL sono molto facili da implementare in quel linguaggio. Come vedremo, Scala offre un supporto eccellente per la creazione di DSL interni ed esterni.
Creiamo un DSL interno per un’applicazione di contabilità che calcola la busta paga di un impiegato per ogni periodo di retribuzione quindicinale. La busta paga conterrà lo stipendio netto dell’impiegato, che è lo stipendio lordo meno le ritenute per le tasse, i premi di assicurazione (almeno in alcuni paesi), i contributi per la pensione, &c.
Per capire meglio le differenze tra il codice che usa i DSL e il codice che non li usa, proviamo a risolvere il problema con entrambe le tecniche. Ecco come si potrebbe calcolare la busta paga di due impiegati senza l’aiuto di un DSL.
// esempi/cap-11/payroll/api/payroll-api-script.scala
import payroll.api._
import payroll.api.DeductionsCalculator._
import payroll._
import payroll.Type2Money._
val buck = Employee(Name("Buck", "Trends"), Money(80000))
val jane = Employee(Name("Jane", "Doe"), Money(90000))
List(buck, jane).foreach { employee =>
// Supponiamo che l'anno sia basato su 52 settimane.
val biweeklyGross = employee.annualGrossSalary / 26.
val deductions = federalIncomeTax(employee, biweeklyGross) +
stateIncomeTax(employee, biweeklyGross) +
insurancePremiums(employee, biweeklyGross) +
retirementFundContributions(employee, biweeklyGross)
val check = Paycheck(biweeklyGross, biweeklyGross - deductions, deductions)
format("%s %s: %s\n", employee.name.first, employee.name.last, check)
}
Per ogni impiegato, lo script calcola lo stipendio lordo per il periodo di retribuzione, le ritenute e il risultato netto. Questi valori vengono memorizzati in un oggetto Paycheck
, che poi viene stampato. Prima di descrivere i tipi che stiamo usando, esaminiamo alcune caratteristiche del ciclo foreach
che svolge le operazioni.
Per prima cosa, si noti che c’è molto “rumore”: il ciclo cita continuamente employee e biweeklyGross, per esempio. Un DSL ci aiuterà a minimizzare questa rumorosità e a concentrarci su ciò che sta realmente accadendo.
Secondo, il codice è imperativo: dice “dividi questo, aggiungi quello” e così via. Vedremo che i nostri DSL sembrano simili, ma il loro stile è più dichiarativo, in quanto nascondono le modalità operative all’utente.
Ecco la semplice classe Paycheck
usata nello script.
// esempi/cap-11/payroll/paycheck.scala
package payroll
/**
* Stiamo ignorando i casi non validi (?) in cui il netto è negativo
* quando le ritenute superano il lordo.
*/
case class Paycheck(gross: Money, net: Money, deductions: Money) {
def plusGross (m: Money) = Paycheck(gross + m, net + m, deductions)
def plusDeductions (m: Money) = Paycheck(gross, net - m, deductions + m)
}
Il tipo Employee
usa un tipo Name
.
// esempi/cap-11/payroll/employee.scala
package payroll
case class Name(first: String, last: String)
case class Employee(name: Name, annualGrossSalary: Money)
Il tipo Money
gestisce i calcoli, l’arrotondamento a quattro posti decimali, &c. Ignora la valuta, tranne che nel metodo toString
. I calcoli finanziari appropriati sono notoriamente difficili da eseguire correttamente per le transazioni reali. Questa implementazione non è perfettamente accurata, ma lo è abbastanza per i nostri scopi. [MoneyInJava] offre informazioni utili su come effettuare calcoli monetari reali.
// esempi/cap-11/payroll/money.scala
package payroll
import java.math.{BigDecimal => JBigDecimal,
MathContext => JMathContext, RoundingMode => JRoundingMode}
/**
* La maggior parte dei calcoli viene eseguita usando JBigDecimal
* per un controllo più rigido.
*/
class Money(val amount: BigDecimal) {
def + (m: Money) =
Money(amount.bigDecimal.add(m.amount.bigDecimal))
def - (m: Money) =
Money(amount.bigDecimal.subtract(m.amount.bigDecimal))
def * (m: Money) =
Money(amount.bigDecimal.multiply(m.amount.bigDecimal))
def / (m: Money) =
Money(amount.bigDecimal.divide(m.amount.bigDecimal,
Money.scale, Money.jroundingMode))
def < (m: Money) = amount < m.amount
def <= (m: Money) = amount <= m.amount
def > (m: Money) = amount > m.amount
def >= (m: Money) = amount >= m.amount
override def equals (o: Any) = o match {
case m: Money => amount equals m.amount
case _ => false
}
override def hashCode = amount.hashCode * 31
// Hack: deve invocare esplicitamente la conversione corretta: double2Double
override def toString =
String.format("$%.2f", double2Double(amount.doubleValue))
}
object Money {
def apply(amount: BigDecimal) = new Money(amount)
def apply(amount: JBigDecimal) = new Money(scaled(new BigDecimal(amount)))
def apply(amount: Double) = new Money(scaled(BigDecimal(amount)))
def apply(amount: Long) = new Money(scaled(BigDecimal(amount)))
def apply(amount: Int) = new Money(scaled(BigDecimal(amount)))
def unapply(m: Money) = Some(m.amount)
protected def scaled(d: BigDecimal) = d.setScale(scale, roundingMode)
val scale = 4
val jroundingMode = JRoundingMode.HALF_UP
val roundingMode = BigDecimal.RoundingMode.ROUND_HALF_UP
val context = new JMathContext(scale, jroundingMode)
}
object Type2Money {
implicit def bigDecimal2Money(b: BigDecimal) = Money(b)
implicit def jBigDecimal2Money(b: JBigDecimal) = Money(b)
implicit def double2Money(d: Double) = Money(d)
implicit def long2Money(l: Long) = Money(l)
implicit def int2Money(i: Int) = Money(i)
}
Notate l’uso di scala.BigDecimal
, che avvolge java.math.BigDecimal
, come tipo per memorizzare le cifre finanziarie.
Le ritenute vengono calcolate usando quattro metodi di utilità contenuti in payroll.api.DeductionsCalculator
.
// esempi/cap-11/payroll/api/deductions-calc.scala
package payroll.api
import payroll.Type2Money._
object DeductionsCalculator {
def federalIncomeTax(empl: Employee, gross: Money) = gross * .25
def stateIncomeTax(empl: Employee, gross: Money) = gross * .05
def insurancePremiums(empl: Employee, gross: Money) = Money(500)
def retirementFundContributions(empl: Employee, gross: Money) = gross * .10
}
Ogni metodo potrebbe usare le informazioni sull’impiegato e lo stipendio lordo per il periodo di retribuzione, ma in questo caso usiamo algoritmi molto semplici basati solo sullo stipendio lordo, tranne che per i premi di assicurazione, qui trattati come un valore fisso.
L’esecuzione dello script per la API del libro paga produce l’uscita seguente.
(665) $ scala -cp ... payroll-api-script.scala Buck Trends: Paycheck($3076.92,$1346.15,$1730.77) Jane Doe: Paycheck($3461.54,$1576.92,$1884.62)
Il codice precedente funziona abbastanza bene, ma supponiamo di volerlo mostrare all’Ufficio Contabilità per confermare che stiamo calcolando correttamente le buste paga. Molto probabilmente, i contabili si perderanno negli idiomi di Scala. Supponiamo anche che sia necessario personalizzare frequentemente questo algoritmo, per esempio per adeguarlo a differenti tipi di impiegato (salariato, a ore, &c.) o per modificare il calcolo delle ritenute. Idealmente, vorremmo mettere i contabili in grado di effettuare da soli queste personalizzazioni senza ricorrere al nostro aiuto.
Potremmo raggiungere i nostri scopi se riuscissimo a esprimere l’algoritmo in un DSL sufficientemente intuitivo per un contabile. Siamo in grado di trasformare il nostro esempio di API in un DSL di questo tipo?
Tornando allo script per la API del libro paga, cosa succede se nascondiamo la maggior parte dei riferimenti espliciti a informazioni di contesto, come l’impiegato, lo stipendio lordo e il valori delle ritenute? Considerate il testo seguente.
Rules to calculate an employee's paycheck: employee's gross salary for 2 weeks minus deductions for federalIncomeTax, which is 25% of gross stateIncomeTax, which is 5% of gross insurancePremiums, which are 500. in gross's currency retirementFundContributions are 10% of gross
Queste righe si leggono come normale inglese,1 non come codice. Abbiamo incluso alcune parole “superflue” (chiamate bubble words in [Ford2009]) che aumentano la leggibilità ma non corrispondono necessariamente a qualcosa di essenziale, come to
, an
, is
, for
, of
e which
. Nel nostro DSL in Scala elimineremo alcune di queste parole superflue e ne manterremo altre.
Confrontando questa versione con lo script della API del libro paga, il rumore che nasconde le parti essenziali dell’algoritmo è molto inferiore, in quanto abbiamo minimizzato i riferimenti espliciti alle informazioni di contesto: menzioniamo employee
solo due volte; menzioniamo gross
cinque volte, ma sperabilmente in modi “intuitivi”.
Potremmo costruire molti DSL interni in Scala che somigliano a questo DSL ad hoc. Eccone uno, sempre contenuto in uno script, che produce la stessa uscita di prima.
// esempi/cap-11/payroll/dsl/payroll-dsl-script.scala
import payroll._
import payroll.dsl._
import payroll.dsl.rules._
val payrollCalculator = rules { employee =>
employee salary_for 2.weeks minus_deductions_for { gross =>
federalIncomeTax is (25. percent_of gross)
stateIncomeTax is (5. percent_of gross)
insurancePremiums are (500. in gross.currency)
retirementFundContributions are (10. percent_of gross)
}
}
val buck = Employee(Name("Buck", "Trends"), Money(80000))
val jane = Employee(Name("Jane", "Doe"), Money(90000))
List(buck, jane).foreach { employee =>
val check = payrollCalculator(employee)
format("%s %s: %s\n", employee.name.first, employee.name.last, check)
}
Analizzeremo l’implementazione passo per passo, ma prima riassumiamo le caratteristiche di Scala che ci permettono di implementare questo DSL.
Considerate questa riga nella definizione di payrollCalculator
.
employee salary_for 2.weeks minus_deductions_for { gross =>
Questa notazione infissa è equivalente alla seguente forma meno leggibile.
employee.salary_for(2.weeks).minus_deductions_for { gross =>
Ora potete capire perché prima abbiamo scritto 2.weeks
, dato che il risultato di questa espressione viene passato a salary_for
. Senza il punto, l’espressione infissa verrebbe riconosciuta come employee.salary_for(2).weeks…
; ma non c’è alcun metodo weeks
in Int
, naturalmente. Rivisiteremo questa espressione fra un momento.
La concatenazione di metodi viene spesso impiegata in casi come questo, dove ogni metodo restituisce this
in modo che possiate continuare a invocare metodi sulla stessa istanza. Notate che la restituzione di this
consente alle invocazioni di metodo di avvenire in qualsiasi ordine. Se avete bisogno di imporre un ordine particolare, allora restituite un’istanza di tipo differente. Per esempio, se minus_deductions_for
deve essere invocato dopo salary_for
, allora salary_for
dovrebbe restituire una nuova istanza.
Dato che la concatenazione è così facile, avremmo potuto creare metodi separati per salary
, for
, minus
e deductions
, prendendoci la libertà di scrivere l’espressione seguente.
employee salary for 2.weeks minus deductions for { gross =>
Notate che le invocazioni di for
sono precedute da invocazioni differenti con significati molto diversi. Quindi, se durante tutto il procedimento venisse usata la stessa istanza, essa dovrebbe tenere traccia del “flusso” internamente. Concatenare istanze differenti eliminerebbe questo problema. Tuttavia, dato che non deve avvenire nessuna computazione tra queste parole, abbiamo scelto la forma più semplice in cui le parole sono unite tra loro da un trattino basso _
.
Tornando a 2.weeks
, dato che Int
non ha un metodo weeks
, impieghiamo una conversione implicita verso un’istanza di Duration
che racchiude un intero usato per specificare una quantità.
// esempi/cap-11/payroll/dsl/duration.scala
package payroll.dsl
case class Duration(val amount: Int) {
/** @return il numero di giorni lavorativi in "amount" settimane. */
def weeks = amount * 5
/** @return il numero di giorni lavorativi in "amount" anni. */
def years = amount * 260
}
Il metodo weeks
moltiplica quella quantità per 5
allo scopo di restituire il corrispondente numero di giorni lavorativi. Quindi, abbiamo progettato il calcolo dei pagamenti in modo da utilizzare i giorni come unità di tempo. Questa decisione è completamente nascosta dietro il DSL. Se più tardi dovessimo aggiungere il supporto per le ore lavorative, sarebbe facile riorganizzare il codice per usare le ore al posto dei giorni.
Duration
è uno di quei tipi ad hoc che abbiamo progettato allo scopo di incapsulare il contesto implicito, implementare metodi di utilità per il DSL, &c. Discuteremo il necessario metodo di conversione implicita fra un momento.
apply
Nella nostra implementazione, un certo numero di oggetti usa apply
per invocare un determinato comportamento. L’oggetto rules
incapsula il processo di costruzione delle regole per il calcolo dei pagamenti. Il metodo apply
di questo oggetto accetta un letterale funzione Employee => Paycheck
.
Ora esploriamo l’implementazione, attraversandola a partire dall’oggetto rules
.
// esempi/cap-11/payroll/dsl/payroll.scala
package payroll.dsl
import payroll._
object rules {
def apply(rules: Employee => Paycheck) = new PayrollBuilderRules(rules)
implicit def int2Duration(i: Int) = Duration(i)
implicit def employee2GrossPayBuilder(e: Employee) =
new GrossPayBuilder(e)
implicit def grossPayBuilder2DeductionsBuilder(b: GrossPayBuilder)
= new DeductionsBuilder(b)
implicit def double2DeductionsBuilderDeductionHelper(d: Double) =
new DeductionsBuilderDeductionHelper(d)
}
import rules._
…
Il letterale funzione passato come argomento a rules.apply
viene usato per costruire un oggetto PayrollBuilderRules
che elaborerà le regole specificate. Questo avviene proprio all’inizio del DSL.
val payrollCalculator = rules { employee => …
L’oggetto rules
definisce anche i metodi di conversione implicita. Il primo viene usato dall’espressione 2.weeks
e converte 2
in un’istanza di Duration
, come abbiamo detto in precedenza. L’altro metodo viene usato più avanti nel DSL per abilitare la conversione trasparente di Double
, Employee
, &c. in istanze avvolgenti che descriveremo tra breve.
Notate che l’oggetto rules
viene importato in modo che queste conversioni siano visibili nel resto del file corrente. È necessario importarlo anche nei file che usano il DSL.
PayrollBuilderRules
è la prima delle nostre istanze avvolgenti. Si occupa di valutare il letterale funzione per l’intero insieme di regole all’interno di un blocco try
/catch
.
// esempi/cap-11/payroll/dsl/payroll.scala
…
class PayrollException(message: String, cause: Throwable)
extends RuntimeException(message, cause)
protected[dsl] class PayrollBuilderRules(rules: Employee => Paycheck) {
def apply(employee: Employee) = {
try {
rules(employee)
} catch {
case th: Throwable => new PayrollException(
"Impossibile calcolare lo stipendio per l'impiegato: " + employee, th)
}
}
}
…
La protezione dell’accesso a PayrollBuilderRules
è necessaria per evitare che i clienti lo usino direttamente. Tuttavia, l’eccezione rimane pubblica in modo da usarla nelle clausole catch
. (È possibile scegliere se avvolgere un’eccezione lanciata in un’eccezione “domain-specific”, come si vede dal codice.)
Notate che dobbiamo passare l’impiegato come un’istanza di “contesto” all’interno del letterale funzione. Abbiamo detto che è preferibile rendere quanto più possibile implicito il contesto. Le nostre classi di implementazione, come PayrollBuilderRules
, sono accomunate dall’idea di conservare l’informazione di contesto in istanze avvolgenti minimizzandone la visibilità nel DSL. In alternativa sarebbe possibile memorizzare il contesto in oggetti singleton utilizzabili anche da altre istanze, ma sfortunatamente questo approccio si espone a problemi di sicurezza con i thread.
Per capire cosa vogliamo dire riguardo al contesto, considerate il punto del nostro script che usa il DSL del libro paga in cui vengono specificate le ritenute.
… { gross =>
federalIncomeTax is (25. percent_of gross)
stateIncomeTax is (5. percent_of gross)
insurancePremiums are (500. in gross.currency)
retirementFundContributions are (10. percent_of gross)
}
Considerate i premi assicurativi, per i quali viene ritenuta una quota fissa pari a Money(500)
. Perché non abbiamo semplicemente scritto insurancePremiums are 500.
? A quanto pare, dobbiamo “intrufolare” l’istanza gross nell’espressione, in qualche modo. Il nome gross la identifica come un’istanza di Money
che rappresenta lo stipendio dell’impiegato per il periodo di retribuzione. DSL birichini!!! In realtà è un’istanza della classe di utilità DeudctionsBuilder
a memorizzare l’intera busta paga, incluso lo stipendio lordo e l’istanza dell’impiegato. Il nome gross viene usato semplicemente perché aumenta la leggibilità delle frasi in cui è presente.
Questo blocco calcola le ritenute e le trattiene dallo stipendio lordo per determinare lo stipendio netto. L’istanza gross gestisce questo processo. Non c’è “comunicazione” tra le quattro righe del letterale funzione. Inoltre, federalIncomeTax, insurancePremiums, &c. sono oggetti senza alcuna relazione con DeductionsBuilder
(come vedremo fra breve). Sarebbe fantastico se potessero essere membri di DeductionsBuilder
o magari di qualche altra istanza avvolgente che incapsula questo ambito, perché allora ogni riga rappresenterebbe una chiamata di metodo su una o sull’altra classe avvolgente. Sfortunatamente questo non è possibile, quindi ogni riga deve specificare l’istanza gross per mantenere continuità. Abbiamo fatto i salti mortali per supportare la sintassi e rendere disponibile gross allo stesso tempo, come necessario.
Quindi, abbiamo escogitato la convenzione per cui i numeri “grezzi”, come le ritenute assicurative, devono essere qualificati dalla particolare valuta usata per lo stipendio lordo. Tra un attimo vedremo come funziona l’espressione 500. in gross.currency
. È una specie di “hack”, ma si legge bene e risolve il nostro problema di progettazione.
Ecco una possibile forma alternativa che avrebbe evitato il problema.
… { builder =>
builder federalIncomeTax (25. percent_of gross)
builder stateIncomeTax (5. percent_of gross)
builder insurancePremiums 500.
builder retirementFundContributions (10. percent_of gross)
}
Ora l’utilizzo di builder è più esplicito, e federalIncomeTax
, insurancePremiums
, &c. sono metodi dell’assemblatore. Abbiamo optato per uno stile più leggibile, venendo penalizzati da un’implementazione più difficile da realizzare. A volte sentirete l’espressione interfaccia fluida per fare riferimento a DSL che enfatizzano la leggibilità.
Ecco la nostra classe GrossPayBuilder
.
// esempi/cap-11/payroll/dsl/payroll.scala
…
import payroll.Type2Money._
protected[dsl] class GrossPayBuilder(val employee: Employee) {
var gross: Money = 0
def salary_for(days: Int) = {
gross += dailyGrossSalary(employee.annualGrossSalary) * days
this
}
// Presume 260 giorni lavorativi: 52 settimane (vacanze incluse) * 5 giorni/settimana.
def weeklyGrossSalary(annual: Money) = annual / 52.0
def dailyGrossSalary(annual: Money) = annual / 260.0
}
…
Ricordatevi che rules
definisce un metodo di conversione implicita da Employee
verso questo tipo. La conversione viene operata dall’espressione employee salary_for
, in modo che il metodo GrossPayBuilder.salary_for
possa essere invocato. GrossPayBuilder
inizializza gross e gli aggiunge nuovi valori ogni volta che salary_for
viene invocato; il metodo presume di accumulare lo stipendio lordo a incrementi di giorni. Infine, salary_for
restituisce this
per supportare la concatenazione.
Il calcolo delle ritenute è la parte più complessa. Quando minus_deductions_for
viene usato nel DSL, innesca la conversione implicita da GrossPayBuilder
a DeductionsBuilder
definita in rules
.
// esempi/cap-11/payroll/dsl/payroll.scala
…
protected[dsl] class DeductionsBuilder(gpb: GrossPayBuilder) {
val employee = gpb.employee
var paycheck: Paycheck = new Paycheck(gpb.gross, gpb.gross, 0)
def currency = this
def minus_deductions_for(deductionRules: DeductionsBuilder => Unit) = {
deductionRules(this)
paycheck
}
def addDeductions(amount: Money) = paycheck = paycheck plusDeductions amount
def addDeductionsPercentageOfGross(percentage: Double) = {
val amount = paycheck.gross * (percentage/100.)
addDeductions(amount)
}
}
…
DeductionsBuilder
salva l’impiegato dell’istanza di GrossPayBuilder
ricevuta, che non lo memorizza come campo, e inizializza anche il valore di paycheck usando lo stipendio lordo calcolato.
Notate che il metodo currency
restituisce semplicemente this
. Non abbiamo bisogno di fare nulla con la valuta effettiva quando questo metodo viene invocato. Invece, esso viene usato per supportare un idioma di progettazione che discuteremo più avanti.
minus_deductions_for
svolge il lavoro importante: invoca il letterale funzione con le singole regole e poi restituisce l’istanza di Paycheck
completata, che in definitiva è quanto viene restituito da rules.apply
.
I nostri due metodi rimanenti sono usati per calcolare le singole ritenute. Vengono invocati da DeductionsBuilderDeductionHelper
, come mostrato nel codice seguente.
// esempi/cap-11/payroll/dsl/payroll.scala
…
class DeductionCalculator {
def is(builder: DeductionsBuilder) = apply(builder)
def are(builder: DeductionsBuilder) = apply(builder)
def apply(builder: DeductionsBuilder) = {}
}
object federalIncomeTax extends DeductionCalculator
object stateIncomeTax extends DeductionCalculator
object insurancePremiums extends DeductionCalculator
object retirementFundContributions extends DeductionCalculator
protected[dsl] class DeductionsBuilderDeductionHelper(val factor: Double) {
def in (builder: DeductionsBuilder) = {
builder addDeductions Money(factor)
builder
}
def percent_of (builder: DeductionsBuilder) = {
builder addDeductionsPercentageOfGross factor
builder
}
}
Ora vediamo che federalIncomeTax
&c. sono oggetti singleton. Notate i metodi “sinonimi” is
e are
: abbiamo usato is
per gli oggetti con un nome singolare, come federalIncomeTax
, e are
per gli oggetti con un nome plurale, come insurancePremiums
. In effetti, dato che entrambi i metodi delegano le proprie funzioni ad apply
, le parole is
e are
sono effettivamente “superflue” e l’utente potrebbe ometterle. In altre parole, le due righe seguenti sono equivalenti nel nostro DSL.
federalIncomeTax is (25. percent_of gross)
federalIncomeTax (25. percent_of gross)
Il metodo apply
prende DeductionsBuilder
e non ci fa nulla! In effetti, nel momento in cui apply
viene invocato, le ritenute sono già state calcolate e considerate nella busta paga. Questo implica che le espressioni come federalIncomeTax is
sono effettivamente zucchero sintattico (almeno per il modo in cui questo DSL è stato implementato), una forma elaborata di commento che se non altro ha il vantaggio di controllare il tipo dei vari “generi” di ritenute consentite. Naturalmente, con l’evolversi dell’implementazione, queste istanze potrebbero svolgere un lavoro reale.
Per capire perché DeductionCalculator.apply
è vuoto, consideriamo DeductionsBuilderDeductionHelper
. Ricordatevi che l’oggetto rules
è dotato di un metodo di conversione che trasforma un Double
in un DeductionsBuilderDeductionHelper
. Una volta che abbiamo un’istanza di questo tipo possiamo invocare il metodo in
o il metodo percent_of
. Ogni riga nel letterale funzione delle ritenute sfrutta questa istanza.
Per esempio, (25. percent_of gross)
è pressappoco equivalente ai passi seguenti.
rules.double2DeductionsBuilderDeductionHelper(25.)
per creare una nuova istanza DeductionsBuilderDeductionHelper(25.)
.percent_of(gross)
sulla nuova istanza, dove gross è di tipo DeductionsBuilder
.gross.addDeductionsPercentageOfGross(factor)
In altre parole, abbiamo usato DeductionsBuilderDeductionHelper
per convertire un’espressione della forma Double metodo DeductionsBuilder
in un’espressione della forma DeductionsBuilder metodo2 Double
. DeductionsBuilder
accumula tutte le ritenute nella busta paga che stiamo assemblando.
L’espressione 500. in gross.currency
funziona quasi allo stesso modo. DeductionsBuilder.currency
è in effetti un’altra parola superflua; restituisce semplicemente this
, ma fornisce un idioma leggibile per il DSL. Il metodo in
converte semplicemente l’istanza di Double
in un’istanza di Money
e la passa a DeductionsBuilder.addDeductions
.
Quindi DeductionCalculator.apply
non fa nulla, perché nel momento in cui apply
viene invocato tutto il lavoro è già stato fatto.
In definitiva, è meglio l’implementazione originale sotto forma di API o l’implementazione sotto forma di DSL? L’implementazione del DSL è complessa. Come ogni linguaggio, verificarne la robustezza può essere complicato. Gli utenti proveranno molte combinazioni di espressioni e probabilmente non capiranno i messaggi di errore del compilatore che si riferiscono ai meccanismi interni nascosti dietro il DSL.
Progettare un DSL di qualità è difficile. Con una API, potete seguire le convenzioni della libreria Scala per i tipi, i nomi dei metodi, &c. Tuttavia, con un DSL, state provando a imitare il linguaggio di un nuovo dominio: farlo correttamente non è facile.
Ne vale la pena, però. Un DSL ben progettato minimizza lo sforzo di tradurre i requisiti in codice, migliorando così le comunicazioni sui requisiti con i soggetti interessati. I DSL agevolano anche il cambiamento rapido delle funzionalità e nascondono i dettagli di implementazione che potrebbero creare confusione. Come sempre, dovreste effettuare un’analisi costi/benefici quando decidete se usare o meno un DSL.
Supponendo che abbiate preso la decisione di “procedere”, un problema comune nella progettazione di un DSL è il problema della conclusione [Ford2009]. Come fate a sapere quando l’assemblaggio dello stato di un’istanza si è concluso, e che tale istanza è pronta per essere usata?
Noi abbiamo risolto il problema in due modi. Per prima cosa, abbiamo annidato i passi di computazione in un letterale funzione. Nel momento in cui rules(employee)
viene chiamato, la costruzione della busta paga è completa. In più, tutti i passi vengono valutati in maniera “avida”: non è necessario inserire tutte le regole e poi eseguirle alla fine. L’unico requisito di ordinamento era la necessità di calcolare lo stipendio lordo come prima cosa, dato che è il valore su cui si basano le ritenute. Abbiamo imposto l’ordine di invocazione corretto usando istanze di tipi differenti.
Ci sono casi in cui non potete valutare i passi di assemblaggio in maniera avida. Per esempio, un DSL che costruisce una query SQL non può eseguire una query dopo ogni singolo passo del processo di costruzione. In questo caso, la valutazione deve attendere fino a quando la query non è costruita completamente.
Al contrario, se i passi di computazione del vostro DSL sono privi di stato, l’invocazione di metodi concatenati funziona alla perfezione; in questo caso, non è importante sapere quando l’invocazione dei metodi concatenati si conclude. Se concatenate metodi che assemblano uno stato, dovrete aggiungere una qualche sorta di metodo conclusivo e contare sul fatto che gli utenti lo usino sempre alla fine.
Quando si scrive un riconoscitore per un DSL esterno, è possibile usare uno strumento di generazione di riconoscitori come Antlr [Antlr]. Tuttavia, Scala include una potente libreria di combinazione di riconoscitori che può essere usata per riconoscere la maggior parte dei DSL esterni dotati di una grammatica libera dal contesto. Una caratteristica attraente di questa libreria è il modo in cui definisce un DSL interno che rende le definizioni dei riconoscitori molto simili alle comuni notazioni grammaticali come EBNF (Extended Backus-Naur Form, forma di Backus-Naur estesa) [BNF].
Gli operatori di riconoscimento (anche detti combinatori) sono blocchi di costruzione per riconoscitori. I riconoscitori che gestiscono specifici tipi di ingresso, come per esempio numeri in virgola mobile, numeri interi, &c., possono essere combinati insieme per formare altri riconoscitori per espressioni più grandi. Un framework di combinazione rende più facile comporre i riconoscitori per gestire le sequenze di termini, i casi alternativi, la ripetizione, i termini opzionali, &c.
Impareremo di più sulle tecniche di riconoscimento e la relativa terminologia man mano che procediamo. Una esposizione completa delle tecniche di riconoscimento esula dall’ambito di questo libro, ma i nostri esempi dovrebbero esservi utili come punto di partenza. In [Spiewak2009b], [Ghosh2008a] e [Odersky2008] potete trovare ulteriori esempi di riconoscitori scritti usando la libreria di operatori di riconoscimento di Scala.
Per illustrare la combinazione di riconoscitori, riutilizzeremo il caso appena discusso per i DSL interni alterandone leggermente la grammatica, dato che il nostro DSL esterno non deve rispettare la sintassi di Scala. Altre modifiche semplificheranno la costruzione del riconoscitore. Ecco un esempio d’uso scritto usando il DSL esterno.
paycheck for employee "Buck Trends" is salary for 2 weeks minus deductions for { federal income tax is 25. percent of gross, state income tax is 5. percent of gross, insurance premiums are 500. in gross currency, retirement fund contributions are 10. percent of gross }
Confrontate questo esempio con il DSL interno che abbiamo definito nella sezione Un DSL interno per il libro paga appena vista.
… = rules { employee =>
employee salary_for 2.weeks minus_deductions_for { gross =>
federalIncomeTax is (25. percent_of gross)
stateIncomeTax is (5. percent_of gross)
insurancePremiums are (500. in gross.currency)
retirementFundContributions are (10. percent_of gross)
}
}
…
Nel nostro nuovo DSL, inseriamo uno specifico impiegato nello script. Non pretendiamo che un utente effettui una copia di questo script per ogni impiegato: una naturale estensione che non perseguiremo permetterebbe all’utente di effettuare un ciclo su tutti gli impiegati stipendiati contenuti in un database, per esempio.
Alcune differenze sono “gratuite”; avremmo potuto usare la stessa sintassi vista in precedenza. Questi cambiamenti includono la rimozione del trattino basso tra le parole in alcune espressioni e l’espansione delle parole in camel-case in parole separate da spazi; cioè, abbiamo trasformato alcune parole singole in espressioni con più parole. Grazie a queste modifiche l’implementazione basata sugli operatori di riconoscimento risulterà più semplice, ma le stesse espressioni con più parole avrebbero reso molto più complessa l’implementazione del DSL interno.
Le “variabili locali” come employee e gross non servono più. Queste parole compaiono ancora nel DSL, ma il nostro riconoscitore terrà traccia internamente delle istanze corrispondenti.
Le rimanenti modifiche riguardano la punteggiatura. È ancora conveniente racchiudere la lista di ritenute tra parentesi graffe, ma ora usiamo una virgola per separare le singole ritenute, poiché questo faciliterà il lavoro del riconoscitore. Possiamo anche tralasciare le parentesi che abbiamo usato in precedenza.
Per constatare quanto il DSL interno della libreria di operatori di riconoscimento di Scala somigli da vicino alla grammatica libera da contesto, partiamo dalla grammatica vera e propria, scritta in una variazione della sintassi EBNF. Per chiarezza, ometteremo le virgole che separano le sequenze.
paycheck = empl gross deduct; empl = "paycheck" "for" "employee" employeeName; gross = "is" "salary" "for" duration; deduct = "minus" "deductions" "for" "{" deductItems "}"; employeeName = "\"" name " " name "\""; name = … duration = decimalNumber weeksDays; weeksDays = "week" | "weeks" | "day" | "days"; deductItems = ε | deductItem { "," deductItem }; deductItem = deductKind deductAmount; deductKind = tax | insurance | retirement; tax = fedState "income" "tax"; fedState = "federal" | "state"; insurance = "insurance" "premiums"; retirement = "retirement" "fund" "contributions"; deductAmount = percentage | amount; percentage = toBe doubleNumber "percent" "of" "gross"; amount = toBe doubleNumber "in" "gross" "currency"; toBe = "is" | "are"; decimalNumber = … doubleNumber = …
È possibile notare che la maggior parte dei simboli terminali (le stringhe letterali paycheck
, for
, employee
, i caratteri { e }, &c.) saranno parole “superflue”, come definite nella sezione precedente, che ignoreremo dopo il riconoscimento. La lettera greca ε viene usata per indicare una produzione vuota per deductItems
, anche se le ritenute saranno raramente assenti!
Non abbiamo precisato i dettagli per i numeri decimali, i numeri in virgola mobile e le lettere consentite nel nome degli impiegati, elidendo semplicemente quelle definizioni. Gestiremo questi dettagli più avanti.
Ogni riga nella grammatica definisce una regola di produzione. La fine di una definizione viene segnalata con un punto e virgola. Sul lato sinistro del segno di uguale compare un simbolo non terminale. Il lato destro è composto da simboli terminali (per esempio le stringhe letterali e i caratteri appena menzionati) che non richiedono un’analisi ulteriore, da altri simboli non terminali (tra cui è possibile includere un riferimento ricorsivo al simbolo non terminale presente sul lato sinistro) e da operatori che esprimono relazioni tra le entità. Si noti che le forme della grammatica possono essere decomoposte in maniera gerarchica; pur non essendo un grafo diretto aciclico, generalmente parlando, una grammatica di questo tipo può contenere cicli.
La nostra è una grammatica libera da contesto perché ogni regola di produzione ha un singolo simbolo non terminale sul lato sinistro del segno di uguale, cioè non è necessaria alcuna informazione di contesto aggiuntiva per specificare l’applicabilità e il significato della produzione.
Le regole di produzione come toBe = "is" | "are"
indicano una corrispondenza con la produzione is
(un simbolo terminale, in questo caso) oppure con la produzione are
. Questo è un esempio di composizione alternativa.
Quando le produzioni sul lato destro di un’altra produzione sono separate da spazi bianchi, come per esempio prod1 prod2
, tutte le produzioni devono apparire sequenzialmente per ottenere una corrispondenza. (La maggior parte dei formati EBNF richiede una virgola per separare queste entità.) Quindi, queste espressioni sono simili a “congiunzioni”, ma la composizione sequenziale è talmente comune che non viene usato nessun operatore &
come analogo di |
per la composizione alternativa.
La regola di produzione che contiene "{" deductItem { "," deductItem } "}"
mostra come specificare ripetizioni opzionali (zero o più). Questa espressione corrisponde a un carattere letterale {
, seguito da deductItem
(un’altra produzione), seguita da zero o più espressioni che consistono in una virgola letterale e in un’altra produzione deductItem
, terminando infine con un carattere letterale }
. A volte si usa un asterisco per indicare elementi ripetuti zero o più volte, come per esempio in prod *
. Per gli elementi ripetuti almeno una volta, si può usare prod +
.
Infine, se la nostra grammatica contenesse entità opzionali, potremmo racchiuderle tra parentesi quadre. Ci sono altri tipi di possibili operatori di composizione (supportati dalla libreria Scala), alcuni dei quali verranno discussi più avanti. Si veda la voce Parsers
in [ScalaAPI2008] per maggiori dettagli.
Ecco il riconoscitore scritto usando gli operatori di riconoscimento di Scala. In questa sezione non faremo niente di speciale per calcolare effettivamente la busta paga di un impiegato, quindi aggiungeremo V1
al nome della classe.
// esempi/cap-11/payroll/pcdsl/payroll-parser-comb-v1.scala
package payroll.pcdsl
import scala.util.parsing.combinator._
import org.specs._
import payroll._
import payroll.Type2Money._
class PayrollParserCombinatorsV1 extends JavaTokenParsers {
def paycheck = empl ~ gross ~ deduct
def empl = "paycheck" ~> "for" ~> "employee" ~> employeeName
def gross = "is" ~> "salary" ~> "for" ~> duration
def deduct = "minus" ~> "deductions" ~> "for" ~> "{" ~> deductItems <~ "}"
// stringLiteral viene fornito da JavaTokenParsers
def employeeName = stringLiteral
// decimalNumber viene fornito da JavaTokenParsers
def duration = decimalNumber ~ weeksDays
def weeksDays = "weeks" | "week" | "days" | "day"
def deductItems = repsep(deductItem, "," )
def deductItem = deductKind ~> deductAmount
def deductKind = tax | insurance | retirement
def tax = fedState <~ "income" <~ "tax"
def fedState = "federal" | "state"
def insurance = "insurance" ~> "premiums"
def retirement = "retirement" ~> "fund" ~> "contributions"
def deductAmount = percentage | amount
def percentage = toBe ~> doubleNumber <~ "percent" <~ "of" <~ "gross"
def amount = toBe ~> doubleNumber <~ "in" <~ "gross" <~ "currency"
def toBe = "is" | "are"
// floatingPointNumber viene fornito da JavaTokenParsers
def doubleNumber = floatingPointNumber
}
Il corpo di PayrollParserCombinatorsV1
sembra molto simile alla grammatica che abbiamo definito per il DSL. Ogni regola di produzione diventa un metodo. Il punto e virgola terminale viene scartato, ma dato che la produzione è un metodo la sua presenza rispetterebbe comunque la sintassi di Scala.
Al posto degli spazi bianchi tra le produzioni sul lato destro, ora usiamo un operatore di combinazione come ∼
, ∼>
, oppure <∼
. Il combinatore per la composizione sequenziale è ∼
, usato quando si desidera conservare per ulteriori elaborazioni i risultati prodotti da entrambe le produzioni sui lati sinistro e destro di ∼
. Per esempio, quando elaboriamo la produzione paycheck
, vogliamo mantenere tutti e tre i risultati di empl
, gross
e deduct
, quindi usiamo due operatori ∼
.
def paycheck = empl ~ gross ~ deduct
Si usa un altro combinatore di composizione sequenziale ∼>
quando non è più necessario conservare il risultato della produzione sulla sinistra. Per esempio, quando elaboriamo la produzione empl
, vogliamo tenere solo il risultato del riconoscimento dell’ultima produzione, employeeName
.
def empl = "paycheck" ~> "for" ~> "employee" ~> employeeName
Similmente, si usa <∼
quando non è più necessario conservare il risultato della produzione sulla destra. Per esempio, quando elaboriamo la produzione tax
, vogliamo tenere solo il risultato della prima produzione, fedState
.
def tax = fedState <~ "income" <~ "tax"
L’uso pesante che facciamo del combinatore sequenziale <∼
nelle varie produzioni relative alle ritenute indica che non stiamo tenendo traccia della natura di ogni ritenuta, ma solo della quantità. Un’applicazione reale per il calcolo delle buste paga stamperebbe questa informazione, naturalmente, ma qui noi miriamo alla semplicità. Come esercizio, riflettete su come modificare PayrollParserCombinatorsV1
e le sue versioni successive, che vedremo più avanti, per tenere traccia di questa informazione: conservereste necessariamente le stringhe riconosciute oppure impieghereste una strategia diversa?
La “disgiunzione” viene espressa con il metodo |
, proprio come nella grammatica.
def weeksDays = "weeks" | "week" | "days" | "day"
Il metodo rep
può essere usato per indicare zero o più ripetizioni. In realtà usiamo un metodo simile, repsep
, che ci permette di specificare un separatore, nel nostro caso una virgola.
def deduct = ... ~> "{" ~> repsep(deductItem, "," ) <~ "}"
Notate che deduct
combina diverse caratteristiche tra quelle che abbiamo appena descritto.
Come per le ripetizioni, esiste un metodo opt
per i termini opzionali, che però non stiamo usando.
PayrollParserCombinatorsV1
estende JavaTokenParsers
, che estende RegexParsers
, che estende il tratto radice Parsers
per i riconoscitori. È risaputo che ogni tentativo di riconoscere grammatiche non banali usando solo le espressioni regolari tende a fallire piuttosto velocemente. Tuttavia, le espressioni regolari possono essere molto efficaci per riconoscere i singoli termini all’interno di un framework di analisi sintattica. Nel nostro esempio, sfruttiamo le produzioni di JavaTokenParsers
per riconoscere le stringhe tra virgolette (usate per il nome dell’impiegato), i letterali decimali e i letterali in virgola mobile.
Facciamo una prova! Ecco una specifica che esercita il riconoscitore in due casi, con e senza le ritenute.
// esempi/cap-11/payroll/pcdsl/payroll-parser-comb-v1-spec.scala
package payroll.pcdsl
import scala.util.parsing.combinator._
import org.specs._
import payroll._
import payroll.Type2Money._
object PayrollParserCombinatorsV1Spec
extends Specification("PayrollParserCombinatorsV1") {
"PayrollParserCombinatorsV1" should {
"riconoscere le regole quando non ci sono ritenute" in {
val input = """paycheck for employee "Buck Trends"
is salary for 2 weeks minus deductions for {}"""
val p = new PayrollParserCombinatorsV1
p.parseAll(p.paycheck, input) match {
case p.Success(r, _) => r.toString mustEqual
"""(("Buck Trends"~(2~weeks))~List())"""
case x => fail(x.toString)
}
}
"calcolare il lordo, il netto e le ritenute per il periodo di retribuzione" in {
val input =
"""paycheck for employee "Buck Trends"
is salary for 2 weeks minus deductions for {
federal income tax is 25. percent of gross,
state income tax is 5. percent of gross,
insurance premiums are 500. in gross currency,
retirement fund contributions are 10. percent of gross
}"""
val p = new PayrollParserCombinatorsV1
p.parseAll(p.paycheck, input) match {
case p.Success(r, _) => r.toString mustEqual
"""(("Buck Trends"~(2~weeks))~List(25., 5., 500., 10.))"""
case x => fail(x.toString)
}
}
}
}
Questa parte della specifica mostra come istanziare e usare il riconoscitore.
val p = new PayrollParserCombinatorsV1
p.parseAll(p.paycheck, input) match {
case p.Success(r, _) => r.toString mustEqual "…"
case x => fail(x.toString)
}
Il metodo parseAll
è definito in una classe genitore. Invochiamo il metodo di produzione a livello radice, paycheck
, poi passiamo a parseAll
il suo valore di ritorno come primo argomento e la stringa da riconoscere come secondo argomento.
Se il processo di riconoscimento ha successo, il suo risultato viene restituito come un’istanza di tipo p.Success[+T]
, una classe case
dichiarata nel tratto Parsers
. Il prefisso p.
indica che p.Success
è un tipo dipendente dal percorso, di cui parleremo nella sezione Tipi dipendenti dal percorso del capitolo 12. Per ora vi basti sapere che, anche se Success
è definito nel tratto Parsers
, il tipo reale dell’istanza dipende dall’istanza di PayrollParserCombinatorsV1
che abbiamo creato. In altre parole, se avessimo un altro riconoscitore p2
di tipo MyOtherParser
, allora p2.Success[String]
sarebbe differente da p.Success[String]
e non sarebbe possibile sostituire l’uno con l’altro.
L’istanza di Success
contiene due campi: il risultato del riconoscimento, che è un’istanza di tipo T
(assegnata a r nella clausola case
), e la parte rimanente della stringa in ingresso da riconoscere, che sarà vuota dopo un riconoscimento avvenuto con successo (a quel punto abbiamo riconosciuto l’intera stringa). Questa stringa viene assegnata a _
.
Se il riconoscimento fallisce, l’istanza restituita è di tipo p.Failure
oppure p.Error
, che il nostro esempio gestisce con una clausola case
generica. Entrambi i tipi derivano da p.NoSuccess
, che contiene campi per un messaggio di errore e per l’ingresso non consumato al momento del fallimento. Un risultato di tipo p.Failure
in un riconoscitore innescherà il backtracking in modo che il framework di riconoscimento provi un riconoscitore differente, se è possibile. Un risultato di tipo p.Error
non innesca il backtracking e viene usato per segnalare problemi più gravi.
Per completezza, sia p.Success
sia p.NoSuccess
derivano da p.ParseResult
.
Rimangono due grandi domande ancora senza risposta: cosa restituiscono effettivamente i metodi delle produzioni, e qual è il tipo dell’istanza restituita come risultato in p.Success
?
I metodi di produzione restituiscono riconoscitori. Nel nostro esempio, la maggior parte dei metodi restituisce p.Parser[String]
(ancora una volta, un tipo dipendente dal percorso). Tuttavia, dato che il metodo deduct
gestisce la ripetizione (invocando il metodo repsep
), in effetti esso restituisce p.Parser[List[String]]
. Quando questo riconoscitore viene usato restituisce List[String]
, che contiene una stringa per ogni corrispondenza nella ripetizione.
Quindi, l’invocazione di p.parseAll(p.paycheck, input)
che abbiamo visto riconosce la stringa input usando il riconoscitore restituito da p.paycheck
. Questo ci porta alla seconda domanda: qual è il risultato di un riconoscimento riuscito?
Per vedere ciò che viene restituito, compilate il file che contiene PayrollParserCombinatorsV1
indicato all’inizio di questa sezione, e invocate l’interprete scala con l’opzione -cp
per includere la directory dove sono stati creati i file di classe (questa directory si chiamerà build se avete usato il processo di assemblaggio definito nell’archivio degli esempi di codice).
Una volta nell’interprete, digitate le seguenti espressioni dopo il prompt scala>
. (Potete anche trovare queste espressioni nel file payroll-parser-comb-script.scala incluso nell’archivio degli esempi di codice.)
scala> import scala.util.parsing.combinator._ import scala.util.parsing.combinator._ scala> import payroll.pcdsl._ import payroll.pcdsl._ scala> val p = new PayrollParserCombinatorsV1 p: payroll.pcdsl.PayrollParserCombinatorsV1 = payroll.pcdsl.PayrollParserCombinatorsV1@79e84310 scala> p.empl res0: p.Parser[String] = Parser (~>) scala> p.weeksDays res2: p.Parser[String] = Parser (|) scala> p.doubleNumber res3: p.Parser[String] = Parser () scala> p.deduct res1: p.Parser[List[String]] = Parser (<~) scala> p.paycheck res4: p.Parser[p.~[p.~[String,p.~[String,String]],List[String]]] = Parser (~) scala> p.parseAll(p.weeksDays, "weeks") res5: p.ParseResult[String] = [1.6] parsed: weeks scala> val input = """paycheck for employee "Buck Trends" | is salary for 2 weeks minus deductions for {}""" input: java.lang.String = paycheck for employee "Buck Trends" is salary for 2 weeks minus deductions for {} scala> p.parseAll(p.paycheck, input) res6: p.ParseResult[p.~[p.~[String,p.~[String,String]],List[String]]] = [2.53] parsed: (("Buck Trends"~(2~weeks))~List()) scala>
Importiamo i tipi necessari e creiamo un’istanza di PayrollParserCombinatorsV1
. Poi invochiamo diversi metodi di produzione per vedere che tipo di Parser
restituiscono. I primi tre, cioè empl
, weekDays
e doubleNumber
, restituiscono p.Parser[String]
.
Notate ciò che viene scritto sul lato destro dell’uscita per i primi tre riconoscitori: empl
, weeksDays
e doubleNumber
mostrano rispettivamente Parser (∼>)
, Parser (|)
e Parser ()
. I riconoscitori restituiti riflettono le definizioni delle regole di produzione, dove empl
termina con un combinatore della forma prod1 ∼> prod2
e weeksDays
termina con un combinatore della forma prod1 | prod2
, mentre doubleNumber
restituisce un riconoscitore per una singola produzione.
Dato che deduct
consiste di operatori che gestiscono la ripetizione, il riconoscitore restituito da deduct
è di tipo p.Parser[List[String]]
, come avevamo detto in precedenza. Il lato destro dell’uscita è Parser (<∼)
, perché la definizione di deduct
termina con prod1 <∼ prod2
.
Le cose si fanno più interessanti quando esaminiamo la produzione a livello radice, paycheck
. Cosa dovrebbe significare p.Parser[p.∼[p.∼[String,p.∼[String,String]],List[String]]] = Parser (∼)
? Dunque, il lato destro dovrebbe essere facile da capire ora; la definizione di paycheck
termina con prod1 ∼ prod2
. Che cosa rappresenta il parametro di tipo di p.Parser
sul lato sinistro del segno di uguale?
Il tratto Parsers
definisce anche una classe case
chiamata ∼
che rappresenta una coppia di regole sequenziali.
case class ~[+a, +b](_1: a, _2: b) {
override def toString = "(" + _1 + "~" + _2 + ")"
}
Nel nostro esempio, l’effettivo tipo dipendente dal percorso è p.∼[+a,+b]
. Quindi, il parametro di tipo T
in p.Parser[T]
è p.∼[p.∼[String,p.∼[String,String]],List[String]]
, che rappresenta un albero gerarchico di tipi.
Suddividiamolo in parti e leggiamolo dall’interno verso l’esterno. Notate che ci sono tre p.∼
. Cominceremo con il tipo più interno, p.∼[String,String]
, e ne tracceremo una corrispondenza con la dichiarazione di tipo che abbiamo visto in uscita nella sessione di scala, "Buck Trends"∼(2∼weeks∼List())
.
Il tipo p.∼[String,String]
corrisponde al riconoscitore che gestisce espressioni come 2 weeks
. Quindi, l’istanza creata quando analizziamo la nostra stringa di esempio è l’istanza p.∼("2", "weeks")
. L’invocazione del metodo p.∼.toString
produce l’uscita (2~weeks)
.
Muovendoci di un livello verso l’esterno, abbiamo p.∼[String,p.∼[String,String]]
. Questa combinazione riconosce paycheck for employee "Buck Trends" is salary for 2 weeks
. Ricordatevi che scartiamo paycheck for employee
e is salary for
, tenendo solo le parti "Buck Trends"
e 2 weeks
. Quindi l’istanza creata è p.∼("Buck Trends", p.∼("2", "weeks"))
. Una nuova invocazione di toString
produce come risultato la stringa ("Buck Trends"∼(2∼weeks))
.
Infine, al livello più esterno, abbiamo p.∼[p.∼[String,p.∼[String,String]],List[String]]
, di cui abbiamo già esaminato tutto tranne l’ultimo List[String]
, che proviene dalla produzione deduct
.
def deduct = "minus" ~> "deductions" ~> "for" ~>
"{" ~> repsep(deductItem, "," ) <~ "}"
Scartiamo tutto tranne la lista di zero o più deductItem
. Nel nostro esempio non ce ne sono, quindi otteniamo una lista vuota per la quale toString
restituisce List()
. Di conseguenza, l’invocazione di p.∼.toString
sul nostro tipo più esterno, quello che parametrizza p.Parser
, restituisce la stringa "Buck Trends"∼(2∼weeks∼List())
. Abbiamo finito!
Be’, non proprio. Non abbiamo ancora calcolato un vero stipendio per la retribuzione del vecchio Buck. Ora completeremo la nostra implementazione.
Durante il riconoscimento del DSL vogliamo cercare l’impiegato per nome, recuperare il suo stipendio lordo per il periodo di retribuzione specificato e poi calcolare le ritenute man mano che procediamo. Quando il riconoscitore restituito da paycheck
termina le proprie operazioni, desideriamo restituire un’istanza di Pair
che contenga l’istanza di Employee
e l’istanza di Paycheck
completa.
Riutilizzeremo classi “di dominio” come Employee
, Money
, Paycheck
, &c. dalle sezioni precedenti di questo capitolo. Per effettuare i calcoli su richiesta, creeremo una seconda iterazione di PayrollParserCombinatorsV1
che chiameremo PayrollParserCombinators
. Modificheremo i riconoscitori restituiti da alcuni metodi di produzione per restituire nuovi tipi di riconoscitore. Svolgeremo anche alcune attività di ordinaria amministrazione come memorizzare i dati di contesto durante l’esecuzione, nel caso ce ne sia bisogno. La nostra implementazione non sarà thread-safe; vorrete assicurarvi che un solo thread possa accedere a un dato PayrollParserCombinators
. Potremmo renderla più robusta, ma farlo non rientra tra gli scopi di questo esercizio.
Ecco la versione finale del nostro PayrollParserCombinators
.
// esempi/cap-11/payroll/pcdsl/payroll-parser-comb.scala
package payroll.pcdsl
import scala.util.parsing.combinator._
import org.specs._
import payroll._
import payroll.Type2Money._
class UnknownEmployee(name: Name) extends RuntimeException(name.toString)
class PayrollParserCombinators(val employees: Map[Name, Employee])
extends JavaTokenParsers {
var currentEmployee: Employee = null
var grossAmount: Money = Money(0)
/** @return Parser[(Employee, Paycheck)] */
def paycheck = empl ~ gross ~ deduct ^^ {
case e ~ g ~ d => (e, Paycheck(g, g - d, d))
}
/** @return Parser[Employee] */
def empl = "paycheck" ~> "for" ~> "employee" ~> employeeName ^^ { name =>
val names = name.substring(1, name.length - 1).split(" ") // rimuove ""
val n = Name(names(0), names(1));
if (!employees.contains(n))
throw new UnknownEmployee(n)
currentEmployee = employees(n)
currentEmployee
}
/** @return Parser[Money] */
def gross = "is" ~> "salary" ~> "for" ~> duration ^^ { dur =>
grossAmount = salaryForDays(dur)
grossAmount
}
def deduct = "minus" ~> "deductions" ~> "for" ~> "{" ~> deductItems <~ "}"
/**
* "stringLiteral" fornito da JavaTokenParsers
* @return Parser[String]
*/
def employeeName = stringLiteral
/**
* "decimalNumber" fornito da JavaTokenParsers
* @return Parser[Int]
*/
def duration = decimalNumber ~ weeksDays ^^ {
case n ~ factor => n.toInt * factor
}
def weeksDays = weeks | days
def weeks = "weeks?".r ^^ { _ => 5 }
def days = "days?".r ^^ { _ => 1 }
/** @return Parser[Money] */
def deductItems = repsep(deductItem, ",") ^^ { items =>
items.foldLeft(Money(0)) {_ + _}
}
/** @return Parser[Money] */
def deductItem = deductKind ~> deductAmount
def deductKind = tax | insurance | retirement
def tax = fedState <~ "income" <~ "tax"
def fedState = "federal" | "state"
def insurance = "insurance" ~> "premiums"
def retirement = "retirement" ~> "fund" ~> "contributions"
def deductAmount = percentage | amount
/** @return Parser[Money] */
def percentage = toBe ~> doubleNumber <~ "percent" <~ "of" <~ "gross" ^^ {
percentage => grossAmount * (percentage / 100.)
}
def amount = toBe ~> doubleNumber <~ "in" <~ "gross" <~ "currency" ^^ {
Money(_)
}
def toBe = "is" | "are"
def doubleNumber = floatingPointNumber ^^ { _.toDouble }
// Metodo di supporto. Presume 260 (52 * 5) giorni di lavoro retribuiti all'anno
def salaryForDays(days: Int) =
(currentEmployee.annualGrossSalary / 260.0) * days
}
Per semplicità useremo una mappa di impiegati “noti”, in cui le chiavi sono istanze di Name
, che abbiamo salvato in un campo di PayrollParserCombinators
. Una implementazione reale userebbe probabilmente un database di qualche tipo.
Ci sono altri due campi: currentEmployee
, che memorizza l’impiegato su cui stiamo lavorando, e grossAmount
, che memorizza lo stipendio lordo dell’impiegato per il periodo di retribuzione. Entrambi i campi hanno un odore di cattiva progettazione. Sono mutabili; e sono impostati solo una volta durante ogni riconoscimento, ma non nel momento in cui vengono dichiarati, bensì solo quando analizziamo l’ingresso che ci permette di calcolarli. Potreste avere anche notato che, se la stessa istanza di PayrollParserCombinators
viene usata più di una volta, quei campi non vengono riportati ai loro valori predefiniti. Senza dubbio sarebbe possibile usare il DSL per scrivere programmi maliziosi che sfruttano questo difetto.
Queste debolezze non sono inerenti alla combinazione di riconoscitori, ma riflettono le semplificazioni che abbiamo adottato per i nostri scopi. Come esercizio, potreste provare a migliorare l’implementazione eliminandole.
Abbiamo aggiunto annotazioni @return
nello stile Javadoc alla maggior parte delle produzioni per chiarire che cosa restituiscono ora. In alcuni casi, le produzioni sono rimaste inalterate, in quanto le istanze originali dei riconoscitori vanno bene così come sono. La maggior parte delle modifiche riflette il nostro desiderio di calcolare la busta paga man mano che procediamo.
Considerate la nuova produzione paycheck
.
/** @return Parser[(Employee, Paycheck)] */
def paycheck = empl ~ gross ~ deduct ^^ {
case e ~ g ~ d => (e, Paycheck(g, g - d, d))
}
Ora restituiamo un’istanza di Pair
con l’impiegato e la busta paga calcolata. La combinazione empl ∼ gross ∼ deduct
restituirebbe ancora Parser[String]
(trascureremo il prefisso dipendente dal percorso per ora), ma abbiamo aggiunto un nuovo operatore ^^
, usato seguendo lo schema prod1 ^^ func1
, dove func1
è una funzione. Se prod1
ha successo, allora viene restituito il risultato dell’applicazione di func1
al risultato di prod1
; cioè, restituiamo func1(prod1)
.
In paycheck
, diamo all’operatore un letterale funzione che esegue il pattern matching per estrarre i tre risultati da empl
, gross
e deduct
, rispettivamente. Creiamo una tupla di due elementi (un’istanza di Pair
) che contiene l’istanza e
di Employee
e l’istanza di Paycheck
calcolata a partire dallo stipendio lordo per il periodo di retribuzione (in g
) e dalla somma di tutte le ritenute (in d
).
È importante mettere in chiaro che la funzione anonima passata come argomento a ^^
restituisce una tupla (Employee, Paycheck)
, ma che il metodo di produzione paycheck
restiuisce Parser[(Employee, Paycheck)]
. In effetti, questo è stato vero fin dall’inizio, quando la nostra prima versione lavorava su istanze di String
, e rimarrà vero per tutte le regole di produzione in PayrollParserCombinators
.
La produzione empl
presume che il nome e il cognome dell’impiegato siano dati. (Ovviamente, questo non sarebbe adeguato per un’applicazione reale.)
/** @return Parser[Employee] */
def empl = "paycheck" ~> "for" ~> "employee" ~> employeeName ^^ { name =>
val names = name.substring(1, name.length - 1).split(" ") // rimuove ""
val n = Name(names(0), names(1));
if (!employees.contains(n))
throw new UnknownEmployee(n)
currentEmployee = employees(n)
currentEmployee
}
Per costruire il nome le virgolette incluse devono essere rimosse, perciò il metodo comincia estraendo la sottostringa che elimina il primo e l’ultimo carattere. Il nome viene usato per cercare l’istanza di Employee
nella mappa, salvando il valore corrispondente nel campo currentEmployee
. In generale, la gestione degli errori in PayrollParserCombinators
non è “elegante”. Tuttavia, il metodo empl
gestisce il caso in cui non venga trovato alcun impiegato con il nome specificato, lanciando all’occorrenza una eccezione di tipo UnknownEmployee
.
Il resto delle produzioni funziona in modo simile. A volte, un riconoscitore converte una stringa in ingresso in un numero Int
(per esempio, duration
) o in un’istanza di Money
(per esempio, gross
). Un caso interessante è quello di deduct
: il riconoscitore ripiega la lista di ritenute in una singola ritenuta usando l’addizione. Il metodo foldLeft
prende due liste di argomenti: la prima contiene un singolo argomento che specifica il valore iniziale, in questo caso l’istanza di Money
che rappresenta zero; la seconda lista di argomenti contiene un singolo letterale funzione che accetta come argomenti il valore accumulato dall’operazione di ripiegamento e un elemento della lista. In questo caso, vogliamo restituire la somma degli argomenti, perciò foldLeft
itera sulla collezione items
, sommando insieme ogni elemento. Si veda la sezione Operazioni comuni sulle strutture dati funzionali nel capitolo 8 per maggiori informazioni su foldLeft
e sulle operazioni correlate.
Le produzioni weeks
e days
ci ricordano che stiamo usando operatori di riconoscimento basati su espressioni regolari. (Stiamo anche usando stringLiteral
, decimalNumber
e floatingPointNumber
, forniti da JavaTokenParsers
). Notate che weeks
e days
ignorano la stringa riconosciuta e restituiscono semplicemente un fattore moltiplicativo usato per determinare i giorni totali del periodo di retribuzione nella regola di produzione duration
.
Esistono altri operatori di combinazione che applicano funzioni ai risultati dei riconoscitori in modi differenti. Si veda la pagina Scaladoc di Parsers
per i dettagli.
La seguente specifica (piuttosto incompleta) illustra il calcolo delle buste paga in assenza e in presenza di ritenute.
// esempi/cap-11/payroll/pcdsl/payroll-parser-comb-spec.scala
package payroll.pcdsl
import scala.util.parsing.combinator._
import org.specs._
import payroll._
import payroll.Type2Money._
// Non verifica gli scenari "di eccezione"...
object PayrollParserCombinatorsSpec
extends Specification("PayrollParserCombinators") {
val salary = Money(100000.1) // per un anno intero
val gross = salary / 26. // per due settimane
val buck = Employee(Name("Buck", "Trends"), salary)
val employees = Map(buck.name -> buck)
implicit def money2double(m: Money) = m.amount.doubleValue
"PayrollParserCombinators" should {
"calcolare il lordo uguale al netto quando non ci sono ritenute" in {
val input = """paycheck for employee "Buck Trends"
is salary for 2 weeks minus deductions for {}"""
val p = new PayrollParserCombinators(employees)
p.parseAll(p.paycheck, input) match {
case p.Success(Pair(employee, paycheck), _) =>
employee mustEqual buck
paycheck.gross must beCloseTo(gross, Money(.001))
paycheck.net must beCloseTo(gross, Money(.001))
// zero ritenute?
paycheck.deductions must beCloseTo(Money(0.), Money(.001))
case x => fail(x.toString)
}
}
"calcolare il lordo, il netto e le ritenute per il periodo di retribuzione" in {
val input =
"""paycheck for employee "Buck Trends"
is salary for 2 weeks minus deductions for {
federal income tax is 25. percent of gross,
state income tax is 5. percent of gross,
insurance premiums are 500. in gross currency,
retirement fund contributions are 10. percent of gross
}"""
val p = new PayrollParserCombinators(employees)
p.parseAll(p.paycheck, input) match {
case p.Success(Pair(employee, paycheck), _) =>
employee mustEqual buck
val deductions = (gross * .4) + Money(500)
val net = gross - deductions
paycheck.gross must beCloseTo(gross, Money(.001))
paycheck.net must beCloseTo(net, Money(.001))
paycheck.deductions must beCloseTo(deductions, Money(.001))
case x => fail(x.toString)
}
}
}
}
Se calcolate a mano quali dovrebbero essere i risultati dalle stringhe di ingresso, vedrete che l’implementazione calcola correttamente la busta paga.
Oltre ai numerosi piccoli dettagli che differiscono tra questa implementazione del DSL esterno e l’implementazione precedente del DSL interno, c’è una notevole differenza concettuale tra le due implementazioni. Qui stiamo calcolando la busta paga man mano che riconosciamo il codice scritto nel DSL esterno. Nel caso del DSL interno, generiamo un oggetto per calcolare gli stipendi quando riconosciamo il DSL, dopodiché lo usiamo per un impiegato alla volta. Qui avremmo potuto fare la stessa cosa, ma abbiamo scelto un approccio più semplice allo scopo di concentrarci sul riconoscitore. In più, come abbiamo già detto, non siamo stati altrettanto attenti alla sicurezza per i thread e ad altri problemi di implementazione.
Scala vi offre un ricco supporto per creare DSL interni ed esterni. Tuttavia, un DSL non banale può essere difficile da implementare e correggere. Per gli esempi in questo capitolo, l’implementazione con gli operatori di riconoscimento è stata più facile da progettare e scrivere rispetto all’implementazione per il DSL interno, ma abbiamo visto che correggere il DSL interno era più semplice.
Dovete anche considerare quanto deve essere robusto il riconoscitore quando riceve un ingresso non valido. A seconda del livello di sofisticatezza degli utenti del DSL, potreste aver bisogno di fornire messaggi molto informativi quando avviene un errore, specialmente se i vostri utenti non sono programmatori. La libreria di operatori di riconoscimento di Scala 2.8 offrirà un supporto migliorato per segnalare gli errori e riprendere l’esecuzione rispetto alla libreria inclusa nelle versioni 2.7.X.
La libreria di Scala 2.8 fornirà anche un supporto per scrivere riconoscitori “spazzino” (in inglese, packrat) in grado di implementare grammatiche di espressioni analitiche (o PEG, dall’inglese parsing expression grammars) non ambigue. L’implementazione dei riconoscitori spazzino in Scala 2.8 supporta anche la memoizzazione, che vi aiuta a migliorare le prestazioni, tra gli altri benefici. Se avete bisogno di un riconoscitore veloce, un riconoscitore spazzino vi permetterà di fare maggiori progressi prima che abbiate bisogno di considerare strumenti più specializzati come i generatori automatici di riconoscitori.
La prospettiva di dedicarsi alla creazione dei DSL è allettante. Può essere piuttosto divertente lavorare con i DSL in Scala, ma non sottovalutate lo sforzo necessario per creare DSL robusti che soddisfino i requisiti di usabilità dei vostri clienti, e nemmeno i problemi di supporto e di manutenzione a lungo termine.
Se scegliete di scrivere un DSL, Scala vi offre notevoli possibilità. La sintassi del linguaggio è flessibile ma abbastanza potente da fare in modo che un DSL interno possa essere sufficiente. Un DSL interno è un eccellente punto di partenza, in particolare se a scrivere codice nel DSL saranno principalmente altri programmatori.
Se vi aspettate che i soggetti interessati che non sono programmatori leggano e persino scrivano codice nel DSL, potrebbe valere la pena di fare lo sforzo aggiuntivo di creare un DSL esterno che elimini per quanto è possibile gli idiomi del linguaggio di programmazione. Considerate o meno se ci sarà bisogno di elaborare il codice scritto nel DSL per altri scopi, come generare documentazione, fogli di calcolo, &c. Dato che dovrete comunque scrivere un riconoscitore per il DSL, potrebbe essere semplice scriverne altri per gestire queste diverse finalità.
Nel prossimo capitolo esploreremo la ricchezza del sistema di tipi di Scala. Abbiamo già imparato molte delle sue caratteristiche, e ora ne analizzeremo tutti i dettagli.
Regole per calcolare la busta paga di un impiegato: stipendio lordo di un impiegato per 2 settimane meno le ritenute per impostaRedditoFederale, che è 25% del lordo impostaRedditoStatale, che è 5% del lordo premiAssicurativi, che sono 500. nella valuta del lordo contributiFondoPensionistico sono 10% del lordo↩
© 2008–9 O’Reilly Media
© 2009–10 Giulio Piancastelli per la traduzione italiana