Лекция 4. Семантика ссылок. Вложенные списки

Семантика ссылок

Языки программирования по отношению к хранению значений в переменных делятся на три категории: с семантикой значений, семантикой ссылок и смешанной семантикой. Нам уже известен один язык с семантикой значений – это язык C. При присваивании переменной нового значения это значение всегда копируется, при вызове функции значение аргумента копируется в параметр функции, с которым она затем работает. То есть, каждый раз происходит копирование, и если нам нужна ссылка на переменную, то мы используем указатели, которые реализуют отдельную операцию доступа по ссылке.

В языке Python3 всё устроено по-другому: при присваивании одной переменной другой присваиваемый объект не копируется, появляется лишь новая ссылка на него. Все переменные в Python3 являются ссылками.

a = 1   # в а лежит ссылка на объект 1
b = a   # теперь в переменной b тоже лежит ссылка на тот же самый объект.
b = b + 1    # Здесь операция + создаёт новый объект с числом 2,
             # и b теперь ссылается на него

Так как числа являются неизменяемым типом данных, то все арифметические операции создают новые объекты. При присваивании переменной нового значения она получает ссылку на это значение. Предыдущее значение не изменяется, так как в переменной хранилась лишь ссылка на него. Таким образом, благодаря неизменяемости все операции с числами работают в Python3 так же, как и в C.

Изменением ссылок в Python заниманются операторы =, +=, -=, *=, /=, //=, %=, &=, |=, ^=

Однако для изменяемых типов данных, таких как списки, учёт ссылочной семантики становится очень важным. Рассмотрим следующий пример:

L = [10, 20, 30]
L1 = L
L[0] = 500
L[1] = 700
print(L)
print(L1)

Что выведет данный код? Дважды выведет [500, 700, 30], так как переменные L и L1 ссылались на один и тот же список! Если же вы хотите получить в разных переменных разные ссылки, то надо их изначально создавать разными:

L = [10, 20, 30]
L1 = [10, 20, 30]

Либо перед присваиванием копировать список:

L = [10, 20, 30]
L1 = L[:]    # Срезы всегда создают копии списков

Хорошо, теперь разберёмся с вложенными списками.

L = [['a', 'b'], ['c', 'd']]
print(L[0][1])  # Первый индекс выбирает элемент внешнего списка
                # и получает вложенный список, в котором действует
                # второй индекс.
L1 = L[:]     # Скопировали список L. Правда-правда.
# Теперь ситуация такая, как на рис. 1

L.append('xyz')
L1[0] = 'abc'
print(L)      # выводит  [['a', 'b'], ['c', 'd'], 'xyz']
print(L1)     # выводит  ['abc', ['c', 'd']]
# Теперь ситуация такая, как на рис. 2

L[1][0] = ':)'   # А вот вложенные списки мы не скопировали. Хе-хе.
print(L)      # выводит  [['a', 'b'], [':)', 'd'], 'xyz']
print(L1)     # выводит  ['abc', [':)', 'd']]
# Теперь ситуация такая, как на рис. 3
_images/reference_semantics.png

Итак, для поверхностного копирования списков можно воспользоваться срезом или функцией copy из модуля copy:

L1 = L[:]
import copy
L1 = copy.copy(L)

Если же нужно полное копирование со всеми вложенными списками или другими изменяемыми объектами, то подойдёт функция deepcopy из того же модуля:

import copy
L = copy.deepcopy(L)

Операторы is, not is

Оператор == сравнивает два списка, и возвращает истину, если все элементы списка равны:

[10, 20, 30] == [10, 20, 30]  # True
[10, 'abc'] == [10, 'abc']    # True
[10, 'abc'] == [10, 'xyz']    # False

В то же время, иногда нас весьма интересует ситуация, когда у нас есть две ссылки не просто на равные списки, а две ссылки на один и тот же объект. И хотелось бы уметь определять такую ситуацию. Для этого существует оператор is, который сравнивает две ссылки:

L1 = [10, 20, 30]
L2 = [10, 20, 30]
# L1 и L2 имеют одинаковое содержимое, но являются
# двумя разными объектами.
print(L1 == L2, L1 is L2)  # True, False

L1 = [10, 20, 30]
L2 = L1
# L1 и L2 -- ссылки на один и тот же объект.
print(L1 == L2, L1 is L2)  # True, True

Оператор not is является отрицанием оператора is, а инструкция a not is b является полным эквивалентом конструкции not(a is b).

Важное замечание. Не стоит сравнивать целые числа с помощью оператора is, потому что когда интерпретатор Python встречает в исходном коде программы небольшое целое число, он может не создавать новый объект типа int, а взять ссылку на уже существующий. Это сделано потому, что небольшие однозначные или двузначные числа часто встречаются в коде программы и на них можно сэкономить немного памяти. Поэтому для сравнения чисел всегда используйте оператор ==.

Вложенные списки

Чтобы создать в Python двумерный массив, нужно воспользоваться вложенными списками. Обратите внимание на то, что каждый раз, когда Python выполняет строку кода с литералом списка, он создаёт новый список. Если инструкция вида L1 = [] выполняется несколько раз в цикле, или при нескольких вызовах одной и той же функции, каждый раз она будет указывать на новый пустой список.

