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

Класс в 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.wheels | obj.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и работает с данными конкретного объекта. Это 90% всех методов. @classmethodполучаетcls, то есть сам класс. Чаще всего он служит альтернативным конструктором: создать объект из строки, словаря, файла.@staticmethodне получает ни объекта, ни класса. Это обычная функция, помещенная в класс, потому что логически к нему относится. Вызывать ее можно и через класс, и через объект.
Если статическому методу понадобились данные объекта или класса, это сигнал, что он должен стать обычным или классовым. И наоборот, если функция не обращается ни к 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__, скорее всего, это обычная функция с параметрами. Класс оправдан, когда есть состояние, которое меняется со временем, и набор операций над ним, которые должны поддерживать это состояние корректным, как баланс счета, который нельзя увести в минус.
Что изучать дальше
- Наследование: как создать класс на основе существующего и переопределить поведение. Тема разобрана в статье Python наследование классов.
- Магические методы: сравнение объектов, арифметика, итерация.
- Dataclasses и аннотации типов для лаконичных классов данных.
- Собственные исключения, чтобы классы сообщали об ошибках понятно.
Частые вопросы
Обязательно ли писать __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__.