Monday, June 29, 2009

Что же делать с рекурсивными объектами ?

На данный момент SnakeYAML не умеет правильно работать с объектами, которые ссылаются на самих себя (напрямую или через другие объекты). Вот сама проблема. При этом имплементация сериализации (dumping) проще и это работает. Десериализация сложнее и имплементация потребует значительных изменений в коде при создании объектов.
Так как в настоящий момент у меня нет нужды работать с рекурсивными объектами, то никаких телодвижений в данном направлении не наблюдается. Если кому-нибудь захочется это починить, то все будут только рады.
В своё время я спросил у разработчика PyYAML, как это там сделано. Кирилл дал очень полный и исчертывающий ответ. (Спасибо!) К сожалению, в Jav'e не доступны те средства, которые используются для этого в PyYAML - generators. Поэтому импортировать реализацию в SnakeYAML не получится.
Для тех, кто не боится интересных задач привожу здесь ответ Кирилла:


Попробую объяснить так. Нам дан ориентированный граф (YAML document) с выделенной вершиной - корнем документа. У каждой вершины есть метка - tag. Каждому тегу мы можем сопоставить конструктор - функцию, которая принимает в качестве параметра вершину графа и возвращает native object. Задача состоит в том, чтобы преобразовать весь документ и вернуть native object соответствующий корню документа.

Как это сделать? Самый простой случай - когда alias-ы вообще не используются. Тогда граф представляет из себя дерево, корень дерева - это корень документа. Алгоритм преобразования дерева в объект можно представить так:

чтобы преобразовать узел дерева:
для sequence node: преобразовываем узлы входящие в
последовательность (рекурсивно)
для mapping node: преобразовываем узлы входящие в
отображение (рекурсивно)
преобразовываем сам узел и возвращаем его.

Не многим сложней и случай, когда alias-ы допускаются, но не создают циклов в графе. В этом случае достаточно добавить в алгоритм проверку:

если узел уже преобразован, вернуть соответствующий объект.

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

--- &A [ *A ]

последовательность в которой один элемент - сама последовательность. Создать такую последовательность в Python-е можно так:

seq = []
seq.append(seq)

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

Итак, мы разбиваем конструктор на две различные функции: создание и заполнение. Для создания объекта не требуется знаний про другие вершины документа и соответствующие им объекты, для заполнения - требуются. Алгоритм выглядит так:

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

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

t = (1, 2, 3)

Это делает невозможным создание tuple содержащей саму себя, т.е. объект,
соответствующий документу:

--- !!python/tuple &A [ *A ]

невозможно построить. Тем не менее, это не значит, что tuple не может входить в сложные рекурсивные структуры. Например документ:

-- !!python/tuple &A [ [ *A ] ]

можно преобразовать так:

l = []
t = (l,) # это конструктор tuple из одного элемента в Python-е
l.append(t),
return t

а документ:

--- &A [ !!python/tuple [ *A ] ]

так:

l = []
t = (l,)
l.append(t)
return l

Интересно, что в обоих случаях создание объектов происходит одинаково.

Итак, дело усложняется тем, что конструкторы бывают двух видов - такие, в которых создание объектов требует информации про другие вершины и такие - в которых создание и заполнение объекта можно разделить. Что делать в этом случае? Назовем вершину A зависимой от вершины B, если для создания объекта, соответствующего вершине A требуется знать объект, соответствующий вершине B. Классифицировать зависимости в документе не сложно: если конструктор вершины разбивается на два шага: создание и заполнение, то вершина ни от кого не зависит. Если же конструктор одношаговый, то вершина зависит от своих элементов.

Далее, отсортируем все вершины так, чтобы B находилась перед A когда A зависит от B. Это делается с помощью так называемой "топологической сортировки" - варианта поиска в глубину в графе. Если вершины отсортированы в нужном порядке, то к ним можно применять конструкторы, зная что объекты, которые требуются для преобразования вершины к этому времени уже созданы. Итак, окончательный алгоритм выглядит так:

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

Как видно, пример с взаимно рекурсивными списком и tuple вполне соответствует этому алгоритму. В PyYAML описанные шаги алгоритма не выражены явно, однако по сути используется именно такой подход.


Буду безумно благодарен тому, кто не пожалеет времени перевести сие на английский, чтобы помочь желающим решить проблему.

6 comments:

alex13 said...

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

Работает следующим образом:
Основная идея - 2х проходной конструктор. На первом этапе создается пустой объект, а на втором инициализация внутренностей.
Но не все объекты создаются используя 2 прохода, т.к. для объектов без рекурсивных ссылок это ни к чему.

При создании графа возможно идентифицировать рекурсивные узлы, что и делается. На этом этапе они помечаются, как нуждающиеся в 2х проходной инициализации.

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

Если такой 2х проходной объект является ключом в Хеше или находится в Множестве, то он туда помещается уже после 2го прохода, т.к. не полностью инициализированый объект может иметь другой хэш код по сравнению с "чистым", только что созданым объектом.

