V. 编码以及更好地编码
1. 编写标注
当我们开始编写略微复杂一些的函数时, 我们可能很难在第一次的时候就写出完全正确的代码。我们通常会拿出一张纸和一根笔来画出示意图,或者写几个例子来更好地理解我们的代码是如何运行的。
同样的,当我们在读代码时,有时也很难在第一眼就看出一个复杂的函数是怎样运行的。
为了避免这些问题,我们经常会有一个标注(specification)的步骤,其中以明确的方式写明了我们的代码是如何运行的。然后,然后,我们会到执行部分,也就是代码本身。这样,我们在编写代码时就知道我们要做什么而不是四处摸索,并且我们也能避免很多的逻辑错误。
具体而言,标注就是放在函数代码前的注释(或者是代码之外,特别是在考试中仅仅要求写出标注时)。这些注释包括:
- 函数的
名称; - 函数的
简况(profile),对应数学意义上开始的集合和结束时的集合(比如说,我们写做 ℝ 而不是float); - 以及一个
signature,如果需要的话。在OCaml中这与函数的简况(profile)有些相似(通常是指函数的类型)。如果函数的signature与简况一致的话,就没有必要再写了。 描述(也被称作语义(semantic)),用文字解释了这个函数的作用;- 如果这是一个递归函数,要指出它的
递归等式(我们会在本节靠后的位置看到); - 最好有如何使用的
恰当的例子;
这是一个具体的例子,此函数可以给出两个元素的最大值:
(* maximum : ℕ → ℕ → ℕ
* int -> int -> int
*
* 返回两个数中的最大值。
*
* maximum 2 3 = 3
* maximum 5 5 = 5
*)
为了使不同的部分更加明确,我们通常会加上标题:
(* SPECIFICATION : maximum
* PROFIL : ℕ → ℕ → ℕ
* SIGNATURE : int -> int -> int
*
* SEMANTIC : Renvoie le maximum de deux entiers.
*
* EXEMPLES :
*
* maximum 2 3 = 3
* maximum 5 5 = 5
*)
在这种情况下,同时编写profile和signature是有意义的,因为在“数学”意义上和在OCaml中,集合ℕ和int是不同的表达方式。
但是,如果我们看到了以下代码:
type seq =
| End
| Element of int * seq
let rec times_two (s : seq) : seq =
match s with
| End -> End
| Element(x, continue) -> Element(x * 2, times_two continue)
为了更精准描述times_two这个函数,我们的注释可以写成如下所示的形式,这次profile和signature并没有区别:
(* SPECIFICATION : first
* PROFILe : seq → seq
*
* SEMANTIC : 将序列中所有元素乘以二。
*
* EXEMPLES :
*
* times_two (Element(1, Element(5, End))) = (Element(2, Element(10, End)))
*)
递归等式
在写递归方程时,我们会多加一个额外的步骤就是添加标注:写出递归等式。递归等式看起来或多或少都会与数学中的等式有些类似,这也能很好指明在不同情况下时,我们的递归函数是如何运作的。具体来说,这就是换一种方式来写明代码中match或者if/else的不同情况。但与match不同的是,不同情况的顺序并没有那么重要,因为当出现歧义时,我们会指明我们所处的情况。
以下是一个例子(没有写出标注的其它部分,仅仅写了递归等式),求整数序列的和(与上文中的seq类型一样):
(* …
*
* EQUATIONS RECURSIVE :
* sum(End) = 0
* sum(Element(x, continue)) = x + somme(continue)
* …
*)
let rec sum (s : seq) : int =
match s with
| Nil -> 0
| Element(x, continue) -> x + (sum continue)
递归等式的“语句”与OCaml中其它语句并不太一样:我们使之更接近在数学中习惯的书写方式。
以下是另一个求 0 到 n 间整数和的例子,以展示如何避免两种情况之间的歧义:
(* …
*
* EQUATIONS RECURSIVES :
* sum(0) = 0
* sum(x) = x + sum(x - 1), 当x ≠ 0
* …
*)
let rec sum (x : int) : int =
match x with
| 0 -> 0
| others -> others + (sum (others - 1))
2. 实现测量
当我们编写递归代码时,有时很难看出来我们的函数是否能在所有情况下顺利结束。但是我们希望避免去得到一个无限地调用它自己的函数。 所以我们要写一个称之为度量(measure)的东西,它可以更直观地让我们看到一个函数是否在减少。
Meature(通常是函数的形式)是一个依赖于待测试函数参数的表达式,并且它的返回值为int。该值应该会随着递归函数的调用而逐步减少,也就是说,如果我们使用与函数相同的参数成功调用了measure(所以每次将返回“上一级”的情况),我们将会得到一个递减数列。并且,measure的结果一定为正数。
有了这些约束,我们很容易就能确定我们的递归函数能够结束。这要归功于那个证明递减且恒为正数(即下界为0)的数列收敛的定理:某一时刻值为0,这意味着我们到达了基层情况。
举个例子,以下为一个计算-3到x之间的和的函数 :
let rec my_sum x =
if x = -3 then
0
else
x + (my_sum (x - 1))
我们想要写一个此函数的measure。那么我们就要找到另一个函数 :
- 变化取决于
x; - 返回值为
int; - 两次调用
my_sum之间递减 ; - 恒为正数 ;
因此,我们可以取 :
let measure x = x + 3
综上,这个函数取决于x并且返回一个int。对于两次递归调用间递减的话,我们应该举个例子以及观察my_sum是如何调用自身的。
从my_sum 2开始 :
-
x最初为2; -
之后
x为1; -
之后为
-1; -
之后为
-2; -
最终为
-3且函数终止;
如果我们将同样的参数传入measure中,我们将得到 :
measure 2 = 5measure 1 = 4measure 0 = 3measure -1 = 2measure -2 = 1measure -3 = 0
因此,measure的结果符合两次调用递归函数间严格递减。
像我们这样举例子并不是证明measure递减的最好方法。我们最好观察以下基础函数(这里是my_sum),调用递归中x的值递减,仅当其参数递减时,measure递减。
然而,在我们的例子中,最后的节点(measure为正)并没有被验证:我们很容易发现当x的值为-10时,measure为负数。这说明要么我们的measure选择有误,要么度量函数有误。
事实上,问题在于:my_sum -10 是无限循环的。为了改正我们的代码,可以将my_sum中的等式换成一个有条件的不等式。
let my_sum x =
if x <= -3 then
0
else
x + (my_sum (x - 1))
let measure x =
if x < -3 then
0
else
x + 3
这样就可以了!
有时我们并不会被要求去以函数的形式写一个measure, 而是要知道如何实现等同的功能:比如说在操作一个序列时,我们通常会通过计算一个list的大小来获得measure。
3. 一些使用小技巧
除了一些specification和measure之外,还有一些另外的技巧可以使得代码更加简洁实用。
‘整体’备注
有时我们定义同义类型时,它会比我们实际想要的更‘大’。举个例子,我们可以使用int模型化一个月中的每一天(在1到31之间),写做:
type day_in_month = int
由于int类型还会表示那些我们不需要的数值(比如说518),所以通常我们会在旁边表明所需子集范围:
type day_in_month = int (* 1 - 31 *)
写测试
OCaml提供一个名为assert的函数,它可以专门用来写测试。它仅需要一个类型为bool的参数。如果这个参数值为true,那么它什么也不做。若值为false,则会使整个程序停止运行。因此,我们可以用它来写一些我们认为是正确的assertion。 然后我们可以运行代码。如果停止了,那么我们就知道有一些语句没有按计划运行:要么是测试语句写错了,要么就是被测函数出了问题。
以下是一个简单的用法示范:
let add x y = x + y
assert (add 2 3 = 5)
assert (add 8 1 = 9)
assert (add 3 -2 = 1)
assert (add 7 7 = 10) (*这个测试是错的:我们的程序将会停止。*)
在这个例子中,测试写的不是很好。但是写一些正确的测试比写一个正确的函数要简单得多,所以通常是和例子相反的情况(即函数出现了错误)发生。