学习哈斯克尔--其他启示

2020-10-13 19:50:18

以下是我在学习哈斯克尔时所经历的一些所谓的时刻。我在这里分享它们,是为了帮助一些人减轻摆在他们面前的绝望的挫折感。

在很长一段时间里,我不明白为什么函数式编程被认为比常规命令式编程更好。因此,我继续按照常规的、刻不容缓的方式制作节目。有一天我突然想到。

我看到了我所做事情的真面目。我看到了命令性程序实际上是如何安排一系列副作用的,大多数时候副作用是变量的突变。我看到了语句之间不可见的依赖网络,它不仅可以跨越空间维度,也可以跨越时间维度,随着我添加到其中的每个语句,它如何在我的命令性函数中增长。我看到了如何打破这个看不见的网络的一条线,可以默默地破坏功能行为,从而破坏整个程序。我顿时开悟了..。

禅宗谈话说得够多了。重点是命令式编程语言,如Python或C,允许程序员创建变量。它还允许程序员在将来引用这些变量,还允许他们在运行时更改它们的值。

这是非常强大的,但伴随着这种力量(是的,你猜对了),随之而来的是巨大的责任。在编写和读取程序时跟踪变量状态的责任。因为您添加到程序中的每个语句都依赖于它周围的语句创建的状态(作用域中所有变量的状态)。

纯粹的函数式编程从程序员手中夺走了这一艰巨的责任,而没有带走相关的功能。它通过提供一组不同的工具来实现这一点。有了这套新工具,程序员可以变得和以前一样强大,甚至更强大。它去掉了可变的状态变化和循环,并给我们提供了连续传递、折叠、拉链、过滤器和地图。这里的启示很简单。那就是您可以用命令式语言中的状态变化和循环来表达任何东西,都可以用这个新词汇以函数式的风格来表达。

人们说哈斯克尔并不复杂,只是不同而已。但我认为那是无用的声明。当你正在处理的事情与你所习惯的大不相同时,不管它实际上有多简单,它都会显得很复杂。

我要说的是,Haskell有一些部分是不同的,但很简单,而有些部分是不同的,并不是那么直接,当你刚接触它时,它会显得非常复杂。但是一点一滴地,你曾经认为超出你理解范围的话题会变得容易接近。当这一切发生时,就像解锁了电子游戏的一个新层次;新的奇迹在等待着你。这就是为什么学习哈斯克尔是非常值得付出努力的原因。它有足够的深度和广度来让您长久地保持兴趣,同时还是一门非常好的通用语言,背后有一个出色的社区。现在它甚至越来越受欢迎了!

霸王龙爸爸:我需要你继续躲避,悄悄地把棒棒糖滑过他们的角头,只是呼喊和呼喊着把他们赶走。ARLO:什么?儿子霸王龙:他只是想让你站到那块石头上尖叫。

重点是,不要被不熟悉的术语所困扰。大多数情况下,整件事意味着比表面看起来简单得多的事情。

Haskell函数没有向callingcode返回值的语句。事后看来,这是相当明显的,Haskell程序根本没有任何元素。相反,Haskell函数是计算结果为a值的表达式,而此值隐式为该函数的";return&34;值。尽管如此,您仍会看到人们说";此函数返回x";这样的话。因为它们只是意味着函数的计算结果是x。

作为从事函数式编程的动画程序员,如果说有什么事情可以让我一下子放松下来的话,那就是let表达式。因为当我发现Haskell函数仅限于单个表达式时,我就想,一个表达式只能做这么多事情,怎么能用它做任何有用的事情呢?我的问题是我在想命令式语言中的表达式。这里的启示是,Haskell中的表达式可以非常精细,并且Haskell的let表达式允许您定义最终表达式所需的任意数量的中间表达式或函数。这使我们非常接近命令式编程风格,尽管执行是完全不同的,正如我们将在下面看到的。

SumOfDoubleAndTriple::int->;Int sumOfDoubleAndTriple x=let Double=2*x Triple=3*x in Double+Triple。

在上面的函数中,我们使用let表达式定义了两个中间结果';Double&39;和';Triple&39;,然后将它们相加并将其作为函数的值返回。

请注意,这些不是变量定义。这些绑定不能更改。不允许您在同一LET表达式中重新定义符号。此外,绑定的范围仅限于';中的';之后的表达式以及嵌套在同一let块中的任何其他定义。即使绑定不能更改,语法更深的级别中的绑定也可以隐藏来自更高级别的绑定。

这里重要的一点是,let表达式中的绑定不像命令式语言中的赋值语句。他们不是自上而下执行的。相反,您可以认为执行是从';子句中';之后的表达式开始,并在绑定中查找所需的值并根据需要进行计算。

关于Haskell类型类有一些非常简单的东西,我花了一段时间才完全掌握。只是Haskell必须能够从调用类型类函数的位置找出匹配的实例。如果它不能,那么它将是一个错误。

如果不理解并牢记这一简单的事情,您将无法理解许多高级类型系统特性。例如,FunctionalDependency扩展。它还有助于理解打字检查器最终向您抛出的许多错误。

如果你问,这对我来说是最大的启迪,也是让很多事情变得井然有序的一次。简单的事实是,根据调用点所需的类型,Haskell函数可以返回不同类型的值。换句话说,Haskell函数的返回类型可以是多态的。我能想到的最简单的例子是String->;a类型的';read';函数。(1::int)+(read";2";)中对此函数的调用将返回Intand in(1::Float)+(read";2";)中的Intand。

当我刚开始使用Haskell时,我记得我纯粹地试图从其中提取一个包含在IO中的值。过了一段时间,我意识到,没有办法纯粹地从IO中获得价值,也就是说,你不能有一个函数IO a>;,这不是因为IO是Monad,Monad是特殊用例的魔术,而仅仅是因为IO的构造函数没有从它的模块中导出。这一点现在看起来很明显,但从来没有一次是这样的。

当我还是Haskell的新手时,我有一种直觉,XYZa形式的类型在它们里面有很小的a的值。有一天,大一遇到了这个函数,它的类型看起来像(b->;a)->;someTypea->;someTypeb->;someTypea->;someTypeb。

我就像是W.T.F!?是否可以对函数进行反向工程并使其反向工作?";当您拥有的只是一个可以从a转换为b的函数时,您还能如何将用f包装的b转换为a呢?

嗯,SomeType被定义为类似于data SomeType a=SomeType(a->;Int)的内容,因此该函数可以很容易地定义为类似这样的内容。

Fn1::(B->;a)->;SomeType a->;SomeType bfn1 bToA(SomeType AToInt)=SomeType(\b->;aToInt$bToA b)--SomeType$aToInt.bToA

重点是,XYZA表格的类型不一定是包装纸或三明治或任何东西。如果没有类型的定义,类型不会告诉您任何关于数据结构的信息。

重点是,如果你在更基本的层面上有缺陷的想法,它会限制你理解高级概念的能力。

尽管我意识到了这一点,但我经常发现自己时不时地抱着前面两个错误的直觉。所以我现在记得它是在块>;>;=块的其余部分中用lambda&39;包装的第一个表达式(desugaringto';first expression in>;>;=rest of block in a lambda&39;

如果您从Monad类中回想起>;>;=的签名,则它是>;>;=::M a->;(a->;mb)->;MB因此>;>;=的参数与去糖化部分匹配,如下所示。

表达式1>;>;=(\a->;表达式2>;>;=(\_->;表达式3)--|-m a--|>;>;=|-(a->;mb)。

另一个我很难摆脱的顽固而错误的直觉是,在RHS of>;>;=get中的Lambdas作为他们的论据,是Monad';的上下文(Monad';of Monad';;Context;of>;>;=Get)。

但事实并非如此。相反,它是在Monadsimplementation中的代码提取Monad之后,在>;>;=的LHS上从Monad中得到的值。可以通过这样的方式设置Monad的值,以使>;>;=Monad的实例中的实现可以做一些具体的事情。在这种情况下,可以在Monad的实例中设置Monad的值,以使>;>;=在Monad的实例中实现某些特定的功能。

例如,Ask函数(它实际上不是一个函数,因为它没有任何参数)只是一个Reader值,其设置方式是Reader monad的>;>;=实现最终将返回readersEnvironment,从而使其对链的其余部分可用。

在很长一段时间里,我都不能理解懒惰、笨拙和他们的评价在哈斯克尔到底有多有效。因此,这里是基本的事情,没有进一步的仪式。如果参数是严格的,则在将其传递到可能最终使用它的函数或表达式之前,会对其进行计算。当它懒惰时,它会作为未经评估的thunk传入。这就是它的全部意思!

为了说明这一点,让我们考虑一个小Haskell程序的两个版本。一种是严格的,另一种是不严格的。

模块Main,其中--sumOfNumbers::int->;Int->;IntsumOfNumbers a 0=asumOfNumbers a x=sumOfNumbers(a+x)(x-1)--Main::IO()Main=do let r=sumOfNumbers 0 10000000 putStrLn$show r。

#STACK GHC app/Main.hs&;&;./app/main+rts-s50000005000000 1,212,745,200字节堆中分配的2,092,393,120字节GC期间复制的最大驻留字节495,266,056字节最大驻留(10个样本)6,964,984字节最大斜率960 MB总内存(0 MB因碎片丢失)。

您可以看到这使用了大量内存。让我们看看sumOfNumbers 0 5是如何扩展的。

SumOfNumbers 0 5=sumOfNumbers(0+5)4sumOfNumbers(0+5)4=sumOfNumbers((0+5)+4)3sumOfNumbers((0+5)+4)3=sumOfNumbers(0+5)+4)+3)+3)2sumOfNumbers(+5)+4)+3)2=sumOfNumbers(0+5)+4)+3)+2)1sumOfNumbers(0+5)+4)+3)+2=sumOfNumbers(0+5)+4)+3)+2)0sumOfNumbers(+5)+4)+3)+2)+1=((0+5)+4)+3)+2=sumOfNumbers(0+5)+4)+3)+2=SumOfNumbers(0+5)+4)+3)+2)1=sumOfNumbers(0+5)+4)+3)+2=sumOfNumbers(0+5)+4)+3)+2)1=sumOfNumbers(0+5)+4)+3)+2=sumOfNumbers(0+5)+4)+3)+2)。

我们看到,随着我们的深入,作为第一个论点的表达式变得越来越大。它保留为表达式本身(称为thunk),不会缩减为单个值。每次递归调用时,这个Tunk都会在内存中增长。

哈斯克尔没有评估这一问题,因为正如哈斯克尔所认为的那样,现在评估它不是一件明智的事情。如果函数/表达式从未真正使用过该值,该怎么办?

还要注意,发生这种情况是因为该thunk的增长发生在sumOfNNumbers函数的阴影后面。每次Haskell尝试计算sumOfNNumbers时,它都会返回另一个sumOfNNumbers,其中包含一个更大的thunk。只有在最后一个递归调用中,Haskell才会得到一个没有sumOfNumbers包装器的表达式。

为了防止每次递归调用都变得越来越大,我们可以将参数设置为严格的。正如我在前面提到的,当参数被标记为严格时,在将其传递到可能最终使用它的函数或表达式之前,会对其求值。

SumOfNNumbers::int->;Int->;Int sumOfNumbers!a 0=a sumOfNumbers!ax=sumOfNumbers(a+x)(x-1)。

SumOfNNumbers::int->;Int->;Int sumOfNumbers a 0=a sumOfNumbers a x=let!b=a in sumOfNumbers(b+x)(x-1)。

模块Main,其中--sumOfNumbers::int->;Int->;IntsumOfNumbers!a 0=asumOfNumbers!ax=sumOfNumbers(a+x)(x-1)--Main::IO()Main=do let r=sumOfNumbers 0 10000000 putStrtLn$show r。

堆栈GHC app/Main.hs&;./app/main+rts-s[1 of 1]编译Main(APP/Main.hs,APP/Main.o)链接APP/Main.o...50000005000000 880,051,696字节分配在堆中54,424字节在GC期间复制44,504字节最大驻留(2个示例)29,224字节最大斜率2 MB总内存正在使用(0 MB因碎片而丢失)。

我们还可以在下面的程序中看到严格性注释工作的证据。

#:set-XBangPatterns#let myFunc a b=a+1--非严格参数#myFunc 2未定义--我们在此传入,但没有错误3#let myFunc a!b=a+1--严格的第二个参数#myFunc 2未定义--在错误中传递未定义的结果异常:Prelude.unfinedCallStack(来自HasCallStack):错误,在库/base/ghc/Err.hs:79:14 in base:gc中调用。Err未定义,在Interactive:Ghci11中调用:71:7中的ErrException:Prelude.unfinedCallStack(来自HasCallStack):error,在library/base/ghc/Err.hs:79:14 in base:ghc.Err未定义,在交互:Ghci11中调用:71:7

函数myFunc有两个参数,但我们只在函数中使用第一个参数。因为参数不是严格的,所以我们能够使用未定义的';为第二个参数调用函数,并且没有错误,因为第二个参数(未定义)从未在函数中求值。

在第二个函数中,我们将参数标记为严格。因此,当我们试图使用第二个参数的未定义来调用它时,会出现错误。因为未定义在传递到函数之前进行了计算。所以我们是否在函数中使用它并不重要。

请注意,即使使用严格注释,也只有在为依赖表达式触发求值时才会对表达式求值。因此,如果依赖表达式仍然是一个thunk,那么在该thunk中,您的严格参数将保持未求值状态。

