ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python数据类型全解析:从对象模型到性能优化

Python数据类型全解析:从对象模型到性能优化 开门见山先回答那个最常被问到的问题Python里到底有哪些数据类型我在带新人或者写技术分享的时候发现很多人对这个问题要么背得滚瓜烂熟但用不起来要么干脆模棱两可。今天这篇东西就当作一次彻底的梳理从官方分类到实际踩坑从内存原理到性能优化尽量一篇讲透。不管你是刚看完Python基础语法准备上手的小白还是写了一段时间代码但总觉得哪里没搞明白的初级开发者这篇文章都值得你花十来分钟认真看看。需要说明的是文中涉及到Python行为细节的内容我都基于默认的CPython实现来测试和说明版本以Python 3.10为主个别地方会提一下历史版本差异。1. 先看清Python数据类型的整体版图1.1 官方标准分类到底在说什么Python官方文档把所有内置类型放在了一起常见的有这些数值类型int整数、float浮点数、complex复数序列类型str字符串、list列表、tuple元组、range范围对象映射类型dict字典集合类型set集合、frozenset冻结集合布尔类型bool布尔值二进制序列类型bytes、bytearray、memoryview空类型NoneType这个分类虽然看似简单但你多看几眼就能发现一个关键点——它是按照行为特征来划分的。也就是说Python不是把一个类型简单定义成存数字的或存文字的而是从这个对象支不支持索引、支不支持修改、存单值还是存多值、能不能哈希这些维度来区分的。理解了这一点你对很多语法细节就不会再死记硬背了。比如字符串和列表都属于序列类型所以它们都支持索引、切片、迭代。但list是可变序列str是不可变序列于是list多出了一堆修改方法append、pop、insert而str没有。再比如dict和set属于映射类型和集合类型但它们都依赖哈希表来实现所以存储的元素都要求可哈希hashable这就是为什么列表不能当字典的键。所以学数据类型不要孤立地记要带着这个容器是用来做什么的有哪些行为约束这个视角去理解。1.2 动态类型机制变量名这把钥匙是什么时候给你配的很多从C、Java转过来的朋友刚接触Python时最不习惯的就是变量声明。写x 10时你不需要告诉Pythonx是一个整数之后写x hello也没有任何报错。这就是动态类型变量本身没有类型它只是名字类型是绑定在对象身上的。这个机制背后的核心逻辑是Python中的一切皆对象变量名本质上是对象的一个引用标签。当你写a 10时实际上发生了两件事创建了一个值为10的整数对象然后把名字a贴到这个对象上。当你写b a时并不是复制了一份整数10而是给同一个整数对象再贴了一个标签b。这时候你用id(a) id(b)去查会发现它们是同一个对象因为id返回的是对象在内存中的地址。不过新手最容易被坑的点在这里如果b和a指向同一个列表对象你修改b的内容a也会跟着变因为它们是同一个对象只是名字不同。a [1, 2, 3] b a b.append(4) print(a) # [1, 2, 3, 4]这其实不是bug而是Python对象引用语义的正常表现。理解了这个变量名是标签的模型后面遇到浅拷贝、深拷贝、可变对象默认参数之类的陷阱你会更容易想通根源。提示Python是强类型语言意味着类型不匹配的操作会直接报错比如1 1会抛出TypeError而不是像JavaScript那样自动把数字转换成字符串。这个强和动态并不矛盾——动态说的是运行时才确定类型强说的是类型确定后不会自动做你不期望的隐式转换。1.3 为什么不搞只有一种类型有些语言为了简化模型几乎所有数据都用一种结构表示比如JSON就只用对象和数组两种容器。Python为什么非要搞出list、tuple、dict、set这么多容器答案是不同的数据访问模式需要不同的性能优化方向。列表需要按位置快速访问所以用连续内存动态数组实现字典需要按键快速查找所以用哈希表集合需要快速判断某个元素是否存在也用了哈希表元组则牺牲了可变性换来了更小的内存占用和可哈希能力。如果你想用最少的类型解决所有问题那最终的结果就是所有场景都不高效。举个例子你需要存储一批学生的姓名如果只用list查找某个名字是否存在最坏情况要遍历整个列表时间复杂度是O(n)。但如果你用set存储底层哈希表让你平均O(1)就能判断张伟在不在。同样的数据量在大规模场景下这可能是毫秒和秒级的天壤之别。所以选数据类型本质上是在选一种操作策略你是要频繁追加要按键取值要判断唯一性要不要作为字典键这些需求决定你应该选哪个容器。2. 不可变类型数字、字符串和元组背后的设计哲学2.1 数值类型int的任意精度比你想的更实用Python的int和很多语言不一样——它不限位数。Java的long有最大值C的int在32位平台上限就是21亿多但Python的int可以无限大只要内存够。这得益于底层用了一个数组来存储数字所以你在Python里算2的100次方只是随手写个表达式的事print(2 ** 100) # 1267650600228229401496703205376这个特性在做大数计算、密码学demo、数学竞赛题的时候特别省心。你不需要像在C语言里那样自己拆成数组做高精度乘法。Python正是因为有这种不折腾的体验才成了很多算法选手的首选语言。float则是双精度64位浮点数遵循IEEE 754标准它的问题在于精度有限。经典例子print(0.1 0.2) # 0.30000000000000004这不是Python的bug而是所有用二进制表示十进制的浮点系统的共性。所以涉及金额计算、需要精确小数的场景我强烈建议你直接用decimal.Decimal别在float上死磕。很多人写爬虫处理价格数据的时候拿到字符串19.99直接float转了然后累加时发现末尾多了一串诡异的小数这时候再回去排查就是浪费时间。complex复数类型用得不多但做信号处理、科学计算时会碰到。它的字面量写法是3 4j注意是j不是i。可以复数直接参与四则运算内置库cmath则提供了针对复数的数学函数。2.2 字符串不可变性到底换来了什么字符串是Python中使用频率最高的数据类型没有之一。它的不可变性意味着创建之后就不能修改其中的某个字符s hello # s[0] H # TypeError: str object does not support item assignment正确做法是new_s H s[1:]。不可变性换来的第一个好处是安全。字符串可以被多个变量安全共享不用担心一个地方改了、其他引用者全部遭殃。这种特性在字典键、多线程环境里尤其有价值。第二个好处是可哈希因此字符串可以直接作为字典的键这也是JSON的key必须是字符串的原因之一——字符串天然稳定、可比较、可哈希。关于字符串我最想说的其实是如何高效拼接。很多新手习惯在循环里用去拼字符串比如result for i in range(10000): result str(i)这种做法在Python里是出了名的慢。因为字符串不可变每次都会创建一个新的字符串对象然后把旧内容拷贝进去。10万次循环就创建10万个临时对象时间和内存双双爆炸。正确做法是收集到列表里再一次性拼接parts [] for i in range(10000): parts.append(str(i)) result .join(parts)这里的关键是join方法只遍历一次列表分配一次足够大的内存空间然后一次性拼接完。实测下来同样拼接10万个字符串片段join方法的速度比快上几个数量级。在写日志、拼SQL、生成HTML字符串这些场景这个习惯能省下大量不必要的开销。还有一个容易踩坑的点是字符串驻留机制。CPython会对短小的字符串做驻留intern优化即值相同的短字符串可能共享同一个对象。但短的标准并不透明所以不要依赖is来比较字符串一律用。字符串驻留只是底层优化细节不是语言规范。2.3 元组它不只是不能修改的列表元组在形式上确实和列表很像但两者设计意图完全不同。列表是我要不断往里面添加元素、修改元素元组是这组数据是固定的谁也别想改。后者在语义上更像一条记录比如坐标点(x, y)RGB颜色(255, 0, 0)。元组的不可变性带来了三大实用价值第一可以哈希因此可以作为字典的键。比如你想用二维坐标作为键来存储网格状态字典的key就可以用元组而列表不行。第二作为函数返回值特别好用。Python函数可以返回多个值本质就是返回一个元组。x, y func()这种解包语法底层就是在拆分元组。第三内存占用更小。因为元组的长度固定且不可变底层结构比列表精简得多。在需要大量创建小对象的场景比如坐标点、键值记录元组的内存优势很明显。我实测过同样装10万个整数tuple占比大约比list少16到20个字节每个元素。别看单次不大量上来之后差距就很可观。注意元组的不可变是浅层的。如果元组里装了一个列表这个列表本身是可以被修改的。比如t (1, [2, 3])执行t[1].append(4)是合法的。所以严格来说元组保证的是元素指向的对象引用不可变而不是对象内容不可变。3. 可变容器类型列表、字典、集合的底层逻辑与坑3.1 列表的底层机制为什么append快而insert慢列表的底层实现是一个动态数组。它并不是每次添加元素都重新分配内存而是预先分配了一块连续空间当空间不足时按一定倍数扩容。所以在列表尾部append元素平均情况下是O(1)复杂度非常快。但是insert操作尤其是往头部插入就不一样了。因为列表是连续内存插入一个元素后面的所有元素都要向后移动一位最坏情况是O(n)复杂度。如果你需要频繁在头部添加元素与其用list.insert(0, x)不如用collections.deque它是双端队列两端添加和弹出的复杂度都是O(1)。扩容机制还解释了为什么很多人说列表存储的是引用而非数据。列表的每个槽位存放的是指向实际对象的指针所以同一个列表里可以混存int、str、自定义对象等不同类型。这个特性虽然灵活但也意味着如果你有一个百万级整数列表底层实际占用的内存是整数对象的空间 指针数组的空间远比一个C语言数组要重。另一个关于列表的经典坑是浅拷贝。很多人复制列表直接写new_list old_list这其实只是新取了个名字并没有复制数据修改一个另一个跟着变。如果你确实需要复制用old_list.copy()或者old_list[:]这能得到一个浅拷贝——第一层容器是新的但里面的元素对象仍然是共享引用。如果列表里还有嵌套列表那浅拷贝后的子列表修改仍然会互相影响这时就需要copy.deepcopy或自己实现递归复制逻辑。3.2 字典哈希表原理和键类型的讲究字典是Python最强大的数据结构之一也是很多Python代码高效运行的基石。底层是一张哈希表当你执行d[key]时Python会计算key的哈希值然后定位到存储槽位。平均时间复杂度O(1)比列表的O(n)查找快得多。但正因为依赖哈希表字典对键的类型有硬性要求——键必须可哈希。什么叫可哈希简单说就是这个对象有稳定的__hash__值并且在生命周期内不会改变。数字、字符串、元组元素都不可变时都是可哈希的列表、字典、集合是可变类型不可哈希所以不能作为字典的键。Python 3.6之后字典除了保持键值的哈希查找能力还额外记住了插入顺序。也就是说字典现在是有序的迭代字典时会按照键的插入顺序返回。这个特性在Python 3.7被正式官方化所以现在你可以放心地依赖字典是有序的这个行为来写代码比如把字典当作有序配置项的数据结构。字典还有个容易被忽视的点空间占用大。哈希表为了减少冲突通常会保留不少空槽位所以字典的内存密度远低于列表。如果你要存储海量记录并且字段非常固定可以考虑用轻量级的namedtuple或dataclass而不是每个记录都建一个字典。3.3 集合去重和集合运算的正确姿势set本质上是一个没有值的字典底层同样是哈希表所以元素也要求可哈希。集合最大的价值在于快速判断某个元素在不在里面以及做数学意义上的交集、并集、差集运算。比如你有两个列表想找出同时在两个列表里的元素新手习惯用嵌套循环common [] for x in list1: for y in list2: if x y: common.append(x)两层循环下来时间复杂度是O(n*m)数据量一上去就卡死。正确做法是把一个列表转成集合然后做交集common list(set(list1) set(list2))一行代码搞定性能提升非常明显。尤其是在去重的场景list(set(data))是所有人最早学会的Python技巧之一但很多人只知其然不知其所以然——它快是因为集合的哈希查找替代了列表的线性遍历。frozenset则是不可变的集合有了不可变性就可以作为字典的键或者另一个集合的元素。虽然平时用得少但你在写图算法、需要用集合作为状态缓存的时候它就能派上用场。4. 类型转换与类型判断写对代码的基础能力4.1 显式转换哪些场景最容易踩坑Python用构造函数来做显式类型转换比如int(x)、float(x)、str(x)、list(x)、tuple(x)、set(x)。大部分情况下转换是直观的但有几个坑值得单独点名。第一个是字符串转数字。int(42)没问题但int(42.5)会报错因为int()底层的解析逻辑只接受整数的字符串表示要么你用float(42.5)先转成浮点数要么用Decimal来处理。类似的int( 42 )虽然能成功因为字符串两侧的空格会被忽略但int(42abc)就直接ValueError。第二个是bool是int的子类。在Python里True其实就是整数1False就是整数0。所以int(True)返回1True 1返回2。这个特性有时候会带来隐性bug比如你想统计一个列表中满足条件的元素个数直接sum([True, False, True])得到2这其实是特性而非bug但如果你没有意识到bool和int的这种关系调试时可能会莫名其妙。第三个是从可迭代对象转换。list(hello)会得到[h, e, l, l, o]注意不是[hello]。如果你想把一个字符串当作整体放进列表应该写[hello]。同理set(hello)会得到{h, e, l, o}因为集合天然去重。这种字符串是可迭代对象的特性新手经常忽略。4.2 隐式转换机制虽然方便但别太依赖Python部分场景下会做隐式类型转换。比如整数和浮点数相加结果自动变成浮点数1 2.5得到3.5。布尔值参与算术运算时会被当作0或1参与计算。这些都是规则的一部分了解之后就不会莫名其妙。但隐式转换最有争议的是比较时的规则。比如1 1.0返回True1 True也返回True。这不完全是Bug而是因为数字类型之间有换算关系。但如果你在代码里依赖这种松散的比较很容易掩盖类型层面的逻辑问题。我个人的建议是在关键业务比较时可以加上类型检查的习惯比如用type(x) is int或isinstance(x, int)先确认类型。另一个容易困惑的是判断一个对象是否为None。千万别用 None要用is None。因为赋值和比较多个None可能不是同一个对象但在Python里None是全局唯一的单例对象用is None才是真正的判断。很多老手写代码时专门用if x is not None:这就是严谨的体现。4.3 类型判断type和isinstance怎么选判断一个对象的类型最常用的是type(obj) SomeClass和isinstance(obj, SomeClass)两者有什么区别type()返回的是对象的实际类型严格精确。但isinstance()支持继承关系的判断它检查对象是否是指定类型或其子类的实例。由于bool是int的子类isinstance(True, int)返回True而type(True) is int返回False。大部分场景下你应该用isinstance而不是type因为它更符合面向对象的多态思维。这里要特别提一下鸭子类型。Python社区经常说不要问它是不是鸭子要看它会不会像鸭子一样叫。也就是说在很多场景下你不应该强求某个对象必须是list只要它支持可迭代操作for循环就能用。这种风格让Python代码更灵活但代价是错误可能要等到运行到某一行才暴露出来。所以在写公共接口、库函数的时候我建议在入口处用isinstance校验一下传入参数的类型给自己留个尽早报错的机会。Python 3.10之后还引入了联合类型写法isinstance(x, int | float)可以直接判断x是不是int或float。这个语法写起来很简洁但要注意你的项目Python版本是否支持。5. 实务中绕不开的坑和性能优化心得5.1 可变默认参数每个Python新手都会踩的雷定义一个函数时默认参数如果是可变对象比如列表或字典就会埋下一个非常隐蔽的雷def add_item(item, items[]): items.append(item) return items第一次调用add_item(1)返回[1]第三次调用add_item(3)却返回[1,2,3]。为什么会这样因为默认参数在函数定义时就被创建了之后的每次调用如果没有显式传入items用的都是同一个列表对象。函数调用了多次但列表只初始化了一次。解决方案很简单用None作为默认值函数内部再创建新对象def add_item(item, itemsNone): if items is None: items [] items.append(item) return items这是Python面试的高频题但更重要的是实际工作中你会真的遇到。我见过有人写配置缓存函数用可变列表当默认参数积累状态结果在测试环境一切正常一到线上并发场景就出现诡异的共享数据问题排错排了一整天。5.2 浅拷贝和深拷贝什么时候该用哪一个如果你处理的是嵌套结构比如列表里套字典字典里再套列表那么拷贝操作就要特别小心。copy.copy()做的是浅拷贝只复制最外层容器固定里面的元素引用。对嵌套子对象修改时原对象和拷贝对象会互相影响。copy.deepcopy()则是递归复制每一层生成完全独立的对象但代价是性能低很多而且在某些对象不可复制时会报错。我写爬虫存配置数据的时候经常遇到这种场景从外部读入了一个大字典作为默认配置每个任务处理前要根据任务ID临时改几个字段。如果直接用默认字典再去改那么第二个任务看到的就是第一个任务改过的脏数据。正确做法是每次任务开始前用copy.deepcopy(default_config)复制一份独立的配置再修改。但深拷贝确实贵如果配置很大、任务又很频繁这会成为性能瓶颈。这时候可以换一种思路把配置设计成不可变结构比如tuple代替list不需要修改的字段用MappingProxyType包起来需要修改时就生成一个新的配置对象而不是复制旧的。5.3 海量数据下的类型选择dict、list还是别的数据量一旦到了百万级别数据类型的差异会被放大到肉眼可感知的程度。比如你需要维护一个用户名到用户ID的映射关系。最直觉的做法是字典这没问题。但如果你的数据是从数据库读出来的一列元组且只做一次查询那直接用列表加循环遍历可能更快——因为建哈希表本身也有开销。数据量小的时候Python的哈希表建设和扩容成本可能超过线性扫描的成本所以不要盲目认为字典一定比列表快。再比如你需要存储一些固定结构的记录每条记录有id、name、age三个字段。用三个独立的list分别存id、name、age比用一个list存dict要省很多内存。Python的list里每个元素是一个指针而dict里每个键值对的结构体开销非常大。如果数据量上了千万这个内存差距会直接决定程序能不能跑完。这里还延伸出一个更进阶的话题如果数据量大到Python原生容器也撑不住该考虑array模块或者numpy了。array.array(i, data)只存储原始C整数内存占用远小于普通int列表因为不需要为每个整数单独建Python对象。numpy的ndarray则更夸张它在连续内存里存储同类型元素还能用矢量指令做批量运算。做数据分析、量化策略的人之所以不管三七二十一直接上numpy就是因为原生list在性能上完全不是对手。5.4 字符串格式化的效率对比%格式化、format和f-stringPython里有好几种字符串格式化方式name Alice age 30 # 方式一%格式化 s1 %s is %d years old % (name, age) # 方式二str.format s2 {} is {} years old.format(name, age) # 方式三f-string s3 f{name} is {age} years old三者功能都能实现但性能上f-string是最快的。因为f-string在编译阶段就解析成高效的字节码不需要解析格式化模板。我简单测过格式化10万次字符串f-string比str.format快20%到30%比%格式化也快。更重要的是f-string可读性最好变量直接写在里面不需要维护参数顺序也不容易写错。所以我的建议很简单新代码一律用f-string除非你需要动态生成模板那才考虑format或者Template类。但格式化的时候有个性能坑要注意——如果是在循环里拼接SQL或者日志内容每行都生成大量中间字符串可以考虑用列表收集之后一次性拼接或者用生成器表达式加join尽量减少临时对象的创建。5.5 不可变类型的另一个隐藏优势hash缓存不可变类型可哈希这是它可以作为字典键、集合元素的原因。但哈希值的计算本身也有成本。对字符串来说每次计算哈希值都要遍历一遍字符串内容如果字符串很长哈希计算开销不小。CPython对此做了一个优化字符串对象会缓存计算过的哈希值第一次算完后存起来后续再哈希就直接用缓存结果。这就是为什么字符串可以反复作为字典键而不必有性能顾虑。元组也有类似机制但前提是它缓存的是各元素的哈希组合结果。如果我需要写一个自定义类并且打算把它的实例作为字典键那一定要正确实现__hash__和__eq__方法。这两个方法的约束是如果两个对象相等它们的哈希值必须相等。最稳妥的做法是基于不可变属性来实现哈希函数如果类中包含可变属性那这个类就不适合作为字典键因为修改属性后哈希值会变键进入字典后就再也找不到了。6. 从数据类型开始建立一个更系统的编程思维回顾整篇文章你会发现Python数据类型这个话题看起来基础但展开之后涉及了对象模型、内存布局、哈希原理、性能优化等多个维度。这些知识单独看可能不显眼但它们组合在一起决定了你写出来的代码是能跑还是跑得快、跑得稳。我见过不少转行做数据分析的同事pandas用得很熟练但一旦脱离DataFrame需要自己处理原始数据时就反复在list和dict之间倒腾性能惨不忍睹。根源就是没有建立起数据结构决定算法复杂度的思维框架。如果你能把每种数据类型的特性、限制、适用场景都了然于胸很多代码问题在动手之前就能避开。这里分享一个我自己的习惯每次编写一个新的数据存储需求时先问自己四个问题这个数据的数量级有多大内存能不能撑住我访问数据的方式是按下标、按key还是按成员判断数据在运行过程中需不需要被修改数据会不会被多个地方共享引用把这四个问题的答案理清楚用哪个类型的基本盘就已经定了。剩下的只是如何写出更优雅、更高效的代码。如果你在阅读过程中发现自己对某个数据类型的使用不熟练不用着急。我给你的建议是去LeetCode找几道简单题故意用错类型比如该用set去重的地方用list该用dict缓存的地方用循环查找感受一下性能差异然后再用正确方式重写一遍。实际操作中获得的感觉比任何文档都来得深刻。Python的数据类型并不复杂但每一条设计背后都有充足的理由。理解这些理由比记住结论更加重要。希望这篇文章能帮你打开那扇门。
返回列表