Наследование и композиция: руководство по ООП в Python

Spread the love

В этой статье мы подробно рассмотрим наследование (inheritance) и композицию (composition) в Python. Наследование (Inheritance) и композиция (composition) – это две важные концепции в объектно-ориентированном программировании, которые моделируют отношения между двумя классами. Они являются строительными блоками объектно-ориентированного проектирования (object oriented design) и помогают программистам писать повторно используемый код.

Оригинальная статья  Isaac Rodriguez  – Inheritance and Composition: A Python OOP Guide

Содержание

К концу этой статьи вы узнаете, как:

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

Что такое наследование и композиция?

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

Оба они реализуют повторное использование кода, но делают это по-разному.

Что такое наследование?

Модели наследования – это отношения. Это означает, что когда у вас есть класс Derived, который наследуется от базового класса, вы создали отношение, в котором Derived является специализированной версией Base.

Наследование представляется с использованием Unified Modeling Language или UML следующим образом:

источник: https://files.realpython.com/media/ic-basic-inheritance.jpg

Классы представлены в виде блоков с именем класса вверху. Отношения наследования представлены стрелкой из производного класса, указывающей на базовый класс. Так же обычно добавляется слово extends к стрелке.

Примечание: в отношениях наследования:

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

Допустим, у вас есть базовый класс Animal, и вы создаете его для создания класса Horse. В наследственных отношениях говорится, что Horse – это Animal. Это означает, что Horse наследует интерфейс и реализацию Animal, и объекты Horse могут использоваться для замены объектов Animal в приложении.

Это известно как принцип подстановки Лисков. Принцип гласит, что «в компьютерной программе, если S является подтипом T, объекты типа T могут быть заменены объектами типа S без изменения каких-либо требуемых свойств программы».

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

Что такое композиция?

Композиция – это концепция, которая моделирует отношения. Она позволяет создавать сложные типы, комбинируя объекты других типов. Это означает, что класс Composite может содержать объект другого класса Component.

UML представляет композицию следующим образом:

Источник: https://files.realpython.com/media/ic-basic-composition.jpg

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

На приведенной выше диаграмме 1 означает, что класс Composite содержит один объект типа Component. Кардинальность может быть выражена следующими способами:

  • Число указывает количество экземпляров Component, которые содержатся в Composite.
  • Символ * указывает, что класс Composite может содержать переменное число экземпляров Component.
  • Диапазон 1..4 указывает, что класс Composite может содержать диапазон экземпляров Component. Диапазон указывается с минимальным и максимальным количеством экземпляров, или с минимальным и множеством экземпляров, как в 1 .. *.

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

Например, наш класс Horse может быть составлен другим объектом типа Tail. Композиция позволяет выразить эти отношения, сказав, что у Horse есть Tail.

Композиция позволяет повторно использовать код, добавляя объекты к другим объектам, в отличие от наследования интерфейса и реализации других классов. Классы Horse и Dog могут использовать функциональность Tail посредством композиции, не выводя один класс из другого.

Обзор наследования в Python

Все в Python является объектом. Модули – это объекты, определения классов и функции – это объекты, и, конечно, объекты, созданные из классов, тоже являются объектами.

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

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

Объект Супер Класс

Самый простой способ увидеть наследование в Python – это перейти в интерактивную оболочку Python и написать немного кода. Мы начнем с написания самого простого из возможных классов:

>>> class MyClass:
...     pass
...

Мы объявили класс MyClass, который мало что делает, но он проиллюстрирует самые основные концепции наследования. Теперь, когда у нас объявлен класс, мы можем использовать функцию dir() для получения списка его членов:

>>> c = MyClass()
>>> dir(c)
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__',
'__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__',
'__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__',
'__str__', '__subclasshook__', '__weakref__']

dir() возвращает список всех членов в указанном объекте. Мы не объявили ни одного члена в MyClass, так откуда этот список? Вы можете узнать с помощью интерактивного интерпритатора:

>>> o = object()
>>> dir(o)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__',
'__ge__', '__getattribute__', '__gt__', '__hash__', '__init__',
'__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__',
'__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__',
'__subclasshook__']

Как видите, два списка практически идентичны. В MyClass есть несколько дополнительных членов, таких как __dict__ и __weakref__, но каждый отдельный член класса object также присутствует в MyClass.

Это потому, что каждый класс, который вы создаете в Python, неявно происходит от object. Мы могли бы быть более явным и написать class MyClass (object):, но это избыточно и не нужно.

Примечание. В Python 2 мы должны явным образом наследовать объект по причинам, выходящим за рамки этой статьи, но вы можете прочитать об этом в разделе New-style and classic classes документации по Python 2.

Исключения – есть исключение

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

Это можно увидеть в интерпретатор Python

>>> class MyError:
...     pass
...
>>> raise MyError()

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: exceptions must derive from BaseException

Мы создали новый класс, чтобы указать тип ошибки. Затем мы попытались использовать это, чтобы вызвать исключение. Возникает исключение, но в выходных данных указывается, что исключение имеет тип TypeError, а не MyError, и что все исключения должны быть производными от BaseException (exceptions must derive from BaseException).

BaseException – это базовый класс, предоставленный для всех типов ошибок. Чтобы создать новый тип ошибки, мы должны получить свой класс из BaseException или одного из его производных классов. Соглашение в Python заключается в получении пользовательских типов ошибок из Exception, который, в свою очередь, является производным от BaseException.

Правильный способ определения ошибки:

>>> class MyError(Exception):
...     pass
...
>>> raise MyError()

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
__main__.MyError

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

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

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

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

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

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

# In hr.py

class PayrollSystem:
    def calculate_payroll(self, employees):
        print('Calculating Payroll')
        print('===================')
        for employee in employees:
            print(f'Payroll for: {employee.id} - {employee.name}')
            print(f'- Check amount: {employee.calculate_payroll()}')
            print('')

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

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

# In hr.py

class Employee:
    def __init__(self, id, name):
        self.id = id
        self.name = name

Employee является базовым классом для всех типов сотрудников. Он объявлен с id и name.

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

Например, административные работники имеют фиксированную зарплату, поэтому каждую неделю им платят одну и ту же сумму:

# In hr.py

class SalaryEmployee(Employee):
    def __init__(self, id, name, weekly_salary):
        super().__init__(id, name)
        self.weekly_salary = weekly_salary

    def calculate_payroll(self):
        return self.weekly_salary

Здесь мы создали производный класс SalaryEmployee, который наследует Employee. Класс инициализируется с помощью id и name, требуемыми базовым классом, и для этого мы используете super() для инициализации членов базового класса. Вы можете прочитать все о super() в разделе Supercharge Your Classes With Python super().

SalaryEmployee также требует параметр инициализации weekly_salary, который представляет сумму, которую сотрудник зарабатывает за неделю.

Класс содержит обязательный метод .calculate_payroll(), используемый системой HR. Его реализация просто возвращает сумму, хранящуюся в weekly_salary.

В компании также работают рабочие, которые получают почасовую оплату, поэтому мы добавим HourlyEmployee в систему управления персоналом:

# In hr.py

class HourlyEmployee(Employee):
    def __init__(self, id, name, hours_worked, hour_rate):
        super().__init__(id, name)
        self.hours_worked = hours_worked
        self.hour_rate = hour_rate

    def calculate_payroll(self):
        return self.hours_worked * self.hour_rate

Класс HourlyEmployee инициализируется с помощью id и name, переменных базового класса, плюс hours_worked и hour_rate, необходимые для расчета заработной платы. Метод .calculate_payroll() реализован путем возврата отработанных часов, умноженных на часовую ставку.

Наконец, в компании работают торговые партнеры, которым выплачивается фиксированная зарплата плюс комиссия, основанная на их продажах, поэтому мы создадим класс CommissionEmployee:

# In hr.py

class CommissionEmployee(SalaryEmployee):
    def __init__(self, id, name, weekly_salary, commission):
        super().__init__(id, name, weekly_salary)
        self.commission = commission

    def calculate_payroll(self):
        fixed = super().calculate_payroll()
        return fixed + self.commission

Мы получаете CommissionEmployee от SalaryEmployee, потому что оба класса имеют weekly_salary. В то же время, CommissionEmployee инициализируется значением commission, основанным на продажах для сотрудника.

.calculate_payroll() использует реализацию базового класса для получения фиксированной зарплаты fixed и добавляет значение комиссии.

Поскольку CommissionEmployee является производным от SalaryEmployee, у нас есть доступ к свойству weekly_salary напрямую, и мы могли бы реализовать .calculate_payroll(), используя значение этого свойства.

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

Мы создали иерархию классов. Диаграмма классов UML будет выглядит следующим образом:

источник: https://files.realpython.com/media/ic-initial-employee-inheritance.jpg

На диаграмме показана иерархия наследования классов. Производные классы реализуют интерфейс IPayrollCalculator, который требуется PayrollSystem. Реализация PayrollSystem.calculate_payroll() требует, чтобы передаваемые объекты employee содержали реализацию id, name и calculate_payroll().

Интерфейсы представлены аналогично классам со словом interface над именем интерфейса. Имена интерфейсов обычно начинаются с заглавной буквы I.

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

# In program.py

import hr

salary_employee = hr.SalaryEmployee(1, 'John Smith', 1500)
hourly_employee = hr.HourlyEmployee(2, 'Jane Doe', 40, 15)
commission_employee = hr.CommissionEmployee(3, 'Kevin Bacon', 1000, 250)
payroll_system = hr.PayrollSystem()
payroll_system.calculate_payroll([
    salary_employee,
    hourly_employee,
    commission_employee
])

Теперь мы можем запустить программу в командной строке и увидеть результаты:

$ python program.py

Calculating Payroll
===================
Payroll for: 1 - John Smith
- Check amount: 1500

