最新消息:

Python程序员必知必会的开发者工具

python admin 2845浏览 0评论

Python已经演化出了一个广泛的生态系统,该生态系统能够让Python程序员的生活变得更加简单,减少他们重复造轮的工作。同样的理念也适用于工具开发者的工作,即便他们开发出的工具并没有出现在最终的程序中。本文将介绍Python程序员必知必会的开发者工具。

 

对于开发者来说,最实用的帮助莫过于帮助他们编写代码文档了。pydoc模块可以根据源代码中的docstrings为任何可导入模块生成格式良好的文档。Python包含了两个测试框架来自动测试代码以及验证代码的正确性:1)doctest模块,该模块可以从源代码或独立文件的例子中抽取出测试用例。2)unittest模块,该模块是一个全功能的自动化测试框架,该框架提供了对测试准备(test fixtures), 预定义测试集(predefined test suite)以及测试发现(test discovery)的支持。

trace模块可以监控Python执行程序的方式,同时生成一个报表来显示程序的每一行执行的次数。这些信息可以用来发现未被自动化测试集所覆盖的程序执行路径,也可以用来研究程序调用图,进而发现模块之间的依赖关系。编写并执行测试可以发现绝大多数程序中的问题,Python使得debug工作变得更加简单,这是因为在大部分情况下,Python都能够将未被处理的错误打印到控制台中,我们称这些错误信息为traceback。如果程序不是在文本控制台中运行的,traceback也能够将错误信息输出到日志文件或是消息对话框中。当标准的traceback无法提供足够的信息时,可以使用cgitb 模块来查看各级栈和源代码上下文中的详细信息,比如局部变量。cgitb模块还能够将这些跟踪信息以HTML的形式输出,用来报告web应用中的错误。

一旦发现了问题出在哪里后,就需要使用到交互式调试器进入到代码中进行调试工作了,pdb模块能够很好地胜任这项工作。该模块可以显示出程序在错误产生时的执行路径,同时可以动态地调整对象和代码进行调试。当程序通过测试并调试后,下一步就是要将注意力放到性能上了。开发者可以使用profile以及timit模块来测试程序的速度,找出程序中到底是哪里很慢,进而对这部分代码独立出来进行调优的工作。Python程序是通过解释器执行的,解释器的输入是原有程序的字节码编译版本。这个字节码编译版本可以在程序执行时动态地生成,也可以在程序打包的时候就生成。compileall模块可以处理程序打包的事宜,它暴露出了打包相关的接口,该接口能够被安装程序和打包工具用来生成包含模块字节码的文件。同时,在开发环境中,compileall模块也可以用来验证源文件是否包含了语法错误。

在源代码级别,pyclbr模块提供了一个类查看器,方便文本编辑器或是其他程序对Python程序中有意思的字符进行扫描,比如函数或者是类。在提供了类查看器以后,就无需引入代码,这样就避免了潜在的副作用影响。


文档字符串与doctest模块

如果函数,类或者是模块的第一行是一个字符串,那么这个字符串就是一个文档字符串。可以认为包含文档字符串是一个良好的编程习惯,这是因为这些字符串可以给Python程序开发工具提供一些信息。比如,help()命令能够检测文档字符串,Python相关的IDE也能够进行检测文档字符串的工作。由于程序员倾向于在交互式shell中查看文档字符串,所以最好将这些字符串写的简短一些。例如

1
2
3
4
5
6
7
8
9
10
11
12
# mult.py
class Test:
    """
    >>> a=Test(5)
    >>> a.multiply_by_2()
    10
    """
    def __init__(self, number):
        self._number=number
    def multiply_by_2(self):
        return self._number*2

在编写文档时,一个常见的问题就是如何保持文档和实际代码的同步。例如,程序员也许会修改函数的实现,但是却忘记了更新文档。针对这个问题,我们可以使用doctest模块。doctest模块收集文档字符串,并对它们进行扫描,然后将它们作为测试进行执行。为了使用doctest模块,我们通常会新建一个用于测试的独立的模块。例如,如果前面的例子Test class包含在文件mult.py中,那么,你应该新建一个testmult.py文件用来测试,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# testmult.py
import mult, doctest
doctest.testmod(mult, verbose=True)
# Trying:
#     a=Test(5)
# Expecting nothing
# ok
# Trying:
#     a.multiply_by_2()
# Expecting:
#     10
# ok
# 3 items had no tests:
#     mult
#     mult.Test.__init__
#     mult.Test.multiply_by_2
# 1 items passed all tests:
#    2 tests in mult.Test
# 2 tests in 4 items.
# 2 passed and 0 failed.
# Test passed.

