第六章 组织、注释、引用代码(一)
编程语言的一个很重要部分就是按照逻辑单元组织代码的能力。为此,F# 提供了模块(modules)和命名空间(namespaces),更多内容,我们将在本章的“模块”、“命名空间”“引用命名空间和模块”中讨论。为了更好地理解 F# 的模块系统,除了在知道模块是如何初始化和如何执行的以外,更重要的要理解模块的作用域,这部分内容,我们通过“模块的作用域”、“模块的执行”两节来讨论。
为了使模块有效果,很重要地使模块部分私有,从而不能从外部看到。为此,F# 提供了两种途径,我们将在“签名文件”、“私有和内部的 let 绑定和成员”两节来讨论。
还有一个很重要的部分是注释代码,为将来的用户、维护人员,甚至自己作笔记,将在“注释”一节来讨论。
为了支持和 O’Caml 交叉编译以及其他的高级功能,需要乃至编译选项。为此,F# 也提供了两种方式:“交叉编译注释”是专为和 O’Caml 交叉编译而设计的;而其他更普通的在“编译选项”中讲解。
使用属性和数据结构去注释程序集,以及其中的类型值,也变得很普遍;其他的库函数或 CLR 能够解释这些属性。我们将在“属性”一节学习用属性标记函数和值的技术。把代码编译成数据结构的技术叫引用(quoting),将在本章的结尾“引用代码”一节讨论。
模块(Modules)
用模块组织 F# 代码,把值和类型分组到一个共同的名称下,这是一种最基本的方法;这种组织在标识符作用域内有效;在一个模块中,标识符可以彼此自由引用;为了引用模块之外的标识符,这个标识符必须是有模块名的全名,除非用 open 命令显式引用(open)这个模块(看这一章后面的“引用命名空间和模块”一节)。
默认情况下,每个模块包含中单独的源文件中;源文件的全部内容组成了这个模块,如果这个模块没有显式命名,那么源文件的名字,第一个字母大写就是它的名字(F# 是区分大小写的,因此,记住这一点很重要)。建议这样的匿名(anonymous)模块只能用在非常简单的程序中。
为了显式地命名模块,使用关键字 module。关键字有两种操作模式:一种是整个源文件中只使用同一个名字;另一种模式是源文件中每一节给一个名字,这样,在一个源文件中就可以出现几个模块。
为了使整个源文件中的内容使用同一个名字,显式命名模块,必须把关键字 module 放在源文件的最前面。模块名可以包含点,把名字分成不同的部分。例如:
module Strangelights.Beginning.ModuleDemo
在同一个源文件中,可以定义嵌套模块,嵌套的模块名不能包含点。嵌套的模块名之后 ,加等号,加缩进的模块定义;也可以用关键字 begin 和 end。为了包装模块定义,可以嵌套子模块。下面的代码定义了三个子模块,FirstModule、 SecondModule 和 ThirdModule, ThirdModule 嵌套在 SecondModule 中:
// create a top level module
module ModuleDemo
// create a first module
module FirstModule =
letn = 1
// create a second module
module SecondModule =
letn = 2
//create a third module
//nested inside the second
moduleThirdModule =
letn = 3
注意
在 F# 交互模式中,不能使用没有等号的module 关键字;当使用没有等号的 module 关键字时,它影响整个源文件,而 F# 交互模式没有源文件的概念,输入的所有代码被看作好像就是在同一个源文件中。这样,当在 F# 交互模式中使用没有等号的 module 关键字时,就会出错;但是,仍可以在 F# 交互模式中使用有等号的 module 来创建子模块。
注意,不同的子模块可以包含相同的标识符,而不会有一点问题;模块只影响标识符的作用域。为了在模块之外访问其中的标识符,需要用有模块名的全名,因此,在不同模块中的标识符之间不会混淆。在前面的例子中,三个模块中都定义了标识符 n;下面的例子演示如何访问每个模块中的标识符 n:
// unpack the values defined in each module
let x = ModuleDemo.FirstModule.n
let y = ModuleDemo.SecondModule.n
let z =ModuleDemo.SecondModule.ThirdModule.n
这一段代码会编译成一个 .NET 类,连同值一起,成为这个类中的方法和字段。在第十四章,会有更多有关 F# 的模块与其他 .NET 编程语言相比较的细节。
命名空间(Namespaces)
命名空间有助于分层组织代码。为了保持模块名跨程序集唯一,模块名用命名空间名限定,命名空间名是用点分隔的几部分字符串组成。例如,F# 提供一个模块,名叫 List,.NETBCL提供一个类,名字也叫 List;但是,这并没有命名冲突,因为,F# 模块在命名空间 Microsoft.FSharp 下,而BCL 类在命名空间 System.Collections.Generic 下。命名空间使编译的代码保持独立,因此,不允许在 F# 交互中使用,因为,这毫无意义。
保持命名空间的名字唯一什么重要。最流行的约定是,命名空间的名字以公司或组织的名字开头,加表示功能的专门名字。虽然不强求一定要这样做,但是这种约定已被广泛采用,如果打算发布代码,特别是以类库的形式发布,最好还是应该遵守。
注意
很有趣,在 F# 的中间语言(IL)层次上,并没有实际的命名空间的概念,类或模块的名字与长的标识符,可能包含点,也可能不包含点并没有什么不同。命名空间是在编译器层次上实现的;当使用 open 指令,就告诉了编译器要做一些额外的工作,用给定的名字去限定所有的标识符。如果它需要,那就要去看这个结果是否和值或类型相匹配。
最简单的情况,可以把模块放在命名空间中,用带点的模块名;模块与命名空间的名字将相同。也可以显式地为模块定义命名空间,用 namespace 指令。例如,下面的定义:
module Strangelights.Beginning.ModuleDemo
下面的定义与前面的代码结果相同:
namespace Strangelights.Beginning
module ModuleDemo
对于模块来说,这可能没有什么用处,但是,正如前一节所讲,子模块的名字不能包含点,因此,用 namespace指令把子模块放在命名空间中。例如:
// put the file in a name space
namespace Strangelights.Beginning
// create a first module
module FirstModule =
letn = 1
// create a second module
module SecondModule =
letn = 2
//create a third module
//nested inside the second
moduleThirdModule =
letn = 3
代码编译以后,从外部来看,n 的第一个实例要用标识符 Strangelights.Foundation.FirstModule.n 来访问,而不能仅仅是 FirstModule.n。把几个命名空间的声明放在同一个源文件中,也是可以的,但是,声明必须放在最前面。这就是说,前面示例中声明的FirstModule 和 SecondModule 也可以放在单独的命名空间中;但是,不能单独的命名空间中声明SecondModule 和 ThirdModule,因为,ThirdModule 是嵌套在 SecondModule 中的,不能为 ThirdModule 声明单独的命名空间。
不用 module 指令定义命名空间,也是可以的,但是,以后这个命名空间只能包含类型定义。例如:
// a namespace definition
namespace Strangelights.Beginning
// a record defintion
type MyRecord = { Field: string }
下面的例子不能通过编译,因为,不能直接把值定义放在命名空间,而没有在命名空间中显式定义模块或子模块:
// a namespace definition
namespace Strangelights.Beginning
// a value defintion, which is illegal
// directly inside a namespace
let value = "val"
事实上,namespace 指示有些有趣而微妙的效果,使代码看起来像其他语言。我们会在第十三章再深入讨论。
引用命名空间和模块
正如我们在前两节中所见到的,如果值或类型不是在当前模块中定义的,就必须用全名(限定名qualifiedname),这会很乏味,因为有些全名可能会很长。幸运的是,F# 提供了 open 指令,可来简化值或类型的名字的使用。
使用关键字 open,加想要引用的命名空间或模块的名字。例如,看下面的代码:
System.Console.WriteLine("Helloworld")
替换成下面的代码:
open System
Console.WriteLine("Hello world")
注意,你不需要指定整个命名空间的名字,可以指定命名空间的前面部分,然后,用余下的部分限定简称。例如,可以指定 System.Collections,而不是System.Collections.Generic,然后,用 Generic.List 去创建泛型 List 类的实例。就像下面的代码:
open System.Collections
// create an instance of a dictionary
let wordCountDict =
newGeneric.Dictionary<string, int>()
警告
使用部分限定名,比如 Generic.Dictionary,可能会使程序难于维护。因此,应该这样使用,要么是名字加完整的命名空间,要么只有名字。
可以引用 F# 的模块,但不能引用非 F# 库函数中的类。如果引用模块,就可以用简称引用其中的值和类型。例如,下面的示例引用两个模块:Microsoft.FSharp.Math.PhysicalConstants 和Microsoft.FSharp.Math.SI,这两个都在 FSharp.PowerPack 中;这两个模块分别包含标准的常量和度量单位。我们使用常量c,光的速度,实现爱因斯坦的著名等式 e = mc2:
openMicrosoft.FSharp.Math.PhysicalConstants
open Microsoft.FSharp.Math.SI
// mass
let m = 1.<kg>
// energy
let e = m * (c * c)
有些认为,应该谨慎使用直接引用模块,因为这样会很难找到标识符的源头。注意,F# 库函数的许多模块不能直接引用。事实上,模块通常可以分为两类:一部分是使用全称访问的,另一部分是直接引用的;大多数模块是使用全称访问的,很少直接引用的。通常的原因是,直接引用模块,会使你直接使用其中的运算符。下面的例子自定义了一个模块,包含了三个等号的运算符,然后,引用这个模块,使用这个运算符:
// module of operators
module MyOps =
//check equality via hash code
let(===) x y =
x.GetHashCode()=
y.GetHashCode()
// open the MyOps module
open MyOps
// use the triple equal operator
let equal = 1 === 1
let nEqual = 1 === 2
如果引用的两个命名空间,包含的模块或类名字相同,却不会引起编译错误,甚至可以使用同名模块或类中的值,只要值的名字不同就行。在图 6-1 中,可以看到引用了命名空间 System,它包含类 Array,在 F# 的库函数中也有一个模块 Array。在图中,既可以看到来自 BCL 的 Array 类的静态方法,都是以大写字母开头,也可以看到 F# 的 Array 模块,它是以小写字母开头的:
图 6-1 Visual Studio 的智能感知
模块的别名(Aliases)
有时,给命名空间、模块一个别名,对于避免冲突也可能是有用的。当两个模块有相同的名字,值用一个共同的名字,把部分代码切换到去使用相似模块的两个不同实现,也是方便的方法。对于在 F# 中创建的模块,只需要给这个模块一个别名。
模块别名的语法,关键字 module,加标识符,加等号,加想要应用别名的命名空间或模块的名字。下面的例子为命名空间Microsoft.FSharp.Collections.Array3 定义了别名 ArrayThreeD:
// give an alias to the Array3 module
module ArrayThreeD =Microsoft.FSharp.Collections.Array3D
// create an matrix using the module alias
let matrix =
ArrayThreeD.create3 3 3 1
签名文件(Signature Files)
签名文件是使函数和值定义对模块来说是私有(private)的。在第二章中,已经看到过签名文件定义的语法。签名文件可以使用编译器的-i 开关创建。任何出现在签名文件中的定义都公开(public)的,使用模块的任何人都能访问;没有出现在签名文件中的定义都私有的,只能在模块内部使用。创建签名文件的典型方法是从模块的源文件中产生,然后,再查找并删除打算私有的值和函数。
签名文件的名字必须与对应的模块同名,扩展名必须是 .fsi 或 .mli;还必须为编译器指定签名文件。在命令行,必须把它直接放在模块的源文件前面;在Visual Studio 中,签名文件必须放在解决方案资源管理器(Solution Explorer)中的源文件前面。
下面的例子,假设代码在 Lib.fs 中:
// define a function to be exposed
let funkyFunction x =
x +": keep it funky!"
// define a function that will be hidden
let notSoFunkyFunction x = x + 1
现在,假设想创建的库只想暴露 funkyFunction,而不想暴露 notSoFunkyFunction,应该使用下面的签名代码:
// expose a function
val funkyFunction: string -> string
接下来,在命令行上执行:
fsc -a Lib.fsi Lib.fs
结果,程序集名为 Lib.dll,类名为Lib,公开函数名为 funkyFunction,私有函数名为notSoFunkyFunction。
私有和内部(Private and Internal)的 let 绑定和成员
F# 还有另一种方法控制值或类型的可见性,即,把关键字 private 或 internal 直接放在 let 绑定的后面,使绑定成为私有或内部。比如:
let private aPrivateBinding = "Keepthis private"
let internal aInternalBinding = "Keepthis internal"
关键字 private 使值或类型只在当前模块中可见,关键字 internal使值或类型只在当前程序集中可见。关键字 private 和internal 的意思大体上与 C# 中的相同。关键字 internal 尤其有用,它可以使类型在程序集内部的各模块之间共享,而在程序集的外边,专门对 F# 是不可见的。下面的示例使用关键字 internal 隐藏联合类型:
// This type will not visible outside thecurrent assembly
type internal MyUnion =
|String of string
|TwoStrings of string * string
当使用面向对象风格编程时,会经常看到这种风格的编程。还可以使用关键字 private 和 internal 隐藏对象的成员,只需要简单地把关键字 private 和 internal 直接放在关键字 member 的后面就行了。比如:
namespace Strangelight.Beginning
type Thing() =
memberprivate x.PrivateThing() =
()
memberx.ExternalThing() =
()
[
这里出来的有点突兀。
]
关键字 private 和 internal 对于接口文件(interface files),也提供了相似的功能,因此,在实际编程可能应该考虑使用哪一个。答案并不清晰:接口文件是对模块的概览,这是保存接口文档的好地方,也可有助于避免由于额外的关键字而使造成源代码的凌乱;然而,接口文件也带来一点烦恼,因为每一个外部定义都会有一定的重复,这样,当重构代码时,必须在两个地方更新定义。对于两次更新的问题,我通常宁可使用关键字private 和 internal;不过,我认为,两种选择都是正确的,而使整个项目保持一致才更为重要。
模块的作用域(Scope)
模块传递到编译器的顺序很重要,因为它影响模块中标识符的作用域,和模块的执行顺序。
我们在这一节讨论作用域,下一节讨论执行顺序。
一个模块中的值和类型在其他模块中是看不到的,除非在命令行中,它要出现在引用它的模块之前,通过例子可能更容易理解。假设有一个源文件 ModuleOne.fs,代码如下:
module ModuleOne
// some text to be used by another module
let text = "some text"
假设有另一个模块 ModuleTwo.fs:
module ModuleTwo
// print out the text defined in ModuleOne
printfn "ModuleOne.text: %s"ModuleOne.text
这两个模块用下面的命令可以编译成功:
fsc ModuleOne.fs ModuleTwo.fs -oModuleScope.exe
但是,用下面的命令编译,就会出错:
fsc ModuleTwo.fs ModuleOne.fs -oModuleScope.exe
出错信息:
ModuleTwo.fs(3,17): error: FS0039: Thenamespace or module 'ModuleOne' is not
defined.
之所以出错,是因为在 ModuleTwo 的定义中用到了 ModuleOne,因此,在命令行中,ModuleOne 必须出现在 ModuleTwo 的前面,否则,ModuleOne 就不会在 ModuleTwo 的作用域内。
Visual Studio 用户应该注意,在解决方案资源管理器(Solution Explorer)中文件出现的顺序,就是它们传递给编译器的顺序。就是说,当向项目中添加新文件时,需要花一点时间,去重新整理文件的顺序。在Visual Studio 中,可以使用解决方案资源管理器的快捷菜单改变文件的顺序(如图 6-2)。
图 6-2 Visual Studio 的文件重排快捷菜单
模块的执行
大体来说,F# 中的执行,是从模块的前面开始,向下执行直至结束。所有非函数的值都会计算,在顶层的所有语句或所有顶层的 do 语句都会执行。看下面的代码:
module ModuleOne
// statements at the top-level
printfn "This is the first line"
printfn "This is the second"
// a value defined at the top-level
let file =
lettemp = new System.IO.FileInfo("test.txt") in
printfn"File exists: %b" temp.Exists
temp
前面代码的执行结果:
This is the first line
This is the second
File exists: false
这就是你所期望的。当源文件被编译成程序集,其中的所有代码直到它当中的值当前执行的函数使用到,才会执行。这样,当文件中的第一个值被涉及时,模块中的所有的 let 表达式和 do 语句就会以词典顺序执行。当一个程序被分成多个模块时,最后被传递给编译器的模块有点特别。这个模块中的所有项都将执行,而其他的项的行为就如同在一个程序集中一样[ 逻辑上有点问题。模块中除了所有项以外,还能有其他项吗? ];其他模块中的项只有当这个模块中的值被当前执行的模块用到时才执行。假设创建了有两个模块的程序。
下面的代码在 ModuleOne.fs 中:
module ModuleOne
// then this one should be printed
printfn "This is the third andfinal"
下面的代码在 ModuleTwo.fs 中:
module ModuleTwo
// these two lines should be printed first
printfn "This is the first line"
printfn "This is the second"
假设用下面的命令编译前面的代码:
fsc ModuleOne.fs ModuleTwo.fs -oModuleExecution.exe
下面是执行结果:
This is the first line
This is the second
这可能出乎你的想像,但是,记住,这很重要,因为 ModuleOne 不是最后传递给编译器的模块,因此,它当中的所有项,只有当它中的值被当前执行的函数用到时,才会执行。在这里,ModuleOne 中的所有值都未使用,因此,它不会执行。考虑到这一点,把程序改一下,使其行为如你所想。
改过之后的 ModuleOne.fs:
module ModuleOne
// this will be printed when the module
// member n is first accessed
printfn "This is the third andfinal"
let n = 1
这是改过之后的 ModuleTwo.fs:
module ModuleTwo
// these two lines should be printed first
printfn "This is the first line"
printfn "This is the second"
// function to access ModuleOne
let funct() =
printfn"%i" ModuleOne.n
funct()
下面是编译命令:
fsc ModuleOne.fs ModuleTwo.fs -oModuleExecution.exe
结果如下:
This is the first line
This is the second
This is the third and final
1
然而,使用这种方法来获得所要的结果是不可取的。通常最好的做法是,只使用在最后传递给编译器的模块中的顶层语句。事实上,一个典型的 F# 程序,在最后传递给编译器的模块中的最后,要有一条顶层语句[ 不知道对不对?]。