Краткий курс ООП на Python: как избежать путаницы в коде
Самая популярная парадигма современной разработки: обучаем питонистов на кошечках, напитках и вечеринках.
Иллюстрация: Катя Павловская для Skillbox Media
Объектно-ориентированное программирование применяют практически все крупные компании, потому что эта методика упрощает разработку. Но в то же время её боятся многие начинающие разработчики. Поэтому в этой статье мы покажем, что это на самом деле не так уж и сложно.
Зачем придумали ООП
Краеугольное понятие в ООП — объект. Это такой своеобразный контейнер, в котором сложены данные и прописаны действия, которые можно с этими данными совершать.
Чтобы понять, чем объекты так полезны и для чего их изобрели, сравним ООП с другой методикой разработки — процедурной. В ней весь код можно поделить на два вида: основную программу и вспомогательные функции, которые могут вызываться как программой, так и другими функциями:
У такого программирования есть существенный недостаток — части кода сильно зависят друг от друга. Например, основная программа вызывает функцию, та вызывает вторую, та, в свою очередь, — третью. При этом, допустим, вторую функцию могут параллельно вызывать ещё несколько других, а также основная программа. Схематически вся эта процедурная путаница представлена на рисунке:
Если мы изменим какую-нибудь функцию, то остальные части кода могут быть к этому не готовы — и сломаются. Тогда придётся переписывать ещё и их, а они, в свою очередь, завязаны на другие функции. В общем, проще будет написать новую программу с нуля.
Кроме того, в процедурном программировании нередко приходится дублировать код и писать похожие функции с небольшими различиями. Например, чтобы поддерживать совместимость разных частей программы друг с другом.
Логика ООП совершенно иная: к основной программе подключаются не функции, а объекты, внутри которых уже лежат собственные переменные и функции. Так выстраивается более иерархичная структура. Переменные внутри объектов называются полями, или атрибутами, а функции — методами.
Объекты независимы друг от друга и самодостаточны, так что, если мы сломаем что-то в одном объекте, это никак не отразится на других. Более того: даже если мы полностью изменим содержание объекта, но сохраним его поведение, весь код продолжит работать.
Как работают классы
Каждый объект в ООП строится по определённому классу — абстрактной модели, описывающей, из чего состоит объект и что с ним можно делать.
Например, у нас есть класс «Кошка», обладающий атрибутами «порода», «окрас», «возраст» и методами «мяукать», «мурчать», «умываться», «спать». Присваивая атрибутам определённые значения, можно создавать вполне конкретные объекты.
Допустим:
- Порода = абиссинская.
- Окрас = рыжий.
- Возраст = 4.
Таким образом мы можем создать сколь угодно много разных кошек:
При этом любой объект класса «Кошка» (неважно, рыжая она, серая или чёрная) будет мяукать, мурчать, умываться и спать — если мы пропишем соответствующие методы.
Принципы ООП на Python
Всё объектно-ориентированное программирование строится на четырёх понятиях: инкапсуляции, наследовании, полиморфизме и абстракциях. Поэтому давайте объявим наш класс «Кошка» и будем объяснять ООП на нём:
Метод __init__ — инициализатор класса. Он вызывается сразу после создания объекта, чтобы присваивать значения динамическим атрибутам. self — ссылка на текущий объект, она даёт доступ к атрибутам и методам, с которыми вы работаете. Её аналог в других языках программирования — this.
Примечание 1. Слово self общепринятое, но не обязательное, вместо него можно использовать любое другое. Однако это может запутать тех, кто будет читать ваш код.
Примечание 2. Названия классов принято писать с прописной буквы, а объектов — со строчной.
Итак, мы создали класс Cat, в котором объявили три атрибута: порода — breed, цвет — color и возраст — age. А ещё добавили два метода, чтобы наша кошка умела мяукать — meow() и мурчать — purr().
Давайте создадим пару объектов нашего класса:
Отлично, теперь, когда у нас есть основа, приступим к изучению принципов ООП.
Инкапсуляция
Доступ к данным объекта должен контролироваться, чтобы пользователь не мог изменить их в произвольном порядке и что-то поломать. Поэтому для работы с данными программисты пишут методы, которые можно будет использовать вне класса и которые ничего не сломают внутри.
Вернёмся к нашим кошечкам. Мы можем разрешить изменять атрибут «возраст», но только в большую сторону, а атрибуты «порода» и «цвет» лучше открыть только для чтения — ведь порода кошки не меняется, а цвет если и меняется, то не по её инициативе.
В нашем классе «Кошка» мы сделали все атрибуты открытыми, поэтому давайте это исправим:
Код стал выглядеть немного сложнее, но мы сейчас всё объясним. Сначала мы сделали все атрибуты закрытыми с помощью символа _. Он говорит интерпретатору, что эта переменная будет доступна только внутри методов класса.
Нам всё ещё нужно получать доступ к атрибутам, поэтому мы предоставляем его через @property и объявляем для каждого атрибута свой метод — breed, color, age. В каждом из этих методов мы возвращаем значение нашего закрытого атрибута. Это делает его доступным только для чтения.
И последнее — мы должны позволить пользователям увеличивать возраст кота. Для этого воспользуемся @age.setter и ещё раз объявим метод age, а внутри него напишем простое условие и вернём значение атрибута.
Теперь создадим экземпляр класса:
Выведем значения атрибутов:
И попробуем изменить атрибут age:
Всё успешно. А теперь сделаем это с другим атрибутом:
Мы получили ошибку, потому что запретили изменять этот атрибут.
Читайте также:
Наследование
Классы могут передавать свои атрибуты и методы классам-потомкам. Например, мы хотим создать новый класс «Домашняя кошка». Он практически идентичен классу «Кошка», но у него появляются новые атрибуты «хозяин» и «кличка», а также метод «клянчить вкусняшку».
Достаточно объявить «Домашнюю кошку» наследником «Кошки» и прописать новые атрибуты и методы — вся остальная функциональность перейдёт от родителя к потомку.
Давайте объявим новый класс:
В первой строке мы как раз наследуем все методы и атрибуты класса Cat. А чтобы всё создалось корректно, мы должны вызвать метод super() в методе __init__() и через него заполнить атрибуты класса-родителя. Поэтому мы и передаём в этот метод «породу», «окрас» и «возраст».
Кроме атрибутов для класса-родителя у класса-потомка есть и собственные атрибуты: «хозяин» — owner и «кличка» — name. Их мы будем использовать только в этом классе, поэтому они будут недоступны для класса-родителя.
Мы сразу сделали атрибуты класса-потомка закрытыми и объявили для них собственные методы. А также добавили метод getTreat(), которого нет в классе-родителе.
Давайте создадим объект класса:
Как видим, у нас работают и новые методы, и старые. Наследование прошло успешно.
Полиморфизм
Этот принцип позволяет применять одни и те же команды к объектам разных классов, даже если они выполняются по-разному. Например, помимо класса «Кошка», у нас есть никак не связанный с ним класс «Попугай» — и у обоих есть метод «спать». Несмотря на то что кошки и попугаи спят по-разному (кошка сворачивается клубком, а попугай сидит на жёрдочке), для этих действий можно использовать одну команду.
Допустим у нас есть два класса — «Кошка» и «Попугай»:
А теперь пусть у нас есть метод, который ожидает, что ему на вход придёт объект, у которого будет метод sleep:
Посмотрим, как это будет работать:
Хотя классы разные, их одноимённые методы работают похожим образом. Это и есть полиморфизм.
Читайте также:
Абстракция
При создании класса мы упрощаем его до тех атрибутов и методов, которые нужны именно в этом коде, не пытаясь описать его целиком и отбрасывая всё второстепенное. Например, у всех хищников есть метод «охотиться», поэтому все животные, которые являются хищниками, автоматически будут уметь охотиться.
Рассмотрим класс Predator:
Этот класс будет общим для всех животных, которые являются хищниками, — например, кошек:
У кошки есть свои атрибуты: «имя» — name и «окрас» — color. Но при этом она потомок хищников, а значит, умеет охотиться:
Читайте также:
Примеры реализации ООП на Python
Давайте ещё пофантазируем и посоздаём классы.
Представьте ситуацию: вашего друга пригласили на пафосную вечеринку в закрытый клуб. Там довольно странный этикет: в разное время все должны пить строго определённые напитки. Причём любой из них, в зависимости от ситуации, все пьют определённым способом: или обычными глотками по 20 мл, или маленькими по 10, или залпом всё, что осталось. Более того: размер глотка для одного и того же напитка может внезапно поменяться по ходу вечеринки.
Вы выучиваете все эти дурацкие правила и вызываетесь помочь другу, но общаться с ним можете только через микронаушник. Таким образом, друг становится интерфейсом вашего взаимодействия с напитками.
Для начала создадим класс Drink:
У любого напитка есть атрибуты: название, стоимость в рублях и объём в миллилитрах. Предположим для простоты, что на нашей вечеринке принято всегда пить из посуды одинакового объёма (200 мл), а остальные атрибуты могут меняться от напитка к напитку.
Соответственно, объём — это статический атрибут, неизменный во всех объектах класса. Название и стоимость, напротив, — динамические: они принадлежат не всему классу в целом, а конкретному объекту, и их значение определяется уже после его создания.
Создадим объект coffee — экземпляр класса Drink. В нашем примере создание нового объекта обозначает заказ нового напитка:
Теперь у нас есть объект coffee, который содержит статический атрибут volume, полученный от класса Drink, и динамические атрибуты name и price, которые мы указали при создании объекта. Давайте попробуем к ним обратиться:
Так как статические атрибуты определяются на уровне класса, то и обращаться к ним можно не только через объект, но и через сам класс:
К динамическим атрибутам мы так обратиться не сможем.
Итак, напиток заказан, и с ним нужно что-то делать. Так как вы общаетесь через микронаушник, то не видите, в каком состоянии напиток друга. Что ж, попросим друга сообщить вам об этом. Для этого добавим ещё один метод внутри класса Drink:
Тусовка делает первый глоток. Скомандуем другу, чтобы он присоединился. Для этого нужен ещё один динамический атрибут remains, информирующий нас, сколько миллилитров напитка осталось. Изначально остаток будет равен объёму посуды. После этого прописываем метод, указывающий товарищу, сколько конкретно глотать в соответствии с этикетом:
Уровни доступа в Python
Чтобы нам не приходилось каждый раз проверять, хватает ли напитка для нужного глотка, напишем служебный метод _is_enough. Затем перепишем метод sip и добавим методы small_sip и drink_all:
Обратите внимание ещё на такой нюанс: в строке coffee.remains = 10 мы извне вмешались в объект и приравняли его атрибут remains к 10. Это удалось потому, что все атрибуты и методы в Python по умолчанию являются публичными, то есть доступными извне.
Чтобы регулировать вмешательство во внутреннюю работу объекта, в ООП есть несколько уровней доступа: публичный (public), защищённый (protected) и приватный (private). Защищённые атрибуты и методы можно вызывать только внутри класса и его классов-наследников. Приватные — только внутри класса: даже наследники не имеют доступа к ним.
В Python это реализовано следующим образом: перед защищёнными атрибутами и методами пишут одинарное нижнее подчёркивание (_example), перед приватными — двойное (__example). Именно это мы сделали в методе _is_enough. Одинарным нижним подчёркиванием мы объявили его защищённым.
При этом в Python само по себе объявление атрибутов и методов защищёнными и приватными не ограничивает доступ к ним извне. Мы всё ещё можем вызвать метод _is_enough из любого места программы:
Атрибуты и методы, объявленные приватными, вызвать напрямую уже нельзя, но есть обходной путь:
Примечание. Возможность игнорировать уровни доступа — нарушение важного для ООП принципа инкапсуляции. Поэтому, несмотря на наличие технической возможности, программисты, пишущие на Python, договорились не обращаться к защищённым и приватным методам откуда-то извне.
Так что и мы объявим защищёнными атрибуты volume и remains, чтобы помнить: ими стоит пользоваться только внутри класса Drink и его наследников. Теперь всё выглядит так:
Наследование в Python
Вечеринка идёт полным ходом. Но тут случается непредвиденное: ваш друг, который уже слегка приспособился к местным нравам и даже начал получать удовольствие, внезапно кричит вам в наушник, что вечер вновь перестаёт быть томным. «Они объявили время соков! — паникует он. — А у каждого сока свой вкус, тут чёрт ногу сломит!»
Действительно. Хьюстон, у нас проблемы. Сок, на первый взгляд, — напиток как напиток: его тоже можно пить глотками и залпом, у него есть цена и объём. Но, как пел гражданин Шнуров, есть один момент: в отличие от любого напитка, у сока появляется новый, специфический атрибут, который не поддерживается классом Drink, — вкус фрукта или ягоды, из которых он выжат.
Не паникуем: даже из самой сложной ситуации всегда есть как минимум два выхода. Можно, конечно, полностью скопировать класс Drink и изменить в этой копии всё, что нам нужно. Но мы поступим изящнее — создадим класс Juice и сделаем его наследником класса Drink:
Примечание. Обратите внимание, что из класса-потомка мы не можем напрямую обратиться к приватным атрибутам и методам класса-родителя.
Создаём объект класса Juice и вызываем в нём методы, унаследованные от родительского класса Drink:
Родительский класс Drink поделился с потомком своими атрибутами и методами, так что нам не пришлось писать их заново.
Теперь посмотрим на атрибут name. В классе Drink, когда мы могли заказать что угодно, от кофе и чая до кваса и коктейля, имело смысл каждый раз указывать название напитка. Но в классе Juice название всегда будет одинаковым: «сок». Тогда зачем всё время при заказе сока спрашивать атрибут name?
Переопределим в классе Juice метод __init__: пусть значением атрибута name всегда будет «сок». И затем снова закажем яблочный сок:
Что же именно происходит при создании объекта apple_juice?
1. Мы вызываем инициализатор класса Juice и в скобках передаём ему аргументы price и taste.
2. Инициализатор класса Juice с помощью функции super() вызывает другой инициализатор — родительского класса Drink.
3. Инициализатор класса Drink просит передать ему аргументы name и price. В качестве аргумента name он получает статический атрибут _juice_name, который мы прописали в классе Juice. А аргумент price подтягивается из инициализатора класса Juice.
4. В инициализаторе класса Drink присваиваются значения атрибутам name, price и _remains.
5. В инициализаторе класса Juice присваивается значение атрибуту taste.
Если вам всё ещё сложно сориентироваться, что откуда берётся и куда передаётся, посмотрите на эту схему. Разными цветами здесь обозначены пути, по которым атрибутам присваиваются их значения:
Итак, вы объяснили другу, что больше не надо каждый раз объявлять, что он хочет именно сок, — а то все подумают, что он деревенщина. Всем и так понятно, что он не компот заказывает. Но посмотрите, что происходит, когда мы просим его сообщить информацию об экземпляре класса Juice:
Он сообщает нам, что пьёт сок, но не говорит, какой именно. Чтобы получить от друга дополнительную информацию, переопределим метод drink_info родительского класса:
Так мы реализовали принцип полиморфизма. Неважно, что пьёт наш друг — кофе или сок, мы можем запросить у него информацию о напитке одной и той же командой drink_info. И приятель уже сам будет ориентироваться по ситуации: если он пьёт сок, то сообщит нам его вкус, а если любой другой напиток — его название.
Примечание. Все классы в Python по умолчанию являются наследниками суперкласса object и наследуют его атрибуты и методы. Такими унаследованными методами, например, являются встроенные __new__, __init__, __del__ и многие другие.
Вечеринка потихоньку подходит к концу, и вашего товарища пока не спалили. Время соков прошло, и каждый теперь волен пить то, что пожелает. Вроде бы можно расслабиться. Но, как вы знаете, у нас и тамада хороший, и конкурсы интересные: посетителей внезапно огорошивают новой затеей. Рассаживайтесь, говорят, за столики в соответствии со стоимостью только что заказанного напитка. Все начинают выкрикивать, почём бокалы в их руках, а официанты отводят их на новые места. Друг снова в ступоре, но мы его спасём.
Так как объявить стоимость можно для любого напитка, пропишем метод tell_price в классе Drink — и дочерний класс Juice автоматически унаследует его:
Теперь проверим, действительно ли он работает с объектами как класса Drink, так и класса Juice:
Профит, коллеги: ваш друг уходит с вечеринки с новой подружкой и приглашением на следующее мероприятие. А всё благодаря вам и объектно-ориентированному программированию.
Что запомнить
Подведём краткие итоги:
- Объектно-ориентированное программирование — распространённая и эффективная парадигма, которая подходит для выполнения многих задач. Здесь основной строительной единицей программы является не функция, а объект, представляющий собой экземпляр некоторого класса.
- ООП строится вокруг четырёх основных принципов: абстракция, инкапсуляция, наследование и полиморфизм.
- Язык Python отлично поддерживает ООП. В нём всё является объектом, даже числа и сами классы. Тем не менее в Python есть баг с уровнями доступа, нарушающий принцип инкапсуляции. Но при ответственном подходе к работе с кодом это не должно стать проблемой.
Больше интересного про код в нашем телеграм-канале. Подписывайтесь!