10.1. Теория

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

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

10.1.1. Введение ООП

10.1.1.1. Проблемы процедурного подхода

Написание программы в процедурном стиле в определенный момент приводит к ряду трудноразрешимых проблем.

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

Одним из простых решений является выбор какой-либо структуры данных, например, списка:

circle = [-2, 3, 10]  # 'x', 'y' и 'r'

Данное решение имеет 3 недостатка:

  1. Неочевидность назначения каждого элемента списка.

    Разработчик может подразумевать [x, у, r] или [r, x, y], кроме того, для обращения к параметру окружности необходимо точно знать его индекс.

    «Сгладить» данную проблему могут помочь именованые константы или использование словаря (Листинг 10.1.1 (а) и (б) соответственно).

    Листинг 10.1.1 (а) - Использование именованых констант для доступа к свойствам окружности | скачать
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    import math
    
    C_X = 0
    C_Y = 1
    C_R = 2
    
    
    def get_s(circle):
        """Вернуть площадь окружности 'circle'."""
        return math.pi**2 * circle[C_R]
    
    circle = [-2, 3, 10]
    
    print("{:.2f}".format(get_s(circle)))  # 98.70
    
    Листинг 10.1.1 (б) - Словарь как структура данных для окружности | скачать
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    import math
    
    
    def get_s(circle):
        """Вернуть площадь окружности 'circle'."""
        return math.pi**2 * circle['r']
    
    circle = dict(x=36, y=77, r=8)
    
    print("{:.2f}".format(get_s(circle)))  # 78.96
    
  2. Отсутствие контроля за значениями.

    Даже ликвидация первого недостатка не помешает выполнить, например,

    circle['r'] = -5
    

    чего не должно быть в принципе.

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

  3. Малая эффективность использования существующего кода.

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

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

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

  • логически «упаковать» данные, предоставив удобный интерфейс доступа (не привязанный к конкретной структуре данных);
  • ограничить круг данных и операций, которые могли бы быть изменены;
  • повторно использовать написанный код при необходимости.

Это может быть достигнуто путем создания собственного объекта (типа данных) Окружность, используя объектно-ориентированный стиль программирования.

10.1.1.2. Основные понятия и терминология

10.1.1.2.1. Объект и черный ящик

Объект - любой материальный предмет, который можно встретить в повседневной жизни: дом, телефон, машина, книга и т.д.

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

_images/10_01_01.png

Рисунок 10.1.1 - Модель черного ящика

Черный ящик рассматривается как система, принимающая некую «входную информацию» (данные) и возвращающая «выходную информацию» (результаты работы), при этом происходящие в ходе работы системы процессы наблюдателю неизвестны.

В качестве примера такой системы можно рассмотреть планшетный компьютер. Не являясь специалистом, ответить на вопросы «что находится внутри?» или «как оно работает?» практически невозможно. При этом известно, что нажатие на определенные участки экрана («входная информация») позволит запустить приложение, установить будильник на завтрашнее утро и т.д. («выходная информация»). Более того, можно приобрести новый планшет, и, несмотря на то, что его «начинка» может существенно отличаться, скорее всего проблем с его использованием не возникнет, т.к. аналогичные функции являются стандартными для любых планшетов. Так концепция «черного ящика» разделяет понятия «что объект делает?» и «как он это делает?», предоставляя необходимый уровень детализации для изучения и использования объекта.

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

Плюсы данного подхода заключаются в том, что декомпозиция позволяет:

  • упростить архитектуру приложения (фактически, сделав ее абстракцией реального мира);
  • облегчить командную разработку (каждый разработчик занят работой над отдельным объектом кода и при взаимодействии с другими объектами ему нужно знать лишь «что объект делает», без необходимости узнавать «как он это делает»).

Объектно-ориентированное программирование (ООП) - парадигма программирования, предусматривающая написание программ в рамках объектно-ориентированного подхода.

10.1.1.2.2. Класс и объект, поля и методы

В ООП центральными являются понятия класса и объекта:

  1. Класс (англ. Class): абстракция реального мира (обобщенный шаблон), специальный тип данных; класс описывает свойства и методы, которые могут быть доступны у подобных объектов;
  2. Объект (англ. Object) (экземпляр класса, англ. Class Instance): частный случай класса.

Пример разницы между классом и объектом приведена на Рисунке 10.1.2.

_images/10_01_02.png

Рисунок 10.1.2 - Соотношение класса (Car) и конкретных объектов [6]

Каждый класс содержит и описывает поля и методы (Рисунок 10.1.3):

  1. Поле (англ. Data Member / Variable / Field): переменная, привязанная к классу;
  2. Метод (англ. Method): действие (функция), которую можно проводить над классом.
_images/10_01_03.png

Рисунок 10.1.3 - Примеры классов с данными и методами [7]

Набор полей и методов определяет интерфейс класса - способ взаимодействия с классом произвольного кода программы. Доступ к полям и методам осуществляется через указание объекта, например, в Python, используя точку:

"Я - экземпляр класса str".count(" ")  # 4

Типичный сценарий написания объектно-ориентированной программы:

  1. Создание одного или нескольких классов (или поиск подходящих существующих).
  2. Создание произвольного количества экземпляров классов (инстанцирование) - объектов.
  3. Изменение полей и вызов методов созданных объектов.

Примечание

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

Как поля так и методы могут иметь разный уровень доступа - область видимости (англ. Scope) (Таблица 10.1.1).

Таблица 10.1.1 - Область видимости полей и методов
Уровень доступа (тип) Доступность
Внутри самого класса Внутри классов-наследников Извне (любой код)
Закрытый / приватный (англ. Private) Да Нет Нет
Защищенный (англ. Protected) Да Да Нет
Открытый / публичный (англ. Public) Да Да Да

Примечание

Хорошо спроектированный класс грамотно определяет уровни доступа к своим полям и методам.

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

Примечание

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

10.1.1.3. Принципы ООП

Объектно-ориентированная парадигма программирования включает 3 основных принципа (свойства):

  1. Инкапсуляция (англ. Encapsulation)

    Как язык скрывает детали внутренней реализации объектов и предохраняет целостность данных?

  2. Наследование (англ. Inheritance)

    Как язык стимулирует многократное использование кода?

  3. Полиморфизм (англ. Polymorphism)

    Как язык позволяет трактовать связанные объекты сходным образом?

10.1.1.3.1. Инкапсуляция

За счет принципа инкапсуляции язык может скрывать некоторые детали реализации от пользователя объекта (Листинг 10.1.2).

Листинг 10.1.2 - Принцип инкапсуляции на примере класса DatabaseReader()
# Этот класс инкапсулирует детали открытия и закрытия базы данных
db_reader = DatabaseReader()
db_reader.open("С:/Balance.sqlite")

# Сделать что-то с файлом данных...
avg_sales = db_reader.get_avg(table="Сотрудник", field="КоличествоПродаж")
# ...

# Закрыть файл
db_reader.close()

