煽りっぽいタイトルだが、この記事は真剣である。リスト内包表記にはpython哲学の本質に関わる問題が潜んでいる。
python使いはリスト内包表記を好む。他の言語の使用者なら「for文で書きゃ良いのに」と思うような処理を、リスト内包表記で書くことを好む。
それはなぜなのか。
# 1~10の値を2乗して合算し、表示する # よくあるpythonコード print( sum([pow(x, 2) for x in range(1, 11)]) ) # こうきゃ良いのにと思う人が世間には多い(と思う) n = 0 for i in range(1, 11): n += pow(i, 2) print(n) # 他の言語の使用者が「ぎゅうぎゅうしててわかりづらいよ」と文句をいうと、python使いは不満げに改行して「これでいいだろ、上等だ」という表情を見せる print( sum([pow(x, 2) for x in range(1, 11)]) )
まったく理解しがたい習性だと思われるかもしれない。確かにリスト内包表記は多少行数は減らせるけど、そのぶん詰まって読みづらくなっているだけだ、と常識的な感性の持ち主なら誰でも思う。
python使いがリスト内包表記を使うのは決して可読性のためではない。ぶっちゃけ読みやすさは(読み手の感性に依存するが)大して変わらないからだ。普通の言語に慣れた人は、for文で書いた方が読みやすいと思うだろう。
パフォーマンスを改善するためでもない。リスト内包で稼げるわずかなパフォーマンス上のメリットなんか米粒のように隠れてしまう複雑な処理を、python使いは平気でリスト内包で処理する。ヘタしたらリスト内包表記にした方が遅くなるくらいなのだが、それでもやるのだ。たとえばpython使いが形態素解析を行いたいと思ったとき、日本語テキストのリストをリスト内包表記でMeCabに投げつける光景がしばしば見られる*1。遅いpythonスクリプトを並列化して処理速度を稼ぐために、リスト内包表記てんこもりの関数をPool.mapで並列実行するなんて本当にありふれた光景だ*2。処理内容によってはメモリが悲鳴をあげ、それ以上領域を確保できなくなってスクリプトが強制終了することすらよくある。python使いは「あちゃー、チューニングするか」とか言って、ソースコードを適当に誤魔化し限られたメモリ上でスクリプトがなんとか動くように書き直してしまう。最初に書いたリスト内包表記は最後まで手もつけられないまま温存されたままだ。
なんでリスト内包表記なんて使うのか、度し難い愚かさだ。だけど、文句を言い続けるより、もう少し色んな例を考えてみよう。たとえば、かけ算九九を格納したリストを生成して表示するなんてどうだろう。
# リスト内包表記を使わない場合 lst1 = [] for i in range(1,10): lst2 = [] for j in range(1,10): lst2.append(i*j) lst1.append(lst2) pprint(lst1) # 使う場合。可読性を考慮して改行してある pprint([[i*j for j in range(1,10)] for i in range(1,10)])
これだとリスト内包表記は際立ってシンプルに見える。どうやら、ちょっと利点が見えてきた。何しろ勝手に空リストを作って要素を入れていってくれるんだから楽ちんだ。
だが、それなら別に大した話じゃない。リスト内包表記は特定の状況下では至って便利なシンタックスシュガーだ、というだけのことである。僕が言いたいのはそんなことではない。
このかけ算九九の表を生成するプログラムを、ちょっと書き換えよう。表を作っているとき、もし表に追加する値が3の倍数なら、値をprintすることにする。printはリスト内包表記じゃ使えないじゃんと思われる方もいるかもしれないが、なに難しいことはない。値を受け取って3の倍数ならprintする、3の倍数であるかどうかに関わらず受け取った値はそのまま返すという関数を定義することにしよう。
def f(x): if x % 3 == 0: print(x) return x pprint([[f(i*j) for j in range(1,10)] for i in range(1,10)])
至って簡単。でも、普通わざわざこんなことはしない。for文で愚直に書くとこうなる。
lst1 = [] for i in range(1,10): lst2 = [] for j in range(1,10): n = i*j if n % 3 == 0: print(n) lst2.append(n) lst1.append(lst2) pprint(lst1)
このコードはpython言語の本質的な問題を曝け出している。
端的に言おう。python言語はオフサイドルールを採用しているので、for文で深いブロックのネストを書いて、インデントが狂うと死ぬ。
エディタ上でうっかりTabキーを叩くとこうなるという例をいくつか示す。
lst1 = [] for i in range(1,10): lst2 = [] for j in range(1,10): n = i*j if n % 3 == 0: print(n) lst2.append(n) lst1.append(lst2) pprint(lst1)
lst1 = [] for i in range(1,10): lst2 = [] for j in range(1,10): n = i*j if n % 3 == 0: print(n) lst2.append(n) lst1.append(lst2) pprint(lst1)
lst1 = [] for i in range(1,10): lst2 = [] for j in range(1,10): n = i*j if n % 3 == 0: print(n) lst2.append(n) lst1.append(lst2) pprint(lst1)
うっかりTabキーを叩いてしまった場所によって、コードは動かなくなったり、動かなくならなかったりする。
動かなくなる場合はまだ良い。エラーを見て修正できる。ただし、ケースによっては「どの行が出っ張っているのか、あるいは引っ込んでいるのか」容易に判断できないかもしれない。簡単に直して終わり、とはいかない。
動かなくならない場合は、最悪だ。処理のフローが変化したことに気づかないままプログラムを実行して、わかりづらいバグに頭を悩まされるかもしれない。インデントが狂ったことに気づいても、元々の処理を覚えていないと修復できない。この修復にはゼロから同じコードを書くのと同程度の労力が必要になるかもしれない。「自分が何を書いたのか思い出す」ことから始める必要があるのだから。
リスト内包表記にはこういう問題はない。理由は単純で、ブロックの境界が明確だからだ。エディタのハイライト支援も効くだろうから、カッコのネストを深くしていくのはそう難しくないし、for文で書いた場合と比べてバグの温床になる機会も圧倒的に少ない。うっかりカッコの位置をずらしてしまったり、開きカッコと閉じカッコの対応が付けられなくなっても、シンタックスエラーになるからflymakeを見ながら修正すれば良い。
おわかり頂けただろうか。
pythonのオフサイドルールは爆弾みたいなもので、そんなものを信頼してコードを書いていたら命が幾つあっても足りないのである。その点リスト内包表記は他の(オフサイドルールではない)言語のブロック構文と同様に信頼できる。だから、ブロックが重要になる処理(つまり大抵の処理)では、python使いは可能ならリスト内包表記で書きたいと思うのである。
では、pythonがオフサイドルールを採用したことは失敗だったのか? これはなんとも言えないだろう。オフサイドルールのデメリットは上記の通りだが、メリットも当然たくさんある。なんといっても見やすく、書きやすく、閉じカッコのために1行を費やしてディスプレイの有効活用できる範囲を狭めるなんて真似も必要ない。また、この記事で述べたように、オフサイドルールの言語ではブロックのネストを深めることは爆弾の山を積み上げる行為に等しい。言語自体にブロックのネストを抑制する機能が備わっていると解釈すれば、pythonを使うことで良質なコードが生まれやすくなる効果があるかもしれない(本当かよ)。
ただ、もしpythonにリスト内包表記という「インデントブロックの代用手段」がなければ、pythonはまるっきり使い物にならないプログラミング言語になったことは確実と思われる。まあ、それならmap,reduce,filterで代用したかもしれないが・・・
なぜpython使いはリスト内包表記を好むのか。それは、端的に言えば「for文だとインデントが狂いそうで怖い」というこの言語の悲しすぎる現実のせいだった。