ES6 のジェネレータは…

存在には結構前に気付いていても触手が伸びなかったそのワケとは。

ES6 のジェネレータについての感想的なもの

前置き: 「ジェネレータ」に求めるもの、のおさらい

Python での話にほとんど書いているが、観点が少しだけ違うので、改めて考えておく。

大きく言えば3つである。

  1. 「要素巡回特殊化」というタイプの責務分割に使う。
  2. 「コルーチン」というタイプの責務分割に使う。
  3. 空間効率。

2. の話は今回の話では本題ではないので、求めるものとしては重要だけれど省略。今回問題にしたい話で関係が深いのは 1.、3. のみなので、これらについておさらい。Python で、ワタシがよくやるパターンで見ておく。

csv な入力に基く何かをしたいとして、とにかく読み込み部分を手っ取り早く書く:

 1 # -*- coding: utf-8 -*-
 2 
 3 #
 4 rawdata = """\
 5 icao,regid,mdl,type,operator
 6 008ff4,zs-gal,a320,Airbus A320-231,Fly Baghdad
 7 008ff7,zs-gao,a320,Airbus A320-231,Global Aviation
 8 008ffb,zs-gas,a320,Airbus A320-231,Global Aviation
 9 008ffd,zs-gau,dc93,McDonnell Douglas DC-9-32,Velvet Sky
10 009123,zs-gmc,pc12,Pilatus PC12/45,Private owner
11 0094c1,zs-hvs,p28b,Piper PA-28-235,Private owner
12 009532,zs-iab,b732,Boeing 737-210C,Africa Charter Airline
13 009534,zs-iad,b732,Boeing 737-2X6C,Imperial Air Cargo"""
14 
15 #
16 import csv
17 reader = csv.reader(rawdata.split("\n"))
18 colnames = []
19 for i, row in enumerate(reader):
20     if i == 0:
21         colnames = row
22     else:
23         print(dict(zip(colnames, row)))

要するに csv な入力を辞書に読み替えているわけだ。そして「単なる読み込み」とそうでない本題部分をごちゃ混ぜにしたくないので、「単なる読み込み」をあらかじめ切り出しておきたいわけである:

 1 # -*- coding: utf-8 -*-
 2 
 3 # ...
 4 def readdata(iterable):
 5     import csv
 6     reader = csv.reader(iterable)
 7     colnames = []
 8     for i, row in enumerate(reader):
 9         if i == 0:
10             colnames = row
11         else:
12             yield dict(zip(colnames, row))  # (2)
13 
14 #
15 for row in readdata(rawdata.split("\n")):  # (1)
16     print(row)  # (3)

着目すべきなのは、処理の流れが「(1)→(2)→(3)→(2)→(3)→(2)→(3)→…」である、という点で、これこそがジェネレータのキモであり、以下と較べればとりわけはっきりする:

 1 # ...
 2 #
 3 def readdata(iterable):
 4     import csv
 5     reader = csv.reader(iterable)
 6     result = []
 7     colnames = []
 8     for i, row in enumerate(reader):
 9         if i == 0:
10             colnames = row
11         else:
12             result.append(dict(zip(colnames, row)))  # (2)
13     return result
14 
15 #
16 for row in readdata(rawdata.split("\n")):  # (1)
17     print(row)  # (3)

こちらは当然「(1)→(2)→(2)→(2)→…→(3)→(3)→(3)→…」だ。この差が、内容によっては「空間効率」の差を生む。すなわち、readdata を利用するコードが以下である場合、メモリ使用量が異なる:

1 # ...
2 #
3 for row in readdata(rawdata.split("\n")):
4     if row["icao"] == "008ffd":
5         break

これこそが「巡回コードのルーチン化と空間効率を期待する」の意味である。

そして、とりわけ Python での例で非常に重要(かつ当たり前過ぎてあまり大騒ぎはされない)のは、そしてこれは C++ でも同じだが、上のジェネレータ版と list 版が、利用者からみると「まったく同じ使い方」である、とい点である。

ES6 のジェネレータ・イタレータは…