Класс DatabaseReader инкапсулирует внутренние детали нахождения, загрузки, манипуляций и закрытия файла данных. Нет необходимости беспокоиться о многочисленных строках кода, которые работают «за кулисами», чтобы использовать класс в своем приложении. Все, что потребуется — это создать экземпляр класса и отправить ему соответствующие сообщения (например, “открыть файл по имени Balance.sqlite, расположенный на диске С:\”).

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

10.1.1.3.2. Наследование

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

Например:

  • диван похож на кресло, но позволяет уместить больше 1 человека;
  • оптический привод в компьютере выполняет запись информации на определенный носитель, как и кассетный проигрыватель;
  • каждый автомобиль имеет свои «внутренности», однако предоставляет общий интерфейс (руль, педали, определенная коробка передач), и нет необходимости изучать каждый автомобиль отдельно для того, чтобы им пользоваться.

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

На Рисунке 10.1.4. приведен пример иерархии наследуемых классов.

_images/10_01_04.png

Рисунок 10.1.4 - Пример иерархии наследуемых классов [8]

Прочесть диаграмму можно следующим образом:

  • классы Прямоугольник (Rectangle) и Окружность (Circle) являются Фигурой (Shape) (дочерними классами);
  • фигура (Shape) имеет следующие характеристики (и ими обладают все дочерние классы!): поля - цвет, толщина линии и методы - отрисовка(), очистка();
  • дочерние классы имеют дополнительные характеристики (расширяя унаследованный класс Фигура): например, Rectangle - координаты верхнего левого и нижнего правого углов, а Circle - радиус.

При наличии классов, связанных этой формой наследования, между типами устанавливается отношение “является”.

10.1.1.3.3. Полиморфизм

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

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

На Рисунке 10.1.5 приведена иерархия классов с общим методом draw().

_images/10_01_05.png

Рисунок 10.1.5 - Иерархия классов с общим методом draw() [8]

В классе Shape определен метод draw(), отвечающий за отрисовку фигуры. Учитывая, что каждая фигура должна рисовать себя уникальным образом, подклассы (такие как Rectangle и Circle) должны реализовать этот метод по своему усмотрению так, что вызов draw() на объекте Circle приведет к рисованию круга, а вызов draw() на объекте Rectangle - к рисованию прямоугольника.

Объект Controller содержит список фигур ShapesList, в котором могут содержаться как прямоугольники, так и окружности. Реализовать метод отрисовки всех фигур drawAllShapes() можно 2 способами: в процедурном стиле (Листинг 10.1.3 (а)) и объектно-ориентированном (Листинг 10.1.3 (б)).

Листинг 10.1.3 (а) - Возможный код drawAllShapes() (процедурный подход)
# Предположим, что в процедурном подходе информация о фигуре
# хранится в словаре с соответствующими ключами и указанием типа, например,
# {'x1': 5, 'y1': 0, 'x2': 3, 'y2': 6, 'type': "Прямоугольник"} для прямоугольника

def drawAllShapes():
    for shape in shapeList:
       if shape["type"] == "Прямоугольник":
           draw_rectangle(shape["x1"], shape["y1"], shape["x2"], shape["y2"])
       elif shape["type"] == "Окружность":
           draw_circle(shape["x"], shape["y"], shape["r"])

       # И так для каждой новой фигуры
       # А еще может быть много операций: масштабирование, пермещение...
Листинг 10.1.3 (б) - Возможный код drawAllShapes() (объектно-ориентированный подход)
def drawAllShapes():
    for shape in shapeList:
        # shape сам решает как себя отрисовать, в зависимости от
        # своего класса и внутренней реализации
        shape.draw()
        # Появление новой фигуры не поменяет (!) этот код

10.1.2. Поддержка ООП в Python

Python - полностью объектно-ориентированный язык, где любое значение является объектом, т.е. экземпляром конкретного класса. Например, число 5 или строка "python" являются объектами, экземплярами классов int и str соответственно.

Python позволяет не только использовать имеющиеся классы, но и создавать собственные, которые могут использоваться как любые встроенные типы данных. Поддержка классов в Python реализована на основе синтаксиса и семантики языков программирования C++ и Modula-3 (один из потомков языка Паскаль).

Большинство классов содержат в себе (инкапсулируют) не только поля (данные), но и методы. В Python традиционно поля и методы вместе называются атрибутами (членами) класса. Доступ к члену класса можно получить через '.'. Например, класс str хранит строки символов Юникода в виде данных и поддерживает методы, такие как str.count().

Все атрибуты класса являются общедоступными (в терминологии C++, публичными), а все методы виртуальными (переопределяемыми).

10.1.2.1. Определение элементарного класса

Python позволяет создавать собственные классы, обладающие произвольной функциональностью.

Примечание

PEP8.

  • для имен классов используйте регистр ВерблюжийРегистр (англ. CamelCase): MyPoint, UserBankAccount;
  • используйте строки документации.

10.1.2.2. Создание и использование класса

Создание класса начинается с ключевого слова class и указания имени класса. Пример определения в Python простого класса и его использования приведен в Листинге 10.1.4.

Листинг 10.1.4 - Простой пример класса в Python | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Point2D:
    """Точка на плоскости."""
    pass


if __name__ == "__main__":

    # Создание объекта (экземпляра класса)
    p = Point2D()

    print(p)        # <__main__.Point2D object at 0x0000000001E43898>
    print(type(p))  # <class '__main__.Point2D'>

10.1.2.3. Инициализация класса

При создании экземпляра класса, как правило, требуется проводить его инициализацию (например, устанавливать начальные значения полей), для чего в Python предназначен специальный метод __init__ (Листинг 10.1.5). В ряде языков программирования близкий по смыслу к __init__ метод называется «конструктором» (англ. Constructor).

Листинг 10.1.5 - Инициализация класса | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Point2D:
    """Точка на плоскости."""

    # Инициализирующий метод (специальный метод с __)
    def __init__(self, x, y):
        self.x = x  # Поля читаются и записываются через 'self'
        self.y = y  # 'self' указывает на текущий экземпляр класса

    # Обычный метод объекта (метод экземпляра класса) имеет те же правила,
    # наименования что и обычные функции
    def distance(self):
        """Вернуть расстояние до центра координат."""
        return (self.x**2 + self.y**2)**0.5


if __name__ == "__main__":

    # Создание объекта (экземпляра класса)
    # Передаем параметры, которые теперь требует '__init__()'
    # Параметр 'self' не передается явно, но содержит ссылку на 'p'
    p = Point2D(3, 4)

    # При выводе объекта на экран по умолчанию отображается имя класса
    print(p)

    # После инициализации доступны атрибуты 'p.x' и 'p.y',
    # где хранятся переданные при создании объекта значения
    print(p.x, p.y)  # 3 4

    # Вызов обычного метода
    print("Расстояние до центра координат: {:.2f}".format(p.distance()))  # 5

В Листинге 10.1.5 объявлены:

  • 2 поля x и y;
  • метод инициализации __init__();
  • метод экземпляра класса distance().