Payroll for: 2 - Jane Doe
- Check amount: 600

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

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

Обратите внимание, что базовый класс Employee не определяет метод .calculate_payroll(). Это означает, что если вы создадите простой объект Employee и передадите его в PayrollSystem, вы получите ошибку. Вы можете попробовать это в интерактивном интерпритаторе Python:

>>> import hr
>>> employee = hr.Employee(1, 'Invalid')
>>> payroll_system = hr.PayrollSystem()
>>> payroll_system.calculate_payroll([employee])

Payroll for: 1 - Invalid
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/hr.py", line 39, in calculate_payroll
    print(f'- Check amount: {employee.calculate_payroll()}')
AttributeError: 'Employee' object has no attribute 'calculate_payroll'

Хотя мы можем создать экземпляр объекта Employee, этот объект не может быть использован системой PayrollSystem. Почему? Потому что он не имеет .calculate_payroll() для Employee. Чтобы соответствовать требованиям PayrollSystem, нам нужно преобразовать класс Employee, который в настоящее время является простым классом, в абстрактный класс. Таким образом, ни один сотрудник никогда не будет просто Employee, а он будет обязательно содержать реализацию .calculate_payroll().

Абстрактные базовые классы в Python

Класс Employee в приведенном выше примере называется абстрактным базовым классом. Абстрактные базовые классы существуют для наследования, но никогда сами не используются для создания объектов. Python предоставляет модуль abc для определения абстрактных базовых классов.

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

Модуль abc в стандартной библиотеке Python предоставляет функциональные возможности для предотвращения создания объектов из абстрактных базовых классов.

Далее мы изменим реализацию класса Employee, чтобы гарантировать, что он не может быть использован для создания объекта:

# In hr.py

from abc import ABC, abstractmethod

class Employee(ABC):
    def __init__(self, id, name):
        self.id = id
        self.name = name

    @abstractmethod
    def calculate_payroll(self):
        pass

Мы наследуем Employee от ABC, делая его абстрактным базовым классом. Затем мы декорируем метод .calculate_payroll() с помощью декоратора @abstractmethod.

Это изменение имеет два приятных побочных эффекта:

  1. Мы говорим пользователям модуля, что объекты типа Employee не могут быть созданы.
  2. Мы говорим другим разработчикам, работающим над модулем hr, что если они являются производными от Employee, они должны переопределить абстрактный метод .calculate_payroll().

Проверим, что объекты типа Employee не могут быть созданы:

>>> import hr
>>> employee = hr.Employee(1, 'abstract')

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class Employee with abstract methods 
calculate_payroll

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

Реализация наследования против наследования интерфейса

Когда вы наследуете один класс от другого, производный класс наследует оба:

  1. Интерфейс базового класса: производный класс наследует все методы, свойства и атрибуты базового класса.
  2. Реализация базового класса: производный класс наследует код, который реализует интерфейс класса.

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

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

В Python вам не нужно явно объявлять интерфейс. Любой объект, который реализует желаемый интерфейс, может использоваться вместо другого объекта. Это известно как утиная типизация (duck typing). Утиная типизация обычно объясняется так «если что то ведет себя как утка, то это утка».

Чтобы проиллюстрировать это, мы добавим класс DisgruntledEmployee в приведенный выше пример, который не является производным от Employee:

# In disgruntled.py

class DisgruntledEmployee:
    def __init__(self, id, name):
        self.id = id
        self.name = name

    def calculate_payroll(self):
        return 1000000

Класс DisgruntledEmployee не является производным от Employee, но предоставляет тот же интерфейс, который требуется PayrollSystem. PayrollSystem.calculate_payroll() требует список объектов, которые реализуют следующий интерфейс:

  • Свойство id или атрибут, который возвращает идентификатор сотрудника
  • Свойство name или атрибут, представляющий имя сотрудника
  • Метод .calculate_payroll(), который не принимает никаких параметров и возвращает сумму заработной платы для обработки

Все эти требования выполняются классом DisgruntledEmployee, поэтому PayrollSystem все еще может рассчитать свою заработную плату.

Мы можем изменить программу для использования класса DisgruntledEmployee:

# In program.py

import hr
import disgruntled

salary_employee = hr.SalaryEmployee(1, 'John Smith', 1500)
hourly_employee = hr.HourlyEmployee(2, 'Jane Doe', 40, 15)
commission_employee = hr.CommissionEmployee(3, 'Kevin Bacon', 1000, 250)
disgruntled_employee = disgruntled.DisgruntledEmployee(20000, 'Anonymous')
payroll_system = hr.PayrollSystem()
payroll_system.calculate_payroll([
    salary_employee,
    hourly_employee,
    commission_employee,
    disgruntled_employee
])

Программа создает объект DisgruntledEmployee и добавляет его в список, обрабатываемый PayrollSystem. Теперь мы можем запустить программу и увидеть ее вывод:

$ python program.py

Calculating Payroll
===================
Payroll for: 1 - John Smith
- Check amount: 1500

Payroll for: 2 - Jane Doe
- Check amount: 600

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Payroll for: 20000 - Anonymous
- Check amount: 1000000

Как видите, PayrollSystem по-прежнему может обрабатывать новый объект, поскольку он соответствует требуемому интерфейсу.

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

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

Теперь очистим приведенный выше пример, чтобы перейти к следующей теме. Вы можете удалить файл disgruntled.py, а затем вернуть модуль hr в исходное состояние:

# In hr.py

class PayrollSystem:
    def calculate_payroll(self, employees):
        print('Calculating Payroll')
        print('===================')
        for employee in employees:
            print(f'Payroll for: {employee.id} - {employee.name}')
            print(f'- Check amount: {employee.calculate_payroll()}')
            print('')

class Employee:
    def __init__(self, id, name):
        self.id = id
        self.name = name

class SalaryEmployee(Employee):
    def __init__(self, id, name, weekly_salary):
        super().__init__(id, name)
        self.weekly_salary = weekly_salary

    def calculate_payroll(self):
        return self.weekly_salary

class HourlyEmployee(Employee):
    def __init__(self, id, name, hours_worked, hour_rate):
        super().__init__(id, name)
        self.hours_worked = hours_worked
        self.hour_rate = hour_rate

    def calculate_payroll(self):
        return self.hours_worked * self.hour_rate

class CommissionEmployee(SalaryEmployee):
    def __init__(self, id, name, weekly_salary, commission):
        super().__init__(id, name, weekly_salary)
        self.commission = commission

    def calculate_payroll(self):
        fixed = super().calculate_payroll()
        return fixed + self.commission

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

По сути, мы наследуете реализацию атрибутов id и name класса Employee в наших производных классах. Поскольку .calculate_payroll() – это просто интерфейс к методу PayrollSystem.calculate_payroll(), нам не нужно реализовывать его в базовом классе Employee.

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

Проблема взрыва класса

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

Мы начали строить иерархию классов типов Employee, используемых системой PayrollSystem для расчета заработной платы. Теперь нам нужно добавить некоторые функциональные возможности в эти классы, чтобы их можно было использовать с новой системой ProductivitySystem.

ProductivitySystem отслеживает производительность на основе ролей сотрудников. Нам потребуются несколько ролей сотрудников:

  • Менеджеры: они ходят, кричат на людей, говорят им, что делать. Они наемные работники и производят больше количество денег.
  • Секретари: они делают всю бумажную работу для менеджеров и гарантируют, что все будет выставлено и оплачено вовремя. Они также являются наемными работниками, но зарабатывают меньше.
  • Продавцы: они делают много телефонных звонков, чтобы продать продукты. У них есть зарплата, но они также получают комиссионные с продаж.
  • Фабричные рабочие: они производят продукцию для компании. Они оплачиваются по часам.

С этими требованиями мы начинаем понимать, что Employee и его производные классы могут принадлежать чему то еще, кроме модуля hr, потому что теперь они также используются системой ProductivitySystem.

Создадим модуль employees и переместим туда классы:

# In employees.py

class Employee:
    def __init__(self, id, name):
        self.id = id
        self.name = name

class SalaryEmployee(Employee):
    def __init__(self, id, name, weekly_salary):
        super().__init__(id, name)
        self.weekly_salary = weekly_salary

    def calculate_payroll(self):
        return self.weekly_salary

class HourlyEmployee(Employee):
    def __init__(self, id, name, hours_worked, hour_rate):
        super().__init__(id, name)
        self.hours_worked = hours_worked
        self.hour_rate = hour_rate

    def calculate_payroll(self):
        return self.hours_worked * self.hour_rate

class CommissionEmployee(SalaryEmployee):
    def __init__(self, id, name, weekly_salary, commission):
        super().__init__(id, name, weekly_salary)
        self.commission = commission

    def calculate_payroll(self):
        fixed = super().calculate_payroll()
        return fixed + self.commission

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

# In program.py

import hr
import employees

salary_employee = employees.SalaryEmployee(1, 'John Smith', 1500)
hourly_employee = employees.HourlyEmployee(2, 'Jane Doe', 40, 15)
commission_employee = employees.CommissionEmployee(3, 'Kevin Bacon', 1000, 250)
payroll_system = hr.PayrollSystem()
payroll_system.calculate_payroll([
    salary_employee,
    hourly_employee,
    commission_employee
])

Запустим программу и проверим, что она все еще работает:

$ python program.py

Calculating Payroll
===================
Payroll for: 1 - John Smith
- Check amount: 1500

Payroll for: 2 - Jane Doe
- Check amount: 600

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Начнем добавлять новые классы:

# In employees.py

class Manager(SalaryEmployee):
    def work(self, hours):
        print(f'{self.name} screams and yells for {hours} hours.')

class Secretary(SalaryEmployee):
    def work(self, hours):
        print(f'{self.name} expends {hours} hours doing office paperwork.')