在这段代码中,doctest.testmod(module)会执行特定模块的测试,并且返回测试失败的个数以及测试的总数目。如果所有的测试都通过了,那么不会产生任何输出。否则的话,你将会看到一个失败报告,用来显示期望值和实际值之间的差别。如果你想看到测试的详细输出,你可以使用testmod(module, verbose=True).

如果不想新建一个单独的测试文件的话,那么另一种选择就是在文件末尾包含相应的测试代码:

1
2
3
if __name__ == '__main__':
    import doctest
    doctest.testmod()

如果想执行这类测试的话,我们可以通过-m选项调用doctest模块。通常来讲,当执行测试的时候没有任何的输出。如果想查看详细信息的话,可以加上-v选项。

1
$ python -m doctest -v mult.py

单元测试与unittest模块

如果想更加彻底地对程序进行测试,我们可以使用unittest模块。通过单元测试,开发者可以为构成程序的每一个元素(例如,独立的函数,方法,类以及模块)编写一系列独立的测试用例。当测试更大的程序时,这些测试就可以作为基石来验证程序的正确性。当我们的程序变得越来越大的时候,对不同构件的单元测试就可以组合起来成为更大的测试框架以及测试工具。这能够极大地简化软件测试的工作,为找到并解决软件问题提供了便利。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# splitter.py
import unittest
def split(line, types=None, delimiter=None):
    """Splits a line of text and optionally performs type conversion.
    ...
    """
    fields = line.split(delimiter)
    if types:
        fields = [ ty(val) for ty,val in zip(types,fields) ]
    return fields
class TestSplitFunction(unittest.TestCase):
    def setUp(self):
        # Perform set up actions (if any)
        pass
    def tearDown(self):
        # Perform clean-up actions (if any)
        pass
    def testsimplestring(self):
        r = split('GOOG 100 490.50')
        self.assertEqual(r,['GOOG','100','490.50'])
    def testtypeconvert(self):
        r = split('GOOG 100 490.50',[str, int, float])
        self.assertEqual(r,['GOOG', 100, 490.5])
    def testdelimiter(self):
        r = split('GOOG,100,490.50',delimiter=',')
        self.assertEqual(r,['GOOG','100','490.50'])
# Run the unittests
if __name__ == '__main__':
    unittest.main()
#...
#----------------------------------------------------------------------
#Ran 3 tests in 0.001s
#OK

在使用单元测试时,我们需要定义一个继承自unittest.TestCase的类。在这个类里面,每一个测试都以方法的形式进行定义,并都以test打头进行命名——例如,’testsimplestring‘,’testtypeconvert‘以及类似的命名方式(有必要强调一下,只要方法名以test打头,那么无论怎么命名都是可以的)。在每个测试中,断言可以用来对不同的条件进行检查。

实际的例子:

假如你在程序里有一个方法,这个方法的输出指向标准输出(sys.stdout)。这通常意味着是往屏幕上输出文本信息。如果你想对你的代码进行测试来证明这一点,只要给出相应的输入,那么对应的输出就会被显示出来。

1
2
3
4
5
# url.py
def urlprint(protocol, host, domain):
    url = '{}://{}.{}'.format(protocol, host, domain)
    print(url)

内置的print函数在默认情况下会往sys.stdout发送输出。为了测试输出已经实际到达,你可以使用一个替身对象对其进行模拟,并且对程序的期望值进行断言。unittest.mock模块中的patch()方法可以只在运行测试的上下文中才替换对象,在测试完成后就立刻返回对象原始的状态。下面是urlprint()方法的测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#urltest.py
from io import StringIO
from unittest import TestCase
from unittest.mock import patch
import url
class TestURLPrint(TestCase):
    def test_url_gets_to_stdout(self):
        protocol = 'http'
        host = 'www'
        domain = 'example.com'
        expected_url = '{}://{}.{}n'.format(protocol, host, domain)
        with patch('sys.stdout', new=StringIO()) as fake_out:
            url.urlprint(protocol, host, domain)
            self.assertEqual(fake_out.getvalue(), expected_url)

