Julia-编程项目-二-

243 阅读46分钟

Julia 编程项目(二)

原文:annas-archive.org/md5/0086c86218c52c8cc6ad2375a5d3ae02

译者:飞龙

协议:CC BY-NC-SA 4.0

第三章:设置 Wiki 游戏

我希望你现在对 Julia 感到兴奋。友好、表达丰富且直观的语法,强大的 read-eval-print 循环REPL),出色的性能,以及内置和第三方库的丰富性,对于数据科学(尤其是编程)来说是一个颠覆性的组合。事实上,仅仅在两个入门章节中,我们就能够掌握语言的基础,并配置一个足够强大的数据科学环境来分析 Iris 数据集,这相当令人惊讶——恭喜,我们做得很好!

但我们实际上才刚刚开始。我们奠定的基础现在足够强大,可以让我们使用 Julia 开发几乎任何类型的程序。难以置信吗?好吧,这里是证据——在接下来的三个章节中,我们将使用 Julia 开发一个基于网页的游戏!

它将遵循互联网上著名的 六度分隔 Wikipedia 的叙事。如果你从未听说过它,其想法是任何两篇维基百科文章都可以通过页面上的链接连接起来,只需点击六次或更少。它也被称为 六度分隔

如果你在想这与 Julia 有什么关系,这是一个有趣的理由来学习数据挖掘和网页抓取,并且更多地了解这门语言,将我们新获得的知识应用到构建网页应用中。

在本章中,我们将奠定网页抓取的基础。我们将探讨在客户端-服务器架构中如何在网络上发出请求,以及如何使用 HTTP 包抓取网页。我们将学习关于 HTML 文档、HTML 和 CSS 选择器,以及 Gumbo,Julia 的 HTML 解析器。在这个过程中,我们将在 REPL 中实验更多代码,并了解语言的其他关键特性,如字典、错误处理、函数和条件语句。我们还将设置我们的第一个 Julia 项目。

本章我们将涵盖以下主题:

  • 网页抓取是什么以及它是如何用于数据采集的

  • 如何使用 Julia 发出请求和抓取网页

  • 了解 Pair 类型

  • 了解字典,这是 Julia 中更灵活的数据结构之一

  • 异常处理,帮助我们捕获代码中的错误

  • 函数,Julia 的基本构建块和最重要的代码单元之一——我们将学习如何定义和使用它们来创建可重用、模块化的代码

  • 一些有用的 Julia 技巧,例如管道操作符和短路评估

  • 使用 Pkg 设置 Julia 项目

技术要求

Julia 包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍然处于测试版(版本 0.x),任何新版本都可能引入破坏性更改。因此,书中展示的代码可能会停止工作。为了确保您的代码能够产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其具体版本:

Gumbo@v0.5.1
HTTP@v0.7.1
IJulia@v1.14.1
OrderedCollections@v1.0.2

为了安装特定版本的包,您需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,您也可以通过下载章节提供的Project.toml文件并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter03/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

通过网络爬虫进行数据采集

使用软件从网页中提取数据的技术称为网络爬虫。它是数据采集的重要组件,通常通过称为网络爬虫的程序实现。数据采集或数据挖掘是一种有用的技术,常用于数据科学工作流程中,从互联网上收集信息,通常是从网站(而不是 API)上,然后使用各种算法对数据进行处理,以达到不同的目的。

在非常高的层面上,这个过程涉及对网页发出请求,获取其内容,解析其结构,然后提取所需的信息。这可能包括图像、文本段落或包含股票信息和价格的表格数据,例如——几乎任何在网页上存在的内容。如果内容分布在多个网页上,爬虫还会提取链接,并自动跟随它们以拉取其余页面,反复应用相同的爬取过程。

网络爬虫最常见的使用是用于网络索引,如 Google 或 Bing 等搜索引擎所做的那样。在线价格监控和价格比较、个人数据挖掘(或联系爬取)、在线声誉系统,以及产品评论平台,都是网络爬虫的其他常见用例。

网络工作原理——快速入门

在过去的十年里,互联网已经成为我们生活的一个基本组成部分。我们中的大多数人都广泛地使用它来获取大量的信息,日复一日。无论是搜索“rambunctious”(喧闹且缺乏自律或纪律),在社交网络上与朋友保持联系,在 Instagram 上查看最新的美食餐厅,在 Netflix 上观看热门电影,还是阅读关于 Attitogon(多哥的一个地方,那里的人们练习巫毒教)的维基百科条目——所有这些,尽管性质不同,但基本上都以相同的方式运作。

一个连接到互联网的设备,无论是使用 Wi-Fi 的计算机还是连接到移动数据网络的智能手机,以及一个用于访问网络的程序(通常是一个 Web 浏览器,如 Chrome 或 Firefox,也可以是专门的程序,如 Facebook 或 Netflix 的移动应用),代表客户端。在另一端是服务器——一个存储信息的计算机,无论是以网页、视频还是整个 Web 应用的形式。

当客户端想要访问服务器上的信息时,它会发起一个请求。如果服务器确定客户端有权访问资源,信息的一个副本将从服务器下载到客户端,以便显示。

发送 HTTP 请求

超文本传输协议HTTP)是一种用于在网络上传输文档的通信协议。它是为了在 Web 浏览器和 Web 服务器之间进行通信而设计的。HTTP 实现了标准的客户端-服务器模型,其中客户端打开一个连接并发出请求,然后等待响应。

了解 HTTP 方法

HTTP 定义了一组请求方法,用于指示对给定资源要执行的操作。最常见的方法是GET,它的目的是从服务器检索数据。当通过链接在互联网上导航时使用。POST方法请求服务器接受一个包含的数据有效负载,通常是提交网页表单的结果。还有一些其他方法,包括HEADPUTDELETEPATCH等——但它们使用较少,并且客户端和 Web 服务器支持较少。由于我们不需要它们进行我们的网络爬虫,所以不会涉及这些。

如果你对它们感兴趣,可以在developer.mozilla.org/en-US/docs/Web/HTTP/Methods上阅读有关内容。

理解 HTTPS

HTTP 安全HTTPS)基本上是在加密连接上运行的 HTTP。它最初是一种主要用于在互联网上处理支付和传输敏感企业信息的替代协议。但近年来,它已经开始得到广泛的使用,主要公司推动在互联网上用 HTTPS 替换普通的 HTTP 连接。在我们的讨论中,HTTP 和 HTTPS 可以互换使用。

理解 HTML 文档

为了从获取的网页中提取数据,我们需要隔离和操作包含所需信息的结构元素。这就是为什么在执行网络爬取时,对网页通用结构的了解很有帮助。如果你之前进行过网络爬取,可能使用的是不同的编程语言,或者如果你对 HTML 文档了解足够多,可以自由跳过这一部分。另一方面,如果你是新手或者只是需要快速复习,请继续阅读。

超文本标记语言(HTML)是创建网页和网页应用的黄金标准。HTML 与 HTTP 协议相辅相成,该协议用于在互联网上传输 HTML 文档。

HTML 页面的构建块是HTML 元素。它们提供了网页的内容和结构。它们可以通过嵌套来定义彼此之间的复杂关系(如父元素、子元素、兄弟元素、祖先元素等)。HTML 元素通过标签表示,标签写在大括号之间(<tag>...</tag>)。官方 W3C 规范定义了大量的此类标签,代表从标题和段落到列表、表单、链接、图片、引语等一切内容。

为了让您有一个概念,以下是如何在 Julia 的维基百科页面en.wikipedia.org/wiki/Julia_(programming_language)上用 HTML 表示主要标题的示例:

<h1>Julia (programming language)</h1> 

在现代浏览器中,这段 HTML 代码会呈现如下:

一个更详细的例子可以展示一个嵌套结构,如下所示:

<div> 
    <h2>Language features</h2> 
    <p>According to the official website, the main features of the language are:</p> 
    <ul> 
           <li>Multiple dispatch</li> 
           <li>Dynamic type sytem</li> 
           <li>Good performance</li> 
    </ul> 
</div> 
<h2>), a paragraph of text (<p>), and an unordered list (<ul>), with three list items (<li>), all within a page section (<div>):

HTML 选择器

HTML 的目的是提供内容和结构。这就是我们传达任何类型信息所需的一切,无论信息多么复杂。然而,随着计算机和网页浏览器的变得更加强大,以及网页的使用变得更加普遍,用户和开发者想要更多。他们要求扩展 HTML,以便包括美丽的格式(设计)和丰富的行为(交互性)。

正因如此,层叠样式表(CSS)被创建出来——一种定义 HTML 文档设计的样式语言。此外,JavaScript 也成为了客户端编程语言的首选,为网页增加了交互性。

CSS 和 JavaScript 提供的样式规则和交互功能与定义良好的 HTML 元素相关联。也就是说,样式和交互必须明确针对相关的 HTML 文档中的元素。例如,一个 CSS 规则可以针对页面的主要标题——或者一个 JavaScript 验证规则可以针对登录表单中的文本输入。如果您将网页视为一个结构化的 HTML 元素集合,这种针对是通过选择(子集合)元素来实现的。

选择元素可以通过简单地识别 HTML 标签的类型和结构(层次结构)来完成。在先前的例子中,我们查看如何表示 Julia 的功能列表时,我们可以通过指定一个层次结构如div > ul > li来选择所有列表项(<li>元素),这表示所有嵌套在ul元素中的li元素,而ul元素又嵌套在div元素中。这些被称为HTML 选择器

然而,这种方法有其局限性。一方面,当处理大型、复杂且深度嵌套的 HTML 文档时,我们必须处理同样复杂的层次结构,这是一项繁琐且容易出错的任务。另一方面,这种方法可能不足以提供足够的特定性,使我们能够选择我们想要的目标元素。例如,在相同的 Julia 维基百科页面上,我们如何区分功能列表和外部链接列表?它们都有相似的结构。

Julia 维基百科页面上的 外部链接 列表看起来是这样的:

图片

语言功能 部分有类似的结构:

图片

两个 HTML 元素在结构上相同的事实使得单独选择语言功能列表项变得困难。

学习 HTML 属性

这就是 HTML 属性发挥作用的地方。这些是键值对,它们增强了 HTML 标签,提供了额外信息。例如,为了定义一个链接,我们将使用 <a> 标签——<a>This is a link</a>

但显然,这还不够。如果这是一个链接,它链接到什么?作为开发者,我们需要提供有关链接位置的一些额外信息。这是通过添加带有相应值的 href 属性来完成的:

<a href="https://julialang.org/">This is a link to Julia's home page</a>

哎呀,现在我们说到点子上了!一个超级方便的链接到 Julia 的主页。

通常,所有属性都可以在选择 HTML 元素时使用。但并非所有属性都同样有用。其中最重要的可能是 id 属性。它允许我们为元素分配一个唯一的标识符,然后以非常高效的方式引用它。另一个重要的属性是 class,它被广泛用于 CSS 样式规则。

这就是我们之前的例子添加额外属性后的样子:

<a href="https://julialang.org/" id="julia_link" class="external_link">This is a link to Julia's home page</a>

学习 CSS 和 JavaScript 选择器

从历史上看,JavaScript 最初使用基于 id 属性和 HTML 元素(标签)名称的选择器。后来,CSS 规范带来了一组更强大的选择器,不仅包括 classid 和标签,还包括属性及其值、元素的状态(如 focuseddisabled),以及更具体的元素层次结构,它考虑了关系。

这里有一些可以用来定位之前讨论的 <a> 标签的 CSS 选择器示例:

  • #julia_linkid 属性的选择器(#

  • .external_linkclass 属性(.)的选择器

  • a<a> 标签的选择器

  • a[href*="julialang.org"] 将选择所有具有包含 "julialang.org"href 属性的 <a> 标签

你可以在developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors了解更多关于 CSS 选择器的信息。这个资源值得保留在身边,因为网络爬虫在很大程度上依赖于 CSS 选择器,正如我们将在下一章中看到的。

理解链接的结构

在技术术语中被称为统一资源定位符URLs)的链接,是一系列字符,它们唯一地标识了互联网上的资源。它们非正式地被称为网页地址。有时你可能看到它们被称为统一资源标识符URIs)。

在我们之前的例子中,Julia 的维基百科网页可以通过 URL en.wikipedia.org/wiki/Julia_(programming_language) 访问。这个 URL 指的是资源 /wiki/Julia_(programming_language),其表示形式,作为一个 HTML 文档,可以通过 HTTPS 协议(https:)从域名是 wikipedia.org 的网络主机请求。(哇,这听起来很复杂,但现在你可以理解请求互联网上网页的过程是多么复杂了)。

因此,一个常见的 URL 可以分解为以下部分——scheme://host/path?query#fragment

例如,如果我们查看en.wikipedia.org/wiki/Julia_(programming_language)?uselang=en#Interaction,我们有https作为schemeen.wikipedia.org作为host/wiki/Julia_(programming_language)作为path?uselang=en作为query,最后,#Interaction作为fragment

从 Julia 访问互联网

现在你已经很好地理解了如何通过客户端-服务器交互在互联网上访问网页,让我们看看我们如何使用 Julia 来实现这一点。

最常见的网络客户端是网络浏览器——如 Chrome 或 Firefox 这样的应用程序。然而,这些是为人类用户设计的,它们使用花哨的样式 UI 和复杂的交互来渲染网页。虽然可以通过网络浏览器手动进行网络爬取,但最有效和可扩展的方式是通过完全自动化的、软件驱动的流程。尽管网络浏览器可以被自动化(例如使用来自 www.seleniumhq.org 的 Selenium),但这是一项更困难、更容易出错且资源密集的任务。对于大多数用例,首选的方法是使用专门的 HTTP 客户端。

使用 HTTP 包进行请求

Pkg,Julia 的内置包管理器,提供了对优秀的 HTTP 包的访问。它暴露了构建网络客户端和服务器的高级功能——我们将广泛使用它。

正如你已经习惯的,额外的功能只需两个命令——pkg> add HTTPjulia> using HTTP

回想一下上一节关于 HTTP 方法的讨论;最重要的方法是 GET,用于从服务器请求资源,以及 POST,它将数据有效负载发送到服务器并接受响应。HTTP 包暴露了一组匹配的函数——我们可以访问 HTTP.getHTTP.postHTTP.deleteHTTP.put 等等。

假设我们想要请求朱莉娅的维基百科页面。我们需要的只是页面的 URL 和 HTTP.get 方法:

julia> HTTP.get("https://en.wikipedia.org/wiki/Julia_(programming_language)") 

结果将是一个 Response 对象,它代表了朱莉娅的维基百科页面及其所有细节。REPL 显示了头部和响应主体的前几行,其余部分被截断:

截图显示了我们所接收的 HTTP.Messages.Response 对象的详细信息——HTTP 头部的列表和响应主体的第一部分。让我们确保我们将其保存在变量中,以便稍后引用。记住,Julia 将上一次计算的结果暂时存储在 ans REPL 变量中,所以让我们从那里获取:

julia> resp = ans 

处理 HTTP 响应

在接收和处理请求后,服务器会发送一个 HTTP 响应消息。这些消息具有标准化的结构。它们包含大量信息,其中最重要的部分是状态码、头信息和主体。

HTTP 状态码

状态码是一个三位整数,其中第一位数字表示类别,而接下来的两位数字用于定义子类别。它们如下:

  • 1XX - 信息性: 请求已接收。这表示有一个临时响应。

  • 2XX - 成功:这是最重要的响应状态,表示请求已被成功接收、理解和接受。这是我们网络挖掘脚本所寻找的。

  • 3XX - 重定向:这类状态码表示客户端必须采取额外行动。这通常意味着必须进行额外的请求才能到达资源,因此我们的脚本将不得不处理这种情况。我们还需要积极防止循环重定向。在我们的项目中,我们不会处理这种复杂的情况,但在实际应用中,3XX 状态码将需要根据子类别进行专门处理。

维基百科提供了关于各种 3XX 状态码及其每种情况下应采取的操作的良好描述:en.wikipedia.org/wiki/List_of_HTTP_status_codes#3xx_Redirection

  • 4XX - 客户端错误:这意味着我们在发送请求时可能犯了一个错误。可能是 URL 错误,资源无法找到(404),或者我们可能无法访问页面(401403 状态码)。4XX 响应代码有很多,类似于 3XX 代码,我们的程序应该处理各种情况,以确保请求最终成功。

  • 5XX - 服务器错误:恭喜你,你在服务器上找到了或导致了问题!根据实际的状态码,这可能或可能不是可操作的。503(服务不可用)或504(网关超时)是相关的,因为它们表明我们应该稍后再尝试。

学习 HTTP 头信息

HTTP 头信息允许客户端和服务器传递额外的信息。我们不会深入讨论头信息的传输细节,因为 Julia 的HTTP库帮我们避免了处理原始头信息的麻烦。然而,有一些值得提及,因为它们对于网络爬虫很重要:

  • AgeCache-ControlExpires代表页面的有效性,可以用来设置数据刷新时间。

  • Last-ModifiedEtagIf-Modified-Since可用于内容版本控制,以检查页面自上次检索以来是否已更改。

  • CookieSet-Cookie必须使用,以便读取和写入与服务器正确通信所需的 cookie。

  • Content-*系列头信息,例如Content-DispositionContent-LengthContent-TypeContent-Encoding等,在处理和验证响应信息时非常有用。

查看 developer.mozilla.org/en-US/docs/…en.wikipedia.org/wiki/List_o… 以获取关于 HTTP 头信息的完整讨论。

HTTP 消息体

消息体,网络爬虫最重要的部分和原因(网页本身的内容),实际上是响应的一个可选部分。是否存在消息体、其属性及其大小由Content-*系列头信息指定。

理解 HTTP 响应

HTTP.get调用的结果是对象,它紧密地反映了原始 HTTP 响应。该包通过提取原始 HTTP 数据并将其整洁地设置在数据结构中,使我们的生活变得更简单,这使得操作它变得轻而易举。

让我们看看它的属性(或 Julia 语言中的字段):

julia> fieldnames(typeof(resp)) 
(:version, :status, :headers, :body, :request) 

fieldnames函数接受一个类型作为其参数,并返回一个包含字段(或属性)名称的元组。为了获取值的类型,我们可以使用typeof函数,就像前面的例子一样。

对了!到如今,statusheadersbody字段应该听起来很熟悉。version字段表示 HTTP 协议的版本(响应第一行中的HTTP/1.1部分)。今天互联网上的大多数 Web 服务器都使用协议的 1.1 版本,但一个新的主要版本 2.0 几乎准备广泛部署。最后,request字段包含触发当前响应的HTTP.Messages.Request对象的引用。

状态码

让我们更仔细地看看状态码:

julia> resp.status 200 

当然,我们得到了一个有效的响应,这通过200状态码得到了确认。

头信息

关于头信息呢?如前所述,它们包含指示消息体是否存在的重要信息。让我们来看看:

julia> resp.headers 

输出如下:

图片

您的输出在有些值上可能会有所不同,但应该很容易找到我们之前提到的关键 HTTP 头。Content-Length确认了响应体的存在。Content-Type提供了关于如何解释消息体编码的信息(它是一个使用 UTF-8 字符编码的 HTML 文档)。我们可以使用Last-Modified值来优化我们的网络爬虫的缓存和更新频率。

消息体

既然我们已经确认我们有一个响应体,让我们看看它:

julia> resp.body 
193324-element Array{UInt8,1}: 
 0x3c 
 0x21 
 0x44  
# ... output truncated ...  

哎呀,这看起来不像我们预期的网页。不过别担心,这些是原始响应的字节——我们可以轻松地将它们转换为可读的 HTML 字符串。记得我提到过学习字符串时的String方法吗?嗯,这就是它派上用场的地方:

julia> resp_body = String(resp.body) 

您的 REPL 现在应该正在输出一个代表 Julia 维基百科页面的长 HTML 字符串。

如果我们查看前500个字符,我们开始看到熟悉的模式:

julia> resp_body[1:500] 

输出如下:

图片

确实,使用 Chrome 的查看页面源代码将揭示相同的 HTML:

图片

已经确认了——我们刚刚迈出了建立我们的网络爬虫的第一步!

了解关于Pair的知识

当查看响应头时,您可能已经注意到它的类型是一个ArrayPair对象:

julia> resp.headers 
25-element Array{Pair{SubString{String},SubString{String}},1} 

Pair代表一个 Julia 数据结构及其对应的类型。Pair包含一些值,通常用于引用键值关系。两个元素的类型决定了Pair的具体类型。

例如,我们可以用以下方式构造一个Pair

julia> Pair(:foo, "bar") 
:foo => "bar" 

如果我们检查它的类型,我们会看到它是一个SymbolStringPair

julia> typeof(Pair(:foo, "bar")) 
Pair{Symbol,String} 

我们也可以通过使用x => y字面量表示法来创建Pairs

julia> 3 => 'C' 
3 => 'C' 

julia> typeof(3 => 'C') 
Pair{Int64,Char} 

=>双箭头应该很熟悉。这是我们之前在响应头中看到的,例如:

"Content-Type" => "text/html; charset=UTF-8"

显然,一旦创建,就可以访问存储在Pair中的值。一种方法是通过索引它:

julia> p = "one" => 1 
"one" => 1 

julia> p[1] 
"one" 

julia> p[2] 
1 

我们也可以访问firstsecond字段,分别获取firstsecond值:

julia> p.first 
"one" 

julia> p.second 
1 

就像元组一样,Pairs是不可变的,所以这不会起作用:

julia> p.first = "two" 
ERROR: type Pair is immutable 

julia> p[1] = "two" 
ERROR: MethodError: no method matching setindex!(::Pair{String,Int64} 

Pairs是 Julia 的构建块之一,可以用于创建字典,这是最重要的类型之一和数据结构。

字典

字典,称为Dict,是 Julia 最强大和多功能的数据结构之一。它是一个关联集合——它将键与值相关联。您可以将Dict视为查找表实现——给定一个单一的信息,即键,它将返回相应的值。

构建字典

创建一个空的Dict实例就像以下这样:

julia> d = Dict() 
Dict{Any,Any} with 0 entries 

大括号内的信息{Any,Any}表示Dict的键和值的类型。因此,Dict本身的具体类型由其键和值的类型定义。编译器将尽最大努力从其部分类型推断集合的类型。在这种情况下,由于字典为空,无法推断信息,因此 Julia 默认为AnyAny

{Any,Any}类型的Dict允许我们添加任何类型的数据,不加区分。我们可以使用setindex!方法向集合中添加新的键值对:

julia> setindex!(d, "World", "Hello") 
Dict{Any,Any} with 1 entry: 
  "Hello" => "World" 

然而,向Dict中添加值通常使用方括号符号(这与对其索引类似,同时执行赋值操作):

julia> d["Hola"] = "Mundo" 
"Mundo"  

到目前为止,我们只添加了Strings——但正如我所说的,因为我们的Dict接受任何类型的键和值,所以没有约束:

julia> d[:speed] = 6.4 
6.4 

现在是我们的Dict

julia> d 
Dict{Any,Any} with 3 entries: 
  "Hello" => "World" 
  :speed  => 6.4 
  "Hola"  => "Mundo" 

注意,键=>值对不是我们添加它们的顺序。在 Julia 中,Dict不是有序集合。我们将在接下来的几段中更多地讨论这一点。

如果键已存在,相应的值将被更新,返回新值:

julia> d["Hello"] = "Earth" "Earth" 

这是我们的更新后的Dict。注意,现在"Hello"指向"Earth"而不是"World"

julia> d 
Dict{Any,Any} with 3 entries: 
  "Hello" => "Earth" 
  :speed  => 6.4 
  "Hola"  => "Mundo"   

如果在实例化Dict时提供一些初始数据,编译器将能够更好地识别类型:

julia> dt = Dict("age" => 12) 
Dict{String,Int64} with 1 entry: 
  "age" => 12 

我们可以看到,Dict的类型现在限制了键必须是String,值必须是Int——这是我们用来实例化DictPair的类型。现在,如果传递了不同类型的键或值,Julia 将尝试转换它——如果失败,将发生错误:

julia> dt[:price] = 9.99 
MethodError: Cannot `convert` an object of type Symbol to an object of type String 

在某些情况下,自动转换是有效的:

julia> dx = Dict(1 => 11) 
Dict{Int64,Int64} with 1 entry: 
  1 => 11 
julia> dx[2.0] = 12 
12

Julia 已静默地将2.0转换为相应的Int值:

julia> dx 
Dict{Int64,Int64} with 2 entries: 
  2 => 12 
  1 => 11 

但这并不总是有效:

julia> dx[2.4] = 12 
InexactError: Int64(Int64, 2.4) 

我们可以在Dict中存储随机复杂的数据,Julia 会正确推断其类型:

 julia> clients_purchases = Dict( 
       "John Roche" => ["soap", "wine", "apples", "bread"], 
       "Merry Lou"  => ["bottled water", "apples", "cereals", "milk"] 
       ) 
Dict{String,Array{String,1}} with 2 entries: 
  "John Roche" => ["soap", "wine", "apples", "bread"] 
  "Merry Lou"  => ["bottled water", "apples", "cereals", "milk"] 

您也可以在构建时指定和约束Dict的类型,而不是让 Julia 来决定:

julia> dd = Dict{String,Int}("" => 2.0) 
Dict{String,Int64} with 1 entry: 
  "x" => 2 

在这里,我们可以看到类型定义如何覆盖了2.0值(这是一个Float64类型,当然,如前例所示,Julia 已将2.0转换为它的整数等价物)。

我们还可以使用Pairs来创建Dict

julia> p1 = "a" => 1 
"a"=>1 
julia> p2 = Pair("b", 2) 
"b"=>2 
julia> Dict(p1, p2) 
Dict{String,Int64} with 2 entries: 
  "b" => 2 
  "a" => 1 

我们还可以使用Pair的数组:

julia> Dict([p1, p2]) 
Dict{String,Int64} with 2 entries: 
  "b" => 2 
  "a" => 1 

我们可以用元组的数组来做同样的事情:

julia> Dict([("a", 5), ("b", 10)]) 
Dict{String,Int64} with 2 entries: 
  "b" => 10 
  "a" => 5 

最后,可以使用列表推导式来构建Dict

julia> using Dates 
julia> Dict([x => Dates.dayname(x) for x = (1:7)]) 
Dict{Int64,String} with 7 entries: 
  7 => "Sunday" 
  4 => "Thursday" 
  2 => "Tuesday" 
  3 => "Wednesday" 
  5 => "Friday" 
  6 => "Saturday" 
  1 => "Monday" 

您的输出可能会有所不同,因为键可能不会按17的顺序排列。这是一个非常重要的观点——如前所述,在 Julia 中,Dict是无序的。

有序字典

如果您需要您的字典保持有序,可以使用OrderedCollections包(github.com/JuliaCollections/OrderedCollections.jl),特别是OrderedDict

pkg> add OrderedCollections 
julia> using OrderedCollections, Dates 
julia> OrderedDict(x => Dates.monthname(x) for x = (1:12)) 
DataStructures.OrderedDict{Any,Any} with 12 entries: 
  1  => "January" 
  2  => "February" 
  3  => "March" 
  4  => "April" 
  5  => "May" 
  6  => "June" 
  7  => "July" 
  8  => "August" 
  9  => "September" 
  10 => "October" 
  11 => "November" 
  12 => "December" 

现在元素是按照它们添加到集合中的顺序存储的(从 112)。

与字典一起工作

正如我们已经看到的,我们可以使用方括号符号索引 Dict

julia> d = Dict(:foo => 1, :bar => 2) 
Dict{Symbol,Int64} with 2 entries: 
  :bar => 2 
  :foo => 1 

julia> d[:bar] 
2 

尝试访问一个未定义的键将导致 KeyError,如下所示:

julia> d[:baz] 
ERROR: KeyError: key :baz not found 

为了避免这种情况,我们可以检查键是否首先存在:

julia> haskey(d, :baz) 
false 

作为一种替代方法,如果我们想在键不存在时也获取默认值,我们可以使用以下方法:

julia> get(d, :baz, 0) 
0 

get 函数有一个更强大的双胞胎,get!,它也会将搜索到的键存储到 Dict 中,使用默认值:

julia> d 
Dict{Symbol,Int64} with 2 entries: 
  :bar => 2 
  :foo => 1 

julia> get!(d, :baz, 100) 
100 

julia> d 
Dict{Symbol,Int64} with 3 entries: 
  :baz => 100 
  :bar => 2 
  :foo => 1 

julia> haskey(d, :baz) 
true  

如果你在想,函数名末尾的感叹号是有效的——它表示一个重要的 Julia 命名约定。这应该被视为一个警告,即使用该函数将修改其参数的数据。在这种情况下,get! 函数将添加 :baz = 100PairdDict 中。

删除键值 Pair 只需调用 delete!(注意这里也有感叹号的存在):

julia> delete!(d, :baz) 
Dict{Symbol,Int64} with 2 entries: 
  :bar => 2 
  :foo => 1 

julia> haskey(d, :baz) 
false 

如请求所示,:baz 键及其对应值已经消失。

我们可以使用名为 keysvalues 的函数请求键和值的集合。它们将返回它们底层集合的迭代器:

julia> keys(d) 
Base.KeySet for a Dict{Symbol,Int64} with 2 entries. Keys: 
  :bar 
  :foo 

julia> values(d) 
Base.ValueIterator for a Dict{Symbol,Int64} with 2 entries. Values: 
  2 
  1 

使用 collect 获取相应的数组:

julia> collect(keys(d)) 
2-element Array{Symbol,1}: 
 :bar 
 :foo 

julia> collect(values(d)) 
2-element Array{Int64,1}: 
 2 
 1 

我们可以将一个 Dict 与另一个 Dict 结合:

julia> d2 = Dict(:baz => 3) 
Dict{Symbol,Int64} with 1 entry: 
  :baz => 3 

julia> d3 = merge(d, d2) 
Dict{Symbol,Int64} with 3 entries: 
  :baz => 3 
  :bar => 2 
  :foo => 1 

如果一些键在多个字典中存在,则将保留最后一个集合中的值:

julia> merge(d3, Dict(:baz => 10)) 
Dict{Symbol,Int64} with 3 entries: 
  :baz => 10 
  :bar => 2 
  :foo => 1 

使用 HTTP 响应

在了解了 Julia 的字典数据结构之后,我们现在可以更仔细地查看 respheaders 属性,我们的 HTTP 响应对象。

为了更容易访问各种标题,首先让我们将 Pair 数组转换为 Dict

julia> headers = Dict(resp.headers) 
Dict{SubString{String},SubString{String}} with 23 entries: 
"Connection"     => "keep-alive" 
  "Via"          => "1.1 varnish (Varnish/5.1), 1.1 varnish (Varni... 
  "X-Analytics"  => "ns=0;page_id=38455554;https=1;nocookies=1" 
#... output truncated... #

我们可以检查 Content-Length 值以确定是否有响应体。如果它大于 0,这意味着我们收到了一个 HTML 消息:

julia> headers["Content-Length"] 
"193324" 

重要的是要记住,headers 字典中的所有值都是字符串,因此我们不能直接比较它们:

julia> headers["Content-Length"] > 0 
ERROR: MethodError: no method matching isless(::Int64, ::String) 

我们需要首先将其解析为整数:

julia> parse(Int, headers["Content-Length"]) > 0 
true 

操作响应体

之前,我们将响应体读入一个 String 并存储在 resp_body 变量中。它是一个长的 HTML 字符串,从理论上讲,我们可以使用 Regex 和其他字符串处理函数来查找和提取我们所需的数据。然而,这种方法将非常复杂且容易出错。在 HTML 文档中搜索内容最好的方法是使用 HTML 和 CSS 选择器。唯一的问题是这些选择器不作用于字符串——它们只对 文档对象模型DOM)起作用。

构建页面 DOM 表示

DOM 代表 HTML 文档的内存结构。它是一种数据结构,允许我们以编程方式操作底层 HTML 元素。DOM 将文档表示为一个逻辑树,我们可以使用选择器来遍历和查询这个层次结构。

使用 Gumbo 解析 HTML

Julia 的Pkg生态系统提供了对Gumbo的访问,这是一个 HTML 解析库。提供 HTML 字符串后,Gumbo会将其解析成文档及其对应的 DOM。这个包是使用 Julia 进行网络爬取的重要工具,所以让我们添加它。

如往常一样,使用以下命令安装:

pkg> add Gumbo 
julia> using Gumbo  

现在,我们已经准备好将 HTML 字符串解析成 DOM,如下所示:

julia> dom = parsehtml(resp_body)
 HTML Document

dom变量现在引用了一个Gumbo.HTMLDocument,这是网页的内存中 Julia 表示。它是一个只有两个字段的简单对象:

julia> fieldnames(typeof(dom)) 
(:doctype, :root)  

doctype代表 HTML 的<!DOCTYPE html>元素,这是维基百科页面使用的:

julia> dom.doctype 
"html" 

现在,让我们关注root属性。这实际上是 HTML 页面的最外层元素——包含其余元素的<html>标签。它为我们提供了进入 DOM 的入口点。我们可以询问Gumbo它的属性:

julia> dom.root.attributes 
Dict{AbstractString,AbstractString} with 3 entries: 
  "class" => "client-nojs" 
  "lang"  => "en" 
  "dir"   => "ltr" 

它是一个Dict,键代表 HTML 属性,值是属性的值。确实,它们与页面的 HTML 相匹配:

图片

还有一个类似的attrs方法,它具有相同的作用:

julia> attrs(dom.root) 
Dict{AbstractString,AbstractString} with 3 entries: 
  "class" => "client-nojs" 
  "lang"  => "en" 
  "dir"   => "ltr" 

当不确定时,我们可以使用tag方法来询问元素的名称:

julia> tag(dom.root) 
:HTML 

Gumbo提供了一个children方法,它返回一个包含所有嵌套HTMLElement的数组。如果你直接执行julia> children(dom.root),REPL 的输出将难以跟踪。HTMLElement的 REPL 表示是其 HTML 代码,对于具有许多子元素的最高层元素,它将填满许多终端屏幕。让我们使用for循环遍历子元素并仅显示它们的标签:

julia> for c in children(dom.root) 
           @show tag(c) 
       end 
tag(c) = :head 
tag(c) = :body 

好多了!

由于子元素是集合的一部分,我们可以对它们进行索引:

julia> body = children(dom.root)[2]; 

请注意分号(;)的用法。当在 REPL(Read-Eval-Print Loop,即交互式解释器)的语句末尾使用时,它会抑制输出(因此我们不会看到其他情况下会输出的非常长的<body> HTML 代码)。现在body变量将引用一个HTMLElement{:body}的实例:

HTMLElement{:body}: 
<body class="mediawiki ltr sitedir-ltr mw-hide-empty-elt ns-0 ns-subject page-Julia_programming_language rootpage-Julia_programming_language skin-vector action-view"> 
# ... output truncated ...

我们需要的最后一个方法是getattr,它返回属性名称的值。如果元素没有定义该属性,它将引发一个KeyError

julia> getattr(dom.root, "class") 
"client-nojs" 

julia> getattr(dom.root, "href") # oops! 
ERROR: KeyError: key "href" not found 

询问<html>标签的href属性没有意义。果然,我们很快得到了一个KeyError,因为href不是这个HTMLElement的属性。

编码防御性

像之前的错误一样,当它是更大脚本的一部分时,有可能完全改变程序的执行,导致不希望的结果,甚至可能造成损失。一般来说,当程序执行过程中发生意外时,它可能会使软件处于错误状态,使得无法返回正确的值。在这种情况下,而不是继续执行并可能在整个执行堆栈中传播问题,最好通过抛出 Exception 明确通知调用代码关于这种情况。

许多函数,无论是 Julia 的核心函数还是第三方包中的函数,都很好地使用了错误抛出机制。检查你使用的函数的文档并查看它们抛出什么类型的错误是一个好习惯。在编程术语中,错误被称为异常。

