G. Jagiella

Skrypt do wykładu Programowanie 2 (Python)

ostatnia modyfikacja: 08.06.2021

Wykład 14 - dekoratory, interfejsy i abstrakcyjne klasy bazowe

Składnia przekazywania argumentów

Rozdział rozpoczynamy od uogólnienia sposobu przekazywania argumentów funkcjom w Pythonie - w szczególności w sytuacji, gdy ilość argumentów nie jest znana w momencie pisania kodu. Posłużą do tego tak zwane wyrażenia z gwiadzką.

Uwaga. "Wyrażenia z gwiazdką" pojawiają się w Pythonie w kilku kontekstach; w tym rozdziale prezentujemy jeden z nich (a właściwie dwa, ściśle ze sobą związane).

Rozważmy elementarny przykład funkcji trzech parametrów pozycyjnych:

In [1]:
def f(a, b, c):
    print(a, b, c)
    
x = 1
y = 2
z = 3
f(x, y, z)
1 2 3

Załóżmy, że zamiast trzech nazw x, y, z mamy krotkę o nazwie t składającą się z trzech obiektów, np. (1, 2, 3). Nie możemy jej oczywiście przekazać jako (jedynego) parametru funkcji f. Moglibyśmy ją wcześniej rozpakować do trzech nazw, i przekazać te nazwy:

In [2]:
t = (1, 2, 3)
x, y, z = t
f(x, y, z)
1 2 3

Ten sposób działa, ale jedynie w sytuacji, gdy znamy ilość argumentów, które powinniśmy przekazać f (możemy zawczasu nie wiedzieć, czym ta funkcja jest - sama może być parametrem). Istnieje sposób, aby przekazać obiekty z dowolnej krotki (ogólniej: obiektu iterowalnego) jako kolejne argumenty pozycyjne, używając wyrażeń z gwiazdką:

In [3]:
s = (1, 2, 3)
f(*s)
1 2 3

Wyrażenie *t powoduje "rozpakowanie" zawartości t w miejsce tylu argumentów, ile wynosi długość t. O wyrażeniu można myśleć w ten sposób, że zastępuje przekazanie kolejnych argumentów na kolejnych pozycjach. W szczególności oznacza to, że wyrażeń z gwiazdką można używać w zastępstwie dowolnego podciągu argumentów pozycyjnych:

In [4]:
def g(a, b, c, d, e):
    print(a, b, c, d, e)

t = (1, 2)
g(*t, 3, 4, 5)
g(1, *[2, 3, 4], 5) # lista zamiast krotki
g(*(1, 2, 3), *(4, 5))
1 2 3 4 5
1 2 3 4 5
1 2 3 4 5

Rozmiary obiektów iterowalnych przekazywanych w wyrażeniach z gwiazdką nie muszą być znane w momencie pisania kodu.

Podobnie, jak dla argumentów pozycyjnych, istnieje sposób przekazywania argumentów nazwanych. Rozważmy funkcję:

In [5]:
def h(a, b, c, x=10, y=20, z=30):
    print(a, b, c, x, y, z)
    
h(1, 2, 3)
h(1, 2, 3, x=4, y=5)
h(1, 2, 3, z=6, y=5)
1 2 3 10 20 30
1 2 3 4 5 30
1 2 3 10 5 6

Ponieważ niektóre z parametrów funkcji są opcjonalne (nazwane), rozpakowywanie ich z krotki nie byłoby wystarczające. Zamiast tego, parametry można przekazać jako słownik, którego parami klucz-wartość są napisy nazywające parametry i stowarzyszone z nimi wartości do przekazania. Takie stowarzyszenie nazw parametrów i ich wartości przekazuje się przez wyrażenie z dwoma gwiazdkami:

In [6]:
d = {'x': 100, 'y': 200}

h(1, 2, 3, **d)
1 2 3 100 200 30

Przekazywanie argumentów możemy dokonywać w dowolny sposób mieszając przekazywanie ich explicite i przez wyrażenia z jedną lub dwoma gwiazdkami:

In [7]:
h(*(1, 2), 3, z=5, **{'x': 'iks'})
1 2 3 iks 20 5

Przypomnijmy też, że słowniki można konstruować przekazując do zwykłego konstruktora dowolne argumenty nazwane. Napisy reprezentujące te argumenty stają się wtedy kluczami w słowniku z odpowiadającymi wartościami:

In [8]:
d = dict(x='X', z='Z')
print(d)
{'x': 'X', 'z': 'Z'}

Składnia konstrukcji takiego słownika "pasuje" wtedy do jego użycia przy przekazywaniu argumentów nazwanych:

