AWKGo, an AWK-to-Go compiler

Ben Hoyt

AWKGo, AWK를 Go로 변환하는 컴파일러

원문은 Ben Hoyt님이 에 게재했습니다. 이 블로그 구독하기

저는 스스로를 너드 스나이프할 정도로 괴짜입니다.

Go로 작성된 AWK 인터프리터인 GoAWK의 저자로서, 문득 AWK 프로그램을 Go 코드로 번역하는 게 얼마나 어려울지 궁금해졌습니다. 그리 어렵진 않겠지, 그렇죠? 그래서 한번 해보기로 했습니다.

특별히 어렵진 않았습니다 — 적어도 제 컴파일러가 지원하는 AWK의 부분 집합에 대해서는요 — 그리고 GoAWK의 파서와 수많은 테스트를 재활용할 수 있었습니다. 창의적인 작명 센스를 뽐내기 위해 이 컴파일러를 AWKGo라 부르기로 했습니다.

이 글의 나머지 부분에서는 AWKGo가 하는 일과 동작 방식, 작성하고 테스트하면서 배운 흥미로운 점 몇 가지, 그리고 출력 결과 예시를 소개합니다. 생성된 코드의 성능도 간략히 살펴보겠습니다.

GitHub에서 AWKGo 소스를 볼 수 있습니다.

지원 범위

AWKGo는 AWK의 유용한 부분 집합을 지원하지만, 여러 면에서 AWK의 의미론과 차이가 있습니다. 대부분의 작은 AWK 스크립트는 문제없이 동작하겠지만, 일부는 약간의 수정이 필요할 수 있습니다.

지원하는 기능은 다음과 같습니다:

  • BEGIN, END 및 패턴-액션 블록(범위 패턴 포함).
  • 제어 구조: if, else, for, while, break 등.
  • 입력의 자동 필드 분할($1, $2 등).
  • printprintf를 이용한 출력 — Go의 bufio 패키지를 이용해 빠르고 버퍼링된 방식으로 동작합니다.
  • 스칼라 및 배열 변수, 사용과 대입은 물론 x+=10 같은 복합 대입과 x++ 같은 증감 연산.
  • FS, NF, OFS 등 자주 쓰이는 특수 변수.
  • AWK와 마찬가지로 자동 타입 변환: 숫자가 문자열 문맥에서 사용되면 자동으로 문자열로 변환되고, 그 반대도 마찬가지입니다.
  • 일반적인 단항 및 이항 연산자(~(정규식 매칭) 포함). 또한 삼항 조건 연산자(예: n!=1?"s":"" — 두 분기의 타입이 같을 때 동작합니다).
  • 대부분의 내장 함수: 수학 함수, split, sprintf, sub, substr 등.

그리고 지원하지 않는 기능은 다음과 같습니다:

  • 동적 타이핑: AWK는 자체적인 동적 타이핑을 사용하지만 Go는 정적 타입 언어입니다. 따라서 AWKGo에서는 변수에 문자열을 대입하면 그 변수는 계속 문자열로 남아 있어야 하고, 숫자를 대입하면 계속 숫자로 남아 있어야 합니다.
  • 숫자형 문자열: 위와 관련하여, AWK에서는 사용자 입력에서 읽은 값이 숫자처럼 보이면 “숫자형 문자열”로 간주되어 문자열 또는 숫자로 모두 취급될 수 있습니다. AWKGo도 올바르게 동작하려 노력하지만, 일단 어떤 값을 문자열(또는 숫자)로 판단하면 그 타입을 유지해야 합니다.
  • Null 값: AWK에서는 설정되지 않은 변수가 “null”이며 출력 시 빈 문자열로 나타납니다. 반면 AWKGo에서는 설정되지 않은 숫자 변수를 0으로 출력합니다.
  • 사용자 정의 함수: AWK 기준으로도 큰 스크립트에서 주로 쓰이며, 동적 타이핑 없이는 구현이 다소 어려웠습니다.
  • 리터럴이 아닌 printf 포맷 문자열: printf("%s %d", k, v)처럼 쓸 수는 있지만 printf(fmt, k, v)처럼 쓸 수는 없습니다. 후자는 간단한 스크립트에서는 드뭅니다.
  • 출력 리다이렉션: AWKGo는 print "foo" >"out.txt" 같은 형식이나 getline을 지원하지 않습니다.
  • 존재하지 않는 배열 요소: POSIX에서는 존재하지 않는 배열 요소를 참조하면 해당 요소가 생성되어야 합니다. 저는 이 기능이 끔찍하다고 생각하며, 어차피 Go의 map 의미론을 따르고 싶었습니다.
  • x = y+=2 같은 일부 복합 대입 형태. 이런 덜 일반적인 구문을 지원하지 못할 이유는 없지만, 그냥 구현하지 않았습니다.
  • ARGCFILENAME 같은 일부 특수 변수. 또한 NF 같은 일부 특수 변수는 AWKGo에서 읽기 전용입니다.

