Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

Простая объектная модель (продолжение)

Оригинал: A Simple Object Model
Автор: Carl Friedrich Bolz
Дата публикации: 07 October 2015
Перевод: Н.Ромоданов
Дата перевода: май 2016 г.

Начало статьи смотрите здесь

Модель, базирующаяся на атрибутах

Теперь, когда заработал самый простой вариант нашей объектной модели, мы можем подумать о том, как можно ее изменить. В этом разделе рассмотрим различие между моделью, базирующуюся на использовании методов, и моделью, базирующуюся на использовании атрибутов. В этом состоит одно из основных различий между моделями в языках Smalltalk, Ruby и JavaScript, с одной стороны, и моделями в языках Python и Lua, с другой стороны.

Модель, базирующаяся на методах, осуществляет вызовы методов в виде примитивных операций выполнения программы:

result = obj.f(arg1, arg2)

Модель, базирующаяся на атрибутах, делит вызов метода на два этапа: поиск атрибута и получение результата:

method = obj.f
result = method(arg1, arg2)

Эту разницу можно продемонстрировать на следующем тесте:

def test_bound_method():
    # Код на языке Python
    class A(object):
        def f(self, a):
            return self.x + a + 1
    obj = A()
    obj.x = 2
    m = obj.f
    assert m(4) == 7

    class B(A):
        pass
    obj = B()
    obj.x = 1
    m = obj.f
    assert m(10) == 12 # works on subclass too

    # Код объектной модели
    def f_A(self, a):
        return self.read_attr("x") + a + 1
    A = Class(name="A", base_class=OBJECT, fields={"f": f_A}, metaclass=TYPE)
    obj = Instance(A)
    obj.write_attr("x", 2)
    m = obj.read_attr("f")
    assert m(4) == 7

    B = Class(name="B", base_class=A, fields={}, metaclass=TYPE)
    obj = Instance(B)
    obj.write_attr("x", 1)
    m = obj.read_attr("f")
    assert m(10) == 12

Хотя настройка классов точно такая же, как в соответствующем тесте для вызовов методов, способ, с помощью которого осуществляются вызовы, другой. Во-первых, в объекте происходит поиск атрибута с именем метода. Результатом этой операции поиска являетсясвязанный метод (bound method), объект, в котором инкапсулирован объект и функция, найденная в классе. Далее с помощью операции вызова метода вызывается этот связанный метод [2].

Чтобы реализовать такое поведение, мы должны изменить реализацию Base.read_attr. Если атрибут в словаре не найден, то поиск будет продолжен в классе. Если он будет найден в классе, и атрибут вызвать можно, то он должен быть включен в связанный метод. Чтобы проэмулировать связанный метод, мы просто воспользуемся замыканием. Кроме изменения Base.read_att мы также можем изменить Base.callmethod с тем, чтобы использовать новый подход к вызовам методов и убедиться, что все еще можно будет пройти все предыдущие тесты.

class Base(object):
    ...
    def read_attr(self, fieldname):
        """ чтение в объекте поля 'fieldname' (имя поля) """
        result = self._read_dict(fieldname)
        if result is not MISSING:
            return result
        result = self.cls._read_from_class(fieldname)
        if _is_bindable(result):
            return _make_boundmethod(result, self)
        if result is not MISSING:
            return result
        raise AttributeError(fieldname)

    def callmethod(self, methname, *args):
        """ вызов в объекте метода 'methname' (имя метода) с аргументами 'args' """
        meth = self.read_attr(methname)
        return meth(*args)

def _is_bindable(meth):
    return callable(meth)

def _make_boundmethod(meth, self):
    def bound(*args):
        return meth(self, *args)
    return bound

Остальную часть кода вообще не нужно не менять.

Протоколы метаобъектов

В дополнение к "нормальным" методам, которые вызываются непосредственно программой, во многих динамических языках поддерживается использование специальных методов. Эти методы не предназначены для непосредственного к ним обращения; они будут вызываться системой объектов. В языке Python такие специальные методы, как правило, имеют имена, которые начинаются и заканчиваются двумя символами подчеркивания; например, __init__. Специальные методы могут быть использованы для переопределения примитивных операций и перенастраивать их поведение. Т.е. это специальные крючки или хуки (hooks), с помощью которых механизму объектной модели сообщается, как делать определенные вещи. В объектной модели языка Python есть десятки специальных методов.

