Профессиональная разработка на Python
Продвинутое ООП и Протоколы
Множественное наследование и проблема ромба
Вы уже знаете, как класс может наследовать поведение от одного родительского класса. Но Python позволяет классу наследовать сразу от нескольких. Это называется множественным наследованием и может быть мощным инструментом для смешивания функциональности из разных источников.
Однако это создает потенциальную проблему: что, если два родительских класса имеют метод с одинаковым именем? Какой из них должен вызвать дочерний класс? Эта дилемма известна как «проблема ромба» (Diamond Problem).
Представьте, что у Класса A есть метод do_something(). И Класс B, и Класс C переопределяют этот метод. Когда вы вызываете do_something() у объекта Класса D, какой метод должен быть исполнен: версия из B или из C? Python решает эту неоднозначность с помощью четкого набора правил.
Порядок разрешения методов (MRO)
Чтобы справиться с такими ситуациями, Python использует Порядок разрешения методов (Method Resolution Order, или MRO). Это алгоритм, который определяет точную последовательность, в которой Python будет искать метод в иерархии классов. Для этого используется алгоритм линеаризации C3.
Вам не нужно знать детали самого алгоритма, но важно понимать, что он создает предсказуемый, линейный порядок обхода. Этот порядок гарантирует, что каждый класс в иерархии наследования посещается ровно один раз. Вы можете легко проверить MRO для любого класса.
class A:
def ping(self):
print('ping from A')
class B(A):
def ping(self):
print('ping from B')
class C(A):
def ping(self):
print('ping from C')
class D(B, C):
pass
# Посмотрим на порядок разрешения методов
print(D.__mro__)
# Вывод:
# (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
d = D()
d.ping() # Выведет 'ping from B'
Как видите, MRO для класса D — это D, затем B, затем C и, наконец, A. Поэтому при вызове d.ping() Python находит реализацию в B первой и использует ее. Порядок родительских классов в определении class D(B, C): имеет решающее значение: поменяйте B и C местами, и MRO изменится.
Функция
super()также следует MRO. В сложном сценарии с множественным наследованиемsuper()не обязательно вызывает метод родительского класса. Вместо этого она вызывает метод следующего класса в MRO, что позволяет всем классам в иерархии корректно взаимодействовать.
Абстрактные классы и протоколы
Иногда полезно определить «шаблон» для класса, не реализуя его полностью. Вы хотите сказать: «Любой класс, который выполняет эту роль, должен иметь эти методы». Это концепция интерфейса.
В Python есть два основных способа определения интерфейсов: абстрактные базовые классы и протоколы.
Утиная типизация
other
Принцип в динамических языках программирования, который гласит: «Если что-то крякает как утка и ходит как утка, то это утка». Другими словами, тип объекта определяется его поведением (методами и свойствами), а не его наследованием.
Абстрактные базовые классы (ABC)
Модуль abc позволяет создавать классы, от которых нельзя создать экземпляры. Они служат чертежами. Чтобы создать ABC, вы наследуете от ABC и используете декоратор @abstractmethod для методов, которые дочерние классы обязаны реализовать.
from abc import ABC, abstractmethod
class Stream(ABC):
@abstractmethod
def read(self, maxbytes: int) -> bytes:
pass
@abstractmethod
def write(self, data: bytes) -> int:
pass
# Попытка создать экземпляр вызовет ошибку
# stream = Stream() # TypeError: Can't instantiate abstract class
class SocketStream(Stream):
def read(self, maxbytes: int) -> bytes:
# реализация для сокета
print(f"Reading {maxbytes} from socket")
return b''
def write(self, data: bytes) -> int:
# реализация для сокета
print(f"Writing {len(data)} to socket")
return len(data)
sock = SocketStream()
sock.read(1024)
Если вы попытаетесь создать дочерний класс, который не реализует все абстрактные методы, Python также выдаст TypeError.
Протоколы: статическая утиная типизация
Протоколы, введенные в PEP 544 и доступные в модуле typing, являются более современным и гибким подходом. Они позволяют вам определить интерфейс, которому класс может соответствовать, даже не наследуя его явно. Это утиная типизация, но с поддержкой статических анализаторов кода, таких как Mypy.
from typing import Protocol
class Readable(Protocol):
def read(self, maxbytes: int) -> bytes:
...
class FileLike:
def read(self, maxbytes: int) -> bytes:
print("Reading from a file-like object")
return b'data'
class NotFileLike:
def write(self, data: bytes):
pass
def process_stream(stream: Readable):
stream.read(1024)
# Этот вызов пройдет проверку типов
process_stream(FileLike())
# А этот — нет, так как у NotFileLike нет метода read
# process_stream(NotFileLike())
Обратите внимание, FileLike ничего не знает о Readable. Но поскольку у него есть метод read с правильной сигнатурой, он соответствует протоколу. Это делает код более гибким и менее связанным.
Оптимизация с помощью __slots__
По умолчанию каждый экземпляр класса в Python имеет специальный атрибут __dict__, который представляет собой словарь, хранящий все его атрибуты. Это делает Python очень динамичным — вы можете добавлять новые атрибуты к объекту на лету.
Однако словари потребляют довольно много памяти. Если вы создаете миллионы маленьких объектов, это может стать проблемой. Для таких случаев можно использовать __slots__.
__slots__— это атрибут класса, которому присваивается кортеж или список строк, являющихся именами атрибутов экземпляра. Когда__slots__определен, Python не создает__dict__для каждого экземпляра, а выделяет фиксированное количество памяти для перечисленных атрибутов.
import sys
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
class PointWithSlots:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
p_dict = Point(10, 20)
p_slots = PointWithSlots(10, 20)
# __dict__ занимает больше памяти
print(f"Размер с __dict__: {sys.getsizeof(p_dict.__dict__)}")
# __slots__ не имеет __dict__
# print(p_slots.__dict__) # AttributeError
# Попытка добавить новый атрибут вызовет ошибку
# p_slots.z = 30 # AttributeError
Используйте __slots__ только тогда, когда вы уверены, что оптимизация памяти критически важна для вашего приложения и вы готовы пожертвовать гибкостью.
Что такое Порядок разрешения методов (MRO) в Python?
Рассмотрите следующий код:
class A:
def ping(self):
return "A"
class B(A):
def ping(self):
return "B"
class C(A):
def ping(self):
return "C"
class D(C, B):
pass
print(D().ping())
Что будет выведено на экран?
Эти продвинутые концепции ООП дают вам инструменты для написания более чистого, гибкого и эффективного кода на Python.