Sunday, July 21, 2013

Մեծ ամբողջ թվերի հաշվարկիչ (Calculator with big integers)

Հաշվարկիչ կամ արտահայտությունների ինտերպրետատոր

Ինչպե՞ս գրել տողով տրված մաթեմատիկական արտահայտությունը հաշվարկող ծրագիր։ Կարող է թվալ, թե ավելորդ է գրել այս թեմայով, քանի որ կարելի է գտնել բազմաթիվ ու բազմազան գրականություն՝ հարուստ տեսական ու գործնական նյութով։ Բայց ես որոշեցի սկսել մի շարք, որտեղ կպատմեմ, թե ինչպես նախ կառուցել մի քանի թվաբանական գործողություններ կատարող հաշվարկիչ, ապա դրա հենքի վրա՝ քիչ-քիչ ընդլայնելով, ստեղծել ծրագրավորման լեզվի ինտերպրետատոր։

Որպես իրականացման միջոց ընտրել եմ Go լեզուն՝ երկու պատճառով. ա) ես ուզում եմ ավելի խորն ուսումնասիրել այդ լեզուն, և բ) ուզում եմ իմ կուտակած փորձը կիսել նրանց հետ, ովքեր հետաքրքրվում են Go լեզվով։

Սահմանումներ

Ամենից առաջ պետք է որոշել, թե ի՞նչ ֆունկցիոնալությամբ է օժտված լինելու հաշվարկիչը։ Սկզբի համար ես ընտրել եմ թվաբանական չորս բինար գործողություններ՝ գումարում, հանում, բազմապատկում և բաժանում, մեկ ունար գործողություն՝ բացասում, խմբավորման փակագծեր՝ գործողություններ։ Հաշվարկիչն աշխատելու է մեծ (“անսահմանափակ” երկարությամբ) ամբողջ թվերի հետ։ Օգտագործողի տեսակետից հաշվարկիչը ստանալու է արտահայտության տեքստային ներկայացումը, հաշվելու է նրա արժեքը և վերադարձնելու է պատասխանը՝ նորից տեքստային ներկայացմամբ։

EBNF գրառմամբ հաշվարկիչի լեզուն ներկայացնող քերականությունը կունենա այսպիսի տեսք.

Factor = "Number"
       | "-" Factor
       | "(" Expression ")".
Term = Factor {("*" | "/") Factor}.
Expression = Term {("+" | "-") Term}.

Այս երեք կանոններն ամբողջությամբ նկարագրում են գործողությունների թե՛ առաջայնությունը և թե՛ ասոցիատիվությունը։

Աբստրակտ քերականական ծառ

Քերականությամբ նկարագրված արտահայտությունների մոդելը աբստրակտ քերականական ծառն է (abstract syntax tree, AST)։ Այդ ծառի հանգույցները ներկայացնում են արտահայտության գործողությունները, իսկ տերևները ներկայացնում են տվյալները։ Օրինակ, 123 + 45 * 6 արտահայտության համար պետք է կառուցել հետևյալ ծառը.

    +
   / \
  /   \
123    *
      / \
     45  6

Աբստրակտ քերականական ծառը կառուցվում է քերականական անալիզատորի (parser) կողմից, և օգտագործվում է արտահայտության ինտերպրետացիայի (կամ կոմպիլյացիայի) ժամանակ։

Go լեզվով սահմանենք asteval փաթեթը, որը տրամադրում է ծառի հանգույցների ստրուկտուրան, կոնստրուկտոր ֆունկցիաներ՝ հանգույցներ ու տերևներ ստեղծելու համար, ինչպես նաև ծառի ինտերպրետացիայի ֆունկցիան։ Պրոյեկտի src պանակում ստեղծենք asteval պանակ, իսկ նրա մեջ՝ asteval.go ֆայլը։

Հայտարարենք asteval փաթեթը.

package asteval

Քանի որ մեր արտահայտություններն օգտագործելու են մեծ ամբողջ թվեր, Go լեզվի գրադարանից ներմուծենք big փաթեթը.

import "math/big"

Սահմանենք number = 0, unary = 1, binary = 2 հաստատունները, որոնք ցույց են տալու ծառի հանգույցի տիպը.

const (
  number byte = iota
  unary
  binary
)

Ծառի հանգույցը սահմանենք որպես Expression անունով կառուցվածք։ Նրա class դաշտը ցույց է տալիս հանգույցի տիպը, value դաշտը ցույց է տալիս հանգույցի արժեքը, եթե տիպը number է, operation դաշտը ցույց է տալիս հանգույցում ներկայացված ունար կամ բինար գործողությունը, իսկ first և second դաշտերը ցույց են տալիս հանգույցի ենթածառերը։ Եթե class = unary, ապա second = nil։

type Expression struct {
  class byte
  value *big.Int
  operation rune
  first, second *Expression
}

Ինչպես նշվեց, մենք գործ ենք ունենալու երեք տիպի հանգույցների հետ՝ թիվ, ունար գործողություն և բինար գքործողություն։ Սահմանենք կոնստրուկտոր ֆունկցիաներ նրանց համար։

Թիվ (number) ներկայացնող կոնստրուկտերը արգումենտում ստանում է թվի տեքստային տեսքը, և վերադարձնում է Expression կառուցվածքի նոր ստեղծված նմուշի ցուցիչը, որի class դաշտին վերագրված է number հաստատունը, իսկ value դաշտին՝ big.Int օբյեկտի ցուցիչը։

func Number(num string) *Expression {
  nm := new(big.Int)
  nm.SetString(num, 10)
  return &Expression{class: number, value: nm}
}

ՈՒնար գործողությանը համապատասխանող հանգույց ստեղծելու համար պետք է տալ գործողության նիշը և ենթաարտահայտությունը։ Չնայաց, որ մեր դեպքում կա միայն մեկ ունար գործողություն՝ բացասումը, կոնստրուկտոր ֆունկցիան որպես առաջին արգումենտ ընդունում է նաև գործողության նշանը։

func Unary(op rune, ex *Expression) *Expression {
  return &Expression{class: unary, operation: op, first: ex}
}

Բինար գործողության հանգույցի համար էլ պետք է ունենալ գործողության նշանը, աջ ու ձախ ենթաարտահայտությունները։

func Binary(op rune, exo, exi *Expression) *Expression {
  return &Expression{class: binary, operation: op, first: exo, second: exi}
}

Արտահայտության արժեքը հաշվելու համար պետք է անցում կատարել նրա համար կառուցած աբստրակտ քերականական ծառով՝ ըստ հետևյալ կանոնների.

  1. եթե class = number, ապա վերադարձնել նրա value դաշտը,

  2. եթե class = unary, ապա այս կանոնների կիրառմամբ հաշվել first ենթաարտահայտությունը և այդ արժեքի վրա կիրառել operation դաշտով որոշվող գործողությունը։

  3. եթե class = binary, ապա այս կանոնների կիրառմամբ հաշվել first և second ենթաարտահայտությունները, ապա ստացված արժեքների նկատմամբ կիրառել operation դաշտով որոշվող գործողությունը։

Նշված կանոններն իրականացնելու համար Expression կառուցվածքի ցուցիչի համար սահմանենք Evaluate մեթոդը։

func (e *Expression) Evaluate() *big.Int {
  var res *big.Int = new(big.Int) 

  switch e.class {
    case number:
      res.Set(e.value)
    case unary:
      res = e.first.Evaluate()
      if '-' == e.operation { res.Neg(res) }
    case binary:
      exo := e.first.Evaluate()
      exi := e.second.Evaluate()
      switch e.operation {
        case '+': res.Add(exo, exi)
        case '-': res.Sub(exo, exi)
        case '*': res.Mul(exo, exi)
        case '/': res.Div(exo, exi)
      }
  }

  return res
} 

Ակնհայտ է, որ այս ֆունկցիան ունի թերություն. այն բաժանման գործողությունը կատարելուց առաջ չի ստուգում երկրորդ արգումենտի զրո լինելը։ Բայց այս անգամ անտեսենք այդ թերությունը՝ հետագայում ճշտելու պայմանով։

Ահա և վերջ, ամբողջությամբ սահմանված է asteval փաթեթը։ Այն տրամադրում է Expression կառուցվածքը, Number, Unary, Binary կոնստրուկտորները և Evaluate մեթոդը։

Լեքսիկսկան և քերականական վերլուծություն

Աբստրակտ քերականական ծառն արտահայտության ներքին, օգտագործողին չերևացող մասն է։ Տեքստային ներկայացումից այն ստանալու համար պետք է արտահայտությունը ենթարկել քերականական վերլուծության, և այդ վերլուծությունը պետք է կատարել վերը բերված կանոններով։ Բայց քերականական վերլուծության համար պետք է նախ արտահայտության տեքստը տրոհել տերմինալային տրամաբանական կտորների՝ լեքսեմների և ամենի մի լեքսեմի հետ կապել նրա տիպը (նշանակությունը) ցույց տվող հատկություն՝ թոքեն։ Օրինակ, 123 + 45 * 6 արտահայտության համար տրոհումը կարող է ունենալ այսպիսի տեսք.

Lexeme Token
"123 Number
“+” Add
“45” Number
* Mul
“6” Number

Լեքսիկական անալիզատորի խնդիրն է հենց այս տրոհումը։ Քերականական անալիզատորն օգտագործում է թոքենները քերականական կանոնն ընտրելու համար, իսկ լեքսեմներն օգտագործում է ծառի հանգույցների պարունակությունը լրացնելու համար։

scanner փաթեթը

Լեքսիկական անլիզատորն իրականացնենք scanner փաթեթում։

package scanner

Ընթերցվող սիմվոլների տիպը որոշելու համար ներմուծենք Go լեզվի գրադարանային unicode փաթեթը։

import "unicode"

Սահմանենք Scanner կառուցվածքը, որի text դաշտը արտահայտության տեքստի նիշերի զանգվածն է, իսկ pos ինդեքսը ցույց է տալիս հերթական ընթերցվող սիմվոլը։

type Scanner struct {
    text []rune
    pos int
}

Scanner կառուցվածքի կոնստրուկտորը ստանում է վերլուծության ենթակա արտահայտության տեքստը, նրա վերջից կցում է “;” նիշը որպես արտահայտության ավարտի նշան, ապա տողը վերածում է նիշերի վեկտորի և վերագրում text դաշտին։

func New(txt string) *Scanner {
  return &Scanner{[]rune(txt+";"), 0}
}

Թոքենների համար սահմանենք հաստատուններ.

const (
  None byte = iota
  Number  // Digit{Digit}
  Add     // '+'
  Sub     // '-'
  Mul     // '*'
  Div     // '/'
  LPar    // '('
  RPar    // ')'
  Eos     // ';'
)

Սահմանենք մի օգնական մեթոդ, որը արտահայտության նիշերի շարքից կարդում է տրված պրեդիկատին բավարարող նիշերի անընդհատ շարք և վերադարձնում է դրանցից կազմած տողը։

func (s* Scanner) scanWith(predicate func(r rune) bool) string {
    k := s.pos
    for predicate(s.text[s.pos]) { s.pos++ }
    return string(s.text[k:s.pos])
}

Scanner կառուցվածքի Next մեթոդը վերադարձնում է երկու արժեք՝ հերթական լեքսեմը և նրան համապատասխանող թոքենը։ Մեթոդը նախ կանչում է scanWith մեթոդը IsSpace պրեդիկատով, որպեսզի դեն նետի բացատնանիշերը։ Այնուհետև, եթե հերթական նիշը թվանշան է, ապա scanWith մեթոդը կանչում է IsDigit պրեդիկատով ստանում է ամբողջ թիվ կազմող թվանշանների շարք։ Այս դեպքում վերադարձվում է թվանշաններից կազմված տողը որպես լեքսեմ և Number հաստատունը որպես թոքեն։ Թվանշաններից բացի դիտարկվում են միայն ‘+’, ‘-’, ‘*’, ‘/’, ‘(’, ‘)’ և ‘;’ նիշերը և ամեն մեկի համար վերադարձվում է համապատասխան թոքենը։ Բոլոր այլ նիշերի համար վերադարձվում է None թոքենը։

func (s *Scanner) Next() (string, byte) {
  s.scanWith(unicode.IsSpace)

  ch := s.text[s.pos]

  if unicode.IsDigit(ch) {
    return s.scanWith(unicode.IsDigit), Number
  }

  var res byte = None
  s.pos++
  switch ch {
    case '+': res = Add
    case '-': res = Sub
    case '*': res = Mul
    case '/': res = Div
    case '(': res = LPar
    case ')': res = RPar
    case ';': res = Eos
  }

  return string(ch), res
}

Սա էլ լեքսիկական անալիզատորի փաթեթեը։ նորից ոչ մի սխալների մշակում չի կարարված զուտ պարզության համար։ Նախագծի հետագա զարգացումներում անպայմանորեն պետք է հաշվի առնել հնարավոր սխալները։

parser փաթեթը

Քերականական անալիզատորն իրականացված է parser փաթեթում։ Այն ըստ քերականական կանոնների վերլուծում է արտահայտության տեքստը և կառուցում է աբստրակտ քերականական ծառը։ Քերականական սխալները չեն մշակվում՝ նորից պարզության համար։

package parser

Ներմուծենք մեր նախապես ստեղծած երկու փաթեթները։

import (
  "scanner"
  "asteval"
)

Parser կառուցվածքը քերականական անալիզատորի մոդելն է։ Նրա look դաշտը ընթացիկ թոքենն է՝ look-a-head սիմվոլը, lexeme դաշտը ընթացիկ լեքսեմն է, scan դաշտը լեքսիկական անալիզատորի ցուցիչն է։

type Parser struct {
  look byte
  lexeme string
  scan *scanner.Scanner
}

Parser կառուցվածքի համար կոնստրուկտոր չենք գրել, քանի որ դրա կարիքը պարզապես չկա։

Միակ միջոցը, որով քերականական անալիզատորը շփվում է արտաքին աշխարհի հետ, դա Parse մեթոդն է։ Այն ստանում է արտահայտության տեքստը և վերադարձնում է աբստրակտ քերականական ծառի արմատը (եթե վերլուծության ընթացքում սխալներ չեն հայտնաբերվել)։

func (p* Parser) Parse(src string) *asteval.Expression {
  p.look = scanner.None
    p.scan = scanner.New(src)
  p.match(scanner.None)
  return p.expr()
}

Parser-ը լեքսիկական անալիզատորից հերթական լեքսեմ-թոքեն զույգը կարդում է match մեթոդի օգնությամբ։ Հետագայում այս մեթոդի մեջ է իրականացվելու որոշ քերականական սխալների մասին ազդարարումը։

func (p *Parser) match(tok byte) bool {
  if tok != p.look { return false }
  p.lexeme, p.look = p.scan.Next()
  return true
}

Դիտարկվող արտահայտություների քերականությունն ունի երեք արտածման կանոն, ըստ որոնց էլ կառուցել եմ քերականական անալիզատորի մեթոդները։

Առաջինը Factor կանոնն է, որն ունի արտածման երեք տարբերակ․

Factor = "Number".
Factor = "-" Factor.
Factor = "(" Expression ")". 

ՈՒշադիր նայելով այս արտածման կանոններին տեսնում ենք, որ Factor կանոնը կարելի է կիրառել միայն այն դեպքում, երբ լեքսիկական անալիզատորի look-a-head սիմվոլը “Number”, “-”, “(” թոքեններից որևէ մեկն է։

Եթե look-a-head = “Number”, ապա ստեղծում և վերադարձնում ենք ծառի նոր տերև՝ ընթացիկ լեքսեմի արժեքով։ Եթե look-a-head = “-”, ապա հանդիպել ենք ունար գործողության։ Ռեկուրսիվ կանչով կառուցում ենք գործողության արգումենտի ծառը, ապա ստեղծում ենք ծառի ունար գործողության հանգույց։ Եթե look-a-head = “(”, ապա ռեկուրսիվ կանչով կառուցում ենք փակագծերի ներսում գրված արտահայտության ծառը, ապա match մեթոդի կանչով համոզվում ենք, որ առկա է փակվող փակագիծը։

func (p *Parser) factor() *asteval.Expression {
  // number
  if p.look == scanner.Number {
    num := p.lexeme
    p.match(scanner.Number)
    return asteval.Number(num)
  }

  // unary operation '-'
  if p.look == scanner.Sub {
    p.match(scanner.Sub)
    ex := p.factor()
    return asteval.Unary('-', ex)
  }

  // expression in parentheses
  if p.look == scanner.LPar {
    p.match(scanner.LPar)
    ex := p.expr()
    p.match(scanner.RPar)
    return ex
  }

  return nil
}

Քերականության հաջորդ կանոնը կառուցում է բազմապատկման ու բաժանման բինար գործողություններին համապատասխանող հանգույցները։ Ըստ քերականական երկրորդ կանոնի, Term-ը կարող է լինել կա՛մ Factor, կա՛մ “*” և “/” նիշերով իրար կցված Factor-ների հաջորդականություն․

Term = Factor {("*" | "/") Factor}.

Քերականական անալիզատորի term մեթոդը այս կանոնի տառացի իրականացումն է։

func (p *Parser) term() *asteval.Expression {
  ex1 := p.factor()
  for p.look == scanner.Mul || p.look == scanner.Div {
    op := rune(p.lexeme[0])
    p.match(p.look)
    ex2 := p.factor()
    ex1 = asteval.Binary(op, ex1, ex2)
  }
  return ex1
}

Երրորդ քերականական կանոնը կառուցում է գումարման ու հանման բինար գործողություններին համապատասխանող հանգույցները։ expr մեթոդը շատ նման է term մեթոդին։ Քանի որ մեր քննարկած արտահայտություններում գումարման ու հանման գործողություններն ունեն ամենացածր նախապատվությունը, հենց այս մեթոդն է կանչվում Parse մեթոդից։

func (p *Parser) expr() *asteval.Expression {
  ex1 := p.term()
  for p.look == scanner.Add || p.look == scanner.Sub {
    op := rune(p.lexeme[0])
    p.match(p.look)
    ex2 := p.term()
    ex1 = asteval.Binary(op, ex1, ex2)
  }
  return ex1
}

Երկխոսություն օգտագործողի հետ

Հաշվարկիչի և օգտագործողի շփումն իրականացված է հրամանային տողի օգնությամբ։ Գործարկումից հետո ծրագիրն արտածում է “>” սիմվոլը և սպասում է, որ օգտագործողը ներմուծի արտահայտության տեքստը։ Արտահայտության ներմուծումից և Enter ստեղնի սեղմումից հետո հաշվարկվում և արտածվում է արտահայտությունը արժեքը։

Փաթեթն անվանված է repl՝ (Read-Evaluate-Print-Loop)։

package repl

Ներմուծենք անհրաժեշտ փաթեթները։

import (
  "fmt"
  "os"
  "bufio"
  "parser"
)

Եվ միակ Run պրոցեդուրան, որը reader-ի միջոցով կարդում է օգտագործողի տված տեքստը, parser-ի միջոցով վերլուծում է այն ու կառուցում է աբստրակտ քերականական ծառ։ Այնուհետև ծառի արմատի համար կանչելով Evaluate մեթոդը՝ հաշվում է արտահայտության արժեքը։ Ցիկլը կատարվում է այնքան ժամանակ, քանի դեռ օգտագործողի ներածած տեքստի առաջին նիշը “.” (կետ) չէ։

func Run() {
  reader := bufio.NewReader(os.Stdin)
  parser := new(parser.Parser)

  for {
    fmt.Printf("> ")
    sr, _ := reader.ReadString('\n')
    ex := string(sr)
    if '.' == ex[0] { break }

    ast := parser.Parse(ex)
    res := ast.Evaluate()
    fmt.Println(">", res.String())
  }
}

Ծրագրի մուտքի կետը և գործարկումը

Go լեզվով գրված ծրագրերի մուտքի կետ է հանդիսանում main փաթեթի main ֆունկցիան։ Այն պարզապես կանչում է repl փաթեթի Run ֆունկցիան։

package main

import "repl"

func main() {
  repl.Run()
}

Հաշվարկիչ վերագրման և արտածման հրամաններով

Այս անգամ ես ներկայացնում եմ հաշվարկիչի մի նոր զարգացում, որտեղ ավելացված են փոփոխականին արժեքի վերագրման և արտահայտության արժեքի արտածման հրամանները։ Ի տարբերություն միայն թվաբանական գործողություններ կատարող հաշվարկիչի, փոփոխականներով հաշվարկիչը հնարավորություն է տալիս պահպանել և կրկին օգտագործել հաշվարկման արդյունքները։ Օրինակ, կարելի է գրել հետևյալ հրամանները․

a = 5
b = 4
print a + 1 + b

որոնց կատարումից հետո հաշվարկիչը կարտածի 10 արդյունքը։

Պետք է նկատի ունենալ, որ վերագրման և արտածման գործողությունները հրամաններ են, այլ ոչ թե արտահայտություններ։

Նոր քերականությունը

Հրամանների ավելացմամբ հաշվարկիչի լեզվի քերականությունն ընդլայնվում է և ստանում է ահա այսպիսի տեսք։

Statement = Assignment 
          | Print.
Assignment = 'Ident' '=' Expr.
Print = 'print' Expr.
Expr = Term {('+' | '-') Term}.
Term = Factor {('*' | '/') Factor}.
Factor = '(' Expr ')'
       | '-' Factor
       | 'Number'
       | 'Ident'.

Կատարման միջավայր

Քանի որ հաշվարկիչը պետք է կարողանա հիշել փոփոխականների արժեքները, սահմանենք Environment կառուցվածքը, որը մասնակցելու է հրամանների կատարման և արտահայտությունների հաշվարկման պրոցեսին և որի միջոցով փոխանցվելու են փոփոխականների արժեքները։

package environ

import "math/big"

Environment կառուցվածքը պարունակում է data դաշտը, որը արտապատկերում է փոփոխականի անունները արժեքներին։ Արտապատկերումը կազմակերպելու համար օգտագործված է Go լեզվի map օբյեկտը։

type Environment struct {
  data map[string]*big.Int
}

Միջավայրի կոնստրուկտորը պարզապես ստեղծում և վերադարձնում է նոր Environment օբյեկտի ցուցիչ։

func New() *Environment {
  return &Environment{data: make(map[string]*big.Int)}
}

Փոփոխականների արժեքները միջավայրում ավելացվում կամ թարմացվում են Set մեթոդով։

func (e *Environment) Set(name string, value *big.Int) {
  e.data[name] = value
}

Որևէ փոփոխականի արժեքը միջավայրից ստանալու համար է նախատեսված Get մեթոդը։

func (e *Environment) Get(name string) *big.Int {
  value, _ := e.data[name]
  return value
}

Աբստրակտ քերականական ծառի ընդլայոնում

Քերականության Factor արտածման կանոնում ավելացել է նոր ճյուղ՝ 'Ident', որը ցույց է տալիս, որ փոփոխականները նույնպես արտահայտություն են։ Ընդլայնենք asteval փաթեթի Expression կառուցվածքն այնպես, որ այն սպասարկի նաև փոփոխականները։

type Expression struct {
  class byte
  value *big.Int
  varname string
  operation rune
  first, second *Expression
}

Իսկ արտահայտության տիպը որոշող հաստատունների ցուցակում ավելացնենք ևս մեկ հաստատուն՝ variable։

const (
  number byte = iota
  variable
  unary
  binary
)

Բնականաբար պետք է ունենալ նաև նոր կոնստրուկտոր, որը կառուցում է փոփոխականին համապատասխան հանգույց.

func Variable(nam string) *Expression {
  return &Expression{class: variable, varname: nam}
}

Փոփոխության է ենթարկվում նաև արտահայտության արժեքը հաշվարկող Evaluate մեթոդը։ Այժմ այն իր արգումենտում ստանում է հաշվարկման միջավայրը՝ Environment կառուցվածքի ցուցիչ, որից վերցնելու ենք փոփոխականի արժեքը։

func (e *Expression) Evaluate(env *environ.Environment) *big.Int {
  var res *big.Int = new(big.Int)

  switch e.class {
    case number:
      res.Set(e.value)
    case variable:
      res.Set(env.Get(e.varname))
    case unary:
      res = e.first.Evaluate(env)
      if '-' == e.operation { res.Neg(res) }
    case binary:
      exo := e.first.Evaluate(env)
      exi := e.second.Evaluate(env)
      switch e.operation {
        case '+': res.Add(exo, exi)
        case '-': res.Sub(exo, exi)
        case '*': res.Mul(exo, exi)
        case '/': res.Div(exo, exi)
      }
  }

  return res
}

Լեզվի քերականության մեջ ավելացել է հրամանի հասկացությունը՝ Statement, իր երկու ներկայացումներով՝ Assignment և Print։ Հրամանների աբստրակտ քերականական ծառը կառուցելու համար ստեղծենք նոր փաթեթ.

package astexec

import (
  "asteval"
  "fmt"
  "environ"
)

Սահմանենք Statement ինտերֆեյսը, որի Execute մեթոդով իրականացնելու ենք տարբեր հրամանների վարքը.

type Statement interface {
  Execute(env *environ.Environment)
}

Վերագրման հրամանն իրականացված է Assignment կառուցվածքով, որի name դաշտը փոփոխականի անունն է, իսկ expr դաշտը այն արտահայտությունն է, որի արժեքը միջավայրում պետք է կապել փոփոխականի հետ։

type Assignment struct {
  name string
  expr *asteval.Expression
}

Կոնստրուկտորը շատ պարզ է։

func NewAssignment(nm string, ex *asteval.Expression) *Assignment {
  return &Assignment{name: nm, expr: ex}
}

Execute մեթոդի իրականացումը նախ հաշվում է expr արտահայտության արժեքը, ապա միջավայրում ավելացնում է նոր համապատասխանություն։

func (a *Assignment) Execute(env *environ.Environment) {
  val := a.expr.Evaluate(env)
  env.Set(a.name, val)
}

Արտածման հրամանի միակ expr դաշտը այն արտահայտությունն է, որի արժեքը պետք է հաշվել ու արտածել։

type Print struct {
  expr *asteval.Expression
}

Print հրամանի կոնստրուկտորն է.

func NewPrint(ex *asteval.Expression) *Print {
  return &Print{expr: ex}
}

Իսկ արտածման հրամանի կատարման համար պետք է պարզապես հաշվել expr արտահայտության արժեքը և fmt.Println ֆունկցիայով արտածել ստանդարտ արտածման հոսքի վրա։

func (p *Print) Execute(env *environ.Environment) {
  val := p.expr.Evaluate(env)
  fmt.Println(val.String())
}

Փոփոխություններ լեքսիկական անալիզատորում

Քերականության մեջ ավելացել են երեք թոքեններ՝ “Ident”, “Print” և “=”։ Իդենտիֆիկատորը դա տառով սկսվող տառաթվային հաջորդականություն է։ ‘print’-ը արտածման հրամանի ծառայողական բառն է։ ‘=’ նիշը վերագրման հրամանի սիմվոլն է։

const (
  None byte = iota
  Number  // Digit{Digit}
  Ident   // Letter{Letter|Digit}
  Add     // '+'
  Sub     // '-'
  Mul     // '*'
  Div     // '/'
  LPar    // '('
  RPar    // ')'
  Equal   // '='
  Print   // 'print', '?'
  Eos     // '@'
)

Ծառայողական բառերի համար սահմանենք մի աղյուսակ, որում իդենտիֆիկատորին համապատասխանեցված է թոքեն։ Այս աղյուսակն օգտագործելու ենք, որպեսզի պարզենք արդոք կարդացած իդենտիֆիկատորը ծառայողական բառ է, թե՝ ոչ։

var keywords = map[string]byte {
  "print": Print }

Սահմանենք նաև մի օգնական ֆունկցիա, որը դրական պատասխան է տալիս այն դեպքում, երբ արգումենտում տրված սիմվոլը տառ է կամ թվանշան։

func isLetterOrDigit(r rune) bool {
  return unicode.IsLetter(r) || unicode.IsDigit(r)
}

Լեքսիկական անալիզատորի Next մեթոդում ավելացնենք մի բլոկ, որը կարդում է տառով սկսվող տառաթվային հաջորդականություն, որոնում է այն ծառայողական բառերի աղյուսակում և, եթե գտնվել է, ապա վերադարձնում է համապատասխան թոքենը, հակառակ դեպքում վերադարձնում է Ident թոքենը։

...
  if unicode.IsLetter(ch) {
    iden := s.scanWith(isLetterOrDigit)
    tok, iskey := keywords[iden]
    if !iskey { tok = Ident }
    return iden, tok
  }
...

Փոփոխություններ քերականական անալիզատորում

Նախ factor մեթոդում ավելացնենք մի ճյուղ, որը վերլուծում է փոփոխականի առկայությունը.

...
  if p.look == scanner.Ident {
    nam := p.lexeme
    p.match(scanner.Ident)
    return asteval.Variable(nam)
  }
...

Քերականական անալիզատորում ավելացնենք երեք նոր մեթոդ՝ քերականության հետևյալ երեք կանոնների համար.

Statement = Assignment 
          | Print.
Assignment = 'Ident' '=' Expr.
Print = 'print' Expr.

Այս պահին մեր հրամանները կարող են սկսվել կա՛մ իդենտիֆիկատորով, կա՛մ print ծառայողական բառով։ Ես ճյուղավորման համար ընտրել եմ switch կառուցվածքը, որպեսզի հետագայում հեշտ լինի նոր հրամանների վերլուծության ճյուղերն ավելացնելը։

func (p *Parser) statement() astexec.Statement {
  switch p.look {
    case scanner.Ident:
      return p.assignment()
    case scanner.Print:
      return p.printexpr()
  }
  return nil
}

Վերագրման հրամանը վերլուծելու համար հերթականությամբ պետք է ճանաչել իդենտիֆիկատորը, հավասարության նշանը և հաջորդող արտահայտությունը։ Ապա կառուցել և վերադարձնել նոր Assignment հանգույց։

func (p *Parser) assignment() astexec.Statement {
  nm := p.lexeme
  p.match(scanner.Ident)
  p.match(scanner.Equal)
  ex := p.expr()
  return astexec.NewAssignment(nm, ex)
}

Արտածման հրամանը վերլուծելիս պետք է ճանաչել print ծառայողական բառը, ապա վերլուծել նրան հաջորդող արտահայտությունը։

func (p *Parser) printexpr() astexec.Statement {
  p.match(scanner.Print)
  ex := p.expr()
  return astexec.NewPrint(ex)
}

Քանի որ նոր քերականության մեջ առաջինը Statement կանոնն է, փոփոխենք Parse մեթոդն այնպես, որ նախ՝ այն վերադարձնի astexec.Statement, ապա՝ վերլուծությունը սկսի ոչ թե expr մեթոդից, այլ statement մեթոդից։

func (p* Parser) Parse(src string) astexec.Statement {
  p.look = scanner.None
  p.scan = scanner.New(src)
  p.match(scanner.None)
  return p.statement()
}

Երկխոսության ցիկլը

Ձևափոխված երկխոսության ցիկլում ստեղծում ենք նոր Environment օբյեկտ, որում պահվելու են փոփոխականների արժեքները։ Երբ Parse մեթոդը վերադարձնում է հրամանին համապատասխան աբստրակտ քերականական ծառը, կանչում ենք նրա Execute մեթոդը՝ արգումենտում տալով env օբյեկտը որպես կատարման միջավայր։

func Run() {
  reader := bufio.NewReader(os.Stdin)
  parser := new(parser.Parser)
  env := environ.New()

  for {
    fmt.Printf("> ")
    sr, _ := reader.ReadString('\n')
    ex := string(sr)
    if '.' == ex[0] { break }

    ast := parser.Parse(ex)
    ast.Execute(env)
  }
}

Հաշվարկիչից դեպի լեզվի ինտերպրետատոր

Հաշվարկիչի այս հերթական զարգացումը նպատակ ունի առաջին քայլն անել պարզագույն REPL ցիկլից դեպի ծրագրավորման լեզվի ինտերպրետատոր։ Նախ՝ լեքսիկական անալիզատորը կփոփոխվի այնպես, որ հրամանային տողի փոխարեն ծրագրի տեքստը ընթերցվի ֆայլից։ Ապա՝ լեզվի հրամանների համակարգը կընդլայնվի ներածման և հաջորդման հրամաններով։

Ծրագրի կոդի ընթերցումը ֆայլից

Ձևափոխենք լեքսիկական անալիզատորն այնպես, որ ծրագրի նիշերն ընթերցվեն ոչ թե տողից, այլ bufio.Reader օբյեկտից։ Scanner կառուցվածքի նոր տեսքն ահա այսպիսինն է.

type Scanner struct {
  source *bufio.Reader
  line int
}

Որտեղ line փոփոխականը նախատեսված է տողի համարը պահելու համար։ Այն մեզ հարկավոր է լինելու քերականական սխալների տեղը նշելիս։

Փոփոխության է ենթարկվում նաև Scanner-ի կոնստրուկտորը.

func New(src *bufio.Reader) *Scanner {
  return &Scanner{src, 1}
}

Հոսքից նիշեր կարդալիս անհրաժեշտ է մի ֆունկցիա, որը վերադարձնում է հերթական նիշը, բայց այն չի հեռացնում հոսքից։ Եթե հոսքն արդեն դատարկ է, ապա վերադարձնում է 0։

func (s *Scanner) peek() rune {
  br, ok := s.source.Peek(1)
  if ok != nil { return 0 }
  return rune(br[0])
}

Մեկ այլ ֆունկցիա կարդում և վերադարձնում է հերթական սիմվոլը։ Այս դեպքում նույնպես, եթե հոսքն արդեն դատարկ է, ապա վերադարձնում է 0։

func (s *Scanner) char() rune {
  ch, _, ok := s.source.ReadRune()
  if ok != nil { return 0 }
  return ch
}

scanWith մեթոդն արդեն ձևափոխված է այնպես, որ օգտագործվի char մեթոդը։ Այստեղ ձևավորվում է նաև line դաշտի ընթացիկ արժեքը։

func (s* Scanner) scanWith(predicate func(r rune) bool) string {
  res := ""
  ch := s.char()
  for predicate(ch) {
    res += string(ch)
    if ch == '\n' { s.line++ }
    ch = s.char()
  }
  s.source.UnreadRune()
  return res
}

Փոքր փոփոխություններ է կրել նաև քերականական անալիզատորը. Parse մեթոդը արգումենտում ստանում է ոչ թե տող, այլ bufio.Reader օբյեկտի ցուցիչ։

func (p* Parser) Parse(src *bufio.Reader) astexec.Statement {
  p.look = scanner.None
  p.scan = scanner.New(src)
  p.match(scanner.None)
  return p.sequence()
}

Այս փոփոխությունները բավական են, որպեսզի ֆայլի դեսկրիպտորից ստեղծվի bufio.Reader օբյեկտ և նրանից ընթերցվի ծրագիրը։

Լեզվի քերականության ընդլայնում

Հաշվարկիչի լեզուն ընդլայնված է երկու նոր հրամաններով՝ տվյալների ներածման և հրամաններ հաջորդման։

Sequence = Statement {';' Statement}.
Input = 'input' 'Ident'.

Հրամանների հաջորդում

Հաջորդման հրաման (կամ հրամանների հաջորդման կառուցվածք) ասելով կհասկանանք ծրագրի տեքստում իրար հետևից գրված հրամանները, որոնք բաժանված են “;” նիշով։ Պետք է ուշադրություն դարձնել, որ ոչ թե ամեն մի հրաման ավարտվում է “;” նիշով, այլ նրանով հրամաններն իրարից բաժանվում են։

Sequence կառուցվածքը պարունակում է միակ children դաշտը, որը պարունակում է հաջորդականություն կազմող հրամանների ցուցիչները։

type Sequence struct {
  children *list.List
}

Կոնստրուկտորը պարզապես արժեքավորում է children դաշտը։

func NewSequence() *Sequence {
  return &Sequence{children: list.New().Init()}
}

Append մեթոդը ցուցակում ավելացնում է հրամանների հաջորդականության հերթական տարրը։

func (q *Sequence) Append(st Statement) {
  q.children.PushBack(st)
}

Կատարման ժամանակ պարզապես հաջորդաբար կատարվում են children ցուցակի հրամանները (ճիշտ կլիներ, իհարկե, այստեղ ստուգել, որ ցուցակի տարրը nil չլինի)։

func (q *Sequence) Execute(env *environ.Environment) {
  for e := q.children.Front(); e != nil; e = e.Next() {
    st := e.Value.(Statement)
    st.Execute(env)
  }
}

Լեքսիկական անալիզատորի թոքենների ցուցակում ավելացնենք Semic թոքենը՝ “;” նիշի համար։

Քերականական անալիզատորում ավելացնենք sequence մեթոդը։ Այն ստեղծում է նոր Sequence օբյեկտ, ապա նրանում ավելացնում է հերծական վերլուծած հրամանները։

func (p *Parser) sequence() astexec.Statement {
  seq := astexec.NewSequence()
  st := p.statement()
  seq.Append(st)
  for p.look == scanner.Semic {
    p.match(scanner.Semic)
    seq.Append(p.statement())
  }
  return seq
}

Եվ նշենք, որ Parse մեթոդը ձևափոխված է այնպես, որ լեզվի վերլուծությունը սկսվի հենց sequence մեթոդից։

Ներածման հրաման

Ներածման հրամանը որոշվում է input ծառայողական բառով և նրան հետևող իդենտիֆիկատորով։ Այն օգտագործողից պահանջում է ստեղնաշարից ներմուծել ամբողջ թվի արժեքը։

Աբստրակտ քերականական ծառում ներածման հրամանի հանգույցն ունի հետևյալ տեսքը, որտեղ name դաշտը այն փոփոխականի անունն է, որի համար պետք է կարդալ արժեքը։

type Input struct {
  name string
}

Կոնստրուկտորը պարզ է.

func NewInput(nm string) *Input {
  return &Input{name: nm}
}

Ներածման հրամանը կատարելու համար ստեղծում ենք bufio.Reader օբյեկտ՝ os.Stdin ֆայլի հետ կապված։ Ապա կարդում ենք տող, նրանով արժեքավորում նոր big.Int օբյեկտ և փոփոխականի անունի հետ միասին ավելացում միջավայրում։

func (i *Input) Execute(env *environ.Environment) {
  reader := bufio.NewReader(os.Stdin)
  num, _ := reader.ReadString('\n')
  value := new(big.Int)
  value.SetString(num, 10)
  env.Set(i.name, value)
}

Parser կառուցվածքի inputval մեթոդը նախ չանաչում է scanner.Input թոքենը, ապա իդենտիֆիկատորը, վերջինիս համապատասխան լեքսեմն էլ օգտագործելով ստեղծում է նոր astexec.Input օբյեկտ։

func (p *Parser) inputval() astexec.Statement {
  p.match(scanner.Input)
  nm := p.lexeme
  p.match(scanner.Ident)
  return astexec.NewInput(nm)
}

Եվ ներածման հրամանը վերլուծության ենթական հրամանների շարքում ավելացնելու համար statement մեթոդում ավելացնում ենք նոր ճյուղ։

func (p *Parser) statement() astexec.Statement {
  switch p.look {
    case scanner.Ident:
      return p.assignment()
    case scanner.Print:
      return p.printexpr()
    case scanner.Input:
      return p.inputval()
  }
  return nil
}

Ծրագրի մուտքի կետը

main փաթեթի main ֆունկցիայում նախ ստուգում ենք, որ հրամանայի տողում տրված արումենտները լինեն ճիշտ երկու հատ՝ len(os.Args) != 2։ Ապա փորձում ենք os.Open ֆունկցիայով բացել ծրագրի ֆայլը՝ անհաջողության դեպքում տալով հաղորդագրություն։

func main() {
  if len(os.Args) != 2 {
    fmt.Println("Source file missing.")
    os.Exit(1)
  }

  fin, err := os.Open(os.Args[1])
  if err != nil {
    fmt.Println("Cannot open input file.")
    os.Exit(2)
  }
  defer fin.Close()

  par := new(parser.Parser)
  ast := par.Parse(bufio.NewReader(fin))
  env := environ.New()
  ast.Execute(env)
}

Օրինակ

Ստեղծենք test0.calc անունով ֆայլ և նրա մեջ գրենք հետևյալը.

input a;
input b;
c = a+b;
print c.

Համեմատում, ճյուղավորում, կրկնություն

Ինտերպրետատորի զարգացման այս քայլում ես պլանավորել էի իրականացնել ճյուղավորման և կրկնման հրամանները։ Բայց, քանի որ համեմատման գործողությունները սերտորեն կապված են դրանց հետ, ես նախ ընդլայնեցի արտահայտություններն այնպես, որ նրանք ընդգրկեն համեմատման վեց գործողություններ, ապա նոր միայն ընդլայնեցի հրամանների համակարգը ճյուղավորման և կրկնման հրամաններով։

Համեմատման գործողություններ

Երկու թվերի համեմատման գործողությունների համար ես ընտրել եմ հետևյալ նշանակումները․

# = - հավասար է, # <> - հավասար չէ, # > - մեծ է, # >= - մեծ է կամ հավասար, # < - փոքր է, # <= - փոքր է կամ հավասար:

Արտահայտությունների քերականությունն ընդլայնվում է և ստանում է այսպիսի տեսք․

Relation = Expr [RelOper Expr].
RelOper = '=' | '<>' | '>' | '>=' | '<' | '<='.
Expr = Term {('+' | '-') Term}.
Term = Factor {('*' | '/') Factor}.
Factor = 'Number'
       | 'Ident'
       | '-' Factor
       | '(' Expr ')'.

Լեքսիկական անալիզատորի փոփոխությունները

Թոքենների ցուցակում ավելացել են հաստատուններ վերը նշված համեմատման գործողությունների համար․

const (
  None byte = iota
  ...
  Equal       // '='
  NotEq       // '<>'
  Great       // '>'
  GrEq        // '>='
  Less        // '<'
  LsEq        // '<='
  ...
)

Քանի որ սրանով թվաբանական ու համեմատման գործողությունների քանակն իրար հետ վերցրած հասավ տասի, ես որոշեցի լեքսեմներին թոքեններ համապատասխանեցնելու համար սահմանել մի հեշ աղյուսակ․

var operations = map[string]byte{
  "+":  Add,
  "-":  Sub,
  "*":  Mul,
  "/":  Div,
  "=":  Equal,
  "<>": NotEq,
  ">":  Great,
  ">=": GrEq,
  "<":  Less,
  "<=": LsEq,
}

Իսկ գործողություններ կազմող սիմվոլները ճանաչելու համար սահմանեցի isOpChar ֆունկցիան, որպեսզի հնարավորություն ունենամ գործողությունների նշաններ կարդալ scanWith մեթոդով․

func isOpChar(r rune) bool {
  return strings.ContainsRune("+-*/=><", r)
}

Համապատախանաբար Next մեթոդի մարմնում ավելացավ մի նոր ճյուղ, որը կարդում և ճանաչում թվաբանական ու համեմատման գործողությունների նշանները․

func (s *Scanner) Next() (string, byte) {
  ...
  // operations
  if isOpChar(ch) {
    str := s.scanWith(isOpChar)
    tok, iskey := operations[str]
    if !iskey {
      tok = None
    }
    return str, tok
  }
  ...
}

Աբստրակտ քերականական ծառի փոփոխությունները

Քանի որ համեմատումները բինար գործողություններ են, նրանց համապատասխան ծառի հանգույցներ կստեղծենք NewBinary կոնստրուկտորով։ Փոփոխություններ կատարվել են միայն Evaluate մեթոդում։ big փաթեթի Int կառուցվածքի համար սահմանված Cmp մեթոդը համեմատում է երկու Big թվեր և վերադարձնում է -1, 0, 1, երբ առաջին թիվը համապատասխանաբար փոքր է, հավասար է կամ մեծ է երկրորդ թվից։

func (e *Expression) Evaluate(env *environ.Environment) *big.Int {
  var res *big.Int = new(big.Int)

  switch e.class {
    ...
    case binary:
      exo := e.first.Evaluate(env)
      exi := e.second.Evaluate(env)
      switch e.operation {
        ...
        case "=":
          u := exo.Cmp(exi)
          res.SetInt64(toInt64(u == 0))
        case "<>":
          u := exo.Cmp(exi)
          res.SetInt64(toInt64(u != 0))
        case ">":
          u := exo.Cmp(exi)
          res.SetInt64(toInt64(u > 0))
        case ">=":
          u := exo.Cmp(exi)
          res.SetInt64(toInt64(u >= 0))
        case "<":
          u := exo.Cmp(exi)
          res.SetInt64(toInt64(u < 0))
        case "<=":
          u := exo.Cmp(exi)
          res.SetInt64(toInt64(u <= 0))
      }
  }
  return res
}

Քերականական անալիզատորի փոփոխությունները

Քերականական անալիզատորում ավելացել է relation մեթոդը, որը տառացիորեն համընկնում է քերականության հետևյալ կանոնին.

Relation = Expr [RelOper Expr].
RelOper = '=' | '<>' | '>' | '>=' | '<' | '<='.
func (p *Parser) relation() *asteval.Expression {
  ex := p.expr()
  if p.look >= scanner.Equal && p.look <= scanner.LsEq {
    op := p.lexeme
    p.match(p.look)
    ex = asteval.Binary(op, ex, p.expr())
  }
  return ex
}

Ճյուղավորման և կրկնման կառուցվածքներ

Երկու նոր հրամանները, որոնք անհրաժեշտ են քիչ թե շատ կարգին ալգորիթմներ գրելու համար դրանք ճյուղավորման ու կրկնման հրամաններն են։ Քերականական տեսակետից նրանք ունեն այսպիսի տեսք.

Statement = ... 
          | Branching
          | Looping.
Branching = 'if' Relation 'then'
              Sequence
           {'elseif' Relation 'then'
              Sequence}
           ['else'
              Sequence]
            'end'.
Looping = 'while' Relation 'do'
            Sequence
          'end'.

Լեքսիկական անալիզատորի փոփոխությունները

Թոքենների ցուցակում պետք է հաստատուններ ավելացնել if, then, elseif, else, while, do և end ծառայողական բառերի համար։

const (
  None   byte = iota
  ...
  If          // 'if'
  Then        // 'then'
  ElseIf      // 'elseif'
  Else        // 'else'
  While       // 'while'
  Do          // 'do'
  End         // 'end'
  ...
)

Ծառայողական բառերը թոքենների արտապատկերող հեշ աղյուսակը նույնպես պետք է ընդլայնել հետևյալ կերպ.

var keywords = map[string]byte{
  ...
  "if":     If,
  "then":   Then,
  "elseif": ElseIf,
  "else":   Else,
  "while":  While,
  "do":     Do,
  "end":    End,
}

Աբստրակտ քերականական ծառի ընդլայոնւմը

astexec փաթեթում ավելանում են Branching և Looping կառուցվածքներն իրենց կոնստրուկտորներով ու Execute մեթոդներով։

Branching կառուցվածքը, որ նախատեսված է ճյուղավորման հրամանի համար, պարունակում է cond դաշտը որպես պայմանի արտահայտություն, thenpart դաշտը որպես հրամանների հաջորդականություն, որոնք պիտի կատարվեն այն դեպքում, երբ cond արտահայտության արժեքը հաշվարկվել է զրոյից տարբեր, և elsepart դաշտը որպես հարմանների հաջորդականություն, որոնք կատարվում են cond արտահայտության զրո հաշվարկված արժեքի դեպքում։

type Branching struct {
  cond *asteval.Expression
  thenpart, elsepart Statement
}

Կոնստրուկտորը ստանում է միայն երկու արգումենտ՝ cond և thenpart դաշտերի համար, իսկ elsepart դաշտն արժեքավորվում է առանձին SetElse մեթոդով։

func NewBranching(cd *asteval.Expression, tp Statement) *Branching {
  return &Branching{cond: cd, thenpart: tp, elsepart: nil}
}

func (b *Branching) SetElse(e Statement) {
  b.elsepart = e
}

Ճյուղավորման հրամանի կատարումը շատ պարզ է։ Նախ հաշվարկվում է cond արտահայտության արժեքը, ապա, եթե այն տարբեր է զրոյից՝ կատարվում է thenpart հրամանների հաջորդականությունը, հակառակ դեպքում՝ elsepart հաջորդականությունը։

func (b *Branching) Execute(env *environ.Environment) {
  ex := b.cond.Evaluate(env)
  if 0 != ex.Cmp(big.NewInt(0)) {
    b.thenpart.Execute(env)
  } else {
    b.elsepart.Execute(env)
  }
}

Կրկնման հրամանը մոդելավորող Looping կառուցվածքում cond դաշտը ցիկլի կատարման պայմանն է, իսկ body դաշտը՝ ցիկլի մարմինը։ Կոնստրուկտորը պարզապես արժեքավորում է այդ դաշտերը։

type Looping struct {
  cond *asteval.Expression
  body Statement
}

func NewLooping(cd *asteval.Expression, bd Statement) *Looping {
  return &Looping{cd, bd}
}

Կրկնման հրամանը կատարելու համար նախ հաշվվում է cond արտահայտությունը։ Եթե նրա արժեքը զրոյից տարբեր է, ապա կատարվում է body հրամանների հաջորդականությունը։ Նույնը կրկնվում է այնքան ժամանակ, քանի դեռ cond արտահայտության արժեքը տարբեր է զրոյից։

func (w *Looping) Execute(env *environ.Environment) {
  zr := big.NewInt(0)
  ex := w.cond.Evaluate(env)
  for 0 != ex.Cmp(zr) {
    w.body.Execute(env)
    ex = w.cond.Evaluate(env)
  }
}

Քերականական անալիզատորի ընդլայնումը

Ճյուղավորման հրամանի քերականական կանոնը պահանջում է, որ հրամանը սկսվի if ծառայողական բառով, որին հետևում է պայմանը ներկայացնող արտահայտությունը, ապա then ծառայողական բառը, որին հետևում են այն հրամանները, որոնք կատարվելու են, երբ պայմանի արտահայտության արժեքը հաշվարկվի զրոյից տարբեր։

func (p *Parser) branching() astexec.Statement {
  p.match(scanner.If)
  ex := p.relation()
  p.match(scanner.Then)
  st := p.sequence()
  res := astexec.NewBranching(ex, st)

Ճյուղավորման հրամանի առաջին պայմանին կարող են հաջորդել անսահմանափակ քանակով elseif կառուցվածքներ՝ իրենց համապատասխան հրամանների հաջորդականություններով։

  t := res
  for p.look == scanner.ElseIf {
    p.match(scanner.ElseIf)
    ex = p.relation()
    p.match(scanner.Then)
    st = p.sequence()
    v := astexec.NewBranching(ex, st)
    t.SetElse(v)
    t = v
  }

Եվ հրամանը կարող է ունենալ միակ else ճյուղ, որում թվարկված հրամանները կկատարվեն, եթե մինչև իրեն նշված բոլոր պայմանների արտահայտությունների հաշվարկը վերադարձրել զրոյական արժեք։

  if p.look == scanner.Else {
    p.match(scanner.Else)
    se := p.sequence()
    t.SetElse(se)
  }

Ճյուղավորման հրամանն ավարտվում է end ծառայողական բառով։

  p.match(scanner.End)
  return res
}

Կրկնման հրամանը սկսվում է while ծառայողական բառով, որին հետևում են կրկնման պայմանի արտահայտությունը, do բառը, հրամանի մարմնի հրամանների հաջորդականությունը և ապա end բառը։

func (p *Parser) looping() astexec.Statement {
  p.match(scanner.While)
  ex := p.relation()
  p.match(scanner.Do)
  st := p.sequence()
  p.match(scanner.End)
  return astexec.NewLooping(ex, st)
}

Բնականաբար branching և looping մեթոդներն իրականացնելուց հետո պետք է statement մեթոդում ավելացնել նրանց համապատասխան ճյուղերը։

Օրինակներ

Հետևյալ երկու օրինակները ցուցադրում են ճյուղավորման ու կրկնման հրամանների օգտագործումը։

Առաջին օգտագործողից հարցնում է ինչ-որ թիվ և ամեն մի թվի համար արտածում է պատասխան։

input x;
if x = 1 then
  print 100
elseif x = 2 then 
  print 200
elseif x = 3 then
  print 300
elseif x = 4 then
  print 400
else
  print 100000
end.

Երկրորդ օրինակը հաշվում է 10-ի ֆակտորիալը։

i = 10;
r = 1;
while i do 
  print i;
  r = r * i;
  i = i - 1
end;
print r.

Saturday, July 20, 2013

Լեքսիկական (նիշային) վերլուծություն

Լեքսիկական կամ նիշային վերլուծությունը տեքստի մշակման այն փուլն է, երբ, ըստ նախապես տրված կանոնների, տեստը տրոհվում է առանձին հատվածների և ամեն մի հատվածի վերագրվում է իր տիպին համապատասխան պիտակ։ Օրինակ, թող որ տրված են ինչ-որ ծրագրավորման լեզվի ծրագրերի լեքսիկական վերլուծության հետևյալ մի քանի (ոչ լրիվ) կանոնները․

  1. if, then, else, print բառերը ծառայողական բառեր են՝ ամեն մեկն իր անունով,

  2. Տառով սկսվող և տառերից ու թվերից բաղկացած սիմվոլների անընդհատ հաջորդականությունը իդենտիֆիկատոր է,

  3. Թվանշանների անընդհատ հաջորդականությունը ամբողջ թիվ է,

  4. Երկու չակերտներով պարփակված և չակերտ չպարունակող տեքստի հատվածը տող է,

  5. >, >=, <, <= և այլն, համեմատման գործողություններ են՝ ամեն մեկն իր անունով։

Եվ պետք է ըստ այս կանոնների լեքսիկական վերլուծության ենթարկել հետևյալ տեքստը․

if num >= 10 then
    print "Test done"

Սիմվոլների առաջին անընդհատ հատվածը if բառն է, որը, ըստ տրված կանոնների, ծառայողական բառ է։ Հաջորդ հատվածը num բառն է, որը չկա ծառայողական բառերի ցուցակում և, ըստ երկրորդ կանոնի, պետք է համարել իդենտիֆիկատոր։ >= սիմվոլների հաջորդականությունը մեծ է կամ հավասար համեմատման գործողությունն է, ըստ հինգերորդ կանոնի։ Ըստ երրորդ կանոնի 10 հաջորդականութթյունը ամբողջ թիվ է։ then և print բառերը նորից ծառայողական բառեր են։ Իսկ Test done հատվածը, ընդ որում՝ առանց ընդգրկող չակերտների, տող է։

Ընդունված տերմինաբանությամբ նախապես տրված տեքստից առանձնացված հատվածները կոչվում են լեքսեմներ (lexeme), իսկ նրանց համապատասխանեցված պիտակները՝ տոկեններ (token) կամ թոքեններ։ Եթե բերված օրինակի համար կազմենք լեքսեմների ու թոքեների աղյուսակը, ապա այն կունենա այսպիսի տեսք․

Լեքսեմ Թոքեն
if IF
num IDENTIFIER
>= GE
10 INTEGER
then THEN
print PRINT
Test done STRING

Լեքսիկական վերլուծության ժամանակ նիշ առ նիշ անցում է կատարվում վերլուծության ենթական տեքստով և փորձ է կատարվում ճանաչել վերլուծության կանոններին համապատասխանող նիշերի ամենաերկար հաջորդականությունը։ Կարելի է լեքսիկական անալիզատորը ներկայացնել որպես վերջավոր ավտոմատ, և հմնականում հենց այդպես էլ արվում է։ Ավտոմատի սկզբնակական վիճակից, կողմնորոշվելով հերթական սիմվոլով, ընտրվում է վերլուծության համապատասխան կանոնը։ Այնուհետև ավտոմատն աշխատում և փոխում է իր վիճակներն այնքան ժամանակ, քանի դեռ չի հասել վերջնական վիճակի կամ քանի դեռ չի ավարտվել վերլուծության ենթարկվող տողը։ Առաջին դեպքում համարվում է, որ թոքենը ճանաչվել է, և ավտոմատը վերադառնում է սկզբնական վիճակին՝ նոր թոքեն ճանաչելու փորձ կատարելու համար։ Երկրորդ դեպքում, երբ ավարտվել է տողի ընթերցումը, բայց ավտոմատը չի հասել որևէ վերջնական վիճակի, կա՛մ կարելի է տալ սխալի մասին ազդանշան, կա՛մ պահանջել ևս մի տող՝ վերլուծությունը շարունակելու համար։

Ստորև բերված է մի դետերմինացված վերջավոր ավտոմատի (DFA) ուրվապատկեր, որը «կարողանում է ճանաչել» իդենտիֆիկատորները, չակերտների մեջ վերցրած տողերը և ամբողջ թվերը։

Հերթական սիմվոլը կարդալուց հետո «1» համարով վիճակում որոշում է կայացվում, թե որ ուղղությամբ պետք է շարժվել, իսկ «3», «4» և «5» վիճակները ցույց են տալիս, թե ինչ տիպի տեքստ է ճանաչվել։


Լեքսիկական վերլուծության ժամանակ հոսքից տվյալների ընթերցումը կատարվում է նիշ առ նիշ։ Այդ նիշերն իրար կցելու և ամբողջական տող ստանալու համար է սահմանված char-list-to-string ֆունկցիան․

(defun char-list-to-string (chars)
  (apply #'concatenate 'string (mapcar #'string chars)))

Ամենատարածված դեպքերն են, երբ երբ հոսքից պետք է կարդալ կա՛մ որևէ տիպի նիշերի շարք, կա՛մ տրված երկու նիշերի միջև պարփակված տեքստ։ Օրինակ, եթե հոսքի ընթացիկ նիշը տառ է՝ alpha-char-p, ապա պետք կարդալ նրան հաջորդող բոլոր տառերն ու թվանշանները՝ alphanumericp, ապա պարզել կարդացածը իդենտիֆիկատո՞ր է, թե՞ ծառայողական բառ։ Տրված պրեդիկատին բավարարող նիշերի շղթա կարդալու համար սահմանված է scan-sequence ֆունկցիան։

(defun scan-sequence (fu sm)
  (char-list-to-string
    (loop for ch = (read-char sm nil)
          until (null ch)
          while (funcall fu ch)
          collect ch
          finally (when ch (unread-char ch sm)))))

Իսկ տրված երկու նիշերի մեջ պարփակված նիշերի շարքը կարդալու համար սահմանված է scan-quoted ֆունկցիան։

(defun scan-quoted (co ci sm)
  (if (char= co (read-char sm nil))
    (prog1 
      (scan-sequence #'(lambda (c) (char/= c ci)) sm)
      (read-char sm nil))))

Հիմանականում անհրաժեշտ է լինում ընթերցման հոսքից հեռացնել իմաստ չպարունակող բացատանիշերը (white spaces)։ Որպեսզի հնարավոր լինի scan-sequence ֆունկցիայով կարդալ իրարա հաջորդող բացատանիշերը, սահմանված են space-char-p պրեդիկատը և skip-white-spaces ֆունկցիան։

(defun space-char-p (c)
  (or (char= c #\Newline)
      (char= c #\Tab)
      (char= c #\Space)
      (char= c #\Return)))
(defun skip-white-spaces (stream)
  (scan-sequence #'space-char-p stream))

Իդենտիֆիկատորներն ու ծառայողական բառերը կարդացվոլու են նույն կանոնով։ +keywords+ ցուցակում առանձնացված են այն լեքսեմները, որոք ծառայողական բառեր են, և նրանցից ամեն մեկին համապատասխանեցված է իր թոքենը։

(defconstant +keywords+
  '(("if"    . :lex-if)
    ("then"  . :lex-then)
    ("else"  . :lex-then)
    ("for"   . :lex-for)
    ("while" . :lex-while)
    ("input" . :lex-input)
    ("print" . :lex-print)))

oper-char-p պրեդիկատը դրական պատասխան է տալիս իր արգումենտում տրված այն նիշերի համար, որոնցով կարելի է կազմել հարաբերության կամ թվաբանական գործողություններ։

(defun oper-char-p (c)
  (member c '(#\= #\> #\< #\+ #\- #\* #\/ #\%)))

Իսկ +operations+ ցուցակում հավաքված են այն գործողությունների նշանները, որոնք կազմված են open-char-p պրեդիկատին բավարարող նիշերից (կամ դրանց համակցությունից)։

(defconstant +operations+
  '(("="  . :lex-equal)
    ("<>" . :lex-not-equal)
    (">"  . :lex-greater)
    (">=" . :lex-great-equal)
    ("<"  . :lex-lesser)
    ("<=" . :lex-less-equal)
    ("+"  . :lex-add)
    ("-"  . :lex-sub)
    ("*"  . :lex-mul)
    ("/"  . :lex-div)
    ("%"  . :lex-mod)))

Պարզելու համար, թե արդյոք կարդացած լեքսեմը պատկանո՞ւմ է +keywords+ կամ +operations+ ցուցակներից որևէ մեկին, սահմանված է known-lexeme ֆունկցիան։

(defun known-lexeme (lexeme pairs &optional (default :lex-unknown))
  (let ((res (find lexeme pairs :test #'equal :key #'first)))
    (if res res (cons lexeme default))))

Եվ վերջապես, լեքսիկական (նիշային) վերլուծություն կատարող scan-lexeme հիմնական ֆունկցիան։ Այն նախապես ընթերցման հոսքից հեռացնում է բացատանիշերը։ Ապա, ըստ դեն նետված բացատանիշերին հաջորդող առաջին նիշի, որոշում է կայացնում, թե ինչ լեքսեմ պետք է կարդալ։

(defun scan-lexeme (stream)
  (skip-white-spaces stream)
  (let ((ch (peek-char nil stream nil)))
    (cond
      ((null ch)
       (cons "<EOS>" :lex-eos))
      ((alpha-char-p ch)
       (known-lexeme (scan-sequence #'alphanumericp stream)
             +keywords+ :lex-identifier))
      ((digit-char-p ch)
       (cons (scan-sequence #'digit-char-p stream)
         :lex-integer))
      ((oper-char-p ch)
       (known-lexeme (scan-sequence #'oper-char-p stream)
             +operations+))
      ((char= #\" ch)
       (cons (scan-quoted #\" #\" stream)
         :lex-string))
      (t (cons (read-char stream nil) :lex-character)))))

Վերջ։ Հիմա պատրաստենք մի ֆայլ, որ պարունակում է տեստային տվյալներ։ Անվանենք ֆայլը test0.txt և նրանում գրառենք հետևյալը․

" asda asdkak "
1234
abcd3434jf
if
then
=
>
<>
%
$
#

Այնուհետև Common Lisp միջավայրում, հաշվարկենք հետևյալ կոդը․

(with-open-file (inp "test0.txt" :direction :input)
  (loop for lex = (scan-lexeme inp)
        until (eq (cdr lex) :lex-eos)
        do (print lex)))
(terpri)

Կատարման արդյունքում պետք է արտաշվի հետևյալը․

(" asda asdkak " . :LEX-STRING) 
("1234" . :LEX-INTEGER) 
("abcd3434jf" . :LEX-IDENTIFIER) 
("if" . :LEX-IF) 
("then" . :LEX-THEN) 
("=" . :LEX-EQUAL) 
(">" . :LEX-GREATER) 
("<>" . :LEX-NOT-EQUAL) 
("%" . :LEX-MOD) 
(#\$ . :LEX-CHARACTER) 
(#\# . :LEX-CHARACTER) 

Որն էլ ցույց է տալիս, որ կառուցված լեքսիկական (նիշային) վերլուծությունը ճիշտ է աշխատում։

Մի քանի հին ֆայլեր

Ծրագրավորման լեզու վերջավոր ավտոմատների համար

Բեյսիկ լեզվի ինտերպրետատոր

Գծապատկերների կառուցման լեզու

Շատ փոքր ծրագրավորման լեզու