- アプリケーションコードにおいて、デフォルト値は原則として使用しない方がよい
- ライブラリ/フレームワーク/ツールのような、不特定のユーザーが使用するコードではない
- 主にWebアプリケーションの開発
- 一般性があるかもしれないが、いったんスコープ外
- 複数人開発かつスキルベースがある程度バラバラの想定
- とはいえ、個人開発でも趣旨は変わらないはず
- 「デフォルト値」という考え方は、プログラミングやシステム開発のいろんな箇所で出てくる
- ただ、プログラミング言語やライブラリやフレームワークやサービスが、デフォルト値を「提供している」ということとアプリ開発者が「使用する」ということは完全に別
- 雑なデフォルト値の指定は、単にコードの複雑性を上げるだけになっている
- ひどいコードのリファクタリング案件では、だいたいこの問題がある
- プログラミング言語の提供機能
- Pythonのdict.get
- 例:
{"a": 1}.get("b", 2) = >2
- 例:
- Pythonの関数の引数
- 例:
def get_x(z: int = 1) => 1
- 例:
- Pythonのor演算子
- 例:
a or 2 => 2
- 例:
- Pythonのgetattr
- 例:
getattr(obj, "a", True) => True
- 例:
- Pythonのdict.get
- DB(ORM)のデフォルト値
- DjangoのModel
- 例:
models.IntegerField(default=0) => 0
- Null許可も似たようなもの
- 例:
- DjangoのModel
- オワってる部類のコードのリファクタリング案件では、いろんな箇所でデフォルト値を指定していて、かつ防御的なチェックを大量に入れている
- 可読性がかなり下がっているし、たいてい抜けがありバグやデグレにつながっていて、自分の首を締めている
- 複雑性を自ら上げて問題を難しくしていることに無自覚
- 感覚的には「コード記述時にとりあえずでラクしようとした結果、大量のツケがまわってきて、どうにもならなくなっている」という症状
- 実際には、厳密な定義でコードを記述した方がどんどん簡単になる
- 「継承は悪」という言説が広まって久しいが、デフォルト値は継承のようなもの
- 継承は「挙動を変える」ためのもので、デフォルト値は「挙動を変えない」ためのもの
- いずれにせよ、雑に使うと複雑性が上がる
- その割には、あまり悪という風潮はなく、なんとなく使われがち
- 継承と似ているという意味では、1段ならまだしも、複数の引数や多段になることで問題が起きやすい
- A関数 -> B関数 -> C関数 とたらい回しにする実装の場合、実質的に全部読まないと意図が理解できず、デフォルト値の指定が実質的に意味をなさない
- 処理がコード上近くに記述されているならある程度把握しやすいが、実際にはモジュールが分かれていて、コードジャンプが多発することが多い
- コードリーディングの負荷が高くなる
- 「引数や変数がどうなるか」というところまでは把握できても「デフォルト値として指定している意図」がコードからは読み解けなくなる
- 「とりあえずよくわかんなくて挙動を変えたくなかっただけなんだろうな」という感じのエスパーになりがち
- 「デフォルト値を定義する」ことで「相応に複雑性を上げる」という覚悟をもって記述する必要がある
- 言い方を変えると、将来の他の誰かに負荷を投げるという意識をもつ
- 結局のところ「ある関数がどのように利用されるか」ということを把握していない(orサボっている)だけに見えることも多々ある
- 関数の使用箇所がすべて把握できているなら、すべて明示的に値を渡せる(=デフォルト値が不要になる)
- 例: 「Aの場面では必ずTrueで、B,Cの場面では必ずFalseになる」と記述できる
- 関数の使用箇所がすべて把握できているなら、すべて明示的に値を渡せる(=デフォルト値が不要になる)
- 改修時に影響範囲をちゃんと調べていないから、雑にデフォルト値をとって以前の挙動を変えないようにするパターンもある
- 感情的には分からなくもないが、基本的には影響範囲を調べてないという雑な作業をしている
- 雑に済ませること自体の是非はいったん置いておくが
- 影響範囲を調べるのが難しくなっているのなら、リファクタリングが先に必要
- リファクタリングさえ難しいのなら別の問題
- 感情的には分からなくもないが、基本的には影響範囲を調べてないという雑な作業をしている
- 初期値として空文字や0やNoneをデフォルト値に指定していることがあるが、別のユースケースとして関数を分けるべき
- 「値が渡されていなければ初期値を設定する」じゃなくて、「初期値を設定したユーザーを作成する」というユースケースにならない?
- 「Noneをチェックしないで直接初期値をデフォルト値に入れた方が簡潔でいいじゃん」という主張はもっともらしいが、「動いていればそれでいいじゃん」とほぼ同義。短く早く書けて自分のタスクがすぐに終わることを優先しているだけで、開発継続性については気にしておらず、他の箇所で問題を増やすようなコードを書く可能性が高い
# NG
def create_user(name: str = "", age: int | None = None):
User.objects.create(name=name, age=age)
# OK: ユースケースを分けるパターン
def create_user(name: str, age: int):
User.objects.create(name=name, age=age)
def create_initial_user():
User.objects.create(name="", age=None)
# OK: 「値が未指定」という表現をするパターン
def create_user(name: str | None, age: int | None):
if name is None:
name = ""
User.objects.create(name=name, age=age)
環境変数を扱う場合も、継承やデフォルト値と似たような問題がある。環境変数での問題はインフラ関係のデバッグや本番運用になってから問題が出ることが多いので、負荷が大きくなりやすい。
- アプリケーションで環境変数を扱うとき、よくあるパターンとして以下の方法がある
- (1): 環境変数に直接値を指定する
- (2): 環境変数ファイルを用意し、アプリで環境変数ファイルを読み込む
- (3): アプリで環境変数の有無をチェックする。もし環境変数が存在しなければ、デフォルト値を使用する
- このとき、(3)が問題で、「ある環境変数が存在しなかった場合、デフォルト値を使用する」という挙動に頼っていると、デプロイ時の不備など環境変数の指定が漏れがちな箇所で問題を先送りにしてしまいがち
- 例: 本番環境なのに開発用のデバッグ出力を有効にしてしまう
- また、(1)と(2)の併用も問題で、優先度がわかりにくくなる
- 例: 環境変数ファイルに値が書かれているが、環境変数にも同じ値が書かれている
- 持論としては、(1)のみにする方が良い
- 環境変数はローカル/本番/CIなど、それぞれの環境にほぼ直接記述できるような箇所に書く
- 例: Docker Compose用のYAMLファイル、GitHub Actions用のYAMLファイル、terraformで管理する環境ごとのテンプレート
- アプリでは、環境変数の値が存在しない場合、その場で落とす
- 環境変数はローカル/本番/CIなど、それぞれの環境にほぼ直接記述できるような箇所に書く
「前提」の逆の場面となる
- 不特定のユーザーが使用する箇所
- ライブラリ/フレームワーク/ツール
- アプリの中でも比較的ライブラリのレイヤーになる箇所
- ある1つの関数/メソッド/クラスで、より多くのユースケースをカバーする必要があり、部分部分でカスタマイズする必要がある箇所
- 例: requests.get, boto3, Djangoのtestのclient
- レイヤーとして責務を明確に分けていて、当然単体テストも十分に書いているものとする