Voi siete qui: Inizio Programmare in Scala

Linguaggi domain-specific 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.

Incapsulamento
Un DSL nasconde i dettagli di implementazione ed espone solo quelle astrazioni che sono rilevanti per il dominio.
Efficienza
Dato che i dettagli di implementazione sono incapsulati, un DSL ottimizza lo sforzo richiesto per creare o modificare le funzionalità di una applicazione.
Comunicazione
Un DSL aiuta gli sviluppatori a capire il dominio, e gli esperti di dominio a verificare che l’implementazione soddisfi i requisiti.
Qualità
Un DSL minimizza il “disadattamento di impedenza” tra i requisiti funzionali, nel modo in cui vengono espressi dagli esperti del dominio, e il codice sorgente dell’implementazione, limitando così i potenziali errori.

Tuttavia, i DSL hanno anche diversi svantaggi.

Difficoltà nel creare un buon DSL
Un buon DSL è più difficile da progettare rispetto alle API tradizionali, anche se le difficoltà nel progettare una API elegante, efficace e facile da usare non mancano. Le API tendono a seguire gli idiomi del linguaggio di programmazione allo scopo di favorire l’uniformità, che per una API è una caratteristica importante. Al contrario, ogni DSL dovrebbe riflettere gli idiomi linguistici specifici del proprio dominio. Il progettista di DSL gode di una maggiore libertà di azione, ma questo significa anche che è molto più difficile determinare le scelte di progettazione “migliori”.
Manutenzione di lungo termine
I DSL possono richiedere una manutenzione più frequente nel lungo termine per tenere conto dei cambiamenti nel dominio. Inoltre, gli sviluppatori avranno bisogno di più tempo per imparare a usare e a mantenere un DSL.

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.

DSL interni

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)

Un DSL interno per il libro paga

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.

Notazione infissa per gli operatori

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 _.

Conversioni implicite e tipi definiti dall’utente

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.

Metodi 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.

Implementazione delle regole di pagamento come DSL

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.

  1. Invocazione di rules.double2DeductionsBuilderDeductionHelper(25.) per creare una nuova istanza DeductionsBuilderDeductionHelper(25.).
  2. Invocazione del metodo percent_of(gross) sulla nuova istanza, dove gross è di tipo DeductionsBuilder.
  3. 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.

DSL interni: considerazioni conclusive

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.

DSL esterni con la combinazione di riconoscitori

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].

Informazioni sulla combinazione di riconoscitori

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.

Un DSL esterno per il libro paga

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.

Una implementazione in Scala della grammatica di un DSL esterno

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.

Generare le buste paga con un DSL esterno

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.

DSL interni vs. esterni: considerazioni finali

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.

Riepilogo, e poi?

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.


  1. [NdT] Il testo originale in inglese è stato qui mantenuto per evitare che, nella successiva implementazione, nomi di metodo in inglese si mescolassero a nomi di metodo in italiano, dando luogo a frasi illeggibili nel DSL. Per facilitare la comprensione di questo testo e delle frasi del DSL utilizzate nel presente capitolo, viene comunque proposta la seguente traduzione letterale:
    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