ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

008、Python语法速成(下)

008、Python语法速成(下) 008、Python语法速成下昨晚线上告警一个跑了三年的数据处理脚本突然内存暴涨我登上去用py-spy dump一看线程卡在了一个看起来人畜无害的列表推导式里。那个脚本我写的当初图省事把一个百万级的生成器结果直接list()出来再切片。三年了数据量翻了几十倍终于在今天把内存撑爆了。我盯着屏幕上的MemoryError心想这不就是Python语法速成下篇最该讲的东西吗——你觉得自己会用Python但你真的会吗先说函数。Python的函数定义比C语言松泛得多但正是这种松泛埋了很多雷。默认参数那个坑我相信每个写过一段时间Python的人都踩过——你别摇头我知道你肯定踩过。看这个defadd_item(item,container[]):container.append(item)returncontainer你第一次调用add_item(1)返回[1]第二次调用add_item(2)返回[1, 2]。为什么因为默认参数[]在函数定义时只创建一次后续调用都复用它。我当年在写一个配置合并工具时就因为这个把不同模块的配置全串到了一起查了整整一个下午。别这样写正确的做法是defadd_item(item,containerNone):ifcontainerisNone:container[]container.append(item)returncontainer这里踩过坑的人自然懂没踩过的我提前帮你把坑填上。再讲*args和**kwargs。很多人以为它们是语法糖其实它们是打包和解包的操作符。*args把位置参数收成一个元组**kwargs把关键字参数收成一个字典。反过来在调用时你也可以用*和**去展开一个序列或字典。这个机制在做函数装饰器的时候特别有用但如果你只会在函数定义里抄一遍*args, **kwargs那就没意思了。真正的技巧在于你想在函数内部把收到的参数再传给另一个函数时这两个星号是唯一干净的传递方式。我见过有人用locals()把一堆局部变量拼成字典再传然后跑出来全是嵌套的{self: {...}}那叫一个酸爽。接下来是类和对象。Python的类机制和Java/C完全不同它本质上是运行时动态构建的。self不是关键字只是约定俗成。你完全可以把第一个参数改成this还能跑。但没人这么干因为你写了别人会骂你。类的属性查找顺序是先实例、再类、再父类这个顺序决定了你覆盖方法或者属性时的行为。很多人搞不清staticmethod和classmethod的区别简单说静态方法就是普通函数放在类里当工具用它不知道类也看不到实例类方法第一个参数是cls能访问类属性还能被继承后绑定到子类上。什么时候用类方法当你需要一个构造函数重载时比如User.from_dict(data)这时候必须用classmethod因为你拿不到实例而你想返回一个实例。异常处理这块我得说点难听的。大多数工程师写的try...except就是在敷衍自己。动不动就except Exception:然后print(e)跑起来跟没捕获一样日志里全是无用的堆栈。我见过生产环境里一个异常被吞掉导致后续所有数据都写进了错误的目录最后靠人工核对才捞回来。你至少应该区分异常类型捕获你能处理的处理不了的让它炸。还要学会try...except...else...finally——else是在没有异常时执行的代码块finally无论如何都执行。else这个分支很多教科书都不讲但它特别适合放在“如果成功我需要用那个返回值做后续操作”的场景。另外raise...from...这个语法能保留异常链排查问题的时候看到The above exception was the direct cause...就知道原始异常在哪别一raise就原地起飞把根因丢了。文件操作。open()是内置函数但你真的用它打开文件时别忘了上下文管理器with。这个不是装酷是保证文件一定会被关闭。如果你还写着fopen(data.txt,r)dataf.read()f.close()那你就是在赌代码中间不会抛异常。一旦抛了f.close()永远执行不到文件句柄泄漏Windows上你连删这个文件都删不掉。正确写法withopen(data.txt,r)asf:dataf.read()这行代码背后是__enter__和__exit__协议也就是上下文管理器。你不仅可以with文件还可以with一个线程锁、一个数据库连接、一个网络请求。任何需要“用完必须清理”的资源都应当用一个类实现__enter__和__exit__然后塞进with里。我自己写底层驱动的时候经常用contextlib.contextmanager这个装饰器把生成器函数变成上下文管理器代码量能省一半而且读起来像在描述流程不是像在管理资源生命周期。生成器这个是我今天最想聊的。你写一个函数里面有yield它就变成了生成器函数。调用这个函数不会执行函数体而是返回一个生成器对象。每次next()它才会跑到下一个yield处暂停。这个机制是惰性求值也就是你只需要生成当前需要的值不需要把所有值都放在内存里。回到我开头那个线上事故——如果当时我用的是生成器而不是list百万级数据根本不会一次性占用所有内存。你可能会说业务上我就是需要随机访问某个索引的值。那你应该用itertools.islice去切片生成器而不是把整个生成器拉成列表。记住能用生成器就绝不用列表除非你明确知道数据量小到可以忽略。还有列表推导式它和生成器表达式长得像但语法不同。列表推导式是方括号生成器表达式是圆括号# 这个会创建整个列表squares[x*xforxinrange(1000000)]# 这个只是创建了一个生成器squares_gen(x*xforxinrange(1000000))两者在写法上只差一个括号但内存消耗差了十万八千里。我见过有人把生成器表达式传给list()去构造列表然后又只遍历一次——那你为什么要造这个中间变量呢直接遍历生成器不香吗装饰器。Python的装饰器本质是一个接收函数、返回新函数的可调用对象。你用语法换来的是代码复用。我见过很多人为了装饰器而装饰器把三行逻辑包了八层。但装饰器的真正价值在于横切关注点——比如日志、鉴权、重试、计时。这些和业务逻辑无关但每个函数都要做。你写一个timed装饰器测量函数耗时然后往所有关键接口上一贴比在函数体内手写start time.time()干净十倍。但你得注意装饰器里的functools.wraps是必须的否则你会把原函数的__name__和__doc__都弄丢后续调试和sphinx文档生成都会出问题。这里踩过坑的人都知道我最早写装饰器时不加wraps结果flask里面注册路由全乱了吓得我以为是框架bug。说到functools顺便提一下partial。它本质上是提前绑定部分参数生成一个新的可调用对象。这个在回调场景特别有用。比如你写了一个事件循环某个事件触发要调用函数并且需要带参数但事件回调接口只允许传一个函数不传参数。这时候partial(handler, event_id, callbackxxx)就能把参数先绑好。这比用lambda干净多了而且partial对象还能pickle某些情况下lambda不行。作用域和闭包。Python的变量查找遵循LEGB规则局部、闭包、全局、内置。在函数内部修改全局变量要声明global修改外层函数变量要声明nonlocal。这两个关键字很容易被忽视直到你在多线程或回调函数里发现变量值不对才知道自己犯了错。闭包指的是内层函数引用了外层函数的变量并且这个引用关系在函数返回后依然保持。闭包能做出很优雅的工厂函数但如果用不好内存泄漏也常见——因为闭包会持有整个外层栈帧如果外层函数里有个大对象内层函数一直不被回收这个大对象就一直活着。我自己的习惯是写Python先追求可读性再追求性能。可读性的核心是“别人能一眼看出你在干什么”。比如enumerate替代range(len(...))zip替代两个索引循环items()替代keys()再去取值。这些内置函数都是帮你把循环意图表达清楚的。别小看这些你在review别人代码时看到for i in range(len(a)):就知道这人还在用C语言的思维写Python。最后说一点实际经验。如果你要写一个脚本处理大量数据请从一开始就考虑用生成器迭代器上下文管理器的组合。生成器负责惰性读取上下文管理器负责资源释放迭代器协议负责统一遍历接口。比如你处理一个十GB的日志文件普通open().read()直接死给你看但with open(...) as f:配合for line in f:它内部就是按行惰性读取内存占用只有一行大小。加上itertools模块里的groupby、chain、filterfalse几乎能不用写循环就把数据处理完。你会感觉像在搭建数据管道而不是在写命令式脚本。写这篇的时候我旁边刚好放着一本Python文档打印稿上面被我的咖啡印染了几个圈。说白了Python语法速成上篇教你怎么写下篇教你什么时候别写。很多坑不是语言设计的失败而是使用者的惯性思维踩出来的。你还是会在深夜碰到一个诡异的MemoryError还是会为了一个闭包变量苦思冥想三个小时。但只要你能分辨出列表和生成器的区别记着默认参数别用可变对象文件操作一定走with装饰器记得加wraps——我保证你至少不会死得那么难看。剩下的就交给时间和报错日志去打磨吧。
返回列表