【译】【OCaml函数式编程教程】第五章:编码以及更好地编码

422 阅读7分钟

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))

rulers.jpg

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

  • 之后x1

  • 之后为-1

  • 之后为-2

  • 最终为-3且函数终止;

如果我们将同样的参数传入measure中,我们将得到 :

  • measure 2 = 5
  • measure 1 = 4
  • measure 0 = 3
  • measure -1 = 2
  • measure -2 = 1
  • measure -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) (*这个测试是错的:我们的程序将会停止。*)

在这个例子中,测试写的不是很好。但是写一些正确的测试比写一个正确的函数要简单得多,所以通常是和例子相反的情况(即函数出现了错误)发生。