Vanson's Eternal Blog

Python码农们最容易犯的错(千万要规避)

Mistake in python.jpg
Published on
/19 mins read/---

is和==的区别

  • is:用于检查两个变量是否指向同一个对象,即它们是否具有相同的内存地址。
  • ==:用于检查两个对象的值是否相等。
a = [4, 5, 6]
b = a
print(a == b)  # True
print(a is b)  # True
 
c = [4, 5, 6]
print(a == c)  # True
print(a is c)  # False

is 检查的是对象的身份(内存地址),而 == 检查的是对象的值。 在 Python 中,列表是可变对象,每次创建新的列表时,都会分配一个新的内存地址。 因此,即使两个列表的值相同,它们也是不同的对象。

# Python 对小整数(通常是 -5 到 256)进行了缓存优化。
a = 256
b = 256
print(a is b)  # True
 
# 对于大于 256 的整数,Python 不会进行缓存优化。
x = 257
y = 257
print(x is y)  # False
 
 

文件操作

正确地处理文件操作非常重要,尤其是在打开和关闭文件时。

如果不正确地关闭文件句柄,可能会导致资源泄漏、文件损坏或数据丢失。使用上下文管理器(with 语句)是处理文件操作的最佳实践。

# 错误实例
file = open("example.txt", "w")
file.write("Hello, world!")
# 漏掉了关闭文件的步骤

在这个错误示例中,文件被打开后没有被正确关闭。如果程序在文件操作过程中发生异常,文件句柄可能永远不会被关闭,从而导致资源泄漏。

# 正确方式一:手动关闭文件
file = open("example.txt", "w")
try:
    file.write("Hello, world!")
finally:
    file.close()
 

使用了 try...finally 块来确保文件在所有情况下都能被正确关闭。finally 块会在 try 块执行完毕后执行,无论是否发生异常。

# 正确且推荐:with...as...
with open("example.txt", "w") as file:
    file.write("Hello, world!")
# 文件会在离开上下文管理器时自动关闭
 

使用了 with 语句来打开文件。with 语句会确保文件在离开上下文管理器时自动关闭,即使在文件操作过程中发生异常也是如此。这是处理文件操作的推荐方式,因为它更安全、更简洁。

为什么使用 with 更方便?

  • 自动管理资源:with 语句会自动调用文件对象的 enterexit 方法。在退出上下文管理器时,exit 方法会自动关闭文件,释放资源。
  • 代码更简洁:使用 with 语句可以减少代码量,避免手动关闭文件的繁琐操作。
  • 异常安全:即使在文件操作过程中发生异常,with 语句也能确保文件被正确关闭,避免资源泄漏。

修改正在迭代

修改正在迭代的字典确实会导致 RuntimeError: dictionary changed size during iteration 异常。

这是因为 Python 的迭代器在迭代过程中不允许对字典的大小进行修改(如添加或删除键)。以下是详细解释和更多正确处理方式。

 
my_dict = {'a': 1, 'b': 2, 'c': 3}
for key, value in my_dict.items():
    if value % 2 == 0:
        del my_dict[key] # 尝试删除
 
print(my_dict)  # 抛出 RuntimeError 异常
 

正确方式一:使用字典推导式构建新的字典

 
my_dict = {'a': 1, 'b': 2, 'c': 3}
new_dict = {key: value for key, value in my_dict.items() if value % 2 != 0}
print(new_dict)  # {'a': 1, 'c': 3}
 

使用了字典推导式来创建一个新的字典,只包含满足条件的键值对。 这种方法不会直接修改原字典,而是生成一个新的字典,因此不会触发 RuntimeError。

正确方式二:先收集要删除的键 如果需要在迭代过程中删除键,可以先收集要删除的键,然后在迭代结束后再删除这些键。

my_dict = {'a': 1, 'b': 2, 'c': 3}
keys_to_remove = [key for key, value in my_dict.items() if value % 2 == 0]
 
for key in keys_to_remove:
    del my_dict[key]
 
print(my_dict)  # {'a': 1, 'c': 3}
 

正确方式三:使用 copy() 方法

如果需要在迭代过程中修改字典,可以先对字典进行浅拷贝,然后在拷贝上进行迭代。

# 使用 my_dict.copy() 创建了一个字典的浅拷贝。
# 在拷贝上进行迭代,同时直接修改原字典。
 
my_dict = {'a': 1, 'b': 2, 'c': 3}
for key, value in my_dict.copy().items():
    if value % 2 == 0:
        del my_dict[key]
 
print(my_dict)  # {'a': 1, 'c': 3}
 

修改正在迭代列表

在循环中直接修改列表的大小(例如删除元素)会导致迭代器失效,从而跳过某些项或引发其他意外行为。

这是因为列表的大小发生了变化,而迭代器的索引没有相应地更新。

numbers = [1, 2, 3, 4, 5]
for num in numbers:
    if num % 2 == 0:
        numbers.remove(num)
