【干货💥】手摸手教你函数式编程语言 SML

4,521 阅读21分钟

考完就不看のSML语言基础

来自于educoder

SML语言中的基本类型
概述

SML语言中基本类型有bool, int, real, char, stringfunction(函数类型),一个数据的类型可以是布尔值,整数,实数,字符(串),甚至是函数。

通过这些基本类型可以组合出其他的类型,例如,一个含有两个元素的表(二元组)可以表达为(13,"Hello, world!"),其中第一个元素的类型为int,第二个元素的类型为string,整个二元组的类型为string * int,注意中间的*并不是乘号,而是一个连接符号。

提示:("John",18)(18,"John")不是一个类型,前者为string * int,后者为int * string

int类型

请打开页面给出的命令行窗口,键入sml命令,即可进入SML交互式环境。

此时,屏幕会弹出


Standard ML of New Jersey v110.78 [built: Thu Jul 23 11:21:58 2015]

表示你已经进入了SML的开发环境。不过,在调试之后,请把要用的代码复制到提交的文本框内,系统才能对其进行编译和评测。

我们可以通过

val a = 15;

来创建一个名称为a,值为15的整型变量,其中,val为SML的关键字,表示使用名字a代替15,语句末尾需要加上分号;表示结束。

SML通常会返回如下内容

> val a = 15 : int

我们以后在代码前加一个符号>,表示编译器返回给我们的结果。

如果你想创建一个负数,请输入

val b = ~15;

注意前面的符号是~而不是负号-,SML对运算符有着极为严格的约束,符号-为减法的中缀运算符,只能且必须接受两个参数。符号~为取相反数的运算符。或者,你也可以输入

val c = 0 - 15;

此时程序输出

> val c = ~15 : int

表示你创建了一个整数c,值为-15

提示:int类型的上界为2^{30}-1=1\ 073\ 741\ 823230−1=1 073 741 823,下界为-2^{30}=-1\ 073\ 741\ 824−230=−1 073 741 824

接下来,如果你想做整数的计算,我们有算符+, -, *, div, mod,其中特别注意整数除法a除以ba div baba mod b

real类型

SML中的带符号小数使用real类型,你可以通过

val pi = 3.14159;

创建一个名为pi的实数变量,程序通常会返回

> val pi = 3.14159 : real

其中,real表示变量的类型为实数。

另外,如果你想创建一个绝对值为1的实数,必须输入

val k = 1.0;

小数点是不可少的,如果没有小数点,编译器会认为k是一个int类型而非real类型。

另外,定义在real上的算符有+, -, *, /,注意实数除法a除以ba/b而不是a div b,和整数是不同的。

我们要特别指出,两种不同的数据类型之间不能进行任何运算,例如,一个整数无法和一个实数相加。如果你想要用一个整数加一个实数,需要把整数转换成实数,或实数转换成整数,对于整数转换为实数,我们有

Real.fromInt(4)

它可以把整数4转换为实数4.0

对于实数转换为整数,我们有

floor(1.4);ceil(1.4);trunc(~1.4);

它们的功能请自行实验。

stringchar类型

文本是由字符组成的串,它的类型为string,字符串常量是用双引号括起来的:

val s = "Madam, I'm Adam";

用符号^可以连接两个字符串,如

val s1 = "Hello" ^ "world!";

程序会输出

> val s1 = "Helloworld!" : string

注意程序并不会特地帮助你在两个字符串之间插入一个空格。

bool类型

布尔类型只可能取两个值,如下:

val a = false;val b = true;

如果试图把a的值设为0,那么编译器将认为a是一个整数而非布尔数,另外,布尔数true, false只能和自身比较,试图比较true1的关系将会产生编译错误。

输出

在整个编程实践中,我们主要将目光放在如何利用SML语言编写一个算法上,输入和输出的函数程序会预先给定,我们只需要调用即可。

int的输出

我们已经预先给出了printInt函数,调用

printInt(25);

就可以在屏幕上输出25一个空格,不过并不需要担心在测评过程中出现什么问题,我们已经忽略了行末可能多出来的那个空格。另外,我们输入

printInt 25;

也可以在屏幕上输出25,括号并不是必须的。

real的输出

我们也预先给出了printReal函数,不过考虑到精度问题,我们会尽量避免输出实数。总之,你可以使用

printReal(25.0);

在屏幕上输出25.0和一个空格,另外,同样地,括号也不是必须的。

string的输出

你可以使用printString来输出一个string,当然,直接使用内置函数print也可以。

printString("Hello, world!");
输入两个整数

我们预先给定了函数

getInt();getReal();

来输入整数和实数。

可以通过

val m = getInt();

从标准输入读取一个正整数mm

使用条件表达式

为了分情况定义函数,或者说定义分段函数,我们可以引入条件表达式和测试表达式。

测试表达式

一个测试表达式EE是像这样的东西

a = 4;b > 5;c <= 6;

这个表达式返回一个bool类型的值,true表示表达式成立,false表示表达式不成立。

一些简单的判断关系有=, >, <, >=, <=, <>,其中最后一个表示不相等。特别注意这里判断相等是一个等号,不等是一个小于号和一个大于号(而不是!=)。这些关系可以用来判断整数或实数。

判断表达式

一个判断表达式是像这样的东西

if E1 then E2 else E3

其中,else不能省略,E1是一个测试表达式。

这个表达式的作用是如果E1true,那么把这个位置的式子替换为E2,否则把它替换为E3

例如一个计算n的符号的函数可以写成这样:

fun sign(n) =    if n > 0 then 1    else if n = 0 then 0    else ~1;

注意分号的位置,与C语言有所不同,实际上,把这四行代码写在一行里,也不会有任何问题。

定义一个函数

在SML中,一个函数的定义大概像下面这个样子

fun area (r : real ) = 3.14 * r * r;

函数定义以关键字fun开始,而area是函数的名字,r形式参数,或者可以简单理解为这个函数的参数,冒号后接类型名,表示限定r必须为实数,不过如果不接类型名,编译器会自动推导参数的类型。但是不能保证推导结果一定准确。

=之后的部分为这个函数所代表的公式(注意前面我们提到的,相比起C语言中一个函数是一个依次执行的过程,在SML中一个函数是一个数学公式,我们不过是把参数代入了这个公式),或者说,把area(r)替换3.14 * r * r。当然,它和替换有一点点小区别,但是我们这么理解是无妨的。

另外,函数的括弧不是必要的,我们可以写成

fun area r = 3.14 * r * r;

和上面的函数具有相同的作用。如果我们要定义一个接受多个参数的函数,如

fun plus(a, b) = a + b;

此时这个函数并非接收了两个参数,而是接受了一个二元组,这个数组中的两个元素在函数中有各自的作用,但总体来说函数只接收了一个对象,或者说一个“东西”。

我们要特别指出,我们可以反复定义一个函数,例如这样

fun area (r) = 3.14 * r * r;val a = area(5.0);fun area (r) = 3.14 * r;val b = area(5.0);

那么,对于这个例子,a的值将为78.5,而b的值将为15.7

无限精度整数的使用

无限精度整数类型为

IntInf.int

我们在声明一个数值的同时,显式指定类型,就能声明该类型的数据,比如这样

val a : IntInf.int = 2147483648;

我们声明了一个无限精度的整数aa,它的大小是2^{31}231,已经超过了普通整数的上界。程序输出如下结果

val a = 2147483648 : IntInf.int

接下来,我们计算a的平方,因为是两个相同类型的数据相乘,无限精度整数也定义了它自己的乘法符号

val b = a * a;

程序输出如下

val b = 4611686018427387904 : ?.intinf

(注意这个版本的编译器可能有一些bug,产生了一些乱码,但是不影响使用)

接下来,我们的快速幂将在无限精度上完成。

特别地,我们新增了getIntInf函数和printIntInf,用来帮助你读取和输出大整数。它的使用方法像这样

val b = getIntInf();printIntInf(b);
迭代与递归

你或许在C语言里编写过这样的代码

