2018/09/25

Python Tips: functools.reduce() を活用したい

Python が標準で提供する関数のひとつに functoolsreduce() があります。

from functools import reduce

reduce() は一見使いどころがわかりづらいのですが、活用できるようになるととても便利な関数です。今回はそんな reduce() について、 使いどころと実践的なサンプル をご紹介してみたいと思います。

使いどころ


reduce() は「 シーケンス → ひとつの値 」という処理をしたいときに利用できる関数です。関数でいえば「引数にシーケンスを受け取り、何らかの処理をして、戻り値をひとつだけ返す」というふるまいの関数を書きたいときに使えます。

このパターンは標準的なビジネスロジックの中にたくさん登場します。いくつか例をあげてみます。

注文の合計金額を計算する: 「注文のラインアイテム」というシーケンスから「合計金額」を計算する。

複数の権限がすべて満たされているかをチェックする: 「複数の権限」というシーケンスから「操作の可否」を計算する。

複数のロールのうちひとつでもユーザが所属するものがあるかをチェックする: 「複数のロール」というシーケンスから「ユーザの所属の有無」を計算する。

リストを連結する: 「複数のリスト」というシーケンスから「連結したリスト」を計算する。

集合の共通部分を取る: 「複数の集合」というシーケンスから「共通部分」を計算する。

集合の和集合を取る: 「複数の集合」というシーケンスから「和集合」を計算する。

複数のフラグをまとめる: 「複数のフラグ」というシーケンスから「合算したフラグ」を計算する。

reduce() については「 reduce() を使うと [3, 5, 10]150 といった数列の積の計算がかんたんにできます」といったシンプルな例を使った說明がよくなされますが、それだけ聞いても「え、それ、業務のプログラミングで使います?」となりがちです。対して、上のような実際にありそうな例を見ると「 reduce() が活用できる場面は意外に多いなぁ」と思えるのではないかと思うのですがいかがでしょうか。

続いて、上にあげた例のいくつかに対してサンプルコードを見てみましょう。

実践的なサンプル


注文の合計金額を計算する

注文のラインアイテムから合計金額を計算します。

from collections import namedtuple
from functools import reduce

LineItem = namedtuple('LineItem', ['商品ID', '単価', '数量'])

def calc_total(items):
    """ラインアイテムの合計金額を計算する"""
    return reduce(lambda accum, line_item: accum + line_item.単価 * line_item.数量, items, 0)

line_items = [
    LineItem('shirt a', 1000, 2),
    LineItem('shirt b', 1200, 1),
    LineItem('shirt c', 4000, 2),
]

calc_total(line_items)
# => 11200

関数 calc_total() は各ラインアイテムの単価と数量を見てその合計金額を計算する関数です。 reduce() の中の accum には「単価 ✕ 数量」の値が蓄積され、最終的に注文全体の合計金額が出ます。

余談ですが、 accum の値がどのように変化していくのかを見たい場合は lambda をローカル関数に差し替えて中身を出力するとよいでしょう。

def calc_total(items):
    """ラインアイテムの合計金額を計算する"""
    def _sumproduct(accum, line_item):
        print(accum)
        return accum + line_item.単価 * line_item.数量

    return reduce(_sumproduct, items, 0)
    # return reduce(lambda accum, line_item: accum + line_item.単価 * line_item.数量, items, 0)

calc_total(line_items)
# 0
# 2000
# 3200
# => 11200

ただ、この処理は、例えば内包表記を使って次のように書くこともできます。

def calc_total(line_items):
    return sum(x.単価 * x.数量 for x in line_items)

reduce() を使った書き方がしっくり来ない場合はムリに reduce() を使わず、そのときどきでコードの意図がわかりやすい・メンテナンスしやすい書き方を選ぶとよいかと思います。

複数の権限がすべて満たされているかをチェックする

あらかじめ定義されている権限を対象ユーザがすべて持っているかどうかをチェックします。

from functools import reduce


class User:
    def __init__(self, permissions):
        self._permissions = permissions

    def has_perm(self, permission):
        return permission in self._permissions

    def has_all_perms(self, permissions):
        """指定された権限をすべて持っているかチェックする"""
        return reduce(lambda accum, p: accum and self.has_perm(p), permissions, True)


required_permissions = ('perm_a', 'perm_b', 'perm_c')


user1 = User(('perm_c',))
print(user1.has_all_perms(required_permissions))
# => False

user2 = User(('perm_a', 'perm_b', 'perm_c'))
print(user2.has_all_perms(required_permissions))
# => True

クラス User のメソッド has_all_perms() は、 list あるいは tuple で渡された一連の権限をユーザがすべて持っているかどうかをチェックします。 reduce() を使うことでわかりやすくシンプルに書くことができています。

上の「注文の合計金額」が sum() を使って書けたのと同様に、こちらは all() と内容表記を使って書くこともできます。

def has_all_perms(self, permissions):
        """指定された権限をすべて持っているかチェックする"""
        return all(self.has_perm(p) for p in permissions)

こちらも all() の方が読みやすくメンテナンスしやすいのであればムリに reduce() を使う必要はありません。

ちなみに、上の「 複数のロールのうちひとつでもユーザが所属するものがあるかをチェックする 」はこのチェック対象のデータが権限からロールに変わって all()any() に変わるようなイメージです。

リストを連結する

各要素がリストのリストに対して、その要素をすべて連結した大きなリストを生成します。

from functools import reduce


def concat_lists(lists):
    """リストを連結する"""
    return reduce(lambda accum, x: accum + x, lists, [])


lists1 = [[1, 2, 3], [5, 8], [13, 21, 34, 55]]
print(concat_lists(lists1))
# => [1, 2, 3, 5, 8, 13, 21, 34, 55]

関数 concat_lists() は、リストからなるリストの各要素を連結します。こちらも reduce() を使うことで、わかりやすく簡潔に書くことができています。

もし処理の途中経過が見たければ、上で述べたように lambda の部分を print() 等で出力を行うローカル関数に差し替えるとよいでしょう。

def concat_lists(lists):
    """リストを連結する"""
    def _extend(accum, x):
        print(accum)
        return accum + x
    return reduce(_extend, lists, [])
    # return reduce(lambda accum, x: accum + x, lists, [])

lists1 = [[1, 2, 3], [5, 8], [13, 21, 34, 55]]
print(concat_lists(lists1))
# []
# [1, 2, 3]
# [1, 2, 3, 5, 8]
# [1, 2, 3, 5, 8, 13, 21, 34, 55]
# => [1, 2, 3, 5, 8, 13, 21, 34, 55]

この concat_lists() の処理は sum() を使って書くことも可能です。

def concat_lists(lists):
    """リストを連結する"""
    return sum(lists, [])

この単純な例だと sum() を使った方がむしろわかりやすいかもしれませんね。

集合の和集合を取る

複数ある集合の和集合を計算します。

from functools import reduce


def union_multiple(*sets):
    """和集合を作る"""
    return reduce(lambda accum, x: accum | x, sets, set({}))


set1 = {'鹿児島', '宮崎', '熊本'}
set2 = {'大分', '佐賀'}
set3 = {'長崎', '福岡'}

print(union_multiple(set1, set2, set3))
# => {'熊本', '長崎', '福岡', '大分', '宮崎', '佐賀', '鹿児島'}

関数 union_multiple() は任意の数の引数( set )を受け取って、それらの和集合を返す関数です。 Python では 2 つの集合の和集合は set_a | set_b (あるいは set_a.union(set_b) )で計算できるので、ここで必要なのは reduce() を使ってそれを蓄積していくことだけです。

標準ライブラリ operator になじみがあって、コードのわかりやすさが損なわれないと考えられるのであれば、同じ処理は operator.or_ を使って次のように書いてもよいでしょう。

from functools import reduce
from operator import or_


def union_multiple(*sets):
    """和集合を作る"""
    # return reduce(lambda accum, x: accum | x, sets, set({}))
    return reduce(or_, sets, set({}))

Python では +-*/&| 等の 2 項演算子をオブジェクトとして扱うことができませんが、その代わりに operator の中にそれらに相当する関数が用意されています。

一例:

  • +: operator.add() または operator.concat()
  • -: operator.sub()
  • *: operator.mul()
  • /: operator.truediv()
  • &: operator.and_()
  • |: operator.or_()

ちなみに、 reduce()operator が提供する関数を知っておくとよりシンプルにわかりやすく書けることがあるので、 reduce() を活用したい方は operator もあわせてチェックしておくとよいかもしれません。

尚、上の「 集合の和集合を取る 」と「 複数のフラグをまとめる 」については、ここではサンプルコードは示しませんがこの union_multiple() と同じような考え方で書くことができます。

応用的なサンプル


ちょっと応用的(トリッキー?)な例もいくつかあげてみます。 reduce() をむやみやたらと使ってしまうとかえってコードがわかりづらくなりますが、こういう使い方もできるということを知っておくと実装の選択肢の幅が広がってよいのではないかと思います。

入れ子の辞書の要素にアクセスする