Но из-за этого скорее всего порядок элементов в LinkedMap(Set) будет неправильным, если хоть один из ключей такой рекурсивный объект.
Было бы неплохо написать тест на такой случай, но пока я этого не сделал.

Другая проблема, если 2х проходной объект использутся по ХЭШУ где-то вне Map или Set

Патч я приатачил к Issue 1 в гуугле коде.

Andrey Somov said...

Огромное спасибо. Это была последняя не реализаванная часть спецификации YAML 1.1.
У меня есть некоторые вопросы, и мне кажется лучше будет если мы перед тем как залить решение в основной репозиторий закроем все неясности в отдельном месте.
У меня остался account on Assembla:
http://hg.assembla.com/snakeyaml

(http://www.assembla.com/spaces/snakeyaml/trac_mercurial_tool)

Я уже залил туда патчик из issue 1 со своими комментариями.
Нужно немного подшлифовать код и добавить тестов. Вместе у нас получиться гораздо быстрее.
Когда закончим, я залью всё на Google Code. Mercurial рулит!

По-моему, про порядок элементов в рекурсивных структурах можно смело забыть - он не может быть правильным или неправильным: его просто нет. Тот факт, что они как-то упорядочены в YAML документе не имеет значения, это просто особенность dump реализации.

"Другая проблема, если 2х проходной объект использутся по ХЭШУ где-то вне Map или Set" - задача SnakeYAML создать объект, а как он будет впоследствии использаваться это отдельный вопрос. Или я чего-то не догоняю ?

Andrey Somov said...

Я добавил HumanTest. Часть теста не проходит.

http://trac-hg.assembla.com/snakeyaml/browser/src/test/java/org/yaml/snakeyaml/recursive/HumanTest.java

alex13 said...

"Другая проблема, если 2х проходной объект использутся по ХЭШУ где-то вне Map или Set" - задача SnakeYAML создать объект, а как он будет впоследствии использаваться это отдельный вопрос. Или я чего-то не догоняю ?

Создание объектов - это да, первоочередная задача. Хотя не менее важно, как мне кажется, чтобы объекты создавались правильные (работоспособные).
Попробую на примере про Map(Set) ключи. В основном используются Map'ы и Set'ы хранящие ключи на основе хеш кода. По умолчанию хеш код основан на адресе объекта и т.п., но можно же переписать его и вычислять на основе каких-то значений внутри объекта, как например списки и теже мапы вычисляют свой хеш на основа элементов, входящих в них. Т.е. только созданный список будет имень другой хеш код, чем, если в него добавить элемент.
Пример
class Person { id = 0 name = "name" hashCode = id bestFriend=&Person}

set { Person1( 1,name,&Person1 ), Person2 (2,name2,&Person1), Person3 (3, name2, &Person3} }

Сохранили мы это в док и считываем назад. Двое из нашего множества являются лучшим другом самого себя, т.е. этих "товарищей" мы будем создавать в 2 этапа. Допустим мы так все и сделаем, но тогда результат у нас будет не тот, что мы ожидаем:
set.contains(Person1) false
set.contains(Person2) true
set.contains(Person3) false
set.contains(Person(0,name,...)) true и содержать этот скорее всего будет Person3

А случится это все потому, что при создании на первом этапе для первого и 3го мы создаем просто чистый объект, хэш которого 0, и кладем его во множество. То, что на 2м этапе инициализации хеши для них меняются, никаким образом не "волнует" множество :)

Вот для этого необходим последний проход (заполнение мапов и сетов), в случае, если ключ является 2х проходным объектом. надеюсь несильно запутано ;)

alex13 said...

по поводу HumanTest. Кое что я внем поправил, патч приатачу туда же (на основе сорцов с ассембла)

ТестДети не проходил, потому, что дети в классе это список и лоадер просто не знает какого типа объекты должны лежать в списке. Если добавить класс дескриптор, то все проходит (что я и сделал в патче). Но это, кстати следующий вопрос, касающийся именно JavaBeans, если какие-то свойства бина список или еще хуже мап, содержащий другие бины, то без дескрипторов класса, указывающих какого типа списки и т.п. загрузить правильно объект не представляется возможным, т.к. в дампе нет тегов о классе членов коллекций. В общем-то конечно это уменьшает размер и загруженность всякими тагами файл, но с другой стороны делает немного заморочным использование с более менее сложной моделью. Как вариант, может быть можно сделать режим в опциях дампера (useCustomTags4Beans) или что-то в этом роде. Кто хочет сможет включить.


testCollectionRing на мой взгляд далек от совершенства почасти он связан как раз с тем, о чем я писал в прошлом комментарии. Когда дампится сет, его содержание переписывается в мап, как ключи, и тут-то получается проблема StackOverflow. Т.к. хеш считается для списка,мапа,сета основываясь на хеше элементом, то получается замкнутый круг, никак не возможно остановится :)
Возможно было бы неплохо переделать дамп сетов и мапов, чтобы избежать этого, но такие извороты сами по себе довольно опасная штука ;)

пошёл атачить патч

Andrey said...

Cущественно снизился test coverage in the org.yaml.snakeyaml.constructor. Хотелось бы бобольше тестов.