In [9]:
d = dict(x='X', z='Z')
h(1, 2, 3, **d)
1 2 3 X 20 Z

Sposób z początku rozdziału pozwala nam przekazanie funkcji dowolnej ilości parametrów - również nazwanych, bez konieczności decydowania o ich ilości w momencie pisania kodu. Nasuwa się pytanie, czy możemy dokonać zabiegu odwrotnego, czyli napisać funkcję, która przyjmuje dowolną ilość parametrów (pozycyjnych lub nazwanych). Wiemy, że takie funkcji istnieją, przede wszystkim wbudowana funkcja print.

Zabiegu takiego można dokonać w sposób dualny do rozpakowywania argumentów: przez pakowanie argumentów do krotek i słowników, używając wyrażeń z gwiazdką (lub dwoma) w definicji funkcji:

In [10]:
def f(a, b, *args):
    print(a, b, args)
    
f(1, 2, 3, 4, 5)
1 2 (3, 4, 5)

Wystąpienie parametru pozycyjnego poprzedzonego gwiazdką prowadzi do przechwycenia wszystkich argumentów pozycyjnych, które nie są przechwytywane przez parametry podane przed wystąpieniem tego parametru z gwiazdką (tutaj: a i b). Parametr args staje się krotką, zawierającą wszystkie te dodatkowe argumenty. Parametry a i b są obowiązkowe, zatem tak zdefiniowana f przyjmuje dowolną ilość argumentów pozycyjnych większą niż 1. Parametr z gwiazdką musi wystąpić jako ostatni parametr pozycyjny.

Podobnie, dowolnie nazwane argumenty nazwane można przechwycić do słownika przez podanie parametru z dwoma gwiazdkami:

In [11]:
def g(a, b, *args, **kwargs):
    print(a, b, args, kwargs)
    
g(10, 20, x=10, y=20, z=30)
10 20 () {'x': 10, 'y': 20, 'z': 30}

Słownik ma postać taką, jak słowniki używane do przekazywania (rozpakowywania) argumentów nazwanych. Zwróćmy też uwagę, że w wywołaniu g przekazaliśmy tylko dwa argumenty pozycyjne - w konsekwencji krotka args jest długości 0.

Niektóre parametry nazwane możemy podać explicite, nadając im też domyślne wartości. Do słownika przechwytującego argumenty trafią wtedy tylko te, które nie zostały podane w definicji funkcji:

In [12]:
def h(a, b, x=1, y=20, **kwargs):
    print(a, b, x, kwargs)
    
h(1, 2, x=10, y=20, z=30)
1 2 10 {'z': 30}

Tutaj słownik przechwycił jedynie jeden argument.

Zwracamy uwagę, że nazwy argumentów nazwanych przekazanych przez słownik mogą być dowolnymi legalnymi nazwami w Pythonie.

Uwaga. Nazwy parametrów args i kwargs są standardową konwencją w Pythonie: kwargs to skrót od keyword arguments, czyli argumentów nazwanych.

Rozważmy funkcję zdefiniowaną następująco:

In [13]:
def test(*args, **kwargs):
    pass

Tak ogólna postać funkcji pozwala na przekazywanie jej dowolnej ilości argumentów, pozycyjnych i nazwanych. Inspekcji argumentów możemy dokonać jak dla każdej krotki i słownika:

In [14]:
def test(*args, **kwargs):
    for arg in args:
        print('argument:', arg)
    for k, v in kwargs.items():
        print('keyword argument', k, '=', v)
        
test(10, 20, x=10, y=20, tower='Eiffel')
argument: 10
argument: 20
keyword argument x = 10
keyword argument y = 20
keyword argument tower = Eiffel

Krotki i słowniki tworzone w ten sposób są tej samej postaci, co krotki i słowniki używane przy rozpakowywaniu, zatem można ich w ten sposób użyć. Przykładowo, następująca funkcja duplikuje funkcjonalność print, ale dodając prefiks do każdej wypisywanej treści:

In [15]:
def new_print(*args, **kwargs):
    print(">>>", *args, **kwargs)
        
new_print('a', 'b', end='XX\n')
>>> a bXX

W matematyce często można się spotkać z funkcjonałami (zwanymi też, w różnych kontekstach, operatorami lub funkcjami wyższych rzędów), czyli funkcjami, których dziedziną i przeciwdziedziną są zbiory funkcji. Standardowe przykłady pochodzą z analizy i algebry: jednymi z najprostszych są operator różniczkowania (przykładowo, wielomianów jednej zmiennej), lub domnażania wielomianu jednej zmiennej przez tą zmienną. Funkcjonał taki przekształca jedną funkcję w inną.

