深入大型数据集:并行与分布化Python代码
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

2.1 map函数简介

在第1章中,我们讨论了一点有关map函数的内容,它是一个转换数据序列的函数。具体来说,我们演示了将数学函数n+7应用于一个整数列表:[-1,0,1,2]。图2.1展示了这一组数字是如何被映射(map)为输出的。

图2.1 map函数会对序列中的所有值应用另一个函数,并返回它们的输出序列:例如,将[-1,0,1,2]转换为[6,7,8,9]。

图2.1显示了map函数的本质。我们有一个长度为4的输入和一个相同长度的输出。每个输入都被同一个函数进行了转换。然后将这些转换后的输入作为输出返回。

需要一些Python的背景知识

在学习如何处理大型数据集的过程中,我们会在本书中涉猎一些高级的话题。也就是说,在本书的第1部分(第1章到第6章),我的目标之一是向所有读者介绍他们在编程教育中可能缺失的背景知识。根据经验,你可能已经熟悉了一些概念,比如正则表达式、类和方法、高阶函数以及匿名函数。如果你对这些概念并不熟悉,则可在第6章中学习它们。

在第1部分结束时,我的目标是让你准备好学习分布式计算框架和处理大型数据集的知识。如果在任何时候你觉得需要了解更多关于Python的背景知识,我推荐你阅读Naomi Ceder的The Quick Python Book(Manning出版社于2018年出版)。

这些知识都很有用,但是我们大多数人并不关心自己中学所学的这些数学问题,比如如何使用简单的代数变换。让我们来看看map函数在实践中应用的几种方式,这样才能真正地知道它的力量。

场景 你想为自己的销售团队生成一个呼叫列表,但是最初开发客户注册表单的开发人员,忘记了在表单中添加对数据的验证检查。因此,所有电话号码的格式都不同。例如,有些电话号码的格式会很标准,类似(123)456-7890;有些电话号码只是数字,类似1234567890;还有些电话号码使用点作为分隔符,类似123.456.7890;其他的电话号码可能会包括某个国家代码,类似+1 123 456-7890。

首先,让我们以一种你可能熟悉的方式来处理这个问题——for循环。如清单2.1中所示,我们会首先创建一个匹配所有数字的正则表达式,并且编译它。然后,我们会遍历每个电话号码,并使用正则表达式的findall方法从该号码中提取数字。接下来我们从右边开始数数字。从右边开始向左循环,前4个数字我们会作为电话号码的最后4位,之后取3个数字作为电话号码的前3位,最后再取3个数字作为区号。我们假设其他数字都是某个国家代码(美国的国家代码是+1)。我们将所有这些数字都存储在变量中,随后使用Python的字符串格式方法,将它们添加到一个用来存储结果的列表new_numbers中。

清单2.1 使用一个for循环来格式化电话号码

我们如何用map函数来解决这个问题?整个过程与上面的清单是类似的;但是对于map函数,我们必须把这个问题分成两个部分。我们将这样分开它:

1. 解析电话号码的格式。

2. 把这个方法应用到我们所有的电话号码上。

首先,我们需要处理电话号码的格式。为此,让我们创建一个类,用一个方法查找字符串的最后10个数字,并以经过美化的格式将结果返回。这个类将编译一个正则表达式来查找所有的数字。然后,我们可以使用最后7个数字,以自己希望的格式打印出一个电话号码。如果超过7个数字,我们就忽略国家代码。

注意 我们希望在这里使用一个类(而不是一个函数);因为使用类时,我们只需要编译一次正则表达式,就可以多次使用它。从长远来看,这将为计算机省却反复编译正则表达式的工作。

我们将创建一个pretty_format方法,该方法期望接受一个格式错误的电话号码(一个字符串),并使用编译后的正则表达式来查找所有的号码。然后,就像在前面的示例中所做的一样,我们将使用Python的切分语法,获取位置为-10、-9和-8的匹配项,并将它们赋给一个名为area_code的变量,这些数字应该是我们的区号。之后我们将获取位置为-7、-6和-5的匹配项,并将它们赋给电话号码的前3个数字。随后我们将最后4个数字作为电话号码的最后4个数字。同样,在位置-10之前的任何数字都将被忽略,这些是国家代码。最后,我们使用Python的字符串格式化功能,将这些数字打印成想要的格式。该类的代码如清单2.2所示。