int sum = 0;int NumberCount = 0;scanf("%d", &NumberCount);for(int i = 0; i < NumberCount; i++)  {    int tmp;    scanf("%d", &tmp);    sum += tmp;  }print("%d\n", sum);

我们很容易发现,它的作用是计算一组数据的和。但是,在ML语言里,你可能很难写出这样的代码,为什么呢?

你也许会觉得,ML里舍弃这种循环的写法,而是必须生硬地写成递归形式,是很没有道理的。但是,一个设计良好的递归算法,比如求这一组数据的和的算法,很容易划分为求两个(相等长度的)子序列的和,再把它们相加。于是,用迭代的方法,算法的复杂度为

但是利用递归,我们可以做到

它不仅是 的,而且可以在并行计算机上实现更低的复杂度。

数据结构:表

如果我们的计算机只用处理几个数据,这时我们可以把它们分别命名并进行处理,但是有时候要求输入一组数据到计算机中,这些数据有某种共性,并且各自具有差异性。对于这类数据,我们往往会把它们放到C语言的数组里,就像这样

for(int i = 0; i < NumberCount; i++)    scanf("%d", & Array[i]);

可以做成表的数据多种多样,例如一个学号表,一张质数表,或者一个利用结构体构造的实例表,如

struct Student{    int IdentityNumber;    enum Grade;    string Name;    int Year_of_birth;};Student Table[100];

这样就构造了一个包含100个学生信息的表。总之,表是一个常用且重要的数据结构。

但是,在C语言中,对表的处理仅仅是简单地划分了一块内存,类似

Student tmp = Table[102];

的语句在编译时很可能不会提示错误,但是却成为了运行时的灾难。甚至连语句

Student tmp = Table[-3];

也是合法的,毕竟,从C语言的角度看,我们只是对内存地址进行了相应的偏移而已。

但是,在SML中,表是作为一个数据结构出现的,和C语言有很大的区别(后者仅仅认为表是一块内存而已)。在学习SML中的表的时候,请舍弃C语言中数组的概念。某种意义上,SML中的表更像C++ STL中的list,远非仅仅一块内存。

(list) 是一个有限的元素序列,其中元素的顺序是有意义的,并且元素可以重复出现。类似的数据结构集合中,元素的顺序没有意义,并且元素只能出现一次(当然,我们并没有讨论多重集)。例如,以下的表都是不同的

[3,4]   [4,3]   [3,4,3]   [3,3,4]

表中的元素的类型可以是任意的,比如整数、实数、字符串或布尔值,但是表中每个元素的类型必须相同(对比元组来看),例如

(33.0)

会被认为是一个二元组,第一个元素是整数3,第二个元素是实数3.0,但是一个表如果像下面这样

[33.0]

那么编译器就会提示错误。

除了类型上有限制,表和元组还有一个重要的区别:表的长度是可变的。这一点和C语言中的表有很大不同,SML中的表实际上仅仅是一些元素连接起来构成的,例如

nil

表示一个空表,也可以用[ ]表示一个空表。

3 :: nil

中间的::符号称为表的构造符号,这个表达式产生一个表,表中的元素只有3,大概像这样的感觉:

3 ]

如果你输入一个表[3,5],实际上和输入

3 :: 5 :: nil

等价。

最后,我们给出表的递归定义:

一个表L要么是空表,要么形如x :: L1,其中L1也是一个表,x是一个数据元素。

所以,每个表最后必须以空表结尾,类似

3 :: 5

并不能构造一个表,编译器只会弹出一个错误,但是你可以通过3 :: 5 :: nil3 :: 5 :: []来构造一个表。

几个内置函数

我们给出表的三个内置函数null, hdtl

其中

null

判断一个表是否为空表,例如

null [10];> val it = false : boolnull [];> val it = true : bool
 hd

给出一个表的表头,例如

hd [1,2,3];> val it = 1 : int

特别注意,

hd

在接受一个空表作为参数的时候,会产生异常。

 tl

给出一个非空表的表头后面的那部分,例如

tl [1,2,3];> val it = [2,3] : int listtl [1];> val it = [] : int listtl [];> uncaught exception Empty

