Примеры идут от простого к сложному: сначала — что вообще значит «переменная» в Python и чем ссылка отличается от копии, затем работа с вложенными списками и del, затем поверхностное и глубокое копирование, и в конце — три частые ловушки, где неверное понимание ссылок ломает код. Весь вывод в комментариях получен запуском кода, а не набран по памяти; значения id() у вас на экране будут другими — это нормально, каждый запуск процесса получает свои адреса.
Пример 1. Переменная — это ссылка на объект
Прежде чем говорить о копиях, нужно увидеть, что переменная в Python не «коробка со значением», а имя, указывающее на объект в памяти (в куче). Функция id() возвращает идентификатор объекта — в CPython это фактически адрес в памяти на время жизни объекта.
numbers = [1, 2, 3]
print(numbers) # [1, 2, 3]
print(id(numbers)) # 2691946361536 (у вас будет другое число)
print(type(numbers)) # <class 'list'>
Что происходит: имя numbers появляется в пространстве имён (namespace) и связывается со списком, который живёт в куче. Число из id() — это адрес именно этого объекта, и оно не меняется, пока объект существует.
Пример 2. Присваивание списка — общая ссылка, а не копия
Раз переменная — это ссылка, присваивание same_items = items не создаёт новый список. Оно создаёт второе имя для того же самого объекта. Изменение через любое из имён видно через оба.
items = ["Bread", "Milk"]
same_items = items
same_items.append("Eggs")
print(items) # ['Bread', 'Milk', 'Eggs']
print(same_items) # ['Bread', 'Milk', 'Eggs']
print(id(items) == id(same_items)) # True
Почему так: append() изменяет список на месте (in-place) — списки изменяемы (mutable). Поскольку items и same_items — два имени одного и того же объекта, любое изменение объекта видно через обе ссылки.
Пример 3. is против ==: тождество и равенство
== сравнивает содержимое объектов, is сравнивает их идентичность — тот же ли это объект в памяти. Для списков это разные вопросы, и путать их — источник ошибок.
a = [1, 2, 3]
b = [1, 2, 3]
c = a
print(a == b) # True — содержимое одинаковое
print(a is b) # False — это два разных объекта
print(a == c) # True
print(a is c) # True — c и a ссылаются на один и тот же объект
Как проверить себя: если нужно узнать «одинаковые ли значения» — используйте ==. Если нужно узнать «это буквально один и тот же объект» (например, чтобы понять, повлияет ли мутация одной переменной на другую) — используйте is.
Пример 4. Неизменяемые объекты ведут себя иначе
Строки, числа и кортежи нельзя изменить на месте. Операция, похожая на изменение, на самом деле создаёт новый объект и переподключает к нему имя — старый объект остаётся как был. Это объясняет, почему списки и строки выглядят «одинаково по присваиванию», но ведут себя по-разному при изменении.
text1 = "hello"
text2 = text1
print(id(text1) == id(text2)) # True — пока объект один
text2 += " world"
print(text1) # hello
print(text2) # hello world
print(id(text1) == id(text2)) # False — text2 указывает на новый объект
Что происходит: text2 += " world" для строки — это не изменение на месте, а text2 = text2 + " world": создаётся новый объект-строка, и text2 переподключается к нему. text1 по-прежнему указывает на исходную строку. Со списком (numbers += [4]) результат был бы другим — список расширяется на месте, id не меняется.
Пример 5. Вложенные списки: несколько уровней индексации
Список списков — обычный способ смоделировать таблицу: внешний список хранит строки, внутренние — значения строки. Каждый дополнительный уровень вложенности требует ещё одного индекса.
matrix = [[1, 2], [3, 4], [5, 6]]
print(matrix[0][1]) # 2
for row_index, row in enumerate(matrix):
for col_index, value in enumerate(row):
matrix[row_index][col_index] = value + 1
print(matrix) # [[2, 3], [4, 5], [6, 7]]
deep_list = [[1, 2, 3], [4, 5], 6, [7, [8, [9], 10]]]
print(deep_list[3][1][2]) # 10
print(deep_list[3][1][1][0]) # 9
Что происходит: matrix[row_index][col_index] = ... изменяет элемент вложенного списка через двойную индексацию. Когда вложенность становится глубже (как в deep_list), выражение вида deep_list[3][1][1][0] технически работает, но промежуточная переменная почти всегда читается лучше.
Пример 6. del: удаляет имя, элемент или срез — но не возвращает значение
del — оператор, а не функция: он удаляет привязку имени в пространстве имён, элемент по индексу или срез элементов. Он ничего не возвращает, поэтому для получения удалённого значения нужен pop().
numbers = [10, 20, 30, 40, 50]
del numbers[2] # удаляем элемент с индексом 2 (30)
print(numbers) # [10, 20, 40, 50]
del numbers[1:3] # удаляем срез — элементы 20 и 40
print(numbers) # [10, 50]
old_numbers = [1, 2, 3]
new_numbers = old_numbers
del old_numbers # удалили только имя old_numbers
print(new_numbers) # [1, 2, 3] — объект жив, на него всё ещё ссылается new_numbers
try:
print(old_numbers)
except NameError as error:
print("NameError:", error) # NameError: name 'old_numbers' is not defined
Почему так: объект в куче удаляется сборщиком мусора только тогда, когда на него не остаётся ни одной ссылки. del old_numbers убрал ровно одну ссылку из двух — список остался жив благодаря new_numbers.
Пример 7. Поверхностная копия: list(), .copy(), срез [:]
Три способа сделать «настоящую» копию верхнего уровня списка. Но если элементы сами являются списками, поверхностная копия их не копирует — вложенные списки остаются общими с оригиналом.
original = [[1, 2], [3, 4]]
copy_slice = original[:]
copy_method = original.copy()
copy_list_call = list(original)
original.append([5, 6])
print(copy_slice) # [[1, 2], [3, 4]] — новую строку копии не увидели
print(copy_method) # [[1, 2], [3, 4]]
print(copy_list_call) # [[1, 2], [3, 4]]
original[0][0] = 0
print(original) # [[0, 2], [3, 4], [5, 6]]
print(copy_slice) # [[0, 2], [3, 4]] — вложенный список общий с оригиналом!
print(copy_method) # [[0, 2], [3, 4]]
print(copy_list_call) # [[0, 2], [3, 4]]
Что происходит: append([5, 6]) на original копий не касается — это подтверждает, что верхний уровень действительно скопирован. А вот original[0][0] = 0 меняет вложенный список [1, 2], на который по-прежнему ссылаются все три копии — значит, вложенный уровень скопирован не был.
Пример 8. Глубокая копия: copy.deepcopy()
copy.deepcopy() рекурсивно копирует все вложенные уровни, поэтому изменения в копии никогда не затрагивают оригинал. Практический пример: отфильтровать значения в каждой группе, не трогая исходные данные.
import copy
nested_list = [[10, 15, 20], [5, 25, 30], [35, 40, 80]]
filtered = copy.deepcopy(nested_list)
for row in filtered:
average = sum(row) / len(row)
row[:] = [value for value in row if value >= average]
print("Исходный список:", nested_list)
# Исходный список: [[10, 15, 20], [5, 25, 30], [35, 40, 80]]
print("Отфильтрованная копия:", filtered)
# Отфильтрованная копия: [[15, 20], [25, 30], [80]]
Что происходит: filtered — полностью независимая структура, поэтому row[:] = [...] внутри цикла меняет только копию. nested_list остаётся тем, чем был. Это и есть главное отличие от примера 7.
Пример 9. Ловушка [[0] * n] * n: общая ссылка на подсписки
Умножение списка на число не копирует элементы — оно повторяет ссылку на один и тот же объект n раз. Если элемент сам список, все «строки» окажутся одним и тем же списком.
board = [[0] * 3] * 3
print(board) # [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
board[0][0] = 1
print(board) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]] — изменилась КАЖДАЯ строка
board_fixed = [[0] * 3 for _ in range(3)]
board_fixed[0][0] = 1
print(board_fixed) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]] — теперь строки разные объекты
[x] * 3 — это то же самое, что [x, x, x]: три ссылки на один и тот же объект x. Если x — список [0, 0, 0], то все три «строки» доски — одна и та же строка под тремя именами. List comprehension [[0] * 3 for _ in range(3)] на каждой итерации создаёт новый список, поэтому строки становятся независимыми.
Пример 10. Список как аргумент функции: мутация видна снаружи
Python передаёт аргументы по ссылке на объект. Если функция мутирует список (например, через append()), изменение видно и вызывающему коду. Но если функция переприсваивает параметр, снаружи ничего не меняется.
def add_bonus_item(cart):
cart.append("gift")
return cart
my_cart = ["book", "pen"]
result = add_bonus_item(my_cart)
print(my_cart) # ['book', 'pen', 'gift']
print(result) # ['book', 'pen', 'gift']
print(id(my_cart) == id(result)) # True — тот же объект
def replace_cart(cart):
cart = ["new", "cart"] # это создаёт НОВОЕ локальное имя cart
return cart
cart2 = ["book"]
result2 = replace_cart(cart2)
print(cart2) # ['book'] — исходный объект не тронут
print(result2) # ['new', 'cart']
Что происходит: add_bonus_item получает ту же ссылку, что и my_cart, и append() меняет объект на месте — изменение видно снаружи. replace_cart, наоборот, присваивает параметру cart новый список — это лишь переподключает локальное имя внутри функции, на cart2 снаружи это не влияет.
Пример 11. Ловушка изменяемого аргумента по умолчанию
Значение по умолчанию для параметра функции вычисляется один раз — при определении функции, а не при каждом вызове. Если это изменяемый объект (список, словарь), он переиспользуется между вызовами и накапливает состояние.
def add_item_buggy(item, basket=[]):
basket.append(item)
return basket
print(add_item_buggy("apple")) # ['apple']
print(add_item_buggy("banana")) # ['apple', 'banana'] — старая корзина никуда не делась!
def add_item_fixed(item, basket=None):
if basket is None:
basket = []
basket.append(item)
return basket
print(add_item_fixed("apple")) # ['apple']
print(add_item_fixed("banana")) # ['banana'] — каждый вызов начинает с чистого списка
[] в сигнатуре add_item_buggy создаётся ровно один раз, в момент выполнения def, и живёт всё время существования функции — все вызовы без явного basket получают одну и ту же ссылку. Стандартное решение — basket=None и создание нового списка внутри тела функции при каждом вызове.
Пример 12. Изменение списка во время обхода: границы применимости
Удаление элементов при проходе слева направо — частая ошибка: индексы сдвигаются при каждом del, и цикл либо пропускает значения, либо падает с ошибкой. Обход с конца устраняет проблему, потому что уже обработанные индексы не сдвигаются.
nums_forward = [2, 5, 1, 8, 3]
try:
for index in range(len(nums_forward)):
if nums_forward[index] < 4:
del nums_forward[index]
print(nums_forward)
except IndexError as error:
print("IndexError:", error, "| список в момент сбоя:", nums_forward)
# IndexError: list index out of range | список в момент сбоя: [5, 8]
nums_backward = [2, 5, 1, 8, 3]
for index in range(len(nums_backward) - 1, -1, -1):
if nums_backward[index] < 4:
del nums_backward[index]
print(nums_backward) # [5, 8] — корректный результат
Что происходит: range(len(nums_forward)) вычисляется один раз для длины 5, но список уменьшается при каждом del — к концу цикла запрошенный индекс выходит за границы, и Python поднимает IndexError. При обходе с конца (range(len(...) - 1, -1, -1)) уже пройденные позиции с большими индексами не влияют на индексы, которые ещё предстоит проверить.
Тема ссылок и памяти не заканчивается на списках: те же правила общей ссылки и поверхностного/глубокого копирования применимы к словарям и множествам (урок 26 — методы списков и урок 18 — списки дают базу для этой темы). Кортежи (урок 24) неизменяемы и потому не подвержены ловушке общей ссылки на верхнем уровне — но кортеж, содержащий список, всё ещё может измениться изнутри. Ловушка изменяемого аргумента по умолчанию из примера 11 напрямую связана с областью видимости функций (урок 38), а list comprehension из примера 9 подробнее разбирается в уроке 30.
Что делать дальше
Разберите файлы репозитория — там те же идеи в исходном виде из лекции, затем переходите к заданиям. Если что-то из примеров не запустилось — сначала загляните в типичные ошибки.