W Pythonie, funkcje są tak zwanymi obywatelami pierwszej kategorii: są pełnoprawnymi obiektami, na których można wykonywać te same podstawowe działania, co na liczbach, listach, krotkach, własnych klasach etc. W szczególności, można przekazywać je jako argumenty funkcji, a same funkcje mogą je zwracać.

W szczególności więc rozważmy funkcję, która jako argument przyjmuje funkcję func (na razie dla uproszczenia przyjmijmy, że func sama nie ma parametrów), a następnie zwraca funkcję new_func taką, że jej wywołanie prowadzi do dwukrotnego wywołania func:

In [16]:
def do_twice(func):
    def new_func():
        func()
        func()
    return new_func

Tak napisaną funkcję (funkcjonał) możemy więc "nałożyć" na dowolną funkcję bez parametrów, uzyskując jej "podwojenie":

In [17]:
def f():
    print('!')
    
g = do_twice(f)

g()
!
!

Roważamy teraz sytuację, w której pewne funkcje - nazwijmy je f, g, h - są używane w kodzie wielokrotnie, i chcielibyśmy najmniejszym nakładem kosztów i jak najmniej inwazyjnie przerobić niektóre z nich na takie właśnie podwojenia. To znaczy: sprawić, aby za każdym razem, gdy wywołujemy gdzieś (na przykład) f i h, to wywołanie w rzeczywistości wywoływało odpowiednią funkcję dwukrotnie.

Jednym ze sposobów byłaby edycja całego kodu - zamieniajac wywołania funkcji na dwa. Jest to opcja dość inwazyjna. Można też zmienić treść odpowiednich funkcji. Prościej byłoby jednak podmienić same funkcje na ich podwojenia:

In [18]:
def f():
    print('!')
f = do_twice(f) # od teraz f nazywa podwojenie tego, co f nazywało przed tą linijką

def g(): # tego nie podwajamy
    print('?')
    
def h():
    print('.')
h = do_twice(h)

f()
g()
h()
!
!
?
.
.

Okazuje się, że powyższa konstrukcja - definicja funkcji, po której następuje podmiana jej na wynik nałożenia funkcjonału na nią samą - jest na tyle przydatna, że doczekała się pewnego skrótu (przykład tak zwanego lukru syntaktycznego, syntactic sugar - wprowadzonych dla wygody skrótowych reguł syntaktycznych):

In [19]:
@do_twice
def f():
    print('!')
    
f()
!
!

Poprzedzenie definicji funkcji przez @ i nazwę dekoratora do_twice jest równoważne z konstrukcją z poprzedniego przykładu. Pozwala to na zgrabny zapis, oznaczający, że funkcja ma zostać podmieniona.

Funkcjonały, pojawiąjące się w takim kontekście nazywają się dekoratorami (ściślej: dekoratorami funkcji). Ich celem jest rozszerzenie lub zmiana działania funkcji bez konieczności zmiany jej treści. Raz napisany dekorator można "zaaplikować" do dowolnej funkcji o zgodnych parametrach - inaczej mówiąc, "udekorować" ją.

Najpierw napiszmy dekorator "identycznościowy", zwracający nową funkcję, ale równoważną podanej:

In [20]:
def identity(func):
    def new_func(*args, **kwargs):
        return func(*args, **kwargs)
    return new_func

Zwrócona funkcja new_func przyjmuje dowolne argumenty, które są przekazywane w wywołaniu oryginalnej func. Następnie new_func zwraca to, co zwraca owo wywołanie func.

In [21]:
@identity
def f(a, b):
    print(a, b)
    
f(10, 20)
10 20

Będziemy teraz wprowadzać modyfikacje identity, prowadząc do konkretnych dekoratorów.

Przykład 1. Dekorator print_arguments, który dla każdego wywołania udekorowanej funkcji wypisze na ekran wartości przekazanych jej argumentów. Może być przydatny przy debugowaniu większej aplikacji, lub zbieraniu danych dotyczących jej wykonywania. Implementacja polega na wtrąceniu jednej linijki w identity, przed wywołaniem samej dekorowanej funkcji:

In [22]:
def print_arguments(func):
    def new_func(*args, **kwargs):
        print('function {} called with {} {}'.format(func.__name__, args, kwargs))
        return func(*args, **kwargs)
    return new_func

Przykładowe zachowanie po udekorowaniu typowej, rekurencyjnej implementacji algorytmu gcd Euklidesa:

In [23]:
@print_arguments
def gcd(a, b):
    if b == 0:
        return a
    return gcd(b, a % b)

