Метка: кортежи

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

namedtuple

from collections import namedtuple
# новый класс User с полями name и phone
User = namedtuple('User', ['name', 'phone'])

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

Вывоз конструктора namedtuple по сути вернет новый тип с определенными настройками. Пример:

from collections import namedtuple

# новый класс User с полями name и phone
User = namedtuple('User', ['name', 'phone'])

# конкретный юзер - экземпляр
user1 = User('John', phone='+79991002030')
print(user1)  # User(name='John', phone='+79991002030')

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

Работа с namedtuple:

>> Point = namedtuple('Point', ['x', 'y'])
>> p = Point(x=11, y=22)  # создание
>>> p[0] + p[1]    # доступ по индексам
33
>>> p.x + p.y   # доступ по именам
33
>>> x, y = p   # распаковка, как обычный кортеж
>>> x, y
(11, 22)
>>> p    # читабельное repr
Point(x=11, y=22)
>>> Point._make((22, 33))  # создание из обычного кортежа или другого итерируемого объекта
Point(x=22, y=33)
>>> p._asdict()  # представление в форме словаря
{'x': 11, 'y': 22}

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

Кроме описанных выше возможностей, namedtuple позволяет также:

1. Задавать значения поле по умолчанию

2. Добавлять док-стринги (подсказки) к самому классу и отдельным полям

3. Создавать копии кортежа с заменой полей

4. Расширять функциональность путем наследования

Значения по умолчанию для namedtuple (Python 3.7+)

from collections import namedtuple
User = namedtuple('User', ['name', 'phone'])
user = User(name='Jack')  # ошибка! не указан phone!

Значения по умолчанию при создании namedtuple дают возможность не указывать одно или несколько полей в конструкторе непосредственных экземпляров. Значения по умолчанию задаются через параметр defaults (он всегда указывается явно по имени) в форме списка или кортежа длиной, обычно равной количеству полей в создаваемом namedtuple.

User = namedtuple('User', ['name', 'phone'], defaults=('NoName', 'NoPhone'))
user = User(name='Jack') 
>> user
User(name='Jack', phone='NoPhone')
>>> User()  # вообще без параметров
User(name='NoName', phone='NoPhone')

Чтобы устранить исключение о недостающих полях в конструкторе, достаточно передать defaults как кортеж из None:

fields = ('val', 'left', 'right')
Node = namedtuple('Node', fields, defaults=(None,) * len(fields))

>>> Node()
Node(val=None, left=None, right=None)

🤸 Вообще говоря, defaults могут быть и короче, чем список полей. В таком случае значения по умолчанию применяются только к самым правым полям. Чтобы было понятно, вот пример:

Node = namedtuple('Node', ('val', 'left', 'right'), defaults=(100, 200))

Так как defaults из двух элементов, а полей – три, то они применятся только к последним полям ‘left‘ и ‘right‘, а ‘val‘ останется без дефолтного значения, вынуждая нас всегда его указывать:

>>> Node()
TypeError: __new__() missing 1 required positional argument: 'val'
>>> Node(13)
Node(val=13, left=100, right=200)
>>> Node(val=42)
Node(val=42, left=100, right=200)

💣 Важно! namedtuple, как и обычный tuple является неизменяемым типом (immutable), однако, вам не запрещено в качестве поля использовать изменяемый тип, к примеру, list или dict, так как кортеж содержит лишь ссылку на объект, а за содержимое самого объекта он не отвечает. Поэтому, не рекомендуется делать значением по умолчанию списки, сеты и словари, а также пользовательские изменяемые объекты.

Посмотрите, здесь все экземпляры Group будут разделять один и тот же список по умолчанию для поля ‘users‘:

Group = namedtuple('Node', ('name', 'users'), defaults=('5B', []))  # плохо!
g = Group()
g.users.append('Vanya')  # повлияет на g2 тоже!
g2 = Group()
print(g2.users)  # ['Vanya']

Лучше указать None. Или создать отдельную функцию, которая каждый раз будет создавать новый пустой список:

def new_empty_group(name='5B'):
    return Group(name=name, users=[])

Замена поля в namedtuple

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

from collections import namedtuple
Book = namedtuple('Book', ['id', 'title', 'authors'])
book1 = Book(1, 'Игрок', 'Достоевский Ф.М.')
book2 = book1._replace(id=2, title='Преступление и наказание')
>>> book1
Book(id=1, title='Игрок', authors='Достоевский Ф.М.')
>>> book2
Book(id=2, title='Преступление и наказание', authors='Достоевский Ф.М.')

