65.9K
CodeProject 正在变化。 阅读更多。
Home

F# 和 Ruby 中的函数组合、函数链、柯里化和部分函数/应用

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (4投票s)

2013年10月14日

CPOL

10分钟阅读

viewsIcon

25698

探索如何在 Ruby 中实现函数组合和函数链等函数式编程特性。

引言

前段时间我写了一篇文章 比较/对比 Ruby 和 C# 中的类语言元素,包括构造函数、析构函数、字段、属性、初始化器、事件、方法等。然而,Ruby 也涉足了函数式语言范式,尽管它需要 一定的纪律 来确保您遵守函数式编程语言的特性,例如无状态性和不变性。然而,出于本文的目的,我更感兴趣的是探索 Ruby 如何处理一些似乎是大多数函数式编程语言核心的概念:

  • 函数组合
  • 函数管道(链式调用)
  • 连续函数
  • 柯里化
  • 部分函数
  • 部分应用

这些概念相互交织,正如我们将看到的,您当然可以在 Ruby 中实现这些行为,尽管不如 F#(或其他函数式编程语言)优雅。顺便说一句,Ruby 之所以能够被称为函数式编程语言,其中一个原因是它支持 Lambda 表达式,以及代码块在迭代、特定应用处理(如 IoC)等方面易于生成。同样,这些不是我在这篇文章中感兴趣的领域,因为此时它们也已成为大多数命令式语言(如 C#)的一部分,进一步模糊了命令式、声明式和函数式编程之间的界限。

背景

这项研究的起因是我几天前写的一个小辅助函数。这有点离题,所以请坐下来享受这段简短的绕道。Ruby on Rails 开发中一个有趣的地方是能够编写“特性”来定义由“步骤”组成的“场景”,以测试网站的行为。这都是许多 Ruby 开发者所遵循的行为驱动开发范式的一部分,这种能力由一个名为 Cucumber 的包提供,它也适用于 .NET。.NET 世界中的其他 BDD 测试范式有 SpecFlow NSpec

像任何旨在提供帮助的事物一样,它也带来了一系列自身的问题。对于 BDD 来说,这变成了应用程序定义的大量步骤,通常分布在许多文件中。步骤看起来像这样:

When /^(?:|I )go to (.+)$/ do |page_name|
  visit path_to(page_name)
end

