LoginSignup
6
5

More than 1 year has passed since last update.

JavaScriptでPythonのzip関数を実装してみました

Last updated at Posted at 2021-03-04

Python3にはzip()という便利な組み込み関数があります。
以下のように、複数のイテラブル1を同時にイテレーションできる関数です。

chars = ["a", "b", "c"]
nums = [1, 2, 3, 4, 5]

for elems in zip(chars, nums):
    print(elems)

# 出力
# ('a', 1)
# ('b', 2)
# ('c', 3)

引数にイテラブルを複数指定すると、各イテラブルの要素をまとめたタプルを返すイテレータを作ってくれます。
イテラブル間で要素数が異なる場合は、もっとも短いイテラブルの要素が尽きた時点で止まります。

この関数をJavaScriptで再現してみます。

環境と背景知識

先行事例

「JavaScript Python zip」で検索すると似たような試みは何件かヒットします。
Qiitaでも先駆者がいらっしゃいます。

個人的にいつも参考にしている民主主義に乾杯さんから実装例を引用します。

function* zip(...args) {

    const length = args[0].length;

    // 引数チェック
    for (let arr of args) {
        if (arr.length !== length){
            throw "Lengths of arrays are not eqaul.";
        }
    }

    // 
    for (let index = 0; index < length; index++) {
        let elms = [];
        for (arr of args) {
            elms.push(arr[index]);
        }
        yield elms;
    }
}

空の配列elmsを作る→イテラブルのそれぞれi番目の要素を取得して順次elmsにpush→elmsをyield、を繰り返す流れです。
配列間の要素数が同じである必要はあるものの、Pythonのzip関数と同じようなことができそうです。
イテレータを返すところも再現度が高くて良いですね。

yucatioさんの実装もご紹介します。

const zip = (...arrays) => {
  const length = Math.min(...(arrays.map(arr => arr.length)))
  return new Array(length).fill().map((_, i) => arrays.map(arr => arr[i]))
}

こちらはジェネレータ関数ではなく普通の関数(アロー関数)としての実装で、返すのはイテレータではなく配列になります。
この実装が優れている点の一つは、配列間の要素数が違っていても、最も短い配列に合わせて処理してくれるところです(2行目)。
3行目はmap()が入り乱れていたり、謎のfill()が挟まっていたりと少々読みづらいですが、元サイトで詳しく解説されているのでご覧になってみてください。

先行事例についてさらに考察しましたが、ちょっと長くなったので折りたたみ。
要約すると、「先行事例のコードでは一部のイテラブルが期待通りに動かないよ」という内容です。

改良してみた

上記二つのコードを組み合わせてみます。

function* zip(...args) { 
    const length = Math.min(...args.map(arg => arg.length));
    for (let index = 0; index < length; index++) {
        let elms = [];
        for (const arr of args) {
            elms.push(arr[index]);
        }
        yield elms;
    }
}

引数のイテラブルの要素数が異なっても対応でき、さらにPythonのzip()関数と同じくイテレータを返すようになっています。
これでなかなかの再現度になったのではないでしょうか?

使ってみましょう。

const chars = ["a", "b", "c"];
const nums = [1, 2, 3, 4, 5];

for (const elems of zip(chars, nums)) {
    console.log(elems);
}

// 出力
// Array [ "a", 1 ]
// Array [ "b", 2 ]
// Array [ "c", 3 ]

Pythonと同じ結果が得られました!

また、Pythonのzip()はリスト2以外でも、文字列などイテラブルであれば何でも引数にとれます。
JavaScript版zip()でも文字列を引数にして試してみます。

const str = "qwe";
const nums = [1, 2, 3, 4, 5];

for (const elems of zip(str, nums)) {
    console.log(elems);
}

// 出力
// Array [ "q", 1 ]
// Array [ "w", 2 ]
// Array [ "e", 3 ]

いけますね。
その他、NodeListやargumentsオブジェクトなどのイテラブルにも使うことができます。
このzip関数ならどんなイテラブルが来ても怖くありません。

本当に?

添字アクセスができないと使えない

たしかに、JavaScriptの代表的なイテラブルである配列や文字列は問題なくイテレートできます。
それでは、先ほどのzip()でSetオブジェクトをイテレートしてみます。

const str = "qwe";
const numSet = new Set([1, 2, 3, 4, 5]);

for (const elems of zip(str, numSet)){
    console.log(elems);
}

// 出力
// 

…ダメですね。期待通りにイテレートしません。
Setオブジェクトは間違いなくイテラブルのはずなんですが。

原因はzip()の定義の6行目、elms.push(arr[index]);の部分で行われている添字アクセスです。
Setオブジェクトでは、numSet[1]のような添字を使った形で要素にアクセスすることができない3ので、意図した通りに処理されないのです。

Mapオブジェクトやジェネレータオブジェクトも同様に、イテラブルかつ添字アクセスができないオブジェクトです。

本編です

折りたたみを読んでくださった方、ありがとうございました。ここから本編です。
折りたたみを飛ばした方、大丈夫です。本編はここからです。

今回作るzip()は、Pythonのzip()のように以下の要件を満たします。

  • 引数のイテラブルをまとめてイテレートする機能を提供する
  • 実行したらイテレータを返す
  • イテレートはもっとも短いイテラブルの要素が尽きたら終了する
  • イテラブルならなんでも、いくつでも対応可能である

方針

Pythonの公式ドキュメントのzip()の項目を見てみると、ありがたいことにzip()と等価なコードが紹介されています。
引用します。

def zip(*iterables):
    # zip('ABCD', 'xy') --> Ax By
    sentinel = object()
    iterators = [iter(it) for it in iterables]
    while iterators:
        result = []
        for it in iterators:
            elem = next(it, sentinel)
            if elem is sentinel:
                return
            result.append(elem)
        yield tuple(result)

このコードのzip()は組み込みのほうのzip()と等価なので、当然先ほど挙げた要件を満たします。
これをJavaScriptで書き直せばよさそうです。

解読します

上のコードの処理を解読します。

全体としては中にyieldがある関数宣言の形ですので、ジェネレータ関数です。

sentinel = object()

どういうわけかobject()で空のオブジェクトsentinelを生成しています。
この段階では何のためかわかりません。とりあえず保留します。

iterators = [iter(it) for it in iterables]

引数のイテラブルからiter()でイテレータを取得して、iteratorsという名前のリストに格納しています。
リスト内包表記でiterablesの全ての要素について一括で処理していますね。
[イテラブル1, イテラブル2, イテラブル3,…]から[イテレータ1, イテレータ2, イテレータ3,…]を作っている感じです。

続いてwhile iterators:でループが始まっています。
もしiteratorsが空ならここはスルーされてなにもyieldされないので、空のジェネレータオブジェクトになります。

#ループ内
result = []
for it in iterators:
    elem = next(it, sentinel)
    if elem is sentinel:
        return
    result.append(elem)
yield tuple(result)

ループの中では、ループ毎にresultという空の配列が作られています。
そのあとfor文でiteratorsを走査しています。
具体的には、「イテレータからnext()で値を取り出す→resultに追加」を全てのイテレータについて行っています。
resultへの追加が完了したら、タプルに変換してyieldしています。

ここで、始めに作られた謎のsentinelがnext()の第二引数に使われていますね。
next()のドキュメントを確認します。

next(iterator[, default])
iterator の __next__() メソッドを呼び出すことにより、次の要素を取得します。イテレータが尽きている場合、 default が与えられていればそれが返され、そうでなければ StopIteration が送出されます。

next(it, sentinel)の直後にif文でelem is sentinelが評価されていることを考え合わせると、イテレータが尽きているかどうかをnext()の結果がsentinelかどうかで判断しているようです。
つまり、「イテレータが尽きる→elem is sentinelがTrueになる→returnしてジェネレータを終了」ということ。
「もっとも短いイテラブルの要素が尽きたら終了」という仕様がこういう形で実現できるんですね。

いざ、JavaScriptで実装

