Классы

И снова обратимся к Лутцу, он явно скажет лучше чем я: Классы – это основные инструменты объектно-ориентированного программирования (ООП) в языке Python, поэтому в этой статье мы попутно рассмотрим основы ООП. ООП предлагает другой, часто более эффективный подход к программированию, при котором мы разлагаем программный код на составляющие, чтобы уменьшить его избыточность, и пишем новые программы, адаптируя имеющийся программный код, а не изменяя его.

Давайте еще определение ООП из Википедии:

Объе́ктно-ориенти́рованное программи́рование (ООП) — методология программирования, основанная на представлении программы в виде совокупности объектов, каждый из которых является экземпляром определённого класса, а классы образуют иерархию наследования.

  • Объекты - данные + медоты(функции). Для создания объектов используются классы, все объекты являются экземпляром какого-то класса.
  • Класс - объект, создающий новые объекты(экземпляры этого класса), объекты будут наследовать методы класса, которым они были порождены.

Некоторые утверждения

  • В python все является объектами. А значит и классы тоже являются объектами, поэтому нам важно разделять объект класса(сам класс) и экземпляры класса(объекты порожденные этим классом)

Класс как пространство имен

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

Создание классов

Для создание класса в python используется инструкция class. Она сильно похожа на объявление функций(def) и так же как и функция является исполняющейся инструкцией, помимо этого так же как и def, class создает объект, только в этот раз не объект функции, а объект класса.

# 👇 Название класса
# | 👇 Опциональное перечисление суперклассов(родительский классов)
class <Name>([<Superclass1>], [<Superclass2>]):
<name declarations> # 👈 Различные объявления имен

❗️ Важно

Среди разработчиков есть договоренность, называть классы с заглавной буквы, это помогает проще отличать их от обычных функций.

Мы с вами можем создать класс без методов и аттрибутов:

class Foo:
pass

Представленный пример эквивалентен следующей записи:

class Foo(object):
pass

Это значит, что любой класс является наследником класса object и мы можем в этом убедиться используя глобальную функцию isinstance:

num = 5
string = "some string"
isinstance(num, object) # True
isinstance(string, object) # True

Создание экземпляров

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

foo = Foo()

Функция-конструктор __init__

Помните мы говорили, что классы создают объекты? Но как они это делают? Как было сказанно до этого, экземпляр наследует пространство имен своего класса + если в классе имеется метод __init__, эта функция будет вызываться для коструирования экземпляра класса. Такой метод называется конструктором. Функция-конструктор __init__ будет первым аргументом получать экземпляр класса, это аргумент можно называть как угодно, но принято называть его self.

class Person():
all_presons = []
# 👇 self - ссылка на экземпляр класса
def __init__(self, name: str):
self.name = name
Person.all_presons.append(self)
def say_hello(self):
return print(f"Hello! I am {self.name}")
a1 = Person("John")
a2 = Person("Bob")

Как можно заметить из примера, функция __init__ получает не только self, но и name. Это значит, что вызывать ее мы можем с дополнительными аргументами, но как они передаются? Верно! Как видно из примера, аргументы передаются при вызове класса, если класс вызвать с аргументами, они будут переданы в __init__.

Обратите внимание

Функция __init__ не является какой-то "особенной", ее отличает только необычное название и первый позиционный аргумент, который автоматически будет подставлен. Поэтому все что вы знаете про функции применимо и к ней.

Поиск аттрибутов

Поиск аттрибутов в экземплярах/классах. Когда вы обращаетесь к аттрибуту, поиск значения этого аттрибута проводится по:

  1. Собственным аттрибутам экземпляра
  2. Аттрибутам класса
  3. Аттрибутам родительских классов

Мы хоть еще и не говорили про наследование, но давайте посмотрим следующий пример, в котором класс Dog является подклассом Animal и рассмотрим как будет происходить поиск аттрибутов.

class Animal:
live = True
class Dog(Animal):
mammal = True
def __init__(self, nickname):
self.nickname = nickname
chucky = Dog("chucky")
chucky.nickname # найдется в аттрибутах экземпляра
chucky.mammal # найдется в аттрибутах класса
chucky.live # найдется в аттрибутах суперкласса

