Lisp对Ruby语言的深远影响
速览
本文分析了Lisp语言对Ruby开发的深远影响。Ruby的设计者深受Lisp启发,将其函数式编程理念融入语言核心。这种影响体现在Ruby的语法简洁性与元编程能力上。
AI 深度解读
Lisp 对 Ruby 的深远影响:从语法糖到灵魂
背景
在 Ruby 开发者社区中,常有一种说法:“Ruby 的代码读起来像英语。”然而,这种看似自然的语法背后,隐藏着深厚的 Lisp 血统。尽管 Ruby 在表面上去掉了 Lisp 标志性的括号和前缀表示法,将函数调用转换为点号链式调用,并将匿名函数从语法块转变为更友好的形式,但其核心代码形态——链式过滤、转换、基于 ? 的谓词判断以及不可变数据的构建——本质上依然是 Lisp 风格的。
Ruby 之父 Matz(Yukihiro Matsumoto)曾明确表示,Ruby 的设计起点是一个简化的 Lisp。他剥离了宏(macros)和 S-表达式(s-expressions),随后加入了对象系统、块(blocks)以及 Smalltalk 风格的方法。对于许多 Ruby 开发者而言,他们最初爱上这门语言的原因往往不是其面向对象特性,而是那些披着友好外衣的函数式编程特性。这篇文章深入剖析了 Ruby 中源自 Lisp 的关键特性,揭示了为什么 Ruby 只是给 Lisp 穿上了“商务休闲装”。
核心内容
以问号结尾的方法名(Predicate Methods)
在 Ruby 中,以 ? 结尾的方法约定用于表示谓词(predicates),即返回布尔值的方法。这一惯例源自 Scheme。例如 zero?、nil?、empty?、respond_to? 和 valid?。
这个后缀不仅仅是命名约定,它传达了重要的语义信息:该方法回答一个“是或否”的问题,不会修改(mutate)接收者,也不会执行副作用操作,仅仅是陈述关于接收者的一个真假事实。
return if user.nil?
return unless user.admin?
notify(user) if user.subscribed?
这三行代码之所以能像英语一样被阅读,是因为 ? 承担了主要的语义负载。与之相对的是以 ! 结尾的方法(如 save!、sort!、compact!),通常表示该方法会修改对象或抛出异常。这两种标记均源自 Scheme(如 null?、pair? 和 set!)。虽然只是微小的语法借用,但它贯穿了整个语言,使得阅读 Ruby 代码的速度显著加快。
闭包与块(Closures and Blocks)
当被问及最喜欢 Ruby 的哪个特性时,Ruby ists 通常会首先提到“块”(Blocks)。块本质上是闭包:它们捕获周围的作用域,并可以作为值传递。
total = 0
[1, 2, 3].each { |n| total += n }
total # => 6
在这个例子中,块闭包(closes over)了变量 total。这就是闭包模式:一个记住其定义时环境的函数值。Lisp 在 Ruby 出现几十年前就拥有了闭包,而 Scheme 率先将其作为一等公民(first-class objects),可以传递给任何函数。Ruby 保留了这一思想,并添加了更轻量的语法。使用 do...end 或花括号的块,本质上就是去掉了括号的闭包。
Proc 和 lambda 则是同一概念的不同表现形式,只是重新加上了括号:
square = ->(n) { n * n }
[1, 2, 3].map(&square) # => [4, 4, 9]
箭头语法 -> 是 Ruby 中的 lambda。这个词本身源自 Lisp,进而追溯到 Church 的 lambda 演算,并于 1958 年首次被引入实际编程语言中。
一等函数(First-class Functions)
一旦闭包可以被命名和传递,函数就变成了值。你可以将它们存储在数组中、从方法中返回、或附加到对象上。Ruby 的 Method 和 Proc 类使这一点变得显式化。此外,&:method_name 语法将符号转换为块,通过在接收者上查找方法来工作。
emails = users.map(&:email)
admins = users.select(&:admin?)
&:foo 是一种微小的“魔法”,但它之所以有效,是因为在 Ruby 中函数是一等值。符号被强制转换为 proc,proc 作为块传递,并在每个元素上被调用。这是一等函数的完整体现。
这是 Lisp 的基础理念:程序是通过组合函数构建的。Ruby 借用了这种组合方式,并用点号链(dot-chains)进行了包装。
符号(Symbols)
:foo 是一个符号。它看起来像带冒号的字符串,但它是不同类型的值。符号是驻留的(interned):每次你写 :foo,你得到的都是同一个对象。两个看起来相同的字符串通常是内存中的两个不同对象;而两个看起来相同的符号始终是同一个对象。
这一特性源自 Lisp。Lisp 符号(在某些方言中称为原子)是最原始的驻留值。读取器看到 foo,在符号表中查找,要么返回现有符号,要么创建新符号并记住它。此后,所有对 foo 的引用都指向同一个对象。
:status.equal?(:status) # => true
"status".equal?("status") # => false
在 Ruby 中,这带来了快速比较、免费的哈希处理,以及非字符串名称的清晰语法。虽然哈希键是最明显的使用场景,但更深层的用途是方法名。method_name 和 :method_name 是在两个层面的同一概念。send(:save) 调用 save 方法;define_method(:fetch) {…} 定义一个方法;respond_to?(:to_s) 询问是否存在该方法。符号是 Ruby 进行反射性引用方法的方式,这也是元编程(metaprogramming)运作的基础。上一节提到的 &:foo 快捷方式也是这一理念的近距离应用:一个命名方法的符号被强制转换为可调用对象。
集合方法(Collection Methods)
map、select、reject、reduce、each、flat_map、zip、partition、chunk_while。如果必须离开 Ruby,Enumerable 模块是我最怀念的部分。这也是最直接源自 Lisp 的部分。
Lisp 给了我们 mapcar、filter、reduce。形态是一样的:接受一个集合,应用一个函数,返回一个集合。没有索引,没有 off-by-one 错误,没有需要忘记重置的累加器变量。
orders
.select { |o| o.placed_at > 1.week.ago }
.group_by(&:customer_id)
.transform_values { |group| group.sum(&:total) }
这段代码在表达能力较弱的语言中可能需要五个 for 循环和一个哈希表。而在 Ruby 中,它是一个自上而下阅读的段落。这种链式操作与一系列嵌套的 Lisp map 和 reduce 做的事情相同;只是语法从括号变成了点号。
当 Ruby ists 说“语言读起来像英语”时,他们通常的意思是“集合方法组合成了句子”。这是 Lisp 的馈赠,配以 Ruby 的标点符号。
惰性枚举器(Lazy Enumerators)
急切的(Eager)集合方法会构建整个结果然后返回。例如 [1, 2, 3].map { |n| n * 2 } 会分配一个新数组,填充它,然后交还。对于小列表来说没问题,但对于大型或无限列表来说,这是一个问题。
Lisp 通过惰性求值(lazy evaluation)和流(streams)解决了这个问题。Scheme 的 delay 和 force,Clojure 的惰性序列,Haskell 的万物皆是如此。理念是:直到有人请求时才计算结果。列表不是内存中坐着的数组,而是按需生产元素的配方。
Ruby 拥有相同的技巧。Enumerable#lazy 返回一个枚举器,它将操作管道连接起来,而无需物化中间集合。
(1..Float::INFINITY)
.lazy
.select { |n| n % 3 == 0 }
.map { |n| n * n }
.first(5)
# => [9, 36,
