map()やリスト内包表記をジェネレーターで機能拡張(?)する

(主に Python を対象に書いたけれど、Julia その他 map 関数ジェネレーターがある処理系すべてで通用する話)

あるリストを元に、新しいリストを生成したいとする。ここで、もし結果のリストが元のリストの各要素に一対一対応するのであれば、普通に map() を使えば良い。しかし例えば次のような場合、map() では対応できない。:

  1. 一部は結果リストの要素とせず除外したい
  2. 元のリストの複数の要素から結果リストの個々の要素を生成したい

map() の上位互換機能と言える Python のリスト内包表記 (list comprehension) であれば(1)は解決できるけれど、(2)は難しい。なので(2)程度の複雑さになったら素直に for ループを書くようにしてきた。が。できれば上記のような処理でも map() やリスト内包表記を使いたいなぁと思っていた。なにせリストを作りたいのだから、その意図が素直に表現された方が嬉しい。それに、ループで書く場合に必要な結果格納用リストが最終的に欲しいデータではないこともあって、そういう場合は名前空間が汚れて嬉しくない(result とか numbers とか無意味な変数名になりがちだし)。そんなわけで、map() やリスト内包表記をもっと柔軟に使う方法が無いかと頭をひねってみたところ、ジェネレーターを使えば(1)も(2)も解決できると気が付いた。

まず(1)の、一部の要素を除外する例を挙げる。なお(1)はジェネレーターを使わずともリスト内法表記で普通に書けるので、その例も掲載している。

>>> def drop_none(numbers):  # Noneを除外する
...     for n in numbers:
...        if n is not None:
...            yield n
...
>>> fruits = ['apple', 'orange', 'grape', 'pineapple']
>>> indices = [0, 1, None, 3]
>>> [fruits[i] for i in drop_none(indices)]  # リスト内法表記版
['apple', 'orange', 'pineapple']
>>> list(map(lambda i: fruits[i], drop_none(indices)))  # map()版
['apple', 'orange', 'pineapple']
>>> [fruits[i] for i in indices if i is not None]  # リスト内法表記版その2
['apple', 'orange', 'pineapple']

続いて(2)の、入力と出力の要素が一対一対応しない例を挙げる。

>>> def pairwise(numbers):  # 隣り合う値のペアを取り出す
...    for i in range(1, len(numbers), 2):
...        yield numbers[i - 1], numbers[i]
...
>>> numbers = [1, 2, 3, 4, 5, 6]
>>> [sum(pair) for pair in pairwise(numbers)]  # リスト内法表記版
[3, 7, 11]
>>> list(map(sum, pairwise(numbers)))  # map()版
[3, 7, 11]

さらに複雑な例も挙げてみよう。コマンドライン引数リストからオプションの名前と小文字変換された値のペアを取り出すが、ただし値を取らないオプション(フラグ)についてはオプション値を None とする…という処理は次のように書ける。

>>> def cmd_options(args):
...     """コマンドオプションの名前と値のペアを取り出す."""
...     FLAG_OPTIONS = ['-b']
...     i = 0
...     while i < len(args):
...         if not args[i].startswith('-'):
...             pass  # 非オプションは無視
...         elif args[i] in FLAG_OPTIONS:
...             yield args[i], None
...         elif i+1 < len(args):
...             yield args[i], args[i+1]
...             i += 1
...         else:
...             raise Exception('Option ' + args[i] + ' needs a value.')
...         i += 1
...
>>> def _lower(s):
...     return s.lower() if s else None
...
>>> sequence = '-a SPAM -b HAM -c EGGS'.split()
>>> [(name, _lower(value)) for name, value in cmd_options(sequence)]
[('-a', 'spam'), ('-b', None), ('-c', 'eggs')]
>>> list(map(lambda p: (p[0], _lower(p[1])), cmd_options(sequence)))
[('-a', 'spam'), ('-b', None), ('-c', 'eggs')]

…うーん、まあ、書けるけれど、このくらい複雑になると map() は読みにくくて使う気がしなくなってくるなぁ…。リスト内法表記は、改めて優秀だなと思う。

コード的にはジェネレーターでソースのリストをラッピングするのだけれど、そう捉えるより「ソースとなるシーケンスを変換するフィルターを適用している」と捉えた方が良いね。そもそもmap()やリスト内包表記は「シーケンスから取り出したデータを詰め込んだリストを作る」機能でもある。そして最初に掲げた問題を手元にあるシーケンスの出力が欲しい形式ではない問題であると解釈すれば、お望みの形式で出力するシーケンスに変換すれば素直な map() やリスト内包表記で書けるのは道理。そーいう変換(フィルター処理)には、ジェネレーターが適任だ。こうした考え方に早くたどり着いていれば、こうした書き方も早く気が付いていたのかもしれないなぁと思う。