#15. Множинне успадкування. Magic methods

Множинне успадкування. MRO. Magic methods.

План

  1. Типи успадкування
  2. Mixin
  3. MRO - Method resolution order
  4. Magic methods
    1. init, new, del
    2. Магічні методи порівняння
    3. Звичайні арифметичні оператори
    4. Розширені арифметичні оператори
    5. Магічні методи перетворення типів
    6. Представлення своїх класів
    7. Протоколи
    8. Магія контейнерів
    9. Об’єкти, що викликаються
  5. Домашнє завдання
  6. Література

Типи успадкування

  1. Одиночне успадкування
    «Просте» успадкування або одиночне успадкування, описує спорідненість між двома класами: один з яких успадковується від іншого. З одного класу може виводитися багато класів, але навіть в цьому випадку подібний вид взаємозв’язку залишається «простим» успадкуванням.
  2. Множинне успадкування
    При множинному успадкуванні, у класа може бути більше одного базового класу. В цьому випадку клас успадковує всі батьківські методи і поля. Переваги такого підходу в більшій гнучкості. Недоліками є потенційні конфлікти через наявність однакових імен методів у пращурів. Тому в багатьох мовах множинне успадкування не підтримується.

Наприклад, у нас є клас автомобіля:

class Auto:
    def ride(self):
        print("Riding on a ground")

Також у нас є клас для човна:

class Boat:
    def swim(self):
        print("Sailing in the ocean")

Тепер, якщо нам потрібно запрограмувати автомобіль-амфібію, який плаватиме у воді та їздитиме по землі, ми замість написання нового класу, можемо просто успадкувати від вже існуючих, просто написавши їх через кому:

class Auto:
    def ride(self):
        print("Riding on a ground")


class Boat:
    def swim(self):
        print("Sailing in the ocean")


class Amphibian(Auto, Boat):
    pass


a = Amphibian()
a.ride()
a.swim()

Тепер наш клас має атрибути та методи обох батьків (їх може бути скільки завгодно)

Зверніть увагу, що об’єкт класу Amphibian буде одночасно об’єктом класу Auto і Boat, тобто:

a = Amphibian()
isinstance(a, Auto)
# True
isinstance(a, Boat)
# True
isinstance(a, Amphibian)
# True

Mixin

Міксин, він же домішка - це тип класів, які потрібні, щоб додавати до звичайних класів якісь методи або атрибути, але ці класи не використовуються для створення об’єктів. (Нас нічого не зупиняє створити об’єкт цього класу, але завдання в іншому)

Припустимо, що ми програмуємо клас для автомобіля. Ми хочемо, щоб ми мали можливість слухати музику в машині. Звичайно, можна просто додати метод play_music() у клас Car:

class Car:
    def ride(self):
        print("Riding a car")

    def play_music(self, song):
        print("Now playing: {} ".format(song))


c = Car()
c.ride()
# Riding a car
c.play_music("Queen - Bohemian Rhapsody")
# Now playing: Queen - Bohemian Rhapsody

Але якщо, у нас є ще й телефон, радіо або будь-який інший девайс, з якого ми слухатимемо музику.
У такому разі, краще винести функціонал програвання музики в окремий клас-міксин:

class MusicPlayerMixin:
    def play_music(self, song):
        print("Now playing: {}".format(song))

Ми можемо “домішувати” цей клас у будь-який інший, де потрібна функція відтворення музики:

class Smartphone(MusicPlayerMixin):
    pass


class Radio(MusicPlayerMixin):
    pass


class Amphibian(Auto, Boat, MusicPlayerMixin):
    pass

Diamond problem

Отже, класи-спадкоємці можуть використовувати батьківські атрибути та методи. Але що буде, якщо у кількох батьків будуть однакові атрибути чи методи? Який метод у такому разі використовуватиме спадкоємець?

Розглянемо класичний приклад:

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


class B(A):
    def hi(self):
        print("B")


class C(A):
    def hi(self):
        print("C")


class D(B, C):
    pass


d = D()
d.hi()

Це називається ромбоподібне успадкування (англ. diamond inheritance), а ситація, коли є однакові методи у батківських класах - проблема ромба (англ. diamond problem)

MRO - Method resolution order

Це вирішується в Python шляхом встановлення порядку вирішення методів.

