OCaml 编程指南

这是一套针对 OCaml 程序编写的合理指南,它反映了 OCaml 老手们的共识。

OCaml 源代码可以使用 OCamlFormat 自动格式化,因此您无需手动格式化。您可以专注于重要的部分,从而加快代码审查速度。尽管如此,一些最佳实践是无法自动化的,因此在本篇文章中对此进行了说明。如果您更喜欢手动格式化代码,则本文末尾提供了一些格式化指南。

一般指南

简洁易懂

您在编写程序上花费的时间与阅读程序上花费的时间相比可以忽略不计。这就是为什么如果您努力优化可读性,您可以节省大量时间的原因。

您今天“浪费”在简化程序上的时间,将来在对程序进行无数次修改和阅读时(从首次调试开始),将获得百倍的回报。

编写程序法则:程序编写一次,修改十次,阅读一百次。因此,简化编写,始终牢记未来的修改,并且永远不要损害可读性是有益的。

为复杂的参数命名

代替

let temp =
  f x y z
large
    expressionother large
    expressionin
...

let t =
large
  expressionand u =
other large
  expressionin
let temp =
  f x y z t u in
...

命名匿名函数

对于参数为复杂函数的迭代器,也使用 let 绑定来定义函数。代替

List.map
  (function x ->
    blabla
    blabla
    blabla)
  l

let f x =
  blabla
  blabla
  blabla in
List.map f l

理由:更清晰,特别是如果函数的名称有意义的话。

编程指南

如何编写程序

始终将您的手工作品放回工作台,
然后抛光它,反复抛光它。

编写简洁明了的程序

在创建的每个阶段都要重新阅读,简化和澄清。动动脑筋!

将您的程序细分为小函数

小函数更容易掌握。

通过在单独的函数中定义来提取重复代码片段

以这种方式共享代码有助于维护,因为每个更正或改进都会自动传播到整个程序。此外,隔离和命名代码片段的简单行为有时可以让您识别出未曾预料到的功能。

编写程序时不要复制粘贴代码

粘贴代码几乎肯定会引入代码共享默认值,而忽略识别和编写有用的辅助函数。因此,这意味着程序中丢失了一些代码共享。丢失代码共享意味着以后维护将遇到更多问题。粘贴代码中的错误必须在代码的每个副本中对错误的每个出现进行更正!

此外,很难识别出相同的十行代码在整个程序中重复了二十次。相反,如果辅助函数定义了这十行,那么很容易看到和找到这些行在哪里使用:只需找到函数被调用的地方。如果代码被到处复制粘贴,那么程序就更难理解。

总之,复制粘贴代码会导致更难阅读和更难维护的程序。必须禁止这种行为。

如何注释程序

遇到困难时,不要犹豫注释。如果没有困难,就没有注释的必要。这只会产生不必要的噪音。

避免在函数体中添加注释。最好在函数开头添加一个注释,说明特定算法的工作原理。再说一次,如果没有困难,就没有注释的必要。

避免有害注释

有害注释是指没有任何价值的注释,例如,琐碎的信息。有害注释显然没有兴趣;它是一个令人讨厌的东西,毫无用处地分散了读者的注意力。它通常用于满足与所谓的软件度量相关的某些奇怪标准,即注释数量 / 代码行数比率。这个任意比率没有理论或实际解释。

绝对避免有害注释。

以下注释是一个避免的例子,它使用技术术语,因此伪装成真正的注释,但它没有提供任何有价值的额外信息

(*
  Function print_lambda:
  print a lambda-expression given as argument.

  Arguments: lam, any lambda-expression.
  Returns: nothing.

  Remark: print_lambda can only be used for its side effect.
*)
let rec print_lambda lam =
  match lam with
  | Var s -> printf "%s" s
  | Abs l -> printf "\\ %a" print_lambda l
  | App (l1, l2) ->
     printf "(%a %a)" print_lambda l1 print_lambda l2

在模块接口中的使用

函数的使用必须出现在导出它的模块的接口中,而不是在实现它的程序中。选择注释与 OCaml 系统的接口模块中的注释一样,这样以后如果需要,将自动提取接口模块的文档。

使用断言

尽可能使用断言,因为它们可以让您避免冗长的注释,同时允许在执行时进行有用的验证。

例如,验证函数参数的条件可以通过断言来有效地验证。

let f x =
  assert (x >= 0);
  ...

还要注意,断言通常比注释更可取,因为它更值得信赖。断言必须相关,因为它在每次执行时都会被验证,而注释很快就会变得过时,从而损害对程序的理解。

在命令式代码中逐行注释

在编写困难的代码时,特别是对于包含大量内存修改(数据结构中的物理变异)的命令式代码,有时必须在函数体内添加注释来解释此处编码的算法实现,或跟踪函数必须维护的连续不变性修改。再说一次,如果有困难,注释是强制性的,必要时可以对每行程序进行注释。

如何选择标识符

很难选择标识符,其名称能反映相应程序部分的含义。这就是为什么您必须特别注意这一点,强调命名法的清晰性和规律性。

不要对全局名称使用缩写

全局标识符(包括函数的名称)可以很长,因为了解它们在远离定义的地方所起的作用很重要。

用下划线分隔单词:(int_of_string,而不是 intOfString)

在 OCaml 中,大小写修改是有意义的。实际上,大写单词保留给构造函数和模块名称。相反,常规变量(函数或标识符)必须以小写字母开头。这些规则阻止了在标识符中使用大小写修改来分隔单词的正确用法。第一个单词从标识符开始,因此必须是小写,并且禁止选择 IntOfString 作为函数名称。

始终为具有相同含义的函数参数赋予相同的名称

