Как устроен namedtuple или динамическое создание типов

http://jameso.be/2013/08/06/namedtuple.html
  • Перевод
Мы в Буруках любим не только людей и цифры. Мы также без устали совершенствуемся во владении нашим основным инструментом, языком Python. Ссылка для тех, кто хочет совершенствоваться с нами. В этой статье-переводе автор разбирает устройство namedtuple и по ходу рассказывает об одной из основных концепций языка.

Пару дней назад я был на пути в Сан-Франциско. Интернета в самолёте не было, поэтому я читал исходники стандартной библиотеки Python 2.7. Реализация namedtuple показалась мне особенно интересной, наверное, потому, что на деле всё гораздо проще, чем я думал раньше.

Вот здесь лежат исходники. Если вы никогда раньше не знали о namedtuple, то рекомендую ознакомиться с этой функцией.

Код


################################################################################
### namedtuple
################################################################################

Ого! Впечатляющий заголовок, правда?

В начале, как и полагается, определение функции, и пример хорошего доктеста.

def namedtuple(typename, field_names, verbose=False, rename=False):
    """Возвращает новый подкласс кортежа с именованными полями.

    >>> Point = namedtuple('Point', 'x y')
    >>> Point.__doc__                   # докстринг нового класса
    'Point(x, y)'
    >>> p = Point(11, y=22)             # создание экземпляра с позиционными и именованными аргументами
    >>> p[0] + p[1]                     # индексируется как обычный кортеж
    33
    >>> x, y = p                        # распаковывается как обычный кортеж
    >>> x, y
    (11, 22)
    >>> p.x + p.y                       # поля доступны по имени
    33
    >>> d = p._asdict()                 # конвертируется в словарь
    >>> d['x']
    11
    >>> Point(**d)                      # создаётся из словаря
    Point(x=11, y=22)
    >>> p._replace(x=100)               # обновляет значение именованного поля
    Point(x=100, y=22)

    """

Потом начинаются разборки с аргументами. Обратите внимание на использование basestring в вызове isinstance — так мы определим, что работаем со строкой, если тип объекта unicode или str (это точно работает в Python < 3.0).

    # Парсим и валидируем имена полей. Валидация служит двум целям:
    # генерация информативных сообщений об ошибках
    # и предотвращение атак внедрением в шаблоны.
    if isinstance(field_names, basestring):
        field_names = field_names.replace(',', ' ').split() # имена разделены пробелами и/или запятыми

Если установлен атрибут rename, то все неправильные названия полей будут переименованы соответственно их позициям.

    field_names = tuple(map(str, field_names))
    if rename:
        names = list(field_names)
        seen = set()
        for i, name in enumerate(names):
            if (not all(c.isalnum() or c=='_' for c in name) or _iskeyword(name)
                or not name or name[0].isdigit() or name.startswith('_')
                or name in seen):
                names[i] = '_%d' % i
            seen.add(name)
        field_names = tuple(names)

Обратите внимание на генераторное выражение, обёрнутое в all(). Такая запись, all(bool_expr(x) for x in things), — крайне удобный способ описать желаемый результат в одном выражении.

    for name in (typename,) + field_names:
        if not all(c.isalnum() or c=='_' for c in name):
            raise ValueError(
                'Type names and field names can only contain alphanumeric characters and underscores: %r' % name
            )
        if _iskeyword(name):
            raise ValueError('Type names and field names cannot be a keyword: %r' % name)
        if name[0].isdigit():
            raise ValueError('Type names and field names cannot start with a number: %r' % name)

Проверочка на повторения имён:

    seen_names = set()
    for name in field_names:
        if name.startswith('_') and not rename:
            raise ValueError('Field names cannot start with an underscore: %r' % name)
        if name in seen_names:
            raise ValueError('Encountered duplicate field name: %r' % name)
        seen_names.add(name)

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

    # Создадим и заполним шаблон класса
    numfields = len(field_names)
    argtxt = repr(field_names).replace("'", "")[1:-1]   # представление кортежа без кавычек и скобок
    reprtxt = ', '.join('%s=%%r' % name for name in field_names)

И вот что на самом деле творится под капотом namedtuple. Эта строка впоследствие превратится в Python-код.

    template = '''class %(typename)s(tuple):
        '%(typename)s(%(argtxt)s)' \n
        __slots__ = () \n
        _fields = %(field_names)r \n

        def __new__(_cls, %(argtxt)s):
            'Create new instance of %(typename)s(%(argtxt)s)'
            return _tuple.__new__(_cls, (%(argtxt)s)) \n

        @classmethod
        def _make(cls, iterable, new=tuple.__new__, len=len):
            'Make a new %(typename)s object from a sequence or iterable'
            result = new(cls, iterable)
            if len(result) != %(numfields)d:
                raise TypeError('Expected %(numfields)d arguments, got %%d' %% len(result))
            return result \n

        def __repr__(self):
            'Return a nicely formatted representation string'
            return '%(typename)s(%(reprtxt)s)' %% self \n

        def _asdict(self):
            'Return a new OrderedDict which maps field names to their values'
            return OrderedDict(zip(self._fields, self)) \n

        __dict__ = property(_asdict) \n

        def _replace(_self, **kwds):
            'Return a new %(typename)s object replacing specified fields with new values'
            result = _self._make(map(kwds.pop, %(field_names)r, _self))
            if kwds:
                raise ValueError('Got unexpected field names: %%r' %% kwds.keys())
            return result \n

        def __getnewargs__(self):
            'Return self as a plain tuple.  Used by copy and pickle.'
            return tuple(self) \n\n
        
        ''' % locals()

Собственно, это и есть шаблон нашего нового класса.

Использование locals() для строковой интерполяции мне кажется очень удобным. Питону не хватает простой интерполяции локальных переменных. В Groovy и CoffeeScript, например, можно написать что-то вроде "{name} is {some_value}". Но я думаю, что этот Python-вариант вполне сойдёт: "{name} is {some_value}".format(**locals()).

Вы, наверное, заметили, что __slots__ определяется как пустой кортеж. Питон в таком случае не использует для экземпляров словари в качестве пространств имён, что немного экономит ресурсы. Благодаря неизменяемости, которая наследуется от родительского класса (tuple), и невозможности добавлять новые атрибуты (потому что __slots__ = ()), экземпляры namedtuple-типов являются объектами-значениями.

Идём дальше. На каждое имя создаётся свойство только для чтения. _itemgetter — это itemgetter из модуля operator, который возвращает функцию одного аргумента, что как раз подходит для свойства.

    for i, name in enumerate(field_names):
        template += "        %s = _property(_itemgetter(%d), doc='Alias for field number %d')\n" % (name, i, i)
    if verbose:
        print template

Итак, у нас есть грандиозная строчка с питонячьим кодом. Что с ней делать? Выполнение в ограниченном пространстве имён кажется разумным. Посмотрите, как тут используется exec ... in:

    # Исполним полученный код во временном пространстве имён.
    # Не забываем о поддержке трассировщиков, определяем значение
    # frame.f_globals['__name__']
    namespace = dict(_itemgetter=_itemgetter, __name__='namedtuple_%s' % typename,
                     OrderedDict=OrderedDict, _property=property, _tuple=tuple)
    try:
        exec template in namespace
    except SyntaxError, e:
        raise SyntaxError(e.message + ':\n' + template)
    result = namespace[typename]

Очень хитро! Идея исполнить строку кода в изолированном пространстве имён, а затем вытащить из него новый тип непривычна для меня. За подробностями об exec идём в пост Армина Ронахера.

Дальше немного магии, чтобы определить __module__ нового класса как модуль, который вызвал namedtuple:

    try:
        result.__module__ = _sys._getframe(1).f_globals.get('__name__', '__main__')
    except (AttributeError, ValueError):
        pass

и на этом всё!

    return result

Просто, не так ли?

Мысли о реализации


Самой интересной для меня деталью описанного выше кода стало динамическое исполнение строки кода в пространстве имён, которое создаётся исключительно для одного этого исполнения. Этот ход подчёркивает простоту модели данных Питона: все пространства имён, включая модули и классы, являются по сути словарями. Изучение внутренностей namedtuple снова доказывает мощь такой простоты.
Используя эту технику мы могли бы упростить валидацию названий полей, и вместо

for name in (typename,) + field_names:
    if not all(c.isalnum() or c=='_' for c in name):
        raise ValueError('Type names and field names can only contain alphanumeric characters and underscores: %r' % name)
    if _iskeyword(name):
        raise ValueError('Type names and field names cannot be a keyword: %r' % name)
    if name[0].isdigit():
        raise ValueError('Type names and field names cannot start with a number: %r' % name)

можно было бы написать

for name in (typename,) + field_names:
    try:
        exec ("%s = True" % name) in {}
    except (SyntaxError, NameError):
        raise ValueError('Invalid field name: %r' % name)

чтобы прямо и коротко протестировать валидность идентификатора. Но в этом случае мы потеряем точность в описании проблемы при возникновении ошибки. А так как это стандартная библиотека, то явные сообщения об ошибках делают текущую реализацию лучшим выбором.

Между нами только find


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

И вообще. Изучайте возможности инструментов, которыми пользуетесь, не занимайтесь велосипедостроением!
Метки:
  • +12
  • 13,2k
  • 4
Буруки 26,78
Компания
Поделиться публикацией
Похожие публикации
Комментарии 4
  • +3
    Тема интересная. Текущая реализация немного другая: hg.python.org/cpython/file/tip/Lib/collections/__init__.py

    Использование locals() для строковой интерполяции мне кажется очень удобным. Питону не хватает простой интерполяции локальных переменных. В Groovy и CoffeeScript, например, можно написать что-то вроде "{name} is {some_value}". Но я думаю, что этот Python-вариант вполне сойдёт: "{name} is {some_value}".format(**locals()).

    В текущей реализации namedtuple всех этих locals() нет, и правильно.

    Вы, наверное, заметили, что __slots__ определяется как пустой кортеж. Питон в таком случае не использует для экземпляров словари в качестве пространств имён, что немного экономит ресурсы. Благодаря неизменяемости, которая наследуется от родительского класса (tuple), и невозможности добавлять новые атрибуты (потому что __slots__ = ()), экземпляры namedtuple-типов являются объектами-значениями, что позволяет использовать их, например, в качестве ключей в словарях.

    __slots__ не имеет никакого отношения к тому, можно ли использовать объект в качестве ключей в словарях, или нет. Да и неизменяемость, в общем-то, не обязательна. См. docs.python.org/3/glossary.html#term-hashable.
    • 0
      __slots__ не имеет никакого отношения
      Спасибо за верное замечание. В статье такого нет, это мой косяк :)

      locals() нет, и правильно
      Согласен, explicit is better.
    • 0
      В этой статье-переводе

      Добавьте ссылку на оригинал
      • 0
        А она есть :) Над комментариями небольшая плашка, там имя автора (James O'Beirne). Ткните туда, попадёте на страницу оригинала.

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

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