引言
“Python太慢了。”这种观点在编程语言的讨论中频频出现,常常使人忽视Python的众多优点。
但事实真的如此吗?与普遍看法相反,如果你掌握了Python式的编程技巧,Python其实可以像冠军选手一样快速奔跑。
在表面之下,精通Python的开发者们掌握着一系列微妙而强大的技巧,这些技巧能显著提升他们代码的性能,远超常规水平。这些不仅仅是技巧,它们甚至改变了游戏规则。
今天,我们将揭示九种变革性的策略,这些策略可以彻底改变你对Python编程的看法。这些策略乍看之下或许很简单,但它们具有强大的效力,能以你从未想象的方式提升效率。准备好给你的Python技能加速了吗?让我们深入了解并开始优化吧!
1.join 或 +:更快的字符串连接
如果你的程序中经常进行字符串操作,那么字符串连接可能会成为你的 Python 程序的瓶颈。
基本上,在 Python 中有两种字符串连接的方法:
- 使用join()函数将一系列字符串合并为一个
- 使用+或+=符号逐一将每个字符串添加到一个中
那么哪种方法更快?废话少说,下面我们使用3种不同的方式连接相同的字符串:
str_list = ['Facts', 'speak', 'louder', 'than', 'words!']
# 使用 + 号
def concat_plus(strings):
result = ''
for word in strings:
result += word + ' '
return result
# 使用 join() 方法
def concat_join(strings):
return ' '.join(strings)
# 直接连接
def concat_directly():
return 'Facts' + 'speak' + 'louder' + 'than' + 'words!'
根据您那作为男士or女士神奇的第六感(🤭😜),悄悄告诉我您认为哪个函数速度最快,哪个最慢?实际结果可能会让您感到惊讶哦😛😛😛:
import timeit
print(f'The plus symbol: {timeit.timeit(concat_plus, number=10000)}')
print(f'The join function: {timeit.timeit(concat_join, number=10000)}')
print(f'The direct concatenation: {timeit.timeit(concat_directly, number=10000)}')
图片
如上所示,对于字符串连接,join() 方法比通过循环逐个添加字符串要快。
原因很简单。一方面,在Python中,字符串是不可变数据,每个 += 操作都会伴随新字符串变量的创建和旧字符串的复制,这会额外消耗更多的计算资源。另一方面,.join() 方法专门针对连接列表字符串进行了优化。它预先计算生成字符串的大小,然后一次性为其分配存储空间。因此,它避免了循环中的 += 操作带来的开销,因此更快。
然而,在我们的测试中,最快的函数是直接连接字符串字面量。其高速度归结于:
- Python 解释器可以在编译时优化字符串字面值的连接,将它们转换为单个字符串字面值。这里没有涉及到循环迭代或函数调用,因此操作效率非常高。
- 由于在编译时已知所有字符串,Python 可以非常快速地执行此操作,比在循环中运行时连接甚至优化过的 .join() 方法都要快得多。
总之,如果您需要连接字符串列表,请选择 join() 而不是 +=。如果您想直接连接字符串,只需使用 + 即可。
2.更快的列表创建:选择“[]”而非“list()”
创建列表并不困难。两种常见的方法是:
- 使用 list() 函数
- 直接使用 []:
import timeit
print('The List Creation:')
print(f"[]: {timeit.timeit('[]', number=10 ** 7)}")
print(f'The list function: {timeit.timeit(list, number=10 ** 7)}')
图片
正如结果所示,直接使用 [] 比执行 list() 函数要快差不多2倍。这是因为 [] 是一种字面语法,而 list() 是一个构造函数调用。毫无疑问,调用函数需要额外的时间。相同的逻辑,在创建字典时,我们也应该利用 {} 而不是 dict()。
3.更快的成员检查:用 Set 而不用 List
成员检查操作的性能在很大程度上取决于底层数据结构,一起来看看下面这个例子:
import timeit
target_dataset = range(1000000)
search_element = 1314
target_list = list(target_dataset)
target_set = set(target_dataset)
def list_membership_test():
return search_element in target_list
def set_membership_test():
return search_element in target_set
print(f'The list membership test: {timeit.timeit(list_membership_test, number=1000)}')
print(f'The set membership test: {timeit.timeit(set_membership_test, number=1000)}')
图片
结果显示,在集合中进行成员检查比在列表中快得多。我还发现一个问题,那就是搜索的元素越靠前则耗时越短,如果搜索一个不存在的元素则耗时最长。上面我们搜索的目标元素是1314,如果我们搜索一个不存在的元素1314520,则明显耗时更多:
图片
因为搜索一个不存在的元素必须遍历完整个列表或集合。By the way,从这个例子可以看出要做到一生一世(1314)很容易,因为每个人生来便有,但是要做到一生一世我爱你(1314520)却并不简单,因为需要付出更多的代价。哈哈😄😄😄,开个玩笑,扯远了,权当是给您枯燥的阅读带来一点小乐趣!
回到主题,为什么成员检查用集合比列表更快呢?
- 在Python列表中,成员检查(element in list)是通过迭代每个元素直到找到所需元素或达到列表末尾来执行的。因此,这个操作的时间复杂度为O(n)。
- 在Python中,集合用哈希表实现。在检查成员关系(element in set)时,Python使用哈希机制,其时间复杂度平均为O(1)。
这里的要点是在编写程序时仔细考虑底层数据结构。利用正确的数据结构可以显著加快我们的代码速度。
4.更快的数据生成:用推导式而不用 for 循环
Python 中有四种推导式:列表、字典、集合和生成器。它们不仅提供更简洁的语法来创建相关的数据结构,而且比使用 for 循环的性能更好。因为它们使用 C 语言实现的,性能进行了优化。
一起看看下面这个生成1-10000的立方示例:
import timeit
def generate_cubes_for_loop():
cubes = []
for i in range(10000):
cubes.append(i * i * i)
return cubes
def generate_cubes_comprehension():
return [i * i * i for i in range(10000)]
print(f'For loop: {timeit.timeit(generate_cubes_for_loop, number=10000)}')
print(f'Comprehension: {timeit.timeit(generate_cubes_comprehension, number=10000)}')
上述代码只是列表推导式和 for 循环之间的简单速度比较。正如如结果所示,列表推导式更快。
5.更快的循环:优先用局部变量
在Python中,访问局部变量比访问全局变量或对象属性要快。这里用一个简单例子来证明这一点:
import timeit
class Test:
def __init__(self):
self.value = 0
obj = Test()
def access_global_variable():
for _ in range(1000):
obj.value += 1
def access_local_variable():
value = obj.value
for _ in range(1000):
value += 1
print(f'Access global variable: {timeit.timeit(access_global_variable, number=1000)}')
print(f'Access local variable: {timeit.timeit(access_local_variable, number=1000)}')
这就是 Python 的工作原理。直观地说,当函数编译时,其中的局部变量是已知的,但其他外部变量则需要时间来检索。
这只是一个很小的改良,但有时候我们缺可以利用它来优化我们的代码,特别是在处理大数据集时。
6.更快的执行:优先使用内置模块和库
当工程师们说 Python 时,默认是指 CPython。因为 CPython 是 Python 语言的默认和最广泛使用的实现。
考虑到大多数内置模块和库都是用更快速和更底层的语言 C 编写的,我们应该尽可能利用这些内置工具并避免重复发明轮子。
import timeit
import random
from collections import Counter
def counter_custom(lst):
frequency = {}
for item in lst:
if item in frequency:
frequency[item] += 1
else:
frequency[item] = 1
return frequency
def counter_builtin(lst):
return Counter(lst)
target_dataset = [random.randint(0, 100) for _ in range(1000)]
print(f'Counter custom: {timeit.timeit(lambda: counter_custom(target_dataset), number=100)}')
print(f'Counter builtin: {timeit.timeit(lambda: counter_custom(target_dataset), number=100)}')
这里比较了在列表中计算元素频率的两种方法。正如我们所看到的,利用 collections 模块中的内置 Counter 比自己编写 for 循环更快,更整洁,更好(但有时候自定义的性能又会比内置模块更好,尚不知道原因)。
7.更快的函数调用:利用缓存装饰器
缓存是一种常用的技术,用于避免重复计算并加快程序的运行速度。幸运的是,在大多数情况下,我们不需要编写自己的缓存处理代码,因为Python提供了一个用于此目的的开箱即用的装饰器 — @functools.cache。
例如,以下代码将执行两个斐波那契数生成函数,一个带有缓存装饰器,而另一个没有:
import timeit
from functools import cache
def fibonacci_norm(n):
if n <= 1:
return n
return fibonacci_norm(n - 1) + fibonacci_norm(n - 2)
@cache
def fibonacci_cached(n):
if n <= 1:
return n
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)
print(f'fibonacci normal: {timeit.timeit(lambda: fibonacci_norm(30), number=1)}')
print(f'fibonacci cached: {timeit.timeit(lambda: fibonacci_cached(30), number=1)}')
结果显示 cache 装饰器版本比普通版本的速度快得多。
普通的斐波那契函数效率低下,因为在获取 fibonacci(30) 的结果过程中,它多次重新计算相同的斐波那契数。
缓存版本明显更快,因为它缓存了先前计算的结果。因此,每个斐波那契数它只计算一次,并且使用相同参数进行的后续调用都从缓存中获取。
仅仅添加一个内置装饰器就可以带来如此大的性能提升,这就是 Pythonic 的意义所在。😎
8.更快的无限循环: 优先选择“while 1”而不是“while True”
要创建一个无限循环,我们可以使用 while True 或 while 1 。它们的性能差异通常是可以忽略的。但有趣的是 while 1 稍微更快。这源于 1 是字面值,而 True 是 Python 全局范围内需要查找的全局名称,因此需要微小的额外开销。
我们也通过一个简单的示例比较这两种方式的性能:
import timeit
def infinite_loop_with_true():
result = 0
while True:
if result >= 10000:
break
result += 1
def infinite_loop_with_one():
result = 0
while 1:
if result >= 10000:
break
result += 1
print(f'Infinite loop with true: {timeit.timeit(infinite_loop_with_true, number=10000)}')
print(f'Infinite loop with one: {timeit.timeit(infinite_loop_with_one, number=10000)}')
正如我们所看到的,while 1 确实略快。但是,现代 Python 解释器(如CPython)经过高度优化,这样的差异通常微不足道。因此,我们无需在意这种微不足道的差异。另外,从代码可读性角度来说,其实 while True 的可读性比 while 1 更强。
9.更快的脚本启动:智能导入Python模块
通常情况下,我们都习惯在Python 脚本顶部导入所有模块。事实上,有些时候不必这样做。此外,如果模块太大,则按需导入可能会是一个更好的主意。比如,在用到模块的函数内部导入:
def target_function():
import specific_module
# rest of the function
如上面的代码所示,specific_module 在函数内部执行导入操作。这是“惰性加载”的思想,在函数调用时才导入指定模块。
这种方法的好处是,如果在脚本执行期间从未调用 target_function,则永远不会加载 specific_module,从而节省资源并减少脚本的启动时间。