Классы и исключения

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

Содержание лекции

Классы и объекты

Класс - это сложный составной тип данных, состоящий из произвольного количества атрибутов и методов. Атрибутами (иногда называемыми также полями) класса называются его внутренние переменные, а методами - функции, которые выполняют различные операции над ними. Атрибуты и методы определяют структуру, общую для всех конкретных представителей класса, которых называют объектами или экземплярами. Вообще, понятия класса и его объекта связаны друг с другом так же, как, например, понятия "автомобиль" (класс, имеющие такие атрибуты, как тип кузова и мощность) и "toyota corolla" (конкретный представитель, со значениями атрибутов "седан" и "124").

Классы создаются (или по другому - определяются) в Python с помощью инструкции class, имеющей следующий синтаксис:

def class_name(base_classes):
    instruction1
    ...
    instructionN

Имя класса class_name должно быть допустимым идентификатором в языке Python. Если имя состоит из нескольких слов, то они записываются слитно без пробелов, при этом каждое слово пишется с заглавной буквы (такой стиль называется CamelCase), например, Circle, MyClass, SomeLongNameForClass.

Предложение base_classes содержит список базовых классов, разделенных запятой (подробнее об этом рассказывает в разделе, посвященном наследованию).

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

In [1]:
class MyClass:
    def print_hello(self):
        print('hello, object:', id(self))

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

Итак, для того, чтобы вызывать метод print_hello, нам для начала нужно создать объект класса MyClass. Делается это очень просто:

In [2]:
my_object = MyClass()  # обратите внимание на скобки после имени класса - их нужно обязательно указывать!
print(type(my_object)) # убедимся, что my_object имеет тип данных MyClass
<class '__main__.MyClass'>

Теперь мы можем вызвать метод print_hello для объекта my_object. Делается это тем же способом, как мы вызывали функции из модуля в предыдущей лекции, а именно - с помощью операции .:

In [3]:
my_object.print_hello()
hello, object: 113878632

Обратите внимание, что несмотря на то, что при определении метода print_hello мы указали один параметр, при вызове мы ничего в него не передаем. Как говорилось чуть выше, первый параметр класса инициализируется самим интерпретатором, и принимает в качестве значения ссылку на объект, для которого вызвался метод (в нашем случае - my_object). Если мы создадим второй объект, то увидим, что для него метод print_hello выведет другое число:

In [4]:
my_object2 = MyClass()
my_object2.print_hello()
hello, object: 113878856

Конструктор

Добавить атрибут к объекту можно с помощью обычной операции присваивания:

In [5]:
class MyClass:
    def print_hello(self):
        print('hello,', self.name) # обращаемся к атрибуту name объекта

my_object = MyClass()
my_object.name = 'Alice' # добавляем атрибут name объекту

my_object.print_hello()
hello, Alice

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

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

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

In [6]:
class MyClass:
    def __init__(self, name): # конструктор с параметром name
        print('in constructor')
        self.name = name
    def print_hello(self):
        print('hello,', self.name)

my_object = MyClass('Bob') # создаем объект, в качестве name передаем строку 'Bob'
print('next instruction')
my_object.print_hello()
in constructor
next instruction
hello, Bob

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

Статические атрибуты

Все атрибуты, с которыми мы сталкивались ранее в этой лекции, принадлежат своему объекту, и чтобы получить доступ к ним, нужно иметь ссылку на этот объект. Такие атрибуты еще называются переменными экземпляра (instance variables).

Атрибуты, которые являются общими для всех объектов класса, называются статическими, или переменными класса (class variables). Чтобы создать такой атрибут, достаточно просто поместить в определение класса инструкцию присваивания для него:

In [7]:
class MyClass:
    greeting = 'hello'
    
    def __init__(self, name):
        self.name = name
    
    def print_hello(self):
        print(self.greeting, self.name)

Статический атрибут является частью класса, а не объекта, поэтому обращаться к нему можно не только через объект, но и через сам класс:

In [8]:
my_object = MyClass('John')

print(my_object.greeting) # правильно, статические атрибуты доступны через любой объект класса
print(MyClass.greeting)   # правильно, статические атрибуты доступны через сам класс

# print(MyClass.name)     # неправильно, name обычный атрибут, доступен только через объект, если
                          # выполнить этот код, то будет сгенерировано исключение!
hello
hello

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

In [9]:
my_object1 = MyClass("Alice")
my_object2 = MyClass("Bob")

my_object1.print_hello();
my_object2.print_hello();

MyClass.greeting = "bonjour"  # это изменение влияет на оба объекта

my_object1.print_hello();
my_object2.print_hello();

my_object1.greeting = "aloha" # это изменение влияет только на my_object1

my_object1.print_hello();
my_object2.print_hello();
hello Alice
hello Bob
bonjour Alice
bonjour Bob
aloha Alice
bonjour Bob

Пространства имен

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

  • Пространство имен класса, содержащее все методы и статические атрибуты, входящие в класс.
  • Пространство имен объекта, содержащее имена из пространства имен класса, плюс имена атрибутов объекта.
In [10]:
my_object = MyClass('Kate')
print(dir(MyClass))    # выводим пространство имен класса
print('')              # выводим пустую строку для наглядности
print(dir(my_object))  # выводим пространство имен объекта
['__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__', 'greeting', 'print_hello']

['__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__', 'greeting', 'name', 'print_hello']

В обоих пространствах присутствует много имен, являющихся зарезервированными (те, которые начинаются и заканчиваются двумя символами подчеркивания). Кроме этих идентификаторов, мы видим, что в пространство имен класса попали имена greeting и print_hello, а в пространство имен объекта еще и name.

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

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

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

In [11]:
var = 10 # эта переменная в глобальном пространстве имен

class Test:
    var = 20 # эта переменная в пространстве имен класса
    
    def print_var(self):
        print(var) # программист хотел вывести на экран значение статического атрибута класса (var)

t = Test()
t.print_var()
10

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

In [12]:
var = 10

class Test:
    var = 20
    
    def print_var(self):
        print(self.var) # также можно было написать print(Test.var)

t = Test()
t.print_var()
20

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

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

При использовании объектно-ориентированного подхода, разработчик пытается выделить из предметной области, в рамках которой создается программа, сущности, которые обладают определенным состоянием и поведением. Такие сущности в дальнейшем становятся объектами некоторого класса, причем их состояние описывается атрибутами объекта, а поведение - методами. Например, в исходном коде программы, позволяющей отправлять электронные письма и реализованной в соответствии с принципами ООП, можно обнаружить объекты класса Email, атрибутами которых будут subject, from, to и text, а среди методов вероятно найдется attach_file.

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

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

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

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

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

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