출력 예시

AWKGo의 출력은 어떻게 생겼을까요? 많은 “트랜스파일러”와 마찬가지로 출력 결과가 아름답지도, 관용적이지도 않습니다. 어떤 구문은 잘 변환되지만, 그렇지 않은 경우도 있습니다. 간단한 것부터 조금 더 복잡한 것까지 세 가지 예를 살펴보겠습니다.

매번 동일한 내용이므로, 맨 앞의 import 선언이나 맨 뒤에 포함되는 런타임 헬퍼 함수는 생략하겠습니다.

특별히 흥미롭지 않은 공통 설정 코드도 생략하겠습니다. 전체 코드는 링크에서 볼 수 있지만, 여기서는 컴파일러 출력의 핵심, 즉 가장 중요한 부분만 발췌하겠습니다.

첫 번째 예시는 웹 서버 로그에서 “about” 페이지에 대한 요청을 찾아 4번째 필드를 출력하는 간단한 프로그램입니다:

/about/ { print $4 }

이 코드는 다음과 같이 컴파일됩니다:

func main() {
    _output = bufio.NewWriter(os.Stdout)
    defer _output.Flush()

    _scanner = bufio.NewScanner(os.Stdin)

    // A few lines of setup code elided...

    for _scanner.Scan() {
        _lineNum++
        _line = _scanner.Text()
        _fields = _splitHelper(_line, FS)

        // The is the heart of the translated code
        if _re1.MatchString(_line) {
            fmt.Fprintln(_output, _getField(4))
        }
    }

    if _scanner.Err() != nil {
        fmt.Fprintln(os.Stderr, _scanner.Err())
        os.Exit(1)
    }
}

var (
    _re1 = regexp.MustCompile("about")
)

_splitHelper_getField 같은 헬퍼 함수가 어떻게 정의되어 있는지는 GitHub의 전체 출력에서 볼 수 있지만, 여기서는 각각 대략 strings.Fields(_line)_fields[3]에 해당합니다.

정규식 리터럴(_re1)을 최상위 수준에서 Go의 regexp.MustCompile 함수로 미리 컴파일하고 있다는 점에 주목하세요.

보시다시피 이 예시는 꽤 읽기 쉽고, 직접 Go로 손수 작성한 코드와 크게 다르지 않습니다. AWK에서 I/O 처리(와 대부분의 변수)는 전역 상태이므로, 단순화를 위해 Go에서도 전역 변수로 정의했습니다.

두 번째 예시는 조금 더 복잡하지만 여전히 꽤 현실적인 예시입니다. 텍스트 파일에서 고유한 단어들의 빈도를 세고, 단어와 그 개수를 출력하는 코드입니다:

{
    for (i = 1; i <= NF; i++)
        counts[tolower($i)]++
}

END {
    for (k in counts)
        print k, counts[k]
}

이 코드는 다음과 같이 컴파일됩니다(GitHub의 전체 출력):

func main() {
    // Common setup code elided...

    counts = make(map[string]float64)

    for _scanner.Scan() {
        _lineNum++
        _line = _scanner.Text()
        _fields = _splitHelper(_line, FS)

        // The action (first for loop)
        for i = 1.0; i <= float64(len(_fields)); i++ {
            counts[strings.ToLower(_getField(int(i)))]++
        }
    }

    // Error handling elided...

    // The END for loop
    for k = range counts {
        fmt.Fprintln(_output, k, _formatNum(counts[k]))
    }
}

