2.3 使用命名空间、模块和包

Julia生态系统建立在命名空间中,这实际上是我们保持代码有序的唯一方法。为什么这么说呢?原因是命名空间用于在逻辑上分隔源代码的片段,以便可以独立开发它们而不会互相影响。在一个命名空间中定义一个函数后,仍然可以在另一个命名空间中定义另一个相同名称的函数。

在Julia中,命名空间是用模块和子模块创建的。为了管理分派和依赖关系,通常将模块组织为包。Julia包有一个标准的目录结构。尽管顶层目录结构定义明确,但是开发人员在组织源文件方面仍然有很大的自由度。

在本节中,我们将探讨以下主题:

·理解和使用命名空间

·如何创建模块和包

·如何创建子模块

·如何在模块中管理文件

让我们在以下各节中详细了解它们。

2.3.1 理解命名空间

什么是命名空间?让我们来看一个现实的例子。

每种语言都有在其字典中定义的单词。当来自不同文化背景的人互相交谈时,他们常常会发生有趣的情形。看下面的例子:

对话1:

·美国人:你的裤子(pants)脏了,你应该换一下。

·英国人:你是说的我的裤子(trousers)吗?我的内裤(underpant)很干净的!

对话2:

·美国人:这些饼干(biscuit)很好吃!

·英国人:哪里有饼干(cookie)?

对话3:

·美国人:我想瘦回去,尝试了很多健身教练(trainer),没有一个起到作用的。

·英国人:你试了耐克的新款跑鞋吗?我觉得每天穿着跑步很舒服。

有些时候不同上下文,同一单词已经具有不同的含义。比如:

·pool——游泳池还是一堆事情?

·squash——蔬菜还是运动?

·current——电流还是水流?

由于存在此类歧义,我们无法跨所有领域强制使用单个词汇表。幸运的是,聪明的计算机科学很早以前就解决了与其所属领域有关的问题:要区分单个单词的两种不同含义,我们只需在单词前加上相应的上下文即可。用上面的例子,我们可以限定每个单词的意思如下:

·Facility.Pool和Grouping.Pool

·Vegetable.Squash和Sport.Squash

·Electricity.Current和Liquid.Current

这个前缀就是命名空间。现在,单词已使用其各自的命名空间进行了限定,它们具有明确的含义而不再产生歧义。

在Julia中,命名空间是使用模块来实现的,我们将在2.3.2节中学习有关模块。

2.3.2 创建模块和包

模块用于创建新的命名空间。在Julia中,创建模块就像将代码包装在模块中一样简单,例如:

创建模块通常是为了共享和重用,实现此目的的最佳方法是在Julia的包中组织代码。Julia包是用于维护模块定义、测试脚本、文档和相关数据的目录和文件结构。

Julia包有一个标准的目录结构和约定。但是,每次在新起程序时都手动配置相同的结构会很麻烦。幸运的是有一些开源工具可以自动为新包创建结构。在没有正式认可任何特定工具的情况下,我选择了PkgTemplates包进行演示,如下所示。

如果你之前未安装过PkgTemplates包,则可以按以下方式安装。

安装完成后,我们可以使用它来创建示例模块。第一步是创建一个Template对象,如下所示。

基本上template对象包含一些默认值,这些默认值将用于创建新包。然后,创建新包就像调用generate函数一样容易。

默认情况下,包生成器在?/.julia/dev目录中创建新目录,但可以使用Template对象的dir关键字参数对其进行自定义。

使用generate命令创建一个名为Calculator的新包。它会自动创建具有以下包结构的目录。

这时候你就可以开始编辑Calculator.jl文件了,并将文件内容替换为你自己的源代码。

如果你不熟悉Julia,请先下载Revise包,它可让你编辑源代码并自动更新工作环境。使用Julia会将你的工作效率提高10倍。

让我们通过实现一些财务计算来使用Calculator模块。在本示例的过程中,我们将学习如何从外部客户端管理变量和函数的可访问性。我们的初始代码设置如下:

这些代码都保存在Calculator.jl文件中。

定义函数行为

Calculator模块定义2个函数:

·interest函数用于计算整个投资期间内具有指定利率(rate)的存款金额(amount)的利息。

·rate函数用于计算利率,你可以针对该利率投资存款金额(amount),并获得利息金额(interest)。

请记住,利息(interest Interest在英文中既有兴趣的意思,还有利息的意思。——译者注)和利率(rate Rate在英文中有比例,汇率的意思。——译者注)在Calculator上下文之外有着完全不同的含义。

函数暴露

函数定义在模块内部而不会暴露给外界。如果要公开它们,可以使用export语句输出interest和rate函数,以便该模块的用户可以轻松地将它们带入自己的命名空间:

函数一旦被暴露,使用using关键字加载模块的客户端便可应用这些函数。在加载模块之前,让我们尝试从Julia REPL引用这些函数。

出错是由于我们尚未加载Calculator包,因此没有定义interest函数或rate函数。现在把它们引进来。

执行using语句时,从模块暴露的所有内容都将带入当前命名空间。在Julia REPL中,当前模块称为Main,如图2-1所示。

图 2-1

通过使用特定名称限定using语句,我们可以引入命名空间的子集。让我们重新启动Julia REPL,然后重试。

在这种情况下,只有interest函数被带入Main模块(如图2-2所示)。

图 2-2

实际上,有几种方法可以将名称从另一个模块导入当前的命名空间。为了简单起见,我们可以将它们进行总结,如表2-1所示。

表 2-1

如上表所见,有4种方法(上表的1、2、4、5)将interest函数引入当前命名空间。在using和import语句之间进行选择有一些微妙之处。比较好的经验是在使用函数时使用using语句,而在需要从模块扩展函数时选择import语句。从另一个包扩展函数是Julia的主要语言功能,你将从本书的各个示例中了解更多有关该功能的信息。

解决冲突

情况并不总是乐观的。假设主程序需要使用另一个名为Rater的模块,该模块为在线图书提供评级服务。在这种情况下,主程序可能会尝试从两个模块中获取函数,如图2-3所示。

图 2-3

但是出问题了!rate函数是从Calculator模块引入的,但这恰好与Rater模块的另一个函数发生冲突。Julia首次使用时会自动检测到此冲突,并打印警告,然后要求开发人员使用其完全限定的名称来访问任一函数。

如果你不喜欢这样,特别是那个丑陋的警告,你可以选择另一种方法。你可以先问自己,主程序中是否需要两个rate函数。如果仅需要一个rate函数,则只需将其纳入范围内,这样就不再存在冲突:

根据我的经验,将特定名称引入当前命名空间确实是大多数用例的最佳选择。这样做的原因是,你所依赖的函数可以立刻看见。这种依赖关系也在代码中自行记录。

有时你可能需要同时使用两个rate函数。在这种情况下,可以使用常规的import语句解决问题:

这样它就只会加载包,而不会在当前命名空间中使用任何名称。你现在可以使用两个完全合格的名称来引用这两个rate函数,即Calculator.rate和Rater.rate。创建完这些模块后,让我们继续来看如何创建子模块。

[1] Interest在英文中既有兴趣的意思,还有利息的意思。——译者注

[2] Rate在英文中有比例,汇率的意思。——译者注

2.3.3 创建子模块

当模块变得太大时,将其拆分成较小的模块会更好,这便于开发和维护。解决此问题的方法是创建子模块。创建子模块很方便,因为它们只是在父模块的范围内定义的。假设我们将Calculator模块组织为两个子模块,即Mortgage和Banking。这两个子模块可以在单独的文件中定义,也可以直接包含在父模块中。请看以下代码:

与常规模块一样,子模块也使用模块的块定义。Mortgage的源代码看起来就是一个常规模块的定义:

由于Mortgage的源码包含在Calculator模块内,因此它形成了嵌套结构。除了必须通过父模块引用它们之外,子模块的用法与任何常规模块的用法相同。在这种情况下,你需要使用Calculator.Mortgage或Calculator.Banking进行引用。

使用子模块是大型代码库分离代码的有效方法。接下来我们将讨论如何在模块中管理源代码。

2.3.4 在模块中管理文件

模块的源代码通常组织为多个源文件。尽管对于源文件的组织方式没有严格的规定,但以下是有用的准则:

·耦合:高度耦合的函数应放在同一文件中。这样做可以减少编辑源文件时的上下文切换。例如当你更改函数的签名时,该函数的所有调用者可能都需要更新。像最小化爆炸半径一样,理想情况下做到最少的文件改动。

·文件大小:一个文件中包含几百行代码可能是一个警告信号。如果文件中的代码都紧密耦合,那么最好重新设计系统以减少耦合。

·顺序:Julia会按照包含它们的顺序加载源文件。由于数据类型和工具函数通常是共享的,因此最好将它们分别保存在type.jl和utils.jl文件中,并将其包含在模块的开头。

组织测试脚本时也同样应使用上面的方法。

到目前为止,我们已经学习了如何使用模块和子模块创建新的命名空间。更方便的是,将模块组织在一个包中,以便可以从应用程序中重用它。一旦创建多个包,不可避免的是它们可能必须相互依赖。重要的是我们知道如何正确处理这些依赖关系。这将是2.4节的主要主题。