Объекты и классы: первое знакомство с ООП
Три буквы ООП обычно пугают новичков сильнее всего остального. Их произносят с интонацией «ну, это уже серьёзно», в вакансиях пишут «знание ООП обязательно», а в учебниках сразу вываливают четыре страшных слова: инкапсуляция, наследование, полиморфизм, абстракция.
Хорошая новость: сама идея простая. Настолько простая, что вы, скорее всего, уже ей пользовались, не зная названия. Мы дойдём до неё постепенно — сначала посмотрим, какая боль её породила.
Проблема: данные отдельно, действия отдельно
Мы уже умеем хранить набор данных в словаре и умеем описывать действия функциями. Попробуем этими средствами описать собаку в приюте.
# Собака — словарь: имя и возраст
rex = {"name": "Рекс", "age": 3}
# Что собака умеет — отдельные функции
def bark(dog):
print(dog["name"] + " говорит: Гав!")
def birthday(dog):
dog["age"] = dog["age"] + 1
bark(rex)
birthday(rex)
print(rex["age"])
Вывод:
Рекс говорит: Гав!
4
Работает. И всё же тут неудобно жить, и вот почему:
- Данные и действия разъехались. Словарь
rexлежит в одном месте, функцииbarkиbirthday— в другом. Ничто не связывает их между собой. Если в файле есть ещё и функцияbarkдля тюленей, вы однажды передадите собаку не туда, и Python не возразит. - Никто не гарантирует структуру. Ничто не мешает создать
{"nmae": "Белла"}с опечаткой или забыть возраст. Ошибка вылезет позже и совсем в другом месте. - Каждую собаку надо собирать вручную. Если у собаки станет десять полей, каждое создание превратится в простыню, которую легко заполнить неправильно.
Хочется сказать компьютеру одну фразу: «есть такая штука — собака. У неё всегда есть имя и возраст, и она умеет лаять и стареть». Ровно это и делают классы.
Аналогия: чертёж и изделия
Класс — это чертёж. Объект — конкретное изделие, сделанное по чертежу.
Чертёж стула описывает: у стула четыре ножки, спинка и цвет. Сам чертёж — не стул: на нём нельзя сидеть, он висит на стене в единственном экземпляре. А стульев по нему можно сделать сколько угодно, и все они будут разного цвета, но одинаковой конструкции.
- Класс — один. Объектов по нему — сколько нужно.
- Класс говорит, какие поля будут у каждого объекта, но значения полей у каждого объекта свои.
- Класс говорит, что объект умеет делать.
Где аналогия ломается. Чертёж нельзя изменить после того, как стулья уже сделаны, — а класс в Python можно, и изменения затронут все существующие объекты. Кроме того, изделие можно сделать «не совсем по чертежу», а объект всегда точно соответствует своему классу. Держите это в голове, но не переживайте: на старте аналогия работает почти всегда.
Ещё вы уже встречали объекты, даже если не замечали. Строка "привет" — объект. У неё есть данные (сами буквы) и умения: "привет".upper() возвращает "ПРИВЕТ". Точка перед upper() — это и есть обращение к умению объекта. Список — тоже объект: nums.append(5) — «список, добавь себе пятёрку». Так что синтаксис вам знаком; сейчас мы научимся делать свои такие штуки.
Первый класс
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
Четыре строки, и в них сразу три новых вещи. Разберём построчно.
class Dog:— ключевое словоclass, потом имя класса, потом двоеточие. Имена классов по традиции пишут с большой буквы и в стилеCamelCase(Dog,BankAccount,UserProfile) — так их сразу отличают от функций и переменных, которые пишутсясо_знаком_подчёркивания. Это соглашение из PEP 8, официального стиля Python; Python его не проверяет, но все программисты его соблюдают.def __init__(self, name, age):— обычная функция, объявленная внутри класса (обратите внимание на отступ). Функция внутри класса называется методом. Имя__init__— специальное, о нём ниже.self.name = name— сохраняем то, что нам передали, «внутрь объекта».
Что такое __init__
__init__ (от initialize — «подготовить к работе», два подчёркивания с каждой стороны) — метод, который Python вызывает автоматически в момент создания объекта. Его задача одна: разложить по объекту начальные данные. Его часто называют конструктором, хотя строго говоря он не создаёт объект, а обставляет уже созданный.
rex = Dog("Рекс", 3)
Читается как «сделай мне новую собаку по чертежу Dog, имя Рекс, возраст 3». Обратите внимание: мы пишем имя класса со скобками, как будто вызываем функцию. Так и есть — вызов класса означает «создай объект».
Вот что при этом происходит по шагам:
класса Dog — коробку без содержимого"] B --> C["Python сам вызывает __init__,
подставляя эту коробку первым аргументом (self)"] C --> D["self.name = name → в коробку кладётся name = 'Рекс'"] D --> E["self.age = age → в коробку кладётся age = 3"] E --> F["Заполненная коробка возвращается
и попадает в переменную rex"]
Ключевой момент: __init__ ничего не возвращает. Он не пишет return. Объект возвращает сам Python — задача __init__ только заполнить его.
Что такое self
Это то место, где спотыкаются почти все. Разберём медленно.
Собак может быть много: Рекс, Белла, Тузик. А метод __init__ в классе — один, общий на всех. Когда он выполняется, ему нужно понимать, про какую именно собаку сейчас речь. Вот эту информацию и передаёт self.
self — это просто первый параметр, в который Python автоматически кладёт тот самый объект, с которым сейчас работают. Слово self (англ. «сам») — не ключевое слово языка, а всего лишь традиция; технически можно назвать параметр как угодно, но никто так не делает, и вы не делайте — читающий ваш код человек ожидает self.
Строка self.name = name читается так: «в объекте, с которым сейчас работаем, заведи поле name и положи туда значение параметра name». Слева и справа стоят разные вещи с одинаковыми именами: слева — поле объекта, справа — параметр функции. Их можно было бы назвать по-разному:
class Dog:
def __init__(self, dog_name, dog_age):
self.name = dog_name # слева поле объекта, справа параметр
self.age = dog_age
Работает точно так же. Одинаковые имена используют просто потому, что так меньше выдумывать.
Поля объекта называют атрибутами. К ним обращаются через точку — и снаружи тоже:
rex = Dog("Рекс", 3)
bella = Dog("Белла", 7)
print(rex.name) # Рекс
print(bella.name) # Белла
print(rex.age) # 3
rex.age = 4 # атрибут можно и поменять
print(rex.age) # 4
Два объекта, созданные по одному классу, полностью независимы. Изменили возраст Рекса — Белла об этом не знает.
Методы: что объект умеет
Пока наша собака только хранит данные. Добавим ей умения.
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def bark(self):
print(self.name + " говорит: Гав!")
def birthday(self):
self.age = self.age + 1
print(f"{self.name} теперь {self.age} лет")
def is_puppy(self):
return self.age < 1
rex = Dog("Рекс", 3)
rex.bark()
rex.birthday()
print(rex.is_puppy())
Вывод:
Рекс говорит: Гав!
Рекс теперь 4 лет
False
Что здесь важно:
- Каждый метод обязательно принимает
selfпервым параметром. Даже если больше параметров нет. - Внутри метода к данным объекта обращаются через
self:self.name, а не простоname. БезselfPython будет искать обычную локальную переменную и не найдёт её. - Метод может печатать (
bark), может менять состояние объекта (birthday), может возвращать значение (is_puppy). Это обычная функция, просто живущая внутри класса. - При вызове
rex.bark()скобки пустые, хотя в объявлении стоитself. Python подставляетrexвselfсам. Фактическиrex.bark()— это то же самое, чтоDog.bark(rex), и второй вариант даже можно написать и запустить.
Вот структура класса целиком:
снизу — умения (методы)"
Такие картинки называются диаграммами классов; их рисуют, когда хотят обсудить устройство программы, не показывая код.
Пример побольше: банковский счёт
Классы начинают окупаться там, где у объекта есть состояние, которое надо беречь. Классический пример — счёт с деньгами: баланс не должен становиться отрицательным, а любая операция должна попадать в историю.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
self.history = [] # у каждого счёта своя история операций
def deposit(self, amount):
"""Положить деньги на счёт."""
if amount <= 0:
print("Сумма пополнения должна быть больше нуля")
return
self.balance = self.balance + amount
self.history.append(f"+{amount}")
def withdraw(self, amount):
"""Снять деньги. Возвращает True, если получилось."""
if amount > self.balance:
print(f"Недостаточно средств: на счёте {self.balance}")
return False
self.balance = self.balance - amount
self.history.append(f"-{amount}")
return True
def show(self):
print(f"Счёт {self.owner}: {self.balance} руб.")
print("Операции:", ", ".join(self.history))
acc = BankAccount("Аня", 1000)
acc.deposit(500)
acc.withdraw(300)
acc.withdraw(10000)
acc.show()
Вывод:
Недостаточно средств: на счёте 1200
Счёт Аня: 1200 руб.
Операции: +500, -300
Разберём, что тут нового и почему это удобно.
balance=0— значение по умолчанию параметра. Можно создать счёт и без начальной суммы:BankAccount("Борис").self.history = []создаёт новый пустой список для каждого счёта. Это важно: у Ани и у Бориса истории разные. (Если бы список был объявлен в классе вне__init__, он оказался бы общим на всех — распространённая ловушка, к которой вы вернётесь позже.)- Проверка «хватает ли денег» лежит внутри метода
withdraw. Кто бы ни попытался снять деньги, проверка сработает. Не нужно помнить о ней в каждом месте программы — она приехала вместе с объектом.
Вот это и есть главная практическая ценность классов: правила хранятся рядом с данными, которые они защищают.
Состояние счёта удобно представить как набор допустимых ситуаций и переходов между ними:
отказ, состояние не меняется
Обратите внимание на последнюю стрелку: неудачная операция не ломает объект и не оставляет его в странном промежуточном состоянии. Это хороший признак правильно написанного класса.
Печать объекта: __str__
Попробуйте напечатать объект напрямую:
rex = Dog("Рекс", 3)
print(rex)
Вывод будет примерно такой:
<__main__.Dog object at 0x7f3c8c1d5e50>
Это не ошибка. Python просто не знает, как показать вашу собаку человеку, и печатает служебную информацию: класс Dog и адрес в памяти. Научить его можно — добавив метод __str__:
class Dog:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"Собака {self.name}, {self.age} лет"
rex = Dog("Рекс", 3)
print(rex) # Собака Рекс, 3 лет
print(str(rex)) # то же самое
__str__ обязан возвращать строку (не печатать её!). Python вызывает его сам, когда объект попадает в print() или в str().
Методы с двумя подчёркиваниями по краям называют дандер-методами (от double underscore) или «магическими». Их не вызывают напрямую — Python вызывает их сам в нужный момент. Вы уже видели два: __init__ (при создании) и __str__ (при печати). Их около сотни, но на старте эти два покрывают почти всё. Полный список — в документации по модели данных Python.
Наследование: когда классы похожи
Допустим, в приюте есть не только собаки, но и кошки. У них общее: имя, возраст, «покормить». Разное: звук.
Можно написать два класса и продублировать общее — но мы помним про DRY. Вместо этого делают наследование: один класс берёт всё от другого и добавляет своё.
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
def feed(self):
print(f"{self.name} поел(а)")
def speak(self):
print("...") # базовое животное молчит
class Dog(Animal): # Dog наследует от Animal
def speak(self):
print(f"{self.name}: Гав!")
class Cat(Animal):
def speak(self):
print(f"{self.name}: Мяу!")
animals = [Dog("Рекс", 3), Cat("Мурка", 2), Dog("Белла", 7)]
for animal in animals:
animal.feed()
animal.speak()
Вывод:
Рекс поел(а)
Рекс: Гав!
Мурка поел(а)
Мурка: Мяу!
Белла поел(а)
Белла: Гав!
Что произошло:
class Dog(Animal):— в скобках указан родительский класс.Dogполучил__init__иfeedбесплатно, писать их заново не нужно.Dogобъявил свойspeak— и он перекрыл родительский. Это называется переопределением метода.- В цикле мы вызываем
animal.speak(), не спрашивая, кто там внутри. Каждый объект знает свой ответ сам.
Честное предупреждение. Наследование выглядит настолько красиво, что новички начинают строить из него башни: Animal → Mammal → Carnivore → Dog → Puppy. На практике такие башни очень быстро становятся неповоротливыми — поменяешь что-то наверху, посыплется внизу. Опытные разработчики следуют правилу «композиция вместо наследования»: чаще полезнее не наследовать, а вкладывать один объект в другой (у машины есть двигатель — а не «машина есть двигатель»). Подробный разбор этой мысли — на refactoring.guru. Пока просто запомните: наследование — не цель, а инструмент, и берут его, только когда между классами действительно есть отношение «является частным случаем».
Четыре страшных слова
Теперь их можно расшифровать за минуту — вы уже видели всё это в коде.
- Инкапсуляция — «данные и работающий с ними код держим в одной коробке, наружу торчит только необходимое». Это наш
BankAccount: снаружи вызываютwithdraw, а как устроенbalance, знает только сам класс. - Наследование — «новый класс берёт готовое у старого и добавляет своё». Наши
DogиCat. - Полиморфизм (греч. «многоформенность») — «на одну и ту же команду разные объекты отвечают по-своему». Наш цикл
animal.speak(). - Абстракция — «прячем сложность за простым именем».
acc.withdraw(300)— простая фраза, за которой стоят проверки, изменения баланса и запись в историю.
Ни одно из этих слов не описывает ничего волшебного. Это просто названия приёмов, которыми наводят порядок.
Когда класс не нужен
Важная мысль, о которой учебники часто забывают: классы нужны не всегда. Признаки того, что класс уместен:
- есть данные, которые логично держать вместе (имя + возраст + история);
- эти данные живут долго и меняются со временем;
- есть правила, которые должны соблюдаться при любых изменениях;
- нужно много однотипных штук (сто собак, сто счетов).
А если вам просто надо посчитать среднее по списку чисел — пишите функцию. Класс с одним методом и без состояния — это функция, которую зачем-то нарядили. Такое даже имеет полупрезрительное имя в профессиональной среде: «класс-обёртка ради класса». Простое решение почти всегда лучше.
Типичные ошибки новичков
TypeError: Dog.__init__() missing 1 required positional argument: 'age'
Дословно: «в Dog.__init__() не хватает одного обязательного позиционного аргумента: age». Вы написали Dog("Рекс"), а класс требует ещё и возраст. Про self беспокоиться не нужно — Python его не считает, он подставляет его сам.
TypeError: Dog.bark() takes 0 positional arguments but 1 was given
Дословно: «bark() принимает 0 аргументов, а передан 1». Вы забыли self в объявлении метода:
def bark(): # неправильно
print("Гав")
def bark(self): # правильно
print("Гав")
Тот самый один аргумент, о котором ругается Python, — это и есть объект, который он попытался подставить.
NameError: name 'name' is not defined
Внутри метода вы написали print(name) вместо print(self.name). Атрибуты объекта видны только через self — сами по себе они переменными не становятся.
AttributeError: 'Dog' object has no attribute 'nmae'
Дословно: «у объекта класса Dog нет атрибута nmae». Почти всегда — опечатка. Реже — обращение к атрибуту, который создаётся не в __init__, а где-то в другом методе, который ещё не вызывали. Отсюда правило: все атрибуты объекта заводите в __init__, даже если пока кладёте в них None или пустой список.
Объект напечатался как <__main__.Dog object at 0x...>
Не ошибка, а отсутствие __str__. См. раздел выше.
Забыли скобки при создании
rex = Dog # это не собака, а сам класс!
rex = Dog("Рекс", 3) # а вот это собака
Первая строка не создаёт объект — она даёт классу второе имя. Ошибка вылезет позже: TypeError: Dog.bark() missing 1 required positional argument: 'self' при попытке rex.bark().
Практика
Пишите каждое задание в отдельный файл и обязательно запускайте — глазами ООП не осваивается.
1. Класс Book
Создайте класс Book с атрибутами title (название), author (автор) и year (год). Добавьте метод describe(), который печатает: «Мастер и Маргарита», Булгаков, 1967. Создайте три книги и вызовите метод у каждой.
Подсказка: три строки в __init__ и одна f-строка в describe(). Русские кавычки-ёлочки внутри f-строки писать можно, они обычные символы.
2. Счётчик
Создайте класс Counter без параметров в __init__ — он просто ставит self.value = 0. Добавьте методы increment() (увеличить на единицу), reset() (обнулить) и show() (напечатать текущее значение). Создайте два счётчика и убедитесь, что они считают независимо.
Подсказка: def __init__(self): — да, только self. Проверка независимости и есть суть задания: увеличьте первый счётчик пять раз, второй — два, и напечатайте оба.
3. Корзина покупок
Класс Cart со списком товаров. Методы: add(name, price) — добавить товар, total() — вернуть общую сумму, show() — напечатать список товаров и итог.
Подсказка: в __init__ заведите self.items = []. Каждый товар удобно хранить словарём: self.items.append({"name": name, "price": price}). В total() пройдите по списку циклом и накопите сумму в переменной. Важно: total() должен именно return-ить число, а не печатать его, — тогда show() сможет им воспользоваться.
4. Прямоугольник
Класс Rectangle с шириной и высотой. Методы area() (площадь), perimeter() (периметр) и is_square() (возвращает True, если стороны равны). Добавьте __str__, чтобы print(r) печатал Прямоугольник 3x4.
Подсказка: __str__ должен возвращать строку, а не печатать её — если внутри написать print, объект напечатается дважды и Python ещё выдаст TypeError: __str__ returned non-string (type NoneType).
5. Со звёздочкой: приют для животных
Сделайте базовый класс Animal (имя, возраст, метод speak() и метод info()), от него — Dog, Cat и Parrot со своими speak(). Затем создайте класс Shelter, который хранит список животных и умеет: add(animal) — принять животное, feed_all() — покормить всех, find(name) — найти животное по имени и вернуть его (или None, если такого нет).
Подсказки: Shelter не наследует ни от кого — он содержит животных, это и есть композиция. В find() идите циклом по self.animals и сравнивайте animal.name с искомым именем; не забудьте, что функция без явного return возвращает None сама — но лучше написать это явно, чтобы читателю было понятно ваше намерение. Если хотите усложнить: сделайте так, чтобы Parrot при speak() повторял последнюю услышанную фразу.
Мини-итог
- Класс — чертёж, описание. Объект — конкретная вещь, сделанная по чертежу. Классов один, объектов сколько угодно.
- Объект соединяет данные (атрибуты) и действия (методы) в одну штуку. Это и есть вся суть ООП.
__init__вызывается автоматически при создании объекта; его дело — разложить начальные данные. Он ничего не возвращает.self— первый параметр каждого метода, в который Python сам кладёт текущий объект. Обращение к данным объекта — всегда черезself..rex.bark()— то же самое, чтоDog.bark(rex). Точка означает «у этого объекта».__str__учит Python печатать ваш объект по-человечески и обязан возвращать строку.- Наследование позволяет не дублировать общее, но не увлекайтесь: композиция чаще уместнее.
- Класс нужен, когда есть долгоживущее состояние и правила его изменения. Для разового вычисления хватит функции.
Что почитать дальше: глава «Classes» в официальном учебнике Python — она короткая и точная; Object-Oriented Programming in Python на Real Python — с подробными примерами; и раздел про принципы ООП на refactoring.guru, когда захочется системной картины.
Что дальше
Мы прошли весь путь от переменной до объекта — теперь вы умеете писать программы. Но программу мало написать: её надо запускать не через кнопку в редакторе, а из командной строки, хранить историю изменений, чтобы можно было откатиться, когда что-то сломалось, и делиться кодом с другими людьми.
Этим занимаются два инструмента, которые разработчик открывает каждый день: терминал и Git. Они выглядят сурово, но устроены логично.