如有必要,请在文件顶部添加注释来明确说明这种命名法。 如果有多个参数具有相同的含义,则为其附加数字后缀。

局部标识符可以简短,并且应该在一个函数到另一个函数之间重复使用

这增强了样式一致性。 避免使用可能导致混淆的标识符,例如lO,它们很容易与10混淆。

示例

let add_expression expr1 expr2 = ...
let print_expression expr = ...

当与使用此命名约定的现有库交互时,不使用大写字母来分隔标识符中单词的建议是可以接受的例外。 这使 OCaml 库用户能够更容易地在原始库文档中找到自己的位置。

如何使用模块

细分为模块

您必须将程序细分为连贯的模块。

对于每个模块,您必须明确编写一个接口。

对于每个接口,您必须记录模块定义的元素:函数、类型、异常等。

打开模块

避免使用open指令,而是使用限定标识符符号。 因此,您将更喜欢简短但有意义的模块名称。

理由:使用非限定标识符是不明确的,并且会导致难以发现的语义错误。

let lim = String.length name - 1 in
...
let lim = Array.length v - 1 in
...
... List.map succ ...
... Array.map succ ...

何时使用开放模块而不是让它们保持关闭状态

将打开修改环境并引入重要函数集的其他版本的模块视为正常操作。 例如,Format模块自动提供缩进打印。 该模块重新定义了通常的打印函数print_stringprint_intprint_float等,因此,当您使用Format时,请在文件顶部系统地打开它。
如果您没有打开Format,您可能会错过打印函数限定符,这可能是完全静默的,因为Format的许多函数在默认环境(Stdlib)中都有对应函数。 混合使用FormatStdlib的打印函数会导致显示中难以追踪的细微错误。 例如

let f () =
  Format.print_string "Hello World!"; print_newline ()

是错误的,因为它没有调用Format.print_newline来刷新美化打印程序队列并输出"Hello World!"。 相反,"Hello World!"被卡在美化打印程序队列中,而Stdlib.print_newline在标准输出上输出回车符。

如果Format正在文件上打印,而标准输出是终端,用户将很难找到文件缺少回车符(并且文件上显示的材料很奇怪,因为应该由Format.print_newline关闭的方框仍然处于打开状态),同时屏幕上出现了一个多余的回车符!

出于同样的原因,请打开大型库(例如具有任意精度整数的库),以免给使用它们的程序带来负担。

open Num

let rec fib n =
  if n <= 2 then Int 1 else fib (n - 1) +/ fib (n - 2)

理由:如果必须限定所有标识符,程序的可读性将降低。

在共享类型定义的程序中,将这些定义收集到一个或多个不包含实现的模块(仅包含类型)中是有益的。 然后,系统地打开导出共享类型定义的模块是可以接受的。

模式匹配

永远不要害怕过度使用模式匹配! 另一方面,要小心避免非穷尽模式匹配构造。 仔细完成它们,不要使用“万能”子句,例如| _ -> ...| x -> ...(例如,当匹配程序中定义的具体类型时)。 另请参阅下一节:编译器警告。

编译器警告

编译器警告旨在防止潜在的错误,这就是您绝对必须注意它们并在编译程序时产生此类警告时更正程序的原因。 此外,编译时产生警告的程序散发着业余气息,这肯定不适合您自己的工作!

模式匹配警告

必须极其小心地对待有关模式匹配的警告。

  • 当然,必须消除具有无用子句的警告。
  • 对于非穷尽模式匹配,您必须完成相应的模式匹配构造,而不是添加默认情况“万能”子句,例如| _ -> ... ,而是添加一个构造函数列表,该列表未被构造的其余部分检查,例如| Cn _ | Cn1 _ -> ...

理由:以这种方式编写它并不复杂,这使得程序能够更安全地演变。 事实上,向匹配的 datatype 添加新的构造函数会再次产生警报,这将允许程序员添加对应于新构造函数的子句(如果有必要)。 相反, “万能”子句将使函数静默地编译,并且可能会认为函数是正确的,因为新构造函数将由默认情况处理。

  • 由带有守卫的子句引起的非穷尽模式匹配也必须得到纠正。 一个典型的案例包括抑制冗余守卫。

解构let绑定

“解构let绑定”是一种同时将多个名称绑定到多个表达式的绑定。 您将所有要绑定的名称打包到集合中,例如元组或列表,然后将所有表达式相应地打包到一个集合表达式中。 当let绑定被评估时,它将解包两侧的集合,并将每个表达式绑定到其对应的名称。 例如,let x, y = 1, 2是一个解构let绑定,它同时执行let x = 1let y = 2的绑定。

let绑定不限于简单的标识符定义。 您可以在更复杂或更简单的模式下使用它。 例如

  • 带有复杂模式的let
    let [x; y] as l = ...
    同时定义一个列表l及其两个元素xy
  • 带有简单模式的let
    let _ = ...不定义任何内容,它只是评估=符号右侧的表达式。

解构let必须是穷尽的

仅在模式匹配是穷尽的(模式永远不会无法匹配)时才使用解构let绑定。 通常,您将仅限于产品类型定义(元组或记录),或者在单个情况下,使用变体类型定义。 在任何其他情况下,请使用显式的match ... with构造。

  • let ... in:给出警告的解构let必须用显式模式匹配替换。 例如,而不是let [x; y] as l = List.map succ (l1 @ l2) in expression,请编写
match List.map succ (l1 @ l2) with
| [x; y] as l -> expression
| _ -> assert false
  • 使用解构let语句的全局定义应使用显式模式匹配和元组重新编写
let x, y, l =
  match List.map succ (l1 @ l2) with
  | [x; y] as l -> x, y, l
  | _ -> assert false

理由:如果您使用一般解构let绑定,则无法使模式匹配穷尽。

序列警告和let _ = ...

当编译器发出关于顺序表达式类型的警告时,您必须明确指示您要忽略此表达式的结果。 为此

  • 使用空绑定并抑制序列警告
List.map f l;
print_newline ()

let _ = List.map f l in
print_newline ()
  • 您还可以使用预定义函数ignore : 'a -> unit,它忽略其参数以返回unit
ignore (List.map f l);
print_newline ()
  • 无论如何,抑制此警告的最佳方法是了解编译器为什么发出此警告。 编译器警告您,因为您的代码计算的结果毫无用处,因为它在计算后就被删除了。 因此,如果它有任何用处,则此计算仅执行其副作用; 因此,它应该返回unit

大多数情况下,警告表明使用了错误的函数,可能是将函数的仅副作用版本(该版本是一个结果无关紧要的过程)与它的函数式对应版本(其结果是有意义的)混淆了。

在上面提到的示例中,出现了第一种情况,程序员应该调用iter而不是map,然后只需编写

List.iter f l;
print_newline ()

在实际程序中,合适的(仅副作用)函数可能不存在,必须编写。 通常,仔细地将函数的过程部分与函数部分分离可以优雅地解决问题,并且生成的程序看起来更好! 例如,您将有问题的定义

let add x y =
  if x > 1 then print_int x;
  print_newline ();
  x + y;;

转换为更清晰、更独立的定义,并相应地更改对add的旧调用。

无论如何,仅在您要忽略结果的那些情况下使用let _ = ...构造。 不要系统地用此构造替换序列。

理由:序列要清晰得多! 将e1; e2; e3

let _ = e1 in
let _ = e2 in
e3

hdtl函数

不要使用hdtl函数,而是明确地对列表参数进行模式匹配。

理由:这与使用hdtl一样简短,并且比使用hdtl清晰得多,后者必须由try... with...保护以捕获这些函数可能引发的异常。

循环

for循环

要简单地遍历数组或字符串,请使用for循环。

for i = 0 to Array.length v - 1 do
  ...
done

如果循环很复杂或返回结果,请使用递归函数。

let find_index e v =
  let rec loop i =
    if i >= Array.length v then raise Not_found else
    if v.(i) = e then i else loop (i + 1) in
  loop 0;;

理由:递归函数允许您简单地编写任何循环,即使是复杂的循环,例如,具有多个退出点或具有奇怪的索引步长(例如,步长取决于数据值)。

此外,递归循环避免使用在循环的任何部分(甚至在外部)都可以修改其值的可变值。相反,递归循环明确地将可能在递归调用期间更改的值作为参数。

while 循环

While 循环定律:注意!while 循环通常是错误的,除非其循环不变式已明确编写。

while 循环的主要用途是无限循环 while true do ...。您通常通过异常退出循环,通常在程序终止时。

其他 while 循环很难使用,除非它们来自算法课程的预制程序,并且已经过证明。

理由while 循环需要一个或多个可变值,因此循环条件会改变值,循环最终会终止。为了证明它们的正确性,您必须发现循环不变式,这是一项有趣但困难的运动。

异常

不要害怕在您的程序中定义自己的异常,但另一方面,尽可能多地使用系统预定义的异常。例如,每个失败的搜索函数都应该引发预定义的异常 Not_found。注意使用 try ... with 处理函数调用可能引发的异常。

通过 try ... with _ -> 处理所有异常通常保留给程序的主函数。如果您必须捕获每个异常以维护算法的不变性,请注意命名异常并在重置不变性后重新引发它。通常

let ic = open_in ...
and oc = open_out ... in
try
  treatment ic oc;
  close_in ic; close_out oc
with x -> close_in ic; close_out oc; raise x

理由try ... with _ -> 静默地捕获所有异常,即使是与当前计算无关的异常(例如,中断将被捕获,计算将继续进行!)。

数据结构

OCaml 的一大优势是可定义数据结构的强大功能以及操作它们的简单性。因此,您必须充分利用这一点!不要犹豫,定义自己的数据结构。特别是,不要系统地用整数表示枚举,也不要用布尔值表示两个情况的枚举。示例

type figure =
   | Triangle | Square | Circle | Parallelogram
type convexity =
   | Convex | Concave | Other
type type_of_definition =
   | Recursive | Non_recursive

理由:布尔值通常会阻止对相应代码的直观理解。例如,如果 type_of_definition 由布尔值编码,那么 true 代表什么?“正常”定义(即非递归定义)还是递归定义?

在用整数编码的枚举类型的情况下,很难限制可接受整数的范围。必须定义构造函数以确保程序的强制不变性(然后验证没有直接构建任何值)或在程序中添加断言并在模式匹配中添加保护。当求和类型的定义优雅地解决了这个问题,并伴随着利用模式匹配的全部力量和编译器对穷举性的验证的额外好处时,这不是一个好做法。

批评:对于二进制枚举,可以系统地定义谓词,其名称带有实现类型的布尔值的语义。例如,我们可以采用一个约定,即谓词以字母 p 结尾。然后,代替为 type_of_definition 定义新的求和类型,我们将使用一个谓词函数 recursivep,如果定义是递归的,则返回 true

答案:这种方法特定于二进制枚举,不能轻易扩展;此外,它不适合模式匹配。例如,对由 | Let of bool * string * expression 编码的定义进行的典型模式匹配如下所示

| Let (_, v, e) as def ->
   if recursivep def then code_for_recursive_case
   else code_for_non_recursive_case

或者,如果 recursivep 可以应用于布尔值

| Let (b, v, e) ->
   if recursivep b then code_for_recursive_case
   else code_for_non_recursive_case

将其与显式编码进行对比

| Let (Recursive, v, e) -> code_for_recursive_case
| Let (Non_recursive, v, e) -> code_for_non_recursive_case

两个程序之间的差异很细微,您可能认为这只是一个品味问题;但是,显式编码对于修改来说无疑更加健壮,并且更适合该语言。

相反,当构造函数 truefalse 的解释清楚时,没有必要为布尔标志系统地定义新的类型。那么,以下类型的定义的实用性就很值得怀疑了

type switch = On | Off
type bit = One | Zero

当这些整数对表示的数据具有明显的解释时,对于用整数表示的枚举类型,同样的反对意见也是可以接受的。

何时使用可变值

可变值很有用,有时对于简单明了的编程来说是必不可少的。但是,您必须明智地使用它们,因为 OCaml 的正常数据结构是不可变的。它们因其允许的编程的清晰性和安全性而受到青睐。

迭代器

OCaml 的迭代器是一个强大而有用的功能。但是,您不应该过度使用它们,也不应该忽视它们。它们由库提供,并且很有可能由库的作者进行正确且经过深思熟虑的编写,因此,重新发明它们是没有意义的。

let square_elements elements = List.map square elements

而不是

let rec square_elements = function
  | [] -> []
  | elem :: elements -> square elem :: square_elements elements

另一方面,避免编写

let iterator f x l =
  List.fold_right (List.fold_left f) [List.map x l] l

即使您获得了

  let iterator f x l =
    List.fold_right (List.fold_left f) [List.map x l] l;;
  iterator (fun l x -> x :: l) (fun l -> List.rev l) [[1; 2; 3]]

如果有明确的需要,请务必添加解释性注释。我认为,这是绝对必要的!

如何优化程序

优化伪定律:没有先验优化。
也没有后验优化。

最重要的是,简单明了地编程。在确定程序的瓶颈之前(通常是在编写了一些例程之后),不要开始优化。然后,优化主要在于改变算法的复杂度。这通常通过重新定义操作的数据结构并完全重写出现问题的程序部分来实现。

理由:程序的清晰度和正确性优先。此外,在大型程序中,实际上不可能先验地识别程序中效率至关重要的部分。

如何在类和模块之间进行选择

当您需要继承时,使用 OCaml 类,即数据及其功能的增量细化。

当您需要模式匹配时,使用传统的数据结构(特别是变体类型)。

当数据结构是固定的,并且它们的功能也固定,或者在使用它们的程序中添加新函数就足够了,则使用模块。

OCaml 代码的清晰度

OCaml 语言包含强大的构造,允许简单明了的编程。要获得清晰的程序,主要问题是正确使用它们。

该语言具有多种编程风格(或编程范式):命令式编程(基于状态和赋值的概念)、函数式编程(基于函数、函数结果和微积分的概念)、面向对象编程(基于封装状态和一些可以修改状态的过程或方法的对象的概念)。程序员的第一项工作是选择最适合当前问题的编程范式。在使用编程范式时,难点在于使用语言构造来表达计算,以便以最自然、最简单的方式实现算法。

风格危险

关于编程风格,通常可以观察到两种对称的、有问题的行为。一方面是“全命令式”方式(系统地使用循环和赋值),另一方面是“纯函数式”方式(永远不使用循环或赋值)。“100% 对象”风格将来肯定会出现。

  • “过于命令式”的危险:
    • 使用命令式风格来编写本质上是递归的函数是一个坏主意。例如,要计算列表的长度,你不应该写
let list_length l =
  let l = ref l in
  let res = ref 0 in
  while !l <> [] do
    incr res; l := List.tl !l
  done;
  !res;;

代替以下简单明了的递归函数

let rec list_length = function
  | [] -> 0
  | _ :: l -> 1 + list_length l

(对于那些质疑这两个版本等效性的人,请参见下面的说明)。

  • 在命令式世界中,另一个常见的“过度命令式错误”是,不是系统地选择简单的 for 循环来迭代向量的元素,而是使用一个或两个引用来使用复杂的 while 循环。太多的无用赋值意味着太多的出错机会。

  • 这类程序员认为记录类型定义中的 mutable 关键字应该是隐式的。

  • “过于函数式”的危险:

    • 坚持这种教条的程序员避免使用数组和赋值。在最严重的情况下,可以观察到完全拒绝编写任何命令式构造,即使它明显地是解决问题的最优雅方式。
    • 特征症状:用递归函数系统地重写 for 循环,在命令式数据结构似乎对任何人都很有必要的情况下使用列表,将问题的许多全局参数传递给每个函数,即使全局引用完全避免了这些虚假的参数,这些参数主要是必须重复传递的不变性。
    • 这个程序员认为记录类型定义中的 mutable 关键字应该从语言中删除。

通常被认为难以理解的 OCaml 代码

OCaml 语言包含强大的构造,允许简单明了的编程。但是,这些构造的强大功能也允许您编写无用地复杂的代码,以至于您最终得到一个完全难以理解的程序。

以下是一些编写过度复杂代码的常见方法

  • 使用无用(因此对于可读性来说是新手)的 if then else,例如
let flush_ps () =
  if not !psused then psused := true

或者(更微妙)

let sync b =
  if !last_is_dvi <> b then last_is_dvi := b
  • 用另一个构造来编码一个构造。例如,用匿名函数对参数的应用来编码 let ... in。你会写
(fun x y -> x + y)
   e1 e2

而不是简单地写

let x = e1
and y = e2 in
x + y
  • 使用let in 绑定系统地编码序列。

  • 混合计算和副作用,特别是在函数调用中。请记住,函数调用参数的求值顺序是不确定的,这意味着您不能在函数调用中混合副作用和计算。但是,当只有一个参数时,您可以利用这一点在参数中执行副作用,尽管这对读者来说非常麻烦,但不会危及程序语义。这绝对应该禁止。

  • 迭代器和高阶函数的误用(即过度或不足使用)。例如,最好使用List.mapList.iter,而不是使用自己的递归函数编写等效代码。更糟糕的是,不要使用List.mapList.iter,而是用List.fold_rightList.fold_left 编写等效代码。

  • 另一种编写不可读代码的有效方法是混合所有或部分这些方法。例如

(fun u -> print_string "world"; print_string u)
  (let temp = print_string "Hello"; "!" in
   ((fun x -> print_string x; flush stdout) " ";
    temp));;

如果您自然地以这种方式编写程序 print_string "Hello world!",请将您的作品提交到 混淆的 OCaml 比赛

管理程序开发

以下是来自在编译器开发中经验丰富的 OCaml 程序员的建议。这些都是由小型团队开发的大型复杂程序的良好示例。

如何编辑程序

许多开发人员对在 Emacs 编辑器(一般来说是 GNU Emacs)中编写程序有一种敬畏之情。编辑器与语言很好地交互,因为它能够对 OCaml 源代码进行语法着色(用颜色呈现不同类别的单词,从而对关键字着色,例如)。

以下两个命令被认为是不可或缺的

  • CTRL-C-CTRL-CMeta-X compile:从编辑器内部启动重新编译(使用make 命令)。
  • CTRL-X-`:将光标置于文件中,并在 OCaml 编译器发出错误的确切位置。

开发人员描述如何使用这些功能:CTRL-C-CTRL-C 组合重新编译整个应用程序;然后,在出现错误的情况下,一系列CTRL-X-` 命令允许修正所有发出的错误;最后,该循环用CTRL-C-CTRL-C 启动的新的重新编译开始。

其他 Emacs 技巧

ESC-/ 命令(dynamic-abbrev-expand)会自动使用正在编辑文件中的某个单词完成光标前面的单词。这使您始终可以选择有意义的标识符,而无需在程序中输入扩展名称的繁琐工作,即 ESC-/ 可以轻松完成输入前几个字母后的标识符。如果它显示了错误的完成,每个后续的 ESC-/ 会提出一个备选的完成。

在 Unix 下,CTRL-C-CTRL-CMeta-X compile 组合,后跟 CTRL-X-`,也用于在 OCaml 程序中查找特定字符串的所有出现。而不是启动make 来重新编译,启动grep 命令。然后,来自grep 的所有“错误消息”与CTRL-X-` 的用法兼容,它会自动将您带到文件中,并带到找到字符串的位置。

如何使用交互式系统编辑

在 Unix 下:使用行编辑器ledit,它提供强大的“à la Emacs”编辑功能(包括 ESC-/!),以及一个历史机制,允许您检索以前输入的命令,甚至从一个会话检索到另一个会话。ledit 是用 OCaml 编写的,可以从 这里 免费下载。

如何编译

make 实用程序对于管理程序的编译和重新编译是必不可少的。可以在 The Hump 上找到示例make 文件。您还可以查阅 OCaml 编译器的Makefiles

如何作为团队进行开发:版本控制

使用 Git 软件版本控制系统的用户对它带来的生产力提升赞不绝口。该系统支持由编程团队管理开发,同时在他们之间强制一致性,并维护对软件所做更改的日志。
Git 还支持由多个团队同时进行开发,这些团队可能分散在互联网上连接的多个站点中。

一个匿名的 Git 只读镜像 包含 OCaml 编译器的源代码,以及与 OCaml 相关的其他软件的源代码。

格式指南

如果您选择不使用 OCamlFormat 自动格式化源代码,请在手动格式化时考虑以下样式指南。

**伪空格定律**:不要犹豫在程序中用空格分隔单词。空格键是键盘上最容易找到的键,因此尽可能多地按下它!

分隔符

分隔符符号后面应该始终有一个空格,并且操作符符号应该用空格包围。在排版中,用空格分隔单词以使书面文本更易读,这已经是一个巨大的进步。如果您希望程序可读,请在程序中也这样做。

如何编写对

元组用括号括起来,其中的逗号(分隔符)后面都跟着一个空格:(1, 2)let triplet = (x, y, z)...

普遍接受的例外情况:

  • **对的组件的定义**:代替let (x, y) = ...,您可以编写let x, y = ...

    **理由**:重点是同时定义多个值,而不是构造元组。此外,模式在let= 之间很好地隔开。

  • **同时匹配多个值**:在同时匹配多个值时,省略 n 元组周围的括号是可以的。

    match x, y with
    | 1, _ -> ...
    | x, 1 -> ...
    | x, y -> ...
    

    **理由**:重点是并行匹配多个值,而不是构造元组。此外,要匹配的表达式由matchwith 隔开,而模式则由|-> 很好地隔开。

如何编写列表

用空格围绕:: 编写x :: l(因为:: 是一个中缀运算符,因此用空格包围)和[1; 2; 3](因为; 是一个分隔符,因此后面跟着一个空格)。

如何编写操作符符号

注意将操作符符号用空格隔开。不仅您的公式更易读,而且您还可以避免与多字符操作符混淆。(此规则的明显例外是符号!.,它们不会与它们的参数分开。)
示例:编写x + 1x + !y

**理由**:如果您省略空格,则x+1 会被理解,但x+!y 会改变其含义,因为+! 会被解释为一个多字符操作符。

**批评**:操作符周围没有空格,当用于反映操作符的相对优先级时,会提高公式的可读性。例如x*y + 2*z 使乘法优先于加法非常明显。

**回应**:这是一个糟糕的想法,一个幻想,因为语言中没有任何东西可以确保空格能正确地反映公式的含义。例如x * z-1 意味着(x * z) - 1,而不是x * (z - 1),如建议的空格解释所暗示的那样。此外,多字符符号的问题会阻止您以统一的方式使用此约定,即您不能省略乘法周围的空格来编写x*!y + 2*!z。最后,玩空格是一个微妙而脆弱的约定,一个潜意识的信息,在阅读时很难理解。如果您想使优先级显而易见,请使用语言为您提供的表达方式:编写括号。

**附加理由**:系统地用空格包围运算符简化了对中缀运算符的处理,中缀运算符不是一个复杂的特殊情况。实际上,虽然您可以编写没有空格的(+),但显然您不能编写(*),因为(* 被读取为注释的开始。您必须至少编写一个空格,如“( *)”,尽管在* 后面加一个额外的空格是绝对更好的,如果您想避免*) 在某些情况下被读取为注释的结束。

如果您采用此处提出的简单规则,就可以轻松避免所有这些困难:将操作符符号用空格隔开。
事实上,您会很快发现这个规则并不难遵循。空格键是键盘上最大且位置最好的键。它最容易使用,因为您不会错过它!

如何编写长字符字符串

使用该行有效的约定缩进长字符字符串,加上在每行末尾的字符串继续的指示(行末的\ 字符省略下一行开头的空白)

let universal_declaration =
  "-1- Programs are born and remain free and equal under the law;\n\
   distinctions can only be based on the common good." in
  ...

何时在表达式中使用括号

括号是有意义的。它们表示使用不寻常优先级的必要性,因此应该明智地使用它们,而不是随机散布在程序中。为此,您应该了解通常的优先级,即不需要括号的操作组合。幸运的是,如果您了解一些数学知识,或者努力遵循以下规则,这并不复杂

算术运算符:与数学中的规则相同

例如:1 + 2 * x 表示 1 + (2 * x)

函数应用:与数学中使用三角函数的规则相同

在数学中,你写 sin x 表示 sin (x)。同理,sin x + cos x 表示 (sin x) + (cos x),而不是 sin (x + (cos x))。在 OCaml 中使用相同的约定:写 f x + g x 表示 (f x) + (g x)
此约定适用于所有(中缀)运算符f x :: g x 表示 (f x) :: (g x)f x @ g x 表示 (f x) @ (g x),以及 failwith s ^ s' 表示 (failwith s) ^ s'而不是 failwith (s ^ s')

比较和布尔运算符

比较是中缀运算符,因此上述规则适用。这就是为什么 f x < g x 表示 (f x) < (g x)。出于类型原因(以及没有其他合理的解释),表达式 f x < x + 2 表示 (f x) < (x + 2)。同样地,f x < x + 2 && x > 3 表示 ((f x) < (x + 2)) && (x > 3)

布尔运算符的相对优先级与数学中的相同

虽然数学家倾向于过度使用括号,但布尔“或”运算符类似于加法,而“与”运算符类似于乘法。因此,就像 1 + 2 * x 表示 1 + (2 * x) 一样,true || false && x 表示 true || (false && x)

如何在程序中分隔结构

当需要在程序中分隔语法结构时,使用关键字 beginend 作为分隔符,而不是括号。但是,如果您以一致且系统的方式使用括号,那么使用括号是可以接受的。

此显式结构分隔主要涉及模式匹配结构或嵌入在 if then else 结构中的序列。

match 结构在 match 结构中

match ... withtry ... with 结构出现在模式匹配子句中时,绝对有必要分隔此嵌入结构(否则,封闭模式匹配结构的后续子句将自动与封闭模式匹配结构相关联)。例如

match x with
| 1 ->
  begin match y with
  | ...
  end
| 2 ->
...

if 分支内部的序列

同样地,出现在条件语句的 thenelse 部分中的序列必须分隔

if cond then begin
  e1;
  e2
end else begin
  e3;
  e4
end

程序的缩进

Landin 的伪法则:将程序的缩进视为决定程序意义的方式。

我将在此法则中添加:注意程序中的缩进,因为在某些情况下,它确实定义了程序的意义!

程序的缩进是一门艺术,会引起许多强烈的意见。这里给出了几种缩进风格,这些风格源于经验,并且没有受到严重批评。

当采用的风格的理由对我来说很明显时,我已经指明了。另一方面,批评也被注意到。

因此,每次你必须在建议的不同风格之间进行选择。
唯一的绝对规则是下面的第一个。

缩进的一致性

选择一种普遍接受的缩进风格,然后在整个应用程序中系统地使用它。

页面的宽度

页面宽 80 列。

理由:此宽度使代码能够在所有显示器上阅读,并以清晰的字体打印在标准纸张上。

页面的高度

一个函数应始终适合在一个屏幕内(大约 70 行),或在特殊情况下最多两个,最多三个。超出这个范围是不合理的。

理由:当一个函数超过一个屏幕时,是时候将其划分为子问题并独立处理它们了。超过一个屏幕,人就会迷失在代码中。缩进不可读,难以保持正确。

缩进多少

程序连续行的缩进变化通常为 1 或 2 个空格。选择一个缩进量并在整个程序中保持一致。

使用制表符

绝对建议使用制表符(ASCII 字符 9)。

理由:在不同的显示器之间,程序的缩进会完全改变。如果程序员同时使用制表符和空格来缩进程序,它也可能变得完全错误。

批评:使用制表符的目的是为了让读者能够通过更改制表符位置来进行更多或更少的缩进。总体缩进保持正确,读者很高兴能够轻松地自定义缩进量。

回答:这种方法似乎几乎不可能实现,因为你应该始终使用制表符进行缩进,这很困难且不自然。

如何缩进运算

当一个运算符采用复杂的参数,或在同一个运算符的多次调用存在的情况下,将下一行以运算符开头,不要缩进运算符的其余部分。例如

x + y + z
+ t + u

理由:当运算符以行开头时,很明显运算将在此行继续。

在处理这种运算序列中的“大表达式”时,最好使用 let in 结构来定义“大表达式”,而不是缩进该行。代替

x + y + z
+large
  expression

let t =
large
   expressionin
x + y + z + t

当使用运算符组合时,你必须将过大而无法在一行中写出的表达式绑定在一起。代替不可读的表达式

(x + y + z * t)
/ (large
    expression)

let u =
large
  expressionin
(x + y + z * t) / u

这些指南适用于所有运算符。例如

let u =
large
  expressionin
x :: y
:: z + 1 :: t :: u

如何缩进全局 let ... ;; 定义

在模块中全局定义的函数的主体通常会进行正常的缩进。但是,可以对这种情况进行特殊处理,以便更好地偏移定义。

使用 1 或 2 个空格的常规缩进

let f x = function
  | C ->
  | D ->
  ...

let g x =
  let tmp =
    match x with
    | C -> 1
    | x -> 0 in
  tmp + 1

理由:缩进量没有例外。

其他约定是可以接受的,例如

  • 当模式匹配时,主体左对齐。
let f x = function
| C ->
| D ->
...

理由:分隔模式的竖线在定义完成时停止,因此仍然可以轻松地转到下一个定义。

批评:对正常缩进的不愉快例外。

  • 主体在定义的函数名称下对齐。
let f x =
    let tmp = ... in
    try g x with
    | Not_found ->
    ...

理由:定义的第一行很好地偏移,因此可以更轻松地从一个定义转移到另一个定义。

批评:你很快就会碰到右边界。

如何缩进 let ... in 结构

let 引入的定义之后,紧随其后的表达式缩进到与关键字 let 相同的级别,而引入它的关键字 in 则写在行尾

let expr1 = ... in
expr1 + expr1

在一系列 let 定义的情况下,上述规则意味着。这些定义应该放在相同的缩进级别

let expr1 = ... in
let n = ... in
...

理由:建议一系列 let ... in 结构类似于数学文本中的一组假设,因此所有假设的缩进级别相同。

变体:有些人将关键字 in 单独写在一行上,以分隔计算的最终表达式

let e1 = ... in
let e2 = ... in
let new_expr =
  let e1' = derive_expression e1
  and e2' = derive_expression e2 in
  Add_expression e1' e2'
in
Mult_expression (new_expr, new_expr)

批评:缺乏一致性。

如何缩进 if ... then ... else ...

多个分支

将具有多个分支的条件写在相同的缩进级别

if cond1 ...
if cond2 ...
if cond3 ...

理由:类似于模式匹配子句的处理方式,全部对齐到相同的制表符位置。

如果条件的大小和表达式允许,请写

if cond1 then e1 else
if cond2 then e2 else
if cond3 then e3 else
e4

如果多个条件分支中的表达式必须包含(例如,当它们包含语句时),请写

if cond then begin
    e1
  end else
if cond2 then begin
    e2
  end else
if cond3 then ...

有些人建议对多个条件语句使用另一种方法:以关键字 else 开头每一行

if cond1 ...
else if cond2 ...
else if cond3 ...

理由elsif 是许多语言中的关键字,因此使用缩进和 else if 来提醒它。此外,你无需查看行尾,就知道条件是继续还是执行另一个测试。

批评:处理所有条件缺乏一致性。为什么要对第一个条件使用特殊情况?

再一次,选择你的风格并系统地使用它。

单个分支

对于单个分支,根据相关表达式的尺寸,尤其是这些表达式中是否存在 beginend( ) 分隔符,可以使用多种风格。

在分隔条件语句的分支时,会使用多种风格

( 在行尾

if cond then (
  e1
) else (
  e2
)

或者可选地将第一个 begin 放在行首

if cond then
  begin
    e1
  end else begin
    e2
  end

事实上,条件语句的缩进取决于其表达式的尺寸。

如果 conde1e2 很小,只需将它们写在一行上

if cond then e1 else e2

如果构成条件语句的表达式是纯函数的(没有副作用),我们建议在它们太大而无法在一行中容纳时,使用 let e = ... in 将它们绑定在条件语句中。

理由: 这样你就可以在同一行获得简单的缩进,这是最易读的。作为额外的好处,命名有助于理解。

现在我们考虑表达式有副作用的情况,这阻止我们简单地用let e = ... in进行绑定。

如果e1cond很小,但e2很大

if cond then e1 else
  e2

如果e1cond很大,但e2很小

if cond then
  e1
else e2

如果所有表达式都很长

if cond then
  e1
else
  e2

如果有( )分隔符

if cond then (
  e1
) else (
  e2
)

混合情况,其中e1需要( ),但e2很小

if cond then (
    e1
) else e2

如何缩进模式匹配结构

一般原则

所有模式匹配子句都由竖线引入,包括第一个子句。

批评: 第一个竖线不是强制性的。因此,不需要写它。

对批评的回答: 如果你省略了第一个竖线,缩进看起来不自然。第一个情况的缩进量大于正常换行所需的缩进量。因此,这是一个对正确缩进规则的无用例外。它还坚持不为整组子句使用相同的语法,而是将第一个子句作为例外,使用稍微不同的语法。最后,美学价值值得怀疑(有些人会说“糟糕”而不是“值得怀疑”。

将所有模式匹配子句与开始每个子句的竖线对齐,包括第一个子句。

如果子句中的表达式太大而无法在一行内写下,则必须在对应子句的箭头后立即换行。然后从子句模式的开头开始正常缩进。

模式匹配子句的箭头不应对齐。

matchtry

对于matchtry,将子句与构造的开头对齐

match lam with
| Abs (x, body) -> 1 + size_lambda body
| App (lam1, lam2) -> size_lambda lam1 + size_lambda lam2
| Var v -> 1

try f x with
| Not_found -> ...
| Failure "not yet implemented" -> ...

将关键字with放在行尾。如果前面的表达式超出了一行,将with放在它自己的行上

try
  let y = f x in
  if ...
with
| Not_found -> ...
| Failure "not yet implemented" -> ...

理由: 关键字with在它自己的行上表明程序进入了构造的模式匹配部分。

缩进子句中的表达式

如果模式匹配箭头右边的表达式太大,则在箭头后断行。

match lam with
| Abs (x, body) ->
   1 + size_lambda body
| App (lam1, lam2) ->
   size_lambda lam1 + size_lambda lam2
| Var v ->

一些程序员将此规则推广到所有子句,只要一个表达式溢出。然后他们将最后一个子句缩进如下

| Var v ->
   1

其他程序员更进一步,将此规则系统地应用于任何模式匹配的任何子句。

let rec fib = function
  | 0 ->
     1
  | 1 ->
     1
  | n ->
     fib (n - 1) + fib ( n - 2)

批评: 可能不够紧凑。对于简单的模式匹配(或复杂匹配中的简单子句),此规则不会提高可读性。

理由: 我看不到使用此规则的任何理由,除非你的薪酬与代码行数成正比。在这种情况下,使用此规则可以获得更多报酬,而不会在你的 OCaml 程序中添加更多错误!

匿名函数中的模式匹配

matchtry类似,以function开头的匿名函数的模式匹配相对于function关键字缩进

map
  (function
   | Abs (x, body) -> 1 + size_lambda 0 body
   | App (lam1, lam2) -> size_lambda (size_lambda 0 lam1) lam2
   | Var v -> 1)
  lambda_list

命名函数中的模式匹配

letlet rec定义的函数中的模式匹配会产生几种合理的风格,这些风格遵循前面的模式匹配规则(匿名函数的规则明显除外)。请参阅上面推荐的风格。

let rec size_lambda accu = function
  | Abs (x, body) -> size_lambda (succ accu) body
  | App (lam1, lam2) -> size_lambda (size_lambda accu lam1) lam2
  | Var v -> succ accu

let rec size_lambda accu = function
| Abs (x, body) -> size_lambda (succ accu) body
| App (lam1, lam2) -> size_lambda (size_lambda accu lam1) lam2
| Var v -> succ accu

模式捕获结构的错误缩进

野蛮缩进函数和情况分析。

这包括在之前被推向右边的matchfunction关键字下正常缩进。不要写

let rec f x = function
              | [] -> ...
              ...

而是选择在let关键字下缩进该行

let rec f x = function
  | [] -> ...
  ...

理由: 你会撞到页边距。美学价值值得怀疑。

野蛮对齐模式匹配子句中的->符号。

仔细对齐模式匹配箭头被认为是不好的做法,如下面的片段所示

let f = function
  | C1          -> 1
  | Long_name _ -> 2
  | _           -> 3

理由: 这使得维护程序变得更加困难(添加额外的案例会导致所有缩进发生变化,因此我们通常会放弃对齐。在这种情况下,最好一开始就不对齐箭头!)。

如何缩进函数调用

缩进到函数名称

除了具有许多参数(或非常复杂的参数)的函数无法在同一行上写下以外,没有问题。你必须根据函数名称缩进表达式(根据选择的约定缩进 1 或 2 个空格)。将小参数放在同一行,并在参数开头换行。

尽可能避免由复杂表达式组成的参数。在这些情况下,使用let构造定义“大”参数。

理由: 没有缩进问题。如果给表达式起的名字有意义,代码的可读性会更高。

额外理由: 如果参数的求值产生副作用,则实际上需要let绑定来明确定义求值顺序。

注释

list_length的命令式和函数式版本

list_length的这两个版本在复杂度方面并不完全等效。命令式版本使用恒定的堆栈空间来执行,而函数式版本需要存储挂起的递归调用的返回地址(其最大数量等于列表参数的长度)。如果你想检索恒定的空间要求来运行函数式程序,你只需编写一个尾递归(或尾递归)的函数。这是一种以递归调用结束的函数(这里不是这种情况,因为必须在递归调用返回后执行对+的调用)。只需像这样使用累加器来保存中间结果

let list_length l =
  let rec loop accu = function
    | [] -> accu
    | _ :: l -> loop (accu + 1) l in
  loop 0 l

这样,你就可以获得一个程序,该程序具有与命令式程序相同的计算特性,以及处理属于递归求和数据类型的参数的模式匹配和递归调用的算法的额外清晰度和自然外观。

鸣谢

法语原文翻译:Ruchira Datta.

感谢所有已经参与此页面评论的人:Daniel de Rauglaudre、Luc Maranget、Jacques Garrigue、Damien Doligez、Xavier Leroy、Bruno Verlyck、Bruno Petazzoni、Francois Maltey、Basile Starynkevitch、Toby Moth、Pierre Lescanne。

仍然需要帮助?

帮助改进我们的文档

所有 OCaml 文档都是开源的。发现错误或不清楚的地方?提交拉取请求。

OCaml

创新。社区。安全。