ООП в Python

Серия - Объектно-ориентированное программирование

Список вопросов к Python собеседованию

Существуют два главных подхода к написанию программ:

  1. Процедурное программирование
  2. Объектно-ориентированное программирование (ООП)

Цель у этих подходов одна - сделать процесс программирования максимально эффективным. Но в ООП, в отличии от процедурного подхода, данные первичны, а код для обработки этих данных - вторичен.

  • В процедурном подходе основой программы является функция. Функции вызывают друг друга и при необходимости передают данные. В программе функции живут отдельно, данные — отдельно.
  • Основной недостаток процедурного подхода - сложность создания и поддержки больших программ. Наличие сотен функций в таких проектах очень часто приводит к ошибкам и спагетти-коду.
  • В основе объектно-ориентированного программирования лежит понятие объекта. Объект совмещает в себе и функции и данные.
  • Основное преимущество ООП перед процедурным программированием - изоляция кода на уровне классов, что позволяет писать более простой и лаконичный код.1

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

Класс

  • описывает множество объектов, имеющих общую структуру и обладающих одинаковым поведением
  • это шаблон кода, по которому создаются объекты. Т. е. сам по себе класс ничего не делает, но с его помощью можно создать объект и уже его использовать в работе
  • Классы в Python – это тоже объекты
  • Допустимо динамическое изменение и добавление атрибутов классов
  • Для скрытия внутренних данных используются синтаксические соглашения
  • Поддерживается наследование
  • Полиморфизм обеспечивается виртуальностью всех методов
  • Доступно метапрограммирование

Объект (экземпляр класса)

  • это конкретный представитель класса
  • Жизненным циклом объекта можно управлять
  • Многие операторы могут быть перезагружены
  • Многие методы встроенных объектов можно эмулировать
1
2
3
4
5
6
# класс с минимально-возможным функционалом
class A:
  pass

# объект класса
a = A()

Атрибут класса (объекта) - любой элемент (свойство, метод, подкласс), на который можно сослаться через символ точки (MyClass.<атрибут> или my_object.<атрибут>).

Атрибуты делятся на встроенные и пользовательские:

  • Встроенные (служебные) атрибуты - методы и свойства унаследованные от общего для всех классов в Python родительского класса object. Многие из этих атрибутов можно переопределить внутри своего класса.

  • Пользовательские атрибуты - поля и методы, которые описываются программистом в теле класса. Добавляются в общий список атрибутов наряду со встроенными.3

  • Поля класса - это характеристики объекта класса.

  • Методы класса - это функции, с помощью которых можно оперировать данными класса.

Любой метод является атрибутом, но не любой атрибут - методом.

Атрибуты-поля можно условно разделить на две группы:

  1. Статические - поля класса, которые объявляются внутри тела класса и создаются тогда, когда создается класс.
  2. Динамические - поля экземпляра. Для создания динамического поля необходимо обратиться к self внутри метода.
1
2
3
4
5
6
7
8
9
class Phone:
    # Статические атрибуты (поля)
    default_color = 'Grey'
    default_model = 'C385'

    def __init__(self, color, model):
        # Динамические атрибуты (поля)
        self.color = color
        self.model = model

Служебное слово self - это ссылка на текущий экземпляр класса. self не является зарезервированным. Является аналогом этого this (Java, C++).

Атрибуты (поля и методы), имена которых обрамляются __, Python трактует как специальные. Специальные атрибуты, как правило, идут первыми при объявлении класса.

Использование:

  • операторы перегрузки - если необходимо добавить возможность выполнения стандартных операций над классами
  • дополнить…

Основные встроенные методы и поля:

__new__(cls, ...) - Конструктор. Создает экземпляр класса. Сам класс передается в качестве аргумента. Редко переопределяется, чаще используется реализация от базового класса object

__init__(self, ...) - Инициализатор. Принимает свежесозданный объект класса из конструктора. Является очень удобным способом задать параметры объекта при его создании.

__del__(self) - Деструктор. Вызывается при удалении объекта сборщиком мусора

__str__(self) - Возвращает строковое представление объекта.

__repr__

__hash__(self) - Возвращает хэш-сумму объекта.

__doc__ - Тип: str. Документация класса.

__dict__ - Тип: dict. Словарь, в котором хранится пространство имен класса

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

В частности, это предполагает
  • предоставлять доступ к переменным напрямую, например, foo.x = 0, а не foo.set_x(0);
  • в случае необходимости проверки устанавливаемого значения использовать свойства, которые сохраняют единый синтаксис доступа, установка значения foo.x = 0 приводит к вызову foo.set_x(0).

Преимуществом данного подхода является возможность использование синтаксиса foo.x += 1, хотя на самом деле внутри происходит вызов foo.set_x(foo.get_x() + 1).

Свойства (Property) — это особый вид атрибутов имитирующий поле (но который при чтении вызывает какой-либо метод).

  • У них есть методы получения, установки и удаления, такие как __get__, __set__ и __delete__
  • Мы можем определить геттеры, сеттеры и деструкторы с помощью функции property()
  • Свойство может определяться при помощи декораторов: @property - определяет метод получения значения, @field.setter - определяет метод установки значения свойства field. Имя свойства field определяется в наименовании обоих методов и декораторе @field.setter
  • Если необходимо реализовать свойство «только для чтения», второй метод может быть опущен вместе с декоратором @field.setter
  • Примером применения свойства является получение информации, которая может потребовать затратного первоначального поиска и простого повторного
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
class Cash:
    def __init__(self, value):
        self.value = value

    @property
    def formatted(self):
        return '${:.2f}'.format(self.value)

    @formatted.setter
    def formatted(self, new):
        self.value = float(new[1:])

По сути, когда Python встречает следующий код:

1
2
spam = SomeObject()
print(spam.eggs)

он ищет eggs в spam, а затем проверяет eggs на наличие у него методов __get__, __set__ или __delete__ и если они есть, то это свойство. Если это свойство, то вместо того, чтобы просто вернуть объект eggs (как это было бы для любого другого атрибута), он вызовет метод __get__ и возвращает все, что возвращает этот метод.4

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

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

Обозначаются специальным декоратором @staticmethod.

  • ничего не знают о классе или об объекте, на котором они вызываются
  • не принимают специальных аргументов типа self или cls поэтому не используют сам объект или класс при выполнении
  • могут быть вызваны, как через сам класс, так и через его экземпляр

Методы класса принимают в качестве первого параметра cls (вместо self в обычных методах). cls - это ссылка на класс, на котором был вызван метод.

Обозначаются специальным декоратором @classmethod.

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

Методы класса часто используются, когда:

  • Необходимо создать специфичный объект текущего класса
  • Нужно реализовать фабричный паттерн (создаём объекты различных унаследованных классов прямо внутри метода)
  • Реализовать дополнительные методы инициализации

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

Python по умолчанию не поддерживает перегрузку методов, поскольку запоминает только самое последнее определение метода. Для их реализации необходимо подключать сторонние Python библиотеки, например, multimethods.py.

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

У одного объекта может быть несколько родительских классов, а также специальные методы вроде __getattribute__, которые перехватывают запросы к атрибутам.

Каким же образом интерпретатор разрешает сложные запросы к свойствам и методам? Рассмотрим последовательность поиска на примере запроса obj.field:

  1. Вызов obj.__getattribute__('field'), если он определен. При установке или удалении атрибута проверяется соответственно наличие __setattr__ или __delattr__.
  2. Поиск в obj.__dict__ (пользовательские атрибуты).
  3. Поиск в object.__class__.__slots__.
  4. Рекурсивный поиск в поле __dict__ всех родительских классов. Если класс имеет несколько предков, порядок проверки соответствует порядку их перечисления в определении.
  5. Если определен метод __getattr__, то происходит вызов obj.__getattr__('field')
  6. Выбрасывается исключение несуществующего атрибута – AttributeError.

