装飾芸術の哲学

プログラムを作成する際、多くの機能を実装したものの、それらの機能に対して同じ追加操作を実行する必要が生じる状況に遭遇することがよくあります。これらの追加操作を各関数に記述することもできますが、コードが冗長になり、保守が困難になります。デコレータは、この問題を解決する優れた手段です。

デコレータとは、基本的に他の関数を「装飾」できる関数です。つまり、元の関数のコードを変更することなく、関数に機能を追加したり、実行状況を監視したりすることができます。

装飾品の使用

Pythonのデコレータは@記号とともに使用され、基本的にはパラメータなしとパラメータありの2つの一般的な構成があります。

# 不带参数
@decorator
def func():
    pass

# 带参数
@decorator(arg1, arg2)
def func():
    pass

Pythonには多くの組み込みデコレータがあります。例えば、クラスを作成する際に、 @dataclass使用して__init__メソッドを自動的に実装したり、 @propertyを使用して属性のgetterメソッドとsetterメソッドを実装したりできます。また、 @staticmethodと@classmethod使用して、それぞれ静的メソッドとクラスメソッドを実装することもできます。

人気のPythonウェブフレームワークであるFlaskの@app.route('/path')のようなパラメータを持つデコレータを使用すると、関数をURLにバインドでき、ユーザーがそのURLにアクセスしたときにその関数が実行されます。

建築装飾業者

初めてこれらのデコレータに出会ったとき、私は魔法のように感じました。関数に「ハット」を追加するだけで、特定のロジックを実現できたからです。今では、この言語をしばらく学んだ後、これらのデコレータの原理を自分の言葉で説明できるようになったと感じています。様々なチュートリアルでプログラミング言語の原理を説明する際にHello World例を用いるのは、あまりにも単純すぎるため不十分だと私は常々考えてきました。人々は、この構文構造が実際の使用においてもたらす利便性を理解できないからです。そこで、私は自分のプロジェクトから実際の例を用いて説明したいと思います。

データのダウンロードおよび取得要件

例えば、Webデータベースからデータを取得し、いくつかのフォーマット変換を行い、その結果を次の関数に渡してさらに処理を進める複雑な関数があるとします。この関数のコードは以下のとおりです。

def get_data(url, start_date, end_date, label_name):
    """从网络数据库中获取数据

    Args:
        url (str): 数据接口地址
        start_date (str): 开始日期 如:2023-03-11
        end_date (str): 结束日期 如:2023-03-12
        label_name (str): 标签字段名称

    Returns:
        list: 数据列表
    """
    # 从网络数据库中获取数据
    data = requests.get(
        url,
        params={
            'start_date': start_date,
            'end_date': end_date,
            'label_name': label_name
        }
    )
    # 转换成pandas的DataFrame格式
    data = pd.DataFrame(data)
    return data

私はデータ分析プロジェクトに取り組んでいるため、分析コードは常に改良されており、分析手順を書き直すたびに関数を再実行する必要があります。取得するデータ量が非常に大きく、ネットワーク速度が平均的な場合、データのダウンロードに時間がかかることがよくあります。

まず最初に考えたのは、ダウンロードしたデータをローカルに保存するように関数を修正することでした。pandasに詳しい方なら、このアプローチはお馴染みかもしれません。例えば:

data = pd.to_csv('data.csv')

そして、後で呼び出すときには、データを読み込むための別の関数を作成します。

def read_data():
    data = pd.read_csv('data.csv')
    # 熟悉的人可能也知道,pandas的to_csv和read_csv不是互为逆运算
    # 即read_csv读取的数据和保存之前的df不是完全一样的
    # 所以还需要再做一些格式转换。
    return data

この方法で一時的に問題は解決しました。しかし、プロジェクトを進めるにつれて、取得する必要のあるデータがますます多く、複雑になってきました。例えば、 get_data関数、 url 、日付範囲、その他のパラメータが変更されるようになりました。データ取得関数ごとに個別のread_data関数を作成する必要があるのでしょうか?

キャッシュメカニズム

この時点で、データの保存と取得のロジックを抽象化し、関数として記述し、各データ取得関数で保存と取得のロジックを同時に実行させる方法を思いつきました。これはインターネットブラウザのキャッシュメカニズムと同じです。ウェブページにアクセスすると、ブラウザはまずローカルマシンにキャッシュがあるかどうかを確認します。キャッシュがあれば直接使用し、なければサーバーからダウンロードします。これによりネットワークリクエストが減り、ページの読み込み速度が向上します。

データ分析プロジェクトでも同様の手法が使えます。初めてデータを取得する際に保存しておけば、次回同じデータを取得する際は、ネットワークから取得することなくローカルマシンから直接読み込むことができます。これによりネットワークリクエストが減り、データ取得速度が向上します。

ここで考慮すべき点がいくつかあります。ブラウザのキャッシュメカニズムでは、ブラウザがURLにアクセスすると、まずサーバーに問い合わせて、そのURLに対応するデータが更新されているかどうかを確認します。更新されている場合は、再度ダウンロードします。そうでない場合は、ローカルキャッシュを使用します。つまり、ブラウザは、データが更新されているかどうかに関する情報を取得するために、小さなリクエストを送信します。しかし、私のデータ取得プロセスでは、URLアクセスメカニズムを分解できないため、要求されたデータが既にダウンロードされているかどうかを判断する方法を見つける必要があります。解決策は簡単です。各アクセスからパラメータを保存し、次にURLにアクセスしたときに、まずこれらのパラメータが以前と同じかどうかを確認します。同じ場合は、ローカルキャッシュから直接読み込みます。異なる場合は、データを再度取得します。

この方法はまだ完璧ではありません。データ更新がアクセスパラメータと無関係な場合、問題が発生する可能性があります。しかし、現在のプロジェクトにおいては、この方法で十分です。ロジックは既に構築済みで、次のステップは実装です。このような機能においては、デコレータの利便性が非常に顕著になります。

具体的な実装

保存して読む

Pythonでは、 Pickle使ってデータを保存したり読み込んだりできます。Pickle Pickle Pythonに組み込まれているシリアライゼーションモジュールで、Pythonオブジェクトをバイトストリームに変換し、さらにバイトストリームをPythonオブジェクトに戻すことができます。

import pickle

# 保存数据
with open('data.pkl', 'wb') as f:
    pickle.dump(data, f)

# 读取数据
with open('data.pkl', 'rb') as f:
    data = pickle.load(f)

pickleの利点は、保存と読み込みが逆の操作であることです。つまり、Pythonオブジェクトをローカルに保存し、フォーマット変換を気にすることなく読み込むことができます。しかし、欠点は、データをテキストとして保存しないため、CSVファイルのように直接表示することができないことです。

ファイル名ハッシュ

パラメータに特殊文字が含まれていたり、関数にシリアル化できないオブジェクトが含まれていたりする場合があります。このような場合、パラメータをファイル名に直接保存することはできません。そこで、パラメータをハッシュ値に変換します。ハッシュ値の計算は一方向暗号化アルゴリズムであり、任意の文字列をハッシュ値に変換することはできますが、ハッシュ値から元の文字列を推測することはできません。

この変換方法は、ユーザーのパスワードをハッシュ値に変換し、そのハッシュ値をデータベースに保存できるため、パスワードの保存に広く用いられています。この方法であれば、たとえデータベースがハッキングされたとしても、ハッカーはハッシュ値からユーザーのパスワードを推測することはできません。

弊社では、キャッシュロジックにおいて、有効かつ一意なファイル名を生成するためにこれを使用しています。

このアルゴリズムは完璧ではないことを付け加えておきます。異なるデータから同じハッシュ値が生成される場合があり、これはハッシュ衝突と呼ばれます。ただし、ハッシュ衝突が発生する確率は極めて小さく、私のプロジェクトにおいては無視できるレベルです。