⚠️ Метод replace делает поверхностную копию данных, то есть копирует ссылки. Если со строчками и числами все будет в порядке (они скопируются), то ссылка на список будет разделяться между обоими объектами.

Документация namedtuple

Имеется возможность снабдить сам класс и его поля документацией (doc-strings):

# сам класс
Book.__doc__ += ': Hardcover book in active collection'
# его поля
Book.id.__doc__ = '13-digit ISBN'
Book.title.__doc__ = 'Title of first printing'
Book.authors.__doc__ = 'List of authors sorted by last name'

Наберите в интерпретаторе help(Book) и увидите, что у Book есть теперь описание класса и всех полей. А также по умолчанию namedtuple добавляет кучу стандартной документации по методам класса. Вот вывод подсказок о классе:

Help on class Book in module __main__:

class Book(builtins.tuple)
 |  Book(id, title, authors)
 |
 |  Book(id, title, authors): Hardcover book in active collection
 |
 |  Method resolution order:
 |      Book
 |      builtins.tuple
 |      builtins.object
 |
 |  Methods defined here:
 |
 |  __getnewargs__(self)
 |      Return self as a plain tuple.  Used by copy and pickle.
 |
 |  __repr__(self)
 |      Return a nicely formatted representation string
 |
 |  _asdict(self)
 |      Return a new dict which maps field names to their values.
 |
 |  _replace(self, /, **kwds)
и так далее...

Расширение namedtuple

Можно расширить функциональность namedtuple, создав класс-наследник с целью добавлять в него новые методы и свойства, не теряя всех преимуществ namedtuple. Например, для точки можно определить метод hypot, рассчитывающий расстояние от начала координат.

class Point(namedtuple('Point', ['x', 'y'])):
    __slots = ()
    @property
    def hypot(self):
        return (self.x  2 + self.y  2) ** 0.5
    def __str__(self):
        return f'Point: x={self.x}  y={self.y}  hypot={self.hypot}'

>>> print(Point(1, 2))
Point: x=1  y=2  hypot=2.23606797749979

Однако, вы не можете менять значения полей внутри методов, так как кортеж – неизменяемый тип данных. 

Существует еще один экзотический способ создать класс от namedtuple, причем с «типизированными» полями:

from typing import NamedTuple

class Employee(NamedTuple):
    name: str
    id: int

Это эквивалент такой записи:

Employee = namedtuple('Employee', ['name', 'id'])

Однако, первый вариант смотрится выразительнее и помогает IDE анализировать код.

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

Строка __slots = () здесь играет особую роль. Я еще не рассказывал про слоты, но если вкратце, то таким образом мы предотвращаем создание внутреннего словаря для данного класса, что экономит еще 8 байт.

namedtuple – производная от tuple, который написан на Си, что дает дополнительный буст производительности по сравнению с классами, написанными на чистом Python.

Вывод: namedtuple позволяет быстро и удобно создавать небольшие неизменяемые классы для хранения данных. Такие классы эффективны по объему используемой памяти и уже содержат большое количество вспомогательных функций, таких как инициализация, сравнение, хэширование, представление данных, преобразование в словарь и так далее. Также, namedtuple наследует поведение обычного кортежа tuple, в том плане, что можно обратиться к полям по индексам или распаковать их в отдельные переменные.

Но не спешите переделывать все ваши классы на namedtuple, потому что:

1. namedtuple не изменяем

2. без танцев с бубнами невозможно наследование

3. много прочих проблем с ООП

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

Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Индексирование в Python

Бунгало на море

Положительные и отрицательные индексы

Допустим у нас есть список или кортеж.

