Python кажется простым и понятным — именно поэтому с него так классно начинать карьеру разработчика. Но за этой простотой скрываются тонкости, которые любят проверять на собеседованиях. И тут начинающий разработчик может неожиданно попасть впросак: вроде бы код работает, но поведение совсем не такое, как ожидалось.
Как автор курса «Python-разработчик» в Яндекс Практикуме, я часто разбираю подобные ситуации на своём YouTube-канале, где провожу открытые тестовые интервью с джунами. Всё, о чём я говорю, — это не абстрактные примеры, а реальные наблюдения и выводы, сделанные прямо в ходе этих собеседований.
В статье я собрал самые интересные особенности Python, которые неоднократно встречались в подобных интервью и которые почти наверняка всплывут на вашем техническом собеседовании. Покажу, что именно спрашивают интервьюеры, и поясню, почему это важно. Если вы только готовитесь к первой работе или хотите подтянуть фундаментальные знания — продолжайте читать.
Изменяемые типы данных в параметрах по умолчанию
Наверное, это самая известная «ловушка» Python. Вопрос про параметры любят задавать на собеседованиях именно потому, что тема кажется простой — пока не увидишь поведение кода вживую.
Посмотрим на код:
def add_item(item, items=[]):
items.append(item)
return items
print(add_item(1)) # ???
print(add_item(2)) # ???
print(add_item(3)) # ???
Вроде бы ожидаешь, что каждый вызов функции создаст новый список, и результат будет:
[1]
[2]
[3]
Но в реальности вывод будет такой:
[1]
[1, 2]
[1, 2, 3]
Дело в том, что значения параметров по умолчанию вычисляются один раз — в момент определения функции, а не при каждом вызове. Поэтому список items создаётся единожды и «живёт» между вызовами функции.
Если вы хотите, чтобы при каждом вызове создавался новый список, нужно использовать None как значение по умолчанию и инициализировать список внутри функции:
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Теперь результат будет таким, как нужно:
[1]
[2]
[3]
Эту фишку очень любят проверять на собеседованиях, потому что она показывает, понимаете ли вы, как Python работает с объектами в памяти.
Поэтому общее правило простое:
изменяемые типы данных (списки, словари, множества и т. д.) нельзя использовать в качестве значений по умолчанию, если не хотите неожиданного поведения;
неизменяемые типы данных (числа, строки, кортежи,
None,True/False) безопасны и могут использоваться как значения по умолчанию.
Такой принцип помогает избежать трудноуловимых багов и делает код предсказуемым.
Умножение списков и «размножение ссылок»
Работа с изменяемыми типами данных, а в частности со списками в Python, иногда подбрасывает неожиданные эффекты. Один из них связан с оператором *, который многие используют для инициализации повторяющихся структур данных. На первый взгляд кажется, что он создаёт новые независимые объекты, но на деле всё не так очевидно.
В Python запись вида…
a = [[]] * 3
print(a) # [[], [], []]
…часто сбивает с толку. На первый взгляд кажется, что мы создали три независимых пустых списка. Но на самом деле это три ссылки на один и тот же объект.
Проверим:
a[0].append(1)
print(a) # [[1], [1], [1]]
Оператор * для списков не клонирует элементы «по-настоящему». Он просто создаёт новый список и заполняет его ссылками на один и тот же объект.
То же самое произойдёт, если размножать любой изменяемый объект:
row = [0] * 3
matrix = [row] * 3
print(matrix) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
matrix[0][0] = 1
print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
Мы хотели получить «матрицу», где можно менять каждую строку отдельно, а получили три ссылки на одну и ту же строку.
Чтобы этого избежать, используйте генераторы списков или явное копирование:
a = [[] for _ in range(3)]
print(a) # [[], [], []]
a[0].append(1)
print(a) # [[1], [], []]
Теперь каждая строка независима от остальных.
Итог: оператор * при работе со списками дублирует ссылки, а не сами объекты. Для изменяемых структур данных это может привести к неожиданному поведению. Если же вы хотите получить действительно независимые копии — используйте генераторы списков, copy.deepcopy или другие способы явного копирования.
Пустые и непустые объекты в логических выражениях
В Python не все проверки в управляющей конструкции if завязаны только на True и False. Язык использует понятие truthy и falsy объектов.
Falsy — это значения, которые в логическом контексте интерпретируются как False. Сюда относятся:
0и0.0,пустые коллекции
[],{},(),set(),"",None,False.
Всё остальное считается truthy — то есть истиной.
Например:
if []:
print("Сработало!")
else:
print("Не сработало!")
Вывод:
Не сработало!
Пустой список [] считается falsy, поэтому условие не выполняется. Но стоит добавить в него элемент:
if [0]:
print("Сработало!")
И мы получим:
Сработало!
Даже если внутри списка «ложное» значение (0), сам факт, что список не пустой, делает его truthy.
Это активно используется в Python-коде, чтобы писать компактнее:
users = []
if not users:
print("Нет пользователей")
или так:
if items:
process(items)
Здесь мы проверяем не «явно» (len(items) > 0), а сразу используем truthy/falsy-поведение.
Иногда разработчики путают «пусто» и «ноль».
Например:
limit = 0
if not limit:
print("Нет лимита!")
Хотя логика могла требовать, что 0 — это валидное значение, а не «отсутствие».
В таких случаях лучше писать явно:
if limit is None:
print("Нет лимита!")
Таким образом, в Python пустые объекты, 0, None и False интерпретируются как ложь, а всё остальное считается истиной. Это упрощает запись условий и делает код компактнее, но при этом может ввести в заблуждение. Например, и пустой список, и число 0 считаются falsy, хотя по смыслу это могут быть разные ситуации.
Цепочки сравнений
Одна из фишек Python — возможность писать условия так, как они выглядят в математике:
if 3 < value < 7:
print("value в диапазоне")
Красиво, читаемо и работает именно так, как ожидается: сначала проверяется 3 < value, потом value < 7, а результат объединяется через and. Но тут многие джуны попадаются в ловушку.
Раз мы знаем, что 3 < value < 7 возвращает True или False, то, казалось бы, можно записать проверку на результат сравнения вот так:
if 3 < value < 7 == True:
print("value в диапазоне")
То есть «проверим, что выражение 3 < value < 7 действительно равно True».
Звучит логично, но работает не так, как кажется.
Python не воспринимает это выражение как:
if (3 < value < 7) == True:
Вместо этого он разворачивает цепочку сравнений:
if (3 < value) and (value < 7) and (7 == True):
А так как 7 == True всегда False, условие никогда не выполнится.
Если цель именно проверить результат логического выражения, то нужны скобки:
if (3 < value < 7) == True:
print("value в диапазоне")
Хотя на практике обычно == True не пишут вовсе — достаточно самой проверки:
if 3 < value < 7:
print("value в диапазоне")
Таким образом, в Python цепочки сравнений работают особым образом: выражение раскладывается на серию проверок, объединённых через and. Это делает код более выразительным, но при этом может привести к неожиданностям, если пытаться явно сравнивать результат с True или False. Поэтому лучше придерживаться «питоничного» стиля и писать условия напрямую, без лишних сравнений, — так код будет и чище, и предсказуемее.
Разница между a = a + 1 и a += 1
В Python, как и во многих других языках, есть не только обычное присваивание (=), но и так называемые составные операторы присваивания — +=, -=, *=, /=, и так далее. На первый взгляд они просто короче:
a = 5
a = a + 1 # a += 1 даёт тот же результат.
Для чисел (и вообще для неизменяемых типов — int, str, tuple) разницы действительно нет. Но в Python, в отличие от многих языков, под капотом это всё же разные операции.
Это означает, что в случае с a = a + 1:
Вычисляется
a + 1→ создаётся новый объект.Имя
aначинает ссылаться на этот новый объект.
А в случае с a += 1 вызывается специальный метод __iadd__ (”in-place addition”). Если метода __iadd__нет, то Python использует обычное сложение через __add__.
То есть если объект (например, список) поддерживает изменение на месте, он действительно меняется внутри. А если не поддерживает (например, int), то Python тихо «откатится» к обычному a = a + 1.
Разберём на примере списка:
a = [1, 2]
b = a
a = a + [3] # Создаётся новый список.
print(a) # [1, 2, 3]
print(b) # [1, 2]
Здесь a и b больше не указывают на один объект: a ссылается на новый список, а b остался со старым.
А теперь посмотрим на работу операции +=:
a = [1, 2]
b = a
a += [3] # Изменяем "на месте".
print(a) # [1, 2, 3]
print(b) # [1, 2, 3]
Теперь и a, и b изменились одновременно, потому что обе переменные указывают на один и тот же объект, который был модифицирован внутри.
Таким образом, для неизменяемых объектов вроде чисел, строк или кортежей разницы между a = a + ... и a += ... в поведении почти нет — оба варианта создают новый объект и переназначают ссылку.
Но для изменяемых объектов, например списков или словарей, ситуация иная: оператор += изменяет объект «на месте», тогда как a = a + … создаёт новый объект и переназначает имя. Именно поэтому в первом случае изменения будут видны всем переменным, которые указывают на один и тот же объект, а во втором — связь между ними разрывается.
Срезы и in-place операции: где создаётся новый объект, а где нет
Когда речь заходит о срезах в Python, новички часто запоминают только одно правило: «срез возвращает новый список». Это действительно так, если мы говорим о чтении среза. Но как только срез оказывается в левой части присваивания или используется вместе с оператором del, поведение меняется. Именно это различие между копированием и in-place изменением часто становится источником неожиданных багов.
Начнём с самого очевидного: чтение среза всегда возвращает новый список.
lst = [1, 2, 3, 4, 5]
part = lst[1:4]
print(part) # [2, 3, 4]
print(part is lst) # False
А вот если использовать срез в левой части присваивания, ситуация меняется — список модифицируется на месте:
lst = [1, 2, 3, 4]
lst[1:3] = [99]
print(lst) # [1, 99, 4]
Точно так же работает и удаление элементов с помощью del:
lst = [1, 2, 3, 4, 5]
del lst[1:4]
print(lst) # [1, 5]
Более того, через срез можно даже менять размер списка, вставляя на место одного элемента сразу несколько:
lst = [1, 2, 3, 4]
lst[1:2] = [20, 30, 40]
print(lst) # [1, 20, 30, 40, 3, 4]
Если у среза указан шаг, присваивание всё равно выполняется in-place, но количество элементов должно совпадать:
lst = [0, 0, 0, 0, 0, 0]
lst[::2] = [1, 2, 3]
print(lst) # [1, 0, 2, 0, 3, 0]
Таким образом, срезы списков в Python ведут себя двояко. Когда мы просто читаем их, мы всегда получаем новый список. Но если использовать срез в левой части выражения присваивания или вместе с del, Python меняет исходный объект напрямую. И именно это различие важно помнить: срез может быть как способом «снять копию», так и инструментом для in-place модификации списка.
Логические операторы, короткое вычисление и «небулевы» результаты
В Python логические операторы and, or, not работают шире, чем простая булева алгебра. Они не только приводят выражения к истине или лжи, но и возвращают один из операндов, сохраняя его тип. При этом действует короткое вычисление (short-circuit): если результат выражения можно определить сразу, оставшаяся часть даже не исполняется. Это делает код компактнее, но иногда приводит к неожиданным эффектам.
Вот пример когда при записи result = True or a() — никогда не будет вызвана функция a():
def a():
print("a() вызвана")
return 42
result = True or a()
# a() НЕ будет вызвана.
# result == True
Для or достаточно, что левый операнд truthy — значит, всё выражение уже истинно, дальнейшие вычисления не нужны. Поэтому a() даже не исполнится (никаких побочных эффектов), а result получит левый операнд — здесь это True.
А вот с and наоборот:
result = True and a()
# a() будет вызвана.
# result == 42
Здесь первый операнд truthy, значит, исход зависит от второго — его надо вычислить.
И для полноты:
result = False and a() # a() НЕ вызовется; result == False
result = False or a() # a() вызовется; result == 42
Другим важным моментом является тот факт, что or и and возвращают сам операнд, а не приведённый к bool. Это удобно, но иногда удивляет:
"hello" or 0 # -> "hello"
"" or "fallback" # -> "fallback" (пустая строка — falsy)
[1] and [] # -> [] (первый truthy, возвращаем второй)
0 and 123 # -> 0
Отсюда рождаются идиомы вида «значение по умолчанию»:
name = user_input or "Anonymous"
Но важно помнить про жизненно важный нюанс: 0, "", [], {} — это falsy. Если 0 — валидное значение, то такой приём сломает логику:
limit = 0
safe_limit = limit or 100 # Будет 100, хотя хотели 0.
В таких случаях используйте явную проверку на None:
limit = limit if limit is not None else 100
# или
limit = 100 if limit is None else limit
Иногда встречается старомодная запись «тернарника»:
result = cond and a or b
Но лучше пишите современно и безопасно с использованием тернарного оператора:
result = a if cond else b
Помните о приоритетах (not > and > or) и группируйте скобками, если есть риск неверного чтения:
# Плохо читается, легко ошибиться:
ok = is_ready or has_cache and not is_expired
# Яснее:
ok = is_ready or (has_cache and not is_expired)
Логические операторы в Python позволяют писать более выразительный и лаконичный код, но при этом они ведут себя не так, как в классической логике. Они могут вернуть не булево значение, а сам операнд, и останавливают вычисления там, где результат уже ясен.
Это открывает возможности для удобных идиом, вроде задания значений по умолчанию через or, но требует осторожности, особенно когда 0, пустые строки или списки считаются ложными, хотя по смыслу могут быть корректными значениями.
Чтобы избежать ошибок, стоит помнить о коротком вычислении, учитывать приоритет операторов и использовать явные проверки там, где неочевидно, как Python интерпретирует условие.
Пространства имён и области видимости: когда создаётся новое, а когда нет
Студенты и джуны часто путаются, где именно Python ищет переменные и в какой момент создаётся новое пространство имён. Отсюда появляются самые неожиданные баги.
Важный момент — функции всегда создают новое пространство имён.
Каждый вызов функции живёт в своей области видимости:
x = 10
def func():
x = 5
print("Внутри функции:", x)
func()
print("Снаружи:", x)
# Результат:
# Внутри функции: 5
# Снаружи: 10
Функция «видит» глобальные переменные, но если внутри определить одноимённую — это уже совсем другая переменная в локальном пространстве.
А вот условные блоки и циклы не создают новое пространство имён.
Это многих удивляет, особенно тех, кто пришёл из языков вроде Java или C++:
if True:
y = 42
print(y) # 42 — переменная доступна снаружи!
В Python if, for, while не создают нового пространства. Всё, что объявлено внутри этих конструкций, остаётся в той же области видимости.
А вот так выглядит очень популярный баг:
funcs = []
for i in range(3):
funcs.append(lambda: i)
for f in funcs:
print(f())
Многие ждут:
0
1
2
Но вывод будет:
2
2
2
Почему? Потому что цикл не создаёт новое пространство имён, и все лямбды-функции замкнулись на одну и ту же переменную i, которая после цикла равна 2.
Чтобы исправить — нужно «заморозить» значение через аргументы по умолчанию:
funcs = []
for i in range(3):
funcs.append(lambda x=i: x)
for f in funcs:
print(f())
Теперь вывод будет ожидаемым:
0
1
2
Но важно также отметить, что в Python 3 переменная цикла внутри list/dict/set-comprehension живёт в своём собственном локальном пространстве имён.
# List-comprehension — своё локальное пространство имён.
lst = [i for i in range(3)]
print(lst) # [0, 1, 2]
print(i) # NameError: name 'i' is not defined.
# Переменная i из comprehension не видна снаружи.
Итак, в Python функции всегда создают новое пространство имён, а вот условные блоки и циклы — нет.
Особенности чисел с плавающей точкой
Ещё один популярный вопрос на собеседованиях связан с арифметикой. Попробуйте догадаться, что выведет этот код:
result = 0.3 + 0.3 + 0.3
print(result == 0.9)
print(result)
Вроде бы ожидаете:
True
0.9
Но в реальности получаете:
False
0.8999999999999999
Дело не в Python, а в самой природе чисел с плавающей точкой в двоичной системе.
Некоторые дроби, которые в десятичной системе записываются «красиво», в двоичной не имеют конечного представления. Например, 0.3 в памяти хранится лишь с приближением, и при сложении ошибка накапливается.
Если важно сравнивать «человеческие» значения, то в базовом случае можно использовать округление:
result = round(0.3 + 0.3 + 0.3, 2)
print(result == 0.9) # True
Существуют и специальные функции для «приближённых» сравнений, например isclose:
import math
result = 0.3 + 0.3 + 0.3
print(math.isclose(result, 0.9)) # True
Для финансовых и других задач, где точность критична, есть специальный модуль Decimal:
from decimal import Decimal
result = Decimal("0.3") + Decimal("0.3") + Decimal("0.3")
print(result == Decimal("0.9")) # True
Таким образом, это не баг Python, а свойство всех языков, которые используют двоичную арифметику с плавающей точкой. Поэтому:
Никогда не сравнивайте float напрямую, если важна точность.
Для простых случаев используйте
roundилиmath.isclose.Для критически точных расчётов (например, деньги) — decimal.
Как реагировать на вопрос с подвохом
Будьте особенно внимательны к вопросам, которые кажутся слишком простыми. На собеседованиях такие вопросы редко бывают без подвоха. Отвечать на автомате не стоит: за очевидностью нередко скрывается сложность.
Сделайте небольшую паузу и подумайте вслух. Для интервьюера важнее увидеть ход ваших рассуждений, чем услышать быстрый ответ.
Полезно проверить себя по нескольким направлениям:
создаётся ли в задаче новый объект или изменяется существующий;
определяется ли результат заранее или только в момент выполнения;
сравниваются значения или сами объекты.
Чаще всего подвох кроется именно здесь.
Даже если вы не уверены в точном результате, стоит показать логику. Например, можно сказать: «Судя по принципам языка, должно быть так, но я бы перепроверил это в редакторе кода». Такой ответ демонстрирует зрелость и умение рассуждать — качества, которые ценятся гораздо выше, чем механическое знание всех нюансов.
Вместо заключения
Python любят за простоту, но именно эта простота часто обманчива: язык скрывает немало тонкостей, которые становятся настоящими ловушками на собеседованиях. Каждая из этих тонкостей показывает, что в Python важно не только «писать так, чтобы работало», но и понимать, почему под капотом всё работает именно так.
На техническом интервью от вас, как от начинающего разработчика, обычно ждут понимания логики языка, умения увидеть подвох в вопросе и навыка раскладывать задачу по шагам. Хорошая новость в том, что эти «классические ловушки» можно легко разобрать заранее, а значит — прийти на интервью уверенным и без страха.
Если разобранные примеры для вас уже очевидны — поздравляю, вы на полшага ближе к заветному офферу. А если где-то пришлось удивиться — это тоже здорово: значит, вы только что закрыли ещё одну дыру в знаниях.
Экспериментируйте с кодом и не бойтесь вопросов на собеседовании: они всего лишь инструмент, чтобы проверить, как вы думаете.
Комментарии (38)

arheops
04.09.2025 09:52Мне одному кажеться, что если такое задают на собеседовании - то вам врядли надо в такую компанию?

bartenev_ev Автор
04.09.2025 09:52На собеседованиях действительно можно услышать очень разные вопросы, и многое зависит и от роли, и от контекста, и от конкретного интервьюера (в том числе его настроения). Собеседование - это всегда субъективная история. Но есть ряд вещей в Python, знание и понимание которых показывает уровень зрелости разработчика — и именно про такие моменты статья.
Я и сам неоднократно сталкивался с этими вопросами, когда проходил интервью, и сейчас иногда задаю их кандидатам - живые примеры у меня на канале. Конечно, не всё подряд и не всем, а исходя из позиции и задач.

arheops
04.09.2025 09:52Конечно, кто-то задаст вопрос о += операторе(который кстати описан неверно в статье, никакого фолбека не происходит на простых типах, просто передается их адрес).
Вопрос в том, что вам врядли стоит работать в такой компании.
Из всего описаного только факт, что лист является обьектом и передается по ссылке - реально необходим.

bartenev_ev Автор
04.09.2025 09:52Работать или нет в такой компании где задают подобные вопросы на собеседовании - это ваш выбор, и я ничего не имею против него. Но если у вас есть конкретные предложения по улучшению описания оператора +=, то будет здорово их увидеть.

netch80
04.09.2025 09:52Нет, с этими ситуациями легко нарваться вживую, и потому опыт на них таки проверяется.
Со значениями по умолчанию у меня был случай, но не как аргумент функции, а как поле объекта: я определил его в классе. На этом состоянии релизеры отфоркнули версию. Я потом по ходу заметил и исправил, просто за компанию с другими проблемами, а потом сообразил... пришлось пинать службу поддержки: типа, в 18.0 эту функциональность использовать нельзя, срочно готовьте хотфикс... А мог и не заметить, тогда был бы вопрос "а почему система нормально живёт до первого трансфера, а потом ей сносит крышу".
И с переменными в лямбде нарывался, только мне тогда посоветовали синтаксис: funcs.append(lambda i=i: i) (автор тут разделяет x и i, что таки излишне).
Ну а сравнения в плавучке это вечная тема. Причём он только равенство вспомнил, с неравенствами не лучше:
>>> 0.1 + 0.2 <= 0.3 False >>> 10.4 + 20.8 > 31.2 True >>> 0.8 - 0.1 > 0.7 True
Andy_U
04.09.2025 09:52Ну ведь не рекомендуется так lambda использовать: См. PEP8 E731 do not assign a lambda expression, use a def. Тут конечно, их массив засовывают, а не скаляру прсваивают, но разве это большая разница?

bartenev_ev Автор
04.09.2025 09:52В данном случае суть проблемы не в том, что используется lambda — её хоть сразу перепиши на именованную функцию, особенность позднего связывания от этого никуда не денется. Чтобы избежать эффекта, нужно явно “фиксировать” значение переменной в момент создания функции, например через аргументы по умолчанию.

Andy_U
04.09.2025 09:52Если речь идет о примере
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f())то он идиотский, потому что тут лямбды вообще не нужны:
funcs = [] for i in range(3): funcs.append(i) for f in funcs: print(f)А если нужно зафиксировать один аргумент из многих, то я или бы классом воспользовался с __call__() или functools.partial

bartenev_ev Автор
04.09.2025 09:52Собственно, в этом и заключается суть — речь идёт о поведении именно функций, и неважно, как они определены: через lambda или через def. Особенность позднего связывания одинаково проявляется в обоих случаях.
Пример с funcs.append(lambda: i) действительно выглядит искусственным — тут нет никакой необходимости в функциях, можно хранить сами значения. Но как только задача сводится к тому, чтобы зафиксировать часть аргументов (а остальные подставлять при вызове), мы вынуждены оперировать именно функциями. И тогда уже приходится либо использовать аргументы по умолчанию, либо functools.partial, либо ещё что-то.

