Python: уроки, справочник и задачи

Классы и объекты в Python: от class до рабочего примера

Раздел: ООП в Python · Обновлено 25.10.2026
Редактор кода с программой на Python и графиком matplotlib
Фото: MikeRun, CC BY-SA 4.0

Класс в Python - это шаблон, по которому создаются объекты: он описывает, какие данные объект хранит и что умеет делать. Если вы уже писали функции и работали со словарями, классы покажутся следующим логичным шагом: они связывают данные и функции, которые с этими данными работают, в одно целое. Ниже разобраны синтаксис class, роль __init__ и self, разница между атрибутами класса и экземпляра, декораторы @property и @staticmethod, а в конце два законченных примера: банковский счет и корзина интернет-магазина.

Зачем нужны классы

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

student = {"name": "Анна", "grades": [5, 4, 5]}

def average(st: dict) -> float:
    return sum(st["grades"]) / len(st["grades"])

print(average(student))  # -> 4.666666666666667

Пока сущностей мало, это работает. Но когда появляются десятки функций, становится неясно, какие из них к каким данным относятся, а опечатка в ключе ("grade" вместо "grades") всплывает только во время выполнения. Класс собирает все в одном месте:

class Student:
    def __init__(self, name: str, grades: list[int]):
        self.name = name
        self.grades = grades

    def average(self) -> float:
        return sum(self.grades) / len(self.grades)

anna = Student("Анна", [5, 4, 5])
print(anna.name)                 # -> Анна
print(round(anna.average(), 2))  # -> 4.67

Теперь данные и поведение связаны: у каждого студента есть метод average, и редактор кода подсказывает доступные атрибуты. Общие принципы объектно-ориентированного подхода описаны в статье ООП Python, здесь сосредоточимся на практике.

Класс и объект: в чем разница

Класс - это описание, объект (экземпляр) - конкретная вещь, созданная по этому описанию. Аналогия: чертеж дома и сами дома, построенные по нему. По одному классу можно создать сколько угодно объектов, и у каждого будут свои данные.

class Dog:
    pass

rex = Dog()
bobik = Dog()

print(type(rex))          # -> <class '__main__.Dog'>
print(rex is bobik)       # -> False (два разных объекта)
print(isinstance(rex, Dog))  # -> True

Объект создается вызовом класса как функции: Dog(). Имена классов по соглашению пишут в стиле CapWords: Student, BankAccount, ShoppingCart. Имена переменных и методов, как обычно, строчными буквами через подчеркивание.

На самом деле в Python объектами являются вообще все значения: числа, строки, списки и даже функции. Когда вы пишете "text".upper(), вы вызываете метод объекта класса str. Собственные классы работают по тем же правилам.

Конструктор __init__ и параметр self

Метод __init__ вызывается автоматически сразу после создания объекта. Его задача - заполнить новый объект начальными данными. Строго говоря, это инициализатор, а не конструктор (объект к этому моменту уже создан методом __new__), но в разговоре его обычно называют конструктором.

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y
        print(f"Создана точка ({x}, {y})")

p = Point(3, 4)   # -> Создана точка (3, 4)
print(p.x, p.y)   # -> 3 4

Что такое self

self - это ссылка на тот объект, для которого вызван метод. Когда вы пишете p.distance(), Python фактически выполняет Point.distance(p), то есть передает сам объект первым аргументом. Внутри метода через self вы обращаетесь к данным именно этого экземпляра.

import math

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def distance_to_origin(self) -> float:
        return math.hypot(self.x, self.y)

p = Point(3, 4)
print(p.distance_to_origin())        # -> 5.0
print(Point.distance_to_origin(p))   # -> 5.0 (то же самое)

Имя self не является ключевым словом, это лишь соглашение. Технически можно назвать параметр как угодно, но так не делает никто, и линтеры на это ругаются. Самая частая ошибка новичков - забыть self в объявлении метода:

class Broken:
    def hello():          # забыли self
        print("hi")

Broken().hello()
# TypeError: Broken.hello() takes 0 positional arguments but 1 was given

Сообщение "takes 0 positional arguments but 1 was given" почти всегда означает именно это: Python передал объект, а принять его некуда. Вторая частая ошибка - присвоить значение без self. внутри __init__. Тогда создается обычная локальная переменная, которая исчезает после выхода из метода, а у объекта атрибута не появляется.

Атрибуты класса и атрибуты экземпляра

Атрибуты, которые задаются через self.имя = ..., принадлежат конкретному объекту. Атрибуты, объявленные прямо в теле класса, принадлежат самому классу и общие для всех его экземпляров.

class Car:
    wheels = 4          # атрибут класса: общий для всех
    count = 0

    def __init__(self, brand: str):
        self.brand = brand   # атрибут экземпляра: свой у каждого
        Car.count += 1

a = Car("Lada")
b = Car("Kia")

print(a.brand, b.brand)    # -> Lada Kia
print(a.wheels, Car.wheels)  # -> 4 4
print(Car.count)           # -> 2

При чтении a.wheels Python сначала ищет атрибут в самом объекте, не находит его и идет в класс. А вот присваивание через экземпляр создает новый атрибут у объекта и "затеняет" атрибут класса, не меняя его:

a.wheels = 3
print(a.wheels, b.wheels, Car.wheels)  # -> 3 4 4
print(a.__dict__)  # -> {'brand': 'Lada', 'wheels': 3}

Словарь __dict__ показывает атрибуты конкретного объекта. Получить все атрибуты, включая унаследованные и методы, можно встроенной функцией dir(), а vars(obj) возвращает тот же __dict__.

Ловушка с изменяемым атрибутом класса

Если атрибут класса - список или словарь, все объекты работают с одним и тем же экземпляром списка. Изменение через один объект видно во всех:

class Team:
    members = []          # ошибка: общий список для всех команд

    def add(self, name: str):
        self.members.append(name)

red = Team()
blue = Team()
red.add("Иван")
print(blue.members)  # -> ['Иван'] (неожиданно!)

Исправление простое: изменяемые данные создавайте в __init__, чтобы у каждого объекта был свой список (self.members = []). Атрибуты класса используйте для констант и действительно общих значений, например счетчиков или настроек по умолчанию.

ПризнакАтрибут классаАтрибут экземпляра
Где объявляетсяв теле классав методах через self.
Кому принадлежитклассу, общий для всеходному объекту
Как обратитьсяCar.wheels или obj.wheelsobj.brand
Типичное применениеконстанты, счетчики, значения по умолчаниюданные конкретного объекта
Изменяемые типы (list, dict)опасно, общий объектбезопасно

Методы: обычные, классовые и статические

Метод - это функция, объявленная внутри класса. Python различает три вида методов по тому, что они получают первым аргументом.

from datetime import date

class Person:
    adult_age = 18

    def __init__(self, name: str, birth_year: int):
        self.name = name
        self.birth_year = birth_year

    def age(self, year: int) -> int:          # обычный метод: получает объект
        return year - self.birth_year

    @classmethod
    def from_string(cls, data: str) -> "Person":  # классовый: получает класс
        name, year = data.split(",")
        return cls(name.strip(), int(year))

    @staticmethod
    def is_valid_year(year: int) -> bool:     # статический: ничего не получает
        return 1900 <= year <= date.today().year

p = Person.from_string("Мария, 1999")
print(p.name, p.age(2026))           # -> Мария 27
print(Person.is_valid_year(1850))    # -> False
print(p.is_valid_year(2001))         # -> True

Если статическому методу понадобились данные объекта или класса, это сигнал, что он должен стать обычным или классовым. И наоборот, если функция не обращается ни к self, ни к cls, и к классу отношения не имеет, лучше вынести ее на уровень модуля.

Свойства через @property

В Python не принято писать методы get_x() и set_x(), как в Java. К атрибутам обращаются напрямую. Если позже понадобилась проверка значения или вычисление, атрибут превращают в свойство с помощью @property, и внешний код при этом не меняется.

class Temperature:
    def __init__(self, celsius: float):
        self.celsius = celsius        # здесь сработает setter

    @property
    def celsius(self) -> float:
        return self._celsius

    @celsius.setter
    def celsius(self, value: float) -> None:
        if value < -273.15:
            raise ValueError("ниже абсолютного нуля")
        self._celsius = value

    @property
    def fahrenheit(self) -> float:     # вычисляемое свойство только для чтения
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.fahrenheit)   # -> 77.0
t.celsius = 100
print(t.fahrenheit)   # -> 212.0
t.celsius = -300      # ValueError: ниже абсолютного нуля

Снаружи t.celsius выглядит как обычный атрибут, но при каждом присваивании выполняется проверка. Само значение хранится в атрибуте с подчеркиванием _celsius. Одно подчеркивание в Python означает "внутреннее, не трогайте снаружи"; это соглашение, а не запрет, доступ технически остается. Попытка присвоить t.fahrenheit = 50 вызовет AttributeError, потому что сеттер для этого свойства не определен. Больше примеров в статье Property python.

Читаемый вывод объекта: __str__ и __repr__

По умолчанию print(obj) показывает что-то вроде <__main__.Point object at 0x7f...>. Чтобы объект печатался понятно, определите методы __repr__ (для разработчика, в отладке и в списках) и при необходимости __str__ (для пользователя).

class Point:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

    def __repr__(self) -> str:
        return f"Point(x={self.x}, y={self.y})"

    def __str__(self) -> str:
        return f"({self.x}; {self.y})"

p = Point(1, 2)
print(p)          # -> (1; 2)
print([p, p])     # -> [Point(x=1, y=2), Point(x=1, y=2)]

Кроме этих двух, у классов есть десятки других специальных методов с двойными подчеркиваниями: для сравнения, сложения, вычисления длины, итерации. Один из них, __len__, встретится в примере с корзиной ниже.

Практический пример: банковский счет

Соберем все вместе. Счет хранит владельца и баланс, умеет пополняться, списывать деньги с проверкой и переводить средства на другой счет. Баланс доступен только для чтения через свойство, чтобы его нельзя было изменить в обход методов.

class InsufficientFunds(Exception):
    pass

class BankAccount:
    bank_name = "ПитонБанк"
    _next_number = 1

    def __init__(self, owner: str, balance: float = 0):
        if balance < 0:
            raise ValueError("начальный баланс не может быть отрицательным")
        self.owner = owner
        self._balance = balance
        self.number = BankAccount._next_number
        BankAccount._next_number += 1
        self.history: list[str] = []

    @property
    def balance(self) -> float:
        return self._balance

    def deposit(self, amount: float) -> None:
        self._check_amount(amount)
        self._balance += amount
        self.history.append(f"+{amount}")

    def withdraw(self, amount: float) -> None:
        self._check_amount(amount)
        if amount > self._balance:
            raise InsufficientFunds(f"на счете {self._balance}, нужно {amount}")
        self._balance -= amount
        self.history.append(f"-{amount}")

    def transfer(self, other: "BankAccount", amount: float) -> None:
        self.withdraw(amount)
        other.deposit(amount)

    @staticmethod
    def _check_amount(amount: float) -> None:
        if amount <= 0:
            raise ValueError("сумма должна быть положительной")

    def __repr__(self) -> str:
        return f"BankAccount(#{self.number}, {self.owner!r}, {self._balance})"

anna = BankAccount("Анна", 1000)
oleg = BankAccount("Олег")

anna.deposit(500)
anna.transfer(oleg, 300)
print(anna)           # -> BankAccount(#1, 'Анна', 1200)
print(oleg)           # -> BankAccount(#2, 'Олег', 300)
print(anna.history)   # -> ['+500', '-300']

try:
    oleg.withdraw(1000)
except InsufficientFunds as e:
    print("Отказ:", e)  # -> Отказ: на счете 300, нужно 1000

Обратите внимание на детали. Счетчик номеров _next_number - атрибут класса, поэтому он общий и растет с каждым новым счетом. Список history создается в __init__, поэтому у каждого счета свой. Проверка суммы вынесена в статический метод, потому что ей не нужны данные объекта. Метод transfer переиспользует withdraw и deposit, и все проверки срабатывают автоматически.

Практический пример: корзина покупок

Во втором примере два класса работают вместе: товар и корзина, которая хранит товары с количеством и считает итог со скидкой. Класс Product оформлен через dataclass: декоратор сам генерирует __init__, __repr__ и сравнение, это экономит много однотипного кода (подробности в статье про dataclasses).

from dataclasses import dataclass

@dataclass(frozen=True)
class Product:
    sku: str
    title: str
    price: float

class Cart:
    def __init__(self, discount_percent: float = 0):
        self.items: dict[Product, int] = {}
        self.discount_percent = discount_percent

    def add(self, product: Product, qty: int = 1) -> None:
        if qty <= 0:
            raise ValueError("количество должно быть больше нуля")
        self.items[product] = self.items.get(product, 0) + qty

    def remove(self, product: Product) -> None:
        self.items.pop(product, None)

    @property
    def subtotal(self) -> float:
        return sum(p.price * q for p, q in self.items.items())

    @property
    def total(self) -> float:
        return round(self.subtotal * (1 - self.discount_percent / 100), 2)

    def __len__(self) -> int:
        return sum(self.items.values())

    def receipt(self) -> str:
        lines = [f"{p.title} x{q} = {p.price * q:.2f}" for p, q in self.items.items()]
        lines.append(f"Итого со скидкой {self.discount_percent}%: {self.total:.2f}")
        return "\n".join(lines)

book = Product("B-01", "Книга по Python", 890)
pen = Product("P-07", "Ручка", 45.5)

cart = Cart(discount_percent=10)
cart.add(book)
cart.add(pen, 4)
cart.add(book)

print(len(cart))       # -> 6
print(cart.subtotal)   # -> 1962.0
print(cart.receipt())
# -> Книга по Python x2 = 1780.00
# -> Ручка x4 = 182.00
# -> Итого со скидкой 10%: 1765.80

Параметр frozen=True делает товар неизменяемым и хешируемым, поэтому объекты Product можно использовать как ключи словаря. Метод __len__ позволяет вызывать для корзины встроенную функцию len(). Свойства subtotal и total вычисляются при каждом обращении, поэтому всегда актуальны и не требуют ручного пересчета.

Для денег в учебном примере используется float. В реальных финансовых расчетах берите decimal.Decimal или храните суммы в копейках целыми числами, чтобы избежать ошибок округления.

Типичные ошибки начинающих

Большинство проблем с первыми классами повторяются из раза в раз. Если код ведет себя странно, проверьте этот список.

Вызов класса или метода без скобок

Запись acc = BankAccount не создает объект, а просто дает классу второе имя. Аналогично print(cart.receipt) напечатает <bound method ...> вместо чека. Метод нужно вызывать со скобками: cart.receipt(). Свойства, объявленные через @property, наоборот, вызываются без скобок, и cart.total() приведет к ошибке "'float' object is not callable".

Изменяемое значение по умолчанию в __init__

Конструкция def __init__(self, items=[]) создает один список при объявлении функции, и он делится между всеми объектами, у которых аргумент не передан. Это та же ловушка, что и с изменяемым атрибутом класса, только спрятанная глубже:

class Order:
    def __init__(self, items: list[str] | None = None):
        self.items = items if items is not None else []

a = Order()
b = Order()
a.items.append("книга")
print(b.items)  # -> [] (у каждого заказа свой список)

Правильный шаблон: по умолчанию передавать None и создавать новый список внутри метода.

Один огромный класс на все случаи

Новички часто складывают в один класс всю программу: работу с файлами, расчеты, вывод на экран. Такой класс трудно читать и невозможно тестировать по частям. Хорошее правило: класс отвечает за одну сущность предметной области. Счет управляет балансом, корзина хранит товары, а печать отчета или сохранение в базу живут в отдельных функциях или классах.

Когда класс не нужен

Классы - не обязательный атрибут "правильного" кода. Если у сущности нет поведения и она просто хранит несколько полей, достаточно словаря, NamedTuple или dataclass. Если у класса только один метод, кроме __init__, скорее всего, это обычная функция с параметрами. Класс оправдан, когда есть состояние, которое меняется со временем, и набор операций над ним, которые должны поддерживать это состояние корректным, как баланс счета, который нельзя увести в минус.

Что изучать дальше

  1. Наследование: как создать класс на основе существующего и переопределить поведение. Тема разобрана в статье Python наследование классов.
  2. Магические методы: сравнение объектов, арифметика, итерация.
  3. Dataclasses и аннотации типов для лаконичных классов данных.
  4. Собственные исключения, чтобы классы сообщали об ошибках понятно.

Частые вопросы

Обязательно ли писать __init__ в классе?

Нет. Если объекту не нужны начальные данные, __init__ можно не объявлять, Python использует пустую версию из базового класса object. Но почти всегда он нужен, чтобы задать атрибуты.

Почему self нужно писать явно в каждом методе?

Это решение дизайна языка: явное лучше неявного. Метод в Python - обычная функция, а объект передается в нее первым аргументом. Явный self сразу показывает, где используются данные объекта, а где локальные переменные.

Чем staticmethod отличается от classmethod?

classmethod получает класс в параметре cls и может создавать объекты или читать атрибуты класса, корректно работая с наследниками. staticmethod не получает ничего и ведет себя как обычная функция внутри пространства имен класса.

Как посмотреть все атрибуты объекта?

vars(obj) или obj.__dict__ вернут словарь атрибутов экземпляра. dir(obj) покажет все доступные имена, включая методы и атрибуты класса.

Есть ли в Python приватные атрибуты?

Настоящих нет. Одно подчеркивание (_balance) означает внутренний атрибут по соглашению. Два подчеркивания (__balance) включают искажение имени (name mangling), которое защищает от случайных конфликтов в наследниках, но не от намеренного доступа.

Почему два объекта с одинаковыми данными не равны?

По умолчанию оператор == для объектов собственного класса сравнивает их идентичность, то есть работает как is. Два экземпляра Point(1, 2) - это разные объекты в памяти, поэтому сравнение вернет False. Чтобы сравнивать по содержимому, определите метод __eq__, который сравнивает нужные атрибуты, или объявите класс через @dataclass: декоратор сгенерирует сравнение по всем полям автоматически. Учтите, что после определения __eq__ объекты перестают быть хешируемыми, пока вы явно не определите и __hash__.