Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Pythonによる契約プログラミング入門 / PyCon JP 2025

Avatar for 7pairs 7pairs
September 27, 2025

Pythonによる契約プログラミング入門 / PyCon JP 2025

PyCon JP 2025の発表資料です。

Avatar for 7pairs

7pairs

September 27, 2025
Tweet

More Decks by 7pairs

Other Decks in Technology

Transcript

  1. ࣗݾ঺հ speaker = Person( name = "長谷場 潤也 (HASEBA Junya)",

    url = "https://lit.link/7pairs", company = "株式会社アイモバイル", job = "Androidエンジニア", languages = {"Python", "Clojure"}, favorites = {"U-NEXT Pirates", "埼玉西武ライオンズ"} )
  2. ࣄલ৚݅ͷྫᶃ Ҿ਺ͷੑ࣭ class BankAccount: def __init__(self, balance=0): self.balance = balance

    self.status = "active" def deposit(self, amount): # PRE[型]: amountは整数 # PRE[範囲]: amount > 0 self.balance += amount return self.balance def withdraw(self, amount): # PRE[型]: amountは整数 # PRE[範囲]: amount > 0 self.balance -= amount return self.balance ૝ఆ֎ͷ஋͕ೖΓࠐΉͷΛ ๷͙ͨΊɺೖΓޱͰߜΔɻ
  3. ࣄલ৚݅ͷྫᶄ 4VQQMJFSͷঢ়ଶ def deposit(self, amount): # PRE[型]: amountは整数 # PRE[範囲]:

    amount > 0 # PRE[状態]: status == "active" self.balance += amount return self.balance def withdraw(self, amount): # PRE[型]: amountは整数 # PRE[範囲]: amount > 0 # PRE[状態]: status == "active" # PRE[関係]: amount <= balance self.balance -= amount return self.balance ॲཧ͕ՄೳͳλΠϛϯάͰ ͔͠ݺͼग़͠Λڐ͞ͳ͍ɻ
  4. #BOL"DDPVOUΫϥεͷ࣮૷ͷΰʔϧ # 残高10万円の口座を作成 account = BankAccount(100_000) # セッションが終わるころの実装では # 表明に違反した呼び出しはエラーになります!

    account.deposit(-10_000) # ❌ マイナス金額の預け入れ account.withdraw(3.14) # ❌ 小数金額の引き出し account.withdraw(200_000) # ❌ 残高を超える引き出し
  5. ࣄޙ৚݅ͷྫᶄ 4VQQMJFSͷঢ়ଶ def freeze(self): # PRE[状態]: status == "active" #

    POST[状態]: status == "frozen" self.status = "frozen" def unfreeze(self): # PRE[状態]: status == "frozen" # POST[状態]: status == "active" self.status = "active" ݺͼग़͠ޙͷঢ়ଶ͕อূ͞ Ε͍ͯΕ͹ɺγεςϜશମ ͷঢ়ଶભҠΛ҆৺ͯ͠ߟ͑ ΒΕΔɻ
  6. ࣄޙ৚݅ͷྫᶅ 4VQQMJFSͷࠩ෼ def deposit(self, amount): # PRE[型]: amountは整数 # PRE[範囲]:

    amount > 0 # PRE[状態]: status == "active" # POST[型]: 戻り値は整数 # POST[範囲]: 戻り値 >= 0 # POST[差分]: balanceは実行前後でamountだけ増加 self.balance += amount return self.balance ࠩ෼Λอূ͢Δ͜ͱͰɺϏ δωεϩδοΫͷҰ؏ੑΛ ่͞ͳ͍ɻ
  7. Ϋϥεෆมද໌ͷྫ class BankAccount: # INV[状態]: balance >= 0 # INV[状態]:

    status in {"active", "frozen"} def __init__(self, balance=0): self.balance = balance self.status = "active" ͲͷQVCMJDϝιουΛݺΜ Ͱ΋ঢ়ଶ͕յΕͳ͍͜ͱΛ อূ͠ɺݺͼग़͠ଆͷ҆৺ ײΛϧʔϧͰ࡞Γग़͢ɻ
  8. Ϋϥεෆมද໌͕ ຬͨ͞ΕΔλΠϛϯά ‣ @@JOJU@@ऴྃ࣌ͱQVCMJDϝιουͷ࣮ ߦલޙͰ͸ɺඞͣΫϥεෆมද໌͕ ຬͨ͞Ε͍ͯΔɻ ‣ ϝιουͷॲཧத΍ɺݺͼग़͠ઌͷ QSJWBUFϝιουͷதͰ͸ɺҰ࣌తʹ ຬͨ͞Εͳ͍ঢ়ଶ͕͋ͬͯ΋ྑ͍ɻ

    ‣ ͨͩ͠ɺQVCMJDϝιου͕ऴྃ͢Δ લʹঢ়ଶΛճ෮ͤ͞Δɻ @@JOJU@@։࢝ @@JOJU@@ऴྃ QVCMJDϝιου"։࢝ QVCMJDϝιου"ऴྃ QSJWBUFϝιου#։࢝ QSJWBUFϝιου#ऴྃ ຬͨ͞Ε͍ͯΔ ຬͨ͞Ε͍ͯΔ
  9. Ϋϥεෆมද໌͕ຬͨ͞Εͳͯ͘΋໰୊ͷͳ͍λΠϛϯά # INV[状態]: status in {"active", "frozen"} def freeze(self): self.status

    = "freezing" # メソッドの実行中なので不変表明に違反しても問題なし ... # 口座凍結のための重い処理... self.status = "frozen" # メソッドの終了時に不変表明を満たす状態に回復させる
  10. ܖ໿ϓϩάϥϛϯάΛαϙʔτ͢Δݴޠ ‣ &J ff FM ‣ "EB ‣ %ݴޠ ‣

    4QFDʢ$ͷ%C$֦ு൛ʣ ‣ ,PUMJO ‣ $MPKVSF ೔ຊޠ൛8JLJQFEJBʮܖ໿ϓϩάϥϛϯάʯΑΓҾ༻
  11. ෬ઢճऩᶃ speaker = Person( name = "長谷場 潤也 (HASEBA Junya)",

    url = "https://lit.link/7pairs", company = "株式会社アイモバイル", job = "Androidエンジニア", languages = {"Python", "Clojure"}, favorites = {"U-NEXT Pirates", "埼玉西武ライオンズ"} )
  12. ෬ઢճऩᶄ ‣ &J ff FM ‣ "EB ‣ %ݴޠ ‣

    4QFDʢ$ͷ%C$֦ு൛ʣ ‣ ,PUMJO ‣ $MPKVSF ೔ຊޠ൛8JLJQFEJBʮܖ໿ϓϩάϥϛϯάʯΑΓҾ༻
  13. $MPKVSFͷྫᶃ ࢓༷ͱܖ໿ ; 単価 * 数量を整数に丸めて返す ; ※ 実はバグがあります (defn

    total [price qty] {:pre [(number? price) (>= price 0) (integer? qty) (> qty 0)] :post [(integer? %) (>= % 0)]} (* price qty)) ‣ 13&<ܕ>QSJDF͸਺஋ ‣ 13&<ൣғ>QSJDF ‣ 13&<ܕ>RUZ͸੔਺ ‣ 13&<ൣғ>RUZ ‣ 1045<ܕ>໭Γ஋͸੔਺ ‣ 1045<ൣғ>໭Γ஋
  14. $MPKVSFͷྫᶄܖ໿͕όάΛݕ஌͢Δ ; 事前条件を満たさない引数はエラーになる (total "33" 4) ; => Assert failed:

    (number? price) (total 33 -4) ; => Assert failed: (> qty 0) ; 事後条件を満たさない戻り値はエラーになる (total 3.3 4) ; => Assert failed: (integer? %) ; 期待結果は13.2を四捨五入して13 ; 実際には整数が返っていない!バグ?
  15. $MPKVSFͷྫᶅ ਖ਼͍࣮͠૷ ; 単価 * 数量を整数に丸めて返す ; Math/round(四捨五入)を追加 (defn total

    [price qty] {:pre [(number? price) (>= price 0) (integer? qty) (> qty 0)] :post [(integer? %) (>= % 0)]} (Math/round (* price qty))) ࣄޙ৚͕݅͋ͬͨͷͰɺ࣮ ૷࿙Ε͕͙͢ʹൃ֮ͨ͠ɻ
  16. ,PUMJOͷྫᶄ εϚʔτΩϟετ var nullableString: String? = "I am nullable." var

    nonNullString: String = "I am not null." // null許容型の値はnull非許容型に代入できない nonNullString = nullableString // ビルドエラー if (nullableString != null) { // このブロック内ではnullではないことを // コンパイラが推論可能なので代入できる nonNullString = nullableString } if (!nullableString.isNullOrEmpty()) { // 通常のメソッド呼び出しでは推論できないが // contractがあるためnullではないと推論可能 nonNullString = nullableString } ,PUMJOͰͷܖ໿͸4VQQMJFS ͱ$MJFOUͷؒͷ໿ଋͰ͸ͳ ͘ɺίϯύΠϥʹର͢Δอ ূ͕໨తʢεϚʔτΩϟε τ΍෭࡞༻ղੳʹ࢖༻ʣɻ
  17. -FWFM ܕώϯτ࣮૷ def deposit(self, amount: int) -> int: # PRE[型]:

    amountは整数 # PRE[範囲]: amount > 0 # PRE[状態]: status == "active" # POST[型]: 戻り値は整数 # POST[範囲]: 戻り値 >= 0 # POST[差分]: balanceは実行前後でamountだけ増加 self.balance += amount return self.balance def freeze(self) -> None: # PRE[状態]: status == "active" # POST[状態]: status == "frozen" self.status = "frozen" Ҿ਺ͱ໭Γ஋ʹܕώϯτΛ ࢦఆ͢Δɻ ˞࿩Λ୯७ʹ͢ΔͨΊɺۚ ֹͷܕΛJOUʹ͍ͯ͠·͢ɻ
  18. -FWFMܕώϯτܖ໿ͷޮՌᶃ account = BankAccount(100_000) # mypyで引数の型のミスを静的に検出できる # => error: Argument

    1 to "deposit" of "BankAccount" has # incompatible type "str"; expected "int" [arg-type] account.deposit("10_000")
  19. -FWFMBTTFSU EPDTUSJOH࣮૷ྫᶃ def withdraw(self, amount: int) -> int: # ----------

    Precondition ---------- assert amount > 0, "PRE violation: amount must be > 0" assert self.status == "active", "PRE violation: status must be 'active'" assert amount <= self.balance, "PRE violation: amount must not exceed balance" # ---------- Product Code ---------- self.balance -= amount # ---------- Postcondition ---------- result = self.balance assert result >= 0, "POST violation: result must be >= 0" return result
  20. -FWFM BTTFSU EPDTUSJOH ࣮૷ྫᶄ def withdraw(self, amount: int) -> int:

    """ 事前条件: - amountは整数 - amount > 0 - status == "active" - amount <= balance 事後条件: - 戻り値は整数 - 戻り値 >= 0 - balanceは実行前後でamountだけ減少 """ ... EPDTUSJOHʹද໌ͷ಺༰Λه ࡌ͢Δɻ ܕʹ͍ͭͯ͸ܕώϯτͰରԠ ࡁΈɻCBMBODFͷݮগ෼ʹͭ ͍ͯ͸-FWFMͰରԠ͢Δɻ
  21. -FWFMBTTFSU EPDTUSJOHܖ໿ͷޮՌ account = BankAccount(100_000) # 引数の範囲のミスを実行時に検出できる # => AssertionError:

    PRE violation: amount must not exceed balance account.withdraw(200_000) # Supplierの状態のミスを実行時に検出できる # => AssertionError: PRE violation: status must be 'active' account.freeze() account.withdraw(50_000)
  22. ๷ޚతϓϩάϥϛϯάͱͷ࢖͍෼͚ ‣ ܖ໿ϓϩάϥϛϯάʢ಺෦Ͱͷ໿ଋʣ ‣ ৴པͰ͖Δ૬खʢνʔϜ಺ͷίʔυɺಉҰϞδϡʔϧ಺ͷݺͼग़͠ʣͱͷؔ܎ ‣ ʮࣄલ৚݅Λकͬͯ͘ΕΔͳΒɺͪΌΜͱࣄޙ৚݅Λอূ͢ΔΑʯ ‣ ઃܭΛ໌֬ʹͯ͠όάΛૣظʹݕग़͢Δ ‣

    ๷ޚతϓϩάϥϛϯάʢ֎෦ͱͷڥքʣ ‣ ৴པͰ͖ͳ͍૬खʢϢʔβʔೖྗɺ֎෦γεςϜʣͱͷؔ܎ ‣ ʮ૬ख͕ԿΛૹͬͯ͘Δ͔෼͔Βͳ͍ͷͰɺյΕͳ͍Α͏ʹࣗ෼ΛकΔʯ ‣ γεςϜͷݎ࿚ੑ΍ηΩϡϦςΟΛ֬อ͢Δ
  23. -FWFM1ZEBOUJD࣮૷ྫ class WithdrawAmount(BaseModel): value: conint(gt=0) # 0より大きい整数 def withdraw(self, amount:

    WithdrawAmount) -> int: """※docstringは省略""" # ---------- Precondition ---------- assert self.status == "active", "PRE violation: status must be 'active'" assert amount.value <= self.balance, "PRE violation: amount must not exceed balance" # ---------- Product Code ---------- self.balance -= amount # ---------- Postcondition ---------- result = self.balance assert result >= 0, "POST violation: result must be >= 0" return result
  24. -FWFM1ZEBOUJDܖ໿ͷޮՌ account = BankAccount(100_000) # Pydanticで引数の性質のミスを実行時に検出できる # => pydantic.ValidationError: 1

    validation error for WithdrawAmount value # Input should be greater than 0 [type=greater_than, input_value=-500, gt=0] amount.withdraw(WithdrawAmount(value=-500)) # 引数とSupplierの関係性のミスを実行時に検出できる(assertでカバー) # => AssertionError: PRE violation: amount must not exceed balance account.withdraw(WithdrawAmount(value=200_000))
  25. -FWFMJDPOUSBDU࣮૷ྫᶃ import icontract # @invariantデコレータでクラス不変表明を定義 # publicメソッドの実行前後でチェックされる @icontract.invariant(lambda self: self.balance

    >= 0) @icontract.invariant(lambda self: self.status in {"active", "frozen"}) class BankAccount: def __init__(self, balance: int = 0) -> None: self.balance = balance self.status = "active"
  26. -FWFMJDPOUSBDU࣮૷ྫᶄ # @requireデコレータで事前条件を定義 # @snapshot / @ensureデコレータで事後条件を定義 @icontract.require(lambda self, amount:

    self.status == "active") @icontract.require(lambda self, amount: amount > 0) @icontract.require(lambda self, amount: amount <= self.balance) @icontract.snapshot(lambda self: self.balance, name="bal") @icontract.ensure(lambda self, amount, result, OLD: result == self.balance and OLD.bal - self.balance == amount) def withdraw(self, amount: int) -> int: self.balance -= amount return self.balance
  27. -FWFMJDPOUSBDUܖ໿ͷޮՌᶅ # withdrawなのに残高に足してしまった!(デコレータ略) def withdraw(self, amount: int) -> int: self.balance

    += amount return self.balance # icontractで実行時に検出できる # => icontract.errors.ViolationError: ... # result == self.balance and OLD.bal - self.balance == amount: # OLD was a bunch of OLD values # OLD.bal was 100000 # amount was 50000 # result was 150000 # self.balance was 150000 account.withdraw(50_000)
  28. -FWFMJDPOUSBDUܖ໿ͷޮՌᶆ # ステータスを壊してしまった!(デコレータ略) def withdraw(self, amount: int) -> int: self.status

    = "error" self.balance -= amount return self.balance # icontractで実行時に検出できる # => icontract.errors.ViolationError: ... # self.status in {"active", "frozen"}: # self.status was 'error' account.withdraw(50_000)
  29. ࣄྫϕʔεςετͷྫ def test_deposit(): account = BankAccount(100_000) assert account.available() == 100_000

    assert account.deposit(50_000) == 150_000 assert account.available() == 150_000
  30. ϓϩύςΟϕʔεςετͷྫ from hypothesis import given, strategies as st from bank

    import BankAccount # 事前条件を満たすランダムな入力に対して事後条件を満たすことを確認 @given(balance=st.integers(min_value=0, max_value=1_000_000), amount=st.integers(min_value=1, max_value=1_000_000)) def test_deposit_property(balance, amount): account = BankAccount(balance) new_balance = account.deposit(amount) assert new_balance == balance + amount assert account.available() == new_balance
  31. ࣄྫϕʔεςετͱϓϩύςΟϕʔεςετͷҧ͍ ‣ ࣄྫϕʔεςετ ‣ ࢒ߴ͕ ԁͷޱ࠲ʹ ԁͷ༬͚ೖΕΛ͢Δͱ ࢒ߴ͕ ԁʹ૿͑Δͱ͍͏఺Λςετ͢Δ ‣

    ޮ཰తʹ఺ΛબͿͨΊͷςΫχοΫ͕֤छͷςετٕ๏ ‣ ϓϩύςΟϕʔεςετ ‣ ࢒ߴ͕CBMBODFͷޱ࠲ʹBNPVOUͷ༬͚ೖΕΛ͢Δͱ ࢒ߴ͕CBMBODF BNPVOUʹ૿͑Δͱ͍͏ੑ࣭Λςετ͢Δ ‣ ੑ࣭ͱ͍͏໘ʹϥϯμϜʹ఺Λଧͬͯ֬ೝ͢ΔΠϝʔδ
  32. νʔϜಋೖͷεςοϓ ‣ 4UFQ৽نͷίʔυʹ͚ͩಋೖ͢Δ ‣ طଘͷίʔυ͸৮Βͳͯ͘΋0, ‣ 4UFQςετͷް͍Ϟδϡʔϧ͔Βಋೖ͢Δ ‣ ҆શੑ͕୲อ͞Ε΍͍͢ ‣

    4UFQϧʔϧΛνʔϜͰڞ༗͢Δ ‣ EPDTUSJOHͷϑΥʔϚοτͳͲ ‣ 4UFQ੩తղੳͱ૊Έ߹Θͤͯίʔυن໿ͱͯ͠ఆணͤ͞Δ ‣ ͜͜·ͰདྷΕ͹ࣗಈతʹճΓ࢝ΊΔ
  33. ܖ໿νΣοΫͷ༗ޮʗແޮ੾Γସ͑ ‣ ։ൃ؀ڥ ‣ جຊతʹ͸͢΂ͯͷܖ໿νΣοΫΛ༗ޮʹ͢΂͖ ‣ ςετ࣮ߦ΍εςʔδϯάͰଈ࠲ʹྫ֎Λ౤͛ͯݪҼΛಛఆ͢Δ ‣ ϓϩμΫγϣϯ؀ڥ ‣

    0QUJPOܖ໿νΣοΫΛແޮԽ͢ΔʢੑೳΛॏࢹʣ ‣ 0QUJPOக໋తͳෆม৚͚݅ͩ༗ޮԽ͢Δʢ҆શੑΛॏࢹʣ ‣ 0QUJPOϩάʹग़ྗ͢Δ͚ͩͰॲཧ͸ଓߦ͢Δʢམͱͤͳ͍γεςϜʣ
  34. ܖ໿ͱςετ͸໾ׂ͕ҧ͏ ‣ ܖ໿ʢࣄલ৚݅ɾࣄޙ৚݅ɾΫϥεෆมද໌ʣ ‣ ϓϩάϥϜͷڥքΛकΔ ‣ ςετ ‣ ࣮ࡍʹίʔυΛಈ͔ͯ͠࢓༷Ͳ͓Γʹಈ͘͜ͱΛ֬ೝ͢Δ ‣

    ܖ໿ͰΧόʔͰ͖ͳ͍෦෼Λݕূ͢Δ ‣ ܖ໿Ͱ͸දͤͳ͍࢓༷ ‣ ྫ֎తͳϑϩʔ ‣ ྆ྠͰ࢖͏͜ͱͰ҆৺ײΛߴΊΔ
  35. ςετͷϙΠϯτ ‣ ܖ໿͕ಘҙͱ͢Δൣғ͸ܖ໿ͷྗΛआΓΔ΂͖ ‣ ܗࣜతʹදݱͰ͖Δ෦෼ ‣ Ҿ਺΍໭Γ஋ͷੑ࣭ ‣ ΦϒδΣΫτͷ੔߹ੑ ‣

    ςετ͕ಘҙͱ͢Δൣғʹ஫ྗ͢Δ ‣ ࣮ߦͯ͠Έͳ͍ͱ෼͔Βͳ͍෦෼ ‣ ֎෦γεςϜͱͷ࿈ܞ ‣ ྫ֎ܥͳͲಛघͳϑϩʔ