Python: модель данных и ссылки
Дандер-методы это и есть «питоничность»: len(), for, with работают через протоколы, и вопросы про __eq__/__hash__ и is против == проверяют, понимаешь ли ты язык изнутри.
// Самый частый провал темы: переопределил __eq__, и объекты внезапно перестали лезть в set. Разберём именно его.
Протоколы: язык зовёт дандеры
len(x) → __len__, x[k] → __getitem__, for → __iter__, with → __enter__/__exit__. Реализуешь протокол - объект ведёт себя как встроенный. Это и отличает питонический дизайн от джавы-на-питоне.
__repr__ - однозначное представление для разработчика (логи, отладка), __str__ - для пользователя. Минимум __repr__ стоит определять всегда: дебажить <Object at 0x…> - самонаказание.
// Истинность: __bool__, иначе __len__, иначе True. Пустые коллекции ложны - if not items: идиоматичнее сравнения длины с нулём.
- __repr__ / __str__
- для разработчика (однозначно) / для пользователя (красиво)
__eq__ и __hash__ ходят парой; is не равно ==
Контракт: равные объекты обязаны иметь равные хеши. Переопределил __eq__ - Python занулил __hash__, объект выпал из set и ключей dict. Определяй __hash__ по тем же полям, что участвуют в __eq__; мутабельные объекты честнее не хешировать вовсе.
is - идентичность (тот же объект в памяти), == - равенство значений. is уместен для None, True, False и синглтонов, и только для них.
// x is 1000 может быть False при x == 1000: интернирование мелких int - деталь реализации, а не гарантия языка. Сравнивать числа через is - мина.
- хеш-контракт
- a == b ⇒ hash(a) == hash(b); иначе dict/set ломаются
Контекстные менеджеры и опасные дандеры
with гарантирует __exit__ при любом исходе - исключение, return, что угодно: ресурс освобождён. Свой менеджер - пара __enter__/__exit__ или генератор с @contextlib.contextmanager: код до yield - вход, после - гарантированная уборка.
__getattr__ зовётся только при провале обычного поиска атрибута; __getattribute__ - на каждый доступ, и self.attr внутри него - бесконечная рекурсия (нужен object.__getattribute__).
// Для доменных типов операторы перегружают дандерами (__add__, __lt__), а functools.total_ordering достроит все сравнения из __eq__ + __lt__.
- contextmanager
- контекстный менеджер из генератора: до yield - вход, после - уборка
Как отвечать: «Переопределил __eq__ - что ещё сломалось и почему?»
Хешируемость: когда класс определяет __eq__ без __hash__, Python выставляет __hash__ = None, и объект больше не годится в set и в ключи dict - TypeError: unhashable. Причина - контракт: равные объекты обязаны давать равный хеш, а старый хеш по identity его нарушил бы. Чиню, определяя __hash__ по тем же полям, что сравнивает __eq__; если объект мутабельный - не хеширую вовсе. Либо отдаю это dataclass'у: eq=True, frozen=True генерируют согласованную пару за меня.
Названо не только «что сломалось», но и почему язык ломает это намеренно - плюс два пути починки, ручной и декларативный.
На чём валят
- −x is 1000 может быть False при равенстве значений - интернирование не гарантия; is только для None и синглтонов.
- −__eq__ без __hash__ - TypeError: unhashable при попытке положить в set.
- −Ресурсы без with: исключение между open и close - утечка.
- −__getattribute__ с self.attr внутри - бесконечная рекурсия.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.
- t = ([1],); затем t[0] += [2]. Что произойдёт?A)Бросится TypeError (кортеж неизменяем), НО t[0] всё равно станет [1, 2] — список мутировал до неудачного присваивания обратно в кортежB)Ничего не изменится и не произойдёт: кортеж неизменяем, поэтому t[0] спокойно останется равен [1] безо всяких ошибок и предупрежденийC)Список внутри просто спокойно расширится до [1, 2] безо всякой ошибки — кортеж хранит лишь ссылку, а менять содержимое по ссылке можноD)Кортеж целиком заменится на новый ([1, 2],), потому что операция += создаёт новый кортеж на месте старого
показать ответ и разбор
+A)Бросится TypeError (кортеж неизменяем), НО t[0] всё равно станет [1, 2] — список мутировал до неудачного присваивания обратно в кортеж// разбор: t[0] += [2] для списка означает t[0] = t[0].__iadd__([2]): сначала список расширяется на месте (__iadd__ мутирует его до [1,2] и возвращает тот же объект), а затем Python пытается ПРИСВОИТЬ результат обратно в t[0] — и это присваивание в неизменяемый кортеж падает с TypeError. Итог парадоксален (проверено): исключение брошено, но список УЖЕ стал [1,2]. Отсюда правило: не держать мутабельные объекты в кортежах и не полагаться на += к ним.
- a = [1, 2, 3]; b = a; b.append(4). Что теперь лежит в a?A)[1, 2, 3] — b получил собственную копию спискаB)ошибка времени выполнения: список менять через второе имяC)зависит от области видимости, где объявлены a и bD)[1, 2, 3, 4] — a и b указывают на один список
показать ответ и разбор
+D)[1, 2, 3, 4] — a и b указывают на один список// разбор: Присваивание в Python не копирует объект — оно привязывает имя к тому же объекту. a и b — две ссылки на один список, изменение через любую видно через обе. Нужна копия — list(a), a[:], copy.copy(); для вложенных структур — copy.deepcopy(). Тот же механизм даёт сюрпризы с изменяемыми аргументами функций.
- Какие из встроенных типов Python неизменяемы (immutable)?A)list, dict и set — их содержимое защищено от измененияB)Неизменяемых типов среди встроенных нет, менять можно всёC)int, str и tuple — их значение нельзя поменять на местеD)Только str: строки хранятся в общем пуле и переиспользуются
показать ответ и разбор
+C)int, str и tuple — их значение нельзя поменять на месте// разбор: Числа, строки, кортежи и frozenset неизменяемы: s += 'x' не правит строку, а создаёт новую и переназначает имя. Списки, словари и множества меняются на месте. Практическое следствие — ключом словаря и элементом множества может быть только хешируемое значение: кортеж подойдёт, список нет. Отсюда же и грабля с изменяемым аргументом по умолчанию.
- t = (1, 2); затем t += (3,). Кортеж ведь неизменяем — почему код работает?A)Кортеж изменяем при добавлении в конец, запрещена лишь замена элемента внутриB)Создаётся новый кортеж, и имя t начинает указывать на негоC)Python молча превращает t в список, чтобы выполнить операциюD)Операция выполняется на месте: у кортежей есть метод __iadd__
показать ответ и разбор
+B)Создаётся новый кортеж, и имя t начинает указывать на него// разбор: Оператор += для неизменяемых типов не правит объект, а собирает новый и переназначает имя — id(t) до и после будет разным. Так же ведут себя строки и числа. Отсюда неочевидное следствие: если тот же кортеж лежал в другой переменной, она по-прежнему указывает на старую версию. Списки же реализуют __iadd__ и меняются на месте, поэтому там += видят все ссылки.
- Фильтр статусов пропускает вообще все заказы:
Почему условие всегда истинно?for o in orders: if o.status == 'paid' or 'shipped': process(o) # выполняется всегдаA)Оператор or в Python сравнивает строки по длине, а 'shipped' длиннее типичных статусовB)Строковые литералы в условии интернируются и совпадают со статусами того же размераC)Условие читается как (status == 'paid') or 'shipped': непустая строка истинна сама по себеD)== имеет более низкий приоритет, чем or, поэтому сравнивается уже результат целого or-выраженияпоказать ответ и разбор
+C)Условие читается как (status == 'paid') or 'shipped': непустая строка истинна сама по себе// разбор:
orне «дораспределяет» сравнение на второй операнд: правая часть — просто строка 'shipped', а непустая строка истинна. Итог — условие всегда True. Правильно:o.status == 'paid' or o.status == 'shipped', а идиоматичнее —o.status in ('paid', 'shipped'). Ошибка коварна тем, что код валиден, линтеры старых настроек молчат, а фильтр «работает» — просто пропускает всё.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.