Перевод статьи 5 Pairs of Magic Methods in Python That You Should Know.

В этой статье мы изучим пять ключевых концепций Python, связанных с «магическими» методами.

Введение

Часто программируя на Python мы сталкиваемся с выбором имен для функций и обычно используем для этого буквы (конечно же алфавита английского языка) и цифры, а также символ подчеркивания. При этом использование в имени функций и методов символа подчеркивания между отдельными словами, лишь улучшает их читаемость (и соответственно понимаемость), имитируя пробелы между отдельными словами. Этот стиль именования известен как snake naming style или «змеиный» стиль. Например, следующее имя для переменной или функции: calculate_mean_score читается значительно легче и понятнее, чем calculatemeanscore. Помимо этого наиболее распространенного способа применения символа подчеркивания, он может использоваться в качестве префикса для имени функции (метода), например, _func, __func, для обозначения того, что эта функция (метод) предназначены для внутреннего private использования в классе или модуле. Имена без префикса подчеркивания считаются общедоступными public API.

В этой статье будет рассмотрен подробнее другой способ использования символов подчеркивания при именовании функций (методов) — обозначение «магических» или специальных методов. В этом случае, мы помещаем два символа подчеркивания перед и после имени функции (метода) и в результате получим что-то вроде этого: __func__. В виду подобного использования двойного подчеркивания некоторые разработчики называют специальные методы «dunder-методами» или просто «dunders» (double under). В настоящей статье рассмотрены пять тесно связанных друг с другом пар наиболее известных «магических» методов, каждый из которых иллюстрирует основные концепции реализации Python как языка программирования.

1. Создание: __new__ и __init__

Изучая основы реализации и применения на практике различных структур данных в Python (например, словарей, списков и т.д.), вы уже наверняка успели познакомиться с примерами определения пользовательских классов, и тогда, наверняка, впервые обратили внимание на следующий «магический»: __init__. Он используется для описания поведения класса при инициализации его экземпляра. В частности, в методе __init__ вы можете инициализировать начальные значения для атрибутов класса при создании его экземпляра. Рассмотрим простой пример использования этого метода:

class Product:
        def __init__(self, name, price):
        self.name = name
        self.price = price

Когда мы вызываем метод __init__ , мы не делаем это непосредственно. Метод __init__ используется в роли «строительной» основы метода конструктора класса, который имеет ту же функциональную сигнатуру, что и метод __init__ . Например, чтобы создать новый экземпляр класса Product, используется код следующего вида:

product = Product("Vacuum", 150.0)

С методом __init__ тесно связан другой «магический» метод __new__, который обычно не реализуется в пользовательских классах. По сути, метод __new__ создает пустой объект экземпляра класса, который затем передается методу __init__ для завершения процесса инициализации класса: непосредственной передачи начальных значений его атрибутам.

Другими словами, создание нового экземпляра класса или процесс, более известный как реализация экземпляра класса, влечет последовательный вызов двух «магических» методов __new__ и __init__.

Следующий код демонстрирует цепочку вызовов этих методов:

>>> class Product:
                def __new__(cls, *args):
                        new_product = object.__new__(cls)
                        print("Product __new__ gets called")
                        return new_product
        def __init__(self, name, price):
                self.name = name
                        self.price = price
                        print("Product __init__ gets called")
    
>>> product = Product("Vacuum", 150.0)
Product __new__ gets called
Product __init__ gets called

2. Строковое представление: __repr__ и __str__

Оба этих метода важны для определения корректного строкового представления содержимого пользовательского класса. И прежде мы рассмотрим их поподробнее, обратите внимание на код ниже:

class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price
        
        def __repr__(self):
        return f"Product({self.name!r}, {self.price!r})"
    
    def __str__(self):
        return f"Product: {self.name}, ${self.price:.2f}"

Метод __repr__ фактически возвращает строку кода, с помощью которой был создан экземпляра класса. Проще говоря, эту строку можно передать в функцию eval()для воспроизведения нового экземпляра класса. Следующий фрагмент кода иллюстрирует использование этой возможности на практике:

>>> product = Product("Vacuum", 150.0)
>>> repr(product)
"Product('Vacuum', 150.0)"
>>> evaluated = eval(repr(product))
>>> type(evaluated)