print(numbers)  # 输出 [1, 3, 4, 5],而不是预期的 [1, 3, 5]
 

在这个错误示例中,循环试图删除列表中的偶数。然而,当删除一个元素后,列表的大小会改变,后续元素的索引也会随之改变。

这会导致迭代器跳过某些元素,最终结果不符合预期。

正确方式一:使用列表推导式

numbers = [1, 2, 3, 4, 5]
new_numbers = [num for num in numbers if num % 2 != 0]
print(new_numbers)  # 输出 [1, 3, 5]

使用了列表推导式来创建一个新的列表,只包含满足条件的元素。

这种方法不会直接修改原列表,因此不会导致迭代器失效。

正确方式二:使用 while 循环

如果你需要在循环中直接删除元素,可以使用 while 循环,从列表的末尾开始删除,这样不会影响前面元素的索引。

numbers = [1, 2, 3, 4, 5]
i = 0
while i < len(numbers):
    if numbers[i] % 2 == 0:
        numbers.pop(i)
    else:
        i += 1
print(numbers)  # 输出 [1, 3, 5]
  1. 使用 while 循环遍历列表。
  2. 如果当前元素是偶数,则使用 pop(i) 删除该元素。
  3. 如果当前元素不是偶数,则将索引 i 增加 1。

正确方式三:使用 enumerate 和 del

如果你需要在 for 循环中删除元素,可以使用 enumerate 来获取索引,然后使用 del 删除元素。

numbers = [1, 2, 3, 4, 5]
for i, num in enumerate(numbers[:]):  # 注意这里使用了 numbers 的拷贝
    if num % 2 == 0:
        del numbers[i]
print(numbers)  # 输出 [1, 3, 5]
  • 使用 enumerate(numbers[:]) 遍历列表的拷贝,这样不会影响原列表的迭代。
  • 如果当前元素是偶数,则使用 del numbers[i] 删除该元素。
numbers = [1, 2, 3]
for i in numbers:
    if i < 3:
        numbers.append(i + 1)  # 这里会无限循环

在 for 循环中,迭代器会逐个访问列表中的元素。每次循环迭代时,迭代器会获取列表中的下一个元素。

然而,当 numbers.append(i + 1) 被调用时,列表的大小会增加,迭代器会继续访问新添加的元素,从而导致无限循环。

  • 避免在 for 循环中直接修改列表:这会导致迭代器失效,甚至引发无限循环。
  • 使用列表推导式:这是一种安全且高效的方法,可以避免直接修改原列表。
  • 使用 enumerate 函数:通过索引直接修改列表中的元素,而不会影响迭代器。
  • 使用 while 循环:如果需要动态修改列表,while 循环可以提供更多的灵活性

不规范处理异常

# 不正确的处理
 
try:
    # 一些代码
    result = 10 / 0
except:
    print("发生了异常")
 

except 语句没有指定任何异常类型,因此它会捕获所有类型的异常,包括那些可能需要特别处理的异常(如 KeyboardInterrupt 或 SystemExit)。

这种做法可能会隐藏真正的错误,使得调试变得困难。

# 正确示例
 
try:
    # 一些代码
    result = 10 / 2
except ZeroDivisionError as e:
    print("发生了特定的异常:", e)
else:
    print("没有异常发生,结果是:", result)
finally:
    print("清理资源")
 

为什么捕获特定异常更好?

  • 更精确的错误处理:通过捕获特定的异常,可以针对不同类型的错误采取不同的处理策略。例如,对于 **ZeroDivisionError,可以提示用户输入非零值;对于 FileNotFoundError,可以提示用户检查文件路径。
  • 避免隐藏错误:捕获所有异常可能会隐藏一些不应该被捕获的错误,如 KeyboardInterrupt(用户中断程序)或 SystemExit(程序退出)。这些异常通常需要特殊处理,而不是简单地打印一条消息。
  • 便于调试:当程序抛出未被捕获的异常时,Python 会提供详细的错误信息和堆栈跟踪,这有助于快速定位问题。如果捕获了所有异常,这些信息可能会丢失。

函数默认参数:可变对象

默认参数在函数定义时只被初始化一次,而不是每次调用函数时都重新初始化。

因此,如果默认参数是一个可变对象,多次调用函数时会共享同一个对象。

# 错误
def add_item(item, items=[]):
    items.append(item)
    return items
 
print(add_item(1))  # [1]
print(add_item(2))  # [1, 2] 而不是预期的 [2]

为了避免这个问题,可以将默认参数设置为 None,然后在函数内部检查是否为 None,如果是,则初始化一个新的可变对象。

# 正确
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items
 
print(add_item(1))  # [1]
print(add_item(2))  # [2]

避免共享可变对象:使用 None 作为默认参数,可以确保每次调用函数时都创建一个新的可变对象。

导入自定义模块

如果尝试导入一个不在当前目录或 Python 搜索路径中的模块,会报 ModuleNotFoundError。正确地管理模块路径是确保模块能够被正确导入的关键。