Вроде бы понятно, но давайте я представлю парочку примеров на засыпку?

Пример 1

class Dog:
mammal = True
def __init__(self, nickname):
self.nickname = nickname
chucky = Dog("chucky")
Dog.mammal = False
print(chucky.mammal) # Что выведется в консоль?

Пример 2

class Foo:
x = 1
def __init__(self):
self.y = 2
def set_x(self, new_value):
self.x = new_value
foo1 = Foo()
print(foo1.x) # Что выведется здесь?
foo1.set_x(5)
print(foo1.x) # Что выведется теперь?
foo2 = Foo()
print(foo2.x) # Что выведется сейчас?

Присваивание аттрибутов

Для того чтобы присвоить значение в аттрибут объекта используется следующий синтаксис:

obj.attr = "value"

Либо существует 2 глобальные функции setattr(obj, name, value) и getattr(obj, name, default = None).

setattr(obj, "name", "value")
getattr(obj, "name") # 'value'

Где хранятся аттрибуты объектов?

Почти у каждого объекта в python, за некоторыми исключениями к объекту привязан словарь содержащий его аттрибуты. Посмотреть на это словарь мы можем с помощью функции vars() которая возвращает магический аттрибут __dict__ объекта, в котором как раз и хранятся все аттрибуты объекта.

❗️ Внимание

Обращаю внимание, на то, что мы говорим про собственные аттрибуты объекта, и не касаемся операции поиска аттрибутов в дереве наследования.

Приватные методы

Приватные методы - те методы объекта, которые разработчик решил не предоставлять для использования другим людям, кто пользуется его классом. Есть некоторое соглашение между разработчиками что:

  • Методы начинающиеся на букву(some_method) - доступны для использования.
  • Методы начинающиеся на одно подчеркивания(_some_private_method) пользовать самостоятельно не стоит.

Почему это могло ему понадобиться? Представьте что вы делаете класс которым надо пользоваться определенным способом... например класс отвечающий за отправку SMS.

class SmsSender():
def __init__(self, phone_number, text):
self.sended = False
self.phone_number = pnohe_number
self.text = text
self.getway = "DEFAULT_GETWAY"
def send_sms(self):
if not self._validate():
raise SmsSenderException("Отправка невозможна, данные некорректны")
self._chose_getway()
self._send_sms()
def _validate(self):
""" Метод проверяет корректно ли указаны данные, точно ли номер корректен и сообщение проходит по каким-то критериям """
def _chose_getway(self):
""" Метод определяет через какой шлюз надо будет отправлять SMS """
def _send_sms(self):
""" Meтод отправляет sms используя уже какой-то определенный шлюз """
sender = SmsSender("+996700000000", "Hello!")
sender.send_sms()

Вообще к представленному коду можно прикопаться...

  • Валидатор можно было бы выделить в отдельный класс
  • Можно было бы так же выделить класс для SmsGetaway, который знает как отправлять смс'ки через определенный шлюз
  • Вообще зачем делать проверки и определять подходящий шлюз только в методе send_sms, а не в __init__
  • А под каждое сообщение надо инициализировать какой-то экземпляр класса SmsSender? Вопросов много, но код я показываю только как пример того зачем нам нужны приватные методы.

Вы можете заметить, что нам предоставлен только конструктор и метод send_sms, который производит обязательную работу перед отправкой(валидирует параметры и выбирает шлюз), будет крайне нежелательно если кто-то другой воспользуется этим классом и сразу вызовет метод _send_sms мимо валидации.

Методы с двумя нижними подчеркиваниями

TODO: Описать зачем это надо

class Foo():
def __init__(self):
self.__attr = "value"
def get_attr(self):
return self.__attr
foo = Foo()
print(foo.get_attr()) # 'value'
print(foo.__attr) # AttributeError
print(foo._Foo__attr) # 'value'

Магические методы

