Klasy i obiekty - elementy zaawansowane

Python jako język obiektowy - podstawowe informacje

Wszystko jest obiektem

Python jest językiem w pełni obiektowym - wszystko jest obiektem, także wartości prymitywne i funkcje.

(5).__add__(3)
8

Dynamiczne typowanie

Python jest językiem z dynamicznym typowaniem. Zmienne przechowują referencję do obiektu, a dopiero obiekty posiadają typ. Dlatego raz zdefiniowana zmienna może “zmienić typ”.

var = 'text'
var
'text'
var = 1
var
1

Dynamiczne typowanie znacznie ułatwia programowanie, ponieważ nie narzuca ograniczeń na typ zmiennych.

Dzięki temu programista może skupić się na bardziej istotnych aspektach, takich jak poprawność kodu, albo po prostu napisać kod szybciej.

Dzięki dynamicznemu typowaniu dostępna jest funkcja eval, która pozwala na wykonanie dowolnego, dynamicznie utworzonego wyrażenia:

x = 1
eval('x+1')
2

Dynamiczne typowanie umożliwia także łatwe użycie technik metaprogramowania, takich jak metaklasy, które zostaną omówione później. Pozwala to np. na dynamiczne tworzenie nowych typów. Dzięki temu implementacje ORM (Object-Relational Mapping, mapowanie obiektowo-relacyjne) są bardziej naturalne.

Tożsamość, typ a wartość

Każdy obiekt posiada:

  • tożsamość (identity) - wskazuje na lokalizację obiektu w pamięci i można ją sprawdzić wywołując wbudowaną funkcję id;

  • typ (type) opisuje reprezentację obiektu dla Pythona;

  • wartość (value) to dane przechowywane w obiekcie.

numbers = [1, 2, 3]
id(numbers)
140268810295360
type(numbers)
list
numbers
[1, 2, 3]

Definiowanie klasy

Klasę definiujemy za pomocą słowa kluczowego class:

class MyClass:
    def method(self):
        pass

W Pythonie wszystko jest obiektem, także klasa, dlatego możemy mówić o obiekcie klasy. Taki obiekt również ma swój typ:

MyClass
__main__.MyClass
type(MyClass)
type

Metody specjalne

Metodami specjalnymi nazywane są metody zaczynające i kończące się dwoma podkreślnikami. Implementują one operacje, które wywołujemy przy użyciu specjalnej składni (np. porównanie dwóch obiektów a < b, dostęp do atrybutów obj.attribute lub składnia obj[key]).

Najważniejsze metody specjalne

Metoda specjalna

Opis

__new__(cls[, ...])

Stworzenie nowej instancji klasy. Dokładne omówienie tej metody znajduje się w rozdziale o metaklasach.

__init__(self[, ...])

Konstruktor. Metoda wywoływana przy tworzeniu nowej instancji danej klasy.

__del__(self)

Destruktor. Ta metoda powinna być przeciążana tylko w szczególnych przypadkach. W większości przypadków lepiej jest utworzyć osobną metodę (np. close), która powinna być jawnie wywołana.

__repr__(self)

Wywoływana przez funkcję repr() i konwersję do ciągów znaków.

__str__(self)

Wywoływana przez funkcję str() i print()

__lt__(self, other), __le__, __eq__, __ne__, __gt__, __ge__

Operatory porównania wywoływane przez wyrażenia, takie jak a < b, a <= b itd.

__cmp__(self, other)

Funkcja porównania. Wywoływana, gdy nie zdefiniowano powyższych operatorów.

__hash__(self)

Wywoływana przez funkcję hash(), używana w kolekcjach.

__nonzero__(self)

Wywoływana w trakcie sprawdzania wartości bool.

Dostęp do atrybutów

Metoda specjalna

Opis

__getattr__(self, name)

Wywoływana, gdy obiekt nie ma atrybutu name

__setattr__(self, name, value)

Wywoływana podczas przypisywania atrybutów

__delattr__(self, name)

Wywoływana przy usuwaniu atrybutu (del obj.attr)

Przykład słownika wykorzystującego składnię dict.key zamiast dict[key]:

class Record:
    def __init__(self):
        # Nie możemy użyć poniższego kodu:
        #     self._d = {}
        # ponieważ zakończyłby się on rekurencyjnym wywoływaniem metody __setattr__
        super().__setattr__('_dict', {})

    def __getattr__(self, name):
        print('getting', name)
        return self._dict[name]
    
    def __setattr__(self, name, value):
        print('setting', name, 'to', value)
        self._dict[name] = value
        
    def __delattr__(self, name):
        print('deleting', name)
        del self._dict[name]
person = Record()
person.first_name = "John"
person.first_name
setting first_name to John
getting first_name
'John'
del person.first_name
deleting first_name

Metoda specjalna

Opis

__getattribute__(self, name)

Wywoływana bezwarunkowo przy dostępie do atrybutów klasy, nawet jeśli dany atrybut istnieje.

class Person:
    def __init__(self, first_name):
        self.first_name = first_name
    
    def __getattribute__(self, name):
        print('getattribute', name)
        return object.__getattribute__(self, name)
p = Person('John')
p.first_name
getattribute first_name
'John'

Przykład ilustrujący różnicę między __getattr__ a __getattribute__:

class Foo:
    def __init__(self):
        self.a = "a"

    def __getattr__(self,attribute):
        return f"You asked for {attribute}, but I'm giving you default"


class Bar:
    def __init__(self):
        self.a = "a"

    def __getattribute__(self,attribute):
        return f"You asked for {attribute}, but I'm giving you default"
foo = Foo()
foo.a
'a'
foo.b
"You asked for b, but I'm giving you default"
getattr(foo, "a")
'a'
getattr(foo, "b")
"You asked for b, but I'm giving you default"
bar = Bar()
bar.a
"You asked for a, but I'm giving you default"
bar.b
"You asked for b, but I'm giving you default"
getattr(bar, "a")
"You asked for a, but I'm giving you default"
getattr(bar, "b")
"You asked for b, but I'm giving you default"

Składowe chronione i prywatne

Składowe chronione

Składowe, które powinny być modyfikowane tylko przez klasę, powinny zaczynać się od podkreślnika. Jest to powszechnie przyjęta konwencja oznaczająca, że dana składowa nie powinna być modyfikowana z zewnątrz. Jest to jednak tylko konwencja - Python nie posiada mechanizmu ukrywającego takie składowe. Wciąż można je modyfikować spoza klasy.

class BankAccount:
    def __init__(self, initial_balance):
        self._balance = initial_balance
    
    @property
    def balance(self):
        return self._balance
account = BankAccount(100.0)
print(account.balance)
print(account._balance) # # składowe chronione są wciąż dostępne z zewnątrz klas
100.0
100.0

Składowe prywatne

Aby ukryć atrybut lub metodę przed dostępem spoza klasy (składowa private), należy jej nazwę poprzedzić dwoma podkreślnikami (np. __atrybut). Taka składowa jest dostępna tylko wewnątrz tej klasy. Składowe zaczynające się od dwóch podkreślników (nie będące metodami specjalnymi) są traktowane w szczególny sposób - ich nazwa zostaje zmieniona na _NazwaKlasy__atrybut. Do tej składowej można się wciąż odwołać z zewnątrz klasy, ale tylko używając zmienionej nazwy.

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance
    
    def withdraw(self, amount):
        self.__balance -= amount
    
    def deposit(self, amount):
        self.__balance += amount
    
    def info(self):
        print("owner:", self.owner, "; balance:", self.__balance)
jk = BankAccount("Jan Kowalski", 1000)
print(jk.__balance) # błąd!
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-29-e6f05cd5e055> in <module>
      1 jk = BankAccount("Jan Kowalski", 1000)
----> 2 print(jk.__balance) # błąd!

AttributeError: 'BankAccount' object has no attribute '__balance'
print(jk._BankAccount__balance) # ok
1000

Atrybuty klasy i metody statyczne

Składowe statyczne są wspólne dla wszystkich instancji klasy.

Z kolei metoda statyczna to po prostu funkcja w przestrzeni nazw klasy. Taką funkcję należy udekorować @staticmethod. Taka funkcja nie przyjmuje instancji klasy self.

class CountedObject(object):
    count = 0   # statyczna składowa
    
    def __init__(self):
        CountedObject.count += 1
    
    @staticmethod  # statyczna metoda
    def get_count():
        return CountedObject.count
CountedObject.get_count()
0
c1 = CountedObject()
c2 = CountedObject()
cs = [CountedObject(), CountedObject()]

CountedObject.get_count()
4

Czasami atrybutów klasy używa się, aby zainicjalizować domyślną wartość dla pewnych atrybutów instancji. Należy jednak być ostrożnym:

class PersonWithDefaultAttributes:
    first_name = 'John'
    last_name = 'Smith'
    phones = []

p1 = PersonWithDefaultAttributes()
p2 = PersonWithDefaultAttributes()

print(p1.first_name)
print(p2.first_name)
John
John
p1.first_name = 'Bob'
print(p1.first_name)
print(p2.first_name)
Bob
John
PersonWithDefaultAttributes.last_name = 'Williams'
print(p1.last_name)
print(p2.last_name)
Williams
Williams
p1.phones.append('+48123456789')
print(p1.phones)
print(p2.phones)
['+48123456789']
['+48123456789']

Metody klasy

Zwykła metoda ma dostęp do instancji klasy (poprzez parametr self). Z kolei metoda klasy ma dostęp do klasy, z której została wywołana, lub do klasy instacji, z której została wywołana.

Metody klasy są przydatne, jeżeli chcemy pozwolić na więcej niż jeden sposób tworzenia instancji.

class Date:
    def __init__(self, day, month, year):
        self.day = day
        self.month = month
        self.year = year
    
    @classmethod
    def from_string(cls, date_as_string):
        day, month, year = date_as_string.split('-')
        return cls(int(day), int(month), int(year)) # utworzenie instancji klasy cls
d1 = Date(20, 1, 2016)
d2 = Date.from_string('20-01-2016')

Warto zwrócić uwagę na to, że wewnątrz metody from_string tworzona jest nowa instancja klasy cls. Nie musi być to klasa Date. Tak będzie w przypadku klas dziedziczących po Date.

Deskryptory

Deskryptorem jest dowolny obiekt, który zawiera przynajmniej jedną z trzech poniższych metod specjalnych. Taki obiekt musi pojawić się jako atrybut w obiekcie - “właścicielu”. W momencie dostępu do takiego atrybutu wywoływane są odpowiednie metody deskryptora:

Metody specjalne deskryptora

Opis

__get__(self, instance, owner)

Wywoływana do pobrania atrybutu z klasy - “właściciela”

__set__(self, instance, value)

Wywoływana do ustawienia atrybutu z klasy - “właściciela”

__delete__(self, instance)

Wywoływana w czasie usuwania atrybutu w klasie - “właścicielu”

Przykład deskryptora

import random

class Die:
    def __init__(self, sides=6):
        self.sides = sides
    
    def __get__(self, instance, owner):
        print('self =', self)
        print('instance =', instance)
        print('owner =', owner)
        return int(random.random() * self.sides) + 1
    
    def __set__(self, instance, value):
        print('self =', self)
        print('instance =', instance)
        print('value =', value)
    
    def __delete__(self, instance):
        print('self =', self)
        print('instance =', instance)

class Game:
    d6 = Die()
    d10 = Die(sides=10)
    d20 = Die(sides=20)
Game.d6
self = <__main__.Die object at 0x7fc85b329460>
instance = None
owner = <class '__main__.Game'>
4
game = Game()
game.d20
self = <__main__.Die object at 0x7fc85b33b070>
instance = <__main__.Game object at 0x7fc85b3815b0>
owner = <class '__main__.Game'>
15
game.d20 = 42
self = <__main__.Die object at 0x7fc85b33b070>
instance = <__main__.Game object at 0x7fc85b3815b0>
value = 42
del game.d20
self = <__main__.Die object at 0x7fc85b33b070>
instance = <__main__.Game object at 0x7fc85b3815b0>

Właściwości (properties)

Przykładem deskryptora jest wbudowany dekorator @property pozwalający na enkapsulację obiektu, to znaczy kontrolę dostępu do atrybutów przy użyciu metod dostępowych.

class BankAccount:
    def __init__(self, daily_limit):
        self.__daily_limit = daily_limit
    
    @property
    def daily_limit(self):
        print('getting daily_limit')
        return self.__daily_limit
    
    @daily_limit.setter
    def daily_limit(self, value):
        if value < 0:
            raise ValueError('Value must be >= 0')
        self.__daily_limit = value