将模块路径添加到 sys.path

# 通过修改 sys.path 来添加自定义路径,这样 Python 就可以在指定路径中查找模块
import sys
sys.path.append("/你的/模块/路径")  # 添加自定义路径
import utils
# 在运行时动态导入模块,可以使用 importlib 模块。
 
import importlib.util
spec = importlib.util.spec_from_file_location("utils", "/你的/模块/路径/utils.py")
utils = importlib.util.module_from_spec(spec)
spec.loader.exec_module(utils)
 

闭包的时空陷阱

Python 闭包中引用的外部变量是 ‌动态查找‌ 的,而非定义时的静态快照。

def create_buttons():
    buttons = []
    for i in range(5):
        def on_click():
            print(f"点击了按钮 {i}")
        buttons.append(on_click)
    return buttons
 
for btn in create_buttons():
    btn()
 
# 点击了按钮 0
# 点击了按钮 1
# 点击了按钮 2
# 点击了按钮 3
# 点击了按钮 4
 

过程:

  1. ‌循环定义闭包时‌:在 for i in range(5) 循环中,所有 on_click 函数都 ‌共享同一个变量 i‌ 的引用,而非捕获当前 i 的值。 ‌2. 循环结束后变量状态‌:循环结束时 i = 4,此时所有闭包中引用的 i 均指向最终值 4。
  2. ‌执行闭包时‌:调用 btn() 时,闭包才会去读取 i 的当前值(此时已固定为 4),因此全部输出 4。

解决:

def on_click(num=i):  # 用默认参数固定当前i的值
    print(f"点击了按钮 {num}")
 
# 或
buttons.append(lambda x=i: print(f"点击了按钮 {x}"))
 

默认参数陷阱

默认参数在函数定义时只被初始化一次,而不是每次调用函数时都重新初始化。

因此,如果默认参数是一个可变对象(如字典或列表),多次调用函数时会共享同一个对象。

def func(data={}):
    data['count'] = data.get('count', 0) + 1
    return data
 
print(func())
print(func())
print(func())
 

每次调用 func 时,data['count'] 的值都会增加 1。

  • 第一次调用 func() 时,data 是一个空字典 ,data.get('count', 0) 返回 0,然后 data['count'] 被设置为 1。
  • 第二次调用 func() 时,data 仍然是同一个字典,data.get('count', 0) 返回 1,然后 data['count'] 被设置为 2。
  • 第三次调用 func() 时,data 仍然是同一个字典,data.get('count', 0) 返回 2,然后 data['count'] 被设置为 3。

为了避免这种陷阱,可以将默认参数设置为 None,然后在函数内部检查是否为 None,如果是,则初始化一个新的可变对象。

合并字典

class1 = {"张三": 90}
class2 = {"李四": 85}
merged_class = {**class1, **class2}  # 像拼桌一样简单
print(merged_class)
 
# 输出
# {'张三': 90, '李四': 85}

字典解包:

使用 ** 操作符可以将一个字典解包为键值对。 在字典字面量中使用多个 ** 操作符可以将多个字典合并为一个新的字典。

合并字典: {**class1, **class2} 会将 class1 和 class2 中的键值对合并到一个新的字典中。 如果两个字典中有相同的键,后面的字典中的键值对会覆盖前面的字典中的键值对。

元组定义陷阱

元组(Tuple)是一种不可变的数据结构,通常用一对小括号 () 来定义。然而,真正决定一个数据是否是元组的关键是逗号(,),而不是括号。

单元素元组

如果要创建一个包含单个元素的元组,必须在元素后面加上逗号。否则,Python 会将其解释为普通的数据类型(如整数、浮点数等)。

single_element_tuple = (1)
print(type(single_element_tuple))  # <class 'int'>
# 即使没有括号,只要使用逗号分隔,也可以创建元组。
coordinates = 1, 2
print(type(coordinates))  # <class 'tuple'>
 
  • 决定元组的关键是逗号:逗号(,)是决定一个数据是否是元组的关键,而不是括号。
  • 单元素元组需要逗号:创建单元素元组时,必须在元素后面加上逗号,例如 (1,)。
  • 括号的作用:括号主要是为了提高代码的可读性和明确性,但在某些情况下可以省略。

并行修改列表

zip 函数可以将两个列表的元素配对,生成一个迭代器。通过解包这些配对,可以更简洁地实现并行操作。

在循环中修改两个列表的元素,可以使用 zip 函数结合索引。但需要注意的是,zip 函数生成的是一个迭代器,不能直接通过索引修改。因此,可以结合 enumerate 函数来实现。

list1 = [1, 2, 3]
list2 = [4, 5, 6]
for i, (a, b) in enumerate(zip(list1, list2)):
    list1[i], list2[i] = b, a  # 交换元素
print(list1)  # [4, 5, 6]
print(list2)  # [1, 2, 3]