
2026/10/06 19:12
Pendulum がなぜ、Python 史上もっとも「呪われた」+ オペレータを書くことになったのか
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
Pendulum ライブラリの主な問題は、時区処理のためにコールスタックを検査することに過度に依存している点であり、この設計選択により重大なパフォーマンスのボトルネックと予測不能なバグが引き起こされます。標準的な算術演算か複雑な夏時間ロジックかの二者択一を決定するために実行スタックを分析することにより、Pendulum は Python 標準モジュールの
datetime よりも約 600 倍遅くなります。このアプローチは、さらに dateutil といったサードパーティライブラリとの互換性を損ない、PyPy 上でクラッシュを引き起こします。直近の修正は、調整を適用する前に単純な datetime オブジェクトを使用することでこれらの特定の問題の緩和を目指していますが、ユーザーがシームレスなドロップイン置換機能を必要とする場合、依然として深い互不相容性が残ります。したがって、開発者は信頼できない文字列を処理するかホットループを実行する際に極度の注意が必要です。沈黙のエラーやパフォーマンスのペナルティというリスクなしに堅牢な時区管理を必要とする新しいプロジェクトについては、whenever などの代替案が推奨され、これは datetime のサブクラス化を回避し、推測的なコールスタック分析への依存性を排除します。
Text to translate:
The primary issue with the Pendulum library is its heavy reliance on inspecting call stacks to handle time zones, a design choice that creates severe performance bottlenecks and unpredictable bugs. By analyzing the execution stack to decide between standard arithmetic or complex daylight saving time logic, Pendulum becomes roughly 600 times slower than Python's built-in
datetime module. This approach also breaks compatibility with third-party libraries like dateutil and causes crashes on PyPy. While a recent fix aims to mitigate these specific problems by using plain datetime objects before applying adjustments, deep incompatibilities remain if users require seamless drop-in replacement functionality. Consequently, developers must exercise extreme caution when handling untrusted strings or running in hot loops. For new projects needing robust time zone management without the risk of silent errors or performance penalties, alternatives like whenever are recommended, as they avoid subclassing datetime and eliminate dependency on speculative call-stack analysis.本文
Pendulum ライブラリの「+」演算と設計の罠:読み解くべき理由と今後の対応
概要
Pendulum は日付時刻データの処理に広く利用されている Python ライブラリです。そのライブラリ内にある + 演算子は、文脈に応じて異なる挙動を示すという特異な設計を採用しています。この「ハッキング的」な手法自体は古くなっていますが、背後にある 設計思想とトレードオフを理解することはコードの品質維持に不可欠です。
以下に執筆時点での最新版 (
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 時間後(DST を考慮した壁時計表示)
Pendulum の二つの約束と矛盾
Pendulum は以下の 二つの約束によって名声を得ました。
- DST に Aware な演算:
- 標準ライブラリの
は「壁時計上の時刻(nominal time)」に基づいて動作するため、DST 移行時に予期せぬ結果を生じます(例:24 時間後が実際には 23 時間や 25 時間になる)。+ - 一方、Pendulum の
は経過時間を正確に数えることを保証します。これは他の言語でも採用されているモデルであり、Pendulum を使用する正当な理由の一つです。+
- 標準ライブラリの
- 即座での互換性 (Drop-in compatibility):
は標準のDateTime
を継承し、datetime
はDuration
を継承しています。timedelta- これにより既存のコードやライブラリは追加の変更なしに Pendulum のオブジェクトを受け付けることができ、導入が容易になります。
矛盾する二つの約束
これらの約束には根本的な矛盾が存在します。
- 「即座での代替品」:標準ライブラリの値に置き換えても同様の振る舞いが保証されること。
- 「DST に Aware な演算」:改良された「+」であり、異なる振る舞いを要求すること。
この矛盾は、標準ライブラリの
への依存がどこにあるかを無視して解消できません。特に時区の変換処理において破綻が生じます。+
時区変換における問題点
標準ライブラリ自身の手続きで値に対して演算を行う必要があります。そのプロセスは以下のようになります:
dt.astimezone(paris) # あなたのコード (Pendulum) -> DateTime.astimezone() # Pendulum (Python) -> datetime.astimezone() # 標準ライブラリ (C) -> ZoneInfo.fromutc() # 標準ライブラリ (C): dt + オフセットを計算 -> DateTime.__add__() # Pendulum: あなたはどの「+」を望んでいるのか?
ここで問題が発生します。
ZoneInfo.fromutc() は壁時計セマンティクスを想定して標準ライブラリの + を呼び出しますが、Pendulum のオーバーロードされた __add__ が介入し、DST に Aware な演算を適用してしまいます。その結果、DST 移行の前後で 1 時間ズレた値が返されてしまうという矛盾が生じます。
Pendulum は行き詰まりに直面しました:
- 時区変換のロジックを変更はできない(標準ライブラリの領域のため)。
のオーバーロードを除去もできない(すると「DST に Aware な演算」の約束が破れるため)。+- 継承関係を止めることもできない(すると「即座での互換性」の約束が破れるため)。
したがって、Pendulum が選んだ答えは、ランタイムで呼び出し元のセマンティクスを推測するという道です。これが
__add__ 内のスタックチェックの理由であり、「astimezone」という関名であれば標準ライブラリの演算を行うと判断します。
推測することの問題点 (Who else is calling?)
呼び出し元を推測することには重大な問題があります。Pendulum のハードコーディングされたチェックは脆く、単一の関名しかカバーせず、正確なスタック構造に依存しています。
1. 不正確な結果
標準ライブラリの演算を期待する他のコードも Pendulum の演算を受け取ってしまいます。
の時区への変換では異なる呼び出し経路となり、結果として無知 (naive) な日付かつ UTC オフセットがずれた状態になる場合があります (#820)。dateutil
>>> 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) # naive — および 14:00 にすべきなのにそうではない
2. パフォーマンスの劣化
推測自体にはコストがかかります。
traceback.extract_stack() が各フレームを要約する(ファイル変更確認やソースコード読み取りなど)ため、「+」演算は標準ライブラリに対して約 600 倍も遅くなってしまいます。
>>> 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, 約 600 倍の遅延)
3. PyPy での挙動変化
PyPy では Pendulum 自身のコンバージョンさえも異なる呼び出しスタックを通り、誤った分岐を取ります。例えば
in_tz("Europe/Paris") は 2024 年 3 月 31 日の 01:30 UTC を 04:30 に変換し、1 時間ズレてしまいます。
他の推測とそれらの欠陥 (Guess what?)
Pendulum は他にも同様の「隙間に仮定を埋める」推測を行っていますが、これらには四つの問題があります。
Pendulum の他の推測
- 時区なしの場合:
やparse()
は UTC を仮定します。instance() - 日付がない時刻の場合:
は実行マシンの「今日」の日付を採用します。parse("12:00") - DST での跳ね飛ばし:
を標準ライブラリとは逆の方法で読み取るため、値の変換時に 1 時間ずれることがあります。fold - 「+」演算: 呼び出し元によって異なる処理を行います。
推測自体の四つの欠陥
- 正直な不完全性の隠蔽: 時区のないタイムスタンプがサーバー由来かユーザー由来か「不明」ですが、Pendulum はこれを自動的に「UTC」と変換してしまい、下流で違いを検出できません。
- 外部要素への依存: 現在時刻、マシンの時区、関数名などコード内で言及していない要素の結果に依存します(例:
は実行環境によって日付が異なる)。parse("12:00") - 拡張不可能性: 新しい呼び出し元や入力が常に存在するため、推測の網は決して完了しません。
- 取り返しのつかなさ: Pendulum を使用している全てのプログラムが同様の推測に依存しているため、逆転させると計算結果が無言で変化してしまいます。
例外:「+」演算子における推測の排除
Pendulum は本当に
__add__ 内で推測する必要があったのでしょうか?Timezone が ZoneInfo のサブクラスになることで生じた対立を解決する手段として、呼び出しスタックを辿る以外には選択肢がありませんでした。
よりクリーンな解決策として、推測そのものを排除することが提唱されました:
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)
この提案は Pull Request として受理され、次のリリースでマージされる予定です。これにより:
内での推測が排除され、上記のバグも消失します。__add__- パフォーマンスは約 5 倍高速化し、標準ライブラリとの遅延差も約 600 倍から約 120 倍に改善されます(残りは純粋な Python の演算コスト)。
それでも根本的な問題は解決しない
しかしながら、Pendulum は追い詰められたと言えます。残念ながら最も高価な方法で脱出を選択しましたが、これでは根本的な問題を解決できません。どの回避策でも Pendulum が制御できない領域には到達せず、
DateTime を datetime として受け取り演算を行うあらゆるコードは Pendulum の「+」を受け取ってしまいます(期待しているかどうかに関わらず)。
標準ライブラリの
astimezone() を直接呼び出すだけでも同様のエラーが起きます:
>>> d = pendulum.datetime(2024, 7, 1, 12, tz="UTC") >>> datetime.astimezone(d, ZoneInfo("Europe/Paris")) DateTime(2024, 7, 1, 12, 0, 0) # naive — および 14:00 にすべきなのにそうではない
これを正しく修正するには、継承関係を止めることが必要です。
ではどうすべきか? (What now?)
すでに Pendulum を使用している場合パニックする必要はありませんが、以下のケースには注意してください:
などのサードパーティ製時区への変換を行う場合dateutil- PyPy で動作させる場合
- 「+」をホットループ(頻繁に呼ばれる場所)で使用する場合
- 信頼できない文字列をパースする場合
- Pendulum の値を
が期待される箇所に渡す場合datetime
今後の対応策
次のリリースでは、上記の「サードパーティ製時区変換」と「PyPy」の問題が修正され、「+」もさらに高速化されます。残りの問題は Pendulum の設計に基づくものであり、今後も存続します。
これから新たに開始する場合は、
datetime に基づくアーキテクチャを採用すべきかどうか問いかけられます。標準ライブラリ自体は悪くありませんが、その落とし穴を知る必要があります。もしこれらの問題を修正したいなら、Pendulum は「即座での代替品」としての役割を放棄する必要があります。
そこにトレードオフが存在します:
- Pendulum:
を継承し互換性を保つが、推測とバグのリスクがある(著者はdatetime
でも同様のトレードオフを選択)。whenever - 新しいアプローチ (whenever 的スタイル):
を継承せず、types が値の意味を守ります(naive と aware は混ざれない)。演算は他の言語で標準になりつつあるモデルに従い、何も推測されません。datetime
呼び出しスタックを利用したこのハッキング手法は、Pendulum の設計判断の一つに過ぎません。Python でさらに「呪われた演算子」をご存知でしょうか?もしあれば教えてほしいです!