Формат сериализации Marshal в Ruby

В Ruby любой объект можно превратить (сериализовать) в последовательность байт, а потом без каких-либо потерь обратно восстановить в объект. Для этого нужен модуль Marshal и методы dump и load, которые идут из коробки. Это очень любопытный механизм, потому что он универсальный (может сериализовать любой объект любого класса) и совместимый (как backward так и forward) с предыдущими версиями Ruby. Кроме того одна из целей - это маленький размер дампа. Экономят буквально на байтах. Учитывая динамическую природу Ruby, богатство функционала и большую стандартную библиотеку разработать такой формат было нетривиальной задачей.

Давайте разберем в деталях этого формата на примере Ruby 3.2.

Содержание

Структура дампа

В начале дампа сохраняется версия формата. Она кодируется двумя байтами. Текущая версия - 4.8. Далее идут данные самого объекта.

Есть несколько форматов данных, которые описывают:

  • как универсальное представление для объекта,
  • так и built-in классы из core library
  • и дополнительные свойства объекта - его instance variables, подмешанные в объект (используя метод #extend) модули итд.

В начале каждого формата идет 1 байт - префикс формата. Обычно это ASCII символ, который указывает на название формата. И затем уже идут данные. Форматы могут быть вложенными друг в друга.

Для объектов пользовательских классов применяется универсальный формат объекта - сохраняется имя класса и instance variables. C многочисленными built-in классами из core library все сложнее - большая их часть реализована на С и данные хранятся во внутренних структурах, а не instance variables.

Такие built-in классы могут использовать свои специализированные форматы, например классы String или Symbol. Или как Range использовать универсальный формат объекта и сохранять данные как instance variables, но при десериализации читать данные из instance variables и инициализировать свои внутренние структуры.

true

Singleton объект класса TrueClass представлен одним байтом - ASCII код символа “T”:

\x04\bT

false

Singleton объект класса FalseClass представлен одним байтом - ASCII код символа “F”:

\x04\bF

nil

Singleton объект класса NilClass представлен одним байтом - ASCII код цифры “0”:

\x04\b0

Integer в примерах

Числа представлены довольно сложным образом - это описано в отдельном разделе. Сейчас нам хватит только формата для положительных маленьких чисел (до 127).

Такие числа представлены одним байтом, а к значению прибавляется 5 (так как значения 1-4 зарезервированы для представления бОльших чисел). Таким образом число 1 кодируется как 6 - 0x06 в 16-й системе и "\x06" в виде строки.

Приведем значения, которые будут использоваться ниже:

число +5 в 16-чной системе в виде строки
2 7 0x07 “\a”
3 8 0x08 “\b”
4 9 0x09 “\t”
5 10 0x0A “\n”
6 11 0x0B “\v”
7 12 0x0C “\f”
8 13 0x0D “\r”
9 14 0x0E “\0E”
10 15 0x0F “\0F”

0 кодируется особым способом - это просто 0x00.

Префикс для Integer - ASCII код символа “i”.

И в итоге число 10, например, выглядит следующим образом:

\x04\bi\x0F

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“i” префикс формата Integer
“\x0F” Integer 10

Объекты

Все объекты за редким исключением используют универсальный формат объектов. Сохраняется имя класса и instance variables. Как следствие нельзя сериализовать объект анонимного класса.

Префикс для объектов - ASCII код символа “o”.

Формат состоит из

  • префикса объекта (“o”),
  • затем имя класса в формате Symbol (“:” + длина + строка)
  • затем количество instance variables
  • затем instance variables как пары имя-значение, где имя в формате Symbol

Дамп объекта класса User и instance variables @foo = 1 и @bar = 2:

\x04\bo:\tUser\a:\t@fooi\x06:\t@bari\a

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“o” префикс объекта
”:” префикс Symbol
“\t” Integer 4
“User” строка
“\a” Integer 2
”:” префикс Symbol
“\t” Integer 4
“@foo” строка
“i” префикс Integer
“\x06” Integer 1
”:” префикс Symbol
“\t” Integer 4
“@bar” строка
“i” префикс Integer
“\a” Integer 2

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

o
  : "\t" User
  "\a"
  : "\t" @foo
  i "\x06"
  : "\t" @bar
  i "\a"

где:

  • o - префикс объекта
  • : "\t" User - имя класса
  • "\a" - количество instance variables - 2
  • : "\t" @foo - имя переменной @foo
  • i "\x06" - ее значение 1
  • : "\t" @bar - имя переменной @bar
  • i "\a" - ее значение 2

Объект с подмешанным модулем

В объект можно подмешать модуль используя метод #extend. В таком случае имя этого модуля тоже попадает в дамп. Как следствие нельзя сделать дамп если модуль анонимный.

Префикс для подмешанного модуля - ASCII код символа “e”.

Формат состоит из:

  • префикс “e”
  • имя модуля в формате Symbol
  • дамп самого объекта

Дамп объекта класса User с подмешанным модулем Comparable:

\x04\be:\x0FComparableo:\tUser\x00

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“e” префикс подмешанного модуля
”:” префикс Symbol
“\x0F” Integer 10
“Comparable” строка
“o” префикс объекта
”:” префикс Symbol
“\t” Integer 4
“User” строка
“\x00” Integer 0

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

e
  : "\x0F" Comparable
  o
    : "\t" User
    "\x00"

где:

  • e - префикс подмешанного модуля
  • : "\x0F" Comparable - имя модуля
  • o - префикс объекта
  • : "\t" User - имя класса
  • "\x00" - количество instance variables - 0

Ссылка на объект

Вместо объекта в дампе может встретиться ссылка на него. Нередко объект повторяется и в первый раз он заносится в таблицу объектов, а во второй раз в дамп попадает его порядковый номер в таблице. Для обратной совместимости очень важно сохранить порядок обхода вложенных объектов - элементов коллекции (таких как Array или Hash) или instance variables.

Не все объекты попадают в таблицу объектов и заменяются ссылками. Singleton объекты, такие как nil, true, false или Symbol’ы, не заменяются ссылками (для Symbol’ов ведется отдельная таблица, но об этом позже). Целые singleton числа (которые помещаются в нативный int) тоже не заменяются ссылками.

Кстати, таблица объектов решает и проблему циклов.

Префикс для ссылки на объект - ASCII код символа @.

Формат состоит из:

  • префикс “@”
  • номер объекта в таблице объектов

Дамп массива с двумя элементами [a = Object.new, a]:

\x04\b[\ao:\vObject\x00@\x06

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
”[” префикс Array
“\a” Integer 2
“o” префикс объекта
”:” префикс Symbol
“\v” Integer 6
“Object” строка
“\x00” Integer 0
”@” префикс ссылки на объект
“\x06” Integer 1

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

[
  "\a"
  o
    : "\v" Object
    "\x00"
  @ "\x06"

где:

  • [ - префикс Array
  • \a - количество элементов в Array - 2
  • o : "\v" Object "\x00" - 1й элемент - объект
  • @ "\x06" - 2й элемент - ссылка на объект #1 (#0 - это сам Array)

Пользовательский формат

Формат Marshal расширяем и можно задавать способ, как сериализовать объект конкретного пользовательского класса. Поддерживается два способа задать новый формат.

Методы #_dump и ._load

Можно реализовать свой механизм сериализации объекта строку и десериализации из нее. Для этого надо реализовать в пользовательском классе методы #_dump и ._load. Метод #_dump возвращает строку, а метод ._load создает и возвращает объект десериализованный из строки.

Префикс для объекта - ASCII код символа u.

Формат состоит из:

  • префикс “u”
  • имя класса
  • строка с сериализованными данными

Возьмем пример из документации

class MyObj
  def initialize name, version, data
    @name    = name
    @version = version
    @data    = data
  end

  def _dump level
    [@name, @version].join ':'
  end

  def self._load args
    new(*args.split(':'))
  end
end

Дамп объекта MyObj.new('Apollo', 11, 'July 20, 1969'):

\x04\bIu:\nMyObj\x0EApollo:11\x06:\x06ET

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объекта built-in класса с instance variables
“u” префикс объекта сериализованного используя механизм #_dump и ._load
”:” префикс Symbol
“\n” Integer 5
“MyObj” строка
“\x0E” Integer 9
“Apollo:11” строка
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“E” строка
“T” true

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

I
  u
    : "\n" MyObj
    "\x0E"
    Apollo:11
  "\x06"
  : "\x06" E
  T

где:

  • I - префикс объекта built-in класса с instance variables
  • u - префикс объекта сериализованного используя механизм #_dump и ._load
  • : "\n" MyObj - имя класса
  • "\x0E" Apollo:11 - сериализованные данные
  • "\x06" - количество instance variables - 1
  • : "\x06" E - имя переменной E
  • T - ее значение - true

Формат объекта built-in класса с instance variables описан ниже. Instance variable E и значение true означают кодировку строки UTF-8.

Методы #marshal_dump и #marshal_load

Есть и более простой вариант. Можно не реализовывать полностью свой формат, а положиться на уже существующий. Метод #marshal_dump должен вернуть объект, который будет сериализован вызовом Marshal.dump. А метод #marshal_load вызывается на созданном в Marshal.load но не инициализированном объекте (используя метод Class#allocate) и должен выполнить инициализацию используя десериализованный объект. Удобно использовать контейнеры из core library, такие как Array или Hash.

Префикс для объекта - ASCII код символа U.

Формат состоит из:

  • префикс “U”
  • имя класса
  • дамп объекта, который вернул метод #marshal_dump

Возьмем пример из документации

class MyObj
  def initialize name, version, data
    @name    = name
    @version = version
    @data    = data
  end

  def marshal_dump
    [@name, @version]
  end

  def marshal_load array
    @name, @version = array
  end
end

Дамп объекта MyObj.new('Apollo', 11, 'July 20, 1969'):

\x04\bU:\nMyObj[\aI"\vApollo\x06:\x06ETi\x10

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“U” префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
”:” префикс Symbol
“\n” Integer 5
“MyObj” строка
”[” префикс Array
“\a” Integer 2
“I” префикс объекта built-in класса с instance variables
”"” префикс String
“\v” Integer 6
“Apollo” строка
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“E” строка
“T” true
“i” префикс Integer
“\x10” Integer 11

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

U
  : "\n" MyObj
  [
    "\a"
    I
      " "\v" Apollo
      "\x06"
      : "\x06" E
      T
    i "\x10"

где:

  • U - префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
  • : "\n" MyObj - имя класса в формате Symbol
  • [ - префикс Array
  • "\a" - количество элементов в Array
  • I " "\v" Apollo "\x06" : "\x06" E T - 1й элемент - строка “Apollo”
  • i "\x10" - 2й элемент - Integer 11

Built-in классы из core library

Как уже отметили выше часть классов из core library, которые реализованы на C, не используют универсальный формат объектов. Большая их часть использует свой собственный формат, например String, Array и Hash.

Некоторые классы используют формат пользовательской сериализации с методами #_dump и ._load, например Time и Encoding. Но они реализовывают методы #_dump и ._load как приватные, то есть нельзя полагаться на их наличие и вызывать в пользовательском коде (если, например, мы решим реализовать формат Marshal самостоятельно)

Для Range используют универсальный формат объектов. Данные сериализуются в виде Range source строки и instance variables. Вот только у объекта Range нет таких instance variables, а значит сериализация и десериализация Range реализована иначе чем для универсального формата объектов.

По этим причинам и пользовательская сериализация и универсальная сериализация объектов для built-in классов это тоже часть формата Marshal и будут описаны ниже.

Instance variables

В дамп можно сохранить instance variables для любого объекта. Для этого есть специальный формат и префикс “I”:

  • префикс “I”
  • дамп объекта
  • количество instance variables
  • пары имя переменной и ее значение

По факту этот формат используется только для форматов built-in классов и для пользовательской сериализации используя методы #_dump и ._load (данные сериализуются в строку, а для строки надо сохранить ее кодировку - для этого сохраняют instance variable E или encoding).

Часто у объекта built-in класса есть какие-то атрибуты, например кодировка строки, или название часового пояса. Такие атрибуты могут быть опциональными. Они сериализуются как псевдо instance variables используя такой механизм.

Подклассы built-in классов

Built-in классы можно наследовать. Для подклассов Array, Hash, Regexp и String введен дополнительный префикс “C”, который означает, что надо использовать формат родительского класса, но создавать объект дочернего.

Формат состоит из:

  • префикс “C”
  • имя дочернего класса
  • дамп объекта (в формате родительского built-in класса)

Рассмотрим пример для подкласса Array:

class MyArray < Array
end

Дамп объекта MyArray.new([0]):

\x04\bC:\fMyArray[\x06i\x00

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“C” префикс подкласса built-in класса
”: \f MyArray” имя класса
”[\x06i\x00” дамп массива [0]

Кодировка

Некоторые классы, такие как String, Symbol и Regexp, имеют кодировку, которая тоже должна сохраняться в дамп. Кодировка сохраняется как instance variable encoding, а ее значение - название кодировки. В некоторых случаях используют instance variable E.

Есть несколько вариантов представления кодировки:

кодировка объекта представление
ASCII-8BIT кодировка не сохраняется в дампе
US-ASCII instance variable E и значение “F” (false)
UTF-8 instance variable E и значение “T” (true)
остальные кодировки instance variable encoding, значение - название кодировки

Строка “foobar” с кодировкой ASCII-8BIT:

\x04\b"\vfoobar

Как видим, здесь нет instance variable и как следствие префикса “I” тоже нет.

Строка с кодировкой US-ASCII:

\x04\bI"\vfoobar\x06:\x06EF

Строка с кодировкой UTF-8:

\x04\bI"\vfoobar\x06:\x06ET

Строка с кодировкой отличной от приведенных выше, например UTF-16LE:

\x04\bI"\vfoobar\x06:\rencoding"\rUTF-16LE

String

Префикс для объекта класса String - ASCII код символа ".

Формат состоит из:

  • префикс объекта built-in класса с instance variables
  • префикс объекта класса String
  • длина строки (в байтах)
  • символы строки
  • количество instance variables
  • instance variables

Дамп строки "foobar":

\x04\bI"\vfoobar\x06:\x06ET

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объекта built-in класса с instance variables
”"” префикс String
“\v” Integer 6
“foobar” строка
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“E” строка
“T” true

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

I
  " "\v" foobar
  "\x06"
  : "\x06" E
  T

где:

  • I - префикс объекта built-in класса с instance variables
  • " "\v" foobar - строка “foobar”
  • "\x06" - количество instance variables - 1
  • : "\x06" E - имя переменной - E
  • T - ее значение - true

Symbol

Префикс для объекта класса Symbol - ASCII код символа :.

Формат состоит из:

  • префикс объекта класса Symbol
  • длина в байтах
  • символы

Дамп :foobar:

\x04\b:\vfoobar

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
”:” префикс объекта класса Symbol
“\v” длина - 6
“foobar” символы

Кодировка

Для Symbol кодировка сохраняется немного иначе, чем для String.

Если Symbol имеет кодировку US-ASCII или ASCII-8BIT - тогда кодировка не сохраняется в дампе вообще. При чтении объекта из дампа кодировка по-умолчанию - ASCII-8BIT. Если нет явно сохраненной кодировки и все символы ASCII-совместимые (т.е. codepoint в диапазоне 0-127), тогда выставляется кодировка US-ASCII.

Дамп для Symbol с ASCII-8BIT ("\xFF".b.to_sym):

\x04\b:\x06\xFF

Дамп для Symbol с US-ASCII ("a".to_sym):

\x04\b:\x06a

Ссылка на Symbol

Подобно таблице объектов ведется и таблица символов. Вместо повторяющегося объекта класса Symbol в дамп попадает ссылка на него.

Префикс для ссылки на Symbol - ASCII код символа ;.

Формат состоит из:

  • префикс “;”
  • номер Symbol в таблице символов

Дамп массива из двух элементов [:symbol, :symbol]:

\x04\b[\a:\vsymbol;\x00

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
”[” префикс Array
“\a” Integer 2
”:” префикс Symbol
“\v” Integer 6
“symbol” строка
”;” префикс ссылки на Symbol
“\x00” Integer 0

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

[
  "\a"
  : "\v" symbol
  ; "\x00"

где:

  • [ - префикс Array
  • "\a" - количество элементов в Array - 2
  • : "\v" symbol - 1й элемент - :symbol
  • ; "\x00" - 2й элемент - ссылка на Symbol #0

Array

Префикс для объекта класса Array - ASCII код символа [.

Формат состоит из:

  • префикс объекта класса Array
  • количество элементов
  • элементы массива

Дамп массива [1, 2, 3]:

\x04\b[\bi\x06i\ai\b

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
”[” префикс объекта класса Array
“\b” Integer 3
“i” префикс Integer
“\x06” Integer 1
“i” префикс Integer
“\a” Integer 2
“i” префикс Integer
“\b” Integer 3

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

[
  "\b"
  i "\x06"
  i "\a"
  i "\b"

где:

  • [ - префикс массива
  • "\b" - длина массива, 3
  • i "\x06" - 1й элемент - число 1
  • i "\a" - 2й элемент - число 2
  • i "\b" - 3й элемент - число 3

Hash

Префикс для объекта класса Hash - ASCII код символа {.

Формат состоит из:

  • префикс объекта класса Hash
  • количество пар ключ-значение
  • последовательность пар

Дамп {a: 9}:

\x04\b{\x06:\x06ai\x0E

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
”{“ префикс объекта класса Hash
“\x06” Integer 1
”:” префикс объекта класса Symbol
“\x06” Integer 1
“a” символы Symbol
“i” префикс объекта класса Integer
“\x0E” Integer 9

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

{
  "\x06"
  : "\x06" a
  i "\x0E"

где:

  • { - префикс объекта класса Hash
  • "\x06" - размер - 1 пара ключ-значение
  • : "\x06" a - ключ - :a
  • i "\x0E" - значение - число 9

Значение по-умолчанию

Hash может иметь значение по-умолчанию и это тоже должно сохраняться в дамп.

Префикс для объекта класса Hash со значением по-умолчанию - ASCII код символа }.

Формат состоит из:

  • префикс }
  • количество пар ключ-значение
  • последовательность пар
  • значение по-умолчанию

Дамп объекта класса Hash с парой a => 9 и значением по-умолчанию :foo:

\x04\b}\x06:\x06ai\x0E:\bfoo

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
”}” префикс для объекта класса Hash со значением по-умолчанию
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“a” символы Symbol
“i” префикс Integer
“\x0E” Integer 9
”:” префикс Symbol
“\b” Integer 3
“foo” символы Symbol

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

}
  "\x06"
  : "\x06" a
  i "\x0E"
  : "\b" foo

где:

  • } - префикс объекта класса Hash со значением по-умолчанию
  • "\x06" - размер - 1 пара ключ-значение
  • : "\x06" a - ключ - :a
  • i "\x0E" - значение - число 9
  • : "\b" foo - значение по-умолчанию - :foo

Флаг compare_by_identity

У Hash’а есть флаг compare_by_identity, который влияет на поведение:

Sets self to consider only identity in comparing keys; two keys are considered the same only if they are the same object; returns self.

Поэтому он тоже должен сохраняться в дампе.

Формат состоит из:

  • префикс C:\tHash
  • префикс объекта класса Hash
  • количество пар ключ-значение
  • последовательность пар

Дамп {a: 9} с выставленным флагом compare_by_identity:

\x04\bC:\tHash{\x06:\x06ai\x0E

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“C” префикс экземпляра класса, который наследуют built-in класс
”:” префикс Symbol
“\t” Integer 4
“Hash” строка
”{“ префикс объекта класса Hash
“\x06” Integer 1
”:” префикс объекта класса Symbol
“\x06” Integer 1
“a” символы Symbol
“i” префикс объекта класса Integer
“\x0E” Integer 9

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

C
  : "\t" Hash
  {
    "\x06"
    : "\x06" a
    i "\x0E"

где:

  • C - префикс экземпляра класса, который наследуют built-in класс
  • : "\t" Hash - имя built-in класса - :Hash
  • { - префикс объекта класса Hash
  • "\x06" - размер - 1 пара ключ-значение
  • : "\x06" a - ключ - :a
  • i "\x0E" - значение - число 9

Флаг ruby2_keywords

Hash можно выставить также еще один флаг - ruby2_keywords (документация). Он сохраняется в дампе как instance variable K.

Рассмотрим дамп для Hash.ruby2_keywords_hash({a: 1}):

\x04\bI{\x06:\x06ai\x06\x06:\x06KT

Как видим добавилась переменная K и значение T (true).

Class

Префикс для объекта класса Class - ASCII код символа c.

Формат состоит из:

  • префикс класса
  • длина имени класса
  • имя класса

Дамп класса String:

\x04\bc\vString

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“c” префикс класса
“\v” Integer 6
“String” строка

Module

Префикс для объекта класса Module - ASCII код символа m.

Формат состоит из:

  • префикс модуля
  • длина имени модуля
  • имя модуля

Дамп модуля Enumerable:

\x04\bm\x0FEnumerable

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“m” префикс модуля
“\x0F” Integer 10
“Enumerable” строка

Range

Объект класса представлен в стандартном формате объекта.

Формат состоит из:

  • префикс объекта
  • имя класса
  • количество instance variables (3)
  • instance variables - excl, begin, end

Обратите внимание, что имена переменных без символа @ в начале, как у обычного объекта. Методы #instance_variable_get и #instance_variable_set требуют, чтобы имя переменной было корректным и начиналось с “@”.

Дамп Range 1..2:

\x04\bo:\nRange\b:\texclF:\nbegini\x06:\bendi\a

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“o” префикс объекта
”:” префикс Symbol
“\n” Integer 5
“Range” строка
“\b” Integer 3
”:” префикс Symbol
“\t” Integer 4
“excl” строка
“F” false
”:” префикс Symbol
“\n” Integer 5
“begin” строка
“i” префикс Integer
“\x06” Integer 1
”:” префикс Symbol
“\b” Integer 3
“end” строка
“i” префикс Integer
“\a” Integer 2

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

o
  : "\n" Range
  "\b"
  : "\t" excl
  F
  : "\n" begin
  i "\x06"
  : "\b" end
  i "\a"

где:

  • o - префикс объекта
  • : "\n" Range - имя класса объекта - Range
  • "\b" - количество instance variables - 3
  • : "\t" excl - имя instance variable :excl
  • F - ее значение - false
  • : "\n" begin - имя instance variable begin
  • i "\x06" - ее значение - 1
  • : "\b" end - имя instance variable end
  • i "\a" - ее значение - 2

Beginless и endless Range

Если границу Range не задать - то в дампе сохраняется значение nil (символ '0') в соответствующей instance variable - begin или end.

Beginless Range (..2):

\x04\bo:\nRange\b:\texclF:\nbegin0:\bendi\a

Instance variable begin выставлена в nil - :\nbegin0.

Endless Range (1..):

\x04\bo:\nRange\b:\texclF:\nbegini\x06:\bend0

Instance variable end выставлена в nil - :\bend0.

.. и …

Признак включения правой границы (который возвращает метод #exclude_end?) сохраняется в instance variable excl как булевое значение true или false, которые сериализуются в символы T и F соответственно.

Дамп для 1..2:

\x04\bo:\nRange\b:\texclF:\nbegini\x06:\bendi\a

В переменной :excl сохранили false: :\texclF.

Дамп для 1...2:

\x04\bo:\nRange\b:\texclT:\nbegini\x06:\bendi\

В переменной :excl сохранили true: :\texclT.

Regexp

Префикс для объекта класса Regexp - ASCII код символа /.

Формат состоит из:

  • префикс объект built-in класса с instance variables
  • префикс объекта класса Regexp
  • длина строки Regexp source (в байтах)
  • символы строки Regexp source
  • Regexp options (число)
  • количество instance variables
  • instance variables

Для Regexp сохраняется только одна instance variable - E (или encoding), в которой сохраняется данные о кодировке.

Дамп Regexp /abc/:

\x04\bI/\babc\x00\x06:\x06EF

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объект built-in класса с instance variables
”/” префикс Regexp
“\b” Integer 3
“abc” строка
“\x00” байт 0
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“E” строка
“F” false

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

I
  /
    "\b" abc
    "\x00"
  "\x06"
  : "\x06" E
  F

где:

  • I - префикс объект built-in класса с instance variables
  • / - префикс Regexp
  • "\b" abc - Regexp source - “abc”
  • "\x00" - Regexp options - 0
  • "\x06" - количество instance variables - 1
  • : "\x06" E - переменная E
  • F - ее значение false

Timeout

В Ruby 3.2 у Regexp появился параметр timeout, но в дамп он не сохраняется:

Дамп Regexp.new("abc", timeout: 5):

\x04\bI/\babc\x00\x06:\x06EF

Time

Для класса Time используют формат пользовательской сериализации используя методы #_dump и ._load.

Формат состоит из:

  • префикс объекта built-in класса с instance variables
  • префикс объекта сериализованного используя механизм #_dump и ._load
  • имя класса - Time
  • бинарное представление даты/времени
  • количество instance variables
  • instance variables - обязательные offset и zone и опциональные nano_num, nano_den и submicro

В offset сохраняется UTC offset в секундах, а в zone - название часового пояса (например EET).

Важная для реализации деталь - instance variables Time заносятся в таблицу объектов до того как в таблицу попадает сам объект Time, в отличии от всех остальных форматов со вложенными объектами.

Дамп для Time.new('2023-12-03 18:30:59 +0300'):

\x04\bIu:\tTime\ro\xEC\x1E\x80\x00\x00\xB0{\a:\voffseti\x020*:\tzone0

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объекта built-in класса с instance variables
“u” префикс объекта сериализованного используя механизм #_dump и ._load
”:” префикс Symbol
“\t” Integer 4
“Time” строка
“\r” Integer 8
“o\xEC\x1E\x80\x00\x00\xB0{“ строка
“\a” Integer 2
”:” префикс Symbol
“\v” Integer 6
“offset” строка
“i” префикс Integer
“\x020*” Integer 10800
”:” префикс Symbol
“\t” Integer 4
“zone” строка
“0” nil

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

I
  u
    : "\t" Time
    "\r"
    "o\xEC\x1E\x80\x00\x00\xB0{"
  "\a"
  : "\v" offset
  i "\x020*"
  : "\t" zone
  0

где:

  • I - префикс объекта built-in класса с instance variables
  • u - префикс объекта сериализованного используя механизм #_dump и ._load
  • : "\t" Time - имя класса Time
  • "\r" - длина строки в байтах
  • "o\xEC\x1E\x80\x00\x00\xB0{" - бинарное представление даты/времени
  • "\a" - количество instance variables - 2
  • : "\v" offset - имя переменной offset
  • i "\x020*" - ее значение - Integer 10800 - число секунд в 3-х часах
  • : "\t" zone - имя переменной zone
  • 0 - ее значение - nil

Бинарное представление

Время/дата занимают всего 8 байт. В них упакованы все составляющие Time - часы, минуты, секунды итд:

диапазон бит длина компонент
0-4 5 часы
5-9 5 день месяца
10-13 4 месяц - 1
14-29 16 год - 1900
30 1 время в UTC?
31 1 константа 0x1UL
32-51 20 миллисекунды
52-57 6 секунды
58-63 6 минуты

Возьмем пример выше - время 2023-12-03 18:30:59 +0300 кодируется следующей строкой:

o\xEC\x1E\x80\x00\x00\xB0{

В бинарном виде это:

01101111 11101100 0001111010 000000 00000000 00000000 10110000 01111011

Разобьем его на компоненты:

диапазон бит компонент биты значение
0-4 часы 0 1111 15, учитывая utc offset +0300 часы равны 18
5-9 день месяца 0 0011 3
10-13 месяц - 1 1011 11, 11 + 1 = 12
14-29 год - 1900 0000 0000 0111 1011 123, 123 + 1900 = 2023
30 время в UTC? 0 0, нет
31 константа 0x1UL 1 1
32-51 миллисекунды 0000 0000 0000 0000 0000 0
52-57 секунды 11 1011 59
58-63 минуты 01 1110 30

zone

В переменную zone сохраняется значение возвращаемое методом Time#zone - это название локального часового пояса или nil.

В дампе объекта в локальном часовом поясе Time.local(2023, 12, 3, 18, 30, 59):

\x04\bIu:\tTime\rp\xEC\x1E\x80\x00\x00\xB0{\a:\voffseti\x02 \x1C:\tzoneI"\bEET\x06:\x06EF

в переменную zone сохраняется строка “EET” - :\tzoneI"\bEET\x06:\x06EF.

В случае с UTC:

\x04\bIu:\tTime\rr\xEC\x1E\xC0\x00\x00\xB0{\x06:\tzoneI"\x00\x06:\x06EF

в zone сохраняется пустая строка - :\tzoneI\"\x00\x06:\x06EF.

nano_num, nano_den и submicro

Time хранит время с точностью до наносекунд, то есть до 10 -9 . В бинарном представлении хранятся только микросекунды (точность до 10 -6 ), а оставшиеся 3 разряда для наносекунд представляются дополнительными instance variables nano_num, nano_den и submicro.

Если верить комментарию в исходном коде, то переменная submicro нужна только для совместимости с Ruby 1.9.1. Это строка из двух символов, байтовое представление которых представляет 4 десятичные цифры. Первые 3 из них представляют 3 разряда для наносекунд, а 4-я отбрасывается.

Рассмотрим пример с 123456789 наносекундами.

Дамп для Time.new(2000, 12, 31, 23, 59, 59.1234567891):

\x04\bIu:\tTime\r\xF5/\x19\x80@\xE2\xB1\xEF\n:\rnano_numl+\bwqYfF\xC5:\rnano_denl+\b\x00\x00\x00\x00@\x00:\rsubmicro"\ax\x90:\voffseti\x02 \x1C:\tzoneI"\bEET\x06:\x06EF

Значение переменной submicro - строка “x\x90”, или в 16й системе 78 90. Первые 3 цифры соответствуют 7-й, 8-й и 9-й цифре наносекунд 123456789.

Одновременно эту же информацию кодируют переменные nano_num и nano_den. Число 789 представлено в виде дроби, числитель и знаменатель которой сохранены в этих переменных.

В примере выше nano_num равна 216906155520375, а nano_den - 274877906944. Целая часть от деления nano_num на nano_den равна 789.

Struct

Префикс для объекта подкласса Struct - ASCII код символа S.

Формат состоит из:

  • префикс Struct
  • имя класса
  • количество атрибутов (members)
  • последовательность пар имя атрибута и его значение

Дамп структуры Struct.new("Person", :name).new("Alex"):

\x04\bS:\x13Struct::Person\x06:\tnameI"\tAlex\x06:\x06ET

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“S” префикс Struct
”:” префикс Symbol
“\x13” Integer 14
“Struct::Person” строка
“\x06” Integer 1
”:” префикс Symbol
“\t” Integer 4
“name” строка
“I” префикс объекта built-in класса с instance variables
”"” префикс String
“\t” Integer 4
“Alex” строка
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“E” строка
“T” true

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

S
  : "\x13" Struct::Person
  "\x06"
  : "\t" name
  I
    " "\t" Alex
    "\x06"
    : "\x06" E
    T

где:

  • S - префикс Struct
  • : "\x13" Struct::Person - имя класса “Struct::Person”
  • "\x06" - количество атрибутов - 1
  • : "\t" name - имя атрибута - “name”
  • I " "\t" Alex "\x06" : "\x06" E T - значение атрибута - строка “Alex”

Encoding

Для класса Encoding используют формат пользовательской сериализации используя методы #_dump и ._load.

Формат состоит из:

  • префикс объекта built-in класса с instance variables
  • префикс объекта сериализованного используя механизм #_dump и ._load
  • имя класса - Encoding
  • название кодировки
  • количество instance variables
  • instance variables

Дамп Encoding::UTF_8:

\x04\bIu:\rEncoding\nUTF-8\x06:\x06EF

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объекта built-in класса с instance variables
“u” префикс объекта сериализованного используя механизм #_dump и ._load
”:” префикс Symbol
“\r” Integer 8
“Encoding” строка
“\n” Integer 5
“UTF-8” строка
“\x06” Integer 1
”:” префикс Symbol
“\x06” Integer 1
“E” строка
“F” false

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

I
  u
    : "\r" Encoding
    "\n"
    UTF-8
  "\x06"
  : "\x06" E
  F

где:

  • I - префикс объекта built-in класса с instance variables
  • u - префикс объекта сериализованного используя механизм #_dump и ._load
  • : "\r" Encoding - имя класса
  • "\n" - длина строки в байтах
  • UTF-8 - имя encoding в виде последовательности байтов
  • "\x06" - количество instance variables - 1
  • : "\x06" E - имя переменной - E
  • F - ее значение false

Float

Префикс для объекта класса Float - ASCII код символа f.

Формат состоит из:

  • префикс Float
  • длина строки
  • строковое представление в 10-чной системе

Дамп числа 3.14:

\x04\bf\t3.14

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“f” префикс Float
“\t” Integer 4
“3.14” строка

Экспоненциальная запись

Иногда число может быть в экспоненциальной записи (scientific notation):

Дамп литерала 1e10:

\x04\bf\t1e10

Видим, что число сохранили в исходной нотации - 1e10.

Специальные значения (NaN, Infinity)

Специальные значения, такие как Float::INFINITY и Float::NAN, сохраняются особым образом - в обычном формате Float, но строковое представление - “inf” и “nan” соответственно:

Marshal.dump(Float::INFINITY) # => "\x04\bf\binf"
Marshal.dump(Float::NAN) # => "\x04\bf\bnan"

Отрицательные числа

Знак сохраняется в строковом представлении:

  • -3.14 - \x04\bf\n-3.14
  • -Float::INFINITY - \x04\bf\t-inf

Rational

Для класса Rational используют формат пользовательской сериализации используя методы #marshal_dump и #marshal_load. Сериализуется массив из двух элементов - числитель и знаменатель (numerator и denominator).

Формат состоит из:

  • префикс объекта built-in класса с instance variables
  • префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
  • имя класса - Rational
  • числитель и знаменатель
  • количество instance variables
  • instance variables

Дамп Rational(5, 6):

\x04\bU:\rRational[\ai\ni\v

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объекта built-in класса с instance variables
“U” префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
”:” префикс Symbol
“\r” Integer 8
“Rational” строка
”[” префикс Array
“\a” Integer 2
“i” префикс Integer
“\n” Integer 5
“i” префикс Integer
“\v” Integer 6

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

U
  : "\r" Rational
  [
    "\a"
    i "\n"
    i "\v"

где:

  • U - префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
  • : "\r" Rational - имя класса - Rational
  • [ - префикс Array
  • "\a" - количество элементов - 2
  • i "\n" - 1й элемент - 5
  • i "\v" - 2й элемент - 6

Complex

Для класса Complex используют формат пользовательской сериализации используя методы #marshal_dump и #marshal_load. Аналогично Rational для Complex сериализуется массив из двух элементов - действительное число (real) и мнимое (imagine).

Формат состоит из:

  • префикс объекта built-in класса с instance variables
  • префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
  • имя класса - Complex
  • действительное и мнимое числа
  • количество instance variables
  • instance variables

Дамп литерала 5 + 6i:

\x04\bU:\fComplex[\ai\ni\v

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“I” префикс объекта built-in класса с instance variables
“U” префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
”:” префикс Symbol
“\f” Integer 8
“Complex” строка
”[” префикс Array
“\a” Integer 2
“i” префикс Integer
“\n” Integer 5
“i” префикс Integer
“\v” Integer 6

Если сгруппировать отдельные фрагменты, то выйдет следующая структура:

U
  : "\f" Complex
  [
    "\a"
    i "\n"
    i "\v"

где:

  • U - префикс объекта сериализованного используя механизм #marshal_dump и #marshal_load
  • : "\f" Complex - имя класса - Complex
  • [ - префикс Array
  • "\a" - количество элементов - 2
  • i "\n" - 1й элемент - 5
  • i "\v" - 2й элемент - 6

Integers

Префикс для объекта класса Integer - ASCII код символа i.

Для представления целых чисел используют несколько форматов:

  • 0 представлено значением 0 (0x00)
  • небольшие значения (до 122 (0x7A)) занимают 1 байт, и хранятся как n + 5
  • значения до 2 30 - 1 (0x3FFFFFFF) хранятся в формате <количество байт><байты> с little-endian порядком байт
  • значения больше 2 30 - 1 хранятся в формате Bignum (разберем его немного позже)
значения формат пример
0 0x00 0x00 - \x04\bi\x00
0x01..0x7A n + 5 0x01 - \x04\bi\x06
0x7B..0xFF 0x01 + 1 байт 0xF1 - \x04\bi\x01\xF1
0x0100..0xFFFF 0x02 + 2 байта 0xABCD - \x04\bi\x02\xCD\xAB
0x010000..0xFFFFFF 0x03 + 3 байта 0xABCDEF - \x04\bi\x03\xEF\xCD\xAB
0x01000000..0x3FFFFFFF 0x04 + 4 байта 0x03ABCDEF - \x04\bi\x04\xEF\xCD\xAB\x03
0x40000000.. Bignum 0xABCDEF98 - \x04\bl+\a\x98\xEF\xCD\xAB

Отрицательные числа

Отрицательные числа кодируется аналогично, только используется two’s complement представление.

Для представления целых чисел используют несколько форматов:

  • небольшие значения (до -123 (-0x7B)) занимают 1 байт, и хранятся как n - 5
  • значения до -2 30 (-0x40000000) хранятся в формате <количество байт><байты> с little-endian порядком байт и количество байт тоже кодируется в two’s complement формате
  • значения меньше -2 30 хранятся в формате Bignum (разберем его немного позже)
значения формат пример
-0x7B..-0x01 n - 5 -0x01 - \x04\bi\x06
-0x100..-0x7C 0xFF + 1 байт -0x100 - \x04\bi\xFF\x00
-0x10000..-0x0101 0xFE + 2 байта -0x10000 - \x04\bi\xFE\x00\x00
-0x1000000..-0x10001 0xFD + 3 байта -0x1000000 - \x04\bi\xFD\x00\x00\x00
-0x40000000..-0x1000001 0xFC + 4 байта -0x40000000 - \x04\bi\xFC\x00\x00\x00\xC0
..-0x40000001 Bignum -0x40000001 - \x04\bl-\a\x01\x00\x00@

Формат Bignum

Префикс для больших значений Integer - ASCII код символа l.

Диапазон значений, которые представляются в этом формате, это < -2 30 или 2 30 .

Формат состоит из:

  • префикс “l”
  • знак “+” или “-“
  • количество байт деленное на 2
  • бинарное представление числа

Дамп числа -2 30 :

\x04\bl+\a\x00\x00\x00@

Разберем этот дамп:

фрагмент значение
“\x04\b” версия формата (4.8)
“l” префикс Bignum Integer
”+” знак числа - ‘+’
“\a” длина в байтах деленная на 2 равна 2, следовательно длина равна 4 байтам
“\x00\x00\x00@” бинарное представление числа -2 30 (0x40000000)

Bignum и большие Fixnum

Исторически сложилось, что числа могут трактоваться как объекты с точки зрения таблицы объектов, а могут и нет:

значения формат входят в таблицу объектов
-2 30 i < 2 30 префикс "i" (Fixnum) нет
-2 62 i < -2 30 , 2 30 i < 2 62 префикс "l" (Bignum) нет
i < -2 62 , i 2 62 префикс "l" (Bignum) да

Подробнее о больших Fixnum расскажу ниже в “Переносимость между платформами”.

Решения в этом формате

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

Внимание также уделили удобству разработчиков - несмотря на бинарный формат дампа его можно читать как есть благодаря символьным префиксам. Например, дамп String начинается с символа/байта ", Array - с [, а Hash - с {.

Компактность

Компактность достигается разными способами.

Самый яркий пример - представление целых чисел. Формат чисел имеет переменную длину. Малые значения (и, вероятно, самые часто используемые) занимают всего один байт. БОльшие значения занимают 2, 3 и 4 байта плюс 1 дополнительный байт для длины. Совсем большИе числа (и, вероятно, самые редкие) требуют уже от двух и больше дополнительных байт.

Singleton объекты true, false и nil, которые часто встречаются в коде, занимают также всего один байт.

Для повсеместно используемых String сериализуются еще и кодировка символов. Часто используемые кодировки US-ASCII и UTF-8 сохраняются в компактном виде - как “T” (true) или “F” (false) в сумме занимая всего 4 байта. Для остальных кодировок сохраняется полное название плюс имя переменной encoding.

Еще один способ сэкономить место - избежать дублирования с помощью таблиц объектов и Symbol’ов. Вместо всего объекта-дубликата в дамп попадает ссылка - его номер в таблице.

Переносимость между платформами

Ruby можно использовать на совсем разных аппаратных платформах и дамп сделанный на одно платформе должен успешно прочитаться на любой другой.

Основная проблема возникает в представлении чисел - может отличаться порядок байт - big-endian или little-endian, разрядность чисел - 32 или 64 бита, нативное представление чисел с плавающей точкой. Поэтому формат изолируют от особенностей реальной платформы.

Примеры:

  • бинарное представление целых чисел строго в little-endian порядке
  • Float представлены в строковой форме полностью избегая бинарного представления (интересное обсуждение потери точности)
  • большие Fixnum (которым нужно от 32 до 64 бит) не попадают в таблицу объектов, хотя и сохраняются в формате Bignum
Проблема больших Fixnum

Проблема больших Fixnum любопытна. CRuby по возможности хранит целые числа в нативном int и называет это Fixnum. Значения, которые не помещаются в int, реализованы как объекты и называются Bignum. Эта деталь реализации скрыта за общим Ruby классом Integer. CRuby поддерживает как x86 так и amd64 платформы и дамп созданный на amd64 должен прочитаться на x86 и наоборот. На amd64 Fixnum использует 62 бита, а на x86 соответственно 30 бит. Поэтому Fixnum на amd64, которые занимает от 30 до 62 бит (большой Fixnum), с точки зрения CRuby на x86 уже Bignum.

Эта проблема решена следующим способом - на amd64 большие Fixnum сериализуются в формате Bignum, а не Fixnum. Поэтому такой дамп успешно прочтется на x86. Но теперь усложнится чтение формата Bignum на amd64 - результат может быть как Fixnum так и Bignum в зависимости от значения числа.

Совместимость

Из версии в версию core library Ruby расширяется - добавляется новый функционал, фиксятся баги, делают breaking changes. Меняется и формат Marshal.

В дампе сохраняется версия формата (текущая 4.8), но уже много лет она не меняется. Это означает, что все изменения в формате должны быть одновременно forward и backward совместимы. То есть дамп сделанные в старой версии Ruby должен корректно читаться в новой версии. И наоборот - дамп сделанный в новой версии должен прочитаться в старой. Давайте посмотрим как это получается.

Новый атрибут built-in класса можно попросту не сериализовывать. Например Regexp#timeout в Ruby 3.2 есть, а в дамп его не сохраняют.

Новый атрибут может сохраниться как instance variable. Так сохраняют флаг Hash ruby2_keywords - в виде переменной K.

Еще один добавленный атрибут Hash compare_by_identity сохранился совершенно неожиданным способом - используя формат “подкласс built-in класса”. К дампу Hash просто добавился префикс C:\tHash, что должно означать, что это объект класса Hash унаследованного от built-in класса. Но так как built-in класса это все тот же Hash то получилось странно и длинно.

А вот с изменением дела сложнее. Например, наносекунды Time раньше сохранялись в instance variable submicro как строка, а сейчас в instance variables nano_num и nano_den как числа. Чтобы добиться forward и backward совместимости сейчас сохраняются и читаются и submicro и nano_num с nano_den.

Гибкость и расширяемость

Гибкость и динамичность Ruby потребовало гибкости и от формата сериализации. Возможность комбинировать свойства объектов (instance variables, подмешанные модули) и разные форматы самого объекта (универсальный формат объекта, объект разных built-in классов, класс, модуль, пользовательские форматы) позволило просто и красиво выразить многообразие свойств объекта в Ruby.

Возможность задавать пользовательский формат сериализации оказалась такой удачной, что ее используют для сериализации некоторых built-in классов, например, Time, Complex, Rational и Encoding.

Итоги

Формат Marshal получился и компактный и гибкий и расширяемый. Еще и невооруженным глазом можно читать.

С другой стороны он не совсем бинарный и, как следствие, компактный (в строковом виде сохраняются Float и Bignum, а сами строки не сжаты). Можно и компактнее ужать. Но формат и не совсем текстовый - с числами и Time надо возиться на уровне битов и байтов.

Текущая версия формата 4.8 с нами уже много-много лет. Она появилась не то в Ruby 1.8.0 не то в какой-то из версий 1.7.x. За годы накопился груз изменений и компромиссов, чтобы сохранить совместимость. Накопились не оптимальные решения. И с этим придется жить до следующей версии.

PS

Для удобства чтения дампов, декодирования чисел и описания фрагментов дампа я сделал небольшой gem marshal-parser.

Ссылки