哈斯克尔懒惰的故事要深入一些。比如,即使当它评估某些东西时,它也只能评估足够的东西,而不会进一步评估。它从头到尾都很懒!

关于Haskell中的异常有很多需要学习的地方,抛出和捕获异常的方式多种多样,但是关于它们有一件基本的事情。它是您可以从纯代码抛出异常。但是要抓住它,你必须在IO里。

我们已经看到了懒惰是如何使Haskell将表达式的求值推迟到绝对需要的时候。这意味着,如果您从未计算的thunk抛出异常,该thunk可以传递您包装它的所有catch块,并在最终将在更高级别进行计算时在您的脸上爆炸。

为了防止出现这种情况,如果您希望捕获进程中抛出的任何异常,则应该使用';EVALUATE';函数强制计算纯值。认真地说,您应该阅读评估函数的文档。

Haskell独一无二的一件事可能是各种语言扩展的可用性,尽管这个名字可能意味着什么,但类型系统的大部分功能都隐藏在这些扩展的背后。但实际上,在现实世界中学习使用这些东西有点像电影“功夫熊猫”中的师父角色所说的“完全劈腿”。

Haskell扩展并不是那么糟糕。它们中的一些,比如OverLoadedStrings或LambdaCase,实际上是直截了当的。但另一方面,我很难理解GADT、TypeFamilies、DataKinds等扩展,但是YMMV。我注意到的一件事是,对这些扩展的解释通常以复杂的设置和不必要的高级示例开始。嘿,如果你想了解XYZ扩展,让我用一个简单的例子告诉你,我们将为FORTRAN";创建一个小型编译器!当然,这是夸张的,但你明白这一点。这通常是因为很难找出涉及容易关联的情况的例子。

因此,在接下来的几节中,我试着对其中一些没有任何实际使用案例的内容进行非常简明的介绍。我唯一能给他们的承诺就是他们会,嗯.。简明;-)。

它允许我们具有数据定义,其中可以显式地将构造函数与具体类型相关联。看看可能类型的定义。

这里只有a中的a的类型和可能a中的a类型之间存在隐式关联,但是您无法显式地将构造函数与(比方说String)关联起来。比方说,您想要添加第三个构造函数NothingString,该构造函数将显式返回一个可能字符串。

将不起作用,因为NothingString仍将返回多态类型,也许.gadts扩展名使其成为可能。但是它的语法略有不同。

{-#language GADTS#-}数据可能是Where Just::A->;可能是Nothing::可能是NothingString::可能是字符串。

在这里,通过能够为构造函数提供显式类型签名,我们能够使NothingString构造函数显式返回可能的String。在下面的内容中,您可以看到另外两个构造函数,它们可能清楚地说明使用此扩展的可能性。

{-#language GADTS#-}数据可能是WHERE Just::A->;可能是NothingString::可能是字符串JustString::String->;可能是字符串JustNonSense::int->;可能是字符串

#:t只';c';只';c';:可能字符#:t NothingNothingNothing::可能a#:t NothingStringNothingString::可能字符串#:t JustString";Things";JustString";东西";:可能字符串#:t JustNonSense 45JustNonSense 45::可能String。

如果要使用接受多态函数作为参数的函数,则需要RankNTypes。

Rank2多态是指将多态函数(Rank1多态)作为参数的函数。

Rank3多态是指将Rank2多态函数作为参数的函数。

RankN多态是指将Rank(N-1)多态函数作为参数的函数。

关于这一点的一个微妙之处在于,如果您有一个签名为Int->;(a->;a)->;Int的函数,那么第二个参数不需要多态函数。这里唯一的多态函数是整个函数本身,即Int->;(a->;a)->;Int,因为第二个参数是多态的(但本身不是多态函数),因为它可以接受(String->;String)、(Int->;Int)、(Float->;Float)等函数,但是这些函数本身都不是多态函数。

下面是一个函数,它有一个用于第二个参数的多态函数。Int->;(对于所有A.a->;a)->;int。要启用这些类型的功能,您需要RankNTypes扩展。

类转换a b WHERE CONVERT::a->;b实例转换CHAR字符串WHERE CONVERT=showInstance CONVERT Int字符串WHERE CONVERT=show

这个就行了。因为如果有一个预期返回字符串类型值的调用Convert';c';c';,编译器将能够解析该实例以转换Char字符串,从而使用该实例中的Convert函数来替换原来的调用。

现在,假设我们想要向该类型类再添加一个函数,如下所示。

类转换a b WHERE CONVERT::a->;b ConvertToString::a->;StringInstance转换CHAR字符串WHERE CONVER。

.