ПРОГРАММИРОВАНИЕ НА ПИТОНЕ

Кортеж Python, вся правда и только правда: давайте копнем глубже

Изучите тонкости кортежей.

В предыдущей статье мы обсудили основы кортежей:



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

  • Тонкости кортежа: эффект неизменности при копировании кортежа и подсказка типа кортежа.
  • Наследование от кортежа.
  • Производительность кортежа: время выполнения и память.
  • Преимущества кортежей перед списками (?): ясность, производительность и кортежи как ключи словаря.
  • Понимание кортежей (?)
  • Именованные кортежи

Тонкости кортежа

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

Влияние неизменности на копирование кортежей

Это будет весело!

Теоретик, вероятно, закричал бы мне, что существует только одна неизменяемость кортежей, та, которую мы обсуждали в предыдущей статье. Что ж, это правда, но… но сам Python проводит различие между двумя разными типами неизменности! И Python должен проводить это различие. Это связано с тем, что хэшируется только действительно неизменный объект. В приведенном ниже коде вы увидите, что первый кортеж хешируется, а второй нет:

>>> hash((1,2))
-3550055125485641917
>>> hash((1,[2]))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'

Независимо от того, является ли объект хешируемым, это влияет на разные вещи — и именно поэтому Python различает хэшируемые и нехэшируемые кортежи; первые - это то, что я называю действительно неизменяемыми кортежами. Я покажу вам, как Python обрабатывает как копирование кортежей, так и использование кортежей в качестве ключей словаря.

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

>>> import copy
>>> a = (1, 2, 3)
>>> b = a
>>> c = tuple(a)
>>> d = a[:]
>>> e = copy.copy(a)     # a shallow copy
>>> f = copy.deepcopy(a) # a deep copy

Поскольку a — полностью неизменяемый кортеж, исходный кортеж (a) и все его копии должны указывать на один и тот же объект:

>>> a is b is c is d is e is f
True

Как и ожидалось — и как должно быть в случае действительно неизменяемого типа — все эти имена указывают на один и тот же объект; их id одинаковы. Это то, что я называю истинной или полной неизменностью.

Теперь проделаем то же самое с кортежем второго типа; то есть кортеж с одним или несколькими изменяемыми элементами:

>>> import copy
>>> a = ([1], 2, 3)
>>> b = a
>>> c = tuple(a)
>>> d = a[:]
>>> e = copy.copy(a)     # a shallow copy
>>> f = copy.deepcopy(a) # a deep copy

Копии от b до e неглубокие, поэтому они будут ссылаться на тот же объект, что и исходное имя:

>>> a is b is c is d is e
True

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

>>> a is f
False

Первый элемент (по индексу 0) кортежа — [1], поэтому он изменчив. Когда мы создали поверхностные копии a, первые элементы кортежей с a по e указывали на один и тот же список:

>>> a[0] is b[0] is c[0] is d[0] is e[0]
True

но создание глубокой копии означало создание нового списка:

>>> a[0] is f[0]
False

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

>>> d = {}
>>> d[(1, 2)] = 3
>>> d[(1, [2])] = 4
Traceback (most recent call last):
    ...
TypeError: unhashable type: 'list'

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

Итак, если кто-то скажет вам, что существует только один тип неизменяемости кортежей Python, вы будете знать, что это не совсем так, поскольку существует два вида неизменяемости кортежей:

  • полностью неизменяемые кортежи, содержащие только неизменяемые элементы; это неизменность с точки зрения как ссылок, так и значений;
  • неизменяемые кортежи с точки зрения ссылок, но не значений, то есть кортежи, содержащие изменяемые элементы.

Если их не различить, вы не сможете понять, как работает копирование кортежей.

Подсказка типа кортежа

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



Здесь давайте кратко обсудим, как работать с подсказками типов для кортежей. Я покажу современную версию кортежей подсказок, то есть Python 3.11. Однако, поскольку подсказки типов динамически менялись, имейте в виду, что в старых версиях Python не все работало одинаково.

Начиная с Python 3.9 все стало проще, так как вы можете использовать встроенный тип tuple с полями, указанными в квадратных скобках []. Ниже приведены несколько примеров того, что вы можете сделать.

tuple[int, ...], tuple[str, ...] и т.п.
Это означает, что объект является кортежем из int / str / и т.п. элементов любой длины. Многоточие ... сообщает, что кортеж может иметь любую длину; нет никакого способа исправить это.

tuple[int | float, ...]
Как и выше, но кортеж может содержать как int, так и float элементов.

tuple[int, int]
В отличие от приведенного выше, этот кортеж представляет собой запись двух элементов, оба из которых являются целыми числами.

tuple[str, int|float]
Снова запись из двух элементов, первый из которых является строкой, а второй — целым числом или числом с плавающей запятой.

tuple[str, str, tuple[int, float]]
Запись с тремя элементами, первые два из которых являются строками, а третий — двухэлементным кортежем из целого числа и числа с плавающей запятой.

tuple[Population, Area, Coordinates]
Это конкретная запись, содержащая три элемента определенных типов. Эти типы, Population, Area, Coordinates, являются либо именованными кортежами, либо типами данных, определенными ранее, либо псевдонимами типов. Как я объяснял в вышеупомянутой статье, использование таких псевдонимов типов может быть намного более читабельным, чем использование встроенных типов, таких как int, float и им подобных.

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

Наследование от tuple

Вы можете наследовать от list, хотя иногда лучше наследовать от collections.UserList. Итак, может быть, мы можем сделать то же самое с кортежем? Можем ли мы наследовать от класса tuple?

По сути, забудьте о создании общего типа, похожего на кортеж. У tuple нет собственного метода .__init__(), поэтому нельзя делать то, что можно при наследовании от списка, то есть нельзя вызывать super().__init__(). А без этого вы остаетесь почти ни с чем, поскольку класс tuple вместо этого наследует object.__init__().

Тем не менее, это не означает, что вы вообще не можете наследовать от tuple. Можно, но создавать не общий тип, а конкретный. Вы помните класс City? Мы можем сделать что-то подобное с кортежем, но имейте в виду, что это будет неинтересно.

>>> class City(tuple):
...    def __new__(self, lat, long, population, area):
...        return tuple.__new__(City, (lat, long, population, area))

У нас есть кортежеподобный класс City:

>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2)
>>> Warsaw
(52.2297, 21.0122, 1765000, 517.2)
>>> Warsaw[0]
52.2297

Этот класс принимает ровночетыре аргумента, не меньше и не больше:

>>> Warsaw = City(52.2297, 21.0122, 1_765_000)
Traceback (most recent call last):
    ...
TypeError: __new__() missing 1 required positional argument: 'area'
>>> Warsaw = City(52.2297, 21.0122, 1_765_000, 517.2, 50)
Traceback (most recent call last):
    ...
TypeError: __new__() takes 5 positional arguments but 6 were given

Обратите внимание, что в текущей версии мы можем использовать имена аргументов, но не обязаны, так как они позиционные:

>>> Warsaw_names = City(
...     lat=52.2297,
...     long=21.0122,
...     population=1_765_000,
...     area=517.2
... )
>>> Warsaw == Warsaw_names
True

Но мы не можем получить доступ к значениям по именам:

>>> Warsaw.area
Traceback (most recent call last):
    ...
AttributeError: 'City' object has no attribute 'area'

Мы можем изменить это двумя способами. Один из них — использование именованного кортежа из модуля collections или typing; мы обсудим их в ближайшее время. Но мы можем добиться того же эффекта, используя наш класс City, благодаря модулю operator:

>>> import operator
>>> City.lat = property(operator.itemgetter(0))
>>> City.long = property(operator.itemgetter(1))

И теперь мы можем получить доступ к атрибутам lat и long по имени:

>>> Warsaw.lat
52.2297
>>> Warsaw.long
21.0122

Однако, поскольку мы сделали это только для lat и long, мы не сможем получить доступ к population и area по имени:

>>> Warsaw.area
Traceback (most recent call last):
    ...
AttributeError: 'City' object has no attribute 'area'

Мы, конечно, можем это изменить:

>>> City.population = property(operator.itemgetter(2))
>>> City.area = property(operator.itemgetter(3))
>>> Warsaw.population
1765000
>>> Warsaw.area
517.2

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

Производительность кортежа

Время исполнения

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

В целом список был всегда быстрее, независимо от его размера и выполняемой операции. Я часто слышал, что одной из причин создания кортежей было меньшее потребление памяти. Наш небольшой эксперимент далек от подтверждения этой идеи. Хотя иногда кортежи использовали немного меньше памяти, обычно они использовали немного больше. Поэтому я провел эксперимент для очень длинных списков и кортежей из 5 млн и 10 млн целочисленных элементов. И опять же, списки обычно потребляют меньше памяти…

Итак, где это небольшое потребление памяти кортежами? Возможно, это связано с тем, сколько места на диске занимает кортеж и соответствующий ему список? Давай проверим:

>>> from pympler.asizeof import asizeof
>>> for n in (3, 10, 100, 1000, 1_000_000, 5_000_000, 10_000_000):
...     print(
...         f"tuple, n of {n: 9}: {asizeof(tuple(range(n))):10d}"
...         "\n"
...         f" list, n of {n: 9}: {asizeof(list(range(n))):10d}"
...         "\n"
...         f"{'-'*33}"
...         )
tuple, n of         3:        152
 list, n of         3:        168
---------------------------------
tuple, n of        10:        432
 list, n of        10:        448
---------------------------------
tuple, n of       100:       4032
 list, n of       100:       4048
---------------------------------
tuple, n of      1000:      40032
 list, n of      1000:      40048
---------------------------------
tuple, n of   1000000:   40000032
 list, n of   1000000:   40000048
---------------------------------
tuple, n of   5000000:  200000032
 list, n of   5000000:  200000048
---------------------------------
tuple, n of  10000000:  400000032
 list, n of  10000000:  400000048
---------------------------------

Только в случае небольших кортежей и соответствующих им списков разница в использовании памяти заметна — как, например, 152 против 168. Но я думаю, вы согласитесь со мной, что 400_000_032 на самом деле не намного меньше, чем 400_000_048, не так ли?

Есть еще одна вещь, которую я наблюдал в своих прошлых экспериментах (код не представлен). Литералы кортежей обрабатываются компилятором Python исключительным образом, поскольку он хранит их в статической памяти — поэтому они создаются во время компиляции. Ни списки, ни кортежи, созданные каким-либо другим способом, нельзя хранить в статической памяти — они всегда используют динамическую память, а значит, создаются во время выполнения. Эта тема достаточно сложна, чтобы заслужить отдельную статью, так что давайте остановимся здесь.

Я оставлю вас здесь с этими тестами. Если вы хотите продлить их, пожалуйста. Если вы узнаете что-то новое и неожиданное, пожалуйста, поделитесь этим в комментариях.

Что я узнал, так это то, что кортежи почти никогда не следует использовать только из-за их производительности. Но на самом деле кортежи могут быть интересным выбором, если нам нужен простой тип для хранения очень маленьких записей, например, состоящих из двух или трех элементов. Однако, если бы имена полей могли помочь, и для большего количества полей я бы предпочел использовать что-то другое, именованный кортеж был бы одним из вариантов, а dataclasses.dataclass — другим.

Преимущества кортежей перед списками (?)

В Fluent Python Л. Рамальо упоминает два преимущества кортежа перед списком: ясность и производительность. Честно говоря, я не могу найти другого преимущества, но этих двух может быть достаточно. Итак, давайте обсудим их один за другим и решим, действительно ли они делают кортежи лучше, чем списки, по крайней мере, в некоторых аспектах.

Ясность

Как пишет Л. Рамальо, когда вы используете кортеж, вы знаете, что его длина никогда не изменится — и это повышает ясность кода. Мы уже обсуждали, что может произойти с длиной кортежа. Действительно, ясность благодаря неизменности — это здорово, и мы знаем, что длина любого кортежа никогда не изменится, но…

Как предупреждает сам Л. Рамальо, кортеж с изменяемыми элементами может быть источником ошибок, которые трудно найти. Вы помните, что я упоминал выше в отношении операций на месте? С одной стороны, мы можем быть уверены, что кортеж, скажем, x, никогда не изменит своей длины. Это ценная информация с точки зрения ясности, я согласен. Однако, когда мы выполняем операции на месте над x, этот кортеж перестанет быть тем же самым кортежем, даже если он останется кортежем с именем x, но, повторюсь, другим кортежем по имени x.

Таким образом, мы должны пересмотреть вышеуказанное преимущество ясности следующим образом:

  • Мы можем быть уверены, что кортеж определенногоidникогда не изменит свою длину.

Or:

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

Звучит немного безумно? Полностью согласен: это сумасшествие. Для меня это не ясность; это противоположно ясности. Кто-нибудь так думает? Представьте, что у вас есть функция, в которой вы определяете кортеж x. Затем вы выполняете конкатенацию на месте, например, x += y, так что выглядит так, как будто y осталось нетронутым, а x изменилось. Мы знаем, что это неправда — исходного x больше не существует, а у нас есть новый x — но он выглядит именно так, особенно потому, что у нас все еще есть кортеж x, первыми элементами которого являются те самые, которые составляли исходный кортеж x.

Конечно, я знаю, что все это имеет смысл с точки зрения Python. Но когда я кодирую, я не хочу, чтобы мои мысли были заняты таким образом. Чтобы код был ясным, я предпочитаю, чтобы он был ясным без необходимости делать такие предположения. И именно поэтому для меня кортежи не означают ясности; они означают меньшую ясность, чем я вижу в списках.

Это не все в контексте ясности кортежа. С точки зрения кода, есть одна вещь, которая мне особенно нравится в списках, но не нравится в кортежах. Квадратные скобки [], используемые для создания списков, позволяют им выделяться в коде, поскольку нет другого контейнера, в котором использовались бы квадратные скобки. Посмотрите на словари: они используют фигурные скобки {}, и их тоже можно использовать в наборах. В кортежах используются круглые скобки (), и они используются не только в выражениях генератора, но и во многих других местах кода, поскольку код Python использует круглые скобки для самых разных целей. Поэтому мне нравится, как списки выделяются в коде, и мне не нравится, как нет кортежи.

Производительность

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

Это знание в сочетании с лучшей производительностью списков с точки зрения времени выполнения заставляет меня думать, что производительность не делает кортежи лучшим выбором. С точки зрения производительности с точки зрения времени выполнения списки лучше. С точки зрения использования памяти кортежи действительно могут быть лучше, но в наши дни, с современными компьютерами, различия очень малы. Кроме того, когда мне нужен действительно эффективный по памяти контейнер для сбора большого количества данных, я бы выбрал не список и не кортеж, а генератор.

Еще одна вещь: кортежи как словарные ключи

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

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

Понимание кортежей (?)

Если вы надеетесь узнать из этого раздела, что в Python есть кортежное понимание, или если вы надеетесь узнать что-то удивительное, что взорвет умы ваших коллег-питоновцев — мне очень жаль! Я не хотел создавать ложных надежд. Сегодня нет понимания кортежей; нет умопомрачительного синтаксиса.

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



Это потому, что нет понимания кортежей. Но так как я не хочу оставлять вас ни с чем, то у меня есть для вас утешительный подарок. Я покажу вам несколько заменителей кортежей.

Прежде всего, помните, что генераторное выражение не является кортежем. Я думаю, что многие новички в Python совершают ошибку, путая их. Я особенно помню, как увидел свое первое генераторное выражение после изучения понимания списка. Моей первой мыслью было: «Ага, вот оно. Понимание кортежа». Я быстро понял, что хотя первое из этих двух действительно было пониманием списка, второе не было пониманием кортежа:

>>> listcomp = [i**2 for i in range(7)] # a list comprehension
>>> genexp = (i**2 for i in range(7))   # NOT a tuple comprehension

Я потратил некоторое время — если не впустую — только на то, чтобы узнать, что есть списки, множества, словари и генераторы выражений, но не кортежи. Не повторяй мою ошибку. Не тратьте часы на поиск понятий кортежей. В Питоне их нет.

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

Замените 1: tuple() + genexp

