[자바스크립트 완벽가이드] - 메타프로그래밍

Lee Jeong Min·2022년 7월 10일
1

자바스크립트

목록 보기
15/17
post-thumbnail

자바스크립트 완벽가이드 14장에 해당하는 부분이고, 읽으면서 자바스크립트에 대해 새롭게 알게된 부분만 정리한 내용입니다.

이 장은 라이브러리를 작성하는 사람이나 JS 객체가 어떻게 동작하는지 자세히 알고 싶은 사람에게 가치 있는 고급 JS기능을 설명한다.

→ 다른 코드를 조작하는 코드를 작성하는 것과 관련되기에 메타프로그래밍이라고도 부른다.

프로퍼티 속성

이곳에서 말하는 데이터 프로퍼티(value, writable, enumerable, configurable)과 접근자 프로퍼티(get, set, enumerable, configurable) 모두 모던 자바스크립트 딥 다이브에서 다룬 내용이어서 크게 어려운 부분은 없었다.

다만, getOwnProepertyDescriptor()라는 메서드는 자체 프로퍼티에만 동작하여 상속된 프로퍼티 속성을 검색할 땐, Reflect.getOwnPropertyDescriptor()함수를 참고하라.

Object.assign()은 열거 가능한 프로퍼티와 값은 복사할 수 있지만 프로퍼티 속성은 복사할 수 없다.

let o = {
  c: 1,
  get count() {
    return this.c++;
  },
};

let p = Object.assign({}, o);
console.log(p.count); // => 1
console.log(p.count); // => 1

따라서 이 경우 책의 예제처럼, 대상 객체에 복사되는 것은 게터 메서드가 반환하는 값이지 게터 메서드 자체가 아니다!

객체 확장성

확장 가능 속성은 객체에 새로운 프로퍼티를 추가할 수 있는지 결정한다.

  • Object.isExtensible()
  • Object.preventExtensions()

두 개의 메서드 사용하고, 일단 확장 불가로 만든 후에는 다시 확장 가능으로 되돌릴 방법 X. 프로토타입에 추가하는 것은 그대로 상속 가능

Object.seal()Object.freeze()는 전달받은 객체에만 효과가 있고, 그 객체의 프로토타입은 변경하지 않는다.

그리고 이 함수들 모두 전달받은 객체를 반환하므로 중첩해서 호출이 가능(Object.preventExtensions()도 마찬가지)

더하여 동결된 객체(Object.freeze()를 사용한 객체)는 JS 테스트 스위트를 방해할 수 있다.

테스트 스위트가 무엇인지는 궁금함..
아마 테스트할때 객체에 프로퍼티 추가하는등의 조작이 있는데 이를 못해서 테스트 스위트를 방해한다는 것인가..?

프로토타입 속성

브라우저 일부에서는 __proto__라는 이름을 사용했는데, Object.getPrototypeOf()Object.setPrototypeOf()가 나오면서 더이상 사용하지 않는 것이 좋다.

참고: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/proto

잘 알려진 심벌

Symbol.iterator와 Symbol.asyncIterator

이 두 심벌은 객체나 클래스를 이터러블이나 비동기 이터러블로 만든다.

앞에서 계속 설명해와서 따로 자세히 언급하지는 않는다.

Symbol.hasInstance

instanceof라는 연산자를 설명할 때 오른쪽은 반드시 생성자 함수이고, 왼쪽의 값의 프로토타입 체인에서 오른쪽의 프로토타입을 찾는 방식으로 평가한다.

ES6 이후에는 Symbol.hasInstance도 사용할 수 있게되어, 오른쪽에 [Symbol.hasInstance] 메서드가 있는 객체가 있으면 왼쪽 값을 인자로 [Symbol.hasInstance] 메서드를 호출하여 메서드의 반환 값을 불로 반환한 값이 instanceof 연산자의 값이다.

let uint8 = {
  [Symbol.hasInstance](x) {
    return Number.isInteger(x) && x >= 0 && x <= 255;
  },
};

console.log(128 instanceof uint8); // true
console.log(256 instanceof uint8); // false
console.log(Math.PI instanceof uint8); // false (정수가 아님)

Symbol.toStringTag

일반적인 toString() 메서드를 호출한 경우

console.log({}.toString()); // [object Object]

이를 call과 같이 사용하면 다른 방법으로 얻을 수 없는 객체의 타입 정보를 얻을 수 있다.

console.log(Object.prototype.toString.call([])); // [object Array]
console.log(Object.prototype.toString.call(/./)); // [object RegExp]
console.log(Object.prototype.toString.call(() => {})); //[object Function]
console.log(Object.prototype.toString.call("")); //[object String]

이러한 메서드를 classof()라는 함수를 만들어서 사용하면 객체 타입을 구분하지 않는 typeof 연산자보다 유용하게 사용할 수 있다.

const classof = (o) => Object.prototype.toString.call(o).slice(8, -1);

console.log(classof(null)); // Null
console.log(classof(undefined)); // Undefined
console.log(classof(1)); // Number
console.log(classof(/./)); // RegExp

ES6 전에는 이 Object.prototype.toString() 메서드를 내장 타입의 인스턴스에만 사용할 수 있었는데, ES6이후 Object.prototype.toString()은 인자에서 심벌 이름 Symbol.toStringTag를 가진 프로퍼티를 찾고, 그 프로퍼티가 존재하면 그 값을 반환한다.

const classof = (o) => Object.prototype.toString.call(o).slice(8, -1);

class Range {
  get [Symbol.toStringTag]() {
    return "Range";
  }
}

let r = new Range(1, 10);
console.log(Object.prototype.toString.call(r)); // [object Range]
console.log(classof(r)); // Range

Symbol.species

ES6에서는 classextends 키워드를 사용해 내장 클래스를 쉽게 활용할 수 있다.
→ 즉, 배열 내장 메서드를 가진 객체를 생성하는데 extendsArray를 지정하여 쉽게 만들 수 있음

ES6 이후의 Array() 생성자에는 심벌 이름 Symbol.species를 가진 프로퍼티가 존재하여 extends로 서브클래스를 만들면 이를 상속받게 된다.

Array[Symbol.species]는 읽기 전용 프로퍼티여서, 그 게터 함수는 단순히 this를 반환하고, 서브클래스 생성자는 이 게터 함수를 상속하여 기본적으로 모든 서브 클래스는 독립적인 '종족'이 된다.

그래서 이를 이용하여 만약 서브 클래스에서 Array와 같은 일반적인 메서드를 반환하길 원한다면 서브클래스의 Symbol.species를 Array로 설정하면 된다.
다만, 읽기 전용 접근자이기 때문에 defineProperty()를 사용해야 한다. 또는 간단한 방법으로 서브클래스를 처음 만들 때 Symbol.species 게터를 명시적으로 정의하는 방법이 있다.

class EZArray extends Array {
  static get [Symbol.species]() {
    return Array;
  }
  get first() {
    return this[0];
  }
  get last() {
    return this[this.length - 1];
  }
}

let e = new EZArray(1, 2, 3);
let f = e.map((x) => x - 1);
console.log(e.last); // 3
console.log(f.last); // undefined: f는 last 게터가 없는 일반적인 배열이다.

Symbol.isConcatSpreadable

concat()은 반환될 배열에 사용할 생성자를 정할 때 Symbol.species를 사용하기도 하지만 Symbol.isConcatSpreadable 역시 사용한다.

ES6 전에 concat() 사용시, 값이 배열인지 아닌지 판단할 때 Array.isArray()를 사용했는데 이 알고리즘이 바뀌어, concat()의 인자나 this 값이 객체이고 심벌 이름 Symbol.isConcatSpreadable이 있는 프로퍼티가 있다면 이 프로퍼티의 불 값을 사용해 인자를 '분해'할지 판단한다.

const arraylike = {
  length: 1,
  0: 1,
  [Symbol.isConcatSpreadable]: true,
};

console.log([].concat(arraylike)); // [1]

const arraylike2 = {
  length: 1,
  0: 1,
  [Symbol.isConcatSpreadable]: false,
};

console.log([].concat(arraylike2)); // [ { '0': 1, length: 1, [Symbol(Symbol.isConcatSpreadable)]: false } ]

패턴 매칭 심벌

범용적인 문자열 메서드에 잘 알려진 심벌 메서드를 써서 패턴매칭 클래스를 정의할 수 있다.

그 원리는 아래와 같다.

// 이 코드는 
string.method(pattern, arg)

// 다음과 같이 패턴 객체의 심벌 이름 메서들르 호출하는 것과 같다.
pattern[symbol](string, arg)

예제

class Glob {
  constructor(glob) {
    this.glob = glob;

    // ?는 /를 제외한 글자 하나에 일치하고 *는 0개 이상의 글자에 일치한다.
    let regexpText = glob.replace("?", "([^/]").replace("*", "([^/]*");

    // 유니코드를 인식하도록 u 플래그를 사용
    this.regexp = new RegExp(`^${regexpText}$`, "u");
  }

  toString() {
    return this.glob;
  }

  [Symbol.search](s) {
    return s.search(this.regexp);
  }
  [Symbol.match](s) {
    return s.match(this.regexp);
  }
  [Symbol.replace](s, replacement) {
    return s.replace(this.regexp, replacement);
  }
}

위와 같은클래스를 만들어 잘 알려진 심벌 메서드를 사용하여 패턴매칭 클래스를 정의할 수 있다.

Symbol.toPrimitive

객체를 기본 값으로 변환하는 과정

  • 문자열 값을 예상하거나 선호하는 곳
  1. toString() 메서드 먼저 호출
  2. valueOf() 메서드 사용
  • 숫자 값을 선호하는 곳
  1. valueOf() 메서드 사용
  2. toString() 메서드 사용
  • 선호하는 곳이 없는 곳
  1. 클래스에서 변환방법을 결정

Date 객체는 toString()을 먼저 사용하고 다른 타입은 모두 valueOf()를 먼저 사용

ES6 이후에는 Symbol.toPrimitive가 객체를 기본 값으로 변환하는 기본 동작을 덮어쓸 수 있게 하여, 클래스 인스턴스가 기본 값으로 변환되는 방법을 제어할 수 있다. 이 Symbol.toPrimitive 메서드는 문자열 인자를 하나 받는데, 각 인자는 JS가 객체를 어떤 값으로 변환하려 하는지 나타낸다.

  • 인자가 string이면 JS가 문자열을 예상하거나 선호하지만 필수는 아닌 컨텍스트에 있다는 뜻이다. ex) 템플릿 리터럴에 객체를 사용하는 경우
  • 인자가 number면 JS가 숫자 값을 예상하거나 선호하지만 필수는 아닌 컨텍스트에 있다는 뜻이다. ex) 객체를 < 나 > 연산자 또는 * 같은 산술 연산자와 함께 사용하는 경우
  • 인자가 default이면 JS가 숫자나 문자열이 모두 가능한 컨텍스트에 있다는 뜻 ex) +, ==, !=가 이에 해당

Symbol.unscopables

구식 with문 때문에 발생한 호환성 문제를 해결하기 위해 도입된 것인데, 배열 클래스에 새로운 메서드를 추가할 때 with 문 때문에 호환성 문제가 발생했다. 이를 해결하기 위해 Symbol.unscopables가 도입되어 객체 o가 있을 때 with문은 Object.keys(o[Symbol.unscopables]||{})를 계산하고 바디의 가상 스코프를 생성할 때 그 결과에 포함된 프로퍼티는 무시한다.

이 심벌을 사용하는 최신 배열 메서드는 다음과 같이 알아볼 수 있다.

let newArrayMethods = Object.keys(Array.prototype[Symbol.unscopables]);

템플릿 태그

값이 함수인 표현식 뒤에 템플릿 리터럴이 있으면 표현식은 함수로 바뀌고 이 함수를 '태그된 템플릿 리터럴'이라 부른다. 태그된 템플릿은 도메인에 속한 언어인 DSL(Domain Specific Language)을 정의할 때 자주 사용된다.

태그 함수 호출이 갖는 특징으로 태그 함수를 호출할 때, 첫 번째 인자는 문자열 배열이다. 하지만 이 배열에는 raw라는 프로퍼티가 있는데 그 값은 같은 수의 문자열로 이루어진 다른 배열이다. 이 배열에는 이스케이프 시퀀스를 해석하지 않은 문자열이 들어 있고, 이 특징은 역슬래시를 사용하는 DSL을 정의할 때 중요하다.

리플렉트 API

