纯函数式编程语言如何在没有赋值语句的情况下进行管理?


26

在阅读著名的SICP时,我发现作者似乎不太愿意在第3章中向Scheme介绍转让声明。

由于Scheme是我所知的第一门函数式编程语言,令我有些惊讶的是,有些函数式编程语言(当然不是Scheme)可以在没有赋值的情况下完成。

让我们使用本书提供的bank account示例。如果没有赋值语句,该怎么办?如何更改balance变量?我之所以这么问,是因为我知道那里有一些所谓的纯函数式语言,根据图灵完整理论,这也必须做到。

我学习了C,Java,Python,并在我编写的每个程序中大量使用赋值。因此,这确实是一次令人大开眼界的体验。我真的希望有人能简要解释一下这些函数式编程语言中如何避免赋值以及对这些语言有何深远影响(如果有的话)。

上面提到的示例在这里:

(define (make-withdraw balance)
    (lambda (amount)
        (if (>= balance amount)
            (begin (set! balance (- balance amount))
                balance)
            "Insufficient funds")))

这改变了balance通过set!。在我看来,更改类成员非常像类方法balance

如我所说,我对函数式编程语言并不熟悉,因此,如果我对它们说错了,请随时指出。


1
关于学习纯函数式语言:我不一定建议立即这样做。如果您学习Haskell,那么除了学习如何编写没有可变变量的程序外,您还必须了解惰性和Haskell执行IO的方式。可能一下子就差不多了。如果您想学习编写没有可变状态的程序,最简单的方法可能就是只编写一堆方案程序而不使用set!或其他以!。结尾的函数。对此感到满意之后,向纯FP的过渡应该会更容易。
sepp2k 2012年

Answers:


21

如果没有分配说明,该怎么办?如何更改余额变量?

没有某种赋值运算符就无法更改变量。

我之所以这样问,是因为我知道那里有一些所谓的纯函数语言,根据图灵完整理论,这也必须做到。

不完全的。如果一种语言是图灵完备语言,则意味着它可以计算任何其他图灵完备语言可以计算的任何内容。这并不意味着它必须具有其他语言具有的所有功能。

图灵完整的编程语言无法更改变量的值并不矛盾,只要对于每个具有可变变量的程序,您都可以编写不具有可变变量的等效程序(其中“等效”表示它计算相同的东西)。实际上,每个程序都可以用这种方式编写。

关于您的示例:在纯功能语言中,您根本无法编写每次调用都会返回不同帐户余额的函数。但是您仍然能够以不同的方式重写使用该功能的每个程序。


既然您要求一个示例,让我们考虑一个使用make-withdraw函数(伪代码)的命令式程序。该程序允许用户从帐户中提取,存入帐户或查询帐户中的金额:

account = make-withdraw(0)
ask for input until the user enters "quit"
    if the user entered "withdraw $x"
        account(x)
    if the user entered "deposit $x"
        account(-x)
    if the user entered "query"
        print("The balance of the account is " + account(0))

这是一种不使用可变变量就可以编写相同程序的方法(我不会因为透明问题而烦恼参考透明IO):

function IO_loop(balance):
    ask for input
    if the user entered "withdraw $x"
        IO_loop(balance - x)
    if the user entered "deposit $x"
        IO_loop(balance + x)
    if the user entered "query"
        print("The balance of the account is " + balance)
        IO_loop(balance)
    if the user entered "quit"
        do nothing

 IO_loop(0)

也可以通过在用户输入上使用fold来编写不使用递归的相同函数(这比显式递归更惯用),但是我不知道您是否熟悉折叠,因此我将其写在不使用您不知道的任何东西的方式。


我可以理解您的观点,但是让我看到我想要一个程序,该程序还可以模拟银行帐户,并且还可以执行这些操作(提款和存款),那么有什么简单的方法可以做到这一点?
Gnijuohz 2012年

@Gnijuohz它始终取决于您要解决的问题。例如,如果您有一个初始余额以及提款和存款清单,并且想知道这些提款和存款之后的余额,则只需计算存款总额减去提款总额,然后将其加到初始余额中。所以在代码中应该是newBalance = startingBalance + sum(deposits) - sum(withdrawals)
sepp2k 2012年

1
@Gnijuohz我在答案中添加了一个示例程序。
sepp2k 2012年

感谢您花费时间和精力编写和重写答案!:)
Gnijuohz 2012年

我要补充一点,使用延续也可能是在scheme中实现这一目标的方式(只要您可以将参数传递给延续?)
dader51

