JParser (9) Abstract Syntax Tree 프로세서

파스 트리는 말그대로 파스의 결과를 나타내는 것으로, 인풋 스트링의 모든 글자에 대한 모든 derivation의 정보를 모두 포함한다. 하지만 실제로 파싱 결과를 이용할 때는 보통 파스 트리 정보 중 관심 있는 일부 정보만 필요하다. 예를 들어 컴파일러는 프로그램에 포함된 주석같은 것에는 관심이 없다. 그래서 파스 트리를 가공한 abstract syntax tree, 줄여서 AST라는 것을 도입하게 된다.

간단한 문법도 직접 파스 트리를 AST로 바꾸는 코드를 만들려면 코드가 간단치 않다. 복잡하다기보단 코드 양이 많다. 앞에서부터 계속 사용해오고 있는 다음의 예제를 보자.

Expr = Term WS '+' WS Expr
     | Term
Term = Factor WS '*' WS Term
     | Factor
Factor = '0-9'
     | '(' WS Expr WS ')'
WS = ' '*

이 문법으로 파싱한 결과로 나온 파스 트리를 AST로 바꾸려는 경우를 생각해보자. 먼저 AST 자료 구조를 정의해야 한다. Expr 심볼은 덧셈 연산 수식을 나타낼 수 있고, Term 심볼이 될 수도 있다. Term 심볼은 곱셈 연산 수식을 나타낼 수 있고, Factor 심볼일 수 있다. Factor는 한자리 숫자이거나 괄호로 묶인 Expr일 수 있다. 이 정보를 자료 구조로 나타낸다면 다음과 같은 형태가 될 것이다.

sealed trait Expr
case class Add(lhs: Term, rhs: Expr) extends Expr
sealed trait Term extends Expr
case class Multiply(lhs: Factor, rhs: Term) extends Term
sealed trait Factor extends Term
case class Number(value: String) extends Factor
case class Paren(body: Expr) extends Factor

Paren 클래스가 별도로 있는 것은 Term이나 Factor로 들어와서 괄호 수식이 나온 경우, 즉 (1)+2와 같은 경우 (1) 부분은 바로 Term이 되어 Expr값을 가질 수 없기 때문이다.

클래스 구조를 잡은 뒤엔 파스 결과를 받아서 이들 클래스 값으로 변환하는 코드도 작성해야 한다.

def matchExpr(node: ParseResultTree.Node): Expr = {
  val BindNode(v1, v2) = node
  val v12 = v1.id match {
    case 3 =>
      val v3 = v2.asInstanceOf[SequenceNode].children.head
      val BindNode(v4, v5) = v3
      assert(v4.id == 4)
      val v6 = v2.asInstanceOf[SequenceNode].children(4)
      val BindNode(v7, v8) = v6
      assert(v7.id == 2)
      Add(matchTerm(v5), matchExpr(v8))
    case 21 =>
      val v9 = v2.asInstanceOf[SequenceNode].children.head
      val BindNode(v10, v11) = v9
      assert(v10.id == 4)
      matchTerm(v11)
  }
  v12
}

“case 3” 이후의 경우는 Term WS '+' WS Expr로 매치된 경우이고 “case 21”의 경우는 Term으로 매치된 경우이다. 중간 중간 등장하는 숫자들은 NGrammar로 변환된 심볼의 ID이다.

여기서 끝이 아니라 matchExpr과 비슷한 matchTerm, matchFactor 등의 함수도 만들어야 한다. 이런 단순한 문법 하나도 처리하려면 이렇게 많은 작업이 필요하다.

작업의 양이 많은 것 외에도, 이런 코드를 만들 때 또 한가지 문제는, CDG 문법이 실제 Grammar 클래스로 변환되는 과정이 사용자에겐 헷갈릴 수 있다는 점이다. 위의 matchExpr 함수에서 case 21의 경우, RHS에는 Term이라는 심볼 하나밖에 없지만 실제 Grammar 함수로 변환될 때 Term 심볼 하나를 가진 시퀀스로 변환되었기 때문에 .asInstanceOf[SequenceNode].children.head와 같은 코드가 포함된 것을 볼 수 있다.

다행히 이런 작업들은 모두 자동화될 수 있다. 물론 사용자가 원하는 AST가 어떤 형태인지는 사용자가 지정해주어야 하고, 그 정보로부터 위와 같이 클래스 구조를 정의하고, 파스 노드를 AST 클래스로 변경하는 코드를 생성해주는 기능이 jparser에 구현되어 있다. 사용자는 문법 정의에 자신이 원하는 AST 구조에 대한 정보를 입력하는데, 그 기능을 앞으로 AST 프로세서라고 부를 것이다.

AST 프로세서 문법

AST 프로세서는 derivation rule의 RHS에 다른 심볼들과 같이 등장한다. AST 프로세서는 $0과 같이 $ 기호 뒤에 숫자가 오는 형태이거나 {Abc(value=ispresnet($1) ? str($0):"foo")}와 같이 중괄호로 묶인 수식의 형태이다.

예제를 보면서 이야기해보자.

Expr: Expr = Term WS '+' WS Expr {Add(lhs=$0, rhs=$4)}
           | Term
Term: Term = Factor WS '*' WS Term {Multiply(lhs=$0, rhs=$4)}
           | Factor
Factor: Factor = '0-9' {Number(value=str($0))}
           | '(' WS Expr WS ')' {Paren(body=$2)}
WS = ' '*

앞서 살펴보았던 문법에 AST 프로세서들을 추가하였다. 앞서 보았던 자료 구조와 matchExpr 함수는 사실 jparser가 이 문법으로부터 생성해낸 코드(를 살짝 수정한 것)이다.

Expr: Expr에서 콜론 왼쪽은 넌터미널 이름이고 오른쪽은 타입의 이름이다. 심볼의 이름과 클래스 이름의 별도의 세계에 존재하는 것이기 때문에 이름이 동일해도 상관 없다. Expr이라는 넌터미널은 Expr 클래스로 변환됨을 명시적으로 정의한 것이다. 이런 명시적 타입을 생략할 수 있는 경우도 있는데 이 경우엔 필요하다(왜 필요한지는 다음 포스팅에서 자세히 살펴보자).

Expr 넌터미널의 첫번째 RHS Term WS '+' WS Expr {Add(lhs=$0, rhs=$4)}에서 $0은 그 시퀀스의 첫번째 심볼(Term 심볼)의 값을 나타내고, $4는 다섯번째 심볼(Expr 심볼)의 값을 나타낸다. Add는 클래스의 이름이고, lhsrhs는 클래스의 멤버 변수 이름이다. 따라서 Add(lhs=$0, rhs=$4)는 이 RHS가 매치되는 경우, 첫번재 심볼인 Term의 결과를 lhs라는 이름의 멤버 변수로, 다섯번째 심볼인 Expr의 결과를 rhs라는 이름의 멤버 변수로 갖는 Add라는 클래스를 만든다는 의미이다. 보다시피 별도로 클래스 정의는 필요 없으며, AST 프로세서 정의에 나오는 클래스의 이름들과 멤버 변수들의 이름들을 취합하고 멤버 변수들의 타입을 유추해서 데이터 구조를 생성해준다.

시퀀스 심볼에는 프로세스건 심볼이건 가장 마지막 것의 값을 시퀀스 전체를 대표하는 값으로 본다. 그래서 Term WS '+' WS Expr {Add(lhs=$0, rhs=$4)}라는 시퀀스의 실행 결과는 마지막 프로세서의 실행 결과가 된다.

Expr: Expr = TermTerm: Term으로부터 Expr 타입이 Term 타입의 값을 받을 수 있어야함을 알 수 있다. 이는 Expr 클래스가 Term 클래스의 상위 클래스임을 뜻한다. 문법 전체에 이런 식으로 정보를 취합하여 클래스 정의에 반영한다. Term 클래스는 Factor의 상위 클래스여야하고, Factor 클래스는 NumberParen의 상위 클래스여야 한다.

Factor의 첫번째 RHS에는 {Number(value=str($0))}이라는 수식이 있다. 예상대로 str$0을 스트링으로 바꿔주는 빌트인 함수이다.

같은 문법으로부터 AST를 다른 형태로 추출할 수도 있다. 다음의 예를 보자.

Expr: Expr = Term WS '+' WS Expr {BinOp(op:%Op=%Add, lhs=$0, rhs=$4)}
           | Term
Term: Term = Factor WS '*' WS Term {BinOp(op=%Mul, lhs=$0, rhs=$4)}
           | Factor
Factor: Factor = '0-9' {Number(value=str($0))}
           | '(' WS Expr WS ')' {Paren(body=$2)}
WS = ' '*

문법은 완전히 동일하지만 덧셈과 곱하기 연산 수식을 나타내는 방식이 바뀌었다. BinOp이라는 클래스를 만들었고 그 클래스에 op이라는 필드를 추가했다. %Op과 같이 %가 앞에 붙은 것은 enum 타입의 이름이다. %Op.Add, %Op.Mul과 같이 enum의 이름을 적을 수 있는데, %Op 타입인것이 분명한 경우엔 %Add, %Mul과 같이 타입명을 생략해도 된다. 각 enum 타입에 어떤 값이 지정되었는지 취합해서 enum 타입 정의에 반영해 준다.

sealed trait Expr
case class BinOp(op: Op.Value, lhs: Term, rhs: Expr) extends Term
object Op extends Enumeration { val Add, Mul = Value }
sealed trait Term extends Expr
sealed trait Factor extends Term
case class Number(value: String) extends Factor
case class Paren(body: Expr) extends Factor

Add, Multiply 클래스가 사라지고 Op이라는 enum 타입과 BinOp 클래스가 생성되었다.

데이터 타입

AST 프로세서에는 다음과 같은 타입이 있다.

null도 지원되며, 타입 뒤에 물음표를 붙여서 명시적으로 nullable로 정의할 수 있다. nullable로 명시되지 않은 타입은 null값을 가질 수 없다. 숫자 타입은 없다. any 타입도 지정할 수 있지만 나는 한 번도 써본 적이 없다.

AST 프로세싱 규칙

AST 프로세서를 지정하지 않으면 심볼은 타입에 따라 다음과 같이 처리된다.

앞서 말한대로 시퀀스는 시퀀스의 마지막 element 값을 따라간다. 시퀀스의 마지막 element가 프로세서면 프로세서가 반환하는 값, 심볼이면 심볼의 값이 된다.

AST 프로세서 expression

AST 프로세서를 포함한 CDG 문법

다음은 AST 프로세서 기능을 포함한 CDG의 문법을 AST 프로세서 기능을 포함한 CDG로 정의한 것이다. (MetaLang3Grammar.scala) 실제 jparser 구현체에서 사용하는 문법 정의이다. 중간 중간 문법에 대한 해석을 붙여 놓았다.

Grammar = WS Def (WSNL Def)* WS {Grammar(defs=[$1] + $2)}
Def: Def = Rule | TypeDef

Rule = LHS WS '=' WS (RHS (WS '|' WS RHS)* {[$0] + $1}) {Rule(lhs=$0, rhs=$4)}
  // $4는 괄호 안 시퀀스의 마지막 값인 [$0] + $1 을 가리킨다. $4{[$0] + $1} 라고 바꿔 쓸 수 있다.
LHS = Nonterminal (WS ':' WS TypeDesc)? {LHS(name=$0, typeDesc=$1)}
  // LHS의 typeDesc는 TypeDesc 심볼의 타입(TypeDesc 클래스)의 nullable 타입이다.
RHS = Sequence
Sequence = Elem (WS Elem)* {Sequence<Symbol>(seq=[$0] + $1)}
  // Sequence<Symbol>이라고 쓰면 Sequence 타입이 Symbol 타입을 상속받아야 함을 명시한 것
  // $0은 Elem이고 $1은 Elem의 리스트 타입이므로 둘을 이어 붙이려면 [$0] + $1 라고 써야 한다.
Elem: Elem = Symbol | Processor


// Symbol
Symbol: Symbol = BinSymbol
BinSymbol: BinSymbol = BinSymbol WS '&' WS PreUnSymbol {JoinSymbol(body=$0, join=$4)}
  | BinSymbol WS '-' WS PreUnSymbol {ExceptSymbol(body=$0, except=$4)}
  | PreUnSymbol
PreUnSymbol: PreUnSymbol = '^' WS PreUnSymbol {FollowedBy(followedBy=$2)}
  | '!' WS PreUnSymbol {NotFollowedBy(notFollowedBy=$2)}
  | PostUnSymbol
PostUnSymbol: PostUnSymbol = PostUnSymbol WS '?' {Optional(body=$0)}
  | PostUnSymbol WS '*' {RepeatFromZero(body=$0)}
  | PostUnSymbol WS '+' {RepeatFromOne(body=$0)}
  | AtomSymbol
AtomSymbol: AtomSymbol = Terminal
  | TerminalChoice
  | StringSymbol
  | Nonterminal
  | '(' WS InPlaceChoices WS ')' $2  // $2 대신 {$2}라고 써도 같은 의미이다.
  | Longest
  | EmptySequence
Terminal: Terminal = '\'' TerminalChar '\'' $1
  | '.' {AnyTerminal()}  // 인자를 받지 않는 클래스도 있을 수 있다
TerminalChoice: TerminalChoice = '\'' TerminalChoiceElem TerminalChoiceElem+ '\'' {TerminalChoice(choices=[$1] + $2)}
  | '\'' TerminalChoiceRange '\'' {TerminalChoice(choices=[$1])}
  // 클래스의 멤버 변수 이름과 순서는 한 번만 정의하면 된다.
  // 즉, 위에서 TerminalChoice가 choices라는 하나의 파라메터를 받는 것을 알 수 있으니 두번째는 TerminalChoice([$1]) 라고만 써도 된다.
TerminalChoiceElem: TerminalChoiceElem = TerminalChoiceChar
  | TerminalChoiceRange
TerminalChoiceRange = TerminalChoiceChar '-' TerminalChoiceChar {TerminalChoiceRange(start=$0, end=$2)}
StringSymbol = '"' StringChar* '"' {StringSymbol(value=$1)}
Nonterminal = NonterminalName {Nonterminal(name=$0)}
InPlaceChoices = Sequence (WS '|' WS Sequence)* {InPlaceChoices(choices=[$0] + $1)}
Longest = '<' WS InPlaceChoices WS '>' {Longest(choices=$2)}
EmptySequence = '#' {EmptySeq()}

TerminalChar: TerminalChar = .-'\\' {CharAsIs(value=$0)}
  | '\\' '\'\\bnrt' {CharEscaped(escapeCode=$1)}
  | UnicodeChar
TerminalChoiceChar: TerminalChoiceChar = .-'\'\-\\' {CharAsIs(value=$0)}
  | '\\' '\'\-\\bnrt' {CharEscaped(escapeCode=$1)}
  | UnicodeChar
StringChar: StringChar = .-'"\\' {CharAsIs(value=$0)}
  | '\\' '"\\bnrt' {CharEscaped(escapeCode=$1)}
  | UnicodeChar
UnicodeChar = '\\' 'u' '0-9A-Fa-f' '0-9A-Fa-f' '0-9A-Fa-f' '0-9A-Fa-f' {CharUnicode(code=[$2, $3, $4, $5])}


// Processor
Processor: Processor = Ref
  | PExprBlock

Ref: Ref = ValRef | RawRef
ValRef = '$' CondSymPath? RefIdx {ValRef(idx=$2, condSymPath=$1)}
CondSymPath: [%CondSymDir{BODY, COND}] = ('<' {%BODY} | '>' {%COND})+
  // %CondSymDir{BODY, COND} 라고 쓰면 CondSymDir이란 enum 타입의 멤버 값이 BODY와 COND가 있다는 의미이다.
  // enum의 멤버 값을 이렇게 명시하면 명시된 값만 사용할 수 있다. 문법 정의의 다른 곳에서 명시되지 않은 값을 쓰려고 할 때 오류가 발생한다.
RawRef = "\\$" CondSymPath? RefIdx {RawRef(idx=$2, condSymPath=$1)}

PExprBlock = '{' WS PExpr WS '}' {ProcessorBlock(body=$2)}
PExpr: PExpr = TernaryExpr WS ':' WS TypeDesc {TypedPExpr(body=$0, typ=$4)}
  | TernaryExpr
