我是否被命令式编程思维束缚,没有像应该一样拥抱Haskell?
你并没有被束缚,至少我不希望如此。你所经历的是非常正常的。在使用命令式语言时,您学会了(也许是不知不觉地)从一个非常特定的角度看待编程问题 - 即用冯诺伊曼机器的术语来描述。
例如,如果您要创建一个包含某些数字序列的列表(假设我们想要前1000个偶数),则您会立即想到:使用链表实现(可能来自您的编程语言的标准库),循环和一个变量,您将设置起始值,然后循环一段时间,通过加2更新变量并将其放置于列表末尾。
注意到您大多考虑如何服务机器?内存位置、循环等!在命令式编程中,人们始终考虑如何按特定顺序操作某些内存单元以达到解决方案。(这也是初学者发现学习(命令式)编程困难的原因之一。非程序员根本不习惯通过将问题简化为一系列内存操作来解决问题。他们为什么要这样做呢?但是一旦您学会了,您就可以掌握命令式世界的力量。对于函数式编程,您需要放弃这种思维方式。)
在函数式编程中,特别是在Haskell中,您仅声明列表的构建法则。因为列表是一个递归数据结构,所以这个法则当然也是递归的。在我们的例子中,例如,我们可以说以下内容:
constructStartingWith n = n : constructStartingWith (n+2)
差不多完成了!要得到我们的最终列表,我们只需要说明从哪里开始,以及需要多少个:
result = take 1000 (constructStartingWith 0)
请注意,库中还提供了一个更通用版本的constructStartingWith
,称为iterate
,它不仅需要起始值,还需要将当前值转换为下一个列表元素的函数:
iterate f n = n : iterate f (f n)
constructStartingWith = iterate (2+) -- defined in terms of iterate
另一种方法是假设我们有另一个列表,我们可以很容易地从中制作出我们的列表。例如,如果我们有前n个整数的列表,我们可以通过将每个元素乘以2轻松地将其变成偶数列表。现在,在Haskell中,前1000个(非负)整数的列表简单地表示为:
[0..999]
有一个函数map
,它通过将给定的函数应用于每个参数来转换列表。 我们想要的功能是使元素加倍:
double n = 2*n
因此:
result = map double [0..999]
以后你将学习更多的快捷方式。例如,我们不需要定义double
,而可以使用一个部分:(2*)
或者我们可以直接将列表写成序列[0,2..1998]
。
但是现在还不知道这些技巧不应该让你感到难过!你现在面临的主要挑战是培养一种心态,即你需要看到构建前1000个偶数列表的问题是一个两阶段的问题:a)定义所有偶数的列表长什么样子; b)从该列表中取出一定的部分。一旦你开始这样思考,即使你仍然使用手写版本的iterate
和take
,你也完成了任务。
回到欧拉问题:这里我们可以使用自顶向下的方法(以及一些基本的列表操作函数,人们确实应该了解:head
、drop
、filter
、any
)。首先,如果我们已经有了素数列表,我们可以只删除前1000个并取剩余部分的头部来获得第1001个素数:
result = head (drop 1000 primes)
我们知道从一个无限列表中删除任意数量的元素后,仍将保留一个非空列表可供选择 head
,因此,在这里使用head
是合理的。当你不确定是否有超过1000个质数时,应该编写类似以下内容的代码:
result = case drop 1000 primes of
[] -> error "The ancient greeks were wrong! There are less than 1001 primes!"
(r:_) -> r
现在来到了难点。不知道如何继续,我们可以编写一些伪代码:
primes = 2 : {-an infinite list of numbers that are prime-}
我们已经确定了2是第一个质数,也就是所谓的基本情况,因此我们可以把它写下来。未填充的部分让我们有些思考。例如,列表应该从大于2的某个值开始,这是显而易见的原因。因此,修正:
primes = 2 :
现在,出现了一个模式,需要学习认识。这肯定是通过一个谓词(即素性)filter
的列表(我们还不知道如何检查素数,但逻辑结构是重要的点。(而且,我们可以确定测试素数是可能的!))。这让我们能够编写更多代码:
primes = 2 : filter isPrime [3..]
看到了吗?我们已经接近完成了。通过3个步骤,我们已经将一个相当复杂的问题简化为只需要编写一个非常简单的谓词。
同样,我们可以用伪代码来表示:
isPrime n = {- false if any number in 2..n-1 divides n, otherwise true -}
并且可以进一步完善。因为这几乎已经是Haskell,所以太容易了:
isPrime n = not (any (divides n) [2..n-1])
divides n p = n `rem` p == 0
请注意,我们还没有进行优化。例如,我们可以立即构造要过滤的列表,只包含奇数,因为我们知道偶数不是质数。更重要的是,我们希望减少在isPrime中必须尝试的候选数的数量。这里需要一些数学知识(当然,如果您用C++或Java编写此代码也是如此),告诉我们只需检查我们正在测试的n
是否被任何质数整除,而且我们不需要检查平方大于n
的质数的可除性。幸运的是,我们已经定义了质数列表,并且可以从中选择候选集!我把这留作练习。
稍后您将学习如何使用标准库和语法糖,例如部分、列表推导式等,并逐渐放弃编写自己的基本函数。
甚至稍后,当您再次使用命令式编程语言时,您将发现没有无限列表、高阶函数、不可变数据等非常难以生存。这将像从C转回Assembler一样困难。
祝玩得开心!
primes = 2 : 3 : filter isPrime [5,7..] where isPrime n = not $ any ((==0) . mod n) $ takeWhile ((<n) . (^2)) primes
这样的东西 - Lily Ballard