Наконец, когда атрибут нашелся, проверяется наличие метода __get__ (при установке – __set__, при удалении – __delete__).

Все эти проверки совершаются только для пользовательских атрибутов.5

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

На текущий момент ООП является самой востребованной и распространенной парадигмой программирования. Концепция ООП строится на основе 4 принципов: абстракция, инкапсуляция, наследование и полиморфизм.

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

Абстракция позволяет представить сложную концепцию в более простой форме:

  • Выделить главные и наиболее значимые свойства предмета.
  • Отбросить второстепенные характеристики.

Абстракция не поддерживается в Python напрямую.

Инкапсуляция - принцип ООП, согласно которому сложность реализации программного компонента должна быть спрятана за его интерфейсом.

Инкапсуляция не дает взглянуть на внутреннюю реализацию сложной концепции:

  • Отсутствует доступ к внутреннему устройству программного компонента.
  • Взаимодействие компонента с внешним миром осуществляется посредством интерфейса, который включает публичные методы и поля.

В ряде языков, например, С++, существует четкое разделение членов класса на закрытые (private), защищенные (protected) и публичные (public).

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

Концепция отсутствия закрытых атрибутов в Python описывается фразой одного из разработчиков языка: «Мы все взрослые люди. Если программист хочет выстрелить себе в ногу - нужно предоставить ему возможность это сделать».

В Python принята следующая договоренность:

Protected (Non-Public) - обозначается при помощи одинарного нижнего подчеркивания _. Данный синтаксис указывает на то, что атрибут:

  • используется для внутренней реализации класса и не предназначен для использования извне;
  • должен быть использован/изменен только если разработчик-пользователь класса абсолютно уверен в этом.
  • При этом атрибут с _ доступен извне, как и обычный public-атрибут класса.

Private - обозначается при помощи двойного нижнего подчеркивания __. Данный синтаксис указывает на то, что атрибут:

  • используется для внутренней реализации класса и не предназначен для использования извне;
  • не должен быть использован/изменен разработчиком-пользователем класса.
  • При этом атрибут с __ оказывается недоступным извне, используя технику сокрытия имен (Name Mangling). Несмотря на это, в отличие от ряда языков (например, Java) такие «закрытые» члены класса также можно изменять, но более сложным способом - их можно увидеть, используя функцию dir().

We don’t use the term “private” here, since no attribute is really private in Python (without a generally unnecessary amount of work).6

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class SomeClass:
  def __init__(self, public_var: str, protected_var: str, private_var: str):
    self.public_var = public_var    # public
    self._protected_var = protected_var   # protected
    self.__private_var = private_var  # private

  def _private(self):   # Это внутренний метод объекта
      print("Private method")

obj = SomeClass("I'm Public", "I'm Protected", "I'm Private")
obj.public_var  # >>I'm Public
obj._protected_var  # >>I'm Protected
obj.__private_var  # AttributeError: 'SomeClass' object has no attribute '__private_var'
obj._SomeClass__private_var  # >>I'm Private

obj._private()  # >>Private method

Кроме прямого доступа к атрибутам (obj.attrName), могут быть использованы специальные методы доступа: геттеры, сеттеры и деструкторы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class SomeClass:
  def __init__(self, value):
    self._value = value

  def getvalue(self): # получение значения атрибута - геттер
    return self._value

  def setvalue(self, value): # установка значения атрибута - сеттер
    self._value = value

  def delvalue(self): # удаление атрибута - деструктор
    del self._value

  value = property(getvalue, setvalue, delvalue, "Свойство value")

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

Вместо того чтобы вручную создавать геттеры и сеттеры для каждого атрибута, можно перегрузить встроенные методы __getattr__, __setattr__ и __delattr__. Например, так можно перехватить обращение к свойствам и методам, которых в объекте не существует:

1
2
3
4
5
6
7
8
9
class SomeClass():
  attr1 = 42

  def __getattr__(self, attr):
    return attr.upper()

obj = SomeClass()
obj.attr1  # 42
obj.attr2  # ATTR2

__getattribute__ перехватывает все обращения (в том числе и к существующим атрибутам).

Для чего нужна инкапсуляция?

  1. Инкапсуляция упрощает процесс разработки, т. к. позволяет нам не вникать в тонкости реализации того или иного объекта.
  2. Повышается надежность программ за счет того, что при внесении изменений в один из компонентов, остальные части программы остаются неизменными.
  3. Становится более легким обмен компонентами между программами.

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

  • Класс потомок может переопределять родительские методы.
  • При этом, обычно, дочерний класс дополняет родительский метод, добавив свой код после кода родителя (используя функцию super(), предоставляющую ссылку на родительский класс).
  • Каждый класс также может получить информацию о своих «родителях» через метод __bases__() или isinstance().
  • Наследование описывается словом «является» (легковой автомобиль является автомобилем).
  • Существуют и другой вид взаимосвязи (модель включения/делегации), когда один класс включает в себя другой класс в качестве одного из полей - ассоциация, композиция и агрегация. Ассоциация описывается словом «имеет» (автомобиль имеет двигатель).
1
2
3
4
5
6
7
8
class Parent:
  def __init__(self, var1):
    self.var1 = var1

class Child(Parent):
  def __init__(self, var1, var2):
    super().__init__(self, var1)
    self.var2 = var2

Метод super() дает возможность наследнику обратиться к родительскому классу.

Для чего нужно наследование?

  1. Принцип DRY (повторное использование кода);
  2. Классы-потомки берут общий функционал у родительского класса.
  3. Ускорение разработки нового ПО на основе переиспользования существующих открытых классов.
  4. Наследование упрощает процесс написания кода.

Python реализует как стандартное одиночное наследование так и множественное.

Используя множественное наследования можно создавать классы-миксины (примеси), представляющие собой определенную особенность поведения.

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

Термин «полиморфизм» происходит из греческого языка и означает «нечто, что принимает несколько форм».

Полиморфизм - это поддержка нескольких реализаций на основе общего интерфейса.

Абстрактный метод (виртуальный метод) - это метод класса, реализация для которого отсутствует.

Полиморфизм в компилируемых языках
В компилируемых языках программирования полиморфизм достигается за счет создания виртуальных методов, которые в отличие от невиртуальных можно перегрузить в классе-потомке.

Все методы в языке изначально виртуальные. Это значит, что дочерние классы могут их переопределять и решать одну и ту же задачу разными путями. Название метода остается прежним, а реализация изменяется и будет выбрана только во время исполнения программы. Такие классы называют полиморфными.

Другая формулировка
Полиморфизм позволяет одинаково обращаться с объектами, имеющими однотипный интерфейс, независимо от внутренней реализации объекта.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Bird:
  def move(self):
    print('Летает')

class Penguin(Bird):
  def move(self):
    print('Ходит')

crow = Bird()
crow.move()  # Летает
emperor_penguin = Penguin()
emperor_penguin.move()  # Ходит

Можно получить и доступ к методам класса-предка либо по прямому обращению, либо с помощью функции super():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class Parent:
  def __init__(self):
    print('Parent init')

  def method(self):
    print('Parent method')

class Child(Parent):
  def __init__(self):
    Parent.__init__(self)  # Прямое обращение

  def method(self):
    super(Child, self).method()  # Через метод super()

child = Child() # Parent init
child.method() # Parent method

Одинаковый интерфейс с разной реализацией могут иметь и классы, которые не связаны отношениями Родитель-Потомок. Это возможно благодаря утиной типизации.

Метаклассы – это классы, инстансы которых тоже являются классами.8