清单2.2 使用map函数重新格式化电话号码的类

既然我们已经能够将任何格式的电话号码转换成美化格式的电话号码,因此可以结合这个类和map函数,将它应用到任意长度的电话号码列表中。为了将两者结合起来,我们将实例化这个类,并将方法传递给map函数,应用在某个序列的所有元素上。最后的代码如清单2.3所示。

清单2.3 将pretty_format方法应用到电话号码上

在底部你会注意到,我们在打印前将map结果转换为一个列表。如果我们要在代码中使用它们,无须这样做;但是由于map函数是惰性的,因此,如果我们不将它们转换成列表就打印,将只会看到输出一个通用的map对象。这并不符合我们期望的电话号码美化格式。

关于这个例子,你会注意到的另一件事是我们非常完美地使用了map函数,因为我们在做一个1对1的转换。也就是说,我们转换了序列中的每个元素。实际上,我们已经把这个问题变成了一个中学代数的例子:对一组数字列表应用n+7。

在图2.2中,我们可以看到这两个问题的相似之处。对于每个问题,我们要做3件事情:获取一个数据序列,用某个函数转换它,并得到输出。两者之间的唯一区别是数据类型(整数与电话号码字符串)和转换过程(简单的算术与通过正则表达式模式来匹配和格式美化)。

map函数的关键是识别出我们可以应用这个三步模式的场景。一旦我们开始寻找它,就会发现它无处不在。让我们来看看这个模式的另一个更复杂的版本:Web抓取。

图2.2 通过对所有字符串应用一个清洗函数,我们可以使用map函数将文本字符串清洗成通用的格式。

场景 在21世纪初,你们公司的主要竞争对手可能在他们的博客上,发布了一些关于自己公司的绝密配方信息。你可以通过一个URL访问他们所有的博客文章,这个URL包含了文章发表的日期(比如,链接4)。我们需要设计一个脚本,以检索2001年1月1日到2010年12月31日间发布的每个Web页面的内容。

让我们考虑一下如何从竞争对手的博客中获取数据。我们需要从URL获取数据。这些URL可以作为输入数据,然后转换函数可以把这些URL转换成Web页面的内容。思考一下这样的问题,可以看到,它与我们在本章中使用map函数解决的其他问题很相似。

图2.3显示的问题,与我们之前使用map函数解决的问题有相同的模式。在顶部,我们可以看到输入数据。但是,这里不是电话号码或者整数,而是URL。在底部,我们有输出的数据。这就是最终得到HTML的地方。在中间有一个函数,它接受每个URL并返回HTML。

图2.3 我们还可以使用map函数来获取与URL序列对应的HTML(只需要编写一个可以抓取单个URL内容的函数)。

2.1.1 通过map函数来获取URL