In [13]:
class TextInfo:
    def __init__(self, text):
        self.text = text
    
    def get_symbols_count(self):
        return len(self.text)
    
    def get_words_count(self):
        words_count = 0
        in_word = False # булевая переменная, с помощью которой мы будем определять,
                        # находится ли следующий цикл внутри слова или нет
        
        for symbol in self.text:
            # первая строка в if проверяет, что мы не встретили конец слова
            # вторая строка в if проверяет, что мы не встретили конец предложения
            
            # специальный символ "\" в конце первой строки нужен, чтобы интерпретатор
            # понял, что выражение продолжается на следующей строке (иначе он бы выдал
            # ошибку SyntaxError)
            if symbol == ' ' or symbol == ',' or symbol == ':' or symbol == '-' or \
               symbol == '.' or symbol == '!' or symbol == '?':
                if in_word:
                    words_count += 1
                in_word = False
            else:
                in_word = True
        
        return words_count
    
    def get_sentences_count(self):
        sentences_count = 0
        in_sentence = False
        
        for symbol in self.text:
            if symbol == '.' or symbol == '!' or symbol == '?':
                if in_sentence:
                    sentences_count += 1
                in_sentence = False
            else:
                in_sentence = True
        
        return sentences_count
    
    def print_info(self):
        print('Symbols:', self.get_symbols_count())
        print('Words:', self.get_words_count())
        print('Sentences:', self.get_sentences_count())


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

text = ('Tom appeared on the sidewalk with a bucket of whitewash and a long-handled brush.'
        'He surveyed the fence, and all gladness left him and a deep melancholy settled down upon his spirit.'
        'Thirty yards of board fence nine feet high.'
        'Life to him seemed hollow, and existence but a burden.')

text_info = TextInfo(text)
text_info.print_info()
Symbols: 278
Words: 51
Sentences: 4

Здесь мы применили первый аспект инкапсуляции - объединили данные и код, который их обрабатывает, в один класс. Реализация, представленная выше, далека от идеальной, потому что в Python существуют функции, позволяющие сделать все то же самое быстрее и лаконичнее. Мы однако пока о них не знаем, так что решили задачу "в лоб" - проходом по всему тексту и подсчетом нужных значений. Единственное исключение - метод get_symbols_count, в котором мы воспользовались уже знакомой нам встроенной функцией len.

Еще одним существенным недостатоком нашей реализации является то, что при каждом вызове методов get_words_count и get_sentences_count выполняется проход в цикле по всему тексту. Если текст будет очень большим, то эти операции будут занимать много времени. Немного подумав, мы можем догадаться до такого решения: подсчитывать все значения один раз при первом вызове любого метода, а затем просто возвращать уже готовый результат. Оптимизация, при которой какие-то трудновычисляемые результаты сохраняются в переменных, чтобы потом просто возвращать их значения, называется кэшированием. Заодно в следующей версии TextInfo можно объединить подсчет количества слов и предложений в одном цикле.

In [14]:
class TextInfo:
    def __init__(self, text):
        self.text = text
        self.is_calculated = False
        self.words_count = 0
        self.sentences_count = 0
    
    def get_symbols_count(self):
        return len(self.text)
    
    def get_words_count(self):
        self.calculate()
        return self.words_count
    
    def get_sentences_count(self):
        self.calculate()
        return self.sentences_count
    
    def print_info(self):
        print('Symbols:', self.get_symbols_count())
        print('Words:', self.get_words_count())
        print('Sentences:', self.get_sentences_count())
    
    def calculate(self):
        if self.is_calculated:
            # ничего делать не нужно, вся информация уже подсчитана (выводим строку для
            # того, чтобы убедиться, что метод действительно не делает лишней работы)
            print('already calculated')
            return
        
        self.words_count = 0
        self.sentences_count = 0
        in_word = False
        in_sentence = False
        
        for symbol in self.text:
            if symbol == ' ' or symbol == ',' or symbol == ':' or symbol == '-':
                if in_word:
                    self.words_count += 1
                in_word = False
            elif symbol == '.' or symbol == '!' or symbol == '?':
                if in_word:
                    self.words_count += 1
                if in_sentence:
                    self.sentences_count += 1
                in_word = in_sentence = False
            else:
                in_word = in_sentence = True
        
        # все подсчитано, не забываем зафиксировать это!
        self.is_calculated = True

Убедимся, что наша новая версия работает так, как ожидается (обратите внимание, что при вызове get_sentences_count был сразу возвращен результат, проход в цикле по тексту не понадобился):

In [15]:
text_info = TextInfo(text)
text_info.print_info()
Symbols: 278
Words: 51
already calculated
Sentences: 4

Теперь мы добились быстрой работы нашего класса TextInfo, однако появилась новая проблема: если программист изменит атрибут text, но забудет установить атрибут is_calculated в False, то при вызове методов get_words_count и get_sentences_count он получит неверные результаты, так как соответствующие атрибуты не будут пересчитаны. Как мы помним, инкапсуляция должна защищать объекты от неправильного использования, значит нам нужно сделать так, чтобы при любом изменении атрибута text, атрибут is_calculated становился равен False. Этого можно добиться, если добавить такой метод для установки значения атрибута text (для краткости мы не будем приводить код класса TextInfo целиком):

In [ ]:
class TextInfo:
...
def set_text(self, text):
    self.text = text
    self.is_calculated = False
...

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

In [16]:
text_info = TextInfo(text)
text_info.print_info()

print('')

text_info.set_text('This is my favourite book!')
text_info.print_info()
Symbols: 278
Words: 51
already calculated
Sentences: 4

Symbols: 26
Words: 5
already calculated
Sentences: 1

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

In [17]:
text_info = TextInfo(text)
text_info.print_info()

print('')

text_info.text = 'This is bad!' # ничто не мешает нам присвоить значение атрибуту
                                # text напрямую, не используя метод set_text
text_info.print_info()
Symbols: 278
Words: 51
already calculated
Sentences: 4

Symbols: 12
already calculated
Words: 51
already calculated
Sentences: 4

Как видите, из-за того, что атрибут is_calculated не был установлен в False после замены текста в обход метода set_text, только количество символов отображено правильно, а остальная информация получена из закэшированных для старого текста данных. Чтобы обезопасить свои объекты от такого использования, нам нужно ограничить доступ к его внутренним данным и методам, которые могут быть изменены или вызваны не так, как мы предполагали.

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

In [18]:
class TextInfo:
    def __init__(self, text):
        self._text = text
        self._is_calculated = False
        self._words_count = 0
        self._sentences_count = 0
    
    def set_text(self, text):
        self._text = text
        self._is_calculated = False
    
    # так как мы указываем, что не хотели бы, чтобы к атрибуту _text обращались
    # напрямую, нам нужно предоставить метод для его получения
    def get_text(self):
        return self._text;
    
    def get_symbols_count(self):
        return len(self._text)
    
    def get_words_count(self):
        self._calculate()
        return self._words_count
    
    def get_sentences_count(self):
        self._calculate()
        return self._sentences_count
    
    def print_info(self):
        print('Symbols:', self.get_symbols_count())
        print('Words:', self.get_words_count())
        print('Sentences:', self.get_sentences_count())
    
    # эту функцию мы вызываем сами когда нужно, поэтому не хотим, чтобы к ней
    # обращались напрямую
    def _calculate(self): 
        if self._is_calculated:
            # ничего делать не нужно, вся информация уже подсчитана (выводим строку для
            # того, чтобы убедиться, что метод действительно не делает лишней работы)
            print('already calculated')
            return
        
        self._words_count = 0
        self._sentences_count = 0
        in_word = False
        in_sentence = False
        
        for symbol in self.text:
            if symbol == ' ' or symbol == ',' or symbol == ':' or symbol == '-':
                if in_word:
                    self._words_count += 1
                in_word = False
            elif symbol == '.' or symbol == '!' or symbol == '?':
                if in_word:
                    self._words_count += 1
                if in_sentence:
                    self._sentences_count += 1
                in_word = in_sentence = False
            else:
                in_word = in_sentence = True
        
        # все подсчитано, не забываем зафиксировать это!
        self._is_calculated = True

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

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

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

