尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python数据类型难点全解析:可变与不可变、类型判断与拷贝陷阱

Python数据类型难点全解析:可变与不可变、类型判断与拷贝陷阱 如果你刚开始学 Python很可能会觉得“数据类型”是整门语言里最没有技术含量的一块字符串、整数、列表、字典背一背不就行了但等你真正开始写爬虫、处理接口数据、用 pandas 做分析或者维护一个别人留下的项目时会发现大量莫名其妙的 bug 其实都出在数据类型上。比如接口返回的 JSON 里明明是一个数字Python 却把它当作字符串导致求和时报错比如你复制了一个列表改“副本”时原列表居然也跟着变了又比如一个函数多调用几次后默认列表参数里莫名多出了上一次的数据。这些问题并不是语法没背熟而是对 Python 数据类型背后的运行机制理解不够。做技术分享这些年我有一个很清晰的判断Python 数据类型的难点从来不在“记住哪几种类型”而在三件事——可变与不可变的区别、类型判断与转换的边界、对象引用与拷贝的时机。这篇文章会围绕这三条主线把 Python 的基本数据类型、类型判断、类型转换、常见坑和工程建议一次讲透。无论你是刚入门的新手还是已经写了一阵子脚本但经常被奇怪问题绊住的开发者这篇文章都值得收藏。1. 先给结论Python 数据类型的真正难点不在记忆如果把“Python 数据类型”当成一道填空题很多人的答案是int、float、str、list、tuple、dict、set、bool也许再加一个 None。这个答案本身没有错但它只能帮你应付面试帮不了你写代码。我见过太多这类场景从 Excel 或接口读到的“数字”在 Python 里是字符串直接做运算变成字符串拼接把一个 list 赋值给另一个变量然后在新变量上append结果原来的列表也被改了函数定义时写了def f(items[])连续调用几次以后默认列表里攒了一堆历史数据用isinstance(x, int)判断用户输入结果 True 也被当成了整数因为bool是int的子类用type(x) int判断时遇到从第三方库返回的numpy.int64又判断失败。这些坑的共同点是它们都不是“记错类型名”导致的而是没有理解 Python 的类型系统是动态的、变量只是对象的引用以及某些类型天生不可变、某些类型天生可变。所以在展开后面的内容前我希望你先记住一个判断学习 Python 数据类型真正的分界线是可变与不可变而不是种类清单。下面所有章节本质上都在围绕这条线展开。2. Python 内置数据类型全景与正确分类在讲可变不可变之前先把全景图铺开。Python 官方文档里对内置类型的分类和很多入门教程里那种“八大类型”的表格并不完全一样。为了贴近实际使用我习惯把内置类型分成以下几类大类常见类型是否可变典型用途数值类型int、float、complex不可变计数、运算、科学计算文本序列str不可变文本处理、格式化输出不可变序列tuple、range不可变函数多返回值、固定长度数据可变序列list可变有序数据集合、栈和队列二进制序列bytes、bytearray、memoryviewbytes 不可变bytearray 可变文件读写、网络传输映射类型dict可变键值查询、JSON 对应结构集合类型set、frozensetset 可变frozenset 不可变去重、集合运算布尔类型bool不可变条件判断、开关状态空类型NoneType不可变空值占位、函数默认返回值这个表格比“八大数据类型”更有价值因为单一存在一个可变性的分组。如果你是从 Java、C 或者 C 转过来的这里会出现第一个认知冲击Java 里有“基本类型”和“引用类型”的明确区分int 存的是值Integer 存的是引用而 Python 更彻底所有对象都是引用变量本身没有类型类型是对象上的属性。看下面这段代码a 10 print(type(a)) # class int a hello print(type(a)) # class str很多入门教程会把上面的行为解释成“变量 a 的类型从 int 变成了 str”这种说法其实不准确。更准确的理解是a只是一个贴在对象上的标签一开始它指向了一个 int 对象后来被重新指向了一个 str 对象。对象本身的类型没有变变量也没有类型变的是引用指向。这个细节在写 Python 时非常重要。正因为变量只是引用你才会遇到“明明复制了列表修改副本却影响了原列表”这样的问题。后面第 6 章会专门展开。另外还应知道Python 虽然是动态类型语言同时也是强类型语言。所谓强类型是指它不会像 JavaScript 那样自动把1 1变成11。在 Python 中1 1会直接抛出TypeError因为字符串和整数不能直接相加。这一点既是好事也是坏事好事是程序错误能尽早暴露坏事是新手在做数据类型转换时经常被TypeError、ValueError困扰。从工程角度看Python 的这套设计目标是让代码更灵活、更接近自然语言表达。但灵活性是有代价的代价就是你必须自己管好数据类型转换和边界判断。后面的章节都在讲怎么补上这个代价。3. 可变与不可变贯穿所有类型的一条暗线理解 Python 数据类型最值得花时间的地方就是可变与不可变。这个概念的底层逻辑其实非常简单一个对象被创建之后它内部的内容能不能被修改。能修改的就是可变对象不能修改的就是不可变对象。不可变对象包括 int、float、str、tuple、frozenset 等。可变对象包括 list、dict、set、bytearray 等。但是只看这个列表远远不够因为代码中很容易出现“看起来变了但实际是创建了新对象”和“看起来没变但内部其实变了”的迷惑行为。我们先做一个实验体验不可变对象的行为s hello print(id(s)) # 第一次打印 id s world print(id(s)) # id 变了说明生成了新的字符串对象id()返回对象在内存中的唯一标识。你执行这段代码时两次打印的id大概率是不同的。这意味着s world并没有在原来的字符串对象上追加内容而是创建了一个全新的字符串对象然后让变量s指向它。字符串属于不可变对象任何看似“修改”的操作背后都是新建对象。再看可变对象 list 的行为nums [1, 2, 3] print(id(nums)) nums.append(4) print(id(nums)) # id 不变 nums [5] print(id(nums)) # id 仍然不变这里append和都没有改变nums指向的对象列表对象本身内部结构发生了变化。两相对比就能看出 Python 数据类型设计的一条暗线不可变对象适合做安全共享可变对象容易产生别名问题但操作起来更高效。有一个特别容易让新手困惑的组合类型元组内嵌套列表。例如t (1, 2, [3, 4]) print(id(t)) t[2].append(5) print(t) # (1, 2, [3, 4, 5]) print(id(t)) # t 本身的 id 没有变理论上 tuple 是不可变对象但它内部的 list 是可变对象。因此你不能执行t[2] [6]因为这改变了元组内部的引用结构但你可以执行t[2].append(5)因为列表对象内部内容发生了变化元组对这个列表的引用没有变。理解这一层后很多数据结构相关的坑就有了明确的答案为什么 dict 的 key 必须是不可变对象因为哈希表依赖 key 的哈希值如果 key 的对象在存进去之后内部内容变了哈希值也会变之后就无法正确查到它。list 不可哈希所以不能当 keytuple 如果只包含不可变元素可以作为 key。为什么 set 里的元素也有哈希要求同理set 底层依赖哈希表。为什么有人建议把函数返回的多值用 tuple 打包因为不可变结构可以在多处安全共用不会被某个模块意外改坏。这里可以给出一个小实验帮助你把可变不可变的直觉建立起来。建议在你自己电脑的交互式终端里跑一遍观察id()变化a [1, 2] b a b.append(3) print(a) # [1, 2, 3]a 变了 c (1, 2) d c # d 和 c 指向同一个元组但谁也无法原地修改它看到a变成[1, 2, 3]的那一刻你对“变量是引用”的理解就会真正落地。这并不是 Python 的 bug而是所有可变对象共享引用的正常结果。要避免这种结果需要浅拷贝或深拷贝我会在第 6 章详细写。4. type() 和 isinstance()为什么你总在类型判断上出错网上关于类型判断最流行的说法是“推荐使用 isinstance不要用 type”。这句话有一定道理但它只讲了结果没有讲原因。实际项目里很多错误恰恰来自盲目使用 isinstance。先看一个最小例子print(type(1) is int) # True print(type(True) is bool) # True print(type(True) is int) # False print(isinstance(True, int)) # True为什么isinstance(True, int)是 True因为 Python 里 bool 继承自 intTrue本质上就是整数1的“语义化封装”。所以用 isinstance 判断一个值是否是 int 时布尔值会被算进去。这在很多业务场景中是有问题的。比如你写了一个函数希望接收整数年龄def check_age(age): if not isinstance(age, int): raise ValueError(age 必须是整数) if age 18: return 未成年 return 成年调用check_age(True)时不会报错因为True被 isinstance 判断成了 int然后与18比较时被当作1最后返回“未成年”。也就是说一个布尔值混进了年龄字段程序却没有拦截住它。如果确实要拦截可以写成def is_strict_int(value): return isinstance(value, int) and not isinstance(value, bool)或者def is_strict_int(value): return type(value) is inttype 和 isinstance 的区别本质上在于是否考虑继承关系。isinstance 会检查对象类型是否是指定类型或其子类的实例type 只做精确匹配。在 Python 中我们需要一个更细分的判断策略场景推荐写法原因判断是不是某种类型或其子类isinstance(x, list)兼容继承和多态判断多个类型之一isinstance(x, (int, float))避免重复判断精确区分 bool 和 inttype(x) is int排除 bool 子类干扰精确区分 float 和 inttype(x) not in (int, float)需要时按业务取舍判断是否可迭代isinstance(x, Iterable)从 collections.abc 导入实际编码中我的建议是大多数业务代码优先用 isinstance。因为它更符合面向对象的多态原则。子类对象在“是父类类型”这个判断上应该返回 True。但涉及 bool/int、float/int 这类容易混淆的数值语义时要额外排除子类干扰。还有一个容易被忽略的事实Python 中的很多数值类型不止是 int、float。做数据分析时你会经常遇到numpy.int64、pandas.Timestamp这类第三方类型。它们在某些环境下和 Python 内置的 int/float 表现相似但type(obj) is int会返回 False。这时候要看你到底想判断“精确类型”还是想判断“行为上是不是整数”。如果是后者更稳妥的方案是不要直接判断硬件类型而是尝试转换后再判断或者使用针对性的库函数。5. 类型转换的本质显式转换、隐式转换和安全边界在 Python 里类型转换分成两类显式转换和隐式转换。显式转换是开发者调用int()、str()、float()、list()等函数主动完成隐式转换是 Python 在运算中“自动完成”的转换。例如整数和浮点数相加时a 1 b 2.5 print(a b) # 3.5 print(type(a b)) # class floatint 和 float 运算时Python 会把 int 提升为 float这个过程你不需要手动干预。但要注意这种隐式提升并不会发生在所有类型之间。字符串和数字相加会直接抛出 TypeError这就是强类型语言的特点。显式转换的常用函数和注意点如下目标类型常用函数典型注意点intint(x)int(3.14)会报错需要先转 floatfloatfloat(x)float(1,000)会报错逗号不识别strstr(x)得到的是给人看的字符串不一定是还原表达式listlist(iterable)对字符串转换会把字符串拆成一个个字符tupletuple(iterable)同上会逐个拆分可迭代对象setset(iterable)要求元素可哈希list 中的子元素不能是 listdictdict(pairs)常见方式是从键值对列表转换如dict([(a, 1)])在这些转换中最高频也最容易踩坑的是“字符串到整数”的转换。比如接口返回的 JSON 里可能有age: 18你直接用int(18)没问题但如果是int(18.5)就会报 ValueError因为int()不认带小数点的字符串。正确的中间步骤是先把字符串转成 float再转成 inttext 18.5 number int(float(text)) print(number) # 18不过这里还有第二个坑int(3.7)的值是 3也就是说 float 转 int 默认是向零截断而不是四舍五入。如果业务需要四舍五入应该使用round(float(text))。实际业务里调用方给的数据经常是不可信的。你从 Excel 读取、从接口请求、从用户输入中拿到的数据可能携带空格、千分位、单位甚至可能是 None。更工程化的写法是封装一个安全转换函数def safe_to_int(value, defaultNone): if isinstance(value, bool): return default if isinstance(value, int): return value try: return int(value) except (TypeError, ValueError): try: return int(float(value)) except (TypeError, ValueError, OverflowError): return default print(safe_to_int(42)) # 42 print(safe_to_int(3.14)) # 3 print(safe_to_int(18.9)) # 18 print(safe_to_int(abc)) # None print(safe_to_int(True)) # None这个函数里我特别处理了 bool布尔值不应被静默地转成整数 1否则年龄、数量这类字段很容易被传成 True 后混过去。如果调用方传入了异常字符串函数会返回 default而不是让整个程序崩溃。除了内置类型之间的转换还要注意容器类型转换的语义。例如list(hello)会生成[h, e, l, l, o]而不是[hello]。如果你想把一个字符串当作独立元素放进列表应该写[hello]而不是list(hello)。同理set(hello)的结果是字母集合而不是包含整个字符串的集合。在很多“JSON 解析”场景里类型转换问题会更加隐蔽。json.loads()会把 JSON 里的数字解析成 int 或 float把1这样的字符串原样解析成 str。当你从外部接口拿到数据时字段类型是否符合预期必须在解析后主动检查而不是默认相信接口文档。6. 引用与拷贝一个列表被复制了为什么修改后原列表也会变正文开头提到的那种情况很多初学者都遇到过a [1, 2, 3] b a b.append(4) print(a) # [1, 2, 3, 4]这里你只是把a赋值给b两个变量现在指向同一个列表对象。列表是可变对象所以对b做append会直接影响a看到的同一个对象。这不算 bug只是 Python 对象模型的自然结果。真正的问题是很多新手误以为b a是“复制列表”。假如你想要独立副本应该用切片、.copy()或者list()a [1, 2, 3] b a.copy() b.append(4) print(a) # [1, 2, 3] print(b) # [1, 2, 3, 4]但.copy()只做了浅拷贝。如果列表里还有嵌套的可变对象浅拷贝只复制外层列表内层元素仍然是共享引用。看这个例子row1 [1, 2] row2 [3, 4] matrix [row1, row2] matrix_copy matrix.copy() matrix_copy[0].append(99) print(matrix) # [[1, 2, 99], [3, 4]]matrix.copy()创建了新的外层列表但matrix_copy[0]和matrix[0]仍然指向同一个 row1 列表对象。修改嵌套列表时原数据同样会变。这时需要深拷贝import copy row1 [1, 2] row2 [3, 4] matrix [row1, row2] matrix_deepcopy copy.deepcopy(matrix) matrix_deepcopy[0].append(99) print(matrix) # [[1, 2], [3, 4]] print(matrix_deepcopy) # [[1, 2, 99], [3, 4]]深拷贝会递归复制内部所有可变对象得到一份完全独立的副本。代价是性能更低而且某些对象不一定能被深拷贝。所以在工程上先考虑浅拷贝或不可变结构必要时才使用 deepcopy。比浅拷贝更隐蔽的另一个经典大坑是用乘号初始化二维列表# 错误示范 matrix [[0] * 3] * 3 matrix[0][0] 1 print(matrix) # [[1, 0, 0], [1, 0, 0], [1, 0, 0]][[0] * 3] * 3看似创建了一个 3 行 3 列的矩阵实际上乘号把同一个行列表复制了三份引用。修改其中一行其他行也会同步变化。正确写法是用列表推导式创建独立行# 正确示范 matrix [[0] * 3 for _ in range(3)] matrix[0][0] 1 print(matrix) # [[1, 0, 0], [0, 0, 0], [0, 0, 0]]这两个例子值得反复记忆因为它们出现的频率实在太高了。另一个与默认参数相关的“隐藏引用”问题也很典型def add_item(item, items[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2]而不是 [2]默认参数items[]只在函数定义时创建一次之后每次调用如果没有传入 items用的都是同一个列表对象。于是多次调用之间会共享历史数据产生非常难查的 bug。正确做法是使用不可变对象作为默认值然后在函数内部创建新对象def add_item(item, itemsNone): if items is None: items [] items.append(item) return items与这个思想一致后面用 dataclass 定义带列表字段的结构时也要用field(default_factorylist)而不是直接写[]。7. 从真实场景看类型选择爬虫字典、dataclass 到 pandas数据类型的选择不是“越高级越好”而是“在合适的场景用合适的结构”。这里我把最常见的几种场景拆开给出类型选择的思路。7.1 简单结构化数据dict 仍然是最常用选择爬虫解析或接口对接时最自然的做法是用 dict 保存一条记录user { uid: 1001, name: 张三, tags: [python, data], }dict 的优势是灵活、序列化成 JSON 方便、不需要额外定义类。缺点是字段拼写错误时无法在 IDE 中快速发现字段很多时书写容易出错。如果只是在一个函数里临时传递数据dict 完全够用。7.2 跨模块共享的复杂结构dataclass 更可靠当同一个结构体在多个模块中频繁使用时我建议用 dataclass 代替普通 dictfrom dataclasses import dataclass, field dataclass class User: uid: int name: str tags: list[str] field(default_factorylist) u1 User(uid1001, name张三, tags[python]) u2 User(name李四, uid1002) print(u1) print(u2.tags) # []使用 dataclass你可以看到字段定义、类型标注和默认值比散开的 dict 更清晰。
返回列表