Julia设计模式
上QQ阅读APP看书,第一时间看更新

2.5 设计抽象类型和具体类型

Julia的类型系统是其许多语言功能(例如多重分派)的基础。在本节中,我们将学习抽象类型和具体类型,如何设计和使用它们,以及它们是否与其他主流的面向对象的编程语言不同。

在本节中,我们将介绍以下主题:

·设计抽象类型

·设计具体类型

·理解isA和<:运算符

·理解抽象类型和具体类型之间的区别

首先让我们看一下抽象类型。

2.5.1 设计抽象类型

与许多其他面向对象的编程语言类似,Julia支持抽象类型的层次结构。抽象类型通常用于对现实世界的数据概念进行建模。例如,“动物”可以是猫或狗的抽象类型,“车辆”可以是汽车、卡车或公共汽车的抽象类型。将类型分组并给组命名一个名称,这样让Julia开发人员可以将通用代码应用到那些类型上。

在特定领域的类型层次结构中可以方便地定义抽象类型。我们可以将抽象类型之间的关系描述为父子关系,或更严格地说,是超类型–子类型(is-a-subtype-of)关系。父类型和子类型的术语分别是超类型和子类型。

Julia与大多数其他语言不同的独特之处设计在于定义的抽象类型没有任何字段。由于这个原因,抽象类型没有指定实际如何将数据存储在内存中。乍一看似乎有些限制,但是随着我们对Julia的了解越来越多,在本设计中使用它时似乎更加自然。结果,抽象类型仅用于为一组对象的行为建模,而不用于指定数据的存储方式。

矩形和正方形对象模型是当允许抽象类型定义数据字段时事物如何分解的经典示例。假设我们能够定义一个带有Width和height字段的矩形。正方形是矩形的一种,因此从直观上讲,我们应该能够将正方形建模为矩形的子类型。但是我们很快就会遇到麻烦,因为一个正方形不需要两个字段来存储其边长。我们应该改用side length字段。因此,在这种情况下,从超类型继承字段是没有意义的。我们将在第12章中更详细地讨论这种情况。

在以下各节中,我们将通过示例构建一个抽象类型层次结构。

个人资产类型层次结构示例

假设我们正在构建一个追踪用户财富的金融应用程序,其中可能包括各种资产。下图显示了抽象类型及其父子关系的层次结构。在这种设计中,资产(Asset)可以是财产(Property)、投资(Investment)或是现金(Cash)类型。财产可以是房屋(House)或公寓(Apartment)。投资可以是固定收益(FixedIncome)或股权(Equity)。按照惯例,为了表明它们是抽象类型而不是具体类型,我们选择在框中用斜体表示它们的名称,如图2-6所示。

图 2-6

要创建抽象类型层次结构,我们可以使用以下代码:

<:符号表示超类型–子类型关系。因此,Property类型是Asset的子类型,Equity类型是Investment的子类型,依此类推。

事实上,Asset抽象类型似乎是层次结构的顶层,但它也有一个称为Any的超类型,当未指定任何超类型并且定义了抽象类型时,它就是隐式的。Any是Julia中的顶级超类型。

类型层次结构导航

Julia提供了一些方便的函数来导航类型层次结构。要查找现有类型的子类型,我们可以使用subtypes函数。

同样,要查找现有类型的超类型,我们可以使用supertype函数。

以树形格式查看完整的层次结构有时很方便。Julia没有提供可用于实现此目的的标准函数,但是我们可以使用递归技术轻松地自己创建一个,如下所示:

对于新的Julia用户此函数可能非常方便。我实际上已将代码保存在startup.jl文件中,以便将其自动加载到REPL中。

startup.jl文件是用户自定义脚本,位于$HOME/.julia/config目录中。它可以用于存储每次REPL启动时用户要运行的任何代码或函数。

我们现在可以轻松显示个人资产类型层次结构,如下所示。

注意此函数只能显示已经加载到内存中的类型的层次结构。现在我们已经定义了抽象类型,我们应该能够将函数与它们相关联。接下来,让我们开始将它们关联。

