Python3 基本数据类型这个话题,看着简单,但说实话,我在带新人的时候发现,很多人就是栽在这些“简单”的东西上。比如列表当成参数传进去被改了、浮点数比较莫名其妙出错、字典的键到底什么能用……这些问题追根溯源,都是对Python3基本数据类型理解得不够透彻。这一篇我打算把Python3的几种核心数据类型从头捋一遍,不只讲“它是什么”,更重要的是讲“为什么设计成这样”以及“实际写代码时应该怎么用”,全是实操经验和平时不太会写进文档里的细节,适合刚入门的朋友扎基础,也适合写过一阵子但总感觉哪里模糊的朋友回头查漏补缺。
1. 数据类型总览:先搞懂Python眼中的“一切皆对象”
1.1 变量不是盒子,是标签
很多初学者甚至写了两三年Python的人,对变量和类型的关系都有误解。以为变量是一个容器,往里面放不同的数据,容器就装下了不同东西。但实际上在Python里,变量只是一个贴在对象上的标签,真正的数据对象存在内存里,变量名只是指向它的引用。
a = 10 b = a a = 20 print(b) # 10这个例子看起来简单,但你必须想明白为什么b还是10。因为b = a是把b这个标签也贴到了10这个对象上,之后a = 20仅仅是让a改贴到20上,b依然指着10。明白了这一点,后面学列表、字典那些可变类型时,才能理解“为什么我改了a,b也跟着变了”这种经典问题。
所以,Python3里所有数据类型,本质都是“类”,而每一个具体的值,都是这个类的一个实例。数字、字符串、列表、元组、字典、集合,全都逃不出“对象”这个概念。理解了这点,你再去理解type()、isinstance()、id()这些内置函数,才会真正通透。
1.2 可变与不可变:Python中最关键的底层分水岭
Python3的数据类型,第一大分水岭不是“数字、字符串、容器”这种表面分类,而是能否原地修改:
- 不可变类型(immutable):整数、浮点数、字符串、元组、布尔值、冻结集合。这些对象一经创建,内容就不可修改,任何修改操作实际都是新生成了对象。
- 可变类型(mutable):列表、字典、集合。这些对象创建后,可以往里面增删改元素,内存地址不变。
这个区别对编码的影响极其深远。最典型的一个坑:把列表当作函数默认参数。
def add_item(item, target_list=[]): target_list.append(item) return target_list print(add_item(1)) # [1] print(add_item(2)) # [1, 2] !!!很多新手第一次看到这个结果都会懵。原因就在于默认参数[]是定义函数时就创建好的一个列表对象,每次调用且没有传入新列表时,用的都是同一个对象,往里append当然会一直积累。正确做法是默认参数设为None,函数体内再新建列表。这个经典问题,根源就是“可变类型对象的生命周期”没理解透。
还有个和不可变类型相关的细节,可能会颠覆你的直觉:字符串拼接。
s = "" for i in range(10000): s += str(i)在Python3里,字符串是不可变的,每次+=都会创建一个新字符串对象,然后把两个字符串的内容拷进去。循环一万次,就是一万次创建和拷贝,效率极低。我见过有人处理几万条日志时用这种方式拼字符串,程序跑了几分钟没反应的。正确做法用join():
parts = [] for i in range(10000): parts.append(str(i)) s = "".join(parts)列表的append是原地操作,几乎无开销,最后一次性用join生成最终字符串,这才是省内存省时间的正道。所以说,搞懂可变和不可变,不只是应付面试,是真能帮你写出高性能代码的。
2. 数值类型:数字不只是数字
2.1 整数int在Python3中的“无限长”
Python2时代,整数分int和long,超过范围就得用long,但Python3统一了,只有一个int,而且长度是动态的,理论上可以存储任意大的整数,只受内存限制。这让很多从Java或C++过来的朋友非常不适应——不用再担心溢出问题。但别高兴太早,“不限长度”带来的代价是大整数运算变慢和内存占用增加。当初我做一个关于大数运算的小工具,算两万个十进制位的乘积,Python原生int明显吃力,后来才理解为什么有个领域还有那么多C扩展库在干这些活。
另一个和整数相关的坑,是不同进制的表示和转换。Python3支持二、八、十、十六进制:
a = 0b1010 # 二进制,输出10 b = 0o17 # 八进制,输出15 c = 0xFF # 十六进制,输出255进制转换很容易在对接硬件协议、解析二进制文件时用到。举个实际场景:从串口读取一个字节0x4A,想查看它二进制下的某一位,可以这么做:
data = 0x4A # 查看第3位是否为1(从0开始计) is_set = (data >> 3) & 1 print(bin(data)) # 0b1001010位运算在底层开发里非常常用,&、|、^、<<、>>都要熟悉。搞网络协议解析、文件格式解析、权限位判断,全靠这套东西。新手觉得Python只能写写业务脚本,其实它做底层工具照样顺手,前提是这些基础得扎实。
2.2 浮点数的精度陷阱:为什么0.1加0.2不等于0.3
每个接触Python的人,迟早都会撞上这个诡异的输出:
print(0.1 + 0.2) # 0.30000000000000004这不是bug,而是IEEE 754浮点数标准的固有限制。计算机用二进制表示小数,但二进制无法精确表示0.1这种十进制小数,所以所有用二进制存储浮点数的语言里0.1都是近似值。Python3的float默认是双精度64位浮点数,精度约为15~17位有效数字,足够日常使用,但累积误差在金融计算、科学计算中可能会造成问题。
实际开发中,我处理金额时一律不用float,用decimal模块:
from decimal import Decimal price = Decimal("19.99") quantity = Decimal("3") total = price * quantity print(total) # 59.97,精确注意Decimal的参数必须是字符串,不能直接传浮点数——Decimal(0.1)会得到一个非常精确地表示“0.1这个浮点数实际值”的对象,反而是另一个坑。同理,Fraction模块用于分数精确运算:
from fractions import Fraction a = Fraction(1, 3) b = Fraction(2, 5) print(a + b) # 11/15浮点数的另一个典型怪癖:比较时不能直接用==。正确姿势是比较差值是否在允许误差内:
a = 0.1 + 0.2 b = 0.3 print(abs(a - b) < 1e-9) # TruePython3提供了math.isclose()更规范:
import math print(math.isclose(a, b, rel_tol=1e-9))2.3 布尔型是整型的“亲儿子”
布尔值True和False,在Python3里其实是int的子类:
# isinstance(True, int) 返回 True print(True + 1) # 2这意味着很多从其他语言过来的朋友会把1 + True当成错误,但Python里它是合法的。同时,这也带来了一个经典陷阱:
sum([True, False, True, True]) # 3这种写法在一些人眼里很“巧妙”——用布尔值求和来判断有多少条件成立,省去手写统计。但我不建议这么用,尤其是团队协作时,代码的可读性比“炫技”重要得多。还有,判断时新手常写if x == True,但Python崇尚if x这种隐式布尔判断,配合“真值测试”规则(数字0、空字符串、空列表、空字典、空集合、None都视为假,其余为真),简洁高效。
3. 字符串:使用频率最高、暗坑最多的数据类型
3.1 不可变字符串和常用操作的背后逻辑
字符串str是Python中最常用的不可变类型。它不可变,意味着任何“修改”操作,本质都是生成一个新的字符串对象。这带来一个很重要的编码体验:你不需要担心字符串被某个函数意外改动。但同时也要警惕,频繁拼接、裁剪大字符串时性能会下降。
字符串常用操作里,我每带新人必强调几个点:
- 切片是左闭右开区间。
s[0:3]取前三个字符而不是0到3号字符,很多人就是从切片开始懵的。 - 负索引非常好用。
s[-1]取最后一个字符,s[-3:]取最后三个字符,比我以前用s[len(s)-3:]清爽太多。 split和join是文本处理的黄金搭档。解析配置文件、处理csv行、拼接路径,全靠它们。strip()不带参数时去掉首尾空白字符,包括空格、\t、\n;但注意它不会去掉字符串中间的空格。
实测一个场景:处理用户输入的电话号码,用户可能输入“ 138-1234-5678 ”,要规范化:
phone = " 138-1234-5678 " phone = phone.strip().replace("-", "") print(phone) # 138123456783.2 三种格式化方式怎么选
Python3提供了至少三种字符串格式化方式:百分号格式化、format()、f-string。老项目里还经常看到%风格:
name = "小明" age = 25 print("姓名:%s,年龄:%d" % (name, age))format()是大而全的方案:
print("姓名:{},年龄:{}".format(name, age)) print("{name}今年{age}岁".format(name=name, age=age)) print("{:.2f}".format(3.14159)) # 保留两位小数但我个人的真实体会是,只要是Python3.6以上的项目,默认无脑选f-string。它最直观、最不容易出错:
print(f"姓名:{name},年龄:{age}") print(f"圆周率保留两位:{3.14159:.2f}")f-string还能直接嵌入表达式,调试代码时极好用:
import math print(f"半径={r}, 面积={math.pi * r ** 2:.4f}")唯一要注意的是,f-string内部的大括号不能随便嵌套,而且如果你的字符串内容本身包含很多{}(比如模板代码),会让可读性变差。那种场景下建议还是用format()。
3.3 编码问题是中文开发者的必修课
字符串最隐蔽的坑,永远是编码。Python3的str类型内部使用Unicode,这已经比Python2时代简单太多——Python2里处理中文经常需要手动decode、encode,折磨了无数人。Python3里字符串和字节串是两种完全不同类型:
str:Unicode字符序列,给人读的。bytes:原始字节序列,给文件、网络、二进制协议用的。
从外部数据到str,必须先解码:
raw = b'\xe4\xb8\xad\xe6\x96\x87' text = raw.decode("utf-8") print(text) # "中文"反过来,把字符串交给外部系统,必须先编码:
data = text.encode("utf-8")我检查文件读写bug时,几乎一半以上的怪问题都出在编码上。比如从某Windows平台传来的txt文件,里面是“GBK”编码,直接用open(path, encoding="utf-8")读就报错,需要指定:
with open(path, "r", encoding="gbk", errors="ignore") as f: content = f.read()这里errors="ignore"的意思是遇到无法解码的字节就跳过,虽然会丢失信息但至少程序不会崩溃。如果是关键数据,建议用errors="replace",不可解码字符统一替换为?,至少能定位问题位置。编码知识确实琐碎,但每个做数据处理的人都会无数次面对它,早点吃透少走很多弯路。
4. 容器类型:列表、元组、集合、字典怎么选怎么用
4.1 列表:灵活但要知道代价
列表list是Python里最常用的可变容器,底层是动态数组,支持随机访问O(1),末尾追加元素均摊也是O(1)。但它有两个操作代价不小:在头部或中间插入、删除元素时,需要移动后续所有元素,复杂度为O(n)。所以如果你频繁要在序列头部操作,collections.deque更合适。
列表的“浅拷贝”是个高频翻车点。很多人想复制一份列表然后改副本,直接赋值:
a = [1, 2, 3] b = a b.append(4) print(a) # [1, 2, 3, 4] !!原因前面说过了:b和a贴的是同一个列表对象。解决方法是:
b = a.copy() # 浅拷贝 b = a[:] # 切片也生成新列表 b = list(a) # 工厂函数同样可行但请注意这几个都只是“浅拷贝”。如果列表里的元素本身是可变对象(比如嵌套列表),拷贝后的新列表里仍然引用着同一批内部对象:
a = [[1, 2], [3, 4]] b = a.copy() b[0].append(99) print(a) # [[1, 2, 99], [3, 4]] !!想要连内部对象一起复制,得用copy模块的deepcopy:
import copy a = [[1, 2], [3, 4]] b = copy.deepcopy(a) b[0].append(99) print(a) # [[1, 2], [3, 4]]我几乎在每个项目里都会用到deepcopy处理配置数据,所以这个知识点虽然基础,但含金量不低。
列表生成式也是Python的一大特色,简洁得让人上瘾:
squares = [x * x for x in range(10) if x % 2 == 0] # [0, 4, 16, 36, 64]但用列表生成式时注意别写太复杂,嵌套三层以上循环再加两个条件,代码基本没法读了,那种时候老老实实用普通循环加上注释更靠谱。
4.2 元组:不可变带来的安全性和性能
元组tuple和列表长得几乎一样,区别在于创建后不能修改。正因如此,它天生适合表达“固定结构”的数据,也天然是“哈希可用的”——元组可以作为字典的键,而列表不行。
元组最容易被忽略的语法细节:单个元素的元组必须加逗号:
t = (1) # 这是一个整数,不是元组! t = (1,) # 这才是元组很多人在写代码时被这个坑过。还有,元组虽然不可变,但如果元组里面放了可变对象(比如列表),那个列表内部还是可以改的:
t = (1, [2, 3]) t[1].append(4) print(t) # (1, [2, 3, 4])这种现象叫“元组的浅层不可变性”。搞清楚这个概念,能让你在设计数据结构时少犯错误。
解包(unpacking)是元组的经典用法,配合函数的多元返回非常丝滑:
def get_min_max(data): return min(data), max(data) lo, hi = get_min_max([3, 1, 4, 1, 5]) print(lo, hi) # 1 5还可以用星号做扩展解包:
first, *middle, last = [1, 2, 3, 4, 5] print(first, middle, last) # 1 [2, 3, 4] 54.3 集合与字典:哈希表的两副面孔
集合set和字典dict底层都是哈希表,所以它们才有那么变态的查找效率——平均O(1)。代价是:键必须是可哈希的。整数、浮点数、字符串、元组都是可哈希的;列表、字典、集合本身不可哈希,所以不能作为字典的键或放进集合中。
集合最实在的用途是去重和集合运算:
seen = set([1, 2, 2, 3, 3, 3]) print(seen) # {1, 2, 3} a = {1, 2, 3} b = {2, 3, 4} print(a & b) # 交集 {2, 3} print(a | b) # 并集 {1, 2, 3, 4} print(a - b) # 差集 {1} print(a ^ b) # 对称差 {1, 4}实际开发中,我经常用集合快速找出两个数据源之间的差异。比如对比两个文件里的ID列表,一行代码就找出“只出现在文件A里的”。
字典在Python3.7以后保持插入顺序,这是一个很大的行为变化。在更早版本中字典是无序的,导致很多人被迫用OrderedDict来保证顺序。现在的dict本身就保序,开发体验舒服很多。
字典的常用操作里有个经典坑:用dict[key]取值时,如果键不存在会直接抛KeyError。稳妥的做法有三个:
# 方式一:get带默认值 value = data.get("name", "unknown") # 方式二:先判断再取 if "name" in data: value = data["name"] # 方式三:setdefault配合默认结构很实用 data.setdefault("tags", []).append("python")第三个setdefault很多人第一次看到会觉得很怪,但它在“初始化嵌套字典/列表”时非常好用。比如按类别分组统计:
stats = {} for item in items: stats.setdefault(item.category, []).append(item.value)另外,从Python3.9起,字典支持|和|=运算符做合并,比update更简洁:
a = {"x": 1} b = {"y": 2} c = a | b # {'x': 1, 'y': 2}4.4 四个容器到底怎么选?
碰见一个需求,到底用列表、元组、集合还是字典?我一般按这个顺序问自己:
- 需要按键取值吗?需要关联映射关系?用字典。
- 只关心“是否存在”,不关心顺序和重复?用集合。
- 需要保持顺序、允许重复、按索引访问?用列表。
- 创建后不应该被修改,或者要当字典键?用元组。
这个决策路径听起来简单,但团队里很多人写代码其实没想清楚,顺手就上了列表,结果后续查“是否存在”用了in遍历,数据一大就卡。实际上几万条数据时,in list是线性扫描,in set是哈希查找,速度差距是数量级的。我测过一个真实的案例:10万元素里查找100个目标的代码,列表写法耗时是集合写法的几百倍。选对容器,代码性能直接起飞。
5. 类型转换与判断:动态类型是把双刃剑
5.1 鸭子类型与隐式转换
Python是强类型语言,但也是动态类型语言——变量可以随时指向任何类型的对象。这带来了灵活度,也带来了隐患。
“鸭子类型”是Python的核心哲学之一:如果对象叫起来像鸭子、走起来像鸭子,那它就可以被当作鸭子用。写函数时不必强制要求参数是某某类型,只要它支持你要的操作就行。这种设计写起来爽,但调用方传错类型时,报错往往发生在很晚的时候——运行到那一步才炸。
经典现象是字符串和数字的+问题:
a = "123" b = 1 print(a + b) # TypeError: can only concatenate str not "int" to strPython不会帮你把字符串自动转成数字再相加,它直接报错。这就是“强类型”的含义。作为开发者,你要明确做转换:
print(int(a) + b) # 1245.2 显式转换的常用方法
内置类型转换函数是基础中的基础,但我发现很多人并不能熟练使用它们:
int(x):转整数。int("42")得到42,int(3.99)得到3(不是四舍五入,是截断)。float(x):转浮点数。float("3.14")得到3.14。str(x):转字符串,几乎所有类型都能转。list(x)、tuple(x)、set(x):可迭代对象间互转。dict():构造字典,传入键值对序列或关键字参数。
注意int()在转换字符串时,如果字符串不是合法数字格式,会抛ValueError。从不可靠来源(比如用户输入、配置文件)取值时,建议包一层try/except,或者用str.isdigit()先判断。不过isdigit()也有个坑:它只判断纯数字,不处理负号和小数点,所以"-123".isdigit()是False。要判断一个字符串能否安全转成浮点数,我往往直接try/except:
def safe_to_float(text): try: float(text) return True except ValueError: return False5.3 类型判断:isinstance比type更靠谱
新手判断类型常常用type(x) == list,这种写法有两个问题:一是无法处理继承关系,二是代码风格不够Pythonic。更推荐isinstance(x, list):
print(isinstance([], list)) # True print(isinstance((1, 2), tuple)) # Trueisinstance还能一次判断多个类型:
from collections.abc import Iterable print(isinstance([1, 2], (list, tuple, set))) # True print(isinstance("abc", Iterable)) # True别忘了,在Python中一切皆对象,所以type(x)本身返回的也是一个类对象,你甚至可以拿它做工厂函数。理解这些,对“动态类型”的把握能更上一层楼。
6. 常见问题与排查技巧:把踩过的坑都记下来
6.1 可变默认参数:经典中的经典
前面提到了列表默认参数的坑,这里再扩展一下。除了列表,字典作为默认参数同样会出问题:
def log_message(msg, cache={}): cache[msg] = True这段代码看似没问题,但cache是所有调用共享的同一个字典,长期运行会无限膨胀还互相污染。这是生产环境中真实出现过的bug,排查时非常隐蔽——因为很难想到是默认参数惹的祸。应对规则只有一条:默认参数只放不可变类型,需要可变容器时在函数体内新建。
6.2 “改一个全变”的变量赋值陷阱
这是我和我带的同事讨论最多的问题。一个典型场景:读取配置文件里的某个列表,想基于它生成另一个稍加修改的列表:
original = ["a", "b", "c"] modified = original modified.append("d") print(original) # ['a', 'b', 'c', 'd'] 没有想要的原样保留如果业务代码里存在这种共享引用,排查起来会花掉大量时间,尤其是数据经过几层函数传递后,你根本不知道哪一步把原始数据改了。排查技巧:找几个关键节点分别打印id(对象)和内容,追踪到底谁和谁是同一个对象。另外,写函数参数时,如果不想让外部对象被修改,可以在函数内部第一时间做拷贝:
def process(items): items = list(items) # 转换为新列表,防止影响外部 items.sort() return items6.3 浮点数比较、大数显示和类型混乱的最后一课
浮点比较的问题再强调一次——不要用==去比较两个经过计算后的浮点数。哪怕是看起来一模一样的两组计算,也可能因为舍入顺序不同产生微小差异。
还有一个实际场景容易踩雷:大整数在显示时不会变成科学计数法,但很大的浮点数会。比如1e20打印出来是1e+20,而不是完整数字。如果你需要完整的十进制表示,可以用format(x, "f")。而极小的浮点数可能会显示成1e-10,需要完整显示时同样处理。
最后聊聊type和isinstance的混用:如果要判断“是不是某个类的实例或子类的实例”,isinstance永远是首选。但如果你真的要精确匹配某个类型且不考虑继承,type(x) is int也可以接受。从我个人的经验来看,绝大多数场景isinstance更恰当。
还有一个经常让别人困惑的点:None怎么判断。用is None而不是== None。因为None是单例对象,is比较的是身份,速度和语义都更正确。
6.4 实战排查:一段代码看穿类型问题
结合一次实际问题的排查,展示一下类型相关bug的完整定位思路。假设有一个小函数想要处理一批数字,但偶尔报错:
def average(values): return sum(values) / len(values) data = ["1", "2", "3"] print(average(data)) # TypeError: unsupported operand type(s) for +: 'int' and 'str'报错信息很直白:sum在内部做加法时把初始值0和字符串“1”相加,类型不兼容。解决方式:
data = [float(x) for x in data]但更深一层的问题是:数据来源为什么混入了字符串?是接口返回的JSON字段全是字符串,还是读取文件时没做类型转换?我建议在数据进入业务逻辑的第一道关口就做好类型转换和校验,即“净化输入”。工程上这叫fail early——问题越早暴露,定位越容易。
再看一个经典类型混乱场景:字典中键缺失却试图按类型访问。
data = {"name": "张三", "age": "25"} # 接口返回全是字符串 age = data["age"] + 1 # 直接报错这种问题几乎每天都有人在问。排查思路:先打印type(data["age"]),发现是字符串,然后再决定是改接口还是改解析逻辑。很多人习惯拿起来就修,改一行试试,不行再改回来,效率很低。我习惯的做法是在报错点前加一个断点或打印,把变量的类型和值都打出来看看,只要这一步做了,90%的类型问题都是秒懂。
7. 写在最后的一些实在话
写到这里,其实能聊的细节还有很多,比如namedtuple提升元组可读性、defaultdict自动初始化、typing模块做类型注解等。但从我的实际经历看,基础类型理解得越透彻,后面学这些进阶工具越轻松。Python3的数据类型之所以设计成这样,背后基本是两条主线:一条是“一切皆对象”的哲学,另一条是“可变与不可变”带来的行为差异。把这两条主线的逻辑理清楚,你写的代码会更稳、更高效、更不容易被各种“灵异现象”卡住。
我印象最深的一次经历,是帮另一位开发者排查一个线上脚本的还原问题。他一直没搞懂为什么某个全局的列表在函数运行后被改了。当我指出他和函数内部的数据其实是同一个对象时,他是恍然大悟的表情。那之后他写代码就特别留心“引用”和“拷贝”的区别了。这也说明了Pythonese入门最基础的知识点,恰恰是生产环境中最常见的bug源头。
最后给个踏实的建议:多花点时间在交互式环境里做实验,type()、id()、dir()这几个内置工具用起来。遇到不确定的数据,当场打印出来看看到底是什么,比翻多少文档都直观。数据类型这关过了,后面的并发、网络、数据分析,才能稳扎稳打地推进。