导航菜单

火鸡面-写了三年代码,仍是不明白 Python 国际的规矩

文 | piglei@piglei 大众号 修正 | EarlGrey

引荐 | 编程派大众号(ID:codingpy)

前语

编程,其实和玩电子游戏有一些相似之处。你在玩不同游戏前,需求先学习每个游戏的不同规矩,只需了解和灵活运用游戏规矩,才更有或许在游戏中取胜。

而编程也是相同,不同编程言语相同有着不相同的“规矩”。大到是否支撑面向目标,小到是否能够界说常量,编程言语的规矩比绝大多数电子游戏要杂乱的多。

当咱们编程时,假设直接拿一种言语的经历套用到别的一种言语上,许多时分并不能获得最佳成果。这就如同一个 CS(反恐精英) 高手在不了解规矩的情况下去玩 PUBG(绝地求生),尽管他的枪法或许万中无一,可是极有或许在发现第一个敌人前,他就会倒在某个窝在草丛里的敌人的埋伏下。

Python 里的规矩

Python 是一门初见简略、深化后愈觉杂乱的言语。拿 Python 里最重要的“目标”概念来说,Python 为其界说了多到让你记不全的规矩,比方:

  • 界说了 __str__办法的目标,就能够运用str函数来回来可读称号

  • 界说了 __next____iter__办法的目标,就能够被循环迭代

  • 界说了 __bool__办法的目标,在进行布尔判别时就会运用自界说的逻辑

  • ... ...

了解规矩,并让自己的代码习惯这些规矩,能够协助咱们写出更地道的代码,事半功倍的完结作业。下面,让咱们来看一个有关习惯规矩的故事。

事例:从两份旅行数据中获取人员名单

某日,在一个主打新西兰出境游的旅行公司里,商务搭档忽然兴冲冲的跑过来找到我,说他从某合作伙伴那里火鸡面-写了三年代码,仍是不明白 Python 国际的规矩,要到了两份重要的数据:

  1. 一切去过“泰国普吉岛”的人员及联系办法

  2. 一切去过“新西兰”的人员及联系办法

数据采用了 JSON 格局,如下所示:

  1. # 去过普吉岛的人员数据

  2. users_visited_phuket = [

  3. {"first_name": "Sirena", "last_name": "Gross", "phone_number": "650-568-0388", "date_visited": "2018-03-14"},

  4. {"first_name": "James", "last_name": "Ashcraft", "phone_number": "412-334-4380", "date_visited": "2014-09-16"},

  5. ... ...

  6. ]


  7. # 去过新西兰的人员数据

  8. users_visited_nz = [

  9. {"first_name": "Justin", "last_name": "Malcom", "phone_number": "267-282-1964", "date_visited": "2011-03-13"},

  10. {"first_name": "Albert", "last_name": "Potter", "phone_number": "702-249-3714", "date_visited": "2013-09-11"},

  11. ... ...

  12. ]

每份数据里边都有着 手机号码旅行时刻四个字段。根据这份数据,商务同学提出了一个(听上去毫无道理)的假定:“去过普吉岛的人,应该对去新西兰旅行也很有爱好。咱们需求从这份数据里,找出那些去过普吉岛但没有去过新西兰的人,针对性的卖产品给他们。

第一次蛮力测验

有了原始数据和明晰的需求,接下来的问题便是怎么写代码了。依托蛮力,我很快就写出了第一个计划:

  1. def find_potential_customers_v1:

  2. """找到去过普吉岛可是没去过新西兰的人

  3. """

  4. for phuket_record in users_visited_phuket:

  5. is_potential = True

  6. for nz_record in users_visited_nz:

  7. if phuket_record['first_name'] == nz_record['first_name'] and \

  8. phuket_record['last_name'] == nz_record['last_name'] and \

  9. phuket_record['phone_number'] == nz_record['phone_number']:

  10. is_potential = False

  11. break


  12. if is_potential:

  13. yield phuket_record

由于原始数据里没有“用户 ID”之类的仅有标明,所以咱们只能把“姓名和电话号码彻底相同”作为判别是不是同一个人的规范。

find_potential_customers_v1函数经过循环的办法,先遍历一切去过普吉岛的人,然后再遍历新西兰的人,假设在新西兰的记载中找不到彻底匹配的记载,就把它作为“潜在客户”回来。

这个函数尽管能够完结任务,可是信任不必我说你也能发现。它有着十分严峻的功能问题。关于每一条去过普吉岛的记载,咱们都需求遍历一切新西兰拜访记载,测验找到匹配。整个算法的时刻杂乱度是可怕的O(n*m),假设新西兰的拜访条目数许多的话,那么履行它将消耗十分长的时刻。

为了优化内层循环功能,咱们需求削减线性查找匹配部分的开支。

测验运用调集优化函数

假设你对 Python 有所了解的话,那么你必定知道,Python 里的字典和调集目标都是根据 哈希表(Hash Table) 完结的。判别一个东西是不是在调集里的均匀时刻杂乱度是 O(1),十分快。

所以,关于上面的函数,咱们能够先测验针对新西兰拜访记载初始化一个调集,之后的查找匹配部分就能够变得很快,函数全体时刻杂乱度就能变为 O(n+m)

让咱们看看新的函数:

  1. def find_potential_customers_v2:

  2. """找到去过普吉岛可是没去过新西兰的人,功能改进版

  3. """

  4. # 首要,遍历一切新西兰拜访记载,创立查找索引

  5. nz_records_idx = {

  6. (rec['first_name'], rec['last_name'], rec['phone_number'])

  7. for rec in users_visited_nz

  8. }


  9. for rec in users_visited_phuket:

  10. key = (rec['first_name'], rec['last_name'], rec['phone_number'])

  11. if key not in nz_records_idx:

  12. yield rec

运用了调集目标后,新函数在速度上比较旧版别有了腾跃性的打破。可是,对这个问题的优化并不是到此为止,否则文章标题就应该改成:“怎么运用调集进步程序功能” 了。

对问题的从头考虑

让咱们来测验从头笼统考虑一下问题的实质。首要,咱们有一份装了许多东西的容器 A(普吉岛拜访记载),然后给咱们另一个装了许多东西的容器 B(新西兰拜访记载),之后界说持平规矩:“姓名与电话共同”。终究根据这个持平规矩,求 A 和 B 之间的“差集”

假设你对 Python 里的调集不是特别了解,我就略微多介绍一点。假设咱们具有两个调集 A 和 B,那么咱们能够直接运用 A-B这样的数学运算表达式来核算二者之间的差集

  1. >>> a = {1, 3, 5, 7}

  2. >>> b = {3, 5, 8}

  3. # 发作新调集:一切在 a 可是不在 b 里的元素

  4. >>> a - b

  5. {1, 7}

所以,核算“一切去过普吉岛但没去过新西兰的人”,其实便是一次调集的求差值操作。那么要怎么做,才能把咱们的问题套入到调集的游戏规矩里去呢?

运用调集的游戏规矩

在 Python 中,假设要把某个东西装到调集或字典里,一定要满意一个根本条件:“这个东西有必要是能够被哈希(Hashable)的”。什么是 “Hashable”?

举个比方,Python 里边的一切可变目标,比方字典,就 不是Hashable 的。当你测验把字典放入调集中时,会发作这样的过错:

  1. >>> s = set

  2. >>> s.add({'foo': 'bar'})

  3. Traceback (most recent call last):

  4. File "", line 1, in

  5. TypeError: unhashable type: 'dict'

所以,假设要运用调集处理咱们的问题,就首要得界说咱们自己的 “Hashable” 目标:VisitRecord。而要让一个自界说目标变得 Hashable,仅有要做的作业便是界说目标的__hash__办法。

  1. class VisitRecord:

  2. """旅行记载

  3. """

  4. def __init__(self, first_name, last_name, phone_number, date_visited):

  5. self.first_name = first_name

  6. self.last_name = last_name

  7. self.phone_number = phone_number

  8. self.date_visited = date_visited

一个好的哈希算法,应该让不同目标之间的值尽或许的仅有,这样能够最大程度削减“哈希磕碰”发作的概率,默许情况下,一切 Python 目标的哈希值来自它的内存地址。

在这个问题里,咱们需求自界说目标的 __hash__办法,让它运用(姓,名,电话)元组作为VisitRecord类的哈希值来历。

  1. def __hash__(self):

  2. return hash(

  3. (self.first_name, self.last_name, self.phone_number)

  4. )

自界说完 __hash__办法后,VisitRecord实例就能够正常的被放入调集中了。但这还不行,为了让前面说到的求差值算法正常作业,咱们还需求完结__eq__特别办法。

__eq__是 Python 在判别两个目标是否持平时调用的特别办法。默许情况下,它只需在自己和另一个目标的内存地址彻底共同时,才会回来True。可是在这儿,咱们复用了VisitRecord目标的哈希值,当二者持平时,就以为它们相同。

  1. def __eq__(self, other):

  2. # 当两条拜访记载的姓名与电话号持平时,断定二者持平。

  3. if isinstance(other, VisitRecord) and hash火鸡面-写了三年代码,仍是不明白 Python 国际的规矩(other) == hash(self):

  4. return True

  5. return False

完结了恰当的数据建模后,之后的求差值运算便算是瓜熟蒂落了。新版别的函数只需求一行代码就能完结操作:

  1. def find_potential_customers_v3:

  2. return set(VisitRecord(**r) for r in users_visited_phuket) - \

  3. set(VisitRecord(**r) for r in users_visited_nz)

Hint:假设你运火鸡面-写了三年代码,仍是不明白 Python 国际的规矩用的是 Python 2,那么除了 __eq__办法外,你还需求自界说类的__ne__(判别不持平时运用) 办法。

运用 dataclass 简化代码

故事到这儿并没有完毕。在上面的代码里,咱们手动界说了自己的 数据类VisitRecord,完结了__init____eq__等初始化办法。但其实还有更简略的做法。

由于界说数据类这种需求在 Python 中实在太常见了,所以在 3.7 版别中,规范库中新增了 dataclasses 模块,专门帮你简化这类作业。

假设运用 dataclasses 供给的特性,咱们的代码能够终究简化成下面这样:

  1. @dataclass(unsafe_hash=True)

  2. class VisitRecordDC:

  3. first_name: str

  4. last_name: str

  5. phone_number: str

  6. # 越过“拜访时刻”字段,不作为任何比照条件

  7. date_visited: str = field(hash=False, compare=False)



  8. def find_potential_customers_v4:

  9. return set(VisitRecordDC(**r) for r in users_visited_phuket) - \

  10. set(VisitRecordDC(**r) for r in users_visited_nz)

不必干任何脏活累活,只需不到十行代码就完结了作业。

事例总结

问题处理今后,让咱们再做一点小小的总结。在处理这个问题时,咱们总共运用了三种计划:

  1. 运用一般的两层循环挑选契合规矩的成果集

  2. 运用哈希表结构(set 目标)创立索引,提高处理功率

  3. 将数据转换为自界说目标,运用规矩,直接运用调集运算

为什么第三种办法会比前面两种好呢?

首要,第一个计划的功能问题过于显着,所以很快就会被抛弃。那么第二个计划呢?细心想想看,计划二其实并没有什么显着的缺陷。乃至和第三个计划比较,由于少了自界说目标的进程,它在功能与内存占用上,乃至有或许会轻轻强于后者。

但请再考虑一下,假设你把计划二的代码换成别的一种言语,比方 Java,它是不是根本能够做到 1:1 的彻底翻译?换句话说,它尽管功率高、代码直接,可是它没有彻底运用好 Python 国际供给的规矩,最大化的从中获益。

假设要详细化这个问题里的“规矩”,那便是 “Python 具有内置结构调集,调集之间能够进行差值等四则运算”这个现实自身。匹配规矩后编写的计划三代码具有下面这些优势:

  • 为数据建模后,能够更便利的界说其他办法

  • 假设需求改变,做反向差值运算、求交集运算都很简略

  • 了解调集与 dataclasses 逻辑后,代码远比其他版别更简练明晰

  • 假设要修正持平规矩,比方“只具有相同姓的记载就算作相同”,只需求承继 VisitRecord掩盖火鸡面-写了三年代码,仍是不明白 Python 国际的规矩__eq__办法即可

其他规矩怎么影响咱们

在前面,咱们花了很大的篇幅讲了怎么运用“调集的规矩”来编写事半功倍的代码。除此之外,Python 国际中还有着许多其他规矩。假设能熟练掌握这些规矩,就能够规划出契合 Python 常规的 API,让代码更简练精粹。

下面是两个详细的比方。

运用 __format__做目标字符串格局化

假设你的自界说目标需求界说多种字符串表明办法,就像下面这样:

  1. class Student:

  2. def __init__(self, name, age):

  3. self.name = name

  4. self.age = age


  5. def get_simple_display(self):

  6. return f'{self.name}({self.age})'


  7. def get_long_display(self):

  8. return f'{self.name} is {self.age} years old.'



  9. piglei = Student('piglei', '18')

  10. # OUTPUT: piglei(18)

  11. print(piglei.get_simple_display)

  12. # OUTPUT: piglei is 18 years old.

  13. print(piglei.get_long_display)

那么除了添加这种 get_xxx_display额定办法外,你还能够测验自界说Student类的__format__办法,由于那才是将目标变为字符串的规范规矩。

  1. class Student:

  2. def __init__(self, name, age):

  3. self.name = name

  4. self.age = age


  5. def __format__(self, format_spec):

  6. if format_spec == 'long':

  7. return f'{self.name} is {self.age} years old.'

  8. elif format_spec == 'simple':

  9. return f'{self.name}({self.age})'

  10. raise ValueError('invalid format spec')



  11. piglei = Student('piglei', '18')

  12. print('{0:simple}'.format(piglei))

  13. print('{0:long}'.format(piglei))

运用 __getitem__界说目标切片操作

假设你要规划某个能够装东西的容器类型,那么你很或许会为它界说“是否为空”、“获取第 N 个目标”等办法:

  1. class Events:

  2. def __init__(self, events):

  3. self.events = events


  4. def is_empty(self):

  5. return not bool(self.events)


  6. def list_events_by_range(self, start, end):

  7. return self.events[start:end]


  8. events = Events([

  9. 'computer started',

  10. 'os launched',

  11. 'dock二十四桥明月夜er started',

  12. 'os stopped',

  13. ])


  14. # 判别是否有内容,打印第二个和第三个目标

  15. if not events.is_empty:

  16. print(events.list_events_by_range(1, 3))

可是,这样并非最好的做法。由于 Python 现已为咱们供给了一套目标规矩,所以咱们不需求像写其他言语的 OO(面向目标) 代码那样去自己界说额定办法。咱们有更好的挑选:

  1. class Events:

  2. def __init__(self, events):

  3. self.events = events


  4. def __len__(self):

  5. """自界说长度,将会被用来做布尔判别"""

  6. return len(self.events)


  7. def __getitem__(self, index):

  8. """自界说切片办法"""

  9. # 直接将 slice 切片目标透传给 events 处理

  10. return self.events[index]


  11. # 判别是否有内容,打印第二个和第三个目标

  12. if events:

  13. print(events[1:3])

新的写法比较旧代码,更能适配进 Python 国际的规矩,API 也更为简练。

关于怎么适配规矩、写出更好的 Python 代码。Raymond Hettinger 在 PyCon 2015 上有过一次十分精彩的讲演 “Beyond PEP8 - Best practices for beautiful intelligible code”。这次讲演长时间排在我个人的 “PyCon 视频 TOP5” 名单上,假设你还没有看过,我强烈建议你现在就去看一遍 :)

Hint:更全面的 Python 目标模型规矩能够在 官方文档 找到,有点难读,但值得一读。

总结

Python 国际有着一套十分杂乱的规矩,这些规矩的包含规模包含“目标与目标是否持平“、”目标与目标谁大谁小”等等。它们大部分都需求经过从头界说“双下划线办法 __xxx__” 去完结。

假设了解这些规矩,并在日常编码中活用它们,有助于咱们更高效的处理问题、规划出更契合 Python 哲学的 API。下面是本文的一些关键总结:

  • 永久记住对原始需求做笼统剖析,比方问题是否能用调集求差集处理

  • 假设要把目标放入调集,需求自界说目标的 __hash____eq__办法

  • __hash__办法决议功能(磕碰呈现概率),__eq__决议目标间持平逻辑

  • 运用 dataclasses 模块能够让你少写许多代码

  • 运用 __format__办法代替自己界说的字符串格局化办法

  • 在容器类目标上运用 __len____getitem__办法,而不是自己完结

看完文章的你,有没有什么想吐槽的?请留言或许在 项目 Github Issues 告诉我吧。

回复下方「关键词」,获取优质资源

回复关键词「 pybook03」,当即获取主页君与小伙伴一同翻译的《Think Python 2e》电子版

回复关键词「pybooks02」,当即获取 O'Reilly 出版社推出的免费 Python 相关电子书合集

回复关键词「书单02」,当即获取主页君收拾的 10 本 Python 入门书的电子版

Python 或将逾越 C、Java,成为最受欢迎的言语

Python 容器运用的 5 个技巧和 2 个误区

怎么写出高雅的 Python 函数?

题图:pexels,CC0 授权。

二维码