主题
第 2 章 基础语法与数据类型
学习目标:能在面试官面前准确说出「Python 6 大内置数据类型」的「可变性、可哈希性、底层结构」;能解释「
is和==的区别」、「dict 为什么 3.7 后变有序了」、「浅拷贝和深拷贝的坑」;能用 REPL 和dis验证「小整数缓存」「字符串驻留」等 CPython 实现细节。
2.1 概念引入:把数据类型看成「厨房储物方式」
2.1.1 一个生活类比:厨房里有 6 种存东西的容器
想象你刚搬进新家,要规划厨房怎么放食材。市面上的容器分两大派:
┌──────────────────────────────────────────────────────────────────┐
│ 不可变(贴了封条,重新做才能换内容) 可变(随时打开往里加) │
│ ───────────────────────────────── ───────────────── │
│ 📦 真空袋(int / float / str / tuple) 🥡 保鲜盒(list) │
│ 「装好就不动,做菜要用直接整袋拿」 「随时丢菜进去 / 拿出来」│
│ │
│ 📦 罐头(frozenset) 📋 调味盒(dict) │
│ 「永久封存,配料表写在外面」 「每个格子贴名字找东西」│
│ │
│ 🧂 调料瓶(set) │
│ 「只看是不是有,不要重」│
└──────────────────────────────────────────────────────────────────┘为什么要分这两类? 你买冰箱贴时,会写在「真空袋」上还是写在「保鲜盒」上?
答案是真空袋——因为内容不会变,写名字才有意义。Python 也一样:只有不可变对象才能做 dict 的 key、放进 set——这就是「可哈希」的本质。
💡 一句话记忆:不可变 = 可哈希 = 能当 dict 的 key。把这条钉在脑子里,数据类型选型 80% 的纠结就解决了。
2.1.2 技术定义
Python 的「内置数据类型」是 CPython 解释器在启动时就准备好的「7 种可哈希原子 + 4 种容器」:
| 维度 | 不可变(immutable) | 可变(mutable) |
|---|---|---|
| 数字 | int float complex bool Decimal | — |
| 文本 | str bytes | bytearray |
| 序列 | tuple | list |
| 集合 | frozenset | set |
| 映射 | — | dict |
🔍 CPython 源码视角:所有类型在 C 层面都是
PyObject*,前 16 字节是「引用计数 + 类型指针」。不同类型的差异从第 17 字节开始。第 11 章会展开。
2.2 数字类型:四种数你必须分清
2.2.1 int:任意精度整数(不会溢出)
python
>>> 2 ** 100
1267650600228229401496703205376
>>> 2 ** 1000 # 30 行长的整数也不会溢出
107150860718626732094842504906000181056140481170553360744375038...为什么 Python 的 int 不会像 C 那样溢出? 因为 CPython 不用 CPU 原生的 int32 / int64 存整数,而是用一个变长结构体:
PyLongObject {
ob_refcnt; // 引用计数
ob_type; // 指向 int 类型对象
ob_size; // 「位数」+ 符号(负数 ob_size 为负)
ob_digit[]; // 变长数组,每个元素存 30 bits 数字
}这就是为什么 2 ** 100 在 Python 里只是「ob_digit 数组多几个元素」而已。代价是:int 比 C 的 int64 慢 5~10 倍——所以科学计算用 numpy(C 层面是 int64)。
REPL 实操:
python
>>> import sys
>>> sys.getsizeof(0) # 一个 int 至少 24/28 字节
28
>>> sys.getsizeof(2 ** 30) # 不到 30 bits,仍然 28
28
>>> sys.getsizeof(2 ** 100) # 超过 30 bits,多了一组 digit
402.2.2 float:IEEE-754 双精度的「浮点陷阱」
python
>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False这不是 Python 的 bug,是 IEEE-754 的「物理限制」:0.1 在二进制下是无限循环(类比十进制下的 1/3 = 0.333...),存进 64 位浮点必有舍入误差。
💡 生活类比:你用 1 升的量杯量「1/3 升的酱油」,永远只能近似——到 0.333 升就停了。每次量都差一点点,量 3 次合起来不会等于 1 升。
正确比较 float:
python
>>> import math
>>> math.isclose(0.1 + 0.2, 0.3)
True
>>> math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9)
True2.2.3 bool 是 int 的子类(震惊!)
python
>>> isinstance(True, int)
True
>>> True + True
2
>>> sum([True, False, True, True]) # 这不是 hack,是合法 Python
3
>>> True == 1, False == 0
(True, True)设计原因:Python 早期版本没有 bool,是用 0/1 充当布尔的。Python 2.3 才加入 bool,为了向后兼容,把它做成了 int 的子类。
实战受益:统计列表里满足条件的元素个数,一行搞定:
python
>>> nums = [3, 7, 1, 9, 4, 6]
>>> sum(n > 5 for n in nums)
32.2.4 Decimal:金钱计算的救星
python
>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')
>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
True何时用 Decimal:金额、税率、利率、医学剂量等绝对不能误差的场景。代价是比 float 慢约 100 倍——所以科学计算还是用 float。
| 类型 | 精度 | 速度 | 适用 |
|---|---|---|---|
float | ~15 位 | 最快 | 科学/工程计算 |
Decimal | 任意 | 慢 100x | 金融/财务 |
Fraction | 精确分数 | 慢 | 数学符号计算 |
2.3 字符串:不可变 Unicode 序列
2.3.1 str vs bytes:人话 vs 机器码
str | bytes | |
|---|---|---|
| 存的是 | 字符(Unicode 码点) | 字节(0~255 数字) |
| 字面量 | '你好' | b'\xe4\xbd\xa0\xe5\xa5\xbd' |
| 长度 | len('你好') == 2(2 个字) | len(b'\xe4...') == 6(6 个字节) |
| 用于 | 程序内部、显示给人看 | 网络传输、文件读写 |
它们之间的桥梁是「编码」:
python
>>> '你好'.encode('utf-8') # str -> bytes
b'\xe4\xbd\xa0\xe5\xa5\xbd'
>>> b'\xe4\xbd\xa0\xe5\xa5\xbd'.decode('utf-8') # bytes -> str
'你好'
>>> b'\xe4\xbd\xa0'.decode('gbk') # 编码不对会乱码或报错
UnicodeDecodeError: 'gbk' codec can't decode byte ...💡 生活类比:
str像「中文菜单」,bytes像「条形码」。同一道菜("你好"),不同的「编码」(UTF-8 / GBK / GB2312)会生成不同的条形码。Python 不会替你猜编码,显式写.encode('utf-8')是不出错的唯一办法。
2.3.2 f-string 的 4 种姿势(3.6+ 必学)
python
>>> name, score = "小明", 92.5
>>> f"{name} 考了 {score} 分" # 基础
'小明 考了 92.5 分'
>>> f"{name} 考了 {score:.1f} 分" # 格式化(保留 1 位小数)
'小明 考了 92.5 分'
>>> f"{score=}" # 3.8+ 自动带变量名(debug 神器)
'score=92.5'
>>> f"{name!r}" # !r 自动调 repr()
"'小明'"| 格式 | 效果 |
|---|---|
f"{x}" | 普通插值 |
f"{x:.2f}" | 保留 2 位小数 |
f"{x:>10}" | 右对齐宽度 10 |
f"{x:,}" | 千分位(1234567 → 1,234,567) |
f"{x=}" | 3.8+:debug 利器 |
2.3.3 切片三参数:[start:stop:step]
切片是 Python 三大杀手锏之一。完整语法是 seq[start:stop:step],三个都可省略。
python
>>> s = "Python is fun"
>>> s[0:6] # 取索引 [0, 6)
'Python'
>>> s[7:] # start 省略 = 0
'is fun'
>>> s[::2] # step=2 隔一个取一个
'Pto sfn'
>>> s[::-1] # step=-1 反转!经典面试题
'nuf si nohtyP'
>>> s[-3:] # 负索引 = 倒数
'fun'⚠️ 切片不会越界:
'abc'[0:100]不会报错,返回'abc'。这和 list 切片一样。但 下标取单个元素会越界'abc'[100]→IndexError。
2.4 列表 list:动态数组
2.4.1 内存布局:为什么 append 是 O(1)?
底层是「动态数组」:CPython 给 list 多分配几个空槽(over-allocation),所以 append 只是填一个空槽,不需要重新分配。
扩容公式(CPython 源码 listobject.c):
new_allocated = (size_used >> 3) + (size_used < 9 ? 3 : 6) + size_used;简单说:每次容量大约增长 1.125 倍(不是 Java ArrayList 的 1.5 倍,更省内存)。
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
lst[i] | O(1) | 数组下标 |
lst.append(x) | O(1) 摊销 | 偶尔扩容才慢一次 |
lst.pop() | O(1) | 从尾部弹 |
lst.pop(0) | O(n) | 头部弹要搬全员!用 collections.deque |
lst.insert(0, x) | O(n) | 同上 |
x in lst | O(n) | 线性搜索 |
2.4.2 切片陷阱
python
>>> a = [1, 2, 3, 4, 5]
>>> b = a[:] # 浅拷贝(创建新 list)
>>> b is a
False
>>> b == a
True
>>> a[:2] = [99, 100, 200] # 切片赋值可以改变长度!
>>> a
[99, 100, 200, 3, 4, 5]2.4.3 * 复制陷阱(面试高频)
python
>>> matrix = [[0] * 3] * 3 # 看起来对,实际是大坑!
>>> matrix
[[0, 0, 0], [0, 0, 0], [0, 0, 0]]
>>> matrix[0][0] = 99
>>> matrix
[[99, 0, 0], [99, 0, 0], [99, 0, 0]] # 三行同时变了!为什么:[lst] * 3 创建的是 [lst, lst, lst]——3 个槽位指向同一个子列表!
正确写法:
python
>>> matrix = [[0] * 3 for _ in range(3)] # 列表推导式才会创建独立子列表
>>> matrix[0][0] = 99
>>> matrix
[[99, 0, 0], [0, 0, 0], [0, 0, 0]]2.5 元组 tuple:不可变 + 可哈希
2.5.1 一个常见的误解
python
>>> t = (1, 2, [3, 4])
>>> t[2].append(5) # tuple 不可变,但里面的 list 还是可变的!
>>> t
(1, 2, [3, 4, 5])
>>> hash(t) # 因为内部有 list,整个 tuple 不可哈希
TypeError: unhashable type: 'list'口诀:tuple 是「外壳不变」,不是「内容全部冻结」。
2.5.2 命名元组:dict 的「轻量替身」
python
>>> from collections import namedtuple
>>> Point = namedtuple('Point', ['x', 'y'])
>>> p = Point(3, 4)
>>> p.x, p.y
(3, 4)
>>> p[0], p[1] # 还能像普通 tuple 用下标
(3, 4)
>>> p._asdict() # 转 dict
{'x': 3, 'y': 4}何时用 namedtuple 替代 dict:
- 字段固定(不会动态加 key)
- 内存敏感(namedtuple 比 dict 省 ~50% 内存)
- 想要属性式访问(
p.x比d['x']漂亮)
3.6+ 推荐 dataclass(第 4 章会讲),更强大;但 namedtuple 仍是「轻量、高性能」的首选。
2.6 字典 dict:紧凑哈希表
2.6.1 一句话概括
dict 用哈希表实现,平均 O(1) 增删查改,3.7 起官方保证「插入有序」。
2.6.2 底层结构:3.6 的革命性优化
核心思想:把「哈希槽」和「数据」拆开存。
indices:稀疏的小数组,存 entries 的下标。容量 < 256 时每个槽只占 1 字节。entries:紧凑的大数组,按插入顺序追加(hash, key, value)。
收益:
- 省 30% 内存(稀疏槽只存「下标」,不再重复存大对象)
- 天然有序(entries 按插入顺序排列)
🎯 面试加分:3.6 在 CPython 实现时「顺便」做到了有序,3.7 才把它写进语言规范成为「保证特性」。问到这题,能说出「3.6 实现 + 3.7 规范」就是 100 分。
2.6.3 dict 常用操作
python
>>> d = {'name': 'Tom', 'age': 18}
>>> d.get('email', 'unknown') # 安全取值,无 key 不报错
'unknown'
>>> d.setdefault('city', 'BJ') # 没有则设默认值
'BJ'
>>> {**d, 'age': 19} # 解包合并(创建新 dict)
{'name': 'Tom', 'age': 19, 'city': 'BJ'}
>>> d | {'age': 20} # 3.9+ 的合并语法糖
{'name': 'Tom', 'age': 20, 'city': 'BJ'}
# 推导式
>>> {x: x**2 for x in range(5)}
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}2.7 集合 set / frozenset:去重利器
2.7.1 三大核心运算
python
>>> a = {1, 2, 3, 4}
>>> b = {3, 4, 5, 6}
>>> a | b # 并集(union)
{1, 2, 3, 4, 5, 6}
>>> a & b # 交集(intersection)
{3, 4}
>>> a - b # 差集(difference)
{1, 2}
>>> a ^ b # 对称差(symmetric_difference)
{1, 2, 5, 6}2.7.2 set 与 dict 是「亲兄弟」
set 底层也是哈希表(只有 key 没有 value)。所以:
x in s平均 O(1) → 判断「是否在大集合里」用 set 不要用 list- 元素必须可哈希(不能放 list)
- 无序(3.7 后 dict 有序,但 set 没有「插入有序」保证)
实战:从 100 万行日志里去重 IP,list 是 O(n²),set 是 O(n)。差距 1000 倍。
python
>>> ips = ['1.1.1.1', '2.2.2.2', '1.1.1.1']
>>> list(set(ips)) # 一行去重(顺序会乱)
['2.2.2.2', '1.1.1.1']
>>> list(dict.fromkeys(ips)) # 去重且保留顺序(推荐!)
['1.1.1.1', '2.2.2.2']2.8 可变 / 不可变 / 拷贝 / is vs ==
2.8.1 浅拷贝 vs 深拷贝
python
>>> import copy
>>> a = [[1, 2], [3, 4]]
>>> b = copy.copy(a) # 浅拷贝:外层新对象,内层共享
>>> b[0].append(99)
>>> a # a 也变了!
[[1, 2, 99], [3, 4]]
>>> c = copy.deepcopy(a) # 深拷贝:递归克隆所有层级
>>> c[0].append(88)
>>> a # a 不动
[[1, 2, 99], [3, 4]]记忆口诀:「浅只拷皮,深才入骨」。
3 种浅拷贝写法等价:
python
b = copy.copy(a)
b = a[:]
b = list(a)2.8.2 is vs == 与小整数缓存
python
# 小整数([-5, 256])走缓存池,is 也是 True
>>> a, b = 100, 100
>>> a is b
True
# 超出缓存范围,is 翻车!
>>> a, b = 1000, 1000
>>> a is b
False
>>> a == b
True铁律:
- 比值用
==(业务判断) - 判身份用
is,仅限单例:x is None、x is True、x is False - 永远不要用
is比数字、字符串、列表
⚠️ 教科书级反例:曾有团队代码
if status is 200(来自 Java 思维),开发机上恒为 True(小整数缓存),上线大流量后status走特殊解析逻辑生成新的 int 对象,结果偶发性 False,半夜紧急回滚。
2.9 本章小结
2.9.1 知识点速记
✓ 6 大内置类型:int / float / str / list / tuple / dict / set
✓ 不可变 = 可哈希 = 可做 dict key
✓ bool 是 int 子类,True == 1,可直接 sum
✓ float 是 IEEE-754,有舍入误差,比较用 math.isclose
✓ str 是 Unicode 字符序列,bytes 是字节序列,桥梁是 encode/decode
✓ list 是动态数组,append O(1);pop(0) 是 O(n)(陷阱)
✓ [[0]*3]*3 是 3 个共享引用(陷阱)
✓ tuple 不可变 + 可哈希(前提:内部都可哈希)
✓ dict 3.7+ 插入有序(语言规范保证),底层是紧凑哈希表
✓ 比值用 ==,判身份用 is,仅限 None/True/False
✓ 浅拷贝外层新、内层共享;深拷贝全部克隆2.9.2 下一步
- ✅ 跑一遍
code/types_demo.py,对照 REPL 输出理解每条 - ✅ 打开
demo.html玩 5 个交互演示 - ✅ 把本章末 6 道面试题默写一遍
- ➡️ 进入 第 3 章:控制流与函数
2.10 面试高频题
Q1. is 和 == 的区别?为什么 a is b 有时是 True 有时是 False?⭐⭐⭐
考察点:对象身份 vs 值相等、CPython 小整数缓存。
标准答案:
==比较值,本质是调用__eq__()。is比较身份,等价于id(a) == id(b),看是不是「同一个内存对象」。
二者结果不一致的典型场景:小整数缓存(-5 ~ 256)。CPython 在解释器启动时预创建了 [-5, 256] 范围内的所有 int 对象,所以:
python
>>> a, b = 100, 100
>>> a is b # True(都指向缓存对象)
>>> a, b = 1000, 1000
>>> a is b # False(两个独立对象)加分项:
- 提到
is推荐场景:只用于单例x is None / True / False - 提到字符串驻留(intern):编译期短字符串也走缓存
- 提到这是 CPython 的实现细节,不是语言规范,PyPy 行为可能不同
易错点:
- 别用
if x is 1、if s is 'abc'——能过测试但是脆弱代码 - 别说「
is比==快所以用 is」(思路错了)
Q2. dict 是怎么实现的?为什么 3.7 后变有序了?⭐⭐⭐⭐
考察点:哈希表底层 + 对 CPython 演进的理解。
标准答案:
dict 底层是哈希表,平均 O(1) 增删查改。3.6 之前用「单一稀疏数组」存 (hash, key, value),每个槽 24 字节,槽位按哈希值散布——无序、且大量空槽浪费内存。
3.6 起 CPython 引入「紧凑哈希表」(compact dict):
indices数组:稀疏的小数组,每槽只存 entries 的「下标」(容量 ≤ 128 时只占 1 字节)。entries数组:紧凑的大数组,按插入顺序追加(hash, key, value)。
带来两个收益:
- 节省约 30% 内存
- 天然保留插入顺序(因为 entries 是顺序追加)
3.6 是「实现细节」,3.7 把它写进 Python 语言规范,从此所有符合规范的 Python 实现都必须保证 dict 有序。
加分项:
- 提到设计者是 Raymond Hettinger,灵感来自 PyPy 早就这么做了
- 提到这让
OrderedDict在 3.7+ 失去主要意义(仍保留move_to_end等方法差异) - 提到 set 的底层也是哈希表,但 没有保证插入有序
易错点:
- 别说「dict 一直都是有序的」(错的,3.6 之前不是)
- 别和 Java 的 LinkedHashMap 混淆(实现思路不同)
Q3. [[0]*3]*3 和 [[0]*3 for _ in range(3)] 有什么区别?⭐⭐
考察点:可变对象的引用语义。
标准答案:
[[0]*3]*3是[lst, lst, lst]——3 个槽位指向同一个内层 list。改一个,三个一起变。[[0]*3 for _ in range(3)]在循环中每次创建新的[0,0,0],得到 3 个独立对象。
python
>>> a = [[0]*3]*3
>>> a[0][0] = 9
>>> a
[[9, 0, 0], [9, 0, 0], [9, 0, 0]] # 全变了
>>> b = [[0]*3 for _ in range(3)]
>>> b[0][0] = 9
>>> b
[[9, 0, 0], [0, 0, 0], [0, 0, 0]] # 只动一个加分项:
- 提到「
*是浅拷贝引用」的本质 - 提到 numpy 的
np.zeros((3,3))不会有这问题(连续内存) - 提到
copy.deepcopy(a)可以「修复」
易错点:
- 别说「Python 的 bug」,这是引用语义的必然
[0]*3不会有这个问题(int 是不可变,无法被改)
Q4. 字符串拼接用 + 还是 ''.join()?⭐⭐⭐
考察点:对 str 不可变性的理解 + 性能直觉。
标准答案:
由于 str 不可变,每次 s = s + 'x' 都会创建新对象:
python
# 拼接 N 次的复杂度
s = ''
for x in arr: # arr 长度 N
s += x # 每次复制全部已有字符 -> 总 O(N²)str.join() 一次性分配大小合适的 buffer,把所有片段拷过去:
python
s = ''.join(arr) # 总 O(N)实测:拼接 10 万个字符串,+= 用 200ms,join 用 4ms(50 倍差距)。
加分项:
- 提到 CPython 在某些情况会优化连续
+=(PEP 393 后的小优化),但不要依赖 - 提到
io.StringIO也是高效拼接方案 - 提到 f-string 比
%和.format()都快
易错点:
- 别说「Python 拼接很慢」,短字符串少量拼接
+完全够用,可读性更好 ''.join只接受可迭代的 str 元素,元素是 int 会报错
Q5. tuple 是不可变的,那为什么 (1, 2, [3]) 还能改?⭐⭐⭐
考察点:理解「不可变」是「对象引用不可变」,不是「内容不可变」。
标准答案:
tuple 的「不可变」指的是内部存的「对象引用列表」长度和内容不可变——但引用所指向的对象仍可能是可变的。
python
>>> t = (1, 2, [3, 4])
>>> t[2].append(5) # 在 list 自身上操作,不改 tuple 的引用
>>> t
(1, 2, [3, 4, 5]) # tuple 的 ob_item[2] 仍是同一个 list 对象
>>> t[2] = [] # 这才会报错(动了 tuple 的「引用槽」)
TypeError: 'tuple' object does not support item assignment因此:含有可变对象的 tuple 不可哈希:
python
>>> hash((1, 2, [3]))
TypeError: unhashable type: 'list'加分项:
- 提到这就是「深不可变」(deep immutable)vs「浅不可变」(shallow immutable)
- 提到
frozenset、namedtuple也是浅不可变 - 提到生产代码可以用
MappingProxyType让 dict 看起来不可变
易错点:
- 别说「tuple 一旦创建就完全不能改」(不严谨)
- 别说「tuple 可以哈希」(要看里面装了啥)
Q6. 浅拷贝和深拷贝的区别?什么时候必须用深拷贝?⭐⭐⭐
考察点:对引用 / 共享内存的理解。
标准答案:
浅拷贝 copy.copy | 深拷贝 copy.deepcopy | |
|---|---|---|
| 外层 | 创建新对象 | 创建新对象 |
| 内层(嵌套) | 共享原对象的引用 | 递归克隆每一层 |
| 性能 | 快 | 慢(递归 + 维护已访问对象表防循环) |
python
>>> import copy
>>> a = [[1, 2], [3, 4]]
>>> b = copy.copy(a) # 浅
>>> b[0].append(99)
>>> a # a 也变了
[[1, 2, 99], [3, 4]]
>>> c = copy.deepcopy(a) # 深
>>> c[0].append(88)
>>> a
[[1, 2, 99], [3, 4]] # a 不动必须用深拷贝的场景:
- 嵌套结构(list 嵌套 list、dict 嵌套 list 等)需要完全独立
- 给函数传可变参数,又不希望函数修改外部状态
- 需要保留对象的「历史快照」用于回溯
加分项:
- 提到 deepcopy 内部维护「memo dict」处理循环引用(
a = []; a.append(a)) - 提到 deepcopy 比 shallow 慢 10~100 倍,能不用就不用
- 提到
pickle.loads(pickle.dumps(obj))也能实现深拷贝(且能跨进程传输)
易错点:
- 别以为
b = a[:]是深拷贝(它是浅拷贝,等价于copy.copy(a)) - 别忽视循环引用,不带 memo 的递归会无限递归爆栈
📚 延伸阅读
- Python 数据模型官方文档
- PEP 468 - Preserving Keyword Argument Order
- PEP 3101 - Advanced String Formatting
- Raymond Hettinger - Modern Python Dictionaries (PyCon 2017)
- 《流畅的 Python》第 2、3 章(数据序列、字典与集合)
- 《CPython Internals》第 4 章(变量与对象内存模型)