代码书写规则写了三年代码,还是不懂Python世界的规则

文 | piglei@piglei 公众号 编辑 | EarlGrey 推荐 | 编程派公众号(ID:codingpy) 前言 编程,其实和玩电子游戏有一些相似之处。你在玩不同游戏前,需要先学习每个游戏的不同规则,只有熟悉和灵活运用
原标题:写了三年代码,还是不懂Python世界的规则文|piglei@piglei公众号编辑|EarlGrey推荐|编程派公众号(ID:codingpy)前言编程,其实和玩电子游戏有一些相似之处。你在玩不同游戏前,需要先学习每个游戏的不同规则,只有熟悉和灵活运用游戏规则,才更有可能在游戏中获胜。而编程也是一样,不同编程语言同样有着不一样的“规则”。大到是否支持面向对象,小到是否可以定义常量,编程语言的规则比绝大多数电子游戏要复杂的多。当我们编程时,如果直接拿一种语言的经验套用到另外一种语言上,很多时候并不能取得最佳结果。这就好像一个CS(反恐精英)高手在不了解规则的情况下去玩PUBG(绝地求生),虽然他的枪法可能万中无一,但是极有可能在发现第一个敌人前,他就会倒在某个窝在草丛里的敌人的伏击下。Python里的规则Python是一门初见简单、深入后愈觉复杂的语言。拿Python里最重要的“对象”概念来说,Python为其定义了多到让你记不全的规则,比如:定义了__str__方法的对象,就可以使用str函数来返回可读名称定义了__next__和__iter__方法的对象,就可以被循环迭代定义了__bool__方法的对象,在进行布尔判断时就会使用自定义的逻辑......熟悉规则,并让自己的代码适应这些规则,可以帮助我们写出更地道的代码,事半功倍的完成工作。下面,让我们来看一个有关适应规则的故事。案例:从两份旅游数据中获取人员名单某日,在一个主打新西兰出境游的旅游公司里,商务同事突然兴冲冲的跑过来找到我,说他从某合作伙伴那里,要到了两份重要的数据:所有去过“泰国普吉岛”的人员及联系方式所有去过“新西兰”的人员及联系方式数据采用了JSON格式,如下所示:#去过普吉岛的人员数据users_visited_phuket=[{\"first_name\":\"Sirena\",\"last_name\":\"Gross\",\"phone_number\":\"650-568-0388\",\"date_visited\":\"2018-03-14\"},{\"first_name\":\"James\",\"last_name\":\"Ashcraft\",\"phone_number\":\"412-334-4380\",\"date_visited\":\"2014-09-16\"},......]#去过新西兰的人员数据users_visited_nz=[{\"first_name\":\"Justin\",\"last_name\":\"Malcom\",\"phone_number\":\"267-282-1964\",\"date_visited\":\"2011-03-13\"},{\"first_name\":\"Albert\",\"last_name\":\"Potter\",\"phone_number\":\"702-249-3714\",\"date_visited\":\"2013-09-11\"},......]姓、名、手机号码、旅游时间四个字段。基于这份数据,商务同学提出了一个(听上去毫无道理)的假设:“去过普吉岛的人,应该对去新西兰旅游也很有兴趣。我们需要从这份数据里,找出那些去过普吉岛但没有去过新西兰的人有了原始数据和明确的需求,接下来的问题就是如何写代码了。依靠蛮力,我很快就写出了第一个方案:deffind_potential_customers_v1:\"\"\"找到去过普吉岛但是没去过新西兰的人forphuket_recordinusers_visited_phuket:is_potential=Truefornz_recordinusers_visited_nz:ifphuket_record[\'first_name\']==nz_record[\'first_name\']andphuket_record[\'last_name\']==nz_record[\'last_name\']andphuket_record[\'phone_number\']==nz_record[\'phone_number\']:is_potential=Falsebreakifis_potential:yieldphuket_record因为原始数据里没有“用户ID”之类的唯一标示,所以我们只能把“姓名和电话号码完全相同”作为判断是不是同一个人的标准。find_potential_customers_v1函数通过循环的方式,先遍历所有去过普吉岛的人,然后再遍历新西兰的人,如果在新西兰的记录中找不到完全匹配的记录,就把它当做“潜在客户”返回。它有着非常严重的性能问题。对于每一条去过普吉岛的记录,我们都需要遍历所有新西兰访问记录,尝试找到匹配。整个算法的时间复杂度是可怕的O(n*m),如果新西兰的访问条目数很多的话,那么执行它将耗费非常长的时间。尝试使用集合优化函数如果你对Python有所了解的话,那么你肯定知道,Python里的字典和集合对象都是基于哈希表(HashTable)实现的。判断一个东西是不是在集合里的平均时间复杂度是O(1)所以,对于上面的函数,我们可以先尝试针对新西兰访问记录初始化一个集合,之后的查找匹配部分就可以变得很快,函数整体时间复杂度就能变为O(n+m)。让我们看看新的函数:deffind_potential_customers_v2:\"\"\"找到去过普吉岛但是没去过新西兰的人,性能改进版#首先,遍历所有新西兰访问记录,创建查找索引nz_records_idx={(rec[\'first_name\'],rec[\'last_name\'],rec[\'phone_number\'])forrecinusers_visited_nz}forrecinusers_visited_phuket:key=(rec[\'first_name\'],rec[\'last_name\'],rec[\'phone_number\'])ifkeynotinnz_records_idx:yieldrec使用了集合对象后,新函数在速度上相比旧版本有了飞跃性的突破。但是,对这个问题的优化并不是到此为止,不然文章标题就应该改成:“如何使用集合提高程序性能”了。让我们来尝试重新抽象思考一下问题的本质。首先,我们有一份装了很多东西的容器A(普吉岛访问记录),然后给我们另一个装了很多东西的容器B(新西兰访问记录),之后定义相等规则:“姓名与电话一致”。最后基于这个相等规则,求A和B之间的“差集”。如果你对Python里的集合不是特别熟悉,我就稍微多介绍一点。假如我们拥有两个集合A和B,那么我们可以直接使用A-B这样的数学运算表达式来计算二者之间的差集。>>>a={1,3,5,7}>>>b={3,5,8}#产生新集合:所有在a但是不在b里的元素>>>a-b{1,7}在Python中,如果要把某个东西装到集合或字典里,一定要满足一个基本条件:“这个东西必须是可以被哈希(Hashable)的”。什么是“Hashable”?举个例子,Python里面的所有可变对象,比如字典,就不是Hashable的。当你尝试把字典放入集合中时,会发生这样的错误:>>>s=set>>>s.add({\'foo\':\'bar\'})TypeError:unhashabletype:\'dict\'所以,如果要利用集合解决我们的问题,就首先得定义我们自己的“Hashable”对象:VisitRecord。而要让一个自定义对象变得Hashable,唯一要做的事情就是定义对象的__hash__方法。classVisitRecord:\"\"\"旅游记录def__init__(self,first_name,last_name,phone_number,date_visited):self.first_name=first_nameself.last_name=last_nameself.phone_number=phone_numberself.date_visited=date_visited一个好的哈希算法,应该让不同对象之间的值尽可能的唯一,这样可以最大程度减少“哈希碰撞”发生的概率,默认情况下,所有Python对象的哈希值来自它的内存地址。在这个问题里,我们需要自定义对象的__hash__方法,让它利用(姓,名,电话)元组作为VisitRecord类的哈希值来源。def__hash__(self):returnhash((self.first_name,self.last_name,self.phone_number))自定义完__hash__VisitRecord实例就可以正常的被放入集合中了。但这还不够,为了让前面提到的求差值算法正常工作,我们还需要实现__eq__特殊方法。__eq__是Python在判断两个对象是否相等时调用的特殊方法。默认情况下,它只有在自己和另一个对象的内存地址完全一致时,才会返回TrueVisitRecord对象的哈希值,当二者相等时,就认为它们一样。def__eq__(self,other):#当两条访问记录的名字与电话号相等时,判定二者相等。ifisinstance(other,VisitRecord)andhash(other)==hash(self):returnTruereturnFalse完成了恰当的数据建模后,之后的求差值运算便算是水到渠成了。新版本的函数只需要一行代码就能完成操作:deffind_potential_customers_v3:returnset(VisitRecord(**r)forrinusers_visited_phuket)-set(VisitRecord(**r)forrinusers_visited_nz)Hint:如果你使用的是Python2,那么除了__eq__方法外,你还需要自定义类的__ne__(判断不相等时使用)方法。使用dataclass简化代码故事到这里并没有结束。在上面的代码里,我们手动定义了自己的数据类VisitRecord__init__、__eq__等初始化方法。但其实还有更简单的做法。因为定义数据类这种需求在Python中实在太常见了,所以在3.7版本中,标准库中新增了dataclasses模块,专门帮你简化这类工作。如果使用dataclasses提供的特性,我们的代码可以最终简化成下面这样:@dataclass(unsafe_hash=True)classVisitRecordDC:first_name:strlast_name:strphone_number:str#跳过“访问时间”字段,不作为任何对比条件date_visited:str=field(hash=False,compare=False)deffind_potential_customers_v4:returnset(VisitRecordDC(**r)forrinusers_visited_phuket)-set(VisitRecordDC(**r)forrinusers_visited_nz)不用干任何脏活累活,只要不到十行代码就完成了工作。问题解决以后,让我们再做一点小小的总结。在处理这个问题时,我们一共使用了三种方案:使用普通的两层循环筛选符合规则的结果集利用哈希表结构(set对象)创建索引,提升处理效率首先,第一个方案的性能问题过于明显,所以很快就会被放弃。那么第二个方案呢?仔细想想看,方案二其实并没有什么明显的缺点。甚至和第三个方案相比,因为少了自定义对象的过程,它在性能与内存占用上,甚至有可能会微微强于后者。但请再思考一下,如果你把方案二的代码换成另外一种语言,比如Java,它是不是基本可以做到1:1的完全翻译?换句话说,它虽然效率高、代码直接,但是它没有完全利用好Python世界提供的规则,最大化的从中受益。如果要具体化这个问题里的“规则”,那就是“Python拥有内置结构集合,集合之间可以进行差值等四则运算”这个事实本身。匹配规则后编写的方案三代码拥有下面这些优势:理解集合与dataclasses逻辑后,代码远比其他版本更简洁清晰如果要修改相等规则,比如“只拥有相同姓的记录就算作一样”,只需要继承VisitRecord覆盖__eq__方法即可在前面,我们花了很大的篇幅讲了如何利用“集合的规则”来编写事半功倍的代码。除此之外,Python世界中还有着很多其他规则。如果能熟练掌握这些规则,就可以设计出符合Python惯例的API,让代码更简洁精炼。使用__format__做对象字符串格式化如果你的自定义对象需要定义多种字符串表示方式,就像下面这样:classStudent:def__init__(self,name,age):self.name=nameself.age=agedefget_simple_display(self):returnf\'{self.name}({self.age})\'defget_long_display(self):returnf\'{self.name}is{self.age}yearsold.\'piglei=Student(\'piglei\',\'18\')#OUTPUT:piglei(18)print(piglei.get_simple_display)#OUTPUT:pigleiis18yearsold.print(piglei.get_long_display)get_xxx_display额外方法外,你还可以尝试自定义Student类的__format__方法,因为那才是将对象变为字符串的标准规则。classStudent:def__init__(self,name,age):self.name=nameself.age=agedef__format__(self,format_spec):ifformat_spec==\'long\':returnf\'{self.name}is{self.age}yearsold.\'elifformat_spec==\'simple\':returnf\'{self.name}({self.age})\'raiseValueError(\'invalidformatspec\')piglei=Student(\'piglei\',\'18\')print(\'{0:simple}\'.format(piglei))print(\'{0:long}\'.format(piglei))使用__getitem__定义对象切片操作如果你要设计某个可以装东西的容器类型,那么你很可能会为它定义“是否为空”、“获取第N个对象”等方法:classEvents:def__init__(self,events):self.events=eventsdefis_empty(self):returnnotbool(self.events)deflist_events_by_range(self,start,end):returnself.events[start:end]events=Events([\'computerstarted\',\'oslaunched\',\'dockerstarted\',\'osstopped\',])#判断是否有内容,打印第二个和第三个对象ifnotevents.is_empty:print(events.list_events_by_range(1,3))但是,这样并非最好的做法。因为Python已经为我们提供了一套对象规则,所以我们不需要像写其他语言的OO(面向对象)代码那样去自己定义额外方法。我们有更好的选择:classEvents:def__init__(self,events):self.events=eventsdef__len__(self):\"\"\"自定义长度,将会被用来做布尔判断\"\"\"returnlen(self.events)def__getitem__(self,index):\"\"\"自定义切片方法\"\"\"#直接将slice切片对象透传给events处理returnself.events[index]#判断是否有内容,打印第二个和第三个对象ifevents:print(events[1:3])新的写法相比旧代码,更能适配进Python世界的规则,API也更为简洁。关于如何适配规则、写出更好的Python代码。RaymondHettinger在PyCon2015上有过一次非常精彩的演讲“BeyondPEP8-Bestpracticesforbeautifulintelligiblecode”。这次演讲长期排在我个人的“PyCon视频TOP5”名单上,如果你还没有看过,我强烈建议你现在就去看一遍:)Hint:更全面的Python对象模型规则可以在官方文档找到,有点难读,但值得一读。总结Python世界有着一套非常复杂的规则,这些规则的涵盖范围包括“对象与对象是否相等“、”对象与对象谁大谁小”等等。它们大部分都需要通过重新定义“双下划线方法__xxx__”去实现。如果熟悉这些规则,并在日常编码中活用它们,有助于我们更高效的解决问题、设计出更符合Python哲学的API。下面是本文的一些要点总结:永远记得对原始需求做抽象分析,比如问题是否能用集合求差集解决如果要把对象放入集合,需要自定义对象的__hash__与__eq__方法__hash__方法决定性能(碰撞出现概率),__eq__决定对象间相等逻辑使用dataclasses模块可以让你少写很多代码使用__format__方法替代自己定义的字符串格式化方法在容器类对象上使用__len__、__getitem__方法,而不是自己实现看完文章的你,有没有什么想吐槽的?请留言或者在项目GithubIssues告诉我吧。责任编辑:

本文来自投稿,不代表长河网立场,转载请注明出处: http://www.changhe99.com/a/mEroBeJo6O.html

(0)

相关推荐