注意

tl

的返回值总是一张表。

定义在表上的库函数

我们先给出三个函数,分别是lengthtakedrop

length函数接受一个表作为参数,返回这个表的长度,例如

length([1,2,3]);> val it = 3 : intlength([]);> val it = 0 : int

时间复杂度为

take函数定义在List结构里,接受两个参数,第一个参数是一个表,第二个参数是一个整数N,会返回另一个表,包含这个表的前N项,例如

List.take([1,2,3],2);> val it = [1,2] : int listList.take([1,2,3],0);> val it = [] : int listList.take([1,2,3],5);> uncaught exception Subscript [subscript out of bounds]

drop函数定义在List结构里,接受两个参数,第一个参数是一个表,第二个参数是一个整数NN,会返回另一个表,包含弃去这个表前NN项后剩下的表,例如

List.drop([1,2,3],2);> val it = [3] : int listList.drop([1,2,3],1);> val it = [2,3] : int listList.drop([1,2,3],3);> val it = [] : int listList.drop([1,2,3],4);> uncaught exception Subscript [subscript out of bounds]List.drop([1,2,3],0);> val it = [1,2,3] : int list

这两个函数的时间复杂度均与length相同。

另外,还有一个函数nth,它可以取出这个表的第nn项,从00开始计数。用法像这样

List.nth(["Hello","world","You"],0);        > val it = "Hello" : string
追加和翻转

你可以使用SML中内置的方法把一个表接到另一个表之后,或者将一个表内的元素翻转过来。

追加操作符@可以将一个表追加到另一表之后,像这样

[1,2,3] @ [2,3,3];> val it = [1,2,3,2,3,3] : int list[ ] @ [2,3,3];> val it = [2,3,3] : int list[1,2,3] @ [ ];> val it = [1,2,3] : int list[1,2,3] @ [4.0];> Error: operator and operand don't agree [overload conflict]

注意要追加的两个表内部的数据类型必须相同,否则会产生错误。

另外,我们可以使用rev函数翻转一个表中的元素,像这样

List.rev([1,2,3]);> val it = [3,2,1] : int list

注意如果你试图翻转一个空表,结果仍是一个空表,同时会产生一个警告。

表的配对

是不是觉得一直都在介绍表的操作,有些无聊了?不用担心~从下一题开始就是表的应用了。

如果你有两张表,一张表上记录了学生的学号,另一张表上记录了学生的姓名,你或许会希望把每一个学号和一个姓名配对起来,像这样

val Name = ["Three Zhang""Four Lee""Five Wong"];val IdentityNumber = [1000,1001,1002];

你想生成一张新表,内容大概是这样的

[ ("Three Zhang"1000), ("Four Lee"1001), ("Five Wong"1002) ]

那么,你可以使用ListPair库中提供的zip函数,像这样

ListPair.zip(Name,IdentityNumber);> val it = [("Three Zhang",1000),("Four Lee",1001),("Five Wong",1002)]    : (string * int) list

你可以在这里找到关于结构List的更多内置函数,在这里找到关于结构ListPair的更多内置函数。

向量的使用

SML中的表,类似C语言中的链表,你只能从前往后逐个访问,访问某个表中元素的开销为O(N)O(N)。不过在SML中我们也提供了可以随机访问的表vector,你可以利用函数Vector.fromList从表中创建一个向量。

val vec = Vector.fromList [1,2,3];> val vec = #[1,2,3] : int vector

特别要注意的是,在交互式环境中,你可以用

val vec = #[1,2,3];

直接创建一个vector,但是我们的评测环境不支持这种做法,请不要这样使用。

我们提供了getIntVector函数和getIntInfVector函数,方便你直接从标准输入读入一个vector

接下来,你可以使用Vector.length来求出一个vector中含有的元素个数,你可以使用Vector.sub函数取出一个vector里的任意元素,下标从00开始。这个操作是O(1)O(1)的。

Vector.length(vec);> val it = 3 : intVector.sub(vec,1);> val it = 2 : int

