이터레이터와 제너레이터

Grace·2022년 6월 13일
0

JavaScript

목록 보기
5/11
post-thumbnail

이터레이터는 '지금 어디 있는지' 파악할 수 있도록 돕는다는 면에서 일종의 책갈피와 비슷한 개념입니다. 배열은 이터러블 객체의 좋은 예입니다. 배열에는 여러 요소가 들어 있으므로, 이터레이터를 사용할 수 있습니다. book이란 배열이 있고, 이 배열의 각 요소는 책의 한 페이지를 나타내는 문자열이라고 합시다.

const book = [
  "Twinke, twinkle, little bat!",
  "How I wonder what you're at!",
  "Up above the world you fly,",
  "Like a tea tray in the sky.",
  "Twinkle, twinkle, little bat!",
  "How I wonder what you're at!",
]

이제 book 배열에 values 메서드를 써서 이터레이터를 만들 수 있습니다.

const it = book.values()

이터레이터(보통 it이라고 줄여 씁니다)는 책갈피지만, 이 책에만 사용할 수 있습니다. '읽기 시작'하려면 이터레이터의 next 메서드를 호출합니다. 이 메서드가 반환하는 객체에는 value 프로퍼티(지금 보이는 페이지)와 done 프로퍼티(마지막 페이지를 읽으면 true로 바뀌는)가 있습니다.

it.next() // {value: "Twinkle, twinkle, little bat!", done: false}
it.next() // {value: "How I wonder what you're at!", done: false}
it.next() // {value: "Up above the world you fly,", done: false}
it.next() // {value: "Like a tea tray in the sky.", done: false}
it.next() // {value: "Twinkle, twinkle, little bat!", done: false}
it.next() // {value: "How I wonder what you're at!", done: false}
it.next() // {value: undefined, done: true}
it.next() // {value: undefined, done: true}
it.next() // {value: undefined, done: true}

여기는 중요한 점이 몇 개 있습니다. 첫째, next에서 책의 마지막 페이지를 반환헀다 해서 끝난 것은 아니란 겁니다. 이터레이터는 책을 읽는 것보다 훨씬 다양한 상황에서 쓰일 수 있고 끝나는 시점을 간단히 결정할 수는 없습니다. 더 진행할 것이 없으면 value는 undefined가 되지만, next는 계속 호출할 수 있습니다. 물론 그렇다고 결과가 바뀌는건 아닙니다. 일단 이터레이터가 끝까지 진행하면 뒤로 돌아가서 다른 데이터를 제공할 수는 없습니다.

이 배열의 요소를 나열하는 것이 목적이라면 for 루프나 for...of 루프를 쓸 수 있습니다. for 루프의 원리는 간단합니다. 배열 요소의 인덱스는 숫자형이고 순차적으로 인덱스 변수를 써서 해당하는 배열 요소에 접근할 수 있습니다. 하지만 for...of 루프는 어떻게 된 걸까요? 인덱스 없이 어떻게 루프를 실행할 수 있었을까요? 답은 이터레이터입니다. 이터레이터만 제공할 수 있다면 무엇이든 for...of 루프와 함께 쓸 수 있습니다.

const it = book.values()
let current = it.next()
while(!current.done){
  console.log(current.value)
  current = it.next()
}

이터레이터는 모두 독립적입니다. 즉, 새 이터레이터를 만들 때마다 처음에서 시작합니다. 그리고 각각 다른 요소를 가리키는 이터레이터 여러 개를 동시에 사용할 수도 있습니다.

const it1 = book.values()
const it2 = book.values()
// 어느 이터레이터도 아직 시작하지 않았습니다.

// it1으로 두 페이지를 읽습니다.
it1.next() // {value: "Twinkle, twinkle, little bat!", done: false}
it1.next() // {value: "How I wonder what you're at!", done: false}

//it2로 한페이지를 읽습니다.
it2.next() // {value: "Twinkle, twinkle, little bat!", done: false}

//it1으로 한 페이지를 더 읽습니다.
it1.next() // {value: "Up above the world you fly,", done: false}

이터레이션 프로토콜

이터레이터는 그 자체로 크게 쓸모가 있다기보다는, 더 쓸모 있는 동작이 가능해지도록 한다는 데 의미가 있습니다. 이터레이터 프로토콜은 모든 객체를 이터러블 객체로 바꿀 수 있습니다. 메시지에 타임스탬프를 붙이는 로그 클래스가 필요하다고 생각해 봅시다.

class Log {
  constructor() {
    this.messages = []
  }
  add(message) {
    this.messages.push({message,timestamp: Date.now()})
  }
}

로그를 기록한 항목을 순회하고 싶다면 어떻게 해야할까요? 이터레이션 프로토콜을 사용하면 가능합니다. 이터레이션 프로토콜은 클래스에 심볼 메서드 Symbol.iterator가 있고 이 메서드가 이터레이터처럼 동작하는 객체, 즉 value와 done 프로퍼티가 있는 객체를 반환하는 next 메서드를 가진 객체를 반환한다면 그 클래스의 인스턴스는 이터러블 객체라는 뜻입니다. Log 클래스에 Symbol.iterator 메서드를 추가합니다.

class Log {
  constructor() {
    this.messages = []
  }
  add(message) {
    this.messages.push({message,timestamp: Date.now()})
  }
  [Symbol.iterator](){
    return this.messages.values()
  }
}

이제 Log 인스턴스를 배열처럼 순회할 수 있습니다.

const log = new Log()
log.add("first day at sea")
log.add("spotted whale")
log.add("spotted another vessel")
// ...

// 로그를 배열처럼 순회합니다!
for(let entry of log) {
  console.log(`${entry.message} @ ${entry.timestamp}`)
}

다음과 같이 직접 이터레이터를 만들 수도 있습니다.

class Log {
  //...
  
  [Symbol.iterator](){
    let i = 0
    const messages = this.messages
    return {
      next() {
        if(i>=messages.length) return {value: undefined, done: true}
        return {value: messages[i++], done: false}
      }
    }
  }
}

이터레이터는 무한한 데이터에도 사용할 수 있습니다.

피보나치 수열은 무한히 계속되고, 프로그램에서는 몇 번째 숫자까지 계산해야 할지 알 수 없으므로 이터레이터를 사용하기에 알맞습니다. 차이점은 이터레이터가 done에서 절대 true를 반환하지 않는다는 것 뿐입니다.

class FibonacciSequence {
  [Symbol.iterator](){
    let a = 0, b = 1
    return {
      next() {
        let rval = {value: b, done: false}
        b += a
        a = rval.value
        return rval
      }
    }
  }
}

for...of 루프로 FibonacciSequence 인스턴스를 계산하면 무한 루프에 빠집니다. 피보나치 수열은 무한하니까요. 무한 루프에 빠지지 않도록 10회 계산한 뒤 break 문으로 빠져나옵니다.

const fib = new FibonacciSequence()
le i = 0
for(let n of fib) {
  console.log(n)
  if(++i>9) break
}

제너레이터

이터레이터를 사용해 자신의 실행을 제어하는 함수입니다. 일반적인 함수는 매개변수를 받고 값을 반환하지만, 호출자는 매개변수 외에는 함수의 실행을 제어할 방법이 전혀 없습니다. 함수를 호출하면 그 함수가 종료될 때까지 제어권을 완전히 넘기는 겁니다. 제너레이터에서는 그렇지 않습니다.

제너레이터는 두 가지 새로운 개념을 도입했습니다. 하나는 함수의 실행을 개별적 단계로 나눔으로써 함수의 실행을 제어한다는 것입니다. 다른 하나는 실행 중인 함수와 통신한다는 것입니다.

제너레이터는 두 가지 예외를 제외하면 일반적인 함수와 같습니다.

  • 제너레이터는 언제든 호출자에게 제어권을 넘길 수 있습니다.
  • 제너레이터는 호출한 즉시 실행되지는 않습니다. 대신 이터레이터를 반환하고, 이터레이터의 next 메서드를 호출함에 따라 실행됩니다.

제너레이터를 만들 때는 function 키워드 뒤에 애스터리스크(*)를 붙입니다. 이것을 제외하면 문법은 일반적인 함수와 같습니다. 제너레이터에서는 return 외에 yield 키워드를 쓸 수 있습니다.

function* rainbow() {
  yield 'red'
  yield 'orange'
  yield 'yellow'
  yield 'green'
  yield 'blue'
  yield 'indigo'
  yield 'violet'
}

제너레이터를 호출하면 이터레이터를 얻습니다. 함수를 호출한 다음 이터레이터를 써서 단계별로 진행합니다.

const it = rainbow()
it.next() // {value: "red", done: false}
it.next() // {value: "orange", done: false}
it.next() // {value: "yellow", done: false}
it.next() // {value: "green", done: false}
it.next() // {value: "blue", done: false}
it.next() // {value: "indigo", done: false}
it.next() // {value: "violet", done: false}
it.next() // {value: undefined, done: true}

rainbow 제너레이터는 이터레이터를 반환하므로 for...of 루프에서 쓸 수 있습니다.

for(let color of rainbow()) {
  console.log(color)
}

이 코드를 실행하면 무지개의 색깔이 모두 콘솔에 기록됩니다.

yield 표현식과 양방향 통신

통신은 yield 표현식을 통해 이루어집니다. 표현식은 값으로 평가되고 yield는 표현식이므로 반드시 어떤 값으로 평가됩니다. yield 표현식의 값은 호출자가 제너레이터의 이터레이터에서 next를 호출할 때 제공하는 매개변수입니다.

function* interrogate(){
  const name = yield "What is your name?"
  const color = yield "What is your favorite color?"
  return `${name}'s favorite color is ${color}.`
}

이 제너레이터를 호출하면 이터레이터를 얻습니다. 그리고 제너레이터의 어떤 부분도 아직 실행하지 않은 상태입니다. next를 호출하면 제너레이터는 첫 번째 행을 실행하려 합니다. 하지만 그 행에는 yield 표현식이 들어 있으므로 제너레이터는 반드시 제어권을 호출자에게 넘겨야 합니다. 제너레이터의 첫 번째 행이 완료되려면 호출자가 next를 다시 호출해야 합니다. 그러면 name은 next에서 전달하는 값을 받습니다.

const it =- interogate()
it.next() // {value: "What is your name?", done: false}
it.next('Ethan') // {value: "What is yout favorite color?", done: false}
it.next('orange') // {value: "Ethan's favorite color is orange, done: true}
  1. 제너레이터는 이터레이터를 반환하고 일시 정지한 상태로 시작합니다.
  2. undefined를 제너레이터에 넘깁니다(이 값은 사용되지 않습니다). 제너레이터는 "What is your name?"을 넘기고 일시 정지합니다.
  3. "Ethan"을 제너레이터에 넘깁니다. 제너레이터는 "What is your favorite color?"를 넘기고 일시 정지합니다.
  4. "orange"를 제너레이터에 넘깁니다. 제너레이터에는 "Ethan's favorite color is orange"를 반환하고 멈춥니다.

제너레이터를 활용하면 호출자가 함수의 실행을 제어할 수 있어서 아주 유용하게 쓸 수 있습니다. 호출자가 제너레이터에 정보를 전달하므로, 제너레이터는 그 정보에 따라 자신의 동작 방식 자체를 바꿀 수 있습니다.

제너레이터와 return

yield문은 제너레이터의 마지막 문이라도 제너레이터를 끝내지 않습니다. 제너레이터에서 return 문을 사용하면 그 위치와 관계없이 done은 true가 되고, value 프로퍼티는 return이 반환하는 값이 됩니다.

function*abc(){
  yield 'a'
  yield 'b'
  yield 'b'
}

const it = abc()
it.next() // {value: 'a', done: false}
it.next() // {value: 'b', done: false}
it.next() // {value: 'c', done: true}

이런 동작 방식이 정확하기는 하지만, 제너레이터를 사용할 때는 보통 done이 true이면 value 프로퍼티에 주의를 기울이지 않는다는 점을 주의하세요.

// 'a'와 'b'는 출력되지만 'c'는 출력되지 않습니다.
for(let l of abc()){
	console.log(l)
}
profile
기술블로그 이전:: https://meercat.tistory.com/

0개의 댓글