Заметим, что в Python вообще все типы данных (в том числе встроенные, вроде int и bool) являются классами, причем наследующими общий базовый класс, который называется object. При создании своего класса нам не нужно явно указывать, что он наследует от object, потому что интерпретатор делает это автоматически.

Воспользовавшись функцией dir мы можем посмотреть, какие методы и атрибуты предоставляет класс object:

In [19]:
print(dir(object))
['__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__']

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

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

  • isinstance - определяет, является ли объект экземпляром указанного класса
  • issubclass - определяет, является ли класс подклассом другого класса
In [20]:
# создаем пустые классы
# инструкция pass никак не обрабатывается интерпретатором и нужна только для
# того, чтобы при создании пустого класса не возникло синтаксической ошибки
class A: pass

a = A()

print('A is object subclass:', issubclass(A, object))
print('a is A:', isinstance(a, A))
print('a is object:', isinstance(a, object))
A is object subclass: True
a is A: True
a is object: True

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

Построение грамотной иерархии классов - творческий процесс, требующий большого опыта от программиста, однако есть простое правило, с помощью которого можно определить, стоит ли сделать один класс (B) наследником другого (A): нужно вслух произнести "B является A" и оценить, насколько логично это звучит. Традиционный пример, использующийся в книгах по ООП - отношение наследования между классами "Кот" и "Животное" ("кот является животным"). Конечная иерархия классов может быть весьма сложной, например:

Иерархия наследования

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

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

In [21]:
class A:
    def parent_method(self):
        print('parent_method')

class B(A): # B наследник A
    def descendant_method(self):
        print('descendant_method')

print(dir(A))
print('')
print(dir(B))
['__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__', 'parent_method']

['__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__', 'descendant_method', 'parent_method']

Как видите, в пространстве имен класса B присутствуют и все имена из класса A (а также, разумеется, и из класса object, потому что A в свою очередь неявно наследуется от него). Это означает, что с помощью объектов класса B мы можем обращаться к методам класса A (но не наоборот!):

In [22]:
a = A()
b = B()

a.parent_method()     # правильно, parent_method опеределен в собственном классе
b.parent_method()     # правильно, parent_method определен в родительском классе
a.descendant_method() # ошибка, descendant_method определен только в наследнике!
parent_method
parent_method
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-22-401042c279ef> in <module>()
      4 a.parent_method()     # правильно, parent_method опеределен в собственном классе
      5 b.parent_method()     # правильно, parent_method определен в родительском классе
----> 6 a.descendant_method() # ошибка, descendant_method определен только в наследнике!

AttributeError: 'A' object has no attribute 'descendant_method'

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

In [23]:
class A:
    def get_class_name(self):
        return 'class A'

class B(A):
    def get_class_name(self):
        return 'class B'

Метод get_class_name называется полиморфным - для него существует реализация и в родительском, и в дочернем классе. Какой именно вариант использовать, интерепретатор будет решать в зависимости от того, для объекта какого типа он вызывается:

In [24]:
def print_class_name(obj):
    print(obj.get_class_name())

a = A()
b = B()

print_class_name(a)
print_class_name(b)
class A
class B

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

In [25]:
class SymbolCounter:
    def get_counted_symbols(self):
        return 'All'
    
    def get_count(self, text):
        count = 0
        for symbol in text:
            # делегируем решение о том, учитывать символ или нет, подклассам
            if self._should_count(symbol):
                count += 1
        return count
    
    # этот метод будет переопределяться в наследниках
    def _should_count(self, symbol):
        # по умолчанию подсчитываем все символы
        return True

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

In [26]:
class VowelCounter(SymbolCounter):
    def get_counted_symbols(self):
        return 'Vowels'
    def _should_count(self, symbol):
        # подсчитываем только гласные
        if symbol in 'aeiouyAEIOUY':
            return True
        return False

class ConsonantCounter(SymbolCounter):
    def get_counted_symbols(self):
        return 'Consonants'
    def _should_count(self, symbol):
        # подсчитываем только согласные
        if symbol in 'bcdfghjklmnpqrstvwxzBCDFGHJKLMNPQRSTVWXZ':
            return True
        return False

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

In [27]:
def print_symbols_count(counter, text):
    print('{}: {}'.format(counter.get_counted_symbols(), counter.get_count(text)))

text = 'Hello World!'

all_counter = SymbolCounter()
vowel_counter = VowelCounter()
consonant_counter = ConsonantCounter()

print_symbols_count(all_counter, text)
print_symbols_count(vowel_counter, text)
print_symbols_count(consonant_counter, text)
All: 12
Vowels: 3
Consonants: 7

Наследование, когда применяется с умом, позволяет значительно упростить написание программ и увеличить степень повторной используемости классов. Рассмотрим пример, в котором мы реализуем примитивный класс Line, представляющий прямую на плоскости (напомним, что уравнение прямой имеет вид $y=k*x+b$). Также нам понадобится класс Point, описывающий произвольную точку на плоскости. В своей реализации для хранения координат мы будем использовать тип Decimal, потому что только он обеспечивает абсолютную точность при вычислениях с вещественными числами.

In [28]:
from decimal import Decimal

class Point:
    def __init__(self, x, y):
        self.x = Decimal(x)
        self.y = Decimal(y)

class Line:
    def __init__(self, k, b):
        self.k = Decimal(k)
        self.b = Decimal(b)
    
    def has(self, point):
        return point.y - (self.k * point.x + self.b) == 0


# пример использования

line = Line('1.5', '3.45')
p = Point('2', '6.45')

if line.has(p):
    print('point ({}, {}) belongs to line'.format(p.x, p.y))
point (2, 6.45) belongs to line

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

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

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

In [29]:
class LineSegment(Line):
    def __init__(self, k, b, x_min, x_max):
        # нам обязательно нужно вызывать конструктор базового класса Line, чтобы его
        # атрибуты были проинициализированы; используем для этого функцию super
        super().__init__(k, b)
        
        # инициализируем данные, имеющие отношение к отрезку
        self.x_min = Decimal(x_min)
        self.x_max = Decimal(x_max)
    
    def has(self, point):
        # проверяем, что точка лежит на прямой, на которой лежит сам отрезок
        if not super().has(point):
            return False
        
        # проверяем, что точка лежит на отрезке
        if self.x_min <= point.x <= self.x_max:
            return True
        return False
    
    def get_length(self):
        # применим теорему Пифагора для вычисления длины отрезка
        
        catheter1 = self.x_max - self.x_min
        catheter2 = (self.k * self.x_max + self.b) - (self.k * self.x_min + self.b)
        return (catheter1**2 + catheter2**2) ** Decimal('0.5')


# пример использования

segment = LineSegment('0.75', '0', '1', '5')
p1 = Point('4', '3')
p2 = Point('0', '0')

