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

⚡ Шпаргалка: ссылки, copy и comprehension

numbers = [1, 2, 3]
alias = numbers          # ссылка на тот же список
copy_list = numbers[:]   # новый список (поверхностная копия)
alias.append(4)
copy_list.append(99)
print(numbers)      # [1, 2, 3, 4]  — alias меняет оригинал
print(copy_list)    # [1, 2, 3, 99] — copy_list независим
КонструкцияЧто делаетНа что обратить внимание
b = aновое имя для того же объектаid(a) == id(b)
a.copy() / a[:]поверхностная копиявложенные списки остаются общими
copy.deepcopy(a)независимая копия всех уровнеймедленнее, зато безопасно
[expr for x in it if cond]список по правилу с фильтромисходный итерируемый объект не меняется
zip(a, b)параллельный обход по позициямостанавливается на самой короткой
Топ-3 ошибки: путать ссылку и копию; ожидать порядок там, где его нет; усложнять выражение, когда обычный цикл читается лучше.

Этот урок подводит итог двум темам сессии: уроку 28 «Списки. Работа с памятью» (примеры 1–5) и уроку 30 «List comprehension. Стек и очередь» (примеры 6–10). Примеры 11–12 — практические задачи, которые связывают обе темы вместе. Порядок — от простого к сложному, весь вывод в комментариях получен реальным запуском кода.

Пример 1. Ссылка на объект и id()

Присваивание списка не копирует его: обе переменные указывают на один и тот же объект в памяти. Это база, без которой непонятно, почему меняется «не тот» список.

numbers = [1, 2, 3]
alias = numbers
alias.append(4)
print(numbers)                    # [1, 2, 3, 4]
print(alias)                      # [1, 2, 3, 4]
print(id(numbers) == id(alias))   # True

Что происходит: alias = numbers не создаёт новый список, а даёт второе имя тому же объекту. Изменение через alias.append(4) видно и через numbers, потому что список — изменяемый тип, и оба имени ссылаются на одну и ту же область памяти.

Пример 2. Неизменяемые объекты ведут себя иначе

Строки, числа и кортежи изменить «на месте» нельзя. Операция, похожая на изменение, на самом деле создаёт новый объект.

text1 = "hello"
text2 = text1
text2 += " world"
print(text1)                      # hello
print(text2)                      # hello world
print(id(text1) == id(text2))     # False

Почему так: text2 += " world" для строки — это не изменение существующего объекта, а создание нового и привязка имени text2 к нему. text1 продолжает указывать на старый объект. Сравните с примером 1: там был один объект на два имени, здесь — два разных объекта.

Пример 3. Вложенные списки: чтение и изменение через несколько индексов

Таблица данных часто представлена списком списков. Каждый уровень вложенности — ещё один индекс; при изменении во время обхода enumerate() даёт сразу индекс и значение.

matrix = [[1, 2], [3, 4], [5, 6]]
print(matrix[1][0])   # 3

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]]

Что происходит: matrix[1][0] сначала берёт вторую строку ([3, 4]), затем первый элемент в ней. В цикле мы не создаём новый список, а меняем каждый элемент на месте по двум индексам сразу — так же, как в примере 1 меняли список через alias.

Пример 4. del: элемент, срез и имя

del — не метод списка, а оператор языка. Он умеет удалять элемент по индексу, диапазон элементов срезом и просто имя переменной — и это три разных действия.

numbers = [10, 20, 30, 40, 50]
del numbers[1]
print(numbers)        # [10, 30, 40, 50]

del numbers[1:3]
print(numbers)        # [10, 50]

old_numbers = [1, 2, 3]
new_numbers = old_numbers
del old_numbers
print(new_numbers)    # [1, 2, 3] — объект жив, пока есть другая ссылка

Что происходит: del numbers[1] убирает один элемент и сдвигает индексы; del numbers[1:3] убирает целый диапазон. del old_numbers вообще не трогает список — он удаляет только имя old_numbers. Список остаётся в памяти, потому что new_numbers всё ещё на него ссылается (как в примере 1). В отличие от pop(), del ничего не возвращает.

Пример 5. Поверхностная и глубокая копия

Раз присваивание не копирует список (пример 1), нужен явный способ получить независимую копию. Но и «копия» бывает разной глубины.

import copy

original = [[1, 2], [3, 4]]
shallow = original.copy()
deep = copy.deepcopy(original)

shallow[0][0] = 99
deep[1][0] = 0

print(original)   # [[99, 2], [3, 4]]
print(shallow)     # [[99, 2], [3, 4]]
print(deep)        # [[1, 2], [0, 4]]

Почему так: original.copy() создаёт новый внешний список, но его элементы — те же самые вложенные списки, что и в original. Поэтому shallow[0][0] = 99 меняет и original. copy.deepcopy() рекурсивно копирует все уровни, поэтому изменение deep оригинал не затрагивает. Правило: если элементы изменяемые (списки, словари) и копия должна быть по-настоящему независимой — нужен deepcopy, а не copy() или срез [:].

Пример 6. List comprehension: преобразование и фильтр

List comprehension — компактная запись цикла, который строит новый список. Исходная коллекция не меняется, если в выражении нет побочных эффектов.

numbers = [1, 4, 6, 7, 9]
squares = [number ** 2 for number in numbers]
print(squares)   # [1, 16, 36, 49, 81]
print(numbers)   # [1, 4, 6, 7, 9] — исходный список не тронут

even_numbers = [number for number in range(10) if number % 2 == 0]
print(even_numbers)   # [0, 2, 4, 6, 8]

Что происходит: [number ** 2 for number in numbers] — это выражение (что положить в список) плюс цикл (откуда брать элементы). Фильтр if number % 2 == 0 в конце добавляет только элементы, прошедшие условие — эквивалент if внутри обычного for с append(), но без промежуточного пустого списка.

Пример 7. Условное выражение if-else и вложенный обход

Если условие выбирает не «включать или нет», а «какое значение положить», if-else пишется перед for — это другое место в синтаксисе, не путать с фильтром из примера 6. Вложенный comprehension читается как вложенные циклы слева направо.

numbers = [2, 7, 5, 4, 1, 1, 7, 8]
modified = [number if number % 2 == 0 else -1 for number in numbers]
print(modified)   # [2, -1, -1, 4, -1, -1, -1, 8]

matrix = [[1, 2, 3], [4, 5, 6]]
flattened = [value for row in matrix for value in row]
print(flattened)   # [1, 2, 3, 4, 5, 6]

Почему так: в modified каждый элемент остаётся собой, если чётный, иначе заменяется на -1 — список той же длины, ни один элемент не выброшен. В flattened первый for row in matrix идёт по строкам матрицы (пример 3), второй for value in row — по значениям внутри строки; порядок циклов в comprehension совпадает с порядком вложенных for в обычном коде.

Пример 8. zip(): параллельный обход и ограничение по короткой последовательности

zip() объединяет несколько последовательностей в пары по позициям — удобно, когда есть два параллельных списка (имена и возраст, ключи и значения).

names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35, 40]
for name, age in zip(names, ages):
    print(f"{name} is {age} years old.")
# Alice is 25 years old.
# Bob is 30 years old.
# Charlie is 35 years old.

print(list(zip(names, ages)))
# [('Alice', 25), ('Bob', 30), ('Charlie', 35)]

Внимание к длинам: в ages четыре элемента, а в names три — 40 нигде не появился, и ошибки при этом нет. zip() молча останавливается на самой короткой последовательности. Если длины должны совпадать и расхождение — это баг данных, длины стоит сравнить явно (len(names) == len(ages)) до вызова zip().

Пример 9. Стек и очередь: LIFO против FIFO

Стек и очередь — не отдельные типы Python, а способ использования уже знакомых структур: обычного списка и collections.deque.

from collections import deque

stack = []
stack.append("open")
stack.append("edit")
stack.append("save")
print(stack.pop())   # save — последним пришёл, первым ушёл
print(stack)          # ['open', 'edit']

queue = deque(["print", "scan", "send"])
queue.append("archive")
print(queue.popleft())   # print — первым пришёл, первым ушёл
print(queue)               # deque(['scan', 'send', 'archive'])

Что происходит: append() + pop() без аргумента снимают элемент с того же конца, куда клали — это LIFO, стек. deque + append() + popleft() кладут с одного конца, а снимают с другого — это FIFO, очередь. На обычном списке тоже можно сделать pop(0) для очереди, но у списка это медленная операция (сдвигаются все элементы); у deque удаление с любого конца быстрое.

Пример 10. Устойчивая сортировка

Сортировка в Python устойчива: элементы с одинаковым ключом сохраняют исходный взаимный порядок. Это не деталь реализации, а гарантия языка, на которую можно полагаться.

words = ["orange", "mango", "apple", "banana", "kiwi", "cherry"]
for word in sorted(words, key=len):
    print(f"{len(word)}: {word}")
# 4: kiwi
# 5: mango
# 5: apple
# 6: orange
# 6: banana
# 6: cherry

data = [("b", 2), ("a", 1), ("a", 0)]
print(sorted(data, key=lambda pair: pair[0]))
# [('a', 1), ('a', 0), ('b', 2)]

Почему так: слова длиной 5 (mango, apple) остались в том порядке, в каком стояли в исходном списке, — сортировка их не переставила местами просто потому, что ключи равны. Во втором примере оба кортежа с ключом "a" сохранили исходный порядок (("a", 1) раньше ("a", 0)), хотя по второму полю они не отсортированы — key сравнивает только то, что ему передали.

Пример 11. Практика: удаление хвоста после первого нуля

Собираем темы вместе: находим индекс нуля и удаляем срезом всё, что после него, — использование del из примера 4 на практической задаче.

numbers = [1, 2, 0, 3, 4, 5]
if 0 in numbers:
    zero_index = numbers.index(0)
    del numbers[zero_index:]
print("Список после удаления:", numbers)
# Список после удаления: [1, 2]

Что происходит: numbers.index(0) находит позицию первого нуля (2), а del numbers[zero_index:] удаляет срез от этой позиции и до конца — сам ноль и всё, что за ним. Проверка if 0 in numbers нужна, иначе .index(0) на списке без нуля бросит ValueError. Решение через срез короче и надёжнее, чем цикл с удалением элементов по одному, — при удалении из списка внутри цикла легко «перепрыгнуть» через элемент из-за сдвига индексов.

Пример 12. Практика: конвейер zip + comprehension

Финальный пример объединяет zip() (пример 8) и list comprehension (примеры 6–7) в одном практическом сценарии: подписи для вывода и все попарные произведения двух списков.

names = ["Alice", "Bob", "Charlie"]
ages = [25, 30, 35]
people = [f"{name} ({age})" for name, age in zip(names, ages)]
print(people)
# ['Alice (25)', 'Bob (30)', 'Charlie (35)']

list1 = [1, 2, 3]
list2 = [4, 5, 6]
products = [left * right for left in list1 for right in list2]
print(products)
# [4, 5, 6, 8, 10, 12, 12, 15, 18]

Что происходит: people распаковывает пары из zip(names, ages) прямо в цикле comprehension и строит f-строку для каждой. products — вложенный comprehension (как в примере 7): для каждого left из list1 перебираются все right из list2, то есть результат — декартово произведение пар, а не покомпонентное умножение через zip.

from itertools import product
print([left * right for left, right in product(list1, list2)])
# [4, 5, 6, 8, 10, 12, 12, 15, 18] — тот же результат
⚠️ Проверить по документации: itertools.product не входит в материал этой сессии — это дополнение курса, показывающее стандартный инструмент для той же задачи. Сверьтесь с разделом itertools.product. Для двух коротких списков разницы нет; на больших данных itertools.product экономнее по памяти, так как не строит промежуточные списки.

Что дальше

Если пример не запустился — сначала загляните в типичные ошибки, затем решайте задания: задание 8 в них — это, по сути, примеры 11–12 без готового кода.