Программирование с нуля Объекты и классы: первое знакомство с ООП
0%

Объекты и классы: первое знакомство с ООП

Объекты и классы: первое знакомство с ООП

Три буквы ООП обычно пугают новичков сильнее всего остального. Их произносят с интонацией «ну, это уже серьёзно», в вакансиях пишут «знание ООП обязательно», а в учебниках сразу вываливают четыре страшных слова: инкапсуляция, наследование, полиморфизм, абстракция.

Хорошая новость: сама идея простая. Настолько простая, что вы, скорее всего, уже ей пользовались, не зная названия. Мы дойдём до неё постепенно — сначала посмотрим, какая боль её породила.

Проблема: данные отдельно, действия отдельно

Мы уже умеем хранить набор данных в словаре и умеем описывать действия функциями. Попробуем этими средствами описать собаку в приюте.

# Собака — словарь: имя и возраст
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

Работает. И всё же тут неудобно жить, и вот почему:

  1. Данные и действия разъехались. Словарь rex лежит в одном месте, функции bark и birthday — в другом. Ничто не связывает их между собой. Если в файле есть ещё и функция bark для тюленей, вы однажды передадите собаку не туда, и Python не возразит.
  2. Никто не гарантирует структуру. Ничто не мешает создать {"nmae": "Белла"} с опечаткой или забыть возраст. Ошибка вылезет позже и совсем в другом месте.
  3. Каждую собаку надо собирать вручную. Если у собаки станет десять полей, каждое создание превратится в простыню, которую легко заполнить неправильно.

Хочется сказать компьютеру одну фразу: «есть такая штука — собака. У неё всегда есть имя и возраст, и она умеет лаять и стареть». Ровно это и делают классы.

Аналогия: чертёж и изделия

Класс — это чертёж. Объект — конкретное изделие, сделанное по чертежу.

Чертёж стула описывает: у стула четыре ножки, спинка и цвет. Сам чертёж — не стул: на нём нельзя сидеть, он висит на стене в единственном экземпляре. А стульев по нему можно сделать сколько угодно, и все они будут разного цвета, но одинаковой конструкции.

  • Класс — один. Объектов по нему — сколько нужно.
  • Класс говорит, какие поля будут у каждого объекта, но значения полей у каждого объекта свои.
  • Класс говорит, что объект умеет делать.

Где аналогия ломается. Чертёж нельзя изменить после того, как стулья уже сделаны, — а класс в Python можно, и изменения затронут все существующие объекты. Кроме того, изделие можно сделать «не совсем по чертежу», а объект всегда точно соответствует своему классу. Держите это в голове, но не переживайте: на старте аналогия работает почти всегда.

Класс как чертёж и объекты как изделия по нему

Ещё вы уже встречали объекты, даже если не замечали. Строка "привет" — объект. У неё есть данные (сами буквы) и умения: "привет".upper() возвращает "ПРИВЕТ". Точка перед upper() — это и есть обращение к умению объекта. Список — тоже объект: nums.append(5) — «список, добавь себе пятёрку». Так что синтаксис вам знаком; сейчас мы научимся делать свои такие штуки.

Первый класс

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

Четыре строки, и в них сразу три новых вещи. Разберём построчно.

  1. class Dog: — ключевое слово class, потом имя класса, потом двоеточие. Имена классов по традиции пишут с большой буквы и в стиле CamelCase (Dog, BankAccount, UserProfile) — так их сразу отличают от функций и переменных, которые пишутся со_знаком_подчёркивания. Это соглашение из PEP 8, официального стиля Python; Python его не проверяет, но все программисты его соблюдают.
  2. def __init__(self, name, age): — обычная функция, объявленная внутри класса (обратите внимание на отступ). Функция внутри класса называется методом. Имя __init__ — специальное, о нём ниже.
  3. self.name = name — сохраняем то, что нам передали, «внутрь объекта».

Что такое __init__

__init__ (от initialize — «подготовить к работе», два подчёркивания с каждой стороны) — метод, который Python вызывает автоматически в момент создания объекта. Его задача одна: разложить по объекту начальные данные. Его часто называют конструктором, хотя строго говоря он не создаёт объект, а обставляет уже созданный.

rex = Dog("Рекс", 3)

Читается как «сделай мне новую собаку по чертежу Dog, имя Рекс, возраст 3». Обратите внимание: мы пишем имя класса со скобками, как будто вызываем функцию. Так и есть — вызов класса означает «создай объект».

Вот что при этом происходит по шагам:

Ключевой момент: __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. Без self Python будет искать обычную локальную переменную и не найдёт её.
  • Метод может печатать (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. Они выглядят сурово, но устроены логично.

Командная строка и Git: как разработчики работают с кодом

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов