查看: 254|回复: 3

Python成员运算符in与身份运算符is原理陷阱与性能实践

[复制链接]
发表于 2 小时前 | 显示全部楼层 |阅读模式
在Python日常编码中,in/not in和is/is not几乎随处可见,但容器类型、魔术方法、CPython缓存都会影响结果。理解它们的语义、底层调用和性能差异,才能避免把“碰巧正确”当成稳定行为。

一、in/not in的语义与容器差异

in用于判断成员是否属于容器,not in取反。字符串判断子串且大小写敏感;列表、元组按元素值查找;字典检查的是键而不是值,若要查值需使用values();集合按哈希成员判断。
  1. print('Py' in 'Python')
  2. print('py' in 'Python')
  3. print('X' not in 'Python')
  4. fruits = ['苹果', '香蕉', '橘子']
  5. print('苹果' in fruits)
  6. print('西瓜' not in fruits)
  7. user = {'name': '张三', 'age': 25, 'city': '北京'}
  8. print('name' in user)
  9. print('张三' in user)
  10. print('张三' in user.values())
  11. valid_colors = {'red', 'green', 'blue'}
  12. print('red' in valid_colors)
  13. print('yellow' in valid_colors)
复制代码

二、底层机制:__contains__与迭代回退

表达式x in container优先调用container.__contains__(x)。如果对象没有实现该方法,Python会退化为迭代查找,逐个用==比较元素。自定义类可以实现__contains__,从而控制in的判定逻辑。
  1. class Classroom:
  2.     def __init__(self, students):
  3.         self.students = students
  4.     def __contains__(self, student_name):
  5.         return any(student_name in s for s in self.students)
  6. classroom = Classroom(['张三', '张三丰', '李四', '王五'])
  7. print('张三' in classroom)
  8. print('张' in classroom)
  9. print('李四' in classroom)
  10. print('赵六' in classroom)
复制代码

这里__contains__内部又使用了字符串in,因此“张”能匹配“张三”,形成模糊匹配效果。这说明in的行为不仅取决于运算符本身,还取决于容器或自定义对象提供的成员判断实现。

三、查找效率与容器选择

列表和元组的in查找通常是O(n)线性扫描;集合和字典基于哈希,平均为O(1);字符串的子串查找复杂度为O(n*m)。数据量大且需要频繁判断成员时,应优先考虑set或dict。
  1. import timeit
  2. n = 100000
  3. test_list = list(range(n))
  4. test_set = set(range(n))
  5. test_dict = {i: i for i in range(n)}
  6. test_tuple = tuple(range(n))
  7. target = 99999
  8. t_list = timeit.timeit(lambda: target in test_list, number=1000)
  9. t_set = timeit.timeit(lambda: target in test_set, number=1000)
  10. t_dict = timeit.timeit(lambda: target in test_dict, number=1000)
  11. t_tuple = timeit.timeit(lambda: target in test_tuple, number=1000)
  12. print(f'列表: {t_list:.4f}s')
  13. print(f'集合: {t_set:.4f}s')
  14. print(f'字典: {t_dict:.4f}s')
  15. print(f'元组: {t_tuple:.4f}s')
复制代码

性能结论要结合数据规模看:小数据量时差异可能不大,列表甚至可能因没有哈希开销而不慢;大数据量下集合和字典优势明显。分界线通常在几十到几百个元素附近,频繁查找场景应直接使用set/dict。

四、for中的in与成员运算符

for item in iterable中的in是语法关键字,用于迭代;表达式item in container中的in是成员运算符,返回布尔值。两者可以同时出现在列表推导式中。
  1. text = 'Python is great'
  2. vowels = {'a', 'e', 'i', 'o', 'u', 'A', 'E', 'I', 'O', 'U'}
  3. vowel_chars = [c for c in text if c in vowels]
  4. print(vowel_chars)
  5. print(f'元音数量: {len(vowel_chars)}')
复制代码

五、is/is not:对象身份而非值

is判断两个变量是否指向同一个对象,本质是比较id();==判断值是否相等。两个内容相同的列表可能==为True,但is为False;让一个变量直接指向另一个变量后,is才为True。
  1. a = [1, 2, 3]
  2. b = [1, 2, 3]
  3. c = a
  4. print(a == b)
  5. print(a is b)
  6. print(a is c)
  7. print(id(a) == id(b))
复制代码

is最适合与None、True、False等单例对象比较。比较None时推荐用is None,因为is不受自定义__eq__影响,速度也更快,语义上更符合“是否为同一个单例对象”。

小整数缓存是is的著名陷阱。CPython会缓存-5到256之间的小整数,因此两个变量分别赋值为256时可能is为True,赋值为257时可能is为False。但这是实现细节,不应在业务代码中依赖。
  1. a = 256
  2. b = 256
  3. print(a is b)
  4. a = 257
  5. b = 257
  6. print(a is b)
复制代码

字符串也有驻留机制,但规则更复杂。看起来像标识符的短字符串可能被驻留,包含特殊字符的字符串则不一定。下面的结果只应作为观察,不能作为稳定判断依据。
  1. a = 'hello'
  2. b = 'hello'
  3. print(a is b)
  4. a = 'hello world!'
  5. b = 'hello world!'
  6. print(a is b)
  7. a = 'hello world!@#'
  8. b = 'hello world!@#'
  9. print(a is b)
复制代码

is的行为固定,不能被__eq__覆盖。下面的类让==几乎永远返回True,但is仍然只认对象身份。
  1. class WeirdClass:
  2.     def __eq__(self, other):
  3.         return True
  4. w = WeirdClass()
  5. print(w == None)
  6. print(w == 42)
  7. print(w is None)
  8. print(w is w)
复制代码

因此,比较值用==,比较None用is,其他情况不要依赖is的缓存或驻留行为。

六、in与==、is与==的选择

检查一个值是否属于多个允许值之一时,用in比串联多个==更清晰;选项较多时可用set或frozenset提升查找效率。字符串精确匹配用==,子串匹配用in,前后缀匹配用startswith/endswith。
  1. status = 'active'
  2. VALID_STATUSES = frozenset(['active', 'pending', 'processing'])
  3. if status in VALID_STATUSES:
  4.     print('有效状态')
  5. name = 'Alice'
  6. print(name == 'Alice')
  7. print('Ali' in name)
  8. print(name.startswith('Al'))
  9. print(name.endswith('ce'))
复制代码

选择策略可以归纳为:比较None、True、False或自定义单例用is;比较普通值用==;判断成员属于容器用in;不要用is比较字符串值,也不推荐用==比较None。

七、实战:路由匹配与权限检查

URL路由中,精确路径可以用字典键做O(1)匹配,前缀路由则遍历检查startswith。这个例子能同时体现in、字典查找和前缀匹配的取舍。
  1. class SimpleRouter:
  2.     def __init__(self):
  3.         self.routes = {}
  4.     def add_route(self, path, handler):
  5.         self.routes[path] = handler
  6.     def dispatch(self, path):
  7.         if path in self.routes:
  8.             return self.routes[path]()
  9.         for route_path, handler in self.routes.items():
  10.             if path.startswith(route_path):
  11.                 return handler()
  12.         return self.not_found()
  13.     def not_found(self):
  14.         return '404 Not Found'
  15. router = SimpleRouter()
  16. router.add_route('/', lambda: '首页')
  17. router.add_route('/about', lambda: '关于页')
  18. router.add_route('/api/users', lambda: '用户API')
  19. router.add_route('/api/', lambda: 'API入口')
  20. print(router.dispatch('/'))
  21. print(router.dispatch('/about'))
  22. print(router.dispatch('/api/users'))
  23. print(router.dispatch('/api/products'))
  24. print(router.dispatch('/contact'))
复制代码

权限检查中,用集合保存用户权限,再用in判断权限是否存在,平均复杂度为O(1)。结合装饰器可以把权限校验从业务函数中抽离出来。
  1. from functools import wraps
  2. class PermissionChecker:
  3.     def __init__(self):
  4.         self.user_permissions = {}
  5.     def grant(self, user, permission):
  6.         if user not in self.user_permissions:
  7.             self.user_permissions[user] = set()
  8.         self.user_permissions[user].add(permission)
  9.     def revoke(self, user, permission):
  10.         if user in self.user_permissions:
  11.             self.user_permissions[user].discard(permission)
  12.     def has_permission(self, user, permission):
  13.         return (user in self.user_permissions and
  14.                 permission in self.user_permissions[user])
  15.     def require(self, permission):
  16.         def decorator(func):
  17.             @wraps(func)
  18.             def wrapper(user, *args, **kwargs):
  19.                 if self.has_permission(user, permission):
  20.                     return func(user, *args, **kwargs)
  21.                 raise PermissionError(f'用户 {user} 没有 {permission} 权限')
  22.             return wrapper
  23.         return decorator
  24. checker = PermissionChecker()
  25. checker.grant('admin', 'delete_user')
  26. checker.grant('editor', 'edit_article')
  27. @checker.require('delete_user')
  28. def delete_user(user, target_user):
  29.     print(f'{user} 删除了用户 {target_user}')
  30. try:
  31.     delete_user('admin', 'user123')
  32.     delete_user('editor', 'user456')
  33. except PermissionError as e:
  34.     print(f'❌ {e}')
复制代码

八、常见陷阱与最佳实践

常见陷阱包括:把字典的in误认为检查值;用is比较字符串值;用==比较None;在大型列表上频繁做in判断;依赖小整数缓存或字符串驻留结果。最佳实践是:值比较用==,None和单例判断用is,成员判断用in;频繁成员检查改用set或dict;字符串子串用in,精确值用==,前后缀用startswith/endswith;自定义__eq__存在时更要用is判断None。正确选择运算符和容器,既影响代码正确性,也直接影响查找性能。
回复

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python成员运算符in与身份运算符is原理陷阱与性能实践

这篇整理很扎实,尤其是把 in 优先调用 __contains__、没有实现时回退为迭代比较讲清楚了,自定义类那段也能直观看出成员判断其实由容器实现决定。性能和陷阱部分也很实用:列表元组线性扫描、集合字典哈希平均 O(1)、小数据量未必有明显优势,以及 is 只比较身份、比较 None 用 is None、小整数缓存和字符串驻留不能当稳定行为依赖,这些都是实际写代码容易踩的点。一个小建议:文中最后提到自定义类让 == 几乎总为 True、但 is 仍只认对象身份,如果能把那段示例补完整,读者对照起来会更清楚。整体很适合当成员运算符和身份运算符的避坑笔记。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python成员运算符in与身份运算符is原理陷阱与性能实践

这篇整理得很清楚,尤其是把 in 的容器语义、__contains__ 的调用和迭代回退,以及 is 与 == 的区别串起来了。自定义 Classroom 那个例子很直观,说明 in 的结果取决于对象怎么实现成员判断,“张”能匹配“张三”这种模糊效果在业务里确实要特别留意,不然容易把实现细节当成默认行为。 性能部分也提醒得好,列表、元组、集合、字典不能只看运算符写法,还要看数据规模和查找频率;小数据量时差异可能不明显,频繁查找就更适合 set 或 dict。小整数缓存和字符串驻留那段也很关键,结论就是不要把 is 的缓存表现写进业务判断,比较 None 用 is None,比较值还是用 ==。整体内容挺适合当避坑参考的。
回复 支持 反对

使用道具 举报

发表于 2 小时前 | 显示全部楼层

Re: Python成员运算符in与身份运算符is原理陷阱与性能实践

收藏了,楼主这篇从语义到实现再讲到性能,链路挺完整的。尤其把 for item in 和 item in container 两个“in”分开讲,这点我见过不少人混着说;还有 `__contains__` 里自己又用了一次字符串 in 才形成模糊匹配,这个解释很清楚。 下面补充几点实践中容易踩到的: 1. 找不到 `__contains__` 时,回退顺序其实有三级:先 `__contains__`,再 `__iter__`,最后老式序列协议 `__getitem__`。所以只实现了 `__getitem__` 的旧类也能用 in;如果对象是无限流式数据,或者 `__getitem__` 会自动生成元素,就会死循环或一直跑下去。给这类类补一个 `__contains__` 更稳妥。 2. 关于字典,顺手提醒一句 `x in d.values()` 是 O(n) 逐个比值;字典大时,它跟列表差不多慢。很多人以为字典查什么都快,其实是 `in d` 和 `in d.keys()` 快,查值得反过来建个值到键的索引。 3. 字符串子串查找实际用的是 Two-Way 一类的算法,比朴素的 O(n*m) 好不少;但最坏情况量级还在,超长文本反复判子串还是得考虑别的结构。 4. 楼主这份 timeit 里 target 取的是 99999,也就是列表和元组的最坏位置;换成不存在的值,或者放中间
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-11-6 06:34 , Processed in 0.029726 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部