if segment.has(p1):
    print('point ({}, {}) belongs to segment'.format(p1.x, p1.y))

if segment.has(p2):
    print('point ({}, {}) belongs to segment'.format(p2.x, p2.y))

print('Segment length:', segment.get_length())
point (4, 3) belongs to segment
Segment length: 5.000000000000000000000000000

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

In [30]:
def get_intersection_point(line1, line2):
    if line1.k == line2.k:
        # прямые (или отрезки) параллельны
        return None
    
    intersection_point = Point('0', '0')
    intersection_point.x = (line2.b - line1.b) / (line1.k - line2.k)
    intersection_point.y = line1.k * intersection_point.x + line1.b
    
    # отдельно нужно проверить, что точка пересечения прямых принадлежит
    # и line1, и line2 (это может быть не так, если одна из прямых на
    # самом деле является отрезком)
    if not line1.has(intersection_point) or not line2.has(intersection_point):
        return None
    return intersection_point


# пример использования

line1 = Line('0', '5')
line2 = Line('2', '-2')
segment1 = LineSegment('-0.5', '4', '-3', '-2')

p1 = get_intersection_point(line1, line2)
p2 = get_intersection_point(line1, segment1)
p3 = get_intersection_point(segment1, line2)

if p1 is not None:
    print('line1 intersects line2 at ({}, {})'.format(p1.x, p1.y))
else:
    print('line1 does not intersect line2')

if p2 is not None:
    print('line1 intersects segment1 at ({}, {})'.format(p2.x, p2.y))
else:
    print('line1 does not intersect segment1')

if p3 is not None:
    print('line2 intersects segment1 at ({}, {})'.format(p3.x, p3.y))
else:
    print('line2 does not intersect segment1')
line1 intersects line2 at (3.5, 5.0)
line1 intersects segment1 at (-2, 5)
line2 does not intersect segment1

Композиция

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

В качестве примера снова рассмотрим задачу из геометрии:

In [31]:
from decimal import Decimal

class Point:
    def __init__(self, x, y):
        self.x = Decimal(x)
        self.y = Decimal(y)
    
    def get_distance(self, point):
        # возвращает расстояние до точки, вычисленное по теореме Пифагора
        catheter1 = self.x - point.x
        catheter2 = self.y - point.y
        return (catheter1**2 + catheter2**2) ** Decimal('0.5')

class Circle:
    def __init__(self, center, radius):
        self.center = center
        self.radius = Decimal(radius)
    
    def has_intersection(self, circle):
        # обращаемся к методу класса Point для вычисления
        # расстояния между центрами окружности
        distance = self.center.get_distance(circle.center)
        
        if distance > self.radius + circle.radius:
            return False
        
        # теперь нужно определить, что одна окружность не вложена в другую
        
        min_radius = self.radius
        max_radius = circle.radius
        
        if self.radius > circle.radius:
            min_radius = circle.radius
            max_radius = self.radius
            
        if distance + min_radius < max_radius:
            return False
        
        return True

circle1 = Circle(Point('2', '2'), '3')
circle2 = Circle(Point('1', '5'), '5')
circle3 = Circle(Point('2.2', '2.2'), '1.5')

print(circle1.has_intersection(circle2))
print(circle1.has_intersection(circle3))
True
False

Для реализации метода has_intersection класса Circle мы воспользовались композицией, делегировав задачу вычисления расстояния между центрами окружности объекту класса Point.

Исключения

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

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

...
#define FILE_NOT_FOUND    1
#define CONNECTION_FAILED 2
#define SEND_FAILED       3

result = open_file('file.txt')
if (result == FILE_NOT_FOUND)
{ /*обрабатывается ошибка, когда файл не найден*/ }

result = connect_to_host('drive.google.com')
if (result == CONNECTION_FAILED)
{ /*обрабатывается ошибка, когда не удается законнектиться к удаленному компьютеру*/ }

result = send_file(connection, file)
if (result == SEND_FAILED)
{ /*обрабатывается ошибка, когда не удалось послать файл*/ }
...

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

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

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

В Python исключения используются очень широко. Только во встроенном пространстве имен можно обнаружить более 30 классов исключений (они имеют слово "error" в своем названии), в чем можно убедиться с помощью функции dir или обратившись к примеру из лекции 7. Более полную информацию об имеющихся исключениях можно получить из справочного руководства Python. Там же в разделе 5.4 можно увидеть, какую иерархию образуют все типы исключений.

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

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

raise exception_type(argument_list)

Здесь exception_type представляет класс исключения, а argument_list список (возможно пустой) аргументов, которые передаются в его конструктор.

Давайте напишем функцию divide, которая будет генерировать исключение, если в качестве знаменателя указан 0. Если обратиться к справочному руководству Python, то можно обнаружить в нем исключение ZeroDivisionError, которое является как раз тем, что нам нужно.

In [32]:
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError('Can\'t divide {} by {}!'.format(a, b))
    return a / b

print(divide(5, 0))
print('program is finished')
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-32-909d9fdb66b4> in <module>()
      4     return a / b
      5 
----> 6 print(divide(5, 0))
      7 print('program is finished')

<ipython-input-32-909d9fdb66b4> in divide(a, b)
      1 def divide(a, b):
      2     if b == 0:
----> 3         raise ZeroDivisionError('Can\'t divide {} by {}!'.format(a, b))
      4     return a / b
      5 

ZeroDivisionError: Can't divide 5 by 0!

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

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

In [33]:
class SqrtError(Exception): pass

# вычисляет квадратный корень из числа
def sqrt(a):
    if a < 0:
        raise SqrtError('Can\'t extract square root!', a)
    return a ** 0.5

print(sqrt(9))
print(sqrt(-9))
3.0
---------------------------------------------------------------------------
SqrtError                                 Traceback (most recent call last)
<ipython-input-33-bc8e6b0e958c> in <module>()
      8 
      9 print(sqrt(9))
---> 10 print(sqrt(-9))

<ipython-input-33-bc8e6b0e958c> in sqrt(a)
      4 def sqrt(a):
      5     if a < 0:
----> 6         raise SqrtError('Can\'t extract square root!', a)
      7     return a ** 0.5
      8 

SqrtError: ("Can't extract square root!", -9)

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

Для перехвата исключений используются блоки try...except...finally, имеющие следующий синтаксис:

try:
    try_code_block
except exception_list_1 as variable_1:
    except_code_block_1
...
except exception_list_N as variableN:
    except_code_block_N
else:
    else_code_block
finally:
    finally_code_block

Давайте разберем подробно компоненты этой конструкции:

  • try_code_block содержит инструкции, для которых мы хотим выполнять перехват исключений
  • except_code_block_K содержит блок кода, являющийся обработчиком исключений, указанных в соответствующем exception_list_K
  • else_code_block содержит инструкции, которые должны быть выполнены, если никаких исключений при выполнении try_code_block не возникло
  • finally_code_block содержит инструкции, которые должны быть выполнены в любом случае, причем в самом конце (либо после try_code_block, если не было исключений, либо после except_code_block_K, если произошло одно из exception_list_K исключений, либо после того, как было сгенерировано исключение, но не был найден подходящий обработчик, и интерпретатор продолжил его поиск в вызывающей функции или методе)

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

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

