<jupyter_start><jupyter_text>Методы

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

Методы являются объектами типа ```method```, поэтому их не обязательно вызывать. С ними можно выполнять те же операции, что и с функциями, т.е. хранить в структурах данных, удалять, передавать в качестве аргументов, возвращать из других функций и методов.<jupyter_code>class A:
    def foo(self):
        print(f'Это класс {self.__class__.__name__}!')

a = A()
a.foo()

print(f'{type(a.foo) = }')<jupyter_output>Это класс A!
type(a.foo) = <class 'method'><jupyter_text>Все методы можно разделить на три группы:
- методы экземпляра;
- методы класса;
- статические методы.

По умолчанию все методы являются методами экземпляра. Их отличает одна особенность. Такие методы **должны** принимать один **обязательный** аргумент - ```self```. Имя ```self``` не обязательно, оно используется по соглашению, но не рекомендуется использование других имен.

Этот аргумент отвечает за передачу экземпляра класса в метод. Поэтому при вызове метода ```obj.method()``` не нужно передавать этот аргумент вручную, это делается неявно. Эту конструкцию можно переписать в эквивалентную ```Class.method(obj)```, вызвав метод у класса и, передав в качестве аргумента ```self``` экземпляр класса ```obj```. Аналогом аргумента ```self``` является ```this``` в других языках программирования.<jupyter_code>a.foo()
A.foo(a)<jupyter_output>Это класс A!
Это класс A!<jupyter_text>Стоит обратить внимание, что типы этих объектов отличаются. Верхнее выражение ```a.foo``` имеет тип ```method```, а второе ```A.foo``` - ```function```. Далее мы разберемся почему происходит именно так.<jupyter_code>print(f'{type(a.foo) = }')
print(f'{type(A.foo) = }')<jupyter_output>type(a.foo) = <class 'method'>
type(A.foo) = <class 'function'><jupyter_text>Работа методов происходит следующим образом. При вызове метода у
укземпляра ```obj.method()```, происходит поиск в классе экземпляра.
Если имя обозначает атрибут, тип которого является функцией, то
происходит создаение объекта метода. Это происходит с помощью упаковки
указателя объекта (экземпляра) и функции в новый объект - объект
метода. В момент вызова метода с набором аргументов происходит создание
нового набора аргументов из объекта экземпляра и списка остальных
аргументов, в заключении вызывается функция с этим набором аргументов.

 Связанные и несвязанные методы

Стоит сразу упомянуть, что концепция несвязанные методов была удалена в
версии языка 3.0. Изначально она рассматривалась в качестве способа
обеспечения "равноправия" между всеми объектами и в том числе методами.
Рассмотрим эту концепцию подробнее на следующем простом примере класса
с парой методов.<jupyter_code>class A:
    def __init__(self, x):
        self.x = x
    
    def foo(self, y):
        print(f'{self.x = }; {y = }')<jupyter_output><empty_output><jupyter_text>Если методы рассматривать как объекты первого класса, то они должны
поддерживать связывание с именами переменных и соответственно вызов как
обычных функций. Рассмотрим следующий вариант вызова ```b = A.spam```.
В этом случае переменная ```b``` связывается непосредственно с методом
класса ```A```, который на самом деле является функцией. Но методы
немного отличаются от обычных функций наличием первого аргумента, в
качестве которого передается экзепляр класса, где определен метод.

В результате было введено понятие как несвязанный метод. В версиях
языка < 3.0 это был отдельный тип, который налагал ограничения на то,
что первым аргументом должен быть экземпляр класса, в котором объявлен
метод. Таким образом, если нужно было вызвать ```b``` в качестве
функции, потребовалось бы создать экземпляр класса ```A``` и передать
его первым аргументом.<jupyter_code>b = A.foo
a = A(42)
b(a, 3)<jupyter_output>self.x = 42; y = 3<jupyter_text>Сейчас несвязанный метод это просто функция. И на тип первого аргумента
не накладываются ограничения, т.е. не обязательно передавать именно
экземпляр класса, в котором был определе метод.<jupyter_code>class B:
    pass

mock = B()
mock.x = 42

b(mock, 1)<jupyter_output>self.x = 42; y = 1<jupyter_text>Такое поведение называется утиной типизацией, которая будет рассмотрена
отдельно.

Связанные методы возникают, если создается имя, которое ссылается на
метод конкретного экземпляра.<jupyter_code>a = A(42)
b = a.foo  # связанный метод

print(f'{type(b) = }')
print(b)<jupyter_output>type(b) = <class 'method'>
<bound method A.foo of <__main__.A object at 0x00000214A560BE20>><jupyter_text>Здесь переменная ```b``` ссылается на метод класса ```A```, но ссылка
получена через экземпляр ```a```, т.е. после связывания конкретного
экземпляра и метода. В этом случае тип таких объектов
```bound method```, а не ```function```. Этот объект называется
связанным методом. Сам объект представляет собой оболочку для объекта
функции-метода. Эта оболочка неявно хранит ссылку на исходный экземпляр
```a```, который был использован для получения метода. Таким образом,
становиться доступным вызов ```b``` без передачи первого аргумента, он
будет передан неявно самой оболочкой (как в декораторах).

Подробнее эти концепции описаны в
[блоге об истории Python Гвидо ван Россума](https://python-history.blogspot.com/2009/02/first-class-everything.html).

Далее примидена небольша упращенная иллюстрация работы связанных и
несвязанных методов. К ее изучению лучше вернуться после изучения всей
главы о классах.<jupyter_code>class BoundMethod:
    """Связанный метод"""
    def __init__(self, name, instance, function):
        # Сохраним ссылки на экземпляр и функцию
        self.name = name
        self.instance = instance
        self.function = function

    def __call__(self, *args, **kwds):
        # переопределение вызова
        # неявно передаем экземпляр класса в качестве первого аргумента
        return self.function(self.instance, *args, **kwds)
class Class:
    """Пользовательский класс"""
    def __init__(self, name, class_attributes):
        # имя класса
        self.name = name
        # словарь атрибутов класса
        self.class_attributes = class_attributes

    def create_instance(self, *args):
        """Создание экземпляра"""
        # Замена методов __new__ и __init__
        return Instance(self, *args)

    def __getattr__(self, name):
        # Переопределим поиск атрибутов
        if name not in self.class_attributes:
            raise AttributeError
        return self.class_attributes[name]

    def __call__(self, *args):
        # Переопределим вызов, для создания экземпляров
        return self.create_instance(*args)
from typing import Callable


class Instance:
    """Экземпляр класса"""
    def __init__(self, type_, attributes):
        # Запомним тип и атрибуты экземпляра
        self.type_ = type_
        self.attributes = attributes

    def __getattr__(self, name):
        # Переопределим поиск атрибутов
        # Сначала ищем атрибуты экземпляра
        if name not in self.attributes:
            # Если ничего не найдено ищем в классе
            attributes = self.type_.__getattr__(name)
        else:
            attributes = self.attributes[name]
        # Для функций создаем связанный метод
        if isinstance(attributes, Callable):
            return BoundMethod(name, self, attributes)
        return attributes
def method(self, attr_a, attr_b):
    """Эмулируем метод"""
    print(f'{self.bar = }; {attr_a = }; {attr_b = }')
# Создаем класс
Foo = Class('Foo', {'func': method})

print(f'{Foo.class_attributes = }')

# можем обратиться к методу через класс
print(f'{Foo.func = }')

# Создаем экземпляр
instance_a = Foo({'bar': 1, 'baz': 2})

print(f'{instance_a.type_ = }')
print(f'{instance_a.attributes = }')

# можем обратиться к методу, теперь он связан
print(instance_a.func)
# пробуем вызвать
Foo.func(instance_a, 2, 1)

instance_a.func(2, 1)<jupyter_output>self.bar = 1; attr_a = 2; attr_b = 1
self.bar = 1; attr_a = 2; attr_b = 1<jupyter_text>Методы экземпляра, статические методы

Методы экземпляра это все те методы, которые создаются по умолчанию в
Python, с указанием обязательного первого аргумента ```self```.
Все методы, которые приводились выше были методами экземпляра.
Их отличительной чертой является аргумент ```self```, посредством
которого из метода можно получить доступ к экземпляру, а через него и
к самому классу. Ниже привиден привер счетчика, который вы уже видели
ранее. Все его методы являются методами экземпляра. В методе ```inc```
показано, как можно обратиться к атрибуту экземпляра ```count``` и
изменить его значение. Доступ к атрибутам класс, например
```global_count```, из таких методов осуществляется с помощью
специального "магического" атрибута ```__class__```. О "магических"
атрибутах и методах речь пойдет в следующих разделах.<jupyter_code>class Counter:
    global_count = 0
    def __init__(self, initial=0):
        self.count = initial
    
    def inc(self):
        self.count += 1
        self.__class__.global_count += 1
    
    def get_global_counter(self):
        return self.__class__.global_count
c_1 = Counter(5)
c_2 = Counter()

c_1.inc()
c_1.inc()
c_1.inc()

c_2.inc()
c_2.inc()

print(f'{c_1.count = }')
print(f'{c_1.get_global_counter() = }')

print(f'{c_2.count = }')
print(f'{c_2.get_global_counter() = }')<jupyter_output>c_1.count = 8
c_1.get_global_counter() = 5
c_2.count = 2
c_2.get_global_counter() = 5<jupyter_text>Методы класса

Другим видом методов выступают методы класса. Объявить их можно
декорировав метод декоратором ```classmethod```. Метод класса также
принимает один обязательный аргумент. По стандарту его имя ```cls```,
не рекомендуется использовать другие имена. Этот аргумент обозначает,
что метод принимает объект класса. В этомзаключается главное отличие
метода класса от метода экземпляра. Метод класса связан с самим классом
посредствам первого аргумента и не может получить доступ к экземплярам.

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

Частым использованием таких методов является создание новых экземпляров
на оснвое разных данных, например, такие методы могут принимать данные
в разном формате (```json```, ```csv```, ```xml``` и др.) и создавать
нужные экземпляры.

Ниже привиден пример класса даты, где метод класса используется для
создание даты из строки.<jupyter_code>class Date:
    """Дата

    :ivar day: день
    :ivar month: месяц
    :ivar year: год
    """
    def __init__(self, day, month, year):
        self.day = day
        self.month = month
        self.year = year

    @classmethod
    def from_str(cls, date_str):
        day, month, year = map(int, date_str.split('.'))
        return cls(day, month, year)
date = Date(5, 10, 2020)

# можно вызвать у экземпляра
print(f'{date.from_str("9.11.2007") = }')

# и у класса
print(f'{Date.from_str("27.10.2019") = }')<jupyter_output>date.from_str("9.11.2007") = <__main__.Date object at 0x00000221AB291E80>
Date.from_str("27.10.2019") = <__main__.Date object at 0x00000221AB2911C0><jupyter_text>Статические методы

Еще один вид методов это статические методы. Объявить статический метод
можно с помощью декортатора ```staticmethod```. Суть статического метода
заключается в том, что он не принимает никаких обязательных аргументов.
Это значит, что получить доступ к экземпляру или классу через
статический метод нельзя. По сути это просто функция, объявленная внутри
класса. Зачастую использование таких методов заключается в реализации
поведения подходящего по смыслу этому классу, но которому не нужны
объекты класса и экземпляра. 

Давайте попробуем расширить функционал класса ```Date```. Неплохим
дополнением был бы функционал проверки строки на правильный формат даты.
Такой метод должен принимать строку и возвращать ```True``` или
```False``` в зависимости от того содержит или нет строка правильный
формат даты. Очевидно, что к конкретному экземпляру такой метод
привязывать незачем. С классом он связан только по смыслу. Добавим этот
метод как статический.<jupyter_code>class Date:
    def __init__(self, day, month, year):
        self.day = day
        self.month = month
        self.year = year
    
    @classmethod
    def from_str(cls, date_str):
        day, month, year = map(int, date_str.split('.'))
        return cls(day, month, year)
    
    @staticmethod
    def is_valid(date_str):
        day, month, year = map(int, date_str.split('.'))
        return 1 <= day <= 31 and 1 <= month <= 12 and 0 <= year <= 2038
date = Date(3, 11, 2008)

# можно вызвать у экземпляра
print(f'{date.is_valid("5.10.1969") = }')

# и у класса
print(f'{Date.is_valid("31.01.1956") = }')<jupyter_output>date.is_valid("5.10.1969") = True
Date.is_valid("31.01.1956") = True
{'fraction_non_alphanumeric': 0.09491602705372748, 'fraction_numerical': 0.01421080629227145, 'mean_word_length': 4.118630882924932, 'pattern_counts': {'":': 0, '<': 50, '<?xml version=': 0, '>': 43, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 1}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '11', 'dir': 'jupyter-structured-clean-dedup', 'id': '342787', 'license': 'MIT', 'n_tokens_mistral': 5318, 'n_tokens_neox': 6045, 'n_words': 1407, 'path': 'python_pd/06_classes/03_methods.ipynb', 'repo_name': 'Alexey-smurf/python-bp', 'size': '13159'}