[Leetcode] 2804 Array Prototype ForEach
Leetcode 2804번 Array Prototype ForEach 풀이. callback 내부 this 를 context 로 바인딩하는 다섯 가지 방법 (call, apply, bind, 객체 메서드, 그리고 화살표 함수 함정) 을 비교한다.
문제 요약
표준 Array.prototype.forEach 의 동작을 직접 구현하는 문제다. 두 인자를 받는다.
callback(value, index, array)가 배열의 각 요소마다 호출된다.- 두 번째 인자
context가 주어지면,callback내부의this가context를 가리켜야 한다.
표준 메서드를 쓰지 말고 직접 구현하라는 제약이 붙는다.
Array.prototype.forEach = function (callback, context) {
for (let i = 0; i < this.length; i++) {
// 여기서 callback 을 호출하면서 this 를 context 로 바꾸어야 한다
}
};
표면적으로는 단순한 반복문이다. 다만 “함수의 this 를 외부에서 지정한다” 는 요건이 function 의 this 동적 바인딩, arrow function 의 lexical this, 그리고 call / apply / bind 까지 한 자리에 모이는 좋은 출발점이라 정리할 가치가 있다.
검증용 예시:
const arr = [1, 2, 3];
function callback(val) {
console.log(this.name, val);
}
arr.forEach(callback, { name: 'context' });context 1
context 2
context 3같은 결과를 만들어내는 다섯 가지 길을 차례로 본다.
1. call 사용 (정석)
구현
Array.prototype.forEach = function (callback, context) {
for (let i = 0; i < this.length; i++) {
callback.call(context, this[i], i, this);
}
};
동작 원리
Function.prototype.call(thisArg, ...args) 는 함수를 즉시 실행하면서 this 를 thisArg 로 지정 한다. 인자는 일반 호출처럼 콤마로 나열한다.
function hello() {
console.log(this.name);
}
hello.call({ name: 'Tom' });TomforEach 구현에 그대로 옮기면 매 반복마다 callback 을 호출할 때 this 가 context 로 묶인다. 표준 명세 Array.prototype.forEach 도 내부적으로 Call(callbackfn, T, ...) 형태, 즉 같은 방식으로 정의돼 있다.
장단점
- 표준 동작과 가장 닮았다.
- 반복문 안이 한 줄로 끝나 읽기 쉽다.
- 매 반복마다
call한 번씩의 호출 비용이 있지만 실측으로 드러나는 수준은 아니다.
면접/코딩 테스트에서 기대되는 답이 보통 이 형태다.
2. apply 사용
구현
Array.prototype.forEach = function (callback, context) {
for (let i = 0; i < this.length; i++) {
callback.apply(context, [this[i], i, this]);
}
};
동작 원리
Function.prototype.apply(thisArg, argArray) 는 call 과 동일하게 함수를 즉시 실행하면서 this 를 지정한다. 차이는 인자를 배열로 전달 한다는 것뿐이다.
function hello(a, b) {
console.log(this.name, a, b);
}
hello.apply({ name: 'Tom' }, [1, 2]);Tom 1 2장단점
- 인자가 동적으로 정해지는 (이미 배열 형태로 들고 있는) 경우 편리하다.
- 이 문제처럼 인자 개수가 고정 (3 개) 이라면 매 반복마다 임시 배열을 새로 만드는 비용 만 더해진다.
TIP
ES6 의 ...spread 이후로 apply 의 쓰임이 많이 줄었다. fn.apply(ctx, args) 는 fn.call(ctx, ...args) 로 대체할 수 있기 때문이다.
3. bind 사용
구현
Array.prototype.forEach = function (callback, context) {
const bound = callback.bind(context);
for (let i = 0; i < this.length; i++) {
bound(this[i], i, this);
}
};
동작 원리
Function.prototype.bind(thisArg) 는 함수를 실행하지 않는다. 대신 this 가 미리 묶인 새 함수 객체 를 반환한다.
function hello() {
console.log(this.name);
}
const boundHello = hello.bind({ name: 'Tom' });
boundHello();Tom장단점
- 반복문이 그냥
bound(...)가 되어 가장 깔끔해 보인다. - 매 반복마다
call을 부르지 않아도 된다. - 대신 새 함수 객체를 한 번 생성 한다. 반복 호출이 많다면 절약이 되지만, 한 번 쓰고 버리는 경우에는 오히려 손해다.
- 만약
bound를 반복문 안에서 만들면 (callback.bind(context)를 매 반복마다 호출) 가장 비싼 선택이 된다.
4. 객체 메서드 방식 (call 의 내부 구현)
구현
Array.prototype.forEach = function (callback, context) {
const key = Symbol();
context[key] = callback;
for (let i = 0; i < this.length; i++) {
context[key](this[i], i, this);
}
delete context[key];
};
동작 원리
JavaScript 에서 함수 호출 구문 obj.fn() 의 this 는 호출 직전에 . 좌측이 무엇이었는지 로 결정된다 (function 의 this 동적 바인딩). 즉,
context.temp = callback;
context.temp(); // 이 호출 시점에 this === context
이렇게 임시로 객체의 메서드로 만든 뒤 호출하면 call 없이도 this 를 바꿀 수 있다.
이 방식은 사실 Function.prototype.call 을 직접 구현해야 하는 면접 문제의 표준 해법으로 자주 등장한다. Symbol() 로 고유 키를 만들어 기존 속성과의 충돌을 막고, 끝난 뒤 delete 로 흔적을 지운다.
장단점
this바인딩의 메커니즘을 가장 노골적으로 보여준다.- 인자로 받은
context객체를 직접 변형 한다. 호출 도중throw가 나면delete까지 도달하지 못해 키가 남는다 (try / finally로 보완 필요). - frozen 객체 (
Object.freeze), Proxy, getter/setter 가 걸린 객체에는 통하지 않는다. - 실무에서는 거의 쓰지 않는다. “원리를 설명할 때” 용도.
CAUTION
입력으로 받은 객체를 임의로 수정하는 것은 사이드 이펙트의 원천이다. 라이브러리/표준 메서드 구현에서는 피해야 할 패턴이다.
5. 화살표 함수 (Arrow Function) 주의사항
위 네 가지 방식 모두 한 가지 가정에 기대고 있다. callback 이 일반 함수일 것 이다.
arrow function 은 자체 this 를 가지지 않는다. 자신이 선언된 위치의 Lexical Environment 에 있는 this 를 그대로 본다. call / apply / bind 모두 화살표 함수의 this 를 바꿀 수 없다.
const fn = () => {
console.log(this);
};
fn.call({ name: 'Tom' });(전역 객체 또는 상위 스코프의 this, strict 모듈에서는 undefined)즉, 문제의 검증 케이스가 callback 으로 화살표 함수를 넘긴다면 어떤 구현을 써도 context 가 반영되지 않는다.
같은 lexical vs dynamic this 차이는 arrow function 의 setInterval 예시에서도 동일하게 드러난다.
IMPORTANT
LeetCode 의 채점 케이스도 이 차이를 노린다. (val, i, arr) => arr[i] = this 같은 화살표 콜백을 넘겼을 때 this 가 정의 시점의 모듈 스코프 (테스트 러너가 화살표 함수를 평가한 환경) 로 묶이는 것을 확인하는 식이다. 구현이 잘못된 게 아니라, 화살표 함수의 정의 그 자체가 그렇게 동작한다.
call / apply / bind 는 모두 일반 함수에만 의미가 있다. 이 사실을 모르고 forEach 의 callback 으로 화살표 함수를 쓰면 디버그하기 까다로운 버그가 된다.
각 방식 비교
| 방식 | 즉시 실행 | this 지정 | 함수 객체 생성 | 입력 객체 변형 |
|---|---|---|---|---|
call | O | O | X | X |
apply | O | O | X | X |
bind | X | O | O (한 번) | X |
| 객체 메서드 | O | O | X | O (Symbol 키) |
call 과 apply 는 호출 시 비용 외에 부산물이 없다. bind 는 함수 객체 하나를 새로 만들지만 반복 호출 비용은 가장 적다. 객체 메서드 방식은 호출 비용 자체는 가장 적지만 입력 객체를 변형한다는 점에서 사이드 이펙트가 가장 크다.
결론, 무엇을 골라야 하는가
Array.prototype.forEach = function (callback, context) {
for (let i = 0; i < this.length; i++) {
callback.call(context, this[i], i, this);
}
};
이 풀이가 가장 무난한 선택이다.
- 명세 Array.prototype.forEach 의 내부 알고리즘이 사실상
Call(callbackfn, thisArg, ...)라, 그것과 가장 가깝다. - 코드가 짧고 읽기 쉽다.
- 입력 객체를 변형하지 않는다.
- 새로운 함수 객체를 만들지 않는다.
bind 는 같은 callback 을 여러 곳에서 재사용할 때 의미가 있다. 객체 메서드 방식은 call 의 동작 원리를 묻는 면접에서 별도로 등장한다. 둘 다 이 문제의 정답으로는 과한 선택이다.
화살표 함수에 관한 함정은 forEach 만의 문제가 아니다. setTimeout 의 콜백, 이벤트 리스너, Promise.then 의 핸들러 등 외부에서 this 를 지정하려는 모든 API 와 화살표 함수는 어울리지 않는다. 이 부분의 메커니즘은 비동기와 타이밍, 콜백부터 async/await까지의 발전사 의 콜백 절에서도 같은 언어로 설명된다.
이 글의 용어 (3개)
- [Javascript] Arrow Functionjavascript
- 정의 화살표 함수 (arrow function) 는 ES2015 (ES6) 에서 도입된 함수 표현식. 모양. 더 짧은 문법뿐 아니라 가 lexical 로 바인딩 되는 본질적 차이…
- [Javascript] functionjavascript
- 정의 은 JavaScript 에서 함수를 만드는 가장 기본적인 키워드. 호이스팅, 동적 바인딩, 객체, 호출 등 과 구분되는 고유한 특성을 가진다. 함수가 값 으로 다뤄지는 의 …
- [Javascript] Lexical Environmentjavascript
- 정의 Lexical Environment (어휘적 환경) 는 ECMAScript 명세 (§9.1) 가 정의한 내부 객체. 함수가 호출될 때마다 새로 만들어지며, 이 호출의 변수들…
💬 댓글