これから ES6 のジェネレータに対する「文句」を書くけれど、公平のために言っておくと、「Python 2.x のジェネレータだって不十分だった」ことは言っておく。このことについてはPython での話に書いておいたので読んでもらえればいい。

不平はコードの整理をしたいときこそ露呈しやすいものである

最初からジェネレータを使うつもりで書き始めれば、そういうもんだとしてあきらめも付くのだろうが、こうした言語の新機能を使いたい動機の半分くらいは、「長くなってしまって保守しずらいコードを整理するのに活躍させたい」なわけで、こういうときこそ「残念感」を強く感じるのは、まぁどんな言語に取り組む場合でも一緒だったりする。

以下実際のワタシの「凄まじく印象が悪くて保守に耐えない」例をまず見てほしい:

 1         Object.keys(raw["a"]).filter(function (e) {
 2             return nodevisf.filter_a(raw["a"][e]);
 3         }).forEach(function (e) {
 4             let anime = raw["a"][e];
 5             let chara_node_added = false;
 6             anime["chs"].filter(function (node) {
 7                 return nodevisf.filter_c(
 8                     node["ms"], raw["c"][node["c"]["#"]]) &&
 9                     node["v"].length;
10             }).forEach(function (node) {
11                 node["v"].filter(function (v) {
12                     return nodevisf.filter_v(anime["#"], raw["v"][v["#"]]);
13                 }).map(function (v) {
14                     return {
15                         "c": node["c"],
16                         "v": v,
17                         "a": [],
18                         "id": v["#"] + "_" + node["c"]["#"],
19                     };
20                 }).forEach(function (ch_node) {
21                     chara_node_added = true;
22                     let already_added;
23                     if (already_added = chara_nodes.find(
24                         function (cn) {
25                             return cn["id"] === ch_node["id"];
26                         })) {
27                         ch_node = already_added;
28                     } else {
29                         chara_nodes.push(ch_node);
30                     }
31                     ch_node["a"].push({"#": e, "ms": node["ms"]});
32                     //
33                     result_eles["ac_edges"].push({
34                         group: "edges",
35                         data: {
36                             source: "a" + e,
37                             target: ch_node["id"],
38                         },
39                         classes: "ac"
40                     });
41                 });
42                 // --------------------------
43             });
44             if (chara_node_added) {
45                 result_eles["anime_nodes"].push({
46                     group: "nodes",
47                     data: {
48                         "id": "a" + e,
49                         "a": {
50                             "#": e,
51                             "n": raw["a"][e]["n"]
52                         }
53                     },
54                     style: {
55                         "background-image": [
56                             _datmng.get_image_url(e, "a")
57                         ],
58                     },
59                     classes: "a"
60                 });
61             }
62         });

ここいらの話で書いている通り、これでも ESLint の max-statements チェックには引っかからないのは、forEach, filter, map らの小さな function に「曲がりなりにも」分割されているからである。にもかかわらず見ての通り「シンプルで理解しやすく保守しやすい」のとは程遠い。

最初の Python の例での表現と同じ言い回しで言えば、「単純な巡回部分」を切り出して「本題」に集中出来るようにしたい、ということを考えたいわけであり、この「ヒドいコード」ではまずは nodevisf などでフィルタしている部分が候補となる、というわけだ。

ところがこれを Python と同じノリで「ジェネレータで」と考えようにも、ES6 の「ジェネレータ・イタレータ」は、ワタシには「世界が一貫してない」としか思えない。簡単に言えば「イタレータプロトコル」が完全に言語に統合しきれていない、ということ。つまり、本当にやりたいこういうことは出来ないのだ:

 1 function* prog_types() {
 2     yield *[
 3         [1, "TV"],
 4         [2, "OVA"],
 5         [3, "Movie"],
 6         [4, "Special"],
 7         [5, "ONA"],
 8         [6, "Music"],
 9     ];
10 }
11 
12 for (let pt of prog_types()) { // of が「イタレータを知る限られた演算子」
13     console.log(pt);
14 }
15 // map も forEach も「イタレータを知るもの」ではなく Array の prototype
16 // で定義されたメソッドに過ぎないので以下は出来ない:
17 prog_types().map(e => e[1]).forEach(function (e) {
18     // (prog_types(...).map is not a function)
19     console.log(e);
20 });

