Кортеж здорового человека

    Именованный кортеж
    Эта статья — об одном из лучших изобретений 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 строк кода. Что ещё надо для счастья ツ


    Если хотите узнать больше о стандартной библиотеке Python — подписывайтесь на канал @ohmypy

    Поделиться публикацией

    Комментарии 34

      +2
      С тех пор, как я познакомился с pandas, я всё чаще использую его в тех ситуациях, в которых раньше взял бы список из namedtuple.
        +2
        Справедливости ради, есть либа dataslots или же крутой attrs. Но реализация dataclass в 3.7 безумно ущербная, к сожалению :(
          0
          А чем она плоха?
            +2
            Ну, помимо того, что там нет возможности таки включить слоты, что как бы очень нелогично, там так же нет никакой валидации по типам, возможности расширения или каких-то полезных твиков.

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

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

              Мне кажется, сами авторы довольны дизайном дата-классов. Вот выступление Реймонда Хеттингера по этому поводу. Про слоты он там тоже говорит — мол, жаль, что не получилось, в будущем, возможно, поддержим.

                +1
                Ну, я безусловно рад за авторов, но это же программирование, тут не бывает правильных ответов или реализаций, что бы всем понравились :)

                Авторы ставили перед датаклассами одни цели, я себе нафантазировал другие и вот получилось то, что получилось :)
            0
            Вот именно. Я тоже очень ждал датаклассов, а когда глаза побежали по этому бзацу в топике:
            Когда впервые видишь дата-класс, хочется перейти на новую версию языка только ради него:

            У меня промелькнула сперва мысль, что «да!, дейтсвительно хочется перейти на другую сторону улицы!»
            Потом был когнитивный диссонанс. Датаклассы получились совсем не тем. Хотелось, ытобы на них можно было делать полноценный ORM, хотелось больше свободы управления типами, больше средств автоматизации для валидации, конвертации и сериализации. Получилось же, что теперь это всёё равно приходится впиливать вручную, но появляется еще одна «магическая» скрытая прослойка в виде декоратора, который «колдует» над моим классом.
            +2

            В моём представлении главная проблема с namedtuples в отсутствии синтаксического сахара работы с словарями.


            Если бы мы вместо foo["bar"] могли бы писать foo.bar, наша жизнь была бы восхитительной, и namedtuples не понадобились бы. Ансибл, например, так разрешил в шаблонах, и оно восхитительно.

              0
              Попробуйте attrdict.
                0
                Да. Но в самом языке было бы лучше.
                –1
                ну есть же Lua :)

                foo = {bar = 42}
                print(foo[«bar»], foo.bar)

                  +1
                  Но lua не питон. Обидно.
                    +3
                    Как раз наоборот, это просто замечательно, что lua не питон. :)
                  0

                  class Bunch(dict):
                  def init(self, *args, *kwargs):
                  super().init(
                  args, **kwargs)
                  self.dict = self

                    0

                    Затупил с форматированием, но идея должна быть понятна :)

                      0
                      Это есть на стековерфлоу, и там же приведены минусы такого подхода:
                      • входящие данные могут запороть служебные свойства и методы, например keys() или dunder'ы.
                      • на некоторых версиях питона дёт утечку памяти
                      • ну и просто крайне неочевидно.

                      Так что, если нужен attrdict, лучше написать явную логику фильтрации ключей и хранить всё в отдельном словаре.
                    +1
                    Для удобства конструирования namedtuple можно переопределить магический метод __new__().
                    Примерно так
                    class Pet(namedtuple('BasePet', ('type', 'name', 'age'))):
                        @staticmethod
                        def __new__(cls, type, name='', age=1): #можно делать необязательные параметры
                            #тут можно делать преобразование типов или иную логику
                            type = str(type)
                            name = name if name else default_names.get(type, type)
                            return super(Pet, cls).__new__(cls, name, type, age)
                    

                      0
                      Забыл одну строчку:
                      __slots__ = ()

                      Она нужна, чтобы указать, что мы используем механизм slots и в классе-потомке. В этом случае родительские slots мы унаследуем, а своих добавим ноль (пустой кортеж).
                    • НЛО прилетело и опубликовало эту надпись здесь
                        0
                        Это логичное предположение, если знаешь про слоты. В докладе, на который я выше дал ссылку, Реймонд Хеттингер говорит, что просто не получилось их заиспользовать — а то бы так и сделали.
                        • НЛО прилетело и опубликовало эту надпись здесь
                            0
                            Так и я о том же ツ
                        +1
                        Так происходит, потому что обычные объекты в питоне таскают с собой увесистый дандер __dict__

                        Если вы давно работаете с питоном, то наверняка знаете: легковесный объект можно создать через дандер __slots__:

                        Прошу простить мое невежество, но что за термин "дандер"?
                        Гугл (при попытке поиска) отсылает к домашнему самогоноварению :)

                          +3

                          «Дандер» — калька с английского dunder = Double UNDERscore. Они же «магические методы» вида __whatever__

                            0

                            Ясно, спасибо

                            0
                            термин "дандер"?

                            Это название магических методов питона

                            +1
                            кортеж согласно Википедии

                            Почему вы считаете кортежи изобретением питона, при том что это математический термин, широко применявшийся в информатике например при описании реляционых баз данных, когда питона насколько я помню, еще не существовало? Возможно питон и был первым языком, где такое появилось, но тут уже мало кто может быть уверенным. Но базы данных точно были раньше, статьи Кодда датируются где-то 1970 годом.
                              0
                              Возможно питон и был первым языком, где такое появилось, но тут уже мало кто может быть уверенным.
                              Не был. В детстве я читал про язык РАПИРА.
                                0
                                Я в том смысле, что вряд ли мы найдем самый первый.
                                  0
                                  вряд ли мы найдем самый первый.
                                  Раньше LISP'а не так много языков было, но утверждать, что это он, не буду. ¯\_(ツ)_/¯
                                    0
                                    Я тоже. Но по-моему в самом первом лиспе (а это где-то 1960) были только car/cdr, а это все-таки не кортежи ну никак). Впрочем, я практически познакомился с лиспом вообще где-то в 1990-х, на AutoCAD, так что в истории его могу плавать.
                              +3
                              Ещё есть отличный способ — наследование от
                              typing.NamedTuple
                              . Работает так же, выглядит прилично.

                              
                              class A(typing.NamedTuple):
                                  field: int
                                  field_with_default: int = 3
                              

                                0
                                Так, стоп… а нафига нам после этого классические namedtuple и dataclasses вместе взятые? Ну ок, кроме специфических случаев обработки CSV.
                                Я даже задумался выкинуть пакет `dataclasses` из проекта на 3.6 питоне и полностью юзать именно этот вариант…
                                  0

                                  В 95% случаев, имхо, dataclasses не нужны. Только если нужны мутабельные контейнеры — их на NamedTuple не сделаешь. Плюс, в typing.NamedTuple нельзя переопределять некоторые методы, а точнее:


                                  # attributes prohibited to set in NamedTuple class syntax
                                  _prohibited = ('__new__', '__init__', '__slots__', '__getnewargs__',
                                                 '_fields', '_field_defaults', '_field_types',
                                                 '_make', '_replace', '_asdict', '_source')

                              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                              Самое читаемое