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

5.02. ООП в Python

Разработчику Архитектору

ООП в Python

Пример класса в Python

class Unit:
def __init__(self):
self.name = "Имя"
self.intel = 10
self.agility = 10
self.strength = 10
self.health = 100
self.mana = 50
self.level = 1

@property
def damage(self):
return (self.intel + self.agility + self.strength) + (self.level * 2)

def attack(self, target):
print(f"{self.name} атакует {target.name} и наносит {self.damage} единиц урона.")
target.health -= self.damage
print(f"{target.name} теперь имеет {target.health} здоровья.")


warrior = Unit()
warrior.name = "Воин"
warrior.intel = 5
warrior.agility = 15
warrior.strength = 30

mage = Unit()
mage.name = "Маг"
mage.intel = 35
mage.agility = 10
mage.strength = 5

warrior.attack(mage)
mage.attack(warrior)

Ключевое слово class создаёт новый класс. Имя класса начинается с заглавной буквы по соглашению об именовании в Python.

Метод __init__ выполняется автоматически при создании экземпляра класса. В этом методе устанавливаются начальные значения атрибутов объекта через обращение к self.

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

Декоратор @property превращает метод в вычисляемое свойство. При каждом обращении к damage происходит пересчёт значения на основе текущих характеристик объекта. Такой подход обеспечивает актуальность данных без явного вызова метода.

Метод attack принимает другой объект в качестве аргумента. Первый параметр self ссылается на объект, у которого вызывается метод. Внутри метода изменяется состояние целевого объекта через прямое обращение к его атрибутам.

Функция Unit() создаёт новый экземпляр класса. После создания объекта можно изменять значения его атрибутов через точечную нотацию.

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


Объектно-ориентированное программирование

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

В Python ООП реализовано не как строго типизированная система, подобная C# или Java, а как гибкая, динамическая модель, где границы между типами, классами и объектами стерты. Это придаёт Python особую выразительность, но одновременно требует от разработчика более глубокого понимания механизмов языка.

ООП в Python существенно отличается от его реализации в строго типизированных языках, таких как C#. Эти различия обусловлены философией самого языка: duck typing, динамическая типизация, метаклассы, единообразие системы типов и первоклассность функций.


Всё является объектом

В Python всё является объектом: числа, строки, функции, модули, классы. Каждый объект имеет тип, значение и набор атрибутов. Даже сам класс — это объект, экземпляр метакласса (по умолчанию type).

Это означает, что классы можно создавать динамически, передавать как аргументы, присваивать переменным.

class MyClass:
pass

print(type(MyClass)) # <class 'type'>
# Числа
x = 42
print(type(x)) # <class 'int'>
print(x.bit_length()) # 6

# Строки
s = "hello"
print(type(s)) # <class 'str'>
print(s.upper()) # HELLO

# Функции
def greet(name):
return f"Hello, {name}!"

print(type(greet)) # <class 'function'>
greet.description = "Простое приветствие"
print(greet.description) # Простое приветствие

# Модули
import math
print(type(math)) # <class 'module'>
print(math.sqrt(16)) # 4.0

# Классы
class MyClass:
pass

print(type(MyClass)) # <class 'type'>
MyClass.version = "1.0"
print(MyClass.version) # 1.0

# Даже type является объектом
print(type(type)) # <class 'type'>

Каждый объект имеет атрибут __class__, ссылающийся на его тип, и атрибут __dict__, содержащий пространство имён (для объектов, поддерживающих его). Классы могут иметь собственные атрибуты и методы, так как они тоже объекты.


Динамичность и изменяемость

Классы и объекты в Python являются изменяемыми во время выполнения. Можно добавлять, удалять или изменять атрибуты и методы "на лету".

Это даёт большую гибкость, но усложняет статический анализ кода.

obj = MyClass()
obj.new_attr = "dynamic"
MyClass.new_method = lambda self: print("Added dynamically")

Отсутствие строгой инкапсуляции

Python не поддерживает настоящих приватных членов. Вместо этого используется соглашение об именовании (_ и __) и механизм name mangling. Это означает, что инкапсуляция — скорее социальный контракт, чем техническое ограничение.


Утиная типизация

Duck typing вместо строгой типизации.

Python не требует явного наследования от интерфейса. Если объект ведёт себя как нужный тип (имеет нужные методы и поведение), он считается совместимым. Это центральный элемент полиморфизма в Python.

Если это ходит как утка и крякает как утка, значит, это утка.

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

class Duck:
def quack(self):
return "Quack!"

def walk(self):
return "Waddle"

class RobotDuck:
def quack(self):
return "Beep-quack!"

def walk(self):
return "Roll"

class Human:
def quack(self):
return "Imitating duck"

def walk(self):
return "Bipedal walk"

def make_it_quack_and_walk(thing):
print(f"{thing.quack()}{thing.walk()}")

make_it_quack_and_walk(Duck()) # Quack! → Waddle
make_it_quack_and_walk(RobotDuck()) # Beep-quack! → Roll
make_it_quack_and_walk(Human()) # Imitating duck → Bipedal walk

Функция make_it_quack_and_walk работает с любым объектом, имеющим методы quack и walk, независимо от его класса. Это устраняет необходимость в явных интерфейсах и позволяет создавать гибкие, расширяемые системы. Утиная типизация лежит в основе многих паттернов проектирования в Python, включая адаптеры и стратегии.


Метаклассы

Классы в Python создаются с помощью метаклассов. По умолчанию используется type, но можно определить собственный метакласс для изменения способа создания классов.

Это мощный механизм, позволяющий внедрять поведение на уровне определения класса (например, регистрация классов, автоматическое добавление методов).

Метакласс — это класс, экземплярами которого являются другие классы. Обычный класс создаёт экземпляры объектов, а метакласс создаёт сами классы. По умолчанию все классы в Python являются экземплярами метакласса type.

class Meta(type):
def __new__(mcs, name, bases, namespace):
print(f"Создаём класс {name} через метакласс {mcs.__name__}")
namespace['created_by_meta'] = True
return super().__new__(mcs, name, bases, namespace)

def __init__(cls, name, bases, namespace):
print(f"Инициализируем класс {name}")
super().__init__(name, bases, namespace)

class MyClass(metaclass=Meta):
value = 42

print(MyClass.created_by_meta) # True
print(type(MyClass)) # <class '__main__.Meta'>

Метаклассы применяются для автоматической регистрации классов, применения ограничений к структуре класса, внедрения методов или атрибутов на этапе определения класса. Распространённый пример — фреймворк Django, где метаклассы обрабатывают объявления полей модели и строят схему базы данных.

class RegistryMeta(type):
registry = {}

def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
if name != 'BasePlugin':
RegistryMeta.registry[name] = cls
return cls

class BasePlugin(metaclass=RegistryMeta):
pass

class PluginA(BasePlugin):
pass

class PluginB(BasePlugin):
pass

print(RegistryMeta.registry) # {'PluginA': <class '__main__.PluginA'>, 'PluginB': <class '__main__.PluginB'>}

Класс

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

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

Класс является вызываемым объектом. Его вызов приводит к созданию нового экземпляра.

Класс объявляется ключевым словом class, за которым следует имя класса в формате CamelCase и двоеточие. Тело класса записывается с отступом.

class SimpleClass:
pass

Класс может наследовать один или несколько базовых классов, перечисленных в круглых скобках после имени.

class Base:
pass

class Derived(Base):
pass

class MultiDerived(Base1, Base2, Base3):
pass

В теле класса определяются атрибуты класса, методы и другие члены. Атрибуты класса создаются присваиванием на уровне класса. Методы определяются как обычные функции с первым параметром self (для методов экземпляра).

class Person:
species = "Homo sapiens" # атрибут класса

def __init__(self, name, age):
self.name = name # атрибут экземпляра
self.age = age

def introduce(self):
return f"Я {self.name}, мне {self.age} лет"

Имя класса становится переменной в текущем пространстве имён, ссылающейся на объект класса. Это позволяет передавать классы как аргументы, присваивать их переменным и создавать их динамически.


Объект

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

p = Point(1, 2)

Состояние объекта хранится в его пространстве имён — словаре __dict__. Каждый объект имеет ссылку на свой класс через атрибут __class__.

Экземпляр создаётся вызовом класса как функции. Этот вызов приводит к выполнению метода __new__, создающего объект, и метода __init__, инициализирующего его состояние.

class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
self.mileage = 0

my_car = Car("Toyota", "Camry")

Вызов класса — это операция __call__, определённая в метаклассе. Для обычных классов этот вызов преобразуется в последовательность __new____init__.

Фабричные функции и методы класса предоставляют альтернативные способы создания экземпляров с разной логикой инициализации.

class DatabaseConnection:
def __init__(self, host, port, user, password):
self.host = host
self.port = port
self.user = user
self.password = password

@classmethod
def from_config(cls, config_dict):
return cls(
config_dict['host'],
config_dict['port'],
config_dict['user'],
config_dict['password']
)

conn = DatabaseConnection.from_config({
'host': 'localhost',
'port': 5432,
'user': 'admin',
'password': 'secret'
})

Конструкторы

Конструктор в Python состоит из двух этапов: создание объекта методом __new__ и его инициализация методом __init__. Метод __new__ отвечает за выделение памяти и возврат нового экземпляра. Он вызывается первым и принимает класс как первый аргумент cls.

class Point:
def __new__(cls, x, y):
print(f"Создаём экземпляр класса {cls.__name__}")
instance = super().__new__(cls)
return instance

def __init__(self, x, y):
print(f"Инициализируем экземпляр с координатами ({x}, {y})")
self.x = x
self.y = y

p = Point(3, 4)

В большинстве случаев достаточно переопределять только __init__, так как базовая реализация __new__ уже корректно создаёт экземпляр. Метод __new__ требуется при создании неизменяемых типов или при необходимости вернуть объект другого класса.

Альтернативные конструкторы реализуются через методы класса с декоратором @classmethod. Это позволяет создавать объекты разными способами, сохраняя чистоту основного __init__.

class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day

@classmethod
def from_string(cls, date_string):
year, month, day = map(int, date_string.split('-'))
return cls(year, month, day)

@classmethod
def today(cls):
import datetime
now = datetime.date.today()
return cls(now.year, now.month, now.day)

d1 = Date(2026, 1, 31)
d2 = Date.from_string("2026-01-31")
d3 = Date.today()

self в Python

Параметр self — это ссылка на конкретный экземпляр класса, от которого вызывается метод. В отличие от некоторых языков, где ключевое слово this подставляется автоматически, Python требует явного указания первого параметра в определении метода.

class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height

def area(self):
return self.width * self.height

rect = Rectangle(5, 10)
print(rect.area()) # Вызов преобразуется в Rectangle.area(rect)

При вызове rect.area() интерпретатор автоматически передаёт rect в качестве первого аргумента методу area. Имя self — соглашение, а не ключевое слово. Можно использовать любое другое имя, но это нарушает стандарты сообщества и снижает читаемость кода.

class Example:
def show(instance):
print(f"Экземпляр: {instance}")

e = Example()
e.show() # Работает, но так писать не принято

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


Обращение к атрибутам и методам

Атрибуты и методы доступны через точечную нотацию: объект.атрибут или объект.метод(). Поиск атрибута происходит сначала в пространстве имён экземпляра (__dict__), затем в классе и далее по цепочке наследования согласно MRO.

class Widget:
default_color = "gray"

def __init__(self, name):
self.name = name
self.color = Widget.default_color

def render(self):
return f"<{self.name} color='{self.color}'>"

w = Widget("button")
print(w.name) # button (атрибут экземпляра)
print(w.color) # gray (атрибут экземпляра, инициализированный из класса)
print(w.render()) # <button color='gray'>

Динамический доступ к атрибутам осуществляется функциями getattr, setattr, hasattr и delattr.

class Config:
debug = True
timeout = 30

cfg = Config()
print(getattr(cfg, 'debug')) # True
setattr(cfg, 'timeout', 60)
print(hasattr(cfg, 'log_level')) # False
delattr(cfg, 'debug')

Свойства (@property) позволяют контролировать доступ к атрибутам через методы, сохраняя простой синтаксис обращения.

class Temperature:
def __init__(self, celsius):
self._celsius = celsius

@property
def celsius(self):
return self._celsius

@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Температура не может быть ниже абсолютного нуля")
self._celsius = value

@property
def fahrenheit(self):
return self._celsius * 9/5 + 32

temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.celsius = 30 # Вызов сеттера с валидацией

Метод

Метод — это функция, определённая внутри класса и предназначенная для работы с экземпляром (или самим классом).

В Python различают несколько видов методов:

  1. Обычные методы (instance methods). Принимают self в качестве первого аргумента — ссылку на экземпляр. Вызываются от объекта.
def move(self, dx, dy):
self.x += dx
self.y += dy
  1. Методы класса (@classmethod). Принимают cls — ссылку на сам класс. Полезны для альтернативных конструкторов.
@classmethod
def origin(cls):
return cls(0, 0)
  1. Статические методы (@staticmethod). Не принимают ни self, ни cls. Логически связаны с классом, но не зависят от его состояния. По сути — обычные функции, принадлежащие пространству имён класса.
@staticmethod
def distance(p1, p2):
return ((p1.x - p2.x)**2 + (p1.y - p2.y)**2)**0.5

Поля, свойства и атрибуты

Поля (атрибуты экземпляра и класса).

Атрибуты — это данные, связанные с объектом или классом.

  • Атрибуты экземпляра создаются в __init__ или позже и хранятся в instance.__dict__.
  • Атрибуты класса определяются на уровне класса и разделяются между всеми экземплярами (осторожно с изменяемыми типами!).
class Counter:
count = 0 # атрибут класса

def __init__(self):
Counter.count += 1 # общая статистика
self.id = Counter.count # атрибут экземпляра

Свойства (property) позволяют контролировать доступ к атрибутам через геттеры, сеттеры и делитеры, сохраняя синтаксис обращения к обычному атрибуту.

class Circle:
def __init__(self, radius):
self._radius = radius

@property
def radius(self):
return self._radius

@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value

@property
def area(self):
return 3.14159 * self._radius ** 2

Использование:

c = Circle(5)
print(c.area) # вызов без ()
c.radius = 10 # проверка через сеттер

Свойства особенно важны при необходимости валидации, ленивых вычислений или совместимости с существующим API.


Принципы ООП

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

Инкапсуляция — принцип сокрытия внутренней реализации объекта. В Python она реализуется не через ключевые слова (private, protected), а через соглашения и механизм преобразования имён.

Одно подчёркивание (_attr) — указывает, что атрибут является внутренним. Это сигнал другим разработчикам: "не используйте напрямую, поведение может измениться".

Двойное подчёркивание (__attr) — активирует name mangling: имя атрибута преобразуется в _ClassName__attr. Это предотвращает случайное переопределение в подклассах.

class A:
def __init__(self):
self._internal = 42
self.__mangled = "hidden"

class B(A):
def __init__(self):
super().__init__()
self.__mangled = "also hidden in B"

b = B()
print(b._internal) # 42
print(b._A__mangled) # "hidden"
print(b._B__mangled) # "also hidden in B"

Таким образом, __ — защита от случайного доступа и конфликтов имён в иерархии наследования.


Наследование

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

class Animal:
def speak(self):
raise NotImplementedError

class Dog(Animal):
def speak(self):
return "Woof!"

Python поддерживает множественное наследование. Порядок разрешения методов определяется алгоритмом C3 linearization, доступным через __mro__ (Method Resolution Order).

class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

print(D.__mro__) # D -> B -> C -> A -> object

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

class A:
def method(self):
print("A")

class B(A):
def method(self):
super().method()
print("B")

class C(A):
def method(self):
super().method()
print("C")

class D(B, C):
def method(self):
super().method()
print("D")

D().method() # A → C → B → D

Полиморфизм

Полиморфизм — способность объектов с одинаковым интерфейсом вести себя по-разному.

  1. Переопределение методов. Производный класс может переопределять методы базового, обеспечивая специфическую реализацию.
class Shape:
def area(self):
raise NotImplementedError

class Rectangle(Shape):
def __init__(self, w, h):
self.w = w
self.h = h

def area(self):
return self.w * self.h
  1. Duck typing. В Python полиморфизм основан на наличии нужного интерфейса.
def print_area(shape):
print(shape.area()) # работает с любым объектом, у которого есть area()

Это позволяет использовать композицию вместо наследования и достигать большей гибкости.

Переопределение (override) — предоставление новой реализации метода в подклассе. Базовый метод остаётся доступным через super().

class Animal:
def speak(self):
return "Some sound"

class Dog(Animal):
def speak(self):
base_sound = super().speak()
return f"{base_sound} → Woof!"

class Cat(Animal):
def speak(self):
return "Meow"

animals = [Animal(), Dog(), Cat()]
for a in animals:
print(a.speak())

Python не поддерживает перегрузку методов (overload) в классическом смысле — нескольких методов с одинаковым именем, но разными параметрами. Последнее определение метода заменяет предыдущие.

class Calculator:
def add(self, a, b):
return a + b

def add(self, a, b, c): # Первая версия add перезаписана
return a + b + c

calc = Calculator()
print(calc.add(1, 2, 3)) # 6
# print(calc.add(1, 2)) # TypeError: add() missing 1 required positional argument

Вместо перегрузки используются аргументы по умолчанию, *args, **kwargs или модуль typing.overload для статической проверки типов без изменения поведения во время выполнения.

from typing import overload, Union

class FlexibleCalculator:
@overload
def add(self, a: int, b: int) -> int: ...

@overload
def add(self, a: str, b: str) -> str: ...

def add(self, a, b):
return a + b

calc = FlexibleCalculator()
print(calc.add(2, 3)) # 5
print(calc.add("a", "b")) # ab

Декоратор @overload влияет только на анализ типов в инструментах вроде mypy и не создаёт нескольких реализаций метода.


Абстракция

Абстракция — выделение ключевых характеристик, скрывая детали реализации. В Python абстрактные классы реализуются через модуль abc.

Класс становится абстрактным, если содержит хотя бы один абстрактный метод, помеченный декоратором @abstractmethod.

from abc import ABC, abstractmethod

class Drawable(ABC):
@abstractmethod
def draw(self):
pass

@abstractmethod
def bounding_box(self):
pass

class Circle(Drawable):
def draw(self):
print("Drawing circle")

def bounding_box(self):
return (0, 0, 10, 10)

# Drawable() # Ошибка: нельзя создать экземпляр абстрактного класса

ABC позволяют:

  • Формально определить интерфейс.
  • Запретить создание неполных реализаций.
  • Использовать isinstance() для проверки соответствия интерфейсу.

Также можно определять абстрактные свойства и абстрактные классовые методы.

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

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
@abstractmethod
def authorize(self, amount: float) -> bool:
pass

@abstractmethod
def capture(self, transaction_id: str) -> bool:
pass

def refund(self, transaction_id: str, amount: float) -> bool:
"""Неабстрактный метод с реализацией по умолчанию"""
raise NotImplementedError("Возврат не поддерживается этим процессором")

class StripeProcessor(PaymentProcessor):
def authorize(self, amount: float) -> bool:
print(f"Авторизуем {amount} через Stripe")
return True

def capture(self, transaction_id: str) -> bool:
print(f"Захватываем транзакцию {transaction_id}")
return True

# processor = PaymentProcessor() # TypeError: Can't instantiate abstract class
processor = StripeProcessor()
processor.authorize(100.0)

Абстрактными могут быть методы, свойства и классовые методы.

class DataStore(ABC):
@property
@abstractmethod
def capacity(self) -> int:
pass

@abstractmethod
def store(self, key: str, value: bytes) -> None:
pass

@abstractmethod
def retrieve(self, key: str) -> bytes:
pass

class MemoryStore(DataStore):
def __init__(self):
self._data = {}
self._max_size = 1024 * 1024 # 1 MB

@property
def capacity(self) -> int:
return self._max_size

def store(self, key: str, value: bytes) -> None:
if len(value) > self.capacity:
raise ValueError("Данные превышают ёмкость хранилища")
self._data[key] = value

def retrieve(self, key: str) -> bytes:
return self._data[key]

Абстрактные базовые классы позволяют определять интерфейсы с обязательными контрактами, обеспечивая корректность реализаций на этапе разработки. Они особенно полезны при проектировании фреймворков и библиотек, где требуется строгая спецификация поведения подклассов.


Прочие особенности

Магические методы

Магические методы (от double underscore) — это методы с именами вида __xxx__, которые определяют поведение объекта при взаимодействии с языковыми конструкциями. Они не предназначены для прямого вызова, а вызываются интерпретатором в ответ на определённые операции.

Основные магические методы:

  • __init__(self, ...) — инициализация экземпляра. Не конструктор! Конструктор — __new__.
  • __new__(cls, ...) — создание экземпляра. Вызывается до __init__. Может возвращать объект другого класса.
  • __str__(self) — строковое представление для пользователя (str(obj)).
  • __repr__(self) — подробное представление для разработчика (repr(obj)). Желательно, чтобы eval(repr(obj)) == obj.
  • __call__(self, ...) — позволяет вызывать объект как функцию.
  • __len__(self) — возвращает длину (len(obj)).
  • __getitem__(self, key) / __setitem__ / __delitem__ — доступ по индексу (obj[key]).
  • __iter__(self) / __next__(self) — итерация.
  • __enter__(self) /__exit__(self, exc_type, exc_val, exc_tb) — контекстный менеджер (with).
  • __eq__(self, other) / __lt__ / __hash__ — сравнение и хеширование.
  • __add__(self, other) / __mul__ и т.д. — перегрузка операторов.
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y

def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)

def __repr__(self):
return f"Vector({self.x}, {self.y})"

v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)

Магические методы — основа протоколов Python: итерируемый, контейнер, вызываемый, численный и т.д.

Рассмотрим реализацию упрощённого словаря с возможностью отслеживания изменений.

from collections.abc import MutableMapping

class TrackedDict(MutableMapping):
def __init__(self, *args, **kwargs):
self._data = dict(*args, **kwargs)
self._changes = []

def __getitem__(self, key):
return self._data[key]

def __setitem__(self, key, value):
action = "update" if key in self._data else "add"
self._changes.append((action, key, value))
self._data[key] = value

def __delitem__(self, key):
old_value = self._data.pop(key)
self._changes.append(("delete", key, old_value))

def __iter__(self):
return iter(self._data)

def __len__(self):
return len(self._data)

def __repr__(self):
return f"TrackedDict({self._data})"

def get_changes(self):
return self._changes.copy()

def clear_changes(self):
self._changes.clear()

Использование:

td = TrackedDict(a=1)
td['b'] = 2
del td['a']
print(td.get_changes()) # [('add', 'b', 2), ('delete', 'a', 1)]

Здесь мы реализовали протокол изменяемого отображения, используя магические методы и наследование от MutableMapping, которое также предоставляет стандартные методы (keys, values, items и т.д.).