원글 : Marja Hölttä, Understanding the ECMAScript spec, part 2
명세 읽는 기술을 좀 더 다듬어 보자. 이전 글을 보지 않았다면 지금 보길 추천한다!
이미 아는 것에서 출발하는 것은 뭔가를 재밌게 배우는 방법 중 하나이다. 여기서 우리가 이미 알 만한 JavaScript 기능을 어떻게 명세해 두었는지부터 살펴보자.
경고! 이 글에는 2020년 2월 ECMAScript 명세에서 그대로 가져온 알고리즘을 포함한다. 그러므로 시간이 지나면 실제 명세와 예제가 다를 수 있다.
이 글을 읽는 여러분은 아마 속성을 조회할 때 프로토타입 체인을 이용한다는 것을 알고 있다. 만약 해당 객체에 우리가 찾는 속성이 없다면, 자바스크립트 엔진은 해당 속성을 찾을 때까지(또는 프로토타입이 더 이상 이어지지 않을 때까지) 프로토타입 체인을 따라 이동한다.
예를 들면
const o1 = { foo: 99 };
const o2 = {};
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 99
이 동작이 정의된 위치를 살펴보자. 객체 내부 메서드 목록에서부터 시작해보자.
[[GetOwnProperty]]
와 [[Get]]
이 있다. 우리는 소유 속성(own properties)으로 제한되지 않는 버전에 관심이 있으므로 [[Get]]
을 사용하자.
불행히도 속성 설명자 명세 타입에는 [[Get]]
이라는 필드도 있으므로 [[Get]]
명세을 보는 동안 두 가지가 다름을 염두에 두자.
[[Get]]
은 주요 내부 메서드이다. 일반(ordinary) 객체는 주요 내부 메서드에 대한 기본 동작을 구현한다. 특수(exotic) 객체는 기본 동작에서 벗어나는 자체 내부 메서드 [[Get]]
를 정의할 수 있지만, 이 글에서는 일반 객체만 살펴보기로 하자.
[[Get]]
은 기본적으로 OrdinaryGet
에 동작을 위임해 구현한다.
속성 키
P
와 ECMAScript 언어 값Receiver
를 사용하여O
의[[Get]]
내부 메서드를 호출하면 다음 동작이 수행된다.
? OrdinaryGet(O, P, Receiver)
를 반환한다.
위 명세의 Receiver
가 접근자 속성의 getter 함수를 호출할 때 this
값으로 사용하는 값임을 알 수 있다.
OrdinaryGet
은 다음과 같이 정의된다.
OrdinaryGet ( O, P, Receiver )
추상 연산
OrdinaryGet
이 ObjectO
, 속성 키P
및 ECMAScript 언어 값Receiver
로 호출되면 다음 동작이 수행된다.
- 단언:
IsPropertyKey(P)
는true
이다.desc
를? O.[[GetOwnProperty]](P)
로 한다.
desc
가undefined
라면,
parent
를? O.[[GetPrototypeOf]]()
로 한다.parent
가null
이면,undefined
를 반환한다.? parent.[[Get]](P, Receiver)
를 반환한다.IsDataDescriptor(desc)
가true
라면,desc.[[Value]]
를 반환한다.- 단언:
IsAccessorDescriptor(desc)
는true
이다getter
를desc.[[Get]]
로 한다.getter
가undefined
라면,undefined
를 반환한다.? Call(getter, Receiver)
를 반환한다.
프로토타입 체인 동작은 3단계로 이루어져 있다. 속성을 자체 속성으로 찾지 못하면 프로토타입의 [[Get]]
메서드를 호출하여 OrdinaryGet
에 다시 위임한다. 그래도 속성을 찾지 못하면 해당 속성을 찾거나 프로토타입이 없는 객체에 도달할 때까지 해당 프로토타입의 [[Get]]
메서드를 호출하여 다시 OrdinaryGet
에 위임한다.
o2.foo
에 접근할 때 이 알고리즘이 어떻게 작동하는지 살펴보자. 먼저 O
가 o2
이고 P
가 "foo"
인 OrdinaryGet
을 호출한다. O.[[GetOwnProperty]]("foo")
는 o2
에 "foo"
라는 자체 속성이 없기 때문에 undefined
을 반환하므로 3단계에서 if 분기를 사용한다. 3.a
단계에서 o1
인 o2
의 프로토타입에 parent
를 설정한다. parent
가 null
이 아니므로 3.b
단계에서는 반환하지 않는다. 3.c
단계에서 속성 키 "foo"
로 부모의 [[Get]]
메서드를 호출하고, 반환한다.
부모(o1
)는 일반 객체이므로, [[Get]]
메서드는 OrdinaryGet
을 다시 호출하며, 이번에는 O
가 o1
이고 P
는 "foo"
이다. O1
은 "foo"
라는 자체 속성을 가지고 있으므로, 2단계에서 O.[[GetOwnProperty]]("foo")
는 관련 속성 설명자를 반환하고 desc
에 저장한다.
속성 설명자는 명세 타입이다. 데이터 속성 설명자는 속성의 값을 [[Value]]
필드에 직접 저장한다. 접근자 속성 설명자는 접근자 함수를 [[Get]]
및/또는 [[Set]]
필드에 저장한다. 이 경우, "foo"
와 관련된 속성 설명자는 데이터 속성 설명자이다.
2단계에서 desc
에 저장한 데이터 속성 설명자는 undefined
이 아니므로 3단계에서 if 분기를 사용하지 않는다. 다음으로 우리는 4단계를 실행한다. 속성 설명자는 데이터 속성 설명자이므로 4단계에서 [[Value]]
필드(99
)를 반환하고 완료된다.
Receiver
매개변수는 8단계에서 접근자 속성인 경우에만 사용된다. 접근자 속성의 getter 함수를 호출할 때 this
값으로 전달된다.
OrdinaryGet
은 변경되지 않은 상태로 반복되는 동안 원본 Receiver
를 전달한다(3.c 단계). Receiver
가 원래 어디에서 전달되었는지 알아보자!
[[Get]]
를 호출하는 위치를 찾아보면 참조(References)에서 동작하는 추상 연산 GetValue
를 찾을 수 있다. 참조(Reference)는 기본 값, 참조한 이름 및 엄격한 참조 플래그(strict reference flag)로 구성된 명세 타입이다. o2.foo
의 경우 기본 값은 객체 o2
이고 참조된 이름은 문자열 "foo"
이며 엄격한 참조 플래그는 예제 코드가 엉성하기 때문에 false
이다.
번외: 참조는 Record 일 것 같지만 Record가 아니다. 여기에는 세 개의 구성요소가 포함되어 있으며, 이는 동일하게 세 개의 명명된 필드로 표현될 수 있다. 참조는 역사적 이유 때문에 Record가 아니다.
GetValue
로 돌아와서명세에서 GetValue
를 어떻게 정의하는지 살펴보자.
ReturnIfAbrupt(V)
.Type(V)
이Reference
가 아니라면,V
를 반환한다.base
를GetBase(V)
로 한다.IsUnresolvableReference(V)
이true
라면,ReferenceError
예외가 발생한다.
IsPropertyReference(V)
가true
라면,
HasPrimitiveBase(V)
가true
라면,
- 단언: 이런 경우,
base
는 절대undefined
또는null
가 되지 않는다.base
를! ToObject(base)
로 설정한다.? base.[[Get]](GetReferencedName(V), GetThisValue(V))
를 반환한다.그 외,
- 단언:
base
은 Environment Record 이다.? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))
를 반환한다.
이 예에서 참조는 속성 참조인 o2.foo
이므로 5단계를 확인한다. base
(o2
)가 원시 값(Number, String, Symbol, BigInt, Boolean, Undefined 또는 Null)이 아니기 때문에 5.a
에서 분기하지 않는다.
다음으로 5.b
단계에서 [[Get]]
을 호출한다. 전달하는 Receiver
는 GetThisValue(V)
이며, 이 경우 이 값은 참조의 기본 값이다.
- 단언:
IsPropertyReference(V)
는true
이다.
IsSuperReference(V)
가true
라면,
- 참조
V
의thisValue
구성 요소 값을 반환한다.GetBase(V)
를 반환한다.
o2.foo
의 경우 슈퍼 참조(예: super.foo)가 아니기 때문에 2단계에서 분기를 수행하지 않지만 3단계를 수행하여 참조의 기본 값인 o2를 반환한다.
모든 과정을 종합하면 Receiver
를 원본 참조의 base
로 설정한 다음 프로토타입 체인 동작 동안 변경하지 않음을 알 수 있다. 마지막으로 찾은 속성이 접근자 속성이면 호출할 때 Receiver
를 this
값으로 사용한다.
특히 getter 내부의 this
값은 프로토타입 체인 동작 동안 속성을 찾은 것이 아니라 속성을 가져오려고 했던 원래 개체를 나타낸다.
위에서 배운 내용을 사용해보자!
const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50
위 예제에서는 foo
라는 접근자 속성과 이에 대한 getter를 정의했다. 그리고 이 getter는 this.x
를 반환한다.
이제 o2.foo
에 접근해보자. - getter는 무엇을 반환할까?
getter를 호출할 때 this 값은 원래 속성을 가져오려고 했던 객체이지 찾은 객체가 아니다.
이 경우 this
값은 o1
이 아니라 o2
이다. getter가 o2.x
또는 o1.x
를 반환하는지 확인해보면 실제로 o2.x
를 반환하는 것을 확인할 수 있다.
제대로 동작한다! 명세에서 읽은 내용을 기반으로 이 코드 조각의 동작을 예측할 수 있었다.
[[Get]]
을 호출하는 이유는 무엇일까?명세에서 o2.foo
와 같은 속성에 접근할 때 객체 내부 메서드 [[Get]]
를 호출한다는 내용이 있었나? 분명 어딘가에는 있어야 할 텐데... 필자의 말을 믿지 말자!
이 글을 통해 객체 내부 메서드 [[Get]]
은 참조에서 동작하는 추상 연산 GetValue
에서 호출한다는 것을 배웠다. 그렇다면 GetValue
는 또 어디에서 호출될까?
명세의 문법 규칙은 언어의 구문을 정의한다. Runtime semantics는 구문 구성의 "의미"(런타임에 이를 평가하는 방법)를 정의한다.
문맥 자유 문법에 익숙하지 않다면 지금 살펴보자!
이후 글에서 문법 규칙에 대해 더 자세히 알아볼 것이다. 지금은 간단하게 살펴보자! 특히 이 글의 프로덕션에서 사용되는 첨자(Yield, Await 등)는 무시해도 된다.
다음은 프로덕션 MemberExpression
이 어떻게 구성되었는지 보여준다.
MemberExpression :
PrimaryExpression
MemberExpression [ Expression ]
MemberExpression . IdentifierName
MemberExpression TemplateLiteral
SuperProperty
MetaProperty
new MemberExpression Arguments
여기에 MemberExpression
에 대한 7개의 프로덕션이 있다. MemberExpression
은 PrimaryExpression
일 수 있다. 또는 MemberExpression
은 다른 MemberExpression
과 Expression
을 함께 연결하여 구성할 수 있다(MemberExpression [ Expression ]
)
, 예를 들면 o2['foo']
또는 MemberExpression . IdentifierName
일 수 있다, 예를 들면 o2.foo
— 이것은 예시와 관련된 프로덕션이다.
프로덕션 MemberExpression : MemberExpression . IdentifierName
에 대한 런타임 의미론은 이를 평가할 때 취해야 할 일련의 단계를 정의한다.
Runtime Semantics: Evaluation for
MemberExpression : MemberExpression . IdentifierName
baseReference
를MemberExpression
를 평가한 결과로 한다.baseValue
를? GetValue(baseReference)
로 한다.MemberExpression
과 일치하는 코드가 엄격한 모드 코드인 경우strict
는true
이고, 그렇지 않으면strict
는false
이다.? EvaluatePropertyAccessWithIdentifierKey(baseValue, IdentifierName, strict)
를 반환한다.
위 알고리즘은 동작을 추상 연산 EvaluatePropertyAccessWithIdentifierKey
에 위임하므로 이 명세도 살펴봐야 한다.
EvaluatePropertyAccessWithIdentifierKey( baseValue, identifierName, strict )
추상 작업
EvaluatePropertyAccessWithIdentifierKey
는baseValue
, Parse NodeidentifierName
및 Boolean 인수strict
를 인수로 사용한다. 다음 동작을 수행한다.
- 단언:
identifierName
는IdentifierName
이다bv
를? RequireObjectCoercible(baseValue)
로 한다.propertyNameString
를identifierName
의StringValue
로 한다.- 기본 값 구성 요소가
bv
이고 참조된 이름 구성 요소가propertyNameString
이고 엄격한 참조 플래그가strict
인 Reference 타입의 값을 반환한다.
즉, EvaluatePropertyAccessWithIdentifierKey
는 제공된 baseValue
를 기본으로 사용하고 identifierName
의 문자열 값을 속성 이름으로 사용하며 strict
를 엄격 모드 플래그로 사용하는 참조를 구성한다.
나중에 이 참조는 GetValue
로 전달된다. 이 값은 참조가 사용되는 방식에 따라 명세의 여러 위치에서 정의한다.
MemberExpression
이 예제에서는 속성 접근를 매개변수로 사용한다.
console.log(o2.foo);
이 경우 동작은 인수에서 GetValue
를 호출하는 ArgumentList
프로덕션의 런타임 의미론으로 정의된다.
Runtime Semantics:
ArgumentListEvaluation
ArgumentList : AssignmentExpression
ref
를AssignmentExpression
를 평가한 결과로 한다.arg
를? GetValue(ref)
로 한다.- 유일한 항목이
arg
인 목록을 반환한다.
o2.foo
는 AssignmentExpression
처럼 보이지 않지만 하나이므로 이 프로덕션에 해당한다. 이유를 알아보기 위해 이 추가 콘텐츠를 확인할 수 있지만 이 시점에서 꼭 필요한 것은 아니다.
1단계의 AssignmentExpression
은 o2.foo
이다. o2.foo
를 평가한 결과인 ref
는 위에서 언급한 참조이다. 2단계에서 GetValue
를 호출한다. 따라서 객체 내부 메서드 [[Get]]
이 호출되고 프로토타입 체인 동작이 발생한다는 것을 알 수 있다.
이 글에서 우리는 명세에서 언어 기능(이 경우 프로토타입 조회)을 모든 다른 계층(기능을 트리거하는 구문 구조와 이를 정의하는 알고리즘)에서 어떻게 정의하는지 살펴보았다.