对于这样的问题,我们知道可以用map函数来解决。那么,问题就变成了:如何获得所有这些URL的列表?Python提供了一个方便的datetime库来解决这样的问题。在这里,我们创建了一个生成器函数,它以(YYYY,MM,DD)格式来获取开始和结束日期元组,并生成一个它们之间的日期列表。之所以使用生成器而不是普通的循环,是因为这样可以避免预先将所有数字存储在内存中。清单2.4中的关键字yield表明这是一个生成器,而不是一个使用return的传统函数(下面“*********.com”所代表的具体网址可通过http://www.broadview.com.cn/40368进行下载)。

清单2.4 一个生成日期范围的函数

利用datatime库的优势

这个函数所做的大部分工作来自Python中datetime库的date类。datetime库的date类表示一个日期,包含了有关公历的知识和一些处理日期的简便方法。你会注意到,我们将date类直接导入为“date”。在我们的函数中,我们实例化了两个类:一个用于开始日期,另一个用于结束日期。然后,我们让函数生成新的日期,直到到达结束日期。

我们函数的最后一行使用了序号日期表示方法,即将日期作为自第1年1月1日起的天数。通过增加这个值并将其转换为date类,我们可以将日期增加1天。因为date类是日历感知的,所以它会自动地把周、月和年,甚至闰年都考虑在内。

最后,我们应该看一下yield语句这一行。这是输出URL的地方。我们会使用Web站点的基本URL:参见链接5,并将以MM-DD-YYYY格式化的日期字符串附加到URL的末尾,就像问题中的说明一样。date类中的strftime方法允许我们使用一个日期格式化语言,将日期转换成我们想要的任何格式的字符串。

把输入变成输出

一旦我们得到了输入数据,下一步就是通过一个函数将输入数据转换为输出数据。这里的输出数据是URL的Web内容。幸运的是,Python在其urllib.request库中提供了一些有用的工具。通过使用这些工具,我们可以编写一段如下所示的函数:

这个函数接受一个URL并返回该URL的HTML内容。我们依赖于Python的请求库的urlopen函数来获取URL上的数据。该数据会作为一个HTTPResponse对象返回给我们,但我们可以使用它的read方法将HTML返回为一个字符串。你可以在自己的REPL环境中试验一下这个函数,抓取一个你经常访问的网站的URL来了解它的实际作用。

然后,就像在前面的场景中一样,我们将这个函数通过map函数应用到序列中的所有数据上,如下所示:

这行代码接受get_url函数,并将其应用于days_between函数生成的每个URL。通过将开始日期和结束日期——(2000,1,1)和(2011,1,1)传递给days_between函数,会生成2000年1月1日到2011年1月1日之间的天数,即21世纪第一个10年中每一天的日期。这个函数返回的值会存储在变量blog_posts中。

如果你在本地机器上运行这个程序,那么该程序应该可以几乎立即完成。这怎么可能?当然,实际上我们不可能这么快地抓取10年间的网页。而且,在生成器函数和map函数中,实际上也并不会尝试这样做。

2.1.2 惰性函数(比如map)对大型数据集的强大功能

map函数就是我们所说的惰性函数。这意味着当我们调用它时,它实际上并不进行实际的计算。相反,当我们调用map时,Python会将计算函数的指令保存下来,并且在我们实际请求值的时候运行它们。这就是当我们之前想要查看map语句的值时,显式地将映射转换为列表的原因。Python中的列表需要实际的对象,而不是生成那些对象的指令。

现在我们回想一下第一个map函数的例子,即对一组数字[-1,0,1,2]应用n+7。我们通过图2.4来描述这个“map”过程。

图2.4 我们最初认为map函数会将一个输入序列转换为一个输出序列。

不过,图2.5对map函数的描述会更准确一些。

在图2.5中,我们在顶部有相同的输入值,并且对所有这些值应用相同的函数。然而,我们的输出已经改变了。之前我们有6、7、8、9,现在我们有的是指令。如果让计算机来执行这些指令,结果也会是6、7、8、9。通常在程序中,我们会认为这两个输出是相等的。但是,作为程序员,我们需要记住这里有一个细微的区别,即Python中默认的map函数在调用时不计算值,而是为以后的计算创建指令。

图2.5 在Python中,基本的map函数会将一个输入序列转换为一个用于计算输出序列的指令,而不是输出序列本身。

作为一名Python程序员,你可能已经了解了什么是惰性数据。在Python中查找惰性对象的常见函数是range函数。当从Python 2迁移到Python 3时,Python的维护者们决定让range变得有惰性,这样Python程序员(你和我)无须做以下两件事就可以创建大量的范围对象:

1. 花时间来生成一个巨大的数字列表。

2. 当我们可能只需要几个值时,将它们存储在内存中。

这些好处对于map函数也是一样的。我们喜欢惰性的map函数,因为它允许转换大量数据,而无须花费大量的内存或者时间来生成数据。这正是我们想要的。