就像 getattr 的情况一样,Gumbo 包的作者警告我们,尝试读取未定义的属性将导致 KeyError 异常。我们将很快学习如何通过在代码中捕获异常、获取有关问题的信息以及停止或允许异常进一步向上传播调用堆栈来处理异常。有时这是最好的方法,但我们不希望过度使用这种方法,因为以这种方式处理错误可能会消耗大量资源。处理异常比执行简单的数据完整性检查和分支要慢得多。

对于我们的项目,第一道防线是简单地检查属性是否确实定义在元素中。我们可以通过检索属性 Dict 的键并检查我们想要的键是否是集合的一部分来实现这一点。这是一个单行代码:

julia> in("href", collect(keys(attrs(dom.root)))) 
false 

显然,href 不是 <html> 标签的属性。

使用这种方法,我们可以在尝试查找属性值之前轻松地编写逻辑来检查属性的存在。

管道操作符

阅读多层嵌套函数可能会对大脑造成负担。上一个例子 collect(keys(attrs(dom.root))) 可以使用 Julia 的管道操作符 |> 重新编写以提高可读性。

例如,以下代码片段嵌套了三个函数调用,每个内部函数都成为最外层函数的参数:

julia> collect(keys(attrs(dom.root))) 
3-element Array{AbstractString,1}: 
 "class" 
 "lang" 
 "dir"

这可以通过使用管道操作符将函数链式调用重写以提高可读性。这段代码会产生完全相同的结果:

julia> dom.root |> attrs |> keys |> collect 
3-element Array{AbstractString,1}: 
 "class" 
 "lang" 
 "dir" 

|> 操作符的作用是取第一个值的输出,并将其作为下一个函数的参数。所以 dom.root |> attrs 等同于 attrs(dom.root)。不幸的是,管道操作符仅适用于单参数函数。但它在清理代码、大幅提高可读性方面仍然非常有用。

对于更高级的管道功能,你可以查看 Lazy 包,特别是 @>@>>,请参阅 github.com/MikeInnes/Lazy.jl#macros

像专业人士一样处理错误

有时候,编写防御性代码可能不是解决方案。也许你的程序的关键部分需要从网络上读取文件或访问数据库。如果由于临时网络故障无法访问资源,在没有数据的情况下,你实际上真的无能为力。

try...catch 语句

如果你确定你的代码中某些部分可能会因为超出你控制的条件(即异常条件——因此得名异常)而执行偏离轨道,你可以使用 Julia 的try...catch语句。这正是它的名字——你指示编译器尝试一段代码,如果由于问题而抛出异常,就捕获它。异常被捕获的事实意味着它不会在整个应用程序中传播。

让我们看看它是如何工作的:

julia> try 
    getattr(dom.root, "href") 
catch 
    println("The $(tag(dom.root)) tag doesn't have a 'href' attribute.") 
end 
The HTML tag doesn't have a 'href' attribute. 

在这个例子中,一旦遇到错误,try分支中的代码执行就会在 exactly 那个点停止,并且立即在catch分支中继续执行。

如果我们按如下方式修改代码片段,就会更清晰:

julia> try 
    getattr(dom.root, "href") 
    println("I'm here too") 
catch 
    println("The $(tag(dom.root)) tag doesn't have a 'href' attribute.") 
end 
The HTML tag doesn't have a 'href' attribute. 

新添加的行println("I'm here too")没有执行,正如消息没有输出的事实所证明的那样。

当然,如果没有抛出异常,事情就会变得清晰:

julia> try 
getattr(dom.root, "class") 
    println("I'm here too") 
catch 
    println("The $(tag(dom.root)) tag doesn't have a 'href' attribute.") 
end 
I'm here too 

catch构造函数接受一个可选参数,即由try块抛出的Exception对象。这允许我们检查异常并根据其属性分支我们的代码。

在我们的例子中,KeyError异常是 Julia 内置的。当我们尝试访问或删除一个不存在的元素(例如Dict中的键或HTMLElement的属性)时,会抛出KeyError异常。所有KeyError实例都有一个键属性,它提供了有关缺失数据的信息。因此,我们可以使我们的代码更加通用:

julia> try 
     getattr(dom.root, "href") 
catch ex 
    if isa(ex, KeyError)  
            println("The $(tag(dom.root)) tag doesn't have a '$(ex.key)' attribute.") 
    else  
           println("Some other exception has occurred") 
    end 
end 
The HTML tag doesn't have a 'href' attribute. 

在这里,我们将异常作为ex变量传递到catch块中。然后我们检查是否处理的是KeyError异常——如果是,我们使用这个信息通过访问ex.key字段来检索缺失的键来显示自定义错误。如果它是一种不同类型的异常,我们显示一个通用的错误消息:

julia> try 
     error("Oh my!") 
catch ex 
    if isa(ex, KeyError)  
            println("The $(tag(dom.root)) tag doesn't have a '$(ex.key)' attribute.") 
    else  
           println("Some exception has occurred") 
    end 
end 
Some exception has occurred 

finally 子句

在执行状态改变或使用文件或数据库等资源的代码中,通常需要在代码完成后进行一些清理工作(例如关闭文件或数据库连接)。这段代码通常会进入try分支——但是,如果抛出了异常会发生什么呢?

在这种情况下,finally子句就派上用场了。这可以在try之后或catch分支之后添加。finally块中的代码将被保证执行,无论是否抛出异常:

julia> try 
    getattr(dom.root, "href") 
catch ex 
    println("The $(tag(dom.root)) tag doesn't have a '$(ex.key)' attribute.") 
finally 
    println("I always get called") 
end 
The HTML tag doesn't have a 'href' attribute. 
I always get called 

没有catchfinallytry是非法的:

julia> try getattr(dom.root, "href") end syntax: try without catch or finally 

我们需要提供一个catchfinally块(或两者都提供)。

try/catch/finally块将返回最后评估的表达式,因此我们可以将其捕获到变量中:

julia> result = try 
           error("Oh no!") 
       catch ex 
           "Everything is under control" 
        end 
"Everything is under control" 

julia> result 
"Everything is under control" 

在错误上抛出异常

作为开发者,当我们的代码遇到问题且不应继续执行时,我们也有创建和抛出异常的选项。Julia 提供了一系列内置异常,涵盖了多种用例。您可以在docs.julialang.org/en/stable/manual/control-flow/#Built-in-Exceptions-1上了解它们。

为了抛出异常,我们使用名为throw的函数。例如,如果我们想复制 Gumbo 的getattr方法引发的错误,我们只需调用以下操作:

julia> throw(KeyError("href")) 
ERROR: KeyError: key "href" not found 

如果 Julia 提供的内置异常对于您的情况来说不够相关,该语言提供了一个通用的错误类型,即ErrorException。它接受一个额外的msg参数,该参数应提供更多关于错误本质的详细信息:

julia> ex = ErrorException("To err is human, but to really foul things up you need a computer.") 
ErrorException("To err is human, but to really foul things up you need a computer.") 

julia> throw(ex) 
ERROR: To err is human, but to really foul things up you need a computer. 

julia> ex.msg 
"To err is human, but to really foul things up you need a computer." 

Julia 提供了抛出ErrorException的快捷方式,即error函数:

julia> error("To err is human - to blame it on a computer is even more so.") 
ERROR: To err is human - to blame it on a computer is even more so. 

重新抛出异常

但如果我们意识到我们捕获的异常无法(或不应)由我们的代码处理怎么办?例如,假设我们预计会捕获一个可能缺失的属性,但结果我们得到了一个Gumbo解析异常。这种问题必须在上层的执行堆栈中处理,可能尝试再次获取网页并重新解析,或者为管理员记录一个错误信息。

如果我们自行throw异常,初始错误的来源(堆栈跟踪)将会丢失。对于这种情况,Julia 提供了rethrow函数,可以使用如下方式:

julia> try 
           Dict()[:foo] 
       catch ex 
           "nothing to see here" 
       end 
"nothing to see here" 

如果我们简单地自行抛出异常,这就是会发生的情况:

julia> try 
           Dict()[:foo] 
       catch ex 
           throw(ex) 
       end 
ERROR: KeyError: key :foo not found 
Stacktrace: 
 [1] top-level scope at REPL 

我们抛出KeyError异常,但异常的来源丢失;它看起来像是在我们的代码的catch块中产生的。与以下示例进行对比,其中我们使用了rethrow

julia> try 
           Dict()[:foo] 
       catch ex 
            rethrow(ex) 
       end 
ERROR: KeyError: key :foo not found 
Stacktrace: 
 [1] getindex(::Dict{Any,Any}, ::Symbol) at ./dict.jl:474 
 [2] top-level scope at REPL[140]

原始异常正在被重新抛出,而不改变堆栈跟踪。现在我们可以看到异常起源于dict.jl文件。

学习函数

在我们编写第一个完整的 Julia 程序(网络爬虫)之前,我们还需要进行另一个重要的转折。这是最后一个,我保证。

随着我们的代码变得越来越复杂,我们应该开始使用函数。REPL 由于其快速输入输出反馈循环,非常适合探索性编程,但对于任何非平凡的软件,使用函数是最佳选择。函数是 Julia 的核心部分,它促进了可读性、代码重用和性能。

在 Julia 中,一个函数是一个对象,它接受一个值元组作为参数并返回一个值:

julia> function add(x, y) 
           x + y 
       end 
add (generic function with 1 method) 

对于函数声明,还有一个紧凑的赋值形式

julia> add(x, y) = x + y 
add (generic function with 1 method) 

这种第二种形式非常适合简单的单行函数。

调用一个函数只是简单地调用它的名字并传递所需的参数:

julia> add(1, 2) 
3 

返回关键字

如果你有过编程经验,你可能会惊讶地看到,尽管我们没有在函数体中放置任何显式的return语句,调用add函数仍然可以正确地返回预期的值。在 Julia 中,函数会自动返回最后一个评估的表达式的结果。这通常是函数体中的最后一个表达式。

明确的return关键字也是可用的。使用它将导致函数立即退出,并将传递给return语句的值返回:

julia> function add(x, y) 
           return "I don't feel like doing math today" 
           x + y 
       end 
add (generic function with 1 method) 

julia> add(1, 2) 
"I don't feel like doing math today" 

返回多个值

虽然 Julia 不支持返回多个值,但它确实提供了一个非常接近实际操作的巧妙技巧。任何函数都可以返回一个元组。由于元组的构造和析构非常灵活,这种方法非常强大且易于阅读:

julia> function addremove(x, y) 
           x+y, x-y 
       end 
addremove (generic function with 1 method) 

julia> a, b = addremove(10, 5) 
(15, 5) 

julia> a 
15 

julia> b 
5 

在这里,我们定义了一个名为addremove的函数,它返回一个包含两个整数的元组。我们可以通过简单地给每个元素分配一个变量来提取元组内的值。

可选参数

函数参数可以有合理的默认值。在这种情况下,Julia 允许定义默认值。当它们被提供时,相应的参数在每次调用时不再需要显式传递:

julia> function addremove(x=100, y=10) 
           x+y, x-y 
       end 
addremove (generic function with 3 methods) 

这个函数为xy都设置了默认值。我们可以不传递任何参数来调用它:

julia> addremove() 
(110, 90) 

这个片段演示了当在函数调用时没有提供默认值时,Julia 如何使用默认值。

我们只能传递第一个参数——对于第二个参数,将使用默认值:

julia> addremove(5) 
(15, -5) 

最后,我们可以传递两个参数;所有默认值都将被覆盖:

julia> addremove(5, 1) 
(6, 4) 

关键字参数

需要长列表参数的函数可能难以使用,因为程序员必须记住期望值的顺序和类型。对于这种情况,我们可以定义接受标记参数的函数。这些被称为关键字参数

为了定义接受关键字参数的函数,我们需要在函数未标记参数列表之后添加一个分号,并跟随着一个或多个keyword=value对。实际上,我们在第二章,创建我们的第一个 Julia 应用程序时遇到了这样的函数,当时我们使用Gadfly绘制了鸢尾花数据集:

plot(iris, x=:SepalLength, y=:PetalLength, color=:Species) 

在这个例子中,xycolor都是关键字参数。

关键字参数函数的定义如下:

function thermal_confort(temperature, humidity; scale = :celsius, age = 35) 

在这里,我们定义了一个新的函数thermal_confort,它有两个必需的参数temperaturehumidity。该函数还接受两个关键字参数scaleage,分别具有默认值:celsius35。对于所有关键字参数来说,具有默认值是必要的。

调用此类函数意味着同时使用位置参数和关键字参数:

thermal_confort(27, 56, age = 72, scale = :fahrenheit)

如果没有提供关键字参数的值,将使用默认值。

关键字参数默认值是从左到右评估的,这意味着默认表达式可以引用先前的关键字参数:

function thermal_confort(temperature, humidity; scale = :celsius, age = 35, health_risk = age/100) 

注意,我们在health_risk的默认值中引用了关键字参数age

记录函数

Julia 自带强大的代码文档功能。使用方法简单——任何出现在对象之前顶级字符串都将被解释为文档(它被称为docstring)。docstring 被解释为 Markdown,因此我们可以使用标记来丰富格式。

thermal_confort函数的文档可能如下所示:

""" 
        thermal_confort(temperature, humidity; <keyword arguments>) 
Compute the thermal comfort index based on temperature and humidity. It can optionally take into account the age of the patient. Works for both Celsius and Fahrenheit.  
# Examples: 
```julia-repl

julia> thermal_confort(32, 78)

12

```py 
# Arguments 
- temperature: the current air temperature 
- humidity: the current air humidity 
- scale: whether :celsius or :fahrenheit, defaults to :celsius 
- age: the age of the patient 
""" 
function thermal_confort(temperature, humidity; scale = :celsius, age = 35)

现在,我们可以通过使用 REPL 的帮助模式来访问我们函数的文档:

help?> thermal_confort 

输出如下所示:

非常有用,不是吗?文档字符串也可以用来为你的 Julia 项目生成完整的文档,这需要外部包的帮助,这些包构建完整的 API 文档作为独立的网站、Markdown 文档、PDF 文档等。我们将在第十一章中看到如何做到这一点,创建 Julia 包

编写基本的网络爬虫 – 开始

现在我们已经准备好编写我们的第一个完整的 Julia 程序——一个简单的网络爬虫。这个迭代将向 Julia 的维基百科页面发起请求,解析它并提取所有内部 URL,将它们存储在Array中。

设置我们的项目

我们需要做的第一件事是设置一个专用项目。这是通过使用Pkg来完成的。这是一个非常重要的步骤,因为它允许我们有效地管理和版本化程序所依赖的包。

首先,我们需要为我们的软件创建一个文件夹。创建一个——让我们称它为WebCrawler。我会使用 Julia 来做这件事,但你可以按照你喜欢的任何方式来做:

julia> mkdir("WebCrawler") 
"WebCrawler" 

julia> cd("WebCrawler/") 

现在我们可以使用Pkg来添加依赖项。当我们开始一个新的项目时,我们需要初始化它。这是通过以下方式实现的:

pkg> activate .

这告诉Pkg我们想要在当前项目中管理依赖项,而不是全局操作。你会注意到光标已经改变,指示了活动项目的名称,WebCrawler

(WebCrawler) pkg> 

到目前为止,我们安装的所有其他包都在全局环境中,这可以通过(v1.0)光标来指示:

(v1.0) pkg> 

(v1.0)是全局环境,标记了当前安装的 Julia 版本。如果你在不同的 Julia 版本上尝试这些示例,你会得到不同的标签。

如果我们检查状态,我们会看到在项目的环境中还没有安装任何包:

(WebCrawler) pkg> st 
    Status `Project.toml` 

我们软件将有两个依赖项——HTTPGumbo。是时候添加它们了:

(WebCrawler) pkg> add HTTP 
(WebCrawler) pkg> add Gumbo 

现在我们可以创建一个新的文件来存放我们的代码。让我们称它为webcrawler.jl。它可以由 Julia 创建:

julia> touch("webcrawler.jl") 
"webcrawler.jl" 

编写 Julia 程序

与我们在 REPL 和 IJulia 笔记本中的先前工作不同,这将是一个独立的程序:所有逻辑都将放在这个 webcrawler.jl 文件中,准备好后,我们将使用 julia 二进制文件来执行它。

Julia 文件是从上到下解析的,所以我们需要按正确的顺序提供所有必要的指令(使用语句、变量初始化、函数定义等)。我们将基本上将本章中迄今为止所采取的所有步骤压缩到这个小程序中。

为了使事情更简单,最好使用一个完整的 Julia 编辑器。在 Atom/Juno 或 Visual Studio Code(或你喜欢的任何编辑器)中打开 webcrawler.jl

我们想要做的第一件事是通知 Julia 我们计划使用 HTTPGumbo 包。我们可以写一个单独的 using 语句并列出多个依赖项,用逗号分隔:

using HTTP, Gumbo 

此外,我们决定我们想要使用 Julia 的维基百科页面来测试我们的爬虫。链接是 en.wikipedia.org/wiki/Julia_(programming_language)。将此类配置值存储在常量中而不是在整个代码库中散布 魔法字符串 是一种好的做法:

const PAGE_URL = "https://en.wikipedia.org/wiki/Julia_(programming_language)" 

我们还说过我们想要将所有链接存储在一个 Array 中——让我们也设置一下。记住,Julia 中的常量主要与类型相关,所以在我们声明后向数组中推入值是没有问题的:

const LINKS = String[] 

在这里,我们将 LINKS 常量初始化为一个空的 String 数组。记法 String[]Array{String,1}()Vector{String}() 产生相同的结果。它基本上表示空的 Array 字面量 [] 加上 Type 约束 String——创建一个 String 值的 Vector

接下来的步骤是——获取页面,寻找成功的响应(状态 200),然后检查头信息以查看是否收到了消息体(Content-Length 大于零)。在这个第一次迭代中,我们只需要做一次。但向前看,对于游戏的最终版本,我们可能需要在每个游戏会话中重复这个过程多达六次(因为会有多达六度维基百科,所以我们需要爬取多达六个页面)。我们能做的最好的事情是编写一个通用函数,它只接受页面 URL 作为其唯一参数,获取页面,执行必要的检查,并在可用的情况下返回消息体。让我们把这个函数叫做 fetchpage

function fetchpage(url)
    response = HTTP.get(url)
    if response.status == 200 && parse(Int, Dict(response.headers)["Content-Length"]) > 0
        String(response.body)
    else
        ""
    end
end   

首先,我们调用 HTTP.get(url),将 HTTP.Messages.Response 对象存储在 response 变量中。然后我们检查响应状态是否为 200,以及 Content-Length 头是否大于 0。如果是,我们将消息体读取到字符串中。如果不是,我们返回一个空字符串 "" 来表示空体。这里有很多 if 条件——看起来是时候我们仔细看看条件 if/else 语句了,因为它们真的很重要。

if、elseif 和 else 语句的条件评估

所有程序,除了最基础的,都必须能够评估变量并根据它们的当前值执行不同的逻辑分支。条件评估允许根据布尔表达式的值执行(或不执行)代码的一部分。Julia 提供了 ifelseifelse 语句来编写条件表达式。它们的工作方式如下:

julia> x = 5 
5 

julia> if x < 0 
           println("x is a negative number") 
      elseif x > 0 
           println("x is a positive number greater than 0") 
      else  
           println("x is 0") 
      end 
x is a positive number greater than 0 

如果条件 x < 0 为真,则其基础块将被评估。如果不为真,则表达式 x > 0 作为 elseif 分支的一部分被评估。如果为真,则评估其对应的块。如果两个表达式都不为真,则评估 else 块。

elseifelse 块是可选的,我们可以使用任意数量的 elseif 块。在 ifelseifelse 构造中的条件会被评估,直到第一个返回 true。然后评估相关的块,并返回其最后计算出的值,退出条件评估。因此,Julia 中的条件语句也会返回一个值——所选择分支中最后执行语句的值。以下代码展示了这一点:

julia> status = if x < 0 
                         "x is a negative number" 
                  elseif x > 0 
                         "x is a positive number greater than 0" 
                   else  
                         "x is 0" 
                   end 
"x is a positive number greater than 0" 

julia> status 
"x is a positive number greater than 0" 

最后,非常重要的一点是要记住,if 块不会引入局部作用域。也就是说,在其中定义的变量在块退出后仍然可访问(当然,前提是相应的分支已被评估):

julia> status = if x < 0 
            "x is a negative number" 
       elseif x > 0 
            y = 20 
            "x is a positive number greater than 0" 
       else  
            "x is 0" 
       end 
"x is a positive number greater than 0" 

julia> y 
20 

我们可以看到,在 elseif 块中初始化的 y 变量在条件表达式外部仍然可访问。

如果我们声明变量为 local,则可以避免这种情况:

julia> status = if x < 0 
            "x is a negative number" 
       elseif x > 0 
            local z = 20 
            "x is a positive number greater than 0" 
       else  
            "x is 0" 
       end 
"x is a positive number greater than 0" 

julia> z 
UndefVarError: z not defined

当声明为 local 时,变量不再会从 if 块中 泄漏

三元运算符

可以使用三元运算符 ? : 表达 ifthenelse 类型的条件。其语法如下:

x ? y : z 

如果 x 为真,则评估表达式 y;否则,评估 z。例如,考虑以下代码:

julia> x = 10 
10 

julia> x < 0 ? "negative" : "positive" 
"positive" 

短路评估

Julia 提供了一种更简洁的评估类型——短路评估。在一系列由 &&|| 操作符连接的布尔表达式中,只评估最小数量的表达式——只要足以确定整个链的最终布尔值。我们可以利用这一点来返回某些值,具体取决于什么被评估。例如:

julia> x = 10 
10 

julia> x > 5 && "bigger than 5" "bigger than 5"

在表达式 A && B 中,只有当 A 评估为 true 时,第二个表达式 B 才会进行评估。在这种情况下,整个表达式的返回值是子表达式 B 的返回值,在先前的例子中是 大于 5

相反,如果 A 评估为 false,则 B 完全不会进行评估。因此,请注意——整个表达式将返回一个 false 布尔值(而不是字符串!):

julia> x > 15 && "bigger than 15" 
false 

同样的逻辑适用于逻辑 or 操作符,||

julia> x < 5 || "greater than 5"
"greater than 5"

在表达式 A || B 中,只有当 A 评估为 false 时,第二个表达式 B 才会被评估。当第一个子表达式评估为 true 时,同样的逻辑也适用;true 将是整个表达式的返回值:

julia> x > 5 || "less than 5" 
true 

注意运算符优先级

有时短路表达式可能会让编译器困惑,导致错误或意外结果。例如,短路表达式经常与赋值操作一起使用,如下所示:

julia> x > 15 || message = "That's a lot" 

这将因为 syntax: invalid assignment location "(x > 15) || message" 错误而失败,因为 = 赋值运算符的优先级高于逻辑 or||。可以通过使用括号来显式控制评估顺序来轻松修复:

julia> x > 15 || (message = "That's a lot") 
"That's a lot" 

这是一件需要记住的事情,因为它是初学者常见的错误来源。

继续爬虫的实现

到目前为止,你的代码应该看起来像这样:

using HTTP, Gumbo 

const PAGE_URL = "https://en.wikipedia.org/wiki/Julia_(programming_language)" 
const LINKS = String[] 

function fetchpage(url) 
  response = HTTP.get(url) 
  if response.status == 200 && parse(Int, Dict(response.headers)["Content-Length"]) > 0 
    String(response.body) 
  else 
    "" 
  end 
end 

现在应该很清楚,if/else 语句返回的是响应体或空字符串。由于这是 fetchpage 函数内部最后评估的代码片段,这个值也成为了整个函数的返回值。

所有都很好,我们现在可以使用 fetchpage 函数获取维基百科页面的 HTML 内容并将其存储在 content 变量中:

content = fetchpage(PAGE_URL)  

如果获取操作成功且 content 不是空字符串,我们可以将 HTML 字符串传递给 Gumbo 以构建 DOM。然后,我们可以遍历此 DOM 的 root 元素的子元素并查找链接(使用 a 标签选择器)。对于每个元素,我们想要检查 href 属性,并且只有当它指向另一个维基百科页面时才存储其值:

if ! isempty(content) 
  dom = Gumbo.parsehtml(content) 
  extractlinks(dom.root) 
end

提取链接的函数是:

function extractlinks(elem) 
  if  isa(elem, HTMLElement) &&  
      tag(elem) == :a && in("href", collect(keys(attrs(elem)))) 
        url = getattr(elem, "href") 
        startswith(url, "/wiki/") && push!(LINKS, url) 
  end 

  for child in children(elem) 
    extractlinks(child) 
  end 
end 

在这里,我们声明一个 extractlinks 函数,它接受一个名为 elemGumbo 元素作为其唯一参数。然后我们检查 elem 是否是一个 HTMLElement,如果是,我们检查它是否对应于一个链接标签(表示 <a> HTML 标签的 Julia Symbol :a)。然后我们检查该元素是否定义了 href 属性,以避免出现 KeyError。如果一切正常,我们获取 href 元素的值。最后,如果 href 的值是一个内部 URL——即以 /wiki/ 开头的 URL——我们将它添加到我们的 LINKS 数组 中。

一旦我们检查完元素中的链接,我们检查它是否包含其他嵌套的 HTML 元素。如果包含,我们想要对其每个子元素重复相同的流程。这就是最终 for 循环所做的。

剩下的唯一任务是显示我们文件末尾填充好的 LINKS 数组。由于一些链接可能会在页面中出现多次,让我们确保通过使用 unique 函数将 数组 精简为仅包含唯一元素:

display(unique(LINKS))  

现在,我们可以通过在存储文件的文件夹中打开终端来执行此脚本。然后运行——$ julia webcrawler.jl

链接很多,所以输出将会相当长。以下是列表的顶部:

 $ julia webcrawler.jl 
440-element Array{String,1}: 
 "/wiki/Programming_paradigm" 
 "/wiki/Multi-paradigm_programming_language" 
 "/wiki/Multiple_dispatch" 
 "/wiki/Object-oriented_programming" 
 "/wiki/Procedural_programming" 
# ... output truncated ... 

通过查看输出,我们会注意到在第一次优化中,一些链接指向特殊的维基百科页面——包含如 File:, /Category:, /Help:, /Special: 等部分的页面。因此,我们可以直接跳过所有包含列,即 :, 的 URL,因为这些不是文章,对我们游戏没有用。

要做到这一点,请查找以下行:

startswith(url, "/wiki/") && push!(LINKS, url)

将前面的行替换为以下内容:

startswith(url, "/wiki/") && ! occursin(":", url) && push!(LINKS, url) 

如果你现在运行程序,你应该会看到来自 Julia 维基百科页面的所有链接到其他维基百科文章的 URL 列表。

这是完整的代码:

using HTTP, Gumbo 

const PAGE_URL = "https://en.wikipedia.org/wiki/Julia_(programming_language)" 
const LINKS = String[] 

function fetchpage(url) 
  response = HTTP.get(url) 
  if response.status == 200 && parse(Int, Dict(response.headers)["Content-Length"]) > 0 
    String(response.body) 
  else 
    "" 
  end 
end 

function extractlinks(elem) 
  if  isa(elem, HTMLElement) && tag(elem) == :a && in("href", collect(keys(attrs(elem)))) 
        url = getattr(elem, "href") 
        startswith(url, "/wiki/") && ! occursin(":", url) && push!(LINKS, url) 
  end 

  for child in children(elem) 
    extractlinks(child) 
  end 
end 

content = fetchpage(PAGE_URL) 

if ! isempty(content) 
  dom = Gumbo.parsehtml(content)
  extractlinks(dom.root) 
end

display(unique(LINKS)) 

摘要

网络爬虫是数据挖掘的关键组成部分,Julia 提供了一个强大的工具箱来处理这些任务。在本章中,我们讨论了构建网络爬虫的基本原理。我们学习了如何使用 Julia 网络客户端请求网页以及如何读取响应,如何使用 Julia 强大的 Dict 数据结构读取 HTTP 信息,如何通过处理错误来使我们的软件更具鲁棒性,如何通过编写函数并对其进行文档化来更好地组织我们的代码,以及如何使用条件逻辑来做出决策。

带着这些知识,我们构建了我们网络爬虫的第一个版本。在下一章中,我们将对其进行改进,并使用它来提取即将推出的 Wiki 游戏的数据。在这个过程中,我们将更深入地了解语言,学习类型、方法和模块,以及如何与关系型数据库交互。

第四章:构建 Wiki 游戏网络爬虫

哇,第三章,设置 Wiki 游戏,真是一次刺激的旅程!为我们维基百科游戏打下基础带我们经历了一场真正的学习之旅。在快速回顾了网络和网页的工作原理之后,我们深入研究了语言的关键部分,研究了字典数据结构和相应的数据类型、条件表达式、函数、异常处理,甚至非常实用的管道操作符(|>)。在这个过程中,我们构建了一个简短的脚本,该脚本使用几个强大的第三方包 HTTPGumbo 从维基百科请求网页,将其解析为 HTML DOM,并从页面中提取所有内部链接。我们的脚本是一个完整的 Julia 项目的一部分,该项目使用 Pkg 来高效管理依赖项。

在本章中,我们将继续开发我们的游戏,实现完整的流程和游戏玩法。即使你不是经验丰富的开发者,也容易想象这样一个简单的游戏最终会有多个逻辑部分。我们可能有一个用于维基百科页面爬虫的模块,一个用于游戏本身,还有一个用于 UI(我们将在下一章中创建的 Web 应用)。将问题分解成更小的部分总是会使解决方案更简单。而且,这在编写代码时尤其如此——拥有小型、专业的函数,按责任分组,使得软件更容易推理、开发、扩展和维护。在本章中,我们将学习 Julia 的代码结构化构造,并讨论语言的一些更多关键元素:类型系统、构造函数、方法和多态。

