Tag Archives: Serialization

对象序列化 Marshal 格式 dump 规则

前言

序列化就是把对象的数据转换成字节流。Ruby 的对象序列化和反序列化是通过 Marshal 这个模块进行的,这个过程有一个别称叫”marshalling”,取其“编排成组,井然有序”之意(在计算机科学中“编组”和“序列化”另有微妙的区别,但在 Ruby 中似乎并不需要考虑)。Ruby 对所有的可序列化类型都赋予了默认的序列化编码格式,下文是基于 Marshal 4.8 版本的 marshal.c 源文件的序列化格式分析。

源文件参考:http://ruby-doc.org/doxygen/1.8.4/marshal_8c-source.html

整体格式

最先写入的两个字节,分别标识了 Marshal 的主要版本号和次要版本号,标识着一个对象数据的数据头,可以是 0-255 之间的的任何值。每调用 Marshal.dump 一次序列化一个对象,Ruby 都会将这两个字节写入流中。写入版本号的意义在于,如果 $VERBOSE 标记为 true,Marshal.load 只会在版本号匹配时才进行反序列化,防止了反序列化不兼容的对象数据导致的错误。

紧跟着版本号的是对象数据的开头。其存储格式随着对象类型的不同而不同,但无论如何都肯定包含一个类型指示符,用来指定对象的类型。有的对象只需要一个类型指示符来存储,如 NilClass、 FalseClass、TrueClass 这些简单类型的对象;而其它类型的对象在类型指示符后还会有一个或多个的长整数值。长整数这个名称取自 marshal.c 源文件中的 w_long 函数名,也就是 C 系语言中的长整型 long。w_long 函数就是用来写入一个长整数的。要注意的是,在序列化后,这些长整数并不是占用固定数量的字节(如 32 位),而是会以某种空间优化后的格式存储,其占用的字节数是不定的。在这些需要长整数值的类型中,不同的类型的长整数值也有不同的意义。有的直接保存了对象数据,如 Fixnum 类型;有的保存了对象需要的额外数据所占的字节数,如 Bignum、Float 类型;有的则是保存了对象的附属对象(即子对象,成员变量、实例变量)的个数,如 Array、Hash 类型;有的则拥有更复杂的格式,如沿用了默认 Object 序列化格式的自定义类型,被了一个模块扩充了的对象,以及由 String 继承而来的类型等。

这些格式在下文还会详细讲解,但看到这里,你应该知道一个对象的数据中,可能包含的三种最基本的东西:类型指示符、长整数值和可能的额外数据。额外的数据可以是直接的数据值,也可能是一个以上的附属对象的“子数据”,而这些附属对象的数据格式可能是上述格式中的任何一种。不难看出这些数据的存储有着递归的特性。marshal.c 源文件中,有一个用来读取各种类型对象数据的泛用函数 r_object0,它会递归调用其本身来读取更深一层次的附属对象数据。

每次 Marshal.dump 都只会写入一个对象,包括这个对象内部所有的成员。如果多次调用 Marshal.dump 将对象数据写入到同一个字节流中,那么在该流中就会出现多个主版本号和副版本号数据头。

长整数值的格式

长整数值的格式应该是 Ruby 对象序列化中需要讨论的最重要的概念了,这种手法在数据存储技术领域上是通用的。如果我直接告诉你存储方式,而你之前又从来没有分析过存档文件的结构,你可能会觉得这种方式有点匪夷所思,不知道为什么要如此大绕弯子。为了知其所以然,下面长篇大论都是一个关于数据偏移法的讲解,已经熟知的朋友完全可以跳过。

伟大的 bluecat 曾经讲过这么一个故事:从前有个人叫小明,他想存储很多整数到文件中,但又想节省空间,不用传统的固定 16/32/64 位保存一个整数的方法,而是根据整数的大小调整其所占的字节数,使得该整数尽可能占用更少的空间。于是小明在写入数据时,果然只用了最少的字节,比原来传统的方法节约了大概一半的空间。小明很高兴,放心地把数据搁在一旁,伸了个懒腰,开始忙别的事了。忽一日阳光明媚,同学们召开全校大会,谁说小明没准备,那是在没有搜索引擎的旧社会。小明早就把大会上需要的几个关键数字存储到了文件中,养兵千日,现在是用兵之时了。小明哈哈大笑,哼着小曲找到了数据文件,高高兴兴地打开 VB,写下了几行经典的 IO 初始化代码。正敲着键盘,蓦地一怔,意识到了一个严峻的问题:这些数字分别是占多少个字节来着?原来啊,小明当初在写入整数的时候,并没有考虑到之后读取数据时不知道一个整数占多少字节的问题。不知道一个整数占多少字节,又怎么能知道什么时候该停止读取一个整数的数据呢?小明自恃聪明,用了节省空间的存储方式,得意洋洋之际,哪料得到得到日后还有这许多麻烦。行成于思毁于随,做事一定要设想周全,考虑全面。小朋友们,你们明白了吗?

Ruby 冰雪聪明,也想效仿前人小明,尽量节省空间。因此 Marshal.dump 写入的数据,都是尽可能地使用了更少的字节。但 Ruby 比小明高明之处在于,她有一套方法能够在读取数据时获知一个数据所占的字节数。于是她定下了这样的规则:

当前字节 x 的范围 意义
-128 ≤ x ≤ -5 当前字节为直接数据 x+5
-4 ≤ x ≤ -1 接下来是有 |x| 个字节的负整数
x = 0 当前字节为直接数据 0
1 ≤ x ≤ 4 接下来是有 |x| 个字节的正整数
5 ≤ x ≤ 127 当前字节为直接数据 x-5

x 本身是一个有符号补码形式的字节。-123 至 122 都可以用一个字节来表示,超过这个范围的整数数据就通过一个值为 -4 至 -1 或 1 至 4 的字节来指定存储一个更大的整数需要用的字节数及其符号,之后就用这么多字节存储一个整数。x 和这个整数数据一起就组成了第一节中所说的长整数值部分。由于 x 已经保存了整数的符号,紧接着 x 的实际数据就不需要符号位了(可以多表示一倍的数),但仍然需要将其当做补码来处理,这样可以直接一次位运算就把数据读取到一个整型变量中。如果按原码保存,还需要进行正负的转换,也就是按位取反后再加一,显然两者在效率上有那么一点差别。

当 x 指定了字节数量时,为了表示 -256 至 -124,123 至 255 之间的数需要一个字节,-65536 至 -257,256 至 65535 需要两个字节,-224 至 -65537,65536 至 224-1 需要三个字节,-230 至 -224-1,224 至 230-1 需要四个字节(在这些范围之外的整数就会被转换为 Bignum 类型)。

如此一来,既能节省空间,又能不丢失数据长度,确实是一个巧妙的法子。由于这个手法是通过将数据偏移了 5 来区分数据的意义,姑且就称之为数据偏移法。整数数据本身最多只需要四个字节来存储,x = ±5 则表示当前字节为直接数值 0,但这种情况似乎不会在序列化字节流中出现。

Marshal 4.8 使用的字节序是小端序,即最先读写最低有效字节,最后读取最高有效字节。

对象数据格式

以下是 Ruby 中的所有内建库可序列化类型的详细格式:

类型 类型指示符 对象数据格式 例子 注释
ASCII HEX 对象 字节流 专用 通用
任何类型 @ 40 n N/A N/A 引用链接
n = 对象数据表索引键
n∈{ 长整数值 }
0x01≤ai≤0xff
0x01≤bi≤0xff
oi∈{ 对象格式 }
ki∈{ 对象格式 }
Array [ 5b n o1…on [1, false] 5b 07 69 06 46 N/A
Bignum l 6c sign n b1…b2n 0xfffffffff 6c 2b 08 ff ff ff ff 0f 00 sign∈{ +, – }
数据以 16 位对齐
Class c 63 n b1…bn IO 63 07 49 4f n∈{ 长整数值 }
b1…bn = 类名
匿名类不可序列化
单例类不可序列化
FalseClass F 46 N/A false 46 N/A
Fixnum i 69 n -1286515 69 fd 8d 5e ec N/A
Float f 66 n b1…bn -3.145e2 66 0b 2d 33 31 34 2e 35 b1…bn = 浮点数据
Hash { 7b n k1o1…knon { 1=>"1" } 7b 06 69 06 22 06 31 (ki, oi) = (键, 值)
o = 默认值
o∈{ 对象格式 }
带默认 Proc 的
散列表不可序列化
} 7d n k1o1…knon o Hash.new(0) 7d 00 69 00
Module m 6d n b1…bn Math 6d 09 4d 61 74 68 b1…bn = 模块名
匿名模块不可序列化
M 4d N/A N/A 老版格式;已弃用
NilClass 0 30 N/A nil 30 N/A
Regexp / 2f n b1…bn m /[a-d]+/x 2f 0b 5b 61 2d 64 5d 2b 02 b1…bn = 正则表达式
m = 正则表达式选项
0x00≤m≤0xff
String " 22 n b1…bn "Hello" 22 0a 48 65 6c 6c b1…bn = 字符串
Symbol : 3a n b1…bn :sym 3a 08 73 79 6d b1…bn = 符号
; 3b n N/A N/A 符号链接
n = 符号表索引键
TrueClass T 54 N/A true 54 N/A
Struct S 53 s c Process.times 53 3a 10 53 74 72 75 63 … N/A s = 类名
s∈{ ‘:’ 型对象格式 }
c = 成员数据
c = n s1o1…snon
n = 成员个数
si = 成员变量名
si∈{ ‘:’ 型对象格式 }
oi = 成员数据
oi∈{ 对象格式 }
其余类型 o 6f s c o = Object.new
o.instance_eval(
"@a = 1")
6f 3a 0b 4f 62 6a 65
63 74 06 3a 07 40 61
普通自定义对象类型
e 65 s0 s c "".extend(Math) 65 3a 09 4d 61 74 带扩展模块的对象
s0 = 模块名
C 43 s u class A < String
end
A.new
43 3a 06 41 22 00 String, Hash, Regexp,
和 Array 的子类对象
无自定义成员
u = 父类对象数据
I 49 C s u c class A < Array
end
o = A.new
o.instance_eval(
"@a = 1")
49 43 3a 06 41 5b 00
06 3a 07 40 61 69 06
String, Hash, Regexp,
和 Array 的子类对象
有自定义成员
C = 并存的类型指示符
u = 父类对象数据
u 75 s n b1…bn N/A N/A 自定义 _dump
b1…bn = _dump 数据
U 55 s u N/A N/A 自定义 marshal_dump
u = marshal_dump 数据

在上表中,“对象数据数据”不包括类型指示符。“字节流”下面的数据则是类型指示符 + 对象数据。n∈{ 长整数值 } 的形式表示 n 是一个长整数值。

先看一下最简单的三种类型:NilClass,TrueClass,FalseClass。在 Ruby 中有三个内建的单例对象,分别是以上三个类的唯一实例:nil, true, false。这三个单例都仅仅是一种标志,分别有着“空”、“真”、“伪”的意义,并没有其它变化,也不会出现它们的“克隆实例”,因此只需要一个字节来标识这个对象。于是 nil、true、false 分别被编码为 ‘0’、’T’、’F’。

再来就是稍微复杂一点的 Fixnum 类型。Ruby 的 Fixnum 可以表示 231 个不同的整数,所以肯定不能像上述三种简单类型一样存储。由于 Fixnum 最多只可能占 4 个字节,我们完全可以用一个长整数值来表示所有的 Fixnum 实例。于是 Fixnum 的格式是一个类型指示符 ‘i’ 跟着一个长整数值。

用来表示大整数的 Bignum 类型又比 Fixnum 复杂一点。跟在类型指示符 ‘l’ 后面的是一个符号字节,用来指定整数的正负性。接着是一个长整数值,指定了接下来的整数数据占据了多少个16 位字(两个字节),字节序仍然是小端序。

Float 的序列化格式花样比较多。在 Marshal 底层的 C 源码中,序列化浮点数使用的是 C 标准库里的格式化字符串函数,并使用了 %g 类型指示符。%g 是取 %e 和 %f 中长度较短的作为结果,也就是将浮点数分别用原始记数法和科学记数法表示,转换为字符串后,取长度更短的一个来序列化。比如 “3.5” 的长度小于 “3.5e1″,就会取用前者,而 “3000.0” 的长度大于 “3e3″,就会取用后者。转换为字符串后,串中的每一个字符都会被存在一个字节中。所以浮点数其中一种序列化格式就是在指定了数据字节数的长整数值后跟上这么多字节的浮点数字符串。由于双精度浮点数在不同的平台上可能会有所出入(如双精度却只有 32 位),而 Ruby 是想要让序列化数据可移植的,所以不能直接保存浮点数的二进制数据。浮点数的三个特殊值:”NaN”、”inf”、”-inf” 则是直接按照字符串的形式存储,其长整数值指定的长度分别是3、3、4。

String 的格式相对比较简单,长整数值指定字符串的长度,跟着这么长的字符串。

Symbol 基本和 String 相同,长整数值保存的是符号的长度,后面跟着这么长的字符串数据。

Regexp 的格式也基本和 String 相同,只是在表达式数据结束后还会有一个字节的数据,保存了正则表达式的选项。这个字节本身的值并不重要,重要的是其二进制数据中哪一位是 1,就像 Windows API 中的窗口样式参数一样。其格式大概是:第一位(最低有效位)指定表达式是否大小写不敏感(正则表达式后缀 i),第二位指定是否为扩展模式(后缀 x),第三位指定是否为多行模式(后缀 m)。

Class 和 Module 的格式则是类/模块名长度跟上这么长的类/模块名字符串。Ruby 并不会把类/模块的信息动态序列化到对象数据中,因为她认为你在反序列化的时候已经定义了这个类的完整结构了。匿名的类/模块是不能被序列化的(如 Class.new)。只为一个对象定义的单例类也是无法序列化的。”M” 是以前使用的模块类型指示符,现已作废。Marshal 4.8 可以读取 ‘M’ 类型,但不会写入。

Array 的序列化格式是迄今为止所讨论的第一个有递归特性的格式。在类型指示符后的长整数值是数组的长度,后面则是数组元素。每个元素又是一个附属对象(成员),其格式可以是此表中的任何一种。

Hash 的格式类似 Array,只不过在原本是数组元素的地方存储的是散列表的键值对。如果 Hash 对象有一个默认值,那么这个值(也是一个附属对象)会被添加到散列表数据结尾。附带默认 Proc 的散列表是不可序列化的,如:Hash.new {}。

大部分自定义的对象类型都会使用 ‘o’ 的格式。这些类型要么是 Object 类型本身,要么就是没有定义 _dump 和 marshal_dump 方法的子类,但是不包括 String、Regexp、Array 和 Hash 的子类。其它还没有提到的大部分的格式都是在这个格式的基础上附加一些别的数据。对象数据中的 s 是一个符号对象格式,表示的是类名,接着的 c 则保存了所有成员的数据。我们从上表的通用注释中得知,c 的格式是一个长整数值,指定了成员的个数,跟着这么多个的成员变量名(符号对象格式)以及成员变量值(对象格式),它们相互配对。

Struct 类型的格式和 ‘o’ 格式一模一样。由于 Struct::Tms 实例的数据很长,所以上表的 Struct 类型例子中并没有把全部数据写出。

‘e’ 是针对被某个模块扩展了的对象(通过 Object#extend 方法)。其数据格式是在 ‘o’ 的格式前再加上一个符号对象格式,指定扩展了这个对象的模块名,而后面的数据和 ‘o’ 格式相同。

‘C’ 是没有自定义成员的 String、Regexp、Array 或 Hash 的子类类型。s 依旧是类名,而 u 则是基类的对象数据格式(四种类型格式中的一种)。

‘I’ 是有自定义成员的 String、Regexp、Array 或 Hash 子类类型。’I’ 的格式很特殊,在类型指示符 ‘I’ 后面会继续跟着类型指示符 ‘C’,然后是依次是类名、基类数据和子类数据。这是唯一一种类型同时出现两个类型指示符的。

‘u’ 是自定义了 _dump 方法的类型。_dump 是一个实例方法,返回一个字符串,保存了这个类型的对象需要序列化的所有数据,这样就能自己设定序列化的规则。Marshal.dump 会帮你计算出 _dump 返回的字符串长度,并在字符串数据写入之前写入到字节流中。

‘U’ 是自定义了 marshal_dump 的类型。marshal_dump 也是一个实例方法,但不同于 _dump 的是,marshal_dump 返回的应该是任何一种类型的 Ruby 对象,包含了 mashal_dump 需要序列化的所有数据。所以 ‘U’ 的格式为类名后直接跟着 marshal_dump 返回的对象的数据格式。

最后是两种特殊的格式,’@’ 和 ‘;’——为了链接对象而存在的格式。Ruby 的变量是引用,把一个引用赋予另一个引用的过程是引用的拷贝,所以两个引用最终会指向同一个对象。这在反序列化的时候也需要考虑——如果一个容器对象中的所有附属对象都用其类型指示符来表示,如何才能知道一个对象实际上是另一个对象的引用呢?我们需要链接这些对象,让 Marshal.load 知道这些对象都是同一个对象,这样在 Marshal.load 返回该容器对象的时候,才不会出现不应该有的对象拷贝。我们只需要考虑一次 Marshal.load 时可能存在的对象引用(这也是为什么只需要考虑容器类型),因为如果两个对象是通过两次 Marshal.load 获取的,那么如何处理对象的克隆就完全取决于调用者了,这和“保证一个容器对象中没有不应该有的克隆对象是被调用者的职责”是不一样的。从设计的角度来想,也不可能在一个对象的序列化字节流中把某个附属对象链接到另一个未知的字节流中任意位置处的可能还没有序列化的对象。

‘;’ 类型指示符针对的是出现在了序列化字节流中的符号对象,指定了当前符号在本次 Marshal.dump 序列化的对象数据中已经被序列化过,所以需要链接到之前序列化的那个相同符号。这么做是因为符号对象是具有唯一性的,不能在内存中同时出现两个内容相同的符号对象。如果不使用符号链接,那么在反序列化的时候就会因为符号出现两次而生成克隆的符号对象。跟在 ‘;’ 后面的是一个长整数值,指定了之前序列化的相同符号在序列化字节流中的一个基于 0 的索引值。Marshal.load 在反序列化对象的时候会建立一个临时的符号表(散列),以基于 0 的索引值为键,符号内容为值记录出现过的符号。索引的顺序是序列化字节流中符号的出现顺序,这个顺序取决于虚表项的散列码。反序列化的时候也会生成相同的一个符号表,在见到 ‘;’ 的时候就知道要通过索引键去链接符号表中的符号对象。’;’ 可能出现在任何需要符号对象的地方,可以是一个容器中的某个附属符号对象本身,也可以是序列化字节流中的模块/类名、成员变量名。如果容器拥有很多层次,如容器的容器,那么符号的链接就可能会大量出现。

‘@’ 则是除了符号以外的其它对象类型的链接,格式和 ‘;’ 相同。之所以要把符号和其它对象区别开,是因为符号以外的对象是在另一个临时对象数据表(也是散列的)中维护的,两者用的是不同的表,但键值的格式差不多——对象数据表以索引值为键,对象本身为值。这个对象数据表记录的是一次 Marshal.dump 中出现的所有对象数据,包括正在 dump 的对象本身及其附属对象。其索引顺序和符号一样,仍然是在字节流中的出现顺序,有点像树的先序遍历,先访问父对象,再按照先序的规则遍历子对象。注意一定要只包括实际存在于对象成员列表中的附属对象。跟在 ‘@’ 后面的是一个长整数值,指定了当前对象在临时对象数据表中的索引键,这样在 Marshal.load 反序列化 ‘@’ 对象的时候,就会直接引用到之前已经生成的对象,而不会产生对象的克隆。

关于符号和对象链接的例子,可以参考最后一个实例分析。

实例分析

Marshal.dump(-32769)
# 0000000: 04 08 69 fe ff 7f                    ..i...

04 08 = 版本号(下同,故省略);
69 = ‘i’,表示接下来是一个 Fixnum 类型;
fe = -2,表示接下来的长整数值占 |-2| = 2 个字节;
ff 7f = 0xffff7fff = -32769。

Marshal.dump({ false => "test", 3.14 => :sym })
# 0000000: 04 08 7b 07 46 22 09 74 65 73 74 66  ..{.F".testf
# 000000c: 1a 33 2e 31 34 30 30 30 30 30 30 30  .3.140000000
# 0000018: 30 30 30 30 30 30 31 00 85 1f 3a 08  0000001...:.
# 0000024: 73 79 6d                             sym

7b = ‘{‘,表示接下来是一个无默认值的 Hash 类型;
07 表示 Hash 一共有 0x7-0x2 = 5 对键值;
46 = ’F’,表示第一个键是 false;
22 = ‘”‘,表示第一个值是 String 类型;
09 表示该字符串长度是 0x9-0x5 = 4;74 65 73 74 = “test”;
66 = ‘f’,表示第二个键是 Float 类型;
1a 表示浮点数据占 0x1a-0x5 = 0x15 = 21 字节;
33 2e 31 34 30 30 … 30 30 31 00 85 1f = “3.14000…1″;
3a = ‘:’,表示第二个值是符号类型;
08 表示符号长度为 0x08-0x05 = 3 字节;
73 79 6d = “sym”。

Marshal.dump(0x19823764567438219)
# 0000000: 04 08 6c 2b 0a 19 82 43 67 45 76 23  ..l+...CgEv#
# 000000c: 98 01 00                             ...

6c = ‘l’,表示接下来是一个 Bignum 类型;
2b = ‘+’,表示大整数是正数;
0a 19 82 43 67 45 76 23 98 01 00 = 0x00019823764567438219。

class A; end
o = A.new
o.instance_eval { @a = /./im; @b = [Math, nil] }
Marshal.dump(o)
# 0000000: 04 08 6f 3a 06 41 07 3a 07 40 62 5b  ..o:.A.:.@b[
# 000000c: 07 6d 09 4d 61 74 68 30 3a 07 40 61  .m.Math0:.@a
# 0000018: 2f 06 2e 05                          /...

6f = ‘o’,表示接下来是一个 ‘o’ 格式对象;
3a = ‘:’,表示接下来是符号对象类型的类名;
06 表示接下来的符号长度为 0x6-0x5 = 1;
41 = “A”,表示符号 :A;
07 表示成员变量个数为 0x7-0x5 = 2;
3a 接下来是符号对象的第一个成员变量名;
07 表示符号长度为 0x7-0x5 = 2 字节;
40 62 = “@b” = 第一个成员变量名;
5b = ‘[‘,表示成员变量 @b 的值是 Array 类型;
07 表示数组的长度为 0x7-0x5 = 2 字节;
6d = ‘m’,表示数组第一个元素为 Module 类型;
09 表示模块名的长度为 0x9-0x5 = 4 字节;
4d 61 74 68 = “Math” = 模块名;
30 = ‘0’,表示数组第二个元素为 nil;
3a = ‘:’,表示接下来是符号对象的第二个成员变量名;
07 表示符号长度为 0x7-0x5 = 2;
40 61 = “@a” = 第二个成员变量名;
2f = ‘/’,表示成员变量 @a 的值是 Regexp 类型;
06 表示正则表达式的长度为 0x6-0x5 = 1;
2e = “.” = 正则表达式;
05 = 正则表达式选项 = 0b00000101,第一位和第三位是 1,说明打开了 i 和 m 选项。
(注:对象的成员变量是由虚表管理的,自是毫无顺序可言,这里说的“第一”和“第二”成员变量是按照在字节流中出现的先后顺序来排列)

class A < Array
  def initialize
    @a = Object.new
    @b = :b
    @c = :b
    @d = @a
    @e = @c
    @f = Object.new
  end
end
Marshal.dump(A.new)
# 00000000: 04 08 49 43 3a 06 41 5b 00 0b 3a 07  ..IC:.A[..:.
# 0000000c: 40 63 3a 06 62 3a 07 40 66 6f 3a 0b  @c:.b:.@fo:.
# 00000018: 4f 62 6a 65 63 74 00 3a 07 40 65 3b  Object.:.@e;
# 00000024: 07 3a 07 40 62 3b 07 3a 07 40 64 6f  .:.@b;.:.@do
# 00000030: 3b 09 00 3a 07 40 61 40 07           ;..:.@a@.

49 43 = “IC”,表示对象继承自 Hash、Array、String 或 Regexp,且有自定义成员变量;
3a = ‘:’,表示接下来是一个符号对象的类名;
06 表示一个长度为 0x6-0x5 = 1 字节的符号对象,41 = ‘A’ = 符号 :A(下类似,故省略细节);
5b = ‘[‘,表示继承自 Array 类型;
00 = 0,表示数组(继承自 Array 基类的数据)有 0 个元素;
0b 表示有 0x0b-0x5 = 6 个自定义成员变量;
3a 07 40 63 表示第一个成员变量名是符号对象 :@a;
3a 06 62 表示成员变量 @a 的值为符号对象 :b;
3a 07 40 66 表示第二个成员变量名是符号对象 :@f;
6f = ‘o’,表示接下来是 ‘o’ 格式对象数据;
3a 0b 4f 62 6a 65 63 64 表示类名为符号对象 :Object;
00 表示成员变量 @f 引用的 Object 对象没有成员;
3a 07 40 65 表示第三个成员变量名是符号对象 :@e;
3b = ‘;’,表示成员变量 @e 的值是一个符号类型,且链接到另一个已序列化的符号;
07 表示链接到符号表中索引键为 0x7-0x5 = 2 的符号,也就是在字节流中第 2+1 = 3 个出现的符号——第一个出现的符号是 :A,第二个是 :@c,第三个是 :b,于是成员变量 @e 就引用到了符号 :b;
3a 07 40 62 表示第四个成员变量名是符号对象 :@b;
3b 07 表示成员变量 @b 的值是一个符号类型,且也是链接到索引键位 2 的符号 :b;
3a 07 40 64 表示第五个成员变量名是符号对象 :@d;
6f = ‘o’ 表示接下来是 ‘o’ 格式对象数据;
3b = ‘;’,表示这是个 ‘o’ 格式对象的类型(类名)是之前已序列化的符号对象;
09 表示链接到符号表中索引键为 0x9-0x5 = 4 的对象数据,也就是在字节流中第 4+1 = 5 个出现的符号——第四个出现的符号是 :@f,第五个是 :Object,于是成员变量 @d 的类型就是符号对象 :Object;
00 表示这个 ‘o’ 格式对象没有成员;
3a 07 40 61 表示第六个成员变量名是符号对象 :@a;
40 = ‘@’,表示这个对象需要链接到另一个已序列化的非符号对象;
最后 07 表示链接到对象数据表中索引键为 0x7-0x5 = 2 的对象数据,也就是在字节流中第 2+1 = 3 个出现的非符号对象——第一个出现的非符号对象是 “IC” 格式的对象本身,第二个是成员变量 @f 引用的 ‘o’ 格式的 Object 对象,第三个是成员变量 @d 引用的 ‘o’ 格式的 Object 对象,于是成员变量 @a 就链接到了这个被 @d 引用的 Object 对象。

  不难看出,在 Ruby 脚本中写下的非引用赋值语句不一定就对应序列化格式中的非对象链接格式。在这个例子中,@a 是直接被赋予了 Object.new,然而在序列化字节流中,@a 却是链接到了 @d。这在反序列化的时候无伤大雅,因为自始至终这两个引用都指向同一个对象。

总结

  1. Ruby 的对象序列化和反序列化分别通过 Marshal.dump 和 Marshal.load 进行;
  2. 整数的表示法有了空间上的优化;
  3. 层层嵌套,有递归特性;
  4. 一切模块/类名、成员变量名都是由符号对象来表示;
  5. 对象的引用、符号的唯一性得到了保证;
  6. 用户可以通过定义 _dump 或 marshal_dump 自定义序列化规则;
  7. 不同版本 Marshal 间的兼容性不高;
  8. ……?

MSVC 标准输入设备缓冲区

getchar() 从可以让用户从控制台输入字符,并即时把键盘的输入回显到控制台窗口中。刚接触 C/C++ 的人可能经常会有这样的问题:在调用 scanf (比如输入一个整数)之后,本来在 main 函数返回之前调用了一个 gechar() 来让控制台窗口停留在屏幕上,按下回车再退出程序,结果窗口并没有停留,程序直接退出了。这是为什么呢?让我们看看 MSDN 对于 getchar() 的说明:

从 stdin 读取单个字符,并让相关的文件指针增加使其指向下一个字符。

即是说,getchar() 并不是用户输入一个字符就读取那一个字符,而是把用户的输入一次性放到内存中的一个缓冲区中,再逐个读取。利用 VC++ 的功能,我们找到定义 getchar() 的地方,发现 getchar() 原来就是 stdio.h 中的一个宏,展开后调用的是 getc(stdin)。那 stdin 又是什么呢?再跟踪找到 stdin 的定义,不难发现 stdin 就是指向标准输入流的 FILE 指针。这个指针指向的 FILE 结构体中,有很多公有的数据,比如 _base 指向缓冲区内存首地址,_cnt 是缓冲区中未读字节数,_bufSize 是缓冲区的大小,_ptr 指向缓冲区下一个字节。我们可以写如下的 C++ 代码来看一下缓冲区中数据的变化:

#include <stdio.h>

void PrintBuffer() {
    for(int i = 0; i < 20; ++i) {
        printf("%02x ", stdin->_ptr[i]);                  // 十六进制输出格式,空位填充零
    }
    printf("\n");
} 

void main() {
    printf("Input string of characters\n");
    printf("Output bytes in the buffer\nInitially:\n");
    do {
        PrintBuffer();
        getchar();
    } while(1);
}

这个程序首先调用 getchar(),继而打印出缓冲区指针指向的字节及其之后的 19 个字节数据,然后循环以上。测试程序:

Input string of characters
Output bytes in the buffer
Initially:
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
this is an input
68 69 73 20 69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00
69 73 20 69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00
73 20 69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00
20 69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00
69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00
73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00
20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00 00
61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00 00 00
6e 20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00 00 00 00
20 69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00 00 00 00 00
69 6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00 00 00 00 00 00
6e 70 75 74 0a 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00
70 75 74 0a 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
75 74 0a 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
74 0a 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0a 0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
0a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
1
0a 0a 73 20 69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00
0a 73 20 69 73 20 61 6e 20 69 6e 70 75 74 0a 0a 00 00 00 00

初始时缓冲区中的数据都是 0x00,输入 this is an input回车,输出一行缓冲区中的数据。图中输入之后的第一行输出,第一个字节 0x68 就是 ‘h’ 的十六进制表示方式,因为指针这个时候已经移动到了第二个字节,第一个字节 ‘t’ 已经由 getchar() 返回了。

注意最后空字节之前有两个 0x0a。在 Windows 中,按下回车之后本来产生的是一个回车符 0x0d ‘\r’ 接一个换行符 0x0a ‘\n’,但在复制到缓冲区的时候本来应该写入 0x0d 的位置就写入了 0x0a,所以这样就有了两个 0x0a ‘\n’。这主要是因为控制台是按照文本格式来读写的,而文本格式读取会将 “\r\n” 转换为 ‘\n’,写入则是把 ‘\n’ 转换为 “\r\n”,所以这里就把前面的 ‘\r’ 转换成了 ‘\n’。

接着,用户没有任何输入,却连续输出了十多行,而缓冲流的文件指针也在移动。也就是说,getchar() 在缓冲流中有数据的情况下,直接从缓冲流中读取数据并返回它,直到 FILE 的 _cnt 成员(剩下未读的字节数,这个值是在把数据读入缓冲区的时候就记录了)减为 0 为止,这时候指针指向前一个 0x0a,所以会返回 0x0a,然后指针指向了第二个 0x0a。这也就解答了为什么 scanf 之后经常 getchar() 并没有停留住窗口,因为 scanf 读取了它该读取的数据之后,当前文件指针指向还在缓冲区中的 ‘\n’,调用 getchar() 就会返回它。这时再次调用 getchar(),发现 _cnt 为 0,于是重定向指针到内存首地址(_base),又开始接受控制台键盘输入。

图中显示在指针指向最后一个 0x0a 之后,用户输入:1回车,getchar() 返回 ‘1’ ,原本是 0x68 0x69 “hi” 的位置就被替换成了两个 0x0a。当指针再次指向第二个 0x0a 的时候,又开始接受键盘输入。要注意的是这个程序的输出并不是从缓冲区的第一个字节开始的,而是从当前指针指向的位置开始的。

关于缓冲机制,网上经常会有这样的说法:“缓冲区中还有数据,某某函数从缓冲区中取走数据”这种说法是不准确的。缓冲区中只有一开始没有数据,即便是当 getchar() 返回了 0x0a 时,缓冲区中的数据在内存中也并没有丢失,直到新输入的字节将它们替换。也有人说 fflush(stdin) 可以清除缓冲区的数据,其实数据在内存中也并没有清除,只不过是把 FILE 结构体中的 _cnt 成员直接设为了 0,让指针指向首地址,重新获取键盘输入然后覆盖之前的数据。