Протоколы метаобъектов были введены в языке Smalltalk, но использовались они еще в системах объектов в Common Lisp, например, в CLOS. Тогда же появились протоколы мета-объектов для работы с коллекциями специальных методов [3].

В этой главе мы добавим в объектную модели три таких мета-хука. Они используются для более тонкой настройки, которая нужна при чтении и записи атрибутов. Мы сначала добавим специальные методы __getattr__ и __setattr__, которые в языке Python используются для работы с именами.

Настройка операций чтения и записи и атрибуты

Метод __getattr__ вызывается объектной моделью в случае, когда происходит поиск атрибута и его не удается найти обычными средствами; т.е. ни в экземпляре, ни в классе. Метод получает в качестве аргумента имя искомого атрибута. Эквивалентом специального метода __getattr__ была частью системы раннего языка Smalltalk [4], которая называлась doesNotUnderstand:.

Ситуация с __setattr__ немного другая. Т.к. при задании атрибута его, прежде всего, нужно создать, то метод __setattr__ будет вызываться всегда. Чтобы метод __setattr__ всегда существовал, в классе OBJECT есть определение __setattr__. Эта базовая реализация просто делает то, что делалось для атрибута до этого, т. е. записывает атрибут в словарь объекта. Так что пользователь в некоторых случаях может определить свой метод __setattr__ и делегировать его в базовый метод OBJECT.__setattr__.

Тест для этих двух специальных методов следующий:

def test_getattr():
    # Код на языке Python
    class A(object):
        def __getattr__(self, name):
            if name == "fahrenheit":
                return self.celsius * 9. / 5. + 32
            raise AttributeError(name)

        def __setattr__(self, name, value):
            if name == "fahrenheit":
                self.celsius = (value - 32) * 5. / 9.
            else:
                # call the base implementation
                object.__setattr__(self, name, value)
    obj = A()
    obj.celsius = 30
    assert obj.fahrenheit == 86 # test __getattr__
    obj.celsius = 40
    assert obj.fahrenheit == 104

    obj.fahrenheit = 86 # test __setattr__
    assert obj.celsius == 30
    assert obj.fahrenheit == 86

    # Код объектной модели
    def __getattr__(self, name):
        if name == "fahrenheit":
            return self.read_attr("celsius") * 9. / 5. + 32
        raise AttributeError(name)
    def __setattr__(self, name, value):
        if name == "fahrenheit":
            self.write_attr("celsius", (value - 32) * 5. / 9.)
        else:
            # call the base implementation
            OBJECT.read_attr("__setattr__")(self, name, value)

    A = Class(name="A", base_class=OBJECT,
              fields={"__getattr__": __getattr__, "__setattr__": __setattr__},
              metaclass=TYPE)
    obj = Instance(A)
    obj.write_attr("celsius", 30)
    assert obj.read_attr("fahrenheit") == 86 # test __getattr__
    obj.write_attr("celsius", 40)
    assert obj.read_attr("fahrenheit") == 104
    obj.write_attr("fahrenheit", 86) # test __setattr__
    assert obj.read_attr("celsius") == 30
    assert obj.read_attr("fahrenheit") == 86

Чтобы пройти эти тесты, методы Base.read_attr и Base.write_attr необходимо изменить следующим образом:

class Base(object):
    ...

    def read_attr(self, fieldname):
        """ чтение в объекте поля 'fieldname' (имя поля)  """
        result = self._read_dict(fieldname)
        if result is not MISSING:
            return result
        result = self.cls._read_from_class(fieldname)
        if _is_bindable(result):
            return _make_boundmethod(result, self)
        if result is not MISSING:
            return result
        meth = self.cls._read_from_class("__getattr__")
        if meth is not MISSING:
            return meth(self, fieldname)
        raise AttributeError(fieldname)

    def write_attr(self, fieldname, value):
        """ запись в объекте поля 'fieldname' (имя поля) """
        meth = self.cls._read_from_class("__setattr__")
        return meth(self, fieldname, value)

Процедура чтения атрибута заменяется на вызов метода __getattr__ с именем поля в качестве аргумента. Если метода нет, то ошибка не выдается. Обратите внимание на то, что поиск метода __getattr__ (и, на самом деле, всех специальных методов языка Python) выполняется только в классе, а не с помощью рекурсивного обращение к методу self.read_attr("__getattr__"). Это потому, что если в объекте не указано read_attr if __getattr__, то в последнем случае возникнет бесконечная рекурсия.

