Урок курса

View и copy: предсказание влияния изменений среза на исходный массив

NumPy и pandas: практический тренажёр

Срезы в NumPy вы уже использовали — синтаксис start:stop:step знаком с урока по индексации. Но за этим синтаксисом скрывается поведение, которое легко упустить: срез не копирует данные, а возвращает другой «взгляд» на те же байты в памяти. Именно это поведение определяет, что произойдёт с исходным массивом, если вы изменили срез — и этому посвящён весь урок.

View и copy: два режима доступа к памяти ndarray

Когда вы пишете v = a[1:4], NumPy не выделяет новый кусок памяти под три элемента. Вместо этого v — это отдельный объект ndarray, который знает: «мои данные начинаются вот здесь в памяти, шаг такой-то, длина такая-то». Байты, в которых хранятся числа, общие — и у a, и у v. Такой объект называется view (представление).

Последствие прямое: если вы изменяете элемент через v, вы фактически обращаетесь к тем же байтам в памяти, на которые смотрит a. Никакой «защиты» нет — это не баг, а намеренное поведение NumPy ради экономии памяти и скорости.

import numpy as np

a = np.array([10, 20, 30, 40, 50])
v = a[1:4]   # view: элементы 20, 30, 40

v[0] = 99    # меняем первый элемент view
print(a)     # [10 99 30 40 50] — исходный тоже изменился

Это и есть view в действии: v[0] и a[1] — один и тот же элемент в общей области памяти.

Если вам нужна независимая копия — объект, который хранит те же значения, но в отдельном куске памяти — нужно явно попросить об этом:

a2 = np.array([10, 20, 30, 40, 50])
c = a2[1:4].copy()   # независимая копия

c[0] = 99
print(a2)   # [10 20 30 40 50] — исходный не тронут

Метод .copy() выделяет новое хранилище и переписывает туда значения. После этого c и a2 ничего не знают друг о друге на уровне памяти.

Разница между view и copy — не в значениях (при создании они одинаковы), а в том, где эти значения живут. View смотрит в чужую память, copy владеет своей. Это разграничение и определяет всё поведение, которое мы разберём дальше.

Проверка общей памяти через np.shares_memory

Понять концептуально, что view разделяет память с оригиналом, — хорошо. Но когда нужно убедиться в этом программно, есть конкретный инструмент: np.shares_memory(a, b).

Функция возвращает True, если массивы a и b делят хотя бы один общий байт памяти. Если их данные полностью независимы — вернёт False. Никакого анализа значений, никаких сравнений элементов — только проверка адресов.

import numpy as np

a = np.array([10, 20, 30, 40, 50])

# Срез — view: данные общие
v = a[1:4]
print(np.shares_memory(a, v))   # True

Теперь создадим копию через .copy() и проверим то же самое:

c = a[1:4].copy()
print(np.shares_memory(a, c))   # False

.copy() запрашивает у NumPy новый кусок памяти и копирует туда значения. После этого c — полностью самостоятельный объект. Связи с a на уровне байтов нет, и np.shares_memory это честно подтверждает.

Важный момент: .copy() можно вызывать как на срезе, так и на целом массиве. Оба варианта дают одинаковый результат — независимое хранилище:

full_copy = a.copy()
print(np.shares_memory(a, full_copy))   # False

np.shares_memory стоит использовать как основной инструмент всякий раз, когда есть сомнение: «это view или копия?». Сравнение значений здесь не поможет — при создании view и copy содержат одинаковые числа, разница только в том, где они хранятся.

Предсказание и наблюдение мутации через view и copy

Алгоритм предсказания сводится к трём шагам:

  1. Определить, как получен объект. Срез start:stop:step — view. Вызов .copy() — независимое хранилище.
  2. Если view — любая запись в него затрагивает исходный массив в соответствующих позициях.
  3. Если копия — исходный массив не изменится ни при каких мутациях копии.

Чтобы алгоритм был реально применим, нужно уметь заранее называть конкретные индексы, которые изменятся. Разберём это на примере с шаговым срезом и двумерным массивом — они чуть менее очевидны, чем простой a[1:4].

Шаговый срез: предсказываем затронутые индексы

import numpy as np

a = np.array([0, 10, 20, 30, 40, 50, 60])
v = a[1:7:2]   # элементы с индексами 1, 3, 5 → значения 10, 30, 50

