概要
Pythonのライブラリでたまに見る、「withの中でのみアクセスできるグローバル変数」という設計パターンに、オレオレの名前をつけてまとめてみた。
すでに名前がついてたらすいません。
例題
ある設定に基づき実験(関数experiment
)を行うプログラムを書く。
実験は複数の関数(first
second
)に分割して記述されており、どちらも設定を参照してある実験操作を行う。
config0
とconfig1
に基づき二回実験を行いたい。
ストレートに書くとこう。
# 本当はもっといろいろ設定がある
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
を呼び出した後conf
がconfig1
になってることを忘れて、config0
のつもりで再度main
を実行したりするとやばい
- 例えば今回の場合、
パターンの導入
バケツリレーを避け、グローバル変数の導入も避けたいということで、その中間的な書き方としてGlocal Variableパターンを紹介する。
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 = 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のことを思い出し、こういうパターンあるなと気づいたのだった。