Języki programowaniaPython

Magiczne metody – trochę magii w klasach

Magiczne metody pozwalają nam rozszerzyć funkcjonalność klas. Zazwyczaj nie są one wywoływane bezpośrednio przez programistę, tylko przy wykonywaniu operacji na klasie. Załóżmy że chcielibyśmy dodać lub porównać do siebie dwie instancje zdefiniowanej przez nas klasy. Normalnie dostalibyśmy błąd, ale wystarczy, że zaimplementujemy odpowiednie magiczne metody i będziemy mogli te operacje wykonać.

Wbudowane w pythonie klasy implementują wiele magicznych metod. Dla przykładu spójrzmy na klasę int.

>>> dir(int)
['__abs__', '__add__', '__and__', '__bool__', '__ceil__', '__class__',
'__delattr__', '__dir__', '__divmod__', '__doc__', '__eq__',
'__float__', '__floor__', '__floordiv__', '__format__', '__ge__',
'__getattribute__', '__getnewargs__', '__gt__', '__hash__', '__index__',
'__init__', '__init_subclass__', '__int__', '__invert__', '__le__',
'__lshift__', '__lt__', '__mod__', '__mul__', '__ne__', '__neg__',
'__new__', '__or__', '__pos__', '__pow__', '__radd__', '__rand__',
'__rdivmod__', '__reduce__', '__reduce_ex__', '__repr__',
'__rfloordiv__', '__rlshift__', '__rmod__', '__rmul__', '__ror__',
'__round__', '__rpow__', '__rrshift__', '__rshift__', '__rsub__',
'__rtruediv__', '__rxor__', '__setattr__', '__sizeof__', '__str__',
'__sub__', '__subclasshook__', '__truediv__', '__trunc__', '__xor__',
'bit_length', 'conjugate', 'denominator', 'from_bytes', 'imag',
'numerator', 'real', 'to_bytes']

Definiując zmienne x oraz y oraz wykonując operację dodawania x + y, tak naprawdę wykonywane jest x.__add__(y). Innym przykładem jest metoda __abs__ zwracająca wartość bezwzględną liczby. Wywołanie abs(x) tak naprawdę wykonuje x.__abs__().

Trochę praktyki

Spróbujmy napisać klasę przechowującą ułamek oraz zaimplementować metody pozwalające na wykonywanie podstawowych operacji matematycznych. Zacznijmy od utworzenia klasy i zaimplementowania metody __init__.

class Fraction:
    def __init__(self, numerator, denominator):
        gcd = Fraction.gcd(numerator, denominator)
        self.numerator = numerator // gcd
        self.denominator = denominator // gcd

    @staticmethod
    def gcd(a, b):
        if b > 0:
            return Fraction.gcd(b, a % b)
        else:
            return a

Powyższa klasa przyjmuje jako argumenty licznik oraz mianownik, szuka największego wspólnego dzielnika, a następnie skraca ułamek (jeżeli oczywiście jest to możliwe).
Aktualnie gdybyśmy chcieli dodać do siebie dwa obiekty Fraction w taki sposób:

f1 = Fraction(2, 3)
f2 = Fraction(3, 5)
fsum = f1 + f2
print(fsum.numerator, fsum.denominator)

To dostaniemy błąd, mówiący o tym, że operacja dodawania jest niewspierana.

Traceback (most recent call last):
  File "/home/ksanek/.PyCharmCE2019.3/config/scratches/scratch_8.py", line 9, in <module>
    fsum = f1 + f2
TypeError: unsupported operand type(s) for +: 'Fraction' and 'Fraction'

Zaimplementujmy metodę __add__ i spróbujmy dodać do siebie obiekty ponownie.

