제어 흐름
논리는 위스키와 같아서 너무 많이 섭취하면 이로운 효과를 잃는다.
에드워드 존 모턴 드랙스 플렁킷, 던세이니 경
지난 장의 고된 마라톤에 비하면, 오늘은 데이지 꽃밭을 거니는 가벼운 산책과 같습니다. 하지만 작업은 쉽지만, 그 보상은 놀랍도록 큽니다.
현재 우리의 인터프리터는 계산기에 지나지 않습니다. Lox 프로그램은 정해진 양의 작업만 수행하고 완료됩니다. 실행 시간을 두 배로 늘리려면 소스 코드 길이도 두 배로 늘려야만 했습니다. 이제 그 문제를 해결할 시간입니다. 이 장에서 우리의 인터프리터는 프로그래밍 언어의 주요 리그인 튜링 완전성(Turing-completeness)을 향한 큰 발걸음을 내딛습니다.
9 . 1튜링 머신 (간단히)
지난 세기 초반, 수학자들은 혼란스러운 역설의 연속에 부딪혀, 그들이 세워온 기반의 안정성에 의구심을 품게 되었습니다. 이러한 위기를 해결하기 위해, 그들은 처음부터 다시 시작했습니다. 몇 가지 공리(axiom), 논리, 그리고 집합론을 기반으로 수학을 견고한 토대 위에 재건하고자 했습니다.
그들은 "모든 참된 진술을 증명할 수 있는가?", "정의할 수 있는 모든 함수를 계산할 수 있는가?" 또는 더 일반적인 질문인 "함수가 '계산 가능하다'고 주장할 때, 우리는 무엇을 의미하는가?"와 같은 질문에 엄밀하게 답하고자 했습니다.
그들은 처음 두 질문에 대한 답이 "예"일 것이라고 가정했습니다. 남은 것은 그것을 증명하는 것이었죠. 하지만 놀랍게도, 두 질문에 대한 답은 "아니오"였으며, 이 두 질문은 깊이 얽혀 있었습니다. 이는 뇌가 무엇을 할 수 있고 우주가 어떻게 작동하는지에 대한 근본적인 질문들을 건드리는 수학의 흥미로운 부분입니다. 여기서 그 모든 것을 다룰 수는 없습니다.
제가 주목하고 싶은 것은, 처음 두 질문에 대한 답이 "아니오"임을 증명하는 과정에서 앨런 튜링(Alan Turing)과 알론조 처치(Alonzo Church)가 마지막 질문에 대한 정확한 답, 즉 어떤 종류의 함수가 계산 가능한지에 대한 정의를 고안했다는 것입니다. 그들은 각자 최소한의 장치만으로도 (매우) 광범위한 함수를 계산할 수 있을 만큼 강력한 작은 시스템을 만들었습니다.
이것들은 이제 "계산 가능한 함수"로 간주됩니다. 튜링의 시스템은 튜링 머신(Turing machine)이라고 불리고, 처치의 시스템은 람다 계산(lambda calculus)이라고 불립니다. 둘 다 계산 모델의 기반으로 널리 사용되고 있으며, 실제로 많은 현대 함수형 프로그래밍 언어는 람다 계산을 핵심으로 사용합니다.
튜링 머신이 더 잘 알려져 있지만(아직 알론조 처치에 대한 할리우드 영화는 없습니다), 두 형식 체계는 능력 면에서 동등합니다. 사실, 최소한의 표현력을 가진 어떤 프로그래밍 언어라도 어떤 계산 가능한 함수든 계산할 수 있을 만큼 강력합니다.
이는 여러분의 언어로 튜링 머신 시뮬레이터를 작성함으로써 증명할 수 있습니다. 튜링이 자신의 머신이 어떤 계산 가능한 함수든 계산할 수 있음을 증명했으므로, 확장하여 여러분의 언어도 그렇다는 것을 의미합니다. 함수를 튜링 머신으로 번역한 다음, 그 시뮬레이터에서 실행하기만 하면 됩니다.
여러분의 언어가 그럴 만큼 표현력이 있다면, 그것은 튜링 완전(Turing-complete)하다고 간주됩니다. 튜링 머신은 매우 간단하므로, 이를 수행하는 데 많은 능력은 필요하지 않습니다. 기본적으로 산술 연산, 약간의 제어 흐름, 그리고 (이론적으로) 임의의 양의 메모리를 할당하고 사용할 수 있는 능력이 필요합니다. 우리는 첫 번째를 가지고 있습니다. 이 장이 끝날 때까지 우리는 두 번째 것을 갖게 될 것입니다.
9 . 2조건부 실행
역사는 충분히 다루었으니, 이제 우리 언어를 활기차게 만들어봅시다. 제어 흐름은 크게 두 가지로 나눌 수 있습니다.
-
조건부(Conditional) 또는 분기 제어 흐름(branching control flow)은 특정 코드 조각을 실행하지 않기 위해 사용됩니다. 명령형(imperative) 관점에서 보면, 코드 영역을 건너뛰어 앞으로 점프하는 것으로 생각할 수 있습니다.
-
반복 제어 흐름(Looping control flow)은 코드 덩어리를 여러 번 실행합니다. 뒤로 점프하여 무언가를 다시 수행할 수 있게 합니다. 일반적으로 무한 루프를 원하지 않으므로, 루프를 언제 중지해야 할지 아는 조건부 로직도 포함합니다.
분기가 더 간단하므로 거기서부터 시작합시다. C에서 파생된 언어들은 두 가지 주요 조건부 실행 기능, 즉 if 문과 명칭이 직관적인 "조건부" 연산자 (?:)를 가지고 있습니다. if 문은 조건에 따라 문장을 실행하게 하고, 조건부 연산자는 조건에 따라 표현식을 실행하게 합니다.
단순화를 위해 Lox에는 조건부 연산자가 없으므로, if 문을 구현해 봅시다. 우리 문법(statement grammar)에 새로운 프로덕션(production)을 추가합니다.
statement → exprStmt | ifStmt | printStmt | block ; ifStmt → "if" "(" expression ")" statement ( "else" statement )? ;
if 문은 조건식(condition expression)과, 조건이 참일 경우 실행할 문장(statement)을 가집니다. 선택적으로 else 키워드와 조건이 거짓일 경우 실행할 문장을 가질 수도 있습니다. 구문 트리 노드는 이 세 부분 각각에 대한 필드를 가집니다.
"Expression : Expr expression",
in main()
"If : Expr condition, Stmt thenBranch," + " Stmt elseBranch",
"Print : Expr expression",
다른 문장들과 마찬가지로, 파서는 선행 if 키워드를 통해 if 문을 인식합니다.
private Stmt statement() {
in statement()
if (match(IF)) return ifStatement();
if (match(PRINT)) return printStatement();
발견하면 이 새로운 메서드를 호출하여 나머지를 파싱합니다.
add after statement()
private Stmt ifStatement() { consume(LEFT_PAREN, "Expect '(' after 'if'."); Expr condition = expression(); consume(RIGHT_PAREN, "Expect ')' after if condition."); Stmt thenBranch = statement(); Stmt elseBranch = null; if (match(ELSE)) { elseBranch = statement(); } return new Stmt.If(condition, thenBranch, elseBranch); }
늘 그렇듯이, 파싱 코드는 문법을 충실히 따릅니다. else 키워드를 찾음으로써 else 절을 감지합니다. else 절이 없으면 구문 트리의 elseBranch 필드는 null이 됩니다.
겉보기에는 무해해 보이는 이 선택적 else는 사실 우리 문법에 모호성을 불러왔습니다. 다음을 생각해 보세요.
if (first) if (second) whenTrue(); else whenFalse();
수수께끼는 다음과 같습니다. 이 else 절은 어느 if 문에 속할까요? 이것은 우리가 문법을 어떻게 표기하는지에 대한 이론적인 질문이 아닙니다. 실제로 코드가 실행되는 방식에 영향을 미칩니다.
-
만약
else를 첫 번째if문에 연결하면,first가 거짓일 때whenFalse()가 호출됩니다.second의 값은 상관없습니다. -
만약
else를 두 번째if문에 연결하면,whenFalse()는first가 참이고second가 거짓일 때만 호출됩니다.
else 절은 선택 사항이며, if 문의 끝을 나타내는 명시적인 구분자가 없기 때문에, 이렇게 if 문을 중첩하면 문법이 모호해집니다. 이 고전적인 구문 함정을 매달린 else 문제(dangling else problem)라고 합니다.
모호성을 직접적으로 피하는 문맥 자유 문법을 정의하는 것이 가능하지만, 대부분의 문장 규칙을 쌍으로 나누어야 합니다. 하나는 else가 있는 if를 허용하고, 다른 하나는 그렇지 않은 경우를 허용하죠. 이는 번거롭습니다.
대신 대부분의 언어와 파서는 임시적인(ad hoc) 방식으로 이 문제를 피합니다. 어떤 해결책을 사용하든, 그들은 항상 같은 해석을 선택합니다. 즉, else는 그 앞에 오는 가장 가까운 if에 묶입니다.
우리 파서는 이미 편리하게 그렇게 동작합니다. ifStatement()가 반환하기 전에 else를 적극적으로 찾기 때문에, 중첩된 일련의 호출 중 가장 안쪽에 있는 호출이 else 절을 자신에게 할당하고 나서 외부 if 문으로 돌아갑니다.
구문 준비가 되었으니, 이제 해석할 차례입니다.
add after visitExpressionStmt()
@Override public Void visitIfStmt(Stmt.If stmt) { if (isTruthy(evaluate(stmt.condition))) { execute(stmt.thenBranch); } else if (stmt.elseBranch != null) { execute(stmt.elseBranch); } return null; }
인터프리터 구현은 동일한 Java 코드에 대한 얇은 래퍼(wrapper)입니다. 조건을 평가하고, 참이면 then 브랜치(then branch)를 실행합니다. 그렇지 않고 else 브랜치가 있다면, 그것을 실행합니다.
이 코드를 우리가 구현했던 다른 구문을 처리하는 방식과 비교해 보면, 제어 흐름을 특별하게 만드는 부분은 바로 Java의 if 문입니다. 다른 대부분의 구문 트리는 항상 서브트리를 평가합니다. 하지만 여기서는 then 또는 else 문을 평가하지 않을 수도 있습니다. 이들 중 어느 하나라도 부작용(side effect)을 일으킨다면, 그것을 평가하지 않는다는 결정은 사용자에게 눈에 보이는 변화가 됩니다.
9 . 3논리 연산자
조건부 연산자가 없으니 분기는 끝났다고 생각할 수도 있지만, 그렇지 않습니다. 삼항 연산자가 없더라도, 기술적으로 제어 흐름 구조인 두 가지 다른 연산자, 즉 논리 연산자 and와 or가 있습니다.
이것들은 다른 이항 연산자들과는 다릅니다. 왜냐하면 단락 평가(short-circuit)를 하기 때문입니다. 왼쪽 피연산자를 평가한 후, 논리식의 결과가 무엇인지 알 수 있다면 오른쪽 피연산자를 평가하지 않습니다. 예를 들어:
false and sideEffect();
and 표현식이 참 값을 가지려면, 두 피연산자 모두 참이어야 합니다. 왼쪽 피연산자인 false를 평가하는 즉시, 이것이 참이 될 수 없다는 것을 알 수 있으므로, sideEffect()를 평가할 필요가 없으며 건너뛰어집니다.
이것이 우리가 논리 연산자를 다른 이항 연산자들과 함께 구현하지 않은 이유입니다. 이제 준비가 되었습니다. 두 새로운 연산자는 우선순위 테이블에서 낮게 위치합니다. C의 || 및 &&와 유사하게, 각각 고유의 우선순위를 가지며 or가 and보다 낮습니다. 우리는 이것들을 assignment와 equality 사이에 배치합니다.
expression → assignment ; assignment → IDENTIFIER "=" assignment | logic_or ; logic_or → logic_and ( "or" logic_and )* ; logic_and → equality ( "and" equality )* ;
이제 assignment는 equality 대신 logic_or로 연결됩니다. 두 새로운 규칙인 logic_or와 logic_and는 다른 이항 연산자와 유사합니다. 그리고 logic_and는 피연산자를 위해 equality를 호출하고, 우리는 나머지 표현식 규칙들로 다시 연결됩니다.
이 두 새로운 표현식은 동일한 필드를 가지고 있으므로 기존의 `Expr.Binary` 클래스를 재사용할 수 있습니다. 하지만 그렇게 하면 `visitBinaryExpr()`가 연산자가 논리 연산자인지 확인하고 단락 평가를 처리하기 위해 다른 코드 경로를 사용해야 할 것입니다. 저는 이 연산자들을 위한 새로운 클래스를 정의하여 자신만의 visit 메서드를 가지도록 하는 것이 더 깔끔하다고 생각합니다.
"Literal : Object value",
in main()
"Logical : Expr left, Token operator, Expr right",
"Unary : Token operator, Expr right",
새로운 표현식들을 파서에 통합하기 위해, 먼저 assignment의 파싱 코드를 변경하여 or()를 호출하게 합니다.
private Expr assignment() {
in assignment()
replace 1 line
Expr expr = or();
if (match(EQUAL)) {
일련의 or 표현식을 파싱하는 코드는 다른 이항 연산자와 유사합니다.
add after assignment()
private Expr or() { Expr expr = and(); while (match(OR)) { Token operator = previous(); Expr right = and(); expr = new Expr.Logical(expr, operator, right); } return expr; }
그 피연산자들은 다음으로 높은 우선순위 수준인 새로운 and 표현식입니다.
add after or()
private Expr and() { Expr expr = equality(); while (match(AND)) { Token operator = previous(); Expr right = equality(); expr = new Expr.Logical(expr, operator, right); } return expr; }
이 메서드는 피연산자를 위해 equality()를 호출하며, 이로써 표현식 파서가 다시 연결됩니다. 이제 해석할 준비가 되었습니다.
add after visitLiteralExpr()
@Override public Object visitLogicalExpr(Expr.Logical expr) { Object left = evaluate(expr.left); if (expr.operator.type == TokenType.OR) { if (isTruthy(left)) return left; } else { if (!isTruthy(left)) return left; } return evaluate(expr.right); }
이것을 이전 장의 visitBinaryExpr() 메서드와 비교해보면 차이점을 알 수 있습니다. 여기서는 먼저 왼쪽 피연산자를 평가합니다. 단락 평가가 가능한지 확인하기 위해 그 값을 확인합니다. 단락 평가가 불가능할 때만 오른쪽 피연산자를 평가합니다.
여기서 또 다른 흥미로운 부분은 실제로 어떤 값을 반환할지 결정하는 것입니다. Lox는 동적 타입이므로, 우리는 어떤 타입의 피연산자도 허용하며, 참/거짓 값을 통해 각 피연산자가 무엇을 나타내는지 결정합니다. 우리는 결과에도 유사한 논리를 적용합니다. 문자 그대로 true나 false를 반환하겠다고 약속하는 대신, 논리 연산자는 적절한 참/거짓 값을 가진 값을 반환할 것을 보장합니다.
다행히, 우리는 적절한 참/거짓 값을 가진 값, 즉 피연산자 자체의 결과를 바로 가지고 있습니다. 그래서 우리는 그것들을 사용합니다. 예를 들어:
print "hi" or 2; // "hi". print nil or "yes"; // "yes".
첫 번째 줄에서 "hi"는 참이므로, or는 단락 평가를 하고 "hi"를 반환합니다. 두 번째 줄에서 nil은 거짓이므로, 두 번째 피연수인 "yes"를 평가하고 반환합니다.
이것으로 Lox의 모든 분기 프리미티브(branching primitives)를 다루었습니다. 이제 루프로 넘어가겠습니다. 제가 방금 뭐라고 했는지 아시겠나요? 점프(Jump). 넘어가다(Ahead). 이해하셨나요? 음, 마치 . . . 아, 잊어버리세요.
9 . 4While 루프
Lox에는 두 가지 반복 제어 흐름 문장이 있습니다: while과 for. while 루프가 더 간단하므로, 거기서부터 시작하겠습니다. 문법은 C와 동일합니다.
statement → exprStmt | ifStmt | printStmt | whileStmt | block ; whileStmt → "while" "(" expression ")" statement ;
statement 규칙에 while을 가리키는 새로운 절을 추가합니다. while 키워드 다음에 괄호로 묶인 조건식, 그리고 본문을 위한 문장이 옵니다. 이 새로운 문법 규칙은 구문 트리 노드를 가집니다.
"Print : Expr expression",
"Var : Token name, Expr initializer",
in main()
add “,” to previous line
"While : Expr condition, Stmt body"
));
이 노드는 조건과 본문을 저장합니다. 여기서 표현식과 문장을 위한 별도의 기본 클래스를 갖는 것이 왜 좋은지 알 수 있습니다. 필드 선언은 조건이 표현식이고 본문이 문장임을 명확히 보여줍니다.
파서에서는 if 문에서 사용했던 것과 동일한 절차를 따릅니다. 먼저, statement()에 선행 키워드를 감지하고 일치시키기 위한 또 다른 경우를 추가합니다.
if (match(PRINT)) return printStatement();
in statement()
if (match(WHILE)) return whileStatement();
if (match(LEFT_BRACE)) return new Stmt.Block(block());
이것은 실제 작업을 다음 메서드에 위임합니다.
add after varDeclaration()
private Stmt whileStatement() { consume(LEFT_PAREN, "Expect '(' after 'while'."); Expr condition = expression(); consume(RIGHT_PAREN, "Expect ')' after condition."); Stmt body = statement(); return new Stmt.While(condition, body); }
문법은 매우 간단하며, 이것은 자바 코드로의 직역입니다. 자바로의 직역에 관해 말하자면, 새로운 구문을 실행하는 방법은 다음과 같습니다.
add after visitVarStmt()
@Override public Void visitWhileStmt(Stmt.While stmt) { while (isTruthy(evaluate(stmt.condition))) { execute(stmt.body); } return null; }
if에 대한 visit 메서드처럼, 이 visitor는 해당 Java 기능을 사용합니다. 이 메서드는 복잡하지 않지만, Lox를 훨씬 더 강력하게 만듭니다. 이제 우리는 실행 시간이 소스 코드의 길이에 의해 엄격하게 제한되지 않는 프로그램을 드디어 작성할 수 있습니다.
9 . 5For 루프
이제 마지막 제어 흐름 구조인 오래된 C 스타일 for 루프만 남았습니다. 다시 상기시킬 필요는 없겠지만, 다음과 같이 생겼습니다.
for (var i = 0; i < 10; i = i + 1) print i;
문법 형식으로는 다음과 같습니다:
statement → exprStmt | forStmt | ifStmt | printStmt | whileStmt | block ; forStmt → "for" "(" ( varDecl | exprStmt | ";" ) expression? ";" expression? ")" statement ;
괄호 안에는 세미콜론으로 구분된 세 개의 절이 있습니다.
-
첫 번째 절은 초기화 구문(initializer)입니다. 다른 어떤 것보다 먼저 정확히 한 번 실행됩니다. 일반적으로 표현식이지만, 편의를 위해 변수 선언도 허용합니다. 이 경우, 변수는 나머지
for루프(다른 두 절과 본문)에 범위가 지정됩니다. -
다음은 조건(condition)입니다.
while루프에서와 마찬가지로, 이 표현식은 루프를 언제 종료할지 제어합니다. 첫 번째를 포함하여 각 반복 시작 시 한 번 평가됩니다. 결과가 참이면 루프 본문을 실행합니다. 그렇지 않으면 종료합니다. -
마지막 절은 증감(increment)입니다. 각 루프 반복의 끝에서 어떤 작업을 수행하는 임의의 표현식입니다. 표현식의 결과는 버려지므로, 유용하려면 부작용(side effect)이 있어야 합니다. 실제로, 보통 변수를 증가시킵니다.
이 절들 중 어느 것도 생략될 수 있습니다. 닫는 괄호 다음에는 본문을 위한 문장이 오며, 일반적으로 블록입니다.
9 . 5 . 1문법 제거 (Desugaring)
많은 메커니즘이 있지만, 이 중 어떤 것도 우리가 이미 가지고 있는 문장으로 할 수 없는 일을 하지는 않습니다. 만약 for 루프가 초기화 절을 지원하지 않았다면, for 문장 앞에 초기화 표현식을 단순히 놓을 수 있었을 것입니다. 증감 절이 없다면, 본문 끝에 증감 표현식을 직접 넣을 수 있었을 것입니다.
다시 말해, Lox는 for 루프가 필요하지는 않지만, 일반적인 코드 패턴을 더 즐겁게 작성할 수 있게 해줍니다. 이러한 종류의 기능을 문법적 설탕(syntactic sugar)이라고 합니다. 예를 들어, 이전 for 루프는 다음과 같이 다시 작성될 수 있습니다.
{
var i = 0;
while (i < 10) {
print i;
i = i + 1;
}
}
이 스크립트는 이전 스크립트와 정확히 같은 의미를 가지지만, 보기에 쉽지는 않습니다. Lox의 for 루프와 같은 문법적 설탕 기능은 언어를 더 쾌적하고 생산적으로 작업할 수 있게 합니다. 하지만, 특히 정교한 언어 구현에서는 백엔드 지원과 최적화가 필요한 모든 언어 기능은 비용이 많이 듭니다.
우리는 문법 제거(desugaring)를 통해 두 마리 토끼를 다 잡을 수 있습니다. 이 재미있는 단어는 프론트엔드가 문법적 설탕을 사용하는 코드를 가져와 백엔드가 이미 실행하는 방법을 아는 더 원시적인 형태로 번역하는 과정을 설명합니다.
우리는 for 루프를 인터프리터가 이미 처리하는 while 루프 및 다른 문장들로 문법 제거할 것입니다. 우리의 간단한 인터프리터에서는 문법 제거가 많은 작업을 절약해 주지는 않지만, 이 기법을 여러분에게 소개할 핑계가 됩니다. 따라서, 이전 문장들과는 달리, 우리는 새로운 구문 트리 노드를 추가하지 않을 것입니다. 대신, 바로 파싱으로 들어갑니다. 먼저, 곧 필요할 import 문을 추가합니다.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
모든 문장과 마찬가지로, for 루프의 키워드를 매치하는 것으로 파싱을 시작합니다.
private Stmt statement() {
in statement()
if (match(FOR)) return forStatement();
if (match(IF)) return ifStatement();
여기서부터 흥미로워집니다. 문법 제거는 여기서 일어날 것이므로, 이 메서드를 하나씩 만들어나가겠습니다. 절들 앞에 오는 열린 괄호부터 시작합니다.
add after statement()
private Stmt forStatement() { consume(LEFT_PAREN, "Expect '(' after 'for'."); // 더 많은 내용이 여기에... }
그 다음에 오는 첫 번째 절은 초기화 구문입니다.
consume(LEFT_PAREN, "Expect '(' after 'for'.");
in forStatement()
replace 1 line
Stmt initializer; if (match(SEMICOLON)) { initializer = null; } else if (match(VAR)) { initializer = varDeclaration(); } else { initializer = expressionStatement(); }
}
( 뒤에 오는 토큰이 세미콜론이면 초기화 구문이 생략된 것입니다. 그렇지 않으면 var 키워드를 확인하여 변수 선언인지 확인합니다. 둘 다 일치하지 않으면 표현식이어야 합니다. 이를 파싱하여 표현식 문장으로 감싸서 초기화 구문이 항상 Stmt 타입이 되도록 합니다.
다음은 조건(condition)입니다.
initializer = expressionStatement();
}
in forStatement()
Expr condition = null;
if (!check(SEMICOLON)) {
condition = expression();
}
consume(SEMICOLON, "Expect ';' after loop condition.");
}
다시, 절이 생략되었는지 확인하기 위해 세미콜론을 찾습니다. 마지막 절은 증감(increment)입니다.
consume(SEMICOLON, "Expect ';' after loop condition.");
in forStatement()
Expr increment = null;
if (!check(RIGHT_PAREN)) {
increment = expression();
}
consume(RIGHT_PAREN, "Expect ')' after for clauses.");
}
이것은 조건 절과 유사하지만, 이 절은 닫는 괄호로 끝납니다. 남은 것은 본문입니다.
consume(RIGHT_PAREN, "Expect ')' after for clauses.");
in forStatement()
Stmt body = statement(); return body;
}
우리는 for 루프의 다양한 부분을 모두 파싱했으며, 결과 AST 노드들은 몇 개의 Java 지역 변수에 저장되어 있습니다. 여기가 문법 제거가 일어나는 곳입니다. 우리는 그것들을 사용하여 for 루프의 의미를 표현하는 구문 트리 노드를 합성합니다. 앞서 보여드렸던 직접 문법 제거된 예시와 같이 말이죠.
코드는 뒤에서부터 작업하면 약간 더 간단하므로, 증감 절부터 시작합니다.
Stmt body = statement();
in forStatement()
if (increment != null) { body = new Stmt.Block( Arrays.asList( body, new Stmt.Expression(increment))); }
return body;
증감 부분이 있다면, 루프의 각 반복에서 본문 실행 후에 실행됩니다. 이를 위해 본문을 원래 본문과 증감 표현식을 평가하는 표현식 문장을 포함하는 작은 블록으로 대체합니다.
}
in forStatement()
if (condition == null) condition = new Expr.Literal(true); body = new Stmt.While(condition, body);
return body;
다음으로, 조건과 본문을 가져와 원시 while 루프를 사용하여 루프를 구축합니다. 만약 조건이 생략되었다면, 무한 루프를 만들기 위해 true를 끼워 넣습니다.
body = new Stmt.While(condition, body);
in forStatement()
if (initializer != null) { body = new Stmt.Block(Arrays.asList(initializer, body)); }
return body;
마지막으로, 초기화 구문이 있다면 전체 루프 전에 한 번 실행됩니다. 이를 위해, 다시 전체 문장을 초기화 구문을 실행하고 나서 루프를 실행하는 블록으로 대체합니다.
그게 다입니다. 우리 인터프리터는 이제 C 스타일 for 루프를 지원하며, Interpreter 클래스는 전혀 건드리지 않았습니다. 인터프리터가 이미 방문하는 방법을 아는 노드로 문법 제거했기 때문에, 더 이상 할 일이 없습니다.
마침내 Lox는 적어도 몇 분 동안은 우리를 즐겁게 할 만큼 강력해졌습니다. 다음은 피보나치 수열의 처음 21개 요소를 출력하는 작은 프로그램입니다.
var a = 0; var temp; for (var b = 1; a < 10000; b = temp + b) { print a; temp = a; a = b; }
도전 과제
-
몇 장 뒤에 Lox가 일급 함수(first-class functions)와 동적 디스패치(dynamic dispatch)를 지원하게 되면, 기술적으로 언어에 내장된 분기문이 필요하지 않게 됩니다. 이를 통해 조건부 실행을 어떻게 구현할 수 있는지 보여주세요. 이 기법을 제어 흐름에 사용하는 언어의 이름을 하나 대세요.
-
마찬가지로, 반복(looping)도 동일한 도구를 사용하여 구현할 수 있습니다. 단, 우리 인터프리터가 중요한 최적화를 지원해야 합니다. 이 최적화는 무엇이며, 왜 필요한가요? 이 기법을 반복에 사용하는 언어의 이름을 하나 대세요.
-
Lox와 달리, 대부분의 다른 C 스타일 언어는 루프 내에서
break및continue문을 지원합니다.break문 지원을 추가하세요.구문은
break키워드 뒤에 세미콜론이 오는 형태입니다.break문이 어떤 루프의 바깥에 나타나는 것은 문법 오류여야 합니다. 런타임에break문은 가장 가까운 바깥 루프의 끝으로 실행을 점프시키고 거기서부터 진행됩니다.break가 다른 블록이나if문 안에 중첩될 수 있으며, 이들도 빠져나와야 한다는 점에 유의하세요.
디자인 노트: 문법적 설탕 한 스푼
자신만의 언어를 설계할 때, 문법에 얼마나 많은 문법적 설탕을 넣을지 선택하게 됩니다. 각 의미론적 연산이 단일 문법 단위에 매핑되는 설탕 없는 건강 식품을 만들 것인가, 아니면 모든 동작이 10가지 다른 방식으로 표현될 수 있는 퇴폐적인 디저트를 만들 것인가? 성공적인 언어들은 이 연속선상의 모든 지점에 존재합니다.
극도로 불쾌한 끝에는 Lisp, Forth, Smalltalk처럼 무자비하게 최소화된 문법을 가진 언어들이 있습니다. 리스프 사용자들은 자랑스럽게 자신들의 언어가 "문법이 없다"고 주장하고, 스몰토크 사용자들은 전체 문법을 색인 카드 한 장에 담을 수 있음을 보여줍니다. 이 부족은 언어 자체가 문법적 설탕을 필요로 하지 않는다는 철학을 가지고 있습니다. 대신, 언어가 제공하는 최소한의 문법과 의미론은 라이브러리 코드가 마치 언어 자체의 일부인 것처럼 표현될 수 있을 만큼 강력합니다.
이들 근처에는 C, Lua, Go와 같은 언어들이 있습니다. 이들은 미니멀리즘보다는 단순성과 명료함을 추구합니다. Go와 같은 일부 언어는 문법적 설탕과 이전 범주의 문법적 확장성 모두를 의도적으로 피합니다. 그들은 문법이 의미론을 방해하지 않기를 바라므로, 문법과 라이브러리 모두를 단순하게 유지하는 데 중점을 둡니다. 코드는 아름답기보다는 명확해야 합니다.
중간쯤에는 Java, C#, Python과 같은 언어들이 있습니다. 결국 Ruby, C++, Perl, D에 이르게 됩니다. 이 언어들은 문법에 너무 많은 구문을 채워 넣어 키보드의 구두점 문자가 부족할 정도입니다.
어느 정도는 스펙트럼에서의 위치가 나이와 관련이 있습니다. 나중에 출시되는 버전에서 문법적 설탕을 추가하는 것은 비교적 쉽습니다. 새로운 구문은 대중을 즐겁게 하며, 의미론을 변경하는 것보다 기존 프로그램을 손상시킬 가능성이 적습니다. 일단 추가되면 다시 제거할 수 없으므로, 언어는 시간이 지남에 따라 달콤해지는 경향이 있습니다. 처음부터 새로운 언어를 만드는 주된 이점 중 하나는 축적된 설탕 층을 긁어내고 다시 시작할 기회를 제공한다는 것입니다.
문법적 설탕은 PL(프로그래밍 언어) 지식인들 사이에서 나쁜 평판을 가지고 있습니다. 그들 사이에는 미니멀리즘에 대한 진정한 숭배가 있습니다. 여기에는 어느 정도 정당성이 있습니다. 잘못 설계되고 불필요한 구문은 충분한 표현력을 추가하지 않으면서 인지 부하를 증가시킵니다. 언어에 새로운 기능을 추가하라는 압력이 항상 존재하므로, 단순성에 대한 규율과 집중이 부풀어 오르는 것을 피하는 데 필요합니다. 일단 어떤 구문을 추가하면, 그것과 함께 살아가야 하므로, 검소하게 행동하는 것이 현명합니다.
동시에, 대부분의 성공적인 언어는 적어도 널리 사용될 때쯤에는 상당히 복잡한 문법을 가지고 있습니다. 프로그래머는 자신이 선택한 언어에서 엄청난 시간을 보내며, 여기저기 몇 가지 편의 기능은 작업의 편안함과 효율성을 실제로 향상시킬 수 있습니다.
올바른 균형을 잡는 것, 즉 여러분의 언어에 적절한 수준의 단맛을 선택하는 것은 여러분 자신의 미적 감각에 달려 있습니다.