>>> tuple(i**2 for i in range(7))
(0, 1, 4, 9, 16, 25, 36)

Вы заметили, что вам не нужно сначала создавать понимание списка, а затем кортеж? Действительно, здесь мы создаем генераторное выражение и используем к нему класс tuple(). Это, конечно, дает нам кортеж.

Замена 2: genexp + распаковка генератора

>>> *(i**2 for i in range(7)),
(0, 1, 4, 9, 16, 25, 36)

Хороший лайфхак, не так ли? Он использует расширенную распаковку итерируемых объектов, которая возвращает кортеж. Вы можете использовать его для любого итерируемого объекта, и, поскольку генератор один, он работает! Давайте проверим, работает ли это также для списка:

>>> x = [i**2 for i in range(7)]
>>> *x,
(0, 1, 4, 9, 16, 25, 36)

Вы можете сделать то же самое, не присваивая x:

>>> *[i**2 for i in range(7)],
(0, 1, 4, 9, 16, 25, 36)

Это будет работать для любого итерируемого объекта, но не забывайте про запятую в конце строки; без него фокус не получится:

>>> *[i**2 for i in range(7)]
  File "<stdin>", line 1
SyntaxError: can't use starred expression here

Давайте проверим наборы:

>>> x = {i**2 for i in range(7)}
>>> *x,
(0, 1, 4, 9, 16, 25, 36)

Оно работает! И обратите внимание, что обычно при распаковке получается кортеж. Вот почему расширенная итерируемая распаковка немного похожа на понимание кортежа. Хотя это и выглядит как небольшой лайфхак, это не так: это один из инструментов, которые предлагает Python, хотя это действительно крайний случай.

Но я бы неиспользовал замену 2. Я определенно выбрал бы замену 1, в которой используется tuple(). Большинству из нас нравятся такие трюки, как вторая замена, но они редко бывают ясны, а замена 2, в отличие от замены 1, далеко не ясна. Тем не менее, любой Pythonist увидит, что происходит в substitute 1, даже если он не увидит, что в промежуточном шаге спрятано выражение генератора.

Именованные кортежи

Кортежи не имеют имен, но это не означает, что в Python нет именованных кортежей. Наоборот, есть — и, что неудивительно, они называются… именованными кортежами.

У вас есть две возможности использовать именованные кортежи: collections.namedtuple и typing.NamedTuple. Именованные кортежи — это то, что предполагает их имя: кортежи, элементы которых (называемые полями) имеют имена. Вы можете увидеть первый в действии в Приложении, в скрипте бенчмаркинга.

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

Таким образом, хотя я часто выбираю обычный кортеж, иногда решаю выбрать именованный кортеж — и это именно из-за его наглядности.

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

Заключение

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

Честно говоря, начиная писать о кортежах, я думал, что найду в них больше плюсов. Я использую их с первого дня, когда начал использовать Python. Хотя я использовал списки гораздо чаще, мне почему-то нравились кортежи, хотя я не слишком много о них знал — так что часть информации, которую я включил в эту статью, была для меня новой.

Однако после написания этой статьи я перестал быть большим поклонником кортежей. Я по-прежнему считаю их ценным типом для небольших записей, хотя довольно часто их расширение — именованные кортежи — или классы данных кажутся лучшим подходом. Более того, кортежи не кажутся слишком эффективными. Они медленнее, чем списки, и используют лишь немного меньше памяти. Итак, почему я должен их использовать?

Может из-за их неизменности? Может быть. Если вам нравится функциональное программирование, основанное на концепции неизменности, вы определенно предпочтете кортежи спискам. Я использовал этот аргумент не раз и не два, чтобы убедить себя, что я должен предпочесть кортеж списку в той или иной ситуации.

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

>>> x = (1, 2, 'Zulu Minster', )
>>> y = (4, 4, )
>>> x += y
>>> x
(1, 2, 'Zulu Minster', 4, 4)
>>> x *= 2
>>> x
(1, 2, 'Zulu Minster', 4, 4, 1, 2, 'Zulu Minster', 4, 4)

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

>>> x = x + y

