Этот урок подводит итог двум темам сессии: уроку 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 без готового кода.