Это специальные методы, с помощью которых вы можете добавить в ваши классы «магию». Они всегда обрамлены двумя нижними подчеркиваниями (например, init или lt). Ещё, они не так хорошо документированны, как хотелось бы.

  • __init__() -> None - функция-конструктор
  • __del__() -> None - функция-деконструктор
  • __new__() ->
  • __str__() -> str - переопределяет текстовое представление объекта
  • __repr__() -> str - очень похоже на str, но используется для различной отладочной информаци и должно возвращать представление в виде python кода

Переопределение операторов

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

Переопределение обращения к аттрибутам

  • __getattr__(self, name) - переопределяет обращение к неизвестному аттрибуту
  • __setattr__(self, name, value) - переопределяет поведение для присвоения значения аттрибуту
  • __delattr__(self, name) - переопределяет поведение для удаления значения по аттрибуту
  • __getattribute__(self, name) - переопределяет оператор обращения ко всем аттрибутам

Переопределение поведения обращения по ключам

  • __getitem__(self, name) - переопределение оператора обращения по ключу
  • __setitem__(self, name, value) - переопределение оператора добавления значения по ключу
  • __delitem__(self, name) - переопределение оператора удаления значения по ключу

Декоратор @property (setters/getters)

Мы еще не говорили с вами про декораторы, но достаточно будет знать только то, что декораторы - это функции высшего порядка, получающие оригинальную функцию и возвращающие функцию обертки(не всегда, но чаще всего так).

Мы можем сделать так, что обращение к аттрибуту объекта будет вызывать метод, который называют getter, а установка будет вызывать setter. Для наглядности я напишу пример:

import re
RE_FULLNAME = re.compile(r"^(?P<first_name>\w+)\s+(?P<last_name>\w+)$")
class Person:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
@property
def fullname(self):
return f"{self.first_name} {self.last_name}"
@fullname.setter
def fullname(self, value):
match = RE_FULLNAME.match(value)
if match:
self.first_name = match.group("first_name")
self.last_name = match.group("last_name")
p = Person("John", "Doe")
print(p.fullname)
p.fullname = "John Dog"
print(p.fullname)

Наследование классов

Думаю, что сейчас увас должна сложиться картина про наследование, но мы с вами должны детально посмотреть на:

  • На то как находится значение аттрибута в дереве наследования
  • Что будет в self если метод был найден в родительском классе
  • Рассмотреть метод mro() у классов
  • Разобраться с функцией super()

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

Method resolution order

У каждого класса есть специальный метод mro(), который нам расскажет в какой последовательности будет происходить поиск аттрибутов.

class Base:
pass
class Animal(Base):
pass
print(Animal.mro()) # (Animal, Base, object)

Как видно из примера поиск сначала будет производиться в пространстве имен класса Animal, потом в Base, а уже в конце object.

Пример с множественным наследованием:

class Base:
pass
class Animal(Base):
pass
class Patroler(Base):
pass
class Dog(Patroler, Animal):
pass
print(Dog.mro()) # (Dog, Patroler, Animal, Base, object)

Переопределение методов

Давайте рассмотрим эту тему ссылаясь на следующий код:

class Superclass():
def method1():
print("I am method1 in Superclass")
class Subclass(Superclass):
pass
instance = Subclass()

Помните, что дочерние классы просто наследуют пространство имен своих родителей? А поиск аттрибутов происходит вверх по дереву наследования, что будет если мы в дочернем классе(Subclass) определим аттрибут с тем же именем что и у родительского(Superclass)? Верно! При поиске аттрибутов мы получим уже не значение аттрибута из Superclass а из Subclass потому что он находится раньше в mro.

Подобное поведение называется "переопределением метода". Давайте в коде попробуем это сделать

class Superclass():
def method1():
print("I am method1 in Superclass")
class Subclass(Superclass):
def method1():
print("I am method1 in Subclass")
instance = Subclass()
instance.method1() # 'I am method1 in Superclass'

super()

Мы можем не только полностью переопределять методы ну и расширять методы родительских классов. Для этого мы воспользуемся функцией super()

  • super() - предоставляет нам доступ к следующему классу в дереве наследования и если мы обращаемся к методу родительского класса подставляет в него значение self.