스캐닝
크게 한입 베어 물어요. 가치 있는 일이라면 무엇이든 과장해서 할 가치가 있어요.
로버트 A. 하인라인, 사랑할 시간만 있다면
모든 컴파일러나 인터프리터의 첫 번째 단계는 스캐닝(scanning)입니다. 스캐너는 원본 소스 코드를 문자열로 받아들여, 이를 의미 있는 조각인 토큰(token)으로 묶습니다. 이 토큰들은 해당 언어의 문법을 구성하는 의미 있는 "단어"와 "구두점"입니다.
스캐닝은 우리에게도 좋은 시작점입니다. 코드가 그다지 어렵지 않기 때문입니다—거창한 망상에 사로잡힌 switch 문에 가깝죠. 이 스캐닝을 통해 나중에 더 흥미로운 자료를 다루기 전에 몸을 풀 수 있을 겁니다. 이 장을 마칠 때쯤이면, 어떤 Lox 소스 코드 문자열이든 받아들여 다음 장에서 파서에 공급할 토큰을 생성할 수 있는 완전하고 빠른 스캐너를 갖게 될 것입니다.
4 . 1인터프리터 프레임워크
이번 장이 첫 번째 실제 장이므로, 코드를 실제로 스캔하기 전에 우리의 인터프리터인 jlox의 기본 틀을 잡을 필요가 있습니다. 자바에서는 모든 것이 클래스에서 시작합니다.
create new file
package com.craftinginterpreters.lox; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Paths; import java.util.List; public class Lox { public static void main(String[] args) throws IOException { if (args.length > 1) { System.out.println("사용법: jlox [스크립트]"); System.exit(64); } else if (args.length == 1) { runFile(args[0]); } else { runPrompt(); } } }
이 내용을 텍스트 파일에 넣고, IDE나 Makefile 등을 설정하세요. 준비가 되면 제가 여기서 기다리겠습니다. 됐나요? 좋아요!
Lox는 스크립트 언어이며, 이는 소스 코드에서 직접 실행됨을 의미합니다. 우리 인터프리터는 코드를 실행하는 두 가지 방법을 지원합니다. jlox를 명령줄에서 시작하고 파일 경로를 제공하면, 파일을 읽고 실행합니다.
add after main()
private static void runFile(String path) throws IOException { byte[] bytes = Files.readAllAllBytes(Paths.get(path)); run(new String(bytes, Charset.defaultCharset())); }
인터프리터와 좀 더 긴밀하게 대화하고 싶다면, 대화형으로 실행할 수도 있습니다. 인자 없이 jlox를 실행하면, 한 번에 한 줄씩 코드를 입력하고 실행할 수 있는 프롬프트로 들어갑니다.
add after runFile()
private static void runPrompt() throws IOException { InputStreamReader input = new InputStreamReader(System.in); BufferedReader reader = new BufferedReader(input); for (;;) { System.out.print("> "); String line = reader.readLine(); if (line == null) break; run(line); } }
readLine() 함수는 이름이 친절하게 암시하듯이, 명령줄에서 사용자로부터 한 줄의 입력을 읽어 결과를 반환합니다. 대화형 명령줄 앱을 종료하려면 보통 Control-D를 입력합니다. 이렇게 하면 프로그램에 "파일 끝(end-of-file)" 조건을 알립니다. 이 경우 readLine()은 null을 반환하므로, 우리는 이를 확인하여 루프를 종료합니다.
프롬프트와 파일 실행기 모두 이 핵심 함수를 얇게 감싸는 래퍼입니다:
add after runPrompt()
private static void run(String source) { Scanner scanner = new Scanner(source); List<Token> tokens = scanner.scanTokens(); // 일단은 토큰들을 출력합니다. for (Token token : tokens) { System.out.println(token); } }
아직 인터프리터를 작성하지 않았기 때문에 크게 유용하지는 않지만, 천리길도 한 걸음부터라는 거 아시죠? 지금은 앞으로 우리가 만들 스캐너가 생성할 토큰들을 출력하여 진행 상황을 확인할 수 있도록 합니다.
4 . 1 . 1오류 처리
환경을 설정하는 동안, 또 다른 핵심 인프라는 오류 처리(error handling)입니다. 교과서에서는 이것이 형식적인 컴퓨터 과학 문제라기보다는 실용적인 문제이기 때문에 때때로 간과하기도 합니다. 하지만 실제로 사용 가능한 언어를 만드는 데 관심이 있다면, 오류를 우아하게 처리하는 것이 매우 중요합니다.
우리 언어가 오류 처리를 위해 제공하는 도구들은 사용자 인터페이스의 상당 부분을 차지합니다. 사용자 코드가 잘 작동할 때는 우리 언어에 대해 전혀 생각하지 않습니다—그들의 머릿속은 온통 자신의 프로그램으로 가득 차 있습니다. 보통 문제가 발생했을 때에야 비로소 우리의 구현을 알아차리게 됩니다.
문제가 발생했을 때, 무엇이 잘못되었는지 이해하고 원하는 방향으로 부드럽게 돌아갈 수 있도록 필요한 모든 정보를 사용자에게 제공하는 것은 우리의 책임입니다. 이를 잘 수행하려면 지금부터 인터프리터 구현 전반에 걸쳐 오류 처리에 대해 생각해야 합니다.
add after run()
static void error(int line, String message) { report(line, "", message); } private static void report(int line, String where, String message) { System.err.println( "[줄 " + line + "] 오류" + where + ": " + message); hadError = true; }
이 error() 함수와 그 보조 함수인 report()는 사용자에게 특정 줄에서 구문 오류가 발생했음을 알립니다. 이는 오류 보고 기능이 있다고 주장할 수 있는 최소한의 조건입니다. 예를 들어, 어떤 함수 호출에 쉼표를 실수로 남겨두었는데 인터프리터가 다음과 같이 출력한다고 상상해 보세요:
오류: 코드 어딘가에 예상치 못한 ","가 있습니다. 행운을 빌어요!
그것은 별로 도움이 되지 않습니다. 적어도 올바른 줄을 알려줘야 합니다. 더 나은 방법은 시작과 끝 열을 알려주어 줄의 어디에 문제가 있는지 알게 하는 것입니다. 그보다 더 좋은 방법은 사용자에게 문제의 줄을 보여주는 것입니다. 예를 들어:
오류: 인자 목록에 예상치 못한 ","가 있습니다.
15 | function(first, second,);
^-- 여기.
이 책에서 그런 기능을 구현하고 싶지만, 솔직히 말해서 지저분한 문자열 조작 코드가 많습니다. 사용자에게는 매우 유용하지만, 책에서 읽기에는 그리 재미있지 않고 기술적으로도 흥미롭지 않습니다. 그래서 우리는 줄 번호만 표시하는 데 만족할 것입니다. 여러분의 인터프리터에서는 제가 말한 대로 하고 제가 하는 대로 따르지 마세요.
이 오류 보고 함수를 주 Lox 클래스에 유지하는 주된 이유는 hadError 필드 때문입니다. 이 필드는 여기에 정의되어 있습니다:
public class Lox {
in class Lox
static boolean hadError = false;
우리는 이것을 사용하여 알려진 오류가 있는 코드를 실행하려고 시도하지 않도록 할 것입니다. 또한, 이는 훌륭한 명령줄 프로그램이 해야 하는 것처럼 0이 아닌 종료 코드로 종료할 수 있게 해줍니다.
run(new String(bytes, Charset.defaultCharset()));
in runFile()
// 종료 코드에 오류를 알립니다.
if (hadError) System.exit(65);
}
대화형 루프에서는 이 플래그를 재설정해야 합니다. 사용자가 실수를 해도 전체 세션을 종료해서는 안 됩니다.
run(line);
in runPrompt()
hadError = false;
}
오류가 발생할 수 있는 스캐너 및 다른 단계에 오류 보고를 직접 넣는 대신 여기에 별도로 분리한 또 다른 이유는, 오류를 생성하는 코드와 오류를 보고하는 코드를 분리하는 것이 좋은 공학적 관행이라는 것을 상기시키기 위함입니다.
프론트엔드의 다양한 단계에서 오류를 감지하겠지만, 사용자에게 이를 어떻게 제시할지는 실제로는 그들의 역할이 아닙니다. 완전한 기능을 갖춘 언어 구현에서는 오류가 표시되는 여러 방법(stderr에, IDE의 오류 창에, 파일에 로그로 등)이 있을 수 있습니다. 그런 코드를 스캐너와 파서 전체에 뒤섞어 놓는 것을 원치 않을 것입니다.
이상적으로는, 스캐너와 파서에 전달되는 일종의 “ErrorReporter” 인터페이스와 같은 실제 추상화를 두어, 다양한 보고 전략을 교체할 수 있도록 할 것입니다. 여기 우리의 간단한 인터프리터에서는 그렇게 하지 않았지만, 적어도 오류 보고 코드를 다른 클래스로 옮겼습니다.
기본적인 오류 처리가 마련되었으니, 우리 애플리케이션 셸은 준비되었습니다. scanTokens() 메서드를 가진 Scanner 클래스가 생기면 바로 실행을 시작할 수 있습니다. 그전에 토큰이 무엇인지 좀 더 정확히 알아보겠습니다.
4 . 2렉심과 토큰
다음은 Lox 코드 한 줄입니다:
var language = "lox";
여기서 var는 변수를 선언하는 키워드입니다. 세 문자 "v-a-r" 시퀀스는 어떤 의미를 가집니다. 하지만 language의 중간에서 "g-u-a"와 같은 세 글자를 뽑아내면, 그것들은 그 자체로 아무 의미도 없습니다.
이것이 어휘 분석(lexical analysis)이 하는 일입니다. 우리의 임무는 문자 목록을 스캔하여 여전히 무언가를 나타내는 가장 작은 시퀀스로 묶는 것입니다. 이렇게 묶인 문자 덩어리들을 각각 렉심(lexeme)이라고 부릅니다. 위 코드 예시에서 렉심은 다음과 같습니다:
렉심은 그저 소스 코드의 원본 부분 문자열일 뿐입니다. 하지만 문자 시퀀스를 렉심으로 묶는 과정에서 다른 유용한 정보들도 발견하게 됩니다. 렉심을 다른 데이터와 함께 묶으면, 그 결과가 토큰(token)입니다. 여기에는 다음과 같은 유용한 정보들이 포함됩니다:
4 . 2 . 1토큰 타입
키워드는 언어 문법의 일부이므로, 파서에는 종종 "다음 토큰이 while이면 ...을 하라"와 같은 코드가 있습니다. 이는 파서가 어떤 식별자에 대한 렉심을 가지고 있다는 것뿐만 아니라, 그것이 예약된 단어이며, 어떤 키워드인지 알고 싶어 한다는 의미입니다.
파서는 원본 렉심을 문자열 비교를 통해 토큰을 분류할 수 있지만, 이는 느리고 다소 보기 좋지 않습니다. 대신, 렉심을 인식하는 시점에서 해당 렉심이 어떤 종류를 나타내는지도 기억합니다. 우리는 각 키워드, 연산자, 구두점, 그리고 리터럴 타입마다 다른 타입을 가집니다.
create new file
package com.craftinginterpreters.lox; enum TokenType { // 한 글자 토큰. LEFT_PAREN, RIGHT_PAREN, LEFT_BRACE, RIGHT_BRACE, COMMA, DOT, MINUS, PLUS, SEMICOLON, SLASH, STAR, // 한두 글자 토큰. BANG, BANG_EQUAL, EQUAL, EQUAL_EQUAL, GREATER, GREATER_EQUAL, LESS, LESS_EQUAL, // 리터럴. IDENTIFIER, STRING, NUMBER, // 키워드. AND, CLASS, ELSE, FALSE, FUN, FOR, IF, NIL, OR, PRINT, RETURN, SUPER, THIS, TRUE, VAR, WHILE, EOF }
4 . 2 . 2리터럴 값
숫자, 문자열 등 리터럴 값에 대한 렉심이 있습니다—. 스캐너는 리터럴의 각 문자를 따라가며 정확하게 식별해야 하므로, 값의 텍스트 표현을 나중에 인터프리터에서 사용될 실제 런타임 객체로 변환할 수도 있습니다.
4 . 2 . 3위치 정보
오류 처리에 대해 열심히 설명할 때, 우리는 사용자에게 오류가 어디에서 발생했는지 알려줄 필요가 있다는 것을 보았습니다. 그 추적은 여기서 시작됩니다. 우리의 간단한 인터프리터에서는 토큰이 나타나는 줄만 기록하지만, 더 정교한 구현에서는 열과 길이도 포함합니다.
우리는 이 모든 데이터를 클래스에 래핑합니다.
create new file
package com.craftinginterpreters.lox; class Token { final TokenType type; final String lexeme; final Object literal; final int line; Token(TokenType type, String lexeme, Object literal, int line) { this.type = type; this.lexeme = lexeme; this.literal = literal; this.line = line; } public String toString() { return type + " " + lexeme + " " + literal; } }
이제 인터프리터의 모든 이후 단계에서 유용하게 사용될 수 있을 만큼의 충분한 구조를 가진 객체를 갖게 되었습니다.
4 . 3정규 언어와 정규 표현식
이제 우리가 무엇을 만들어야 할지 알았으니, 이제 그것을 만들어 봅시다. 스캐너의 핵심은 루프입니다. 스캐너는 소스 코드의 첫 문자부터 시작하여 해당 문자가 속하는 렉심을 파악하고, 그 문자와 그 렉심의 일부인 모든 후속 문자를 소비합니다. 해당 렉심의 끝에 도달하면 토큰을 생성합니다.
그러고 나서 루프를 돌아 소스 코드의 바로 다음 문자부터 다시 시작합니다. 입력을 끝까지 다다를 때까지 문자를 계속 먹고 가끔, 음, 토큰을 배출합니다.
루프에서 몇 개의 문자를 살펴보며 어떤 종류의 렉심에 "일치"하는지 파악하는 부분이 익숙하게 들릴 수 있습니다. 정규 표현식(regular expressions)을 안다면, 각 렉심 종류마다 정규 표현식을 정의하고 이를 사용하여 문자를 일치시키는 것을 고려할 수도 있습니다. 예를 들어, Lox는 식별자(변수 이름 등)에 대해 C와 동일한 규칙을 가집니다. 다음 정규 표현식이 하나에 일치합니다:
[a-zA-Z_][a-zA-Z_0-9]*
정규 표현식을 생각했다면, 여러분의 직관은 매우 뛰어납니다. 특정 언어가 문자를 렉심으로 그룹화하는 방식을 결정하는 규칙을 어휘 문법(lexical grammar)이라고 합니다. Lox에서는 대부분의 프로그래밍 언어와 마찬가지로, 이 문법의 규칙이 너무 단순하여 언어가 정규 언어(regular language)로 분류됩니다. 여기서 "정규"는 정규 표현식의 "정규"와 같습니다.
원한다면 정규 표현식을 사용하여 Lox의 모든 다양한 렉심을 매우 정확하게 인식할 수 있습니다. 그리고 왜 그런지, 무엇을 의미하는지에 대한 흥미로운 이론들이 많이 있습니다. Lex나 Flex와 같은 도구들은 바로 이런 일을 할 수 있도록 설계되었습니다—몇 개의 정규 표현식을 입력하면, 완전한 스캐너를 돌려줍니다.
우리의 목표는 스캐너가 어떻게 작동하는지 이해하는 것이므로, 그 작업을 위임하지 않을 것입니다. 우리는 수공예품을 만드는 것과 같습니다.
4 . 4Scanner 클래스
더 이상 지체하지 말고, 우리만의 스캐너를 만들어 봅시다.
create new file
package com.craftinginterpreters.lox; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import static com.craftinginterpreters.lox.TokenType.*; class Scanner { private final String source; private final List<Token> tokens = new ArrayList<>(); Scanner(String source) { this.source = source; } }
우리는 원본 소스 코드를 단순한 문자열로 저장하며, 생성할 토큰들로 채울 목록을 준비해 두었습니다. 위에서 언급한 루프는 다음과 같습니다:
add after Scanner()
List<Token> scanTokens() { while (!isAtEnd()) { // 다음 렉심의 시작점에 있습니다. start = current; scanToken(); } tokens.add(new Token(EOF, "", null, line)); return tokens; }
스캐너는 소스 코드를 따라가며 문자가 다 떨어질 때까지 토큰을 추가합니다. 그런 다음 마지막 "파일 끝" 토큰 하나를 덧붙입니다. 이것이 엄격히 필요하지는 않지만, 파서를 조금 더 깔끔하게 만듭니다.
이 루프는 스캐너가 소스 코드의 어디에 있는지 추적하기 위한 몇 가지 필드에 의존합니다.
private final List<Token> tokens = new ArrayList<>();
in class Scanner
private int start = 0; private int current = 0; private int line = 1;
Scanner(String source) {
start와 current 필드는 문자열을 인덱싱하는 오프셋입니다. start 필드는 스캔 중인 렉심의 첫 문자를 가리키고, current는 현재 고려 중인 문자를 가리킵니다. line 필드는 current가 있는 소스 줄을 추적하여 위치를 아는 토큰을 생성할 수 있도록 합니다.
그런 다음 모든 문자를 다 소비했는지 알려주는 작은 도우미 함수가 있습니다.
add after scanTokens()
private boolean isAtEnd() { return current >= source.length(); }
4 . 5렉심 인식하기
루프의 각 회전마다 하나의 토큰을 스캔합니다. 이것이 스캐너의 진정한 핵심입니다. 간단하게 시작해 보겠습니다. 모든 렉심이 단일 문자 길이였다고 상상해 보세요. 다음 문자를 소비하고 해당 문자에 대한 토큰 타입을 선택하기만 하면 됩니다. Lox에는 실제로 단일 문자 렉심이 여러 개 있으므로, 그것들부터 시작해 봅시다.
add after scanTokens()
private void scanToken() { char c = advance(); switch (c) { case '(': addToken(LEFT_PAREN); break; case ')': addToken(RIGHT_PAREN); break; case '{': addToken(LEFT_BRACE); break; case '}': addToken(RIGHT_BRACE); break; case ',': addToken(COMMA); break; case '.': addToken(DOT); break; case '-': addToken(MINUS); break; case '+': addToken(PLUS); break; case ';': addToken(SEMICOLON); break; case '*': addToken(STAR); break; } }
다시 한번, 몇 가지 도우미 메서드가 필요합니다.
add after isAtEnd()
private char advance() { return source.charAt(current++); } private void addToken(TokenType type) { addToken(type, null); } private void addToken(TokenType type, Object literal) { String text = source.substring(start, current); tokens.add(new Token(type, text, literal, line)); }
advance() 메서드는 소스 파일에서 다음 문자를 소비하고 반환합니다. advance()가 입력용이라면, addToken()은 출력용입니다. 현재 렉심의 텍스트를 가져와서 새로운 토큰을 생성합니다. 곧 리터럴 값을 가진 토큰을 처리하기 위해 다른 오버로드를 사용할 것입니다.
4 . 5 . 1어휘 오류
너무 멀리 가기 전에, 어휘 수준의 오류에 대해 잠시 생각해 봅시다. 사용자가 Lox에서 사용하지 않는 @#^와 같은 문자를 포함하는 소스 파일을 우리 인터프리터에 던지면 어떻게 될까요? 지금으로서는 그런 문자들은 조용히 버려집니다. 그것들은 Lox 언어에 의해 사용되지 않지만, 그렇다고 인터프리터가 그것들이 없는 척할 수는 없습니다. 대신, 우리는 오류를 보고합니다.
case '*': addToken(STAR); break;
in scanToken()
default:
Lox.error(line, "예상치 못한 문자입니다.");
break;
}
오류가 있는 문자가 이전 advance() 호출에 의해 여전히 소비된다는 점에 주목하세요. 이는 무한 루프에 빠지지 않도록 하는 데 중요합니다.
또한 우리는 계속 스캔한다는 점에도 주목하세요. 프로그램 뒤에 다른 오류들이 있을 수 있습니다. 가능한 한 많은 오류를 한 번에 감지한다면 사용자에게 더 나은 경험을 제공할 것입니다. 그렇지 않으면, 사용자는 하나의 작은 오류를 보고 수정하지만, 다음 오류가 나타나고, 또 다음 오류가 나타나는 식의 반복을 겪게 됩니다. 구문 오류 두더지 잡기 게임은 재미없습니다.
(걱정 마세요. hadError가 설정되므로, 나머지 코드를 계속 스캔하더라도 우리는 그 어떤 코드도 실행하려 하지 않을 것입니다.)
4 . 5 . 2연산자
단일 문자 렉심은 작동하지만, Lox의 모든 연산자를 다루지는 못합니다. !는 어떤가요? 한 문자죠, 맞죠? 때로는 그렇지만, 바로 다음 문자가 등호(=)이면 대신 != 렉심을 생성해야 합니다. !와 =는 두 개의 독립적인 연산자가 아닙니다. Lox에서 ! =라고 써서 부등 연산자처럼 동작하게 할 수는 없습니다. 그래서 !=를 하나의 렉심으로 스캔해야 합니다. 마찬가지로 <, >, =는 모두 뒤에 =가 올 수 있어 다른 동등 및 비교 연산자를 만듭니다.
이 모든 경우에 우리는 두 번째 문자를 살펴보아야 합니다.
case '*': addToken(STAR); break;
in scanToken()
case '!': addToken(match('=') ? BANG_EQUAL : BANG); break; case '=': addToken(match('=') ? EQUAL_EQUAL : EQUAL); break; case '<': addToken(match('=') ? LESS_EQUAL : LESS); break; case '>': addToken(match('=') ? GREATER_EQUAL : GREATER); break;
default:
이 경우들은 이 새로운 메서드를 사용합니다:
add after scanToken()
private boolean match(char expected) { if (isAtEnd()) return false; if (source.charAt(current) != expected) return false; current++; return true; }
이것은 조건부 advance()와 같습니다. 우리가 찾고 있는 문자일 때만 현재 문자를 소비합니다.
match()를 사용하여 이 렉심들을 두 단계로 인식합니다. 예를 들어 !에 도달하면 해당 switch case로 점프합니다. 이는 렉심이 !로 시작한다는 것을 의미합니다. 그런 다음 다음 문자를 확인하여 !=인지 아니면 단순히 !인지를 결정합니다.
4 . 6더 긴 렉심
아직 하나의 연산자가 빠져 있습니다: 나눗셈을 위한 /입니다. 이 문자는 주석도 슬래시로 시작하기 때문에 약간 특별한 처리가 필요합니다.
break;
in scanToken()
case '/': if (match('/')) { // 한 줄 주석은 줄의 끝까지 이어집니다. while (peek() != '\n' && !isAtEnd()) advance(); } else { addToken(SLASH); } break;
default:
이것은 다른 두 문자 연산자와 비슷하지만, 두 번째 /를 찾았을 때 아직 토큰을 끝내지 않는다는 점이 다릅니다. 대신, 줄의 끝에 도달할 때까지 문자를 계속 소비합니다.
이것은 더 긴 렉심을 처리하는 우리의 일반적인 전략입니다. 렉심의 시작을 감지한 후에는, 끝을 볼 때까지 문자를 계속 읽어들이는 렉심별 코드로 전환합니다.
또 다른 도우미 메서드가 있습니다:
add after match()
private char peek() { if (isAtEnd()) return '\0'; return source.charAt(current); }
advance()와 비슷하지만 문자를 소비하지 않습니다. 이를 미리 보기(lookahead)라고 합니다. 현재 소비되지 않은 문자만 보기 때문에, 우리는 한 문자를 미리 볼 수 있습니다. 이 숫자가 작을수록 일반적으로 스캐너는 더 빠르게 실행됩니다. 어휘 문법의 규칙은 우리가 얼마나 많은 미리 보기를 필요로 하는지 결정합니다. 다행히도, 널리 사용되는 대부분의 언어는 한두 문자만 미리 봅니다.
주석은 렉심이지만, 의미가 없으며 파서는 이를 다루고 싶어 하지 않습니다. 따라서 주석의 끝에 도달하면 addToken()을 호출하지 않습니다. 다음 렉심을 시작하기 위해 루프를 다시 돌 때, start가 재설정되고 주석 렉심은 연기처럼 사라집니다.
김에, 이제 다른 의미 없는 문자들, 즉 줄바꿈(newline)과 공백(whitespace)을 건너뛰는 좋은 시점입니다.
break;
in scanToken()
case ' ':
case '\r':
case '\t':
// 공백 무시.
break;
case '\n':
line++;
break;
default:
Lox.error(line, "예상치 못한 문자입니다.");
공백을 만나면 스캔 루프의 시작으로 단순히 돌아갑니다. 그러면 공백 문자 뒤에 새로운 렉심이 시작됩니다. 줄바꿈의 경우에도 마찬가지로 처리하지만, 줄 카운터도 증가시킵니다. (이것이 주석을 끝내는 줄바꿈을 찾기 위해 match() 대신 peek()를 사용한 이유입니다. 그 줄바꿈이 여기에 도달하여 line을 업데이트할 수 있도록 하려는 것입니다.)
우리 스캐너는 점점 더 똑똑해지고 있습니다. 다음과 같이 비교적 자유로운 형식의 코드를 처리할 수 있습니다:
// 이것은 주석입니다. (( )){} // 괄호 묶기 !*+-/=<> <= == // 연산자
4 . 6 . 1문자열 리터럴
이제 더 긴 렉심에 익숙해졌으니, 리터럴(literal)을 다룰 준비가 되었습니다. 문자열은 항상 특정 문자 "로 시작하므로, 문자열부터 처리하겠습니다.
break;
in scanToken()
case '"': string(); break;
default:
이 코드는 다음을 호출합니다:
add after scanToken()
private void string() { while (peek() != '"' && !isAtEnd()) { if (peek() == '\n') line++; advance(); } if (isAtEnd()) { Lox.error(line, "닫히지 않은 문자열입니다."); return; } // 닫는 따옴표 "입니다. advance(); // 주변 따옴표를 제거합니다. String value = source.substring(start + 1, current - 1); addToken(STRING, value); }
주석과 마찬가지로, 문자열을 끝내는 "를 만날 때까지 문자를 소비합니다. 또한 문자열이 닫히기 전에 입력이 끝나는 경우를 우아하게 처리하고 이에 대한 오류를 보고합니다.
특별한 이유 없이, Lox는 여러 줄 문자열을 지원합니다. 장단점이 있지만, 금지하는 것이 허용하는 것보다 약간 더 복잡했기 때문에 그대로 두었습니다. 이는 문자열 내부에서 줄바꿈을 만날 때 line도 업데이트해야 한다는 의미입니다.
마지막으로 흥미로운 부분은 토큰을 생성할 때, 나중에 인터프리터에서 사용될 실제 문자열 값도 생성한다는 것입니다. 여기서는 그 변환에 주변 따옴표를 제거하는 substring()만 필요합니다. 만약 Lox가 \n과 같은 이스케이프 시퀀스를 지원한다면, 여기에서 이스케이프를 해제해야 했을 것입니다.
4 . 6 . 2숫자 리터럴
Lox의 모든 숫자는 런타임에 부동 소수점이지만, 정수 및 십진수 리터럴 모두 지원됩니다. 숫자 리터럴은 선택적으로 .와 하나 이상의 후행 숫자가 뒤따르는 일련의 숫자입니다.
1234 12.34
선행 또는 후행 소수점을 허용하지 않으므로, 이 두 가지는 모두 유효하지 않습니다:
.1234 1234.
전자는 쉽게 지원할 수 있었지만, 간단하게 유지하기 위해 제외했습니다. 후자는 123.sqrt()와 같이 숫자에 메서드를 허용하고 싶을 경우 이상해집니다.
숫자 렉심의 시작을 인식하기 위해, 우리는 모든 숫자를 찾습니다. 모든 십진수에 대한 케이스를 추가하는 것은 다소 지루하므로, 대신 기본(default) 케이스에 포함시킬 것입니다.
default:
in scanToken()
replace 1 line
if (isDigit(c)) { number(); } else { Lox.error(line, "예상치 못한 문자입니다."); }
break;
이는 이 작은 유틸리티에 의존합니다:
add after peek()
private boolean isDigit(char c) { return c >= '0' && c <= '9'; }
숫자임을 알게 되면, 문자열을 처리할 때처럼 리터럴의 나머지 부분을 소비하기 위해 별도의 메서드로 분기합니다.
add after scanToken()
private void number() { while (isDigit(peek())) advance(); // 소수 부분을 찾습니다. if (peek() == '.' && isDigit(peekNext())) { // "."를 소비합니다. advance(); while (isDigit(peek())) advance(); } addToken(NUMBER, Double.parseDouble(source.substring(start, current))); }
리터럴의 정수 부분을 위해 찾을 수 있는 만큼 많은 숫자를 소비합니다. 그런 다음 소수점(.)과 그 뒤에 적어도 하나의 숫자가 오는 소수 부분을 찾습니다. 소수 부분이 있다면, 다시 한번, 찾을 수 있는 만큼 많은 숫자를 소비합니다.
소수점 뒤를 보는 것은 두 번째 문자를 미리 보는 것을 요구합니다. 왜냐하면 소수점 뒤에 숫자가 있는지 확실하기 전까지는 .을 소비하고 싶지 않기 때문입니다. 그래서 다음을 추가합니다:
add after peek()
private char peekNext() { if (current + 1 >= source.length()) return '\0'; return source.charAt(current + 1); }
마지막으로, 렉심을 숫자 값으로 변환합니다. 우리 인터프리터는 숫자를 표현하기 위해 자바의 Double 타입을 사용하므로, 해당 타입의 값을 생성합니다. 렉심을 실제 자바 double로 변환하기 위해 자바 자체의 파싱 메서드를 사용합니다. 우리가 직접 구현할 수도 있지만, 솔직히 말해서 다가오는 프로그래밍 면접을 위해 벼락치기를 하는 경우가 아니라면 시간을 낭비할 가치는 없습니다.
남은 리터럴은 불리언과 nil이지만, 이들은 키워드로 처리할 것이며, 이제 우리는 다음으로 넘어갑니다 . . .
4 . 7예약어와 식별자
우리 스캐너는 거의 완성되었습니다. 어휘 문법에서 구현할 남은 부분은 식별자와 그 친척인 예약어(reserved words)입니다. or와 같은 키워드를 <=와 같은 여러 문자 연산자를 처리하는 방식과 동일하게 매칭할 수 있다고 생각할 수도 있습니다.
case 'o': if (match('r')) { addToken(OR); } break;
만약 사용자가 변수 이름을 orchid라고 지었다면 어떤 일이 발생할지 생각해 봅시다. 스캐너는 처음 두 글자인 or를 보고 즉시 or 키워드 토큰을 발행할 것입니다. 이는 최대 탐식(maximal munch)이라는 중요한 원칙으로 이어집니다. 두 개의 어휘 문법 규칙이 스캐너가 보고 있는 코드 덩어리에 모두 일치할 수 있을 때, 가장 많은 문자를 일치시키는 규칙이 이깁니다.
이 규칙은 orchid를 식별자로, or를 키워드로 매칭할 수 있다면 전자가 이긴다고 말합니다. 이것이 또한 우리가 이전에 <=를 < 다음에 =가 오는 것이 아니라 단일 <= 토큰으로 스캔해야 한다고 암묵적으로 가정했던 이유입니다.
최대 탐식은 우리가 식별자일 수도 있는 것의 끝에 도달할 때까지 예약어를 쉽게 감지할 수 없다는 것을 의미합니다. 결국, 예약어는 식별자이지만, 언어가 자체 용도로 사용하기 위해 주장한 식별자일 뿐입니다. 예약어(reserved word)라는 용어는 여기서 유래합니다.
따라서 우리는 글자나 밑줄로 시작하는 모든 렉심을 식별자로 가정하는 것으로 시작합니다.
default:
if (isDigit(c)) {
number();
in scanToken()
} else if (isAlpha(c)) { identifier();
} else {
Lox.error(line, "예상치 못한 문자입니다.");
}
나머지 코드는 여기에 있습니다:
add after scanToken()
private void identifier() { while (isAlphaNumeric(peek())) advance(); addToken(IDENTIFIER); }
이것을 다음과 같은 도우미 함수들로 정의합니다:
add after peekNext()
private boolean isAlpha(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'; } private boolean isAlphaNumeric(char c) { return isAlpha(c) || isDigit(c); }
이것으로 식별자가 작동합니다. 키워드를 처리하기 위해, 식별자의 렉심이 예약어 중 하나인지 확인합니다. 그렇다면 해당 키워드에 특정한 토큰 타입을 사용합니다. 예약어 집합은 맵(map)에 정의합니다.
in class Scanner
private static final Map<String, TokenType> keywords; static { keywords = new HashMap<>(); keywords.put("and", AND); keywords.put("class", CLASS); keywords.put("else", ELSE); keywords.put("false", FALSE); keywords.put("for", FOR); keywords.put("fun", FUN); keywords.put("if", IF); keywords.put("nil", NIL); keywords.put("or", OR); keywords.put("print", PRINT); keywords.put("return", RETURN); keywords.put("super", SUPER); keywords.put("this", THIS); keywords.put("true", TRUE); keywords.put("var", VAR); keywords.put("while", WHILE); }
그런 다음, 식별자를 스캔한 후에, 맵에 있는 어떤 것과 일치하는지 확인합니다.
while (isAlphaNumeric(peek())) advance();
in identifier()
replace 1 line
String text = source.substring(start, current); TokenType type = keywords.get(text); if (type == null) type = IDENTIFIER; addToken(type);
}
그렇다면, 해당 키워드의 토큰 타입을 사용합니다. 그렇지 않다면, 일반적인 사용자 정의 식별자입니다.
이로써 Lox의 전체 어휘 문법을 위한 완전한 스캐너를 갖게 되었습니다. REPL을 실행하고 유효하거나 유효하지 않은 코드를 입력해 보세요. 예상하는 토큰이 생성되나요? 흥미로운 예외 경우들을 생각해내어 스캐너가 제대로 처리하는지 확인해 보세요.
도전 과제
-
Python과 Haskell의 어휘 문법은 정규적(regular)이지 않습니다. 이것이 무엇을 의미하며, 왜 정규적이지 않을까요?
-
토큰을 분리하는 것—예:
print foo와printfoo를 구별—외에, 대부분의 언어에서 공백은 크게 사용되지 않습니다. 하지만 몇몇 특이한 경우에, CoffeeScript, Ruby, 그리고 C 전처리기에서 공백이 코드 파싱 방식에 영향을 미칩니다. 이 언어들 각각에서 공백은 어디서 어떤 영향을 미칠까요? -
대부분의 스캐너와 마찬가지로, 여기 우리 스캐너도 파서에 필요 없는 주석과 공백을 버립니다. 하지만 주석과 공백을 버리지 않는 스캐너를 작성하고 싶은 이유는 무엇일까요? 그것은 무엇에 유용할까요?
-
Lox 스캐너에 C 스타일의
/* ... */블록 주석 지원을 추가하세요. 주석 내부의 줄바꿈을 처리하도록 해야 합니다. 중첩을 허용하는 것도 고려해 보세요. 중첩 지원을 추가하는 것이 예상보다 더 많은 작업인가요? 왜 그럴까요?
디자인 노트: 묵시적 세미콜론
오늘날 프로그래머들은 언어 선택지가 많아져 문법에 대해 까다로워졌습니다. 그들은 자신들이 사용하는 언어가 깔끔하고 현대적으로 보이기를 원합니다. 거의 모든 새로운 언어들이 제거하는(그리고 BASIC과 같은 일부 오래된 언어들은 애초에 없었던) 구문적 "이끼" 중 하나는 명시적인 문장 종결자로서의 ;입니다.
대신, 합리적인 경우에 줄바꿈을 문장 종결자로 취급합니다. 여기서 "합리적인 경우"라는 부분이 어려운 점입니다. 대부분의 문장은 자체 줄에 있지만, 때로는 하나의 문장을 여러 줄에 걸쳐 작성해야 할 때가 있습니다. 이렇게 뒤섞인 줄바꿈은 종결자로 취급되어서는 안 됩니다.
줄바꿈을 무시해야 하는 대부분의 명백한 경우는 감지하기 쉽지만, 몇 가지 까다로운 경우가 있습니다:
-
다음 줄에 반환 값:
if (condition) return "value"
여기서 "value"는 반환되는 값일까요, 아니면 값이 없는
return문 뒤에 문자열 리터럴을 포함하는 표현식 문이 오는 것일까요? -
다음 줄에 괄호로 묶인 표현식:
func (parenthesized)
이것은
func(parenthesized)호출일까요, 아니면func에 대한 표현식 문 하나와 괄호로 묶인 표현식에 대한 표현식 문 하나, 총 두 개의 표현식 문일까요? -
다음 줄에
-:first -second
이것은
first - second—즉 중위(infix) 뺄셈—일까요, 아니면first에 대한 표현식 문 하나와second를 부정하는 표현식 문 하나, 총 두 개의 표현식 문일까요?
이 모든 경우에서, 줄바꿈을 구분자로 처리하든 안 하든 모두 유효한 코드를 생성하지만, 사용자가 원하는 코드가 아닐 수 있습니다. 여러 언어에 걸쳐, 어떤 줄바꿈을 구분자로 삼을지 결정하는 데 사용되는 규칙은 놀라울 정도로 다양합니다. 몇 가지 예를 들어보겠습니다:
-
Lua는 줄바꿈을 완전히 무시하지만, 대부분의 경우 문장 사이에 구분자가 전혀 필요 없도록 문법을 신중하게 제어합니다. 다음은 완전히 합법적인 코드입니다:
a = 1 b = 2
Lua는
return문이 블록의 맨 마지막 문장이어야 한다고 요구함으로써return문제를 회피합니다.return키워드 뒤에end키워드 앞에 값이 있다면, 그것은 반드시return을 위한 값이어야 합니다. 다른 두 가지 경우에는 명시적인;를 허용하고 사용자들이 그것을 사용하기를 기대합니다. 실제로는 괄호로 묶인 표현식이나 단항 부정 표현식 문장이 별 의미가 없기 때문에 그런 일은 거의 일어나지 않습니다. -
Go는 스캐너에서 줄바꿈을 처리합니다. 문장을 잠재적으로 끝낼 수 있다고 알려진 몇몇 토큰 타입 뒤에 줄바꿈이 나타나면, 그 줄바꿈은 세미콜론처럼 취급됩니다. 그렇지 않으면 무시됩니다. Go 팀은 표준 코드 포매터인 gofmt를 제공하며, 생태계는 이를 열렬히 사용하도록 장려하여, 관용적인 스타일의 코드가 이 간단한 규칙과 잘 작동하도록 보장합니다.
-
Python은 명시적인 백슬래시가 줄의 끝에서 다음 줄로 이어질 때를 제외하고는 모든 줄바꿈을 의미 있는 것으로 간주합니다. 하지만 한 쌍의 괄호(
(),[], 또는{}) 내부의 모든 줄바꿈은 무시됩니다. 관용적인 스타일은 후자를 강력히 선호합니다.이 규칙은 Python이 매우 문장 중심적인 언어이기 때문에 잘 작동합니다. 특히 Python의 문법은 표현식 내부에 문장이 나타나지 않도록 보장합니다. C도 마찬가지이지만, "람다" 또는 함수 리터럴 문법을 가진 다른 많은 언어들은 그렇지 않습니다.
JavaScript의 예시:
console.log(function() { statement(); });
여기서
console.log()표현식은 함수 리터럴을 포함하며, 이 함수 리터럴은 다시 문장인statement();를 포함합니다.만약 괄호 안에 중첩된 상태에서 줄바꿈이 의미를 갖게 되는 문장으로 다시 들어갈 수 있다면, Python은 암묵적으로 줄을 연결하는 다른 규칙 세트가 필요할 것입니다.
-
JavaScript의 “자동 세미콜론 삽입(automatic semicolon insertion)” 규칙은 정말 특이합니다. 다른 언어들이 대부분의 줄바꿈이 의미 있다고 가정하고 여러 줄 문장에서 일부만 무시해야 한다고 보는 반면, JS는 그 반대를 가정합니다. JS는 파싱 오류가 발생하지 않는 한 모든 줄바꿈을 의미 없는 공백으로 취급합니다. 오류가 발생하면, 이전 줄바꿈을 세미콜론으로 바꾸어 문법적으로 유효한 것을 얻으려고 시도합니다.
이 디자인 노트에서 그것이 어떻게 작동하는지, 그리고 JavaScript의 "해결책"이 얼마나 많은 면에서 나쁜 아이디어인지에 대해 완전히 자세히 설명한다면, 이것은 디자인에 대한 장황한 비판이 될 것입니다. 정말 혼란스럽습니다. JavaScript는 제가 아는 유일한 언어로, 이론적으로 세미콜론을 생략할 수 있게 해줌에도 불구하고 많은 스타일 가이드에서 모든 문장 뒤에 명시적인 세미콜론을 요구합니다.
새로운 언어를 설계하고 있다면, 명시적인 문장 종결자를 피해야 합니다. 프로그래머는 다른 인간들처럼 유행에 민감한 존재이며, 세미콜론은 대문자 키워드만큼이나 시대에 뒤떨어진 것입니다. 단지 여러분의 언어의 특정 문법과 관용구에 맞는 규칙 집합을 선택하는 것만 명심하세요. 그리고 JavaScript가 했던 방식은 따르지 마세요.