В программировании есть элитная и сложная для понимания тема — ООП, объектно-ориентированное программирование. Объекты используются почти везде — от игр и программ с графическим интерфейсом до сложных серверных приложений. Проблема в том, что концепции ООП довольное неинтуитивные. Мы постараемся эту проблему решить.
Сегодня будет теория про инкапсуляцию, наследование и полиморфизм — три основные слова в ООП. Для этого нам понадобится вспомнить статью про классы и объекты на примере компьютерной игры:
- Объект — это коробка, в которой лежат какие-то рабочие части. Объект может действовать как единое целое и исполнять полезную работу в программе. Объект можно создать вручную или автоматически, как на конвейере.
- Класс — это инструкция для конвейера, на котором можно собирать объекты. В классе прописывают, какой должна быть «коробка» при её создании, какие полезные штуки в ней должны лежать.
- Все объекты на основе одного и того же класса обладают одними и теми же свойствами и умеют одно и то же, по крайней мере при создании.
- Объекты могут отличаться друг от друга разными параметрами, которые мы указываем при создании. Например, с конвейера могут сходить красные, синие и зелёные шары из резины разной плотности, но все они отлиты по одной форме и различаются только материалом и цветом.
- У объектов есть методы — это то, что объект умеет делать сам или что он позволяет сделать пользователю. Например, если объект — часы, то у него могут быть методы «установить текущее время» и «поставить будильник».
- У всех объектов, созданных на основе одного класса, будут одинаковые методы.
Сила объектов в ООП в том, что мы можем один раз описать нужные нам свойства и поведения в классе, а потом на основе этого класса наштамповать сколько угодно объектов. Они будут работать независимо друг от друга и могут различаться своими характеристиками, но логика у них будет одинаковая. Самый простой пример объектов — толпа в игровом мире: это просто объекты одного и того же класса «прохожий», но с разными параметрами внешности при создании.
Что такое ООП
Основная задача ООП — сделать сложный код проще. Для этого программу разбивают на независимые блоки, которые мы называем объектами.
Объект — это не какая-то космическая сущность. Это всего лишь набор данных и функций — таких же, как в традиционном функциональном программировании. Можно представить, что просто взяли кусок программы и положили его в коробку и закрыли крышку. Вот эта коробка с крышками — это объект.
Программисты договорились, что данные внутри объекта будут называться свойствами, а функции — методами. Но это просто слова, по сути это те же переменные и функции.
Объект можно представить как независимый электроприбор у вас на кухне. Чайник кипятит воду, плита греет, блендер взбивает, мясорубка делает фарш. Внутри каждого устройства куча всего: моторы, контроллеры, кнопки, пружины, предохранители — но вы о них не думаете. Вы нажимаете кнопки на панели каждого прибора, и он делает то, что от него ожидается. И благодаря совместной работе этих приборов у вас получается ужин.
✅ Плюсы объектно-ориентированного программирования:
- Инкапсуляция позволяет скрыть детали реализации от пользователя, предоставляя только необходимый интерфейс. Это упрощает использование кода и уменьшает вероятность ошибок.
- Классы могут наследовать свойства и методы от других классов, что позволяет переиспользовать код и упрощает его поддержку.
- Полиморфизм позволяет оъектам обрабатываться как их базовые типы или как любой тип, который они наследуют. Это позволяет писать более общий и переиспользуемый код.
- ООП легко разделяет функционал на отдельные модули.
- ООП позволяет работать с высокоуровневыми структурами, игнорируя низкоуровневые детали реализации.
🤔 Минусы ООП:
- Объектно-ориентированный код может быть более сложным для понимания и написания.
- Иногда ООП может приводить к небольшой потере производительности из-за дополнительных уровней абстракции.
- Некоторые задачи лучше решать с помощью других подходов к написанию кода, например, функционального или процедурного программирования.
- В ООП легко переборщить с количеством классов и иерархиями наследования и этим сильно усложнить код.
- ООП не всегда хорошо сочетается с многопоточным программированием из-за неочевидного управления состоянием объектов.
Что делаем
В прошлой статье из этого цикла у нас была игра в шарики. Мы сделали игровое поле и поместили на него объекты шариков, которые что-то умели. Мы возьмём код из прошлой статьи и на её основе разберём три новых термина в ООП — инкапсуляцию, наследование и полиморфизм. В разных языках программирования реализация этого выглядит по-разному, но в основе база всегда одна. Эту базу и будем разбирать.
# подключаем графическую библиотеку
from tkinter import *
# подключаем модули, которые отвечают за время и случайные числа
import time
# создаём новый объект — окно с игровым полем. В нашем случае переменная окна называется tk, и мы его сделали из класса Tk() — он есть в графической библиотеке
tk = Tk()
# делаем заголовок окна — Games с помощью свойства объекта title
tk.title('Разбираем ООП')
# запрещаем менять размеры окна, для этого используем свойство resizable
tk.resizable(0, 0)
# помещаем наше игровое окно выше остальных окон на компьютере, чтобы другие окна не могли его заслонить. Попробуйте 🙂
tk.wm_attributes('-topmost', 1)
# создаём новый холст — 400 на 500 пикселей, где и будем рисовать игру
canvas = Canvas(tk, width=500, height=400, highlightthickness=0)
# говорим холсту, что у каждого видимого элемента будут свои отдельные координаты
canvas.pack()
# обновляем окно с холстом
tk.update()
# Описываем класс, который будет отвечать за шарики
class Ball:
# конструктор — он вызывается в момент создания нового объекта на основе этого класса
def __init__(self, canvas, color, x, y, up, down, left, right):
# задаём параметры нового объекта при создании
# игровое поле
self.canvas = canvas
# координаты
self.x = 0
self.y = 0
# цвет нужен был для того, чтобы мы им закрасили весь шарик
# здесь появляется новое свойство id, в котором хранится внутреннее название шарика
# а ещё командой create_oval мы создаём круг радиусом 15 пикселей и закрашиваем нужным цветом
self.id = canvas.create_oval(10,10, 25, 25, fill=color)
# помещаем шарик в точку с переданными координатами
self.canvas.move(self.id, x, y)
# если нажата стрелка вправо — двигаемся вправо
self.canvas.bind_all(right, self.turn_right)
# влево
self.canvas.bind_all(left, self.turn_left)
# наверх
self.canvas.bind_all(up, self.turn_up)
# вниз
self.canvas.bind_all(down, self.turn_down)
# шарик запоминает свою высоту и ширину
self.canvas_height = self.canvas.winfo_height()
self.canvas_width = self.canvas.winfo_width()
# движемся вправо
# смещаемся на 2 пикселя в указанную сторону
def turn_right(self, event):
# получаем текущие координаты шарика
pos = self.canvas.coords(self.id)
# если не вышли за границы холста
if not pos[2] >= self.canvas_width:
# будем смещаться правее на 2 пикселя по оси х
self.x = 2
self.y = 0
# влево
def turn_left(self, event):
pos = self.canvas.coords(self.id)
if not pos[0] <= 0:
self.x = -2
self.y = 0
# вверх
def turn_up(self, event):
pos = self.canvas.coords(self.id)
if not pos[1] <= 0:
self.x = 0
self.y = -2
# вниз
def turn_down(self, event):
pos = self.canvas.coords(self.id)
if not pos[3] >= self.canvas_height:
self.x = 0
self.y = 2
# метод, который отвечает за отрисовку шарика на новом месте
def draw(self):
# передвигаем шарик на заданный вектор x и y
self.canvas.move(self.id, self.x, self.y)
# запоминаем новые координаты шарика
pos = self.canvas.coords(self.id)
# если коснулись левой стенки
if pos[0] <= 0:
# останавливаемся
self.x = 0
# верхней
if pos[1] <= 0:
self.y = 0
# правой
if pos[2] >= self.canvas_width:
self.x = 0
# нижней
if pos[3] >= self.canvas_height:
self.y = 0
# создаём шарик — объект на основе класса
ball_one = Ball(canvas,'red', 150, 150, '<KeyPress-Up>', '<KeyPress-Down>', '<KeyPress-Left>', '<KeyPress-Right>')
# создаём второй шарик — другой объект на основе этого же класса, но с другими параметрами
ball_two = Ball(canvas,'green', 100, 100, '<w>', '<s>', '<a>', '<d>')
# запускаем бесконечный цикл
while not False:
# рисуем шарик
ball_one.draw()
ball_two.draw()
# обновляем наше игровое поле, чтобы всё, что нужно, закончило рисоваться
tk.update_idletasks()
# обновляем игровое поле и смотрим за тем, чтобы всё, что должно было быть сделано — было сделано
tk.update()
# замираем на одну сотую секунды, чтобы движение элементов выглядело плавно
time.sleep(0.01)
Наследование
Наследование — самый простой механизм в ООП, который в общем виде звучит так:
потомок при создании получает все свойства и методы родителя.
Родитель — это класс, на основе которого мы создаём что-то новое. Потомок (или дочерний элемент) — это то, что получилось при создании на основе класса или объекта. В Python создавать новые объекты можно только на основе класса, а в некоторых языках — и на основе объекта.
В нашей игре мы два раза использовали наследование — когда создавали объекты «Шарик» на основе класса:
# создаём шарик — объект на основе класса
ball_one = Ball(canvas,'red', 150, 150, '<KeyPress-Up>', '<KeyPress-Down>', '<KeyPress-Left>', '<KeyPress-Right>')
# создаём второй шарик — другой объект на основе этого же класса, но с другими параметрами
ball_two = Ball(canvas,'green', 100, 100, '<w>', '<s>', '<a>', '<d>')
Два объекта, которые у нас получились, получили все свойства и методы родителя — класса Ball:
они умели выводиться на экран в определённом цвете и двигаться.
Также мы можем создать новый класс на основе класса Ball и добавить ему те свойства, которых нет у родителя. Например, мы можем научить шарик отскакивать от стенок — он унаследует все предыдущие свойства и получит одно новое — «При столкновении со стеной — отскочить». Но для этого нам понадобится полиморфизм.
Полиморфизм
Полиморфизм — это когда мы можем вызывать одни и те же методы у разных классов. Внутри они могут работать по-разному, но внешне всё будет выглядеть одинаково.
Классический пример полиморфизма — функция len() в Python, которая возвращает длину указанного в ней объекта:
print(len('Привет, это журнал «Код»!'))
# выведет 25
print(len([1,2,3,4,5]))
# выведет 5
print(len({"Имя": "Михаил", "Фамилия": "Полянин"}))
# выведет 2
Кажется, что это логично, когда функция работает именно так: ты спрашиваешь её длину, она возвращает длину. Но с точки зрения компьютера мы имеем здесь дело с разными типами данных, и совершенно не очевидно, что такое «длина строки» или «длина кортежа». Но благодаря тому, что создатели Python позаботились о полиморфизме, одна и та же функция len() в разных типах данных работает одинаково.
Чтобы добавить полиморфизма в игру, создадим новый класс на основе существующего и укажем свойство, которое мы хотим переопределить. В разделе про наследование мы решили, что научим шарик отпрыгивать от стенок при касании. Для этого нам нужно заменить нулевые значения в методе draw() на новые — так шарик начнёт сразу двигаться в противоположную сторону при касании:
# НАСЛЕДОВАНИЕ
# новый класс на основе старого
class Jumper_ball(Ball):
# ПОЛИМОРФИЗМ
# этот метод называется так же, как и в старом классе, но мы его подменяем новыми действиями
# метод, который отвечает за отрисовку шарика на новом месте
def draw(self):
# передвигаем шарик на заданный вектор x и y
self.canvas.move(self.id, self.x, self.y)
# запоминаем новые координаты шарика
pos = self.canvas.coords(self.id)
# если коснулись левой стенки
if pos[0] <= 0:
# отпрыгиваем в обратную сторону
self.x = 2
# верхней
if pos[1] <= 0:
self.y = 2
# правой
if pos[2] >= self.canvas_width:
self.x = -2
# нижней
if pos[3] >= self.canvas_height:
self.y = -2
Теперь немного поменяем исходный код и сделаем первый шарик на основе старого класса, а второй — на основе нового:
# создаём шарик — объект на основе класса
ball_one = Ball(canvas,'red', 150, 150, '<KeyPress-Up>', '<KeyPress-Down>', '<KeyPress-Left>', '<KeyPress-Right>')
# НАСЛЕДОВАНИЕ
# создаём второй шарик — другой объект на основе другого (дочернего) класса, и с другими параметрами
ball_two = Jumper_ball(canvas,'green', 100, 100, '<w>', '<s>', '<a>', '<d>')
Теперь, когда мы запустим игру, то увидим, что поведение первого шарика осталось таким же, а второй сам отскакивает от стенок. При этом благодаря наследованию второй шарик получил все остальные свойства такие же, как у первого шарика со старым классом:
Инкапсуляция
Напоследок — самое простое в ООП. Инкапсуляция — это когда данные или то, как устроены и работают методы и классы, помещают в виртуальную капсулу, чтобы их нельзя было повредить извне.
Идея в том, чтобы программист пользовался только теми свойствами и методами, которые есть у класса или объекта и не лез внутрь. Именно для этого используют геттеры и сеттеры:
- геттеры нужны, чтобы узнать значение свойства объекта;
- а сеттеры — чтобы установить новое значение этому свойству.
Получается, что доступ к внутренностям объекта возможен только через геттеры и сеттеры. Нельзя просто взять и грязными руками поменять в объекте что-то, что не должно быть изменено.
При этом в сеттерах часто заложена такая логика: если что-то со свойством сделать нельзя, то сеттер этого делать и не будет. Вместо этого он может выдать предупреждение, или установить значение по умолчанию, или вообще ничего не будет делать.
В нашем примере инкапсуляция — это когда мы вызываем метод ball_one.draw() и не лезем внутрь объекта:
- не берём его свойства;
- не лезем в его текущее состояние;
- не проверяем сами его координаты;
- не пытаемся нарисовать его средствами графической библиотеки.
Вместо этого мы вызываем нужный метод и сразу получаем всё, что нужно. В этом и есть смысл инкапсуляции — когда мы не лезем внутрь, а пользуемся тем, что доступно.
# подключаем графическую библиотеку
from tkinter import *
# подключаем модули, которые отвечают за время и случайные числа
import time
# создаём новый объект — окно с игровым полем. В нашем случае переменная окна называется tk, и мы его сделали из класса Tk() — он есть в графической библиотеке
tk = Tk()
# делаем заголовок окна — Games с помощью свойства объекта title
tk.title('Разбираем ООП')
# запрещаем менять размеры окна, для этого используем свойство resizable
tk.resizable(0, 0)
# помещаем наше игровое окно выше остальных окон на компьютере, чтобы другие окна не могли его заслонить. Попробуйте 🙂
tk.wm_attributes('-topmost', 1)
# создаём новый холст — 400 на 500 пикселей, где и будем рисовать игру
canvas = Canvas(tk, width=500, height=400, highlightthickness=0)
# говорим холсту, что у каждого видимого элемента будут свои отдельные координаты
canvas.pack()
# обновляем окно с холстом
tk.update()
# Описываем класс, который будет отвечать за шарики
class Ball:
# конструктор — он вызывается в момент создания нового объекта на основе этого класса
def __init__(self, canvas, color, x, y, up, down, left, right):
# задаём параметры нового объекта при создании
# игровое поле
self.canvas = canvas
# координаты
self.x = 0
self.y = 0
# цвет нужен был для того, чтобы мы им закрасили весь шарик
# здесь появляется новое свойство id, в котором хранится внутреннее название шарика
# а ещё командой create_oval мы создаём круг радиусом 15 пикселей и закрашиваем нужным цветом
self.id = canvas.create_oval(10,10, 25, 25, fill=color)
# помещаем шарик в точку с переданными координатами
self.canvas.move(self.id, x, y)
# если нажата стрелка вправо — двигаемся вправо
self.canvas.bind_all(right, self.turn_right)
# влево
self.canvas.bind_all(left, self.turn_left)
# наверх
self.canvas.bind_all(up, self.turn_up)
# вниз
self.canvas.bind_all(down, self.turn_down)
# шарик запоминает свою высоту и ширину
self.canvas_height = self.canvas.winfo_height()
self.canvas_width = self.canvas.winfo_width()
# движемся вправо
# смещаемся на 2 пикселя в указанную сторону
def turn_right(self, event):
# получаем текущие координаты шарика
pos = self.canvas.coords(self.id)
# если не вышли за границы холста
if not pos[2] >= self.canvas_width:
# будем смещаться правее на 2 пикселя по оси х
self.x = 2
self.y = 0
# влево
def turn_left(self, event):
pos = self.canvas.coords(self.id)
if not pos[0] <= 0:
self.x = -2
self.y = 0
# вверх
def turn_up(self, event):
pos = self.canvas.coords(self.id)
if not pos[1] <= 0:
self.x = 0
self.y = -2
# вниз
def turn_down(self, event):
pos = self.canvas.coords(self.id)
if not pos[3] >= self.canvas_height:
self.x = 0
self.y = 2
# метод, который отвечает за отрисовку шарика на новом месте
def draw(self):
# передвигаем шарик на заданный вектор x и y
self.canvas.move(self.id, self.x, self.y)
# запоминаем новые координаты шарика
pos = self.canvas.coords(self.id)
# если коснулись левой стенки
if pos[0] <= 0:
# останавливаемся
self.x = 0
# верхней
if pos[1] <= 0:
self.y = 0
# правой
if pos[2] >= self.canvas_width:
self.x = 0
# нижней
if pos[3] >= self.canvas_height:
self.y = 0
# НАСЛЕДОВАНИЕ
# новый класс на основе старого
class Jumper_ball(Ball):
# ПОЛИМОРФИЗМ
# этот метод называется так же, как и в старом классе, но мы его подменяем новыми действиями
# метод, который отвечает за отрисовку шарика на новом месте
def draw(self):
# передвигаем шарик на заданный вектор x и y
self.canvas.move(self.id, self.x, self.y)
# запоминаем новые координаты шарика
pos = self.canvas.coords(self.id)
# если коснулись левой стенки
if pos[0] <= 0:
# отпрыгиваем в обратную сторону
self.x = 2
# верхней
if pos[1] <= 0:
self.y = 2
# правой
if pos[2] >= self.canvas_width:
self.x = -2
# нижней
if pos[3] >= self.canvas_height:
self.y = -2
# создаём шарик — объект на основе класса
ball_one = Ball(canvas,'red', 150, 150, '<KeyPress-Up>', '<KeyPress-Down>', '<KeyPress-Left>', '<KeyPress-Right>')
# НАСЛЕДОВАНИЕ
# создаём второй шарик — другой объект на основе другого (дочернего) класса, и с другими параметрами
ball_two = Jumper_ball(canvas,'green', 100, 100, '<w>', '<s>', '<a>', '<d>')
# запускаем бесконечный цикл
while not False:
# рисуем шарик
ball_one.draw()
ball_two.draw()
# обновляем наше игровое поле, чтобы всё, что нужно, закончило рисоваться
tk.update_idletasks()
# обновляем игровое поле и смотрим за тем, чтобы всё, что должно было быть сделано — было сделано
tk.update()
# замираем на одну сотую секунды, чтобы движение элементов выглядело плавно
time.sleep(0.01)
Что дальше
Дальше будем копать глубже: в прототипы, абстракции и интерфейсы. Сложная штука, но без неё иногда никуда.