在本章中,我们将涵盖以下主题:

  • 维基百科的六度分隔,游戏玩法

  • 使用模块组织我们的代码和从多个文件加载代码(所谓的 mixin 行为

  • 类型以及类型系统,这是 Julia 灵活性和性能的关键

  • 构造函数,特殊函数,允许我们创建我们类型的实例

  • 方法和多态,这是语言最重要的方面之一

  • 与关系型数据库交互(特别是 MySQL)

我希望你已经准备好深入研究了。

技术要求

Julia 的包生态系统正在持续发展中,并且每天都有新的包版本发布。大多数时候,这是一个好消息,因为新版本带来了新功能和错误修复。然而,由于许多包仍然处于测试版(版本 0.x),任何新版本都可能引入破坏性更改。因此,书中展示的代码可能会停止工作。为了确保你的代码能够产生与书中描述相同的结果,建议使用相同的包版本。以下是本章使用的外部包及其具体版本:

Cascadia@v0.4.0
Gumbo@v0.5.1
HTTP@v0.7.1
IJulia@v1.14.1
JSON@v0.20.0
MySQL@v0.7.0

为了安装特定版本的包,你需要运行:

pkg> add PackageName@vX.Y.Z 

例如:

pkg> add IJulia@v1.14.1

或者,你可以通过下载本章提供的 Project.toml 文件并使用pkg>实例化来安装所有使用的包:

julia> download("https://raw.githubusercontent.com/PacktPublishing/Julia-Programming-Projects/master/Chapter04/Project.toml", "Project.toml")
pkg> activate . 
pkg> instantiate

维基百科六度分隔游戏,游戏玩法

正如我们在上一章中看到的,维基百科六度分隔游戏是对六度分隔理论概念的戏仿——即所有生物(以及世界上几乎所有事物)彼此之间相隔六步或更少。例如,一个“朋友的朋友”的链条可以在最多六步内连接任何两个人。

对于我们自己的游戏,玩家的目标是连接任意两个给定的维基百科文章,通过六个或更少的其他维基百科页面。为了确保问题有解决方案(六度分隔理论尚未得到证实)并且确实存在从起始文章到结束文章的路径,我们将预先爬取完整路径。也就是说,我们将从一个随机的维基百科页面开始,这将是我们的起点,然后通过多个页面链接到我们的目的地,即结束文章。选择下一个链接页面的算法将是最简单的——我们只需随机选择任何内部链接。

为了使事情更有趣,我们还将提供难度设置——简单、中等或困难。这将影响起始页面和结束页面之间的距离。对于简单游戏,它们相隔两页,对于中等,四页,对于困难,六页。当然,这个逻辑并不非常严格。是的,直观上,我们可以这样说,相隔更远的文章之间关系较少,更难连接。但,玩家也可能找到更短的路径。尽管如此,我们不会担心这一点。

游戏还将允许玩家在最多步数内找不到解决方案时返回。

最后,如果玩家放弃,我们将添加一个选项来显示解决方案——从起始文章到目标文章的路径。

这听起来很令人兴奋——让我们写一些代码吧!

一些额外的要求

为了跟随本章,你需要以下内容:

  • 一个有效的 Julia 安装

  • 有互联网连接

  • 文本编辑器

组织我们的代码

到目前为止,我们主要在 REPL 中进行编码。最近,在上一章中,我们开始更多地依赖 IDE 来快速创建简短的 Julia 文件。

但是,随着我们的技能集的增长和越来越雄心勃勃的项目的发展,我们程序的复杂性也将增长。这反过来又会导致代码行数、逻辑和文件的增加——以及维护和理解所有这些的困难增加。正如著名的编码公理所说,代码被阅读的次数远多于被编写的次数——因此我们需要相应地规划。

每种语言在代码组织方面都有自己的哲学和工具集。在 Julia 中,我们有文件、模块和包。我们将在下一章中了解所有这些。

使用模块来驯服我们的代码

模块将相关的函数、变量和其他定义组合在一起。但,它们不仅仅是组织单元——它们是语言结构,可以理解为变量工作空间。它们允许我们定义变量和函数,而不用担心名称冲突。Julia 的 Module 是语言的基础之一——一个关键的架构和逻辑实体,有助于使代码更容易开发、理解和维护。我们将通过围绕模块构建我们的游戏来充分利用模块。

模块是通过使用 module <<name>>...end 结构定义的:

module MyModule 
# code here 
end

让我们开始一个新的 REPL 会话,看看一些例子。

假设我们想要编写一个函数来检索一个随机的维基百科页面——这是我们的游戏功能之一。我们可以称这个函数为 rand

如您所怀疑的,创建随机的 东西 是一项相当常见的任务,所以我们不是第一个考虑它的人。您可以亲自查看。在 REPL 中尝试这个:

julia> rand 
rand (generic function with 56 methods) 

结果,已经定义了 56 个 rand 方法。

这将使得添加我们自己的变体变得困难:

julia> function rand() 
           # code here 
       end 
error in method definition: function Base.rand must be explicitly imported to be extended 

我们尝试定义一个新的 rand 方法时引发了错误,因为它已经被定义并加载。

很容易看出,当我们选择函数名称时,这可能导致一个噩梦般的场景。如果所有定义的名称都生活在同一个工作空间中,我们就会陷入无休止的名称冲突,因为我们将耗尽为我们的函数和变量提供的相关名称。

Julia 的模块允许我们定义独立的工作空间,提供一种封装级别,将我们的变量和函数与其他人的变量和函数分开。通过使用模块,我们可以消除名称冲突。

模块是在 module...end 语言结构中定义的。尝试这个例子(在 REPL 中),我们在名为 MyModule 的模块中定义我们的 rand 函数:

julia> module MyModule 

      function rand() 
           println("I'll get a random Wikipedia page") 
      end 

      end 
Main.MyModule 
MyModule—and within it, a function called rand. Here, MyModule effectively encapsulates the rand function, which no longer clashes with Julia's Base.rand.

如您从其全名 Main.MyModule 中所见,我们新创建的模块实际上是在另一个名为 Main 的现有模块中添加的。这个模块 Main 是 REPL 中执行代码的默认模块。

为了访问我们新定义的函数,我们需要在 MyModule 中引用它,通过 点号连接

julia> MyModule.rand() 
I'll get a random wikipedia page 

定义模块

由于模块是为与较大的代码库一起使用而设计的,它们并不适合 REPL。因为一旦它们被定义,我们就不能通过额外的定义来扩展它们,我们必须重新输入并重新定义整个模块,因此最好使用一个完整的编辑器。

让我们创建一个新的文件夹来存放我们的代码。在其中,我们希望创建一个名为 modules/ 的新文件夹。然后,在 modules/ 文件夹中,添加三个文件—Letters.jlNumbers.jlmodule_name.jl

包含 Julia 代码的文件按照惯例使用 .jl 文件扩展名。

Julia 的高效 REPL 会话

为什么不使用 Julia 的文件管理能力来设置这个文件结构?让我们看看如何做这件事,因为它在我们的日常工作中会很有用。

记住,你可以在 REPL 中输入 ;,在行的开头,以触发 shell 模式。你的光标将从 julia> 变为 shell> 以确认上下文的变化。在 IJulia/Jupyter 中,你必须使用 ; 作为单元格中代码的前缀,以便在 shell 模式下执行。

现在,我们可以执行以下操作:

shell> mkdir modules # create a new dir called "modules" 
shell> cd modules # switch to the "modules" directory 

不要忘记 Julia 的 shell 模式会像它们直接在 OS 终端中运行一样调用命令——因此被调用的二进制文件必须存在于该平台上。mkdircd 在所有主要操作系统上都受支持,所以我们很安全。但是,当涉及到创建文件时,我们就无能为力了——在 Windows 上不可用 touch 命令。不过,没问题——在这种情况下,我们只需要调用具有相同名称的 Julia 函数。这将以平台无关的方式程序化地创建文件:

julia> for f in ["Letters.jl", "Numbers.jl", "module_name.jl"] 
           touch(f) 
       end 

如果你想要确保文件已被创建,请使用 readdir

julia> readdir() 
3-element Array{String,1}: 
 "Letters.jl" 
 "Numbers.jl" 
 "module_name.jl" 

请确保文件名与指示的完全一致,注意大小写。

Letters.jl in whatever default editor you have configured:
julia> edit("Letters.jl")  

如果默认编辑器不是你最喜欢的 Julia IDE,你可以通过设置 JULIA_EDITORVISUALEDITOR 环境变量之一来更改它,指向你选择的编辑器。例如,在我的 Mac 上,我可以使用以下命令获取 Atom 编辑器的路径:

shell> which atom 
/usr/local/bin/atom 

然后,我可以将 JULIA_EDITOR 设置如下:

julia> ENV["JULIA_EDITOR"] = "/usr/local/bin/atom" 

这三个变量有略微不同的用途,但在这个情况下,设置任何一个都将产生相同的效果——更改当前 Julia 会话的默认编辑器。不过,请记住,它们有不同的 权重,其中 JULIA_EDITOR 优先于 VISUAL,而 VISUAL 优先于 EDITOR

设置我们的模块

让我们首先编辑 Letters.jl,使其看起来像这样:

module Letters 

using Random 

export randstring 

const MY_NAME = "Letters" 

function rand() 
  Random.rand('A':'Z') 
end 

function randstring() 
  [rand() for _ in 1:10] |> join 
end 

include("module_name.jl") 

end 

在这里,我们定义了一个名为 Letters 的模块。在其中,我们添加了一个 rand 函数,该函数使用 Julia 的 Random.rand 来返回一个 AZ 之间的随机字母,形式为一个 Char。接下来,我们添加了一个名为 Letters.randstring 的函数,该函数返回一个由 10 个随机字符组成的 String。这个字符串是通过一个 Char[] 数组推导式(在 Julia 中 _ 变量名是合法的,并且按照惯例,它表示一个值未使用的变量)生成的,然后通过管道输入到 join 函数中,以返回字符串结果。

请注意,这是一个生成随机字符串的过于复杂的方法,因为 Julia 提供了 Random.randstring 函数。但是,在这个阶段,重要的是要抓住每一个机会来练习编写代码,而且我并不想浪费使用 Julia 的推导语法和管道操作符的机会。熟能生巧!

将我们的注意力转向代码的第一行,我们声明我们将 using Random——并指示编译器通过 export randstring 使 randstring 公开。最后,我们还声明了一个名为 MY_NAME 的常量,它指向 Letters 字符串(即模块本身的名称)。

模块的最后一行,include("module_name.jl"),将 module_name.jl 的内容加载到 Letters 中。include 函数通常用于交互式加载源代码,或将分成多个源文件的包中的文件组合在一起——我们很快就会看到它是如何工作的。

接下来,让我们编辑 Number.jl。它将有一个类似的 rand 函数,该函数将返回一个介于 11_000 之间的随机 Integer。它导出 halfrand 函数,该函数从 rand 获取一个值并将其除以 2。我们将除法的结果传递给 floor 函数,该函数将将其转换为最接近的小于或等于的值。而且,就像 Letters 一样,它还包括 module_name.jl

module Numbers 

using Random

export halfrand

const MY_NAME = "Numbers"

function rand() 
  Random.rand(1:1_000) 
end
function halfrand() 
  floor(rand() / 2) 
end

include("module_name.jl")
end 

因此,对于这两个模块,我们定义了一个 MY_NAME 常量。我们将通过编辑 module_name.jl 文件来引用它,使其看起来像这样:

function myname() 
  MY_NAME 
end 

代码返回常量的对应值,这取决于我们包含 module_name.jl 文件的实际模块。这说明了 Julia 的 mixin 行为,其中包含的代码表现得就像它被直接写入包含文件中一样。我们将在下一节中看到它是如何工作的。

模块引用

尽管我们现在只是正式讨论模块,但我们一直在使用它们。我们多次使用的 using 语句将其参数作为模块名称。这是一个关键的语言结构,告诉编译器将模块的定义引入当前作用域。在 Julia 中引用其他模块中定义的函数、变量和类型是编程的常规部分——例如,访问第三方包提供的功能,围绕通过 using 将其主模块引入作用域。但是,using 并不是 Julia 武器库中的唯一工具。我们还有几个其他命令可供使用,例如 importincludeexport

using 指令允许我们引用其他模块导出的函数、变量、类型等。这告诉 Julia 使模块的导出定义在当前工作区中可用。如果这些定义是由模块的作者导出的,我们可以调用它们而无需在它们前面加上模块的名称(在函数名称前加上模块名称表示完全限定名称)。但是,请注意,这是一把双刃剑——如果两个使用的模块导出了具有相同名称的函数,仍然必须使用完全限定名称来访问这些函数——否则 Julia 将抛出异常,因为它不知道我们指的是哪个函数。

至于 import,它在某种程度上是相似的,因为它也将另一个模块的定义引入作用域。但是,它在两个重要方面有所不同。首先,调用 import MyModule 仍然需要在定义前加上模块的名称,从而避免潜在的名称冲突。其次,如果我们想用新方法扩展其他模块中定义的函数,必须使用 import

另一方面,include 在概念上是不同的。它用于将一个文件的内容评估到当前上下文中(即当前模块的 全局 作用域)。这是一种通过提供类似 mixin 的行为来重用代码的方法,正如我们之前所看到的。

被包含的文件在模块的全局作用域中评估的事实是一个非常重要的点。这意味着,即使我们在函数体中包含一个文件,文件的内容也不会在函数的作用域内评估,而是在模块的作用域内评估。为了看到这一点,让我们在我们的 modules/ 文件夹中创建一个名为 testinclude.jl 的文件。编辑 testinclude.jl 并添加以下代码行:

somevar = 10

现在,如果你在 REPL 或 IJulia 中运行以下代码,你就能明白我的意思:

julia> function testinclude() 
             include("testinclude.jl") 
             println(somevar) 
       end 

julia> testinclude() 
10 

显然,一切正常。testinclude.jl 文件被包含进来,somevar 变量被定义了。然而,somevar 并不是在 testinclude 函数中创建的,而是在 Main 模块中的全局变量。我们可以很容易地看到这一点,因为我们可以直接访问 somevar 变量:

julia> somevar 
10 

请记住这种行为,因为它可能导致在全局作用域中暴露变量时出现难以理解的错误。

最后,模块的作者使用 export 来暴露定义,就像公共接口一样。正如我们所见,导出的函数和变量是通过模块的用户通过 using 引入作用域的。

设置 LOAD_PATH

让我们看看一些示例,这些示例说明了在处理模块时作用域规则。请打开一个新的 Julia REPL。

我们在前几章中多次看到了 using 语句,现在我们理解了它的作用——就是将另一个模块及其定义(变量、函数、类型)引入作用域。让我们用我们新创建的模块来试一试:

julia> using Letters 
ERROR: ArgumentError: Package Letters not found in current path: 
- Run `Pkg.add("Letters")` to install the Letters package. 

哎呀,出现了一个异常!Julia 告诉我们它不知道在哪里找到 Letters 模块,并建议我们使用 Pkg.add("Letters") 来安装它。但是,由于 Pkg.add 只与已注册的包一起工作,而我们还没有将我们的模块发布到 Julia 的注册表中,这不会有所帮助。结果是我们只需要告诉 Julia 我们代码的位置。

当被要求通过 using 将一个模块引入作用域时,Julia 会检查一系列路径以查找相应的文件。这些查找路径存储在一个名为 LOAD_PATHVector 中——我们可以通过使用 push! 函数将我们的 modules/ 文件夹添加到这个集合中:

julia> push!(LOAD_PATH, "modules/") 
4-element Array{String,1}: 
 "@" 
 "@v#.#" 
 "@stdlib" 
 "modules/" 

你的输出可能会有所不同,但重要的是在调用 push! 之后,LOAD_PATH 集合现在有一个额外的元素,表示 modules/ 文件夹的路径。

为了让 Julia 能够将模块的名称与其对应的文件匹配,文件必须与模块具有完全相同的名称,加上 .jl 扩展名。一个文件可以包含多个模块,但 Julia 将无法通过文件名自动找到额外的模块。

关于模块命名的命名约定是使用驼峰式命名法。因此,我们最终会在名为Letters.jl的文件中定义一个名为Letters的模块,或者在一个名为WebSockets.jl的文件中定义一个名为WebSockets的模块。

使用using加载模块

现在我们已经将我们的文件夹添加到LOAD_PATH中,我们就可以使用我们的模块了:

julia> using Letters 

到目前为止,发生了两件事:

  • 所有导出的定义现在都可以在 REPL 中直接调用,在我们的例子中,是randstring

  • 未导出的定义可以通过Lettersdotting into来访问——例如,Letters.rand()

让我们试试:

julia> randstring() # has been exported and is directly accessible 
"TCNXFLUOUU" 
julia> myname() # has not been exported so it's not available in the REPLERROR: UndefVarError: myname not defined
 julia> Letters.myname() # but we can access it under the Letters namespace 
"Letters"
 julia> Letters.rand() # does not conflict with Base.rand 
'L': ASCII/Unicode U+004c (category Lu: Letter, uppercase) 

我们可以使用names函数查看模块导出了什么:

julia> names(Letters) 
2-element Array{Symbol,1}: 
 :Letters 
 :randstring 

如果我们想获取一个模块的所有定义,无论是否导出,names函数接受一个名为all的第二个参数,一个Boolean

julia> names(Letters, all = true) 
11-element Array{Symbol,1}: 
 # output truncated 
 :Letters 
 :MY_NAME 
 :eval 
 :myname 
 :rand 
 :randstring 

我们可以轻松地识别我们定义的变量和函数。

正如我们所见,例如,myname并没有直接引入作用域,因为它在Letters中没有导出。但结果是,如果我们明确告诉 Julia 使用该函数,我们仍然可以得到类似导出的行为:

julia> using Letters: myname
julia> myname() # we no longer need to "dot into" Letters.myname() 
"Letters" 

如果我们想直接将同一模块中的多个定义引入作用域,我们可以传递一个以逗号分隔的名称列表:

julia> using Letters: myname, MY_NAME 

使用import加载模块

现在,让我们看看import函数的效果,使用Numbers

julia> import Numbers
julia> names(Numbers) 
2-element Array{Symbol,1}: 
 :Numbers 
 :halfrand
julia> halfrand() 
ERROR: UndefVarError: halfrand not defined 

我们可以看到,与using不同,import函数不会将导出的定义引入作用域

然而,显式导入一个定义本身会将其直接引入作用域,不考虑它是否被导出:

julia> import Numbers.halfrand, Numbers.MY_NAME 

这段代码等同于以下代码:

julia> import Numbers: halfrand, MY_NAME 

julia> halfrand() 
271.0 

使用include加载模块

当开发独立的程序,如我们现在正在做的,操作LOAD_PATH效果很好。但是,对于包开发者来说,这种方法不可用。在这种情况下——以及所有由于某种原因使用LOAD_PATH不是选项的情况——加载模块的常见方式是通过包含它们的文件。

例如,我们可以将我们的Letters模块包含在 REPL 中,如下所示(启动一个新的 REPL 会话):

julia> include("modules/Letters.jl") 
Main.Letters 

这将读取并评估当前作用域中modules/Letters.jl文件的内容。结果,它将在我们的当前模块Main中定义Letters模块。但是,这还不够——在这个阶段,Letters中的任何定义都没有被导出:

julia> randstring() 
ERROR: UndefVarError: randstring not defined 

我们需要将它们引入作用域:

julia> using Letters 
ERROR: ArgumentError: Package Letters not found in current path: 
- Run `Pkg.add("Letters")` to install the Letters package.

别再了!刚才发生了什么?当使用include与模块时,这是一个重要的区别。正如我们刚才说的,Letters模块被包含在当前模块Main中,因此我们需要相应地引用它:

julia> using Main.Letters 

julia> randstring() 
"QUPCDZKSAH" 

我们也可以通过使用相对路径来引用这种嵌套模块层次结构。例如,一个点.代表current module。因此,之前的Main.Letters嵌套可以表示为.Letters——这正是同一件事:

julia> using .Letters 

类似地,我们可以使用两个点..来引用父模块,三个点用于父模块的父模块,依此类推。

模块嵌套

正如我们所看到的,有时我们程序的逻辑会要求一个模块必须成为另一个模块的一部分,从而有效地嵌套它们。我们在开发自己的包时特别喜欢使用这种方法。组织包的最佳方式是暴露一个顶层模块,并在其中包含所有其他定义(函数、变量和其他模块)(以封装功能)。一个例子应该有助于澄清这些内容。

让我们做一个改变——在Letters.jl文件中,在说include("module_name.jl")的行下面,继续添加另一行——include("Numbers.jl")

通过这个变化,Numbers模块将实际上在Letters模块内部定义。为了访问嵌套模块的功能,我们需要点进到必要的深度:

julia> using .Letters 

julia> Letters.Numbers.halfrand() 
432.5 

设置我们游戏的架构

让我们为我们的游戏找一个家——创建一个名为sixdegrees/的新文件夹。我们将用它来组织我们的游戏文件。每个文件将包含一个模块,每个模块将打包相关的功能。我们将利用 Julia 的自动加载功能,这意味着每个模块的文件名将与模块的名称相同,加上.jl扩展名。

然而,一旦我们进入sixdegrees/文件夹,我们首先需要通过Pkg初始化我们的项目——这样我们就可以使用 Julia 的依赖项管理功能:

julia> mkdir("sixdegrees") 
"sixdegrees" 

julia> cd("sixdegrees/") 

julia> ] # go into pkg mode 

(v1.0) pkg> activate . 

(sixdegrees) pkg> 

我们将使用HTTPGumbo包,所以在处理依赖项时,现在添加它们是个好主意:

(sixdegrees) pkg> add HTTP Gumbo 

接下来我们需要的是一个用于存放与维基百科相关代码的容器——一个封装了请求文章和提取内部 URL 功能的模块。我们已经在第三章,“设置维基游戏”中编写的webcrawler.jl文件中完成了代码的第一个迭代。现在,我们只需要创建一个Wikipedia模块,并用webcrawler.jl的内容填充它。

sixdegrees文件夹内,创建一个名为Wikipedia.jl的新文件。用以下代码设置它:

module Wikipedia
using HTTP, Gumbo 

const RANDOM_PAGE_URL = "https://en.m.wikipedia.org/wiki/Special:Random" 

export fetchrandom, fetchpage, articlelinks 

function fetchpage(url) 
  response = HTTP.get(url) 
  if response.status == 200 && length(response.body) > 0 
    String(response.body) 
  else 
    "" 
  end 
end 

function extractlinks(elem, links = String[]) 
  if  isa(elem, HTMLElement) && tag(elem) == :a && in("href", collect(keys(attrs(elem)))) 
        url = getattr(elem, "href") 
        startswith(url, "/wiki/") && ! occursin(":", url) && push!(links, url) 
  end 
  for child in children(elem) 
    extractlinks(child, links) 
  end 
  unique(links) 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articlelinks(content) 
  if ! isempty(content) 
    dom = Gumbo.parsehtml(content) 

    links = extractlinks(dom.root) 
  end 
end

end

前面的代码应该看起来很熟悉,因为它与webcrawler.jl共享了大部分逻辑。但是,有一些重要的变化。

首先,我们将一切包裹在一个module声明中。

请注意一个非常重要的约定:在 Julia 中,我们不会在模块内部缩进代码,因为这会导致整个文件缩进,从而影响可读性。

在第三行,我们原本有 Julia 维基百科条目的链接,现在我们定义了一个String常量RANDOM_PAGE_URL,它指向一个特殊的维基百科 URL,该 URL 返回一个随机文章。我们还切换到了维基百科网站的移动版本,如en.m.子域名所示。使用移动页面会使我们的工作更简单,因为它们更简单,标记也更少。

fetchpage 函数中,我们不再寻找 Content-Length 标头,而是检查 response.body 属性的 length。我们这样做是因为请求特殊的随机维基百科页面会进行重定向,在这个过程中,Content-Length 标头会被丢弃。

我们还替换了文件底部的部分逻辑。我们不再自动获取 Julia 的维基百科页面并将内部链接列表输出到屏幕上,我们现在定义了两个额外的函数:fetchrandomarticlelinks。这些函数将是 Wikipedia 模块的公共接口,并且通过 export 语句公开。fetchrandom 函数确实如其名所示——它调用 fetchpage 函数,传入 RANDOM_PAGE_URL 常量,实际上是从随机维基百科页面获取。articlelinks 返回一个表示链接文章的字符串数组。

最后,我们移除了 LINKS 常量——应该避免使用全局变量。extractlinks 函数已经相应地重构,现在接受第二个参数,links,一个 StringVector,它在递归过程中用于维护状态。

检查我们的代码

让我们确保在这次重构之后,我们的代码仍然按预期工作。Julia 默认带有单元测试功能,我们将在第十一章 创建 Julia 包中探讨这些内容。现在,我们将按照老方法来做,手动运行代码并检查输出。

我们将在 sixdegrees/ 文件夹内添加一个新文件,命名为 six_degrees.jl。从其名称来看,您可以猜测它将是一个纯 Julia 文件,而不是一个模块。我们将使用它来编排游戏的加载:

using Pkg 
pkg"activate ." 

include("Wikipedia.jl") 
using .Wikipedia 

fetchrandom() |> articlelinks |> display 

代码简单且简洁——我们使用 Pkg 激活当前项目。然后,我们将 Wikipedia.jl 文件包含到当前模块中,然后要求编译器将 Wikipedia 模块引入作用域。最后,我们使用之前讨论过的 fetchrandomarticlelinks 来从随机维基百科页面检索文章 URL 列表并显示。

是时候运行我们的代码了!在 REPL 中,确保您已经 cdsixdegrees 文件夹并执行:

julia> include("six_degrees.jl") 
21-element Array{String,1}: 
 "/wiki/Main_Page" 
 "/wiki/Arena" 
 "/wiki/Saskatoon,_Saskatchewan" 
 "/wiki/South_Saskatchewan_River" 
 "/wiki/New_York_Rangers" 
# ... output omitted ... #
Array{String,1} with entries that start with /wiki/.

或者,您可以在 Visual Studio Code 和 Atom 中使用运行代码或运行文件选项。以下是 Atom 运行 six_degrees.jl 文件的情况:

图片

构建我们的维基百科爬虫 - 第二部分

我们的代码按预期运行,重构并整洁地打包到一个模块中。然而,在继续之前,我还有一个东西想让我们重构。我对我们的 extractlinks 函数并不特别满意。

首先,它天真地遍历了所有的 HTML 元素。例如,假设我们还想提取页面的标题——每次我们想要处理不是链接的内容时,我们都需要再次遍历整个文档。这将非常消耗资源,并且运行速度会变慢。

其次,我们正在重新发明轮子。在第三章 设置 Wiki 游戏 中,我们说 CSS 选择器是 DOM 解析的通用语言。如果我们使用 CSS 选择器的简洁语法和由专用库提供的底层优化,我们将从中获得巨大的好处。

幸运的是,我们不需要寻找太远就能找到这种功能。Julia 的Pkg系统提供了对Cascadia的访问,这是一个本地的 CSS 选择器库。而且,它的一大优点是它与Gumbo协同工作。

为了使用 Cascadia,我们需要将其添加到我们项目的依赖列表中:

(sixdegrees) pkg> add Cascadia

接下来,告诉 Julia 我们将使用它——修改Wikipedia.jl,使其第三行如下所示:

using HTTP, Gumbo, Cascadia

Cascadia的帮助下,我们现在可以重构extractlinks函数,如下所示:

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

让我们更仔细地看看这里发生的一切。首先引起注意的是Selector函数。这是由Cascadia提供的,它构建一个新的 CSS 选择器对象。传递给它作为唯一参数的字符串是一个 CSS 选择器,其内容为——所有具有以'/wiki/'开头且不包含列(:)的href属性的<a>元素。

Cascadia还导出了eachmatch方法。更准确地说,它是扩展了我们之前看到的带有正则表达式的现有Base.eachmatch方法。这提供了一个熟悉的接口——我们将在本章后面的方法部分看到如何扩展方法。Cascadia.eachmatch函数返回一个匹配选择器的Vector元素。

一旦我们检索到匹配的元素集合,我们就将其传递给map函数。map函数是函数式编程工具箱中最常用的工具之一。它接受一个函数f和一个集合c作为其参数——并通过将f应用于每个元素来转换集合c,返回修改后的集合作为结果。其定义如下:

map(f, c...) -> collection  
map function, it's true. But it is, in fact, the exact same function invocation, except with a more readable syntax, provided by Julia's blocks.

使用块

因为在 Julia 中,函数是一等语言构造,它们可以被引用和操作,就像任何其他类型的变量一样。它们可以作为其他函数的参数传递,或者可以作为其他函数调用的结果返回。将另一个函数作为其参数或返回另一个函数作为其结果的函数称为高阶函数

让我们通过一个简单的map示例来看看。我们将取一个IntVector,并将一个函数应用于其集合的每个元素,该函数将值加倍。你可以在新的 REPL 会话(或在配套的 IJulia 笔记本)中跟随:

julia> double(x) = x*2 
double (generic function with 1 method) 

julia> map(double, [1, 2, 3, 5, 8, 13]) 
6-element Array{Int64,1}: 
  2 
  4 
  6 
 10 
 16 
 26 
double function as the argument of the higher-order function map. As a result, we got back the Vector, which was passed as the second argument, but with all the elements doubled.

那都是好的,但是不得不定义一个函数只是为了将其作为另一个函数的一次性参数是不方便的,而且有点浪费。出于这个原因,支持函数式特性的编程语言,包括 Julia,通常支持 匿名函数。匿名函数,或称为 lambda,是一个没有绑定到标识符的函数定义。

我们可以将前面的 map 调用重写为使用匿名函数,该函数通过使用箭头 -> 语法现场定义:

julia> map(x -> x*2, [1, 2, 3, 5, 8, 13]) 
6-element Array{Int64,1}: 
  2 
  4 
  6 
 10 
 16 
 26

在定义中,x -> x*2,箭头左边的 x 代表传递给函数的参数,而 x*2 代表函数体。

太棒了!我们没有必要单独定义 double 就达到了相同的结果。但是,如果我们需要使用更复杂的函数呢?例如,注意以下内容:

julia> map(x -> 
           if x % 2 == 0 
                  x * 2 
           elseif x % 3 == 0 
                  x * 3 
           elseif x % 5 == 0 
                  x * 5 
           else 
                  x 
           end,  
      [1, 2, 3, 5, 8, 13]) 

这很难理解!因为 Julia 允许我们缩进我们的代码,我们可以增强这个示例的可读性,使其更加易于接受,但结果仍然远远不够好。

由于这些情况经常发生,Julia 提供了用于定义匿名函数的块语法。所有将另一个函数作为其 第一个 参数的函数都可以使用块语法。对这种调用的支持已经内置于语言中,因此你不需要做任何事情——只要函数是第一个位置参数,你的函数就会自动支持它。为了使用它,我们在调用高阶函数时跳过传递第一个参数——而是在参数列表的末尾,在参数元组之外,添加一个 do...end 块。在这个块内部,我们定义我们的 lambda。

因此,我们可以将前面的示例重写如下:

map([1, 2, 3, 5, 8, 13]) do x 
       if x % 2 == 0 
              x * 2 
       elseif x % 3 == 0 
              x * 3 
       elseif x % 5 == 0 
              x * 5 
        else 
              x 
        end 
 end 

阅读起来更加清晰!

实现游戏玩法

我们现在对维基百科解析器相当有信心,添加 Cascadia 大大简化了代码。现在是时候考虑实际的游戏玩法了。

最重要的是,游戏的精髓是创建谜题——要求玩家从初始文章找到通往结束文章的路径。我们之前决定,为了确保两篇文章之间确实存在路径,我们将预先爬取所有页面,从第一页到最后一页。为了从一个页面导航到下一个页面,我们将简单地随机选择一个内部 URL。

我们还提到了包括难度设置。我们将使用常识假设,即起始文章和结束文章之间的链接越多,它们的主题就越不相关;因此,识别它们之间路径的难度就越大,导致更具有挑战性的难度级别。

好吧,是时候开始编码了!首先,在 sixdegrees/ 文件夹内创建一个新文件。命名为 Gameplay.jl 并复制粘贴以下内容:

module Gameplay 

using ..Wikipedia 

export newgame 

const DIFFICULTY_EASY = 2 
const DIFFICULTY_MEDIUM = 4 
const DIFFICULTY_HARD = 6 

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = [] 

  for i in 1:difficulty 
    article = if i == 1 
      fetchrandom() 
    else 
      rand(articles[i-1][:links]) |> Wikipedia.fetchpage 
    end 

article_data = Dict(:content => article, 
  :links => articlelinks(article)) 
    push!(articles, article_data) 
  end 

  articles 
end 

end 

Gamplay.jl 定义了一个新的 module 并将 Wikipedia 带入作用域。在这里,你可以看到我们如何通过使用 .. 在父作用域中引用 Wikipedia 模块。然后它定义了三个常量,这些常量将难度设置映射到分离度(分别命名为 DIFFICULTY_EASYDIFFICULTY_MEDIUMDIFFICULTY_HARD)。

然后,它定义了一个名为 newgame 的函数,该函数接受一个难度参数,默认设置为困难。在函数的主体中,我们循环的次数等于难度值。在每次迭代中,我们检查当前的分离度——如果是第一篇文章,我们调用 fetchrandom 来启动爬取过程。如果不是第一篇文章,我们从先前爬取的文章的链接列表中随机选择一个链接(rand(articles[i-1][:links]))。然后我们将此 URL 传递给 fetchpage。在讨论条件语句时,我们了解到在 Julia 中 if/else 语句返回最后一个评估表达式的值。我们可以看到它在这里得到了很好的应用,评估的结果被存储在 article 变量中。

一旦我们获取了文章,我们将其内容及其链接存储在一个名为 article_dataDict 中。然后,article_data 被添加到 articles 数组中。在其最后一行,newgame 函数返回包含所有步骤(从第一个到最后一个)的 articles 向量。此函数也被导出。

这并不太难!但是,有一个小问题。如果你现在尝试运行代码,它将会失败。原因是文章链接是 relative 的。这意味着它们不是完全限定的 URL;它们看起来像 /wiki/Some_Article_Title。当 HTTP.jl 发起请求时,它需要包含协议、链接和域名。但别担心,在 Wikipedia.jl 中修复这个问题很容易。请切换你的编辑器到 Wikipedia 模块,并将 const RANDOM_PAGE_URL 行替换为以下三行:

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

我们将随机页面 URL 分解为其组成部分——协议、域名和剩余的相对路径。

我们将使用类似的方法在获取文章时将相对 URL 转换为绝对 URL。为此,更改 fetchpage 的主体,并将其作为第一行代码添加以下内容:

url = startswith(url, "/") ? PROTOCOL * DOMAIN_NAME * url : url 

在这里,我们检查 url 参数——如果它以 "/" 开头,这意味着它是一个相对 URL,因此我们需要将其转换为它的绝对形式。正如你可以看到的,我们使用了三元运算符。

我们现在的代码应该可以正常工作,但将这个 PROTOCOL * DOMAIN_NAME * url 散布到我们的游戏中有点像 code smell。让我们将其抽象成一个函数:

function buildurl(article_url) 
    PROTOCOL * DOMAIN_NAME * article_url 
end 

在编程术语中,code smell 指的是违反基本设计原则并负面影响的实践。它本身不是一个 bug,但它表明设计中的弱点可能会增加未来出现错误或失败的风险。

Wikipedia.jl 文件现在应该看起来像这样:

module Wikipedia 

using HTTP, Gumbo, Cascadia 

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

export fetchrandom, fetchpage, articlelinks 

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 
  response = HTTP.get(url) 

  if response.status == 200 && length(response.body) > 0 
    String(response.body) 
  else  
    "" 
  end 
end 

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articlelinks(content) 
  if ! isempty(content) 
    dom = Gumbo.parsehtml(content) 

    links = extractlinks(dom.root) 
  end 
end 

function buildurl(article_url) 
  PROTOCOL * DOMAIN_NAME * article_url 
end 

end 

完成细节

我们的游戏玩法发展得很好。只剩下几个部件了。在思考我们的游戏用户界面时,我们希望展示游戏的进度,指出玩家已经导航过的文章。为此,我们需要文章的标题。如果我们还能包括一张图片,那会让我们的游戏看起来更漂亮。

幸运的是,我们现在使用 CSS 选择器,因此提取缺失的数据应该轻而易举。我们只需要将以下内容添加到Wikipedia模块中:

import Cascadia: matchFirst 

function extracttitle(elem) 
  matchFirst(Selector("#section_0"), elem) |> nodeText 
end 

function extractimage(elem) 
  e = matchFirst(Selector(".content a.image img"), elem) 
  isa(e, Void) ? "" : e.attributes["src"] 
end 

extracttitleextractimage函数将从我们的文章页面检索相应的内容。在两种情况下,因为我们只想选择一个元素,即主页标题和第一张图片,所以我们使用Cascadia.matchFirstmatchFirst函数不是由Cascadia公开暴露的——但因为它非常有用,所以我们import它。

#section_0选择器标识主页标题,一个<h1>元素。而且,因为我们需要提取其<h1>...</h1>标签之间的文本,我们调用Cascadia提供的nodeText方法。

你可以在以下屏幕截图(显示 Safari 检查器中的 Wikipedia 页面的主要标题)中看到,如何识别所需的 HTML 元素以及如何通过检查页面源代码和相应的 DOM 元素来选择它们的 CSS 选择器。HTML 属性id="section_0"对应于#section_0CSS 选择器:

对于extractimage,我们寻找主要文章图片,表示为".content a.image img"选择器。由于并非所有页面都有它,我们检查是否确实得到了一个有效的元素。如果页面没有图片,我们将得到一个Nothing实例,称为nothing。这是一个重要的构造——nothingNothing的单例实例,表示没有对象,对应于其他语言中的NULL。如果我们确实得到了一个img元素,我们提取其src属性的值,即图片的 URL。

这里是另一个 Wikipedia 截图,其中我标记了我们要针对的图像元素。旗帜是 Wikipedia 的澳大利亚页面上的第一张图片——一个完美的匹配:

接下来,我们可以扩展Gameplay.newgame函数,以处理新的功能和值。但到目前为止,这感觉并不合适——太多的Wikipedia逻辑会泄露到Gameplay模块中,使它们耦合;这是一个危险的反模式。相反,让我们让数据的提取和文章的设置,即Dict,成为Wikipedia的全权责任,完全封装逻辑。让Gameplay.newgame函数看起来如下所示:

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = [] 

  for i in 1:difficulty  
    article = if i == 1 
                fetchrandom() 
              else  
                rand(articles[i-1][:links]) |> Wikipedia.fetchpage 
              end 
    push!(articles, articleinfo(article)) 
  end 

  articles 
end 

然后,更新Wikipedia模块如下所示:

module Wikipedia 

using HTTP, Gumbo, Cascadia 
import Cascadia: matchFirst 

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

export fetchrandom, fetchpage, articleinfo 

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 

  response = HTTP.get(url) 

  if response.status == 200 && length(response.body) > 0 
    String(response.body) 
  else  
    "" 
  end 
end 

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

function extracttitle(elem) 
  matchFirst(Selector("#section_0"), elem) |> nodeText 
end 

function extractimage(elem) 
  e = matchFirst(Selector(".content a.image img"), elem) 
  isa(e, Nothing) ? "" : e.attributes["src"] 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articledom(content) 
  if ! isempty(content) 
    return Gumbo.parsehtml(content) 
  end 

  error("Article content can not be parsed into DOM") 
end 

function articleinfo(content) 
  dom = articledom(content) 

  Dict( :content => content,  
        :links => extractlinks(dom.root),  
        :title => extracttitle(dom.root),  
        :image => extractimage(dom.root) 
  ) 
end 

function buildurl(article_url) 
  PROTOCOL * DOMAIN_NAME * article_url 
end 

end 

文件有几处重要更改。我们移除了articlelinks函数,并添加了articleinfoarticledom。新的articledom函数使用Gumbo解析 HTML 并生成 DOM,这非常重要,DOM 只解析一次。我们不希望在每次提取元素类型时都解析 HTML 到 DOM,就像如果我们保留之前的articlelinks函数那样。至于articleinfo,它负责设置一个包含所有相关信息的文章Dict——内容、链接、标题和图片。

我们可以通过修改six_degrees.jl文件来进行代码的测试运行,如下所示:

using Pkg 
pkg"activate ." 

include("Wikipedia.jl") 
include("Gameplay.jl") 

using .Wikipedia, .Gameplay 

for article in newgame(Gameplay.DIFFICULTY_EASY) 
  println(article[:title]) 
end 

我们开始一个新的游戏,它包含两篇文章(Gameplay.DIFFICULTY_EASY),并且对于每一篇文章,我们都会显示其标题。我们可以通过在 REPL 会话中运行它来看到它的实际效果,通过julia> include("six_degrees.jl"),或者简单地通过在 Visual Studio Code 或 Atom 中运行文件。下面是 REPL 中的样子:

julia> include("six_degrees.jl") 
Miracle Bell 
Indie pop  

还有一件事

我们的测试运行显示我们的难度设置有一个小故障。我们应该在起点之后爬取一定数量的文章。我们的初始文章不应计入。这个问题非常容易解决。在Gameplay.newgame中,我们需要将for i in 1:difficulty替换为for i in 1:difficulty+1(注意最后的+1)。现在,如果我们再次尝试,它将按预期工作:

julia> include("six_degrees.jl") 
John O'Brien (Australian politician) 
Harlaxton, Queensland 
Ballard, Queensland 

学习 Julia 的类型系统

我们的游戏运行得非常顺利,但有一件事我们可以改进——将我们的文章信息存储为Dict。Julia 的字典非常灵活和强大,但它们并不适合所有情况。Dict是一个通用的数据结构,它针对搜索、删除和插入操作进行了优化。这里我们都不需要这些——我们的文章具有固定的结构,并且创建后数据不会改变。这是一个非常适合使用对象和面向对象编程OOP)的用例。看来是时候学习类型了。

Julia 的类型系统是语言的核心——它无处不在,定义了语言的语法,并且是 Julia 性能和灵活性的驱动力。Julia 的类型系统是动态的,这意味着在程序可用的实际值之前,我们对类型一无所知。然而,我们可以通过使用类型注解来利用静态类型的好处——表明某些值具有特定的类型。这可以大大提高代码的性能,并增强可读性,简化调试。

讨论 Julia 语言而不提及类型是不可能的。确实,到目前为止,我们已经看到了许多原始类型——IntegerFloat64BooleanChar等等。在学习各种数据结构,如ArrayDict或元组时,我们也接触到了类型。这些都是语言内置的,但结果是 Julia 使得创建我们自己的类型变得非常容易。

定义我们自己的类型

Julia 支持两种类型的类别——原始类型和复合类型。原始类型是一个具体类型,其数据由普通的位组成。复合类型是一组命名字段,其实例可以被视为单个值。在许多语言中,复合类型是唯一一种用户可定义的类型,但 Julia 允许我们声明自己的原始类型,而不仅仅是提供一组固定的内置类型。

我们在这里不会讨论定义原始类型,但你可以在官方文档中了解更多信息,网址为docs.julialang.org/en/v1/manual/types/

为了表示我们的文章,我们最好使用一个不可变的复合类型。一旦我们的文章对象被创建,其数据就不会改变。不可变的复合类型是通过struct关键字后跟一个字段名称块来引入的:

struct Article 
    content 
    links 
    title 
    image 
end 

由于我们没有为字段提供类型信息——也就是说,我们没有告诉 Julia 我们希望每个字段是什么类型——它们将默认为任何类型,允许存储任何类型的值。但是,由于我们已经知道我们想要存储什么数据,我们将极大地从限制每个字段的类型中受益。::运算符可以用来将类型注解附加到表达式和变量上。它可以读作“是一个实例”。因此,我们定义Article类型如下:

struct Article 
    content::String 
    links::Vector{String} 
    title::String 
    image::String 
end 

所有字段都是String类型,除了links,它是一个一维的Array,也称为Vector{String}

类型注解可以提供重要的性能优势——同时消除一类与类型相关的错误。

构造类型

创建Article类型的新对象是通过将Article类型名称像函数一样应用来实现的。参数是该字段的值:

julia> julia = Article( 
           "Julia is a high-level dynamic programming language", 
           ["/wiki/Jeff_Bezanson", "/wiki/Stefan_Karpinski",  
            "/wiki/Viral_B._Shah", "/wiki/Alan_Edelman"], 
           "Julia (programming language)", 
           "/220px-Julia_prog_language.svg.png" 
       ) 
Article("Julia is a high-level dynamic programming language", ["/wiki/Jeff_Bezanson", "/wiki/Stefan_Karpinski", "/wiki/Viral_B._Shah", "/wiki/Alan_Edelman"], "Julia (programming language)", "/220px-Julia_prog_language.svg.png") 

可以使用标准的点表示法访问新创建对象的字段:

julia> julia.title 
"Julia (programming language)" 

由于我们声明我们的类型为不可变的,所以值是只读的,因此它们不能被更改:

julia> julia.title = "The best programming language, period" 
ERROR: type Article is immutable 

我们的Article类型定义不会允许我们更改julia.title属性。但是,不可变性不应该被忽视,因为它确实带来了相当大的优势,如官方 Julia 文档所述:

  • 它可能更有效。某些结构可以有效地打包到数组中,在某些情况下,编译器能够避免分配不可变对象。

  • 无法违反类型构造函数提供的不变性。

  • 使用不可变对象的代码可能更容易推理。

但是,这并不是全部的故事。一个不可变对象可以拥有引用可变对象的字段,例如,比如links,它指向一个Array{String, 1}。这个数组仍然是可变的:

julia> push!(julia.links, "/wiki/Multiple_dispatch") 
5-element Array{String,1}: 
 "/wiki/Jeff_Bezanson" 
 "/wiki/Stefan_Karpinski" 
 "/wiki/Viral_B._Shah" 
 "/wiki/Alan_Edelman" 
 "/wiki/Multiple_dispatch" 

我们可以通过尝试向底层集合推送一个额外的 URL 来改变links属性,看到没有错误发生。如果一个属性指向一个可变类型,那么这个类型可以被修改,只要它的类型保持不变:

julia> julia.links = [1, 2, 3] 
MethodError: Cannot `convert` an object of type Int64 to an object of type String 

我们不允许更改links字段的类型——Julia 试图适应并尝试将我们提供的值从Int转换为String,但失败了。

可变复合类型

同样地(并且同样简单),我们也可以构造可变复合类型。我们唯一需要做的是使用mutable struct语句,而不是仅仅使用struct

julia> mutable struct Player 
           username::String 
           score::Int 
       end 

我们的Player对象应该是可变的,因为我们需要在每次游戏后更新score属性:

julia> me = Player("adrian", 0) 
Player("adrian", 0) 

julia> me.score += 10 
10 

julia> me 
Player("adrian", 10) 

类型层次结构和继承

就像所有实现 OOP 特性的编程语言一样,Julia 允许开发者定义丰富和表达性的类型层次结构。然而,与大多数 OOP 语言不同的是,有一个非常重要的区别——在 Julia 中,只有层次结构中的最终(上层)类型可以被实例化。所有它的父类型只是类型图中的节点,我们无法创建它们的实例。它们是抽象类型,使用abstract类型关键字定义:

julia> abstract type Person end 

我们可以使用<:运算符来表示一个类型是现有父类型的子类型:

julia> abstract type Mammal end 
julia> abstract type Person <: Mammal end 
julia> mutable struct Player <: Person 
           username::String 
           score::Int 
       end 

或者,在另一个例子中,这是 Julia 的数值类型层次结构:

abstract type Number end 
abstract type Real     <: Number end 
abstract type AbstractFloat <: Real end 
abstract type Integer  <: Real end 
abstract type Signed   <: Integer end 
abstract type Unsigned <: Integer end 

超类型不能实例化的事实可能看起来很有限,但它们有一个非常强大的作用。我们可以定义接受超类型作为参数的函数,实际上接受所有其子类型:

julia> struct User <: Person 
           username::String 
           password::String 
       end 

julia> sam = User("sam", "password") 
User("sam", "password") 

julia> function getusername(p::Person) 
           p.username 
      end 

julia> getusername(me) 
"adrian" 

julia> getusername(sam) 
"sam" 

julia> getusername(julia) 
ERROR: MethodError: no method matching getusername(::Article) 
Closest candidates are: 
  getusername(::Person) at REPL[25]:2 

在这里,我们可以看到我们如何定义了一个getusername函数,它接受一个(抽象)类型参数,Person。由于UserPlayer都是Person的子类型,它们的实例被接受为参数。

类型联合

有时,我们可能希望允许一个函数接受一组不一定属于同一类型层次结构的类型。当然,我们可以允许函数接受任何类型,但根据用例,可能希望严格限制参数到一个定义良好的类型子集。对于这种情况,Julia 提供了类型联合

类型联合是一种特殊的抽象类型,它包括使用特殊Union函数构造的所有其参数类型的实例:

julia> GameEntity = Union{Person,Article} 
Union{Article, Person} 

在这里,我们定义了一个新的类型联合,GameEntity,它包括两种类型——PersonArticle。现在,我们可以定义知道如何处理GameEntities的函数:

julia> function entityname(e::GameEntity) 
           isa(e, Person) ? e.username : e.title 
       end 
entityname (generic function with 1 method) 

julia> entityname(julia) 
"Julia (programming language)" 

julia> entityname(me) 
"adrian" 

使用文章类型

我们可以将我们的代码重构,以消除通用的Dict数据结构,并用专门的Article复合类型来表示我们的文章。

让我们在我们的sixdegrees/工作文件夹中创建一个新的文件,命名为Articles.jl。通过输入相应的module声明来编辑文件。然后,添加我们类型的定义并将其export

module Articles 

export Article 

struct Article 
  content::String 
  links::Vector{String} 
  title::String 
  image::String 
end 

end 

我们本可以将Article类型定义添加到Wikipedia.jl文件中,但很可能会增长,因此最好将它们分开。

另一点需要注意的是,moduletype 都是 Julia 实体,它们在相同的作用域中被加载。因此,我们不能同时使用 Article 这个名字来命名 moduletype——否则会出现名称冲突。然而,复数形式的 Articles 是一个很好的模块名称,因为它将封装处理一般文章的逻辑,而 Article 类型代表一个文章实体——因此使用单数形式。

然而,由于概念上 Article 对象引用了一个维基百科页面,它应该是 Wikipedia 命名空间的一部分。这很简单,我们只需要将其包含到 Wikipedia 模块中。在 import Cascadia: matchFirst 行之后添加以下内容:

include("Articles.jl") 
using .Articles 

我们包含了 Articles 模块文件并将其带入作用域。

接下来,在同一个 Wikipedia.jl 文件中,我们需要修改 articleinfo 函数。请确保它如下所示:

function articleinfo(content) 
  dom = articledom(content) 
  Article(content,  
          extractlinks(dom.root),  
          extracttitle(dom.root),  
          extractimage(dom.root)) 
end 

我们现在不是创建一个通用的 Dict 对象,而是实例化一个 Article 的实例。

我们还需要对 Gameplay.jl 进行一些修改,以使用 Article 类型而不是 Dict。它现在应该看起来像这样:

module Gameplay 

using ..Wikipedia, ..Wikipedia.Articles 

export newgame 

const DIFFICULTY_EASY = 2 
const DIFFICULTY_MEDIUM = 4 
const DIFFICULTY_HARD = 6 

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = Article[] 

  for i in 1:difficulty+1 
    article = if i == 1 
                fetchrandom() 
              else  
                rand(articles[i-1].links) |> fetchpage 
              end 
    push!(articles, articleinfo(article)) 
  end 

  articles 
end 

end 

注意,在第三行我们将 Wikipedia.Articles 带入作用域。然后,在 newgame 函数中,我们将 articles 数组初始化为 Vector{Article} 类型。接着,我们更新 for 循环中的代码来处理 Article 对象——rand(articles[i-1].links)

最后的更改在 six_degrees.jl 中。由于 newgame 现在返回一个 Article 对象的向量而不是 Dict,我们通过访问 title 字段来打印标题:

using Pkg 
pkg"activate ." 

include("Wikipedia.jl") 
include("Gameplay.jl") 

using .Wikipedia, .Gameplay 

articles = newgame(Gameplay.DIFFICULTY_EASY) 

for article in articles 
  println(article.title) 
end 

新的测试运行应该确认所有工作如预期(由于我们正在拉取随机文章,所以你的输出将不同):

julia> include("six_degrees.jl") 
Sonpur Bazari 
Bengali language 
Diacritic 

内部构造函数

外部构造函数(我们作为函数调用 type)是一个默认构造函数,我们为所有字段提供值,并按正确的顺序返回相应类型的实例。但是,如果我们想提供额外的构造函数,可能施加某些约束、执行验证或者只是更用户友好呢?为此,Julia 提供了 内部构造函数。我有一个很好的用例。

我并不特别喜欢我们的 Article 构造函数——它需要太多的参数,并且必须按正确的顺序传递。很难记住如何实例化它。我们之前学过关键字参数——提供一个接受关键字参数的替代构造函数会非常棒。内部构造函数正是我们所需要的。

内部构造函数与外部构造函数非常相似,但有两大主要区别:

  • 它们是在类型声明块的内部声明的,而不是像正常方法那样在块外部声明。

  • 它们可以访问一个特殊的本地存在函数 new,该函数创建相同类型的对象。

另一方面,外部构造函数有一个明显的限制(按设计)——我们可以创建尽可能多的构造函数,但它们只能通过调用现有的内部构造函数来实例化对象(它们没有访问 new 函数的权限)。这样,如果我们定义了实现某些业务逻辑约束的内部构造函数,Julia 保证外部构造函数不能绕过这些约束

我们使用关键字参数的内部构造函数看起来是这样的:

Article(; content = "", links = String[], title = "", image = "") = new(content, links, title, image) 

注意到 ; 的使用,它将空的位置参数列表与关键字参数列表分开。

这个构造函数允许我们使用关键字参数来实例化 Article 对象,我们可以按任何顺序提供这些参数:

julia = Article( 
          title = "Julia (programming language)", 
          content = "Julia is a high-level dynamic programming language", 
          links = ["/wiki/Jeff_Bezanson", "/wiki/Stefan_Karpinski",  
                  "/wiki/Viral_B._Shah", "/wiki/Alan_Edelman"], 
          image = "/220px-Julia_prog_language.svg.png" 
        ) 

然而,有一个小问题。当我们没有提供任何内部构造函数时,Julia 提供默认的一个。但是,如果定义了任何内部构造函数,就不再提供默认构造函数方法——假设我们已经提供了所有必要的内部构造函数。在这种情况下,如果我们想获取带有位置参数的默认构造函数,我们必须自己定义它作为一个内部构造函数:

Article(content, links, title, image) = new(content, links, title, image) 

Articles.jl 文件的最终版本现在应该是以下内容,包含两个内部构造函数:

module Articles 

export Article 

struct Article 
  content::String 
  links::Vector{String} 
  title::String 
  image::String 

  Article(; content = "", links = String[], title = "", image = "") = new(content, links, title, image) 
  Article(content, links, title, image) = new(content, links, title, image) end 

end 

值得指出的是,在这种情况下,我们的关键字构造函数也可以作为一个外部构造函数添加,并定义在 struct...end 主体之外。你使用哪种构造函数是一个架构决策,必须根据具体情况逐个案例进行考虑,考虑到内部构造函数和外部构造函数之间的差异。

方法

如果你来自面向对象编程的背景,你可能会注意到在我们的类型讨论中一个非常有趣的方面。与其他语言不同,Julia 中的对象不定义行为。也就是说,Julia 的类型只定义字段(属性),但不封装函数。

原因在于 Julia 对 多重调度 的实现,这是语言的一个独特特性。

多重调度在官方文档中的解释如下:

"当对一个函数应用时,选择执行哪个方法的过程称为调度。Julia 允许调度过程根据提供的参数数量以及所有函数参数的类型来选择调用函数的哪个方法。这与传统的面向对象语言不同,在传统的面向对象语言中,调度仅基于第一个参数[...]。使用一个函数的所有参数来选择应该调用的方法,而不是仅使用第一个参数,这被称为多重调度。多重调度对于数学代码特别有用,因为它使得人为地将操作归因于一个参数比其他任何参数更有意义的情况变得没有意义。"

Julia 允许我们定义函数,为某些参数类型的组合提供特定的行为。一个函数可能行为的定义被称为方法。方法定义的签名可以注解以指示参数的类型,而不仅仅是它们的数量,并且可以提供多个方法定义。一个例子将有所帮助。

假设我们之前定义了Player类型,如下所示:

julia> mutable struct Player 
           username::String 
           score::Int 
       end 

在这里,我们看到相应的getscore函数:

julia> function getscore(p) 
           p.score 
       end 
getscore (generic function with 1 method) 

到目前为止,一切顺利。但是,随着我们的游戏取得惊人的成功,我们可能会添加一个应用商店来提供应用内购买。这将使我们定义一个Customer类型,该类型可能有一个同名的credit_score字段,用于存储他们的信用评分:

julia> mutable struct Customer 
           name::String 
           total_purchase_value::Float64 
           credit_score::Float64 
       end 

当然,我们需要一个相应的getscore函数:

julia> function getscore(c) 
           c.credit_score 
      end 
getscore (generic function with 1 method) 

现在,Julia 将如何知道使用哪个函数呢?它不会。因为这两个函数都被定义为接受任何类型的参数,最后定义的函数覆盖了之前的函数。我们需要根据它们的参数类型对两个getscore声明进行特殊化:

julia> function getscore(p::Player) 
           p.score 
       end 
getscore (generic function with 1 method) 

julia> function getscore(c::Customer) 
           c.credit_score 
       end 
getscore (generic function with 2 methods) 

如果你仔细查看每个函数声明的输出,你会看到一些有趣的东西。在定义getscore(p::Player)之后,它说getscore (generic function with 1 method)。但是,在定义getscore(c::Customer)之后,它显示getscore (generic function with 2 methods)。所以现在,我们已经为getscore函数定义了两种方法,每种方法都针对其参数类型进行了特殊化。

但是,如果我们添加以下内容呢?

julia> function getscore(t::Union{Player,Customer}) 
           isa(t, Player) ? t.score : t.credit_score 
       end 
getscore (generic function with 3 methods) 

或者,我们可以注意以下可能添加的内容:

julia> function getscore(s) 
            if in(:score, fieldnames(typeof(s))) 
            s.score 
       elseif in(:credit_score, fieldnames(typeof(s))) 
            s.credit_score 
       else 
            error("$(typeof(s)) does not have a score property") 
       end 
end 
getscore (generic function with 4 methods) 

你能猜到在调用getscore时,使用PlayerCustomerArticle对象将使用哪些方法吗?我会给你一个提示:当一个函数应用于一组特定的参数时,将调用适用于这些参数的最具体的方法。

如果我们想查看给定参数集调用的方法,我们可以使用@which

julia> me = Player("adrian", 10) 
Player("adrian", 10) 

julia> @which getscore(me) 
getscore(p::Player) in Main at REPL[58]:2

对于Customer类型也是如此:

julia> sam = Customer("Sam", 72.95, 100) 
Customer("Sam", 72.95, 100.0) 

julia> @which getscore(sam) 
getscore(c::Customer) in Main at REPL[59]:2 

我们可以看到最专业的方法是如何被调用的——getscore(t::Union{Player,Customer}),这是一个更通用的方法,实际上从未被使用。

然而,以下情况又如何呢?

julia> @which getscore(julia) 
getscore(s) in Main at REPL[61]:2 

传递Article类型将调用getscore的最后一个定义,即接受Any类型参数的定义:

julia> getscore(julia) 
ERROR: Article does not have a score property 

由于Article类型没有scorecredit_score属性,我们定义的ErrorException正在被抛出。