Andy_U
04.09.2025 09:52И тогда уже приходится либо использовать аргументы по умолчанию
Так опять или не нужна (лямбда) функция, если аргумент один, или никак напрямую если их больше (см.пример ниже с оберткой функии в функцию).

netch80
04.09.2025 09:52В моём практическом случае нужна была именно функция, потому что какая функция вызывается - тоже было важно.

Andy_U
04.09.2025 09:52Увы, в упрощенных примерах это не получается продемонстрировать. А как только оказывается, что функция с двумя аргументами уже есть, а нужно зафиксировать первый, то фокус с аргументом по умолчанию перестает работать.

netch80
04.09.2025 09:52Можете привести пример именно в вашем стиле? (Хочу избежать ещё 1-2 циклов доводки/шлифовки.)

Andy_U
04.09.2025 09:52def f(x: int, y: int) -> int: """ Assume that it is an external function and we can't modify its signature. """ return x + 2 * y class Wrapper: """ Convert f(x, y) to the function with the single argument y. """ def __init__(self, x: int): """ Save first argument of function f(x, y). """ self._x = x def __call__(self, y: int) -> int: """ Return f(x, y) using previously saved its first argument. """ return f(self._x, y) if __name__ == '__main__': ww = [Wrapper(x) for x in [111, 222, 333, 444, 555, 666]] for y in [-1, 0, 1]: print([w(y) for w in ww])Ну, типа этого.

netch80
04.09.2025 09:52Ну, совсем не проблема. Единственно что - порядок аргументов в случае лямбды неудобный (может, я ещё какого трюка не знаю), но в случае функции уже проблем нет:
adders = [] for x in (111, 222, 333, 444, 555, 666): ## Вот тут если писать lambda x=x, y - ругается: ## SyntaxError: non-default argument follows default argument adders.append(lambda y, x=x: x+2*y) for y in [-1, 0, 1]: print([w(y) for w in adders]) print('-'*64) adders = [] def make_adder(x): def adder(y): return x + 2*y return adder for x in (111, 222, 333, 444, 555, 666): adders.append(make_adder(x)) for y in [-1, 0, 1]: print([w(y) for w in adders])Можно было бы ещё с functools.partial построить, но не хочется влезать. Вариант с def внутри функции-создателя (которая таким образом создаёт блок) более чем достаточен.
Вероятно, ваш вариант выгоднее по времени, тут уже надо бенчмаркать, и не на голом коде, а в чём-то более-менее реальном. Но мне сейчас неоткуда взять такое реальное. И для большинства читателей, я думаю, вложенная функция понятнее.

Andy_U
04.09.2025 09:52Ну, совсем не проблема
Давайте заменим ваши x+2*y на f(x, y) и попробуем поотлаживаться, ставя breakpoint на строчки с print. В моем варианте и втором вашем - мы легко доберемся до тела функции, а вот в случае с lambda, по крайней мере, PyCharm туда не залезает.
А теперь про расширяемость. Мне легко узнать, какая величина x спрятана в объекте класса w = Wrapper(...). Это w._x (ну, да лучше property имплементировать). И поменять я ее могу w._x = 2*w._x, Ваш ход?

netch80
04.09.2025 09:52а вот в случае с lambda, по крайней мере, PyCharm туда не залезает.
Это явная недоработка PyCharm, но да, может быть проблемой. У меня самые толстые проекты, где реально нужно что-то отлаживать, это телефония, там интерактивным отладчиком не залезешь - надо только логать. Поэтому мне такая разница не очень важна. Имя функции в стектрейсе, да, регулярно важно.
И поменять я ее могу w._x = 2*w._x, Ваш ход?
func.__closure__. Неизменяемо, да (ну если без особых хаков), но и не было нужно. Опять таки специфика, вот ещё ни разу за 20+ лет с питоном не делал такое;
Заранее признаю, что мой домен сильно перекошен.Но вот читабельность мы сравнивали - и явный def в среднем лучше всего, а вот делать класс с
__call__заметно проигрывало, по общему мнению команды.

Andy_U
04.09.2025 09:52и явный def в среднем лучше всего
Вот даже внутри кода, а не снаружи от тела текущей функции и без Type Annotations? Вдобавок, я просто очень не люблю вложенные функции и использование там "внешних" переменных.
Конечно, в каждой избушке свои погремушки..

netch80
04.09.2025 09:52Вот даже внутри кода, а не снаружи от тела текущей функции и без Type Annotations?
Ну где написать def make_adder из моего примера - вопрос стиля и чуть производительности, может, тут будет оптимизация, если увидят, что сама эта функция "чистая" и окружение не захватывает. Можно и снаружи. Так и класс описывать можно и внутри, и снаружи. Если снаружи - к нему надо идти, его читать, ещё и в логике отдельно инициализатор, а отдельно
__call__, а если def, то всё это более связно.Вдобавок, я просто очень не люблю вложенные функции и использование там "внешних" переменных.
Вот это точно вкусовщина. Я понимаю, но именно вариант описать где используется и предельно прямо в коде - по мне (и по команде), выигрывает в среднем. Дао Питона, как известно, "явное лучше неявного", даже когда от этого страдает производительность.

arheops
04.09.2025 09:52Особенности позднего связывания используются в куче библиотечных и ваших личных декораторов.
Такшо вы так или иначе о них узнаете.
Вопрос - нафига об этом спрашивать? узнать то узнаете, но не факт, что распознаете в этом примере или сможете сформулировать.

netch80
04.09.2025 09:52Вот тестик (давно валялся, вытащил к теме):
def moo(x): return repr(x) def gen_moof2(i): def moof2(): return moo(i) return moof2 if __name__ == "__main__": clos_l1 = [] clos_l2 = [] clos_f1 = [] clos_f2 = [] for i in range(4): p_i = str(i) clos_l1.append(lambda: moo(p_i)) clos_l2.append(lambda p_i=p_i: moo(p_i)) def moof1(): return moo(p_i) clos_f1.append(moof1) clos_f2.append(gen_moof2(p_i)) print(*[x() for x in clos_l1]) print(*[x() for x in clos_l2]) print(*[x() for x in clos_f1]) print(*[x() for x in clos_f2])Выводит, Python 3.10.12:
'3' '3' '3' '3' '0' '1' '2' '3' '3' '3' '3' '3' '0' '1' '2' '3'То есть от того, что мы просто завернули в функцию (локальный def) вместо лямбды, ничего не поменялось: копия значения для конкретного замыкания - не создалась.
Чтобы её создать, надо явно скопировать переменную. А чтобы эта копия была действительно уникальной - нужно своё пространство, что достигается, как сказано в статье, или аргументами лямбды, или отдельной функцией - генератором этой самой лямбды.
А то, что у def есть имя, а у lambda - нет, вопрос не связанный. Кстати, тут переопределение имени функции может быть нужно даже при def, если нам нужен какой-то уникальный идентификатор для трейса.

Andy_U
04.09.2025 09:52С моей точки зрения так проще и понятнее:
class Moo: def __init__(self, i): self.i = i def __call__(self): return repr(self.i) if __name__ == '__main__': clos_class = [] for i in range(4): clos_class.append(Moo(str(i))) print(*[x() for x in clos_class])
netch80
04.09.2025 09:52Ну, у меня была задача сравнить собственно функцию с функцией, в зависимости от метода создания. Довернуть ещё и объект с возможностью вызова как функция это уже следующий уровень наворота, полезный где-то на практике, но тут затрудняющий понимание примера.
Разумеется, он также показывает, как создание нового контекста (а тут это объект) помогает отвязаться от одного контекста на всех.

re4t1rt998
04.09.2025 09:52В конце про числа с плавающей точкой и округление, можно добавить, что округление работает в сторону ближайшего чётного числа, если используется round()
Пример:
round(2.5) # 2
round(3.5) # 4
Тоже не очевидный результат получается, особенно когда впервые с таким встречаешься)

bartenev_ev Автор
04.09.2025 09:52Да, вы абсолютно правы!
В Python функция round() по умолчанию использует стратегию округления, называемую “round half to even” (или банковское округление).

netch80
04.09.2025 09:52Это если вы встроенный float округляете:) А вот эффект следующего уровня:
>>> from decimal import * >>> getcontext().rounding = ROUND_DOWN >>> round(Decimal("1.5"), 0) Decimal('1') >>> round(Decimal("2.5"), 0) Decimal('2') >>> getcontext().rounding = ROUND_UP >>> round(Decimal("1.5"), 0) Decimal('2') >>> round(Decimal("2.5"), 0) Decimal('3') >>> getcontext().rounding = ROUND_05UP >>> round(Decimal("3.5"), 0) Decimal('3') >>> round(Decimal("4.5"), 0) Decimal('4') >>> round(Decimal("5.5"), 0) Decimal('6') >>> round(Decimal("6.5"), 0) Decimal('6')Функция стандартная round(), но зависит от контекста, определённого где-то там далеко, потому что реально у объекта она вызывает
__round__(), а он уже знает, куда смотреть. А ещё round(x) и round(x, 0) тут работают по-разному.

Vplusplus
04.09.2025 09:52Пример со списком и a += [3] высосан из пальца, вообще удивлен его наличию а языке, учитывая принцип питона "явное лучше чем неявное". По-нормальному надо писать a.append(3) и тогда всегда будет однозначность понимания, а за такое "заумство" в реальном проекте наругал бы!

bartenev_ev Автор
04.09.2025 09:52На самом деле a += [3] эквивалентно не append, а extend.
То есть правильнее сравнивать:
a += [3] # [1, 2, 3] a.extend([3]) # [1, 2, 3]А append() в этом случае даёт другой результат:
a.append([3]) # [1, 2, [3]]Пример с += был больше как иллюстрация того, что оператор перегружен и работает со списками, а не как рекомендация к применению. В реальном проекте я бы тоже наругал за это.

Gadd
04.09.2025 09:52принцип питона "явное лучше чем неявное"
Посмотрите на досуге исходники модуля
this, того самого Дзэна Питона ;-)
tenzink
Хороший способ отстрелить себе ногу. Интересно, зачем так было сделано
bartenev_ev Автор
Это было сделано сознательно: модель остаётся простой и предсказуемой, переменные удобно использовать в интерактивной работе (например, в REPL или Jupyter), а сама идея идёт ещё от языков, на которые ориентировался Python, где также не было блочной видимости.
Megadeth77
Мне кажется стоит упомянуть, что есть просто два "пространства имен" а именно глобальное, доступное через globals() и локальное, соответственно locals() и это просто словари, все имена питон относит или к одному или ко второму, присваивание или создает ключ в словаре или модифицирует. Соответственно пространство поиска засады на собеседовании сводится к тому чтобы определить к какому из двух пространств относится имя. С замыканиями чуть хитрее, кстати в примере про лямбды замыкание не создается, поэтому фраза "замкнулись на локальную переменную" не совсем корректна.
Чтобы создать замыкание надо использовать функции высшего порядка, и у полученного объекта прям будет атрибут с переменной, которую возвращаемая функция захватила. А в Вашем примере лямбды просто работают с глобальной переменной.
bartenev_ev Автор
Спасибо за комментарий. Позволю себе немного уточнить, что в Python поиск имён идёт по правилу LEGB: сначала в локальной области, потом во внешних функциях (enclosing), затем в глобальной и, наконец, в builtins. Поэтому кроме globals и locals есть ещё один важный уровень — enclosing. При этом globals() и locals() действительно возвращают словари, но это скорее интерфейс: в функциях локальные переменные хранятся иначе для скорости, и изменения в locals() не затрагивают реальные значения. Что касается лямбды, замыкание создаётся только если она действительно использует переменные из внешней области; если таких переменных нет, то замыкания тоже нет.
Megadeth77
Дада, точно, ключевое слово nonlocal
Тут кстати тоже есть где подзарыть кандидата
VADemon
Главное, чтобы потом не рыли канавы в продакшене, где, благодаря одноименным переменным и контекста в пару экранов кода, придется потратить несколько часов (подсчитываем денежки) на поиск и устранение бага. А то ж "мы же правила знаем, никогда такого не допустим!" Ага.
Megadeth77
Мнда, занятно с этими замыканиями в питоне всё-таки
В первом случае замыкания нет, а вот во втором замыкание есть но на одну и ту же переменную, и то и то работает не так как ожидается интуитивно
adante
Мне кажется норм по здравому смыслу
Функция это по сути законченная программа, она имеет смысл сама по себе и поэтому имеет свою область видимости.
Условия и циклы это части программы, они сами по себе бесполезны и поэтому пользуются переменными большой программы.