DevGang
Авторизоваться

Магические методы Python, о которых вы, возможно, не слышали

Волшебные методы Python, также известные как методы dunder (двойное подчеркивание), могут быть использованы для реализации множества интересных вещей. Большую часть времени мы используем их для простых вещей, таких как конструкторы (__init__), строковое представление (__str__, __repr__) или арифметические операторы (__add__/__mul__). Однако существует еще много волшебных методов, о которых вы, вероятно, не слышали, и в этой статье мы рассмотрим их все (даже скрытые и недокументированные).

Длина итератора

Мы все знаем метод __len__, который вы можете использовать для реализации функции len() в ваших контейнерных классах. Однако что, если вы хотите получить длину объекта класса, который реализует итератор?

length_hint.py
it = iter(range(100))
print(it.__length_hint__())
# 100
next(it)
print(it.__length_hint__())
# 99

a = [1, 2, 3, 4, 5]
it = iter(a)
print(it.__length_hint__())
# 5
next(it)
print(it.__length_hint__())
# 4
a.append(6)
print(it.__length_hint__())
# 5

Все, что вам нужно сделать, это реализовать метод __length_hint__, который также присутствует во встроенных итераторах (но не в генераторах), как вы можете видеть выше. Кроме того, как вы можете видеть здесь, он также поддерживает динамические изменения длины. Однако с учетом сказанного — как следует из названия — на самом деле это всего лишь подсказка и может быть совершенно неточной: для итератора списка вы получите точные результаты, для других итераторов не обязательно. Однако, даже если это неточно, это может быть очень полезно для оптимизации, как описано в PEP 424, который представил его некоторое время назад.

Метапрограммирование

Большая часть магических методов, которые вы редко видите используемыми, связаны с метапрограммированием, и хотя метапрограммирование - это то, что вам, вероятно, не придется использовать каждый день, есть несколько удобных приемов, для которых вы можете его использовать.

Одним из таких трюков является использование __init_subclass__ в качестве ярлыка для расширения функциональных возможностей базового класса без необходимости обработки метаклассов:

init_subclass.py
class Pet:
    def __init_subclass__(cls, /, default_breed, **kwargs):
        super().__init_subclass__(**kwargs)
        cls.default_breed = default_breed

class Dog(Pet, default_name="German Shepherd"):
    pass

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

Хотя это может показаться очень непонятным и редко полезным, вы, вероятно, уже сталкивались с этим много раз, поскольку его можно использовать при создании API, где пользователи подклассируют ваш родительский класс, как в моделях SQLAlchemy или Flask Views.

Другой магический метод метакласса, который вы могли бы использовать, - это __call__. Этот метод позволяет вам настроить то, что происходит при вызове экземпляра класса:

call.py
class CallableClass:
    def __call__(self, *args, **kwargs):
        print("I was called!")

instance = CallableClass()

instance()
# I was called!

Забавно, но вы можете использовать это для создания класса, который нельзя вызвать:

no_instances.py
class NoInstances(type):
    def __call__(cls, *args, **kwargs):
        raise TypeError("Can't create instance of this class")

class SomeClass(metaclass=NoInstances):
    @staticmethod
    def func(x):
        print('A static method')

instance = SomeClass()
# TypeError: Can't create instance of this class

Это может быть полезно, если у вас есть класс, который имеет только статические методы, и поэтому нет веской причины для создания экземпляров указанного класса.

Другой подобный вариант использования, который приходит на ум, — это шаблон singleton - класс, который может иметь не более одного экземпляра:

singleton.py
class Singleton(type):
    def __init__(cls, *args, **kwargs):
        cls.__instance = None
        super().__init__(*args, **kwargs)

    def __call__(cls, *args, **kwargs):
        if cls.__instance is None:
            cls.__instance = super().__call__(*args, **kwargs)
            return cls.__instance
        else:
            return cls.__instance

class Logger(metaclass=Singleton):
    def __init__(self):
        print("Creating global Logger instance")