print(gcd(10, 6))
function gcd called with (10, 6) {}
function gcd called with (6, 4) {}
function gcd called with (4, 2) {}
function gcd called with (2, 0) {}
2

Przykład 2. Dekorator print_return, który dla każdego wywołania udekorowanej funkcji wypisze na ekran to, co dane wywołanie zwróciło. Ma podobne zastosowania, co print_arguments, implementacja też polega na odpowiednim wtrąceniu:

In [24]:
def print_return(func):
    def new_func(*args, **kwargs):
        ret = func(*args, **kwargs)
        print('{} returned {}'.format(func.__name__, ret))
        return ret
    return new_func

Znów przykład dla gcd:

In [25]:
@print_return
def gcd(a, b):
    if b == 0:
        return a
    return gcd(b, a % b)

print(gcd(10, 6))
gcd returned 2
gcd returned 2
gcd returned 2
gcd returned 2
2

Przykład 3. Połączenie obu poprzednich przykładów - bez pisania nowego kodu. Do funkcji możemy zaaplikować więcej, niż jeden dekorator:

In [26]:
@print_arguments
@print_return
def gcd(a, b):
    if b == 0:
        return a
    return gcd(b, a % b)

print(gcd(10, 6))
function new_func called with (10, 6) {}
function new_func called with (6, 4) {}
function new_func called with (4, 2) {}
function new_func called with (2, 0) {}
gcd returned 2
gcd returned 2
gcd returned 2
gcd returned 2
2

Zwracamy uwagę, że print_arguments mówi o wywołaniach new_func - rzeczywiście, jest to nazwa funkcji zwróconej przez dekorator print_return, gdy został wywołany dla funkcji gcd.

Przykład 4. Zduszanie wyjątków: funkcja udekorowana przez suppress nie rzuca wyjątków (wszystkie są przechwytywane w funkcji zwracanej przez dekorator); w sytuacji, gdyby miał być rzucony, jest on raportowany, a funkcja zwraca None:

In [27]:
def suppress(func):
    def new_func(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except Exception as e:
            print('{} raised {} : {}'.format(func.__name__, type(e), e))
    return new_func

@suppress
def f():
    raise ValueError('Ow!')
    
f()
print(':)')
f raised <class 'ValueError'> : Ow!
:)

Wyrażenie pojawiające się po @ przed definicją funkcji nie musi być nazwą samego dekoratora. Może być również wywołaniem funkcji, która zwraca dekoratory.

Funkcja która zwraca dekoratory to, fundamentalnie, funkcja, która zwraca funkcję, która bierze funkcję i która zwraca funkcję. Funkcja zwracająca dekoratory może przyjmować dowolne argumenty i używać ich w definicji zwracanych dekoratorów.

