Ruby 函数式编程

Posted Ruby那些事

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Ruby 函数式编程相关的知识,希望对你有一定的参考价值。

简介

命令式编程比较牛吗? 不!不!不!只是比较快,比较简单,比较诱人而已。

x = x + 1

在美好的小学时光里,我们会对上面这行感到困惑。这个 x 到底是什么呢?为什么加了一之后,x 仍然还是 x

不知道为什么,当我们就开始编程时,就不在乎这是为什么了。心想:“嗯”,“这不是什么大问题,编程中将事情做完最重要,没有必要去挑剔数学的纯粹性 (让大学里的大鬍子教兽们去烦恼就好)” 。但我们错了,也付出极高的代价,只是我们还不知道而已。

理论部分

维基百科的解释:“函数式编程是一种写程序的范式,将计算视为对数学函数的求值,并避免使用状态及可变的数据” 换句话说,函数式编程提倡没有副作用的代码,不改变变量的值。这与命令式编程相反,命令式编程强调改变状态。

仅此而已。那有什么好处呢?

  • 更简洁的代码:“变量”一旦定义之后就不再改动,所以我们不需要追踪变量的状态,就可以理解一个函数、方法、类别、甚至是整个项目是怎么工作的。

  • 引用透明:表达式可以用本身的值换掉。如果我们用同样的参数调用一个函数,可以确信输出会是一样的结果(没有其它的状态可改变它的值)。这也是为什么爱因斯坦说:“重复做一样的事却期望不同的结果”是疯狂的理由。

引用透明打开了前往某些美妙事物的大门

  • 并行化:如果调用函数是各自独立的,则他们可以在不同的进程甚至是机器里执行,而不会有竞态条件的问题。“平常” 写并发程序讨厌的细节(锁、semaphore…等)在函数式编程里面通通消失不见了。

  • 记忆化:由于函数调用的结果等于它的返回值,我们可以把这些值缓存起来。

  • 模组化:代码里不存有状态,所以我们可以将项目用小的黑箱连结起来,函数式编程提倡自下而上的编程风格。

  • 容易调试:函数彼此互相隔离,只依赖输入与输出,所以很容易调试。

Ruby 的函数式编程

听着不错,但怎样才能在每天的 Ruby 编程中运用函数式编程呢(Ruby 不是函数式语言)?函数式编程广义来说,是一种风格,任何语言都能使用。用在特别为这种范式打造的语言里当然会更自然,但某种程度上来说,可以应用到任何语言。

说明一点:本文不提倡仅仅为了保持理论上的函数纯粹性去编写古怪风格的代码。相反,我要表达的是 只要能提升代码品质,就可以用使用函数式编程 。

不要更新变量

别更新它们,创建新的变量。

不要对数组或字串做 append

No:

indexes = [1, 2, 3]
indexes << 4indexes # [1, 2, 3, 4]

Yes:

indexes = [1, 2, 3]
all_indexes = indexes + [4] # [1, 2, 3, 4]

不要更新 hash

No:

hash = {:a => 1, :b => 2}
hash[:c] = 3hash

Yes:

hash = {:a => 1, :b => 2}
new_hash = hash.merge(:c => 3)

不要使用有破坏性的方法,即那些带惊叹号的方法

No:

string = "hello"string.gsub!(/l/, 'z')
string # "hezzo"

Yes:

string = "hello"new_string =  string.gsub(/l/, 'z') # "hezzo"

如何累积值

No:

output = []
output << 1output << 2 if i_have_to_add_two
output << 3

Yes:

output = [1, (2 if i_have_to_add_two), 3].compact

不要重用变量

我们应该避免的常见模式:

number = gets
number = number.to_i

这里,我们没有更新 number,但用新的值冲掉了之前的值。从函数式变成的观点看,更新变量和冲掉之前的变量都是不好的。它们都违背了同样的原则:一旦写下 number = gets , number 在其 scope 中值就不该变化。如果要做转化,使用其它变量名:

number_string = gets
number = number_string.to_i

记住,把 “变量 = 值” 中的等号看做是数学中的等号。一旦等于了,就不应该改变。这该是程序员和将来读该代码的人之间的神圣约定:scope 中的变量总是可以直接被它们的值所替代。

用 Blocks 作为高阶函数

语言需要有高阶函数才能支持函数式编程。高阶函数是可以接受别的函数作为参数,并可以返回函数的函数。

Ruby (与 Smalltalk 还有其它语言)在这个方面上非常特别,语言本身就内置这个功能: blocks 区块。区块是一段匿名的代码,你可以随意的传来传去或是执行它。让我们看区块的典型用途,来构造函数式编程的构造子。

init-empty + each + push = map

No:

dogs = []
["milu", "rantanplan"].each do |name|
  dogs << name.upcaseenddogs # => ["MILU", "RANTANPLAN"]

Yes:

dogs = ["milu", "rantanplan"].map do |name|
  name.upcaseend # => ["MILU", "RANTANPLAN"]

init-empty + each + conditional push -> select/reject

No:

dogs = []
["milu", "rantanplan"].each do |name|  if name.size == 4
    dogs << name  endenddogs # => ["milu"]

Yes:

dogs = ["milu", "rantanplan"].select do |name|
  name.size == 4end # => ["milu"]

initialize + each + accumulate -> inject

No:

length = 0["milu", "rantanplan"].each do |dog_name|
  length += dog_name.lengthendlength # => 14

Yes:

length = ["milu", "rantanplan"].inject(0) do |accumulator, dog_name|
  accumulator + dog_name.lengthend # => 14

在上例这种特殊情况下,累积器与元素之间的操作很简单,我们不需要区块,只要将二元操作符传给符号初始值即可。

length = ["milu", "rantanplan"].map(&:length).inject(0, :+) # 14

empty + each + accumulate + push -> scan

想像一下,你不仅想要摺迭(fold)的结果,也想得到过程中产生的部分值。用命令式编程风格,你可能会这么写:

lengths = []
total_length = 0["milu", "rantanplan"].each do |dog_name|
  lengths << total_length
  total_length += dog_name.lengthendlengths # [0, 4, 14]

在函数式的世界里,Haskell 称之为 scan, C++ 称之为 partial_sum, Clojure 称之为 reductions。

令人讶异的是,Ruby 居然没有这样的函数!让我们自己写一个。这个怎么样:

lengths = ["milu", "rantanplan"].partial_inject(0) do |dog_name|
  dog_name.lengthend # [0, 4, 14]

Enumerable#partial_inject 可以这么实现:

module Enumerable
  def partial_inject(initial_value, &block)    self.inject([initial_value, [initial_value]]) do |(accumulated, output), element|
      new_value = yield(accumulated, element)
      [new_value, output << new_value]    end[1]  endend

实现的细节不重要(为了效率我使用了 <<),重要的是,当发现有趣的模式可以被抽象化时,我们将其写在函式库中,为其撰写文档,对其反覆测试,然后就可在将来任意项目中使用了。

initial assign + conditional assign + conditional assign + ...

这样的程序我们常常看到:

name = obj1.name
name = obj2.name if !name
name = ask_name if !name

在此时你应该觉得这样的代码使你很不自在(变量一会是这个值,一会是那个值;变量名 name 到处都是…等)。函数式的方式更简短,也更简洁:

name = obj1.name || obj2.name || ask_name

另一个有更复杂条件的例子:

def get_best_object(obj1, obj2, obj3)  return obj1 if obj1.price < 20
  return obj2 if obj2.quality > 3
  obj3end

初衷是为了节省几行代码,结果是代码变得难读懂了。别这样。写成如下表达式会更清楚:

def get_best_object(obj1, obj2, obj3)  if obj1.price < 20
    obj1  elsif obj2.quality > 3
    obj2  else
    obj3  endend

确实有一点啰嗦,但逻辑(缩进让它更突出了)比一堆行内 if/unless 来得清楚。经验法则告诉我们,仅在你确定会用到副作用时,使用行内条件式,而不是在变量赋值或返回的场合使用:

country = Country.find(1)
country.invade if country.has_oil?# more code here

如何从 enumerable 创造一个 hash

Ruby 默认没有实现从 Enumerable 直接转到 Hash 的函数(本人认为这个缺陷令人遗憾)。这也是为什么新手持续写出下面这个糟糕的模式(而你又怎么能责怪他们呢?唉!):

hash = {}
input.each do |item|
  hash[item] = process(item)endhash

丑陋啊!但手边有没有更好的办法呢?过去 Hash 构造子需要一个有着连续键值对的扁平集合 (呃,用 flatten 数组来描述映射?Lisp 曾这么做,但还是很丑陋)。幸运的是,最新版本的 Ruby 也接受键值对,这样更有意义(作为 hash.to_a的逆操作),现在你可以这么写:

Hash[input.map do |item|
  [item, process(item)]end]

不赖嘛,但这打破了平常的撰写顺序。在 Ruby 中我们期望从左向右写,给对象调用方法。而“好的”函数式方式是使用inject

input.inject({}) do |hash, item|
  hash.merge(item => process(item))end

还是很啰嗦,所以我们最好将它放在 Enumerable 模组,Facets 正是这么干的。命名为 Enumerable#mash

module Enumerable
  def mash(&block)    self.inject({}) do |output, item|
      key, value = block_given? ? yield(item) : item
      output.merge(key => value)    end
  endend
["functional", "programming", "rules"].map { |s| [s, s.length] }.mash# {"functional"=>10, "programming"=>11, "rules"=>5}

或使用 mash 及选择性区块来一步完成:

["functional", "programming", "rules"].mash { |s| [s, s.length] }# {"functional"=>10, "programming"=>11, "rules"=>5}

面向对象与函数式编程

Joe Armstrong (Erlang 发明人) 在 “Coders At work” 谈论过面向对象编程的重用性:

“我认为缺少重用性是面向对象语言造成的,而不是函数式语言。面向对象语言的问题是,它们带着语言执行环境的所有隐含资讯四处乱窜。你想要的是香蕉,但看到的却是拿着香蕉的大猩猩和整个丛林”

公平点说,我的看法是这不是面向对象编程的本质问题。你可以写出函数式的面向对象程序,但确定的是:

  • 典型的 OOP 倾向强调改变对象的状态。

  • 典型的 OOP 倾向层与层之间紧密的耦合。

  • 典型的 OOP 将同一性(identity)与状态的概念混在一起了。

  • 数据与代码的混合引出了概念与实际操作中的问题。

Rich Hickey,Clojure 的发明人(运行在 JVM 上的的函数式 Lisp 方言),在这场出色的演讲里谈论了状态、数值以及同一性。

万物皆表达式

可以这么写:

if found_dog == our_dog
  name = found_dog.name
  message = "We found our dog #{name}!"else
  message = "No luck"end

然而,控制结构(if, while, case 等)也返回表达式,所以只要这样写就好:

message = if found_dog == my_dog
  name = found_dog.name  "We found our dog #{name}!"else
  "No luck"end

这样子我们不用重复变量名 message,意图也更明显。我们可以专注在程序在干什么(返回讯息),而不是冗长的代码和一堆我们并不在乎的变量。再强调一次,我们在缩小程序的作用域。

另一个函数式程序的好处是,表达式可以用来构造数据:

{  :name => "M.Cassatt",  :paintings => paintings.select { |p| p.author == "M.Cassatt" },  :birth => painters.detect { |p| p.name == "M.Cassatt" }.birth.year,
  ...
}

递归

纯函数式语言没有隐含的状态,大量利用了递归。为了避免栈溢出,函数式使用一种称为尾递归优化(TCO)的机制。Ruby 1.9 实现了这种机制,但缺省没有打开。要是你希望你的程序,在哪都跑的话,就不要用尾递归。

但是某些情况下,递归仍然是很有用的,即便是每次递归时都创建新的栈。注意!某些递归的用途可以用 foldings 来实现(像 Enumerable#inject)。

在 MRI-1.9 启用 TCO:

RubyVM::InstructionSequence.compile_option = {  :tailcall_optimization => true,  :trace_instruction => false,
}

简单例子:

module Math
  def self.factorial_tco(n, acc=1)
    n < 1 ? acc : factorial_tco(n-1, n*acc)  endend

在递归深度不太可能很深的情况下,你仍可以使用:

class Node
  has_many :children, :class_name => "Node"

  def all_children
    self.children.flat_map do |child|
      [child] + child.all_children    end
  endend

惰性枚举器

惰性求值延迟了表达式的求值,在真正需要时才会求值。与 eager evaluation(迫切求值) 相反,eager evaluation 当一个变量被赋值时、函数被调用时…甚至变量没有被使用的情况下,都立马对表达式求值,惰性不是函数式编程的必需品,但这是个符合函数式范式的好策略(Haskell 大概是最佳的例子,弥漫着懒惰的语言)。

Ruby 所猜用的基本上是迫切求值(虽然同许多其它的语言一样,在条件语句中不满足判定条件时,在短路布尔运算 &&,||等情况下,不对表达式求值)。然而,与任何内置高阶函数的语言一样,延迟求值是隐性支援,因为程序员可自己决定区块何时被调用。

Enumerators 同样 从 Ruby 1.9 开始支援(1.8 请用 backports),它们提供了一个简单的介面来定义惰性 enumerables。经典的例子是构造一个枚举器,返回所有的自然数:

require 'backports' # 1.8 才需要natural_numbers = Enumerator.new do |yielder|
  number = 1
  loop do
    yielder.yield number
    number += 1
  endend

可以写得更有函数味:

natural_numbers = Enumerator.new do |yielder|
  (1..1.0/0).each do |number|
    yielder.yield number  endend
natural_numbers.take(10)# [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

现在,试试给 natural_numbersmap,发生什么事?它不会停止。标准的 enumerable 方法 (map, select 等)返回一个数组,所以在输入流是无穷大时,无法正常工作。让我们扩展 Enumerator 类别,比如加入这个惰性的Enumerator#map

class Enumerator
  def map(&block)    Enumerator.new do |yielder|      self.each do |value|
        yielder.yield(block.call(value))      end
    end
  endend

现在我们可以给所有自然数的流做 map 了:

natural_numbers.map { |x| 2 * x }.take(10)# [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

枚举器是构造惰性行为的砖瓦,你也可使用实现了所有 Enumerable 方法惰性化的函式库:

https://github.com/yhara/enumerable-lazy

require 'enumerable/lazy'(1..1.0/0).lazy.map { |x| 2*x }.take(10).to_a# [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

惰性求值的好处

  1. 显而易见的好处: 无需在不必要的情况下,构造、储存完整的结构(也许,可以更有效率的使用 CPU 及内存)

  2. 不太显而易见的好处: 惰性求值使写的程序无需了解超出它需要了解的范围。让我们看一个例子:你写了某种解题工具,可以提供无数种解法,但在某个时候,你只想要前十种解法。你可能会这么写:

solver(input, :max => 10)

当你与惰性结构一起工作时,不需要说什么时候该结束。调用者自己会决定他需要多少值。代码更简单,编码者也不许负责默认输出多少个结果:

solver(input).take(10)

一个实际的例子

练习:“前十个平方可被五整除的自然数的和是多少?”

Integer::natural.select { |x| x**2 % 5 == 0 }.take(10).inject(:+) #=> 275

让我们跟等价的命令式版本来比较:

n, num_elements, sum = 1, 0, 0while num_elements < 10
  if n**2 % 5 == 0
    sum += n
    num_elements += 1
  end
  n += 1endsum #=> 275

我希望这个例子展示了本文档谈到的函数式编程优点:

  1. 简洁: 你会撰写更少的代码。函数式程序处理的是表达式,而表达式可以连锁起来;命令式程序处理的是变量的改动(叙述式),而这不能连锁。

  2. 抽象: 你可以争论说我们使用 select, inject…等等,来隐藏了一大堆代码,我很高兴你这么说,事情就是这样。将通用的、可重用的代码隐藏起来,撰写抽象代码是任何编程的重点,函数式编程尤为这样。让人高兴的不是因为代码少了,而是因为依靠辨认出可重用的模式,简化了代码的复杂性。

  3. 更有声明式的味道: 看看命令式的版本,第一眼看起来是一沱无用的代码 ── 没有注解的话 ── 它会做什么你完全没有概念。你可能会说:“好吧,从这里开始读,草草记下 nsum 的值,进入某个循环,看看 nsum 的值如何变化,看看最后一次迭代的情形” 等等。函数式版本则不言自明,函数式版本描述、声明它在干的事,而不是如何干这件事。

“函数式编程像是将你的问题叙述给数学家。命令式编程像是给白痴下指令” (arcus 在 Freenode #scheme 频道所说)

结论

更好地理解函数式编程原理,能帮助我们写出更清晰、重用性更高和更简洁的代码。Ruby 基本上是一个命令式语言,但它的函数式能力也很强,学会何时用和如何用(以及何时不该用)这种能力吧。“状态是万恶的根源,尽可能避免它”, 将这句话当成你的座右铭吧。


以上是关于Ruby 函数式编程的主要内容,如果未能解决你的问题,请参考以下文章

函数式编程/命令式编程

《On Java 8》中文版 第十三章 函数式编程

Ruby篇

函数式编程实用介绍

web代码片段

函数式编程