Перевод статьи «A Slicing Story».
Вы уверены, что знаете все о таком объекте, как срез? Давайте проверим!
Обычно статьи начинаются с представления основ и наиболее частых вариантов использования срезов. Но я пойду другим путем. Скорее всего вы уже знакомы с основами срезов (но если нет — не переживайте). Я начну изложение не с «начала».
Все является объектом, и срезы — не исключение
Начнем с простого примера операции среза (здесь и далее код выполняется в консоли):
my_favorite_numbers = [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792] my_favorite_numbers[2:7] # [1.414, 1.618, 2.718, 3.142, 42]
Что находится в квадратных скобках? Точно, это срез. Но что такое срез?
Давайте выясним это, создав новый тип данных — модифицированный список, отображающий строковое представление того, что находится в квадратных скобках при использовании индексов, и его тип данных. Вы можете переопределить специальный метод __getitem__(), который используется при извлечении элементов с помощью квадратных скобок, и добавить пару вызовов print():
my_favorite_numbers = [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792]
class TestList(list):
def __init__(self, lst):
super().__init__(lst)
def __getitem__(self, item):
print(item)
print(type(item))
return super().__getitem__(item)
# Преобразовать список в TestList
my_favorite_numbers = TestList(my_favorite_numbers)
# А теперь давайте получим срез...
print(my_favorite_numbers[2:7])
slice(2, 7, None) <class 'slice'> [1.414, 1.618, 2.718, 3.142, 42]
Когда вы индексируете объект с помощью квадратных скобок, вызывается его метод __getitem__(). Значения в квадратных скобках передаются в качестве аргумента в __getitem__(). Таким образом, параметр item представляет собой все, что вы помещаете в квадратные скобки при индексации объекта.
В этом новом классе TestList, который наследуется от list, вы сначала выводите значение и тип данных аргумента в методе __getitem__(), а затем вызываете метод __getitem__() списка и возвращаете его значение. Именно это делает super().__getitem__(item), поскольку list является суперклассом для TestList.
Синтаксис 2:7 в квадратных скобках представляет объект slice. Вы, вероятно, слышали, что в Python все является объектом. Следовательно, срезы тоже являются объектами.
При выводе элемента вы видите следующее:
slice(2, 7, None)
Три аргумента представляют значения начального индекса, конечного индекса и шага среза. Поскольку вы используете синтаксис 2:7, вы включаете только значения начального и конечного индекса. Следовательно, значение шага равно None, что эквивалентно размеру шага 1.
Если включить размер шага в срез, вы увидите его в виде третьего аргумента в slice():
my_favorite_numbers[2:7:2]
slice(2, 7, 2) <class 'slice'> [1.414, 2.718, 42]
При индексировании последовательности, такой как список, можно указать в квадратных скобках и одно целое число. Вы можете попробовать это с TestList. Но эта статья посвящена срезам, поэтому я перейду дальше.
Именованные срезы
Вы видели, что синтаксис среза, который вы используете в квадратных скобках, например 2:7 или 2:7:2, ссылается на объект slice. Можно ли вызвать конструктор slice() непосредственно в квадратных скобках?
my_favorite_numbers = [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792] my_favorite_numbers[slice(2, 7)] # [1.414, 1.618, 2.718, 3.142, 42]
Да, оказывается, можно. Но зачем? Вероятно, вам это не понадобится.
Но иногда может быть удобно присвоить имя срезу, который вы хотите повторно использовать в своем коде. Поскольку срез является объектом, как и большинство других вещей в Python, вы можете создать экземпляр slice и присвоить ему имя. Затем можно использовать это имя в квадратных скобках:
first_half = slice(0, 5) second_half = slice(5, 10) my_favorite_numbers = [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792] my_favorite_numbers[first_half] # [0, 1, 1.414, 1.618, 2.718] my_favorite_numbers[second_half] # [3.142, 42, 49, 1024, 299792]
Использование именованного среза также может помочь в некоторых ситуациях с читаемостью кода. Мы еще вернемся к объекту slice, чтобы рассмотреть его метод .indices().
Назад к основам
В начале этой статьи я пропустил описание того, что такое срезы и как их использовать. Поэтому давайте вернёмся к основам. Но я буду краток. И вы можете смело пропустить этот раздел, если знакомы с использованием срезов.
Вы можете извлечь подмножество последовательности — или срез последовательности:
my_favorite_numbers = [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792] my_favorite_numbers[4:8] # [2.718, 3.142, 42, 49]
В результате вы получаете срез последовательности. Он содержит элементы от индекса 4 до индекса 8, не включая элемент с индексом 8. Если хотите, чтобы срез начинался с начала или заканчивался в конце последовательности, можно использовать сокращенную запись:
my_favorite_numbers[:4] # [0, 1, 1.414, 1.618] my_favorite_numbers[4:] # [2.718, 3.142, 42, 49, 1024, 299792]
Конечно, можно сделать срез, включив в него всю последовательность:
my_favorite_numbers[:] # [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792]
Вам может показаться, что это бессмысленно. Зачем выбирать в срез всю последовательность? Однако вы часто будете встречать это в коде Python, поскольку это создает копию последовательности. Мы вернемся к этому вопросу позже в этой статье.
Для справки, я предпочитаю использовать my_favourite_numbers.copy() при работе со списком, так как это более читаемо. В стандартной библиотеке также есть модуль copy для создания копий других типов данных.
Также можно указать шаг, добавив третье значение в синтаксис среза:
# От индекса 1 до индекса 9 (не включая его), с шагом 2 my_favorite_numbers[1:9:2] # [1, 1.618, 3.142, 49] # От начала списка до индекса 9 (не включая его), с шагом 2 my_favorite_numbers[:9:2] # [0, 1.414, 2.718, 42, 1024] # От индекса 2 до конца списка, с шагом 2 my_favorite_numbers[2::2] # [1.414, 2.718, 42, 1024] # От начала до конца списка, с шагом 2 my_favorite_numbers[::2] # [0, 1.414, 2.718, 42, 1024]
Если использовать отрицательный шаг, можно пойти в обратном направлении:
# От индекса 8 до индекса 2 (не включая его), с шагом -1 my_favorite_numbers[8:2:-1] # [1024, 49, 42, 3.142, 2.718, 1.618] # От конца списка до индекса 3 (не включая его), с шагом -2 my_favorite_numbers[:3:-2] # [299792, 49, 3.142] # От индекса 8 до начала списка, с шагом -2 my_favorite_numbers[8::-2] # [1024, 42, 2.718, 1.414, 0] # От конца списка до его начала, с шагом -1 (переворот последовательности) my_favorite_numbers[::-1] # [299792, 1024, 49, 42, 3.142, 2.718, 1.618, 1.414, 1, 0]
Обратите внимание, что если вы попытаетесь создать невозможный срез, вы получите пустой список:
my_favorite_numbers[4:2] # [] my_favorite_numbers[2:7:-1] # []
В первом примере нет элементов, начиная с индекса 4 и заканчивая индексом 2, но не включая его. Шаг по умолчанию равен 1.
Во втором примере вы делаете срез от индекса 2 до индекса 7, но не включая его, с шагом -1. Это невозможно, поэтому вы получаете пустой список.
Расширение и сокращение последовательности с помощью операции среза
Вы можете использовать срезы для замены нескольких элементов в последовательности. В этом разделе я буду использовать другой список чисел:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers[4:7] = [40, 50, 60] numbers # [0, 1, 2, 3, 40, 50, 60, 7, 8, 9]
Вы присваиваете элементы 40, 50 и 60 позициям в исходном списке, представленным срезом [4:7]. На этих позициях находятся элементы с индексами 4, 5 и 6.
Что произойдет, если срез, который вы присваиваете, превышает размер исходного списка?
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers[5:15] = range(50, 150, 10) numbers # [0, 1, 2, 3, 4, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]
Ничего страшного. Это расширит список, чтобы он соответствовал данным справа от знака равенства. Длина среза даже не должна совпадать с длиной данных:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # Обратите внимание, что объект range длиннее среза numbers[5:15] = range(50, 200, 10) numbers # [0, 1, 2, 3, 4, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190]
Или даже проще:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers[5:] = range(50, 200, 10) numbers # [0, 1, 2, 3, 4, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190]
Я слышу ваш вопрос, и вот ответ: да, это работает и в обратном случае, когда срез длиннее последовательности справа от знака равенства:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # В этом примере срез длиннее объекта range numbers[5:20] = range(50, 150, 10) numbers # [0, 1, 2, 3, 4, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]
А теперь будет немного странно:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers[20:30] = range(20, 30) numbers # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
Исходный список имеет длину 10. Вы присваиваете значения срезу, начиная с индекса 20 и заканчивая индексом 29. И это работает! Но новые элементы добавляются в конец исходного списка, начиная с индекса 11, а не с индекса 20, как указывает срез. В списке не может быть пропусков!
Сжатие данных в последовательность
Возьмем другой пример:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # Этот срез представляет два элемента numbers[4:6] # [4, 5] numbers[4:6] = [4.0, 4.5, 5.0, 5.5] numbers # [0, 1, 2, 3, 4.0, 4.5, 5.0, 5.5, 6, 7, 8, 9]
Давайте посмотрим, что тут происходит. Вы присваиваете список с четырьмя элементами — [4.0, 4.5, 5.0, 5.5] — срезу, представляющему два элемента. Поэтому эти два элемента, 4 и 5, заменяются четырьмя новыми элементами.
Аналогичным образом можно и сократить список:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers[2:8] = [100, 200] numbers # [0, 1, 100, 200, 8, 9]
Числа от 2 до 7 заменяются двумя значениями, 100 и 200.
Копии и представления
Давайте рассмотрим срезы подробнее. Посмотрите на этот пример:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] subset_of_numbers = numbers[3:8] subset_of_numbers # [3, 4, 5, 6, 7] # Присваиваем новое значение первому элементу в "subset_of_numbers" subset_of_numbers[0] = 300 subset_of_numbers # [300, 4, 5, 6, 7] # ...но это не изменяет исходный список numbers # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Вы присваиваете срез numbers новой переменной с именем subset_of_numbers. Но когда вы вносите изменения в subset_of_numbers, исходный список numbers остается неизменным. При создании среза списка создается копия списка. Срез не ссылается на те же объекты в исходном списке.
Давайте пойдем дальше и создадим вложенные списки:
nested_numbers = [[0, 1, 2], [3, 4, 5], [6, 7, 8]] subset_of_nested_numbers = nested_numbers[:2] subset_of_nested_numbers # [[0, 1, 2], [3, 4, 5]] # Присваиваем новое значение внутри одного из вложенных списков subset_of_nested_numbers[1][0] = 300 subset_of_nested_numbers # [[0, 1, 2], [300, 4, 5]] # Теперь исходный список изменяется nested_numbers # [[0, 1, 2], [300, 4, 5], [6, 7, 8]]
В этом случае изменение значения в одном из внутренних списков subset_of_nested_numbers также влияет на исходный список nested_numbers. Такое поведение может показаться странным — некоторые новички считают, что это ошибка. Но это не так. Срез списка создает поверхностную копию списка.
Срез — это новый список, но он содержит ссылки на исходные данные. Давайте снова посмотрим, как это работает, расширив предыдущую сессию REPL/Console:
# Дополнение предыдущего кода: subset_of_nested_numbers[0] = [999, 999, 999] subset_of_nested_numbers # [[999, 999, 999], [300, 4, 5]] # Это изменение НЕ отражается на исходном списке nested_numbers # [[0, 1, 2], [300, 4, 5], [6, 7, 8]]
Запутались? Одно присвоение изменило исходный список, а второе — нет. Давайте разберемся:
1.
nested_numbers = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
Это создает четыре объекта списка: внешний список с именем nested_numbers, и три внутренних списка.
2.
subset_of_nested_numbers = nested_numbers[:2] subset_of_nested_numbers # [[0, 1, 2], [3, 4, 5]]
Это создает новый список, с именем subset_of_nested_numbers. Этот список является новым объектом. Однако внутренние списки являются теми же списками, что и в исходном списке nested_numbers. Они не просто равны этим спискам, они являются теми же объектами. Вот доказательство:
id(nested_numbers[0]) # 140431821514560 id(subset_of_nested_numbers[0]) # 140431821514560 # <-- Тот же id объекта # Но 'nested_numbers' и 'subset_of_nested_numbers' различны id(nested_numbers) # 140431821601728 id(subset_of_nested_numbers) # 140431821601216
Первый элемент в nested_numbers и первый элемент в subset_of_nested_numbers имеют одинаковый идентификатор. Следовательно, они являются одним и тем же объектом.
3.
subset_of_nested_numbers[1][0] = 300 subset_of_nested_numbers # [[0, 1, 2], [300, 4, 5]]
Когда вы обращаетесь к subset_of_nested_numbers[1], вы обращаетесь к тому же объекту, что и nested_numbers[1], поскольку они являются одним и тем же объектом. Поэтому, когда вы изменяете первый элемент этого внутреннего списка, это влияет на оба внешних списка:
nested_numbers # [[0, 1, 2], [300, 4, 5], [6, 7, 8]]
4.
subset_of_nested_numbers[0] = [999, 999, 999] subset_of_nested_numbers # [[999, 999, 999], [300, 4, 5]] nested_numbers # [[0, 1, 2], [300, 4, 5], [6, 7, 8]]
Но когда вы присваиваете новый список subset_of_nested_numbers[0], вы заменяете ссылку на исходный внутренний список. Таким образом, subset_of_nested_numbers[0] и nested_numbers[0] больше не являются одним и тем же объектом. Первый элемент в nested_numbers не изменился и остался тем же внутренним списком, что и раньше, [0, 1, 2]. Однако первый элемент в subset_of_nested_lists теперь является новым списком [999, 999, 999].
Запутались? Это еще не все…
Итак, срез создает копию подмножества последовательности, верно? Не так быстро…
До сих пор во всех примерах в этой статье я использовал списки. Но можно создавать срезы и других последовательностей:
this_substack = "The Python Coding Stack" this_substack[4:10] print(this_substack[4:10]) # Python
И это также создает копию. При создании срезов строк или кортежей нет другого варианта, кроме создания копии, поскольку они являются неизменяемыми типами данных.
Итак, давайте попробуем другой тип данных, чтобы увидеть, всегда ли операция среза создает копию. Воспользуемся массивом NumPy. Я повторю первый пример из раздела «Копии и представления», который я показываю снова ниже, а также воспользуюсь массивами NumPy в дополнение к спискам:
### # Для начала давайте вспомним, что происходит со списками numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] subset_of_numbers = numbers[3:8] subset_of_numbers # [3, 4, 5, 6, 7] # Присваиваем новое значение первому элементу в "subset_of_numbers" subset_of_numbers[0] = 300 subset_of_numbers # [300, 4, 5, 6, 7] # ...но это не изменяет исходный список numbers # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
Теперь давайте повторим эти шаги, используя массивы NumPy. Вам нужно будет установить NumPy с помощью pip install numpy или вашего любимого менеджера пакетов:
### # Теперь двайте повторим это с массивами NumPy import numpy as np numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers_array = np.array(numbers) numbers_array # array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) subset_of_numbers_array = numbers_array[3:8] subset_of_numbers_array # array([3, 4, 5, 6, 7]) subset_of_numbers_array[0] = 300 subset_of_numbers_array # array([300, 4, 5, 6, 7]) # Пока все так же само. # Но взгляните на исходный массив... numbers_array # array([ 0, 1, 2, 300, 4, 5, 6, 7, 8, 9])
Поведение при операции среза массива NumPy отличается от поведения при операции среза списка. Когда вы присваиваете новое значение элементу в subset_of_numbers_array, исходный массив также изменяется. В этом случае срез не создает копию части массива. Вместо этого создается представление. Это означает, что subset_of_numbers_array ссылается на те же данные, что и numbers_array. Когда вы изменяете одно, другое также изменяется.
Вы можете узнать больше о копиях и представлениях в NumPy в документации.
Итак, создает ли срез копию данных, зависит от типа данных. Срезы создают копии при работе со списками, строками, кортежами и другими встроенными типами данных. Однако такое поведение не гарантируется при операции среза, как вы видели в случае массивов NumPy.
Вернемся к объекту slice
Прежде чем продолжить, я должен упомянуть метод .indices(). Это единственный метод объекта slice, помимо обычных специальных методов. Вряд ли вам когда-нибудь понадобится его использовать, но он может помочь вам немного лучше понять срезы.
Предположим, вы хотите извлечь из последовательности элементы с четвертого по восьмой с шагом 2:
numbers = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] numbers[3:9:2] # [3, 5, 7]
Что произойдет, если список короче и не содержит достаточно элементов? Срез все равно вернет значение:
numbers = [0, 1, 2, 3, 4, 5] numbers[3:9:2] # [3, 5] numbers = [0, 1, 2] numbers[3:9:2] # []
Однако, если вам нужен срез, идеально соответствующий используемой последовательности, вы можете преобразовать срез в его эквивалент, используя длину последовательности. На примере будет понятнее.
Возьмем срез 3:9:2 из примера выше. Он эквивалентен slice(3, 9, 2). Вы можете преобразовать этот срез в эквивалентный для списка длиной 6:
required_slice = slice(3, 9, 2) required_slice.indices(6) # (3, 6, 2) numbers = [0, 1, 2, 3, 4, 5] numbers[3:6:2] print(numbers[3:6:2]) # [3, 5]
Метод .indices() с аргументом 6, длиной новой последовательности, возвращает идеальные значения start, stop и step для воссоздания этого среза для последовательности длиной 6. Значение stop теперь равно 6, поскольку самый высокий индекс в списке равен 5.
Давайте попробуем это с более коротким списком:
numbers = [0, 1, 2] required_slice.indices(3) # (3, 3, 2) numbers[3:3:2] # []
Я не буду больше останавливаться на .indices(). Вы ничего не потеряете, если забудете об этом методе!
Итераторный срез
Я закончу эту статью кратким упоминанием о «кузене» объекта slice, объекте islice(), который является частью модуля itertools. Когда вы создаете срез последовательности, вы получаете объект того же типа данных. Срез списка — это другой список, а срез строки — это тоже строка. Однако вы можете использовать itertools.islice(), чтобы получить итератор. Я снова воспользуюсь списком my_favorite_numbers:
my_favorite_numbers = [0, 1, 1.414, 1.618, 2.718, 3.142, 42, 49, 1024, 299792]
import itertools
some_slice = itertools.islice(my_favorite_numbers, 2, 7)
some_slice
# <itertools.islice object at 0x7fa435d1a5e0>
# Убедимся, что "some_slice" - итератор
import collections.abc
isinstance(some_slice, collections.abc.Iterator)
# True
# Получаем из итератора по одному значению за раз
next(some_slice)
# 1.414
next(some_slice)
# 1.618
next(some_slice)
# 2.718
next(some_slice)
# 3.142
next(some_slice)
# 42
next(some_slice)
# Traceback (most recent call last):
...
StopIteration
Функция islice() возвращает итератор, эквивалентный срезу 2:7. Первый аргумент в islice() — это итерируемый объект, срез которого вы хотите получить. Второй и третий аргументы — начальный и конечный индексы среза. Можно добавить еще один аргумент, если не хотите использовать шаг по умолчанию, равный 1.
Первым элементом, возвращаемым итератором, является элемент с индексом 2. Элементы возвращаются последовательно, поскольку используется шаг по умолчанию, равный 1. Однако после возврата элемента с индексом 6, который является числом 42, итератор исчерпывается. При следующем вызове next() генерируется исключение StopIteration. Напомним, что значение с конечным индексом 7 не включается в срез.
Еще одно отличие между обычным срезом и islice() заключается в том, что в islice() нельзя использовать отрицательные значения для значений start, stop и step.
Заключительные слова
В этой статье я рассмотрел некоторые малоизвестные особенности и причуды срезов в Python. Возможно, некоторые из этих техник пригодятся вам в вашем коде. Но даже если это не так, вы лучше поймете, что происходит при создании среза списка или другой последовательности. Следите за тем, возвращает ли срез копию или представление исходной последовательности, особенно при создании срезов типов данных, отличных от встроенных.