Пример кода, создающего двумерный массив M строк на N столбцов:

L = []
for i in range(M):   # по строкам
    L1 = []          # новый вложенный список для i-ой строки
    for j in range(N):   # по столбцам
        L1.append(0)     # заполняем вложенный список N нулями
    L.append(L1)     # добавляем вложенный список во внешний

Для M = 3, N = 7 этот код сгенерирует список [[0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]]

Запись L[i][j] означает доступ к j-ому элементу i-го вложенного списка, то есть, к i-ой строке и j-ому столбцу:

_images/2d_list.png

Срезы

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

L1 = L[:]

Слева и справа от символа : можно указывать начальный (включается) и конечный (не включается) индексы:

L = [10, 20, 30, 40, 50, 60]
L1 = L[1:5]  # [20, 30, 40, 50]

Также, можно указывать отрицательные индексы. В этом случае отсчёт ведётся от конца списка.

L1 = L[1:-1]   # [20, 30, 40, 50]
L1 = L[-5:-1]  # [20, 30, 40, 50]
L1 = L[-5:5]   # [20, 30, 40, 50]

Если первый индекс больше или равен второму, в срезе будет лежать пустой список:

L1 = L[3:1]    # []

Если не указан первый индекс среза, это означает “сначала”, если не указан второй, то “до конца”.

L1 = L[:3]  # [10, 20, 30]
L1 = L[3:]  # [40, 50, 60]

Кроме того, существует другой вариант среза с тремя аргументами: индекс начала a, индекс конца b, шаг среза step. Здесь правила аналогичны range(a, b, step): индексы элементов срезы выбираются из прогрессии, начинающейся c a, идущей c шагом step и не включающей b.

L = [10, 20, 30, 40, 50, 60]
L1 = L[1:4:2]  # [20, 40]
L1 = L[1:5:2]  # [20, 40]
L1 = L[1:-1:3]  # [20, 50]

Можно задавать отрицательный шаг и получать обратный порядок элементов. При этом индекс начала должен быть больше индекса конца, иначе результат будет пустым списком.

L1 = L[-1:1:-1]  # [50, 40, 30]

Точно так же, как и для оператора среза от двух аргументов, в срезе от трёх аргументов можно не указывать индекс начала или индекс конца. Если не указан индекс начала и шаг положительный, то срез начнётся с начала списка, если отрицательный – с конца. Аналогично, отсутствие второго индекса означает “до конца в направлении шага”.

L1 = L[::2]   # копия ячеек с чётными индексами
L1 = L[1::2]  # копия ячеек с нечётными индексами
L1 = L[::-1]  # копия списка в обратном порядке

Ещё раз нужно уточнить, что срез всегда создаёт поверхностную копию списка, а значит, интерпретатор выделяет память под новые ячейки и тратит некоторое время на их заполнение. Не стоит создавать слишком много срезов, если можно обойтись без них.

Обход списка пар

Предположим, у нас есть список точек на плоскости. Каждая точка представлена вложенным списком из двух элементов, x- и y-координат точки. Тогда при проходе по такому списку можно распаковать его значения в две переменные непосредственно в заголовке цикла for:

L = [[1, 5], [6, 8], [-1, 3]]
for x, y in coords:
    print(x, y)

Такой код выведет следующее:

1 5
6 8
-1 3

Если во вложенном списке более двух элементов, их тоже можно распаковывать в соответствующее количество переменных цикла for. Если число элементов в очередном вложенном списке не соответствует числу переменных цикла for, for прекратит выполняться и выдаст ошибку.

Итераторы. enumerate и zip

Для обсуждения функций enumerate и zip нужно представлять себе концепцию итераторов. Итератор – это объект, который позволяет обойти некоторую последовательность или множество. (Примечание: последовательность отличается от множества тем, что ней элементы упорядочены, а в множестве – нет). При этом, часто итератор не обладает полной информацией о последовательности или множестве, а просто содержит текущую позицию обхода в них. Этого достаточно, чтобы извлечь текущий элемент и двигаться дальше. Примером простейшего итератора является range. Итератор range(1000000) не выделяет память под миллион элементов, а содержит текущее значение, шаг (равный 1) и конечное значение.

Функция enumerate принимает список и возвращает итератор, в каждом элементе которого находятся индекс элемента (начиная с нуля) и сам элемент. Обычно эти два значения сразу распаковываются в разные переменные.

L = ['Alice', 'Bob', 'John']
for i, elem in enumerate(L):
    print(i, elem)

Такой код выведет следующее:

1 Alice
2 Bob
3 John

Иногда трудно запомнить, в каком порядке надо писать переменные в заголовке цикла for: индекс и значение, или значение, затем индекс. Удобно запомнить, что запись идёт так же, как и в списках на бумаге, слева индекс, справа значение.

Функция zip принимает два и более списка, и возвращает итератор, в каждом элементе которого находится комплект элементов переданных ему списков:

animals = ['cat', 'dog', 'bird']
names = ['Kitty', 'Rex', 'Tweety']
for animal, name in zip(animals, names):
    print(animal, name)

Такой код выведет:

cat Kitty
dog Rex
bird Tweety

Если списки разной длины, zip прекратит работать, когда закончится самый короткий список.