まぁいつものことだが「理由は理解できるが腑に落ちない」の最たるもんだ。どう考えても javascript の「元々のデザイン」からして「致し方ないというのも理解は出来ないこともない」が、だからって「もっと頑張ってくれてもいいのに」と思ってもバチは当たるまい。

要するにワタシの上で挙げた「世界が破滅するほどムゴいコード」をジェネレータ流儀に書き換えるにあたり、「返却のリストやイタレーションに対してさらに map やら filter をかます」という処理のチェインを続けようにもそれを直接は出来ない、ということ。もっと言うなら「相手がジェネレータであることを熟知したクライアントコードを書く必要がある」ということ。伝わるかなぁ? 「ジェネレータにしたらジェネレータ用のコードに書き換える必要がある」てこと。

たとえば最初の Python の csv 相手の例で考えた場合に、

 1 # -*- coding: utf-8 -*-
 2 
 3 # ...
 4 def readdata1(iterable):
 5     import csv
 6     reader = csv.reader(iterable)
 7     result = []
 8     colnames = []
 9     for i, row in enumerate(reader):
10         if i == 0:
11             colnames = row
12         else:
13             result.append(dict(zip(colnames, row)))
14     return result
15 
16 def readdata2(iterable):
17     import csv
18     reader = csv.reader(iterable)
19     colnames = []
20     for i, row in enumerate(reader):
21         if i == 0:
22             colnames = row
23         else:
24             yield dict(zip(colnames, row))
25 
26 #
27 for row in readdata1(rawdata.split("\n")):  # これは可能だ
28     print(row)
29 
30 for row in readdata2(rawdata.split("\n")):
31     # ジェネレータなので NG…、と言われるようなもの(本物の python では無論 OK)
32     print(row)

これを「一貫性に欠ける」と言うワタシは間違っているか? 間違ってはいまい。

蛇足: 結局そのムゴいコードをどうした?

「空間効率」のことがあるので後ろ髪引かれる思いは当然ありつつ、少なくとも「切り出した単純巡回処理」の後続の処理が(現時点では) map やらを繰り返す必要があるので、仕方がないのでジェネレータをいったん諦めて…:

 1 function NodeVisFilter(settings, malrawdata) {
 2     // ...
 3     this.get_filtered = function () {
 4         // 最初の「ムゴ」いのうち「刈り込み」部分だけを切り出したもの。
 5         let raw = this._malrawdata;
 6         return Object.keys(raw["a"]).filter(function (e) {
 7             return _this.filter_a(raw["a"][e]);
 8         }).map(function (e) {
 9             let a = raw["a"][e];
10             return {
11                 "#": e,
12                 "chs": a["chs"].filter(function (ach) {
13                     return _this.filter_c(
14                         ach["ms"], raw["c"][ach["c"]["#"]]);
15                 }).map(function (ach) {
16                     return {
17                         "ms": ach["ms"],
18                         "c": ach["c"],
19                         "v": ach["v"].filter(function (v) {
20                             return _this.filter_v(
21                                 a["#"], raw["v"][v["#"]]);
22                         }),
23                     };
24                 }).filter(function (d) {
25                     return d["v"].length;
26                 }),
27             };
28         }).filter(function (d) {
29             return d["chs"].length;
30         });
31     };
32 }
33 // ...
34 // 以下がもとの「ムゴ」いコードを書き換えたもの:
35         nodevisf.get_filtered().forEach(function (a) {
36             result_eles["anime_nodes"].push({
37                 group: "nodes",
38                 data: {
39                     "id": "a" + a["#"],
40                     "a": {
41                         "#": a["#"],
42                         "n": raw["a"][a["#"]]["n"]
43                     }
44                 },
45                 style: {
46                     "background-image": [
47                         _datmng.get_image_url(a["#"], "a")
48                     ],
49                 },
50                 classes: "a"
51             });
52             a["chs"].forEach(function (node) {
53                 node["v"].map(function (v) {
54                     return {
55                         "c": node["c"],
56                         "v": v,
57                         "a": [],
58                         "id": v["#"] + "_" + node["c"]["#"],
59                     };
60                 }).forEach(function (ch_node) {
61                     let already_added;
62                     if (already_added = chara_nodes.find(
63                         function (cn) {
64                             return cn["id"] === ch_node["id"];
65                         })) {
66                         ch_node = already_added;
67                     } else {
68                         chara_nodes.push(ch_node);
69                     }
70                     ch_node["a"].push({"#": a["#"], "ms": node["ms"]});
71                     //
72                     result_eles["ac_edges"].push({
73                         group: "edges",
74                         data: {
75                             source: "a" + a["#"],
76                             target: ch_node["id"],
77                         },
78                         classes: "ac"
79                     });
80                 });
81                 // --------------------------
82             });
83         });

