几乎每个开发者学面向对象时,脑子里都被塞进同一张教科书图景:对象把数据和操作它的方法打包在一起,形成一个整体。在Ruby里,这个印象还会被一句口号反复加固——"一切皆对象"。
你创建一个字符串,调用它的 .upcase,感觉很自然:这个方法属于这个字符串。你创建一个用户对象,调用 .save,也默认 .save就住在用户对象里。
但Ruby根本不是这么工作的。在Ruby里,对象身上一个方法都不存。
如果深入到CRuby(MRI)的C源码层面去看一个实例,会发现一件反直觉的事:对象只存实例变量、一组内部标志位,以及一个指向另一个结构的指针。它不含任何一行方法代码,也不含方法表。
那方法到底住在哪?当你写下 user.greet,Ruby怎么知道该执行什么?在单个对象上定义方法时,又发生了什么?要真正吃透Ruby,就得拨开语法糖,看清对象模型的机械结构:方法表、隐藏的单例类,以及一条祖先链。
对象存状态,类存方法
先看一个简单的类。定义 User,带 initialize 和 greet 两个方法,然后 User.new 造一个实例。当Ruby执行 User.new 时,虚拟机会在堆上分配一个叫 RObject 的结构。
这个 RObject 里只放两样东西:
- 一个实例变量数组:@name 是 "Alice",@email 是 "alice@example.com";
- 一个名为 klass 的内部指针,直接指向 User 这个类对象。
注意 user 里缺了什么:greet 方法无影无踪。在Ruby里,方法只住在类中(内部表示为 RClass)。User 类里有一张哈希表叫 m_tbl,也就是方法表。当你写下 def greet,Ruby就往 User 的方法表里写一条记录,把符号 :greet 映射到编译后的字节码主体。
调用 user.greet 时,Ruby不会去 user 里面找。它顺着 user 的 klass 指针走到 User 类,在 User 的 m_tbl 里搜 :greet,找到后以 self = user 的上下文执行它。
这种分离正是Ruby省内存的原因。你可以造十万个 User 实例,这十万个实例共享 User 里同一张方法表,实例自己只持有各自的实例变量。
单例方法的谜题
再看Ruby一个著名特性:你可以只给某一个对象加方法,不影响该类的其他实例。给 str1 定义 shout,str1.shout 正常返回,而 str2.shout 直接抛 NoMethodError。
这就引出一个有趣的谜题:对象存不了方法,所以 :shout 不可能在 str1 里;它也不可能在 String 里,否则 str2 也能用。那它到底在哪?
Ruby的解法很巧妙:它悄悄在运行时凭空造出一个看不见的类。就在你给 str1 定义方法的那一刻,Ruby创建了一个匿名的隐藏类,叫特征类(eigenclass),官方称单例类(singleton class)。
接着它做了一次架构层面的"手法":拦截 str1 的内部 klass 指针,把它重指到这个新的单例类;把单例类的父类设为 String;再把 :shout 放进单例类的方法表。
于是调用 str1.shout 时,Ruby顺着 klass 指针进入单例类,找到 :shout 并执行。调用 str1.upcase 时,Ruby在单例类里没找到 :upcase,就顺着父类指针爬到 String,找到后执行。
对普通代码来说,单例类几乎完全透明:str1.class 依然报告 String。但用反射方法可以证明它存在——str1.singleton_class 能拿到它,str1.singleton_methods 返回 [:shout]。
"类方法"其实不存在
理解了特征类,Ruby里最大的一个迷思就蒸发了:根本不存在"类方法"这种东西。
开发者常写 def self.recent 这样的代码,教程会说"这在 Post 上定义了一个类方法"。在Java或C++里,静态方法是独立的语言构造;但在Ruby里,Post 不是什么特殊实体,它就是一个对象,是 Class 类的实例——Post.class 返回 Class。
既然 Post 是对象,写 def Post.recent 或 def self.recent,和写 def str1.shout 是同一件事:在一个对象上定义单例方法。:recent 不在 Post 里,也不在 Class 里,它住在 Post 自己的私有特征类里。
Ruby里只有实例方法。所谓"类方法",不过是住在类对象的单例类上的实例方法。整门语言只运行在一条统一规则上。
完整的方法查找阶梯
写下 receiver.message 时,Ruby按一条确定性的阶梯——祖先链——逐级查找,遇到第一个匹配就立即执行:
- 接收者的特征类:对象若有单例方法,优先级最高;
- 特征类上被 prepend 的模块:用 prepend 加入的模块排在特征类前面;
- 接收者的类:普通实例方法所在的常规类;
- 类上被 prepend 的模块:prepend 进来的模块会覆盖类自身的方法;
- 类上被 include 的模块:include 进来的模块插在类正上方;
- 父类及其模块:向上爬,逐层检查父类的 prepend、自身方法和 include;
- Object、Kernel 和 BasicObject:几乎所有Ruby对象共享的根祖先。
如果一路爬到 BasicObject 都没找到匹配,Ruby不会立刻报错。它会回到起点接收者,从第一步重新搜一遍,这次找的是名为 method_missing 的方法。因为 Kernel 提供了 method_missing 的默认实现、会抛 NoMethodError,所以只有当没有任何中间模块或类拦截它时,程序才会崩。
这套两趟查找,正是Ruby元编程库的底层动力,从 ActiveRecord 的动态查找器到 OpenStruct,都靠它。
"一切皆对象"如何跑得快:标记指针
如果Ruby里每个数字、布尔值和 nil 都是带方法的对象,那它怎么避免为基础运算不停分配堆内存而拖垮性能?
跑一个百万次的累加循环,Ruby会分配一百万个 RObject 结构吗?在CRuby里答案是不会,靠的是一项叫标记指针(Tagged Pointers,即立即值)的内部优化。
在64位系统上,内存地址按8字节对齐,意味着任何合法堆指针的最低3位永远是 000。CRuby利用这些闲置位:当一个64位值表示小整数(Fixnum)时,Ruby把整数左移一位,再把最低位设为 1。虚拟机看到最低位是 1,就知道"这不是堆指针,这就是整数本身"。
类似地,CRuby为单例值保留了特定比特模式:false 表示为 0x00,nil 表示为 0x08,true 表示为 0x14。
调用 42.even? 时,Ruby不去检查堆块,而是识别出标记指针,在 Integer 类的方法表里查方法并执行,全程零内存分配、零垃圾回收开销。你既得到了"一切皆对象"的概念纯度,又拿到了原始寄存器运算的效率。
心智模型的转变
Ruby的对象模型常被说成复杂,但剥开表层,它其实极其精简:
- 对象存状态和一个内部 klass 指针,从不持有方法;
- 类存方法和一个内部 super 指针,从不持有实例状态;
- 单例方法住在看不见的特征类里,被无缝接入查找链;
- 类方法只是类对象上的单例方法,因为类本身也是对象;
- 标记指针在不牺牲性能的前提下保住了纯粹的对象语义。
用这个视角看对象模型,Ruby元编程就不再像黑魔法。class_eval、instance_eval、prepend 和单例类,都会变成遵循同一套规则的、可预测的机械工具。
热门跟贴