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 важно не только «писать так, чтобы работало», но и понимать, почему под капотом всё работает именно так.
На техническом интервью от вас, как от начинающего разработчика, обычно ждут понимания логики языка, умения увидеть подвох в вопросе и навыка раскладывать задачу по шагам. Хорошая новость в том, что эти «классические ловушки» можно легко разобрать заранее, а значит — прийти на интервью уверенным и без страха.
Если разобранные примеры для вас уже очевидны — поздравляю, вы на полшага ближе к заветному офферу. А если где-то пришлось удивиться — это тоже здорово: значит, вы только что закрыли ещё одну дыру в знаниях.
Экспериментируйте с кодом и не бойтесь вопросов на собеседовании: они всего лишь инструмент, чтобы проверить, как вы думаете.