OCaml学习——《Real-World-Ocaml》Chap2

Chap2 变量和函数

预计 1 天内推进两章(不过今晚和 CSU F8 去庆祝,估计要拖一下了。)

变量

变量,标识符,将含义绑定到一个特定的值。在 OCaml 中,通常使用let关键字引入。变量名必须以小写字母或者下划线开头。

1
let <variable> = <expr>

作用域,let 创建有限作用域的变量,将其限定在某个特定的表达式。

1
let <variable> = <expr1> in <expr2>

let的变量限制在in之后的<expr2>中,脱离后回归顶层,访问该变量是不允许的,例子:

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
open Core.Std;;

(* 作用域 非掩盖的例子 *)
let languages = "OCaml,Perl,C++,C";;
let dashed_languages =
let language_list = String.split languages ~on:',' in String.concat ~sep:"-" language_list
;;
language_list;;


(* 作用域 掩盖外层定义的例子 *)
let languages = "OCaml,Perl,C++,C";;
let dashed_languages =
let languages = String.split languages ~on:',' in String.concat ~sep:"-" languages
;;
languages;;

顶层环境运行结果

1
2
3
4
5
6
7
8
# let dashed_languages =
let language_list = String.split languages ~on:',' in String.concat ~sep:"-" language_list;;
val dashed_languages : Core.Std.String.t = "OCaml-Perl-C++-C"
# language_list;;
Characters 0-13:
language_list;;
^^^^^^^^^^^^^
Error: Unbound value language_list

掩盖外层定义,输出对比

1
2
3
4
5
6
7
# let languages = "OCaml,Perl,C++,C";;
val languages : string = "OCaml,Perl,C++,C"
# let dashed_languages =
let languages = String.split languages ~on:',' in String.concat ~sep:"-" languages;;
val dashed_languages : Core.Std.String.t = "OCaml-Perl-C++-C"
# languages;;
- : string = "OCaml,Perl,C++,C"

关于 OCaml 中变量不能改变这件事

Ocaml(以及一般的函数式语言)中的变量更像是等是中的变量,而非命令中的变量。考虑数学恒等式\(x(y+z) = xy + xz\),这里没有提到变量\(x\)\(y\)\(z\)本身要改变。但可以为这些变量提供不同的数来实例化这个等式,而等式依然成立,从这个意义上讲它们是变化的。

函数式语言中也是一样。可以为一个函数提供不同的输入,所以它的变量可以有不同的值,不过变量本身没有改变。

let绑定的另一个特性是允许在绑定的左边使用模式。Example:

1
2
3
4
5
(* 模式匹配和let  *)

let (ints,strings) = List.unzip [(1,"one") ; (2,"two"); (3,"three")];;
ints;;
strings;;
1
2
3
4
5
6
7
# let (ints,strings) = List.unzip [(1,"one") ; (2,"two"); (3,"three")];;
val ints : int Core.Std.List.t = [1; 2; 3]
val strings : string Core.Std.List.t = ["one"; "two"; "three"]
# ints;;
- : int Core.Std.List.t = [1; 2; 3]
# strings;;
- : string Core.Std.List.t = ["one"; "two"; "three"]

let绑定使用模式对于 irrefutable 的模式最有意义,也就是说,当前类型中所有值都能与该模式匹配。元组tuple 和记录模式record 就是 irrefutable 的(这个很好理解),列表模式则并非。

函数

既然 OCaml 是一个函数式语言,毫无疑问函数重中之重。

匿名函数

fun关键字声明(初看觉得和lambda有些相似),匿名函数处理,传入另一函数、填入数据结构。下面是做的一些实验:

1
2
3
4
5
6
7
8
9
10
List.map ~f:(fun x -> x+1) [1;2;3] ;; (* 传入另一个函数 *)

let increments = [ (fun x -> x +1) ; (fun x -> x+2) ];; (* 嵌入list *)
let get_head_func func_lists =
match func_lists with
| [] -> (fun x -> 0)
| hd::tl -> hd
;;
let testfunc = get_head_func increments;;
testfunc 4;;

输出结果

1
2
3
4
5
6
7
8
9
10
11
# List.map ~f:(fun x -> x+1) [1;2;3];;
- : int Core.Std.List.t = [2; 3; 4]
# let increments = [ (fun x -> x +1) ; (fun x -> x+2) ];;
val increments : (int -> int) list = [<fun>; <fun>]
# let get_head_func func_lists =
match func_lists with
| [] -> (fun x -> 0)
| hd::tl -> hd;;
val get_head_func : ('a -> int) list -> 'a -> int = <fun>
# let testfunc = get_head_func increments;;
val testfunc : int -> int = <fun>

多参数函数

定义命名函数的语法糖,下述两种定义方式等价:

1
2
let plusone = (fun x -> x+1);;
let plusone x = x+1;;

多参数函数的语法糖,以及重写版本。

1
2
3
4
5
let abs_diff   x  y = abs(x-y);;
let abs_diff =
(fun x ->
(fun y ->
abs (x-y)));;

!从重写版本易看出,abs_diff是有单个参数的函数,返回另一个有单个函数的参数。

这种函数称为柯里(curried)函数[柯里化(Curring)得名于 Haskell Curry,他是一位逻辑学家,对编程语言的设计和理论有重要的影响]。要解释一个柯里函数类型签名,关键是->具有右结合性。因此可以为abs_diff的类型签名加上小括号,如下所示:

1
val abs_diff : int -> (nt -> int)

这些小括号没有改变签名的含义,不过这样可以更容易地看出柯里化。

About Curried Functions

Named after the logician Haskell B. Curry (1950s).

  • was tring to find minimal logics that are powerful enugh to encode traditional logics.

  • much easier to prove something about a logic with 3 connectives than one with 20

  • the ideas translate directly to math (set & category theory) as well as to computer science.

  • Actually, Moses Schonfinkel did some of this in 1924

What's so good about curring?

In addition to simplifying the language, curring functions so that they only take one argument leads to two major wins:

  • We can partially apply a function.

  • We can more easily compose functions.

fun 支持自己的柯里化,fun匿名函数可以直接按柯里化语法写出。

部分应用,给柯里函数传入部分参数构成新函数。

1
2
3
4
5
6
let my_mod x y =
if x mod y = 0 then true else false;; (* 与下面柯里化展开的函数等价 *)
let my_mod =
(fun x -> ( fun y -> if x mod y = 0 then true else false))
;;
let my_mod_3 = my_mod 3;;

测试结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# let my_mod x y =
if x mod y = 0 then true else false;;
val my_mod : int -> int -> bool = <fun>
# let my_mod =
(fun x -> ( fun y -> if x mod y = 0 then true else false));;
val my_mod : int -> int -> bool = <fun>
# let my_mod_3 = my_mod 3;;
val my_mod_3 : int -> bool = <fun>
# my_mod 10 2;;
- : bool = true
# my_mod 10 3;;
- : bool = false
# my_mod_3 10;;
- : bool = false

递归函数

OCaml 区分了非递归定义(使用let)和递归(使用let rec)这很大程度上是技术上的原因:如果一组函数定义是相互递归的,类型推断算法需要知道这一点,另外出于某些原因(这些援引对 Haskell 等纯语言不适用),必须由程序员显式地标志函数是否是递归的。

1
2
3
4
5
6
7
let rec find_first_stutter list =
match list with
| [] | [_] -> None
| x :: y :: tl -> if x =y then Some x else find_first_stutter (y::tl)
;;
find_first_stutter [1;2;3;4;5;6;1;2];;
find_first_stutter [1;2;2;3;3;4;4;4;];;

优先级和结合性

操作符前缀和结合性有关(详细见表),比如后面例子中|前缀为左结合,^前缀为右结合。另外由于不同于 C 等语言,传入参数至函数时没有括号分隔,所以要注意-作为减法操作符合(中缀),以及负号(前缀)使用情况,比如传参时负数要括起来。

1
Int.max 3 (-4);;

后面是关于|>^>自定义操作符有意思的例子。行为很大程度上取决于前面描述的优先级规则.

1
2
3
4
5
6
let (|>) x f = f x;;
let path = "/usr/bin:/usr/local/bin:/bin:/sbin";;
String.split ~on:':' path (* |>是左结合的,从path运算内层到外层始终是字符串 *)
|> List.dedup ~compare:String.compare
|> List.iter ~f:print_endline
;;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# let (|>) x f = f x;;
val ( |> ) : 'a -> ('a -> 'b) -> 'b = <fun>
# let path = "/usr/bin:/usr/local/bin:/bin:/sbin";;
val path : string = "/usr/bin:/usr/local/bin:/bin:/sbin"
# String.split ~on:':' path (* |>是左结合的,从path运算内层到外层始终是字符串 *)
|> List.dedup ~compare:String.compare
|> List.iter ~f:print_endline;;
Characters 108-118:
|> List.dedup ~compare:String.compare
^^^^^^^^^^
Warning 3: deprecated: Core.Std.List.dedup
[since 2017-04] Use [dedup_and_sort] instead
/bin
/sbin
/usr/bin
/usr/local/bin
- : unit = ()

|>这个操作符被定义为,接受一个值和一个函数为输入,得到函数作用于该值的结果(从柯里展开更容易看出)。注意由于|前缀是左结合的,可以正常运行并得到最终结果。下面我们看另一个:

1
2
3
4
5
let (^>) x f = f x;;
String.split ~on:':' path
^> List.dedup ~compare:String.compare
^> List.iter ~f:print_endline
;;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# let (^>) x f = f x;;
val ( ^> ) : 'a -> ('a -> 'b) -> 'b = <fun>
# String.split ~on:':' path
^> List.dedup ~compare:String.compare
^> List.iter ~f:print_endline;;
Characters 29-39:
^> List.dedup ~compare:String.compare
^^^^^^^^^^
Warning 3: deprecated: Core.Std.List.dedup
[since 2017-04] Use [dedup_and_sort] instead
Characters 67-93:
^> List.iter ~f:print_endline;;
^^^^^^^^^^^^^^^^^^^^^^^^^^
Error: This expression has type string Core.Std.List.t -> unit
but an expression was expected of type
(Core.Std.String.t Core.Std.List.t ->
Core.Std.String.t Core.Std.List.t) ->
'a
Type string Core.Std.List.t = string list is not compatible with type
Core.Std.String.t Core.Std.List.t ->
Core.Std.String.t Core.Std.List.t

发现报 Error,由于^>右结合的而在最右边 expression,接受的两个参数都是函数而没有字符串,所以出现了类型不匹配。那既然由于语言限制,不好改变^>的结合性,我们改变一下写法:

1
2
3
4
5
let (^>) x f = f x;;
((String.split ~on:':' path
^> List.dedup ~compare:String.compare)
^> List.iter ~f:print_endline)
;;
1
2
3
4
5
6
7
8
9
10
11
12
13
# ((String.split ~on:':' path
^> List.dedup ~compare:String.compare)
^> List.iter ~f:print_endline);;
Characters 31-41:
^> List.dedup ~compare:String.compare)
^^^^^^^^^^
Warning 3: deprecated: Core.Std.List.dedup
[since 2017-04] Use [dedup_and_sort] instead
/bin
/sbin
/usr/bin
/usr/local/bin
- : unit = ()

手动加了括号控制结合就可以正常运行了。

用函数声明函数

定义函数的另一种方法是使用function关键字。function内置了模式匹配,而不是为声明多参数(柯里)函数提供语法支持,不过我们可以使用将不同的函数声明组合在一起,声明多参数柯里函数,并对其中一些参数进行模式匹配。

1
2
3
4
5
6
7
8
let some_or_default default = function
| Some x -> x
| None -> default
;;
some_or_default "default" (Some "test");;
some_or_default "test" None;;
List.map ~f:(some_or_default "t") [Some "test";Some "default";None];;
some_or_default 3 (Some 5);;

标签参数

按名称标识一个函数参数,可以按任意顺序提供参数。标签参数由前面的一个波浪线标志,并把标签(后面有一个冒号)放在要加标签的变量前面(实际上这里使用了标签双关,所以省略了冒号和后面的同名变量)。

1
let ratio ~num ~denom = float num /. float denom;;
1
2
3
4
5
6
# let ratio ~num ~denom = float num /. float denom;;
val ratio : num:int -> denom:int -> float = <fun>
# ratio ~num:3 ~denom:4;;
- : float = 0.75
# ratio ~denom:4 ~num:3;;
- : float = 0.75

常见使用情况

  • 定义一个有很多参数的函数时
  • 仅从类型无法清楚地了解某个特定参数的含义时
  • 定义有多个参数的函数,参数可能相互混淆时
  • 如果希望传递参数的顺序具有灵活性

高阶函数和标签

关于标签参数有一个奇怪的问题,尽管调用有标签参数的函数时参数的顺序并不重要,但是对于高阶函数,这个顺序却是有影响的。例子:

1
2
let apply_to_tuple f (first,second) = f ~first ~second;;   (* 这里f作为参数传入apply *)
let apply_to_tuple2 f (first,second) = f ~second ~first;; (* 我们改变标签参数列出的顺序 *)
1
2
3
4
let apply_to_tuple f (first,second) = f ~first ~second;;
val apply_to_tuple : (first:'a -> second:'b -> 'c) -> 'a * 'b -> 'c = <fun>
# let apply_to_tuple2 f (first,second) = f ~second ~first;;
val apply_to_tuple2 : (second:'a -> first:'b -> 'c) -> 'b * 'a -> 'c = <fun>

从函数定义看,有影响。比如我们分别传递下面函数到上述两函数中,一个可行一个不可行。

1
2
3
4
5
let apply_to_tuple f (first,second) = f ~first ~second;;   (* 这里f作为参数传入apply *)
let apply_to_tuple2 f (first,second) = f ~second ~first;; (* 我们改变标签参数列出的顺序 *)
let divide ~first ~second = first / second;;
apply_to_tuple divide (3,4);; (* 可以正常工作 *)
apply_to_tuple2 divide (3,4);; (* 类别不符 *)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# let apply_to_tuple f (first,second) = f ~first ~second;;
val apply_to_tuple : (first:'a -> second:'b -> 'c) -> 'a * 'b -> 'c = <fun>
# let apply_to_tuple2 f (first,second) = f ~second ~first;;
val apply_to_tuple2 : (second:'a -> first:'b -> 'c) -> 'b * 'a -> 'c = <fun>
# let divide ~first ~second = first / second;;
val divide : first:int -> second:int -> int = <fun>
# apply_to_tuple divide (3,4);;
- : int = 0
# apply_to_tuple2 divide (3,4);;
Characters 16-22:
apply_to_tuple2 divide (3,4);;
^^^^^^
Error: This expression has type first:int -> second:int -> int
but an expression was expected of type second:'a -> first:'b -> 'c

做个改变,如果我们调整divide的声明,似乎两边都不行了,跟人猜测和标签名“绑定”有关,但是书中没有给出解答。

1
2
3
4
5
6
7
8
9
10
11
12
#   apply_to_tuple divide (3,4);;
Characters 16-22:
apply_to_tuple divide (3,4);;
^^^^^^
Error: This expression has type fi:int -> se:int -> int
but an expression was expected of type first:'a -> second:'b -> 'c
# apply_to_tuple2 divide (3,4);;
Characters 16-22:
apply_to_tuple2 divide (3,4);;
^^^^^^
Error: This expression has type fi:int -> se:int -> int
but an expression was expected of type second:'a -> first:'b -> 'c

可选参数

可以选择是否提供这个参数,可选参数也可以按任意的顺序提供。

1
2
3
4
5
6
7
let concat ?sep x y =
let sep = match sep with
| None -> ""
| Some x -> x in
x ^ sep ^ y;;
concat "foo" "bar";;
concat ?sep:(Some "what") "foo" "bar";;
1
2
3
4
5
6
7
8
9
10
# let concat ?sep x y =
let sep = match sep with
| None -> ""
| Some x -> x in
x ^ sep ^ y;;
val concat : ?sep:string -> string -> string -> string = <fun>
# concat "foo" "bar";;
- : string = "foobar"
# concat ?sep:(Some "what") "foo" "bar";;
- : string = "foowhatbar"

可选参数优缺点:

  • 优点
    • 编写有多个参数的函数,弹性扩展功能而不影响默认使用
  • 缺点
    • 调用者不清楚还有选择,可能会盲目(错误地)选择默认行为。
    • 带来简介性,损失明确性