Урок 41 из 84 · ООП
Магические методы
Содержание урока
В Python существуют так называемые магические методы. Их назвали так потому, что они добавляют магию в поведение класса. Также у них есть такие названия как специальные, служебные и dunder (double underscore) методы.
Что общего у всех этих методов?!
Во-первых, все они начинаются и заканчиваются двойными подчеркиваниями (__init__, __str__, __add__). В связи с этим они получили название dunder методы (методы с двойным подчеркиванием).
Во-вторых, они реализуют дополнительные интерфейсы класса и вызываются несколько иначе. В Python мы не вызываем эти методы вручную, это происходит автоматически. Например, при выполнении str(obj), выполняется вызов obj.str __ ().
Вообще, любой хорошо спроектированный язык определяет набор соглашений и применяет их в своей стандартной библиотеке. Соглашения могут касаться как чисто внешних признаков: соглашения синтаксиса названий переменных (CamelCase, snake_case), так и поведения объектов. Язык Python в этом смысле — весьма последовательный.
Синтаксис в Python нерасширяем, но зато интерфейсы взаимодействия между объектами хорошо определены и доступны любому разработчику. В Python нет формальной концепции интерфейсов класса, любой класс может предоставлять любой интерфейс, достаточно определить методы с нужными именами и аргументами и убедиться, что их поведение соответствует ожидаемому.
Интерфейсы в Python
Поскольку Python динамически типизирован, проверить соответствие класса объекта на этапе компиляции невозможно. Возможности для указания аннотаций типов предназначены прежде всего для внешних статических анализаторов и не используются во время выполнения. Явная проверка класса с помощью type() считается дурным тоном.
В крайнем случае можно использовать isinstance() — в отличие от type() эта функция возвращает True не только для самого класса, но и для всех его потомков. Проверка с помощью type() сломается при наследовании, именно поэтому люди к ней так плохо относятся.
Интерфейсы объектов определяются так называемыми магическими методами. По соглашению их имена окружаются двойным подчеркиванием. Метод __init__, который используется для инициализации экземпляра класса, — пример, известный каждому. Почти каждая стандартная операция, включая форматированный вывод и арифметику, реализуется каким-то магическим способом.
Магические методы в коде
Как же именно используются магические методы в Python. Давайте, рассмотрим это на практических примерах...
Метод __init__ неявно вызывается при инициализации объекта:
>>> class Foo:
... def __init__(self, a, b):
... self.a = a
... self.b = b
>>> foo = Foo(7, 9) # вызывается __init__
>>> print(foo.a, foo.b) # 7, 9
Среди магических методов есть метод __bool__. В тех классах, где этот метод определен, он сообщает интерпретатору, как оценить булево значение произвольного объекта.
Зачем нам это? Это нестареющая классика: чтобы проверить словарь или список на непустоту, начинающие часто используют стиль, как в C++:
>>> if len(a) != 0:
... pass
В то время как PEP8 рекомендует делать просто:
>>> if not a:
... pass
И это работает ровно потому, что в момент выполнения кода if a: вызывается метод __bool__ класса список.
Для пользовательских объектов, где __bool__ не перегружен, по умолчанию возвращается True.
>>> if foo:
... print(True)
... else:
... print(False)
# True
Но если добавить перегрузку метода __bool__ в класс Foo:
>>> class Foo:
... def __init__(self, a, b):
... self.a = a
... self.b = b
... def __bool__(self):
... return False
В таком случае поведение изменится:
>>> if foo:
... print(True)
... else:
... print(False)
# False
Кстати, в случае, если метода __bool__ в классе нет, то интерпретатор будет искать метод __len__ (длина). Поведение такое же: если __len__ возвращает 0, то логическое значение оценивается как False.
Что с этим делать? Помнить, что falsy (значения, которые оцениваются как False) в Python это:
- пустые словари
{}, - пустые списки
[], - пустые кортежи
(), - пустые множества
set(), - пустые строки
"", - пустые
range(0), - нули любого численного типа:
0,0.0,0j - константы
Noneи собственноFalse
А truthy значения это:
- любые пользовательские объекты по умолчанию
- непустые словари, множества, строки, списки, …
- константа
True
И писать проверку на непустоту считается более правильным, чем сравнение длины с 0:
if not a:
pass
Перегрузка операторов
Перегрузка операторов в Python – это возможность с помощью специальных методов в классах переопределять различные операторы языка.
Под операторами в данном контексте понимаются ни только знаки +, -, *, /, обеспечивающие операции сложения, вычитания и другие, но также специфика синтаксиса языка, обеспечивающая операции создания объекта, вызова объекта как функции, обращение к элементу объекта по индексу, вывод объекта и другое. В программировании специальное поведение того или иного класса называют интерфейсами класса.
Некоторые из самых известных магических методов:
- __init__() – инициализатор объектов класса, вызывается при создании объектов
- __del__() – деструктор объектов класса, вызывается при удалении объектов
- __str__() – преобразование объекта к строковому представлению, вызывается, когда объект передается функциям print() и str()
- __add__() – метод перегрузки оператора сложения, вызывается, когда
объект участвует в операции сложения будучи операндом с левой стороны - __setattr__() – вызывается, когда атрибуту объекта выполняется присваивание
На самом деле перегрузка операторов в пользовательских классах используется не так часто, если не считать конструктора. Но сам факт наличия такой особенности объектно-ориентированного программирования, требует отдельного рассмотрения темы.
Возможность перегрузки операторов обеспечивает схожесть пользовательского класса со встроенными классами Python. Ведь все встроенные типы данных в Python – это классы. В результате все объекты могут иметь одинаковые интерфейсы. Так если ваш класс предполагает обращение к элементу объекта по индексу, например a[0], то это можно обеспечить.
Пусть будет класс-агрегат B, содержащий в списке объекты класса A:
class A:
def __init__(self, arg):
self.arg = arg
def __str__(self):
return str(self.arg)
class B:
def __init__(self, *args):
self.aList = []
for i in args:
self.aList.append(A(i))
group = B(5, 10, 'abc')
Чтобы получить элемент списка, несомненно, мы можем обратиться по индексу к полю aList:
print(group.aList[1])
Однако куда интереснее извлекать элемент по индексу из самого объекта, а не из его поля:
…
class B:
def __init__(self, *args):
self.aList = []
for i in args:
self.aList.append(A(i))
def __getitem__(self, i):
return self.aList[i]
group = B(5, 10, 'abc')
print(group.aList[1]) # выведет 10
print(group[0]) # 5
print(group[2]) # abc
Это делает объекты класса B похожими на объекты встроенных в Python классов-последовательностей (списков, строк, кортежей). Здесь метод __getitem__ перегружает операцию извлечения элемента по индексу. Другими словами, этот метод вызывается, когда к объекту применяется операция извлечения элемента: объект[индекс].
Бывает необходимо, чтобы объект вел себя как функция. Это значит, если у нас есть объект a, то мы можем обращаться к нему в нотации функции, т. е. ставить после него круглые скобки и даже передавать в них аргументы:
a = A()
a()
a(3, 4)
Метод __call__ автоматически вызывается, когда к объекту обращаются как к функции. Например, здесь во второй строке произойдет вызов метода __call__ класса CustomClass:
obj = CustomClass()
obj([3, 4])
Пример:
class Changeable:
def __init__(self, color):
self.color = color
def __call__(self, newcolor):
self.color = newcolor
def __str__(self):
return "%s" % self.color
canvas = Changeable("green")
frame = Changeable("blue")
canvas("red")
frame("yellow")
print(canvas, frame)
В этом примере с помощью конструктора класса при создании объектов устанавливается их цвет. Если требуется его поменять, то достаточно обратиться к объекту как к функции и в качестве аргумента передать новый цвет. Такой обращение автоматически вызовет метод __call__, который, в данном случае, изменит атрибут color объекта.
В Python кроме метода __str__ есть схожий с ним по поведению, но более "низкоуровневый" метод __repr__. Оба метода должны возвращать строку.
Если в классе есть только метод __str__, то при обращении к объекту в интерпретаторе без функции print(), он не будет вызываться:
>>> class A:
... def __str__(self):
... return "This is object of A"
...
>>> a = A()
>>> print(a)
This is object of A
>>> a
<__main__.A instance at 0x7fe964a4cdd0>
>>> str(a)
'This is object of A'
>>> repr(a)
'<__main__.A instance at 0x7fe964a4cdd0>'
Попытка преобразования объекта в строку с помощью встроенной функции str() также вызывает метод __str__. Функция же print() так устроена, что сама вызывает str() для своих аргументов.
В Python есть встроенная функция repr(), которая также как str() преобразует объект в строку. Но "сырую" строку. Что это значит, попробуем понять с помощью примера:
>>> a = '3 + 2'
>>> b = repr(a)
>>> a
'3 + 2'
>>> b
"'3 + 2'"
>>> eval(a)
5
>>> eval(b)
'3 + 2'
>>> c = "Hello\nWorld"
>>> d = repr(c)
>>> c
'Hello\nWorld'
>>> d
"'Hello\\nWorld'"
>>> print(c)
Hello
World
>>> print(d)
'Hello\nWorld'
Функция eval преобразует переданную строку в программный код, который тут же выполняется. Функция print() выполняет переход на новую строку, если встречает символ \n. Функция repr() выполняет действия, направленные на своего рода защиту строки от интерпретации, оставляет ее "сырой", т. е. в исходном виде. Еще раз:
>>> c = "Hello\nWorld"
>>> c # аналог print(repr(c))
'Hello\nWorld'
>>> print(c) # аналог print(str(c))
Hello
World
Однако для большинства случаев различия между repr() и str() не важны. Поэтому в классах проще определять один метод __repr__. Он будет вызываться для всех случаев преобразования к строке:
>>> class A:
... def __repr__(self):
... return "It's obj of A"
...
>>> a = A()
>>> a
It's obj of A
>>> repr(a)
"It's obj of A"
>>> str(a)
"It's obj of A"
>>> print(a)
It's obj of A
Если же нужен различающийся вывод данных, тогда в классе следует определить оба метода перегрузки операторов преобразования к строке: и __str__ и __repr__.
Специальные методы также могут быть использованы, если необходимо
добавить возможность выполнения стандартных операций над классами
(например, сложить 2 класса-точки или обратить ее координаты), т.е.
определить смысл операторов + - * / и т.д. для создаваемого класса.
Конечно, для этого можно создать дополнительные методы (например, add()), однако определение действий операторов с помощью магических методов является более лаконичным способом.
Давайте, рассмотрим следующий код:
class Point2D:
"""Точка на плоскости."""
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
"""Вернуть строку в виде 'Точка 2D (x, y)'."""
return "Точка 2D ({}, {})".format(self.x, self.y)
def __add__(self, other):
"""Создать новый объект как сумму координат 'self' и 'other'."""
# Вместо Point2D можно использовать self.__class__,
# что позволит не привязываться к имени класса и быть
# валидным для потомков
return Point2D(self.x + other.x, self.y + other.y)
def __sub__(self, other):
"""Создать новый объект как разность координат 'self' и 'other'."""
return Point2D(self.x - other.x, self.y - other.y)
def __neg__(self):
"""Вернуть новый объект, инвертировав координаты."""
return Point2D(-self.x, -self.y)
def __eq__(self, other):
"""Вернуть ответ, являются ли точки одинаковыми."""
return self.x == other.x and self.y == other.y
def __ne__(self, other):
"""Вернуть ответ, являются ли точки разными.
Используем реализованную операцию ==."""
return not (self == other)
def distance(self):
"""Вернуть расстояние до центра координат."""
return (self.x**2 + self.y**2)**0.5
if __name__ == "__main__":
p1 = Point2D(0, 5)
p2 = Point2D(-5, 10)
# Если Python не найдет в определении класса метода '__add__()',
# строка ниже сгенерирует исключение:
# 'TypeError: unsupported operand type(s) for +: 'Point2D' and 'Point2D''
print(p1 + p2) # Точка 2D (-5, 15)
print(p1 - p2) # Точка 2D (5, -5)
print(-p2) # Точка 2D (5, -10)
print(p1 == p2, p1 != p2) # False True
print("Расстояние до центра координат (p1): {:.2f}".format(p1.distance())) # 5.00
print("Расстояние до центра координат (p2): {:.2f}".format(p2.distance())) # 11.18
В данном примере мы создали класс Point2D для точек в двухмерной системе координат (x, y). В нем мы определили логику для оператора сложения двух точек +, оператора вычитания -, оператора инверсии, оператора сравнения ==, а также создали метод distance() для определения расстояния до центра координат.
Рассмотрим другой пример переопределения операторов с помощью магических методов:
import math
class Vector2D:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return 'Vector2D({}, {})'.format(self.x, self.y)
def __str__(self):
return '({}, {})'.format(self.x, self.y)
def __add__(self, other):
return Vector2D(self.x + other.x, self.y + other.y)
def __iadd__(self, other):
self.x += other.x
self.y += other.y
return self
def __sub__(self, other):
return Vector2D(self.x - other.x, self.y - other.y)
def __isub__(self, other):
self.x -= other.x
self.y -= other.y
return self
def __abs__(self):
return math.hypot(self.x, self.y)
def __bool__(self):
return self.x != 0 or self.y != 0
def __neg__(self):
return Vector2D(-self.x, -self.y)
>>> x = Vector2D(3, 4)
>>> x
Vector2D(3, 4)
>>> print(x)
(3, 4)
>>> abs(x)
5.0
>>> y = Vector2D(5, 6)
>>> y
Vector2D(5, 6)
>>> x + y
Vector2D(8, 10)
>>> x - y
Vector2D(-2, -2)
>>> -x
Vector2D(-3, -4)
>>> x += y
>>> x
Vector2D(8, 10)
>>> bool(x)
True
>>> z = Vector2D(0, 0)
>>> bool(z)
False
>>> -z
Vector2D(0, 0)
В данном примере мы создали класс Vector2D для двухмерного вектора, для которого переопределили базовые операторы с помощью магических методов.
Перегрузка специальных методов - вещь хорошая, но не стоит ей слишком злоупотреблять. Перегружайте их только тогда, когда вы уверены в том, что это поможет пониманию программного кода.
Вывод
Магические методы - отличный механизм расширения базовых функций классов и объектов Python и обеспечения более интуитивно понятных интерфейсов.
Ссылки
Больше информации о магических методах вы сможете найти в официальной документации Python.