Перейти к содержимому

Лекция 1. Классы, объекты и инкапсуляция

Введение: зачем нужна парадигма ООП

Программирование как дисциплина прошло несколько этапов развития: от машинных кодов и ассемблера к процедурному программированию, далее — к структурному подходу и, наконец, к объектно-ориентированному программированию (ООП). Первыми языками, поддержавшими идеи ООП, стали Simula 67 и Smalltalk; впоследствии эти принципы легли в основу C++, Java, C# и оказали глубокое влияние на Python.

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

Процедурный подход против объектно-ориентированного

В процедурном программировании данные и функции существуют раздельно. Данные «текут» через функции, и за их согласованность отвечает программист вручную.

# Процедурный стиль: данные и функции отдельно
def make_account(balance):
return {"balance": balance}
def deposit(account, amount):
account["balance"] += amount # никто не мешает положить отрицательную сумму
acc = {"balance": 100}
deposit(acc, 50)
acc["balance"] = -1000 # данные ничем не защищены

Проблема в том, что словарь acc открыт для любых изменений, а правила («баланс не может быть отрицательным») нигде не закреплены. По мере роста программы такие неявные договорённости становится всё труднее соблюдать.

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

# Объектно-ориентированный стиль: данные и поведение вместе
class Account:
def __init__(self, balance: float):
self._balance = balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Сумма пополнения должна быть положительной")
self._balance += amount

Главные преимущества подхода: управление сложностью, повторное использование кода, локализация изменений и моделирование задачи в естественных терминах.


Класс и объект

Что такое класс

Класс — это абстракция, описание набора объектов с одинаковыми характеристиками. Класс задаёт три вещи:

  • состояние — какими атрибутами обладают объекты;
  • поведение — какие методы (операции) им доступны;
  • инварианты — ограничения, которым подчиняются объекты данного класса.

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

class Rectangle:
def __init__(self, width: float, height: float):
self.width = width
self.height = height
def area(self) -> float:
return self.width * self.height
def perimeter(self) -> float:
return 2 * (self.width + self.height)
r = Rectangle(3, 4)
print(r.area()) # 12
print(r.perimeter()) # 14

Что такое объект

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

class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
p1 = Point(0, 0)
p2 = Point(0, 0)
print(p1 is p2) # False — это разные объекты с одинаковым состоянием
print(p1 == p2) # False — по умолчанию сравнение идёт по идентичности

Оператор is проверяет идентичность (один и тот же объект в памяти), а == по умолчанию — тоже идентичность, пока мы не переопределим поведение сравнения (см. ниже __eq__).

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

Метод __init__ — это инициализатор: он вызывается автоматически сразу после создания объекта и заполняет его начальное состояние. Первый параметр любого метода экземпляра — self — это ссылка на сам создаваемый/вызывающий объект. Через self мы записываем и читаем атрибуты конкретного экземпляра.

class User:
def __init__(self, name: str, age: int):
self.name = name # self.name — атрибут ИМЕННО этого объекта
self.age = age
u = User("Анна", 25) # Python сам передаёт u как self
print(u.name, u.age) # Анна 25

Важно: self не является ключевым словом — это просто соглашение об имени первого параметра. Python подставляет объект автоматически, поэтому при вызове u.name аргумент self мы не пишем.

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

  • Атрибут экземпляра принадлежит конкретному объекту и обычно создаётся в __init__ через self.
  • Атрибут класса объявляется в теле класса и является общим для всех экземпляров.
class Counter:
created = 0 # атрибут класса — общий для всех объектов
def __init__(self):
self.value = 0 # атрибут экземпляра — свой у каждого объекта
Counter.created += 1
def inc(self):
self.value += 1
c1 = Counter(); c1.inc()
c2 = Counter()
print(Counter.created) # 2 — счётчик общий
print(c1.value, c2.value) # 1 0 — значения независимы

Будьте осторожны с изменяемыми атрибутами класса (списками, словарями): если их менять «на месте», изменение увидят все экземпляры. Для индивидуальных данных инициализируйте атрибуты в __init__.


Методы: экземпляра, классов и статические

В Python существуют три вида методов, различающихся тем, что они получают первым аргументом и к чему имеют доступ.

class Pizza:
standard_radius = 15 # атрибут класса
def __init__(self, ingredients: list[str]):
self.ingredients = ingredients
# 1. Метод экземпляра: получает self, работает с конкретным объектом
def describe(self) -> str:
return "Пицца с: " + ", ".join(self.ingredients)
# 2. Метод класса: получает cls, работает с самим классом
@classmethod
def margherita(cls) -> "Pizza":
return cls(["томаты", "моцарелла", "базилик"]) # фабричный метод
# 3. Статический метод: ни self, ни cls — обычная функция в пространстве класса
@staticmethod
def area(radius: float) -> float:
from math import pi
return pi * radius ** 2
p = Pizza.margherita() # удобный «фабричный» конструктор
print(p.describe())
print(Pizza.area(15)) # вспомогательная функция, не требующая объекта
  • Метод экземпляра (self) — основной вид; имеет доступ к данным объекта.
  • Метод класса (@classmethod, cls) — работает с классом, а не с экземпляром. Часто используется как альтернативный конструктор (фабричный метод): cls(...) корректно создаёт объект даже в наследниках.
  • Статический метод (@staticmethod) — функция, логически связанная с классом, но не нуждающаяся ни в объекте, ни в классе. Удобна для вспомогательных операций.

Инкапсуляция

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

В отличие от Java или C++, в Python нет жёстких модификаторов доступа (private, protected). Вместо них используются соглашения об именах и встроенные средства языка.

Соглашение _protected

Одиночное подчёркивание в начале имени (_balance) — это сигнал «для внутреннего использования». Технически язык ничего не запрещает, но по соглашению такой атрибут не следует трогать извне.

class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius # «защищённый» по соглашению

__private и name mangling

Двойное подчёркивание в начале имени (__token) запускает механизм name mangling: интерпретатор автоматически переименовывает атрибут в _ИмяКласса__token. Это не настоящая защита, а способ избежать случайного конфликта имён при наследовании.

class Secret:
def __init__(self):
self.__token = "abc" # фактически станет _Secret__token
def reveal(self):
return self.__token
s = Secret()
print(s.reveal()) # abc
# print(s.__token) # AttributeError: атрибута с таким именем нет
print(s._Secret__token) # abc — доступ всё же возможен, защита «мягкая»

Вывод: инкапсуляция в Python основана на договорённости и культуре, а не на запретах. «Мы все здесь взрослые люди» — так звучит известный принцип сообщества.

Свойства @property и сеттеры

Свойство (@property) позволяет обращаться к методу так, будто это обычный атрибут. Это даёт лучшее из двух миров: простой синтаксис доступа (obj.value) и при этом контроль — валидацию, вычисления, защиту от некорректных значений.

class Temperature:
def __init__(self, celsius: float):
self._celsius = celsius
@property
def celsius(self) -> float: # геттер: читаем как атрибут
return self._celsius
@celsius.setter
def celsius(self, value: float): # сеттер: с проверкой при записи
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.celsius) # 25 — вызывается геттер
print(t.fahrenheit) # 77.0 — вычисляется на лету
t.celsius = 30 # вызывается сеттер с проверкой
# t.celsius = -300 # ValueError

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


Магические методы (введение)

Магические (или «дандер», от double underscore) методы имеют имена вида __name__ и определяют, как объект ведёт себя со встроенными операциями языка: печатью, сравнением, арифметикой и т. д. Здесь рассмотрим три базовых.

class Money:
def __init__(self, amount: int, currency: str = "RUB"):
self.amount = amount
self.currency = currency
# Понятное для пользователя представление (print, str)
def __str__(self) -> str:
return f"{self.amount} {self.currency}"
# Однозначное представление для разработчика (repr, отладка, консоль)
def __repr__(self) -> str:
return f"Money(amount={self.amount!r}, currency={self.currency!r})"
# Сравнение на равенство «по значению»
def __eq__(self, other) -> bool:
if not isinstance(other, Money):
return NotImplemented
return self.amount == other.amount and self.currency == other.currency
m1 = Money(100)
m2 = Money(100)
print(m1) # 100 RUB — сработал __str__
print(repr(m1)) # Money(amount=100, currency='RUB') — сработал __repr__
print(m1 == m2) # True — сработал __eq__ (сравнение по значению)
  • __str__ — «человеческое» представление, для пользователя (print, str()).
  • __repr__ — однозначное техническое представление, для отладки; в идеале по нему можно воссоздать объект. Если определён только __repr__, он используется и вместо __str__.
  • __eq__ — определяет смысл ==. Часто его дополняют __hash__, чтобы объекты можно было использовать в множествах и как ключи словарей.

Магических методов в Python десятки (__len__, __add__, __getitem__ и др.) — они будут подробно разобраны в следующих лекциях. Сейчас важно понять идею: реализуя «дандер»-методы, мы интегрируем свои классы в синтаксис языка.


Краткие итоги

  • ООП объединяет данные и поведение в объектах, в отличие от процедурного подхода, где они разделены; это упрощает управление сложностью.
  • Класс — это шаблон (описание состояния, поведения и инвариантов), объект — конкретный экземпляр со своей идентичностью и состоянием.
  • __init__ инициализирует объект, а self — ссылка на текущий экземпляр.
  • Атрибуты класса общие для всех экземпляров; атрибуты экземпляра индивидуальны и обычно создаются в __init__.
  • Существуют три вида методов: экземпляра (self), класса (@classmethod, cls) и статические (@staticmethod).
  • Инкапсуляция в Python мягкая: _protected — соглашение, __private — name mangling, а @property даёт контролируемый доступ к данным.
  • Магические методы (__str__, __repr__, __eq__ и др.) встраивают объекты в синтаксис языка.

Вопросы для самопроверки

  1. Чем объектно-ориентированный подход отличается от процедурного? Какие проблемы процедурного стиля решает ООП?
  2. В чём разница между классом и объектом? Приведите аналогию.
  3. Для чего нужен параметр self и почему его не передают явно при вызове метода?
  4. Чем атрибут класса отличается от атрибута экземпляра? Когда изменение атрибута класса затронет все объекты?
  5. Назовите три вида методов в Python. В каких случаях уместен @classmethod, а в каких — @staticmethod?
  6. Как работает механизм name mangling для имён вида __private? Является ли это настоящей защитой данных?
  7. Зачем нужны свойства @property? Какое преимущество они дают перед прямым доступом к атрибуту?
  8. В чём разница между __str__ и __repr__? Что произойдёт при print(obj), если определён только __repr__?