С сериализацией всё вроде бы просто. Есть значение x, мы превращаем его в последовательность байт, передаём куда‑то ещё, а потом восстанавливаем:
x → encode → bytes → decode → x'
И первое, что хочется проверить:
decode(encode(x)) == x
Если получилось — значит, сериализация работает. По крайней мере, так кажется. Потому что в этой формуле есть одна маленькая деталь, которую легко не заметить: что именно означает ==?
Когда одинаковое — не то же самое
Возьмём совершенно обычный Go‑код: a := &Node{Value: 42} и x := []*Node{a, a}.
В x два элемента ссылаются на один объект. Можно посмотреть на содержимое и увидеть два раза Node{Value: 42}, но это не два независимых объекта. Если изменить x[0], изменение будет видно через x[1].
Теперь представим, что после сериализации и восстановления мы получили два отдельных Node с теми же полями. Содержимое совпадает, тип совпадает, на первый взгляд всё выглядит правильно. Но исходное отношение между двумя ссылками исчезло. И это отношение вполне можно наблюдать из программы.
Так что же именно должен означать успешный round‑trip? Можно сделать вопрос ещё неприятнее:
m := map[string]any{}m["self"] = m
Здесь значение содержит ссылку на само себя. Если рассматривать его как набор вложенных данных, получается довольно странная картина: внутри значения находится ссылка на это же значение. Если же посмотреть на структуру связей, всё становится естественнее. Но тогда оказывается, что нам нужно сохранить не только содержимое объектов. Нужно сохранить то, как объекты связаны друг с другом.
Есть и ещё один случай, который хорошо показывает, насколько легко потерять часть смысла:
a := make([]byte, 8) x := a[:4] y := a[2:6]
x и y содержат свои последовательности байт, но используют одну backing array. Можно восстановить те же байты, те же длины и получить два slice, которые совершенно нормально выглядят при чтении.
И всё же после восстановления поведение может быть другим: изменение через один slice перестаёт быть изменением той же области памяти, которую видит другой. Получается странная вещь. Мы можем сохранить все данные и всё равно потерять что‑то, что было частью наблюдаемого поведения исходного значения. В этот момент вопрос уже перестаёт быть вопросом про конкретный pointer, map или slice. Он становится вопросом о самом понятии значения.
Вопрос старше конкретного формата
На первый взгляд всё это легко списать на особенности Go: pointers, cycles, slices, особенности runtime. Но подобные вопросы возникают всякий раз, когда значение пересекает границу процесса, памяти, языка или машины. В какой‑то момент приходится решить, какая часть исходного состояния должна пережить эту границу. И почти никогда не требуется сохранять абсолютно всё.
Поэтому сериализация давно научилась выбирать подходящий уровень абстракции. Для одной системы достаточно сохранить данные. Для другой важно сохранить типы. Где‑то нужна структура, но совершенно неважна идентичность объектов. Где‑то, наоборот, два пути к одному объекту должны остаться двумя путями к тому же объекту.
Если важна совместимость между языками, приходится искать общий уровень представления и отказываться от части особенностей конкретного runtime. Если важна простота, нет смысла переносить через wire format всю семантику исходной среды выполнения. Если важна компактность, часть информации можно считать избыточной. Если важна скорость, некоторые отношения между объектами можно не восстанавливать.
Все эти решения выглядят разумно. Более того, именно так и должны выглядеть хорошие инженерные решения: не сохранять то, за что пользователь не готов платить. Проблема появляется позже. Когда один и тот же выбор повторяется достаточно много раз, мы перестаём замечать, что это был выбор. «Это не сохраняется» постепенно превращается в «это не является частью значения». «Эти два объекта будут восстановлены независимо» — в «их идентичность несущественна». «У одного значения может быть несколько бинарных представлений» — в «каноничность нам не нужна». Компромисс начинает выглядеть как определение самой задачи. Если свойство не сохраняется достаточно долго, мы перестаём воспринимать его потерю как потерю.
Что именно мы называем значением?
Допустим, мы говорим: «Нужно сохранить значение». Но какое именно свойство значения мы имеем в виду? Его содержимое? Тип? Структуру? Идентичность объектов? Связи между ними? Точное представление? Возможность наблюдать те же изменения через разные ссылки?
Для огромного количества практических задач этот вопрос вообще не возникает. Если после декодирования приложение получает нужные данные, задача решена. Но как только хотя бы одно из этих свойств становится существенным, привычная формула round‑trip начинает требовать уточнения. Не потому, что она неправильная. Просто за одним == оказалось слишком много разных понятий эквивалентности.
Мы привыкли представлять сериализацию как преобразование уже определённого значения в байты и обратно. Но чтобы сказать, что значение сохранилось, сначала приходится решить, какие свойства этого значения считаются существенными. А это уже не столько вопрос encoding, сколько вопрос контракта. И здесь граница между «самим значением» и «деталями реализации» становится частью определения того, что именно мы собираемся сохранять.
Если значение не дерево
Для простых структур удобно представлять сериализацию как обход дерева: есть объект, у него есть поля, в полях лежат другие значения, коллекции содержат элементы, элементы содержат следующие значения. Для огромного класса задач такая модель прекрасно работает. Но объект, на который ссылаются из десяти мест, уже немного неудобен для такого представления. Это не десять одинаковых поддеревьев, а одна сущность, к которой ведут десять путей.
Циклическая ссылка делает это ещё очевиднее. И тогда появляется другая модель: значение можно рассматривать не только как дерево данных, но и как граф объектов. Эта идея сама по себе совершенно не нова, но она меняет то, что именно приходится сохранять. Нужно сохранить не только то, что находится в вершинах графа, но и отношения между вершинами. Общий объект должен остаться общим объектом, а цикл — циклом.
Как только начинаешь смотреть на значение таким образом, некоторые привычные компромиссы уже не выглядят неизбежными. Они становятся следствием выбранной модели. Отсюда возникает довольно простой вопрос: что получится, если эту модель не упрощать заранее?
Но даже граф нужно как‑то представить
Предположим, мы решили, что структура связей имеет значение. Теперь возникает другая проблема. Одно и то же значение можно представить в wire format несколькими способами:
x → bytes₁
x → bytes₂
Оба потока могут быть корректными. Для многих систем этого совершенно достаточно: decoder получает допустимое представление и восстанавливает значение. Но иногда сами байты становятся самостоятельным объектом. Их хешируют, сохраняют как snapshot, сравнивают между процессами, используют для дедупликации или просто передают дальше, ожидая воспроизводимого результата.
Тогда вопрос немного меняется. Если два значения эквивалентны, должны ли их бинарные представления тоже совпадать? Если да, то что именно означает «эквивалентны»? И может ли одно значение иметь несколько корректных wire‑представлений? Снова нет универсального ответа, который обязан подходить всем. Можно выбрать более слабый контракт. Можно выбрать более сильный. Но сам выбор становится частью дизайна формата. И это ещё один случай, когда то, что раньше выглядело как деталь реализации, неожиданно оказывается частью семантики.
А потом выясняется, что bytes могут быть враждебными
До этого мы всё время предполагали, что decode получает нормальный результат работы encode. Это удобная модель. Но реальный decoder часто получает просто bytes: из файла, из сети, от другого процесса, от другой версии программы. А иногда — из источника, которому доверять вообще не стоит. Поток может быть обрезан, повреждён, сформирован вручную или специально построен так, чтобы заставить decoder сделать что‑нибудь неожиданное. И тогда появляется вопрос, которого вообще не было в исходной формуле:
Если вход утверждает, что внутри него находится огромное значение, сколько ресурсов decoder должен быть готов на него потратить?
Это уже не совсем вопрос о сохранении значения. Здесь речь идёт о цене его восстановления. Можно ограничивать глубину, размеры коллекций, количество объектов, объём входа, объём создаваемой памяти. Можно отвергать некоторые входы заранее. Но где провести границу? Что именно считать ресурсом, который должен быть ограничен? На каком этапе decoder должен понять, что вход слишком дорогой? И является ли это свойством конкретной реализации или частью самого контракта формата? Чем дальше разворачивается эта цепочка, тем труднее считать все эти вопросы независимыми.
Старые проблемы, новые сочетания
Самое любопытное здесь в том, что ни одна из этих идей не выглядит революционной сама по себе. Графовые структуры известны давно. Проблема identity известна давно. Canonical representations известны давно. Ограничение ресурсов при обработке недоверенного входа — тоже. И сериализация давно умеет выбирать компромиссы между ними. Обычно просто приходится выбирать, что важнее именно в данной системе.
Сохранить больше — значит усложнить формат. Сделать представление более строгим — значит ограничить свободу encoder‑а. Сохранить больше структуры — значит заставить decoder знать и восстанавливать больше. Ужесточить ограничения на ресурсы — значит отказать некоторым потенциально корректным входам. Это не недостатки. Это цена. И именно поэтому традиционный подход выглядит настолько естественным: сначала определить, что действительно нужно приложению, а затем не платить за остальное.
Но можно поставить вопрос немного иначе. Что произойдёт, если начать не с того, чем мы готовы пожертвовать, а с того, что хотим гарантировать? Не пытаться сохранить всё подряд и не утверждать, что существует универсальная семантика значения. Просто сделать границу явной и попробовать провести её немного дальше привычного. Возможно, окажется, что цена слишком высока. Возможно, некоторые требования окажутся несовместимыми. Возможно, в каких‑то местах придётся вернуться к старым компромиссам. Но это уже можно проверить.
Когда вопросы сходятся
То, что сначала выглядит как несколько разных задач, постепенно собирается в одну картину. Нужно понять, что именно означает сохранение значения. Какое представление этого значения допустимо. Можно ли сделать такое представление однозначным. Какую цену decoder имеет право заплатить за восстановление входа.
На поверхности это всё ещё разные проблемы. Но если рассматривать wire format как контракт между двумя состояниями программы, разделять их становится уже не так просто. Семантика значения влияет на его представление. Представление — на каноничность. Структура значения — на стоимость восстановления. А ограничения decoder‑а, в свою очередь, влияют на то, какие представления вообще допустимы. Получается не набор независимых требований, а система взаимных ограничений.
И тогда главный вопрос можно сформулировать совсем просто: насколько далеко вообще можно сдвинуть границу между тем, что принято считать существенным свойством значения, и тем, чем принято жертвовать ради простоты?
А если границу всё‑таки сдвинуть?
Мы уже знаем, почему одни свойства обычно сохраняют, а от других отказываются. Знаем, что за каждым таким отказом стоит цена. Но из этого ещё не следует, что выбранная граница — единственно возможная. Можно попробовать провести её дальше. Сохранить не только содержимое, но и отношения между объектами. Не допускать нескольких представлений там, где они не нужны. Заранее ограничить цену, которую decoder готов заплатить за восстановление входа.
Вопрос теперь не в том, разумны ли такие требования. Разумны. Вопрос в другом: что произойдёт, если попытаться собрать их в одном формате? Именно эту границу и пытается исследовать GBON.
У него есть спецификация, реализация и набор публичных экспериментов. И в них уже можно посмотреть, что происходит, когда требования, которые обычно рассматривают по отдельности, оказываются частью одного wire format.
Если хочется продолжить эту линию, начать можно со спецификации GBON, а затем посмотреть на публичный showcase — там эта конструкция уже встречается не в виде идей, а в виде работающего формата и экспериментов.

