• 使用Python时要注意的十大陷阱
  • 发布于 1周前
  • 46 热度
    0 评论
  • 苒青绾
  • 0 粉丝 29 篇博客
  •   
一.is 和 == 的误用
• is 比较对象 ID(内存地址,通过 id() 函数获取),
• == 比较对象值
注意:小整数和短字符串可能被驻留优化,导致 is 误判。
如下所示的代码中,变量 a 和 b 其实是两个完全不同的对象,但是通过 is 判断时,结果都是 True,显然不是程序开发者预期的结果。
def main():
    a = 256
    b = 256
    print(id(a), id(b))
    print(a is b)  # True

    a = 1_000_000
    b = 1_000_000
    print(id(a), id(b))
    print(a is b)  # True

    s1 = "hello!"
    s2 = "hello!"
    print(id(a), id(b))
    print(s1 is s2)  # True
 最佳实践:始终用 == 比较值,is 仅用于 None、True、False 三种类型比较。

二.字符串结尾反斜杠
字符串不能以单个 反斜杠 结尾,否则会导致语法错误。
原因: 因为最后一个反斜杠,转义了结尾的字符串引号。
def main():
    # 下面两行都会 SyntaxError
    path1 = r"C:\new_folder\"
    path2 = r"\"
修正方案当然也很简单,直接再对末尾的反斜杠进行二次转义即可。
def main():
    # 下面两行都会 SyntaxError
    path1 = r"C:\new_folder\\"
    path2 = r"\\"
三.zap 截取到最短匹配
zip() 函数在参数中,最短的迭代器结束时停止,不会为其他剩余元素配对。
# 堆代码 duidaima.com
def main():
    names = ["Alice", "Bob", "Carol"]
    scores = [90, 85]
    print(list(zip(names, scores)))  # [('Alice', 90), ('Bob', 85)]
如果需要填充缺失的值,可以使用 itertools.zip_longest(...) 或者指定参数 strict=True 强制进行剩余元素匹配,但是这样当迭代器元素数量不同时,就会出现报错
 zip() argument 2 is shorter than argument 1 ...
四.列表的浅拷贝初始化
下面的二维数组初始化之后,结果并未像预期一样,只有第一个元素 grid[0][0] 的值是 1。
def main():
    grid = [[0]*3]*3
    grid[0][0] = 1
    print(grid) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]]
问题的原因在于:* 操作符对于列表只复制引用,因为上面的代码中是一个二维数组,每一行都指向一个子数组/列表,所以最终每个子数组的第一个元素都是 1 (因为 3 个子数组/列表指向的是同一个引用)。解决方案也很简单,就是为每个子数组/列表创建一个全新的值。
def main():
    grid = [[0]*3 for _ in range(3)]
    grid[0][0] = 1
    print(grid) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]
五.可变类型作为函数参数
Python 函数的参数默认值,如果是可变数据类型 (列表/字典/集合),那么默认参数只在函数定义时创建一次,后续的函数调用拿到的默认值都是同一个对象。也就是说,函数每次调用完成后,默认值都会发生变化 (相当于变成全局变量参数的了)。
def my_append(val, vals = []):
    vals.append(val)
    return vals

def main():
    print(my_append(100)) # [100]

    # 第二次调用的时候,参数的默认值变成了第一次调用后的结果
    print(my_append(100)) # [100, 100]

    # 如果希望每次调用都使用 新的空列表 作为默认参数
    # 必须手动指定
    print(my_append(100, [])) # [100]
针对上面的问题,更加约定俗成的解决方法是采用 None 作为默认值,这样默认参数在每次调用时都会创建一个新的值。
def my_append(val, vals = None):
    if vals is None:
        vals = []
    vals.append(val)
    return vals

def main():
    print(my_append(100)) # [100]
    print(my_append(100)) # [100]
    print(my_append(100)) # [100]
    print(my_append(100)) # [100]
六.额外的思考
但是反过来说,我们又可以通过 Python 提供的这个默认值可变机制,来实现一些自定义数据结构,并对外开放非常简单友好的 API。下面是一个模拟向数据库/数据表插入数据的示例代码 (仅限示例,请勿在生产环境中使用)。
def insert_record(item, items = []):
    items.append(item)
    return items

def main():
    # 连续插入 3 条数据
    insert_record(100)
    insert_record(200)

    rows = insert_record(300)
    print(rows) # [100, 200, 300]
七.闭包和变量的延迟绑定
在 Python 中,闭包捕获的是变量本身而非调用闭包函数当时的值,导致在循环中创建的多个函数,最终都引用同一个循环变量的最后值,这就是所谓的 延迟绑定 问题。
def make_multipliers():
    return [lambda x: i * x for i in range(5)]

def main():
    funcs = make_multipliers()
    print([f(2) for f in funcs])  # [8, 8, 8, 8, 8]  ← 全部是 i=4 的结果
解决方案: 利用默认参数在定义时绑定当前值。
def make_multipliers():
    return [lambda x, i=i: i * x for i in range(5)]

def main():
    funcs = make_multipliers()
    print([f(2) for f in funcs])  # [0, 2, 4, 6, 8]
八.生成器的遍历规则
不同于列表/字典/集合等基础数据类型,生成器 (数据类型) 有一个非常重要的特点:一次性消耗。也就是说,生成器的所有元素只会被遍历一次,当所有的元素被遍历完成后,该生成器就会抛出异常 StopIteration。即使捕获并处理了异常,再次遍历时也无法再次访问到任何元素。
def main():
    primes = [2, 3, 5, 7, 11]

    # 将每个质数乘以 2 作为结果,存入一个生成器 (迭代器)
    numbers = (x * 2for x in primes)
    print(type(numbers)) # <class 'generator'>

    # 查询元素是否存在时,同样会消耗生成器元素
    print(4in numbers)
    print(next(numbers))

    # 当元素不存在时,说明此时已经遍历到了生成器的末尾
    # 也就是说,生成器已经被消耗完了
    print(100in numbers)

    # 再次访问,就会报错
    # print(next(numbers)) # StopIteration
最简单直接的方案是将生成器的结果,一次性地放入到一个列表中,但是这样有一个潜在的问题:当生成器的元素很多时,可能出现性能问题甚至内存溢出 (OOM)。为了解决潜在的内存性能问题,可以通过将生成器本身封装到一个专门的工厂函数中,这样每次调用时,都会返回一个新的生成器函数,避免对同一生成器返回调用时出现的问题。
def make_gen(n):
    for i inrange(n):
        yield i

def main():
    # 每次都是新的生成器
    for x in make_gen(5):
        print(x)

    # 每次都是新的生成器
    for x in make_gen(5):
        print(x)
如果我们每次访问的都是同一个生成器的元素,并且可以在生成器 一次性消耗 之后,可以从零开始接着访问,然后无限循环这个访问过程,类似于数据结构中的环形队列。这时就可以通过创建专属的自定义生成器类,然后重写迭代相关的魔术方法,最终实现 “循环利用” 生成器。
class Regenerator:
    def __init__(self, factory, *args, **kwargs):
        # 生成器的工厂函数通过初始化方法注入
        self.factory = factory
        self.args = args
        self.kwargs = kwargs
        self._gen = factory(*args, **kwargs)

    def __iter__(self):
        returnself

    def __next__(self):
        try:
            returnnext(self._gen)
        except StopIteration:
            # 生成器消耗完之后,重新创建生成器,循环利用
            self._gen = self.factory(*self.args, **self.kwargs)
            returnnext(self._gen)

# 生成器的工厂函数
def make_gen(n):
    for i inrange(n):
        yield i

def main():
    reg = Regenerator(make_gen, 5)
    # 输出如下
    # 0 1 2 3 4 0 1 2 3 4
    for _ inrange(10):
        print(next(reg))
九.生成器中忽略资源释放
生成器为惰性求值和节省内存提供了强大支持,最经典的应用场景中的大文件读取、惰性计算求值,但是稍不留神,就可能引发资源泄漏的问题。下面的代码中,即使读取文件的生成器提前退出,已经打开的文件句柄也不会立即自动关闭,造成文件句柄资源闲置浪费,直到最终被 GC 回收。
def read_file(path):
    f = open(path)
    for line in f:
        iflen(line) == 0:
            # 此时退出文件句柄未被关闭
            return
        yield line.rstrip('\n')

def main():
    for row in read_file('/var/log/nginx/access.log'):
        # 逐行读取和处理
        ...
解决方案也很简单,直接使用上下文管理器来负责文件句柄的生命周期,修改后的代码如下所示。
def read_file(path):
    with open(path, 'r') as f:
        for line in f:
            if len(line) == 0:
                # 此时退出文件句柄可以正常被关闭
                return
            yield line.rstrip('\n')
十.cache 装饰器解决递归问题
如果使用 @cache 或者 @lru_cache 缓存装饰器,来解决递归过程中的重复子任务计算问题,那么这个设计本身就存在缺陷。递归需要考虑到 Python 语言本身对于递归深度的执行限制。比如当需要计算的数字很大时,递归函数在执行时会形成一个非常深的嵌套调用栈,当深度超过一定限制后,就会抛出 RecursionError 异常。
用户评论