元编程:让代码写代码

Ruby 最被人津津乐道也最容易被误解的一点,就是「元编程」:在运行时查看、修改、甚至生成代码本身。Rails 里的 has_many、validates_presence_of 这些魔法般的写法,本质都是方法调用——它们在类被加载时执行,顺手把一批新方法动态定义出来。

元编程不是 Ruby 独有的,但 Ruby 把它做到了极致:方法可以在运行时被定义、被改写、被拦截;类的打开可以随时进行;代码字符串可以被求值成真正的逻辑。这些能力来自 Ruby 对象模型的几个关键事实:类是对象、方法存在类上、方法调用其实是一次消息发送。

掌握元编程的正确姿势,是把它当成「机制」而不是「魔法」。每个魔法写法背后都有一个可解释的查找规则:方法怎么被找到、在哪一层被找到、没找到时会发生什么。理解了这三问,Rails 的神秘感就消失了一半,你也能在自己代码里安全地使用这些技巧。

元编程的第一原则:能不用就不用,要用就用在接口边界上,让调用方完全无感。
本文核心知识点
  • method_missing拦截未定义方法的调用。
  • define_method在运行时按规则定义方法。
  • 打开类随时向既有类追加方法。
  • class_eval在类语境里执行代码块。
  • send把方法名当作参数动态调用。
Ruby 元编程实战配图:深蓝至蓝紫渐变背景,中央一个多层嵌套的透明宝石立方体框架,内部有发光镜面把局部倒影变形为不同几何形状,纤细发光丝线注入立方体内部改变模块排列,周围点缀菱形与圆环节点
一块能被镜子改写内部结构的立方体——元编程就是在运行时重新塑造类的形状。

method_missing:兜住不存在的调用

Ruby 里调用一个方法,本质是给对象发一条消息。消息找不到对应方法时,对象会把这条消息转给 method_missing。默认的 method_missing 抛 NoMethodError,但你可以重写它,把「未定义的方法」变成动态响应。

class DynamicGreeter
  def method_missing(name, *args)
    if name.to_s.start_with?("greet_")
      puts "Hello, #{name.to_s.sub("greet_", "").capitalize}!"
    else
      super   # 不是我们认识的模式,交给默认实现
    end
  end

  def respond_to_missing?(name, include_private = false)
    name.to_s.start_with?("greet_") || super
  end
end

g = DynamicGreeter.new
g.greet_world   # Hello, World!

两个细节必须一起写:重写 method_missing 的同时,要重写 respond_to_missing?,否则 respond_to?(:greet_world) 会返回 false,破坏鸭子类型。方法名里带上模式前缀(这里是 greet_),是避免误拦常规调用的关键——毕竟拼错方法名时,你希望报错而不是被静默吞掉。

method_missing 适合「方法集合不确定、但模式确定」的场景,比如代理对象、API 客户端、装饰器。滥用它的代价是代码难以追踪:IDE 找不到方法定义、报错信息变模糊,所以能用显式方法的地方不要用 method_missing。

define_method:在运行时批量定义方法

和 method_missing 的「接住消息」不同,define_method 是真的在运行时把方法造出来。造出来的方法是普通方法,能被 IDE 找到(配合代码生成工具)、能被 respond_to? 识别,调用时也没有 method_missing 的开销。

class Animal
  %w[run jump swim].each do |action|
    define_method(action) do
      puts "doing #{action}!"
    end
  end
end

Animal.new.run    # doing run!
Animal.new.swim   # doing swim!

关键点是闭包:define_method 的代码块会捕获定义时的局部变量(action),所以每个方法记住的是自己的动作名。这比用字符串拼接 eval 安全得多——变量在词法作用域里,不会带来字符串注入风险。

批量定义方法最常见的用途,是把数据驱动的规则转成方法:配置里声明了哪些字段,类上就生成对应的读写方法。Rails 的 ActiveRecord 动态 finder(find_by_name)原理与此同源。记住选择顺序:能写普通 def 就写普通 def;只有方法名或方法体依赖运行时数据时,才用 define_method。

打开类与单例方法:eigenclass 的世界

Ruby 的类可以被「重新打开」:在 class 关键字后面再次写上同一个类名,就能向它追加方法,包括标准库的类。这是最直白的元编程,也是双刃剑——改 String、Array 这些全局类时要格外小心,可能影响整个程序。

class String
  def shout
    "#{upcase}!"
  end
end

puts "hello".shout   # HELLO!

单例方法则只给某一个对象加方法,而不是整个类。每个对象背后有一个隐藏的「单例类」(eigenclass / singleton class),对象特有的方法就存在那里。类方法本质也是单例方法——方法定义在类对象的单例类上。

obj = "plain"
def obj.special
  "only me"
end
puts obj.special   # only me
puts "other".special rescue puts "other: NoMethodError"

理解单例类是理解 Ruby 方法查找链的最后一块拼图:对象 → 单例类 → 类 → 父类 → 模块链 → method_missing。Rails 里大量用 class << self 写类方法,其实就是进入单例类语境。这一层想通,方法查找就不再是玄学。

class_eval 与 instance_eval:切换执行语境

class_eval 和 instance_eval 都在「另一个对象的语境」里执行代码块,区别在于语境:class_eval 把 self 设为类,块里可以定义实例方法;instance_eval 把 self 设为对象本身,块里可以访问对象状态、定义单例方法。

class Config
  def initialize
    @values = {}
  end

  def set(key, value)
    @values[key] = value
  end
end

cfg = Config.new
cfg.instance_eval do
  set(:host, "localhost")   # 在对象语境里访问私有方法
end

instance_eval 是 DSL 的发动机:Rails 路由文件、Gemfile、Rakefile 都是「在特定对象语境里执行一段声明性代码」。调用方写的是近乎自然语言的配置,背后其实是方法调用——可读性和能力兼备。

注意与 class_eval 的区别:在 class_eval 块里调用 def foo 会定义实例方法;在 instance_eval 块里 def foo 会定义单例方法。记不住就记住一句话:class_eval 改类、instance_eval 改对象。两者都接受字符串形式(不推荐,优先传块,避免字符串求值的注入风险)。