allやanyを使って二重ループをスッキリさせる方法
はじめに
配列filesから、特定の日付文字列が含まれるものを除外したい。
ダウンロード済みのファイルから内容を読み込んでSQLにinsertする、というプログラムを組みました。ファイルは複数あり、毎日新しいファイルがネットに上げられます。新しいファイルをダウンロードしたらまたSQLにinsertします。
ファイルは圧縮されており、解凍には時間がかかるので、新たにダウンロードしたファイルのみをinsertしたいのです。
SQLには日付の項目があり、ファイル名にも日付が含まれています。これを利用して以下の工程で「insertされていないファイル」を抽出します。
- SQLから日付をDISTINCTして取り出す
- ダウンロード済みのファイル一覧を配列として取り出す(glob使用)
- 2の配列から1の日付がファイル名に含まれていないものを抽出する
これを簡単にしたものが以下のコードです。
inserted = ["20210708", "20210720", "20210730"]
files = [
"path/to/20210708.rar",
"path/to/20210720.rar",
"path/to/202107210.rar",
]
result = []
for file in files:
flag = False
for date in inserted:
if date in file:
flag = True
break
if not flag:
result.append(file)
assert len(result) == 1
assert result[0] == "path/to/202107210.rar"
ポイント
fileを1つずつ確認します。ファイル名にinserted
がどれも含まれていないならresult
に追加していきます。
つまりfiles
をループしながらinserted
を二重ループして確認していきます。
allとany
pythonには組み込み関数all
とany
があります。これを使って上記のコードを簡略化したいと思います。まずは機能の確認。
all
配列に含まれるもの全てが真なら真を返す
all([1, 1, 1]) # True
all([1, 1, 0]) # False
any
配列に含まれるもののうち、1つでも真なら真を返す
any([1, 0, 0]) # True
any([0, 0, 0]) # False
ジェネレーター式
allやanyは配列を引数に取りますので、リスト内包表記で書くことができます。
all([i < 10 for i in range(100)])
リスト内包表記の[]
を()
に変えるだけでジェネレーター式になります。
これも引数として有効です。
引数として渡す場合は()
を省略できるので以下のように書けます。
all(i < 10 for i in range(100))
リスト内包表記との違い
先に書いた通り、all関数は全てが真なら真を返します。逆に言えば「1つでも偽なら偽を返す」ことになります。
例えば[0, 0, 0]
という配列の場合、最初の0
で偽と確定するので残りの要素を見る必要はありません。
しかしリスト内包表記の場合、一旦配列を完成させてから引数として渡すことになります。
ジェネレーター式の場合、配列を作成しながら順に引数として渡します。
つまり、ジェネレーター式なら配列の途中で条件を満たした場合に処理を打ち切るので高速化できる可能性があります。
速度比較
例として10万回のループで比較してみます。
from timeit2 import ti2
def f1():
return all([i < 10 for i in range(10 ** 5)])
def f2():
return all(i < 10 for i in range(10 ** 5))
ti2(f1, f2)
共に10万回のループ中、iが10になった時点でFalseが返るのは決定します。
f1:
0.007387 sec
f2:
0.000002 sec
f1は10万回のループが発生しているのに対し、f2は途中で打ち切っていることがわかります。
ちなみに、timeit2は処理時間計測のpackageです。
ここではall
を例としましたが、any
だと1つでも真が見つかった時点で処理を打ち切りますのでジェネレーター式は有効になります。
(ちなみに偽となる為には全件の確認が必要になります)
使用例
ここで冒頭のコードを再掲します。
inserted = ["20210708", "20210720", "20210730"]
files = [
"path/to/20210708.rar",
"path/to/20210720.rar",
"path/to/202107210.rar",
]
result = []
for file in files:
flag = False
for date in inserted:
if date in file:
flag = True
break
if not flag:
result.append(file)
assert len(result) == 1
assert result[0] == "path/to/202107210.rar"
result作成部分を、anyを使って書き直してみます。
result = []
for file in files:
if not any(s in file for s in inserted):
result.append(file)
(s in file for s in inserted)
の部分でin
が2回出てくるのでわかりにくいのですが、s in file
とfor s in inserted
に分かれると考えれば理解しやすいと思います。
条件はnot any
であり、「全てがFalseならば」なので途中で打ち切ることはなく、ジェネレーター式の恩恵はありません。しかし大分スッキリしました。
result = list(filter(lambda file: not any(s in file for s in inserted), files))
さらにfilter
関数でまとめると1行で書くことができました。しかし若干読みにくくなっている気がします。
result
を再度ループさせる場合はlist化する必要はありません。
おまけ
pythonにはall
とany
がありますが、「全てが条件を満たさない」ものを判定するempty
のような関数はありません。
しかしnot any
とすることで実現できます。
def empty(l):
return not any(l)
li1 = [0, 1, 0]
li2 = [0, 0, 0]
li3 = []
assert empty(li1) is False
assert empty(li2) is True
assert empty(li3) is True
Discussion