"BOKU"のITな日常

還暦越えの文系システムエンジニアの”BOKU”は新しいことが大好きです。

pythonの内包表記構文とジェネレータの使い方と注意点をまとめる

pythonの強力な機能である「内包表記」と「ジェネレータ」について、使い方や使用上の注意点などについて、一度まとめておこうと思います。

f:id:arakan_no_boku:20190427205325j:plain

 

内包表記

 

内包表記は、forループを簡潔に書けて、かつ、圧倒的に実行速度が速い優れモノです。

 

listの内包表記

 

一番シンプルなリストの例で書くと。

こういうforループと同じものが。 

L = []
for i in range(num):
    L.append(i)

内包表記 にするとこんな感じで書けます。

[i for i in range(num)]    

試しに、どのくらい速度が違うかを「timeit」を使って計測してみます。 

上記をそれぞれ関数にして、1回あたり10000回のループを1000回繰り返した結果の時間を出してます。

1回とかにすると、時間が短すぎるので。

import timeit

def sample_loop(num):
    L = []
    for i in range(num):
        L.append(i)
    return L

def sample_comprehension(num):
    return [i for i in range(num)]

repeat_count = 10000
loop_count = 1000

result = timeit.timeit(
    'sample_loop(repeat_count)',
    globals=globals(),
    number=loop_count)
print(result)

result = timeit.timeit(
    'sample_comprehension(repeat_count)',
    globals=globals(),
    number=loop_count)
print(result)

自分のPCで実行してみた結果はこちらです。 

通常のループ:785ミリ秒

内包処理使用:379ミリ秒

だいたい、倍くらい違いますね。

 

辞書の内包表記

 

辞書の内包表記の構文の意味合いはこんな感じです。

{格納する要素のkey:格納する要素のvalue for 配列内要素 in 配列}

ただ、日本語で理解しようとしても、たぶん、よくわからないので例で書きます。

例えば。

name_rank = {'高橋君': 'A001', '松本君': 'A002', '石田君': 'A003'}

 このように「名前(name)」がキーで、「格付(rank)」が値(Value)の辞書があった時に、キーと値を逆転させて、「格付(rank)」がキーで、「名前(name)」が値(value)のの辞書にする場合なら以下のようになります。

name_rank = {'高橋君': 'A001', '松本君': 'A002', '石田君': 'A003'}
rank_name = {rank: name for name, rank in name_rank.items()}
print(rank_name)

このプリントの結果はこうです。 

 {'A001': '高橋君', 'A002': '松本君', 'A003': '石田君'}

意図の通りに入れ替わってます。

 

集合(Set)の内包表記

 

Setの内包表記は、辞書のValueがないようなもんなので、こんな感じです。

{格納する要素 for 配列内要素 in 配列}

これも例で見た方がわかりやすいので、上記辞書で使ったものを使ってやってみます。

name_rank = {'高橋君': 'A001', '松本君': 'A002', '石田君': 'A003'}

 このように「名前(name)」がキーで、「格付(rank)」が値(Value)の辞書があった時に、キーの「名前(name)」の集合(Set)を作る場合にはこうなります。

name_rank = {'高橋君': 'A001', '松本君': 'A002', '石田君': 'A003'}
name_set = {name for name in name_rank.keys()}
print(name_set)

このプリントの結果はこんな感じ 

{'石田君', '高橋君', '松本君'}

 キーの名前だけの集合(Set)になってます。

ただ、辞書の時の並び順にはならなないという所は注意が必要ですけどね。

 

ジェネレータとヘルパー関数

 

前述の内包表記は、慣れると式が少なければ意外に読みやすいですし、処理速度も速いので積極的に利用したほうが良いと思ってます。

ただ。

万能の特効薬とはいきません。

2つ問題があります。

  • 入力するデータ量が大量だと、大きなメモリを消費してしまう。
  • 内包表記の式の数が増えると、非常に読みづらくなる

です。

これらに対する対策法です。

 

入力するデータ量が大量だと、大きなメモリを消費してしまう。

 

例えば、リスト内包表記のこんな使い方が該当します。

value_list = [len(x) for x in open('big_text_file.txt')]

巨大なデータを一気にメモリに読み込もうとするので、ファイルがでかいと、最悪プログラムがクラッシュします。

これを回避するには「ジェネレータ式」を使います。

書き方はこうです。

value_list = (len(x) for x in open('big_text_file.txt'))

違いは、リスト内包表記構文を()で囲っているだけです。

この書き方をすると、valueにデータは展開されず、単にイテレータとして評価されるだけです。

それを取り出す方法は2通り。

  • 次の値ひとつを取り出す「next(value_list)」
  • forループの中でイテレータとして処理する

です。

 

こうすることで、元データがどんなに大きくても問題なくなります。

なんせ・・、メモリにデータを読み込みませんから。

高速に動作して安全なのですが、1点だけ注意が必要な部分があります。

それは、ジェネレータ式が返すイテレータは「ステートフル」であることです。

ステートフルとは「状態を保持して結果がその影響をうける」みたいな意味なのですが、ようするに、繰り返して使った時に同じ結果が返ってくることが保証されない=1回限りしか使えない前提で利用しないといけない・・ことです。

 

内包表記の式の数が増えると、非常に読みづらくなる

 

内包表記のネストが増えたり、条件分岐がはいったりすると非常に読みづらいのは誰でもわかります。

例えば。

こんな処理を考えます。

tmp_lists = [
    [[1, 2, 3], [4, 5, 6]],
    [[7, 8, 9], [10, 11, 12]],
]

flat_list1 = [x for sub1 in tmp_lists for sub2 in sub1 for x in sub2]
print(flat_list1)

tmp_lists という3次元リストを、1次元リストにフラット化する処理です。

上記の内包表記によって「[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]」という1次元リストに変換されるのですが・・・、実に読みづらいです。

やっぱ、ソースは読みやすく見通しが良い・・ってのが一番なので、これは、以下のように普通にforをネストさせて、その処理を関数化した方がいいです。

def to_flat_list(tmp_lists_arg):
    flat_list = []
    for sub1 in tmp_lists_arg:
        for sub2 in sub1:
            flat_list.extend(sub2)
    return flat_list

これの利用イメージです。

tmp_lists = [
    [[1, 2, 3], [4, 5, 6]],
    [[7, 8, 9], [10, 11, 12]],
]

flat_list2 = to_flat_list(tmp_lists)
print(flat_list2)

こういうやり方を「ヘルパー関数」とか言うらしいですが、 読みやすさが全然違うと思います。

これも結果は「[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]」という1次元リストに変換されます。

 

ヘルパー関数でデータが巨大である可能性があるとき

 

たいていは、上記でいけます。

でも、前で「データが巨大だったら・・」のケースをやりましたが、ヘルパ関数の場合でも最終的に生成済のリストを返すわけなので、かなりメモリを食いつぶすリスクは残ってます。

こんな時はやっぱり「ジェネレータ」です。

ヘルパ関数の場合のジェネレータには「yield」を使います。

ループの中で返したい値の生成時に「yield」をつけると、上で説明したジェネレータ式と同様にイテレータを返すヘルパ関数になります。

替えられたイテレータ\から値を取り出す方法は2通り。

  • 次の値ひとつを取り出す「next(value_list)」
  • forループの中でイテレータとして処理する

です。

さきほどの例を「ジェネレータ」化した例はこんな感じです。

def to_flat_list_yield(tmp_lists_arg):
    flat_list = []
    for sub1 in tmp_lists_arg:
        for sub2 in sub1:
            yield sub2

この利用例です。

tmp_lists = [
    [[1, 2, 3], [4, 5, 6]],
    [[7, 8, 9], [10, 11, 12]],
]

tmp_gen = to_flat_list_yield(tmp_lists)
flat_list3 = []
for x in tmp_gen:
    flat_list3.extend(x)
print(flat_list3)

returnではなく、yieldで返します。 

returnは処理済の結果を返しますが、yieldはイテレータを返します。

なので、上記のtmp_genの場合だと

  • 次の値ひとつを取り出す「next(tmp_gen)」
  • forループの中でイテレータとして処理する

の2通りのやり方の中から後者(forループの中でイテレータとして処理)を選んで、ループを回して受取側で一次元リストを組み立てている感じになります。

若干、関数の利用者側が面倒ですが「メモリ不足な環境なのに、データ量が多くて、でかいリストができてしまう恐れがある」場合などの対策にはなりえます。

 

まとめ

 

リストの内包表記もジェネレータも両方共便利で素晴らしい機能です。

しかし。

しばらく使わないでいると「あれ?どうやったかな」となりやすい機能のひとつではあります。

なので、積極的に使っていきたいと思ってはいます。

いまのとこ。

ではでは。