AWKGo는 문자열과 숫자 두 가지 타입만 인식하므로, 첫 번째 for 루프에 정수만 쓰면 된다는 사실을 감지하지 못합니다 — 모든 숫자는 float64로 정의됩니다. 이는 AWK의 의미론을 단순한 방식으로 반영한 것이지만, Go로 직접 작성한다면 당연히 int를 쓸 것입니다.

AWK의 연관 배열 연산이 꽤 관용적인 Go의 map 사용법으로 변환되는 방식을 눈여겨보세요. AWKGo는 문자열을 키로, 숫자를 값으로 하는 맵을 만들고 있음을 감지합니다. 또한 tolower는 그대로 strings.ToLower로 변환됩니다.

세 번째 예시로는 조금 더 까다로운 코드를 컴파일해 보겠습니다:

$1+0 == $2 { x += ++n }
END { print x }

이 코드는 다음과 같이 컴파일됩니다(GitHub의 전체 출력):

func main() {
    // Common setup code elided...

    for _scanner.Scan() {
        _lineNum++
        _line = _scanner.Text()
        _fields = _splitHelper(_line, FS)

        if _numToStr(_strToNum(_getField(1))+0.0) == _getField(2) {
            x += func() float64 { n++; return n }()
        }
    }

    // Error handling elided...

    fmt.Fprintln(_output, _formatNum(x))
}

그다지 유용한 프로그램은 아닐지 모르지만, 아래에서 더 자세히 다룰 AWKGo의 흥미로운 특징 두 가지를 보여줍니다. 때로는 +0 같은 구문으로 AWKGo의 타입 감지를 유도해 표현식을 숫자(또는 문자열)로 강제해야 합니다. 또한 출력 결과는 AWK에서는 표현식이지만 Go에서는 문장인 증감 연산 같은 것을 어떻게 변환하는지도 보여줍니다. 이에 대해서는 아래에서 더 설명하겠습니다.

타입

AWK는 “문자열 중심 타입(stringly typed)”이라고 불리기도 합니다. 다소 폄하하는 표현이지만, 실제로는 거의 정적 타입에 가깝습니다. “숫자형 문자열” 같은 몇 가지 예외를 제외하면, 명시적인 타입 선언이 전혀 없어도 대부분의 표현식 타입을 컴파일 타임에 파악할 수 있습니다.

사실 저는 언어를 조금만 손보면 AWK 타입을 컴파일 타임에 완전히 결정할 수 있을 것이며, 그렇게 하면 언어가 더 좋아질 것이라고 생각합니다. “숫자형 문자열”은 AWK를 배울 때 이해하기 까다로운 개념 중 하나입니다.

어쨌든 AWKGo의 역할은 동적 타입의 AWK를 정적 타입의 Go로 변환하는 것입니다. 이를 위해 AWK 코드의 각 표현식과 변수의 타입을 결정하는 패스를 수행하는 “typer”가 있습니다.

숫자 리터럴이나 수학 연산이 수행되면 숫자임을 알 수 있고, 문자열 리터럴이나 문자열 연산이 수행되면 문자열임을 알 수 있습니다. typer가 대입문 우변의 타입을 알게 되면, 좌변의 변수도 해당 타입으로 지정합니다.

실제로 어떻게 동작하는지 보여주기 위해 typer 코드의 일부를 발췌했습니다(표현식의 타입을 결정하는 함수에서 가져온 코드입니다):

func (t *typer) expr(expr Expr) (typ valueType) {
    switch e := expr.(type) {
    case *FieldExpr:
        t.expr(e.Index)
        return typeStr

    case *UnaryExpr:
        t.expr(e.Value)
        return typeNum // all unary operators yield num

    case *BinaryExpr:
        t.expr(e.Left)
        t.expr(e.Right)
        if e.Op == CONCAT {
            return typeStr
        }
        return typeNum // all binary operators except CONCAT yield str

    case *InExpr:
        for _, index := range e.Index {
            t.expr(index)
        }
        t.expr(e.Array)
        return typeNum

    case *NumExpr:
        return typeNum // number literal

    case *StrExpr:
        return typeStr // string literal

    ...
}

typer는 구문 트리를 두 번 순회하여, 처음 사용된 이후에 대입되는 변수의 타입도 감지할 수 있도록 합니다. 예를 들어 while (i<5) i++에서 i++가 대입이고 i<5가 사용입니다.

위에서 언급했듯이, AWKGo에서는 때때로 n+0(0을 더하기)처럼 실질적인 연산이 없는 표현식으로 표현식을 숫자로 강제하거나, s ""(빈 문자열 이어 붙이기)로 문자열로 강제해야 합니다. 이는 위에서 본 $1 == $2 같은 “숫자형 문자열”을 비교할 때 필요합니다. AWKGo는 이를 문자열로 비교해야 할지 숫자로 비교해야 할지 알 수 없기 때문입니다. 따라서 $1+0 == $2처럼 써서 숫자로 비교하거나, $1 "" == $2처럼 써서 문자열로 비교한다는 것을 명시해야 합니다. 많은 AWK 스크립트에서는 이미 타입을 알 수 있는 표현식과 비교하므로 이런 트릭이 필요하지 않습니다.

명시적 변환이 필요한 또 다른 경우는 연산 없이 필드를 변수에 직접 대입할 때입니다. 이 경우 AWKGo는 $1 같은 필드를 문자열로 취급하므로, n = $1; n++처럼 작성하면 variable "n" already set to str, can't set to num이라는 오류가 발생합니다. n = $1+0; n++처럼 작성해 타입을 강제로 지정해야 합니다.

컴파일러

AWKGo의 컴파일러는 구문 트리를 다시 순회하면서 Go 코드를 출력하는 단순한 “트리 워커”입니다. 지루한 작업이지만 특별히 화려한 기법이 들어가 있지는 않습니다. 표현식을 컴파일하는 함수의 일부를 맛보기로 보여드리겠습니다:

func (c *compiler) expr(expr Expr) string {
    switch e := expr.(type) {
    case *NumExpr:
        if e.Value == float64(int(e.Value)) {
            return fmt.Sprintf("%d.0", int(e.Value))
        }
        if math.IsInf(e.Value, 0) {
            panic(errorf("number literal out of range"))
        }
        return fmt.Sprintf("%g", e.Value)

    case *StrExpr:
        return strconv.Quote(e.Value)

    case *FieldExpr:
        return "_getField(" + c.intExpr(e.Index) + ")"

    case *VarExpr:
        switch e.Scope {
        case ScopeSpecial:
            return c.special(e.Name, e.Index)
        case ScopeGlobal:
            return e.Name
        default:
            panic(errorf("unexpected scope %v", e.Scope))
        }

    case *RegExpr:
        return fmt.Sprintf("_boolToNum(%s.MatchString(_line))", c.regexLiteral(e.Regex))

    case *BinaryExpr:
        return c.binaryExpr(e.Op, e.Left, e.Right)

    case *IncrExpr:
        exprStr := c.expr(e.Expr) // will be an lvalue (VarExpr, IndexExpr, FieldExpr)
        if e.Pre {
            // Change ++x expression to:
            // func() float64 { x++; return x }()
            return fmt.Sprintf("func() float64 { %s%s; return %s }()",
                exprStr, e.Op, exprStr)
        } else {
            // Change x++ expression to:
            // func() float64 { _t := x; x++; return _t }()
            return fmt.Sprintf("func() float64 { _t := %s; %s%s; return _t }()",
                exprStr, exprStr, e.Op)
        }

    ...
}

패턴-액션, 제어 구조, 대입, tolower() 같은 내장 함수 등을 처리하는 코드가 더 있습니다.

흥미로웠던 문제 중 하나는 x++(위에서 본 예시)처럼 AWK에서는 값으로 쓸 수 있는 표현식이지만 Go에서는 최상위 문장인 구문을 어떻게 처리할지였습니다. 이를 해결하기 위해 위에서 보인 것처럼 x++를 익명 함수 즉시 호출로 바꾸는 기법을 사용했습니다. 이렇게 하면 값을 반환할 수 있고, 함수 본문 안에 Go 문장을 넣을 수도 있습니다.

예를 들어, y = x++를 컴파일한 결과(여러 줄로 나눈 형태)는 다음과 같습니다:

y = func() float64 {
    _t := x // temp variable to store current x
    x++
    return _t
}()

하지만 컴파일러는 증감이나 대입 표현식이 문장으로 사용될 때는 “최적화”를 적용합니다. 이 경우 표현식의 값이 사용되지 않고(부수 효과만 중요하므로), 일반적인 Go 대입문이나 증감문으로 축약할 수 있습니다.

예를 들어, { x++; print x }는 직관적인 Go 코드로 컴파일됩니다:

x++
fmt.Fprintln(_output, _formatNum(x))

컴파일러에서 한 가지 편법을 쓴 것은 공백이나 불필요한 괄호에 신경 쓰지 않은 것입니다. Go 컴파일러는 공백이 없거나 괄호가 많아도 신경 쓰지 않으며, 위 예시들의 출력 결과는 gofmt -r '(x) -> x'로 실행해 코드를 포맷하고 재작성 규칙으로 불필요한 괄호를 제거한 것입니다.

따라서 AWK의 연산자 우선순위를 Go의 우선순위로 변환하려고 애쓸 필요가 없습니다. 모든 이항 및 단항 연산 주위에 단순히 괄호를 출력하고, gofmt가 이를 정리하도록 합니다. 예를 들어, AWK 프로그램 BEGIN { print 1+2*3 }은 다음과 같이 컴파일됩니다:

func main() {
// Common setup code elided...
fmt.Fprintln(_output, _formatNum((1.0 + (2.0 * 3.0))))
}

하지만 위 gofmt 명령을 실행하면 다음과 같이 정리됩니다:

func main() {
    // Common setup code elided...
    fmt.Fprintln(_output, _formatNum(1.0+2.0*3.0))
}

헬퍼 함수

필드 조회 및 설정(예: $1), 문자열과 숫자 간의 변환, match, substr, sub 같은 내장 함수 구현 등에는 작은 AWK “런타임”이 필요합니다.

이들은 매번 동일하므로, helpers.go에 Go 소스 코드를 담은 여러 줄 문자열로 포함해 두었습니다. 이름 충돌을 피하기 위해 각 이름 앞에는 밑줄을 붙였습니다(완벽하진 않지만 충분히 괜찮습니다).

예를 들어, 필드를 조회하고 설정하는 헬퍼($i$i = s에 해당)는 아래와 같습니다. 직접 작성하는 Go 프로그램이라면 _line이나 _fields 같은 것에 전역 변수를 사용하지 않겠지만, AWK에서는 그 상태가 전역이므로 그렇게 변환하는 것이 타당합니다.

func _getField(i int) string {
    if i < 0 || i > len(_fields) {
        return ""
    }
    if i == 0 {
        return _line
    }
    return _fields[i-1]
}

func _setField(i int, s string) {
    if i == 0 {
        _line = s
        _fields = _splitHelper(_line, FS)
        return
    }
    for j := len(_fields); j < i; j++ {
        _fields = append(_fields, "")
    }
    _fields[i-1] = s
    _line = strings.Join(_fields, OFS)
}

func _splitHelper(s, fs string) []string {
    var parts []string
    if fs == " " {
        parts = strings.Fields(s)
    } else if s == "" {
        // NF should be 0 on empty line
    } else if utf8.RuneCountInString(fs) <= 1 {
        parts = strings.Split(s, fs)
    } else {
        parts = _reCompile(fs).Split(s, -1)
    }
    return parts
}

테스트

저는 이미 GoAWK로부터 인터프리터의 다양한 측면이 올바르게 동작하는지 보장하는 수많은 테스트를 가지고 있었습니다. 이를 awkgo 디렉터리에 복사한 뒤, AWKGo를 거쳐 실행되도록 조금 손봤습니다.

원래 테스트와 마찬가지로, AWKGo 테스트도 단일 TestAWKGo 함수로 구동되는 테이블 기반 테스트로 작성되어 있습니다. AWK 소스를 파싱하고 컴파일해 Go 코드를 임시 파일로 출력한 뒤, go run으로 실행하고 그 출력을 예상 결과와 비교합니다.

또한 테스트를 실행하고 실행마다 달라지는 실행 시간 같은 부분을 출력에서 제거한 뒤 결과를 awkgo/tests.txt에 기록하는 작은 스크립트인 awkgo/run_tests.sh도 작성했습니다.

시작할 때는 통과하는 테스트가 몇 개뿐이었습니다. 하지만 기능을 하나씩 구현하고 버그를 하나씩 고칠 때마다 천천히, 그러나 확실히 PASS 수가 FAIL 수를 초과하기 시작했습니다. 여러 테스트에 영향을 주는 문제를 고치고 tests.txt diff에서 수많은 실패가 사라지는 것을 보면 정말 뿌듯합니다.

구현하고자 했던 기능들을 모두 마쳤을 때, 선을 긋고 나머지 테스트들을 주석 처리하여 go test가 실패 없이 실행되도록 했습니다.

성능

AWKGo 프로그램의 성능은 동일한 프로그램을 AWK 인터프리터로 실행했을 때나, 직접 Go로 작성했을 때와 비교하면 어떨까요?

음, 프로그램에 따라 다릅니다. 빡빡한 루프에서 수학 연산을 수행하는 프로그램은 훨씬 빨라집니다. 0부터 1억까지의 정수를 합산하는 다음 AWK 원라이너를 사용해 보겠습니다:

BEGIN { for (i=0; i<100000000; i++) s += i; print s }

AWKGo 버전의 실행 시간을 GoAWK, Gawk, mawk, 그리고 오리지널 Kernighan awk와 비교해 보겠습니다. 각각 세 번 실행한 것 중 가장 좋은 기록을 느린 순부터 빠른 순으로 정렬했습니다:

버전시간(초)
goawk6.59
awk6.27
gawk5.73
mawk2.88
awkgo0.33

처음 세 인터프리터는 모두 비슷하며, Gawk가 약간 앞섰습니다. mawk는 인터프리터치고는 매우 빠릅니다! 하지만 물론 컴파일된 Go 코드는 그보다 약 9배 더 빠릅니다.

비교를 위해, 전역 변수 대신 지역 변수를 쓰고 float64 대신 int를 사용하는 손수 작성한 Go 버전은 0.098초에 실행되며, 그보다도 3배 이상 빠릅니다.

I/O를 수행하는 프로그램(공정하게 말하자면, AWK를 보통 그런 용도로 사용하죠)은 약간만 더 빠릅니다. 위에서 보여드린 “단어 빈도 세기” 프로그램을 킹 제임스 성경 10개를 이어 붙인 파일에 대해 실행하고(출력은 /dev/null로 파이프) 측정한 결과는 다음과 같습니다:

버전시간(초)
awk4.56
gawk3.55
goawk3.16
mawk1.23
awkgo0.98

mawk는 다시 한번 인터프리터들 사이에서 압도적으로 앞서며, 컴파일된 AWKGo 버전과 거의 대등합니다. mawk가 바이트코드 컴파일러를 사용한다는 것은 알고 있지만, Gawk도 마찬가지죠 … “Mawk는 어떻게 그 속도를 달성하는가?”라는 흥미로운 글이 나올 법합니다. 혹시 쓰게 되면 링크를 보내주세요!

결론

그만한 가치가 있었을까요? 제게는 거의 확실히 그랬습니다. 저는 언어와 컴파일러를 좋아하고, 단순한 3패스 컴파일러를 만드는 것은 좋은 경험이었습니다. 이전에도 단순한 컴파일러를 작성해 본 적은 있지만, 정적 타입 언어로 컴파일하는 “타이핑” 패스를 가진 컴파일러는 처음이었습니다.

유용할까요? 사실 그렇진 않습니다. 성능이 필요하다면 그냥 Mawk를 쓰세요! 그리고 텍스트 처리 스크립트를 AWK보다 더 유지보수하기 쉬운 언어로 만들고 싶다면, 처음부터 Go 같은 언어로 작성하는 편이 나을 겁니다. 그렇게 하면 더 관용적인 Go 코드가 나올 것이 거의 확실하고, 아마 더 효율적일 것입니다.

그래도 주변에 “진짜 프로그램”으로 변환하고 싶은 AWK 스크립트가 있다면, AWKGo는 나쁘지 않은 출발점이 될 수 있습니다. 스크립트를 Go로 컴파일해 구조를 잡은 뒤, 정리해서 그 버전을 유지보수하면 되니까요.

어쨌든 AWKGo가 흥미롭거나 유용하다고 생각되시면 알려주세요. 피드백은 언제나 환영합니다!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글