제너레이터.. 오지말라고 그렇게 협박을 했거늘.. 하지만 마주하게 되었습니다.
[34장 이터러블]
시작되었읍니다.. 제너레이터로 가기 이전의 첫번째 지옥열차를 탑승하신 것을 축하합니다.. 이터레이터를 들어가기 전에, 먼저 우리가 모던자바스크립트 책에서 자주 볼 수 있었던 '유사 배열
ddaeunbb.tistory.com
우리는 이전에 이터러블을 공부하면서 직접 이터러블과 이터레이터를 구현해보았죠?
왜 이터러블이 필요한지에 대해서는 아래에 포스팅해 두었습니다.
[이터레이터 왜쓸까? - 제너레이터로 향하는 길]
약 한달 전.. 저는 이터러블, 이터레이터에 대해 공부를 하고.. 스터디에서 발표도 진행했었습니다. [34장 이터러블] 시작되었읍니다.. 제너레이터로 가기 이전의 첫번째 지옥열차를 탑승하신 것
ddaeunbb.tistory.com
물론 배열이나 arguments, string 등 빌트인 이터러블이 있지만 가끔 원하는 수열을 만들어서 이터러블, 이터레이터를 구현해야할 때가 있을 것입니다.
그럼 그럴 때마다 만들어야됨?? ㅎㅎ;
객체를 돌 수 있게 만드려고 했던 아래의 코드.. 우리 이거.. 매번 쳐야할까요?
const obj = {
0 : 'a',
1 : 'b',
2 : 'c',
length : 3,
[Symbol.iterator](){
let count = -1;
let cur = this[count]
const end = this[this.length - 1];
return {
next(){
count++;
cur = obj[count]
return { value : cur, done : count === 3}
}
}
}
}
이를 편하게 하기 위해서 제너레이터가 탄생했습니다.
● 제너레이터
제너레이터 함수를 만들어서 제너레이터 인스턴스를 만들면 우리가 따로 이터러블과 이터레이터를 만들지 않아도 알아서 생성되는 매~직 이 일어나게 됩니다.
옷홍홍 그럼 앞으로 제너레이터를 써야겠죠?
먼저 제너레이터란 무엇인지 알아봅시다.
제너레이터란 ES6에서 도입되었으며 코드 블록의 실행을 일시 중지 했다가 필요한 시점에 재개할 수 있는 특수한 함수입니다. 제너레이터와 일반 함수의 차이는 다음과 같습니다.
- 제너레이터 함수는 함수 호출자에게 함수 실행의 제어권을 양도할 수 있다.
- 제너레이터 함수는 함수 호출자와 함수의 상태를 주고 받을 수 있다.
- 제너레이터 함수를 호출하면 제너레이터 객체를 반환한다.
?? 이게 뭔소리 ?? 인가 싶을 수 있습니다.
제너레이터 함수는 함수 호출자에게 함수 실행의 제어권을 양도할 수 있다.
- 만약에 코드가 실행된다면 위에서 아래의 순서대로 즉, 1번 순서대로 코드가 실행될 것입니다.
- 그러다가 foo함수를 마주쳤다면 foo함수 실행코드로 올라가 코드의 제어권이 함수에게 주어지게 됩니다.
- 그뒤로 함수 내부가 실행이 되고 다시 돌아와 3번째로 실행이 되는 것이 일반적으로 저희가 알고 있는 방식입니다.
즉, 함수의 제어권이 함수에게 있습니다.
하지만 제너레이터 함수같은 경우, 함수의 제어권이 함수의 호출자에게 있습니다.
아직 제너레이터 함수가 어떤 형태로 이루어져있는지 알지 못하니까 일단 우리가 아는 대로의 반대로 작동한다고 생각하고 넘어갑니다.
제너레이터 함수는 함수 호출자와 함수의 상태를 주고받을 수 있다.
우리는 함수를 호출할 때 인자를 통해 함수 외부에서 값을 주입하고, 함수는 매개변수를 가지고 코드를 실행하게 됩니다.
함수가 실행되는 도중에 우리는 함수에 뭘 전달한적이 잇나요? 그런건 불가능했습니다. 그리고 함수가 실행도중에 호출자에게 뭘 전달해준적이 있나요? 그런건 불가능햇습니다..
하지만 제너레이터는 그것이 가능합니다..
제너레이터 함수를 호출하면 제너레이터 객체를 반환한다.
제너레이터 함수를 호출하면 함수를 실행하는 것이 아니라 제너레이터 객체를 반환합니다. 아직은 무슨말인지 잘 와닿지 않지만
생성자함수, 클래스를 new연산자나 아니면 그냥 실행하면 객체를 내뱉었던 것처럼, 제너레이터도 인스턴스를 반환합니다.
● 제너레이터 그럼 왜 쓸까요?
뭐 이럿쿵 저럿쿵 제너레이터도 lazy evaluation이 가능하다던데.. 이터레이터랑 차이가 뭘까요? 도대체 왜 제너레이터를 쓸까요?
제너레이터를 쓰는 이유는 2가지 이유로 볼 수 있습니다.
- 이터레이터와 다르게 여러 기능이 있어서
- 이터러블과이터레이터를 알아서 만들어줘서
이터레이터도 제너레이터와 똑같이 무한 수열을 만들 수 있지만, 우리는 저번에 직접적으로 이터러블과 이터레이터를 구현해야하는 지옥을 맛본 경험이 있습니다... 제너레이터로는 한번에 해결되는 일이라 아주 쉬워집니다.
아까 위에서 제너레이터의 3가지 특징을 보았는데요, 그것처럼 제너레이터는 이터레이터와 다르게 여러 기능을 갖추고 있다고 생각하면 됩니다. 그 기능들에 대해서는 아래에서 좀 더 상세히 보겠습니다.
먼저 제너레이터 함수를 어떻게 정의할 수 있는지 보겠습니다.
● 제너레이터 함수의 정의
function* genFunc(){
yield 1;
}
const genFunc = function* (){
yield 1;
}
const obj = {
*genFunc(){
yield 1;
}
}
class MyClass{
*genFunc(){
yield 1;
}
}
위는 제너레이터 함수를 정의한 예시입니다. function뒤에 바로 *(에스터리스크) 를 달아주면 됩니다. function과 함수 이름 사이라면 어디든 상관없지만 function* 함수이름 으로 만들어주는 것이 일반적입니다.
하지만 화살표 함수로는 정의할 수 없습니다. 아래는 잘못된 예시입니다.
const genFunc = * ()->{ // 화살표함수로 제너레이터 함수를 정의할 수 없다.
yield 1;
}
또한 제너레이터 함수는 new 연산자와 호출해선 안되고 그냥 호출해야합니다.
const genFunc = * ()->{ // 화살표함수로 제너레이터 함수를 정의할 수 없다.
yield 1;
}
const test1 = new genFunc(); // X 잘못된 예시
const test2 = genFunc(); // O 옳은 예시
● 제너레이터 객체
class와 생성자 함수처럼 호출하면 인스턴스를 반환하듯이 제너레이터 함수도 호출하면 제너레이터 객체를 반환합니다.
여기서 중요한 포인트!
아까 위에서 제너레이터는 이터러블과 이터레이터를 일일이 구현해주지 않아도 된다고 했습니다.
제너레이터 함수가 반환한 제너레이터 객체는 이터러블이면서 동시에 이터레이터 입니다..!!!!
진짠지 확인해볼까욤?
function* genFunc(){
yield 1;
}
const test = genFunc();
console.log(Symbol.iterator in test); // true
console.log('next' in test); // true
먼저 제너레이터 함수를 생성해주고(yield 부분은 일단 넘어갑니다.) , 제너레이터 함수를 실행해주었습니다.
그리고 test를 출력해보았습니다.
- 우리는 객체가 [Symbol.iterator]를 프로퍼티로 갖는다면 이터러블이라고 했습니다.
- 우리는 객체가 next 메서드를 가지고 있고, 리절트 객체를 반환한다면 이터레이터라고 했습니다.
따라서 제너레이터함수가 반환한 객체에 프로퍼티로 Symbol.iterator도 가지고있고, next 메서드를 가지고 있기 때문에 이터러블인 동시에 이터레이터라고 할 수 있습니다.
function* genFunc(){
yield 1;
}
const test = genFunc();
console.log(test.next()) // { value: 1, done: false }
const testChild = test[Symbol.iterator]();
따라서 위와 같이 제너레이터 객체에서 next 메서드를 호출하면 리절트 객체를 반환합니다. 또한 Symbol.iterator 메서드를 호출할 수 있습니다.
그러면 어떤식으로 이터레이터와 비슷하게 리절트 객체를 내뱉는 지 알아봅시다.
● yield
여러분은 yield 라는 영어단어의 뜻을 알고 계신가요? 이는 '산출하다, 양보하다' 라는 뜻입니다.
그래서 가끔 저런 표지판을 볼 수 있었는데요, 아마 JS에서 쓰이는 yield는 양보하다로 쓰이는 것 같습니다. '함수의 제어권을 양보한다.'라는 의미로 말이죠. 제너레이터(generator)도 generate '생산하다' 라는 어원에서 시작한게 아닐까 추측해봅니다. 제너레이터 객체를 생산하는 생산자 라는 의미로 생각했습니다 ^^;;
각설하고, 아까 위에서 제너레이터는 함수는 함수 호출자에게 함수 실행의 제어권을 양도할 수 있다고 했습니다.
function* genFunc(){
yield 1;
}
const test = genFunc();
따라서 위와 같이 genFunc를 실행하면 함수 호출자인 test에게 함수 호출의 제어권이 넘어가게 되는 것입니다.
그럼 이제 test(제너레이터 객체)가 제어권을 넘겨줄 때마다 genFunc가 실행이 되겠죠?
function* genFunc(){
yield 1;
yield 2;
yield 3;
}
const test = genFunc();
console.log(test.next()); // { value: 1, done: false }
console.log(test.next()); // { value: 2, done: false }
위와 같이 제너레이터 객체인 test가 next 메서드를 실행하니 함수가 yield까지 실행되는 것을 볼 수 있습니다.
- 첫번째 호출에서는 yield 1 값을 value 에 넣은 리절트 객체를 반환하고,
- 두번째 호출에서는 yield 2 값을 value 에 넣은 리절트 객체를 반환하고 있습니다.
순서는 아래와 같겠습니다.
test.next() -> yield -> text.next() -> yield
만약 제너레이터 함수가 아니라 그냥 함수였다면 함수 몸체에 있는 결과값을 한번에 내뱉을텐데, 몸체에있는 값들을 하나씩 빼서 반환해주는.. 이런일은 있을 수 없습니다. 따라서 이런 것이 제너레이터의 특징이라고 볼 수 있겠습니다.
따라서 결과적으로 보자면 제너레이터는 yield 표현식까지 실행되고 다시 일시 중지 됩니다.
● 제너레이터 프로토타입 메서드
제너레이터는 new 연산자가 아니라 일반 함수로 호출하면 인스턴스를 반환한다고 했습니다.
으..아니!? 그럼 Generator도 프로토타입이 있읍니까?
옙. 제너레이터도 프로토타입이 있기 때문에, 인스턴스에게 next, return, throw라는 메서드를 상속해줍니다.
따라서 제너레이터 객체는 모두 next, return, throw라는 메서드를 갖게 됩니다.
next는 이터레이터에서도 쓰이고, 아까 위의 예제에서도 계속 보던 메서드와 똑같습니다.
이제 return, throw를 보겠습니다.
- return 메서드
생각해보면 우리는 제너레이터로 리절트 객체에있는 value값을 한 개씩만 가져옵니다. 그리고 리절트 객체를 next 메서드로 모두 호출해야지만 { value : undefined, done : true }; 를 반환시킬 수 있었습니다. 하지만 return 메서드를 활용하면 한번에 끝나는 문제입니다.
function* genFunc(){
yield 1;
yield 2;
yield 3;
}
const test = genFunc();
console.log(test.next()); // { value: 1, done: false }
console.log(test.next()); // { value: 2, done: false }
console.log(test.return()); // { value: undefined, done: true }
function* genFunc(){
yield 1;
yield 2;
yield 3;
}
const test = genFunc();
console.log(test.next()); // { value: 1, done: false }
console.log(test.next()); // { value: 2, done: false }
console.log(test.return('끝이에요잉')); // { value: '끝이에요잉', done: true }
위의 예시를 보면 return메서드 호출을 통해서 리절트 객체가 { value : undefined, done : true } 인 것을 볼 수 있습니다.
그리고 return 문에는 원하는 인자를 넣어줄 수 있습니다. 그 값은 리절트 객체의 value로 들어가고 제너레이터의 순환이 중단됩니다.
- throw 메서드
throw메서드를 호출하면 인수로 전달받은 에러를 발생시키고 undefined를 value의 프로퍼티 값으로, true를 done 프로퍼티 값으로 갖는 이터레이터 리절트 객체를 반환합니다.
function* genFunc(){
try{
yield 1;
yield 2;
yield 3;
} catch (e){
console.error(e);
}
}
const test = genFunc();
console.log(test.next()); // { value: 1, done: false }
console.log(test.next()); // { value: 2, done: false }
console.log(test.throw('Error')); // { value: undefined, done: true }
throw 메서드 같은 경우에는 비동기 함수를 처리할 때, 에러가 생기면 에러가 throw되게 할 수 있지 않을까 싶네요잉.
● 제너레이터 함수와 함수 호출자의 상태 주고 받기
function* genFunc(){
const A = yield 1;
const B = yield ( A + 10 );
return A + B;
}
const generator = genFunc();
console.log(generator.next()) // { value: 1, done: false }
console.log(generator.next(10)) // { value: 20, done: false }
console.log(generator.next(50)) // { value: 60, done: true }
제너레이터 함수는 함수 호출자와 함수의 상태를 주고받을 수 있다고 했는데요, 위의 예제를 보면 next메서드에 인자를 넘겨주고 있습니다.
- 첫 번째 next 호출에는 첫번째 yield 까지, 즉, value에는 1이 들어가게 됩니다. (아직 const A는 변수가 지정되지 않았습니다.)
- 두 번째 next 호출에 10이 넘겨집니다. 즉, const A = 10; 으로 변수 지정이 됩니다. 그 다음 yield( A + 10 )을 마주하게 됩니다. 아까 const A는 10이 되었었습니다. 따라서 두번째 yield에서는 10 + 10 = 20 으로 지정되어 value에 20을 넣어 리절트 객체를 반환하게 됩니다.
- 세 번째 next 호출에는 50이 넘겨집니다. 즉, const B = 50; 으로 변수 지정이 됩니다. 다음 리턴문이 A + B 으로 내려옵니다. 리턴문에서 A는 10, B는 50이었으니 60을 value로 넣어 리절트 객체를 반환합니다.
● 이터러블과 제너레이터 차이점 한눈에 보기
이터러블로 구현한 무한 피보나치 수열
const infiniteFibonacci = (function fibonacci(){
let [pre, cur] = [0, 1];
return {
[Symbol.iterator]() { return this; },
next(){
[pre, cur] = [cur, pre + cur];
return { value : cur };
}
}
})();
for (const num of infiniteFibonacci){
if (num > 100) break;
console.log(num)
}
제너레이터로 구현한 무한 피보나치 수열
const infiniteFibonacci = (function* fibonacci(){
let [pre, cur] = [0, 1];
while (true){
[pre, cur] = [cur, pre + cur];
yield cur;
}
})();
for (const num of infiniteFibonacci){
if (num > 100) break;
console.log(num)
}
심각.. 개편안.. 그냥 제너레이터 쓸래염..
● 제너레이터 비동기 처리 - Promise - asyn await (etc. tmi)
제너레이터 함수의 특징을 활용하면 프로미스를 사용한 비동기 처리를 동기 처럼 구현할 수 있습니다. 즉, 프로미스의 후속메서드인 then, catch, finally가 없어도 비동기 처리 결과를 반환하도록 구현할 수 있습니다.
*원래 Promise가 출시 되기 이전에는 제너레이터로 비동기 방식을 구현했었다고 하네요.
따라서 제너레이터로 비동기 구현 - 프로미스 출현 - async와 await 의 순서대로 등장했다고 생각하면 됩니다.
(굳이 제너레이터로 비동기를 구현해야할까요 ^^?)
그리고 제너레이터 함수를 선언 할 때 애스터리스크(*) 를 붙이지 않고 사용했었다고 합니다.
하지만 지금은 애스터리스크를 꼭 붙여야하고, async 같은 경우도 꼭 함수 앞에 async를 붙여야된다고 하죠.
그 이유로는 함수에게 반환할 return 값이 애스터리스크 같은경우는 제너레이터 객체이고 async같은 경우는 프로미스라고 명시하는 것과 같다고 합니다.
즉 앞에 붙은 것에 따라 함수의 return이 다르다는 것이죠,
이전에는 함수의 내부 코드를 파싱해서 await 을 사용한게 있는지 확인을 하고, await 이 있으면 프로미스를 반환, 없으면 기존 return value 를 그대로 반환하게 만들어야했습니다. 제너레이터같은 경우에는 yield 키워드를 찾아야 했겠죠?
코드를 해석하고 돌려줄 인터프리터 레벨에서도 이건 상당히 번거로운 일인데, 코드 가독성 측면에서도 마찬가지가 됩니다. 프로그래머 입장에서도 특정 함수의 return type이 프로미스인지 아닌지를 알려면 내부 코드를 모두 읽어봐야합니다. 하지만, function 앞에 async prefix를 넣으면, 반환값(return value)의 타입이 어떻게 되는지 매우 명확해집니다.
● 제너레이터는 어떻게 실행중인 상태와 위치를 기억하는 것인가?
먼저 제너레이터는 내부슬롯값인 [[GeneratorState]] 를 가지고 있습니다.
function* genFunc(){
yield 1;
yield 2;
yield 3;
}
const test = genFunc();
console.log(test.next())
console.dir(test)
저는 위와 같은 코드를 브라우저에서 실행해보았는데요,
보면 next 메서드를 호출한 결과와, test에 내부슬롯값인 [[GeneratorState]] 가 'suspended' (중단된) 인 것을 볼 수 있습니다.
만약 모든 yield를 반환하고 나면?
function* genFunc(){
yield 1;
yield 2;
yield 3;
}
const test = genFunc();
console.log(test.next())
console.log(test.next())
console.log(test.next())
console.log(test.next())
console.dir(test)
내부 슬롯의 값이 'closed'인 것을 확인할 수 있습니다.
일단 실행중인 상태는 내부슬롯인 [[GeneratorState]]로 저장이 되는 것을 확인했습니다.
그런데 어떻게 함수의 실행이 끝났음에도 불구하고 함수 내부의 value를 기억하는 것일까요?
이건 저희가 배웠던 클로저의 원리와 어느정도 비슷합니다.
클로저에서 외부함수보다 내부함수가 생명주기가 길기때문에 내부함수가 외부함수안에 있는 변수들을 기억할 수 있었습니다. 즉, 내부함수가 외부함수의 안에 있는 변수들을 참조하고 있기때문에 가비지 컬랙터 대상이 되지 않았습니다.
즉, 제너레이터함수도 비슷하게 어디선가 이 함수를 참조하고 있다는 것이죠. (가비지컬랙터 대상이 안되려고)
그림으로 보면 이해하기가 좀 쉽습니다.
예시는 아래 코드를 기반으로 그림으로 구현하였습니다.
function* genFunc(){
yield 1;
yield 2;
yield 3;
}
const test = genFunc();
const result1 = test.next(); // { value: 1, done: false }
const result2 = test.next(); // { value: 2, done: false }
- 먼저 전역 코드가 평가되고 전역 실행 컨텍스트가 생성됩니다. 근데 함수 호출을 마주쳐?
- 함수 실행컨택스트가 콜스택에 쌓이게 되고, 함수 환경 레코드가 만들어진다.
- 함수 실행이 끝나면 콜스택에서 제거되지만, genFunc 실행컨텍스트가 test가 참조하게 되므로 가비지 컬랙터 대상이 되지 않는다.
- test.next() 메서드가 호출되고 genFunc가 재개됩니다. 메모리상 존재하는 genFunc의 실행 컨택스트를 다시 콜스택으로 가져옵니다. 그렇게 다시 함수를 실행할 수 있습니다.
- yield를 만나고 정지하면 genFunc의 실행을 멈추고 리절트 객체를 내보내게 됩니다. 그다음 다시 genFunc의 실행 컨택스트가 콜스택에서 나오게 됩니다.
- 리절트 객체의 done: true가 되면 함수가 완전히 종료되고 함수의 실행컨택스트가 콜스택에서 없어집니다. 그리고 메모리에서 완전히 제거됩니다.
참고자료 (우아한 테코톡, 태훈 프론트개발자 유튜브)
우아한 테코톡 : https://youtu.be/3uuBHt_SNTA
이터레이터와 제너레이터 간단히 알아보기 : https://youtu.be/WusTq2xa-C8
배열과 이터레이터의 차이점 알아보기 : https://youtu.be/FxBMhEy0aSk
async, await, 정말 좋은데 이게 왜 좋을까요? : https://youtu.be/27hXXsT_S4U
제너레이터와 실행컨텍스트
제너레이터를 공부하면서 생긴 의문이다. 과연 제너레이터 함수는 실행 컨텍스트에서 어떻게 돌아가는 걸까? https://tc39.es/ecma262/#sec-generator-function-definitions 자바스크립트에서의 일반 함수가 실
velog.io