ここでは、パラメータをハッシュ値に変換するシンプルなハッシュ関数を記述します。

import hashlib

def params_to_hash(*args, **kwargs):
    """将函数的参数转换为32个字符串的哈希值

    Args:
        *args: 位置参数
        **kwargs: 关键字参数

    Returns:
        str: 参数的哈希值前32位
    """
    # 用'_'把参数拼接起来
    args_str = '_'.join([str(arg) for arg in args])
    kwargs_str = '_'.join([f'{key}:{value}' for key, value in kwargs.items()])
    # 用'.'把位置参数和关键字参数拼接起来
    total_str = '.'.join([args_str, kwargs_str])
    # 用sha256算法计算哈希值
    result = hashlib.sha256(total_str.encode('utf-8')).hexdigest()[:32]
    return result
装飾家

まず、パラメータのないデコレータを作成します。

from functools import wraps

def cache(func):
    # 使用python内置的functools.wraps装饰器
    # 保留原函数的元信息
    # 这样可以通过func.__name__获取到被装饰的函数名
    # 否则,func.__name__ 会变成 'wrapper'
    @wraps(func)
    def wrapper(*args, **kwargs):
        # 保存目录
        cache_dir = 'data'
        # 文件名
        cache_file = f'{cache_dir}/{func.__name__}/{params_to_hash(*args, **kwargs)}.pkl'

        # 保存和读取数据的逻辑
        def read():
            # 读取数据,直接返回数据
            with open(cache_file, 'rb') as f:
                return pickle.load(f)

        def exec_and_save():
            # 执行函数,获取结果
            result = func(*args, **kwargs)
            # 创建保存目录如果目录不存在
            os.makedirs(os.path.dirname(cache_file), exist_ok=True)
            # 保存结果
            with open(cache_file, 'wb') as f:
                pickle.dump(result, f)
            # 返回结果
            return result

        # 如果文件存在,就读取文件,否则就执行函数并保存结果
        if os.path.exists(cache_file):
            return read()
        else:
            return exec_and_save()
    return wrapper

これがその使い方です。

@cache
def get_data(url, start_date, end_date, label_name):
    ...

一度実行すると、作業ディレクトリ内にdata/get_dataディレクトリが作成され、そこにキャッシュされたデータが格納されます。

さて、私の要求はさらに高まりました。データの保存先となるフォルダ名を指定できるようにしたいのです。そのためには、デコレータにパラメータを追加する必要があります。先ほど使用したデコレータを修正してみましょう。

def cache(cache_dir='data'):
    def inner(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            cache_file = f'{cache_dir}/{func.__name__}/{params_to_hash(*args, **kwargs)}.pkl'

            def read():
                with open(cache_file, 'rb') as f:
                    return pickle.load(f)

            def exec_and_save():
                result = func(*args, **kwargs)
                os.makedirs(os.path.dirname(cache_file), exist_ok=True)
                with open(cache_file, 'wb') as f:
                    pickle.dump(result, f)
                return result

            if os.path.exists(cache_file):
                return read()
            else:
                return exec_and_save()
        return wrapper
    return inner

これにより、キャッシュディレクトリを指定できます。

# 不改变默认保存目录
@cache()
def get_data(url, start_date, end_date, label_name):
    ...

または

# 目录名改成api
@cache(cache_dir='api')
def get_data(url, start_date, end_date, label_name):
    ...

'@' は構文糖衣です

最後に、 @は Python の構文糖衣であることを述べておきます。その目的は、デコレータ呼び出しを関数定義の上に配置することで、元の関数が呼び出されるたびにラップする必要がなくなるようにすることです。

以下の構文は、関数定義の上に@を追加することと同等です。

# 对于不带参数的装饰器
get_data = cache(get_data)
# 对于带参数的装饰器
get_data = cache(cache_dir='api')(get_data)