反復可能とは?

簡単に言えば、for of 文で利用可能になるオブジェクトのこと。

1. 反復可能プロトコル

反復可能プロトコルによれば、次の動作を満たすオブジェクトを反復可能とみなすようだ。

  • [Symbol.iterator]() メソッドを実装しているオブジェクト

実際には、yield キーワードで呼び出しごとに値を返却するために * を付けたジェネレーターにすることが多い。このような関数は yield キーワードが出現するまで関数が実行され、その値を返却したのち、次の呼び出しまで休眠状態に入る。再度呼び出されるとそのあとから実行を再開する。while ループの中で yield を使って無限に値を返し続けてもいいし、関数を終了して反復処理の終了を通知してもよい。

const generator = {
    *[Symbol.iterator]() {
        yield 1
        yield 2
        yield 3
    }
}
 
for (const value of generator) {
    // 1, 2, 3を列挙する
}

2. イテレータープロトコル

イテレータープロトコルによれば、次の動作を満たすオブジェクトをイテレーターとみなすようだ。

  • next() メソッドを定義する
  • next() メソッドは { done: boolean, value: any } を返却する
    • done: true の場合は、これ以上反復できる要素がないことを示す
    • done: false の場合は、まだ反復できる要素があることを示す

ただし、イテレーター(列挙可能)であることと、Iterator オブジェクトを継承していることは別なので、次のようなオブジェクトはうまく動作しない。

const generator = {
    next() {
        return { done: false, value: 0 }
    }
}
 
// これは動かない: `TypeError: {} is not iterable`
for (const value of generator) {
    // 無限に0を列挙する
}

3. ジェネレーター

すでに反復可能プロトコルの項で述べており、説明が重複してしまうものの、計算プロパティとしてのジェネレーターによれば、次の動作を満たすオブジェクトを反復可能とみなすようだ。

  • function* で宣言されて yield キーワードで値を返却するメソッド

そして、Generatorオブジェクトは反復可能プロトコルとイテレータープロトコルの両方に対応している。つまりジェネレーター関数を実行すると、その後 next() で値を取り出すことができることを意味している。

const generator = {
    *[Symbol.iterator]() {
        yield 1
        yield 2
        yield 3
    }
}
 
const iterator = generator[Symbol.iterator]()
console.log(iterator.next())  // `Object { value: 1, done: false }`
console.log(iterator.next())  // `Object { value: 2, done: false }`
console.log(iterator.next())  // `Object { value: 3, done: false }`
console.log(iterator.next())  // `Object { value: undefined, done: true }`

4. イテレーター

Iteratorはイテレータープロトコルに準拠したオブジェクトで反復可能でもある。つまり、次のような実装がなされている。

const iterator = {
    next() {
        return { done: false, value: 0 }
    },
    [Symbol.iterator]() {
        return this
    }
}
 
for (const value of iterator) {
    // 無限に0を列挙する
}

これまで Iterator オブジェクトの定義はありつつも、実行可能なメソッドは用意されていなかった。最新版のモダンブラウザでは反復可能メソッドが定義されているが、環境によっては動作しないことが考えられるので、特に業務用途ではPolyfillなしには安心して利用はできないだろう。

カスタムイテレーター

また、Iterator を継承することで自分が望む動作をするイテレーターを作成することもできる。next() メソッドを上書きして次に返すべき値を指定するだけだ。

class MyIterator extends Iterator {
    next() {
        // ...
    }
}

イテレーターの課題

JavaScriptではCloneable(複製可能)の考え方があまりないように感じられる。例えば、Dateオブジェクトの複製は new Date(+date) となるし、配列の複製は再帰的な処理を行うヘルパーメソッドが必要になる。そのほかのオブジェクトについてはどこまで複製すれば正しい挙動になるのか不明瞭なこともある。

イテレーターオブジェクトについても同様で、イテレーターの中には複製可能でないと実装が困難なものもある。例えばシーケンスを繰り返す Cycle イテレーターは現在のイテレーターが打ち止めになったら、内部的にイテレーターの複製と設定をして無限に反復できるようにしている。

class Cycle extends Iterator {
    #iter; #orig
 
    constructor(iter) {
        super()
        this.#iter = iter
        this.#orig = iter.clone()  // これは誰が用意してくれるの?
    }
 
    next() {
        let result = this.#iter.next()
        if (result.done) {
            this.#iter = this.#orig.clone()  // これも!
            result = this.#iter.next()
        }
        return result
    }
}

もしイテレーターのソースが配列のように長さが明らかなオブジェクトであれば複製ができなくとも問題ないが、イテレーターは無限に反復できるようになる可能性がある。まさに Cycle イテレーターがその典型例であるし、途中まで反復した状態を複製できないのも悩ましい。

const a = [1, 2, 3].values()  // 長さ3の配列であることは内部的にわかっている
a.next()  // { done: false, value: 1 }
 
const b = a.clone()  // [2, 3] を反復するイテレーターであるべき
b.next()  // { done: false, value: 2 }
b.next()  // { done: false, value: 3 }
b.next()  // { done: true, value: undefined }
a.next()  // { done: false, value: 2 } a と b は独立しているべき
 
const c = [1, 2, 3].values().cycle()
c.next()  // { done: false, value: 1 }
c.next()  // { done: false, value: 2 }
c.next()  // { done: false, value: 3 }
c.next()  // { done: false, value: 1 } ここで内部的にイテレータが複製されている

とはいえ、Array.prototype.values()で「配列イテレーターオブジェクトは、一回のみ使用可能なオブジェクトになります。再利用しないでください」と触れられているように、JavaScriptの思想としてイテレーターを再利用可能にするような考え方はないようにも読み取れる。