x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
t = (0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

Без потери общности будем работать только со списком х (с кортежем t – тоже самое).

Легко получить i-тый элемент этого списка по индексу.

Внимание! Индексы в Python считаются с нуля (0), как в С++ и Java.

>>> x[0]
0
>>> x[7]
7
>>> x[11]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
IndexError: list index out of range

В последней строке мы вылезли за пределы (у нас в списке последний индекс – 10) и получили исключение IndexError.

Но что будет, если мы обратимся к элементу с отрицательным индексом? В С++ такой операцией вы бы прострелили себе ногу. А в Python? IndexError? Нет!

>>> x[-1]
10
>>> x[-2]
9
>>> x[-10]
1
>>> x[-11]
0

Это совершенно легально. Мы просто получаем элементы не с начала списка, а с конца (-i-тый элемент).
x[-1] – последний элемент.
x[-2] – предпоследний элемент.

Это аналогично конструкции x[len(x)-i]:

>>> x[len(x)-1]
10

Обратите внимание, что начальный (слева) элемент в отрицательной нотации имеет индекс -11.

Срезы

Срезы, они же slices, позволяют вам получить какую-то часть списка или кортежа.

Форма x[start:end] даст элементы от индекса start (включительно) до end (не включая end). Если не указать start – мы начнем с 0-го элемента, если не указать end – то закончим последним элементом (включительно). Соотвественно, x[:] это тоже самое, что и просто x.

>>> x = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> x[2:8]
[2, 3, 4, 5, 6, 7]
>>> x[:8]
[0, 1, 2, 3, 4, 5, 6, 7]
>>> x[2:]
[2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> x[:]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Если end <= start, получим пустой список.

>>> x[5:3]
[]

Аналогично мы можем получать срезы с отчетом от конца списка с помощью отрицательных индексов.

>>> a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> a[-4:-2]
[7, 8]

В этом случае также start < end, иначе будет пустой список.

Форма x[start:end:step] даст элементы от индекса start (включительно) до end (не включая end), в шагом step. Если step равен 1, то эта форма аналогична предыдущей рассмотренной x[start:end].

>>> x[::2]
[0, 2, 4, 6, 8, 10]
>>> x[::3]
[0, 3, 6, 9]
>>> x[2:8:2]
[2, 4, 6]

x[::2] – каждый второй элемент, а x[::3] – каждый третий. 

Отрицательный шаг вернет нам элементы в обратном порядке:

>>> x[::-1]
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# как если бы:
>>> list(reversed(x))
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

# в обратном порядке с шагом 2
>>> x[::-2]
[10, 8, 6, 4, 2, 0]

Запись в список по срезу

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

Если размеры равны (в примере два элемента в срезе и два элемента во втором списке) – происходит замена элементов.

>>> a = [1,2,3,4,5]
>>> a[1:3] = [22, 33]
>>> a
[1, 22, 33, 4, 5]

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

>>> a = [1, 2, 3, 4, 5]
# размер среза = 1 элемент, а вставляем два (массив расширился)
>>> a[2:3] = [0, 0]
>>> a
[1, 2, 0, 0, 4, 5]

# тут вообще пустой размер среза = вставка подсписка по индексу 1
>>> a[1:1] = [8, 9]
>>> a
[1, 8, 9, 2, 0, 0, 4, 5]

# начиная с элемента 1 и кончая предпоследним элементом мы уберем (присвоив пустой список)
>>> a[1:-1] = []
>>> a
[1, 5]

Именованные срезы

Можно заранее создавать срезы с какими-то параметрами без привязки к списку или кортежу встроенной функцией slice. А потом применить этот срез к какому-то списку.

>>> a = [0, 1, 2, 3, 4, 5]
>>> LASTTHREE = slice(-3, None)
>>> LASTTHREE
slice(-3, None, None)
>>> a[LASTTHREE]
[3, 4, 5]

Вместо пустых мест для start, end или step здесь мы пишем None.

В заключение к этому разделу хочу сказать, что срезы списков возвращают списки, срезы кортежей – кортежи.

Индексирование своих объектов

В конце концов, мы можете определить самостоятельно поведение оператор индексации [], определив для своего класса магические методы __getitem__, __setitem__ и __delitem__. Первый вызывается при получении значения по индекса (или индексам), второй – если мы попытаемся нашему объекту что-то присвоить по индексу. А третий – если мы будет пытаться делать del по индексу. Необязательно реализовывать их все. Можно только один, например:

# при чтении по индексу из этого класса, мы получим удвоенных индекс
class MyClass:
    def __getitem__(self, key):
       return key * 2

myobj = MyClass()
myobj[3]  # вернет 6
myobj["privet!"] # приколись, будет: 'privet!privet!'

В качестве ключей можно использовать не только целые числа, но и строки или любые другие значения, в том числе slice и Ellipsis. Как вы будете обрабатывать их – решать вам. Естественно, логика, описанная в предыдущих разделах, здесь будет только в том случае, если вы ее сами так реализуете.

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

class IntegerNumbers:
  def __getitem__(self, key):
    if isinstance(key, int):
      return key
    elif isinstance(key, slice):
      return list(range(key.start, key.stop, key.step))
    else:
      raise ValueError

ints = IntegerNumbers() 
print(ints[10])  # 10
print(ints[1:10:2]) # [1, 3, 5, 7, 9]
print(ints["wwdwd"]) # так нельзя

Можно иметь несколько индексов. Ниже мы суммируем все значения индексов.

class MultiIndex:
  def __getitem__(self, keys): 
    # все индексы (если их 2 и больше попадут) в keys с типом tuple
    return sum(keys)  # просуммируем их

prod = MultiIndex()
print(prod[10, 20])  # напечает 30

Удачи в программировании и жизни!

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