Typescript로 다시 쓰는 GoF - Iterator

아홉번째태양·2023년 7월 30일
0

Iterator란?

반복문을 사용해서 개발자가 직접 인덱스를 통해 각각의 원소를 꺼내오는 대신, 자동으로 모든 원소를 차례대로 꺼내서 작업을 연속적으로 할 수 있게 해주는 행동 패턴(Behavioral Pattern)의 일종.

왜 쓸까?

반복로직을 구현과 분리가 가능하다.

while(bookShelf.hasNext()) {
  const book = bookShelf.next();
  console.log(book);
}

예를들어, 위와 같은 반복문이 있을 때 BookShelf의 내부로직이 어떻게 바뀌더라도 while문은 영향을 받지 않는다.



Javascript의 Iterator?

자바스크립트에도 Iterator라는 패턴은 정의되어 있다.

MDN에 따르면, Iterator란, iterator protocol을 따르며 valuedone을 갖는 객체를 리턴하는 next()라는 메소드를 갖는 객체다.

그리고 for of문처럼 Iterator 객체가 대상이 되는 문법을 사용하기 위해서는 iterable 해야하는데, 어떤 객체가 iterable하기 위해서는 [Symbol.iterator]에 위에서 정의한 Iterator의 규칙을 따르는 값을 추가하면 된다.


ES2015

타입스크립트(ES2015)에서는 Iterator의 형식을 아래처럼 정의하고 있는 것을 확인할 수 있다.

// lib es2015.iterator.d.ts

interface IteratorYieldResult<TYield> {
    done?: false;
    value: TYield;
}

interface IteratorReturnResult<TReturn> {
    done: true;
    value: TReturn;
}

type IteratorResult<T, TReturn = any> = IteratorYieldResult<T> | IteratorReturnResult<TReturn>;

interface Iterator<T, TReturn = any, TNext = undefined> {
    // NOTE: 'next' is defined using a tuple to ensure we report the correct assignability errors in all places.
    next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
    return?(value?: TReturn): IteratorResult<T, TReturn>;
    throw?(e?: any): IteratorResult<T, TReturn>;
}

interface Iterable<T> {
    [Symbol.iterator](): Iterator<T>;
}


Iterator 사용법

기본예제

MDN에서는 다음과 같은 예제를 소개하고 있다.

const myIterator = {
  *[Symbol.iterator]() {
    yield 1;
    yield 2;
    yield 3;
  }
}

for (const value of myIterator) {
  console.log(value);
}

배열사용 예시

만약 JS의 배열Array처럼 동작하는 객체를 만든다면 아래처럼 사용이 가능할 것이다.

const myIterator2 = {
  arr: [1, 2, 3],
  *[Symbol.iterator]() {
    let index = 0;
    while (index < this.arr.length) {
      yield this.arr[index++];
    }
  }
}

for (const value of myIterator2) {
  console.log(value);
}

Custom Iterator

BookShelf라는 클래스에서 Book을 하나씩 순차적으로 꺼내는 Iterator를 구현해보자.

Iterator 패턴을 구현할 때는 다음의 4가지 객체가 필요하다.

  1. Iterator(반복자)
    구성요소를 순서대로 검색하는 인터페이스를 정의. 자바스크립트에서는 value와 done을 갖는 객체를 반환하는 next 메소드를 내포해야만 한다.

  2. ConcreteIterator(구체적인 반복자)
    Iterator에서 결정한 인터페이스를 실제로 구현하는 역할. 반복하는 대상이 되는 객체에 대한 정보와, 현재 검색중인 순번을 기억할 수 있는 index가 필요하다.

  3. Aggregate(집합체)
    Iterator를 내포하는 인터페이스로, Iterator의 타겟이 되는 객체에 대한 정의와 ConcreteIterator에 대한 정의를 함께 갖는다. Iterable 인터페이스가 이에 해당한다.

  4. ConcreteAggregate(구체적인 집합체)
    Aggregate에서 정의한 인터페이스를 실제로 구현한 객체.


0. Book

일단 모든 것에 앞서서 표시하려는 데이터를 정의한 클래스를 먼저 작성해둔다.

class Book {
  constructor(
    private name: string
  ) {
    this.name = name;
  }

  getName() {
    return this.name;
  }
}

1. Iterator

일단, for of문처럼 Iterable한 객체를 타겟으로하는 자바스크립트 기능들을 쓰려면 위에서 봤던 Iterator와 관련하여 타입스크립트에서 정의한 타입들을 사용하지 않을 수가 없다.

하지만, 타입스크립트에서 인터페이스나 추상클래스는 글로벌로 공유되기 때문에 기본스펙만 사용할거라면 굳이 따로 코드를 작성할 필요는 없으며, 여기에 간단하게 한두가지 필요로 하는 메소드를 추가하려 할때 정도만 아래처럼 코드를 추가한다.

interface Iterator<T> {
  hasNext(): boolean;
  
  // next()는 이미 es2015.iterator.d.ts에 정의되어 있다.
  // next(...args: [] | [TNext]): IteratorResult<T, TReturn>;
}

하지만, 이렇게 정의할 경우 워크스페이스 내 모든 Iterator들도 hasNext를 필요로 할 수 있기 때문에 이 경우 기존의 Iterator를 상속받는 새로운 인터페이스를 작성하는 것이 바람직하다.

interface CustomIterator<T> extends Iterator<T> {
  hasNext(): boolean;
}

2. ConcreteIterator

Iterator에서 정의한 스펙을 구현하는 구현체를 작성하다.

class BookShelfIterator implements CustomIterator<Book> {
  private index = 0;

  constructor(
    private bookShelf: BookShelf
  ) {
    this.bookShelf = bookShelf;
  }

  hasNext() {
    return this.index < this.bookShelf.getLength();
  }

  next() {
    const book = this.bookShelf.getBookAt(this.index);
    this.index++;
    return {
      value: book,
      done: !this.hasNext(),
    };
  }
}

검색의 대상이 되는 객체를 constructor를 통해서 받아 저장하고 검색의 기준이 될 수 있는 index를 프로퍼티에 추가한다.

그리고 Iterator에 원래 있던 next 메소드와 새로운 CustomIterator에서 추가한 hasNext 메소드를 구현한다.


3. Aggregate

굳이 기존의 Iterable을 수정할 필요는 없어보이기에 여기서는 별다른 코드를 추가하지 않고 글로벌 Iterable을 그대로 쓰기로 한다.

4. ConcreteAggregate

이제 Iterable한 객체를 정의한다.

class BookShelf implements Iterable<Book> {
  private books: Book[] = [];
  private last = 0;

  getBookAt(index: number) {
    return this.books[index];
  }

  appendBook(book: Book) {
    this.books.push(book);
    this.last++;
  }

  getLength() {
    return this.last;
  }

  [Symbol.iterator]() {
    return new BookShelfIterator(this);
  }
}

참고로 이때 iterator는 두 가지 방법으로 정의할 수 있다.

return을 갖는 일반적인 함수 형태와, 앞의 예시에서 봤던 yield를 갖는 generator 형태.

만일 generator 형태로 iterator를 작성한다면 다음과 같이 수정한다.

class BookShelf implements Iterable<Book> {

  ... 생략

  *[Symbol.iterator]() {
    const iterator =  new BookShelfIterator(this);
    while (iterator.hasNext()) {
      yield iterator.next().value;
    }
  }
}

실행결과

이제 작성한 코드를 실행해보자.

const bookShelf = new BookShelf();
bookShelf.appendBook(new Book('Around the World in 80 Days'));
bookShelf.appendBook(new Book('Bible'));
bookShelf.appendBook(new Book('Cinderella'));
bookShelf.appendBook(new Book('Daddy-Long-Legs'));


for (const book of bookShelf) {
  console.log(book.getName());
}
$ ts-node src/1-Iterator.ts
Around the World in 80 Days
Bible
Cinderella
Daddy-Long-Legs

Iterator 수동제어

만약, 자바스크립트의 Iterator에 덧씌우는 것이 아니라 직접 Iterator를 제어하고 싶다면, Iterable을 먼저 새로 정의해야한다.

interface CustomIterable<T> {
  iterator(): CustomIterator<T>;
}

class BookShelf implements CustomIterable<Book> {
  ... 생략

  iterator(): CustomIterator<Book> {
    return new BookShelfIterator(this);
  }
}

이렇게 작성할 경우 외부에서 iterator를 직접 받고, 내부의 next()를 직접 호출해서 반복 동작을 직접 제어할 수 있다.

const iterator = bookShelf.iterator();
while (iterator.hasNext()) {
  const book = iterator.next();
  console.log(book.value.getName());
}



참고자료

Java언어로 배우는 디자인 패턴 입문 - 쉽게 배우는 Gof의 23가지 디자인패턴 (영진닷컴)
Javascript MDN
Typescript Documentation

0개의 댓글