Первым параметром метода идет параметр self, в котором содержится ссылка на экземпляр, который вызвал данный метод.

10.1.2.4. Строковое представление класса

Еще одним часто используемым специальным методом является специальный метод __str__, возвращающий строковое представление класса (Листинг 10.1.6).

Листинг 10.1.6 - Метод __str__ | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Point2D:
    """Точка на плоскости."""

    # Инициализирующий метод
    def __init__(self, x, y):
        self.x = x
        self.y = y

    # Строковое представление класса
    def __str__(self):
        """Вернуть строку в виде 'Точка 2D (x, y)'."""
        return "Точка 2D ({}, {})".format(self.x, self.y)

    def distance(self):
        """Вернуть расстояние до центра координат."""
        return (self.x**2 + self.y**2)**0.5


if __name__ == "__main__":
    p = Point2D(3, 4)
    print(p)  # Точка 2D (3, 4)

Если метод __str__ не реализован, осуществляется вывод строкового представления класса по умолчанию (содержит имя класса, см. Листинг 10.1.4).

10.1.2.5. Специальные методы

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

10.1.2.5.1. Определение операторов

Специальные методы также могут быть использованы, если необходимо добавить возможность выполнения стандартных операций над классами (например, сложить 2 класса-точки или обратить ее координаты), т.е. определить смысл операторов + и т.д. для создаваемого класса.

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

Листинг 10.1.7 содержит пример определения операторов для сложения/разности двух объектов класса Point2D, а также инверсии их координат и равенства/неравенства.

Листинг 10.1.7 - Переопределение операций для класса | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Point2D:
    """Точка на плоскости."""

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        """Вернуть строку в виде 'Точка 2D (x, y)'."""
        return "Точка 2D ({}, {})".format(self.x, self.y)

    def __add__(self, other):
        """Создать новый объект как сумму координат 'self' и 'other'."""

        # Вместо Point2D можно использовать self.__class__,
        # что позволит не привязываться к имени класса и быть
        # валидным для потомков
        return Point2D(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        """Создать новый объект как разность координат 'self' и 'other'."""
        return Point2D(self.x - other.x, self.y - other.y)

    def __neg__(self):
        """Вернуть новый объект, инвертировав координаты."""
        return Point2D(-self.x, -self.y)

    def __eq__(self, other):
        """Вернуть ответ, являются ли точки одинаковыми."""
        return self.x == other.x and self.y == other.y

    def __ne__(self, other):
        """Вернуть ответ, являются ли точки разными.

        Используем реализованную операцию ==."""
        return not (self == other)

    def distance(self):
        """Вернуть расстояние до центра координат."""
        return (self.x**2 + self.y**2)**0.5

if __name__ == "__main__":

    p1 = Point2D(0, 5)
    p2 = Point2D(-5, 10)

    # Если Python не найдет в определении класса метода '__add__()',
    # строка ниже сгенерирует исключение:
    # 'TypeError: unsupported operand type(s) for +: 'Point2D' and 'Point2D''
    print(p1 + p2)             # Точка 2D (-5, 15)
    print(p1 - p2)             # Точка 2D (5, -5)
    print(-p2)                 # Точка 2D (5, -10)
    print(p1 == p2, p1 != p2)  # False True
    print("Расстояние до центра координат (p1): {:.2f}".format(p1.distance()))  # 5.00
    print("Расстояние до центра координат (p2): {:.2f}".format(p2.distance()))  # 11.18

В Таблице 10.1.2 приведены некоторые операторы и соответствующие им специальные функции (полный список доступен в справке [9]).

Таблица 10.1.2 - Некоторые операторы и соответствующие им специальные функции
Операция Синтаксис Функция
  Арифметические
Отрицание -a __neg__(a)
“Не” отрицание +a __pos__(a)
Сложение a + b __add__(a, b)
Вычитание a - b __sub__(a, b)
Умножение a * b __mul__(a, b)
Деление a / b __truediv__(a, b)
Целочисленное деление a // b __floordiv__(a, b)
Остаток от деления a % b __mod__(a, b)
Возведение в степень a ** b __pow__(a, b)
  Индексация и срезы
Доступ по индексу obj[k] __getitem__(obj, k)
Присвоение по индексу obj[k] = v __setitem__(obj, k, v)
Удаление по индексу del obj[k] __delitem__(obj, k)
Срез seq[i:j] __getitem__(seq, slice(i, j))
Присвоение срезу seq[i:j] = values __setitem__(seq, slice(i, j), values)
Удаление среза del seq[i:j] __delitem__(seq, slice(i, j))
Конкатенация seq1 + seq2 __concat__(seq1, seq2)
  Идентификация и сравнение
Идентификация a is b __is__(a, b)
  a is not b __is_not__(a, b)
Проверка на вхождение obj in seq __contains__(seq, obj)
Преобразование в логический тип bool(obj) __truth__(obj)
Равенство a == b __eq__(a, b)
Неравенство a != b __ne__(a, b)
Сравнение a < b __lt__(a, b)
  a > b __gt__(a, b)
  a <= b __le__(a, b)
  a >= b __ge__(a, b)

10.1.2.5.2. Проверка типов

Определение операторов предоставляет удобный синтаксис работы с классом, однако в виду того, что точный тип передаваемых аргументов не известен (Python - язык с неявной динамической типизацией), могут возникнуть проблемы при передаче «не ожидаемых» значений (Листинг 10.1.8).

Листинг 10.1.8 - Ошибка при попытке сложить Point2D и int | скачать
if __name__ == "__main__":

    p1 = Point2D(0, 5)
    p2 = Point2D(-5, 10)

    print(p1 + 2)  # Ошибка: AttributeError: 'int' object has no attribute 'x'

Исходя из этого, перед действием рекомендуется проверить, экземпляром какого класса является переданный объект. Выполнить такую проверку можно используя функции type() или isinstance(obj, class) (Листинг 10.1.9).

Листинг 10.1.9 - Изменение действия в зависимости от типа переданного параметра | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Point2D:
    """Точка на плоскости."""

    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __str__(self):
        """Вернуть строку в виде 'Точка 2D (x, y)'."""
        return "Точка 2D ({}, {})".format(self.x, self.y)

    def __add__(self, other):
        """Создать новый объект как сумму координат self и other."""

        if isinstance(other, self.__class__):
            # Точка с точкой
            # Возвращаем новый объект!
            return Point2D(self.x + other.x, self.y + other.y)
        elif isinstance(other, (int, float)):
            # Точка и число
            # Добавим к обеим координатам self число other и вернем результат
            # Возвращаем старый, измененный, объект!
            self.x += other
            self.y += other
            return self
        else:
            # В противном случае возбуждаем исключение
            raise TypeError("Не могу добавить {1} к {0}".
                            format(self.__class__, type(other)))

if __name__ == "__main__":

    p1 = Point2D(0, 5)
    p2 = Point2D(-5, 10)

    print(p1 + 2)    # (2, 7)
    print(p1 + 5.0)  # (7.0, 12.0)
    # TypeError: Не могу добавить <class 'str'> к <class '__main__.Point2D'>
    print(p1 + "я строка")

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

10.1.2.6. Атрибуты объекта и атрибуты класса

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

В Python для этого предназначены:

  • поля и методы класса (англ. Class Methods):

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

    Поля класса указываются без указания self. Методы класса обозначаются декоратором @classmethod.

  • статические методы (англ. Static Methods):

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

    Статические методы обозначаются декоратором @staticmethod.

Примечание

Декораторы не рассматриваются отдельно в настоящем курсе. См.: https://habrahabr.ru/post/141411/.

Пример использования переменных и методов класса, а также статических методов приведен в Листинге 10.1.10:

  • переменная класса instances_count: хранение количество созданных точек;
  • статический метод sum(): сложение произвольного количество точек;
  • метод класса from_string(): создание точки из строки вида 'x, y'.
Листинг 10.1.10 - Атрибуты и методы класса | скачать
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
class Point2D:
    """Точка на плоскости."""

    # Поле класса (доступна без создания экземпляра)
    # Хранит количество экземпляров класса и является общей (!)
    # для всех объектов этого класса
    instances_count = 0

    def __init__(self, x, y):
        self.x = x
        self.y = y

        # При инициализации нового класса увеличиваем количество
        # созданных экземпляров
        Point2D.instances_count += 1

    def __str__(self):
        """Вернуть строку в виде 'Точка 2D (x, y)'."""
        return 'Точка 2D ({}, {})'.format(self.x, self.y)

    def __add__(self, other):
        """Сложить self и other.

        Параметры:
            - other (Point2D): вернуть новый объект-сумму;
            - other (int, float): сдвинуть точку на other по x и y;
            - other (другой тип): возбудить исключение TypeError.
        """
        if isinstance(other, self.__class__):
            # Точка с точкой
            # Возвращаем новый объект!
            return Point2D(self.x + other.x, self.y + other.y)
        elif isinstance(other, (int, float)):
            # Точка и число
            # Добавим к обеим координатам self число other и вернем результат
            # Возвращаем старый, измененный, объект!
            self.x += other
            self.y += other
            return self
        else:
            # В противном случае сгенерируем исключение
            raise TypeError("Не могу добавить {1} к {0}".
                            format(self.__class__, type(other)))

    def __sub__(self, other):
        """Создать новый объект как разность координат self и other."""
        return Point2D(self.x - other.x, self.y - other.y)

    def __neg__(self):
        """Вернуть новый объект, инвертировав координаты."""
        return Point2D(-self.x, -self.y)

    def __eq__(self, other):
        """Вернуть ответ, являются ли точки одинаковыми."""
        return self.x == other.x and self.y == other.y

    def __ne__(self, other):
        """Вернуть ответ, являются ли точки разными.

        Используем реализованную операцию ==."""
        return not (self == other)

    @staticmethod
    def sum(*points):
        """Вернуть сумму точек 'points' как новый объект.

        Статический метод: принадлежит классу, но ничего о нем не знает.
        """
        assert len(points) > 0, "Количество суммируемых точек = 0!"

        res = points[0]
        for point in points[1:]:
            res += point

        return res

    @classmethod
    def from_string(cls, str_value):
        """Создать экземпляр класса из строки 'str_value'.

        Классовый метод, доступен для вызова как:
            Point2D.from_string(...)

        Параметры:
            - cls: ссылка на класс (Point2D);
            - str_value: строка вида "float, float".

        Результат:
            - Экземпляр класса cls (Point2D).
        """
        values = [float(x) for x in str_value.split(',')]
        assert len(values) == 2

        return cls(*values)

if __name__ == "__main__":

    p1 = Point2D(0, 5)
    p2 = Point2D(-5, 10)

    # Создаем 3-ю точку через метод класса
    p3 = Point2D.from_string("5, 6")

    print(p1 + p3)  # Точка 2D (5.0, 11.0)

    # Отображаем количество созданных точек через переменную класса
    print(Point2D.instances_count)  # 4 (p1, p2, p3, p1 + p2)

    # Сложение точек через статический метод
    p4 = Point2D.sum(p1, p2, p3, Point2D(0, -21))
    print(p4)  # Точка 2D (0.0, 0.0)

10.1.3. Инкапсуляция

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

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

Несмотря на это, в Python принята следующая конвенция:

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

    Пример: _spam для поля или _get_count() для метода.

    Данный синтаксис указывает на то, что атрибут:

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

    При этом атрибут с _ доступен извне, как и обычный public-атрибут класса.

  2. Атрибут, который должен быть закрытым (англ. Private), обозначается при помощи ведущего двойного подчеркивания __:

    Пример: __spam для поля или __get_count() для метода.

    Данный синтаксис указывает на то, что атрибут:

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

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

10.1.3.1. Свойства

Организация доступа к членам класса в 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).

