高质量 developer 写 faster code (python 版)|Python 主题月

772 阅读10分钟

本文正在参加「Python主题月」,详情查看活动链接

今天我们聊一聊如何优化你的 python 代码,让代码运行 faster,通过对比实现同一个功能不同方法来解释说明如何优化你的程序。

简单易学的 Python

Python 的 faster 一个方便就是体现在上手块,易于上手,所以现在很多初中都开设了 Python 编程语言。

002.png

python 如此受欢迎的原因就是易于上手,例如 python 的 hello world 和 java 的 hello world 想必要简单。当你第一次选择语言,仅下面 java 和 python 的 Hello world 代码为依据,我感觉大多数人都会选择 python 这样简单语言。

public class HelloWord{
    public static void main(String[] args){
        System.out.println("Hello, world!")
    }
}

print("hello world!")

都谁在用 python

001.jpeg

大家看到上面图列出哪些公司正在使用 python,显而易见其中不少 Google、Firefox YaHoo 这样大公司,那么也就是数以千计的程序员和每天层出不穷的需求在支持 python 这门语言不断发展和进步。

优化代码有什么基本规则可循

005.png

第一条规律好的优化方案就是不做优化(Don't)

可能不需要浪费时间在代码优化上,只要购买足够快的硬件设备,这样可能要比花在代码优化时间。

优化时机是否成熟

通常在决定是否需要进行优化时,我们需要先问一问自己这些工作是否完成,功能是否实现,以及单元测试是否已经覆盖到可测试的代码? 通常在完成这两项工作后,我们才会考虑进行代码优化。然后源码级别优化,这部分内容是今天我们要展开分享的内容。接下就是构建、编译以及运行层级的优化,在这些层级上优化需要我们了解编译工具或者解释器帮助我们优化代码来适应编译和运行。

选择适当工具衡量我们优化是否有效果

有的时候我们优化代码,例如换一个有效算法,或者少写一两行代码。不过我们优化效果如何体现呢,所以在开始优化前,我们需要选择一个工具可以衡量代码优化后效果。

  • cProfile
  • pstats
  • RunSnakeRun,SnakeViz

优化的级别

优化不限于代码优化层面,这里有很多层面代码优化,例如设计层面的优化,对软件进行设计层面或者架构层面上优化,这是比较耗时的,所以没有必要应该很少会有人选择设计层面上的优化。还有算法上优化,这也是很多大厂为什么需要面试的原因。

006.jpeg

  • 设计层面的优化(design):
  • 算法和数据结构层面
sum = 0
N = 1_000_000
for x in range(1,N+1):
    sum += x
print(sum)
500000500000
# algorithm optimize
print(N * (1 + N)/2)
    500000500000.0
  • 源码层面
  • 构建层面
  • 编译层面
  • 运行时层面

代码优化

设置环境

我的环境是运行在 jupyter notebook,然后通过 %timeit 来查看代码的性能。是Python标准库内置的小工具,可以快速测试小段代码的性能。引入%timeit就是用数字来说明实现同一个功能,不同实现方式哪一个耗时更少更有效

%timeit

创建一个函数ultimate_answer_to_life

def ultimate_answer_to_life():
    return 42

调用函数时只要 %timeit 放置函数前即可。

%timeit ultimate_answer_to_life()
36.6 ns ± 0.297 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
  • 1秒(s) =1000毫秒(ms)
  • 1毫秒(ms)=1000微秒 (us)
  • 1微秒(us)=1000纳秒 (ns)
  • 1纳秒(ns)=1000皮秒 (ps)

在开始之前,我们需要这些时间单位,以及他们之间换算有所了解。

计算集合中元素的数量

how_many = 0
ONE_MILLION_ELEMENTS = range(1_000_000)
def count_element_in_a_list(how_many):
    ONE_MILLION_ELEMENTS = range(1_000_000)
    for element in ONE_MILLION_ELEMENTS:
        how_many += 1
#     print(how_many)

这种方法通过循环集合来将 how_many 这个变量增加 1,虽然这样可以实现统计集合中元素数量的功能。但是用这种方式统计略显笨拙。

%timeit count_element_in_a_list(how_many)
28.8 ms ± 202 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

接下来将统计集合中元素个数工作交len这个 python 内置函数来做。效率大大提升从 ms 级别降到 ns 数量级。

%timeit len(ONE_MILLION_ELEMENTS)
39.5 ns ± 0.258 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

尽可能用 python 内置函数,但不是所有情况下,都是选择内置函数是最好选择,看看下面例子。

对集合进行过滤

def filter_a_list():
    output = []
    for element in ONE_MILLION_ELEMENTS:
        if element % 2:
            output.append(element)

从一个集合中选择所有偶数,

%timeit filter_a_list()
48.2 ms ± 149 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

这一次我们用 python 内置函数 filter 来进行过滤,在 python2 返回的是一个 list 而在 python 3 中返回的是一个 iterator。

%timeit list(filter(lambda x: x % 2, ONE_MILLION_ELEMENTS))
62.1 ms ± 180 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

通过上面例子来看 filter 函数执行要比 for 循环慢一些,这是因为 filter 返回值由进行 list,list 函数对耗时做出一点贡献,让性能下降了一些,最好的做法是下面的做法,理论上要快 75%

%timeit [item for item in ONE_MILLION_ELEMENTS if item % 2 ]
34.6 ms ± 172 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Permission 还是 forgiveness

两种不用方式来判断对象或者 dict 是否具有某一个属性,如果有属性这将其输出,本来将给出什么是 Permission 方式,什么是 forgiveness 方式,最后还是放弃了去翻译,我们直接使用吧,用代码来说明什么是 Permission 什么是 forgivess 感觉是一个事前预防一个事后的补救。

class Foo(object):
    hello = 'world'
foo = Foo()
Permission
def hasattr_permissions_or_forgiveness():
    if hasattr(foo, 'hello'):
        foo.hello
%timeit hasattr_permissions_or_forgiveness()
89.9 ns ± 0.229 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

permission 方式就是先正确许可,判断一下类中是否有该属性,然后再去执行有关这个属性的操作,

Forgiveness
def try_catch_permission_or_forgiveness():
    try:
        foo.hello
    except AttributeError:
        pass
%timeit try_catch_permission_or_forgiveness()
56.4 ns ± 0.123 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

forgiveness 不会先去检测类别是否该属性,而是执行到使用该属性发生错误后,通过抛出异常,所以这种方式要优于 permission 方式。

forgiveness 要比 permission 方式快大约 2 倍

def hasattr_permissions():
    if (hasattr(foo,'foo') and hasattr(foo,'bar') and hasattr(foo,'baz')):
        foo.foo
        foo.bar
        foo.baz
%timeit hasattr_permissions()
75.7 ns ± 0.182 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
def try_catch_forgiveness():
    try:
        foo.foo
        foo.bar
        foo.baz
    except AttributeError:
        pass
        
%timeit try_catch_forgiveness()
293 ns ± 1.88 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

当要判断类别属性比较多,forvieness 的优势就变得更加明显。不过这种方式适合我们对该类别具有这个属性把握比较大时候,而把握不大时候还是 permission 这种效率反而高

判断元素是否为集合成员

def check_number(number):
    for element in ONE_MILLION_ELEMENTS:
        if element == number:
            return True
    return False
%timeit check_number(5000)
112 µs ± 75.3 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
%timeit 5000 in ONE_MILLION_ELEMENTS
58.6 ns ± 0.255 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)
%timeit 100 in ONE_MILLION_ELEMENTS
50.3 ns ± 0.136 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

