数据类型
Python 的数据类型主要分为 不可变类型(数值、字符串、元组)和 可变类型(列表、字典、集合)。
- 不可变类型 对象的内容一旦创建就无法修改,任何修改操作都会在内存中创建全新对象,并改变变量的引用地址
- 可变类型 对象在创建后可以原地修改其内容,且内存地址(id)保持不变
数据类型统计
| 机制 / 特征 | 不可变类型 (Immutable) | 可变类型 (Mutable) |
|---|---|---|
| 内存特征 | 修改值会开辟新内存,id 改变 | 原地修改值,id 不变 |
| 常见类型 | int, float, str, tuple, bool | list, dict, set |
| 字典的键 | 可以作为 dict 的键或 set 的元素 | 不能作为键(会报 TypeError) |
| 数据安全 | 线程安全,传递时不怕被意外修改 | 多个变量引用同一对象时,修改会相互影响 |
示例1:
num1 = 100
print("num1未改变值前id:", id(num1))
num1 = 101
print("num1改变值后id:", id(num1))
num2 = 100.123
print("num2未改变值前id:", id(num2))
num2 = 200.123
print("num2改变值后id:", id(num2))
str1 = "hello"
print("str1未改变值前id:", id(str1))
str1 = "nihao"
print("str1改变值后id:", id(str1))
names = ("laowang", "zhangsan")
print("names未改变值前id:", id(names))
names = ("lisi", "zhangsan")
print("names改变值后id:", id(names))
flag = False
print("flag未改变值前id:", id(flag))
flag = True
print("flag改变值后id:", id(flag))
运行结果
num1未改变值前id: 4367530008
num1改变值后id: 4367530040
num2未改变值前id: 4369231056
num2改变值后id: 4369230384
str1未改变值前id: 4371145584
str1改变值后id: 4371145872
names未改变值前id: 4370213888
names改变值后id: 4370299712
flag未改变值前id: 4367524008
flag改变值后id: 4367524040
结果显示: int, float, str, tuple, bool 类型变量在修改值后都改变了变量的引用地址
注意:这里我们用”改变了变量的引用地址”,而不是开辟新内存,创建新对象
首先,我们理清为何这几种类型不可改变。 为了完美服务于哈希(Hash)机制,从而在整个 Python 世界里实现“快速搜索”和“极致的运行效率”。
(1)为了能被哈希,从而成为“快速搜索”的工具 Python 的字典(dict)和集合(set)拥有瞬间定位的“快速搜索”能力。但这种能力有一个绝对的前提:作为搜索凭证的“键(Key)”或“元素”,其内容绝对不能变。
- 为什么数值和字符串最需要不可变? 在实际写代码时,我们最常拿来当字典 Key 的是什么?是字符串(比如 {“name”: “Jack”})和数字(比如 {101: “商品A”})。
- 如果它们可变会怎样?如果 str 是可变的,你用 str1 = “name” 作为 Key 存了数据。过了一会儿你把 str1 原地改成了 “age”。由于内容变了,它的哈希值也变了。当你想用 “name” 去查找时,底层哈希表会告诉你:“找不到!”。整个 Python 最核心的字典和集合功能将直接瘫痪。
- 结论:为了让 str, int, float, tuple, bool 能够稳稳当当地作为字典的键和集合的元素,享受 O(1) 的快速搜索,它们在出生时就被剥夺了“原地修改”的能力。
(2)因为不可变,才能进行“内存驻留与复用”(省内存) 由于这些基础类型在程序中被使用的频率极高(一个项目里可能出现几万次数字 1 或字符串 “id”),如果每次出现都开辟新内存,内存很快就会被吃光。
- 复用的安全基础:因为它们不可变,Python 才可以放心地让几百个不同的变量同时指向同一个小整数 1 或同一个字符串 “hello”(即对象池和驻留池)。
- 保护机制:如果 int 是可变的,a = 1 且 b = 1 指向同一个地方。你执行 a += 1 试图把内存里的 1 改成 2。如果它是原地的,那么变量 b 也会在不知情的情况下变成 2!整个程序的逻辑就彻底崩塌了。结论:不可变性保护了共享内存的安全性。
那么接下来我们再来看 int, float, str, tuple, bool 这几种类型再重新赋值时到底发生了什么?我们拓展下示例1:
num1 = 100
print("num1未改变值前id:", id(num1))
num1 = 101
print("num1改变值后id:", id(num1))
num11 = 100
num12 = 101
print("num11的id:", id(num11))
print("num12的id:", id(num12))
num2 = 100.123
print("num2未改变值前id:", id(num2))
num2 = 200.123
print("num2改变值后id:", id(num2))
num21 = 100.123
num22 = 200.123
print("num21的id:", id(num21))
print("num22的id:", id(num22))
str1 = "hello"
print("str1未改变值前id:", id(str1))
str1 = "nihao"
print("str1改变值后id:", id(str1))
str11 = "hello"
str12 = "nihao"
print("str11的id:", id(str11))
print("str12的id:", id(str12))
names = ("laowang", "zhangsan")
print("names未改变值前id:", id(names))
names = ("lisi", "zhangsan")
print("names改变值后id:", id(names))
names1 = ("laowang", "zhangsan")
names2 = ("lisi", "zhangsan")
print("names1的id:", id(names1))
print("names2的id:", id(names2))
flag = False
print("flag未改变值前id:", id(flag))
flag = True
print("flag改变值后id:", id(flag))
flag1 = False
flag2 = True
print("flag1的id:", id(flag1))
print("flag2的id:", id(flag2))
num1未改变值前id: 4378310680
num1改变值后id: 4378310712
num11的id: 4378310680
num12的id: 4378310712
num2未改变值前id: 4380011728
num2改变值后id: 4380011056
num21的id: 4380011728
num22的id: 4380011056
str1未改变值前id: 4381927600
str1改变值后id: 4381927888
str11的id: 4381927600
str12的id: 4381927888
names未改变值前id: 4380994560
names改变值后id: 4381080384
names1的id: 4380994560
names2的id: 4381080384
flag未改变值前id: 4378304680
flag改变值后id: 4378304712
flag1的id: 4378304680
flag2的id: 4378304712
结果表明: (1)num1与num11值相同,id也相同 (2)num1修改后的值与num12值相同,id也相同 (3)num2与num21值相同,id也相同 (4)num2修改后的值与num22值相同,id也相同 (5)str1的值与str11值相同,id也相同 (6)str1修改后的值与str12值相同,id也相同 (7)names的值与names1值相同,id也相同 (8)names修改后的值与names2值相同,id也相同 (9)flag的值与flag1值相同,id也相同 (10)flag修改后的值与flag2值相同,id也相同
似乎将不可变量值赋给一个新变量时,如果前面出现过赋给某个变量,那么新变量的id与前面变量id相同。这其实就是不可变类型的对象复用(Object Reuse)或驻留机制(Interning)。
当你在代码中写下赋值语句时,Python 确实会先去“内存池(缓存)”里肉眼搜索有没有现成的值。如果有,直接把旧地址贴给新变量:
- 小整数池:当你声明 x = 10 时,Python 不会创建新对象。它直接去小整数池里找到 10 的地址,赋给 x。
- 字符串驻留池(String Interning):当你声明 s = “hello” 时,Python 会去字符串驻留池里找。如果别的变量已经创建过 “hello”,新变量就会直接共享这个老地址。
- 布尔值:当你声明 flag = True 时,由于全局只有唯一的一个 True 对象,Python 也是直接把这个固定地址赋给变量。
不可变类型的完整赋值逻辑,实际上是两步走的:
- 先找缓存:先去对应的内存池/缓存中查找。如果存在相同的值,直接复用老地址。
- 找不到则新建:如果内存池里没有(比如你声明了一个大整数 x = 999999,或者带空格的复杂字符串 s = “hello world!”),Python 就会在内存中现场开辟一块全新的空间,把值放进去,然后将这个新地址赋给变量。
这就相当于将一些常用,项目可能会反复使用到的小内存数据固定到内存中,避免避免反复去开启空间创建数据然后回收内存。
可变类型
在 Python 中,可变类型(Mutable types)是指对象在创建之后,可以原地(In-place)修改其内部内容,而其在内存中的地址(id)保持不变的数据类型。Python 常见的可变类型只有三种:list(列表)、dict(字典) 和 set(集合)。
为什么需要可变类型? 如果整个 Python 世界只有不可变类型(如数字、字符串),每当我们想往容器里多加一个元素,系统就必须把原先所有的元素复制一遍,开辟一块全新的内存来存放新容器。 当数据量达到几万、几百万时,这种“每次修改都复制全家”的逻辑会导致内存瞬间暴涨,计算速度卡死。
因此,Python 引入可变类型,核心是为了解决以下三个关键问题:
- 1.动态管理海量数据(避免频繁拷贝)可变类型在底层拥有动态扩容的机制(如列表会预留多余的空间)。当你执行 my_list.append(item) 时,Python 只是在原有的连续内存块末尾增加了一个指针,不需要重新复制整个列表。这使得频繁的增、删、改操作效率极高。
- 2.原地修改与内存节约(高效利用空间)假设你正在写一个文字处理器,用户每打一个字,程序就要更新一次文本列表。
- 如果是不可变类型:哪怕只改一个字,也要复制整本书的文本,老文本还会变成垃圾等待回收(带来严重的 GC 回收压力)。
- 如果是可变类型:直接在对应的内存坑位里把旧字符擦掉,换成新字符,物理内存地址完全不变。
- 3.映射与关系运算的需要 dict(字典)负责维护键值对,set(集合)负责去重和交并集运算。它们通常被用来作为“数据中转站”。如果这些中转站是不可变的,那么每次接收新数据(如网站新增一个注册用户、商品价格实时变动)都要重构整个中转站,这在工程上是不可接受的。
list的底层原理
list 的底层结构本质上是一个动态的“指针数组”(Pointer Array)。
- 连续的内存,存的只是“地址” list 在物理内存中占用的是一块连续的内存空间。但是,这块空间里存放的并不是具体的数据(如整数 10、字符串 “hello”),而是存放着这些对象在内存中的引用地址(指针)。
list内存示意图:
nums = ["hello", 10, [1, 2]]
============================== 物理内存连续区 ==============================
【 列表底层的指针数组 (Pointer Array) 】-> 连续开辟的格子,大小固定
物理内存地址: 0x10040 0x10048 0x10050 (每次严格递增 8 字节)
┌──────────┬──────────┬──────────┐
抽屉 (索引): │ nums[0] │ nums[1] │ nums[2] │
├──────────┼──────────┼──────────┤
里面存的内容: │ 0x30010 │ 0x50020 │ 0x70090 │ -> 雷打不动全是 8 字节的「指针」
└────┬─────┴────┬─────┴────┬─────┘
│ │ │
======================│==========│==========│=============================
│ │ │ (顺着地址指针,解引用跳转)
▼ ▼ ▼
================ 散落在内存各处的具体对象 ================
【 0x30010 】➔ 字符串对象 "hello" (穿戴豪华外壳,体积较大)
【 0x50020 】➔ 整数对象 10 (24字节外壳 + 4字节核心/32位)
【 0x70090 】➔ 另一个列表对象 [1, 2]
扩容机制(Resize) 和 预分配(Over-allocation) 在计算机底层,一块连续的内存一旦申请好,它的长度就是死、固定的。如果你申请了一个能放 3 个指针的列表,当你想 append 第 4 个数字时,如果它屁股后面恰好有别的数据挡着,它就没办法直接在原地变长。 我们可以通过 sys.getsizeof() 返回的列表总内存大小,配合 64 位系统下列表的底层结构体公式,把这个“隐藏的预分配格子数量”给算出来。 在 64 位操作系统(Python 3.12 及以上版本)中:
- 一个完全空的列表,其基础元数据外壳固定占用 56 字节。 print(sys.getsizeof([])),输出56
- 往后列表只要每多预分配一个格子,内存就会雷打不动地增加一个指针的大小——即 8 字节。 预分配格子总数=(sys.getsizeof(nums)-56)/8
示例:
import sys
# 创建一个空列表
nums = []
print("-" * 80)
print(f"{'实际元素个数':^12} | {'列表总内存 (字节)':^14} | {'底层预分配格子总数 (Capacity)':^20} | {'nums地址':^5}")
print("-" * 80)
for i in range(15):
current_length = len(nums)
total_bytes = sys.getsizeof(nums)
# 运用上面的公式,逆算出底层当前总共开辟了多少个连续格子
allocated_slots = (total_bytes - 56) // 8 if total_bytes > 56 else 0
print(f"{current_length:^14} | {total_bytes:^16} | {allocated_slots:^31} | {id(nums):^10}")
# 往列表里追加数据
nums.append(i)
print("-" * 80)
运行结果
--------------------------------------------------------------------------------
实际元素个数 | 列表总内存 (字节) | 底层预分配格子总数 (Capacity) | nums地址
--------------------------------------------------------------------------------
0 | 56 | 0 | 4340009280
1 | 88 | 4 | 4340009280
2 | 88 | 4 | 4340009280
3 | 88 | 4 | 4340009280
4 | 88 | 4 | 4340009280
5 | 120 | 8 | 4340009280
6 | 120 | 8 | 4340009280
7 | 120 | 8 | 4340009280
8 | 120 | 8 | 4340009280
9 | 184 | 16 | 4340009280
10 | 184 | 16 | 4340009280
11 | 184 | 16 | 4340009280
12 | 184 | 16 | 4340009280
13 | 184 | 16 | 4340009280
14 | 184 | 16 | 4340009280
--------------------------------------------------------------------------------
分析:
- 元素个数为 0 时:格子也是 0,此时 Python 还没开始为您多花内存。
- 元素个数变成 1 的瞬间:Python 底层的扩容算法(listobject.c)瞬间被激活!它预测到您后续可能还会继续加数据,于是慷慨地在内存里一口气连续开辟了 4 个格子(8字节 × 4 = 32字节)。
- 元素个数为 2, 3, 4 时:因为这 4 个格子的空间早就开好了,所以这几次 append 完全不涉及内存申请,更不需要搬家。Python 只是纯粹地把数字对象的地址指针往现成的空位里一塞,这就保证了常数级 O(1) 的极速性能。
- 元素个数变成 5 的瞬间:前面的 4 个抽屉全被装满了。Python 别无选择,在幕后触发了“搬家机制”——重新在内存里找了一块能放下 8 个格子 的连续大空间,把前 4 个指针复制过去,然后继续塞入第 5 个指针。
上面结果显示:nums地址一致不变,那么新指向格子的地址存放在哪里呢? 核心底层原理:
- 在 64 位系统 CPython 结构体中,列表大楼前 24 字节存放的是基础元数据(引用计数、类型指针、元素数量)。
- 从第 24 字节(内存序号,其实是第25字节,包括25字节)开始,存放的就是大楼的核心参数:ob_item(即底层连续格子的首地址)。 我们用 ctypes 从大楼地址往后偏移 24 字节的位置,把这个 8 字节的指针值读出来:
import sys
import ctypes
# 定义一个获取列表中“连续格子真实物理地址(ob_item)”的函数
def get_ob_item_address(list_obj):
list_id = id(list_obj)
ob_item_ptr = ctypes.c_void_p.from_address(list_id + 24).value
return ob_item_ptr
# 创建一个空列表
nums = []
print("-" * 110)
print(f"{'实际元素个数':^12} | {'列表总内存 (字节)':^14} | {'底层预分配格子总数 (Capacity)':^20} | {'nums地址':^5} | {'新格子的物理存放地址 (ob_item)':^22}")
print("-" * 110)
for i in range(15):
current_length = len(nums)
total_bytes = sys.getsizeof(nums)
grid_addr = get_ob_item_address(nums)
# 运用上面的公式,逆算出底层当前总共开辟了多少个连续格子
allocated_slots = (total_bytes - 56) // 8 if total_bytes > 56 else 0
print(f"{current_length:^14} | {total_bytes:^16} | {allocated_slots:^31} | {id(nums):^10} | {hex(grid_addr if grid_addr else 0):<22}")
# 往列表里追加数据
nums.append(i)
print("-" * 110)
运行结果
--------------------------------------------------------------------------------------------------------------
实际元素个数 | 列表总内存 (字节) | 底层预分配格子总数 (Capacity) | nums地址 | 新格子的物理存放地址 (ob_item)
--------------------------------------------------------------------------------------------------------------
0 | 56 | 0 | 4341633088 | 0x0
1 | 88 | 4 | 4341633088 | 0x102bef730
2 | 88 | 4 | 4341633088 | 0x102bef730
3 | 88 | 4 | 4341633088 | 0x102bef730
4 | 88 | 4 | 4341633088 | 0x102bef730
5 | 120 | 8 | 4341633088 | 0x102ddbef0
6 | 120 | 8 | 4341633088 | 0x102ddbef0
7 | 120 | 8 | 4341633088 | 0x102ddbef0
8 | 120 | 8 | 4341633088 | 0x102ddbef0
9 | 184 | 16 | 4341633088 | 0x102df9230
10 | 184 | 16 | 4341633088 | 0x102df9230
11 | 184 | 16 | 4341633088 | 0x102df9230
12 | 184 | 16 | 4341633088 | 0x102df9230
13 | 184 | 16 | 4341633088 | 0x102df9230
14 | 184 | 16 | 4341633088 | 0x102df9230
--------------------------------------------------------------------------------------------------------------
结果表明新格子的物理存放地址只有预分配格子总数满了时才会发生变化。 所以:
- nums变量地址不会变化
- 存放数据的内存地址是否会发生变化取决于有预分配格子总数是否满了。
- 预分配格子总数遵循翻倍原则(本环境64位,python3.12版本,有些版本可能是1.25倍,1.5倍不等)
那么有个问题新分配的内存是否连续,存的是否是真实数据呢? 其实这个分配的内存地址才是我们所说的list列表的内存地址,所以是连续的存放真实数据内存地址的指针数组。
========================================================================================================================
Python list 底层内存全景图
========================================================================================================================
【 变量指针: nums 】
│
▼ (指向一个在内存中固定不动、体积永远是 56 字节的结构体大楼)
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 🏢 列表管理大楼 (PyListObject 结构体,总长固定为 56 字节) │
├───────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ id(nums) + 00字节 │ [占 8字节] ➔ 引用计数 (管理生命周期) │
│ id(nums) + 08字节 │ [占 8字节] ➔ 类型指针 (指向 <class 'list'> 的定义) │
│ id(nums) + 16字节 │ [占 8字节] ➔ 有效长度 (当前列表中已经实际塞入了几个元素,对应 len(nums)) │
├───────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ id(nums) + 24字节 │ 🌟 [第 25 ~ 32 字节] ➔ 核心参数: ob_item (底层隐秘仓库的指路牌指针) │
│ │ 里面存放着的纯物理内存地址是 ➔ 【 0x1000A00 】 ──────────────────────────────────────────┐ │
├───────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────│───┤
│ id(nums) + 32字节 │ 🌟 [第 33 ~ 40 字节] ➔ 核心参数: allocated (真正预分配的格子总数 Capacity) │ │
│ │ 当前里面记录的格子数量是 ➔ 【 16 】(对应您电脑上的 16 个格子) │ │
├───────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────┴───┤
│ id(nums) + 40字节 │ [占16字节] ➔ 填充对齐等其他内部公摊元数据 │
└───────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
▼ (一旦列表超容搬家,大楼本身一动不动,只有这个指针的值会变,它会指向远方全新的、容量更大的连续仓库)
========================================= 【这才是真正的 list 列表内存】 ===============================================
➔ ➔ ➔ 它是在物理内存中【绝对连续】、手拉手紧密排列的【指针数组】
物理门牌号: 0x1000A00 0x1000A08 0x1000A10 (每格严格递增 8 字节)
┌─────────────────────────┬─────────────────────────┬─────────────────────────┐
列表索引: │ nums │ nums │ nums │
├─────────────────────────┼─────────────────────────┼─────────────────────────┤
格子(抽屉)内容: │ 0x5000100 │ 0x5000200 │ 0x5000300 │ ➔ 纯8字节的地址纸条
└────────────┬────────────┴────────────┬────────────┴────────────┬────────────┘
│ │ │
▼ ▼ ▼ (顺着纸条地址解引用跳转)
======================================= 散落在内存各处的【真实数据单身公寓】 ===========================================
【 0x5000100 】➔ 数字 1 的豪华单身公寓 (总大小 32 字节 / 256 位)
├── 🧱 前 24 字节:Python 官方管理外壳 (引用计数、类型声明等公摊面积)
└── 💎 后 08 字节格子 ➔ 👑 包含您说的【 4 字节 / 32 位 纯数字核心 】➔ 里面正放着数字 1 的二进制
【 0x5000200 】➔ 数字 2 的豪华单身公寓 (总大小 32 字节 / 256 位)
├── 🧱 前 24 字节:Python 官方管理外壳
└── 💎 后 08 字节格子 ➔ 👑 包含您说的【 4 字节 / 32 位 纯数字核心 】➔ 里面正放着数字 2 的二进制
========================================================================================================================
dict的底层原理
字典在底层将 list 的连续内存特性发挥到了极致,并融合了”hash表”和”紧凑型双层阵列”设计。 为什么 list 的结构满足不了字典?
- list(索引查找):格子排得整整齐齐,找第 3 个直接用 起点 + 3 * 8字节 数式一发直达(O(1) 极速)。
- dict(按键查找):如果我们用 info[“age”] 去找数据,如果像列表那样挨个排,Python 就必须从头到尾人肉循环:“你是 age 吗?你呢?”(O(n) 速度)。如果字典里有一百万个键,查询速度会遭遇灾难。
字典的核心魔法就是:“哪怕你传入的是字符串 “age”,我也能通过一套数学公式,一秒把它算成一个固定的数字(索引),实现像列表一样一发直达(O(1) 神速)!”
和列表一样,字典在最顶层也有一个固定不动的管理外壳。
- 外壳的物理大小:在 64 位系统下,一个完全空的字典 {},连带它的基础元数据和隐式垃圾回收(GC)头部,固定占用 64 字节的内存(您可以去电脑上打印 sys.getsizeof({}),稳稳的就是 64)。
- 大楼内部的秘密指针:大楼里除了记录字典长度、引用计数外,最核心的参数是一个名为 ma_keys 的指针。它像一个调度员,指向了字典真正在外面买下的“底层双层大仓库”。
双层大仓库结构
-
哈希索引表(Indices):极小、散落的连续整型数组
- 物理本质:它是一个在物理内存中绝对连续的低成本数组。
- 内存开销:为了将内存压缩到极致,它的每个格子仅占用 1 个字节(8位)。
- 里面装什么:这里面不装任何数据,也不装 8 字节的内存指针,它只存放一个低成本的“房间号数字”。
- 存在的作用:专门用来作为哈希取余的“导航目录”。
-
键值对数组(Entries):极大、紧凑的绝对连续大楼
- 物理本质:这栋大楼在内存中是一丁点空隙都没有、绝对连续排列的 [1, 2]。每当你写入一个新键值对,它们就会像军训排队一样,按顺序在这里住下。
- 内存开销:它的每一个房间(Entry)大小被死死固定为 24 字节。
- 里面装什么:每个房间并排摆着 3 张 8 字节的底层纸条:
- me_hash:键的原始哈希值(一个超大的纯整数)。
- Key 指针:➔ 指向键对象实体的内存地址(如字符串公寓)。
- Value 指针:➔ 指向值对象实体的内存地址(如数字或列表公寓)
========================================================================================================================
Python dict 字典底层内存全景图
========================================================================================================================
【 变量指针: info 】
│
▼ (指向一个在内存中固定不动、体积永远是 64 字节的字典大楼前台)
┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 🏢 字典管理大楼 (PyDictObject 结构体,基础元数据外壳固定为 64 字节) │
├───────────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ id(info) + 00字节 │ [占 8字节] ➔ 引用计数 (管理生命周期,看当前有多少变量在使用这个字典) │
│ id(info) + 08字节 │ [占 8字节] ➔ 类型指针 (指向 <class 'dict'> 的定义,证明自己是一个字典) │
│ id(info) + 16字节 │ [占 8字节] ➔ 有效长度 (当前字典中实际塞入了几个键值对,对应 len(info)) │
├───────────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────┤
│ id(info) + 24字节 │ 🌟 [核心指向指针: ma_keys] ➔ 负责指向下方这个独一无二的“双层大仓库” │
│ │ 里面存放着的纯物理内存地址是 ➔ 【 0x1000A00 】 ──────────────────────────────────────────┐ │
└───────────────────┴──────────────────────────────────────────────────────────────────────────────────────────────│───┘
│
┌───────────────────────────────────────────────────────────────────────────────────────────────────────────┘
▼
===================================== 📦 【第一层仓库:哈希索引表 (Indices)】 ===========================================
➔ ➔ ➔ 在物理内存中【绝对连续】、手拉手排列的格子阵列。负责留出空位防止哈希冲突。
➔ ➔ ➔ 为了极度省内存,每个格子的大小仅仅只有【1 个字节】!里面只塞了一个纯数字的“房间号”。
物理索引号: 0 1 2 ... ... 7 (共 8 个格子)
┌────────────────────┬────────────────────┬────────────────────┐ ┌────────────────────┐
格子门牌号: │ 0 号格 │ 1 号格 │ 2 号格 │ │ 7 号格 │
├────────────────────┼────────────────────┼────────────────────┤ ├────────────────────┤
1字节房间号: │ -1 │ 1 │ -1 │ │ 0 │ -> -1代表空位
└────────────────────┴─────────┬──────────┴────────────────────┘ └─────────┬──────────┘
│ │
└─────────┐ │
▼ (根据 1 字节房间号,瞬间寻址 JUMP) │
│
===================================== 📦 【第二层仓库:键值对数组 (Entries)】 =================================──────────
➔ ➔ ➔ 在物理内存中【绝对连续】、一丁点空隙和浪费都没有、按写入顺序紧密排队的“实体数据大楼”。
➔ ➔ ➔ 每一个房间规格写死就是【24 个字节】 (8字节哈希值 + 8字节Key指针 + 8字节Value指针)。
│
房间序号: 【 0 号房 】 【 1 号房 】 ◀────────┘
┌──────────────────────────┐ ┌──────────────────────────┐
24字节固定结构: │ 原始哈希值(8B): 0x8849F │ │ 原始哈希值(8B): 0x12A9E │
├──────────────────────────┤ ├──────────────────────────┤
│ Key 指针(8B) : ➔ "name" │ │ Key 指针(8B) : ➔ "age" │
├──────────────────────────┤ ├──────────────────────────┤
│ Value指针(8B): ➔ "Alex" │ │ Value指针(8B): ➔ 18 (公寓)│
└──────────────────────────┘ └────────────┬─────────────┘
│
▼ (顺着 8 字节纸条解引用跳转)
=================================== 🏠 【第三层仓库:散落在内存各处的真实数据公寓】 =======================================
➔ ➔ ➔ 属于完全独立的单身公寓,不计入字典本身的内存账单!
【 0x5000100 (假设) 】➔ 键对象 "age" 的独立公寓
├── 🧱 前 24 字节:Python 官方管理外壳 (引用计数、类型声明等公摊面积)
└── 💎 后 08 字节格子 ➔ 包含字符串 "age" 的纯文本底层数据
【 0x5000200 (假设) 】➔ 值对象 18 的豪华单身公寓 (总大小 32 字节 / 256 位)
├── 🧱 前 24 字节:Python 官方管理外壳 (引用计数等)
└── 💎 后 08 字节格子 ➔ 👑 包含您最熟悉的【 4 字节 / 32 位 纯数字核心 】➔ 里面正放着数字 18 的二进制
========================================================================================================================
在针对字典往下分析前我们先来搞清什么是Hash(哈希)?
在计算机底层,哈希(Hash)本质上就是一个“数据粉碎再重组”的数学变数器(散列函数)。它的工作就是:无论你丢给它一个多么庞大、长短不一的数据(比如一部几百兆的电影,或者一个单词 “age”),它都能通过一套死记硬背的数学公式,瞬间将其“揉碎、压缩”,吐出一个固定长度的、纯粹的大整数
任意长短的数据 ➔ ➔ ➔ 【 🧬 哈希函数 hash() 】 ➔ ➔ ➔ 固定长度的大整数 (哈希值)
"age" ➔ (数据粉碎压缩) ➔ 1145141919810
"A muito longa..." ➔ ➔ 9876543210
只要数据不变,无论你今天算、明天算、在全宇宙任何一台电脑上算,hash(“age”) 吐出来的那个大整数永远死死固定、绝对不变。
什么数据才能 Hash?
在 Python 中,有一个极其地道的专业术语叫 Hashable(可哈希)。官方的硬核铁律是:只有“一出生它的内容就死死被写死、绝对不能改变”的对象,才能进行哈希运算!
- 允许哈希(可哈希对象):全部是不可变类型
- 数字(int, float)、字符串(str)、元组(tuple)、布尔值(bool)。
- 为什么它们可以? 因为数字 100 或者字符串 “age” 一出生,里面的二进制内容就定死了。内容不动,算出来的哈希值就永远稳如泰山。
- 拒绝哈希(不可哈希对象):全部是可变类型
- 列表(list)、字典(dict)、集合(set)。
- 如果你强行对列表求哈希,Python 会直接抛出灾难性报错:TypeError: unhashable type: ‘list’。
- 为什么它们绝对不行? 因为列表是可以 append 的。如果一个列表原本叫 [1, 2],算出来的哈希值是 999;你一执行 append(3),内容变了,下次再算哈希值可能变成了 888。
- 物理惨状:如果允许它做字典的 Key,它改变内容后,哈希值变了。当你去二层【哈希索引表】取余找它时,算出来的格子序号彻底错乱,导致之前存在下层【键值对数组】里的老房客在内存里彻底失联。
以下是描述 Python 字典(CPython 3.6+ 双层结构)查找与插入过程的 Markdown 源码。你可以直接复制以下代码块使用。
查找过程 (d[key])
查找操作旨在通过键快速定位到 Entries 中的值。
- 计算哈希:
- 调用
hash(key)获取原始哈希值。 - 进行扰动处理(高位与低位异或),减少低位冲突概率。
- 调用
- 定位 Indices:
- 使用位运算计算初始索引:
idx = hash_perturbed & mask(mask = size - 1)。 - 读取
Indices[idx]的值。
- 使用位运算计算初始索引:
- 判断状态:
- 若为 -1 (EMPTY):键不存在,抛出
KeyError。 - 若 >= 0 (占用):获取该值作为
entry_idx,跳转到 Entries 数组。
- 若为 -1 (EMPTY):键不存在,抛出
- 验证匹配:
- 检查
Entries[entry_idx].key是否与目标key相等(先比哈希,再比对象)。 - 匹配成功:返回
Entries[entry_idx].value。 - 匹配失败:发生哈希冲突,进入探测流程。
- 检查
- 开放寻址探测:
- 使用伪随机算法计算下一个 Indices 位置:
next_idx = (5 * idx + 1 + perturb) & mask。 - 重复步骤 3-4,直到找到匹配键或遇到 EMPTY。
- 使用伪随机算法计算下一个 Indices 位置:
插入过程 (d[key] = value)
插入操作需要处理新键新增、旧键更新以及哈希冲突。
-
步骤 1:计算与定位
- 计算扰动后的哈希值
h。 - 计算初始 Indices 索引
idx = h & mask。
- 计算扰动后的哈希值
-
步骤 2:检查 Indices[idx] 状态
-
情况 A:Indices[idx] == -1 (空闲)
- 动作:直接使用该位置。
- 写入 Entries:将
(h, key, value)追加到 Entries 末尾,记录新下标new_entry_idx。 - 更新 Indices:设置
Indices[idx] = new_entry_idx。
-
情况 B:Indices[idx] >= 0 (占用)
- 获取 Entry:读取
entry_idx = Indices[idx],获取Entries[entry_idx]。 - 比对 Key:
- 若 Key 相同(更新操作):
- 直接修改
Entries[entry_idx].value = value。 - 不改变 Indices 和 Entries 结构,保持顺序不变。
- 直接修改
- 若 Key 不同(哈希冲突):
- 启动探测算法寻找新的空闲 Indices 位置。
- 循环计算
next_idx,直到找到Indices[next_idx] == -1或匹配的 Key。 - 若找到新空位:
- 将
(h, key, value)追加到 Entries 末尾。 - 设置
Indices[next_idx] = 新下标。
- 将
- 若中途发现匹配 Key:转为更新操作。
- 若 Key 相同(更新操作):
- 获取 Entry:读取
-
情况 C:遇到 Dummy (已删除标记)
- 注:在某些实现中,删除会将 Indices 位置标记为特殊值(如复用 -1 或特定负数,具体视版本而定,逻辑上视为“可复用但需继续探测”)。
- 查找时:遇到 Dummy 不停止,继续探测(因为目标可能在后面)。
- 插入时:记录第一个遇到的 Dummy 位置。如果后续找到了真正的 EMPTY,优先复用该 Dummy 位置以填补空洞。
-
扩容机制 (Resize)
当满足以下条件之一时触发扩容:
- Entries 数组已满。
- Indices 数组填充率 > 2/3(冲突概率过高)。
扩容流程:
- 分配更大的 Indices 数组(通常容量翻倍)和新的 Entries 空间。
- 重新哈希 (Rehash):遍历旧 Entries 中的所有有效键值对。
- 重新计算每个键的 Indices 位置,并插入到新表中。
- 替换旧表。此过程保证了新的哈希分布更均匀,且 Entries 依然保持插入顺序。
字典是有序的吗?
根据双层大仓库结构(cpython3.6+版本),第二层Entries有序列表,数据也会按字典写入存放,那么字典也应该是有序的: 示例:
d = {"k1": "v1", "k2": "v2", "k3": "v3"}
for i in range(5):
print(d)
for key in d.keys():
print(key,"->",d[key])
运行结果
{'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
{'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
{'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
{'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
{'k1': 'v1', 'k2': 'v2', 'k3': 'v3'}
k1 -> v1
k2 -> v2
k3 -> v3
打印多次,字典输出保持不变,遍历也是有序的,所以字典是有序的(旧版本无序)
关键特性总结
- 有序性:由于 Entries 是追加写入的,遍历字典时直接线性扫描 Entries,因此天然保证插入顺序。
- 内存优化:Indices 只存小整数(int8/16/32),Entries 无空洞,相比传统稀疏哈希表节省约 20%-30% 内存。
- 高速查找:绝大多数情况下只需 1 次 Indices 访问 + 1 次 Entries 访问即可命中。
第一层与第二层 有一一对应关系,为何不保持一致呢?
触及了数据结构设计中「查找效率」与「空间利用率」的终极博弈。 从逻辑上看,第一层索引引出的终点,确实对应着第二层有序列表里的某一条数据。但如果真的让两层的容量(格数)保持绝对一致,整个字典的架构就会瞬间崩塌。
-
第一层必须“大而空”,否则 Hash 就会失效 第一层 indices 是哈希数组,它的核心任务是承接哈希值的随机性,消灭拥堵。
- 哈希的本质是随机散列:你给 5 个不同的 Key,算出来的 hash & mask 可能会随机分布在 0 到 7 的任何位置。
- 必须留有“缓冲区”:如果第一层不保持「大而空」(初始给 8 个格子,却只准放 5 个数),而是死板地让它也只有 5 个格子(两层一致),那么根据数学上的鴿巢原理和哈希概率,这 5 个 Key 撞车(哈希冲突)的概率会高到令人发指。
- 结论:第一层必须多留出至少 1/3 的空槽(-1)作为防撞缓冲区,只有这样,新来的 Key 才能在 (O(1)) 时间内轻松找到空位,不至于陷入无休止的冲突探测中。
-
第二层必须“小而紧”,否则内存就会暴增 如果为了追求你说的“两层一致”,我们把第二层 entries 也强行扩大到和第一层一样(比如也是 8 个格子),那就会直接退回到 Python 3.5 之前的旧时代。
- 因为第一层散列是随机的,第二层为了和第一层一一对应,也必须变得断断续续。原本紧凑的第二层,就会被塞入大量完全不存数据的「空槽」。
- 恐怖的内存代价:第一层的空槽(-1)只占 1 个字节(int8);而第二层的一个空槽,因为要预留 C 语言结构体空间,足足要占 24 个字节!
- 两层一致的后果:初始存 5 个 Key,剩下的 3 个空槽在第一层只浪费 3 字节,但在第二层就会暴虐地浪费 (3 \times 24 = 72) 字节。如果是大字典,这种空槽带来的内存浪费会达到数个 GB。
到这里我们可以总结一条:列表与字典变量没有指向真实数据的内存地址,指向一个中间内存地址。真实数据地址在这个中间表里,这个中间内存地址存放在变量指向内存空间的元数据里。 变量->变量内存(元数据)->数据地址中间表->真实数据地址
set的底层原理
set 本质上是只有 Key 没有 Value 的 dict。 它同样基于哈希表,采用开放寻址法解决冲突,在 CPython 3.6+ 中也经历了类似的内存优化,但其内部结构通常被视为单层紧凑数组(因为不需要维护 Key-Value 映射,只需存储 Key)。
与字典不同,set 不需要区分 Indices 和 Entries 两层来维护插入顺序(set 是无序的)。其底层主要包含:
- 哈希表数组(Table):一个连续的数组,直接存储表项(Entry)。
- 表项内容:每个位置存储 (hash, key)。由于没有 Value,结构比字典更简单。 注:在较新的 CPython 版本中,为了进一步优化内存,set 也可能采用类似字典的“索引+数据”分离思想,但逻辑上它依然是一个单纯的键集合,不涉及 Value 的存储和顺序维护。
+-------------------------------------------------------------------------------+
| 【 PySetObject 集合核心控制头部 】 |
| - used : 3 (目前存了 3 个有效元素,len() 直接读这里) |
| - fill : 3 (已占用的格子数,包含活着的元素和已删除的墓碑标记) |
| - mask : 7 (总格子数 - 1,目前容量为 8) |
| - table : *ptr ----------------+ (指向底层唯一的单层中间表) |
+-------------------------------------+-----------------------------------------+
|
v
+-------------------------------------------------------------------------------+
| 【 中间表:单层哈希指针数组 (Sparse Array) 】 |
| - 承接哈希值的随机散列,格子不是连续填满的,充满了 [空槽] |
| - 每个有效格子(Entry) 固定 16 字节:包含 [哈希值 + 真实数据指针] |
| |
| 下标 (i): 0 1 2 3 4 5 6 7 |
| +----------+------+------+------+------+------+------+------+------+ |
| | table | [空] | Entry| [空] | Entry| [空] | [空] | Entry| [空] | |
| +----------+------+------+------+------+------+------+------+------+ |
| | | | |
| ┌────────────────┘ │ └────────┐ |
| ▼ ▼ ▼ |
| [Entry 1] [Entry 3] [Entry 6] |
| - me_hash: -492018341 - me_hash: 849302183 - me_hash: 1122
| - me_key : *ptr - me_key : *ptr - me_key : *pt
| │ │ │ |
+--------|--------------------------------|------------------------------|------+
│ │ │
▼ ▼ ▼
+------------------+ +------------------+ +-------------
| 【 第三层:真实数据对象 】 | 【 第三层:真实数据对象 】 | 【 第三层:真
| | | | |
| "age" (PyObject) | | "name" (PyObject) | | "city" (Py
+------------------+ +------------------+ +-------------
有了列表(List),为什么还需要集合(Set)? 如果只用你最熟悉的指针数组(列表),哪怕内存排得再整齐,它在面对某些高频业务场景时,也有两个无法通过“堆硬件”解决的致命物理缺陷:
- 寻找数据时,必须“人肉挨个遍历”,数据量大时会极慢((O(N)))。
- 无法自动判断重复,想去重就必须两层 for 循环硬撞,算力消耗恐怖。
Set 的出现,就是为了用哈希指针数组这把锋利的武器,彻底干掉这两个痛点。我们在实际开发中之所以必须使用 Set,完全是因为以下三个不可替代的底层原因:
-
为了实现 (O(1)) 级别的”盲狙查找” 假设你的程序接到了一个高并发的需求:判断当前登录的用户,在不在 1000 万个“黑名单用户”里面。
- 如果用列表(List):由于列表的指针数组只认位置、不认内容,CPU 别无选择,只能从第 0 个格子、第 1 个格子、第 2 个格子……像大海捞针一样一路数下去。如果这个人不在黑名单里,CPU 就要白白比对 1000 万次!哪怕你的 CPU 频率再高,遇到高并发请求也会瞬间被卡死。
- 如果用集合(Set):Set 的指针数组多了一层 Hash 索引。用户一进来,CPU 拿用户名现算一个哈希值,通过 hash & mask 一步到位、瞬间盲狙到中间表的精准下标格子里。看一眼格子是不是 NULL,就能立刻知道他在不在黑名单里。无论黑名单里有 10 个人还是 1000 万人,Set 查找都只需要 1 步。这种恐怖的 (O(1)) 极速查找,是普通列表永远无法做到的。
-
为了实现“天然的、硬件级的去重” 在海量数据处理中(比如清洗日志、爬虫去重),“去掉重复数据”是最常见的刚需。
- 如果用列表:为了去重新数据,你每拿到一个新元素,都必须在旧列表里用 for 循环扫一遍,确保没有重复才能追加(Append)。这种两层循环嵌套((O(N^2)))在处理百万级数据时,耗时会直接飙升到几个小时甚至几天。
- 如果用集合:由于 Set 写入数据的物理公式是固定的,新数据算出来的座位一旦被相同的内容占领了,底层的指针就直接静默忽略,拒绝写入。Set 不需要做任何额外的人肉比对,它底层那层哈希指针数组的物理排他性,让它天然就拥有了海量数据秒级去重的超能力。
-
为了高性能的“数理逻辑运算”(交集、并集、差集)
- 在社交应用、推荐系统里,我们经常需要计算:“我和你的共同好友有哪些?”(求交集 &)
- “游戏推荐系统里,两个玩家共同购买的游戏有哪些?”
- “微博里,我关注的人里有哪些人没有关注我?”(求差集 -)
既然set无序,那么一次代码多次调用会一致吗?每次重新运行输出顺序会保持一致吗? 示例代码:
import os
import sys
# 建立一个字符串集合(Set)
my_set = {"apple", "banana", "cherry", "date"}
print("=========================================")
print(f"【当前 Python 进程 PID: {os.getpid()}】")
print("=========================================")
# ----------------------------------------------------
# 实验一:在同一次代码运行中,多次调用(循环多次打印)
# ----------------------------------------------------
print("\n--- 实验一:同一次运行中,连续调用 5 次 ---")
for i in range(1, 6):
# 将 set 转换成 list 打印,方便肉眼严格对齐顺序
print(f"第 {i} 次调用输出: {list(my_set)}")
print("\n💡 【实验一结论】: 你会发现这 5 次调用的输出顺序「死死固定」、完全一模一样!")
print("因为此时内存里的『单层哈希指针数组』位置已经排好,没有任何物理挪动。")
# ----------------------------------------------------
# 实验二:模拟重新运行(重启程序)
# ----------------------------------------------------
print("\n--- 实验二:请手动重新运行(重启)本程序 ---")
print("👉 请记下你画面上第 1 次调用输出的水果顺序。")
print("👉 现在请关闭这个控制台,然后重新点击『Run/运行』按钮再次执行。")
print("👉 你会发现,新进程产生的顺序,有极大概率和刚才记下来的顺序完全不同!")
print("\n🔒 底层安全机制提示:")
print(f"目前 Python 的哈希随机化种子(HASH SEED)在本次运行中是固定的。")
print("一旦你重新启动程序,Python 虚拟机 forest 会重新抽签生成全新的『盐值(Salt)』,")
print("导致字符串的 hash() 值全面大洗牌,它们在指针数组里的座位(内存下标)也随之彻底改变。")
第一次运行结果
=========================================
【当前 Python 进程 PID: 67980】
=========================================
--- 实验一:同一次运行中,连续调用 5 次 ---
第 1 次调用输出: ['cherry', 'banana', 'date', 'apple']
第 2 次调用输出: ['cherry', 'banana', 'date', 'apple']
第 3 次调用输出: ['cherry', 'banana', 'date', 'apple']
第 4 次调用输出: ['cherry', 'banana', 'date', 'apple']
第 5 次调用输出: ['cherry', 'banana', 'date', 'apple']
💡 【实验一结论】: 你会发现这 5 次调用的输出顺序「死死固定」、完全一模一样!
因为此时内存里的『单层哈希指针数组』位置已经排好,没有任何物理挪动。
--- 实验二:请手动重新运行(重启)本程序 ---
👉 请记下你画面上第 1 次调用输出的水果顺序。
👉 现在请关闭这个控制台,然后重新点击『Run/运行』按钮再次执行。
👉 你会发现,新进程产生的顺序,有极大概率和刚才记下来的顺序完全不同!
🔒 底层安全机制提示:
目前 Python 的哈希随机化种子(HASH SEED)在本次运行中是固定的。
一旦你重新启动程序,Python 虚拟机 forest 会重新抽签生成全新的『盐值(Salt)』,
导致字符串的 hash() 值全面大洗牌,它们在指针数组里的座位(内存下标)也随之彻底改变。
第二次运行结果
=========================================
【当前 Python 进程 PID: 67989】
=========================================
--- 实验一:同一次运行中,连续调用 5 次 ---
第 1 次调用输出: ['cherry', 'banana', 'apple', 'date']
第 2 次调用输出: ['cherry', 'banana', 'apple', 'date']
第 3 次调用输出: ['cherry', 'banana', 'apple', 'date']
第 4 次调用输出: ['cherry', 'banana', 'apple', 'date']
第 5 次调用输出: ['cherry', 'banana', 'apple', 'date']
💡 【实验一结论】: 你会发现这 5 次调用的输出顺序「死死固定」、完全一模一样!
因为此时内存里的『单层哈希指针数组』位置已经排好,没有任何物理挪动。
--- 实验二:请手动重新运行(重启)本程序 ---
👉 请记下你画面上第 1 次调用输出的水果顺序。
👉 现在请关闭这个控制台,然后重新点击『Run/运行』按钮再次执行。
👉 你会发现,新进程产生的顺序,有极大概率和刚才记下来的顺序完全不同!
🔒 底层安全机制提示:
目前 Python 的哈希随机化种子(HASH SEED)在本次运行中是固定的。
一旦你重新启动程序,Python 虚拟机 forest 会重新抽签生成全新的『盐值(Salt)』,
导致字符串的 hash() 值全面大洗牌,它们在指针数组里的座位(内存下标)也随之彻底改变。
结果和原因已经在在代码里写了。哈希随机化种子每次运行不一样,但是同一次运行里是一样的。
操作方法
(1)int
- 内置函数:
- abs(x):返回整数的绝对值。
- divmod(a, b):同时返回商和余数的元组 (a // b, a % b)。
- pow(x, y, z=None):计算 x 的 y 次方。若提供 z,则计算 (x ** y) % z(模幂运算,速度极快)。
- round(x, n=0):对整数进行四舍五入。对 int 传入正数 n 依然返回原整数。
- hash(x):返回整数的哈希值(整数的哈希值通常是其自身)。
- id(x):返回对象在内存中的地址。
- 专属内置方法 (Methods)
- bit_length():返回表示该整数二进制形式所需的最少位数(不含符号位和前导零)。
- bit_count():返回该整数的二进制表示中,数字 1 的总个数。
- to_bytes(length, byteorder, *, signed=False):将整数转换为 bytes(字节串)。
- from_bytes(bytes, byteorder, *, signed=False):[类方法] 将 bytes 字节串 还原转换为整数。
- as_integer_ratio():返回一个元组 (分子, 分母)。由于是整数,分母恒为 1。
- 内置属性 (Attributes)
- numerator:获取整数的分子(数值等于其自身)。
- denominator:获取整数的分母(数值恒为 1)。
- real:获取整数的实部(数值等于其自身)。
- imag:获取整数的虚部(数值恒为 0)。
(2)float
- 全局内置函数
- float(x):将整数、数字字符串(包括 ‘inf’ 无穷大、’nan’ 非数字)转换为浮点数。float(“3.14”) ➡️ 3.14float(“inf”) ➡️ 正无穷大(用于初始化寻找最小值的初始变量)
- int(x):将浮点数转换为整数(直接截断小数部分,向 0 取整,不进行四舍五入)。int(3.9) ➡️ 3int(-3.9) ➡️ -3
- str(x):将浮点数转换为字符串。如果需要做文本排版或使用 lower() 等字符串方法,必须先执行此步骤。
- 内置属性 (Attributes)
- real:获取浮点数的实部(数值等于其自身)。(3.14).real ➡️ 3.14
- imag:获取浮点数的虚部(数值恒为 0.0)。(3.14).imag ➡️ 0.0
- 专属内置方法 (Methods)
- is_integer():判断该浮点数是否为整数(即小数部分是否为 0)。
- (5.0).is_integer() ➡️ Tru
- e(5.1).is_integer() ➡️ False
- as_integer_ratio():将浮点数精准精确地转换为一个元组 (分子, 分母)。
- (0.25).as_integer_ratio() ➡️ (1, 4)
- 注意:由于浮点数存在二进制精度误差,(0.1).as_integer_ratio() 会返回一个非常庞大的数字组合。
- hex():将浮点数转换为其十六进制字符串表示。
- (3.75).hex() ➡️ ‘0x1.e000000000000p+1’(常用于在不同系统间无损传递浮点数)。
- fromhex(string):[类方法] 将十六进制浮点数字符串还原为 float 对象。
- float.fromhex(‘0x1.e000000000000p+1’) ➡️ 3.75
- is_integer():判断该浮点数是否为整数(即小数部分是否为 0)。
(3)str
-
大小写与格式转换 这类方法在处理文本、清洗数据时极常用,注意:因为字符串是不可变的,这些方法全部会返回一个新字符串。
- lower():全部字母转为小写。”Hello”.lower() ➡️ “hello”
- upper():全部字母转为大写。”Hello”.upper() ➡️ “HELLO”
- title():每个单词的首字母转大写。”hello world”.title() ➡️ “Hello World”
- capitalize():仅整个字符串的第一个字母转大写。”hello world”.capitalize() ➡️ “Hello world”
- swapcase():大写变小写,小写变大写。”Hi”.swapcase() ➡️ “hI”
-
查找与替换(高效搜索)
- find(sub[, start[, end]]):查找子字符串 sub 首次出现的索引。找不到返回 -1。
- index(sub[, start[, end]]):与 find 相同,但找不到时会抛出 ValueError 报错。
- rfind() / rindex():从右侧(末尾)开始查找子字符串。
- replace(old, new[, count]):将 old 替换为 new。可指定 count 限制替换前几次。
- count(sub):统计子字符串 sub 在文本中出现的总次数。
-
拆分与合并(核心高频)
- split(sep=None, maxsplit=-1):按照分隔符 sep 切割字符串,返回一个列表 (list)。默认按空格、换行符等空白切割。
- “a,b,c”.split(“,”) ➡️ [‘a’, ‘b’, ‘c’]
- rsplit():从右侧开始切割。
- splitlines():按照换行符(\n, \r)切割文本。
- join(iterable):以当前字符串作为拼接符,将一个可迭代对象(如列表)中的所有字符串串联起来。
- “-“.join([‘a’, ‘b’, ‘c’]) ➡️ “a-b-c”
- split(sep=None, maxsplit=-1):按照分隔符 sep 切割字符串,返回一个列表 (list)。默认按空格、换行符等空白切割。
-
修剪与对齐(排版/清洗)
- strip([chars]):移除字符串开头和结尾的指定字符(默认移除空格、制表符 \t、换行符 \n)。
- lstrip() / rstrip():仅移除左侧(开头)或右侧(结尾)的指定字符。
- center(width[, fillchar]):让字符串居中,两侧用 fillchar 填充至总长度 width。
- ljust() / rjust():左对齐或右对齐。
- zfill(width):在字符串左侧补零至指定长度(常用于数字字符串格式化)。”42″.zfill(5) ➡️ “00042”
-
状态判断(返回 True 或 False)
- startswith(prefix):是否以指定的字符串开头。
- endswith(suffix):是否以指定的字符串结尾。
- isalpha():是否全是字母。
- isdigit() / isnumeric():是否全是数字。
- isalnum():是否全是字母或数字。
- isspace():是否全是空白字符(空格、换行、制表符等)。
- islower() / isupper() / istitle():判断是否符合相应的大小写格式。
-
全局内置函数与高级操作
- len(s):返回字符串的字符总数(注意:Python 3 中一个汉字也算作 1 个长度)。
- str(x):将其他类型强制转换为字符串(如将 int、float 转换为 str 以调用上述方法)。
- s[start:end:step](切片操作):虽然不是函数,但切片是操作字符串的超级利器。逆序反转字符串:
- s[::-1]s.encode(encoding=’utf-8′):将文本字符串编码为底层二进制 bytes 字节流(网络传输、保存文件时必用)。
(4)tuple
-
2个内置方法
- count(value):统计某个元素在元组中出现的总次数。
- t = (1, 2, 3, 2, 2)
- t.count(2) ➡️ 3
- index(value[, start[, end]]):查找某个元素在元组中首次出现的索引位置。如果找不到该元素,会抛出 ValueError 报错。
- t = (‘a’, ‘b’, ‘c’)
- t.index(‘b’) ➡️ 1
- count(value):统计某个元素在元组中出现的总次数。
-
常用的全局内置函数 由于元组是一个序列(Sequence),Python 许多全局内置函数都可以直接作用于元组,用于获取其状态或进行类型转换:
- len(t):返回元组中元素的总个数。
- max(t)/min(t):返回元组中元素的最大值或最小值(元组内的元素必须是可相互比较的类型,例如全是数字)。
- sum(t):计算元组中所有数字的总和。
- tuple(iterable):类型转换函数。将其他可迭代对象(如列表 list、字符串 str、集合 set)转换为元组。tuple([1, 2, 3]) ➡️ (1, 2, 3)
- sorted(t):对元组进行排序。注意:因为元组不可变,该函数会返回一个排序后的全新“列表(list)”,而不会改变原元组。
- reversed(t):反转元组。返回一个反转后的迭代器对象,若要看结果需要用 tuple() 包裹。
-
元组强大的基础语法操作(非函数) 虽然 tuple 的内置方法很少,但配合 Python 的原生语法,它可以玩出非常高效的操作:
- 切片操作(Slicing)—— 安全获取子集
与字符串、列表一样,元组支持通过 [start:end:step] 访问部分元素。切片会返回一个新开辟的元组。
- t = (1, 2, 3, 4, 5)
- t[1:4] ➡️ (2, 3, 4)
- t[::-1] ➡️ (5, 4, 3, 2, 1)(实现元组反转的最高效写法)
- 元组拼接与重复(+ 与 *)
- +(拼接):将两个元组合并为一个全新的元组 ➡️ (1, 2) + (3, 4) ➡️ (1, 2, 3, 4)。
- (重复):将元组内容复制多次组成新元组 ➡️ (‘A’,) 3 ➡️ (‘A’, ‘A’, ‘A’)。
- 成员资格判断(in 与 not in)
- 3 in (1, 2, 3) ➡️ True3 in (1, 2, 3) ➡️ True
- 元组拆包(Unpacking)—— 极高频且优雅
- point = (10, 20)x, y = point # x 变为 10,y 变为 20
- 切片操作(Slicing)—— 安全获取子集
与字符串、列表一样,元组支持通过 [start:end:step] 访问部分元素。切片会返回一个新开辟的元组。
(5)list
-
列表追加与扩容
- append(x):在指针数组的当前有效末尾(ob_size 位置)追加一个新指针,指向 x。如果当前总容量(allocated)满了,会触发 2 倍的内存重新分配与整体拷贝。
- insert(index, x):在指定的 index 位置强行塞入指针 x。物理代价极高:因为为了保持指针数组的连续性,index 之后的所有指针在内存中必须整体向后平移一格(时间复杂度 O(N))。
- extend(iterable):接收另一个可迭代对象,将其中的所有元素指针批量追加到当前数组末尾。相比循环调用 append,它在底层的 C 语言级别是一次性计算总长度并扩容,效率更高。
-
列表缩减与指针销毁阵营
- pop([index]):默认弹出一个指针(index=-1,即尾部弹出)。这在底层极快(O(1)),只需把 ob_size 计数器 -1,并返回最后一个指针即可。但如果弹出中间的 index,由于数组不能留空洞,后面的所有指针必须集体向前平移一格(O(N))。
- remove(x):人肉线性扫描数组,找到第一个与 x 原始值相等的指针对象,并将其切断(从数组中移除)。同样会引发后面所有指针向前平移。如果找不到则抛出 ValueError。
- clear():将控制头部的 ob_size 清零,断开所有与真实数据对象的连接,但通常不会立刻释放底层申请的指针数组空间(allocated),而是保留该内存块以备下次快速写入。
-
检索与内部调整
- index(x):从下标 0 开始数,人肉挨个遍历数组中的指针,跳转到第三层比对原始值,返回第一个匹配到的下标。时间复杂度为恶劣的 O(N)(这就是有了 List 还要 Set 的原因)。
- count(x):线性遍历整个指针数组,统计有多少个格子的指针指向与 x 相同的真实对象。
- sort(key=None, reverse=False):采用极其高效的 Timsort 算法,在底层的中间指针数组内部原地打乱并重新排列指针对齐顺序。它移动的是 8 字节的指针地址,而不是第三层的庞大实体对象,所以速度极快。
- reverse():将底层指针数组的头尾顺序原地对调。
-
切片操作 除了上述封装好的方法,List 还有一个最常用的操作——切片(如 my_list[1:4])。 切片的底层物理原理 当你执行 sub_list = my_list[1:4] 时,Python 在底层会引发一场微型的”新生命诞生”:
- 开辟新元数据:在堆内存中创建一个全新的 PyListObject 控制头部。
- 开辟新中间表:为 sub_list 分配一块全新的、大小为 3 的一维指针数组(中间表)。
- 复制指引,不复制实体:CPU 走到旧列表的下标 1、2、3 处,把那 3 个格子里记录的 8 字节指针地址,原封不动地复制到新列表的三个格子里。
【 旧列表 my_list 】
下标 1: [ 指针A ] ───────┐
下标 2: [ 指针B ] ───────┼───┐
下标 3: [ 指针C ] ───────┼───┼───┐
│ │ │
▼ ▼ ▼ 共享第三层相同的实体
+------------------+
| 真实对象 (Heap) |
+------------------+
▲ ▲ ▲
【 新列表 sub_list 】 │ │ │
下标 0: [ 指针A ] ───────┘ │ │
下标 1: [ 指针B ] ───────────┘ │
下标 2: [ 指针C ] ───────────────┘
(6)dict
-
增改阵营:元素的写入与覆盖
- dict[key] = value:
- 如果是新 Key:属于尾部追加(Append)。在第二层 entries 数组的末尾(dk_nentries 位置)写入 [hash, key, value] 指针,然后在第一层 indices 数组经过 hash & mask 算出的位置填入该 entries 的下标。(如果达到 2/3 临界点,则触发 2 倍翻家扩容)。
- 如果是老 Key:属于原地覆盖。通过第一层快速定位到第二层的下标,直接把原有的 value 指针擦除,指向新的真实 Value 对象。不挪动在第二层数组里的物理位置,因此不会改变其插入顺序。
- update(other_dict):批量合并。在底层它会一次性遍历 other_dict,并重复上述写动作。
- dict[key] = value:
-
删除阵营:指针打洞与逻辑删除
- pop(key) / del dict[key]:
- 核心物理动作:为了保持 O(1) 的极致吞吐,当删除一个键值对时,Python 绝对不会把第二层 entries 数组后面的元素往前挪(那样会触发 O(N) 的高成本内存搬移)。
- 做法:它通过第一层找到 entries 下标,然后仅仅把第一层对应的映射数字改为一个特殊的负数标记,同时把第二层对应槽位的 me_key 和 me_value 改为 NULL。这个被挖掉的坑位在底层被称为 “墓碑(Tombstone / Dummy)”。
- 结果:ma_used 计数器 -1(len() 减小),但这块墓碑空洞会一直留着,直到下一次字典触发扩容(Resize)时,才会在搬家过程中被彻底打包压缩、一扫而空。
- clear():将控制头部的 ma_used 和 dk_nentries 清零。断开第一层与第二层所有的指针连接。通常它并不立刻释放两层数组的物理总内存,而是将整个连续大块内存格式化为初始或重置状态,等待下次复用。
- pop(key) / del dict[key]:
-
视图检索阵营:高效率的线性遍历
- keys() / values() / items():
- 底层进化:在 Python 3.5 之前,遍历字典必须去扫描那个充满空槽、断断续续的巨大哈希表,导致 Cache Miss 极高。
- 现代底层:这三个方法返回的是动态视图对象(View)。当你循环它们时,CPU 拿着指针直接、规规矩矩地去遍历第二层那个紧密排列的 entries 有序数组。遇到墓碑跳过,遇到有效指针直接吐出。由于内存绝对连续,它完美压榨了 CPU 的 L1/L2 高速缓存(Cache Line),速度极快。
- keys() / values() / items():
-
安全查询阵营:防崩溃的盲狙
- get(key, default=None):标准的 5 步走盲狙查询(哈希 ──► indices ──► entries ──► 比对 ──► 吐出)。如果遇到第一层是 -1(空槽)或者没匹配到,它不抛出 KeyError 异常,而是直接返回你指定的默认值。
- setdefault(key, default):防守型写入。先去查 Key,如果 Key 存在,直接返回对应的 Value;如果不存在,则把 [key, default] 写入双数组中。
-
新版高级合并操纵 在当前最新版的 Python 中,字典加入了两个非常强大的新操作符:|(合并)和 |=(就地更新)。
x = {'a': 1, 'b': 2}
y = {'b': 99, 'c': 4}
# 1. 产生新字典
z = x | y # z 是 {'a': 1, 'b': 99, 'c': 4}
# 2. 就地更新
x |= y # x 变成了 {'a': 1, 'b': 99, 'c': 4}
合并操作符的底层物理原理(以 z = x | y 为例):
- 预判容量:Python 会先读取 x 和 y 核心控制头部的 ma_used 变量,把它们相加,算出新字典至少需要容纳多少个 Key。
- 一次性开辟连续空间:根据相加后的元素量,一步到位向操作系统申请足够大的新 PyDictKeysObject(双数组一体化内存块),防止在中途合并时频繁触发搬家扩容。
- 顺序列车大复制:
- CPU 先把 x 里的 entries 指针按顺序拷贝到新字典中,写入对应的 indices。
- 接着把 y 里的 entries 拷贝过去。如果遇到相同的 Key(比如 ‘b’),直接采取老键原地覆盖策略,用 y 的新指针覆盖掉 x 的老指引。
- 结果:新字典 z 的物理存储完美保留了从左到右的插入顺序。
(7)set
-
增补与去重阵营:元素的写入
- add(element):
- 核心物理动作:CPU 拿着 element 的哈希值,通过 hash & mask 盲狙到单层中间表的某个格子(Slot)。
- 去重本质:如果格子是 NULL,则把 [hash, element_ptr] 填入,used 计数器 +1。如果格子里有数据,则顺着指针去第三层比对原始值。若相同,直接静默忽略,拒绝写入。
- 扩容时机:如果填入后,总槽位占用率(包括活着的和墓碑)达到了 60% 的临界点,立刻触发 2 倍或 4 倍的开辟新数组、全量重新哈希搬家。
- update(*iterables):批量并入。等同于数学里的并集就地更新(|=)。它在底层会一次性扫描传入的所有可迭代对象,在 C 语言级别连续执行哈希落座判定。
- add(element):
-
缩减与抹除阵营:指针打洞
- remove(element) / discard(element):
- 核心物理动作:和字典一样,为了保住 O(1) 的吞吐效率,删除元素时绝对不能引发数组指针的向前平移。
- 做法:通过 hash & mask 瞬间锁死格子,验证原始值匹配后,直接将对应的 me_key 改为一个特殊的 DUMMY(墓碑)指针。
- 两者的唯一区别:如果通过哈希找不到这个元素,remove 会在应用层抛出 KeyError 崩溃;而 discard 会在底层发现空槽后默默收工,假装什么都没发生,确保程序不崩溃。
- pop():由于 Set 是无序的,它的 pop() 是随机弹出一个元素。底层真相:CPU 并不是拿随机数去抽签,而是偷懒地直接从底层中间表数组的下标 0 开始往后扫,遇到第一个不是 NULL 且不是墓碑的有效 Entry,立刻把它挖掉(打上墓碑标记)并返回。之所以每次重启程序弹出的东西不一样,是因为“哈希随机化加盐”导致元素每次重启动作时的落座下标在变。
- clear():将 used 和 fill 清零,整个指针数组全部格式化填满 NULL。
- remove(element) / discard(element):






赣ICP备2025054460号-1