Programowanie z kontraktami w języku Python

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

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

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 biblioteki do określenia warunków, które muszą być prawdziwe po wykonaniu funkcji.
  • 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!

TUX - maskotka systemu Linux

About the author

Autor "BIELI" to zapalony entuzjasta otwartego oprogramowania, który dzieli się swoją pasją na blogu poznajlinuxa.pl. Jego wpisy są skarbnicą wiedzy na temat Linuxa, programowania oraz najnowszych trendów w świecie technologii. Autor "BIELI" wierzy w siłę społeczności Open Source i zawsze stara się inspirować swoich czytelników do eksplorowania i eksperymentowania z kodem.