我是否停留在命令式编程思维中,没有像我应该的那样拥抱 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
,但可以使用一个 section:(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 : {- something like [3..] but only the ones that are prime -}
现在,这是一个需要学习识别的模式出现的地方。这肯定是一个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 回到汇编程序一样困难。
玩得开心!