Псевдонимы (aliasing) списков в Python: скрытая ловушка для новичков

В прошлом году я написал функцию, которая принимала список оценок и "для удобства" сортировала его внутри. Вызывающий код передавал оригинальный список, получал результат и двигался дальше. Через неделю коллега заметил, что оригинальные данные в другой части программы приходят отсортированными, хотя никто их не трогал. Причина: функция получала не копию, а ссылку на тот же самый список. Изменение внутри функции изменяло оригинал. Это и есть aliasing: когда две переменные ссылаются на один объект в памяти.
В этой статье разбираю, как Python хранит списки, почему присваивание не создаёт копию, чем отличаются поверхностное и глубокое копирование, и в каких ситуациях aliasing ломает код.
Как Python хранит списки в памяти
Переменная в Python это не ящик с данными, а наклейка на объекте. Когда вы пишете a = [1, 2, 3], Python создаёт объект-список в памяти и привязывает к нему имя a. Имя a хранит ссылку (reference) на объект, а не сам объект.
a = [1, 2, 3]
print(id(a)) # 140234866534400 (адрес объекта в памяти)
print(type(a)) # <class 'list'>
Функция id() возвращает числовой адрес объекта. Этот адрес не меняется на протяжении жизни объекта.
Что такое aliasing
Aliasing (псевдоним) возникает, когда две переменные ссылаются на один и тот же объект. Присваивание b = a не создаёт новый список. Оно создаёт вторую ссылку на тот же объект.
a = [1, 2, 3]
b = a
print(b is a) # True (один и тот же объект)
print(id(a)) # 140234866534400
print(id(b)) # 140234866534400 (тот же адрес)
Оператор is проверяет идентичность: ссылаются ли переменные на один объект. Оператор == проверяет эквивалентность: одинаковое ли содержимое. Для алиасов оба возвращают True.
Последствия: изменение через одну переменную видно через другую
a = [1, 2, 3]
b = a
b[0] = 17
print(a) # [17, 2, 3] — оригинал изменился!
b.append(4)
print(a) # [17, 2, 3, 4]
b и a это два имени одного списка. Любое изменение через b видно через a, и наоборот. Ничего не скопировано, просто два имени указывают в одно место.
Со строками такого не бывает
a = "banana"
b = "banana"
print(a is b) # True (Python кэширует строки)
a = a.upper()
print(a) # BANANA
print(b) # banana (не изменилась)
Строки неизменяемы (immutable). Метод upper() не меняет строку, а создаёт новую. Переменная a начинает ссылаться на новый объект "BANANA", а b продолжает ссылаться на старый "banana". С неизменяемыми типами aliasing безопасен, потому что объект нельзя изменить через любую ссылку.
Пять ситуаций, где aliasing ломает код
Ситуация 1: "копирование" списка через присваивание
Самая частая ошибка новичков.
original = [1, 2, 3]
backup = original # это НЕ копия
original.append(4)
print(backup) # [1, 2, 3, 4] — "бэкап" тоже изменился
Переменная backup не содержит копию данных. Она указывает на тот же список, что и original.
Ситуация 2: передача списка в функцию
Когда функция получает список как аргумент, она получает ссылку на оригинал, а не копию.
def delete_head(t):
del t[0]
letters = ["a", "b", "c"]
delete_head(letters)
print(letters) # ['b', 'c'] — оригинал изменён!
Параметр t внутри функции это алиас для letters. Удаление t[0] удаляет первый элемент оригинального списка.
Важный нюанс: если внутри функции переназначить переменную, связь с оригиналом разрывается.
def bad_delete(t):
t = t[1:] # создаёт НОВЫЙ список, присваивает локальной переменной
letters = ["a", "b", "c"]
bad_delete(letters)
print(letters) # ['a', 'b', 'c'] — ничего не изменилось
Строка t = t[1:] не изменяет оригинальный список. Она создаёт новый список и присваивает его локальной переменной t, которая перестаёт быть алиасом.
Ситуация 3: список в цикле сбора данных
row = [0, 0, 0]
matrix = []
for i in range(3):
matrix.append(row)
matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]] — все три строки изменились
Все три элемента matrix это ссылки на один и тот же список row. Изменение через matrix[0] затрагивает все строки, потому что строка одна.
Исправление: создавать новый список на каждой итерации.
matrix = []
for i in range(3):
matrix.append([0, 0, 0]) # новый список каждый раз
matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [0, 0, 0], [0, 0, 0]] — только первая строка
Ситуация 4: умножение списка со вложенными списками
matrix = [[0] * 3] * 3
matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [1, 0, 0], [1, 0, 0]] — та же проблема
Оператор * для списков дублирует ссылки, а не объекты. [[0]*3] * 3 создаёт три ссылки на один внутренний список.
Исправление:
matrix = [[0] * 3 for _ in range(3)]
matrix[0][0] = 1
print(matrix)
# [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
List comprehension создаёт новый [0]*3 на каждой итерации.
Ситуация 5: изменяемый аргумент по умолчанию
Одна из самых известных ловушек Python.
def add_item(item, items=[]):
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['a', 'b'] — что?!
print(add_item("c")) # ['a', 'b', 'c']
Аргумент по умолчанию [] вычисляется один раз при определении функции. Все вызовы без явного аргумента используют один и тот же объект-список. Каждый append добавляет элемент в этот общий список.
Исправление: используйте None как значение по умолчанию.
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
print(add_item("a")) # ['a']
print(add_item("b")) # ['b'] — теперь корректно
Способы скопировать список
Поверхностная копия (shallow copy)
Поверхностная копия создаёт новый список, но элементы внутри него ссылаются на те же объекты, что и в оригинале.
Четыре способа сделать поверхностную копию:
original = [1, 2, 3]
copy1 = original[:] # срез
copy2 = list(original) # конструктор list()
copy3 = original.copy() # метод .copy()
import copy
copy4 = copy.copy(original) # модуль copy
Все четыре дают одинаковый результат: новый список с теми же элементами.
original = [1, 2, 3]
backup = original[:]
print(backup is original) # False (разные объекты)
print(backup == original) # True (одинаковое содержимое)
original.append(4)
print(backup) # [1, 2, 3] — копия не затронута
Когда поверхностной копии недостаточно
Если список содержит вложенные изменяемые объекты (списки, словари), поверхностная копия копирует только верхний уровень. Вложенные объекты остаются общими.
original = [[1, 2], [3, 4]]
shallow = original[:]
print(shallow is original) # False (разные списки)
print(shallow[0] is original[0]) # True (вложенный список — тот же!)
original[0].append(99)
print(shallow) # [[1, 2, 99], [3, 4]] — вложенный список изменился
Внешний список скопирован, но внутренние списки [1, 2] и [3, 4] остались общими.
Глубокая копия (deep copy)
Глубокая копия рекурсивно копирует все вложенные объекты. Результат полностью независим от оригинала.
import copy
original = [[1, 2], [3, 4]]
deep = copy.deepcopy(original)
print(deep[0] is original[0]) # False (разные объекты)
original[0].append(99)
print(deep) # [[1, 2], [3, 4]] — глубокая копия не затронута
deepcopy() доступен только в модуле copy. Срез, list() и .copy() не делают глубоких копий.
Сравнение способов копирования
Как обнаружить aliasing
id() и is
a = [1, 2, 3]
b = a
c = a[:]
print(f"id(a) = {id(a)}") # id(a) = 140234866534400
print(f"id(b) = {id(b)}") # id(b) = 140234866534400 (алиас!)
print(f"id(c) = {id(c)}") # id(c) = 140234866589760 (копия)
print(a is b) # True — один объект
print(a is c) # False — разные объекты
print(a == c) # True — одинаковое содержимое
Правило: is отвечает на вопрос "это тот же объект?", == отвечает на вопрос "одинаковое содержимое?".
Проверка в отладке
Если программа ведёт себя странно и данные меняются "сами по себе", проверьте id() подозрительных переменных. Одинаковые id означают aliasing.
def debug_alias(name, obj):
print(f"{name}: id={id(obj)}, value={obj}")
scores = [90, 85, 78]
backup = scores
debug_alias("scores", scores) # scores: id=140..., value=[90, 85, 78]
debug_alias("backup", backup) # backup: id=140..., value=[90, 85, 78]
# Одинаковые id — это алиас, не копия!
Безопасная работа с функциями
Функция не должна менять входные данные (если не просят)
# Плохо: сортирует оригинал
def get_sorted(data):
data.sort()
return data
# Хорошо: возвращает новый отсортированный список
def get_sorted(data):
return sorted(data)
Встроенная функция sorted() всегда возвращает новый список, не изменяя оригинал. Метод .sort() изменяет список на месте и возвращает None.
Защитная копия на входе функции
Если функция должна изменять список внутри, но не затрагивать оригинал, создайте копию в начале.
def process(data):
local = data[:] # поверхностная копия
local.sort()
local.pop(0)
return local
original = [3, 1, 4, 1, 5]
result = process(original)
print(original) # [3, 1, 4, 1, 5] — не изменился
print(result) # [1, 4, 5]
Возврат без создания алиаса
# Плохо: возвращает ссылку на внутреннее состояние
class Student:
def __init__(self):
self._grades = []
def add_grade(self, grade):
self._grades.append(grade)
def get_grades(self):
return self._grades # вызывающий код может изменить!
# Хорошо: возвращает копию
def get_grades(self):
return self._grades[:]
Возврат self._grades отдаёт прямую ссылку на внутренний список объекта. Вызывающий код может случайно его изменить. Возврат self._grades[:] отдаёт поверхностную копию.
Aliasing со словарями
Словари изменяемы, и aliasing работает с ними точно так же.
config = {"debug": True, "port": 8080}
backup = config
config["port"] = 9090
print(backup["port"]) # 9090
# Копия
safe_backup = config.copy()
config["port"] = 3000
print(safe_backup["port"]) # 9090 (не изменился)
Метод .copy() у словарей тоже делает поверхностную копию. Для вложенных словарей нужен copy.deepcopy().
Неочевидные детали
Первый факт: a += [4] и a = a + [4] работают по-разному. Оператор += изменяет список на месте (вызывает __iadd__), а + создаёт новый список.
a = [1, 2, 3]
b = a
a += [4]
print(b) # [1, 2, 3, 4] — b тоже изменился (тот же объект)
a = [1, 2, 3]
b = a
a = a + [4]
print(b) # [1, 2, 3] — b не изменился (a теперь указывает на новый объект)
Второй факт: append возвращает None, не новый список. Код b = a.append(4) присвоит b значение None, а не список.
a = [1, 2, 3]
b = a.append(4)
print(b) # None
print(a) # [1, 2, 3, 4]
Третий факт: срез всегда создаёт новый список, даже если он полный (a[:]). Но внутренние элементы не копируются: это поверхностная копия.
Четвёртый факт: целые числа и строки кэшируются Python. Маленькие числа (-5 до 256) и короткие строки могут иметь одинаковый id(), даже если созданы отдельно. Но это безопасно, потому что они неизменяемы.
a = 42
b = 42
print(a is b) # True (кэшировано)
a = 1000
b = 1000
print(a is b) # False (или True — зависит от реализации)
Пятый факт: copy.deepcopy() корректно обрабатывает циклические ссылки. Если список содержит ссылку на самого себя, deepcopy не уйдёт в бесконечную рекурсию.
import copy
a = [1, 2]
a.append(a) # циклическая ссылка
b = copy.deepcopy(a)
print(b[2] is b) # True (deepcopy сохраняет структуру ссылок)
print(b[2] is a) # False (но это независимая копия)
FAQ
Как понять, копия это или алиас?
Используйте оператор is. Если a is b возвращает True, это алиас (один объект). Если False, это два разных объекта.
Нужна ли глубокая копия для списка чисел?
Нет. Числа неизменяемы, поэтому поверхностной копии достаточно. Глубокая копия нужна, когда список содержит вложенные изменяемые объекты: списки, словари, объекты классов.
Почему Python не копирует список при присваивании?
Копирование дорогое. Список может содержать миллионы элементов. Создание копии при каждом присваивании или передаче в функцию сделало бы Python медленным. Вместо этого Python копирует только ссылку (8 байт), а не данные.
Как передать список в функцию без риска изменения?
Передайте срез: my_func(data[:]). Или передайте кортеж: my_func(tuple(data)). Кортеж нельзя изменить, и функция получит TypeError при попытке.
Работает ли aliasing с кортежами?
Aliasing технически возникает (b = a создаёт вторую ссылку), но не вызывает проблем, потому что кортеж неизменяем. Нельзя изменить объект через любую из ссылок.
Мой совет: запомните одно правило и закроете 90% проблем с aliasing. Если вы пишете b = a и a это список (или словарь), вы получили не копию, а второе имя того же объекта. Хотите независимую копию: b = a[:] для плоского списка, b = copy.deepcopy(a) для вложенного. Проверяйте через is, если сомневаетесь.
