ジェダイ流・Pythonの内包表記

醜いより美しい方がいい。暗示するより明示する方がいい。
Pythonの禅より

私はよく、ドロイドやジェダイ、惑星、ライトセーバー、スターファイターなどのコレクショングッズを題材にしてプログラムを書きます。Pythonでプログラミングをする際は大抵、これらをリストやセット、辞書として表現するわけです。私は日頃からコレクショングッズをさまざまな形に変身させたいと思っています。そして、その願望を叶えてくれるのが、内包表記という強力な記法です。内包表記は私がさまざまな場面で使っている手法であり、Pythonを使い続けている理由の1つでもあります。では、いくつか例と共に、内包表記がいかに便利かを説明していきましょう。

以下の例に出てくる処理はどれも、種類豊富なPythonの標準ライブラリがあれば実装できます。その中には、より簡潔で効率の良い処理に改善できるものもあるでしょう。とはいえ、私は標準ライブラリに不満があるわけではあません。内包表記は明快で、実に的確で美しいものだと思っています。必要な情報は全て簡潔で読みやすいコードに記載されているので、ドキュメントを読みあさる必要はありません。

注:今回、私が使用するのはPython 3.5です。リスト、セット、辞書の内包表記はPython2.7以降のバージョンで利用できます。それ以前のバージョンでは、以下の例で使用している関数やシンタックスは提供されていない、または無効になっている場合があります。

ピポピポ


私たちは今、R2-D2と意思疎通を図ろうとしています。でもR2-D2は、何やら不規則な電子音を発しているだけです。しばらく頭を抱えた後、私たちは、ピー:0とポー:1の羅列を書き出してみることにしました。

bbs = '01110011001000000110111001101111001000000010000001101001001000000111001101101110001000000110010100100000001000000110100000100000001000000110010100100000011100100010000000100000011100000110110100100000011011110010000001100011'

面白いですね。もしかすると、これはASCII文字を表すビットのオクテットかもしれません。では、このビットの羅列をオクテット単位で区切ってみましょう。

命令型で書くと以下のようになります。

octets = []
for i in range(0, len(bbs), 8):
  octets.append(bbs[i:i+8])

最初に新しいリストを初期化します。そして一連のビットを8番目のインデックスごとに区切って、長さが8になったものをオクテット用のリストに追加していきます。

これが最善策でしょうか? もっといい方法がありますよね。では、関数型で書いた以下のコードを見てください。

octets = list(map(lambda i: bbs[i:i+8], range(0, len(bbs), 8)))

オクテットのインデックスをラムダ関数にmapすると、ラムダ関数はそのインデックスから始まるオクテットを返します。このmap関数はイテレータ引数を返すので、それをlist関数を使ってリストに変換します。こうすると、命令型の処理よりも少し簡潔になりますが、可読性は下がってしまいます。

ここでマスターヨーダに意見を仰ぐと、彼は以下の提案をしてくれました。

octets = [bbs[i:i+8] for i in range(0, len(bbs), 8)]

もしやフォース? いいえ、これは内包表記です。より正確に言うと、リスト内包表記です。

このブラケット[]は、新しいリストを作成していることを意味します。ブラケット内には、まずbbs[i:i+8]と表記します。次はfor i in range(0, len(bbs), 8)というfor節です。このfor節では、新しいリストのベースとして使うイテレータを定義しており、初期状態では新しいリスト内の結果要素を定義しています。

追加情報:ブラケット内にあるのは、ジェネレータ式と呼ばれるもので、これはイテレータを作成するために自動的に使用されます。

ここまででリスト内包表記については理解できました。次は再びリスト内包表記を使って、オクテットを文字に変換しましょう。

chrs = [chr(int(octet, 2)) for octet in octets]

すると以下の結果が得られます。

['s', ' ', 'n', 'o', ' ', ' ', 'i', ' ', 's', 'n', ' ', 'e', ' ', ' ', 'h', ' ', ' ', 'e', ' ', 'r', ' ', ' ', 'p', 'm', ' ', 'o', ' ', 'c']

希望が見えてきましたが、まだ断片化しています。ではスペースを削除したらどうなるでしょう?

通常は' '(空白)文字を全てfilterして削除します。

chrs = list(filter(lambda c: c != ' ', chrs))

これでもうまくいきますが、内包表記の真の力を生かせば、以下のようにシンプルにできます。

chrs = [c for c in chrs if c != ' ']

リスト内包表記内でif節を使ってフィルタ作業が行えるとは、素晴らしいですね。

最後にメッセージをさらに読みやすくするために、文字を統合して文字列にします。

message = ''.join(chrs)

さて、「snoisneherpmoc」とは一体何でしょう? もしかしたらR2-D2は何かしらの理由で、メッセージを逆に話していたのかもしれません。

message = ''.join(reversed(chrs))

なんとメッセージは「comprehensions(内包表記)」でした。R2-D2は私たちが何をしているか知っていたんですね。

ドロイド出会い系サービス

この例では勇敢なドロイドのための出会い系サービスを作成します。ドロイドを組み合わせる方法を次のように一覧にします。

droids = [
  {'name': 'BB-8', 'fav_jedi': 'Rey'},
  {'name': 'R2-D2', 'fav_jedi': 'Luke Skywalker'},
  {'name': 'C-3PO', 'fav_jedi': 'Luke Skywalker'},
]

itertools.combinationsを使って実現できますが、一旦ここでは、このコードは存在しないことにして、独自のコードを書きましょう。

まず組み合わせの可能なドロイドを昔ながらの方法で列挙してみましょう。

matches = []
for i in range(len(droids)):
  for j in range(i + 1, len(droids)):
    matches.append((droids[i], droids[j]))

これをビルドインのenumerate関数と配列スライス機能を使ってリストを少し改善しましょう。

matches = []
for i, a in enumerate(droids):
  for b in droids[i + 1:]:
    matches.append((a, b))

これをネストしたリスト内包表記にすれば、1行に集約することができます(そうです、ネストできるのです)。

matches = [(a, b) for i, a in enumerate(droids) for b in droids[i + 1:]]

最後に好きなジェダイが共通しているかを基に相性度に点数を付けます。条件式を埋め込めばとても簡単にできます。

scores = ['Great' if a['fav_jedi'] == b['fav_jedi'] else 'Miserable' for a, b in matches]

では、組み合わせと点数をzip関数を使って、読みやすい書式で出力できるようにします。

print(['{[name]} + {[name]} = {}'.format(*m, s) for m, s in zip(matches, scores)])
# ['BB-8 + R2-D2 = Miserable', 'BB-8 + C-3PO = Miserable', 'R2-D2 + C-3PO = Great']

その結果、R2-D2とC-3POは相性抜群と結論付けることができます。

発進

Death Star周辺での追跡劇の前、Darth VaderとLuke Skywalkerがそれぞれの宇宙船を見つけることができません。見つけられるようにしましょう。

pilots = [
  {'name': 'Luke Skywalker', 'ship_id': 0},
  {'name': 'Darth Vader', 'ship_id': 1},
]
ships = [
  {'id': 0, 'model': 'T-65B X-wing'},
  {'id': 1, 'model': 'TIE Advanced x1'},
]

問題ありません。ネストしたリスト内包表記を使って2つのリストを一緒にします。

pilot_ships = [(p, s) for p in pilots for s in ships if p['ship_id'] == s['id']]

それぞれの宇宙船とパイロットに対して同じことを繰り返すようにします。もしパイロットのship_idが宇宙船のidと同じ場合は一致とし、tupleをリストに追加します。

では、正しくできたか見てみましょう。

print(['{[name]} → {[model]}'.format(p, s) for p, s in pilot_ships])
# ['Luke Skywalker → T-65B X-wing', 'Darth Vader → TIE Advanced x1']

発進の準備完了です。

惑星

それぞれのエピソードに出てくる惑星名のリスト(網羅しているものではありません)を含むエピソードの辞書が提示されます。

episodes = {
  'Episode I': {'planets': ['Naboo', 'Tatooine', 'Coruscant']},
  'Episode II': {'planets': ['Geonosis', 'Kamino', 'Geonosis']},
  'Episode III': {'planets': ['Felucia', 'Utapau', 'Coruscant', 'Mustafar']},
  'Episode IV': {'planets': ['Tatooine', 'Alderaan', 'Yavin 4']},
  'Episode V': {'planets': ['Hoth', 'Dagobah', 'Bespin']},
  'Episode VI': {'planets': ['Tatooine', 'Endor']},
  'Episode VII': {'planets': ['Jakku', 'Takodana', 'Ahch-To']},
}