Запись атрибутов полностью перенесено в метод __setattr__. Чтобы выполнить эту работу, в классе OBJECT должен быть метод __setattr__, в котором задано поведение, реализуемое по умолчанию, а именно:

def OBJECT__setattr__(self, fieldname, value):
    self._write_dict(fieldname, value)
OBJECT = Class("object", None, {"__setattr__": OBJECT__setattr__}, None)

Поведение метода OBJECT__setattr__ похоже на предыдущее поведение метода write_attr. После этих изменений новый тест пройдет.

Протокол дескрипторов

Приведенный выше тест, реализующий автоматическое преобразование различных шкал температуры, работает, но создавать его было крайне тяжело, т. к. в методах __getattr__ и __setattr__ имя атрибута нужно было проверять в явном виде. Чтобы обойти эту проблему, в языке Python был введен протокол дескрипторов.

Хотя методы __getattr__ и __setattr__ вызываются для объекта, из которого считывается атрибут, результат получения атрибута из объекта с помощью протокола дескрипторов будет сопровождаться вызовом специального метода. Его можно рассматривать в качестве обобщенной привязки метода к объекту - и, действительно, связывание метода с объектом осуществляется с использованием протокола дескрипторов. Кроме связанных методов в языке Python для практического использования важно реализовать методы staticmethod, classmethod и property.

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

def test_get():
    # Код на языке Python
    class FahrenheitGetter(object):
        def __get__(self, inst, cls):
            return inst.celsius * 9. / 5. + 32

    class A(object):
        fahrenheit = FahrenheitGetter()
    obj = A()
    obj.celsius = 30
    assert obj.fahrenheit == 86

    # Код объектной модели
    class FahrenheitGetter(object):
        def __get__(self, inst, cls):
            return inst.read_attr("celsius") * 9. / 5. + 32

    A = Class(name="A", base_class=OBJECT,
              fields={"fahrenheit": FahrenheitGetter()},
              metaclass=TYPE)
    obj = Instance(A)
    obj.write_attr("celsius", 30)
    assert obj.read_attr("fahrenheit") == 86

Метод __get__ будет вызван для экземпляра FahrenheitGetter после того, как экземпляр найден в классе obj. Аргументы для __get__ будут экземпляром, в котором был выполнен поиск [5].

Реализовать такое поведения просто. Мы просто должны изменить методы _is_bindable и _make_boundmethod:

def _is_bindable(meth):
    return hasattr(meth, "__get__")

def _make_boundmethod(meth, self):
    return meth.__get__(self, None)

В результате тест проходит. Предыдущие тесты, предназначенные для связанных методов, также по-прежнему проходят, поскольку в функциях языка Python есть метод__get__, возвращающий объект связанного метода.

На практике, протокол дескрипторов гораздо сложнее. В нем также поддерживается переопределение метода __set__, с помощью которого выполняются настройки каждого атрибута. Кроме того, в текущей реализации мы обошли несколько острых углов. Следует отметить, что метод _make_boundmethod вызывает метод __get__ на уровне реализации, а не использует конструкцию meth.read_attr("__get__"). Это необходимо, поскольку наша объектная модель заимствует функции и, следовательно, методы из языка Python вместо того, чтобы их реализовывать в рамках нашей объектной модели. Более полная объектная модель должна решать эту проблему.

Оптимизация использования памяти для экземпляров

Первые три варианта объектной модели изменяли ее поведение; в этом последнем разделе мы рассмотрим оптимизацию, которая не повлияет на поведение объектной модели. Такая оптимизация называется отображениями (maps) и впервые она была применена в виртуальной машине для языка программирования Self [6]. Она все еще остается одним из самых важных принципов оптимизации объектной модели: она используется в проекте PyPy и всех современных виртуальных машинах JavaScript, таких как V8 (где эту оптимизацию называют скрытыми классами hidden classes).

Оптимизация начинается с следующего наблюдения: В объектной модели, реализованной до сих пор, все экземпляры для хранения своих атрибутов использовали полноразмерный словарь. Словарь реализован с использованием хэш-отображения (hash map), для которого нужно много памяти. Кроме того, словари экземпляров одного и того же класса, как правило, имеют также одни и те же ключи. Например, для заданного класса Point ключами словарей всех его экземпляров скорее всего будут "х" и "у".

При оптимизации отображения пользуется этим фактом. Словарь каждого экземпляра делится на две части. Имеется часть хранения ключей (отображение), которой могут совместно пользоваться все экземпляры, имеющие один и тот же набор имен атрибутов. Тогда в экземпляре можно хранить только ссылку на это общее отображение, а также значения атрибутов, представленных в виде списка (что с точки зрения использования памяти более экономно, чем использование словаря). В отображении хранятся взаимосвязи между именами и индексами этого списка.

Простой тест для проверки этой функциональности выглядит следующим образом:

def test_maps():
    # тест типа «белый ящик», проверяющий реализацию
    Point = Class(name="Point", base_class=OBJECT, fields={}, metaclass=TYPE)
    p1 = Instance(Point)
    p1.write_attr("x", 1)
    p1.write_attr("y", 2)
    assert p1.storage == [1, 2]
    assert p1.map.attrs == {"x": 0, "y": 1}

    p2 = Instance(Point)
    p2.write_attr("x", 5)
    p2.write_attr("y", 6)
    assert p1.map is p2.map
    assert p2.storage == [5, 6]

    p1.write_attr("x", -1)
    p1.write_attr("y", -2)
    assert p1.map is p2.map
    assert p1.storage == [-1, -2]

    p3 = Instance(Point)
    p3.write_attr("x", 100)
    p3.write_attr("z", -343)
    assert p3.map is not p1.map
    assert p3.map.attrs == {"x": 0, "z": 1}

Обратите внимание, что стиль теста отличается от тех, которые мы писали ранее. Все предыдущие тесты просто проверяли поведение классов с помощью обращения к открытым интерфейсам. В этом тесте проверяются детали реализации класса Instance: читаются внутренние атрибуты и они сравниваются с заранее заданным значениям. Поэтому этот тест можно назвать тестом типа «белый ящик».

Атрибут attrs отображения экземпляра p1 описывает внутреннюю структуру экземпляра, как имеющего два атрибута "x" и "y", которые хранятся в позициях 0 и 1 хранилища storage экземпляра p1. Создание второго экземпляра p2 и добавление к нему тех же самых атрибутов в том же порядке приводит в конечном итоге к тому, что в нем будет использовано то же самое отображение. Если, с другой стороны, добавляется другой атрибут, то первое отображение для второго экземпляра использоваться не будет.

Класс отображения Map выглядит следующим образом:

class Map(object):
    def __init__(self, attrs):
        self.attrs = attrs
        self.next_maps = {}

    def get_index(self, fieldname):
        return self.attrs.get(fieldname, -1)

    def next_map(self, fieldname):
        assert fieldname not in self.attrs
        if fieldname in self.next_maps:
            return self.next_maps[fieldname]
        attrs = self.attrs.copy()
        attrs[fieldname] = len(attrs)
        result = self.next_maps[fieldname] = Map(attrs)
        return result

EMPTY_MAP = Map({})

Отображения имеют два метода get_index и next_map. Первый метод используется для поиска индекса имени атрибута в хранилище объекта. Последний метод используется, когда к объекту добавляется новый атрибут. В этом случае объект должен пользоваться другим отображением, которое вычисляется с помощью next_map. Метод использует словарь next_maps, в котором закэшированы уже созданные отображения. Таким образом, объекты, в которых одна и та же внутренняя компоновка, будут, в конечном итоге, использовать один и тот же объект Map.

Рис.2. Транзитивность отображений

Реализация экземпляра Instance, в котором используются отображения, выглядит следующим образом:

 def __init__(self, cls):
        assert isinstance(cls, Class)
        Base.__init__(self, cls, None)
        self.map = EMPTY_MAP
        self.storage = []

    def _read_dict(self, fieldname):
        index = self.map.get_index(fieldname)
        if index == -1:
            return MISSING
        return self.storage[index]

    def _write_dict(self, fieldname, value):
        index = self.map.get_index(fieldname)
        if index != -1:
            self.storage[index] = value
        else:
            new_map = self.map.next_map(fieldname)
            self.storage.append(value)
            self.map = new_map

Теперь класс передает None в Base в качестве словаря полей, т. к. содержимое словаря в Instance будет хранить по-другому. Поэтому нужно переопределить методы _read_dict и _write_dict. В реальной реализации нам следовало бы реорганизовать класс Base так, чтобы он больше не отвечал за хранение словаря полей, но т. к. сейчас у нас есть хранилище None, то этого достаточно.

Во вновь созданном экземпляре используется отображение EMPTY_MAP, в котором нет атрибутов, а хранилище пустое. Чтобы реализовать _read_dict, отображение, используемое в экземпляре, запросит индекс имени атрибута. Затем будет возвращен соответствующий элемент списка хранилища.

Возможны две ситуации при записи в словарь полей. С одной стороны может быть изменено значение существующего атрибута. Это выполняется с помощью простого изменения хранилища с соответствующим индексом. С другой стороны, если атрибут еще не существует, то, согласно транзитивности отображений (рис.2), нужно пользоваться методом next_map. В список хранилища будет добавлено значение нового атрибута.

Что можно достичь с помощью такой оптимизации? Она в общем случае, когда есть много экземпляров с одной и той же внутренней организацией, уменьшает объем используемой памяти. Эта оптимизация не универсальная: код, который создает экземпляры с совершенно разными наборами атрибутов будет тратить больше памяти, чем если бы мы просто пользовались словарями.

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

Еще одним интересным аспектом отображений является то, что, хотя здесь оптимизация касалась только затрачиваемой памяти, в реальных виртуальных машинах, в которых применяется компилятор JIT (компилирующий «на лету»), требуется также повышение производительности программы. Для того, чтобы этого достичь, компилятор JIT с тем, чтобы полностью избавиться от всех поисков в словаре, применяет отображения, преобразующие поиск атрибутов в поиск в хранилище объектов с фиксированным смещением [7].

Потенциально возможные расширения

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

  • Самое простое, что можно сделать, это еще добавить специальные методы. Некоторые простые и интересные методы, которые можно добавить, это __init__, __getattribute__, __set__.
  • Модель может быть очень легко расширена для поддержки множественного наследования. Чтобы сделать это, в каждом классе должен быть список базовых классов. Затем нужно изменить метод Class.method_resolution_order с тем, чтобы поддерживался поиск методов. Можно воспользоваться простым способом поиска в глубину с удалением дубликатов. Более сложным, но лучшим будет алгоритм C3, который улучшает управляемость за счет использования множественных иерархий наследования ромбовидного вида и не применяет те варианты наследования, которые считаются несущественными.
  • Более радикальным изменением является переход к модели-прототипу, когда исчезают различия между классами и экземплярами.

Выводы

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

Такие объектные модели могут быть также полезны на практике, а не только в качестве предмета экспериментирования. Их можно встраивать и использовать в других языках. Примеры такого подхода известны: объектная модель GObject, написанная на языке C, которая используется в библиотеке GLib и в других библиотеках Gnome; или различные системы классов, реализованные в языке JavaScript.

  1. P. Cointe, “Metaclasses are first class: The ObjVlisp Model,” SIGPLAN Not, vol. 22, no. 12, pp. 156–162, 1987.
  2. Кажется, что модель, базирующаяся на атрибутах, более сложная, поскольку нужно искать метод и делать вызов. На практике, когда происходит вызов метода, выполняется поиск и вызывается специальный атрибут __call__, так что концептуальная простота восстанавливается. Но в данной главе это не реализовано.
  3. G. Kiczales, J. des Rivieres, and D. G. Bobrow, The Art of the Metaobject Protocol. Cambridge, Mass: The MIT Press, 1991.
  4. A. Goldberg, Smalltalk-80: The Language and its Implementation. Addison-Wesley, 1983, page 61
  5. В языке Python второй аргумент является классом, в котором был найден атрибут, но здесь это игнорируется
  6. C. Chambers, D. Ungar, and E. Lee, “An efficient implementation of SELF, a dynamically-typed object-oriented language based on prototypes,” in OOPSLA, 1989, vol. 24
  7. Описание работы выходит за рамки рассмотрения данной главы. Я попытался подробно рассказать об этом в статье, которую я написал несколько лет назад. В ней используется объектная модель, которая, по большей мере, является вариантом модели из статьи: C. F. Bolz, A. Cuni, M. Fijałkowski, M. Leuschel, S. Pedroni, and A. Rigo, “Runtime feedback in a meta-tracing JIT for efficient dynamic languages,” in Proceedings of the 6th Workshop on Implementation, Compilation, Optimization of Object-Oriented Languages, Programs and Systems, New York, NY, USA, 2011, pp. 9:1–9:8.

Дополнение

Перевод данной главы сделан по тексту преварительной публикации. 12 июля 2016 был выпущен и опубликован на сайте AOSA окончательный вариант сборника "500 строк или меньше", четвертой книги из серии книг "Архитектура приложений с открытым исходным кодом". Окончательный вариант данной главы можно найти по следующей ссылке.