簡體   English   中英

函數式編程語言是如何工作的?

[英]How do functional programming languages work?

如果函數式編程語言無法保存任何狀態,那么它們如何執行諸如讀取用戶輸入之類的簡單操作? 他們如何“存儲”輸入(或為此存儲任何數據?)

例如:這個簡單的 C 語言如何轉換為 Haskell 之類的函數式編程語言?

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

(我的問題受到這篇優秀文章的啟發: “名詞王國中的執行” 。閱讀它讓我更好地理解了面向對象編程到底是什么,Java 如何以一種極端的方式實現它,以及函數式編程語言如何成為一種對比。)

如果函數式編程語言不能保存任何狀態,他們如何做一些簡單的事情,比如從用戶那里讀取輸入(我的意思是他們如何“存儲”它),或者存儲任何數據?

在您收集時,函數式編程沒有狀態 - 但這並不意味着它無法存儲數據。 不同之處在於,如果我寫一個(Haskell)語句的話

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

我保證x的值總是相同的... :沒有什么可以改變它。 類似地,如果我有一個函數f :: String -> Integer (一個函數接受一個字符串並返回一個整數),我可以確定f不會修改它的參數,或者更改任何全局變量,或者將數據寫入文件, 等等。 正如sepp2k在上面的評論中所說的那樣,這種不可變性對於推理程序非常有用:你編寫折疊,主軸和毀壞數據的函數,返回新副本以便將它們鏈接在一起,你可以確定沒有那些函數調用可以做任何“有害”的事情。 你知道x總是x ,你不必擔心有人在聲明x及其使用之間的某處寫了x := foo bar ,因為這是不可能的。

現在,如果我想讀取用戶的輸入怎么辦? 正如KennyTM所說,這個想法是一個不純的函數是一個純粹的函數,它作為一個參數傳遞給整個世界,並返回它的結果和世界。 當然,你不想真正這樣做:首先,它是非常笨重的,而另一方面,如果我重用同一個世界對象會發生什么? 所以這會以某種方式被抽象化。 Haskell使用IO類型處理它:

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

這告訴我們main是一個IO動作,什么都不返回; 執行此操作是運行Haskell程序的意義。 規則是IO類型永遠不能逃脫IO操作; 在這種情況下,我們用do介紹這個動作。 因此, getLine返回一個IO String ,可以通過兩種方式來考慮:首先,作為一個動作,當它運行時,產生一個字符串; 第二,作為一個被IO“污染”的字符串,因為它是不純的。 第一個更正確,但第二個可能更有幫助。 <-StringIO String String取出並將其存儲在str -but中,因為我們處於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的對象和修改后的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'

第一個允許我們討論不執行任何操作的IO操作: return 3是一個IO操作,它不查詢現實世界而只返回3 >>=運算符,發音為“bind”,允許我們運行IO動作。 它從IO操作中提取值,通過函數傳遞它和現實世界,並返回生成的IO操作。 請注意>>=強制執行我們的規則,即IO動作的結果永遠不允許轉義。

然后我們可以將上面的main轉換為以下普通的函數應用程序集:

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

Haskell的運行時跳開始main與初始RealWorld ,我們就大功告成了! 一切都是純粹的,它只是一個花哨的語法。

[ 編輯: 正如@Conal所指出的 ,這實際上並不是Haskell用來做IO的。 如果你添加並發性,或者實際上任何方式讓世界在IO操作的中間發生變化,這個模型就會中斷,所以Haskell不可能使用這個模型。 它僅對順序計算是准確的。 因此,可能是Haskell的IO有點躲閃; 即使不是,它肯定不是那么優雅。 根據Per @ Conal的觀察,看看Simon Peyton-Jones在處理尷尬小隊[pdf]中所說的內容,3.1節; 他提出了可能相當於這些方面的替代模型的東西,但隨后因其復雜性而放棄它並采取不同的方法。

再次,這解釋了(幾乎)IO和一般的可變性如何在Haskell中工作; 如果你想知道一切,你可以在這里停止閱讀。 如果你想要最后一劑理論,請繼續閱讀 - 但請記住,在這一點上,我們離你的問題已經走得很遠了!

所以最后一件事:事實證明這個結構 - 帶有return的參數類型和>>= - 非常通用; 這就是所謂的單子,並do標記, return>>=其中的任何工作。 正如你在這里看到的那樣,單子並不是神奇的; 所有這就是神奇的是, do塊變成函數調用。 RealWorld類型是我們看到任何魔法的唯一地方。 []這樣的類型,列表構造函數,也是monad,它們與不純的代碼無關。

你現在知道(幾乎)關於monad概念的一切(除了一些必須滿足的法則和正式的數學定義),但你缺乏直覺。 在線有一些荒謬的monad教程; 我喜歡這個 ,但你有選擇。 但是, 這可能對你沒有幫助 ; 獲得直覺的唯一真正方法是通過結合使用它們並在合適的時間閱讀幾個教程。

但是, 您不需要那種直覺來理解IO 完全普遍地理解monad是錦上添花,但你現在可以使用IO。 在我向您展示第一個main功能后,您可以使用它。 您甚至可以將IO代碼視為不純凈的語言! 但請記住,有一個潛在的功能表示:沒有人作弊。

(PS:對不起長度。我走得有點遠。)

這里有很多好的答案,但它們很長。 我將嘗試給出一個有用的簡短回答:

  • 函數式語言將狀態放在C所做的相同位置:命名變量和堆上分配的對象。 不同之處在於:

    • 在函數式語言中,“變量”在進入范圍(通過函數調用或let-binding)時獲取其初始值,並且該值在之后不會更改 類似地,在堆上分配的對象立即用其所有字段的值初始化,之后不會改變。

    • “狀態的變化”不是通過改變現有變量或對象而是通過綁定新變量或分配新對象來處理的。

  • IO通過技巧工作。 產生字符串的副作用計算由一個以World作為參數的函數描述,並返回包含字符串和新World的對。 世界包括所有磁盤驅動器的內容,發送或接收的每個網絡數據包的歷史記錄,屏幕上每個像素的顏色以及類似的東西。 訣竅的關鍵在於嚴格限制對世界的訪問

    • 沒有程序可以復制世界(你會把它放在哪里?)

    • 沒有任何計划可以扔掉世界

    使用這個技巧可以創造一個獨特的世界,其狀態隨着時間的推移而演變。 語言運行時系統不是用函數式語言編寫的,它通過更新唯一的World而不是返回一個新的World來實現副作用計算。

    Simon Peyton Jones和Phil Wadler在他們的標志性文章“勢在必行的功能編程”中精彩地解釋了這個技巧。

我打破了對新答案的評論回復,以提供更多空間:

我寫:

據我所知,這個IO故事( World -> (a,World) )在應用於Haskell時是一個神話,因為該模型僅解釋純順序計算,而Haskell的IO類型包括並發。 通過“純粹順序”,我的意思是除了由於計算之外,甚至不允許世界(宇宙)在命令式計算的開始和結束之間改變。 例如,當您的計算機正在消失時,您的大腦等不能。 並發可以通過更像World -> PowerSet [(a,World)]的東西來處理,它允許不確定性和交錯。

諾曼寫道:

@Conal:我認為IO故事很好地概括了非確定性和交錯; 如果我沒記錯的話,在“尷尬的小隊”論文中有一個很好的解釋。 但我不知道一篇好文章清楚地解釋了真正的並行性。

@Norman:概括在什么意義上? 我建議通常給出的指稱模型/解釋, World -> (a,World) ,與Haskell IO不匹配,因為它不考慮非確定性和並發性。 可能有一個更復雜的模型適合,例如World -> PowerSet [(a,World)] ,但我不知道這樣的模型是否已經制定出來並顯示出足夠和一致。 我個人懷疑可以找到這樣的野獸,因為IO由數千個FFI導入的命令式API調用填充。 因此, IO正在實現其目的:

開放問題: IO monad已成為Haskell的罪魁禍首。 (每當我們不理解某些東西時,我們就把它扔進IO monad。)

(來自Simon PJ的POPL演講穿着頭發襯衫戴着頭發襯衫:回顧Haskell 。)

處理Awkward Squad的 3.1節中,Simon指出了關於type IO a = World -> (a, World)不起作用的內容,包括“當我們添加並發時,方法不能很好地擴展”。 然后,他提出了一個可能的替代模型,然后放棄了對指稱性解釋的嘗試,說

然而,我們將采用基於過程計算語義的標准方法的操作語義。

找不到精確和有用的指稱模型的失敗是我為什么看到Haskell IO偏離精神和我們稱之為“函數式編程”的深層利益,或者Peter Landin更具體地命名為“外延編程”的根本原因。 。 在這里看評論。

功能編程源自lambda Calculus。 如果您真的想了解功能編程,請查看http://worrydream.com/AlligatorEggs/

學習lambda微積分是一種“有趣”的方式,讓您進入令人興奮的功能編程世界!

了解Lambda微積分如何有助於函數式編程。

所以Lambda Calculus是許多真實編程語言的基礎,例如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))plus3 x) (succ 0) ≡ ((λ x. (succ (succ (succ x)))) (succ 0))

請注意,我們使用lambda符號作為函數(我認為它看起來有點像鱷魚我猜這是鱷魚蛋的想法來自哪里)

lambda符號是Alligator (函數),x是它的顏色。 您還可以將x視為一個參數(Lambda微積分函數實際上只假設有一個參數)其余的您可以將其視為函數體。

現在考慮抽象:

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

參數f用於函數位置(在調用中)。 我們稱ga為高階函數,因為它需要另一個函數作為輸入。 您可以將其他函數調用f視為“ egg ”。 現在我們已經創建了兩個函數或“ Alligators ”,我們可以這樣做:

(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鱷魚。

然后你就可以使用這個簡單的“ Alligators ”規則來吃Alligators ”來設計一個語法,從而出現了函數式編程語言!

因此,您可以看到,如果您了解Lambda Calculus,您將了解Functional Languages的工作原理。

在Haskell中處理狀態的技術非常簡單。 而且你不需要了解monad來處理它。

在具有狀態的編程語言中,通常在某處存儲某些值,執行某些代碼,然后存儲新值。 在命令式語言中,這種狀態只是“在后台”的某個地方。 在(純)函數語言中,您可以將其顯式化,因此您可以顯式編寫轉換狀態的函數。

因此,不是使用X類型的某種狀態,而是編寫將X映射到X的函數。就是這樣! 您從考慮狀態轉向考慮要對狀態執行哪些操作。 然后,您可以將這些功能鏈接在一起,並以各種方式將它們組合在一起以制作整個程序 當然,您不僅限於將X映射到X.您可以編寫函數以將各種數據組合作為輸入,並在最后返回各種組合。

Monads是其中一個工具,可以幫助組織這個工具。 但monad實際上並不是問題的解決方案。 解決方案是考慮狀態轉換而不是狀態。

這也適用於I / O. 實際上會發生這樣的事情:不是從用戶那里得到一些直接相當於scanf輸入,而是將它存儲在某個地方,而是編寫一個函數來說明你對scanf的結果做了什么,如果你有的話,然后將該函數傳遞給I / O API。 當你在Haskell中使用IO monad時,這正是>>= 因此,您永遠不需要在任何地方存儲任何I / O的結果 - 您只需要編寫說明您希望如何轉換它的代碼。

(某些功能語言允許不純的功能。)

對於純函數式語言,真實世界的交互通常作為函數參數之一包含在內,如下所示:

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

不同的語言有不同的策略來抽象世界,遠離程序員。 例如,Haskell使用monads隱藏world參數。


但是功能語言本身的純粹部分已經是圖靈完整的,這意味着在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);
}

功能語言可以保存狀態! 他們通常只是鼓勵或強迫你明確這樣做。

例如,查看Haskell的State Monad

哈斯克爾:

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

您當然可以在函數式語言中將變量分配給變量。 你無法改變它們(因此基本上所有變量都是函數式語言中的常量)。

如果函數式編程語言不能保存任何狀態,那么它們如何做一些簡單的事情,比如從用戶那里讀取輸入 [供以后使用]?

該語言可能不會,但它的實現肯定會! 想想那里的所有狀態——至少一個堆棧、一個或多個堆、各種文件描述符、當前配置等等。 謝天謝地,處理這一切的是計算機,而不是你。 嗯 - 讓計算機處理無聊的部分:這是什么概念!

按照這個速度,現在任何一天的實現都將承擔所有那些沉悶的 I/O 活動——然后你會聽到關於指稱語言……是的,對新手來說更多的行話! 但是現在,我們將專注於已經存在的東西——函數式語言:它們如何做簡單的 I/O 事情,比如讀取輸入?

非常小心!

大多數函數式語言與命令式語言的不同之處在於,只允許直接操作 I/O 的狀態——你不能在定義中匿名定義一些額外的狀態,例如記錄它被使用的次數。 為了防止這種情況發生,類型通常用於區分基於 I/O 的代碼和無 I/O 的代碼, HaskellClean廣泛使用了該技術。

這可以很好地工作,甚至可以使函數式語言能夠通過所謂的“外部函數接口”調用命令式語言中的子例程過程。 這允許將真正無限的以 I/O 為中心的操作(以及隨后對基於 I/O 的狀態進行操作)引入到函數式語言中 - scanf()只是開始......


...等一下: “名副其實的無限以 I/O 為中心的操作”? 一個有限的實現不可能擁有所有這些,因此一個完全外延的語言在其程序的外部交互方面總是會受到某種限制。 因此,I/O 必須始終是任何通用編程語言的一部分。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM