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:

  1. Вычисляется a + 1 → создаётся новый объект.

  2. Имя 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 важно не только «писать так, чтобы работало», но и понимать, почему под капотом всё работает именно так.

На техническом интервью от вас, как от начинающего разработчика, обычно ждут понимания логики языка, умения увидеть подвох в вопросе и навыка раскладывать задачу по шагам. Хорошая новость в том, что эти «классические ловушки» можно легко разобрать заранее, а значит — прийти на интервью уверенным и без страха.

Если разобранные примеры для вас уже очевидны — поздравляю, вы на полшага ближе к заветному офферу. А если где-то пришлось удивиться — это тоже здорово: значит, вы только что закрыли ещё одну дыру в знаниях.

Экспериментируйте с кодом и не бойтесь вопросов на собеседовании: они всего лишь инструмент, чтобы проверить, как вы думаете.

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