目次
背景と結論
冒頭の処理は結局何してたのか
そもそもargparseとは
argparseの主要メソッドとその役割
vars(args)とは何か
なぜvarsは組み込み関数なのか
varsの裏で何が起きているのか
動的言語の仕様
蛇足:現場におけるvars(args)の使い方
感想
背景と結論
def main():
parser = argparse.ArgmentParser()
parser.add_argument("--env", required=True, type=str)
parser.add_argument(...)
args = parser.parse_args()
run_flows(args)
こちらは毎月実行されるバッチ処理のmain関数です。
これしか書いてないのに20時間くらいの処理が回ります。
しかも本番環境とテスト環境で処理の流れが分岐しています。
ここでふと疑問に思いました。
run_flowsでメイン処理が流れるけど渡しているのはargsのみmain関数は引数がない- それなのに、どこかで「環境変数」や「実行条件」が使われている。
「この引数はどこから何を渡しているんだ?」
そんな小さな疑問から始まり、調べていくと、
argparseというライブラリでCLIツールを作成しているargparse.parse_args()でコマンドライン引数を渡しているargparse.parse_args()が返しているのはNameSpace型のオブジェクト- その中身は
vars(args)で辞書として取り出せる - その正体は
__dict__というPythonオブジェクト共通の特殊属性
ということがわかってきました。
ちょっとした疑問が、思いがけずにPythonという動的言語のオブジェクト設計そのものまでつながった、というお話です。
本記事はそのような学びの流れをまとめたものです。
下記のような内容を扱います。
- Pythonにおけるコマンドラインツールの作り方
- コマンドライン引数をスクリプトに落とし込むには
- Pythonオブジェクトはどのように属性を保持しているのか
- 動的言語に共通するオブジェクト設計の仕方
argparseの使い方だけで終わらせず、「なぜこういう設計になっているのか」 まで掘り下げていきます。
冒頭の処理は結局何してたのか
冒頭の処理を再掲したのが下記。
def main():
parser = argparse.ArgmentParser()
parser.add_argument("--env", required=True, type=str)
parser.add_argument(...)
args = parser.parse_args()
run_flows(args)
このコードがやっていることをまとめると、「コマンドライン引数をPythonオブジェクトに変換し、そのオブジェクトを処理の入り口に渡している」という風になります。
Pythonオブジェクトに変換するためにargparseというライブラリを使用している、ということになります。
そもそもargparseとは
argparseとは、Python標準ライブラリの一つで、コマンドライン引数を定義・解析し、プログラム内で扱いやすい形に変換するためのライブラリです。
文字だけではイメージがつきにくいので、具体例を下記に載せておきます。
# 下記がコマンドラインに記載するコマンド例
python app.py --env prod --date 2025-01-01
# argparseの内部では下記のように属性が定義される
args.env # == "prod"
args.date # == "2025-01-01"
このように、コマンドラインで入力された変数をスクリプト内で処理できるように変換するのがargparseというライブラリです。
結果として、argparseはコマンドラインツールを作成するライブラリともいえそうです。
コマンドラインツールとは、bashなどのコマンドラインから引数を与えて実行し、標準入力・標準出力を通じて処理結果をやり取りするプログラムのことです。
要するに、通常Pythonはスクリプトを作成して実行しますが、そのスクリプトをコマンドラインから実行できるようにするものがargparseです。
argparseの主要メソッドとその役割
ArgumentParser()
parser = argparse.ArgumentParser()
- 役割
- コマンドライン引数の仕様を管理するオブジェクトの生成
- 以降の
add_argumentやparse_argsの土台となる
add_argument()
parser.add_argument("--env", required=True, type=str)
- 役割
- 受け取る引数のルールを定義する
- あくまで仕様の宣言をしているだけ
- ここではまだ値は存在しない
- 受け取る引数のルールを定義する
parse_args()
args = parser.parse_args()
- 役割
- 実際にコマンドライン引数を引き受ける
-
add_argumentで定義したルールに従って解析する - 結果を
NameSpaceオブジェクトとして返す
- 裏で処理していること
- コマンドラインから単なる文字列を引き受け、定義通りに型変換
- 必須チェック
- 不正引数の検出
- ヘルプ表示
上記の主要メソッドを使用して、コマンドライン引数をPythonオブジェクトに落とし込んでいます。
冒頭の処理ではargsという引数にparse_args()しているのですが、返却値は前述のとおりNameSpaceという特殊な型になっています。
中身を確認すると、NameSpace(env="prod", date="2025-01-01", ...)のような形で、コマンドライン引数がひとまとめになってargsになっていました。
このままでもargs.envのような指定をすれば個々の引数の値を取り出すことは可能ですが、
- コマンドライン引数の数が未知の場合がある
-
NameSpaceという特殊な型に依存しないほうが疎結合な設計が実現できる - デバッグや可視化を楽にしたい
といった理由があり、よりPythonで使いやすい型に落とし込む必要があります。
その方法が次のvars(args)です。
vars(args)とは何か
varsとは、オブジェクトが内部に持っている属性を、辞書として取り出すための組み込み関数です。
今回ですと下記のようなイメージになります。
# 【イメージ】argsがNameSpace型でデータを保持している
args = NameSpace(env="prod", date="2025-01-01", ...)
# varsを使用してdict型に変換
dict_args = vars(args)
dict_args = {
"env": "prod",
"date": "2025-01-01",
...
}
このように辞書型に変換することによって、コマンドライン引数を「名前と値の集合」として一括で扱えるようになります。
ここで気になるのが、なぜvarsはargparseのメソッドではなく組み込み関数なのでしょうか?
この答えが今回の調査で一番面白い部分です。
組み込み関数である、ということはPythonオブジェクト共通の挙動を利用しているということを意味します。
ここを深ぼることで、Pythonやほかの動的言語の仕様が見えてきます。
なぜvarsは組み込み関数なのか
varsが組み込み関数なのは、NameSpaceから辞書型に変換するのに利用した処理は、argparse固有の処理ではなく、Pythonオブジェクト共通の仕組みを利用しているからです。
varsについてもう少し正確に説明すると、「属性を持つPythonオブジェクト」から、その属性を辞書として取り出すための共通インターフェースとして用意されている関数です。
重要な点として、varsは
argparseを知らない-
NameSpace型を特別扱いしていない
という事実があります。
つまり、vars(args)が動くのは、argsがargparseのオブジェクトだからではなく、Pythonオブジェクトとして属性を持っているからです。
varsの裏で何が起きているのか
実はvars(args)という処理はargs.__dict__と同じ処理、というか前者が後者を呼ぶ関数になっています。
...よくわからないですね、順に説明します。
Pythonでは、多くのオブジェクトが内部に__dict__という辞書を持ち、そこに属性名と値の対応関係を保持しています。
これは、Pythonのクラスが最終的にobjectクラスを基底に持ち、「属性を辞書として管理する」という設計を採用しているためです。
ですので、argparseだけではなく、下記のように自作のクラスにも__dict__は存在しています。
class Config:
def __init__(self):
self.env = "prod"
self.date = "2025-01-01"
config = Config()
print(vars(config)) # {"env": "prod", "date": "2025-01-01"}
print(config.__dict__) # {"env": "prod", "date": "2025-01-01"}
print(vars(config) is config.__dict__) # True
ここまでを見ると、
vars(obj)はobj.__dict__をそのまま返している__dict__は Python オブジェクトが属性を保持するための辞書である
ということが分かります。
では、なぜ Python はこのような設計を採用しているのでしょうか。
動的言語の仕様
Pythonは動的言語であり、実行時にオブジェクトへ自由に属性を追加・変更できます。
config = Config()
config.env = "prod"
config.new_param = 123
このような操作を可能にするためには、
- 属性名が事前に固定されていない
- 実行時に属性が増減する
という前提を受け入れる必要があります。
この要件を満たすために、Pythonでは「属性 = 名前と値の対応関係」を辞書(dict)として管理する設計 が採用されています。
Pythonにおいて、キーと値の対応関係を柔軟かつ高速に扱うための基本的なデータ構造が dict(ハッシュマップ)です。
ハッシュマップの解説は下記記事をご覧ください。
https://zenn.dev/zenn_mita/articles/f9811270b871d5
そのため、
- オブジェクトの属性
- CLI引数
- 設定情報
といった「名前付きデータの集合」は、自然に辞書として表現されることになります。
obj.__dict__ は、そのための標準的な保存場所です。
話を戻すと、argparseが返すNamespace も、このPythonの設計思想に従ったオブジェクトにすぎない、ということになります。
コマンドライン引数という「名前と値の集合」を、Pythonが標準で扱いやすい形(属性+辞書)に落とし込んでいるだけなのです。
蛇足:現場におけるvars(args)の使い方
実務ではアンパックを利用してコマンドライン引数を関数に一気に渡す、という処理にも使われていました。
args = ArgsDataClass(**vars(args), ...)
アンパックとは、辞書型のキーと値を、そのままキーワード引数として展開する構文です。
{"env": "prod", "date": "2025-01-01"}という辞書が渡されたとすると、
ArgsDataClass(env="prod", date="2025-01-01", ...)という形で引数列に展開されるようになっています。
この処理によって、
- コマンドライン引数をそのままデータクラスへ変換できる
-
argparse.Namespaceに依存しない形で値を受け渡せる - 引数定義を型付きのクラスとして明示できる
といったメリットがあります。
特に、argparseは実行条件の取得までを担当し、その後の処理ではデータクラスや辞書を使ってロジックを組み立てることで、責務を分離した読みやすい設計が可能になります。
このようにvars(args)は、CLI引数を確認するための手段であると同時に、Pythonの標準的なデータ構造へ変換するための「橋渡し」として、実務でもよく利用されています。
感想
説明がながーーーーーーーーい!!!!
ちょっと引数の入れ方を調べたかっただけなのに...
- 引数の流れわからん
- 調べたがコマンドラインで打っているコマンドの意味が分からない
- argparseで引き受けているのは分かったが、具体的にどういう処理流れているのかわからない
- args.○○でいろいろしているのは分かったが、その裏で何が起こっているのかわからない
- **vars(args)でアンパックしているのも見つけたがなぜアンパックできるのかわからない
- __dict__が属性を保持しているのは分かったがargparseとのつながりがわからない
- Pythonの属性を持つオブジェクトは皆ObjectというCPythonのクラスを継承していて、そいつが__dict__を持っていることを理解
- それがPythonだけでなくJavaScript等の別動的言語でも同じように設計されていることを知る
- 結局何を調べているのかわからなくなったのでまとめてみよう
- 記事化
という流れ。
掘っても掘っても新しくデカい知識が出てくる感覚から、幼稚園の頃に行った芋ほりを思い出しました。
想定外の小さな疑問から、プログラミング全体に繋がる知識が掘れることもあると知れたので、これからもいろいろ掘っていきます。