Здесь мы демонстрируем это, реализуя глобальный класс регистратора, экземпляр которого может быть только один. Концепция может показаться немного сложной, но эта реализация довольно проста — класс Singleton содержит закрытый __instance — если его нет, он создается и присваивается атрибуту, если он уже существует, он просто возвращается.

Теперь, допустим, у вас есть класс, и вы хотите создать его экземпляр без вызова __init__. __new__ волшебный метод может помочь в этом:

new.py
class Document:
    def __init__(self, text):
        self.text = text

bare_document = Document.__new__(Document)
print(bare_document.text)
# AttributeError: 'Document' object has no attribute 'text'

setattr(bare_document, "text", "Text of the document")

Бывают ситуации, когда вам может потребоваться обойти обычный процесс создания экземпляра, и приведенный выше код показывает, как вы можете это сделать. Вместо вызова Document(...) мы вызываем Document.__new__(Document), который создает пустой экземпляр без вызова __init__. Из-за этого атрибут(ы) экземпляра - в данном случае text - не инициализируется, чтобы исправить это, мы можем использовать функцию setattr (которая, кстати, также является волшебным методом - __setattr__).

Возможно, вам интересно, зачем вам вообще понадобилось это делать. Одним из примеров может быть реализация альтернативного конструктора, такого как этот:

alternative_constructor.py
class Document:
    def __init__(self, text):
        self.text = text
    
    @classmethod
    def from_file(cls, file):  # Alternative constructor
        d = cls.__new__(cls)
        # Do stuff...
        return d

Здесь мы определяем метод from_file, который служит конструктором, сначала создавая экземпляр с помощью __new__, а затем настраивая его без вызова __init__.

Следующий магический метод, связанный с метапрограммированием, который мы рассмотрим здесь, - это __getattr__. Этот метод вызывается при сбое обычного доступа к атрибуту. Это может быть использовано для делегирования доступа/вызовов к отсутствующим методам другому классу:

getattr.py
class String:
    def __init__(self, value):
        self._value = str(value)

    def custom_operation(self):
        pass

    def __getattr__(self, name):
        return getattr(self._value, name)

s = String("some text")
s.custom_operation()  # Calls String.custom_operation()
print(s.split())  # Calls String.__getattr__("split") and delegates to str.split
# ['some', 'text']

print("some text" + "more text")
# ... works
print(s + "more text")
# TypeError: unsupported operand type(s) for +: 'String' and 'str'

Давайте предположим, что мы хотим определить пользовательскую реализацию string с некоторыми дополнительными функциями, такими как custom_operation выше. Однако мы не хотим повторно реализовывать каждый отдельный строковый метод, такой как split, join, capitalize и так далее. Поэтому мы используем __getattr__ для вызова этих существующих строковых методов в случае, если они не найдены в нашем классе.

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

Самоанализ

Последний магический метод, связанный с метапрограммированием, который мы опробуем, - это __getattribute__. Этот очень похож на предыдущий __getattr__. Однако есть небольшая разница — как уже упоминалось, __getattr__ вызывается только при сбое поиска атрибута, в то время как __getattribute__ вызывается перед попыткой поиска атрибута.

Поэтому вы можете использовать __getattribute__ для управления доступом к атрибутам, или вы можете, например, создать декоратор, который регистрирует каждую попытку доступа к атрибуту экземпляра:

getattribute.py
def logger(cls):
    original_getattribute = cls.__getattribute__

    def getattribute(self, name):
        print(f"Getting: '{name}'")
        return original_getattribute(self, name)

    cls.__getattribute__ = getattribute
    return cls

@logger
class SomeClass:
    def __init__(self, attr):
        self.attr = attr

    def func(self):
        ...

instance = SomeClass("value")
instance.attr
# Getting: 'attr'
instance.func()
# Getting: 'func'

Функция декоратора logger начинается с того, что принимает к сведению исходный метод __getattribute__ класса, который он украшает. Затем он заменяет его пользовательским методом, который сначала регистрирует имя атрибута, к которому осуществляется доступ, перед вызовом исходного метода __getattribute__.

Магические атрибуты

До сих пор мы говорили только о магических методах, но в Python также есть довольно много магических переменных/атрибутов. Один из них - __all__:

all.py
# some_module/__init__.py
__all__ = ["func", "some_var"]

some_var = "data"
some_other_var = "more data"

def func():
    return "hello"

# -----------

from some_module import *

print(some_var)
# "data"
print(func())
# "hello"

print(some_other_var)
# Exception, "some_other_var" is not exported by the module

Этот волшебный атрибут можно использовать для определения того, какие переменные и функции экспортируются из модуля. В примере мы создаем модуль Python в .../some_module/ с одним файлом (__init__.py). В этом файле мы определяем 2 переменные и одну функцию, из которых мы экспортируем только 2 (func и some_var). Если мы затем попытаемся импортировать содержимое some_module в другую программу Python, мы получим только 2 экспортированных файла.

Имейте в виду, однако, что переменная __all__ влияет только на * импорт, показанный выше, вы все равно можете импортировать неэкспортированные функции и переменные с помощью импорта, такого как import some_other_var from some_module.

Еще одна переменная с двойным подчеркиванием (атрибут модуля), которую вы, возможно, видели, - это __file__. Эта переменная просто определяет путь к файлу, из которого к ней осуществляется доступ:

file.py
from pathlib import Path

print(__file__)
print(Path(__file__).resolve())
# /home/.../directory/examples.py

# Or the old way:
import os
print(os.path.dirname(os.path.abspath(__file__)))
# /home/.../directory/

И объединив __all__ и __file__, вы можете, например, загрузить все модули в папку:

load_modules.py
# Directory structure:
# .
# |____some_dir
#   |____module_three.py
#   |____module_two.py
#   |____module_one.py

from pathlib import Path, PurePath
modules = list(Path(__file__).parent.glob("*.py"))
print([PurePath(f).stem for f in modules if f.is_file() and not f.name == "__init__.py"])
# ['module_one', 'module_two', 'module_three']

И последнее, что мы попробуем, - это атрибут __debug__. Этот - очевидно - может быть использован для отладки, но более конкретно его можно использовать для лучшего управления утверждениями:

debug.py
# example.py
def func():
    if __debug__:
        print("debugging logs")

    # Do stuff...

func()

Если мы запустим этот фрагмент кода в обычном режиме с помощью python example.py, мы увидим распечатанные "debugging logs", однако, если мы используем python3 -O example.py, флаг оптимизации (-O) установит для __debug__ значение false и лишит вывод отладочных сообщений. Поэтому, если вы запускаете свой код с -O в производственной среде, вам не придется беспокоиться о забытых вызовах print, оставшихся после отладки, поскольку все они будут удалены.

Скрытый и недокументированный

Все вышеперечисленные методы и атрибуты могут быть несколько неизвестны, но все они есть в документах Python. Однако есть пара моментов, которые четко не задокументированы и/или несколько скрыты.

Вы можете, например, запустить следующий код, чтобы обнаружить пару новых:

dir.py
import struct
dir(struct)
# ['Struct', '__all__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', 
# '__spec__', '_clearcache', 'calcsize', 'error', 'iter_unpack', 'pack', 'pack_into', 'unpack', 'unpack_from']

Помимо этого, есть еще несколько, перечисленных в системе отслеживания ошибок Python BPO 23639. Однако, как указано там, большинство из них являются деталями реализации или частными именами, к которым не следует обращаться. Так что то, что они не задокументированы, вероятно, к лучшему.

Сделать свой

Теперь, с таким количеством магических методов и атрибутов, не могли бы вы действительно создать свой собственный? Что ж, вы могли бы, но не должны.

Имена с двойным подчеркиванием зарезервированы для будущих расширений языка Python и не должны использоваться для вашего собственного кода. Если вы все равно решите использовать такое имя в своем коде, то вы рискуете тем, что в будущем они будут добавлены в интерпретатор Python, что, скорее всего, нарушит ваш код.

Заключительные мысли

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

#Python
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться