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

3.2 设计函数

函数是Julia的核心组件,用于定义应用程序的行为。事实上,Julia的工作方式更像是过程/函数式编程语言,而不是面向对象的编程语言。在面向对象的编程中,我们主要关注构建类及在类中定义函数。在Julia中,我们更关注构建数据类型或对数据结构进行操作的函数。

在本节中,我们将演示如何定义函数以及函数所具有的强大功能。

3.2.1 用例——太空战争游戏

在本章中,我们将通过构建太空战争游戏的各个部分来阐述编程概念。游戏的设计非常简单明了,它由散布在二维网格周围的游戏零件(例如飞船和小行星)组成。这些游戏零件在我们的程序中称为小部件。

首先让我们定义数据类型,如下代码所示:

由于数据类型是我们设计的核心,因此需要更多说明:

·Position类型用于存储游戏零件的坐标。它由两个整数表示:x和y。

·Size类型用于存储游戏零件的大小。它由两个整数表示:width和height。

·Widget类型用于容纳单个游戏零件。它由name、position和size表示。

需要注意的是,Position类型是可变的,因为我们期望游戏零件仅通过更新其坐标即可移动。

接下来,我们将讨论如何定义函数。

3.2.2 定义函数

实际上,我们可以使用两种不同的语法来定义一个函数:

·第一种方法是一个简单的单行代码,其中包含函数的签名和主体。

·第二种方法使用带有签名的function关键字,然后是代码块和end关键字。

如果函数足够简单(例如如果只有一条指令),那么最好在一行中编写该函数。这种函数定义样式在科学计算项目中非常常见,因为许多函数只是模仿相应的数学公式。

对于我们的游戏来说,只需编写四个函数即可通过修改小部件的坐标在游戏板上移动游戏零件。

在Julia中编写单行函数是一种惯用的形式。不同编程背景的人们可能会发现编写如下所示的更详细的多行会更直观。两种形式都没有错,都可以正常工作:

关于如何编写这些函数,需要牢记一些注意事项:

·下划线的使用:函数名称中使用下划线分隔单词。根据Julia的官方手册约定,单词要连在一起而不要任何分隔符,除非它变得太混乱或难以阅读。我个人认为下划线应始终用于多词函数名称,因为它可以增强可读性并使代码更一致。

·感叹号的使用:函数名称中包含感叹号,以表示该函数使正在传递到自身中的对象的状态发生变异。这是一个好习惯,因为它提醒开发人员调用该函数时会有副作用。

·鸭子类型:你可能想知道为什么函数参数未使用任何类型信息进行注释。在move_up!函数中,虽然没有任何类型注释,但是我们希望在使用该函数时,widget参数具有Widget类型,而v具有Int类型。这是一个非常有意思的话题,我们将在3.2.3节中进一步讨论。

如你所见,定义函数是一个相当简单的任务,Julia处理函数参数的方式非常有趣。接下来,我们将继续进行讨论。

3.2.3 注释函数参数

在没有任何多态性的静态类型语言(例如C或Fortran)中,每个参数都必须使用确切的类型指定。但是,Julia是动态类型,并支持鸭子类型——如果它走路像鸭子,像鸭子一样嘎嘎叫,那它就是鸭子。编译器查看传递到函数中的运行时类型,并编译专用于这些类型的适当方法,所以在源代码中根本不需要类型信息。根据参数类型在整个方法主体中推导类型的过程称为类型推断。

因此,根本不需要使用类型信息来注释函数参数。人们有时会产生一种印象,即他们在Julia代码中放置参数类型注释会提高性能,但其实并非如此。对于方法签名,类型对性能没有影响,它们仅用于控制调度。

你会选择什么呢?是否对参数进行类型注释呢?

无类型参数

当函数自变量没有使用类型信息时,函数实际上更加灵活。为什么?这是因为它可以与传递给函数的任何类型一起使用。比如说,未来坐标系将从Int更改为Float64。发生这种情况时,无须更改函数,它可以正常工作!

相反,使所有类型都保持无类型可能不是最好的主意,因为该函数不能真正与世界上定义的每种可能的数据类型一起使用。此外,它通常可能会导致模糊的异常消息,并使程序更难以调试。例如,如果我们错误地将Int值作为Widget参数传递给move_up!函数,那么它将报错——Int64没有position字段。

该错误消息非常模糊。我们可以做些什么来使调试更容易一些?答案是我们可以提供函数参数的类型。让我们看看如何做到这一点。

类型化参数

我们知道,对move函数的实现带有一些隐式设计假设:

·v的值应为数值,如+或-运算符所暗示的那样。

·widget必须是Widget对象,或者至少包含Position对象,如对position字段的访问所暗示的那样。

由于这些原因,使用一些类型信息来定义函数通常更安全。move_up!函数可以按如下方式被重新定义:

如果我们以相同的方式定义所有move函数,则调试会变得更加容易。假设我们通过传递整数作为第一个参数而犯了与前面代码相同的错误,那么我们将收到一条更明确的错误信息。

Julia编译器将直接告诉我们,不存在这种参数类型的函数,而不是去尝试使用错误类型的参数运行该函数从而导致未知的后果。

在继续讨论下一个主题之前,让我们玩一下游戏。为了在Julia REPL中更好地显示这些对象,我们可以定义一些show函数,如下所示:

这些Base.show函数提供了需要在特定I/O设备(例如REPL)上显示Position、Size或Widget对象时使用的实现。通过定义这些函数,我们可以获得更好的输出。

请注意,Widget类型的show函数将打印小部件的名称、位置和大小。对应Position和Size类型的show函数将从print函数中调用。

show函数带有另一种形式show(io,mime,x),因此可以针对不同的MIME类型以不同的格式显示值x。

MIME表示多用途Internet邮件扩展,也称为媒体类型。它是用于指定数据流类型的标准。例如,text/plain表示纯文本流,text/html表示具有HTML内容的文本流。

show函数的默认MIME类型是text/plain,这实际上是我们在Julia REPL环境中使用的类型。如果我们在Jupyter之类的Notebook环境中使用Julia,那么我们可以提供一个show函数,该函数使用MIME类型的text/html,以此在HTML中提供其他格式。

最后,让我们对其进行测试。我们可以通过调用各种move函数来围绕小行星游戏零件移动,如下所示:

请注意,小行星小部件的输出格式与我们对其编码的方式完全相同,结果如下。

使用类型化参数来定义函数通常被认为是一种好习惯,因为该函数只能使用参数的特定数据类型。另外,从客户端的使用角度来看,仅通过查看函数定义,就可以清楚地看到该函数需要什么。

有时,使用无类型参数来定义函数会更有利。例如,标准print函数具有一个类似于print(io::IO,x)的函数签名。目的是保证print函数可用于所有可能的数据类型。

一般来说,这应该是一个例外,而不是规范。在大多数情况下,使用类型化参数更有意义。

接下来,我们将讨论如何为参数提供默认值。

3.2.4 使用可选参数

有时,我们不想对函数中的任何值进行硬编码。通用的解决方案是提取硬编码的值并将其放入函数参数工作。在Julia中,我们还可以为参数提供默认值。当我们具有默认值时,参数将变为可选的。

为了说明这个概念,让我们编写一个生成一堆小行星的函数:

该函数将参数N指定为小行星的数量。它还接受位置范围pos_range和size_range,用于创建随机大小的小行星,这些小行星随机放置在我们的游戏地图上。你可能会注意到,我们还直接在make_asteroid函数的主体内部定义了两个单行函数pos_rand和sz_rand。这些函数仅在这个函数范围内有效。

让我们在无须为pos_range或size_range指定任何值的情况下尝试一下。

但是它们是可选的,也能使用我们提供的自定义值。例如,我们可以通过指定更窄的范围将小行星彼此放置得更近。

这个魔法从何而来呢?如果从REPL进入make_asteroid函数时按下Tab键,你可能会注意到单个函数定义以三种方法结束。

什么是函数和方法?

函数在Julia中是泛型的。这意味着我们可以通过定义具有相同名称但采用不同类型参数的各种方法来扩展函数的用途。

因此,Julia中的每个函数都可以与一个或多个相关方法相关联。

在内部,Julia为不同的签名自动创建一种方法。

查找函数方法的另一种方法是只使用来自Julia Base包的methods函数。

当然,我们可以完全指定所有参数,如下代码所示:

如上图所示,为位置参数提供默认值非常方便。在通常接受默认值的情况下,调用函数变得更简单,因为它不必指定所有参数。

但是,这里有些奇怪的地方——代码变得越来越难以阅读,如make_asteroids(5,100:5:200,200:10:500)。5,100:5:200和200:10:500是什么意思?这些参数看起来很难懂,开发人员可能不查源代码或手册就不记得它们的含义。一定有更好的方法!接下来,我们将检查如何使用关键字参数解决此问题。

3.2.5 使用关键字参数

可选参数的一个缺点是,调用时必须与定义的顺序相同。当有更多参数时,调用的可读性就不好,不易判定哪个值对应第几个参数。在这种情况下,我们可以使用关键字参数来提高可读性。

让我们重新定义make_asteroid函数,如下所示:

此函数与3.2.4节中的函数的唯一区别只有一个字符。位置参数(在这种情况下为N)和关键字参数(pos_range和size_range)仅需用分号(;)分隔字符。

从调用者的角度来看,关键字参数必须与参数名称一起传递。

使用关键字参数可以使代码更具可读性!实际上,关键字参数甚至不需要按照在函数中定义的顺序传递。

另一个很酷的功能是关键字参数不必带有任何默认值。例如,我们可以定义相同的函数,其中第一个参数N成为强制性关键字参数:

此时,我们可以调用指定N的函数。

使用关键字参数是编写自文档代码的好方法。一些开源包(例如Plots)广泛使用关键字参数。当一个函数需要许多参数时,它也可以很好地工作。

尽管在此示例中我们为关键字参数指定了默认值,但实际上这并不是必需的。在没有默认值的情况下,调用该函数时,关键字参数会成为强制性的。

另一个很酷的功能是我们可以将可变数量的参数传递给函数。接下来,我们将对此进行研究。

3.2.6 接受可变数量的参数

有时,如果函数可以接受任意数量的参数,则更为方便。在这种情况下,我们可以在函数参数中添加三个点...,Julia会自动将所有传递的参数汇总到一个变量中。此功能称为slurping。

以下是一个例子:

在shoot函数中,我们首先打印targets变量的类型,然后打印每个被发射的目标。让我们首先设置游戏零件:

现在我们可以开始射击了!让我们先通过传递一个目标来调用shoot函数,然后再通过传递三个目标来再次执行。

事实证明,参数只是作为一个元组组合并绑定到单个targets变量。在这种情况下,我们仅迭代元组并对每个元组执行操作。

slurping是一种绝佳的方法,可以将函数参数组合在一起并一起处理。这样就可以使用任意数量的参数来调用函数。

接下来,我们将学习一个类似的功能,称为splatting,,该功能实际上执行与slurping相反的功能。

3.2.7 splatting参数

三点符号(...)在slurping时就非常有用,而实际上它还有另外一种用法。调用函数时当变量后面跟随三个点时,该变量将自动分配为多个函数参数。此功能称为splatting。实际上,该机制与slurping非常相似,只是它的作用相反。我们来看一个例子。

假设我们编写了一个函数,以特定的形式排列几个飞船:

在太空战争之前,我们还建造了几个飞船。

现在,我们可以调用triangular_formation!函数来使用splatting技巧,该函数在参数后附加三个点。

在这种情况下,spaceships向量里面的三个元素被分配到三个参数以满足triangular_formation!函数的期望。

splatting在技术上可以与任何集合类型(向量和元组)一起使用。只要被分散的变量支持泛型迭代接口,它就应该起作用。

另外,你可能想知道当变量中的元素数量与函数中定义的参数数量不相等时会发生什么。你最好通过练习去验证它。

splatting是创建函数参数并将其直接传递到函数中的好方法,而不必将其拆分为单独的参数。因此非常方便。

接下来,我们将讨论如何传递函数以实现高阶编程功能。

3.2.8 第一类实体函数

当函数可以分配给变量或结构字段、传递给函数、从函数返回时,它们被认为是第一类实体。就像常规数据类型一样,它们被视为第一类实体。现在我们来看看如何像常规数值一样传递函数。

让我们设计一个新函数,该函数可以推动飞船沿随机方向飞行一段随机距离。你可能会想起本章开头,我们已经定义了四个move函数move_up!、move_down!、move_left!和move_right!。以下是我们的策略:

1)创建一个random_move函数,该函数返回可能的move函数之一。这为选择方向提供了基础。

2)创建一个random_leap!函数使用指定的move函数和飞行距离来移动飞船。

代码如下:

如上所述,random_move函数返回一个从move函数数组中随机选择的函数。random_leap!函数接受move函数move_func作为参数,然后仅使用Widget和distance进行调用。现在来测试random_leap!函数。

我们已经成功调用了随机选择的move函数。所有这些操作都可以轻松完成,因为我们可以将函数存储为常规变量。函数的第一类实体的性质使其易于使用。

接下来,我们将学习匿名函数。匿名函数通常在Julia程序中使用,因为匿名函数是创建函数并将其传递给其他函数的快速方法。

3.2.9 开发匿名函数

有时,我们只想创建一个简单的函数并在不分配名称的情况下传递它。这种编程风格实际上在函数编程语言中相当普遍。我们可以通过一个例子来说明它的用法。

假设我们要炸毁所有小行星。一种方法是定义一个explode函数,并将其传递给foreach函数,如下所示:

结果看起来不错。

如果仅将匿名函数传递给foreach,我们可以达到相同的效果:

匿名函数的语法包含参数变量,后跟细箭头(->)和函数主体。在这种情况下,我们只有一个参数。如果我们有更多的参数,则可以将它们编写为包含在括号中的元组。匿名函数也可以分配给变量并传递。假设我们也要炸飞船:

我们可以看到使用匿名函数有一些优点:

·无须使用函数名称并污染模块的命名空间。

·在调用时提供匿名函数逻辑使代码更易于阅读。

·代码稍微紧凑。

到目前为止,我们已经讨论了有关如何定义和使用函数的大多数相关细节。下一个主题do语法与匿名函数密切相关。这是增强代码可读性的好方法。

3.2.10 使用do语法

当使用匿名函数时,我们可能最终在函数调用的中间有一个代码块,这使得代码更难以阅读。do语法是解决此问题并生成清晰易读的代码的好方法。

为了说明这个概念,让我们为战斗舰队建立一个新的用例。要特殊处理的是,我们将通过添加发射导弹的功能来增强飞船的战斗力。我们还希望支持这样的需求:发射武器需要飞船运转良好。

我们可以定义一个fire函数,fire函数的参数是launch函数和飞船。仅当飞船运转良好时才执行launch函数。为什么我们要将函数作为参数?因为我们要使其具有灵活性,以便今后可以使用相同的fire函数来发射激光束和其他可能的武器:

让我们尝试使用匿名函数来发射导弹。

看起来都很好。但是,如果我们需要更复杂的程序来发射导弹怎么办?例如,假设我们想在发射前将飞船上移,然后再将其下移:

这种语法现在看起来不易理解,我们可以利用do语法来重写它,让它更加可读:

它是如何工作的?语法其实已经翻译好了,它是将do语句块变成了一个匿名函数,然后将其作为函数的第一个参数插入。

do语法的有趣用法可以在Julia的open函数中找到。由于读取文件涉及打开和关闭文件处理程序,因此open函数旨在接受一个带IOStream并对其进行处理的匿名函数,而打开/关闭任务则由open函数本身处理。

这个想法很简单,所以让我们用我们自己的process_file函数在这里复制它:

使用do语法,我们可以专注于开发文件处理的逻辑,而不必担心整理工作,例如打开和关闭文件。参考以下代码。

如图所示,do语法可以通过两种方式使用:

·通过以块格式重新排列匿名函数参数,可以使代码更具可读性。

·它允许将匿名函数包装于可以在该函数之前或之后执行附加逻辑的上下文中。

接下来,我们将研究多重分派,这是一种独特的功能,在面向对象的语言中并不常见。