Przykład 5. Dekorator powtarzania repeat(), działający jak `do_twice' (tym razem dla funkcji o dowolnych parametrach), ale o dowolnej zadanej ilości powtórzeń:

In [28]:
def repeat(n):
    def decorator_function(func):
        def new_func(*args, **kwargs):
            for i in range(n):
                func(*args, **kwargs)
        return new_func
    return decorator_function

Wywołanie repeat(n) zwraca więc dekorator decorator_function, którym można dekorować funkcję. Przykładowo:

In [29]:
@repeat(4)
def f():
    print('!')
    
@repeat(2)
def g(x):
    print(x, '!')
    
f()
g(1)
!
!
!
!
1 !
1 !

Przykład 6. Dekorator check_args() sprawdzający, czy wszystkie argumenty podane dekorowanej funkcji są instancjami zadanego typu:

In [30]:
def check_args(t):
    def decorator_function(func):
        def new_func(*args, **kwargs):
            for a in args:
                if not isinstance(a, t):
                    raise TypeError('{} is not an instance of {}'.format(a, t))
            for a in kwargs.values():
                if not isinstance(a, t):
                    raise TypeError('{} is not an instance of {}'.format(a, t))
            return func(*args, **kwargs)
        return new_func
    return decorator_function

Przykładowo, dla typu t=int:

In [31]:
@check_args(int)
def gcd(a, b):
    if b == 0:
        return a
    return gcd(b, a % b)

print(gcd(10, 6)) # ok
try:
    print(gcd(10, "kalosze")) # wyjątek!
except Exception as e:
    print(e)
2
kalosze is not an instance of <class 'int'>

Dekoratory mogą też zmieniać funkcjonalność w inny sposób, na przyklad zmieniając jej sygnaturę (przyjmowane parametry), nadając im nowe znaczenie.

Przykład 7. Dekorator repeating, który dodaje dekorowanej funkcji nowy parametr opcjonalny repeat. Jego wartość (domyślnie 1) mówi, ile razy funkcja ma być powtórzona:

In [32]:
def repeating(func):
    def new_func(*args, repeat=1, **kwargs):
        for i in range(repeat):
            func(*args, **kwargs)
    return new_func

Użycie:

In [33]:
@repeating
def f(x, y):
    print(x, y, ":)")
    
f("a", "b", repeat=5)
a b :)
a b :)
a b :)
a b :)
a b :)

Na koniec przykłady dwóch standardowych dekoratorów:

  • staticmethod, aplikowany do metod - przerabia (nieprzywiązaną) metodę zdefiniowaną w klasie na zwykłą funkcję, atrybut klasy. Przykład: struktura MinHeap z wykładu z metodami związanymi z indeksami węzłów.
  • Dekorator lru_cache ze standardowej biblioteki functools, przerabiający funkcję na taką, która używa schowka, podobnego jak w przykładach do rekurencji ze spamiętywaniem. Przykładowe użycie:
In [34]:
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n in range(2):
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(100))
354224848179261915075

Argument maxsize oznacza maksymalną ilość zapamiętanych, ostatnio użytych argumentów, z którymi została wywołana funkcja (dla None jest to ilość nieograniczona). Gdy funkcja zostaje wywołana z danym argumentem, zwracana wartość jest zapamiętywana i następne wywołania z takim argumentem nie prowadzą do wykonania funkcji, a tylko zwrócenia wartości ze schowka.

Rozważmy sytuację, w której prowadzimy symulację z użyciem różnego rodzaju pojazdów (samochodów, pociągów, samolotów, statków, etc.). Każdy pojazd ma swoją dziedzinę (ląd, woda, powietrze, może inne) oraz prędkość, których symulacja używa. Konkretniej, każdy obiekt implementuje metody get_domain i get_speed i jest instancją jednej z klas:

Ponieważ symulacja jest częścią większej aplikacji, chcemy wprowadzić silny mechanizm kontroli, czy obiekty przekazywane do jej różnych części, a także nowe napisane klasy reprezentujące takie obiekty spełniają powyższe założenia.

"Miękkim" sposobem takiej kontroli jest sprawdzenie po prostu, czy obiekty mają odpowiednie atrybuty (tu: metody) wbudowaną funkcją hasattr:

if hasattr(obj, "get_domain") and hasattr(obj, "get_speed"): ...

Mechanizm dziedziczenia klas daje silniejszy sposób takiej kontroli, dodatkowo podkreślający jaki jest typ obiektu, którego dany fragment kodu oczekuje. Naturalnym byłoby więc stworzenie klasy Vehicle uogólniającej pojazdy, zawierającej pożądane metody. Wtedy mamy łatwy sposób sprawdzania, czy dany obiekt spełnia założenia:

if isinstance(obj, Vehicle): ...

Wtedy wymuszamy też, aby nowo dopisane klasy (np. SpaceShip), które będą uczestniczyć w symulacji, również dziedziczyły z Vehicle, a więc koniecznie implementowały metody, których potrzebuje symulacja.

Napotykamy jednak na problem: w jaki sposób Vehicle powinno implementować get_domain i get_speed. O ile w konkrenych klasach Car czy Train, get_domain powinno zwracać "ląd", a dla get_speed mogą istnieć sensowne, specyficzne dla klasy algorytmy (używające np. mocy silnika, masy pojazdu, współczynników tarcia etc.), to nie ma takiego uniwersalnego sposobu dla ogólnie pojętego, abstrakcyjnego "pojazdu". Narzuca się wniosek, że get_domain i get_speed powinny pozostać niezaimplementowane. Z tego samego powodu, nie chcemy, aby w symulacji były wykorzystane obiekty typu Vehicle, a jedynie te instancje Vehicle, które są też instancjami jednej z klas reprezentujących konkretne pojazdy.

Najprostszym sposobem zgłaszania problemu użycia obiektu typu Vehicle jest rzucenie stosownego wyjątku NotImplementedError przez metodę pozbawioną implementacji; zmuszając tym samym podklasy do dostarczenia konkretnej realizacji metody, aby mogła być używana:

In [35]:
# podejście pierwsze
class Vehicle:
    def get_domain(self):
        raise NotImplementedError
    
    def get_speed(self):
        raise NotImplementedError
        
# konkretne klasy, np. Car:
class Car:
    def get_domain(self):
        return "land"
    
    def get_speed(self):
        return 100 # atrapa

Ten sposób wciąż pozwala jednak na tworzenie obiektów klasy Vehicle, które nie reprezentują prawdziwych obiektów.

W programowaniu obiektowym, rozwiązaniem powyższego problemu są interfejsy. Interfejs to pewnego rodzaju konstrukcja (niekoniecznie klasa!), składająca się z deklaracji metod, ale nie ich implementacji (czyli tak zwanych metod abstrakcyjnych). Konkretne klasy mogą implementować interfejs poprzez implementowanie jego metod.

Interfejs jest elementem języka UML: wygląda jak klasa, z nazwą i metodami zapisanymi kursywą. Jeśli klasa implementuje interfejs, tę relację zaznacza się strzałką podobną do tej z relacji dziedziczenia, ale przerywaną. Przykładowo, dla hipotetycznego interfejsu Vehicle deklarującego metody get_domain i get_speed oraz implementujących go klas z poprzedniego diagramu:

Interfejs można traktować jako "obietnicę" lub "kontrakt": każda klasa implementująca go gwarantuje, że implementuje pewne metody. W językach programowania, w których pojawiają się interfejsy, istnieją z reguły sposoby sprawdzania, czy dana klasa implementuje interfejs.

Na diagramach UML, oznaczając relacje między klasami, interfejsu można używać w zastępstwie klas, które go implementują. Na przykład, jeśli komponentami klasy Simulation są konkretne realizacje Vehicle, zaznaczyć to można następująco:

W niektórych językach, interfejsy są jego wbudowanymi elementami. Przykładowo, w Javie:

// Język: Java
// podwójny ukośnik rozpoczyna komentarz
interface Vehicle // definicja interfejsu
{
  public String get_domain(); // abstrakcyjna metoda get_domain interfejsu, bez implementacji
  public int get_speed(); // jak wyżej, dla get_speed
}

class Car implements Vehicle // konkretna klasa Car, która implementuje interfejs Vehicle
{
  public String get_domain() // konkretna implementacja get_domain...
  {
      return "land";
  }
  public int get_speed() // i get_speed
  {
      return 100;
  }
}

W Pythonie nie ma interfejsów per se, ale są sposoby emulowania ich: abstrakcyjne klasy bazowe (abstract base class, abc). Elastyczność Pythona pozwala na redefinicję wielu elementarnych operacji języka (przykładowo: definiowanie operacji arytmetycznych dla nowych klas). Jedną z funkcjonalności, którą można w nim zredefiniować jest instancjacja (czyli kreacja nowych obiektów).

Wbudowany moduł abc zawiera klasę bazową ABC, oraz dekoratory, służące do oznaczenia metod klas z niej dziedziczących jako metody abstrakcyjne. Klasa redefiniuje sposób tworzenia jej instancji w taki sposób, aby zapobiec kreacji klas, które zawierają metody abstrakcyjne (również, jeśli zostały odziedziczone). To pozwala na zaimplementowanie Vehicle jako klasy dziedziczącej z ABC, w której wszystkie metody są abstrakcyjne:

In [36]:
import abc

class Vehicle(abc.ABC):
    @abc.abstractmethod
    def get_domain(self):
        pass
    
    @abc.abstractmethod
    def get_speed(self):
        pass
    
try:
    veh = Vehicle()
except Exception as e:
    print('Wyjątek:', type(e), e)
Wyjątek: <class 'TypeError'> Can't instantiate abstract class Vehicle with abstract methods get_domain, get_speed

Już sama próba stworzenia obiektu typu Vehicle prowadzi do błędu.

Z tak utworzonej klasy Vehicle dziedziczyć mogą klasy Car, Train etc., konkretnie implementując obie metody - bez oznaczania ich, jako abstrakcyjne:

In [37]:
class Car(Vehicle):
    def get_domain(self):
        return "land"
    
    def get_speed(self):
        return 100
    
car = Car()
print(isinstance(car, Vehicle)) # Car jest instancją Vehicle - czyli realizuje wymagany interfejs
print(car.get_speed())
True
100

W ten sposób, Vehicle może być używany w charakterze interfejsu: nie można tworzyć obiektów typu Vehicle, ale można tworzyć obiekty różnych podklas, które dziedziczą z Vehicle.

Klasa nie musi w całości realizować danego interfejsu. W naszym przykładzie, rozważmy klasę LandVehicle, implementującą funkcję get_domain, ale pozostawiającą niezaimplementowaną abstrakcyjną metodę get_speed:

In [38]:
class LandVehicle(Vehicle):
    def get_domain(self):
        return "land"
    
try:
    veh = LandVehicle()
except Exception as e:
    print('Wyjątek:', type(e), e)
Wyjątek: <class 'TypeError'> Can't instantiate abstract class LandVehicle with abstract methods get_speed

Z tak utworzonej klasy mogą dziedziczyć inne - zostają wtedy podklasami Vehicle z częściowo zrealizowanym interfejsem.

Zastosowanie interfejsów zobaczymy na przykładzie niewielkiej aplikacji, służącej do symulowania wielu rozgrywek w kółko i krzyżyk pomiędzy dwoma graczami kontrolowanymi na różne sposoby (przez ludzi i komputer). Jej diagram jest następujący:

Całość programu znajduje się w katalogu ttt wraz z demonstracją w main.py. Tutaj omówimy głównie jej część związaną z użyciem interfejsu. Najpierw jednak opiszemy główną część samej symulacji.

  • Główną klasą aplikacji jest Game, reprezentującą symulator rozgrywek. Z jej punktu widzenia, w symulacji uczestniczą dwie inne klasy: Board, reprezentująca planszę do gry (wraz z jej aktualnym stanem) oraz Player, reprezentująca gracza. Dwie instancje Player są podawane przy konstrukcji obiektu Game - pierwszy z graczy zawsze gra kółkiem, drugi krzyżykiem. Game ma jedną kluczową metodę play(size, games, show) przeprowadzającą podaną ilość games symulacji rozgrywek na planszy o boku size. Parametr show to wartość boolowska - jeśli jest True, gra na bieżąco raportuje szczegółowo przebieg gry. Game ma też metodę get_score_string() służącą do odczytania końcowych wyników przeprowadzonych symulacji.
  • Board jest wewnętrznie reprezentowana przez listę list pół planszy, z których pole to napis przyjmujący jedną z trzech możliwych wartości: '.' oznacza puste pole, 'O' i 'X' oznaczają pola zajęte odpowiednio przez kółko i krzyżyk. Plansza pozwala na odczytywanie i zapisywanie wartości pól o zadanych współrzędnych oraz posiada kilka metod badających stan planszy, między innymi:
    • get_winner(), zwracająca symbol zwycięzcy w aktualnie reprezentowanej pozycji: 'O', 'X', lub None.
    • is_draw() zwracająca True dokładnie wtedy, gdy pozycja jest remisowa (nie ma wolnych pól i nie ma zwycięzcy).
    • get_empty_cells() zwraca listę współrzędnych pustych pól.
  • Klasa Player zostanie też omówiona osobno - tu podamy, jakich jej metod używa Game:
    • start_game(symbol), nadaje graczowi jego symbol ('O' lub 'X').
    • give_point(), wywoływane, gdy gracz wygrał.
    • get_move(board), metoda zwracająca ruch, który chce wykonać gracz (pamiętający symbol, którym gra) w podanej pozycji board. Zwracaną wartością są współrzędne pola.

Proces symulacji (to, co dzieje się po wywołaniu play() dla obiektu typu Game) jest następujący:

  • Tworzona jest nowa plansza - obiekt typu Board.
  • Obaj gracze otrzymują swoje symbole przez wywołanie start_game(symbol) (w pojedynczej symulacji gracze zawsze grają tymi samymi symbolami, ale zakładamy, że obiekty klasy Player tego nie wiedzą).
  • Obaj gracze naprzemiennie pytani są o wykonane ruchy przez wywołania get_move(board), której argumentem jest aktualny stan planszy. Jeśli wywołanie zwróci nielegalny ruch, to jest powtarzane.
  • Legalne ruchy są odnotowywane na planszy.
  • Jeśli ruch doprowadza do remisu lub wygranej, gra się kończy i instancja Game zapamiętuje wynik. Gracz, który wygrał (jeśli taki jest) otrzymuje punkt przez wywołanie na nim give_point()
  • Proces powtarza się tyle razy, ile wynosi wartość parametru games przekazanego w wywołaniu play().
Uwaga. W kółku i krzyżyku to, który gracz jest na ruchu, można wywnioskować ze stanu planszy (parzystości ilości kółek na niej). Załóżmy jednak, że o tym nie wiemy.

Teraz opiszemy, jak wygląda symulacja z punktu widzenia klasy Player. W istocie, gracz jest reprezentowany przez parę klas: sama klasa Player i jej komponent, który jest dowolną klasą implementującą interfejs (tu: abstrakcyjną klasę bazową) Controller.

  • O klasie Player można myśleć jako tożsamości gracza wewnątrz gry. Obiekty tej klasy przechowują podstawowe informacje o graczu, na przykład imię, statystyki itd. W aplikacji reprezentujemy tylko imię i (zbiorczo) atrapę pozostałych własności. Obiekt klasy Player przechowuje też używany w trakcie rozgrywki symbol gracza, oraz jego kontroler.
    • Metoda get_move(board) dostaje jako argument planszę, przechowującą aktualną pozycję - wraz z przechowywanym przez gracz symbolem, jest to komplet informacji, jakie gracz musi posiadać, aby rozstrzygnąć jaki ruch ma wykonać. Dobrze jest mysleć o tym w taki sposób: gracz dostaje planszę z symbolami, ale sam przechowuje symbol, który reprezentuje go na planszy.
  • O klasach implementujących Controller myślimy jako o sposobach kontroli gracza w grze: przez AI (być może wiele rodzajów), lub człowieka kontrolującego gracza z pomocą klawiatury lub pada. Każda instancja Player przed rozgrywką musi zostać stowarzyszona z kontrolerem.
    • Kontroler ma tylko jedną metodę: get_move(board, symbol), której zadaniem jest zwrócić ruch, który ma wykonać gracz sterowany danym kontrolerem. Dostaje ona jako parametr zarówno planszę, jak i symbol. Nie musi natomiast znać instnacji Player, z którą jest stowarzyszony kontroler. Dzięki temu, ten sam kontroler może kontrolować jednocześnie wielu graczy.
    • Nie istnieje sensowna, domyślna implementacja get_move dla kontrolera - dlatego Controller jest klasą abstrakcyjną.

Przyjrzyjmy się fragmentowi klasy Player:

class Player:
    def __init__(self, name, other_info, controller):
        if not isinstance(controller, Controller):
            raise ValueError('Wrong type of controller')
        self.name = name
        self.other_info = other_info # nie używane
        self.victories = 0
        self.controller = controller
        self.symbol = None

    # ...

    def get_move(self, board):
        return self.controller.get_move(board, self.symbol)

Dla uproszczenia zakładamy, że kontroler jest stowarzyszany z obiektem typu Player już w momencie jego kreacji (w praktyce moglibyśmy dopuszczać zmianę kontrolera w czasie działania programi - raz gracz jest kontrolowany przez maszynę, raz przez człowieka etc.).

Jak widać z implementacji, get_move klasy Player nie podejmuje żadnej decyzji o wykonywanym ruchu, a deleguje ją do kontrolera, przekazując mu wszystkie potrzebne informacje (przekazany board i pamiętany symbol). Z tej perspektywy, o kontrolerze można myśleć jak o wkomponowanej w Player strategii wykonywania ruchu. Strategia ta jest jednym z atrybutów gracza.

Dowolna konkretna klasa realizująca interfejs kontroler dostarcza takiej strategii. Sam interfejs jest zdefiniowany następująco:

import abc

class Controller(abc.ABC):
    @abc.abstractmethod
    def get_move(self, board, symbol):
        pass

Przykładowy kontroler ControllerRandom zawsze wykonuje losowy ruch (spośród dostępnych, wolnych pól):

from controller import Controller
import random


class ControllerRandom(Controller):
    def get_move(self, board, symbol):
        cells = board.get_empty_cells()
        return random.choice(cells)

W aplikacji zaimplementowane są też kontrolery ControllerHuman, pobierający żądany ruch z wejścia przez input() - można go traktować jako atrapę kontrolera przywiązanego do dowolnego urządzenia wejściowego, np. pada - po jednej instancji klasy na urządzenie; oraz klasa ControllerMinMax, która gra w sposób optymalny - wykonując ruchy tak, aby nie przegrać (lub wygrać, o ile w danej pozycji jest to możliwe). Druga z tych klas wewnętrznei rozwiązuje grę w kółko i krzyżyk za pomocą programowania dynamicznego (rekurencyjnie ze spamiętywaniem).

Po zaimportowaniu odpowiednich obiektów, można ich użyć następująco:

In [39]:
import os       # można zignorować (na potrzeby Jupytera)
os.chdir('ttt') # można zignorować

# importowanie obiektów:
from game import Game
from player import Player
from controller_random import ControllerRandom
from controller_human import ControllerHuman
from controller_minmax import ControllerMinMax

# tworzymy kontrolery: jeden losowy, jeden grający optymalnie:
controller1 = ControllerRandom()
controller2 = ControllerMinMax()

# tworzymy parę graczy, każdy stowarzyszony z kontrolerem:
player1 = Player("Anna", "very important information", controller1)
player2 = Player("Bart", "also very important information", controller2)

# prowadzimy symulację i pokazujemy wynik:
num_games = 100
game = Game(player1, player2)
game.play(3, num_games, False)
print(game.get_score_string())

os.chdir('..') # można zignorować
Score - O: 0, X: 82, Draws: 18

Podobna demonstracja znajduje się w pliku ttt/main.py.