# Предсказание:
# v[0] → a[1], v[1] → a[3], v[2] → a[5]
# Меняем v[1] = 99 → ожидаем a[3] == 99, остальные не трогаем
v[1] = 99
print(a)   # [ 0 10 20 99 40 50 60]

Прежде чем запустить код, стоит явно выписать соответствие: v[k] отображается на a[start + k * step]. Здесь start=1, step=2, поэтому v[1]a[1 + 1*2] = a[3]. Предсказание подтверждается наблюдением.

Теперь мутация в обратную сторону — из исходного массива в view:

a[5] = 777   # a[5] входит в срез v как v[2]
print(v)     # [ 10  99 777]

Общая область памяти работает в обе стороны: запись через a видна в v точно так же, как запись через v видна в a.

Двумерный массив: срез строк — тоже view

m = np.array([[1, 2, 3],
              [4, 5, 6],
              [7, 8, 9]])

rows = m[0:2]   # первые две строки — view

# Предсказание: rows[1, 2] → m[1, 2]
# Меняем rows[1, 2] = 99 → ожидаем m[1, 2] == 99
rows[1, 2] = 99
print(m)
# [[1 2 3]
#  [4 5 99]
#  [7 8 9]]

Прямоугольный срез по строкам (синтаксис start:stop без fancy indexing) возвращает view — это тот же принцип, что и для одномерного случая.

Когда нужна изоляция: копия блокирует мутацию

m2 = np.array([[1, 2, 3],
               [4, 5, 6],
               [7, 8, 9]])

rows_copy = m2[0:2].copy()
rows_copy[1, 2] = 99   # меняем копию
print(m2)
# [[1 2 3]
#  [4 5 6]   ← не изменилось
#  [7 8 9]]

Если вы не уверены, является ли объект view или копией — проверьте np.shares_memory до того, как что-то менять. Это особенно важно, когда объект передан в функцию и вы не контролируете, как он был создан.

Атрибут .base: иллюстрация владения памятью и его ограничения

Каждый ndarray хранит атрибут .base. Если массив владеет своими данными сам — .base равен None. Если массив смотрит в чужую память (то есть является view) — .base указывает на объект, которому эта память принадлежит.

В простом случае это выглядит понятно:

import numpy as np

a = np.array([10, 20, 30, 40, 50])
v = a[0:3]          # view от a

print(v.base is a)  # True — v смотрит в память a

c = a.copy()
print(c.base is None)  # True — c владеет своими данными

Для этих двух случаев .base читается легко и интуитивно. На этом его удобство заканчивается.

Проблема возникает в цепочках. Если вы берёте срез от среза, .base у конечного объекта будет указывать не на ближайшего «родителя», а на того, кто реально владеет памятью — то есть на исходный массив или даже на какой-то промежуточный объект, в зависимости от внутреннего устройства NumPy:

a = np.array([10, 20, 30, 40, 50])
v1 = a[1:5]     # view от a
v2 = v1[0:2]    # view от v1 — но чья память?

print(v2.base is v1)  # может быть False
print(v2.base is a)   # может быть True

Здесь v2.base is v1 может дать False, потому что NumPy «пробрасывает» .base к реальному владельцу данных, минуя промежуточные объекты. Если вы рассчитываете на v2.base is v1 и получаете False, это не значит, что v2 — копия. Просто .base работает не так, как вы ожидаете.

Ещё одна ловушка: .base отвечает на вопрос «кто владеет памятью», но не на вопрос «разделяют ли вот эти два конкретных объекта память». Если у вас есть x и y, и вы хотите знать, связаны ли они, проверка x.base is y или y.base is x не гарантирует правильного ответа во всех сценариях.

Для этого есть np.shares_memory(x, y) — он отвечает именно на этот вопрос, без допущений о структуре цепочки.

.base полезен как быстрая иллюстрация: увидеть, что view ссылается на чужую память, а копия — нет. Но принимать на его основе решения в коде не стоит. Если нужна надёжная проверка — np.shares_memory.

Попробуйте решить

Дан массив a = np.array([1, 2, 3, 4, 5]). Выполнены команды:
v = a[1:3]
v[0] = 99
Чему равен a[1] после этих операций?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку