원글 : Marja Hölttä, Understanding the ECMAScript spec, part 3
이 글에서는 ECMAScript 언어의 정의와 구문에 대해 좀 더 자세히 살펴보자. 문맥 자유 문법에 익숙하지 않다면, 지금이 기본 사항을 확인하기에 좋은 때이다. 왜냐하면 명세는 문맥 자유 문법을 사용하여 언어를 정의하기 때문이다. "Crafting Interpreters"의 문맥 자유 문법에 대한 장을 참조하여 쉽게 접근하거나 더 많은 수학적 정의를 위해서는 위키백과를 참조하자.
ECMAScript 명세는 네 가지 문법에 대해서 다음과 같이 정의한다.
어휘(lexical) 문법에서는 Unicode code points가 일련의 입력 요소(토큰, line terminators, 주석, 공백)로 변환되는 방법을 정의한다.
구문(syntactic) 문법은 구문적으로 올바른 프로그램이 토큰으로 구성되는 방식을 정의한다.
정규 표현식 문법은 Unicode code points를 정규 표현식으로 변환하는 방법을 정의한다.
숫자 문자열 문법은 문자열이 숫자 값으로 변환되는 방법을 정의한다.
각 문법은 일련의 프로덕션으로 구성된 문맥 자유 문법으로 정의한다.
문법은 약간 다른 표기법을 사용한다. 구문 문법은 LeftHandSideSymbol :
을 사용하는 반면 어휘 문법과 정규 표현식 문법은 LeftHandSideSymbol ::
을 사용하고 숫자 문자열 문법은 LeftHandSideSymbol :::
을 사용한다.
다음으로 어휘 문법과 구문 문법에 대해 좀 더 자세히 살펴보자.
명세는 ECMAScript 원본 텍스트를 일련의 Unicode code points로 정의한다. 예를 들어, 변수 이름은 ASCII 문자로 제한되지 않고 다른 유니코드 문자도 포함할 수 있다. 명세는 실제 인코딩(예: UTF-8 또는 UTF-16)에 대해 정의하지 않는다. 소스 코드가 이미 인코딩에 따라 일련의 Unicode code points로 변환되었다고 가정한다.
사전에 ECMAScript 소스 코드를 토큰화할 수 없으므로 어휘 문법 정의가 약간 더 복잡해진다.
예를 들어, 다음과 같은 상황에서 다음 컨텍스트을 보지 않고 /
가 나눗셈 연산자인지 정규 표현식의 시작인지 확인할 수 없다.
const x = 10 / 5;
여기서 /
는 DivPunctuator
이다.
const r = /foo/;
여기서 첫 번째 /
는 RegularExpressionLiteral
의 시작이다.
템플릿은 유사한 모호성을 유발하는데, }`
의 해석은 다음과 같은 상황에서 발생하는 컨텍스트에 따라 달라진다.
const what1 = 'temp';
const what2 = 'late';
const t = `I am a ${ what1 + what2 }`;
여기서 `I am a ${`
는 TemplateHead
이고 }`
는 TemplateTail
이다.
if (0 == 1) {
}`not very useful`;
여기서 }
는 RightBracePunctuator
이고 `
는 NoSubstitutionTemplate
의 시작이다.
/
와 }
의 해석은 "컨텍스트"(코드 구문 구조에서 위치)에 달려 있지만 다음에 설명할 문법은 여전히 컨텍스트가 없다.
어휘 문법은 일부 입력 요소가 허용되는 컨텍스트와 그렇지 않은 컨텍스트를 구별하기 위해 몇 가지 목표 기호(goal symbol)를 사용한다. 예를 들어 목표 기호 InputElementDiv
는 /
가 나눗셈이고 /=
가 나눗셈 할당인 컨텍스트에서 사용한다. InputElementDiv 프로덕션은 이 컨텍스트에서 생성할 수 있는 토큰을 나열한다.
InputElementDiv ::
WhiteSpace
LineTerminator
Comment
CommonToken
DivPunctuator
RightBracePunctuator
이러한 컨텍스트에서 DivPunctuator
입력 요소가 발생되거나 생성된다. RegularExpressionLiteral
을 생성하는 것은 선택 사항이 아니다.
반면, InputElementRegExp
는 /
가 정규 표현식의 시작인 컨텍스트에 대한 목표 기호이다.
InputElementRegExp ::
WhiteSpace
LineTerminator
Comment
CommonToken
RightBracePunctuator
RegularExpressionLiteral
프로덕션에서 알 수 있듯이 RegularExpressionLiteral
입력 요소를 생성할 수 있지만 DivPunctuator
를 생성할 수는 없다.
마찬가지로 TemplateMiddle
및 TemplateTail
이 허용되는 컨텍스트에는 RegularExpressionLiteral
외에 InputElementRegExpOrTemplateTail
이라는 또 다른 목표 기호가 있다.
마지막으로 InputElementTemplateTail
은 TemplateMiddle
과 TemplateTail
만 허용되고 RegularExpressionLiteral
은 허용되지 않는 컨텍스트의 목표 기호이다.
내부 구현에서 구문 문법 분석기(parser)는 어휘 문법 분석기(tokenizer 또는 lexer)를 호출하여 목표 기호를 매개 변수로 전달하고 해당 목표 기호에 적합한 다음 입력 요소를 요청할 수 있다.
Unicode code points에서 토큰을 구성하는 방법을 정의하는 어휘 문법을 살펴보았다. 구문 문법은 이를 기반으로 하며, 구문적으로 올바른 프로그램이 토큰으로 구성되는 방식을 정의한다.
문법에 새로운 키워드를 도입하는 것은 잠재적인 변경 사항일 수 있다. 기존 코드가 이미 키워드를 식별자로 사용하고 있다면 어떻게 될까?
예를 들어, await
가 키워드이기 전에 누군가 다음 코드를 작성했을 수 있다.
function old() {
var await;
}
ECMAScript 문법은 이 코드가 계속 동작하도록 조심스럽게 await
키워드를 추가했다. 비동기 함수 내에서 await
는 키워드이므로 동작하지 않는다.
async function modern() {
var await; // Syntax error
}
제너레이터가 아닌 경우 식별자로 yield
를 허용하고 위와 같이 제너레이터에서는 yield
는 키워드이므로 동작하지 않는다.
await
가 식별자로 허용되는 방법을 이해하려면 ECMAScript 고유의 구문 문법 표기법을 이해해야 한다.
VariableStatement
에 대한 프로덕션이 어떻게 정의되는지 살펴보자. 언뜻 보면 문법이 약간 어렵게 보일 수 있다.
VariableStatement[Yield, Await] :
var VariableDeclarationList[+In, ?Yield, ?Await] ;
첨자([Yield, Await]
)와 접두사(+In
의 +
및 ?Async
의 ?
)는 무엇을 의미할까?
표기법은 문법 표기법 부분에서 정의한다.
첨자는 왼쪽 기호 집합에 대한 일련의 프로덕션을 한 번에 표현하기 위한 줄임말이다. 왼쪽 기호는 두 개의 매개변수를 가지며, 이는 네 개의 "실제" 왼쪽 기호로 확장된다.
VariableStatement
,VariableStatement_Yield
,VariableStatement_Await
, VariableStatement_Yield_Await
여기서 일반 VariableStatement
는 "_Await
및 _Yield
가 없는 VariableStatement
"를 의미한다. VariableStatement[Yield, Await]
와 혼동해서는 안된다.
프로덕션의 오른쪽에는 "_In
과 함께 사용"을 의미하는 단축표기법 +In
과 "왼쪽 기호에 _Await
가 있는 경우에만 _Await
와 함께 사용"을 의미하는 ?Await
가 있다. (?Yield
와 비슷하게).
세 번째 단축표기법인 ~Foo
는 "_Foo
를 제외하고 사용"을 의미하며, 이 프로덕션에서는 사용되지 않는다.
이 내용를 통해 다음과 같이 프로덕션을 확장할 수 있다.
VariableStatement :
var VariableDeclarationList_In ;
VariableStatement_Yield :
var VariableDeclarationList_In_Yield ;
VariableStatement_Await :
var VariableDeclarationList_In_Await ;
VariableStatement_Yield_Await :
var VariableDeclarationList_In_Yield_Await ;
궁극적으로 우리는 두 가지 사실을 알아야 한다.
_Await
가 있는지 없는지 여부는 어디에서 결정되는가?Something_Await
와 Something
(_Await
제외)의 프로덕션은 어디에서 나뉘어 질까?_Await
or no_Await
?먼저 1번 문제를 풀어보자. 함수 본문과 무관하게 _Await
매개변수를 선택하는지 여부에 따라 비동기가 아닌 함수와 비동기 함수가 다르다고 추측하는 것은 다소 쉽다. 다음은 비동기 함수 선언에 대한 프로덕션 중에서 찾을 수 있다.
AsyncFunctionBody :
FunctionBody[~Yield, +Await]
AsyncFunctionBody
에는 매개 변수가 없다. 매개 변수는 FunctionBody
의 오른쪽에 추가된다.
이 프로덕션을 확장하면 다음과 같다.
AsyncFunctionBody :
FunctionBody_Await
즉, 비동기 함수에는 FunctionBody_Await
가 있으며, 이는 await
가 키워드로 처리되는 함수 본문을 의미글다.
반면에 비동기가 아닌 함수 내부에 있는 경우 관련 프로덕션은 다음과 같다.
FunctionDeclaration[Yield, Await, Default] :
function BindingIdentifier[?Yield, ?Await] ( FormalParameters[~Yield, ~Await] ) { FunctionBody[~Yield, ~Await] }
(FunctionDeclaration
에는 또 다른 프로덕션이 있지만 코드 예제와 관련이 없다.)
조합 확장을 방지하려면 이 특정 프로덕션에서 사용되지 않는 Default
매개변수를 무시한다.
확장된 형태의 프로덕션은 다음과 같다.
FunctionDeclaration :
function BindingIdentifier ( FormalParameters ) { FunctionBody }
FunctionDeclaration_Yield :
function BindingIdentifier_Yield ( FormalParameters ) { FunctionBody }
FunctionDeclaration_Await :
function BindingIdentifier_Await ( FormalParameters ) { FunctionBody }
FunctionDeclaration_Yield_Await :
function BindingIdentifier_Yield_Await ( FormalParameters ) { FunctionBody }
이 프로덕션에서는 확장되지 않은 프로덕션에서 [~Yield, ~Await]
로 매개변수화되기 때문에 _Yield
및 _Await
를 제외한 FunctionBody
및 FormalParameters
를 항상 얻는다.
함수 이름은 다르게 처리된다. 왼쪽 기호에 매개변수가 있으면 _Await
및 _Yield
매개변수를 가져온다.
요약하자면, 비동기 함수에는 FunctionBody_Await
가 있고 비동기가 아닌 함수에는 _Await
가 제외된 FunctionBody
가 있다. 제너레이터가 아닌 함수에 대해 이야기하고 있기 때문에 비동기 예제 함수와 비동기가 아닌 예제 함수는 모두 _Yield
없이 매개 변수화 된다.
어떤 것이 FunctionBody
이고 어떤 것이 FunctionBody_Await
인지 기억하기 어려울 수 있다. FunctionBody_Await
는 await
가 식별자인 함수일까, 아니면 await
가 키워드인 함수일까?
_Await
매개변수는 "await
는 키워드"라는 의미로 생각할 수 있으며, 이 접근 방식은 미래에도 사용할 수 있다. blob
가 추가되지만 "blobby" 함수 내부에만 있는 새로운 키워드를 상상해 보자. non-blobby non-async non-generators는 여전히 현재와 똑같이 FunctionBody
(_Await
, _Yield
또는 _Blob
제외)를 갖는다. blobby 함수에는 FunctionBody_Blob
이 있고, 비동기 blobby 함수에는 FunctionBody_Await_Blob
등이 있다. 우리는 여전히 프로덕션에 Blob
첨자를 추가해야 하지만 이미 존재하는 기능에 대한 FunctionBody
의 확장된 형태는 그대로 유지한다.
await
를 허용하지 않는다.다음으로, FunctionBody_Await
내부에 있는 경우 식별자로써 await
가 어떻게 허용되지 않는지 알아보자.
_Await
매개 변수가 FunctionBody
에서 이전에 살펴본 VariableStatement
프로덕션까지 변경되지 않고 전달되는 것을 확인하기 위해 추가로 프로덕션을 추적할 수 있다.
따라서 비동기 함수 내부에는 VariableStatement_Await
가 있고 비동기 함수가 아닌 내부에는 VariableStatement
가 있다.
프로덕션을 더 추적하고 매개변수를 추적할 수 있다. VariableStatement
의 프로덕션은 이미 확인했다.
VariableStatement[Yield, Await] :
var VariableDeclarationList[+In, ?Yield, ?Await] ;
VariableDeclarationList
의 모든 프로덕션은 매개변수를 있는 그대로 전달한다.
VariableDeclarationList[In, Yield, Await] :
VariableDeclaration[?In, ?Yield, ?Await]
(여기서는 예제와 관련된 프로덕션만 보여준다.)
VariableDeclaration[In, Yield, Await] :
BindingIdentifier[?Yield, ?Await] Initializer[?In, ?Yield, ?Await] opt
opt
단축표기법은 오른쪽 기호가 선택 사항임을 의미한다. 실제로 두 가지 프로덕션이 있다. 하나는 선택적 기호가 있고 다른 하나는 기호가 없다.
예제와 관련된 간단한 사례에서 VariableStatement
는 키워드 var
와 initializer가 없는 단일 BindingIdentifier
로 구성되며 세미콜론으로 끝난다.
BindingIdentifier
로 await
를 허용하지 않거나 허용하기 위해 다음과 같이 되어야 한다.
BindingIdentifier_Await :
Identifier
yield
BindingIdentifier :
Identifier
yield
await
이렇게 하면 await
가 비동기 함수 내에서 식별자로 허용되지 않고 비동기가 아닌 함수 내에서 식별자로 허용된다.
그러나 명세는 다음과 같이 정의하지 않고, 대신 다음과 같은 프로덕션을 찾는다.
BindingIdentifier[Yield, Await] :
Identifier
yield
await
확장하면 다음과 같은 프로덕션을 의미한다.
BindingIdentifier_Await :
Identifier
yield
await
BindingIdentifier :
Identifier
yield
await
(예제에서는 필요하지 않은 BindingIdentifier_Yield
와 BindingIdentifier_Yield_Await
에 대한 프로덕션은 생략한다.)
await
와 yield
가 항상 식별자로 허용되는 것처럼 보인다. 왜 그럴까? 이 글 전체가 의미없는 것일까?
비동기 함수 내에서 식별자로 await
를 금지하려면 정적 의미론이 필요하다는 것을 알게 되었다.
정적 의미론은 정적 규칙, 즉 프로그램이 실행되기 전에 확인되는 규칙을 정의한다.
이 경우 BindingIdentifier
에 대한 정적 의미론은 다음과 같은 구문 지향 규칙을 정의한다.
BindingIdentifier[Yield, Await] : await
프로덕션에
[Await]
매개변수가 있으면 구문 오류이다.
사실상 BindingIdentifier_Await : await
프로덕션을 금지한다.
명세는 이러한 프로덕션이 있지만 정적 의미론에 의해 구문 오류로 정의되는 이유는 자동 세미콜론 삽입(ASI) 간섭 때문이라고 설명한다.
ASI는 문법 생성에 따라 코드 라인을 구문 분석할 수 없을 때 시작된다는 것을 기억하자. ASI는 문과 선언이 세미콜론으로 끝나야 한다는 요구 사항을 충족하기 위해 세미콜론을 추가하려고 한다.
(ASI에 대해서는 다음 글에서 자세히 설명한다.)
다음 코드를 자세히 보자 (명세의 예)
async function too_few_semicolons() {
let
await 0;
}
문법이 식별자로 await
를 허용하지 않는다면, ASI가 동작하여 코드를 다음과 같이 문법적으로 let
을 식별자로 사용하는 올바른 코드로 변환한다.
async function too_few_semicolons() {
let;
await 0;
}
ASI에 대한 이러한 종류의 간섭은 너무 혼란스럽다고 여겨져서 식별자로 await
를 허용하지 않기 위해 정적 의미론이 사용되었다.
StringValues
또 다른 관련 규칙이 있다.
BindingIdentifier : Identifier
프로덕션에
[Await]
매개변수가 있고Identifier
의StringValue
가"await"
인 경우 구문 오류이다.
처음에는 혼란스러울 수 있다. 식별자는 다음과 같이 정의된다.
Identifier :
IdentifierName but not ReservedWord
await
는 ReservedWord
인데 어떻게 식별자
가 await
가 될 수 있을까?
결과적으로 식별자는 await
가 될 수 없지만 StringValue
가 "await"
인 다른 것일 수 있다. 즉, 문자 시퀀스의 다른 표현이 await
이다.
식별자 이름에 대한 정적 의미론은 식별자 이름의 StringValue
가 계산되는 방법을 정의한다.
예를 들어 a
에 대한 Unicode escape sequence는 \u0061
이므로 \u0061wait
는 StringValue
"await"
를 뜻한다. \u0061wait
는 어휘 문법에 의해 키워드로 인식되지 않고 대신 식별자
가 된다.
위 내용이 비동기 함수 내에서 변수 이름으로 사용을 금지하기 위한 정적 의미론이다.
그래서 다음은 정상 동작한다.
function old() {
var \u0061wait;
}
그리고 다음은 동작하지 않는다.
async function modern() {
var \u0061wait; // Syntax error
}
이 글에서 우리는 어휘 문법, 구문 문법, 구문 문법을 정의하는 데 사용되는 단축표기법에 익숙해졌다. 예를 들어 await
를 비동기 함수 내에서 식별자로 사용하는 것을 금지하지만 비동기가 아닌 함수 내에서는 허용하는 방법을 살펴보았다.
자동 세미콜론 삽입과 커버 문법과 같은 구문 문법의 다른 흥미로운 부분은 다음 글에서 다룰 것이다. 계속 지켜봐 주세요!