Python. Кортеж здорового человека
Эта статья — об одном из лучших изобретений Python: именованном кортеже (namedtuple). Мы рассмотрим его приятные особенности, от известных до неочевидных. Уровень погружения в тему будет нарастать постепенно, так что, надеюсь, каждый найдёт для себя что-то интересное. Поехали!
Введение
Наверняка вы сталкивались с ситуацией, когда нужно передать несколько свойств объекта одним куском. Например, информацию о домашнем питомце: тип, кличка и возраст.
Часто создавать отдельный класс под это дело лень, и используют кортежи:
("pigeon", "Френк", 3)
("fox", "Клер", 7)
("parrot", "Питер", 1)
Для большей наглядности подойдёт именованный кортеж — collections.namedtuple
:
from collections import namedtuple
Pet = namedtuple("Pet", "type name age")
frank = Pet(type="pigeon", name="Френк", age=3)
>>> frank.age
3
Это все знают ツ А вот несколько менее известных особенностей:
Быстрое изменение полей
Что делать, если одно из свойств надо изменить? Френк стареет, а кортеж-то неизменяемый. Чтобы не пересоздавать его целиком, придумали метод _replace()
:
>>> frank._replace(age=4)
Pet(type='pigeon', name='Френк', age=4)
А если хотите сделать всю структуру изменяемой — _asdict()
:
>>> frank._asdict()
OrderedDict([('type', 'pigeon'), ('name', 'Френк'), ('age', 3)])
Автоматическая замена названий
Допустим, вы импортируете данные из CSV и превращаете каждую строчку в кортеж. Названия полей взяли из заголовка CSV-файла. Но что-то идёт не так:
# headers = ("name", "age", "with")
>>> Pet = namedtuple("Pet", headers)
ValueError: Type names and field names cannot be a keyword: 'with'
# headers = ("name", "age", "name")
>>> Pet = namedtuple("Pet", headers)
ValueError: Encountered duplicate field name: 'name'
Решение — аргумент rename=True
в конструкторе:
# headers = ("name", "age", "with", "color", "name", "food")
Pet = namedtuple("Pet", headers, rename=True)
>>> Pet._fields
('name', 'age', '_2', 'color', '_4', 'food')
«Неудачные» названия переименовались в соответствии с порядковыми номерами.
Значения по умолчанию
Если у кортежа куча необязательных полей, всё равно приходится каждый раз перечислять их при создании объекта:
Pet = namedtuple("Pet", "type name alt_name")
>>> Pet("pigeon", "Френк")
TypeError: __new__() missing 1 required positional argument: 'alt_name'
>>> Pet("pigeon", "Френк", None)
Pet(type='pigeon', name='Френк', alt_name=None)
Чтобы этого избежать, укажите в конструкторе аргумент defaults
:
Pet = namedtuple("Pet", "type name alt_name", defaults=("нет",))
>>> Pet("pigeon", "Френк")
Pet(type='pigeon', name='Френк', alt_name='нет')
defaults
присваивает умолчательные значения с хвоста. Работает в питоне 3.7+
Для старых версий можно более коряво добиться того же результата через прототип:
Pet = namedtuple("Pet", "type name alt_name")
default_pet = Pet(None, None, "нет")
>>> default_pet._replace(type="pigeon", name="Френк")
Pet(type='pigeon', name='Френк', alt_name='нет')
>>> default_pet._replace(type="fox", name="Клер")
Pet(type='fox', name='Клер', alt_name='нет')
Но с defaults
, конечно, куда приятнее.
Необычайная лёгкость
Одно из преимуществ именованного кортежа — легковесность. Армия из ста тысяч голубей займёт всего 10 мегабайт:
from collections import namedtuple
import objsize # 3rd party
Pet = namedtuple("Pet", "type name age")
frank = Pet(type="pigeon", name="Френк", age=None)
pigeons = [frank._replace(age=idx) for idx in range(100000)]
>>> round(objsize.get_deep_size(pigeons)/(1024**2), 2)
10.3
Для сравнения, если Pet сделать обычным классом, аналогичный список займёт уже 19 мегабайт.
Так происходит, потому что обычные объекты в питоне таскают с собой увесистый дандер __dict__
, в котором лежат названия и значения всех атрибутов объекта:
class PetObj:
def __init__(self, type, name, age):
self.type = type
self.name = name
self.age = age
frank_obj = PetObj(type="pigeon", name="Френк", age=3)
>>> frank_obj.__dict__
{'type': 'pigeon', 'name': 'Френк', 'age': 3}
Объекты-namedtuple же лишены этого словаря, а потому занимают меньше памяти:
frank = Pet(type="pigeon", name="Френк", age=3)
>>> frank.__dict__
AttributeError: 'Pet' object has no attribute '__dict__'
>>> objsize.get_deep_size(frank_obj)
335
>>> objsize.get_deep_size(frank)
239
Но как именованному кортежу удалось избавиться от __dict__
? Читайте дальше ツ
Богатый внутренний мир
Если вы давно работаете с питоном, то наверняка знаете: легковесный объект можно создать через дандер __slots__
:
class PetSlots:
__slots__ = ("type", "name", "age")
def __init__(self, type, name, age):
self.type = type
self.name = name
self.age = age
frank_slots = PetSlots(type="pigeon", name="Френк", age=3)
У «слотовых» объектов нет словаря с атрибутами, поэтому они занимают мало памяти. «Френк на слотах» такой же лёгкий, как «Френк на кортеже», смотрите:
>>> objsize.get_deep_size(frank)
239
>>> objsize.get_deep_size(frank_slots)
231
Если вы решили, что namedtuple тоже использует слоты — это недалеко от истины. Как вы помните, конкретные классы-кортежи объявляются динамически:
Pet = namedtuple("Pet", "type name age")
Конструктор namedtuple применяет разную тёмную магию и генерит примерно такой класс (сильно упрощаю):
class Pet(tuple):
__slots__ = ()
type = property(operator.itemgetter(0))
name = property(operator.itemgetter(1))
age = property(operator.itemgetter(2))
def __new__(cls, type, name, age):
return tuple.__new__(cls, (type, name, age))
То есть наш Pet — это обычный tuple
, к которому гвоздями приколотили три метода-свойства:
type
возвращает нулевой элемент кортежаname
— первый элемент кортежаage
— второй элемент кортежа
А __slots__
нужен только для того, чтобы объекты получились лёгкими. В результате Pet и занимает мало места, и может использоваться как обычный кортеж:
>>> frank.index("Френк")
1
>>> type, _, _ = frank
>>> type
'pigeon'
Хитро придумано, а?
Не уступает дата-классам
Раз уж мы заговорили о генерации кода. В питоне 3.7 появился убер-генератор кода, которому нет равных — дата-классы (dataclasses).
Когда впервые видишь дата-класс, хочется перейти на новую версию языка только ради него:
from dataclasses import dataclass
@dataclass
class PetData:
type: str
name: str
age: int
Чудо как хорош! Но есть нюанс — он толстый:
frank_data = PetData(type="pigeon", name="Френк", age=3)
>>> objsize.get_deep_size(frank_data)
335
>>> objsize.get_deep_size(frank)
239
Дата-класс генерит обычный питонячий класс, объекты которого изнемогают под тяжестью __dict__
. Так что если вы начитываете из базы вагон строк и превращаете их в объекты, дата-классы — не лучший выбор.
Но постойте, дата-класс ведь можно «заморозить», как кортеж. Может тогда он станет легче?
@dataclass(frozen=True)
class PetFrozen:
type: str
name: str
age: int
frank_frozen = PetFrozen(type="pigeon", name="Френк", age=3)
>>> objsize.get_deep_size(frank_frozen)
335
Увы. Даже замороженный, он остался обычным увесистым объектом со словарём атрибутов. Так что если вам нужны лёгкие неизменяемые объекты (которые к тому же можно использовать как обычные кортежи) — namedtuple по-прежнему лучший выбор.
⌘ ⌘ ⌘
Мне очень нравится именованный кортеж:
- честный iterable,
- динамическое объявление типов,
- именованный доступ к атрибутам,
- лёгкий и неизменяемый.
И при этом реализован в 150 строк кода. Что ещё надо для счастья ツ
★ Подписывайтесь на новые заметки.