面向对象编程:第三阶段知识点回顾与总结
面向对象第三次作业总结
JML基础梳理及工具链
注释结构
- 行注释:
//@annotation
- 块注释:
/*@ annotation @*/
//@annotation
/*@ annotation @*/
两种注释方法均适用@开头,并将JML注释放在被注释成分的近邻上部。
常见表达式
-
原子表达式
-
\result
表达式:表示一个非 void 类型的方法执行所获得的结果,即方法执行后的返回值。这里此表达式的类型根据被注释的函数的返回值而定。 -
\old(expr)
表达式:用来表示一个表达式expr在相应方法执行前的取值。当expr有被修改时,使用此表达式,表示expr被修改之前的值。这里有一点需要注意,对于一个引用对象,只能判断引用本身是否发生变化,即只能描述引用对象的地址是否发生变化,而不能描述对象的成员变量等是否发生变化。 -
\not_assigned(x,y,…)
表达式:用来表示括号中的变量是否在方法执行过程中被赋值。 -
\not_modified(x,y,…)
表达式:限制括号中的变量在方法执行期间的取值未发生变化。 -
\nonnullelements(container)
表达式:表示container对象中存储的对象不会有null。
-
-
量化表达式
-
\forall表达式
:全称量词修饰的表达式,表示对于给定范围内的元素,每个元素都满足相应的约束。使用的结构如下:(\forall 类型 变量; 变量满足的限制; 在限制条件下的结果)
以下几种表达式均有类似的使用结构。
-
\exists
表达式:与forall表达式使用结构类似,为存在量词修饰的表达式,表示对于给定范围内的元素,存在某个元素满足相应的约束。 -
\sum
表达式:返回给定范围内的表达式的和。 -
\product
表达式:返回给定范围内的表达式的连乘结果。 -
\max
表达式:返回给定范围内的表达式的最大值。 -
\min
表达式:返回给定范围内的表达式的最小值。 -
\num_of
表达式:返回指定变量中满足相应条件的取值个数。
-
-
操作符
- 子类型关系操作符
E1<:E2
,如果类型E1是类型E2的子类型(sub type),则该表达式的结果为真,否则为假。如果E1和E2是相同的类型,该表达式的结果也为真。 - 等价关系操作符
b_expr1<==>b_expr2
或者b_expr1<=!=>b_expr2
,其中b_expr1和b_expr2都是布尔表达式,这两个表达式的意思是 b_expr1==b_expr2 或者 b_expr1!=b_expr2 。 - 推理操作符
b_expr1==>b_expr2
或者b_expr2<==b_expr1
,即数理逻辑中的蕴含运算。 - 变量引用操作符
-
\nothing
表示空集; -
\everything
表示全集。
-
- 子类型关系操作符
方法规格
-
前置条件(pre-condition)
前置条件是对调用者的限制,即要求调用者确保条件为真。
-
后置条件(post-condition)
后置条件是对方法实现者的要求,即方法实现者确保方法执行返回结果一定满足谓词的要求,即确保后置条件为真。
-
副作用范围限定(side-effects)
-
assignable
表示可赋值 -
modifiable
表示可修改 - 多数情况下二者可以交换使用
-
-
signals子句
signals子句的结构为
signals (Exception e) b_expr
,当b_expr为true时,方法会抛出括号中给出的相应异常e。signals_only子句
是对signals子句的简化版本,不强调对象状态条件,强调满足前置条件时抛出相应的异常。
类型规格
-
不变式invariant
不变式是要求在所有可见状态下都必须满足的特性。可见状态主要包含以下几种:
对象的有状态构造方法(用来初始化对象成员变量初值)的执行结束时刻 在调用一个对象回收方法(finalize方法)来释放相关资源开始的时刻 在调用对象o的非静态、有状态方法(non-helper)的开始和结束时刻 在调用对象o对应的类或父类的静态、有状态方法的开始和结束时刻 在未处于对象o的构造方法、回收方法、非静态方法被调用过程中的任意时刻 在未处于对象o对应类或者父类的静态方法被调用过程中的任意时刻
-
状态变化约束constraint
可以认为状态变化约束比上面的不变式放宽了一定的限制,它并不要求一定不发生变化,而是在变化过程中形成要满足一定的约束条件。
JML工具
-
openJML
可以根据JML对实现进行静态的检查,其中用于进行JML分析的部分在
solvers
中,包括常见的如cvc4
以及z3
。 -
JMLUnitNG
可以根据JML自动生成对应的测试样例,用于进行单元化测试。
关于这两种工具的使用,实际情况如下所示。
JMLUnitNG使用实例
在这里我根据讨论区中的示例进行了尝试,过程如下。
我在当前目录下的test
文件夹下创建里一个名为Test.java
的文件,然后上述四步基本上仿照讨论区的示例。
首先在Test.java
所在文件目录下生成对应的测试用的文件。此时该目录下文件如下:
然后后续的指令分别进行的是对test
中Test_JML_Data
目录下的java文件进行编译,然后使用jmlc编译待测试文件。以上步骤完成之后,再直接使用第四条指令会报错,第四条指令是运行已经生成好的测试文件,查看测试结果,但是此前尚未对这个文件进行编译,因此,再执行一句:
javac -cp jmlunitng.jar test/*.java
这样,使用上述第四条指令之后,就可以看到测试效果:
可以看到自动生成的用例对于compare函数测试,其测试方法是使用极端的数据进行组合,分别使用0、int类上限、int类下限,对这个函数进行测试。
架构设计与重构分析
第一次作业
- 结构分析
首先是关于第一次作业的结构设计。从整体上看层次比较简单,在主类Main
中通过将MyPath
和MyPathContainer
两个类传给AppRunner
函数,实现对这两个功能模块的测试。而在MyPathContainer
这个类中,又因为涉及到对Path
的操作,因此会一定程度上依赖于Path
。
从细节上讲,在类MyPath
中,主要进行了存取的操作,增加的操作只在初始化时存在,并没有删除的操作,保存路径的节点又是一个有序的结构,因此使用Arraylist
进行存储。另外,由于涉及到要查找不相同的节点的个数,为了避免每次查找都统计一次浪费时间,直接采用将统计的操作平摊到时间复杂度不容易减小的初始化操作中,并使用HashSet
来进行存储,这样在尝试获取不同节点个数时,只要获取此set的size即可。
在类MyPathContainer
中,有比较多的增删操作,同时还涉及从Path到PathId以及反向的双向查找,因此考虑使用两个HashMap,分别记录双向的索引关系。此外,在这个类中要统计所有Path的不同节点的个数,因此再次维护一个HashMap,目的是将获取不同节点个数的操作平摊到增加路径和删除路径中,这个HashMap的Value部分是这一个节点在全局中出现的次数,用于在删除路径时判读是否要删除此节点。
public int removePath(Path path) throws PathNotFoundException {
if (path != null && path.isValid() && pathList.containsValue(path)) {
int oldId = pidList.get(path);
pathList.remove(oldId, path);
pidList.remove(path, oldId);
for (Integer node : ((MyPath) path).getNodesSet()) {
if (nodesSet.get(node) >= 2) {
nodesSet.replace(node, nodesSet.get(node) - 1);
} else {
nodesSet.remove(node);
}
}
return oldId;
} else {
throw new PathNotFoundException(path);
}
}
- 复杂度与依赖度分析
因为是第一次作业,方法的功能都不算很难,因此从整体上看复杂度与相互之间的依赖度大体上在比较合理的范围之内。其中,在MyPath
类中,equals()
方法的复杂度相对较高,其实我认为只是其他方法实现的功能比较基础,并不需要复杂的条件判断等,而此方法根据JML的描述,需要分不同的情况讨论,同时需要有一个循环遍历的过程。其他的方法中复杂度相对较高的,应该是对Path的增加和删除的方法,由于这样的方法不可避免地要进行遍历的操作,自身的时间复杂度不容易下降,因此将其他操作分摊到这些方法中,导致了其复杂度的相对偏高。
第二次作业
- 结构分析
首先是关于继承与重构的分析。从上面的图中可以看到,在MyPath
类中基本完全继承于上一次作业中的设计,然后新增加的MyGraph
类,与之前MyPathContainer
类相同的方法基本没有做改变,直接来自于上一次作业,这里做的不好的一点,就是没有必要使用复制的方式,应该直接让MyGraph
直接继承 MyPathContainer
类即可。
至于重构方面,我对之前的MyPathContainer
的成员变量进行了增加。增加的成员变量是一个GraphStructure
类型的变量。因为这次涉及到了更多关于图的操作,因此我将图这个数据结构单独封装成一个类,即上面的GraphStructure
类,里面我使用了静态数组存储图的邻接矩阵,考虑到指导书上对于图的节点数的限制,我使用了一个LinkedList
的结构保存当前空闲的节点,并通过一个HashMap结构存储节点编号与静态矩阵的索引号的映射。基于这个邻接矩阵,我使用Floyd算法,在每次对图进行变更时,一次性计算出图中任意两点之间的最短距离,将查找最短距离的操作转化成对于静态数组的访问,以节省每次使用单源最短路径算法的计算成本。
如上所言,由于我将与对图有关的操作转移到了GraphStructure
这个类中,因此在第一次作业中在MyPathContainer
中进行路径的增加和删除操作,实际上是改成了调用GraphSturcture
实例中的方法,降低了这个方法的复杂度,对功能进行了更好的分离。
// 与前一段代码相比较,其中删除节点的细节已经被封装在`GraphStructure`这个类中
// 在`MyGraph`类中只是进行对应的方法调用
public int removePath(Path path) throws PathNotFoundException {
if (path != null && path.isValid() && pathList.containsValue(path)) {
int oldId = pidList.get(path);
pathList.remove(oldId, path);
pidList.remove(path, oldId);
for (Integer node : ((MyPath) path).getNodesSet()) {
if (nodesSet.get(node) >= 2) {
nodesSet.replace(node, nodesSet.get(node) - 1);
} else {
nodesSet.remove(node);
}
}
return oldId;
} else {
throw new PathNotFoundException(path);
}
}
此外,一个没有必要的设计就是上图中的EdgeStructure
结构,我本意是希望通过这个结构判断两条边是否是同一条边,因此也为这个类重写了equals()
方法和HashCode()
方法。其实这个类完全可以通过两级的HashMap的嵌套来实现。
- 复杂度与依赖度分析
如果如上面所言,使用两个HashMap嵌套的结构来取代自己创建的EdgeStructure
类,可以减少其他类对这个类不必要的依赖。其余的依赖均在正常情况之内。
这次复杂度比较高的地方,主要集中在了与图有关的操作上,比如GraphStructure
类中加载一条路径以及删除一条路径的操作,以及使用Floyd算法更新图的操作。其实在加载路径(loadPath
函数)以及删除路径(delPath
函数)中,由于之前设计的不好的EdgeStructure
类,导致为了加载每一条边,还会额外增加一层循环。
for (EdgeStructure edge : path.getEdges()) {
// imitate add node to nodesSet
if (edgesSet.keySet().contains(edge)) {
edgesSet.replace(edge, edgesSet.get(edge) + 1);
} else {
edgesSet.put(edge, 1);
// edge already has two ori
oriMap[matMap.get(edge.getFrom())]
[matMap.get(edge.getTo())] = 1;
updateFloydMat = true;
}
}
如果对这里进行优化的话,完全可以进一步降低复杂度。而且在实际测试中,可以看出这个类的hashCode
方法重写地不好,导致散列表的散度不够,会使得访问HashMap的时间成本增加。总而言这,这样的设计是一个很大的败笔。
第三次作业
- 结构分析
首先是继承与重构的分析。这次作业为了改进结构,没有直接对原来的函数进行复制,而是直接使用了继承的方式。与第一次作业相比,MyPath
类以及MyPathContainer
类基本上没有变化。然而与第二次作业相比,我对MyGraph
类以及GraphStruct
类进行了重构,重构的原因是因为指导书增加了增删指令的条数,继续沿用第二次的结构,但是会因为时间复杂度过高导致CPU time超时。
这次我采用了BFS+Priority+Cache的策略。我将这一结构分别封装在了BfsAlgo
类以及CacheStruct
类中,同时为了辅助实现,又封装了一个WeightNode
的类。主要的思路是,通过BFS进行遍历,同时根据指定的权重计算方式(在这里我使用了modeSwitcher
函数以及三种权重的计算函数来实现),计算当前路径到达此节点时的权重,并根据此创建WeightNode
对象,将其加入优先队列中。每次从优先队列中取队首,作为最小权重,重复此过程,直到找到为止。由于每次第一次从优先队列里取出的WeightNode是到当前节点的最短加权距离,因此将其保存到cache,以便下次进行访问。
- 依赖度和复杂度分析
由于相对于第二次作业进行了一些改善,这次不必要的依赖有所减少。这次比较复杂的方法都集中在了这次作业新增的功能上。其原因在于这些功能增加了对算法的要求,而这次使用的BFS也不再是单纯的BFS,其中增加了很多诸如标记、存储、以及遍历的条件判断的操作,因此复杂度不免会增加。
bug与修复
-
第一次作业
这次作业在强测和互测中均没有被爆出bug。在程序写完进行调试的过程中出现的bug是在处理路径的id部分,由于最开始没有审题,误将id从0开始计算。
这次作业从完成到最终提交其实也经历了很多重构以及修改的过程。最开始的版本,由于对操作要求缺乏分析,以及对Java的数据结构类的实现方式的不清楚,我在
MyPath
以及MyPathContainer
两个类中均只使用了Arraylist
结构,后来通过看讨论区以及看了一些源码之后,才做出了之前所述的调整。 -
第二次作业
这次作业在强测和互测中均没有被爆出bug。在程序写完进行调试的过程中也没有遇到bug,但是由于最开始结构设计与算法使用的不合理,导致本地自己测试时CPU时间偏长。
最开始我使用的是BFS+cache的模式,其实这种做法本身没有问题,但是正如前面所讲,我使用了自己构造的
EdgeStruct
类作为HashMap的索引,然而hashCode
方法重写地不好,导致经常会有不止一个对象拥有同一个hashCode的情况,因此在数据集比较大的情况下,使用JProfiler查看各部分对CPU时间的贡献,可以看到对于这个HashMap的操作,比如put方法、contains方法等等,都会占据比较多的CPU时间。因此,我当时选择了多源最短路径的Floyd算法,进行了一次重构。 -
第三次作业
这次作业的情况非常糟糕。首先这次被大面积杀伤的原因在于当时使用的BFS+优先队列的方法是我从周围的同学了解到并相互讨论得到的方法,感觉从清晰度方面并不能解释地很清楚,因此出现了一些意料之外的情况,比如当到达一个节点后,我通过当前节点的邻接节点是否被标记过来判断是否应当跳过这个节点,但是完全存在这种情况,使得一个节点被标记后不能访问,但是有一条别的路径,如果可以访问这个节点,将能够得到更短的带权路径长度。因此我通过为每一个权重节点增加一个reject容器,里面保存对于这一个路径上的节点所不能访问的节点,然后每一条路径根据自身的情况判断,而不会对其他路径造成影响。
使用的反例如下:
PATH_ADD 1 2 PATH_ADD 1 3 2 4 LEAST_TRANSFER_COUNT 1 4
正确结果显然为0,但是因为上面所讲的那个错误,我在标记了2节点后,3节点无法访问2节点,因此统计得到的输出结果为1。
规格撰写与理解的心得体会
使用规格的目的是在于能够用一种相对于自然语言而言更加严谨、更加没有二义性的方式对一个方法或者类的功能进行描述,这样在多人进行协作开发,而且开发的项目对于正确性与准确性的要求比较严格的时候,更能够保证不会因为不同的成员对于方法或者类的功能理解缠身歧义,导致开发过程中出错。
在实际体会了这种模式之后,一方面确实看到,如果确实能够做到描述严谨,JML可以对规格进行非常客观的描述,这种描述并不是实现思路,而是提供对于功能与特性的理解。但是与此同时,我也感觉到对于我来说JML其本事还是很有难度的,对于一个相对比较复杂的功能,JML的表述会显得很长,而且有时为了简化表述,还需要借助一些本来并不一定需要实现的方法,并且为这些方法还要单独写规格。感觉这样不论是书写还是理解,都会产生一定的困难,特别是当读者的编程水平并不高的时候,有可能反而因为其复杂性造成理解的偏差。因此,我觉得在一些要求比较高的特定工作领域中,使用JML更能够进行规范,但是在一些领域,比如小团队进行普通的应用开发时,个人感觉使用比较详细的自然语言更能够节约开发成本,提高效率。
上一篇: 图的两种表示方法:邻接表与邻接矩阵详解
推荐阅读
-
面向对象编程:第三阶段知识点回顾与总结
-
F#探险之旅(二):函数式编程(上)-函数式编程范式简介 F#主要支持三种编程范式:函数式编程(Functional Programming,FP)、命令式编程(Imperative Programming)和面向对象(Object-Oriented,OO)的编程。回顾它们的历史,FP是最早的一种范式,第一种FP语言是IPL,产生于1955年,大约在Fortran一年之前。第二种FP语言是Lisp,产生于1958,早于Cobol一年。Fortan和Cobol都是命令式编程语言,它们在科学和商业领域的迅速成功使得命令式编程在30多年的时间里独领风骚。而产生于1970年代的面向对象编程则不断成熟,至今已是最流行的编程范式。有道是“*代有语言出,各领风骚数十年”。 尽管强大的FP语言(SML,Ocaml,Haskell及Clean等)和类FP语言(APL和Lisp是现实世界中最成功的两个)在1950年代就不断发展,FP仍停留在学院派的“象牙塔”里;而命令式编程和面向对象编程则分别凭着在商业领域和企业级应用的需要占据领先。今天,FP的潜力终被认识——它是用来解决更复杂的问题的(当然更简单的问题也不在话下)。 纯粹的FP将程序看作是接受参数并返回值的函数的集合,它不允许有副作用(side effect,即改变了状态),使用递归而不是循环进行迭代。FP中的函数很像数学中的函数,它们都不改变程序的状态。举个简单的例子,一旦将一个值赋给一个标识符,它就不会改变了,函数不改变参数的值,返回值是全新的值。 FP的数学基础使得它很是优雅,FP的程序看起来往往简洁、漂亮。但它无状态和递归的天性使得它在处理很多通用的编程任务时没有其它的编程范式来得方便。但对F#来说这不是问题,它的优势之一就是融合了多种编程范式,允许开发人员按照需要采用最好的范式。 关于FP的更多内容建议阅读一下这篇文章:Why Functional Programming Matters(中文版)。F#中的函数式编程 从现在开始,我将对F#中FP相关的主要语言结构逐一进行介绍。标识符(Identifier) 在F#中,我们通过标识符给值(value)取名字,这样就可以在后面的程序中引用它。通过关键字let定义标识符,如: let x = 42 这看起来像命令式编程语言中的赋值语句,两者有着关键的不同。在纯粹的FP中,一旦值赋给了标识符就不能改变了,这也是把它称为标识符而非变量(variable)的原因。另外,在某些条件下,我们可以重定义标识符;在F#的命令式编程范式下,在某些条件下标识符的值是可以修改的。 标识符也可用于引用函数,在F#中函数本质上也是值。也就是说,F#中没有真正的函数名和参数名的概念,它们都是标识符。定义函数的方式与定义值是类似的,只是会有额外的标识符表示参数: let add x y = x + y 这里共有三个标识符,add表示函数名,x和y表示它的参数。关键字和保留字关键字是指语言中一些标记,它们被编译器保留作特殊之用。在F#中,不能用作标识符或类型的名称(后面会讨论“定义类型”)。它们是: abstract and as asr assert begin class default delegate do donedowncast downto elif else end exception extern false finally forfun function if in inherit inline interface internal land lazy letlor lsr lxor match member mod module mutable namespace new nullof open or override private public rec return sig static structthen to true try type upcast use val void when while with yield 保留字是指当前还不是关键字,但被F#保留做将来之用。可以用它们来定义标识符或类型名称,但编译器会报告一个警告。如果你在意程序与未来版本编译器的兼容性,最好不要使用。它们是: atomic break checked component const constraint constructor continue eager event external fixed functor global include method mixinobject parallel process protected pure sealed trait virtual volatile 文字值(Literals) 文字值表示常数值,在构建计算代码块时很有用,F#提供了丰富的文字值集。与C#类似,这些文字值包括了常见的字符串、字符、布尔值、整型数、浮点数等,在此不再赘述,详细信息请查看F#手册。 与C#一样,F#中的字符串常量表示也有两种方式。一是常规字符串(regular string),其中可包含转义字符;二是逐字字符串(verbatim string),其中的(")被看作是常规的字符,而两个双引号作为双引号的转义表示。下面这个简单的例子演示了常见的文字常量表示: let message = "Hello World"r"n!" // 常规字符串let dir = @"C:"FS"FP" // 逐字字符串let bytes = "bytes"B // byte 数组let xA = 0xFFy // sbyte, 16进制表示let xB = 0o777un // unsigned native-sized integer,8进制表示let print x = printfn "%A" xlet main = print message; print dir; print bytes; print xA; print xB; main Printf函数通过F#的反射机制和.NET的ToString方法来解析“%A”模式,适用于任何类型的值,也可以通过F#中的print_any和print_to_string函数来完成类似的功能。值和函数(Values and Functions) 在F#中函数也是值,F#处理它们的语法也是类似的。 let n = 10let add a b = a + blet addFour = add 4let result = addFour n printfn "result = %i" result 可以看到定义值n和函数add的语法很类似,只不过add还有两个参数。对于add来说a + b的值自动作为其返回值,也就是说在F#中我们不需要显式地为函数定义返回值。对于函数addFour来说,它定义在add的基础上,它只向add传递了一个参数,这样对于不同的参数addFour将返回不同的值。考虑数学中的函数概念,F(x, y) = x + y,G(y) = F(4, y),实际上G(y) = 4 + y,G也是一个函数,它接收一个参数,这个地方是不是很类似?这种只向函数传递部分参数的特性称为函数的柯里化(curried function)。 当然对某些函数来说,传递部分参数是无意义的,此时需要强制提供所有参数,可是将参数括起来,将它们转换为元组(tuple)。下面的例子将不能编译通过: let sub(a, b) = a - blet subFour = sub 4 必须为sub提供两个参数,如sub(4, 5),这样就很像C#中的方法调用了。 对于这两种方式来说,前者具有更高的灵活性,一般可优先考虑。 如果函数的计算过程中需要定义一些中间值,我们应当将这些行进行缩进: let halfWay a b = let dif = b - a let mid = dif / 2 mid + a 需要注意的是,缩进时要用空格而不是Tab,如果你不想每次都按几次空格键,可以在VS中设置,将Tab字符自动转换为空格;虽然缩进的字符数没有限制,但一般建议用4个空格。而且此时一定要用在文件开头添加#light指令。作用域(Scope)作用域是编程语言中的一个重要的概念,它表示在何处可以访问(使用)一个标识符或类型。所有标识符,不管是函数还是值,其作用域都从其声明处开始,结束自其所处的代码块。对于一个处于最顶层的标识符而言,一旦为其赋值,它的值就不能修改或重定义了。标识符在定义之后才能使用,这意味着在定义过程中不能使用自身的值。 let defineMessage = let message = "Help me" print_endline message // error 对于在函数内部定义的标识符,一般而言,它们的作用域会到函数的结束处。 但可使用let关键字重定义它们,有时这会很有用,对于某些函数来说,计算过程涉及多个中间值,因为值是不可修改的,所以我们就需要定义多个标识符,这就要求我们去维护这些标识符的名称,其实是没必要的,这时可以使用重定义标识符。但这并不同于可以修改标识符的值。你甚至可以修改标识符的类型,但F#仍能确保类型安全。所谓类型安全,其基本意义是F#会避免对值的错误操作,比如我们不能像对待字符串那样对待整数。这个跟C#也是类似的。 let changeType = let x = 1 let x = "change me" let x = x + 1 print_string x 在本例的函数中,第一行和第二行都没问题,第三行就有问题了,在重定义x的时候,赋给它的值是x + 1,而x是字符串,与1相加在F#中是非法的。 另外,如果在嵌套函数中重定义标识符就更有趣了。 let printMessages = let message = "fun value" printfn "%s" message; let innerFun = let message = "inner fun value" printfn "%s" message innerFun printfn "%s" message printMessages 打印结果: fun value inner fun valuefun value 最后一次不是inner fun value,因为在innerFun仅仅将值重新绑定而不是赋值,其有效范围仅仅在innerFun内部。递归(Recursion)递归是编程中的一个极为重要的概念,它表示函数通过自身进行定义,亦即在定义处调用自身。在FP中常用于表达命令式编程的循环。很多人认为使用递归表示的算法要比循环更易理解。 使用rec关键字进行递归函数的定义。看下面的计算阶乘的函数: let rec factorial x = match x with | x when x < 0 -> failwith "value must be greater than or equal to 0" | 0 -> 1 | x -> x * factorial(x - 1) 这里使用了模式匹配(F#的一个很棒的特性),其C#版本为: public static long Factorial(int n) { if (n < 0) { throw new ArgumentOutOfRangeException("value must be greater than or equal to 0"); } if (n == 0) { return 1; } return n * Factorial (n - 1); } 递归在解决阶乘、Fibonacci数列这样的问题时尤为适合。但使用的时候要当心,可能会写出不能终止的递归。匿名函数(Anonymous Function) 定义函数的时候F#提供了第二种方式:使用关键字fun。有时我们没必要给函数起名,这种函数就是所谓的匿名函数,有时称为lambda函数,这也是C#3.0的一个新特性。比如有的函数仅仅作为一个参数传给另一个函数,通常就不需要起名。在后面的“列表”一节中你会看到这样的例子。除了fun,我们还可以使用function关键字定义匿名函数,它们的区别在于后者可以使用模式匹配(本文后面将做介绍)特性。看下面的例子: let x = (fun x y -> x + y) 1 2let x1 = (function x -> function y -> x + y) 1 2let x2 = (function (x, y) -> x + y) (1, 2) 我们可优先考虑fun,因为它更为紧凑,在F#类库中你能看到很多这样的例子。 注意:本文中的代码均在F# 1.9.4.17版本下编写,在F# CTP 1.9.6.0版本下可能不能通过编译。 F#系列随笔索引页面