Wstęp
Witaj w kolejnym wpisie na blogu poświęconym programowaniu na systemach Linux! Dziś zagłębimy się w fascynujący świat programowania kontraktowego z użyciem języka Python i dostępnych w nim popularnych bibliotek.
Programowanie kontraktowe to podejście w programowaniu komputerów, które pozwala na definiowanie precyzyjnych warunków, które muszą być spełnione przed, w trakcie i po wykonaniu funkcji lub metod. To narzędzie, które nie tylko zwiększa niezawodność kodu, ale również sprawia, że staje się on bardziej przejrzysty i czytelny, w odniesieniu do ekspresji logiki biznesowej.

Czym jest programowanie kontraktowe?
Programowanie kontraktowe, DbC (ang. Design by Contract), opiera się na założeniu, że funkcje i metody powinny mieć jasno określone prewarunki (ang. preconditions
), postwarunki (ang. postconditions
) oraz inwarianty, niezmienności (ang. invariants
).
Oto krótkie wyjaśnienie każdego z tych elementów:
- Prewarunki: Warunki, które muszą być spełnione przed wykonaniem funkcji. Przykładem może być sprawdzenie, czy argumenty funkcji są prawidłowe.
- Postwarunki: Warunki, które muszą być spełnione po zakończeniu wykonania funkcji. Przykładem może być sprawdzenie, czy wynik funkcji spełnia określone kryteria.
- Inwarianty: Warunki, które muszą być zawsze prawdziwe dla obiektu w trakcie jego istnienia, z wyjątkiem momentów, gdy metoda obiektu jest wykonywana.
Tutaj opisane zostały kontrakty w języku programowania D – dla chcących zgłębić tematykę.
Dlaczego warto korzystać z programowania kontraktowego?
Programowanie kontraktowe ma wiele zalet:
- Zwiększona Niezawodność: Dzięki precyzyjnym warunkom, możemy wcześniej wykryć błędy i zapobiec ich eskalacji.
- Lepsza Czytelność: Warunki kontraktów działają jak dokumentacja, jasno określając, czego można się spodziewać po funkcji.
- Łatwiejsze Debugowanie: Błędy są łatwiejsze do zlokalizowania, ponieważ są zgłaszane w momencie naruszenia warunków.
Biblioteka icontract
w Python do programowania kontraktowego
W języku programowania Python możemy wykorzystać bibliotekę icontract
, która umożliwia implementację programowania kontraktowego w prosty i elegancki sposób. Dzięki icontract
możemy definiować prewarunki, postwarunki oraz inwarianty za pomocą dekoratorów, co sprawia, że kod jest bardziej zorganizowany i czytelny.
W kolejnych częściach tego wpisu pokażemy, jak zainstalować icontract
, a także przedstawimy przykłady implementacji kontraktów w praktycznych scenariuszach. Będziemy eksplorować różne aspekty programowania kontraktowego, aby pomóc Ci zrozumieć, jak wprowadzić tę metodologię do swojego codziennego kodowania.
Zasadniczo, aby skorzystać z biblioteki można użyć PIP:
pip install icontract
Przykład kontraktu dla argumentów wejściowych funkcji w języku Python
import icontract
@icontract.require(lambda denominator: denominator != 0, "Denominator must not be zero")
def divide(numerator, denominator):
return numerator / denominator
print(divide(10, 2)) # Outputs: 5
print(divide(10, 0)) # Throws a icontract.errors.ViolationError
# Wyjście i spodziewany wyjątek
#icontract.errors.ViolationError: File 0.py, line 3 in <module>:
#Denominator must not be zero: denominator != 0:
#denominator was 0
#numerator was 10
Przykład kontraktu dla argumentów wyjściowych funkcji w języku Python
import icontract
@icontract.ensure(lambda result: result >= 0, "Result must be non-negative")
def absolute_difference(a, b):
return a - b
print(absolute_difference(10, 3)) # Outputs: 7
print(absolute_difference(3, 10)) # Raises a icontract.errors.ViolationError
# Wyjście i spodziewany wyjątek
#icontract.errors.ViolationError: File 1.py, line 3 in <module>:
#Result must be non-negative: result >= 0:
#a was 3
#b was 10
#result was -7
Przykład inwariantów w języku Python
import icontract
@icontract.invariant(lambda self: self.balance >= 0, "Balance must be non-negative")
class BankAccount:
def __init__(self, initial_balance):
assert initial_balance >= 0, "Initial balance must be non-negative"
self.balance = initial_balance
@icontract.require(lambda amount: amount > 0, "Deposit amount must be positive")
def deposit(self, amount):
self.balance += amount
@icontract.require(lambda amount: amount > 0, "Withdrawal amount must be positive")
#### @icontract.require(lambda self, amount: self.balance >= amount, "Insufficient balance") ### celowo zakomentowana linia dla pokazania działania invariant
def withdraw(self, amount):
self.balance -= amount
def get_balance(self):
return self.balance
account = BankAccount(100)
account.deposit(50)
print("Balance after deposit:", account.get_balance()) # Outputs: 150
account.withdraw(30)
print("Balance after withdrawal:", account.get_balance()) # Outputs: 120
account.withdraw(300) # Throws a icontract.errors.ViolationError (Balance must be non-negative)
# Wyjście i spodziewany wyjątek
#icontract.errors.ViolationError: File 3.py, line 3 in <module>:
#Balance must be non-negative: self.balance >= 0:
#self was <__main__.BankAccount object at 0x7fe1058244f0>
#self.balance was -180
Biblioteka PyContracts
w Python do programowania kontraktowego
PyContracts
Biblioteka PyContracts
w Pythonie umożliwia stosowanie programowania kontraktowego przez definiowanie prewarunków, postwarunków i inwariantów. Oto kilka przykładów kodów, które ilustrują, jak korzystać z tej biblioteki.
W celu użycia biblioteki można użyć PIP:
pip install PyContracts
# jeśli będą występowały błędy podczas działania przykładów możesz wykonać poniższe kroki - krok 1/2 zainstaluj poniższy pakiet we wskazanej wersji
pip install pyparsing==2.4.7
# krok 2/2 - jeśli nada będą występowały błędy - musisz rozwiązać problem w z np.<typ> (usunąć "np." w przemapowaniu w kodzie biblioteki "contracts/library/array_ops.py" - to było wymagane dla Python3.8, w Twojej wersji nie koniecznie może występować!)
Na potrzeby działania przykładów zmieniłem w lokalnym ekosystemie (zaznaczam, to nie jest docelowe rozwiązanie a spostrzeżenie dla przypadków będów w różnych wersji Pythona i biblioteki NumPy, która jest zależnością PyContracts):
np_types = {
'np_int': int, # Platform integer (normally either int32 or int64)
...
'np_float': float, # Shorthand for float64.
...
'np_complex': complex, # Shorthand for complex128.
}
Przykład kontraktu dla argumentów wejściowych funkcji w języku Python
from contracts import contract
@contract(numerator='int', denominator='int,!=0', returns='float')
def divide(numerator, denominator):
return numerator / denominator
print(divide(10, 2)) # Outputs: 5.0
print(divide(10, 0)) # Raises ContractNotRespected: divide(numerator=10, denominator=0) violated precondition denominator != 0
##### Spodziewane wyjście: wyjątek ze stosem plus poniższe informacje
#contracts.interface.ContractNotRespected: Breach for argument 'denominator' #to divide().
#Shouldn't have satisfied the clause =0.
#checking: !=0 for value: Instance of <class 'int'>: 0
#checking: int,!=0 for value: Instance of <class 'int'>: 0
#Variables bound in inner context:
Przykład kontraktu dla argumentów wyjściowych funkcji w języku Python
from contracts import contract
@contract(x='int,>=0', returns='int')
def sqrt(x):
return int(x ** 0.5)
print(sqrt(25)) # Outputs: 5
print(sqrt(9)) # Outputs: 3
print(sqrt(-4)) # Raises ContractNotRespected: sqrt(x=-4) violated precondition x >= 0
##### Spodziewane wyjście: wyjątek ze stosem plus poniższe informacje
#contracts.interface.ContractNotRespected: Breach for argument 'x' to sqrt().
#Condition -4 >= 0 not respected
#checking: >=0 for value: Instance of <class 'int'>: -4
#checking: int,>=0 for value: Instance of <class 'int'>: -4
#Variables bound in inner context:
Przykład inwariantów w języku Python (ważna uwaga! w tej bibliotece nie ma invariantów, stąd użycie assercji, jako przykład zastępczy)
from contracts import contract, new_contract
# Definiowanie własnych typów kontraktów
new_contract('positive', lambda x: x > 0)
new_contract('non_negative', lambda x: x >= 0)
class BankAccount:
def __init__(self, initial_balance):
# Prewarunki konstruktora
assert initial_balance >= 0, "Initial balance must be non-negative"
self.balance = initial_balance
self.check_invariant()
@contract(amount='positive')
def deposit(self, amount):
self.balance += amount
self.check_invariant()
@contract(amount='positive')
def withdraw(self, amount):
assert self.balance >= amount, "Insufficient balance"
self.balance -= amount
self.check_invariant()
@contract(returns='non_negative')
def get_balance(self):
return self.balance
def check_invariant(self):
assert self.balance >= 0, "Balance must be non-negative"
# Tworzenie instancji klasy BankAccount
account = BankAccount(100.0)
account.deposit(50.0)
print("Balance after deposit:", account.get_balance()) # Outputs: 150.0
account.withdraw(30.0)
print("Balance after withdrawal:", account.get_balance()) # Outputs: 120.0
# Poniższa linia wywoła wyjątek, ponieważ saldo nie wystarczy na wypłatę
account.withdraw(200.0) # Raises AssertionError: Balance must be non-negative
##### Spodziewane wyjście: wyjątek ze stosem plus poniższe informacje
#Balance after deposit: 150.0
#Balance after withdrawal: 120.0
#Traceback (most recent call last):
#...
# assert self.balance >= amount, "Insufficient balance"
#AssertionError: Insufficient balance
Biblioteka covenant
w Python do programowania kontraktowego
covenant
Jest to lekka biblioteka bez zaimplementowanych zaawansowanch inwariantów, która dobrze sobie radzi z funkcjami o jednym argumencie. Pomyślałem, że może w konkretnych i minimalistycznych zastosowaniach się Wam przyda.
Jak pobrać tą bibliotekę
$ git clone https://github.com/kisielk/covenant.git
$ cd covenant/
$ pip3 install .
Przykład kontraktu dla argumentów wejściowych funkcji w języku Python
from covenant.conditions import *
@pre(lambda x: x < 10)
@pre(lambda x: x > 3)
def foo(x):
return x
print(foo(4))
print(foo(9))
print(foo(10)) # Raises covenant.exceptions.PreconditionViolationError: Precondition check failed.
# Komentarz: ograniczeniem dekoratora @pre jest obsługa jednoargumentowych metod, gdy chcemy dodać predykat dla drugiego argumentu funkcji mamy wyjątki z biblioteki
Przykład kontraktu dla argumentów wyjściowych funkcji w języku Python
from covenant.conditions import *
@post(lambda r, a: r == a * 2)
@post(lambda r, a: r % 2 == 0)
@post(lambda r, a: True)
def foo_post(a):
return a * 2
print(foo_post(2))
print(foo_post("100")) # Raises covenant.exceptions.PostconditionViolationError: Postcondition check failed: not all arguments converted during string formatting
Przykład inwariantów w języku Python
from covenant.invariant import *
@invariant(lambda self: self.foo >= 0)
class Foo(object):
foo = 0
def __init__(self):
self.foo = 0
def add(self, num):
self.foo += num
f = Foo()
f.add(5)
f.add(-6) # Raises covenant.exceptions.InvariantViolationError: Invariant violated.
Podsumowanie programowania kontraktowego w języku Python
- Prewarunki: Stosowanie asercji lub
biblioteki
do określenia warunków, które muszą być prawdziwe przed wykonaniem funkcji. - Postwarunki: Użycie
do określenia warunków, które muszą być prawdziwe po wykonaniu funkcji.biblioteki
- Inwarianty: Definiowanie inwariantów klasy za pomocą
biblioteki
Te przykłady ilustrują, jak można zaimplementować programowanie kontraktowe w Pythonie, aby zapewnić bardziej niezawodny i czytelny kod. Pytanie jakie zazwyczaj nam się nasuwa:
Jaki jest koszt stosowania walidacji dla trybu działania programu (tzw. runtime) w Python?
Aby odpowiedzieć na to pytanie potrzebujemy przeprowadzić eksperyment z pomiarami czasów działania kodu z kontraktami i bez kontraktów w języku Python. Oto propozycje:
Kod Bez Kontraktów
import timeit
def divide(numerator, denominator):
return numerator / denominator
# Benchmark
time_without_contracts = timeit.timeit('divide(10, 2)', globals=globals(), number=1000000)
print(f"Time without contracts: {time_without_contracts:.6f} seconds")
Kod Z Kontraktami
import icontract
import timeit
@icontract.require(lambda denominator: denominator != 0, "Denominator must not be zero")
def divide_with_contracts(numerator, denominator):
return numerator / denominator
# Benchmark
time_with_contracts = timeit.timeit('divide_with_contracts(10, 2)', globals=globals(), number=1000000)
print(f"Time with contracts: {time_with_contracts:.6f} seconds")
Wyniki porównawcze dla czasów działania kodu z kontraktami i bez kontraktów
(.venv) icontract-benchmark $ python3 without.py
Time without contracts: 0.157390 seconds
(.venv) icontract-benchmark $ python3 with.py
Time with contracts: 3.211050 seconds
(.venv) icontract-benchmark $ python3
Python 3.8.10 (default, Feb 4 2025, 15:02:54)
[GCC 9.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> 3.211050 / 0.157390
20.40186797128153
Biorąc nawet tak prymitywną metodę testowania okazuje się, że kontrakty spowalniają kod Pythona ok. 20 razy – naturalnie tego się można było spodziewać. Jednakże nie przekreśla to całej masy dodatnich efektów, jakimi są walidacje logiki biznesowej w zapisanej w tym samym miejscu, czyli w kodzie. Do przeprowadzenia testów precyzyjnie potrzebujemy więcej przypadków i wersji Pythona i systemów z różnymi jądrami Linuxa, aby posiadać pełny obraz zachowania i czasów działania.
Słowo końcowe
Polecam programowanie kontraktowe jako narzędzie walidacji logiki biznesowej i stałej kontroli nad tym, jak na prawdę działa program. Szczególnie w języku programowania Python, który jest interpretowalny i pozwala na zgadywanie typów przez interpreter powinno to mieć duże znaczenie!
Dobrego kodowania ze sprawnymi kontraktami i stabilnego Linuxa!