どのようにすれば、全エピソードに登場する特徴的な惑星を集約できるのでしょうか。まずネストされたリスト内包表記をと使って平滑化した1つのリストにします。

planets_flat = [planet for episode in episodes.values() for planet in episode['planets']]

注:ネストされた内包表記では左から右へと順番に評価されるため、惑星ループの前にエピソードループが必要になります。

ここからは、重複したものを除くためにできたリストを次のようなセットにラップすることができます。

planets_set = set(planets_flat)

しかし、ここではしません。実は秘密兵器を使ってこのタスクを単純化して、取り除きます。

planets_set = {planet for episode in episodes.values() for planet in episode['planets']}

内包表記の設定完了です。

ライトセイバー

最近になって、友人の書いたコードを見てcollections.Counterクラスに遭遇しました。友人はこれを辞書に出てくる特定の値の頻度を表す辞書の作成に用いていました。次のようになります。

import collections

jedis = [
  {'name': 'Ahsoka Tano', 'lightsaber_color': 'green'},
  {'name': 'Anakin Skywalker', 'lightsaber_color': 'blue'},
  {'name': 'Anakin Solo', 'lightsaber_color': 'blue'},
  {'name': 'Ben Skywalker', 'lightsaber_color': 'blue'},
  {'name': 'Count Duku', 'lightsaber_color': 'red'},
  {'name': 'Darth Craidus', 'lightsaber_color': 'red'},
  {'name': 'Darth Maul', 'lightsaber_color': 'red'},
  {'name': 'Darth Vader', 'lightsaber_color': 'red'},
  {'name': 'Jacen Solo', 'lightsaber_color': 'green'},
  {'name': 'Ki-Adi-Mundi', 'lightsaber_color': 'blue'},
  {'name': 'Kit Fisto', 'lightsaber_color': 'green'},
  {'name': 'Luke Skywalker', 'lightsaber_color': 'green'},
  {'name': 'Obi-Wan Kenobi', 'lightsaber_color': 'blue'},
  {'name': 'Palpatine', 'lightsaber_color': 'red'},
  {'name': 'Plo-Koon', 'lightsaber_color': 'blue'},
  {'name': 'Qui-Gon Jinn', 'lightsaber_color': 'green'},
  {'name': 'Yoda', 'lightsaber_color': 'green'},
]

frequencies = collections.Counter(jedi['lightsaber_color'] for jedi in jedis)

print(frequencies)
# Counter({'blue': 6, 'green': 6, 'red': 5})

とてもカッコいい解決方法だと思いました。リストが不要だったので、リスト内包表記ではなく、ジェネレータ式を使っていることに注意してください(ジェネレータ式を使うため、Counterはイテレータ引数を取ります)。

しかし、本当にこれを実現するためには、クラスをインポートしてそのクラスのドキュメントを読み込む必要はあるのでしょうか。いいえ、辞書内包表記がこれをしてくれます。次のとおりです。

colors = [jedi['lightsaber_color'] for jedi in jedis]
frequencies = {color: colors.count(color) for color in set(colors)}

print(frequencies)
# {'green': 6, 'red': 5, 'blue': 6}

この方法では、行を追加して色のリストを作ります。しかし、その一方でCounterのドキュメントを読まなくても何が起きているのかを簡単に理解することができます。

注:この解決法では内包表記は二次元時間で実行されますが、collections.Counterは線形時間で実行されます。効率良くするためには、collections.Counterを使ってください。

終わりに

内包表記についての概要が理解できたと思っていただけたら幸いです。もし、思えないのであれば、実際に試してみてください。

最後まで記事を読んでいただき、ありがとうございました。どのように内包表記を使ったかぜひコメントでお聞かせください。

感謝

  • Thomas Dybdahl AhleAndreas Bruun Okholm に評価してもらったことと創造意欲を刺激してくれたことに感謝します。
  • Origami Yodaには素晴らしいライトセイバーの色とJediのリストを提供してくれたことに感謝します。
  • Wookieepediaにはスターウォーズのあらゆる知識を提供してくれたことに感謝します。