ООП для новичков: инкапсуляция, наследование и полиморфизм
easy

ООП для новичков: инкапсуляция, наследование и полиморфизм

Объясняем без машин

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

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

  • Объект — это коробка, в которой лежат какие-то рабочие части. Объект может действовать как единое целое и исполнять полезную работу в программе. Объект можно создать вручную или автоматически, как на конвейере.
  • Класс — это инструкция для конвейера, на котором можно собирать объекты. В классе прописывают, какой должна быть «коробка» при её создании, какие полезные штуки в ней должны лежать.
  • Все объекты на основе одного и того же класса обладают одними и теми же свойствами и умеют одно и то же, по крайней мере при создании.
  • Объекты могут отличаться друг от друга разными параметрами, которые мы указываем при создании. Например, с конвейера могут сходить красные, синие и зелёные шары из резины разной плотности, но все они отлиты по одной форме и различаются только материалом и цветом.
  • У объектов есть методы — это то, что объект умеет делать сам или что он позволяет сделать пользователю. Например, если объект — часы, то у него могут быть методы «установить текущее время» и «поставить будильник».
  • У всех объектов, созданных на основе одного класса, будут одинаковые методы.

Сила объектов в ООП в том, что мы можем один раз описать нужные нам свойства и поведения в классе, а потом на основе этого класса наштамповать сколько угодно объектов. Они будут работать независимо друг от друга и могут различаться своими характеристиками, но логика у них будет одинаковая. Самый простой пример объектов — толпа в игровом мире: это просто объекты одного и того же класса «прохожий», но с разными параметрами внешности при создании.

Что такое ООП

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

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

Объектно-ориентированное программирование: на пальцах

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

Объект можно представить как независимый электроприбор у вас на кухне. Чайник кипятит воду, плита греет, блендер взбивает, мясорубка делает фарш. Внутри каждого устройства куча всего: моторы, контроллеры, кнопки, пружины, предохранители — но вы о них не думаете. Вы нажимаете кнопки на панели каждого прибора, и он делает то, что от него ожидается. И благодаря совместной работе этих приборов у вас получается ужин.

✅ Плюсы объектно-ориентированного программирования:

  1. Инкапсуляция позволяет скрыть детали реализации от пользователя, предоставляя только необходимый интерфейс. Это упрощает использование кода и уменьшает вероятность ошибок.
  2. Классы могут наследовать свойства и методы от других классов, что позволяет переиспользовать код и упрощает его поддержку.
  3. Полиморфизм позволяет оъектам обрабатываться как их базовые типы или как любой тип, который они наследуют. Это позволяет писать более общий и переиспользуемый код.
  4. ООП легко разделяет функционал на отдельные модули.
  5. ООП позволяет работать с высокоуровневыми структурами, игнорируя низкоуровневые детали реализации.

🤔 Минусы ООП:

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

Что делаем 

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

# подключаем графическую библиотеку
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)

Что дальше

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

С чего начать карьеру в ИТ и другие вопросы гуманитариев
Скачайте бесплатный гайд журнала «Код», чтобы ответить на все вопросы о старте в программировании
Скачать бесплатно
С чего начать карьеру в ИТ и другие вопросы гуманитариев С чего начать карьеру в ИТ и другие вопросы гуманитариев С чего начать карьеру в ИТ и другие вопросы гуманитариев С чего начать карьеру в ИТ и другие вопросы гуманитариев
Получите ИТ-профессию
В «Яндекс Практикуме» можно стать разработчиком, тестировщиком, аналитиком и менеджером цифровых продуктов. Первая часть обучения всегда бесплатная, чтобы попробовать и найти то, что вам по душе. Дальше — программы трудоустройства.
Начать карьеру в ИТ
Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию Получите ИТ-профессию
Еще по теме
easy