显然判断某一个元素是否存在集合中,相比遍历每一个元素去判断,只用 element in collections这种方式更加直接好用,不过如果你要判断元素出现集合靠前位置,效率很很高,但是对于一个大的集合你的元素出现比较靠近集合尾部的位置,这耗时也会很长。

如果有点数据结构知识,我们知道在 set 这样数据结构搜索相对会比较快,所以我们可以使用 set 或者 dict 这样对于搜索进行优化的数据结构来定义集合,这样即使要搜素元素位置靠后,搜索时间也不会过长。

In [1]: MILLION_NUMBERS = 1_000_000


In [3]: MILLION_SET = set(range(MILLION_NUMBERS))

In [4]: %timeit 100 in  MILLION_SET
47.3 ns ± 3.38 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [5]: %timeit 999999 in  MILLION_SET
62.9 ns ± 2.12 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

列表去重

def remove_duplicates():
    unique = []
    for element in range(10000):
        if element not in unique:
            unique.append(element)
%timeit remove_duplicates()
380 ms ± 5.14 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit set(range(10000))
187 µs ± 1.76 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

对于列表去重,比较简单直观方式就是使用set对集合进行包裹,这种方式简单可靠。

列表排序

In [7]: %timeit sorted(range(MILLION_NUMBERS))
45.4 ms ± 2 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [9]: %timeit list(range(MILLION_NUMBERS)).sort()
45.1 ms ± 2.8 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