def __add__(self, other):
    lcm = self.lcm(self.denominator, other.denominator)
    new_numerator = self.numerator * (lcm // self.denominator) + other.numerator * (lcm // other.denominator)
    gcd = Fraction.gcd(new_numerator, lcm)
    return Fraction(new_numerator // gcd, lcm // gcd)

@staticmethod
def lcm(a, b):
    return a * b // Fraction.gcd(a, b)

Do naszej klasy dodaliśmy metodę lcm, zwracającą najmniejszą wspólną wielokrotność przesłanych liczb. Podczas dodawania ułamków używamy tej metody, a następnie na podstawie wyniku liczymy nowy licznik dla ułamka. Nowym mianownikiem będzie oczywiście najmniejsza wspólna wielokrotność mianowników tych ułamków. Może się zdarzyć, że nowy ułamek będzie się dało skrócić, więc dzielimy zarówno licznik jak i mianownik przez największy wspólny dzielnik, a na koniec zwracamy nowy obiekt ułamka.

Teraz wykonując powyższą operację dodawania powinniśmy ujrzeć wartości 19 oraz 15, reprezentujące ułamek 19/15.

A co jeżeli chcielibyśmy za pomocą funkcji print wyświetlić ładną reprezentację obiektu zamiast czegoś takiego <main.Fraction object at 0x7f319f664c88>? Musimy zaimplementować metodę __repr__, która powinna zwrócić ciąg znaków:

def __repr__(self):
    return f"Fraction({self.numerator}, {self.denominator})"

Teraz wykonanie print(fsum) powinno nam wyświelić Fraction(19, 15).

Dodawanie in-place

Oprócz zwykłego dodawania z użyciem metody __add__, możemy również zaimplementować dodawanie „w miejscu”, czyli bez tworzenia nowego obiektu ułamka. Następujący kod: f1 += f2 wywoła metodę f1.__iadd__(f2). Metoda ta mogłaby wyglądać tak:

def __iadd__(self, other):
    lcm = self.lcm(self.denominator, other.denominator)
    self.numerator = self.numerator * (lcm // self.denominator) + other.numerator * (lcm // other.denominator)
    self.denominator = lcm
    gcd = Fraction.gcd(self.numerator, self.denominator)
    self.numerator = self.numerator // gcd
    self.denominator = self.denominator // gcd
    return self

Oblicza ona licznik i mianownik po dodaniu innego ułamka oraz przypisuje te wartości do pól numerator oraz denominator.

Porównywanie obiektów

Implementując metody __lt__, __gt__, __le__, __ge__, __eq__, __ne__ jesteśmy w stanie dodać możliwość używania operatorów porównania na naszym obiekcie. Przykładowa implementacja mogłaby wyglądać następująco:

    def __lt__(self, other):
        return self.numerator / self.denominator < other.numerator / other.denominator

    def __le__(self, other):
        return self.numerator / self.denominator <= other.numerator / other.denominator

    def __gt__(self, other):
        return self.numerator / self.denominator > other.numerator / other.denominator

    def __ge__(self, other):
        return self.numerator / self.denominator >= other.numerator / other.denominator

    def __eq__(self, other):
        return self.numerator / self.denominator == other.numerator / other.denominator

    def __ne__(self, other):
        return self.numerator / self.denominator != other.numerator / other.denominator

Po tym powinniśmy bez problemu móc używać operatorów <, <=, >, >=, == oraz !=.

Rzutowanie na liczbę

Jeżeli chcielibyśmy nasz ułamek zamienić na liczbę wykorzystując funkcje int oraz float, musimy zaimplementować metody __int__ oraz __float__. Metoda __float__ powinna zwracać wynik dzielenia pola numerator przez pole denominator, a metoda __int__ powinna dodatkowo rzutować ten wynik na liczbę całkowitą.

def __int__(self):
    return int(self.numerator / self.denominator)

def __float__(self):
    return self.numerator / self.denominator

__add__ vs __radd__

Czym różnią się powyższe metody? Przypuśćmy że zaktualizowaliśmy kod funkcji __add__ tak, aby możliwe było dodanie do ułamka liczby całkowitej:

    def __add__(self, other):
        if isinstance(other, Fraction):
            return self._add_fraction(other)
        elif isinstance(other, int):
            return self._add_int(other)
        raise TypeError("Operation not supported")

    def _add_fraction(self, fraction):
        lcm = self.lcm(self.denominator, fraction.denominator)
        new_numerator = self.numerator * (lcm // self.denominator) + fraction.numerator * (lcm // fraction.denominator)
        gcd = Fraction.gcd(new_numerator, lcm)
        return Fraction(new_numerator // gcd, lcm // gcd)

    def _add_int(self, value):
        return Fraction(self.numerator + value * self.denominator, self.denominator)

Powinniśmy mieć możliwość dodania liczby całkowitej do ułamka, tak jak przedstawiono poniżej:

>>> f1 = Fraction(2, 5)
>>> print(f1 + 2)
Fraction(12, 5)

Spróbujmy jednak zamienić kolejnością dodawanie i wyświetlić wynik 2 + f1. Dostaniemy następujący błąd: TypeError: unsupported operand type(s) for +: 'int' and 'Fraction'. Dzieje się tak, ponieważ zaimplementowaliśmy lewostronny operator dodawania, a brakuje nam implementacji prawostronnego operatora dodawania.
Python najpierw sprawdza czy typ int implementuje dodawanie obiektu typu Fraction, a następnie sprawdza czy typ Fraction implementuje metodę __radd__. Jeżeli mu się nie uda znaleźć odpowiedniej metody, to rzuca błędem TypeError. Jako że dodawanie jest działaniem przemiennym, to funkcję __radd__ możemy dodać w prosty sposób, tak aby wykonywała funkcję __add__.

def __radd__(self, other):
    return self.__add__(other)

Podsumowanie

Omówiliśmy sobie kilka podstawowych magicznych metod oraz zaimplementowaliśmy klasę ułamka, bazującą na tych metodach. Klasę dalej można rozszerzać o kolejne magiczne metody jak __abs__(wartość bezwzględna), __sub__, __mul__, __truediv__, __pow__ (operacje matematyczne).

Python zawiera również bardziej zaawansowane magiczne metody jak np. __new__, której zadaniem jest zwrócenie nowej instancji klasy. Metodę __new__ omówimy przy okazji omawiania metaprogramowania w języku Python.

Jeżeli zaciekawił Cię ten temat, to polecam zapoznać się z dokumentacją, a jeżeli nie czytałeś poprzedniego wpisu omawiającego dekoratory, to zachęcam go do przeczytania tutaj.

You may also like

Leave a reply

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *