В этой лекции рассказывается о том, что такое классы и как с помощью них определять собственные сложные типы данных. Отдельно рассматриваются специальные классы, называемые исключениями, которые используются для обработки ошибок, возникающих в процессе выполнения программы. Также мы представим концепцию и основные принципы объектно-ориентированного программирования, которое пришло на смену процедурному, рассмотренному в предыдущей лекции.
Класс - это сложный составной тип данных, состоящий из произвольного количества атрибутов и методов. Атрибутами (иногда называемыми также полями) класса называются его внутренние переменные, а методами - функции, которые выполняют различные операции над ними. Атрибуты и методы определяют структуру, общую для всех конкретных представителей класса, которых называют объектами или экземплярами. Вообще, понятия класса и его объекта связаны друг с другом так же, как, например, понятия "автомобиль" (класс, имеющие такие атрибуты, как тип кузова и мощность) и "toyota corolla" (конкретный представитель, со значениями атрибутов "седан" и "124").
Классы создаются (или по другому - определяются) в Python с помощью инструкции class
, имеющей следующий синтаксис:
def class_name(base_classes): instruction1 ... instructionN
Имя класса class_name должно быть допустимым идентификатором в языке Python. Если имя состоит из нескольких слов, то они записываются слитно без пробелов, при этом каждое слово пишется с заглавной буквы (такой стиль называется CamelCase), например, Circle
, MyClass
, SomeLongNameForClass
.
Предложение base_classes содержит список базовых классов, разделенных запятой (подробнее об этом рассказывает в разделе, посвященном наследованию).
Инструкции instructionK могут быть любыми корректными инструкциями языка Python, однако в большинстве случаев используются иснтрукции присваивания =
и определения функции def
.
class MyClass:
def print_hello(self):
print('hello, object:', id(self))
В примере выше мы создали наш первый очень простой класс, в котором описан только один метод print_hello
. Методы вызываются для конкретного объекта и имеют как минимум один обязательный параметр, который по общепринятому соглашению называется self
. Этот параметр инициализируется самим интерпретатором, и в качестве значения получает ссылку на тот объект, для которого вызван метод.
Итак, для того, чтобы вызывать метод print_hello
, нам для начала нужно создать объект класса MyClass
. Делается это очень просто:
my_object = MyClass() # обратите внимание на скобки после имени класса - их нужно обязательно указывать!
print(type(my_object)) # убедимся, что my_object имеет тип данных MyClass
<class '__main__.MyClass'>
Теперь мы можем вызвать метод print_hello
для объекта my_object
. Делается это тем же способом, как мы вызывали функции из модуля в предыдущей лекции, а именно - с помощью операции .
:
my_object.print_hello()
hello, object: 113878632
Обратите внимание, что несмотря на то, что при определении метода print_hello
мы указали один параметр, при вызове мы ничего в него не передаем. Как говорилось чуть выше, первый параметр класса инициализируется самим интерпретатором, и принимает в качестве значения ссылку на объект, для которого вызвался метод (в нашем случае - my_object
). Если мы создадим второй объект, то увидим, что для него метод print_hello
выведет другое число:
my_object2 = MyClass()
my_object2.print_hello()
hello, object: 113878856
Добавить атрибут к объекту можно с помощью обычной операции присваивания:
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 идентификаторы, начинающиеся и заканчивающиеся двумя символами подчеркивания, зарезервированы для специальных методов и переменных, к которым обращается сам интерпретатор во время выполнения кода. Для обычных идентификаторов не рекомендуется использовать такую схему именования.
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). Чтобы создать такой атрибут, достаточно просто поместить в определение класса инструкцию присваивания для него:
class MyClass:
greeting = 'hello'
def __init__(self, name):
self.name = name
def print_hello(self):
print(self.greeting, self.name)
Статический атрибут является частью класса, а не объекта, поэтому обращаться к нему можно не только через объект, но и через сам класс:
my_object = MyClass('John')
print(my_object.greeting) # правильно, статические атрибуты доступны через любой объект класса
print(MyClass.greeting) # правильно, статические атрибуты доступны через сам класс
# print(MyClass.name) # неправильно, name обычный атрибут, доступен только через объект, если
# выполнить этот код, то будет сгенерировано исключение!
hello hello
Если изменить статический атрибут, обратившись к нему через класс, то изменение будет видно в каждом объекте, а если изменить через объект - то только для данного объекта:
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. Классы и их экземпляры добавляют к ним еще два:
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
.
Из-за того, что атрибуты и методы класса содержатся в собственном пространстве имен, при обращении к ним мы используем операцию .
.
Отметим еще один важный момент: интерпретатор не пытается искать идентификаторы в пространстве имен класса или объекта, если это не запрашивается явно с помощью операции .
. В прошлой лекции мы рассказывали о том, что интерпретатор пытается найти имя вначале в локальном пространстве имен, затем в глобальном и, наконец, во встроенном. Так вот, пространства имен класса и объекта не используются интерпретатором в этой цепочке - любое обращение к атрибутам и методам класса должно выполняться с помощью операции .
, даже если оно осуществляется внутри методов этого же класса.
Это - неочевидное требование, и порой случайное игнорирование его приводит к труднообнаруживаемым ошибкам. Посмотрите на следующий пример и его результат и попробуйте сами объяснить, что в нем произошло:
var = 10 # эта переменная в глобальном пространстве имен
class Test:
var = 20 # эта переменная в пространстве имен класса
def print_var(self):
print(var) # программист хотел вывести на экран значение статического атрибута класса (var)
t = Test()
t.print_var()
10
Такой результат получился потому, что при выполнении метода print_var
интерпретатор вообще не искал имя var
в пространстве имен объекта или класса. Вначале он попытался найти его в локальном пространстве имен, но потерпел неудачу. Затем, в соответствие с упомянутой чуть выше схемой, начал искать имя var
в глобальном пространстве имен, где оно и было обнаружено. Чтобы пример работал так, как хотел разработчик, его нужно исправить:
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
.
В продолжение этого раздела мы рассмотрим ключевые особенности объектно-ориентированных программ, которые используются для достижения следующих целей:
Обе этих цели тесно связаны друг с другом и должны достигаться совместно. Например, улучшая повторную используемость исходного кода, мы уменьшаем его размер (потому что нужно какой-то блок написать всего один раз, а потом просто к нему обращаться), а следовательно упрощаем программу и повышаем степень ее расширяемости.
Инкапсуляцией называется механизм, объединяющий данные и манипулирующий ими код, а также обеспечивающий сокрытие деталей реализации и защищающий объекты от неправильного использования.
Рассмотрим для примера следующий класс, позволяющий получить информацию о произвольном тексте, а именно - количество символов, слов и предложений. Мы не будем обрабатывать сложные ситуации, например, когда текст содержит прямую речь. Будем предполагать, что он состоит из предложений, оканчивающихся на точку, вопросительный или восклицательный знак, а предложения состоят из слов, разделенных пробелами, запятыми, двоеточиями и тире.
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
можно объединить подсчет количества слов и предложений в одном цикле.
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
был сразу возвращен результат, проход в цикле по тексту не понадобился):
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
целиком):
class TextInfo:
...
def set_text(self, text):
self.text = text
self.is_calculated = False
...
Теперь мы спокойно можем писать следующий код:
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
Наша реализация уже почти идеальна! Тем не менее, ее недостаток заключается в том, что по-прежнему есть возможность повредить наш объект, если напрямую изменить его атрибуты:
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 предлагают следовать соглашению, по которому программисты не должны в принципе обращаться в своем коде к атрибутам и методам, начинающимся с одного символа подчеркивания. Учитывая это, правильнее будет так переписать наш пример:
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
:
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
- определяет, является ли класс подклассом другого класса# создаем пустые классы
# инструкция 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.
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
(но не наоборот!):
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'
Давайте теперь продемонстрируем, как используется полиморфизм в объектно-ориентированном программировании.
class A:
def get_class_name(self):
return 'class A'
class B(A):
def get_class_name(self):
return 'class B'
Метод get_class_name
называется полиморфным - для него существует реализация и в родительском, и в дочернем классе. Какой именно вариант использовать, интерепретатор будет решать в зависимости от того, для объекта какого типа он вызывается:
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
Часто в ООП используется прием, когда в обычном (не полиморфном) методе базового класса реализуется общая логика работы, но в некоторых местах вызывается другой метод, который может быть переопределен наследниками. Рассмотрим пример класса, предоставляющего метод для подсчета разного вида символов в тексте.
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
для того, чтобы обеспечить подсчет только конкретных символов.
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
Теперь мы можем написать небольшую программу, которая подсчитывает количество разных видов символов в тексте и выводит его на экран.
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
, потому что только он обеспечивает абсолютную точность при вычислениях с вещественными числами.
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
, возвращающей специальный объект, через который доступны атрибуты и методы базового класса.
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
С помощью представленных классов мы можем таким образом реализовать функцию для определения точки пересечения двух прямых, отрезков или прямой и отрезка:
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
Композицией или агрегированием в ООП называется методика создания класса из уже существующих путем их использования внутри нового класса для реализации его методов.
В качестве примера снова рассмотрим задачу из геометрии:
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)
{ /*обрабатывается ошибка, когда не удалось послать файл*/ }
...
Существенный недостаток такого подхода заключается в том, что код, в котором выполняется полезная работа, перемешивается с кодом, в котором обрабатываются ошибки, из-за чего программу становится сложнее читать и понимать.
В объектно-ориентированных языках программирования для обработки ошибок используется механизм исключений, базовый принцип работы которого заключается в следующем:
В 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
, которое является как раз тем, что нам нужно.
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
. Часто собственные исключения представляют собой пустой класс, потому что вся необходимая функциональность уже есть в базовых классах.
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
и хотя бы одно предложение except
или finally
, все остальное можно не указывать, если в этом нет необходимости.
Список перехватываемых исключений exception_list_K может отсутствовать в предложении except
. В этом случае, перехватываться будет исключение любого типа.
Часть as предложения except
также является необязательной. Она используется для того, чтобы при перехвате исключения создать новую переменную с именем variableK, и присвоить ей ссылку на объект исключения. Тогда в обработчике можно будет обращаться к данным, хранящимся в исключении.
Начнем рассматривать примеры обработки исключений, начиная с простых и двигаясь к все более сложным. Для начала реализуем функцию деления в старом процедурном стиле:
def divide_old(a, b):
if b == 0:
return None
return a / b
С помощью такой функции мы можем написать такую программу:
a = 10
b = 5
result = divide_old(a, b)
result += 1
print(result)
print('program is finished')
3.0 program is finished
С этим кодом все в порядке ровно до тех пор, пока переменная b
не окажется равной 0:
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
отработала без ошибок:
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
, использующей исключение при попытке деления на ноль:
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
:
try:
print(sqrt(-1))
except SqrtError as err:
print(err)
("Can't extract square root!", -1)
В одном предложении except
можно перечислить сразу несколько типов исключений, например так:
try:
result = divide(-8, 2)
print(sqrt(result))
except (ZeroDivisionError, SqrtError) as err:
print(err)
("Can't extract square root!", -4.0)
Если разные типы исключений нужно обрабатывать по-разному, то для этого потребуется использовать несколько предложений except
:
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
, см. здесь):
try:
print(divide(1, 0))
except ArithmeticError as err:
print(err)
Can't divide 1 by 0!
Про эту особенность выбора обработчика исключения стоит помнить, иначе можно допустить труднообнаруживаемую ошибку, например:
try:
print(divide(1, 0))
except ArithmeticError:
print('not interesting error')
except ZeroDivisionError as err:
# никогда не выполнится, потому что при возникновении этого исключения,
# первым для него будет найден предыдущий обработчик
print(err)
not interesting error
Чтобы этот пример работал правильно, нужно переписать его таким образом:
try:
print(divide(1, 0))
except ZeroDivisionError as err:
# обрабатываем ошибку, связанную с делением на ноль
print(err)
except ArithmeticError:
# обрабатываем все остальные арифметические ошибки
print('not interesting error')
Can't divide 1 by 0!
Как мы уже говорили, если тип возникшего исключения не был найден среди типов, указанных в предложениях except
, интерпретатор начинает поиск обработчика в функции, вызвавшей ту, где произошло исключение. Этот процесс продолжается до тех пор, пока не найден соответствующий обработчик, либо пока интерпретатор не дойдет до функции самого верхнего уровня.
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 завершился нормально (не было сгенерировано исключение). Это может быть полезно в такой ситуации:
# предположим, что это некоторая сложная функция, которая генерирует
# различные исключения (в нашем примере, конечно, это не так)
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, пока лишь скажем, что вначале файл нужно открыть, а после работы с ним - закрыть. Если этого не сделать, то изменения могут не сохраниться. Один из способов гарантировать, что открытый файл всегда будет закрыт, такой:
# определим функции, которые как бы "работают" с файлом
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
) для доступа к нему.
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
Теперь мы можем писать такой код:
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
. Для создания его объектов мы должны передать параметры прямой, на которой лежит отрезок, а также два значения, которые ограничивают эту прямую слева и справа - полезно было бы также дать возможность создавать отрезок, просто указывая две точки. Второй момент - мы предполагаем, что операция получения длины будет часто использоваться сама по себе, поэтому нам бы не хотелось каждый раз создавать объект для того, чтобы выполнить лишь этот метод. Ну и наконец, добавим проверки на случай, если отрезок создается неправильно.
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
, но теперь определим для него метод, возвращающий площадь круга:
from math import pi
class Circle:
def __init__(self, radius):
self.radius = radius
def get_area(self):
return pi * (self.radius ** 2)
Мы бы хотели иметь возможность сравнивать два круга по их площади. Если мы сейчас попытаемся воспользоваться для этой цели операцией ==
, то всегда будем получать результат False
, поскольку интерпретатор не знает, как нужно обрабатывать ее для объектов наших типов:
c1 = Circle(3)
c2 = Circle(3)
print(c1 == c2)
False
Конечно, мы могли бы определить для этой цели метод и использовать его, но код при этом станет менее удобочитаемым. Лучшим вариантом будет перегрузка операции ==
для типа Circle
. Для этого нам нужно определить в нем специальный метод __eq__
, который ищется интерпретатором, когда он выполняет операцию ==
. В примерах ниже мы дополнительно выводим некоторую информацию об объектах, чтобы удобнее было анализировать результаты программы.
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) |
Если для типа определена операция ==
, то операцию !=
переопределять не обязательно - в случае ее отсутствия интерпретатор просто воспользуется операцией ==
, а затем возьмет противоположный результат:
c1 = Circle(3)
c2 = Circle(1)
print(c1 != c2)
id=114715784, radius=3 id=114715504, radius=1 id=114715784: Circle.eq True
Для того, чтобы добавить к своему типу поддержку вообще всех операций сравнения, достаточно определить в своем типе лишь три метода:
__eq__
- наличие его в классе позволяет интерпретатору не только выполнять операцию ==
с объектами этого класса, но и операцию !=
, как было показано выше__lt__
или __gt__
, потому что интерпретатор умеет выводить один из другого (выражение "x меньше y" является тем же самым, что выражение "y больше x")__le__
или __ge__
по той же причине, что указана в предыдущем пункте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()
Проверим, как это работает (обратите пристальное внимание на то, для каких именно объектов вызывается тот или иной метод):
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
, который используется для представления комплексных чисел.
# представляет числа в формате 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
), то интерпретатор сгенерирует такое исключение:
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
, которая позволяет узнать, объектом какого класса является переменная. Используя ее, доработаем наш класс:
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
Теперь мы свободно можем писать следующие выражения:
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
не будет знать, что делать:
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
):
__add__
у объекта x
. Если метод найден, то интерпретатор вызывает его с аргументом y
.NotImplemented
, то интерпретатор пытается найти метод __radd__
у объекта y
. Если метод найден, то интерпретатор вызывает его с аргументом x
, а иначе генерирует исключение.Продемонстрируем вышесказанное на простом примере:
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, потому что это позволяет в дальнейшем интегрировать новые типы в существующую систему.
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 называются вызываемыми объектами или функторами. Их прелесть в том, что как объекты, они могут иметь атрибуты и использовать их для хранения некоторого состояния между вызовами. Рассмотрим пример функции, которая генерирует арифметическую прогрессию:
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, например, используется подход, заключающийся в том, что вначале метод ищется в базовом классе, указанном первым в списке, затем во втором и так далее:
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). Объекты таких классов обычно не создаются, потому что основной способ использования примеси заключается в наследовании от нее своих классов и тем самым получении доступа к реализованной в ней функциональности.
# класс, выполняющий операцию 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
, рассмотренном в разделе про перегрузку операторов!), как раз для этого и предназначенное.
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
:
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
Эти классы теперь могут быть использованы таким образом:
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
). Поэтому нам бы не хотелось давать возможность создавать объекты таких классов - это может приводить к случайным ошибкам, если нерелизованные методы будут вызваны. В нашем же примере это допустимо:
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:
Чтобы исправить эту неточность, нам потребуется сделать следующее:
abc
, содержащие необходимые типы для создания абстрактных классов.Shape
указать в качестве метакласса тип ABCMeta
. Мы не будем подробно рассматривать работу с метаклассами, скажем лишь, что они нужны для управления процессом создания классов, то есть играют для них ту же роль, что сами классы играют по отношению к своим объектам.abstractmethod
указать методы, для которых отсутствует реализация в абстрактном классе.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
, то получим следующую ошибку:
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
реализуют все абстрактные методы, их нельзя назвать абстрактными классами, поэтому их объекты можно спокойно создавать:
circle = Circle(10)
rectangle = Rectangle(1, 1)
print_shape_info(circle)
print_shape_info(rectangle)
circle: area=314.1592653589793 rectangle: area=1
object
? Что такое множественное наследование?TextInfo
вместо методов get_symbols_count
, get_words_count
и get_sentences_count
создайте свойства, доступные только для чтения (для этого просто не нужно определять setter-метод).Circle
реализуйте в явном виде все операции сравнения (<
, <=
, ==
, !=
, >=
, >
), но с одним условием - вычислять площадь можно только в методе __lt__
, а все остальные не должны внутри себя содержать этот код, но могут использовать метод __lt__
для решения своей задачи. Тривиальный пример - метод __ge__
для реализации которого можно вызвать метод __lt__
и инвертировать результат.MyComplex
. Нужно добавить в него реализацию специальных методов для вычитания, умножения и деления комлексных чисел, а также метод для создания объектов MyComplex
из показательной формы записи $r*e^{i\varphi}$ (используйте для него декоратор classmethod
).Shape
:get_area
используйте абстрактное свойство area
perimeter
и определите его в наследникахShape
поддержку операций сравнения (сравнивать фигуры нужно по их площади)Triangle