account = BankAccount(100.0)
account.daily_limit
getting daily_limit
100.0
account.daily_limit = 200.0
account.daily_limit
getting daily_limit
200.0
account.daily_limit = -100.0
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-73-764421fb5f17> in <module>
----> 1 account.daily_limit = -100.0

<ipython-input-70-a7a363ba8f92> in daily_limit(self, value)
     11     def daily_limit(self, value):
     12         if value < 0:
---> 13             raise ValueError('Value must be >= 0')
     14         self.__daily_limit = value

ValueError: Value must be >= 0

Gdyby nie został zdefiniowany setter, to znaczy w kodzie nie pojawiłby się @daily_limit.setter, wówczas daily_limit byłaby właściwością tylko do odczytu. Próby zmiany jej wartości kończyłyby się błędem.

Sloty

Każdy obiekt pythona posiada dict - słownik atrybutów.

Powoduje to spory narzut na pamięć. Jeśli nasza klasa nie będzie korzystała z dynamicznej natury takiego słownika, to w klasie można zdefiniować __slots__ i podać listę wszystkich składowych.

class Point:
    __slots__ = ['x', 'y']
p = Point()
p.x = 10
p.y = 20
p.z = 30
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-75-0ad9e402b8fa> in <module>
      2 p.x = 10
      3 p.y = 20
----> 4 p.z = 30

AttributeError: 'Point' object has no attribute 'z'

Dziedziczenie

Podstawy

Dziedziczenie definiowane jest za pomocą składni:

class Base:
    def base_method(self):
        pass

class Derived(Base):
    def derived_method(self):
        pass

Method Resolution Order (MRO)

Wszystkie metody zdefiniowane bezpośrednio w klasie C są przechowywane w słowniku C.__dict__:

Base.__dict__
mappingproxy({'__module__': '__main__',
              'base_method': <function __main__.Base.base_method(self)>,
              '__dict__': <attribute '__dict__' of 'Base' objects>,
              '__weakref__': <attribute '__weakref__' of 'Base' objects>,
              '__doc__': None})
Derived.__dict__
mappingproxy({'__module__': '__main__',
              'derived_method': <function __main__.Derived.derived_method(self)>,
              '__doc__': None})
d = Derived()
d.base_method() # it works

W klasie potomnej Derived dostępne są metody zdefiniowane w klasie bazowej Base (takie jak base_method), mimo że nie występują one bezpośrednio w słowniku potomka. W momencie wywoływania metody d.base_method(), metoda base_method jest poszukiwana w słowniku klasy Derived. Ponieważ ten słownik nie ma tej metody, przeszukiwany jest słownik klasy Base.

W ogólnym przypadku, przeszukiwane są słowniki wszystkich klas określonych w C.__mro__. Ta krotka zawiera klasę C, jej klasy nadrzędne, itd.

Derived.__mro__
(__main__.Derived, __main__.Base, object)

Dziedziczenie wielobazowe

W Pythonie możliwe jest dziedziczenie po więcej niż jednej klasie.

Przeciążając metody (w szczególności konstruktor __init__) należy pamiętać, aby wywołać także wersję rodzica przy użyciu funkcji super, która zwraca obiekt proxy służący do wywoływania metod rodzica. Obiekt jest wybierany zgodnie z MRO.

class A:
    def __init__(self):
        print("A")

class B(A):
    def __init__(self):
        super().__init__()
        print("B")

class C(A):
    def __init__(self):
        super().__init__()
        print("C")

class D(B, C):
    def __init__(self):
        super().__init__()
        print("D")
D.__mro__
(__main__.D, __main__.B, __main__.C, __main__.A, object)
D()
A
C
B
D
<__main__.D at 0x7fc85b329ee0>

Czasami nie jest możliwe utworzenie sensownego MRO:

class A: pass
class B: pass
class C(A, B): pass
class D(B, A): pass
class E(C, D): pass
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-86-b8bb9ff5be5c> in <module>
      3 class C(A, B): pass
      4 class D(B, A): pass
----> 5 class E(C, D): pass

TypeError: Cannot create a consistent method resolution
order (MRO) for bases A, B

Mixins (klasy domieszkowe)

Klasy domieszkowe (mixins) to klasy, które dostarczają określoną funkcjonalność innym klasom (poprzez mechanizm wielokrotnego dziedziczenia). Nie są samodzielnymi klasami i, w związku z tym, nie tworzy się instancji klas domieszkowych. Zazwyczaj nazwa takiej klasy kończy się sufixem Mixin (np. ComparableMixin), ale nie jest to bezwględnie obowiązująca konwencja.

Definiując klasę i wymieniając jej rodziców należy pamiętać, aby najpierw wymienić wszystkie klasy domieszkowe, a dopiero na końcu podać klasę bazową (chyba że jest nią object).

W przypadku porównywania obiektów wywoływana jest jedna z sześciu specjalnych metod (__lt__, __le__, __eq__, __ne__, __gt__ lub __ge__). Wystarczy jednak zdefiniować dwie z nich (np. __le__ i __eq__), a pozostałe porównania to odpowiednia kombinacja tych dwóch metod.

Poniżej przedstawiono klasę domieszkową ComparableMixin. Jej użycie powoduje, że wystarczy zdefiniować metody __le__ i __eq__, aby obiekty klasy dziedziczącej po ComparableMixin mogły być porównywane.

class ComparableMixin:
    def __ne__(self, other):
        return not (self == other)
    def __lt__(self, other):
        return self <= other and (self != other)
    def __gt__(self, other):
        return not self <= other
    def __ge__(self, other):
        return self == other or self > other

class MyInteger(ComparableMixin):  # klasą bazową jest "object"
    def __init__(self, i):
        self.i = i
    def __le__(self, other):
        return self.i <= other.i
    def __eq__(self, other):
        return self.i == other.i
MyInteger(1) > MyInteger(0)
True

Dziedziczenie po typach wbudowanych

Od Pythona 2.2 można dziedziczyć po wszystkich typach wbudowanych.

class CountDict(dict):
    def __getitem__(self, key):
        if key in self:
            return super(CountDict, self).__getitem__(key)
        else:
            return 0
cd = CountDict()
cd['unknown-key']
0

Dziedziczenie po typach niezmiennych

Dla typów niezmiennych (immutable) nie działa przeciążanie konstruktora __init__. Po utworzeniu obiektu jest już za późno na jakąkolwiek modyfikację.

class PositiveInt(int):
    def __new__(cls, value):
        print('__new__')
        obj = int.__new__(cls, value)
        return obj if obj > 0 else -obj
PositiveInt(-7)
__new__
7

Przykład ilustrujący gdzie przekazywane są argumenty:

class Test:
    def __new__(cls, *args):
        print('__new__', args)
        obj = object.__new__(cls)
        obj.new_attr = "test"
        return obj
    
    def __init__(self, *args):
        print('__init__', args)
        self.args = args
t = Test("gadget", 42)
__new__ ('gadget', 42)
__init__ ('gadget', 42)
t.args
('gadget', 42)
t.new_attr
'test'

Abstract Base Classes

W Pythonie stosowany jest duck-typing, w związku z tym nie ma potrzeby definiowania abstrakcyjnych klas lub interfejsów określających kontrakt. Warto jednak czasami jawnie określić kontrakt, to znaczy stworzyć abstrakcyjną klasę bazową i określić, jakie metody powinny zostać zdefiniowane. Nie tworzy się instancji takiej klasy - służy ona jedynie jako dokumentacja.

import abc

class BaseCalculator(abc.ABC):
    @abc.abstractmethod
    def process(self, expr):
        pass

class Calculator(BaseCalculator):
    def process(self, expr):
        return eval(expr)
c = Calculator()
c.process('2 + 2')
4

Jeżeli w klasie potomnej nie zostanie zdefiniowana wymagana metoda, zostanie rzucony wyjątek.

class InvalidCalculator(BaseCalculator):
    pass
ic = InvalidCalculator()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-101-4973d17f9fb6> in <module>
----> 1 ic = InvalidCalculator()

TypeError: Can't instantiate abstract class InvalidCalculator with abstract methods process

Klasa jako obiekt

W Pythonie wszystko jest obiektem, także klasa. Dlatego możemy mówić o obiekcie klasy. obiekt klasy, tak samo jak każdy obiekt, może być modyfikowany po jego utworzeniu.

class Record:
    pass

Record.name = "John"
person = Record()
person.name
'John'
Record.age = 42
person.age
42

Ma to wpływ na wszystkie instancje.