💻 Примеры кода

⚡ Ссылка, копия, память — за один экран

numbers = [1, 2, 3]
alias = numbers        # НЕ копия — то же имя для объекта
alias.append(4)
print(numbers)          # [1, 2, 3, 4]
print(id(numbers) == id(alias))  # True
safe_copy = numbers.copy()   # новый список
safe_copy.append(5)
print(numbers)          # [1, 2, 3, 4] — оригинал не тронут
КонструкцияЧто делаетВложенные списки
b = aалиас: обе переменные — одна ссылка на один объектобщие, это тот же список
a.copy(), list(a), a[:]поверхностная копия: новый внешний списокостаются общими с оригиналом
copy.deepcopy(a)глубокая копия: рекурсивно копирует все уровнинезависимые, свои у каждой копии
a is b / a == bтот же объект? / одинаковое содержимое?—
del a[i]удаляет элемент/срез/имя, значения не возвращает—
Топ-3 ошибки: copied = original вместо .copy() · [[0]*n]*n — общая ссылка на все строки · изменяемый default-аргумент ([]) сохраняется между вызовами.

Примеры идут от простого к сложному: сначала — что вообще значит «переменная» в 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 и создание нового списка внутри тела функции при каждом вызове.
⚠️ Проверить по документации: материалы урока разбирают ссылки и копирование на уровне переменных, а этот пример показывает тот же механизм на уровне аргументов функции — сверьтесь с разделом Function definitions документации Python.

Пример 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.

Что делать дальше

Разберите файлы репозитория — там те же идеи в исходном виде из лекции, затем переходите к заданиям. Если что-то из примеров не запустилось — сначала загляните в типичные ошибки.