поскольку это означает присвоение x + y x, что в основном означает перезапись этого имени. Если вы решите перезаписать предыдущее значение x, это ваш выбор. Но операции на месте, по крайней мере, в моих глазах, не дают ощущения неизменности. Я бы предпочел не сделать это на Python.

Если не неизменность, то, может быть, что-то еще должно убедить меня чаще использовать кортежи? Но что? Производительность? Производительность кортежей низкая, так что это меня не убеждает. Что касается времени выполнения, то здесь нет обсуждения; они определенно медленнее, чем соответствующие списки. Вы можете сказать, что с точки зрения памяти. Действительно, они занимают меньше места на диске, но разница невелика, а для длинных контейнеров — вообще незначительна. Использование оперативной памяти? Этот аргумент также оказался не слишком удачным, потому что в целом списки оказались столь же эффективными, как и кортежи, а иногда даже более эффективными. А если у нас огромная коллекция, то с точки зрения памяти лучше подойдет генератор.

Несмотря на все это, у кортежей есть свое место в Python. Они очень часто используются для возврата двух или трех элементов из функции или метода — то есть в виде небольших безымянных записей. Они используются как результат итерируемой распаковки. И они составляют основу именованных кортежей — collections.namedtuple и typing.NamedTuple — мощных братьев и сестер кортежа, которые можно использовать как записи с именованными полями.

В общем, я не так сильно люблю кортежи, как до написания этой статьи. Я рассматривал их как важный тип Python; теперь это не так важно в моих глазах, как было раньше — но я принимаю их различные варианты использования в Python, и некоторые из них мне даже нравятся.

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

Спасибо за чтение. Если вам понравилась эта статья, вам могут понравиться и другие статьи, которые я написал; вы увидите их здесь. Если вы хотите присоединиться к Medium, воспользуйтесь моей реферальной ссылкой ниже:



Ресурсы







Приложение

В этом приложении вы найдете сценарий, который я использовал для сравнения кортежей со списками. Я использовал пакет perftester, о котором вы можете прочитать в этой статье:



Это код:

import perftester

from collections import namedtuple
from typing import Callable, Optional
Length = int

TimeBenchmarks = namedtuple("TimeBenchmarks", "tuple list better")
MemoryBenchmarks = namedtuple("MemoryBenchmarks", "tuple list better")
Benchmarks = namedtuple("Benchmarks", "time memory")


def benchmark(func_tuple, func_list: Callable,
              number: Optional[int] = None) -> Benchmarks:
    # time
    t_tuple = perftester.time_benchmark(func_tuple, Number=number)
    t_list = perftester.time_benchmark(func_list, Number=number)
    better = "tuple" if t_tuple["min"] < t_list["min"] else "list"
    time = TimeBenchmarks(t_tuple["min"], t_list["min"], better)
    
    # memory
    m_tuple = perftester.memory_usage_benchmark(func_tuple)
    m_list = perftester.memory_usage_benchmark(func_list)
    better = "tuple" if m_tuple["max"] < m_list["max"] else "list"
    memory = MemoryBenchmarks(m_tuple["max"], m_list["max"], better)
    
    return Benchmarks(time, memory)


def comprehension(n: Length) -> Benchmarks:
    """List comprehension vs tuple comprehension.
    
    Here, we're benchmarking two operations:
      * creating a container
      * looping over it, using a for loop; nothing is done in the loop.
    """
    def with_tuple(n: Length):
        x = tuple(i**2 for i in range(n))
        for _ in x:
            pass
    
    def with_list(n: Length):
        x = [i**2 for i in range(n)]
        for _ in x:
            pass
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(n), lambda: with_list(n), number)


def empty_container() -> Benchmarks:
    """List vs tuple benchmark: creating an empty container."""
    return benchmark(lambda: tuple(), lambda: [], number=100_000)


def short_literal() -> Benchmarks:
    """List vs tuple benchmark: tuple literal."""
    return benchmark(lambda: (1, 2, 3), lambda: [1, 2, 3], number=100_000)


def long_literal() -> Benchmarks:
    """List vs tuple benchmark: tuple literal."""
    return benchmark(
        lambda: (1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,),
        lambda: [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,
1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3,],
        number=100_000)


