闭包是否被视为不纯正的功能风格?


33

在函数式编程中,闭包是否被视为不纯?

似乎通常可以通过将值直接传递给函数来避免闭包。因此,应尽可能避免关闭?

如果它们是不纯的,并且我正确地指出可以避免使用它们,那么为什么这么多函数式编程语言支持闭包?

纯函数的标准之一是“在给定相同参数值的情况下,该函数始终求值相同结果值”。

假设

f: x -> x + y

f(3)不会总是给出相同的结果。f(3)取决于的值y不是的参数f。因此f这不是一个纯粹的功能。

由于所有闭包都依赖于不是参数的值,所以任何闭包怎么可能是纯净的?是的,从理论上讲,封闭值可以是恒定的,但仅通过查看函数本身的源代码就无法知道。

这导致我想到的是,同一功能在一种情况下可能是纯净的,而在另一种情况下是不纯净的。不能总是通过研究其源代码来确定一个函数是否纯净。相反,在进行区分之前,可能必须在调用它时就其环境来考虑它。

我在想这个吗?


6
我一直在Haskell中使用闭包,Haskell尽可能地纯净。
Thomas Eding 2014年

5
在纯函数式语言中,y无法更改,因此输出f(3)始终是相同的。
莉莉·钟

4
y是的定义的一部分,f即使它没有被明确标记为输入f-仍然f是根据y(我们可以表示函数f_y,以使对y显式的依赖)定义的情况,因此更改y给出了一个不同的函数。为特定对象f_y定义的特定函数y非常纯净。(例如,即使我们碰巧使用相同的字母表示它们,这两个函数f: x -> x + 3f: x -> x + 5也是不同的函数,并且都是纯函数。)
ShreevatsaR 2014年

Answers:


26

纯度可以通过两件事来衡量:

  1. 给定相同的输入,函数是否总是返回相同的输出;即它是参照透明的吗?
  2. 该函数是否会修改自身之外的任何内容,即是否具有副作用?

如果对1的回答为是,对2的回答为否,则该函数为纯函数。仅当您修改封闭变量时,闭包才使函数不纯。


第一项不是确定性吗?还是那也是纯度的一部分?我不太熟悉编程中的“纯度”概念。

4
@JimmyHoffa:不一定。您可以将硬件计时器的输出放在函数中,而函数外的任何内容都不会被修改。
罗伯特·哈维

1
@RobertHarvey这就是我们如何定义函数输入的全部内容吗?我在Wikipedia中的引文着重于函数参数,而您另外将封闭变量视为输入。
user2179977 2014年

8
@ user2179977:除非他们是可变的,你应该考虑封闭了变量作为额外的输入功能。相反,您应该将闭包本身视为一个函数,并且当其关闭的其他值时,则将其视为另一个函数y。因此,例如,我们定义了一个函数gg(y)该函数本身就是函数x -> x + y。然后g是整数回返功能的功能,g(3)是整数的函数,返回整数,并且g(2)不同的整数回返整数的函数。这三个函数都是纯函数。
史蒂夫·杰索普

1
@Darkhogg:是的。查看我的更新。
罗伯特·哈维

10

闭包出现Lambda微积分,这是可能的最纯粹的函数式编程形式,因此我不会称其为“不纯净的” ...

闭包不是“不纯净的”,因为功能语言中的函数是一等公民-这意味着它们可以被视为价值。

想象一下这个(伪代码):

foo(x) {
    let y = x + 1
    ...
}

y是一个值。它的值取决于x,但是x是不可变的,因此y的值也是不可变的。我们可以foo用不同的参数多次调用,这些参数会产生不同y的,但是这些y都存在于不同的范围内并且依赖于不同x的,因此纯度保持不变。

现在让我们更改它:

bar(x) {
    let y(z) = x + z
    ....
}

在这里,我们使用闭包(在x上闭包),但与in相同foo-对bar不同参数的不同调用会创建不同的y(记住-函数为值)值,这些值都是不可变的,因此纯度保持不变。

另外,请注意,闭包与curring的作用非常相似:

adder(a)(b) {
    return a + b
}
baz(x) {
    let y = adder(x)
    ...
}

baz并没有什么不同bar-在这两者中,我们都创建了一个名为函数值y,该函数值返回其参数plus x。实际上,在Lambda Calculus中,您可以使用闭包来创建具有多个参数的函数-但这仍然不纯。


9

其他人在回答中很好地涵盖了一般性问题,因此,我只会着眼于清除您在编辑中表示的困惑。

闭包不会成为函数的输入,而是“进入”函数主体。更具体地说,函数是指函数外部范围内的值。

您的印象是它使功能不纯。通常情况并非如此。在函数式编程中,值在大多数时间是不可变。这也适用于封闭值。

假设您有一段这样的代码:

let make y =
    fun x -> x + y

调用make 3make 4会给你封了两个函数makey参数。他们中的一个会回来x + 3,另一个x + 4。但是,它们是两个不同的功能,并且都是纯函数。它们是使用相同的make函数创建的,仅此而已。

大多数情况下,请注意几段。

  1. 在纯粹的Haskell中,您只能关闭不可变的值。没有可变状态可以关闭。您一定会以这种方式获得纯函数。
  2. 在像F#这样的不纯函数语言中,您可以关闭引用单元格和引用类型,并获得不纯函数的作用。正确的是,您必须跟踪定义函数的范围,以了解它是否是纯函数。您可以轻松判断一个值在这些语言中是否可变,因此这不是什么大问题。
  3. 在支持闭包的OOP语言(例如C#和JavaScript)中,情况类似于不纯函数语言,但是由于默认情况下变量是可变的,因此跟踪外部作用域变得更加棘手。

请注意,对于2和3,这些语言不提供有关纯度的任何保证。那里的杂质不是闭包的属性,而是语言本身的属性。闭包本身并不能改变很多情况。


1
您可以绝对关闭Haskell中的可变值,但是IO monad会注释这种情况。
Daniel Gratzer 2014年

1
@jozefg不,您关闭了一个不可变的IO A值,并且您的关闭类型为IO (B -> C)或类似的值。保持纯度
Caleth

5

通常,我会请您澄清“不纯”的定义,但是在这种情况下,这并不重要。假设您将其与“ 纯功能 ”一词作对比,答案是“否”,因为闭包本身并没有破坏性的内容。如果您的语言纯粹是没有闭包的函数,那么它仍然是纯粹有闭包的函数。相反,如果您的意思是“不起作用”,那么答案仍然是“否”。闭包有助于函数的创建。

似乎通常可以通过将数据直接传递给函数来避免闭包。

是的,但是您的函数将再有一个参数,这将更改其类型。闭包允许您基于变量创建函数,而无需添加参数。例如,当您有一个带有2个参数的函数,并且想要创建一个仅带有1个参数的版本时,这很有用。

编辑:关于您自己的编辑/示例...

假设

f:x-> x + y

f(3)不会总是给出相同的结果。f(3)取决于y的值,它不是f的参数。因此,f不是一个纯函数。

取决于是这里单词的错误选择。引用与您相同的Wikipedia文章:

在计算机编程中,如果关于函数的以下两个语句均成立,则该函数可以描述为纯函数:

  1. 给定相同的参数值,该函数始终求值相同的结果值。函数结果值不能依赖于任何隐藏的信息或状态,这些信息或状态可能随着程序执行的进行或程序的不同执行之间的变化而改变,也不能依赖于I / O设备的任何外部输入。
  2. 结果评估不会引起任何语义上可观察到的副作用或输出,例如可变对象的突变或输出到I / O设备。

假设y是不可变的(在函数式语言中通常是这种情况),则满足条件1:对于的所有值x,的值f(x)不变。y从与常数没有区别并且x + 3是纯净的事实应该清楚这一点。也很明显,没有任何突变或I / O正在进行。


3

很快:如果“用相似的替换导致相似”替换是“相对透明的”,而如果函数的所有作用都包含在返回值中,则该函数是“纯”的。可以将两者精确化,但必须指出的是,它们既不相同也不暗示彼此。

现在让我们谈谈闭包。

无聊(大多是纯净的)“关闭”

之所以会出现闭包,是因为在评估lambda项时,我们会将变量(绑定)解释为环境查询。因此,当我们返回lambda项作为求值结果时,其中的变量将“覆盖”它们在定义时所取的值。

用普通的lambda演算,这是微不足道的,整个概念就消失了。为了证明这一点,这是一个相对轻量级的lambda演算解释器:

-- untyped lambda calculus values are functions
data Value = FunVal (Value -> Value)

-- we write expressions where variables take string-based names, but we'll
-- also just assume that nobody ever shadows names to avoid having to do
-- capture-avoiding substitutions

type Name = String

data Expr
  = Var Name
  | App Expr Expr
  | Abs Name Expr

-- We model the environment as function from strings to values, 
-- notably ignoring any kind of smooth lookup failures
type Env = Name -> Value

-- The empty environment
env0 :: Env
env0 _ = error "Nope!"

-- Augmenting the environment with a value, "closing over" it!
addEnv :: Name -> Value -> Env -> Env
addEnv nm v e nm' | nm' == nm = v
                  | otherwise = e nm

-- And finally the interpreter itself
interp :: Env -> Expr -> Value
interp e (Var name) = e name          -- variable lookup in the env
interp e (App ef ex) =
  let FunVal f = interp e ef
      x        = interp e ex
  in f x                              -- application to lambda terms
interp e (Abs name expr) =
  -- augmentation of a local (lexical) environment
  FunVal (\value -> interp (addEnv name value e) expr)

需要注意的重要部分是addEnv当我们使用新名称扩展环境时。该函数仅被称为解释的Abs牵引项(拉姆达项)的“内部” 。每当我们评估一个Var术语时,环境都会被“查找” ,因此这些Vars解析为NameEnv其中Abs包含的牵引力捕获了所引用的内容Var

现在,再用普通LC术语来说,这很无聊。这意味着就任何人而言,绑定变量只是常量。它们会立即和立即评估为它们在环境中所表示的值(按词法作用域限定)。

这也是(几乎)纯净的。在我们的lambda演算中,任何术语的唯一含义取决于其返回值。唯一的例外是欧米茄(Omega)术语所体现的不终止的副作用:

-- in simple LC syntax:
--
-- (\x -> (x x)) (\x -> (x x))
omega :: Expr
omega = App (Abs "x" (App (Var "x") 
                          (Var "x")))
            (Abs "x" (App (Var "x") 
                          (Var "x")))

有趣的(不纯净的)关闭

现在,在某些背景下,上面简单的LC中描述的闭包很无聊,因为没有观念能够与我们已经封闭的变量进行交互。特别是,“ closure”一词倾向于调用类似以下Javascript的代码

> function mk_counter() {
  var n = 0;
  return function incr() {
    return n += 1;
  }
}
undefined

> var c = mk_counter()
undefined
> c()
1
> c()
2
> c()
3

这表明我们已经关闭了n内部函数中的变量,incr并且调用incr与该变量进行了有意义的交互。mk_counter是纯净的,但incr绝对不纯(并且也不是参照透明的)。

这两个实例之间有什么不同?

“变量”的概念

如果我们看一下普通LC意义上的替换和抽象意味着什么,我们会注意到它们绝对是普通的。实际上,变量无非就是立即环境查找。Lambda抽象实际上只是创建一个增强的环境来评估内部表达式而已。在此模型中,我们对于用mk_counter/ 看到的行为没有空间,incr因为不允许有变化。

对于许多人来说,这就是“变量”含义的核心-变异。但是,语义学家喜欢区分LC中使用的变量类型和Javascript中使用的“变量”类型。为此,他们倾向于将后者称为“可变单元”或“插槽”。

这种命名法遵循了“变量”在数学上的悠久历史用法,在该术语中它的含义更像是“未知” :(数学)表达式“ x + x”不允许x随时间变化,而是意味着无论(单个,常量)值x的。

因此,我们说“槽”是为了强调将值放入槽并将其取出的能力。

更令人困惑的是,在Javascript中,这些“槽”看起来与变量相同:

var x;

创建一个,然后当我们写

x;

它表示我们正在查找当前存储在该广告位中的值。为了更清楚地说明这一点,纯净的语言倾向于将槽视为将名称视为(数学,lambda演算)名称。在这种情况下,我们必须在从插槽取出或放入插槽时显式标记。这种表示法看起来像

-- create a fresh, empty slot and name it `x` in the context of the 
-- expression E
let x = newSlot in E

-- look up the value stored in the named slot named `x`, return that value
get x

-- store a new value, `v`, in the slot named `x`, return the slot
put x v

这种表示法的优点是,我们现在在数学变量和可变槽之间有了明确的区分。变量可以将插槽作为其值,但是变量命名的特定插槽在整个范围内都是恒定的。

使用这种表示法,我们可以重写mk_counter示例(这次使用类似Haskell的语法,尽管绝对不是类似Haskell的语义):

mkCounter = 
  let x = newSlot 
  in (\() -> let old = get x 
             in get (put x (old + 1)))

在这种情况下,我们将使用操作此可变插槽的过程。为了实现它,我们不仅需要关闭名称之类的恒定环境,x而且需要封闭一个包含所有所需插槽的可变环境。这与人们如此钟爱的“关闭”的普遍观念更为接近。

再次,mkCounter是很不纯正的。它也是非常不透明的。但是请注意,副作用不是由名称捕获或关闭引起的,而是由可变单元格的捕获及其上的副作用操作引起的,如getput

最终,我认为这是对您问题的最终答案:纯度不受(数学)变量捕获的影响,而是受捕获变量命名的可变插槽上执行的副作用操作的影响。

只是在不试图接近于LC或不试图保持纯正的语言中,这两个概念经常被混淆,导致混淆。


1

不,只要闭包的值是恒定的(闭包或其他代码都不会更改),闭包就不会导致函数不纯正,这在函数编程中很常见。

请注意,尽管您始终可以将值作为参数传递,但通常情况下,如果没有很多困难,您就不能这样做。例如(咖啡脚本):

closedValue = 42
return (arg) -> console.log "#{closedValue} #{arg}"

根据您的建议,您可以返回:

return (arg, closedValue) -> console.log "#{closedValue} #{arg}"

此功能不被称为在这一点上,刚刚定义的,所以你必须找到某种方式来传递你想要的值closedValue在该函数实际上是被称为点。充其量这会产生很多耦合。最糟糕的是,您不会在调用点控制代码,因此实际上是不可能的。

不支持闭包的语言中的事件库通常提供将其他数据传递回回调的其他方法,但它不是很漂亮,并且给库维护者和库用户带来很多复杂性。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.