HN 日本語サマリー

← 一覧へ戻る
プログラミング

なぜPendulumはPythonで最も呪われた「+」演算子を書かなければならなかったのか

Why Pendulum had to write the most cursed "+" operator in all of Python (dev.arie.bovenberg.net)

35 pointsby ariebovenberg15 コメント

要約

PythonのdatetimeライブラリであるPendulumは、timedeltaを加算する際の「+」演算子で、呼び出しスタックを調べて動作を決定するという、非常に珍しい実装を採用しています。これは、夏時間(DST)の切り替わりを考慮した正確な時刻計算というPendulumの約束と、標準ライブラリとの互換性という別の約束との間の衝突を解決するための苦肉の策でした。このハックは最近のバージョンで削除されつつありますが、その設計思想は依然として重要です。

全文翻訳

Pendulumは、datetimeを扱うための人気のPythonライブラリです。その+演算子は、datetimeライブラリであっても珍しいものです。Pendulum DateTimeにtimedeltaを加算すると、呼び出しスタックを調べて、どちらの答えを返すかを決定します。何?なぜ?このハック自体はもうすぐなくなりますが、その背後にある設計はあなたのコードにとって依然として重要です。 執筆時点での最新リリースであるPendulum 3.2.0の__add__メソッドを見てみましょう。これは標準ライブラリのdatetime.__add__()をオーバーライドしています。 def __add__(self, other): ... caller = traceback.extract_stack(limit=2)[0].name if caller == "astimezone": return super().__add__(other) return self._add_timedelta_(other) 答えは、それを呼び出した関数の名前に依存します。したがって、自分の関数の名前を変更することで結果を変更できます。 >>> import pendulum >>> from datetime import timedelta >>> def astimezone(dt): ... return dt + timedelta(hours=24) >>> base = pendulum.datetime(2013, 3, 30, 12, tz="Europe/Paris") >>> base + timedelta(hours=24) DateTime(2013, 3, 31, 13, ...) # +24時間経過(DSTで1時間がスキップされた) >>> astimezone(base) DateTime(2013, 3, 31, 12, ...) # 壁時計で+24時間 私は自分のdatetimeライブラリであるwheneverを開発中にこれに遭遇し、その動作をPendulumと比較しました。PendulumはDST対応の算術演算を正しく行うことを誇りにしています。これは、その+が標準ライブラリのものとは異なる必要があることを意味します。それが__add__メソッドのself._add_timedelta_(other)ブランチが行うことです。しかし、なぜ時々しかこのブランチを取らないのでしょうか?そして、これは安価でもありません。呼び出しスタックをたどることは、Pendulumの+演算子を標準ライブラリのものよりも数百倍遅くします。これは、通常の# Sorry, sorry, let me explainコメントなしの、必死のハックのように見えます。誰も理由もなく、これほど広く使われているライブラリに出荷しません。では、何が彼らを追い詰めたのでしょうか? 2つの約束 Pendulumは2つの約束でその名声を築きました。 DST対応の算術演算。標準ライブラリの+は壁時計で演算するため、timedeltaをDST遷移をまたいで加算すると予期しない結果になります。「24時間後」は実際には23時間または25時間後になります。Pendulumの+は代わりに経過時間をカウントします。これは他の言語で標準になりつつあるモデル(3)に沿っており、Pendulumを使用する良い理由です。 ドロップイン互換性。PendulumのDateTimeはdatetimeをサブクラス化し、Durationはtimedeltaをサブクラス化し、TimezoneはZoneInfoをサブクラス化します。既存のコードと使用するすべてのライブラリは、変更なしでPendulumの値を受け入れます。これにより、Pendulumの導入が容易になります。 しかし、この2つの約束の間には対立があります。「ドロップイン置換」は、Pendulumの値を標準ライブラリの値に置き換えても同じ動作が得られると約束します。しかし、DST対応の算術演算は反対を約束します。つまり、改善された+であり、異なる動作です(4)。この対立は、コードが標準ライブラリの+に依存するあらゆる場所で現れます。たとえそれが直感的でなくても(5)。 これが最初に破綻するのは、タイムゾーン変換です。datetimeを変換するために、標準ライブラリ自身のメカニズムが中間ステップとしてあなたの値に対して算術演算を行います。 dt.astimezone(paris) # あなたのコード -> DateTime.astimezone() # Pendulum (Python) -> datetime.astimezone() # 標準ライブラリ (C) -> ZoneInfo.fromutc() # 標準ライブラリ (C): dt + offset を計算する -> DateTime.__add__() # Pendulum: どの+を意味しましたか? ZoneInfo.fromutc()は壁時計のセマンティクスを期待して+を呼び出します。しかし、Pendulumのオーバーロードされた+はDST対応の算術演算を適用し、DST遷移の周りで1時間ずれた値になります。 Pendulumは、困難な状況に置かれています。変換ロジック(標準ライブラリに属する)を変更することはできません。+オーバーロードを削除することもできません(それはDST対応算術演算の約束を破ることになります)。サブクラスになることをやめることもできません(それはドロップイン互換性の約束を破ることになります)。その答えは、実行時に呼び出し元がどのセマンティクスを期待しているかを推測することです。 それが__add__メソッドの呼び出しスタックチェックを説明しています。呼び出し元がastimezone()という名前の関数であれば、Pendulumは標準ライブラリを経由して変換していると想定し、標準ライブラリの算術演算を適用します。 誰が他に呼び出しているのか? 誰が呼び出しているかを推測することの難しさは、すべての呼び出し元を予測できないことです。Pendulumのハードコードされたチェックは脆弱です。それは1つの名前しかカバーしておらず、Pendulumが制御していない正確な呼び出しスタックに依存しています(6)。標準ライブラリの算術演算を期待する他のコードはPendulumのものを代わりに受け取ります。例えば、dateutilのタイムゾーンへの変換は異なる呼び出しスタックを経由し、結果はサイレントにナイーブで、UTCオフセットでずれます(#820)。 >>> from dateutil import tz >>> d = pendulum.datetime(2024, 7, 1, 12, tz="UTC") >>> d.astimezone(tz.gettz("Europe/Paris")) DateTime(2024, 7, 1, 12, 0, 0) # ナイーブ — そして14:00ではない PyPyでは、Pendulum自身の変換さえも異なる呼び出しスタックを経由し、間違ったブランチを取ります。in_tz("Europe/Paris")は、2024年3月31日の01:30 UTCを04:30に変換します。これは1時間ずれています。 そして推測は無料ではありません。呼び出し元の名前を知るために、traceback.extract_stack()は各フレームを要約します。オペレーティングシステムにソースファイルが変更されたかどうかを尋ね(stat()呼び出し)、ソース行を読み取ります。全体として、+は約600倍遅くなります(7)。 >>> from datetime import datetime >>> from zoneinfo import ZoneInfo >>> from timeit import timeit >>> hour = timedelta(hours=1) >>> std = datetime(2024, 1, 1, tzinfo=ZoneInfo("Europe/Paris")) >>> pdl = pendulum.datetime(2024, 1, 1, tz="Europe/Paris") >>> timeit("std + hour", globals=globals(), number=100_000) 0.0069 # 秒 >>> timeit("pdl + hour", globals=globals(), number=100_000) 4.36 # 秒 推測してみてください?Pendulumは他の場所でも推測します。それほど劇的ではありませんが。情報が不足している場所では、仮定でギャップを埋めます。タイムゾーンがない?parse()とinstance()はUTCを仮定します。日付のない時間?parse("12:00")はあなたのマシンの今日の日付を取ります。DSTがスキップする壁時計の時間?Pendulumは標準ライブラリとは反対のfoldを読み取るため、値を変換すると1時間移動する可能性があります。そして+?ご覧のとおり、それは誰が尋ねているかによります。 各推測はそれ自体では妥当に見えるかもしれません。しかし、推測自体には4つの問題があります。 推測は、正直に不完全な値を、自信を持って間違った値に変える可能性があります。タイムゾーンのないタイムスタンプは、UTCのサーバーから、または東京のユーザーから来るかもしれません。Pendulumは「不明」を「UTC」に変え、下流の何も違いを伝えることができません。 推測は、あなたのコードが言及していないものに結果を依存させる可能性があります。現在の時刻、マシンのタイムゾーン、関数の名前。parse("12:00")は、いつ、どこで実行されるかによって異なる日付を返します。 推測は拡張できますが、完了することはありません。常に別の呼び出し元、別の入力、予期されなかった別のタイムゾーンがあります。 そして、推測は取り消すことができません。Pendulumを使用するすべてのプログラムは、それが正確にそのように推測することに依存しているため、それを逆転させると、それらのプログラムが計算するものをサイレントに変更します。 ただし、1つの例外を除きます。+の推測です。 Pendulumはそれをしなければなりませんでしたか? では、Pendulumはこの+演算子を書かなければならなかったのでしょうか?それは何かを書く必要がありました。そのTimezoneがZoneInfoのサブクラスになった(8)後、fromutc()内の衝突に対処する必要がありました。しかし、呼び出しスタックをたどることが唯一の選択肢ではありませんでした。 最もクリーンな回避策は、推測を完全に削除することです。astimezone()は標準ライブラリにプレーンなdatetimeを渡し、結果を戻る途中で再度ラップすることができます。 def astimezone(self, tz=None): plain = datetime(self.year, ..., tzinfo=self.tzinfo, fold=self.fold) dt = plain.astimezone(tz) # ここでは標準ライブラリの+のみが実行される return self.__class__(dt.year, ..., tzinfo=dt.tzinfo, fold=dt.fold) 私はこれをプルリクエストとして提案し、Pendulumはそれをマージしました。次のリリースに含まれるはずです。それにより、__add__は推測するものがなくなり、上記のバグは消えます。興味深いことに、Pendulum自身のadd()メソッドはすでにこの方法で動作しています。そして+は5倍高速になります。約600倍から標準ライブラリの約120倍遅くなります。残るのは、純粋なPythonで書かれたPendulum自身の算術演算のコストです。だから、はい。