Python八大内置函数深度解析:set、sorted、super等边界与实战
Python 的内置函数说多不多说少不少加起来也就七八十个。大多数人每天都会碰到其中几个但说句实在话天天用和真懂之间隔着一条不小的沟。我最近在整理某社团的成绩数据时被 set() 的去重顺序、sorted() 的稳定性问题挨个绊了一跤查完文档才意识到这些函数背后藏着大量平时根本不会注意到的细节。这篇文章我集中梳理了 set()、setattr()、slice()、sorted()、staticmethod、str()、sum()、super() 这八个内置函数目标读者是那些已经能写业务代码、但想更进一步理解 Python 运行机制的开发者。看完你会知道每个函数的真实边界在哪、什么时候用很划算、什么时候其实应该绕道。1. set()去重只是入门集合运算才是它的本职1.1 去重与保序list(set()) 的顺序之坑set(iterable) 会把任意可迭代对象转成集合集合天然去重这是最基础的用法。我第一次用的时候也跟大多数人一样写出list(set(data))就觉得完事了直到有次处理一份按时间排列的报名日志去重后顺序全乱了排查半天才发现问题出在这里。集合底层是哈希表迭代顺序取决于哈希值和插入时的槽位分布跟出现先后的顺序没有任何关系。如果业务上对顺序无要求list(set(data))没问题如果要求保留首次出现的顺序正确姿势是items [b, a, b, c, a] unique list(dict.fromkeys(items)) print(unique) # [b, a, c]原理很简单字典从 3.7 起保证按插入顺序迭代重复键会被覆盖但保留首次插入的位置所以dict.fromkeys天然完成了保序去重。提示保序去重用dict.fromkeys别用list(set(...))后者只适合不关心顺序的场景。另一个容易被忽略的点是成员判断的效率。判断一个元素在不在集合里底层是哈希查找平均 O(1)判断在不在列表里是线性扫描 O(n)。我在某次处理几千条报名记录时原来的代码用if id not in temp_list去重数据量到了几万条就开始卡换成 set 维护已见集合之后耗时直接降了两个数量级。这类性能问题在小数据上完全无感一旦量级上来就会被放大早换早省心。1.2 集合运算交、并、差、对称差的真实场景集合真正值钱的不是去重而是它自带的那套代数运算并集、交集、差集、对称差。四个运算分别有两种写法运算符版本和同名方法版本运算运算符方法含义并集a | ba.union(b)出现在 a 或 b 中交集a ba.intersection(b)同时出现在 a 和 b 中差集a - ba.difference(b)在 a 中但不在 b 中对称差a ^ ba.symmetric_difference(b)只在一边出现实际场景里比如两个社团的成员名单想找出重复报名的人一句set(list_a) set(list_b)就搞定要找只在 A 名单里的人用差集。需要注意运算符两侧都要求是 set而方法版本可以接受任意可迭代对象比如a.union([x, y])是合法的但a | [x, y]会直接抛 TypeError。我的习惯是两边都是集合时用运算符可读性更好混合类型数据用方法版本省去手动转换。1.3 可哈希限制与 frozenset集合的边界条件set 元素必须是可哈希的简单说就是不可变对象。所以list、dict、set都不能作为集合元素如果你确实想把一组有序数据放进去得先转成tuple。还有一种更隐蔽的情况当需要把一个集合作为另一个集合的元素或者作为字典的键时普通 set 不能胜任因为它在哈希表里本身是可变的、哈希值不稳定。此时用frozensetfs frozenset([a, b]) outer {fs: ok} # 这样才合法遍历时修改集合也会翻车。比如在for item in my_set的循环里执行my_set.remove(item)解释器会抛RuntimeError: Set changed size during iteration。要清理集合元素先把它转成 list 快照再遍历快照做修改。这个坑在写过滤逻辑时特别容易踩我至少遇到过两回。2. sorted()稳定性与 key 参数排序的关键不只在排序2.1 sorted() 与 list.sort()返回新列表还是原地修改sorted(iterable)返回一个新列表原来的可迭代对象不动list.sort()是原地排序返回 None。这决定了它们的适用场景如果你还要用原始顺序的数据用sorted()如果数据量很大、后续也不再需要旧顺序用list.sort()省一份拷贝的内存。有一点容易搞混sorted 可以作用于任何可迭代对象元组、字典、集合、生成器比如sorted(d.items())返回排好序的键值对列表而 sort 只存在于列表对象上。字典排序是个高频场景。sorted(d)排的是键sorted(d.items(), keylambda item: item[1])按值排。字符串默认比较字典序且区分大小写想忽略大小写就keystr.lower。2.2 key 参数施瓦茨变换在 Python 里的落地key 参数接受一个函数Python 在排序前对每个元素调用一次生成排序键然后按这个键比较大小。它对应经典的 decorate-sort-undecorate装饰-排序-去装饰技巧相当于先给每个元素贴上用于比较的标签排完序再把标签撕掉。之所以设计成对每个元素只调用一次 key而不是在比较过程中反复调用比较函数是从性能角度考虑的——一旦 key 计算昂贵比如解析字符串、查字典一次性调用能省下大量重复计算。words [banana, apple, Cherry, date] print(sorted(words)) # 大小写敏感 print(sorted(words, keystr.lower)) # 大小写不敏感 students [ {name: A同学, total: 257}, {name: B同学, total: 246}, {name: C同学, total: 222}, ] print(sorted(students, keylambda s: s[total], reverseTrue))很多人习惯写 lambda确实直观。但当你要排序的是对象列表或者需要多键排序时operator.itemgetter和operator.attrgetter更合适from operator import itemgetter sorted(students, keyitemgetter(total), reverseTrue) sorted(students, keyitemgetter(math, chinese))itemgetter 是 C 层实现的实测比 lambda 快一点而且多键排序的语义一目了然上面的例子就是依次按 math、chinese 排序。特别适合总分相同再看另一门成绩这类需求。日常我自己的排序场景能不用 lambda 就不用itemgetter 的可读性在代码 review 时优势很明显。2.3 稳定排序多级排序的两种实现Python 的排序算法是 Timsort稳定排序。稳定意味着当两个元素的排序键相等时它们在结果里保持原来的先后顺序。这个性质平时不起眼但在做多级排序时是个利器。经典的两步排序法# 先按姓名排 students.sort(keylambda s: s[name]) # 再按总分排总分相同的人会保持姓名升序 students.sort(keylambda s: s[total], reverseTrue)第一次排序把姓名顺序固定下来第二次排序按总分排列稳定性能保证总分一致的学生仍然按照姓名顺序排列。另一种做法是用元组作为 keysorted(students, keylambda s: (s[total], s[name]), reverseTrue)一次排序完成两级排序。但要注意提示reverseTrue会把元组里的所有字段都倒过来。想要总分降序、姓名升序reverse 做不到得回到两步 sort或者把需要升序的数值字段取负sorted(students, keylambda s: (-s[total], s[name]))。这种细节不到现场排数据基本碰不到但碰到了确实能卡你半小时。我实际用过两次两次都查了文档才想起来。3. setattr()把属性当成数据来操作的元编程入口3.1 属性操作四件套getattr、setattr、hasattr、delattrPython 的属性操作除了点号语法还有四个内置函数getattr、setattr、hasattr、delattr。setattr(obj, name, value)等价于obj.name valuegetattr(obj, name)等价于obj.name区别只是属性名以字符串形式传入。既然属性名可以是字符串它就可以是变量可以来自配置、来自循环、来自外部输入。这就是它们被称为反射工具的原因运行期操作对象的成员而不是把成员写死在代码里。getattr 常用在不确定属性是否存在的场合比如getattr(obj, maybe_missing, default)。hasattr 其实就是 try/except AttributeError 的封装。delattr 则删除属性。看一个简单例子class Point: def __init__(self, x, y): self.x x self.y y p Point(1, 2) setattr(p, z, 3) print(p.z) # 3 print(getattr(p, x)) # 1 print(getattr(p, w, none)) # none值得一提的是 setattr 会触发__setattr__如果你在类里重写了该方法用 setattr 赋值同样会经过它。这一致性在写元编程时很重要避免出现点号赋值走自定义逻辑、setattr 却绕过的割裂。3.2 典型场景字典转配置对象最典型的实践是把配置字典变成对象的属性。举个例子某模拟项目里的配置是外部读进来的 dictconfig_data { subjects: [math, chinese, english], pass_lines: {math: 60, chinese: 60, english: 60}, max_score: 100, }如果业务代码统一用config[pass_lines]这种下标访问容易写错键名还得不到编译期提示。改成对象属性访问会舒服很多class Config: def __init__(self, config_dict): for key, value in config_dict.items(): setattr(self, key, value) config Config(config_data) print(config.max_score) # 100 print(config.pass_lines[math]) # 60这样配置的键就成了对象的属性既有字典的灵活又有属性访问的简洁。如果不想自己写这个类标准库types.SimpleNamespace能做到同样的事SimpleNamespace(**config_data)。自己写的好处是可以在__init__里加默认值、类型校验等逻辑。3.3 什么时候应该绕过 setattrsetattr 好用但不能滥用我的三条经验第一属性名在写代码时就知道的话直接用点号。点号写错了 IDE 和解释器能立刻报错setattr 写错了可能直到运行某个分支才暴露排查成本高。第二数据结构是固定的表格式数据优先用 dataclass 或显式类定义全部字段不要为了少写几行把一切变成动态属性。动态属性的坏处是你无法在类定义处看到完整的字段列表代码的可读性、静态检查、自动补全都会受影响。第三不要把外部传入的任意字符串直接 setattr 到核心对象上。我之前见过一段代码把一个请求参数直接 setattr 到了用户对象上结果对象上挂了一堆乱七八糟的属性后续序列化时全暴露出去查了半天才定位到。属性也是数据但它是有边界的、应该被计划好的数据不是用来兜底的字典。4. slice()切片语法背后的工厂函数与自定义序列4.1 slice 对象的三个参数lst[start:stop:step]是语法糖解释器会把中括号里的部分转成slice(start, stop, step)再传给序列的__getitem__。所以直接创建 slice 对象就是绕过语法糖显式地描述一次切片s slice(1, 5, 2) data [0, 1, 2, 3, 4, 5, 6, 7] print(data[s]) # [1, 3]slice(stop)只传一个参数时相当于从开头切到 stopslice(start, stop)是常规区间。这里的 start、stop、step 都可以是 NoneNone 对应的就是语法切片里省略的位置。比如slice(None, None, -1)等价于data[::-1]用于反转序列。4.2 复用切片与动态分页slice 对象最大的价值在于把一次切片变成一个可复用的对象。同一段切片逻辑要在多个地方使用时不必每次写一模一样的 start:stop:step定义一个变量即可。更重要的是动态切片start 和 stop 是运行时算出来的直接拼进切片表达式会很难读用 slice 对象则很清晰。分页就是典型场景def paginate(items, page, page_size): sl slice((page - 1) * page_size, page * page_size) return items[sl] data list(range(20)) print(paginate(data, 1, 5)) # [0, 1, 2, 3, 4] print(paginate(data, 3, 5)) # [10, 11, 12, 13, 14]page 从 1 开始第 1 页取索引 0 到 size-1第 2 页取 size 到 2size-1公式直接写在 slice 里。比手写 start/end 两个变量再data[start:end]更不容易越界语义也更集中。4.3 indices()切片的安全规范化slice 对象还带一个容易被忽略的方法indices(length)。它接收序列长度返回一个三元组 (start, stop, step)表示该切片在这个长度下的规范化索引。负索引、None、越界值全部会被处理干净s slice(-3, None) print(s.indices(10)) # (7, 10, 1) t slice(0, 100, 2) print(t.indices(10)) # (0, 10, 2)第一行里 -3 被换算成了 7stop 是 None 就被填成 10第二行 stop100 超出长度也被夹紧成 10。自己写自定义序列类时__getitem__会收到 slice 对象先用indices(len(self))得到规范化三元组再基于它做真正的位置计算可以避开大量负数索引和越界的边界 bug。range 也是序列range(10)[s]可以直接用切片对象很多数据处理工具在这种场景下都用 slice 传递切片意图。5. staticmethod 与 super()两个反直觉的面向对象机制5.1 staticmethod 到底静态在哪先明确一个容易混淆的点staticmethod 修饰的函数不收 self也不收 cls它跟一个普通模块级函数几乎没有本质区别只是被放在了类的命名空间里。那它存在的意义是什么两个一是组织代码把与类逻辑紧密相关、但不依赖实例和类状态的工具函数放在类内部调用方一眼就能看到归属二是允许子类在继承体系里覆盖它实现按类替换工具函数的效果。什么时候用 staticmethod 而不是 classmethod看方法需不需要知道是哪个类在调用。classmethod 会收到 cls意味着它可以访问类的属性、可以被子类继承并体现子类的配置staticmethod 完全拿不到类信息。所以工厂方法、需要根据子类个性生成对象的方法用 classmethod纯工具函数格式校验、单位换算、文本清洗不关心调用者是谁用 staticmethod 就够了。方法类型第一个参数能访问什么典型用途实例方法self实例和类依赖实例状态的行为类方法 classmethodcls类属性和类方法工厂方法、读取类配置静态方法 staticmethod无都不能直接访问工具函数、校验逻辑一个常见的翻车示例在 staticmethod 里试图访问 self 或 cls直接报参数不匹配。写之前想清楚这个方法到底需不需要访问实例或类比写完了再找问题要省事。5.2 super() 与 MRO从单继承到协作式多继承super() 可能是这些函数里最容易被误解的一个。很多人认为 super() 返回父类对象其实它返回的是一个代理对象代理的是当前类在 MRO方法解析顺序里的下一个类。单继承里下一个类恰好是父类所以看起来像是调父类多继承里下一个类完全由 MRO 决定跟你直觉上的父类可能不是同一个。MRO 可以通过Class.mro()查看。零参数 super() 之所以在类方法里能用是解释器借助__class__单元格和方法的第一个参数自动补全了super(current_class, first_param)。这也是为什么在普通函数里直接写 super() 会报错——它依赖那个隐藏的类上下文。在__init__里最常见的写法class Base: def __init__(self): print(Base init) self.value 0 class Child(Base): def __init__(self): super().__init__() print(Child init) self.value 1super().__init__()保证父类的初始化逻辑被执行而不是手工复制父类__init__的代码。不调用 super().init()父类里设置的属性就全部缺失这是新手最常见的错误之一。5.3 钻石继承场景super 调用的完整链路多继承下 super() 的协作行为看一个经典钻石结构class A: def __init__(self): print(A init) super().__init__() class B(A): def __init__(self): print(B init) super().__init__() class C(A): def __init__(self): print(C init) super().__init__() class D(B, C): def __init__(self): print(D init) super().__init__() d D() print(D.mro())输出初始化顺序是 D init → B init → C init → A initMRO 是[D, B, C, A, object]。每个__init__都调用 super().init()super 就会沿着 MRO 把接力棒依次传下去最终 A 的 super 传给 objectobject 没有额外动作链路结束。这样一来 A 的初始化只执行一次不会出现菱形结构里祖先初始化两次的问题。提示多继承里千万不要用类名直接调用祖先方法比如A.__init__(self)那会打断 super 协作链路导致祖先可能被初始化两次行为不可预测。mixin 模式能稳定工作靠的就是这条纪律。6. str() 与 sum()类型转换和聚合运算里最容易被忽略的细节6.1 str() 的调用链str与repr的分工str(obj) 做的事情是先尝试调用obj.__str__()如果类没有定义__str__就回退到__repr__()。print()、f-string、字符串格式化接口在显示对象时走的也是差不多相同的路径。所以想控制对象在打印时显示什么定义__str__就够了想让开发者在交互式环境里看到无歧义的对象表示就该写__repr__。一个简单例子class Student: def __init__(self, name): self.name name def __str__(self): return fStudent({self.name}) def __repr__(self): return fStudent(name{self.name!r}) s Student(A同学) print(str(s)) # Student(A同学) print(repr(s)) # Student(nameA同学)这里的惯例是__repr__尽量返回一个可以还原对象的表达式方便调试__str__面向最终用户怎么好读怎么来。另外注意 str() 对 bytes 的处理str(babc)得到的是babc而不是abc要拿真正的文本得babc.decode(utf-8)或者str(babc, utf-8)。这个坑在文件读取和网络数据处理场景里经常绊人至少我身边就有同事中过招。6.2 sum() 的字符串困境为什么它不拼字符串sum(iterable, start0) 是对可迭代对象求和的最直接工具。它的报错信息很能说明问题sum([a, b])会抛TypeError: unsupported operand type(s) for : int and str。原因就是 start 默认是 0Python 试图执行0 a当然失败。语言设计者不打算让 sum 做字符串拼接因为字符串拼接有更明确的工具.join(seq)而且 join 是为高效拼接专门设计的sum 强行支持字符串只会鼓励低效写法。同样道理sum 也不适合用来展开列表。sum([[1], [2]], [])虽然能跑会对每个元素做一次列表连接整体复杂度是 O(n²)。想展开嵌套列表标准答案是用itertools.chain.from_iterable([[1], [2]])或者列表推导式。判断标准很简单看清楚 sum 的语义是数值聚合还是结构拼接前者用 sum后者选别的工具。6.3 start 参数、生成器与自定义数值类型start 参数是 sum 容易被忽略的第二个维度。它决定了求和的初始值也可以用于把一堆数叠加到某个基数上sum(float_list, 0.0)。只要类型支持__add__sum 就能工作所以 complex、Decimal、Fraction 都可以直接求和但要注意 start 默认是 int 0它参与了首次加法运算。若你的数据是 Decimal 而 start 是 int虽然也能加但为了精确控制类型最好显式传同类型的 start。sum 和生成器表达式搭配非常省内存total_math sum(student[math] for student in students)这一句不会额外生成一个中间列表是边遍历边累加。数据量大时这种写法的内存优势很明显。性能方面sum 的累加循环在 C 层执行比手写 for 循环快但如果你处理的是大型数组对象优先用数组库自带的向量化聚合方法内置 sum 逐个元素调用加法在那种场景下反而是慢的。7. 综合实战用这八个内置函数重构一份成绩处理代码7.1 原始需求与初版代码场景是这样的某社团的模拟项目里收集到一批学生的成绩记录格式是字典列表可能存在同一个学生报名两次导致重复需要去掉重复记录、计算总分并按总分从高到低排名、把排名结果分页展示、生成一份可读的报告文本。科目及格线等配置来自外部字典还要能按学期扩展。初版代码我按新手最容易写出来的方式整理一下长这样records [...] unique [] temp [] for rec in records: if rec[id] in temp: continue temp.append(rec[id]) unique.append(rec) for rec in unique: rec[total] rec[math] rec[chinese] rec[english] for i in range(len(unique)): for j in range(i 1, len(unique)): if unique[i][total] unique[j][total]: unique[i], unique[j] unique[j], unique[i] page, size 1, 2 start (page - 1) * size end page * size page_items unique[start:end] print(排名 学生 总分) for idx, rec in enumerate(page_items, 1): print(idx, rec[name], rec[total])这段代码最大的问题不是慢而是意图不清晰去重用了一个临时列表当已见集合排序写的是手写冒泡分页的 start/end 散落在主流程里所有数据都用裸字典承载。能跑但根本没法扩展。7.2 重构过程八个函数逐个落地第一步去重逻辑用 set 重写。维护一个 seen 集合遇到已经见过的 id 直接跳过既保留首次出现的顺序成员判断又是 O(1)def dedup_by_id(records): seen set() result [] for rec in records: if rec[id] in seen: continue seen.add(rec[id]) result.append(rec) return result第二步把裸字典换成 Student 类。总分计算直接交给 sum()展示交给__str__调试表示交给__repr__分数合法性校验用 staticmethod 挂到类上class Student: def __init__(self, sid, name, math, chinese, english): self.id sid self.name name self.scores {math: math, chinese: chinese, english: english} staticmethod def is_valid_score(value, max_score100): return 0 value max_score property def total(self): return sum(self.scores.values()) def __str__(self): return f{self.name}({self.id}) 总分 {self.total} def __repr__(self): return fStudent({self.id}, {self.name}, {self.scores})第三步排序交给 sorted() 的 key 参数替代手写冒泡ranked sorted(students, keylambda s: s.total, reverseTrue)。第四步配置用 setattr 动态挂载并让子类通过 super 调父类初始化class CourseConfig: def __init__(self, config_dict): for key, value in config_dict.items(): setattr(self, key, value) class TermConfig(CourseConfig): def __init__(self, config_dict, term): super().__init__(config_dict) self.term term第五步分页改用 slice 对象封装页号和每页条数作为参数传入逻辑集中且不易越界。第六步报告输出通过 str() 完成f-string 直接把 Student 对象转成可读文本。7.3 重构后的代码与收益对照把上面这些拼起来完整代码就清楚了raw_records [ {id: S001, name: A同学, math: 92, chinese: 88, english: 77}, {id: S002, name: B同学, math: 70, chinese: 95, english: 81}, {id: S003, name: C同学, math: 64, chinese: 70, english: 88}, {id: S002, name: B同学, math: 70, chinese: 95, english: 81}, {id: S004, name: D同学, math: 88, chinese: 60, english: 93}, {id: S001, name: A同学, math: 92, chinese: 88, english: 77}, ] students [ Student(rec[id], rec[name], rec[math], rec[chinese], rec[english]) for rec in dedup_by_id(raw_records) ] ranked sorted(students, keylambda s: s.total, reverseTrue) def paginate(items, page, page_size): sl slice((page - 1) * page_size, page * page_size) return items[sl] first_page paginate(ranked, page1, page_size2) def render_report(students): lines [排名 | 学生 | 总分] for rank, stu in enumerate(students, start1): lines.append(f{rank} | {str(stu)}) return \n.join(lines) term_config TermConfig({ max_score: 100, pass_lines: {math: 60, chinese: 60, english: 60}, }, term春季学期) print(配置, term_config.term, term_config.max_score) print(render_report(first_page))输出是清晰的配置 春季学期 100 排名 | 学生 | 总分 1 | A同学(S001) 总分 257 2 | B同学(S002) 总分 246整体收益可以这样对照原始问题使用的内置函数收益去重保留顺序set()去重逻辑变成显式的已见集合复杂度从 O(n²) 降到 O(n)总分计算sum()一行替代手写循环属性化之后调用点清晰排名sorted() key排序意图一目了然稳定排序保证并列时的相对顺序配置对象setattr()外部字典成为对象属性访问方式统一分页slice()页号公式集中在一个函数里不易越界报告文本str() / 类内__str__输出逻辑收归对象自身渲染层不再关心字段细节校验逻辑staticmethod工具方法和类绑定调用方容易发现子类扩展super()初始化链路协作式传递不用重复父类代码这段代码比初版长但每一块都能独立测试、独立复用。数据清洗、排名渲染、配置管理三块逻辑彻底解耦这比代码越短越好重要得多。回过头看八个函数没有一个炫技都是在自己最合适的位置上干了最合适的事。写到这里我最大的感受是内置函数文档里每一条都只有一两句话但真正的知识点全在文档没写的地方——set 的无序性、sorted 的稳定性、sum 对字符串的拒绝、super 的 MRO 接力。这些行为不是缺陷它们是 Python 设计者深思熟虑后的边界。理解边界不是为了绕开它们而是为了在合适的场景里把它们变成你的工具。