函数式编程语言与物理学习指南-全-

函数式编程语言与物理学习指南(全)

原文:zh.annas-archive.org/md5/c6e940e26354dd4e4134b835b070b7e7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Image

学习某件事最好的方法之一就是教授它。拥有一个愿意倾听我们所说、阅读我们所写并给予回应的人是无价的。知道有人在倾听或阅读,会激励我们花时间和精力去创作有质量的东西。如果我们的写作能够引发回应,那就更好了,因为我们已经开始了一场可能挑战我们加深理解的对话。

学习物理的一种愉快而富有成效的方式是教计算机如何做。我们承认,计算机不像人类那样丰富地倾听,也无法提供像人类那样深度和广度的回应。然而,计算机非常专注,愿意不停地倾听,不会接受除非表达清晰且有意义的陈述。计算机可以为我们提供有用的反馈,因为它会乐于计算我们要求它计算的内容,并会迅速告诉我们我们刚才说的是否有意义(并且希望能给我们提供一个提示,告诉我们为什么它没有意义)。

本书的目的是通过教计算机如何做来学习基础理论物理。我们将花费相当多的时间在牛顿第二定律上。我们将重点关注物理系统的状态概念,并看到牛顿第二定律是描述状态随时间变化的核心规则。我们将研究基本的电磁理论,要求计算机计算由电荷和电流分布产生的电场和磁场。关键在于通过从一个新角度、用一种新语言来接近物理学,进而深化我们对物理学的理解。我们将使用精确的语言,这将有助于澄清我们的思维。

本书的读者对象

本书源于我在黎巴嫩山谷学院教授的计算物理课程,面向物理专业的二年级学生。我预计你已经学习过一年入门物理课程,并且至少有一个学期的微积分基础。不需要任何编程经验。本书的目的是通过用一种新语言探索物理学,从而加深你对基础物理学的理解。通过使用一种形式化语言表达物理学的思想,我们将提高自己表述和交流物理学思想的能力,同时计算我们感兴趣的量,制作图表和动画。

由于本书为从未编程过的人提供了自包含的 Haskell 编程语言介绍,因此可以作为物理学入门和中级课程的补充教材,适合那些希望通过编程加深对物理学理解的学生。

  • 希望包含计算组件,或

  • 渴望深入理解基本物理理论的结构。

本书也适合任何希望通过编程加深物理学理解的学生进行自学。

为什么选择函数式编程,为什么是 Haskell?

许多科学家在学习第二门编程语言后,产生了所有编程语言或多或少是相同的看法,认为语言之间的差异主要是语法上的差异。科学家们通常非常忙碌,他们有自己的工作要做,所以或许可以原谅他们选择不去深入了解那些可用的编程语言,去学习更加复杂的真理,即不同编程语言在语义层面上也可能有所不同,而且这种差异可能对人们思考他们正在编写代码解决的问题的方式产生深远的影响。被称为函数式编程的编程风格,源于编程语言树中的一个与面向对象编程不同的分支,这两者不太容易结合在一起。两者在所有应用中都没有明显的优劣之分。

物理学可以用任何编程语言来编码。为什么要使用函数式语言而不是更主流的面向对象语言?美丽和力量更多地体现在动词上,而非名词上。牛顿发现美丽和力量不在于世界本身,而在于描述世界如何变化的方式。函数式编程发现美丽和力量不在于对象,而在于那些以对象为输入并以对象为输出的函数,及其对象本身也可能是函数的概念。Haskell 是学习物理的一个很好的编程语言,原因有二。首先,Haskell 是一种函数式编程语言。这意味着函数在语言中占据了核心地位,包括那些接受其他函数作为参数并返回函数作为结果的函数。许多物理思想自然可以用高阶函数的语言来表达。其次,Haskell 的类型系统提供了一种清晰的方式来组织我们对物理中感兴趣的物理量和过程的思考。我不知道有什么比用函数式语言来表达我的想法更能澄清我的思路了。

关于本书

本书由三部分组成。第一部分是对函数式编程的一般介绍,特别是对 Haskell 的介绍,面向从未编程过的人。第二部分展示了如何使用函数式语言表达牛顿第二定律,并进而解决力学问题。第三部分旨在阐述电磁理论,展示如何在函数式语言中表达法拉第和麦克斯韦的思想,并解决涉及电场和磁场的问题。在整个过程中,我们将看到函数式语言如何接近数学;它实际上是一种计算机可以理解的数学形式。许多在数学语言中清晰而简洁表达的物理学深刻思想,在函数式语言中也找到了美丽的表达。

本书包括以下内容:

第一部分:Haskell 语言

第一章:使用 Haskell 进行计算 本章内容是关于如何将 Haskell 用作计算器。基本的数学运算已经内建于 Haskell 中,可以立即用于进行计算。

第二章:编写基本函数 在这里,我们开始编写函数。Haskell 函数与数学函数非常相似。最简单的 Haskell 函数接受一个数字作为输入,并产生一个数字作为输出。正如你可能猜到的那样,函数在函数式编程语言中发挥着核心作用。

第三章:类型和实体 本章介绍了类型的概念。Haskell 处理的实体,如数字和函数,都是按类型分类的;每个实体都有一个类型。类型帮助我们思考可以对实体进行哪些操作。例如,实数可以被平方,但对函数进行平方通常是没有意义的。

第四章:描述运动 在这里,我们研究如何在 Haskell 中描述一维运动中的粒子运动。我们引入了位置、速度和加速度,并注意到这些量是通过微积分中的导数概念相互关联的。

第五章:操作列表 本章讨论了 Haskell 中的列表。列表可以是数字列表、函数列表,或更复杂对象的列表。在函数之后,列表可能是函数式编程中最重要的结构,因为它们用于迭代过程(反复执行某些操作)。

第六章:高阶函数 本章介绍了高阶函数,它是接受其他函数作为输入或输出其他函数的函数。高阶函数是函数式编程语言的强大功能和简洁性的核心。我们举例说明了高阶函数如何自然地出现在物理学中。

第七章:绘制函数图像 本章展示了如何绘制函数的图像,例如余弦函数或你定义的函数,这些函数接受数字作为输入并生成数字作为输出。

第八章:类型类 在这里,我们介绍了 Haskell 中的类型类。类型类拥有需要能够处理某些类型(而非所有类型)的函数。等式检查就是这样的一个函数。我们希望能够检查数字的相等性、列表的相等性,以及其他事物的相等性。等式检查函数由类型类拥有。

第九章:元组和类型构造器 本章介绍了元组,一种包含两个或更多对象的结构。本章还讨论了类型构造器,它是类型级别的函数(换句话说,接受类型作为输入并产生类型作为输出的函数)。

第十章:描述三维中的运动 本章与第四章类似,专注于物理学的特定需求(在本章中是对向量的需求),并展示了 Haskell 如何满足这一需求。

第十一章:创建图形 本章我们回到创建图形的主题,首次在第七章提到,并且更详细地介绍了如何制作美观且信息丰富的图形。

第十二章:创建独立程序 在书的开头,我们主要通过 GHCi 交互式编译器与 Haskell 进行交互。后来在书中,当我们开始做动画时,我们制作独立程序。本章展示了几种创建独立程序的方法。

第十三章:创建二维和三维动画 本章介绍了动画,展示了如何制作简单的二维和三维动画。

第二部分:牛顿力学

第十四章:牛顿第二定律与微分方程 本章介绍了牛顿的第一定律和第二定律。我们学习如何在一维空间中解决有限类别的力学问题。我们还学习了为什么有些力学问题容易解决,而有些则难以解决。这归结于力依赖于什么。本章涵盖了不断增加复杂性的情况,从恒定力到依赖于时间和粒子速度的力。本章介绍了微分方程的概念,我们编写了能够求解一阶微分方程的代码。

第十五章:一维力学 本章继续增加复杂性的路径,研究依赖于时间、位置和速度的力。这些情况导致了一个二阶微分方程,我们通过引入状态变量来求解。

第十六章:三维力学 本章我们回到在第十章首次看到的向量设置,完成了单个物体的力学理论。我们展示了如何在三维空间中表达和求解牛顿第二定律。

第十七章:卫星、抛体和质子运动 本章提供了三个扩展示例,应用前一章中开发的思想和工具。

第十八章:相对论简短入门 本章展示了如果我们采纳特殊相对论的理念而不是牛顿理论,力学会是什么样子。我们看到,许多工具在转变后依然有效,使我们能够解决相对论中的问题。

第十九章:相互作用的粒子 本章介绍了牛顿第三定律,当我们关心不止一个物体时需要使用该定律。我们发展了一个相互作用粒子的理论,并用 Haskell 表达了关键概念。

第二十章:弹簧、台球和吉他弦 本章通过三个扩展示例,展示了相互作用粒子的问题,我们运用了第十九章中的思想和工具。在处理了三维空间中任意数量相互作用粒子的力学后,我们的牛顿力学讨论已完结。

第三部分:电磁理论

第二十一章:电学 本章介绍了古老的库仑电学理论,其中电学仅仅是由其他带电粒子产生的对带电粒子的力,这与牛顿引力的精神相似。库仑电学不使用电场的概念。

第二十二章:坐标系与场 本章引入了场的关键概念,场是空间的一个函数——一个在空间的每个位置可能具有不同值的量。本章还介绍了三维空间中的笛卡尔坐标、柱面坐标和球面坐标。

第二十三章:曲线、曲面与体积 本章讨论了我们如何在 Haskell 语言中描述曲线、曲面和体积。

第二十四章:电荷 本章涵盖了电荷这一导致电气现象的物理量以及不同类型的电荷分布。

第二十五章:电场 本章描述了电荷如何产生电场,开始了我们对现代法拉第-麦克斯韦电磁理论的学习,在该理论中,电场和磁场扮演着至关重要的角色。

第二十六章:电流 本章讨论电流和电流分布,类似于第二十四章中对电荷的讨论。

第二十七章:磁场 本章描述了电流如何产生磁场,与第二十五章类似,电荷与电场的关系就像电流与磁场的关系,至少在静态情况下是如此。

第二十八章:洛伦兹力定律 第二十四章到第二十七章讨论了电磁理论中电荷如何产生场,而本章讨论了电磁理论的第二个方面,即场如何对电荷施加力。洛伦兹力定律描述了这一第二个方面。

第二十九章:麦克斯韦方程组 本章介绍了麦克斯韦方程组,其中电磁理论的第一个方面达到了完整的复杂性,我们看到电场和磁场是动态的量,它们在相互作用并随时间变化。尽管有许多情况和应用我们不讨论,麦克斯韦方程和洛伦兹力定律提供了现代电磁理论的完整描述——这一理论不仅对于解释电、磁和光有重要意义,而且也为当今的基本粒子物理学理论提供了原型。

附录:安装 Haskell 本附录展示了如何安装我们将使用的 Haskell 编译器和软件库。

本书是我满怀热情的劳动成果,意味着写作本书的动力源于我对其中思想的热爱以及分享这些思想的愿望。我希望我创造了一本美丽的书,但更重要的是,我希望这本书能帮助你用优美的代码表达出美丽的思想。享受其中!

第一部分

面向物理学家的 HASKELL 入门

第一章:使用 Haskell 进行计算

图片

在本章中,我们将看到如何将 Haskell 用作科学计算器。这个计算器默认提供了多个科学函数。在第二章中,我们将编写我们自己的函数,这些函数可以加载并使用。本章介绍了一些后续章节中会有用的语言特性和细节。让我们从一个运动学问题开始。

一个运动学问题

假设我们有一辆车在气轨上。该车的加速度为 0.4 m/s²。在时间 t = 0 时,车辆是静止的。那么,这辆车行驶 2 米需要多长时间?

由于加速度是恒定的,因此我们可以使用位置-时间方程。

图片

因为车从静止开始,我们知道 v(0) = 0。假设这辆车从位置零(原点)开始,那么 x(0) = 0。我们正在寻找时间 t,使得 x(t) = 2 米。一点代数运算告诉我们:

图片

我们可以使用笔和纸来解决这个问题,或者使用计算器。在本章中,我们将使用 Haskell 作为我们的计算器。

在下一部分,我们将逐步解释如何使用 Haskell 作为计算器。为了结束这一部分,我们将展示如何输入内容来计算时间。

Prelude > sqrt (2 * 2 / 0.4)
3.1622776601683795

如你所见,车行驶 2 米大约需要 3.2 秒。现在,让我们看看如何启动交互式 Haskell “计算器”。

交互式编译器

这个运动学例子为我们提供了一个机会,可以介绍格拉斯哥 Haskell 编译器(GHC)的交互式版本。GHC 的交互式版本叫做 GHCi,我们可以将其用作计算器。GHCi 随格拉斯哥 Haskell 编译器一起提供,Haskell 编译器可以从https://www.haskell.org免费下载。启动 GHCi 的方法可能取决于你计算机所使用的操作系统。通常,你可以点击一个图标,从菜单中选择 GHCi,或者在命令行中输入 ghci

当 GHCi 启动时,我们会看到一个提示符,在此处可以输入表达式。GHCi 给出的第一个提示符是 Prelude>。Prelude 是一个包含常量、函数和运算符的集合,默认情况下可以直接使用,我们可以立即使用它来构建表达式。GHCi 通过在提示符中显示 Prelude 来指示 Prelude 已经为我们加载好了。现在,GHCi 正在等待我们输入表达式。如果你输入 2/3,然后按下 ENTER,GHCi 将评估这个表达式并打印结果。

Prelude> 2/3
0.6666666666666666

在表达式 2/3 中,Haskell 将 23 解释为数字,而 / 被当作二元运算符表示除法。GHCi 执行所请求的除法并返回结果。

数值函数

Haskell 在 Prelude 中提供了执行许多计算任务的函数。这里是一个例子:

Prelude> log(2)
0.6931471805599453

这是应用于数字 2 的自然对数函数。Haskell 语言在应用函数时不需要括号。函数应用(也叫 函数使用函数求值)是 Haskell 中一个非常基础的概念,即两个表达式并列时,表示第一个表达式是一个函数,第二个是该函数的参数,函数被应用到这个参数上。因此,我们可以输入如下代码:

Prelude> log 2
0.6931471805599453

表 1-1 列出了 Prelude 中可用的常见数值函数。

表 1-1: 一些常见的数值函数

函数 描述
exp exp x = e^x
sqrt 平方根
abs 绝对值
log 自然对数(以 e 为底的对数)
sin 弧度制下的正弦函数
cos 弧度制下的余弦函数
tan 弧度制下的正切函数
asin 反正弦(反弧度)
acos 反余弦
atan 反正切函数
sinh sinh x = (e^xe^(–x))/2
cosh cosh x = (e^x + e^(–x))/2
tanh tanh x = (e^xe(–x)*)/(*ex + e^(–x))
asinh 反双曲正弦函数
acosh 反双曲余弦函数
atanh 反双曲正切函数

Haskell 还在 Prelude 中提供了常数 π

Prelude> pi
3.141592653589793

这里是一个三角函数:

Prelude> cos pi
-1.0

请注意,Haskell 中的三角函数需要以弧度作为参数。

现在,我们来计算 cos Image

Prelude> cos pi/2
-0.5

计算机没有给出我们预期的结果;cos Image 应该是 0,而不是 –0.5。原因在于,Haskell 中函数应用的优先级高于除法,因此 Haskell 将我们输入的内容解释为

Prelude> (cos pi)/2
-0.5

而不是先将 π 除以 2,再取余弦。我们可以通过加括号来得到我们想要的结果。

Prelude> cos (pi/2)
6.123233995736766e-17

计算机给出的结果是 Image,对吗?不完全是。这里我们看到的是一个近似计算结果的示例。我的计算机给出的结果是某个值乘以 10^(–17),这几乎等于零,这是计算机所能达到的最接近零的值。值得记住的是,在进行数值计算时,计算机(就像你的计算器)大多数时候不会给出精确的结果,而是给出近似值。我们必须保持警惕,确保我们通过正确解释这些结果,得到有价值的信息。我们将在 第三章 讨论 Haskell 的类型系统后,进一步说明什么时候我们可以期待计算机给出精确的结果,什么时候不能。

运算符

Haskell Prelude 提供了几个 二元运算符,如 表 1-2 所示。二元运算符作用于两个输入,或 参数,以产生一个结果。例如,加法运算符(+)是一个二元运算符,因为它接受两个输入并将它们相加。在计算机科学中,一般将放置在参数之前的运算符称为前缀运算符,放置在参数之后的运算符称为后缀运算符,而放置在参数之间的运算符称为中缀运算符。在 Haskell 中,术语 运算符 暗示的是中缀运算符,尽管在 第六章中我们将看到如何将中缀运算符转化为前缀运算符。

表 1-2 展示了常见的 Haskell 运算符,并列出了它们的优先级和结合性,接下来将对此进行解释。加法、减法、乘法和除法运算符的运作方式几乎和你预期的一样。

表 1-2: 常见运算符的优先级与结合性

操作 运算符 优先级 结合性
组合 . 9 右结合
指数运算 ^, ^^, ** 8 右结合
乘法、除法 *, / 7 左结合
加法、减法 +, - 6 左结合
列表运算符 :, ++ 5 右结合
等式、不等式 ==, /= 4
比较 <, >, <=, >= 4
逻辑与 && 3 右结合
逻辑或 &#124;&#124; 2 右结合
应用 ` 操作 运算符
--- --- --- ---
组合 . 9 右结合
指数运算 ^, ^^, ** 8 右结合
乘法、除法 *, / 7 左结合
加法、减法 +, - 6 左结合
列表运算符 :, ++ 5 右结合
等式、不等式 ==, /= 4
比较 <, >, <=, >= 4
逻辑与 && 3 右结合
逻辑或 &#124;&#124; 2 右结合
0 右结合

表格还展示了三种不同的指数运算符。这些差异与 Haskell 的类型系统有关,更多内容将在 第三章 和 第八章中讨论。

照 Caret 运算符(^)仅能处理非负整数指数。表达式 x^n 表示 xn 次方的乘积。双 Caret 运算符(^^)可以处理任何整数指数。** 运算符可以处理任何实数指数。现在,我建议使用 ** 进行指数运算。

等式、不等式和比较运算符可以用于数字表达式之间。

Prelude> pi > 3
True

比较的结果是一个布尔表达式,可能是 TrueFalse。第五章涵盖了 表 1-2 中的列表运算符 :++

优先级与结合性

如我们之前看到的,当我们尝试计算 π/2 的余弦值时,函数应用的优先级高于中缀运算符。此外,一些运算符的优先级高于其他运算符。在表达式中:

Prelude>  1 + 2 * 3
7

2 和 3 的乘法会先于与 1 的加法进行。这与通常的数学惯例一致。为了实现这一点,Haskell 中的二元运算符有一个与之相关的优先级,用来描述哪些操作应该先执行。二元运算符的优先级范围从 0 到 9。优先级数字越高,表示该操作会先执行。例如,加法和减法在 Haskell 中的优先级是 6,而乘法和除法的优先级是 7,指数运算的优先级是 8。布尔值之间的“或”操作 || 的优先级是 2,而“与”操作 && 的优先级是 3。

表 1-2 的最右列列出了某些运算符的结合性。考虑表达式 8 - 3 - 2。这个表达式可能有两种解释方式。标准的数学惯例是,表达式是(8 – 3) – 2 的简写,计算结果为 3。但另一种解释是,表达式是 8 – (3 – 2)的简写,计算结果为 7。显然,我们必须理解原始表达式的正确解释是哪种,这就是结合性规则的作用。查看表 1-2,我们可以看到减法是左结合的。这意味着最左边的减法最先执行,从而得出第一个解释(结果为 3,而不是 7)。优先级和结合性帮助我们明确地确定哪些运算符首先执行。

学习优先级和结合性规则的目的是让我们尽量避免使用括号。多个嵌套的表达式会让代码难以阅读。我的建议是,尽量不要尝试使用超过两层的嵌套括号。除了了解优先级和结合性规则外,还有其他避免使用括号的方法,比如定义局部变量。我们稍后会讨论这些方法。

我们为以下表达式添加括号,以表示 Haskell 中的优先级和结合性规则将如何计算该表达式。

8 / 7 / 4 ** 2 ** 3 > sin pi/4

函数应用的优先级高于所有运算符,因此 sin pi 是第一个计算的部分。

8 / 7 / 4 ** 2 ** 3 > (sin pi)/4

接下来,指数运算是表 1-2 中具有最高优先级的运算符。指数运算出现了两次,由于它是右结合的,因此最右边的指数运算最先执行。

8 / 7 / 4 ** (2 ** 3) > (sin pi)/4

接下来是左侧的指数运算。

8 / 7 / (4 ** (2 ** 3)) > (sin pi)/4

接下来是除法。表达式中有三个除法。最右边的除法没有问题,但我们需要通过结合性规则解决表达式左边的两个除法。除法是左结合的。

(8 / 7) / (4 ** (2 ** 3)) > ((sin pi)/4)

注意,我们在最后一步插入了两组括号。一个是用于最右边的除法,另一个是用于最左边的除法。现在我们可以为最终的除法加上括号,它发生在比较之前。

((8 / 7) / (4 ** (2 ** 3))) > ((sin pi)/4)

最后执行的运算符是比较运算符>。无需将整个表达式放在括号中,因此我们完成了。完全括号化的表达式是

((8 / 7) / (4 ** (2 ** 3))) > ((sin pi)/4)

应用运算符

表 1-2 中的运算符$称为函数应用运算符。应用函数时不需要运算符。两个表达式并列时,意味着第一个是一个函数,第二个是一个参数,且函数要应用于该参数。

函数应用运算符的作用只是将其左侧的函数应用于右侧的表达式。

Prelude> cos pi
-1.0
Prelude> cos $ pi
-1.0  
Prelude> cos $ pi / 2
6.123233995736766e-17

它使用的关键在于它的优先级为 0。这意味着运算符$改变了应用顺序,从首先要做的事情变成最后要做的事情。这样,$就充当了一种单符号括号的作用。我们不需要像上面的例子中那样将pi / 2放在括号中,而是可以使用这个单符号的函数应用运算符。因为它具有右结合性,函数应用运算符成为 Haskell 中的常用习惯用法,使得嵌套应用(h `$` g `$` f x)更容易阅读。

有两个参数的函数

表 1-1 中的所有函数都接受一个实数作为输入,并返回一个实数作为输出(假设输入在函数的定义域内)。还有一些有用的数值函数,它们接受两个实数作为输入。这些函数列在表 1-3 中。

表 1-3: 两个参数的数值函数

函数 示例
logBase logBase 10 100 = 2
atan2 atan2 1 0 = π/2

让我们看看这些函数的实际应用:

Prelude> logBase 10 100
2.0  
Prelude> atan2 1 0
1.5707963267948966

logBase函数接受两个参数:第一个是对数的底数,第二个是我们希望计算对数的数字。

atan2函数解决了一个问题,如果你曾尝试使用反正切函数从笛卡尔坐标转换到极坐标,你可能会遇到这个问题。考虑以下极坐标(r, θ)与笛卡尔坐标(x, y)之间的方程:

Image

假设我们正在尝试找到与点 Image 相关的极坐标。答案需要是第三象限中的一个点,因为 xy 都是负数。这意味着 θ 应该在 π < θ < 3π/2(或 – π < θ < –π/2)范围内。但是,如果我们机械地使用上述公式计算 θ,我们会得到 Image,而我们的计算器或计算机会告诉我们它是 π/3. 问题在于反正切函数的定义域,解决方案是使用 atan2 函数,而不是 atan 函数。atan2 y x 的结果将给出正确象限中的角度(以弧度表示)。

注意如何将两个参数传递给 logBaseatan2 函数。特别是,两个参数值之间没有逗号,这在传统的数学表示法中是必须的。

Haskell 中的数字

关于 Haskell 中数字的一些细节对于第一次接触这门语言的人来说并不直观。在本节中,我们将指出一些与负数、小数和指数表示法相关的问题。

Haskell 中的负数

如果你尝试运行以下代码

Prelude> 5 * -1

<interactive>:15:1: error:
    Precedence parsing error
        cannot mix '*' [infixl 7] and prefix `-' [infixl 6] in the same infix
            expression

你将遇到一个错误,尽管表达式的含义看起来足够清楚:我们想将 5 乘以 – 1. 问题在于,减号既充当二元运算符(如表达式 3 – 2 中),也充当一元运算符(如表达式 – 2 中)。二元运算符在 Haskell 中起着重要作用,且语言的语法支持以一致、统一的方式使用它们。而一元运算符在 Haskell 中则更多是特殊情况;事实上,减号是唯一的一个。一些 Haskell 设计者的决策(参见 wiki.haskell.org/Unary_operator)导致负数有时不会像你期望的那样被及时识别。

解决方案是简单地将负数用括号括起来。例如,

Prelude> 5 * (-1)
-5

计算结果为 – 5。

Haskell 中的小数

包含小数点的数字必须在小数点前后都有数字(0 到 9)。因此,我们必须写成 0.1,而不能写成 .1;而不是 5.,我们必须写成 5.05(不带小数点)。原因是点字符在语言中有另一个作用(即函数组合,这在 表 1-2 的顶部行中提到,我们将在 第二章中进一步学习)。这一规则要求小数点前后有数字,帮助编译器区分点字符的含义。

指数表示法

你可以使用指数表示法来描述在 Haskell 中非常大或非常小的数字。以下是一些例子:

数学表示法 Haskell 表示法
3.00 × 10⁸ 3.00e8
6.63 × 10^(–34) 6.63e-34

Haskell 还会使用指数表示法来展示那些非常大或非常小的数字。

表达式 计算结果
8**8 1.6777216e7
8**(-8) 5.960464477539063e-8

近似计算

我们做的大多数计算都不是精确计算。当我们要求计算机找到 5 的平方根时,

Prelude> sqrt 5
2.23606797749979

它给出了一个非常精确的结果,但并不是一个精确的结果。这是因为计算机使用有限的位数来表示这个数字,不能表示每一个可以想象的数字。

如果你在 GHCi 中计算sqrt 5 ^ 2,你可能得不到精确的 5.0 作为结果。

Prelude> sqrt 5 ^ 2
5.000000000000001

计算机并没有精确表示图片。我们甚至可以在 GHCi 中询问如下:

Prelude> sqrt 5 ^ 2 == 5
False

我的电脑给出False是因为近似计算。

计算中另一个导致不精确的来源是计算机使用二进制(基 2)内部表示数字。当我将 3 乘以 0.2 时,我得不到精确的 0.6。为什么?原因在于 0.2 虽然在十进制(基 10)中有一个漂亮的有限小数表示,但它在二进制(基 2)中有一个无限循环的表示。就像分数 1/3 在十进制中有一个无限循环的表示(0.333333...),分数 1/5 在二进制中也有一个无限循环的表示。表格 1-4 展示了一些简单分数在十进制(基 10)和二进制(基 2)中的表示。

表格 1-4: 十进制和二进制表示的数字

数字 十进制 二进制
1/2 0.5 0.1
1/3 0.333333... 0.01010101...
1/4 0.25 0.01
1/5 0.2 0.001100110011...

计算机将我们提供的每个数字从十进制转换为其内部的二进制形式,并只保留有限的位数(实际上是位)。大多数时候,我们不需要担心这个问题,但它解释了为什么一些看起来应该是精确可计算的计算并不是这样。这个故事的一个教训是:当某个数字经过近似计算时,永远不要进行相等性检查。

错误

人们会犯错误。这是正常的。当你输入电脑无法理解的内容时,它会给你错误信息。这些信息可能看起来很吓人,但它们是一个很好的学习机会,值得学会如何阅读这些错误信息。

当我们尝试将 5 乘以–1 而没有将–1 用括号括起来时,我们在“《Haskell 中的数字》”中看到一个“优先级解析错误”。另一个你迟早会遇到的错误是“No instance for Show”。

在 Haskell 中有一些完全合法、定义明确的表达式,无法很好地显示在屏幕上。函数就是最常见的例子。因为一个函数可以接受多种输入并产生多种输出,一般来说,没有一种好的方法来显示函数的“值”。如果你请求 GHCi 告诉你平方根函数“是什么”,它会抱怨没有办法显示给你看,表示它“没有 Show 实例”。

Prelude> sqrt

<interactive>:25:1: error:
    • No instance for (Show (Double -> Double))
        arising from a use of 'print'
        (maybe you haven't applied a function to enough arguments?)
    • In a stmt of an interactive GHCi command: print it

这个消息完全不是错误。sqrt 函数是一个完全合法的 Haskell 表达式。GHCi 只是说它不知道如何显示它。

获取帮助和退出

要请求 GHCi 的帮助,输入 :help(或 :h)。要退出 GHCi,输入 :quit(或 :q)。

以冒号开头的命令并不属于 Haskell 编程语言本身,而是属于 GHCi 交互式编译器,它们控制 GHCi 的操作。稍后我们会看到更多以冒号开头的命令。

更多信息

要了解更多关于 Haskell 编程语言(GHCi 是一个流行的实现)的信息,可以访问 www.haskell.org 网站。

haskell.org 网站提供了许多学习语言的在线和纸质资源的链接。其中一些特别好的资源是 Learn You a Haskell for Great Good! [1] (learnyouahaskell.com) 和 Real World Haskell [2] (book.realworldhaskell.org)。

总结

在本章中,我们学习了如何将 GHCi 用作计算器。GHCi 配备了一系列科学函数和运算符。运算符有优先级和结合性规则,决定它们执行的顺序。了解并使用这些规则可以减少表达式中的括号数量。函数应用的优先级高于任何运算符。负数有时需要括号围住才能正确解析。带小数点的数字需要在小数点前后都有数字。计算机进行的许多计算都是近似的。错误应该被看作是有用的提示;保持对错误的好奇心是一种有益的态度。面对错误时的耐心和坚持是通向理解的道路的一部分。

在下一章中,我们将展示如何定义自己的函数并将其加载到 GHCi 中。

练习

练习 1.1. 在 GHCi 中计算 sin 30。为什么它不等于 0.5?

练习 1.2. 在以下表达式中添加括号,表示 Haskell 的优先级和结合性规则将如何计算这些表达式:

(a) 2 ^ 3 ^ 4

(b) 2 / 3 / 4

(c) 7 - 5 / 4

(d) log 49/7

练习 1.3. 使用 GHCi 计算 log[2] 32。

练习 1.4. 在 GHCi 中使用 atan2 函数计算与笛卡尔坐标 (x, y) = (–3,4) 相关的极坐标 (r,θ)。

练习 1.5. 找一个新的计算示例,其中计算机得到的结果与精确结果有一点点不同。

练习 1.6. 为什么在 表 1-2 中没有列出等号、不等号和比较操作符的结合性?(提示:写出你能想到的最简单的表达式,它需要结合性规则来解决比较操作符的优先级问题,然后尝试理解它。)

第二章:编写基本函数

Image

函数是函数式编程的核心概念。在本章中,我们将学习如何定义函数和常量,以及如何在 GHCi 中使用这些函数和常量。我们将讨论用于描述函数的语言,并且我们将看到,与计算机交流通常比与人类交流要求更多的精确性。然后,我们将介绍 Haskell 的匿名函数系统,这些函数没有名称。在简要了解 Haskell 的类型系统后(我们将在第三章中详细描述),我们将展示如何使用函数组合运算符来组合函数。最后,我们将展示如果使用未定义的名称会出现什么样的错误。

常量、函数和类型

用 Haskell 编程的过程是定义函数的过程。函数向计算机表达我们想要计算的内容。Haskell 函数与数学函数非常相似:它们接受输入,并生成一个依赖于输入的输出。与数学函数类似,Haskell 函数有一个定义域,描述可以作为输入的实体类型,还有一个值域(有时称为范围),描述将会生成的输出实体类型。

与数学函数不同,Haskell 函数必须是构造性的。它们必须提供一个清晰、明确的构造输出的步骤,基于输入。Abelson 和 Sussman 在他们的精彩著作《计算机程序的结构与解释[3]中提到,平方根函数的定义是一个非负数,且其平方等于输入值,这是一个完全合法的数学函数。但这个定义并没有提供如何从输入构造平方根的步骤,因此无法转化为一个 Haskell 函数。幸运的是,存在一些其他的平方根定义是构造性的,可以转化为 Haskell 函数。

有一种方法可以在 GHCi 中定义函数,但由于我们通常希望定义的函数能被多次使用,因此最好将函数定义在源代码文件中,也叫做程序文件,然后将该文件加载到 GHCi 中。

我们需要一个文本编辑器来创建这样的文件。常见的文本编辑器包括 GNU Emacs、Vim 和 gedit。

你可能用来输入信件或文档的文字处理程序并不适用于这个目的,因为它们在你输入的文本中存储了额外的信息(如字体类型和大小),这些信息对 Haskell 编译器来说是没有意义的。

使用文本编辑器,让我们创建一个名为first.hs的文件,来编写我们的第一个程序。(.hs 扩展名表示一个 Haskell 程序。)在文件中写入以下内容:

-- First Haskell program

-- Here we define a constant
e :: Double
e = exp 1

-- Here we define a function
square :: Double -> Double
square x = x**2

该程序文件定义了一个常量和一个函数。以双连字符开头的行是注释。Haskell 编译器会忽略任何以双连字符开头的行;事实上,它会忽略双连字符之后直到行尾的所有内容,除非双连字符是字符串或某些其他特殊环境的一部分。注释的目的是帮助人类阅读代码。

文件的前两行非注释部分定义了常量e,自然对数的底数。与π不同,e并不包含在 Haskell 的预置库中。

e :: Double

声明了e类型Double类型是对实体如何使用的共性的描述。Haskell 中的每个表达式都有一个类型,它告诉编译器在什么情况下该表达式可以使用,在哪些情况下不能使用。例如,Double类型告诉编译器e是一个实数的近似值,通常称为浮动点数。Double这个名称是出于历史原因,意味着双精度浮动点数。这种类型的数字可以达到大约 15 位小数精度,而单精度数字大约只能达到 7 位小数精度。Haskell 中有一个类型Float用于单精度数字。除非有充分的理由,否则我们总是使用Double类型来表示我们的(实数的)近似值。

除了Double,我们可能还想使用其他一些类型。Haskell 有一个类型Int用于小整数(至少到几十亿),以及一个类型Integer用于任意大小的整数。第三章专门讲解类型。

让我们回到我们的first.hs程序文件。正如我们之前所说,文件的第一行非注释部分声明了e的类型为Double。这种类型的行,以一个名称后跟双冒号然后是类型,被称为类型签名。我们也可以称这样的行为声明,因为它声明了名称e具有类型Double

文件的第二行非注释部分实际上是定义e。在这里,我们使用内置函数exp对数字1进行应用,得到常数e。请记住,我们在应用函数到参数时不需要使用括号。

接下来,我们有了函数square的类型签名。square的类型被声明为Double -> Double。包含箭头的类型被称为函数类型。(函数类型将在下一章中更详细地探讨。)这表示square是一个接收Double作为输入并输出一个Double的函数。最后一行定义了函数square。请注意用于指数运算的**运算符。

要将此程序文件加载到 GHCi 中,请使用 GHCi 的:load命令(简称:l)。

Prelude> :l first.hs
[1 of 1] Compiling Main            ( first.hs, interpreted )
Ok, one module loaded.
*Main> square 7
49.0
*Main> square e
7.3890560989306495

加载first.hs文件后,GHCi 提示符会从Prelude>变为*Main>。这表明我们的程序文件已成功加载,并且被赋予了默认名称Main。我们现在可以访问文件中定义的常量和函数。

文件first.hs中定义的esquare是 Haskell 中的变量标识符的示例。变量标识符必须以小写字母开头,后跟零个或多个大写字母、小写字母、数字、下划线和单引号。以大写字母开头的名称保留给类型、类型类(我们将在第八章中讨论)和模块名称。

如果你忘记了某个东西的类型,或者不知道它的类型,可以通过 GHCi 的:type命令(简写为:t)询问类型。

*Main> :t square
square :: Double -> Double

在 Haskell 中定义函数的符号在某些方面与数学符号类似,在其他方面则有所不同。让我们来讨论这些不同之处。表 2-1 展示了一些示例。

表 2-1: 传统数学符号定义的函数与 Haskell 定义的函数对比

数学定义 Haskell 定义
f(x) = x³ f x = x**3
f(x) = 3x² – 4x + 5 f x = 3 * x**2 - 4 * x + 5
g(x) = cos 2x g x = cos (2 * x)
v(t) = 10t + 20 v t = 10 * t + 20
h(x) = e^(–x) h x = exp (-x)

首先,请注意传统的数学符号(以及某些计算机代数系统)使用相邻符号表示乘法。例如,2x表示 2 乘以x,仅仅因为这些符号放在一起。Haskell 需要使用乘法运算符*。在 Haskell 中,相邻符号表示的是函数应用。

接下来,请注意传统数学符号要求函数参数必须放在函数名后面的小括号内。这对于函数定义(比较f (x) = x³与 Haskell 的f x = x**3)以及函数应用(比较f (2)与 Haskell 的f 2)都是如此。而 Haskell 在函数定义和应用中并不需要括号。Haskell 使用括号来表示运算顺序。

最后,传统的数学符号通常使用单个字母的函数名,例如f。Haskell 允许使用单个字母的函数名,但更常见的是使用多个字母组成的函数名(例如上面的square),尤其是当这个词能很好地描述函数的功能时。

如何讨论函数

假设我们定义一个函数 ff (x) = x² – 3x + 2。在数学和物理中,常常会说“函数 f (x)”。Haskell 鼓励我们更仔细、更准确地思考这一常见的习惯。(实际上,它要求我们更仔细地思考这一点,但总是更好的是被邀请,而不是被要求,不是吗?)与其说“函数 f (x)”,我们应该根据具体含义说出以下之一:

  • 函数 f

  • f (x)

  • 给定一个数字 x,函数 fx 处的值

第二点和第三点是两种表达相同意思的方式。第一点则与第二点和第三点表达的含义不同。

说“函数 f (x)”有什么问题?在数学和物理中,通常将“函数 f”与“函数 f (x)”互换使用,后者只是明确地表示 f 依赖于 x。我们通常认为数学符号是某个概念的精确表示,但在这种情况下,常用的符号并不精确。

避免使用“函数 f (x)”这个表述的一个原因是,如果 f (x) = x² – 3x + 2,那么 f (y) = ^(y²) – 3y + 2。字母 x 与函数 f 实际上没有任何关系。当然,我们需要某个字母来定义,但其实哪个字母都无所谓。当 x 用于定义其他内容时,我们称其为虚拟变量

在 Haskell 中,当我们想要通过输入 x 来求值函数 f 时,我们说 f (x)。当我们想谈论函数本身,而不是求值时(即不给它任何输入),我们说 f。那除了给函数输入,我们还能做什么呢?嗯,你可以在给定的区间内积分该函数。你也可以求导该函数得到另一个函数。在某些情况下,你还可以将该函数应用两次。简而言之,除了简单地求值外,我们可能还想对一个函数做很多其他事情。

Haskell 的类型系统帮助我们理解 ff (x) 之间的关键区别。变量 x 是一个数字,因此它具有类似 Double 的类型。而 f 是一个函数,因此它的类型是 Double -> Double。最后,f (x) 表示函数 f 在数字 x 处的值,所以 f (x) 的类型是 Double。类型为 Double -> Double 的是函数,类型为 Double 的是数字。下表总结了这些区别。

数学符号 Haskell 符号 Haskell 类型
f f Double -> Double
f (3) f 3 Double
f (x) f x Double

计算机在理解人类的意思方面因其不灵活性而臭名昭著。计算机会精确地检查你所说的内容,如果你的输入不符合它们的格式和解释要求,它们会给出警告和错误。大多数时候,这让人非常头疼。我们希望能有一个理解我们意思并按照我们希望去做的助手。

然而,在类型和函数的情况下,Haskell 的严格性是一个很好的教学辅助工具。Haskell 帮助我们组织思维,以便我们能够以结构化和有序的方式准备进行更复杂的操作。在第六章中,我们将看到关于类型和函数的精心思考如何使我们能够简单而轻松地编码更复杂的思想。

当我们使用 Haskell 时,我们做出了一个权衡。我们同意以一种精确而谨慎的方式使用语言(编译器会检查我们),作为交换,我们可以在语言中表达一些在容忍不精确的语言中难以表达的复杂内容。因此,我们能够揭示像牛顿力学这样的物理理论的基本结构。

匿名函数

Haskell 提供了一种无需命名即可指定函数的方式。例如,平方其参数的函数可以写作\x -> x**2

以这种方式指定的函数称为匿名函数λ函数,其名称来源于 20 世纪 30 年代阿隆佐·丘奇(Alonzo Church)发展出的λ演算。(丘奇是艾伦·图灵(Alan Turing)的博士导师。)Haskell 的创作者认为反斜杠字符(\)看起来有点像希腊字母小写 lambda(λ)。

表 2-2 展示了以λ函数形式书写的数学函数示例。这是表 2-1 中函数定义的另一种方式。

表 2-2: 传统数学符号函数定义与在 Haskell 中定义的λ函数的对比

数学函数 Haskell λ函数
f (x) = x³ f = \x -> x**3
f (x) = 3x² – 4x + 5 f = \x -> 3 * x**2 - 4 * x + 5
g(x) = cos 2x g = \x -> cos (2 * x)
v(t) = 10t + 20 v = \t -> 10 * t + 20
h(x) = e^(–x) h = \x -> exp (-x)

λ函数的真正优势在于,我们可以在需要一个函数但又不想花费精力(即不想声明和定义)来命名新函数的地方使用它们。我们将在第六章中看到如何利用这一点,在该章中我们讨论了接受其他函数作为输入的高阶函数。这些其他函数有时可以方便地表示为匿名函数。

我们可以通过在 GHCi 提示符下写(\x -> x**2) 3来将匿名平方函数\x -> x**2应用于参数 3。

*Main> (\x -> x**2) 3
9.0

注意,当我们写 \x -> x**2 时,我们并没有定义x是什么。相反,我们是在说,如果我们暂时允许x代表函数的参数(如上面的3),我们就有一个规则来确定应用该函数于该参数时的结果。对于(命名的)数学函数也有类似的说法;当我们定义 f (x) = x² 时,这是对 f 的定义,而不是对 x 的定义。函数 \x -> x**2 与函数 \y -> y**2 是相同的;我们用来命名参数的变量并不重要。两者都是将其参数平方的函数。

表 2-3 展示了匿名函数应用于一个参数的例子。

表 2-3: 应用匿名函数于参数的例子

表达式 求值结果
(\x -> x**2) 3 9.0
(\y -> y**2) 3 9.0
(\x -> x**3) 3 27.0
(\x -> 3 * x**2 - 4 * x + 5) 3 20.0
(\x -> cos (2 * x)) pi 1.0
(\t -> 10 * t + 20) 3 50
(\x -> exp (-x)) (log 2) 0.5

这些例子可以在 GHCi 提示符下进行求值。

组合函数

写 cos² x 是 (cos x)² 的简写,意思是“先取 x 的余弦值,再对结果平方。”当我们将一个函数 f 的输出作为另一个函数 g 的输入时,我们是在组合这两个函数来生成一个新函数。我们写 gf,称为g after f,表示先将 f 应用于其输入,再将 g 应用于结果。

Image

来自表 1-2 的函数组合运算符 (.) 扮演着数学符号中∘的角色。以下四个函数是定义余弦平方函数的等价方式:

cosSq :: Double -> Double
cosSq x = square (cos x)

cosSq' :: Double -> Double
cosSq' x = square $ cos x

cosSq'' :: Double -> Double
cosSq'' x = (square . cos) x

cosSq''' :: Double -> Double
cosSq''' = square . cos

第一个函数cosSq以最直接的方式定义了一个数字的余弦平方。从括号中可以清楚地看出,余弦首先作用于x,然后应用函数square。第二个函数cosSq'做了同样的事情,但它使用了函数应用操作符$而不是括号(参见第一章中的“函数应用操作符”)。第三个函数cosSq''展示了如何使用组合操作符将函数squarecos组合起来。表达式square . cos就像方程 2.1 左侧的gf,其中square充当g的角色,cos充当f的角色。第四个函数cosSq'''展示了 Haskell 如何让我们定义一个不提及其应用参数的函数。这种定义方式称为无点风格。如果h是由h(x) = g(f (x))定义的函数,那么数学符号允许我们将h定义为h = gf。函数cosSq''表达了前一种定义,而函数cosSq'''表达了后一种定义。如果你需要定义一个余弦平方函数,四个函数中的任何一个都是完全可接受的。选择只是风格问题。最后一个定义是我最喜欢的,因为它简洁。

上面显示的定义是 Haskell 一个令人愉快的特性——允许在标识符中使用撇号(单引号)的示例。这很棒,因为它支持我们在数学中使用简洁的“x prime”表示与x相关的事物。

函数组合操作符可以用于任何两个函数,其中第一个函数的输出类型与第二个函数的输入类型匹配。在实践中,函数组合操作符通常作为一种避免命名新函数的方式。如果squarecos这两个函数可用,那么实际上没有必要做出任何四个定义中的任何一个,因为square . cos是一个完全有效的函数,可以在任何需要cosSq的地方使用。

变量不在作用域错误

最简单的一种错误类型是使用了一个未定义的名称。如果我们请求 GHCi 输出x的值而没有定义x,我们将得到一个“变量不在作用域”错误。

*Main> x

<interactive>:6:1: error: Variable not in scope: x

一个名称的作用域是指可以使用该名称并且编译器能够正确理解的情况集合。 “变量不在作用域”错误可能更适合称为“名称无法识别”。任何编译器预期能识别但无法识别的名称都会产生此错误。该错误源于使用了一个我们未定义的名称,或者没有告诉编译器在哪里可以找到该名称。这适用于函数、常量和局部变量(我们稍后会介绍)——基本上是任何可以拥有名称的实体。常见标识符,例如x,可以重复使用,而且有办法在程序的特定位置明确控制我们指的是哪个x

摘要

在本章中,我们学习了如何在源代码文件中定义函数并将其加载到 GHCi 中使用。我们展示了如何在需要函数但又不想命名它的地方使用匿名函数。匿名函数的需求和实用性将在第六章中更加明确。函数组合运算符可以用于组合任意两个函数,其中第一个函数的输出类型与第二个函数的输入类型匹配。我们看到,当计算机认为应该知道一个名字的含义但又没有时,可能会出现“变量不在作用域”错误。在下一章中,我们将更深入地了解 Haskell 的类型系统,它为我们组织思维并在写作中反映这种组织提供了强大的工具。

练习

练习 2.1. 在一个 Haskell 程序文件中(一个新的文件,文件名以.hs结尾),定义函数 Image。就像我们为函数square做的那样,给出类型签名和函数定义。然后将该文件加载到 GHCi 中,检查 f (0) 是否等于 1,f (1) 是否约等于 1.414,以及 f (3) 是否等于 2。

练习 2.2. 假设从地面将一块石头以 30 米/秒的速度直线上抛。忽略空气阻力,找到石头的高度 y(t) 作为时间的函数。

在你的程序文件first.hs中添加一个函数

yRock30 :: Double -> Double

该函数接受时间(从抛出石头开始的秒数)作为输入,输出石头的高度(单位:米)。

练习 2.3. 继续使用石头的例子,编写一个函数

vRock30 :: Double -> Double

该函数接受时间(从抛出石头开始的秒数)作为输入,输出石头的上升速度(单位:米/秒)。(向下的速度应该返回负数。)

练习 2.4. 定义一个函数sinDeg,计算给定角度(单位:度)的正弦值。通过计算sinDeg 30来测试你的函数。

练习 2.5. 编写 Haskell 函数定义,表示以下数学函数。在每种情况下,写出类型签名(每个函数的类型应为Double -> Double)和函数定义。你需要为这些函数选择其他名称,因为 Haskell 函数必须以小写字母开头。不要使用超过两个括号嵌套的函数。

Image

(b) g(y) = e^y + 8^(y)

Image

Image

Image

Image

Image

Image

练习 2.6.

(a) 将Image表示为匿名函数。

(b) 写一个表达式,将部分(a)中的匿名函数应用于参数 0.8。你从 GHCi 得到什么结果?

第三章:类型与实体

图片

每个表达式都有一个类型的这一理念是 Haskell 的核心。Haskell 提供了多个预定义的基本类型,并且有一套用于定义我们自己的类型的系统。在本章中,我们将讨论一些内建的类型,而在 第十章 中,我们将看到如何创建我们自己的类型。

基本类型

类型反映了信息的性质。例如,在物理学中,我们需要知道某物是标量还是向量。这是两种不同的类型。将标量与向量相加是没有意义的,如果我们使用一个好的类型系统,计算机可以防止我们犯这个错误。

表 3-1 显示了 Haskell 最重要的基本类型。

表 3-1: Haskell 的基本类型

类型 描述 示例
Bool 布尔值 False, True
Char 字符 'h', '7'
String 字符串 "101 N. College Ave."
Int 小型(机器精度)整数 42
Integer 任意大小的整数 18446744073709551616
Float 单精度浮点数 0.33333334
Double 双精度浮点数 0.3333333333333333

Bool 类型用于表示真或假的值,比如比较的结果。例如,3 > 4 的结果是 False

Prelude> 3 > 4
False

Char 类型用于单个字符。String 类型用于一系列字符。IntIntegerFloatDouble 类型用于表示数字。

让我们更仔细地看看这些类型。

布尔类型

Bool 类型只有两个可能的值:FalseTrue。该类型用于表示可能为真或假的声明。

Haskell 有一个 if-then-else 表达式,它的值取决于布尔值。表达式的形式是 if b then c else a。这里 b 是一个布尔类型的表达式,称为 条件c结果a替代结果。Haskell 的类型系统要求不仅 b 的类型必须是 Bool,而且结果 c 和替代结果 a 必须具有相同的类型(可以是任意类型,Bool 或其他类型)。如果条件 b 计算结果为 True,整个 if-then-else 表达式的结果为 c;如果条件 b 计算结果为 False,整个 if-then-else 表达式的结果为 a

如果你熟悉像 Python 或 C 这样的命令式语言,可能会发现,Haskell 的if-then-else结构是一个表达式,而不是一个语句。表达式会计算出一个值。在命令式语言中,if-then结构通常是语句,当条件为真时执行,否则被忽略。在命令式语言中,else子句是可选的;也就是说,只有在条件为假时需要执行某些语句时,才会使用else子句。因为在函数式语言中,if-then-else结构是一个表达式,所以else子句是强制性的,而不是可选的。无论条件为真还是为假,都必须返回某个值。

作为if-then-else表达式的一个示例,考虑以下函数(有时称为Heaviside 阶跃函数单位阶跃函数):

Image

我们可以使用if-then-else结构在 Haskell 中为这个函数编写定义。在 Haskell 中,我们不允许常量或函数名以大写字母开头(回忆一下上一章对变量标识符的讨论),所以我们将这个函数命名为stepFunction

stepFunction :: Double -> Double
stepFunction x = if x <= 0
                 then 0
                 else 1

函数stepFunction接受一个Double类型的输入(在定义中叫做x),并返回一个Double类型的输出。表达式x <= 0是条件,表达式0是结果,表达式1是替代。

Prelude 提供了一些与布尔值一起使用的函数。第一个是not,它的类型是Bool -> Bool,意味着它接受一个布尔值作为输入并返回另一个布尔值作为输出。函数not如果输入为False,返回True,如果输入为True,返回False。你可以在 GHCi 中自己验证这一点,只需输入:

Prelude> not False
True

或者

Prelude> not True
False

在 GHCi 提示符下。

正如你在第二章中看到的,GHCi 有一个:type命令(简写为:t),用于询问某个东西的类型。你可以通过输入以下命令来询问 GHCi not的类型:

Prelude> :t not
not :: Bool -> Bool

在 GHCi 提示符下,GHCi 命令以冒号开头的命令不是 Haskell 语言的一部分。你不能在 Haskell 程序文件中使用冒号命令。

布尔与操作符&&接受两个布尔值作为输入,并返回一个布尔值作为输出。当两个输入都为True时,输出为True,否则输出为False。表 3-2 描述了&&操作符的行为。

表 3-2: 与操作符定义

x y x && y
False False False
False True False
True False False
True True True

布尔或操作符||接受两个布尔值作为输入,并返回一个布尔值作为输出。当两个输入都为False时,输出为False,否则输出为True。表 3-3 描述了||操作符的行为。

表 3-3: 或操作符定义

x y x &#124;&#124; y
False False False
False True True
True False True
True True True

这些运算符在表 1-2 中列出了它们的优先级和结合性。你可以在 GHCi 中试验它们,评估像这样的表达式:

Prelude> True || False && True
True

在 GHCi 提示符下。

字符类型

Char类型用于单个字符,包括大写和小写字母、数字以及一些特殊字符(比如换行符,它会产生一个新的文本行)。以下是一些字符定义的示例:

ticTacToeMarker :: Char
ticTacToeMarker = 'X'

newLine :: Char
newLine = '\n'

很少有理由做这些定义,因为我们可以在任何需要使用newLine的地方,轻松使用'\n',它占用的空间更小。我们这里这样做仅仅是为了展示术语'X'与类型Char之间的关系。如上面的示例所示,字符可以通过将单个字母或数字用单引号括起来来形成。

字符串类型

字符串是一个字符序列。(在第五章中,我们将了解到字符串是一个字符列表,其中列表有明确的含义。)以下是一些示例:

hello :: String
hello = "Hello, world!"

errorMessage :: String
errorMessage = "Can't take the square root of a Boolean!"

这些定义不像之前提到的字符定义那样没用,因为虽然"Hello, world!"hello完全等价,但名字hello至少比它所表示的字符串更短且更容易输入。如果在程序中的多个不同位置需要这样的字符串,那么定义像hello这样的名字是有意义的。要从一系列字符形成一个字符串,我们将字符序列用双引号括起来。

数值类型

基本的数值类型有IntIntegerFloatDoubleInt类型用于小整数。32 位机器会用 32 位表示一个Int,这可以表示最多几十亿的数字。64 位机器会用 64 位表示一个Int,这可以表示大约 10¹⁸的数字。Integer类型用于任意大小的整数。计算机会根据需要使用足够的位数来精确表示Integer。在我的 64 位机器上,得到以下结果:

Prelude> 10¹⁸ :: Int
1000000000000000000
Prelude> 10¹⁸ :: Integer
1000000000000000000
Prelude> 10¹⁹ :: Int
-8446744073709551616
Prelude> 10¹⁹ :: Integer
10000000000000000000

请注意,我并没有收到关于Int类型过高的错误信息;我只得到错误的结果。Int类型适用于几乎任何你要求计算机执行的计数任务。计算机无法计数到 10¹⁸,因为它需要太长时间。

Float类型用于对实数的近似表示,精度大约为 7 位小数。Double类型用于对实数的近似表示,精度大约为 15 位小数。除非使用别人写的库,并且该库使用Float,否则我总是选择Double来表示实数。

表 3-1 最右列中的数值示例可以是所示类型的表达式,但单独一个表达式,如42不一定具有Int类型。具体来说,FalseTrue必须是Bool类型,'h''7'必须是Char类型,"101 N. College Ave."必须是String类型。另一方面,42可以是IntIntegerFloatDouble类型。明确这种歧义的原因之一是,在 Haskell 程序中给每个定义的名称提供类型签名。如果没有类型签名,编译器无法确定像18446744073709551616这样的数字应使用哪种类型。任何四种数字类型都可以表示这个数字,但只有Integer可以准确地表示这个数字。Haskell 中的数字类型的复杂性与一种更高级的语言特性——类型类相关,我们将在第八章中讨论。

表 3-1 中的四种数字类型并不是 Prelude 中唯一的数字类型。Prelude 中还包括一个Rational类型,用于有理数,在本书中我们不会使用它,但如果你感兴趣,可以自行探索。有一个名为Data.Complex的库模块提供了复数类型,我们在本书中不会使用复数。

函数类型

Haskell 提供了几种方法来从现有类型中构造新类型。给定任意两个类型ab,就有一个类型a -> b,用于表示接受类型a的表达式作为输入并产生类型b的表达式作为输出的函数。这里是一个例子:

isX :: Char -> Bool
isX c = c == 'X'

函数isX接受一个字符作为输入,并返回一个布尔值作为输出。如果输入字符是'X',则函数返回True,否则返回False。加上括号有助于阅读函数定义。这个定义等价于

isX c = (c == 'X')

一般来说,在定义中,等号(=)左边的名称是正在定义的名称(在本例中是isX),等号右边的表达式是定义的主体。表达式c == 'X'使用来自表 1-2 的等式运算符==,用于检查输入字符c是否与'X'相同。

如果我们将这个函数定义放入 Haskell 程序文件(例如,FunctionType.hs)并加载到 GHCi 中,

Prelude> :l FunctionType.hs
[1 of 1] Compiling Main            ( FunctionType.hs, interpreted )
Ok, one module loaded.

我们可以询问事物的类型。如果我们询问isX的类型,

*Main> :t isX
isX :: Char -> Bool

我们看到了我们在类型签名中写的内容。在 GHCi 中,我们也可以查询isX 't'的类型:

*Main> :t isX 't'
isX 't' :: Bool

这是合理的,因为表达式isX 't'表示将函数 isX 应用于字符参数't'。因此,类型表示的是isX的输出类型,即Bool

我们还可以询问 GHCi 关于isX 't'(与表达式的类型不同)。如果我们在 GHCi 提示符下输入isX 't'

*Main> isX 't'
False

我们看到isX 't'的值是False,因为't'不等于'X'

这是一个类型为Bool -> String的函数示例:

bagFeeMessage :: Bool -> String
bagFeeMessage checkingBags = if checkingBags
                             then "There is a $100 fee."
                             else "There is no fee."

函数 bagFeeMessage 以布尔值作为输入,并返回一个字符串作为输出。输入的布尔值(称为 checkingBags)表示一个答案(TrueFalse),用于回答乘客是否托运行李。将多个单词连接在一起且每个单词首字母大写的命名方式,在 Haskell 编程中很常见。

还有一种替代方式可以编写 bagFeeMessage 函数,它使用了 Haskell 中的一个功能叫做 模式匹配。一些数据类型具有一个或多个模式,值会符合其中的某个模式。对于 Bool 类型的模式匹配的思想是,唯一可能的值是 FalseTrue,那么为何不为每个可能的输入给出输出呢?实现模式匹配的基本方式是使用 case-of 构造。下面是使用模式匹配的函数样式:

bagFeeMessage2 :: Bool -> String
bagFeeMessage2 checkingBags = case checkingBags of
                                False -> "There is no fee."
                                True  -> "There is a $100 fee."

这看起来与 if-then-else 构造没有什么不同,但 case-of 构造更为通用,因为它不仅可以与 Bool 类型一起使用,还可以与其他数据类型一起使用。例如,在第五章中,我们将看到每个列表都属于两种模式之一,可以通过 case-of 构造来区分。

尽管 case-of 构造是进行模式匹配的基本方式,Haskell 还为特殊情况提供了一些语法糖,在这种情况下我们希望对函数的输入进行模式匹配。

bagFeeMessage3 :: Bool -> String
bagFeeMessage3 False = "There is no fee."
bagFeeMessage3 True  = "There is a $100 fee."

通过对输入进行模式匹配,我们避免了使用 if-then-else 构造。而且,我们不再需要变量 checkingBags,它用于保存输入值。

总结

Haskell 内置了类型和工具来创建我们自己的类型。类型的目的是描述数据的含义。本章介绍了七种最常见的内置类型:BoolCharStringIntIntegerFloatDouble。它还涉及了函数类型,这对语言来说非常重要,因为函数在其中扮演着核心角色。我们初步接触了模式匹配,包括使用 case-of 构造和通过对输入进行模式匹配。在下一章中,我们将开始我们的物理学工作,从一维运动开始。

练习

练习 3.1. 为以下表达式添加括号,以指示 Haskell 的优先级和结合性规则(表 1-2)如何评估这些表达式。有些表达式是格式正确的,并且具有明确的类型。对于这些表达式,给出(整个)表达式的类型。还要识别格式不正确的表达式(因此没有明确类型),并说明其错误所在。

(a) False || True && False || True

(b) 2 / 3 / 4 == 4 / 3 / 2

(c) 7 - 5 / 4 > 6 || 2 ^ 5 - 1 == 31

(d) 2 < 3 < 4

(e) 2 < 3 && 3 < 4

(f) 2 && 3 < 4

练习 3.2. 为以下数学函数编写 Haskell 函数定义。对于每个函数,编写类型签名(每个函数的类型应为Double -> Double)和函数定义。

(a) Image

(b) Image

练习 3.3. 定义一个函数isXorY,并提供类型签名

isXorY :: Char -> Bool

如果输入字符是'X''Y'(大写的 X 或 Y),则返回True,否则返回False。通过将其加载到 GHCi 中并给定'X''Y''Z'等输入来测试你的函数。

练习 3.4. 定义一个函数bagFee,并提供类型签名

bagFee :: Bool -> Int

该函数将返回整数100,如果此人正在检查行李,则返回整数0,如果没有。对于此函数,使用if-then-else结构。然后定义第二个函数bagFee2,具有相同的类型签名,使用模式匹配输入而不是if-then-else结构。

练习 3.5. 定义一个函数greaterThan50,并提供类型签名

greaterThan50 :: Integer -> Bool

如果给定的整数大于 50,则返回True,否则返回False

练习 3.6. 定义一个函数amazingCurve,并提供类型签名

amazingCurve :: Int -> Int

该函数将学生考试分数翻倍。如果翻倍后的新分数大于 100,函数应输出100

练习 3.7. 使用你在练习 3.4 中编写的bagFee定义,表达式bagFee False类型是什么?使用该bagFee定义,表达式bagFee False是什么?

练习 3.8. “为每个函数添加类型签名。” 在 Haskell 中,良好的实践是为程序文件中定义的每个函数添加类型签名。我们一直在这样做。类型签名作为一种文档形式,供程序读者(包括你自己)使用。

为下面代码中的每个定义添加类型签名:

circleRadius = 3.5

cot x = 1 / tan x

fe epsilon = epsilon * tan (epsilon * pi / 2)

fo epsilon = -epsilon * cot (epsilon * pi / 2)

g nu epsilon = sqrt (nu**2 - epsilon**2)

练习 3.9. 具有类型Bool -> Bool的函数只有有限个。它们有多少个?这些函数的名字应该是什么?具有类型Bool -> Bool -> Bool的函数有多少个?

练习 3.10. 构造一个表达式,使用TrueFalse&&||,如果||的优先级高于&&,其结果会有所不同。

第四章:描述运动

Image

通过位置、速度和加速度来描述物体的运动被称为运动学。在本章中,我们将简要回顾一维运动学,并展示 Haskell 语言如何自然地编码其思想和方程。我们将使用在第二章中介绍的 Haskell 函数,以及在第三章中介绍的类型。由于运动学方程大多是定义性的,它们几乎与 Haskell 函数一一对应。

空气轨道上的位置和速度

你见过空气轨道吗?空气轨道是一个有趣的玩具,或者,如果你更严肃一些,它也是一件实验设备。它由一条长水平轨道(大约 2 或 3 米长)组成,轨道上有小孔,可以让空气从轨道中喷出。一个没有轮子的小车(大约 5 厘米宽、10 厘米长)沿着这条空气轨道滑行。空气减少了小车与轨道之间的摩擦,使得小车可以在空气轨道上自由滑动。轨道的横截面设计使得小车只能沿轨道的长度来回滑动;小车不能横向滑动或上下移动。

我们可以在空气轨道上做标记,以便讨论小车的位置。假设我们有一条已经用米标记过的空气轨道。对于小车在空气轨道上的特定运动,我们定义x为一个函数,将每个时间t与该时刻小车的位置关联起来。我们说x(t)是小车在时刻t的位置。

速度被定义为位置变化的速率。某个时间区间内的平均速度,该时间区间从时间t[0]开始,到时间t[1]结束,为:

Image

平均速度是位置变化除以时间变化。

使用 Haskell 编程语言的一个优点是,物理方程与我们用来描述它们的代码几乎可以一一对应。在 Haskell 中,以下几行代码出现在源文件中,它们的意义与方程 4.1 相同:

averageVelocity :: Time -> Time -> PositionFunction -> Velocity
averageVelocity t0 t1 x = (x t1 - x t0) / (t1 - t0)

第一行 Haskell 代码是一个类型签名,表示averageVelocity是一个函数,它接受两个时间和一个位置函数作为输入,并输出一个速度。我们可以使用箭头(->)将输入连接起来。最后一个术语是输出类型,其他所有术语都是输入类型。这个符号表示法有一个更深层的原因,我们将在第六章和第九章中进行探讨。

上面 Haskell 代码的第二行是函数 averageVelocity 的定义。该定义表明,如果我们将 t0 作为第一个时间,t1 作为第二个时间,x 作为位置函数,则速度由等号右边的表达式给出。输入 t0t1 是数字,而输入 x 是一个函数。在 Haskell 中,将函数作为输入传递给其他函数是一种常见做法;我们将在 第六章 中详细讨论这一点。

表 4-1 显示了数学符号和 Haskell 符号的对比。

表 4-1: 数学符号与 Haskell 符号的对比

数学符号 Haskell 符号
t[0] t0
t[1] t1
x x
x(t[0]) x t0
x(t[1]) x t1
Image averageVelocity

正如我们在 第一章 中看到的,应用函数到参数时不需要括号。代码中的 x 是一个函数,就像方程 4.1 中的 x 一样。当我们写 x(t[0]) 时,我们的意思是将函数 x 应用到时间 t[0](或在 t[0] 处求值)。类似地,当我们写 x t0 时,我们的意思是将函数 x 应用到时间 t0(或在 t0 处求值)。函数在 Haskell 中扮演着如此核心的角色,以至于名称的并列意味着第一个是函数,第二个是参数,而该函数将应用于这个参数。

该符号 Image 明确表示平均速度依赖于时间间隔的初始时间 t[0] 和最终时间 t[1],但对位置函数的依赖是隐含的,并未在方程 4.1 中显示。Haskell 代码显式地展示了所有依赖关系。

物理量的类型

如果我们可以在某一时刻讨论速度,而不是在时间间隔内讨论速度,这将使我们的思维更简洁。我们可以通过使用时间间隔中心的时间和间隔长度来标记平均速度,而不是使用开始和结束时间,从而朝着这个方向迈出一步。

Image

在 Haskell 中,方程 4.2 看起来如下:

averageVelocity2 :: Time -> TimeInterval -> PositionFunction
                 -> Velocity
averageVelocity2 t dt x = (x (t + dt/2) - x (t - dt/2)) / dt

在这里,我们确实需要将 t + dt/2 括起来,以便在应用函数 x 之前先进行 tdt/2 的加法运算。

请注意,在上面的代码中,类型签名中的类型与定义中的参数按相同顺序匹配,如 表 4-2 中所强调。

表 4-2: 函数 average Velocity2 的参数与类型匹配

参数 类型
t 时间
dt 时间间隔
x 位置函数

到这一步,我们只处理一维运动,因此时间、位置和速度都由数字表示。我们通过以下几行告诉 Haskell 编译器这一点,这些行被称为 类型同义词

type R = Double

type Time         = R
type TimeInterval = R
type Position     = R
type Velocity     = R

type PositionFunction = Time -> Position
type VelocityFunction = Time -> Velocity

上面几行中 Haskell 默认能理解的唯一类型是Double。我更倾向于把它看作实数类型(并非每个实数都能用Double表示,但我们愿意进行近似计算),因此我更喜欢使用R而不是Double这个名称。

第一行表示,每当我使用类型R时,它和Double的含义是相同的。接下来的四行表示时间、时间间隔、位置和速度在这一点上都只是实数。最后的两行定义了函数类型。类型PositionFunction是一个函数类型,它接受时间作为输入并返回位置作为输出。回忆一下,上面的参数x是一个具有此类型的函数。由于TimeR是相同的,PositionR也是相同的,因此PositionFunction类型等同于R -> R,它接受一个实数作为输入并输出一个实数。出于类似的原因,VelocityFunction类型也与函数类型R -> R相同。

类型同义词只是为现有类型提供了一个额外的名称。编译器会把DoubleRPositionVelocity视为相同,并且在我们尝试在需要Position的地方使用Velocity时,不会发出警告。在第十章中,我们将介绍一种定义新类型的方法,这种新类型与所有现有类型不同,并允许编译器检查我们是否混淆了不同的类型。此外,还有一个 Haskell 包叫做unitshttps://hackage.haskell.org/package/units),专门用于将物理单位(如米每秒)附加到数值量上。

引入导数

如果在使时间间隔Δt变得越来越短时,我们发现平均速度趋向于某个特定值,那么我们就称这个值为瞬时速度

图片

差商的极限出现得很频繁,以至于它被赋予了导数这个名称。给定一个一元函数x,它的导数,记作Dxx'或 图片,是一个一元函数,定义如下:

图片

处理连续时间

极限和导数的数学定义要求实数的连续性,而这种连续性在物理世界中可能并不存在。当我们假设时间是连续的时候,实际上有些理想化的成分。时间确实看起来是连续的,没有任何直接的测量结果表明时间是离散的,但基于量子理论,有理由相信在某些极短的尺度上,时间可能并不像完美的连续体那样表现。

为了推测量子效应可能干扰时间连续性的时间尺度,我们可以使用维度分析,即将与情况相关的参数结合,构造出具有时间维度的量。我们还没有提到任何关于情况的内容,因此我们唯一能用的就是物理学中的基本常数,即牛顿引力常数G,普朗克常数Image,以及真空中的光速c。只有一种方法可以将这些基本常数的幂相乘,得出一个具有时间维度的结果。得到的时间被称为普朗克时间,其表达式如下:

Image

普朗克时间比目前可以探测到的最小时间尺度小得多,数量级上相差很多。对于今天实验上可探测的物理,时间表现为连续的,导数也没有过时的危险。

我们可以说,瞬时速度是位置的导数。

Image

请注意,方程式 4.5 是一个函数的等式:瞬时速度函数位于等式左边,位置函数的导数位于右边。当两个函数相等时,对于相等的输入,它们会给出相等的结果,因此我们也可以写成

Image

对于任意时刻t,右侧是函数Dx在时刻t的值。我们可以将导数算子视为接受整个位置函数作为输入并返回速度函数作为输出。

更常见的表示法是

Image

用以定义速度。方程式 4.5 更加简洁,但方程式 4.5、4.6 和 4.7 的含义是相同的。我们说v(t)是时刻t的车速。请注意,空气轨道上车速可以是负的(意味着位置在减少)或正的(意味着位置在增加)。

Haskell 中的导数

导数将一个函数作为输入,并返回一个函数作为输出。换句话说,导数是从函数到函数的映射。一个接受另一个函数作为输入或返回一个函数作为输出的函数被称为高阶函数。如果函数作为其他函数的输入和输出这一概念对你来说是新的,需要通过一些练习和示例来适应,但我向你保证,这是值得的。在物理学中,有许多概念,导数只是其中之一,它们自然地表现为高阶函数。第六章完全讲解了这种高阶函数。

导数的一种可能的类型同义词如下所示:

type Derivative = (R -> R) -> R -> R

由于箭头是右结合的,最右边的箭头具有最高优先级,类型(R -> R) -> R -> R(R -> R) -> (R -> R)是等价的。

我们可以像这样在 Haskell 中编写数值导数:

derivative :: R -> Derivative
derivative dt x t = (x (t + dt/2) - x (t - dt/2)) / dt

这种数值导数并不采用极限,而是使用由用户提供的小间隔dt。如果间隔足够小,结果应该是导数的良好近似值。

让我们像处理函数averageVelocity2一样,通过参数和类型来玩匹配游戏,看看derivative函数。乍一看,似乎dt的类型是Rx的类型是Derivative,而t则没有类型。这是没有意义的;问题在于我们需要展开Derivative类型。展开后,derivative具有以下类型:

derivative :: R -> (R -> R) -> R -> R

现在,dt的类型是Rx的类型是R -> Rt的类型是R,最终的R是返回类型。

在进行匹配游戏时,我们认为derivative是一个具有三个输入和一个输出的函数。箭头符号可能看起来是指定一个具有三个输入的函数的一种奇怪方式。这个符号有更深的含义,我们将在这里简要讨论,并在第六章中进行更详细的阐述。

由于箭头右结合,编译器认为以下三种类型是相同的:

  • R -> (R -> R) -> R -> R

  • R -> (R -> R) -> (R -> R)

  • R -> ((R -> R) -> (R -> R))

使用匹配游戏中的思维方式,似乎第一个类型需要三个输入,第二个类型需要两个输入,第三个类型需要一个输入。

有三种方式来思考derivative函数:

  • derivative接受三个输入,类型分别为RR -> RR,并产生一个类型为R的输出。通过这种思维方式,derivative接受一个时间间隔、一个位置函数和一个时间,然后返回该时刻的数值速度。这是我们在匹配游戏中的思维方式。

  • derivative接受两个输入,类型分别为RR -> R,并产生一个类型为R -> R的输出。通过这种思维方式,derivative接受一个时间间隔和一个位置函数,并返回一个速度函数。

  • derivative接受一个类型为R的输入(即dt),并生成一个类型为(R -> R) -> R -> R(或者类型为Derivative)的输出。这是编译器的理解方式。

这三种思维方式在数学上是等价的,但它们在大脑中的感觉方式不同。第二种思维方式是我最喜欢的,因为我喜欢将导数看作是一个接受函数作为输入并返回函数作为输出的过程。

将一个具有两个输入的函数转换为一个具有一个输入的函数,并且其输出为另一个函数,这种方式被称为柯里化,以逻辑学家哈斯凯尔·柯里(他将自己的名字赠予了我们所使用的编程语言)命名。柯里化在第六章和第九章中有更详细的讨论。柯里化允许编译器将所有函数视为只有一个输入和一个输出,前提是输入和/或输出可能是一个函数。

模拟汽车的位置和速度

假设我们有一个汽车位置函数

Image

其中 t 以秒为单位,x[C] 以米为单位。相应的 Haskell 代码如下:

carPosition :: Time -> Position
carPosition t = cos t

使用方程 4.5,我们可以找到汽车的速度函数。

Image

相应的 Haskell 代码可能如下所示:

carVelocity :: Time -> Velocity
carVelocity = derivative 0.01 carPosition

在 Haskell 代码中,derivative 0.01扮演着数学表达式中导数算符D的角色。这两者不完全相同,因为D是真正的数学导数,而derivative 0.01只是一个数值导数,但通过使用它,我们可以得到不错的近似结果,而且我们可以通过使用小于0.01的数字来提高结果的精度。此外,derivative 0.01 sin是一个完全有效的函数,其类型为R -> R,在 Haskell 语言中与函数cos(同样类型为R -> R)一样合法。它可以被求值、绘制图形、求导、积分,或者在任何需要类型为R -> R的函数的地方使用。

方程 4.9 是函数的等式,相应的 Haskell 代码定义了函数carVelocity,且不使用任何函数参数。这就是在第二章中介绍的点自由风格。

基于方程 4.6 编写 Haskell 代码也是有效的。此时,数学方程看起来会像下面这样:

Image

相应的 Haskell 代码如下所示:

carVelocity' :: Time -> Velocity
carVelocity' t = derivative 0.01 carPosition t

我们使用撇号来表示写函数的另一种方式。对计算机而言,carVelocitycarVelocity'表示相同的含义。区别只在于符号的偏好。在代码中,我们经常使用撇号来表示另一种写法。这个撇号与导数无关。

汽车的位置函数是通过解析给出的,因此我们可以通过解析求导,并写出汽车速度的显式方程。

Image

我们也可以用 Haskell 编写如下代码:

carVelocityAnalytic :: Time -> Velocity
carVelocityAnalytic t = -sin t

但在本书中,我们并不要求计算机做符号代数运算或解析求导。函数carVelocityAnalytic并不是carVelocitycarVelocity'相同的函数。carVelocity 2的数值接近但不完全等于carVelocityAnalytic 2的数值。在本书中,我们仅要求计算机完成科学计算器能做的任务。然而,我们会发现,Haskell 的符号会通过关注表达式的类型、高阶函数带来的简洁性以及避免可变状态的简洁语言帮助我们进行思考。

还有一种更好的方式来用 Haskell 表达方程 4.5。函数velFromPos接受任何位置函数作为输入,并提供相应的速度函数作为输出。

velFromPos :: R                   -- dt
           -> (Time -> Position)  -- position function
           -> (Time -> Velocity)  -- velocity function
velFromPos dt x = derivative dt x

我们可以看到,求取速度从位置的函数实际上就是我们之前定义的导数函数。还需要注意的是,我们可以将类型签名分布在多行上。这通常是一个好习惯,它给了代码编写者一个机会,在签名的每个类型旁边添加简短的注释,以解释其含义。

如果速度恰好是恒定的,比如v[0],我们可以对方程 4.5 或 4.7 的两边进行积分,得到:

v[0]t = x(t) – x(0)

如果速度恒定,则位置是时间的线性函数。

x(t) = v[0]t + x(0)

这是对应的 Haskell 代码:

positionCV :: Position -> Velocity -> Time -> Position
positionCV x0 v0 t = v0 * t + x0

名字末尾的CV是“恒定速度”(constant velocity)的缩写。再次注意,我们可以有不同的方式来理解类型:我们可以将positionCV看作一个接受三个参数并返回Position的函数,也可以看作一个接受两个参数并返回一个函数Time -> Position,或者看作一个接受一个参数并返回一个函数Velocity -> Time -> Position。表达式positionCV 5 10 2表示一个物体在时间 2 秒时的位置,如果它以 10 米/秒的恒定速度移动,且在时间为 0 时的位置为 5 米。表达式positionCV 5 10表示描述一个物体以 10 米/秒的恒定速度移动、且在时间为 0 时位置为 5 米的PositionFunction

在日常语言中,我们经常将速度速率这两个术语互换使用。物理学语言对这两个术语做了技术性的区分。速率是速度的大小(绝对值)。速率永远不会是负数。虽然速率(即物体的移动速度)是一个更容易理解的概念,但速度在运动理论中要重要得多。速度包含了比速率更多的信息,因为它不仅告诉我们物体的移动速度,还能提供物体的运动方向。一个被垂直向上抛的石头,其速度会在向上运动和向下运动时都发生规律性的变化。而速率则在向上运动时减小,在向下运动时增大,这使得我们不必要地将其看作两个独立的过程。有时候,讨论速率是方便的,并且为其定义一个概念和一个术语是非常值得的。当我们讨论多维运动时,速度需要用一个向量来描述,而速率则依然只是一个数值。

建模加速度

加速度被定义为速度变化的速率。我们定义a为一个函数,它将每个时间t与该时刻的速度变化速率相联系。用微积分的语言,我们可以写出:

Image

或者

Image

定义加速度。方程 4.12 更简洁,但这两个方程的含义是相同的。我们说* a (t)是汽车在时间t*时的加速度。

本章只处理一维运动,因此我们用一个数字表示加速度。

type Acceleration = R

方程式 4.12 可以通过一个名为 accFromVel 的函数进行编码,该函数根据速度函数生成加速度函数。

accFromVel :: R                       -- dt
           -> (Time -> Velocity)      -- velocity function
           -> (Time -> Acceleration)  -- acceleration function
accFromVel = derivative

再次强调,这个函数只是导数。在这里,我们使用无点风格来强调这两个函数的相等性。

如果加速度恰好是常数,比如 a[0],我们可以对方程 4.12 或 4.13 的两边进行积分,得到:

a[0]t = v(t) – v(0)

如果加速度是常数,则速度是时间的线性函数。

Image

这是方程式 4.14 的 Haskell 代码:

velocityCA :: Velocity -> Acceleration -> Time -> Velocity
velocityCA v0 a0 t = a0 * t + v0

名称末尾的 CA 是“恒定加速度”的缩写。

为了相信我们真正了解一个运动物体的情况,我们需要一个表达式来给出物体的位置随时间变化的关系。由于位置是速度的反导数或积分,我们可以通过对方程式 4.14 两边进行积分,得到这种关系:

Image

如果加速度是常数,则位置是时间的二次函数。

Image

这是方程式 4.15 的 Haskell 代码:

positionCA :: Position -> Velocity -> Acceleration
           -> Time -> Position
positionCA x0 v0 a0 t = a0 * t**2 / 2 + v0 * t + x0

方程式 4.14 和 4.15 被称为恒定加速度方程。它们在典型的入门物理课程中反复使用。稍后我们将学习一些技术来处理加速度不是常数的情况。

时间、位置、速度和加速度之间的关系被称为运动学,即运动的描述。这些是描述气轨上汽车运动所需的量。在我们有一个能解释运动原因的理论之前,我们需要引入其他概念,如力和质量。

近似算法和有限精度

在数学中,导数由方程 4.4 中的极限定义。在许多情况下,可以精确地计算出一个显式指定函数的数学导数。本章中定义的数值derivative并不采用极限,而是依赖于一个小但有限的ϵ。因此,数值导数计算的是函数导数的近似值。我们将从有限的ϵ值计算导数的规则称为近似算法

近似算法的使用是我们计算中不精确性的第二个来源。在第一章中,我们看到R类型或Double类型的数字通常不能被计算机精确表示。有些数字可以精确表示,但即使是看似无害的数字 0.1 在计算机中也不能精确表示为R。这是因为像表 1-4 中的数字 0.2 一样,0.1 需要无限的二进制扩展(0.0001100110011...),而计算机会在某个点截断它。这通常不会成为问题,因为R提供大约 15 位有效数字的精度,足以满足我们的需求,但仍然是有限精度。如果我们将一个非常小的数加到一个非常大的数中,

Prelude> 1e9 + 1e-9
1.0e9

计算机直接丢弃了这个非常小的数字。

即使计算机没有极端地丢弃一个小数,当它被加到一个大数时,小数的相对精度也会变差。例如,表 4-3 中的每个数字,从 1/3 到 1/3 × 10¹⁸,都有大约 15 位小数的精度,这通过它的表达式中的三的个数得以指示。

表 4-3: 分数精确到约 15 位小数的计算结果

表达式 计算结果
1/3 0.3333333333333333
1/3000 3.333333333333333e-4
1/3e6 3.3333333333333335e-7
1/3e9 3.333333333333333e-10
1/3e12 3.3333333333333334e-13
1/3e15 3.333333333333333e-16
1/3e18 3.3333333333333334e-19

然而,当这些数值与相对较大的数字 1 相加时,保留的三的个数会有所不同,如表 4-4 所示,这取决于被加的两个数的相对大小。例如,当加上 1/3 × 10⁹时,只有它的 15 个三位数中的 6 个被保留。

表 4-4: 将小数加到相对较大的数字上时,如何减少小数的相对精度

表达式 计算结果
1 + 1/3 1.3333333333333333
1 + 1/3000 1.0003333333333333
1 + 1/3e6 1.0000003333333334
1 + 1/3e9 1.0000000003333334
1 + 1/3e12 1.0000000000003333
1 + 1/3e15 1.0000000000000004
1 + 1/3e18 1.0

将一个小数加到一个大数上的过程是导数概念的核心。我们希望ϵ很小,但R仅是一个近似值,意味着我们不希望它太小。

表 4-5 展示了函数f的数值导数的相对误差,其中f(x) = x⁴/4。精确的导数是Df(x) = x³。导数在x = 1 时求值,因此精确结果是 1。表中的每一行显示了ϵ的相对误差,其范围从 1 到 10^(–18)。

表 4-5: 数值导数的相对误差,随着ϵ变小而减小,随着ϵ进一步减小而增大

表达式 计算结果
derivative 1 (\x -> x**4 / 4) 1 - 1 0.25
derivative 1e-3 (\x -> x**4 / 4) 1 - 1 2.499998827953931e-7
derivative 1e-6 (\x -> x**4 / 4) 1 - 1 1.000088900582341e-12
derivative 1e-9 (\x -> x**4 / 4) 1 - 1 8.274037099909037e-8
derivative 1e-12 (\x -> x**4 / 4) 1 - 1 8.890058234101161e-5
derivative 1e-15 (\x -> x**4 / 4) 1 - 1 -7.992778373592246e-4
derivative 1e-18 (\x -> x**4 / 4) 1 - 1 -1.0

随着ϵ从 1 下降到 10^(–6),误差变小。在这些ϵ值下,导数算法的近似性质对误差的贡献大于计算机使用的有限精度。但随着ϵ继续减小,表中的误差变大。在这些ϵ值下,计算和表示数字时使用的有限精度对误差的贡献大于计算导数的近似算法。

在数值导数的情况下,有限精度希望ϵ较大,以保持其相对精度,但算法希望ϵ较小,以逼近真实的导数。最佳结果出现在中间值附近,对于表 4-5 中的情况,ϵ = 10^(–6)。

这两种不准确来源——有限精度和近似算法——将在我们计算物理学的旅程中一直伴随我们。稍后我们介绍的求解微分方程的算法也是近似算法,它依赖于小但有限的步骤来求解连续的微分方程。我们将介绍一些经验法则来选择这些小的有限参数。本书的态度并非深入研究数值分析这一有趣的学科,也不是对不准确性采取警惕的立场,而是简单地意识到近似计算的本质,以便我们能够得出有意义的结果。

小结

本章介绍了位置、速度、加速度和时间的概念,以及它们之间的关系,这些关系通过导数的数学概念来表述。我们看到如何将各种运动学方程编码为 Haskell 语言。在下一章,我们将介绍列表,它们在函数式编程中的作用几乎与函数同等重要,因为它们是大多数迭代的基础。

习题

习题 4.1: 考虑以下函数:

Image

该函数的导数是Df(x) = x。在这种情况下,Df是实数上的恒等函数。因为

Image

即使在我们取极限之前,我们的数值 derivative 应该对我们使用的任何 ϵ 都给出精确的结果。编写 Haskell 代码,使用 derivative 10derivative 1derivative 0.1 来计算 f 的导数。你应该会发现,derivative 10derivative 1 会准确地得到恒等函数,而 derivative 0.1 接近但并不完全准确。为什么 derivative 0.1 在实数上无法精确产生恒等函数?

练习 4.2. 考虑以下函数:

f(x) = x³

该函数的导数为 Df (x) = 3x²。在特定的 x 值下,由数值导数引入的误差是数值导数在 x 处的计算值与精确导数在 x 处的计算值之间的绝对差。编写 Haskell 代码,使用 derivative 1 计算 f 的导数。通过在不同的 x 值下计算导数,看看你能否找到数值导数引入误差的规律。在你找到这种误差的规律后,扩展你的探索,考虑对不同的 a 值使用 derivative a。你能给出一个关于误差的表达式,涉及 a 吗?

x = 4 时,Df (4) = 48。什么值的 a 会在 x = 4 时产生 1% 的误差?当 x = 0.1 时,Df (0.1) = 0.03。什么值的 a 会在 x = 0.1 时产生 1% 的误差?

练习 4.3. 找一个函数和其自变量的一个值,使得使用derivative 0.01时与精确导数相比产生至少 10% 的误差。

练习 4.4. 考虑余弦函数 cos 及其数值导数 derivative a cos。对于哪些自变量值(我们称之为 t)数值导数对 a 的值最为敏感?对于哪些值最不敏感?你应该能够找到一些 t 值,使得 a 可以非常大,而数值导数仍然是一个很好的近似值。

练习 4.5. 考虑以下位置函数:

pos1 :: Time -> Position
pos1 t = if t < 0
         then 0
         else 5 * t**2

编写函数

vel1Analytic :: Time -> Velocity
vel1Analytic t = undefined

并且

acc1Analytic :: Time -> Acceleration
acc1Analytic t = undefined

通过对位置函数进行解析导数,得到对应的速度和加速度函数。

undefined 函数可以用作尚未编写的代码的占位符。编译器会接受undefined并愉快地编译代码,但如果你尝试使用基于undefined的函数,你将会遇到运行时错误。

编写函数

vel1Numerical :: Time -> Velocity
vel1Numerical t = undefined

并且

acc1Numerical :: Time -> Acceleration
acc1Numerical t = undefined

通过对位置函数使用derivative 0.01进行数值导数,可以得到对应的速度和加速度函数。你能找到vel1Analytic tvel1Numerical t显著不同的t值吗?你能找到acc1Analytic tacc1Numerical t显著不同的t值吗?

第五章:处理列表

Image

人们天生就会列出清单。无论是愿望清单、购物清单,还是最喜欢的十本书清单,所有有共同点的项按顺序排列都容易被大脑处理。函数式编程的历史与列表紧密相连。早期的函数式语言 Lisp 甚至有一个名字,Lisp 是“list processor”的缩写。在 Haskell 中,列表同样重要,因为我们在函数式编程中思考迭代的方式,通常是构建一个列表,然后使用它来产生我们想要的结果。

在本章中,我们将学习关于列表及其相关函数的内容。我们将从列表基础开始,例如如何构造列表,如何选择列表中的特定元素,以及如何连接列表。然后我们将学习如何为列表指定类型。数字列表有特殊的作用。对于算术序列有特殊的语法,而且有多个 Prelude 函数用于操作数字列表。接下来,我们将介绍类型变量的概念。我们将稍微偏离话题讨论类型转换,然后介绍列表推导式,这是一种非常有用的从旧列表形成新列表的方式。最后,我们将通过模式匹配结束本章,了解列表类型的数据构造器。

列表基础

在 Haskell 中,列表是一个有序的数据序列,所有元素类型相同。以下是一个列表的示例:

physicists :: [String]
physicists = ["Einstein","Newton","Maxwell"]

类型[String]表示physicists是一个String类型的列表。

类型周围的方括号表示一个列表。一个类型为[String]的列表可以有任意数量的项(包括零),但每个项必须是String类型。在第二行,我们通过将物理学家元素用方括号括起来,并用逗号分隔元素来定义物理学家。空列表用[]表示。

使用类型同义词

type R = Double

这是一个实数列表:

velocities :: [R]
velocities = [0,-9.8,-19.6,-29.4]

从列表中选择一个元素

列表元素操作符!!可以用来获取列表中单个元素的值。我们在列表和元素的位置(或索引)之间使用该操作符。列表的第一个元素被视为第 0 个元素。

Prelude> :l Lists.hs
[1 of 1] Compiling Main            ( Lists.hs, interpreted )
Ok, one module loaded.
*Main> velocities !! 0
0.0
*Main> velocities !! 1
-9.8
*Main> velocities !! 3
-29.4

第一个命令加载文件Lists.hs,该文件包含本章的代码。此文件以及其他章节的代码文件可以在lpfp.io找到。加载文件后,我们可以引用velocities,这个名字在加载文件之前对 GHCi 是未知的。

连接列表

相同类型的列表可以使用++操作符进行连接,如表 1-2 所示。例如,如果我们有另一个类型为[R]的列表,

moreVelocities :: [R]
moreVelocities = [-39.2,-49.0]

我们可以将velocities与这个列表连接起来:

*Main> velocities ++ moreVelocities
[0.0,-9.8,-19.6,-29.4,-39.2,-49.0]
*Main> :t velocities ++ moreVelocities
velocities ++ moreVelocities :: [R]

请注意,连接后的列表类型与每个组成列表的类型相同,在这个例子中是[R]

尝试连接不同底层类型的列表会产生错误。例如,physicists ++ velocities会报错。

   *Main> physicists ++ velocities

➊ <interactive>:7:15: error:
   ➋ • Couldn't match type Double with [Char]
     ➌ Expected type: [String]
       ➍ Actual type: [R]
   ➎ • In the second argument of (++), namely velocities
        In the expression: physicists ++ velocities
        In an equation for it: it = physicists ++ velocities

这种错误被称为 类型错误。虽然 physicists 有明确的类型,即 [String],而 velocities 有明确的类型,即 [R],但是表达式 physicists ++ velocities 无法得到一个明确的类型。类型错误发生在我们试图对一个期望某种类型输入的函数(在这种情况下是连接操作符 ++)应用一个实际上具有不同类型的值时。连接操作符期望它的第二个参数类型是 [String],因为 physicists 的类型是 [String]。然而,我们给了一个第二个参数 velocities,它的类型是 [R],因此与 [String] 不匹配。在 第六章 中,我们将讨论连接操作符的类型。

让我们试着理解错误信息。单词“interactive” ➊ 表示错误发生在 GHCi 提示符下,而不是在源代码文件中。数字 ➊ 是错误发生的行号和列号。这对于源代码文件中的错误是有用的信息,但对于 GHCi 提示符下的错误来说其实并不需要。“Couldn’t match type” ➋ 表示类型错误。类型不匹配的是 Double[Char] ➋。由于 RDouble 的类型别名,而 String[Char] 的类型别名,正如我们在本章稍后会看到的,编译器告诉我们 RString 不匹配。

接下来,编译器告诉我们一个函数期望一个类型为 [String] 的表达式(“预期类型”) ➌,但实际上给定了一个类型为 [R] 的表达式(“实际类型”) ➍。错误信息接着告诉我们,这个不匹配的地方是在 ++ 操作符的第二个参数 ➎。能够阅读像这样的类型错误是非常有用的。犯这样的错误并不丢脸。编译器通过检查我们的代码来帮助我们确保它的合理性。在物理学中,我们可以通过为不同的概念实体分配不同的类型,充分利用 Haskell 的类型系统。例如,我们知道把一个数字加到一个向量上是没有意义的。通过给数字和向量不同的类型,我们利用 Haskell 的类型系统来确保我们编写的代码不会试图将数字加到向量上。

任何类型相同的多个列表都可以通过 concat 函数进行连接。如果我们定义一个字符串列表,

shortWords :: [String]
shortWords = ["am","I","to"]

然后我们可以进行如下的连接:

*Main> concat [shortWords,physicists,shortWords]
["am","I","to","Einstein","Newton","Maxwell","am","I","to"]

算术序列

算术序列 是一个由两个点(..)形成的列表,像这样:

ns :: [Int]
ns = [0..10]

列表 ns 包含从 0 到 10 的整数。我选择了名称 ns,因为它看起来像是 n 的复数形式,而 n 似乎是一个整数的好名字。在 Haskell 程序中,使用以 s 结尾的名字来表示列表是一种常见的风格,但这并不是必须的。

如果我们输入一个列表到 GHCi,GHCi 将会评估每个元素并返回评估后的元素列表:

*Main>  [0,2,5+3]
[0,2,8]

如果我们给 GHCi 输入一个算术序列,GHCi 会为我们展开这个序列:

*Main>  [0..10]
[0,1,2,3,4,5,6,7,8,9,10]

第二种算术数列形式允许我们通过一个不同于 1 的值从一个项递增到下一个项:

*Main>  [-2,-1.5..1]
[-2.0,-1.5,-1.0,-0.5,0.0,0.5,1.0]

在第二种形式中,我们指定所需列表的第一个、第二个和最后一个条目。我们甚至可以做一个递减的列表:

*Main>  [10,9.5..8]
[10.0,9.5,9.0,8.5,8.0]

列表类型

列表类型 是从现有类型形成新类型的第二种方式(第一种方式是函数类型,正如我们在第三章中的“函数类型”部分所看到的)。给定任何类型 a(如 IntIntegerDouble 等),都有一个类型 [a] 用于表示元素类型为 a 的列表。

例如,你可以制作一个函数列表。回想一下我们在第二章中定义的平方函数。

square :: R -> R
square x = x**2

我们可以定义以下列表,其中 cossin 是在 Haskell Prelude 中定义的函数:

funcs :: [R -> R]
funcs = [cos,square,sin]

为什么我们需要一个函数列表呢?其中一个原因会在第十一章中出现,当我们遇到一个接受函数列表并将它们绘制在同一坐标轴上的函数时。第二个原因出现在第十四章,当作用于物体的力是时间或速度的函数时。我们用于求解牛顿第二定律的函数将接受这些力函数的列表,以描述作用于物体的力。

数字列表的函数

Haskell 提供了几个可以与数字列表一起使用的 Prelude 函数。前两个是 sumproduct,如表 5-1 所示。正如你从名称中可能猜到的那样,sum 返回列表中项的和,对于空列表返回 0product 函数返回列表中项的积,对于空列表返回 1maximumminimum 函数分别返回列表中的最大值和最小值,如果给它们传递空列表,则会产生错误。

表 5-1: 数字列表的函数

表达式 计算结果
sum [3,4,5] 12
sum [] 0
product [3,4,5] 60
product [] 1
maximum [4,5,-2,1] 5
minimum [4,5,-2,1] -2

何时不使用列表

有时你可能需要将不同类型的表达式“捆绑在一起”。例如,我们可能希望组成一对一对的项,其中包括一个人的姓名(String)和年龄(Int)。列表不是适合这种情况的结构。列表中的所有元素必须具有相同的类型。在第九章中,我们将学习元组,它是将不同类型的项捆绑在一起的好方法。

类型变量

在上一节中,我们看到列表元素运算符 !! 返回指定的列表元素。列表元素运算符不关心列表中包含的元素类型。我们会写 physicists !! 2 来获取 physicists 列表中的第二个元素,就像我们写 velocities !! 2 来获取 velocities 列表中的第二个元素一样,尽管前者的列表类型是 [String],而后者的列表类型是 [R]

还有其他一些函数也不关心列表元素的类型。表 5-2 展示了预定义函数中的几个这样的函数。这些函数的类型是通过类型变量(这里是a)表示的。类型变量必须以小写字母开头,并且可以代表任何类型。

表 5-2: 用于处理列表的一些预定义函数

函数 类型 描述
head :: [a] -> a 返回列表的第一个元素
tail :: [a] -> [a] 返回列表中除了第一个元素的所有元素
last :: [a] -> a 返回列表的最后一个元素
init :: [a] -> [a] 返回列表中除了最后一个元素的所有元素
reverse :: [a] -> [a] 反转列表的顺序
repeat :: a -> [a] 无限重复单一项的列表
cycle :: [a] -> [a] 无限循环给定列表

head函数返回列表中的第一个元素。你可以在表 5-3 中看到一些head的使用示例,以及其他列表函数的使用示例。

表 5-3: 列表函数的使用

表达式 计算结果
head ["Gal","Jo","Isaac","Mike"] "Gal"
head [1, 2, 4, 8, 16] 1
tail ["Gal","Jo","Isaac","Mike"] ["Jo","Isaac","Mike"]
tail [1, 2, 4, 8, 16] [2,4,8,16]
last ["Gal","Jo","Isaac","Mike"] "Mike"
last [1, 2, 4, 8, 16] 16
init ["Gal","Jo","Isaac","Mike"] ["Gal","Jo","Isaac"]
init [1, 2, 4, 8, 16] [1,2,4,8]
length ["Gal","Jo","Isaac","Mike"] 4
length [1, 2, 4, 8, 16] 5

head函数可以接受类型为[Double]的列表、类型为[Char]的列表或类型为[Int]的列表。因为head不关心负载的类型,最好的方式是通过使用类型变量a来表达head接受[a]类型的输入。相同的类型变量a也出现在输出中;head的返回类型是a

如果你查询 GHCi 空列表的类型,你会看到类型变量。

*Main> :t []
[] :: [a]

让我们再看一下表 5-2 中的一些函数。tail函数返回列表中的所有元素,但不包括第一个元素。last函数返回列表中的最后一个元素。init函数返回列表中除了最后一个元素的所有元素。书籍《Learn You a Haskell for Great Good!》有一张可爱的毛毛虫图片(learnyouahaskell.com/starting-out#an-intro-to-lists),它形象地解释了这些列表函数。表 5-2 给出了这些函数的类型,表 5-3 展示了如何使用它们的一些示例。

既然我们已经介绍了类型变量,现在正是一个短暂探讨类型转换的好时机。

类型转换

GHCi 似乎允许DoubleInt进行除法运算:

*Main> 0.4 / 4
0.1

然而,这并不是这里发生的情况。数字 0.4 可以是 FloatDouble。数字 4 可以是 IntIntegerFloatDouble。除法运算符要求被除的两个数字的类型相同。在这种情况下,两个数字必须都解释为 Float 或都解释为 Double。加法、减法、乘法和除法要求参与操作的两个表达式具有相同的类型。就我们之前介绍的类型变量而言,加法、减法、乘法和除法的类型都是 a -> a -> a,意味着两个参与运算的数字必须具有相同的类型 a,然后运算的结果也将是类型 a。 (关于加法等算术运算类型的完整故事更为复杂。你不能将两个 String 相加,但一个类型为 a -> a -> a 的函数必须能够接受两个 String 作为输入并产生一个 String 作为输出。缺少的部分涉及类型类的概念,我们将在 第八章 讨论。)

Haskell 编译器会拒绝将 Double 除以 Int。如果我们给一些数字明确指定类型,

oneDouble :: Double
oneDouble = 1

twoInt :: Int
twoInt = 2

我们可以看到编译器生成的错误:

*Main> oneDouble / twoInt

<interactive>:42:13: error:
    • Couldn't match expected type Double with actual type Int
    • In the second argument of (/), namely twoInt
      In the expression: oneDouble / twoInt
      In an equation for it: it = oneDouble / twoInt

这是另一个类型错误的示例。除法的第一个输入是 Double,因此除法的第二个参数的“期望类型”也是 Double。我们提供的“实际类型”是 Int,这不匹配。编译器同样会拒绝将 Float 加到 Double 上。

除法操作只能发生在相同类型的数字之间,这可能会令人烦恼,尤其是当我们期望编译器自动将一种类型转换为另一种类型时。解决方案是使用一个类型转换函数,比如将一个 Int 转换为 Double

有两个重要的类型转换函数,你可能需要不时使用。第一个是 fromIntegral,它将一个 IntInteger 转换成其他类型的数字。编译器通常能够推断出应该转换成哪种类型,但它需要通过这个函数获得你的明确许可。第二个转换函数是 realToFrac,它将一个 Float 转换为 Double,或者将一个 Double 转换为 Float。同样,你通常不需要明确指定要转换成的类型;你只需要允许进行转换。以下是一个示例:

*Main> oneDouble / fromIntegral twoInt
0.5

转换要求背后的理由是,在 Haskell 中,大多数错误是类型错误。类型错误通常意味着我们没有完全考虑过自己编写的代码。举例来说,将一个 Double 除以一个 Int 可能不是我们所期望的,我们感谢类型检查器生成了错误,而不是悄悄地将 Int 转换成 Double

这就是我们对类型转换的简要讨论。现在我们可以回到我们正常的程序,具体来说是列表。

列表的长度

Prelude 提供了一个 length 函数,用于返回列表中项目的数量。

*Main> length velocities
4
*Main> length ns
11

*Main> length funcs
3

在 Haskell 的早期,length是一个简单的函数,具有简单的类型。length的类型是[a] -> Int,意味着你可以给length传递一个任意类型的列表,它会返回一个整数。这很简单。如果我们今天在 GHCi 中查询length的类型,

*Main> :t length
length :: Foldable t => t a -> Int

我们看到一个更复杂的类型。这个类型涉及到类型类的概念,我们将在第八章中进行探讨。但目前,我们可以用简单的类型[a] -> Int定义自己的length函数:

len :: [a] -> Int
len = length

如果我们在 GHCi 中查看len的类型,

*Main> :t len
len :: [a] -> Int

我们看到了我们所需要的简单类型。

定义我们自己的length函数并没有太大好处,因为我们可以自由地使用length,即使它有一个复杂的类型,但是理解我们使用的函数的类型能让我们真正了解我们在做什么。我们希望理解我们编写和使用的函数的类型,并希望它们尽可能简单。当然,简洁性与功能之间会有权衡。Haskell 设计者决定让length函数具有更复杂的类型,这意味着它可以在更广泛的场景中使用。在这种情况下,设计者做出了更偏向功能而非简洁性的决定。我们通常会偏向于简洁性而非功能性。

字符串就是字符的列表

现在我已经介绍了列表,可以告诉你,在 Haskell 中,字符串不过是一个字符的列表。换句话说,String类型与[Char]类型完全相同;事实上,String在 Haskell 预定义模块中被定义为[Char]的类型同义词,正如我们将R定义为Double的类型同义词一样。Haskell 为字符串提供了一些特殊的语法,特别是能将一串字符用双引号括起来,从而形成一个String。显然,这比要求显式列出字符列表(如['W','h','y','?'])要更加方便。你可以在 GHCi 中检查这是否与"Why?"相同:

*Main>  ['W','h','y','?'] == "Why?"
True

GHCi 返回 True,表示它认为这两个表达式是相同的。

String[Char]这两种类型的身份也意味着字符串可以在任何期望接收某种类型列表的函数中使用。例如,我们可以在一个字符串上使用length函数来告诉我们它包含多少个字符。

有一些编程经验的读者可能会担心将字符串表示为字符列表的效率问题。请放心,Haskell 还有一些更高效的选项,适用于那些需要处理大量字符串的程序员。然而,就我们目前的需求而言,我们不需要处理大量字符串,因此基本的String类型完全足够了。

列表推导式

Haskell 提供了一种强大的方法,用新的列表从旧的列表中生成。假设你有一个时间列表(单位为秒),

ts :: [R]
ts = [0,0.1..6]

你可能希望得到一个位置列表,表示你以 30 米/秒的速度将一块石头抛向空中,每个位置对应时间列表中的某个时间点。在练习 2.2 中,你编写了一个yRock30函数来计算给定时间的石头位置。也许你的函数看起来像下面这样:

yRock30 :: R -> R
yRock30 t = 30 * t - 0.5 * 9.8 * t**2

以下代码生成所需的位置列表:

xs :: [R]
xs = [yRock30 t | t <- ts]

xs的定义是一个列表推导式的例子。列表推导式的语法包括方括号、竖线和左箭头,如下所示:

[ 函数 item | item >- list ]

这意味着,给定一个函数和一个列表,Haskell 会对列表中的每个元素计算该函数,然后形成一个包含计算结果值的列表。在我们上面的例子中,对于ts中的每个t,Haskell 会计算yRock30 t并形成这些值的列表。位置列表xs将与原始的时间列表ts具有相同的长度。

列表推导式与sumproduct函数结合使用,使我们能够编写优雅的 Haskell 表达式,模拟数学中的求和和求积符号(sigma 和 pi 表示法)。表 5-4 展示了数学表示法与 Haskell 表示法之间的对应关系。

表 5-4: Haskell 中的求和和积符号

数学表示法 Haskell 表示法
Image sum [f(i) &#124; i <- [m..n]]
Image product [f(i) &#124; i <- [m..n]]

无穷列表

Haskell 是一种懒惰语言,这意味着它并不总是按照你预期的顺序计算所有内容。相反,它会等待查看是否需要某些值,然后才会实际执行工作。Haskell 的懒惰特性使得无穷列表成为可能。当然,Haskell 并不会实际创建一个无穷列表,但你可以把这个列表当作无穷列表,因为 Haskell 愿意根据需要继续计算列表中的元素。列表[1..]就是一个无穷列表的例子。如果你请求 GHCi 显示这个列表,它将不断打印下去。你可以按 CTRL-C 或类似的操作来停止无休止的数字打印。

当你不知道事先需要多少列表元素时,无穷列表非常方便。例如,我们可能想要计算一个粒子在每 0.01 秒增量下的位置列表。我们可能无法预先知道我们需要获取这一信息的时间长度。如果我们编写函数返回一个无穷列表的位置,那么该函数会更简单,因为它不需要知道计算位置的总数。

查看无穷列表的前几个元素的一个好方法是使用take函数。在 GHCi 中尝试以下操作:

*Main> take 10 [3..]
[3,4,5,6,7,8,9,10,11,12]

GHCi 显示了无穷列表[3..]的前 10 个元素。

来自 表 5-2 的两个 Prelude 函数可以创建无限列表。函数 repeat 接受一个表达式,并返回一个由该表达式重复无限次构成的无限列表。单独使用时,这个函数似乎没有太大用处,但结合我们稍后将学到的其他函数和技巧,它可以变得非常有用。

Prelude 函数 cycle 接受一个(有限)列表,并返回一个无限列表,该列表是通过反复循环有限列表中的元素得到的。你可以通过让 GHCi 显示这个列表的前几个元素,来了解 cycle 是如何工作的,如下所示:

*Main> take 10 (cycle [4,7,8])
[4,7,8,4,7,8,4,7,8,4]

列表构造器与模式匹配

: 运算符(由于早期函数式编程语言 Lisp 的历史原因,它被称为 cons)来自于 表 1-2,可以用于将类型为 a 的单个元素附加到类型为 [a] 的列表中。例如,3:[4,5] 与 [3,4,5] 是等价的,而 3:[] 与 [3] 也是等价的。

在 第三章 中,我们看到了如何对 Bool 类型进行模式匹配。Bool 类型有两个模式,FalseTrue。列表类型也有两个模式。一个列表要么是空列表 [],要么是带有一个项 x 和列表 xs 的 cons x:xs。每个列表都恰好是这两种互斥且穷尽的可能之一。事实上,Haskell 内部将列表视为由两个 构造器(也叫 数据构造器)[] 和 : 组成的。Haskell 中的每个类型都有一个或多个数据构造器,用于构造该类型的表达式。因此,数据构造器是一种构造特定类型表达式的方法。当我们在 第十章 中定义我们自己的类型时,我们将看到数据构造器是类型定义中不可或缺的一部分。

我们所认为的列表 [13,6,4] 在内部表示为 13:6:4:[],这意味着 13:(6:(4:[])),考虑到 : 的右结合性时如此表示。表 5-5 显示了布尔型和列表类型的数据构造器。

表 5-5: 布尔型和列表类型的数据构造器

类型 数据构造器
Bool False, True
[a] [], :

Haskell 中用于模式匹配的基本机制是 case-of 结构。如果我们对 Bool 类型进行模式匹配,则会有两个对应的情况,分别是 FalseTrue,它们是构造 Bool 类型的两个数据构造器。如果我们对列表进行模式匹配,则会有两个对应的情况,分别是 []:,它们是构造所有列表的两个数据构造器。

我们来看一个使用模式匹配定义函数的例子。我们来定义一个函数 sndItem,它返回列表的第二个元素,如果列表少于两个元素则返回错误。我们的想法是, sndItem [8,6,7,5]应该返回 6。我们第一个定义使用了case-of构造:

sndItem :: [a] -> a
sndItem ys = case ys of
               []     -> error "Empty list has no second element."
               (x:xs) -> if null xs
                         then error "1-item list has no 2nd item."
                         else head xs

ys为空列表的情况下,我们使用错误函数,该函数的类型是[Char] -> a,意味着它接受一个字符串作为输入,并且可以作为任何类型。错误函数会终止执行并返回给定的字符串作为消息。

如果输入是cons一个元素和一个列表的形式,那么表示法(x:xs)表示该元素将被赋值给名称x,而列表将被赋值给名称xs,以便在定义的主体中使用(主体是箭头右侧的表达式)。例如,如果ys是列表[1879,3,14],那么x将被赋值为1879,而xs将被赋值为[3,14]。这种xxs的赋值是局部的,意味着它只在定义的主体内有效。在定义主体之外,xxs可能有其他含义,甚至没有任何含义。

表达式null xs如果xs为空则返回True,否则返回False。如果xs为空,则原始列表x:xs只有一个元素,因此我们返回该单一元素的错误。如果xs不为空,则其第一个元素(head)就是原始列表的第二个元素,因此我们应该返回该值。

如果我们正在进行模式匹配的值(之前的ys)也是某个函数的输入,我们可以直接在输入上进行模式匹配,而不是使用case-of构造。

sndItem2 :: [a] -> a
sndItem2 []     = error "Empty list has no second element."
sndItem2 (x:xs) = if null xs
                  then error "1-item list has no 2nd item."
                  else head xs

请注意,我们在 sndItem2中不再需要局部变量 ys

在对输入进行模式匹配时,定义被分成几个部分,每个部分对应输入类型的一个数据构造器。在这个例子中,输入类型是一个列表,而列表的构造器有空列表和cons,所以定义的第一部分为空列表定义了 sndItem2,第二部分为cons一个元素和一个列表定义了函数。

我们可以通过进一步的改进,使用额外的模式匹配来改进函数定义,特别是在 sndItem中的xs列表。我们来定义一个函数 sndItem3,它与 sndItem做相同的事情,但使用了更多的模式匹配:

sndItem3 :: [a] -> a
sndItem3 ys = case ys of
                []      -> error "Empty list has no second element."
                (x:[])  -> error "1-item list has no 2nd item."
                (x:z:_) -> z

sndItem的第一个案例保持不变。 sndItem的第二个案例分为两个子案例,取决于 sndItem中的 xs是空列表还是cons一个元素和一个列表。

注意最后一行中的下划线字符(_)。在 Haskell 中,以下划线开头的名字是我们永远不打算引用的名字。我们本可以用(x:z:zs)来替代(x:z:_)。下划线表示我们懒得给这个列表起一个真实的名字,因为我们没有打算再次使用它或引用它。在定义体中我们没有引用这个列表,所以没有必要给它一个合适的名字。有时候,给一个永远不会被使用的东西起个名字,对代码阅读者(可能是你)是有帮助的。如果你想给某个东西起个名字,并且表明它永远不会被使用,你可以使用一个以下划线开头的名字,比如_zs。最后需要注意的是,cons操作符(:)是右结合的,因此表达式x:z:_应当理解为x:(z:_)

因为我们进行模式匹配的值是传递给函数的输入,所以我们可以对输入进行模式匹配,而不必使用case-of结构。

sndItem4 :: [a] -> a
sndItem4 []      = error "Empty list has no second element."
sndItem4 (x:[])  = error "1-item list has no 2nd item."
sndItem4 (x:z:_) = z

概述

本章介绍了列表。列表中的每个成员必须具有相同的类型。方括号在列表中有两个作用:方括号中包含的类型表示列表类型,而方括号中包含并用逗号分隔的项则形成一个列表。类型变量作为任何类型的占位符。许多列表函数的类型中有类型变量,因为它们不关心列表的底层类型。由于加法、减法、乘法和除法仅在 Haskell 中的相同类型的数字之间有效,因此我们引入了两个类型转换函数,用于需要转换的情况。列表推导是一种从现有列表形成新列表的方法。由于 Haskell 是懒惰语言,它支持无限列表。列表由两个构造器构成:空列表和 cons 操作符。可以使用模式匹配来定义一个以列表为输入的函数。列表可能具有两种模式:它要么是空列表,要么是某个元素和另一个列表的 cons

练习

练习 5.1. 使用双点(..)表示法给以下列表提供一个缩写。使用 GHCi 检查你的表达式是否正确。

numbers :: [R]
numbers = [-2.0,-1.2,-0.4,0.4,1.2,2.0]

练习 5.2. 编写一个函数sndItem0 :: [a] -> a,它与sndItem的功能相同,但不使用任何模式匹配。

练习 5.3. 以下表达式的类型是什么?

length "Hello, world!"

这个表达式的值是多少?

练习 5.4. 编写一个类型为Int -> [Int]的函数,并用文字描述它的功能。

练习 5.5. 编写一个函数null',它与 Prelude 中的 null 函数功能相同。在定义 null' 时使用 Prelude 函数 length,但不要使用 null 函数。

练习 5.6. 编写一个函数last',它与 Prelude 中的 last 函数功能相同。在定义 last' 时使用 Prelude 函数 headreverse,但不要使用 last 函数。

练习 5.7. 编写一个函数 palindrome :: String -> Bool,如果输入字符串是回文(例如 radar,正读和倒读都相同),则返回 True,否则返回 False。

练习 5.8. 无限列表 [9,1..] 的前五个元素是什么?

练习 5.9. 编写一个函数 cycle',实现与 Prelude 中的函数 cycle 相同的功能。在定义 cycle' 时使用 Prelude 中的函数 repeat 和 concat,但不要使用 cycle 函数。

练习 5.10. 以下哪些是有效的 Haskell 表达式?如果一个表达式有效,请给出其类型。如果表达式无效,请说明哪里出错了。

(a) ["hello",42]

(b) ['h',"ello"]

(c) ['a','b','c']

(d) length ['w','h','o']

(e) length "hello"

(f) reverse(提示:这是一个有效的 Haskell 表达式,并且它有一个明确定义的类型,尽管 GHCi 无法打印该表达式。)

练习 5.11. 在一个算术序列中,如果指定的最后一个元素没有出现在序列中,

*Main>  [0,3..8]
[0,3,6]
*Main>  [0,3..8.0]
[0.0,3.0,6.0,9.0]

结果似乎取决于你是否使用整数。探讨这一点,并尝试找到一个通用规则,说明算术序列将在哪里结束。

练习 5.12. 在 1730 年代,莱昂哈德·欧拉证明了:

Image

编写一个 Haskell 表达式来求值

Image

练习 5.13. 数字 n!,称为 "n 阶乘",是小于或等于 n 的所有正整数的乘积:

n! = n(n – 1) . . . 1

这是一个例子:

5! = 5 × 4 × 3 × 2 × 1 = 120

使用 product 函数编写一个阶乘函数。

fact :: Integer -> Integer
fact n = undefined

练习 5.14. 指数函数等于以下极限:

Image

编写一个函数

expList :: R -> [R]
expList x = undefined

它接受一个实数 x 作为输入,并生成一个无限长的列表,包含对 exp(x) 的连续逼近:

Image

使得 x = 1 时的值与正确值的误差在 1% 以内需要多大 n?对于 x = 10,n 需要多大才能使得误差在 1% 以内?

练习 5.15. 指数函数等于以下无限级数:

Image

编写一个函数

expSeries :: R -> [R]
expSeries x = undefined

它接受一个实数 x 作为输入,并生成一个无限长的列表,包含对 exp(x) 的连续逼近:

Image

对于以下情况,n 需要多大:

Image

使得 x = 1 时的值与正确值的误差在 1% 以内需要多大 n?对于 x = 10,n 需要多大才能使得误差在 1% 以内?你可能需要在这里使用函数 fromIntegral

第六章:高阶函数

图片

高阶函数是函数式编程的核心,它自然地源自于函数应该是语言中的“第一类对象”的思想,拥有数字或列表的所有权利和特权。高阶函数是接受一个函数作为输入和/或返回一个函数作为输出的函数。许多我们希望计算机为我们做的事情可以自然地表达为高阶函数。

在这一章中,我们将首先讨论产生函数作为输出的高阶函数。我们将看到,这些高阶函数可以被视为具有一个输入,或者作为具有多个输入。接着,我们将讨论映射,即将一个函数应用于列表的每个元素并生成结果列表的概念。然后,我们将展示如何使用高阶函数iterate,另一个接受函数作为输入的函数,来进行迭代。查看匿名高阶函数和操作符之后,我们将讨论基于谓词的高阶函数。最后,我们将详细探讨数值积分,这是物理学中的一个核心工具,它作为高阶函数有着自然的表达。

如何理解具有参数的函数

考虑一个线性弹簧的力,弹簧常数为k。我们通常将其表示为

F[spring] = –kx

其中负号表示力的方向与位移方向相反。

假设我们希望编写一个 Haskell 函数,计算一个弹簧常数为 5500 N/m 的弹簧产生的牛顿力。我们可以写如下代码:

springForce5500 :: R -> R
springForce5500 x = -5500 * x

这是一个不错的函数,但它仅处理具有 5500 N/m 弹簧常数的弹簧产生的力。最好有一个能够处理任何弹簧常数的函数。

请注意,和往常一样,我们使用了类型同义词

type R = Double

因为我们喜欢将这些数字看作是实数。

现在考虑以下函数:

springForce :: R -> R -> R
springForce k x = -k * x

因为类型之间的箭头是向右关联的,springForce的类型R -> R -> R等价于R -> (R -> R),意味着如果我们将springForce函数传递给一个R(弹簧常数),它将返回给我们一个类型为R -> R函数。这个后续函数希望输入一个R(位移),并将输出一个R(力)。

我们可以使用 GHCi 的:type 命令(缩写为t)来查看这些函数的类型:

Prelude> :l HigherOrder.lhs
[1 of 1] Compiling Main            ( HigherOrder.lhs, interpreted )
Ok, one module loaded.
*Main> :t springForce
springForce :: R -> R -> R

接下来,我们来看springForce 2200函数:

*Main> :t springForce 2200
springForce 2200 :: R -> R

函数springForce 2200表示一个弹簧力函数(输入:位移,输出:力),用于具有 2200 N/m 弹簧常数的弹簧。它与上面的 springForce5500 函数具有相同的类型并发挥相同的作用。然而,它看起来有些奇怪,因为它是由两个部分组成的:springForce 部分和 2200 部分。

最后,看看springForce 2200 0.4的类型:

*Main> :t springForce 2200 0.4
springForce 2200 0.4 :: R

这不是一个函数,而只是一个数字,表示当弹簧常数为 2200 N/m 时,弹簧被拉伸 0.4 米所施加的力。

一个接受另一个函数作为输入或返回另一个函数作为结果的函数被称为高阶函数。函数springForce是一个高阶函数,因为它返回一个函数作为结果。图 6-1 显示了springForce函数接受一个数字作为输入(弹簧常数k :: R),并返回一个函数作为输出(springForce k :: R -> R)。然后,函数springForce k接受一个数字作为输入(位移x :: R),并返回一个数字作为输出(力springForce k x :: R)。

Image

图 6-1: 高阶函数springForce接受一个数字作为输入,并返回函数springForce k作为输出。函数springForce k然后接受一个数字作为输入并返回一个数字作为输出。

高阶函数为我们提供了一种方便的方式来定义一个函数,它除了“实际”输入(如位移)外,还接受一个或多个参数(如弹簧常数)作为输入。表 6-1 展示了 Prelude 中一些返回函数作为输出的高阶函数。

表 6-1: Prelude 中一些返回函数作为输出的高阶函数

函数 类型
take :: Int -> [a] -> [a]
drop :: Int -> [a] -> [a]
replicate :: Int -> a -> [a]

考虑高阶函数taketake函数通过从给定列表中取出指定数量的元素来生成一个新列表。表 6-2 展示了它的一些使用示例。

表 6-2: take 使用示例

表达式 求值结果
take 3 [9,7,5,3,17] [9,7,5]
take 3 [3,2] [3,2]
take 4 [1..] [1,2,3,4]
take 4 [-10.0,-9.5..10] [-10.0,-9.5,-9.0,-8.5]

让我们来看一下take的类型:

*Main> :t take
take :: Int -> [a] -> [a]

根据take的类型,当传入一个Int时,它应该返回一个类型为[a] -> [a]的函数。那么take应该返回什么样的函数呢?如果我们将整数n传给take,返回的函数将接受一个列表作为输入,并返回输入列表的前n个元素。

有两种方式来理解高阶函数take(以及其他类似的返回函数作为输出的函数),如表 6-3 所示。

表 6-3: 理解高阶函数take的两种思维方式

思考方式 传给 take 的输入 take 的输出
单输入思维 Int [a] -> [a]
双输入思维 Int 和 [a] [a]

我们已经通过springForce描述了“单输入思维”,即将take的类型签名理解为期望一个Int作为输入,并产生一个[a] -> [a](可以读作“a 类型的列表到 a 类型的列表”)作为输出。图 6-2 展示了take的单输入图。

图片

图 6-2: 以单个输入的方式理解高阶函数 take

另一种理解类型签名Int -> [a] -> [a]的方法是,函数期望接收两个输入,第一个是类型为Int,第二个是类型为[a],并返回一个类型为[a]的输出。图 6-3 展示了take的两个输入图。

图片

图 6-3: 以两个输入的方式理解高阶函数 take

作为高阶函数的读者和编写者,我们可以选择不同的方式来理解它们。有时,将高阶函数理解为接受多个输入会很方便,但同样,考虑每个函数(包括高阶函数)只接受一个输入也是非常有用的。Haskell 编译器将每个函数视为具有一个输入。

take一样,drop这个高阶函数是操作列表的另一种常用工具。drop函数通过丢弃给定列表中的若干个元素来生成一个新的列表。表 6-4 展示了它的一些使用示例。

表 6-4: drop函数使用示例

表达式 计算结果
drop 3 [9,7,5,10,17] [10,17]
drop 3 [4,2] []
drop 37 [-10.0,-9.5..10] [8.5,9.0,9.5,10.0]

replicate函数通过重复某一项若干次来生成一个列表。表 6-5 展示了它的一些使用示例。

表 6-5: replicate函数使用示例

表达式 计算结果
replicate 2 False [False,False]
replicate 3 "ho" ["ho","ho","ho"]
replicate 4 5 [5,5,5,5]
replicate 3 'x' "xxx"

在本节中,我们关注的是返回函数作为输出的高阶函数。接下来,我们将介绍一个接受函数作为输入的高阶函数。

对列表应用函数

表 6-6 展示了一些接受其他函数作为输入的高阶 Prelude 函数。

表 6-6: 一些接受函数作为输入的 Prelude 高阶函数

函数 类型
map :: (a -> b) -> [a] -> [b]
iterate :: (a -> a) -> a -> [a]
flip :: (a -> b -> c) -> b -> a -> c

Prelude 中的map函数是一个很好的高阶函数示例,它接受另一个函数作为输入。map函数会将你提供的函数应用于你提供的列表中的每个元素。表 6-7 展示了map的一些使用示例。

表 6-7: map函数使用示例

表达式 计算结果
map sqrt [1,4,9] [1.0,2.0,3.0]
map length ["Four","score","and"] [4,5,3]
map (logBase 2) [1,64,1024] [0.0,6.0,10.0]
map reverse ["Four","score"] ["ruoF","erocs"]

在表 6-7 中列出的第一个示例中,我们说函数 sqrt 被“映射”到列表上,这意味着它会应用于列表中的每个元素。

请注意,函数类型 a -> bmap 的类型签名中的括号是至关重要的。类型 a -> b -> [a] -> [b](其中没有括号)是完全不同的类型。这个类型将输入一个类型为 a 的值,一个类型为 b 的值,以及一个类型为 [a] 的列表,输出一个类型为 [b] 的列表。后者类型是 a -> (b -> ([a] -> [b])) 的简写,因为箭头是从右向左关联的。

列表推导可以完成 map 的工作。选择 map 还是列表推导,取决于个人风格。

*Main> map sqrt [1,4,9]
[1.0,2.0,3.0]
*Main> [sqrt x | x <- [1,4,9]]
[1.0,2.0,3.0]

将函数映射到结构上的概念实际上不仅限于列表结构,还可以扩展到树等其他结构。Haskell 有一个 fmap 函数可以实现这一点,尽管在本书中我们不会使用它。

迭代与递归

迭代是任何编程语言中的一个基本特性。能够反复执行某些操作是计算机强大功能的主要来源之一。那么人们如何在编程语言中表达迭代的概念呢?

命令式编程语言提供了编写循环的方式,循环指令用于反复执行某些操作,循环的次数可以是固定的,也可以是直到满足某个条件为止。

函数式编程语言有不同的方式来表达迭代。函数式程序员最常用的迭代方法是编写递归函数,即调用自身的函数。递归函数非常强大,但对于初次接触递归函数的人来说,理解如何编写递归函数需要一些时间和精力。

Haskell Prelude 中有许多内建的递归函数,我们可以直接使用,而无需显式编写自己的递归函数。在本书中,我们将避免编写显式的递归函数,意味着你可以从函数定义中看出该函数会调用自身。我们将使用 Prelude 中的内建递归函数编写函数,这些我们编写的函数可以被称为递归的,因为它们的行为是递归的,即在底层某个地方会调用自身。然而,我们编写的函数不是显式的递归函数,因为它们不会直接调用自身。

我们可以通过示例来理解和解释 Prelude 中大多数递归函数的行为,而无需了解它们是如何通过递归的力量实现这一行为的。递归确实非常有趣,如果你有时间,我鼓励你深入了解。我从《The Little Schemer》一书中学到了递归,推荐这本使用 Scheme 语言的书。[4]。书籍《Learn You a Haskell for Great Good!》[1]在第四章中讨论了递归,展示了如何编写像 takereversereplicaterepeat 这样的 Prelude 函数。

我们如何在 Haskell 中实现迭代,而不写显式的递归函数呢?我们将使用 Prelude 函数iterate。我们不会以命令式的方式思考我们希望计算机什么,而是以函数式的方式思考我们希望拥有什么,并安排这些内容以列表的形式出现。在这里,我们可以使用“列表代替循环”作为函数式编程的口号。

Prelude 函数iterate,其类型在表 6-6 中给出,是一个高阶函数,接受一个函数作为输入。迭代和iterate函数在我们解决第二部分中的牛顿第二定律时非常重要。函数iterate生成一个无限列表,如下所示:如果 f :: a -> ax :: a(读作 "f 的类型是 aa" 和 "x 的类型是 a"),那么iterate f x 会生成这个无限列表:

[x, f x, f (f x), f (f (f x)), ...]

换句话说,结果是一个列表,其中 f 被应用零次、一次、两次、三次,依此类推。表 6-8 展示了 iterate 的一些使用示例。

表 6-8: 使用 iterate 的示例

表达式 计算结果
iterate (\n -> 2*n) 1 [1,2,4,8,...]
iterate (\n -> n*n) 1 [1,1,1,1,...]
iterate (\n -> n*n) 2 [2,4,16,256,...]
iterate (\v -> v - 9.8*0.1) 4 [4.0,3.02,2.04,1.06,...]

图 6-4 展示了 iterate f 如何将函数 f 应用到它的输入零次、一次、两次,依此类推,并将结果收集在一个列表中。

Image

图 6-4:函数 iterate f 将函数 f 应用到输入的零次、一次、两次、三次及更多次,并将结果收集在一个无限列表中。

匿名高阶函数

在第二章中,我们讨论了匿名函数,作为描述一个没有名字的函数的方式。我们也可以对高阶函数做同样的事情,不给它们命名,而是直接描述它们。

让我们回到之前讨论的函数 springForce。我们如何在不命名它的情况下编写 springForce 呢?实际上,有两种方式可以将此函数编写为匿名函数,对应我们在《如何思考带有参数的函数》中描述的单输入思维和双输入思维。在单输入思维中,我们把 springForce 的输入看作一个数字(R),输出则是一个函数 R -> R。单输入思维的匿名函数在表 6-9 的第一行中显示。从该函数的形式来看,它返回的是一个函数,即 \x -> -k*x

表 6-9: 以匿名函数形式编写 springForce 函数的两种方式

思考方式 匿名函数
单输入思维 \k -> \x -> -k*x
双输入思维 \k x -> -k*x

在双输入思维中,我们将 springForce 的输入视为弹簧常数 R 和第二个 R 作为位置,输出则是一个简单的 R。双输入思维的匿名函数在表 6-9 的第二行中显示。该匿名函数的形式表明它返回的是一个数字。无论哪种形式都是完全合法的,事实上,这两种形式描述的是相同的函数。

运算符作为高阶函数

在第一章中,我们介绍了表 1-2 中的几个中缀运算符。任何中缀运算符都可以通过将其用括号包围转化为高阶函数。表 6-10 展示了如何将中缀运算符写成高阶函数的例子。

表 6-10: 通过括号包围将中缀运算符转化为前缀函数

中缀表达式 等效前缀表达式
f . g (.) f g
'A':"moral" (:) 'A' "moral"
[3,9] ++ [6,7] (++) [3,9] [6,7]
True && False (&&) True False
log . sqrt $ 10 | ($) (log . sqrt) 10

表 6-11 展示了通过运算符得到的某些高阶函数类型。如果你想查询一个运算符的类型,必须在使用 GHCi 的 :t 命令时,将运算符用括号括起来。

表 6-11: 将中缀运算符视为高阶函数

函数 类型
(.) :: (b -> c) -> (a -> b) -> a -> c
(:) :: a -> [a] -> [a]
(++) :: [a] -> [a] -> [a]
(&&) :: Bool -> Bool -> Bool
(&#124;&#124;) :: Bool -> Bool -> Bool
($) :: (a -> b) -> a -> b

在第五章中,我们讨论了尝试连接不同类型的列表时发生的类型错误。现在,我们已经知道了(++)的类型,更容易一般性地理解类型错误。类型错误来自于尝试将一个期望特定类型输入的函数应用于一个实际上具有不同类型的表达式。当我们将连接函数(++)应用于表达式physicists时,

physicists :: [String]
physicists = ["Einstein","Newton","Maxwell"]

我们得到一个函数(++) physicists,其类型是[String] -> [String]

*Main> :t (++)
(++) :: [a] -> [a] -> [a]
*Main> :t physicists

physicists :: [String]
*Main> :t (++) physicists
(++) physicists :: [String] -> [String]

连接函数接受任何类型a的列表。当给定一个字符串列表时,具体类型String会替代所有出现的类型变量a,使得函数(++) physicists的类型变为[String] -> [String]。这个函数(++) physicists期望的输入类型是[String],因此如果我们给它一个不同类型的输入,就会得到类型错误。

velocities :: [R]
velocities = [0,-9.8,-19.6,-29.4]

在 GHCi 中,我们得到:

*Main> :t (++) physicists velocities

<interactive>:1:17: error:
    • Couldn't match type Double with [Char]
      Expected type: [String]
        Actual type: [R]
    • In the second argument of (++), namely velocities
      In the expression: (++) physicists velocities

“预期类型”[String]是函数(++) physicists期望的输入类型,而“实际类型”[R]是列表velocities的类型。

一类被称为组合子的函数和运算符,可以被视为标准连接器,它们可以使得使用高阶函数变得更容易。我们接下来将讨论这些。

组合子

组合子,从广义上讲,是将事物组合起来的函数。它们通常是具有非常广泛适用性的函数,且它们的类型通过充满类型变量来展示这一点。它们往往不特定于数字、列表、布尔值或任何特定的基本类型。它们是标准的连接组件,使得处理和连接高阶函数变得更容易。表 6-12 列出了一些被认为是组合子的 Haskell 函数。

表 6-12: 一些被认为是组合子的 Haskell 函数

函数 类型
id :: a -> a
const :: a -> b -> a
flip :: (a -> b -> c) -> b -> a -> c
(.) :: (b -> c) -> (a -> b) -> a -> c
($) :: (a -> b) -> a -> b

身份函数id乍一看似乎是一个无意义的函数,因为当我将其应用于某个值时,我得到的还是那个值。那么,身份函数有什么用呢?身份函数的意义,就像我们在第二章中研究的匿名函数一样,体现在它作为高阶函数的输入。在第二十二章中,我们将编写一个高阶函数来展示向量场。该函数的一个输入将是一个接受一个数字并返回另一个数字的缩放函数。最简单的缩放可以通过使用身份函数实现,它不改变任何输入。高阶函数要求提供某种缩放函数,当不需要缩放时,可以提供身份函数id

const组合子将一个值转化为一个常量函数,该函数返回该值。例如,const 3是一个无论输入是什么都会返回3的函数。所以,const 3 7的结果是3const 3 5的结果是3const 3 "Hi"的结果也是3。在第十四章中,我们将编写一个函数来解决力学问题,该函数接受一个力的列表作为输入。力是依赖于速度等输入的函数,因此,如果我们想指定一个常量力,可以使用const组合子。在第二十三章中,我们将编写半球体和球体等表面。我们的表面将通过两个参数来指定,这些参数的取值范围可以是函数,因此我们可以写出例如一个三角形表面。如果我们想要一个常量函数,再次可以使用const

flip组合子接受一个高阶函数,并交换其输入的位置。它通常与点自由风格一起使用。例如,指数函数(**)接受两个输入:底数和指数。如果我们提供第一个输入而不提供第二个输入,如(**) 2,我们得到“二的幂”函数。假设我们想要一个将输入值立方的函数,我们需要将(**)的第二个输入固定为3,同时将第一个输入留空,作为立方函数的输入。我们可以用匿名函数 \x -> x ** 3来实现这一点,也可以使用flip,如flip (**) 3。这两种方式都是立方函数的表达式。

组合子composition已在表 1-2 中列出,并在第二章中讨论过。我们将在第十六章中使用它来通过一系列函数传递信息来解决力学问题。最终的解决方案是将这些函数按顺序组合起来的结果。

函数应用组合子已在表 1-2 中列出,并在第一章中讨论过。它看起来是一个无用的操作符,直到我们意识到它的优先级允许它像括号一样作用。

下一节讨论了一类用于分类数据的高阶函数。

基于谓词的高阶函数

谓词是一个类型为a -> Bool的函数,其中a是任何有效的 Haskell 类型。(例如,a可以是像IntR -> R这样的具体类型,也可以是像a这样的类型变量,或者是包含类型变量的类型,如[a],甚至是a -> [b]。)谓词表达了类型a的元素可能具有或不具有的某个属性。例如,整数大于或等于 7 的属性就是一个谓词。我们可以在 Haskell 中定义这样的谓词。

greaterThanOrEq7 :: Int -> Bool
greaterThanOrEq7 n = if n >= 7 then True else False

表 6-13 展示了几个将谓词作为第一个参数的高阶函数。

表 6-13: 一些基于谓词的高阶函数来自 Prelude

函数 类型
filter :: (a -> Bool) -> [a] -> [a]
takeWhile :: (a -> Bool) -> [a] -> [a]
dropWhile :: (a -> Bool) -> [a] -> [a]

让我们来看看这些函数的使用。假设我们定义了以下的“小于 10”谓词:

lt10 :: Int -> Bool
lt10 n = n < 10

表 6-14 显示了如何使用表 6-13 中的高阶函数的示例。

表 6-14: 一些基于谓词的高阶函数的使用示例

表达式 计算结果
filter lt10 [6,4,8,13,7] [6,4,8,7]
takeWhile lt10 [6,4,8,13,7] [6,4,8]
dropWhile lt10 [6,4,8,13,7] [13,7]
any lt10 [6,4,8,13,7] True
all lt10 [6,4,8,13,7] False

让我们逐一讲解表格中的函数。filter 函数返回列表中所有满足谓词的元素,无论它们在列表中的位置如何。takeWhile 函数返回满足谓词的元素,直到它找到一个不满足谓词的元素为止,并返回满足谓词的元素的初始列表。在输入列表中,第一次遇到不满足谓词的元素之后的元素将不会被考虑在结果列表中。dropWhile 函数返回一个列表,该列表从输入列表中的第一个不满足谓词的元素开始,并包含从该点起的每个元素,无论它是否满足谓词。any 函数如果输入列表中有一个或多个元素满足谓词,则返回 True,否则返回 Falseall 函数如果输入列表中的所有元素都满足谓词,则返回 True,否则返回 False

列表推导也可以完成 filter 的工作。

*Main> filter lt10 [6,4,8,13,7]
[6,4,8,7]
*Main>  [x | x <- [6,4,8,13,7], x < 10]
[6,4,8,7]

要使用列表推导来过滤列表,可以在列表推导的右侧的逗号后面包含一个布尔表达式(例如上面的 x < 10)。这样的表达式被称为 guard(保护条件)。只有满足该布尔保护条件的项才会被包括在结果列表中。

数值积分

加速度是速度变化的速率。如果我们知道速度如何依赖于时间,我们可以使用导数来求得加速度,就像我们在第四章中做的那样。那么反过来怎么办呢?如果我们知道加速度如何依赖于时间,并且我们想知道速度,应该怎么做呢?这正是微积分中积分的目的。积分是求导的逆操作;这个命题是微积分基本定理的内容。

引入积分器

如果 a(t) 是物体在时间 t 时的加速度,物体的速度 v(t) 可以通过积分来求得:

Image

其中 v(0) 是物体在时间 0 时的速度。

如果我们正在进行积分,并且积分变量是时间,我们可以想象一个设备,它以时间t的加速度值为输入,输出时间t的速度值。我们可以将这样的设备称为积分器,并可以像在图 6-5 中所示那样进行想象。

图片

图 6-5:积分器是连续的且有状态的。右侧持续变化的输出依赖于左侧持续变化的输入以及某些存储的状态。

积分器的输入描述了输出变化的速率。例如,输入可以是水龙头向水箱中流入的水流量(以加仑/分钟为单位)。输出是水箱中的水量(以加仑为单位)。或者,输入可以是流入电容器的电流,输出则是电容器板上的电荷。流入电容器的电流描述了电荷在电容器板上积累的速率。表 6-15 展示了通过积分关联的物理量。

表 6-15: 通过积分关联的物理量

积分器的输入 积分器的输出
加速度 速度
速度 位置
流量 体积
电容器电流 电容器电荷

积分器是连续的和有状态的。这里的连续是指输入始终存在,输出持续变化。有状态意味着积分器必须保持一些内部信息;也就是说,输出不是输入的纯函数。为了让积分器根据流量输出水箱中的水量,它需要保持一个水量的数值,并且这个数值会根据流量不断更新。类似地,为了让积分器根据电流输出电容器的电荷量,它需要保持一个电荷值,并且这个值会根据输入电流不断更新。

数字积分

我们希望能够教 Haskell 进行积分。不幸的是,虽然自然界提供了许多适合做积分器的好例子,但数字计算却没有。积分器是一个模拟的连续设备。为了建模积分器,我们将从连续转为离散,并在小于我们问题中任何时间尺度的时间步长下进行工作。

图 6-6 展示了我们如何建模一个积分器。

图片

图 6-6:离散且有状态的积分器模型

该模型旨在以离散方式使用。在由Δt隔开的时间瞬间,我们对输入进行采样,将其乘以Δt,然后将结果加到当前输出值中以生成新的输出。如果Δt相对于输入变化的时间尺度非常小,这个离散模型将能够很好地模拟连续积分器。

图 6-6 中的模型构成了数值积分方法的基础。为了积分一个函数,选择一个小的时间步长Δt,在离散时间点上采样函数的值,将每个函数输出乘以Δt,然后将结果求和。

如果我们展开图 6-6 中的有状态积分器,就会得到图 6-7 中的无状态积分器,如图所示,这里我们是通过积分加速度来得到速度。

图片

图 6-7:一个离散且无状态的积分器的功能模型。这个积分器使用中点法则。

所有的矩形都是纯粹的功能性操作(加法和乘法);现在,圆形积分器中包含的状态仅存在于纯函数之间的线路中。为了逼近在时间Δt时的速度,我们在时间Δt/2 时采样加速度,将其乘以Δt,然后将结果加到时间 0 时的速度上。时间Δt/2 的采样被称为数值积分的中点法则。如果Δt相较于该情境下的重要时间尺度很小,那么我们可以从这种方法中获得一个良好的近似值。图 6-7 展示了加速度函数的四个样本;通常我们会要求计算机做更多的采样。

我们希望能够做的是,给计算机一个函数f,给计算机提供上下限ab,然后让它计算这个数字:

图片

从这个角度来看,Integration是一个接受一个函数R -> R作为输入,并接受两个上下限,最终输出一个数字的函数。

type Integration = (R -> R)  -- function
                 -> R        -- lower limit
                 -> R        -- upper limit
                 -> R        -- result

积分通常被认为是求曲线下的面积。数值积分的中点法则在自变量的每个区间的中点处对函数进行采样,如图 6-8 所示。

图片

图 6-8:使用中点法则的数值积分, Δt = 0.5

这是一个使用中点法则的数值积分器的 Haskell 代码:

integral :: R -> Integration
integral dt f a b
    = sum [f t * dt | t <- [a+dt/2, a+3*dt/2 .. b - dt/2]]

传递给函数的第一个参数是用于数值积分的步长。我们之前假设自变量是时间,但在数学中它可以是任何东西。integral函数的第二个参数是需要积分的函数。注意,我们使用单一标识符(f)来命名传递给integral的函数。此外,我们不需要定义函数f;我们在这里做的是命名用户传递给integral的函数。

我们使用算术序列来指定采样函数的时间点。我们使用列表推导式返回一个相同大小的列表,包含函数返回值与步长的乘积。这些乘积就是图 6-8 中矩形的面积。剩下的就是使用sum函数将这些面积加起来。

让我们来测试一下我们的积分函数。

*Main> integral 0.01 (\x -> x**2) 0 1
0.33332499999999987

在这里,我们使用匿名函数来指定一个将输入平方的函数,因为它比写一个函数定义来命名该函数更方便。在第二章中,我们提到过匿名函数将在作为高阶函数的输入时非常有用,现在我们看到一个实例。这个定积分的确切值是 1/3,如此处所示:

Image

实现反导数

类型同义词Integration对应于定积分的概念,其中有一个函数和两个极限,并且期望得到一个数字作为输出,正如在表达式 6.2 中所示。然而,还有第二种思考积分的方式,即将一个函数积分得到另一个函数。例如,在方程 6.1 中,我们将加速度函数积分得到速度函数。

在微积分中,通常会区分定积分和不定积分,或者称为反导数。反导数想要成为导数的反函数,但这里有一个问题,因为像sin\x -> sin x + 7这样的函数具有相同的导数,即cos。因此,导数作为从函数到函数的高阶函数,并没有一个明确的反函数。一个函数f的反导数是任何一个其导数为f的函数F。换句话说,当DF = f时,Ff的反导数。如果Ff的反导数,我们写作

Image

使用没有限制的积分符号和积分常数C。例如,我们写作

Image

其中,C是一个未确定的积分常数。定积分与不定积分之间存在关系。微积分的基本定理声称,如果Ff的任何一个反导数,那么

Image

通过重命名变量和重新排列项,我们得到一个表达式,使我们能够将积分常数与F的初始值相关联。

Image

对于任何实数a,反导数F是一个函数,其值F(x)在x处是它在a处的“初始”值F(a)和fax的定积分之和。如果我们将方程 6.3 中的不定积分∫ f (x)dx与方程 6.4 中的定积分Image关联起来,我们可以将方程 6.4 中的初始值F(a)与–C相关联,其中C是方程 6.3 中的积分常数。从这个意义上讲,积分常数与初始值是相关的。

一个典型的函数有很多可以作为其反导数的函数。我们如何选择其中一个特定的函数呢?有两种方法:我们可以指定一个下限,或者指定一个初始值。指定下限对应于我们之前探索的定积分Integration,指定初始值则导致我们下面要探讨的AntiDerivative

我们称反导数为一个函数,它接受一个初始值(例如方程式 6.1 中的v(0))和一个函数(例如a),并返回一个函数(例如v)。

type AntiDerivative =   R        -- initial value
                    -> (R -> R)  -- function
                    -> (R -> R)  -- antiderivative of function

反导数的概念与积分的概念密切相关。我们可以通过我们已经定义的integral来定义一个antiDerivative函数。

antiDerivative :: R -> AntiDerivative
antiDerivative dt v0 a t = v0 + integral dt a 0 t

第四章展示了如何将方程式 4.5 和 4.12 实现为velFromPosaccFromVel函数。现在让我们在 Haskell 中实现方程式 6.1。

velFromAcc :: R                       -- dt
           -> Velocity                -- initial velocity
           -> (Time -> Acceleration)  -- acceleration function
           -> (Time -> Velocity)      -- velocity function
velFromAcc dt v0 a t = antiDerivative dt v0 a t

我们看到,从加速度函数中求得速度函数其实就是反导数。

从速度函数中求得位置函数怎么样?

Image

在 Haskell 中,这样表示:

posFromVel :: R                   -- dt
           -> Position            -- initial position
           -> (Time -> Velocity)  -- velocity function
           -> (Time -> Position)  -- position function
posFromVel = antiDerivative

仍然是反导数。这里我们使用无点风格来展示另一种编写函数的方式,并强调这两个函数的等价性。之前的velFromAccposFromVel函数是相同的,每一个都等同于antiDerivative。它们都从 0 到t对给定函数进行积分,并加上初始值。

类型同义词表明时间、速度和加速度都被视为数字。

type Time         = R
type Position     = R
type Velocity     = R
type Acceleration = R

也许你宁愿为你的数值积分器提供从下限到上限的步数,而不是步长。这并不难实现。

integralN :: Int -> Integration
integralN n f a b
    = let dt = (b - a) / fromIntegral n
      in integral dt f a b

let关键字引入了局部变量和/或函数,这些变量和函数可以在in关键字之后的主体中使用。变量dt是一个局部变量,在函数integralN内部定义。这个dtintegralN的定义外部不可见。我们在函数外部使用的任何dt都有一个独立的意义,和这个函数内部的dt不同。局部变量尤其在它们在定义的其余部分中使用多次时非常有用。在这种情况下,我们只使用一次dt,因此我们本可以直接在最后一行插入dt的定义。

integralN' :: Int -> Integration
integralN' n f a b
    = integral ((b - a) / fromIntegral n) f a b

使用局部变量dt节省了一组括号,并且使代码更易于阅读,因为dt这个名字在我们看来是有意义的,表示步长。每当你能想到有意义的名字时,我鼓励你使用let定义局部变量。这将有助于代码的读者,包括编写者。

除法运算符(/)只能在相同类型的数字之间使用。由于b - a的类型是Rn的类型是Int,我们不能直接进行除法运算。解决方法是将n转换为R类型,fromIntegral可以完成这个转换。

总结

高阶函数接受另一个函数作为输入和/或产生一个函数作为输出。产生函数作为输出的高阶函数可以看作是接受多个输入的函数。数值积分是一个典型的高阶函数示例,它接受一个函数作为输入。用于编写匿名函数的λ符号也可以用于高阶函数。将一个函数映射到一个列表上是一个高阶函数的例子,它接受另一个函数作为输入,并且类似于列表推导。我们可以通过将二元中缀操作符括起来,来将其转化为高阶函数。Haskell 通过高阶函数iterate实现迭代,该函数接受一个函数和一个起始值,并重复应用该函数来生成一个无限列表。一些高阶函数,如filter,接受一个谓词作为输入。我们也可以通过列表推导来实现过滤功能。在下一章中,我们将介绍一个允许我们绘制函数的库。

习题

习题 6.1. 让我们回到投掷石块向上的例子。也许我们不希望以 30 米每秒的速度向上投掷,而是希望能够根据我们选择的初速度投掷。编写一个函数

yRock :: R -> R -> R

它接受一个初速度作为输入,并返回一个以时间为输入、高度为输出的函数。同时,编写一个函数

vRock :: R -> R -> R

它接受一个初速度作为输入,并返回一个以时间为输入、速度为输出的函数。

习题 6.2. 给出take 4的类型。

习题 6.3. 函数map的类型是(a -> b) -> [a] -> [b]。这意味着map期望第一个参数是类型为a -> b的函数。函数not的类型是Bool -> Boolnot能作为map的第一个参数吗?如果可以,map not的类型是什么?请展示如何从mapnot的类型出发,推导出map not的类型。

习题 6.4. 编写一个函数

greaterThanOrEq7' :: Int -> Bool
greaterThanOrEq7' n = undefined

它完成与greaterThanOrEq7相同的功能,但不使用if-then-else结构。(提示:查看函数lt10。)

习题 6.5. 编写一个类型为Int -> String -> Bool的函数,并用文字描述它的功能。

习题 6.6. 编写一个谓词表达式,用于表示“元素超过六个”的属性,该谓词接受一个列表作为输入。请在谓词定义中包含类型签名。

习题 6.7. 表 6-5 展示了replicate函数的使用示例。在前三个示例中,创建了一个具有请求长度的请求项的列表。在最后一个示例中,创建了一个字符串。这似乎有所不同。解释这里发生了什么。

习题 6.8. 创建一个包含前 1,000 个平方数的列表。不要打印列表,只打印你的定义。你可以先打印前 10 个平方数来检查你的方法是否有效。

练习 6.9. 使用iterate定义一个函数repeat',它与 Prelude 中的repeat函数执行相同的操作。

练习 6.10. 使用takerepeat定义一个函数replicate',它与 Prelude 中的replicate函数执行相同的操作。

练习 6.11. 一辆汽车从静止开始,在一条平坦的高速公路上以 5 m/s² 的加速度加速。使用iterate生成一个包含该车每秒速度的无限列表。(该列表应类似于[0,5,10,15,...]。使用take函数查看无限列表中的前几个元素。)

练习 6.12. 列表推导式可以作为map函数的替代。为了证明这一点,编写一个函数。

  map' :: (a -> b) -> [a] -> [b]

它的功能与map相同。使用列表推导式编写你的定义。

练习 6.13. 列表推导式可以作为filter函数的替代。为了证明这一点,编写一个函数。

  filter' :: (a -> Bool) -> [a] -> [a]

它的功能与filter相同。使用列表推导式编写你的定义。

练习 6.14. 编写一个函数。

average :: [R] -> R
average xs = undefined

计算一个数字列表的平均值。你可以假设列表至少包含一个数字。你可能需要使用fromIntegral函数。

练习 6.15. 生成一输入和二输入的图形,类似于图 6-2 和 6-3,用于高阶函数drop。对高阶函数replicate做同样的操作。

练习 6.16. 作为数值积分的中点法的替代规则,梯形规则也可以使用。在梯形规则中,我们通过一系列梯形的面积之和来近似曲线下的面积,如图 6-9 所示。

Image

图 6-9:梯形规则

对于例子,参见图 6-9,第一个梯形的面积是:

Image

图中所有四个梯形的面积之和是:

Image

为函数编写定义。

trapIntegrate :: Int       -- # of trapezoids n
              -> (R -> R)  -- function f
              -> R         -- lower limit a
              -> R         -- upper limit b
              -> R         -- result
trapIntegrate n f a b = undefined

它接受若干个梯形数、一个函数和两个限制作为参数,并返回(近似)定积分的值,使用梯形规则。测试你的积分器在以下积分上的表现,并查看它能接近正确值多少:

Image

第七章:绘制函数

Image

类型为R -> R的函数是可以在图表上绘制的函数。本章将展示如何绘制这类函数。绘图工具不属于 Prelude 的一部分,因此我们将首先讨论如何安装和使用库模块。

使用库模块

有一些别人写的函数是我们希望使用的,但它们并不包含在 Prelude 中。然而,这些函数存在于可以导入到源代码文件或直接加载到 GHCi 中的库模块中。GHC(我们使用的格拉斯哥 Haskell 编译器)自带一套标准的库模块,但其他模块则需要安装。

标准库模块

Data.List是标准库模块之一。它包含用于处理列表的函数。要将其加载到 GHCi 中,可以使用:module命令(简写为:m)。

Prelude> :m Data.List

现在,我们可以使用此模块中的函数,比如sort

Prelude Data.List> :t sort
sort :: Ord a => [a] -> [a]
Prelude Data.List> sort [7,5,6]
[5,6,7]

请注意,通常显示Prelude>的 GHCi 提示符已经扩展,包含了我们刚刚加载的模块的名称。

若要在源代码文件中使用sort函数,可以在文件中包含以下行:

import Data.List

在源代码文件的顶部。

标准库的文档可以在线访问,网址是https://www.haskell.org,点击 Documentation 然后选择 Library Documentation,或者你也可以直接访问downloads.haskell.org/~ghc/latest/docs/html/libraries/index.html

其他库模块

标准库之外的库模块被组织成。附录中描述了如何安装 Haskell 库包。每个包包含一个或多个模块。对于本章中的绘图,我们需要Graphics.Gnuplot.Simple模块,该模块由gnuplot包提供。

按照附录中的说明安装 gnuplot。安装过程需要几个步骤。安装结束时会执行如下命令:

$ cabal install gnuplot

或者

$ stack install gnuplot

安装完gnuplot包后,你可以重启 GHCi 并将Graphics.Gnuplot.Simple模块加载到 GHCi 中,方法如下:

Prelude Data.List> :m Graphics.Gnuplot.Simple

在开始下一节之前,让我们卸载Graphics.Gnuplot.Simple模块,这样我们就可以从干净的状态开始:

Prelude Graphics.Gnuplot.Simple> :m

执行:m命令而不带任何模块名称将清除所有已加载的模块。

绘图

有时你可能需要快速绘制一个图形,以查看一个函数的形态。下面是使用 GHCi 进行绘制的示例:

Prelude> :m Graphics.Gnuplot.Simple
Prelude Graphics.Gnuplot.Simple> plotFunc [] [0,0.1..10] cos

第一个命令加载一个可以绘制图形的图形模块。第二个命令绘制从 0 到 10 的cos函数,增量为 0.1。这个操作通过plotFunc函数实现,plotFuncGraphics.Gnuplot.Simple模块提供的函数之一。plotFunc函数接受一组属性(在这里是空列表[]),一组计算函数的值(在这里是[0,0.1..10],这是从 0 到 10 的 101 个数字,增量为 0.1),以及一个待绘制的函数(在这里是cos)。

100 个点通常足以得到一个平滑的图形。如果对平滑度要求更高,你可以使用 500 个点或更多。如果只使用 4 个点,你将无法得到平滑的图形(试试看,看看会发生什么)。在第十一章中,我们将学习如何为演示或作业制作一个带有标题和轴标签的漂亮图形。

如果你希望绘制一个在程序文件中定义的函数,你有几种选择:

  • 只在程序文件中放入你想绘制的函数。

  • 使用程序文件导入绘图模块,并定义你想要绘制的函数。

  • 使用程序文件导入绘图模块,定义你想要绘制的函数,并定义图形。

我们将依次探索这些选项。

仅函数

假设我们想要绘制在第二章中定义的square函数,从x = –3 到x = 3。让我们卸载Graphics.Gnuplot.Simple模块,以便从一个干净的状态开始:

Prelude Graphics.Gnuplot.Simple> :m

现在,我们执行以下命令序列:

Prelude> :m Graphics.Gnuplot.Simple
Prelude Graphics.Gnuplot.Simple> :l first.hs
[1 of 1] Compiling Main            ( first.hs, interpreted )
Ok, one module loaded.
*Main Graphics.Gnuplot.Simple> plotFunc [] [-3,-2.99..3] square

第一个命令加载绘图模块,第二个命令加载包含函数定义的文件,第三个命令绘制图形。使用:module命令会清除之前使用:load命令加载的任何源代码文件,因此必须在加载源代码文件之前先加载模块。

函数和模块

如果我们知道程序文件包含我们希望绘制的函数,我们可以在程序文件中导入Graphics.Gnuplot.Simple模块,这样我们就不需要在 GHCi 命令行中执行了。我们可以在程序文件顶部添加以下代码,而不必在 GHCi 中输入:m Graphics.Gnuplot.Simple

import Graphics.Gnuplot.Simple

假设这个扩展的程序文件叫做firstWithImport.hs。让我们从卸载文件和模块开始,清理一下:

*Main Graphics.Gnuplot.Simple> :l
Ok, no modules loaded.
Prelude Graphics.Gnuplot.Simple> :m

在没有文件名的情况下执行:l命令将清除已加载的程序文件,但会保留任何已加载的模块。

现在在 GHCi 中我们执行以下操作:

Prelude> :l firstWithImport.hs
[1 of 1] Compiling Main            ( firstWithImport.hs, interpreted )
Ok, one module loaded.
*Main> plotFunc [] [-3,-2.99..3] square

你应该会看到你在上一节中看到的相同图形。

函数、模块和绘图定义

如果我们提前知道想要的图形,我们可以将绘图命令包含在程序文件中。在我们的源代码文件中,我们将包含import命令,

import Graphics.Gnuplot.Simple

定义类型R的类型同义词,

type R = Double

我们将绘制的函数,

square :: R -> R
square x = x**2

我们想要的图形,

plot1 :: IO ()
plot1 = plotFunc [] [-3,-2.99..3] square

注意plot1的类型IO ()(读作“eye oh unit”)。IO代表输入/输出,它表示一个具有副作用的非纯函数的类型。在这种情况下,副作用是图形在屏幕上弹出。任何类型为IO ()的内容,都是仅为了其副作用而执行的,而不是因为我们期待返回一个值。

让我们在 GHCi 中清理一下工作区。

*Main> :l
Ok, no modules loaded.
Prelude> :m

如果源代码文件名为QuickPlotting.hs,我们只需加载文件并给出我们的图形名称。

Prelude> :l QuickPlotting.hs
[1 of 1] Compiling Main            ( QuickPlotting.hs, interpreted )
Ok, one module loaded.
*Main> plot1

你应该再次看到图形。

总结

本章介绍了库模块,包括标准库模块以及需要安装的模块。我们安装了gnuplot包,该包提供了Graphics.Gnuplot.Simple模块,并展示了如何使用函数plotFunc绘制基本图形。本章还展示了使用模块提供的函数的不同方式,既可以通过:module命令将模块加载到 GHCi 中,也可以通过import关键字将模块导入源代码文件。

下一章将介绍类型类,这是一个利用类型间共性的机制。

练习

练习 7.1. 绘制从x = –10 到x = 10 的 sin(x)图形。

练习 7.2. 绘制从t = 0 到t = 6 秒的yRock30函数图形。

练习 7.3. 绘制从t = 0 到t = 4 秒的yRock 20函数图形。使用plotFunc作为参数时,你需要将yRock 20括在圆括号中。

第八章:类型类

Image

我们见过具有具体类型的函数,例如not,它接受一个Bool类型作为输入,并返回一个Bool类型作为输出。我们也见过使用类型变量来表示它们可以处理所有类型的函数,例如head,它接受任何类型的列表并返回第一个元素。使用类型变量表示了所有类型之间的共性。

在处理单一类型的函数和处理所有类型的函数之间,我们需要表达一个更有限的类型间共性,这种共性并不适用于所有类型。例如,我们希望加法操作适用于像IntIntegerDouble这样的数值类型,而无需为所有类型定义加法。术语参数多态性用于表示所有类型之间的共性。之前提到的head函数就是对输入列表的基础类型进行参数多态性的。术语临时多态性则用于表示更有限的共性。类型类是 Haskell 提供的处理临时多态性的机制。它们表达的是类型之间的共性,而这种共性并不适用于所有类型。

在本章中,我们将介绍类型类的概念,并介绍 Prelude 中一些常见的类型类。我们将描述哪些基本类型是这些类型类的成员,以及为什么这样设计。Haskell 中有三个不同的指数运算符的解释也基于类型类。Section是基于一个运算符和它的一个参数构造的函数,许多 section 的类型涉及类型类。虽然类型类提供了一种表达共性的好方法,但它们也允许编译器在需要时无法确定值的具体类型,我们将给出一个示例,说明代码编写者必须提供额外的类型信息给编译器。

类型类与数字

让我们首先在 GHCi 中询问数字4的类型。

Prelude> :t 4
4 :: Num p => p

期望数字4的类型是IntInteger是完全合理的。但 Haskell 语言的设计者希望像4这样的数字能够根据程序员的需要,既可以是Int,也可以是IntegerDouble,甚至其他一些类型。正因如此(以及其他更有说服力的原因),他们发明了类型类的概念。

类型类就像一个俱乐部,类型可以加入该俱乐部,这样类型就可以使用该俱乐部中的某些函数。IntIntegerDouble这些类型都属于类型类Num(数字类型的缩写)。这些类型都可以使用加法、减法和乘法操作,因为这些函数是由Num类型类提供的。当一个类型属于某个类型类时,我们称它是该类型类的实例

类型签名 4 :: Num p => p 可以理解为 “4 的类型是 p,前提是 p 属于类型类 Num。” 字母 p 是这个类型签名中的类型变量,代表任何类型。双箭头 (=>) 左边的条件是 类型类约束。在上面的类型签名中,存在一个类型类约束 Num p,它表示 p 必须属于类型类 Num

类型类是一种表示类型之间共性的方式。类型 IntIntegerFloatDouble 之间有很多相似之处;也就是说,我们希望对它们执行相同类型的操作。我们希望能够对这些类型的数字进行加、减、乘等操作。通过让类型类 Num 负责加法、减法和乘法,我们允许与 Int 类型配合使用的加法运算符同样适用于 Double 类型。

在类型签名 4 :: Num p => p 中,GHCi 尚未为数字 4 确定具体类型。但这种对 4 类型的非承诺态度不能永远持续下去。最终,Haskell 编译器将要求每个值都具有具体类型。编译器无法为值分配具体类型可能会带来麻烦。然而,GHCi 有一些类型默认规则可以让我们的生活更轻松。例如,如果你输入以下这一行

x = 4

将其写入程序文件(如 typetest.hs),不给 x 添加类型签名,加载到 GHCi 中,然后询问 x 的类型,

Prelude> :l typetest.hs
[1 of 1] Compiling Main            ( typetest.hs, interpreted )
Ok, one module loaded.
*Main> :t x
x :: Integer

GHCi 会告诉你 x 的类型是 Integer。在这里,GHCi 在没有我们指定类型的情况下,已经为其确定了具体的类型。

还有其他情况,比如在本章末尾的“类型类与绘图示例”中,GHCi 无法分配具体类型,在这种情况下,你需要通过为代码添加类型签名来帮助它。

Prelude 中的类型类

表 8-1 显示了 Prelude 提供的几个类型类。该表还显示了哪些基本类型是每个类型类的实例。

表 8-1: 各种类型类的实例基本类型

类型类 Bool Char Int Integer Float Double
Eq X X X X X X
Ord X X X X X X
Show X X X X X X
Num X X X X
Integral X X
Fractional X X
Floating X X

以下部分将讨论 表 8-1 中列出的类型类的用途和使用方法。

Eq 类型类

我们希望能够询问计算机两个事物是否相等。这就是 ==(相等)运算符(首次出现在 表 1-2)的作用。但是,如果我们认真的考虑函数的类型,(==) 的类型应该是什么呢?

一个接受两个字符串作为输入并输出一个布尔值(true 或 false),表示这两个字符串是否相等的函数,应该具有类型String -> String -> Bool。一个接受两个整数作为输入并输出一个布尔值,表示这两个整数是否相等的函数,应该具有类型Integer -> Integer -> Bool。如果我们需要为每种类型(StringInteger等)分别编写一个函数来检查相等性,那将是一个不幸的局面。也许一个类型变量可以解决这个问题,(==)的类型可以是a -> a -> Bool。这几乎是正确的,但类型a -> a -> Bool暗示每个类型a都可以检查相等性,而实际上有些类型是无法检查相等性的(比如函数类型)。

类型类Eq用于具有相等概念的类型。换句话说,对于那些相等性检查有意义的类型,将会是Eq的实例。这些类型定义了操作符==/=。你可以在表格 8-1 中看到,所有六种基本类型(BoolCharIntIntegerFloatDouble)都是Eq的实例。

函数(==)的类型是

*Main> :t (==)
(==) :: Eq a => a -> a -> Bool

这意味着我们可以在任何两个相同类型a的表达式之间使用==操作符,只要aEq的实例。哪些类型不会是Eq的实例呢?通常,函数类型不是Eq的实例。例如,类型R -> R不是Eq的实例。

这是因为通常很难或不可能检查两个函数是否相等。(有一个严格的数学结果叫做理查森定理,它给出了充分的条件,这些条件相当宽松,说明了在何种情况下函数相等性是不可判定的。)

从计算物理的角度来看,FloatDouble作为Eq的实例是一个坏主意。因为这两种类型用于近似计算,你不应该测试FloatDouble是否相等。(这个规则的一个例外是你可能需要在尝试除以它们之前检查FloatDouble是否为零。)从计算机的角度来看,这些类型由有限数量的位表示,计算机会愉快地检查一个Double的每一位是否与另一个Double对应的位相同。但正如我们在第一章中看到的,sqrt 5 ^ 2的位与5的位不相同。它们非常接近,但不完全相同。如果你想更深入了解,关于浮点计算的一个很好的介绍是[5]。计算物理的关键消息是,避免对近似类型如Double使用==。浮点数的相等性检查问题并不仅仅是 Haskell 或函数式编程中的问题。任何语言中,近似计算结果都不应进行相等性测试。

Show 类型类

Show 类型类用于那些可以用文本显示其值的类型。如 表 8-1 中所示,所有基本类型(BoolCharIntIntegerFloatDouble)都是 Show 的实例。

函数类型通常不是 Show 的实例。如果我在 GHCi 提示符下输入一个函数的名字,我会得到一条消息,抱怨没有 Show 实例来显示 sqrt

*Main> sqrt

<interactive>:5:1: error:
    • No instance for (Show (Double -> Double))
        arising from a use of print
        (maybe you haven't applied a function to enough arguments?)
    • In a stmt of an interactive GHCi command: print it

GHCi 知道如何将 sqrt 函数应用于数字并显示结果,但它不知道如何显示 sqrt 函数本身。这背后的原因是设计决策:任何属于 Show 类型类的成员也应当是 Read 类型类的成员。这意味着从其内部形式渲染为 StringShow 实例)能够被从 String 转换回其内部形式(Read 实例)。如果一个函数的 Show 实例返回函数的名称,甚至是其源代码定义,它通常无法转换回其内部表示,因为缺少源文件的上下文。需要注意的是,sqrt 是一个完全合法的 Haskell 表达式,具有明确的类型,尽管它无法被显示。

Num 类型类

如我们之前所见,Num 类型类用于数值类型。从 表 8-1 中可以看到,IntIntegerFloatDoubleNum 的实例,而 BoolChar 不是。(+)(加法)、(-)(减法)和 (*)(乘法)是 Num 所拥有的函数。(+) 函数的类型是

*Main> :t (+)
(+) :: Num a => a -> a -> a

这意味着我们可以在任何两个相同类型 a 的表达式之间使用运算符 +,只要 aNum 的实例,结果将是一个类型为 a 的表达式。Num 类型类允许加法函数 (+) 的类型表现得像 Int -> Int -> IntInteger -> Integer -> IntegerFloat -> Float -> FloatDouble -> Double -> Double

我们可以使用 GHCi 的 :info(或 :i)命令来查询关于 Num 类型类的信息:

*Main> :i Num
class Num a where
  (+) :: a -> a -> a
  (-) :: a -> a -> a
  (*) :: a -> a -> a
  negate :: a -> a
  abs :: a -> a
  signum :: a -> a
  fromInteger :: Integer -> a
  {-# MINIMAL (+), (*), abs, signum, fromInteger, (negate | (-)) #-}
   -- Defined in 'GHC.Num'
instance Num Word -- Defined in 'GHC.Num'
instance Num Integer -- Defined in 'GHC.Num'
instance Num Int -- Defined in 'GHC.Num'
instance Num Float -- Defined in 'GHC.Float'
instance Num Double -- Defined in 'GHC.Float'

在这里我们看到,Num 拥有加法、减法、乘法和其他几个函数。我们还看到了作为 Num 实例的一些具体类型。

Integral 类型类

Integral 类型类用于像整数一样表现的类型。一个类型必须先是 Num 的实例,才能成为 Integral 的实例。从 表 8-1 中可以看到,IntIntegerIntegral 的实例,而 FloatDouble 不是。函数 rem 的类型是,rem 计算一个整数除以另一个整数的余数。

*Main> :t rem
rem :: Integral a => a -> a -> a

这意味着我们可以在任何两个相同类型 a 的表达式之间使用 rem 函数,只要 aIntegral 的实例,结果将是一个类型为 a 的表达式。

Ord 类型类

我们希望能够进行比较,大多数人会同意

"kitchen" > 4

是没有意义的。Haskell 语言的设计者决定,这样的表达式不应该求值为 TrueFalse,而应该被视为类型错误。为了使用比较运算符(<<=>,或 >=),必须满足两个要求:

  • 被比较的两个对象必须具有相同的类型。我们将这种类型称为 a

  • 类型 a 必须属于 Ord 类型类。

Ord 类型类用于具有顺序概念的类型。一个类型必须首先是 Eq 的实例,才能成为 Ord 的实例。(<) 函数的类型是

*Main> :t (<)
(<) :: Ord a => a -> a -> Bool

这意味着我们可以在任何两个相同类型 a 的表达式之间使用运算符 <,前提是 aOrd 的实例。(<) 的类型表达了我们上面列出的两个要求。

有些类型没有明显的比较概念。例如,三维向量,我们将在第十章中定义 Vec 类型,并且三维向量没有明显的顺序概念。这并不是说向量无法定义比较。我们可以比较它们的大小或它们的 x 分量。例如。关键点是,比较的含义没有单一的、明显的候选项。由于向量没有明显的比较概念,Vec 不属于 Ord

那么表达式如何呢

x > y

如果 x 的值是 4.2,而 y 的值是 4,怎么办?大多数人会同意,这个表达式应该求值为 True。但是如果 x 的类型是 Double,而 y 的类型是 Int,Haskell 会将该表达式视为错误,因为 DoubleInt 不是相同的类型。为了比较两个事物,我们必须显式地将其中一个的类型转换为另一个的类型。为了避免四舍五入错误,我们希望将 y(即 Int)转换为 Double。为此,我们可以使用 Prelude 函数 fromIntegral,将原始表达式 x > y 替换为

x > fromIntegral y

fromIntegral 的类型是

*Main> :t fromIntegral
fromIntegral :: (Integral a, Num b) => a -> b

表示 fromIntegral 将会把 Integral 中的任何类型转换为 Num 中的任何类型。类型检查器会推导出,在这种情况下,既然 x 的类型是 Double,那么 y 需要转换成 Double

一些其他编程语言有 类型强制转换 的过程,它会改变一个值的类型,以便与另一个值进行比较或使用。例如,整数在比较时会自动转换为浮动点数。Haskell 没有自动类型转换,这一决定是经过深思熟虑的。语言设计者认为,许多或大多数类型强制转换实际上是程序员无意中犯的错误,而不是编译器可以提供的自动帮助。

分数类型类

Fractional 类型类用于支持除法的数值类型。一个类型必须是 Num 的实例,才能成为 Fractional 的实例。我们在 表 8-1 中看到,FloatDoubleFractional 的实例,而 IntInteger 不是。函数 (/) 的类型是

*Main> :t (/)
(/) :: Fractional a => a -> a -> a

这意味着我们可以在任何两个类型为 a 的表达式之间使用 / 运算符,只要 aFractional 类型类的实例。结果将是一个类型为 a 的表达式。

浮动类型类

Floating 类型类用于存储为浮点数的数值类型,即存储为不精确的近似值。在一个类型成为 Floating 的实例之前,它必须先是 Fractional 的实例。你可以在 表 8-1 中看到,FloatDoubleFloating 的实例,而 IntInteger 不是。函数 cos 的类型是

*Main> :t cos
cos :: Floating a => a -> a

这意味着我们可以在任何类型为 a 的表达式上使用 cos 函数,只要 aFloating 类型类的实例。结果将是一个类型为 a 的表达式。

图 8-1 显示了我们刚才讨论的数值类型类之间的关系。

Image

图 8-1: 数值类型类 Num, Integral, FractionalFloating 之间的关系

在 图 8-1 中,类型前面有一个符号,而类型类没有。如你所见,类型 IntInteger 是类型类 IntegralNum 的实例。类型 FloatDouble 是类型类 FloatingFractionalNum 的实例。

指数运算和类型类

Haskell 提供了三种指数运算符,如 表 8-2 所示。这些运算符的区别在于它们所依赖的类型类约束以及实现方式。单个插入符号运算符 (^) 要求指数是一个非负整数。^ 运算符通过重复将 Num 自乘来进行指数运算。双插入符号运算符 (^^) 要求指数是一个整数,这一约束由 Integral b 类型类强制。^^ 运算符可以通过重复乘法和取倒数来实现,并且可以接受负指数。双星号运算符 (**) 要求基数和指数具有相同的类型,并且该类型必须是 Floating 类型类的实例。此运算符需要更复杂的实现,通过对数运算和指数函数来完成。

表 8-2: Haskell 的三种指数运算函数

函数 类型
(^) :: (Integral b, Num a) => a -> b -> a
(^^) :: (Fractional a, Integral b) => a -> b -> a
(**) :: Floating a => a -> a -> a

由于 FloatDoubleFloating 类型类的成员,因此它们表示的是近似的数字,** 运算符通常会进行近似计算。对于非整数指数,这是我们想要的行为。在适当的情况下,插入符号和双插入符号运算符可以进行精确计算。

表 8-3 显示了表达式 x ^^ y 中基数 x 和指数 y 的类型(IntIntegerFloatDouble)的允许情况。

表 8-3: 使用双插入符号指数运算符的 xy 的可能类型

y :: Int y :: Integer y :: Float y :: Double
x :: Int
x :: Integer
x :: Float ^^ ^^
x :: Double ^^ ^^

由于基数必须具有 Fractional 实例类型,只有 FloatDouble 可以作为基数的类型。由于指数必须具有 Integral 实例类型,只有 IntInteger 可以作为指数的类型。

区段

中缀运算符期望其左侧和右侧都有一个参数。如果只给定其中一个参数,结果表达式可以被看作是一个等待另一个参数的函数。Haskell 允许我们通过将运算符和其中一个参数括在括号中来创建这样的函数。通过将运算符和一个参数括在括号中形成的函数被称为区段

表 8-4 显示了区段示例及其类型。许多有用的区段具有带有类型类约束的类型。

表 8-4: 区段示例

函数 类型
(+1) :: Num a => a -> a
(2*) :: Num a => a -> a
(²) :: Num a => a -> a
(2^) :: (Integral b, Num a) => b -> a
('A':) :: [Char] -> [Char]
(:"end") :: Char -> [Char]
("I won't " ++) :: [Char] -> [Char]
($ True) :: (Bool -> b) -> b

例如,(+1),也可以写作 (1+),是一个将 1 加到其参数上的函数,(2*),也可以写作 (*2),是一个将其参数乘以 2 的函数。然而,区段 (²)(2^) 不是同一个函数;前者是平方函数,后者是“2 的幂次”函数。

区段的用法类似于匿名函数的用法:它们提供了一种快速指定函数的方式,而不需要为函数命名,通常作为高阶函数的输入。

让我们使用区段来对平方函数进行积分。使用 第六章 中的 integral 函数,

type R = Double

integral :: R -> (R -> R) -> R -> R -> R
integral dt f a b
    = sum [f t * dt | t <- [a+dt/2, a+3*dt/2 .. b - dt/2]]

我们可以如下使用区段来表示平方函数:

*Main> :l TypeClasses
[1 of 1] Compiling Main            ( TypeClasses.lhs, interpreted )
Ok, one module loaded.
*Main> integral 0.01 (²) 0 1
0.33332499999999987

就像匿名函数一样,区段(sections)为程序员提供了在不命名函数的情况下“动态”创建函数的工具。像匿名函数一样,使用区段需要小心,因为很容易忘记你定义某个特定区段的原因。这是因为简洁的语法并没有提供关于你当时意图的任何线索。如果一个区段的含义不立即显现,定义一个简短的带有描述性名称的函数可能是更好的选择。

类型类和绘图的示例

在本章开始时,我们提到过,如果 Haskell 类型检查器无法确定它需要处理的每个表达式的具体类型,它有时会报错。解决方法是给代码添加类型签名或类型注解。作为示例,创建一个新的程序文件叫做typeTrouble.hs,并包含以下代码:

import Graphics.Gnuplot.Simple

plot1 = plotFunc [] [0,0.01..10] cos

当我尝试将这个文件加载到 GHCi 时,我得到这个看起来很可怕的错误信息:

typeTrouble.hs:3:9: error:
    • Ambiguous type variable 'a0' arising from a use of 'plotFunc'
      prevents the constraint '(Graphics.Gnuplot.Value.Atom.C
                                  a0)' from being solved.
      Probable fix: use a type annotation to specify what 'a0' should be.
      These potential instances exist:
        instance [safe] Graphics.Gnuplot.Value.Atom.C Integer
          -- Defined in 'Graphics.Gnuplot.Value.Atom'
        instance [safe] Graphics.Gnuplot.Value.Atom.C Double
          -- Defined in 'Graphics.Gnuplot.Value.Atom'
        instance [safe] Graphics.Gnuplot.Value.Atom.C Float
          -- Defined in 'Graphics.Gnuplot.Value.Atom'
        ...plus one other
        ...plus 11 instances involving out-of-scope types
        (use -fprint-potential-instances to see them all)
    • In the expression: plotFunc [] [0, 0.01 .. 10] cos
      In an equation for 'plot1': plot1 = plotFunc [] [0, 0.01 .. 10] cos
   |
3  | plot1 = plotFunc [] [0,0.01..10] cos
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

别慌。这个错误信息包含了比我们解决问题所需更多的信息。最有用的部分是信息的第一行,它告诉我们代码中问题所在的位置(第 3 行,第 9 列)。在我们代码的第 3 行,第 9 列是plotFunc函数。让我们来看一下plotFunc的类型。

*Main> :t plotFunc

<interactive>:1:1: error: Variable not in scope: plotFunc

嗯,生活变得更糟了。但这个错误其实很容易解决。“Variable not in scope”表示 GHCi 不知道这个函数。其实这很有道理,因为它没有包含在 Prelude 中(Prelude 是 GHCi 启动时自动加载的内建函数集合),而且 GHCi 拒绝加载我们的typeTrouble.hs文件,因为它遇到了问题。目前,GHCi 对plotFunc一无所知。plotFunc函数是在Graphics.Gnuplot.Simple模块中定义的。我们可以通过手动加载绘图模块来访问plotFunc,就像我们最初为了快速绘制图形所做的那样。

*Main> :m Graphics.Gnuplot.Simple

现在,让我们再次查询plotFunc的类型。

Prelude Graphics.Gnuplot.Simple> :t plotFunc
plotFunc
  :: (Graphics.Gnuplot.Value.Atom.C a,
      Graphics.Gnuplot.Value.Tuple.C a) =>
     [Attribute] -> [a] -> (a -> a) -> IO ()

=>的左边有几个类型类约束。我不清楚这些类型类的具体细节,但只要a(一个类型变量)属于这两个类型类,plotFunc的类型就是以下内容:

[Attribute] -> [a] -> (a -> a) -> IO ()

换句话说,plotFunc需要一个Attribute列表(到目前为止我们在示例中给出了一个空列表)、一个a的列表,以及一个接受a作为输入并返回a作为输出的函数。如果我们把这些都给plotFunc,它会返回一个IO (),这意味着它会实际一些事情(绘制图形)。

解决“模糊类型变量”错误的关键在于错误信息第五行中的建议。给代码添加类型签名。Haskell 类型检查器希望得到更多帮助,以便弄清楚事物的类型。特别是,它无法确定[0,0.01..10]cos的类型。让我们询问 GHCi 这两个的类型。

Prelude Graphics.Gnuplot.Simple> :t [0,0.01..10]
[0,0.01..10] :: (Fractional a, Enum a) => [a]
Prelude Graphics.Gnuplot.Simple> :t cos
cos :: Floating a => a -> a

这两个表达式都包含了类型类约束。

解决这个问题的一种方法是给列表[0,0.01..10]一个名称和类型签名。我们可以创建一个名为typeTrouble2.hs的程序文件,内容如下:

import Graphics.Gnuplot.Simple

xRange :: [Double]
xRange = [0,0.01..10]

plot2 = plotFunc [] xRange cos

这个程序文件应该能正常加载,并且当你输入plot2时,能够给出一个不错的图形。试试看吧。

第二种解决方案是在线使用列表[0,0.01..10]的地方指定其类型。我们可以创建一个名为typeTrouble3.hs的程序文件,内容如下:

import Graphics.Gnuplot.Simple

plot3 = plotFunc [] ([0,0.01..10] :: [Double]) cos

第三种解决方案是我最喜欢的,因为它涉及的按键最少,就是告诉编译器列表的最后一个元素10的类型是Double。这意味着列表中的所有元素都是Double类型。

import Graphics.Gnuplot.Simple

plot4 = plotFunc [] [0,0.01..10 :: Double] cos

这个故事的寓意是你应该为你定义的所有函数包含类型签名,并且如果类型检查器提示错误,你应该准备好添加更多的类型签名。

总结

本章介绍了类型类的概念,类型类包含类型并拥有特定的函数。我们讨论了 Prelude 中的几个标准类型类,以及具有类型类约束的函数。我们还看到,区段是没有名称的函数,通过将运算符与其参数结合形成。许多区段具有带类型类约束的类型。最后,我们举了一个例子,说明在代码中添加类型注解是如何满足 Haskell 类型检查器的要求的。

随着类型类的引入,我们几乎描述了 Haskell 的整个类型系统。它包括基本类型、函数类型、列表类型、类型变量和类型类。一旦我们在下一章中讲解元组类型,我们就会完整描述 Haskell 的类型系统。

练习

练习 8.1. 是否有可能一个类型属于多个类型类?如果可以,请举个例子。如果不行,为什么不行?

练习 8.2. 本章中我们提到函数类型通常不是Eq的实例,因为检查两个函数是否相等太困难。

(a) 数学上,两个函数相等意味着什么?

(b) 为什么计算机通常很难或者不可能检查两个函数是否相等?

(c) 请举一个容易检查相等性的函数类型的具体例子。

练习 8.3. 函数(*2)与函数(2*)是一样的。函数(/2)与函数(2/)是一样的吗?解释这些函数的作用。

练习 8.4. 在第二章中,我们定义了一个函数square。现在我们知道 Haskell 有区段(sections)后,可以看出我们其实不需要定义square。请展示如何使用区段来编写一个平方其参数的函数。对于一个立方其参数的函数,如何使用区段表示?

练习 8.5. 你可以通过使用 GHCi 命令 :info(缩写为 :i)来获取关于类型或类型类的信息,后面跟上你想查询的类型或类型类的名称。如果你查询的是类型,GHCi 会告诉你该类型属于哪些类型类的实例(例如,instance Num Double 表示类型 Double 是类型类 Num 的实例)。如果你查询的是类型类,GHCi 会告诉你哪些类型是该类型类的实例。

(a) 我们在 表 8-1 中展示了类型 Integer 是类型类 EqOrdShowNumIntegral 的实例。还有一些我们没有讨论的类型类,Integer 也是这些类型类的实例。找出这些类型类。

(b) 类型类 Enum 适用于可以被枚举或列出的类型。哪些 Prelude 类型是 Enum 的实例?

练习 8.6. 找出以下 Prelude Haskell 表达式的类型(其中一些是函数,有些不是):

(a) 42

(b) 42.0

(c) 42.5

(d) pi

(e) [3,1,4]

(f) [3,3.5,4]

(g) [3,3.1,pi]

(h) (==)

(i) (/=)

(j) (<)

(k) (<=)

(l) (+)

(m) (-)

(n) (*)

(o) (/)

(p) (^)

(q) (**)

(r) 8/4

(s) sqrt

(t) cos

(u) show

(v) (2/)

练习 8.7. 如果 8/4 = 2,并且 2 :: Num a => a(2 对于每个类型类 Num 中的类型 a 都是类型 a),那么为什么 8/4 :: Fractional a => a

练习 8.8. 函数 quotremdivmod 都与整数除法和余数有关。所有这些函数都适用于类型类 Integral 的实例,具体如下表所示:

函数 类型
quot :: Integral a => a -> a -> a
rem :: Integral a => a -> a -> a
div :: Integral a => a -> a -> a
mod :: Integral a => a -> a -> a

通过操作这些函数,尝试用语言解释每个函数的作用。quotdiv 有什么区别?remmod 有什么区别?

练习 8.9. 制作一张类似于 表 8-3 的表格,展示哪些类型 IntIntegerFloatDouble 可以作为表达式 x ^ y 中的底数 x 和指数 y。同样地,做一张表格展示哪些类型可以作为 x ** y 中的底数和指数。最后,找出一对类型 ab,对于这对类型,没有任何指数运算符允许底数为 a 类型,指数为 b 类型。

第九章:元组与类型构造器

Image

元组是有序值的集合。对于有序对、有序三元组、有序四元组等,均有对应的元组类型。元组中值的类型通常是不同的,但也可以相同。

在这一章中,我们将讨论元组及其推广:类型构造器。函数类型、列表类型和元组类型都是类型构造器的例子。我们将介绍一种统一的方式来思考这些类型构造器,表面上看它们似乎不同,但它们都有共同的特征。最后,我们将重新审视数值积分,展示如何结合元组和迭代,给出一种执行数值积分的方法,稍后我们将推广这一方法来求解微分方程。

最简单的元组是对。一个对类型通过给出一个有序对(a,b)来指定,其中ab是两种类型,并用逗号分隔并用括号括起来。如果x :: ay :: b,那么(x,y) :: (a,b)。值(x,y)的类型是(a,b)。逗号和括号有两个作用:它们构成了类型(a,b),也构成了该类型的值(x,y)

例如,这里有一个元组,由描述一个人姓名的String和表示该人考试成绩的Int组成:

nameScore :: (String,Int)
nameScore = ("Albert Einstein", 79)

在这个例子中,元组类型是(String,Int),元组值是("Albert" Einstein", 79)

为了更好地理解元组,让我们编写一个函数pythag,计算直角三角形的斜边长,已知它的两个直角边的长度。我们将通过一个元组将这两个边长传递给函数。以下是编写这个函数的一种方式:

pythag :: (R,R) -> R
pythag (x,y) = sqrt (x**2 + y**2)

这个类型签名表明,pythag期望一个由两个R类型组成的对作为输入,并产生一个R类型作为输出。第二行中我们将输入称为(x,y)(而不是像p这样的简单变量),意味着这个定义在输入上使用了模式匹配。这类似于我们之前看到的Bool和列表的模式匹配。对于对的模式匹配很简单,因为只有一个模式:每个对都有形式(x,y)。回想一下,Bool有两个模式(TrueFalse),列表有两个模式(空列表[]和包含一个元素及列表的x:xs)。

有几个 Prelude 函数用于处理对。fst函数接受一个对作为输入,返回对的第一个元素作为输出。snd函数接受一个对作为输入,返回对的第二个元素作为输出。我们可以在 GHCi 中测试这个行为:

Prelude> fst ("Albert Einstein", 79)
"Albert Einstein"

fstsnd这两个函数的类型完全由类型变量给出,表明这些函数与载荷的类型无关。

Prelude> :t fst
fst :: (a, b) -> a
Prelude> :t snd
snd :: (a, b) -> b

通常,有两种方式可以从像包含多个数据项的对这样的类型中获取数据。一种方式是模式匹配,另一种是使用像fstsnd这样的函数,通常被称为消解器。虽然理论上可以写出只使用模式匹配而不使用消解器的代码,但有时消解器更简单,所以能够同时使用两者是很方便的。如果你处理的数据结果是一个对,并且你只需要其中的一部分,那么消解器fstsnd就特别有用。一个例子是本章最后一节中的integral'函数。

对二元函数进行柯里化

在第六章中,我们讨论了柯里化,作为一种将函数视为接受多个参数的方式。通过使用高阶函数,我们可以将斜边函数写成如下形式:

pythagCurried :: R -> R -> R
pythagCurried x y = sqrt (x**2 + y**2)

元组提供了另一种写二元函数的方式。虽然pythagpythagCurried是不同的 Haskell 函数,具有不同的类型,但它们在数学上做的是同样的事情:它们都表示寻找斜边的数学函数。我们将pythag称为斜边函数的元组形式,将pythagCurried称为斜边函数的柯里化形式

这两种编码二元函数的方式是互斥的。你必须为一个特定的函数选择其中一种方式;不能同时使用这两者。请注意,元组形式的pythag需要使用括号和逗号来包围两个参数。这是因为你需要一个元组作为输入!请注意,柯里化形式的pythagCurried没有括号也没有逗号。并不是逗号是可选的,而是逗号不能出现。

有时你可能会使用一种形式,但后来意识到你希望使用另一种形式。Haskell 提供了两个函数让你在这两种形式之间转换。为了从元组形式转换为柯里化形式,Haskell 提供了函数curry。函数curry pythag与我们之前定义的pythagCurried完全相同。函数uncurry pythagCurried与函数pythag相同。然而,写curry pythagCurrieduncurry pythag是没有意义的;当编译器尝试读取它们时,这些构造会产生类型错误。

如果我们将这些函数加载到 GHCi 中(代码文件可以在https://lpfp.io找到),

Prelude> :l Tuples
[1 of 1] Compiling Main            ( Tuples.lhs, interpreted )
Ok, one module loaded.

我们可以看到,pythagCurriedcurry pythag的类型是相同的:

*Main> :t pythagCurried
pythagCurried :: R -> R -> R
*Main> :t curry pythag
curry pythag :: R -> R -> R

我们还可以看到,pythaguncurry pythagCurried的类型是相同的:

*Main> :t pythag
pythag :: (R, R) -> R

*Main> :t uncurry pythagCurried
uncurry pythagCurried :: (R, R) -> R

看一下curryuncurry的类型:

*Main> :t curry
curry :: ((a, b) -> c) -> a -> b -> c
*Main> :t uncurry
uncurry :: (a -> b -> c) -> (a, b) -> c

图 9-1 试图解释这些复杂的类型。图中显示的两种类型是编码一个二元函数的不同方式。高阶函数curryuncurry将一种二元函数类型转换成另一种。

Image

图 9-1:两变量函数的两种类型。高阶函数 curryuncurry 将一种类型的两变量函数转换为另一种类型的函数。

三元组

除了配对之外,你还可以创建三元组,或者具有更多组成部分的元组。然而,fstsnd 函数仅适用于配对。要访问三元组和更大元组的元素,标准方法是使用模式匹配。例如,可以按如下方式定义提取三元组组件的函数:

fst3 :: (a,b,c) -> a
fst3 (x,y,z) = x

snd3 :: (a,b,c) -> b
snd3 (_,y,_) = y

thd3 :: (a,b,c) -> c
thd3 (_x,_y,z) = z

fst3snd3thd3 的定义使用模式匹配为三元组中的元素分配名称。这些名称随后可以在定义的右侧用于表示我们希望函数返回的值。在 fst3 函数中,yz 的值没有被使用。因为它们没有被使用,从某种意义上说,给它们命名是多余的。在 snd3 的定义中,_(下划线)字符被用作占位符,表示在后续表达式中不使用该量。在 snd3 的定义中,我们在三元组的第一和第三个位置使用下划线。这表明给这些元素命名是多余的,因为在定义中没有使用这些名称。在 thd3 的定义中,我们展示了下划线的另一种用法。这里我们以一个下划线开始变量名称,表明它不会被使用,但我们仍然为自己命名,可能是为了提醒我们该变量的用途。对于那些不被使用的量,使用下划线是最佳实践,因为编译器会生成关于未使用变量的警告,这通常是错误。为了将这些真正的错误(通常是拼写错误)与那些你不想使用的项区分开来,可以使用下划线。

比较列表和元组

元组与列表不同,列表中的每个元素必须具有相同的类型。另一方面,与列表不同,元组的类型明确表示元组包含多少个元素。例如,如果一个表达式的类型是 [Int],它可以是一个包含零个、一个、两个或更多 Int 的列表。然而,如果一个表达式的类型是 (String, Int),它就是一个包含一个 String 和一个 Int 的配对。如果你想要组合正好两个或正好三个元素,元组就是你需要的。超过三个元素后,元组会迅速变得难以操作。另一方面,列表通常很长。一个列表可以包含成千上万个元素。

在我们讨论配对列表之前,我们将绕开一点,简要介绍一种对于该讨论有帮助的类型类别。

可能的类型

我们在第五章中看到,对于任何类型 a,都有另一种类型 [a],它由每个元素类型为 a 的列表组成。这样的列表可以包含零个、一个、两个或更多类型为 a 的元素。

同样,对于任何类型a,都有一个新的类型Maybe a,它由零个或一个类型为a的元素组成。为了说明这个新数据类型的动机,假设我们正在编写一个函数findFirst,它将在列表中查找第一个符合某些条件的元素。我们可能希望这个函数具有如下类型:

findFirst :: (b -> Bool) -> [b] -> b

该类型表示我们希望函数findFirst接受一个谓词和一个类型为b的元素列表作为输入,并返回一个类型为b的单个元素作为输出。但如果列表中没有符合我们条件的元素怎么办?在这种情况下,问题就来了,因为函数findFirst无法提供一个类型为b的元素,而该类型却要求函数返回一个类型为b的元素。一个可能的做法是,如果没有找到合适的元素,findFirst可以使用error函数,但这是一个极端的做法,会终止程序,使得后续无法恢复。

更好的解决方案是使用不同的类型签名,像这样:

findFirstMaybe :: (b -> Bool) -> [b] -> Maybe b

如果findFirstMaybe找到一个符合条件的元素x :: b,它将返回Just x。如果没有找到符合条件的b类型元素,findFirstMaybe将返回Nothing。我们来看一下函数定义是怎样的:

findFirstMaybe p xs = case dropWhile (not . p) xs of
                        []    -> Nothing
                        (x:_) -> Just x

表达式dropWhile (not . p) xs是对列表xs进行处理后,移除掉前面最长的一段不满足谓词p的元素后剩下的部分。case结构允许我们对表达式dropWhile (not . p) xs进行模式匹配,检查该表达式匹配的是哪种列表模式,并在每种情况下返回适当的结果。

类型Maybe a有两个模式。(回忆一下,Bool有两个模式,列表有两个模式,元组有一个模式。)Maybe a的值要么是Nothing,要么是Just x,其中x :: aNothing表示没有类型为a的元素,而Just x表示一个类型为a的元素(即x)。表 9-1 展示了涉及Maybe的一些表达式及其类型。

表 9-1: 涉及 Maybe 的表达式及其类型

表达式 类型
Nothing :: Maybe a
Just "我" :: Maybe [Char]
Just 'X' :: Maybe Char
Just False :: Maybe Bool
Just 4 :: Num a => Maybe a

表 9-2 展示了Maybe类型表达式与基础类型表达式的比较。

表 9-2: Maybe 类型与基础类型表达式的比较

类型 具有此类型的表达式
Bool False, True
Maybe Bool Just False, Just True, Nothing
Char 'h', '7'
Maybe Char Just 'h', Just '7', Nothing
String "星期一", "星期二"
Maybe String Just "星期一", Just "星期二", Nothing
Int 3, 7, -13
Maybe Int Just 3Just 7Just (-13)Nothing

表 9-2 展示了四个内容:对于每种类型 a,都有一个类型 Maybe a;一个类型为 Maybe String 的表达式,除非它是 Nothing,否则持有一个类型为 String 的值;一个类型为 String 的表达式可以通过前缀构造函数 Just 转换成类型为 Maybe String 的表达式;这些关于 String 类型的观察对于 BoolChar 或任何其他类型也适用。

现在我们已经掌握了 Maybe 类型,让我们来看一下配对列表。

配对列表

就像我们可以形成列表的列表一样,我们也可以生成成对的对、配对的列表、列表的对以及更复杂的结构。配对列表可能是这些结构中最有用的一种(虽然列表的列表也非常有用),原因我们接下来会看到。

为了形成一对对的列表,我们可以使用 Prelude 函数 zip

*Main> :t zip
zip :: [a] -> [b] -> [(a, b)]

zip 函数接受两个列表,并将它们的第一个元素、第二个元素等配对,直到较短列表的末尾。 表 9-3 显示了如何使用 zip 的一些示例。

表 9-3: zipzipWith 的示例

表达式 计算结果
zip [1,2,3] [4,5,6] [(1,4),(2,5),(3,6)]
zip [1,2] [4,5,6] [(1,4),(2,5)]
zip [5..7] "who" [(5,'w'),(6,'h'),(7,'o')]
zipWith (+) [1,2,3] [4,5,6] [5,7,9]
zipWith (-) [1,2,3] [4,5,6] [-3,-3,-3]
zipWith (*) [1,2,3] [4,5,6] [4,10,18]

Prelude 函数 zipWithzip 的高效变体,它更进一步,并将一个函数应用于 zip 本会生成的每一对值。

*Main> :t zipWith
zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]

zipWith 的第一个参数是一个高阶函数,描述如何处理类型为 a 的元素(来自第一个列表)和类型为 b 的元素(来自第二个列表)。zipWith 的第二个参数是第一个列表,第三个参数是第二个列表。

Prelude 函数 unzip 接受一对对的列表,并将其转换为一对列表。

*Main> :t unzip
unzip :: [(a, b)] -> ([a], [b])

配对列表的一个用途是查找表。在查找表中,每对中的第一个元素充当 ,第二个元素充当 。这样的对称被称为 键值对。以下是一个包含三位著名科学家所修“西方文明史”课程最终成绩的查找表。每个人的名字作为键,成绩作为值。

grades :: [(String, Int)]
grades = [ ("Albert Einstein", 89)
         , ("Isaac Newton"   , 95)
         , ("Alan Turing"    , 91)
         ]

Prelude 函数 lookup 接受一个键和一个查找表,并返回相应的值(如果有的话)。函数 lookup 返回一个 Maybe 类型,以允许键在查找表中未找到的情况。

*Main> :t lookup
lookup :: Eq a => a -> [(a, b)] -> Maybe b
*Main> lookup "Isaac Newton" grades
Just 95
*Main> lookup "Richard Feynman" grades
Nothing

元组和列表推导

以后,我们会希望使用列表推导式来形成我们想要绘制的对(x,y)的列表。例如,在第十一章中,我们会遇到一个绘图函数plotPath,它接受一个由数字对组成的列表作为输入,通常是[(R,R)],并生成一个图表。我们可以使用列表推导式将数据转换为适合绘图的形式。如果我们想要绘制位置随时间变化的图像,可以按照以下方式形成一个时间-位置对的列表:

txPairs :: [(R,R)]
txPairs = [(t,yRock30 t) | t <- [0,0.1..6]]

type R = Double

相同的对列表可以通过map生成:

txPairs' :: [(R,R)]
txPairs' = map (\t -> (t,yRock30 t)) [0,0.1..6]

yRock30 :: R -> R
yRock30 t = 30 * t - 0.5 * 9.8 * t**2

除了映射,列表推导式还可以基于布尔表达式过滤数据。让我们继续用yRock30形成时间-位置对的例子。假设我们只希望在岩石在空中时(y > 0)保留对。

txPairsInAir :: [(R,R)]
txPairsInAir
   = [(t,yRock30 t) | t <- [0,0.1..20], yRock30 t > 0]

在给出t值来源的列表后,我们添加一个逗号,然后是用于过滤的布尔表达式。计算机会像以前一样生成一个列表,但现在只保留布尔表达式返回True的值。

我们可以通过mapfilter的组合实现相同的效果。我们可以先进行过滤,

txPairsInAir' :: [(R,R)]
txPairsInAir'
   = map (\t -> (t,yRock30 t)) $
     filter (\t -> yRock30 t > 0) [0,0.1..20]

或者我们可以先进行映射:

txPairsInAir'' :: [(R,R)]
txPairsInAir''
    = filter (\(_t,y) -> y > 0) $
      map (\t -> (t,yRock30 t)) [0,0.1..20]

表 1-2 中的应用操作符$的优先级为 0,因此它两侧的表达式会在合并之前先被计算。通过这种方式,应用操作符充当了一种单符号的括号。通过将整个map行用括号括起来,也能达到相同的效果。

注意在刚才展示的匿名函数中使用了_(下划线)字符。由于条件表达式仅依赖于对中的第二项,因此没有必要给对中的第一项命名。

一对的类型是由两个现有类型组成的。三元组的类型是由三个现有类型组成的。类型构造器的概念(我们将在下一节探讨)提供了一个统一的框架,用于从旧的类型构建新的类型。

类型构造器与种类

Maybe Int是一个类型,Maybe Bool是一个类型,Maybe R是一个类型,但Maybe本身不是一个类型。它是一个类型构造器。类型构造器是一个接受零个或多个类型作为输入并生成一个类型作为输出的对象。Maybe是一个一元类型构造器,它接受类型Int作为输入并生成类型Maybe Int作为输出。换句话说,Maybe是一个类型层面的函数。一个零元类型构造器与一个类型相同。为了跟踪这种复杂性,Haskell 为每个类型和类型构造器分配了一个种类。例如,R具有种类*。GHCi 有一个命令:kind(简写为:k),用于查询某个对象的种类。

*Main> :k R
R :: *

一个一元类型构造器,例如Maybe,具有种类* -> *

*Main> :k Maybe
Maybe :: * -> *

一旦我们将Maybe应用于R,得到的Maybe R再次是一个类型,具有种类*

*Main> :k Maybe R
Maybe R :: *

类型的种类为 *,单位置类型构造器的种类为 * -> *,而且还有一些种类更为复杂的对象。值得注意的是,你可以询问 GHCi 一个类型类的种类。

*Main> :k Num
Num :: * -> Constraint

这种种类意味着,当提供一个类型时,类型类 Num 会生成一个约束。

函数类型、列表类型和元组类型都是由类型构造器构造的特殊类型。Haskell 为函数类型、列表类型和元组类型提供了特殊语法,因此我们可以通过为生成函数、列表和元组的类型构造器命名,来帮助理解它们。type 关键字,在第四章中用于将 R 作为 Double 的同义词,也可以用于参数化类型。

type List a = [a]
type Function a b = a -> b
type Pair a b = (a,b)
type Triple a b c = (a,b,c)

List(列表),像 Maybe(可能),是一个单一位置的类型构造器。它接受一个类型作为输入,并生成一个类型作为输出。Function(函数)和 Pair(对)是双位置的类型构造器。它们接受两个类型作为输入,并生成一个类型作为输出。Triple(三元组)是一个三位置的类型构造器。它接受三个类型作为输入,并生成一个类型作为输出。

表 9-4 展示了某些类型构造器和类型类的种类。

表 9-4: 各种类型构造器和类型类的种类

类型构造器/类 种类
Integer :: *
R -> R :: *
[String] :: *
(Int,String) :: *
Maybe Int :: *
() :: *
List :: * -> *
[] :: * -> *
Maybe :: * -> *
IO :: * -> *
Function :: * -> * -> *
(->) :: * -> * -> *
Pair :: * -> * -> *
(,) :: * -> * -> *
Either :: * -> * -> *
Triple :: * -> * -> * -> *
(,,) :: * -> * -> * -> *
Num :: * -> Constraint
Foldable :: (* -> *) -> Constraint

基本类型、函数类型、列表类型、元组类型、Maybe 类型和单位类型的种类都是 *。单位置类型构造器,如 ListMaybeIO,具有 * -> * 的种类,这意味着它们接受一个类型作为输入并生成一个类型作为输出。请注意,符号 [],它是空列表,因此是列表类型的数据构造器,同时也作为列表类型的类型构造器发挥作用。在这个角色中,它的作用与我们上面定义的 List 类型构造器相同。IO 是一个类型构造器,它将一个纯粹的类型转换为带有副作用的类型;我们将在第十一章中讨论它。

二元类型构造器,如 FunctionPairEither,具有 kind * -> * -> *,表示它们接受两个类型作为输入并产生一个类型作为输出。符号 (->) 与我们之前定义的 Function 类型构造器相同,(,)Pair 相同。三元类型构造器,如 Triple,具有 kind * -> * -> * -> *,表示它接受三个类型作为输入并产生一个类型作为输出。符号 (,,)Triple 相同。

表 9-5 显示了 Haskell 中各种 kind 的含义。

表 9-5: Kinds 的含义

Kind 含义
* 类型
* -> * 一元类型构造器
* -> * -> * 二元类型构造器
* -> * -> * -> * 三元类型构造器
* -> Constraint 类型的类型类
(* -> *) -> Constraint 类型构造器的类型类

每个类型类也有一个 kind。类型类接受一个类型或类型构造器作为输入,并产生一个约束作为输出。我们在第八章讨论的基本类型类具有 kind * -> Constraint,意味着它们接受一个类型作为输入,并产生一个类型类约束作为输出。类型类 Foldable 的 kind 是 (* -> *) -> Constraint,意味着它接受一个类型构造器(如 ListMaybe)作为输入,并产生一个类型类约束作为输出。ListMaybeFoldable 的实例,但 IO 不是。

本章中我们对元组的最终使用是在数值积分中。通过将元组与 iterate 一起使用,我们获得了一种数值积分的方法,稍后我们可以将其推广为解决微分方程的方法。

数值积分 Redux

我们在第六章中讨论了数值积分,在那里我们使用了列表推导来计算曲线下方矩形的面积。现在我们有了元组,我们可以提出一种数值积分的替代方法,这种方法更接近我们后来用来解决微分方程的方法。这个思想是,如果我们将当前积分变量的值(假设为时间)与当前积分值配对,我们就可以一步步地得到整个积分。为了向前推进一步,我们将时间增加一个时间步长,并通过曲线下一个矩形的面积来增加最终得到的积分的累积值。

推进一步的函数如下所示:

oneStep :: R         -- time step
        -> (R -> R)  -- function to integrate
        -> (R,R)     -- current (t,y)
        -> (R,R)     -- updated (t,y)
oneStep dt f (t,y) = let t' = t + dt
                         y' = y + f t * dt
                     in (t',y')

函数 oneStep 将传入的时间步长命名为 dt,被积分的函数命名为 f,当前积分变量的值命名为 t,当前积分值的累积值命名为 y。然后它返回一个元组,包含增加了时间步长的积分变量值和通过函数 f 下一个矩形面积 f t * dt 增加的当前积分值。

为了计算积分,我们在自变量小于上限时反复执行单步操作。

integral' :: R -> Integration
integral' dt f a b
    = snd $ head $ dropWhile (\(t,_) -> t < b) $
      iterate (oneStep dt f) (a + dt/2,0)

表达式 oneStep dt f :: (R,R) -> (R,R) 是一个通过一个时间步长更新当前时间积分对的函数。由于该函数的类型是 a -> a,它可以与 iterate 一起迭代。表达式 iterate (oneStep dt f) (a + dt/2,0) 会生成一个无限的时间积分对列表,起始时间是 a + dt/2,即第一个时间区间的中间值,初始积分值为 0,随着迭代积累。

通过对无限列表应用 dropWhile (\(t,_) -> t < b),我们丢弃了时间小于上限 b 的初始对,以获得一个无限列表,其第一个对的时间非常接近上限 b。然后,应用 head 对这个无限列表进行操作,返回时间非常接近上限的对。最后,对该对应用 snd,返回积分的值。

为方便起见,这里是 第六章 中我们之前在 integral' 的类型签名中使用的 Integration 类型:

type Integration = (R -> R)  -- function
                 -> R        -- lower limit
                 -> R        -- upper limit
                 -> R        -- result

总结

本章介绍了元组,它是一种将两个或更多值组合成一个单一值的方式。接着我们讨论了类型构造函数,它是在类型层面上的函数,用来从输入类型形成输出类型。我们通过使用元组介绍了一种使用 iterate 来实现数值积分的替代方法,结束了这一章。

在下一章中,我们将回到物理学,研究三维运动学,并开发我们将用于向量的数据类型。

练习题

练习 9.1. 编写一个函数

polarToCart :: (R,R) -> (R,R)

它接受极坐标 (r, θ),其中 θ 是弧度表示,并返回一对笛卡尔坐标 (x, y) 作为输出。

练习 9.2. 用语言解释 curryuncurry 的类型含义。

练习 9.3. Prelude 函数

head :: [a] -> a

存在一些问题,因为如果传入一个空列表,它会导致运行时错误。编写一个函数

headSafe :: [a] -> Maybe a
headSafe = undefined

如果传入空列表,它返回 Nothing,否则返回 Just x,其中 x 是给定列表的第一个元素(头部)。用你自己的代码替换 undefined。(如果你希望在代码未写完之前加载代码,可以在自己的函数中使用 undefined 作为占位符。)

练习 9.4. 我们之前提到,类型 Maybe a 有点像类型 [a],不同之处在于 Maybe a 的元素只能有零个或一个。为了使这个类比更加精确,编写一个函数

maybeToList :: Maybe a -> [a]
maybeToList = undefined

它将一个 Maybe 类型转换为一个列表。Nothing 应该映射到哪个列表?Just x 应该映射到哪个列表?

练习 9.5. 查明并解释当 zip 与两个长度不相同的列表一起使用时会发生什么。

练习 9.6. 定义一个函数

zip' :: ([a], [b]) -> [(a, b)]
zip' = undefined

它将一对列表转换为一对元素的列表。(提示:可以考虑使用 curryuncurry。)

练习 9.7. 点操作符 (.) 用于函数组合。如果我们先做 unzip 再做 zip',我们会得到一个具有以下类型签名的函数:

   zip' . unzip :: [(a, b)] -> [(a, b)]

这是恒等函数吗?(换句话说,它是否总是返回给定的表达式?)如果是,你怎么知道?如果不是,请给出反例。

如果我们先使用 zip' 再使用 unzip,我们会得到一个具有以下类型签名的函数:

   unzip . zip' :: ([a], [b]) -> ([a], [b])

这是恒等函数吗?

练习 9.8. 使用之前的 grades 查找表,展示如何使用 lookup 函数来生成值 Just 89。还要展示如何使用 lookup 函数生成值 Nothing

练习 9.9. 将以下数学函数翻译成 Haskell:

x(r, θ, ϕ) = r sin θ cos ϕ

使用三元组作为函数 x 的输入。同时给出类型签名以及函数定义。

练习 9.10. 一辆汽车从静止开始,在一条平直的公路上以 5 m/s² 的加速度行驶。我们想要为这辆车生成一个无限长的时间-速度对 tvPairs 列表,每秒一个。以下是 tvPairs 的代码:

tvPairs :: [(R,R)]
tvPairs = iterate tvUpdate (0,0)

tvUpdate 编写类型签名和函数定义。

tvUpdate = undefined

列表 tvPairs 应该看起来像 [(0,0),(1,5),(2,10),(3,15),...]。在编写 tvUpdate 函数后,使用 take 函数查看 tvPairs 的前几个元素。

练习 9.11. 斐波那契数列是这样的序列:每一项是前两项的和。前几个项是 1, 1, 2, 3, 5, 8, 13, 21, 34, 55。编写一个序列

fibonacci :: [Int]
fibonacci = undefined

用于(无限)斐波那契数列。

建议编写一个辅助序列

fibHelper :: [(Int,Int)]
fibHelper = undefined

使用函数 iteratefibHelper 的前几个项应为 [(0,1),(1,1),(1,2),(2,3),(3,5),...]。然后使用 fibHelper 编写 fibonacci 序列。

练习 9.12. 阶乘函数接受一个非负整数并返回从 1 到该整数的所有正整数的积。通常用感叹号表示。例如,5! = 5 × 4 × 3 × 2 × 1 = 120。我们定义 0! = 1。本练习的目的是编写一个阶乘函数

fact :: Int -> Int

使用 iterate。练习 9.11 中的建议在这里也有用(编写一个序列 factHelper :: [(Int,Int)],使用 iterate,然后定义 fact 来通过 !! 运算符从该序列中获取值)。

练习 9.13. 使用列表推导式编写以下函数,而不是使用 map:

pick13 :: [(R,R,R)] -> [(R,R)]
pick13 triples = map (\(x1,_,x3) -> (x1,x3)) triples

练习 9.14. 假设我们以 15 m/s 的速度将一块石头垂直抛向空中。使用列表推导式生成一个 (时间, 位置, 速度) 三元组的列表(类型为 [(R,R,R)]),记录石头在空中的时间间隔。你的列表应该包含足够的三元组,以便如果绘制数据,图形看起来会比较平滑。

练习 9.15. 元组可以嵌套,比如 ((3,4),5)。尽管这个对包含三个数字,但它与三元组不同。编写一个函数

toTriple :: ((a,b),c) -> (a,b,c)
toTriple = undefined

它将一个第一个分量是对的对转换为三元组。

第十章:描述三维中的运动

图片

在第四章中,我们回顾了一维运动学,使用实数来描述速度和加速度等量。在本章中,我们将讨论三维运动学,将速度和加速度描述为向量。Haskell 没有内置的向量类型,但它有强大的工具来创建自定义类型,我们将使用这些工具来创建一个 Vec 类型的向量。在决定如何实现 Vec 类型之前,我们将仔细研究向量在物理学中的意义和用途,以便能够实现一个与我们思考和写作向量的方式相符的实现。

三维向量

三维向量的概念在物理学中至关重要。在物理学中,向量是用来描述具有大小和方向的量的几何对象。向量最好被看作是箭头,其中箭头的长度代表大小,箭头指向某个方向。像我们一样生活在地球表面时,方向有时可以简要地用词语来描述,比如“向上”、“向北”等等。我们只能相对于某个物体(如地球)来指定向量的方向;没有普遍的或绝对的方向概念。

当我们在第四章讨论一维运动时,我们是以一个已经按米标定的空气轨道为背景的。空气轨道上的标记相当于一维坐标系。坐标系 是一种用数字描述位置的方法。

自然界通常不会提供给我们一个坐标系来使用;相反,我们选择我们想要使用的坐标系。在三维空间中,这相当于选择一个位置和三个互相垂直方向的方向。沿着每个方向(我们称之为 xyz),我们做(真实的或假想的)标记,以米为单位。x = y = z = 0 的地方被称为坐标系的 原点。一旦我们选择了坐标系,位置就可以通过三个数字(xyz)来描述,表示每个方向上从原点出发的(正或负)距离。

为了描述三维中的运动,我们通常需要引入一个坐标系。但物理定律不应依赖于任何特定的坐标系,它们应该适用于我们想要使用的任何坐标系。向量是几何对象;与物理定律一起,向量具有独立于任何坐标系的存在。向量允许我们进行各种操作,我们可以在没有坐标系的情况下描述这些操作。我们将提供向量重要属性和操作的几何(无坐标)描述,然后展示在引入坐标系后这些操作是如何表现出来的。

在我们进入各种向量运算之前,我想写出本章源代码文件顶部必须存在的代码:

{-# OPTIONS -Wall #-}

module SimpleVec where

infixl 6 ^+^
infixl 6 ^-^
infixr 7 *^
infixl 7 ^*
infixr 7 ^/
infixr 7 <.>
infixl 7 ><

第一行启用了编译器警告,这是一个好主意,有助于避免一些常见的错误,这些错误可能是合法的代码,但可能并不是你认为的那样。如果有警告,加载文件时你会看到它们。

下一行给本章中的代码提供了一个模块名称,以便稍后可以将该代码导入到另一个源代码文件中。模块名称SimpleVec必须与包含该代码的文件名匹配,因此文件名应该是SimpleVec.hs。其余的行指定了我们稍后在本章中定义的操作符的优先级和结合性。优先级是从 0 到 9 的数字,描述了在多个操作符的表达式中哪个操作符先执行,详见第一章。关键字infixl用于具有左结合性的操作符,而infixr用于右结合性操作符。

无坐标向量

现在我们已经在文件顶部写好了这段代码,并且对向量有了基本的了解,让我们来看看它们的一些几何性质。我们将给出向量加法、标量乘法、向量减法、点积、叉积以及实数函数的向量值导数的几何定义。如果你感兴趣的话,Kip Thorne 和 Roger Blandford 的《现代经典物理学》Modern Classical Physics [6]一书为无坐标的几何视角提供了优雅的动机。

向量加法的几何定义

我们可以使用我们所称的向量加法来合并两个向量。从几何角度看,我们定义两个向量的和为一个向量,这个向量从第一个向量的尾部指向第二个向量的顶端,当两个向量按尾对尾的方式排列时。你可以从图 10-1 中看到,向量的尾端到顶端的排列顺序并不重要;因此,向量加法是可交换的(A + B = B + A)。

Image

图 10-1:向量加法。向量A + B是向量AB的和。

物理学家需要知道一个符号是表示数字还是向量;因此,牛顿力学的理论(以及物理学中的大多数其他理论)促使我们从类型的角度思考。物理学家通常使用的数学符号表示向量是语法性的,通过粗体符号来标识向量。在 Haskell 中,数字和向量之间的区别不是语法上的;它们的名称只是以小写字母开头的标识符。在 Haskell 中,数字和向量之间的区别是语义上的,并通过值的类型来表达:R代表数字,Vec代表向量。

在数学符号中,我们使用与数字加法相同的+符号表示向量加法,尽管向量和数字是截然不同的事物,将向量与数字相加是没有意义的。在 Haskell 中,我们会为向量加法使用与数字加法不同的符号(^+^)。如果ab是向量(我们写a :: Vec来表示a的类型是Vec),那么a ^+^ b将是它们的向量和。章末我们会展示如何定义Vec类型和^+^运算符。

在这一章中,我们引入了用于向量加法、减法和标量乘法的新运算符。一种替代的路径是扩展加法(+)、减法(-)和乘法(*)的定义,使其既适用于向量也适用于数字。Haskell 语言完全能够实现这一点。我们没有选择这种方法的原因是,我们更倾向于为向量操作定义简单、具体的类型,而不是涉及类型类的类型。我们为新运算符使用的名称,如^+^,借用了 Conal Elliott 的vector-space[7],这是处理向量的一种比我们这里介绍的更复杂、更通用的方法。

向量缩放的几何定义

我们定义将一个向量缩放为一个数字(也叫做标量乘法或将数字乘以向量)如下:如果数字为正,我们将向量的大小乘以该数字,并保持向量的方向不变。如果数字为负,我们将向量的大小乘以该数字的绝对值,并翻转向量的方向。如果数字为 0,结果是零向量。

我们定义将向量A除以数字m为将向量乘以m的倒数,即标量乘法。

Image

在图 10-2 中,我们展示了按 2、–1 和 –1/2 缩放一个向量的结果。按正数缩放会增加向量的长度,保持方向不变;按负数缩放会增加向量的长度并翻转方向。

Image

图 10-2:标量乘法。将A按 2、–1 和 –1/2 缩放,分别得到 2A、–A 和 –A/2。

在数学符号中,我们对于标量乘法使用与数字相乘相同的符号(将一个数字放在向量旁边),尽管这两个操作是不同的。类似地,我们用相同的符号(/)表示将向量除以数字,尽管这两个操作也是不同的。

在 Haskell 中,我们为标量乘法使用与数字乘法不同的符号,为向量除以数字使用与数字除以数字不同的符号。如果 m 是一个数字,a 是一个向量,那么 m *^ aa ^* m 都表示将 am 缩放。注意,在这两种情况下,插入符号都离向量更近。要将 a 除以 m,我们写作 a ^/ m

向量相减的几何定义

另一种组合两个向量的方法是我们所称的向量相减。两个向量的差定义为当两个向量尾对尾摆放时,从第一个向量的尖端指向第二个向量尖端的向量。图 10-3 显示了两个向量的差等于一个向量与另一个向量的相反数之和。用符号表示,B - A = B + (–A)。

Image

图 10-3:向量相减。向量B - A是向量BA的差。

在数学表示中,我们使用相同的符号(–)来表示向量相减,就像我们用相同的符号表示数字相减一样,尽管向量和数字是非常不同的东西。在 Haskell 中,如果 ab 是向量,我们将定义 a ^-^ b 为它们的向量差。

点积的几何定义

在物理学中,向量有(至少)两种重要的积。一个是点积,或内积。两个向量的点积是一个标量,或数字。以下是它的几何定义:

A · B = AB cos θ

在这个方程中,θ 是当两个向量尾对尾摆放时它们之间的角度,我们使用标准符号,即让斜体符号表示与粗体符号具有相同字母的向量的大小。换句话说,A = |A| 和 B = |B|。

图 10-4 显示了两个向量的点积是一个向量的大小(BA)与第二个向量在第一个向量上的投影(A cos θB cos θ)的乘积。请注意,当 θ > 90^∘ 时,投影将为负。

Image

图 10-4:两个向量的点积是一个向量的大小与第二个向量在第一个向量上的投影的乘积。

注意,点积是可交换的:A ⋅B = B ⋅A。此外,点积与向量的大小有关。

A · A = |A|² cos (0) = |A|² = A²

因此,

Image

点积对向量和分配。

C · (A + B) = C · A + C · B

在 Haskell 中,如果 ab 是向量,那么 a <.> b 将是它们的点积。

叉积的几何定义

两个向量的叉积是一个向量,其大小由下式给出

|A × B| = AB sin θ

其方向与向量AB都垂直。

图 10-5 展示了包含向量AB的平面。为了找到A × B的方向,想象将向量A绕其尾部旋转一个小于 180^∘的角度,直到它与向量B对齐。如果需要逆时针旋转来完成这一动作,那么A × B的方向是指向页面外。如果需要顺时针旋转,则A × B的方向是指向页面内。

Image

图 10-5:叉积。向量 A × B 指向页面外。向量 B × A 指向页面内。

对于图 10-5 中的AB,向量A × B指向页面外,而向量B × A指向页面内。叉积是反交换的:A × B = –B × A。还要注意,任何向量与其自身的叉积为 0。叉积的大小给出了由这两个向量所形成的平行四边形的面积,当加入两个额外的平行边时。

叉积对向量和分配:

C × (A + B) = C × A + C × B

在 Haskell 中,如果ab是向量,我们将定义a >< b为它们的叉积。(操作符><的设计应该像叉积运算符。)

注意

如果你对数学创新感兴趣,几何积比点积和叉积更为复杂,但它包含了两者的精髓。Chris Doran 和 Anthony Lasenby 所著的书《几何代数物理学入门》[8] 是一本很好的入门书。David Hestenes 所著的《时空代数》[9] 是另一本很棒的参考书。

向量值函数的导数

假设V是一个函数,它接受一个实数变量(如时间)作为输入,并输出一个向量(如速度)。因为我们可以做向量减法,并且可以将向量除以一个数,所以我们可以定义实数值函数的向量导数。在 Haskell 中,这样的函数类型是R -> Vec

V的导数,记作*DVV’或 Image,是一个一元函数,定义如下:

Image

注意到最左边的减号表示向量的减法。我们使用相同的D符号来表示向量值函数的导数,就像我们表示数值函数导数时使用的符号一样。

向量导数接受一个一元向量值函数(类型为R -> Vec)作为输入,并输出一个一元向量值函数。

type VecDerivative = (R -> Vec) -> R -> Vec

类型(R -> Vec) -> R -> Vec与类型(R -> Vec) -> (R -> Vec)是相同的。下面是 Haskell 中的向量导数:

vecDerivative :: R -> VecDerivative
vecDerivative dt v t = (v (t + dt/2) ^-^ v (t - dt/2)) ^/ dt

就像第四章中的derivative函数一样,这个数值导数并不取极限,而是使用一个由函数用户提供的小间隔dt

表 10-1 显示了我们介绍的向量运算中,数学符号与 Haskell 符号的对比。

表 10-1: 数学符号与 Haskell 符号在向量运算中的比较

数学符号 Haskell 符号 Haskell 类型
t t R
m m R
A a Vec
B b Vec
V v R -> Vec
V(t) v t Vec
A + B a ^+^ b Vec
m A m *^ a Vec
A m a ^* m Vec
A/m a ^/ m Vec
AB a ^-^ b Vec
AB a <.> b R
A × B a >< b Vec
DV vecDerivative 0.01 v R -> Vec
DV(t) vecDerivative 0.01 v t Vec
图片 vecDerivative 0.01 v t Vec

让我们继续,看看当引入坐标系统时,向量会发生什么变化。

坐标系统

我们通过选择一个位置和方向来选择坐标系统,这样可以定义三个互相垂直的方向。我们定义 î 为一个大小为 1,指向增加的 x 方向的向量。大小为 1 的向量也被称为 单位向量。带帽的向量是单位向量。图 10-6 显示了一个坐标系统,并标出每个坐标方向的坐标单位向量。

图片

图 10-6:一个右手坐标系统。z 轴被想象为从页面中突出。

因为 图片 的大小为 1,我们知道 图片。类似地,我们定义 图片 为一个单位向量,指向增加的 y 方向,我们定义 图片 为一个单位向量,指向增加的 z 方向。它们之所以被称为 图片图片图片,可以追溯到威廉·罗恩·汉密尔顿(William Rowan Hamilton)和他的四元数。(可以查找 A Capella Science 的《William Rowan Hamilton》视频,了解这位数学物理学家的精彩音乐传记,配乐是为同名的政治人物写的。)由于 图片图片 是垂直的,我们知道 图片。通过类似的推理,我们可以找到所有坐标单位向量的点积。

图片

因为任何向量与自身的叉积为 0,所以我们知道Image。由于坐标系的三个方向是互相垂直的,我们知道Image。为了消除符号歧义,我们通常同意使用右手坐标系,这意味着Image。图 10-6 展示了一个右手坐标系。通过类似的推理,我们可以求出所有坐标单位向量的叉积。

Image

一旦我们有了一个坐标系,并且它所衍生出的坐标单位向量,我们就可以“将一个向量分解为分量”。任何向量A都可以表示为ImageImageImage线性组合。向量的线性组合意味着第一个向量乘以一个数,第二个向量乘以另一个数,依此类推。

Image

我们称* A[x]Ax分量,对yz也如此。A[x]A[y]A[z]这三个数字的集合被称为A相对于坐标系的分量。通过对上面的方程进行点积运算,得到一个关于 A[x]的表达式,该表达式涉及ImageA。我们可以对A[y]A[z]*做同样的处理。

Image

章节末的 Haskell 代码定义了一个默认坐标系,你可以使用它。该默认坐标系提供了坐标单位向量iHatjHatkHat,它们分别充当ImageImageImage的角色。

让我们从几何的角度重新审视上面介绍的向量运算,看看它们在坐标系下是什么样子的。

向量加法与坐标分量

和分量相加一样,和分量的求和就是各分量的求和。如果C = A + B

Image

对 y 分量和 z 分量也同样适用。如果C = A + B,那么

Image

在 Haskell 中,这是

*SimpleVec> vec 1 2 3 ^+^ vec 4 5 6
vec 5.0 7.0 9.0

你可以将加号两边的插入符号看作是一个提醒,表示左边是向量,右边也是向量。

向量的坐标分量缩放

如果C = m A,那么

Image

对 y 分量和 z 分量同样适用。如果C = m A,那么

Image

要缩放一个向量,我们可以使用*^操作符。

*SimpleVec> 5 *^ vec 1 2 3
vec 5.0 10.0 15.0

请注意,插入符号位于星号的右边,因为向量位于右侧。你可以使用^*操作符将Vec左侧的向量与右侧的R相乘。

*SimpleVec> vec 1 2 3 ^* 5
vec 5.0 10.0 15.0

由于向量在左侧,插入符号也在左侧。同样,我们可以使用^/操作符除以R

*SimpleVec> vec 1 2 3 ^/ 5
vec 0.2 0.4 0.6

向量减法与坐标分量

如果C = AB,那么

Image

yz分量也同样适用。如果C = AB,那么

Image

我们说“差的分量是分量的差”。第一次使用“差”一词指的是向量差,而第二次使用则指的是数值差。如果ab是向量,那么xComp (a ^-^ b)xComp a - xComp b会得到相同的数值。

这是一个向量减法的例子:

*SimpleVec> vec 1 2 3 ^-^ vec 4 5 6
vec (-3.0) (-3.0) (-3.0)

点积与坐标分量

假设AB是向量。给定一个坐标系,我们可以使用方程 10.4 将每个向量表示为分量,然后利用点积的分配律以及方程 10.2 来简化结果。

Image

如果我们知道两个向量AB的分量,方程 10.8 提供了一种方便的方法来求它们的点积。

你可以使用<.>运算符来计算两个Vec的点积。

*SimpleVec> vec 1 2 3 <.> vec 4 5 6
32.0

叉积与坐标分量

假设AB是向量。给定一个坐标系,我们可以使用方程 10.4 将每个向量表示为分量,然后利用叉积的分配律和方程 10.3 来简化结果。

Image

如果我们知道两个向量AB的分量,方程 10.9 提供了一种很好的方法来求它们的叉积。

你可以使用><运算符来计算两个Vec的叉积。

*SimpleVec> vec 1 2 3 >< vec 4 5 6
vec (-3.0) 6.0 (-3.0)

如果你需要一个向量的分量,可以使用xComp函数来获取。

*SimpleVec> xComp $ vec 1 2 3 >< vec 4 5 6
-3.0

还有函数yCompzComp

一元负号(-)不能用于取反一个向量,但你可以使用negateV来取反一个向量。

*SimpleVec> negateV $ vec 1 2 3 >< vec 4 5 6
vec 3.0 (-6.0) 3.0

坐标分量的导数

假设V是一个单一实变量的向量值函数。如果W = DV,那么

Image

对 y 分量和 z 分量同样适用。如果W = DV,那么

Image

这是一个向量值函数的例子:

v1 :: R -> Vec
v1 t = 2 *^ t**2 *^ iHat ^+^ 3 *^ t**3 *^ jHat ^+^ t**4 *^ kHat

请注意,我们不能以最直观的方式写出这个向量值函数的 x 分量,即xComp v1。这样会产生类型错误,因为xComp接受的是Vec类型作为输入,而不是一个函数R -> Vec。我们真正的意思是,当我们讨论一个向量值函数的 x 分量时,指的是一个标量值函数,它接受一个输入t,应用向量值函数并返回 x 分量。在 Haskell 中,向量值函数的 x 分量可以按以下方式写出:

xCompFunc :: (R -> Vec) -> R -> R
xCompFunc v t = xComp (v t)

用文字表达,方程 10.10 表示导数的 x 分量与 x 分量的导数相同。在 Haskell 中,相同的方程表示向量导数的 x 分量。

xCompFunc . vecDerivative dt

与(标量)导数的 x 分量相同:

derivative dt . xCompFunc

我们可以在 GHCi 中检查一个特定的向量值函数在特定自变量值下的计算结果。

*SimpleVec>  (xCompFunc . vecDerivative 0.01) v1 3
11.999999999999744
*SimpleVec>  (derivative 0.01 . xCompFunc) v1 3
11.999999999999744

我们在第四章中定义了标量导数,这里为了方便再次提及。

type Derivative = (R -> R) -> R -> R

derivative :: R -> Derivative
derivative dt x t = (x (t + dt/2) - x (t - dt/2)) / dt

表格 10-2 显示了我们正在使用的向量函数和表达式的类型。

表 10-2: 用于操作向量的表达式和函数

表达式 类型
zeroV :: Vec
iHat :: Vec
(^+^) :: Vec -> Vec -> Vec
(^-^) :: Vec -> Vec -> Vec
(*^) :: R -> Vec -> Vec
(^*) :: Vec -> R -> Vec
(^/) :: Vec -> R -> Vec
(<.>) :: Vec -> Vec -> R
(><) :: Vec -> Vec -> Vec
negateV :: Vec -> Vec
magnitude :: Vec -> R
xComp :: Vec -> R
vec :: R -> R -> R -> Vec
sumV :: [Vec] -> Vec

现在我们已经了解了向量在几何和坐标设置中的一些关键性质,接下来我们来看看向量如何用于描述三维空间中的运动学。

三维运动学

运动学的基本量是时间、位置、速度和加速度。时间将继续作为实数,就像在第四章中一样。速度和加速度我们现在将作为向量处理,使用在本章末尾定义的Vec类型。那么位置呢?

位置实际上并不是向量。将位置相加是没有意义的,也没有意义通过数值来缩放位置。然而,减去位置是有意义的,这会产生从一个位置到另一个位置的位移向量。在第二十二章中,我们将创建一个适合的类型来表示位置,这将允许我们使用笛卡尔坐标系、圆柱坐标系和球坐标系来描述位置。然而,目前我们的目标更为简朴,简单性表明我们应该使用Vec类型来表示位置,尽管我们刚才提到了一些不这样做的理由。位移显然是一个向量,因此我们可以将一个向量值的位置视为从默认坐标系原点的位移。

本章我们将使用以下类型同义词:

type Time         = R
type PosVec       = Vec
type Velocity     = Vec
type Acceleration = Vec

我们使用类型PosVec来表示位置的类型,当位置由向量表示时。这将避免我们将其与第二十二章中定义的Position类型混淆,后者不是向量。

定义位置、速度和加速度

对于物体的特定运动,我们定义r为一个函数,它将每个时间点t与物体在该时刻的位置相关联。我们说r(t)是物体在时间t的位置信息。

物体的速度函数是其位置函数的导数。

Image

请注意,方程式 10.11 是一个函数等式,左边是瞬时速度函数,右边是位置函数的导数。方程式 10.11 可以用 Haskell 编写为velFromPos函数,它接受一个小的时间步长和位置函数,返回一个速度函数。

velFromPos :: R                   -- dt
           -> (Time -> PosVec  )  -- position function
           -> (Time -> Velocity)  -- velocity function
velFromPos = vecDerivative

从定义中可以看出,velFromPos函数就是我们之前在本章定义的向量导数。

当两个函数相等时,它们在相等的输入下给出相等的结果,所以我们也可以写成

Image

对于任何时间 t,右边是函数 Dr 在时间 t 时的值。我们可以把导数运算符看作是将整个位置函数作为输入,返回速度函数作为输出。也常见使用符号

Image

用来定义速度。

速度 是速度向量的大小。加速度 被定义为速度变化的速率。我们定义 a 为一个函数,它与每个时间 t 相关联,表示在时间 t 时速度变化的速率。在微积分的语言中,我们可以写作

Image

或者

Image

为了定义加速度,方程 10.14 可以通过函数 accFrom Vel 来编码,该函数根据速度函数生成加速度函数。再说一遍,这个函数只是向量的导数。

accFromVel :: R                       -- dt
           -> (Time -> Velocity)      -- velocity function
           -> (Time -> Acceleration)  -- acceleration function
accFromVel = vecDerivative

如果速度恰好是常数,比如 v[0],我们可以对方程 10.11 的两边进行积分,得到

v[0]t = r(t) – r(0)

如果速度是常数,位置是时间的线性函数。

r(t) = v[0]t + r(0)

这是相应的 Haskell 代码:

positionCV :: PosVec -> Velocity -> Time -> PosVec
positionCV r0 v0 t = v0 ^* t ^+^ r0

名称末尾的 CV 是常速(constant velocity)的缩写。

如果加速度恰好是常数,比如 a[0],我们可以对方程 10.14 或 10.15 的两边进行积分,得到

a[0]t = v(t) – v(0)

如果加速度是常数,速度是时间的线性函数。

Image

我喜欢把方程 10.16 称为 恒定加速度下的速度-时间方程,因为它给出了任何时间 t 下物体的速度 v(t),前提是我们知道恒定加速度 a[0] 和初始速度 v(0)。这是方程 10.16 的 Haskell 代码:

velocityCA :: Velocity -> Acceleration -> Time -> Velocity
velocityCA v0 a0 t = a0 ^* t ^+^ v0

名称末尾的 CA 是恒定加速度(constant acceleration)的缩写。我们可以对方程 10.16 的两边进行积分,得到

Image

如果加速度是常数,位置是时间的二次函数。

Image

我喜欢把方程 10.17 称为 恒定加速度下的位置-时间方程,因为它给出了任何时间 t 下物体的位置 r(t),前提是我们知道恒定加速度 a[0]、初始位置 r(0) 和初始速度 v(0)。这是方程 10.17 的 Haskell 代码:

positionCA :: PosVec -> Velocity -> Acceleration
           -> Time -> PosVec
positionCA r0 v0 a0 t = 0.5 *^ t**2 *^ a0 ^+^ v0 ^* t ^+^ r0

方程 10.16 和 10.17 被称为 恒定加速度方程。它们在典型的入门物理课程中反复使用。稍后我们将学习一些应对加速度不恒定情况的技巧。

在三维向量空间中引入了速度和加速度的定义后,我们现在可以看看加速度是如何由两个质的不同的分量组成的。

加速度的两个分量

如果物体在任何时刻的速度为 0,物体所具有的任何加速度都用于赋予物体沿加速度方向的速度。另一方面,如果v(t)≠0,速度和加速度的相对方向决定了物体的定性运动。在日常语言中,人们常用加速度来表示速度的增加。然而,在物理学中,加速度是单位时间内速度的变化,速度可以在大小或方向上发生变化。在物理学中,加速度不仅负责速度的增加,还负责速度的减少和方向的变化。

如果v(t)≠0,我们可以将加速度分解为与速度平行的分量和与速度垂直的分量。

Image

由于v(t)≠0,我们可以定义一个沿速度方向的单位向量。

Image

加速度的平行分量和垂直分量由以下方程给出:

Image

下面是计算加速度平行分量和垂直分量的 Haskell 函数:

aParallel :: Vec -> Vec -> Vec
aParallel v a = let vHat = v ^/ magnitude v
                in (vHat <.> a) *^ vHat

aPerp :: Vec -> Vec -> Vec
aPerp v a = a ^-^ aParallel v a

平行分量a∥也称为加速度的切向分量,它负责物体速度的变化。垂直分量a⊥也称为径向横向分量,它负责物体方向的变化。

速度和加速度的点积取决于它们之间的角度,因此包含有用的信息。我们来对速度平方的时间导数* v^(t)² = v(t) ⋅v(t*)进行计算:

Image

我们可以看到,速度和加速度的点积控制了速度的变化:

点积 对速度的影响
v(t) ⋅a(t) > 0 速度增加
v(t) ⋅a(t) = 0 速度保持恒定
v(t) ⋅a(t) < 0 速度减小

本章中,v表示速度函数,这与第四章中的不同约定,在第四章中,v是一个维度的速度函数。一维速度可以为负,但速度不可以为负。

图 10-7 显示了速度和加速度的相对方向如何控制物体运动的定性行为。

Image

图 10-7:速度和加速度的相对方向决定了物体的定性运动。

当加速度在速度方向上有分量时,物体会加速。当加速度在与速度方向相反的方向上有分量时,物体会减速。当加速度只有与速度垂直的分量时,物体保持其速度。为了得出这些结论,不需要使用坐标系统;这种运动定性行为的特点纯粹是几何学的。

我们已经看到了切向加速度分量与加速和减速之间的关系。我们可以做出更强的陈述:速度变化率与切向分量直接相关。

Image

这是一个 Haskell 函数,用于表示速度变化率。给定物体的速度和加速度,该函数返回速度变化的速率,负值表示速度在减小。

speedRateChange :: Vec -> Vec -> R
speedRateChange v a = (v <.> a) / magnitude v

切向加速度分量的大小等于速度变化率的大小。

Image

如果存在横向加速度分量,它会导致物体发生转向(换句话说,改变方向)。我们可以计算这个转向运动的曲率半径。图 10-8 展示了一个具有速度vt)和横向加速度a[⊥](t)的粒子轨迹。在一个小的时间间隔Δt内,粒子会沿前进方向移动vt)Δt的距离,并在垂直方向上移动a[⊥](t)Δ^(t²)/2 的距离。

Image

图 10-8:从横向加速度确定曲率半径

图 10-8 提供了一种方法,用于根据速度和横向加速度找到曲率半径的表达式。这里我们写出图中直角三角形的勾股定理:

Image

展开此方程并在Δt → 0 时求极限,舍去与Δt⁴成正比的项,我们得到以下曲率半径的方程:

Image

这是一个 Haskell 函数,它根据物体的速度和加速度计算物体运动的(瞬时)曲率半径:

radiusOfCurvature :: Vec -> Vec -> R
radiusOfCurvature v a = (v <.> v) / magnitude (aPerp v a)

如果我们愿意,可以反转方程式 10.22,从而得到一个以曲率半径R为变量的横向加速度表达式。

Image

正如从方程式 10.21 和 10.23 中看到的,切向加速度分量控制速度变化,而径向加速度分量控制方向变化。

抛体运动

在物理学中,向量的最初用途之一通常是研究抛体运动。抛体是指任何被抛出、发射或射出的物体,且这些物体通常接近地球表面。问题在于预测物体在投掷、发射或射击力消失后的运动;事实上,我们不再谈论发射力,而是假设发射过程仅仅赋予抛体某个初始速度。

正是地球的重力使得抛体运动变得有趣。物理学提供了四种重力理论,其中三种我们将在本书中讨论:

  1. 重力使得靠近地球表面的物体发生加速。一个在地球表面附近被允许自由移动或下落的物体,会以大约 9.81 m/s²的速率加速朝地球中心方向运动。

  2. 重力是地球对其表面附近物体产生的一种力。在我们开始学习力学并讨论力、质量和牛顿第二定律的概念后,我们将在第十五章和第十六章中探讨这一重力观念。第十六章中的 Haskell 函数 earthSurfaceGravity 描述了这种重力。

  3. 重力是任何两个有质量的物体之间的力。这就是牛顿的万有引力定律。我们将在第十六章和第十九章中讨论它,并通过 Haskell 函数 universalGravity 来描述它,前提是我们已经介绍了牛顿的第三定律。

  4. 重力是时空的弯曲。这是爱因斯坦的广义相对论。我们在本书中不会深入讨论这一点。由 Charles Misner、Kip Thorne 和 John Wheeler 合著的《引力》[10] 是广义相对论的优秀入门书籍。Gerald Sussman 和 Jack Wisdom 的《功能微分几何》[11] 从计算角度探讨广义相对论,并用函数式编程语言 Scheme 描述它。Rindler [12]、Carroll [13] 和 Schutz [14] 等人对广义相对论的其他介绍也是值得推荐的。

这个列表中的每一个理论都比前一个理论更为复杂。从这个意义上讲,后来的理论比早期的“更为正确”,尽管早期的理论通常更有用,因为它们更简单,更容易应用。尤其是广义相对论,虽然优美且准确,但应用和计算起来相当复杂。

一些物理学家可能不同意将我列出的前两个理论称为“理论”,他们认为它们只是牛顿万有引力定律在简单情况下的近似。至于是否该将前两个称为“理论”,并不是我关注的问题;重要的是,它们是将重力纳入我们计算中的不同方式。

处理抛射物运动的最简单方法,也是我们在本章中采用的方法,是基于理论 1。我们假设抛射物仅因地球的引力而加速;因此,抛射物的加速度由重力加速度 g 给出,它是一个指向地球中心的矢量,大小为 9.81 m/s²。

由于重力加速度是恒定的,我们可以使用位置-时间方程式,即方程 10.17,来表示一个抛射物的位置与时间的关系。

Image

如果 z 轴指向上方,并且我们使用国际单位制,以下函数将返回位置与时间的关系,其中 r0 是抛射物的初始位置,v0 是初始速度:

projectilePos :: PosVec -> Velocity -> Time -> PosVec
projectilePos r0 v0 = positionCA r0 v0 (9.81 *^ negateV kHat)

在练习 10.5 中,你需要编写一个函数 projectileVel,该函数返回抛射物的速度与时间的关系。

有空气阻力的抛体运动需要理论 2,因为我们将空气阻力和重力视为平等的两种力。抛体运动发生在地球表面附近时,通常不需要理论 3 或 4,这样做只会在大幅增加计算复杂度的情况下,产生微小的结果差异。理论 3 将在后面的章节中应用于其他用途,如卫星运动。

在看到抛体运动作为向量早期应用的例子之后,让我们来探讨一下我们一直在使用的向量数据类型的创建。

创建你自己的数据类型

Haskell 拥有一个复杂而灵活的类型系统。使得该类型系统如此强大的语言特性之一,就是能够创建你自己的类型。

在讨论第三章和第五章中的模式匹配时,我们提到每个类型都有一个或多个数据构造器,用于构造该类型的值。在创建我们自己的数据类型时,我们必须提供一个或多个全新的数据构造器,作为构造我们新数据类型的值的方式。

我们首先会看如何通过一个单一数据构造器来创建一个新的数据类型,然后再讲解如何使用多个数据构造器来创建新数据类型。

单一数据构造器

在第四章中,我们使用了type关键字来创建类型同义词。在类型同义词中,例如

type R = Double

编译器将类型RDouble视为可互换。在第四章中,类型TimeVelocityRDouble都是可互换的。这虽然很方便,但却没有让 Haskell 类型检查器帮助代码编写者避免将TimeVelocity混淆,或者在需要Time的地方使用Velocity

我们使用data关键字来定义新的类型,这些类型与任何现有类型不可互换。时间和质量在物理学中都由实数描述,但我们绝不应该在需要时间的方程中提供质量。让我们定义一个新的数据类型Mass,它包含一个实数,但不能与R或任何其他现有数据类型混淆。

data Mass = Mass R
            deriving (Eq,Show)

我们使用data关键字来定义一个新的数据类型。在data关键字后,我们给出新数据类型的名称,在这个例子中是Mass,接着是一个等号。在等号右侧,我们给出一个数据构造器,在这个例子中是Mass,然后是我们新数据类型包含的信息,这里是R。新数据类型的名称和数据构造器的名称可以相同也可以不同。在这个例子中,它们名称相同,但代表的事物不同。在定义单一构造器的数据类型时,通常会使用与类型相同的名称作为数据构造器的名称,但这并不是必须的。

默认情况下,一个新数据类型不是任何类型类的实例。由于通常希望新数据类型成为一些标准类型类的实例,如EqShow,Haskell 提供了一个deriving关键字,尝试使新类型成为列出类型类的实例。

要构造一个类型为Mass的值,我们使用数据构造器Mass

*SimpleVec> Mass 9
Mass 9.0

如果我们请求这个值的类型,GHCi 将告诉我们以下内容:

*SimpleVec>  :t Mass 9
Mass 9 :: Mass

在 GHCi 的响应中,双冒号左边的Mass是数据构造器,双冒号右边的Mass是数据类型。

数据构造器本身具有函数类型。它接受一个R作为输入,并返回一个Mass作为输出。

*SimpleVec> :t Mass
Mass :: R -> Mass

我们请求的是数据构造器的类型,而不是类型的类型,这没有任何意义。同样,双冒号左边的Mass是数据构造器,双冒号右边的Mass是数据类型。

如果我们现在不小心在需要R的地方提供了一个Mass,类型检查器将给我们一个类型错误,帮助我们识别错误,而不是默默地执行错误的操作。

我们可以在数据构造器下提供多个信息。在第九章中,我们使用了一个由对组成的列表来保存成绩信息。让我们定义一个新的数据类型Grade,它包含一个String和一个Int,表示一个人的名字和他们在某项任务上的成绩。

data Grade = Grade String Int
             deriving (Eq,Show)

我们给数据构造器取与类型相同的名字,并简单列出新数据类型中将包含的信息类型。

这是几个人的成绩列表:

grades :: [Grade]
grades = [Grade "Albert Einstein" 89
         ,Grade "Isaac Newton"    95
         ,Grade "Alan Turing"    91
         ]

要构造一个类型为Grade的值,我们使用数据构造器Grade,后跟一个String和一个Int

如果我们查看数据构造器Grade的类型,

*SimpleVec> :t Grade
Grade :: String -> Int -> Grade

我们看到它接受一个String和一个Int作为输入,并返回一个Grade作为输出。像之前一样,双冒号左边的Grade是数据构造器,双冒号右边的Grade是数据类型。

定义新数据类型还有一种替代语法,叫做记录语法,它为构造器下的每个数据项命名。让我们定义一个新的数据类型GradeRecord,它本质上与Grade类型相同,但使用记录语法进行定义。

data GradeRecord = GradeRecord { name  :: String
                               , grade :: Int
                               } deriving (Eq,Show)

要使用记录语法,我们在数据构造器后用大括号括起每个信息的名称和类型。使用记录语法会自动为每个命名的信息创建一个新函数。

*SimpleVec> :t name
name :: GradeRecord -> String
*SimpleVec> :t grade
grade :: GradeRecord -> Int

函数name接受一个GradeRecord作为输入,并返回GradeRecord中保存的String类型的名字。函数grade接受一个GradeRecord作为输入,并返回GradeRecord中保存的Int类型的成绩。默认情况下,namegrade这两个名称会被放入全局命名空间,因此不能在另一个数据类型中重复使用作为字段名。这种默认行为虽然简单,但在某些情况下过于限制,因此可以通过语言选项DuplicateRecordFields来覆盖这个默认行为,尽管我们在本书中不会探讨这个选项。

如果我们使用记录语法来定义我们的新数据类型,那么有两种方式可以构造该类型的值。首先,我们可以使用和上面定义Grade类型时相同的语法,简单地给出数据构造器,然后跟上一个String和一个Int

gradeRecords1 :: [GradeRecord]
gradeRecords1 = [GradeRecord "Albert Einstein" 89
                ,GradeRecord "Isaac Newton"    95
                ,GradeRecord "Alan Turing"    91
                ]

其次,我们可以使用记录语法来构造GradeRecord类型的值。

gradeRecords2 :: [GradeRecord]
gradeRecords2 = [GradeRecord {name = "Albert Einstein", grade = 89}
                ,GradeRecord {name = "Isaac Newton"   , grade = 95}
                ,GradeRecord {name = "Alan Turing"    , grade = 91}
                ]

在这里,我们使用大括号,并按名称而不是按位置给出数据项。

是否使用记录语法的决定应该基于为新类型中的数据元素提供名称的实用性。如果不需要名称,应该使用基本语法。如果名称看起来很有用,那么使用记录语法是一个不错的选择。

我们已经看到了如何使用单个数据构造器定义新数据类型。现在让我们来看看包含多个数据构造器的数据类型。

多个数据构造器

Prelude 类型Bool有两个数据构造器,FalseTrue,正如我们在对Bool进行模式匹配时看到的。没有任何数据构造器包含除构造器本身名称以外的其他信息。

让我们定义一个新的数据类型,叫做MyBool,它的工作方式和Bool相同。我们需要一个新的名字,因为Bool已经在 Prelude 中定义过了。

data MyBool = MyFalse | MyTrue
              deriving (Eq,Show)

我们像以前一样从data关键字开始,接着是我们新数据类型的名称MyBool。在等号的右边,我们给出第一个数据构造器,称为MyFalse,然后是竖线,再接着是第二个数据构造器MyTrue。我们需要为数据构造器取新的名字,因为FalseTrue已经被占用了。

定义中的竖线可以理解为“或者”,也就是说,MyBool类型的值要么是MyFalse,要么是MyTrue

定义了新的数据类型MyBool后,我们可以查询MyFalse的类型。

*SimpleVec> :t MyFalse
MyFalse :: MyBool

我们并不感到惊讶,发现它的类型是MyBool

当我们有多个数据构造器时,它们通常与数据类型本身有不同的名称。

让我们定义我们自己的Maybe版本,叫做MyMaybe。回顾第九章,Maybe是一个类型构造器,意味着它接受一个类型作为输入,以生成一个新类型。

data MyMaybe a = MyNothing
               | MyJust a
               deriving (Eq,Show)

类型变量a代表任何类型。我们在这个数据类型定义中使用类型变量a后缀的MyMaybe使得MyMaybe成为一个类型构造器,而不是一个类型。这里我们有两个数据构造函数,但与MyBool类型不同的是,数据构造函数MyJust包含了一些信息,即一个a类型的值。MyMaybe a类型的值要么是MyNothing,要么是MyJust x,其中x :: a

我们来看一下数据构造函数的类型。

*SimpleVec> :t MyNothing
MyNothing :: MyMaybe a
*SimpleVec> :t MyJust
MyJust :: a -> MyMaybe a

为了比较,我们可以查看Maybe类型的 Prelude 数据构造函数的类型。

*SimpleVec> :t Nothing
Nothing :: Maybe a
*SimpleVec> :t Just
Just :: a -> Maybe a

我们看到Nothing甚至不是一个函数,它只是Maybe a类型的一个值。另一方面,Just是一个函数,它接受一个a类型的值并返回一个Maybe a类型的值。

在第十九章中,当我们讨论粒子系统时,我们将定义一个名为Force的新数据类型,它有两个构造函数:一个用于外力,一个用于内力。

既然我们已经讨论了如何定义新的数据类型,现在让我们继续定义我们在本章中使用的Vec类型。

为三维向量定义一个新的数据类型

Haskell 并没有内建向量类型,因此我们必须自己定义它。在本章的开始,我们看到了物理中如何定义和使用向量。凭借这些知识,我们将讨论如何在 Haskell 中实现三维向量。这个新类型必须存储三个实数,用于表示某坐标系中向量的三个分量,或者等效的东西。我们有几个选择。

可能的实现方式

在做出最终选择之前,让我们考虑一下Vec类型的几种可能实现。

选项 1:使用列表

我们可以使用一个实数列表来存储向量的三个分量。这个定义的类型同义词如下所示:

type Vec = [R]  -- not our definition

这种类型的向量可以存储所有可能的实数三元组。这个定义的问题在于,它的类型也可以存储不是三个元素的实数列表。这个潜在的类型与我们的需求不完全匹配,它有点太大,因为空列表或包含两个实数的列表会被类型检查器视为该类型的合法值。

选项 2:使用元组

一个更好的选择是选择一个实数的三元组。类型同义词如下所示:

type Vec = (R,R,R)  -- not our definition

这个选项更符合我们的需求,因为这个类型保证必须有三个组件。这个选项唯一的缺点是,三元组可能会引起混淆——它代表的是向量的三个分量,而不是代表其他三个数字的三元组,比如位置的球面坐标。由于选项 2 使用了类型同义词,类型检查器无法帮助我们捕捉到在需要其他三个实数的地方错误使用了我们新的向量类型。

选项 3:创建一个新数据类型

第三种选择是为Vec定义一个新的数据类型,该类型与任何其他数据类型不会混淆,即使其他数据类型本质上是由三个实数组成的,像Vec一样。我们希望像三维向量这样的物理基本概念能在类型系统中得到体现,这样类型系统就能帮助我们保持事物的清晰,尊重我们对学科的理解。这是我们接下来要追求的选项。

Vec的数据类型定义

这是我们的数据类型定义:

data Vec = Vec { xComp :: R  -- x component
               , yComp :: R  -- y component
               , zComp :: R  -- z component
               } deriving (Eq)

我们决定使用相同的名称Vec作为我们为该类型使用的数据构造器。我们使用记录语法,因为这会自动为向量的三个分量生成函数xCompyCompzComp。我们通过deriving关键字请求编译器为新的Vec数据类型创建一个Eq实例。然而,我们并没有请求自动生成Show实例,因为我们希望手动定义它。

接下来,我们将展示如何将类型Vec变成类型类Show的实例。使类型成为类型类实例的一般方法是使用instance关键字。

instance Show Vec where
    show (Vec x y z) = "vec " ++ showDouble x ++ " "
                              ++ showDouble y ++ " "
                              ++ showDouble z

instance关键字后,我们给出类型类,接着是要成为该类型类实例的类型,然后是where关键字,再给出类型类拥有的函数的定义。

从第二行开始,我们定义了类型类拥有的函数,并说明它们在特定类型(在本例中是Vec)的情况下应该如何工作。在Show的实例中,唯一需要定义的函数是show,它描述了如何将Vec转换为String以便显示。

我们显示一个向量的方式是从字符串"vec "开始,接着依次显示三个分量。函数showDouble负责将每个实数转换为字符串。

在实例定义中,show函数的定义必须相对于instance关键字缩进。任何在实例定义中定义的其他函数也必须进行相同程度的缩进。

其实,类型类Show还有两个其他函数,分别是showsPrecshowList,但如果我们不定义它们,它们会自动获得默认定义,正如我们之前所做的那样。在 GHCi 中使用:i Show可以列出类型类Show拥有的函数,以及哪些函数必须在实例定义中定义。

这是之前在函数show中使用的函数showDouble

showDouble :: R -> String
showDouble x
    | x < 0      = "(" ++ show x ++ ")"
    | otherwise  = show x

类型Double已经是Show的一个实例,如第八章所述,因此show函数已经可以将Double转换为String。我们的showDouble函数使用show函数,并简单地将负数用括号括起来。将负数成分括在括号中的原因是,这样显示出来的Vec的形式是一个合法的表达式,意味着它可以在需要Vec的地方作为输入。为了实现像vec 3.1 (-4.2) 5.0这样的表达式被接受为Vec类型的合法值,我们需要一个vec函数。

-- Form a vector by giving its x, y, and z components.
vec :: R  -- x component
    -> R  -- y component
    -> R  -- z component
    -> Vec
vec = Vec

这个vec函数和数据构造函数Vec做的事情是一样的。

为什么不直接使用数据构造函数Vec来构建和显示我们的向量,从而省去为Vec定义Show实例和定义vec函数的需求呢?这确实是一种可行的做法,而且并不差。之所以没有这样做,主要是因为我想使用记录语法,而通过使用deriving关键字自动生成的Show实例会使用记录语法来显示向量。这本身没有问题,但当我们处理向量的列表或向量元组的列表时,我们将需要一种简洁的方式来显示向量。

Haskell 传统上倾向于能够显示的东西也能被读取。Read类型类用于那些可以从String中读取的类型,它作为Show类型类的逆操作,后者用于那些可以显示为String的类型。这也是为什么Show实例看起来就像是将vec函数应用于三个分量的原因。

Vec 函数

下面是 x、y 和 z 方向的单位向量:

iHat :: Vec
iHat = vec 1 0 0

jHat :: Vec
jHat = vec 0 1 0

kHat :: Vec
kHat = vec 0 0 1

我们将零向量命名为zeroV

zeroV :: Vec
zeroV = vec 0 0 0

一元负号在向量前是无法使用的,因此我们定义了一个函数negateV,它返回一个向量的加法逆元(即该向量的负向量)。

negateV :: Vec -> Vec
negateV (Vec ax ay az) = Vec (-ax) (-ay) (-az)

向量的加法和减法只是对应笛卡尔分量的加法和减法。

(^+^) :: Vec -> Vec -> Vec
Vec ax ay az ^+^ Vec bx by bz = Vec (ax+bx) (ay+by) (az+bz)

(^-^) :: Vec -> Vec -> Vec
Vec ax ay az ^-^ Vec bx by bz = Vec (ax-bx) (ay-by) (az-bz)

拥有一个可以添加整个向量列表的函数会很有用。当我们做向量值函数的数值积分时,将会用到这个函数。

sumV :: [Vec] -> Vec
sumV = foldr (^+^) zeroV

函数 foldr 在 Prelude 中已定义。sumV的定义采用点自由样式,这意味着它是sumV vs = foldr (^+^) zeroV vs的简写。大致来说,foldr 接受一个二元操作符(这里是(^+^))、一个初始值和一个值的列表,并将初始值与列表中的一个元素“折叠”成一个累积值,接着继续将累积值与下一个元素折叠,直到列表耗尽并返回最终的累积值。它是一个相当强大的函数,但这里的作用仅仅是不断地将列表中的成员相加,直到没有更多元素为止。

有三种方法可以相乘三维向量。第一种是标量乘法,我们将一个数与一个向量相乘,或者一个向量与一个数相乘。我们使用(*^)(^*)来表示标量乘法。第一个符号表示左边是数值,右边是向量;第二个符号则表示左边是向量,右边是数值。向量总是紧挨着插入符号。第二种向量乘法方法是点积。我们用(<.>)表示点积。第三种向量乘法方法是叉积。我们用(><)表示叉积。

这里是三种向量乘法的定义:

(*^)  :: R   -> Vec -> Vec
c *^ Vec ax ay az = Vec (c*ax) (c*ay) (c*az)

(^*)  :: Vec -> R   -> Vec
Vec ax ay az ^* c = Vec (c*ax) (c*ay) (c*az)

(<.>) :: Vec -> Vec -> R
Vec ax ay az <.> Vec bx by bz = ax*bx + ay*by + az*bz

(><)  :: Vec -> Vec -> Vec
Vec ax ay az >< Vec bx by bz
    = Vec (ay*bz - az*by) (az*bx - ax*bz) (ax*by - ay*bx)

前两个定义是关于标量乘法的。如果向量在数值的右边,我们使用右侧有插入符号的操作符。如果向量在数值的左边,我们使用左侧有插入符号的操作符。在这两种情况下,定义表明标量乘法是通过将每个笛卡尔分量与缩放数相乘来完成的。点积通过方程式 10.8 定义。叉积通过方程式 10.9 定义。

我们还可以将向量除以标量。

(^/) :: Vec -> R -> Vec
Vec ax ay az ^/ c = Vec (ax/c) (ay/c) (az/c)

最后,我们定义了一个magnitude函数来计算向量的大小。

magnitude :: Vec -> R
magnitude v = sqrt(v <.> v)

这完成了我们为新的数据类型Vec定义的数据类型,并附带支持函数,使我们能够以我们对向量的理解和在物理中使用它们的方式来编写代码。

总结

本章讨论了三维空间中的运动学。在三维空间中,时间由实数表示,而速度和加速度由向量表示。位置严格来说并不是一个向量,但在本章中,我们简化处理,将位置视为某个优选或默认坐标系统原点到某点的位移向量。

向量本质上是几何实体;为了用数字描述向量的分量,我们必须引入一个坐标系。在任何物体运动的情境中,我们都可以将加速度分解为与速度平行的分量和与速度垂直的分量。这种分解是与坐标系无关的。

有了向量系统,我们现在可以解决我们曾经想做的所有抛物运动问题。我们展示了 Haskell 在定义自定义数据类型方面的便捷性,并利用这一系统实现了三维向量的Vec类型。

练习

练习 10.1. 将以下数学定义翻译为 Haskell 定义:

(a) 图片 (在 Haskell 中使用v0表示**v**[0]。)
(b) 图片 (使用v1表示**v**[1]。)
(c) 图片 (使用v表示v。)
(d) 图片 (使用r表示r。)
(e) 图片 (使用x表示x。)

v0、v1、v、r 和 x 的 Haskell 类型是什么?

练习 10.2. 编写一个积分函数

vecIntegral :: R          -- step size dt
            -> (R -> Vec) -- vector-valued function
            -> R          -- lower limit
            -> R          -- upper limit
            -> Vec        -- result
vecIntegral = undefined

用于实变量的向量值函数,类似于函数

integral :: R -> (R -> R) -> R -> R -> R -- from Chapter 6

我们在 第六章 中写过的函数。

练习 10.3. 编写一个函数

maxHeight :: PosVec -> Velocity -> R
maxHeight = undefined

返回投射运动中的最大 z 分量,其中给定了物体的初始位置和初始速度。假设重力作用于负 z 方向。

练习 10.4. 编写一个函数

speedCA :: Velocity -> Acceleration -> Time -> R
speedCA = undefined

给定初始速度和恒定加速度,返回一个给出速度随时间变化的函数。

练习 10.5. 按照 projectilePos 函数的思路,编写类型签名和函数定义,定义一个计算给定时间投射物速度的函数 projectileVel

练习 10.6. 为二维向量定义一个新的类型 Vec2D。然后定义函数

magAngleFromVec2D :: Vec2D -> (R,R)
magAngleFromVec2D = undefined

vec2DFromMagAngle :: (R,R) -> Vec2D
vec2DFromMagAngle = undefined

计算二维向量的大小和角度,并从大小和角度构建一个二维向量。你可能想使用我们在 第一章 中讨论过的 atanatan2 函数。

练习 10.7. 定义一个函数

xyProj :: Vec -> Vec
xyProj = undefined

计算一个向量在 xy 平面的投影。例如,xyProj (vec 6 9 7) 应该计算为 vec 6 9 0

练习 10.8. 定义一个函数

magAngles :: Vec -> (R,R,R)
magAngles = undefined

返回一个三元组 (v, θ, ϕ),其中 v 是一个向量,并且

Image

例如,magAngles (vec (-1) (-2) (-3)) 应该计算为:

(3.7416573867739413,2.5010703409103687,-2.0344439357957027)

练习 10.9. 从地面发射的一个球的速度和加速度是

Image

其中 v[0] 是球的初速度,g 是重力加速度。假设一颗球从地面发射,初速度为 25 m/s,角度为 52^∘ 以上水平面。选择一个坐标系并定义一个常量

gEarth :: Vec
gEarth = undefined

用于地球表面附近的重力加速度。它应该是 9.8 m/s²,指向地球的中心。接着,定义一个函数

vBall :: R -> Vec
vBall t = undefined t

给出球的速度随时间变化的函数。现在定义一个函数

speedRateChangeBall :: R -> R
speedRateChangeBall t = undefined t

给出球的速度变化率作为时间的函数。你可能想使用 speedRateChange 来处理这个问题。在球的运动过程中,在哪一点速度变化率为零?此时它的速度是零吗?此时它的加速度是零吗?使用 第七章 中的 plotFunc 绘制球的速度变化率随时间变化的图形,时间为四秒。

练习 10.10. 考虑一个做匀速圆周运动的粒子。如果我们选择坐标系,使得运动发生在 xy 平面内,圆心作为原点,则可以将粒子的位置写为

Image

其中 R 是圆的半径,ω 是运动的角速度。粒子的速度可以通过对位置关于时间的导数来找到。

Image

粒子的加速度可以通过对速度关于时间的导数来求得。

Image

这个在匀速圆周运动中的粒子有一个速度 v[UCM] (t) = ωR,这个速度与时间无关。常数速度就是我们所说的“匀速”。

对于半径 R = 2 米,角速度 ω = 6 rad/s 的匀速圆周运动,用 Haskell 编码粒子的位移、速度和加速度。使用aParallel确认在多个不同的时刻,加速度的切向分量为 0。使用aPerp确认在多个不同的时刻,加速度的径向分量的大小为[v[UCM] (t)]²/R = ^(ω²)R

练习 10.11. 考虑一个在半径 R 的圆上做非匀速圆周运动的粒子。如果我们选择坐标系统,使得运动发生在 xy 平面上,圆心为原点,则可以将粒子的位置写作

Image

其中 θ(t) 描述了粒子与 x 轴的夹角随时间变化的函数。粒子的速度可以通过对位置关于时间的导数来求得。

Image

粒子的加速度可以通过对速度关于时间的导数来求得。

Image

这个在圆周运动中的粒子有一个速度 v[NCM] (t) = R |(t)|,这个速度会依赖于时间,除非 (t) 是常数。粒子切向加速度的大小是 R |^(D²)θ(t)|,其径向加速度的大小是 [v[NCM] (t)]²/R = R[(t)]²。

编写一个函数

rNCM :: (R, R -> R) -> R -> Vec
rNCM (radius, theta) t = undefined radius theta t

该函数接受半径 R、函数 θ 和时间 t 作为输入,并返回位置向量作为输出。

本练习的目的是确认即使在非匀速圆周运动中,粒子的加速度径向分量的大小也等于其速度的平方除以圆的半径。以下函数可以求得任何粒子的加速度径向分量,其位置可以表示为时间的函数。它的第一个输入是用于数值求导的小时间间隔。第二个输入是粒子的位置函数,第三个输入是时间。

aPerpFromPosition :: R -> (R -> Vec) -> R -> Vec
aPerpFromPosition epsilon r t
    = let v = vecDerivative epsilon r
          a = vecDerivative epsilon v
      in aPerp (v t) (a t)

对于半径 R = 2 米,并且

Image

使用aPerpFromPositiont = 2 秒时求解加速度的径向分量。然后求粒子在该时刻的速度。最后,证明径向分量的大小等于其速度的平方除以圆的半径。

第十一章:创建图形

图片

当你为正式报告制作图表时,你希望有标题、轴标签,可能还有其他帮助读者理解你想表达内容的特性。在本章中,我们将展示如何使用 Haskell 创建这样的图表。我们将探讨标题、轴标签和其他标签。我们将学习如何绘制以一对对的形式给出的数据。接着我们将展示如何在同一组坐标轴上绘制多个函数或多个数据集,如何控制轴的范围,以及如何将图表保存为文件,以便导入到其他文档中。

标题和轴标签

以下代码生成带有标题和轴标签的图形:

{-# OPTIONS_GHC -Wall #-}

import Graphics.Gnuplot.Simple

type R = Double

tRange :: [R]
tRange = [0,0.01..5]

yPos :: R  -- y0
     -> R  -- vy0
     -> R  -- ay
     -> R  -- t
     -> R  -- y
yPos y0 vy0 ay t = y0 + vy0 * t + ay * t**2 / 2

plot1 :: IO ()
plot1 = plotFunc [Title "Projectile Motion"
                 ,XLabel "Time (s)"
                 ,YLabel "Height of projectile (m)"
                 ,PNG "projectile.png"
                 ,Key Nothing
                 ] tRange (yPos 0 20 (-9.8))

和上一章一样,我们开启警告,以捕捉任何我们可能没有意识到的糟糕编程。然后我们导入Graphics.Gnuplot.Simple模块,它用于生成图形。接下来,我们将R设置为Double类型别名。这样我们就可以把Double看作实数,并用简短的名字R来表示它们。然后我们定义一个时间值的列表tRange,将在图形中使用,并定义一个表示抛物体高度的函数yPos

最后,我们定义plot1来生成一个图形。回顾一下,plotFunc的类型是

[Attribute] -> [a] -> (a -> a) -> IO ()

其中,a是某些专门类型类中的一种类型。Attribute类型定义在Graphics.Gnuplot.Simple模块中。如果你在 GHCi 提示符下输入:i Attribute:i:info的缩写),你将看到一些关于如何使用这些Attribute的选项。在plot1中,我们将五个Attribute的列表传递给plotFunc。第一个用于创建标题,第二个和第三个用于生成轴标签,第四个指定用于输出的文件名,最后一个请求不要显示图例。

注意IO ()(发音为“eye oh unit”)类型的plot1IO是一个类型构造子,像Maybe一样,但它是一个特殊的类型构造子,旨在表示一种效果,也就是一种非纯粹函数式的计算。效果以某种方式改变了世界(例如,更改硬盘上的文件或在屏幕上显示图片)。

类型(),称为unit,是一种只包含一个值的类型,这个值也写作(),也被称为 unit。一个只有一个值的类型无法传递任何信息,因为值没有选择余地。由于它无法传递任何信息,因此单独使用 unit 类型并不太有用。然而,当与IO类型构造子结合使用时,类型IO ()代表没有值的效果,这是一个非常有用的类型。

Key NothingAttribute省略了默认包含在图表中的键。由于该键引用的是一个我们不关心的临时文件,通常不包括默认键是没有信息意义的。读者应当被告知,Graphics.Gnuplot.Simple模块不仅仅是简单的,它有点“简单粗暴”。特别是,如果通过 Haskell 的String传递一个无效的gnuplot关键字,结果是完全没有输出,甚至没有错误信息。(例如,如果你想将图例的键从图表顶部移动到底部,Key (Just ["bottom"])是有效的,但Key (Just ["Bottom"])将没有任何输出,因为gnuplot的关键字是区分大小写的。)建议读者查阅Graphics.Gnuplot.Simple模块的在线文档以及gnuplot程序本身的文档。

如果你将刚才显示的代码加载到 GHCi 并在提示符下输入 plot1,它将生成一个名为projectile.png的文件,并保存在硬盘上,你可以将它插入到文档中。图 11-1 展示了它的样子。

Image

图 11-1:由函数 plot1 生成的图表

其他标签

你可能希望在图表上放置其他标签。以下是你可以做到的方法:

plot1Custom :: IO ()
plot1Custom
    = plotFunc [Title "Projectile Motion"
               ,XLabel "Time (s)"
               ,YLabel "Height of projectile (m)"
               ,PNG "CustomLabel.png"
               ,Key Nothing
               ,Custom "label" ["\"Peak Height\" at 1.5,22"]
               ] tRange (yPos 0 20 (-9.8))

请注意我们添加的Custom属性。引号前的反斜杠是因为我们需要在引号内传递引号。坐标1.5,22是我们希望标签出现的图表上的水平和垂直坐标。图 11-2 显示了它的样子。

Image

图 11-2:由函数 plot1Custom 生成的图表

包含自定义标签的语法相当繁琐且难以记忆,因此写一个接受更简单参数的新函数是明智的选择。

customLabel :: (R,R) -> String -> Attribute
customLabel (x,y) label
     = Custom "label" ["\"" ++ label ++ "\"" ++ " at "
                                ++ show x ++ "," ++ show y]

我们向自定义标签函数传递了两项信息:标签位置的坐标和标签的名称。第一项信息的类型是(R,R),第二项的类型是String。我们的函数customLabel将生成一个Attribute,可以包含在plotFunc函数的属性列表中。我们使用show函数将R类型转换为String,并使用++运算符连接字符串。

在 Haskell 中,我们通过在双引号字符前添加反斜杠来引用它。反斜杠告诉编译器我们是想写双引号字符本身,而不是表示字符串的开始。完成这一操作后,我们可以将双引号字符视为任何其他字符。

Prelude>  :t 'c'
'c' :: Char
Prelude>  :t '\"'
'\"' :: Char
Prelude>  :t "c"
"c" :: [Char]
Prelude>  :t "\""
"\"" :: [Char]

定义了customLabel函数后,我们可以使用以下更简洁的语法来绘制我们的图表:

plot2Custom :: IO ()
plot2Custom
    = plotFunc [Title "Projectile Motion"
               ,XLabel "Time (s)"
               ,YLabel "Height of projectile (m)"
               ,Key Nothing
               ,customLabel (1.5,22) "Peak Height"
               ] tRange (yPos 0 20 (-9.8))

绘制数据

有时我们希望绘制的是 (x, y) 配对的点,而不是函数。我们可以使用 plotPath 函数来实现这一点(这个函数也在 Graphics.Gnuplot.Simple 包中定义)。让我们看看 plotPath 函数的类型,以更好地理解它的用法。

Prelude> :m Graphics.Gnuplot.Simple
Prelude Graphics.Gnuplot.Simple> :t plotPath
plotPath
  :: Graphics.Gnuplot.Value.Tuple.C a =>
     [Attribute] -> [(a, a)] -> IO ()

在一系列属性之后,plotPath 接受一个包含我们要绘制的数据的配对列表。以下代码生成了与 图 11-2 相同的图表,但使用 plotPath 而不是 plotFunc

plot3Custom :: IO ()
plot3Custom
    = plotPath [Title "Projectile Motion"
               ,XLabel "Time (s)"
               ,YLabel "Height of projectile (m)"
               ,Key Nothing
               ,customLabel (1.5,22) "Peak Height"
               ] [(t, yPos 0 20 (-9.8) t) | t <- tRange]

我们使用列表推导式生成了 plotPath 所需的配对列表。

在一组坐标轴上绘制多条曲线

您可以在同一组坐标轴上绘制多条曲线。这在比较两个具有相同自变量和因变量的函数时特别有用。Graphics.Gnuplot.Simple 中的 plotFuncs 函数使我们能够绘制一个函数列表。

Prelude Graphics.Gnuplot.Simple> :t plotFuncs
plotFuncs
   :: (Graphics.Gnuplot.Value.Atom.C a,
       Graphics.Gnuplot.Value.Tuple.C a) =>
      [Attribute] -> [a] -> [a -> a] -> IO ()

请注意,plotFuncs 函数将一个函数列表作为其参数之一。我们在 第五章 中承诺过会找到一个使用函数列表的例子,现在我们实现了!以下是如何使用 plotFuncs 的示例:

xRange :: [R]
xRange = [0,0.02..10]

f3 :: R -> R
f3 x = exp (-x)

usePlotFuncs :: IO ()
usePlotFuncs = plotFuncs [] xRange [cos,sin,f3]

两个图表的 x 值范围不必相同。考虑以下示例,它引入了新的函数 plotPaths

xs1, xs2 :: [R]
xs1 = [0,0.1..10]
xs2 = [-5,-4.9..5]

xys1, xys2 :: [(R,R)]
xys1 = [(x,cos x) | x <- xs1]
xys2 = [(x,sin x) | x <- xs2]

usePlotPaths :: IO ()
usePlotPaths = plotPaths [] [xys1,xys2]

plotPaths 函数接受一个包含配对列表的列表,而 plotPath 函数则接受一个配对列表。

控制图表范围

默认情况下,gnuplot(在幕后生成图表的程序)将根据您提供的 x 范围和相应计算的 y 范围来绘制图形。有时,您可能希望更多地控制 x 范围或 y 范围。

重新回到之前三个图表的示例,尝试以下代码:

usePlotFuncs' :: IO ()
usePlotFuncs' = plotFuncs [ XRange (-2,8)
                          , YRange (-0.2,1)
                          ] xRange [cos,sin,f3]

通过指定 XRange (-2,8),我们生成了一个从 x = –2 到 x = 8 的图表。由于 xRange 范围是从 0 到 10,在 x = –2 到 x = 0 这一范围内没有计算数据,因此这一区域在图表上是空白的。尽管我们要求计算数据直到 x = 10,但它只显示到 x = 8。因为我们指定了 YRange (-0.2,1),所以在 y = –1 到 y = –0.2 之间的余弦和正弦函数值将不会显示。

注意我以一种有趣的风格列出了 [XRange (-2,8), YRange (-0.2,1)]。有些编写 Haskell 代码的人会在列表的第二行首位放置逗号,但你并不一定要这么做。你可以把它全部写在一行里,或者将逗号放在第一行的末尾。这完全是风格问题。

制作图例

gnuplot 提供的默认图例并不是很有用。它显示的是我们不感兴趣的临时文件名。制作一个漂亮的图例并不是一件简单的事,但它是可以做到的。以下代码给出了一个示例:

xRange' :: [R]
xRange' = [-10.0, -9.99 .. 10.0]

sinPath :: [(R,R)]
sinPath = [(x, sin x) | x <- xRange' ]

cosPath :: [(R,R)]
cosPath = [(x, cos x) | x <- xRange' ]

plot4 :: IO ()
plot4 = plotPathsStyle [ Title "Sine and Cosine"
                       , XLabel "x"
                       , YLabel "Function Value"
                       , YRange (-1.2,1.5)
                       ] [ (defaultStyle {lineSpec = CustomStyle
                                          [LineTitle "sin x"]}, sinPath)
                         , (defaultStyle {lineSpec = CustomStyle
                                          [LineTitle "cos x"]}, cosPath) ]

这里我们使用 plotPathsStyle 函数,它是 plotPaths 的扩展版本,允许进行样式上的调整。与 plotPaths 需要的列表列表对不同,plotPathsStyle 需要一个由配对组成的列表,每个配对包括一个 PlotStyle 和一个包含要绘制数据的配对列表。通过这种方式,我们可以为每条曲线提供一个标题,并在图例中显示。

总结

在本章中,我们向工具箱中添加了绘图工具。我们学习了如何为图形提供标题、坐标轴标签以及其他标签。我们学习了如何绘制给定为配对列表形式的数据。我们了解了如何在一个坐标轴上绘制多个函数或多个配对列表。我们学习了如何手动控制坐标轴范围,并如何将图形保存为文件,以便导入到其他文档中。在下一章中,我们将学习如何在 Haskell 中创建独立的程序。

习题

习题 11.1. 绘制从 x = –3 到 x = 3 的 y = x² 曲线,并添加标题和坐标轴标签。

习题 11.2. 绘制余弦和正弦函数的图形,将它们放在同一坐标轴上,范围从 x = 0 到 x = 10。

习题 11.3. 查看 plotPath 的类型签名,并弄清楚如何绘制下面 txPairs 列表中的点:

ts :: [R]
ts = [0,0.1..6]

txPairs :: [(R,R)]
txPairs = [(t,30 * t - 4.9 * t**2) | t <- ts]

制作一个带有标题和坐标轴标签(包括单位)的图表。

习题 11.4. 编写一个函数

approxsin :: R -> R
approxsin = undefined

使用泰勒展开的前四项来逼近正弦函数。

Image

(根据你如何操作,可能会遇到不能将R除以IntInteger的问题,这在 Haskell 中是常见的。你只能将同一数值类型之间进行除法运算。如果你遇到这个问题,可以使用fromIntegral函数将IntInteger转换为其他类型,如R。)

在 GHCi 中尝试以下命令来测试你的函数:

plotFuncs [] [-4,-3.99..4] [sin,approxsin]

制作一个漂亮的图表(包括标题、坐标轴标签、标注每条曲线的标签等)。

第十二章:创建独立程序

图片

到目前为止,我们已经使用 GHCi 进行所有计算并显示结果。我们已经编写了相当复杂的源代码文件,但我们一直将它们加载到 GHCi 中以使用其函数。然而,Haskell 是一种功能齐全、适合生产环境的计算机语言,完全能够编译无需任何 GHCi 参与的独立程序。第十三章及后续章节中的动画演示最好使用独立程序来执行,而不是 GHCi。

本章解释了三种不同的构建独立(可执行)程序的方法。最基本的方法是使用ghc来生成可执行程序。使用这种方法时,你需要自己负责安装程序所需的任何库包。第二种方法是使用cabal,它会自动安装程序所需的库包,但这些包必须在配置文件的适当位置列出。第三种方法是使用stack,它会自动执行更多操作,比如安装与你请求的包版本兼容的 GHC 编译器版本。要创建一个独立程序,你只需使用这三种方法中的一种。如果你是 Haskell 新手,你可能会发现stack方法是最容易使用的。

对于这三种方法中的每一种,我们将逐步讲解如何生成一个可执行程序:(a)用于一个非常简单的程序,(b)用于一个同时使用我们编写的模块和其他人编写的模块的程序。

使用 GHC 构建独立程序

在本节中,我们直接使用 GHC 来创建独立程序。首先,我们为一个非常简单的程序“你好,世界!”做演示,然后再为一个更复杂的程序做演示,该程序导入了模块。

你好,世界!

在学习一门新语言时,人们通常编写的最简单的独立程序被称为“你好,世界!”。这个程序的功能就是打印“你好,世界!”并退出。对于许多计算机语言来说,学习如何编写“你好,世界!”程序是语言学习过程中的早期步骤。然而,在 Haskell 中,提早学习“你好,世界!”没有太大意义,因为“你好,世界!”程序的重点是产生效果,即在屏幕上打印某些内容,而 Haskell 编程的核心,甚至是函数式编程的核心,是关于纯函数的,纯函数没有副作用。

Haskell 中的“你好,世界!”程序由两行代码组成:

main :: IO ()
main = putStrLn "Hello, world!"

每个独立程序都需要一个名为main的函数,它通常具有类型IO ()。我们在第七章中首次介绍了IO (),它是一个不返回有意义值但会产生副作用的非纯函数的类型。一般来说,IO a类型表示一个类型为a的值以及一个副作用。

main函数需要产生某些效果,否则我们无法确认程序是否真正运行。main这个有副作用的函数的目的是向编译器描述我们希望计算机做什么,而IO ()类型正好适合这个目的,因为它表示一个没有实际值的副作用。

函数putStrLn是一个 Prelude 函数,它接受一个字符串作为输入,在屏幕上打印该字符串,并换行,以便任何后续打印将在新的一行显示。还有一个名为putStr的函数,类型与putStrLn相同,它打印一个字符串,但不会换行,因此后续的打印会直接跟在打印的字符串后面。名称中的Ln提醒我们,该函数在打印后会换行。putStrLn的类型显示它接受一个字符串作为输入,并产生一个效果。

Prelude> :t putStrLn
putStrLn :: String -> IO ()

假设我们将这两行代码放在一个名为hello.hs的源代码文件中。如果你的操作系统提供了命令行,命令如下:

$ ghc hello.hs

将编译源代码文件hello.hs,生成一个名为hello的可执行文件,你可以运行它。在 Linux 系统上,你可以通过命令行使用命令运行程序hello

$ ./hello

程序名称前面的点斜杠告诉操作系统执行当前工作目录中的名为hello的程序。如果省略点斜杠,操作系统会在标准搜索路径中查找名为hello的程序,如果当前工作目录不在搜索路径中,可能找不到该程序。

一个导入模块的程序

现在我们来编译一个独立的程序,使用我们在第十章中编写的SimpleVec模块的函数,以及gnuplot包中的Graphics .Gnuplot.Simple模块的函数。包含SimpleVec模块源代码的文件SimpleVec.hs可以在lpfp.io找到。列表 12-1 展示了我们想要编译的独立程序。

{-# OPTIONS -Wall #-}

import SimpleVec ( iHat, kHat, xComp, zComp, projectilePos, (^+^), (*^) )
import Graphics.Gnuplot.Simple ( Attribute(..), plotPath )

main :: IO ()
main = let posInitial = 10 *^ kHat
           velInitial = 20 *^ cos (pi/6) *^ iHat ^+^ 20 *^ sin (pi/6) *^ kHat
           posFunc = projectilePos posInitial velInitial
           pairs = [(xComp r, zComp r) | t <- [0, 0.01 ..], let r = posFunc t]
           plottingPairs = takeWhile (\(_,z) -> z >= 0) pairs
       in plotPath [Title "Projectile Motion"
                   ,XLabel "Horizontal position (m)"
                   ,YLabel "Height of projectile (m)"
                   ,PNG "projectile.png"
                   ,Key Nothing
                   ] plottingPairs

列表 12-1:独立程序 MakeTrajectoryGraph.hs,使用了 SimpleVec 模块和 Graphics.Gnuplot.Simple 模块的函数

清单 12-1 中的程序生成了一个图表,展示了从离地面 10 米高的建筑物顶部抛出的一个球的轨迹,初速为 20 米/秒,抛掷角度为水平面上方 3^(0∘)。程序生成了一个名为projectile.png的文件,包含了该图表。为了完成这项工作,程序从第十章的SimpleVec模块导入了如projectilePosxCompzCompiHatkHat等函数。程序还使用了来自Graphics.Gnuplot.Simple模块的plotPath函数。由于使用了数据构造器TitleXLabel等来自Attribute数据类型,因此我们通过在类型名Attribute后附加两个冒号和括号,导入了Attribute数据类型及其构造器。

我们假设清单 12-1 中的代码包含在名为MakeTrajectoryGraph.hs的源代码文件中。要使用ghc编译程序,必须满足两个条件:

  • 包含SimpleVec模块的文件SimpleVec.hs必须与包含主程序的文件MakeTrajectoryGraph.hs位于同一目录。我们将这个目录称为工作目录

  • 工作目录必须能够访问Graphics.Gnuplot.Simple模块。这要求gnuplot包已被安装,(a)全局安装,以便从任何目录访问,或(b)局部安装,以便从工作目录访问。要全局安装gnuplot包,请发出以下命令:

    $ cabal install --lib gnuplot
    

    在我的计算机上,此命令会创建或更改文件/home/walck/.ghc/x86_64-linux-8.10.5/environments/default,其中包含了全局安装的 Haskell 包列表。要在本地(即工作目录中)安装gnuplot包,请发出以下命令:

    $ cabal install --lib gnuplot --package-env .
    

    此命令会在当前工作目录中创建或更改一个名为.ghc .environment.x86_64-linux-8.10.5的文件。该文件包含了在本地安装的包列表(即当前工作目录中的包)。有关安装 Haskell 包的更多信息,请参见附录。

一旦满足这两个条件,我们通过发出以下命令,将源代码文件MakeTrajectoryGraph.hs编译成可执行程序:

$ ghc MakeTrajectoryGraph.hs

此命令必须从包含文件MakeTrajectoryGraph.hs、文件SimpleVec.hs并且能够访问Graphics.Gnuplot.Simple模块的同一工作目录中发出。

如果编译器找不到Graphics.Gnuplot.Simple模块,你将看到如下错误:

MakeTrajectoryGraph.hs:4:1: error:
    Could not load module 'Graphics.Gnuplot.Simple'

在这种情况下,必须安装gnuplot包,无论是全局安装还是局部安装,这样它才可以从工作目录中访问。

如果一切顺利,编译器将在当前工作目录下生成一个名为Make TrajectoryGraph的可执行文件。该可执行文件不会安装到任何全局位置,因此要运行程序,你需要提供可执行文件的完整路径名,或者通过在可执行文件名前加上./来从所在目录运行,如下所示:

$ ./MakeTrajectoryGraph

使用ghc来生成可执行程序的好处是你不需要担心配置文件。缺点是程序所需的任何模块,不论是你自己写的还是其他人写的,都必须能够从程序所在的目录中访问。随着你的程序依赖的库包数量增加,这种安装负担也在增加,特别是因为程序可以接受的包版本可能与其他程序或你想使用的其他库包的可接受版本发生冲突。我们接下来要介绍的cabalstack工具是为了解决这种复杂性设计的,这样你就不必亲自处理这些问题。

使用 Cabal 制作独立程序

在上一节中,我们使用了cabal安装了一个包。但是cabal工具在你的 Haskell 生态系统中可以发挥更大的作用,它可以管理你的独立程序所需的模块和包,并使用兼容的版本,即使它们与其他项目使用的包发生冲突。要获取有关cabal工具可以做什么的基本信息,请输入以下命令

$ cabal help

在命令提示符下输入。

使用cabal来管理项目依赖关系的第一步是创建一个新的子目录,里面包含你的独立程序的源代码以及cabal执行其工作的所需文件。我们使用以下命令在当前目录下创建一个名为Trajectory的新目录。为这个目录使用一个唯一的名称,因为该名称将成为可执行程序的默认名称,也是项目的一般名称。

$ mkdir Trajectory

我们进入这个新目录,并通过输入命令使其成为工作目录

$ cd Trajectory

其中cd代表“更改目录”。在这个新目录内,我们输入以下命令:

$ cabal init

这将创建一个名为Trajectory.cabal的文件和一个名为app的子目录,其中包含一个名为Main.hs的文件。旧版本的cabal会将Main.hs创建在当前目录,而不是app子目录中。

假设你可能希望将代码与其他人共享,cabal要求你有一个名为LICENSE的文件,内容包括其他人可以使用你代码的条款。cabal工具可能要求你在编译代码之前有这样的文件,因此请准备好提供一个。cabal程序并不关心LICENSE文件的内容,只关心它是否存在。

文件Main.hs是一个默认的源代码文件,包含一个非常简单的程序。要编译它,请输入

$ cabal install

在命令提示符下,当当前工作目录为Trajectory时,如果一切顺利,cabal将编译Main.hs中的代码,生成名为Trajectory的可执行文件,并使该可执行文件在全局可用,这意味着可以通过输入其名称Trajectory来运行,而不需要输入包含文件路径结构的完整路径名。

我们可以使用以下命令测试可执行文件:

$ Trajectory

然后我们应该看到一条简短的欢迎信息。

继续使用cabal为清单 12-1 中的代码生成独立程序,我们查看Trajectory.cabal文件。这是cabal的配置文件,告诉它如何将源代码编译成当前目录项目的可执行代码。之前展示的cabal init命令在创建Trajectory.cabal时选择了多个选项的默认值。我们现在关注的行大致如下所示:

executable Trajectory
    main-is:          Main.hs

    -- Modules included in this executable, other than Main.
    -- other-modules:

    -- LANGUAGE extensions used by modules in this package.
    -- other-extensions: build-depends:    base ^>=4.14.2.0
    hs-source-dirs:   app

第一行表示可执行程序的名称将是Trajectory。这个默认名称与项目目录的名称相匹配;但是,如果我们愿意,也可以将其更改为其他名称。第二行给出了包含main函数的源代码文件的名称。默认情况下,这个文件被称为Main.hs,并位于Trajectory目录的app子目录中。由双短横线开头的行是注释。以build-depends:开头的行列出了主程序所依赖的包。默认情况下,.cabal文件只包含对base包的依赖。base包使所有 Prelude 函数和类型可用。以hs-source-dirs:开头的行是包含源文件的子目录列表。

要编译清单 12-1 中的代码,该代码包含在Make TrajectoryGraph.hs文件中,我们需要做三件事:

  1. 将文件MakeTrajectoryGraph.hs复制或移动到Trajectory目录的app子目录中。然后编辑Trajectory.cabal,将主源代码文件的名称从Main.hs更改为MakeTrajectoryGraph.hs。修改后的Trajectory.cabal中的行如下所示:

       main-is:         MakeTrajectoryGraph.hs
    
  2. 将包含SimpleVec模块的文件SimpleVec.hs复制或移动到Trajectory目录的app子目录中。(该文件以及本书中的所有其他模块可以在lpfp.io获取。)然后编辑Trajectory.cabal,取消注释(去掉双短横线)other-modules:行,并添加SimpleVec模块(不带.hs扩展名)。

       other-modules:    SimpleVec
    
  3. 编辑Trajectory.cabal,在build-depends:行中包含gnuplot。这样,我们就可以在主程序中导入模块Graphics.Gnuplot.Simple。做完这三项更改后,修改后的Trajectory.cabal中的行如下所示:

    executable Trajectory
        main-is:          MakeTrajectoryGraph.hs
    
        -- Modules included in this executable, other than Main.
        other-modules:    SimpleVec
    
        -- LANGUAGE extensions used by modules in this package.
        -- other-extensions:
        build-depends:    base ^>=4.14.2.0, gnuplot
        hs-source-dirs:   app
    

虽然 base 包对允许的 base 版本有界限,但我们并未为 gnuplot 包指定版本限制。版本限制的目的是允许仍在开发中的代码以与以前版本不兼容的方式演变。库包的作者遵循约定,表示小版本号变化和修复是由小的版本号变化表示的,而主要变更则通过版本号的较大变化来表示。使用版本限制,就像刚刚展示的 base,是一种确保你获得期望功能的技术。

gnuplot 添加到构建依赖列表中,会导致 cabal 安装 gnuplot 包,但以使其对这个项目私有的方式,即在 Trajectory 目录中的项目。作为结果,gnuplot 包在 GHCi 中将不可用。要使 gnuplot 在 GHCi 中可用,请按照附录中的说明进行操作。

现在重新发布

$ cabal install

重新编译名为 Trajectory 的程序。我们可以用以下命令测试可执行文件:

$ Trajectory

可执行文件应生成一个名为 projectile.png 的文件。

cabal 安装的包,如 gnuplot,存放在 https://hackage.haskell.org 上。你可以访问该网站搜索、浏览并阅读关于任何 cabal 可以安装的包的文档。

使用 Stack 来创建独立程序

stack 工具可以管理你的独立程序所需的模块和包,使用能够协同工作的版本,即使它们与你可能拥有的其他项目使用的包发生冲突。要获取有关 stack 工具功能的基本信息,请执行以下命令

$ stack --help

在你的命令提示符下。

使用 stack 管理名为 Trajectory 的新项目的依赖项的第一步是执行以下命令

$ stack new Trajectory

这将创建一个名为 Trajectory 的子目录。我们通过执行以下命令进入这个新目录,并将其设置为当前目录:

$ cd Trajectory

在这个目录中,我们可以找到 stack 为我们创建的几个文件和子目录。最重要的文件是 Trajectory.cabal,它包含关于如何编译你的程序的重要信息。stack 工具是在 cabal 工具的基础上构建的,并使用其配置文件。Trajectory.cabal 中最重要的几行内容如下:

library
  exposed-modules:
     Lib
  other-modules:
     Paths_Trajectory
  hs-source-dirs:
     src
  build-depends:
     base >=4.7 && <5
  default-language: Haskell2010

executable Trajectory-exe
  main-is: Main.hs
  other-modules:
     Paths_Trajectory
  hs-source-dirs:
     app
  ghc-options: -threaded -rtsopts -with-rtsopts=-N
  build-depends:
     Trajectory
   , base >=4.7 && <5
  default-language: Haskell2010

在这里,我们看到两个部分:一个以library开头,另一个以executable开头。library部分负责我们编写的模块的名称、位置和依赖项,例如SimpleVec。我们希望stack管理的模块名称放在exposed-modules:下,如果有多个模块,它们之间用逗号隔开。新建的stack项目的默认程序只使用一个模块,名为Lib。在这里,我们不需要使用other-modules:这一部分,可以保持为空。我们模块所在的目录放在hs-source-dirs:下。默认情况下,Trajectory目录下的子目录src是模块的存放位置,我们不需要修改这个设置。我们只需将模块复制或移动到stack为我们创建的src目录中。我们没有编写,但模块依赖的包(例如gnuplot)列在build-depends:下。

executable部分的第一行表明可执行程序的名称将是Trajectory-exe。这个默认名称与我们为项目命名时一致,但如果需要,我们可以将其改为其他名称。main-is:后跟包含main函数的源代码文件的名称。默认值为Main.hs。在executable部分,和library部分一样,我们不需要使用other-modules:这一部分,可以保持为空。可执行文件(独立程序)源代码所在的目录放在hs-source-dirs:下。默认情况下,Trajectory目录下的子目录app是主程序源代码的存放位置,我们不需要更改这个设置。我们只需将代码复制或移动到stack为我们创建的app目录中。目前,app子目录包含Main.hs源代码文件。

文件Main.hs是一个默认的源代码文件,包含一个非常简单的程序。要编译它,输入以下命令:

$ stack install

在命令提示符下,当前工作目录是Trajectory(包含.cabal文件的目录)。如果一切顺利,stack将编译Main.hs中的代码,生成一个名为Trajectory-exe的可执行文件,并将该可执行文件设置为全局可用,因此即使从其他目录,也可以通过输入其名称Trajectory-exe来运行它。

我们可以通过以下方式测试可执行文件:

$ Trajectory-exe

然后,我们应该看到屏幕上出现一串简短的文本。

接下来,我们使用stack将清单 12-1 中的代码,存储在文件MakeTrajectoryGraph.hs中,生成一个独立程序,步骤如下:

  1. 将文件 MakeTrajectoryGraph.hs 复制或移动到 Trajectory 目录下的 app 子目录中。然后编辑 Trajectory.cabal,将主源代码文件的名称从 Main.hs 改为 MakeTrajectoryGraph.hs。修改后的 Trajectory.cabal 中的行如下:

      main-is: MakeTrajectoryGraph.hs
    
  2. 将包含 SimpleVec 模块的文件 SimpleVec.hs 复制或移动到 Trajectory 目录下的 src 子目录中。该文件以及本书中的所有其他模块可以在 lpfp.io 获取。然后编辑 Trajectory.cabal,将 SimpleVec 模块添加到 library 部分的 exposed-modules: 字段中。

    library
      exposed-modules:
          SimpleVec
    
  3. 编辑 Trajectory.cabal,在 executable 部分的 build -depends: 头下包含 gnuplot 包。这允许我们在主程序中导入 Graphics.Gnuplot.Simple 模块。做完这三项更改后,Trajectory.cabal 中的修改行如下:```
    library
    exposed-modules:
    SimpleVec
    other-modules:
    Paths_Trajectory
    hs-source-dirs:
    src
    build-depends:
    base >=4.7 && <5
    default-language: Haskell2010

    executable Trajectory-exe
    main-is: MakeTrajectoryGraph.hs
    other-modules:
    Paths_Trajectory
    hs-source-dirs:
    app
    ghc-options: -threaded -rtsopts -with-rtsopts=-N
    build-depends:
    Trajectory
    , base >=4.7 && <5
    , gnuplot
    default-language: Haskell2010

    
    

请记住,构建依赖中需要包含的是包名,而不是模块名。当使用 stack 时,错误地将模块名 Graphics.Gnuplot.Simple 替换为包名 gnuplot 会导致解析错误,并且没有任何提示告诉你真正的问题所在。

现在重新发布

$ stack install

重新编译名为 Trajectory-exe 的程序。我们可以用以下命令测试该可执行文件:

$ Trajectory-exe

可执行文件应当创建一个名为 projectile.png 的文件。

stack 安装的包,例如 gnuplot,存放在 https://hackage.haskell.org 上。你可以访问该网站,搜索、浏览并阅读关于 stack 可以安装的任何包的文档。

总结

本章展示了三种生成独立 Haskell 程序的方法。第一种使用 ghc,你必须自己安装任何需要的库包。第二种使用 cabal,它可以帮助管理库包的依赖。第三种使用 stack,它同样可以帮助管理库包的依赖。在下一章,我们将把这些技巧应用于制作动画。

练习

练习 12.1. print 函数在独立程序中非常有用。询问 GHCi print 的类型,GHCi 会告诉你 print 是一个函数,它的输入可以是任何 Show 实例的类型,输出是 IO (),意味着它执行某些操作。print 的作用是将输入的值输出到屏幕上。你可以打印数字、列表、字符串以及任何可以显示的内容。你可以在 GHCi 中使用 print,但在 GHCi 中并不需要它,因为 GHCi 会自动打印你提供的值。

编写一个独立程序,打印前 21 个 2 的幂,从 2⁰ 到 2²⁰。当你运行程序时,输出应该如下所示:

[1,2,4,8,16,32,64,128,256,512,1024,2048,4096,8192,16384,32768,65536,131072,
262144,524288,1048576]

第十三章:创建二维和三维动画

图片

一个随时间变化的图像可以很好地可视化许多情况。Haskell Prelude 本身并不支持动画,但在 hackage.haskell.org 上有一些不错的库包可用。对于二维图像和动画,我们将使用 gloss 包。对于三维图像和动画,我们将使用一个名为 not-gloss 的包。

二维动画

gloss 包提供了 Graphics.Gloss 模块,其中包含四个主要函数:display、animate、simulate 和 play。第一个用于静态图像,第二和第三个用于随时间变化的图像,第四个用于随时间和用户输入变化的图像。我们主要关注前面三个函数。接下来的几节将详细描述这些函数。

显示二维图像

函数 display 生成一个静态图像。让我们向 GHCi 查询 display 的类型。由于 display 不是 Prelude 的一部分,因此我们必须先加载该模块。

Prelude> :m Graphics.Gloss
Prelude Graphics.Gloss> :t display
display :: Display -> Color -> Picture -> IO ()

类型 DisplayColorPicture 是由 Graphics.Gloss 模块定义的,或者可能是由 Graphics.Gloss 导入的 gloss 包中的另一个模块定义的。类型 DisplayColor 分别用于显示模式和背景颜色。最有趣的类型是 Picture,它代表了可以显示的内容类型。gloss 文档中关于 Picture 的描述说明了我们可以创建的图像(例如线条、圆形、多边形等)。你可以通过点击 Graphics.Glosshackage.haskell.org/package/gloss 查阅文档。

GHCi 在显示 gloss 创建的图像方面不太理想,因此最好创建一个独立的程序。让我们编写一个程序,帮助我们熟悉 Graphics.Gloss 模块使用的默认坐标系。我们将从原点绘制一条红色的线段,终点为(100,0),以及一条绿色的线段,终点为(0,100)。因为 gloss 是以像素为单位来测量距离的,我们使用 100 这样线段就足够长,能够在屏幕上看到。

{-# OPTIONS -Wall #-}

import Graphics.Gloss

displayMode :: Display
displayMode = InWindow "Axes" (1000, 700) (10, 10)

axes :: Picture
axes = Pictures [Color red   $ Line [(0,0),(100,  0)]
                ,Color green $ Line [(0,0),(  0,100)]
                ]

main :: IO ()
main = display displayMode black axes

如常,我们打开警告。然后导入 Graphics.Gloss 模块。我们需要这么做,因为接下来的代码使用了类型 DisplayPicture;数据构造器 InWindowPicturesColorLine;常量 redgreenblack;以及函数 display。这些名称都是在 Graphics.Gloss 模块中定义的。如果没有 import 语句,每次使用这些名称时都会出现“变量不在作用域”错误。

我们定义了一个常量displayMode来保存display函数所需的Display类型的值。我们将窗口命名为“Axes”,该窗口将由display函数打开。我们请求窗口宽度为 1000 像素,高度为 700 像素,并要求将窗口位置设置为距原点 10 像素上方和 10 像素右侧。

我们定义了一个名为axes的常量,用来保存我们想要制作的Picture。我们使用数据构造器Pictures来生成这幅图像,它提供了一种将图像列表组合成单一图像的方法。我们可以在 GHCi 中查看一些未明确指定类型的内容的类型。

Prelude Graphics.Gloss> :t Line [(0,0),(100,0)]
Line [(0,0),(100,0)] :: Picture
Prelude Graphics.Gloss> :t Color green $ Line [(0,0),(0,100)]
Color green $ Line [(0,0),(0,100)] :: Picture

这里的main函数使用display函数来生成图像。我们将displayMode、背景色black和图像axes传递给display

当我们使用第十二章中描述的三种方法之一编译并运行上述程序时,我们应该能看到一条红色的水平线和一条绿色的垂直线。默认的gloss坐标系中,x 轴朝右,y 轴朝上。根据你的操作系统,可能需要按两次 CTRL-C 才能关闭图形窗口。

gloss包没有提供原生的圆盘或填充圆形图像。作为display函数的第二个示例,让我们制作一幅蓝色圆圈和红色圆盘并排的图像。

{-# OPTIONS -Wall #-}

import Graphics.Gloss

displayMode :: Display
displayMode = InWindow "My Window" (1000, 700) (10, 10)

blueCircle :: Picture
blueCircle = Color blue (Circle 100)

disk :: Float -> Picture
disk radius = ThickCircle (radius / 2) radius

redDisk :: Picture
redDisk = Color red (disk 100)

wholePicture :: Picture
wholePicture = Pictures [Translate (-120) 0 blueCircle
                        ,Translate   120  0 redDisk
                        ]

main :: IO ()
main = display displayMode black wholePicture

在这里,我们使用了与之前相同的警告、导入和displayMode行。常量blueCircle是一个半径为 100 像素的蓝色圆圈。

由于gloss没有提供生成圆盘的函数,我们将自己编写一个。我们的disk函数使用gloss内建的ThickCircle函数来生成圆盘。ThickCircle接受半径和厚度作为输入。在这里,我们选择将厚圆的半径设置为圆盘所需半径的一半,并将厚度设置为圆盘的完整半径。这个圆圈非常厚,中心没有留下空洞,从而形成了一个圆盘。

常量redDisk是一个半径为 100 像素的红色圆盘。常量wholePicture使用Picture类型的Translate数据构造器将圆圈向左移动,圆盘向右移动。main函数与上一个程序非常相似,只不过现在我们显示的是wholePicture

当我们运行程序时,我们应该能看到一个蓝色圆圈位于一个大小相同的红色圆盘的左侧。

创建二维动画

给定一个随时间变化的图像,animate函数可以生成动画。让我们在 GHCi 中查看animate的类型。

Prelude> :m Graphics.Gloss
Prelude Graphics.Gloss> :t animate
animate :: Display -> Color -> (Float -> Picture) -> IO ()

display相比,类型的区别在于display中的Pictureanimate中的Float -> Picture所替代。animate函数使用Float来描述时间,因此Float -> Picture类型的表达式是从时间到图像的函数,或者说是时间函数的图像。

这是如何使用animate的示例:

{-# OPTIONS -Wall #-}

import Graphics.Gloss

displayMode :: Display
displayMode = InWindow "My Window" (1000, 700) (10, 10)

disk :: Float -> Picture
disk radius = ThickCircle (radius / 2) radius

redDisk :: Picture
redDisk = Color red (disk 25)

projectileMotion :: Float -> Picture
projectileMotion t = Translate (xDisk t) (yDisk t) redDisk

xDisk :: Float -> Float
xDisk t = 40 * t

yDisk :: Float -> Float
yDisk t = 80 * t - 4.9 * t**2

main :: IO ()
main = animate displayMode black projectileMotion

projectileMotion 函数接受一个 Float 类型的时间作为输入,并通过将红色圆盘水平移动 xDisk t,垂直移动 yDisk t 来生成一个 PicturexDiskyDisk 函数明确地给出了时间的函数。

当我们编译并运行这段代码时,我们会看到一个经历抛物线运动的红色圆盘。一个米被表示为一个像素,现实世界中的一秒钟在动画中也等于一秒钟。抛物线的初始 x 分量速度为 40 米/秒,初始 y 分量速度为 80 米/秒。

创建一个二维模拟

gloss 包的 simulate 函数允许用户在没有明确描述为时间函数的画面函数时,创建动画。让我们询问 GHCi simulate 的类型。

Prelude> :m Graphics.Gloss
Prelude Graphics.Gloss> :t simulate
simulate
  :: Display
     -> Color
     -> Int
     -> model
     -> (model -> Picture)
     -> (Graphics.Gloss.Data.ViewPort.ViewPort
         -> Float -> model -> model)
     -> IO ()

simulate 函数需要六个信息项。前两个,显示模式(类型为 Display)和背景颜色(类型为 Color),与 displayanimate 中相同。第三个信息项是速率(类型为 Int),表示模拟运行的更新频率(每秒更新次数)。第四个信息项是一个类型变量 model,而不是具体类型。我们可以通过 model 以小写字母开头来判断它是类型变量;它不能是常量或函数,因为它位于需要类型的类型签名中。我们,simulate 函数的使用者,决定为 model 选择什么类型。我们需要一个类型,可以保存我们正在模拟的系统的状态,也就是在任意时刻(1)生成一个画面所需的信息,以及(2)随着时间的推移,确定接下来会发生什么的必要信息。这种状态的概念将在本书的第二部分和第三部分的物理学描述中发挥重要作用。simulate 函数所需的 model 类型的值是要显示的情况的初始状态。

第五个信息项(类型为 model -> Picture)是一个函数,它描述了给定 model 类型的值时,应该生成什么样的画面。simulate 所需的第六个信息项是一个函数(类型为 Viewport -> Float -> model -> model),它描述了系统的状态如何随时间推进。这里的 Float 表示时间步长,而我们不会使用 Viewport

示例 13-1 给出了一个完整的程序,展示了如何使用 simulate 函数。

{-# OPTIONS -Wall #-}

import Graphics.Gloss

displayMode :: Display
displayMode = InWindow "My Window" (1000, 700) (10, 10)

-- updates per second of real time
rate :: Int
rate = 2

disk :: Float -> Picture
disk radius = ThickCircle (radius / 2) radius

redDisk :: Picture
redDisk = Color red (disk 25)

type State = (Float,Float)

initialState :: State
initialState = (0,0)

displayFunc :: State -> Picture
displayFunc (x,y) = Translate x y redDisk

updateFunc :: Float -> State -> State
updateFunc dt (x,y) = (x + 10 * dt, y - 5 * dt)

main :: IO ()
main = simulate displayMode black rate initialState displayFunc
       (\_ -> updateFunc)

示例 13-1:使用 gloss 包中的 simulate 函数的示例

显示模式和背景颜色与之前相同。我们定义了一个常量 rate 来保存模拟的速率。对于类型变量 model,我们选择了 (Float,Float) 并给它定义了类型别名 State。这个状态用来表示红色圆盘当前位置的 (x, y) 坐标。状态的初始值在 initialState 中定义。

模拟的核心包含在两个函数displayFuncupdateFunc中。第一个函数负责根据状态生成图像。在这个例子中,我们使用状态中的(x, y)坐标来将红色圆盘沿x轴平移并沿y轴上移。显示函数只关心当前的状态(xy的当前值)。它与图像如何随时间变化无关。

更新函数updateFunc解释了状态如何随时间变化。我们需要给出一个规则,说明新状态如何通过旧状态和时间步长dt计算得出。在这个例子中,我们将 x 值增加 10 像素/秒,并将 y 值减少 5 像素/秒。

当我们运行程序时,应该看到红色圆盘随着模拟的推进向右和向下移动。由于我们选择了每秒 2 次更新的速率,模拟会显得有些生硬,所以你会看到每次更新是一个离散的运动。试着增加更新速率来获得更流畅的动画。高清电视使用每秒 24 到 60 帧,所以你不需要超过这个范围。如果你所在建筑的灯光变暗了,那说明你选择的帧率太高。

让我们再看一个simulate函数的例子,看看我们之前用animate实现的抛体运动在使用simulate时会是什么样子。匀速运动和抛体运动的区别在于,抛体运动中的速度是会变化的。为了允许速度变化,我们需要扩展状态中的信息,同时包含红色圆盘的位置和速度。为此,我们在 Listing 13-2 的代码中定义了类型同义词PositionVelocityState

现在我们的initialState需要同时包含初始位置(0,0)和初始速度(40,80)。初始的 x 分量速度是 40 米/秒,初始的 y 分量速度是 80 米/秒。

我们的显示函数在意义上不需要改变,和之前的模拟一样,红色圆盘的显示仍然只依赖于圆盘的位置,而与当前速度无关。然而,由于状态的类型发生了变化,displayFunc函数需要做一些语法上的修正。语法上的修正是将参数(x, y)替换为((x, y), _),以反映新的状态类型。如果我们完全不修改函数,编译器会认为参数(x, y)表示位置的 x 值和速度的 y 值。这样会产生类型错误,提示 x 的预期类型是Float,而实际类型是PositionPosition的“实际类型”来自于displayFunc的类型签名,而Float的“预期类型”则来自于 x 作为Translate函数的参数在displayFunc中的使用。

让我们实现这些变化,结果请参见 Listing 13-2。

{-# OPTIONS -Wall #-}

import Graphics.Gloss

displayMode :: Display
displayMode = InWindow "My Window" (1000, 700) (10, 10)

-- updates per second of real time
rate :: Int rate = 24

disk :: Float -> Picture
disk radius = ThickCircle (radius / 2) radius

redDisk :: Picture
redDisk = Color red (disk 25)

type Position = (Float,Float)
type Velocity = (Float,Float)
type State = (Position,Velocity)

initialState :: State
initialState = ((0,0),(40,80))

displayFunc :: State -> Picture
displayFunc ((x,y),_) = Translate x y redDisk

updateFunc :: Float -> State -> State
updateFunc dt ((x,y),(vx,vy))
   = (( x + vx * dt, y +  vy * dt)
     ,(vx         ,vy - 9.8 * dt))

main :: IO ()
main = simulate displayMode black rate initialState displayFunc
       (\_ -> updateFunc)

Listing 13-2:使用simulate函数生成抛体运动的示例

更新函数是所有操作发生的地方。位置的 x 和 y 分量根据当前的速度更新。速度的 x 和 y 分量根据加速度的分量更新。加速度的 x 分量为 0,所以速度的 x 分量保持不变。加速度的 y 分量为–9.8 米/秒²,因此我们使用它更新速度的 y 分量,假设 1 米等于我们模拟中的 1 像素。

当我们运行这个程序时,结果应该与我们用animate编写的抛体程序相同。

注意使用animatesimulate时,在生成抛体运动动画所需信息方面的差异。使用animate时,我们需要有明确的关于位置随时间变化的表达式。使用simulate时,我们提供了等效的信息,但看起来我们提供的更少。状态更新过程是数值求解运动方程的强大工具。在本书的第二部分和第三部分中,我们将更深入地利用这个工具。

3D 动画

not-gloss包提供了四个主要函数,它们的名称与gloss中的相同:displayanimatesimulateplay。与gloss中一样,第一个用于静止图像,第二个和第三个用于随时间变化的图像,第四个用于随时间和用户输入变化的图像。我们主要关心前三个函数。这些函数的类型与gloss函数的类型不同,部分原因是not-gloss包的作者不同于gloss包的作者。两个包之间有相似之处,但也有我们将指出的差异。

显示 3D 图像

让我们检查一下display的类型。正如gloss包中必须导入名为Graphics.Gloss的模块才能使用其函数一样,not-gloss包也有一个名为Vis的模块,我们必须导入它才能使用。

Prelude> :m Vis
Prelude Vis> :t display
display :: Real b => Options -> VisObject b -> IO ()

如果我们查询Real类型类,我们会发现Real适用于可以转换为有理数的数值类型:

Prelude Vis> :i Real
class (Num a, Ord a) => Real a where
  toRational :: a -> Rational
  {-# MINIMAL toRational #-}
   -- Defined in 'GHC.Real'
instance Real Word -- Defined in 'GHC.Real'
instance Real Integer -- Defined in 'GHC.Real'
instance Real Int -- Defined in 'GHC.Real'
instance Real Float -- Defined in 'GHC.Float'
instance Real Double -- Defined in 'GHC.Float'

我们最喜欢的Real类型类实例是R(或Double)。除非有特别的理由选择其他类型,否则我们将默认选择它。如果display类型中的类型变量bR,则display的类型如下:

display :: Options -> VisObject R -> IO ()

显示函数要求我们提供两件事:一个类型为Options的对象,以及要显示的对象(类型VisObject R)。返回类型IO ()意味着计算机将执行某些操作(在这种情况下是显示对象)。

有哪些类型是VisObject Rnot-gloss包提供了一个长长的列表,包括球体、立方体、线条、文本等等。你可以通过访问hackage.haskell.org并搜索not-gloss来查看文档。

下面是一个生成蓝色立方体的示例:

{-# OPTIONS -Wall #-}

import Vis

type R = Double

blueCube :: VisObject R
blueCube = Cube 1 Solid blue

main :: IO ()
main = display defaultOpts blueCube

常量defaultOptsVis模块提供,作为一组默认选项。你可以像以前一样将此代码编译成独立程序。当你运行程序时,一个包含蓝色立方体的显示窗口将打开。显示窗口打开后,按 e 键放大,按 q 键缩小。你还可以使用鼠标旋转立方体。这些是not-gloss的标准功能,我们无需编写代码来实现。

下一个程序将帮助我们熟悉Vis模块使用的默认坐标系统。我们将绘制一条从原点到点(1, 0, 0)的红色线段,一条从原点到点(0, 1, 0)的绿色线段,以及一条从原点到点(0, 0, 1)的蓝色线段。

{-# OPTIONS -Wall #-}

import Vis
import Linear

type R = Double

axes :: VisObject R
axes = VisObjects [Line Nothing [V3 0 0 0, V3 1 0 0] red
                  ,Line Nothing [V3 0 0 0, V3 0 1 0] green
                  ,Line Nothing [V3 0 0 0, V3 0 0 1] blue
                  ]

main :: IO ()
main = display defaultOpts axes

在这里我们导入Linear模块,以便使用V3构造函数。Linear模块定义了几种类型的向量;V3Vis模块使用的类型。Nothing表示使用默认的线宽(尝试将Nothing替换为(Just 5),以获得更粗的线宽)。

当我们编译并运行刚才展示的程序时,我们会看到三维坐标系的坐标轴。我们看到not-gloss的默认方向是 x 轴指向右方并朝向观察者,y 轴指向左方并朝向观察者,z 轴指向下方。

个人来说,我认为 z 轴正方向指向下方令人不安且无法接受。我喜欢认为自己是一个灵活的人,但这真的有些过分了。(not-gloss的作者 Greg Horn 告诉我,z 向下的约定在航空航天行业中是标准的。)幸运的是,not-gloss提供了让我们按自己的方式旋转物体的工具。我喜欢将 x 轴主要指向页面外,y 轴指向右侧,z 轴指向上方。以下是一个实现这一点的程序:

{-# OPTIONS -Wall #-}

import Vis
import Linear
import SpatialMath

type R = Double

axes :: VisObject R
axes = VisObjects [Line Nothing [V3 0 0 0, V3 1 0 0] red
                  ,Line Nothing [V3 0 0 0, V3 0 1 0] green
                  ,Line Nothing [V3 0 0 0, V3 0 0 1] blue
                  ]

orient :: VisObject R -> VisObject R
orient pict = RotEulerDeg (Euler 270 180 0) $ pict

main :: IO ()
main = display defaultOpts (orient axes)

我们像之前一样导入VisLinear,但在这里我们还导入了SpatialMath,这样我们就可以使用Euler来进行三维旋转,使用欧拉角。axes图像没有变化。我们定义了一个orient函数,该函数接受一个图片作为输入,并返回一个重新定向后的图片作为输出。为此,我们使用VisObject类型的RotEulerDeg数据构造函数,执行由欧拉角指定的旋转。在这种情况下,欧拉角意味着我们首先绕 x 轴旋转 0^∘,然后绕 y 轴旋转 180^∘,最后绕 z 轴旋转 270^∘。等效地,我们可以将其视为首先绕 z 轴旋转 270^∘,然后绕旋转后的 y 轴旋转 180^∘,最后绕旋转后的 x 轴旋转 0^∘。

最后,我们将orient axes传递给display,作为要显示的图片。如果你喜欢这种方向系统,你可以在显示之前将任何图片传递给orient函数,作为使用该坐标系统的一种方式。你甚至可以定义自己的显示函数,来为你进行重新定向。

myDisplay :: VisObject R -> IO ()
myDisplay pict = display defaultOpts (orient pict)

创建 3D 动画

让我们看一下animate的类型。

Prelude> :m Vis
Prelude Vis> :t animate
animate :: Real b => Options -> (Float -> VisObject b) -> IO ()

animate 的类型与 display 的类型相同,区别在于 display 中的 VisObject banimate 中的 Float -> VisObject b 所替代。animate 不要求我们提供一张图片,而是要求我们提供一个从时间到图片的函数。animate 函数要求我们使用 Float 来表示时间的实数值。

以下是一个旋转蓝色立方体的动画,立方体绕着 x 轴逆时针旋转,我最喜欢的坐标系为(x 轴指向屏幕外,y 轴指向右,z 轴指向屏幕上方):

{-# OPTIONS -Wall #-}

import Vis
import SpatialMath

rotatingCube :: Float -> VisObject Float
rotatingCube t = RotEulerRad (Euler 0 0 t) (Cube 1 Solid blue)

orient :: VisObject Float -> VisObject Float
orient pict = RotEulerDeg (Euler 270 180 0) $ pict

main :: IO ()
main = animate defaultOpts (orient . rotatingCube)

注意 rotatingCubeorient 之间的函数组合。rotatingCube 函数接受一个数字作为输入,输出一张图片。orient 函数接受一张图片作为输入,输出一张(重新定向的)图片。组合后的函数 orient . rotatingCube 接受一个数字作为输入,输出一张图片,这正是 animate 所需要的函数类型。

制作 3D 仿真

not-gloss 函数 simulate 允许用户在没有明确描述图片随时间变化的函数时,制作动画。我们来询问 GHCi simulate 的类型。

Prelude> :m Vis
Prelude Vis> :t simulate
simulate
  :: Real b =>
     Options
     -> Double
     -> world
     -> (world -> VisObject b)
     -> (Float -> world -> world)
     -> IO ()

simulate 函数需要五个信息。第一个信息(类型 Options)与 displayanimate 中的一样。第二个信息(一个 Double)是时间步长,单位是每次更新之间的秒数,表示动画显示中连续帧之间的时间间隔。请注意与 gloss 库的不同:gloss 要求以每秒更新次数为速率,而 not-gloss 要求以每次更新的秒数为时间步长。

第三个信息是要显示的情况的初始状态。类型变量 world 代表一个由用户选择的类型,用来描述情况的状态,类似于 gloss 函数 simulate 中使用的类型变量 model

第四个信息(类型 world -> VisObject b)是一个显示函数,它描述了给定类型 world 值时应生成什么图片。这个显示函数与 gloss 的显示函数非常相似。

最后,simulate 需要的第五个信息是一个函数(类型 Float -> world -> world),它描述了系统状态如何随时间推进。这个类型中的 Float 代表自仿真开始以来经过的总时间。这与 gloss 不同,gloss 中类似项中的 Float 描述的是自上一帧以来的时间步长。

清单 13-3 演示了 simulate 函数如何使用我们提供的第五个参数——更新函数。代码的目的是通过实验确定 simulate 函数如何使用我们提供的 Float -> world -> world 类型的函数。如果你不习惯使用高阶函数,这可能会显得很奇怪。通常,我们编写函数供自己使用,或者使用别人写的函数。但当别人为我们编写一个高阶函数并且这个高阶函数接受一个用户定义的函数作为输入时,我们可能会想知道高阶函数打算如何使用我们提供的用户定义函数。(我们可以阅读高阶函数的代码或文档,但这里我们将通过实验来弄清楚。)

奇怪的是,我们在 清单 13-3 中编写了一个函数 updateFunc,但我们并不直接使用这个函数。我们并不决定传递给 updateFuncFloat 值;是另一个函数 simulate 来决定的。

{-# OPTIONS -Wall #-}

import Vis

type State = (Int,[Float])

-- seconds / update
dt :: Double dt = 0.5

displayFunc :: State -> VisObject Double
displayFunc (n,ts) = Text2d (show n ++ " " ++ show (take 4 ts))
                     (100,100) Fixed9By15 orange

updateFunc :: Float -> State -> State
updateFunc t (n,ts) = (n+1,t:ts)

main :: IO ()
main = simulate defaultOpts dt (0,[]) displayFunc updateFunc

清单 13-3:使用 not-gloss 库中的 simulate 函数。本代码的目的是通过实验确定 simulate 如何使用 updateFunc

我们通过导入 Vis 模块来开始代码。对于类型变量 world,我们选择了一个对 (Int,[Float]) 的元组,其中 Int 用于表示自仿真开始以来所执行的更新次数,而浮点数列表则表示传递给更新函数 updateFunc 的时间值。我们并不选择这些时间值;是 simulate 来决定的。

我们设置了一个时间步长 dt,它是半秒钟。displayFunc 定义了如何从 State 生成图像。它使用了 VisObject 类型中的 Text2d 数据构造器,你可以查看 not-glosssimulate 函数的文档来了解更多信息。

更新函数 updateFunc 跟踪两件事:它被调用的次数和它所使用的 Float 值。每次调用 updateFunc 时,它会将调用次数加一,并将最新的 Float 值添加到列表的前面。

当我们运行这个程序时,可以看到更新次数以每秒两次的速度增加,并且看到传递进去的时间值在不断增大,从而确认了更新函数以仿真开始以来的时间作为输入的说法。

总结

在本章中,我们探讨了几种生成二维和三维图形及动画的方法。我们提供了展示每种图形功能的代码,这些功能将在本书后续章节中帮助我们在讨论和书写物理学内容时提供可视化支持。

本章结束后,我们已经完成了书的第一部分—这是对函数式编程思想的一般介绍,特别是对 Haskell 编程语言的介绍。在第二部分中,我们将探索牛顿力学,目标是预测受力的一个或多个物体的运动。牛顿力学的核心原理是牛顿第二定律,这是下一章的主题。

习题

习题 13.1. 查阅gloss文档中的Picture类型,使用display函数创建一个有趣的图形。结合线条、圆形、文字、颜色和你喜欢的任何元素。发挥创意。

习题 13.2. 使用animate制作一个简单的动画。发挥创意。

习题 13.3. 使用animate使红色圆盘左右摆动。然后,稍微修改代码,使红色圆盘沿圆形轨道运动。你能让红色圆盘沿椭圆轨道移动吗?

习题 13.4. 使用animate制作与我们在示例 13-1 中通过simulate实现的红色圆盘相同的运动。

习题 13.5. 使用simulate做一些你认为有趣的事情。发挥创意。

习题 13.6. 在示例 13-2 中的二维抛体运动示例中,现实世界中的一米由动画中的一个像素表示。修改代码,使一米由 10 个像素表示。可以自由更改初始速度分量,以便抛体不会立即飞出屏幕。

习题 13.7. 挑战性习题:尝试使用simulate使红色圆盘左右摆动,而不显式给它提供像 sin 或 cos 这样的振荡函数。我们将在书的第二部分展示如何实现这一点。

习题 13.8. 重写三维坐标轴代码,使 x 轴指向右,y 轴指向上,z 轴指向页面外。这是我第二喜欢的坐标系。

习题 13.9. 修改旋转立方体的动画,使旋转围绕 x 轴顺时针进行,而不是逆时针。

习题 13.10. 编写一个实验程序,类似于示例 13-3,使用gloss函数simulate来理解glosssimulate如何使用更新函数。使用与示例 13-3 中相同的updateFuncState表达式。你需要更改displayFuncmain的值。使用rate为 2,而不是dt为 0.5。当你运行这个时,你应该会看到glosssimulate传入的时间步长都接近 0.5。

第二部分

表达牛顿力学并解决问题

第十四章:牛顿第二定律与微分方程

图片

艾萨克·牛顿成就斐然。他为我们留下了众多物理和数学的深刻见解,其中有三条以他名字命名的定律。牛顿的第二定律是这三条定律中最重要的一条;它提供了一种理解物体运动的方法,只要我们知道作用在物体上的力。牛顿的第三定律几乎同样重要;它是关于两个物体如何相互作用的规则。从数学角度来看,牛顿的第一定律是牛顿第二定律的一个推论,因此它看起来是三条定律中最简单的一条。但由于牛顿的第二定律足够颠覆直觉,因此在试图理解它之前,先理解一些更简单的内容是很有帮助的。牛顿的第一定律在这方面非常有效;它提出了一个看似明显错误的大胆主张。

在本章中,我们将讨论牛顿的第一定律,然后将注意力转向牛顿的第二定律,集中于一个线性维度,例如水平线或垂直线。我们将展示如何在逐渐增加复杂性的情境中思考牛顿的第二定律,按力的依赖性进行组织。我们将从常力开始,最简单的情形,然后转向仅依赖于时间的力。接着,我们将讨论依赖于粒子速度的力,然后是同时依赖于时间和速度的力。解决牛顿第二定律的方法会随着所涉及力依赖的物理量不同而变化。我们将介绍求解微分方程的欧拉方法,并探讨在牛顿第二定律是核心原理的多种情境中,这一原理帮助我们理解物体运动。

牛顿的第一定律

让我们回到第四章的气轨道。如果你在气轨道上轻推小车,然后松手,它会以恒定的速度行驶,直到碰到轨道的尽头。在我们停止推车后,它会继续以某种速度运动,即使在运动方向上没有施加任何力。这种物体持续运动的倾向被称为惯性。惯性的概念在气轨道这一一维空间中是相关的,也同样适用于我们生活的无约束三维空间。这个概念足够重要,以至于它被确立为物理学中的一个原则——牛顿的第一定律。以下是三种版本:

牛顿的第一定律,牛顿的原话 [15]

每个物体都保持其静止状态或匀速直线前进状态,除非受到外力的作用迫使其改变状态。

牛顿的第一定律,诗意版

一个运动中的物体保持运动,一个静止的物体保持静止。

牛顿的第一定律,现代版本

在没有外力作用的情况下,物体保持相同的速度。

回想一下,速度是一个向量,所以保持相同的速度意味着保持相同的速度和相同的方向。由于加速度是单位时间内速度的变化,牛顿第一定律的一个等效表述是:在没有外力作用的情况下,物体不会经历加速度。

注意到牛顿的第一定律并未提到过去施加的力。关键在于,如果现在没有施加任何力,物体的速度将保持不变。任何时候如果没有力作用,速度都会保持不变。

为什么牛顿的第一定律看起来明显是错误的?因为我们被困在地球表面,这里充满了许多我们可能未曾考虑的力,其中摩擦力和空气阻力尤为重要。在太空中,事情则简单一些。我们可以想象一名宇航员将一个小扳手慢速抛给另一名宇航员。扳手就这样沿着飞船滑行,或许围绕它的中心慢慢旋转。这个扳手就是牛顿第一定律的一个很好的例子。

也许你曾经在车里,当司机猛踩刹车时,书籍、纸张和玩具会飞向前方(相对于车座)。在我们家,我们会通过大喊“牛顿第一定律!”来庆祝这些时刻。从外部(减速中的)车的视角来看,书籍、纸张和玩具正尽力沿直线运动,至少在重力和其他物体阻止它们的直线运动之前,短暂的一段时间内是如此。

牛顿的第一定律告诉我们,物体自然会保持平稳并沿直线运动。然而,实际上它们并不会这样做。牛顿第二定律解释了如何以及为什么。

牛顿第二定律在一维中的应用

牛顿的第一定律告诉我们,当没有外力作用时,物体不会加速。牛顿第二定律则宣称加速度是由外力引起的。

牛顿第二定律,牛顿的原话 [15]

运动的变化与施加的动力成正比,并沿着施加力的直线方向发生。

牛顿第二定律,诗意版本

物体的加速度与作用在物体上的净外力成正比,与物体的质量成反比。

牛顿第二定律的现代版本通过方程式 14.1 来表示一维的牛顿第二定律,方程式 16.1 来表示三维的牛顿第二定律。在本章的其余部分,我们将讨论牛顿的第二定律在一维中的应用,这样我们可以通过使用数字而非向量来简化速度、加速度和力的表示。在第十六章中,我们将全面讨论牛顿的第二定律,包括向量形式。

为了以定量的方式讨论力和质量,我们需要度量单位。在国际单位制(SI)中,力的单位是牛顿(N)。100N 的力对高尔夫球的作用与对保龄球的作用不同。根据牛顿的理论,每个物体都有一个质量,它决定了物体对力的加速度反应能力。相比于暴露于相同力的质量较小的物体,质量较大的物体会经历较小的加速度。质量的国际单位是千克(kg)。

牛顿第二定律表达了以下三者之间的关系:

  • 作用在物体上的力

  • 物体的质量

  • 物体的加速度

牛顿第二定律指出,物体的加速度可以通过将作用在物体上的净力除以物体的质量来求得。作用在物体上的净力是作用在物体上的所有力的合力。在一维空间中,某些力可能是负的,某些力可能是正的。

牛顿第二定律通常写作F[net] = ma。与速度和加速度的一维方程(方程 4.5 和 4.12)不同,这个方程并不是函数的相等式。物体的加速度只是时间的函数,但净力通常依赖于时间、物体的位置和物体的速度。时刻 t 的净力是F[net] (t, x(t), v(t))。更好的方式来写牛顿第二定律是:

Image

牛顿第二定律存在一个“先有鸡还是先有蛋”的问题。我们从方程 4.5 和 4.12 中知道,v = Dxa = Dv。如果我们知道加速度函数* a (意味着我们知道它在所有时刻的值),我们可以在已知初始速度的情况下求得速度函数。(参见方程 6.1 以及对应的函数 velFromAcc。)然后我们可以继续求得位置函数 x *(参见方程 6.5 以及对应的函数 posFromVel)。但是,牛顿第二定律告诉我们,加速度依赖于力,而力又依赖于位置和速度。为了找到物体的位置,似乎需要先找到速度,而要找到速度,又需要加速度。然而,加速度又依赖于位置和速度。

这种“先有鸡还是先有蛋”的问题有一个专门的名字。牛顿第二定律是一个微分方程的例子。微分方程是未知函数的导数之间的关系,通常未知函数本身被视为零阶导数。在牛顿第二定律的情况下,未知函数通常是位置 x 或速度 v。速度可以写作位置的第一导数(v = Dx),加速度可以写作位置的第二导数(a = Dv = D²x)。

如果我们将牛顿第二定律写成未知位置函数的形式,它看起来更像一个微分方程。

Image

这是一个二阶微分方程,因为它是位置函数x、其一阶导数Dx和二阶导数D²x之间的关系。特定物体的关系取决于合力F[net],而合力又依赖于作用在物体上的力的性质。

在简单的情况下,物体上的合力可能不依赖于时间、位置和速度,而只依赖于这些物理量中的零个、一个或两个。在这些简单的情况下,牛顿第二定律可能表现为比二阶微分方程更简单的形式。表 14-1 按力所依赖的物理量列出了各种情况,并给出了求解牛顿第二定律所需的数学技巧。

表 14-1: 基于力依赖的物理量的牛顿第二定律求解技巧

力只依赖于 解决技巧
代数
时间 积分
速度 一阶微分方程
时间和速度 一阶微分方程
时间、位置和速度 二阶微分方程

一个不依赖任何因素的合力是一个恒定的合力。它的大小在时间、位置或速度的变化下保持不变。在接下来的几节中,我们将讨论恒定力、只依赖于时间的力、只依赖于速度的力以及依赖于时间和速度的力。这种限制使我们能够在本章中将注意力集中在一阶微分方程上。在第十五章中,我们将研究更一般的情况,即合力可以依赖于时间、位置、以及速度的一维运动。

恒定力下的第二定律

牛顿第二定律的最简单情况是当合力是恒定的,与时间、位置和速度无关。大多数入门物理课程中的问题都属于这种情况,因为它们可以在没有微分方程和计算机的情况下解决。

我们来考虑一个常力的例子问题。

例 14.1 假设我们有一辆质量为 0.1 千克的车,在气轨上。车初速度为 0.6 米/秒,向东运动。从时间t = 0 开始,我们对这辆车施加一个向东的恒定力 0.04 N。与此同时,我们的朋友对同一辆车施加一个向西的恒定力 0.08 N。那么,这辆车接下来的运动将是什么样的?特别是,车的速度和位置将如何随时间变化?

图 14-1 展示了示意图。

图像

图 14-1:带有恒定力的牛顿第二定律示意图

恒定的净力 Image(上标 c 表示常数)作用于物体,必须除以物体的质量,才能得到物体的加速度。由于净力是常数,加速度也为常数。

Image

我们写 a(t) 而不是 a 来表示加速度,并不是因为加速度随时间变化,而是因为 a 是加速度函数(类型为 R -> R),而 a(t) 是加速度(类型为 R)。我们随后对加速度进行积分,以得到速度。

Image

产生速度的积分器包含一个实数(类型为 R)作为状态。这个类型在 图 14-1 中的积分器下方显示。这个积分器记住当前的速度,以便可以使用加速度来更新它。

然后我们对速度进行积分,以获得位置。

Image

图中的电线表示随时间持续变化的量。图中的每根电线都有一个名称和类型。在这个图中,所有的电线类型都是实数。

矩形框表示纯粹的函数常量和函数。换句话说,它们是没有任何状态的常量和函数,因此输出仅取决于输入。圆形积分器包含必须与输入结合以产生输出的状态。积分器标注有其包含的状态类型,这与积分器的输出类型相同。

在我们编写 Haskell 代码以解决牛顿第二定律在恒定力作用下的问题之前,我们将编写几行代码,这些代码需要放在我们在本章中构建的源代码文件的顶部。第一行打开警告,我建议开启,因为编译器会警告你一些合法但足够不常见的情况,可能不是你想要的。第二行为本章中的代码指定模块名为 Newton2。如果我们想在后续章节中使用这里编写的函数,我们将通过模块名引用当前代码。模块名是可选的,但如果使用,必须与文件名匹配;在这种情况下,文件名应为 Newton2.hs。第三行加载 gnuplot 图形库,以便我们能够绘制图形。像这样的导入必须在任何函数定义或类型签名之前发生。

{-# OPTIONS -Wall #-}

module Newton2 where

import Graphics.Gnuplot.Simple

示例 14.1 是牛顿第二定律应用的典型情况。给定一个质量、初始速度和一些力,我们需要求出速度随时间的变化。在 Haskell 语言中,解决此问题的方案将是一个(高阶)函数 velocityCFCF 代表恒定力),其类型如下:

velocityCF :: Mass
           -> Velocity          -- initial velocity
           -> [Force]           -- list of forces
           -> Time -> Velocity  -- velocity function

回想一下,解读这个类型签名有(至少)两种方式。一种解读是,velocityCF接受四个输入——质量、初始速度、一个力的列表和时间——并输出一个表示速度的实数。另一种解读是,velocityCF接受三个输入——质量、初始速度和一个力的列表——并输出一个关于速度如何随时间变化的函数。如果我们想强调后者的观点,我们可以这样写:

velocityCF :: Mass -> Velocity -> [Force] -> (Time -> Velocity)

但它与原始类型签名的含义是一样的。

我们使用了类型TimeMassVelocityForce。这些在 Haskell 中不是内建类型,因此我们最好定义它们的含义。在一维力学中,所有这些量都可以用实数表示,因此我们可以写一些类型别名来定义这些类型。使用一个类型别名,其中R代表Double

type R = Double

我们可以为所有其他类型写类型别名:

type Mass     = R
type Time     = R
type Position = R
type Velocity = R
type Force    = R

类型MassTime等的定义不必出现在它们在类型签名中的使用之前。Haskell 允许在使用前或使用后定义常量、函数和类型。

如果我们能写出一个类型签名如上的函数velocityCF,那么我们不仅解决了例子 14.1 的问题,还解决了所有类似的问题。我们写这样一个函数的策略是:

  • 通过加总所有的力来求得合力

  • 使用牛顿第二定律(方程 14.3)求加速度

  • 通过加速度求得速度(方程 4.14 或 14.4)

下面是velocityCF的定义,它表达了这三步操作,并具有我们之前声明的类型。

velocityCF m v0 fs
    = let fNet = sum fs       -- net force
          a0   = fNet / m     -- Newton's second law
          v t  = v0 + a0 * t  -- constant acceleration eqn
      in v

为了编写velocityCF函数,我们首先命名三个输入:质量m、初始速度v0和力的列表fs。然后,我们使用let结构定义三个局部名称,分别代表合力、加速度和速度。为了求合力,我们使用内建的sum函数将列表中的所有力相加。为了求加速度,我们将物体的合力除以物体的质量,正如牛顿第二定律所规定的那样。

let结构中的第三个方程定义了一个局部函数v来表示速度函数。我们使用方程 4.14,这是在标准入门物理教材中引入的常数加速度方程,但我们完全可以用方程 14.4 代替let结构的第二行和第三行。请注意,我们已经使用了之前提到的“三输入思维”来写velocityCF的定义。练习 14.1 要求你使用四输入思维重写这个函数。

我们可以编写一个函数positionCF,给定质量、初始位置、初始速度和常力列表,生成一个位置函数。

positionCF :: Mass
           -> Position          -- initial position
           -> Velocity          -- initial velocity
           -> [Force]           -- list of forces
           -> Time -> Position  -- position function
positionCF m x0 v0 fs
    = let fNet = sum fs
          a0   = fNet / m
          x t  = x0 + v0 * t + a0*t**2 / 2
      in x

这里,我们使用了方程 4.15 或 14.5。回到例子 14.1,汽车的速度作为时间的函数是

velocityCF 0.1 0.6 [0.04, -0.08]

因为 0.1 kg 是汽车的质量,0.6 m/s 是其初速度,方括号中的列表包含了以牛顿为单位的力。我们可以在 GHCi 中查询这个函数的类型,也可以查询特定时间点的速度值。

Prelude>  :l Newton2
[1 of 1] Compiling Newton2         ( Newton2.hs, interpreted )
Ok, one module loaded.
*Newton2>  :t velocityCF 0.1 0.6 [0.04, -0.08]
velocityCF 0.1 0.6 [0.04, -0.08] :: Time -> Velocity
*Newton2> velocityCF 0.1 0.6 [0.04, -0.08] 0
0.6
*Newton2> velocityCF 0.1 0.6 [0.04, -0.08] 1
0.2

由于我们已经有了速度函数,我们可以绘制它的图形。首先让我们写出绘制图形的代码。下面的大部分代码用于设置标题、坐标轴标签和我们希望生成的文件名。最有趣的部分在最后,我们给出了在特定时间点评估函数的时间列表,以及函数本身。

carGraph :: IO ()
carGraph
    = plotFunc [Title "Car on an air track"
               ,XLabel "Time (s)"
               ,YLabel "Velocity of Car (m/s)"
               ,PNG "CarVelocity.png"
               ,Key Nothing
               ] [0..4 :: Time] (velocityCF 0.1 0.6 [0.04, -0.08])

这段代码生成了图 14-2 中的图形。

Image

图 14-2:示例 14.1 中汽车速度随时间的变化

如果你在 GHCi 中加载本章的模块Newton2并输入carGraph

*Newton2> carGraph

你不会得到任何返回值,但该函数会在你的硬盘上生成一个名为CarVelocity.png的可移植网络图形(PNG)文件。如果没有PNG "CarVelocity.png"选项,carGraph函数会在屏幕上生成一个图形。

请注意,图表中图 14-2 的负加速度(存在于从t = 0 到t = 4 秒的整个时间间隔内)并不意味着汽车一直在减速。相反,负加速度意味着加速度指向西方。汽车在前 1.5 秒内向东行驶时减速,但随后开始向西行驶时加速。当物体的加速度和速度方向一致时,物体加速;当物体的加速度和速度方向相反时,物体减速。

使用函数velocityCFpositionCF,我们有了处理任何牛顿第二定律问题的通用方法,适用于一维空间中具有恒定力的情况。接下来我们将考虑随时间变化的力。

仅依赖于时间的力的第二定律

牛顿第二定律的下一个情况是当净力仅依赖于时间,而不依赖于位置或速度时。图 14-3 显示了牛顿第二定律的示意图,力仅依赖于时间。

Image

图 14-3:牛顿第二定律示意图,力仅依赖于时间

常数 1 被输入到积分器中,生成时间值。(时间以每秒 1 秒的速率变化。)如往常一样,电线标有名称和类型。积分器标注了它们所持有的状态类型。时间输入到净力函数中Image(上标t表示时间相关),该函数输出净力。为了得到物体的加速度,我们需要将作用在物体上的净力除以物体的质量。

Image

我们然后对加速度进行积分,以得到速度,

Image

我们然后对速度进行积分,以得到位置:

Image

图中的电线表示随时间连续变化的量。矩形框表示纯函数,而圆形元素包含状态。

为了解决依赖于时间的力问题,我们希望得到一个更高阶的函数,产生一个速度函数,类似于上一节中的 velocityCF。一个区别是,我们现在需要提供一个力 函数 的列表,而不是一个数值力的列表。我们希望得到一个函数 velocityFtFt 后缀表示力仅依赖于时间),其类型签名如下:

velocityFt :: Mass -> Velocity -> [Time -> Force] -> Time -> Velocity

给定我们物体的质量、初始速度和一个力函数列表,我们希望得到一个速度函数。

因为我们将进行数值积分来得到速度函数,所以我们会在这个类型签名中增加一个额外的参数,即数值积分的时间步长。因此,我们得到了 velocityFt 的以下定义:

velocityFt :: R                 -- dt for integral
           -> Mass
           -> Velocity          -- initial velocity
           -> [Time -> Force]   -- list of force functions
           -> Time -> Velocity  -- velocity function
velocityFt dt m v0 fs
    = let fNet t = sum [f t | f <- fs]
          a t = fNet t / m
      in antiDerivative dt v0 a

在这个定义中,我们首先命名输入:dt 代表积分时间步长,m 代表我们关注的物体的质量,v0 代表该物体的初始速度,fs 代表一个力函数列表。请注意,fs 这个局部变量在处理常力情况时,作为 velocityCFpositionCF 中使用时,其类型为 [Force](或 [R]),但在处理依赖于时间的力时,其类型现在变为 [Time -> Force](或 [R -> R])。

我们再次使用 let 结构来定义局部函数,一个是净力函数,一个是加速度函数。净力函数将 fs 列表中的所有力加在一起。我们可能希望能够像在 velocityCF 中那样使用相同的一行代码,即 fNet = sum fs 来求和力。毕竟,fs 仍然是一个列表。问题是 sum 仅适用于 Num 的实例类型,如你查看 sum 的类型所见。因此,它很乐意对数字(类型 R)进行求和,但对函数(类型 R -> R)则不行。幸运的是,我们可以在 fNet 的参数中引入时间 t,对力函数进行评估,并将得到的数字加在一起。

加速度函数来源于牛顿第二定律。在这里,我们可能希望通过将净力函数除以质量来获得加速度函数,或许写成 a = fNet / m。但是回想一下,除法运算符要求两个值的类型相同,并且该类型是 Fractional 的实例。除法运算符不愿意与函数一起使用。我们通过对加速度函数 a 的时间参数 t 传入,来解决这个问题,再次评估 fNet 函数。

最后,速度来自对加速度函数的反导数。我们在第六章中定义了 antiDerivativeintegral 函数,但我们将在这里重复它们的定义:

antiDerivative :: R -> R -> (R -> R) -> (R -> R)
antiDerivative dt v0 a t = v0 + integral dt a 0 t

integral :: R -> (R -> R) -> R -> R -> R
integral dt f a b
    = sum [f t * dt | t <- [a+dt/2, a+3*dt/2 .. b - dt/2]]

注意,velocityFt dt m v0 fs 的类型是 R -> R,它是一个描述物体质量为 m、初速度为 v0,并且受力函数列表为 fs 的速度函数。这个速度函数是力学问题解的一部分。解的另一个部分是位置函数。我们可以写一个 positionFt 函数,通过给定质量、初始位置、初速度和力函数列表,生成位置函数。

positionFt :: R                 -- dt for integral
           -> Mass
           -> Position          -- initial position
           -> Velocity          -- initial velocity
           -> [Time -> Force]   -- list of force functions
           -> Time -> Position  -- position function
positionFt dt m x0 v0 fs
     = antiDerivative dt x0 (velocityFt dt m v0 fs)

这个函数通过对速度函数取反导数来工作,我们通过 velocityFt 找到这个速度函数。

作为求解牛顿第二定律的一个示例,考虑一个骑自行车的孩子。通过踩踏板,孩子使地面在 10 秒内对自行车施加一个 10 N 的恒定前向力,然后孩子滑行 10 秒。滑行结束后,孩子会恢复 10 N 的力,再持续 10 秒,如 图 14-4 所示。

Image

图 14-4:儿童骑车时力与时间的关系

在这个示例中,我们假设空气阻力不重要,并且自行车上只有一个力。

这是踩踏和滑行的时间相关力的方程:

Image

力的大小要么是 0 N,要么是 10 N,具体取决于时间在 20 秒周期中的位置。如果时间处于周期的前 10 秒,力是 10 N;如果时间处于周期的后 10 秒,力是 0 N。

下面是 Haskell 中方程 14.8 的时间相关力:

pedalCoast :: Time -> Force
pedalCoast t
    = let tCycle = 20
          nComplete :: Int
          nComplete = truncate (t / tCycle)
          remainder = t - fromIntegral nComplete * tCycle
      in if remainder < 10
         then 10
         else 0

局部变量 tCycle 是一个完整周期的秒数。变量 nComplete 使用 Prelude 函数 truncate 计算从时间 t 开始的完整周期数。truncate 函数会生成一个类型为 Integral 的类型类(回忆一下,IntegerInt 都是 Integral 的实例)。我们提供了一个局部类型签名,表示我们希望 nComplete 的类型为 Int。这个局部类型签名是可选的,但如果不指定类型,编译器会给我们一个默认类型的警告。去掉局部类型签名看看警告是什么样子的。这只是一个轻微的警告。如果编译器选择 Integer 而不是 Int,我们也不介意。你可以选择忽略这个警告,并在没有类型签名的情况下使用代码。

remainder 是自最近一个周期开始以来经过的秒数,范围在 0 到 20 之间。我们希望 remainder 是一个实数,因此必须使用 fromIntegralnComplete :: Int 转换为实数。

图 14-5 显示了儿童的位置与时间的关系。

Image

图 14-5:儿童骑车时位置与时间的关系

下面是生成 图 14-5 的 Haskell 代码:

childGraph :: IO ()
childGraph
    = plotFunc [Title "Child pedaling then coasting"
               ,XLabel "Time (s)"
               ,YLabel "Position of Bike (m)"
               ,PNG "ChildPosition.png"
               ,Key Nothing
               ] [0..40 :: R] (positionFt 0.1 20 0 0 [pedalCoast])

代码中最有趣的部分是最后一行,在这里我们指定了要绘制的函数。这个函数positionFt 0.1 20 0 0 [pedalCoast]使用了我们在本章早些时候开发的positionFt函数,时间步长为 0.1 秒,质量为 20 千克,初始位置和初始速度均为 0 秒,力的列表中仅包括踩踏和滑行的力量。所有相关的物理信息都包含在我们绘制的函数的“名称”中。

从图 14-5 中的图表可以看出,在前 10 秒内,孩子的位置曲线呈抛物线形状,这符合恒定加速度的预期。从 10 秒到 20 秒,位置曲线表现为恒定速度,这时孩子正在滑行。从 20 秒到 30 秒,再次出现加速阶段,位置曲线呈抛物线形状,随后是第二次滑行阶段。

通过velocityFtpositionFt这两个函数,我们可以在一维空间中求解任何牛顿第二定律类型问题,且这些问题的力仅依赖于时间。现在,我们准备研究依赖于速度的力,其中最常见的就是空气阻力。

空气阻力

在本节中,我们将短暂偏离原来的思路,考虑牛顿第二定律在时间、速度、两者或都不依赖的力的作用下,推导出空气阻力在一维运动中的作用力表达式。空气阻力是一个仅依赖于速度的力,在接下来的几个章节中,我们将使用它来发展求解牛顿第二定律的方式,这些方式涉及到依赖于速度的力。

物理入门课程通常忽略空气阻力,或者仅轻微处理它,因为空气阻力的存在使得牛顿第二定律转变为微分方程,这被认为超出了入门物理课程的范围。在本章及下章中,我们将开发求解微分方程的数值方法,这意味着空气阻力不是我们想要避免的东西;事实上,它展示了我们工具的强大能力。

为了建立空气阻力的模型,我们可以将物体与周围空气的相互作用看作一次碰撞。假设物体以速度v运动。在本节中,v代表物体的实数值一维速度(类型为R),而不是速度函数或速率。

设物体的横截面积为A,空气密度为ρ。我们分析物体在一个小时间间隔dt内的运动。我们假设空气的初始速度为 0,空气的最终速度为v(换句话说,碰撞后空气与物体以相同速度移动)。

物体在时间dt内移动的距离是v dt。物体在时间dt内扫过的空气体积是Av dt。物体在时间dt内扰动的空气质量是ρAv dt。物体在时间dt内传递给空气的动量是空气质量ρAv dt与空气速度变化v的乘积,假设空气从静止开始,并在短时间内以速度v结束。传递给空气的动量是ρAv²dt。空气受到的力是单位时间内动量变化,即ρAv²。根据牛顿第三定律,物体受到的来自空气的力与此相等且方向相反,我们将在第十九章讨论这个问题。

我们的推导实际上相当近似,因为我们并不知道空气分子是否最终以速度v结束,而且我们也没有尝试考虑空气分子之间的相互作用力,尤其是空气压缩时的力。然而,我们的结果形式是相当有用且近似正确的。不过,不同形状的物体响应略有不同,因此引入阻力系数 C来考虑这些差异是有用的。阻力系数是一个无量纲常数,是物体飞行时穿越空气的一个特性。通常还会包括 1/2 的因子,使得物体所受的空气阻力的大小为CρA v²/2\。这个表达式从不为负。我们更希望有一个表达式,当速度为正时力为负,速度为负时力为正。我们得到的空气阻力一维力的最终表达式是

图片

其中,负号和绝对值确保力的方向与速度相反。空气阻力作用于减速物体。在 Haskell 中,我们将空气阻力的方程 14.9 写作如下:

fAir :: R  -- drag coefficient
     -> R  -- air density
     -> R  -- cross-sectional area of object
     -> Velocity
     -> Force
fAir drag rho area v = -drag * rho * area * abs v * v / 2

在方程 14.9 的数学表示中,我们将F[air]视为一个单变量的函数。参数CρA并没有显式列出,作为F[air]依赖的变量。像这样省略参数是物理学中的标准做法,但从某种意义上讲,这也是一种符号滥用。在 Haskell 符号中,我们必须包括空气阻力所依赖的所有变量。我们首先列出这三个参数,再列出Velocity,这样表达式fAir 1 1.225 0.6就是一个完全合法的函数,只接受速度作为输入。函数fAir 1 1.225 0.6已经选择了阻力=1,密度=1.225,面积=0.6。

通过这次简要的空气阻力探讨,尤其是方程 14.9 的发展,我们现在准备好探讨牛顿第二定律,考虑物体上的力仅依赖于其速度的情况。

只依赖于速度的力的第二定律

牛顿第二定律的下一个情况是净力依赖于速度,而不依赖于时间或位置。我们在这里的真正意思是力并不显式地依赖于时间。速度是一个依赖于时间的函数,而力在本节中可以依赖于速度,因此某种意义上力依赖于时间。本节的约束是力只能通过速度来依赖时间。

力函数可能只依赖于一个变量,即速度。我们使用 Image 来表示一个变量的 j 号力函数,它在给定速度时提供力;我们使用 Image 来表示一个变量的函数,它在给定速度时提供净力。

Image

在本节中,我们使用 v[0] 作为速度的局部变量(类型 R),而不是 v,因为我们希望 v 代表物体的速度函数(类型 R -> R)。

图 14-6 展示了一个牛顿第二定律的示意图,其中力仅依赖于速度。

Image

图 14-6:牛顿第二定律与仅依赖于速度的力

该图与之前的图不同,包含了一个闭环。由加速度积分器产生的速度作为净力函数的输入。图中的闭环表明,牛顿第二定律产生了一个微分方程。由于闭环包含一个积分器,我们得到了一个一阶微分方程。微分方程比单纯的积分或反导数更难求解,正如当力只依赖于时间时的情况。

牛顿第二定律由以下方程给出:

Image

该方程所表示的信息与图 14-6 的示意图中的信息相同。该方程描述了速度的变化率如何通过作用在物体上的力依赖于速度本身。接下来的 newtonSecondV 函数是表达牛顿第二定律的第三种方式;当给定当前的速度值和作用于物体的力时,该函数返回速度的变化率。

newtonSecondV :: Mass
              -> [Velocity -> Force]  -- list of force functions
              -> Velocity             -- current velocity
              -> R                    -- derivative of velocity
newtonSecondV m fs v0 = sum [f v0 | f <- fs] / m

我们可以对加速度进行积分以获得速度。

Image

与时间依赖力的情况不同,在这里我们不能简单地进行积分,因为我们试图找到的速度函数出现在积分下方。该如何进行呢?

为了解这个微分方程(方程 14.10),我们将对时间进行离散化,这实际上就是我们在选择时间步长时,所做的数值导数和积分处理。只要我们的时间步长Δt小于所处理问题中的任何重要时间尺度,连接点(t, v(t))和(t + Δt, v(t + Δt))的直线的斜率就大致等于时间t时速度的导数。

Image

重新排列这个方程式可以得到求解一阶微分方程的欧拉方法

Image

欧拉方法通过将时间t的速度与导数在t时的乘积,再加上时间步长Δt,来近似计算t + Δt时的速度。欧拉方法提供了一种方法,可以从已知时刻的速度和导数,推算出稍后时刻的速度。

图 14-7 以图示方式描述了欧拉方法解决牛顿第二定律的问题。

Image

图 14-7:在一维情况下,欧拉方法应用于牛顿第二定律,特殊情况为净力仅依赖于速度

该图展示了数据如何通过纯函数作用来计算物体在不同时间的速度。因为该图仅使用纯函数(仅返回输出结果的函数,且不修改任何输入或全局值),我们称之为函数图。而图 14-6 中的示意图将时间表示为连续的,这个图则将时间表示为离散的。示意图中的电线值随时间连续变化,而函数图中的电线值保持不变。在函数图中,不同的时间点对应不同的电线。尽管示意图可能包含图 6-5 中的带状态积分器,但函数图则展开并用离散的函数模型替代积分器,如图 6-7 所示。从图 14-7 中可以看出,每个时间步都会执行相同的一组计算,用新的速度值替换旧的速度值。我们称每个时间步执行的计算集合为速度更新函数

图 14-8 展示了速度更新函数,该函数是欧拉方法应用于一个小时间步长的结果。

Image

图 14-8:用于求解仅依赖于速度的牛顿第二定律的欧拉方法中的速度更新函数

图 14-8 展示了一个速度更新的函数图,直观地描述了如何从时间t的速度和力,计算出时间t + Δt时的速度。

这是速度更新方程,展示了如何从旧的速度获得新的速度:

Image

最后,我们有 Haskell 函数updateVelocity,它将速度的值推进一个时间步。

updateVelocity :: R                    -- time interval dt
               -> Mass
               -> [Velocity -> Force]  -- list of force functions
               -> Velocity             -- current velocity
               -> Velocity             -- new velocity
updateVelocity dt m fs v0
   = v0 + (newtonSecondV m fs v0) * dt

图 14-8 中的功能图、速度更新方程(方程 14.12)和函数updateVelocity以不同的形式表达相同的信息,即如何使用欧拉方法进行一步时间推进。

现在我们想写一个函数velocityFv,类似于velocityCFvelocityFt,但用于依赖于速度的力的情况。将updateVelocity看作一个输入为Velocity,输出为Velocity的函数时,我们希望将时间步长、质量和力函数列表作为参数。函数updateVelocity dt m fs的类型是Velocity -> Velocity,它在图 6-4(第 203 页)中扮演着可迭代函数f的角色。

velocityFv :: R                    -- time step
           -> Mass
           -> Velocity             -- initial velocity v(0)
           -> [Velocity -> Force]  -- list of force functions
           -> Time -> Velocity     -- velocity function
velocityFv dt m v0 fs t
    = let numSteps = abs $ round (t / dt)
      in iterate (updateVelocity dt m fs) v0 !! numSteps

我们定义了一个局部变量numSteps,表示我们需要的时间步数,以尽可能接近目标时间t。我们从初始速度v0开始,迭代函数updateVelocity dt m fs,然后从这个无限列表中选择一个最接近目标时间的速度值。

作为一个力只依赖于速度的情况示例,假设一位自行车骑行者在一条平坦的水平路面上朝北骑行。我们在这种情况下考虑两个力。首先,是路面施加在自行车轮胎上的北向力,因为骑行者正在踩踏板。我们将这个力称为F[rider](它是路面直接施加在自行车上的,但间接是由骑行者产生的),并假设这个力为恒定的 100 N。其次,是阻碍骑行者北向前进的南向空气阻力,特别是当她快速行驶时。我们将使用上一节中推导的空气阻力表达式和方程 14.9。净力是

Image

设自行车加骑行者的质量为m = 70 kg。我们选择拖拽系数C = 2,假设空气密度为ρ = 1.225 kg/m³,并将自行车和骑行者的横截面积近似为 0.6 m²。从静止开始,我们的任务是找出自行车的速度随时间变化的函数。

在我们使用 Haskell 函数来研究自行车的运动之前,我们将展示如何手动使用欧拉方法。

手动欧拉方法

让我们手动使用欧拉方法计算自行车的前几个速度值。这样做的目的是为了清楚理解欧拉方法中发生的事情,这样我们编写的代码才有意义,而不仅仅是某个抽象模糊过程的形式化表示。我们选择时间步长为 0.5 秒。我们的任务是完成以下表格。我们可以填入所有的时间值,因为它们只是以 0.5 秒的间隔进行排列。初始速度为 0,所以我们也填入了这一点。

t (s) v(t) (m/s)
0.0 0.0000
0.5
1.0
1.5

我们将通过反复使用方程 14.12 更新速度,来完成表格。为了计算 0.5 秒时的速度,我们选择t = 0 代入方程 14.12。

Image

我们用以下内容更新表格:

t (s) v(t) (m/s)
0.0 0.0000
0.5 0.7143
1.0
1.5

然后我们使用方程 14.12 计算v(1.0 s),其中t = 0.5 s:

Image

我们将此添加到表格的相应行中并继续。

Image

完成的表格如下所示:

t (s) v(t) (m/s)
0.0 0.0000
0.5 0.7143
1.0 1.4259
1.5 2.1295

Haskell 中的欧拉方法

现在我们将使用velocityFv函数来计算自行车的速度。以下是一个自行车的速度函数,时间步长为 1 秒:

bikeVelocity :: Time -> Velocity
bikeVelocity = velocityFv 1 70 0 [const 100,fAir 2 1.225 0.6]

高阶函数const可以用来创建常量函数。函数const 100接受一个输入,忽略它,并返回 100 作为输出。它等同于匿名函数\_ -> 100。我们在这里使用它来表示常量力 100 N。

注意,解决自行车问题时必须提供的数据。我们提供了 70 公斤的质量、自行车的初始速度为 0 m/s,以及两个力:const 100,一个 100 N 的常量力,和fAir 2 1.225 0.6,这是一个空气阻力的力,具有 2 的阻力系数,1.225 kg/m³的空气密度和 0.6 m²的横截面积。

这是生成速度与时间图表的代码:

bikeGraph :: IO ()
bikeGraph = plotFunc [Title "Bike velocity"
                     ,XLabel "Time (s)"
                     ,YLabel "Velocity of Bike (m/s)"
                     ,PNG "BikeVelocity1.png"
                     ,Key Nothing
                     ] [0,0.5..60] bikeVelocity

该代码绘制了bikeVelocity函数,包括标题和坐标轴标签,并生成一个 PNG 文件, 可以在其他文档中使用。图 14-9 包含了该图表。

Image

图 14-9:自行车速度随时间变化的图。台阶状的外观可以修复,并在文中讨论。

在图 14-9 中出现了一种现象,这在恒定加速度情况下不会发生:终端速度的建立。在大约 20 秒后,路面的前向力(来自踏车)与空气的反向力相匹配。此时我们没有净力(或者只有一个非常小的净力),速度保持在终端速度。

为什么图 14-9 看起来像台阶状?我们使用了一秒钟的时间步长来计算速度函数bikeVelocity,但接着我们要求plotFunc函数每半秒给出该函数的图形。如果我们想要平滑的图形,我们有几个选择。最简单的方式是要求时间值间隔至少一秒的图形。或者,我们可以使用更小的时间步长来计算bikeVelocity函数。无论如何,我们不应要求图表的分辨率超过我们在绘制函数时所要求的分辨率。

使用velocityFvpositionFv这两个函数,其中后者是你在练习 14.4 中需要编写的,我们就有了通用工具来解决任何仅依赖于速度的单维度空间中的牛顿第二定律类型的问题。在我们转向力同时依赖时间和速度的情况之前,让我们先从更广泛的视角看一下我们刚才所做的事情。

物理系统的状态

思考牛顿第二定律的一个富有成效的方式,以及后续的麦克斯韦方程,围绕着物理系统的状态这一概念展开,状态是指为了精确描述系统在某一特定时刻发生的情况所需的信息的集合。

状态代表系统当前的“事态”,包含足够的信息,使得未来的预测可以基于当前状态,而不是系统的过去信息。状态随时间变化,并根据某种规则改变。

给定一个我们希望理解的物理系统,基于状态的范式提出了以下概念性划分:

  1. 要指定系统的状态,需要哪些信息?

  2. 在某个初始时刻,系统的状态是什么?

  3. 状态随时间变化的规则是什么?

当我们处理牛顿第二定律时,若力是常量或者仅依赖于时间时,我们没有使用基于状态的方法,因为我们不需要。在这些情况下,我们可以使用代数或积分来找出物体在时间上位置和速度的变化。当我们查看依赖于速度的力时,我们得到了一个与微分方程相对应的框图,如图 14-6 所示。基于状态的方法对于微分方程特别有用。

有三点需要注意与图 14-6 相关的基于状态的方法。首先,注意回路中有一个积分器,且该积分器将速度作为状态值。其次,注意微分方程(方程 14.10)给出了速度变化率的表达式。最后,注意力的大小依赖于速度。由于这三点,在力仅依赖于速度的情况下,物体的状态由物体的速度组成。

通常,问题 1 的答案是一个数据类型。经历仅依赖于物体速度的力的物体的状态是Velocity数据类型的一个值。在接下来的章节中,当力依赖于时间和速度时,我们将使用数据类型(Time,Velocity)来表示状态。随着我们考虑更复杂的物理情境,我们用来表示物理系统状态的数据类型将包含更多的信息。

上述问题 2 在某种意义上是最小的问题。即使没有问题 2 的答案,也可能进行一些分析。但是,如果我们希望知道系统在某一未来时刻的性质,那么我们希望知道该时刻的状态,这通常需要知道某个早期时刻的状态。问题 2 的答案是问题 1 中数据类型的一个值。

问题 3 需要物理理论来回答。在力学中,牛顿第二定律提供了描述状态随时间变化的规律。

让我们看看基于状态的方法如何应用于力仅依赖于物体的时间和速度的情况。

依赖于时间和速度的力的第二定律

牛顿第二定律的下一个情况是力依赖于时间和速度,但不依赖于位置。力的函数依赖于两个变量:时间和速度。我们用Image表示当给定时间和速度时产生力的第 j个函数,用Image表示当给定时间和速度时产生合力的两个变量的函数。

Image

图 14-10 展示了牛顿第二定律的示意图,其中的力依赖于时间和速度。

Image

图 14-10:牛顿第二定律,力依赖于时间和速度

示意图包含一个回路,因此牛顿第二定律是一个微分方程,如方程 14.14 所示。

Image

请注意,在图 14-10 中的循环里有一个积分器,它保持着速度作为状态值。有一种方法可以仅使用速度作为物体的状态来求解这个微分方程。然而,由于方程 14.14 中的速度变化率同时依赖于时间和速度(因为力依赖于时间和速度),如果我们将时间和速度都作为状态变量,基于状态的方法会更容易应用。这就是说,我们将用于状态的数据类型是(Time,Velocity)。方程 14.10 表达的牛顿第二定律,力仅依赖于速度,而方程 14.14 表达的牛顿第二定律,力依赖于时间和/或速度,二者的区别在于,后者需要知道当前的时间值,而前者则不需要。在状态(Time,Velocity)中包含时间,是方便获取当前时间的一种简单方法。

哪些量应该被称为状态变量?假设我有一个粒子在空间中,受到已知(与时间无关)力法则的作用。状态变量是位置和速度,因为我们可以从这些变量计算出下一个时间点的位置和速度。为什么加速度不是状态变量?使用本章前面部分的术语,状态变量是用来确定微分方程特定解的数字——它们是将积分转化为反导数的初始值。时间通常不被视为状态变量,但将其视为状态变量可以更容易地思考时间依赖的力。对于有兴趣深入讨论状态变量及其用途的读者,建议参阅[16]和[17]。

下面展示的 Haskell 函数newtonSecondTV表达了牛顿第二定律,其中力依赖于时间和速度。

newtonSecondTV :: Mass
               -> [(Time,Velocity) -> Force]  -- force funcs
               -> (Time,Velocity)             -- current state
               -> (R,R)                       -- deriv of state
newtonSecondTV m fs (t,v0)
    = let fNet = sum [f (t,v0) | f <- fs]
          acc = fNet / m
      in (1,acc)

给定物体的质量以及作用于物体的力列表,现在这些力被表示为状态(Time,Velocity)的函数,newtonSecondTV给出了从状态变量本身计算状态变量时间导数的指令。返回类型(R,R)表示时间的时间导数,它始终是无量纲的数字 1,以及速度的时间导数,即加速度。加速度是通过牛顿第二定律计算的,方法是求出净力并除以质量。

为了解决方程 14.14,我们将离散化时间并使用欧拉方法。我们将继续使用方程 14.11 来进行欧拉方法。图 14-11 以图示形式描述了当力依赖于时间和/或速度时,使用欧拉方法解决牛顿第二定律的问题。

Image

图 14-11:欧拉方法在一维中解决牛顿第二定律的情况,针对净力仅依赖于时间和/或速度的特殊情况

图示展示了函数如何在某一时刻作用于状态变量,以计算下一个时刻的状态变量。相同的计算集合在每个时间步都会重复,以从旧的状态产生新的状态。我们称每个时间步发生的计算集合为状态更新函数

状态更新函数在图 14-12 中以图示方式展示。图示描述了如何根据力函数,从时刻t的时间和速度计算出时刻t + Δt的时间和速度。

Image

图 14-12:欧拉法更新,适用于仅依赖于时间和速度的牛顿第二定律中的力

以下是状态更新方程,展示了如何从旧的状态变量获得新的状态变量:

Image

方程 14.15 和 14.16 是适用于受到依赖于时间和速度的力作用的物体的状态更新方程。状态更新方程告诉我们如何更新状态变量时间和速度,以推进到下一个时间步。方程 14.15 中的时间更新很简单:我们只需将Δt加到旧时间上即可得到新时间。要更新方程 14.16 中的速度,我们需要计算加速度,将其与时间步相乘得到速度的变化,再将此变化加到旧的速度上。应用这些状态更新方程是我们用欧拉法解微分方程的方式。这一状态更新过程是我们解决牛顿力学问题的主要工具。

以下是 Haskell 函数updateTV,因其同时更新时间和速度,所以命名为此,它将状态值推进一个时间步。

updateTV :: R                           -- time interval dt
         -> Mass
         -> [(Time,Velocity) -> Force]  -- list of force funcs
         -> (Time,Velocity)             -- current state
         -> (Time,Velocity)             -- new state
updateTV dt m fs (t,v0)
    = let (dtdt, dvdt) = newtonSecondTV m fs (t,v0)
      in (t  + dtdt * dt
         ,v0 + dvdt * dt)

函数updateTV接受几个参数,生成类型为(Time,Velocity) -> (Time,Velocity)的函数。updateTV的第三个输入,命名为fs,类型为[(Time,Velocity) -> Force],本可以是类型为[Time -> Velocity -> Force]的输入;这只是风格问题,选择任何一个都能正常工作。这里我选择了前者,因为时间和速度已经在函数输出中配对。

我们在此函数中传递的时间-速度对表示的是我们应用牛顿第二定律的物体的状态。函数updateTV是一个状态更新函数的示例。在前面的章节中,当力只依赖于速度时,速度本身作为状态,而函数updateVelocity则是适当的状态更新函数。

图 14-12、方程 14.15 和 14.16 以及函数updateTV以不同形式表达了相同的信息,即如何通过欧拉法在时间上迈出一步。

根据我们想要计算的内容,updateTV函数有两种使用方式,对应于时间-速度数据的两种表示方式。首先,我们可能希望生成一个时间-速度对的列表。其次,我们可能希望生成时间作为速度的函数。我们将在接下来的两个小节中为这两种目的开发函数。

方法 1:生成状态列表

时间-速度对的列表可以视为一个牛顿第二定律问题的解决方案,其中力依赖于时间和速度,因为时间-速度对给出了状态。状态列表包含了每个被欧拉方法探测的时间对应的时间-速度对,见图 14-11。函数statesTV在给定时间步长、质量、初始状态和力函数列表时,产生一个时间-速度对的列表。

statesTV :: R                           -- time step
         -> Mass
         -> (Time,Velocity)             -- initial state
         -> [(Time,Velocity) -> Force]  -- list of force funcs
         -> [(Time,Velocity)]           -- infinite list of states
statesTV dt m tv0 fs
    = iterate (updateTV dt m fs) tv0

我们使用iterate来实现图 14-11 中的重复组合。但我们想要迭代哪个函数呢?它不只是updateTV,因为updateTV需要三个参数作为输入,才能得到时间-速度对。我们要迭代的函数必须是a -> a类型,或者在这个例子中是(Time,Velocity) -> (Time,Velocity)类型。解决方案是给updateTV提供它的前三个参数,形成我们传递给iterate的函数。我们想要迭代的函数是updateTV dt m fs,从初始的时间-速度对tv0开始。

函数statesTV提供了一种通用的方法来解决任何牛顿第二定律类型的问题,适用于单一空间维度且力仅依赖于时间和速度。所谓的解决方案是指一个无穷长的状态列表(时间-速度对),这些状态之间的时间步长间隔是均匀的。

方法 2:生成速度函数

现在我们想编写一个函数velocityFtv,它类似于velocityCFvelocityFtvelocityFv,但适用于力依赖于时间和速度的情况。我们将使用statesTV生成的无穷长列表,选出最接近我们期望时间的时间-速度对,并使用 Prelude 函数snd返回与时间分离的速度。

velocityFtv :: R                          -- time step
           -> Mass
           -> (Time,Velocity)             -- initial state
           -> [(Time,Velocity) -> Force]  -- list of force funcs
           -> Time -> Velocity            -- velocity function
velocityFtv dt m tv0 fs t
   = let numSteps = abs $ round (t / dt)
     in snd $ statesTV dt m tv0 fs !! numSteps

通过函数velocityFtvpositionFtv,其中后者将在练习 14.9 中要求你编写,我们可以用通用的方法解决任何涉及时间和速度依赖的力的单一空间维度的牛顿第二定律问题。现在我们来看看一个涉及这种力的情况。

示例:骑行与风阻滑行

作为一个依赖时间和速度的力的示例,让我们重新考虑我们的小孩自行车骑行者,他正在踩踏和滑行,但现在有了空气阻力。我们将考虑这种情况中的两个力。首先,存在来自方程 14.8 中描述的踩踏力 Fpc,这是一个时间相关的力。其次,存在空气阻力 Fair,它阻碍孩子的运动,我们将使用方程 14.9 来描述这个力。净力是

Image

自行车和孩子的总质量是m = 20 kg。我们选择拖曳系数C = 2,取空气密度ρ = 1.225 kg/m³,并将自行车和骑行者的横截面积近似为 0.5 m²。从静止开始,我们的任务是找到自行车速度随时间的变化函数。

我们使用方程 14.16 来更新速度。在使用 Haskell 函数研究自行车运动之前,我们将展示如何手动使用欧拉方法。

手动欧拉方法

让我们手动使用欧拉方法来计算自行车的几个速度值。同样,手动进行欧拉方法的目的是清楚地了解在欧拉方法中如何更新状态变量。我们将选择 6 秒的时间步长,尽管这个时间步长对于获取准确结果来说太大,因为它相对于相关的时间尺度(如 20 秒的周期时间)并不小。我们选择 6 秒的时间步长是为了能够在前几个时间步长中同时采样踩踏和滑行。我们的任务是完成以下表格。我们可以填充所有的时间值,因为它们只是以六秒的间隔分布。初始速度是 0,因此我们也将其填写。

t (s) v(t) (m/s)
0 0.0000
6
12
18

踩踏的力要么是 10 N,要么是 0 N,取决于时间的值。

Fpc = Fpc = 10 N

Fpc = Fpc = 0 N

通过反复应用方程 14.16,我们得到以下结果:

Image

完成的表格如下所示:

t (s) v(t) (m/s)
0 0.0000
6 3.0000
12 4.3463
18 0.8752

现在我们转向 Haskell,使用我们之前讨论的两种方法。

方法 1:生成状态列表

在这里,我们将使用函数statesTV生成一个无限的速度-时间对列表,称为pedalCoastAir,用于描述孩子在自行车上的运动。

pedalCoastAir :: [(Time,Velocity)]
pedalCoastAir = statesTV 0.1 20 (0,0)
                [\(t,_) -> pedalCoast t
                ,\(_,v) -> fAir 2 1.225 0.5 v]

请注意解决此问题所需提供的数据。我们提供了 0.1 秒的时间步长、20 千克的质量、初始状态(时间为 0,速度为 0),以及以匿名函数形式表示的两个力。pedalCoast函数仅依赖于时间,因此不能直接列为力函数,因为statesTV的力函数需要时间-速度对作为输入。下划线的存在是因为蹬踏函数不依赖于状态中的第二项(即速度),而空气阻力也不依赖于状态中的第一项(即时间)。

一对对的列表是我们可以使用gnuplot库中的plotPath函数绘制的,但我们需要在绘图前截断列表为有限列表,否则plotPath会在尝试计算无限列表时挂起。在下面的pedalCoastAirGraph中,我们使用takeWhile函数提取时间小于或等于 100 秒的状态。

pedalCoastAirGraph :: IO ()
pedalCoastAirGraph
    = plotPath [Title "Pedaling and coasting with air"
               ,XLabel "Time (s)"
               ,YLabel "Velocity of Bike (m/s)"
               ,PNG "pedalCoastAirGraph.png"
               ,Key Nothing
               ] (takeWhile (\(t,_) -> t <= 100)
                  pedalCoastAir)

这段代码生成了图 14-13,展示了孩子在空气阻力下蹬踏与滑行时,速度随时间变化的关系。

图像

图 14-13:带有空气阻力的蹬踏与滑行

正如预期的那样,孩子在蹬踏间隔期间速度增加,而在滑行间隔期间速度减小。

方法二:生成速度函数

现在让我们使用函数velocityFtv为骑车的孩子生成一个速度函数。

pedalCoastAir2 :: Time -> Velocity
pedalCoastAir2 = velocityFtv 0.1 20 (0,0)
                 [\( t,_v) -> pedalCoast t
                 ,\(_t, v) -> fAir 1 1.225 0.5 v]

我们给pedalCoastAir2的数据与给pedalCoastAir的数据相同。由于pedalCoastAir2是一个R -> R函数,因此可以使用gnuplot包中的plotFunc函数进行绘图。它将生成与图 14-13 中相同的图形。

总结

本章讨论了牛顿的第一定律,并在一维运动的背景下介绍了牛顿的第二定律。本章呈现了一系列越来越复杂的牛顿第二定律应用场景。其中最简单的是物体上的力是常量,即随时间不变。接下来是当物体上的力仅依赖于时间时,在这种情况下我们可以应用积分来找到物体的速度和位置。依赖于速度的力,比如本章介绍的空气阻力,要求我们解一个微分方程,这比积分更复杂。章节还介绍了欧拉方法来解一阶微分方程。欧拉方法与牛顿第二定律一起,为更新我们正在追踪的物体的状态提供了规则,使我们能够预测其未来的运动。状态变量或包含在状态中的物理量的选择由力的依赖关系决定。如果力仅依赖于速度,那么速度本身可以作为粒子的状态。如果力依赖于时间和速度,那么我们使用时间和速度作为状态变量。

在下一章中,我们允许力依赖于位置以及时间和速度。这将产生一个二阶微分方程,并要求时间、位置和速度都作为状态变量。

练习

练习 14.1. 编写一个函数velocityCF',它与velocityCF做相同的事情并具有相同的类型签名,但在定义中,时间t :: Time显式列在等号的左侧。

velocityCF' :: Mass
            -> Velocity          -- initial velocity
            -> [Force]           -- list of forces
            -> Time -> Velocity  -- velocity function
velocityCF' m v0 fs t = undefined m v0 fs t

练习 14.2. 使用positionCF函数,为示例 14.1 中的汽车在气轨上的位置画图,位置作为时间的函数。假设汽车的初始位置为-1 米。

练习 14.3. 编写一个函数

sumF :: [R -> R] -> R -> R
sumF = undefined

添加一个函数列表,以生成一个表示总和的函数。用你的代码替换undefined,并可以在定义的等号左侧加入一个或两个变量。使用sumF,我们可以将velocityFtlet构造中的第一行写为fNet = sumF fs

练习 14.4. 编写一个 Haskell 函数

positionFv :: R                   -- time step
           -> Mass
           -> Position            -- initial position x(0)
           -> Velocity            -- initial velocity v(0)
           -> [Velocity -> Force] -- list of force functions
           -> Time -> Position    -- position function
positionFv = undefined

返回一个描述牛顿第二定律的位移函数,该定律中的力仅依赖于速度。用你的代码替换undefined,并可以在定义的等号左侧加入变量。

练习 14.5. 任何可以用velocityFv解决的牛顿第二定律问题,也可以用velocityFtv来解决。重写bikeVelocity函数,使其使用velocityFtv而不是velocityFv

练习 14.6. 手动使用欧拉方法计算第 225 页的速度,我们发现 1.5 秒后的速度为v(1.5 秒) = 2.1295 米/秒。使用velocityFv函数来计算这个相同的数值。

习题 14.7. 在 第 235 页手工计算欧拉法时,我们发现 18 秒时的速度为 v(18 s) = 0.8752 m/s。使用 statesTVvelocityFtv 来计算这个相同的结果。

习题 14.8. 修复 图 14-9 中的阶梯效应,使图表显示平滑。

习题 14.9. 编写一个 Haskell 函数

positionFtv :: R                    -- time step
            -> Mass
            -> Position             -- initial position x(0)
            -> Velocity             -- initial velocity v(0)
            -> [(Time,Velocity) -> Force]  -- force functions
            -> Time -> Position     -- position function
positionFtv = undefined

该函数返回一个位置函数,适用于牛顿第二定律情形,其中力仅依赖于时间和速度。将 undefined 替换为你的代码,且可以自由在等号左侧的定义中包含变量。

习题 14.10. 绘制 图 14-13 中情形的位移与时间的关系图。

习题 14.11. 为了加深我们对欧拉法的理解,我们将手工进行一次计算(仅使用计算器,而不是计算机)。

考虑一个受到两个力作用的 1 公斤物体。第一个力是一个振荡力,先一个方向推力,然后再反方向推力。用 t 表示秒,力的单位是牛顿,其表达式为

F1 = 4 cos 2t

第二个力是一个空气阻力力,单位是牛顿,表达式为

F2 = –3v[0]

其中 v[0] 是当前质量的速度,以米每秒为单位。

净力是

图片

假设质量最初以 2 m/s 的速度运动,因此

v(0 s) = 2 m/s

使用欧拉法,时间步长 Δt = 0.1 s,近似计算 v(0.3s) 的值。在计算中保留小数点后至少四位。将你的计算过程展示在一个小表格中。

习题 14.12. 编写一个 Haskell 函数

updateExample :: (Time,Velocity)  -- starting state
              -> (Time,Velocity)  -- ending state
updateExample = undefined

该函数接受一个时间-速度对 (t[0], v[0]),并返回一个更新后的时间-速度对 (t[1], v[1]),适用于 1 公斤物体在欧拉法下进行单步计算的情形,假设其受到净力的作用。

图片

使用时间步长 Δt = 0.1 s。展示如何使用函数 updateExample 计算你在习题 14.11 中手工计算的 v(0.3 s) 值。

习题 14.13. 考虑一个 1 公斤物体,受净力作用

图片

其中 α = 1 N·s/m,初始条件是 v(0 s) = 8 m/s。使用欧拉法求解物体在时间区间 0 s ≤t ≤ 10 s 内的速度,并绘制速度与时间的关系图,观察结果。将结果与精确解进行比较:

图片

尝试不同的时间步长,看看当时间步长过大时会发生什么。

找到一个足够小的时间步长,使得欧拉解和精确解在图表上能够很好地重合。找到另一个足够大的时间步长,使你可以在图表上看到欧拉解和精确解之间的差异。

绘制一个漂亮的图表(包括标题、坐标轴标签等),将这三种解法(坏的欧拉法、好的欧拉法和精确解)绘制在同一图表上。标记欧拉法的结果,并标出你使用的时间步长,将精确解标记为“Exact”。

习题 14.14. 考虑以下微分方程

Image

并且满足初始条件 v(0) = 0\。这个微分方程没有精确解。使用欧拉方法,步长为 Δt = 0.01,计算区间 0 ≤ t ≤ 3 上的 v(t)。绘制结果函数的图像,并将 v(3) 的值精确到五位有效数字。

习题 14.15. 功能图中的每一根电线都可以标记一个类型。请为图 14-11 中的每根电线标记类型。

第十五章:一维力学

图片

本章中,我们将通过开发适用于物体所受力既依赖于位置又依赖于时间和速度的情况的工具,完成一维力学的故事。这将需要思考二阶微分方程,并将其转化为一阶微分方程系统。

如同之前一样,我们将通过几种不同的形式转化关于物理情况的信息,从物体的质量和作用在其上的力开始,最终得出给出物体位置和速度随时间变化的函数。像 Haskell 这样的函数式语言通过为每种信息形式赋予名称和类型,并允许在适当的时候将这些信息自然地以函数的形式存在,有助于我们组织思考如何解决力学问题。

为了应用我们正在开发的工具,我们将看一个例子:乒乓球在弹簧末端运动,受到空气阻力的影响——换句话说,是一个阻尼谐振子。我们将展示如何应用欧拉法求解二阶微分方程,并展示如何将物体的状态列表或位置和速度函数视为力学问题的解。接着我们将介绍欧拉-克罗梅法,这是对欧拉法在二阶微分方程中的改进。由于我们将在接下来的章节中引入向量和多个粒子,修改状态变量和状态使用的数据类型,在本章结束时,我们将总结我们的微分方程求解方法,使其能够支持任意数据类型的状态,并允许选择数值方法。

入门代码

让我们从源代码文件开头需要出现的代码开始。我总是喜欢先开启警告。我还会包含两个语言设置,我们将在本章“求解微分方程”部分稍后使用。

{-# OPTIONS_GHC -Wall #-}
{-# LANGUAGE FlexibleInstances, MultiParamTypeClasses #-}

让我们将本章中的代码制作成一个名为Mechanics1D的模块。

module Mechanics1D where

我们稍后还需要绘制图表,所以让我们导入必要的模块。

import Graphics.Gnuplot.Simple

对于我们举的例子——一个乒乓球挂在弹簧的末端,我们将使用上一章中的空气阻力函数fAir。要访问这个函数,我们需要导入我们在第十四章中编写的Newton2模块。

import Newton2 ( fAir )

Haskell 编译器将在当前工作目录(即包含我们正在编写的Mechanics1D.hs文件的目录)中查找一个名为Newton2.hs的文件。Newton2.hs文件包含了我们在上一章编写的代码,并可以通过lpfp.io访问。

如果我们在模块名称后面包含一个以逗号分隔的类型和函数列表(放在括号中),我们将只导入这些类型和函数。如果我们省略这个列表,像我们导入 Graphics.Gnuplot.Simple 模块时那样,我们将导入该模块提供的所有类型和函数。

像往常一样,我们将使用类型同义词 R 来代替 Double。我们在第十章的 SimpleVec 模块中创建了这个类型同义词,并从那里导入它。

import SimpleVec ( R )

警告选项、语言设置、模块名称和导入语句需要位于源代码文件的开头。类型同义词可以出现在任何地方。

当我们在一维中做力学时,时间、时间步长、质量、位置、速度和力都用实数来表示。

type Time     = R
type TimeStep = R
type Mass     = R
type Position = R
type Velocity = R
type Force    = R

依赖于时间、位置和速度的力

当物体上的力依赖于时间、位置和速度时,力函数依赖于三个变量。我们将使用 Image 来表示在给定时间、位置和速度时给出力的 j 维函数;我们将使用 Image 来表示给出合力的三个变量的函数。图 15-1 展示了一个示意图,说明了牛顿第二定律在力依赖于时间、位置和速度时的情况。

Image

图 15-1:牛顿第二定律在一维中的表现。力依赖于时间、位置和速度。

图中的矩形框表示纯函数,其输出仅依赖于输入。积分器被包含在圆形中,以提醒我们每个积分器都包含一些状态。例如,输出为 x(t) 的积分器必须包含当前位置值。图 15-1 中的示意图是连续的,并且是有状态的。

牛顿第二定律表现为以下的微分方程:

Image

我们已经得到了牛顿第二定律在一维中的完全一般形式,正如上一章方程 14.2 所给出的。位置是我们在这个微分方程中要找的未知函数。因为方程中出现了位置的二阶导数,所以这是一个二阶微分方程。为了使用基于状态的方法来求解二阶微分方程,我们将选择一些状态变量,并为每个状态变量写出一阶微分方程。

我们的状态变量应该是什么?图 15-1 中包含了两个集成器,它们在循环中保持位置和速度作为状态,因此位置和速度必须是状态变量。虽然仅使用位置和速度作为状态变量也可以求解方程 15.1,但如果我们也允许时间作为状态变量,则会更容易,因为力也可能依赖于时间。我们描述单个物体在一维运动的力学状态的数据类型是时间-位置-速度三元组。

type State1D = (Time,Position,Velocity)

我们使用状态空间这个名称来描述像State1D这样的数据类型,它们表示状态。前一章中的类型Velocity(Time,Velocity)是状态空间的其他示例。

方程 15.2、15.3 和 15.4 显示了通过写出每个状态变量的时间导数所得到的三个一阶微分方程。方程右侧可能涉及状态变量,但可能没有任何导数。因为位置函数的导数依赖于速度函数,反之亦然,所以我们称这组方程为耦合微分方程。

图片

函数newtonSecond1D给出了状态变量的时间导数的表达式,表达式中的状态变量本身就是状态变量。注意,在这个函数中,状态变量是实数,而不是时间的函数。通过表达微分方程 15.2、15.3 和 15.4,函数newtonSecond1D表达了牛顿第二定律的一维形式。图 15-1 中的示意图、微分方程和 Haskell 函数newtonSecond1D都包含了理解和解决一维牛顿第二定律所需的基本信息。

newtonSecond1D :: Mass
               -> [State1D -> Force]  -- force funcs
               -> State1D             -- current state
               -> (R,R,R)             -- deriv of state
newtonSecond1D m fs (t,x0,v0)
    = let fNet = sum [f (t,x0,v0) | f <- fs]
          acc = fNet / m
      in (1,v0,acc)

解决力学问题的一般策略

我们构建和求解牛顿第二定律的策略包括通过一系列五种不同的形式来转化有关物理情况的信息:

  1. 质量和力函数

  2. 微分方程

  3. 状态更新函数

  4. 状态列表

  5. 位置和速度函数

信息从我们考虑的物体的质量以及作用在其上的力开始,这些力是状态变量的函数。函数newtonSecond1D将这些质量和力信息转化为微分方程。微分方程是一个函数State1D -> (R,R,R),它给出了时间、位置和速度的状态变量的导数,表示为状态变量本身。欧拉法将微分方程转化为状态更新函数,即函数State1D -> State1D,该函数根据早期时刻的状态变量计算后期时刻的状态变量。从状态更新函数和初始状态开始,我们可以计算出一个无限长的状态列表,这是描述我们物理情况的第四种数据表示。最后,我们可以从状态列表中提取时间和速度作为时间的函数。预测物体位置和速度作为时间的函数是最终的数据表示,我们将其视为理解物体运动问题的解。

图 15-2 是一个功能图,展示了上述五种数据表示以及将数据从一种表示转换为另一种表示的函数。

Image

图 15-2:在一维中求解力学问题的数据流。函数通过五种表示的序列转换数据。

我们已经讨论了newtonSecond1D如何将质量和力数据转化为微分方程。我们稍后将详细讨论其他转换。图 15-2 是本章和接下来几章中的第一张图,旨在概述解决力学问题的过程。随着我们扩展和概括状态和数值方法的思想,这些图将变得更加通用、更加简洁,并且有些抽象。它们的目的是,在深入理解牛顿力学的过程中,建议一种高级思维方式来思考牛顿定律的含义和解决技巧。这些图将我们编写的众多函数组织成一个连贯的、可操作的描述,说明如何用牛顿力学进行预测。理解求解牛顿第二定律所需的步骤是对这一法则意义的更深刻理解的先决条件。

请注意,在图 15-2 中有两个地方需要额外的信息。欧拉法需要一个时间步长,将微分方程转化为状态更新函数,同时需要一个初始状态,将状态更新函数转化为状态列表。在这张图中,我认为物体的质量和所受的力是问题初始信息的一部分。

使用欧拉法求解

为了解决微分方程 15.2、15.3 和 15.4,我们将对时间进行离散化,选择一个比问题中任何重要时间尺度都小的时间步长Δt。我们将使用欧拉法,通过该方法在一个时间步内,利用该状态变量在时间步开始时的时间导数来近似每个状态变量的时间斜率。图 15-3 展示了欧拉法如何用于求解牛顿第二定律的一维形式。

Image

图 15-3:一维欧拉法求解牛顿第二定律

与图 15-1 中值随时间连续变化的示意图不同,图 15-3 中的功能图的值保持不变。一个量(如位置)随时间变化的过程通过不同电线上的一系列值来表示。

下述函数euler1D将微分方程转化为状态更新函数,如图 15-2 所示。为此,它除了微分方程外,还将时间步长作为输入。每个状态变量通过改变其值,由其导数(从微分方程中计算得到)与时间步长的乘积来更新。

euler1D :: R                     -- time step dt
        -> (State1D -> (R,R,R))  -- differential equation
        -> State1D -> State1D    -- state-update function
euler1D dt deriv (t0,x0,v0)
    = let (_, _, dvdt) = deriv (t0,x0,v0)
          t1 = t0 + dt
          x1 = x0 + v0 * dt
          v1 = v0 + dvdt * dt
      in (t1,x1,v1)

图 15-3 不断地组合状态更新函数。我们将这个状态更新函数称为updateTXV,因为它更新时间、位置和速度。如前所述,接下来我们将介绍这个状态更新函数的三种表示方式。图 15-4 展示了状态更新函数的功能图,说明了如何从一个旧的三元组生成一个新的时间-位置-速度三元组。

Image

图 15-4:当一个小的时间间隔 Δ*t 已经过去时,如何更新状态变量时间、位置和速度

现在我们将给出数学符号表示的状态更新方程。状态更新方程告诉我们如何更新状态变量——时间、位置和速度——以便进入下一个时间步。

Image

最后,我们定义了 Haskell 函数updateTXV,它从旧的状态三元组生成新的状态三元组。

updateTXV :: R                   -- time interval dt
          -> Mass
          -> [State1D -> Force]  -- list of force funcs
          -> State1D -> State1D  -- state-update function
updateTXV dt m fs = euler1D dt (newtonSecond1D m fs)

请注意,updateTXV本质上是newtonSecond1Deuler1D的组合,因此它将质量和力数据(我们五种数据表示中的第一种)转化为状态更新函数(五种中的第三种)。

生成状态列表

数据流的下一步是根据状态更新函数和初始状态生成无限的状态列表,参见图 15-2。Haskell Prelude 函数iterate可以做到这一点,本质上将图 15-4 转换为图 15-3。时间-位置-速度三元组的列表可以视为牛顿第二定律问题的解决方案。这个状态列表包含了每次通过欧拉方法(见图 15-3)探测到的时间-位置-速度三元组。函数statesTXV在给定时间步长、质量、初始状态和力函数列表时,生成一个状态列表。

statesTXV :: R                   -- time step
          -> Mass
          -> State1D             -- initial state
          -> [State1D -> Force]  -- list of force funcs
          -> [State1D]           -- infinite list of states
statesTXV dt m txv0 fs = iterate (updateTXV dt m fs) txv0

我们在这里做的就是迭代更新函数,以生成一个无限状态列表。请注意,我们需要在updateTXV之前将时间步长、质量和力列表作为参数传入,才能使其成为一个可迭代的函数,可以传递给iterateIterate要求一个类型为a -> a的函数,该函数可以反复应用。

函数statesTXV将质量和力的数据(我们五个数据表示中的第一个),以及一个时间步长和初始状态,转换为一个无限的状态列表(我们五个数据表示中的第四个)。通过这个函数,我们就有了一种通用的方法来解决任何一维空间中的牛顿第二定律问题。这里的“解决方案”是指一个无限的状态列表(时间-位置-速度三元组),这些状态彼此之间的时间间隔为一个时间步长。从这个无限的状态列表中,我们可以提取出我们最感兴趣的数据,并将其绘制成图表或动画。

位置和速度函数

对于我们的第五个也是最后一个数据表示,我们希望编写一个函数velocity Ftxv,类似于velocityCFvelocityFtvelocityFvvelocityFtv,但针对依赖于时间、位置和速度的力。这个函数将把质量和力的数据(我们五个数据表示中的第一个),以及一个时间步长和初始状态,转换成一个速度函数(我们五个数据表示中的第五个部分)。

为了帮助我们做到这一点,我们想编写一个函数velocity1D,它将从一个无限状态列表(我们五个数据表示中的第四个)转换成一个速度函数。

-- assume that dt is the same between adjacent pairs
velocity1D :: [State1D]         -- infinite list
           -> Time -> Velocity  -- velocity function
velocity1D sts t
    = let (t0,_,_) = sts !! 0
          (t1,_,_) = sts !! 1
          dt = t1 - t0
          numSteps = abs $ round (t / dt)
          (_,_,v0) = sts !! numSteps
      in v0

我们将无限状态列表命名为sts,并将目标时间命名为t。我们假设在整个无限状态列表中,时间步长保持不变,并通过列表中前两个状态的时间值来计算时间步长。在let子句的前两行中,我们使用列表索引运算符(!!)提取出列表中的第一个和第二个状态。由于State1D(R,R,R)的类型同义词,我们使用模式匹配来提取状态中的时间。局部变量t0t1分别是列表中前两个状态的时间。

let语句中的第三行定义了一个局部变量dt,表示时间步长,计算方法是第一个和第二个状态的时间差。let语句中的第四行计算了达到最接近目标时间的状态所需的时间步数,并将这个步数命名为局部变量numStepslet语句中的第五行使用列表索引运算符来选出最接近目标时间的状态,然后通过模式匹配将该状态的速度命名为v0

这个函数的实际工作都在let语句中完成。该函数返回v0,即最接近目标时间t的速度。

对于函数velocityFtxv,我们将使用由statesTXV生成的无限列表,结合velocity1D进行完整的转换。

velocityFtxv :: R                   -- time step
             -> Mass
             -> State1D             -- initial state
             -> [State1D -> Force]  -- list of force funcs
             -> Time -> Velocity    -- velocity function
velocityFtxv dt m txv0 fs = velocity1D (statesTXV dt m txv0 fs)

一旦我们得到速度函数,我们可以通过积分得到位置函数,但所有这些位置信息都包含在我们的状态列表中,所以我们可以直接从中提取位置函数,方法是使用position1D,它与velocity1D非常相似。

-- assume that dt is the same between adjacent pairs
position1D :: [State1D]           -- infinite list
           -> Time -> Position    -- position function
position1D sts t
    = let (t0,_,_) = sts !! 0
          (t1,_,_) = sts !! 1
          dt = t1 - t0
          numSteps = abs $ round (t / dt)
          (_,x0,_) = sts !! numSteps
      in x0

这是函数positionFtxv,它将我们从初始数据表示(通过质量和力表示)以及时间步长和初始状态,转换为最终的表示,给出物体位置随时间变化的函数。

positionFtxv :: R                   -- time step
             -> Mass
             -> State1D             -- initial state
             -> [State1D -> Force]  -- list of force funcs
             -> Time -> Position    -- position function
positionFtxv dt m txv0 fs = position1D (statesTXV dt m txv0 fs)

使用velocityFtxvpositionFtxv这两个函数,我们就有了一种通用的方法来解决任何一维空间中的牛顿第二定律问题。

接下来让我们看一个使用这种技术的例子:一个乒乓球在弹簧玩具的末端摆动。弹簧对乒乓球的恢复力取决于乒乓球的位置,而空气阻力则取决于乒乓球的速度。

一个受阻尼的简谐振动器

作为一个位置和速度相关力的例子,考虑一个受阻尼的简谐振动器。特别地,考虑一个乒乓球在垂直悬挂的弹簧玩具末端摆动的情况。弹簧玩具是一种由金属或塑料制成的弹簧,被当作儿童玩具出售。我们选择一个坐标系,其中向上为正,并选择位置的零点为弹簧玩具下端没有乒乓球时的挂点。乒乓球的质量为 2.7 克,半径为 2 厘米。

我们将考虑作用在乒乓球上的三个力,所有力都垂直作用。第一个力来自弹簧,作用于恢复物体至平衡位置。弹簧力由胡克定律给出,

Image

该公式声称弹簧产生的力与质量从平衡位置的位移x[0]成正比。常数k称为弹簧的弹簧常数。具有较大弹簧常数的弹簧较为坚硬,需要较大的力来拉伸或压缩。负号使弹簧力成为恢复力,其作用方向是将物体恢复到平衡位置。

平衡位置为x[0] = 0。如果x[0]为正,则Fspring 为负,力的方向是指向平衡位置。如果x[0]为负,则Fspring 为正,力的方向同样指向平衡位置。

弹簧力只依赖于球体的位置,而不依赖于它的速度或时间。但考虑到我们想使用一个类似statesTXVpositionFtxv的函数,它需要一个作为状态函数的力列表State1D -> Force,我们将按此形式编写胡克定律。

springForce :: R -> State1D -> Force
springForce k (_,x0,_) = -k * x0

第二种力是空气阻力,它会阻碍弹簧上球体的自然振动。我们将使用方程 14.9 来表示空气阻力,公式如下。

Image

我们使用的阻力系数为 2。

第三种力是作用在球体上的重力。在地球表面,质量为m的物体所受的重力为

F[g] = –mg

其中g = 9.80665 米/秒²是重力加速度,并且我们采用一个坐标系统,其中“远离地球中心”为正方向。

我们假设弹簧的弹簧常数为 0.8 kg/s²。我们将在距离平衡位置 10 厘米的地方释放球体,意味着x(0 秒) = 0.1 米,v(0 秒) = 0 米/秒。

在我们使用 Haskell 函数来研究球体的运动之前,让我们先通过手动计算欧拉方法来研究它。

手动计算欧拉方法

我们希望清晰地理解计算机在应用欧拉方法时的处理过程。为此,我们将手动计算欧拉方法的几个步骤(即用计算器),以便详细了解发生了什么。

我们将使用状态更新方程 15.6 和 15.7,时间步长为Δt = 0.1 秒。0.1 秒的时间步长对于这个问题来说过大,无法获得精确的结果,但它有助于展示欧拉方法的核心思想。

我们的任务是完成以下表格。由于时间值是以 0.1 秒的间隔均匀分布的,因此我们可以填入所有时间值。我们还将填入位置和速度的初始值。

t (秒) x(t) (米) v(t) (米/秒)
0.0 0.1000 0.0000
0.1
0.2
0.3

乒乓球所受的合力由以下表达式给出:

Image

使用t = 0.0 s,x(0.0 s) = 0.1000 m 和v(0.0 s) = 0.0000 m/s(换句话说,第一行的表格信息),代入状态更新方程 15.6 和 15.7,我们可以求出x(0.1 s)和v(0.1 s)(表格第二行的信息)。状态更新方程正是我们需要的,用来根据现有的一行数据生成新的一行数据。

Image

使用t = 0.1 s,x(0.1 s) = 0.1000 m,以及v(0.1 s) = –3.9436 m/s(我们表格第二行的信息),代入状态更新方程,我们可以求出x(0.2 s)和v(0.2 s)(我们表格第三行的信息)。

Image

使用t = 0.2 s,x(0.2 s) = –0.2944 m,以及v(0.2 s) = –7.0005 m/s(我们表格第三行的信息),代入状态更新方程,我们可以求出x(0.3 s)和v(0.3 s)(我们表格第四行的信息)。

Image

完整的表格如下所示:

t (s) x(t) (m) v(t) (m/s)
0.0 0.1000 0.0000
0.1 0.1000 –3.9436
0.2 –0.2944 –7.0005
0.3 –0.9945 3.5359

由于状态是时间-位置-速度三元组,完成的这个表格包含了使用欧拉方法和 0.1 s 时间步长得到的乒乓球的前四个状态。如果我们让计算机使用我们的statesTXV函数生成无限长的状态列表,那么前四个状态应当是这样的。我们可以想象,完成这个表格的过程就是计算机反复执行的操作,用来为我们生成一个状态列表。

现在,让我们回到本章前面开发的技术,来找出乒乓球的位置随时间的变化。

方法 1:生成状态列表

在这里,我们将使用statesTXV函数生成状态列表,然后提取位置与时间的信息来绘制图表。

我们不需要给作用在乒乓球上的力列表命名。我们可以将这个列表作为适当的输入传递给statesTXV,但我认为给这个力列表命名可能有助于我们的思考并使其更有条理。我们就把它称作dampedHOForces

dampedHOForces :: [State1D -> Force]
dampedHOForces = [springForce 0.8
                 ,\(_,_,v0) -> fAir 2 1.225 (pi * 0.02**2) v0
                 ,\_ -> -0.0027 * 9.80665
                 ]

我们看到这是一份我们之前讨论的三个力的列表。首先是弹簧力,然后是空气阻力,最后是重力。我们使用匿名函数符号来表示后两个力,因为它们需要表示为状态的函数。对于重力,我们完全不关心状态,因此不需要指定状态或组成状态的时间、位置和/或速度变量。对于空气阻力,状态中的时间和位置条目下划线提醒我们,空气阻力函数并不需要它们的值。

为了生成无限的状态列表,我们使用statesTXV函数,时间步长为 1 毫秒,即 0.001 秒,这在接受范围内,因为更小的时间步长只会导致图形结果有细微差别。

dampedHOStates :: [State1D]
dampedHOStates = statesTXV 0.001 0.0027 (0.0,0.1,0.0) dampedHOForces

我们需要将所有关于我们问题的信息传递给dampedHOStates。除了 0.001 秒的时间步长外,我们还需要传递乒乓球的质量 0.0027 kg,初始状态为时间 0 秒、位置 0.1 米和速度 0 米/秒,以及我们命名为dampedHOForces的力的列表。

如果你想查看原始的时间-位置-速度数据,可以使用列表元素操作符(!!)来选择列表中的特定状态,或者你可以take这个无限列表中的前几个元素。

Prelude> :l Mechanics1D
[1 of 2] Compiling Newton2         ( Newton2.hs, interpreted )
[2 of 2] Compiling Mechanics1D     ( Mechanics1D.hs, interpreted )
Ok, two modules loaded.
*Mechanics1D> dampedHOStates !! 0
(0.0,0.1,0.0)
*Mechanics1D> dampedHOStates !! 5
(5.0e-3,9.960571335911717e-2,-0.1970379672671094)
*Mechanics1D> take 2 dampedHOStates
[(0.0,0.1,0.0),(1.0e-3,0.1,-3.943627962962963e-2)]

一对对的数据是我们可以使用gnuplot包中的plotPath函数绘制的内容,但在绘制之前,我们需要将列表截断为有限的列表;否则,plotPath会在计算无限列表时挂起。接下来的代码中,我们使用take函数提取前 3000 个状态,表示运动的前三秒。

dampedHOGraph :: IO ()
dampedHOGraph
    = plotPath [Title "Ping Pong Ball on a Slinky"
               ,XLabel "Time (s)"
               ,YLabel "Position (m)"
               ,PNG "dho.png"
               ,Key Nothing
               ] [(t,x) | (t,x,_) <- take 3000 dampedHOStates]

图 15-5 显示了球随时间变化的振荡情况。注意,振荡并没有以位置零为中心。这是因为零是没有附着球的弹簧的平衡位置。当我们附上乒乓球时,它的重量会拉伸弹簧向下,形成一个新的平衡位置,在此位置 – k[x0] – mg = 0。弹簧的向上力与重力的向下力相互抵消;空气阻力在新的平衡位置中不起作用,因为球在平衡时并未移动。新的平衡位置是 x[0] = –mg/k = –0.033 m,因此振荡围绕此位置进行,如你在图 15-5 中所见。

图片

图 15-5:乒乓球在弹簧末端的振荡

在探索了使用状态列表作为解决牛顿第二定律的一种数据表示方式后,让我们来看另一种表示方式,即位置和速度函数。

方法二:生成位置和速度函数

我们可以使用positionFtxv函数为乒乓球在弹簧上的运动生成一个位置函数。

pingpongPosition :: Time -> Velocity
pingpongPosition = positionFtxv 0.001 0.0027 (0,0.1,0) dampedHOForces

描述此情况所需的所有信息都包含在构成此函数主体的那一行中:0.0027 kg 的质量;初始状态为时间 0 秒、位置 0.1 米和速度 0 米/秒;以及之前定义的三种力的列表dampedHOForces

以下代码将生成与图 15-5 中所示非常相似的图表。

dampedHOGraph2 :: IO ()
dampedHOGraph2
    = plotFunc [Title "Ping Pong Ball on a Slinky"
               ,XLabel "Time (s)"
               ,YLabel "Position (m)"
               ,Key Nothing
               ] [0,0.01..3] pingpongPosition

我们可以使用velocityFtxv函数为乒乓球在弹簧上的运动生成一个速度函数。

pingpongVelocity :: Time -> Velocity
pingpongVelocity = velocityFtxv 0.001 0.0027 (0,0.1,0) dampedHOForces

如之前所述,我们可以绘制我们的函数图形:

dampedHOGraph3 :: IO ()
dampedHOGraph3
    = plotFunc [Title "Ping Pong Ball on a Slinky"
               ,XLabel "Time (s)"
               ,YLabel "Velocity (m/s)"
               ,PNG "dho2.png"
               ,Key Nothing
               ] [0,0.01..3] pingpongVelocity

这段代码生成了图 15-6,图中展示了乒乓球的速度与时间的关系。由于我们将球从静止状态释放,速度从零开始,随着球向下运动,速度变为负值。速度发生振荡,并且表现出与位置相同的阻尼效应。

Image

图 15-6:一只弹簧球上的乒乓球的速度

欧拉方法是一种通用的基于状态的求解一阶常微分方程组(或等价于单一高阶微分方程)的方法。然而,欧拉方法通常无法充分发挥计算效率,因为为了得到可接受的结果,时间步长通常需要非常小。有许多其他方法可以选择,我们将在下一节中探讨对欧拉方法的小改动,这通常能够在较大的步长下得到可接受的结果,从而减少计算成本。

欧拉-克罗默方法

我们可以对欧拉方法做一个小的修改,从而在许多情况下改善牛顿第二定律计算的结果。我们不再使用描述欧拉方法的功能图图 15-3,而是来看一下欧拉-克罗默方法的功能图,如图 15-7 所示。

Image

图 15-7:一维牛顿第二定律的欧拉-克罗默方法

差异在于用来更新位置的速度值。欧拉方法使用旧的速度值来更新位置。欧拉-克罗默方法使用与欧拉方法相同的速度更新方程计算新的速度,然后用这个新速度来更新位置。

欧拉-克罗默方法使用了以下略微修改过的方程,而不是欧拉方法中的状态更新方程 15.6 和 15.7:

Image

欧拉-克罗默方法的速度更新方程与欧拉方法的速度更新方程相同。不同之处在于在位置更新方程中将v(t)替换为v(t + Δt)。尽管欧拉方法中的方程 15.6 和 15.7 的求值顺序无关紧要,但欧拉-克罗默方法中的速度更新方程必须在位置更新方程之前求值,因为更新后的速度将用于后者方程。

然而,使用具有不变对象引用的函数式编程语言的一个好处是,我们不需要担心告诉计算机先更新速度。我们可以按照任何顺序排列方程,编译器会自动确定合适的求值顺序。以下函数在欧拉-克罗默方法中起到了和euler1D在欧拉方法中所起的作用:

eulerCromer1D :: R                     -- time step dt
              -> (State1D -> (R,R,R))  -- differential equation
              -> State1D -> State1D    -- state-update function
eulerCromer1D dt deriv (t0,x0,v0)
    = let (_, _, dvdt) = deriv (t0,x0,v0)
          t1 = t0 + dt
          x1 = x0 + v1 * dt
          v1 = v0 + dvdt * dt
      in (t1,x1,v1)

在这段代码中,我使用了局部变量 v1,它是更新后的速度值,先给出如何计算它的方程。编译器会知道如何安排计算顺序,以便在使用 v1 之前先计算出它。

函数 updateTXVEC 是对应欧拉的 updateTXV 的欧拉-克罗默版本的状态更新函数。

updateTXVEC :: R                   -- time interval dt
            -> Mass
            -> [State1D -> Force]  -- list of force funcs
            -> State1D -> State1D  -- state-update function
updateTXVEC dt m fs = eulerCromer1D dt (newtonSecond1D m fs)

欧拉-克罗默方法在应该保持能量守恒的情况下更接近能量守恒,且通常对于具有振荡行为的情况更为适用。无论是欧拉方法还是欧拉-克罗默方法,当时间步长减小时,两者都会收敛到正确的结果,但欧拉-克罗默方法通常能在比欧拉方法所需的更大时间步长下也能得到可接受的结果。

图 15-7 显示了与欧拉-克罗默方法对应的 图 15-1 的另一种展开方式。比较 图 15-3(描述欧拉方法)和 图 15-7,我们可以看到,唯一的区别在于使用更新后的速度来更新位置。

函数 statesTXVvelocityFtxvpositionFtxv 使用了函数 updateTXV,这意味着它们使用的是欧拉方法。在习题 15.13 中,你需要为欧拉-克罗默方法编写类似的函数。

请注意,欧拉-克罗默方法是特定于二阶微分方程的,因为必须有一个状态变量扮演速度的角色,可以先更新它,然后用来更新主要未知函数(对于牛顿第二定律而言是位置)。

随着我们在接下来的几章中继续研究力学,我们将继续解微分方程。本章的最后一节通过更普遍地处理微分方程求解的过程,为未来章节的内容做准备。

解微分方程

一个典型的力学问题从物理问题开始,我们利用物理信息构建微分方程,然后变成数学问题,通过解微分方程得到结果,最后再变成物理问题,通过解释结果来得到物理意义。本节聚焦于数学活动,从微分方程开始,到最后掌握状态变量如何随自变量(在力学中代表时间)变化。

在 图 15-2 中,我们展示了在解决力学问题的过程中信息是如何转化的。从质量和力的信息开始,牛顿第二定律产生了一个微分方程。然后,欧拉方法将该微分方程转化为状态更新函数。通过迭代状态更新函数,给定初始状态,我们可以得到一系列状态。这些状态序列可以看作是问题的解,或者我们可以进行一个附加步骤,得出物体的位移和速度函数。

在接下来的几章中,我们将使用向量来描述像速度这样的量,并处理多个相互作用的粒子,我们将继续将求解力学问题的过程视为信息转化的过程,就像在图 15-2 中那样。为此,我们需要以两种方式对图 15-2 进行推广。euler1D方法将适用于任何使用状态空间State1D的微分方程。在前一章中,我们使用了Velocity(Time,Velocity)作为状态空间,在接下来的章节中,我们将继续扩展我们使用的状态空间,包括向量和多个粒子。我们希望能够使用我们设计的新状态空间来应用欧拉方法,并且如果我们能够编写一次通用的欧拉方法,使其适用于任何状态空间,那将是非常棒的。在本节后面,我们将找出这些状态空间之间的共性,这使得我们能够做到这一点。因此,第一个推广是从状态空间State1D到更广泛的状态空间类别。

图 15-2 使用欧拉方法将微分方程转化为状态更新函数。现在我们引入了欧拉-克罗默方法,我们有了两种数值方法,每种方法都可以执行这种转化。第二个推广是从欧拉方法到其他数值方法。我们希望我们的信息转化过程能够支持我们希望使用的任何数值方法。

现在让我们来探讨如何推广状态空间的问题。

推广状态空间

为了将状态空间从State1D推广到其他可能的状态空间,我们将使用类型变量s来表示状态的数据类型。如果我们能够编写以s而非State1D表示类型的函数,那么我们就可以在任何状态空间中使用这些函数。当我们推广Velocity(Time,Velocity)State1D时,类型s将包含任何物理系统所需的状态变量。

在图 15-2 中,基于状态空间State1D的微分方程,我们希望得到基于状态空间s的微分方程。在图 15-2 中,基于状态空间State1D的状态更新函数,我们希望得到基于状态空间s的状态更新函数。euler1D是一个数值方法,将基于State1D的微分方程转化为基于State1D的状态更新函数,我们希望能够讨论和编写将基于状态空间s的微分方程转化为基于s的状态更新函数的数值方法。

精确地说,我们将通过编写类型同义词,给出状态空间s的微分方程、状态更新函数和数值方法的形式定义。我们将从状态更新函数开始,因为它是三者中最简单的。

由于状态更新函数(例如 updateTXV dt m fs)会生成与输入状态具有相同类型的新状态,因此用于状态空间 s 的状态更新函数是一个 s -> s 的函数。这个定义可以通过类型同义词来表示。

type UpdateFunction s = s -> s

UpdateFunction s 类型是用于与状态空间 s 配合使用的状态更新函数类型。

表 15-1 显示了我们在上一章和本章中使用的状态更新函数。

表 15-1: 适用于不同状态空间的状态更新函数

状态更新函数 类型
updateVelocity dt m fs UpdateFunction Velocity
updateTV dt m fs UpdateFunction (Time,Velocity)
updateTXV dt m fs UpdateFunction State1D

请注意,对于每个函数,我们必须提供时间步长、质量和一个力函数列表,之后结果表达式才会具有表格右侧所示的类型。

微分方程以状态作为输入,并生成每个状态变量的导数作为输出。在状态空间 State1D 中,输入由时间、位置和速度组成,而输出则是数字、速度和加速度。

时间与无量纲数值不同,位置与速度不同,速度与加速度不同。然而,这些在 State1D 中都是实数,因此状态空间也可以写作 (R,R,R)。在前面提到的 euler1D 函数中,我使用了类型 State1D -> (R,R,R) 来表示微分方程,并使用类型 State1D -> State1D 来表示状态更新函数。对编译器而言,这些是相同的类型。我之所以这样写,是因为数字、速度和加速度并不是属于 State1D 中的量。

为了处理状态变量与其时间导数之间的差异,我们将使用一个类型变量 ds 来表示状态变量的时间导数。就像类型变量 s 用于状态一样,类型变量 ds 用于状态的时间导数。

微分方程在 Haskell 中的表示方式是一个函数,给定一组状态变量时,它会返回一组状态变量的导数。如果 s 是状态的数据类型,而 ds 是状态的时间导数数据类型,那么微分方程的定义可以通过类型同义词给出。

type DifferentialEquation s ds = s -> ds

DifferentialEquation s ds 类型是适用于状态空间 s 和状态空间时间导数 ds 的微分方程类型。

表 15-2 显示了我们在上一章和本章中使用的微分方程。

表 15-2: 适用于不同状态空间的微分方程

微分方程 类型
newtonSecondV m fs DifferentialEquation Velocity R
newtonSecondTV m fs DifferentialEquation (Time,Velocity) (R,R)
newtonSecond1D m fs DifferentialEquation State1D (R,R,R)

请注意,对于每个函数,我们必须提供质量和力函数列表,才能使结果表达式具有表右侧所示的类型。

数值方法将微分方程转化为状态更新函数。状态空间s和导数空间ds的数值方法定义可以通过类型同义词给出。

type NumericalMethod s ds = DifferentialEquation s ds -> UpdateFunction s

类型NumericalMethod s ds是与状态空间s和时间导数空间ds一起使用的数值方法的类型。

虽然微分方程本身是一个数学上精确的表达式,但应用数值方法来求解它必然涉及到近似。到目前为止,我们已经见过两种数值方法:欧拉方法和欧拉-克罗默方法。然而,在求解微分方程时还有许多数值方法可供选择。

由于存在许多数值方法,每种方法会导致不同的近似结果,因此将某个特定的数值方法嵌入到构成我们思考如何解决力学问题的基础理念和代码中并不合理。我们应该能够自由选择数值方法,与我们所求解的微分方程无关。我们希望将数值方法与微分方程分开。

表 15-3 显示了我们在本章中使用过的数值方法。在上一章中,我们从未编写过明确的数值方法,而是将欧拉方法的代码直接放入状态更新函数中。

表 15-3: 与 State1D 状态空间一起使用的数值方法

数值方法 类型
euler1D dt NumericalMethod State1D (R,R,R)
eulerCromer1D dt NumericalMethod State1D (R,R,R)

请注意,对于每个函数,我们必须提供时间步长,才能使结果表达式具有表右侧所示的类型。

给定一个微分方程和一个数值方法,我们可以通过应用该数值方法来求解微分方程,得到一个状态更新函数,然后迭代该状态更新函数生成状态列表。以下函数接收数值方法、微分方程和初始状态作为输入,输出一个状态列表。我们可以将该函数视为一个通用的微分方程求解器。

solver :: NumericalMethod s ds -> DifferentialEquation s ds -> s -> [s]
solver method = iterate . method

我们之前提到,求解微分方程是解决力学问题的数学部分,在此过程中,我们通过状态更新函数将微分方程转化,最终得到状态列表。函数solver完成了求解微分方程的整个数学过程。换句话说,它处理了求解力学问题的数学部分。

我们已经给出了微分方程、状态更新函数和数值方法的定义,每个方法都与状态空间s和时间导数空间ds配合使用。现在,我们转向编写一个通用函数euler,它可以作为几乎任意状态空间s的数值方法。

状态空间的类型类

状态空间类型s和时间导数空间ds不能是任意数据类型。为了执行欧拉方法和其他数值方法,我们需要能够对时间导数空间ds的元素进行加法运算,并且希望能够通过时间步长对元素进行缩放。一个支持状态加法和通过实数对状态进行标量乘法的空间称为实向量空间

为了表达我们的时间导数空间是一个实向量空间的约束,我们定义了一个类型类。

class RealVectorSpace ds where
      (+++) :: ds -> ds -> ds
      scale :: R -> ds -> ds

这段代码定义了一个新的类型类RealVectorSpace,该类包含两个函数:(+++)用于加法,scale用于标量乘法。从类型签名可以看出,加法函数接受两个状态导数作为输入,输出一个状态导数,而标量乘法函数接受一个实数和一个状态导数作为输入,输出一个状态导数。

对于我们希望使用的每个导数空间,我们将编写一个实例声明,明确该数据类型的加法和标量乘法应如何定义。对于类型(R,R,R),这是与状态空间State1D相关联的导数空间,以下是实例声明:

instance RealVectorSpace (R,R,R) where
    (dtdt0, dxdt0, dvdt0) +++ (dtdt1, dxdt1, dvdt1)
        = (dtdt0 + dtdt1, dxdt0 + dxdt1, dvdt0 + dvdt1)
    scale w (dtdt0, dxdt0, dvdt0) = (w * dtdt0, w * dxdt0, w * dvdt0)

我们使用局部变量dxdt0来提醒我们这个名称代表的是一个量,表示位置相对于时间的导数。实例声明定义了两个三元组相加意味着对每对对应元素进行相加;它定义了通过一个实数对三元组进行缩放意味着对三元组中的每个元素都进行该实数的缩放。

我们还将使用一个类型类来声明一个必须在状态空间s和其导数空间ds之间保持的关系。这个关系描述了如何使用ds中状态变量的时间导数来推进状态空间s中的状态变量。我们将该类型类命名为Diff,以提醒我们它是与状态空间s和其导数空间ds之间的微分(即时间导数)相关的。

class RealVectorSpace ds => Diff s ds where
    shift :: R -> ds -> s -> s

这个类型类定义包含一个类型类约束,要求ds必须是一个RealVectorSpace。类型类Diff包含函数shift。对于我们希望使用的每对类型作为状态空间和导数空间,我们将提供一个实例声明,定义函数shift如何处理这两个空间的值。函数shift描述了如何使用导数状态的信息来推进一个状态。函数shift接受一个时间步长、一个状态导数和一个状态作为输入,输出一个新的状态。

一种用于关联两种类型,而非声明单一类型成员的类型类被称为多参数类型类。我们在本章编写的模块的引言代码中的第二行启用了一个名为MultiParamTypeClassesLANGUAGE特性,以允许使用多参数类型类。默认情况下,它们是禁用的。

以下实例声明通过为State1D(R,R,R)定义函数shift,声明了它们之间的微分关系:

instance Diff State1D (R,R,R) where
    shift dt (dtdt,dxdt,dvdt) (t,x,v)
        = (t + dtdt * dt, x + dxdt * dt, v + dvdt * dt)

函数shift表示,为了更新时间,我们应该将时间步长dt与时间变化的速率dtdt(即 1)相乘。它表示,为了更新位置,我们应该将时间步长dt与位置变化速率dxdt相乘,更新速度时也应类似处理。时间、位置和速度来自状态,而变化率则来自状态导数。

我们看到shift看起来很像欧拉方法。事实上,它比那更基本。shift函数将被欧拉方法使用,但也会被其他数值方法使用。

这是欧拉方法的通用版本,适用于任何基于任何状态空间的微分方程。

euler :: Diff s ds => R -> (s -> ds) -> s -> s
euler dt deriv st0 = shift dt (deriv st0) st0

只要数据类型sds通过Diff类型类恰当地关联,也就是说s是状态空间,ds是与s相伴随的导数空间,函数euler将使用状态变量从s进行欧拉方法运算,使用作为deriv传入的微分方程。欧拉方法通过一次调用shift函数来执行,其中导数在当前状态下被评估。

表 15-4 比较了euler1Deuler的类型。

表 15-4: 比较函数euler1D与更通用的函数euler

函数 类型
euler1D :: R -> NumericalMethod State1D (R,R,R)
euler :: Diff s ds => R -> NumericalMethod s ds

函数euler可以在任何可以使用euler1D的地方使用,并且也可以用于其他地方。由于它是使用类型变量编写的,我们可以在未来的章节中使用euler来处理我们创建的状态空间。

我们能否使用欧拉-克罗默方法做同样的事情?也就是说,我们能否一次性编写一个适用于任何状态空间和任何微分方程的数值方法?遗憾的是,答案是否定的。欧拉法是一种通用的解决任何一阶微分方程系统的技术。欧拉-克罗默方法是专门针对二阶微分方程,或者是当我们能够识别出一个量来充当速度时,适用于一阶微分方程系统的方法。我们需要为每个处理的状态空间编写新的欧拉-克罗默函数。

接下来,我们将介绍另一种通用数值方法,它可以替代欧拉方法或欧拉-克罗默方法。

另一种数值方法

在本节中,我们将介绍另一种通用数值方法,称为四阶龙格-库塔方法。数值方法有时会根据阶数进行分类。阶数给出了误差(数值解与精确解之间的差异)与步长的关系。当我们将步长缩小 10 倍时,第一阶求解器的误差缩小约 10 倍,而第二阶求解器的误差则缩小约 10²,或 100 倍。欧拉法和欧拉-克罗梅尔法是第一阶方法,它们的优点是简单;很容易理解它们为何有效。四阶龙格-库塔法则复杂得多。我们不会深入讨论它为何有效或为什么是四阶方法。但它是一种流行的微分方程求解方法,并且它让我们看到了另一种通用方法来求解微分方程。四阶龙格-库塔是一种通用方法,可以用于任何微分方程和状态空间。

以下是四阶龙格-库塔方法的代码:

rungeKutta4 :: Diff s ds => R -> (s -> ds) -> s -> s
rungeKutta4 dt deriv st0
    = let m0 = deriv                 st0
          m1 = deriv (shift (dt/2) m0 st0)
          m2 = deriv (shift (dt/2) m1 st0)
          m3 = deriv (shift  dt    m2 st0)
      in shift (dt/6) (m0 +++ m1 +++ m1 +++ m2 +++ m2 +++ m3) st0

你可以看到这个方法更复杂,但它与 euler 的类型相同,起到的作用也相同:将微分方程转化为状态更新函数。

既然我们有了三种数值方法,接下来让我们在一个已知解的微分方程上进行比较。

数值方法比较

让我们比较我们介绍的三种数值方法。微分方程为

图片

可以用 Haskell 编写如下:

exponential :: DifferentialEquation (R,R,R) (R,R,R)
exponential (_,x0,v0) = (1,v0,x0)

这些微分方程是可以精确求解的,解为

x(t) = Ae^t

v(t) = Ae^t

x(t) = Ae^(”t)

v(t) = –Ae^(–t)

对某些常数 A。如果我们关注初始状态,其中 x(0) = 1 且 v(0) = 1,解为

x(t) = e^t

v(t) = e^t

让我们将 t = 8 时的精确解与我们使用不同步长的三种数值方法得到的近似值进行比较。

solver 函数接受一个数值方法、一个微分方程和一个初始状态,返回一个状态列表。

*Mechanics1D> solver (euler 0.01) exponential (0,1,1) !! 800
(7.999999999999874,2864.8311229272326,2864.8311229272326)
*Mechanics1D> solver (eulerCromer1D 0.1) exponential (0,1,1) !! 80
(7.999999999999988,3043.379244966009,2895.0121485099035)
*Mechanics1D> solver (rungeKutta4 1) exponential (0,1,1) !! 8
(8.0,2894.789038540849,2894.789038540849)

在第一次使用 solver 时,我们使用了步长为 0.01,因此列表中的第 800 项对应于 t = 8。solver 的其他两次使用采用了不同的步长,因此对应 t = 8 的项号也不同。

表 15-5 比较了我们讨论的三种数值方法。你可以看到这些示例计算在表格中的位置。

表 15-5: 欧拉法、欧拉-克罗梅尔法和四阶龙格-库塔法与微分方程的精确解的比较

Δt = 1 Δt = 0.1 Δt = 0.01 Δt = 0.001
精确解 2981 2981 2981 2981
RK4 2895 2981 2981 2981
欧拉-克罗梅尔 2584 3043 2988 2982
欧拉法 256 2048 2865 2969

精确结果不依赖于任何步长,它只是 e⁸。随着步长的减小,所有三种数值方法的结果会越来越接近精确结果。欧拉方法在步长Δt = 0.01 时与精确值的误差为 4%,在步长Δt = 0.001 时为 1%。在Δt = 0.1 时,欧拉-克罗梅方法比Δt = 0.01 的欧拉方法更准确,在Δt = 0.01 时,欧拉-克罗梅方法比Δt = 0.001 的欧拉方法更准确。因此,与欧拉方法相比,我们可以在使用欧拉-克罗梅时将步长增加约 10 倍,并且得到相当的结果。同样,使用四阶龙格-库塔方法时,可以将步长增加约 10 倍,相比欧拉-克罗梅得到相似的结果。

总结

本章完成了上一章开始的一维力学的处理。我们看到了如何处理依赖于位置的力。对于依赖位置的力,牛顿第二定律是一个二阶微分方程,我们将其转换为位置和速度的状态变量的耦合一阶微分方程。我们可以将力学问题的解看作通过五种表示方式的序列转化:质量和力函数、微分方程、状态更新函数、状态列表和位置-速度函数。

一个乒乓球在弹簧玩具末端因空气阻力而发生的振荡是本章的核心示例。我们介绍了欧拉-克罗梅方法,这是一种改进的欧拉方法,用于求解二阶微分方程。我们还介绍了四阶龙格-库塔方法,它与欧拉方法一起,作为一种通用的数值方法,用于求解任何包含状态变量的微分方程。在下一章中,我们将通过将位置、速度和加速度视为向量,开始研究三维力学。

练习

练习 15.1. 让我们通过一个基本的抛体运动问题来热身,我们知道答案应该是什么样子。假设某人从地面上将一个球直上抛起,初速度为 10 m/s。忽略空气阻力,使用函数 positionFtxv 计算球的高度随时间的变化。绘制高度与时间的关系图。

练习 15.2. 在第 254 页手动做欧拉方法时,我们得到了位置和速度的数值表。请展示如何使用 Haskell 函数计算这些值。由于我在手动执行欧拉方法时对中间结果进行了四舍五入,所得值可能与四位小数不完全匹配,但在每种情况下,小数点后的前两位应该是相同的。

练习 15.3.(手动欧拉方法。)考虑以下微分方程:

Image

以及初始条件

Image

使用欧拉法,时间步长为 Δt = 0.1,近似计算 x(0.3) 的值。计算时保留小数点后至少四位。将你的计算结果显示在一个小表格中。该表格将有三列,分别表示时间、位置和速度。

练习 15.4。 编写一个 Haskell 函数

update2 :: (R,R,R)  -- starting state
        -> (R,R,R)  -- ending state
update2 = undefined

它接收一个元组(t[0],x[0],v[0]),并返回一个元组(t[1],x[1],v[1]),用于求解微分方程的欧拉法单步结果。

图片

使用时间步长 Δt = 0.1,并且与前一个练习中相同的初始条件。展示如何使用函数 update2 计算你在前一个练习中手动计算的 x(0.3) 值。

练习 15.5。 考虑一个质量为 3 千克的物体,通过一个弹簧与墙壁连接,弹簧的弹性常数为 100,000 N/m。忽略重力和摩擦力,如果将弹簧拉伸 0.01 米并释放,后续的运动将是什么样子?在多个振荡周期内研究该运动。将你的结果与精确解进行比较。找出一个足够小的时间步长,使得欧拉法解和精确解在图中完全重合。再找出一个足够大的时间步长,使得你能在图中看到欧拉法解与精确解之间的差异。

绘制一个漂亮的图表(带标题、坐标轴标签等),将这三种解(坏的欧拉法、好的欧拉法和精确解)画在同一个图中。标记欧拉法结果所使用的时间步长,并将精确解标记为“精确”。

练习 15.6。 让我们研究从高处掉落物体,特别是研究乒乓球和保龄球。在每种情况下,取 C = 1/2。你需要找出这些球的尺寸和质量等的良好近似值。让我们从 100 米和 500 米的高度将它们释放。绘制速度随时间变化的图和速度随垂直位置变化的图。在每种情况下,达到了终端速度的百分比是多少?以某种有意义且易于理解的方式整理你的结果。

练习 15.7。 返回到练习 15.5 中的谐振子。对于时间步长为 0.001 秒(你会记得这对欧拉法来说不是一个很好的时间步长),将欧拉法和欧拉-克罗梅法的解与精确解进行比较。绘制物体在前 0.1 秒内的位移随时间变化的图。将欧拉法、欧拉-克罗梅法和精确解绘制在同一坐标系上。同时,给出在 t = 0.1 秒时,三种解的物体位置(保留四位有效数字)。

习题 15.8. 考虑一个质量为m的物体,附着在一个弹簧上,弹簧的弹性常数为k。弹簧的另一端固定在垂直墙壁上。物体在地板上水平滑动。物体和地板之间有一个动摩擦系数μ[k] = 0.3。物体的重力是mg,所以物体上的动摩擦力是μ[k] mg,方向与物体的速度相反。

m = 3 kg 和 k = 12 N/m。

(a) 编写一个类型为State1D -> Force的函数,给出动摩擦力的水平分量。你可能需要使用signum函数。

(b) 使用函数positionFtxv来找出物体随时间变化的位置。

(c) 绘制位置随时间变化的图像。

习题 15.9. 在大多数力学问题中,我们关心的物体质量是恒定的。没有必要在状态中包含一个不变的量。然而,由于一些力(如重力)依赖于质量,因此出于方便考虑,将质量包含在状态中是有一定动机的。本章中我们开发的几个函数接受一个力函数列表[State1D -> Force]作为输入。如果我们想将地球的重力作为一个依赖于状态的力,我们需要编写类似以下的代码:

earthGravity :: Mass -> State1D -> Force
earthGravity m _ = let g = 9.80665
                   in -m * g

另一方面,假设我们通过使用以下 4 元组作为状态的数据类型,将物体的质量包含在其状态中。

type MState = (Time,Mass,Position,Velocity)

然后我们可以编写一个地球重力函数,如下所示:

earthGravity2 :: MState -> Force
earthGravity2 (_,m,_,_) = let g = 9.80665
                          in -m * g

请注意,由于质量已包含在状态中,我们不再需要Mass作为函数类型中的额外参数。

编写以下函数的定义,使用MState替代State1D

positionFtxv2 :: R                  -- time step
              -> MState             -- initial state
              -> [MState -> Force]  -- list of force funcs
              -> Time -> Position   -- position function
positionFtxv2 = undefined

statesTXV2 :: R                 -- time step
          -> MState             -- initial state
          -> [MState -> Force]  -- list of force funcs
          -> [MState]           -- infinite list of states
statesTXV2 = undefined

updateTXV2 :: R                  -- dt for stepping
           -> [MState -> Force]  -- list of force funcs
           -> MState             -- current state
           -> MState             -- new state
updateTXV2 = undefined

习题 15.10. 伦纳德-琼斯势能

图片

有时用来模拟原子之间的相互作用。表达式 V[LJ] (r)给出了当两个原子之间的距离为r时的系统潜能。当r → 0 时,潜能变得无限大,表示两个原子非常靠近时的困难。最低的潜能值发生在原子间隔r = r[e]时,这意味着参数r[e]是原子的平衡间隔。参数D[e]表示两原子分子解离能,也就是将两个原子(任意远)分开所需提供的能量。

伦纳德-琼斯力

图片

给出了由另一个原子产生的力,正值表示排斥力,负值表示吸引力。我们可以将 Lennard-Jones 力看作是连接两个原子的非线性弹簧。当原子间的距离大于r[e]时,弹簧提供一种吸引力,试图恢复平衡。当原子间的距离小于r[e]时,弹簧提供一种排斥力,试图恢复平衡。该弹簧是非线性的,因为恢复力与原子间距离的偏差不成正比。

图 15-8 显示了 Lennard-Jones 力作为原子间距离r的函数,以及最接近它的线性弹簧力。

图像

图 15-8:Lennard-Jones 力(曲线)和最接近它的线性力(直线)

对于 Lennard-Jones 力,有效弹簧常数是力与原子间距离的函数的负斜率。

图像

假设我们有一个质量为m的物体,通过 Lennard-Jones 弹簧与墙壁相连。在这个问题中,我们忽略重力和摩擦,因此 Lennard-Jones 力是唯一作用在物体上的力。如果围绕平衡位置的振荡幅度很小,振荡的角频率将接近

图像

并且周期将接近

图像

选择任意参数的r[e]D[e]m

(a) 当初始位置为r = 1.01r[e],初始速度为零时,绘制位置与时间的图形。图形应为振荡型。确认周期接近先前给出的值。

(b) 当初始位置为r = 5r[e],初始速度为零时,绘制位置与时间的图形。现在的周期是多少?

这是一个非简谐振子示例,其中周期取决于振荡的幅度。只有简谐振子的特殊情况中,周期才与幅度无关。

练习 15.11. 手动应用 Euler 方法在第 254 页,我们得到了位置和速度的值表。用手工方式使用 Euler-Cromer 方法生成类似的值表。

练习 15.12. 编写一个函数statesTXVEC,它类似于statesTXV,但使用 Euler-Cromer 方法而不是 Euler 方法。使用该函数检查你在前一个练习中手工计算的值表。

练习 15.13. 编写statesTXVvelocityFtxvpositionFtxv的版本,使用 Euler-Cromer 方法而不是 Euler 方法。

练习 15.14. 展示如何使用 Haskell 函数计算比较表中的数值方法条目。

练习 15.15. 使用以下代码

instance RealVectorSpace (R,R) where
    (dtdt0, dvdt0) +++ (dtdt1, dvdt1) = (dtdt0 + dtdt1, dvdt0 + dvdt1)
    scale w (dtdt0, dvdt0) = (w * dtdt0, w * dvdt0)

instance Diff (Time,Velocity) (R,R) where
    shift dt (dtdt,dvdt) (t,v)
        = (t + dtdt * dt, v + dvdt * dt)

我们已经将数据类型(R,R)做为RealVectorSpace类型类的一个实例,并为类型对(Time,Velocity)(R,R)编写了Diff实例。现在我们可以将(Time,Velocity)用作状态空间,(R,R)用作其导数空间,适用于欧拉法或四阶龙格-库塔方法。编写一个函数

updateTV' :: R                           -- dt for stepping
          -> Mass
          -> [(Time,Velocity) -> Force]  -- list of force funcs
          -> (Time,Velocity)             -- current state
          -> (Time,Velocity)             -- new state
updateTV' = undefined

它的作用与第十四章中的updateTV相同,但使用了本章中的euler函数。

练习 15.16. 牛顿第二定律通常会产生一个二阶微分方程(回顾表 14-1)。我们的DifferentialEquation s ds数据类型用于在给定状态变量时返回状态变量导数的函数。类型为DifferentialEquation s ds的函数表示一组耦合的一阶微分方程。

在这个练习中,我们将一个二阶微分方程重写为两个耦合的一阶微分方程。一个二阶(常规)微分方程有一个自变量和一个因变量(换句话说,一个未知函数)。一组耦合的二个一阶微分方程有一个自变量和两个因变量(自变量的两个未知函数)。

下面是将二阶微分方程转化为一组耦合一阶微分方程的步骤:

  1. 联立方程组的自变量与二阶方程的自变量相同。

  2. 对于联立方程组中的第一个未知函数,选择二阶方程中的未知函数。

  3. 对于联立方程组的第二个未知函数,选择第一个未知函数对自变量的导数,并为此函数命名。

  4. 联立方程组中的第一个微分方程表示第一个未知函数的导数等于第二个未知函数。

  5. 为了形成这一组微分方程中的第二个微分方程,从原始的二阶微分方程开始,将未知函数的第一个导数替换为新的第二个未知函数,将未知函数的第二个导数替换为新的第二个未知函数的导数,并求解新的第二个未知函数的导数。

表示微分方程

Image

作为一组耦合的一阶微分方程。

练习 15.17. 范德波尔振荡器是谐振子的一种推广,常用于探索混沌。它由以下微分方程描述:

Image

我们可以将这个方程视为来自牛顿第二定律,且存在两个力:一个类似弹簧的线性恢复力和一个阻尼力。在这个练习中,我们将放弃国际单位制(SI 单位),将质量和弹簧常数都设为 1。弹簧力则由以下公式给出:

Fspring = –x

并且阻尼力由以下公式给出:

F阻尼 = μ(1 – x²)v

其中μ是一个参数,用于控制阻尼力的非线性程度。如果μ = 0,则范德波尔振荡器退化为简谐振荡器。

在研究混沌时,人们常常喜欢绘制相平面图,这些图是速度与位置的函数图。(它们也可以是动量与位置的函数图,但我们将使用速度。)填写以下代码中未定义的部分,以绘制μ = 0、μ = 2、μ = 4 和 μ = 6 的相平面图,并将它们绘制在同一张图上。

forces :: R -> [State1D -> R]
forces mu = [\(_t,x,_v) -> undefined x
            ,\(_t,x, v) -> undefined mu x v]

vdp :: R -> [(R,R)]
vdp mu = map (\(_,x,v) -> (x,v)) $ take 10000 $
         solver (rungeKutta4 0.01) (newtonSecond1D 1 $ forces mu) (0,2,0)

vdpPhasePlanePlot :: IO ()
vdpPhasePlanePlot = plotPaths [Title "Van der Pol oscillator"
                             ,XLabel "x"
                             ,YLabel "v"
                             ,PNG "VanderPol.png"
                             ,Key Nothing] (undefined :: [[(R,R)]])

结果应类似于图 15-9。

图片

图 15-9:范德波尔振荡器的相平面图

第十六章:三维力学

Image

为了预测一个抛射体、卫星或任何可以在三维空间中自由运动的物体的运动,我们需要使用三维向量来描述速度、加速度和力。在本章中,我们将把在第十章中描述的三维向量与在第十五章中基于状态的求解技术结合起来。

描述物体或粒子的状态仍然是我们预测其未来运动的核心任务。我们将为三维空间中的粒子开发一组适当的状态变量,并定义一个名为ParticleState的新类型来保存它们。

在承认力与状态变量之间的依赖关系时,我们将一体力命名为一个函数,该函数在给定粒子状态时返回一个力矢量。我们给出多个一体力的例子,例如地球表面重力和空气阻力。

求解力学问题是一个通过一系列表示转换信息的过程,开始时是一个一体力列表,然后是一个微分方程,再是一个状态更新函数,最后是一个状态列表。牛顿第二定律表现为从力到微分方程的转换。数值方法将微分方程转化为状态更新函数。从初始状态开始反复执行状态更新函数,最终产生一个状态列表。

本章探讨了允许我们通过一系列表示转换信息来预测运动的基础思想和代码。让我们从一些引导代码开始。

引导代码

在本章以及接下来的两章中,我们将创建一个模块,该模块包含用于设置和求解三维中的牛顿第二定律的思想。在我们开始添加类型签名和函数定义之前,有一些代码需要放在源代码文件的顶部。这段引导代码由四个部分组成:请求警告、请求使用语言选项、模块名称以及我们希望从其他模块导入的类型和函数集合。

{-# OPTIONS -Wall #-}
{-# LANGUAGE MultiParamTypeClasses #-}

module Mechanics3D where

import SimpleVec
    ( R, Vec, PosVec, (^+^), (^-^), (*^), (^*), (^/), (<.>), (><)
    , vec, sumV, magnitude, zeroV, xComp, yComp, zComp, iHat, jHat, kHat)
import Mechanics1D
    ( RealVectorSpace(..), Diff(..), NumericalMethod
    , Time, TimeStep, rungeKutta4, solver )
import SpatialMath
    ( V3(..), Euler(..) )
import Graphics.Gnuplot.Simple
    ( Attribute(..), Aspect(..), plotFunc, plotPaths )
import qualified Graphics.Gloss as G
import qualified Vis as V

与往常一样,我们首先开启警告。然后,我们启用允许多参数类型类的语言选项,就像我们在上一章中做的那样。我们将这个模块命名为Mechanics3D,这将是我们在使用本模块中定义的任何类型或函数时的名称,无论是在独立程序中,还是在后续章节中我们编写的其他模块中。其余的代码是导入语句,表示我们希望使用其他人编写的模块中定义的类型、类型类和函数,或者我们在之前章节中编写的模块中定义的类型和函数。

具体来说,我们从第十章编写的SimpleVec模块中导入了向量运算,另外还从第十五章编写的Mechanics1D模块中导入了一些微分方程求解类型、类型类和函数。我们列出了从SimpleVecMechanics1D模块中导入的每一项名称,而不是直接导入整个模块。这是我偏好的风格,因为它可以显示出我们在模块中使用的每个名称的来源。如果你想导入所有的名称,可以写一行导入语句,使用关键字import后跟模块名称,就像我们在第十五章中做的那样。如果你从多个不同模块导入所有的名称,可能会有一个名称在多个模块中定义,导致编译器报错。这时你需要明确指定希望从哪个模块导入该名称。

在数据类型如Attribute后面的括号中加上两个点(..)表示我们想要导入该数据类型及其所有构造函数。如果省略两个点,则只会导入数据类型的名称。类型类后面的两个点,比如RealVectorSpace,表示我们希望除了导入类型类的名称外,还导入该类型类所拥有的函数。

最后,我们有对Graphics.GlossVis模块的限定导入。第一个限定导入语句将Graphics.Gloss模块的简称G分配给该模块,这样我们就可以通过短名称G加点来访问Graphics.Gloss提供的任何类型或函数。例如,Graphics.Gloss模块中的Picture类型必须以G.Picture来引用。我选择这种限定导入方式的一个原因是,Graphics.GlossVis模块都定义了几个相同的名称,比如simulate。我希望在我编写的代码中使用这两个simulate定义,因此需要一种方式来告诉编译器每次使用时应该选择哪个定义。

完成了我们的入门代码后,让我们来看看三维中的牛顿第二定律。

三维中的牛顿第二定律

方程 14.1 给出了牛顿的第二定律在一维中的表达式。在三维中,位置、速度、加速度和力是通过向量来表示,而不是通过数字。在三维中,物体上的合力是作用在物体上的各个力的向量和:

Image

这是牛顿第二定律在三维中的微分方程形式:

Image

图 16-1 展示了牛顿第二定律在三维中的示意图。由于加速度、速度和位置现在都作为向量处理,因此有两个积分器具有向量输入、向量输出和向量状态。

Image

图 16-1:牛顿第二定律的示意图

这里,力依赖于时间、位置和速度。加速度依赖于净力。速度是加速度的积分,位置是速度的积分。每个积分器下方的类型表示该积分器作为状态所持有的量的类型。输出时间的积分器持有一个实数作为状态,输出位置的积分器持有一个向量作为状态,输出速度的积分器也持有一个向量作为状态。

图示中的两个积分器分别保持速度和位置作为状态,因此至少我们需要将速度和位置作为状态变量。我们在第十四章和第十五章中为了方便而包含了时间作为状态变量,我们将在这里继续这么做。将牛顿第二定律写成一组耦合的一阶微分方程,得到以下方程:

Image

方程 16.3、16.4 和 16.5 包含的信息等同于图 16-1 中的图示。

在介绍了三维的牛顿第二定律之后,现在让我们转向如何在三维空间中描述粒子的状态的问题。

一个粒子的状态

粒子的状态发挥着五个作用。首先,状态指定了预测所需的信息;它是关于系统的当前信息,可以在没有历史信息的情况下进行未来预测(即过去的信息)。第二,状态为一阶微分方程 16.3、16.4 和 16.5 提供了模板,这些方程构成了我们数值逼近方法的起点;每个一阶微分方程表示状态变量之一的时间变化率,表达式中仅涉及状态变量。第三,状态描述了需要通过状态更新函数来更新的信息。第四,状态包含了力所依赖的信息。最后,随时了解状态就是解决牛顿第二定律问题,因为我们想知道关于粒子的任何信息,都是其状态的某个函数。

由于作用在粒子上的力可能依赖于粒子的质量(如引力)或电荷(如洛伦兹力定律,我们将在本章稍后讨论),因此将质量和电荷包含在状态中是方便的。然而,这并不是必需的;质量和电荷在大多数物理情境下保持恒定,因此我们可以将它们视为与状态无关的全局值。但是,将质量和电荷作为状态变量将简化我们的一些代码,并允许某些力仅作为状态的函数,而不是作为状态和一个或多个参数的函数来表达。

我们将用于描述单个粒子状态的ParticleState数据类型定义包括质量、电荷、时间、位置和速度作为状态变量。

data ParticleState = ParticleState { mass     :: R
                                   , charge   :: R
                                   , time     :: R
                                   , posVec   :: Vec
                                   , velocity :: Vec }
                     deriving Show

我们使用记录语法为新数据类型的每个字段提供自己的提取函数(masscharge等)。提取函数也称为消除器或选择器。我们决定创建一个新的数据类型(使用data关键字),而不是类型同义词,以确保该类型不会与任何其他类型混淆。我们希望能够显示该数据类型的值,因此我们希望ParticleState是类型类Show的一个实例。通过包含deriving Show,我们要求编译器自动计算出如何创建一个Show实例。

为了方便起见,我们定义了一个默认的ParticleState,可以用来创建新的粒子状态。

defaultParticleState :: ParticleState
defaultParticleState = ParticleState { mass     = 1
                                     , charge   = 0
                                     , time     = 0
                                     , posVec   = zeroV
                                    , velocity  = zeroV }

defaultParticleState允许我们定义一个粒子状态,而无需显式提供所有五个信息。例如,要指定一个 2 千克的石头状态,电荷为零,位于原点,速度为Image米/秒,我们可以写出以下代码:

rockState :: ParticleState
rockState
    = defaultParticleState { mass    = 2                         -- kg
                           , velocity = 3 *^ iHat ^+^ 4 *^ kHat  -- m/s
                           }

因为我们有默认状态,所以不需要显式地提供与默认值相同的状态变量,如电荷、时间和位置。回想一下,操作符*^用于通过左侧的数字缩放右侧的向量。

牛顿第二定律是根据力函数列表构造微分方程的一个公式。在第十四章和第十五章中,我们看到了力函数的实用性,其中力依赖于粒子状态。我们将定义一体力为依赖于当前粒子状态的力,这个状态由ParticleState表示;换句话说,力可能依赖于时间或粒子的位置、速度、质量或电荷。

type OneBodyForce = ParticleState -> Vec

在接下来的章节中,我们将看到许多常见的力在力学中自然地表现为一体力。

我们下面写的代码用于牛顿第二定律将产生一个微分方程。换句话说,它将产生一个函数,当给定状态变量本身时,返回状态变量的时间导数。我们应该如何返回这些状态变量的时间导数呢?由于状态变量被捆绑在一个类型为ParticleState的对象中,我们将类似地将时间导数捆绑在一个类型为DParticleState的对象中。以下是我们定义的新数据类型DParticleState

data DParticleState = DParticleState { dmdt :: R
                                     , dqdt :: R
                                     , dtdt :: R
                                     , drdt :: Vec
                                     , dvdt :: Vec }
                      deriving Show

由于粒子状态中包含五个量,因此状态导数中也包含五个量。其提取函数为dmdt(得名于导数dm/dt)的实数表示质量变化率。质量在我们的示例中不会发生变化,因此该变化率为零,但在某些情况下,质量变化的能力是有用的,例如火箭运动(火箭消耗燃料)。其他每个提取函数的命名旨在表示该量表示状态变量变化率。dqdtdtdt表示电荷和时间变化率的实数。时间对时间的变化率为 1,所以追踪这个变化率有点多余。另一种做法是编写一个省略该量的数据类型;我选择了一个与状态结构相平行的数据类型,即使有些槽位存储的内容看起来显而易见。drdtdvdt表示位置和速度变化率。这些量是向量,正如数据类型定义所示。

下述函数newtonSecondPS是牛顿第二定律的 Haskell 表示形式,相当于微分方程 16.3、16.4 和 16.5。

newtonSecondPS :: [OneBodyForce]
               -> ParticleState -> DParticleState  -- a differential equation
newtonSecondPS fs st
    = let fNet = sumV [f st | f <- fs]
          m = mass st
          v = velocity st
          acc = fNet ^/ m
      in DParticleState { dmdt = 0    -- dm/dt
                        , dqdt = 0    -- dq/dt
                        , dtdt = 1    -- dt/dt
                        , drdt = v    -- dr/dt
                        , dvdt = acc  -- dv/dt
                        }

函数newtonSecondPS是将一组单体力转换为微分方程的公式。名称中的 PS 表示该函数与ParticleState数据类型一起工作。由newtonSecondPS生成的微分方程表示每个状态变量的时间变化率,用状态变量本身表示。给定五个状态变量的值,函数newtonSecondPS将返回这五个状态变量的时间变化率的值。

函数newtonSecondPS包含一个let表达式,在该表达式中,我们首先找到表示粒子当前状态下合力的Vec,将其命名为fNet,然后分别将粒子的质量和速度命名为mv,最后通过将合力除以质量来计算粒子的加速度。let表达式的主体返回一个类型为DParticleState的状态导数。质量和电荷对时间的导数为 0,因为质量和电荷不变化。时间对时间的导数为 1。最后,位置的导数是当前状态下的速度,而速度的导数是let表达式中计算出的加速度。

我们将函数newtonSecondPS fs视为牛顿第二定律的哈斯克尔版本,其中fs是描述物理情况的一体力列表。图 16-1 中的示意图、微分方程 16.3、16.4 和 16.5,以及哈斯克尔函数newtonSecondPS是表达牛顿第二定律的不同方式,适用于三维空间中的单一物体。

解决牛顿第二定律

我们构建并求解牛顿第二定律的策略是通过四种不同形式的信息转换来处理物理情况:

  1. 一体力

  2. 微分方程

  3. 状态更新函数

  4. 状态列表

图 16-2 展示了数据表示的功能图,垂直箭头表示数据表示,方框表示将数据从一种表示转换为另一种表示的函数。

图片

图 16-2:解决单粒子力学问题的数据流

一体力列表是我们用来描述物理情况的四种信息表示方式中的第一种,每种方式都越来越接近解决方案。这个一体力列表描述了粒子所处的物理环境或情况,作为代数类比,它类似于初学物理时所用的自由体图,用于展示作用于物体上的所有力。

牛顿第二定律提供了将一体力转化为微分方程的方法,这是我们的第二种信息表示。函数newtonSecondPS将牛顿第二定律应用于ParticleState数据类型。微分方程的哈斯克尔表示是函数ParticleState -> DParticleState,它给出了状态变量随时间变化的速率,并以状态变量本身为表达式。

状态更新函数是我们第三种信息表示方式;它描述了如何在时间上向前迈出小步,从一个旧状态产生一个新状态。我们使用的二维和三维动画工具以状态更新函数作为输入;因为粒子运动的动画可视化算作一个力学问题的解,所以该动画中的状态更新函数也可以看作是一个解。为了从微分方程中得到状态更新函数,我们需要数值方法。通过使用数值方法,我们承认我们仅仅是在寻找力学问题的近似解,而不是通过解析方法解决微分方程时能找到的精确解。我们可以选择不同的数值方法;euler 0.01eulerCromerPS 0.1rungeKutta4 0.1 都是可以用来生成状态更新函数的数值方法示例。我们将在本章后面写出eulerCromerPS函数,也会展示如何使用在上一章中编写的通用函数eulerrungeKutta4。选择了数值方法后,我们将其应用到微分方程上,从而得到一个状态更新函数。

我们使用的第四种信息表示方式是一个状态列表。该列表给出了通过数值方法计算的粒子在每个时间点的状态;换句话说,每个列表元素是特定时刻的状态,它比前一个列表元素的时间步长要长。这几乎是我们能知道的关于粒子的信息了。通过这些信息,我们可以将任何状态变量绘制为时间或其他状态变量的函数。

我们可能关心的其他量,如能量或动量,虽然不包含在状态中,但却是状态变量的函数。如果我们愿意,我们可以写一个更高阶的函数,从状态列表中提取出粒子的位置函数或速度函数。要从状态更新函数得到状态列表,我们只需使用 Prelude 中的iterate函数迭代状态更新函数,这个函数将状态更新函数应用到给定的初始状态,然后不断应用到更新后的状态,直到生成一个列表。

图 16-2 应当被视为在三维空间中解决单粒子力学问题的过程概述。该图与图 15-2 相似,主要有两个区别:(1) 我们使用了新的ParticleState数据类型,它包含质量;(2) 新的图允许选择数值方法,而上一图则坚持使用欧拉方法。

总结来说,我们的过程大致是将我们的物理问题(由一体力给出)转化为数学问题(一个微分方程),解决数学问题(通过数值方法生成状态更新函数,并进行迭代产生一系列状态),然后回到物理学解释结果。

在概述了我们将用于解决牛顿第二定律的过程后,接下来让我们看看一些一体力的例子。

一体力

我们在本章之前介绍了一体力的定义,但并未给出任何例子。许多我们希望纳入牛顿第二定律的常见力本质上都可以表示为一体力。

地球表面重力

一个靠近地球表面的物体会感受到来自地球的引力。(这是引力理论列表中第 148 页的理论 2。)如果g是指向地球中心的重力加速度,那么地球对靠近地球表面质量为m的粒子或物体施加的引力为:

F[g] = mg

如果我们同意让坐标系的 z 轴指向远离地球中心的方向,并且使用国际单位制,那么地球表面重力的一体力可以写作如下:

-- z direction is toward the sky
-- assumes SI units
earthSurfaceGravity :: OneBodyForce
earthSurfaceGravity st
    = let g = 9.80665  -- m/s²
      in (-mass st * g) *^ kHat

回想一下,一体力是从粒子状态到力向量的函数。局部变量st保存粒子状态,mass st通过提取函数mass从粒子状态中提取质量,该提取函数是因为我们在定义ParticleState时使用了记录语法而自动生成的。

如果地球表面重力是作用于我们粒子的力,那么我们需要做的就是将earthSurfaceGravity包含在构成newtonSecondPS输入的一体力列表中。适当的质量将从状态中提取,引力将被包含在牛顿第二定律中。

太阳产生的引力

任何具有质量的物体都会对任何其他具有质量的物体施加引力。(这是引力理论列表中第 148 页的理论 3。)如果物体是球形的,一个物体对另一个物体施加的引力与每个物体的质量成正比,与它们中心之间距离的平方成反比。这就是牛顿万有引力定律的内容,我们将在第十九章中详细讨论。

在我们的太阳系中,有许多对象对,其中一个对象比另一个对象重得多,例如太阳/地球、地球/月球和地球/通信卫星。如果我们想要理解地球在太阳系中移动的运动,可以很好地假设两件事:一是其他行星(如火星、金星和木星)的引力吸引对地球的影响非常小,因此可以忽略不计;二是太阳与地球相比如此之大,其位置可以视为固定。在这些近似情况下,太阳产生的普遍重力可以视为作用于地球(或火星、金星、哈雷彗星等)的一体力。

太阳对具有质量m的物体或粒子施加的引力是

图片

其中G是牛顿引力常数(在国际单位制中,G = 6.67408 × 10^(-11) N m²/kg²),M[s]是太阳的质量(M[s] = 1.98848 × 10³⁰ kg),r是太阳中心与物体中心之间的距离,以及

图片

是一个单位矢量,指向太阳朝向物体。负号意味着物体上的力指向太阳。可以写出太阳引力的一体力如下:

-- origin is at center of sun
-- assumes SI units
sunGravity :: OneBodyForce
sunGravity (ParticleState m _q _t r _v)
    = let bigG = 6.67408e-11  -- N m²/kg²
          sunMass = 1.98848e30  -- kg
      in (-bigG * sunMass * m) *^ r ^/ magnitude r ** 3

这里我们使用输入模式匹配来提取状态变量,而不是我们用于地球表面重力的前一个一体力的提取函数。我们使用ParticleState构造函数匹配粒子状态的模式。我们分配跟随构造函数的五个局部变量的值为质量、电荷、时间、位置和速度。我们不需要电荷、时间或速度来计算太阳施加的引力,因此它们前面有下划线。 (我们可以仅对任何或所有未使用的变量使用下划线,但在下划线后提供名称提醒我们忽略的内容。)选择是使用提取函数还是模式匹配来从状态中获取状态变量是一种风格问题,您可以使用您最喜欢的任何一种。

如果我们对月球绕地球运动感兴趣,我们可以将地球的普遍重力表达为作用于月球的一体力。练习 16.4 要求您为地球产生的普遍重力编写一个一体力。另一方面,如果我们对月球在太阳系中的运动感兴趣,那么太阳和地球的引力都很重要,最好使用第十九章的技术。

空气阻力

空气阻力是一种一体力,依赖于物体在空气中移动的速度。我们假设空气在我们的坐标系中是静止的。在第十四章中,我们推导出了适用于一维情况的空气阻力表达式。在三维情况下,速度是一个向量,空气阻力的力表现为

Image

参数CρA 仍然分别表示拖曳系数、空气密度和物体的横截面积。

这是对应于方程 16.6 的一体力的 Haskell 代码:

airResistance :: R  -- drag coefficient
              -> R  -- air density
              -> R  -- cross-sectional area of object
              -> OneBodyForce
airResistance drag rho area (ParticleState _m _q _t _r v)
    = (-0.5 * drag * rho * area * magnitude v) *^ v

我们在命名传入粒子状态ParticleState _m _q _t _r v时,使用了模式匹配。力仅依赖于速度,因此速度是唯一需要命名的状态变量。

对于任何需要考虑空气阻力的情况,我们需要估算一个拖曳系数,确定物体的横截面积,并确定一个适当的空气密度值。在地球表面附近的合理温度和压力下,空气的密度约为 1.225 kg/m³。例如,如果我们的拖曳系数是 0.8,物体的横截面积是 0.003 m²,那么

airResistance 0.8 1.225 0.003

newtonSecondPS的一体力列表中将包括空气阻力力,这出现在牛顿第二定律中。

如果我们要做大量的空气阻力问题,可能会将物体的横截面积纳入状态,因为它显然是物体的一个属性。如果我们认为拖曳系数是物体的属性,而不是物体与空气之间相互作用的属性,我们甚至可以考虑将拖曳系数纳入状态。我们不会对状态数据类型进行这些修改;相反,我们将坚持使用ParticleState,当力依赖于不包含在状态中的参数时,我们将根据具体情况逐一处理,就像我们在这里做的那样。

风力

我们刚刚考虑的空气阻力一体力假设空气在我们的坐标系中是静止的。本节中考虑的风力是一种空气阻力的推广,即空气相对于我们的坐标系以某个恒定速度运动。我们可以使用空气阻力公式来计算风力,但适用的速度是物体与风之间的相对速度。如果v是物体相对于我们坐标系的速度,v[wind]是空气相对于我们坐标系的速度,那么vv[wind]就是物体相对于空气的速度。风力可以表达为:

Image

这是对应的 Haskell 代码:

windForce :: Vec  -- wind velocity
          -> R    -- drag coefficient
          -> R    -- air density
          -> R    -- cross-sectional area of object
          -> OneBodyForce
windForce vWind drag rho area (ParticleState _m _q _t _r v)
    = let vRel = v ^-^ vWind
      in (-0.5 * drag * rho * area * magnitude vRel) *^ vRel

风力的代码与空气阻力的代码类似。练习 17.5 提供了尝试这一力的机会。请注意,如果选择风速为 0,那么风力就变成了我们在上一节中讨论的空气阻力。空气阻力是静止的空气对物体施加的力,而风力是流动的空气对物体施加的力。如果空气的力在某种情况下很重要,你需要使用空气阻力或风力,而不是两者兼用。

来自均匀电场和磁场的力

我们还没有讨论电场或磁场,但我们将在本书的第三部分中讨论。现在,重要的是要知道,这些场是由电荷产生的,粒子在电场和/或磁场中会经历一个力。当这些场是均匀时,意味着它们在空间中的不同位置是相同的,那么一个向量可以描述电场,另一个向量可以描述磁场。

假设E是一个均匀电场向量,B是一个均匀磁场向量。这些场对穿越它们的带电粒子施加力,如下所示:

图片

其中q是粒子的电荷,v(t)是粒子的速度。这个方程被称为洛伦兹力定律,我们将在电磁学理论部分详细研究它,包括场不一定均匀时的更一般情况。以下是对应的 Haskell 代码,表示单粒子的力:

uniformLorentzForce :: Vec  -- E
                    -> Vec  -- B
                    -> OneBodyForce
uniformLorentzForce vE vB (ParticleState _m q _t _r v)
    = q *^ (vE ^+^ v >< vB)

函数uniformLorentzForce的类型是Vec -> Vec -> OneBodyForce,这与Vec -> Vec -> ParticleState -> Vec相同。给定一个电场向量vE :: Vec、一个磁场向量vB :: Vec和一个粒子状态ParticleState _m q _t _r v :: ParticleState,通过模式匹配输入,该函数通过应用洛伦兹力定律(方程 16.8)返回一个力向量。粒子的电荷和速度是计算这一电磁力所需的状态变量。

在看到多个单粒子力的例子后,我们继续沿着图 16-2 探讨状态更新过程。

单粒子状态更新

数值方法将微分方程转化为状态更新函数。欧拉-克罗默方法就是一种数值方法,由于它不是一种通用数值方法,因此我们需要为每种状态数据类型编写一个新的函数。以下是ParticleState数据类型的欧拉-克罗默函数:

eulerCromerPS :: TimeStep        -- dt for stepping
              -> NumericalMethod ParticleState DParticleState
eulerCromerPS dt deriv st
    = let t   = time     st
          r   = posVec   st
          v   = velocity st
          dst = deriv st
          acc = dvdt dst
          v'  = v ^+^ acc ^* dt
      in st { time     = t  +         dt
            , posVec   = r ^+^ v'  ^* dt
            , velocity = v ^+^ acc ^* dt
            }

正如我们在上一章中使用欧拉-克罗默方法所看到的,与欧拉方法相比,关键的区别在于它使用更新后的速度来更新位置。eulerCromerPS中的更新方程几乎与上一章的eulerCromer1D中的方程相同,唯一的区别是我们现在使用的是向量。

欧拉法和四阶龙格-库塔法是用于解决任何微分方程的通用方法。在第十五章中,我们编写了eulerrungeKutta4函数,这些函数可以处理任何微分方程和任何状态类型。为了将它们与ParticleState数据类型一起使用,我们必须为DParticleState编写一个RealVectorSpace实例,并为ParticleStateDParticleState类型编写一个Diff实例。

这是RealVectorSpace实例:

instance RealVectorSpace DParticleState where
    dst1 +++ dst2
        = DParticleState { dmdt = dmdt dst1  +  dmdt dst2
                         , dqdt = dqdt dst1  +  dqdt dst2
                         , dtdt = dtdt dst1  +  dtdt dst2
                         , drdt = drdt dst1 ^+^ drdt dst2
                         , dvdt = dvdt dst1 ^+^ dvdt dst2
                         }
    scale w dst
        = DParticleState { dmdt = w *  dmdt dst
                         , dqdt = w *  dqdt dst
                         , dtdt = w *  dtdt dst
                         , drdt = w *^ drdt dst
                         , dvdt = w *^ dvdt dst
                         }

在此实例声明中,我们定义加法为每个项的逐项加法,定义标量乘法为每个项的逐项缩放。

这是Diff实例:

instance Diff ParticleState DParticleState where
    shift dt dps (ParticleState m q t r v)
        = ParticleState (m  +  dmdt dps  * dt)
                        (q  +  dqdt dps  * dt)
                        (t  +  dtdt dps  * dt)
                        (r ^+^ drdt dps ^* dt)
                        (v ^+^ dvdt dps ^* dt)

状态中的每个项都通过其导数与时间步长的乘积进行平移。

在做出这些实例声明后,我们现在可以访问在上一章中编写的eulerrungeKutta4函数。我们可以使用三种数值方法中的任何一种,欧拉法、欧拉-克罗默法或四阶龙格-库塔法,从微分方程中生成状态更新函数。

图 16-2 展示了我们用来解决力学问题的四种数据表示方式和三种从一种数据表示转换到另一种数据表示的函数。这三种函数的组合非常重要,以至于需要命名,并在图 16-3 的两侧以箭头形式显示。我们在上一章中编写了solver,并将在接下来的章节中编写updatePSstatesPS

图片

图 16-3:数据表示与它们之间转换的函数

图 16-3 再次展示了四种数据表示方式,并进行了一个小的更改。与图 16-2 中的最终表示为状态列表不同,图 16-3 展示了从初始状态到状态列表的一个函数,我们称之为进化器。之所以做出这个改变,是因为我们希望将图 16-3 中的每个表示视为将单个函数应用于由前一个表示构成的单一输入的结果。换句话说,在图 16-2 中,初始状态作为输入出现,而在图 16-3 中,它是进化器类型的一部分。为了在图 16-3 中进行相邻表示之间的转换,我们应用了牛顿第二定律、数值方法,然后进行迭代。

让我们编写一个函数statesPS,它通过从力中产生微分方程,使用数值方法将微分方程转化为状态更新函数,并迭代状态更新函数来生成一个进化器,从而生成图 16-3 中的所有三种转换。这个函数的输入将是一个数值方法和一组单体力。输出将是一个可以作用于初始状态并生成无限状态列表的进化器。我们称这个函数为statesPS,因为它在提供初始状态时生成状态列表,且适用于ParticleState数据类型。

statesPS :: NumericalMethod ParticleState DParticleState
         -> [OneBodyForce]  -- list of force funcs
         -> ParticleState -> [ParticleState]  --evolver
statesPS method = iterate . method . newtonSecondPS

局部变量method代表我们在使用statesPS时提供的数值方法。从定义中可以看出,这个函数是三个函数的复合,如图 16-3 所示。回想一下,数值方法包括euler 0.01eulerCromerPS 0.1rungeKutta4 0.1等。请注意,newtonSecondPS函数将力转换为微分方程,它可以与任何数值方法一起使用。

同样地,将力列表转换为状态更新函数的函数名称也非常有用,尤其是对于动画。我们将这个函数称为updatePS,从它的定义可以看出,它只是牛顿第二定律与数值方法的复合。

updatePS :: NumericalMethod ParticleState DParticleState
         -> [OneBodyForce]
         -> ParticleState -> ParticleState
updatePS method = method . newtonSecondPS

图 16-3 演示了这个函数如何适配到数据表示的序列中。

我们可能希望进行的最终转换组合由我们在上一章中编写的solver函数表示。与statesPSupdatePS需要ParticleState数据类型不同,solver函数适用于任何数据类型(任何状态空间)。如果回顾它的定义,你会发现它只是一个数值方法与迭代的复合。

我们现在处于一个极好的位置。要解决任何一个体力学问题,我们只需向计算机提供:

  • 一个数值方法

  • 一组单体力

  • 物体的初始状态

然后,计算机会计算出一个状态列表,我们可以用它来在任意时刻找出诸如位置和速度等量。

将所有内容结合起来,我们可以编写一个函数positionPS,类似于我们之前编写的positionFtxv和其他函数,它接受上述三个信息,并生成一个可以给出物体在任何时刻位置的函数。

positionPS :: NumericalMethod ParticleState DParticleState
           -> [OneBodyForce]  -- list of force funcs
           -> ParticleState   -- initial state
           -> Time -> PosVec  -- position function
positionPS method fs st t
   = let states = statesPS method fs st
         dt = time (states !! 1) - time (states !! 0)
         numSteps = abs $ round (t / dt)
         st1 = solver method (newtonSecondPS fs) st !! numSteps
     in posVec st1

函数首先定义传入的数值方法method、单体力的列表fs、初始粒子状态st和时间tlet子句中的第一行使用statesPS基于给定的数值方法、力和初始粒子状态创建一个无限的粒子状态列表。第二行通过减去列表中第一个和第二个状态的时间来计算时间步长。第三行找出为尽可能接近目标时间t所需的时间步数。第四行选出最接近目标时间的状态,而let构造体中的主体部分,在in关键字之后,使用提取函数posVec从状态中提取位置。

在编写了允许我们使用任意数值方法解决单体力学问题的函数之后,我们将注意力转向动画的一些最后细节。

准备动画

在第十三章中,我们讨论了如何使用Graphics.GlossVis模块制作二维和三维动画。记住,每个模块都有一个simulate函数,但这两个函数在它们所要求的输入上并不相同。在本节中,我们通过创建两个新函数simulateGlosssimulateVis,使它们接受非常相似的输入,从而减少大脑的未来负担,这样我们就可以在二维动画和三维动画之间切换,而无需记住glosssimulate函数与非 glosssimulate函数之间的所有细节。

两个有用的动画函数

每个函数simulateGlosssimulateVis都调用自己版本的simulate来完成实际的工作。我们的目的是使用这些新函数,而不是使用任何版本的simulate。我们将简要解释simulateGlosssimulateVis是如何工作的;然而,与本书中许多 Haskell 函数不同,编写这些函数的目的是为了让动画变得更简单,而不是为了展示关于物理或编程的重要或美丽的思想。我们愿意为编写这些函数付出一次性代价,因为这样我们就可以反复使用它们,更方便地制作动画。

了解如何使用这些函数比理解它们是如何工作的更为重要。如果你想跳过函数的定义和它们的工作原理的解释,这对你后续不会造成困扰。然而,确实需要关注这两个新函数的类型,以及必须提供的输入,以使它们能够完成任务。

以下是simulateGlosssimulateVis的类型签名和函数定义:

simulateGloss :: R    -- time-scale factor
              -> Int  -- animation rate
              -> s    -- initial state
              -> (s -> G.Picture)
              -> (TimeStep -> s -> s)
              -> IO ()
simulateGloss tsFactor rate initialState picFunc updateFunc
    = G.simulate (G.InWindow "" (1000, 750) (10, 10)) G.black rate
      initialState picFunc
          (\_ -> updateFunc . (* tsFactor) . realToFrac)

simulateVis :: HasTime s => R  -- time-scale factor
            -> Int             -- animation rate
            -> s               -- initial state
            -> (s -> V.VisObject R)
            -> (TimeStep -> s -> s)
            -> IO ()
simulateVis tsFactor rate initialState picFunc updateFunc
    = let visUpdateFunc ta st
              = let dtp = tsFactor * realToFrac ta - timeOf st
                in updateFunc dtp st
      in V.simulate V.defaultOpts (1/fromIntegral rate)
      initialState (orient . picFunc) visUpdateFunc

simulateGloss函数生成 2D 动画,而simulateVis生成 3D 动画。每个函数都接受五个输入参数。虽然其中一个输入在simulateGloss中与simulateVis中有所不同,但这五个输入的含义和顺序是相同的。我们来讨论每个输入的含义和用途。

时间比例因子

simulateGlosssimulateVis的第一个输入参数tsFactor表示我们希望动画相对于物理演化的运行速度。有时我们希望动画比对应的物理情况发展得更快或更慢。例如,月球绕地球一圈大约需要一个月,但我们可能希望动画中月球在六秒钟内完成一个完整的周期。我们几乎总是希望动画发生在秒或分钟级别。如果更短时间,变化太快看不清;如果更长时间,就会失去耐心。

我们可以区分两种时间形式。物理时间是某个过程在物理世界中发生的时间。月球绕地球一圈的物理时间是一个月。动画时间是某个过程在计算机动画中发生的时间。在我们的示例中,月球绕地球一圈的动画时间是六秒。

为了区分物理时间和动画时间,我们的simulateGlosssimulateVis函数将时间比例因子作为第一个输入参数,因此命名为tsFactor。时间比例因子是物理时间与动画时间的比率。在月球轨道示例中,物理时间远大于动画时间,因此时间比例因子是一个大于 1 的数字。对于在物理世界中发生得非常快、我们希望以“慢动作”方式查看的过程,应使用小于 1 的时间比例因子,以便在发生变化时看到有趣的变化。通过将时间比例因子作为第一个输入传递给simulateGlosssimulateVis,我们声明了希望动画与物理演化相比的运行速度。

动画速率

动画速率,在之前展示的代码中称为rate,是每秒钟显示的画面帧数,这是simulateGlosssimulateVis的第二个输入参数。由于每次调用状态更新函数以生成新的状态时,都会生成一个新的画面帧,因此动画速率也是每秒钟的状态更新次数。

时间比例因子、动画速率和时间步长之间存在关系。如果我们让α表示时间比例因子,r表示动画速率,Δt[p]表示时间步长(这是物理时间,因此下标为 p),那么它们之间的关系为:

Image

这些输入中只有两个可以独立选择。由于时间步长是物理时间,并且我们可能会关注从纳秒到年份的各种物理时间尺度,因此将时间尺度因子和动画速率告诉simulateGlosssimulateVis,并让它们计算用于状态更新的时间步长是很方便的。这样,如果我们选择了合适的时间尺度因子,我们可以选择每秒 20 帧的动画速率,并且有很大的机会使用合理的时间步长。如果我们发现需要更小的时间步长,我们可以提高动画速率(正如我们在下一章中对哈雷彗星的处理方式一样)。

初始状态

第三个输入是我们希望动画化的粒子或系统的初始状态,initialState。在本章中,粒子的状态类型是ParticleState。在上一章中,单维粒子的状态类型是State1D。在第十九章中,粒子系统的状态类型将是MultiParticleState。我们的两个动画函数可以与这些类型中的任何一个配合使用,如初始状态中使用的类型变量s所示。

显示函数

第四个输入,picFunc,是一个显示函数,它必须解释在给定状态下应该生成什么样的 2D 或 3D 图片。由于gloss使用Picture类型来表示图片,而not-gloss使用VisObject R类型,因此这个第四个输入在simulateGlosssimulateVis中的类型是不同的。当我们想为特定物理情况生成动画时,需要为该情况编写一个显示函数。not-gloss包有自己的三维向量类型,它与我们一直使用的Vec类型不同。由于有一个名为Trans的三维平移函数,它接受not-gloss向量作为输入,因此在为 3D 动画编写显示函数时,拥有一个转换函数是非常有用的。函数v3FromVec可以将一个Vec类型的向量转换为not-gloss向量。

v3FromVec :: Vec -> V3 R
v3FromVec v = V3 x y z
    where
      x = xComp v
      y = yComp v
      z = zComp v

我们将在下一章的 3D 动画中使用这个函数,包括抛体运动和质子在磁场中的运动。

状态更新函数

第五个也是最后一个输入是状态更新函数,updateFunc。状态更新函数是我们解牛顿第二定律方法的核心,即使在没有动画的情况下也是如此。请注意,状态更新函数的类型是TimeStep -> s -> s。这个函数必须解释如何通过给定时间步长从旧状态创建新状态。我们在这里并没有选择时间步长,而是指定了一个接受时间步长和旧状态作为输入并返回新状态的函数。为了得到状态更新函数,我们可以应用一个数值方法来求解来自牛顿第二定律的微分方程,或者使用我们之前定义的updatePS函数,该函数结合了数值方法和单体力的列表。

刚才讨论的五个输入——时间尺度因子、动画速率、初始状态、显示函数和状态更新函数——包含了我们所建模的物理情况的所有信息,并且也包含了关于如何生成随时间变化的画面的所有信息。

在讨论了simulateGlosssimulateVis函数的输入参数后,我们来看看这些函数是如何工作的,以生成二维和三维动画。

函数的工作原理

我们更容易理解simulateGloss函数的工作原理,因此我们从这个函数开始。simulateGloss函数命名了五个输入参数:tsFactor表示时间尺度因子,等等。它调用glosssimulate函数来执行实际工作,并向该函数传递六个参数。传递给simulate的第一个参数指定了一个空的窗口名称、窗口大小(以像素为单位)和窗口位置。由于这些参数并不那么重要,而且我们不太可能希望在每个动画之间更改这些值,因此我们选择了一些希望能够一次性使用的值。传递给simulate的第二个参数是背景颜色,我们选择为黑色。第三、第四和第五个输入分别是动画速率、初始状态和显示函数。这些都是simulateGloss的输入,因此可以直接传递给glosssimulate函数。

glosssimulate函数所需的最终输入是一个更新函数,但它与我们之前使用的状态更新函数在三个方面有所不同。首先,glosssimulate期望一个更新函数,其第一个参数是ViewPort,而我们并不打算使用它。为了提供一个视口的位置,我们编写了一个匿名函数,它丢弃了第一个参数。第二,glosssimulate期望一个基于动画时间而非物理时间的更新函数。由于我们的更新函数是基于物理时间的,因此我们需要使用时间尺度因子进行转换。第三,我们需要使用realToFracR转换为Float。总之,我们的simulateGloss函数通过将输入传递给glosssimulate函数来工作,其中两个输入是直接指定的,三个输入从simulateGloss传递过去没有变化,最后一个是对simulateGloss输入的修改。

simulateVis函数为其五个输入参数赋予与simulateGloss相同的名称,因为这些输入参数具有相同的含义。它调用not -glosssimulate函数来执行实际的工作,并将五个参数传递给该函数。传递给simulate的第一个参数指定了一些选项,我们将这些选项一劳永逸地设置为默认选项。传递给simulate的第二个参数是每帧动画运行的秒数。由于这只是动画速率的倒数,我们可以在适当将其类型从整数转换为实数后反转rate。第三个输入是初始状态,我们传递给它,保持不变。

第四个输入是显示函数,虽然我们本可以将其原样传递,但我们并没有这样做,因为我想借此机会使用orient函数,该函数最初写于第十三章,并在下面重复,用于旋转坐标轴,使得 y 轴指向右侧,z 轴指向屏幕上方,x 轴指向左侧并看起来延伸出屏幕。换句话说,我使用orient来使我们的动画自动使用我最喜欢的坐标系。

orient :: V.VisObject R -> V.VisObject R
orient pict = V.RotEulerDeg (Euler 270 180 0) $ pict

not-glosssimulate所需的第五个也是最后一个输入是更新函数;然而,它与我们需要处理的状态更新函数updateFunc有着实质性的不同。由于这种差异非常大,我们使用let构造来定义一个局部函数visUpdateFunc,并将其作为最终输入传递给not-glosssimulate函数。我们将第一个visUpdateFunc的输入命名为ta,以提醒它表示动画时间。我们将第二个visUpdateFunc的输入命名为st,表示状态。我们的策略是使用传递给simulateVisupdateFunc来计算visUpdateFunc ta st的值,该值的类型为s

visUpdateFuncupdateFunc的区别完全在于它们如何解释第一个参数。visUpdateFunc的第一个输入,名为ta,是从动画开始以来已经经过的动画时间。相比之下,updateFunc的第一个输入是从上一个状态计算后到当前状态的物理时间步长。在visUpdateFunc的局部定义中,我们可以访问动画时间ta,并利用它来计算我们将传递给updateFunc的物理时间步长dtp。这个转换比gloss的更为复杂,因为我们实际上进行的是两个转换:一个是从动画时间到物理时间,另一个是从动画开始的物理时间到物理时间步长。我们使用嵌套的let结构来定义局部变量dtp,即我们将传递给updateFunc以产生新状态visUpdateFunc ta st的物理时间步长。我们通过首先将ta的类型从Float转换为R,然后按时间尺度因子缩放该动画时间,得到自仿真开始以来的物理时间,最后减去旧状态的(物理)时间来计算dtp,一个类型为R的实数。与状态st相关的物理时间是timeOf st。接下来,我会解释这一如何运作。

我们需要知道一个状态的时间(这是Particle State中的一个状态变量)。如果simulateVis只针对ParticleState数据类型工作,这不会成为问题。但我们希望simulateVis能够处理任何状态空间s,或者至少能够处理包含时间作为状态变量的状态空间s。为了解决这个问题,我们似乎必须发明一个新的类型类,叫做HasTime,用于表示能够提取特定时间值的状态类型。这个类型类只拥有一个函数timeOf,用于从状态中提取时间。以下是类型类HasTime的定义:

class HasTime s where
    timeOf :: s -> Time

每种类型,若想成为HasTime的实例,必须通过实例声明表达如何实现timeOf。以下是ParticleState的实例声明:

instance HasTime ParticleState where
    timeOf = time

总结来说,我们的simulateVis函数通过将输入传递给not -glosssimulate函数来工作。not-glosssimulate函数的输入之一是简单指定的,另外两个输入是从simulateVis的输入中直接传递过来的,最后两个输入是simulateVis输入的修改版本。

总结

本章将牛顿力学应用于三维空间中单个物体的运动。解决力学问题的过程是通过一系列四个表示来转换信息,首先是单体力,然后是一个微分方程,再是一个状态更新函数,最后是一个状态列表。在这个过程中,牛顿第二定律作为一种手段,将作用在物体上的力的列表转化为微分方程。数值方法将微分方程转化为状态更新函数。我们在本章中使用了欧拉-克罗梅方法和四阶龙格-库塔方法,并选择了合适的时间步长作为数值方法。状态更新函数是运动动画中的一个基本要素。

本章仍然围绕基于状态的范式展开,在这一章中,我们定义了一种新的数据类型,用于存储粒子的状态。这个新数据类型包含粒子的质量、电荷、位置、速度以及时间。我们引入了单体力的概念,这也成为了我们在这一章讨论力的主要方式。在下一章中,我们将这些概念应用于具体的例子,并对许多结果进行动画演示。

习题

习题 16.1. 将函数newtonSecondPS应用于一个非常简单的力列表,比如仅包含恒定力的列表,以及一个非常简单的状态,比如defaultParticleState,并找到结果表达式的类型。

习题 16.2. 编写一个函数

constantForce :: Vec -> OneBodyForce
constantForce f = undefined f

该函数接受一个力向量作为输入,返回一个OneBodyForce,该力无论给定什么状态,都会返回相同的恒定力。例如,如果我们使用constantForce来创建一个始终产生 10ImageN 的单体力,

tenNewtoniHatForce :: OneBodyForce
tenNewtoniHatForce = constantForce (10 *^ iHat)

然后tenNewtoniHatForce defaultParticleState应该产生vec 10.0 0.0 0.0

习题 16.3. 编写一个函数

moonSurfaceGravity :: OneBodyForce
moonSurfaceGravity = undefined

返回地球月球对月球表面附近物体施加的引力。

习题 16.4. 编写一个表示地球产生的普遍引力的单体力。

earthGravity :: OneBodyForce
earthGravity = undefined

习题 16.5. 使用函数uniformLorentzForce来找出在正 z 方向的均匀磁场中,沿正 x 方向运动的质子的力的方向。没有电场。你可以选择质子的速度和磁场的大小。根据力的方向,你预期质子接下来的运动是什么样的?

习题 16.6. 我们在本章中开发的工具通过生成粒子状态的无限列表来解决力学问题。为了理解这个解,我们通常希望提取一些数据并将其绘制成图。如果我们想绘制 y 分量速度随时间的变化曲线,我们需要一组(t, v[y])值的对。编写一个函数

tvyPair :: ParticleState -> (R,R)
tvyPair st = undefined st

生成所需的数字对,来自于一个粒子的状态。然后编写一个函数

tvyPairs :: [ParticleState] -> [(R,R)]
tvyPairs sts = undefined sts

它根据一个粒子的状态列表生成一对数值列表。你可以在第二个函数的主体中使用你的tvyPair函数。

练习 16.7. 编写一个谓词

tle1yr :: ParticleState -> Bool
tle1yr st = undefined st

该函数返回True,如果状态中的时间(假设是秒数)小于或等于一年,否则返回False。这个谓词可以与takeWhile一起使用,将无限状态列表转换为有限列表,以便绘制图形。

练习 16.8. 编写一个函数

stateFunc :: [ParticleState]
          -> Time -> ParticleState
stateFunc sts t
    = let t0 = undefined sts
          t1 = undefined sts
          dt = undefined t0 t1
          numSteps = undefined t dt
      in undefined sts numSteps

它根据给定的状态列表生成一个从时间到粒子状态的函数。假设相邻状态之间的时间间隔相同。

练习 16.9. 在我们迄今为止的所有空气阻力的研究中,我们假设空气的密度是常数。然而,靠近地球表面的空气密度实际上随着高度的增加而减少。一个有用的近似公式来表示空气密度随高度的变化是

ρ = ρ0e^(–h/h[0])

其中ρ[0]是海平面上的空气密度,h是海平面以上的高度,ρ是海拔高度h处的空气密度,h[0]是一个常数。

h[0] = 8,500 米,并使用位置的 z 分量表示海拔高度,编写一个单体力学模型

airResAtAltitude :: R  -- drag coefficient
                 -> R  -- air density at sea level
                 -> R  -- cross-sectional area of object
                 -> OneBodyForce
airResAtAltitude drag rho0 area (ParticleState _m _q _t r v)
    = undefined drag rho0 area r v

它可以代替airResistance在物体处于高海拔时使用。为了测试这个新函数,以下函数比较了从海平面发射的铅球在三种不同条件下的射程:(a)无空气阻力,(b)均匀空气阻力,以及(c)随着高度变化的空气阻力。铅球的直径是 10 厘米。提供初始状态和代码中的最后一行(标记为undefined的两处),然后使用代码查看 45^∘角度发射的铅球射程。尝试初始速度为 10 m/s、100 m/s 和 300 m/s 的情况。

projectileRangeComparison :: R -> R -> (R,R,R)
projectileRangeComparison v0 thetaDeg
    = let vx0 = v0 * cos (thetaDeg / 180 * pi)
          vz0 = v0 * sin (thetaDeg / 180 * pi)
          drag = 1
          ballRadius = 0.05    -- meters
          area = pi * ballRadius**2
          airDensity  =    1.225  -- kg/m³ @ sea level
          leadDensity = 11342     -- kg/m³
          m = leadDensity * 4 * pi * ballRadius**3 / 3
          stateInitial = undefined m vx0 vz0
          aboveSeaLevel :: ParticleState -> Bool
          aboveSeaLevel st = zComp (posVec st) >= 0
          range :: [ParticleState] -> R
          range = xComp . posVec . last . takeWhile aboveSeaLevel
          method = rungeKutta4 0.01
          forcesNoAir
              = [earthSurfaceGravity]
          forcesConstAir
              = [earthSurfaceGravity, airResistance    drag airDensity area]
          forcesVarAir
              = [earthSurfaceGravity, airResAtAltitude drag airDensity area]
          rangeNoAir    = range $ statesPS method forcesNoAir    stateInitial
          rangeConstAir = range $ statesPS method forcesConstAir stateInitial
          rangeVarAir   = range $ statesPS method forcesVarAir   stateInitial
      in undefined rangeNoAir rangeConstAir rangeVarAir

练习 16.10. 考虑从 10 米高处自由落体的铅球,靠近地球表面。使用我们在本章定义的函数编写一个函数,生成此运动的粒子状态列表。如果你能使用takeWhile函数提取z ≥ 0 的粒子状态(即球体仍然处于地球表面或以上的状态),则可以获得额外的学分。

第十七章:卫星、抛体和质子运动

图片

本章讨论了三个扩展示例,这些示例使用了来自第十六章的思想和代码,表达并解决涉及一个物体的牛顿力学问题。示例包括卫星运动、带空气阻力的抛体运动,以及在均匀磁场中的质子运动。我们将展示如何为每个示例制作图表和动画。请注意,本章不会开始一个新的模块;相反,我们将继续扩展上一章中开始的Mechanics3D模块。

卫星运动

作为卫星运动的初步示例,考虑以下情况:地球绕太阳公转是由于它们之间的引力。严格来说,地球和太阳各自绕着一个位于两者之间的点公转。这个点叫做质心,它距离质量较大的太阳要比质量较小的地球近得多,因此将地球绕太阳公转是一个不错的近似。在第十九章中,我们将把万有引力视为一个二体力:太阳和地球都会因此加速,两个天体都会绕着质心公转。然而,在本章中,我们只关注单一物体的运动,比如地球,我们将把太阳对地球的引力视为单体力。这意味着我们将把太阳视为一个“家具”,它的作用只是对地球施加引力,但不会参与到完整的运动中,也不会因感受到力而发生运动变化。

在本章的卫星运动中,我们只关注卫星。围绕卫星轨道运动的较大行星或恒星被假设为固定不动;它唯一的作用是对卫星施加引力。

哈雷彗星绕太阳公转,每 75 年左右公转一圈。它的轨道相当椭圆,彗星在靠近太阳时运动较快,而在远离太阳时运动较慢。在 1986 年,哈雷彗星靠近太阳,因此也足够接近地球,肉眼可见。预计它将在 2061 年再次经过我们的邻近区域。

让我们为哈雷彗星绕太阳的轨道制作动画。在第十六章中,我们描述了如何制作动画。我们需要五个信息来调用simulateGlosssimulateVis函数:一个时间尺度因子、一个动画速率、一个初始状态、一个显示函数和一个状态更新函数。我们将从最具物理意义的信息开始描述。

状态更新函数

状态更新函数halleyUpdate可以使用updatePS编写,如图 16-3 所示,并在第十六章中定义,该函数需要一个数值方法和一体力列表。函数halleyUpdate及本章和下一章中所有不是独立程序的代码,都属于我们在第十六章中开始的Mechanics3D模块,应该放在同一个源代码文件中。

halleyUpdate :: TimeStep
             -> ParticleState -> ParticleState
halleyUpdate dt
    = updatePS (eulerCromerPS dt) [sunGravity]

对于我们的数值方法,我们将选择欧拉-克罗默方法。回想一下,对于动画,我们不会直接选择数值方法的时间步长,而是通过稍后选择的时间尺度因子和动画速率来确定。时间步长dt作为输入传递给halleyUpdate,然后我们将dt传递给eulerCromerPS来构成数值方法。一体力的列表仅包含太阳的引力。

初始状态

初始状态halleyInitial决定了我们是得到一个圆形轨道、椭圆轨道,还是一个速度快到能够逃脱太阳引力的卫星。哈雷彗星的质量为 2.2 × 10¹⁴千克。彗星的净电荷为零,我们的时钟也从零开始。正是初始位置和速度决定了后续的轨道。我选择将初始位置设置在 x 轴正方向上,距离太阳最近的距离为 8.766 × 10¹⁰米。当哈雷彗星最接近太阳时,它的速度是轨道中最快的,速度为 54,569 米/秒,方向垂直于彗星和太阳之间的连线。我们称这个方向为 y 方向。将所有这些信息放入ParticleState数据类型,我们得到了初始状态halleyInitial的以下表达式:

halleyInitial :: ParticleState
halleyInitial = ParticleState { mass     = 2.2e14            -- kg
                              , charge   = 0
                              , time     = 0
                              , posVec   = 8.766e10 *^ iHat  -- m
                              , velocity = 54569 *^ jHat }   -- m/s

时间尺度因子

列表 17-1 显示了时间尺度因子、动画速率和显示函数,并提供了一个独立的程序,用于使用gloss进行哈雷彗星绕太阳运动的 2D 动画。

{-# OPTIONS -Wall #-}

import SimpleVec
    ( xComp, yComp )
import Mechanics3D
    ( ParticleState(..), simulateGloss, disk, halleyInitial, halleyUpdate )
import Graphics.Gloss
    ( Picture(..), pictures, translate, red, yellow )

diskComet :: Picture
diskComet = Color red (disk 10)

diskSun :: Picture
diskSun = Color yellow (disk 20)

halleyPicture :: ParticleState -> Picture

halleyPicture (ParticleState _m _q _t r _v)
    = pictures [diskSun, translate xPixels yPixels diskComet]
          where
            pixelsPerMeter = 1e-10
            xPixels = pixelsPerMeter * realToFrac (xComp r)
            yPixels = pixelsPerMeter * realToFrac (yComp r)

main :: IO ()
main = simulateGloss (365.25 * 24 * 60 * 60) 400
       halleyInitial halleyPicture halleyUpdate

列表 17-1:用于展示哈雷彗星绕太阳轨道运动的 2D 动画的独立程序

我们首先打开警告。然后,我们从第十章的SimpleVec模块、我们在第十六章开始并在本章及下一章继续扩展的Mechanics3D模块,以及Graphics.Gloss模块中导入所需的功能。图片diskCometdiskSun分别是哈雷彗星和太阳的标记。显示函数halleyPicture是动画所需的五个要素之一,它利用彗星的状态将彗星标记移到适当的位置。太阳显示在原点且不会移动。在main函数中,我们选择了365.25 * 24 * 60 * 60作为时间缩放因子,这样一年的物理时间就对应一秒的动画时间。由于哈雷彗星的周期约为 75 年,因此动画显示一个完整轨道大约需要 1 分 15 秒。

动画速率

对于一般的动画,我建议开始时使用每秒约 20 帧的动画速率。对于哈雷彗星,这样的时间步长是 1/20 年,比 75 年的时间尺度小得多,后者似乎是该情形下的重要时间尺度。如果你使用每秒 20 帧,而不是清单 17-1 中所示的每秒 400 帧,你会注意到轨道出现一些奇怪的现象。哈雷彗星会偏离屏幕,并且不会再回到屏幕上绕太阳运行,至少在接近 75 秒内不会发生这种情况。问题在于,彗星在接近太阳时移动非常快,而在远离太阳时则相对较慢。

当彗星接近太阳时,准确的计算需要相对较小的时间步长,因为此时彗星移动迅速且方向变化很快。其余轨道部分的时间步长可以大大增大,而不会造成损害。有一些数值方法使用可变时间步长,但这些超出了本书的范围。我们需要增加动画速率或减小时间缩放因子,以便在最接近太阳的短暂时间内使用足够小的时间步长来保持准确性。通过尝试不同的动画速率,表明每秒 400 帧可能足以给出合理准确的结果。

显示功能

图 17-1 显示了哈雷彗星绕太阳运动的动画帧。在这个快照中,太阳以灰色显示在图的右侧,而哈雷彗星向左移动,远离太阳。我们编写的动画呈现出一个黄色的太阳和一个红色的彗星。

图片

图 17-1:哈雷彗星远离太阳

显示函数 halleyPicture 需要描述如何从状态生成一张图片。我们主要想展示的是彗星的位置。彗星在 z = 0 平面内运动,因此我们只需要处理该函数中的位置的 x 和 y 分量。Listing 17-1 中的 halleyPicture 函数通过模式匹配输入,将局部变量 r 赋值为当前彗星状态的位置。位置是显示函数关心的唯一状态变量;速度或质量在决定图片的外观时不起作用。我们使用 Chapter 10 中 SimpleVec 模块的 xCompyComp 函数提取位置的 x 和 y 分量。

where 关键字与 let 关键字类似,允许代码编写者定义局部变量和函数;然而,where 及其局部名称出现在主要函数体之后,而不是之前。

realToFrac 函数将类型为 R 的实数转换为类型为 Float 的实数,因为 glosstranslate 函数需要以 Float 类型作为输入。最终生成的图像包含一个黄色圆盘表示太阳,一个红色圆盘,经过平移到合适位置,用来表示彗星。gloss 中的 pictures 函数从一个图片列表生成一个单独的图片。

空间缩放需要在显示函数中进行。物理尺寸以米为单位表示,而 gloss 的尺寸则以像素为单位表示。因此,我们需要指定如何进行这种转换。一种自然的缩放策略是按比例显示所有内容,使用一个整体缩放因子将米转换为像素。gloss 中的 scale 函数非常适合这个目的,因为它可以接受一个所有长度以米为单位的图片,并生成一个按每米多少像素缩放后的新图片。但在哈雷彗星动画中,如果我们尝试按比例显示所有内容,使用两个半径和彗星位置的准确米值,以及一个米到像素的整体缩放因子,我们将无法看到彗星或太阳,因为彗星的运动范围过于广阔。

由于我们无法按比例显示太阳和彗星的大小,黄色和红色圆盘仅作为太阳和彗星位置的标记;这些圆盘的大小与轨道运动或彼此之间的比例无关。指定太阳和彗星的半径时,使用像素比使用米更容易,因为米需要缩放到像素,并且按与彗星位置不同的因子进行缩放。图片 diskCometdiskSun 指定了这两个圆盘的半径分别为 10 像素和 20 像素。我们不会再对这些半径进行缩放。这两张图片使用了我们在 Chapter 13 中定义的 disk 函数,以下是重复部分:

disk :: Float -> G.Picture
disk radius = G.ThickCircle (radius/2) radius

我更倾向于在像素中指定半径的另一个原因是,空间缩放通常是通过试错法确定的,缩小或放大一个有效的动画。如果这个试错缩放作用于整个图像,包括轨道大小和半径,就很容易把半径缩小得太多,以至于圆盘看不见,或者把半径放大得太多,导致它们填满整个屏幕。在这两种情况下,有时很难知道问题出在哪里。

在哈雷动画中,只有一件事需要缩放,那就是彗星的位置。我们使用 10^(-10)像素/米的缩放因子来缩放位置的 x 和 y 分量。

带有空气阻力的抛物线运动

在下一个示例中,让我们来看一个击打的棒球。这是一个带有空气阻力的抛物线运动示例。我们将考虑作用在棒球上的两个力:地球表面的重力和空气阻力。我们使用一个 145 克重、直径为 74 毫米、阻力系数为 0.3 的棒球。列表baseballForces包含作用在棒球上的两个单一力。列表baseballForces以及本章和下一章中不属于独立程序的所有代码,都是Mechanics3D模块的一部分。

baseballForces :: [OneBodyForce]
baseballForces
    = let area = pi * (0.074 / 2) ** 2
      in [earthSurfaceGravity
         ,airResistance 0.3 1.225 area]

第一个力是地球表面的重力,第二个力是空气阻力。我们定义了一个局部变量area来保存棒球的横截面积。数字 0.074 是球的直径(单位:米),0.3 是阻力系数,1.225 是空气的密度(单位:kg/m³)。

对于发生在地球表面或附近的情况,我喜欢使用一个坐标系统,其中xy是水平坐标,z是垂直坐标,正z指向远离地球中心的方向。带有空气阻力的抛物线运动发生在一个平面内。选择 xz 平面或 yz 平面来进行这种运动是合理的。我们选择 yz 平面,因为simulateVis函数的默认坐标系统(如果我们选择使用它)中,y是右侧,z是向上。

计算轨迹

下面定义的函数baseballTrajectory会生成一个(y, z)坐标对的列表,其中yz分别是位置的水平分量和垂直分量。我们为这个函数提供一个时间步长、初速度和角度(单位:度)。这个角度是球离开球棒时与水平面的夹角。

baseballTrajectory :: R  -- time step
                   -> R  -- initial speed
                   -> R  -- launch angle in degrees
                   -> [(R,R)]  -- (y,z) pairs
baseballTrajectory dt v0 thetaDeg
    = let thetaRad = thetaDeg * pi / 180
          vy0 = v0 * cos thetaRad
          vz0 = v0 * sin thetaRad
          initialState
              = ParticleState { mass     = 0.145
                              , charge   = 0
                              , time     = 0
                              , posVec   = zeroV
                              , velocity = vec 0 vy0 vz0 }
      in trajectory $ zGE0 $
         statesPS (eulerCromerPS dt) baseballForces initialState

我们定义了几个局部变量来保存角度(弧度制)、初速度的水平和垂直分量以及球的初始状态。我们使用statesPS来生成一个无限状态列表,使用欧拉-克罗梅方法,给定的步长、力的列表(baseballForces)和初始状态。下面定义的函数zGE0会将这个无限列表截断为一个有限列表,仅包含垂直位置大于或等于零的状态。下面定义的函数trajectory将状态列表转化为适合绘制图形的(y, z)对列表。

statesPS生成的无限列表通过函数zGE0被截断为一个有限列表,该函数从无限列表中选择垂直位置分量大于或等于零的元素。当找到一个垂直分量小于零的元素时,它停止检查列表项并返回有限列表。

zGE0 :: [ParticleState] -> [ParticleState]
zGE0 = takeWhile (\(ParticleState _ _ _ r _) -> zComp r >= 0)

通过返回一个有限的状态列表,我们更接近于绘制轨迹图,因为我们无法绘制一个无限的列表。

trajectory函数返回输入列表中每个状态的水平和垂直位置分量。这将是一个自然的绘图目标,因此我们又更接近于绘制轨迹。

trajectory :: [ParticleState] -> [(R,R)]
trajectory sts = [(yComp r,zComp r) | (ParticleState _ _ _ r _) <- sts]

寻找最大射程的角度

让我们继续深入分析棒球。baseballRange函数计算给定初速度和角度下球的水平射程。

baseballRange :: R  -- time step
              -> R  -- initial speed
              -> R  -- launch angle in degrees
              -> R  -- range
baseballRange dt v0 thetaDeg
    = let (y,_) = last $ baseballTrajectory dt v0 thetaDeg
      in y

为了实现这一目标,我们使用之前的baseballTrajectory函数,取最后一个垂直位置分量为非负值的(y, z)对,并返回该对的水平位置分量。

现在让我们绘制击打角度与棒球射程的关系图。在没有空气阻力的情况下,最大射程发生在 45^∘的角度下。也许我们考虑的空气阻力会带来不同的结果。baseballRangeGraph函数在清单 17-2 中绘制了一个以 45 m/s(101 mph)速度击打的棒球射程图。

baseballRangeGraph :: IO ()
baseballRangeGraph
    = plotFunc [Title "Range for baseball hit at 45 m/s"
               ,XLabel "Angle above horizontal (degrees)"
               ,YLabel "Horizontal range (m)"
               ,PNG "baseballrange.png"
               ,Key Nothing
               ] [10,11..80] $ baseballRange 0.01 45

清单 17-2:生成“45 m/s 击打棒球射程”图的代码

图 17-2 展示了击打棒球的水平射程与击球角度之间的关系。我们假设每个角度下的初始速度为 45 m/s(101 mph)。注意,最大射程发生在一个小于 45^∘的角度上。

Image

图 17-2:击打棒球的射程。由于空气阻力,最大射程并不出现在 45^∘的角度下。

我们可以寻找能够产生最大射程的角度。bestAngle值会遍历从 30^∘到 60^∘的所有角度,步长为 1^∘,以找出产生最大射程的角度。

bestAngle :: (R,R)
bestAngle
    = maximum [(baseballRange 0.01 45 thetaDeg,thetaDeg) |
               thetaDeg <- [30,31..60]]

为了找到最大的射程,我们想要比较baseballRange 0.01 45 thetaDeg,即在不同角度下,以 45 米每秒的初速度投掷的射程。但我们希望bestAngle函数返回的是实现最大射程的角度,因此我们不能仅仅要求获取baseballRange 0.01 45 thetaDeg的最大值,因为那只会返回射程,而不会返回实现该射程的角度。

我们可以通过比较一对对的数值并要求返回最大的一对,来获得我们想要的结果,即最大射程和实现该射程的角度。maximum函数在比较一对对时使用字典顺序,因此最大的一对是第一个元素最大的。如果第一个元素相等,函数会比较第二个元素以打破平局。通过将一对的第一个元素选择为射程,比较会根据射程进行,而将第二个元素选择为角度,函数也会返回角度。以下是 GHCi 报告的bestAngle的值:

Prelude> :l Mechanics3D
[1 of 4] Compiling Newton2         ( Newton2.hs, interpreted )
[2 of 4] Compiling Mechanics1D     ( Mechanics1D.hs, interpreted )
[3 of 4] Compiling SimpleVec       ( SimpleVec.hs, interpreted )
[4 of 4] Compiling Mechanics3D     ( Mechanics3D.hs, interpreted )
Ok, four modules loaded.
*Mechanics3D> bestAngle
(116.77499158246208,41.0)

我们看到,在 1^∘的精度范围内,产生最大射程的角度是 41^∘,即高于水平面。

2D 动画

现在让我们转向制作棒球运动的动画。运动发生在一个平面内,因此我们将使用二维的gloss包。我们将制作一个独立的程序来实现动画,然后展示如何让程序接受指定初速度和角度的命令行参数。

主程序

清单 17-3 给出了一个使用gloss进行二维投射物运动动画的独立程序。

{-# OPTIONS -Wall #-}

import SimpleVec
    ( yComp, zComp )
import Mechanics3D
    ( ParticleState(..), simulateGloss, disk
    , projectileInitial, projectileUpdate )
import Graphics.Gloss
    ( Picture(..), red, scale, translate )
import System.Environment
    ( getArgs )

projectilePicture :: ParticleState -> Picture
projectilePicture (ParticleState _m _q _t r _v)
    = scale 0.2 0.2 $ translate yFloat zFloat redDisk
      where
        yFloat = realToFrac (yComp r)
        zFloat = realToFrac (zComp r)
        redDisk :: Picture
        redDisk = Color red (disk 50)

mainWithArgs :: [String] -> IO ()
mainWithArgs args
    = simulateGloss 3 20
      (projectileInitial args) projectilePicture projectileUpdate

main :: IO ()
main = getArgs >>= mainWithArgs

清单 17-3:独立程序,用于二维投射物运动的动画。初速度和角度可以在运行程序时通过命令行指定。

和往常一样,第一行要求显示警告,接下来的几行导入我们在程序中需要使用的函数和类型。

这个程序的一个新特性是我们通过命令行参数向程序传递信息。命令行参数是在你执行程序时,程序名后面给定的一段信息。例如,对于一个叫做GlossProjectile的独立程序,它是通过编译名为GlossProjectile.hs的源代码文件得到的,我们可以通过在命令行输入以下指令来运行该程序:

$ ./GlossProjectile 30 40

我们给出要运行的可执行程序的名称(前面加上点斜杠以指示其在当前目录中的位置),然后跟上一些命令行参数,允许我们将信息传递给程序。我们希望传递初速度和角度(单位为度)。

程序如何接收和使用这些信息?标准模块System.Environment(在你最初安装 GHC 编译器时会包含)提供了一个函数getArgs,它将命令行参数作为字符串列表返回。例如,在执行命令如上所示的程序GlossProjectile时,getArgs函数将返回列表["30","40"]。然后,我们可以使用这些字符串来确定程序的行为。getArgs函数简单且足够满足我们的需求,但如果你在 Haskell 程序中更加认真地使用命令行参数,可能会想查看标准模块System.Console.GetOpt,它同样包含在 GHC 编译器中,提供了更为复杂的命令行参数处理功能。

知道我们将访问包含命令行参数的字符串列表后,我们编写了一个函数来完成我们在之前的动画中main函数的工作,即调用simulateGloss,但它接受一个字符串列表作为输入。清单 17-3 中的mainWithArgs函数正是执行了这个操作。如同以前一样,simulateGloss需要五个信息:一个时间尺度因子、一个动画速率、一个初始状态、一个显示函数和一个状态更新函数。

我们选择了一个时间尺度因子为 3(这样动画的速度比物理演化更快),动画速率为每秒 20 帧。我们将来自命令行的字符串列表命名为args,并将其传递给函数projectileInitial,该函数根据这些字符串创建初始状态。我们稍后会编写projectileInitial函数。

清单 17-3 中的显示函数projectilePicture描述了我们希望为给定粒子状态生成的图像。在这个显示函数中,我们创建了一张图像,然后通过 0.2 像素/米的比例缩放整个图像。scale 0.2 0.2函数将图像在水平和垂直方向上都缩小了五倍。

main程序使用getArgs函数来获取程序运行时指定的任何命令行参数。getArgs函数不是一个纯函数;它是一个有副作用的函数。副作用是指那些不完全符合纯函数计算的操作(即它依赖或以某种方式改变了外部环境)。依赖程序输入、随机性或当前时间的计算都是副作用。向显示器发送信息或写入硬盘也是副作用。一个函数产生的副作用有时被称为副作用,目的是与函数的主要功能区分开来,后者是产生输出。纯函数是指没有副作用的函数;它的输出只依赖于输入和不变的全局值。有副作用的函数是指其输出不仅依赖于输入(例如用户输入、命令行参数或随机性),或者它在输出之外还有副作用。在 Haskell 中,一个有副作用的函数必须具有涉及IO类型构造子的类型。

为了看到这一点,我们加载模块System.Environment,并在其前面加上加号,以便Mechanics3D模块不会被卸载,这是默认行为。

*Mechanics3D> :m +System.Environment
*Mechanics3D System.Environment> :t getArgs
getArgs :: IO [String]

getArgs是一个有副作用的函数,这一点通过IO类型构造子来表明。虽然纯函数的输出只能依赖于输入和不变的全局值,但getArgs的输出依赖于命令行参数,而这些参数既不是函数输入,也不是全局值。因此,getArgs的类型必须是IO [String],而不是[String]。数据上的IO标签意味着这些数据可能是通过某种副作用获得的。数据上没有IO标签则意味着这些数据不是通过任何副作用获得的。

标记有副作用函数的IO类型构造子是称为单子(monads)的一组类型构造子的一个例子。单子的概念在范畴理论的数学中已经存在了几十年;它在函数式编程中的应用较为近期,在那里它代表了一种计算抽象。Haskell 有一个类型类Monad,用于支持能够实现特定功能的类型构造子,如IO。由于IO类型构造子是类型类Monad的一个实例,因此它也被称为IO单子。本书的目的不是深入探讨单子。单子是一个有趣的抽象,但我认为物理学并不强烈需要它们。Real World Haskell [2]和Learn You a Haskell for Great Good [1]对单子有很好的讨论。Stephen Diehl 的《What I Wish I Knew When Learning Haskell》可在dev.stephendiehl.com/hask上找到,也有对单子的很好的讨论。

运算符>>=,称为“bind”,是单子函数中最重要的一个。在我们这里的IO单子上下文中,它提供了一种使用由有副作用的函数产生的信息的方法。为了理解它的使用,我们来看一下它的类型。

*Mechanics3D System.Environment> :t (>>=)
(>>=) :: Monad m => m a -> (a -> m b) -> m b

类型变量ab代表类型,而类型变量m代表类型构造器。类型类Monad有类型构造器作为其实例。Haskell 中的kind概念,在第九章中介绍,有助于分类类型变量所能代表的可能性。

对于我们的目的,类型变量m可以替换为IO,它是类型类Monad的一个实例。将 bind 运算符专门化为IO类型构造器后,bind 具有以下类型:

IO a -> (a -> IO b) -> IO b

我们看到 bind 接收两个参数:一个类型为a的值,由IO类型构造器“标记”,以及一个输入类型为a、输出类型为b的有副作用的函数,输出结果由IO类型构造器“标记”。我们可以将这个IO类型构造器视为一个标签,用来指示值的来源和/或副作用。bind 运算符允许一个IO标记的值作为普通值在承诺返回IO标记结果的函数中使用。由于IO充当副作用的标签,重要的是一旦应用,就不能移除IO标签。然而,如果一个函数返回一个带标签的输出,bind 运算符提供了一种方法,使带标签的输入可以临时去标签并使用,同时知道该函数会重新给输出加标签。

我们在一个类型变量a[String],类型变量b为 unit 的设置中使用了 bind 运算符,因此我们程序中 bind 的具体类型如下:

IO [String] -> ([String] -> IO()) -> IO ()

bind 运算符正是我们所需要的,它将getArgs的输出与mainWithArgs的输入连接起来。实际上,main程序只做了一件事:将getArgs的输出传递给函数mainWithArgs

状态更新函数和初始状态

状态更新函数projectileUpdate是使用updatePS编写的,就像在卫星运动中一样。

projectileUpdate :: TimeStep
                 -> ParticleState  -- old state
                 -> ParticleState  -- new state
projectileUpdate dt
    = updatePS (eulerCromerPS dt) baseballForces

同样,对于我们的数值方法,我们选择了欧拉-克罗默方法。我们使用了同样的单体力列表baseballForces,它之前用于绘制图表。

知道我们想将初始速度和角度作为命令行参数传递给程序,并且这些参数将作为字符串列表提供,我们将编写函数projectileInitial,接受一个来自命令行的字符串列表,并使用这些字符串来确定初始速度。我们希望这个字符串列表有两个元素:第一个字符串表示初始速度,第二个表示初始角度(以度为单位)。

projectileInitial :: [String] -> ParticleState
projectileInitial []        = error "Please supply initial speed and angle."
projectileInitial [_]       = error "Please supply initial speed and angle."
projectileInitial (_:_:_:_)
    = error "First argument is speed.  Second is angle in degrees."
projectileInitial (arg1:arg2:_)
    = let v0       = read arg1 :: R       -- initial speed, m/s
          angleDeg = read arg2 :: R       -- initial angle, degrees
          theta    = angleDeg * pi / 180  -- in radians
      in defaultParticleState
             { mass     = 0.145  -- kg
             , posVec   = zeroV
             , velocity = vec 0 (v0 * cos theta) (v0 * sin theta)
             }

我们对输入进行模式匹配,以便在没有提供两个参数时给出有帮助的错误信息。第一行响应空列表的情况,即没有给定任何命令行参数的情况。第二行响应只有一个命令行参数的情况。第三行响应给定三个或更多命令行参数的情况。最后,第四行处理正好提供两个命令行参数的情况,这就是我们所需要的。

我们使用read函数将字符串转换为实数。read函数接受一个字符串作为输入,并生成一个输出,这个输出可以是多种类型之一。我们需要提供类型注解来指定我们希望将字符串转换为什么类型。以下是read函数的示例:

*Mechanics3D System.Environment> :t read
read :: Read a => String -> a
*Mechanics3D System.Environment> read "56" :: R
56.0

3D 动画

创建 3D 动画所需的几个项目与创建 2D 动画所需的相同,但有一个不同。为了比较制作 3D 动画和制作 2D 动画的过程,让我们使用 3D 动画工具来演示弹道运动。清单 17-4 提供了一个独立的程序,用于使用not-gloss进行弹道运动的 3D 动画。

{-# OPTIONS -Wall #-}

import SimpleVec ( R, (*^) )
import Mechanics3D
    ( ParticleState(..), simulateVis , projectileInitial, projectileUpdate, v3FromVec )
import Vis
    ( VisObject(..), Flavour(..), red )
import System.Environment
    ( getArgs )

projectileVisObject :: ParticleState -> VisObject R
projectileVisObject st
    = let r = posVec st
      in Trans (v3FromVec (0.01 *^ r)) (Sphere 0.1 Solid red)

mainWithArgs :: [String] -> IO ()
mainWithArgs args
    = simulateVis 3 20
      (projectileInitial args) projectileVisObject projectileUpdate

main :: IO ()
main = getArgs >>= mainWithArgs

清单 17-4:一个独立程序用于 3D 动画的弹道运动

main函数与 2D 动画中的完全相同。mainWithArgs函数使用simulateVis代替simulateGloss,但是它使用了与 2D 动画相同的时间尺度因子、动画速率、初始状态函数和状态更新函数。

我们唯一需要的新组件是一个显示函数,projectileVisObject。在这个显示函数中,状态被命名为st,我们定义了一个局部变量r表示物体的位置。我们使用一个半径为 0.1 的实心红色球体来表示弹道物体。not-gloss包并不以像素为单位来测量距离;相反,初始时长度 1 大约是屏幕高度的 20%。然后,你可以通过按 E 或 Q 键,或者使用鼠标来放大或缩小。在我们将红色球体移到适当的位置之前,我们需要将位置r从米转换为Vis单位,并且需要将位置转换为Vis的向量类型。我们将位置乘以一个因子 0.01,将每米转换为Vis单位,这样动画的范围既不太大也不太小。在使用Trans函数之前,我们使用在第十六章中定义的v3FromVec函数将位置转换为Vis的向量类型。

在看到带空气阻力的弹道运动示例、使用命令行参数将信息传递到程序中的技术以及 2D 与 3D 动画的比较之后,接下来我们将看一个真正需要 3D 动画的示例。

磁场中的质子

磁场被用于粒子加速器中,使质子或电子沿圆环轨道运动,以加速它们并使它们相互碰撞。这让实验人员能够观察到在这些高能碰撞中产生的粒子,并了解粒子及其相互作用的性质。

处于均匀磁场中的带电粒子将沿着圆形或螺旋形轨迹运动。这一点并不显而易见,但它是洛伦兹力定律的结果,均匀磁场下可用公式 16.8 表示。幸运的是,螺旋运动对于展示我们的三维动画工具来说是一个很好的运动方式。

根据洛伦兹力定律,粒子所受的磁力与v(t) × B的叉乘成正比,这意味着力垂直于粒子的速度和磁场。由于磁力始终垂直于粒子的速度,它无法使粒子加速或减速;它只能使粒子转弯(改变方向)。

要制作这个动画,我们需要一个状态更新函数、一个初始状态和一个显示函数。这里是一个粒子在强度为 3 × 10^(-8) 特斯拉的均匀磁场中的状态更新函数。

protonUpdate :: TimeStep -> ParticleState -> ParticleState
protonUpdate dt
    = updatePS (rungeKutta4 dt) [uniformLorentzForce zeroV (3e-8 *^ kHat)]

我们使用四阶龙格-库塔方法作为数值计算方法,因为它在步长较大的情况下能产生良好的结果,而欧拉-克罗默方法则需要较小的步长才能得到好的结果。这个在事前是无法知道的。检查结果在步长变化时是否稳定总是一个好主意。力的列表只有一个项,即均匀磁场的洛伦兹力。zeroV表示零电场。

这里是磁场作用下质子的初始状态:

protonInitial :: ParticleState
protonInitial
    = defaultParticleState { mass     = 1.672621898e-27  -- kg
                           , charge   = 1.602176621e-19  -- C
                           , posVec   = zeroV
                           , velocity = 1.5*^jHat ^+^ 0.3*^kHat  -- m/s
                           }

通过给质子在 y 和 z 方向上赋予初始速度分量,我们会得到螺旋运动。如果其中一个分量设置为 0,运动方式将不同。你可以尝试一下,看看会发生什么。

这是显示质子在磁场中的函数:

protonPicture :: ParticleState -> V.VisObject R
protonPicture st
    = let r0 = v3FromVec (posVec st)
      in V.Trans r0 (V.Sphere 0.1 V.Solid V.red)

红色小球用于标记质子的位置。

清单 17-5 展示了一个独立的 Haskell 程序,用于在磁场中动画展示质子。时间尺度因子设置为 1,因此这是一个实时动画。请注意,在这个示例中,磁场非常小,而较大的磁场将使质子在更短的时间内完成螺旋转弯。动画帧率设置为每秒 60 帧。

{-# OPTIONS -Wall #-}

import Mechanics3D (simulateVis, protonInitial, protonPicture, protonUpdate)

main :: IO ()
main = simulateVis 1 60 protonInitial protonPicture protonUpdate

清单 17-5:用于在均匀磁场中显示质子三维动画的独立 Haskell 程序

总结

在本章中,我们使用了第十六章中的思想和代码,研究了受不同力作用的三维空间中单个粒子的运动。我们展示了卫星运动、带空气阻力的抛体运动以及在磁场中的粒子运动的 2D 或 3D 动画示例。在下一章,我们将展示如何使用或稍微修改第十六章中的思想和代码,用相对论理论代替牛顿第二定律,来处理单粒子力学问题。

练习

练习 17.1. 修改清单 17-1 中的halleyPicture函数,在动画中加入 x 轴和 y 轴。你将能够看到彗星的远日点(离太阳最远的点)并未与 x 轴完全对齐。这表明数值方法存在不准确性,减少时间尺度因子和减小时间步长可以降低这种不准确性。

练习 17.2. 假设地球位于我们坐标系统的原点。考虑一颗质量为m的卫星,其初始位置为r[0],初始速度为v[0]。由于卫星的运动发生在一个平面内,我们可以使用位于 xy 平面内的向量。绘制不同初始条件下轨道的轨迹。选择一些初始条件,使其得到近乎圆形的轨道,也选择其他一些初始条件,使其得到椭圆形轨道。你会发现欧拉方法产生的轨道并不会闭合。绘制一张比较欧拉方法和欧拉-克罗梅尔方法的图表,选择你喜欢的一条轨道(圆形或椭圆形)。标明你在欧拉方法和欧拉-克罗梅尔方法中使用的步长以及你的初始条件选择。

练习 17.3. 洛伦兹力定律,方程 16.8,描述了电场E和磁场B对一个带电粒子(电荷为q,速度为v(t))施加的力。考虑一个方向为 z 的均匀磁场。你可能已经知道,带电粒子如果初始速度沿 x 方向,在这个磁场中会沿圆形轨道运动。选择一些磁场强度、粒子电荷、粒子质量和初始速度的值。使用欧拉-克罗梅尔方法确认粒子确实沿圆形轨道运动。绘制不同时间步长下的yx的关系图。如果时间步长过大,即使是欧拉-克罗梅尔方法也无法产生闭合的圆形轨道。你应该选择一个足够小的时间步长,使得轨道看起来会闭合。

练习 17.4. 回到绕地球运行的卫星。编写一个 Haskell 程序,动画展示卫星绕地球的运动。通过使用不同的初始条件,展示你可以得到圆形轨道和椭圆形轨道。

习题 17.5. 假设风速为 10 m/s,并且你将一个乒乓球以 5 m/s 的初速度直接向上发射。它会距离发射点多远落地?你可以估算空气的密度、球的质量和横截面积,但对于阻力系数的估计则更像是猜测。尝试计算阻力系数为 0.5、1.0 和 2.0 时的结果,并比较它们的差异。用相同速度发射一个高尔夫球,并重复计算。

习题 17.6. 对本章中展示的代码进行必要的修改,使得棒球的初始位置为离地面 1 米。绘制一条 40 m/s 速度、角度为 5^∘的平飞轨迹图。

习题 17.7. 调查 30 英里每小时横风对击打棒球的影响。假设风是垂直于球原本会行进的平面,风将球从原本着陆的地方移动多远?选择一些合理的初速度和角度值。

习题 17.8. 给定初速度和阻力系数,最优角度是能够使棒球达到最远距离的角度。绘制初速度为 45 m/s 时,最优角度随阻力系数变化的图像。

习题 17.9. 如果你能产生均匀的电场和磁场,你可以制造一个叫做速度选择器的装置。速度选择器的目的是允许以特定速度运动的带电粒子沿直线前进,而其他速度较快或较慢的粒子则会偏转。从一束具有不同速度的带电粒子中,速度选择器可以产生一束速度几乎相同的粒子。通过这种方式,实验者可以获得具有已知速度的带电粒子束,用于某些实验。

让我们使用一个均匀的电场(强度为 300 N/C,方向沿正 z 轴)和一个均匀的磁场(强度为 0.3 T,方向沿正 x 轴)来模拟速度选择器。我们关注的是质量为 1.00 × 10^(–22) kg 的单次电离粒子的运动。(单次电离意味着去掉了一个电子,粒子带有一个质子的电荷。)我们将赋予该粒子一个沿正 y 轴方向的初速度。如果粒子运动得太快,它会偏转一个方向;如果运动得太慢,它会偏转另一个方向。

使用Vis模块,制作一个独立的程序,输入粒子的初速度(类似于我们投射物运动程序输入初速度和角度的方式),并为速度选择器中的粒子制作动画。为了判断粒子是否偏转以及偏转的程度,在图像中加入坐标系(类似于我们在第十三章中展示的坐标系),这样你就能知道粒子何时偏离 y 轴。使用时间尺度因子 5 × 10^(–4)和动画速率 60 帧/秒。使用不同初速度(介于 0 和 5000 m/s 之间)运行该程序。

(a) 确认粒子在低速时会朝一个方向偏转。

(b) 确认粒子在高速时会朝另一个方向偏转。

(c) 扩展你的程序,加入一个位于y = 1 m 的圆形孔径,允许粒子通过。首先设定孔径半径为 4 厘米。

apR :: R
apR = 0.04  -- meters

位于该半径外的粒子将会被墙壁阻挡,无法通过。修改你的状态更新函数,加入一个墙壁力,当粒子到达y = 1 m 时,阻挡半径外的粒子通过。你可以使用以下墙壁力:

wallForce :: OneBodyForce
wallForce ps
    = let m = mass ps
          r = posVec ps
          x = xComp r
          y = yComp r
          z = zComp r
          v = velocity ps
          timeStep = 5e-4 / 60
      in if y >= 1 && y < 1.1 && sqrt (x**2 + z**2) > apR
         then (-m) *^ (v ^/ timeStep)
         else zeroV

这个墙壁力施加一种耗散性力,如果粒子的 y 值在 100 厘米到 110 厘米之间且位于孔径半径外,它将在很短的时间步内将粒子减速至几乎停下。你可以把墙壁看作是 10 厘米的铅墙,但实际原因是为了捕捉那些快速移动的粒子;如果减少时间步长,墙壁的厚度可以减小。(你可能会注意到粒子沿墙壁缓慢移动,或者穿过这个 10 厘米长的孔径“管道”,因为电场仍然对其起作用。)修改你的显示函数,加入一个圆形代表孔径。找出通过 4 厘米孔径的速度范围。1 厘米半径的孔径允许通过的速度范围是多少?那 1 毫米半径的孔径呢?

尝试猜测目标速度(未偏转时的粒子速度)与电场和磁场的数值之间的关系。

练习 17.10. 使用Vis模块动画化哈雷彗星的运动。改用四阶 Runge-Kutta 法代替欧拉-克罗默法,因为Vis模块无法达到每秒 400 帧的动画速率(尽管它不会告诉你这一点,但会尽力做到最好)。尝试使用 20 帧/秒(太低,轨道会螺旋向太阳靠近)、60 帧/秒(相当好)和 400 帧/秒(非常好,尽管 400 帧/秒实际上未能达到)。你可能需要使用以下函数作为图像的最后变换,然后将其交给simulateVis

zOut :: V.VisObject R -> V.VisObject R
zOut = V.RotEulerDeg (Euler 90 0 90)

函数zOut调整显示方向,使得 xy 平面大致与屏幕平面平行,并且z轴从屏幕中指向外面。默认方向是x轴从屏幕外指向,y轴指向右侧,z轴指向屏幕上方。

习题 17.11. 使用gnuplot绘制哈雷彗星轨道,采用不同的数值方法。使用欧拉法、欧拉-克罗默法和四阶龙格-库塔法,每种方法的时间步长分别为 1/20 年、1/60 年和 1/400 年。结果应类似于图 17-3,其中左列为欧拉法,中列为欧拉-克罗默法,右列为四阶龙格-库塔法。第一行使用 1/20 年的时间步长,中间一行使用 1/60 年的时间步长,底行使用 1/400 年的时间步长。

Image

图 17-3:哈雷彗星轨道,使用欧拉法、欧拉-克罗默法和四阶龙格-库塔法,时间步长分别为 1/20 年、1/60 年和 1/400 年。

在计算轨道时,我们可以利用能量守恒来检查积分方案的数值精度。粒子状态结构包含彗星的位置,从中我们可以找到势能,以及速度,从而计算出动能。编写一个函数

energy :: ParticleState -> R
energy ps = undefined ps

计算给定ParticleState下哈雷彗星的总能量。我们知道哈雷彗星的总能量是守恒的,因此我们计算出的能量变化是由于我们使用的数值方法的不准确性。我们可以通过计算一个轨道周期内的能量变化率来衡量我们的数值方法的准确性。

对于合理的数值方法,例如图 17-3 右下角的三种方法,我们可以使用以下谓词与takeWhile结合,在一个轨道周期结束后截断状态列表:

firstOrbit :: ParticleState -> Bool
firstOrbit st
    = let year = 365.25 * 24 * 60 * 60
      in time st < 50 * year || yComp (posVec st) <= 0

该谓词通过接受前 50 年的轨道数据(记住周期大约是 75 年),此时位置的 y 分量为负数,并继续接收数据,直到 y 分量变为正数,这表示第二个轨道的开始。

计算一个轨道周期内能量的变化率:(a)使用步长为 1/400 年的欧拉-克罗默方法(你应该得到大约 1%的变化率),(b)使用步长为 1/60 年的四阶龙格-库塔方法,(c)使用步长为 1/400 年的四阶龙格-库塔方法。作为额外挑战,使用gnuplot标记每个图形,表示单个轨道周期内的能量变化率。

第十八章:相对论简短入门

Image

阿尔伯特·爱因斯坦对电磁理论充满兴趣,我们将在本书的第三部分中讨论这个话题。试图理解这一理论促使他对空间和时间提出了新的看法,这些看法统称为狭义相对论,他在 1905 年发表了这一理论,并修改了已经存在 200 多年的牛顿力学思想。

狭义相对论在多个方面偏离了牛顿物理学,最显著的概念性偏离是时间的非普适性——也就是说,不同运动模式下的钟表以不同的速度变化。狭义相对论的主题需要一个完整的课程来深入理解相对论的运动学和动力学,但在这里我们只会浅尝辄止。

在本章中,我们将超越牛顿力学,展示狭义相对论如何对粒子的运动作出不同的预测,尤其是当粒子运动非常快时。我们仍然会使用图 16-2 框架,在该框架中,我们从力变换到微分方程,再到状态更新函数,最后得到一系列状态,这一过程依然有效。只是牛顿第二定律需要被相对论运动定律所取代,以计算狭义相对论对受到力作用的粒子运动的预测。相对论定律将把力转化为一个与牛顿第二定律产生的微分方程不同的微分方程。解决微分方程的其余步骤在相对论中与牛顿力学中相同。在本章的最后,我们将展示一些例子,其中牛顿力学和相对论作出了不同的预测。让我们从具体阐述狭义相对论如何偏离牛顿力学开始。

一点理论

在狭义相对论中,作用在粒子上的合力仍然是作用在该粒子上的所有力的矢量和。

Image

相对论中没有新的力。狭义相对论理论声称,合力接近但不完全等于质量乘以加速度,就像牛顿的第二定律所说的那样。这个差异在物体运动速度接近光速时更加明显。然而,牛顿第二定律仍然有一个版本在相对论中成立。合力仍然是动量随时间变化的速率。公式 16.1 需要被替换为

Image

其中p(t)是所考虑物体的动量。

在狭义相对论中,速度和动量之间的关系与牛顿力学中的不同。在牛顿力学中,粒子的动量是其质量乘以速度,p(t) = mv(t)。在相对论物理中,粒子的动量是

Image

其中 c = 299,792,458 m/s,是真空中的光速。我们可以通过代数方式反转这个方程,得到一个以动量为变量的速度表达式。

图片

加速度仍然是速度关于时间的变化率,因此通过对前面的方程取时间导数并将净力代入动量的时间导数,我们得到了一个相对论形式的加速度表达式,表示为净力。

图片

方程 18.1 是方程 16.5 的相对论替代形式。你可以看到,如果粒子速度与光速的比值远小于 1,那么这个方程的右侧就会简化为净力除以质量,我们就恢复了牛顿第二定律的原始形式。这意味着,对于像棒球这样的物体在空气中运动时,牛顿第二定律基本上是有效的。但如果它的速度接近光速,我们就需要使用相对论。

如果方程 18.1 看起来过于复杂且不合逻辑,你应该知道,特殊相对论有自己的符号系统,使得像方程 18.1 这样的方程看起来更简洁。相对论的符号使用了 4-矢量,因为时空有四个维度。我们在本书中使用的牛顿力学矢量被称为 3-矢量,因为空间有三个维度。从相对论的角度看,3-矢量是基于将时空任意划分为一个特定的三维空间和一个特定的时间维度的。我们认为是独立的某些量,例如动量和能量,在相对论中结合成了 Taylor 和 Wheeler 所称的 动能四维矢量momenergy)。^(1) 只有当我们将相对论的新概念转化为牛顿力学的旧符号时,它们才显得如此复杂。然而,尽管相对论有不同的符号表示法,它和我们在本书中使用的符号给出的结果是相同的。

牛顿第二定律的替代形式

在第十六章和第十七章中,我们使用了函数 newtonSecondPS 来生成一个表达牛顿第二定律的微分方程。接下来我们将编写的函数 relativityPS 生成一个符合特殊相对论动力学的微分方程,因此它可以替代 newtonSecondPS。幸运的是,我们可以使用与前几章中相同的数据类型 ParticleState 来表示粒子状态。

newtonSecondPSrelativityPS之间的主要区别在于我们返回加速度的表达式。我们希望使用方程式 18.1,而不是将净力除以质量。函数relativityPS假设使用国际单位制(SI 单位),因此速度以米每秒为单位。相对论更优雅地通过自然单位或几何化单位表达,其中c = 1,这意味着一秒钟可以与 299,792,458 米互换。练习 18.2 要求你编写一个类似的函数,不假设使用 SI 单位。

这里是relativityPS,我们将其包含在Mechanics3D模块中,该模块包含第十六章、第十七章和第十八章中所有不属于独立程序的代码。

relativityPS :: [OneBodyForce]
             -> ParticleState -> DParticleState  -- a differential equation
relativityPS fs st = let fNet = sumV [f st | f <- fs]
          c = 299792458  -- m / s
          m = mass st
          v = velocity st
          u = v ^/ c
          acc = sqrt (1 - u <.> u) *^ (fNet ^-^ (fNet <.> u) *^ u) ^/ m
      in DParticleState { dmdt = 0    -- dm/dt
                        , dqdt = 0    -- dq/dt
                        , dtdt = 1    -- dt/dt
                        , drdt = v    -- dr/dt
                        , dvdt = acc  -- dv/vt
                        }

let子句引入了局部变量,用于表示净力、光速、状态中包含的质量和速度、以光速单位表示的速度u,以及由方程式 18.1 确定的加速度。然后,在let结构的主体中准备并返回状态的时间导数。

现在让我们来看第一个例子,将牛顿力学与特殊相对论进行比较。

对恒定力的响应

让我们将特殊相对论的预测与牛顿力学的预测进行对比。我们将探索的第一个情况是一个粒子的运动,最初处于静止状态,并在一段较长时间内受到恒定力的作用。

图 18-1 显示了一个 1 千克物体在受到 10 牛顿力作用下,速度随时间变化的图表。这接近于地球表面上 1 千克物体所受的重力(1-g加速度)。

Image

图 18-1:牛顿力学和相对论对恒定力响应的比较。一个 1 千克的物体受到 10 牛顿的恒定力作用。

在最初的几个月里,特殊相对论的预测与牛顿力学的预测在速度上几乎没有差异。然而,当速度接近光速时,二者之间出现了差异,特殊相对论的曲线预测速度逐渐接近但永远无法达到光速,而牛顿力学则预测速度线性增加,最终超过光速。由于有非常强的实验证据表明有质量的物体无法超过光速,因此牛顿力学的预测显然是错误的。

清单 18-1 显示了生成该图表的代码。

constantForcePlot :: IO ()
constantForcePlot
    = let year = 365.25 * 24 * 60 * 60  -- seconds
          c = 299792458                -- m/s
          method = rungeKutta4 1000
          forces = [const (10 *^ iHat)]
          initialState = defaultParticleState { mass = 1 }
          newtonStates = solver method (newtonSecondPS forces) initialState
          relativityStates = solver method (relativityPS forces) initialState
          newtonTVs = [(time st / year, xComp (velocity st) / c)
                           | st <- takeWhile tle1yr newtonStates]
          relativityTVs = [(time st / year, xComp (velocity st) / c)
                               | st <- takeWhile tle1yr relativityStates]
      in plotPaths [Key Nothing
                   ,Title "Response to a constant force"
                   ,XLabel "Time (years)"
                   ,YLabel "Velocity (multiples of c)"
                   ,PNG "constantForceComp.png"
                   ,customLabel (0.1,1) "mass = 1 kg"
                   ,customLabel (0.1,0.9) "force = 10 N"
                   ,customLabel (0.5,0.7) "Newtonian"
                   ,customLabel (0.8,0.6) "relativistic"
                   ] [newtonTVs,relativityTVs]

清单 18-1:生成图表“对恒定力的响应”的代码

在代码开始时定义了几个局部变量,例如一年的秒数、光速(以米/秒为单位)、数值方法、初始状态等。前五个局部变量在牛顿计算和相对论计算中都被使用。newtonStatesrelativityStates是牛顿和相对论理论的状态的无限列表。通过比较它们的定义,我们看到它们使用相同的数值方法、相同的力(在 x 方向上的一个 10N 的力)和相同的初始状态。唯一的区别是我们用relativityPS替代了newtonSecondPS,作为生成我们正在求解的微分方程的函数。

最后,newtonTVsrelativityTVs是适合绘图的时间-速度对列表。这两个列表的定义几乎完全相同。在每种情况下,代码使用列表推导和takeWhile函数来生成一个有限列表。谓词tle1yr判断与某个状态相关的时间是否小于或等于一年。你在练习 16.7 中被要求编写这个函数。

代码使用customLabel函数在图表上放置多个标签,这个函数我在第十一章中首次介绍,现为了方便再次提及。

customLabel :: (R,R) -> String -> Attribute
customLabel (x,y) label
   = Custom "label"
     ["\"" ++ label ++ "\"" ++ " at " ++ show x ++ "," ++ show y]

磁场中的质子

作为第二个例子,我们将特殊相对论的预测与牛顿力学的预测进行对比,来看一下带电粒子在磁场中的运动。虽然我们第一个特殊相对论的例子发生在一个空间维度上,但这个例子发生在两个维度上。图 18-2 展示了在指向 z 方向(垂直于圆形轨迹平面)的 1 特斯拉磁场中的质子轨迹。

图像

图 18-2:质子在磁场中的运动

质子的速度是光速的 4/5。牛顿理论和相对论理论都预测了圆形运动,但圆的半径不同。从图表中我们可以看到,相对论预测的半径比牛顿理论的半径要大。相对论的半径最终比牛顿的半径大一个因子图像,这个因子在相对论的许多地方都有出现。在这种情况下,相对论的半径是牛顿半径的 5/3 倍。

清单 18-2 展示了生成轨迹的代码。

circularPlot :: IO ()
circularPlot
    = let c = 299792458  -- m/s
          method = rungeKutta4 1e-9
          forces = [uniformLorentzForce zeroV kHat]    -- 1 T
          initialState = defaultParticleState
                         { mass    = 1.672621898e-27  -- kg
                         , charge   = 1.602176621e-19  -- C
                         , velocity = 0.8 *^ c *^ jHat
                         }
          newtonStates = solver method (newtonSecondPS forces) initialState
          relativityStates = solver method (relativityPS forces) initialState
          newtonXYs = [(xComp (posVec st), yComp (posVec st))
                           | st <- take 100 newtonStates]
          relativityXYs = [(xComp (posVec st), yComp (posVec st))
                               | st <- take 120 relativityStates]
      in plotPaths [Key Nothing
                   ,Aspect (Ratio 1)
                   ,Title "Proton in a 1-T magnetic field"
                   ,XLabel "x (m)"
                   ,YLabel "y (m)"
                   ,PNG "circularComp.png"
                   ,customLabel (0.5,4.5) "v = 0.8 c"
                   ,customLabel (2.5,0.0) "Newtonian"
                   ,customLabel (3.0,3.5) "relativistic"
                   ] [newtonXYs,relativityXYs]

清单 18-2:生成图表“质子在 1 特斯拉磁场中的运动”的代码

这四个局部变量在两个理论的预测中都被使用,它们定义了光速、数值方法、力的列表和初始状态。

我们如何为数值方法选择合适的时间步长?猜测可以奏效,但时间步长过大会导致难以理解的结果,而时间步长过小则可能导致几乎没有运动,或者根据计算机要求的不同,计算时间非常长。关键在于,我们希望时间步长相对于情况的关键时间尺度来说要小。一个情况的关键时间尺度可以通过维度分析来找到。通过维度分析,乘除相关参数以得到具有时间维度的量,我们可以找到一个或多个特征时间尺度。在此情况下,相关的参数是质子电荷、质子质量、1 特斯拉的磁场以及初始速度为 4c/5。

从这些参数中形成一个具有时间维度的量的唯一方式是将质子质量除以质子电荷与磁场的乘积。这些参数的组合产生一个时间为 m[p] / (q[p] B) = 1.04 × 10^(-8) s。为了使时间步长相对于问题的相关时间尺度较小,我们应该将这个时间除以 100 或 1,000。因此,时间步长 10^(-10) s 是一个很好的初始猜测。

列表newtonStatesrelativityStates分别是牛顿力学和相对论情况的无限状态列表,就像之前常力示例中的情况一样。列表newtonXYsrelativityXYs是适合绘图的(x, y)对列表。由于最终会得到圆周运动,因此使用与 y 轴相同的 x 轴比例会更加美观。这可以通过选项列表中的Aspect (Ratio 1)选项来实现。

在相对论和牛顿计算中的速度是相同的,但由于相对论的圆圈较大,质子运动的周期(即完成一次圆周运动的时间)在相对论理论中较大。然而,这一点在图表中并不明显。由于这一点,并且为了展示动画中两个独立运动的技术,我们将为这些质子制作一个动画。

由于运动发生在二维空间中,我们将使用gloss进行动画。我们在这里动画化的并不是两个质子之间的相互作用,那将是一个包含多个粒子的物理问题,我们将在下一章讨论这类问题。而是,我们感兴趣的是展示两个质子同时独立运动的动画。到目前为止,我们编写的每个动画的状态空间都与基础物理情况的状态空间相同。对于三维空间中的单个粒子,状态空间是ParticleState。现在我们想要动画化两个粒子,每个粒子都使用状态空间ParticleState。这意味着动画的状态空间需要是(ParticleState, ParticleState),这样动画才能追踪两个粒子。

以下是用于动画的状态更新函数,它结合了两个状态更新函数:一个是牛顿理论的更新函数,一个是相对论的更新函数。

twoProtUpdate :: TimeStep
              -> (ParticleState,ParticleState)
              -> (ParticleState,ParticleState)
twoProtUpdate dt (stN,stR)
    = let forces = [uniformLorentzForce zeroV kHat]
      in (rungeKutta4 dt (newtonSecondPS forces) stN
         ,rungeKutta4 dt (relativityPS   forces) stR)

局部变量stN表示牛顿计算中的传入(尚未更新)状态,而stR是相对论计算中的类似状态。

动画的初始状态将两个情况(牛顿与相对论)的初始状态结合在一起,这两个状态是相同的。

twoProtInitial :: (ParticleState,ParticleState)
twoProtInitial
    = let c = 299792458  -- m/s
          pInit = protonInitial { velocity = 0.8 *^ c *^ jHat }
      in (pInit,pInit)

显示函数为牛顿计算生成蓝色圆盘,为相对论计算生成红色圆盘。

twoProtPicture :: (ParticleState,ParticleState) -> G.Picture
twoProtPicture (stN,stR)
    = G.scale 50 50 $ G.pictures [G.translate xN yN protonNewtonian
                                 ,G.translate xR yR protonRelativistic]
      where
        xN = realToFrac $ xComp $ posVec stN
        yN = realToFrac $ yComp $ posVec stN
        xR = realToFrac $ xComp $ posVec stR
        yR = realToFrac $ yComp $ posVec stR
        protonNewtonian = G.Color G.blue (disk 0.1)
        protonRelativistic = G.Color G.red (disk 0.1)

Listing 18-3 显示了动画的主程序。这个程序以及所有其他独立的程序都不是Mechanics3D模块的一部分。它使用了时间尺度因子 10^(–8),动画速率为每秒 20 帧,并且使用了我们刚刚定义的三个函数。

{-# OPTIONS -Wall #-}

import Mechanics3D
    ( simulateGloss
    , twoProtInitial, twoProtPicture, twoProtUpdate
    )

main :: IO ()
main = simulateGloss 1e-8 20
       twoProtInitial twoProtPicture twoProtUpdate

Listing 18-3:用于质子在磁场中二维运动动画的独立程序

摘要

在本章中,我们介绍了特殊相对论作为一种不同的、更现代的力学理论。通过将牛顿第二定律替换为适当的相对论公式来从力的列表中生成微分方程,我们的方法能够处理这种理论。使用相对论来解决力学问题仍然是一个通过一系列四种表示形式转换信息的过程,首先是单体力,其次是微分方程,然后是状态更新函数,最后是状态列表。相对论法则,方程 18.1,在这个过程中作为将作用在物体上的力的列表转换为微分方程的手段。数值方法仍然将微分方程转换为状态更新函数,我们仍然使用迭代来生成状态列表,作为力学问题的解答。我们能够使用与牛顿力学相同的ParticleState数据类型。我们开发了思想和工具来使用特殊相对论定律解决任何单粒子力学问题。这是最后一章集中讨论单粒子的内容。在下一章,我们将讨论多个相互作用的粒子。

练习

练习 18.1。 图 18-1 中的计算使用了什么时间步长?

练习 18.2。 我们编写的用于处理相对论动力学的relativityPS函数假设速度以国际单位制(SI)给出。然而,这并不总是方便的。我们可能希望使用自然单位,其中 c = 1。让我们编写一个函数,接受 c 的值作为输入,从而使我们能够使用 SI 单位、自然单位或其他任何我们可能需要的单位。

使用方程 18.1 来编写函数。

relativityPS' :: R  -- c
              -> [OneBodyForce]
              -> ParticleState -> DParticleState
relativityPS' c fs st = undefined c fs st

练习 18.3。 通过将其与牛顿谐振子进行比较,探索相对论谐振子。唯一的力是一个线性恢复力,选择一个弹簧常数,使得牛顿周期为 1 秒。使用 1 公斤的质量、初始位置为 0,并选择一个方向上初始速度为 4/5c。(运动是单维的。)使用本章中的一个示例作为代码模板。绘制牛顿结果和相对论结果的速度与时间的关系图。你的结果应该类似于图 18-3。

Image

图 18-3:相对论谐振子

第十九章:相互作用粒子

Image

在牛顿力学中,作用于粒子的力是由其他粒子产生的。本章的目标是发展出关键的概念,使我们能够预测多个相互作用粒子的运动:牛顿第三定律、两体力,以及内部力与外部力的区别。像往常一样,我们将通过代码来表达这些概念。

我们将通过讨论牛顿第三定律来开始这一章。接着,我们将发展两体力的概念,来表达两个粒子之间的相互作用导致一个粒子对第二个粒子施加一个力,并同时导致第二个粒子对第一个粒子施加另一个力。两体力在多粒子情况中是一个足够重要的概念,因此我们将为两体力定义一种数据类型。我们将决定哪些粒子将出现在我们的系统中,并区分内部力和外部力。最后,我们将通过考虑多粒子系统的状态并编写一个状态更新规则来结束这一章,这个规则会自动应用牛顿第三定律,这样我们就不需要手动应用它了。在下一章中,我们将把这里讨论的概念应用到具体的例子情境中。

牛顿第三定律

如果我们站在滑冰场上并推我们的朋友,我们可能会发现自己会朝着与推的方向相反的方向加速。我们对朋友施加了一个力,但我们的朋友也对我们施加了一个力,不管他们是否有这个意图。牛顿第三定律声称这两个力是相等且方向相反的。

牛顿第三定律,牛顿的原话 [15]

任何动作都会有一个相反且相等的反应;换句话说,两个物体之间的相互作用总是相等的,并且方向总是相反的。

牛顿第三定律,现代版本

如果物体 A 对物体 B 施加一个力,那么物体 B 对物体 A 施加一个力。这个第二个力的大小与第一个力相等,但方向相反。

当我们说第二个力时,并不意味着时间上的顺序。这些力是一起产生的,来自于同一个过程,不管物体之间的相互作用是什么。

在处理牛顿第二定律时,正如我们在前五章中所做的那样,我们只关注作用在我们应用牛顿第二定律的物体上的力。如果一个物体还对其他物体产生了力,这也是可以的,但这些力只有在我们将牛顿第二定律应用到那些其他物体时才会被考虑。牛顿第二定律关心的是作用在物体上的力,而不是物体产生的

另一方面,牛顿第三定律关心的是两者,并对每个相互作用之间的关系进行断言。牛顿第二定律适用于一个物体;而牛顿第三定律适用于两个物体之间的相互作用。

示例 19-1 展示了我们将在本章中开发的MultipleObjects模块的第一行代码。

{-# OPTIONS -Wall #-}
{-# LANGUAGE MultiParamTypeClasses #-}

module MultipleObjects where

import SimpleVec
    ( Vec, R, (^+^), (^-^), (*^), (^*), (^/), zeroV, magnitude )
import Mechanics1D
    ( RealVectorSpace(..), Diff(..), NumericalMethod, Mass, TimeStep, euler )
import Mechanics3D
    ( OneBodyForce, ParticleState(..), DParticleState(..), HasTime(..)
    , defaultParticleState, newtonSecondPS )

示例 19-1:MultipleObjects模块的开头代码

你现在应该已经熟悉第一行代码了:它启用了警告。第二行启用了一个语言选项,允许我们使用多参数类型类;我们将在本章后面解释这一点。我们将模块命名为MultipleObjects。我们从SimpleVec导入数据类型VecR,以便在我们的类型签名中使用它们。我们还导入了零向量、magnitude函数和基本向量运算符,从SimpleVec中进行引用。我们使用类型类RealVectorSpaceHasTime来扩展通用数值方法eulerrungeKutta4,以适应本章的多粒子环境。通过使多粒子状态的新数据类型成为这两个类型类的实例,我们将能够使用这两个通用数值方法。我们导入了newtonSecondPS,该函数将牛顿第二定律应用于单个粒子,供我们在编写将牛顿第二定律应用于一组粒子的函数时使用。我们导入了euler,作为编写多粒子欧拉-克罗梅方法的基础。我们将在本章的过程中完善模块的其余部分。

注意

我们将交替使用术语 体、物体、粒子。粒子指代一个小物体;而物体有时指的是较大且具有空间定向的物体。定向变化叫做旋转,而研究能够旋转并在空间中移动的物体的学科叫做刚体力学我们在这里不涉及刚体力学,尽管本章包含了学习该主题的重要先决知识。我们所说的体、物体粒子是指能够承受力、在空间中移动和加速的东西,但它要么没有定向,要么我们可以忽略其定向。有时使用术语点粒子来强调定向无关紧要。*

两体力

在第十六章,我们定义了一个OneBodyForce,它是一个接受粒子状态作为输入并输出对该粒子的(向量)力的函数。单体力适用于当作用于单一物体的力仅取决于该物体的状态:即其位置、速度、质量、荷电量或当前时间。

力学中的许多力本质上是两体的,这意味着力向量取决于产生力的粒子和经历力的粒子的状态。两体力是指依赖于两个粒子状态的力。

type TwoBodyForce
    =  ParticleState  -- force is produced BY particle with this state
    -> ParticleState  -- force acts ON particle with this state
    -> ForceVector

ForceVector类型是Vec的类型同义词,名称表明我们考虑的特定向量表示一个力。

type ForceVector = Vec

两体力返回的力向量是由TwoBodyForce中首先给定状态的粒子产生的,它作用于第二个给定状态的粒子。我们称之为by-on 约定。刚才展示的代码注释提醒我们这一约定。

我们写的每个二体力应该遵循牛顿第三定律。如果交换两个粒子的状态,产生的力向量应该是原来力的反向。也就是说,如果f是二体力,向量f st2 st1应该是f st1 st2的负向。由于作用于一个粒子的力是作用于另一个粒子的力的反向,像 by-on 约定这样的约定非常重要,以便例如重力作为吸引力作用,而不是排斥力。

二体力和一体力之间存在关系。给定产生力的粒子的状态,我们可以通过仅提供二体力的第一个输入并不提供其他信息,将二体力转换为一体力。这会创建一个函数。该函数接受作用于粒子上的状态并返回一个力向量,使得该函数成为一体力。以下是用 Haskell 代码表达这一概念:

oneFromTwo :: ParticleState  -- state of particle PRODUCING the force
           -> TwoBodyForce
           -> OneBodyForce
oneFromTwo stBy f = f stBy

这段代码看似简单。通过将二体力f应用于产生力的粒子的状态,我们得到了一个一体力。局部变量stBy保存产生力的粒子的状态;等价地,力是由状态为stBy的粒子产生的。

如果我们的脑袋工作方式更像 Haskell 编译器,我们或许不会费心去定义oneFromTwo,因为在任何使用该函数的地方,我们可以通过省略oneFromTwo的名字并反转参数顺序来达到等效的行为。然而,我的大脑并不像 Haskell 编译器那样运作,因此这对我来说并不是一种容易或自然的做法。我相信,这个函数对于 Haskell 编译器来说或许有些傻,但对于人类的代码读者和写作者来说,它有其价值,因为它涉及了力学的概念和术语,特别是一体力和二体力。随着编程经验的积累,你会遇到更多编译器认为等效的写法,但它们在你脑海中的表现方式不同。利用语言的灵活性,以便你自己,或许其他人,能够轻松阅读和理解你的代码。你的代码不仅仅是为了计算机,它同样是为了你和其他人。

我们将在本章后面谈到弹簧时使用oneFromTwo函数。当我们想要收集作用在一个粒子上的所有力时,我们也会使用它。

让我们来看一些双体力的例子。在编写双体力时,我们需要注意两个问题。首先,双体力需要遵守牛顿的第三定律。为此,正如我们在以下例子中所见,两个物体的状态需要对称地使用。(更准确地说,这两个状态需要以反对称的方式使用,这样交换它们会产生负号。)第二,我们需要遵循“由……作用于……”的惯例,以便清楚地理解力作用于哪个物体。Haskell 的类型系统并不会阻止我们错误地写出一个违反牛顿第三定律的TwoBodyForce,或者一个返回错误力的函数,所以我们需要小心。

万有引力

牛顿是第一个给出描述两个大质量球形物体之间引力定量关系的人。他表明,一个物体对另一个物体施加的力与每个物体的质量成正比,并且与它们中心之间距离的平方成反比。作为一个方程,牛顿的万有引力定律可以写为:

图片

其中m[1]是物体 1 的质量,m[2]是物体 2 的质量,r是物体中心之间的距离。这个方程给出了物体 1 对物体 2 施加的力的大小(根据牛顿第三定律,这个力的大小与物体 2 对物体 1 施加的力的大小相同)。在国际单位制中,常数G = 6.67408 × 10^(–11) N m²/kg²。方程 19.1 可以用 Haskell 表示如下:

gravityMagnitude :: Mass -> Mass -> R -> R
gravityMagnitude m1 m2 r = let gg = 6.67408e-11  -- N m² / kg²
                           in gg * m1 * m2 / r**2

我们可以使用向量表示法给出牛顿万有引力定律的更全面版本,该版本包括了方程中的力的方向。定义位移向量r[21]为从粒子 1 指向粒子 2 的向量,如图 19-1 所示。

图片

图 19-1:位移向量 r[21]指向从粒子 1 到粒子 2 的方向。

我们还将定义一个单位向量 图片,它指向从粒子 1 到粒子 2 的方向。

图片

粒子 1 对粒子 2 施加的力F[21]可以通过将方程 19.1 中的r替换为|r[21]|,并用图片表示力的方向来给出,因为作用在粒子 2 上的力是指向粒子 1 的。

图片

注意,作用在物体 2 上的力F[21]与位移向量r[21]的方向相反;也就是说,它指向物体 1。这是合理的,因为引力是一种吸引力。

比较方程 19.1 和 19.3,我们看到方程 19.1 更简单,而方程 19.3 更强大,因为它在方程中编码了力的方向。

如果r[1]是粒子 1 的位置向量,r[2]是粒子 2 的位置向量,那么r[21] = r[2] – r[1],我们可以将粒子 2 上的力表示为:

Image

这是关于普遍引力两体力的 Haskell 定义:

universalGravity :: TwoBodyForce
universalGravity st1 st2
    = let gg = 6.67408e-11  -- N m² / kg²
          m1 = mass st1
          m2 = mass st2
          r1 = posVec st1
          r2 = posVec st2
          r21 = r2 ^-^ r1
      in (-gg) *^ m1 *^ m2 *^ r21 ^/ magnitude r21 ** 3

我们使用ParticleState数据类型中的提取函数(也叫做消除器或选择器)massposVec来提取两个粒子的质量和位置向量,并为这些值指定局部名称。我们返回的最终表达式来自方程 19.4。

请注意,普遍引力遵循牛顿的第三定律。我们通过交换* m [1]和 m *[2],以及交换r[1]和r[2]的位置来计算粒子 2 对粒子 1 施加的力F[12]。

Image

恒定排斥力

让我们尝试编写一个恒定排斥力,作用于两个物体之间;换句话说,一个不依赖于物体之间距离的力。

这里是一个错误的做法:

constantRepulsiveForceWrong :: ForceVector -> TwoBodyForce
constantRepulsiveForceWrong force = \_ _ -> force

代码的意图是明确的:我们打算忽略粒子的状态并返回任何给定的力。此代码通过了 Haskell 类型检查器并成功编译,但它不符合牛顿第三定律。由于完全忽略了粒子状态,因此无法通过交换粒子状态来改变力的方向,这正是牛顿第三定律所要求的。

这里有一个恒定的排斥力,符合牛顿第三定律:

constantRepulsiveForce :: R -> TwoBodyForce
constantRepulsiveForce force st1 st2
    = let r1 = posVec st1
          r2 = posVec st2
          r21 = r2 ^-^ r1
      in force *^ r21 ^/ magnitude r21

我们不再传递一个力向量给我们的函数,而是只传递一个力的大小。我们使用两个物体的位置来确定“排斥”的方向。当我们交换这两个粒子的状态时,力的方向会正确地反转。

线性弹簧

弹簧通常由金属或塑料制成,通过拉开两端可以使其伸长,通过推近两端可以使其压缩。弹簧有一个平衡长度,即弹簧被分离并允许恢复其自然形状时,两端之间的距离。

如果物体连接到弹簧的两端,弹簧就能对这些物体施加力。如果两物体之间的距离小于弹簧的平衡长度,弹簧会压缩,并施加排斥力以恢复其平衡长度。类似地,当弹簧被拉长时,会通过施加吸引力来恢复平衡。如果弹簧的质量相较于弹簧两端物体的质量可以忽略不计,那么弹簧一端对物体施加的力将等于且相反于另一端对另一个物体施加的力。弹簧充当了一个遵循牛顿第三定律的两体力。由于每端力的大小相等,我们有时会将弹簧的力当作一个单一的数值来看待。

我们假设弹簧不弯曲,只在连接两端的直线上进行压缩或拉伸。如果r[21]是弹簧两端之间的距离,r[e]是弹簧的平衡长度,当r[21] > r[e]时,弹簧处于拉伸状态;当r[21] < r[e]时,弹簧处于压缩状态;当r[21] = r[e]时,弹簧处于平衡状态。

弹簧施加的力的大小取决于弹簧从平衡长度延伸或压缩的程度。延伸或压缩越大,力越大。弹簧施加的力依赖于差值r[21] – r[e]线性弹簧是指力与该差值成正比的弹簧。比例常数k称为弹簧常数

让我们称r[21]为从弹簧一端物体 1 到另一端物体 2 的位移向量,如图 19-1 所示。那么r[21] = |r[21]|是从一端到另一端的距离,Image是从端 1 指向端 2 的单位向量。表 19-1 显示了弹簧两端的力。

表 19-1: 线性弹簧两端的力

弹簧状态 端 1 的力 端 2 的力
r[21] > r[e] 拉伸 Image Image
r[21] = r[e] 平衡 0 0
r[21] < r[e] 压缩 Image Image

弹簧作用在物体 2 上的力F[21]如下所示:

Image

这个方程在弹簧处于拉伸、压缩或平衡状态时都成立。如果r[1]是物体 1 的位置向量,r[2]是物体 2 的位置向量,那么r[21] = r[2] – r[1],我们可以将作用在物体 2 上的力写成如下形式:

Image

以下是 Haskell 代码,用于计算具有弹簧常数k和平衡长度re的线性弹簧的双体力:

linearSpring :: R  -- spring constant
             -> R  -- equilibrium length
             -> TwoBodyForce
linearSpring k re st1 st2
    = let r1 = posVec st1
          r2 = posVec st2
          r21 = r2 ^-^ r1
          r21mag = magnitude r21
      in (-k) *^ (r21mag - re) *^ r21 ^/ r21mag

有时我们可能需要将弹簧的一端固定到墙壁或天花板上。在这种情况下,弹簧最好用一个单体力来表示。这是一个很好的机会,可以使用我们在本章前面编写的函数oneFromTwo,将双体力转化为单体力。给定弹簧常数、平衡长度以及弹簧一端的固定位置,函数fixedLinearSpring可以为附着在另一端的物体产生一个单体力。

fixedLinearSpring :: R -> R -> Vec -> OneBodyForce
fixedLinearSpring k re r1
    = oneFromTwo (defaultParticleState { posVec = r1 }) (linearSpring k re)

函数fixedLinearSpring的工作原理是创建一个假粒子状态,位于弹簧的固定端。这个粒子状态是“假”的,因为它的唯一作用是提供位置;我们不打算让这个粒子状态像真实粒子一样演化。当这个假粒子状态与双体力linearSpring k re一起传递给函数oneFromTwo时,我们得到一个一体力,描述了弹簧对可动质量施加的力。

中心力

到目前为止,我们考虑的三种双体力——万有引力、常量排斥力和线性弹簧力——都是中心力的例子,中心力是作用在两个粒子之间的力,仅依赖于它们之间的距离,并沿着连接它们的线方向作用。中心力可以是吸引力也可以是排斥力。由物体 1 产生的中心力对物体 2 的作用力的通用表达式为:

Image

或者

Image

如果r[1]是物体 1 的位置向量,r[2]是物体 2 的位置向量。以下是 Haskell 中的中心力:

centralForce :: (R -> R) -> TwoBodyForce
centralForce f st1 st2
    = let r1 = posVec st1
          r2 = posVec st2
          r21 = r2 ^-^ r1
          r21mag = magnitude r21
      in f r21mag *^ r21 ^/ r21mag

我们提供了centralForce,它使用标量函数f来描述力是如何依赖于两个物体之间的距离的。

前一部分中提到的线性弹簧力可以通过使用这个centralForce函数来重新定义,如下所示:

linearSpringCentral :: R  -- spring constant
                    -> R  -- equilibrium length
                    -> TwoBodyForce
linearSpringCentral k re = centralForce (\r -> -k * (r - re))

在这里我们传递标量函数

f(r) = –k(rr[e])

centralForce,其中负号表示当r > r[e]时力是吸引力。习题 19.3 要求你将万有引力写成中心力。

弹性台球相互作用

在两个物体发生弹性碰撞时,物体会稍微压缩并储存能量,就像弹簧一样,然后再弹开。在初级物理课程中,碰撞通常被视为“黑箱”事件,我们不涉及碰撞物体之间具体的作用力,而是利用动量守恒来推算碰撞后物体的运动。这里,我们将物体之间的力视为一个双体力,当物体之间相隔时力为零,而当物体接触时力表现为弹簧力。

关键是要知道物体是否接触。我们只在状态中跟踪每个物体的质心位置,因此问题就变成了两个物体的质心是否更接近某个阈值距离,这个阈值距离我们称为r[e]。如果物体之间的距离大于r[e],则没有力。如果距离小于r[e],我们将力建模为一个压缩的线性弹簧,弹簧常数为k。以下方程给出了物体 1 对物体 2 的力:

Image

这个力就像一个线性弹簧的一半。它在压缩时表现得像一个线性弹簧,当中心距离小于阈值距离 r[e] 时会产生力,但当弹簧会出现拉伸时,则没有力。下面是 Haskell 代码:

billiardForce :: R  -- spring constant
              -> R  -- threshold center separation
              -> TwoBodyForce
billiardForce k re
    = centralForce $ \r -> if r >= re
                           then 0
                           else (-k * (r - re))

当粒子之间的距离大于或等于阈值分离时,粒子不受任何力作用。当粒子之间的距离小于阈值分离时,物体接触并稍微压缩,感受到排斥力。我们将在下一章中使用这种两体力来模拟碰撞。

内力与外力

当我们有多个相互作用的粒子时,作用在任何一个粒子上的力可以分为两种。一方面,存在由我们关注的粒子集合中的其他粒子产生的力。这些是牛顿第三定律适用的力。如果我们关心粒子 A 和 B,并且 A 感受到来自 B 的力,那么牛顿第三定律提醒我们,在我们的计算中需要考虑 B 感受到来自 A 的力这一事实。

另一方面,存在由我们关注的粒子集合外部的物体产生的力。我们可能希望将地球表面的重力作为一种力,而不需要将地球作为我们关心的粒子之一。我们可能还希望包括由电场或磁场产生的力,而不包括这些场的源头在我们的计算中。对于第二种类型的力,牛顿第三定律并不适用;它告诉我们关于作用在我们不关心的事物上的力,而这些我们不需要在计算中考虑。区分这两种力促使了以下定义的产生。

粒子系统仅仅是一个选择,决定关注哪些粒子。我们决定在系统中包括哪些粒子,这些粒子的运动通过应用牛顿第二定律来计算。

对于粒子系统,区分内力(由我们系统中的粒子产生的力)和外力(由我们系统外部的物体产生的力)是有用的。通过区分这些力,我们能够写出一个状态更新规则,自动为我们应用牛顿第三定律。外力不需要牛顿第三定律;它与上一章的处理方式相同,因为我们不关心产生该力的物体的运动。对于内力,两个粒子都在我们的系统中,我们可以对它们进行对称处理,确保每个粒子都经历了适当的力。

让我们创建一个新的力的数据类型,要求每个力必须是外力或内力之一。

data Force = ExternalForce Int OneBodyForce
           | InternalForce Int Int TwoBodyForce

这个数据类型定义中的Int是粒子编号。我们将从 0 开始编号我们系统中的粒子。通过指定经历某种外力的粒子编号以及描述该外力的单体力,我们可以确定特定的外力。例如,Force

ExternalForce 98 (fixedLinearSpring 1 0.5 (vec 100 0 0))

表示粒子 98 受到一个线性弹簧的作用,弹簧常数为 1,平衡长度为 0.5,且弹簧的另一端固定在位置 100 处Image

特定的内力通过给出参与相互作用的两个粒子的编号,后跟描述这种相互作用的双体力来指定。例如,Force

InternalForce 0 1 universalGravity

表示粒子 0 和 1 通过万有引力相互作用。Force

InternalForce 1 0 universalGravity

表示相同的意思。为了表示粒子 0 和 1 通过万有引力相互作用,我们在描述粒子系统设置的力的列表中仅包含其中之一,而不是两者。

多粒子系统的状态

粒子系统的状态由每个粒子状态中的信息组成。粒子状态的列表是描述多个粒子系统状态的适当类型。

我们可以通过几种方式来处理这个问题。我们可以使用数据类型[ParticleState]来描述粒子系统的状态。我们也可以写一个类型同义词,为单粒子状态的列表提供另一个名称。然而,我们不会选择这两条路径,因为我们已经使用单粒子状态的列表来表示单粒子力学问题的解。在这个解中,每个单粒子状态描述的是同一个粒子在不同时间的状态。多粒子系统的状态需要一个粒子列表来达成不同的目的;每个单粒子状态描述的是不同粒子在同一时刻的状态。

由于我们不想将作为单粒子力学问题解的单粒子状态列表与描述多粒子系统的列表混淆,我们使用data关键字创建了一个新数据类型,以便编译器将这两种类型视为不同类型。不同的用途意味着不同的类型。

data MultiParticleState
    = MPS { particleStates :: [ParticleState] } deriving Show

我们使用数据构造器MPS(多粒子状态的缩写)构建一个类型为MultiParticleState的值。我们本可以使用MultiParticleState作为数据构造器的名称,但我选择了MPS,因为它更短,也在代码中使用起来不那么笨重。数据构造器下方是一个普通的单粒子状态列表。data构造器使得类型MultiParticleState与类型[ParticleState]区分开来。我们使用记录语法来获取提取函数particleStates,而无需显式定义它。

在数据构造器下方,单粒子状态存储在一个列表中,这意味着我们可以通过编号来引用粒子,从0开始。每个粒子都通过一个类型为Int的数字来标记。

我们顺便提一下,使用列表来表示每个粒子的状态并不是处理数据的最高效方式。在本书中,我们主要关注的是我们编写的代码的清晰性、美观性和简洁性,而对其效率关注较少。在 Haskell 编程中,一个好的规则是,直到你的代码运行得比你希望的慢时,才考虑效率问题。到那时,再问怎么做才能让它更快。在过去,函数式编程曾有一个慢的名声,但现在这种说法不再成立。特别是 Haskell,提供了如数组等数据结构,它们可能比我们正在使用的列表结构更高效。我们使用的基于列表的方法对于十几个粒子效果很好,但对于几百个粒子可能就太慢了。如果你达到了需要用几百或几千个粒子来进行模拟的程度,我建议你研究一下数组类型。为了简便起见,本书中我们坚持使用列表数据类型。

为了使用第十六章中编写的simulateVis函数进行动画,在Vis模块中,表示某个事物状态的数据类型需要是类型类HasTime的一个实例,这意味着状态需要与时间相关联。每个单粒子状态都有一个时间;事实上,组成多粒子状态的每个单粒子状态都有相同的时间。因此,我们将直接使用粒子编号 0 的时间。以下是实例声明:

instance HasTime MultiParticleState where
    timeOf (MPS sts) = time (sts !! 0)

我们对输入使用模式匹配来定义timeOf函数。通过给数据构造器MPS后跟一个列表,我们可以在函数体内访问该列表,并使用列表元素操作符(!!)。

在下一章,我们将为两个质量和两个弹簧制作动画。该动画使用了simulateVis,它依赖于timeOf。由于该动画的状态空间是MultiParticleStatesimulateVis需要与MultiParticleState相对应的timeOf,这正是实例声明所提供的内容。

回顾一下,在第十六章中,我们介绍了数据类型DParticleState,用于保存ParticleState中状态变量的时间导数。在这里,在多粒子设置中,我们做了类似的事情,定义了一个新的数据类型DMultiParticleState,用于保存MultiParticleState中状态变量的时间导数。以下是该数据类型的定义:

data DMultiParticleState = DMPS [DParticleState] deriving Show

从这个定义中可以看出,我们只是在打包一个DParticleState的列表,这类似于我们在上面为MultiParticleState的定义中打包ParticleState列表的方式。

有了一个新的数据类型来表示多粒子系统的状态后,我们接下来要讨论的是该状态如何随时间演化——换句话说,状态是如何更新的。

多粒子状态更新

对于多个粒子的系统,图 19-2 提供了数据表示和在它们之间转换的函数的概述,类似于图 16-3 在单粒子情况下的展示。

Image

图 19-2:数据表示和在它们之间转换的函数

四种表示方法分别是:力、微分方程、状态更新函数和演化器。在下一节中,我们将讨论牛顿第二定律以及在多粒子系统中实现它的函数newtonSecondMPS。我们将看到如何在这种设置中使用数值方法,并且定义在图 19-2 中显示的复合函数updateMPSstatesMPS

实现牛顿第二定律

对于相互作用的粒子系统,牛顿第二定律和牛顿第三定律都参与了微分方程的形成。图 19-3 展示了一个两体力学问题的示意图。

Image

图 19-3:牛顿第二定律和牛顿第三定律在两体问题中共同作用的示意图。两个物体相互作用,每个物体也有外力作用在它们身上。

函数F[1e],如图 19-3 左上角所示,表示粒子 1 上的净外力。粒子 1 上的外力可能依赖于时间、粒子 1 的位置和粒子 1 的速度。这三个量作为输入传递给F[1e]。我们通过向该函数提供输入来求得粒子 1 的净外力,形成F1e, v[1] (t))。如果有多个外力作用于粒子 1,它们需要(按向量)相加。函数F[1e]需要返回这个和。

F[1e]向下移动图示,我们找到一个求和过程,它将净外力和净内力相加,得到粒子 1 上的净力,这出现在牛顿第二定律中。我们稍后会讨论净内力;现在先继续向下移动图示的左列。牛顿第二定律表示,将粒子 1 上的净力除以粒子 1 的质量会得到粒子 1 的加速度。对加速度进行积分得到速度,再对速度进行积分得到位置。粒子 1 的位置和速度作为输入反馈给净外力函数F[1e]。

粒子 1 的位置和速度也与粒子 2 的位置和速度结合,根据图 19-3 的右列,产生一个相对位置和相对速度,作为F[21]的输入,即粒子 1 对粒子 2 的内力。如果粒子 1 对粒子 2 施加多个力,比如弹簧力和电力,这些力必须作为矢量相加,形成F21 – r1,v2 – v1), 即粒子 1 对粒子 2 的净内力。两个粒子之间的内力不应显式依赖于时间,而应仅通过它们的相对值依赖于两粒子的位置和速度。我们在本章早些时候介绍的所有二体力都具有这一性质。

由于F21 – r1,v2 – v1)是粒子 2 上的力,它直接进入右侧的求和,与粒子 2 上的净外力相加。根据牛顿第三定律,粒子 1 所受的力等于粒子 2 对粒子 1 的作用力且方向相反,因此乘以-1,得到粒子 1 上的净内力,然后与粒子 1 上的净外力相加。

时间是通过对常数 1 进行积分来生成的,就像我们之前的示意图那样。时间是每个净外力函数的输入,但不是净内力函数的输入。

总结图 19-3,每个粒子上的内力和外力必须加在一起,形成粒子上的净力,根据牛顿第二定律,净力除以粒子的质量即可计算粒子的加速度。牛顿第三定律在中间列中得以体现,在那里计算出相互作用的力,将其不变地传送给粒子 2,并将其取反后传送给粒子 1。内力表达了两个粒子之间的相互作用,而外力则代表了与系统外部事物的相互作用。所有这些反馈意味着一组耦合的微分方程将是多粒子力学问题的数学表达。

我们可以将牛顿第二定律写成一组耦合的微分方程。

ImageImageImageImageImage

时间的时间导数为 1,就像单粒子力学中一样。位置的时间导数是速度,这对每个粒子都成立。速度的时间导数是加速度,它是通过对每个粒子将净力除以质量得到的。粒子上的净力是净外力和净内力的总和。函数F[2e]在给定时间、粒子 2 的位置和粒子 2 的速度时,产生粒子 2 的净外力。函数F[mn]在给定粒子mn的相对位置和相对速度时,产生粒子n对粒子m的内力。例如,粒子 2 上的净内力是:

Image

我们在这里添加了系统中所有其他粒子产生的内力。我们仅为系统中的前两个粒子给出了方程,但每个粒子都有一对类似的方程。希望这个模式是清晰的。

在第十六章中,我们讨论的是单个粒子的运动,我们使用了newtonSecondPS函数将一系列力转换为微分方程。现在我们希望为多个粒子提供一个类似的函数。我们希望有一个如下类型签名的函数:

newtonSecondMPS :: [Force]
                -> MultiParticleState -> DMultiParticleState  -- a diff eqn

这个函数的名字以MPS结尾,提醒我们它与MultiParticleState状态空间一起工作。

我们的计划是将牛顿第二定律应用于系统中的每个粒子。对于每个粒子,我们识别作用于它的所有外力和内力,并将每个内力转化为一个单体力。一旦我们得到了作用于粒子的所有单体力的列表,我们就可以使用newtonSecondPS函数计算该粒子的所有状态变量的时间导数。当我们获得每个粒子每个状态变量的时间导数时,我们将把所有这些合并起来,并请求newtonSecondMPS返回这个合并结果。以下是代码:

newtonSecondMPS fs mpst@(MPS sts)
    = let deriv (n,st) = newtonSecondPS (forcesOn n mpst fs) st
      in DMPS $ map deriv (zip [0..] sts)

代码的第一行命名了传入的力列表fs,并给传入的多粒子状态指定了两个名称。当出现在定义的左侧两个名称之间时,@(“at 符号”)允许代码编写者为传入值提供一个简单的标识符,并同时对输入进行模式匹配。简单标识符mpst代表传入的多粒子状态,类型为MultiParticleState。由于它出现在数据构造器MPS下方,因此名称sts代表单粒子状态的列表,类型为[ParticleState]。我们希望在定义中使用mpststs

在代码的第二行,我们定义了一个局部函数deriv,用于计算单粒子状态变量的时间导数。它的参数(n, st)是粒子编号和单粒子状态的一个对。它的返回值是导数的集合(类型为DParticleState)。这个局部函数使用newtonSecondPS来计算导数。表达式forcesOn n mpst fs是作用在粒子编号n上的单粒子力的列表,其中多粒子状态为mpst,而所有系统力(包括外力和内力)为fs。接下来,我们将编写forcesOn函数。

在最后一行,我们将粒子编号与相应的粒子状态组合在一起,形成一个编号-状态对的列表。然后,我们对该列表应用局部函数deriv,得到单粒子状态的时间导数列表(类型为[DParticleState])。最后,我们应用DMPS数据构造器来形成时间多粒子状态的导数(类型为DMultiParticleState)。

从物理学的角度来看,我们所做的只是将牛顿第二定律应用到系统中的每个粒子。表面上的复杂性部分来自我们需要给粒子编号,部分来自我们希望使用清晰的类型来表示我们关心的事物(例如内部和外部力)。清晰的类型在两个方面对我们有帮助。首先,类型代表了物理学中的重要概念,并帮助我们思考这些概念。其次,类型帮助编译器发现我们的错误。通过在像这样的函数中做一些繁重的工作,我们最终会得到一组强大的函数,使我们能够相对容易地解决多粒子问题。

我们如何找到每个粒子的合力?换句话说,我们如何编写我们之前使用的forcesOn函数?forcesOn应该是什么类型?forcesOn函数需要接受一个粒子编号、一个多粒子状态和一个力列表作为输入,并且它需要输出一个单粒子力的列表。这里是forcesOn的类型签名和定义:

forcesOn :: Int -> MultiParticleState -> [Force] -> [OneBodyForce]
forcesOn n mpst = map (forceOn n mpst)

传入的粒子编号命名为n,传入的多粒子状态命名为mpst。我们不需要给力列表命名,这意味着forcesOn n mpst的类型为[Force] -> [OneBodyForce]。从定义中可以看出,forcesOn将大部分工作委托给另一个尚未定义的函数forceOn(注意省略了s)。其思想是,函数forceOn n mpst的类型为Force -> OneBodyForce,将系统力列表中的外力或内力转换为作用在粒子n上的单粒子力。如果我们能转换单个力,我们就可以使用map来转换力的列表。

函数forceOn需要处理外力和内力。外力较为简单。我们只需检查外力是否作用于粒子n。如果是,我们返回外力内包含的单粒子力。如果不是,我们就构造一个值为零的单粒子力。

对于内力,我们需要检查内力中指定的两个粒子编号是否与我们关心的粒子编号n匹配。如果匹配,我们使用前面章节中编写的oneFromTwo函数,将双体力转换为单体力。如果不匹配,我们再次构造一个零力。以下是forceOn的代码:

forceOn :: Int -> MultiParticleState -> Force -> OneBodyForce
forceOn n _         (ExternalForce n0 fOneBody)
    | n == n0    = fOneBody
    | otherwise  = const zeroV
forceOn n (MPS sts) (InternalForce n0 n1 fTwoBody)
    | n == n0    = oneFromTwo (sts !! n1) fTwoBody  -- n1 acts on n0
    | n == n1    = oneFromTwo (sts !! n0) fTwoBody  -- n0 acts on n1
    | otherwise  = const zeroV

该函数对Force输入使用模式匹配,因此每个Force数据构造函数(即外力和内力)都有一部分定义。这个定义使用了 Haskell 中的守卫功能。守卫就是代码中几行左侧的竖线,它是if-then-else结构的一个便捷替代方案,尤其当有多个可能性时,比如处理内力的部分。

guard构造中的每一行由四个部分组成:竖线、布尔条件、等号和结果。在每个竖线处都会检查条件。如果条件为真,则返回对应的结果;如果条件为假,则继续检查下一个竖线并重复此过程。最后一行的条件通常使用otherwise,它实际上就是True的另一种表示方式。通过使用otherwise,我们可以确保至少有一个守卫条件为真,从而返回相应的结果。

forceOn定义的第一部分用于外力。我们检查粒子编号n(即我们当前关心的粒子)是否与外力作用的粒子编号n0匹配。如果匹配,返回外力中包含的单体力fOneBody;如果不匹配,则返回一个忽略粒子状态、简单返回零向量的单体力const zeroV

forceOn定义的第二部分用于处理内力。内力涉及两个粒子;如果我们关心的粒子是其中之一,函数需要返回适当的单体力。我们首先检查粒子编号n(即我们当前关心的粒子)是否与内力中涉及的两个粒子之一的编号n0匹配。如果n等于n0,我们就关心粒子n1对粒子n0施加的力。我们将前面章节中写的oneFromTwo函数提供给粒子n1的单体状态(即sts !! n1)和内力中包含的双体力(即fTwoBody)。oneFromTwo函数返回作用于粒子n的单体力。

如果n不等于n0,我们检查n是否等于n1,即参与内力作用的另一个粒子。如果是这样,我们关心的是粒子n0对粒子n1施加的力。我们为oneFromTwo提供粒子n0的单粒子状态,称为sts !! n0,以及内力中的双体力。函数oneFromTwo再次返回作用在粒子n上的单体力。最后,如果粒子n既不匹配n0也不匹配n1,我们返回零力。

forceOn定义中处理内力的部分是我们如何应用牛顿第三定律的部分。这一部分保证了力的大小和方向相等且相反,因为它们来自同一个内力。对于涉及的两个粒子,我们使用相同的双体力来产生作用在每个粒子上的单体力;只有单粒子状态被互换。由于双体力具有粒子交换会抵消力的特性,牛顿第三定律自动得到应用。我们不会犯这样的错误:记得n1n0施加力,却忘了n0n1施加力。我们对内力的语言和处理它们的代码确保了牛顿第三定律在没有程序员额外注意的情况下自动得以满足。特别是,每次我们研究一个新的多粒子系统时,我们提供的只是外力和内力的列表。我们不需要确保n1n0施加的力与n0n1施加的力相等且方向相反。Haskell 会自动处理这一切。

多粒子的数值方法

记住,欧拉法和四阶龙格-库塔法是求解任何微分方程的通用方法。在第十五章的末尾,我们编写了函数eulerrungeKutta4,它们适用于任何状态空间s,该状态空间是RealVectorSpace类型类的一个实例。为了使这两个函数能够与MultiParticleState状态空间一起使用,我们需要编写两个实例声明。它们如下所示:

instance RealVectorSpace DMultiParticleState where
    DMPS dsts1 +++ DMPS dsts2 = DMPS $ zipWith (+++) dsts1 dsts2
    scale w (DMPS dsts) = DMPS $ map (scale w) dsts

这个实例声明的内容是两个多粒子状态的和就是每个粒子的逐个和。

instance Diff MultiParticleState DMultiParticleState where
    shift dt (DMPS dsts) (MPS sts) = MPS $ zipWith (shift dt) dsts sts

这个实例声明表示“通过状态导数对多粒子状态进行‘平移’”只是将每个单粒子状态平移由相关的单粒子状态导数所平移。

欧拉-克罗梅尔方法并不是适用于所有微分方程的通用方法,因此我们需要为欧拉-克罗梅尔方法编写一个显式的数值方法,适用于MultiParticleState数据类型。它如下所示:

eulerCromerMPS :: TimeStep        -- dt for stepping
               -> NumericalMethod MultiParticleState DMultiParticleState
eulerCromerMPS dt deriv mpst0
    = let mpst1 = euler dt deriv mpst0 sts0 = particleStates mpst0
          sts1 = particleStates mpst1
          -- now update positions
          in MPS $ [ st1 { posVec = posVec st0 ^+^ velocity st1 ^* dt }
                         | (st0,st1) <- zip sts0 sts1 ]

我们选择通过首先进行欧拉步来计算欧拉-克罗梅尔导数,这样操作简单,能够正确更新每个粒子的质量、电荷、时间和速度。然而,位置需要被修正,因为它需要基于更新后的速度。局部变量mpst0代表传入的多粒子状态,而mpst1代表经过欧拉更新的多粒子状态。变量sts0sts1是传入和欧拉更新后的多粒子状态下的单粒子状态列表。

为了形成欧拉-克罗梅尔更新后的多粒子状态,我们使用列表推导来遍历所有粒子,并应用欧拉-克罗梅尔更新公式 15.10,该公式用欧拉更新后的速度更新传入的位置。

复合函数

与单粒子情况一样,方便的是有复合函数,这些复合函数在图 19-2 中包含两步或三步操作。函数updateMPS是牛顿第二定律与数值方法的组合,适用于动画。你可以从其定义中看到,它正是这种组合:

updateMPS :: NumericalMethod MultiParticleState DMultiParticleState
          -> [Force]
          -> MultiParticleState -> MultiParticleState
updateMPS method = method . newtonSecondMPS

图 19-2 中的函数solver也采取了数据表示中的两步,通过生成一个可以从初始状态生成状态列表的进化器来求解微分方程。我们在第十五章编写了solver,这段代码在多粒子环境中运行得很好。

函数statesMPS包含图 19-2 中的所有三步,将力的列表转化为一个进化器。其定义就是你所预期的:牛顿第二定律、数值方法和迭代的组合。

statesMPS :: NumericalMethod MultiParticleState DMultiParticleState
          -> [Force]
          -> MultiParticleState -> [MultiParticleState]
statesMPS method = iterate . method . newtonSecondMPS

总结

在本章中,我们将牛顿力学应用于多个在三维空间中相互作用的物体。牛顿第三定律支配粒子之间的相互作用。双粒子力是依赖于两粒子状态的力。我们将作用在系统中粒子上的力分为内力和外力,内力由系统内的其他粒子产生,外力则由系统外部的事物产生。粒子系统的状态通过给出每个粒子的单粒子状态来描述。我们的MultiParticleState数据类型正是做到了这一点。我们的状态更新过程仍然基于牛顿第二定律,但现在它会自动将牛顿第三定律应用于所有的内力。

与单粒子情况一样,解决力学问题仍然是通过一系列四种表示形式转换信息的过程。对于多粒子情况,我们首先列出内外力,产生一个微分方程,产生一个状态更新函数,最后生成多粒子状态列表。

通过第二部分的内容,我们已经看到从力到微分方程的推导过程是如何演变的。牛顿的第二定律始终存在,但随着我们从一维到三维,再到多粒子,状态中包含的信息也逐渐增加。表 19-2 展示了我们用来通过生成微分方程来执行牛顿第二定律的函数。newtonSecondV函数用于一维中的单个粒子,当力仅依赖于速度时。newtonSecondTV函数用于一维中的单个粒子,当力仅依赖于时间和速度时。newtonSecond1D函数用于一维中的单个粒子,当力可能依赖于时间、位置或速度的任意组合时。newtonSecondPS函数用于三维中的单个粒子,当力可能依赖于时间、位置或速度时。最后,newtonSecondMPS函数用于三维中的多粒子系统,当力可能依赖于时间、位置或速度时。

表 19-2: 牛顿第二定律的函数

函数 类型
newtonSecondV 质量 -> [速度 -> 力] -> 速度 -> 距离
newtonSecondTV 质量 -> [(时间, 速度) -> 力] -> (时间, 速度) -> (距离, 距离)
newtonSecond1D 质量 -> [一维状态 -> 力] -> 一维状态 -> (距离, 距离, 距离)
newtonSecondPS [单体力] -> 粒子状态 -> D 粒子状态
newtonSecondMPS [力] -> 多粒子状态 -> D 多粒子状态

我们在第二部分中已经使用了Force类型两种不同的方式。在一维设置中,Force只是一个实数类型的同义词。而在本章的三维多粒子设置中,Force的定义要复杂得多,它描述了一种数据类型,既可以是内部力,也可以是外部力,并包括力对状态的依赖。我们已经走了很长一段路。在下一章,我们将把这些思想应用于相互作用的粒子具体示例,并使我们的结果动起来。

练习

练习 19.1. 编写一个speed函数

speed :: ParticleState -> R
speed st = undefined st

返回粒子的速度(从其状态中)。

练习 19.2. 我们可以通过模式匹配而非提取函数来编写万有引力的双体力。这样得到的定义更简洁。完成以下定义:

universalGravity' :: TwoBodyForce
universalGravity' (ParticleState m1 _ _ r1 _) (ParticleState m2 _ _ r2 _)
    = undefined m1 r1 m2 r2

练习 19.3. 万有引力是一个中心力。使用函数centralForce来编写该函数

universalGravityCentral :: TwoBodyForce
universalGravityCentral = undefined

它表达与universalGravity相同的双体力。

练习 19.4. 我们的恒定排斥力是一个中心力。使用centralForce重写恒定排斥力。

练习 19.5. 没有任何真实的弹簧在其整个范围内是完全线性的。在练习 15.10 中,我们引入了伦纳德-琼斯弹簧作为非线性弹簧的例子。

弹簧末端 2 处的力由以下表达式给出,其中 r[e] 是平衡长度,D[e] 是解离能(即,拉伸弹簧使两端非常远的所需能量):

图片

如果 r[21] < r[e],粒子 2 上的力将在 r[21] 方向上,这表示排斥。如果 r[21] > r[e],粒子 2 上的力将在 –r[21] 方向上,这表示吸引。

编写函数 lennardJones,它接受解离能和平衡长度并返回 Lennard-Jones 弹簧的二体力。

lennardJones :: R  -- dissociation energy
             -> R  -- equilibrium length
             -> TwoBodyForce
lennardJones de re = centralForce $ \r -> undefined de re r

习题 19.6. 编写一个函数systemKE

systemKE :: MultiParticleState -> R
systemKE mpst = undefined mpst

该函数返回一个粒子系统的动能,通过将每个粒子的动能加起来。

习题 19.7. 每个示意图中的导线可以用类型标记。请在图 19-3 中标记每条导线的类型。

习题 19.8. 编写函数 forcesOn 的另一种方式是,通过将外力产生的单体力列表附加到由内力产生的单体力列表,形成一个单体力列表。该方法的优点是我们不需要构造任何虚假的零力,也不需要函数 forceOn

forcesOn' :: Int -> MultiParticleState -> [Force] -> [OneBodyForce]
forcesOn' n mpst fs = externalForcesOn n fs ++ internalForcesOn n mpst fs

externalForcesOn :: Int -> [Force] -> [OneBodyForce]
externalForcesOn n fs = undefined n fs

internalForcesOn :: Int -> MultiParticleState -> [Force] -> [OneBodyForce]
internalForcesOn n (MPS sts) fs
    = [oneFromTwo (sts !! n1) f | InternalForce n0 n1 f <- fs, n == n0] ++
      [oneFromTwo (sts !! n0) f | InternalForce n0 n1 f <- fs, n == n1]

在定义函数 internalForcesOn 时,我们在列表推导式中使用模式匹配。我们排除任何不匹配模式的力。根据 internalForcesOn 的模型,编写函数 externalForcesOn

第二十章:弹簧、台球和吉他弦

Image

本章将上一章中的思想和理论应用于三个具体示例。第一个是一个由两个质量和两根弹簧组成的系统,这些弹簧挂在一个固定的天花板上;第二个是台球碰撞;第三个是将吉他弦建模为一条由粒子组成的长线,粒子通过弹簧与相邻的粒子连接。

本章将有一些不同的地方。我们将更详细地研究之前所做的近似数值计算,比之前的章节更深入地处理数值问题。我们还将研究在近似数值计算的背景下,动量和能量守恒。我们还将介绍一种异步动画的方法,首先进行计算,然后制作成可以观看的电影。异步动画适用于当计算变得过于复杂,无法在人类耐心等待结果的时间尺度内完成时。

入门代码

清单 20-1 显示了我们将在本章中开发的MOExamples模块的入门代码(MO代表多物体)。像往常一样,我们导入了我们想在这个模块中使用的函数和类型。

{-# OPTIONS -Wall #-}

module MOExamples where

import SimpleVec
   ( R, Vec, (^+^), (^-^), (*^), vec, zeroV, magnitude
   , sumV, iHat, jHat, kHat, xComp, yComp, zComp )
import Mechanics1D ( TimeStep, NumericalMethod, euler, rungeKutta4 )
import Mechanics3D
   ( ParticleState(..), HasTime(..), defaultParticleState
   , earthSurfaceGravity, customLabel, orient, disk )
import MultipleObjects
   ( MultiParticleState(..), DMultiParticleState, Force(..), TwoBodyForce
   , newtonSecondMPS, updateMPS, statesMPS, eulerCromerMPS
   , linearSpring, fixedLinearSpring, billiardForce )
import Graphics.Gnuplot.Simple
import qualified Graphics.Gloss as G
import qualified Vis as V

清单 20-1:MOExamples 模块的开头代码

ParticleStateMultiParticleState类型是我们用于描述单个粒子和多个粒子状态的类型。函数newtonSecondMPS根据内部和外部力的列表创建一个微分方程。函数eulereulerCromerMPSrungeKutta4用于求解微分方程。我们导入了类型类HasTime,这样我们就可以使用它所包含的timeOf函数,因为我们在一个练习中明确引用了HasTime

两个质量和两根弹簧

作为第一个多物体系统的示例,让我们分析图 20-1 中的情况。

Image

图 20-1:一个由两个质量和两根弹簧组成的系统

在图 20-1 中,我们有两个质量和两根弹簧。上面的弹簧连接到一个固定的天花板,具有弹簧常数k[1]和平衡长度re[1]。下面的弹簧连接两个物体,具有弹簧常数k[2]和平衡长度re[2]。上方物体的质量为m[0],下方物体的质量为m[1]。地球表面的重力作用于每个物体。

这种情况下共涉及四个力:三个外力和一个内力。列表twoSpringsForces包含了这四个力,我们将按照它们的列出顺序进行描述。

twoSpringsForces :: [Force]
twoSpringsForces
    = [ExternalForce 0 (fixedLinearSpring 100 0.5 zeroV)
      ,InternalForce 0 1 (linearSpring 100 0.5)
      ,ExternalForce 0 earthSurfaceGravity
      ,ExternalForce 1 earthSurfaceGravity
      ]

由于一端固定,上方的弹簧对物体 0 施加外力。相关的单体力是fixedLinearSpring 100 0.5 zeroV,这是一个线性弹簧产生的力,弹簧常数为 100 N/m,平衡长度为 0.5 米,附着在原点的天花板上。下方的弹簧作为物体 0 和物体 1 之间的内力。它也具有 100 N/m 的弹簧常数和平衡长度 0.5 米。最后两个力描述了地球对这两个物体施加的重力。

动画函数

我们希望使用非光滑效果对物体的振荡进行动画化。我们需要指定五个作为输入传递给simulateVis函数的参数:时间尺度因子、动画速率、初始状态、显示函数和状态更新函数。我们选择时间尺度因子为 1,动画速率为 20 帧/秒。

在初始状态下,我们必须给出每个物体的质量、初始位置和速度。我们设定Image Imagev1 twoSpringsForces


时间步长被命名为`dt`。回想一下,在动画中,我们并不直接选择时间步长。我们通过时间尺度因子和动画速率间接选择它,然后动画包尽力遵循该速率,尽管它们并不做出任何保证。由于旧状态没有在定义的等号左侧命名,因此返回类型为`MultiParticleState -> MultiParticleState`。我们使用`updateMPS`来创建状态更新函数。给定数值方法和力列表时,它返回一个状态更新函数。我们选择使用时间步长`dt`的欧拉-克罗梅数值方法,并提供我们之前写的力列表。

我们已经指定了时间尺度因子、动画速率、初始状态和我们将用于动画的状态更新函数。接下来我们将在查看独立程序动画时讨论显示函数。

#### 独立动画程序

示例 20-2 展示了一个用于二维物体和弹簧 3D 动画的独立程序。

➊ {-# OPTIONS -Wall #-}

import SimpleVec ( R, zeroV )
import Mechanics3D ( posVec, simulateVis, v3FromVec )
import MultipleObjects ( MultiParticleState(..) )
import MOExamples ( twoSpringsInitial, twoSpringsUpdate )
import Vis ( VisObject(..), Flavour(..), red, green, blue )

main :: IO ()
main = simulateVis 1 20 twoSpringsInitial twoSpringsVisObject twoSpringsUpdate

twoSpringsVisObject :: MultiParticleState -> VisObject R
➋ twoSpringsVisObject (MPS sts)
➌ = let r0 = posVec (sts !! 0)
➍ r1 = posVec (sts !! 1)
➎ springsObj = Line Nothing [v3FromVec zeroV
,v3FromVec r0
,v3FromVec r1] ➏ blue
➐ objs = [Trans (v3FromVec r0) (Sphere 0.1 Solid red)
,Trans (v3FromVec r1) (Sphere 0.1 Solid green)
,springsObj
]
➑ vpm = 1 -- Vis units per meter
➒ in Scale (vpm,vpm,vpm) $ VisObjects objs


*示例 20-2:二维物体和弹簧的独立程序动画*

程序首先启用警告 ➊,然后导入所需的类型和函数。程序从第十章的`SimpleVec`模块、第十六章的`Mechanics3D`模块、第十九章的`Multiple Objects`模块以及当前章节的`MOExamples`模块中导入了所需内容。它从`SimpleVec`模块导入了`zeroV`,以便引用原点,弹簧固定的位置。程序导入了`R`,因为它在类型`VisObject R`中有使用。

让我们讨论一下从`Mechanics3D`模块导入的内容。程序导入了`posVec`函数,这是`ParticleState`数据类型的提取函数,用于返回粒子状态的位移向量。显示函数中唯一需要的状态变量是位置。其他状态变量对给定状态下图片的呈现没有任何贡献。程序导入了`simulateVis`,这是生成动画的主要函数。程序还导入了`v3FromVec`,用于将类型为`Vec`的向量转换为`V3`类型的向量,这是非-华丽模式下的向量类型。

从`MultipleObjects`模块中,我们导入了`MultiParticleState`类型及其构造函数,以便在显示函数的类型签名中引用该类型,并使用构造函数`MPS`在显示函数的定义中对输入进行模式匹配。从当前章节的`MOExamples`模块中,我们导入了初始状态和状态更新函数,这两个是制作动画所需的五个元素中的一部分。

从模块`Vis`中,程序导入了`VisObject`类型及其构造函数,包括`Line`、`Sphere`、`Trans`、`Scale`和`VisObjects`。我们导入了`Flavour`类型及其构造函数,因为球体需要是实体的或线框的,而我们使用的`Solid`数据构造函数是`Flavour`的构造函数之一。程序还导入了颜色`red`、`green`和`blue`。

主程序命名为`main`,类型为`IO ()`。它调用`simulateVis`,并传入制作动画所需的五个元素,其中包括在独立程序中定义的显示函数`twoSpringsVisObject`。

显示函数`twoSpringsVisObject`根据系统的状态生成图像。显示函数的定义开始时通过模式匹配输入数据,以便函数体能够访问由两个单粒子状态组成的二元列表`sts` ➋。我们为两个物体的位置分别命名为`r0`和`r1` ➌ ➍。局部变量`springsObj`是代表两个弹簧的两条线的图像 ➎。为了构造两条线的图像,我们使用了`Line`数据构造函数。

让我们来看一下`Line`类型在非-华丽模式下的定义。

Prelude Graphics.Gloss> :m Vis Linear.V3
Prelude Vis Linear.V3> :t Line
Line :: Maybe a -> [V3 a] -> Color -> VisObject a


这个`Line`有三个输入。第一个输入与线条宽度有关。为了得到默认的线条宽度,在清单 20-2 中,我们为第一个输入提供了`Nothing`➎。第二个输入是一个向量列表,每个向量具有 not-gloss 的本地`V3 a`类型。在我们的用法中,类型变量`a`代表`R`类型。我们通过`v3FromVec`将`Vec`转换为`V3 R`➎。第三个输入是颜色,程序为其提供了`blue`➏。

我们定义`objs`为两质量块和两弹簧的图片列表➐。质量块的图片是球体,位移到状态中包含的位置。

然后,我们定义一个空间缩放因子`vpm`,它等于 1 Vis 单位/米➑。当然,缩放为 1 是没有必要的,但这样写代码可以方便地将值更改为其他数值。最后,`VisObjects`将多个图片合并为一张单独的图片,`Scale`则对整个结果进行缩放➒。

#### 使用机械能作为数值准确性的指导

我们正在研究的由两个质量块和两个弹簧组成的系统应当保持机械能守恒。在本节中,我们将讨论粒子系统可以具有的能量类型,并将了解如何使用能量作为工具来评估我们数值方法的准确性。

##### 动能

每个运动中的粒子都有一种被称为*动能*的运动能量。单个粒子的动能是粒子质量的一半乘以其速度的平方。我们将用小写的*KE*表示单个粒子的动能。动能是一个标量,其国际单位制单位是焦耳(J)。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/369equ01.jpg)

速度*v* = |**v**|是速度**v**的大小。这里是一个返回单个粒子动能的 Haskell 函数:

kineticEnergy :: ParticleState -> R
kineticEnergy st = let m = mass st
v = magnitude (velocity st)
in (1/2) * m * v**2


粒子系统的动能是系统中每个粒子动能的总和。我们用大写的*KE*表示系统的动能。在一个粒子系统中,质量为*m[n]*,速度为**v**[*n*]的第*n*个粒子的动能由以下公式给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/369equ02.jpg)

系统的动能为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/369equ03.jpg)

这是一个返回粒子系统动能的 Haskell 函数:

systemKE :: MultiParticleState -> R
systemKE (MPS sts) = sum [kineticEnergy st | st <- sts]


##### 势能

有些力的特点在于它们可以与*势能*相关联。这样的力被称为*守恒力*,例如弹簧的弹性力和重力就是其中的例子。

弹簧通过被压缩或从平衡位置伸展而获得势能。弹簧可以通过这种方式储存能量。一个线性弹簧的弹簧常数为*k*,当它从平衡位置偏移(压缩或伸展)一个距离*x*时,具有的势能为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/370equ01.jpg)

与弹簧相关的势能类型称为*弹性势能*。函数`linearSpringPE`计算给定弹簧常数、平衡长度和每端粒子状态时的弹性势能。

linearSpringPE :: R -- spring constant
-> R -- equilibrium length
-> ParticleState -- state of particle at one end of spring
-> ParticleState -- state of particle at other end of spring
-> R -- potential energy of the spring
linearSpringPE k re st1 st2
= let r1 = posVec st1
r2 = posVec st2
r21 = r2 - r1
r21mag = magnitude r21
in k * (r21mag - re)**2 / 2


这个函数类似于我们在第十九章中编写的`linearSpring`函数,不同之处在于它计算的是势能,而不是力。公式 20.1 中的平衡位移*x*是弹簧两端之间的距离`r21mag`与弹簧平衡长度`re`的差值。

物体在地球表面附近具有*重力势能*,其大小依赖于物体的高度。具有质量*m*的物体具有势能

PE[*g*] = *mgh*

其中*g*是地球的重力加速度常数,*h*是物体距离某一参考水平(如地球表面)的高度。函数`earthSurfaceGravityPE`计算给定粒子状态时,物体在地球表面附近的重力势能。

-- z direction is toward the sky
-- assumes SI units
earthSurfaceGravityPE :: ParticleState -> R
earthSurfaceGravityPE st
= let g = 9.80665 -- m/s²
m = mass st
z = zComp (posVec st)
in m * g * z


这个函数类似于我们在第十六章中编写的`earthSurfaceGravity`函数,不同之处在于它计算的是势能,而不是力。

回到两个质量和两个弹簧的例子,总势能是每个弹簧的弹性势能加上每个质量的重力势能。

twoSpringsPE :: MultiParticleState -> R
twoSpringsPE (MPS sts)
= linearSpringPE 100 0.5 defaultParticleState (sts !! 0)
+ linearSpringPE 100 0.5 (sts !! 0) (sts !! 1)
+ earthSurfaceGravityPE (sts !! 0)
+ earthSurfaceGravityPE (sts !! 1)


由于顶部弹簧连接到固定的天花板,我们使用默认粒子状态来表示顶部弹簧的一端固定在原点。

##### 机械能

系统的*机械能*是其动能与势能之和。没有非保守力的系统会保持机械能守恒。对于这样的系统,其机械能在后续时刻与之前时刻相同。由于我们在做近似计算,因此不能期望我们的机械能计算在时间上完全一致。由于我们知道如果能进行精确计算,它将保持不变,我们可以将计算中出现的偏差作为我们数值方法产生不准确度的参考。对于一个应该保持机械能守恒的系统,它在我们的计算中保持的程度可以作为数值方法准确度的指标。

函数`twoSpringsME`计算由两个质量和两个弹簧组成的系统的机械能。

twoSpringsME :: MultiParticleState -> R
twoSpringsME mpst = systemKE mpst + twoSpringsPE mpst


对于该系统,机械能是守恒的,因为所有涉及的力都是保守力。对于由两个质量和两个弹簧组成的系统,图 20-2 展示了不同数值方法下机械能随时间的变化情况。第一列展示欧拉法,第二列为欧拉-克罗梅尔法,第三列为四阶龙格-库塔法。第一行使用 0.1 秒的时间步长,第二行使用 0.01 秒,第三行使用 10^(–3)秒,第四行使用 10^(–4)秒。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/372fig01.jpg)

*图 20-2:不同数值方法下,机械能随时间的变化。机械能的变化是数值不准确度的衡量标准。*

机械能在欧拉法中倾向于增加,在欧拉-克罗梅法中则表现为振荡,而在四阶龙格-库塔法中可能会减少。每个图中的横轴显示 10 秒钟的时间段。纵轴显示的尺度差异很大。如果我们将最大机械能和最小机械能之间的差异作为衡量不准确度的标准,就可以制作一个表格来比较数值方法。表 20-1 展示了这种比较。

**表 20-1:** 对于两质量和两弹簧系统,在 10 秒时间间隔内,不同数值方法计算的机械能变化

| **时间步长** | **欧拉法** | **欧拉-克罗梅法** | **四阶龙格-库塔法** |
| --- | --- | --- | --- |
| 10^(–1)s | 偏差很大 | 40% | 7% |
| 10^(–2)s | 偏差很大 | 4% | 10^(–4)% |
| 10^(–3)s | 20% | 0.4% | 10^(–8)% |
| 10^(–4)s | 2% | 0.04% | 10^(–11)% |

表 20-1 显示了在 10 秒钟期间,最大机械能减去最小机械能,并以初始机械能的百分比表示。对于小于毫秒的时间步长,欧拉方法甚至都无法接近正确的结果。如果你将数值方法改为欧拉方法,你可以在动画中看到这一点。弹簧在数值不准确的展示中伸展和下垂。该表还显示了,每次时间步长减少十倍,欧拉法和欧拉-克罗梅方法的准确性大约提高十倍。而四阶龙格-库塔方法每次减少十倍的时间步长,准确性大约提高 10⁴倍。通过这种方式,四阶方法展示了为什么它被认为是四阶方法。

在看到第一个多个物体相互作用的例子后,让我们看一下第二个相互作用的例子,即碰撞。

### 碰撞

我们的第二个多个物体相互作用的例子是两颗台球之间的碰撞。对于这个例子,我们将通过图 19-2 中的四种数据表示形式,讨论参数选择(包括时间步长),查看动量和能量守恒,讨论一些数值问题,并最终展示一些动画结果。

#### 数据表示

四种数据表示形式包括:力的列表、微分方程、状态更新函数和状态列表。我们将依次讨论这些。

##### 力

我们假设在第十九章中描述的弹性台球相互作用是作用于每个粒子的唯一力。这种弹性台球相互作用是作用在两颗台球之间的内部力。以下是力的列表:

billiardForces :: R -> [Force]
billiardForces k = [InternalForce 0 1 (billiardForce k (2*ballRadius))]


弹性台球相互作用要求我们指定一个弹簧常数,用于弹性排斥力,并指定一个阈值距离,在此距离下排斥力才会生效。我们将弹簧常数`k`作为`billiardForces`函数的参数,以便我们可以推迟确定一个特定值,这样也便于尝试不同的值。

每个球的直径为 6 厘米。阈值距离出现在球心相距两个半径的位置。由于这个距离始终相同,我们指定一个特定值`2*ballRadius`作为阈值距离,而不是像我们对弹簧常数做的那样将其作为一个参数。我们命名球半径是因为它在两个地方使用:我们刚才写的力的列表和稍后在本章中编写的显示函数。

ballRadius :: R
ballRadius = 0.03 -- 6cm diameter = 0.03m radius


##### 微分方程

牛顿第二定律将力的列表转化为一个微分方程。对于多粒子系统,`newtonSecondMPS`是执行此转化的函数。我们给表示牛顿第二定律的微分方程命名为`billiardDiffEq k`,它描述了两个台球在`billiardForces k`作用下的运动。

billiardDiffEq :: R -> MultiParticleState -> DMultiParticleState
billiardDiffEq k = newtonSecondMPS $ billiardForces k


我们继续通过弹簧常数`k`来参数化微分方程,直到现在我们还没有指定该值。

##### 状态更新函数

接下来,我们需要一个状态更新函数。编写状态更新函数的最简单方法是使用图 19-2 中的`updateMPS`函数。

billiardUpdate
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> R -- k
-> TimeStep -- dt
-> MultiParticleState -> MultiParticleState
billiardUpdate nMethod k dt = updateMPS (nMethod dt) (billiardForces k)


这个状态更新函数与我们编写的其他函数形式相同,不同之处在于我们包含了数值方法和弹簧常数作为参数,以便稍后可以指定这些项。只有三种类型为`TimeStep -> NumericalMethod MultiParticleState DMultiParticleState`的函数可以作为输入`nMethod`:`euler`、`eulerCromerMPS`和`rungeKutta4`。我们在使用这个函数时需要指定其中之一,但目前我们将推迟做出这个决定。

##### 状态列表

图 19-2 中的第四种数据表示法是一个演化器,它是一个函数,当给定初始状态时,会生成一系列状态。编写演化器的最简单方法是使用图 19-2 中的`statesMPS`函数。

billiardEvolver
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> R -- k
-> TimeStep -- dt
-> MultiParticleState -> [MultiParticleState]
billiardEvolver nMethod k dt = statesMPS (nMethod dt) (billiardForces k)


从演化器获得状态列表需要一个初始状态。在初始状态中,我们给定每个物体的质量以及它们的初始位置和速度。我们让每个台球的质量为 160 克。第一个球从原点开始,初始速度为 0.2 米/秒,方向为 x 轴。第二个球从静止状态开始,位于坐标(1 米,0.02 米)的 xy 平面上。小的 y 分量存在是为了让碰撞稍微倾斜,而不是一维的。初始状态的代码如下:

billiardInitial :: MultiParticleState
billiardInitial
= let ballMass = 0.160 -- 160g
in MPS [defaultParticleState { mass = ballMass
, posVec = zeroV
, velocity = 0.2 *^ iHat }
,defaultParticleState { mass = ballMass
, posVec = iHat + 0.02 *^ jHat
, velocity = zeroV }
]


现在我们基于这个初始状态来命名一个状态列表。该列表

billiardStates nMethod k dt


是一个无限状态列表,表示撞球碰撞的状态,当计算采用数值方法`nMethod`(`euler`,`eulerCromerMPS`或`rungeKutta4`),弹簧常数`k`和时间步长`dt`时得到。

billiardStates
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> R -- k
-> TimeStep -- dt
-> [MultiParticleState]
billiardStates nMethod k dt
= statesMPS (nMethod dt) (billiardForces k) billiardInitial


接下来,我们需要一个有限的状态列表,可以用来绘制图表,或者用来比较碰撞前后某些物理量的值,比如动量或动能。列表`billiardStatesFinite nMethod k dt`是一个有限的撞球碰撞状态列表,当计算采用数值方法`nMethod`,弹簧常数`k`和时间步长`dt`时得到。

billiardStatesFinite
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> R -- k
-> TimeStep -- dt
-> [MultiParticleState]
billiardStatesFinite nMethod k dt
= takeWhile (\st -> timeOf st <= 10) (billiardStates nMethod k dt)


为了形成有限列表,我们使用`takeWhile`来选择在 10 秒内所有的状态。如我们将很快看到的,碰撞大约发生在模拟的第 5 秒。

到目前为止,我们还没有做出关于数值方法、弹簧常数或时间步长的任何选择。接下来我们来讨论这个问题。

#### 弹簧常数和时间步长

在一门入门级的物理课程中,碰撞通常通过动量守恒来处理,而不是通过给出粒子间相互作用的显式力。使用动量守恒是一种优雅的方法,因为我们不需要知道粒子之间的力,只要它是短暂的,碰撞前后的系统动量必须相等。然而,单纯依赖动量守恒来分析碰撞也有缺点。例如,对于二维碰撞,通常需要一些在系统初始状态中没有的信息,比如碰撞后某一粒子的速度,来计算两粒子碰撞后的速度。另一方面,如果我们知道粒子之间的力的性质,那么初始条件就足以决定粒子的未来运动。

在实际操作中,我们通过指定粒子之间的显式力来分析碰撞,这意味着我们需要做出一些选择。弹性撞球碰撞要求我们指定弹簧常数。弹簧常数过小或过大都会给有限时间步长的数值分析带来问题。如果弹簧常数太小,物体在碰撞时会被挤压到一起,使得它们的中心非常接近,存在一个风险:一个物体可能会穿过另一个物体,而不是从它上面反弹出去。如果弹簧常数太大,当物体首次进入其分离阈值时,弹簧会施加一个非常大的力。这个力可能大到在下一个时间步长时物体已经超出了分离阈值。这将导致对力的采样不准确,从而可能导致数值结果较差。

如果时间步长过大,粒子在一个时间步内可能会移动得过远,完全错过粒子处于其阈值分离距离内的任何状态。即使碰撞没有完全错过,过大的时间步长也可能导致不准确的结果。我们的通用建议是选择一个与情况的特征时间尺度相比较小的时间步长。

除了常规的数值分析时间步长外,我们还需要为相互作用力选择一个弹簧常数。我们应该如何选择这两者呢?

选择弹簧常数的一个方法是设想所有初始的动能都转化为弹簧中的势能。如果这是真的,我们可以写出以下方程:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/377equ01.jpg)

我们考虑的两个台球的分离阈值是 6 厘米;也许我们希望球心之间的距离不要小于 5 厘米。那么,平衡位置的位移应不超过 1 厘米。将 1 厘米代入上面的方程并解出* k *,我们得到以下结果:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/377equ02.jpg)

这个计算是通过合理的猜测来确定弹簧常数的一种粗略方法。此碰撞中的初始动能仅部分转化为弹性势能。

碰撞的相关时间尺度是什么?一个时间尺度是移动的台球穿越等于阈值分离距离所需的时间。这个时间由阈值分离除以移动球的初速度给出。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/377equ03.jpg)

第二个时间尺度来自于问题中的弹簧常数和质量。如果这是一个质量可以在弹簧上振荡的问题,振荡周期将与![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/377equ04.jpg)成正比。在这种情况下不会发生振荡,但可以将碰撞看作是发生在一个完整振荡周期的半个周期内。如果这是一个完整的弹簧,半个周期包括从平衡位置到最近接触的弹簧压缩过程,然后是弹簧恢复平衡的膨胀过程。这个基于弹簧的第二个时间尺度是

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/377equ05.jpg)

我们为数值分析选择的时间步长需要比两个时间尺度中较小的那个要小,即 0.05 秒。到此为止,我们已经粗略估算了弹簧常数和时间步长。我们将很快通过识别我们希望计算具有的几个期望属性,并探索这些属性如何依赖于弹簧常数和时间步长,进一步优化这些估算。我们接下来将讨论的两个期望属性是动量守恒和能量守恒。

#### 动量和能量守恒

来自基础物理课程的关于碰撞的基本知识是:动量在所有碰撞中都得到守恒;然而,能量只有在*弹性碰撞*中才得到守恒。我们的碰撞是弹性的,因此我们期望动量和能量都能守恒。

##### 动量守恒

单个粒子的动量是粒子的质量与其速度的乘积。符号**p**通常用于表示动量,它是一个矢量,其国际单位制单位是 kg·m/s。

**p** = m**v**

(我们在第十八章中看到,相对论理论使用了不同的动量定义,但在第十九章和本章中,我们再次关注牛顿力学。)这是一个返回单个粒子动量的 Haskell 函数:

momentum :: ParticleState -> Vec
momentum st = let m = mass st
v = velocity st
in m *^ v


一个粒子系统的动量是系统中每个粒子的动量的矢量和。我们用大写字母**P**表示系统动量。在一个粒子系统中,粒子*n*的动量,假设其质量为*m[n]*,速度为**v**[*n*],由下式给出:

**p**[*n*] = *m[n]***v**[*n*]

系统动量为

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/378equ01.jpg)

这是一个返回粒子系统动量的 Haskell 函数。

systemP :: MultiParticleState -> Vec
systemP (MPS sts) = sumV [momentum st | st <- sts]


在任何只有内部力的系统中,系统的动量是守恒的,这意味着它在时间上保持不变。我们的数值计算涉及一个有限的时间步长,该时间步长必须相对于物理情境的特征时间尺度较小,以便获得准确的结果。随着时间步长的增加,大多数物理量的准确性会变得越来越差。一个例外是系统动量,在只有内部力存在的情况下。我们将力分类为外力或内力,并自动应用牛顿第三定律,保证了在没有外力的任何情况下,系统动量都将得到守恒,无论数值方法如何,时间步长如何。这是因为每个内部力在一个时间步长内作用,会改变一个粒子的动量向量一定的量,同时会改变另一个粒子的动量向量相反的量。即使时间步长如此之大,以至于计算结果不佳,系统动量也不会在时间步长之间发生变化。

即使在外力存在的碰撞中,系统动量通常也会大致守恒,因为碰撞的内部力通常与任何外力相比较强。由于碰撞通常是短暂的,外力在碰撞的短暂时间内的作用通常非常小。

为了确认系统动量的守恒,让我们编写一个函数来计算系统动量的百分比变化。由于状态列表是常见的信息表示方式,我们将使用有限数量的多粒子状态作为该函数的输入,但我们只会比较列表中的第一个和最后一个状态。以下是该函数:

percentChangePMag :: [MultiParticleState] -> R
percentChangePMag mpsts
= let p0 = systemP (head mpsts)
p1 = systemP (last mpsts)
in 100 * magnitude (p1 - p0) / magnitude p0


我们将传入的多粒子状态列表命名为`mpsts`,并使用 Prelude 函数`head`和`last`分别提取列表中的第一个和最后一个状态,将它们的系统动量命名为`p0`和`p1`。然后我们计算最终系统动量`p1`和初始系统动量`p0`之间的差值,形成该动量变化向量的大小,再除以初始系统动量的大小,最后乘以 100 得到百分比。

##### 创建表格

为了查看系统动量在我们计算中的保守情况,让我们做一个小表格,显示不同时间步长和不同弹簧常数下的系统动量的百分比变化。如果我让计算机显示它保存的所有 15 位数字(双精度浮点数,即我们称之为实数的`R`类型),表格会显得很难看。下面的`sigFigs`函数将一个数字四舍五入到指定的有效数字位数:

sigFigs :: Int -> R -> Float
sigFigs n x = let expon :: Int
expon = floor (logBase 10 x) - n + 1
toInt :: R -> Int
toInt = round
in (10^^expon *) $ fromIntegral $ toInt (10^^(-expon) * x)


该函数通过将输入数字`x`除以 10^(*m*),其中*m*是某个整数,四舍五入该数字,然后再将数字乘以 10^(*m*)来工作。整数*m*在代码中被称为`expon`;其值取决于请求的有效数字位数`n`。Prelude 中的`round`函数有一个相当通用的类型;我通过定义一个局部函数`toInt`,使用一个简单的具体类型来专门化它以满足我的需求。

我们需要的最终工具是用于制作可爱的表格的工具,我们将使用它来表示动量、能量以及其他一些内容,它是一个带有`Show`实例的数据类型,可以使表格以格式化的方式显示。首先,我们定义一个新的数据类型`Table a`,它是类型`a`的项目的表格。

data Justification = LJ | RJ deriving Show

data Table a = Table Justification [[a]]


数据类型`Justification`用于指定我们希望表格左对齐还是右对齐。一个`Table a`包含一个`Justification`和一个包含类型`a`的项目列表列表。

我们为新的数据类型编写了一个显式的`show`实例,以便以漂亮的方式格式化输出。

instance Show a => Show (Table a) where
show (Table j xss)
= let pairWithLength x = let str = show x in (str, length str)
pairss = map (map pairWithLength) xss
maxLength = maximum (map maximum (map (map snd) pairss))
showPair (str,len)
= case j of
LJ -> str ++ replicate (maxLength + 1 - len) ' '
RJ -> replicate (maxLength + 1 - len) ' ' ++ str
showLine pairs = concatMap showPair pairs ++ "\n"
in init $ concatMap showLine pairss


在第一行中,我们看到一个类型类约束;类型`a`必须是类型类`Show`的实例,才能使`Table a`成为类型类`Show`的实例。`Show`的实例声明只要求我们定义一个函数`show`,该函数以`Table a`作为输入,并产生一个字符串作为输出。我们定义了一个局部函数`pairWithLength`,它将值的字符串表示和该字符串的长度配对。我们关心长度是因为我们希望列能够整齐对齐。局部变量`pairss`是一个由字符串和长度对组成的列表的列表。名字末尾的双*s*表示这是一个列表的列表。我们通过将`map pairWithLength`映射到输入的列表列表`xss`来形成`pairss`。由于`xss`的每个元素都是一个列表,所以我们对`xss`中的每个列表应用`map pairWithLength`,因此`pairWithLength`会作用于列表中的每个项。

局部变量`maxLength`用于查找表格中最长项目的长度。然后,我们使用这个最长的长度来设置将要显示的所有列的宽度。我们编写局部函数来显示单个项目和表格中的一行。最后,我们通过将`showLine`应用于`pairss`并连接结果来形成表格。如果你觉得这个显示表格的小技巧有趣,可以尽情研究;否则,我们就继续使用它。

以下是展示欧拉法、欧拉-克罗梅法和四阶龙格-库塔法在不同弹簧常数和时间步长下动量百分比变化的表格:

pTable :: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> [R] -- ks
-> [TimeStep] -- dts
-> Table Float
pTable nMethod ks dts
= Table LJ [[sigFigs 2 $
percentChangePMag (billiardStatesFinite nMethod k dt)
| dt <- dts] | k <- ks]

pTableEu :: [R] -- ks
-> [TimeStep] -- dts
-> Table Float
pTableEu = pTable euler


我们可以在 GHCi 中查看这些表格。

Prelude Vis> :m
Prelude> :l MOExamples
[1 of 6] Compiling Newton2 ( Newton2.hs, interpreted )
[2 of 6] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[3 of 6] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[4 of 6] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[5 of 6] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[6 of 6] Compiling MOExamples ( MOExamples.hs, interpreted )
Ok, six modules loaded.
*MOExamples> pTable euler [10,30,100] [0.003,0.01,0.03,0.1]
4.3e-14 0.0 0.0 0.0
0.0 0.0 0.0 0.0
2.2e-14 0.0 0.0 8.7e-14
*MOExamples> pTable eulerCromerMPS [10,30,100] [0.003,0.01,0.03,0.1]
0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0
0.0 0.0 0.0 0.0
*MOExamples> pTable rungeKutta4 [10,30,100] [0.003,0.01,0.03,0.1]
4.3e-14 2.2e-14 0.0 0.0
2.2e-14 0.0 2.2e-14 0.0
0.0 0.0 0.0 0.0


无论是数值方法、弹簧常数还是时间步长,动量的百分比变化要么为 0,要么为某个数乘以 10^(–14)。由于这是百分比变化,实际上我们谈论的是 10¹⁶分之一的几个部分,这就是双精度浮点数的精度。这个偏离 0 的误差并不是我们所做的有限步长计算导致的;而是因为任何使用浮点数的计算都是近似的。计算机无法精确地除以 10,因为它用重复的二进制展开 0.0001100110011 . . . 来表示分数 1/10(回想一下表 1-4)。10¹⁶分之一的几个部分是我们期望在任何涉及双精度浮点数的计算中看到的偏差。

有了这个理解,这些表格展示了无论使用哪种数值方法、弹簧常数或步长,台球碰撞系统的动量都是守恒的。这是之前提到的一个例子,说明在只有内力的情况下,系统动量会保持守恒,无论数值方法、步长或描述问题的其他参数如何。接下来,我们将研究多粒子系统的能量,这个系统不具备这种理想的属性。

##### 能量守恒

除了在碰撞物体接触的短时间内,系统中唯一的能量形式就是物体的动能。

接下来,让我们看看台球碰撞中系统动能随时间的变化。只要碰撞没有发生,系统动能将完全守恒,因为球的速度没有变化。在碰撞发生的短时间内,一部分动能转化为弹性势能,由我们的弹簧储存,然后再转化回动能。图 20-3 展示了两颗台球碰撞时,系统动能与时间的关系图。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/382fig01.jpg)

*图 20-3:两颗台球碰撞时的系统动能*

我们看到,在碰撞之前,大约在 4.8 秒时刻,系统动能是守恒的,因为进入的粒子以恒定速度运动。我们看到系统动能出现预期的下降,转化为弹性势能。图表显示,系统动能大约有 40%的部分被转化为弹性势能,因为它从最初的 3.2 mJ 降至约 1.9 mJ。随着弹簧从最大压缩状态恢复,弹性势能转化回动能,碰撞后动能保持不变。

在弹性碰撞中,碰撞后的系统动能应该与碰撞前相同。我们从图表中可以看到,碰撞后的系统动能接近,但不完全等于碰撞前的值。这一差异是由于我们方法的有限步长引起的,且它取决于数值方法、步长和其他情况参数。从图中我们可以看到,欧拉-克罗梅方法和四阶龙格-库塔方法产生了略微不同的结果,包括最终系统动能的略微不同的结果。

以下代码生成了图 20-3 中的图表。

systemKEWithTime :: IO ()
systemKEWithTime
= let timeKEPairsEC
= [(timeOf mpst, systemKE mpst)
| mpst <- billiardStatesFinite eulerCromerMPS 30 0.03]
timeKEPairsRK4
= [(timeOf mpst, systemKE mpst)
| mpst <- billiardStatesFinite rungeKutta4 30 0.03]
in plotPaths [Key Nothing
,Title "System Kinetic Energy versus Time"
,XLabel "Time (s)"
,YLabel "System Kinetic Energy (J)"
,XRange (4,6)
,PNG "SystemKE.png"
,customLabel (4.1,0.0026) "dt = 0.03 s"
,customLabel (4.1,0.0025) "k = 30 N/m"
,customLabel (5.4,0.00329) "Euler-Cromer"
,customLabel (5.4,0.00309) "Runge-Kutta 4"
] [timeKEPairsEC,timeKEPairsRK4]


局部变量`timeKEPairsEC`和`timeKEPairsRK4`保存了欧拉-克罗梅和四阶龙格-库塔方法下时间和系统动能的对列表。我们然后使用 gnuplot 的`plotPaths`函数绘制这些对列表。

##### 步长和弹簧常数对动能的影响

系统动能并不像系统动量那样具有保证其守恒的良好性质,无论步长大小。像大多数物理量一样,能量的精确计算需要一个合理小的步长。事实上,观察系统动能的守恒情况是判断我们是否使用了足够小步长的好方法。

为了研究不同步长和弹簧常数下系统动能的守恒,我们编写了一个函数来计算系统动能的百分比变化。由于状态列表是一种常见的信息表示方法,我们将使用一个有限的多粒子状态列表作为此函数的输入,但我们只比较列表中的第一个和最后一个状态。下面是这个函数:

percentChangeKE :: [MultiParticleState] -> R
percentChangeKE mpsts
= let ke0 = systemKE (head mpsts) ke1 = systemKE (last mpsts)
in 100 * (ke1 - ke0) / ke0


我们将传入的多粒子状态列表命名为`mpsts`,并使用 Prelude 函数`head`和`last`分别提取列表中的第一个和最后一个状态,将它们的系统动能分别命名为`ke0`和`ke1`。接着,我们计算最终系统动能`ke1`与初始系统动能`ke0`之差,除以初始系统动能,再乘以 100 以得到百分比。

为了探索动能守恒,我们将制作一些小表格,展示在几个不同的时间步长和弹簧常数下,系统动能的百分比变化。以下`tenths`函数将一个数字四舍五入到小数点后一个位,并有助于制作一个整齐的表格。

tenths :: R -> Float
tenths = let toInt :: R -> Int
toInt = round
in (/ 10) . fromIntegral . toInt . (* 10)


这个函数的工作原理是将输入数字`x`乘以 10,四舍五入后,再将结果除以 10。Prelude 函数`round`具有非常通用的类型;我通过定义一个局部函数`toInt`并为其指定一个简单的具体类型来满足我的需求。

函数`keTable`在给定数值方法、弹簧常数列表和时间步长列表时,生成一个系统动能百分比变化的表格。

keTable
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> [R] -- ks
-> [TimeStep] -- dts
-> Table Float
keTable nMethod ks dts
= Table RJ [[tenths $
percentChangeKE (billiardStatesFinite nMethod k dt)
| dt <- dts] | k <- ks]


我们可以在 GHCi 中查看这些表格。

*MOExamples> keTable euler [10,30,100] [0.003,0.01,0.03,0.1]
4.2 15.9 68.7 705.7
8.3 34.1 185.4 3117.9
16.9 82.9 642.2 39907.1
*MOExamples> keTable eulerCromerMPS [10,30,100] [0.003,0.01,0.03,0.1]
0.0 0.0 -0.3 6.2
0.0 0.1 1.1 154.1
0.0 0.3 -8.9 3705.2

*MOExamples> keTable rungeKutta4 [10,30,100] [0.003,0.01,0.03,0.1]
0.0 0.0 0.0 -2.8
0.0 -0.1 -1.4 -14.6
0.0 -0.5 -1.6 90.3


在每个表格的左上角,动能守恒表现最好,此时时间步长和弹簧常数都较小。小时间步长带来的更好结果并不令人惊讶。较小的弹簧常数使碰撞持续时间更长,发生在更多的时间步长中。只有在少数几个时间步长内发生的碰撞,其计算结果不太可能非常精确。另一方面,弹簧常数过小则有可能使物体彼此靠得太近。我们将在下一节讨论这个问题。

#### 数值问题

我们已经建议,对于碰撞的准确计算,碰撞过程中需要多个时间步长。我们还指出,我们不希望碰撞物体之间的距离过近。这两个对计算的期望性质是相互矛盾的,因为第一个性质更有利于较小的弹簧常数,而第二个性质则更有利于较大的弹簧常数。让我们更详细地分析这两种期望性质。

##### 碰撞过程中的时间步长

如前所述,如果在碰撞过程中仅经过少数几个时间步长,或者更糟,只有一个或零个时间步长,那么我们不太可能得到准确的结果。这一观察促使我们提出一个问题:碰撞过程中经过了多少个时间步长,或者等效地,多少个时间步长内小球的间隔在阈值范围内。这个问题的答案取决于数值方法、弹簧常数和时间步长。我们希望有一个较大的时间步长数量(例如,至少 10 个)。

函数`contactSteps`返回小球在其阈值间隔为 6 厘米时的时间步数。它接受一个有限的多粒子状态列表作为输入。

contactSteps :: [MultiParticleState] -> Int
contactSteps = length . takeWhile inContact . dropWhile (not . inContact)


该函数通过使用`dropWhile`来丢弃球体之间尚未接触的多粒子状态;换句话说,丢弃在球体接近 6 厘米之前的状态。我们使用接下来定义的`inContact`谓词来判断在给定的多粒子状态中,球体是否接触,即它们的中心是否相距 6 厘米以内。然后我们使用`takeWhile`来保留球体接触的状态。最后,我们计算该列表的长度,即球体在接触中的状态数或时间步数。

谓词`inContact`通过计算粒子中心之间的距离并将其与两倍球半径(6 厘米)的阈值分离进行比较来工作。

inContact :: MultiParticleState -> Bool
inContact (MPS sts)
= let r = magnitude $ posVec (sts !! 0) - posVec (sts !! 1)
in r < 2 * ballRadius


函数`contactTable`返回数值方法中该时间步数的接触次数、弹簧常数列表和时间步长列表。

contactTable
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> [R] -- ks
-> [TimeStep] -- dts
-> Table Int
contactTable nMethod ks dts
= Table RJ [[contactSteps (billiardStatesFinite nMethod k dt)
| dt <- dts] | k <- ks]


我们使用列表推导来形成将作为表格显示的列表列表。以下是来自 GHCi 的结果:

*MOExamples> contactTable euler [10,30,100] [0.003,0.01,0.03,0.1]
89 27 9 3
53 16 6 2
29 9 3 2
*MOExamples> contactTable eulerCromerMPS [10,30,100] [0.003,0.01,0.03,0.1]
89 27 9 2
53 16 5 1
29 9 3 1
*MOExamples> contactTable rungeKutta4 [10,30,100] [0.003,0.01,0.03,0.1]
89 27 9 2
53 16 5 1
29 9 3 0


数值方法之间差别不大。不管采用哪种数值方法,最佳结果通常出现在每个表格的左上角,那里弹簧常数和时间步长都最小。较小的弹簧常数意味着弹簧比较松弛(而不是僵硬),可以在更长的距离内压缩,从而使碰撞过程中的时间步数更多。

在 Runge-Kutta 表的右下角,可能会有一些奇怪的现象,尤其是当*k* = 100 N/m 和*dt* = 0.1 s 时。为什么在阈值距离内没有发生任何时间步?并不是因为时间步长太大,以至于运动的小球完全跳过了静止的小球而没有发生碰撞。实际上,这是因为四阶 Runge-Kutta 步由四个子步骤组成,使用在四个不同位置的导数来计算时间步的最终值变化。当两颗球接近但刚好在阈值分离之外时,Runge-Kutta 步会感知到来自一个或多个子步骤的排斥力。弹簧常数非常大,导致一个很大的排斥力作用于球体,将球体推开,因此在下一个实际的时间步之前,球体已经被排斥开了。因此,表中列出的在阈值分离内的时间步数为 0,并不比在阈值内有一个时间步数差别;这对于准确的计算来说并不够。

##### 最近分离

我们不希望球心之间距离过近。如果我们使用一个非常小的弹簧常数,球体可能会压缩直到它们的球心重合,甚至穿越彼此。这显然不是台球的工作方式。台球几乎不会压缩,因此为了准确模拟它们,需要一个相当大的弹簧常数。

了解球心之间的最小距离非常有趣也很重要,这样我们可以避免选择过小的弹簧常数。我们希望知道在碰撞过程中,球体中心到中心的最小分离距离。这个问题的答案取决于数值方法、弹簧常数和时间步长。

函数`closest`返回在碰撞过程中,球体达到的最小分离距离。它接受一个有限的多粒子状态列表作为输入。

closest :: [MultiParticleState] -> R
closest = minimum . map separation


这个函数的作用是对有限列表中的每个多粒子状态应用下面的`separation`函数,并计算最小值。

`separation`函数通过计算球心之间的位移并求其大小来工作。

separation :: MultiParticleState -> R
separation (MPS sts)
= magnitude $ posVec (sts !! 0) - posVec (sts !! 1)


函数`closestTable`在给定数值方法、一系列弹簧常数和时间步长的情况下,返回一个最小分离距离的表格。

closestTable
:: (TimeStep -> NumericalMethod MultiParticleState DMultiParticleState)
-> [R] -- ks
-> [TimeStep] -- dts
-> Table Float
closestTable nMethod ks dts
= Table RJ [[tenths $ (100*) $
closest (billiardStatesFinite nMethod k dt)
| dt <- dts] | k <- ks]


我们将米转换为厘米,乘以 100,结果显示在表格中。

这是 GHCi 的结果:

*MOExamples> closestTable euler [10,30,100] [0.003,0.01,0.03,0.1]
4.4 4.3 4.0 2.8
5.0 4.9 4.6 2.8
5.4 5.3 5.0 2.8
*MOExamples> closestTable eulerCromerMPS [10,30,100] [0.003,0.01,0.03,0.1]
4.4 4.4 4.4 4.5
5.1 5.1 5.0 4.5
5.5 5.5 5.5 4.5
*MOExamples> closestTable rungeKutta4 [10,30,100] [0.003,0.01,0.03,0.1]
4.4 4.4 4.4 4.7
5.1 5.1 5.1 5.2
5.5 5.5 5.5 6.3


如果我们的目标是最小化压缩,从而获得较大的最小分离距离,那么每个表格的左下角就是我们想要的位置。这意味着我们需要一个较大的弹簧常数。

我们可以看到,在 Runge-Kutta 表格的右下角,最小分离距离为 6.3 厘米,这看起来不可能。如果球体从未接近阈值分离距离,怎么会有反作用力呢?答案是,四阶 Runge-Kutta 时间步长是基于四个子步骤的,其中一些子步骤会在阈值距离内采样排斥力。

假设我们需要一些参数(弹簧常数和时间步长),使得碰撞过程中至少有 10 个时间步,最小分离距离不小于 5 厘米,并且动能保持在 1%的误差范围内。

欧拉方法不可用。对于我们采样的任何弹簧常数和时间步长,它无法保持动能在 1%的误差范围内。Euler-Cromer 方法可以在*k* = 30 N/m 和*dt* = 0.003 s 或*dt* = 0.01 s,或者*k* = 100 N/m 和*dt* = 0.003 s 的情况下使用。四阶 Runge-Kutta 也可以使用相同的参数。

#### 动画结果

我们想使用 gloss 为台球碰撞做动画。我们已经编写了一个状态更新函数和初始状态。接下来要做的是编写一个显示函数,我们现在就来实现。

billiardPicture :: MultiParticleState -> G.Picture
billiardPicture (MPS sts)
= G.scale ppm ppm $ G.pictures [place st | st <- sts]
where
ppm = 300 -- pixels per meter
place st = G.translate (xSt st) (ySt st) blueBall
xSt = realToFrac . xComp . posVec
ySt = realToFrac . yComp . posVec
blueBall = G.Color G.blue (disk $ realToFrac ballRadius)


我们对输入进行模式匹配,将传入的单粒子状态列表命名为`sts`。这是一个长度为 2 的列表,因为有两个粒子。我们的显示函数采用“最后缩放整张图片”这一范式,使用`G.scale`函数,并将常数`ppm`作为每米像素数,作为我们统一空间尺度因子的值。预缩放的图片由`G.pictures`函数生成,该函数将每个球的图片列表组合起来。图片列表是通过列表推导和`place`函数生成的,`place`函数将在稍后的几行中定义。这段代码可以用于多粒子系统,粒子数量不限,只要我们接受每个粒子都由蓝色圆盘表示。(如果你不喜欢每个粒子都是蓝色的,可以参考第 20.2 题。)

`billiardPicture`显示函数的其余部分由在`where`关键字之后定义的局部常量和函数组成。回想一下,`where`就像`let`-`in`结构一样,允许我们定义局部变量;局部变量在`where`关键字之前使用,在之后定义。`let`-`in`结构中的局部变量在`in`关键字之前定义,在之后使用。`let`-`in`结构与`where`结构的区别,就像是自下而上的思维方式——先定义最小的部分,再构建整个函数;和自上而下的思维方式——先定义整个函数,再通过尚未定义的部分来阐述。Haskell 支持并鼓励这两种思维方式,通过提供这两种结构,允许我们以尚未定义的常量和函数来做定义。

我们定义的第一个局部变量是空间尺度因子`ppm`,我们将其设置为每米 300 像素。接下来,我们定义了局部函数`place`,我们已经用它将图片移动到状态中指定的 xy 坐标位置。函数`place`使用尚未定义的函数`xSt`和`ySt`从状态中提取坐标,并使用尚未定义的图片`blueBall`表示一个蓝色圆盘。函数`place`通过`G.translate`函数根据坐标来平移图片。

局部函数`xSt`从状态中提取 x 坐标位置,并使用`realToFrac`返回一个`Float`类型的值,这是`G.translate`所期望的类型。`xSt`的定义采用点自由风格,是由三个函数的组合:`posVec`,从状态中提取位置;`xComp`,从位置中提取 x 坐标;以及`realToFrac`,将`R`类型转换为`Float`类型。函数`ySt`与`xSt`类似,不过它用于提取 y 坐标。最后,我们定义了局部常量`blueBall`,它是一个半径为`ballRadius`的蓝色`disk`,必须将其转换为`Float`类型,以匹配第十七章中`disk`函数所期望的输入类型。

清单 20-3 展示了一个独立的程序,使用了我们在第十六章中编写的`simulateGloss`函数。这个独立程序包含一个定义:一个名为`main`的主函数。

{-# OPTIONS -Wall #-}

import Mechanics3D ( simulateGloss )
import MultipleObjects (eulerCromerMPS )

import MOExamples ( billiardInitial, billiardPicture, billiardUpdate )

main :: IO ()
main = simulateGloss 1 100 billiardInitial billiardPicture
(billiardUpdate eulerCromerMPS 30)


*清单 20-3:两个台球碰撞的二维动画的独立程序*

`main`函数使用导入的`simulateGloss`函数执行动画。我们选择一个时间尺度因子为 1,动画速率为 100 帧/秒,给定时间步长为 0.01 秒。我们选择一个弹簧常数为 30 N/m。我们从本章编写的`MOExamples`模块中导入初始状态`billiardInitial`、状态更新函数`billiardUpdate`和显示函数`billiardPicture`。当你运行动画时,你会看到一颗蓝色的台球向右移动,与一颗静止的蓝色台球发生碰撞。碰撞后,原本运动的台球向下移动,而原本静止的台球向上和向右移动。

### 吉他弦上的波动

在这一节中,我们将模拟吉他弦上的波动。特别地,我们将聚焦于吉他的 G 弦。一个典型的 G 弦的质量是每米 0.8293 克。吉他颈部到底部的距离,即弦固定的两个位置,是 65 厘米。当吉他弦在开放位置演奏时,产生的基本振动使得 G 音符的波长为 130 厘米,因为弦的偏离平衡是一个正弦函数,它在吉他颈部从 0 开始,在底部返回 0,完成半个波长。任何开放的吉他弦,其基本振动的波长都是 130 厘米。

我们希望频率为 196 Hz,以产生 G 音符。这是因为约定俗成的规则是,55 Hz、110 Hz、220 Hz、440 Hz 和 880 Hz 分别表示不同版本的 A 音符。将频率加倍会得到相同的音符,上升一个八度。人们使用的十二平均律音阶中,一八度有 12 个“半音”,即频率的加倍。G 音符比 A 音符低两个半音,因此我们必须将 A 音符的频率乘以 2^(–2/12),以得到 G 音符的频率。对于吉他,我们将 220 Hz 乘以 2^(–2/12),得到 196 Hz。

对于任何波,波长*λ*和周期*t*与波速*v*之间的关系为:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/390equ01.jpg)

这个方程最容易理解的是对于一个行进波(即,波峰简单地沿着速度*v*传播的波)。对于一个行进波,波峰在每个周期内通过一个波长的距离,因此它的速度是波长除以周期,这正是方程所声称的。频率*f*和周期*t*之间的关系为:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/390equ02.jpg)

导致如下方程:

*v* = *λf*

该方程涉及波速、波长和频率之间的关系。

注意

*有些人喜欢使用希腊字母*ν*(nu)表示频率,这样他们就能用以下物理笑话来回应常见的问候:*

***朋友:*** *有什么新鲜事吗?(nu?)*

***笑话王:*** *v 除以 λ!*

*大多数人无法外表上笑出声来,但他们肯定内心在笑。*

由于吉他弦的两端是固定的,吉他弦表现为驻波而非行波,但前面所显示的方程依然有效,因为波速与弦中的张力*F*以及单位长度的质量*μ*之间是有关系的:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/391equ01.jpg)

当我们调节吉他的琴弦时,我们改变了张力,这会改变波速,从而改变频率。让我们计算出为了达到 196 Hz 频率所需的张力。

为了达到 196 Hz 的频率,我们需要一个波速,

*v* = *λf* = (130 厘米)(196 Hz) = 254.8 m/s

这需要一个张力:

*F* = *μv*² = (0.8293 g/m)(254.8 m/s)² = 53.84 N

因此,我们需要在吉他 G 弦上施加 53.84 N 的张力。

#### 力

我们将吉他弦建模为 64 个小点质量,这些质量沿着 65 厘米的长度每隔 1 厘米分布。每个质量通过弹簧与其两个邻近的质量连接。如果我们给每个弹簧设置零的平衡长度和 5384 N/m 的弹簧常数,当弹簧被拉伸 1 厘米时,每个弹簧将产生 53.84 N 的力,这也是当弦处于静止状态时,质量间的距离。所以,会有 64 个质量点、63 个内部弹簧和 2 个外部弹簧,分别连接到两个固定端,位于 0 厘米和 65 厘米。以下是力的列表:

-- 64 masses (0 to 63)
-- There are 63 internal springs, 2 external springs
forcesString :: [Force]
forcesString
= [ExternalForce 0 (fixedLinearSpring 5384 0 (vec 0 0 0))
,ExternalForce 63 (fixedLinearSpring 5384 0 (vec 0.65 0 0))] ++
[InternalForce n (n+1) (linearSpring 5384 0) | n <- [0..62]]


#### 状态更新函数

为了制作动画,我们需要一个状态更新函数,而为此我们必须选择一种数值方法。无论是欧拉-克罗梅尔方法还是四阶龙格-库塔方法都可以,这里我们选择龙格-库塔方法,因为它稍微更精确。

stringUpdate :: TimeStep
-> MultiParticleState -- old state
-> MultiParticleState -- new state
stringUpdate dt = updateMPS (rungeKutta4 dt) forcesString


#### 初始状态

我们需要一个初始状态。事实上,探索弦的几个不同初始状态是很有趣的。函数`stringInitialOvertone`产生一个初始状态,其中弦位于 xy 平面内,最初不动,呈现正弦波模式。

stringInitialOvertone :: Int -> MultiParticleState
stringInitialOvertone n
= MPS [defaultParticleState
{ mass = 0.8293e-3 * 0.65 / 64
, posVec = x *^ iHat + y *^ jHat
, velocity = zeroV
} | x <- [0.01, 0.02 .. 0.64],
let y = 0.005 * sin (fromIntegral n * pi * x / 0.65)]


使用此函数,输入 1 将产生我们之前讨论的基频振动。图 20-4 展示了`stringInitialOvertone 1`的样子。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/392fig01.jpg)

*图 20-4:吉他弦基频振动模式的初始状态,由 stringInitialOvertone 1 给出*

较大的数值会产生在更高频率振动的泛音。使用 2 会产生一个振动频率为 392 Hz 的泛音,而 3 会产生一个振动频率为 588 Hz 的泛音。图 20-5 显示了`stringInitialOvertone 3`的样子。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/393fig01.jpg)

*图 20-5:吉他弦第二泛音的初始状态,由 stringInitialOvertone 3 给出*

从吉他弦中发出的声音是基频与泛音的混合。初始状态`stringInitialPluck`旨在模拟吉他弦的拨动。

stringInitialPluck :: MultiParticleState
stringInitialPluck = MPS [defaultParticleState
{ mass = 0.8293e-3 * 0.65 / 64
, posVec = x *^ iHat + y *^ jHat
, velocity = zeroV
} | x <- [0.01, 0.02 .. 0.64], let y = pluckEq x]
where
pluckEq :: R -> R
pluckEq x
| x <= 0.51 = 0.005 / (0.51 - 0.00) * (x - 0.00)
| otherwise = 0.005 / (0.51 - 0.65) * (x - 0.65)


假设拨片在距离琴颈 51 厘米的位置触碰弦,位于吉他琴体的孔前。如果拨片将弦移动了 5 毫米,则生成的弦形态由`stringInitialPluck`给出,如图 20-6 所示。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/394fig01.jpg)

*图 20-6:吉他弦的拨弦初始状态,由 string InitialPluck 给出*

练习 20.10 要求你编写一个函数,从`MultiParticleState`生成像这样的*xy*图像。

#### 独立程序

清单 20-4 展示了一个独立程序,用于制作吉他弦上波动的二维动画。

{-# OPTIONS -Wall #-}

import SimpleVec ( zeroV, iHat, (*^), xComp, yComp )
import Mechanics3D ( ParticleState(..), simulateGloss )
import MultipleObjects ( MultiParticleState(..) )
import MOExamples
import Graphics.Gloss ( Picture(..), scale, blue )

stringPicture :: MultiParticleState -> Picture
stringPicture (MPS sts)
= let rs = [zeroV] ++ [posVec st | st <- sts] ++ [0.65 ^ iHat]
xy r = (realToFrac $ xComp r, realToFrac $ yComp r)
xys = map xy rs
ppm = 400 -- pixels per meter
in scale ppm (20
ppm) $ Color blue $ Line xys

main :: IO ()
main = let initialState = stringInitialOvertone 3
in simulateGloss 0.001 40 initialState stringPicture stringUpdate


*清单 20-4:吉他弦的二维动画独立程序*

我们使用 0.001 的时间尺度因子,这意味着 1 毫秒的物理时间对应 1 秒的动画时间。清单 20-4 中的代码使用了初始状态`stringInitialOvertone 3`,但是我们可以将该初始状态替换为`stringInitialOvertone 1`来动画化基频的振动,或者使用`string` `InitialPluck`来动画化由拨弦产生的振动。196 Hz 的基频代表大约 5 毫秒的周期,因此基频的振动将需要大约 5 秒的动画时间来完成一个周期,而 3 倍泛音只需要 1.7 秒的动画时间来完成一个周期。

我们使用 40 帧/秒的动画速率,得到一个 25*μ*s 的时间步长。这个选择是基于问题中的重要时间尺度。首先是基频的周期,大约是 5 毫秒。泛音的周期逐渐变短,即

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/395equ01.jpg)

显然,我们使用的任何有限时间步长都会丢失一些关于高次泛音的信息,因为它们的周期变得非常短。(我们用 64 个质量点来建模弦的方式,也限制了可以精确计算的泛音数量。例如,200 次泛音有大约 100 个波峰和 100 个波谷;如果我们只追踪 64 个质量点的位置,显然无法精确描述这一点。)

除了基频的周期外,振动弦的另一个重要时间尺度是波从一个小质量点传播到邻近质量点所需的时间。这个时间由质量点之间的距离除以波速得出。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/395equ02.jpg)

这个时间大约是 40*μ*s,比基频的周期要短得多。

对于波动情况或任何具有空间步长Δ*x*且信息以有限速度传播的情况,存在一个稳定性准则。该准则指出,时间步长必须小于信息传播一个空间步长所需的时间。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/395equ03.jpg)

关于这个稳定性标准的更多内容可以在**[18**]中找到。使用高于这个阈值的时间步长会存在数值不稳定的风险,导致不合理的结果。Figure 20-7 展示了一个数值不稳定的例子。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/396fig01.jpg)

*Figure 20-7:当时间步长超过信息在弦上各质量点之间传播所需的时间时,出现数值不稳定的例子。从上到下是三个连续的时间步。*

Figure 20-7 展示了一个有三个连续时间步(第 10、11、12 步)的弦图,这个模拟使用了一个时间步长为 100*μ*s 的值,超出了稳定性阈值。仅仅经过两个时间步,计算结果从合理变得荒谬。使用 100-*μ*s 的时间步长时,计算不稳定。如果我们在动画中使用这个时间步,图像将迅速变得混乱。我通过命令创建了 Figure 20-7 中的面板。

mpsPos (iterate (stringUpdate 100e-6) (stringInitialOvertone 1) !! 10)
mpsPos (iterate (stringUpdate 100e-6) (stringInitialOvertone 1) !! 11)
mpsPos (iterate (stringUpdate 100e-6) (stringInitialOvertone 1) !! 12)


其中`mpsPos`是你在练习 20.10 中需要编写的函数。

时间步长必须小于 40*μ*s,这也是我们之前选择 25*μ*s 的原因。

#### 异步动画

由 gloss 和 non-gloss 生成的吉他弦动画接近我电脑的处理能力极限。在动画运行一段时间后,帧更新似乎变慢,表明计算机在同时执行我们要求的所有计算和显示结果时遇到了困难。随着我们要求电脑做的计算越来越多,最终会有一个时刻,计算机无法以足够的速度完成所有计算,同时显示结果。

这个情况的一个解决方案是使用*异步动画*,在这种方式中,我们首先进行所有计算,然后将结果拼接成一个可以稍后观看的电影。Listing 20-5 展示了一个独立的程序,它创建了 1,000 个 PNG 文件,每个文件显示吉他弦的图片,间隔为 25-*μ*s。这些文件可以通过外部程序(例如 ffmpeg)合成成一个 MP4 电影。

{-# OPTIONS -Wall #-}

import SimpleVec ( R, zeroV, iHat, (*^), xComp, yComp )
import Mechanics3D ( ParticleState(..) )
import MultipleObjects ( MultiParticleState(..) )
import MOExamples
import Graphics.Gnuplot.Simple

makePNG :: (Int,MultiParticleState) -> IO ()
makePNG (n,MPS sts)
= let rs = [zeroV] ++ [posVec st | st <- sts] ++ [0.65 *^ iHat]
xy r = (xComp r, yComp r)
xys :: [(R,R)]
xys = map xy rs
threeDigitString = reverse $ take 3 $ reverse ("00" ++ show n)
pngFilePath = "GnuplotWave" ++ threeDigitString ++ ".png"
in plotPath [Title "Wave"
,XLabel "Position (m)"
,YLabel "Displacement (m)"
,XRange (0,0.65)
,YRange (-0.01,0.01)
,PNG pngFilePath
,Key Nothing
] xys

main :: IO ()
main = sequence_ $ map makePNG $ zip [0..999] $
iterate (stringUpdate 25e-6) (stringInitialOvertone 3)


*Listing 20-5:用于二维异步动画的独立程序,模拟一个柔性弦*

函数`makePNG`以一个整数和一个多粒子状态为输入,生成一个显示弦位置的 PNG 文件。整数的目的是标记输出文件;0 生成文件*GnuplotWave000.png*,8 生成文件*GnuplotWave008.png*,167 生成文件*Gnuplot Wave167.png*。此函数只能使用 0 到 999 之间的整数(包括 0 和 999)。

该函数首先将输入的整数`n`命名,并通过模式匹配输入的单粒子状态列表`sts`。然后,函数在`let`构造体中定义了几个本地变量。本地变量`rs`是一个包含 66 个位置向量的列表,用于描述字符串的位置。该列表包含 64 个小质量点的位置,并通过在字符串两端加上固定位置来扩展。局部函数`xy`从一个位置生成(x, y)对。通过将`xy`映射到`rs`的位置列表上生成的列表`xys`,是我们要求 gnuplot 绘制的点对列表。

本地字符串`threeDigitString`是一个基于输入的整数`n`生成的三位数字符串。该函数通过使用`show`将`n`转换为字符串,前置零填充该字符串,然后取最后三位数字。我们通过反转字符串,使用`take 3`取出前三个数字,然后再反转回原来的顺序来获取最后三位数字。然后,`threeDigitString`作为文件名`pngFilePath`的一部分。 在`let`构造体的主体中,我们使用 gnuplot 的`plotPath`函数绘制我们之前定义的`xys`点对。由于我们打算动画化 gnuplot 生成的帧,因此必须指定`XRange`和`YRange`属性,以确保每一帧都有相同的范围。

让我们将注意力转向`main`函数。我们想要对 1,000 个由整数和多粒子状态组成的元组应用`makePNG`函数。在像 Python 这样的命令式语言中,这是使用循环的一个机会。在像 Haskell 这样的函数式语言中,这是使用列表的一个机会。`main`函数由几个通过函数应用操作符`$`分隔的短语组成。由于这个操作符是右结合的(回想一下表 1-2),因此从右到左阅读`main`的定义是最容易的。最右侧的短语,

iterate (stringUpdate 25e-6) (stringInitialOvertone 3)


这是一个无限的多粒子状态列表,从三次谐波数 3 开始,间隔为 25*μ*s。对这个无限列表应用`zip [0..999]`会产生一个有限的列表,其中每个元素是一个整数和一个多粒子状态的元组。对这个元组列表应用`map makePNG`会生成一个长度为 1,000 的列表,其类型为`[IO ()]`。这不是我们希望`main`函数具有的类型。我们希望`main`的类型是`IO ()`,这意味着它只是执行一些操作。Haskell 提供了一个`sequence_`函数,可以将一个操作列表转换为一个单独的操作。

这里是`sequence_`的类型:

*MOExamples> :t sequence_
sequence_ :: (Foldable t, Monad m) => t (m a) -> m ()


我们在一个上下文中使用`sequence_`,其中`Foldable`是一个列表,`Monad`是`IO`,而类型变量`a`是单位类型,因此在我们使用中的`sequence_`的具体类型是

sequence_ :: [IO ()] -> IO ()


这正是我们需要的来为`main`生成正确的类型。函数`sequence_`通过将一系列动作按顺序组合成一个单一的动作。以下命令要求外部程序 ffmpeg 将所有名为*GnuplotWaveDDD.png*的 PNG 文件合并,其中大写的 D 代表数字。我们要求帧率为每秒 40 帧。最终的视频文件名为*GnuplotWave.mp4*。

$ ffmpeg -framerate 40 -i GnuplotWave%03d.png GnuplotWave.mp4


请注意,百分号后面的字符是零,而不是字母 O。如果你使用的是类 Unix 系统,可以在安装 ffmpeg 后通过命令`man ffmpeg`查阅相关文档。

在进行异步动画时,我们指定时间步长和动画速率,而不是时间尺度因子和动画速率。

### 总结

在本章中,我们研究了涉及多个相互作用粒子的三种物理情况,并应用了第十九章的理论和概念。第一种情况,包含两个质量和两个弹簧,涉及两个粒子以及内部和外部力。

我们的第二种情况,碰撞,仅涉及内部力,因此系统的动量得以守恒。我们在进行近似数值计算时,探讨了动量和能量的守恒,发现无论时间步长多大,动量始终是守恒的。碰撞也为我们提供了一个更深入探讨影响技术参数选择的数值问题的机会,例如弹簧常数和时间步长。

我们的第三种情况,吉他弦,涉及多个粒子,并且暗示了向场和波动的过渡。一直以来,我们通过离散化时间来实现力学问题的实际结果;在这里,通过使用多个粒子来模拟弦,我们已接近于离散化空间,这与数值求解场方程(如麦克斯韦方程)的方法类似。

本书的第二部分已涉及牛顿力学。从一个在一维中运动的单粒子开始,我们逐步引入了处理越来越复杂力学情境的思想和代码。我们讨论了哪些力学问题可以通过代数解决,哪些需要微积分中的积分,哪些需要微分方程。我们开发了一些通用的方法来求解微分方程系统,并将它们应用于力学的服务中。我们利用 Haskell 的类型系统,创建了简单的数据结构,如 3D 向量,用来构建从问题规格到问题解的多种信息表示方式,并创建了一个模块化系统,便于在其中切换数值方法。我们将牛顿第二定律视为根据力的列表构建微分方程的规则。我们将牛顿第三定律内置到粒子相互作用的基础设施中,使其自动应用于多粒子情境中的所有内力。我希望我已经说服你,函数式语言是表达力学思想以及解决力学问题所需思想的一个富有成效的方式。

本书的下一部分探讨了电磁理论。它从关于库仑定律的一章开始,这一内容很好地融入了我们已经建立的相互作用粒子的框架中。库仑定律就像牛顿的万有引力定律一样,表现为两粒子之间的内力。

### 习题

**习题 20.1.** 函数 `kineticEnergy` 中局部变量 `v` 的类型是什么?函数 `momentum` 中局部变量 `v` 的类型是什么?

**习题 20.2.** 我们编写的 `billiardPicture` 显示函数可以显示任意数量的球,但它们都是蓝色的。如果你希望动画中有不同颜色的台球,你可以修改 `billiardPicture` 函数,使其循环显示一组颜色。

通过从 `billiardPicture` 的副本开始并进行以下更改,创建一个新的函数 `billiardPictureColors`。首先,将局部变量 `blueBall` 替换为一个局部函数 `coloredBall`,该函数以颜色作为输入。接下来,修改局部函数 `place`,使其接受颜色作为第二个参数,并使用新的 `coloredBall` 函数替代 `blueBall`。最后,将列表推导式 `[place st | st <- sts]` 替换为

(zipWith place sts (cycle [G.blue, G.red]))


后者将循环显示蓝色和红色。你可以更改此列表,使其循环显示任意数量的颜色。

修改主台球碰撞程序,使用你新的显示函数`billiardPictureColors`,并检查其是否正常工作。

**习题 20.3。** 以列表 20-3 为起点,为太阳和地球等二体引力系统制作动画。将`billiardForces`替换为一个名为`sunEarthForces`的力列表,包含唯一的力:太阳和地球之间的引力。将`billiardUpdate`替换为一个名为`sunEarthUpdate`的更新函数。将`billiardInitial`替换为一个名为`sunEarthInitial`的初始状态,选择适当的初始位置和速度值。将`billiard`的`Picture`替换为一个名为`sunEarthPicture`的显示函数,使太阳呈黄色,地球呈蓝色。你将无法显示太阳或地球的大小与轨道运动成比例。选择任何方便的物体大小值。选择一个时间尺度因子`365*24*60`,使得一年物理时间为一分钟动画时间。选择一个动画速率为 60 帧/秒。通过观察动画中的地球绕太阳转一圈的时间为一分钟,确认轨道周期大约为一年。

**习题 20.4。** 在这个问题中,我们研究木星如何使太阳产生摆动。我们说木星绕太阳运行,因为太阳的质量远大于木星,但在仅由太阳和木星组成的系统中,两个物体围绕系统的质心旋转。质心是位置的加权平均值。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/401equ01.jpg)

太阳与木星之间的距离约为 8 × 10¹¹米。太阳的半径为 6.96 × 10⁸米。太阳的质量为 1.99 × 10³⁰千克。木星的质量为 1.90 × 10²⁷千克。

在上面的方程中将太阳置于原点,只考虑向量的径向分量,我们发现质心

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/401equ02.jpg)

质心位于太阳半径稍微外面的位置。我们应该能够在动画中看到太阳围绕这个质心旋转。

使用光泽或无光泽效果为太阳-木星系统制作动画。唯一的力是太阳与木星之间的引力。显示太阳的大小与轨道运动成比例,但要放大太阳,忽略木星的显示。如果一切顺利,你应该看到太阳围绕一个稍微偏离其半径的点旋转。

你不需要包括上面提到的质心计算来制作这个动画。然而,为了让质心保持固定,你需要提供初始条件,使得太阳-木星系统的总动量为零。如果木星的初速度在 y 方向,那么太阳需要有一个小的初始速度,在负 y 方向,以确保总动量为 0。

为了获得木星的初速度估算,你可以假设木星的轨道是圆形的,且轨道的半径与太阳-木星的距离相同。(这与实际值相差仅 0.1%。)

**练习 20.5.** 使用现实的初始条件,编写一个关于太阳、地球和月球相互通过重力作用的动画程序。实际的地球与太阳之间的距离大约是地球与月球之间的距离的 500 倍,因此你不能在屏幕上分辨出地球和月球是两个独立的物体。为了能够看到月球相对于地球的位置,建议如下操作:不要直接显示计算出的月球位置,而是显示一个假的月球位置,它的方向正确,但距离地球的距离是你计算出的 50 倍。计算虚拟月球位置的公式如下:

**r**[FM] = **r**[E] + *A*(**r**[M] − **r**[E])

其中 **r**[FM] 是虚拟月球的位置,**r**[M] 是(真实)月球的位置,**r**[E] 是地球的位置,*A* 是一个放大系数,人工放大地球到月球的矢量以便显示。尝试 *A* = 50,看看会发生什么。请注意,虚拟月球只需出现在显示函数中。这种情况应涉及三个内部力和没有外部力。

**练习 20.6.** 使用 `fixedLinearSpring` 函数,研究一个弹性摆。选择弹簧常数 *k*、弹簧的平衡长度 *r[e]* 和质量 *m* 的值。

(a) 通过动画或图形确认,将质量放置在距天花板固定点正下方 *r[e]* + *mg*/*k* 的位置,且不赋予其初始速度,结果会使质量处于一种平衡状态,从而使质量在弹簧上静止悬挂。

(b) 选择一种初始状态,其中质量没有初始速度,并且正好位于弹簧固定点下方 *r[e]* 的位置。在这种情况下,弹簧最初不会对质量产生任何力,但重力会产生一个力。通过动画或图形确认,质量的振荡角频率为 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/402equ01.jpg)。这等效于周期为 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/402equ02.jpg)。

(c) 通过将质量放置在距天花板固定点 *r[e]* + *mg*/*k* 的位置,且没有初始速度,研究摆的水平振荡,但不能直接放置在固定点的正下方。让质量随时间变化。通过动画或图形确认,振荡的角频率接近 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/402equ03.jpg),其中 *l* = *r[e]* + *mg*/*k*,或者等效地,周期为 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/402equ04.jpg)。

(d) 找到一个初始位置和初始速度,使得质量进行水平圆周运动。

(e) 如果现在你改变参数,使得

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/402equ05.jpg)

你可能能够找到一些初始条件,使得质量在垂直平面内进行圆周或椭圆运动。看看这是否可能。

**习题 20.7.** 考虑两颗质量相等的台球,它们以相等且相反的速度相向而行。如果它们直接相向运动,比如沿 x 轴运动,那么碰撞将是单维的。另一方面,如果它们的初速度分别沿 x 轴正向和负向,但它们之间有一个小的 y 位移分量,那么碰撞将是二维的。碰撞后球的运动角度(因为对称性,两颗球的角度相同)依赖于初始的 y 位移分量。存在某个初始 y 位移能产生直角。通过反复试验,找到对于某个弹簧常数和时间步长的这个 y 位移。结果可能不是你预期的。尝试不同的弹簧常数和/或时间步长。你能解释为什么产生直角所需的 y 位移依赖于弹簧常数吗?

**习题 20.8.** 为吉他弦编写一个动画,其中弦用 9 个质量点来建模,而不是 64 个。质量点之间的距离将是 6.5 厘米,而不是 1 厘米。这会将时间步长的稳定性阈值增加到 255*μ*s,因此我们可以使用更大的时间步长。由于我们对计算机的要求降低,因此即使在较旧的硬件上,动画也应该运行流畅。你需要为 `forcesString`、`stringUpdate` 和 `stringInitialOvertone` 编写新的定义。`stringPicture` 函数可以保持不变。弹簧的弹簧常数需要不同。

**习题 20.9.** 为了观察当数值不稳定发生时动画的效果,将吉他弦的光滑动画修改为时间步长为 100*μ*s。你可以通过增加时间缩放因子或减少动画速率来实现这一点。

**习题 20.10.** 编写以下函数:

mpsPos :: MultiParticleState -> IO ()
mpsPos = undefined

mpsVel :: MultiParticleState -> IO ()
mpsVel = undefined


使用 gnuplot 绘制多粒子状态下的 x 和 y 位置分量(对于第一个函数)以及速度分量(对于第二个函数)的图像,就像它是吉他弦一样。这些函数可以帮助调试。你可以用它们来可视化在前几个时间步内发生的情况,这样当事情无法正常工作时,你可以获得一些线索。

**习题 20.11.** 使用 `simulateVis` 为吉他弦编写 3D 动画。

**习题 20.12.** 制作一个 3D 吉他弦动画,使其运动看起来像跳绳。你应该通过改变初始状态来实现这一点,使得构成弦的质量点具有某些初始速度。

**习题 20.13.** 修改 `makePNG` 函数,使其使用四位数字而不是三位数字来标记输出文件。这允许生成最多 10,000 帧的较长动画。通过制作 2,000 帧的动画来测试你的函数。

**习题 20.14.** 编写代码生成类似于 图 20-2 的图表。

**习题 20.15.** 探索吉他弦的能量守恒。机械能应该是守恒的,但其守恒程度取决于时间步长。你需要为该系统的机械能写出一个表达式。

**习题 20.16.** 到目前为止,我们的大部分动画结果都是基于状态更新函数,并且使用了`simulateGloss`或`simulateVis`函数。现在有一种方法可以动画化一系列状态,我们将进行探讨。接下来的`animateGloss`和`animateVis`函数的输入包括时间尺度因子、显示函数和状态列表,并生成动画。动画的时间步长来自状态列表。我们不指定动画速率;它是通过时间尺度因子和时间步长计算出来的。

animateGloss :: HasTime s => R -- time-scale factor
-> (s -> G.Picture)
-> [s]
-> IO ()
animateGloss tsFactor displayFunc mpsts
= let dtp = timeOf (mpsts !! 1) - timeOf (mpsts !! 0)
n tp = round (tp / dtp)
picFromAnimTime :: Float -> G.Picture
picFromAnimTime ta = displayFunc (mpsts !! n (tsFactor * realToFrac ta))
displayMode = G.InWindow "My Window" (1000, 700) (10, 10)
in G.animate displayMode G.black picFromAnimTime

animateVis :: HasTime s => R -- time-scale factor
-> (s -> V.VisObject R)
-> [s]
-> IO ()
animateVis tsFactor displayFunc mpsts
= let dtp = timeOf (mpsts !! 1) - timeOf (mpsts !! 0)
n tp = round (tp / dtp)
picFromAnimTime :: Float -> V.VisObject R
picFromAnimTime ta = displayFunc (mpsts !! n (tsFactor * realToFrac ta))
in V.animate V.defaultOpts (orient . picFromAnimTime)


清单 20-6 是一个使用`animateGloss`进行动画制作的独立程序。

{-# OPTIONS -Wall #-}

import MultipleObjects ( eulerCromerMPS )
import MOExamples
( animateGloss, billiardPicture, billiardStates )

main :: IO ()
main = animateGloss 1 billiardPicture (billiardStates eulerCromerMPS 30 0.01)


*清单 20-6:两个台球碰撞的二维动画的独立程序*

写一个独立程序,使用`animateVis`进行动画制作。

**习题 20.17.** 我们在本章研究的台球碰撞是弹性碰撞。任何仅有`billiardForce`作为唯一作用力的碰撞都必须是弹性碰撞。那么,如何产生非弹性碰撞呢?我们需要某种能够耗散能量的双体力。以下的双体力可以在碰撞中提供耗散:

dissipation :: R -- damping constant
-> R -- threshold center separation
-> TwoBodyForce
dissipation b re st1 st2
= let r1 = posVec st1
r2 = posVec st2
v1 = velocity st1
v2 = velocity st2
r21 = r2 - r1
v21 = v2 - v1
in if magnitude r21 >= re
then zeroV
else (-b) *^ v21


当粒子之间的距离大于或等于阈值分离时,粒子不会感受到耗散力。当粒子之间的距离小于阈值分离时,粒子会感受到一个与相对速度成正比的力,方向上有助于减小相对速度的大小。将这一耗散力与`billiardForce`一同列入系统作用力中,结果将导致非弹性碰撞。

修改清单 20-3 中的独立程序,使其产生非弹性碰撞。4 kg/s 的阻尼常数应产生完全非弹性碰撞。

为了确认此碰撞是非弹性的,绘制机械能随时间变化的图表。由于存在非保守力,机械能不会守恒。机械能在碰撞前应是守恒的,在碰撞过程中急剧下降,并在碰撞后保持在较低的值。


# 第三部分

# 表达电磁理论与解题


# 第二十一章:电学

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

当我们想到电学时,通常会想到它是如何被使用的,比如电流通过电话线或从电池中流出。但所有电气技术的起点都来自一个单一的概念:电荷。电荷与所有电气现象都有关,它是我们讨论的逻辑起点。

因此,我们将从描述电荷开始本章的内容。接着,我们将讨论库仑在 18 世纪末提出的电学理论,这就是库仑定律。最后,我们将运用这一理论来研究两种带电粒子相互排斥的运动。

### 电荷

*电荷*是与粒子或物体相关的一个量,它决定了该粒子或物体是否以及如何参与电学现象。在 1700 年代,人们发现有两种类型的电荷。同种电荷相互排斥,异种电荷相互吸引。后来,当物理学家发现亚原子粒子时,他们决定质子带正电,电子带负电,但这是一个随意的选择,现在大家都遵循这个约定。

电荷的国际单位是库仑(C),以 18 世纪末法国物理学家查尔斯·奥古斯丁·库仑(Charles-Augustin de Coulomb)的名字命名,他在电学方面做出了开创性的工作。表 21-1 列出了质子、电子和中子的电荷。质子的电荷是*精确的* 1.602176634 × 10^(–19) C。如何能精确知道质子的电荷?自 2019 年起,国际单位制*定义*库仑为使得一个*基本电荷*恰好等于 1.602176634 × 10^(–19) C 的电荷量。质子被认为具有一个基本电荷单位,电子则具有负的一个基本电荷单位。通过实验,已知质子和电子的电荷大小相等(但符号相反),误差小于 10^(–18)的一个量级。在方程中,我们用*q*或*Q*来表示电荷。

**表 21-1:** 一些常见粒子的电荷和质量

| **粒子** | **电荷** | **质量** |
| --- | --- | --- |
| 质子 | 1.602 × 10^(–19) C | 1.673 × 10^(–27) kg |
| 中子 | 0 C | 1.675 × 10^(–27) kg |
| 电子 | –1.602 × 10^(–19) C | 9.109 × 10^(–31) kg |

列表 21-1 显示了我们将在本章中开发的`Electricity`模块的代码前几行。我们从第十九章的`MultipleObjects`模块中导入了`TwoBodyForce`和`MultiParticleState`,因为我们将在本章描述的库仑定律是一种二体力。

{-# OPTIONS -Wall #-}

module Electricity where

import SimpleVec
( Vec(..), R, (*^), iHat )
import Mechanics3D
( ParticleState(..), defaultParticleState )
import MultipleObjects
( TwoBodyForce, MultiParticleState(..), Force(..), statesMPS
, eulerCromerMPS, centralForce )
import Graphics.Gnuplot.Simple
( Attribute(..), plotPaths )


*列表 21-1:Electricity 模块的开头代码行*

电荷是标量,不是向量。电荷由一个实数表示。这意味着电荷的类型应该是实数。

type Charge = R


让我们对基本电荷的值进行编码。

elementaryCharge :: Charge
elementaryCharge = 1.602176634e-19 -- in Coulombs


电荷是量子化的——也就是说,它是以离散的粒子形式存在——但这一事实在经典电磁理论中并不起作用。事实上,电荷的量子化颗粒非常小,以至于我们常常将电荷看作更像是一种流体。即使现在这还不完全能理解,也没关系;我们将在第二十四章讨论连续电荷分布。

电荷也是守恒的。如果任何体积内的电荷发生变化,它必须通过体积的边界面流入或流出。

关于电荷,最重要和最有趣的问题并不是它的内在性质,而是带电粒子之间的相互关系和相互作用。电荷是如何相互作用的?

### 库仑定律

查尔斯·奥古斯丁·德·库仑是第一个给出描述两个带电粒子相互作用的定量关系的人。他展示了一个点电荷对另一个点电荷施加的力与每个电荷成正比,与它们之间距离的平方成反比。库仑定律可以写作如下方程:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/411equ01.jpg)

其中 *q*[1] 是粒子 1 的电荷,*q*[2] 是粒子 2 的电荷,*r* 是粒子之间的距离。这个方程给出了粒子 1 对粒子 2 施加的力的大小,这个力根据牛顿第三定律与粒子 2 对粒子 1 施加的力大小相同。力的方向取决于电荷的符号;相同电荷之间是排斥力,不同电荷之间是吸引力。在国际单位制中,常数 *k* 为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/411equ02.jpg)

常数 ϵ[0],称为*真空电容率*、*电常数*或*自由空间的电容率*,充当电学单位(如库仑)和机械单位(如牛顿)之间的一种桥梁。表达式 1/(4*π*ϵ[0]) 常常代替 *k*,因为 *k* 是物理学中使用过多的符号。

下面是将方程 21.1 翻译成 Haskell 的版本:

coulombMagnitude :: Charge -> Charge -> R -> R
coulombMagnitude q1 q2 r
= let k = 9e9 -- in N m² / C²
in k * abs (q1 * q2) / r**2


我们可以使用矢量表示法给出更全面的库仑定律版本,其中包括方程中的力的方向。我们将定义位移向量 **r**[21] 为从粒子 1 指向粒子 2 的矢量,如图 21-1 所示。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/412equ01.jpg)

*图 21-1:位移向量 **r**[21] 从粒子 1 指向粒子 2。*

由粒子 1 对粒子 2 施加的力 **F**[21] 以矢量表示如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/412equ02.jpg)

注意,如果两个电荷都是正的,那么粒子 2 上的力**F**[21]将指向与位移向量**r**[21]相同的方向,即远离粒子 1,这与我们对相同电荷的预期一致。如果电荷符号不同,**F**[21]的方向会发生翻转,表示一种吸引力。

如果**r**[1]是粒子 1 的位置向量,**r**[2]是粒子 2 的位置向量,那么**r**[21] = **r**[2] - **r**[1],我们可以如下表示作用在粒子 2 上的力:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/412equ03.jpg)

总结来说,库仑定律 21.1 较为简化,而库仑定律 21.2 和 21.3 则更为强大,因为力的方向已被编码到方程中。

这种库仑相互作用是我们在第十九章中讨论的`TwoBodyForce`类型。这里是 Haskell 中的方程 21.3:

coulombForce :: TwoBodyForce
coulombForce st1 st2
= let k = 9e9 -- N m² / C²
q1 = charge st1
q2 = charge st2
in centralForce (\r -> k * q1 * q2 / r**2) st1 st2


库仑力是另一个中心力的例子,因此这里我们使用在第十九章中定义的`centralForce`函数。

将库仑定律编码为双体力后,我们将其应用于两个质子相互排斥的情况。

### 两个电荷相互作用

假设我们释放两个质子,初始距离为 1 厘米。它们将在五毫秒内移动多远?这是一个适合我们在前几章中开发的工具的问题,特别是第十九章。这个问题不能仅通过代数来解决,因为随着粒子相互远离,力会减弱。粒子从静止开始,彼此加速,而这种加速度随着排斥力的减弱而减小。当两个质子相距较远时,力减小到可以忽略不计的程度,质子接近终极速度。

#### 极限情况分析

在我们应用第十九章中的多粒子工具之前,让我们先通过思考两个极限情况来了解这个问题:一开始发生了什么,经过很长时间后又会发生什么。

对于非常短的时间,在粒子移动不大之前,我们可以将初始加速度近似为常数。我们可以通过将净力除以质子质量来获得其中一个质子的初始加速度。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ01.jpg)

其中,*q[p]*是质子的电荷,*d*是 1 厘米,*m[p]*是质子的质量:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ02.jpg)

将此加速度视为常数时,一个质子的速度和位置为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ03.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ04.jpg)

通过常加速度方程 4.14 和 4.15 获得的这些方程在短时间内是一个很好的近似,但将其延长得太久就显得过于雄心勃勃,且结果不佳。我们将这种近似称为“常加速度近似”。

在质子运动了一段时间后,粒子将接近终端速度。我们可以通过能量守恒来找到这个终端速度。两电荷*q*[1]和*q*[2]之间相距*d*时的电势能为*kq*[1]*q*[2]/*d*,因此两个质子相距*d*时的电势能为![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ05.jpg)。两个质子的初始电势能转换为动能。质量为*m*,以速度*v*运动的粒子的动能为*mv*²/2。两个质子将接近相同的终端速度*υ[T]*,所以能量守恒导致以下方程:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ06.jpg)

每个质子的终端速度通过能量守恒给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/413equ07.jpg)

对于非常长的时间,我们可以将终端速度视为常数,因此一个质子的速度和位置为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/414equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/414equ02.jpg)

其中*x*[1]是某个尚未确定的距离。当*t*非常大时,这些方程是一个很好的近似,但在较短的时间内使用它们会得到不准确的结果。我们将这种近似称为“终端速度近似”。

让我们总结一下从短时间极限和长时间极限中学到的内容。当从静止释放时,每个质子都会经历 1379 m/s²的加速度,远离另一个质子。随着质子之间的距离增大,加速度会减小,直到加速度变得微不足道,质子获得终端速度 3.71 m/s。如果我们将质子速度随时间变化绘制成图,速度将从 0 开始,并以 1379 m/s²的斜率增加。随着时间的推移,速度增加,渐近地接近终端速度 3.71 m/s。

#### 在 Haskell 中建模情形

现在我们已经对预期的结果有了基本的了解,让我们应用在第十九章中为多粒子情形开发的工具。我们需要包括的唯一力是质子之间库伦相互作用的内力。

通过使用来自第十九章的`statesMPS`,我们可以形成一个无限的多粒子状态列表。

twoProtonStates :: R -- time step
-> MultiParticleState -- initial 2-particle state
-> [MultiParticleState] -- infinite list of states
twoProtonStates dt
= statesMPS (eulerCromerMPS dt) [InternalForce 1 0 coulombForce]


我们提供此功能时需要给定时间步长和初始的两粒子状态,它将返回一个无限的两粒子状态列表,我们可以从中提取任何我们想要的信息。

这是一个函数,它设置了一个初始状态,其中两个质子静止,且初始分离距离作为函数参数给定。该函数中的原点位于两个质子之间的中点。质子质量来自表 21-1。

-- protons are released from rest
initialTwoProtonState :: R -- initial separation
-> MultiParticleState
initialTwoProtonState d
= let protonMass = 1.673e-27 -- in kg
in MPS [defaultParticleState { mass = protonMass
, charge = elementaryCharge
, posVec = (-d/2) *^ iHat
}
,defaultParticleState { mass = protonMass
, charge = elementaryCharge
, posVec = ( d/2) *^ iHat
}
]


让我们从绘制质子速度随时间变化的图表开始。函数`oneProtonVelocity`返回一个无限的时间-速度对列表。

oneProtonVelocity :: R -- dt
-> R -- starting separation
-> [(R,R)] -- (time,velocity) pairs
oneProtonVelocity dt d
= let state0 = initialTwoProtonState d
in [(time st2, xComp $ velocity st2)
| MPS [_,st2] <- twoProtonStates dt state0]


我们通过列表推导式构建列表,并在列表推导式中使用模式匹配,为第二个质子的状态命名为`st2`。我们选择第二个质子而不是第一个质子,因为根据我们初始状态的分析,第二个质子将具有正的速度分量,而第一个质子将具有负的速度分量。最后,我们使用`time`、`velocity`和`xComp`函数来提取我们想要绘制的值。

确定使用哪个时间步长并不那么明显。让我们尝试使用维度分析,结合这个问题的参数来估算一个特征时间尺度。这个问题的相关参数包括质子电荷*q[p]*、质子质量*m[p]*、电常数*k*和距离*d*(1 厘米)。我们能否将这些参数结合起来得到一个具有时间维度的量?可以的。这个问题的特征时间尺度由以下公式给出

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/415equ01.jpg)

我们将使用时间步长为 10^(–5)秒,这与我们刚刚找到的特征时间尺度相比是很小的。

以下是时间-速度对的列表,这是我们将绘制的有限结果。

tvPairs :: [(R,R)]
tvPairs = takeWhile ((t,_) -> t <= 2e-2) $
oneProtonVelocity 1e-5 1e-2


我们将时间步长`1e-5`(10^(–5)秒)和初始质子间距`1e-2`(1 厘米)传递给函数`oneProtonVelocity`,从而获得一个无限状态列表。然后,我们将这个无限列表截断为一个有限的状态列表,表示直到 20 毫秒的时间范围内的状态。

图 21-2 展示了质子的速度随时间变化的图像。图中的直线表示常数加速度近似和终端速度近似。计算得到的速度平滑地从初期的线性增加(初始加速度)过渡到后期接近终端速度。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/416fig01.jpg)

*图 21-2:两个质子相互排斥。曲线表示质子的速度随时间的变化。水平线是终端速度,斜线表示质子初始加速度。*

下面是生成图 21-2 图像的代码。

velocityPlot :: IO ()
velocityPlot
= plotPaths [Title "Two protons released from 1 cm"
,XLabel "Time (s)"
,YLabel "Proton velocity (m/s)"
,PNG "protons.png"
,Key Nothing
] $ [tvPairs
,[(t,1379*t) | t <- [0,1e-5..4e-3]]
,[(t,3.71) | t <- [0,1e-3..2e-2]]]


由于我们创建了有限的具体列表`tvPairs`来保存数据,绘图代码主要是使用`plotPaths`函数。通过列表推导式构建时间-速度对来绘制这两种近似曲线。`1379`是质子的初始加速度,单位为米每秒平方(m/s²),而`3.71`是质子的终端速度,单位为米每秒(m/s)。

我们最初的问题是问质子在 5 毫秒内能走多远。让我们绘制质子的位置与时间的关系图,然后直接回答最初的问题。图 21-3 展示了质子位置随时间变化的图像。它还展示了常数加速度近似,这就是图中左边的抛物线,在大约 2 毫秒的时间范围内,它给出了较好的结果。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/417fig01.jpg)

*图 21-3:两个质子相互排斥。一个质子的位置随时间变化的曲线随着时间推移趋于线性。为了比较,抛物线图给出了如果质子最初经历的加速度保持恒定时的位置变化。*

最后,我们要求 GHCi 给出 5 毫秒时质子的位置信息。由于 `initialTwoProtonState 0.01` 是一个初始的两粒子状态,质子之间相距 1 厘米,而 `twoProtonStates 1e-5 (initialTwoProtonState 0.01)` 是一个由时间步长为 10^(-5) 秒构成的无限长两粒子状态列表,因此 5 毫秒对应列表中的第 500 个时间步长。我们可以如下请求所需的信息:

Prelude> :l Electricity
[1 of 6] Compiling Newton2 ( Newton2.hs, interpreted )
[2 of 6] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[3 of 6] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[4 of 6] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[5 of 6] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[6 of 6] Compiling Electricity ( Electricity.hs, interpreted )
Ok, six modules loaded.
*Electricity> twoProtonStates 1e-5 (initialTwoProtonState 0.01) !! 500
MPS {particleStates =
[ParticleState {mass = 1.673e-27,
charge = 1.602176634e-19,
time = 4.9999999999999645e-3,
posVec = vec (-1.550866906307423e-2) 0.0 0.0,
velocity = vec (-3.0582222353914252) 0.0 0.0},
ParticleState {mass = 1.673e-27,
charge = 1.602176634e-19,
time = 4.9999999999999645e-3,
posVec = vec 1.550866906307423e-2 0.0 0.0,
velocity = vec 3.0582222353914252 0.0 0.0}]}


GHCi 返回 5 毫秒时的两粒子状态。我已经将输出格式化以便于阅读。质子沿 x 轴的位置分别为 -1.55 厘米和 1.55 厘米,因此它们在 5 毫秒时相距 3.1 厘米。

### 总结

我们已经概述了 18 世纪的电学理论,这些理论在粒子运动速度相较于光速较慢且没有经历极端加速度的情况下表现良好。库仑的 18 世纪理论仍然是一个有效的静电学理论,也叫做*静电学*。库仑定律是一个双体力学定律,类似于牛顿的万有引力定律。库仑定律旨在应用于我们在第二部分中学习的多粒子牛顿力学的背景下。一个例子是两个质子相互排斥,这个问题虽然陈述起来很简单,但无法通过简单的代数方法解决,而是需要我们已经发展出的思想和工具。

在 19 世纪,迈克尔·法拉第发现了一种电学现象,它不是由电荷直接引起的。这导致了电场和磁场的概念,在现代法拉第-麦克斯韦电磁理论中,电场和磁场是电荷的媒介。这个更新的理论是*电动力学*理论,即使在电荷快速移动并强烈加速的情况下,它也能做出良好的预测。由于这一新理论是场论,意味着作用者是场而非粒子,并且由于物理学中的场是三维空间或时空的一个函数,因此我们将在接下来的两章中研究三维空间的坐标系统和几何学。

### 练习

**练习 21.1.** 绘制一个类似于图 21-2 的图,表示从静止状态释放的两个电子,且它们的初始间距为 1 厘米。在这种情况下,终极速度和特征时间尺度是多少?

**练习 21.2.** 库仑电理论预测电子可以像地球绕太阳公转那样绕质子公转。我们可以称之为“经典氢原子”。(现代法拉第-麦克斯韦电磁理论在本书的后面部分将介绍,这对这种模型提出了问题,因为加速的带电粒子会辐射,这使得经典氢原子不稳定。)编写一个经典氢原子的动画,其中库仑力是质子和电子之间唯一的内部力,并且没有外部力。你需要为质子和电子选择一些初始条件。

**练习 21.3.** 考虑一个从静止状态释放的质子和电子。编写一个函数来计算直到碰撞所需的时间,给定初始的分离距离。它们应该最初相距多远,才能使它们在一秒钟内发生碰撞?

**练习 21.4.** 使用带光泽或不带光泽的方式动画化两个质子之间的排斥。

**练习 21.5.** 编写代码以生成图 21-3 中的图形。以下是你可以使用的起始代码(如果需要的话):

oneProtonPosition :: R -- dt
-> R -- starting separation
-> [(R,R)] -- (time,position) pairs
oneProtonPosition dt d
= undefined dt d

positionPlot :: IO ()
positionPlot = plotPaths [Title "Two protons released from 1 cm"
,XLabel "Time (s)"
,YLabel "Proton position (m)"
,PNG "ProtonPosition.png"
,Key Nothing
] $ [undefined $ oneProtonPosition 1e-5 1e-2
,undefined :: [(R,R)]]


**练习 21.6.** 通过试验和错误,找到方程 21.7 中 *x*[1] 的一个值,使得图 21-3 中的位置-时间曲线对于较大的时间在与方程 21.7 的直线接近时呈渐近状态。


# 第二十二章:坐标系与场

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

在本章中,我们将开始探索法拉第和麦克斯韦的电磁理论,该理论通过引入场的概念突破了库仑基于粒子的观点。法拉第-麦克斯韦理论是我们用来解释电学、磁学和光学现象的最佳理论。作为场理论,法拉第-麦克斯韦理论支持了相对论的局域性思想,早在爱因斯坦写出相对论之前的 40 年,就为其他场理论(如广义相对论)提供了灵感,并成为现代粒子物理学规范场理论的原型。如今,场的概念在物理学的许多领域中扮演着重要角色,例如连续介质力学、流体动力学和量子场论。场作为一个函数这一特性,是功能性编程在物理学中如此有效的原因之一。

在四维时空中阐述电磁理论是可能的,并且是优雅的(像 Haskell 这样的函数式语言特别适合这个任务),但我们将遵循更常见的做法,使用三维表示法,因为对四维相对论时空语言的物理见解和几何见解需要一些时间才能掌握。因此,本章描述了三维空间的坐标系,定义了三维空间中位置的数据类型,并引入了*场*的概念,场是一个函数,其输入是三维空间中的位置。

我们首先通过观察极坐标来获得一些见解,极坐标是二维平面中仅次于笛卡尔坐标的最常见坐标系。接着,我们将研究圆柱坐标和球坐标,这两种是三维空间中仅次于笛卡尔坐标的最常见坐标系。我们将为三维空间中的位置创建一种新的数据类型,该数据类型能够适应笛卡尔坐标、圆柱坐标、球坐标以及我们可能想使用的其他坐标系。我们将引入标量场和向量场及其数据类型,以便我们拥有基本的数学框架来讨论诸如电荷密度(标量场)和电场(向量场)之类的概念。

### 极坐标

极坐标是一种将两个数字赋予平面上每个点的方式,其中一个数字是从原点到该点的距离。极坐标在平面上以某点为中心的旋转对称性情况下是一个自然的选择,尽管它们的使用不必仅限于这种情况。我们将使用变量 *s* 和 *ϕ* 来表示极坐标。*s* 和 *ϕ* 这两个名称来自 Griffiths 的电动力学教材 **[19**]。

笛卡尔坐标 *x* 和 *y* 与极坐标 *s* 和 *ϕ* 通过以下方程相关:

*x* = *s* cos *ϕ*

*y* = *s* sin *ϕ*

坐标 *s* 是从原点到平面中某点的距离,坐标 *ϕ* 是 x 轴与从原点到某点的连线之间的角度(见 图 22-1)。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/423fig01.jpg)

*图 22-1:极坐标*

在 图 22-1 中,我们还介绍了极坐标单位向量。单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 指向远离原点的方向。(这是平面上除原点外每一点的明确方向。)等效地,单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 指向使得 *ϕ* 保持恒定并且 *s* 增加的方向。类似地,单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg) 指向使得 *s* 保持恒定并且 *ϕ* 增加的方向。我们可以用笛卡尔坐标单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg) 来表示极坐标单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg),如下所示:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/423equ01.jpg)

与笛卡尔单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg) 不同,极坐标单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg) 在平面中不同点指向不同的方向。稍后本章的 图 22-5 右侧的图片显示了单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg) 在 xy 平面上不同点的方向,你可以看到它的方向是如何变化的。

二维极坐标的定义使得在三维中定义圆柱坐标变得简单,接下来我们将讨论这一内容。

### 圆柱坐标

圆柱坐标是极坐标在三维空间中的扩展,是描述绕某轴旋转和移动对称性的自然坐标选择。我们可以使用圆柱坐标 *s*、*ϕ* 和 *z* 来表示三维空间中某点的位置,如 图 22-2 所示。坐标 *s* 是从 z 轴到空间中点的距离,坐标 *ϕ* 是 xz 平面与包含 z 轴和该点的平面之间的角度,坐标 *z* 和笛卡尔坐标中的含义相同:即距离 xy 平面的距离。圆柱坐标与极坐标密切相关,因为圆柱坐标以极坐标方式描述 xy 平面,但继续使用笛卡尔坐标的 z 坐标。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/424fig01.jpg)

*图 22-2:圆柱坐标*

笛卡尔坐标 *x*、*y* 和 *z* 与圆柱坐标 *s*、*ϕ* 和 *z* 之间的关系如下方程所示:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/424equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/424equ02.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/424equ03.jpg)

图 22-2 中还展示了柱坐标单位向量。单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 指向远离 z 轴的方向。(在空间中除了 z 轴上的点外,这在每个点上都是一个明确的方向。)等效地,单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 指向的是 *ϕ* 和 *z* 保持不变,*s* 增加的方向。单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg) 指向的是 *s* 和 *z* 保持不变,*ϕ* 增加的方向。最后,单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/zcap.jpg) 指向的是 *s* 和 *ϕ* 保持不变,*z* 增加的方向。

我们可以将柱坐标单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/zcap.jpg) 用笛卡尔坐标单位向量 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/zcap.jpg) 来表示,如下所示:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/425equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/425equ02.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/425equ03.jpg)

现在我们已经讨论了柱坐标系统,并展示了它作为描述三维空间中点的笛卡尔坐标的替代方式,接下来我们将讨论另一个三维坐标系统。

### 球坐标

在具有关于空间中某一点的旋转对称性的情况下,球坐标是一个自然的选择。但像本章中描述的所有三维坐标系统一样,它们也是一个通用的坐标系统,能够描述三维空间中任意位置。我们可以使用球坐标 *r*、*θ* 和 *ϕ* 来表示空间中某点的位置,如图 22-3 所示。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/425fig01.jpg)

*图 22-3:球坐标*

坐标 *r* 是从原点到空间中某点的距离,坐标 *θ* 是 z 轴与从原点到该点的连线之间的夹角,坐标 *ϕ* 是 xz 平面与包含 z 轴和该点的平面之间的夹角。(在球坐标中,坐标 *ϕ* 的含义与在柱坐标中相同。)

笛卡尔坐标 *x*、*y* 和 *z* 与球坐标 *r*、*θ* 和 *ϕ* 之间的关系如下所示:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ02.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ03.jpg)

在图 22-3 中,还展示了球坐标单位向量。单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)指向远离原点的方向。(这是空间中除了原点本身外,每个点的一个明确定义的方向。)等效地,单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)指向*θ*和*ϕ*保持不变而*r*增加的方向。单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)指向*r*和*ϕ*保持不变而*θ*增加的方向。最后,单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)指向*r*和*θ*保持不变而*ϕ*增加的方向。

要将![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)表示为笛卡尔单位向量,我们将位置向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ04.jpg)除以它的大小![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ05.jpg)。![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)的表达式与圆柱坐标下的表达式相同。![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)的表达式可以通过![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ09.jpg)得到。我们可以将球坐标单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)表示为笛卡尔坐标单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/zcap.jpg),表达式如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ06.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ07.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/426equ08.jpg)

现在我们已经完成了对球坐标以及我们打算使用的所有坐标系统的介绍,接下来的任务是为三维空间中的位置定义一个新类型,它将与所有三维坐标系统兼容。然而,在此之前,我们先为本章编写一些引导代码。

### 引入代码

Listing 22-1 展示了我们将在本章中开发的`CoordinateSystems`模块的第一行代码。

{-# OPTIONS -Wall #-}

module CoordinateSystems where

import SimpleVec
( R, Vec, (^/), vec, xComp, yComp, zComp, iHat, jHat, kHat
, magnitude, sumV, zeroV )
import Mechanics3D ( orient, v3FromVec )
import MOExamples ( Table(..), Justification(..) )
import qualified Vis as V
import SpatialMath ( V3(..) )
import Diagrams.Prelude
( Diagram, V2(..), PolyType(..), PolyOrientation(..), PolygonOpts(..)
, (#), (@@), dims, p2, r2, arrowAt, position, fc, black, white
, blend, none, lw, rotate, deg, rad, scale, polygon, sinA )
import Diagrams.Backend.Cairo ( B, renderCairo )


*Listing 22-1: 坐标系统模块的开头代码行*

在这里,我们导入了之前在`SimpleVec`、`Mechanics3D`和`MOExamples`模块中编写的函数和类型。我们将使用`Vis`模块来可视化标量场和向量场,并且由于`Vis`模块的本地向量类型是`V3`类型,所以我们将使用`SpatialMath`中的`V3`类型。`Diagrams`的`.Prelude`和`Diagrams.Backend.Cairo`模块是图表包的一部分,我们将使用它们来进行向量场的可视化。附录中包含了关于如何安装图表包的信息。

### 位置的类型

我们希望有一个 Haskell 类型来描述空间中某一点的位置。我们还希望能够指定三维空间中的点,可以使用笛卡尔、圆柱或球坐标,并能访问以前在任何坐标系统中定义的位置,包括与定义该位置的坐标系统不同的系统。

#### 定义新类型

我们如何使用 Haskell 来描述空间中的一个点?我们有三种选择。选项 A 是使用一个笛卡尔坐标三元组 `(R,R,R)`。这对于许多目的来说是可行的。它的优点是简单,但缺点是我们已经知道,我们有兴趣使用圆柱坐标和球坐标,它们也是由三个数字组成的三元组。这使得我们容易误将笛卡尔坐标(*x*,*y*,*z*)三元组误认作球坐标(*r*,*θ*,*ϕ*)三元组。编译器可以帮助我们避免这个错误,但前提是我们能智能地利用类型系统。选项 A 可行但有风险。我们可以更好地利用计算机来帮助我们避免错误。

选项 B 是使用 `Vec` 类型来表示位置,正如我们在力学中所做的那样。`Vec` 类型显然具有笛卡尔坐标分量,因此与选项 A 相比,它更难产生混淆。如果我们在以前编写的代码中遇到三元组 `(R,R,R)`,该类型并未告诉我们它是笛卡尔坐标三元组还是球坐标三元组。另一方面,如果我们遇到一个 `Vec`,我们知道它在底层是一个笛卡尔坐标三元组。选项 B 是可行的。选项 B 的一个缺点是,位置并不是真正的向量,因为向量根据定义是可以相加的,而位置不能进行相加。如果我们把位置当作向量来看,它就变成了一个从某个固定原点出发的向量。但加法运算是将向量首尾相接,而对于位置“向量”来说,它的尾部是固定在原点的,这种加法并不合适。使用 `Vec` 来表示位置(选项 B)的另一个缺点是,Haskell 类型系统无法帮助我们区分位置和其他 `Vec`(如速度、加速度或动量)。

选项 C 是使用 Haskell 的功能自己创建一个全新的数据类型,这样就不会与其他数据类型混淆。虽然这不是最简单的选项,但它将使我们能够处理我们感兴趣的三种坐标系,并且它的优势是编译器将不会允许我们将位置与速度混淆。我们将选择选项 C。

我们将使用 `data` 关键字在 Haskell 中构建一个新类型。

data Position = Cart R R R
deriving (Show)


紧跟在 `data` 关键字后面的 `Position` 是我们为新类型所取的名称。等号右边的 `Cart` 是该类型的唯一数据构造函数,它的命名是为了提醒我们无论某个特定的 `Position` 是在哪个坐标系中定义或使用的,我们始终在笛卡尔坐标系中存储位置数据。

使用新的 `Position` 数据类型,我们有一种方式来存储三个数字,编译器不会将其与任何其他存储三个数字的方式(如 `Vec`)混淆。但 `Position` 的真正优势在于,我们现在可以定义三种方式来*创建*一个 `Position`(每个坐标系一种),以及三种方式来*使用*一个 `Position`(同样,每个坐标系一种)。

#### 创建一个 Position

在本章开始时,我们展示了如何使用笛卡尔坐标、圆柱坐标和球面坐标来描述空间中的位置。每个坐标系统使用三个数字来指定一个位置。一个坐标系统是从三个实数到空间的函数。

type CoordinateSystem = (R,R,R) -> Position


以下是三种坐标系统的定义。对于笛卡尔坐标,我们只需将坐标附加到数据构造器`Cart`后面。对于圆柱坐标(*s*,*ϕ*,*z*),我们使用方程 22.1 和 22.2 将其转换为笛卡尔坐标,然后将笛卡尔坐标值传递给`Cart`构造器。对于球面坐标(*r*,*θ*,*ϕ*),我们同样使用方程 22.7、22.8 和 22.9 将其转换为笛卡尔坐标,然后应用数据构造器来转换后的笛卡尔值。

cartesian :: CoordinateSystem
cartesian (x,y,z)
= Cart x y z

cylindrical :: CoordinateSystem
cylindrical (s,phi,z)
= Cart (s * cos phi) (s * sin phi) z

spherical :: CoordinateSystem
spherical (r,theta,phi)
= Cart (r * sin theta * cos phi)
(r * sin theta * sin phi)
(r * cos theta)


`笛卡尔坐标`、`圆柱坐标`和`球面坐标`是我们表示`位置`的三种方式。在讨论如何使用`位置`之前,我们将定义三个与笛卡尔坐标、圆柱坐标和球面坐标几乎相同的辅助函数。这三个函数分别叫做`cart`、`cyl`和`sph`,它们的唯一区别是,它们以柯里化的方式接收参数,一个接一个,而不是作为三元组。它们是非常方便的辅助函数。

cart :: R -- x coordinate
-> R -- y coordinate
-> R -- z coordinate
-> Position
cart = Cart

cyl :: R -- s coordinate
-> R -- phi coordinate
-> R -- z coordinate
-> Position
cyl s phi z = cylindrical (s,phi,z)

sph :: R -- r coordinate
-> R -- theta coordinate
-> R -- phi coordinate
-> Position
sph r theta phi = spherical (r,theta,phi)


`cart`函数是一个辅助函数,它接收三个数字(*x*,*y*,*z*),并使用笛卡尔坐标形成适当的位置。`cart`的定义采用了点自由风格,这意味着我们省略了参数,因为它们在方程的两边是相同的。

`cyl`函数是一个辅助函数,它接收三个数字(*s*,*ϕ*,*z*),并使用圆柱坐标形成适当的位置。我们只是调用`cylindrical`函数来执行实际的工作。`sph`函数是一个辅助函数,它接收三个数字(*r*,*θ*,*ϕ*),并使用球面坐标形成适当的位置。

让我们使用`cart`函数来定义`origin`,即所有三个笛卡尔坐标都为 0 的位置。

origin :: Position
origin = cart 0 0 0


#### 使用位置

我们之前提到过,我们希望能够查看一个现有的`位置`,无论它是通过何种坐标系统定义的,都能以笛卡尔坐标、圆柱坐标或球面坐标的形式展示。以下三个函数展示了如何*使用*一个位置来获得所需坐标系统下的三元组:

cartesianCoordinates :: Position -> (R,R,R)
cartesianCoordinates (Cart x y z) = (x,y,z)

cylindricalCoordinates :: Position -> (R,R,R)
cylindricalCoordinates (Cart x y z) = (s,phi,z)
where
s = sqrt(x2 + y2)
phi = atan2 y x

sphericalCoordinates :: Position -> (R,R,R)
sphericalCoordinates (Cart x y z) = (r,theta,phi)
where
r = sqrt(x2 + y2 + z2)
theta = atan2 s z
s = sqrt(x
2 + y**2)
phi = atan2 y x


这三个函数的数学内容只是将笛卡尔坐标转换为三种坐标系中的任意一种。然而,这些函数的价值在于它们的类型。它们允许我们在任何坐标系统中表示一个`Position`,并给出坐标的数值,这样就可以用于其他操作。`Position`数据类型的价值在于它将特定坐标系抽象出来,使我们可以在不混淆三个数字可能意味着什么的情况下,使用任何坐标系。实际上,我们会尽可能保持我们的`Position`类型,只有在需要访问特定坐标值时才会转换到某个特定的坐标系。

在物理学中,位置和位移都具有长度的维度,且其国际单位制单位是米。下一节将努力阐明位置和位移之间的关系。

### 位移

*位移*是一个指向目标位置的向量,起点为源位置。我们之前已经讨论过,物理学中的位置并不是真正的向量。物理学家使用*位移*这个术语来指代具有长度维度的向量。

对于这些具有长度维度的向量,想要定义一个类型`Displacement`是非常有用且自然的。像往常一样,我们可以选择使用`data`关键字创建一个全新的类型,或者仅使用`type`关键字创建一个类型别名。前者可以防止我们将位移与其他向量混淆,但代价是引入了一个新的数据构造器,而后者虽然方便,但没有提供这种保护。我们选择后者,将`Displacement`定义为`Vec`的类型别名。

type Displacement = Vec


位移函数允许我们“减去”位置(回想一下,我们不能相加位置)来得到一个向量。

displacement :: Position -- source position
-> Position -- target position
-> Displacement
displacement (Cart x' y' z') (Cart x y z)
= vec (x-x') (y-y') (z-z')


由于位移向量是从源位置指向目标位置,因此我们通过将目标坐标减去源坐标来计算位移。

`shiftPosition`函数允许我们向一个位置添加一个位移,从而得到一个新位置。

shiftPosition :: Displacement -> Position -> Position
shiftPosition v (Cart x y z)
= Cart (x + xComp v) (y + yComp v) (z + zComp v)


我们将在下一章中使用`shiftPosition`函数来定义一些几何对象。

在介绍了坐标系、位置类型以及位置与位移的区别之后,我们现在转向本章的最后一个主要概念——场。

### 标量场

一些物理量,如体积电荷密度和电势,最好通过为空间中的每个点赋一个数值来描述。这些物理量称为*标量场*。在物理学中,*场*指的是物理空间或时空的函数;换句话说,场是一个可以在空间中的每个点取不同值的东西。(在数学中,*场*的意思不同。)标量场是一个在空间中的每个点分配一个标量值(即一个数字)的场。温度是标量场的另一个例子。例如,安维尔(宾夕法尼亚州)和维罗海滩(佛罗里达州)的温度通常是不同的。

由于标量场将一个数值与空间中的每个位置关联,因此定义标量场类型为从空间到数字的函数是有意义的。

type ScalarField = Position -> R


当我们使用坐标系统时,可以为每个坐标定义标量场。例如,我们可以有一个标量场,将空间中的每个位置与其 x 坐标值关联起来。

xSF :: ScalarField
xSF p = x
where
(x,,) = cartesianCoordinates p


这里是与球坐标 *r* 关联的坐标标量场:

rSF :: ScalarField
rSF p = r
where
(r,,) = sphericalCoordinates p


在第九章,我们定义了从三元组中提取分量的函数:

fst3 :: (a,b,c) -> a
fst3 (u,,) = u

snd3 :: (a,b,c) -> b
snd3 (,u,) = u

thd3 :: (a,b,c) -> c
thd3 (,,u) = u


我们可以使用这些函数将 y 坐标标量场表示为与第二个笛卡尔坐标关联的标量场。

ySF :: ScalarField
ySF = snd3 . cartesianCoordinates


我们可以通过这种方式定义任何坐标标量场。

图 22-4 展示了标量场 `ySF` 的可视化,使用的是一个坐标系统,其中 *x* 从页面中指向外面,*y* 向右增加,*z* 向上增加。与空间中的每个位置关联的是其 y 值,因此数字向右增加,但向上或向外移动时不会改变。在本章稍后,我们将展示如何制作像图 22-4 那样的标量场可视化。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/433fig01.jpg)

*图 22-4:使用程序 ySF3D 生成的 y 坐标标量场 ySF 的截图。鼠标和键盘可以用来放大或缩小以及旋转可视化,这是 Vis 模块的标准功能。*

由于电荷密度是标量场,因此在第二十四章定义电荷分布时,标量场将发挥重要作用。

物理学中使用的第二种场类型,可能更为重要的是向量场,接下来我们将讨论它。

### 向量场

*向量场*将一个向量与空间中的每个点关联起来。

type VectorField = Position -> Vec


在第二十五章和第二十七章,我们将分别讨论电场和磁场,它们是向量场。

当我们使用坐标系时,我们可以定义由坐标派生的向量场。与柱面和球坐标一起使用的单位向量,如![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg),实际上是*单位向量场*,因为它们的方向会根据空间中位置的不同而变化。

向量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)是通过方程 22.4 和 22.5 定义的。

sHat :: VectorField
sHat r = vec ( cos phi) (sin phi) 0
where
(,phi,) = cylindricalCoordinates r

phiHat :: VectorField
phiHat r = vec (-sin phi) (cos phi) 0
where
(,phi,) = cylindricalCoordinates r


图 22-5 展示了向量场`phiHat`的可视化。每个空间位置都与一个向量相关联,其尾部位于空间中的该点,且其大小和方向表示该点处向量的值。左侧的图片展示了三维中的向量场,其中*x*从页面向外,*y*向右增加,*z*向上增加。z 轴是`phiHat`向量场的对称中心轴。右侧的图片展示了 xy 平面中的向量场。由于`phiHat`是一个单位向量场,所有这些图片中的向量长度相同。图片清晰地展示了单位向量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)在空间中不同位置指向不同方向。稍后在本章中,我将展示如何生成像这样的可视化图像。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/434fig01.jpg)

*图 22-5:可视化向量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)或 phiHat 的两种方式。左侧是 phiHat3D 生成的图像截图,右侧是 phiHatPNG 生成的 xy 平面图像。*

下面是使用方程 22.10 和 22.11 对单位向量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)的定义:

rHat :: VectorField
rHat rv = let d = displacement origin rv
in if d == zeroV
then zeroV
else d ^/ magnitude d

thetaHat :: VectorField
thetaHat r = vec ( cos theta * cos phi)
( cos theta * sin phi)
(-sin theta )
where
(_,theta,phi) = sphericalCoordinates r


我们将![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/icap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/jcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/kcap.jpg)视为简单的单位向量(`Vec`),但我们将![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/zcap.jpg)定义为单位向量场(`VectorField`),它们类似于![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)。

xHat :: VectorField
xHat = const iHat

yHat :: VectorField
yHat = const jHat

zHat :: VectorField
zHat = const kHat


一个重要的向量场不是单位向量场,它是向量场**r**,它将每个位置与从原点到该位置的位移向量相关联。我们将这个向量场命名为`rVF`。

rVF :: VectorField
rVF = displacement origin


函数`displacement`接受一个源位置`Position`和一个目标位置`Position`,并返回从源位置到目标位置的位移向量。通过在定义中省略目标位置,函数`rVF`接受一个目标位置作为输入,并输出一个位移向量,这正是我们想要的`VectorField`。

图 22-6 展示了矢量场`rVF`的可视化。两张图都显示了在 xy 平面中的矢量场。左图将每个矢量的尾部放置在它所关联的位置,并且通过较长的箭头显示较大幅度的矢量。右图将每个矢量的中心放置在它所关联的位置,并通过较暗的箭头显示较大幅度的矢量。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/436fig01.jpg)

*图 22-6:两种在 xy 平面中可视化矢量场**r**(或 rVF)的方法。左图由 rVFpng 生成;右图由 rVFGrad 生成。*

本章后面,我们将介绍一些在给定矢量场作为输入时生成图像的函数,例如图 22-6 中的图像;然而,生成图像只是我们可以用矢量场做的几件事之一。矢量场具有两种导数,分别叫做*散度*和*旋度*,它们表示矢量场中矢量在空间中的变化情况。矢量场在曲线、表面和体积上的积分被用来提取信息并断言物理量之间的关系。在物理学中,将矢量场视为一个单一的数学实体是非常有用的。函数式语言在物理学中的一个优势就是,可以很容易地将矢量场作为单一实体进行处理和描述。本书旨在通过呈现一些函数,使得矢量场更加易于理解,并能让你与矢量场进行互动。

电场和磁场是电磁理论中最重要的矢量场,尽管电流密度也是在著名的麦克斯韦方程中出现的矢量场。我们将在第二十五章讨论电场,在第二十六章讨论电流密度,在第二十七章讨论磁场。

标量场和矢量场可以相加。以下是一些实现这一操作的函数:

addScalarFields :: [ScalarField] -> ScalarField
addScalarFields flds r = sum [fld r | fld <- flds]

addVectorFields :: [VectorField] -> VectorField
addVectorFields flds r = sumV [fld r | fld <- flds]


我们将在第二十五章中使用这些函数来添加由多个源产生的电势和电场。现在,让我们来讨论如何可视化标量场和矢量场。

### 可视化标量场的函数

标量场将一个数值与空间中的每个点关联起来。标量场有许多可视化方式。我们将介绍两种:一种使用 Vis,另一种使用文本。

#### 3D 可视化

可视化标量场的一种简单方法是要求 Vis 在一系列位置上显示标量场的值。函数`sf3D`接受一个位置列表和一个标量场作为输入,并返回在屏幕上显示 3D 图像的操作。

sf3D :: [Position] -- positions to use
-> ScalarField -- to display
-> IO ()
sf3D ps sf
= V.display whiteBackground $ orient $
V.VisObjects [V.Text3d (show (round $ sf p :: Int))
(v3FromPos p) V.Fixed9By15 V.black
| p <- ps]


我们将传入的位置列表命名为`ps`,将传入的标量场命名为`sf`。我们使用列表推导来生成一系列图片,每个位置`p`对应一张图片。每张图片是显示该位置上标量场值的文本。值`sf p`表示在位置`p`处标量场`sf`的值`R`。值`round $ sf p :: Int`是将标量场值四舍五入后得到的`Int`。我们进行四舍五入,以便数字只占用较小的空间,并且在最终的图片中不会相互重叠。值`show (round $ sf p :: Int)`是我们传递给`Vis`的构造函数`V.Text3d`的`String`,该文本将显示在屏幕上。值`v3FromPos p`是`V3`(Vis 的本地向量类型),表示文本应该显示的位置。`v3FromPos`的定义与第十六章中的`v3FromVec`类似。

v3FromPos :: Position -> V3 R
v3FromPos p = V3 x y z
where
(x,y,z) = cartesianCoordinates p


`V.VisObjects`构造函数将一系列图片组合成一幅图像,我们通过`orient`将其定向为我最喜欢的坐标系,并使用`V.display`函数显示,使用一组名为`whiteBackground`的选项,这些选项我们将在下面定义。

选项集`whiteBackground`与`V.defaultOpts`的唯一区别在于背景颜色被设置为白色。

whiteBackground :: V.Options
whiteBackground = V.defaultOpts {V.optBackgroundColor = Just V.white}


这个定义使用记录语法来指定`V.Options`数据类型的所有字段应该与`V.defaultOpts`中的值相同,除了`V.optBackgroundColor`,其值被设置为白色。

如果你希望从代码中控制相机位置,可以添加相关选项。例如,选项集`whiteBackground'`将视点设置为距离中心 40 个 Vis 单位的距离。

whiteBackground' :: V.Options
whiteBackground'
= V.defaultOpts {V.optBackgroundColor = Just V.white,
V.optInitialCamera = Just V.Camera0 {V.rho0 = 40.0,
V.theta0 = 45.0,
V.phi0 = 20.0}}


下面是如何使用这个标量场可视化函数来处理*y*标量场`ySF`的示例:

ySF3D :: IO ()
ySF3D = sf3D [cart x y z | x <- [-6,-2..6]
, y <- [-6,-2..6]
, z <- [-6,-2..6]] ySF


图 22-4 显示了本章前面提到的结果图像。像图 22-4 这样的三维标量场可视化最有用的特点,可能是它帮助我们通过在空间的每个点上想象一个数字,来发展对标量场的视觉和几何理解。一旦我们掌握了这个几何概念,并且希望详细查看一个特定的标量场,通常使用二维可视化会更简单、更方便,接下来我们将描述这种方法。

#### 二维可视化

标量场的三维可视化可能会变得笨重且难以阅读,因此有必要使用工具将标量值显示在二维平面或表面上。我们的二维可视化函数将允许用户指定任何平面或表面进行聚焦。我们可以通过指定两个数字,表示二维可视化中的水平和垂直位置,如何映射到三维空间,换句话说,就是通过提供一个函数`(R,R) -> Position`来做到这一点。接下来的函数局部将该函数称为`toPos`。函数`sfTable`允许用户通过指定一个表面来可视化标量场,从而在该表面上查看标量场的值。

sfTable :: ((R,R) -> Position)
-> [R] -- horizontal
-> [R] -- vertical
-> ScalarField
-> Table Int
sfTable toPos ss ts sf
= Table RJ [[round $ sf $ toPos (s,t) | s <- ss] | t <- reverse ts]


`sfTable`的第一个输入,局部称为`toPos`,指定了感兴趣的表面。例如,如果我们想指定 xz 平面,我们可以将函数`\(x,z) -> cart x 0 z`作为`toPos`的输入。

`sfTable`的第二和第三个输入,局部称为`ss`和`ts`,给出了标量值将显示的水平和垂直二维坐标。对于 xz 平面的可视化,水平值可以是 x 值,垂直值可以是 z 值。第四个输入是要可视化的标量场。

该函数通过在给定的点采样并显示标量场的值来工作。我们使用函数`toPos`从水平和垂直二维坐标的`(s,t)`对生成一个`Position`。然后,我们将标量场`sf`应用于此位置,并将其四舍五入,以避免在屏幕上占用过多空间。垂直坐标列表被反转,以便垂直值从表格底部开始,向上延伸。我们使用第二十章中的`Table`数据类型。

这是一个使用`sfTable`可视化 y 坐标标量场的示例:

Prelude> :l CoordinateSystems
[1 of 7] Compiling Newton2 ( Newton2.hs, interpreted )
[2 of 7] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[3 of 7] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[4 of 7] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[5 of 7] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[6 of 7] Compiling MOExamples ( MOExamples.hs, interpreted )
[7 of 7] Compiling CoordinateSystems ( CoordinateSystems.hs, interpreted )
Ok, 7 modules loaded.
*CoordinateSystems> sfTable ((x,y) -> cart x y 0) [-6,-2..6] [-6,-2..6] ySF
6 6 6 6
2 2 2 2
-2 -2 -2 -2
-6 -6 -6 -6


我们可以使用二维标量场可视化来显示房间中的温度或电容器中的电势,例如。

### 可视化向量场的函数

向量场将一个向量与空间中的每个位置关联起来。在本节中,我们将编写三个用于可视化向量场的函数:`vf3D`、`vfPNG` 和 `vfGrad`。

这些函数本质上具有类型`VectorField -> IO ()`,意味着它们接受一个向量场作为输入并执行某些操作,无论是显示一张图像在屏幕上,还是在硬盘上生成一个图形文件。

#### 3D 可视化

`Vis`模块可以生成一个向量场的三维可视化。基本思路是选择一个位置列表,在这些位置显示向量。我们使用向量场计算每个列出位置的向量,然后在该位置显示该向量及其尾部。向量场的单位通常与位置的单位(米)不同,因此我们需要一个缩放因子来指定每米空间应显示的向量场单位数。以下是实现此功能的`vf3D`函数代码。

vf3D :: R -- scale factor, vector field units per meter
-> [Position] -- positions to show the field
-> VectorField -- vector field to display
-> IO ()
vf3D unitsPerMeter ps vf
= V.display whiteBackground $ orient $
V.VisObjects [V.Trans (v3FromPos p) $
visVec V.black (vf p ^/ unitsPerMeter)
| p <- ps]


函数`vf3D`接受一个缩放因子、一组位置和一个向量场作为输入,并生成一个可在屏幕上放大和旋转的图像。如同`sf3D`一样,该函数使用列表推导式生成一个图片列表,每个位置`p`对应一个图片。每个图片都是一个黑色箭头,由下文定义的`visVec`函数生成,表示位置`p`处的向量,适当地缩放并平移到正确的位置。`V.VisObjects`构造函数将这些图片组合成一幅单一的图像,并使用`orient`将其朝向调整为我最喜欢的坐标系,最后使用本章早些时候定义的`whiteBackground`选项,通过`V.display`函数显示出来。

`visVec`函数接受一个颜色和一个向量作为输入,并生成一个箭头的图片作为输出。以下是代码:

visVec :: V.Color -> Vec -> V.VisObject R
visVec color v = let vmag = magnitude v
in V.Arrow (vmag,20*vmag) (v3FromVec v) color


该函数使用`Vis`的`V.Arrow`构造函数来生成一个向量的图片。`V.Arrow`的第一个参数是一个数字对。第一个数字是请求的箭头长度,我们选择`vmag`,即输入向量的大小。第二个数字是箭头长度与箭杆直径的纵横比。我选择了`20*vmag`,因为我希望箭头具有统一的箭杆直径。箭杆直径是箭头长度`vmag`除以纵横比`20*vmag`,其值为 1/20,与箭头长度无关。

`V.Arrow`的第二个参数是`Vis`的原生`V3`类型中的一个向量,用来指定箭头的方向。我们传递`v3FromVec v`,即将输入向量转换为`V3`类型。`V.Arrow`的第三个也是最后一个参数是颜色,我们直接传递给`visVec`的输入颜色。

以下程序使用`vf3D`函数生成一个单位向量场的可视化图,该单位向量场在本章早些时候已定义为`phiHat`:

phiHat3D :: IO ()
phiHat3D = vf3D 1 [cyl r ph z | r <- [1,2,3]
, ph <- [0,pi/4..2*pi]
, z <- [-2..2]] phiHat


本章前面的图 22-5 的左侧显示了`phiHat3D`生成的图像截图。屏幕上的图像是交互式的,可以用鼠标旋转和缩放。

有时候,向量场的三维可视化可能显得杂乱无章,因此我们需要工具来显示向量场的二维切片。右侧的图 22-5 展示了这样的二维可视化,接下来我们将介绍如何制作这种图像。

#### 2D 可视化

我们怎么能期望在二维中可视化三维向量场呢?一般来说,我们做不到。即使我们将注意力限制在三维空间的一个平面上,比如 xy 平面,向量仍然可能有 z 分量,导致它们无法在 xy 平面上表示。然而,向量场中有足够多的例子,在某些平面中,向量指向*平面内*,因此二维可视化仍然是值得尝试的。

与 2D 标量场可视化类似,我们编写的函数将接受一个名为`toPos`的参数,类型为`(R,R) -> Position`,它将我们提供的二维坐标映射到 3D `Position`。在我们收集了平面位置的向量之后,我们需要第二个函数来指定如何将这些 3D 向量视为平面中的 2D 向量。我们可以使用一个`Vec -> (R,R)`的函数,我们将其命名为本地变量`fromVec`。

我们本可以使用 gloss 进行 2D 向量场可视化,但由于我们可能希望有一个用于异步动画的平台(这是我们在第二十章中首次探讨的内容),我们将改为使用一个名为 diagrams 的图形库,该库生成 PNG 文件,这些文件可以拼接成异步动画。我们即将编写的`vfPNG`函数接受`VectorField`作为输入,以及一些其他参数,并生成一个 PNG 文件。

vfPNG :: ((R,R) -> Position)
-> (Vec -> (R,R))
-> FilePath -- file name
-> R -- scale factor in units per meter
-> [(R,R)] -- positions to use
-> VectorField
-> IO ()
vfPNG toPos fromVec fileName unitsPerMeter pts vf
= let vf2d = r2 . fromVec . (^/ unitsPerMeter) . vf . toPos
pic = mconcat [arrowAt (p2 pt) (vf2d pt) | pt <- pts]
in renderCairo fileName (dims (V2 1024 1024)) pic


该函数接受五个项目作为输入,然后是我们希望显示的向量场。前两个项目是分别名为`toPos`和`fromVec`的函数,它们管理 2D 和 3D 向量场之间的连接。第三个项目是 PNG 文件的文件名。第四个是一个比例因子,单位为(向量场)每米的单位,用于控制显示向量的长度。第五个项目是我们希望显示向量的 2D 点列表。最后,第六个项目是向量场本身。

本地函数`vf2d`是五个函数的组合。它接受一个 2D 点作为输入,并生成一个 2D 向量作为输出,其类型是图形所需的用于定位箭头的类型。从 2D 位置`(R,R)`开始,函数`vf2d`首先应用`toPos`,这是`vfPNG`的用户提供的函数,用于将 2D 位置转换为`Position`。然后应用向量场`vf`以生成一个`Vec`。此向量会被比例因子`unitsPerMeter`缩放,接着函数`fromVec`将`Vec`转换为表示 2D 向量的一对实数。最后,图形的`r2`函数将一对实数`(R,R)`转换为图形的 2D 向量类型。

本地变量`pic`用于显示图片,该图片是通过结合一个箭头图片列表形成的,该列表是通过列表推导式生成的。每个箭头图片是通过图形的`arrowAt`函数制作的,该函数将 2D 向量的尾部放置在其第二个参数中的位置,在第一个参数中给定的 2D 位置处。图形包区分了 2D 位置,该位置由一对数字及其`p2`函数形成,以及 2D 向量,该向量由一对数字及其`r2`函数形成。

`vfPNG`中的最后一行通过图形的`renderCairo`函数生成 PNG 文件,该函数接受文件名、像素大小和图片作为输入。

如果 xy 平面恰好是我们感兴趣的平面,我们可以通过提供`vfPNG`的前两个参数来编写一个辅助函数。函数`vfPNGxy`会提供这两个参数:

vfPNGxy :: FilePath -- file name
-> R -- scale factor
-> [(R,R)] -- positions to use
-> VectorField
-> IO ()
vfPNGxy = vfPNG ((x,y) -> cart x y 0) (\v -> (xComp v, yComp v))


`vfPNG`函数本地调用的`toPos`函数在此处被指定为将 `(x, y)` 对映射到 xy 平面的函数。`vfPNG`函数本地调用的`fromVec`函数则将 3D 向量投影到 xy 平面。

以下程序生成一个 PNG 文件,表示向量场 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg),或称为`phiHat`,这是一个在柱面坐标和球面坐标中与坐标*ϕ*对应的单位向量场:

phiHatPNG :: IO ()
phiHatPNG
= vfPNGxy "phiHatPNG.png" 1
[(r * cos ph, r * sin ph) | r <- [1,2]
, ph <- [0,pi/4..2*pi]] phiHat


本章前面图 22-5 右侧展示了由`phiHatPNG`生成的向量场 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)。

以下是生成本章前面介绍的向量场`rVF`的 PNG 图像的代码:

rVFpng :: IO ()
rVFpng
= vfPNGxy "rVFpng.png" 2
[(r * cos ph, r * sin ph) | r <- [1,2]
, ph <- [0,pi/4..2*pi]] rVF


本章前面图 22-6 左侧展示了结果图像。

物理学家至少使用符号**r**三种方式。它可以表示一个单一的位移向量,我们称之为`Vec`。它还可以表示一个位置函数,就像我们在第二部分中处理的那样,给定时间后返回一个位置。在第二部分中,这个位置函数的类型是`R -> Vec`,因为当时位置被看作是一个向量。现在我们有了位置的数据类型,因此这样的函数的类型是`R -> Position`。符号**r**的第三种用法是我们刚刚介绍的向量场。这具有类型`VectorField`,它是`Position -> Vec`的类型同义词。类型系统有助于澄清符号**r**的这三种用法是不同的。

在我们离开向量场可视化话题之前,我们需要再看一种可视化方法。

#### 梯度可视化

当我们可视化电场和磁场时(我们将在接下来的几章中进行),向量的大小在短距离内可能会发生巨大的变化。因此,将向量的大小表示为箭头的长度可能会产生繁琐的图像。另一种选择是使用阴影来表示大小,并用短粗箭头表示方向。我将这种向量场可视化方式称为*梯度可视化*。

我们下面定义的`vfGrad`函数接受一个向量场以及一些其他参数,并生成一个 PNG 文件。

vfGrad :: (R -> R)
-> ((R,R) -> Position)
-> (Vec -> (R,R))
-> FilePath
-> Int -- n for n x n
-> VectorField
-> IO ()
vfGrad curve toPos fromVec fileName n vf
➊ = let step = 2 / fromIntegral n
➋ xs = [-1+step/2, -1+3*step/2 .. 1-step/2]
➌ pts = [(x, y) | x <- xs, y <- xs]
➍ array = [(pt,magRad $ fromVec $ vf $ toPos pt) | pt <- pts]
➎ maxMag = maximum (map (fst . snd) array)
➏ scaledArrow m th = scale step $ arrowMagRad (curve (m/maxMag)) th
➐ pic = position [(p2 pt, scaledArrow m th) | (pt,(m,th)) <- array]
➑ in renderCairo fileName (dims (V2 1024 1024)) pic


`vfGrad`的第一个参数是一个单调函数`curve`,将单位区间[0, 1]映射到自身。该参数的目的是为可能出现某些位置的向量场在某些位置具有非常大的大小,而在其他地方具有较小的大小的情况提供调整。最大的大小的向量将被着色为黑色,最接近零的将被着色为白色。有时,线性缩放会导致在源附近有黑色向量,而其他地方全是白色向量。在这种情况下,使用类似立方根或五次根的幂律可以增强较小的大小,使得从黑到白的连续过渡变得明显。我们可以通过恒等函数`id`实现线性缩放,通过`(**0.2)`实现五次根缩放。

下一个参数,局部名称为`toPos`和`fromVec`,与函数`vfPNG`中的相同。然而,在这个函数中,`toPos`起到了双重作用,因为`vfGrad`不要求提供一组位置来显示向量。相反,`vfGrad`显示的是从(–1, –1)到(1, 1)的正方形。这个正方形必须映射到三维空间中的某个正方形,显示出该正方形上的向量。如果我们想在 xy 平面上看到一个角落坐标为(–10, –10, 0)和(10, 10, 0)的正方形,我们会将函数`\(x,y) -> cart (10*x) (10*y) 0`传入`toPos`。

参数`fileName`是 PNG 文件的文件名。参数`n`是一个整数,指定每个方向上箭头的数量。例如,传入 20 将生成一个 20x20 箭头的图像。最后一个输入`vf`是向量场本身。

函数`vfGrad`由多个局部定义组成,构建出一个图片`pic` ➐,然后跟随与`vfPNG`中相同的`renderCairo`语句 ➑来生成 PNG 文件。`let`子句中的前三行 ➊ ➋ ➌ 用于选择在其中对向量场进行采样和显示的点`pts`。接下来的第➍行定义了`array`(类型为`[((R,R),(R,R))]`)作为点对和二维向量的列表。我们通过应用`toPos`将点`pt`转换为三维`Position`,然后应用向量场`vf`,再使用`fromVec`将三维向量转换为二维向量,最后应用下文定义的`magRad`将二维向量表示为大小-角度形式。

局部变量 `maxMag` ➎ 搜索列表 `array`,找到所有向量中最大的幅度。具有此幅度的向量将被着色为黑色。局部函数 `scaledArrow` ➏ 描述了如何从幅度 `m` 和角度 `th` 制作单个箭头的图像。它通过将幅度 `m` 除以最大幅度 `maxMag` 来归一化幅度 `m`,从而得到一个介于 0 和 1 之间的归一化幅度。然后,该归一化幅度通过 `curve` 函数进行缩放或弯曲,该函数是一个单调函数,将单位区间 0,[1] 映射到自身。归一化并缩放后的幅度与角度一起传递给下文定义的 `arrowMagRad` 函数,以生成箭头图像。最后,代码根据请求的箭头数量调整箭头的大小。我们通过列表推导式形成最终的图像 `pic` ➐,将每个箭头放置在适当的位置。

函数 `magRad` 将一对笛卡尔坐标转换为极坐标,角度以弧度表示。

magRad :: (R,R) -> (R,R)
magRad (x,y) = (sqrt (xx + yy), atan2 y x)


函数 `arrowMagRad` 根据归一化幅度(范围从 0 到 1)和以弧度表示的角度生成一个箭头的图像。

-- magnitude from 0 to 1
arrowMagRad :: R -- magnitude
-> R -- angle in radians, counterclockwise from x axis
-> Diagram B
arrowMagRad mag th
= let r = sinA (15 @@ deg) / sinA (60 @@ deg)
myType = PolyPolar [120 @@ deg, 0 @@ deg, 45 @@ deg, 30 @@ deg,
45 @@ deg, 0 @@ deg, 120 @@ deg]
[1,1,r,1,1,r,1,1]
myOpts = PolygonOpts myType NoOrient (p2 (0,0))
in scale 0.5 $ polygon myOpts # lw none # fc (blend mag black white) #
rotate (th @@ rad)


该函数将箭头的形状定义为一个多边形,并根据归一化幅度选择颜色。归一化幅度为 1 时生成黑色箭头,幅度为 0 时生成白色箭头,介于两者之间的数字则生成灰色的不同阴影。

这是 **r** 向量场(或 `rVF`)的梯度可视化示例:

rVFGrad :: IO ()
rVFGrad = vfGrad id
((x,y) -> cart x y 0)
(\v -> (xComp v,yComp v))
"rVFGrad.png" 20
rVF


本章前面 图 22-6 右侧显示了由 `rVFGrad` 生成的向量场 **r**,或 `rVF`。

### 摘要

本章介绍了场的概念,场是从三维空间中的位置到某个值的函数。标量场和向量场是电磁理论中最重要的两种场。我们介绍了几种可视化标量场和向量场的方法,并探讨了三维空间的坐标系统,特别是圆柱坐标和球坐标系统。然后我们编写了一个新的数据类型来表示位置。

由于电磁理论具有几何性质,下一章将介绍几何对象(如曲线、表面和体积)的数据类型。

### 习题

**习题 22.1.** 证明极坐标单位向量构成一个正交归一系统。正交归一意味着既正交(不同的向量彼此垂直),又归一化(每个向量的长度为 1)。换句话说,证明

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/446equ01.jpg)

**习题 22.2.** 将 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg) 用 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg) 表示。你的结果应包含 *s*、*ϕ*、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg) 和 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg),但不包含 *x* 或 *y*。

**习题 22.3.** 证明球坐标单位向量构成一个正交归一系统。换句话说,证明

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/446equ02.jpg)

**习题 22.4.** 将![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/xcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ycap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/zcap.jpg)用![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)表示。你的结果可以包含*r*、*θ*、*ϕ*、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg)、![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)和![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg),但不能包含*x*、*y*或*z*。

**习题 22.5.** 定义一个坐标标量场。

thetaSF :: ScalarField
thetaSF = undefined


对于球坐标系中的*θ*坐标。

**习题 22.6.** 使用 3D 可视化生成矢量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)或`thetaHat`的图像。

thetaHat3D :: IO ()
thetaHat3D = undefined


**习题 22.7.** 使用`vf3D`函数可视化矢量场**r**,或`rVF`。你可能需要使用大于 1 的比例因子,以防箭头重叠。较大的比例因子会缩小箭头,因为比例因子是以每米为单位的。

**习题 22.8.** 使用梯度矢量场可视化技术,在 xz 平面上生成矢量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/thcap.jpg)或`thetaHat`的图像。在下面的第一个`undefined`中,你需要说明如何将一对坐标映射到一个`Position`,知道你关注的是 xz 平面。在第二个`undefined`中,你需要说明如何将一个`Vec`映射到一对数字,这对数字描述了要显示的矢量的两个分量。

thetaHatGrad :: IO ()
thetaHatGrad = vfGrad id undefined undefined "thetaHatGrad.png" 20 thetaHat


**习题 22.9.** 使用梯度矢量场可视化技术,在 xy 平面上生成矢量场![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg)或`phiHat`的图像。

phiHatGrad :: IO ()
phiHatGrad = undefined



# 第二十三章:曲线、表面和体积

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

电动力学是一个几何学科。在电磁学理论中,曲线、表面和体积扮演着双重角色。它们是电荷和电流可能存在的地方,并且在麦克斯韦方程的形成中起着至关重要的作用,麦克斯韦方程是现代电磁场如何产生以及如何随时间演变的表达式。

在我们深入研究麦克斯韦方程之前,我们需要为曲线、表面和体积构建数据类型——我们将在本章中构建它们。曲线可以通过给定从单一实数参数到空间位置的函数来指定。表面可以通过给定从一对实数到空间位置的函数来指定。体积可以通过给定从三元数到空间位置的函数来指定。这些数学参数化自然地导致数据类型定义。我们将用适当的边界包装这些参数化,形成 `Curve`、`Surface` 和 `Volume` 类型。

让我们从一些入门代码开始。

### 入门代码

清单 23-1 显示了我们将在本章中开发的 `Geometry` 模块的入门代码。

{-# OPTIONS -Wall #-}

module Geometry where

import SimpleVec ( R, Vec, (*^) )
import CoordinateSystems ( Position, cylindrical, spherical, cart, cyl, sph
, shiftPosition, displacement )


*清单 23-1:几何模块的开头代码行*

我们将使用在 第二十二章 的 `CoordinateSystems` 模块中定义的 `Position` 类型及相关函数,因此我们已经导入了这些类型和函数,以及从 第十章 的 `SimpleVec` 模块中导入的一些类型和函数。

我们的第一个几何对象是嵌入三维空间的一维曲线。

### 曲线

曲线在电磁学理论中有两种不同的用途。首先,我们用它们来描述电荷和电流所存在的位置。电流可以沿着一条曲线在导线中流动。静电荷也可以沿曲线分布。

第二个用途是安培定律,它揭示了空间中一条闭合曲线(一个回路)沿着的磁场与穿过以该闭合曲线为边界的表面的电流之间的关系。曲线的第二个用途更加抽象,因为曲线不需要是任何实际物质的位置,但它对于深入理解现代电磁理论也更为重要。

#### 曲线的参数化

我们如何描述空间中的曲线?我们可以对曲线进行参数化,使每个曲线上的点都有一个实数对应,然后通过一个函数给出与每个参数值相关的空间位置。例如,沿 y 轴的直线可以通过以下函数进行参数化:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/450equ01.jpg)

一个半径为 2 的圆,位于 xy 平面并以原点为圆心,可以通过以下函数来参数化:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/450equ02.jpg)

在这些函数中,*t* 仅作为参数的名称(我们可以选择 *s* 或任何方便的符号),与时间无关。

因此,一个参数化的曲线需要一个类型为`R ->` `Position`的函数,将参数`t :: R`沿着曲线发送到空间中的某个点`r :: Position`。但是我们还需要曲线的起始和结束点。例如,xy 平面中半径为 2、以原点为圆心的圆可以用以下函数来指定:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/451equ01.jpg)

以及起始参数*t[a]* = 0 和结束参数*t[b]* = 2*π*。如果我们使用相同的函数和起始参数,但将结束参数更改为*t[b]* = *π*,我们将得到一个半圆(位于 x 轴上方的半圆)。

起始点和结束点可以通过起始参数`startingCurveParam :: R`(我们之前称之为*t[a]*)和结束参数`endingCurveParam :: R`(我们之前称之为*t[b]*)来指定。因此,我们用三部分数据来指定曲线:一个函数、一个起始参数和一个结束参数。

数据类型可以用来组合那些本应属于同一组的数据。对于曲线来说,拥有一个单一类型`Curve`,其中包含函数、起始点和结束点将非常方便。

data Curve = Curve { curveFunc :: R -> Position
, startingCurveParam :: R -- t_a
, endingCurveParam :: R -- t_b
}


数据类型`Curve`有一个单一的数据构造函数,也叫做`Curve`。

#### 曲线示例

让我们编码一个半径为 2 的圆的例子,这个圆位于 xy 平面,且以原点为圆心。

circle2 :: Curve
circle2 = Curve (\t -> cart (2 * cos t) (2 * sin t) 0) 0 (2*pi)


我们将曲线命名为 circle2,以提醒我们半径为 2。参数化 23.1 作为数据构造函数`Curve`的第一个参数,后面跟着起始和结束的曲线参数。

在 xy 平面中,以原点为圆心的圆在圆柱坐标系下比在笛卡尔坐标系下更容易表示。在圆柱坐标系中,我们的圆有常数值*s* = 2 和*z* = 0。只有*ϕ*坐标从 0 变化到 2*π*。这意味着我们可以使用*ϕ*坐标作为曲线的参数。

circle2' :: Curve
circle2' = Curve (\phi -> cyl 2 phi 0) 0 (2*pi)


我们使用 cyl 函数来指定圆柱坐标系中的曲线。曲线`circle2'`与曲线 circle2 相同。

这是单位圆的定义:

unitCircle :: Curve
unitCircle = Curve (\t -> cyl 1 t 0) 0 (2 * pi)


有些曲线族需要我们在定义特定曲线之前提供额外的信息。直线段就是这样的曲线。我们需要提供起始位置和结束位置,而这正是高阶函数的完美任务。

straightLine :: Position -- starting position
-> Position -- ending position
-> Curve -- straight-line curve
straightLine r1 r2 = let d = displacement r1 r2
f t = shiftPosition (t *^ d) r1
in Curve f 0 1


我们定义局部变量`d`为位移向量,从位置`r1`指向位置`r2`。我们还通过使用`shiftPosition`函数来定义一个局部函数`f`作为我们的曲线函数,该函数选取从`r1`通过位移向量`t *^ d`平移得到的位置。曲线参数`t`的范围从`0`到`1`,因此`t *^ d`是位移向量`d`的一个缩放版本,从长度 0 到`d`的完整长度。

我们已经看到了如何在 Haskell 中描述一维曲线。现在让我们提升一个维度,来讨论曲面。

### 曲面

在电磁理论中,曲面有两个不同的用途。我们用它们来描述电荷和电流所在的地方。电流可以沿着曲面流动。静电荷也可以放置在曲面上。

我们还在高斯定律中使用它们,后者阐明了空间中闭合曲面上的电场与该曲面内电荷之间的关系。曲面的第二种用途更加抽象,因为表面不必是任何实际物质的所在,但对于深入理解现代电磁理论,它也更加重要。

#### 参数化曲面

曲面是从两个参数到空间的参数化函数。例如,我们可以用两个参数*θ*和*ϕ*对单位球体进行参数化,作为函数

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/452equ01.jpg)

并且范围为 0 ≤ *θ* ≤ *π*和 0 ≤ *ϕ* ≤ 2*π*。

作为第二个示例,假设我们要对位于 xy 平面上的表面进行参数化,该表面由抛物线*y* = *x*²和直线*y* = 4 所限定。此表面如图 23-1 所示。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/453fig01.jpg)

*图 23-1:一个参数化的表面*

在这种情况下,使用*x*和*y*作为参数是有意义的。这个表面的参数化函数并不特别令人兴奋:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/453equ01.jpg)

这个曲面有趣的地方在于边界的指定。有一个下界曲线*y* = *x*²给出了底部边界,一个上界曲线*y* = 4 给出了顶部边界,一个下限*x* = –2 指定了左边界,一个上限*x* = 2 指定了右边界。

对于一般的曲面,我们将把两个参数称为*s*和*t*。(这个参数*s*与第二十二章中讨论的圆柱坐标的*s*无关。)为了指定一个一般的曲面,我们必须提供五个数据项:两个变量的参数化函数、下界曲线、上界曲线、下限和上限。以下是一般曲面数据类型的定义:

data Surface = Surface { surfaceFunc :: (R,R) -> Position
, lowerLimit :: R -- s_l
, upperLimit :: R -- s_u
, lowerCurve :: R -> R -- t_l(s)
, upperCurve :: R -> R -- t_u(s)
}


函数 surfaceFunc 是一个参数化函数,将(*s*,*t*)映射到一个`Position`。下界曲线作为一个函数*t[l]*(*s*)给出,它为每个*s*值提供曲面上*t*的最小值。上界曲线作为一个函数*t[u]*(*s*)给出,它为每个*s*值提供曲面上*t*的最大值。下限*s[l]*是曲面上*s*的最小值,上限*s[u]*是曲面上*s*的最大值。

#### 曲面的示例

要编码我们之前讨论的单位球体,可以写出以下内容:

unitSphere :: Surface
unitSphere = Surface ((th,phi) -> cart (sin th * cos phi)
(sin th * sin phi)
(cos th))
0 pi (const 0) (const $ 2*pi)


在这种情况下,我们希望为下界和上界曲线使用常数函数,因此我们使用 const 函数将一个数字转换为常数函数,并使用`$`运算符避免在`2*pi`周围使用括号。

不出所料,在球坐标中指定单位球体更为简单。

unitSphere' :: Surface
unitSphere' = Surface ((th,phi) -> sph 1 th phi)
0 pi (const 0) (const $ 2*pi)


在球坐标中,我们使用相同的参数(*θ*,*ϕ*),相同的上下曲线,以及相同的限制条件。只有参数化函数发生了变化。表面`unitSphere'`与 unitSphere 是相同的表面。

让我们对图 23-1 中的抛物面进行编码。

parabolaSurface :: Surface
parabolaSurface = Surface ((x,y) -> cart x y 0)
(-2) 2 (\x -> x*x) (const 4)


我们使用匿名函数来指定表面的参数化以及抛物线下边界曲线。

那么,如何处理一个以任意位置为中心且具有任意半径的球体呢?我们可以手动对其进行参数化,但不如定义一个函数来移动任意表面的位置。这个函数似乎非常有用。

shiftSurface :: Vec -> Surface -> Surface
shiftSurface d (Surface g sl su tl tu)
= Surface (shiftPosition d . g) sl su tl tu


`shiftSurface`函数不会改变正在使用的参数的限制条件。相反,它会通过位移向量`d`来移动参数化函数`g`所提供的位置。

接下来,我们定义一个具有任意半径的中心球面。

centeredSphere :: R -> Surface
centeredSphere r = Surface ((th,phi) -> sph r th phi)
0 pi (const 0) (const $ 2*pi)


最后,我们定义一个具有任意中心和任意半径的球面。

sphere :: R -> Position -> Surface
sphere radius center
= shiftSurface (displacement (cart 0 0 0) center)
(centeredSphere radius)


这是单位球体的北半球:

northernHemisphere :: Surface
northernHemisphere = Surface ((th,phi) -> sph 1 th phi)
0 (pi/2) (const 0) (const $ 2*pi)


这是一个位于 xy 平面上的圆盘,中心在原点:

disk :: R -> Surface
disk radius = Surface ((s,phi) -> cyl s phi 0)
0 radius (const 0) (const (2*pi))


我认为“单位圆锥”这个术语不是标准术语,但这里是一个圆锥,其中底面的圆形边界位于单位球面上,圆锥的顶点位于球心:

unitCone :: R -> Surface
unitCone theta = Surface ((r,phi) -> sph r theta phi)
0 1 (const 0) (const (2*pi))


这些表面,或者你自己写的表面,可以在第二十四章中用于形成一个电荷分布,其中电荷分布在表面上,或者在第二十六章中用于形成一个电流分布,其中电流流过表面。闭合表面,例如球面,可以与高斯定律一起使用。

#### 定向

我们的表面是有方向的表面。*定向*是选择哪个方向(垂直于表面)被视为“正方向”。如果![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)是指向* s *增大的方向的单位向量,且![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/tcap.jpg)是指向*t*增大的方向的单位向量,则定向的正方向是![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/455equ01.jpg)。(用于指定表面及其相关单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)的参数*s*与圆柱坐标*s*及其相关单位向量![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/scap.jpg)无关。上下文应该能明确表示是哪一个。)表面的定向在通量积分中非常重要,通量积分用于计算通过表面的电通量、磁通量和电流。

让我们来确定 `unitSphere` 的方向。我们使用球坐标来参数化这个表面,第一个参数(通常称为 *s*)是单位球的 *θ*,第二个参数(通常称为 *t*)是单位球的 *ϕ*。因此,如 图 23-2 所示,单位球的方向是正向的,指向 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/455equ04.jpg) 方向。在球面坐标中,![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/455equ03.jpg),这意味着“朝外”是单位球的正方向。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/456fig01.jpg)

*图 23-2:当第一个参数 s 是 *θ* 且第二个参数 t 是 *ϕ* 时,方向是 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/455equ04.jpg),与 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/rcap.jpg) 相同,因此方向是朝外的。*

我们可以做一个单位球体,将“朝内”作为方向,但我们需要与 `unitSphere` 不同的参数化方法。如果我们将 *ϕ* 作为第一个参数,*θ* 作为第二个参数,那么方向是朝内的。

### 体积

当我们有一个分布在体积中的电荷时,我们将使用体积电荷密度来描述它;因此,我们需要一个新的数据类型来描述体积。我们需要指定七个数据项来描述一个体积:

1\. 一个从三个参数 (*s*,*t*,*u*) 到空间的参数化函数

2\. 一个下表面 *u[l]*(*s*,*t*),描述每个 (*s*,*t*) 对应的 *u* 的最小值

3\. 一个上表面 *u[u]*(*s*,*t*),描述每个 (*s*,*t*) 对应的 *u* 的最大值

4\. 一个下曲线 *t[l]*(*s*),描述每个 *s* 值对应的 *t* 的最小值

5\. 一个上曲线 *t[u]*(*s*),描述每个 *s* 值对应的 *t* 的最大值

6\. 一个下限 *s[l]*,描述 *s* 的最小值

7\. 一个上限 *s[u]*,描述 *s* 的最大值

这是 `Volume` 数据类型的定义:

data Volume = Volume { volumeFunc :: (R,R,R) -> Position
, loLimit :: R -- s_l
, upLimit :: R -- s_u
, loCurve :: R -> R -- t_l(s)
, upCurve :: R -> R -- t_u(s)
, loSurf :: R -> R -> R -- u_l(s,t)
, upSurf :: R -> R -> R -- u_u(s,t)
}


给定 `Volume` 的 volumeFunc 类型为 `(R,R,R) -> Position`。回想一下在 第二十二章 中提到的,这个类型与 `CoordinateSystem` 是相同的。我们通常会使用笛卡尔坐标系、圆柱坐标系或球面坐标系作为我们的 volumeFunc,尽管你也可以发明自己的坐标系统。

这是一个以原点为中心的单位球:

unitBall :: Volume
unitBall = Volume spherical 0 1 (const 0) (const pi)
(_ _ -> 0) (_ _ -> 2*pi)


对于 volumeFunc,我们使用球面坐标系,这意味着参数 (*s*,*t*,*u*) 是球坐标系中的 (*r*,*θ*,*ϕ*)。我们必须提供下限 *r[l]*、上限 *r[u]*、下曲线 *θ[l]*(*r*)、上曲线 *θ[u]*(*r*)、下表面 *ϕ[l]*(*r*,*θ*) 和上表面 *ϕ[u]*(*r*,*θ*)。对于一个球体,我们应该选择以下内容:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/457equ01.jpg)

请注意,*θ[l]* 是函数 *r* ↦ 0(在 Haskell 表示法中为 `\r -> 0` 或 `\_ -> 0`)。这与返回 0 的常数函数相同(在 Haskell 表示法中为 const 0)。函数 *ϕ[l]* 接受 *两个* 输入并返回 0(在 Haskell 表示法中为 `\_ _ -> 0`)。

这是一个圆柱体,底面圆心位于原点,顶部圆面位于*z* = *h*平面。我们将圆柱的半径和高度作为输入传递给函数`centeredCylinder`。

centeredCylinder :: R -- radius
-> R -- height
-> Volume -- cylinder
centeredCylinder radius height
= Volume cylindrical 0 radius (const 0) (const (2*pi))
(_ _ -> 0) (_ _ -> height)


这些体积,或你编写的体积,可以在第二十四章中用于形成一个电荷分布,在其中电荷分布在整个体积内,或者在第二十六章中用于形成一个电流分布,在其中电流流经整个体积。

### 总结

在本章中,我们开发了`Curve`、`Surface`和`Volume`数据类型,用于描述几何体。我们定义了一些特定的几何体,比如`unitCircle`、`sphere`和`unitBall`。这些曲线、表面和体积将成为我们积分以计算电场的对象,它们也将作为高斯定律和安培定律的抽象背景。下一章将讨论电荷分布,为接下来的电场章节做准备。

### 练习题

**练习 23.1.** 替换下面未定义的半径 r,给出一个定义,该定义将接受一个中心位置和半径,并生成一个与 xy 平面平行的圆。

circle :: Position -- center position
-> R -- radius
-> Curve
circle r radius = undefined r radius


**练习 23.2.** 螺旋线最容易用圆柱坐标参数化。在圆柱坐标系(*s*,*ϕ*,*z*)中,半径为 1 的螺旋线可以参数化为

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/458equ01.jpg)

为这个螺旋线定义一个`Curve`。选择端点使得螺旋线围绕中心旋转五圈。

**练习 23.3.** 一个正方形有四条边。让我们定义一个`Curve`来表示一个顶点为(–1,–1,0)、(1,–1,0)、(1,1,0)和(–1,1,0)的正方形。使曲线的方向为逆时针。填写`undefined`部分。

square :: Curve
square = Curve squareFunc 0 4

squareFunc :: R -> Position
squareFunc t
| t < 1 = cart undefined (-1) 0
| 1 <= t && t < 2 = cart 1 undefined 0
| 2 <= t && t < 3 = cart undefined 1 0
| otherwise = cart (-1) undefined 0


**练习 23.4.** 为一个高度为*h*,半径为*r*的圆锥体定义一个`Surface`。不要包括圆锥底面的表面。你可以根据需要调整圆锥的位置和方向。

**练习 23.5.** 替换下面未定义的部分,给出一个半径为单位的上半球(*z* ≥ 0)的定义,球心位于原点。

northernHalfBall :: Volume
northernHalfBall = undefined


**练习 23.6.** 替换下面的`undefined`部分,给出一个给定半径并以原点为中心的球体的定义。(`R`是半径的类型,你可能想要在等号左侧放一个半径的变量。)

centeredBall :: R -> Volume
centeredBall = undefined


**练习 23.7.** 在之前给出的`shift` `Surface`定义中,`shiftPosition d`的类型是什么?

**练习 23.8.** 定义一个函数

shiftVolume :: Vec -> Volume -> Volume
shiftVolume = undefined


它接受一个位移向量和一个体积作为输入,并返回一个平移后的体积作为输出。

**练习 23.9.** 定义一个函数

quarterDiskBoundary :: R -> Curve
quarterDiskBoundary = undefined


它接受一个半径作为输入,并给出一个与图 23-3 对应的`Curve`作为输出。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/459fig01.jpg)

*图 23-3:表示四分之一圆盘边界的曲线*

**练习 23.10.** 为图 23-4 中显示的矩形区域定义一个`Surface`。选择你的参数化方式,使得方向朝向![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/icap.jpg)方向(即正 x 方向)。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/460fig01.jpg)

*图 23-4:yz 平面中的一个曲面*

**练习 23.11.** 定义一个函数

quarterCylinder :: R -> R -> Volume
quarterCylinder = undefined


该函数接受高度 *h* 和半径 *R* 作为输入,并返回一个对应于图 23-5 的`Volume`。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/460fig02.jpg)

*图 23-5:表示四分之一圆柱体的体积*

**练习 23.12.**

(a) 定义一个`Surface`,表示一个大半径为 3,小半径为 0.5 的环面。

(b) 定义一个`Volume`,表示(a)部分环面内部的空间。


# 第二十四章:电荷

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

电荷最终负责所有电气现象。能够讨论集中在空间中特定点的电荷是有用的,但同样有用的是能够讨论沿曲线、表面或整个体积分布的电荷。这些电荷分布是本章的主题。

我们首先介绍线性电荷密度、表面电荷密度和体积电荷密度的概念。然后,我们将定义一种电荷分布的数据类型,能够表示点电荷、线电荷、面电荷、体电荷或这些电荷的任意组合。我们将编写函数来求出电荷分布的总电荷和电偶极矩。电荷产生电场。掌握了电荷分布的语言,为下一章的内容打下了基础,在这一章中,我们将求解由电荷分布产生的电场。

### 电荷分布

电荷是导致电磁效应的基本量,并且在电磁理论中发挥着关键作用。在我们本书中研究的经典电磁理论中,我们有时将电荷视为与粒子相关联,这时我们称电荷为*点电荷*,假设它在空间中有一个位置,但没有空间扩展。

我们有时也将电荷视为流体(即可以在空间区域内连续分布的物质)。实际上,我们使用三种类型的连续电荷分布。首先,电荷沿一维路径(如直线或曲线)连续分布。在这种情况下,我们称之为*线性电荷密度 λ*,表示单位长度的电荷量。这个希腊字母 λ 的使用与它在第二十章中表示波长的用法是独立的。线性电荷密度的国际单位制(SI)单位是库仑每米(C/m)。

其次,电荷在二维表面上连续分布。在这种情况下,我们称之为*表面电荷密度 σ*(希腊字母 sigma),表示单位面积的电荷量。表面电荷密度的 SI 单位是库仑每平方米(C/m²)。

第三,电荷在三维体积内连续分布。在这种情况下,我们称之为*体积电荷密度 ρ*(希腊字母 rho),表示单位体积的电荷量。体积电荷密度的 SI 单位是库仑每立方米(C/m³)。表 24-1 总结了这些电荷分布。

**表 24-1:** 电荷分布

| **电荷分布** | **维度** | **符号** | **国际单位制单位** |
| --- | --- | --- | --- |
| 点电荷 | 0 | *q*, *Q* | C |
| 线性电荷密度 | 1 | *λ* | C/m |
| 表面电荷密度 | 2 | *σ* | C/m² |
| 体积电荷密度 | 3 | *ρ* | C/m³ |

电荷在微观物理学中起着作用,因为我们将电荷与电子和夸克等基本粒子相关联。电荷在宏观物理学中也起作用,因为电子可以在某些地方堆积,导致净负电荷,或者它们可以从原子中缺失,导致净正电荷。

材料可以根据电子的运动难易程度分为绝缘体或导体。绝缘体是指电子难以离开原子并在材料中移动的材料,而导体是指电子容易在材料中移动的材料。

绝缘体上的电荷分布可以或多或少地是任意的,而像原子这样的物体的电荷分布可能是非均匀的——最终由量子力学决定。但是,导体上的电荷分布是受限的,因为电子不会停留在你放置它们的位置。*固定*的宏观电荷分布只能在绝缘体上实现,在这些材料上电荷不能移动。因此,我可以将我猫的尾巴充电至其鼻子电荷密度的 10 倍,因为电子停留在原地。宏观的*导体*不能支持任意的电荷密度:电荷会移动,迅速重新分布。因此,当我们讨论电荷分布时,应该假设我们是在绝缘体上分布电荷。

在我们介绍各种电荷分布之前,需要在源代码文件的顶部添加一些引导代码;接下来我们将看看这些代码。

### 引导代码

列表 24-1 显示了我们将在本章中编写的`Charge`模块的前几行代码。

{-# OPTIONS -Wall #-}

module Charge where

import SimpleVec ( R, Vec, vec, sumV, (*^), (^/), (<.>), magnitude, negateV )
import Electricity ( elementaryCharge )
import CoordinateSystems ( Position, ScalarField, origin, cart, sph
, rVF, displacement, shiftPosition )
import Geometry ( Curve(..), Surface(..), Volume(..)
, straightLine, shiftSurface, disk )
import Integrals
( scalarLineIntegral, scalarSurfaceIntegral, scalarVolumeIntegral
, vectorLineIntegral, vectorSurfaceIntegral, vectorVolumeIntegral
, curveSample, surfaceSample, volumeSample )


*列表 24-1:电荷模块的开头代码行*

我们使用来自第十章的`SimpleVec`模块、来自第二十一章的`Electricity`模块、来自第二十二章的`CoordinateSystems`模块、来自第二十三章的`Geometry`模块以及下一章中介绍的包含各种函数的`Integrals`模块中的类型和函数。

让我们定义一个电荷的类型同义词:

type Charge = R


以这种方式定义电荷的新类型是半好半傻。它的好处在于,代码的读者(包括代码的编写者)会知道表达式`Charge`的意图。从这个意义上说,它是一种代码文档形式。然而,这也有些傻,因为编译器不会区分`Charge`、`R`和`Double`,因此它不能帮助编写者避免在任何可以使用`R`或`Double`的地方使用`Charge`。类型的主要目的之一是区分那些应该分开的东西,并让计算机帮助强制执行这种分离。例如,电荷与时间完全不同,后者也可以通过一个实数`R`来描述。

再次,我们必须在简单性和功能之间做出选择。在这里,我们可以使用 Haskell 的`data`关键字定义一个新的电荷类型,这样就不会与任何其他类型混淆。在这里定义一个新的数据类型是合理的,但是这会增加一些额外的工作和开销,所以我选择了`type`方法的简单性而不是`data`方法的功能性。

### 电荷分布的类型

我们希望定义一个新的数据类型`ChargeDistribution`,它可以包含点电荷、线电荷、面电荷、体积电荷或这些的组合。这不是强制性的;我们可以选择单独为线电荷、面电荷等定义不同的类型。引入单一的`ChargeDistribution`类型允许我们编写一个函数。

eField :: ChargeDistribution -> VectorField


在下一章中强调电荷是电场的源头。一旦你理解了语言允许的选项,你就可以利用它们来突出你所写的学科的中心思想。

指定每种分布所需的信息是什么?对于点电荷,我们需要指定有多少电荷以及电荷位于何处,因此我们需要给出`Charge`和`Position`。对于线电荷,我们需要指定一个曲线,沿着这条曲线电荷分布,并且需要给出曲线上每一点的线电荷密度。线电荷密度沿着曲线可以不均匀。它在某些地方可能很高,在其他地方可能很低,甚至在一些地方是正的,在另一些地方是负的。我们将使用标量场来指定线电荷密度。因此,线电荷需要我们给出线电荷密度的`ScalarField`和电荷所在的`Curve`。

表面电荷的规范要求我们给出表面电荷密度的标量场,这可能随位置而变化,以及一个电荷所在的表面。表面电荷由`ScalarField`和`Surface`来指定。类似地,体积电荷通过给出`ScalarField`和`Volume`来指定。

最后,我们可以通过给出电荷分布的列表来指定电荷分布的组合。让我们看一下定义数据类型`ChargeDistribution`的代码。

data ChargeDistribution
= PointCharge Charge Position
| LineCharge ScalarField Curve
| SurfaceCharge ScalarField Surface
| VolumeCharge ScalarField Volume
| MultipleCharges [ChargeDistribution]


类型`ChargeDistribution`有五个数据构造函数,分别对应我们之前描述的每种情况。为了构造`ChargeDistribution`,我们使用五个数据构造函数之一以及该类型电荷分布的相关信息。这种数据类型的一个有趣属性是它是递归的。`MultipleCharges`构造函数所需的信息是`ChargeDistribution`的列表,这恰好是我们正在定义的类型。我们可以认为电荷分布有四种基本类型(点、线、面和体积)和一种组合类型,它们结合了分布。

### 电荷分布的示例

让我们写一些电荷分布的例子。我们可以将原点处质子的电荷分布定义为如下:

protonOrigin :: ChargeDistribution
protonOrigin = PointCharge elementaryCharge origin


这里是一个均匀线电荷,带电总量为`q`,长度为`len`,并且以原点为中心:

chargedLine :: Charge -> R -> ChargeDistribution
chargedLine q len
= LineCharge (const $ q / len) $
Curve (\z -> cart 0 0 z) (-len/2) (len/2)


我们传入总电荷`q`和长度`len`。均匀的线电荷密度为`q / len`,我们将其传递给`const`函数,因为`LineCharge`要求其第一个参数为标量场。

这里是一个均匀带电的球体,带电总量为`q`,半径为`radius`,并且以原点为中心:

chargedBall :: Charge -> R -> ChargeDistribution
chargedBall q radius
= VolumeCharge (const $ q / (4/3piradius**3)) $
Volume ((r,theta,phi) -> sph r theta phi)
0 radius (const 0) (const pi) (_ _ -> 0) (_ _ -> 2*pi)


我们传入总电荷`q`和球体的`radius`。然后我们可以通过将`q`除以球体体积 4*π*^(*r*3)/3 来得到均匀的体积电荷密度。

平行板电容器由两块导电板组成,彼此平行。它通常用于让一块板带正电荷,另一块板带相等但符号相反的负电荷。每块板上的电荷分布不会完全均匀,但如果板间距离较小,将每块板上的电荷视为均匀分布在板的表面上是一个很好的近似。

以下是一个平行板电容器的模型,包含两块板,每块板的形状是半径为`radius`的圆盘。两块板平行放置,且彼此之间的距离为`plateSep`。正板上的表面电荷密度为`sigma`,负板上的表面电荷密度为`-sigma`。

diskCap :: R -> R -> R -> ChargeDistribution
diskCap radius plateSep sigma
= MultipleCharges
[SurfaceCharge (const sigma) $ shiftSurface (vec 0 0 (plateSep/2)) (disk radius)
,SurfaceCharge (const $ -sigma) $
shiftSurface (vec 0 0 (-plateSep/2)) (disk radius)
]


这里我们第一次使用了`MultipleCharges`构造函数。电荷分布由两个`disk`形状的电荷组成,这些电荷是我们在上一章中写的那种类型。由于我们在上一章写的`disk`是以原点为中心的,因此我们使用了`shiftSurface`,也是来自上一章,用于将两个盘状电荷放置在原点的上下方。

写了几个电荷分布的例子后,接下来我们来探讨如何求解一个电荷分布的总电荷量。

### 总电荷

如果我们将 2 库仑电荷均匀分布在 4 米的长度上,那么线电荷密度为 0.5 C/m。在这里,我们用的是电荷密度的术语,但有时用总电荷来表达更为方便。例如,当我们关心的是该线电荷在几百米外的某个地方产生的电场时,总电荷更加相关,因为从这个距离来看,线电荷就像一个简单的点电荷。

#### 线电荷的总电荷

线电荷的总电荷可以通过对电荷所在曲线上的线电荷密度进行积分来求得。如果*P*是电荷所沿的路径或曲线,*dl*是曲线上的长度元素,而*λ*是线电荷密度的标量场,那么以下公式就是线电荷的总电荷:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/466equ01.jpg)

在线电荷密度均匀的情况下,将其乘以曲线的长度即得总电荷。一般来说,线电荷密度并不均匀。我们通过将曲线划分为许多小段,乘以每段的长度和代表性的电荷密度值,计算每段的电荷,并将所有这些段的电荷相加。当进行分析计算时,我们考虑一个极限,其中每段的长度趋近于零,段的数量趋向于无穷。当进行数值计算时,我们选择一些较大但有限的段数量。

#### 表面电荷的总电荷

表面电荷的总电荷可以通过对包含电荷的表面积分表面电荷密度来找到。如果 *S* 是表面,*da* 是表面元素,*σ* 是表面电荷密度的标量场,则

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/467equ01.jpg)

是表面电荷的总电荷。

在表面电荷密度均匀的情况下,将其乘以表面面积即得总电荷。一般来说,表面电荷密度并不均匀。我们通过将表面划分为大量小区域来进行积分,乘以每个区域的代表性电荷密度值,计算该区域的电荷,并将所有这些区域的电荷相加。当进行分析计算时,我们考虑一个极限,其中每个区域的面积趋近于零,区域的数量趋向于无穷。当进行数值计算时,我们选择一些较大但有限的区域数量。

#### 体积电荷的总电荷

体积电荷的总电荷可以通过对包含电荷的体积积分体积电荷密度来找到。如果 *V* 是体积,*dv* 是体积元素,*ρ* 是体积电荷密度的标量场,则

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/467equ02.jpg)

是体积电荷的总电荷。

在体积电荷密度均匀的情况下,将其乘以体积即得总电荷。一般来说,体积电荷密度并不均匀。我们进行的积分方法与线电荷和表面电荷的积分方法基本相同。

#### 在 Haskell 中计算总电荷

这里有一个名为 `totalCharge` 的函数,它计算电荷分布的总电荷:

totalCharge :: ChargeDistribution -> Charge
totalCharge (PointCharge q _)
= q
totalCharge (LineCharge lambda c)
= scalarLineIntegral (curveSample 1000) lambda c
totalCharge (SurfaceCharge sigma s)
= scalarSurfaceIntegral (surfaceSample 200) sigma s
totalCharge (VolumeCharge rho v)
= scalarVolumeIntegral (volumeSample 50) rho v
totalCharge (MultipleCharges ds )
= sum [totalCharge d | d <- ds]


`totalCharge` 函数使用模式匹配来分别处理五个数据构造器。在点电荷的情况下,该函数直接返回点电荷的电荷值。对于线电荷,函数使用 `scalarLineIntegral`,我们将在第二十五章中编写它,来执行方程 24.1 中的积分。`scalarLineIntegral` 函数接受一个方法来逼近曲线,一个标量场和曲线作为输入,并返回标量场在曲线上的线积分的近似值。

对于表面电荷,函数使用`scalarSurfaceIntegral`,我们将在第二十五章中讲解它,来执行方程 24.2 中显示的积分。`scalarSurfaceIntegral`函数的输入包括一个近似表面的方法、一个标量场和一个表面,它返回标量场在表面上表面积分的近似值。正如我们在下一章将看到的,方法`surfaceSample`使用与给定数字的平方两倍相等的多个小面来近似表面;前面显示的 200 值将使用 80,000 个小面。

对于体积电荷,函数使用`scalarVolumeIntegral`,我们将在第二十五章中讲解它,来执行方程 24.3 中显示的积分。`scalarVolumeIntegral`函数输入一个近似体积的方法、一个标量场和一个体积,并返回标量场在该体积上体积积分的近似值。正如我们在下一章将看到的,方法`volumeSample`使用与给定数字的立方五倍相等的多个体积元素来近似体积;前面显示的 50 值将使用 625,000 个体积元素。

在多个电荷的情况下,该函数计算列表中每个分布的总电荷并将结果相加。因为`total` `Charge`使用`totalCharge`来执行此操作,所以它是一个递归函数。

让我们检查一下我们之前定义的分布的总电荷。

Prelude> :l Charge
[ 1 of 11] Compiling Newton2 ( Newton2.hs, interpreted )
[ 2 of 11] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[ 3 of 11] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[ 4 of 11] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[ 5 of 11] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[ 6 of 11] Compiling MOExamples ( MOExamples.hs, interpreted )
[ 7 of 11] Compiling Electricity ( Electricity.hs, interpreted )
[ 8 of 11] Compiling CoordinateSystems ( CoordinateSystems.hs, interpreted )
[ 9 of 11] Compiling Geometry ( Geometry.hs, interpreted )
[10 of 11] Compiling VectorIntegrals ( VectorIntegrals.hs, interpreted )
[11 of 11] Compiling Charge ( Charge.hs, interpreted )
Ok, 11 modules loaded.
*Charge> totalCharge protonOrigin
1.602176634e-19
*Charge> totalCharge $ chargedLine 0.25 2
0.2500000000000002


质子的总电荷就是质子的电荷。线电荷的总电荷是我们为线电荷总电荷所给出的值。

### 电偶极矩

*电偶极子*是由正负电荷在空间中分开组成的组合。最简单的情况是一个点电荷*q*和一个点电荷–*q*,它们之间有一定的距离*d*。电偶极子会产生电场,并通过感受力和/或力矩对电场做出响应,因此它可以被看作是一个类似于电荷本身的电性活跃实体。总电荷为 0 的电荷分布通常看起来像一个电偶极子。像氯化钠这样的中性双原子分子就是一个电偶极子的例子。

我们通过其*电偶极矩*来表征电偶极子,电偶极矩是一个从负电荷指向正电荷的向量。对于电荷*q*和–*q*,它们之间距离为*d*的情况,电偶极矩为**p** = *q***d**,其中**d**是从–*q*位置到*q*位置的位移向量。用于电偶极矩的**p**与用于动量的**p**无关。

电荷分布`simpleDipole`描述了两个电荷,*q*和–*q*,它们之间的距离为*d*。电偶极子位于原点。该函数以电偶极矩和点电荷之间的距离为输入。

simpleDipole :: Vec -- electric dipole moment
-> R -- charge separation
-> ChargeDistribution
simpleDipole p sep
= let q = magnitude p / sep
disp = (sep/2) *^ (p ^/ magnitude p)
in MultipleCharges
[PointCharge q (shiftPosition disp origin)
,PointCharge (-q) (shiftPosition (negateV disp) origin)
]


该函数通过将偶极矩的大小除以分离距离来计算点电荷的电荷`q`。位移向量`disp`指向从原点到正电荷的位置。位移向量`negateV disp`指向从原点到负电荷的位置。

电偶极矩可以与任何电荷分布相关联。体电荷密度*ρ*的电偶极矩由以下公式给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/469equ01.jpg)

如果电荷分布的总电荷为 0,那么电偶极矩通常是对该分布的最佳简化表征,能很好地近似该分布所产生的电场。

当然,电荷分布的总电荷和电偶极矩都可能为 0。在这种情况下,可以定义电四极矩来表征该分布。事实上,任何电荷分布都可以看作是电单极子(点电荷)、电偶极子、电四极子、电八极子及更高阶项的组合,这些被称为*多极展开*。我们在本书中不会深入探讨这一展开,只是指出电偶极子是展开中的第二项。像泰勒级数等数学级数展开一样,多极展开中的第一个非零项通常能为电荷分布提供一个简单的近似。

函数`electricDipoleMoment`计算任意电荷分布的电偶极矩。

electricDipoleMoment :: ChargeDistribution -> Vec
electricDipoleMoment (PointCharge q r)
= q *^ displacement origin r
electricDipoleMoment (LineCharge lambda c)
= vectorLineIntegral (curveSample 1000) (\r -> lambda r *^ rVF r) c
electricDipoleMoment (SurfaceCharge sigma s)
= vectorSurfaceIntegral (surfaceSample 200) (\r -> sigma r *^ rVF r) s
electricDipoleMoment (VolumeCharge rho v)
= vectorVolumeIntegral (volumeSample 50) (\r -> rho r *^ rVF r) v
electricDipoleMoment (MultipleCharges ds )
= sumV [electricDipoleMoment d | d <- ds]


该函数使用模式匹配对输入进行拆分,依据分布的构造器将定义划分为不同的情况。点电荷的偶极矩是电荷与从原点到点电荷位置的位移向量的乘积。对于线电荷,我们进行向量线积分,类似于方程 24.4。表面电荷需要进行表面积分,体电荷则使用方程 24.4 本身。最后,组合分布的偶极矩是每个组成部分的偶极矩的向量和。

另一种表现得像电偶极子的电荷分布是线电荷,其线电荷密度平滑地从负值变化到正值。函数`lineDipole`生成这样的分布,线电荷密度随位置线性变化。偶极子位于原点,那里线电荷密度为 0。

lineDipole :: Vec -- dipole moment
-> R -- charge separation
-> ChargeDistribution
lineDipole p sep
= let disp = (sep/2) *^ (p ^/ magnitude p)
curve = straightLine (shiftPosition (negateV disp) origin)
(shiftPosition disp origin)
coeff = 12 / sep**3
lambda r = coeff * (displacement origin r <.> p)
in LineCharge lambda curve


该函数接受与`simpleDipole`相同的输入,并确定产生所需电偶极矩所需的线电荷密度。

在下一章,我们将看到几个电偶极子的示例。我们将研究由两个点粒子组成的简单偶极子、理想偶极子和线偶极子,并比较它们的电场,注意它们的共性。

### 概述

本章介绍了电荷分布,包括线电荷、面电荷和体电荷。我们编写了一个电荷分布的数据类型,能够处理点电荷、线电荷、面电荷、体电荷以及这些的组合。我们编写了一些电荷分布的示例,并编写了计算电荷分布总电荷和电偶极矩的函数。在下一章,我们将计算电荷分布产生的电场。

### 练习

**练习 24.1.** 使用函数`chargedLine`和`chargedBall`创建一些电荷分布,并通过`totalCharge`确认它们具有你预期的总电荷。

**练习 24.2.** 求解平行板电容器`diskCap`的总电荷和电偶极矩,选择半径、板间距和表面电荷密度的参数。通过变化这些参数,尝试确定总电荷和电偶极矩如何依赖于半径、板间距和表面电荷密度。

**练习 24.3.** 写出一个均匀带电表面的电荷分布,该表面为圆盘形,总电荷为`q`,半径为`radius`。

chargedDisk :: Charge -> R -> ChargeDistribution
chargedDisk q radius = undefined q radius


使用`totalCharge`检查你的分布是否具有你预期的总电荷。

**练习 24.4.** 写出一个均匀带电圆形(具有恒定线电荷密度)的电荷分布,总电荷为`q`,半径为`radius`。

circularLineCharge :: Charge -> R -> ChargeDistribution
circularLineCharge q radius = undefined q radius


使用`totalCharge`检查你的分布是否具有你预期的总电荷。

**练习 24.5.** 写出一个均匀带电表面的电荷分布,该表面为正方形,总电荷为`q`,边长为`side`。

chargedSquarePlate :: Charge -> R -> ChargeDistribution
chargedSquarePlate q side = undefined q side


使用`totalCharge`检查你的分布是否具有你预期的总电荷。

**练习 24.6.** 写出一个均匀带电表面的电荷分布,该表面为球形,总电荷为`q`,半径为`radius`。

chargedSphericalShell :: Charge -> R -> ChargeDistribution
chargedSphericalShell q radius = undefined q radius


使用`totalCharge`检查你的分布是否具有你预期的总电荷。

**练习 24.7.** 写出一个均匀带电体积的电荷分布,该体积为立方体,总电荷为`q`,边长为`side`。

chargedCube :: Charge -> R -> ChargeDistribution
chargedCube q side = undefined q side


使用`totalCharge`检查你的分布是否具有你预期的总电荷。

**练习 24.8.** 使用函数`simpleDipole`和`lineDipole`创建一些电荷分布,并通过`electricDipoleMoment`确认它们具有你预期的电偶极矩。

**练习 24.9.** 写出一个平行板电容器的电荷分布,其中电容器的两块板是正方形表面,边长为`side`,板间距为`plateSep`。一块板具有均匀的表面电荷密度`sigma`,另一块板具有均匀的表面电荷密度`-sigma`。

squareCap :: R -> R -> R -> ChargeDistribution
squareCap side plateSep sigma = undefined side plateSep sigma


**练习 24.10.** 一个氢原子在其基态中,由一个静止的质子位于原点和一个具有体积电荷密度的电子云组成。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/472equ01.jpg)

其中 *e* 是质子的基本电荷,*a*[0] 是玻尔半径。为这个氢原子写出一个电荷分布。由于我们的体积是有限的,使用半径为 10[*a*0] 的球体作为包含电荷密度的体积。这将忽略电子负电荷的一小部分。通过使用 `totalCharge` 来检查你写出的电荷分布,看看你的氢原子有多接近中性。

hydrogen :: ChargeDistribution
hydrogen = undefined



# 第二十五章:电场

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

在 19 世纪,法拉第和麦克斯韦发现了一种新的思考电现象(以及磁现象)的方法,这为今天的电磁理论奠定了基础。在这种 19 世纪的观点中,一个粒子并不会像库仑定律所暗示的那样直接施加力于另一个粒子。相反,一个粒子会创建一个*电场*,这个电场施加力于第二个粒子。

我们将从简短的讨论电场究竟是什么开始本章内容。接着,我们将展示如何计算前一章中研究的每一种电荷分布所产生的电场。我们将从点电荷产生的电场开始,因为这实际上是控制所有其他电荷分布的基本因素。介绍了单个点电荷后,我们将探讨由多个点电荷产生的电场。作为一个例子,我们将比较由由两个点电荷组成的简单电偶极子与理想电偶极子产生的电场。

要计算电场,我们需要引入一些新的数学工具。计算由线电荷产生的电场需要矢量线积分;计算由面电荷产生的电场需要矢量面积分;计算由体电荷产生的电场需要矢量体积分。我们会根据需要引入这些工具,并在本章结束时介绍如何对曲线、面和体进行采样或离散化,这将使我们能够进行数值线积分、面积分和体积分。

### 什么是电场?

电场是我们在第二十二章讨论过的*矢量场*。电场将一个矢量**E**(**r**)与空间中每个点**r**关联;如果在空间的点**r**处有一个粒子,这个矢量帮助决定粒子所受的力。

电场是一个物理实体,还是我们用来思考电现象的抽象概念?我认为它两者兼具。电场是一个抽象的数学构造,就像现代理论物理学中许多概念一样。我们用数学语言来描述它,并假设一些公理,好像它的物理实在性不比一个由无理数构成的 7×7 矩阵更真实。

在静态情况下,电荷没有运动或加速时,我们可以对电场的真实性保持模棱两可的态度,甚至可以否定它。在这些静态情况下,库仑定律做出了良好的预测,而描述电场的新的电磁理论则做出了相同的预测。因此,在静态情况下,电场可以被看作仅仅是一个抽象。

然而,在动态情况下,当电荷在运动和/或加速时,我们就很难再坚持电场仅仅是一个计算工具的观点。原因在于,现代电磁理论除了是电和磁的理论外,还是光和辐射的理论。麦克斯韦的洞察力在于,电场和磁场可以用来描述可见光以及整个非可见光谱的光波,包括无线电波和微波。这些波现在被视为电场和磁场中的波动。根据法拉第-麦克斯韦理论,光是一种电磁波。由于光和辐射是物理的、真实的,因此电场也似乎是物理的、真实的。

引入电场将电气情况的分析分为两部分。第一部分是电荷产生电场,这是我们将在本章中讨论的内容。第二部分是电场对(第二个)电荷施加的力,这是我们将在第二十八章中讨论的内容。图 25-1 展示了电场在两个电荷情境中的作用。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/474equ01.jpg)

*图 25-1:当两个带电粒子存在时,电场的作用概念图。粒子 1 产生电场,电场对粒子 2 施加力。*

我们说电场*介导*了两个粒子之间的相互作用。在本章中,我们主要关注由静止电荷或电荷分布产生的电场。当电荷快速移动或加速时,电场概念的实际好处就显现出来,因为库仑定律在这些情况下无法作出准确预测。一般来说,我们将在第二十九章讨论的麦克斯韦方程描述了电场的产生和演化。麦克斯韦方程描述了电荷在任何形式运动中产生的电场,并且当电荷运动或加速时,它们会做出不同于库仑理论的预测。我们在更简单的静态情形下引入电场的概念,将有助于我们在后续讨论麦克斯韦方程时更加得心应手。

### 前言代码

清单 25-1 展示了我们将在本章中编写的`ElectricField`模块的第一行代码。

{-# OPTIONS -Wall #-}

module ElectricField where

import SimpleVec
( R, Vec, (+), (-), (^), (^), (^/), (<.>), (><)
, sumV, magnitude, vec, xComp, yComp, zComp, kHat )
import CoordinateSystems
( Position, ScalarField, VectorField
, displacement, shiftPosition, addVectorFields
, cart, sph, vf3D, vfPNGxy, vfGrad, origin, rVF )
import Geometry ( Curve(..), Surface(..), Volume(..) )
import Charge
( Charge, ChargeDistribution(..)
, diskCap, protonOrigin, simpleDipole, lineDipole )


*清单 25-1:电场模块的开头代码行*

我们使用了来自第十章的`SimpleVec`模块、第二十二章的`CoordinateSystems`模块、第二十三章的`Geometry`模块和第二十四章的`Charge`模块中的类型和函数。

### 电荷产生电场

现代电学的两部分观点的第一部分是电荷产生电场。我们想要计算由各种电荷产生的电场。我们将从最简单的电荷分布——点电荷开始,然后转向更复杂的电荷分布。

#### 点电荷产生的电场

一个带电量为*q*[1]的粒子位于位置**r**[1],将根据以下方程产生电场**E**:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/476equ01.jpg)

电场**E**是从位置到向量的函数(换句话说,是一个向量场)。

一个正点电荷产生的电场指向远离正电荷的方向。一个负点电荷产生的电场指向指向负电荷的方向。

我们在第二十一章中介绍了常数*ϵ*[0],称为*真空的介电常数*;它的定义为

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/476equ02.jpg)

其中,*c*是真空中的光速,*μ*[0]是一个常数,称为*真空的磁导率*。

epsilon0 :: R
epsilon0 = 1/(mu0 * cSI**2)


真空中的光速是* c * = 299792458 m/s(米的定义是光在真空中在 1/299792458 秒内传播的距离)。

cSI :: R
cSI = 299792458 -- m/s


在 2019 年国际单位制(SI)修订之前,常数*μ*[0]被定义为精确值*μ*[0] = 4*π* × 10^(–7) N/A²。2019 年修订选择了其他常数为精确值,留下*μ*[0]由实验确定。然而,它仍然非常接近这个值。

mu0 :: R
mu0 = 4e-7 * pi -- N/A²


我们有*ϵ*[0]和*μ*[0]是因为我们测量电场和磁场(或电压和电流)所使用的单位来源于实验,实验测量了电流和电荷之间的力。这些实验早于基于麦克斯韦方程的现代电磁理论。在麦克斯韦方程的框架下保持常规单位需要*ϵ*[0]和*μ*[0]来使单位得以统一。如果你愿意放弃电流和电压的常规单位(安培和伏特),你可以不使用*ϵ*[0]和*μ*[0]写出麦克斯韦方程。

函数`eFieldFromPointCharge`编码了方程 25.1,输入点粒子的电荷和位置,并输出电场。

eFieldFromPointCharge
:: Charge -- in Coulombs
-> Position -- of point charge (in m)
-> VectorField -- electric field (in V/m)
eFieldFromPointCharge q1 r1 r
= let k = 1 / (4 * pi * epsilon0)
d = displacement r1 r
in (k * q1) *^ d ^/ magnitude d ** 3


局部名称`d`表示从电荷位置**r**[1]到*场点* **r**的位移向量**r** – **r**[1]。场点是我们寻找或讨论电场的位置。场点处不需要存在任何粒子或物质。

在第二十四章中,我们为电荷分布写了一个类型。在本章中,我们将编写函数来计算由各种电荷分布产生的电场。这使我们能够将电荷产生电场的概念封装在以下函数中,该函数根据任意电荷分布产生电场。

eField :: ChargeDistribution -> VectorField
eField (PointCharge q r) = eFieldFromPointCharge q r
eField (LineCharge lam c) = eFieldFromLineCharge lam c
eField (SurfaceCharge sig s) = eFieldFromSurfaceCharge sig s
eField (VolumeCharge rho v) = eFieldFromVolumeCharge rho v
eField (MultipleCharges cds) = addVectorFields $ map eField cds


函数`eField`使用模式匹配对输入进行处理,以分别处理每种电荷分布类型。对于点电荷,它使用我们之前编写的函数`eFieldFromPointCharge`。对于线电荷、面电荷和体电荷,它使用我们将在本章后面编写的函数。对于具有构造函数`MultipleCharges`的组合分布,它使用*叠加原理*,即多个电荷产生的电场是由每个单独电荷产生的电场的矢量和。在这种情况下,我们使用来自第二十二章的函数`addVectorFields`来组合各组成分布的电场。

函数`eField`是一个显式递归函数。在定义的最后一行,我们可以看到`eField`是通过`eField`来定义的。我尽量避免编写显式递归函数,因为它们通常更难理解。在这种情况下,显式递归仅出现在`MultipleCharges`条款中。这意味着,当遇到多个电荷时,正确的做法是,首先找到每个组成电荷的电场(使用相同的`eField`函数,但可能是其他条款中的一个),其次将这些组成电场加在一起。

由质子在原点产生的电场由矢量场`eField protonOrigin`给出,其中`protonOrigin`是我们在第二十四章中为原点处的质子编写的电荷分布。图 25-2 展示了三种可视化质子产生的电场的方法。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/478fig01.jpg)

*图 25-2:三种可视化质子产生的电场 eField protonOrigin 的方法。左上图由 eFieldPicProton2D 生成,右上图由 eFieldPicProtonGrad 生成,下方图是由 eFieldPicProton3D 生成的 3D 交互式图像的截图。*

以下代码生成了图 25-2 左上方的图像。

eFieldPicProton2D :: IO ()
eFieldPicProton2D
= vfPNGxy "eFieldPicProton2D.png" 3e-9 pts (eField protonOrigin)
where
pts = [(r * cos th, r * sin th) | r <- [1,1.5,2]
, th <- [0,pi/4 .. 2*pi]]


样本点位于 xy 平面中,但通过点电荷的任何平面都会给出相同的图像。每个箭头代表箭头尾部位置的电场。在这张图中,电场强度与显示箭头的长度成正比。由于点电荷产生的电场与距离点电荷的平方成反比,因此当我们接近点电荷时,这些箭头的长度会变得非常长。因此,我们选择了合适的样本点和比例因子,以避免在图中出现过长的箭头。

由一个质子在距离 1 米的地方产生的电场如下所示:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/479equ01.jpg)

`3e-9`的尺度因子意味着在图像的尺度中,3 × 10^(-9) N/C 的电场显示为 1 米长的箭头。离质子的箭头距离为 1 米。它们的长度应该大约是(1.4 × 10^(-9) N/C)/(3 × 10^(-9) N/C),或者大约是从质子到矢量尾部的距离的一半,这在这张图中是成立的。

现在考虑以下代码,它生成了图 25-2 右上方的图片:

eFieldPicProtonGrad :: IO ()
eFieldPicProtonGrad
= vfGrad (**0.2) ((x,y) -> cart x y 0) (\v -> (xComp v, yComp v))
"eFieldPicProtonGrad.png" 20 (eField protonOrigin)


再次地,样本点位于 xy 平面中。每个箭头表示箭头中心位置的电场。在这张图中,电场在较暗的箭头处较强,在较亮的箭头处较弱。

最后,我们有以下代码,它生成了图 25-2 下方的图片:

eFieldPicProton3D :: IO ()
eFieldPicProton3D = vf3D 4e-9
[sph r th ph | r <- [1,1.5,2]
, th <- [0,pi/4..pi]
, ph <- [0,pi/4..2*pi]] (eField protonOrigin)


如果你运行程序`eFieldPicProton3D`,也许通过创建一个独立程序并命名为`main`,将会弹出一个 3D 矢量场。你可以用鼠标或指点设备移动并旋转它。

这结束了我们对单一点电荷产生的电场的讨论。接下来,我们将研究多个电荷的情况。

#### 由多个电荷产生的电场

方程 25.1 是由点电荷产生的电场的基本方程。叠加原理表明,多个电荷产生的电场是每个电荷单独产生的电场的矢量和。对于一组点电荷,*i*标记粒子,*q[i]*是粒子*i*的电荷,**r**[*i*]是粒子*i*的位置,那么由这组电荷在场点**r**产生的电场为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/479equ02.jpg)

叠加原理已经在我们的`eField`函数中编码。当我们在一个电荷分布中有多个电荷时,它们会被标记为`MultipleCharges`数据构造器,这告诉`eField`函数应用叠加原理。在上一章中,我们在编写`simpleDipole`电荷分布时使用了这个`MultipleCharges`数据构造器。因此,尽管方程 25.2 是一个有用且重要的方程,告诉我们如何从多个点电荷中找到电场,但我们不需要编写额外的代码;叠加原理已经被`eField`函数自动应用。

作为多个电荷的第一个例子,我们将研究一个简单的电偶极子,由两个粒子组成:一个带正电,另一个带有大小相等但相反的负电荷。

##### 简单电偶极子的电场

在上一章中,我们介绍了`simpleDipole`,它是由两个带相反电荷的点粒子组成的电荷分布,粒子之间有一定的距离。氯化钠的电偶极矩为 2.99 × 10^(–29) C·m。钠原子和氯原子之间的原子间距离为 2.36 × 10^(–10) m。如果我们将 NaCl 视为由两个点粒子组成,钠原子的有效电荷大约是质子电荷的 0.8 倍,氯原子的有效电荷大约是质子电荷的–0.8 倍。由于电子在离子之间共享,有效电荷不是基本电荷的整数倍。以下是 NaCl 的电荷分布,视为简单电偶极子:

simpleDipoleSodiumChloride :: ChargeDistribution
simpleDipoleSodiumChloride = simpleDipole (vec 0 0 2.99e-29) 2.36e-10


要计算此电荷分布的电场,我们只需使用`eField`函数。由于`simpleDipole`是由`MultipleCharges`构造的两`PointCharge`点电荷组成的电荷分布,`eField`函数首先遇到`MultipleCharges`构造器,并使用该条款进行定义。`MultipleCharges`条款指示首先计算每个点电荷的电场,它通过使用`eField`自身来完成这一步骤,但这次是通过`PointCharge`构造器。因此,`eField`实际上被使用了三次。我们使用它一次,但它自己再调用两次来计算每个点电荷的电场,然后将结果加起来并返回给我们。

eFieldSodiumChloride :: VectorField
eFieldSodiumChloride = eField simpleDipoleSodiumChloride


图 25-3 左侧的图片显示了 NaCl 产生的电场。电场从图片顶部的带正电钠原子指向图片底部带负电氯原子。右侧图片显示了由理想偶极子产生的电场,理想偶极子的描述将在下一节中给出。我们将它们并排展示在一张图中,以便观察它们的相同点和不同点。图片的中央部分不同,因为电荷位于不同的位置;而图片的外部部分相似,任何电偶极子在距离源稍远的地方都会产生相同的电场模式。

![图像](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/481fig01.jpg)

*图 25-3:由简单偶极子(左)和理想偶极子(右)产生的电场。简单偶极子由一个正点电荷和一个负点电荷组成。左侧图像显示了由 eFieldSodiumChloride 产生的电场,电场由 eFieldPicSimpleDipole 产生;右侧图像显示了由 eFieldIdealDipole kHat 产生的电场,电场由 eFieldPicIdealDipole 产生。*

下面是产生图 25-3 左侧图片的代码:

eFieldPicSimpleDipole :: IO ()
eFieldPicSimpleDipole
= vfGrad (**0.2) ((y,z) -> cart 0 (3e-10y) (3e-10z))
(\v -> (yComp v, zComp v)) "eFieldPicSimpleDipole.png" 20
eFieldSodiumChloride


该图显示了 yz 平面中的一个正方形,其中*y*的范围从–3 × 10^(–10) m 到 3 × 10^(–10) m,而*z*的范围也是相同的。这是通过将`vfGrad`的第二个输入进行映射实现的,该映射将`y`和`z`的参数缩放为`3e-10`。请记住,我们在第二十二章中编写的`vfGrad`函数的第二个输入,通过将从(–1,–1)到(1,1)的正方形映射到我们希望可视化的区域,来指定感兴趣的区域。

##### 理想电偶极子

等量但相反电荷的粒子是电偶极子的一个例子。*理想电偶极子*是由两个粒子之间的距离趋近于零,而电荷的大小增大,使得电偶极矩保持不变,从而形成电场源。让我们来看一下理想电偶极子产生的电场。

由理想电偶极子在原点产生的电场是

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/481equ01.jpg)

其中**p**是电偶极矩。这里是用 Haskell 表示的:

eFieldIdealDipole :: Vec -- electric dipole moment
-> VectorField -- electric field
eFieldIdealDipole p r
= let k = 1 / (4 * pi * epsilon0) -- SI units
rMag = magnitude (rVF r)
rUnit = rVF r ^/ rMag
in k *^ (1 / rMag**3) *^ (3 *^ (p <.> rUnit) *^ rUnit - p)


图 25-3 的右侧显示了由理想电偶极子产生的电场。电偶极矩**p**的大小在这个图像中并不重要,因为最暗的箭头表示电场最强的地方,尽管电场的大小可能不同。方程 25.3 表明,电场随偶极矩线性增加,因此对于 z 方向上的任何电偶极矩,图像都是相同的。

比较图 25-3 中的两个图像,我们看到电场在图像中心处有所不同,接近电场源的位置。在图像边缘,远离电场源的区域,两个图像中的电场非常相似。电场在离源稍远的地方的相似性使得这两种源都可以被称为电偶极子。

以下是生成图 25-3 右侧图片的代码:

eFieldPicIdealDipole :: IO ()
eFieldPicIdealDipole
= vfGrad (**0.2) ((y,z) -> cart 0 (3e-10y) (3e-10z))
(\v -> (yComp v, zComp v)) "eFieldPicIdealDipole.png" 20
(eFieldIdealDipole kHat)


与`eFieldPicSimpleDipole`相比,该程序在 NaCl 中的唯一区别是文件名和电场。这里的电场是`eFieldIdealDipole kHat`,我们使用单位向量`kHat`表示电偶极矩,因为它的大小不会改变图像。

我们已经看到了由多个点电荷产生的电场示例,并将其与理想电偶极子的电场进行了比较。连续电荷分布可以看作是多个电荷的一个特例,其中有许多电荷分布在某个区域上。在我们转向如何计算特定连续电荷分布产生的电场之前,有一些一般性的说明可以做。

##### 连续分布

当我们从离散的点电荷转向连续分布的电荷时,我们将方程 25.2 中的和替换为积分。写这个积分的一般方法如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/482equ01.jpg)

在方程式 25.4 中,我们已经将离散量替换为连续量,正如表 25-1 所示,表中显示了离散量和连续量之间的对应关系。

**表 25-1:** 电场中离散和连续量的对应关系

|  | **离散** | **连续** |
| --- | --- | --- |
| 聚合方法 | ∑[*i*] | ∫ |
| 电荷量 | *q[i]* | *dq*′ |
| 电荷位置 | **r**[*i*] | **r**′ |

方程式 25.4 中积分的形式是如此一般,以至于我们甚至没有明确规定电荷是分布在一维曲线、二维表面,还是三维体积中。无论如何,积分是通过一个极限过程来定义的,其中电荷量*dq*′变得无限小,电荷数目变得无限大。

有时我们可以精确计算这样的积分;然而,更多时候,我们需要计算积分的近似值。我们通过将连续积分转化为离散和来进行计算,基本上是通过将方程式 25.4 转回方程式 25.2。具体细节取决于我们是在对 1D 曲线、2D 表面还是 3D 体积进行积分。我们将在接下来的各节中依次讨论这些情况。

#### 由线电荷产生的电场

正如我们在上一章讨论的,线电荷由曲线*C*和标量场*λ*指定,*λ*表示曲线上任何点的线电荷密度。当电荷沿一维曲线分布时,在位置**r**′的小电荷*dq*′由线电荷密度*λ*(**r**)与曲线段的小长度*dl*′的乘积给出。

*dq*′ = *λ*(**r**′) *dl*′

然后我们将方程式 25.4 的积分写作如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/483equ01.jpg)

这种对向量场在曲线上的积分称为*向量线积分*。让我们更详细地解释一下向量线积分。

##### 向量线积分

向量线积分将向量场和曲线作为输入,并返回一个向量作为输出。

type VectorLineIntegral = VectorField -> Curve -> Vec


向量场**F**在曲线*C*上的向量线积分表示为

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/484equ01.jpg)

这个积分是什么意思?积分的定义是通过将曲线*C*分成许多小段来实现的。向量场**F**在每个点**r**[*i*](位于或接近段Δ**l**[*i*])处进行评估,并且按段长度Δ*l[i]*进行缩放(乘以)。然后我们将这些向量相加形成和

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/484equ02.jpg)

这个积分是当段长度趋近于 0 并且段数变得无限大的时候,这个向量和的极限。积分的定义涉及对极限过程的精确定义,这部分内容我们将留给向量微积分的教材。

这个积分不仅通过有限和定义,还通过有限和进行逼近。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/484equ03.jpg)

我们对积分的近似计算将使用有限数量的段。我们需要一种方法,通过有限的段列表来近似曲线。我们将把段Δ**l**[*i*]表示为沿曲线的短位移矢量。除了描述段长度和方向的位移矢量Δ**l**[*i*],我们还需要一个位置**r**[*i*]来表示该段在曲线上的位置。曲线的近似是由位置和位移矢量的对组成的列表。注意,段的长度不必相同。曲线近似方法是一个函数,当给定一条曲线时,会返回这样的列表。

type CurveApprox = Curve -> [(Position,Vec)]


一对看起来像是(**r**[*i*], Δ**l**[*i*])。我们可以使用许多曲线近似方法;我们将在本章最后一节讨论曲线近似方法。

这是矢量线积分的 Haskell 定义:

vectorLineIntegral :: CurveApprox -> VectorField -> Curve -> Vec
vectorLineIntegral approx vF c
= sumV [vF r' ^* magnitude dl' | (r',dl') <- approx c]


曲线`c`由输入函数`approx`进行近似,该函数会指定曲线划分的段数、划分方法以及各个段的相关位置。对于每个在位置`r'`的段`dl'`,矢量场`vF`将在位置`r'`进行评估,并按`dl'`的大小进行缩放。然后,我们将这些向量相加,从而得到积分的近似值。表 25-2 显示了数学符号、离散数学符号和 Haskell 符号在定义矢量线积分时的对应关系。

**表 25-2:** 连续数学符号、离散数学符号和 Haskell 符号在矢量线积分中的对应关系

| **连续数学** | **离散数学** | **Haskell** |
| --- | --- | --- |
| ∫ | ∑[*i*] | `sumV` ➊ |
| **r**′ | **r**[*i*] | `r'` ➋ |
| **F** | **F** | `vF` |
| **F**(**r**′) | **F**(**r**[*i*]) | `vF r'` |
| *C* |  | `c` ➌ |
| *dl*′ | Δ**l**[*i*] | `dl'` ➍ |
| *dl*′ | Δ*l[i]* | `magnitude dl'` |
| **F**(**r**′)*dl*′ | **F**(**r**[*i*])Δ*l[i]* | `vF r' ^* magnitude dl'` |

出人意料的是,Haskell 符号在求和符号部分➊上与离散符号更为接近。我们为段位置➋、曲线➌和段位移➍选择的 Haskell 名称更接近于连续符号。这是因为 Haskell 不需要离散符号中使用的索引*i*,所以没有必要引入它。虽然 Haskell 以离散的方式计算积分,但 Haskell 的列表语法避免了谈论需要编号列表元素的索引*i*。

##### 回到电场

从方程 25.5 中,我们看到,想要积分以找到由线电荷产生的电场的矢量场**F**,是将电荷某部分的位置**r**′映射到矢量的函数。

![图像](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/485equ01.jpg)

其中,**r**是场点,即我们希望知道电场的固定位置。在下面的`eFieldFromLineCharge`函数中,这个函数被赋予了局部名称`integrand`。局部名称`d`表示从源点**r**′到场点**r**的位移**r** – **r**′。由于源点`r'`作为`integrand`函数的局部名称引入,因此我们必须通过`where`子句来定义`d`,而不是与`k`和`integrand`的定义一起给出。

eFieldFromLineCharge
:: ScalarField -- linear charge density lambda
-> Curve -- geometry of the line charge
-> VectorField -- electric field (in V/m)
eFieldFromLineCharge lambda c r
= let k = 1 / (4 * pi * epsilon0)
integrand r' = lambda r' *^ d ^/ magnitude d ** 3
where d = displacement r' r
in k *^ vectorLineIntegral (curveSample 1000) integrand c


要找到线电荷产生的电场,我们只需要提供两个信息:线电荷密度(作为标量场表示)和描述线电荷几何形状的曲线。函数`eFieldFromLineCharge`的类型签名明确表明,电场只依赖于这两个参数。我们使用之前定义的`vectorLineIntegral`。曲线近似方法`curveSample 1000`将曲线分成 1000 段,并将在本章稍后定义。

现在我们已经展示了如何找到线电荷的电场,接下来让我们看看前一章讨论的线偶极子`lineDipole`所产生的电场。

##### 线偶极子的示例

在上一章中,我们介绍了`lineDipole`,它是一个具有线性变化电荷密度的线电荷。假设我们有理由相信,NaCl 的电荷分布看起来更像是线偶极子而非简单的偶极子。我没有这样的证据,而且 NaCl 的电荷分布可能很复杂,但假设电荷密度从钠到氯是平滑变化的(即使不是线性变化),这一点是完全合理的。为了将 NaCl 建模为一个线偶极子,我们可以使用之前为简单偶极子计算的电偶极矩和原子间距。这里是 NaCl 作为线偶极子时的电荷分布:

lineDipoleSodiumChloride :: ChargeDistribution
lineDipoleSodiumChloride = lineDipole (vec 0 0 2.99e-29) 2.36e-10


我们可以使用`eField`函数来找到这个电荷分布的电场,在这种情况下,它将使用我们之前写的`eFieldFromLineCharge`函数。

eFieldLineDipole :: VectorField
eFieldLineDipole = eField lineDipoleSodiumChloride


练习 25.11 要求你绘制出这个电场的矢量场图,就像我们之前为简单偶极子和理想偶极子所做的那样。

既然我们已经讨论了如何从第一个连续电荷分布(即线电荷)中找到电场,并展示了一个示例,那么我们接下来将讨论第二个连续电荷分布——表面电荷。

#### 表面电荷产生的电场

正如我们在上一章讨论的,表面电荷是由一个表面*S*和一个标量场*σ*指定的,后者表示表面上任何点的表面电荷密度。当电荷分布在二维表面上时,位置**r**′处的小电荷*dq*′由表面电荷密度*σ*(**r**′)和靠近**r**′的表面小区域*da*′的面积的乘积给出。

*dq*′ = σ(**r**′) *da*′

我们将方程 25.4 的积分写为如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/487equ01.jpg)

这种对曲面上向量场的积分称为*向量曲面积分*。让我们更详细地解释向量曲面积分。

##### 向量曲面积分

向量曲面积分接受一个向量场和一个曲面作为输入,返回一个向量作为输出。

type VectorSurfaceIntegral = VectorField -> Surface -> Vec


向量场**F**在曲面*S*上的向量曲面积分写作:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/487equ02.jpg)

我们通过将曲面*S*划分为许多小贴片来定义积分。你可以将贴片看作一个四边形,但真正重要的是贴片的位置、面积和方向,而不是其形状。每个贴片Δ**a**[*i*]都是一个向量面积,其大小给出贴片的面积,方向垂直于贴片。我们假设每个贴片足够小,可以视为平坦的。由于我们的曲面是有方向的,正如我们在第二十三章中讨论的那样,贴片的方向是明确的。向量场**F**在贴片Δ**a**[*i*]附近的点**r**[*i*]上进行评估,并按贴片面积*Δa[i]*进行缩放。然后我们将这些向量相加,得到和

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/487equ03.jpg)

积分是这个向量和的极限,当贴片的面积趋近于 0 且贴片数量变得无限大时。

积分不仅通过有限和进行近似定义,还通过有限和来近似计算。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/487equ04.jpg)

我们对积分的近似计算将使用有限数量的贴片。我们需要一种方法将一个曲面划分成一系列贴片。我们将用一个垂直于曲面的向量来表示一个有向贴片Δ**a**[*i*]。除了描述贴片面积和方向的向量Δ**a**[*i*],我们还需要一个位置**r**[*i*],表示贴片在曲面上的位置。曲面的近似由一系列位置和面积向量的配对组成。

type SurfaceApprox = Surface -> [(Position,Vec)]


一个配对看起来像是(**r**[*i*], Δ**a**[*i*])。曲面近似的方式有很多,我们将在本章最后一节讨论这一点。

这是向量曲面积分的 Haskell 定义:

vectorSurfaceIntegral :: SurfaceApprox -> VectorField -> Surface -> Vec
vectorSurfaceIntegral approx vF s
= sumV [vF r' ^* magnitude da' | (r',da') <- approx s]


曲面`s`通过函数`approx`进行近似,`approx`作为输入提供。该函数将指定曲面被划分为多少个贴片,并且确定这些贴片的划分方法以及与之相关的位置。对于每个位置为`r'`的贴片`da'`,我们在位置`r'`评估向量场`vF`,并按`da'`的大小进行缩放。然后我们将这些向量相加,从而得到积分的近似值。表 25-3 展示了向量曲面积分的数学符号与 Haskell 符号之间的对应关系。

**表 25-3:** 连续数学符号、离散数学符号和 Haskell 符号之间的对应关系,适用于向量曲面积分

| **连续数学** | **离散数学** | **Haskell** |
| --- | --- | --- |
| ∫ | ∑[*i*] | `sumV` |
| **r**′ | **r**[*i*] | `r'` |
| **F** | **F** | `vF` |
| **F**(**r**′) | **F**(**r**[*i*]) | `vF r'` |
| *S* |  | `s` |
| *d***a**′ | Δ**a**[*i*] | `da'` |
| *da*′ | Δ*a[i]* | `magnitude da'` |
| **F**(**r**′)*da*′ | **F**(**r**[*i*])Δ*a[i]* | `vF r' ^* magnitude da'` |

表格 25-3 的前四行与表格 25-2 的前四行相同,因为它们都涉及向量场和积分。该表的最后四行与前一个表类似,只不过是用表面小块代替了线段。

##### 回到电场

从方程 25.7 中,我们看到我们想要积分以找到由线电荷产生的电场的向量场**F**是一个将电荷片段的位置**r**′映射到向量的函数。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/488equ01.jpg)

在这里,我们考虑**r**是我们想要知道电场的固定位置。在接下来的`eFieldFromSurfaceCharge`函数中,这是方程 25.7 的 Haskell 翻译,我们给这个函数取了本地名称`integrand`。本地名称`d`表示从电荷到场点的位移**r** – **r**′。由于我们将源点`r'`作为`integrand`函数的局部名称引入,因此必须使用`where`子句来定义`d`,而不是与`k`和`integrand`的定义一起定义。

eFieldFromSurfaceCharge
:: ScalarField -- surface charge density sigma
-> Surface -- geometry of the surface charge
-> VectorField -- electric field (in V/m)
eFieldFromSurfaceCharge sigma s r
= let k = 1 / (4 * pi * epsilon0)
integrand r' = sigma r' *^ d ^/ magnitude d ** 3
where d = displacement r' r
in k *^ vectorSurfaceIntegral (surfaceSample 200) integrand s


为了找到表面电荷产生的电场,我们只需要给出两个项目:表面电荷密度*σ*,它作为标量场表示,和描述表面电荷几何形状的表面。`eFieldFromSurfaceCharge`的类型签名清楚地表明,电场仅依赖于这两个项目。我们使用之前定义的`vectorSurfaceIntegral`。函数`surfaceSample 200`将曲线分成 2(200)² = 80,000 个小块,并在本章后面定义。

现在我们已经展示了如何找到表面电荷产生的电场,让我们来看一下上一章讨论过的电容器`diskCap`产生的电场。

##### 电容器示例

让我们找出由平行板电容器产生的电场,其中电容器的板均匀带电。当板距离较近时,均匀电荷的假设是合理的,但随着板间距离增大,这个假设会变得不太准确。假设我们有一个板间距为 4 厘米的电容器,其中的板是半径为 5 厘米的圆盘。正板的表面电荷密度为 20 nC/m²,负板的表面电荷密度为–20 nC/m²。表达式

diskCap 0.05 0.04 2e-8 :: ChargeDistribution


表示该电荷分布。

我们可以使用`eField`函数来找到电场。

eFieldDiskCap :: VectorField
eFieldDiskCap = eField $ diskCap 0.05 0.04 2e-8


在这种情况下,`eField`使用我们之前定义的`eFieldFromSurfaceCharge`函数。首先,让我们来看一下电容器中心的电场。

Prelude> :l ElectricField
[ 1 of 12] Compiling Newton2 ( Newton2.hs, interpreted )
[ 2 of 12] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[ 3 of 12] Compiling SimpleVec ( SimpleVec.hs, interpreted ) [ 4 of 12] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[ 5 of 12] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[ 6 of 12] Compiling MOExamples ( MOExamples.hs, interpreted )
[ 7 of 12] Compiling Electricity ( Electricity.hs, interpreted )
[ 8 of 12] Compiling CoordinateSystems ( CoordinateSystems.hs, interpreted )
[ 9 of 12] Compiling Geometry ( Geometry.hs, interpreted )
[10 of 12] Compiling VectorIntegrals ( VectorIntegrals.hs, interpreted )
[11 of 12] Compiling Charge ( Charge.hs, interpreted )
[12 of 12] Compiling ElectricField ( ElectricField.hs, interpreted )
Ok, 12 modules loaded.
*ElectricField> eFieldDiskCap (cart 0 0 0)
vec 0.0 0.0 (-1419.9046806406095)


电场朝负 z 方向,指向上面的正极板,朝向下面的负极板。

为了比较,一个具有无限宽板和平行板电容器,其表面电荷密度为*σ*,其电场为*σ*/*ϵ*[0]。物理学家喜欢理想的平行板电容器,因为它能简单地表达其产生的电场。理想电容器外部的电场为 0。理想电容器内部(两个板之间)的电场值均匀,为*σ*/*ϵ*[0],并且方向从正极板指向负极板。所产生的电场与板间距无关。对于表面电荷密度*σ* = 20 nC/m²,电场的大小将是

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/490equ01.jpg)

我们之前找到的值小于这个理想值(在量值上),因为我们的电容板半径相对较小。

图 25-4 显示了我们盘形电容器产生的电场。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/490fig01.jpg)

*图 25-4:由平行板电容器产生的电场 eFieldDiskCap。电容板具有均匀的表面电荷密度。(图像由 eFieldPicDiskCap 生成。)*

图 25-4 显示了 x 方向上从–10 cm 到 10 cm 的区域,以及 z 方向上从–10 cm 到 10 cm 的区域。盘形电容器的半径为 5 cm,因此它们水平延伸,覆盖了图中一半的宽度。电场的强度在箭头最黑的地方最大,即板之间。电场在板外部较小,但并非为 0。电场在板之间看起来相当均匀。我们从阴影中可以看到电场如何从板间的最大值过渡到接近盘边缘的中等值,再到离板更远处的最小值。在图的外围,电场看起来像是电偶极子的电场,这并不奇怪,因为该电容器本身就是由正负两块电板组成的电偶极子。

这是生成图 25-4 的代码:

eFieldPicDiskCap :: IO ()
eFieldPicDiskCap = vfGrad (**0.2) ((x,z) -> cart (0.1x) 0 (0.1z))
(\v -> (xComp v, zComp v)) "eFieldPicDiskCap.png" 20
eFieldDiskCap


该程序可能需要几分钟才能运行。表面和体积电荷的积分涉及大量计算,可能会比较慢。这里使用的方法在概念上简单,但数值效率较低。慢速主要是由于简单方法的使用,而非 Haskell 语言本身的局限性。实际上,存在一些数据结构,如无包装向量,可以加速许多操作,但这会使代码变得不那么简洁。

我们之前讨论过线电荷和表面电荷。现在让我们讨论第三种也是最后一种连续电荷分布:体积电荷。

#### 由体积电荷产生的电场

正如我们在上一章中讨论的那样,体积电荷由一个体积 *V* 和一个标量场 *ρ* 指定,该标量场表示体积中任意一点的体积电荷密度。当电荷分布在三维体积中时,位置 **r**′ 处的一个小电荷 *dq*′ 由体积电荷密度 *ρ*(**r**′) 与靠近 **r**′ 的小部分体积 *dv*′ 的乘积给出。

*dq*′ = *ρ*(**r**′) *dv*′

然后我们将方程 25.4 的积分写成如下形式:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/491equ01.jpg)

这样的向量场在体积上的积分被称为 *向量体积积分*。让我们更详细地探讨向量体积积分。

##### 向量体积积分

向量体积积分以向量场和体积为输入,并返回一个向量作为输出。

type VectorVolumeIntegral = VectorField -> Volume -> Vec


向量场 **F** 在体积 *V* 上的向量体积积分表示如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/492equ01.jpg)

积分是通过将体积 *V* 分割成许多小部分来定义的。向量场 **F** 在每个点 **r**[*i*](在该部分上或附近)被评估,并按该部分的体积 Δ*v[i]* 进行缩放。然后我们将这些向量加起来,得到总和

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/492equ02.jpg)

这个积分是当体积部分趋近于 0 且部分数目变得无限大时,这个向量和的极限。

积分既通过有限和来定义,也通过有限和来近似:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/492equ03.jpg)

我们的积分近似计算将使用有限数量的部分。我们将通过其位置 **r**[*i*] 和体积 Δ*v[i]* 来表示一个部分。体积的近似由一组位置和部分体积的对构成。体积近似方法是一个函数,当给定体积时,它返回这样的列表。

type VolumeApprox = Volume -> [(Position,R)]


一对看起来像 (**r**[*i*], Δ*v[i]*)。可以使用许多体积近似方法,正如之前所说的,我们将把这个问题的讨论推迟到本章稍后。

这里是一个向量体积积分的 Haskell 定义:

vectorVolumeIntegral :: VolumeApprox -> VectorField -> Volume -> Vec
vectorVolumeIntegral approx vF vol
= sumV [vF r' ^* dv' | (r',dv') <- approx vol]


体积 `vol` 通过函数 `approx` 近似,给出一组部分位置和体积的列表。对于每个部分 `(r', dv')`,向量场 `vF` 在位置 `r'` 处进行评估,并按 `dv'` 进行缩放。然后我们将这些向量加起来,得到积分的近似值。

表 25-4 显示了向量体积积分中数学符号和 Haskell 符号之间的对应关系。

**表 25-4:** 连续数学符号、离散数学符号和 Haskell 符号在向量体积积分中的对应关系

| **连续数学** | **离散数学** | **Haskell** |
| --- | --- | --- |
| *V* |  | `vol` |
| *dv*′ | Δ*v[i]* | `dv'` |
| **F**(**r**′)*dv*′ | **F**(**r**[*i*])Δ*v[i]* | `vF r' ^* dv'` |

这个表格类似于表 25-3 的最后几行。两者之间的一个重要区别是,表面积分的每个小块是一个向量,而体积积分的每一小部分则是标量。

##### 回到电场

我们只需要给出两个参数来求解由体积电荷产生的电场:体积电荷密度*ρ*,它表示为标量场,以及描述电荷几何形状的体积。以下函数的类型签名,这是方程式 25.8 的 Haskell 翻译,清楚地表明电场仅依赖于这两个参数:

eFieldFromVolumeCharge
:: ScalarField -- volume charge density rho
-> Volume -- geometry of the volume charge
-> VectorField -- electric field (in V/m)
eFieldFromVolumeCharge rho v r
= let k = 1 / (4 * pi * epsilon0)
integrand r' = rho r' *^ d ^/ magnitude d ** 3
where d = displacement r' r
in k *^ vectorVolumeIntegral (volumeSample 50) integrand v


现在我们已经有了计算由线电荷、面电荷和体电荷产生的电场的函数,我们已经完成了本章开始时定义的`eField`。我们现在有了一种方法来求解由任何电荷分布产生的电场。在编写计算电场的函数时,我们花了一些时间讨论了三种类型的矢量积分:线电荷的矢量线积分、面电荷的矢量曲面积分和体电荷的矢量体积积分。通过将这些积分牢记在心,现在是时候将我们的积分方法扩展到标量积分了,这些积分在上一章中用于计算总电荷。

### 标量积分

在计算由线电荷、面电荷或体电荷产生的电场的过程中,我们引入了矢量线积分、矢量曲面积分和矢量体积积分。我们使用这些积分来将每一部分电荷对电场的矢量贡献加起来。有些情况下,我们需要将来自曲线、表面或体积的源的标量贡献加起来。这就是标量线积分、标量曲面积分和标量体积积分的目的。既然我们已经完成了矢量积分的细节,现在理解标量积分就相对容易了。

#### 标量线积分

标量线积分接受一个标量场*f*和一条曲线*C*作为输入,并返回一个标量作为输出。

type ScalarLineIntegral = ScalarField -> Curve -> R


积分的定义以及近似方法是通过将曲线*C*划分为许多小段,正如我们在计算矢量线积分时所做的那样。标量场*f*在每个点**r**[*i*]处被计算,并在段Δ**l**[*i*]的长度Δ*l[i]*上乘以,之后将其相加。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/494equ01.jpg)

这是标量线积分的 Haskell 定义:

scalarLineIntegral :: CurveApprox -> ScalarField -> Curve -> R
scalarLineIntegral approx f c
= sum [f r' * magnitude dl' | (r',dl') <- approx c]


曲线`c`通过输入的函数`approx`进行近似。对于每个在位置`r'`的段`dl'`,在位置`r'`处计算标量场`f`,并与`dl'`的大小相乘。然后,我们将这些数值相加,以得到积分的近似值。

#### 标量曲面积分

标量曲面积分接受一个标量场*f*和一个曲面*S*作为输入,并返回一个标量作为输出。

type ScalarSurfaceIntegral = ScalarField -> Surface -> R


积分是通过将曲面*S*分成许多小块来定义并进行近似的,正如我们在进行矢量曲面积分时所做的那样。在每个小块Δ**a**[*i*]的点**r**[*i*]处,评估标量场*f*,然后与小块的大小*Δa[i]*相乘,再将结果相加。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/495equ01.jpg)

这是标量曲面积分的 Haskell 定义:

scalarSurfaceIntegral :: SurfaceApprox -> ScalarField -> Surface -> R
scalarSurfaceIntegral approx f s
= sum [f r' * magnitude da' | (r',da') <- approx s]


曲面`s`通过函数`approx`进行近似。对于位置`r'`处的每个小块`da'`,在位置`r'`评估标量场`f`并与`da'`的大小相乘。然后我们将这些数值相加,从而得到积分的近似值。

#### 标量体积积分

标量体积积分接受一个标量场*f*和一个体积*V*作为输入,并返回一个标量作为输出。

type ScalarVolumeIntegral = ScalarField -> Volume -> R


积分是通过将体积*V*分成许多小部分来定义并进行近似的,正如我们在进行矢量体积积分时所做的那样。在每个部分中,标量场*f*在该部分处进行评估,并与该部分的体积Δ[v*i*]相乘,再将结果相加。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/495equ02.jpg)

这是标量体积积分的 Haskell 定义:

scalarVolumeIntegral :: VolumeApprox -> ScalarField -> Volume -> R
scalarVolumeIntegral approx f vol
= sum [f r' * dv' | (r',dv') <- approx vol]


体积`vol`通过函数`approx`进行近似。对于位置`r'`处的每一部分`dv'`,在位置`r'`评估标量场`f`并与`dv'`相乘。然后我们将这些数值相加,从而得到积分的近似值。

在我们离开本章之前,还有一个细节需要讨论,那就是当我们对这些形状进行积分时,用来近似曲线、曲面和体积的方法。现在让我们讨论这个细节。

### 近似曲线、曲面和体积

我们已经见过多种情况,我们希望通过线积分、曲面积分或体积积分对曲线、曲面或体积上的内容进行求和。当我们要求和的是矢量时,我们使用矢量线积分、矢量曲面积分或矢量体积积分。类似地,当我们要求和的是标量时,我们使用标量线积分、标量曲面积分或标量体积积分。无论是加标量还是加矢量,我们的方法都要求我们将曲线、曲面和体积近似为有限的数据列表。这个近似是本节的主题;尽管有多种方法可以进行近似,但我们将为每个几何对象提供一种近似方法。数值分析的研究内容是探索不同的近似方法,研究其中的权衡,并以巧妙和高效的方式进行处理。在我们的情况中,我们关注的是简单且易于理解的做法。

#### 近似曲线

记住,曲线近似是将曲线转化为位置和位移向量列表的一种方式。

-- introduced earlier in the Chapter
type CurveApprox = Curve -> [(Position,Vec)]


我们的函数`curveSample`通过多个段来逼近一条曲线,返回一个段的位置和位移向量的列表。

curveSample :: Int -> Curve -> [(Position,Vec)]
curveSample n c
= let segCent :: Segment -> Position
segCent (p1,p2) = shiftPosition ((rVF p1 + rVF p2) ^/ 2) origin
segDisp :: Segment -> Vec
segDisp = uncurry displacement
in [(segCent seg, segDisp seg) | seg <- segments n c]


该函数接受一个整数`n`,用于控制生成的段数,每个段由起始位置和结束位置组成。

type Segment = (Position,Position)


大部分工作由接下来的`segments`函数完成,当给定整数`n`和曲线`c`时,它返回一个段的列表。局部函数`segCent`找到每个段的中心。`rVF`向量场,在第二十二章中介绍,将位置转换为位移向量,接着`shiftPosition`函数对位移向量进行平均并转换回位置。

局部函数`segDisp`计算每个段的位移向量。位移向量从段的起始位置指向段的结束位置。函数`segDisp`是第二十二章中`displacement`的非柯里化版本,接受一对位置而非柯里化函数。

我们在局部定义了`segCent`和`segDisp`,因为它们在其他函数中并没有被使用。需要注意的是,我们仍然可以为它们提供类型签名,尽管这不是必须的。像这样的特定用途函数最好局部定义,因为这样可以减少全局命名空间中的内容,并帮助代码阅读者理解局部函数`segCent`与其父函数`curveSample`之间的关系。`segCent`是局部的这一事实提醒读者该函数不会在其他地方使用。

函数`segments`在给定曲线时返回一个段的列表。

segments :: Int -> Curve -> [Segment]
segments n (Curve g a b)
= let ps = map g $ linSpaced n a b
in zip ps (tail ps)


请注意,`segments`的第一个参数是整数`n`,控制生成多少个段。我们使用模式匹配将曲线`Curve g a b`作为输入传入,因为这个函数需要引用曲线的参数限制`a`和`b`,以及参数化函数`g`。该函数首先使用下面定义的`linSpaced`函数将曲线的参数区间`a`到`b`分成`n`个相等的小区间。在这些小区间的`n+1`个端点上,我们应用函数`g`,形成一个包含`n+1`个位置的列表`ps`。然后,我们将列表`ps`与其尾部配对,生成所需的`n`个段。将列表与其尾部配对会将第一个和第二个项、第二个和第三个项、第三个和第四个项,依此类推,配对起来。

函数`linSpaced`返回一个线性间隔的数字列表。

linSpaced :: Int -> R -> R -> [R]
linSpaced n x0 x1 = take (n+1) [x0, x0+dx .. x1]
where dx = (x1 - x0) / fromIntegral n


输入的`n`是间隔的数量,因此该函数返回一个包含`n+1`个数字的列表,起始值为`x0`,直到包括`x1`。由于`n`的类型是`Int`,而`x1 - x0`的类型是`R`,我们需要使用`fromIntegral`函数将`n`转换为`R`类型,然后才能进行除法操作。我们使用`take`函数处理初始值`x0`和最终值`x1`相同的情况,在这种情况下,`dx`为 0,算术序列是一个包含相同数字的无限列表。`take`函数只返回该无限列表的前`n+1`个元素。

这里有两个`linSpaced`的使用示例:

*ElectricField> :l ElectricField
[ 1 of 12] Compiling Newton2 ( Newton2.hs, interpreted )
[ 2 of 12] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[ 3 of 12] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[ 4 of 12] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[ 5 of 12] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[ 6 of 12] Compiling MOExamples ( MOExamples.hs, interpreted )
[ 7 of 12] Compiling Electricity ( Electricity.hs, interpreted )
[ 8 of 12] Compiling CoordinateSystems ( CoordinateSystems.hs, interpreted )
[ 9 of 12] Compiling Geometry ( Geometry.hs, interpreted )
[10 of 12] Compiling VectorIntegrals ( VectorIntegrals.hs, interpreted )
[11 of 12] Compiling Charge ( Charge.hs, interpreted )
[12 of 12] Compiling ElectricField ( ElectricField.hs, interpreted )
Ok, 12 modules loaded.
*ElectricField> linSpaced 4 0 2
[0.0,0.5,1.0,1.5,2.0]
*ElectricField> linSpaced 4 3 3
[3.0,3.0,3.0,3.0,3.0]


现在我们已经探索了近似曲线的一种方式,接下来让我们做同样的事情来近似表面。

#### 近似一个表面

请记住,表面近似是将一个表面转换为位置和向量面积的列表。

-- introduced earlier in the Chapter
type SurfaceApprox = Surface -> [(Position,Vec)]


我们的函数`surfaceSample`将一个表面近似为多个三角形,返回一个包含三角形位置和向量面积的列表。

surfaceSample :: Int -> Surface -> [(Position,Vec)]
surfaceSample n s = [(triCenter tri, triArea tri) | tri <- triangles n s]


该函数接受一个整数`n`,控制生成的三角形数量。大部分工作由函数`triangles`完成,当给定整数`n`和表面`s`时,它返回一个三角形列表。函数`triCenter`找到每个三角形的中心,函数`triArea`计算每个三角形的向量面积。以下是这两个函数的定义。

三角形通过指定其三个顶点的位置来描述。

data Triangle = Tri Position Position Position


三角形有一个方向,因此我们指定顶点的顺序非常重要。如果我们从一个地方观察三角形,其中顶点`p1`、`p2`和`p3`按逆时针顺序排列,则方向从三角形指向我们的观察位置,垂直于三角形表面。`Tri p1 p2 p3`、`Tri p2 p3 p1`和`Tri p3 p1 p2`都表示具有相同方向的同一个三角形,但`Tri p1 p3 p2`、`Tri p2 p1 p3`和`Tri p3 p2 p1`表示具有相同顶点但方向相反的三角形。

我们通过对三个顶点的位移向量进行平均来找到三角形的中心。

triCenter :: Triangle -> Position
triCenter (Tri p1 p2 p3)
= shiftPosition ((rVF p1 + rVF p2 + rVF p3) ^/ 3) origin


我们使用`rVF`向量场将位置转换为位移向量,这在第二十二章中介绍。然后我们对它们求平均,并使用`shiftPosition`函数将其转换回位置。

三角形的向量面积是两个向量边的叉积的一半。由于我们关心这些三角形的方向,因此我们需要小心进行叉积的操作顺序。

triArea :: Triangle -> Vec -- vector area
triArea (Tri p1 p2 p3) = 0.5 *^ (displacement p1 p2 >< displacement p2 p3)


函数`triangles`在给定一个表面时返回一个三角形列表。

triangles :: Int -> Surface -> [Triangle]
triangles n (Surface g sl su tl tu)
= let sts = [[(s,t) | t <- linSpaced n (tl s) (tu s)]
| s <- linSpaced n sl su]
stSquares = [( sts !! j !! k
, sts !! (j+1) !! k
, sts !! (j+1) !! (k+1)
, sts !! j !! (k+1))
| j <- [0..n-1], k <- [0..n-1]]
twoTriangles (pp1,pp2,pp3,pp4)
= [Tri (g pp1) (g pp2) (g pp3),Tri (g pp1) (g pp3) (g pp4)]
in concatMap twoTriangles stSquares


注意,`triangles`的第一个参数是整数`n`,它控制将生成多少个三角形。我们通过对输入进行模式匹配,传递表面`Surface g sl su tl tu`,因为该函数需要引用表面的参数限制`sl`和`su`,以及表面的所有其他属性。

该函数首先将曲面的参数区间从`sl`到`su`划分为`n`个相等的子区间。在这些`n+1`个子区间的每个端点,我们将参数区间从`tl s`到`tu s`划分为`n`个相等的子区间,其中`s`是每个子区间端点的*参数值*。局部变量`sts :: [[(R,R)]]`是一个列表的列表,可以看作是一个`n+1`×`n+1`的矩阵,包含与曲面上的点对应的参数对。局部变量`stSquares :: [((R,R),(R,R),(R,R),(R,R))]`是一个包含`n²`个“平方”的列表,每个平方由参数对组成。这些平方通过局部函数`twoTriangles`转化为两个三角形。函数`triangles`返回一个包含`2*n²`个三角形的列表,用于近似曲面。

现在我们展示了一种近似曲面的方式,让我们转向近似体积的问题。

#### 近似体积

体积近似是一种将体积转化为位置列表和数值体积的方法。

-- introduced earlier in the Chapter
type VolumeApprox = Volume -> [(Position,R)]


我们的函数`volumeSample`通过多个四面体来近似一个体积。四面体是一个四面体固体,其中每一面都是一个三角形。该函数返回一个四面体位置和数值体积的列表。

volumeSample :: Int -> Volume -> [(Position,R)]
volumeSample n v = [(tetCenter tet, tetVolume tet) | tet <- tetrahedrons n v]


该函数接受一个整数`n`,它控制所使用的四面体数量。大部分工作由下面定义的函数`tetrahedrons`完成,该函数在给定整数`n`和体积`v`时返回四面体列表。函数`tetCenter`找到每个四面体的中心,函数`tetVolume`计算每个四面体的数值体积。这两个函数也在下面定义。

我们可以通过指定四个顶点的位置来描述一个四面体。

data Tet = Tet Position Position Position Position


我们可以通过对四个顶点的位移向量求平均来找到一个四面体的中心。

tetCenter :: Tet -> Position
tetCenter (Tet p1 p2 p3 p4)
= shiftPosition ((rVF p1 + rVF p2 + rVF p3 + rVF p4) ^/ 4) origin


该函数是`triCenter`函数从三角形到四面体的自然扩展。

四面体的体积是*标量三重积*的 1/6,定义为**a** ⋅ (**b** × **c**),其中三个向量边从一个顶点出发或终止于一个顶点。标量三重积也是一个矩阵的行列式,该矩阵的列是这三个向量边。

tetVolume :: Tet -> R
tetVolume (Tet p1 p2 p3 p4)
= abs $ (d1 <.> (d2 >< d3)) / 6
where
d1 = displacement p1 p4
d2 = displacement p2 p4
d3 = displacement p3 p4


我们使用`abs`函数来保证数值体积为正。

就像我们使用“参数平方”来覆盖曲面的参数空间一样,现在我们使用“参数立方体”来覆盖体积的参数空间。让我们为参数立方体定义一个数据类型。

data ParamCube
= PC { v000 :: (R,R,R)
, v001 :: (R,R,R)
, v010 :: (R,R,R)
, v011 :: (R,R,R)
, v100 :: (R,R,R)
, v101 :: (R,R,R)
, v110 :: (R,R,R)
, v111 :: (R,R,R)
}


函数`tetrahedrons`在给定体积时返回一个四面体列表。

tetrahedrons :: Int -> Volume -> [Tet]
tetrahedrons n (Volume g sl su tl tu ul uu)
= let stus = [[[(s,t,u) | u <- linSpaced n (ul s t) (uu s t)]
| t <- linSpaced n (tl s) (tu s)]
| s <- linSpaced n sl su]
stCubes = [PC (stus !! j !! k !! l )
(stus !! j !! k !! (l+1))
(stus !! j !! (k+1) !! l )
(stus !! j !! (k+1) !! (l+1))
(stus !! (j+1) !! k !! l )
(stus !! (j+1) !! k !! (l+1))
(stus !! (j+1) !! (k+1) !! l )
(stus !! (j+1) !! (k+1) !! (l+1))
| j <- [0..n-1], k <- [0..n-1], l <- [0..n-1]]
tets (PC c000 c001 c010 c011 c100 c101 c110 c111)
= [Tet (g c000) (g c100) (g c010) (g c001)
,Tet (g c011) (g c111) (g c001) (g c010)
,Tet (g c110) (g c010) (g c100) (g c111)
,Tet (g c101) (g c001) (g c111) (g c100)
,Tet (g c111) (g c100) (g c010) (g c001)
]
in concatMap tets stCubes


请注意,`tetrahedrons`的第一个参数是一个整数`n`,它控制将产生多少个四面体。我们使用模式匹配对输入进行处理,传递体积`Volume g sl su tl tu ul uu`,因为此函数需要引用体积的参数限制`sl`和`su`,以及体积的其他所有属性。

该函数通过将体积的参数区间从 `sl` 到 `su` 分成 `n` 个相等的小区间开始。在这些小区间的 `n+1` 个端点处,我们将参数区间从 `tl s` 到 `tu s` 分成 `n` 个相等的小区间,其中 `s` 是每个子区间端点的 *s* 参数值。最后,对于第三维度,我们将每个参数区间从 `ul s t` 到 `uu s t` 分成 `n` 个相等的小区间,其中 `t` 是每个子区间端点的 *t* 参数值。局部变量 `stus :: [[[(R,R,R)]]]` 是一个列表的列表的列表,可以看作是一个 `n+1` x `n+1` x `n+1` 的参数三元组数组,对应于体积中的点。局部变量 `stCubes ::` `[ParamCube]` 是一个包含 `n³` 个参数立方体的列表。局部函数 `tets` 将每个立方体转换为五个四面体。函数 `tetrahedrons` 返回一个包含 `5*n³` 个四面体的列表,用于近似该体积。

### 摘要

本章介绍了如何计算由电荷分布产生的电场。我们编写了函数来计算点电荷、线电荷、面电荷和体电荷产生的电场。在实现这一目标的过程中,我们引入了矢量线积分、矢量面积分和矢量体积分。我们编写了一个函数

eField :: ChargeDistribution -> VectorField


该函数通过结合我们为每种电荷分布编写的函数,计算任何电荷分布的电场。

在引入三种矢量积分(线积分、面积分和体积分)后,我们借此机会定义了三种标量积分,其中我们加总的是数值而不是矢量。本章以曲线、曲面和体积的逼近方法为结尾。对这些几何体进行数值积分需要我们有方法将物体划分为有限数量的部分。我们展示了每种物体的一个划分方法。下一章将讨论电流分布,它与本章的电荷分布类似。就像电荷是电场的源头一样,电流是磁场的源头。

### 习题

**练习 25.1.** 考虑一段均匀线性电荷密度为 *λ*[0] 的线电荷段。我们将这段线电荷置于 x 轴上,从 *x* = *–L*/2 到 *x* = *L*/2。我们想要找出该线电荷段在 xy 平面某点产生的电场。编写代码绘制该线电荷段产生的电场图像。你可以将注意力集中在 xy 平面内。由一段线电荷产生的电场是一个完全可解的问题。找出或计算出精确解,并绘制精确电场图像以作比较。

**练习 25.2.** 画出由均匀带电盘产生的电场图像。将盘放置在 xy 平面,并在 xz 平面中展示电场。

**练习 25.3.** 绘制由均匀带电球体产生的电场强度与距离球体中心的关系图。将我们的数值方法与精确解进行比较。

**练习 25.4.** 考虑一个位于 xy 平面的带电环,半径为*R*,线电荷密度*λ*(**r**) = *λ*[0] cos *ϕ*,其中*ϕ*是圆柱坐标,*R*和*λ*[0]是可以选择的常数。创建一个 3D 可视化图像,展示由该电荷分布产生的电场。

**练习 25.5.** 在上一章中,有一道练习要求你为氢原子的基态写出电荷分布。找到氢原子在空间中任意一点产生的电场。绘制电场强度随质子距离变化的图像。

**练习 25.6.** 画出由位于正方形四个角的四个相等的正点电荷产生的电场图像。

**练习 25.7.** 绘制由位于 xy 平面的均匀正表面电荷密度的方形板产生的电场在 xz 平面上的图像。

**练习 25.8.** 绘制由位于原点的 xy 平面内均匀带电圆环产生的 xy 平面和 yz 平面上的电场图像。

**练习 25.9.** 如果`scalarLineIntegral`和`vectorLineIntegral`这两个函数看起来基本上在做相同的事情,且我们应该能够利用某种共性将它们合并成一个既能做标量线积分又能做向量线积分的函数,那么你是对的。

首先,我们定义一个通用场,它可以是标量场、向量场或其他类型。

type Field a = Position -> a


类型`ScalarField`与`Field R`相同,类型`VectorField`与`Field Vec`相同。

接下来,我们为抽象向量创建一个类型类,这些类型具有零向量,能够进行加法运算,并且可以由实数进行缩放。

class AbstractVector a where
zeroVector :: a
add :: a -> a -> a
scale :: R -> a -> a


我们编写一个函数`sumG`,用于求和一个抽象向量列表。该函数是借鉴自第十章中的`sumV`函数。

sumG :: AbstractVector a => [a] -> a
sumG = foldr add zeroVector


使用这些工具,我们可以写出一个通用的线积分函数,它既可以作为标量线积分,也可以作为向量线积分。

generalLineIntegral
:: AbstractVector a => CurveApprox -> Field a -> Curve -> a
generalLineIntegral approx f c
= sumG [scale (magnitude dl') (f r') | (r',dl') <- approx c]


编写实例声明,使得类型`R`和`Vec`成为类型类`AbstractVector`的实例。

**练习 25.10.** *高斯定律*断言,*电通量*通过一个封闭表面的值与该表面所包围的电荷成正比。一个向量场的*通量*描述了如果我们将向量视为某种流体的速度,那么通过一个表面的总流量。电通量Φ*[E]*通过一个表面*S*的定义是电场的表面点积积分。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/504equ01.jpg)

点表面积积分的定义与我们在本章定义的向量表面积积分非常相似。该积分通过将表面 *S* 划分为许多小块来定义。每个小块 Δ**a**[*i*] 是一个向量,其大小表示该小块的面积,方向指向垂直于小块的方向。电场 **E** 在点 **r**[*i*] 上被评估,位于或接近小块 Δ**a**[*i*],并与小块的向量面积 Δ**a**[*i*] 点积。然后,我们将这些数值加起来,形成总和:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/504equ02.jpg)

积分是通过将小块的面积趋近于 0,且小块的数量变得无限大的过程,得到的该向量和的极限。

这是点表面积积分的 Haskell 代码,也称为 *通量积分*:

dottedSurfaceIntegral :: SurfaceApprox -> VectorField -> Surface -> R
dottedSurfaceIntegral approx vF s
= sum [vF r' <.> da' | (r',da') <- approx s]


(a) 编写一个函数

electricFluxFromField :: VectorField -> Surface -> R
electricFluxFromField = undefined


接受电场和表面作为输入,并返回电通量作为输出的函数。

(b) 编写一个函数

electricFluxFromCharge :: ChargeDistribution -> Surface -> R
electricFluxFromCharge dist = undefined dist


返回由给定电荷分布的电场产生的通过给定表面的电通量的函数。

**练习 25.11.** 比较 NaCl 产生的电场 `eFieldLineDipole`,将其视为线偶极子,与简单偶极子和理想偶极子的电场。制作一个类似于我们为简单偶极子和理想偶极子所做的矢量场图。

**练习 25.12.** 点电荷是电场的基本源。给定一个表面近似,我们可以通过将其视为一组点电荷来找到表面电荷的电场。表面近似告诉我们在哪里放置点电荷,以及它们应该具有的值。通过这种方式,我们跳过了本来是我们计算电场的主要方法——向量表面积积分。

eFieldFromSurfaceChargeP :: SurfaceApprox -> ScalarField -> Surface
-> VectorField
eFieldFromSurfaceChargeP approx sigma s r
= sumV [eFieldFromPointCharge (sigma r' * magnitude da') r' r
| (r',da') <- approx s]


编写类似的函数来计算由线电荷和体电荷产生的电场。

**练习 25.13.** 编写一个函数

surfaceArea :: Surface -> R
surfaceArea = undefined


使用 `scalarSurfaceIntegral` 计算表面的表面积的函数。

**练习 25.14.** 电势是一个标量场,可以通过以下方式根据电场定义:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/505equ01.jpg)

积分是对任意曲线 *C* 进行的点线积分,该曲线从原点开始,终点为场点 **r**。电静场 **E** 的保守性保证了结果与所选曲线 *C* 无关。

点线积分通过将曲线 *C* 分割为许多小段来定义和逼近,正如我们为向量线积分所做的那样。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/505equ02.jpg)

在逼近的每个点 **r**[*i*] 处评估矢量场 **F**,与段的位移 Δ**l**[*i*] 点积,然后加总。

这是点线积分的 Haskell 定义:

dottedLineIntegral :: CurveApprox -> VectorField -> Curve -> R
dottedLineIntegral approx f c = sum [f r' <.> dl' | (r',dl') <- approx c]


编写一个函数

electricPotentialFromField :: VectorField -- electric field
-> ScalarField -- electric potential
electricPotentialFromField ef r = undefined ef r


它将电场作为输入,并返回电势作为输出。为了编写这个函数,你需要构造一条从原点开始并以我们希望找到电势的场点结束的曲线。然后,可以将这条曲线传递给`dottedLineIntegral`。


# 第二十六章:电流

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

电流是运动中的电荷。在电路中,比如我们家里和办公室里的电流,是电荷沿着电线流动,但考虑到电荷可能横跨表面或贯穿体积的情况也是很有用的。这三种电流分布——线电流、表面电流和体积电流——是本章讨论的主题。

本章与第二十四章关于电荷的内容相对应。我们将介绍电流、表面电流密度和体积电流密度的概念。接着,我们将定义一个电流分布的数据类型,能够表示线电流、表面电流、体积电流或这些的任意组合。正如电荷是电场的源头一样,电流是磁场的源头。我们将展示如何计算任意电流分布的磁偶极矩,并讨论其与电荷分布的电偶极矩的相似性与差异性。掌握电流分布的语言为我们准备好进入下一章,在这一章中我们将计算由电流分布产生的磁场。

### 电流分布

电流显然是一个电学现象,是电荷的流动。但在 1820 年,汉斯·克里斯蒂安·奥斯特德(Hans Christian Oersted)证明了电流也是一个磁学现象,建立了电与磁之间的第一个联系。现代电磁理论将电流视为磁场的基本来源。

换句话说,电流是产生磁效应的基本量(尽管在人们首次观察到磁现象后,花了几千年才发现这一点),并在电磁理论中起着关键作用。我们使用三种类型的电流分布。首先是沿着一维路径(如直线或曲线)流动的电流,我们通常简称其为*电流*。电流的国际单位制单位是安培(A)。在电线中流动的 1 安培电流意味着每秒有 1 库仑的电荷通过电线上的固定点。我们通常用符号*I*表示电流。根据约定,电流是正电荷的流动。这个约定是在人们还不知道金属中是自由流动的负电荷(电子)在导电之前就已经建立的。按照这个约定,向左流动的电子会在电流中产生向右的方向。

第二种电流分布是电流流过二维表面的情况。在这种情况下,我们称其为*表面电流密度* **K**,表示单位横截面长度的电流。表面电流密度的国际单位制单位是安培每米(A/m)。

最后,电流在三维体积中流动。在这种情况下,我们讨论的是*体积电流密度* **J**,即单位横截面积上的电流。体积电流密度的 SI 单位是安培每平方米(A/m²)。表 26-1 总结了这些电流分布。

**表 26-1:** 电流分布

| **电流分布** | **维度** | **符号** | **SI 单位** |
| --- | --- | --- | --- |
| 点电流 | 0 | 不可能 | 不可能 |
| 当前 | 1 | *我* | A |
| 表面电流密度 | 2 | **K** | A/m |
| 体积电流密度 | 3 | **J** | A/m² |

现在我们转向我们的 Haskell 代码。

### 引导代码

列出 26-1 显示了我们将在本章中编写的 `Current` 模块的前几行代码。

{-# OPTIONS -Wall #-}

module Current where

import SimpleVec
( R, Vec, sumV, (><), (*^) )
import CoordinateSystems
( VectorField, rVF, cyl, phiHat )
import Geometry
( Curve(..), Surface(..), Volume(..) )
import ElectricField
( CurveApprox, curveSample, surfaceSample, volumeSample
, vectorSurfaceIntegral, vectorVolumeIntegral )


*列出 26-1:`Current` 模块的代码开头*

在这里,我们使用了第十章的 `SimpleVec` 模块,第二十二章的 `CoordinateSystems` 模块,第二十三章的 `Geometry` 模块和第二十五章的 `ElectricField` 模块中的类型和函数。

让我们为电流定义一个类型同义词。

type Current = R


这类似于我们为 `Charge` 创建的类型同义词。这是为电流创建类型的一种简单方式,但由于 `Current`、`Charge` 和 `R` 都是相同的类型,编译器将无法帮助我们防止错误地在应该使用 `Current` 的地方使用 `Charge`,反之亦然。

现在我们已经为电流指定了一个类型,让我们看一下电流分布的类型,它会稍微复杂一些。

### 电流分布的类型

就像我们在第二十四章中做的那样,这里我们需要一个新的数据类型 `CurrentDistribution`,它可以容纳线电流、表面电流、体积电流或这些的组合。我们需要哪些信息来指定每种电流?对于线电流,我们需要指定电流流动的曲线以及电流的数值。线电流要求我们给出一个 `Current` 和一个 `Curve`。

为了指定表面电流,我们需要给出一个表面电流密度的矢量场,该密度可能因位置不同而变化,以及电流流过的表面。表面电流通过给定一个 `VectorField` 和一个 `Surface` 来指定。类似地,体积电流通过给定一个 `VectorField` 和一个 `Volume` 来指定。最后,电流分布的组合通过给定电流分布的列表来指定。

让我们看看定义数据类型 `CurrentDistribution` 的代码。

data CurrentDistribution
= LineCurrent Current Curve
| SurfaceCurrent VectorField Surface
| VolumeCurrent VectorField Volume
| MultipleCurrents [CurrentDistribution]


`CurrentDistribution` 类型有四个数据构造器,每个构造器对应我们之前描述的情况之一。为了构造一个 `CurrentDistribution`,我们使用四个数据构造器中的一个,并提供该种电流分布的相关信息。

### 电流分布示例

让我们编写一些电流分布的示例。电流绕着位于原点的 xy 平面中的圆形环流动的电流分布称为`circularCurrentLoop`。

circularCurrentLoop :: R -- radius
-> R -- current
-> CurrentDistribution
circularCurrentLoop radius i
= LineCurrent i (Curve (\phi -> cyl radius phi 0) 0 (2*pi))


这是最简单的电流分布之一。函数`circular` `CurrentLoop`接受半径和电流作为输入,并返回一个电流分布。在接下来的章节中,我们将计算由这种电流分布产生的磁场。圆形电流环也是磁偶极子的一个例子,我们将在本章后面讨论。

*电磁线圈*由绕在圆柱形框架上的许多圈电线组成。函数`wireSolenoid`在提供了电磁线圈的半径、电磁线圈的长度、每单位长度的线圈圈数以及电线中的电流后,返回一个电流分布。

wireSolenoid :: R -- radius
-> R -- length
-> R -- turns/length
-> R -- current
-> CurrentDistribution
wireSolenoid radius len n i
= LineCurrent i (Curve (\phi -> cyl radius phi (phi/(2pin)))
(-pinlen) (pinlen))


电线的曲线是一个螺旋形。我们使用圆柱坐标*ϕ*来参数化这条曲线。随着*ϕ*的增大,z 坐标也增加,从而形成螺旋。如果*n*是单位长度上的线圈圈数,*L*是长度,那么电磁线圈上将有*nL*圈电线。为了产生这个数量的线圈,参数*ϕ*必须从起点到终点经历 2*πnL*弧度。我们将*ϕ*的范围设定为- *πnL*到*πnL*,这样电磁线圈就会集中在原点。我们希望*z*的范围是- *L*/2 到*L*/2;如果我们将*ϕ*除以 2*πn*,就能达到这一目标,因此我们在`cyl`函数中使用*ϕ*/2*πn*作为 z 坐标。

在*片状电磁线圈*中,我们设想电线圈的线圈非常紧密,以至于电流实际上是一个表面电流。我们使用与`wireSolenoid`相同的输入来调用`sheetSolenoid`。

sheetSolenoid :: R -- radius
-> R -- length
-> R -- turns/length
-> R -- current
-> CurrentDistribution
sheetSolenoid radius len n i
= SurfaceCurrent (\r -> (n*i) ^ phiHat r)
(Surface ((phi,z) -> cyl radius phi z)
0 (2
pi) (const $ -len/2) (const $ len/2))


由于片状电磁线圈是表面电流,它需要一个表面电流密度**K**。表面电流密度是单位横截面长度上的电流,因此我们有*K* = *nI*;表面电流密度的大小是电流与单位长度的线圈圈数的乘积。表面电流密度的方向是![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/sdcap.jpg),因此表面电流密度是![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/511equ01.jpg),在代码中给出为`\r -> (n*i) *^ phiHat r`。表面是一个圆柱面,由圆柱坐标*ϕ*和*z*来参数化。*ϕ*的范围是从 0 到 2*π*,尽管我们可以选择- *π*到*π*并得到相同的结果。*z*的范围是- *L*/2 到*L*/2。我们需要使用`const`函数,因为表面需要限制第二个参数,这些限制是第一个参数的函数。如果线圈很紧密,电磁线圈将产生与片状电磁线圈非常相似的磁场。

*环形线圈*是通过将电线绕在一个环形物体上形成的,如图 26-1 所示。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/511fig01.jpg)

*图 26-1:一个具有 40 圈的环形线圈。电线上的箭头表示电流的方向。*

注意

*螺线管和环形线圈都作为*电感器*使用在电路中,电感器是一种可以帮助平滑电压变化的电路元件。对于像调光器这样的设备,环形线圈通常更好,因为大多数磁场都位于环形中,这意味着磁场变化时,较少的电磁噪声会被释放到房间中。噪声的频率是生成电力的频率(在美国是 60 Hz)及其倍数,可能会产生烦人的嗡嗡声,因此最好最小化噪声。*

函数`wireToroid`在提供了一个小半径、大半径、总圈数和电流时,返回一个电流分布。

wireToroid :: R -- small radius
-> R -- big radius
-> R -- number of turns
-> R -- current
-> CurrentDistribution
wireToroid smallR bigR n i
= let alpha phi = n * phi
curve phi = cyl (bigR + smallR * cos (alpha phi)) phi
(smallR * sin (alpha phi))
in LineCurrent i (Curve curve 0 (2*pi))


环形曲线基于我们在练习 26.3 中使用的环形坐标化。环形表面的两个参数是圆柱坐标系中的*ϕ*和一个角度*α*,它绕环形的小横截面圆圈旋转。环形上点的圆柱坐标通过两个参数*ϕ*和*α*给出,如下所示:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/512equ01.jpg)

为了绘制电线环的曲线,我们选择圆柱坐标系中的*ϕ*作为唯一的参数,并让*α*现在依赖于*ϕ*。我们选择

*α*(*ϕ*) = *nϕ*

这样,*α*在小圆圈周围旋转 2*πn*弧度(*n*圈),而*ϕ*在大圆圈周围旋转 2*π*弧度(一个圈)。参数*ϕ*的限制是简单的从 0 到 2*π*。

我们将在下一章看到由电线环产生的磁场。

### 电荷守恒和稳态电流分布的约束

电荷是守恒的。这意味着任何孤立区域内的电荷总量随时间保持不变。实际上,关于电荷守恒还有一个更强的声明成立。空间中任何区域的电荷量变化恰好是电流通过该区域边界的程度。流入区域的电流将增加该区域的电荷,而流出区域的电流将减少该区域的电荷。

如果*Q*(*t*)是某个区域在时间*t*的电荷,*I*(*t*)是该区域在时间*t*外流的电流,那么

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/513equ01.jpg)

换句话说,电荷在该区域增加的速率是该区域外流净电流的负值,也就是说是该区域内流净电流的值。

通过体积电流密度**J**(*t*,**r**)流过任意(闭合或开放)表面*S*的电流由下式给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/513equ02.jpg)

这与我们在练习 25.10 中用于计算电通量的虚线表面积积分或通量积分相同。回到我们带有电荷*Q*(*t*)和净外流电流*I*(*t*)的空间区域,我们可以使用方程 24.3 的时间依赖版本,

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/513equ03.jpg)

重新写方程 26.1 时,我们使用电荷密度和电流密度:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/513equ04.jpg)

在这里,*V*是我们关注的空间区域,*∂V*是构成*V*边界的闭合表面。如果我们允许区域*V*变得非常小,我们可以将方程 26.4 两边都除以*V*的体积,从而得到一个称为*连续性方程*的方程。有关数学细节,请参见**[19**]。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/513equ05.jpg)

数量∇⋅**J**(*t*,**r**)被称为电流密度的*散度*。矢量场的散度是单位体积内的通量,其中通量是在体积的闭合边界表面上计算的,且在体积允许变得非常小时取极限。由于散度是单位体积的通量,因此一个矢量场散度为正的地方是矢量指向远离的地方。同样,散度为负的地方是矢量指向靠近的地方。

符号∇被称为*del 算符*,在笛卡尔坐标系中,它表示为

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/513equ06.jpg)

这里的*算符*一词是物理学家使用的含义,指的是一种接受函数作为输入并产生函数作为输出的东西。函数式编程者将此类东西称为*高阶函数*。del 算符与点积符号的组合形成了散度。在笛卡尔坐标系中,矢量场的散度表示如下:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/514equ01.jpg)

连续性方程的一个结果通过投掷一张揉皱的铝箔纸进微波炉时得到戏剧性的体现。微波在铝箔中感应出大电流,导致大量电荷在铝箔的某些部分积聚。这会产生强烈的电场,最后*啪!*当电场足够强大以致能够电离空气时,火花就会出现。

并不是每个矢量场都可以作为稳态电流密度。在本章中,我们关注的是时间上不变的稳态电流分布。如果电荷密度*ρ*(*t*,**r**)和电流密度**J**(*t*,**r**)与时间*t*无关,那么连续性方程要求∇⋅**J** = 0(即电流密度是无散的)。一个无散的矢量场也被称为*旋度*矢量场,源自螺线管的形状,或者管道。

计算机不会检查你用来表示电流密度的矢量场是否是无散的。在这种情况下,正如我们在许多其他使用计算机建模系统的情形中一样,程序员有责任确保所建模的系统是合理的。

### 磁偶极矩

就像电偶极矩可以与任何电荷分布相关联一样,磁偶极矩也可以与任何电流分布相关联。事实上,这种类比可以扩展到多极展开。就像电荷分布可以看作是单极、偶极、四极以及更高阶电多极的组合,电流分布也可以看作是磁多极的组合,*只是*这种展开中永远没有磁单极。

注意

*我们在第二十九章学习的四个麦克斯韦方程之一强制执行这个“没有磁单极”的规则。电流分布具有磁偶极矩,与电荷分布具有电偶极矩非常相似。电流分布还具有磁多极矩,这与电荷分布具有电多极矩类似。但这种类比不适用于单极矩。我们的宇宙包含电荷(电单极矩),但到目前为止还没有人发现任何磁荷(磁单极矩)。*

磁偶极子会产生磁场,并且也会通过感应到力和/或扭矩而响应磁场,因此它可以被看作是类似于电流的磁性活跃实体。

我们通过其*磁偶极矩* **m** 来表征一个磁偶极子。任何电流分布都可以与磁偶极矩相关联。不幸的是,电偶极矩作为从负电荷到正电荷的矢量的简单模型并不适用于磁偶极矩。电流 *I* 的磁偶极矩由以下公式给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/515equ01.jpg)

一个携带电流 *I* 的电流环的磁偶极矩为 **m** = *I***a**,其中 **a** 是环的矢量面积,面积的大小给出了面积的大小,方向垂直于该面积。

表面电流密度 **K** 的磁偶极矩由以下公式给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/515equ02.jpg)

体积电流密度 **J** 的磁偶极矩由以下公式给出:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/515equ03.jpg)

磁偶极矩通常是局部电流分布(如电流环)的一个良好简单表征,可以很好地近似该分布所产生的磁场。

一条电流的磁偶极矩通过一个交叉线积分来定义,定义如下:

crossedLineIntegral :: CurveApprox -> VectorField -> Curve -> Vec
crossedLineIntegral approx vF c
= sumV [vF r' >< dl' | (r',dl') <- approx c]


这与我们之前定义的矢量线积分相似,只不过它涉及到一个叉乘。

这是电流分布的磁偶极矩的定义:

magneticDipoleMoment :: CurrentDistribution -> Vec
magneticDipoleMoment (LineCurrent i c)
= crossedLineIntegral (curveSample 1000) (\r -> 0.5 *^ i *^ rVF r) c
magneticDipoleMoment (SurfaceCurrent k s)
= vectorSurfaceIntegral (surfaceSample 200) (\r -> 0.5 *^ (rVF r >< k r)) s
magneticDipoleMoment (VolumeCurrent j v)
= vectorVolumeIntegral (volumeSample 50) (\r -> 0.5 *^ (rVF r >< j r)) v
magneticDipoleMoment (MultipleCurrents ds )
= sumV [magneticDipoleMoment d | d <- ds]


表达式 `rVF r` 是指从原点到位置 `r` 的位移矢量。函数 `magneticDipoleMoment` 编码了方程式 26.8、26.9 和 26.10。

### 总结

本章介绍了电流分布,它们是磁场的基本源。我们定义了一种类型 `CurrentDistribution`,它能够容纳线电流、表面电流、体电流或它们的组合。我们电流分布的最简单例子是一个导线环。我们还编写了螺线管和环形线圈作为电流分布的示例。

对于电流分布,有一种多极展开方法,将电流看作是由磁偶极子、磁四极子和更高阶项组成的。然而,这种展开中没有磁单极项。从远处看,电流分布通常像一个磁偶极子,因此我们有时将磁偶极子看作是磁场的源,就像电荷(单极子)和电偶极子可以看作是电场的源一样。

使用我们在本章编写的代码,我们现在可以计算与任何电流分布相关的磁偶极矩。在下一章中,我们将展示如何计算由电流分布产生的磁场。

### 习题

**习题 26.1.** 一个 *赫尔姆霍兹线圈* 由两个相互平行并共享相同中心轴的圆形导线环组成,每个导线环都携带方向相同的电流 *I*。这两个环的半径都是 *R*,并且它们的间距等于半径 *R*。这个特定的间距值使得赫尔姆霍兹线圈中心的磁场相当均匀。写出赫尔姆霍兹线圈的电流分布。

helmholtzCoil :: R -- radius
-> R -- current
-> CurrentDistribution
helmholtzCoil radius i = undefined radius i


实际上,许多导线环路在两个圆圈的每个位置都被缠绕,以便导线中的适当电流将产生每个单一环路周围非常大的电流效应。

**习题 26.2.** 一个简单且常见的电流分布是一个无限长的直线电流 *I*。对于我们来说,写出一个无限长导线的电流分布并不方便,因此让我们将导线的长度作为一个参数。写出一个长直导线的电流分布。

longStraightWire :: R -- wire length
-> R -- current
-> CurrentDistribution
longStraightWire len i = undefined len i


**习题 26.3.** 如果环形线圈中的线圈间距非常近,我们可以通过表面电流很好地近似电流分布。写出一个类似于我们之前的平面螺线管的平面环形线圈的电流分布。这里有一个环面来帮助你开始。函数`torus`接受一个小半径和一个大半径作为输入。

torus :: R -> R -> Surface
torus smallR bigR
= Surface ((phi,alpha) -> cyl (bigR + smallR * cos alpha) phi
(smallR * sin alpha))
0 (2pi) (const 0) (const $ 2pi)


**习题 26.4.** 考虑一个螺线管,它的导线绕得非常多,以至于它变得很胖。内部的导线距离中心轴是 *a*,外部的导线距离中心轴是 *b*(*a* < *b*)。我们通过在 *a* < *s* < *b* 区域内的体电流密度 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/517equ02.jpg) 来对其建模,其中 *J*[0] 是一个常数。在这个区域之外没有电流。螺线管的长度是 *L*。写出胖螺线管的电流分布。

**习题 26.5.** 对于一个不随时间变化的稳恒电流密度,我们可以写出不含时间依赖性的方程 26.2,如下所示:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/517equ01.jpg)

编写一个计算通过表面流动的总电流的函数。

totalCurrent :: VectorField -- volume current density
-> Surface
-> Current -- total current through surface
totalCurrent j s = undefined j s



# 第二十七章:磁场

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

磁铁产生磁场,但电流也会产生磁场。由于磁铁由在微观层面上有环形电流的材料构成,物理学家认为电流是磁场的基本来源。在本章中,我们将探讨电流如何产生磁场。我们将编写函数来求解上章中讨论的所有电流分布所产生的磁场,并绘制由电线圈、理想磁偶极子、螺线管和环形线圈产生的磁场图像。但首先,让我们从一个简单的磁学示例开始。

### 简单的磁效应

两根平行且电流方向相同的导线将相互吸引。在现代的磁学观点中,一个电流并不会直接对另一个电流施加力。相反,第一个电流产生磁场,而这个磁场对第二个电流施加力,如图 27-1 所示。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/520fig01.jpg)

*图 27-1:当存在两个电流时,磁场作用的概念图*

磁场,像电场一样,是我们在第二十二章中讨论过的那种矢量场。磁场将一个矢量**B**(**r**)与空间中的每一个点**r**相关联;当空间中某个点**r**处有粒子时,这个矢量帮助确定该粒子所受的力。图 27-1 展示了磁场的情况,类似于图 25-1 展示了电场的情况。电场是电荷间电力的中介,而磁场是电流间磁力的中介。

引入磁场将磁场相关问题的分析分为两部分。第一部分是电流产生磁场,这将在本章讨论。第二部分是磁场对(第二个)电流施加的力,我们将在第二十八章中讨论。

现在让我们来看一些入门代码。

### 入门代码

列表 27-1 显示了我们将在本章编写的`MagneticField`模块中的第一行代码。

{-# OPTIONS -Wall #-}

module MagneticField where

import SimpleVec ( Vec(..), R
, (-), (*^), (^/), (<.>), (><)
, magnitude, kHat, zComp )
import CoordinateSystems
( VectorField
, rVF, displacement, addVectorFields, cart, vfGrad )
import Geometry ( Curve(..), Surface(..), Volume(..) )
import ElectricField
( curveSample, surfaceSample, volumeSample
, vectorSurfaceIntegral, vectorVolumeIntegral, mu0 )
import Current
( Current, CurrentDistribution(..)
, wireSolenoid, wireToroid, crossedLineIntegral, circularCurrentLoop )


*列表 27-1:`MagneticField` 模块的开头代码行*

我们使用来自第十章的`SimpleVec`模块、第二十二章的`CoordinateSystems`模块、第二十三章的`Geometry`模块、第二十五章的`ElectricField`模块以及第二十六章的`Current`模块中的类型和函数。

### 电流产生磁场

现代磁学的两部分观点的第一部分是电流产生磁场。我们将从由直线电流产生的磁场开始,这是最简单的电流分布,然后再讨论更复杂的电流分布。

#### 由直线电流产生的磁场

与电荷的情况不同,在电荷的情况下,一个点电荷是最简单的电荷形式,而没有所谓的点电流。电流按定义必须流动,而它流动的最简单方式是沿着曲线或电线流动。我们假设电荷不会在电线上积聚,这是一个非常合理的假设,因为要让电荷积聚需要一些额外的工作。结果,电线中任何一点的电流都是相同的。

*比奥-萨伐尔定律*提供了一种计算由电流载流电线产生的磁场的方法。电线可以是任何形状,所以这是一个完美的机会,可以使用我们在第二十三章中定义的`Curve`数据类型。

虽然没有所谓的点电流,但比奥-萨伐尔定律仍然声称,电流载流电线的磁场可以通过电线小段的磁场贡献的叠加(即求和)来计算。每个小电流段本身不能存在,因为电流需要保持流动,但我们仍然可以计算来自小电流段的磁场贡献。

考虑一个携带电流*I*的小电线段。该段由位移向量*d**l**′表征,其长度*dl*′足够短,可以将该段视为直线,其方向与电线的切线方向一致。小电流段对位置**r**的磁场贡献**dB(r)**为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/521equ01.jpg)

这意味着贡献与电流*I*成正比,与段长*dl*′成正比,与从源点**r′**到场点**r**的位移**r** – **r′**的平方成反比,并且方向垂直于电流和位移方向。

我们通过将所有小段的贡献相加,来计算整个电线产生的磁场。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/521equ02.jpg)

这个积分是我们在第二十六章中定义的交叉线积分。负号的引入是因为叉积是反交换的。

交叉线积分的被积函数是将源点**r′**映射到向量的函数。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/522equ01.jpg)

注意,**r′**是我们希望知道磁场的场点,它仅仅是这个积分式中的一个参数;它*不是*积分变量。我们应该将**r′**视为积分变量,因为它是曲线中我们必须评估积分式并求和结果的**r′**位置。

我们将在接下来的`bFieldFromLine` `Current`函数中为被积函数命名为局部名称`integrand`。`bFieldFromLineCurrent`的类型签名明确表示了计算磁场所需的两个输入:`Current`和电流流动的`Curve`。对于 Haskell 的读者而言,`bFieldFromLineCurrent`函数比公式 27.2 更清楚地描述了发生了什么,因为后者并没有明确说明磁场仅依赖于曲线和电流。

bFieldFromLineCurrent
:: Current -- current (in Amps)
-> Curve
-> VectorField -- magnetic field (in Tesla)
bFieldFromLineCurrent i c r
= let coeff = -mu0 * i / (4 * pi) -- SI units
integrand r' = d ^/ magnitude d ** 3
where d = displacement r' r
in coeff *^ crossedLineIntegral (curveSample 1000) integrand c


我们定义了一个局部常量`coeff`来保存– *μ*[0] *I*/4*π*在国际单位制中的数值,并定义了一个局部函数`integrand`来保存被积函数。我们想定义一个局部变量`d`来表示从`r'`到`r`的位移,但由于`r'`只在函数`integrand`内存在,`d`的定义必须放在`integrand`的定义内部,而不能与`coeff`和`integrand`的定义并列。

在第二十六章中,我们编写了电流分布的类型。在本章中,我们将编写函数来计算由每种电流分布产生的磁场。这使我们能够将电流产生磁场的概念封装到以下函数中,该函数根据任何电流分布产生磁场:

bField :: CurrentDistribution -> VectorField
bField (LineCurrent i c) = bFieldFromLineCurrent i c
bField (SurfaceCurrent kC s) = bFieldFromSurfaceCurrent kC s
bField (VolumeCurrent j v) = bFieldFromVolumeCurrent j v
bField (MultipleCurrents cds) = addVectorFields $ map bField cds


`bField`函数通过对输入的模式匹配,分别处理每种电流分布类型。对于线电流,它使用我们之前编写的`bFieldFromLineCurrent`函数。对于表面电流和体电流,它使用我们将在本章后面编写的函数。对于组合电流分布(构造函数`MultipleCurrents`),它使用叠加原理,通过对每个电流所产生的磁场进行求和来找到磁场。我们使用第二十二章中的`addVectorFields`函数来组合各个电流分布的磁场。

##### 圆形电流环的磁场

产生磁场的最简单且最自然的方法之一是使用圆形电流环。圆形电流环也是一个很好的磁偶极子模型,正如我们在第二十六章中讨论的那样。令人惊讶的是,圆形电流环所产生的磁场没有解析解。然而,我们可以通过数值积分得到一个很好的近似解,这个数值积分嵌入在我们的交叉线积分中。

考虑一个位于 xy 平面中的圆形电流环,圆心位于原点,半径为 0.25 米。当从正 z 轴看时,该电流环中流过 10 安培的电流,且电流方向为逆时针。我们可以使用第二十六章中的`circularCurrentLoop`函数来创建这种电流分布,并可以使用`bField`函数来计算由此圆形电流环产生的磁场,该函数内部调用了我们之前编写的`bFieldFromLineCurrent`函数。

circleB :: VectorField -- magnetic field
circleB = bField $ circularCurrentLoop 0.25 10


表达式`circularCurrentLoop 0.25 10`的类型是`CurrentDistribution`;我们本可以给它起个名字,无论是在顶层,就像我们给`circleB`做的那样,或者使用`let`或`where`结构。决定给某个事物命名是编写函数式语言代码创作过程的一部分。给某个事物起个好名字会对你或代码读者有所帮助吗,还是名字会妨碍理解,转移我们对更重要思想的注意力?这是你可以一遍又一遍做出的决定。在这种情况下,我认为`circularCurrentLoop`函数及其参数已经足够好地命名了电流分布,因此无需再给它起另一个名字。

图 27-2 的左侧展示了该电流回路在 yz 平面中的磁场。图中的 x 方向从页面中突出,因此回路在 xy 平面中看起来像是一条位于图中心的水平线,那里场强最强。磁场穿过电流回路并回绕。

图 27-2 的右侧显示了理想偶极子的磁场,我们将在下文解释。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/524fig01.jpg)

*图 27-2:由电流回路(左)和理想磁偶极子(右)产生的磁场。左侧的图像是由`bFieldPicLoop`生成的;右侧的图像是由`bFieldPicIdealDipole`生成的。图形边缘周围的磁场非常相似,表明远离源头时,电流回路看起来像一个磁偶极子。*

下面是生成左侧图像的代码:

bFieldPicLoop :: IO ()
bFieldPicLoop
= vfGrad (**0.2) ((y,z) -> cart 0 y z) (\v -> (yComp v, zComp v))
"bFieldPicLoop.png" 20 circleB


我们使用来自第二十二章的`vfGrad`来绘制梯度向量场图。表达式`(**0.2)`是一个 Haskell 部分,表示函数`\x -> x**0.2`,这是一个缩放函数,我们使用它是因为随着我们远离回路,场的强度迅速减小。`vfGrad`的其他输入声明了我们希望在 yz 平面上查看场,给输出文件指定一个名称,指定每个方向上的箭头数量,并提供要绘制的向量场名称。

如前所述,电流回路是磁偶极子的一个例子。在接下来的部分,我们将更深入地研究磁偶极子。

##### 理想磁偶极子

*理想磁偶极子*是通过让回路的半径*R*趋近于 0,同时回路中的电流增大,以保持磁偶极矩*IπR*²不变,从而形成的磁场源。我们来看一下理想磁偶极子产生的磁场。

理想磁偶极子在原点产生的磁场是

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/524equ01.jpg)

其中**m**是磁偶极矩。下面是同一个方程在 Haskell 中的翻译:

bFieldIdealDipole :: Vec -- magnetic dipole moment
-> VectorField -- magnetic field
bFieldIdealDipole m r
= let coeff = mu0 / (4 * pi) -- SI units
rMag = magnitude (rVF r)
rUnit = rVF r ^/ rMag
in coeff *^ (1 / rMag**3) *^ (3 *^ (m <.> rUnit) *^ rUnit - m)


除了前面的系数,方程式 27.3 与方程式 25.3 对于理想电偶极子产生的电场是相同的。这种相似性体现在函数`bFieldIdealDipole`和`eFieldIdealDipole`中,它们仅在局部变量名称和前面的系数上有所不同。

图 27-2 的右侧显示了由理想磁偶极子产生的磁场。磁偶极矩**m**的大小在这个图中并不那么重要,因为最暗的箭头表示磁场的大小最大,无论其具体值是多少。方程式 27.3 表明,磁场随着偶极矩线性增加,因此对于任何 z 方向的偶极矩,图像都是相同的。

比较图 27-2 中的两幅图,我们可以看到,在图像的中心,靠近场源的地方,磁场是不同的。图像边缘的场,远离场源的地方,两个图像中的磁场非常相似。离场源稍远的地方的场的相似性使得这两个源都可以被称为磁偶极子。

这是生成图 27-2 右侧图片的代码:

bFieldPicIdealDipole :: IO ()
bFieldPicIdealDipole
= vfGrad (**0.2) ((y,z) -> cart 0 y z) (\v -> (yComp v, zComp v))
"bFieldPicIdealDipole.png" 20 (bFieldIdealDipole kHat)


与`bFieldPicLoop`圆形回路程序相比,唯一的区别是文件名和磁场。这里的磁场是`bFieldIdealDipole kHat`,我们使用单位向量`kHat`表示磁偶极矩,因为它的大小不会改变图像。

我们已经看到过一个由线电流产生的磁场的例子——圆形回路,并将其与理想磁偶极子的磁场进行了比较。现在让我们来看第二个由线电流产生的磁场的例子——螺线管。

##### 线圈

在上一章中,我们将线圈定义为电流分布。现在让我们计算它的磁场。我们将观察两个线圈。每个线圈的半径为 1 厘米,长度为 10 厘米,电流为 10 安培。第一个线圈每米有 100 圈,总共 10 圈。第二个线圈每米有 1000 圈,总共 100 圈。

首先,让我们来看一下两个线圈中间的磁场。

Prelude> :l MagneticField
[ 1 of 14] Compiling Newton2 ( Newton2.hs, interpreted )
[ 2 of 14] Compiling Mechanics1D ( Mechanics1D.hs, interpreted ) [ 3 of 14] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[ 4 of 14] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[ 5 of 14] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[ 6 of 14] Compiling MOExamples ( MOExamples.hs, interpreted )
[ 7 of 14] Compiling Electricity ( Electricity.hs, interpreted )
[ 8 of 14] Compiling CoordinateSystems ( CoordinateSystems.hs, interpreted )
[ 9 of 14] Compiling Geometry ( Geometry.hs, interpreted )
[10 of 14] Compiling Integrals ( Integrals.lhs, interpreted )
[11 of 14] Compiling Charge ( Charge.hs, interpreted )
[12 of 14] Compiling ElectricField ( ElectricField.hs, interpreted )
[13 of 14] Compiling Current ( Current.hs, interpreted )
[14 of 14] Compiling MagneticField ( MagneticField.hs, interpreted )
Ok, 14 modules loaded.
*MagneticField> bField (wireSolenoid 0.01 0.1 100 10) (cart 0 0 0)
vec 1.3405110355080298e-18 (-9.787828127867364e-7) 1.2326629789010703e-3
*MagneticField> bField (wireSolenoid 0.01 0.1 1000 10) (cart 0 0 0)
vec 9.429923508719186e-17 7.58448310225564e-6 1.2767867386980748e-2


我们看到,在这两种情况下,磁场主要在 z 方向,即沿着中心轴线,正如预期的那样。磁场的 x 分量基本为 0。y 分量虽然较小,但不为 0。由于电线呈螺旋形状,因此磁场有一个小的横向分量。对于我们特定的螺旋形状,这个小的横向分量出现在 y 方向。较长的螺线管会有更小的横向分量。

为了进行比较,一个理想的线圈具有半径 *R*、无限长、单位长度 *n* 匝,并且携带电流 *I*。物理学家喜欢理想线圈,因为只要 *n* 足够大,使得电流在圆柱体表面的几乎所有位置上都流动,那么它所产生的磁场就有一个简单的表达式。理想线圈外部的磁场(距离中央轴线超过 *R* 的场点)为 0。理想线圈内部的磁场具有均匀值 *μ*[0]*nI*,并沿线圈的中央轴线指向。所产生的磁场与半径无关。

一个理想的线圈,其单位长度上的匝数和电流与我们的第一个线圈相同,应该有 *n* = 100/m 和 *I* = 10 A,因此其磁场在中心(以及内部的任何其他位置)为

*μ*[0]*nI* = (4*π* × 10^(−7) N/A²)(100/m)(10 A) = 1.26 × 10^(−3) T

我们第一个线圈的 z 分量与这个值非常接近,尽管我们的第一个线圈在两个方面不理想:它的长度不是无限的,而且它的线圈间距为每厘米一匝(即不算太紧密)。

一个理想的线圈,其参数与我们的第二个线圈相同,将具有相同的电流,但匝数密度 *n* 是前者的 10 倍,因此其磁场将是我们刚刚计算的磁场的 10 倍。我们的第二个线圈也产生了一个接近理想线圈磁场的值。

图 27-3 展示了我们两个线圈产生的磁场。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/527fig01.jpg)

*图 27-3:两个线圈产生的磁场。两个线圈的半径为 1 cm,长度为 10 cm,电流为 10 A。左侧的线圈每米 100 匝,总共 10 匝;右侧的线圈每米 1,000 匝,总共 100 匝。图片聚焦于线圈中心的 4 cm × 4 cm 区域。左侧图像由 `bFieldPicSolenoid10` 生成;右侧图像由 `bFieldPicSolenoid100` 生成。*

图 27-3 中的图片展示了 yz 平面内的磁场。第一个线圈产生的磁场显示在左边。该图片为 yz 平面中线圈中心的 4 cm × 4 cm 区域。由于此线圈每厘米一匝,我们看到四匝线圈。在电线穿过 yz 平面的地方,磁场围绕着电线循环的地方特别显眼。磁场在靠近电线的位置最强。线圈内部的磁场明显大于外部的磁场,并且方向相反。

由第二个线圈产生的磁场显示在右侧。图片同样是一个 4 厘米 × 4 厘米的区域,位于线圈的 yz 平面中央。这个线圈每厘米有 10 圈,所以图片的高度有 40 圈。这样的圈数使得我们无法看到单独的电线。与第一个线圈相似,磁场在电线附近最强,内侧比外侧强,并且外侧的磁场方向与内侧相反。

这是制作图 27-3 图像的代码。程序`bFieldPicSolenoid10`,命名为 10 圈,总共生成左侧的图片,而`bFieldPicSolenoid100`,命名为 100 圈,总共生成右侧的图片。

bFieldPicSolenoid10 :: IO ()
bFieldPicSolenoid10 = vfGrad (**0.2) ((y,z) -> cart 0 (0.02y) (0.02z))
(\v -> (yComp v, zComp v)) "bFieldPicSolenoid10.png" 20
(bField $ wireSolenoid 0.01 0.1 100 10)

bFieldPicSolenoid100 :: IO ()
bFieldPicSolenoid100 = vfGrad (**0.2) ((y,z) -> cart 0 (0.02y) (0.02z))
(\v -> (yComp v, zComp v)) "bFieldPicSolenoid100.png" 20
(bField $ wireSolenoid 0.01 0.1 1000 10)


我们通过将可见正方形角落(–1, –1)和(1, 1)映射到笛卡尔坐标(0, –0.02, –0.02)和(0, 0.02, 0.02)来获得宽 4 厘米、高 4 厘米的图像,使用的函数是`\(y,z) -> cart 0 (0.02*y) (0.02*z)`。

##### 线圈环

我们在上一章中定义了线圈环作为一种电流分布。现在让我们计算它的磁场。我们将考虑一个小半径为 0.3 米、大半径为 1 米、50 圈电线、10 安培电流的线圈环。这个电流分布的表达式为`wireToroid 0.3 1 50 10`。

程序`bFieldWireToroid`给出了这个线圈环的磁场。

bFieldWireToroid :: VectorField
bFieldWireToroid = bField (wireToroid 0.3 1 50 10)


图 27-4 展示了我们线圈环产生的磁场。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/528fig01.jpg)

*图 27-4:由线圈环产生的磁场`bFieldWireToroid`。图像由`bFieldPicWireToroid`生成。*

图 27-4 中的图片展示了 xy 平面中的磁场。图片呈 1.5 米 × 1.5 米区域,位于线圈环的中心。图片展示了磁场如何被限制在环形结构内。

这是产生该图像的代码:

bFieldPicWireToroid :: IO ()
bFieldPicWireToroid
= vfGrad (**0.2) ((x,y) -> cart (1.5x) (1.5y) 0)
(\v -> (xComp v, yComp v)) "bFieldPicWireToroid.png" 20 bFieldWireToroid


电线圈、线圈电感和线圈环是线电流的例子。让我们提升一个维度,看看如何计算由表面电流产生的磁场。

#### 由表面电流产生的磁场

电流穿过表面时会产生磁场。为了计算这个磁场,我们必须提供两项信息:电流流过的`Surface`以及表面电流密度**K**,它表示为一个`VectorField`。

这是一个描述由表面电流产生的磁场的比奥-萨伐尔定律版本:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/529equ01.jpg)

这是我们在第二十五章中处理的同类型的矢量表面积分。矢量表面积分的被积函数是将源点**r′**映射到矢量的函数。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/529equ02.jpg)

请注意,**r**是我们希望知道磁场的场点,它只是该被积函数中的一个参数;它*不是*积分变量。我们应该将**r′**视为积分变量,因为是**r′**的位置构成了我们必须在其上评估被积函数并累加结果的表面。在我们接下来要编写的`bFieldFromSurfaceCurrent`函数中,我们将被积函数命名为`integrand`。`bFieldFromSurfaceCurrent`的类型签名清楚地以计算机检查的方式表明了必须提供的两个输入和返回一个向量场的返回类型。对于 Haskell 的读者而言,`bFieldFromSurfaceCurrent`函数比方程式 27.4 更清晰地描述了发生了什么。

bFieldFromSurfaceCurrent
:: VectorField -- surface current density
-> Surface -- surface across which current flows
-> VectorField -- magnetic field (in T)
bFieldFromSurfaceCurrent kCurrent s r
= let coeff = mu0 / (4 * pi) -- SI units
integrand r' = (kCurrent r' >< d) ^/ magnitude d ** 3
where d = displacement r' r
in coeff *^ vectorSurfaceIntegral (surfaceSample 200) integrand s


我们定义了一个局部常量`coeff`,用于保存*μ*[0]/4*π*的数值(单位为 SI 单位),并定义了一个局部函数`integrand`,用于保存被积函数。像之前一样,我们希望定义一个局部变量`d`,表示从`r'`到`r`的位移,但因为`r'`在函数`integrand`内部局部存在,所以`d`的定义必须放在`integrand`的定义内部,而不能与`coeff`和`integrand`的定义平行放置。

与`bFieldFromLineCurrent`类似,该函数由`bField`函数调用,后者计算由任何电流分布产生的磁场。`bField`使用的最终函数计算由体积电流产生的磁场,接下来我们将讨论这个函数。

#### 由体积电流产生的磁场

流过体积的电流会产生磁场。为了计算这个磁场,我们必须提供两个信息:电流流经的`Volume`和体积电流密度**J**,它表示为一个`VectorField`。

这是描述由体积电流产生磁场的比奥-萨伐尔定律的版本:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/530equ01.jpg)

这是我们在第二十五章中处理过的同类型的向量体积积分。向量体积积分的被积函数是将源点**r′**映射到向量的函数。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/530equ02.jpg)

函数`bFieldFromVolumeCurrent`计算由体积电流密度产生的磁场。

bFieldFromVolumeCurrent
:: VectorField -- volume current density
-> Volume -- volume throughout which current flows
-> VectorField -- magnetic field (in T)
bFieldFromVolumeCurrent j vol r
= let coeff = mu0 / (4 * pi) -- SI units
integrand r' = (j r' >< d) ^/ magnitude d ** 3
where d = displacement r' r
in coeff *^ vectorVolumeIntegral (volumeSample 50) integrand vol


该函数与`bFieldFromSurfaceCurrent`的唯一区别在于,函数使用`vectorVolumeIntegral`进行体积分,而不是表面积分。与`bFieldFromLineCurrent`和`bFieldFromSurfaceCurrent`函数一样,该函数被`bField`函数调用。

### 总结

本章展示了如何计算由电流分布产生的磁场。我们编写了计算由线电流、表面电流和体积电流产生的磁场的函数。

bField :: CurrentDistribution -> VectorField


该函数通过结合我们为每个电流分布编写的函数,计算任意电流分布的磁场。我们已经研究了由电流线圈、理想磁偶极子、线圈螺线管和线圈环形磁体产生的磁场。在过去的四章中,我们专注于电磁理论中电荷(以及运动电荷,即电流)产生磁场的部分。在下一章,我们将转向电磁理论的另一部分,其中场对电荷施加力。

### 习题

**习题 27.1.** *法拉第定律* 断言磁通量与电流流经表面边界的倾向之间的关系。

磁通量 *Φ[B]* 通过表面 *S* 定义为磁场的点积分:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/531equ01.jpg)

我们在 第二十五章中定义了点表面积分。

(a) 编写一个函数

magneticFluxFromField :: VectorField -> Surface -> R
magneticFluxFromField = undefined


该函数接受一个磁场和一个表面作为输入,返回磁通量作为输出。

(b) 编写一个函数

magneticFluxFromCurrent :: CurrentDistribution -> Surface -> R
magneticFluxFromCurrent = undefined


该函数返回由给定电流分布的磁场通过给定表面产生的磁通量。

**习题 27.2.** 使用 第二十二章中的 `vf3D` 显示一个电流线圈的磁场 `circleB`。你需要查找一个合适的比例因子。圆形线圈中心的磁场大小为 *μ*[0]*I*/2*R*,其中 *I* 是线圈中的电流,*R* 是半径。你可以使用这个表达式来做出一个合理的比例因子初步猜测。

visLoop :: IO ()
visLoop = undefined


**习题 27.3.** 这是一个计算并显示带有 1000 匝导线的线圈磁场的程序。它与我们在本章中为 10 匝和 100 匝线圈所写的程序没有太大区别。

bFieldPicSolenoid1000 :: IO ()
bFieldPicSolenoid1000
= vfGrad (**0.2) ((y,z) -> cart 0 (0.02y) (0.02z))
(\v -> (yComp v, zComp v)) "bFieldPicSolenoid1000.png" 20
(bField $ wireSolenoid 0.01 0.1 10000 10)


结果图像,如图所示,完全不像螺线管的磁场。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/532fig01.jpg)

确定这种奇怪行为的原因并修正它。

**习题 27.4.** 绘制由一片螺线管产生的磁场图像。选择半径、长度、单位长度的匝数和电流的值。决定使用哪种可视化方法。

**习题 27.5.** 考虑由位于 xy 平面、原点处的半径为 0.25 米、电流为 10 安的圆形导线产生的磁场 `circleB`。绘制磁场的 z 分量随 x 轴位置变化的图像。它应该类似于 图 27-5。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/532fig02.jpg)

*图 27-5: 由电流圆形线圈产生的磁场*

**习题 27.6.** 考虑由带电流的圆形线圈产生的磁场。绘制磁场的 z 分量随 z 轴位置变化的图像。包括 *z* 的正值和负值。最大磁场应出现在 *z* = 0 处。

**练习 27.7.** 在上一章的一个练习中,我们介绍了赫尔姆霍兹线圈作为电流分布。绘制磁场的 z 分量作为 z 轴位置的函数的图像。包括 *z* 的正值和负值。你应该会发现,磁场在中心附近比由单个电流环产生的磁场更加均匀。


# 第二十八章:洛伦兹力定律

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

我们已经看到,现代电磁理论是一种场理论。电场和磁场的存在解释了电磁现象。电荷(及其运动形式——电流)既是这些场的源头,又是这些场对其施加力的接受者。因此,电磁理论有两个方面:电荷创造电场和磁场,电场和磁场对电荷施加力。在过去的四章中,我们处理了这两个方面中的第一个,涉及静态和稳定情况。我们展示了电荷如何创造电场,以及电流如何创造磁场。

在本章中,我们将考虑*洛伦兹力定律*,它通过描述电场和磁场如何对电荷施加力来解决电磁理论的第二个方面。接下来,在本书的下一章也是最后一章,我们将回到电磁理论的第一个方面,即*麦克斯韦方程*,它描述了电场和磁场如何在动态情况下被创造和演化。

本章的目标是描述带电粒子在电场和磁场中的运动。在简短讨论电磁理论中的静力学和动力学之后,我们将转向带电粒子在电场和磁场中的适当状态问题。然后,我们将介绍洛伦兹力定律,它描述了这种粒子所受的力。我们将解释电场的作用,然后讨论如何对受电场和磁场力影响的粒子执行状态更新函数。我们将描述粒子对施加的电场和磁场的反应,但关于移动电荷辐射的电磁场的讨论将留到本书的最后一章。最后,我们将通过一些粒子在电磁场中的动画来结束本章。

让我们从一些介绍性的代码开始。

### 介绍性代码

列表 28-1 展示了我们将在本章编写的`Lorentz`模块中的前几行代码。

{-# OPTIONS -Wall #-}

module Lorentz where

import SimpleVec ( R, Vec, (+), (^), (^), (^/), (><), zeroV, magnitude )
import Mechanics1D ( RealVectorSpace(..), Diff(..), rungeKutta4 )
import Mechanics3D ( HasTime(..), simulateVis )
import CoordinateSystems ( Position(..), VectorField, cart, v3FromPos, origin
, shiftPosition, addVectorFields, visVec )
import qualified Vis as V


*列表 28-1:`Lorentz`模块的开头代码行*

我们使用了来自第十章的`SimpleVec`模块、第十五章的`Mechanics1D`模块、第十六章的`Mechanics3D`模块和第二十二章的`CoordinateSystems`模块中的类型和函数。

这是我们第一次处理电磁理论的动力学,因此简短地讨论静力学和动力学将有助于为本章的内容铺垫基础。

### 静力学与动力学

本书的第二部分讲述了力学,这是一个*动态*的学科,因为我们感兴趣的量,包括位置、速度、加速度、力、动量和能量,都是随时间变化的。特别是牛顿第二定律是一个动态方程,因为它告诉我们粒子在力作用下如何改变速度。到目前为止,在本书的第三部分中,电磁学理论似乎是一个*静态*的学科,因为电场和磁场并没有随时间变化。在过去的四章中,我们花时间探讨了静电荷如何产生电场,以及稳定电流如何产生磁场。但实际上,电磁学理论和力学一样,也是一个动态的学科。

电磁学理论的两个方面都有与之相关的动力学。因为电荷运动和加速,电场和磁场随时间变化。我们将在本书最后一章探讨的麦克斯韦方程描述了这些场如何随时间变化。因为粒子受到力的作用,无论是电磁力还是其他力,其速度随时间变化。这就是我们在第二部分中讨论的力学动力学。一旦我们知道作用在粒子上的力,我们就使用牛顿第二定律和第二部分中的思想来找到粒子的运动。

处理动力学的一个好方法是问物理系统的状态如何随时间变化。专注于物理系统的状态如何随时间演变的做法,在电磁学理论中与在力学中同样有用。决定在状态中包含哪些状态变量是一个和当时一样重要的问题。选择状态变量并找到一个微分方程来表达这些变量如何随时间变化的策略,是一种超越许多物理学理论及其以外的策略。它不是理解物理的唯一方式,但它是一种非常重要且有用的方式。

在准备做电动力学时,我们需要考虑一个合适的状态。我们在状态中放入什么?我们可以从我们关心的量开始。在力学中,我们当然关心粒子的位置,因此我们将其放入状态中。如果我们关心的量的变化率依赖于其他量,我们可能也需要将它们放入状态中。在力学中,位置的变化率是速度,因此我们将速度放入状态中。速度的变化率依赖于作用在粒子上的力,因此我们可能选择将力所依赖的量放入状态中。

因此,我们将电场和磁场包括在状态中。从概念上和计算上来看,这是一大步的复杂性提升。从概念上讲,这是我们第一次在状态中包含函数。从计算上讲,从力学到电磁学理论的转变,意味着我们需要在状态中跟踪的数据信息量大大增加。在点粒子力学中,我们为系统中的每个粒子需要六个数字来描述状态。我们生活在三维空间中,因此需要三个数字记录每个粒子的位置,并需要三个数字记录每个粒子的速度。电场作为一个从空间到向量的函数,更接近于一个无限的数字集合,因为电场在空间中的每个点都有一个向量。

将电场和磁场包括在系统状态中的原因有两个。第一个原因是我们关心电场和磁场,并且希望了解它们如何随时间变化,尽管我们直到下一章才会讨论电场和磁场的变化。第二个原因是将场包括在状态中,是因为它们用于确定粒子所受的电力和磁力。我们将在本章中处理这个问题。

除了电场和磁场,我们还应该在状态中包括什么?一种选择是仅记录电场和磁场,而不包括粒子信息。这种状态选择对电磁波或已知源的辐射非常有用,但如果我们关心粒子的运动,就像本章所做的那样,这种选择就不再有用。因此,在本章中我们不会追求这种选择。

另一个关于状态的选择是,除了电场和磁场外,还包括我们关心的每个粒子的位置信息和速度信息。在第二十一章中,我们引入的描述带电粒子之间库伦力的两体力不再需要,因为电场和磁场现在是对粒子产生力的实体。麦克斯韦方程将描述电场和磁场如何根据系统中存在的带电粒子发生变化。牛顿第二定律将描述粒子的速度如何受到电磁力和可能的其他力的影响。事实上,通过引入场来移除两体力是自 1865 年以来物理学中的一个主题。牛顿的万有引力定律,作为一种两体力,一旦引入广义相对论的场论,就可以被移除。

然而,将多个粒子与场结合在一起时会遇到一个技术问题。问题在于每个粒子都会对电场和磁场做出贡献。一个带电点粒子对电场的贡献会随着场点接近粒子的位置而无限增大。按最直接、最天真的方式应用这些方程,会给每个粒子施加一个由它自身产生的无限大或未定义的力。这在概念上和计算上都会引发问题。最好的概念性解决方法是认为点粒子本来就是一种理想化的假设,并将电荷看作是分布在某个体积内,而非继续沿用电荷的粒子理论,而是采用电荷的场理论。一种计算上的“快速修复”是,对于每个粒子,仅跟踪由其他粒子产生的场。虽然这些复杂的情况很有趣,但我们不打算深入其中,因此我们不会考虑多个粒子与场的状态。

相反,我们将选择第三种方案:一个状态,其中包括单个粒子的位置和速度,以及电场和磁场。这将使我们能够集中研究单个粒子在电场和磁场中的运动。让我们看看这个状态是什么样的。

### 一个粒子和场的状态

通过为电场和磁场添加向量场,我们将第十六章中用于一个粒子的`ParticleState`类型扩展为包含场的`ParticleFieldState`类型。以下是使用记录语法定义的数据类型:

data ParticleFieldState = ParticleFieldState { mass :: R
, charge :: R
, time :: R
, position :: Position
, velocity :: Vec
, electricField :: VectorField
, magneticField :: VectorField }


如你所见,我们在状态中包含了质量、电荷、时间、位置和速度,这与第十六章中的`ParticleState`里的五个状态变量相同。现在,我们还加入了电场和磁场。除了为场添加两个新槽位外,我们还对第十六章中使用的`ParticleState`类型做了一个小小的调整。我们现在使用的是在第二十二章中定义的`Position`数据类型来表示位置,而不是我们在第二部分中使用的`Vec`。

正如我们在过去的章节中所做的,当我们为状态编写一个新的数据类型时,我们也会为状态导数编写一个新的数据类型。换句话说,我们编写一个数据结构来保存状态变量的时间导数。按照我们为命名这种状态导数类型所采用的模式,我们将新类型命名为`DParticleFieldState`。

data DParticleFieldState = DParticleFieldState { dmdt :: R
, dqdt :: R
, dtdt :: R
, drdt :: Vec
, dvdt :: Vec
, dEdt :: VectorField
, dBdt :: VectorField }


我们希望能够在这种情况下使用欧拉法和四阶龙格-库塔法来求解微分方程,这需要我们将新的数据类型声明为`RealVectorSpace`类型类的实例。这意味着我们要定义如何对`ParticleFieldState`进行加法操作,以及如何按实数缩放此类表达式。下面是该实例的声明:

instance RealVectorSpace DParticleFieldState where
dst1 +++ dst2
= DParticleFieldState { dmdt = dmdt dst1 + dmdt dst2
, dqdt = dqdt dst1 + dqdt dst2
, dtdt = dtdt dst1 + dtdt dst2
, drdt = drdt dst1 + drdt dst2
, dvdt = dvdt dst1 + dvdt dst2
, dEdt = addVectorFields [dEdt dst1, dEdt dst2]
, dBdt = addVectorFields [dBdt dst1, dBdt dst2]
}
scale w dst
= DParticleFieldState { dmdt = w * dmdt dst
, dqdt = w * dqdt dst
, dtdt = w * dtdt dst
, drdt = w *^ drdt dst
, dvdt = w *^ dvdt dst
, dEdt = (w *^) . (dEdt dst)
, dBdt = (w *^) . (dBdt dst)
}


注意

*不幸的是,这段代码是书中最重复且无信息的代码之一。它仅仅重复我们通过对状态变量的每个导数进行加法或缩放时所必须表达的显而易见的内容。写这种重复的、模板化的代码让我感到痛苦。值得探讨的问题是我们如何避免写这种代码。我很想给你答案,但我们必须集中精力完成当前任务,即定义一个新的数据类型,并确保它可以与我们已经编写的代码一起使用。*

我们需要一个`Diff`实例来描述`ParticleFieldState`和`DParticleFieldState`类型之间的关系。回想一下,这涉及到定义`shift`函数,展示如何通过导数在小时间间隔内平移状态变量。

instance Diff ParticleFieldState DParticleFieldState where
shift dt dst st
= ParticleFieldState
{ mass = mass st + dmdt dst * dt
, charge = charge st + dqdt dst * dt
, time = time st + dtdt dst * dt
, position = shiftPosition (drdt dst ^* dt) (position st)
, velocity = velocity st + dvdt dst ^* dt
, electricField = \r -> electricField st r + dEdt dst r ^* dt
, magneticField = \r -> magneticField st r + dBdt dst r ^* dt
}


和往常一样,我们通过将相应导数的乘积和时间步长来平移每个状态变量。

还有一个我们需要的类型类实例声明。当我们在第十六章中编写`simulateVis`函数以制作 3D 动画时,我们希望它能与我们定义的任何状态空间,或者将来可能定义的任何状态空间一起使用。`simulateVis`对状态空间的唯一要求是它必须包含时间的概念。下面的实例声明只是给出了返回状态时间的函数。

instance HasTime ParticleFieldState where
timeOf = time


现在我们已经为带有电场和磁场的粒子状态定义了一个新的数据类型,让我们转向讨论电场和磁场对粒子施加的力。

### 洛伦兹力定律

电场和磁场会对带电粒子产生力。作用在带电量为*q*,位置为**r**(*t*),速度为**v**(*t*)的粒子上的力,电场为**E**,磁场为**B**,根据洛伦兹力定律给出。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/541equ02.jpg)

电力,

**F**[electric] = *q***E**(**r**(*t*))

给予了电场意义的感觉。电场表示在空间位置上的单位电荷所受的力。空间中的某一点可能没有电荷,也可能有电荷,如果有电荷,那么电荷和该点电场向量的乘积就给出了作用在电荷上的力。正电荷感受到与电场向量相同方向的力;负电荷感受到与电场向量相反方向的力。

磁力

**F**[magnetic] = *q***v**(*t*) × **B**(**r**(*t*))

由于叉乘的存在,它更难以解释,这表明磁场对带电粒子的力与粒子位置的磁场向量以及粒子的速度是垂直的。电荷运动产生磁场和磁场对运动电荷的力之间有某种对称性,因为这两种过程都由包含叉乘的方程控制。磁力方程中出现电荷和速度意味着,要感受磁力,粒子必须带电并且处于运动状态。这是磁场创造和作用之间的另一种对称性。正如运动电荷或电流创造磁场一样,正是运动的电荷才会感受到来自磁场的力。

洛伦兹力定律,方程 28.1,仅仅是电场和磁场力的总和。函数`lorentzForce`在 Haskell 中表示洛伦兹力定律。

lorentzForce :: ParticleFieldState -> Vec
lorentzForce (ParticleFieldState _m q _t r v eF bF)
= q *^ (eF r + v >< bF r)


其中两个项,`eF`和`bF`,属于产生力的场。三个项,`q`、`r`和`v`,属于感受力的粒子。因为我们的状态包含了粒子和场的状态变量,所以洛伦兹力仅仅依赖于系统的状态。

### 我们真的需要电场吗?

如果我们使用现代电学的两部分观点,首先计算一个带电粒子,电荷为*q*[1],位置为**r**[1],所产生的电场**E**。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/541equ01.jpg)

然后应用洛伦兹力定律,计算一个带电粒子,电荷为*q*[2],位置为**r**[2]的受力。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/542equ01.jpg)

我们恢复了方程 21.2,即库仑的 18 世纪电学定律。这有些令人安慰,因为它给了我们一个机会,看看电场与库仑定律之间的关系。但是,介绍电场我们究竟得到了什么?它似乎不过是一个庞大的本体论和数学负担!如果我们最终只是回到了库仑的结果,那为什么还要引入电场呢?

答案是,电场在静态情况下(即电荷不移动或加速时)并未对库仑定律提供新的预测。在静力学的世界里,电场充其量只是一个便利,最糟糕时则是一个恼人的存在。然而,当电荷开始移动或加速时,库仑定律(如方程 21.1、21.2 和 21.3 中体现的)不再成立。我们在第二十五章中发展的方法也不再适用。如果带电粒子移动缓慢,库仑定律和第二十五章中的方程是不错的近似,但当电荷接近光速时,这些理论将完全失效。

更重要的是,当带电粒子加速时,它们会辐射。换句话说,它们会产生电场和磁场,这些场携带能量和动量远离加速粒子。电场和磁场有了自己的生命,它们的描述成为系统状态的重要组成部分。本章不涉及辐射的电磁场,但我们会在本书的最后一章进行讲解。

电场和磁场有助于维护局域性原则,即实体(粒子或场)之间的相互作用发生在彼此靠近的地方,而不是在远距离。牛顿的万有引力定律和库仑定律是*远距离作用*的例子。它们表明一个物体对另一个远离它的物体产生直接且瞬时的影响。人们已经讨论了这个问题的哲学意义几个世纪了。从数学上讲,在一个能够容纳普遍时间观念的框架中,远距离作用并不成问题。如果一个远方的粒子现在摆动,我立刻就能感受到引力的变化。但自从接受爱因斯坦的相对论以来,我们放弃了普遍时间的观念。更重要的是,相对论告诉我们,同时性的概念是依赖于观察者的(或至少依赖于参考系)。在相对论的框架下,像牛顿的万有引力和库仑定律这样的远距离作用定律是相当有问题的。爱因斯坦意识到,他在 1905 年提出的特殊相对论与牛顿的万有引力不兼容,到了 1915 年,他发展出了一种新的引力理论——广义相对论。

到了 1865 年,法拉第和麦克斯韦做到了类似于爱因斯坦在 1915 年对引力的贡献。如果一个带电粒子在一个地方摆动,它会改变粒子产生的电场。变化以光速在电场中传播,直到稍后才会影响第二个粒子。法拉第和麦克斯韦将电磁理论发展为一种场论,去除了远距离作用的需求,解释了电与磁的关系,预测了辐射,并给出了光的理论。电场现在被视为所有这些益处的微小代价。

现在,我们已经介绍了洛伦兹力定律,并稍作讨论,了解了它如何支配电磁理论中场对电荷施加力的部分,让我们将它与基于状态的方法结合起来,预测带电粒子在电场和磁场中的运动。

### 状态更新

与我们在第二部分中关于力学的前几章不同,在那些章节中我们考虑了来自任何源的作用在粒子上的力,而在本章中,我们假设构成洛伦兹力的电磁力是唯一作用在粒子上的力。这样做的原因是为了集中精力研究电磁理论。将我们在本章中编写的代码扩展到包括电磁学之外的任意力并不困难。

函数`newtonSecondPFS`(`PFS`代表`ParticleFieldState`)表示给出状态变量变化率的微分方程。唯一有趣的变化率是速度的变化率,根据牛顿第二定律,它是粒子上作用的净力除以其质量。

newtonSecondPFS :: ParticleFieldState -> DParticleFieldState
newtonSecondPFS st
= let v = velocity st
a = lorentzForce st ^/ mass st
in DParticleFieldState { dmdt = 0 -- dm/dt
, dqdt = 0 -- dq/dt
, dtdt = 1 -- dt/dt
, drdt = v -- dr/dt
, dvdt = a -- dv/dt
, dEdt = const zeroV -- dE/dt
, dBdt = const zeroV -- dB/dt
}


粒子的质量和电荷保持不变,因此它们的变化率为 0。时间的变化率为每秒 1 秒,因此其变化率为 1。位置的变化率由速度给出,这就是速度的定义。速度的变化率由加速度给出,根据牛顿第二定律,加速度等于净力除以质量。这里的净力就是洛伦兹力,因为我们决定将注意力集中在电磁力上。在本章中,我们不允许电场和磁场发生变化,因此它们的变化率为 0。由于它们是矢量函数,它们的变化率是一个常数函数,返回零向量,即`const zeroV`。

表 28-1 将函数`newtonSecondPFS`与我们在第二部分中处理的其他牛顿第二定律函数进行了比较。在那部分中,我们处理了其他状态空间。因为我们将注意力集中在电磁力上,而且这些力仅由粒子-场系统的状态信息决定,所以函数`newtonSecondPFS`不需要输入一个力的列表,而是其他表中所有函数所需要的。

**表 28-1:** 牛顿第二定律的函数

| **函数** | **类型** |
| --- | --- |
| `newtonSecondV` | `质量 -> [速度 -> 力] -> 速度 -> R` |
| `newtonSecondTV` | `质量 ->` |
|  | `[(时间, 速度) -> 力] -> (时间, 速度) -> (R, R)` |
| `newtonSecond1D` | `质量 -> [State1D -> 力] -> State1D -> (R, R, R)` |
| `newtonSecondPS` | `[单体力] -> 粒子状态 -> 微分粒子状态` |
| `newtonSecondMPS` | `[力] -> 多粒子状态 -> 微分多粒子状态` |
| `newtonSecondPFS` | `粒子场状态 -> 微分粒子场状态` |

回顾一下,数值方法允许我们将微分方程转化为状态更新函数。状态更新函数对动画非常重要,也有助于通过获得状态列表来解决问题。

函数`pfsUpdate`(`pfs`代表`ParticleFieldState`,但由于函数必须以小写字母开头,所以使用小写字母)将作为动画的状态更新函数,或用于生成状态列表。

pfsUpdate :: R -- time step
-> ParticleFieldState -> ParticleFieldState
pfsUpdate dt = rungeKutta4 dt newtonSecondPFS


该函数使用四阶 Runge-Kutta 方法,仅仅是因为它通常能给出最好的结果,但我们也可以使用任何数值方法。

有了状态更新函数,我们就可以开始在电场和磁场中动画化带电粒子的运动了。

### 在电场和磁场中动画化粒子

我们希望使用 `simulateVis` 函数来做 3D 动画。这要求我们为该函数提供五个输入项,分别是时间尺度因子、动画速率、初始状态、显示函数和状态更新函数。`pfsUpdate` 函数将作为我们的状态更新函数。

通过定义一个默认状态,提供初始状态变得更加容易。通过拥有默认状态,我们可以通过列出与默认状态不同的项来指定初始状态。默认状态下每个状态变量的值都设为 0。

defaultPFS :: ParticleFieldState
defaultPFS = ParticleFieldState { mass = 0
, charge = 0
, time = 0
, position = origin
, velocity = zeroV
, electricField = const zeroV
, magneticField = const zeroV }


以下函数 `pfsVisObject` 是一个显示函数,它将粒子显示为绿色球体,电场显示为一组蓝色向量,磁场显示为一组红色向量。

pfsVisObject :: R -- cube width
-> ParticleFieldState -> V.VisObject R
pfsVisObject width st
= let r = position st
xs = [-width/2, width/2]
es :: [(Position,Vec)]
es = [(cart x y z, electricField st (cart x y z))
| x <- xs, y <- xs, z <- xs]
maxE = maximum $ map (magnitude . snd) es
bs :: [(Position,Vec)]
bs = [(cart x y z, magneticField st (cart x y z))
| x <- xs, y <- xs, z <- xs]
maxB = maximum $ map (magnitude . snd) bs
metersPerVis = width/2
in V.VisObjects [ vectorsVisObject metersPerVis (2maxE) es V.blue
, vectorsVisObject metersPerVis (2
maxB) bs V.red
, V.Trans (v3FromPos (scalePos metersPerVis r))
(V.Sphere 0.1 V.Solid V.green)
]


该函数接受一个实数作为输入,指定我们希望显示的空间立方体的宽度。它计算八个位置的电场,并找出最大电场强度,以便缩放显示的电场向量。然后,它计算同样八个位置的磁场,并找出最大磁场强度,以便缩放显示的磁场向量。

该函数使用另一个函数,名为 `vectorsVisObject`,来绘制电场和磁场的图像。

vectorsVisObject :: R -- scale factor, meters per Vis unit
-> R -- scale factor, vector field units per Vis unit
-> [(Position,Vec)] -- positions to show the field
-> V.Color
-> V.VisObject R
vectorsVisObject metersPerVis unitsPerVis pvs color
= V.VisObjects [V.Trans (v3FromPos (scalePos metersPerVis r)) $
visVec color (v ^/ unitsPerVis) | (r,v) <- pvs]


该函数接受两个缩放因子作为输入:一个指定每个 Vis 单位的米数,另一个指定每个 Vis 单位的向量场单位数。然后,它接受一对对位置和向量的列表以及向量的颜色。`pfsVisObject` 函数使用 `vectorsVisObject` 两次:一次用于电场,一次用于磁场。

`pfsVisObject` 和 `vectorsVisObject` 都使用另一个辅助函数,名为 `scalePos`,该函数用于缩放位置。

scalePos :: R -> Position -> Position
scalePos metersPerVis (Cart x y z)
= Cart (x/metersPerVis) (y/metersPerVis) (z/metersPerVis)


该函数通过缩放每个位置坐标来工作。

我们的主程序,名为 `animatePFS`,是一个有趣的玩具。我们可以将电场和磁场设置为任何我们想要的样子,粒子的初始条件也可以是任何我们希望的样子,然后看看会发生什么。

animatePFS :: R -- time scale factor
-> Int -- animation rate
-> R -- display width
-> ParticleFieldState -- initial state
-> IO ()
animatePFS tsf ar width st
= simulateVis tsf ar st (pfsVisObject width) pfsUpdate


该函数接受时间尺度因子和动画速率作为输入,以及显示宽度和初始状态。它调用 `simulateVis` 来进行动画。

接下来的两个小节展示了在均匀电场和磁场中质子以及经典电子绕质子轨道的具体动画。

#### 均匀场

列表 28-1 显示了质子在均匀电场和磁场中的运动。系统的初始状态提供了质子的质量、质子电荷、初始质子速度、电场和磁场。电场是沿 y 方向的均匀场,磁场是沿 z 方向的均匀场。在这些场的作用下,质子表现出一种奇特的跳跃运动。

{-# OPTIONS -Wall #-}

import SimpleVec ( vec )
import Electricity ( elementaryCharge )
import Lorentz ( ParticleFieldState(..), animatePFS, defaultPFS )

main :: IO ()
main = animatePFS 1e-5 30 0.05
( defaultPFS { mass = 1.673e-27 -- proton in kg
, charge = elementaryCharge
, velocity = vec 0 2000 0
, electricField = _ -> vec 0 20 0
, magneticField = _ -> vec 0 0 0.01 } )


*列表 28-2:独立程序,演示质子在均匀电场和磁场中的运动*

通过改变电场、磁场或质子的初速度,你可以看到多种不同的运动。图 28-1 显示了动画的快照。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/547fig01.jpg)

*图 28-1:显示质子在特定电场和磁场中运动的动画截图*

#### 经典氢原子

我们的第二个具体动画是经典氢原子。氢是最简单的原子,由一个质子和一个电子组成。我们需要量子力学来正确描述氢的性质,但我们将探索电子在由质子产生的电场中的牛顿运动。这个电场提供了一个非均匀场的例子,展示了我们的代码可以处理任意电场和磁场。需要注意的是,我们的经典氢原子使用的是经典的牛顿力学理论,但并未使用完整的法拉第-麦克斯韦电动力学理论,这种理论通常被认为是“经典的”,因为它是非量子化的。法拉第-麦克斯韦理论,我们将在本书的最后一章中探讨,预测电子会辐射电磁能量,导致其向内螺旋运动。在这里,我们采用了经典氢原子的简化版本,其中电子不会辐射能量。

列表 28-3 显示了在由质子产生的电场中,电子的运动。

{-# OPTIONS -Wall #-}

import SimpleVec ( vec )
import Electricity ( elementaryCharge )
import CoordinateSystems ( cart )
import Charge ( protonOrigin )
import ElectricField ( eField, epsilon0 )
import Lorentz ( ParticleFieldState(..), animatePFS, defaultPFS )

main :: IO ()
main = animatePFS period 30 (4*bohrRadius)
( defaultPFS { mass = electronMass
, charge = -elementaryCharge -- electron charge
, position = cart bohrRadius 0 0
, velocity = vec 0 v0 0
, electricField = eField protonOrigin } )
where electronMass = 9.109e-31 -- kg
bohrRadius = 0.529e-10 -- meters
v0 = elementaryCharge
/ sqrt (4 * pi * epsilon0 * electronMass * bohrRadius)
period = 2 * pi * bohrRadius / v0


*列表 28-3:独立程序,演示经典氢原子中电子的运动*

在这种情况下,我们使用几个局部变量来确定圆周运动的初速度,这是初始状态下所需的,以及运动周期,这是时间尺度因子中使用的。电子执行圆周运动,类似于卫星绕行行星。图 28-2 显示了在由质子产生的电场中电子的动画快照。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/548fig01.jpg)

*图 28-2:显示电子在由质子产生的电场中运动的动画截图*

### 摘要

本章讨论了电磁理论中电场和磁场如何对电荷施加力的方面。洛伦兹力定律描述了电场和磁场对电荷施加的力。接着,我们通过使用牛顿第二定律,找到了带电粒子在电场和磁场中的运动,就像我们在第二部分中做的那样。我们为带电粒子定义了一个新的状态空间,包含电场和磁场。我们的微分方程和状态更新规则只修改了粒子的状态,电场和磁场的状态保持不变。我们将在下一章完成状态更新项目,那里麦克斯韦方程描述了电场和磁场随时间的变化。

### 练习

**练习 28.1.** 编写一个名为 `eulerCromerPFS` 的函数,类似于第十六章中的 `eulerCromerPS` 函数,使用 `ParticleFieldState` 数据类型实现欧拉-克罗梅方法。通过将 `pfsUpdate` 中的 `rungeKutta4` 替换为 `eulerCromerPFS` 来测试它。重新编译动画代码,看看是否有明显的差异。

**练习 28.2.** 假设一个电荷为 9 nC 的粒子固定在原点。(9 nC 的电荷大约是你从干衣机里拿出来的袜子上的电荷量。)一个从静止释放,距离 1 毫米的质子将加速远离原点。绘制质子的位置和速度随时间变化的图形。

**练习 28.3.** 假设一个 1 米 × 1 米的平板具有均匀的表面电荷密度 9 nC/m²。(9 nC 的电荷会将其电势提高到几十伏的水平。)一个质子从静止状态释放,距离平板中心 1 毫米,并加速远离平板。绘制质子的位置、速度和加速度随时间变化的图形。

**练习 28.4.** 一个半径为 10 厘米的电流环通过 100 A 的电流。(100 A 的电流通常是一个小房子或大公寓的最大用电量。)我们将电流环固定在 xy 平面,将其中心定位于原点,并使电流从正 z 轴方向看顺时针流动。我们希望观察电流环附近的质子运动效果。假设质子从位置

(*x, y, z*) = (11 厘米, −1 米, 0)

并给它一个初速度,方向为正 y 轴。当质子接近电流环时,它应该向左偏转,因为电流方向相同的电流会相互吸引。绘制质子在 xy 平面内的轨迹图。研究初始质子速度对偏转的影响。

**练习 28.5.** 探索质子在均匀磁场中没有电场的情况下的运动。通过改变初速度,你应该能够让质子沿着圆形、螺旋线或直线运动。哪些初速度会产生这些不同的运动?

**练习 28.6.** 重写 `newtonSecondPFS` 函数,使其能够接受非电磁力的列表作为输入。

newtonSecondPFS' :: [ParticleFieldState -> Vec]
-> ParticleFieldState -> DParticleFieldState
newtonSecondPFS' fs st = undefined fs st



## 第二十九章:麦克斯韦方程

  

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)  

在过去的几章中,我们介绍了电场和磁场,并指出这些场实际上是描述物理系统状态的一部分,至少对于那些电荷起作用的系统是如此。但我们尚未展示电场和磁场是如何随时间发展的。本章将通过引入麦克斯韦方程来解决这个问题。  

麦克斯韦方程描述了电场和磁场如何由电荷和电流产生,它们之间的关系以及它们如何随时间变化。与洛伦兹力定律一起,麦克斯韦方程表达了现代电磁理论。  

本章将从一些引言代码开始,接着介绍麦克斯韦方程。然后,我们将讨论这些方程所蕴含的电磁学之间的四种关系,以及这些方程如何与我们在前几章中对电场和磁场的处理相关。最后,我们将展示如何将状态更新技术应用于麦克斯韦方程。最后,我们将介绍有限差分时域(FDTD)方法来求解麦克斯韦方程,并利用该方法来模拟由振荡电荷产生的电场。  

### 前言代码  

列表 29-1 展示了我们将在本章编写的`Maxwell`模块的前几行代码。  

{-# OPTIONS -Wall #-}

module Maxwell where

import SimpleVec
( R, Vec(..), (^/), (+), (-), (*^)
, vec, negateV, magnitude, xComp, yComp, zComp, iHat, jHat, kHat )
import CoordinateSystems
( ScalarField, VectorField
, cart, shiftPosition, rVF, magRad )
import ElectricField ( cSI, mu0 )
import qualified Data.Map.Strict as M
import qualified Diagrams.Prelude as D
import Diagrams.Prelude
( Diagram, Colour
, PolyType(..), PolyOrientation(..), PolygonOpts(..), V2(..)
, (#), rotate, deg, rad, polygon, sinA, dims, p2
, fc, none, lw, blend )
import Diagrams.Backend.Cairo ( B, renderCairo )


*列表 29-1:`Maxwell`模块的开头代码*  

我们使用了第十章中`SimpleVec`模块的类型和函数,第二十二章中`CoordinateSystems`模块的类型和函数,以及第二十五章中`ElectricField`模块的类型和函数。在 FDTD 方法的部分,我们还做了对`Data.Map.Strict`的限定导入,并将其简写为`M`。我们还从`Diagrams`包中导入了几个类型和函数,以便在本章末进行异步动画演示。

### 麦克斯韦方程  

在国际单位制(SI)中,麦克斯韦方程包括以下四个方程:  

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/552equ01.jpg)![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/552equ02.jpg)![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/552equ03.jpg)![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/552equ04.jpg)  

电场用**E**表示,磁场用**B**表示,电流密度用**J**表示,电荷密度用*ρ*表示。记住,*ϵ*[0]是自由空间的介电常数,首次在第二十一章中介绍。符号*∇*被称为*散度算子*,在笛卡尔坐标系中其形式为:  

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/553equ01.jpg)  

这里的*算子*一词是物理学家使用的意思,指的是将一个函数作为输入并产生一个函数作为输出的东西。函数式编程中的程序员将此类对象称为高阶函数。  

在方程 29.1 和 29.2 中,梯度算符与点积符号的组合被称为*散度*,这是一个高阶函数,它将一个向量场作为输入,输出一个标量场。散度的定义是单位体积的通量,因此,向量场有正散度的地方是向量指向远离的地方。同样,向量场有负散度的地方是向量指向的地方。在笛卡尔坐标系中,向量场的散度看起来像以下形式:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/553equ02.jpg)

在方程 29.3 和 29.4 中,梯度算符与叉积符号的组合被称为*旋度*,这是一个高阶函数,它将一个向量场作为输入,输出一个向量场。旋度的定义是单位面积的环流,因此它描述了向量如何形成环流模式。向量场在 z 方向上有旋度的地方,是向量以逆时针方向平行于 xy 平面指向的地方。在笛卡尔坐标系中,向量场的旋度看起来像以下形式:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/553equ03.jpg)

方程 29.1 被称为高斯定律(你可能在第二十五章的习题中遇到过高斯定律)。高斯定律指出电荷决定了电场的散度。由于向量指向正散度的地方远离,而指向负散度的地方靠近,高斯定律说明电场从正电荷指向负电荷。

方程 29.2 被称为高斯的磁学定律,或称“没有磁单极子”。由于磁场的散度在空间和时间中的所有点都必须为 0,因此磁场没有磁荷可以指向或远离。

方程 29.3 被称为法拉第定律(你可能在第二十七章的习题中遇到过法拉第定律)。它断言了电场的旋度与磁场的时间变化率之间的关系。法拉第定律解释了电动发电机和变压器的原理。

方程 29.4 是*安培-麦克斯韦定律*,它断言了磁场的旋度、电场的时间变化率和电流密度之间的关系。

麦克斯韦方程中有四个独立变量:三个空间坐标和一个时间坐标。共有六个依赖变量:三个电场分量和三个磁场分量。我们可以将电荷和电流密度看作源项,它们是麦克斯韦方程的输入,决定了场以及场的变化。

在麦克斯韦方程中,电场和磁场首次出现在同一方程中。麦克斯韦方程描述了电与磁之间的四个关系,接下来我们将对此进行描述。

#### 电与磁之间的关系

方程 29.1 是纯电场方程,方程 29.2 是纯磁场方程。剩下的两个麦克斯韦方程表达了电与磁之间的四个关系。

首先,电荷在运动时会产生磁场。这是汉斯·克里斯蒂安·欧斯特德在 1820 年的发现。他观察到电流能够偏转指南针针头。这个关系通过安培-麦克斯韦定律(方程 29.4)来描述,其中电流密度**J**与磁场的旋度∇ × **B**相关。该定律的早期版本,即安培定律,忽略了电场的时间导数,因此以一种更简单(但不那么全面)方式表达了磁场对电流的依赖。

其次,变化的磁场会产生电场。这是法拉第的发现,称为法拉第定律(方程 29.3)。因此,从某种意义上说,电场有两个来源:一个是电荷,另一个是变化的磁场。库仑定律没有考虑磁场变化对电场的贡献,因此与相对论不兼容。今天,大多数电力发电厂使用法拉第定律来产生交流电流。涡轮机的旋转产生变化的磁场,进而产生电场,推动电流。

第三,变化的电场会产生磁场。1865 年,麦克斯韦在安培定律中加入了电场时间导数项,形成了安培-麦克斯韦定律(方程 29.4)。这个新增的项被称为*位移电流*,因为尽管它不是电流,但在磁场的产生中起着类似的作用。

最后,电场和磁场构成了光。现代光学理论断言,光是电磁波。不存在单独的电波或磁波。波动性质的电场总是伴随着波动性质的磁场。

#### 与库仑定律和比奥-萨伐尔定律的关系

如果麦克斯韦方程描述了电场和磁场是如何产生并发展的,为什么我们在第二十五章和第二十七章中没有使用它们呢?那两章分别介绍了电荷如何产生电场,电流如何产生磁场。那么,这些章节中的方法与麦克斯韦方程有什么关系呢?

记住,第二十五章介绍了在静态情况下(即电荷不运动或加速的情况)计算电场的方法。实际上,该章节的方法等同于库仑定律,对于相对于光速运动较慢的电荷,计算效果相当不错。在静态情况下,我们可以从麦克斯韦方程中去除两个时间导数项,使得电场的方程与磁场的方程解耦。因此,在静态情况下,方程 29.1 和 29.3 变为:

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/555equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/555equ02.jpg)

并描述静电。库仑定律是方程 29.8 和 29.9 的解。

类似地,在静态情况下,方程 29.2 和 29.4 变为

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/555equ03.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/555equ04.jpg)

并描述由稳态电流产生的磁场。第二十七章中的比奥-萨伐尔定律是方程 29.10 和 29.11 的解。

我们在第二十五章和第二十七章中介绍的静态方法非常有用,并且比麦克斯韦方程简单得多,但它们无法解释电荷快速或加速移动的动态情况。现在我们将转向解决麦克斯韦方程的任务,采用类似于我们在本书第二部分中解决牛顿第二定律时使用的状态更新方法。

#### 状态更新

为了理解电场和磁场如何随时间变化,重新排列麦克斯韦方程 29.3 和 29.4,使其给出电场和电流密度对场的变化速率会很有帮助。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/555equ05.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/555equ06.jpg)

方程 29.1 和 29.2 作为约束;只要电场和磁场在某一时刻满足这些方程,它们将继续满足这些方程,随着时间的推移根据方程 29.12 和 29.13 发生变化。

图 29-1 显示了麦克斯韦方程的示意图。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/556fig01.jpg)

*图 29-1:表示麦克斯韦方程的示意图*

这个图与我们在本书第二部分中为牛顿第二定律所作的图类似。最显著的区别是,这里由电线承载的值是矢量场,而在力学中它们是数值或矢量。正如我们在力学中通过积分加速度来获得速度一样,这里我们通过积分电场的旋度来获得磁场。就像在这种示意图中的环路表示力学中的微分方程一样,这里的环路也表明麦克斯韦方程是微分方程。

正如你在图 29-1 中看到的,磁场变化由电场的负旋度控制。电场的变化则由电流密度和磁场的旋度共同控制。这个示意图表示了方程 29.3 和 29.4。积分器是关于时间的,正如我们在所有类似的示意图中所做的那样。每个积分器下方的`VectorField`类型表示积分器中状态的性质。每个积分器都包含一个完整的矢量场作为状态,这个状态通过作为输入作用于积分器的矢量场来更新。方程 29.1 和 29.2 对可以作为状态由积分器保持的矢量场设置了约束。乘以*c*²和–*μ* [0]*c*²是适用于国际单位制(SI)的。

如同本书第二部分所述,我们解决麦克斯韦方程的方法涉及将时间离散化,更新我们关心的量,时间步长相对于显著变化发生的时间尺度来说是非常小的,然后在许多小的时间步长中迭代这一更新过程。对于麦克斯韦方程,我们关心的量是电场和磁场。我们使用方程 29.12 和 29.13 更新电场和磁场,以提供电场和磁场变化的速率。更新后的场值通过速率与时间步长的乘积进行变化。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/557equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/557equ02.jpg)

为了在 Haskell 中编码方程 29.14 和 29.15,我们需要计算向量场的旋度。接下来我们将讨论如何在 Haskell 中编写旋度。

#### 空间导数与旋度

在麦克斯韦方程中出现的散度和旋度是空间导数的一种类型。从概念上讲,最简单的空间导数是*方向导数*,它被定义为场值在指定方向上变化的速率。如果*f*是一个标量场,且![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ncap.jpg)是单位向量,则*f*在![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/ncap.jpg)方向上的方向导数被定义为标量场在两个距离为*ϵ*的点之间值的差异与*ϵ*的比率的极限。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/557equ03.jpg)

我们的计算方向导数不会采用极限,而是通过使用一个小的位移来计算比率 ![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/557equ04.jpg),在下面的代码中我们将其称为`d`:

directionalDerivative :: Vec -> ScalarField -> ScalarField
directionalDerivative d f r
= (f (shiftPosition (d ^/ 2) r) - f (shiftPosition (negateV d ^/ 2) r))
/ magnitude d


作为`directional` `Derivative`第一个输入的位移向量`d`有两个作用。它的方向指定了我们希望进行导数计算的方向。在微积分中,当我们取极限时,这就是它的唯一作用。但在计算中,我们的导数涉及的是小的但有限的步长,输入的第二个作用是它的大小指定了导数的步长。我们在两个点上评估场值:一个点沿位移向量`d`的半程偏移,另一个点是沿位移向量`d`的负半程偏移。我们找出这两个场值的差异,并除以位移向量的大小。

回顾方程 29.7,我们可以通过沿三个坐标方向的偏导数来计算旋度。相对于*x*的偏导数是沿![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/icap.jpg)方向的方向导数。代码中用于`curl`的局部函数`derivX`、`derivY`和`derivZ`即是偏导数。

我们用笛卡尔坐标系和偏导数的形式来表示向量场的旋度,正如方程 29.7 所示。

curl :: R -> VectorField -> VectorField
curl a vf r
= let vx = xComp . vf
vy = yComp . vf
vz = zComp . vf
derivX = directionalDerivative (a *^ iHat)
derivY = directionalDerivative (a *^ jHat)
derivZ = directionalDerivative (a *^ kHat)
in (derivY vz r - derivZ vy r) *^ iHat
+ (derivZ vx r - derivX vz r) *^ jHat
+ (derivX vy r - derivY vx r) *^ kHat


输入`a`是一个实数,指定用于计算旋度的空间步长。输入`vf`是我们想要计算旋度的向量场。局部变量`vx`、`vy`和`vz`是`ScalarField`类型,表示向量场`vf`的分量。偏导数`derivX`、`derivY`和`derivZ`的类型是`ScalarField -> ScalarField`。最后,我们使用公式 29.7 来计算旋度。

现在我们可以计算向量场的旋度了,接下来我们准备尝试用 Haskell 编码麦克斯韦方程。

#### 一个朴素的方法

麦克斯韦方程的最简单编码使用一个状态空间,由当前时间、电场和磁场组成。我们使用类型别名`FieldState`来描述一个三元组,其中包括一个表示时间的实数、一个表示电场的向量场和一个表示磁场的向量场。

type FieldState = (R -- time t
,VectorField -- electric field E
,VectorField -- magnetic field B
)


`maxwellUpdate`函数编码了公式 29.14 和 29.15,描述了电场和磁场如何随时间更新。

maxwellUpdate :: R -- dx
-> R -- dt
-> (R -> VectorField) -- J
-> FieldState -> FieldState
maxwellUpdate dx dt j (t,eF,bF)
= let t' = t + dt
eF' r = eF r + cSI**2 *^ dt *^ (curl dx bF r - mu0 *^ j t r)
bF' r = bF r - dt *^ curl dx eF r
in (t',eF',bF')


输入`dx`给`maxwellUpdate`是一个实数,描述用于公式 29.14 和 29.15 中的旋度的空间步长。输入`dt`是一个实数,描述时间步长。输入`j`是一个时间依赖的向量场,表示电流密度**J**。表 29-1 给出了公式 29.14 和 29.15 的数学表示与`maxwellUpdate`中的 Haskell 表示之间的对应关系。

**表 29-1:** 麦克斯韦方程的数学表示与 Haskell 表示的对应关系

|  | **数学** | **Haskell** |
| --- | --- | --- |
| 时间 | *t* | `t` |
| 位置 | **r** | `r` |
| 时间步长 | Δ*t* | `dt` |
| 光速 | *c* | `cSI` |
| 真空的磁导率 | *μ*[0] | `mu0` |
| 电流密度 | **J** | `j` |
| 电流密度 | **J**(*t*,**r**) | `j t r` |
| 电场 | **E**(*t*,**r**) | `eF r` |
| 磁场 | **B**(*t*,**r**) | `bF r` |
| 更新后的电场 | **E**(*t* + Δ*t*,**r**) | `eF' r` |
| 更新后的磁场 | **B**(*t* + Δ*t*,**r**) | `bF' r` |
| 旋度 | ∇× | `curl dx` |
| 电场的旋度 | ∇×**E**(*t*,**r**) | `curl dx eF r` |
| 磁场的旋度 | ∇×**B**(*t*,**r**) | `curl dx bF r` |
| 向量加法 | + | `^+^` |
| 向量减法 | – | `^-^` |
| 标量乘法 | 紧接运算 | `*^` |

我们通过将时间步长`dt`加到当前时间`t`来更新时间,得到更新后的时间`t'`。我们通过将 *c*²[∇×**B**(*t*,**r**) – *μ*[0]**J**(*t*,**r**)]Δ*t* 加到当前电场中来更新电场,得到更新后的电场。我们通过从当前磁场中减去[∇×**E**(*t*,**r**)]Δ*t*来更新磁场,得到更新后的磁场。

为了找到随时间变化的电场和磁场,我们可以迭代`maxwellUpdate`函数,生成一个长长的状态列表。`maxwell`的`Evolve`函数就是这样做的。

maxwellEvolve :: R -- dx
-> R -- dt
-> (R -> VectorField) -- J
-> FieldState -> [FieldState]
maxwellEvolve dx dt j st0 = iterate (maxwellUpdate dx dt j) st0


不幸的是,存在问题。虽然我们编写的代码可以编译并原则上能够运行,但它效率极低。问题在于计算机不会自动记住它已经计算过的函数值,而是一次又一次地重新计算相同的内容。对 Haskell 编译器来说,函数是根据输入计算输出的规则。如果我们知道将来需要某个函数的输出,作为 Haskell 程序员,我们有责任确保它可以获取,通常是通过为其指定一个名称。在这种简单方法中,电场在不同位置的值就是这样的函数输出。它们没有被存储,必须在每次需要时重新计算。例如,当我们到达第八个时间步时,计算机需要知道第七个时间步的电场和磁场值,但这些值没有被存储,因此必须重新计算。而第七个时间步的值又依赖于第六个时间步的值,而这些也没有存储,因此同样必须重新计算。

我们在这一部分编写的状态 `FieldState` 和更新方法 `maxwellUpdate`,虽然优雅且能说明我们希望计算机执行的操作,但在实践中并不可用,这也是我们称之为“简单”方法的原因。然而,我认为这段代码是有价值的。它通过类型检查,表明编译器同意我们请求的内容是有意义的。它以一种可读的风格编写,有助于我们理解麦克斯韦方程的内容。也许有一天,编译器会足够智能,能够规划哪些值应该被记住,因为它们将会被再次使用。

然而,今天我们希望编写可以运行并产生结果的代码。为此,我们将采用一种新的方法。

### FDTD 方法

我们在简单方法中看到,虽然使用函数来描述系统的状态在意义上是清晰的,且在表达上优雅,但这并不是解决麦克斯韦方程的高效方法。为了得到可以执行的代码,我们希望用数字来描述系统的状态,而不是函数。为了实现这一点,我们将选择一个大的但有限的空间位置数目,来跟踪电场和磁场分量。我们在此详细描述的方法称为*有限差分时域*(*FDTD*)方法,用于求解麦克斯韦方程。它是需要数值求解麦克斯韦方程的人们使用的最简单方法。FDTD 方法在 **[18**] 中有更详细的描述。

FDTD 方法仍然基于方程 29.12 和 29.13。每一个方程都是一个向量方程。将这些方程的笛卡尔分量写出是有帮助的。利用方程 29.7 表示旋度,方程 29.17、29.18 和 29.19 列出了方程 29.12 的 x、y 和 z 分量。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/560equ01.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/560equ02.jpg)![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/560equ03.jpg)

类似地,方程 29.13 的 x、y 和 z 分量如下:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/561equ01.jpg)![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/561equ02.jpg)![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/561equ03.jpg)

FDTD 方法通过使用对称的有限差分来近似每个偏导数。这里所说的对称是指,我们可以通过在时间和空间点(*t*,*x*,*y*,*z*)附近,分别采样该场分量的两个等距点,来近似场分量(*E[x]*,*E[y]*,*E[z]*,*B[x]*,*B[y]*,或*B[z]*)的偏导数。在涉及时间的偏导数时,采样点为(*t* + Δ*t*/2,*x*,*y*,*z*) 和 (*t* – Δ*t*/2,*x*,*y*,*z*)。在涉及空间的偏导数时,假设在 y 方向,采样点为(*t*,*x*,*y* + Δ*y*/2,*z*) 和 (*t*,*x*,*y* – Δ*y*/2,*z*)。例如,*E[x]*关于时间的偏导数被近似为

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/561equ04.jpg)

而*E[z]*关于*y*的偏导数被近似为

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/561equ05.jpg)

将这种有限差分近似应用到方程 29.17 并进行一些代数运算,得到一个方程,告诉我们如何更新电场 x 分量的值。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/561equ06.jpg)

对*E[y]*,*E[z]*,*B[x]*,*B[y]*,*B[z]*有五个类似的方程。方程 29.25 和其他五个方程可以简洁地用向量形式表示为

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/562equ01.jpg)![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/562equ02.jpg)

其中,旋度的各分量被近似如下:

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/562equ03.jpg)

注意方程 29.26 和 29.27 与方程 29.14 和 29.15 之间的相似性。唯一的不同在于,场的旋度和电流密度在 FDTD 方程 29.26 和 29.27 中,是在场的原值和更新值之间的中间时刻进行评估的,而在方程 29.14 和 29.15 中,旋度和电流密度是在场的原值时刻进行评估的,这更接近欧拉法。

方程 29.28 的旋度要求*B[y]*和*B[z]*的值,这些值距离当前空间步长的一半。更新一个时空点的*E[x]*依赖于同一地点早一步时间步长*Δt*的*E[x]*值。同时,它也依赖于在 z 方向上,距离该点半个空间步长,且早半个时间步长的*B[y]*值,以及在 y 方向上,距离该点半个空间步长,且早半个时间步长的*B[z]*值。

这些半空间步依赖性意味着我们跟踪六个分量的位置应该是错开的。我们跟踪*E[x]*的位置将略微偏移,和我们跟踪*E[y]*或*B[y]*的位置不同。方程 29.25 及其他五个类似的方程,分别对应*E[y]*、*E[z]*、*B[x]*、*B[y]*和*B[z]*,决定了我们应该在哪里跟踪每个分量。接下来,我们将描述用于跟踪电场和磁场分量的位置。

#### Yee 单元

我们将使用一个三元组`(nx, ny, nz)`来指定一个位置,用于跟踪场的分量。整数`nx`表示从坐标系原点出发,在 x 方向上的半空间步数。换句话说,如果`dx`是 x 方向上的空间步长,相当于数学符号中的Δ*x*,那么与`(nx, ny, nz)`相关的位置的 x 坐标为`fromIntegral nx * dx / 2`。偶数表示从原点起的整步,而奇数表示奇数个半步。表 29-2 显示了每个场分量的跟踪位置。

**表 29-2:** 我们计算电场和磁场分量的位置

| **组件** | `nx` | `ny` | `nz` |
| --- | --- | --- | --- |
| *E[x]* | 奇数 | 偶数 | 偶数 |
| *E[y]* | 偶数 | 奇数 | 偶数 |
| *E[z]* | 偶数 | 偶数 | 奇数 |
| *B[x]* | 偶数 | 奇数 | 奇数 |
| *B[y]* | 奇数 | 偶数 | 奇数 |
| *B[z]* | 奇数 | 奇数 | 偶数 |

存储*E[x]*值的位置保存在一个名为`exLocs`的列表中,该列表通过列表推导式形成,允许整数`nx`在一系列连续的奇数中变化,`ny`在一系列连续的偶数中变化,`nz`在一系列连续的偶数中变化,具体如表 29-2 所示。其他类似名称的列表保存其他场分量的位置。

exLocs, eyLocs, ezLocs, bxLocs, byLocs, bzLocs :: [(Int,Int,Int)]
exLocs = [(nx,ny,nz) | nx <- odds , ny <- evens, nz <- evens]
eyLocs = [(nx,ny,nz) | nx <- evens, ny <- odds , nz <- evens]
ezLocs = [(nx,ny,nz) | nx <- evens, ny <- evens, nz <- odds ]
bxLocs = [(nx,ny,nz) | nx <- evens, ny <- odds , nz <- odds ]
byLocs = [(nx,ny,nz) | nx <- odds , ny <- evens, nz <- odds ]
bzLocs = [(nx,ny,nz) | nx <- odds , ny <- odds , nz <- evens]


常数`spaceStepsCE`(CE 代表从中心到边缘)给出了从网格中心到边缘的完整空间步数。

spaceStepsCE :: Int
spaceStepsCE = 40


我们使用整数来指定网格中的位置。最大的偶数,称为`hiEven`,是从中心到边缘的完整步数的两倍。

hiEven :: Int
hiEven = 2 * spaceStepsCE


用于指定位置的偶数范围从`-hiEven`到`hiEven`。

evens :: [Int]
evens = [-hiEven, -hiEven + 2 .. hiEven]


用于指定位置的奇数从最低偶数上方开始,到最高偶数下方结束。

odds :: [Int]
odds = [-hiEven + 1, -hiEven + 3 .. hiEven - 1]


存储场分量的位置模式称为*Yee 单元*,如图 29-2 所示。Yee 单元以 1960 年代首创 FDTD 方法的 Kane S. Yee 命名。

![图片](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/564fig01.jpg)

*图 29-2:Yee 单元,展示电场和磁场分量的计算位置*

图 29-2 显示了三维空间的一块区域,坐标系中 *x* 向右增加,*y* 向上增加,*z* 向外增加。双箭头表示在空间中跟踪场分量的位置。黑色箭头表示电场分量,灰色箭头表示磁场分量。箭头指向的方向表示显示的是哪一个分量。例如,左右箭头表示 x 分量。 图 29-2 是一种直观的方式,表达了 表 29-2 中的信息。例如,*E[x]* 分量存储在 `(nx,ny,nz) = (1,2,0)` 位置,因为 `nx` 是奇数,`ny` 是偶数,`nz` 是偶数。

Yee 单元格的一个特点是,每个分量的最近邻包含更新该分量所需的信息。

接下来我们讨论如何在 FDTD 方法中表示电场和磁场的状态,并如何更新该状态。

#### 状态的类型

与我们在朴素方法中使用的 `FieldState` 数据类型不同,后者包含电场和磁场的函数,我们希望拥有一个状态,该状态保存电场和磁场分量在 图 29-2 的 Yee 单元格中指定位置的数值。

`Data.Map.Strict` 是标准的 Haskell 库模块之一,其中包含一个名为 `Map` 的数据结构,适合用于这个目的。类型 `Map k v` 是一个键值对查找表的类型,其中 `k` 是键的类型,`v` 是值的类型。在 第九章 中,我们展示了如何使用类型为 `[(k,v)]` 的一对列表作为查找表,但类型 `Map k v` 更好,因为它会以一种能够快速查找键的方式存储这些键。

对于键,我们将使用一个三元组 `(nx,ny,nz)`,它由 `Int` 类型的数字组成,用于描述场分量的位置;而值则使用一个实数 `R`。因此,我们希望存储场数据的类型是 `Map (Int,Int,Int) R`。

由于电场分量存储的位置与磁场分量不同,我们本可以使用一个单独的查找表,但我们选择使用两个表,一个用于电场,一个用于磁场,以使代码更易于阅读。

我们的状态空间称为 `StateFDTD`,包括时间、三个表示每个方向空间步长的实数、一个 `Map (Int,Int,Int) R` 表示电场,以及一个 `Map (Int,Int,Int) R` 表示磁场。虽然在状态中包含空间步长并非绝对必要,但这样做很方便,因为以状态作为输入的函数通常需要知道空间步长才能执行其任务。例如,计算场的旋度的函数就需要空间步长。

data StateFDTD = StateFDTD {timeFDTD :: R
,stepX :: R
,stepY :: R
,stepZ :: R
,eField :: M.Map (Int,Int,Int) R
,bField :: M.Map (Int,Int,Int) R
} deriving Show


在介绍性代码中,`import qualified Data.Map.Strict as M` 使我们能够访问 `Data.Map.Strict` 中定义的所有函数和类型,只要我们在它们前面加上大写的 `M`。由于我们是以这种方式导入模块,我们需要将类型 `Map (Int,Int,Int) R` 称为 `M.Map (Int,Int,Int) R`,正如上面所示,这个类型用于保存电场和磁场。

函数 `initialStateFDTD` 接收一个实数作为输入,表示在三个方向上的空间步长,并返回一个状态,在该状态下,电场和磁场在所有位置的值都为 0。

initialStateFDTD :: R -> StateFDTD
initialStateFDTD spatialStep
= StateFDTD {timeFDTD = 0
,stepX = spatialStep
,stepY = spatialStep
,stepZ = spatialStep
,eField = M.fromList [(loc,0) | loc <- exLocs++eyLocs++ezLocs]
,bField = M.fromList [(loc,0) | loc <- bxLocs++byLocs++bzLocs]
}


`Data.Map.Strict` 模块中的函数 `M.fromList` 将一个键值对列表的查找表转换为一个 `Map` 查找表。我们使用列表推导式来构建一个键值对列表,其中键是场分量的某个位置,值为 0。

`Data.Map.Strict` 模块使用严格求值,而不是 Haskell 的默认懒惰求值。在进行数值计算时,我们几乎总是希望使用严格求值。懒惰求值在我们可能根据输入数据计算程序的部分输出时非常有用。但当我们进行数值模型评估时,我们只希望在所有指定的点上计算我们感兴趣的量。在这种情况下,我们不需要为懒惰求值付出内存空间的代价(即内存指针,指向函数的代码或前一次求值的结果)。一个通用的经验法则是,除非你真的知道自己在做什么,否则函数的严格版本通常是你想要的。

现在我们来看一下如何在 FDTD 方法中计算旋度。

#### FDTD 和旋度

方程 29.28 显示了如何在 FDTD 方法中计算磁场的 x 分量旋度。还有五个类似的方程:两个用于磁场旋度的 y 分量和 z 分量,三个用于电场旋度的分量。方程 29.28 中旋度的近似值是基于方程 29.24 中偏导数的近似,因此我们首先需要编码偏导数。然而,比计算偏导数更基本的是从键值查找表中查找值,所以我们先来处理这个问题。

##### 在查找表中查找值

`Data.Map.Strict` 模块提供了 `lookup` 函数,我们写作 `M.lookup`,用于从查找表中获取值。我们来看看这个函数的类型。

Prelude> :l Maxwell
[ 1 of 13] Compiling Newton2 ( Newton2.hs, interpreted )
[ 2 of 13] Compiling SimpleVec ( SimpleVec.hs, interpreted )
[ 3 of 13] Compiling Mechanics1D ( Mechanics1D.hs, interpreted )
[ 4 of 13] Compiling Mechanics3D ( Mechanics3D.hs, interpreted )
[ 5 of 13] Compiling MultipleObjects ( MultipleObjects.hs, interpreted )
[ 6 of 13] Compiling MOExamples ( MOExamples.hs, interpreted )
[ 7 of 13] Compiling Electricity ( Electricity.hs, interpreted )
[ 8 of 13] Compiling CoordinateSystems ( CoordinateSystems.hs, interpreted )
[ 9 of 13] Compiling Geometry ( Geometry.hs, interpreted )
[10 of 13] Compiling Integrals ( Integrals.lhs, interpreted )
[11 of 13] Compiling Charge ( Charge.hs, interpreted )
[12 of 13] Compiling ElectricField ( ElectricField.hs, interpreted )
[13 of 13] Compiling Maxwell ( Maxwell.hs, interpreted )

Ok, 13 modules loaded.
*Maxwell> :t M.lookup
M.lookup :: Ord k => k -> M.Map k a -> Maybe a


如果我们想在不加载本章代码的情况下查看 `Data.Map.Strict` 中 `lookup` 的类型,可以执行以下操作:

*Maxwell> :m Data.Map.Strict
Prelude Data.Map.Strict> :t Data.Map.Strict.lookup
Data.Map.Strict.lookup :: Ord k => k -> Map k a -> Maybe a


在这里,我们使用函数的完全限定名称来请求 `lookup` 的类型,方法是将模块名加到函数名之前,以区分 `Data.Map.Strict` 中的 `lookup` 和 `Prelude` 中的 `lookup`。

从类型中我们可以看到,`M.lookup`需要一个键和一个查找表,并将返回类型为`Maybe a`的结果。如果它在表中找到了该键,它会返回与之关联的值,值会被`Maybe a`类型的`Just`构造函数包裹。如果它没有找到该键,则会返回`Nothing`。

我们的辅助函数`lookupAZ`使用`M.lookup`来完成其工作。

lookupAZ :: Ord k => k -> M.Map k R -> R
lookupAZ key m = case M.lookup key m of
Nothing -> 0
Just x -> x


函数`lookupAZ`(AZ 代表假定为零)的类型比`M.lookup`稍微简单一些。这个函数有两个作用。首先,它免去了我们每次查找时都需要对结果进行案例分析的麻烦。其次,当我们计算网格边缘位置的旋度时,我们会尝试查找那些不存在的值,因为它们就在网格之外。出于这两个原因,我们编写了一个函数,将不存在的键视作其值为 0。这个做法并不是最安全的,因为如果我们因编程错误请求了不存在的键,它不会帮助我们发现错误。我通常是一个相当小心和保守的人,但在这个特殊情况下,我决定冒点风险。

偏导数要求我们查找目标位置两侧半个空间步长的相关分量值。半个空间步长意味着在相关方向上一个整数较高和一个整数较低。函数`partialX`、`partialY`和`partialZ`的类型是相同的。

partialX,partialY,partialZ :: R -> M.Map (Int,Int,Int) R -> (Int,Int,Int) -> R
partialX dx m (i,j,k) = (lookupAZ (i+1,j,k) m - lookupAZ (i-1,j,k) m) / dx
partialY dy m (i,j,k) = (lookupAZ (i,j+1,k) m - lookupAZ (i,j-1,k) m) / dy
partialZ dz m (i,j,k) = (lookupAZ (i,j,k+1) m - lookupAZ (i,j,k-1) m) / dz


每个函数都接受一个空间步长、一个查找表(称为`m`,即地图)和一个位置作为输入。每个函数通过使用`lookupAZ`函数来检索给定位置两侧的值。将这些值的差值除以步长,得到偏导数的近似值。

##### 计算旋度

有了偏导数之后,我们现在转向旋度。这里有六个函数用于计算电场和磁场的旋度分量。方程 29.7 给出了旋度的分量。

curlEx,curlEy,curlEz,curlBx,curlBy,curlBz :: StateFDTD -> (Int,Int,Int) -> R
curlBx (StateFDTD _ _ dy dz _ b) loc = partialY dy b loc - partialZ dz b loc
curlBy (StateFDTD _ dx _ dz _ b) loc = partialZ dz b loc - partialX dx b loc
curlBz (StateFDTD _ dx dy _ _ b) loc = partialX dx b loc - partialY dy b loc
curlEx (StateFDTD _ _ dy dz e _) loc = partialY dy e loc - partialZ dz e loc
curlEy (StateFDTD _ dx _ dz e _) loc = partialZ dz e loc - partialX dx e loc
curlEz (StateFDTD _ dx dy _ e _) loc = partialX dx e loc - partialY dy e loc


每个旋度函数接受一个`StateFDTD`和一个位置作为输入。函数`curlBx`计算磁场旋度的 x 分量。根据方程 29.7,这是对*B[z]*关于*y*的偏导数(在上面的代码中表示为`partialY dy b loc`)和对*B[y]*关于*z*的偏导数(在上面代码中表示为`partialZ dz b loc`)之间的差值,每个值在给定的位置进行评估。为什么我们不在表达式`partialY dy b loc`中指定我们要求偏导数的是 z 分量?答案是,由于 Yee 单元的构造方式,我们需要计算`curlBx`的每个位置在 y 方向上有一个间隔为 1 的*B[z]*。磁场旋度的 x 分量仅用于更新电场的 x 分量。我们只有在更新*E[x]*时才使用`curlBx`,而*B[z]*在 y 方向上是它的邻居,因此在该位置进行`partialY`操作时,自动计算的是*B[z]*的偏导数。

现在让我们看看如何更新状态。

#### 状态更新

函数`stateUpdate`接受一个时间步长、一个时间依赖的电流密度和一个状态作为输入,并利用这些信息生成更新后的状态作为输出。它将实际的工作委托给`updateE`和`updateB`函数,分别更新电场和磁场。

stateUpdate :: R -- dt
-> (R -> VectorField) -- current density J
-> StateFDTD -> StateFDTD
stateUpdate dt j st0@(StateFDTD t _dx _dy _dz _e _b)
= let st1 = updateE dt (j t) st0
st2 = updateB dt st1
in st2


如方程 29.25 所示,更新电场需要知道电流密度,因此我们将当前时刻的电流密度`j t`作为`updateE`的输入。

`updateE`的作用是执行方程 29.25 以及从方程 29.18 和 29.19 得出的关于*E[y]*和*E[z]*的类似方程。

updateE :: R -- time step dt
-> VectorField -- current density J
-> StateFDTD -> StateFDTD
updateE dt jVF st
= st { timeFDTD = timeFDTD st + dt / 2
, eField = M.mapWithKey (updateEOneLoc dt jVF st) (eField st) }


函数`updateE`使用记录语法更新状态中的两个项目:时间和电场。该函数通过向当前时间`timeFDTD st`添加半个时间步长来更新当前时间。函数`updateB`添加另一个半时间步长。

我们使用`Data.Map.Strict`中的`mapWithKey`函数,在每个存储电场分量的位置更新它们。让我们看看`mapWithKey`的类型。

Prelude Data.Map.Strict> :m Data.Map.Strict
Prelude Data.Map.Strict> :t mapWithKey
mapWithKey :: (k -> a -> b) -> Map k a -> Map k b


`mapWithKey`函数接受一个高阶函数`k -> a -> b`作为输入。对我们来说,这将是一个函数`(Int,Int,Int) -> R -> R`。它描述了如何使用键值对的键和值来生成一个新的值。稍后定义的函数`updateEOneLoc dt jVF st`为我们提供了这个角色,描述了如何更新空间中特定位置的电场分量。

函数`updateB`更新磁场。它对磁场执行的操作与`updateE`对电场执行的操作相同。唯一的区别是更新磁场不需要电流密度,因此它不是`updateB`的输入。

updateB :: R -> StateFDTD -> StateFDTD
updateB dt st
= st { timeFDTD = timeFDTD st + dt / 2
, bField = M.mapWithKey (updateBOneLoc dt st) (bField st) }


正如所承诺的,`updateB`通过半个时间步长来增加时间,因此在我们使用了`updateE`和`updateB`之后,时间就增加了一个完整的时间步长。与`updateE`一样,`updateB`使用`mapWithKey`来执行所有我们在状态中跟踪的位置的更新。对于磁场,我们映射的查找表函数叫做`updateBOneLoc dt st`。我们稍后会定义它,它描述了如何更新空间中一个特定位置的磁场。

现在我们来看一下更新空间中某一点电场和磁场的函数。这里我们终于看到了麦克斯韦方程。`updateEOneLoc`函数负责更新空间中一个位置的电场分量。

updateEOneLoc :: R -> VectorField -> StateFDTD -> (Int,Int,Int) -> R -> R
updateEOneLoc dt jVF st (nx,ny,nz) ec
= let r = cart (fromIntegral nx * stepX st / 2)
(fromIntegral ny * stepY st / 2)
(fromIntegral nz * stepZ st / 2)
Vec jx jy jz = jVF r
in case (odd nx, odd ny, odd nz) of
(True , False, False)
-> ec + cSI2 * (curlBx st (nx,ny,nz) - mu0 * jx) * dt -- Ex
(False, True , False)
-> ec + cSI
2 * (curlBy st (nx,ny,nz) - mu0 * jy) * dt -- Ey
(False, False, True )
-> ec + cSI**2 * (curlBz st (nx,ny,nz) - mu0 * jz) * dt -- Ez
_ -> error "updateEOneLoc passed bad indices"


它需要输入时间步长、电流密度、状态、位置和当前的电场分量值。它使用`let`构造来定义一些局部变量。局部变量`r`表示由整数三元组`(nx,ny,nz)`描述的位置。电流密度需要这个位置,我们通过将每个整数乘以适当方向的半个空间步长来计算它。局部变量`jx`、`jy`和`jz`是该位置的电流密度分量。最后,我们通过检查这三个整数的奇偶性来决定更新哪个分量。正如表 29-2 所示,奇偶偶的整数三元组意味着我们在更新*E[x]*,偶奇偶的整数三元组意味着我们在更新*E[y]*,偶偶奇的整数三元组意味着我们在更新*E[z]*。我们包括最后一行代码来捕捉不属于这三种情况的三元组,这将表示我们的代码中存在错误,因为`updateEOneLoc`只能在包含电场分量的位置使用。

根据案例分析,我们使用方程 29.26 中的三个笛卡尔分量之一来更新电场分量,称为`ec`。局部变量`ec`包含当前要更新的电场分量的值(即*E[x]*、*E[y]*或*E[z]*之一)。

`updateBOneLoc`函数对于磁场的作用类似于`updateEOneLoc`函数对于电场的作用。

updateBOneLoc :: R -> StateFDTD -> (Int,Int,Int) -> R -> R
updateBOneLoc dt st (nx,ny,nz) bc
= case (odd nx, odd ny, odd nz) of
(False, True , True ) -> bc - curlEx st (nx,ny,nz) * dt -- Bx
(True , False, True ) -> bc - curlEy st (nx,ny,nz) * dt -- By
(True , True , False) -> bc - curlEz st (nx,ny,nz) * dt -- Bz
_ -> error "updateBOneLoc passed bad indices"


这个函数更简单,因为它不涉及电流密度。再次,根据描述位置的三个整数的奇偶性进行案例分析,以确定我们要求函数更新哪个磁场分量。正如表 29-2 所示,偶奇奇的整数三元组意味着我们在更新*B[x]*,奇偶奇的整数三元组意味着我们在更新*B[y]*,而奇奇偶的整数三元组意味着我们在更新*B[z]*。

根据具体的案例分析,我们使用方程 29.27 中的三个笛卡尔分量之一来更新磁场分量 `bc`。局部变量 `bc` 包含待更新的磁场分量,这可能是 *B[x]*、*B[y]* 或 *B[z]*,具体取决于三元组中整数的奇偶性。

这完成了 FDTD 方法的描述。`stateUpdate` 函数是希望使用此方法的入口点。它需要一个时间步长、一个时间相关的电流密度和一个初始状态,并返回一个更新后的状态。我们可能需要反复调用这个 `stateUpdate` 函数,以查看场的随时间变化。

现在让我们通过制作由振荡电流密度产生的辐射场动画来使用 FDTD 方法。

### 动画

加速电荷会辐射。换句话说,加速电荷会产生波动型的电场和磁场,从源电荷向外传播。我们可以通过使用我们开发的 FDTD 方法求解麦克斯韦方程来追踪电场和磁场随时间的演变。

#### 电流密度

在本节中,我们将制作一个由振荡电流密度产生的电场动画。我们的电流密度将在空间中局部化,我们将把坐标系的原点设在电流密度的中心。我们可以通过多种方式来产生局部化的电流密度。由于我们已经在 FDTD 方法中离散化了空间,一种指定局部电流密度的方法是允许电流密度在 FDTD 网格中的某个位置非零。对我们来说,更方便的一种方法是指定一个电流密度,该密度扩展到几个网格点,但随着距离中心的增加迅速减小。

一种以这种方式衰减的函数依赖于原点到位置的距离 *r*,其形式为 *e*^(–*r*²/*l*²)。这样的函数被称为*高斯函数*。它在原点的值最大,且其值随着距离的增加而减小。参数 *l* 具有长度的量纲,表示电流密度值显著的区域大小。当 *r* = *l* 时,高斯值为原点值的 36.8%;当 *r* = 2*l* 时,其值仅为原点值的 1.8%;而当 *r* = 3*l* 时,其值仅约为原点值的千分之一。方程 29.29 给出了我们用于辐射动画的电流密度。

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/571equ01.jpg)

我们需要三个参数来完全指定这个电流密度:幅度 *J*[0]、局部化长度 *l* 和频率 *f*。我们可以把这个电流密度看作是在原点振荡的电荷,其振荡方向为 z 轴。

`jGaussian` 函数描述了方程 29.29 中的电流密度。

jGaussian :: R -> VectorField
jGaussian t r
= let wavelength = 1.08 -- meters
frequency = cSI / wavelength -- Hz
j0 = 77.5 -- A/m²
l = 0.108 -- meters
rMag = magnitude (rVF r) -- meters
in j0 ^ exp (-rMag2 / l2) ^ cos (2pifrequency*t) *^ kHat


函数`jGaussian`使用一些局部变量来指定其行为。我们希望振荡以一定的频率发生,从而产生波长为 1.08 米的辐射。频率(以赫兹为单位)是光速除以波长。我们选择了 77.5 A/m²的幅度,因为这样能够辐射约 100 瓦的功率。我们选择参数*l*为 0.108 米,这也是我们稍后在网格的空间步长中选择的值。这意味着只有靠近原点的网格点才会包含任何显著的电流密度。

在决定了电流密度作为电场和磁场的源后,我们转向一些关于网格边界的说明。

#### 网格边界

FDTD 方法使用有限的网格,在有限数量的位置跟踪电场和磁场。我们使用相邻的网格点来计算麦克斯韦方程所需的旋度,正如前面所解释的那样。网格边缘发生了什么情况?我们做出的简单选择是假设网格之外的电场和磁场为 0。这个选择是由 lookupAZ 函数强制执行的,任何网格外的点都会返回 0。虽然这个选择简单且似乎合理,但它也有一些不理想的特性。一个外出的波会在网格的边缘反射,反弹回来并干涉。然而,如果网格非常大,反射波的幅度可能非常小,它的存在可能是可以容忍的。在我们的案例中,我们只显示发生计算的网格的部分区域。我们的动画在波到达边界之前就终止了,因此我们看不到任何反射波。通常情况下,使用我们简单的边界条件得到的结果只在波传播到网格边缘之前有效。也有更复杂的方法可以处理网格边缘的边界条件。一种方法是模拟一个吸收所有入射辐射的材料;这或多或少地像一个无限的盒子,而无需计算无限多的点。Inan 和 Marshall 的书籍**[18**]对 FDTD 方法的边界条件进行了很好的讨论。

即使没有复杂的边界条件,我们正在进行的计算也具有很高的计算量。生成所有 PNG 文件可能需要 20 分钟或更长时间,这些文件将被拼接在一起,生成最终的动画。文件在信息可用时生成,因此你可以在自己的机器上看到每分钟生成的文件数量,并估算整个批次所需的时间。

现在我们已经准备好转向关于生成异步动画帧的问题。

#### 显示函数

我们希望有一个函数能够根据`StateFDTD`生成图片。函数`makeEpng`就是为此而生。它根据电磁场的状态生成一个 PNG 图形文件。我们计划在每个时间步长生成一个这样的图形文件,然后将它们拼接成动画。我们在`makeEpng`中生成的图片是 xz 平面上的电场图。我们使用阴影来表示场的强度,从一个表示零场的颜色(通常是黑色或白色)过渡到另一个表示最大强度的颜色。

makeEpng :: (Colour R, Colour R) -> (Int,StateFDTD) -> IO ()
makeEpng (scol,zcol) (n,StateFDTD _ _ _ _ em _)
= let threeDigitString = reverse $ take 3 $ reverse ("00" ++ show n)
pngFilePath = "MaxVF" ++ threeDigitString ++ ".png"
strongE = 176 -- V/m
vs = [((fromIntegral nx, fromIntegral nz),(xComp ev, zComp ev))
| nx <- evens, nz <- evens, abs nx <= 50, abs nz <= 50
, let ev = getAverage (nx,0,nz) em ^/ strongE]
in gradientVectorPNG pngFilePath (scol,zcol) vs


函数`makeEpng`接受一对颜色作为输入,以及一对包含整数`n`和电磁场状态的值。颜色对由表示最强场的强色`scol`和表示零场的零色`zcol`组成。与状态配对的整数`n`作为 PNG 文件名称的一部分。

函数`makeEpng`使用局部变量来命名 PNG 文件、设置强电场的阈值,并列出需要显示的电场值。局部名称`pngFilePath`是一个`String`,其值是待生成的 PNG 文件的名称。该名称是*MaxVF*后跟整数`n`的三位数字,再后面是*.png*。我们使用阈值`strongE`来表示强电场,用于选择每个电场箭头的显示颜色。我们将 176 V/m 或更高的电场值用强色`scol`表示,0 值用零色`zcol`表示,介于两者之间的值则使用两种颜色的混合。

列表`vs`的类型为`[((R,R),(R,R))]`,包含了要显示的电场的二维位置和分量。下文定义的函数`getAverage`接受一组三个偶数作为输入,通过平均 Yee 单元两侧的值来生成空间中某一点的向量。最后,我们使用下文定义的函数`gradientVectorPNG`来制作图片。

#### 两个辅助函数

函数`getAverage`,之前在`makeEpng`中使用,用于通过平均周围位置的值来生成特定位置的场向量。由于 Yee 单元在不同的位置存储不同的场分量,我们可能会问是否有任何自然的方法可以将这些分量重新组合成一个单一的向量。答案是肯定的,只要我们愿意使用两个位置的值的平均值。在由偶偶偶三元组标记的 Yee 单元的任何位置,电场分量都存储在每个相邻的位置。通过平均这些值,我们可以在任何偶偶偶位置生成一个电场向量。类似地,我们可以在任何奇奇奇位置生成一个磁场向量。

getAverage :: (Int,Int,Int) -- (even,even,even) or (odd,odd,odd)
-> M.Map (Int,Int,Int) R
-> Vec
getAverage (i,j,k) m
= let vXl = lookupAZ (i-1,j ,k ) m
vYl = lookupAZ (i ,j-1,k ) m
vZl = lookupAZ (i ,j ,k-1) m
vXr = lookupAZ (i+1,j ,k ) m
vYr = lookupAZ (i ,j+1,k ) m
vZr = lookupAZ (i ,j ,k+1) m
in vec ((vXl+vXr)/2) ((vYl+vYr)/2) ((vZl+vZr)/2)


函数`getAverage`接受一个整数三元组作为输入,该三元组应该是电场的偶偶偶或磁场的奇奇奇,并附带一个查找表,然后返回一个矢量。它通过采样输入位置相邻的六个位置,平均每个方向的值,并将平均后的分量放入矢量中。

函数`gradientVectorPNG`,在之前的`makeEpng`中使用,与第二十二章中的`vfGrad`类似。它生成一个梯度矢量场图片。

gradientVectorPNG :: FilePath
-> (Colour R, Colour R)
-> [((R,R),(R,R))]
-> IO ()
gradientVectorPNG fileName (scol,zcol) vs
= let maxX = maximum $ map fst $ map fst $ vs
normalize (x,y) = (x/maxX,y/maxX)
array = [(normalize (x,y), magRad v) | ((x,y),v) <- vs]
arrowMagRadColors :: R -- magnitude
-> R -- angle in radians, ccw from x axis
-> Diagram B
arrowMagRadColors mag th
= let r = sinA (15 D.@@ deg) / sinA (60 D.@@ deg)
myType = PolyPolar [120 D.@@ deg, 0 D.@@ deg, 45 D.@@ deg
, 30 D.@@ deg, 45 D.@@ deg, 0 D.@@ deg
,120 D.@@ deg]
[1,1,r,1,1,r,1,1]
myOpts = PolygonOpts myType NoOrient (p2 (0,0))
in D.scale 0.5 $ polygon myOpts # lw none #
fc (blend mag scol zcol) # rotate (th D.@@ rad)
step = 2 / (sqrt $ fromIntegral $ length vs)
scaledArrow m th = D.scale step $ arrowMagRadColors m th
pic = D.position [(p2 pt, scaledArrow m th) | (pt,(m,th)) <- array]
in renderCairo fileName (dims (V2 1024 1024)) pic


函数`gradientVectorPNG`接受三个输入:PNG 文件的名称、要使用的颜色对以及二维矢量位置和分量的列表。它将传入的 PNG 文件名称字符串赋予局部名称`fileName`。它将局部名称`scol`和`zcol`分别赋给要在图片中使用的强色和零色。列表`vs :: [((R,R),(R,R))]`提供了要显示的矢量的位置(实数对的第一个部分)和分量(实数对的第二个部分)。这些二维矢量的大小预期在 0(将获得零色)到 1(将获得强色)之间。

函数`gradientVectorPNG`将描述箭头位置的最大值*x*赋予局部名称`maxX`。局部函数`normalize`接受一个(*x*,*y*)对作为输入,并返回一个位于(–1, –1)到(1, 1)的平方范围内的对。函数`normalize`假设要显示的区域是位于原点的 xy 平面上的一个正方形区域。局部列表`array`包含箭头要放置的归一化位置,以及每个箭头的大小和方向。

函数`arrowMagRadColors`是一个辅助函数,用于生成单个箭头的图示。我们将其定义为局部函数,因为只有`gradientVectorPNG`函数使用它。由于它是局部函数,因此可以使用局部颜色`scol`和`zcol`,而无需将这些颜色作为`arrowMagRadColors`的输入。函数`arrowMagRadColors`期望箭头的大小在 0 到 1 的范围内,将零颜色分配给 0,将强色分配给 1。

我们使用局部变量`step`来缩放箭头的大小。它基于每行要显示的箭头数量,该数量等于要在整个正方形中显示的箭头总数的平方根。局部变量`pic`保存着函数最终渲染的整个图片。

#### 主程序

列表 29-2 通过指定初始状态设置了时间步长、时间步数和空间步长。

{-# OPTIONS -Wall #-}

import Maxwell ( makeEpng, stateUpdate, jGaussian, initialStateFDTD )
import Diagrams.Prelude ( black, yellow )

main :: IO ()
main = let dt = 0.02e-9 -- 0.02 ns time step
numTimeSteps = 719
in sequence_ $ map (makeEpng (yellow,black)) $ zip [0..numTimeSteps] $
iterate (stateUpdate dt jGaussian) (initialStateFDTD 0.108)


*列表 29-2:用于生成电场动画 PNG 文件的独立程序*

它使用`sequence_`函数,该函数在第二十章中有描述,将一系列操作转换为一个单一的操作。由于函数应用运算符`$`是右结合的(回顾表 1-2),因此最容易从右到左阅读`mainPNGs`的定义。最右边的短语,

iterate (stateUpdate dt jGaussian) (initialStateFDTD 0.108)


是一个无限状态列表,从初始状态开始,其中电场和磁场在任何地方均为零,每个方向上的空间步长为 0.108 米。将`zip [0..numTimeSteps]`应用于此无限列表,生成一个有限列表,每个元素都是一个整数与一个状态的配对。将`map (makeEpng (yellow,black))`应用于这个配对列表,会生成一个类型为`[IO ()]`的有限列表。最后,应用`sequence_`将这些操作列表转换为一个单一操作。该程序将生成 720 个文件,命名为*MaxVF000.png*至*MaxVF719.png*,我们可以使用 ffmpeg 等外部程序将它们合并成一个 MP4 电影。

以下命令要求外部程序 ffmpeg 将所有名为*MaxVFDDD.png*的 PNG 文件合并,其中大写的 D 是数字。我们要求每秒 25 帧的帧率。最终的电影叫做*MaxVF.mp4*。

$ ffmpeg -framerate 25 -i MaxVF%03d.png MaxVF.mp4


我们使用 0.108 米的空间步长,因为它是我们期望的电流密度对应波长的十分之一。每个波长 10 个空间步长是我希望的最小步长。更多的空间步长会产生更精确的结果,但假设我们增加网格点的数量,以便使相同数量的波长适应网格,运行时间会更长。

时间步长需要稍微小于光传播一个空间步长所需的时间;否则,方法会变得不稳定。(有关稳定性标准的详细信息,请参见**[18**])光传播一个空间步长大约需要 0.36 纳秒。我们的时间步长 0.02 纳秒足够小,可以避免不稳定性。当然,较小的时间步长会产生更精确的结果,但计算时间会更长。

图 29-3 显示了动画的一个帧,唯一不同的是我们使用了黑色作为强色,白色作为零色。

![图像](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/577fig01.jpg)

*图 29-3:通过求解麦克斯韦方程组得到的电场,使用的是电流密度`jGaussian`。该图像是主程序生成的帧之一,显示了 xz 平面。*

电场的波动性质显而易见。磁场没有显示在图 29-3 中,它指向或背离页面。电场的强度随着距离源点的增加而减弱。辐射电场在*z* = 0 平面上较强,而在源点上下的 z 方向上较弱。

### 摘要

在本章中,我们看到了麦克斯韦方程如何描述电磁场的演化。我们确定了电和磁之间的四种关系,并解释了麦克斯韦方程如何与我们在前几章中对电场和磁场的描述相关联。我们看到了麦克斯韦方程如何像牛顿第二定律一样,可以被视为一种状态更新技术的规则。我们描述了用于求解麦克斯韦方程的 FDTD 方法,并将其应用于振荡电荷和电流密度产生的辐射。我们制作了一个由振荡电流密度产生的类波电场的动画。

本书我们已经涵盖了很多内容。许多想法真的很酷,但并不容易立即理解。如果你和大多数人一样,你可能已经理解了一些内容,但也有一些卡住了。当你遇到困难时,我建议你保持耐心和毅力。耐心尤其重要,它有时意味着跳到下一节或下一章的开始。我书架上曾有过一些书,几年都没法读懂,但不知怎么的,我逐渐获得了所需的背景知识,某一天终于能读懂它们了。

希望你喜欢本书提供的 Haskell 计算物理学的介绍。当然,你可以用任何编程语言来做计算物理学。通过将我们在这里所做的工作翻译成其他语言,你会学到很多东西。让我们简要回顾一下我们所做的工作,回想一下函数式语言在物理学中的好处。纯函数式语言让我们能够并鼓励我们将核心和重要的部分表达为一个单一的函数。第十六章中的`newtonSecondPS`函数表达了牛顿第二定律。本章中的`maxwellUpdate`函数表达了麦克斯韦方程。

纯函数式编程提供了比像 Python 这样的命令式语言更简单的计算模型,因为在纯函数式编程中,名称(变量)指向的是永远不变的量。这鼓励我们为改变名词的动词(函数)命名,而不是为改变的名词命名。物理学是一个天然的候选领域,适合利用纯函数式编程,因为物理学的核心概念,如牛顿第二定律和麦克斯韦方程,可以作为动词来表达。

此外,类型化的函数语言使我们能够精确地表达一个函数所描述的动词的性质。`newtonSecondPS`的类型表明我们可以从一个单体力的列表中产生一个微分方程。来自第二十七章的`bFieldFromLineCurrent`的类型表明我们可以通过一条曲线和电流计算出磁场。

我们写的有副作用的函数,比如带有`IO ()`类型的`gradientVectorPNG`,它们确实对于生成图形和动画非常有用,但它们并不是真正属于本书副标题中所承诺的“优雅代码”范畴。Haskell 在做这些事情上和任何其他语言一样强大,但函数式语言在物理学中的真正强项在于其核心思想的优雅表达,而这些思想是纯粹函数式的。在函数式语言中编程让我能够写出与我脑海中学科组织方式相符的代码。我发现这有助于我思考学科内容。

在函数式语言中编写物理学的实践仍处于起步阶段。关于这个主题的两本进阶书籍是**[20**]和**[11**]。物理学和函数式编程可以相互提供更多东西,仍有许多值得探索的领域。如果你对这些感兴趣,我希望你继续探索这些领域。

### 练习

**练习 29.1.** 使用`gnuplot`绘制高斯函数图像

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/579equ02.jpg)

对于多个*l*值。

**练习 29.2.** 修改主程序和`makeEpng`函数,生成由电流密度`jGaussian`产生的 xy 平面内的磁场动画。尝试使用 10^(–6) T 作为强磁场的阈值。

**练习 29.3.** 电流密度

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/579equ01.jpg)

具有一个振荡的磁偶极矩,而方程 29.29 的磁偶极矩则是一个振荡的电偶极矩。由方程 29.30 的电流密度产生的辐射被称为磁偶极辐射。制作一个 xz 平面内磁场的动画。它应该看起来类似于我们为方程 29.29 的电流密度制作的电偶极辐射的电场动画。尝试使用 2 × 10^(–7) T 作为强磁场的阈值。


## 附录

安装 HASKELL

![Image](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/lrn-phy-fp/img/common.jpg)

本附录解释了如何安装格拉斯哥哈斯克尔编译器以及其他人编写的库。

### 安装 GHC

格拉斯哥哈斯克尔编译器(GHC)是本书中使用的哈斯克尔编译器。它是免费的开源软件,任何人都可以下载和安装。

安装过程取决于你使用的操作系统。对于 GNU/Linux 和 macOS 用户,我推荐访问[*https://www.haskell.org*](https://www.haskell.org),然后选择**下载**。根据你的操作系统,按照相应的说明进行操作。你将知道安装成功,当你可以启动 GHCi 交互式编译器,通常通过在命令提示符下输入 `ghci` 来实现。此时,你已经准备好开始学习第一章。除了 GHC 本身,你使用的安装方法还将安装 Cabal 或 Stack。Cabal 和 Stack 是最常用的两种安装额外库包的工具。我将在本附录后面描述它们的使用。

对于微软 Windows 用户,我推荐按照[`www.fpcomplete.com/haskell/get-started/windows`](https://www.fpcomplete.com/haskell/get-started/windows)上的说明进行操作。FPComplete 是一家为工业哈斯克尔用户提供服务的公司。他们提供的安装程序将同时安装格拉斯哥哈斯克尔编译器和 Stack 库包管理器。当你能够启动 GHCi 交互式编译器,并在 PowerShell 提示符下输入 `stack ghci` 后看到 GHCi 提示符时,说明你已经成功安装。在此时,你已经准备好开始学习第一章。

### 安装文本编辑器

要编写源代码文件,你需要一个文本编辑器。你可以使用像 macOS 上的 Notes 或 Linux 上的 gedit 这样的基本文本编辑器,或者选择多个更复杂的文本编辑器。这些更复杂的编辑器通常具有对程序员有帮助的功能,例如文本高亮显示,且通常可以配置为根据你编写的编程语言进行敏感处理。

你可以在 Haskell 维基页面[*https://wiki.haskell.org/Haskell*](https://wiki.haskell.org/Haskell)找到关于如何使你的 Haskell 环境与编辑器顺利配合的建议。适合 Haskell 的编辑器有 Emacs、Vim、Visual Studio Code 和 Atom。像 Notes 这样的简单文本编辑器通常会随操作系统一起提供。Emacs 可以在[`www.gnu.org/software/emacs`](https://www.gnu.org/software/emacs)下载,Vim 可以在[`www.vim.org`](https://www.vim.org)下载,Atom 可以在[`atom.io`](https://atom.io)下载,Visual Studio Code 可以在[`code.visualstudio.com`](https://code.visualstudio.com)下载。按照你操作系统的说明进行操作。(想要在 macOS 上运行 Emacs 的用户应从[`emacsforosx.com`](https://emacsforosx.com)下载。这一链接提供了为 macOS 环境定制的标准 Emacs。由于它是标准的 Emacs,因此可以根据你在网上找到的建议,可靠地对其进行定制。定制的第一个步骤是[`www.emacswiki.org`](https://www.emacswiki.org)。)

### 安装 Gnuplot

从第七章开始,我们使用`gnuplot`来制作图表。`Gnuplot`是一个独立的图形程序,与 Haskell 无关,官网为[*http://gnuplut.info*](http://gnuplut.info)。安装`gnuplot`使其可以与 Haskell 一起使用是一个两步过程。首先,你需要安装`gnuplot`程序,使其能独立于 Haskell 运行。其次,你需要安装 Haskell 的`gnuplot`包,使得 Haskell 代码能够访问`gnuplot`的功能。本节内容涉及安装`gnuplot`程序,接下来的章节将解释如何安装 Haskell 的`gnuplot`包。

安装`gnuplot`程序的过程取决于你的操作系统。对于 GNU/Linux,你通常可以使用包管理器。例如,在 Ubuntu Linux 上,使用以下命令:

$ sudo apt install gnuplot


将会安装`gnuplot`程序。

在 macOS 上,我推荐使用 Homebrew 包管理器,网址为[`brew.sh`](https://brew.sh)。按照安装 Homebrew 的说明操作后,你可以执行以下命令来安装`gnuplot`程序:

$ brew install gnuplot


在 Microsoft Windows 上,按照[*http://www.gnuplot.info*](http://www.gnuplot.info)的说明下载 Windows 版`gnuplot`安装程序。运行安装程序,它会询问一系列问题,比如安装位置和其他安装细节。记下`gnuplot`安装的目录(可能是*C:*\*Program Files*\*gnuplot*\*bin*)。除了一个问题外,你可以接受安装程序的所有默认设置:当安装程序询问是否“将应用程序目录添加到你的 PATH 环境变量”时,勾选该选项。安装程序完成工作后,还有一件事需要做。使用文件浏览器,导航到`gnuplot`安装的目录,找到名为*wgnuplot*_*pipes*的文件。将此文件复制为同一目录下名为*pgnuplot*的新文件。如果文件名是*wgnuplot*_*pipes.exe*,将其复制为同一目录下名为*pgnuplot.exe*的新文件。这将允许 Haskell 使用`gnuplot`。

在这一点上,不管你的操作系统是什么,你应该能够独立于 Haskell 运行`gnuplot`程序。在命令行中,你需要输入以下内容:

$ gnuplot


启动`gnuplot`后,你应该能够在`gnuplot`提示符下发出命令,比如

gnuplot> plot cos(x)


应该会弹出一个包含图形的窗口。一旦你成功安装了`gnuplot`程序,你就可以准备安装 Haskell 的`gnuplot`包,它允许你从 Haskell 控制`gnuplot`。

### 安装 Haskell 库包

还有一些其他人编写的函数,我们希望使用,但这些函数没有包含在 Prelude(默认可用的标准函数集合)中。这些函数存在于可以在源代码文件中导入或直接加载到 GHCi 中的库模块中。GHC 附带了一组标准库模块,还有一些你可以通过 Cabal 或 Stack 安装的模块。标准库之外的库模块被组织成*包*,每个包包含一个或多个模块。

假设我们希望访问 Haskell `gnuplot`包提供的`Graphics.Gnuplot` `.Simple`模块中的`plotFunc`函数。我们必须安装`gnuplot`包。

安装 Haskell 库包的两个主要工具是 Cabal 和 Stack。你只需要使用其中一个。至少按照 GHC 安装说明,你会有其中一个工具可用。

Cabal(构建应用程序和库的通用架构)最早出现。在它编写时(大约 2005 年),为了最小化所需的下载量,Cabal 被设计为安装一组全局包,所有应用程序都应该针对这组通用包进行构建。同样,为了提高效率,Cabal 只允许安装每个包的一个版本。

这导致了一个问题:许多库迅速发展,添加了新特性并改变了它们的接口。一个常见的问题是,应用程序可能会依赖那些又依赖于不同版本的共同祖先的库。这有时需要卸载并重新安装所有包,偶尔还需要重新加载所有包的不同版本来构建一个新的应用程序。这个问题被称为“依赖地狱”或“Cabal 地狱”,这个名字足以让你了解它有多痛苦。

解决方法是允许安装多个版本的包,Cabal 现在允许这样做。

Stack 系统提供了与 Cabal 类似的许多功能,事实上,它可以与 Cabal 平稳共存,但它的目标略有不同。Stack 的目标是满足商业用户的需求,这些用户需要确保他们的应用程序即使在 Haskell 库基础设施不断发展的情况下也能正常构建。Stack 将这一目标称为“可重复构建”。为了实现可重复构建,Stack 的默认操作模式是让你指定一个编译器版本和一组已知与该编译器正常工作的精选包。精选包集包含超过 2000 个包,因此你很可能会在其中找到大部分需要的内容(如果没有,也不难指定你希望下载和构建的其他包)。这种看似复杂的方式的好处是,你的 Haskell 程序不仅每次都以相同的方式构建,而且以相同的方式运行。

Stack 和 Cabal 通常能够避免不一致的依赖项破坏大型复杂项目构建的问题。然而,这也有代价。它们可能会下载比你预期更多的包。特别是 Stack,可能会下载多个编译器,以确保包和编译器已知能产生一致的结果。这看起来似乎不必要,但这是 GHC 编译器工作方式所要求的。出于一些重要但繁琐的技术原因,GHC 编译器没有标准化的“应用二进制接口”(ABI)。这意味着你不能将用一个版本的 GHC 编译的库与用另一个版本编译的应用程序一起使用。这不是一个 bug——事实证明,为了得到一个纯粹的函数式语言、惰性求值和良好的性能,你需要放弃某些东西。而其中之一就是稳定的 ABI。

#### 使用 Cabal

要将模块加载到 GHCi 中,工作目录必须能够访问该模块。对于 GHC 安装本身提供的标准模块以外的模块,必须安装包含该模块的包。有两种使用 Cabal 安装包的方式:全局安装,这样包可以从任何目录访问;本地安装,这样它只能从当前工作目录访问。

##### 使用 Cabal 全局安装一个包

要全局安装 `gnuplot` 包,请执行以下命令:

$ cabal install --lib gnuplot


在我的计算机上,此命令会创建或更改文件*/home/walck/ .ghc/x86_64-linux-8.10.5/environments/default*,该文件包含全局安装的 Haskell 包列表。在您全局安装了一个或多个包后,类似我们刚刚发出的 Cabal 命令,可能会无法安装新包,因为 Cabal 找不到与已安装全局包兼容的请求包版本。解决此问题的一种方法是重命名包含全局包列表的文件,然后尝试同时安装所有需要的包。例如,要同时安装`gnuplot`、`gloss`和`cyclotomic`包,您可以发出以下命令:

$ cabal install --lib gnuplot gloss cyclotomic


因为我们重命名了全局包列表,Cabal 将找不到全局包列表,因此会创建一个新的包列表。

##### 使用 Cabal 本地安装包

要在本地(当前工作目录)安装`gnuplot`包,请发出以下命令:

$ cabal install --lib gnuplot --package-env .


命令末尾的点表示当前工作目录。此命令会在当前工作目录中创建或更改一个名为*.ghc.environment .x86_64-linux-8.10.5*的文件。该文件包含本地安装的包列表(位于当前工作目录)。当您在某个目录中本地安装了一个或多个包后,类似我们刚刚发出的 Cabal 命令,可能会无法安装新包,因为 Cabal 找不到与已安装本地包兼容的请求包版本。解决此问题的一种方法是重命名包含本地包列表的文件,然后尝试同时安装所有需要的包。例如,要同时安装`gnuplot`、`gloss`和`cyclotomic`包,您可以发出以下命令:

$ cabal install --lib gnuplot gloss cyclotomic --package-env .


因为我们重命名了本地包列表,Cabal 将找不到本地包列表,因此会创建一个新的包列表。

#### 使用 Stack

要使用 Stack 安装`gnuplot`包,请发出以下命令:

$ stack install gnuplot


在命令提示符下。Stack 比 Cabal 跟踪更多的幕后事项,全局安装通过 Stack 通常就是您所需要的。

安装完`gnuplot`包后,您可以将`Graphics.Gnuplot` `.Simple`模块加载到 GHCi 中。如果您使用的是 Stack,应该通过`stack ghci`启动 GHCi,而不是`ghci`。这样,Stack 就能找到您已安装的包的模块。

Prelude> :m Graphics.Gnuplot.Simple
Prelude Graphics.Gnuplot.Simple> :t plotFunc
plotFunc
:: (Graphics.Gnuplot.Value.Atom.C a,
Graphics.Gnuplot.Value.Tuple.C a) =>
[Attribute] -> [a] -> (a -> a) -> IO ()


在这里,我们请求`plotFunc`函数的类型,仅仅是为了展示它在我们加载了定义它的模块之后已经可以使用。

要在源代码文件中使用`plotFunc`函数,请包含以下行:

import Graphics.Gnuplot.Simple


在您的源代码文件顶部。

### 安装 Gloss

从第十三章开始,我们使用`gloss`来制作动画。与`gnuplot`不同,`gloss`不是一个独立的程序;它只是一个 Haskell 包。然而,`gloss`使用 freeglut 图形库来完成工作,freeglut 的功能由非 Haskell 库提供,这些库必须与`gloss`包本身分开安装。因此,像安装`gnuplot`一样,安装`gloss`是一个两步过程。首先,你需要安装非 Haskell 的 freeglut 库。其次,你需要安装 Haskell 的`gloss`包。

安装 freeglut 库的过程取决于你的操作系统。对于 GNU/Linux 系统,可以使用类似下面的命令:

$ sudo apt install freeglut3


应该可以解决问题。在 macOS 上,你可以使用类似的命令,借助`brew`包管理器。

$ brew install freeglut3


是你所需要的。你需要安装`brew`包管理器才能使用此命令。在 macOS 上,你可能还需要安装`xquartz`包来使用 freeglut,你可以通过下面的命令来安装:

$ brew install xquartz


对于 Microsoft Windows 系统,请在网上搜索“freeglut windows”并按照找到的说明操作。

安装了 freeglut 库后,你可以通过类似下面的命令来安装`gloss`包:

$ cabal install --lib gloss


或者

$ stack install gloss


这取决于你是使用 Cabal 还是 Stack。

### 安装 Diagrams

从第二十二章开始,我们使用`diagrams`包来可视化向量场。实际上,`diagrams`包只是对三个包的封装,分别是`diagrams-core`、`diagrams-lib`和`diagrams-contrib`。封装的目的是简化安装过程,因为你只需要发出一个命令而不是三个。我们将使用这三个包中的两个,再加上另一个。我们将使用`diagrams-core`、`diagrams-lib`和`diagrams-cairo`。

与`gloss`类似,`diagrams-cairo`包使用一些图形库来完成工作,必须将这些非 Haskell 库与`diagrams-cairo`包本身分开安装。因此,像安装`gnuplot`和`gloss`一样,安装`diagrams`也是一个两步过程。首先,你需要安装非 Haskell 的图形库。其次,你需要安装 Haskell 的`diagrams`包。

所需的图形库是`cairo`和`pango`。安装这些库的过程取决于你的操作系统。对于 GNU/Linux 系统,可以使用类似下面的命令:

$ sudo apt install libcairo2-dev libpango1.0-dev


应该可以解决问题。在 macOS 上,你可以使用类似的命令,借助`brew`包管理器。

在安装了`cairo`和`pango`库后,你可以通过类似下面的命令来安装`diagrams`包:

$ cabal install --lib diagrams-core diagrams-lib diagrams-cairo


或者

$ stack install diagrams-core diagrams-lib diagrams-cairo


这取决于你是使用 Cabal 还是 Stack。

### 设置你的编码环境

随着本书的进展,我们的代码变得越来越复杂,因为我们开始使用其他人编写的模块以及我们自己编写的模块。我们希望将一些代码加载到 GHCi 中,同时我们还希望编写独立的程序。因此,我们需要一种方法来保持代码的组织性,以便能够访问我们所需的模块,从而使我们能够做我们想做的事情。保持组织性的主要方法有两种:

(1) 将所有源代码文件保存在一个目录中。这包括用于加载到 GHCi 中的文件以及独立的程序。安装软件包,以便该目录能够访问它们。确保该目录能够访问书中的模块。

(2) 为你正在进行的每个项目创建一个新的目录。确保该目录能够访问项目所需的模块和软件包。每个目录可能会有一个 *.cabal* 文件,如果你使用 `stack`,还可能有一个 *stack.yaml* 文件。这些文件描述了你项目的需求。

我建议采用方法 (1),至少在你没有看到为新项目创建新目录的任何优势之前。就本书的目的而言,你需要做的练习并不大,每个练习并不需要自己的目录。

#### 我们对编码环境的需求

在给出关于如何组织你的编码环境的两条具体建议之前,让我们先明确我们想要实现的目标。以下是我们希望编码环境具备的四个期望特性:

(a) 我们希望能够通过 GHCi 的 `:l` 命令将我们编写的源代码文件加载到 GHCi 中。这样的源代码文件可能有模块名,也可能没有模块名。这样的源代码文件也可能会使用 Haskell 的 `import` 关键字导入模块,也可能不会导入模块。

(b) 我们希望能够通过 GHCi 的 `:m` 命令将他人编写的模块,如 `Graphics.Gnuplot.Simple`,加载到 GHCi 中。

(c) 我们希望能够从我们编写的源代码文件生成可执行程序。这样的源代码文件可能会使用 Haskell 的 `import` 关键字导入模块,也可能不会导入模块。

(d) 我们希望能够通过将模块加载到 GHCi 中以及编写源代码 `import` 这些模块来使用本书中定义的模块。

如需将源代码文件加载到 GHCi 中,如 (a) 所述,我们需要在源代码文件所在的目录中启动 GHCi。如果我们的源代码文件导入了模块,它需要能够访问这些模块。如果源代码文件导入的模块由某个包提供,则当前工作目录必须能够访问该包。这可以是本地访问,也可以是全局访问,如本附录前文所定义。如果该模块是在源代码文件中定义的,例如本书中编写的模块之一,那么该文件必须位于工作目录中,或者位于 GHC 知道要查找的位置。

如需将他人编写的模块加载到 GHCi 中,如 (b) 所述,工作目录需要能够访问提供我们希望加载的模块的包。这可以是本地访问,也可以是全局访问,如前文所述。

生产一个独立的程序,如(c)所需的内容,是第十二章的主题。在那里,我们讨论了三种生成独立程序的方法:一种使用 GHC,一种使用 Cabal,另一种使用 Stack。如该章所述,使用 Cabal 或 Stack 是一种方法(2),因为我们每个目录中只能拥有一个*.cabal*文件。然而,该*.cabal*文件允许指定多个独立程序,因此可以使用 Cabal 或 Stack 与方法(1)结合使用。

为了实现(d),最简单的方法是将所有定义模块的*.hs*文件(例如*Mechanics3D.hs*,它定义了`Mechanics3D`模块)放入你的工作目录。由于你编写的源代码文件也在此目录中,GHC 在你加载该文件到 GHCi 时,或在你使用 GHC 编译它时,会在工作目录中查找你的源代码文件所导入的模块。

以下两个部分将提供关于将本书中定义的模块放置在哪些位置的具体建议,你可以在[`lpfp.io`](https://lpfp.io)下载相关文件。这两个建议是替代方案,你只需遵循其中一个即可。

#### 所有代码放在一个目录中

如前所述,保持组织最简单的方法是将所有内容放在一个目录中。这包括:

+   你打算加载到 GHCi 中的源代码文件

+   你打算编译成可执行程序的源代码文件

+   本书中定义的模块的源代码文件,例如*Mechanics3D.hs*

这个目录将是你所有 Haskell 工作的工作目录。如果你继续编程 Haskell,你会逐渐超越这种方法。你将希望处理不同目的和需求的不同项目,而不希望将所有代码放在一个目录中。当你到达这个阶段时,有很多前进的方式。Cabal 和 Stack 工具提供了许多组织工作的方式。

目前,我们需要确保我们的工作目录可以访问本书项目所需的所有包。以下命令需要在命令提示符下以一行输入,它将本地安装我们本书所需的所有包。

$ cabal install --lib gnuplot gloss not-gloss spatial-math diagrams-lib
diagrams-cairo --package-env .


这种方法的一个缺点是我们可以通过 GHCi 的`:l`命令加载书中的模块,但无法通过 GHCi 的`:m`命令加载,这意味着我们一次只能加载一个书中的模块。如果我们希望在 GHCi 中访问不同模块中定义的函数,这可能会很不方便。一种解决方法是创建一个新的源代码文件,将我们需要的所有模块导入其中,然后使用`:l`命令将该源代码文件加载到 GHCi 中。

另一种解决此缺点的方法是使用 Stack 工具来管理本书中的模块,如下一节所述。

#### 使用 Stack 的一种方式

Cabal 和 Stack 工具提供了许多(可能太多)方法来组织你的 Haskell 工作。在这里,我们将详细探讨一种方法。在这种方法中,我们仍然有一个目录来存放所有的 Haskell 工作,但这个目录有两个子目录:一个用于书籍模块,另一个用于独立程序。因此,源代码文件可以存在三个地方。它们可以存放在主工作目录中,也可以存放在模块子目录中,或者可以存放在独立程序子目录中。你打算加载到 GHCi 中的源代码文件可能会存放在主工作目录中。

Stack 需要两个配置文件来管理事务。一个名为 *LPFP.cabal*,另一个名为 *stack.yaml*。这两个文件将位于主工作目录中。文件 *LPFP.cabal* 描述了我们希望访问的模块,以及我们希望 Stack 为我们构建的可执行程序。列表 A-1 给出了这个文件。

cabal-version: 1.12

name: LPFP
version: 1.0
description: Code for the book Learn Physics with Functional Programming
homepage: http://lpfp.io
author: Scott N. Walck
maintainer: walck@lvc.edu
copyright: 2022 Scott N. Walck
license: BSD3
license-file: LICENSE
build-type: Simple

library
exposed-modules:
Charge, CoordinateSystems, Current, ElectricField, Electricity, Geometry
, Integrals, Lorentz, MagneticField, Maxwell, Mechanics1D, Mechanics3D
, MOExamples, MultipleObjects, Newton2, SimpleVec
hs-source-dirs: src
build-depends:
base >=4.7 && <5, gnuplot, spatial-math, gloss, not-gloss, diagrams-lib
, diagrams-cairo, containers
default-language: Haskell2010

executable LPFP-VisTwoSprings
main-is: VisTwoSprings.hs
hs-source-dirs: app
build-depends: LPFP, base >=4.7 && <5, not-gloss
default-language: Haskell2010

executable LPFP-GlossWave
main-is: GlossWave.hs
hs-source-dirs: app
build-depends: LPFP, base >=4.7 && <5, gloss
default-language: Haskell2010


*列表 A-1:描述我们希望访问的模块和我们希望生成的可执行程序的文件* `LPFP.cabal`

在一些介绍性内容之后,出现了一个库段和两个可执行段。库段列出了我们希望访问的本书中的所有模块。它说明了这些模块的源代码位于 *src* 子目录中,并且这些模块依赖于几个包,例如 `gnuplot` 和 `gloss`。`base` 模块包含了大多数简单数据类型所需的基础库。版本规范表示“版本 4.7 或更新,但主版本必须小于 5。” “default-language” 规范告诉我们我们使用的是 2010 版本的 Haskell 语言规范,这是当前版本。之前的版本是 Haskell98,这让你对语言的主要版本修订间隔有个概念。

每个我们希望 Stack 为我们构建的独立程序都有一个可执行段。这里列出了两个,但你可以根据需要列出任意数量的程序。第一个可执行段描述了位于名为 *app* 的子目录中的源代码文件 *VisTwoSprings.hs* 的独立程序。该可执行程序将被命名为 *LPFP-VisTwoSprings*,并可以在任何目录下全局运行。此独立程序所需的包也在此列出。

在撰写本文时,`diagrams` 包不包括在 Stack 默认使用的精选包列表中,因此我们必须在名为 *stack.yaml* 的文件中列出一些额外的包。列表 A-2 显示了这个文件。

resolver: lts-18.21

packages:

  • .

extra-deps:

  • diagrams-cairo-1.4.1.1
  • diagrams-lib-1.4.4
  • active-0.2.0.15
  • cairo-0.13.8.1
  • diagrams-core-1.5.0
  • dual-tree-0.2.3.0
  • monoid-extras-0.6.1
  • pango-0.13.8.1
  • statestack-0.3
  • glib-0.13.8.1
  • gtk2hs-buildtools-0.13.8.2

*列表 A-2:描述本书中模块所需额外依赖的文件 stack.yaml*

对于每个编译器版本,Stack 支持一组已知能与该编译器一起构建并且通常互相兼容的精选软件包。通过版本号指定编译器和软件包集。在示例 A-2 中,`resolver`字段中的`lts-18.21`表示“GHC 8.10.7 及其兼容的软件包”。这个特定的编译器/软件包集合有长期支持(`lts-`前缀)。这意味着你可以依赖它会持续一段时间,通常是几年。

如果你需要在前沿技术中生活以获取所需的功能,可以使用快照集合,若需要最新的功能,则可以使用夜间构建版本。

下一个字段`packages`指的是*你*自己编写的包,通常是对你自己项目有用的库。在示例 A-2 中,包只是当前目录中的文件,或者用 Unix 术语表示为“`.`”。

`extra-deps`是你的应用程序所依赖的额外包,这些包不属于`resolver`字段指定的精选包集合。(事实上,`package`和`extra-dep`之间没有太大区别,除了我们可以为自己的包编写测试和基准目标——这是大型应用程序中非常重要的部分——而这些对于`extra-deps`来说是不可用的。)

如果你有关于*stack.yaml*文件的问题,首先可以访问[`docs.haskellstack.org/en/stable/README`](https://docs.haskellstack.org/en/stable/README)。

你可以看到我们感兴趣的包`diagrams-core`、`diagrams-lib`和`diagrams-cairo`。其余的包是`diagrams`所依赖的包。具体版本的这些包也列出了。在本书出版时,这些包的更新版本可能已经发布。

要构建可执行程序,在主工作目录中输入以下命令(该目录包含*stack.yaml*和*LPFP.cabal*文件):

$ stack install


要启动一个 GHCi 会话,其中所有的书籍模块都会自动加载,你可以输入以下命令:

$ stack ghci


使用这种方法,我们可以将任何或所有的书籍模块加载到 GHCi 中。要移除一个模块,你可以使用 GHCi 的`:m`命令,模块名前加上减号。要移除`Newton2`模块,输入以下命令:

ghci> :m -Newton2


同样,要添加一个额外的模块,使用加号前缀。要添加`Graphics.Gnuplot.Simple`模块,输入以下命令:

ghci> :m +Graphics.Gnuplot.Simple


输入`stack ghci`命令还可以为你提供将可执行程序之一加载到 GHCi 中的选项,如果你愿意的话。

### 总结

本附录介绍了如何安装 Haskell 编译器和文本编辑器,并讲解了使用 Cabal 和 Stack 安装额外库包的方法。它还展示了组织库和源代码文件以便在 Haskell 中构建项目的不同方式。
posted @ 2025-11-28 09:41  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报