When /^(?:|I )fill in "([^"]*)" for "([^"]*)"(?: within "([^"]*)")?$/ do |value, field, selector|
  with_scope(selector) do
    fill_in(field, :with => value)
  end
end

开发人员如何跟踪所有这些步骤,以及我们如何记录所有不同的参数?嗯,Ruby 有一种巧妙的方法可以检查自身,因此,借鉴他人的工作,我编写了一小套函数来对所有 BDD 测试步骤进行排序,并将它们显示为 HTML 文档。以下是其输出的片段:

您可以在 我的博客 上查看所有 Ruby 代码,但重点是这段代码:

def step_definitions_to_html
  # Where are your step definitions defined?
  step_definition_dir = "./features/step_definitions"
  # Where do you want output to go:
  outfile = "output.htm"

  steps = get_step_definitions(step_definition_dir)   # get the step definitions
  steps = sort_steps_by_name(step)	              # sort them
  output_step_definitions_as_html(outfile, steps)     # output as HTML
  show_html(outfile)                                  # show the file in the browser
end

在我看来,这简直是命令式编程的糟糕范例。我认为将其写成函数组合或函数管道会很有趣,抽象地看,就像这样:

get_steps(input_path) >> sort_steps >> display_steps(output_file)

我没有意识到为了在 Ruby 中实现一个在 F# 中极其简单的功能,我需要深入多少“兔子洞”。

函数管道

让我们把问题简化一点。假设

我有一个函数“a”,它将整数转换为字符串。

F#

let a n = Convert.ToString(n:int32);;
val a : n:int32 -> string

这意味着:a 是一个接收 int32 并返回字符串的函数。

Ruby

def a(n) n.to_s; end

我有一个函数“b”,它将一个固定字符串附加到两个参数上。

F#

let b n intstr = Convert.ToString(n:float) + ", " + intstr;;
val b : n:float -> intstr:string -> string

读作:b 是一个接受浮点数和字符串并返回字符串的函数。

Ruby

def b(n, instr) n.to_s + ", " + instr; end

我们使用不同类型的原因是,当中间函数具有不同类型时,更容易看出它们是什么——我们将使用 int、string 和 float 类型来研究函数管道和组合。

这两个参数中的一个就是函数“a”的输出,它将一个整数转换为字符串,这样我就可以编写函数链:

F#

3 |> a |> b 1.2;;
val it : string = "1.2, 3"

给定我们上面定义的函数 b:

let b n intstr = Convert.ToString(n:float) + ", " + intstr

我们可以看到函数 a 评估为“3”。这成为函数 b 的*最后一个参数*的输入,从而产生一个柯里化函数,其中 b 的第一个参数 (n) 仍然需要提供:

let b n intstr = Convert.ToString(n:float) + ", " + "3"

然而,由于这是函数管道,我们不能创建一个中间函数(那将是函数组合)。因此,例如,这会导致错误:

 let r = 3 |> a |> b;;
------------------^

stdin(146,19): error FS0001: Type mismatch. Expecting a
string -> 'a 
but given a
float -> string -> string 
The type 'string' does not match the type 'float'

因此,使用函数管道时,我们需要提供所有参数,以便整个管道可以被评估。

我们如何在 Ruby 中做到这一点呢?“管道式”这个词的问题在于,Ruby 社区的一些人将其解释为在单独的线程(或纤程)中运行,不等函数继续执行管道中的下一个函数。这不是我们想要的。然后,“链式”这个词的问题在于,Ruby 社区几乎所有人都将其解释为返回“self”(相当于 C# 中的“this”)的函数,以便可以调用对象的另一个函数,导致如下表示法:

a(3).b("def")

当然,这也不是我们想要的,因为 a(3) 会返回一个对象,而不是“3”!我们想要做的是保留函数的原始含义,而不是被迫采用特定的编码风格。

现在,回到 F# 代码,管道运算符定义为简单地交换函数和参数:

let inline (|>) x f = f x

要在 Ruby 中做类似的事情,我们必须深入研究 lambda 表达式、Proc 类,以及出于语法糖目的的运算符重载。

用函数扩展 Ruby

函数管道(以及我们稍后将看到的函数组合)不是 Ruby 语言的内置行为。但是,我们可以通过将 Ruby 函数和值转换为 lambda 表达式来创建具有这种行为的函数。这允许我们在表达式上定义运算符并对其进行求值。所以,首先,让我们定义一个简单返回值 3 的 lambda 表达式:

l3 = lambda {3}
=> #<Proc:0x2a12f60@(irb):33 (lambda)>

如您所见,这返回了一个 Proc 对象。我们可以评估这个表达式(请注意语法,有三种不同的方式来评估 lambda 表达式!):

l3.()
=> 3

我们看到它返回 3。

现在我们需要一个封装函数 a 的 lambda 表达式,它将数字转换为字符串:

la = lambda{|x| a(x)}
=> #<Proc:0x2a1e480@(irb):43 (lambda)>

如果我们评估这个表达式:

la.(3)
=> "3"

我们得到了预期的字符串转换。

现在我们只需要做等同于:

la.(l3.())
=> "3"

使用运算符来获得我们想要的语法糖。这涉及到重载运算符:

class Proc
  def self.chain(f, g)
    lambda { g[f.()] }
  end
  def >=(g)
    Proc.chain(self, g)
  end
end

这里,`>=` 运算符被重载(Ruby 只允许重载已经定义的运算符),这样 `chain` 函数就被调用,带有两个参数:左操作数和右操作数。`chain` 函数返回一个 lambda 表达式,它评估右操作数,并将左操作数评估后的 lambda 表达式作为参数传递进去。因此,我们可以看到 F# 实现的等效写法:

let inline (|>) x f = f x

现在,当我们进入交互式 Ruby 控制台 (IRB) 时:

(lambda{3} >= lambda{|n| n.to_s}).()

其计算结果为字符串“3”。

现在,这需要大量的输入,而且看起来很丑陋,请记住,我们希望它与我们之前定义的现有 Ruby 函数一起工作:

def a(n) n.to_s; end
def b(n, instr) n.to_s + ", " + instr; end

所以我们将创建几个辅助函数:

def val(n) lambda {n}; end
def fnc(f) lambda(&method(f)); end

函数 `val` 接受一个值并将其转换为 lambda 表达式。函数 `fnc` 接受一个函数并将其转换为 lambda 表达式。我们现在可以这样写:

(val(3) >= fnc(:a)).()
=> "3"

请注意我们如何“符号化”函数 a。

但是,如果我们想在函数 b 中链式调用参数值“1.2”,我们应该放在哪里呢?答案在于我们对 F# 代码的探索以及对 Ruby 中另一个“兔子洞”——柯里化——的深入研究。

柯里化

我们在 Ruby 函数式编程之旅中的下一个“兔子洞”是意识到,因为函数 b 有两个参数,我们需要对函数进行柯里化,将来自 `>=` 运算符左侧的值作为参数传递,并将其余参数留给调用者指定。我们可以明确地这样做:

(val(3) >= fnc(:a) >= fnc(:b).curry.("1.2")).()
=> "1.2, 3"

因此,字符串“3”将被作为参数 intstr 传入,留下柯里化函数,简单地表示为:

def b(n, instr) n.to_s + ", " + "3"; end

我们可以用一个柯里化“将函数转换为 lambda 表达式”的函数使其更简洁:

def fncc(f, *args) lambda(&method(f)).curry.(*args); end

并以此表示函数链:

(val(3) >= fnc(:a) >= fncc(:b, 1.2)).()
=> "1.2, 3"

在幕后,当 `fnc(:a) >= fncc(:b)` 评估时,运算符左侧的值作为最后一个参数传递给函数 b,并且 `fncc` 返回一个部分函数(作为 lambda),它接受剩余的(第一个)参数。

所以。我们已经实现了 F# 风格的函数链式调用,尽管它相当笨拙,因为我们必须明确地进行柯里化(以及将函数转换为 lambda 表达式),而 F# 中等效的写法:

3 |> a |> b 1.2;;
val it : string = "1.2, 3"

则简洁得多。

要是……

请注意,如果我们不必处理函数 b 中那些烦人的附加参数,我们可以这样写:

(val(3) >= :a).()

如果我们改变 `>=` 运算符的定义,让它为我们执行“lambda-化”:

class Proc
  def self.chain(f, g)
    lambda { g.(f.()) }
  end
  def >=(g)
    Proc.chain(self, fnc(g))
  end
end

不幸的是,这样做对于需要额外参数的函数来说是行不通的。

函数组合

说实话,我们所做的更像是函数组合,因为我们甚至将值(比如 3)都变成了 lambda 表达式。然而,函数组合在 F# 中行为略有不同,并且,利用我们已经完成的工作,我们可以在 Ruby 中以类似的方式实现函数组合。

首先,在 F# 中,我们不能这样做:

 3 >> a >> b 1.2;;
 ^

stdin(92,1): error FS0001: This expression was expected to have type
'a -> 'b 
but here has type
int 

函数组合期望一个函数,而不是一个整数。

但我们可以这样做:

(a >> b 1.2) 3;;
val it : string = "1.2, 3"

这里我们创建了一个组合的部分函数:

a >> b 1.2;;
val it : (int32 -> string) = <fun:it@152-28>

组合函数期望一个整数并返回一个字符串。它是一个部分应用,因为我们已经将最后一个参数应用于函数 b。

再次注意 F# 如何隐式地进行了柯里化,只是现在我们可以给组合函数赋值:

let r = a >> b 1.2;;
val r : (int32 -> string)

> r 3;;
val it : string = "1.2, 3"

还记得在 F# 中,管道运算符只是简单地交换参数吗?

let inline (|>) x f = f x

嗯,对于函数组合,`>>` 组合运算符也做同样的事情:

let inline (>>) f g x = g(f x)

这里,颠倒 f 和 g,使得 g 以“f x”的计算结果调用。

我们可以在 Ruby 中定义相同的运算符和行为:

class Proc
  def self.compose(f, g)
    lambda { |*args| g[f[*args]] }
  end
  def >>(g)
    Proc.compose(self, g)
  end
end

并且,在之前创建了一些辅助函数后,我们现在可以组合函数(注意 fncc 函数中的显式柯里化):

(fnc(:a) >> fncc(:b, 1.2)).(3)
=> "1.2, 3"

将其与上面 F# 语法进行比较:

(a >> b 1.2) 3;;

我们目前所做工作的总结

F# 函数管道

let a n = Convert.ToString(n:int32);;
let b n intstr = Convert.ToString(n:float) + ", " + intstr;;
3 |> a |> b 1.2;;

Ruby 函数管道

class Proc
  def self.chain(f, g)
    lambda { g[f.()] }
  end
  def >=(g)
    Proc.chain(self, g)
  end
end

def val(n) lambda {n}; end
def fnc(f) lambda(&method(f)); end
def fncc(f, *args) lambda(&method(f)).curry.(*args); end

def a(n) n.to_s; end
def b(n, instr) n.to_s + ", " + instr; end

(val(3) >= fnc(:a) >= fncc(:b, 1.2)).()

F# 函数组合

let a n = Convert.ToString(n:int32);;
let b n intstr = Convert.ToString(n:float) + ", " + intstr;;
(a >> b 1.2) 3;;
let r = a >> b 1.2;;
r 3;;

Ruby 函数组合

class Proc
  def self.compose(f, g)
    lambda { |*args| g[f[*args]] }
  end
  def >>(g)
    Proc.compose(self, g)
  end
end

def val(n) lambda {n}; end
def fnc(f) lambda(&method(f)); end
def fncc(f, *args) lambda(&method(f)).curry.(*args); end

def a(n) n.to_s; end
def b(n, instr) n.to_s + ", " + instr; end
(fnc(:a) >> fncc(:b, 1.2)).(3)
r = (fnc(:a) >> fncc(:b, 1.2))
r.(3)

函数管道的替代实现

Jörg W Mittag 回答了我在 stackoverflow 上关于 F# 函数链等效功能的提问,提供了一个不涉及运算符重载的替代实现:

class Object
  def pipe(callable)
    callable.(self)
  end
end

请注意,`pipe` 方法在 `Object` 类中定义,并且还请注意习惯性的 `->` 语法来定义 lambda 表达式。

我们可以这样使用这段代码(利用我们的其他函数辅助器):

3.pipe(fnc(:a)).pipe(fncc(:b, 1.2))
=> "1.2, 3"

请注意,我们不需要将字面值 3 转换为 lambda 表达式,因为我们正在扩展 Object 类,而 Ruby 中的一切都是对象,因此,“pipe”成为可以应用于数字 3 的函数。lambda 函数被传入然后被评估,传递调用 pipe 的对象。这有效地实现了“x.pipe(f) = f(x)”。这是一段非常巧妙的代码!

我们现在在哪里?

还记得我想改成更函数式编程风格实现的 Ruby 代码吗?

def step_definitions_to_html
  # Where are your step definitions defined?
  step_definition_dir = "./features/step_definitions"
  # Where do you want output to go:
  outfile = "output.htm"
  steps = get_step_definitions(step_definition_dir)   # get the step definitions
  steps = steps.sort_by {|step| step.regex}           # sort them
  output_step_definitions_as_html(outfile, steps)     # output as HTML
  show_html(outfile)                                  # show the file in the browser
end

现在它看起来像这样:

def step_definitions_to_html
  step_definition_dir = "./features/step_definitions"
  # Where do you want output to go:
  outfile = "output.htm"

  steps = val((step_definition_dir) >=
          fnc(:get_step_definitions) >=                        # get the step definitions
          fnc(:sort_steps_by_name) >=	                       # sort them
          fncc(:output_step_definitions_as_html, outfile) >=   # output as HTML
          fnc(:show_html)                                      # show the file in the browser
end

结论

最终,我可能甚至不会在 F# 中使用管道来编写这段代码,而是会采用更“命令式”的风格,使用局部变量来存储中间结果。尽管如此,这仍然是一次有趣的旅程,深入探索了 Ruby 语言的一些更深奥的方面。

致谢和参考

感谢 Jörg W Mittagstackoverflow 上给出的出色回答,提出了函数管道的替代实现。
Ruby 函数式编程
Ruby 中的函数组合
函数 (F#)
Ruby 语言,运算符表达式
理解 Ruby 块、Procs 和 Lambdas 
Ruby 柯里化

© . All rights reserved.