Главная / Курсы / Python / Полиморфизм
# Глава 17. Полиморфизм Полиморфизм в языках программирования — это способность выполнять одно и то же действие над объектами разных типов. Выясним, какими способами он достигается в питоне. ## Наследование и утиная типизация В питоне [полиморфизм](https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%BB%D0%B8%D0%BC%D0%BE%D1%80%D1%84%D0%B8%D0%B7%D0%BC_(%D0%B8%D0%BD%D1%84%D0%BE%D1%80%D0%BC%D0%B0%D1%82%D0%B8%D0%BA%D0%B0)) реализуется двумя способами: - [Наследование.](https://ru.wikipedia.org/wiki/%D0%9D%D0%B0%D1%81%D0%BB%D0%B5%D0%B4%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5_(%D0%BF%D1%80%D0%BE%D0%B3%D1%80%D0%B0%D0%BC%D0%BC%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5)) От базового класса наследуются классы-потомки, которые реализуют у себя его методы. Получается, что сигнатура методов совпадает, и к ним может обращаться вызывающий код вне зависимости от класса объекта, с которым он работает. - [Утиная типизация.](https://ru.wikipedia.org/wiki/%D0%A3%D1%82%D0%B8%D0%BD%D0%B0%D1%8F_%D1%82%D0%B8%D0%BF%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F) В питоне реализована [динамическая типизация:](/courses/python/chapters/python_chapter_0010#block-dynamic-typing) переменные связываются с типом не в момент объявления, а в момент присваивания значения. Благодаря этому если классы содержат одинаковые методы, вызывающий код может обрабатывать объекты этих классов единообразно. Рассмотрим каждый из подходов. ## Наследование При наследовании в определении класса-потомка базовый класс указывается в скобках: ```python class Parent: ... class Child(Parent): ... ``` Класс-потомок получает от родителя все атрибуты и способен их модифицировать. В частности, методы родителя можно переопределить либо расширить: - При переопределении метода класс-потомок полностью заменяет код метода на свой. - При расширении метода потомок вызывает логику из класса-родителя, но при этом добавляет в метод новую функциональность. Код метода родителя вызывается через встроенную функцию `super()`. `super()` позволяет получить доступ к атрибутам базового класса: ```python {.example_for_playground} class Parent: def __init__(self, x): self.x = x print("Parent init") def f(self, val): print(f"Parent f({val})") return self.x * val class Child(Parent): def __init__(self, x, y): super().__init__(x) self.y = y print("Child init") def f(self, val): print(f"Child f({val})") return self.x * self.y * val parent = Parent(2) res = parent.f(3) print("Result:", res, "\n") child = Child(2, 5) res = child.f(3) print("Result:", res) ``` ``` Parent init Parent f(3) Result: 6 Parent init Child init Child f(3) Result: 30 ``` Инициализатор класса-потомка мы **расширили,** через `super()` вызвав в нем `__init__()` базового класса. А метод `f()` **переопределили:** полностью заместили функциональность метода из родительского класса. Есть базовый класс `Storage` для хранения текстовых сообщений и их поиска по id. Наследуйте от него класс `InMemoryStorage`. {.task_text} Пусть в инициализаторе он принимает дополнительный аргумент `message_count_limit`. {.task_text} Если при добавлении нового сообщения через `save_message()` количество хранимых сообщений может превысить `message_count_limit`, метод должен сгенерировать исключение с помощью конструкции `raise Exception("Too many messages")`. {.task_text} Если же длина сообщения превышает `message_size_limit`, метод должен сгенерировать исключение с текстом `"Message is too long"`. Если сообщение с указанным id уже есть в хранилище, оно должно быть перезаписано. {.task_text} Метод `get_message()` должен возвращать сообщение либо `None`, если оно не найдено. {.task_text} Имплементируйте методы `save_message()` и `get_message()` таким образом, чтобы они **расширяли** функциональность `Storage`. Код родительского класса должен выполняться в самом начале метода класса-потомка. {.task_text} ```python {.task_source #python_chapter_0170_task_0010} class Storage: def __init__(self, message_size_limit): self._message_size_limit = message_size_limit def save_message(self, message_id, message): print(f"Saving message {message_id}...") def get_message(self, message_id): print(f"Extracting message {message_id}...") ``` Не забудьте во всех трех методах `InMemoryStorage` вызывать методы базового класса через `super()`. {.task_hint} ```python {.task_answer} class Storage: def __init__(self, message_size_limit): self._message_size_limit = message_size_limit def save_message(self, message_id, message): print(f"Saving message {message_id}...") def get_message(self, message_id): print(f"Extracting message {message_id}...") class InMemoryStorage(Storage): def __init__(self, message_size_limit, message_count_limit): super().__init__(message_size_limit) self._message_count_limit = message_count_limit self._saved_messages = {} def save_message(self, message_id, message): super().save_message(message_id, message) if ( len(self._saved_messages) + 1 > self._message_count_limit and message_id not in self._saved_messages ): raise Exception("Too many messages") if len(message) > self._message_size_limit: raise Exception("Message is too long") self._saved_messages[message_id] = message def get_message(self, message_id): super().get_message(message_id) return self._saved_messages.get(message_id) ``` Что будет, если в классе-наследнике объявить метод с таким же именем, как в родителе, но с другим набором параметров? ```python {.example_for_playground} class A: def f(self, a, b): print("A", a, b) class B(A): def f(self, a, b, c): print("B", a, b, c) b = B() b.f(1, 2, 3) ``` ``` B 1 2 3 ``` Имя метода дочернего класса перекроет имя родительского метода в области видимости. Поэтому при переопределении методов не помешает лишний раз проверить соответствие параметров. ## Множественное наследование {#block-multiple-inheritance} В языке поддерживается множественное наследование, при котором потомок получает доступ к атрибутам всех родительских классов. Родительские классы перечисляются через запятую в объявлении класса-потомка: ```python class Child(Parent1, Parent2): ... ``` Каким же образом решается [проблема ромбовидного наследования?](https://ru.wikipedia.org/wiki/%D0%A0%D0%BE%D0%BC%D0%B1%D0%BE%D0%B2%D0%B8%D0%B4%D0%BD%D0%BE%D0%B5_%D0%BD%D0%B0%D1%81%D0%BB%D0%B5%D0%B4%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5) Допустим, есть базовый класс `A`. От него наследуются классы `B` и `C`. От которых наследуется `D`: ``` A / \ / \ / \ B C \ / \ / \ / D ``` Возникает неоднозначность: если инстанс `D` вызывает метод, определенный в `A` и переопределенный в `B` и `C`, то чья реализация метода будет использована — `B` или `C`? ```python {.example_for_playground} class A: def f(self): print("A") class B(A): def f(self): print("B") class C(A): def f(self): print("C") class D(B, C): ... d = D() d.f() ``` ``` B ``` Неоднозначность вызова метода устраняется с помощью зафиксированного в языке **порядка разрешения методов** (MRO, Method Resolution Order) под названием [C3-линеаризация.](https://ru.wikipedia.org/wiki/C3-%D0%BB%D0%B8%D0%BD%D0%B5%D0%B0%D1%80%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F) Это довольно сложный алгоритм определения последовательности, в которой должны наследоваться методы. В общих чертах он выглядит так: - Сначала интерпретатор ищет методы в текущем классе. В нашем случае это `D`. - Затем обходит классы-родители слева направо в том порядке, в котором они были перечислены в объявлении дочернего класса. В нашем случае `B` указан перед `C`. - Если метод не найден, в этом же порядке слева направо обходятся базовые классы родителей. Затем — их родителей, и так далее. У класса в питоне можно вызвать метод `mro()`, который возвращает список классов, от которых наследован данный. В том порядке, в котором они перебираются для разрешения методов. И вот как это выглядит для нашего класса `D`: ```python print(D.mro()) ``` ``` [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>] ``` Обратите внимание, что метод `mro()` был вызван именно от класса, а не от инстанса класса. Как быть, если требуется вызвать метод конкретного родительского класса? Поможет функция `super()`. В примерах выше мы ничего в нее не передавали, то есть использовали аргументы по умолчанию. На самом деле `super(type, type_or_obj)` возвращает объект-посредник (прокси), делегирующий вызовы по цепочке иерархии. И принимает два параметра: - `type` — тип, с которого (не включительно) начинается поиск прокси. Например, если MRO - это `Z -> Y -> X -> object`, а значение `type = Y`, то `super()` выполнит поиск по цепочке `X -> object`. - `type_or_obj` — тип или инстанс класса, определяющий MRO для поиска. Для инстанса будет найден прокси, для которого `isinstance(obj, type) is True`. Для типа будет найден прокси, для которого `issubclass(subtype, type) is True`. Поэтому если в нашем примере с ромбовидным наследованием мы хотим вызвать реализацию `f()` из класса `C` вместо `B`, мы должны написать следующее: ```python d = D() super(B, d).f() ``` ``` C ``` Это равносильно прямому обращению к методу объекта через класс с принудительным пробросом интересующего объекта вместо `self`: ```python C.f(d) ``` ``` C ``` Дана цепочка наследования `Tail -> Z -> Y -> X -> object`. Расширьте в классе `Tail` функцию `f()` таким образом, чтобы она вызывала функцию класса `X`. {.task_text} ```python {.task_source #python_chapter_0170_task_0020} class X: def f(self): print("X") class Y(X): def f(self): print("Y") class Z(Y): def f(self): print("Z") class Tail(Z): ... t = Tail() t.f() ``` В `super()` требуется передать класс `Y` и `self`. {.task_hint} ```python {.task_answer} class X: def f(self): print("X") class Y(X): def f(self): print("Y") class Z(Y): def f(self): print("Z") class Tail(Z): def f(self): super(Y, self).f() ``` ## Абстрактные классы {#block-abstract-classes} [Абстрактные классы](https://ru.wikipedia.org/wiki/%D0%90%D0%B1%D1%81%D1%82%D1%80%D0%B0%D0%BA%D1%82%D0%BD%D1%8B%D0%B9_%D0%BA%D0%BB%D0%B0%D1%81%D1%81) применяются в случаях, если для всей иерархии наследования требуется задать определенный интерфейс, то есть набор публичных атрибутов. {#block-abstract} Для удобной работы с абстрактными классами реализован модуль `abc` (abstract base class). В частности, он содержит класс `ABC` и декоратор `@abstractmethod`. Импортируем их из модуля и используем в абстрактном классе `Cache`: ```python {.example_for_playground} from abc import ABC, abstractmethod class Cache(ABC): @abstractmethod def add(self, key, value): pass @abstractmethod def get(self, key): pass ``` `Cache` наследован от `ABC` и в нем перечисляются пустые методы, которые должны определить у себя производные классы. Каждый из таких методов обернут декоратором `@abstractmethod`. Наследуем от `Cache` специализированную имплементацию кэша — [LRU кэш.](https://ru.wikipedia.org/wiki/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC%D1%8B_%D0%BA%D1%8D%D1%88%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D1%8F#Least_recently_used_(%D0%92%D1%8B%D1%82%D0%B5%D1%81%D0%BD%D0%B5%D0%BD%D0%B8%D0%B5_%D0%B4%D0%B0%D0%B2%D0%BD%D0%BE_%D0%BD%D0%B5%D0%B8%D1%81%D0%BF%D0%BE%D0%BB%D1%8C%D0%B7%D1%83%D0%B5%D0%BC%D1%8B%D1%85)) Попробуем создать объект этого класса: ```python class LRUCache(Cache): def __init__(self, max_items): self.max_items = max_items def add(self, key, value): print(f"Caching {key}...") lru_cache = LRUCache(1000) ``` ``` File "example.py", line 21, in <module> lru_cache = LRUCache(1000) ^^^^^^^^^^^^^^ TypeError: Can't instantiate abstract class LRUCache without an implementation for abstract method 'get' ``` Что-то пошло не так: в классе `LRUCache` мы определили только один из двух методов базового класса `add()` и `get()`. Чтобы можно было создавать объекты классов, наследованных от абстрактного, в этих классах должны быть реализованы **все** его абстрактные методы. Превратите класс `Navigation` (построения маршрутов) в абстрактный класс. Наследуйте от него 2 класса: `CarNavigation` для автомобильных маршрутов и `TransitNavigation` для маршрутов на общественном транспорте. {.task_text} Определенные в производных классах методы должны выводить в консоль сообщение вида `Имя класса. Имя метода` Пусть они ничего не возвращают. {.task_text} ```python {.task_source #python_chapter_0170_task_0030} class Navigation: def build_route(self, start, finish): """ Creates route between start and finish coordinates. Returns route object or None in case if the route couldn't be found. """ ... def get_maneuvers(self): """ Returns list of maneuvers on the last route. """ ... ``` Не забудьте импортировать модуль `abc`, наследовать `Navigation` от `ABC` и декорировать его методы через `@abstractmethod`. {.task_hint} ```python {.task_answer} from abc import ABC, abstractmethod class Navigation(ABC): @abstractmethod def build_route(self, start, finish): """ Creates route between start and finish coordinates. Returns route object or None in case if the route couldn't be found. """ pass @abstractmethod def get_maneuvers(self): """ Returns list of maneuvers on the last route. """ pass class CarNavigation(Navigation): def build_route(self, start, finish): print("CarNavigation. build_route") def get_maneuvers(self): print("CarNavigation. get_maneuvers") class TransitNavigation(Navigation): def build_route(self, start, finish): print("TransitNavigation. build_route") def get_maneuvers(self): print("TransitNavigation. get_maneuvers") ``` На практике использовать абстрактные классы в питоне не всегда удобно: например, если абстрактный класс реализован во внешнем модуле, но его требуется встроить в свою иерархию наследования или подправить интерфейс. Кроме того, даже core-разработчики языка [считают](https://docs.python.org/3/library/typing.html#nominal-vs-structural-subtyping) такой подход не идиоматичным. Дочерние классы приходится явно модифицировать для включения в иерархию наследования. А это — лишний код и связывание общим знанием, что все они — потомки некоего базового класса... ## Протоколы {#block-protocols} Pythonic-way подходом к полиморфизму считается утиная типизация. Никаких перегруженных иерархий наследования, явного прописывания базовых классов. Реализация интерфейса определяется не по цепочке наследования, а просто по факту наличия набора требуемых методов. Но если применять утиную типизацию «как она есть» в средне-крупных проектах, то когнитивная сложность кода стремительно возрастает. Становится тяжело ориентироваться, какой класс реализует какой интерфейс, не забыта ли где-то реализация важного метода, не допущена ли ошибка... Статические анализаторы не спасут положение: у них, как и у разработчиков, просто нет для этого нужной информации. Решение есть! В питон 3.8 были добавлены [протоколы.](https://peps.python.org/pep-0544/) Как следует из названия, они описывают некое поведение: ```python {.example_for_playground} from typing import Protocol class Cache(Protocol): def add(self, key, value): pass def get(self, key): pass ``` В этом примере мы **явно** описали желаемый интерфейс для кэширования в протоколе `Cache`. А вот реализуется он **неявно,** в традициях утиной типизации. Заведем классы `LRUCache` и `MRUCache`, которые имеют все необходимые методы. Их можно использовать везде, где ожидается `Cache`: ```python class LRUCache: """ Least recently used caching policy """ def add(self, key, value): print(f"Caching {key} to LRU...") def get(self, key): print(f"Getting from LRU cache {key}...") class MRUCache: """ Most Recently Used caching policy """ def add(self, key, value): print(f"Caching {key} to MRU...") def get(self, key): print(f"Getting from MRU cache {key}...") # Here we can pass objects implementing Cache protocol: def fill_cache(cache: Cache, source): for k, v in source.items(): cache.add(k, v) ``` Итак, мы вынесли общее поведение классов `LRUCache` и `MRUCache` в протокол `Cache`. И, ключевой момент, в функции `fill_cache()` воспользовались [аннотацией типов:](/courses/python/chapters/python_chapter_0330/) после имени параметра `cache` через двоеточие указали, какой у него должен быть тип. Аннотации типов никак не влияют на рантайм и полностью игнорируются интерпретатором. Мы познакомимся с ними [в одной из следующих глав.](/courses/python/chapters/python_chapter_0330/) А пока скажем, что они упрощают понимание кода и нужны статическим анализаторам. Например, анализатору типов [mypy.](https://mypy.readthedocs.io/en/stable/) Если тип переданного в функцию объекта не совпадает с указанным в аннотации, анализатор сообщает об ошибке. Что изменится, если **не наследовать** класс `Cache` от `Protocol`? При выполнении кода — совершенно ничего. Но при передаче аргумента типа `LRUCache` или `MRUCache` в функцию, ожидающую тип `Cache`, статические анализаторы не смогут понять, что в объекте реализован нужный протокол. `mypy` сгенерирует ошибку: ``` error: Argument 1 to "fill_cache" has incompatible type "LRUCache"; expected "Cache" [arg-type] ``` Поэтому все преимущества протоколов раскрываются только в связке со статическими анализаторами кода. Заведите протокол `Validator`, поддерживающий метод `is_valid(obj)`. Пусть этот протокол реализуют два класса: `TextValidator` и `ValueValidator`. {.task_text} `TextValidator` инициализируется коллекцией символов (алфавитом). Его метод `is_valid()` должен возвращать `True` либо `False` для входной строки в зависимости от того, содержатся ли все ее символы в алфавите. {.task_text} `ValueValidator` инициализируется двумя целыми числами: `min_val` и `max_val`. Его метод `is_valid()` должен возвращать `True` либо `False` для числа-аргумента в зависимости от того, входит ли оно в указанные границы (включая границы). {.task_text} ```python {.task_source #python_chapter_0170_task_0040} ``` Не забудьте импортировать `Protocol`. {.task_hint} ```python {.task_answer} from typing import Protocol class Validator(Protocol): def is_valid(self, obj): pass class TextValidator: def __init__(self, alphabet): self._alphabet = alphabet def is_valid(self, obj): for letter in obj: if letter not in self._alphabet: return False return True class ValueValidator: def __init__(self, min_val, max_val): self._min_val = min_val self._max_val = max_val def is_valid(self, obj): return self._min_val <= obj <= self._max_val ``` ## Резюмируем - В языке поддерживается два варианта полиморфизма: наследование и утиная типизация. - Для доступа к атрибутам родительского класса используется встроенная функция `super()`. - В питоне есть множественное наследование. - Для разрешения проблемы ромбовидного наследования реализован MRO обхода иерархии классов под названием C3-линеаризация. - Протоколы нужны для статической утиной типизации.
Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!