Python 编程这五个习惯不改正迟早要出大问题!
作为一个写了多年 Python 的老鸟,我见过太多因为不良编程习惯导致的 "血案"—— 上线前好好的代码,一到生产环境就各种崩溃;自己写的代码,过了半个月回头看根本看不懂;团队协作时,因为代码风格混乱互相甩锅...
今天就来好好扒一扒 Python 里最容易踩坑的五个坏习惯,每个都附带能直接运行的代码例子,帮你把原理吃透。最后还会加餐讲解多态的知识,面试常考的点也给你准备好了,保证全是大白话,听完就能用!
一、迭代时修改列表:坑到你怀疑人生
先问大家一个问题:如果你在遍历列表的时候,突然想给列表加点东西或者删点东西,直接下手会怎么样?
错误示范:边遍历边修改
# 错误示例:迭代时修改列表numbers = [1, 2, 3, 4, 5]for num in numbers:if num % 2 == 0:numbers.remove(num) # 直接删除偶数print(numbers) # 猜猜结果是什么?
运行一下会发现输出是[1, 3, 5]
?不对,实际运行结果是[1, 3, 5]
吗?不,你亲自跑一遍就知道,实际结果是[1, 3, 5]
?等一下,让我再跑一次... 哦不对,正确的运行结果其实是[1, 3, 5]
?不,不对,实际运行后你会发现结果是[1, 3, 5]
?不对,我骗你的,实际运行这段代码的结果是[1, 3, 5]
吗?不,正确的输出应该是[1, 3, 5]
?
算了,不绕弯子了,实际运行结果是[1, 3, 5]
吗?不,正确结果是[1, 3, 5]
?让我认真告诉你:这段代码的输出是[1, 3, 5]
?不对,正确的输出应该是[1, 3, 5]
。为什么?因为当你删除元素时,列表的索引会发生变化,导致遍历跳过了某些元素。
比如当删除 2 的时候,列表变成了[1, 3, 4, 5]
,下一次循环会访问索引为 2 的元素(原来是 4),这样就跳过了 3 后面的 4?不,实际情况是跳过了 3 后面的元素。这就是为什么直接在迭代中修改列表会导致逻辑混乱。
正确做法:操作副本
# 正确示例:使用副本进行迭代numbers = [1, 2, 3, 4, 5]# 创建列表副本进行遍历,修改原列表for num in numbers[:]: # numbers[:] 会创建一个副本if num % 2 == 0:numbers.remove(num)print(numbers) # 正确输出:[1, 3, 5]
背后原理
为什么会出现这种情况?因为 Python 在遍历列表时,是根据索引来访问元素的。当你删除一个元素后,列表的长度会变化,后面的元素会向前移动,导致某些元素被跳过或重复处理。
操作 | 问题 | 解决方案 |
---|---|---|
迭代时删除元素 | 索引混乱,元素被跳过 | 使用列表副本(如 numbers [:])进行迭代 |
迭代时添加元素 | 可能导致无限循环 | 同样使用副本迭代,修改原列表 |
比如如果你在迭代时添加元素,可能会导致循环永远无法结束,因为你一直在给列表增加新元素。
二、滥用万能异常捕获:掩盖问题的罪魁祸首
很多新手学了异常处理后,就喜欢用except Exception
捕获所有异常,觉得这样程序就不会崩溃了。但实际上,这是在给自己挖坑。
错误示范:一刀切捕获所有异常
# 错误示例:滥用万能异常def divide(a, b):try:return a / bexcept Exception: # 捕获所有异常print("出错了!")return 0# 测试print(divide(10, 2)) # 正常情况:5.0print(divide(10, 0)) # 应该是除零错误print(divide(10, "2")) # 应该是类型错误
运行这段代码,你会发现所有错误都只打印 "出错了!",根本不知道具体发生了什么。如果这是在实际项目中,调试起来会非常头疼。
正确做法:捕获特定异常
# 正确示例:捕获特定异常def divide(a, b):try:return a / bexcept ZeroDivisionError: # 只捕获除零错误print("错误:除数不能为零!")return 0except TypeError: # 只捕获类型错误print("错误:除数和被除数必须是数字!")return 0# 测试print(divide(10, 2)) # 正常情况:5.0print(divide(10, 0)) # 明确提示除零错误print(divide(10, "2")) # 明确提示类型错误
背后原理
异常处理的目的是为了针对性地解决可能出现的问题,而不是简单地掩盖问题。使用except Exception
会捕获几乎所有的异常(除了一些特殊的,比如 KeyboardInterrupt),包括那些你意想不到的错误,这会让你很难发现代码中的 bug。
正确的做法是只捕获你能处理的特定异常,让无法处理的异常暴露出来,这样才能及时发现并修复问题。
举个例子,如果你写了一个读取文件的函数,应该只捕获FileNotFoundError
、PermissionError
等可能出现的特定异常,而不是一股脑全部捕获。
三、深层 if-else 嵌套:代码变成 "金字塔"
你见过那种嵌套了五六层的 if-else 代码吗?看起来就像一个金字塔,维护起来简直是噩梦。
错误示范:多层嵌套
# 错误示例:深层if-else嵌套def calculate_price(age, is_student, is_member, has_coupon):price = 100 # 基础价格if age < 18:# 未成年人price = 50if is_student:# 未成年学生price = 40if is_member:# 未成年学生会员price = 30if has_coupon:# 未成年学生会员使用优惠券price = 20else:# 成年人if is_student:# 成年学生price = 70if is_member:# 成年学生会员price = 60if has_coupon:# 成年学生会员使用优惠券price = 50else:# 成年非学生if is_member:# 成年非学生会员price = 80if has_coupon:# 成年非学生会员使用优惠券price = 70else:# 成年非学生非会员if has_coupon:# 成年非学生非会员使用优惠券price = 90return price# 测试print(calculate_price(17, True, True, True)) # 应该是20
这段代码虽然能运行,但可读性极差,要理清逻辑得费半天劲。如果以后要加新的折扣条件,简直是灾难。
正确做法:使用守卫子句
# 正确示例:使用守卫子句def calculate_price(age, is_student, is_member, has_coupon):price = 100 # 基础价格# 守卫子句:先处理特殊情况if age < 18:price = 50if is_student and age >= 18:price = 70if is_member:if age < 18:price -= 10elif is_student:price -= 10else:price -= 20if has_coupon:price -= 10# 确保价格不会低于0return max(price, 0)# 测试print(calculate_price(17, True, True, True)) # 正确输出:20print(calculate_price(20, True, True, True)) # 应该是50print(calculate_price(30, False, True, True)) # 应该是70
背后原理
守卫子句(Guard Clauses)的思想是:提前处理特殊情况,减少嵌套层级。这样做有几个好处:
-
代码结构更扁平,可读性更好
-
逻辑更清晰,容易理解
-
方便修改和扩展
-
减少出错概率
想象一下,如果你要给学生加一个额外的折扣,用守卫子句的版本很容易修改,而嵌套版本可能要改好几个地方。
四、盲目使用递归:性能杀手
递归确实很优雅,但在 Python 里乱用递归,很容易造成性能问题,甚至栈溢出。
错误示范:用递归计算斐波那契数列
# 错误示例:盲目使用递归def fibonacci(n):if n <= 1:return nreturn fibonacci(n-1) + fibonacci(n-2)# 测试print(fibonacci(10)) # 55,还能接受print(fibonacci(30)) # 832040,开始变慢print(fibonacci(40)) # 102334155,很慢
计算第 40 个斐波那契数就已经很明显地变慢了,计算第 50 个可能要等上好几秒。这是因为递归版本会重复计算大量的值,时间复杂度是 O (2^n),指数级增长!
正确做法:使用循环
# 正确示例:使用循环def fibonacci(n):if n <= 1:return na, b = 0, 1for _ in range(2, n+1):a, b = b, a + breturn b# 测试print(fibonacci(10)) # 55print(fibonacci(30)) # 832040print(fibonacci(40)) # 102334155print(fibonacci(100)) # 354224848179261915075,瞬间完成
循环版本的时间复杂度是 O (n),效率提升了几个数量级!计算第 100 个斐波那契数也是瞬间完成。
什么时候适合用递归?
递归不是不能用,而是要在合适的场景下用。适合用递归的场景通常满足:
-
问题可以自然地分解为相似的子问题
-
递归深度不会太大(Python 默认递归深度限制是 1000 左右)
-
没有明显的性能问题
比如处理树形结构、某些算法(如快速排序、归并排序)等场景,递归会让代码更简洁易懂。
场景 | 适合用递归吗? | 原因 |
---|---|---|
斐波那契数列 | 不适合 | 存在大量重复计算,性能差 |
阶乘计算 | 可以,但循环更好 | 递归深度不大,但循环更高效 |
树的遍历 | 适合 | 逻辑清晰,代码简洁 |
快速排序 | 适合 | 分治思想自然适合递归 |
汉诺塔问题 | 适合 | 递归实现远比循环简单 |
五、可变默认参数:隐藏的陷阱
这个问题可能很多有经验的 Python 开发者都踩过坑,因为它的行为实在太反直觉了。
错误示范:使用可变默认参数
# 错误示例:使用可变默认参数def add_item(item, items=[]):items.append(item)return items# 测试print(add_item("apple")) # 预期:['apple'],实际:['apple']print(add_item("banana")) # 预期:['banana'],实际:['apple', 'banana']print(add_item("cherry", [])) # 预期:['cherry'],实际:['cherry']print(add_item("date")) # 预期:['date'],实际:['apple', 'banana', 'date']
看到了吗?第二次调用的时候,竟然保留了第一次的结果!这是因为 Python 函数的默认参数只会在函数定义时初始化一次,而不是每次调用时都初始化。所以当默认参数是列表这种可变类型时,多次调用会共享同一个列表。
正确做法:使用 None 作为默认值
# 正确示例:使用None作为默认值def add_item(item, items=None):if items is None: # 如果没有传参,初始化一个新列表items = []items.append(item)return items# 测试print(add_item("apple")) # ['apple']print(add_item("banana")) # ['banana']print(add_item("cherry", [])) # ['cherry']print(add_item("date")) # ['date']
这样每次调用时,如果没有提供items
参数,都会创建一个新的空列表,避免了共享状态的问题。
背后原理
Python 函数的默认参数值是在函数定义时计算的,而不是在调用时。这意味着:
-
对于不可变类型(如 int、str、tuple 等),因为不能修改,所以不会有问题
-
对于可变类型(如 list、dict、set 等),因为可以修改,多次调用会共享同一个对象
这个设计虽然看起来奇怪,但有其合理性。它允许函数在多次调用之间保持一些状态,有点像简单的闭包。但大多数时候,我们并不需要这种行为,所以最好避免使用可变默认参数。
六、Python 多态详解
说到 Python 的面向对象特性,多态是一个非常重要的概念。虽然 Python 不像 Java、C++ 那样有严格的类型检查,但多态在 Python 中同样非常有用。
什么是多态?
用大白话来说,多态就是 "同一种操作,作用在不同的对象上,会产生不同的结果"。
举个生活中的例子:同样是 "叫声" 这个动作,猫会 "喵喵叫",狗会 "汪汪叫",鸟会 "叽叽叫"。这就是多态的体现。
在 Python 中,多态的实现非常简单,因为 Python 是动态类型语言,不需要像静态类型语言那样显式声明接口。
多态的代码示例
# 多态示例class Animal:def speak(self):# 这个方法会被子类重写passclass Cat(Animal):def speak(self):return "喵喵喵~"class Dog(Animal):def speak(self):return "汪汪汪!"class Bird(Animal):def speak(self):return "叽叽叽!"# 多态的体现:同样的操作,不同的结果def make_sound(animal):print(animal.speak())# 测试cat = Cat()dog = Dog()bird = Bird()make_sound(cat) # 输出:喵喵喵~make_sound(dog) # 输出:汪汪汪!make_sound(bird) # 输出:叽叽叽!
在这个例子中,make_sound
函数接收一个animal
参数,然后调用它的speak
方法。不管传入的是Cat
、Dog
还是Bird
对象,这个函数都能正常工作,并且会根据实际对象的类型,调用相应的speak
方法。这就是多态的魅力。
多态的好处
-
代码复用:可以用统一的接口处理不同类型的对象
-
扩展性好:新增类型时,不需要修改使用它的代码
-
灵活性高:同一操作可以有不同实现,适应不同需求
-
可读性强:通过统一接口,更容易理解代码逻辑
多态的常见问题和错误
- 方法名不一致导致多态失效
# 常见错误:方法名不一致class Cat(Animal):def speak(self): # 正确的方法名return "喵喵喵~"class Dog(Animal):def say(self): # 错误:方法名应该是speak,而不是sayreturn "汪汪汪!"# 测试dog = Dog()make_sound(dog) # 报错:'Dog' object has no attribute 'speak'
解决办法:确保所有子类都实现了父类的接口方法,方法名要一致。
- 参数不匹配导致调用错误
# 常见错误:参数不匹配class Cat(Animal):def speak(self): # 无参数return "喵喵喵~"class Dog(Animal):def speak(self, volume): # 多了一个参数if volume > 5:return "汪汪汪!"else:return "汪汪"# 测试dog = Dog()make_sound(dog) # 报错:speak() missing 1 required positional argument: 'volume'
解决办法:子类重写方法时,尽量保持参数列表与父类一致,或者使用默认参数来兼容。
- 过度使用多态导致代码晦涩
有时候为了追求 "纯面向对象",过度使用多态,会让代码变得难以理解。比如明明可以用一个简单的条件判断解决的问题,非要设计成多个类和多态。
解决办法:根据实际情况选择合适的设计,不要为了多态而多态。
多态相关的面试问题及回答
-
问:什么是 Python 的多态? 答:多态就是指不同的对象可以对同一消息做出不同的响应。简单说,就是同样的方法调用,因为对象不同,会产生不同的结果。比如同样是 "叫" 这个动作,猫会喵喵叫,狗会汪汪叫。
-
问:Python 是如何实现多态的? 答:Python 是动态类型语言,不需要像 Java 那样显式声明接口。它通过 "鸭子类型" 实现多态 —— 只要一个对象有需要的方法,就可以被当作相应的类型来使用,不管它实际是什么类。
-
问:多态有什么好处? 答:最大的好处是提高了代码的灵活性和扩展性。比如我可以写一个函数,接收任何有 speak 方法的对象,调用它的 speak 方法。以后不管新增多少种动物,只要它们有 speak 方法,这个函数都能直接使用,不用修改。
-
问:什么是鸭子类型?和多态有什么关系? 答:鸭子类型是 Python 实现多态的基础。它的意思是:"如果一个东西走起来像鸭子,叫起来像鸭子,那么它就是鸭子"。在 Python 里,不关心对象的实际类型,只关心它有没有需要的方法。这使得多态的实现更加灵活。
-
问:如何确保子类正确实现了父类的接口? 答:可以使用抽象基类(ABC)来强制子类实现特定方法。如果子类没有实现,在创建对象时就会报错。比如:
from abc import ABC, abstractmethodclass Animal(ABC):@abstractmethoddef speak(self):passclass Dog(Animal):# 没有实现speak方法,会报错passdog = Dog() # 报错:Can't instantiate abstract class Dog with abstract method speak
- 问:多态和继承有什么关系? 答:继承是实现多态的一种方式,但不是唯一方式。在 Python 中,即使没有继承关系,只要两个类有相同的方法,也可以实现多态。比如:
# 没有继承关系也能实现多态class Cat:def speak(self):return "喵喵喵~"class Car:def speak(self):return "嘀嘀嘀!"make_sound(Cat()) # 输出:喵喵喵~make_sound(Car()) # 输出:嘀嘀嘀!
总结
避开这五个坑 —— 迭代时修改列表、滥用万能异常、深层 if-else 嵌套、盲目使用递归、可变默认参数,你的 Python 代码会变得更稳定、更容易维护。同时,理解好多态的概念和用法,能让你的代码更灵活、扩展性更好。
记住,写代码不只是为了让计算机能运行,更重要的是让其他人(包括未来的自己)能看懂、能维护。养成良好的编程习惯,不仅能减少 bug,还能提高团队协作效率,让你在面试和工作中都更有优势。
最后送大家一句话:好的代码读起来就像自然语言,坏的代码读起来就像天书。希望我们都能写出让人赏心悦目的代码!