要找出为函数定义了哪些方法,请使用methods()

julia> methods(getscore) 
# 4 methods for generic function "get_score": 
getscore(c::Customer) in Main at REPL[59]:2 
getscore(p::Player) in Main at REPL[58]:2 
getscore(t::Union{Customer, Player}) in Main at REPL[60]:2 
getscore(s) in Main at REPL[61]:2 

与关系型数据库一起工作

我们的网页爬虫性能相当出色——使用 CSS 选择器非常高效。但是,就目前而言,如果我们不同游戏会话中遇到相同的维基百科文章,我们不得不多次获取、解析和提取其内容。这是一个耗时且资源密集的操作——更重要的是,如果我们只存储第一次获取的文章信息,我们就可以轻松消除这一操作。

我们可以使用 Julia 的序列化功能,我们之前已经看到过了,但由于我们正在构建一个相当复杂的游戏,添加数据库后端将对我们有所帮助。除了存储文章数据外,我们还可以持久化有关玩家、分数、偏好等信息。

我们已经看到了如何与 MongoDB 交互。然而,在这种情况下,关系型数据库是更好的选择,因为我们将与一系列相关实体一起工作:文章、游戏(引用文章)、玩家(引用游戏)等等。

Julia 的包生态系统为与关系数据库交互提供了广泛的选择,从通用的 ODBC 和 JDBC 库到针对主要后端(MySQL/MariaDB、SQLite 和 Postgres 等)的专用包。对于我们的游戏,我们将使用 MySQL。如果你系统上还没有安装 MySQL,请按照dev.mysql.com/downloads/mysql/上的说明进行操作。或者,如果你使用 Docker,你可以从hub.docker.com/r/library/mysql/获取官方的 MySQL Docker 镜像。

在 Julia 这边,(sixdegrees) pkg>add MySQL就是我们需要添加 MySQL 支持的所有操作。确保你在sixdegrees/项目内添加 MySQL。你可以通过查看pkg>光标的前缀来确认这一点;它应该看起来像这样:(sixdegrees)pkg>。如果不是这种情况,只需在确保你处于sixdegrees/文件夹内的情况下执行pkg> activate .

添加 MySQL 支持

当与 SQL 数据库一起工作时,将 DB 相关逻辑抽象出来,避免在所有代码库中散布 SQL 字符串和数据库特定命令是一个好的做法。这将使我们的代码更具可预测性和可管理性,并在我们需要更改或升级数据库系统时提供一层安全的抽象。我是一个使用 ORM 系统的忠实粉丝,但在这个案例中,作为一个学习工具,我们将自己添加这个功能。

连接到数据库

首先,让我们指导我们的应用程序连接到并断开与我们的 MySQL 数据库的连接。让我们通过在其对应的文件中添加一个新的Database模块来扩展我们的游戏:

module Database 

using MySQL 

const HOST = "localhost" 
const USER = "root" 
const PASS = "" 
const DB = "six_degrees" 

const CONN = MySQL.connect(HOST, USER, PASS, db = DB) 

export CONN 

disconnect() = MySQL.disconnect(CONN) 

atexit(disconnect) 

end 
HOST, USER, and PASS constants with your correct MySQL connection info. Also, please don't forget to create a new, empty database called six_degrees—otherwise the connection will fail. I suggest using utf8 for the encoding and utf8_general_ci for the collation, in order to accommodate all the possible characters we might get from Wikipedia.

调用MySQL.connect返回一个连接对象。我们需要它来与数据库交互,因此我们将通过CONN常量来引用它:

julia> Main.Database.CONN 
MySQL Connection 
------------ 
Host: localhost 
Port: 3306 
User: root 
DB:   six_degrees 

由于我们的代码的各个部分都需要访问这个连接对象以对数据库进行查询,我们将其export。同样重要的是,我们需要设置一些清理机制,以便在完成操作后自动断开与数据库的连接。我们定义了一个可以手动调用的disconnect函数。但是,如果我们确保清理函数能够自动调用,那就更安全了。Julia 提供了一个atexit函数,它可以将一个无参数函数f注册为在进程退出时调用。atexit钩子以后进先出LIFO)的顺序调用。

设置我们的文章模块

下一步是向Article模块添加几个更多函数,以启用数据库持久化和检索功能。由于它将需要访问我们的数据库连接对象,让我们给它访问Database模块的权限。我们还将想要使用MySQL函数。因此,在export Article行下,添加using..Database, MySQL

接下来,我们将添加一个createtable方法。这将是一个一次性函数,用于创建相应的数据库表。我们使用这个方法而不是直接在 MySQL 客户端中键入CREATE TABLE查询,以便有一个一致且可重复的创建(重新)创建表的方式。一般来说,我更喜欢使用完整的数据库迁移库,但现在,最好保持简单(你可以在en.wikipedia.org/wiki/Schema_migration上阅读有关模式迁移的内容)。

不再拖延,这是我们的函数:

function createtable() 
  sql = """ 
    CREATE TABLE `articles` ( 
      `title` varchar(1000), 
      `content` text, 
      `links` text, 
      `image` varchar(500), 
      `url` varchar(500), 
      UNIQUE KEY `url` (`url`) 
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 
  """ 

  MySQL.execute!(CONN, sql) 
end 

在这里,我们定义了一个sql变量,它引用了一个CREATE TABLE查询,形式为一个String。该表将有四个列,对应于我们的Article类型的四个字段。然后还有一个第五列,url,它将存储文章的维基百科 URL。我们将通过 URL 来识别文章——因此,我们在url列上添加了一个唯一索引。

函数的末尾,我们将查询字符串传递给MySQL.execute!以在数据库连接上运行。请将createtable定义添加到Articles模块的末尾(在模块内,在关闭end之前)。

现在,让我们看看它是如何工作的。在sixdegrees/文件夹中打开一个新的 REPL 会话,并运行以下命令:

julia> using Pkg 
julia> pkg"activate ." 
julia> include("Database.jl") 
julia> include("Articles.jl") 
julia> using .Articles 
julia> Articles.createtable() 

就这样,我们的表已经准备好了!

工作流程应该是很清晰的——我们确保加载了我们的项目依赖项,包含了Database.jlArticles.jl文件,将Articles引入作用域,然后调用了它的createtable方法。

添加持久化和检索方法

我们提到,当一篇文章被获取并解析后,我们希望将其数据存储到数据库中。因此,在获取文章之前,我们首先会检查我们的数据库。如果文章之前已经被持久化,我们将检索它。如果没有,我们将执行原始的获取和解析工作流程。我们使用url属性来唯一标识文章。

让我们先添加Articles.save(a::Article)方法来持久化文章对象:

function save(a::Article) 
  sql = "INSERT IGNORE INTO articles (title, content, links, image, url) VALUES (?, ?, ?, ?, ?)" 
  stmt = MySQL.Stmt(CONN, sql) 
  result = MySQL.execute!(stmt, [a.title, a.content, JSON.json(a.links), a.image, a.url]) 
end 

在这里,我们使用MySQL.Stmt来创建一个 MySQL 预编译语句。查询本身非常简单,使用了 MySQL 的INSERT IGNORE语句,确保只有当没有与相同url的文章时,才会执行INSERT操作。如果已经存在具有相同url的文章,则查询将被忽略。

预处理语句接受一个特殊格式的查询字符串,其中实际值被占位符替换,占位符由问号?表示。然后我们可以通过将相应的值数组传递给MySQL.execute!来执行预处理语句。值直接从article对象传递,除了links。由于这代表一个更复杂的数据结构,一个Vector{String},我们首先使用JSON序列化它,并将其作为字符串存储在 MySQL 中。为了访问JSON包中的函数,我们必须将其添加到我们的项目中,所以请在 REPL 中执行(sixdegrees) pkg> add JSON

预处理语句提供了一种安全地执行查询的方法,因为值会被自动转义,消除了 MySQL 注入攻击的常见来源。在我们的情况下,MySQL 注入不太令人担忧,因为我们不接受用户生成的输入。但是,这种方法仍然很有价值,可以避免由于不当转义引起的插入错误。

接下来,我们需要一个检索方法。我们将称之为find。作为它的唯一属性,它将接受一个形式为String的文章 URL。它将返回一个Article对象的Array。按照惯例,如果没有找到相应的文章,数组将是空的:

function find(url) :: Vector{Article} 
  articles = Article[] 

  result = MySQL.query(CONN, "SELECT * FROM `articles` WHERE url = '$url'") 

  isempty(result.url) && return articles 

  for i in eachindex(result.url) 
    push!(articles, Article(result.content[i], JSON.parse(result.links[i]), result.title[i], 
                            result.image[i], result.url[i])) 
  end 

  articles 
end 

在这个函数的声明中,我们可以看到另一个 Julia 特性:返回值类型。在常规函数声明function find(url)之后,我们附加了:: Vector{Article}。这限制了find的返回值为一个Article数组。如果我们的函数不会返回那个值,将会抛出错误。

代码的其余部分,虽然非常紧凑,但功能相当多。首先,我们创建了一个articles向量,其中包含Article对象,这将是我们函数的返回值。然后,我们通过MySQL.query方法对 MySQL 数据库执行一个SELECT查询,尝试找到匹配url的行。查询的结果存储在result变量中,它是一个NamedTupleresult NamedTuple中的每个字段都引用了一个与数据库列同名的值数组)。接下来,我们查看我们的查询结果result以查看是否得到了任何东西——我们选择采样result.url字段——如果它是空的,这意味着我们的查询没有找到任何东西,我们可以直接退出函数,返回一个空的articles向量。

另一方面,如果result.url确实包含条目,这意味着我们的查询至少返回了一行;因此,我们使用eachindex遍历result.url数组,并在每次迭代中用相应的值构建一个Article对象。最后,我们将这个新的Article对象push!到返回的articles向量中,循环结束后。

将所有这些放在一起

最后,我们需要更新代码的其余部分,以适应我们迄今为止所做的更改。

首先,我们需要更新 Article 类型以添加额外的 url 字段。我们需要在字段列表和两个构造函数中使用它。以下是 Articles.jl 的最终版本:

module Articles 

export Article, save, find 

using ...Database, MySQL, JSON 

struct Article 
  content::String 
  links::Vector{String} 
  title::String 
  image::String 
  url::String 

  Article(; content = "", links = String[], title = "", image = "", url = "") = 
        new(content, links, title, image, url) 
  Article(content, links, title, image, url) = new(content, links, title, image, url) 
end 

function find(url) :: Vector{Article} 
  articles = Article[] 

  result = MySQL.query(CONN, "SELECT * FROM `articles` WHERE url = '$url'") 

  isempty(result.url) && return articles 

  for i in eachindex(result.url) 
    push!(articles, Article(result.content[i], JSON.parse(result.links[i]), result.title[i], 
                            result.image[i], result.url[i])) 
  end 

  articles 
end 

function save(a::Article) 
  sql = "INSERT IGNORE INTO articles (title, content, links, image, url) VALUES (?, ?, ?, ?, ?)" 
  stmt = MySQL.Stmt(CONN, sql) 
  result = MySQL.execute!(stmt, [ a.title, a.content, JSON.json(a.links), a.image, a.url]) 
end 

function createtable() 
  sql = """ 
    CREATE TABLE `articles` ( 
      `title` varchar(1000), 
      `content` text, 
      `links` text, 
      `image` varchar(500), 
      `url` varchar(500), 
      UNIQUE KEY `url` (`url`) 
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 
  """ 

  MySQL.execute!(CONN, sql) 
end 

end  

我们还需要对 Wikipedia.jl 进行一些重要的更改。首先,我们将从 Wikipedia.articleinfo 中删除 Article 实例化,因为现在创建 Article 对象也应考虑数据库的持久化和检索。相反,我们将返回表示文章数据的元组:

function articleinfo(content) 
  dom = articledom(content) 
  (content, extractlinks(dom.root), extracttitle(dom.root), extractimage(dom.root)) 
end 

我们现在可以添加一个新函数 persistedarticle,它将接受文章内容和文章 URL 作为参数。它将实例化一个新的 Article 对象,将其保存到数据库中,并返回它。从某种意义上说,persistedarticle 可以被视为数据库支持的构造函数,因此得名:

function persistedarticle(article_content, url) 
  article = Article(articleinfo(article_content)..., url) 
  save(article) 

  article 
end 

在这里,你可以看到 splat 操作符 ... 的实际应用——它将 articleinfo 结果 Tuple 分解为其对应的元素,以便它们可以作为单独的参数传递给 Article 构造函数。

此外,我们必须处理一个小的复杂问题。当我们开始新游戏并调用 /wiki/Special:Random URL 时,维基百科会自动将重定向到一个随机文章。当我们获取页面时,我们得到重定向页面的内容,但我们没有其 URL。

因此,我们需要做两件事。首先,我们需要检查我们的请求是否已被重定向,如果是的话,获取重定向 URL。为了做到这一点,我们可以检查 response.parent 字段。在重定向的情况下,response.request.parent 对象将被设置,并将呈现一个 headers 集合。该集合将包括一个 "Location" 项——这正是我们所追求的。

其次,我们还需要返回页面的 HTML 内容以及 URL。这很简单——我们将返回一个元组。

这里是更新后的 fetchpage 函数:

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 
  response = HTTP.get(url) 
  content = if response.status == 200 && length(response.body) > 0 
              String(response.body) 
            else 
              "" 
            end 
  relative_url = collect(eachmatch(r"/wiki/(.*)$",  
(response.request.parent == nothing ? url : Dict(response.request.parent.headers)["Location"])))[1].match 

  content, relative_url 
end 

注意,我们还使用 eachmatch 从绝对 URL 中提取相应的相对 URL 部分。

这里是整个 Wikipedia.jl 文件:

module Wikipedia 
using HTTP, Gumbo, Cascadia 
import Cascadia: matchFirst 

include("Articles.jl") 
using .Articles 

const PROTOCOL = "https://" 
const DOMAIN_NAME = "en.m.wikipedia.org" 
const RANDOM_PAGE_URL = PROTOCOL * DOMAIN_NAME * "/wiki/Special:Random" 

export fetchrandom, fetchpage, articleinfo, persistedarticle 

function fetchpage(url) 
  url = startswith(url, "/") ? buildurl(url) : url 
  response = HTTP.get(url) 
  content = if response.status == 200 && length(response.body) > 0 
              String(response.body) 
            else 
              "" 
            end 
  relative_url = collect(eachmatch(r"/wiki/(.*)$", (response.request.parent == nothing ? url : Dict(response.request.parent.headers)["Location"])))[1].match 

  content, relative_url 
end 

function extractlinks(elem) 
  map(eachmatch(Selector("a[href^='/wiki/']:not(a[href*=':'])"), elem)) do e 
    e.attributes["href"] 
  end |> unique 
end 

function extracttitle(elem) 
  matchFirst(Selector("#section_0"), elem) |> nodeText 
end 

function extractimage(elem) 
  e = matchFirst(Selector(".content a.image img"), elem) 
  isa(e, Nothing) ? "" : e.attributes["src"] 
end 

function fetchrandom() 
  fetchpage(RANDOM_PAGE_URL) 
end 

function articledom(content) 
  if ! isempty(content) 
    return Gumbo.parsehtml(content) 
  end 

  error("Article content can not be parsed into DOM") 
end 

function articleinfo(content) 
  dom = articledom(content) 
  (content, extractlinks(dom.root), extracttitle(dom.root), extractimage(dom.root)) 
end 

function persistedarticle(article_content, url) 
  article = Article(articleinfo(article_content)..., url) 
  save(article) 

  article 
end 

function buildurl(article_url) 
  PROTOCOL * DOMAIN_NAME * article_url 
end 

end 

现在,让我们专注于 Gameplay.jl。我们需要更新 newgame 函数以利用 Wikipedia 模块中新可用的方法:

module Gameplay 

using ..Wikipedia, ..Wikipedia.Articles 

export newgame 

const DIFFICULTY_EASY = 2 
const DIFFICULTY_MEDIUM = 4 
const DIFFICULTY_HARD = 6 

function newgame(difficulty = DIFFICULTY_HARD) 
  articles = Article[] 

  for i in 1:difficulty+1 
    article = if i == 1 
                article = persistedarticle(fetchrandom()...) 
              else 
                url = rand(articles[i-1].links) 
                existing_articles = Articles.find(url) 

                article = isempty(existing_articles) ? persistedarticle(fetchpage(url)...) : existing_articles[1] 
              end 

    push!(articles, article) 
  end 

  articles 
end 

end 

如果是第一篇文章,我们获取一个随机页面并持久化其数据。否则,我们从之前爬取的页面中随机选择一个 URL 并检查是否存在相应的文章。如果没有,我们获取该页面,确保它也被持久化到数据库中。

最后,我们进入应用程序的入口点,即 six_degrees.jl 文件,需要看起来像这样:

using Pkg 
pkg"activate ." 

include("Database.jl") 
include("Wikipedia.jl") 
include("Gameplay.jl") 

using .Wikipedia, .Gameplay 

articles = newgame(Gameplay.DIFFICULTY_EASY) 

for article in articles 
  println(article.title) 
end 

最后的测试运行应该确认一切正常:

$ julia six_degrees.jl                                                                                                                                                               
Hillary Maritim 
Athletics at the 2000 Summer Olympics - Men's 400 metres hurdles 
Zahr-el-Din El-Najem 

在终端中使用 julia 二进制文件运行 six_degrees.jl 文件将输出三个维基百科文章标题。我们可以检查数据库以确认数据已被保存:

之前爬取的三个页面的数据已安全持久化。

摘要

恭喜,这真是一次相当漫长的旅程!我们学习了三个关键的 Julia 概念——模块、类型及其构造函数,以及方法。我们将所有这些知识应用于开发我们的“维基百科六度分隔”游戏后端,在这个过程中,我们看到了如何与 MySQL 数据库交互,持久化和检索我们的Article对象。

在下一章的结尾,我们将有机会享受我们辛勤工作的果实:在我们为我们的“维基百科六度分隔”后端添加了 Web UI 之后,我们将通过玩几轮来放松。看看你是否能打败我的最佳成绩!