看是差别不大,不过如果抛开list这个操作,很明显调用一个集合的sort方法要比sorted包裹一个集合要快的多。

1000 操作要优于调用一个函数 1000 次

In [10]: def square(num):
    ...:     return num**2
    ...: 

In [11]: %timeit [square(i) for i in range(1000)]
437 µs ± 28.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

In [12]: def compute_squares():
    ...:     return [x**2 for x in range(1000)]
In [15]: %timeit compute_squares()
352 µs ± 20.1 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

多次调用调用函数来执行同一个操作,没有执行一次函数,在这函数实现多次操作这样做快。

检测一个变量是否为 True

if variable == True #35.8ns

if variable is True #28.7ns

if variable #20.6ns

检测一个变量是否为 False

if variable == False #35.1ns

if variable is False #26.9ns

if not variable #19.8ns

大家可能都发现了if variableif not variable来判断一个变量是否为 True 或者 False 是最好的方式,这是因为这个方式不会做一些额外判断,当变量为True 或者为 1 或者有一个元素数组时都会为 True。所以 if variable这种形式最快。

判断一个集合长度

if list(a_list) == 0:#91.7ns

if a_list == []: #56.3ns

if not a_list: #32.4ns

定义函数

In [19]: def greet(name):
    ...:     print(f"hi {name}")
    ...: 

In [20]: greet = lambda name: print(f"hi {name}")
  • 使用def来定义一个函数以及通过lambda来定义一个函数后,将这个匿名函数赋值给一个变量greet这种方式。这两种方式创建函数耗时都差不了多少,这是这些代码编译过程是相同,也就是所对应机器码是相同。lambda 还是比较流行,可以做 one-line 函数看起来整洁,也可以作为回调函数,或者作为参数使用。

创建列表和字典

In [23]: %timeit list()
119 ns ± 9.59 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

In [24]: %timeit []
33.3 ns ± 6 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

因为list()这种方式来创建一个列表,而[]创建列表的速度要远远快于list(),这是因为 [] 不用去解析函数,这是[]要比list()快的原因。

可读性

虽然我们在 coding 时候,时时刻刻考虑性能,但是除了性能以外我们考虑可读性。

In [27]: a=1

In [28]: b=2

In [29]: c=3

In [30]: d=5

In [31]: e=6


In [32]: a,b,c,d,e = 1,2,3,5,6

虽然后者效率要高,但是同可读性方面考虑并不可取。


In [34]: def squares(num_list):
    ...:     output = []
    ...:     for ele in num_list:
    ...:         output.append(ele*ele)
    ...:     return output
    ...: 

In [35]: def squares_faster(num_list):
    ...:     output = []
    ...:     append = output.append
    ...:     for ele in num_list:
    ...:         append(ele*ele)
    ...:     return output
    ...: 

虽然第二种方式要快,不多因为考虑可读性,并不如前一个函数可读性好,因为append并不便于理解。今天如何写出 faster 的 python 代码就是给大家开个头,希望我们平时多积累多动手,如果喜欢这篇文章,麻烦点赞、收藏分享一键三连。