🖌

allやanyを使って二重ループをスッキリさせる方法

2021/10/01に公開

はじめに

配列filesから、特定の日付文字列が含まれるものを除外したい。

ダウンロード済みのファイルから内容を読み込んでSQLにinsertする、というプログラムを組みました。ファイルは複数あり、毎日新しいファイルがネットに上げられます。新しいファイルをダウンロードしたらまたSQLにinsertします。

ファイルは圧縮されており、解凍には時間がかかるので、新たにダウンロードしたファイルのみをinsertしたいのです。

SQLには日付の項目があり、ファイル名にも日付が含まれています。これを利用して以下の工程で「insertされていないファイル」を抽出します。

  1. SQLから日付をDISTINCTして取り出す
  2. ダウンロード済みのファイル一覧を配列として取り出す(glob使用)
  3. 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には組み込み関数allanyがあります。これを使って上記のコードを簡略化したいと思います。まずは機能の確認。

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です。

https://pypi.org/project/timeit2/

ここでは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 filefor 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にはallanyがありますが、「全てが条件を満たさない」ものを判定する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
GitHubで編集を提案

Discussion