Часть as предложения except также является необязательной. Она используется для того, чтобы при перехвате исключения создать новую переменную с именем variableK, и присвоить ей ссылку на объект исключения. Тогда в обработчике можно будет обращаться к данным, хранящимся в исключении.

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

In [34]:
def divide_old(a, b):
    if b == 0:
        return None
    return a / b

С помощью такой функции мы можем написать такую программу:

In [35]:
a = 10
b = 5

result = divide_old(a, b)
result += 1
print(result)
print('program is finished')
3.0
program is finished

С этим кодом все в порядке ровно до тех пор, пока переменная b не окажется равной 0:

In [36]:
a = 10
b = 0

result = divide_old(a, b)
result += 1
print(result)
print('program is finished')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-36-38042b47543e> in <module>()
      3 
      4 result = divide_old(a, b)
----> 5 result += 1
      6 print(result)
      7 print('program is finished')

TypeError: unsupported operand type(s) for +=: 'NoneType' and 'int'

По этой причине нам еще нужно добавить проверку на то, что функция divide_old отработала без ошибок:

In [37]:
a = 10
b = 0

result = divide_old(a, b)

if result is not None:
    result += 1
    print(result)
else:
    print('error!')

print('program is finished')
error!
program is finished

Посмотрим, как эту же задачу можно решить с помощью функции divide, использующей исключение при попытке деления на ноль:

In [38]:
a = 10
b = 0

try:
    result = divide(a, b)
    # следующие инструкции не выполнятся, если в divide произойдет исключение
    
    result +=  1
    print(result) 

except: # перехватываем любое исключение
    print('error!')

print('program is finished')
error!
program is finished

По выводу нашей программы мы видим, что при возникновении исключения интерпретатор немедленно прекращает выполнение try_code_block и переходит к коду в подходящем except_code_block. После того, как этот блок кода выполнен, интерпретатор считает, что исключение обработано, и начинает выполнение первой инструкции после try...except...finally. Важно понять, что интерпретатор не продолжает выполнение try_code_block после обработки исключения, поэтому внутри него мы можем писать код, не задумываясь об ошибках, которые могли произойти ранее в этом блоке. Мы добились того, что у нас четко разделена основная часть программы (то, что в блоке try_code_block) и обработка ошибок (то, что в except_code_block), в отличие от примера с функцией divide_old.

Если мы хотим перехватить конкретный тип исключений, то нужно указать это в except:

In [39]:
try:
    print(sqrt(-1))
except SqrtError as err:
    print(err)
("Can't extract square root!", -1)

В одном предложении except можно перечислить сразу несколько типов исключений, например так:

In [40]:
try:
    result = divide(-8, 2)
    print(sqrt(result))
except (ZeroDivisionError, SqrtError) as err:
    print(err)
("Can't extract square root!", -4.0)

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

In [41]:
try:
    # при попытке применить операцию деления к строковому литералу внутри
    # функции divide, интерпретатор сгенерируется исключение TypeError
    result = divide('10', 2) 
    print(sqrt(a))
except (ZeroDivisionError, SqrtError) as err:
    print(err)
except TypeError:
    print('some argument was invalid!')
some argument was invalid!

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

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

Рассмотрим пример, поясняющий это (заметим, что встроенное исключение ZeroDivisionError является потомком более общего типа ArithmeticError, см. здесь):

In [42]:
try:
    print(divide(1, 0))
except ArithmeticError as err:
    print(err)
Can't divide 1 by 0!

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

In [43]:
try:
    print(divide(1, 0))
except ArithmeticError:
    print('not interesting error')
except ZeroDivisionError as err:
    # никогда не выполнится, потому что при возникновении этого исключения,
    # первым для него будет найден предыдущий обработчик
    print(err)
not interesting error

Чтобы этот пример работал правильно, нужно переписать его таким образом:

In [44]:
try:
    print(divide(1, 0))
except ZeroDivisionError as err:
    # обрабатываем ошибку, связанную с делением на ноль
    print(err)
except ArithmeticError:
    # обрабатываем все остальные арифметические ошибки
    print('not interesting error')
Can't divide 1 by 0!

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

In [45]:
def try_divide(a, b):
    try:
        print(divide(a, b)) 
    except ZeroDivisionError as err:
        print(err)


# основная часть программы является функцией самого верхнего уровня

try:
    try_divide(10, '10')
except TypeError:
    print('incorrect call of function try_divide!')
incorrect call of function try_divide!

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

В заключение расскажем о том, для чего нужны предложения else и finally.

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

In [46]:
# предположим, что это некоторая сложная функция, которая генерирует
# различные исключения (в нашем примере, конечно, это не так)
def do_something(value):
    print(sqrt(value))

def start(a, b):
    try:
        res = divide(a, b)
    except:
        print('error occurred while dividing numbers!')
    else:
        print('division successful')
        do_something(res)


# основная программа

try:
    start(-4, 2)
except:
    print('error occurred in do_something function')
division successful
error occurred in do_something function

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

Предложение finally нужно для того, чтобы иметь возможность выполнить некоторый код при любом результате выполнения try_code_block. В подавляющем большинстве случаев, в finally_code_block пишут код, который освобождает ресурсы, которые были использованы при выполнении try_code_block. В одной из следующих лекций мы поговорим о том, как работать с файлами с помощью языка Python, пока лишь скажем, что вначале файл нужно открыть, а после работы с ним - закрыть. Если этого не сделать, то изменения могут не сохраниться. Один из способов гарантировать, что открытый файл всегда будет закрыт, такой:

In [47]:
# определим функции, которые как бы "работают" с файлом

def open_file():
    print('open')
def save_to_file(number):
    print('saved', number)
def close_file():
    print('close')

try:
    open_file()
    
    result = divide(9, 1)
    result = sqrt(result)
    
    save_to_file(result)
except SqrtError as err:
    print(err)
finally:
    close_file()
open
saved 3.0
close

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

Продвинутые приемы программирования

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

Декораторы

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

Одним из наиболее часто используемых декораторов называется property. Он используется для создания свойств - методов, которые выглядят и используются как обычные атрибуты. Вспомним класс TextInfo, который мы реализовали чуть раньше. В нем при изменении текста нам важно было установить атрибут _is_calculated в False, чтобы в дальнейшем были пересчитаны атрибуты, хранящие количество слов и предложений. Чтобы обезопасить наш класс от неправильного использования, мы скрыли атрибут, хранящий текст, и предоставили два метода (get_text и set_text) для доступа к нему.

Более изящным решением в данном случае будет использование декоратора property. Он позволяет создать два метода - один для чтения атрибута (getter-метод), другой для записи (setter-метод) - и присвоить им одинаковое имя. Делается это следующим образом:

In [48]:
class TextInfo:
    def __init__(self, value):
        self._text = value
        self._is_calculated = False
        self._words_count = 0
        self._sentences_count = 0
    
    # создаем свойство text и определяем getter-метод для получения текста
    @property
    def text(self):
        print('getter called')
        return self._text;
    
    # для свойства text устанавливаем setter-метод для установки текста
    @text.setter
    def text(self, value):
        print('setter called')
        self._text = value
        self._is_calculated = False
    
    def get_symbols_count(self):
        return len(self._text)
    
    def get_words_count(self):
        self._calculate()
        return self._words_count
    
    def get_sentences_count(self):
        self._calculate()
        return self._sentences_count
    
    def _calculate(self): 
        if self._is_calculated:
            return
        
        self._words_count = 0
        self._sentences_count = 0
        in_word = False
        in_sentence = False
        
        for symbol in self._text:
            if symbol == ' ' or symbol == ',' or symbol == ':' or symbol == '-':
                if in_word:
                    self._words_count += 1
                in_word = False
            elif symbol == '.' or symbol == '!' or symbol == '?':
                if in_word:
                    self._words_count += 1
                if in_sentence:
                    self._sentences_count += 1
                in_word = in_sentence = False
            else:
                in_word = in_sentence = True
        
        # все подсчитано, не забываем зафиксировать это!
        self._is_calculated = True

Теперь мы можем писать такой код:

In [49]:
info = TextInfo('Hello World!')

print('text:', info.text)
print('words count:', info.get_words_count())
print('')

info.text = 'Testing setter method of a property.'
print('text:', info.text)
print('words count:', info.get_words_count())
getter called
text: Hello World!
words count: 2

setter called
getter called
text: Testing setter method of a property.
words count: 6

Еще два встроенных в Python декоратора называются staticmethod и classmethod. Оба они позволяют создать методы, которые можно вызывать без объекта (этим они напоминают статические атрибуты).

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

На метод, определяемый с помощью декоратора staticmethod, никаких специальных ограничений не накладывается.

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

In [50]:
class LineError(Exception): pass

class LineSegment(Line):
    def __init__(self, k, b, x_min, x_max):
        if x_min >= x_max:
            raise LineError('incorrect segment bounds')
        
        super().__init__(k, b)
        self.x_min = Decimal(x_min)
        self.x_max = Decimal(x_max)
    
    @classmethod
    def create_from_points(cls, p1, p2):
        if p1.x == p2.x:
            raise LineError('can\'t create line equation')
        
        # находим параметры прямой, которая проходит через две точки
        k = (p1.y - p2.y) / (p1.x - p2.x)
        b = p1.y - k * p1.x
        
        # создаем и возвращаемый новый объект класса LineSegment
        if p1.x < p2.x:
            return cls(k, b, p1.x, p2.x)
        else:
            return cls(k, b, p2.x, p1.x)
    
    @staticmethod
    def calc_length(p1, p2):
        catheter1 = p1.x - p2.x
        catheter2 = p1.y - p2.y
        return (catheter1**2 + catheter2**2) ** Decimal('0.5')
        
    def has(self, point):
        if not super().has(point):
            return False
        if self.x_min <= point.x <= self.x_max:
            return True
        return False
    
    def get_length(self):
        p1 = Point(self.x_min, self.k * self.x_min + self.b)
        p2 = Point(self.x_max, self.k * self.x_max + self.b)
        
        # используем статический метод calc_length для вычисления длины
        return self.calc_length(p1, p2)


# пример использования

p1 = Point('0', '1')
p2 = Point('3', '7')
segment1 = LineSegment.create_from_points(p1, p2)

# символ \ в конце строки используется для того, чтобы переносить длинные строки;
# когда интерпретатор встречает его, он понимает, что выражение будет продолжено
# на следующей строке
print('segment equation: {}*x + {} in [{}, {}]'.format(\
      segment1.k, segment1.b, segment1.x_min, segment1.x_max))

print(LineSegment.calc_length(p1, p2))
print(segment1.get_length())
segment equation: 2*x + 1 in [0, 3]
6.708203932499369089227521006
6.708203932499369089227521006

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

Перегрузка операций

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

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

Рассмотрим в качестве примера уже встречавшийся нам класс Circle, но теперь определим для него метод, возвращающий площадь круга:

In [51]:
from math import pi

class Circle:
    def __init__(self, radius):
        self.radius = radius
    
    def get_area(self):
        return pi * (self.radius ** 2)

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

In [52]:
c1 = Circle(3)
c2 = Circle(3)
print(c1 == c2)
False

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

In [53]:
class Circle:
    def __init__(self, radius):
        print('id={}, radius={}'.format(id(self), radius))
        self.radius = radius
    
    def get_area(self):
        return pi * (self.radius ** 2)
    
    def __eq__(self, other):
        print('id={}: Circle.eq'.format(id(self)))
        return self.get_area() == other.get_area()


c1 = Circle(3)
c2 = Circle(3)
print(c1 == c2)
id=114806120, radius=3
id=114804048, radius=3
id=114806120: Circle.eq
True

Все специальные методы для бинарных операций (в том числе и операций сравнения) в качестве аргументов принимают два объекта, участвующие в ней. Например, при выполнении операции == в примере сверху, интерпретатор по сути выполняет инструкцию c1.__eq__(c2). Для унарной операции соответствующий специальный метод имеет один параметр self.

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

Операция Какой метод вызывается
x < y
x.__lt__(y)
x <= y
x.__le__(y)
x == y
x.__eq__(y)
x != y
x.__ne__(y)
x >= y
x.__ge__(y)
x > y
x.__gt__(y)

Если для типа определена операция ==, то операцию != переопределять не обязательно - в случае ее отсутствия интерпретатор просто воспользуется операцией ==, а затем возьмет противоположный результат:

In [54]:
c1 = Circle(3)
c2 = Circle(1)
print(c1 != c2)
id=114715784, radius=3
id=114715504, radius=1
id=114715784: Circle.eq
True

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

  1. __eq__ - наличие его в классе позволяет интерпретатору не только выполнять операцию ==с объектами этого класса, но и операцию !=, как было показано выше
  2. __lt__ или __gt__, потому что интерпретатор умеет выводить один из другого (выражение "x меньше y" является тем же самым, что выражение "y больше x")
  3. __le__ или __ge__ по той же причине, что указана в предыдущем пункте

Добавим поддержку всех операций сравнения в наш класс Circle:

In [55]:
class Circle:
    def __init__(self, radius):
        print('id={}, radius={}'.format(id(self), radius))
        self.radius = radius
    
    def get_area(self):
        return pi * (self.radius ** 2)
    
    def __eq__(self, other):
        print('id={}: Circle.eq'.format(id(self)))
        return self.get_area() == other.get_area()
    
    def __lt__(self, other):
        print('id={}: Circle.lt'.format(id(self)))
        return self.get_area() < other.get_area()

    def __le__(self, other):
        print('id={}: Circle.le'.format(id(self)))
        return self.get_area() <= other.get_area()

Проверим, как это работает (обратите пристальное внимание на то, для каких именно объектов вызывается тот или иной метод):

In [56]:
c1 = Circle(1)
c2 = Circle(2)

print('')
print(c1 != c2)
print(c1 < c2)
print(c1 > c2)
print(c1 >= c2)
id=114910264, radius=1
id=113973512, radius=2

id=114910264: Circle.eq
True
id=114910264: Circle.lt
True
id=113973512: Circle.lt
False
id=113973512: Circle.le
False

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

Арифметическая операция Метод Битовая операция Метод
x + y
x.__add__(y)
x & y
x.__and__(y)
x - y
x.__sub__(y)
x | y
x.__or__(y)
x * y
x.__mul__(y)
x ^ y
x.__xor__(y)
x / y
x.__truediv__(y)
x << y
x.__lshift__(y)
x // y
x.__floordiv__(y)
x >> y
x.__rshift__(y)
x % y
x.__mod__(y)
~x
x.__invert__()
x ** y
x.__pow__(y)
-x
x.__neg__()

Для краткости мы не стали включать в представленные таблицы имена специальных методов для комбинированных инструкций присваивания, имеющих вид x op= y. Эти методы имеют такие же имена, как и те, что соответствуют простой бинарной операции op, но с префиксом "i". Например, для операции += интерпретатор будет искать метод __iadd__.

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

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

In [57]:
# представляет числа в формате a + b*i, где i - мнимая единица
class MyComplex:
    def __init__(self, real, imag):
        self.a = real # действительная часть комплексного числа
        self.b = imag # мнимая часть комплексного числа
    
    # c1 + c2
    def __add__(self, other):
        result = MyComplex(self.a + other.a, self.b + other.b)
        return result
    
    # c1 += c2
    def __iadd__(self, other):
        self.a += other.a
        self.b += other.b
        return self

c1 = MyComplex(1.1, -3)
c2 = MyComplex(0, 5.5)

c3 = c1 + c2
print('{} + {}i'.format(c3.a, c3.b))

c3 += MyComplex(0, 0.5)
print('{} + {}i'.format(c3.a, c3.b))
1.1 + 2.5i
1.1 + 3.0i

Для комплексных чисел имеет смысл операция сложения с действительными и целыми. Пока наш класс это не поддерживает, потому что в реализации метода __add__ мы обращаемся к атрибутам a и b параметра other, и если в качестве него будет передано обычное число (тип float или int), то интерпретатор сгенерирует такое исключение:

In [58]:
c3 + 1.1
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-58-ed8be8f5c108> in <module>()
----> 1 c3 + 1.1

<ipython-input-57-c6cb6e5a0942> in __add__(self, other)
      7     # c1 + c2
      8     def __add__(self, other):
----> 9         result = MyComplex(self.a + other.a, self.b + other.b)
     10         return result
     11 

AttributeError: 'float' object has no attribute 'a'

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

In [59]:
class MyComplex:
    def __init__(self, real, imag):
        self.a = real # действительная часть комплексного числа
        self.b = imag # мнимая часть комплексного числа
    
    def __add__(self, other):
        if isinstance(other, MyComplex):
            return MyComplex(self.a + other.a, self.b + other.b)
        else:
            return MyComplex(self.a + other, self.b)
    
    def __iadd__(self, other):
        if isinstance(other, MyComplex):
            self.a += other.a
            self.b += other.b
        else:
            self.a += other
        return self

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

In [60]:
c1 = MyComplex(1, 1)
c2 = c1 + 0.5
c2 += 0.5
print('{} + {}i'.format(c2.a, c2.b))
2.0 + 1i

К сожалению, поскольку встроенные типы Python ничего не знают о нашем классе MyComplex, при попытке поменять местами слагаемые в выражении c1 + 0.5 мы снова получим ошибку. Это произойдет потому, что интерпретатор вызовет метод __add__ у класса float и передаст ему в качестве аргумента c1, с которым, очевидно, тип float не будет знать, что делать:

In [61]:
0.5 + c1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-61-137418b43f5a> in <module>()
----> 1 0.5 + c1

TypeError: unsupported operand type(s) for +: 'float' and 'MyComplex'

На помощь к нам в этой ситуации приходят специальные методы с префиксом "r". Дело в том, что интерпретатор ищет нужный специальный метод по следующему алгоритму (на примере операции x + y):

  1. Вначале он пытается найти метод __add__ у объекта x. Если метод найден, то интерпретатор вызывает его с аргументом y.
  2. Если метод не найден, или он вернул специальное значение NotImplemented, то интерпретатор пытается найти метод __radd__ у объекта y. Если метод найден, то интерпретатор вызывает его с аргументом x, а иначе генерирует исключение.

Продемонстрируем вышесказанное на простом примере:

In [62]:
class A:
    def __add__(self, other):
        if isinstance(other, int):
            print('A.add')
            return
        return NotImplemented

class B:
    def __radd__(self, other):
        print('B.radd')

a = A()
b = B()

a + 1
print('(a + 1) is done')
a + b
print('(a + b) is done')
A.add
(a + 1) is done
B.radd
(a + b) is done

Как видите, во втором сложении метод A.__add__ вернул NotImplemented, поэтому интерпретатор продолжил поиск подходящего метода в классе B и вызвал его.

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

С учетом того, что мы теперь знаем, давайте представим окончательный вариант реализации класса MyComplex:

In [63]:
class MyComplex:
    def __init__(self, real, imag):
        self.a = real # действительная часть комплексного числа
        self.b = imag # мнимая часть комплексного числа
    
    def __add__(self, other):
        if isinstance(other, MyComplex):
            return MyComplex(self.a + other.a, self.b + other.b)
        elif isinstance(other, int) or isinstance(other, float):
            return MyComplex(self.a + other, self.b)
        else:
            return NotImplemented
    
    def __radd__(self, other):
        if isinstance(other, int) or isinstance(other, float):
            return MyComplex(self.a + other, self.b)
        raise TypeError('Unsupported Type')
    
    def __iadd__(self, other):
        if isinstance(other, MyComplex):
            self.a += other.a
            self.b += other.b
        else:
            self.a += other
        return self
    
    def __str__(self):
        return '{} + {}*i'.format(self.a, self.b)


# пример использования

c1 = MyComplex(1, 3)
c1 += 10

# интерпретатор использует метод `__str__` для того, чтобы получить
# строковое представление объекта тогда, когда это ему нужно (например,
# при передаче объекта в функцию print)
print(c1)
11 + 3*i

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

Еще одним интересным специальным методом, который часто перегружается, является метод __call__. Если класс содержит реализацию этого метода, то его объекты могут использоваться как функции - их можно "вызывать". Такие объекты в Python называются вызываемыми объектами или функторами. Их прелесть в том, что как объекты, они могут иметь атрибуты и использовать их для хранения некоторого состояния между вызовами. Рассмотрим пример функции, которая генерирует арифметическую прогрессию:

In [64]:
class ArithmeticProgression:
    def __init__(self, step, start = 0):
        self._start = start
        self._next_element = start
        self._step = step
    
    def __call__(self):
        result = self._next_element
        self._next_element += self._step
        return result


# пример использования

get_next = ArithmeticProgression(5) 

# выводим три первых члена арифметической прогрессии
print(get_next())
print(get_next())
print(get_next())
0
5
10

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

На этом мы заканчиваем рассмотрение специальных методов в языке Python. Мы познакомили вас не со всеми такими методами - их достаточно много, и большинство имеют узкоспециализированное применение. Более полную информация на эту тему можно получить из раздела Special Method Names справочного руководства.

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

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

In [65]:
class Base1:
    def test(self):
        print('Base1.test')

class Base2:
    def test(self):
        print('Base2.test')

class Derived(Base1, Base2): pass

d = Derived()
d.test()
Base1.test

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

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

