こんにちは、ニーリーの佐古です。
現在開発速度や開発者体験の向上のため、取り組みの諸々を遂行しています。
事前準備がかさばって本題にフォーカスできない問題
さあ本題です。本題までが長い話は読む気がなくなりますね。
成長期のプロダクトをDjangoで開発していると
- あまり型情報が信用できなかったりそもそもなかったり
- 値すら無かったり
するケースがあると思います。Pylanceなどのご利益にあずかろうと思うと
ガードなりチェックなり書くことになるのですが
有無チェックとデフォルトをやると本題までが長い
こういうことになりがちですね。 真面目に読まなくてよいです。
if val0 is None: raise TypeError("val0 must not be None.") if not isinstance(val0, str): raise TypeError(f"val0 must be an instance of str, got {type(val0).__name__}") actual0 = val0 if val1 is None: actual1 = 15 else: if not isinstance(val1, int): raise TypeError(f"val1 must be an instance of int, got {type(val1).__name__}") actual1 = val1 if val2 is None: raise TypeError("val2 must not be None.") if not isinstance(val2, RandomClass): raise TypeError(f"val2 must be an instance of RandomClass, got {type(val2).__name__}") actual2 = val2 if val3 is None: actual3 = (5, "abc") else: if not isinstance(val3, tuple): raise TypeError(f"val3 must be an instance of tuple, got {type(val3).__name__}") actual3 = val3 if val4 is None: actual4 = {"5": 12345} else: if not isinstance(val4, dict): raise TypeError(f"val4 must be an instance of dict, got {type(val4).__name__}") actual4 = val4 if val5 is None: actual5 = "1" else: if not isinstance(val5, str): raise TypeError(f"val5 must be an instance of str, got {type(val5).__name__}") actual5 = val5
ここから本題が始まるはずですがもう読む方はMPが残っていません。 レビュアーやメンテナーに供物が必要になってきます。 バリデーションをメソッドに切り出すのが第一の方法ではありますが特に固有バリデーションというわけでもないので一般化したいです。
対策
ではそうしましょう。
やること
- 簡易型チェック
- 結果としてNoneチェック
今はやらないこと
- dictやtupleはじめ型引数や構造のチェック
ライブラリ
こんなのを用意します。
@dataclass class EnsuringWrapper: _value: Any def as_a(self, expected_type: type[T]) -> T: if not len(get_args(expected_type)) and not isinstance(self._value, expected_type): raise TypeError(f'{self._value} is not an instance of {expected_type.__name__}') return self._value def ensured(value: Any) -> EnsuringWrapper: return EnsuringWrapper(value)
使用感
事前準備は以下で終わります。
actual1 = ensured(val0).as_a(str) actual2 = ensured(15 if val1 is None else val1).as_a(int) actual3 = ensured(val2).as_a(RandomClass) actual4 = ensured((5, "abc") if val3 is None else val3).as_a(tuple) actual5 = ensured({"5": 12345} if val4 is None else val4).as_a(dict) actual6 = ensured("1" if val5 is None else val5).as_a(str)
欲張ってみる
もう少しズボラに書きたいのと、デフォルトが入ると読みにくくなる点を改善したいです。
やること
- 型・候補・デフォルトのタプルを最大6セットまで受け付けて良い感じにnarrowしてくれるようにする
今はやらないこと
- dictやtupleはじめ型引数や構造のチェック
- 6セット以上可変長引数対応
- これ以上引数が増えたら関数やメソッドの設計の問題が別にあると思うのでケアしないことにします。
ライブラリ
先のライブラリを利用します。 まずは内部実装。
def require_or_default( *args: tuple[type, object] | tuple[type, object, object] ) -> tuple: result = [] for idx, arg in enumerate(args): if len(arg) == 2: typ, val = arg if val is None: raise TypeError(f"引数{idx}はNoneであってはなりません。") result.append(ensured(val).as_a(typ)) elif len(arg) == 3: typ, val, default = arg if default is None: raise TypeError(f"引数{idx}に指定されたデフォルト値がNoneです。") result.append(ensured(val if val is not None else default).as_a(typ)) else: raise ValueError(f"Invalid argument at position {idx}: must be 2- or 3-tuple") return tuple(result)
これに腕力オーバーロードを添えます。*1
@overload def require_or_default( __p1: tuple[type[T1], T1 | None] | tuple[type[T1], T1 | None, T1], ) -> tuple[T1]: ... # 2~5は省略 @overload def require_or_default( __p1: tuple[type[T1], T1 | None] | tuple[type[T1], T1 | None, T1], __p2: tuple[type[T2], T2 | None] | tuple[type[T2], T2 | None, T2], __p3: tuple[type[T3], T3 | None] | tuple[type[T3], T3 | None, T3], __p4: tuple[type[T4], T4 | None] | tuple[type[T4], T4 | None, T4], __p5: tuple[type[T5], T5 | None] | tuple[type[T5], T5 | None, T5], __p6: tuple[type[T6], T6 | None] | tuple[type[T6], T6 | None, T6], ) -> tuple[T1, T2, T3, T4, T5, T6]: ...
使用感
そうすると使い心地はこうなります。
actual1, actual2, actual3, actual4, actual5, actual6 = require_or_default(
(str, val0),
(int, val1, 15),
(RandomClass, val2),
(tuple, val3, (5, "abc")),
(dict, val4, {"5": 12345}),
(str, val5, "1"),
)
このくらいならあまり本文の邪魔にはならないでしょう。よかったですね。
今後の展望
型引数やらtupleの長さやらのチェックをオンデマンドで追加するとなお便利になりますね。
さいごに
コードを見て「うわっ」と思ったのにぱっと見てめぼしいライブラリがないとき。
それは書き時です。書きましょう。
Tips
説明の都合上ライブラリを先に書きましたが、実際には
actual1, actual2, actual3, actual4, actual5, actual6 = require_or_default(
(str, val0),
(int, val1, 15),
(RandomClass, val2),
(tuple, val3, (5, "abc")),
(dict, val4, {"5": 12345}),
(str, val5, "1"),
)
こう書きたい、からスタートするのをお勧めします。
*1:このくらいはもう生成させるので腕力とはまた違いますが