Введение в проблему сравнения объектов в Python и библиотеке attrs

Представьте ситуацию: вы пишете сервис синхронизации конфигураций, а тесты внезапно начинают падать просто потому, что у двух идентичных серверов различаются таймстампы последнего пинга. Знакомая боль? Когда стандартная проверка == начинает рушить бизнес-логику из-за служебных полей, на помощь приходит кастомизация (примерно так же мы пытаемся игнорировать баги в легаси-коде, надеясь, что они исчезнут сами). К счастью, библиотека attrs (и её концептуальный наследник в стандартной библиотеке — dataclasses) создана не только для того, чтобы избавлять нас от написания шаблонного кода для __init__, __repr__ и __eq__, но и для тонкой настройки.

По умолчанию attrs генерирует метод сравнения, возвращающий True только тогда, когда оба экземпляра принадлежат одному классу, а значения всех их полей попарно равны. Но на практике этого часто не хватает. Что делать, если экземпляры должны считаться равными по бизнес-логике, даже если часть их полей различается? Как исключить служебные поля (вроде таймстампов или счетчиков попыток) из операции ==? В этой статье мы разберем, как устроен механизм равенства в attrs, и рассмотрим продвинутые паттерны кастомизации сравнения объектов.

Как работает автоматическая генерация сравнения в attrs

Чтобы понять, как обуздать сравнение объектов, заглянем под капот автогенерации. Когда вы декорируете класс с помощью @define или @attr.s, библиотека собирает все поля, помеченные через attr.ib() или аннотации типов. По умолчанию параметр eq равен True для каждого из них.

Рассмотрим базовый пример:

import attr

@attr.s
class ServerConfig:
    host = attr.ib()
    port = attr.ib()
    timeout = attr.ib()

config1 = ServerConfig("localhost", 8080, 30)
config2 = ServerConfig("localhost", 8080, 30)

print(config1 == config2)  # Выведет True

Если требуется исключить определенный атрибут из сравнения (например, время последнего подключения или метаданные трассировки), достаточно передать параметр eq=False при объявлении поля:

@attr.s
class FlexibleConfig:
    host = attr.ib()
    port = attr.ib()
    timeout = attr.ib(eq=False)  # Это поле игнорируется при сравнении

c1 = FlexibleConfig("127.0.0.1", 5432, 10)
c2 = FlexibleConfig("127.0.0.1", 5432, 60)

print(c1 == c2)  # Выведет True, несмотря на разный timeout!

Но что делать, если исключить поле недостаточно и правила равенства начинают диктоваться более сложными условиями?

Продвинутая кастомизация: когда встроенных параметров недостаточно

Иногда логика равенства зависит от внешнего контекста или требует сложных вычислений, которые нельзя описать простым флагом eq=False. В таких случаях стандартный подход attrs можно переопределить, написав собственный метод __eq__ внутри класса (и молиться, чтобы вы не нарушили контракт транзитивности, иначе Stack Overflow будет вашим лучшим другом до конца недели).

Библиотека attrs позволяет комбинировать автоматическую генерацию кода с ручной доработкой. Если вы реализуете метод __eq__ вручную, Python будет использовать вашу логику вместо сгенерированной автоматически:

@attr.s
class CustomEqualConfig:
    host = attr.ib()
    port = attr.ib()
    allowed_envs = attr.ib(default=attr.Factory(list))

    def __eq__(self, other):
        if not isinstance(other, CustomEqualConfig):
            return NotImplemented
        # Сравниваем только хост и порт, игнорируя порядок в списках сред
        return self.host == other.host and self.port == other.port

Заключение

Библиотека attrs предоставляет гибкие инструменты для управления равенством объектов. Для простых сценариев достаточно использовать параметр eq=False на уровне конкретных полей. Если же бизнес-логика требует нестандартных проверок, ничто не мешает переопред