これは Stack Overflow で紹介されていた使い方です。私は目からウロコでした。

入れ子になった dict がありそれを掘り下げる一連のキーが変数として与えられたときに、要素にアクセスするロジックを reduce() を使ってスムーズに書くことができます。

from functools import reduce


def dict_deep_access(adict, key_tree):
    """ネストされた dict の要素にアクセスする"""
    return reduce(lambda elem, key: elem[key], key_tree, adict)
    # 次のように書くこともできる
    # return reduce(dict.__getitem__, key_tree, adict)


d1 = {
    '沖縄': {
        '恩納村': {
            '万座毛': '隆起サンゴの断崖',
            'なかゆくい市場': '道の駅',
            '真栄田岬': '夕日スポット',
        }
    }
}

key_trees = [('沖縄', '恩納村', '万座毛'), ('沖縄', '恩納村', '真栄田岬')]

for tree in key_trees:
    print(dict_deep_access(d1, tree))
# =>
# 隆起サンゴの断崖
# 夕日スポット

特定のディレクトリからの相対パスでアクセス

特定のディレクトリから対象ファイルへの相対パスが list として与えられたときに、対象ファイルのパスを生成するというものです。

from functools import reduce
from pathlib import Path


def file_relative_from(root, relative_path):
    """指定された場所からの相対パスでファイルを取得する"""
    file = reduce(lambda parent, dir: parent / dir, relative_path, root)
    return file.resolve()


path1 = Path('/private/tmp/abc/abc1.txt')
relative_path1 = ['..', '..', 'def', 'def1.txt']

file_relative_from(path1, relative_path1)
# => /private/tmp/def/def1.txt

関数の組み合わせ

複数の関数を組み合わせて順次適用していくというものです。

from functools import reduce


def apply_filters(seq, filters):
    """シーケンスに複数のフィルタを連続で適用する"""
    return reduce(lambda result, fn: fn(result), filters, seq)


filters = (
    lambda seq: [x.upper() for x in seq],
    lambda seq: [x.center(12) for x in seq],
    lambda seq: ['⚡ {} ⚡'.format(x) for x in seq],
    lambda seq: '\n'.join(seq),
)

関数 apply_filters() は、第 2 引数に callable のシーケンスを受け取り、その要素を第 1 引数に順次適用した結果を返してくれます。

apply_filters()filters を組み合わせると次のような処理になります。

apply_filters(['teenage', 'mutant', 'ninja', 'turtles'], filters)
# =>
# ⚡   TEENAGE    ⚡
# ⚡    MUTANT    ⚡
# ⚡    NINJA     ⚡
# ⚡   TURTLES    ⚡

apply_filters(['genetically', 'modified', 'punk', 'rock', 'pandas'], filters)
# =>
# ⚡ GENETICALLY  ⚡
# ⚡   MODIFIED   ⚡
# ⚡     PUNK     ⚡
# ⚡     ROCK     ⚡
# ⚡    PANDAS    ⚡

この処理は例えば map() や内包表記を使うと次のように書くこともできます。

# 内包表記で書いた場合
def apply_filters_hard_1(seq):
    return '\n'.join(
        '⚡ {} ⚡'.format(x) for x in (
            x.center(12) for x in (
                x.upper() for x in seq
            )
        )
    )

# map() で書いた場合
def apply_filters_hard_2(seq):
    return '\n'.join(
        map(
            lambda x: '⚡ {} ⚡'.format(x),
            map(lambda x: x.center(12),
                map(lambda x: x.upper(),
                    seq
                )
            )
        )
    )

・・・が、これだと関数の適用順序とは逆に lambda を書かなくてはなりません。このように書くぐらいであれば各処理の結果を変数に格納し行を分けて書いた方がよいでしょう。

サンプルの紹介は以上です。

まとめ


というわけで、 Python の reduce() の使いどころと実践的なサンプルについてでした。

これは受け売りですが、 reduce() については、要は「 sum()all()any() 等の「シーケンスからひとつの値を生成する」タイプの処理を抽象化したものが reduce() 」という捉え方ができます。もし OOP のクラスと同じような親子関係(継承関係)が関数にもあるなら、 reduce()sum()all() の親関数、と捉えてもよさそうです。

余談ですが、 Python ・ Ruby ・ JavaScript あたりではこの操作を行う関数に reduce という名前がつけられていますが、関数型ベースの言語においては reduce よりも fold という名前が選ばれることが多いような気がします。

以上です。

関連記事


参考

reduce() については以下のページ等もおもしろいです。

0 件のコメント: