처음부터 통역사 작성에 접근하는 방법
게시 됨: 2022-03-11어떤 사람들은 "모든 것이 1과 0으로 요약된다"고 말합니다. 하지만 우리 프로그램이 어떻게 이러한 비트로 변환되는지 이해하고 있습니까?
컴파일러와 인터프리터는 모두 프로그램을 나타내는 원시 문자열을 가져와 구문 분석하고 이해합니다. 인터프리터는 둘 중 더 단순하지만 (덧셈과 곱셈만 수행하는) 매우 간단한 인터프리터를 작성하는 것도 도움이 될 것입니다. 우리는 컴파일러와 인터프리터가 공통적으로 가지고 있는 것, 즉 입력을 렉싱하고 파싱하는 것에 초점을 맞출 것입니다.
나만의 통역사 작성 시 해야 할 일과 하지 말아야 할 일
독자는 정규식에 어떤 문제가 있는지 궁금해 할 수 있습니다. 정규식은 강력하지만 소스 코드 문법은 정규 표현식으로 구문 분석할 만큼 간단하지 않습니다. 둘 다 도메인 특정 언어(DSL)가 아니며 클라이언트는 예를 들어 인증 표현식을 위한 사용자 정의 DSL이 필요할 수 있습니다. 그러나 이 기술을 직접 적용하지 않더라도 인터프리터를 작성하면 많은 프로그래밍 언어, 파일 형식 및 DSL 뒤에 숨은 노력을 훨씬 더 쉽게 이해할 수 있습니다.
파서를 손으로 올바르게 작성하는 것은 관련된 모든 경우에 어려울 수 있습니다. 이것이 많은 인기 있는 프로그래밍 언어에 대한 파서를 생성할 수 있는 ANTLR과 같은 인기 있는 도구가 있는 이유입니다. 개발자가 선호하는 프로그래밍 언어로 직접 파서를 작성할 수 있도록 하는 파서 결합자라는 라이브러리도 있습니다. 예를 들어 Scala용 FastParse 및 Python용 Parsec이 있습니다.
전문적인 맥락에서 독자는 이러한 도구와 라이브러리를 사용하여 바퀴를 재발명하지 않도록 하는 것이 좋습니다. 그래도 처음부터 인터프리터를 작성하는 문제와 가능성을 이해하면 개발자가 이러한 솔루션을 보다 효과적으로 활용하는 데 도움이 됩니다.
통역사 구성 요소 개요
인터프리터는 복잡한 프로그램이므로 여러 단계가 있습니다.
- 렉서 는 일련의 문자(일반 텍스트)를 일련의 토큰으로 바꾸는 인터프리터의 일부입니다.
- 파서 는 차례로 토큰 시퀀스를 가져와 언어의 추상 구문 트리(AST)를 생성합니다. 파서가 작동하는 규칙은 일반적으로 형식 문법에 의해 지정됩니다.
- 인터프리터 는 프로그램 소스의 AST를 즉시(먼저 컴파일하지 않고) 해석하는 프로그램입니다.
여기서는 특정 통합 인터프리터를 구축하지 않습니다. 대신 개별 예제를 통해 이러한 각 부분과 공통 문제를 살펴보겠습니다. 결국 사용자 코드는 다음과 같습니다.
val input = "2 * 7 + 5" val tokens = Lexer(input).lex() val ast = Parser(tokens).parse() val res = Interpreter(ast).interpret() println(s"Result is: $res") 세 단계를 거친 후 이 코드가 최종 값을 계산하고 Result is: 19 . 이 튜토리얼은 다음과 같은 이유로 Scala를 사용합니다.
- 매우 간결하고 많은 코드를 한 화면에 표시합니다.
- 초기화되지 않은/null 변수가 필요 없는 표현식 지향.
- 강력한 컬렉션 라이브러리, 열거형 및 케이스 클래스를 사용하여 안전하게 입력하세요.
특히 여기의 코드는 Scala3 optional-braces 구문(Python과 유사한 들여쓰기 기반 구문)으로 작성되었습니다. 그러나 접근 방식 중 어느 것도 스칼라 전용이 아니며 스칼라는 다른 많은 언어와 유사합니다. 독자는 이러한 코드 샘플을 다른 언어로 변환하는 것이 간단하다는 것을 알게 될 것입니다. 이를 제외하고 예제는 Scastie를 사용하여 온라인으로 실행할 수 있습니다.
마지막으로 Lexer, Parser 및 Interpreter 섹션에는 서로 다른 예제 문법 이 있습니다. 해당 GitHub 리포지토리에서 볼 수 있듯이 이후 예제의 종속성은 이러한 문법을 구현하기 위해 약간 변경되지만 전체 개념은 동일하게 유지됩니다.
인터프리터 구성 요소 1: 렉서 작성
"123 + 45 true * false1" 문자열을 lex 하고 싶다고 가정해 봅시다. 여기에는 다양한 유형의 토큰이 포함되어 있습니다.
- 정수 리터럴
-
+연산자 - A
*연산자 -
true리터럴 - 식별자,
false1
이 예에서는 토큰 사이의 공백을 건너뜁니다.
이 단계에서 표현은 의미가 없습니다. 렉서는 단순히 입력 문자열을 토큰 목록으로 변환합니다. ("토큰을 이해하는" 작업은 파서에 맡겨집니다.)
이 코드를 사용하여 토큰을 나타냅니다.
case class Token( tpe: Token.Type, text: String, startPos: Int ) object Token: enum Type: case Num case Plus case Times case Identifier case True case False case EOF모든 토큰에는 원래 입력의 유형, 텍스트 표현 및 위치가 있습니다. 위치는 렉서의 최종 사용자가 디버깅하는 데 도움이 될 수 있습니다.
EOF 토큰은 입력의 끝을 표시하는 특수 토큰입니다. 소스 텍스트에는 존재하지 않습니다. 파서 단계를 단순화하기 위해서만 사용합니다.
이것은 렉서의 출력이 될 것입니다:
Lexing input: 123 + 45 true * false1 Tokens: List( Token(tpe = Num, text = "123", tokenStartPos = 0), Token(tpe = Plus, text = "+", tokenStartPos = 4), Token(tpe = Num, text = "45", tokenStartPos = 6), Token(tpe = True, text = "true", tokenStartPos = 9), Token(tpe = Times, text = "*", tokenStartPos = 14), Token(tpe = Identifier, text = "false1", tokenStartPos = 16), Token(tpe = EOF, text = "<EOF>", tokenStartPos = 22) )구현을 살펴보겠습니다.
class Lexer(input: String): def lex(): List[Token] = val tokens = mutable.ArrayBuffer.empty[Token] var currentPos = 0 while currentPos < input.length do val tokenStartPos = currentPos val lookahead = input(currentPos) if lookahead.isWhitespace then currentPos += 1 // ignore whitespace else if lookahead == '+' then currentPos += 1 tokens += Token(Type.Plus, lookahead.toString, tokenStartPos) else if lookahead == '*' then currentPos += 1 tokens += Token(Type.Times, lookahead.toString, tokenStartPos) else if lookahead.isDigit then var text = "" while currentPos < input.length && input(currentPos).isDigit do text += input(currentPos) currentPos += 1 tokens += Token(Type.Num, text, tokenStartPos) else if lookahead.isLetter then // first must be letter var text = "" while currentPos < input.length && input(currentPos).isLetterOrDigit do text += input(currentPos) currentPos += 1 val tpe = text match case "true" => Type.True // special casing literals case "false" => Type.False case _ => Type.Identifier tokens += Token(tpe, text, tokenStartPos) else error(s"Unknown character '$lookahead' at position $currentPos") tokens += Token(Type.EOF, "<EOF>", currentPos) // special end marker tokens.toList빈 토큰 목록으로 시작한 다음 문자열을 살펴보고 토큰이 올 때마다 추가합니다.
다음 토큰의 유형을 결정하기 위해 lookahead 문자를 사용합니다. 미리보기 문자가 항상 검사 대상 앞에 있는 가장 먼 문자는 아닙니다. 미리보기를 기반으로 토큰이 어떻게 생겼는지 알고 currentPos 를 사용하여 현재 토큰에서 예상되는 모든 문자를 스캔한 다음 목록에 토큰을 추가합니다.
미리보기가 공백이면 건너뜁니다. 단일 문자 토큰은 간단합니다. 우리는 그것들을 추가하고 인덱스를 증가시킵니다. 정수의 경우 인덱스만 처리하면 됩니다.
이제 우리는 식별자 대 리터럴이라는 약간 복잡한 문제에 도달했습니다. 규칙은 가능한 가장 긴 일치 항목 을 선택하여 리터럴인지 확인하는 것입니다. 그렇지 않은 경우 식별자입니다.
< 및 <= 와 같은 연산자를 처리할 때 주의하십시오. 여기서 한 문자를 더 살펴보고 <= 연산자라고 결론짓기 전에 = 인지 확인해야 합니다. 그렇지 않으면 < 입니다.
이를 통해 렉서는 토큰 목록을 생성했습니다.
인터프리터 구성 요소 2: 파서 작성
우리는 토큰에 약간의 구조 를 부여해야 합니다. 목록만으로는 많은 것을 할 수 없습니다. 예를 들어 다음을 알아야 합니다.
어떤 표현식이 중첩되어 있습니까? 어떤 연산자가 어떤 순서로 적용됩니까? 적용되는 범위 지정 규칙은 무엇입니까?
트리 구조는 중첩 및 순서 지정을 지원합니다. 그러나 먼저 나무를 구성하기 위한 몇 가지 규칙을 정의해야 합니다. 주어진 입력에 대해 항상 동일한 구조를 반환하기 위해 파서가 모호하지 않기를 바랍니다.
다음 파서 는 이전 렉서 예제를 사용하지 않습니다 . 이것은 숫자를 추가하기 위한 것이므로 문법에는 '+' 및 NUM 두 개의 토큰만 있습니다.
expr -> expr '+' expr expr -> NUM 정규식에서와 같이 파이프 문자( | )를 "또는" 기호로 사용하는 등가물은 다음과 같습니다.
expr -> expr '+' expr | NUM 어느 쪽이든 두 가지 규칙이 있습니다. 하나는 두 개의 expr 을 합산할 수 있다는 것이고 다른 하나는 expr 이 NUM 토큰이 될 수 있다는 것입니다. 여기서 이것은 음이 아닌 정수를 의미합니다.
규칙은 일반적으로 형식 문법 으로 지정됩니다. 형식 문법은 다음으로 구성됩니다. 위에 표시된 규칙 자체 시작 규칙(규칙에 따라 지정된 첫 번째 규칙) 규칙을 정의하는 두 가지 유형의 기호: 터미널: 우리 언어의 "문자"(및 기타 문자)— 토큰을 구성하는 환원 불가능한 기호 비단말기: 구문 분석에 사용되는 중간 구조(즉, 대체할 수 있는 기호)
규칙의 왼쪽에는 비터미널만 있을 수 있습니다. 오른쪽은 터미널과 비 터미널을 모두 가질 수 있습니다. 위의 예에서 터미널은 '+' 및 NUM 이며 유일한 비터미널은 expr 입니다. 더 넓은 예로 Java 언어에는 'true' , '+' , Identifier 및 '[' 와 같은 터미널이 있고 BlockStatements , ClassBody 및 MethodOrFieldDecl 과 같은 비터미널이 있습니다.
이 파서를 구현할 수 있는 많은 방법이 있습니다. 여기에서는 "재귀적 하강" 구문 분석 기술을 사용합니다. 이해하고 구현하는 것이 가장 간단하기 때문에 가장 일반적인 유형입니다.
재귀 하강 파서는 문법의 각 비터미널에 대해 하나의 함수를 사용합니다. 시작 규칙에서 시작하여 거기에서 내려오며(따라서 "하강") 각 기능에 적용할 규칙을 파악합니다. "재귀적" 부분은 비터미널을 재귀적으로 중첩할 수 있기 때문에 중요합니다! 정규식은 그렇게 할 수 없습니다. 균형 잡힌 괄호조차 처리할 수 없습니다. 그래서 더 강력한 도구가 필요합니다.
첫 번째 규칙에 대한 파서는 다음과 같습니다(전체 코드).
def expr() = expr() eat('+') expr() eat() 함수는 lookahead가 예상 토큰과 일치하는지 확인한 다음 lookahead 인덱스를 이동합니다. 불행히도 문법 문제를 해결해야 하기 때문에 아직 작동하지 않습니다.
문법 모호성
첫 번째 문제는 문법의 모호성으로, 언뜻 보기에는 분명하지 않을 수 있습니다.
expr -> expr '+' expr | NUM 1 + 2 + 3 입력이 주어지면 파서는 결과 AST에서 왼쪽 expr 또는 오른쪽 expr 을 먼저 계산하도록 선택할 수 있습니다.
이것이 비대칭 을 도입해야 하는 이유입니다.
expr -> expr '+' NUM | NUM이 문법으로 표현할 수 있는 표현식 세트는 첫 번째 버전 이후로 변경되지 않았습니다. 이제야 명확 해집니다. 파서는 항상 왼쪽으로 이동합니다. 우리에게 필요한 것!