你可能会问,为什么我不能用List.nth完成这个问题呢?因为这个操作是O(N)O(N)的而非O(1)O(1)的,List不支持随机位置读写,而Vector支持。

理论上来说,一个vector创建之后就保持不变了,最好不要更改其中元素的个数,不过,你可以修改其中某个元素的值,但是我们并不推荐这样做。

你需要利用vector结构完成二分查找问题。

map方法

map这个函数内置于SML中,作用是给定一个算符f和一组元素序列[x1, x2, ..., xn],计算[f(x1), f(x2), ..., f(xn)]的值,例如,我们有如下的简单小程序

fun getSum ( [] ) = 0   | getSum ( x::xs ) = x + getSum(xs);map getSum [ [1,2,3], [4,5,6,7] ];> val getSum = fn : int list -> int> val it = [6,22] : int list

这里,map函数把getSum分别作用在了[1,2,3]上和[4,5,6,7]上,计算出两个求和的值为622。这两个计算的过程是同时进行的。

SML中的命令式设计

引用及其操作

在ML中,关键字ref可以创建一个引用(reference),例如

val p = ref 5;> val p = ref 5 : int ref

表示分配一段内存空间,并在这段空间里存储整数5,然后把这段空间的地址赋给p

你可以通过操作符!进行反引用,它取出对应内存地址中存储的值,例如

!p> val it = 5 : int

接下来,你可以通过赋值操作符:=对引用所代表的内存空间进行赋值,它和C语言中的赋值有所不同,SML中的赋值操作符两侧都可以是表达式,但C语言的赋值操作符左侧必须是变量名。SML中的赋值操作符要求左侧表达式求值的结果为一个内存地址(指针),右侧求值的结果为一个值,并且类型必须和左侧指针指向的值的类型相同。赋值操作符返回一个unit,所以并不能连续赋值。

总之,我们举几个例子:

p := 6;> val it = () : unitp;> val it = ref 6 : int refp = 6;> stdIn:5.1-5.6 Error: operator and operand does not agree [overload conflict]p = ref 6;> val it = false : bool- !p = 6;val it = true : bool

注意第7行,指针的相等当且仅当它们指向相同的内存地址时才成立,p有自己的内存地址,然而ref 6又创建了另一块内存空间存放6这个数,所以即使它们内存地址里的值是相同的,但是因为它们指向的内存地址不同,所以它们仍然不相等。另外,解引用之后从内存地址中取出的值是相等的。

当然,类似C语言中的多重指针,引用的引用也是允许的,不过除非必要,否则并不推荐这样做。

如果把一个引用赋给另一个引用会发生什么?

val q = p;> val q = ref 6 : int refp := 7;> val it = () : unitq;> val it = ref 7 : int ref

我们发现,当我们修改p的值之后,q的值也改变了,这是因为,你修改的是p所指向的内存空间中存储的值,而qp指向相同的内存空间,所以它们的值会同时改变,这并不是一个好的设计模式,不过我们有时会用到这种设计方法。

当然,函数类型也可以声明为引用,不过我们在此不做讨论。

另外,在函数调用一个二元组(E1,E2)的时候,如果E1改变了状态,它可能会影响E2执行的结果,我们并不推荐这样设计。

命令组

你可以通过表达式

(E1; E2; ...; En)

来执行一组命令,它会从左到右按顺序计算E1, E2, ..., En的值,然而,整个表达式的结果是En的值,其他表达式的值都将被丢弃。由于分号操作符在ML中还有别的作用,所以这一组表达式必须用括号括起来,除非用在let表达式的主体中

let    Din    E1;    E2;    ...;    Enend
迭代

ML给出了while命令进行迭代,命令语法是这样的

while E1 do E2

如果对E1的求值结果为false,那么命令结束。如果对E1的求值结果为true,那么对E2进行求值,并且再次执行while,精确地讲,while可以定义为一个递归方程

while E1 do E2 =     if E1 then (E2; while E1 do E2)    else ();

在这里,我们每次对E2求值,仅仅是为了改变某个状态。

本文使用 mdnice 排版