Примечание

Методы вида set_... и get_... называются сеттерами и геттерами (англ. Setter и Getter) и предназначены для «защищенных» установки и чтения значений атрибутов (т.е. прежде чем произойдет изменение атрибута код метода может выполнить какие-либо проверки). Данный подход используется в языках, в которых отсутствуют свойства.

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

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

В Листинге 10.1.11 приведен пример простой реализации объекта Circle.

Листинг 10.1.11 - Простая реализация класса окружности | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import math


class Circle:
    """Окружность."""

    def __init__(self, x=0, y=0, r=0):
        self.x = x
        self.y = y
        self.r = r

    def length(self):
        """Вернуть длину окружности."""
        return 2 * math.pi * self.r

    def square(self):
        """Вернуть площадь окружности."""
        return math.pi * self.r**2

    def __str__(self):
        return "Окружность ({0.x}; {0.y}) радиус={0.r}".format(self)


if __name__ == "__main__":

    c = Circle(3, 4, 1)
    print(c)  # Окружность (3; 4) радиус=1
    print(c.length(), c.square())  # 6.283185307179586 3.141592653589793

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

Листинг 10.1.12 - Использование сеттера и геттера для «защиты» атрибута радиуса | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import math


class Circle:
    """Окружность."""

    def __init__(self, x=0, y=0, r=0):
        self.x = x
        self.y = y
        # Радиус self._r устанавливается через метод-сеттер set_r(),
        # который проверяет устанавливаемое значение
        self.set_r(r)

    def length(self):
        """Вернуть длину окружности."""
        return 2 * math.pi * self._r

    def square(self):
        """Вернуть площадь окружности."""
        return math.pi * self._r**2

    def get_r(self):
        """Вернуть радиус."""
        return self._r

    def set_r(self, r):
        """Установить радиус в значение 'r'."""
        # Устаналиваем только положительный радиус
        assert r > 0, "Радиус должен быть положительным!"
        self._r = r

    def __str__(self):
        return "Окружность ({0.x}; {0.y}) радиус={0._r}".format(self)


