函数式编程语言如何工作?


92

如果函数式编程语言无法保存任何状态,它们将如何做一些简单的事情,例如从用户那里读取输入内容?他们如何“存储”输入(或存储与此相关的任何数据?)

例如:这个简单的C语言将如何转换为Haskell这样的函数式编程语言?

#include<stdio.h>
int main() {
    int no;
    scanf("%d",&no);
    return 0;
}

(我的问题受到了这篇出色的文章的启发:“名词的王国中的执行”。阅读它使我对面向对象的编程到底是什么,Java如何以一种极端的方式实现它以及函数式编程语言如何成为一种更好的理解。对比。)



4
这是一个很好的问题,因为在系统级别,计算机需要状态是有用的。我观看了对Simon Peyton-Jones(Haskell背后的一名开发人员)的采访,他说一台只运行完全无状态软件的计算机只能完成一件事:变热!下面有很多好的答案。有两种主要策略:1)制作不纯正的语言。2)制定一个狡猾的计划来抽象状态,这是Haskell所做的,本质上是创建一个新的,稍有更改的世界,而不是修改旧的世界。
损害2010年

14
SPJ不是在谈论副作用,不是在说吗?纯计算在参数绑定和调用堆栈中隐含大量状态,但是没有副作用(例如I / O)无法做任何有用的事情。这两点确实非常不同-有大量纯净的有状态Haskell代码,而Statemonad非常优雅;另一方面IO是一个丑陋,肮脏的hack,只是勉强使用。
CA McCann

4
camccann正确。功能语言有很多状态。它是显式管理的,而不是像命令式语言那样“远距离执行怪异的动作”。
只是我的正确观点,2010年

1
这里可能有些混乱。也许计算机需要效果才能有用,但是我认为这里的问题是关于编程语言的,而不是计算机。
圆锥

Answers:


80

如果函数式编程语言无法保存任何状态,它们将如何做一些简单的事情,例如从用户那里读取输入(我的意思是他们如何“存储”它)或为此存储任何数据?

正如您所收集的那样,函数式编程没有状态-但这并不意味着它不能存储数据。区别在于,如果我按照以下方式写(Haskell)语句:

let x = func value 3.14 20 "random"
in ...

我保证在中的值x始终相同...:没有任何可能更改它。同样,如果我有一个函数f :: String -> Integer(一个接受字符串并返回整数的函数),则可以确保f不会修改其参数,更改任何全局变量或将数据写入文件等。就像sepp2k在上面的评论中说的那样,这种不可变异性对于推理程序确实很有帮助:您编写折叠,纺锤和破坏数据的函数,返回新副本以便将它们链接在一起,并且可以确保没有这些函数调用可以做任何“有害”的事情。您知道这x一直都是x,并且您不必担心有人x := foo barx 及其使用,因为这是不可能的。

现在,如果我想读取用户的输入怎么办?正如肯尼(KennyTM)所说,不纯函数是一个纯函数,它已作为参数传递给整个世界,并返回结果和世界。当然,您实际上并不希望这样做:一方面,它太笨拙了;另一方面,如果我重用同一世界对象会发生什么?因此,这是以某种方式抽象的。Haskell使用IO类型处理它:

main :: IO ()
main = do str <- getLine
          let no = fst . head $ reads str :: Integer
          ...

这告诉我们这main是一个IO动作,什么也不返回。执行此操作意味着运行Haskell程序。规则是IO类型永远无法逃避IO操作;在这种情况下,我们使用来介绍该操作do。因此,getLine返回an IO String,可以用两种方式考虑:首先,作为一个运行时产生字符串的动作;第二,因为它是不正确获得的,所以被IO污染了。第一个更正确,但是第二个更有用。该<-String出来的IO String,并将其存储在str-但因为我们是在一个IO动作,我们必须把它包备份,所以它也不能“免俗”。下一行尝试读取整数(reads),并获取第一个成功匹配项(fst . head); 这都是纯的(没有IO),因此我们给它起一个名字let no = ...。然后,我们可以在nostr中使用...。因此,我们已经存储了不纯数据(从getLinestr)和纯数据(let no = ...)。

这种用于IO的机制非常强大:它使您可以将程序的纯算法部分与不纯的用户交互部分分开,并在类型级别上加以实施。您的minimumSpanningTree函数可能无法更改代码中的其他内容,也无法向用户写消息,依此类推。它是安全的。

这就是在Haskell中使用IO所需要知道的一切。如果仅此而已,可以在这里停止。但是,如果您想了解它为什么起作用,请继续阅读。(请注意,这些内容将特定于Haskell,其他语言可能会选择其他实现。)

因此,这似乎有点作弊,以某种方式在纯净的Haskell中添加了杂质。但事实并非如此-事实证明,我们可以完全在纯Haskell中实现IO类型(只要获得RealWorld)即可。想法是这样的:IO操作IO type与函数相同RealWorld -> (type, RealWorld),后者采用真实世界并返回type type和Modifyed 对象RealWorld。然后,我们定义了几个函数,因此我们可以使用这种类型而不会发疯:

return :: a -> IO a
return a = \rw -> (a,rw)

(>>=) :: IO a -> (a -> IO b) -> IO b
ioa >>= fn = \rw -> let (a,rw') = ioa rw in fn a rw'

第一个允许我们谈论什么都不做return 3的IO动作:是一个IO动作,它不会查询现实世界而只是返回3>>=称为“绑定” 的操作员允许我们运行IO操作。它从IO操作中提取值,通过函数将其传递给现实世界,然后返回结果IO操作。请注意,>>=执行我们的规则是永远不允许逸出IO操作的结果。

然后,我们可以将以上内容main转换为以下普通的功能应用程序集:

main = getLine >>= \str -> let no = (fst . head $ reads str :: Integer) in ...

Haskell运行时从maininitial开始RealWorld,我们就开始了!一切都是纯净的,它只有一种奇特的语法。

[ 编辑: 正如@Conal指出的,这实际上不是Haskell用于执行IO的。如果添加并发,或者在IO操作过程中世界发生任何变化,此模型都会中断,因此Haskell不可能使用此模型。它仅对顺序计算准确。因此,Haskell的IO可能有点躲闪。即使不是,也肯定不是那么优雅。根据@Conal的观察,请参见Simon Peyton-Jones在处理尴尬小队[pdf]的第3.1节中所说的内容;他根据这些思路提出了可能构成替代模型的内容,但由于其复杂性而放弃了它,并采取了不同的策略。]

同样,这(很大程度上)解释了IO和总体可变性在Haskell中的工作方式。如果是你想知道的,你可以停止阅读这里。如果您想了解最后一门理论,请继续阅读-但请记住,这一点上,我们离您的问题还很遥远!

因此,最后一件事:事实证明,这种结构(带有return和的参数类型>>=)非常笼统;它被称为monad,do符号return>>=可以与其中任何一个一起使用。正如您在此处看到的那样,单子并不是魔幻的。神奇的是,do块变成了函数调用。该RealWorld类型是我们看不到任何魔法的唯一地方。[]列表构造函数之类的类型也是monad,它们与不纯代码无关。

您现在(几乎)了解有关单子概念的所有信息(除了必须满足的一些定律和正式的数学定义),但您缺乏直觉。在线上有大量荒谬的monad教程;我喜欢这个,但您可以选择。但是,这可能对您没有帮助;获得直觉的唯一真正方法是结合使用它们并在适当的时间阅读一些教程。

但是,您不需要那种直觉就可以了解IO。全面了解monad无疑是锦上添花,但是您现在就可以使用IO。在我向您展示了第一个main功能之后,您可以使用它。您甚至可以将IO代码当作不纯的语言来对待!但是请记住,有一个潜在的功能表示:没有人作弊。

(PS:很抱歉,长度太长了。我走得有点远。)


6
关于Haskell(我所做的并且正在努力学习的东西)总是让我感到困惑的是语法的丑陋。就像他们接受了所有其他语言中最差的一点,将它们丢进一个桶里,然后激怒了。然后这些人会抱怨C ++公认的(在某些地方)怪异的语法!

19
尼尔:真的吗?我实际上发现Haskell的语法很干净。我很好奇; 您特别指的是什么?(就其价值而言,C ++也不> >
会让

6
在我看来,虽然Haskell的语法不如Scheme干净,但它并没有开始与甚至最好的花括号语言的丑陋语法进行比较,其中C ++是最差的。我想没有考虑味道。我认为不存在一种每个人在语法上都无法取悦的语言。
CA McCann

8
@NeilButterworth:我怀疑您的问题不是语法而是函数名。如果函数喜欢>>=$有更多的地方而不是调用bindapply,则haskell代码看起来不像perl。我的意思是haskell和scheme语法之间的主要区别在于haskell具有中缀运算符和可选的parens。如果人们避免过度使用中缀运算符,那么haskell看起来很像方案,但parens较少。
sepp2k 2010年

5
@camcann:好吧,但是我的意思是:scheme的基本语法是(functionName arg1 arg2)。如果删除括号functionName arg1 arg2,则为haskell语法。如果允许使用任意可怕的名称的infix运算符,您将获得arg1 §$%&/*°^? arg2更像haskell的名称。(顺便说一下,我实际上很喜欢haskell)。
sepp2k 2010年

23

这里有很多好的答案,但是很长。我将尝试给出一个有用的简短答案:

  • 功能语言将状态置于C所处的相同位置:命名变量和堆中分配的对象。区别在于:

    • 在函数式语言中,“变量”在进入范围时(通过函数调用或let绑定)会获得其初始值,并且此后此值不会更改。同样,在堆上分配的对象会立即使用其所有字段的值初始化,此后此值不会更改。

    • “状态更改”不是通过更改现有变量或对象,而是通过绑定新变量或分配新对象来处理的。

  • IO通过一个技巧来工作。产生字符串的副作用计算由将World作为参数并返回包含字符串和新World的对的函数描述。世界包括所有磁盘驱动器的内容,曾经发送或接收的每个网络数据包的历史记录,屏幕上每个像素的颜色以及类似内容。诀窍的关键是要严格限制进入世界,以便

    • 没有程序可以复制世界(您将其放置在哪里?)

    • 没有程序可以扔掉世界

    使用这个技巧可以使世界变得一个独特的世界。语言运行时系统不是用功能语言编写的,它通过更新就位的唯一世界而不是返回新的世界来实现副作用计算。

    西蒙·佩顿·琼斯(Simon Peyton Jones)和菲尔·沃德勒(Phil Wadler)在他们的地标性论文“ Imperative Functional Programming”中很好地解释了这一技巧。


4
据我所知,这个IO故事(World -> (a,World))在应用于Haskell时是一个神话,因为该模型仅解释了纯粹的顺序计算,而Haskell的IO类型包括并发。所谓“纯粹顺序的”,是指除了强制性计算之外,甚至不允许世界(宇宙)在强制性计算的开始和结束之间进行更改。例如,当您的计算机不停运转时,您的大脑等无法。并发可以通过类似之类的东西来处理World -> PowerSet [(a,World)],它允许不确定性和交错。
圆锥

1
@Conal:我认为IO故事可以很好地概括为不确定性和交错性。如果我没记错的话,“尴尬小队”中有一个很好的解释。但是我不知道有一篇很好的论文可以清楚地解释真正的并行性。
诺曼·拉姆西

3
据我了解,“尴尬的小队”放弃了对的简单指称模型(IOWorld -> (a,World)我所指的流行和持久的“神话”)进行概括的尝试,而是给出了操作上的解释。有些人喜欢操作语义,但是他们让我完全不满意。请在另一个答案中查看我的较长答复。
圆锥

+1这帮助我更了解IO Monads并回答了问题。
卡西队长

大多数Haskell编译器实际上确实将定义IORealWorld -> (a,RealWorld),但与其实际表示现实世界,还不如说它只是一个抽象值,必须传递给它,并最终由编译器进行优化。
杰里米清单

19

我准备对新答案发表评论,以留出更多空间:

我写:

据我所知,这个IO故事(World -> (a,World))在应用于Haskell时是一个神话,因为该模型仅解释了纯粹的顺序计算,而Haskell的IO类型包括并发。所谓“纯粹顺序的”,是指除了强制性计算之外,甚至不允许世界(宇宙)在强制性计算的开始和结束之间进行更改。例如,当您的计算机不停运转时,您的大脑等无法。并发可以通过类似之类的东西来处理World -> PowerSet [(a,World)],它允许不确定性和交错。

诺曼写道:

@Conal:我认为IO故事可以很好地概括为不确定性和交错性。如果我没记错的话,“尴尬小队”中有一个很好的解释。但是我不知道有一篇很好的论文可以清楚地解释真正的并行性。

@诺曼:概括在什么意义上?我建议通常给出的指称模型/解释World -> (a,World)与Haskell不匹配,IO因为它不考虑不确定性和并发性。可能存在一个更合适的模型,例如World -> PowerSet [(a,World)],但我不知道这样的模型是否已经制定出来并显示出足够的一致性。我个人怀疑会发现这种野兽,因为它IO是由成千上万的FFI导入的命令性API调用组成的。因此,IO正在实现其目标:

IO悬而未决的问题:单子已经成为Haskell的罪孽。(只要我们不了解某些内容,就会将其扔到IO monad中。)

(摘自西蒙·帕杰(Simon PJ)的POPL演讲,戴头戴发夹:戴哈斯克尔回顾展。)

处理尴尬小队的 3.1节中,Simon指出了无效的内容type IO a = World -> (a, World),包括“添加并发时,这种方法无法很好地扩展”。然后他提出了一个可能的替代模型,然后放弃了指称解释的尝试,他说

但是,我们将改用基于过程演算语义的标准方法的操作语义。

找不到精确有用的指称模型的根本原因是为什么我将Haskell IO背离了我们所谓的“函数式编程”或Peter Landin更具体地称为“说明性编程”的精神和深刻利益的原因。 。 在这里查看评论。


感谢您提供更长的答案。我想也许我已经被我们新的业务霸主洗脑了。左动子和右动子等使得证明一些有用的定理成为可能。你有没有看到任何你喜欢的占了不确定性和并发性的指称模型?我还没有。
诺曼·拉姆齐

1
我喜欢如何World -> PowerSet [World]清晰地捕获不确定性和交错式并发。这个领域的定义告诉我,主流的并发命令式编程(包括Haskell的)是棘手的-从字面上看,比顺序地复杂得多。我在Haskell IO神话中看到的巨大危害正在掩盖这种固有的复杂性,从而促使其被推翻。
圆锥

虽然我知道为什么World -> (a, World)会损坏,但不清楚为什么替换World -> PowerSet [(a,World)]正确地建模了并发性等。对我而言,这似乎意味着其中的程序IO应以列表monad之类的方式运行,并将其自身应用于返回的集合的每个项目通过IO行动。我想念什么?
Antal Spector-Zabusky 2010年

3
@Absz:首先,我建议的模型World -> PowerSet [(a,World)]不正确。让我们尝试一下World -> PowerSet ([World],a)PowerSet给出一组可能的结果(不确定性)。 [World]是中间状态的序列(不是列表/不确定性monad),允许交织(线程调度)。而且([World],a)也不完全正确,因为它允许a在经历所有中间状态之前进行访问。取而代之的是,使用World -> PowerSet (Computation a)其中data Computation a = Result a | Step World (Computation a)
Conal

我仍然看不到的问题World -> (a, World)。如果World类型确实包含整个世界,那么它还包含有关并发运行的所有进程的信息,以及所有不确定性的“随机种子”。结果World是一个世界随着时间的推移而进行了互动。该模型的唯一实际问题似乎是它过于笼统,并且World无法构造和操纵其值。
Rotsor

17

函数式编程源自lambda微积分。如果您真的想了解函数式编程,请访问http://worrydream.com/AlligatorEggs/

这是学习lambda微积分并将您带入令人兴奋的函数式编程世界的一种“有趣”方式!

了解Lambda微积分对函数式编程有何帮助。

因此,Lambda微积分是许多现实世界编程语言(例如Lisp,Scheme,ML,Haskell等)的基础。

假设我们要描述一个函数,该函数在任何输入中添加三个,因此我们将编写:

plus3 x = succ(succ(succ x)) 

阅读“ plus3是一个函数,当将其应用于任何数字x时,会产生x的后继者的后继者”

请注意,将3加到任意数字的函数不必命名为plus3;名称“ plus3”只是命名此功能的方便快捷方式

plus3 x) (succ 0) ≡ ((λ x. (succ (succ (succ x)))) (succ 0))

请注意,我们将lambda符号用于函数(我认为它看起来有点像短吻鳄,我猜这就是短吻鳄卵的思想来源)

lambda符号是短吻鳄(一个函数),x是其颜色。您还可以将x视为一个参数(Lambda Calculus函数实际上仅假定有一个参数),其余的则可以将其视为函数的主体。

现在考虑抽象:

g  λ f. (f (f (succ 0)))

参数f用于函数位置(在调用中)。我们称其为ga高阶函数,因为它需要另一个函数作为输入。您可以将f称为“ 鸡蛋 ”。现在,我们创建了两个函数或“ 鳄鱼 ”,我们可以执行以下操作:

(g plus3) =  f. (f (f (succ 0)))(λ x . (succ (succ (succ x)))) 
= ((λ x. (succ (succ (succ x)))((λ x. (succ (succ (succ x)))) (succ 0)))
 = ((λ x. (succ (succ (succ x)))) (succ (succ (succ (succ 0)))))
 = (succ (succ (succ (succ (succ (succ (succ 0)))))))

如果您注意到了,您会发现我们的λf鳄鱼吃掉了λx鳄鱼,然后吃了λx鳄鱼并死亡。然后,我们的λx短吻鳄在λf的短吻鳄卵中重生。然后重复该过程,左侧的λx鳄鱼现在吞噬右侧的另一个λx鳄鱼。

然后,您可以使用这组简单的“ 短吻鳄 ” 规则吃掉“ 短吻鳄 ”来设计语法,从而诞生了函数式编程语言!

因此,您可以查看是否知道Lambda演算,您将了解函数式语言的工作原理。


@tuckster:我之前研究过lambda演算的次数很多……是的,AlligatorEggs的文章对我来说很有意义。但是我无法将其与编程联系起来。对我来说,现在,拉达演算就像一个单独的理论,就在那里。λ语言演算的概念在编程语言中如何使用?
拉泽尔

3
@eSKay:Haskell lambda演算,带有一层薄薄的语法糖,使其看起来更像是普通的编程语言。Lisp族的语言也与未分类的lambda演算非常相似,这就是扬子鳄的代表。Lambda演算本身本质上是一种简约的编程语言,有点像“功能编程汇编语言”。
CA McCann

@eSKay:我添加了一些关于它与示例的关系的信息。我希望这有帮助!
PJT 2010年

如果您要从我的答案中减去,请留下原因的评论,以便我尝试改善我的答案。谢谢。
PJT

14

Haskell中处理状态的技术非常简单。而且您无需了解monad即可掌握它。

在带有状态的编程语言中,通常将某些值存储在某个位置,执行一些代码,然后再存储一个新值。在命令式语言中,此状态仅位于“背景”中。用(纯)功能语言可以使此内容明确,因此您可以明确地编写用于转换状态的函数。

因此,您不必编写某种类型为X的状态,而是编写将X映射到X的函数。就是这样!您从考虑状态切换到考虑要对状态执行什么操作。然后,您可以将这些功能链接在一起,并以各种方式将它们组合在一起以制作整个程序。当然,您不仅限于将X映射到X。您可以编写函数以将各种数据组合作为输入,并在最后返回各种组合。

Monad是众多工具中的一种,可以帮助组织这一工作。但是monad实际上并不是解决问题的方法。解决方案是考虑状态转换而不是状态。

这也适用于I / O。实际上,这是发生了什么:不是从用户那里获得直接与等效的输入scanf并将其存储在某个地方,而是编写了一个函数来说明要对输入的结果进行处理scanf,然后传递该函数I / O API的功能。这就是在Haskell中>>=使用IOmonad时的功能。因此,您无需在任何地方存储任何I / O的结果-您只需要编写说明如何转换它的代码即可。


8

(某些功能语言允许不纯功能。)

对于纯函数式语言,通常将真实世界的交互作为函数参数之一包括在内,如下所示:

RealWorld pureScanf(RealWorld world, const char* format, ...);

不同的语言有不同的策略来使程序员远离世界。例如,Haskell使用monad来隐藏world参数。


但是函数式语言本身的纯净部分已经实现了Turing完善,这意味着在C语言中可以执行的所有操作在Haskell中也可以进行。命令式语言的主要区别在于,它无需修改现有的状态:

int compute_sum_of_squares (int min, int max) {
  int result = 0;
  for (int i = min; i < max; ++ i)
     result += i * i;  // modify "result" in place
  return result;
}

您将修改部分合并到函数调用中,通常将循环转换为递归:

int compute_sum_of_squares (int min, int max) {
  if (min >= max)
    return 0;
  else
    return min * min + compute_sum_of_squares(min + 1, max);
}

或者只是computeSumOfSquares min max = sum [x*x | x <- [min..max]];-)
fredoverflow

@Fred:列表理解只是一种语法糖(然后您需要详细解释List单子)。以及如何实施sum?仍然需要递归。
kennytm 2010年

3

功能语言可以保存状态!他们通常只是鼓励或强迫您明确地这样做。

例如,查看Haskell的State Monad


9
而且要记住,没有什么对StateMonad使国家,因为它们都是在简单的,一般情况下,功能性工具来定义。它们只是捕获了相关的模式,因此您不必如此繁琐。
圆锥


1

haskell:

main = do no <- readLn
          print (no + 1)

您当然可以使用功能语言将其分配给变量。您只是不能更改它们(因此,基本上所有变量在函数式语言中都是常量)。


@ sepp2k:为什么更改它们有什么害处?
拉泽尔2010年

@eSKay,如果您不能更改变量,那么您知道它们始终相同。这使调试变得更容易,迫使您创建更简单的功能,这些功能只能很好地完成一件事。使用并发时,它也有很大帮助。
亨里克·汉森

9
@eSKay:函数式程序员认为可变状态会导致许多错误,并使得推理程序的行为变得更加困难。例如,如果您有一个函数调用,f(x)并且想要查看x的值,则只需转到定义x的位置。如果x是可变的,则还必须考虑在x的定义和使用之间是否有任何可以改变的地方(如果x不是局部变量,这是不平凡的)。
sepp2k 2010年

6
不只是功能程序员不相信可变的状态和副作用。不可变的对象和命令/查询的分离已被很多面向对象的程序员所熟知,几乎每个人都认为可变全局变量是一个坏主意。诸如Haskell之类的语言将这一想法
CA McCann,2010年

5
@eSKay:突变对人体的危害并不大,事实是,如果您同意避免突变,那么编写模块化和可重用的代码就变得容易得多。如果没有共享的可变状态,则代码不同部分之间的耦合将变得很明显,并且易于理解和维护您的设计。约翰·休斯(John Hughes)尽我所能解释这一点。抓住他的论文为什么函数编程事项
诺曼·拉姆齐
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.