22

지역 변수

상상력이 미지의 형상을 만들 때,
시인의 펜은 그것을 모양 짓고,
허공에 머무를 곳과 이름을 부여한다.

윌리엄 셰익스피어, 한여름 밤의 꿈

지난 장에서는 clox에 전역 변수만을 도입했습니다. 이 장에서는 블록, 블록 스코프, 그리고 지역 변수를 지원하도록 확장할 것입니다. jlox에서는 이 모든 것과 전역 변수를 한 장에 담을 수 있었지만, clox에서는 이 작업이 두 장에 걸쳐 이루어집니다. 솔직히 말해, C에서는 모든 것이 더 많은 노력을 필요로 하기 때문입니다.

하지만 더 중요한 이유는, 지역 변수에 대한 우리의 접근 방식이 전역 변수를 구현했던 방식과 상당히 다르기 때문입니다. Lox에서 전역 변수는 지연 바인딩(late bound)됩니다. 이 맥락에서 "지연"이란 "컴파일 시간 이후에 결정됨"을 의미합니다. 이는 컴파일러를 단순하게 유지하는 데는 좋지만, 성능에는 좋지 않습니다. 지역 변수는 언어에서 가장 많이 사용되는 부분 중 하나입니다. 만약 지역 변수가 느리다면, 모든 것이 느려집니다. 그래서 우리는 지역 변수에 대해 가능한 한 효율적인 전략을 원합니다.

다행히도, 어휘적 스코핑(lexical scoping)이 우리를 도와줄 것입니다. 이름에서 알 수 있듯이, 어휘적 스코프는 프로그램 텍스트를 살펴보는 것만으로 지역 변수를 결정할 수 있음을 의미합니다지역 변수는 지연 바인딩되지 않습니다. 컴파일러에서 수행하는 모든 처리 작업은 런타임에 수행할 필요가 없는 작업이므로, 지역 변수 구현은 컴파일러에 크게 의존할 것입니다.

22 . 1지역 변수 표현하기

현대 시대에 프로그래밍 언어를 해킹하는 것의 좋은 점은, 배울 수 있는 다른 언어들의 긴 계보가 있다는 것입니다. 그렇다면 C와 Java는 지역 변수를 어떻게 관리할까요? 물론 스택에 저장합니다! 그들은 일반적으로 칩과 OS가 지원하는 네이티브 스택 메커니즘을 사용합니다. 이는 우리에게는 너무 낮은 수준이지만, clox의 가상 세계에서는 우리가 사용할 수 있는 자체 스택이 있습니다.

지금은 표현식 계산 중에 기억해야 할 짧은 수명의 데이터 덩어리인 임시(temporaries)를 저장하는 데만 스택을 사용하고 있습니다. 임시 데이터에 방해가 되지 않는 한, 지역 변수도 스택에 쌓을 수 있습니다. 이는 성능에 매우 좋습니다. 새로운 지역 변수를 위한 공간 할당은 stackTop 포인터를 증가시키는 것만으로 충분하며, 해제 역시 감소시키는 것만으로 이루어집니다. 알려진 스택 슬롯에서 변수에 접근하는 것은 인덱싱된 배열 조회입니다.

하지만 조심해야 합니다. VM은 스택이, 음, 스택처럼 작동하기를 기대합니다. 우리는 새로운 지역 변수를 스택의 맨 위에만 할당하는 것을 받아들여야 하고, 스택에 그 위에 아무것도 없을 때만 지역 변수를 버릴 수 있다는 점을 받아들여야 합니다. 또한, 임시 데이터가 방해하지 않도록 해야 합니다.

다행히 Lox의 설계는 이러한 제약 조건과 조화를 이룹니다. 새로운 지역 변수는 항상 선언문(declaration statements)에 의해 생성됩니다. 구문은 표현식 안에 중첩되지 않으므로, 구문 실행이 시작될 때 스택에 임시 데이터가 있는 경우는 없습니다. 블록은 엄격하게 중첩됩니다. 블록이 끝나면 항상 가장 안쪽에 있는, 가장 최근에 선언된 지역 변수를 함께 가져갑니다. 이들은 가장 마지막에 스코프에 들어온 지역 변수들이므로, 우리가 필요한 스택의 맨 위에 있어야 합니다.

다음 예제 프로그램을 단계별로 살펴보면서 지역 변수가 스코프에 들어오고 나가는 방식을 확인하세요:

일련의 지역 변수들이 스택과 같은 방식으로 스코프에 들어오고 나갑니다.

스택에 완벽하게 들어맞는 것이 보이죠? 런타임에 지역 변수를 저장하는 데 스택이 효과적일 것 같습니다. 하지만 여기서 더 나아갈 수 있습니다. 스택에 있을 것이라는 점뿐만 아니라, 스택의 어디에 있을지도 정확히 파악할 수 있습니다. 컴파일러는 특정 시점에 어떤 지역 변수가 스코프에 있는지 정확히 알고 있으므로, 컴파일 중에 스택을 효과적으로 시뮬레이션하고 각 변수가 스택의 어디에 위치할지 기록할 수 있습니다.

우리는 이러한 스택 오프셋을 지역 변수를 읽고 저장하는 바이트코드 명령어의 피연산자로 활용할 것입니다. 이는 지역 변수 작업을 매우 빠르게 만듭니다단순히 배열에 인덱싱하는 것만큼 쉽습니다.

이 모든 것을 작동시키려면 컴파일러에서 추적해야 할 상태가 많으므로, 거기서부터 시작합시다. jlox에서는 연결된 "환경" HashMap 체인을 사용하여 현재 스코프에 있는 지역 변수를 추적했습니다. 이는 어휘적 스코프를 표현하는 고전적이고 교과서적인 방법입니다. clox에서는 늘 그렇듯이, 조금 더 하드웨어에 가까운 방식을 사용합니다. 모든 상태는 새로운 구조체에 저장됩니다.

} ParseRule;
compiler.c
ParseRule 구조체 뒤에 추가

typedef struct {
  Local locals[UINT8_COUNT];
  int localCount;
  int scopeDepth;
} Compiler;

Parser parser;
compiler.c, ParseRule 구조체 뒤에 추가

컴파일 과정의 각 시점에서 스코프에 있는 모든 지역 변수를 위한 간단하고 평평한 배열이 있습니다. 이들은 코드에 선언이 나타나는 순서대로 배열에 정렬됩니다. 지역 변수를 인코딩하는 데 사용할 명령어 피연산자가 단일 바이트이므로, VM은 한 번에 스코프에 있을 수 있는 지역 변수의 수에 엄격한 제한을 둡니다. 이는 지역 변수 배열에 고정된 크기를 부여할 수 있음을 의미합니다.

#define DEBUG_TRACE_EXECUTION
common.h

#define UINT8_COUNT (UINT8_MAX + 1)

#endif
common.h

Compiler 구조체로 돌아가서, localCount 필드는 스코프에 있는 지역 변수의 수를 추적합니다즉, 배열 슬롯 중 몇 개가 사용 중인지를 나타냅니다. 또한 "스코프 깊이(scope depth)"도 추적합니다. 이는 현재 컴파일 중인 코드 비트를 둘러싸고 있는 블록의 수를 의미합니다.

우리의 Java 인터프리터는 각 블록의 변수를 다른 블록의 변수와 분리하기 위해 맵 체인을 사용했습니다. 이번에는 변수가 나타나는 중첩 수준으로 단순히 번호를 매길 것입니다. 0은 전역 스코프, 1은 첫 번째 최상위 블록, 2는 그 안에, 이런 식입니다. 우리는 이를 사용하여 각 지역 변수가 어느 블록에 속하는지 추적하여, 블록이 끝날 때 어떤 지역 변수를 버려야 하는지 알 수 있습니다.

배열의 각 지역 변수는 다음 중 하나입니다:

} ParseRule;
compiler.c
ParseRule 구조체 뒤에 추가

typedef struct {
  Token name;
  int depth;
} Local;

typedef struct {
compiler.c, ParseRule 구조체 뒤에 추가

우리는 변수의 이름을 저장합니다. 식별자를 해석할 때, 식별자의 렉셈을 각 지역 변수의 이름과 비교하여 일치하는 것을 찾습니다. 이름을 모르면 변수를 해석하기가 매우 어렵습니다. depth 필드는 지역 변수가 선언된 블록의 스코프 깊이를 기록합니다. 이것이 지금 필요한 모든 상태입니다.

이는 jlox에서 가졌던 표현 방식과는 매우 다르지만, 여전히 컴파일러가 어휘적 환경에 대해 물어야 하는 모든 질문에 답할 수 있게 해줍니다. 다음 단계는 컴파일러가 이 상태에 어떻게 접근하는지 파악하는 것입니다. 만약 우리가 원칙적인 엔지니어였다면, 프론트 엔드의 각 함수에 Compiler 포인터를 매개변수로 받는 기능을 제공했을 것입니다. 시작할 때 Compiler를 생성하고 각 함수 호출을 통해 조심스럽게 전달했을 것입니다 . . . 하지만 이는 이미 작성한 코드에 많은 지루한 변경을 의미할 것이므로, 대신 전역 변수를 사용하겠습니다:

Parser parser;
compiler.c
parser 변수 뒤에 추가
Compiler* current = NULL;
Chunk* compilingChunk;
compiler.c, parser 변수 뒤에 추가

컴파일러를 초기화하는 작은 함수가 있습니다:

compiler.c
emitConstant() 뒤에 추가
static void initCompiler(Compiler* compiler) {
  compiler->localCount = 0;
  compiler->scopeDepth = 0;
  current = compiler;
}
compiler.c, emitConstant() 뒤에 추가

VM을 처음 시작할 때, 모든 것을 깨끗한 상태로 만들기 위해 이 함수를 호출합니다.

  initScanner(source);
compiler.c
compile() 안에
  Compiler compiler;
  initCompiler(&compiler);
  compilingChunk = chunk;
compiler.c, compile() 안에

우리 컴파일러는 필요한 데이터를 가지고 있지만, 그 데이터에 대한 연산은 아직 없습니다. 스코프를 생성하고 파괴하거나, 변수를 추가하고 해결할 방법이 없습니다. 필요에 따라 추가할 것입니다. 먼저, 언어 기능을 구축하기 시작합시다.

22 . 2블록 구문

지역 변수를 가지려면 먼저 지역 스코프가 필요합니다. 이는 두 가지 요소에서 비롯됩니다: 함수 본문과 블록. 함수는 나중 장에서 다룰 큰 작업이므로, 지금은 블록만 다룰 것입니다. 늘 그렇듯이, 구문(syntax)부터 시작합니다. 우리가 도입할 새로운 문법은 다음과 같습니다:

statementexprStmt
               | printStmt
               | block ;

block"{" declaration* "}" ;

블록은 구문의 한 종류이므로, 이에 대한 규칙은 statement 생성에 포함됩니다. 블록 하나를 컴파일하는 해당 코드는 다음과 같습니다:

  if (match(TOKEN_PRINT)) {
    printStatement();
compiler.c
statement() 안에
  } else if (match(TOKEN_LEFT_BRACE)) {
    beginScope();
    block();
    endScope();
  } else {
compiler.c, statement() 안에

초기 중괄호파싱한 후, 이 헬퍼 함수를 사용하여 블록의 나머지를 컴파일합니다:

compiler.c
expression() 뒤에 추가
static void block() {
  while (!check(TOKEN_RIGHT_BRACE) && !check(TOKEN_EOF)) {
    declaration();
  }

  consume(TOKEN_RIGHT_BRACE, "Expect '}' after block.");
}
compiler.c, expression() 뒤에 추가

닫는 중괄호를 만날 때까지 선언과 구문을 계속 파싱합니다. 파서의 모든 루프에서 그렇듯이, 토큰 스트림의 끝도 확인합니다. 이렇게 하면 닫는 중괄호가 누락된 잘못된 프로그램이 있을 때 컴파일러가 루프에 갇히지 않습니다.

블록을 실행하는 것은 단순히 블록에 포함된 구문들을 차례로 실행하는 것을 의미하므로, 컴파일하는 데 많은 것이 필요하지 않습니다. 블록이 의미론적으로 흥미로운 점은 스코프를 생성한다는 것입니다. 블록의 본문을 컴파일하기 전에, 이 함수를 호출하여 새로운 지역 스코프에 진입합니다:

compiler.c
endCompiler() 뒤에 추가
static void beginScope() {
  current->scopeDepth++;
}
compiler.c, endCompiler() 뒤에 추가

스코프를 "생성"하기 위해 우리가 하는 모든 것은 현재 깊이를 증가시키는 것입니다. 이는 각 스코프마다 새로운 HashMap을 할당했던 jlox보다 확실히 빠릅니다. beginScope()를 보면, endScope()가 무엇을 할지 아마 짐작할 수 있을 것입니다.

compiler.c
beginScope() 뒤에 추가
static void endScope() {
  current->scopeDepth--;
}
compiler.c, beginScope() 뒤에 추가

이것으로 블록과 스코프는대략적으로는끝났습니다. 이제 여기에 변수를 채워 넣을 준비가 되었습니다.

22 . 3지역 변수 선언하기

보통 여기서는 파싱부터 시작하지만, 우리 컴파일러는 이미 변수 선언 파싱 및 컴파일을 지원합니다. var 구문, 식별자 표현식, 할당 등이 이미 있습니다. 다만, 컴파일러가 모든 변수를 전역으로 가정할 뿐입니다. 따라서 새로운 파싱 지원은 필요 없고, 기존 코드에 새로운 스코핑 의미론을 연결하기만 하면 됩니다.

varDeclaration() 내의 코드 흐름.

변수 선언 파싱은 varDeclaration()에서 시작하며 몇 가지 다른 함수에 의존합니다. 먼저, parseVariable()은 변수 이름에 대한 식별자 토큰을 소비하고, 해당 렉셈을 청크의 상수 테이블에 문자열로 추가한 다음, 추가된 상수 테이블 인덱스를 반환합니다. 그런 다음, varDeclaration()이 초기화자를 컴파일한 후에 defineVariable()을 호출하여 전역 변수 해시 테이블에 변수 값을 저장하기 위한 바이트코드를 내보냅니다.

이 두 헬퍼 모두 지역 변수를 지원하기 위해 몇 가지 변경이 필요합니다. parseVariable()에 다음을 추가합니다:

  consume(TOKEN_IDENTIFIER, errorMessage);
compiler.c
parseVariable() 안에

  declareVariable();
  if (current->scopeDepth > 0) return 0;

  return identifierConstant(&parser.previous);
compiler.c, parseVariable() 안에

먼저 변수를 "선언"합니다. 그것이 무엇을 의미하는지는 잠시 후에 설명하겠습니다. 그 후, 지역 스코프 내에 있다면 함수를 종료합니다. 런타임에는 지역 변수가 이름으로 조회되지 않습니다. 변수의 이름을 상수 테이블에 넣을 필요가 없으므로, 선언이 지역 스코프 내에 있는 경우 더미 테이블 인덱스를 반환합니다.

defineVariable()에서는, 지역 스코프 내에 있을 경우 지역 변수를 저장하는 코드를 내보내야 합니다. 다음과 같습니다:

static void defineVariable(uint8_t global) {
compiler.c
defineVariable() 안에
  if (current->scopeDepth > 0) {
    return;
  }

  emitBytes(OP_DEFINE_GLOBAL, global);
compiler.c, defineVariable() 안에

잠깐, 이게 뭐죠? 네, 그게 다입니다. 런타임에 지역 변수를 생성하는 코드는 없습니다. VM의 상태를 생각해 보세요. 변수의 초기화자(또는 사용자가 초기화자를 생략한 경우 암묵적인 nil)에 대한 코드를 이미 실행했으며, 그 값은 스택 맨 위에 유일하게 남아있는 임시 값으로 놓여 있습니다. 또한 새로운 지역 변수는 스택 맨 위에 할당됩니다 . . . 값이 이미 있는 바로 그곳에요. 따라서 할 일은 아무것도 없습니다. 임시 값은 단순히 지역 변수가 됩니다. 이보다 더 효율적일 수는 없습니다.

각 초기화자의 결과가 지역 변수 슬롯에 들어가는 것을 보여주는 바이트코드 실행 과정을 단계별로 보여줍니다.

좋습니다. 그렇다면 "선언"은 무엇에 관한 것일까요? 다음은 그 내용입니다:

compiler.c
identifierConstant() 뒤에 추가
static void declareVariable() {
  if (current->scopeDepth == 0) return;

  Token* name = &parser.previous;
  addLocal(*name);
}
compiler.c, identifierConstant() 뒤에 추가

이것이 컴파일러가 변수의 존재를 기록하는 지점입니다. 우리는 이것을 지역 변수에 대해서만 수행하므로, 최상위 전역 스코프에 있다면 그냥 빠져나갑니다. 전역 변수는 지연 바인딩되므로, 컴파일러는 이전에 어떤 선언을 보았는지 추적하지 않습니다.

하지만 지역 변수의 경우, 컴파일러는 변수가 존재한다는 사실을 기억해야 합니다. 선언하는 것이 바로 그것입니다현재 스코프에 있는 변수 목록에 추가하는 것입니다. 이를 다른 새로운 함수를 사용하여 구현합니다.

compiler.c
identifierConstant() 뒤에 추가
static void addLocal(Token name) {
  Local* local = &current->locals[current->localCount++];
  local->name = name;
  local->depth = current->scopeDepth;
}
compiler.c, identifierConstant() 뒤에 추가

이는 컴파일러의 변수 배열에서 다음 사용 가능한 Local을 초기화합니다. 변수의 이름과 변수를 소유하는 스코프의 깊이를 저장합니다.

우리 구현은 올바른 Lox 프로그램에는 괜찮지만, 잘못된 코드는 어떻게 될까요? 견고함을 목표로 합시다. 처리해야 할 첫 번째 오류는 사용자 잘못이라기보다는 VM의 한계에 가깝습니다. 지역 변수를 다루는 명령어는 슬롯 인덱스로 변수를 참조합니다. 이 인덱스는 단일 바이트 피연산자로 저장되므로, VM은 한 번에 256개까지만 지역 변수를 스코프에 가질 수 있습니다.

그 이상으로 시도하면, 런타임에 참조할 수 없을 뿐만 아니라, 컴파일러가 자신의 지역 변수 배열을 덮어쓸 수도 있습니다. 이를 방지합시다.

static void addLocal(Token name) {
compiler.c
addLocal() 안에
  if (current->localCount == UINT8_COUNT) {
    error("Too many local variables in function.");
    return;
  }

  Local* local = &current->locals[current->localCount++];
compiler.c, addLocal() 안에

다음 경우는 더 까다롭습니다. 다음을 고려해 봅시다:

{
  var a = "first";
  var a = "second";
}

최상위 수준에서 Lox는 이전 선언과 같은 이름으로 변수를 다시 선언하는 것을 허용합니다. 이는 REPL에 유용하기 때문입니다. 하지만 지역 스코프 내에서는 상당히 이상한 일입니다. 실수일 가능성이 높으며, 우리 Lox를 포함한 많은 언어는 이를 오류로 간주하여 이러한 가정을 확립합니다.

위 프로그램은 다음 프로그램과 다릅니다:

{
  var a = "outer";
  {
    var a = "inner";
  }
}

서로 다른 스코프에 같은 이름의 변수가 있는 것은 괜찮습니다. 심지어 두 스코프가 동시에 모두 보이는 방식으로 겹치더라도 그렇습니다. 이것은 섀도잉(shadowing)이며, Lox는 이를 허용합니다. 같은 지역 스코프에 같은 이름의 변수가 두 개 있는 경우에만 오류입니다.

그 오류를 다음과 같이 감지합니다:

  Token* name = &parser.previous;
compiler.c
declareVariable() 안에
  for (int i = current->localCount - 1; i >= 0; i--) {
    Local* local = &current->locals[i];
    if (local->depth != -1 && local->depth < current->scopeDepth) {
      break; 
    }

    if (identifiersEqual(name, &local->name)) {
      error("Already a variable with this name in this scope.");
    }
  }

  addLocal(*name);
}
compiler.c, declareVariable() 안에

지역 변수는 선언될 때 배열의 끝에 추가됩니다. 즉, 현재 스코프는 항상 배열의 끝에 있습니다. 새 변수를 선언할 때, 배열의 끝에서부터 뒤로 거슬러 올라가며 같은 이름의 기존 변수를 찾습니다. 현재 스코프에서 찾으면 오류를 보고합니다. 그렇지 않으면 배열의 시작 부분에 도달하거나 다른 스코프에 속한 변수에 도달하면, 해당 스코프의 모든 기존 변수를 확인했음을 알 수 있습니다.

두 식별자가 같은지 확인하려면 다음을 사용합니다:

compiler.c
identifierConstant() 뒤에 추가
static bool identifiersEqual(Token* a, Token* b) {
  if (a->length != b->length) return false;
  return memcmp(a->start, b->start, a->length) == 0;
}
compiler.c, identifierConstant() 뒤에 추가

두 렉셈의 길이를 알고 있으므로, 먼저 이를 확인합니다. 이는 같지 않은 많은 문자열에 대해 빠르게 실패할 것입니다. 길이가 같으면, memcmp()를 사용하여 문자를 확인합니다. memcmp()를 사용하려면 include가 필요합니다.

#include <stdlib.h>
compiler.c
#include <string.h>

#include "common.h"
compiler.c

이것으로 변수를 생성할 수 있습니다. 하지만 유령처럼, 변수는 선언된 스코프를 넘어서까지 남아 있습니다. 블록이 끝나면, 변수들을 편안하게 해줘야 합니다.

  current->scopeDepth--;
compiler.c
endScope() 안에

  while (current->localCount > 0 &&
         current->locals[current->localCount - 1].depth >
            current->scopeDepth) {
    emitByte(OP_POP);
    current->localCount--;
  }
}
compiler.c, endScope() 안에

스코프를 팝할 때, 우리는 지역 변수 배열을 뒤로 훑으며 방금 벗어난 스코프 깊이에서 선언된 변수를 찾습니다. 배열의 길이를 단순히 줄임으로써 변수들을 버립니다.

이것에는 런타임 요소도 있습니다. 지역 변수는 스택의 슬롯을 차지합니다. 지역 변수가 스코프를 벗어나면, 해당 슬롯은 더 이상 필요 없으며 해제되어야 합니다. 그래서 버리는 각 변수에 대해 스택에서 팝하는 OP_POP 명령도 내보냅니다.

22 . 4지역 변수 사용하기

이제 지역 변수 선언을 컴파일하고 실행할 수 있습니다. 런타임에, 그 값들은 스택의 올바른 위치에 있습니다. 이제 사용해 봅시다. 변수 접근과 할당을 동시에 처리할 것입니다. 컴파일러에서 동일한 함수들을 건드리기 때문입니다.

우리는 이미 전역 변수를 가져오고 설정하는 코드를 가지고 있으며좋은 작은 소프트웨어 엔지니어처럼기존 코드를 최대한 재사용하고 싶습니다. 다음과 같이 말이죠:

static void namedVariable(Token name, bool canAssign) {
compiler.c
namedVariable() 안에
1줄 교체
  uint8_t getOp, setOp;
  int arg = resolveLocal(current, &name);
  if (arg != -1) {
    getOp = OP_GET_LOCAL;
    setOp = OP_SET_LOCAL;
  } else {
    arg = identifierConstant(&name);
    getOp = OP_GET_GLOBAL;
    setOp = OP_SET_GLOBAL;
  }

  if (canAssign && match(TOKEN_EQUAL)) {
compiler.c, namedVariable() 안에, 1줄 교체

변수 접근 및 할당에 대해 내보내는 바이트코드 명령어를 하드코딩하는 대신, 몇 개의 C 변수를 사용합니다. 먼저, 주어진 이름으로 지역 변수를 찾으려고 시도합니다. 찾으면, 지역 변수와 함께 작동하는 명령어를 사용합니다. 그렇지 않으면 전역 변수라고 가정하고 기존의 전역 변수용 바이트코드 명령어를 사용합니다.

조금 더 아래로 내려가서, 이 변수들을 사용하여 올바른 명령어를 내보냅니다. 할당의 경우:

  if (canAssign && match(TOKEN_EQUAL)) {
    expression();
compiler.c
namedVariable() 안에
1줄 교체
    emitBytes(setOp, (uint8_t)arg);
  } else {
compiler.c, namedVariable() 안에, 1줄 교체

그리고 접근의 경우:

    emitBytes(setOp, (uint8_t)arg);
  } else {
compiler.c
namedVariable() 안에
1줄 교체
    emitBytes(getOp, (uint8_t)arg);
  }
compiler.c, namedVariable() 안에, 1줄 교체

이 장의 진정한 핵심, 지역 변수를 해결하는 부분은 다음과 같습니다:

compiler.c
identifiersEqual() 뒤에 추가
static int resolveLocal(Compiler* compiler, Token* name) {
  for (int i = compiler->localCount - 1; i >= 0; i--) {
    Local* local = &compiler->locals[i];
    if (identifiersEqual(name, &local->name)) {
      return i;
    }
  }

  return -1;
}
compiler.c, identifiersEqual() 뒤에 추가

이 모든 것에 비해 매우 간단합니다. 현재 스코프에 있는 지역 변수 목록을 순회합니다. 만약 식별자 토큰과 같은 이름을 가진 변수가 있다면, 식별자는 해당 변수를 참조해야 합니다. 찾았습니다! 우리는 배열을 뒤로 순회하여 식별자를 가진 가장 마지막에 선언된 변수를 찾습니다. 이는 내부 지역 변수가 주변 스코프의 같은 이름의 지역 변수를 올바르게 섀도잉하도록 보장합니다.

런타임에, 우리는 스택 슬롯 인덱스를 사용하여 지역 변수를 로드하고 저장하므로, 컴파일러가 변수를 해결한 후 계산해야 하는 것은 바로 그 인덱스입니다. 변수가 선언될 때마다 Compiler의 지역 변수 배열에 추가됩니다. 이는 첫 번째 지역 변수가 인덱스 0에, 다음 변수가 인덱스 1에 있는 식입니다. 즉, 컴파일러의 지역 변수 배열은 런타임에 VM 스택이 가질 정확히 동일한 레이아웃을 가집니다. 지역 변수 배열의 변수 인덱스는 해당 스택 슬롯과 같습니다. 얼마나 편리한가요!

주어진 이름의 변수를 찾지 못하고 배열 전체를 순회했다면, 그것은 지역 변수가 아닐 것입니다. 이 경우, 찾지 못했음을 알리기 위해 -1을 반환하고, 대신 전역 변수라고 가정해야 합니다.

22 . 4 . 1지역 변수 해석하기

우리 컴파일러는 두 개의 새로운 명령어를 내보내고 있으므로, 이제 그것들을 작동시켜 봅시다. 첫 번째는 지역 변수 로딩입니다:

  OP_POP,
chunk.h
OpCode enum 안에
  OP_GET_LOCAL,
  OP_GET_GLOBAL,
chunk.h, OpCode enum 안에

그리고 그 구현:

      case OP_POP: pop(); break;
vm.c
run() 안에
      case OP_GET_LOCAL: {
        uint8_t slot = READ_BYTE();
        push(vm.stack[slot]); 
        break;
      }
      case OP_GET_GLOBAL: {
vm.c, run() 안에

지역 변수가 있는 스택 슬롯에 대한 단일 바이트 피연산자를 받습니다. 해당 인덱스에서 값을 로드한 다음, 나중 명령어가 찾을 수 있도록 스택 맨 위에 푸시합니다.

다음은 할당입니다:

  OP_GET_LOCAL,
chunk.h
OpCode enum 안에
  OP_SET_LOCAL,
  OP_GET_GLOBAL,
chunk.h, OpCode enum 안에

구현을 예측할 수 있을 것입니다.

      }
vm.c
run() 안에
      case OP_SET_LOCAL: {
        uint8_t slot = READ_BYTE();
        vm.stack[slot] = peek(0);
        break;
      }
      case OP_GET_GLOBAL: {
vm.c, run() 안에

스택 맨 위에서 할당된 값을 가져와 지역 변수에 해당하는 스택 슬롯에 저장합니다. 값이 스택에서 팝되지 않는다는 점에 유의하세요. 할당은 표현식이며, 모든 표현식은 값을 생성합니다. 할당 표현식의 값은 할당된 값 자체이므로, VM은 값을 스택에 그대로 둡니다.

우리 디스어셈블러는 이 두 가지 새로운 명령어를 지원하지 않으면 불완전합니다.

      return simpleInstruction("OP_POP", offset);
debug.c
disassembleInstruction() 안에
    case OP_GET_LOCAL:
      return byteInstruction("OP_GET_LOCAL", chunk, offset);
    case OP_SET_LOCAL:
      return byteInstruction("OP_SET_LOCAL", chunk, offset);
    case OP_GET_GLOBAL:
debug.c, disassembleInstruction() 안에

컴파일러는 지역 변수를 직접 슬롯 접근으로 컴파일합니다. 지역 변수의 이름은 컴파일러를 벗어나 청크에 전혀 들어가지 않습니다. 이는 성능에는 좋지만, 내부 검사(introspection)에는 그리 좋지 않습니다. 이 명령어를 역어셈블할 때, 전역 변수처럼 변수 이름을 보여줄 수 없습니다. 대신 슬롯 번호만 표시합니다.

debug.c
simpleInstruction() 뒤에 추가
static int byteInstruction(const char* name, Chunk* chunk,
                           int offset) {
  uint8_t slot = chunk->code[offset + 1];
  printf("%-16s %4d\n", name, slot);
  return offset + 2; 
}
debug.c, simpleInstruction() 뒤에 추가

22 . 4 . 2또 다른 스코프 경계 사례

우리는 이미 스코프를 둘러싼 몇 가지 이상한 경계 사례를 처리하는 데 시간을 들였습니다. 섀도잉이 올바르게 작동하는지 확인했습니다. 동일한 지역 스코프에 두 변수가 같은 이름을 가지면 오류를 보고합니다. 저에게 완전히 명확하지 않은 이유로, 변수 스코핑은 이러한 미묘한 부분들이 많은 것 같습니다. 저는 이 부분이 완전히 우아하게 느껴지는 언어를 본 적이 없습니다.

이 장을 마치기 전에 처리해야 할 경계 사례가 하나 더 있습니다. jlox의 변수 해석 구현에서 처음 만났던 이 이상한 것을 기억해 봅시다:

{
  var a = "outer";
  {
    var a = a;
  }
}

그때는 변수 선언을 두 단계로 나누어 처리하여 해결했고, 여기에서도 다시 그렇게 할 것입니다:

변수 이름 앞에는 '선언되었지만 초기화되지 않음'으로 표시되고, 초기화자 뒤에는 '사용 준비 완료'로 표시된 예시 변수 선언.

변수 선언이 시작되는 즉시즉, 초기화자보다 먼저해당 이름은 현재 스코프에 선언됩니다. 변수는 존재하지만 특별한 "초기화되지 않은" 상태로 있습니다. 그런 다음 초기화자를 컴파일합니다. 그 표현식에서 이 변수를 가리키는 식별자를 해석하는 어떤 시점에서라도, 아직 초기화되지 않았음을 확인하고 오류를 보고할 것입니다. 초기화자 컴파일을 마친 후에는 변수를 초기화되었고 사용할 준비가 되었다고 표시합니다.

이를 구현하기 위해, 지역 변수를 선언할 때 "초기화되지 않은" 상태를 어떤 식으로든 나타내야 합니다. Local에 새로운 필드를 추가할 수도 있지만, 메모리를 좀 더 절약해 봅시다. 대신, 변수의 스코프 깊이를 특수한 sentinel 값인 -1로 설정할 것입니다.

  local->name = name;
compiler.c
addLocal() 안에
1줄 교체
  local->depth = -1;
}
compiler.c, addLocal() 안에, 1줄 교체

나중에, 변수의 초기화자가 컴파일되면, 초기화되었다고 표시합니다.

  if (current->scopeDepth > 0) {
compiler.c
defineVariable() 안에
    markInitialized();
    return;
  }
compiler.c, defineVariable() 안에

이것은 다음과 같이 구현됩니다:

compiler.c
parseVariable() 뒤에 추가
static void markInitialized() {
  current->locals[current->localCount - 1].depth =
      current->scopeDepth;
}
compiler.c, parseVariable() 뒤에 추가

그러므로 이것이 컴파일러에서 변수를 "선언"하고 "정의"하는 것이 정말로 의미하는 바입니다. "선언"은 변수가 스코프에 추가되는 시점이며, "정의"는 변수를 사용할 수 있게 되는 시점입니다.

지역 변수에 대한 참조를 해석할 때, 스코프 깊이를 확인하여 완전히 정의되었는지 확인합니다.

    if (identifiersEqual(name, &local->name)) {
compiler.c
resolveLocal() 안에
      if (local->depth == -1) {
        error("Can't read local variable in its own initializer.");
      }
      return i;
compiler.c, resolveLocal() 안에

변수가 sentinel 깊이를 가지고 있다면, 그것은 자신의 초기화자 내에서 변수를 참조하는 것이 틀림없으며, 우리는 그것을 오류로 보고합니다.

이것으로 이 장은 끝입니다! 우리는 블록, 지역 변수, 그리고 진정한 어휘적 스코핑을 추가했습니다. 변수에 대해 완전히 다른 런타임 표현을 도입했음에도 불구하고, 많은 코드를 작성할 필요는 없었습니다. 구현은 상당히 깔끔하고 효율적으로 마무리되었습니다.

우리가 작성한 코드의 거의 전부가 컴파일러에 있다는 것을 알 수 있을 것입니다. 런타임에는 두 개의 작은 명령어뿐입니다. 이는 jlox와 비교했을 때 clox에서 계속되는 경향으로 볼 수 있습니다. 옵티마이저의 가장 큰 도구 중 하나는 작업을 컴파일러로 앞당겨 런타임에 작업을 수행할 필요가 없도록 하는 것입니다. 이 장에서는 이것이 모든 지역 변수가 차지하는 정확한 스택 슬롯을 결정하는 것을 의미했습니다. 그렇게 하면 런타임에 조회나 해결이 전혀 필요하지 않습니다.

도전 과제

  1. 우리의 간단한 지역 변수 배열은 각 지역 변수의 스택 슬롯을 쉽게 계산할 수 있게 해줍니다. 하지만 이는 컴파일러가 변수에 대한 참조를 해결할 때 배열을 선형적으로 스캔해야 한다는 것을 의미합니다.

    더 효율적인 방법을 고안해 보세요. 추가적인 복잡성이 그만한 가치가 있다고 생각하시나요?

  2. 다른 언어들은 다음과 같은 코드를 어떻게 처리할까요?

    var a = a;
    

    만약 당신의 언어였다면 어떻게 하시겠습니까? 이유는 무엇인가요?

  3. 많은 언어는 재할당할 수 있는 변수와 재할당할 수 없는 변수를 구분합니다. Java에서는 final 한정자가 변수에 할당하는 것을 방지합니다. JavaScript에서는 let으로 선언된 변수는 할당될 수 있지만, const를 사용하여 선언된 변수는 할당될 수 없습니다. Swift는 let을 단일 할당으로 처리하고 var를 할당 가능한 변수에 사용합니다. Scala와 Kotlin은 valvar를 사용합니다.

    Lox에 추가할 단일 할당 변수 형태의 키워드를 선택하세요. 당신의 선택을 정당화하고, 그것을 구현하세요. 새로운 키워드를 사용하여 선언된 변수에 할당하려는 시도는 컴파일 오류를 일으켜야 합니다.

  4. clox가 한 번에 256개 이상의 지역 변수를 스코프에 가질 수 있도록 확장하세요.