У Python 3.x і з 2.2 для визначення порядку використовується алгоритм пошуку завширшки, тобто спочатку інтерпретатор шукатиме метод hi у класі B, якщо його там немає – у класі С, потім A.

У Python 2.1 і старіше використовується алгоритм пошуку в глибину, тобто в даному випадку – спочатку B, потім – А, потім С.

Щоб подивитися в якому порядку Python шукатиме атрибути або методи у батьків, у будь-якого класу можна викликати метод mro:

>>> D.mro()
[<class '__main__.D'>, < class '__main__.B' >, < class '__main__.C' >, < class '__main__.A' >, < class 'object' >]

Лінеаризація (linearization) — це черговість, за якою проводиться пошук зазначеного атрибута спочатку в класах-нащадків, а потім і в батьківських класах.

Зверніть увагу, що в кінці завжди буде object якщо ви використовуєте будь-який Python версії 3. Бо взагалі все успадковане від нього, як і було сказано на минулому занятті.

Якщо з якоїсь причини вас не влаштовує існуючий порядок, є можливість викликати метод рівно з того класу, звідки вам треба, але це вважається поганою практикою і краще не робити, а повністю поміняти структуру.

Якщо вам необхідно використовувати метод конкретного батька, наприклад, hi() класу С, потрібно безпосередньо викликати його на ім’я класу, передавши self як аргумент:

class D(B, C):
    def call_hi(self):
        C.hi(self)


d = D()
d.call_hi() # C

Якщо ж треба просто викликати метод батьківського класу відповідно до MRO, треба використовувати super.

class D(B, C):  
    def call_hi(self):  
        super(B, self).hi()  
  
  
d = D()  
d.call_hi() # C

І ще раз:

  • лінеаризацією класу називається список із самого класу та всіх його
    предків (батьків та пробатьків) у якому по порядку зліва направо буде
    здійснюватися пошук методу.
  • порядок вирішення методів (MRO) - це спосіб, за допомогою якого складається лінеаризація класу.

Magic methods (dunder methods)

Потужність магічних методів у Python неймовірна, а з великою силою приходить і велика відповідальність. Важливо знати, як правильно використовувати магічні методи, нічого не ламаючи.

Це спеціальні методи, за допомогою яких ви можете додати до ваших класів «магію».
Вони завжди обрамлені двома нижніми підкресленнями, два спочатку, два в кінці (наприклад, __init__ або __lt__)
Тому ще їх називають dunder methods, скорочено від duble underscore.
Це методи, які відповідають за будь-які дії під капотом. Ті, які виконуються неявною. Припустимо, вам потрібно скласти два об’єкти через +, нам потрібен магічний метод __add__ або ми хочемо змінити поведінку при передачі у метод print (__str__), поведінку при переборі об’єкта у циклі (__iter__ або __next__) і т.д.

Майже будь-яка дія, яка вам здається, виконується сама собою, швидше за все описана в меджик методі.

init, new, del

При створенні (ініціалізації) об’єкта викликається метод __init__ та допомагає нам задати стартові параметри для нашого класу:

class A:
    def __init__(self, arg_one, arg_two):
        self.some_arg_one = arg_one
        self.some_arg_two = arg_two


a = A('bla', 22)
a.some_arg_one
# bla
a.some_arg_two
# 22

Такий спосіб куди краще, ніж задавати якесь дефолтне значення спочатку і відразу ж його поміняти (номер телефону або місце на дошці з минулого уроку)

Коли пишемо x = SomeClass(), __init__ не перше, що викликається. Насправді, екземпляр об’єкта створює метод __new__, а потім аргументи передаються в ініціалізатор. На іншому кінці життєвого циклу об’єкта знаходиться метод __del__.

Давайте докладніше розглянемо ці три магічні методи:

__new__(cls, [...)

Це перший метод, який буде викликаний під час ініціалізації об’єкта. Він приймає як параметри клас і потім будь-які інші аргументи, які будуть передані до __init__. __new__ використовується дуже рідко, але іноді буває корисним, зокрема, коли клас успадковується від незмінного (immutable) типу, такого як кортеж (tuple) чи рядок. Я не маю намір
дуже детально зупинятися на __new__, тому що він не те щоб дуже часто потрібен, але цей метод дуже добре і детально описано у документації.

__init__(self, [...)

Ініціалізатор класу. Йому передається все, з чим був викликаний початковий конструктор (наприклад, якщо ми викликаємо x = SomeClass(10, 'foo'), __init__ отримає 10 і foo як аргументи. __init__ майже повсюдно використовується при визначення класів.

__del__(self)

Якщо __new__ та __init__ утворюють конструктор об’єкта, __del__ це його деструктор. Він не визначає поведінку для висловлювання del x (тому цей код не еквівалентний x.__del__()). Швидше він визначає поведінку об’єкта в той час, коли об’єкт потрапляє до збирача сміття (garbage collector). Це може бути досить зручним для об’єктів, які можуть вимагати додаткових чисток під час видалення, таких як сокети чи файлові об’єкти. Однак, потрібно бути обережним, оскільки немає гарантії, що __del__ буде викликано, якщо об’єкт продовжує жити, коли інтерпретатор завершує роботу. Тому __del__ не може бути заміною для хороших програмістських практик (завжди завершувати з’єднання, якщо закінчив з ним працювати тощо). Фактично, через відсутність гарантії виклику, __del__ не повинен використовуватись майже ніколи; використовуйте його з обережністю

Магічні методи порівняння

У Python дуже багато магічних методів, створених для визначення інтуїтивного порівняння між об’єктами використовуючи оператори, а не методи (що краще, a==b або a.is_equal(b). Крім того, вони надають спосіб перевизначити поведінку
за замовченням для порівняння об’єктів (за посиланням). Ось список цих методів і що вони роблять:

Operator Method
< object.__lt__(self, other)
<= object.__le__(self, other)
== object.__eq__(self, other)
!= object.__ne__(self, other)
>= object.__ge__(self, other)
> object.__gt__(self, other)

Наприклад, розглянемо клас, який описує слово. Ми можемо порівнювати слова лексиграфічно (за алфавітом), що є дефолтною поведінкою при порівнянні рядків, але можемо захотіти використовувати при порівнянні якийсь інший критерій, такий як довжина чи кількість складів. У цьому прикладі ми порівнюватимемо за довжиною. Ось реалізація:

class Word(str):
    '''Клас для слів, що визначає порівняння за довжиною слів.'''

    def __new__(cls, word):
        # Ми повинні використовувати __new__, тому що тип str незмінний
        # і ми повинні ініціалізувати його раніше (при створенні)
        if ' ' in word:
            print("Value contains spaces. Truncating to first space.")
            word = word[:word.index(' ')] # Тепер Word це всі символи до першого пропуску
        return super().__new__(cls, word)

    def __gt__(self, other):
        return len(self) > len(other)

    def __lt__(self, other):
        return len(self) < len(other)

    def __ge__(self, other):
        return len(self) >= len(other)

    def __le__(self, other):
        return len(self) <= len(other)

fo = Word('fo')  
bar = Word('bar')  
print(bar > fo)  # True

Тепер ми можемо створити два Word (за допомогою Word('fo') та Word('bar')) і порівняти їх за довжиною. Зауважте, що ми не визначали __eq__ і __ne__, оскільки це призведе до дивної поведінки (наприклад, Word('foo') == Word('bar') буде розцінюватися як істина). У цьому немає сенсу при тестуванні на еквівалентність, що базується на довжині, тому ми залишаємо стандартну перевірку на еквівалентність від str.

Звичайні арифметичні оператори

Тепер розглянемо звичайні бінарні оператори (і ще кілька функцій): +, -, * і схожі. Вони, здебільшого, добре самі себе описують.

Operator Method
+ object.__add__(self, other)
- object.__sub__(self, other)
* object.__mul__(self, other)
// object.__floordiv__(self, other)
/ object.__truediv__(self, other)
% object.__mod__(self, other)
** object.__pow__(self, other[, modulo])
<< object.__lshift__(self, other)
>> object.__rshift__(self, other)
& object.__and__(self, other)
^ object.__xor__(self, other)
| object.__or__(self, other)

Розширені арифметичні оператори

Operator Method
+= object.__iadd__(self, other)
-= object.__isub__(self, other)
*= object.__imul__(self, other)
/= object.__idiv__(self, other)
//= object.__ifloordiv__(self, other)
%= object.__imod__(self, other)
**= object.__ipow__(self, other[, modulo])
<<= object.__ilshift__(self, other)
>>= object.__irshift__(self, other)
&= object.__iand__(self, other)
^= object.__ixor__(self, other)
|= object.__ior__(self, other)

Магічні методи перетворення типів

Крім того, є безліч магічних методів, призначених для визначення поведінки для вбудованих функцій перетворення типів, як то float(). Ось вони:

Operator Method
- object.__neg__(self)
+ object.__pos__(self)
abs() object.__abs__(self)
complex() object.__complex__(self)
int() object.__int__(self)
long() object.__long__(self)
float() object.__float__(self)
oct() object.__oct__(self)
hex() object.__hex__(self

Представлення своїх класів

Часто буває корисним уявлення класу у вигляді строки (str). У Python існує кілька методів, які ви можете визначити для налаштування поведінки вбудованих функцій під час представлення вашого класу.

__str__(self) Визначає поведінку функції str(), викликаної для екземпляра класу.
__repr__(self) Визначає поведінку функції repr(), викликаної для екземпляра класу. Головна відмінність від str() в цільовій аудиторії. repr() більше призначений для машинно-орієнтованого виводу (більше того, це часто має бути валідним кодом на Python), а str() призначений для читання людьми.
__unicode__(self) Визначає поведінку функції unicode(), викликаної для екземпляра класу. unicode() схожий на str(), але повертає рядок в юнікод. Будьте обережні: якщо клієнт викликає str() на екземплярі вашого класу, а ви визначили лише __unicode__(), то це не працюватиме. Намагайтеся завжди визначати __str__() для випадку, коли хтось не має такої розкіш як юнікод.
__format__(self, formatstr) Визначає поведінку, коли екземпляр класу використовується у форматуванні рядків нового стилю. Наприклад, "Hello, {0:abc}!".format(a) призведе до виклику a.__format__("abc"). Це може бути корисним для визначення ваших власних числових чи рядкових типів, яким ви можете захотіти надати якісь спеціальні опції форматування.
__hash__(self) Визначає поведінку функції hash(), викликаної для екземпляра класу. Метод повинен повертати ціле чисельне значення, яке використовуватиметься для швидкого порівняння ключів у словниках. Зауважте, що в такому випадку зазвичай потрібно визначати і __eq__ теж. Керуйтеся наступним правилом: a == b має на увазі hash(a) == hash(b).
__bool__(self) Визначає поведінку функції bool(), викликаної для екземпляра класу. Повинна повернути True або False, в залежно від того, коли ви вважаєте екземпляр відповідним True або False.
__dir__(self) Визначає поведінку функції dir(), викликаної на екземплярі класу. Цей метод повинен повертати користувачеві список атрибутів. Зазвичай визначення __dir__ не потрібне, але може бути життєво важливим для інтерактивного використання вашого класу, якщо ви перевизначили __getattr__ або __getattribute__ (з якими ви зустрінетеся у наступній частині), або іншим чином динамічно створюєте атрибути.
__sizeof__(self) Визначає поведінку функції sys.getsizeof(), викликаної на екземплярі класу. Метод повинен повернути розмір вашого об'єкт у байтах. Він головним чином корисний для класів, визначених у розширеннях на C, але все одно корисно про нього знати.

 

class Human:  
    def __init__(self, name):  
        self.name = name  
  
    def __str__(self):  
        return f'My name is:{self.name}'  
  
  
m = Human('Tom')  
print(m)  # My name is:Tom

Протоколи

Протокол для визначення незмінних контейнерів: щоб створити незмінний контейнер, ви повинні тільки визначити __len__ та __getitem__ (докладніше про них далі). Протокол змінного контейнера вимагає того ж, що й незмінного контейнера, плюс __setitem__ та __delitem__. І, нарешті, якщо ви хочете, щоб ваші об’єкти можна було перебирати ітерацією, ви маєте визначити __iter__, який повертає ітератор. Цей ітератор має відповідати протокол ітератора, який вимагає методів __iter__ (повертає самого себе) і __next__.

Наприклад, ітератор, який повертає числа, починаючи з 1, і кожна послідовність буде збільшуватися на одиницю (повертаючи 1,2,3,4,5 тощо), можна реалізувати так:

class MyNumbers:  
  def __iter__(self):  
    self.a = 1  
  return self  
  
  def __next__(self):  
    if self.a <= 20:  
      x = self.a  
      self.a += 1  
  return x  
    else:  
      raise StopIteration  
  
myclass = MyNumbers()
  
for x in myclass:  
  print(x)

Магія контейнерів

Без подальшого зволікання, ось магічні методи, які використовуються контейнерами:

__len__(self) Повертає кількість елементів у контейнері. Частина протоколів для змінного та незмінного контейнерів.
__getitem__(self, key) Визначає поведінку доступу до елемента, використовуючи синтаксис self[key]. Теж відноситься і до протоколу змінних і до протоколу незмінних контейнерів. Повинен викидати відповідні винятки: TypeError, якщо неправильний тип ключа та KeyError якщо ключ не відповідає ніякого значення.
__setitem__(self, key, value) Визначає поведінку при наданні значення елементу, використовуючи синтаксис self[nkey] = value. Частина протоколу змінного контейнера. Знову ж таки, ви повинні викидати KeyError і TypeError у відповідних випадках.
__delitem__(self, key) Визначає поведінку при видаленні елемента (тобто del self[key]). Це частина лише протоколу для зміненого контейнер. Ви повинні викидати відповідний виняток, якщо ключ неправильний.
__iter__(self) Повинен повернути контейнер для контейнера. Ітератори повертаються у безлічі ситуацій, головним чином для вбудованої функції iter() та у разі перебору елементів контейнера виразом for x in container:. Ітератори самі по собі об'єкти і вони також повинні визначати метод __iter__, який повертає self
__reversed__(self) Викликається щоб визначити поведінку для вбудованої функції reversed(). Повинен повернути зворотну версію послідовності. Реалізуйте метод лише якщо клас упорядкований, як список чи кортеж.
__contains__(self, item) Призначений для перевірки приналежності елемента за допомогою in та not in. Ви запитаєте, чому це чи не частина протоколу послідовності? Тому що коли __contains__ не визначено, Python просто перебирає всю послідовність елемент за елементом і повертає True, якщо знаходить потрібний.
__next__(self) Повертає наступний об'єкт у послідовності або StopIteration якщо більше об'єктів немає.

 

Об’єкти, що викликаються

Як ви напевно вже знаєте, у Python функції є об’єктами першого класу (детальніше тут). Це означає, що вони можуть бути передано у функції або методи так само, як будь-які інші об’єкти. Це надзвичайно потужна особливість.

Спеціальний магічний метод дозволяє екземплярам вашого класу поводитися так, ніби вони функції, тобто ви зможете «викликати» їх, передавати їх у функції, які приймають функції як аргументи тощо. Це інша зручна особливість, яка робить програмування на Python таким приємним.

__call__(self, [args...])

Дозволяє будь-якому екземпляру вашого класу бути викликаним ніби він функція. Головним чином це означає, що x() означає те, що і x.__call__(). Зауважте, __call__ приймає довільну кількість аргументів; тобто, ви можете визначити __call__ так само, як будь-яку іншу функцію, що приймає стільки аргументів, скільки вам потрібно.

__call__, зокрема, може бути корисним у класах, чиї екземпляри часто змінюють свій стан. «Викликати» екземпляр може бути інтуїтивно зрозумілим та елегантним способом змінити стан об’єкта. Прикладом може бути клас, представляє положення деякого об’єкта на площині:

class Entity:
    '''Клас, що описує об'єкт на площині. "Викликаний", щоб оновити позицію об'єкта.'''

    def __init__(self, size, x, y):
        self.x, self.y = x, y
        self.size = size

    def __call__(self, x, y):
        '''Змінити положення об'єкта.'''
        self.x, self.y = x, y

Насправді магічних методів більше, і ви можете вивчити їх з документації, якщо вам необхідно, але практично будь-яку дію можна перевизначити за допомогою цих методів.

Практика і домашнє завдання:

  1. Доробити попереднє, додати методи __init__ та __str__

  2. Створити клас Employee.

  3. __init__ має приймати наступні аргументи: ім’я, ЗП за один робочий день.

  4. Створити метод work(self, …) який повертає строку “I come to the office.”

  5. Створити класи Recruiter та Developer, які наслідують клас Employee.

  6. Перевизначити методи work в класах R та D, щоб вони повертали значення:

    1. “I come to the office and start to coding.” - для Developer
    2. “I come to the office and start to hiring.” - для Recruiter
  7. Перевизначити методи __str__, щоб вони повертали строку: “Посада: Ім’я”

  8. Зробити можливим порівнювати Employee по рівню ЗП.

Література

  1. Множинне успадкування
  2. Офіційна документація по магічним методам
  3. Об’єкт першого класу