Уроки курса
1 Введение в Python и философия дзен
30 мин
2 Переменные и динамическая типизация
30 мин
3 Базовые типы данных: числа, строки и булевы значения
30 мин
4 Изменяемые и неизменяемые объекты (Mutable vs Immutable)
30 мин
5 Форматирование строк и f-строки
30 мин
6 Углубленная работа со списками
30 мин
7 Кортежи и их особенности
30 мин
8 Словари под капотом
30 мин
9 Множества и математические операции
30 мин
10 Генераторы списков (List Comprehensions)
30 мин
11 Генераторы словарей и множеств
30 мин
12 Встроенные функции для коллекций
30 мин
13 Условные операторы и логические выражения
30 мин
14 Циклы while и управление потоком
30 мин
15 Итерация с циклом for
30 мин
16 Конструкции for...else и while...else
30 мин
17 Функции enumerate и zip
30 мин
18 Создание собственных функций (def)
30 мин
19 Позиционные и именованные аргументы
30 мин
20 Проблема изменяемых аргументов по умолчанию
30 мин
21 Произвольное число аргументов (*args и **kwargs)
30 мин
22 Область видимости переменных (LEGB)
30 мин
23 Анонимные функции (lambda)
30 мин
24 Функции высшего порядка
30 мин
25 Замыкания (Closures)
30 мин
26 Введение в объектно-ориентированное программирование
30 мин
27 Атрибуты классов и экземпляров
30 мин
28 Магический метод __init__
30 мин
29 Методы экземпляра
30 мин
30 Инкапсуляция и сокрытие данных
30 мин
31 Декоратор @property
30 мин
32 Наследование классов
30 мин
33 Переопределение методов и функция super()
30 мин
34 Полиморфизм в Python
30 мин
35 Магические методы строк (__str__ и __repr__)
30 мин
36 Обработка исключений (try-except)
30 мин
37 Блоки else и finally
30 мин
38 Генерация собственных исключений (raise)
30 мин
39 Открытие и чтение файлов
30 мин
40 Запись данных в файлы
30 мин
41 Контекстные менеджеры (with)
30 мин
42 Работа с форматом JSON
30 мин
43 Модули и импорты
30 мин
44 Полезные модули стандартной библиотеки
30 мин
45 Модуль datetime
30 мин
46 Модуль collections
30 мин
47 Виртуальные окружения (venv)
30 мин
48 Установка сторонних пакетов через pip
30 мин
49 Организация структуры Python-проекта
30 мин
50 Финальный проект: создание приложения
30 мин

Изменяемые и неизменяемые объекты (Mutable vs Immutable)

Студент поймет разницу в поведении объектов в памяти, чтобы избегать неочевидных ошибок в программах.

Прогресс урока: 0%

Глава 1: Матрица управления памятью в Python и концепция объектов

Добро пожаловать на четвертый урок нашего курса уровня Intermediate. Ранее мы рассматривали синтаксис, базовые типы и философию языка. Теперь настало время заглянуть под капот интерпретатора CPython и понять, как именно ваши данные хранятся в оперативной памяти компьютера. Понимание темы «изменяемости» (mutability) — это тот самый водораздел, который отделяет новичков, пишущих код наугад, от профессионалов, создающих надежные и предсказуемые системы.

В классических языках со статической типизацией, таких как C++ или Java, переменная часто воспринимается как физическая «коробка» или ячейка определенного размера в оперативной памяти, куда мы кладем значение. В Python эта парадигма работает совершенно иначе. Здесь переменная выступает исключительно в роли ярлыка, стикера или именной бирки, которая привязывается к объекту, уже существующему в памяти.

Абсолютно все в Python является объектом: числа, строки, функции, классы и даже сами модули. Каждый объект в памяти, согласно внутренней реализации CPython (структура PyObject), состоит как минимум из трех фундаментальных характеристик:

  • Идентификатор (Identity): Уникальный адрес объекта в оперативной памяти. Этот адрес никогда не меняется за все время жизни объекта. В Python его можно получить с помощью встроенной функции id().
  • Тип (Type): Определяет, какие операции можно выполнять с объектом и сколько памяти он занимает. Узнать тип можно с помощью функции type(). Тип также задается при создании и не подлежит изменению (строгая динамическая типизация).
  • Значение (Value): Непосредственно полезная нагрузка объекта (например, число 42 или строка 'Hello'). И вот здесь кроется главный секрет: у одних объектов значение изменить можно, а у других — категорически нельзя.

Именно на основании способности изменять свое Значение после создания, все типы данных в Python жестко делятся на две категории: Неизменяемые (Immutable) и Изменяемые (Mutable). Если объект относится к неизменяемому типу, любая операция, которая на первый взгляд модифицирует его, на самом деле создает в памяти совершенно новый объект и перевешивает на него ярлык переменной. Старый же объект, если на него больше не ссылается ни одна переменная, безжалостно уничтожается встроенным механизмом — Сборщиком мусора (Garbage Collector), который работает на основе подсчета ссылок. Это критически важное понимание для написания производительного кода, так как постоянное пересоздание тяжелых объектов приводит к утечкам памяти и сильному замедлению программы. В этом уроке мы подробно разберем поведение обеих категорий, научимся избегать скрытых ловушек, таких как изменяемые аргументы по умолчанию (о которых часто спрашивают на собеседованиях), и применим методику Active Recall для закрепления материала.

python
variable_a = 100
print(f'Значение: {variable_a}')
print(f'Тип: {type(variable_a)}')
print(f'Идентификатор (адрес памяти): {id(variable_a)}')

Какая характеристика объекта в Python никогда не изменяется за все время его существования в оперативной памяти?

Глава 2: Неизменяемые объекты (Immutable Types) - Фундамент надежности

Начнем наше исследование с неизменяемых типов данных. К ним в Python относятся: целые числа (int), числа с плавающей точкой (float), комплексные числа (complex), булевы значения (bool), строки (str), кортежи (tuple) и замороженные множества (frozenset). Термин «неизменяемый» (immutable) означает, что после того, как объект был инициализирован и помещен в оперативную память, его внутреннее состояние (значение) заблокировано для любых модификаций на уровне интерпретатора.

Давайте рассмотрим классический пример, который сбивает с толку многих новичков. Допустим, мы пишем код: x = 5, а затем на следующей строке x = x + 1. Если рассуждать в парадигме «коробок», можно подумать, что мы достали из коробки x пятерку, прибавили к ней единицу и положили обратно шестерку. Но это иллюзия! В реальности происходит следующее: интерпретатор видит число 5, выделяет для него участок памяти и вешает на этот участок ярлык x. Когда выполняется выражение x + 1, Python не меняет существующую пятерку. Он вычисляет результат (6), выделяет под это новое число совершенно новый участок памяти, а затем отрывает ярлык x от пятерки и переклеивает его на шестерку. Исходный объект «5» остается нетронутым.

Такое поведение гарантирует абсолютную безопасность при передаче неизменяемых объектов в различные функции или при работе в многопоточной среде. Вы можете быть на 100% уверены, что ни один участок кода не сможет тайно изменить вашу строку или число «за вашей спиной». Однако у этого есть и цена: операции конкатенации (сложения) длинных строк в цикле работают крайне медленно именно потому, что на каждой итерации в памяти создается огромная новая строка, куда копируется содержимое старых. Именно поэтому опытные разработчики используют метод .join() или списковые включения (List Comprehensions) для сборки строк, так как это минимизирует количество паразитных выделений памяти.

Особого внимания заслуживают строки. Строки в Python — это последовательности символов, и если вы попытаетесь изменить конкретный символ по его индексу (например, my_string[0] = 'A'), Python немедленно выбросит исключение TypeError: 'str' object does not support item assignment. Чтобы «изменить» строку, вы обязаны создать новую, используя срезы (slices) и конкатенацию: new_string = 'A' + my_string[1:]. Эта строгая неизменяемость позволяет Python применять различные механизмы оптимизации, такие как интернирование (interning), о которых мы поговорим чуть позже, а также позволяет использовать строки в качестве ключей для словарей (dictionaries).

python
# Демонстрация создания нового объекта при "изменении"
x = 10
print(f'Изначальный ID x: {id(x)}')

x = x + 5
print(f'ID x после прибавления 5: {id(x)}  <-- ID изменился! Это новый объект.')

text = "hello"
try:
    text[0] = "H"
except TypeError as e:
    print(f'Ошибка: {e}')

Какой тип данных из перечисленных является неизменяемым: list, dict, set, tuple?

Глава 3: Оптимизация памяти — Интернирование и кэширование

Поскольку неизменяемые объекты не могут изменить свое состояние, создатели CPython внедрили несколько гениальных механизмов оптимизации для экономии оперативной памяти и ускорения работы программ. Одним из таких механизмов является предварительное кэширование малых целых чисел (Small Integer Caching). При запуске интерпретатора Python заранее создает в памяти массив объектов целых чисел в диапазоне от -5 до 256 включительно. Почему именно этот диапазон? Потому что эти числа используются в подавляющем большинстве повседневных задач: в качестве счетчиков для циклов, индексов массивов, флагов статуса и так далее.

Что это означает на практике? Если вы создаете две разные переменные, a = 100 и b = 100, интерпретатор не будет создавать в памяти два отдельных объекта с числом 100. Он поймет, что число 100 входит в закэшированный диапазон, и просто повесит оба ярлыка (и a, и b) на один и тот же заранее созданный объект в памяти. Если вы проверите их идентификаторы с помощью id(a) == id(b), результат будет True. Однако, если вы выйдете за пределы этого диапазона, например, напишете x = 1000 и y = 1000, Python создаст два разных объекта в разных участках памяти (хотя в некоторых современных IDE или при выполнении кода в одном блоке компилятор может оптимизировать и это, но полагаться на это нельзя). Это яркий пример того, как глубокое знание архитектуры помогает понять странности языка.

Похожий механизм применяется и к коротким строкам, состоящим только из букв, цифр и символов подчеркивания. Это называется интернированием строк (String Interning). Если вы создадите переменные s1 = 'hello' и s2 = 'hello', они будут указывать на один и тот же объект в памяти. Это делается для того, чтобы ускорить сравнение ключей в словарях. Сравнивать строки посимвольно — медленно, а вот сравнить два адреса в памяти (проверить, что это один и тот же объект) можно за долю микросекунды. Если строки интернированы, интерпретатор может сначала проверить их адреса. Если адреса совпадают, строки гарантированно идентичны, и дорогостоящее посимвольное сравнение можно пропустить. Понимание этих механизмов микрообучения критически важно для разработчиков уровня Intermediate, так как оно объясняет, почему некоторые операции в Python работают феноменально быстро, а другие могут стать узким местом производительности.

python
# Демонстрация кэширования малых чисел
a = 256
b = 256
print(f'a is b для 256: {a is b}')  # True, так как входит в диапазон кэширования

x = 257
y = 257
print(f'x is y для 257: {x is y}')  # False, созданы разные объекты

# Интернирование строк
s1 = "python_is_great"
s2 = "python_is_great"
print(f's1 is s2: {s1 is s2}')  # True

Почему интерпретатор Python кэширует именно числа от -5 до 256?

Глава 4: Изменяемые объекты (Mutable Types) - Гибкость и опасность

Теперь перейдем к темной стороне силы — изменяемым объектам. К ним относятся списки (list), словари (dict) и множества (set), а также подавляющее большинство пользовательских классов, которые вы будете создавать самостоятельно с помощью объектно-ориентированного программирования (ООП). Изменяемость (mutability) означает, что объект может модифицировать свое внутреннее состояние, добавлять, удалять или изменять элементы, не меняя при этом свой идентификатор (адрес в памяти). Это невероятно мощный инструмент для работы с большими массивами данных, но он же является источником самых трудноуловимых и коварных ошибок (багов) в разработке программного обеспечения.

Давайте разберем поведение на примере списка. Когда вы создаете my_list = [1, 2, 3], интерпретатор выделяет в памяти блок для структуры списка, который содержит метаданные и массив ссылок на объекты-числа 1, 2 и 3. Если вы вызовете метод my_list.append(4), Python не будет копировать весь список целиком. Он просто пойдет по адресу в памяти, где лежит структура списка, найдет свободное место во внутреннем массиве ссылок (или выделит дополнительную память, если места нет — этот процесс называется over-allocation), и запишет туда ссылку на новый объект числа 4. При этом, если вы вызовете id(my_list) до добавления элемента и после него, вы увидите абсолютно одинаковый адрес! Объект мутировал на месте (in-place modification).

В чем же заключается опасность? Представьте, что вы создали список и присвоили его переменной A. Затем вы решили сделать резервную копию и написали B = A. Если бы мы работали с неизменяемыми числами, изменение A никак бы не повлияло на B. Но поскольку списки — это изменяемые объекты, операция присваивания B = A не копирует сами данные! Она лишь создает новый ярлык B и вешает его на тот же самый единственный объект в памяти. Теперь, если вы выполните B.append('вирус'), элемент добавится в этот единственный список. И когда вы позже обратитесь к переменной A, ожидая увидеть там оригинальные данные, вы с ужасом обнаружите там строку 'вирус'. Обе переменные смотрят в одну точку пространства памяти. Это называется «проблема общих ссылок» (shared references).

Именно из-за этого нюанса разработчики, переходящие с уровня Junior на Intermediate, обязаны научиться четко разделять концепции «создания нового объекта» и «мутации существующего». Если вы используете методы, модифицирующие объект на месте, такие как .sort(), .append(), .update() у словарей или .add() у множеств, они обычно возвращают None. Это сделано специально (согласно философии Python), чтобы вы визуально понимали в коде: здесь не создается новый объект, здесь происходит изменение старого.

python
# Демонстрация изменяемости и проблемы общих ссылок
original_list = ['apple', 'banana', 'cherry']
print(f'ID оригинала: {id(original_list)}')

# Создаем алиас (еще одну ссылку), а НЕ копию!
fake_copy = original_list

# Изменяем "копию"
fake_copy.append('dragonfruit')

print(f'Fake copy: {fake_copy}')
print(f'Оригинал ТОЖЕ изменился: {original_list}')
print(f'ID оригинала после изменения: {id(original_list)} (ID остался прежним)')

Что произойдет, если выполнить следующий код: a = [1, 2]; b = a; b.append(3); print(a)?

Глава 5: Оператор 'is' против '==' — Идентичность vs Равенство

Для закрепления материала о работе с памятью необходимо детально разобрать два важнейших оператора сравнения в Python: is и ==. На начальных этапах изучения языка разработчики часто используют их взаимозаменяемо, что приводит к непредсказуемым ошибкам в логике (logical bugs), которые не вызывают падения программы, но заставляют ее работать некорректно. Разница между этими операторами отражает разницу между философскими концепциями «Идентичности» и «Равенства».

Оператор двойного равенства == проверяет Эквивалентность значений (Value Equality). Когда вы пишете A == B, Python, грубо говоря, заглядывает внутрь объекта A, затем заглядывает внутрь объекта B и сравнивает их полезную нагрузку. Если содержимое одинаковое, он возвращает True, даже если это совершенно разные объекты, лежащие в противоположных концах оперативной памяти. Внутри интерпретатора оператор == вызывает специальный магический метод __eq__(), который классы могут переопределять по своему усмотрению. Например, два разных списка с одинаковым набором чисел [1, 2, 3] == [1, 2, 3] будут равны.

С другой стороны, оператор is проверяет Идентичность объектов (Object Identity). Это самая строгая форма проверки. Когда вы пишете A is B, Python вообще не смотрит на значения внутри объектов. Он просто берет адрес в памяти переменной A (то есть id(A)) и сравнивает его с адресом B (id(B)). Если адреса совпадают до последнего бита, это означает, что A и B — это буквально один и тот же физический объект в памяти. Возвращается True. Если адреса разные — False, даже если объекты являются точными клонами друг друга. Выражение A is B равносильно вызову id(A) == id(B), но работает быстрее.

Согласно стандарту оформления кода PEP 8, оператор is должен использоваться исключительно в двух случаях. Во-первых, для проверки на принадлежность к паттерну Singleton. Самым известным синглтоном в Python является объект None. В памяти существует только один объект None на всю запущенную программу. Поэтому правильно писать if result is None:, а не if result == None:. Это гарантирует, что вы проверяете именно на отсутствие значения, а не на какой-то хитрый класс, который переопределил магический метод равенства так, чтобы всегда возвращать True. Во-вторых, is используется для проверки логических значений is True или is False, хотя чаще всего достаточно просто написать if condition:. Использование is для сравнения чисел или строк является грубейшей архитектурной ошибкой, так как из-за механизмов кэширования (которые мы обсуждали ранее) поведение программы может зависеть от версии интерпретатора и размера числа.

python
list1 = [10, 20, 30]
list2 = [10, 20, 30]
list3 = list1

print(f'list1 == list2: {list1 == list2}  (Значения одинаковые)')
print(f'list1 is list2: {list1 is list2}  (Но это разные объекты в памяти!)')

print(f'list1 == list3: {list1 == list3}  (Значения одинаковые)')
print(f'list1 is list3: {list1 is list3}  (И это один и тот же объект!)')

Задание

Практическое задание: Анализ кода на наличие проблем с операторами сравнения. Вам даны фрагменты кода, определите, где используется неверный подход.

  • Прочитайте код: status = get_status(); if status is 'active': print('OK')
  • Поймите ошибку: Строки нужно сравнивать через '==', так как 'is' проверяет адреса памяти. Интернирование строк не гарантировано для динамически созданных значений.
  • Прочитайте код: data = None; if data == None: pass
  • Поймите ошибку: Сравнение с синглтоном None согласно стандарту PEP 8 должно происходить исключительно через 'is'.
  • Запомните правило: Значения сравниваем через '==', объекты-одиночки (None) и проверку одной и той же ссылки — через 'is'.
10 баллов

Глава 6: Передача аргументов в функции (Pass-by-Assignment)

Дойдя до этого момента, вы понимаете, как ведут себя объекты при базовых операциях присваивания. Но настоящая магия (и настоящие проблемы) начинаются, когда мы начинаем передавать эти объекты внутрь функций. В программировании исторически сложились два основных подхода к передаче параметров: передача по значению (Pass-by-Value), где функция получает полную независимую копию данных, и передача по ссылке (Pass-by-Reference), где функция получает прямой доступ к оригинальной переменной и может ее изменить.

Python не использует ни один из этих подходов в чистом виде! Официальная терминология языка называет этот механизм «Pass-by-Assignment» (передача по присваиванию) или «Pass-by-Object-Reference» (передача по ссылке на объект). Чтобы понять это, давайте вспомним нашу аналогию с ярлыками. Когда вы вызываете функцию и передаете ей аргумент, например process_data(my_variable), Python не копирует данные из my_variable. Вместо этого он создает внутри функции новый локальный ярлык (название параметра функции) и приклеивает этот новый ярлык к тому же самому объекту, на который смотрела внешняя переменная.

И вот здесь концепции изменяемости (mutability) и неизменяемости (immutability) раскрываются во всей красе. Представьте, что вы передали в функцию число (неизменяемый объект). Внутри функции параметр x и внешняя переменная a смотрят на число 10. Если внутри функции вы напишете x = x + 5, Python не сможет изменить саму десятку. Он вычислит 15, создаст новый объект и перевесит на него локальный ярлык x. Внешняя переменная a так и останется привязанной к 10. Функция никак не повлияла на внешний мир. Вывод: неизменяемые объекты безопасны при передаче в функции.

Теперь рассмотрим другой сценарий. Вы передаете в функцию список (изменяемый объект). Локальный параметр data внутри функции и внешний список my_list указывают на один и тот же массив в памяти. Если внутри функции вы вызовете data.append(99), вы модифицируете сам объект списка. Поскольку внешний ярлык my_list все еще привязан к этому мутировавшему объекту, изменения будут видны снаружи! Функция изменила данные, переданные ей извне. Это поведение называется «побочным эффектом» (side effect). В функциональном программировании побочных эффектов стараются избегать, так как они делают код сложным для тестирования и отладки. Если функция должна изменять данные, хорошим тоном является возврат нового измененного объекта из функции, оставляя оригинал нетронутым.

python
def modify_string(s):
    s = s + " World"  # Создает новую строку, локальная s указывает на нее
    print(f"Внутри функции (str): {s}")

def modify_list(lst):
    lst.append("!!!")  # Мутирует оригинальный объект in-place
    print(f"Внутри функции (list): {lst}")

my_str = "Hello"
modify_string(my_str)
print(f"Снаружи (str осталась прежней): {my_str}\n")

my_list = ["Hello"]
modify_list(my_list)
print(f"Снаружи (list изменился!): {my_list}")

Глава 7: Имитация Code Review - Изменяемые аргументы по умолчанию (Классическая ошибка)

Настало время рассмотреть самую известную и коварную ловушку Python, связанную с изменяемостью, на которой спотыкается 95% новичков. На собеседованиях этот вопрос звучит почти всегда. Мы разберем эту ситуацию в формате симуляции обсуждения кода (Code Review) между Junior (Джуниор) и Senior (Сеньор) разработчиками. Эта ситуация была упомянута в наших архивных материалах курса, и сейчас мы детально разберем ее механизм.

Ситуация: Джуниор написал функцию для добавления студента в группу. Он хотел сделать так, чтобы, если список группы не передан, функция создавала новый пустой список и добавляла студента туда. Вот его код:
def add_student(name, target_list=[]):
    target_list.append(name)
    return target_list

Диалог:
Junior: «Я создал функцию `add_student`. Если я вызываю ее `add_student('Иван', math_group)`, Иван добавляется в группу по математике. А если я вызываю ее просто `add_student('Анна')`, она должна создавать новую группу с Анной. Но почему-то, когда я вызываю ее второй раз для другой группы `add_student('Петр')`, Петр добавляется в ту же группу, где уже сидит Анна! Функция смешивает студентов!»
Senior: «Поздравляю, ты наступил на классическую мину, которая называется 'Mutable Default Argument' (Изменяемый аргумент по умолчанию). Дело в том, как Python интерпретирует этап объявления функции. В Python аргументы по умолчанию создаются и вычисляются ровно один раз — в момент, когда интерпретатор читает определение функции (блок `def`), а не при каждом ее вызове».

Давайте переведем объяснение Сеньора на язык управления памятью. Когда интерпретатор видит строку def add_student(name, target_list=[]):, он прямо в этот момент создает в памяти один реальный объект пустого списка. Этот список становится «зашитым» в байт-код самой функции. Когда вы первый раз вызываете add_student('Анна'), функция использует этот самый скрытый список и добавляет туда 'Анна'. Вызов завершается, но объект списка продолжает жить в памяти, привязанный к объекту функции. Когда вы делаете второй вызов add_student('Петр'), функция снова обращается к тому же самому списку по умолчанию. Но он уже не пустой! В нем лежит 'Анна'. Поэтому 'Петр' добавляется туда же. С каждым новым вызовом этот скрытый список-мутант будет разрастаться, сохраняя состояние между вызовами, что приведет к катастрофическим утечкам данных.

Как же правильно решить эту задачу? Старшие разработчики применяют универсальный паттерн: в качестве значения по умолчанию используется неизменяемый синглтон None. А внутри самой функции происходит проверка. Если аргумент равен None, мы вручную создаем новый пустой список внутри тела функции. Поскольку код внутри функции выполняется заново при каждом вызове, новый список будет создаваться каждый раз с нуля. Правильный код выглядит так:
def add_student(name, target_list=None):
    if target_list is None:
        target_list = []
    target_list.append(name)
    return target_list

Обратите внимание на использование оператора is None, о важности которого мы говорили в предыдущей главе.

python
# Демонстрация ОШИБОЧНОГО подхода (антипаттерн)
def bad_append(item, lst=[]):
    lst.append(item)
    return lst

print("Плохой подход:")
print(bad_append('Яблоко'))  # ['Яблоко']
print(bad_append('Банан'))   # ['Яблоко', 'Банан'] - Внезапно!

# Демонстрация ПРАВИЛЬНОГО подхода (Pythonic way)
def good_append(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

print("\nХороший подход:")
print(good_append('Яблоко'))  # ['Яблоко']
print(good_append('Банан'))   # ['Банан'] - Как и ожидалось!

В какой момент времени интерпретатор Python создает и вычисляет значения аргументов по умолчанию для функции?

Какое значение принято использовать в качестве аргумента по умолчанию в Python, если нужно передать изменяемый тип (например, список или словарь)? Введите одно слово.

Глава 8: Решение проблемы ссылок - Поверхностное и Глубокое копирование

Если присваивание через знак равенства (B = A) просто создает новую ссылку на тот же объект, как же нам создать настоящую, физически независимую копию списка или словаря, чтобы безопасно её изменять? В Python для этого существует несколько встроенных механизмов, но их нужно применять с осторожностью, так как объекты могут быть вложенными друг в друга (например, список, содержащий списки). Существует два уровня копирования: поверхностное (Shallow Copy) и глубокое (Deep Copy).

Поверхностное копирование (Shallow Copy) создает новый объект-контейнер (например, новый список), но затем заполняет его ссылками на элементы из оригинального контейнера. Получить поверхностную копию списка можно несколькими способами: используя метод .copy() (B = A.copy()), передавая список в конструктор типа (B = list(A)) или используя полный срез (B = A[:]). Если у вас плоский список, состоящий только из неизменяемых чисел или строк ([1, 2, 3]), поверхностного копирования более чем достаточно. Вы создадите независимую копию, добавите в нее элементы, и оригинал не пострадает. Но что произойдет, если у нас сложная структура: matrix = [[1, 2], [3, 4]]? Поверхностная копия создаст новый внешний список, но внутри него будут лежать ссылки на те же самые внутренние списки [1, 2] и [3, 4]. Если вы возьмете поверхностную копию и измените число во вложенном списке: copy_matrix[0][0] = 99, это число изменится и в оригинальной матрице! Вы скопировали оболочку, но не её содержимое.

Для полного клонирования сложных иерархических структур данных применяется Глубокое копирование (Deep Copy). Для этого необходимо импортировать встроенный модуль copy и использовать функцию copy.deepcopy(A). Этот мощный инструмент рекурсивно обходит весь объект. Он создает новый внешний контейнер, затем заходит внутрь, видит там список, создает для него новый контейнер в памяти, затем заходит в него, копирует элементы, и так далее до самого дна структуры. В результате deepcopy возвращает абсолютно независимый клон оригинального объекта. Изменение любых вложенных структур в клоне никак не отразится на оригинале.

Почему же мы не используем deepcopy всегда и везде, раз он такой надежный? Ответ кроется в производительности. Глубокое копирование — это чрезвычайно ресурсоемкая операция. Она требует выделения множества блоков памяти, постоянного отслеживания уже скопированных элементов (чтобы избежать бесконечного зацикливания, если объект ссылается сам на себя) и замедляет выполнение программы. Поэтому правило хорошего тона (Best Practice) гласит: используйте поверхностное копирование везде, где это возможно, и прибегайте к deepcopy только в тех случаях, когда вы имеете дело с глубоко вложенными изменяемыми структурами и вам критически важно обеспечить полную изоляцию данных.

python
import copy

# Демонстрация проблемы поверхностного копирования со вложенными структурами
original_matrix = [[1, 2], [3, 4]]
shallow_copy = original_matrix.copy()  # Поверхностная копия

shallow_copy[0][0] = 99  # Меняем элемент ВНУТРИ вложенного списка
print(f'Оригинал после shallow copy: {original_matrix}')  # [[99, 2], [3, 4]] - Изменился!

# Демонстрация глубокого копирования
original_matrix_2 = [[1, 2], [3, 4]]
deep_copy = copy.deepcopy(original_matrix_2)  # Глубокая копия

deep_copy[0][0] = 99
print(f'Оригинал после deep copy: {original_matrix_2}')  # [[1, 2], [3, 4]] - Остался целым!

Какой метод копирования нужно использовать, чтобы полностью обезопасить оригинальный список словарей от любых изменений во вложенных структурах?

Глава 9: Неизменяемость внутри Изменяемости и наоборот

Чтобы стать истинным мастером Python (Intermediate level), вы должны понимать, как типы данных могут комбинироваться и какие парадоксы при этом возникают. Мир Python не черно-белый, и объекты часто содержат друг друга. Рассмотрим две интригующие ситуации: изменяемый объект (список) внутри неизменяемого (кортежа), и неизменяемый объект (кортеж) внутри изменяемого (списка).

Парадокс 1: Список внутри кортежа. Мы помним, что кортеж (tuple) — это неизменяемый тип данных. Создав кортеж t = (1, 2, [3, 4]), мы заблокировали его состояние. Мы не можем заменить список на число (например, t[2] = 99 выдаст TypeError), потому что кортеж жестко зафиксировал ссылки на свои элементы. Но что если мы обратимся к самому списку по индексу и вызовем его метод модификации: t[2].append(5)? Код выполнится без ошибок! Если мы распечатаем кортеж, он станет (1, 2, [3, 4, 5]). На первый взгляд, кортеж «изменился», что нарушает правила языка. Но если мы вспомним про управление памятью, всё встает на свои места. Кортеж хранит адрес в памяти, где лежит структура списка. При добавлении элемента в список, сам список мутирует in-place, но его адрес в памяти не меняется! Кортеж как указывал на этот адрес, так и продолжает указывать. С точки зрения кортежа, он сохранил свою неизменяемость — его ссылки не поменялись. Это поведение часто является источником багов у невнимательных разработчиков.

Парадокс 2: Кортеж внутри списка. Рассмотрим обратную ситуацию: l = [1, 2, (3, 4)]. Список — структура изменяемая, мы можем свободно удалять и добавлять элементы. Однако, если мы попытаемся изменить содержимое кортежа: l[2][0] = 99, программа мгновенно упадет с TypeError. Список не защищает свои элементы от их собственных внутренних правил. Кортеж внутри списка остается железобетонно неизменяемым. Чтобы «изменить» данные внутри этого кортежа, вам придется создать совершенно новый кортеж (99, 4) и записать его поверх старого в список: l[2] = (99, 4). При этом старый кортеж будет удален сборщиком мусора.

Из этих примеров вытекает важнейшее свойство — Хэшируемость (Hashability). Это концепция, напрямую связанная с неизменяемостью, которая объясняет, почему одни объекты могут быть ключами в словарях (dict) и элементами множеств (set), а другие — нет. В следующей главе мы свяжем наши знания о памяти со структурами данных.

Задание

Практикум: Работа с гибридными структурами

  • Создайте кортеж данных пользователя: user_data = ('Иван', 'Иванов', ['admin', 'editor'])
  • Попытайтесь переназначить список прав напрямую: user_data[2] = ['user'] (Убедитесь, что получаете TypeError).
  • Попытайтесь модифицировать список внутри кортежа: user_data[2].remove('editor') (Обратите внимание, что операция успешна).
  • Сделайте вывод: Неизменяемость кортежа касается только прямых ссылок (ярлыков) внутри него, но не распространяется на глубокие уровни вложенных изменяемых объектов.
10 баллов

Глава 10: Хэшируемость и ключи словаря - Встреча теории с практикой

Мы подошли к финалу нашего глубокого погружения — практическому смыслу неизменяемости. Почему создатели языка вообще придумали разделение на Mutable и Immutable? Почему бы не сделать все объекты изменяемыми для удобства? Основная причина кроется в потребности в невероятно быстрых структурах данных, таких как Словари (dict) и Множества (set), которые работают на основе хэш-таблиц.

Что такое хэширование? Это процесс передачи данных через математическую функцию (хэш-функцию), которая преобразует эти данные в уникальное целое число — хэш (hash). В Python вы можете вызвать встроенную функцию hash() для объекта. Например, hash('apple') вернет длинное число. Словари используют это число, чтобы мгновенно (со сложностью O(1)) вычислять ячейку памяти, где лежит значение. Когда вы ищете что-то в словаре: my_dict['apple'], Python не перебирает все ключи. Он хэширует строку 'apple', получает индекс и сразу берет значение по этому индексу. Это делает поиск в словарях фантастически быстрым, независимо от их размера.

Но здесь есть одно критическое, железобетонное требование архитектуры хэш-таблиц: Хэш объекта никогда не должен меняться за всё время его жизни. Если вы положите объект в словарь по одному хэшу (в ячейку №5), а затем объект изменит свои внутренние данные, его новый хэш может указывать на ячейку №10. Словарь навсегда потеряет доступ к вашему значению, потому что будет искать его по новому хэшу в пустой ячейке! Именно поэтому ключами словарей и элементами множеств могут быть ТОЛЬКО хэшируемые объекты.

А какие объекты у нас хэшируемые? Правильно, Неизменяемые (Immutable). Строки, числа, булевы значения и кортежи (при условии, что внутри кортежа нет изменяемых списков). Их состояние заблокировано, значит их хэш вычисляется один раз и гарантированно остается стабильным. Если вы попытаетесь использовать изменяемый список в качестве ключа словаря: my_dict = {[1, 2]: 'значение'}, интерпретатор немедленно остановит вас с ошибкой TypeError: unhashable type: 'list'. Python даже не имеет встроенной реализации функции hash() для списков, словарей и множеств, так как они могут мутировать и разрушить структуру любой хэш-таблицы.

Подводя итоги: понимание концепций Mutable и Immutable, механизма ссылок (ярлыков) и особенностей управления памятью — это фундамент, без которого невозможно написать эффективный и багоустойчивый код на Python. Вы научились избегать ловушек с аргументами по умолчанию, освоили разницу между 'is' и '==', а также поняли, почему архитектура языка запрещает использовать списки в качестве ключей словаря. Эта экспертиза позволит вам уверенно чувствовать себя на Code Review и создавать надежные архитектурные решения. Переходите к финальному тестированию для закрепления материала!

python
# Демонстрация хэшируемости
print(f"Хэш строки 'test': {hash('test')}")
print(f"Хэш кортежа (1, 2): {hash((1, 2))}")

try:
    # Пытаемся получить хэш списка
    bad_hash = hash([1, 2])
except TypeError as e:
    print(f"Ошибка хэширования списка: {e}")

# Использование неизменяемого кортежа как ключа словаря для географических координат
coordinates = {
    (55.751244, 37.618423): "Москва",
    (59.938630, 30.314130): "Санкт-Петербург"
}
print(f"Город по координатам: {coordinates[(55.751244, 37.618423)]}")

Почему изменяемые объекты (например, списки) не могут быть использованы в качестве ключей в словаре (dict) Python?