이것은 우리의 + 연산 을 연관 되게 만들지만, 이것은 우리가 Interpreter 섹션에 도달할 때 명백해질 것입니다.
왼쪽 재귀 규칙
불행히도 위의 수정 사항은 다른 문제인 왼쪽 재귀를 해결하지 못합니다.
def expr() = expr() eat('+') eat(NUM)여기에 무한 재귀 가 있습니다. 이 기능을 실행하면 결국 스택 오버플로 오류가 발생합니다. 그러나 구문 분석 이론이 도움이 될 수 있습니다!
다음과 같은 문법이 있다고 가정합니다. 여기서 alpha 는 터미널과 비터미널의 모든 시퀀스일 수 있습니다.
A -> A alpha | B이 문법을 다음과 같이 다시 쓸 수 있습니다.
A -> BA' A' -> alpha A' | epsilon 거기에서 epsilon 은 빈 문자열입니다. 아무 것도, 토큰도 없습니다.
최신 문법 개정판을 살펴보겠습니다.
expr -> expr '+' NUM | NUM 위에서 설명한 구문 분석 규칙을 다시 작성하는 방법에 따라 alpha 가 '+' NUM 토큰인 경우 문법은 다음과 같습니다.
expr -> NUM exprOpt exprOpt -> '+' NUM exprOpt | epsilon이제 문법은 정상이며 재귀 하강 구문 분석기로 구문 분석할 수 있습니다. 그러한 파서가 우리 문법의 이 최신 반복을 어떻게 찾는지 봅시다:
class Parser(allTokens: List[Token]): import Token.Type private var tokens = allTokens private var lookahead = tokens.head def parse(): Unit = expr() if lookahead.tpe != Type.EOF then error(s"Unknown token '${lookahead.text}' at position ${lookahead.tokenStartPos}") private def expr(): Unit = eat(Type.Num) exprOpt() private def exprOpt(): Unit = if lookahead.tpe == Type.Plus then eat(Type.Plus) eat(Type.Num) exprOpt() // else: end recursion, epsilon private def eat(tpe: Type): Unit = if lookahead.tpe != tpe then error(s"Expected: $tpe, got: ${lookahead.tpe} at position ${lookahead.startPos}") tokens = tokens.tail lookahead = tokens.head 여기서 우리는 파서를 단순화하기 위해 EOF 토큰을 사용합니다. 우리는 항상 목록에 적어도 하나의 토큰이 있다고 확신하므로 빈 목록의 특별한 경우를 처리할 필요가 없습니다.
또한 스트리밍 렉서로 전환하면 메모리 내 목록이 아니라 반복자가 있으므로 입력 끝에 도달했을 때 알 수 있는 마커가 필요합니다. 마지막에 EOF 토큰이 마지막 남은 토큰이어야 합니다.
코드를 살펴보면 표현식이 숫자일 수 있음을 알 수 있습니다. 남은 것이 없으면 다음 토큰은 Plus 가 아니므로 구문 분석을 중지합니다. 마지막 토큰은 EOF 이고 완료됩니다.
입력 문자열에 더 많은 토큰이 있으면 + 123 처럼 보여야 합니다. 그것이 exprOpt() 의 재귀가 시작되는 곳입니다!
AST 생성
이제 표현식을 성공적으로 구문 분석했으므로 그대로 사용하기가 어렵습니다. 파서에 콜백을 넣을 수는 있지만 매우 번거롭고 읽을 수 없습니다. 대신 입력 표현식을 나타내는 트리인 AST를 반환합니다.
case class Expr(num: Int, exprOpt: ExprOpt) enum ExprOpt: case Opt(num: Int, exprOpt: ExprOpt) case Epsilon이것은 단순한 데이터 클래스를 사용하는 우리의 규칙과 유사합니다.
이제 파서는 유용한 데이터 구조를 반환합니다.
class Parser(allTokens: List[Token]): import Token.Type private var tokens = allTokens private var lookahead = tokens.head def parse(): Expr = val res = expr() if lookahead.tpe != Type.EOF then error(s"Unknown token '${lookahead.text}' at position ${lookahead.tokenStartPos}") else res private def expr(): Expr = val num = eat(Type.Num) Expr(num.text.toInt, exprOpt()) private def exprOpt(): ExprOpt = if lookahead.tpe == Type.Plus then eat(Type.Plus) val num = eat(Type.Num) ExprOpt.Opt(num.text.toInt, exprOpt()) else ExprOpt.Epsilon eat() , error() 및 기타 구현 세부 정보는 함께 제공되는 GitHub 리포지토리를 참조하세요.
규칙 단순화
우리의 ExprOpt 비터미널은 여전히 개선될 수 있습니다:
'+' NUM exprOpt | epsilon우리 문법에서 표현하는 패턴을 보기만 해서는 알아보기 어렵습니다. 이 재귀를 더 간단한 구성으로 대체할 수 있음이 밝혀졌습니다.
('+' NUM)* 이 구성은 단순히 '+' NUM 이 0번 이상 발생함을 의미합니다.
이제 전체 문법은 다음과 같습니다.
expr -> NUM exprOpt* exprOpt -> '+' NUM그리고 우리의 AST가 더 멋지게 보입니다.
case class Expr(num: Int, exprOpts: Seq[ExprOpt]) case class ExprOpt(num: Int) 결과 파서는 길이가 같지만 이해하고 사용하기가 더 간단합니다. 이제 빈 구조로 시작하여 암시되는 Epsilon 을 제거했습니다.
여기에는 ExprOpt 클래스도 필요하지 않았습니다. case class Expr(num: Int, exprOpts: Seq[Int]) 또는 문법 형식 NUM ('+' NUM)* 을 넣을 수 있습니다. 왜 우리는하지 않았습니까?
- 또는 * 와 같은 가능한 연산자가 여러 개 있는 경우 다음과 같은 문법이 있음을 고려하십시오.
expr -> NUM exprOpt* exprOpt -> [+-*] NUM 이 경우 AST는 연산자 유형을 수용하기 위해 ExprOpt 가 필요합니다.
case class Expr(num: Int, exprOpts: Seq[ExprOpt]) case class ExprOpt(op: String, num: Int) 문법의 [+-*] 구문은 정규식과 같은 의미로 "그 세 문자 중 하나"를 의미합니다. 우리는 이것이 곧 작동하는 것을 보게 될 것입니다.
인터프리터 구성 요소 3: 인터프리터 작성
인터프리터는 렉서와 파서를 사용하여 입력 표현식의 AST를 얻은 다음 원하는 방식으로 해당 AST를 평가합니다. 이 경우 우리는 숫자를 다루고 있으며 그 합을 평가하려고 합니다.
인터프리터 예제를 구현할 때 다음과 같은 간단한 문법을 사용합니다.
expr -> NUM exprOpt* exprOpt -> [+-] NUM그리고 이 AST:
case class Expr(num: Int, exprOpts: Seq[ExprOpt]) case class ExprOpt(op: Token.Type, num: Int)(우리는 유사한 문법에 대해 렉서와 파서를 구현하는 방법을 다루었지만, 막힌 독자는 리포지토리에서 이 정확한 문법에 대한 렉서 및 파서 구현을 정독할 수 있습니다.)
이제 위의 문법에 대한 인터프리터를 작성하는 방법을 볼 것입니다.
class Interpreter(ast: Expr): def interpret(): Int = eval(ast) private def eval(expr: Expr): Int = var tmp = expr.num expr.exprOpts.foreach { exprOpt => if exprOpt.op == Token.Type.Plus then tmp += exprOpt.num else tmp -= exprOpt.num } tmp 오류가 발생하지 않고 입력을 AST로 구문 분석하면 항상 최소한 하나의 NUM 을 가질 것이라고 확신합니다. 그런 다음 선택적 숫자를 가져와 결과에 추가(또는 빼기)합니다.
+ 의 왼쪽 연관성에 대한 처음부터의 메모가 이제 명확해집니다. 가장 왼쪽 숫자부터 시작하여 왼쪽에서 오른쪽으로 다른 숫자를 추가합니다. 덧셈에서는 중요하지 않은 것처럼 보일 수 있지만 뺄셈을 고려해 보십시오. 5 - 2 - 1 표현식은 5 - (2 - 1) = 5 - 1 = 4 가 아니라 (5 - 2) - 1 = 3 - 1 = 2 로 평가됩니다. !
그러나 더하기 및 빼기 연산자를 해석하는 것 이상을 원한다면 정의해야 할 또 다른 규칙이 있습니다.
상위
우리는 1 + 2 + 3 과 같은 간단한 표현식을 구문 분석하는 방법을 알고 있지만 2 + 3 * 4 + 5 에 관해서는 약간의 문제가 있습니다.
대부분의 사람들은 곱셈이 덧셈보다 우선한다는 규칙에 동의합니다. 그러나 파서는 그것을 모릅니다. ((2 + 3) * 4) + 5 로 평가할 수 없습니다. 대신 (2 + (3 * 4)) + 5 를 원합니다.
이는 곱셈을 먼저 평가 해야 함을 의미합니다. 곱셈 은 AST의 루트에서 더 멀리 떨어져 있어야 더하기 전에 평가됩니다. 이를 위해 또 다른 간접 계층을 도입해야 합니다.
순진한 문법을 처음부터 끝까지 고치기
이것은 우선순위 규칙이 없는 원래의 왼쪽 재귀 문법입니다.
expr -> expr '+' expr | expr '*' expr | NUM먼저 우선 순위 규칙을 지정하고 모호성 을 제거합니다.
expr -> expr '+' term | term term -> term '*' NUM | NUM그런 다음 왼쪽이 아닌 재귀 규칙 을 얻습니다.
expr -> term exprOpt* exprOpt -> '+' term term -> NUM termOpt* termOpt -> '*' NUM결과는 아름답게 표현되는 AST입니다.
case class Expr(term: Term, exprOpts: Seq[ExprOpt]) case class ExprOpt(term: Term) case class Term(num: Int, termOpts: Seq[TermOpt]) case class TermOpt(num: Int)이것은 우리에게 간결한 인터프리터 구현을 남깁니다:
class Interpreter(ast: Expr): def interpret(): Int = eval(ast) private def eval(expr: Expr): Int = var tmp = eval(expr.term) expr.exprOpts.foreach { exprOpt => tmp += eval(exprOpt.term) } tmp private def eval(term: Term): Int = var tmp = term.num term.termOpts.foreach { termOpt => tmp *= termOpt.num } tmp이전과 마찬가지로 필수 어휘 분석기 및 문법의 아이디어는 이전에 다루었지만 독자는 필요한 경우 저장소에서 찾을 수 있습니다.
통역사 작성의 다음 단계
우리는 이것을 다루지 않았지만 오류 처리 및 보고 는 모든 파서의 중요한 기능입니다. 개발자로서 우리는 컴파일러가 혼란스럽거나 오해의 소지가 있는 오류를 생성할 때 얼마나 실망스러울 수 있는지 알고 있습니다. 정확하고 정확한 오류 메시지를 제공하고, 필요 이상으로 많은 메시지로 사용자를 방해하지 않으며, 오류에서 정상적으로 복구하는 것과 같이 해결해야 할 흥미로운 문제가 많은 영역입니다. 미래의 사용자가 더 나은 경험을 할 수 있도록 인터프리터나 컴파일러를 작성하는 것은 개발자의 몫입니다.
렉서, 파서, 인터프리터의 예를 살펴보면서 다음과 같은 주제를 다루는 컴파일러와 인터프리터 뒤에 있는 이론의 표면만 긁었습니다.
- 범위 및 기호 테이블
- 정적 유형
- 컴파일 시간 최적화
- 정적 프로그램 분석기 및 린터
- 코드 서식 및 예쁜 인쇄
- 도메인별 언어
추가 읽기를 위해 다음 리소스를 권장합니다.
- Terence Parr의 언어 구현 패턴
- Bob Nystrom 의 무료 온라인 책 Crafting Interpreters
- Paul Klint 의 문법 및 구문 분석 소개
- Caleb Meredith의 좋은 컴파일러 오류 메시지 작성
- 이스트 캐롤라이나 대학 과정 "프로그램 번역 및 컴파일"의 메모