Reflect 객체는 클래스가 아니며 Math객체와 마찬가지로 관련 함수를 모아둔 집합이다. 객체와 그 프로퍼티를 '반영(reflect)'하는 API로, 새로운 기능은 거의 없다.

아래와 같은 함수들이 있다.

  • Reflect.apply(f, o, args)
  • Reflect.construct(c, args, newTarget)
  • Reflect.defineProperty(o, name, descriptor)
  • Reflect.deleteProperty(o, name)
  • Reflect.get(o, name, receiver)
  • Reflect.getOwnPropertyDescriptor(o, name)
  • Reflect.getPrototypeOf(o)
  • Reflect.has(o, name)
  • Reflect.isExtensible(o)
  • Reflect.ownKeys(o)
  • Reflect.preventExtensions(o)
  • Reflect.set(o, name, value, receiver)
  • Reflect.setPrototypeOf(o, p)

프록시 객체

프록시 클래스는 JS에서 가장 강력한 메타 프로그래밍 기능으로, JS 객체의 기본적인 동작을 바꿀 수 있다.

// 프록시 객체를 생성할 때 다음과 같이 대상 객체와 핸들러 객체를 제공한다.
let proxy = new Proxy(target, handlers);

결과인 프록시 객체가 어떤 상태를 갖거나 그 자체로 어떤 동작을 수행하진 않는다. 그러나 프록시에 어떤 동작을 할 때마다 그 동작인 핸들러 객체나 대상 객체에 전달이 된다.

예를들어, p라는 프록시 객체에서 delete p.x 동작을 했을때, 프록시 객체는 핸들러 객체에서 deleteProperty() 메서드를 검색한다. 그런 메서드가 존재하면 호출하고, 존재하지 않으면 프록시 객체는 대상 객체의 프로퍼티를 대신 삭제한다.

핸들러 객체가 비어있는 경우 프록시는 대상 객체의 래퍼나 다름 없다.

let t = { x: 1, y: 2 };
let p = new Proxy(t, {});
console.log(p.x); // 1
delete p.y;
console.log(t.y); // undefined
p.z = 3;
console.log(t.z); // 3

이렇게 사용할 이유는 없지만 취소할 수 있는 (revocable) 프록시로 사용하면 조금 더 유용할 것이다.

function accessTheDataBase() {
  return 42;
}
let { proxy, revoke } = Proxy.revocable(accessTheDataBase, {});

console.log(proxy()); // 42
console.log(revoke()); // 접근 차단
console.log(proxy()); // TypeError

대상 객체의 읽기 전용 래퍼를 생성하는 프록시

const readOnlyProxy = (o) => {
  const readonly = () => {
    throw new TypeError("Readonly");
  };
  return new Proxy(o, {
    set: readonly,
    defineProperty: readonly,
    deleteProperty: readonly,
    setPrototypeOf: readonly,
  });
};

let o = { x: 1, y: 2 };
let p = readOnlyProxy(o);
console.log(p.x); // 1
p.x = 2; // TypeError
delete p.y; // TypeError
p.z = 3; // TypeError
p.__proto__ = {}; // TypeError

이러한 특성을 가진 프록시를 사용하여 로그를 남기는 프록시 객체를 만들수도 있다. 자세한것은 책 참조

프록시 불변성

프록시 객체를 사용하면서 사소한 예외 혹은 불일치가 발생할 수 있는데, 이를 막는 안전 장치가 프록시 클래스 자체에 존재한다. 이러한 프록시 클래스는 동작을 위임한 뒤 결과를 점검해서 중요한 JS 불변성이 위반되지 않았는지 확인한다.

let target = Object.preventExtensions({});
let proxy = new Proxy(target, {
  isExtensible() {
    return true;
  },
});
console.log(Reflect.isExtensible(proxy)); // TypeError: 'isExtensible' on proxy: trap result does not reflect extensibility of proxy target (which is 'false')

let target = Object.freeze({x: 1});
let proxy = new Proxy(target, {
  get() {
    return 99;
  },
});
console.log(proxy.x); // TypeError: get()이 반환하는 값이 대상과 일치하지 않는다.

이처럼 프록시는 여러가지 불변성을 강제하는데, 대부분은 확장 불가인 대상 객체, 대상 객체의 변경 불가인 프로퍼티와 관련이 있다.

profile
It is possible for ordinary people to choose to be extraordinary.

0개의 댓글