if __name__ == "__main__":

    c = Circle(3, 4, 1)
    print(c)  # Окружность (3; 4) радиус=1
    print(c.length(), c.square())  # 6.283185307179586 3.141592653589793
    c.set_r(-1)  # AssertionError: Радиус должен быть положительным!

Теперь предполагается, что пользователь класса будет вызывать методы set_r() и get_r() для установки и получения значения радиуса соответственно. Данный подход обычно используется в языках, где отсутствует поддержка свойств по умолчанию (например, С++), для Python же такое решение громоздко и не соответствует принципу универсального доступа.

Используя свойства, аналогичная функциональность будет выглядеть более лаконично (Листинг 10.1.13).

Листинг 10.1.13 - Использование свойств для ограничения доступа к радиусу | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import math


class Circle:
    """Окружность."""

    def __init__(self, x=0, y=0, r=0):
        self.x = x
        self.y = y
        self.r = r

    def length(self):
        """Вернуть длину окружности."""
        return 2 * math.pi * self.r

    def square(self):
        """Вернуть площадь окружности."""
        return math.pi * self.r**2

    @property
    def r(self):
        # Определение свойства 'r': возвращает 'self._r' и позволяет работать с
        # радиусом извне, как с обычным атрибутом
        return self._r

    @r.setter
    def r(self, r):
        """Установить радиус окружности в 'r'.

        Метод может быть опущен, свойство 'r' долно быть доступно только для чтения.
        """
        assert r > 0, "Радиус должен быть положительным!"
        self._r = r

    def __str__(self):
        return "Окружность ({0.x}; {0.y}) радиус={0.r}".format(self)


if __name__ == "__main__":

    c = Circle(3, 4, 1)
    c.r *= 5  # Увеличили радиус в 5 раз
    print(c)  # Окружность (3; 4) радиус=5
    print(c.length(), c.square())  # 6.283185307179586 3.141592653589793
    c.r = -1  # AssertionError: Радиус должен быть положительным!

Свойство определяется при помощи декораторов:

  • @property: определяет метод получения значения;
  • @r.setter: определяет метод установки значения свойства r.

Имя свойства r определяется в наименовании обоих методов и декораторе @r.setter. Если необходимо реализовать свойство «только для чтения», второй метод может быть опущен.

10.1.4. Наследование и полиморфизм

В Python все классы наследуются от класса object, обладающего некоторыми атрибутами по умолчанию (например, __init__, __doc__, __str__ и т.д.).

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

Каждый класс также может получить информацию о своих «родителях» через метод __bases__() или isinstance().

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

Повторное использование кода существует в двух видах:

  1. Наследование (отношение «является»);
  2. Модель включения/делегации (отношение «имеет»).

10.1.4.1. Отношение: «является»

Предположим, существует телефонная компания, хранящая данные о своих клиентах.

Для простого учета используется класс Customer (Клиент), содержащий атрибуты:

  • поле name: имя клиента (чтение/запись);

  • свойство balance: баланс счета клиента (только чтение);

  • метод record_payment(): выполняет пополнение баланса;

  • метод record_call(): выполняет обработку звонка клиента в зависимости от:

    • типа звонка: «городской» (5 руб./мин.) и «мобильный» (1 руб./мин.);
    • количества минут разговора.

Код класса приведен в Листинге 10.1.14.

Листинг 10.1.14 - Класс, представляющий клиента телефонной компании | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
class Customer:
    """Клиент телефонной компании."""

    def __init__(self, name, balance=0):
        self.name = name
        self._balance = balance

    def __str__(self):
        return "Клиент \"{}\". Баланс: {} руб.".format(self.name, self.balance)

    @property
    def balance(self):
        """Вернуть баланс клиента.

        Свойство 'balance' доступно только для чтения:
        давать доступ на изменение его напрямую было бы неправильно."""
        return self._balance

    def record_payment(self, amount_paid):
        """Пополнить баланс клиента на 'amount_paid' руб."""
        assert amount_paid > 0, "Сумма пополнения должна быть > 0!"
        self._balance += amount_paid

    def record_call(self, call_type, minutes):
        """Списать стоимость звонка с баланса клиента.

        Параметры:
            - call_type (str): тип звонка:
                "Г": городской;
                "М": мобильный;

            - minutes (float): количество минут разговора.
        """
        # В реальности, эти значения могут читаться из базы данных
        if call_type == "Г":
            self._balance -= minutes * 5
        elif call_type == "М":
            self._balance -= minutes * 1


if __name__ == "__main__":

    ivan = Customer("Иван Петров")
    elena = Customer("Елена Миронова", 100)

    ivan.record_call("Г", 20)
    ivan.record_call("М", 5)
    elena.record_call("М", 10)

    ivan.record_payment(155)  # Пополнили телефон на 155 руб.

    print(ivan)  # Клиент "Иван Петров". Баланс: 50 руб.
    print(elena)  # Клиент "Елена Миронова". Баланс: 90 руб.

Расширим возможности компании, добавив наличие у клиента тарифного плана, в каждом из которых есть тип звонка («городской» и «мобильный»):

  • Повременный: «городской» (5 руб./мин.) и «мобильный» (1 руб./мин.);
  • После10МинутВ2РазаДешевле: после 10 минут звонка на городской номер каждая вторая минута бесплатно; в остальном как Повременный;
  • ПлатиМеньшеДо5Минут: до 5 минут разговора в 2 раза дешевле тарифа Повременный, после - в 2 раза дороже.

Одним из вариантов реализации может быть добавление строкового поля call_plan и расширение существующего условия списывания средств в методе record_call() (Листинг 10.1.15).

Листинг 10.1.15 - Добавление учета тарифного плана в метод record_call | скачать
--- D:\YURI_DATA\git\python-book-sphinx\docs\source\code\10_01_14.py
+++ D:\YURI_DATA\git\python-book-sphinx\docs\source\code\10_01_15.py
@@ -1,12 +1,14 @@
 class Customer:
     """Клиент телефонной компании."""
 
-    def __init__(self, name, balance=0):
+    def __init__(self, name, balance=0, call_plan="Повременный"):
         self.name = name
         self._balance = balance
+        self.call_plan = call_plan  # "Повременный" по умолчанию
 
     def __str__(self):
-        return "Клиент \"{}\". Баланс: {} руб.".format(self.name, self.balance)
+        return "Клиент \"{}\". Баланс: {} руб. Тариф: \"{}\"". \
+            format(self.name, self.balance, self.call_plan)
 
     @property
     def balance(self):
@@ -32,22 +34,54 @@
             - minutes (float): количество минут разговора.
         """
         # В реальности, эти значения могут читаться из базы данных
-        if call_type == "Г":
-            self._balance -= minutes * 5
-        elif call_type == "М":
-            self._balance -= minutes * 1
+        if self.call_plan == "Повременный":
+            # Фиксированная стоимость минуты в зависимости от типа звонка
+            if call_type == "Г":
+                self._balance -= minutes * 5
+            elif call_type == "М":
+                self._balance -= minutes * 1
 
+        elif self.call_plan == "После10В2РазаДешевле":
+            # После 10 минут звонка на городской номер
+            # каждая вторая минута бесплатно
+            if call_type == "Г":
+                if minutes > 10:
+                    bonus_minutes = (minutes - 10) // 2
+                else:
+                    bonus_minutes = 0
+                self._balance -= (minutes - bonus_minutes) * 5
+            elif call_type == "М":
+                self._balance -= minutes * 1
+
+        elif self.call_plan == "ПлатиМеньшеДо5Минут":
+            # До 5 минут разговора в 2 раза дешевле тарифа 'Повременный',
+            # после - в 2 раза дороже
+            LIMIT_CHEAP = 5
+            if minutes > LIMIT_CHEAP:
+                cheap_minutes = LIMIT_CHEAP
+                expensive_minutes = minutes - LIMIT_CHEAP
+            else:
+                cheap_minutes = minutes
+                expensive_minutes = 0
+
+            if call_type == "Г":
+                self._balance -= cheap_minutes * 2.5 + expensive_minutes * 10
+            elif call_type == "М":
+                self._balance -= cheap_minutes * 0.5 + expensive_minutes * 2
 
 if __name__ == "__main__":
 
-    ivan = Customer("Иван Петров")
-    elena = Customer("Елена Миронова", 100)
+    ivan = Customer("Иван Петров", 100)
+    elena = Customer("Елена Миронова", 100, call_plan="После10В2РазаДешевле")
+    ekaterina = Customer("Екатерина Ефимова", 100, call_plan="После10В2РазаДешевле")
+    sergey = Customer("Сергей Васильев", 100, call_plan="ПлатиМеньшеДо5Минут")
 
     ivan.record_call("Г", 20)
-    ivan.record_call("М", 5)
-    elena.record_call("М", 10)
+    elena.record_call("Г", 20)
+    ekaterina.record_call("М", 20)
+    sergey.record_call("Г", 20)
 
-    ivan.record_payment(155)  # Пополнили телефон на 155 руб.
-
-    print(ivan)  # Клиент "Иван Петров". Баланс: 50 руб.
-    print(elena)  # Клиент "Елена Миронова". Баланс: 90 руб.
+    print(ivan)       # "Иван Петров".       Баланс: 0 руб.      Тариф: "Повременный"
+    print(elena)      # "Елена Миронова".    Баланс: 25 руб.     Тариф: "После10В2РазаДешевле"
+    print(ekaterina)  # "Екатерина Ефимова". Баланс: 80 руб.     Тариф: "После10В2РазаДешевле"
+    print(sergey)     # "Сергей Васильев".   Баланс: -62.5 руб.  Тариф: "ПлатиМеньшеДо5Минут"

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

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

Более эффективным в данном случае будет использование принципов наследования и полиморфизма. Унаследовав класс Customer, можно изменить только метод расчета (засчет полиморфизма - переопределить работу метода), а далее использовать новый класс, если клиент использует новый тарифный план (Листинг 10.1.16).

Листинг 10.1.16 - Добавление тарифного плана через наследование класса Customer | скачать
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
class Customer:
    """Клиент телефонной компании.

    Расходы данного клиента ведутся на повременной основе."""

    def __init__(self, name, balance=0):
        self.name = name
        self._balance = balance

    def __str__(self):
        return "Клиент \"{}\". Баланс: {} руб.".format(self.name, self.balance)

    @property
    def balance(self):
        """Вернуть баланс клиента.

        Свойство 'balance' доступно только для чтения:
        давать доступ на изменение его напрямую было бы неправильно."""
        return self._balance

    def record_payment(self, amount_paid):
        """Пополнить баланс клиента на 'amount_paid' руб."""
        assert amount_paid > 0, "Сумма пополнения должна быть > 0!"
        self._balance += amount_paid

    def record_call(self, call_type, minutes):
        """Списать стоимость звонка с баланса клиента.

        Параметры:
            - call_type (str): тип звонка:
                "Г": городской;
                "М": мобильный;

            - minutes (int): количество минут разговора.
        """
        if call_type == "Г":
            self._balance -= minutes * 5
        elif call_type == "М":
            self._balance -= minutes * 1


class CustomerFree2ndMinuteAfter10(Customer):
    """Клиент телефонной компании (потомок Customer).

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

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

    def record_call(self, call_type, minutes):
        # Данный метод переопределяет соответствующий метод родителя

        # Определяем количество бесплатных минут
        if call_type == "Г" and minutes > 10:
            bonus_minutes = (minutes - 10) // 2
        else:
            bonus_minutes = 0

        # Вызываем родительский метод расчета
        super().record_call(call_type, minutes - bonus_minutes)


class CustomerTwiceCheaperFirst5Minutes(Customer):
    """Клиент телефонной компании (потомок Customer).

    До 5 минут разговора в 2 раза дешевле тарифа "Повременный",
    после - в 2 раза дороже.

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

    def record_call(self, call_type, minutes):
        # Данный метод переопределяет соответствующий метод родителя

        LIMIT_CHEAP = 5
        if minutes > LIMIT_CHEAP:
            cheap_minutes = LIMIT_CHEAP
            expensive_minutes = minutes - LIMIT_CHEAP
        else:
            cheap_minutes = minutes
            expensive_minutes = 0

        # Вызываем родительский метод расчета
        super().record_call(call_type, cheap_minutes / 2 +
                                       expensive_minutes * 2)


if __name__ == "__main__":

    ivan = Customer("Иван Петров", 100)
    # Елена, Екатерина и Сергей теперь экземпляры других классов
    elena = CustomerFree2ndMinuteAfter10("Елена Миронова", 100)
    ekaterina = CustomerFree2ndMinuteAfter10("Екатерина Ефимова", 100)
    sergey = CustomerTwiceCheaperFirst5Minutes("Сергей Васильев", 100)

    ivan.record_call("Г", 20)
    elena.record_call("Г", 20)
    ekaterina.record_call("М", 20)
    sergey.record_call("Г", 20)

    print(ivan)       # "Иван Петров".       Баланс: 0 руб.      Тариф: "Повременный"
    print(elena)      # "Елена Миронова".    Баланс: 50 руб.     Тариф: "После10В2РазаДешевле"
    print(ekaterina)  # "Екатерина Ефимова". Баланс: 80 руб.     Тариф: "После10В2РазаДешевле"
    print(sergey)     # "Сергей Васильев".   Баланс: -62.5 руб.  Тариф: "ПлатиМеньшеДо5Минут"

В Листинге 10.1.16 был переопределен, метод расчет стоимости звонка record_call().

За счет свойств наследования и полиморфизма при необходимости добавления нового тарифа в данном случае достаточно объявить новый класс (например, VipClient, в т.ч. унаследовать его не только от Customer, но и других классов) и реализовать только необходимые методы относительно родителя.

С дочерним классом возможно проводить следующие операции:

  • добавлять собственные поля и методы; при этом данные изменения не коснутся родительского класса;
  • заменять/дополнять реализацию уже существующих родительских атрибутов, переопределяя их.

10.1.4.2. Отношение: «имеет»

Другим возможным вариантом решения системы тарифов (и более логичным при решении данной проблемы) будет реализация тарификации по модели «включения/делегации».

Пусть существует 2 общих класса:

  • CallPlan (Тариф):

    • поле name: наименование тарифа (чтение/запись);

    • метод record_call(): выполняет обработку звонка клиента в зависимости от:

      • типа звонка: «городской» (5 руб./мин.) и «мобильный» (1 руб./мин.);
      • количества минут разговора.

    Наследники CallPlan (прочие тарифы) будут иметь изменять необходимые атрибуты.

  • Customer (Клиент): хранит информацию о пользователе и ссылку на тариф:

    • поле name: имя клиента (чтение/запись);
    • поле call_plan: тариф - ссылка на объекта класса Тариф (чтение/запись);
    • свойство balance: баланс счета клиента (только чтение);
    • метод record_payment(): выполняет пополнение баланса;
    • метод record_call(): выполняет обработку звонка клиента на основании тарифа call_plan.

Алгоритм подсчета стоимости звонка для класса Customer изменится следующим образом:

  • расчет стоимости делегируется объекту call_plan;
  • результат (стоимость звонка по тарифу) вычитается из баланса клиента.

В Листинге 10.1.17 (а-в) приведены модули и код соответствующих классов.

Листинг 10.1.17 (а) - Включение/делегация классов в Python: класс CallPlan | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
class CallPlan:
    """Абстрактный класс для всех тарифных планов."""

    def __init__(self):
        self.name = "Абстрактный тариф"

    def record_call(self, call_type, minutes):
        """Списать стоимость звонка с баланса клиента.

        Параметры:
            - call_type (str): тип звонка:
                "Г": городской;
                "М": мобильный;

            - minutes (float): количество минут разговора.
        """
        # Делегируем расчет стоимости отдельному методу
        # Так, наследнику достаточно будет переопределить каждый из них,
        # не меняя общую логику ниже
        if call_type == "Г":
            return self.record_call_g(minutes)
        elif call_type == "М":
            return self.record_call_m(minutes)
        else:
            return 0

    def record_call_g(self, minutes):
        """Вернуть стоимость звонка на городской номер для 'minutes' минут."""
        raise NotImplementedError  # Должны реализовать дочерние классы

    def record_call_m(self, minutes):
        """Вернуть стоимость звонка на мобильный номер для 'minutes' минут."""
        raise NotImplementedError  # Должны реализовать дочерние классы


class CallPlanSimple(CallPlan):

    def __init__(self):
        self.name = "Повременный"

    def record_call_g(self, minutes):
        return minutes * 5

    def record_call_m(self, minutes):
        return minutes * 1


class CallPlanFree2ndMinuteAfter10(CallPlanSimple):

    def __init__(self):
        self.name = "После10В2РазаДешевле"

    def record_call_g(self, minutes):
        if minutes > 10:
            bonus_minutes = (minutes - 10) // 2
        else:
            bonus_minutes = 0

        # Вызываем родительский метод расчета
        return super().record_call_g(minutes - bonus_minutes)


class CallPlanTwiceCheaperFirst5Minutes(CallPlanSimple):

    def __init__(self):
        self.name = "ПлатиМеньшеДо5Минут"

    def record_call(self, call_type, minutes):
        LIMIT_CHEAP = 5
        if minutes > LIMIT_CHEAP:
            cheap_minutes = LIMIT_CHEAP
            expensive_minutes = minutes - LIMIT_CHEAP
        else:
            cheap_minutes = minutes
            expensive_minutes = 0

        # Вызываем родительский метод расчета
        return super().record_call(call_type, cheap_minutes / 2 +
                                   expensive_minutes * 2)
Листинг 10.1.17 (б) - Включение/делегация классов в Python: класс Customer | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from call_plan import CallPlanSimple

class Customer:
    """Клиент телефонной компании."""

    def __init__(self, name, balance=0, call_plan=None):
        self.name = name
        self._balance = balance
        self.call_plan = call_plan
        # Если тарифный план не был указан, используем CallPlanSimple()
        if self.call_plan is None:
            self.call_plan = CallPlanSimple()

    def __str__(self):
        return "Клиент \"{}\". Баланс: {} руб. Тариф: \"{}\"". \
            format(self.name, self.balance, self.call_plan.name)

    @property
    def balance(self):
        """Вернуть баланс клиента.

        Свойство 'balance' доступно только для чтения:
        давать доступ на изменение его напрямую было бы неправильно."""
        return self._balance

    def record_payment(self, amount_paid):
        """Пополнить баланс клиента на 'amount_paid' руб."""
        assert amount_paid > 0, "Сумма пополнения должна быть > 0!"
        self._balance += amount_paid

    def record_call(self, call_type, minutes):
        """Списать стоимость звонка с баланса клиента.

        Параметры:
            - call_type (str): тип звонка:
                "Г": городской;
                "М": мобильный;

            - minutes (int): количество минут разговора.
        """
        # Делегируем определение стоимости звонка классу call_plan
        costs = self.call_plan.record_call(call_type, minutes)
        self._balance -= costs
Листинг 10.1.17 (в) - Включение/делегация классов в Python: основной модуль | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from customer import Customer
from call_plan import CallPlanFree2ndMinuteAfter10, CallPlanTwiceCheaperFirst5Minutes

if __name__ == "__main__":

    ivan = Customer("Иван Петров", 100)

    # 1. Используется тариф по умолчанию
    ivan.record_call("Г", 20)
    print(ivan)  # Клиент "Иван Петров". Баланс: 0 руб. Тариф: "Повременный"

    ivan.record_payment(100 - ivan.balance)  # Пополнили телефон до 100 руб.

    # 2. Меняем тариф на CallPlanFree2ndMinuteAfter10
    ivan.call_plan = CallPlanFree2ndMinuteAfter10()
    ivan.record_call("Г", 20)
    print(ivan)  # Клиент "Иван Петров". Баланс: 25 руб. Тариф: "После10В2РазаДешевле"

    ivan.record_payment(100 - ivan.balance)  # Пополнили телефон до 100 руб.

    # 3. Меняем тариф на CallPlanTwiceCheaperFirst5Minutes
    ivan.call_plan = CallPlanTwiceCheaperFirst5Minutes()
    ivan.record_call("Г", 20)
    print(ivan)  # Клиент "Иван Петров". Баланс: -62.5 руб. Тариф: "ПлатиМеньшеДо5Минут"

Данный пример может быть расширен, например, добавлением журнала звонков, смены тарифа и т.д.

Засчет наследования и полиморфизма также легко осуществляются групповые операции с классами. В Листинге 10.1.18 приведен пример сравнения затрат по тарифам между собой.

Листинг 10.1.18 - Сравнение затрат по тарифам | скачать
from customer import Customer
from call_plan import CallPlanSimple, \
                      CallPlanFree2ndMinuteAfter10, \
                      CallPlanTwiceCheaperFirst5Minutes

if __name__ == "__main__":

    call_plans = (CallPlanSimple(),
                  CallPlanFree2ndMinuteAfter10(),
                  CallPlanTwiceCheaperFirst5Minutes())

    minutes = tuple(range(0, 26, 5))  # 0, 5, 10, 15, 20, 25 мин.

    # Сравним стоимости звонков для тарифов
    for call_type in ("Г", "М"):
        print("{:20}".format(call_type), end="")
        # Заголовок - минуты
        for minute in minutes:
            print("{:>8d} мин.".format(minute), end="")
        print()

        # Подсчет стоимости
        for call_plan in call_plans:
            print("{:20}".format(call_plan.name), end="")
            for minute in minutes:
                print("{:>8.2f} руб.".format(call_plan.record_call(call_type, minute)), end="")
            print()

        print()

# -------------
# Пример вывода:

# Г                          0 мин.       5 мин.      10 мин.      15 мин.      20 мин.      25 мин.
# Повременный             0.00 руб.   25.00 руб.   50.00 руб.   75.00 руб.  100.00 руб.  125.00 руб.
# После10В2РазаДешевле    0.00 руб.   25.00 руб.   50.00 руб.   65.00 руб.   75.00 руб.   90.00 руб.
# ПлатиМеньшеДо5Минут     0.00 руб.   12.50 руб.   62.50 руб.  112.50 руб.  162.50 руб.  212.50 руб.

# М                          0 мин.       5 мин.      10 мин.      15 мин.      20 мин.      25 мин.
# Повременный             0.00 руб.    5.00 руб.   10.00 руб.   15.00 руб.   20.00 руб.   25.00 руб.
# После10В2РазаДешевле    0.00 руб.    5.00 руб.   10.00 руб.   15.00 руб.   20.00 руб.   25.00 руб.
# ПлатиМеньшеДо5Минут     0.00 руб.    2.50 руб.   12.50 руб.   22.50 руб.   32.50 руб.   42.50 руб.

10.1.5. Дополнительные аспекты ООП

10.1.5.1. Проектирование иерархии классов и класс object

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

Одной из наиболее популярных в мире профессиональных нотаций (системы условных обозначений) моделирования в объектно-ориентированном стиле является язык UML (англ. Unified Modeling Language). На Рисунке 10.1.4 приведен пример изображения иерархии классов в данной нотации.

_images/10_01_06.png

Рисунок 10.1.6 - Диаграмма классов UML

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

В Python все классы наследуются от класса object, так, для классов из Листинга 10.1.16 иерархия будет выглядеть следующим образом:

object -> Customer -> CustomerFree2ndMinuteAfter10
                   -> CallPlanTwiceCheaperFirst5Minutes

Встроенные классы Python также имеют свою иерархию, например, для типа bool она выглядит как:

object -> int -> bool

10.1.5.2. Множественное наследование

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

Листинг 10.1.19 - Множественное наследование в Python | скачать
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Student:
    def pass_exam(self):
        print("Я сдал экзамен!")


class Worker:
    def work(self):
        print("Работаю...")


class FootballPlayer:
    def score(self):
        print("Забил гол!")


class StudentWorkerAndFootballPlayer(Student, Worker, FootballPlayer):
    pass


if __name__ == "__main__":
    s = StudentWorkerAndFootballPlayer()
    s.pass_exam()  # Я сдал экзамен!
    s.work()       # Работаю...
    s.score()      # Забил гол!

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

10.1.5.3. Класс как структура данных

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

Листинг 10.1.20 - Использование класса в Python как структуры в Си | скачать
class FootballPlayer:
    pass

if __name__ == "__main__":

    r9 = FootballPlayer()

    # При присвоении значения несуществующему полю класса Python дополняет класс
    # Синтаксис похож на словарь, где ключ указывается не в скобках [], а через точку
    r9.name = "Роналдо"
    r9.birthday = "18/09/1976"
    r9.position = "Нападающий"
    r9.height = 1.83
    r9.wc_goals = 15

    print(r9.name, r9.birthday, r9.position, r9.height, r9.wc_goals)

10.1.6. Преимущества и недостатки ООП

Объектно-ориентированное программирование как другие парадигмы имеет свои преимущества и недостатки [11].

Преимущества

  1. Улучшение производительности разработки ПО.

    ООП способствует модульности, расширяемости и повторному использованию кода (за счет аспекта наследования и полиморфизма).

  2. Улучшение сопровождения ПО.

    Благодаря модульности, расширяемости и повторному использованию кода его легче поддерживать.

  3. Ускорение разработки.

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

  4. Снижение стоимости разработки.

    Повторное использование кода также позволяет снизить издержки и сосредоточиться на объектно-ориентированном анализе и проектировании, снижающем общую стоимость ПО.

  5. Повышение качества ПО.

    Ускорение разработки и снижение затрат позволяет уделить больше времени и ресурсов на тестирование ПО.

Недостатки

  1. Крутая кривая обучения.

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

  2. Больший объем кода.

    Как правило, ООП приводит появлению большего количества кода, нежели в императивном программировании.

  3. Медленные программы.

    ООП-программы чаще выполняются медленнее, т.к. содержат больше кода для выполнения.

  4. ООП не подходит для всех случаев.

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


[1]Sebesta, W.S Concepts of Programming languages. 10E; ISBN 978-0133943023.
[2]Python - официальный сайт. URL: https://www.python.org/.
[3]Python - FAQ. URL: https://docs.python.org/3/faq/programming.html.
[4]Саммерфилд М. Программирование на Python 3. Подробное руководство. — М.: Символ-Плюс, 2009. — 608 с.: ISBN: 978-5-93286-161-5.
[5]Лучано Рамальо. Python. К вершинам мастерства. — М.: ДМК Пресс , 2016. — 768 с.: ISBN: 978-5-97060-384-0, 978-1-491-94600-8.
[6]Classes, objects, methods and properties. URL: http://phpenthusiast.com/object-oriented-php-tutorials/create-classes-and-objects.
[7]Java Programming Tutorial. Object-oriented Programming (OOP) Basics.URL: .. [#figure_10_01_03] https://www3.ntu.edu.sg/home/ehchua/programming/java/J3a_OOPBasics.html.
[8](1, 2) Really Brief Introduction to Object Oriented Design. URL: http://www.fincher.org/tips/General/SoftwareEngineering/ObjectOrientedDesign.shtml.
[9]Mapping Operators To Functions. URL: https://docs.python.org/3/library/operator.html#mapping-operators-to-functions.
[10]Множественное наследование: критика. URL: https://ru.wikipedia.org/wiki/Множественное_наследование#.D0.9A.D1.80.D0.B8.D1.82.D0.B8.D0.BA.D0.B0.
[11]Advantages and Disadvantages of Object-Oriented Programming (OOP). URL: https://www.saylor.org/site/wp-content/uploads/2013/02/CS101-2.1.2-AdvantagesDisadvantagesOfOOP-FINAL.pdf.