Метод __str__ может вернуть нечто более описательное об объекте экземпляра класса. Следует отметить, что метод __str__ используется «под капотом» функцией print() для отображения информации, связанной с экземпляром класса, как это показано в примере кода ниже.

>>> print(product)
Product: Vacuum, $150.00

Хотя эти оба метода возвращают строку, метод __repr__ обычно используется разработчиками, для отображения информации о реализации конкретного экземпляра класса. В то время как метод __str__ предназначен в большей степени для использования обычными пользователями, когда мы хотим узнать общую информацию об экземпляре класса.

3. Итерирование: __iter__и __next__

Рассмотрим реализацию еще одной ключевой возможности, которую мы можем автоматизировать с помощью нашего кода — это повторение однотипных действий. В частности ее реализация предполагает использование цикла for для организации управляемого, определенной логикой, потока выполнения. Пользуясь принятой терминологией говорят, что некоторые объекты являются iterable, то есть могут быть использованы для обработки в цикле. Самый простой пример использования цикла for для этого случая представлен ниже:

for item in iterable:
    # действия с item

«Под капотом» этой инструкции некоторый iterable объект, который преобразуется в итератор, что позволяет в каждой итерации цикла получать его элементы по одному для последующей обработки. Вообще говоря, итераторы — это специальные объекты Python, которые можно использовать для извлечения отдельных элементов, подлежащих перебору (итерации). Процесс этого преобразования осуществляется с помощью специального «магических» метода __iter__. Кроме того, получение следующего элемента итератора включает реализацию другого «магических» метода __next__. Давайте допишем предыдущий пример кода для использования класса Product в качестве итератора в цикле for:

>>> class Product:
...     def __init__(self, name, price):
...         self.name = name
...         self.price = price
... 
...     def __str__(self):
...         return f"Product: {self.name}, ${self.price:.2f}"
... 
...     def __iter__(self):
...         self._free_samples = [Product(self.name, 0) for _ in range(3)]
...         print("Iterator of the product is created.")
...         return self
... 
...     def __next__(self):
...         if self._free_samples:
...             return self._free_samples.pop()
...         else:
...             raise StopIteration("All free samples have been dispensed.")
... 
>>> product = Product("Perfume", 5.0)
>>> for i, sample in enumerate(product, 1):
...     print(f"Dispense the next sample #{i}: {sample}")
... 
Iterator of the product is created.
Dispense the next sample #1: Product: Perfume, $0.00
Dispense the next sample #2: Product: Perfume, $0.00
Dispense the next sample #3: Product: Perfume, $0.00

Как мы уже говорили выше, сначала с помощью метода __iter__ мы создаем список объектов, содержащий несколько элементов типа _free_samples, и который реализует итератор для экземпляров нашего класса. Затем для того, чтобы реализовать «итерационное» поведение, мы реализуем метод __next__, который возвращает очередной объект из списка. Итерирование заканчивается, когда заканчиваются все элементы из списка.

4. Менеджер контекста: __enter__ и __exit__

Когда мы имеем дело с файловыми объектами Python, то нам часто встречается код, имеющий следующий синтаксис:

with open('filename.txt') as file:
        # Ваши действия с содержимым файла

Эта техника использования оператора with более известна как менеджер контекста. В частности, в примере выше для обработки содержимого файла оператор with создает менеджер контекста для работы с файловым объектом. Затем после всех действий с содержимым файла менеджер контекста обеспечивает своевременное закрытие файлового объекта и файл снова станет доступным для других процессов, т.е. корректно «освободить» некоторый общий ресурс.

Итак, в общем случае менеджеры контекста — это специальные объекты Python, которые управляют доступом к некоторым общим ресурсам, то есть корректно открывают и закрывают их. Без них нам бы пришлось управлять этими процессами вручную, что в конечном счете приводит к возникновению различного рода ошибок.

Для того, чтобы реализовать такое поведения в нашем пользовательском классе, необходимо реализовать соответствующие «магические» методы: __enter__и __exit__. Метод __enter__ настраивает новый менеджер контекста, который подготавливает необходимый для дальнейшей работы ресурс, а метод __exit__ соответственно освобождает используемые ресурсы для того, чтобы снова сделать их доступными. Рассмотрим следующий фрагмент кода, который содержит простой пример с уже знакомым нам классом Product:

>>> class Product:
...     def __init__(self, name, price):
...         self.price = price
... 
...     def __str__(self):
...         return f"Product: {self.name}, ${self.price:.2f}"
... 
...     def _move_to_center(self):
...         print(f"The product ({self}) occupies the center exhibit spot.")
... 
...     def _move_to_side(self):
...         print(f"Move {self} back.")
... 
...     def __enter__(self):
...         print("__enter__ is called")
...         self._move_to_center()
... 
...     def __exit__(self, exc_type, exc_val, exc_tb):
...         print("__exit__ is called")
...         self._move_to_side()
... 
>>> product = Product("BMW Car", 50000)
>>> with product:
...     print("It's a very good car.")
... 
__enter__ is called
The product (Product: BMW Car, $50000.00) occupies the center exhibit spot.
It's a very good car.
__exit__ is called
Move Product: BMW Car, $50000.00 back.

Как видите, когда объект экземпляра нашего класса встраивается в инструкцию оператора with, будет вызван метод __enter__. Если выполнение блока кода ниже инструкции with завершено, будет вызван метод __exit__.

Тем не менее, следует отметить, что мы можем реализовать методы __enter__и __exit__ намного проще с помощью декораторов функций.

5. Полный контроль доступа к атрибутам: __getattr__ и __setattr__

Если у вас имеется опыт программирования на других языках, то вы, возможно, уже использовали технику реализации явных методов для установки и получения значений атрибутов объекта или экземпляра класса (так называемых геттеров и сеттеров). В Python нам не нужно использовать эту технику методов управления доступом для каждого отдельного атрибута объекта. Тем не менее, вполне возможно, что мы можем захотим иметь некоторый контроль над ними путем реализации «магических» методов __getattr__и __setattr__. В частности, метод __getattr__ вызывается при обращении к атрибутам объекта или экземпляра класса, а метод __setattr__ — когда мы устанавливаем значения для их атрибутов.

>>> class Product:
...     def __init__(self, name):
...         self.name = name
... 
...     def __getattr__(self, item):
...         if item == "formatted_name":
...             print(f"__getattr__ is called for {item}")
...             formatted = self.name.capitalize()
...             setattr(self, "formatted_name", formatted)
...             return formatted
...         else:
...             raise AttributeError(f"no attribute of {item}")
... 
...     def __setattr__(self, key, value):
...         print(f"__setattr__ is called for {key!r}: {value!r}")
...         super().__setattr__(key, value)
... 
>>> product = Product("taBLe")
__setattr__ is called for 'name': 'taBLe'
>>> product.name
'taBLe'
>>> product.formatted_name
__getattr__ is called for formatted_name
__setattr__ is called for 'formatted_name': 'Table'
'Table'
>>> product.formatted_name
'Table'

Метод __setattr__ вызывается неявно каждый раз, когда мы пытаемся установить значение для атрибута объекта (экземпляра класса). И чтобы использовать его правильно, мы должны использовать метод суперкласса super(). В противном случае это может привести к бесконечной рекурсии, а затем к зависанию нашего скрипта.

После установки значения атрибута formatted_name, он станет частью объекта __dict__, и поэтому __getattr__не будет бесконечно вызываться.

Атрибуты объекта можно условно разделить две группы: определённые в Python (такие как __class__,) и определённые пользователем. __dict__ согласно этой классификации, относится к “системным” (определённым Python) атрибутам. Его задача — хранить пользовательские атрибуты. Он представляет собой словарь dictionary, в котором ключом является имя_атрибута, а значением, соответственно, значение_атрибута.

В качестве примечания, есть еще один «магический» метод, тесно связанный с управлением доступом к атрибутам __getattribute__, который похож на __getattr__, но вызывается каждый раз при доступе к атрибуту. В этом отношении он по аналогии похож на метод __setattr__, и вы так же должны использовать super() для для собственной реализации метода __getattribute__, чтобы избежать ошибки (бесконечной рекурсии).

Вывод

В этой статье мы достаточно подробно рассмотрели пять пар «магических» методов, с помощью которых изучили на практических примерах пять основных концепций языка Python, непосредственно связанных с каждой из них. Я надеюсь, что теперь вы станете лучше понимать смысл этих концепций, а так же способы использования «магических» методов в ваших проектах Python.

Оставить комментарий