大事な点が一つ。
機能的には、JavaScriptではイテレータ.next()が、Pythonのnext(イテレータ)に相当します。
しかし、両者の戻り値は異なります。
Pythonのnext(イテレータ)は値を直接返します(例えば、ジェネレータならyieldされた値をそのまま返します)。イテレータが尽きていた場合、StopIteration を送出するか、第二引数の値を返します。
一方、JavaScriptのイテレータ.next()が返すのは、valueとdoneというプロパティを持つオブジェクトです。イテレータが実際に返した値はこのオブジェクトのvalueプロパティを参照して取得します。また、イテレータが尽きていた場合、doneプロパティがtrueになります(そうでない場合はfalseです)。

Pythonのコードではイテレータが尽きたかチェックするのにnext()の第二引数を利用していましたが、JavaScriptでは戻り値のdoneプロパティを参照すればよいので空オブジェクトsentinelを用意する必要はありませんね。

ではコードを書きます。
ジェネレータ関数なのでfunction*宣言の形にします。

function* zip(...iterables) {

}

次にiteratorsを作りますが、JavaScriptにはリスト内包表記はないのでmap()で代用。
また、イテラブルからイテレータを取得するiter(it)に相当するのは、JavaScriptではit[Symbol.iterator]()4 5です。

const iterators = iterables.map(it => it[Symbol.iterator]());

あとはwhileの部分です。
JavaScriptとPythonのnext()の違いに注意。
もう一つ注意が必要なのが、whileの条件式です。Pythonでは空のリストはFalsyですが、JavaScriptでは空の配列はTruthyです。したがって、そのままwhile (iterators)と書くわけにはいきません。
また、Pythonではresultをタプルにしてからyieldしていますが、JavaScriptにタプルはないので配列のままyieldします6

while (iterators.length) {
    const result = [];
    for (const it of iterators) {
        const elemObj = it.next();
        if (elemObj.done) {
            return;
        }
        result.push(elemObj.value);
    }
    yield result;
}

以上を組み合わせれば完成です。

完成

function* zip(...iterables) {
    const iterators = iterables.map(it => it[Symbol.iterator]());
    while (iterators.length) {
        const result = [];
        for (const it of iterators) {
            const elemObj = it.next();
            if (elemObj.done) {
                return;
            }
            result.push(elemObj.value);
        }
        yield result;
    }
}

使ってみます

配列・文字列・Setオブジェクト・Mapオブジェクトを、今回作ったzip()でイテレートしてみます。

const chars = ["a", "b", "c"];
const str = "qwe";
const numSet = new Set([1, 2, 3, 4, 5]);
const numMap = new Map([[1, 1], [2, 4], [3, 9], [4, 16]]);

for (const elems of zip(chars, str, numSet, numMap)) {
    console.log(elems);
}

// 出力
// Array(4) [ "a", "q", 1, Array [ 1, 1 ] ]
// Array(4) [ "b", "w", 2, Array [ 2, 4 ] ]
// Array(4) [ "c", "e", 3, Array [ 3, 9 ] ]

期待通りの動作です。


  1. この記事では、PythonのイテラブルオブジェクトとJavaScriptの反復可能オブジェクトをまとめて「イテラブル」と呼ぶことにします。 

  2. この記事では、JavaScriptの配列をPythonのリストに相当するものとして扱います。 

  3. JavaScriptのブラケット表記はプロパティアクセサーなので、numSet[1]のように書くと「numSetというオブジェクトの1というプロパティ」にアクセスすることはできます(通常このようなプロパティは未定義なのでundefinedになります)。しかし、普通はこれをSetオブジェクトの要素へのアクセスとは見做さないでしょう。 

  4. もしこのメソッドに馴染みのない方がおられましたら、MDNの反復処理プロトコルの記事をご覧ください。 

  5. it[Symbol.iterator]()です。it.Symbol.iterator()ではありませんので注意してください。 

  6. 変更不可能な配列として返すだけならyield Object.freeze(result)とすれば実現できますが、あまり意味はないと思います。 

6
5
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5