← Назад к вопросам

Как написать неизменяемый класс?

2.0 Middle🔥 81 комментариев
#Python Core#Архитектура и паттерны

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Как написать неизменяемый класс

Неизменяемость (immutability) — это важный паттерн для создания безопасного, предсказуемого кода. Разберёмся с несколькими способами реализации в Python.

Способ 1: Использование __slots__ и свойств

Базовый подход для контроля над атрибутами:

class Point:
    __slots__ = ('_x', '_y')
    
    def __init__(self, x, y):
        object.__setattr__(self, '_x', x)
        object.__setattr__(self, '_y', y)
    
    @property
    def x(self):
        return self._x
    
    @property
    def y(self):
        return self._y
    
    def __setattr__(self, name, value):
        raise AttributeError(f"Объект неизменяем, нельзя установить {name}")
    
    def __delattr__(self, name):
        raise AttributeError("Объект неизменяем, удаление запрещено")

# Использование:
p = Point(10, 20)
print(p.x, p.y)  # 10 20
# p.x = 5  # AttributeError: Объект неизменяем

Этот подход:

  • Блокирует любые изменения после инициализации
  • Использует __slots__ для оптимизации памяти
  • Требует явного использования object.__setattr__ в __init__

Способ 2: Декоратор @dataclass с frozen=True

Самый современный и удобный способ:

from dataclasses import dataclass

@dataclass(frozen=True)
class User:
    id: int
    name: str
    email: str
    
    def __post_init__(self):
        # Даже здесь нельзя менять атрибуты
        pass

# Использование:
user = User(id=1, name='Alice', email='alice@example.com')
print(user.name)  # Alice
# user.name = 'Bob'  # FrozenInstanceError

Плюсы:

  • Очень компактно
  • Автоматически создаёт __init__, __repr__, __eq__
  • Type hints встроены
  • Хэшируемо по умолчанию

Пример с методами:

from dataclasses import dataclass

@dataclass(frozen=True)
class Point:
    x: float
    y: float
    
    def distance_to_origin(self) -> float:
        return (self.x ** 2 + self.y ** 2) ** 0.5
    
    def translate(self, dx: float, dy: float) -> 'Point':
        # Возвращаем новый объект вместо изменения
        return Point(self.x + dx, self.y + dy)

# Использование:
p1 = Point(3, 4)
print(p1.distance_to_origin())  # 5.0
p2 = p1.translate(1, 1)
print(p2)  # Point(x=4, y=5)
print(p1)  # Point(x=3, y=4) — p1 не изменилась

Способ 3: Именованный кортеж (namedtuple)

Высокоуровневый способ — кортежи уже неизменяемы:

from typing import NamedTuple

class Address(NamedTuple):
    city: str
    country: str
    postal_code: str
    
    def full_address(self) -> str:
        return f"{self.city}, {self.country} {self.postal_code}"

# Использование:
addr = Address('Moscow', 'Russia', '119019')
print(addr.full_address())  # Moscow, Russia 119019
# addr.city = 'SPB'  # AttributeError

Очень компактно:

from collections import namedtuple

Point = namedtuple('Point', ['x', 'y'])
p = Point(1, 2)
# p.x = 5  # AttributeError

Способ 4: Использование __setattr__ с проверкой

Для большей контроля и кастомной логики:

class ImmutableConfig:
    def __init__(self, **kwargs):
        object.__setattr__(self, '_data', kwargs)
    
    def __getattr__(self, name):
        data = object.__getattribute__(self, '_data')
        return data.get(name)
    
    def __setattr__(self, name, value):
        if name == '_data':
            object.__setattr__(self, name, value)
        else:
            raise AttributeError(f"Атрибут {name} неизменяем")
    
    def __delattr__(self, name):
        raise AttributeError(f"Удаление атрибута {name} запрещено")
    
    def __repr__(self):
        data = object.__getattribute__(self, '_data')
        return f"ImmutableConfig({data})"

# Использование:
config = ImmutableConfig(debug=True, timeout=30)
print(config.debug)  # True
# config.debug = False  # AttributeError

Способ 5: Глубокая неизменяемость

Для защиты mutable атрибутов:

from dataclasses import dataclass
from typing import FrozenSet, Tuple
import copy

@dataclass(frozen=True)
class UserGroup:
    name: str
    members: FrozenSet[str]  # Используем frozenset вместо list
    tags: Tuple[str, ...] = ()  # Используем tuple вместо list
    
    def add_member(self, member: str) -> 'UserGroup':
        # Возвращаем новый объект с добавленным членом
        new_members = self.members | {member}
        return UserGroup(self.name, new_members, self.tags)

# Использование:
group = UserGroup('admins', frozenset(['alice', 'bob']))
print(group.members)  # frozenset({'alice', 'bob'})
new_group = group.add_member('charlie')
print(group.members)  # frozenset({'alice', 'bob'}) — не изменилась
print(new_group.members)  # frozenset({'alice', 'bob', 'charlie'})

Способ 6: Copy-on-write паттерн

Для оптимизации памяти при неизменяемости:

from copy import copy

class CopyOnWrite:
    def __init__(self, data):
        self._data = data
        self._hash = None
    
    def __hash__(self):
        if self._hash is None:
            self._hash = hash(frozenset(self._data.items()))
        return self._hash
    
    def __eq__(self, other):
        return self._data == other._data
    
    def with_update(self, **changes):
        # Создаём новый объект с изменениями
        new_data = self._data.copy()
        new_data.update(changes)
        return CopyOnWrite(new_data)
    
    def __repr__(self):
        return f"CopyOnWrite({self._data})"

# Использование:
obj1 = CopyOnWrite({'x': 1, 'y': 2})
obj2 = obj1.with_update(x=10)
print(obj1)  # CopyOnWrite({'x': 1, 'y': 2})
print(obj2)  # CopyOnWrite({'x': 10, 'y': 2})

# Можно использовать как ключи в dict (благодаря __hash__)
db = {obj1: 'first', obj2: 'second'}

Способ 7: Использование attrs библиотеки

Потенциально лучше, чем dataclass для сложных случаев:

import attr

@attr.s(slots=True, frozen=True)
class Coordinate:
    x: float = attr.ib()
    y: float = attr.ib()
    
    def move(self, dx: float, dy: float) -> 'Coordinate':
        return Coordinate(self.x + dx, self.y + dy)

# Использование:
coord = Coordinate(10, 20)
new_coord = coord.move(5, 5)
print(coord)  # Coordinate(x=10, y=20)
print(new_coord)  # Coordinate(x=15, y=25)

Сравнение подходов

┌─────────────────┬────────────┬──────────┬────────────┬────────────┐
│ Способ          │ Простота   │ Скорость │ Функции    │ Type hints │
├─────────────────┼────────────┼──────────┼────────────┼────────────┤
│ __slots__       │ Средняя    │ Быстро   │ Кастомные  │ Нет        │
│ @dataclass      │ Очень      │ Быстро   │ Хорошие    │ Да         │
│ NamedTuple      │ Очень      │ Очень    │ Ограничены │ Да         │
│ Кастомный       │ Сложная    │ Зависит  │ Полные     │ Нет        │
│ attrs           │ Средняя    │ Быстро   │ Очень      │ Да         │
└─────────────────┴────────────┴──────────┴────────────┴────────────┘

Лучшие практики

1. Предпочитай возврат нового объекта вместо изменения:

# Плохо
user.name = 'New Name'

# Хорошо
new_user = user.with_name('New Name')

2. Используй @dataclass(frozen=True) как default:

@dataclass(frozen=True)
class Event:
    timestamp: datetime
    event_type: str
    data: dict

3. Глубокая неизменяемость для mutable полей:

@dataclass(frozen=True)
class Container:
    items: Tuple[int, ...] = ()  # Tuple вместо list
    tags: FrozenSet[str] = field(default_factory=frozenset)

4. Реализуй __hash__ для неизменяемых объектов:

@dataclass(frozen=True)
class Point:  # Автоматически хэшируемо
    x: float
    y: float

points = {Point(1, 2), Point(2, 3)}  # Можно использовать в set
Как написать неизменяемый класс? | PrepBro