定义抽象类型的函数

到目前为止,我们所做的只是创建相关概念的层次结构。凭借有限的知识,我们仍然可以定义一些函数来对行为建模。但是,当我们没有具体的数据元素时,这有什么用呢?在处理抽象类型时,我们可以只关注特定的行为以及它们之间可能的交互。让我们继续这个例子,看看可以添加哪些函数。

描述函数

尽管听起来不太有趣,但是我们可以定义仅基于类型本身的函数:

现在,如果我们曾经使用具有超类型Property的数据元素调用describe,则相应地将调用Property的描述方法。由于我们没有使用Cash类型定义任何描述函数,因此当使用Cash数据元素调用描述时,它将从更高级别的类型Asset返回描述。

由于我们尚未定义任何具体类型,因此我们无法证明Cash对象的describe函数将使用describe(a::Asset)方法。这是一件简单的事情,所以我鼓励读者阅读本章后作为练习来做。

函数行为

具有层次结构的原因是为了创建有关类型的常见行为的抽象。例如,Apartment和House类型具有相同的超类型Property。有意这么继承是因为它们都代表特定位置的某种物理住所。因此我们可以为任何Property定义一个函数,如下所示:

你可能会问,我们做了什么?我们刚刚实现了一个只返回错误的函数!定义此函数实际上有几个目的:

·Property的任何具体子类型都必须实现location函数

·如果没有为相应的具体类型定义location函数,则运行时将调用此特定函数并抛出合理错误,以便开发人员可以修改这个bug。

·函数定义上方的文档字符串包含有用的描述,即Property的具体子类型实现。

另外,我们可以定义一个空函数:

那么空函数和引发错误的函数有什么区别呢?对于此空函数,如果具体类型不实现该函数,则不会出现运行时错误。

对象之间的互作用

定义抽象类型之间的交互也很有用。现在我们知道每个Property都应该有一个位置,我们可以定义一个函数来计算两个财产之间的步行距离,如下所示:

逻辑完全存在于抽象类型中!我们甚至都没有定义任何具体类型,但是我们能够开发通用代码,该代码适用于以后的Property的任何具体子类型。

Julia语言的强大功能使我们可以在抽象级别上定义这些行为。让我们想象一下如果不允许我们在此级别定义函数并且只能实现具有特定具体类型的逻辑,那该怎么办?这种情况下,我们必须为不同类型的财产的每种组合定义一个单独的walking_distance函数。这对于开发人员来说简直太无聊了!

现在我们了解了抽象类型是如何工作的,让我们继续前进,看看如何在Julia中创建具体类型。

2.5.2 设计具体类型

具体类型用于定义数据的组织方式。在Julia中,有两种具体类型:

·原始类型

·复合类型

带有位数的基本类型。Julia的Base包带有多种原始类型——有符号/无符号整数,它们的长度分别为8位、16位、32位、64位或128位。目前Julia仅支持原始类型,其原始类型的位数是8的倍数。例如,如果我们的用例需要非常大的整数,则可以定义256位整数类型(32字节)。如何做到这一点超出了本书的范围。如果你觉得这是一个有趣的项目,则可以在GitHub上查阅Julia的源代码,并了解如何实现现有的原始类型。实际上,Julia语言很大程度上是用Julia本身编写的!

复合类型由一组命名字段定义。将字段分组为单一类型可简化推理、共享和操作。复合类型可以指定为特定的超类型,也可以默认为Any。如果需要,还可以使用字段自己的类型来注释字段,并且类型可以是抽象的也可以是具体的。如果缺少字段的类型信息,则它们默认为Any,这意味着该字段可以容纳任何类型的对象。

在本节中,我们将重点介绍复合类型。

设计复合类型

复合类型使用struct关键字定义。我们继续前面抽象类型部分中的示例,并构建我们的个人资产类型层次结构。

现在,我们将创建一个称为Stock的具体类型作为Equity的子类型。为简单起见,我们只用交易代码(symbol)和公司名称(name)来表示股票:

我们可以使用标准构造函数实例化复合类型,该构造函数将所有字段用作参数。

由于Stock是Equity的子类型,而Equity是Investment的子类型,从而Stock又是Investment的子类型,同样也是Asset的子类型,因此,我们应该通过定义describe函数来遵守我们之前的契约:

describe函数仅以字符串形式返回包含交易代码和股票公司名称的股票。

不可变性

复合类型默认情况下是不可变的。这意味着在创建对象后,它们的字段不可更改。不可变性是一件好事,因为它消除了由于数据修改而导致系统行为意外更改时的意外情况。我们可以轻松证明在上一节中创建的具体Stock类型是不可变的。

不可变性实际上最多就保证到字段级别。如果某个类型包含一个字段并且该字段自己的类型是可变的,则允许更改基础数据。让我们尝试一个不同的示例,方法是创建一个称为BasketOfStocks的新复合类型,该复合类型用于保存股票的数组(即一维数组)以及持有股票的原因:

让我们创建一个用于测试的对象。

我们知道BasketOfStocks是不可变的类型,因此我们无法更改其中的任何字段。但是让我们看看是否可以从stocks字段中删除其中一个股票。

我们调用pop!函数直接作用于stock对象,它会高兴地带走一半我赠送给我妻子的礼物!让我再重复一遍——不可变性对基础字段是没有任何影响的。

这种行为就是这么设计的。开发人员在对不可变性做出任何假设时都应该保持谨慎。

可变性

在某些情况下,我们实际上可能希望对象是可变的。只需在类型定义前面添加mutable关键字即可轻松移除不可变性的约束。为了使Stock类型可变,我们执行以下操作:

现在假设苹果公司更改其公司名称,尝试更新name字段。

name字段已根据需要进行了更新。注意,当一个类型被声明为可变时,其所有字段都变为可变的。所以在这种情况下,我们也可以更改交易代码。这种行为可能是理想的,也可能不是理想的,需要分情况来看。在第8章中,我们将介绍一些可用于构建更具鲁棒性解决方案的设计模式。

可变还是不可变

可变对象看起来更加灵活,并为我们提供良好的性能。如果它这么好,那么为什么我们不希望默认情况下所有内容都是可变的呢?主要有以下两个原因:

·不可变的对象更易于处理。由于对象中的数据是固定的,并且永远不变,因此在这些对象上运行的函数将始终返回一致的结果。这是一个非常不错的优点,不会给你带来“意外惊喜”。而且如果我们构建了一个缓存此类对象计算结果的函数,则缓存将始终保持良好状态并返回一致的结果。

·可变对象在多线程应用程序中更难以使用。假设某个函数正在从可变对象中读取内容,但是该对象的内容是由另一个函数从不同线程中修改的。然后,当前函数可能会产生错误的结果。为了确保一致性,开发人员必须使用锁技术将读/写操作同步到对象。这种并发的处理会使代码更加复杂且难以测试。

还有一方面,可变性对于高性能用例可能有用,因为内存分配是一个相对昂贵的操作。我们可以通过重复使用分配的内存来减少系统开销。所以考虑所有因素之后,不可变对象通常是更好的选择。

使用Union类型以支持多种类型

我们有时需要在一个字段中支持多种类型,这种情况可以使用Union类型来完成。Union类型定义为可以接受任何指定类型的类型。要定义Union类型,我们只需将Union关键字后的花括号括起来即可。例如,可以如下定义Int64和BigInt的Union类型:

当你需要合并来自不同数据类型层次结构的数据类型时,Union类型非常有用。让我们进一步扩展我们的个人资产例子。假设我们需要将一些奇特的项目合并到我们的数据模型中,其中可能包括艺术品、古董、名画等。这些新概念可能已经使用不同的类型层次结构进行了建模,如下所示:

我的妻子喜欢收集名画,因此我可以将BasketOfStock类型归纳为BasketOfThings,如下所示:

集合内的事物可以是Stock或Painting。请记住重要的一点,Julia是一种强类型语言,并且编译器必须知道哪种数据类型适合现有字段。让我们看看它是如何工作的。

为了要创建包含Painting或Stock的数组,我们只需在方括号前面指定数组的元素类型,就像Union{Painting,Stock}[stock,monalisa]。

Union类型的语法可能非常冗长,尤其是存在两种以上类型时,因此使用定义了代表Union类型的有意义名称的常量是很常见的:

综上所述,Thing比Union{Painting,Stock}更容易阅读。Union类型还有一个好处是它可以在源代码的许多地方引用。当我们以后需要添加更多类型(如Antique类型)时,我们只需要在一个地方(即Thing的定义)更改它。这就意味着可以更轻松地维护代码。

在本节中,尽管我们选择使用诸如Stock和Painting之类的具体类型,但没有理由不能为Union类型使用诸如Asset和Art之类的抽象类型。

Union类型的另一种常见用法是将Nothing合并为字段的有效值。这可以通过声明具有Union{T,Nothing}类型的字段来实现,其中T是我们要使用的实际数据类型。在这种情况下,可以为该字段分配一个实际值或仅分配Nothing。

接下来,我们将继续学习如何使用类型运算符。

2.5.3 使用类型运算符

Julia的数据类型本身就是第一类实体。这意味着你可以将它们分配给变量或传递给函数,可以以各种方式对其进行操作。在以下各节中,我们将介绍两个常用的运算符。

isa运算符

isa运算符可用于确定值是否是类型的子类型,看下面的代码。

让我解释一下这些结果:

·数字1是Int类型的实例,因此它返回true。

·因为Float64是不同的具体类型,所以它返回false。

·由于Int是Signed的子类型,而Signed是Integer的子类型,Integer又是Real的子类型,因此返回true。

isa运算符对于检查接受泛型类型参数的函数中的类型可能很有用。但如果函数只能使用实数,则当偶然传递Complex值时,它可能会引发错误。

<:运算符

超类型–子类型关系的运算符<:用于确定某个类型是否为另一种类型的子类型。以上一节中的第3个示例为例,我们可以检查Int是否确实是Real的子类型,如下所示。

有时,开发人员可能会对isa和<:运算符的用法感到困惑,因为它们非常相似。我们只需记住:isa根据类型检查一个类型的值,而<:根据另一个类型检查一个类型的类型。这些运算符的描述文档实际上很有帮助。在Julia REPL中,输入?和运算符可以查看文档。

事实证明,isa和<:都只是函数,但它们也可以用作中间的运算符。

这些运算符对于类型检查非常有用。例如,如果传递的参数没有正确的类型,则可以从构造函数中引发异常。它们也可以用于根据传递给函数的类型动态地执行不同的逻辑。

抽象类型和具体类型是Julia中数据类型的基本构建模块。快速浏览一下它们之间的差异是值得的。接下来,我们将研究具体细节。

2.5.4 抽象类型和具体类型的差异

在讨论了抽象类型和具体类型之后,你可能想知道它们之间的区别。我们可以在表2-2中总结它们的区别。

表 2-2

对于抽象类型,我们可以构建类型的层次结构。顶级类型是Any。抽象类型不能包含任何数据字段,因为它们用于表示概念而不是数据存储。抽象类型是第一类实体的,这意味着它们可以存储和传递,并且可以使用它们的函数,例如isa和<:运算符。

具体类型与抽象类型作为超类型相关联。如果未指定超类型,则假定它为Any。具体类型不允许子类型。这意味着每个具体类型必须是最终类型,并且将是类型层次结构中的叶节点。和抽象类型一样,具体类型也是第一类实体。

Union类型可以引用抽象类型和具体类型。

我们刚提到的内容可能会让来自面向对象编程背景的人感到惊讶。首先,你可能想知道为什么具体类型不允许子类型。其次,你可能想知道为什么不能使用字段定义抽象类型。实际上,这种设计是有意为之的,并且由核心Julia开发团队进行了激烈的辩论。辩论涉及行为继承与结构继承,这将在第12章中进行讨论。

现在,让我们切换一下思维,来看看Julia语言的参数类型功能。