О коллекциях в python#

Разнообразие коллекций#

В python в любой момент времени имеется доступ к нескольким видам коллекций, среди которых:

  • list — списки;

  • tuple — кортежи;

  • range — диапазоны;

  • str — строки;

  • bytearray и bytes — изменяемый и неизменяемый массивы байтов;

  • set и frozenset — изменяемые и неизменяемые множества;

  • dict — словари (ассоциативные массивы, хеш таблицы).

Ещё больше коллекций доступно, если брать в расчет модули стандартной библиотеки:

А ещё ведь существуют разнообразные коллекции из сторонних библиотек. Может показаться, что познакомиться со всеми коллекциями в python — непосильная задача. Однако можно значительно упростить себе задачу, если выявить между некоторыми коллекциями сходства. Благо сам python выделяет среди коллекций некоторую иерархию, которая располагает все свои встроенные коллекции и многие сторонние по полочкам.

Базовые понятия#

Для начала введём три самых базовых понятия с точки зрения python, которые позволят четко сформулировать, что понимается под коллекцией в python.

Контейнер. Container#

Объект считается контейнером (Container), если у него можно спросить, содержит ли он какой-то произвольный элемент x (does it contain x?). Иными словами, если A контейнер, то для любого x следующее выражение должно вычисляться без ошибок и возвращать булевое значение.

x in A 

Note

Строго говоря, тип считается контейнером, если у него перегружен метод __contains__, что и позволяет его использовать справа от ключевого слова in. Однако, если объект не является контейнером, но является итерируемым объектом, то этот объект все равно можно использовать справа от ключевого слова in. Такой объект, тем не менее, формально не считается контейнером.

Например, списки являются контейнерами.

print(0 in [1, 0, 2])
True

Tip

Тест на принадлежность списку работает очень медленно. В большинстве ситуаций, если требуется часто проверять наличие элемента в коллекции, быстрее всего отработают множества (set), которые используют хеш-таблицу для быстрого теста на принадлежность.

Итерируемый объект. Iterable#

Объект считается итерируемым (Iterable), если по нему можно пробежаться в цикле (iterate over). Т.е. A — итерируемый объект, если можно написать следующее выражение.

for x in A: 
    ...

Из изученных на первом занятии типов, списки и диапазоны являются итерируемыми объектами, а числа — нет.

Многие python функции ожидают в качестве аргумента итерируемый объект, т.к. возможности пробежаться по элементам итерируемого объекта достаточно для выполнения большинства операций. Например, из итерируемого объекта можно создать список функцией list, к нему можно поэлементно применить функцию функцией map, отфильтровать значения функцией filter, а при выполнении требования на однородность элементов можно искать минимальное и максимальное значения функциями min и max, или получать упорядоченный список элементов функцией sorted.

Кроме этого, из итерируемости объекта следует, что его допустимо использовать справа от ключевого слова in: если python видит, что объект целенаправленно не реализует интерфейс контейнера, то при тесте на принадлежность python пытается воспользоваться итерируемостью объекта. Действительно, можно в цикле сравнить каждый элемент итерируемого объекта с запрашиваем значением, и если хоть один совпал, вернуть истину. Ниже приводится функция contains, иллюстрирующая данную идею.

def contains(element, iterable):
    for x in iterable:
        if x == element:
            return True
    return False

В настоящем коде достаточно написать element in iterable, а python сам сгенерирует схожий код.

Warning

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

Объект ограниченной длины. Sized#

Объект считается объектом ограниченной длины (Sized), если у него можно спросить количество элементов (длина, размер, size) функцией len. Т.е. A — объект ограниченной длинны, если len(A) вычисляется без ошибок и возвращает целое неотрицательное число.

Все коллекции в python удовлетворяют этому свойству, поэтому функцию len можно использовать с любой из них.

print(len((1, 2, 3)))
print(len([1, 2, 3]))
print(len("abc"))
print(len(range(3)))
3
3
3
3

Пример: контейнер, но не итерируемый и без длины#

В качестве примера рассмотрим класс Interval, экземпляры которого должны репрезентировать в программе математические интервалы, т.е. множества вида \((a, b)=\{x\in\mathbb{R}\mid a<x<b\}\). Кроме наличия двух действительнозначных атрибутов a и b, отвечающих за границы интервала, логично для этого типа Interval перегрузить действие оператора in таким образом, чтобы оно проверяло вхождение числа слева от него в интервал справа от него.

Ниже приводится возможное определение класса Interval, удовлетворяющее обозначенным требованиям. Пока можете не обращать внимание на эту ячейку, объявление пользовательских классов будет обсуждаться значительно позже.

from collections.abc import Container
from dataclasses import dataclass


@dataclass
class Interval(Container):
    a: float
    b: float

    def __contains__(self, x: float) -> bool:
        return self.a < x < self.b

interval = Interval(0, 1)
print(interval)
Interval(a=0, b=1)

Помимо самого объявления нового типа Interval, в ячейке выше также создан один экземпляр interval с параметрами a=0 и b=1, который соответствует интервалу \((0, 1)\). Этот объект теперь можно использовать для определения принадлежности любого числа x интервалу \((0, 1)\) синтаксисом x in interval.

numbers = (-0.5, 0.5, 1.5)

for x in numbers:
    contains = x in interval
    print(f"{x=}: {contains=}")
x=-0.5: contains=False
x=0.5: contains=True
x=1.5: contains=False