TernaryExpr: TernaryExpr = BoolOrExpr WS '?' WS <TernaryExpr> WS ':' WS <TernaryExpr> {TernaryOp(cond=$0, ifTrue=$4, ifFalse=$8)}
  | BoolOrExpr
BoolOrExpr: BoolOrExpr = BoolAndExpr WS "&&" WS BoolOrExpr {BinOp(op=%Op.AND, lhs=$0, rhs=$4)}
  | BoolAndExpr
BoolAndExpr: BoolAndExpr = BoolEqExpr WS "||" WS BoolAndExpr {BinOp(op=%Op.OR, lhs=$0, rhs=$4)}
  | BoolEqExpr
BoolEqExpr: BoolEqExpr = ElvisExpr WS ("==" {%Op.EQ} | "!=" {%Op.NE}) WS BoolEqExpr {BinOp(op=$2, lhs=$0, rhs=$4)}
  | ElvisExpr
ElvisExpr: ElvisExpr = AdditiveExpr WS "?:" WS ElvisExpr {ElvisOp(value=$0, ifNull=$4)}
  | AdditiveExpr
AdditiveExpr: AdditiveExpr = PrefixNotExpr WS ('+' {%Op.ADD}) WS AdditiveExpr {BinOp(op=$2, lhs=$0, rhs=$4)}
  | PrefixNotExpr
PrefixNotExpr: PrefixNotExpr = '!' WS PrefixNotExpr {PrefixOp(op=%PreOp.NOT, expr=$2)}
  | Atom
Atom: Atom = Ref
  | BindExpr
  | NamedConstructExpr
  | FuncCallOrConstructExpr
  | ArrayExpr
  | Literal
  | EnumValue
  | '(' WS PExpr WS ')' {ExprParen(body=$2)}

BindExpr = ValRef BinderExpr {BindExpr(ctx=$0, binder=$1)}
BinderExpr: BinderExpr = Ref
  | BindExpr
  | PExprBlock
NamedConstructExpr = TypeName (WS SuperTypes)? WS NamedConstructParams {NamedConstructExpr(typeName=$0, params=$3, supers=$1)}
NamedConstructParams = '(' WS (NamedParam (WS ',' WS NamedParam)* WS {[$0] + $1}) ')' $2
NamedParam = ParamName (WS ':' WS TypeDesc)? WS '=' WS PExpr {NamedParam(name=$0, typeDesc=$1, expr=$5)}
FuncCallOrConstructExpr = TypeOrFuncName WS CallParams {FuncCallOrConstructExpr(funcName=$0, params=$2)}
CallParams: [PExpr] = '(' WS (PExpr (WS ',' WS PExpr)* WS {[$0] + $1})? ')' {$2 ?: []}
  // $2는 PExpr 리스트의 nullable 타입이다. elvis 연산자로 $2가 null이면 빈 리스트를 반환하도록 하였다.
ArrayExpr = '[' WS (PExpr (WS ',' WS PExpr)* WS)? ']' {ArrayExpr(elems: [PExpr]=$2{[$0] + $1} ?: [])}
Literal: Literal = "null" {NullLiteral()}
  | ("true" {true} | "false" {false}) {BoolLiteral(value=$0)}
  | '\'' CharChar '\'' {CharLiteral(value=$1)}
  | '"' StrChar* '"' {StrLiteral(value=$1)}
EnumValue: AbstractEnumValue = <CanonicalEnumValue | ShortenedEnumValue>
CanonicalEnumValue = EnumTypeName '.' EnumValueName {CanonicalEnumValue(enumName=$0, valueName=$2)}
ShortenedEnumValue = '%' EnumValueName {ShortenedEnumValue(valueName=$1)}
  // ShortenedEnumValue는 값이 enum type이 무엇인지 확실할 때만 사용할 수 있다.


// TypeDef
TypeDef: TypeDef = ClassDef
  | SuperDef
  | EnumTypeDef
ClassDef: ClassDef = TypeName WS SuperTypes {AbstractClassDef(name=$0, supers=$2)}
  | TypeName WS ClassParamsDef {ConcreteClassDef(name=$0, supers: [TypeName]?=null, params=$2)}
  | TypeName WS SuperTypes WS ClassParamsDef {ConcreteClassDef(name=$0, supers=$2, params=$4)}
SuperTypes: [TypeName] = '<' WS (TypeName (WS ',' WS TypeName)* WS {[$0] + $1})? '>' {$2 ?: []}
ClassParamsDef: [ClassParamDef] = '(' WS (ClassParamDef (WS ',' WS ClassParamDef)* WS {[$0] + $1})? WS ')' {$2 ?: []}
ClassParamDef = ParamName (WS ':' WS TypeDesc)? {ClassParamDef(name=$0, typeDesc=$1)}

SuperDef = TypeName (WS SuperTypes)? WS '{' (WS SubTypes)? WS '}' {SuperDef(typeName=$0, subs=$4, supers=$1)}
SubTypes = SubType (WS ',' WS SubType)* {[$0] + $1}
SubType: SubType = TypeName | ClassDef | SuperDef

EnumTypeDef = EnumTypeName WS '{' WS (Id (WS ',' WS Id)* {[$0] + $1}) WS '}' {EnumTypeDef(name=$0, values=$4)}


// TypeDesc
TypeDesc = NonNullTypeDesc (WS '?')? {TypeDesc(typ=$0, optional=ispresent($1))}
NonNullTypeDesc: NonNullTypeDesc = TypeName
  | '[' WS TypeDesc WS ']' {ArrayTypeDesc(elemType=$2)}
  | ValueType
  | AnyType
  | EnumTypeName
  | TypeDef

ValueType: ValueType = "boolean" {BooleanType()}
  | "char" {CharType()}
  | "string" {StringType()}
AnyType = "any" {AnyType()}
EnumTypeName = '%' Id {EnumTypeName(name=str($1))}


// Common
TypeName = IdNoKeyword {TypeName(name=$0)}
  | '`' Id '`' {TypeName(name=$1)}
NonterminalName = IdNoKeyword {NonterminalName(name=$0)}
  | '`' Id '`' {NonterminalName(name=$1)}
TypeOrFuncName = IdNoKeyword {TypeOrFuncName(name=$0)}
  | '`' Id '`' {TypeOrFuncName(name=$1)}
ParamName = IdNoKeyword {ParamName(name=$0)}
  | '`' Id '`' {ParamName(name=$1)}
EnumValueName = Id {EnumValueName(name=$0)}
Keyword: %KeyWord = "boolean" {%BOOLEAN}
  | "char" {%CHAR}
  | "string" {%STRING}
  | "true" {%TRUE}
  | "false" {%FALSE}
  | "null" {%NULL}
  // Keyword가 %Keyword 타입이므로 Keyword의 RHS에 나오는 값들은 모두 %Keyword 타입임을 알 수 있다.
StrChar = StringChar
CharChar = TerminalChar

RefIdx = <'0' | '1-9' '0-9'*> {str(\$0)}
  // \$0라고 쓰면 $0의 parse tree를 있는대로 반환한다.
  // 여기서 \$0 대신 $0라고 쓰면 '1-9' '0-9'*에서 '0-9'*만 반영될 것이다.
Id = <'a-zA-Z_' 'a-zA-Z0-9_'*> {str(\$0)}
IdNoKeyword = Id-Keyword {str(\$0)}
WS = (' \n\r\t' | Comment)*
WSNL = <(' \r\t' | Comment)* '\n' WS>
Comment = LineComment | BlockComment
LineComment = "//" (.-'\n')* (EOF | '\n')
BlockComment = "/*" ((. !"*/")* .)? "*/"
EOF = !.
  // !.는 파일의 마지막을 의미한다.
  // .은 모든 글자에 매치되므로, !.가 나오면 다음에 어떤 글자가 와도 매치될 수 없기 때문이다.

전체 목차:

다음 포스팅에서는 parse tree에서 AST 프로세서 코드를 생성하는 원리를 코드와 함께 살펴보자.

이 페이지에서 오류나 문제점을 발견하시면 이메일로 알려주시면 감사하겠습니다.
뒤로