ES6 のジェネレータ・イタレータとどう付き合っていくとうれしいだろうか?

そもそも「ES6 で」導入された機能なので、古いブラウザも相手にしなければならないならば候補にも挙がらないので、このケースではかえって問題はない。

問題は、見てきた通り「新しいブラウザにしか興味ないもんね」だとしても使用を躊躇してしまうという点にあるが、だからといって「ES6 のジェネレータはつかえねー」という話ではない。最初に言った通り、ジェネレータにはジェネレータのれっきとした「意味と価値」があり、すなわち上で説明した「処理順序」の件、そしてこのことが「空間効率をもたらす」(ことがある)という点だ。メモリの制約が思ったよりずっときついブラウザで動作するコードでは、ジェネレータの利用こそが救世主となりうる場合もあるはずだ。

まっさきに考えるべきは、たぶんこういうことだろう: 「おそらく ES の進化に伴って、将来的にさらにシームレスになっていくのであろう」という将来像を見据えること。

つまり、今は出来立てほやほやのジェネレータであるが、Python などが証明してきた通り、ジェネレータの価値は甚大なので、将来の ES ではもっと「ジェネレータ・イタレータ流儀」の徹底化が進むはずだ、と考えるわけである。だとするならば、おそらく「of を使える場合はいつでも of を使っとけ」ということになるのかなぁ、と、ひとまずは思っておく。概念的な言葉で言うなら「ES の「イタレータ」に従うものを優先して使う」ということである。言い換えれば「Array のメソッドに頼るのではなく」ということ。

それから「ジェネレータによる列挙をさらに map や filter することが出来ない」については、これは「ジェネレータを使っていることを知っている」というスタンスに立つならば解がないとも言えなくて、たとえばこういうことは出来る:

 1 function* prog_types() {
 2     yield *[
 3         [1, "TV"],
 4         [2, "OVA"],
 5         [3, "Movie"],
 6         [4, "Special"],
 7         [5, "ONA"],
 8         [6, "Music"],
 9     ];
10 }
11 
12 for (let pt of prog_types()) { // of が「イタレータを知る限られた演算子」
13     console.log(pt);
14 }
15 //// map も forEach も「イタレータを知るもの」ではなく Array の prototype
16 //// で定義されたメソッドに過ぎないので以下は出来ない:
17 //prog_types().map(e => e[1]).forEach(function (e) {
18 //    // (prog_types(...).map is not a function)
19 //    console.log(e);
20 //});
21 for (let ty of (function* () { // map 相当のこと 
22     for (let pt of prog_types()) {
23         yield pt[1];
24     }
25 }())) {
26     console.log(ty);
27 }

つまりジェネレータからの列挙を包んでさらにイタレータを生成するジェネレータを作る、言うなれば「郷(ジェネレータ)に入っては郷(ジェネレータ)に従」うつもりならば、これは別に「単に Array の map そのものが使えないだけやん」と言うことが出来る。まぁつまりは「頭を完全にリフレッシュして、map や filter なんかこの世に存在しなかったのだ」と思い込んでしまえば、別になんてこともない。

てことなんだろうなぁ、きっと。