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

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

Цель

Закрепить базовые понятия ООП на Python: проектирование классов, разграничение атрибутов экземпляра и класса, методы трёх видов (self, @classmethod, @staticmethod), инкапсуляцию (_protected, __private, name mangling), управляемый доступ через @property и магические методы __str__, __repr__, __eq__. По итогам работы вы должны уметь самостоятельно описать предметную сущность классом с продуманным интерфейсом и защищёнными инвариантами.

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

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

Общие требования ко всем заданиям:

  • добавляйте аннотации типов для параметров и возвращаемых значений;
  • у каждого класса определяйте __repr__;
  • нарушение инвариантов сигнализируйте исключением ValueError;
  • сопровождайте решения минимальными проверками (assert или pytest).

Задания

Задание 1. Класс Rectangle (базовый)

Реализуйте класс прямоугольника с вычисляемыми характеристиками.

Требования:

  • атрибуты экземпляра width: float, height: float;
  • методы area() -> float и perimeter() -> float;
  • __repr__, по которому можно восстановить объект;
  • стороны должны быть положительными (иначе ValueError).
class Rectangle:
def __init__(self, width: float, height: float) -> None:
... # сохраните стороны, проверьте положительность
def area(self) -> float: ...
def perimeter(self) -> float: ...
def __repr__(self) -> str: ... # пример: Rectangle(width=3, height=4)

Проверка: Rectangle(3, 4).area() == 12 и .perimeter() == 14.

Задание 2. Класс Counter (атрибуты класса)

Покажите разницу между атрибутом класса и атрибутом экземпляра.

Требования:

  • атрибут класса created — общее число созданных объектов;
  • атрибут экземпляра count — собственное значение счётчика (стартует с 0);
  • метод inc() увеличивает count на 1;
  • @classmethod how_many() -> int возвращает created.
class Counter:
created = 0 # общий для всех экземпляров
def __init__(self) -> None: ...
def inc(self) -> None: ...
@classmethod
def how_many(cls) -> int: ...

Проверка: после создания двух объектов Counter.how_many() == 2, а их count независимы.

Задание 3. Класс Temperature (@property и инкапсуляция)

Реализуйте температуру с контролируемым доступом и вычисляемым свойством.

Требования:

  • храните значение в «защищённом» атрибуте _celsius;
  • свойство-геттер celsius и сеттер celsius с проверкой: ниже -273.15 выбрасывайте ValueError;
  • вычисляемое свойство fahrenheit (только чтение): c * 9 / 5 + 32;
  • альтернативный конструктор @classmethod from_fahrenheit(value).
class Temperature:
def __init__(self, celsius: float) -> None:
self._celsius = ... # пройдите через проверку сеттера
@property
def celsius(self) -> float: ...
@celsius.setter
def celsius(self, value: float) -> None: ... # валидация абсолютного нуля
@property
def fahrenheit(self) -> float: ...
@classmethod
def from_fahrenheit(cls, value: float) -> "Temperature": ...

Проверка:

t = Temperature(25)
assert t.fahrenheit == 77.0
t.celsius = 30 # сеттер срабатывает
# t.celsius = -300 # должно вызвать ValueError

Задание 4. Класс BankAccount (инкапсуляция + магические методы)

Объедините инкапсуляцию состояния с интеграцией в синтаксис языка.

Требования:

  • баланс храните в _balance, номер счёта — в __number (name mangling);
  • свойство balance только на чтение;
  • методы deposit(amount) и withdraw(amount) с валидацией: сумма должна быть положительной, снятие не должно уводить баланс в минус (иначе ValueError);
  • __str__ — «человеческое» представление (например, Счёт ****1234: 150 RUB);
  • __repr__ — техническое представление для отладки;
  • __eq__ — равенство «по значению» (по номеру счёта и балансу), для чужих типов вернуть NotImplemented;
  • @staticmethod is_valid_amount(amount) -> bool — вспомогательная проверка.
class BankAccount:
def __init__(self, number: str, balance: float = 0) -> None:
self.__number = ...
self._balance = ...
@property
def balance(self) -> float: ...
def deposit(self, amount: float) -> None: ...
def withdraw(self, amount: float) -> None: ...
@staticmethod
def is_valid_amount(amount: float) -> bool: ...
def __str__(self) -> str: ...
def __repr__(self) -> str: ...
def __eq__(self, other) -> bool: ...

Проверка:

acc = BankAccount("1234", 100)
acc.deposit(50)
acc.withdraw(30)
assert acc.balance == 120
# acc.balance = 0 # AttributeError: свойство только на чтение

Задание 5. Класс Fraction (повышенной сложности)

Реализуйте неизменяемую (по соглашению) обыкновенную дробь.

Требования:

  • атрибуты _numerator, _denominator; знаменатель не равен нулю (ValueError);
  • при инициализации сокращайте дробь (используйте math.gcd) и нормализуйте знак (минус хранится в числителе);
  • свойства numerator и denominator только на чтение;
  • арифметика: __add__ и __mul__ (возвращают новый Fraction);
  • __eq__ — сравнение по значению; __repr__ вида Fraction(3, 4) и __str__ вида 3/4;
  • @classmethod from_int(value) — создать дробь из целого числа.
from math import gcd
class Fraction:
def __init__(self, numerator: int, denominator: int) -> None:
... # проверка знаменателя, сокращение, нормализация знака
@property
def numerator(self) -> int: ...
@property
def denominator(self) -> int: ...
def __add__(self, other: "Fraction") -> "Fraction": ...
def __mul__(self, other: "Fraction") -> "Fraction": ...
@classmethod
def from_int(cls, value: int) -> "Fraction": ...

Проверка:

assert Fraction(2, 4) == Fraction(1, 2)
assert Fraction(1, 2) + Fraction(1, 3) == Fraction(5, 6)
assert str(Fraction(6, 8)) == "3/4"

Критерии оценки

  • Задания 1–2 (базовые) — корректные классы, разделение атрибутов класса и экземпляра, наличие __repr__: 30%.
  • Задания 3–4 — корректная работа @property (геттер/сеттер/только чтение), инкапсуляция (_/__), валидация через ValueError, фабричный/статический метод: 35%.
  • Задание 5 — корректные магические методы (__str__, __repr__, __eq__, арифметика), сокращение и нормализация дроби: 20%.
  • Качество кода — аннотации типов, читаемое именование, отсутствие дублирования, наличие проверок/тестов: 15%.

Штрафы: прямой доступ к «защищённым» атрибутам извне вместо интерфейса, отсутствие валидации инвариантов, изменяемый атрибут класса вместо атрибута экземпляра.

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

  1. Чем атрибут класса отличается от атрибута экземпляра? Что произойдёт с created из задания 2, если объявить его в __init__ через self?
  2. Зачем в задании 3 инициализация в __init__ идёт через сеттер, а не присваиванием напрямую в _celsius?
  3. В чём разница между _balance и __number в задании 4? Как добраться до __number извне и почему это «мягкая» защита?
  4. Почему свойство balance сделано только на чтение? Как это согласуется с принципом инкапсуляции?
  5. Зачем __eq__ возвращает NotImplemented для объектов чужого типа, а не False?
  6. В чём разница между __str__ и __repr__? Что выведет print(obj), если определён только __repr__?
  7. Почему from_fahrenheit и from_int оформлены как @classmethodcls), а не как @staticmethod? Какое преимущество это даёт при наследовании?
  8. Почему дробь из задания 5 разумно считать неизменяемой и возвращать новый объект в __add__/__mul__, а не менять текущий?