Библиотека сайта 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.
- P. Cointe, “Metaclasses are first class: The ObjVlisp Model,” SIGPLAN Not, vol. 22, no. 12, pp. 156–162, 1987.
- Кажется, что модель, базирующаяся на атрибутах, более сложная, поскольку нужно искать метод и делать вызов. На практике, когда происходит вызов метода, выполняется поиск и вызывается специальный атрибут
__call__
, так что концептуальная простота восстанавливается. Но в данной главе это не реализовано. - G. Kiczales, J. des Rivieres, and D. G. Bobrow, The Art of the Metaobject Protocol. Cambridge, Mass: The MIT Press, 1991.
- A. Goldberg, Smalltalk-80: The Language and its Implementation. Addison-Wesley, 1983, page 61
- В языке Python второй аргумент является классом, в котором был найден атрибут, но здесь это игнорируется
- 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
- Описание работы выходит за рамки рассмотрения данной главы. Я попытался подробно рассказать об этом в статье, которую я написал несколько лет назад. В ней используется объектная модель, которая, по большей мере, является вариантом модели из статьи: 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 был выпущен и
опубликован