urlprint()函数有三个参数,测试代码首先给每个参数赋了一个假值。变量expected_url包含了期望的输出字符串。为了能够执行测试,我们使用了unittest.mock.patch()方法作为上下文管理器,把标准输出sys.stdout替换为了StringIO对象,这样发送的标准输出的内容就会被StringIO对象所接收。变量fake_out就是在这一过程中所创建出的模拟对象,该对象能够在with所处的代码块中所使用,来进行一系列的测试检查。当with语句完成时,patch方法能够将所有的东西都复原到测试执行之前的状态,就好像测试没有执行一样,而这无需任何额外的工作。但对于某些Python的C扩展来讲,这个例子却显得毫无意义,这是因为这些C扩展程序绕过了sys.stdout的设置,直接将输出发送到了标准输出上。这个例子仅适用于纯Python代码的程序(如果你想捕获到类似C扩展的输入输出,那么你可以通过打开一个临时文件然后将标准输出重定向到该文件的技巧来进行实现)。


Python调试器与pdb模块

Python在pdb模块中包含了一个简单的基于命令行的调试器。pdb模块支持事后调试(post-mortem debugging),栈帧探查(inspection of stack frames),断点(breakpoints),单步调试(single-stepping of source lines)以及代码审查(code evaluation)。

好几个函数都能够在程序中调用调试器,或是在交互式的Python终端中进行调试工作。

在所有启动调试器的函数中,函数set_trace()也许是最简易实用的了。如果在复杂程序中发现了问题,可以在代码中插入set_trace()函数,并运行程序。当执行到set_trace()函数时,这就会暂停程序的执行并直接跳转到调试器中,这时候你就可以大展手脚开始检查运行时环境了。当退出调试器时,调试器会自动恢复程序的执行。

假设你的程序有问题,你想找到一个简单的方法来对它进行调试。

如果你的程序崩溃时报了一个异常错误,那么你可以用python3 -i someprogram.py这个命令来运行你的程序,这能够很好地发现问题所在。-i选项表明只要程序终结就立即启动一个交互式shell。在这个交互式shell中,你就可以很好地探查到底发生了什么导致程序的错误。例如,如果你有以下代码:

1
2
3
4
def function(n):
    return n + 10
function("Hello")

如果使用python3 -i 命令运行程序就会产生如下输出:

1
2
3
4
5
6
7
8
9
10
python3 -i sample.py
Traceback (most recent call last):
  File "sample.py", line 4, in <module>
    function("Hello")
  File "sample.py", line 2, in function
    return n + 10
TypeError: Can't convert 'int' object to str implicitly
>>> function(20)
30
>>>

如果你没有发现什么明显的错误,那么你可以进一步地启动Python调试器。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
>>> import pdb
>>> pdb.pm()
> sample.py(4)func()
-> return n + 10
(Pdb) w
sample.py(6)<module>()
-> func('Hello')
> sample.py(4)func()
-> return n + 10
(Pdb) print n
'Hello'
(Pdb) q
>>>

如果你的代码身处的环境很难启动一个交互式shell的话(比如在服务器环境下),你可以增加错误处理的代码,并自己输出跟踪信息。例如:

1
2
3
4
5
6
7
import traceback
import sys
try:
    func(arg)
except:
    print('**** AN ERROR OCCURRED ****')
    traceback.print_exc(file=sys.stderr)

如果你的程序并没有崩溃,而是说程序的行为与你的预期表现的不一致,那么你可以尝试在一些可能出错的地方加入print()函数。如果你打算采用这种方案的话,那么还有些相关的技巧值得探究。首先,函数traceback.print_stack()能够在被执行时立即打印出程序中栈的跟踪信息。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> def sample(n):
...     if n > 0:
...         sample(n-1)
...     else:
...         traceback.print_stack(file=sys.stderr)
...
>>> sample(5)
File "<stdin>", line 1, in <module>
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 3, in sample
File "<stdin>", line 5, in sample
>>>

另外,你可以在程序中任意一处使用pdb.set_trace()手动地启动调试器,就像这样:

1
2
3
4
5
import pdb
def func(arg):
    ...
    pdb.set_trace()
    ...

在深入解析大型程序的时候,这是一个非常实用的技巧,这样操作能够清楚地了解程序的控制流或是函数的参数。比如,一旦调试器启动了之后,你就可以使用print或者w命令来查看变量,来了解栈的跟踪信息。

在进行软件调试时,千万不要让事情变得很复杂。有时候仅仅需要知道程序的跟踪信息就能够解决大部分的简单错误(比如,实际的错误总是显示在跟踪信息的最后一行)。在实际的开发过程中,将print()函数插入到代码中也能够很方便地显示调试信息(只需要记得在调试完以后将print语句删除掉就行了)。调试器的通用用法是在崩溃的函数中探查变量的值,知道如何在程序崩溃以后再进入到调试器中就显得非常实用。在程序的控制流不是那么清楚的情况下,你可以插入pdb.set_trace()语句来理清复杂程序的思路。本质上,程序会一直执行直到遇到set_trace()调用,之后程序就会立刻跳转进入到调试器中。在调试器里,你就可以进行更多的尝试。如果你正在使用Python的IDE,那么IDE通常会提供基于pdb的调试接口,你可以查阅IDE的相关文档来获取更多的信息。

下面是一些Python调试器入门的资源列表:

  1. 阅读Steve Ferb的文章 “Debugging in Python”
  2. 观看Eric Holscher的截图 “Using pdb, the Python Debugger”
  3. 阅读Ayman Hourieh的文章 “Python Debugging Techniques”
  4. 阅读 Python documentation for pdb – The Python Debugger
  5. 阅读Karen Tracey的D jango 1.1 Testing and Debugging一书中的第九章——When You Don’t Even Know What to Log: Using Debuggers

程序分析

profile模块和cProfile模块可以用来分析程序。它们的工作原理都一样,唯一的区别是,cProfile模块是以C扩展的方式实现的,如此一来运行的速度也快了很多,也显得比较流行。这两个模块都可以用来收集覆盖信息(比如,有多少函数被执行了),也能够收集性能数据。对一个程序进行分析的最简单的方法就是运行这个命令:

1
% python -m cProfile someprogram.py

此外,也可以使用profile模块中的run函数:

1
run(command [, filename])

该函数会使用exec语句执行command中的内容。filename是可选的文件保存名,如果没有filename的话,该命令的输出会直接发送到标准输出上。

下面是分析器执行完成时的输出报告:

1
2
3
4
5
6
7
8
9
126 function calls (6 primitive calls) in 5.130 CPU seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.030 0.030 5.070 5.070 <string>:1(?)
121/1 5.020 0.041 5.020 5.020 book.py:11(process)
1 0.020 0.020 5.040 5.040 book.py:5(?)
2 0.000 0.000 0.000 0.000 exceptions.py:101(_ _init_ _)
1 0.060 0.060 5.130 5.130 profile:0(execfile('book.py'))
0 0.000 0.000 profile:0(profiler)

当输出中的第一列包含了两个数字时(比如,121/1),后者是元调用(primitive call)的次数,前者是实际调用的次数(译者注:只有在递归情况下,实际调用的次数才会大于元调用的次数,其他情况下两者都相等)。对于绝大部分的应用程序来讲使用该模块所产生的的分析报告就已经足够了,比如,你只是想简单地看一下你的程序花费了多少时间。然后,如果你还想将这些数据保存下来,并在将来对其进行分析,你可以使用pstats模块。

假设你想知道你的程序究竟在哪里花费了多少时间。

如果你只是想简单地给你的整个程序计时的话,使用Unix中的time命令就已经完全能够应付了。例如:

1
2
3
4
5
bash % time python3 someprogram.py
real 0m13.937s
user 0m12.162s
sys 0m0.098s
bash %

通常来讲,分析代码的程度会介于这两个极端之间。比如,你可能已经知道你的代码会在一些特定的函数中花的时间特别多。针对这类特定函数的分析,我们可以使用修饰器decorator,例如:

1
2
3
4
5
6
7
8
9
10
11
12
import time
from functools import wraps
def timethis(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        r = func(*args, **kwargs)
        end = time.perf_counter()
        print('{}.{} : {}'.format(func.__module__, func.__name__, end - start))
        return r
    return wrapper

使用decorator的方式很简单,你只需要把它放在你想要分析的函数的定义前面就可以了。例如:

1
2
3
4
5
6
7
8
>>> @timethis
... def countdown(n):
...     while n > 0:
...         n -= 1
...
>>> countdown(10000000)
__main__.countdown : 0.803001880645752
>>>

如果想要分析一个语句块的话,你可以定义一个上下文管理器(context manager)。例如:

1
2
3
4
5
6
7
8
9
10
11
import time
from contextlib import contextmanager
@contextmanager
def timeblock(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        end = time.perf_counter()
        print('{} : {}'.format(label, end - start))

接下来是如何使用上下文管理器的例子:

1
2
3
4
5
6
7
>>> with timeblock('counting'):
...     n = 10000000
...     while n > 0:
...         n -= 1
...
counting : 1.5551159381866455
>>>

如果想研究一小段代码的性能的话,timeit模块会非常有用。例如:

1
2
3
4
5
6
>>> from timeit import timeit
>>> timeit('math.sqrt(2)', 'import math')
0.1432319980012835
>>> timeit('sqrt(2)', 'from math import sqrt')
0.10836604500218527
>>>

timeit的工作原理是,将第一个参数中的语句执行100万次,然后计算所花费的时间。第二个参数指定了一些测试之前需要做的环境准备工作。如果你需要改变迭代的次数,可以附加一个number参数,就像这样:

1
2
3
4
5
>>> timeit('math.sqrt(2)', 'import math', number=10000000)
1.434852126003534
>>> timeit('sqrt(2)', 'from math import sqrt', number=10000000)
1.0270336690009572
>>>

当进行性能评估的时候,要牢记任何得出的结果只是一个估算值。函数time.perf_counter()能够在任一平台提供最高精度的计时器。然而,它也只是记录了自然时间,记录自然时间会被很多其他因素影响,比如,计算机的负载。如果你对处理时间而非自然时间感兴趣的话,你可以使用time.process_time()。例如:

1
2
3
4
5
6
7
8
9
10
11
12
import time
from functools import wraps
def timethis(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.process_time()
        r = func(*args, **kwargs)
        end = time.process_time()
        print('{}.{} : {}'.format(func.__module__, func.__name__, end - start))
        return r
    return wrapper

最后也是相当重要的就是,如果你想做一个详细的性能评估的话,你最好查阅time,timeit以及其他相关模块的文档,这样你才能够对平台相关的不同之处有所了解。

profile模块中最基础的东西就是run()函数了。该函数会把一个语句字符串作为参数,然后在执行语句时生成所花费的时间报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import profile
def fib(n):
    # from literateprograms.org
    # http://bit.ly/hlOQ5m
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n-1) + fib(n-2)
def fib_seq(n):
    seq = []
    if n > 0:
        seq.extend(fib_seq(n-1))
    seq.append(fib(n))
    return seq
profile.run('print(fib_seq(20)); print')

性能优化

当你的程序运行地很慢的时候,你就会想去提升它的运行速度,但是你又不想去借用一些复杂方案的帮助,比如使用C扩展或是just-in-time(JIT)编译器。

那么这时候应该怎么办呢?要牢记性能优化的第一要义就是“不要为了优化而去优化,应该在我们开始写代码之前就想好应该怎样编写高性能的代码”。第二要义就是“优化一定要抓住重点,找到程序中最重要的地方去优化,而不要去优化那些不重要的部分”。

通常来讲,你会发现你的程序在某些热点上花费了很多时间,比如内部数据的循环处理。一旦你发现了问题所在,你就可以对症下药,让你的程序更快地执行。

 使用函数

许多开发者刚开始的时候会将Python作为一个编写简单脚本的工具。当编写脚本的时候,很容易就会写一些没有结构的代码出来。例如:

1
2
3
4
5
import sys
import csv
with open(sys.argv[1]) as f:
    for row in csv.reader(f):
    # Some kind of processing

但是,却很少有人知道,定义在全局范围内的代码要比定义在函数中的代码执行地慢。他们之间速度的差别是因为局部变量与全局变量不同的实现所引起的(局部变量的操作要比全局变量来得快)。所以,如果你想要让程序更快地运行,那么你可以简单地将代码放在一个函数中,就像这样:

1
2
3
4
5
6
7
8
import sys
import csv
def main(filename):
    with open(filename) as f:
        for row in csv.reader(f):
            # Some kind of processing
            ...
main(sys.argv[1])

这样操作以后,处理速度会有提升,但是这个提升的程度依赖于程序的复杂性。根据经验来讲,通常都会提升15%到30%之间。

选择性地减少属性的访问

当使用点(.)操作符去访问属性时都会带来一定的消耗。本质上来讲,这会触发一些特殊方法的执行,比如__getattribute__()和__getattr__(),这通常都会导致去内存中字典数据的查询。

你可以通过两种方式来避免属性的访问,第一种是使用from module import name的方式。第二种是将对象的方法名保存下来,在调用时直接使用。为了解释地更加清楚,我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
import math
def compute_roots(nums):
    result = []
    for n in nums:
        result.append(math.sqrt(n))
    return result
# Test
nums = range(1000000)
for n in range(100):
    r = compute_roots(nums)

上面的代码在我的计算机上运行大概需要40秒的时间。现在我们把上面代码中的compute_roots()函数改写一下:

1
2
3
4
5
6
7
8
9
10
11
from math import sqrt
def compute_roots(nums):
    result = []
    result_append = result.append
    for n in nums:
        result_append(sqrt(n))
    return result
nums = range(1000000)
for n in range(100):
    r = compute_roots(nums)

这个版本的代码执行一下大概需要29秒。这两个版本的代码唯一的不同之处在于后面一个版本减少了对属性的访问。在后面一段代码中,我们使用了sqrt()方法,而非math.sqrt()。result.append()函数也被存进了一个局部变量result_append中,然后在循环当中重复使用。

然而,有必要强调一点是说,这种方式的优化仅仅针对经常运行的代码有效,比如循环。由此可见,优化仅仅在那些小心挑选出来的地方才会真正得到体现。

理解变量的局部性

上面已经讲过,局部变量的操作比全局变量来得快。对于经常要访问的变量来说,最好把他们保存成局部变量。例如,考虑刚才已经讨论过的compute_roots()函数修改版:

1
2
3
4
5
6
7
8
9
import math
def compute_roots(nums):
    sqrt = math.sqrt
    result = []
    result_append = result.append
    for n in nums:
        result_append(sqrt(n))
    return result

在这个版本中,sqrt函数被一个局部变量所替代。如果你执行这段代码的话,大概需要25秒就执行完了(前一个版本需要29秒)。 这次速度的提升是因为sqrt局部变量的查询比sqrt函数的全局查询来得稍快。

局部性原来同样适用于类的参数。通常来讲,使用self.name要比直接访问局部变量来得慢。在内部循环中,我们可以将经常要访问的属性保存为一个局部变量。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
#Slower
class SomeClass:
    ...
    def method(self):
        for x in s:
            op(self.value)
# Faster
class SomeClass:
...
def method(self):
    value = self.value
    for x in s:
        op(value)

 避免不必要的抽象

任何时候当你想给你的代码添加其他处理逻辑,比如添加装饰器,属性或是描述符,你都是在拖慢你的程序。例如,考虑这样一个类:

1
2
3
4
5
6
7
8
9
10
11
12
class A:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    @property
    def y(self):
        return self._y
    @y.setter
    def y(self, value):
        self._y = value

现在,让我们简单地测试一下:

1
2
3
4
5
6
7
>>> from timeit import timeit
>>> a = A(1,2)
>>> timeit('a.x', 'from __main__ import a')
0.07817923510447145
>>> timeit('a.y', 'from __main__ import a')
0.35766440676525235
>>>

正如你所看到的,我们访问属性y比访问简单属性x不是慢了一点点,整整慢了4.5倍之多。如果你在乎性能的话,你就很有必要问一下你自己,对y的那些额外的定义是否都是必要的了。如果不是的话,那么你应该把那些额外的定义删掉,用一个简单的属性就够了。如果只是因为在其他语言里面经常使用getter和setter函数的话,你完全没有必要在Python中也使用相同的编码风格。

使用内置的容器

内置的数据结构,例如字符串(string),元组(tuple),列表(list),集合(set)以及字典(dict)都是用C语言实现的,正是因为采用了C来实现,所以它们的性能表现也很好。如果你倾向于使用你自己的数据结构作为替代的话(例如,链表,平衡树或是其他数据结构),想达到内置数据结构的速度的话是非常困难的。因此,你应该尽可能地使用内置的数据结构。

避免不必要的数据结构或是数据拷贝

有时候程序员会有点儿走神,在不该用到数据结构的地方去用数据结构。例如,有人可能会写这样的的代码:

1
2
values = [x for x in sequence]
squares = [x*x for x in values]

也许他这么写是为了先得到一个列表,然后再在这个列表上进行一些操作。但是第一个列表是完全没有必要写在这里的。我们可以简单地把代码写成这样就行了:

1
squares = [x*x for x in sequence]

有鉴于此,你要小心那些偏执程序员所写的代码了,这些程序员对Python的值共享机制非常偏执。函数copy.deepcopy()的滥用也许是一个信号,表明该代码是由菜鸟或者是不相信Python内存模型的人所编写的。在这样的代码里,减少copy的使用也许会比较安全。

在优化之前,很有必要先详细了解一下你所要使用的算法。如果你能够将算法的复杂度从O(n^2)降为O(n log n)的话,程序的性能将得到极大的提高。

如果你已经打算进行优化工作了,那就很有必要全局地考虑一下。普适的原则就是,不要想去优化程序的每一个部分,这是因为优化工作会让代码变得晦涩难懂。相反,你应该把注意力集中在已知的性能瓶颈处,例如内部循环。

你需要谨慎地对待微优化(micro-optimization)的结果。例如,考虑下面两种创建字典结构的方式:

1
2
3
4
5
6
a = {
'name' : 'AAPL',
'shares' : 100,
'price' : 534.22
}
b = dict(name='AAPL', shares=100, price=534.22)

后面那一种方式打字打的更少一些(因为你不必将key的名字用双引号括起来)。然而当你将这两种编码方式进行性能对比时,你会发现使用dict()函数的方式比另一种慢了3倍之多!知道了这一点以后,你也许会倾向于扫描你的代码,把任何出现dict()的地方替换为另一种冗余的写法。然而,一个聪明的程序员绝对不会这么做,他只会将注意力放在值得关注的地方,比如在循环上。在其他地方,速度的差异并不是最重要的。但是,如果你想让你的程序性能有质的飞跃的话,你可以去研究下基于JIT技术的工具。比如,PyPy项目,该项目是Python解释器的另一种实现,它能够分析程序的执行并为经常执行的代码生成机器码,有时它甚至能够让Python程序的速度提升一个数量级,达到(甚至超过)C语言编写的代码的速度。但是不幸的是,在本文正在写的时候,PyPy还没有完全支持Python 3。所以,我们还是在将来再来看它到底会发展的怎么样。基于JIT技术的还有Numba项目。该项目实现的是一个动态的编译器,你可以将你想要优化的Python函数以注解的方式进行标记,然后这些代码就会在LLVM的帮助下被编译成机器码。该项目也能够带来极大的性能上的提升。然而,就像PyPy一样,该项目对Python 3的支持还只是实验性的。

最后,但是也很重要的是,请牢记John Ousterhout(译者注:Tcl和Tk的发明者,现为斯坦福大学计算机系的教授)说过的话“将不工作的东西变成能够工作的,这才是最大的性能提升”。在你需要优化前不要过分地考虑程序的优化工作。程序的正确性通常来讲都比程序的性能要来的重要。

 

转载请注明:爱开源 » Python程序员必知必会的开发者工具

您必须 登录 才能发表评论!