Метка: tuple

Именованные кортежи или 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 👈