11

没错,它看起来很像对象上的方法。那是因为本质上就是它。该lambda函数是一个将外部变量拉balance入其范围的闭包。具有多个关闭同一外部变量的闭包,以及在同一对象上具有多个方法,是做完全相同的事情的两种不同抽象,如果您了解这两种范例,则可以用另一个实现。

纯功能语言处理状态的方式是通过作弊。例如,在Haskell中,如果您想从外部来源读取输入(当然,它是不确定的,并且重复一次不一定会得到相同的结果),它使用monad技巧来表示“我们已经得到了另一个假装变量,它代表整个世界的状态,我们无法直接对其进行检查,但是读取输入是一个纯函数,它接受外部世界的状态并返回确定性输入,该确定性输入表示该确切状态将始终渲染,再加上外界的新状态。” (当然,这是一个简化的解释。仔细阅读它的实际工作方式会严重破坏您的大脑。)

或者在您的银行帐户出现问题的情况下,与其为变量分配新值,不如将新值作为函数结果返回,则调用者必须以函数样式处理它,通常是通过重新创建任何数据来进行处理使用包含更新值的新版本引用该值。(如果您的数据使用正确的树形结构设置,那么此操作并不像听起来那样庞大。)


我对我们的答案和Haskell的示例非常感兴趣,但是由于缺乏相关知识,我无法完全理解您答案的最后一部分(以及第二部分:()
Gnijuohz 2012年

3
@Gnijuohz最后一段说的,而不是b = makeWithdraw(42); b(1); b(2); b(3); print(b(4))你可以做的b = 42; b1 = withdraw(b1, 1); b2 = withdraw(b1, 2); b3 = withdraw(b2, 3); print(withdraw(b3, 4));地方withdraw被简单地定义为withdraw(balance, amount) = balance - amount
sepp2k 2012年

3

“多重赋值运算符”是语言功能的一个示例,该语言功能通常具有副作用,并且与功能语言的某些有用属性(例如惰性评估)不兼容。

但是,这并不意味着总体上的分配与纯函数式编程风格不兼容(例如,请参见本讨论),也不意味着您不能构造允许总体上看起来像分配的动作的语法,而是实现无副作用。但是,创建这种语法并编写高效的程序既费时又费力。

在您的特定示例中,您是对的-布景!运算符一个赋值。它不是一个无副作用的运算符,它是Scheme用纯函数式编程方法破坏的地方。

最终,任何纯函数式语言都将不得不在某个时候与纯函数式方法相违背-绝大多数有用的程序的确有副作用。决定在哪里进行通常是出于方便的考虑,语言设计人员将尝试给予程序员最大的灵活性,以决定在何处使用纯功能方法(适合其程序和问题领域)来决定在哪里中断。


“最终,任何纯功能语言最终都将不得不与纯功能方法决裂-绝大多数有用的程序确实会产生副作用”。无需可变变量即可编写大量有用的程序。
sepp2k 2012年

1
...而“有用”程序的“绝大多数”是指“全部”,对吧?我什至很难想象存在不执行I / O的任何程序的可能性,这些程序可以合理地称为“有用的”,这种行为需要双向的副作用。
梅森惠勒2012年

@MasonWheeler SQL程序不做IO。用具有REPL的语言编写一堆不做IO的函数,然后简单地从REPL调用这些函数也很常见。如果您的目标受众能够使用REPL(特别是如果您的目标受众是您),那么这将非常有用。
sepp2k 2012年

1
@MasonWheeler:只是一个简单的反例:从概念上计算pi的n位数字不需要任何I / O。它只是“数学”和变量。唯一需要的输入是n,返回值是Pi(至n位)。
Joachim Sauer

1
@Joachim Sauer最终,您需要将结果打印到屏幕上,或者以其他方式向用户报告。首先,您需要从某些地方将一些常量加载到程序中。因此,如果您想成为书呆子,那么所有有用的程序都必须在某个时候做IO,即使是琐碎的情况在环境中是隐式的并且对程序员始终是隐藏的
blueberryfields 2012年

3

用一种纯粹的功能语言,可以将一个银行帐户对象编程为流转换器功能。该对象被视为从帐户所有者(或任何人)的无限请求流到潜在的无限响应流的函数。该函数从初始余额开始,然后处理输入流中的每个请求以计算新的余额,然后将其反馈给递归调用以处理流的其余部分。(我记得SICP在本书的另一部分中讨论了流变压器范例。)

此范例的更详细版本称为“功能反应式编程” ,在StackOverflow上进行了讨论。

做流式变压器的幼稚方式存在一些问题。有可能(实际上很容易)编写越野车程序,以保留所有旧请求,从而浪费空间。更严重的是,可以使对当前请求的响应取决于将来的请求。这些问题的解决方案目前正在研究中。 Neel Krishnaswami是他们背后的力量。

免责声明:我不属于纯函数式编程教会。实际上,我不属于任何教会:-)


我猜你属于某个寺庙吗?:-P
Gnijuohz 2012年

1
自由思考的殿堂。那里没有传教士。
Uday Reddy 2012年

2

如果程序应该做任何有用的事情,就不可能使其100%正常运行。(如果不需要副作用,那么整个想法可能会减少到恒定的编译时间。)像撤消示例一样,您可以使大多数过程起作用,但最终您将需要具有副作用的过程(来自用户的输入,输出到控制台)。就是说,您可以使大部分代码起作用,并且该部分将易于测试,甚至可以自动进行测试。然后,您需要编写一些命令性代码来执行输入/输出/数据库/ ...,这将需要调试,但是保持大多数代码干净将不会做太多工作。我将使用您的提款示例:

(define +no-founds+ "Insufficient funds")

;; functional withdraw
(define (make-withdraw balance amount)
    (if (>= balance amount)
        (- balance amount)
        +no-founds+))

;; functional atm loop
(define (atm balance thunk)
  (let* ((amount (thunk balance)) 
         (new-balance (make-withdraw balance amount)))
    (if (eqv? new-balance +no-founds+)
        (cons +no-founds+ '())
        (cons (list 'withdraw amount 'balance new-balance) (atm new-balance thunk)))))

;; functional balance-line -> string 
(define (balance->string x)
  (if (eqv? x +no-founds+)
      (string-append +no-founds+ "\n")
      (if (null? x)
          "\n"
          (let ((first-token (car x)))
            (string-append
             (cond ((symbol? first-token) (symbol->string first-token))
                   (else (number->string first-token)))
             " "
             (balance->string (cdr x)))))))

;; functional thunk to test  
(define (input-10 x) 10) ;; define a purly functional input-method

;; since all procedures involved are functional 
;; we expect the same result every time.
;; we use this to test atm and make-withdraw
(apply string-append (map balance->string (atm 100 input-10)))

;; no program can be purly functional in any language.
;; From here on there are imperative dirty procedures!

;; A procedure to get input from user is needed. 
;; Side effects makes it imperative
(define (user-input balance)
  (display "You have $")
  (display balance)
  (display " founds. How much to withdraw? ")
  (read))

;; We need a procedure to print stuff to the console 
;; as well. Side effects makes it imperative
(define (pretty-print-result x)
  (for-each (lambda (x) (display (balance->string x))) x))

;; use imperative procedure with atm.
(pretty-print-result (atm 100 user-input))

尽管您可能必须在过程中设置临时变量甚至更改内容,但几乎可以用任何一种语言进行相同的操作并产生相同的结果(减少错误),但这与过程无关紧要实际发挥作用(仅由参数决定结果)。我相信,在您编写了一些LISP之后,无论使用哪种语言,您都可以成为一名更好的程序员:)


+1是有关程序功能部分和非纯功能部分的广泛示例和现实说明,并提及了FP为什么如此重要。
Zelphir Kaltstahl '16

1

分配是不好的操作,因为分配将状态空间分为两部分,分配前和分配后。这导致跟踪程序执行期间如何更改变量的困难。功能语言中的以下内容正在替换分配:

  1. 函数参数直接链接到返回值
  2. 选择要返回的其他对象,而不是修改现有对象。
  3. 创建新的惰性评估值
  4. 列出所有可能的对象,而不仅仅是需要在内存中的对象
  5. 没有副作用

这似乎没有解决提出的问题。您如何用纯功能语言编写银行帐户对象?
Uday Reddy 2012年

它只是从一个银行帐户记录转换到另一个银行记录的功能。关键是,当发生此类转换时,将选择新对象而不是修改现有对象。
tp1 2012年

当您将一个银行帐户记录转换为另一个时,您希望客户在新记录而不是旧记录上进行下一笔交易。客户的“联系点”必须不断更新以指向当前记录。那是“修改”的基本思想。银行帐户“对象”不是银行帐户记录。
Uday Reddy 2012年
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.