Chapter 1: What’s the Scope? - hochan222/Everything-in-JavaScript GitHub Wiki

JS Engine은 프로그램이 시작하기 전에 어떻게 동작할까..?

Compiled vs. Interpreted

Code compilation은 우리의 코드를 컴퓨터가 이해할 수 있는 instruction 목록으로 변환하는 일련의 단계이다.

Interpretation은 프로그램을 기계가 이해할 수있는 명령어로 변환한다는 점에서 컴파일과 유사한 작업을 수행하지만, 처리 모델이 다르다. 한 번에 컴파일되는 프로그램과는 달리 소스 코드는 한 줄 씩 변환된다. 각 행 또는 명령문은 소스 코드의 다음 행 처리를 즉시 진행하기 전에 실행된다.

현대의 JS 엔진은 실제로 JS 프로그램을 처리 할 때 compilation과 interpretation의 다양한 변형을 사용합니다.

Compiling Code

JS가 컴파일되는지 안되는지가 왜 중요할까?
=> Scope는 주로 컴파일 중에 결정되므로 compilation과 interpretation이 어떻게 관련되는지 이해하는 것이 중요하다.

고전적인 컴파일러 이론에서 프로그램은 다음과 같은 세 가지 기본 단계에서 컴파일러에 의해 처리된다.

  1. Tokenizing/Lexing
  • 문자열을 token이라고 불리는 의미있는 덩어리로 나누는 작업이다. (예를 들어, var a = 2;var, a, =, 2, ;로 나뉜다. 공백은 token에서 의미있던 없건 간에 처리되지 않는다. (Tokenizing과 Lexing의 차이는 보통 미묘하고 학문적이지만, stateless와 stateful에 있다.)
  1. Parsing
  • token의 stream (array)을 가져와서 프로그램의 문법적 구조로 나타내주는 트리를 만드는데 이것을 AST(추상 구문 트리)라고 한다. (var a = 2; 에서 가장 상위 node는 VariableDeclaration (설명: var)으로 시작하고 그 하위 node는 Identifier (설명: a) 그 하위는 NumericLiteral(설명 2)를 갖는 AssignmentExpression(설명 =) 가있다.)
  1. Code Generation
  • AST를 가져와서 실행 가능한 코드로 만들어 준다. JS 엔진은 var a = 2에 대해 AST를 사용하고 이를 기계의 instructions로 변환하여 실제 변수를 생성하여 저장한다.(메모리 예약 포함)

Looking for a clear definition of what a “tokenizer”, “parser” and “lexers” are and how they are related to each other and used?

A tokenizer breaks a stream of text into tokens, usually by looking for whitespace (tabs, spaces, new lines).
A lexer is basically a tokenizer, but it usually attaches extra context to the tokens -- this token is a number, that token is a string literal, this other token is an equality operator.
A parser takes the stream of tokens from the lexer and turns it into an abstract syntax tree representing the (usually) program represented by the original text.

JS 엔진은 이 세 단계보다 훨씬 더 복잡하다.구문 분석 및 코드 생성 과정에서 실행 성능을 최적화하는 단계가 있다.(중복된 요소를 축소한다던가...) 코드는 실행이 진행되는 동안 다시 컴파일되고 다시 최적화 될 수도 있다.

JS 엔진은 작업과 최적화를 수행 할 시간이 충분하지 않는데 JS 컴파일은 다른 언어와 마찬가지로 빌드 단계에서 미리 발생하지 않기 때문이다.
일반적으로 코드가 실행되기 직전에 마이크로 초 내에 발생해야한다. 이러한 제약 조건에서 가장 빠른 성능을 보장하기 위해 JS 엔진은 모든 종류의 트릭을 사용한다. (JIT 같이 컴파일을 지연시키는게 있다.)

Required: Two Phases

자바스크립트 프로그램 처리 과정을 두 단계로 나누면, 파싱과 컴파일이 먼저 일어나고 실행된다는 것이다. JS 명세는 compliation가 명세돼있지는 않지만 본질적으로는 컴파일 후 실행된다. 이는 syntax errors, early errors와 hoisting으로 알 수 있다.

Syntax Errors from the Start

var greeting = "Hello"; 

console.log(greeting);
greeting = ."Hi";
// SyntaxError: unexpected token .

다음 코드가 있다. 이 프로그램은 출력을 만들지 않는다. JS가 한줄 씩 하향식으로 출력되는 경우 구문 오류가 발생하기 전에 Hello가 출력돼야하는데, 그렇지 않는다. 첫번째, 두번째 줄을 실행한 다음에 세번째 줄의 구문 오류를 알 수 있는 유일한 방법은 JS가 프로그램을 실행하기전에 전체 구문 분석을 하는 것이다.

Early Errors

console.log("Howdy");

saySomething("Hello","Hi");
// Uncaught SyntaxError: Duplicate parameter name not
// allowed in this context

function saySomething(greeting,greeting) { 
    "use strict";
    console.log(greeting);
}

"use strict"가 함수 saySomething 내에 없을 때에는 구문 오류가 아니지만, 있으면 초기 오류로 중복 파라미터 오류가 나야한다. 변수가 복제 됐다는 사실과 "use strict"의 정보는 함수 밑에 나오는데 어떻게 알 수 있을까? (인터프리터라면!) 코드가 실행되기전에 구문 분석이 완전히 된다.

Hoisting

function saySomething() { 
  var greeting = "Hello"; 
  {
    greeting = "Howdy"; // error comes from here 
    let greeting = "Hi";

    console.log(greeting);
    } 
}

saySomething();
// ReferenceError: Cannot access 'greeting' before
// initialization

greeting에 에러가나는데 이는 Hello가 아닌 hi에 속한다. 오류가 발생한줄 다음 부분에 선언으로 인해 오류가 발생함을 아는 방법은 이전 단계에서 처리한 경우 밖에 없다. 위 원인은 TDZ와 관련있다.

컴파일은 이진표현으로 변환하는 것을 뜻한다. 위 예시로 확실해졌다. 구문분석은 확실히 일어난다. 구문 분석 후 컴파일되고 AST로 파싱된다. JS를 컴파일 언어로 규정짓는 것은 바이너리 파일파일 유무보다 이런 일련의 과정에 있다.

Cheating: Runtime Scope Modifications

프로그램이 컴파일 될 때 범위가 설정된다.
그러나 "use strict"가 아닐 때는 런타임중에 수정 할 수 있는 두가지 트릭이 있다.
이 두 가지는 모두 사용되서는 안되지만 인식하고 있어야 한다.

  1. eval(..) 함수는 프로그램 런타임 동안 즉시 컴파일하고 실행할 코드 문자열을 받는다. 함수나 var 선언이 있을 경우 현재 범위를 수정한다. (최적화된 Scope를 수정, 성능저하 등 좋지 못하다)
function badIdea() {
  eval("var oops = 'Ugh!';"); 
  console.log(oops);
}

badIdea();   // Ugh!
  1. with 키워드로 객체를 local 범위로 동적 변환한다. 또한, 컴파일 시간이 아닌 런타임에 범위로 바뀌고, 속성은 새 범위의 블록에서 식별자로 처리된다.

var badIdea = { oops: "Ugh!" };

with (badIdea) { 
  console.log(oops); // Ugh!
}

결론, "use strict"를 사용하자.

Lexical Scope

lexical scope의 핵심 아이디어는 전적으로 함수, 블록 및 변수 선언의 배치에 의해 제어된다는 것이다.
함수 내부에서 block scope를 갖도록 변수를 정의하면(let이나 const), 가장 가까운 {}와 범위가 연결된다.
변수에 대한 참조는 사용 할 수 있는 lexical scope 내에서 와야하며 가능 할 때까지 계속 중첩을 제거하며 전역에 도달하고 다른 범위에 갈 수 없을 때까지 이 과정이 반복된다.

compilation은 실제로 스코프 및 변수에 대한 메모리 예약 측면에서 아무 작업도 수행하지 않는다. 대신 compilation은 런타임에 필요한 lexical scopes의 모든 정보(lexical environments)를 완성한다.