オレオレデザインパターン:Glocal Variable


概要

Pythonのライブラリでたまに見る、「withの中でのみアクセスできるグローバル変数」という設計パターンに、オレオレの名前をつけてまとめてみた。

すでに名前がついてたらすいません。

例題

ある設定に基づき実験(関数experiment)を行うプログラムを書く。

実験は複数の関数(first second)に分割して記述されており、どちらも設定を参照してある実験操作を行う。

config0config1に基づき二回実験を行いたい。

ストレートに書くとこう。

# 本当はもっといろいろ設定がある
config0 = {
    "id": 0
}


config1 = {
    "id": 1
}


def first(conf):
    # なんかする
    print(f"{conf['id']}: first")


def second(conf):
    # なんかする
    print(f"{conf['id']}: second")


def experiment(conf):
    first(conf)
    second(conf)


def main():
    experiment(config0)
    experiment(config1)


main()

ただこの書き方だと、プログラムが複雑になったとき、設定をバケツリレーしていくのがやや面倒。なしですませられないか?

一つの解法はグローバル変数を使うことだが……。

conf = config0


def first():
    print(f"{conf['id']}: first")


def second():
    print(f"{conf['id']}: second")


def experiment():
    first()
    second()


def main():
    global conf
    experiment()
    conf = config1
    experiment()


main()

あきらかにこれはやばやば。

  • グローバル変数を使ったことで、変数に処理がどう依存するか追跡が難しくなる。
  • 上に加え、グローバル変数を途中で変化させていることで、状態の変化過程が追跡しきれずバグになりがち
    • 例えば今回の場合、mainを呼び出した後confconfig1になってることを忘れて、config0のつもりで再度mainを実行したりするとやばい

パターンの導入

バケツリレーを避け、グローバル変数の導入も避けたいということで、その中間的な書き方としてGlocal Variableパターンを紹介する。

config.py
from contextlib import contextmanager


_config = None
_initialized = False


@contextmanager
def configure(data):
    global _config
    global _initialized
    before = _config
    before_initialized = _initialized
    _config = data
    _initialized = True
    try:
        yield
    finally:
        _config = before
        _initialized = before_initialized


def get_config():
    if _initialized:
        return _config
    else:
        # 本当はもうちょっと真面目に例外投げるべき
        raise RuntimeError
from config import get_config, configure


def first():
    print(f"{get_config()['id']}: first")


def second():
    print(f"{get_config()['id']}: second")


def experiment():
    first()
    second()


def main():
    with configure(config0):
          experiment()
    with configure(config1):
          experiment()


main()
  • バケツリレーは避けることができた
  • グローバル変数に比べると安全
    • configureのコンテキストの中でしか変数が使えないので、自由度に制限がある
    • configを直接変化させる術がなく、withを通してしか設定できない
      • withの前後で必ずconfigが初期化・解放されるので、わけのわからない値が残っていてバグを起こす心配がない
    • ただし、「スコープ」(withの中)以外でget_configを呼び出しても、静的解析でエラーは拾えない

このように、

  • withの前後でグローバル変数を設定・初期化し
  • そのグローバル変数を読み出す関数を用意する

パターンをGlocal Variableと呼ぶことにする。

どういう時に使うか?

  • 多くの関数で共有したいデータが存在する
    • 面倒くさくないならバケツリレーを使えばいい
  • そのデータを動的に決める・変える需要がある
    • なければグローバル変数にしたほうがシンプル

実例

Pythonのライブラリではいくつか使われている。

  • 深層学習パッケージのmxnetでは、行列を計算するコンテキスト(CPU, GPU)をGlocal Variableで設定できる
  • Webフレームワークのflaskでは、リクエストパラメータをグローバル変数のように参照できるが、コード上で設定する場合はGlocal Variableパターンを使う

Racketだと、parametrizeというシンタクスが存在し、これが汎用のGlocal Variable機能を提供する。

バリエーション

デフォルト値

初期値をあらかじめ決めることもできる。先ほど言及したmxnetでは、CPUでの計算がデフォルト値になっている。

変更

セッターも用意すれば、with内でGlocal Variableを変更することもできる。

param.py
_param = None
_initialized = False


@contextmanager
def parametrize(data):
    global _param
    global _initialized
    before = _param
    before_initialized = _initialized
    _param = data
    _initialized = True
    try:
        yield
    finally:
        _param = before
        _initialized = before_initialized


def set_param(data):
    if _initialized:
        global _param
        _param = data
    else:
        raise RuntimeError


def get_param():
    if _initialized:
        return _param
    else:
        raise RuntimeError
from param import parametrize, set_param, get_param

with parametrize(3):
    with parametrize(4):
        print(get_param())
        set_param(2)
        print(get_param())
    print(get_param())
get_param()

# 4
# 2
# 3
# RuntimeError

読み取りしかできない場合に比べると、状態を追う努力が必要な分危険度は高まる。

だが状態変化の影響はwith内に限定できるので、グローバル変数に比べれば安全。

パーサなどを書く場合は、「まだ読んでいない文章」をGlocal Variableにして、少しずつ先頭から消費していくような書き方をすると、バケツリレーなしで書けて便利かも。

注意

Glocal Variableの値は、getterが記述された場所ではなく、実行されたタイミングで決まることに注意しよう。

def get_print_config():
    # この2ではなく
    with configure(2):
        def print_config():
            print(get_config())
        return print_config


print_config = get_print_config()
# この3が参照される
with configure(3):
    print_config()
# 3

備考

元々はPythonでも状態モナドのdo記法みたいな感じで書けないかなーと思って、flaskやmxnetのことを思い出し、こういうパターンあるなと気づいたのだった。