def func_with_range(n: Length) -> Benchmarks:
    """List vs tuple benchmark: func(range(n))."""
    def with_tuple(n: Length):
        return tuple(range(n)) 
    
    def with_list(n: Length):
        return list(range(n))
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(n), lambda: with_list(n), number)


def concatenation(n: Length) -> Benchmarks:
    """List vs tuple benchmark: func(range(n))."""
    def with_tuple(x: tuple):
        x += x
        return x
    
    def with_list(y: list):
        y += y
        return y
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(tuple(range(n))),
                     lambda: with_list(list(range(n))),
                     number)


def repeated_concatenation(n: Length) -> Benchmarks:
    """List vs tuple benchmark: func(range(n))."""
    def with_tuple(x: tuple):
        x *= 5
        return x
    
    def with_list(y: list):
        y *= 5
        return y
    number = int(10_000_000 / n) + 10
    return benchmark(lambda: with_tuple(tuple(range(n))),
                     lambda: with_list(list(range(n))), number)


if __name__ == "__main__":
    n_set = (3, 10, 20, 50, 100, 10_000, 1_000_000)
    functions = (
        comprehension,
        empty_container,
        short_literal,
        long_literal,
        func_with_range,
        concatenation,
        repeated_concatenation,
        )
    functions_with_n = (
        comprehension,
        func_with_range,
        concatenation,
        repeated_concatenation,
    )
    
    results = {}
    for func in functions:
        name = func.__name__
        print(name)
        if func in functions_with_n:
            results[name] = {}
            for n in n_set:
                results[name][n] = func(n)
        else:
            results[name] = func()
    perftester.pp(results)

И вот результаты:

{'comprehension': {3: Benchmarks(time=TimeBenchmarks(tuple=9.549e-07, list=8.086e-07, better='list'), memory=MemoryBenchmarks(tuple=15.62, list=15.63, better='tuple')),
                   10: Benchmarks(time=TimeBenchmarks(tuple=2.09e-06, list=1.94e-06, better='list'), memory=MemoryBenchmarks(tuple=15.64, list=15.64, better='list')),
                   20: Benchmarks(time=TimeBenchmarks(tuple=4.428e-06, list=4.085e-06, better='list'), memory=MemoryBenchmarks(tuple=15.64, list=15.65, better='tuple')),
                   50: Benchmarks(time=TimeBenchmarks(tuple=1.056e-05, list=9.694e-06, better='list'), memory=MemoryBenchmarks(tuple=15.69, list=15.69, better='list')),
                   100: Benchmarks(time=TimeBenchmarks(tuple=2.032e-05, list=1.968e-05, better='list'), memory=MemoryBenchmarks(tuple=15.7, list=15.7, better='list')),
                   10000: Benchmarks(time=TimeBenchmarks(tuple=0.002413, list=0.002266, better='list'), memory=MemoryBenchmarks(tuple=15.96, list=16.04, better='tuple')),
                   1000000: Benchmarks(time=TimeBenchmarks(tuple=0.2522, list=0.2011, better='list'), memory=MemoryBenchmarks(tuple=54.89, list=54.78, better='list'))},
 'concatenation': {3: Benchmarks(time=TimeBenchmarks(tuple=3.38e-07, list=3.527e-07, better='tuple'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   10: Benchmarks(time=TimeBenchmarks(tuple=4.89e-07, list=4.113e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   20: Benchmarks(time=TimeBenchmarks(tuple=5.04e-07, list=4.368e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   50: Benchmarks(time=TimeBenchmarks(tuple=7.542e-07, list=6.22e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   100: Benchmarks(time=TimeBenchmarks(tuple=1.133e-06, list=9.005e-07, better='list'), memory=MemoryBenchmarks(tuple=31.45, list=31.45, better='list')),
                   10000: Benchmarks(time=TimeBenchmarks(tuple=0.0001473, list=0.000126, better='list'), memory=MemoryBenchmarks(tuple=31.7, list=31.7, better='list')),
                   1000000: Benchmarks(time=TimeBenchmarks(tuple=0.04862, list=0.04247, better='list'), memory=MemoryBenchmarks(tuple=123.5, list=125.4, better='tuple'))},
 'empty_container': Benchmarks(time=TimeBenchmarks(tuple=1.285e-07, list=1.107e-07, better='list'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
 'func_with_range': {3: Benchmarks(time=TimeBenchmarks(tuple=3.002e-07, list=3.128e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
                     10: Benchmarks(time=TimeBenchmarks(tuple=4.112e-07, list=3.861e-07, better='list'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
                     20: Benchmarks(time=TimeBenchmarks(tuple=4.228e-07, list=4.104e-07, better='list'), memory=MemoryBenchmarks(tuple=23.93, list=23.93, better='list')),
                     50: Benchmarks(time=TimeBenchmarks(tuple=5.761e-07, list=5.068e-07, better='list'), memory=MemoryBenchmarks(tuple=23.93, list=23.94, better='tuple')),
                     100: Benchmarks(time=TimeBenchmarks(tuple=7.794e-07, list=6.825e-07, better='list'), memory=MemoryBenchmarks(tuple=23.94, list=23.94, better='list')),
                     10000: Benchmarks(time=TimeBenchmarks(tuple=0.0001536, list=0.000159, better='tuple'), memory=MemoryBenchmarks(tuple=24.67, list=24.67, better='list')),
                     1000000: Benchmarks(time=TimeBenchmarks(tuple=0.03574, list=0.03539, better='list'), memory=MemoryBenchmarks(tuple=91.7, list=88.45, better='list'))},
 'long_literal': Benchmarks(time=TimeBenchmarks(tuple=1.081e-07, list=8.712e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list')),
 'repeated_concatenation': {3: Benchmarks(time=TimeBenchmarks(tuple=3.734e-07, list=3.836e-07, better='tuple'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            10: Benchmarks(time=TimeBenchmarks(tuple=4.594e-07, list=4.388e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            20: Benchmarks(time=TimeBenchmarks(tuple=5.975e-07, list=5.578e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            50: Benchmarks(time=TimeBenchmarks(tuple=9.951e-07, list=8.459e-07, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            100: Benchmarks(time=TimeBenchmarks(tuple=1.654e-06, list=1.297e-06, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            10000: Benchmarks(time=TimeBenchmarks(tuple=0.0002266, list=0.0001945, better='list'), memory=MemoryBenchmarks(tuple=31.83, list=31.83, better='list')),
                            1000000: Benchmarks(time=TimeBenchmarks(tuple=0.09504, list=0.08721, better='list'), memory=MemoryBenchmarks(tuple=169.4, list=169.4, better='tuple'))},
 'short_literal': Benchmarks(time=TimeBenchmarks(tuple=1.048e-07, list=1.403e-07, better='tuple'), memory=MemoryBenchmarks(tuple=23.92, list=23.92, better='list'))}

Я решил провести тесты использования памяти для гораздо больших n, то есть 5 и 10 миллионов. Я не буду приводить код здесь, и если у вас есть свободное время, вы можете счесть хорошим упражнением написать его на основе приведенного выше сценария.

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

Вот результаты:

{'comprehension': {5000000: MemoryBenchmarks(tuple=208.8, list=208.8, better='list'),
                   10000000: MemoryBenchmarks(tuple=402.2, list=402.2, better='tuple')},
 'concatenation': {5000000: MemoryBenchmarks(tuple=285.4, list=247.2, better='list'),
                   10000000: MemoryBenchmarks(tuple=554.8, list=478.5, better='list')},
 'func_with_range': {5000000: MemoryBenchmarks(tuple=400.4, list=396.4, better='list'),
                     10000000: MemoryBenchmarks(tuple=402.2, list=402.2, better='list')},
 'repeated_concatenation': {5000000: MemoryBenchmarks(tuple=399.8, list=361.7, better='list'),
                            10000000: MemoryBenchmarks(tuple=783.7, list=707.4, better='list')}}

Как видите, для изучаемых нами операций кортежи занимают столько же или больше памяти — иногда даже значительно больше (сравните, например, 554.8 против 478.5 или 783.7 против 707.4).