class SalesPerson(CommissionEmployee):
    def work(self, hours):
        print(f'{self.name} expends {hours} hours on the phone.')

class FactoryWorker(HourlyEmployee):
    def work(self, hours):
        print(f'{self.name} manufactures gadgets for {hours} hours.')

Сначала добавили класс Manager, производный от SalaryEmployee. Класс предоставляет метод work(), который будет использоваться системой производительности. Метод принимает часы, которые отработал сотрудник.

Затем мы добавили Secretary, SalesPerson и FactoryWorker, а затем реализовали интерфейс work(), чтобы они могли использоваться системой производительности.

Теперь можно добавить класс ProductivitySytem:

# In productivity.py

class ProductivitySystem:
    def track(self, employees, hours):
        print('Tracking Employee Productivity')
        print('==============================')
        for employee in employees:
            employee.work(hours)
        print('')

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

# In program.py

import hr
import employees
import productivity

manager = employees.Manager(1, 'Mary Poppins', 3000)
secretary = employees.Secretary(2, 'John Smith', 1500)
sales_guy = employees.SalesPerson(3, 'Kevin Bacon', 1000, 250)
factory_worker = employees.FactoryWorker(2, 'Jane Doe', 40, 15)
employees = [
    manager,
    secretary,
    sales_guy,
    factory_worker,
]
productivity_system = productivity.ProductivitySystem()
productivity_system.track(employees, 40)
payroll_system = hr.PayrollSystem()
payroll_system.calculate_payroll(employees)

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

Запустим программу, чтобы увидеть результат:

$ python program.py

Tracking Employee Productivity
==============================
Mary Poppins screams and yells for 40 hours.
John Smith expends 40 hours doing office paperwork.
Kevin Bacon expends 40 hours on the phone.
Jane Doe manufactures gadgets for 40 hours.

Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000

Payroll for: 2 - John Smith
- Check amount: 1500

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Payroll for: 4 - Jane Doe
- Check amount: 600

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

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

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

источник: https://files.realpython.com/media/ic-class-explosion.jpg

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

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

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

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

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

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

Допустим что, иногда нанимают временных секретарей, когда слишком много бумажной работы. Класс TemporarySecretary выполняет роль секретаря в контексте ProductivitySystem, но для целей расчета заработной платы используем HourlyEmployee.

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

  1. Создать на основе Secretary: Можно наследоваться от Secretary, чтобы наследовать метод .work() для роли, а затем переопределить метод .calculate_payroll(), чтобы реализовать его как HourlyEmployee.
  2. Создать на основе HourlyEmployee: Можно наследоваться от HourlyEmployee для наследования метода .calculate_payroll(), а затем переопределить метод .work() для реализации его в качестве Secretary.

Затем мы вспоминаем, что Python поддерживает множественное наследование, поэтому мы решаем наследоваться как от Secretary, так и от HourlyEmployee:

# In employees.py

class TemporarySecretary(Secretary, HourlyEmployee):
    pass

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

Теперь мы изменим свою программу, добавив нового временного сотрудника секретаря:

import hr
import employees
import productivity

manager = employees.Manager(1, 'Mary Poppins', 3000)
secretary = employees.Secretary(2, 'John Smith', 1500)
sales_guy = employees.SalesPerson(3, 'Kevin Bacon', 1000, 250)
factory_worker = employees.FactoryWorker(4, 'Jane Doe', 40, 15)
temporary_secretary = employees.TemporarySecretary(5, 'Robin Williams', 40, 9)
company_employees = [
    manager,
    secretary,
    sales_guy,
    factory_worker,
    temporary_secretary,
]
productivity_system = productivity.ProductivitySystem()
productivity_system.track(company_employees, 40)
payroll_system = hr.PayrollSystem()
payroll_system.calculate_payroll(company_employees)

Запустим программу для проверки:

$ python program.py

Traceback (most recent call last):
 File ".\program.py", line 9, in <module>
  temporary_secretary = employee.TemporarySecretary(5, 'Robin Williams', 40, 9)
TypeError: __init__() takes 4 positional arguments but 5 were given

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

Это потому, что вы наследовали TemporarySecretary сначала от Secretary, а затем от HourlyEmployee, поэтому интерпретатор пытается использовать Secretary.__init__() для инициализации объекта.

Хорошо, давайте изменим это:

class TemporarySecretary(HourlyEmployee, Secretary):
    pass

Теперь если снова запустить программу мы получим следующее:

$ python program.py

Traceback (most recent call last):
 File ".\program.py", line 9, in <module>
  temporary_secretary = employee.TemporarySecretary(5, 'Robin Williams', 40, 9)
 File "employee.py", line 16, in __init__
  super().__init__(id, name)
TypeError: __init__() missing 1 required positional argument: 'weekly_salary'

Теперь кажется, что нам не хватает параметра weekly_salary, который необходим для инициализации Secretary, но этот параметр не имеет смысла в контексте TemporarySecretary, потому что это HourlyEmployee.

Может быть, реализация TemporarySecretary.__init__() поможет:

# In employees.py

class TemporarySecretary(HourlyEmployee, Secretary):
    def __init__(self, id, name, hours_worked, hour_rate):
        super().__init__(id, name, hours_worked, hour_rate)

Попробуем:

$ python program.py

Traceback (most recent call last):
 File ".\program.py", line 9, in <module>
  temporary_secretary = employee.TemporarySecretary(5, 'Robin Williams', 40, 9)
 File "employee.py", line 54, in __init__
  super().__init__(id, name, hours_worked, hour_rate)
 File "employee.py", line 16, in __init__
  super().__init__(id, name)
TypeError: __init__() missing 1 required positional argument: 'weekly_salary'

Это тоже не сработало. Хорошо, пришло время погрузиться в порядок разрешения методов Python (MRO – method resolution order), чтобы понять, что происходит.

При обращении к методу или атрибуту класса Python использует MRO, чтобы найти его. MRO также используется super() для определения, какой метод или атрибут вызывать. Вы можете узнать больше о super() в статье Supercharge Your Classes With Python super().

Вы можете посмотреть как работает MRO класса TemporarySecretary с помощью интерактивного интерпритатора:

>>> from employees import TemporarySecretary
>>> TemporarySecretary.__mro__

(<class 'employees.TemporarySecretary'>,
 <class 'employees.HourlyEmployee'>,
 <class 'employees.Secretary'>,
 <class 'employees.SalaryEmployee'>,
 <class 'employees.Employee'>,
 <class 'object'>
)

MRO показывает порядок, в котором Python будет искать соответствующий атрибут или метод. В нашем примере когда мы создаем объект TemporarySecretary происходит следующее:

  1. Вызывается метод TemporarySecretary.__init__(self, id, name, hours_worked, hour_rate).
  2. Вызов super().__init__(id, name, hours_worked, hour_rate) соответствует HourlyEmployee.__init__(self, id, name, hour_worked, hour_rate)
  3. HourlyEmployee вызывает super().__init__(id, name), который MRO собирается сопоставить с Secretary.__init__(), который унаследован от SalaryEmployee.__init__(self, id, name, weekly_salary)

Поскольку параметры не совпадают, возникает исключение TypeError.

Вы можете обойти MRO, изменив порядок наследования и напрямую вызвав HourlyEmployee.__init__() следующим образом:

class TemporarySecretary(Secretary, HourlyEmployee):
    def __init__(self, id, name, hours_worked, hour_rate):
        HourlyEmployee.__init__(self, id, name, hours_worked, hour_rate)

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

$ python program.py

Tracking Employee Productivity
==============================
Mary Poppins screams and yells for 40 hours.
John Smith expends 40 hours doing office paperwork.
Kevin Bacon expends 40 hours on the phone.
Jane Doe manufactures gadgets for 40 hours.
Robin Williams expends 40 hours doing office paperwork.

Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000

Payroll for: 2 - John Smith
- Check amount: 1500

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Payroll for: 4 - Jane Doe
- Check amount: 600

Payroll for: 5 - Robin Williams
Traceback (most recent call last):
  File ".\program.py", line 20, in <module>
    payroll_system.calculate_payroll(employees)
  File "hr.py", line 7, in calculate_payroll
    print(f'- Check amount: {employee.calculate_payroll()}')
  File "employee.py", line 12, in calculate_payroll
    return self.weekly_salary
AttributeError: 'TemporarySecretary' object has no attribute 'weekly_salary'

Теперь проблема в том, что, поскольку мы изменили порядок наследования, MRO находит метод .calculate_payroll() в SalariedEmployee перед тем, как дойдет до HourlyEmployee. Теперь нужно переопределить .calculate_payroll() в TemporarySecretary и вызвать из него правильную реализацию:

class TemporarySecretary(Secretary, HourlyEmployee):
    def __init__(self, id, name, hours_worked, hour_rate):
        HourlyEmployee.__init__(self, id, name, hours_worked, hour_rate)

    def calculate_payroll(self):
        return HourlyEmployee.calculate_payroll(self)

Метод calculate_payroll() напрямую вызывает HourlyEmployee.calculate_payroll(), чтобы гарантировать, что вы получите правильный результат. Вы можете снова запустить программу, чтобы увидеть, как теперь она будет работает:

$ python program.py

Tracking Employee Productivity
==============================
Mary Poppins screams and yells for 40 hours.
John Smith expends 40 hours doing office paperwork.
Kevin Bacon expends 40 hours on the phone.
Jane Doe manufactures gadgets for 40 hours.
Robin Williams expends 40 hours doing office paperwork.

Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000

Payroll for: 2 - John Smith
- Check amount: 1500

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Payroll for: 4 - Jane Doe
- Check amount: 600

Payroll for: 5 - Robin Williams
- Check amount: 360

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

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

Следующая диаграмма показывает проблему алмазов в вашей иерархии классов:

источник: https://files.realpython.com/media/ic-diamond-problem.jpg

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

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

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

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

Производные классы Employee используются в двух разных системах:

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

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

# In productivity.py

class ProductivitySystem:
    def track(self, employees, hours):
        print('Tracking Employee Productivity')
        print('==============================')
        for employee in employees:
            result = employee.work(hours)
            print(f'{employee.name}: {result}')
        print('')

class ManagerRole:
    def work(self, hours):
        return f'screams and yells for {hours} hours.'

class SecretaryRole:
    def work(self, hours):
        return f'expends {hours} hours doing office paperwork.'

class SalesRole:
    def work(self, hours):
        return f'expends {hours} hours on the phone.'

class FactoryRole:
    def work(self, hours):
        return f'manufactures gadgets for {hours} hours.'

Модуль производительности реализует класс ProductivitySystem, а также связанные с ним роли. Классы реализуют интерфейс work(), требуемый системой, но они не получены от Employee.

Далее сделаем то же самое с модулем hr:

# In hr.py

class PayrollSystem:
    def calculate_payroll(self, employees):
        print('Calculating Payroll')
        print('===================')
        for employee in employees:
            print(f'Payroll for: {employee.id} - {employee.name}')
            print(f'- Check amount: {employee.calculate_payroll()}')
            print('')

class SalaryPolicy:
    def __init__(self, weekly_salary):
        self.weekly_salary = weekly_salary

    def calculate_payroll(self):
        return self.weekly_salary

class HourlyPolicy:
    def __init__(self, hours_worked, hour_rate):
        self.hours_worked = hours_worked
        self.hour_rate = hour_rate

    def calculate_payroll(self):
        return self.hours_worked * self.hour_rate

class CommissionPolicy(SalaryPolicy):
    def __init__(self, weekly_salary, commission):
        super().__init__(weekly_salary)
        self.commission = commission

    def calculate_payroll(self):
        fixed = super().calculate_payroll()
        return fixed + self.commission

Модуль hr реализует систему PayrollSystem, которая рассчитывает заработную плату для сотрудников. Он также реализует классы политики для расчета заработной платы. Как видите, классы политики больше не являются производными от Employee.

Теперь добавим необходимые классы в модуль employee:

# In employees.py

from hr import (
    SalaryPolicy,
    CommissionPolicy,
    HourlyPolicy
)
from productivity import (
    ManagerRole,
    SecretaryRole,
    SalesRole,
    FactoryRole
)

class Employee:
    def __init__(self, id, name):
        self.id = id
        self.name = name

class Manager(Employee, ManagerRole, SalaryPolicy):
    def __init__(self, id, name, weekly_salary):
        SalaryPolicy.__init__(self, weekly_salary)
        super().__init__(id, name)

class Secretary(Employee, SecretaryRole, SalaryPolicy):
    def __init__(self, id, name, weekly_salary):
        SalaryPolicy.__init__(self, weekly_salary)
        super().__init__(id, name)

class SalesPerson(Employee, SalesRole, CommissionPolicy):
    def __init__(self, id, name, weekly_salary, commission):
        CommissionPolicy.__init__(self, weekly_salary, commission)
        super().__init__(id, name)

class FactoryWorker(Employee, FactoryRole, HourlyPolicy):
    def __init__(self, id, name, hours_worked, hour_rate):
        HourlyPolicy.__init__(self, hours_worked, hour_rate)
        super().__init__(id, name)

class TemporarySecretary(Employee, SecretaryRole, HourlyPolicy):
    def __init__(self, id, name, hours_worked, hour_rate):
        HourlyPolicy.__init__(self, hours_worked, hour_rate)
        super().__init__(id, name)

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

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

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

Вот UML диаграмма для нового дизайна:

источник: https://files.realpython.com/media/ic-inheritance-policies.jpg

На диаграмме показаны отношения для определения Secretary и TemporarySecretary с использованием множественного наследования, но без проблемы с алмазом.

Запустим программу и посмотрим, как она работает:

$ python program.py

Tracking Employee Productivity
==============================
Mary Poppins: screams and yells for 40 hours.
John Smith: expends 40 hours doing office paperwork.
Kevin Bacon: expends 40 hours on the phone.
Jane Doe: manufactures gadgets for 40 hours.
Robin Williams: expends 40 hours doing office paperwork.

Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000

Payroll for: 2 - John Smith
- Check amount: 1500

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Payroll for: 4 - Jane Doe
- Check amount: 600

Payroll for: 5 - Robin Williams
- Check amount: 360

Вы видели, как наследование и множественное наследование работают в Python. Теперь исследуем тему композиции.

Композиция в Python

Композиция – это объектно-ориентированная концепция дизайна, которая моделирует отношения. В композиции класс, известный как составной, содержит объект другого класса, известный как компонент. Другими словами, составной класс имеет компонент другого класса.

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

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

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

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

Мы уже использовали композицию в наших примерах. Если вы посмотрите на класс Employee, вы увидите, что он содержит два атрибута:

  1. id для идентификации сотрудника.
  2. name, содержащее имя сотрудника.

Эти два атрибута являются объектами, которые есть у класса Employee. Следовательно, вы можете сказать, что у Employee есть id и name.

Другим атрибутом для Employee может быть Address:

# In contacts.py

class Address:
    def __init__(self, street, city, state, zipcode, street2=''):
        self.street = street
        self.street2 = street2
        self.city = city
        self.state = state
        self.zipcode = zipcode

    def __str__(self):
        lines = [self.street]
        if self.street2:
            lines.append(self.street2)
        lines.append(f'{self.city}, {self.state} {self.zipcode}')
        return '\n'.join(lines)

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

Реализовали __str__(), чтобы обеспечить красивое представление Address. Можно увидеть эту реализацию в интерактивном интерпритаторе:

>>> from contacts import Address
>>> address = Address('55 Main St.', 'Concord', 'NH', '03301')
>>> print(address)

55 Main St.
Concord, NH 03301

Когда вы выводите на экран переменную address, вызывается специальный метод __str__(). Поскольку мы перегрузили метод, чтобы вернуть строку, отформатированную как адрес, мы получили хорошее, читаемое представление. Статья Operator and Function Overloading in Custom Python Classes (Перегрузка операторов и функций в пользовательских классах Python) дает хороший обзор специальных методов, доступных в классах, которые могут быть реализованы для настройки поведения ваших объектов.

Теперь можно добавить Address в класс Employee с помощью композиции:

# In employees.py

class Employee:
    def __init__(self, id, name):
        self.id = id
        self.name = name
        self.address = None

Мы инициализируем атрибут address значением None на данный момент, чтобы сделать его необязательным, но, сделав это, теперь можно назначить Address для Employee. Также обратите внимание, что в модуле employee нет ссылки на модуль contacts.

Композиция – это слабо связанные отношения, которые часто не требуют, чтобы составной класс обладал знаниями о компоненте.

Диаграмма UML, представляющая отношения между Employee и Address, выглядит следующим образом:

источник: https://files.realpython.com/media/ic-employee-address.jpg

Диаграмма показывает базовую композиционную связь между Employee и Address.

Теперь можно изменить класс PayrollSystem, чтобы использовать атрибут address в Employee:

# In hr.py

class PayrollSystem:
    def calculate_payroll(self, employees):
        print('Calculating Payroll')
        print('===================')
        for employee in employees:
            print(f'Payroll for: {employee.id} - {employee.name}')
            print(f'- Check amount: {employee.calculate_payroll()}')
            if employee.address:
                print('- Sent to:')
                print(employee.address)
            print('')

Здесь мы проверяем, есть ли у объекта employee адрес, и если он есть, печатаем его. Теперь можно изменить программу, чтобы назначить некоторые адреса сотрудникам:

# In program.py

import hr
import employees
import productivity
import contacts

manager = employees.Manager(1, 'Mary Poppins', 3000)
manager.address = contacts.Address(
    '121 Admin Rd', 
    'Concord', 
    'NH', 
    '03301'
)
secretary = employees.Secretary(2, 'John Smith', 1500)
secretary.address = contacts.Address(
    '67 Paperwork Ave.', 
    'Manchester', 
    'NH', 
    '03101'
)
sales_guy = employees.SalesPerson(3, 'Kevin Bacon', 1000, 250)
factory_worker = employees.FactoryWorker(4, 'Jane Doe', 40, 15)
temporary_secretary = employees.TemporarySecretary(5, 'Robin Williams', 40, 9)
employees = [
    manager,
    secretary,
    sales_guy,
    factory_worker,
    temporary_secretary,
]
productivity_system = productivity.ProductivitySystem()
productivity_system.track(employees, 40)
payroll_system = hr.PayrollSystem()
payroll_system.calculate_payroll(employees)

Мы добавили пару адресов к объектам manager и secretary. Когда вы запустите программу, то увидите напечатанные адреса:

$ python program.py

Tracking Employee Productivity
==============================
Mary Poppins: screams and yells for {hours} hours.
John Smith: expends {hours} hours doing office paperwork.
Kevin Bacon: expends {hours} hours on the phone.
Jane Doe: manufactures gadgets for {hours} hours.
Robin Williams: expends {hours} hours doing office paperwork.

Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000
- Sent to:
121 Admin Rd
Concord, NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500
- Sent to:
67 Paperwork Ave.
Manchester, NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1250

Payroll for: 4 - Jane Doe
- Check amount: 600

Payroll for: 5 - Robin Williams
- Check amount: 360

Обратите внимание, как выходные данные расчета заработной платы для объектов manager и secretary отображают адреса, на которые были отправлены чеки.

Класс Employee использует реализацию класса Address без знания того, что такое объект Address или как он представлен. Этот тип дизайна настолько гибок, что можно изменить класс Address без какого-либо влияния на класс Employee.

Гибкие конструкции с композицией

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

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

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

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

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

Начнем с реализации системы ProductivitySystem:

# In productivity.py

class ProductivitySystem:
    def __init__(self):
        self._roles = {
            'manager': ManagerRole,
            'secretary': SecretaryRole,
            'sales': SalesRole,
            'factory': FactoryRole,
        }

    def get_role(self, role_id):
        role_type = self._roles.get(role_id)
        if not role_type:
            raise ValueError('role_id')
        return role_type()

    def track(self, employees, hours):
        print('Tracking Employee Productivity')
        print('==============================')
        for employee in employees:
            employee.work(hours)
        print('')

Класс ProductivitySystem определяет некоторые роли, используя строковый идентификатор, сопоставленный с классом роли, который реализует роль. Он предоставляет метод .get_role(), который при наличии идентификатора роли возвращает объект типа роли. Если роль не найдена, возникает исключение ValueError.

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

Теперь реализуем различные классы ролей:

# In productivity.py

class ManagerRole:
    def perform_duties(self, hours):
        return f'screams and yells for {hours} hours.'

class SecretaryRole:
    def perform_duties(self, hours):
        return f'does paperwork for {hours} hours.'

class SalesRole:
    def perform_duties(self, hours):
        return f'expends {hours} hours on the phone.'

class FactoryRole:
    def perform_duties(self, hours):
        return f'manufactures gadgets for {hours} hours.'

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

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

Теперь можно реализовать PayrollSystem для приложения:

# In hr.py

class PayrollSystem:
    def __init__(self):
        self._employee_policies = {
            1: SalaryPolicy(3000),
            2: SalaryPolicy(1500),
            3: CommissionPolicy(1000, 100),
            4: HourlyPolicy(15),
            5: HourlyPolicy(9)
        }

    def get_policy(self, employee_id):
        policy = self._employee_policies.get(employee_id)
        if not policy:
            return ValueError(employee_id)
        return policy

    def calculate_payroll(self, employees):
        print('Calculating Payroll')
        print('===================')
        for employee in employees:
            print(f'Payroll for: {employee.id} - {employee.name}')
            print(f'- Check amount: {employee.calculate_payroll()}')
            if employee.address:
                print('- Sent to:')
                print(employee.address)
            print('')

Система PayrollSystem ведет внутреннюю базу данных политик расчета заработной платы для каждого сотрудника. Она предоставляет функцию .get_policy(), которая, учитывая идентификатор сотрудника, возвращает свою политику расчета заработной платы. Если указанный идентификатор не существует в системе, метод вызывает исключение ValueError.

Реализация .calculate_payroll() работает так же, как и раньше. Он берет список сотрудников, рассчитывает фонд заработной платы и печатает результаты.

Теперь можно реализовать классы политики заработной платы:

# In hr.py

class PayrollPolicy:
    def __init__(self):
        self.hours_worked = 0

    def track_work(self, hours):
        self.hours_worked += hours

class SalaryPolicy(PayrollPolicy):
    def __init__(self, weekly_salary):
        super().__init__()
        self.weekly_salary = weekly_salary

    def calculate_payroll(self):
        return self.weekly_salary

class HourlyPolicy(PayrollPolicy):
    def __init__(self, hour_rate):
        super().__init__()
        self.hour_rate = hour_rate

    def calculate_payroll(self):
        return self.hours_worked * self.hour_rate

class CommissionPolicy(SalaryPolicy):
    def __init__(self, weekly_salary, commission_per_sale):
        super().__init__(weekly_salary)
        self.commission_per_sale = commission_per_sale

    @property
    def commission(self):
        sales = self.hours_worked / 5
        return sales * self.commission_per_sale

    def calculate_payroll(self):
        fixed = super().calculate_payroll()
        return fixed + self.commission

Сначала реализуется класс PayrollPolicy, который служит базовым классом для всех политик расчета заработной платы. Этот класс отслеживает часы работы hours_worked, которые являются общими для всех политик расчета заработной платы.

Другие классы политик являются производными от PayrollPolicy. Мы используем наследование здесь, потому что мы хотим использовать реализацию PayrollPolicy. Кроме того, SalaryPolicy, HourlyPolicy и CommissionPolicy являются PayrollPolicy.

SalaryPolicy инициализируется значением weekly_salary, которое затем используется в .calculate_payroll(). HourlyPolicy инициализируется с помощью hour_rate и реализует .calculate_payroll(), используя базовый класс hours_worked.

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

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

CommissionPolicy реализует метод .calculate_payroll(), сначала используя реализацию в SalaryPolicy, а затем добавляя рассчитанную комиссию.

Теперь вы можете добавить класс AddressBook для управления адресами сотрудников:

# In contacts.py

class AddressBook:
    def __init__(self):
        self._employee_addresses = {
            1: Address('121 Admin Rd.', 'Concord', 'NH', '03301'),
            2: Address('67 Paperwork Ave', 'Manchester', 'NH', '03101'),
            3: Address('15 Rose St', 'Concord', 'NH', '03301', 'Apt. B-1'),
            4: Address('39 Sole St.', 'Concord', 'NH', '03301'),
            5: Address('99 Mountain Rd.', 'Concord', 'NH', '03301'),
        }

    def get_employee_address(self, employee_id):
        address = self._employee_addresses.get(employee_id)
        if not address:
            raise ValueError(employee_id)
        return address

Класс AddressBook хранит внутреннюю базу данных объектов Address для каждого сотрудника. Он предоставляет метод get_employee_address(), который возвращает адрес указанного идентификатора сотрудника. Если идентификатор сотрудника не существует, то возникает ошибка ValueError.

Реализация класса Address остается такой же, как и раньше:

# In contacts.py

class Address:
    def __init__(self, street, city, state, zipcode, street2=''):
        self.street = street
        self.street2 = street2
        self.city = city
        self.state = state
        self.zipcode = zipcode

    def __str__(self):
        lines = [self.street]
        if self.street2:
            lines.append(self.street2)
        lines.append(f'{self.city}, {self.state} {self.zipcode}')
        return '\n'.join(lines)

Класс управляет компонентами адреса и обеспечивает красивое представление адреса.

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

Далее реализуем класс EmployeeDatabase:

# In employees.py

from productivity import ProductivitySystem
from hr import PayrollSystem
from contacts import AddressBook

class EmployeeDatabase:
    def __init__(self):
        self._employees = [
            {
                'id': 1,
                'name': 'Mary Poppins',
                'role': 'manager'
            },
            {
                'id': 2,
                'name': 'John Smith',
                'role': 'secretary'
            },
            {
                'id': 3,
                'name': 'Kevin Bacon',
                'role': 'sales'
            },
            {
                'id': 4,
                'name': 'Jane Doe',
                'role': 'factory'
            },
            {
                'id': 5,
                'name': 'Robin Williams',
                'role': 'secretary'
            },
        ]
        self.productivity = ProductivitySystem()
        self.payroll = PayrollSystem()
        self.employee_addresses = AddressBook()

    @property
    def employees(self):
        return [self._create_employee(**data) for data in self._employees]

    def _create_employee(self, id, name, role):
        address = self.employee_addresses.get_employee_address(id)
        employee_role = self.productivity.get_role(role)
        payroll_policy = self.payroll.get_policy(id)
        return Employee(id, name, address, employee_role, payroll_policy)

EmployeeDatabase отслеживает всех сотрудников компании. Для каждого сотрудника он отслеживает id, name и role. У него есть экземпляр ProductivitySystem, PayrollSystem и AddressBook. Эти экземпляры используются для создания сотрудников.

Он содержит свойство .employees, которое возвращает список сотрудников. Объекты Employee создаются во внутреннем методе ._create_employee(). Обратите внимание, что у нас нет разных типов классов Employee. Нам просто нужно реализовать один класс Employee:

# In employees.py

class Employee:
    def __init__(self, id, name, address, role, payroll):
        self.id = id
        self.name = name
        self.address = address
        self.role = role
        self.payroll = payroll

    def work(self, hours):
        duties = self.role.perform_duties(hours)
        print(f'Employee {self.id} - {self.name}:')
        print(f'- {duties}')
        print('')
        self.payroll.track_work(hours)

    def calculate_payroll(self):
        return self.payroll.calculate_payroll()

Класс Employee инициализируется атрибутами id, name и address. Он также требует роли производительности для работника role и политики оплаты труда payroll.

Класс предоставляет метод .work(), который принимает отработанные часы. Этот метод сначала извлекает обязанности duties из роли role. Другими словами, он делегирует объекту role выполнения своих обязанностей.

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

Следующая диаграмма показывает используемый состав композиции:

источник: https://files.realpython.com/media/ic-policy-based-composition.jpg

На диаграмме показан дизайн композиционных политик. Существует один Employee, который состоит из других объектов данных, таких как Address, и зависит от интерфейсов IRole и IPayrollCalculator для делегирования работы. Существует несколько реализаций этих интерфейсов.

Теперь можно использовать этот дизайн в нашей программе:

# In program.py

from hr import PayrollSystem
from productivity import ProductivitySystem
from employees import EmployeeDatabase

productivity_system = ProductivitySystem()
payroll_system = PayrollSystem()
employee_database = EmployeeDatabase()
employees = employee_database.employees
productivity_system.track(employees, 40)
payroll_system.calculate_payroll(employees)

Можно запустить программу, чтобы увидеть ее вывод:

$ python program.py

Tracking Employee Productivity
==============================
Employee 1 - Mary Poppins:
- screams and yells for 40 hours.

Employee 2 - John Smith:
- does paperwork for 40 hours.

Employee 3 - Kevin Bacon:
- expends 40 hours on the phone.

Employee 4 - Jane Doe:
- manufactures gadgets for 40 hours.

Employee 5 - Robin Williams:
- does paperwork for 40 hours.


Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000
- Sent to:
121 Admin Rd.
Concord, NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500
- Sent to:
67 Paperwork Ave
Manchester, NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1800.0
- Sent to:
15 Rose St
Apt. B-1
Concord, NH 03301

Payroll for: 4 - Jane Doe
- Check amount: 600
- Sent to:
39 Sole St.
Concord, NH 03301

Payroll for: 5 - Robin Williams
- Check amount: 360
- Sent to:
99 Mountain Rd.
Concord, NH 03301

Этот дизайн называется таковым, основанным на политиках (policy-based design), где классы состоят из политик, и они делегируют этим политикам работу.

Дизайн на основе политик был представлен в книге «Современный дизайн C ++» (Modern C++ Design), и для достижения результатов в нем используется шаблонное метапрограммирование в C ++.

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

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

Настройка поведения с композицией

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

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

# In program.py

from hr import PayrollSystem, HourlyPolicy
from productivity import ProductivitySystem
from employees import EmployeeDatabase

productivity_system = ProductivitySystem()
payroll_system = PayrollSystem()
employee_database = EmployeeDatabase()
employees = employee_database.employees
manager = employees[0]
manager.payroll = HourlyPolicy(55)

productivity_system.track(employees, 40)
payroll_system.calculate_payroll(employees)

Программа получает список сотрудников из EmployeeDatabase и извлекает первого сотрудника, который нам нужен. Затем она создает новый HourlyPolicy, инициализированный с 55 долларов в час, и назначает его объекту менеджера.

Теперь используется новая политика в PayrollSystem, изменяя существующее поведение. Можно снова запустить программу, чтобы увидеть результат:

$ python program.py

Tracking Employee Productivity
==============================
Employee 1 - Mary Poppins:
- screams and yells for 40 hours.

Employee 2 - John Smith:
- does paperwork for 40 hours.

Employee 3 - Kevin Bacon:
- expends 40 hours on the phone.

Employee 4 - Jane Doe:
- manufactures gadgets for 40 hours.

Employee 5 - Robin Williams:
- does paperwork for 40 hours.


Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 2200
- Sent to:
121 Admin Rd.
Concord, NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500
- Sent to:
67 Paperwork Ave
Manchester, NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1800.0
- Sent to:
15 Rose St
Apt. B-1
Concord, NH 03301

Payroll for: 4 - Jane Doe
- Check amount: 600
- Sent to:
39 Sole St.
Concord, NH 03301

Payroll for: 5 - Robin Williams
- Check amount: 360
- Sent to:
99 Mountain Rd.
Concord, NH 03301

Чек на Мэри Поппинс, нашего менеджера, теперь стоит 2200 долларов вместо фиксированного оклада в 3000 долларов, который она получала в неделю.

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

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

Выбор между наследованием и композицией в Python

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

Мы реализовали два решения одной и той же проблемы. Первое решение использовало множественное наследование, а второе – композицию.

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

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

Общий совет – использовать отношения, которые создают меньше зависимостей между двумя классами. Эти отношение является композицией. Тем не менее, будут случаи, когда наследование будет иметь больше смысла.

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

Наследование модели A

Наследование должно использоваться только для моделирования отношений. Принцип подстановки Лисков гласит, что объект типа Derived, который наследуется от Base, может заменить объект типа Base без изменения свойств программы.

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

Допустим, есть класс A, который предоставляет реализацию и интерфейс, который вы хотите повторно использовать в другом классе B. Ваша первоначальная мысль должна заключаться в том, что можно ли извлечь B из A и наследовать как интерфейс, так и реализацию. Чтобы убедиться, что это правильный дизайн, выполните следующие шаги:

  1. Оцените B – это A: Подумайте об этих отношениях и обоснуйте их. Имеет ли это смысл?
  2. Оцените A – это B: поменяйте отношения и оцените результат. Имеет ли это также смысл?

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

Допустим есть класс Rectangle, который предоставляет свойство .area. Вам нужен класс Square, который также имеет .area. Кажется, что Square – это особый тип Rectangle, поэтому, возможно, вы можете от наследоваться от него и использовать как интерфейс, так и реализацию.

Прежде чем перейти к реализации, используйте принцип подстановки Лисков для оценки взаимосвязи.

Square – это Rectangle, потому что его площадь рассчитывается как произведение его высоты height на его длину length. Ограничение состоит в том, что Square.height и Square.length должны быть равны.

Это имеет смысл. Вы можете обосновать отношения и объяснить, почему Square – это Rectangle. Давайте изменим отношения, чтобы проверить, имеет ли это смысл.

Rectangle – это Square, потому что его площадь рассчитывается как произведение его высоты на его длину. Разница в том, что Rectangle.height и Rectangle.width могут изменяться независимо.

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

Возможно, вы знаете другие примеры, которые выводят Square из Rectangle для объяснения наследования. Вы можете скептически относиться к этому небольшому тесту, который вы только что сделали. И это будет справедливо. Давайте напишем программу, которая иллюстрирует проблему с наследуемым Square от Rectangle.

Сначала реализуем Rectangle. Инкапсулируем атрибуты, чтобы обеспечить соблюдение всех ограничений:

# In rectangle_square_demo.py

class Rectangle:
    def __init__(self, length, height):
        self._length = length
        self._height = height

    @property
    def area(self):
        return self._length * self._height

Класс Rectangle инициализируется длиной length и высотой height и предоставляет свойство .area, которое возвращает область.

Теперь наследуем Square от Rectangle и переопределим необходимый интерфейс для соответствия ограничениям Square:

# In rectangle_square_demo.py

class Square(Rectangle):
    def __init__(self, side_size):
        super().__init__(side_size, side_size)

Класс Square инициализируется с параметром side_size, который используется для инициализации обоих компонентов базового класса. Теперь напишем небольшую программу для проверки поведения:

# In rectangle_square_demo.py

rectangle = Rectangle(2, 4)
assert rectangle.area == 8

square = Square(2)
assert square.area == 4

print('OK!')

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

Предположим позже нам нужно будет добавим метод изменение размеров объектов Rectangle resize, какие бы вы сделали изменения в классе:

# In rectangle_square_demo.py

class Rectangle:
    def __init__(self, length, height):
        self._length = length
        self._height = height

    @property
    def area(self):
        return self._length * self._height

    def resize(self, new_length, new_height):
        self._length = new_length
        self._height = new_height

.resize() принимает new_length и new_width для объекта. Запустим программу, чтобы убедиться, что она работает правильно:

# In rectangle_square_demo.py

rectangle.resize(3, 5)
assert rectangle.area == 15

print('OK!')

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

$ python rectangle_square_demo.py

OK!

Утверждение проходит, и мы видим, что программа работает правильно.

Итак, что произойдет, если мы поменяем размер квадрата? Изменим программу и попробуем изменить объект square:

# In rectangle_square_demo.py

square.resize(3, 5)
print(f'Square area: {square.area}')

Тут мы передаем в square.resize() те же параметры, которые мы использовали с прямоугольником, и отображаем площадь. Когда запустим программу, мы увидим:

$ python rectangle_square_demo.py

Square area: 15
OK!

Программа показывает, что новая площадь 15 похожа на прямоугольный объект. Проблема в том, что квадратный объект больше не соответствует ограничению класса Square, что length и height должны быть равны.

Как мы можем решить эту проблему? Можно попробовать несколько подходов, но все они будут неудобными. Можно переопределить .resize() в квадрате и игнорировать параметр height, но это будет сбивать с толку тех, кто смотрит на другие части программы, где размеры прямоугольников изменяются, и некоторые из них не получают ожидаемые площади, потому что они действительно squares.

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

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

В этом примере не имеет смысла, что Square наследует интерфейс и реализацию .resize() от Rectangle. Это не означает, что объекты Square не могут быть изменены. Это означает, что интерфейс отличается, потому что ему нужен только параметр side_size.

Это различие в интерфейсе объясняет почему не нужно получать Square от Rectangle, как рекомендовано в тесте выше.

Смешивание функций с классами Mixin

Одним из применений множественного наследования в Python является расширение возможностей класса с помощью миксинов (mixins). Mixin – это класс, который предоставляет методы другим классам, но не считается базовым классом.

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

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

Это может быть хорошим кандидатом на миксин. Начнем с небольшого изменения класса Employee из примера композиции:

# In employees.py

class Employee:
    def __init__(self, id, name, address, role, payroll):
        self.id = id
        self.name = name
        self.address = address
        self._role = role
        self._payroll = payroll


    def work(self, hours):
        duties = self._role.perform_duties(hours)
        print(f'Employee {self.id} - {self.name}:')
        print(f'- {duties}')
        print('')
        self._payroll.track_work(hours)

    def calculate_payroll(self):
        return self._payroll.calculate_payroll()

Изменений очень мало. Мы просто изменили role и payroll заработной платы, чтобы они стали внутренними, добавив к их имени начальное подчеркивание. Скоро вы поймете, почему мы сделали это изменение.

Теперь добавим класс AsDictionaryMixin:

# In representations.py

class AsDictionaryMixin:
    def to_dict(self):
        return {
            prop: self._represent(value)
            for prop, value in self.__dict__.items()
            if not self._is_internal(prop)
        }

    def _represent(self, value):
        if isinstance(value, object):
            if hasattr(value, 'to_dict'):
                return value.to_dict()
            else:
                return str(value)
        else:
            return value

    def _is_internal(self, prop):
        return prop.startswith('_')

Класс AsDictionaryMixin содержит метод .to_dict(), который возвращает представление себя в виде словаря.

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

Как было сказано в начале, новый класс наследует некоторые свои члены от object, и один из этих членов – метод __dict__, который в основном представляет собой отображение всех атрибутов объекта на их значение.

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

._represent() проверяет указанное значение. Если значение является объектом, то он проверяет, есть ли у него также член .to_dict(), и использует его для представления объекта. В противном случае он возвращает строковое представление. Если значение не является object, оно просто возвращает значение.

Изменим класс Employee для поддержки этого миксина:

# In employees.py

from representations import AsDictionaryMixin

class Employee(AsDictionaryMixin):
    def __init__(self, id, name, address, role, payroll):
        self.id = id
        self.name = name
        self.address = address
        self._role = role
        self._payroll = payroll

    def work(self, hours):
        duties = self._role.perform_duties(hours)
        print(f'Employee {self.id} - {self.name}:')
        print(f'- {duties}')
        print('')
        self._payroll.track_work(hours)

    def calculate_payroll(self):
        return self._payroll.calculate_payroll()

Все, что нам нужно сделать, это унаследоваться от AsDictionaryMixin для поддержки функциональности. Было бы неплохо поддерживать те же функции в классе Address, поэтому атрибут Employee.address создан таким же образом:

# In contacts.py

from representations import AsDictionaryMixin

class Address(AsDictionaryMixin):
    def __init__(self, street, city, state, zipcode, street2=''):
        self.street = street
        self.street2 = street2
        self.city = city
        self.state = state
        self.zipcode = zipcode

    def __str__(self):
        lines = [self.street]
        if self.street2:
            lines.append(self.street2)
        lines.append(f'{self.city}, {self.state} {self.zipcode}')
        return '\n'.join(lines)

Мы применяете миксин к классу Address для поддержки этой функциональности. Теперь мы можем написать небольшую программу для проверки:

 # In program.py

 import json
 from employees import EmployeeDatabase

 def print_dict(d):
    print(json.dumps(d, indent=2))

for employee in EmployeeDatabase().employees:
    print_dict(employee.to_dict())

В программе реализована функция print_dict(), которая преобразует словарь в строку JSON с использованием отступов, чтобы результат выглядел лучше.

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

 $ python program.py

 {
  "id": "1",
  "name": "Mary Poppins",
  "address": {
    "street": "121 Admin Rd.",
    "street2": "",
    "city": "Concord",
    "state": "NH",
    "zipcode": "03301"
  }
}
{
  "id": "2",
  "name": "John Smith",
  "address": {
    "street": "67 Paperwork Ave",
    "street2": "",
    "city": "Manchester",
    "state": "NH",
    "zipcode": "03101"
  }
}
{
  "id": "3",
  "name": "Kevin Bacon",
  "address": {
    "street": "15 Rose St",
    "street2": "Apt. B-1",
    "city": "Concord",
    "state": "NH",
    "zipcode": "03301"
  }
}
{
  "id": "4",
  "name": "Jane Doe",
  "address": {
    "street": "39 Sole St.",
    "street2": "",
    "city": "Concord",
    "state": "NH",
    "zipcode": "03301"
  }
}
{
  "id": "5",
  "name": "Robin Williams",
  "address": {
    "street": "99 Mountain Rd.",
    "street2": "",
    "city": "Concord",
    "state": "NH",
    "zipcode": "03301"
  }
}

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

Композиция к модели А

С композицией класс Composite имеет экземпляр класса Component и может использовать его реализацию. Класс Component может быть повторно использован в других классах, совершенно не связанных с Composite.

В приведенном выше примере композиции класс Employee имеет объект Address. Address реализует все функциональные возможности для обработки адресов, и он может быть использован другими классами.

Другие классы, такие как Customer или Vendor, могут повторно использовать Address без связи с Employee. Они могут использовать одну и ту же реализацию, гарантируя, что адреса обрабатываются последовательно во всем приложении.

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

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

Если вы посмотрите на реализацию класса EmployeeDatabase, вы заметите, что он использует ._create_employee() для создания объекта Employee с правильными параметрами.

Этот дизайн будет работать, но в идеале вы должны иметь возможность создать объект Employee, просто указав id, например employee = Employee (1).

Следующие изменения смогут улучшить наш дизайн. Вы можете начать с модуля productivity:

# In productivity.py

class _ProductivitySystem:
    def __init__(self):
        self._roles = {
            'manager': ManagerRole,
            'secretary': SecretaryRole,
            'sales': SalesRole,
            'factory': FactoryRole,
        }

    def get_role(self, role_id):
        role_type = self._roles.get(role_id)
        if not role_type:
            raise ValueError('role_id')
        return role_type()

    def track(self, employees, hours):
        print('Tracking Employee Productivity')
        print('==============================')
        for employee in employees:
            employee.work(hours)
        print('')

# Role classes implementation omitted

_productivity_system = _ProductivitySystem()

def get_role(role_id):
    return _productivity_system.get_role(role_id)

def track(employees, hours):
    _productivity_system.track(employees, hours)

Сначала мы сделали класс _ProductivitySystem внутренним, а затем создали внутреннюю переменную _productivity_system для модуля. Таким образом мы сообщаем другим разработчикам, что им не следует создавать или использовать _ProductivitySystem напрямую. Вместо этого мы создали две функции, get_role() и track(), как открытый интерфейс для модуля. Это то, что должны использовать другие модули.

По идее _ProductivitySystem – это Singleton, и из него должен быть создан только один объект.

Теперь можно сделать то же самое с модулем hr:

# In hr.py

class _PayrollSystem:
    def __init__(self):
        self._employee_policies = {
            1: SalaryPolicy(3000),
            2: SalaryPolicy(1500),
            3: CommissionPolicy(1000, 100),
            4: HourlyPolicy(15),
            5: HourlyPolicy(9)
        }

    def get_policy(self, employee_id):
        policy = self._employee_policies.get(employee_id)
        if not policy:
            return ValueError(employee_id)
        return policy

    def calculate_payroll(self, employees):
        print('Calculating Payroll')
        print('===================')
        for employee in employees:
            print(f'Payroll for: {employee.id} - {employee.name}')
            print(f'- Check amount: {employee.calculate_payroll()}')
            if employee.address:
                print('- Sent to:')
                print(employee.address)
            print('')

# Policy classes implementation omitted

_payroll_system = _PayrollSystem()

def get_policy(employee_id):
    return _payroll_system.get_policy(employee_id)

def calculate_payroll(employees):
    _payroll_system.calculate_payroll(employees)

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

Теперь сделаем то же самое с модулем contacts:

# In contacts.py

class _AddressBook:
    def __init__(self):
        self._employee_addresses = {
            1: Address('121 Admin Rd.', 'Concord', 'NH', '03301'),
            2: Address('67 Paperwork Ave', 'Manchester', 'NH', '03101'),
            3: Address('15 Rose St', 'Concord', 'NH', '03301', 'Apt. B-1'),
            4: Address('39 Sole St.', 'Concord', 'NH', '03301'),
            5: Address('99 Mountain Rd.', 'Concord', 'NH', '03301'),
        }

    def get_employee_address(self, employee_id):
        address = self._employee_addresses.get(employee_id)
        if not address:
            raise ValueError(employee_id)
        return address

# Implementation of Address class omitted

_address_book = _AddressBook()

def get_employee_address(employee_id):
    return _address_book.get_employee_address(employee_id)

Везде мы подразумеваем, что должна быть только одна _AddressBook, одна _PayrollSystem и одна _ProductivitySystem. Опять же, этот шаблон проектирования называется шаблоном проектирования Singleton, который пригодится для классов, в которых должен быть только один единственный экземпляр.

Теперь можно работать над модулем сотрудников. Также сделаем Singleton из _EmployeeDatabase, и внесем некоторые дополнительные изменения:

# In employees.py

from productivity import get_role
from hr import get_policy
from contacts import get_employee_address
from representations import AsDictionaryMixin

class _EmployeeDatabase:
    def __init__(self):
        self._employees = {
            1: {
                'name': 'Mary Poppins',
                'role': 'manager'
            },
            2: {
                'name': 'John Smith',
                'role': 'secretary'
            },
            3: {
                'name': 'Kevin Bacon',
                'role': 'sales'
            },
            4: {
                'name': 'Jane Doe',
                'role': 'factory'
            },
            5: {
                'name': 'Robin Williams',
                'role': 'secretary'
            }
        }


    @property
    def employees(self):
        return [Employee(id_) for id_ in sorted(self._employees)]

    def get_employee_info(self, employee_id):
        info = self._employees.get(employee_id)
        if not info:
            raise ValueError(employee_id)
        return info

class Employee(AsDictionaryMixin):
    def __init__(self, id):
        self.id = id
        info = employee_database.get_employee_info(self.id)
        self.name = info.get('name')
        self.address = get_employee_address(self.id)
        self._role = get_role(info.get('role'))
        self._payroll = get_policy(self.id)

    def work(self, hours):
        duties = self._role.perform_duties(hours)
        print(f'Employee {self.id} - {self.name}:')
        print(f'- {duties}')
        print('')
        self._payroll.track_work(hours)

    def calculate_payroll(self):
        return self._payroll.calculate_payroll()


employee_database = _EmployeeDatabase()

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

Мы изменили атрибут _EmployeeDatabase._employees на словарь, в котором ключ – это идентификатор сотрудника, а значение – информация о сотруднике. Мы также создали метод .get_employee_info() для возврата информации для указанного сотрудника employee_id.

Свойство _EmployeeDatabase.employees теперь сортирует ключи для возврата сотрудников, отсортированных по их идентификатору. Мы заменили метод, создавший объекты Employee, на вызовы инициализатора Employee напрямую.

Класс Employee теперь инициализируется идентификатором и использует открытые функции, представленные в других модулях, для инициализации его атрибутов.

Теперь изменим программу, чтобы проверить изменения:

# In program.py

import json

from hr import calculate_payroll
from productivity import track
from employees import employee_database, Employee

def print_dict(d):
    print(json.dumps(d, indent=2))

employees = employee_database.employees

track(employees, 40)
calculate_payroll(employees)

temp_secretary = Employee(5)
print('Temporary Secretary:')
print_dict(temp_secretary.to_dict())

Мы импортируем соответствующие функции из модулей hr и performance, а также из employee_database и класса Employee. Программа стала чище, потому что мы выставили требуемый интерфейс и инкапсулировали способ доступа к объектам.

Обратите внимание, что теперь мы можем создать объект Employee напрямую, просто используя его идентификатор. Запустим программу, чтобы увидеть ее вывод:

$ python program.py

Tracking Employee Productivity
==============================
Employee 1 - Mary Poppins:
- screams and yells for 40 hours.

Employee 2 - John Smith:
- does paperwork for 40 hours.

Employee 3 - Kevin Bacon:
- expends 40 hours on the phone.

Employee 4 - Jane Doe:
- manufactures gadgets for 40 hours.

Employee 5 - Robin Williams:
- does paperwork for 40 hours.

Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000
- Sent to:
121 Admin Rd.
Concord, NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500
- Sent to:
67 Paperwork Ave
Manchester, NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1800.0
- Sent to:
15 Rose St
Apt. B-1
Concord, NH 03301

Payroll for: 4 - Jane Doe
- Check amount: 600
- Sent to:
39 Sole St.
Concord, NH 03301

Payroll for: 5 - Robin Williams
- Check amount: 360
- Sent to:
99 Mountain Rd.
Concord, NH 03301

Temporary Secretary:
{
  "id": "5",
  "name": "Robin Williams",
  "address": {
    "street": "99 Mountain Rd.",
    "street2": "",
    "city": "Concord",
    "state": "NH",
    "zipcode": "03301"
  }
}

Программа работает так же, как и раньше, но теперь можно видеть, что один объект Employee может быть создан из его идентификатора и можно отобразить его словарное представление.

Присмотримся к классу Employee:

# In employees.py

class Employee(AsDictionaryMixin):
    def __init__(self, id):
        self.id = id
        info = employee_database.get_employee_info(self.id)
        self.name = info.get('name')
        self.address = get_employee_address(self.id)
        self._role = get_role(info.get('role'))
        self._payroll = get_policy(self.id)

    def work(self, hours):
        duties = self._role.perform_duties(hours)
        print(f'Employee {self.id} - {self.name}:')
        print(f'- {duties}')
        print('')
        self._payroll.track_work(hours)

    def calculate_payroll(self):
        return self._payroll.calculate_payroll()

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

Employee также содержит роль производительности, предоставляемую модулем productivity, и политику расчета заработной платы, предоставляемую модулем hr. Эти два объекта предоставляют реализации, которые используются классом Employee для отслеживания работы в методе .work() и для расчета заработной платы в методе .calculate_payroll().

Мы использовали композицию двумя разными способами. Класс Address предоставляет дополнительные данные Employee, где объекты роли и расчета заработной платы обеспечивают дополнительное поведение.

Тем не менее, отношения между Employee и этими объектами слабо связаны, что обеспечивает некоторые интересные возможности, которые вы увидите в следующем разделе.

Композиция для изменения поведения во время выполнения

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

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

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

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

# In hr.py

class LTDPolicy:
    def __init__(self):
        self._base_policy = None

    def track_work(self, hours):
        self._check_base_policy()
        return self._base_policy.track_work(hours)

    def calculate_payroll(self):
        self._check_base_policy()
        base_salary = self._base_policy.calculate_payroll()
        return base_salary * 0.6

    def apply_to_policy(self, base_policy):
        self._base_policy = base_policy

    def _check_base_policy(self):
        if not self._base_policy:
            raise RuntimeError('Base policy missing')

Обратите внимание, что LTDPolicy не наследуется от PayrollPolicy, но реализует тот же интерфейс. Это связано с тем, что реализация полностью отличается, поэтому мы не хотим наследовать какую-либо реализацию PayrollPolicy.

LTDPolicy инициализирует _base_policy с None и имеет внутренний метод ._check_base_policy(), который вызывает исключение, если ._base_policy не был применен. Он так же имеет метод .apply_to_policy() для назначения _base_policy.

Открытый интерфейс сначала проверяет, была ли применена _base_policy, а затем реализует функциональность в соответствии с этой базовой политикой. Метод .track_work() просто делегирует базовую политику, а .calculate_payroll() использует его для вычисления base_salary, а затем возвращает 60%.

Теперь мы можем внести небольшое изменение в класс Employee:

# In employees.py

class Employee(AsDictionaryMixin):
    def __init__(self, id):
        self.id = id
        info = employee_database.get_employee_info(self.id)
        self.name = info.get('name')
        self.address = get_employee_address(self.id)
        self._role = get_role(info.get('role'))
        self._payroll = get_policy(self.id)


    def work(self, hours):
        duties = self._role.perform_duties(hours)
        print(f'Employee {self.id} - {self.name}:')
        print(f'- {duties}')
        print('')
        self._payroll.track_work(hours)

    def calculate_payroll(self):
        return self._payroll.calculate_payroll()

    def apply_payroll_policy(self, new_policy):
        new_policy.apply_to_policy(self._payroll)
        self._payroll = new_policy

Мы добавили метод .apply_payroll_policy(), который применяет существующую политику расчета заработной платы к новой политике, а затем заменяет ее. Теперь можно изменить программу, чтобы применить политику к объекту Employee:

# In program.py

from hr import calculate_payroll, LTDPolicy
from productivity import track
from employees import employee_database

employees = employee_database.employees

sales_employee = employees[2]
ltd_policy = LTDPolicy()
sales_employee.apply_payroll_policy(ltd_policy)
track(employees, 40)
calculate_payroll(employees)

Программа обращается к sales_employee, который имеет индекс 2, создает объект LTDPolicy и применяет политику к сотруднику. Когда вызывается .calculate_payroll(), изменение отражается. Можно запустить программу, чтобы оценить ее вывод:

$ python program.py

Tracking Employee Productivity
==============================
Employee 1 - Mary Poppins:
- screams and yells for 40 hours.

Employee 2 - John Smith:
- Does paperwork for 40 hours.

Employee 3 - Kevin Bacon:
- Expends 40 hours on the phone.

Employee 4 - Jane Doe:
- Manufactures gadgets for 40 hours.

Employee 5 - Robin Williams:
- Does paperwork for 40 hours.


Calculating Payroll
===================
Payroll for: 1 - Mary Poppins
- Check amount: 3000
- Sent to:
121 Admin Rd.
Concord, NH 03301

Payroll for: 2 - John Smith
- Check amount: 1500
- Sent to:
67 Paperwork Ave
Manchester, NH 03101

Payroll for: 3 - Kevin Bacon
- Check amount: 1080.0
- Sent to:
15 Rose St
Apt. B-1
Concord, NH 03301

Payroll for: 4 - Jane Doe
- Check amount: 600
- Sent to:
39 Sole St.
Concord, NH 03301

Payroll for: 5 - Robin Williams
- Check amount: 360
- Sent to:
99 Mountain Rd.
Concord, NH 03301

Сумма чека для сотрудника отдела продаж Кевина Бэкона теперь составляет 1080 долларов вместо 1800 долларов. Это потому, что LTDPolicy был применен к его зарплате.

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

Выбор между наследованием и композицией в Python

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

Иногда трудно понять, какими должны быть отношения между двумя классами, но вы можете следовать этим рекомендациям:

  • Используйте наследование вместо композиции для моделирования четких отношений. Во-первых, обоснуйте связь между производным классом и его базой. Затем измените отношения и попытайтесь их оправдать. Если вы можете оправдать отношения в обоих направлениях, то вам не следует использовать наследство между ними.
  • Используйте наследование вместо композиции, чтобы использовать как интерфейс, так и реализацию базового класса.
  • Используйте наследование вместо композиции для предоставления функций смешивания (mixin) нескольким несвязанным классам, когда может существовать только одна реализация этой функции.
  • Использование композиции вместо наследования для моделирования имеет отношение, которое использует реализацию класса компонента.
  • Используйте композицию вместо наследования для создания компонентов, которые могут повторно использоваться несколькими классами в ваших приложениях.
  • Используйте композицию вместо наследования для реализации групп поведений и политик, которые можно применять взаимозаменяемо к другим классам для настройки их поведения.
  • Используйте композицию вместо наследования, чтобы включить изменения поведения во время выполнения, не затрагивая существующие классы.

Заключение

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

В этой статье вы узнали, как:

  • Использовать наследование для выражения отношения между двумя классами
  • Оценивать правильность наследования
  • Использовать множественное наследование в Python и оценивать MRO для устранения проблем множественного наследования
  • Расширять классы миксинами и повторно использовать их реализацию
  • Использовать композиции для выражения связей между двумя классами
  • Создавать гибкие конструкции с использованием композиции
  • Повторно использовать существующий код посредством разработки политики на основе композиции

Вот несколько книг и статей, в которых более подробно рассматривается объектно-ориентированное проектирование, и которые могут помочь вам понять правильное использование наследования и композиции в Python или других языках:


Spread the love

Наследование и композиция: руководство по ООП в Python: 2 комментария

  • 19.09.2019 в 15:56
    Permalink

    Принцип подстановки Лисков, а не принцип подстановки Лискова (в русскоязычноой литературе еще пишут “Принцип подстановки Барбары Лисков”.

    Ответ
    • 19.09.2019 в 17:25
      Permalink

      Спасибо, поправил.

      Ответ

Добавить комментарий

Ваш e-mail не будет опубликован.