С формальной точки зрения такой объект является контейнером. Теперь зададимся вопросами:

  1. можно ли поставить в соответствие интервалу \((a, b)\) какое-то разумное значение длины?

  2. можно ли в каком-то разумном порядке пробежаться по числам из интервала \((a, b)\)?

Ответ на первый вопрос — нет. Функция len должна возвращать неотрицательное целое число, т.к. её смысл — количество элементов. Известно, что в отрезке \((a, b)\) находится бесконечное количество точек, поэтому len не может возвращать количество точек внутри отрезка (значение +inf может принимать только float в python). Математическая длина отрезка \(b-a\) тоже не подходит, т.к. в общем случае эта величина является не целым числом (да и противоречило бы изначальному смыслу, вкладываемому в функцию len). В связи с этим разумно заключить, что Interval не является объектом ограниченной длины.

Ответ на второй вопрос — нет. Мало того, что внутри отрезка \((a, b)\) находится бесконечное множество чисел, так это множество ещё и не является счетным (более конкретно, оно является континуумом), что как раз и значит, что даже теоретически невозможно поставить в соответствие каждому числу из интервала \((a, b)\) уникальное натуральное число (его порядковый номер). В связи с этим разумно заключить, что Interval не является итерируемым объектом.

Итого, такой объект имеет все полномочия называться контейнером, но он не удовлетворяет определениям итерируемого объекта и объекта ограниченной длины.

from collections.abc import Container, Iterable, Sized

print(isinstance(interval, Container))
print(isinstance(interval, Iterable))
print(isinstance(interval, Sized))
True
False
False

Пример: итерируемый, но не контейнер и безграничный#

Забегая вперед, можно привести пример объекта, по которому можно пробежаться в цикле, но который не является контейнером и не имеет определенного размера. В python есть специальный тип генераторов, для создания которых даже предусмотрен свой специальный синтаксис (см. Генераторы).

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

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

Tip

Примером бесконечного генератора является itertools.count, который генерирует бесконечную арифметическую последовательность.

Внизу объявляется генератор generator (обратите на наличие ключевого слова yield), и демонстрируется, что по нему можно пробежаться в цикле.

def generator(n):
    yield from range(n)

for i in generator(10):
    print(i, end=" ")
0 1 2 3 4 5 6 7 8 9 

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

len(generator(10))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[8], line 1
----> 1 len(generator(10))

TypeError: object of type 'generator' has no len()

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

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

g = generator(10)
print(0 in g)
print(0 in g)
True
False

Кроме того, использование бесконечно генератора справа от ключевого слова in запустит бесконечный цикл. Ну и наконец, наиболее формальный ответ: у генераторов не перегружен метод __contains__, что отнимает у них право называться контейнером.

Итого, генераторы являются итерируемыми объектами, но не контейнерами и не объектами ограниченной длины.

from collections.abc import Container, Iterable, Sized

print(isinstance(g, Container))
print(isinstance(g, Iterable))
print(isinstance(g, Sized))
False
True
False

Коллекции#

Коллекция — объект, который одновременно является контейнером, итерируемым объектом и объектом ограниченный длинны.

../../_images/collections_venn.svg

С бытовой точки зрения, коллекция — нечто, хранящее в себе совокупность объектов.

  • Эта совокупность не может быть бесконечной, т.к. она физически хранится внутри коллекции, а значит должна быть ограниченного размера, т.е. допускает вызов функции len.

  • Т.к. эта совокупность ограничена, то мы всегда можем пробежаться по её элементам (например, в случайном порядке, хотя нередко этот порядок возникает естественным образом), а значит она должна являться итерируемым объектом.

  • Т.к. мы всегда можем пробежаться по её элементам, то мы всегда можем проверить наличия произвольного элемента в коллекции (например, просто сравнив каждый элемент коллекции на равенство в цикле), т.е. коллекция должна являться контейнером.

Разработчики языка python смотрят на эту иерархию понятий, как на иерархию классов, где абстрактный класс Collection наследуется от всех трех выше обозначенных абстрактных классов: Container, Iterable и Sized.

../../_images/collections_hierarchy.svg
from collections.abc import Collection, Container, Iterable, Sized

print(issubclass(Collection, Container))
print(issubclass(Collection, Iterable))
print(issubclass(Collection, Sized))
True
True
True

Note

Более конкретно, каждый абстрактный класс в иерархии задаёт интерфейс: только объекты, удовлетворяющие заданному интерфейсу, можно считать экземплярами этого класса. Все производные классы тоже обязаны удовлетворять этому интерфейсу, а значит экземпляры класса Collection должны одновременно удовлетворять интерфейсам классов Container, Iterable и Sized.

Дальнейшее ветвление в иерархии#

Выше было перечисленно сравнительно большое количество объектов, являющихся коллекциями. Дальнейшая их классификация обычно производится по способу получения доступа к элементам. По такому принципу можно выделить два наиболее широких класса — последовательности (Sequence) и отображения (Mapping), хотя ещё существуют множества (Set) и другие.

../../_images/sequence_vs_mapping.svg
from collections.abc import Collection, Sequence, Mapping

print(issubclass(Sequence, Collection))
print(issubclass(Mapping, Collection))
True
True

Про последовательности речь пойдет на следующей странице, а среди класса Mapping мы познакомимся только с одним представителем, но несколько позже (см. Словари. dict).