In [66]:
# класс, выполняющий операцию op (это должен быть вызываемый объект,
# лямбда или функция) над некоторым значением value
class Operation:
    def __init__(self, val, op):
        self._value = val
        self._op = op
    
    def do(self, param):
        self._value = self._op(self._value, param)
    
    # не определяем setter-метод, потому что хотим, чтобы значение,
    # над которым выполняется операция, опеределялось только в конструкторе
    @property
    def value(self):
        return self._value;


# примесь для для хранения параметра операции;
# в нашем простом примере параметр хранится только в памяти, в
# реальности мы возможно хотели бы сохранять его и на диске
class ParamStorageMixin:
    def __init__(self):
        self._param = None
    def push(self, param):
        self._param = param
    def pop(self):
        result = self._param
        self._param = None
        return result


# класс, который позволяет откатить последнюю операцию и использующий
# примесь для того, чтобы хранить параметр, с которым операция выполнялась
# последний раз
class RevertableOperation(Operation, ParamStorageMixin):
    def __init__(self, target, op, undo_op):
        super().__init__(target, op)
        self._undo_op = undo_op
    
    def do(self, param):
        self.push(param)
        super().do(param)
    
    def undo(self):
        param = self.pop()
        
        if param is None: # для операции уже выполнен откат
            return
        
        self._value = self._undo_op(self._value, param)


# пример использования

add_op = lambda x, y: x + y
add_undo_op = lambda x, y: x - y

add = RevertableOperation(10, add_op, add_undo_op)

add.do(3)
print(add.value)

add.do(5)
print(add.value)

add.undo()
print(add.value)
13
18
13

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

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

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

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

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

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

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

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

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

In [67]:
class Shape:
    def __init__(self, name):
        self.name = name
    def get_name(self):
        return self.name
    def get_area(self):
        raise NotImplementedError()

Используя этот базовый класс мы можем создать дочерние классы Circle и Rectangle, в которых будет реализован метод get_area:

In [68]:
from math import pi

class Circle(Shape):
    def __init__(self, radius):
        super().__init__('circle')
        self.radius = radius
    def get_area(self):
        return pi * (self.radius ** 2)

class Rectangle(Shape):
    def __init__(self, a, b):
        super().__init__('rectangle')
        self.a = a
        self.b = b
    def get_area(self):
        return self.a * self.b

Эти классы теперь могут быть использованы таким образом:

In [69]:
def print_shape_info(shape):
    print('{}: area={}'.format(shape.get_name(), shape.get_area()))

shape1 = Circle(3)
shape2 = Rectangle(5, 6)

print_shape_info(shape1)
print_shape_info(shape2)
circle: area=28.274333882308138
rectangle: area=30

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

Классы наподобие Shape называются в ООП абстрактными (сокращенно ABC - abstract base class), потому что они лишь описывают интерфейс, но сами не реализуют его, или реализуют лишь частично (в Shape реализованы только конструктор и get_name). Поэтому нам бы не хотелось давать возможность создавать объекты таких классов - это может приводить к случайным ошибкам, если нерелизованные методы будут вызваны. В нашем же примере это допустимо:

In [70]:
shape = Shape('absract')
shape.get_area()
---------------------------------------------------------------------------
NotImplementedError                       Traceback (most recent call last)
<ipython-input-70-880236a679b0> in <module>()
      1 shape = Shape('absract')
----> 2 shape.get_area()

<ipython-input-67-e38d7823c685> in get_area(self)
      5         return self.name
      6     def get_area(self):
----> 7         raise NotImplementedError()

NotImplementedError: 

Чтобы исправить эту неточность, нам потребуется сделать следующее:

  1. Подключить модуль abc, содержащие необходимые типы для создания абстрактных классов.
  2. Для класса Shape указать в качестве метакласса тип ABCMeta. Мы не будем подробно рассматривать работу с метаклассами, скажем лишь, что они нужны для управления процессом создания классов, то есть играют для них ту же роль, что сами классы играют по отношению к своим объектам.
  3. С помощью декоратора abstractmethod указать методы, для которых отсутствует реализация в абстрактном классе.
In [71]:
import abc

class Shape(metaclass=abc.ABCMeta):
    def __init__(self, name):
        self.name = name
    def get_name(self):
        return self.name
    @abc.abstractmethod
    def get_area(self):
        raise NotImplementedError()

Если мы теперь попробуем создать объект класса Shape, то получим следующую ошибку:

In [72]:
shape = Shape('abstract')
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-72-1124931bb347> in <module>()
----> 1 shape = Shape('abstract')

TypeError: Can't instantiate abstract class Shape with abstract methods get_area

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

Поскольку Circle и Rectangle реализуют все абстрактные методы, их нельзя назвать абстрактными классами, поэтому их объекты можно спокойно создавать:

In [73]:
circle = Circle(10)
rectangle = Rectangle(1, 1)

print_shape_info(circle)
print_shape_info(rectangle)
circle: area=314.1592653589793
rectangle: area=1

Вопросы для самоконтроля

  1. Что такое класс? Из чего он состоит?
  2. Что такой объект? Объясните разницу между объектом и классом. Что называют состоянием объекта? Поведением?
  3. Что такое конструктор? Для чего он нужен?
  4. Что такое переменная экземпляра и переменная класса?
  5. Перечислите и опишите все известные вам разновидности пространств имен.
  6. Что такое инкапсуляция? Какие основные ее принципы?
  7. Что такое наследование? Какие классы наследуются от класса object? Что такое множественное наследование?
  8. Как связаны наследование и полиморфизм? Что такое переопределение метода?
  9. Что такое композиция? Как еще она называется?
  10. Опишите работу механизма обработки исключений.
  11. Что такое декоратор?
  12. Для чего нужна перегрузка операций?
  13. Что такое вызываемый объект? Как его создать?
  14. Что такое класс-примесь?
  15. Что такое интерфейс? Зачем он нужен? Что такое абстрактный базовый класс?

Задание

  1. Для типа TextInfo вместо методов get_symbols_count, get_words_count и get_sentences_count создайте свойства, доступные только для чтения (для этого просто не нужно определять setter-метод).
  2. Для типа Circle реализуйте в явном виде все операции сравнения (<, <=, ==, !=, >=, >), но с одним условием - вычислять площадь можно только в методе __lt__, а все остальные не должны внутри себя содержать этот код, но могут использовать метод __lt__ для решения своей задачи. Тривиальный пример - метод __ge__ для реализации которого можно вызвать метод __lt__ и инвертировать результат.
  3. Доделайте тип MyComplex. Нужно добавить в него реализацию специальных методов для вычитания, умножения и деления комлексных чисел, а также метод для создания объектов MyComplex из показательной формы записи $r*e^{i\varphi}$ (используйте для него декоратор classmethod).
  4. Модифицируйте пример с абстрактным базовым классом Shape:
    1. вместо абстрактного метода get_area используйте абстрактное свойство area
    2. добавьте абстрактное свойство perimeter и определите его в наследниках
    3. создайте исключение, которое генерируется в случае, если конкретные фигуры создаются с некорректными параметрами
    4. добавьте к базовому типу Shape поддержку операций сравнения (сравнивать фигуры нужно по их площади)
    5. реализуйте еще один дочерний класс Triangle