Skip to content

Commit f9263f3

Browse files
committed
docs: add the string object chapter, covering its core capabilities
Write the str (PyUnicodeObject) chapter from scratch, 深入浅出 and fully illustrated, with every source claim verified against CPython v3.7.0: - PEP 393 flexible representation (1/2/4 bytes per char, chosen by the widest code point), the PyASCIIObject layer hierarchy and the compact one-block layout - core capabilities: creation (decode UTF-8), concatenation (the s += t in-place optimization and why ''.join() is the reliable choice), search (stringlib FASTSEARCH: Boyer-Moore/Horspool + bloom filter), and encode/decode with the cached utf8 field - interning and hash caching enabled by immutability - five SVG diagrams (kinds / struct / intern / concat / encode); mark the chapter done in the roadmap
1 parent 2700f16 commit f9263f3

7 files changed

Lines changed: 588 additions & 1 deletion

File tree

index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
- [ ] Python 内建对象
1919
- [x] Python 对象初探
2020
- [x] Python 整数对象
21-
- [ ] Python 字符串对象
21+
- [x] Python 字符串对象
2222
- [x] Python 列表对象
2323
- [x] Python 字典对象
2424
- [x] Python 集合对象

objects/str-object/index.md

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,351 @@
11
# Python 字符串对象
2+
3+
字符串是日常用得最多的类型之一。Python 3 的 `str` 有两个鲜明特点:它是**不可变**的,而且是**完整的 Unicode 序列**——能装下从英文字母到中文、再到 emoji 的任何字符。
4+
5+
```python
6+
>>> s = "abc中文😀"
7+
>>> len(s) # 6 个「字符」(码点),而不是字节数
8+
6
9+
>>> s[3] # 按下标取第 3 个字符,瞬间返回
10+
''
11+
```
12+
13+
这里藏着一个设计难题:Unicode 码点的范围是 U+0000 到 U+10FFFF。如果每个字符都用固定的 4 字节存,纯英文文本会浪费 3/4 的内存;如果用变长的 UTF-8 存,按下标取第 n 个字符又会退化成 O(n)(得从头数)。Python 3 的解法是 PEP 393 提出的**灵活字符串表示**。这一章我们就来看 `PyUnicodeObject` 是怎么实现的。
14+
15+
## 灵活的内部表示:PEP 393
16+
17+
PEP 393 的核心思想是:**看字符串里最宽的那个字符,整串统一用 1、2 或 4 字节来存每个字符**
18+
19+
- 全是 ASCII / Latin-1 字符(U+0000–U+00FF)→ 每字符 **1 字节**
20+
- 含有 BMP 字符(最大到 U+FFFF,如多数汉字)→ 每字符 **2 字节**
21+
- 含有星位字符(U+10000 以上,如 emoji)→ 每字符 **4 字节**
22+
23+
这套规则就写在创建字符串的 `PyUnicode_New` 里——它根据传入的 `maxchar`(串中最大码点)选择存储宽度:
24+
25+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L1252)
26+
27+
```c
28+
// Objects/unicodeobject.c —— PyUnicode_New
29+
if (maxchar < 128) {
30+
kind = PyUnicode_1BYTE_KIND; char_size = 1; is_ascii = 1; // 纯 ASCII
31+
}
32+
else if (maxchar < 256) {
33+
kind = PyUnicode_1BYTE_KIND; char_size = 1; // Latin-1
34+
}
35+
else if (maxchar < 65536) {
36+
kind = PyUnicode_2BYTE_KIND; char_size = 2; // BMP(UCS-2)
37+
}
38+
else {
39+
// maxchar <= 0x10FFFF
40+
kind = PyUnicode_4BYTE_KIND; char_size = 4; // 全 Unicode(UCS-4)
41+
}
42+
```
43+
44+
这个 `kind`(1 / 2 / 4)是字符串表示的灵魂。它带来一个直接可观测的现象——同一个字符,放在不同的串里可能占不同的字节数:
45+
46+
![PEP 393 三种字符宽度](str-kinds.svg)
47+
48+
我们可以用「每多一个字符增加多少字节」来反推 `char_size`
49+
50+
```python
51+
>>> import sys
52+
>>> def per_char(s):
53+
... return sys.getsizeof(s * 2) - sys.getsizeof(s)
54+
...
55+
>>> per_char("a") # 纯 ASCII
56+
1
57+
>>> per_char("") # BMP 汉字
58+
2
59+
>>> per_char("😀") # 星位 emoji
60+
4
61+
```
62+
63+
注意 `kind`**最宽的字符**决定:哪怕只夹了一个 emoji,整串都会升到 4 字节。所以 `"a😀b"` 里本可用 1 字节存的 `a``b`,也按 4 字节存。
64+
65+
这套「定宽」存储的好处是,**按下标取字符是 O(1)**:第 `i` 个字符就在 `数据起始 + i × char_size` 处,一步算出地址。而且这里的「下标」是按**码点**算的,不是 UTF-8 字节、也不是 UTF-16 码元:
66+
67+
```python
68+
>>> s = "a😀b"
69+
>>> len(s) # 3 个码点(UTF-16 下 😀 会算成 2,这里不会)
70+
3
71+
>>> s[1] # 直接定位到第 1 个码点
72+
'😀'
73+
```
74+
75+
## 数据结构
76+
77+
字符串对象有三层结构,一层比一层多。最基础的是 `PyASCIIObject`
78+
79+
`源文件:`[Include/unicodeobject.h](https://github.com/python/cpython/blob/v3.7.0/Include/unicodeobject.h#L271)
80+
81+
```c
82+
// Include/unicodeobject.h
83+
typedef struct {
84+
PyObject_HEAD
85+
Py_ssize_t length; // 码点个数,即 len(s)
86+
Py_hash_t hash; // 缓存的哈希值,-1 表示尚未计算
87+
struct {
88+
unsigned int interned:2; // 驻留状态(见下)
89+
unsigned int kind:3; // 字符宽度:1 / 2 / 4 字节
90+
unsigned int compact:1; // 结构体与数据是否同在一块内存
91+
unsigned int ascii:1; // 是否全为 ASCII
92+
unsigned int ready:1; // 布局是否就绪
93+
} state;
94+
wchar_t *wstr; // 兼容旧 wchar_t API 的表示
95+
} PyASCIIObject;
96+
```
97+
98+
短短几个字段里,`state` 这个**位域**最关键——它用几个比特就记下了字符串的全部「身份信息」:是否驻留、每字符几字节(`kind`)、是否 compact、是否纯 ASCII、布局是否就绪。
99+
100+
另外两层是在它基础上的扩展(非 ASCII 串会多出 UTF-8 缓存等字段):
101+
102+
`源文件:`[Include/unicodeobject.h](https://github.com/python/cpython/blob/v3.7.0/Include/unicodeobject.h#L336)
103+
104+
```c
105+
// Include/unicodeobject.h
106+
typedef struct {
107+
PyASCIIObject _base;
108+
Py_ssize_t utf8_length; // utf8 表示的字节数
109+
char *utf8; // 按需缓存的 UTF-8 表示
110+
Py_ssize_t wstr_length;
111+
} PyCompactUnicodeObject;
112+
113+
typedef struct {
114+
PyCompactUnicodeObject _base;
115+
union { void *any; Py_UCS1 *latin1; Py_UCS2 *ucs2; Py_UCS4 *ucs4; } data;
116+
} PyUnicodeObject;
117+
```
118+
119+
这里还有一个省内存的关键设计:**compact**。对于常见的字符串,CPython 把**对象头和字符数据放在同一块连续内存里**——`PyUnicode_New` 一次就申请 `结构体大小 + (字符数 + 1) × char_size` 的空间,字符数据紧跟在结构体后面(末尾留一个 `\0`):
120+
121+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L1293)
122+
123+
```c
124+
// Objects/unicodeobject.c —— PyUnicode_New
125+
obj = (PyObject *) PyObject_MALLOC(struct_size + (size + 1) * char_size);
126+
```
127+
128+
这样只需一次内存分配(而不是「结构体一块、数据另一块」),而且数据紧挨着对象头,对 CPU 缓存更友好。以纯 ASCII 字符串 `"abc"` 为例,它的内存布局是这样的:
129+
130+
![compact ASCII 字符串内存布局](str-struct.svg)
131+
132+
## 字符串的创建
133+
134+
我们在源码里写下的字符串字面量、或在 C 层从一个 C 字符串构造 `str`,最终都殊途同归——**解码**。看从 C 字符串创建字符串的入口:
135+
136+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L2084)
137+
138+
```c
139+
// Objects/unicodeobject.c
140+
PyObject *
141+
PyUnicode_FromStringAndSize(const char *u, Py_ssize_t size)
142+
{
143+
......
144+
if (u != NULL)
145+
return PyUnicode_DecodeUTF8Stateful(u, size, NULL, NULL); // 把 UTF-8 字节解码成码点
146+
else
147+
return (PyObject *)_PyUnicode_New(size);
148+
}
149+
```
150+
151+
也就是说,「创建一个字符串」本质上是**把 UTF-8 字节解码成码点序列**,再由 `PyUnicode_New` 按 PEP 393 选好 `kind`、分配 compact 内存存进去。Python 源文件默认就是 UTF-8 编码,里面的字符串字面量也是这样被解码、构造出来的。
152+
153+
## 字符串的拼接
154+
155+
用 `+` 拼接两个字符串(`PyUnicode_Concat`)总是**新建**一个字符串。但 `s += t` 这种写法,CPython 藏了一个优化。
156+
157+
回想字符串是不可变的——可一旦某个字符串**只被唯一引用**(没人会察觉它被改动),原地修改就是安全的。`s += t` 正是利用了这点。先看 `s += t` 在虚拟机里的处理:
158+
159+
`源文件:`[Python/ceval.c](https://github.com/python/cpython/blob/v3.7.0/Python/ceval.c#L4977)
160+
161+
```c
162+
// Python/ceval.c —— unicode_concatenate
163+
if (Py_REFCNT(v) == 2) {
164+
/* 通常 += 时,这个字符串有 2 个引用:求值栈上一个、变量里一个。
165+
这里先把变量删掉,让引用计数降到 1。 */
166+
......
167+
SETLOCAL(oparg, NULL); // 删掉变量的引用
168+
......
169+
}
170+
```
171+
172+
引用计数降到 1 后,`PyUnicode_Append` 就能走**原地追加**
173+
174+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L11262)
175+
176+
```c
177+
// Objects/unicodeobject.c —— PyUnicode_Append
178+
if (unicode_modifiable(left) // 可原地修改吗?
179+
&& PyUnicode_CheckExact(right)
180+
&& PyUnicode_KIND(right) <= PyUnicode_KIND(left)
181+
&& !(PyUnicode_IS_ASCII(left) && !PyUnicode_IS_ASCII(right)))
182+
{
183+
/* append inplace —— 原地扩展,把 right 拷到 left 末尾 */
184+
if (unicode_resize(p_left, new_len) != 0) goto error;
185+
_PyUnicode_FastCopyCharacters(*p_left, left_len, right, 0, right_len);
186+
}
187+
else {
188+
...... // 否则:新建一个字符串
189+
}
190+
```
191+
192+
什么叫「可原地修改」?看 `unicode_modifiable` 的判断——必须**引用计数为 1、尚未算过哈希、未被驻留、是精确的 str 类型**
193+
194+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L1827)
195+
196+
```c
197+
// Objects/unicodeobject.c
198+
static int
199+
unicode_modifiable(PyObject *unicode)
200+
{
201+
if (Py_REFCNT(unicode) != 1) return 0; // 还有别人引用
202+
if (_PyUnicode_HASH(unicode) != -1) return 0; // 哈希已被缓存,改了会失效
203+
if (PyUnicode_CHECK_INTERNED(unicode)) return 0; // 已驻留,是共享的
204+
if (!PyUnicode_CheckExact(unicode)) return 0; // 子类,行为未知
205+
return 1;
206+
}
207+
```
208+
209+
![字符串 += 的两种结果](str-concat.svg)
210+
211+
**但请注意:这只是 CPython 的实现细节,并不保证触发**——只要字符串被多处引用、已经算过哈希、或被驻留,就会回退到新建。所以在循环里反复 `s += ...` 仍可能退化成 O(n²)。可靠的做法是把片段收集起来,最后用 `''.join()` 一次拼好:它会**先算出总长度、一次性分配**,再把各片段依次拷入,稳定 O(n)。
212+
213+
```python
214+
>>> "-".join(["a", "b", "c"])
215+
'a-b-c'
216+
```
217+
218+
## 字符串的查找
219+
220+
`s.find(sub)``sub in s``s.replace(...)` 这些都依赖同一套子串查找。它们最终调用到 stringlib 的 **`FASTSEARCH`**
221+
222+
`源文件:`[Objects/stringlib/fastsearch.h](https://github.com/python/cpython/blob/v3.7.0/Objects/stringlib/fastsearch.h#L5)
223+
224+
```c
225+
// Objects/stringlib/fastsearch.h
226+
/* fast search/count implementation, based on a mix between boyer-
227+
moore and horspool, with a few more bells and whistles on the top. */
228+
```
229+
230+
它是 **Boyer-Moore / Horspool 的混合算法**:预处理模式串,匹配失败时根据「坏字符」一次性**跳过**一大段、而不是逐位回退;并用一个 **Bloom 过滤器**(位掩码)快速判断某个字符是否出现在模式串里,进一步加速跳跃。单字符查找则直接走 `memchr` 快路径。平均下来,查找远快于「逐位比对」的朴素做法。
231+
232+
```python
233+
>>> "hello world".find("world")
234+
6
235+
>>> "world" in "hello world"
236+
True
237+
>>> "hello world".find("xyz") # 找不到返回 -1
238+
-1
239+
```
240+
241+
## 编码与解码
242+
243+
`str` 存的是**码点**(内部按 PEP 393 定宽存储),而 `bytes` 存的是**字节**。两者之间靠 `encode` / `decode` 转换:
244+
245+
![str 与 bytes 的编码解码](str-encode.svg)
246+
247+
```python
248+
>>> "café".encode("utf-8") # str → bytes
249+
b'caf\xc3\xa9'
250+
>>> b"caf\xc3\xa9".decode("utf-8") # bytes → str
251+
'café'
252+
>>> len("café"), len("café".encode("utf-8")) # 码点数 vs 字节数
253+
(4, 5)
254+
```
255+
256+
`"café"` 有 4 个码点,编码成 UTF-8 是 5 个字节(`é` 占 2 字节)。前面 `PyUnicode_FromString` 创建字符串走的就是 `decode` 的反向。
257+
258+
C 层经常需要一个字符串的 UTF-8 表示(比如把字符串当文件名、传给操作系统)。为避免重复编码,CPython 会把 UTF-8 结果**缓存**到对象的 `utf8` 字段里,下次直接复用:
259+
260+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L3781)
261+
262+
```c
263+
// Objects/unicodeobject.c —— PyUnicode_AsUTF8AndSize
264+
if (PyUnicode_UTF8(unicode) == NULL) { // 尚未缓存
265+
...... // 编码成 UTF-8
266+
_PyUnicode_UTF8(unicode) = ...; // 存进 utf8 字段
267+
}
268+
return PyUnicode_UTF8(unicode); // 返回缓存
269+
```
270+
271+
对于纯 ASCII 字符串,ASCII 本身就是合法的 UTF-8,所以它的 `utf8` 直接和字符数据共享、无需另存——这也是 ASCII 字符串用最省的 `PyASCIIObject` 结构的原因之一。
272+
273+
## 字符串的驻留
274+
275+
先看一个常见现象:
276+
277+
```python
278+
>>> a = "foo"
279+
>>> b = "foo"
280+
>>> a is b # 两个 "foo" 竟是同一个对象
281+
True
282+
```
283+
284+
明明是两次写 `"foo"`,为什么 `a``b` 是同一个对象?这就是**字符串驻留(interning)**:CPython 维护一个全局的 `interned` 字典,把驻留过的字符串去重,等值的字符串只保留**一个**对象。
285+
286+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L15174)
287+
288+
```c
289+
// Objects/unicodeobject.c —— PyUnicode_InternInPlace
290+
if (interned == NULL) {
291+
interned = PyDict_New(); // 全局唯一的驻留字典
292+
......
293+
}
294+
t = PyDict_SetDefault(interned, s, s); // 字典里已有等值串就返回它,否则存入 s
295+
......
296+
if (t != s) {
297+
Py_INCREF(t);
298+
Py_SETREF(*p, t); // 已存在 → 改用已有对象,丢弃 s
299+
return;
300+
}
301+
_PyUnicode_STATE(s).interned = SSTATE_INTERNED_MORTAL; // 标记为已驻留
302+
```
303+
304+
逻辑很直白:拿字符串去 `interned` 字典里查,已有等值的就复用那一个、丢弃新的;没有就存进去并打上驻留标记。
305+
306+
![字符串驻留](str-intern.svg)
307+
308+
驻留的好处是**比较变快**:变量名、属性名、字典的字符串键……这些标识符在程序里反复出现,驻留后它们的相等比较可以直接比指针(同一对象),不必逐字符对比。所以 CPython 会**自动驻留**那些「长得像标识符」的字符串常量(只含字母、数字、下划线),上面的 `"foo"` 正是如此。
309+
310+
对于不会自动驻留的字符串(比如带空格、标点的),可以用 `sys.intern()` 手动驻留:
311+
312+
```python
313+
>>> import sys
314+
>>> sys.intern("hi there!") is sys.intern("hi there!")
315+
True
316+
```
317+
318+
> 和小整数池类似,`a = "foo"; b = "foo"` 这种演示也要留意**编译期常量折叠**:同一个代码块里相同的字面量可能本就被折叠成一个对象。要稳定地验证「驻留」本身,用 `sys.intern()` 最可靠。
319+
320+
## 不可变与哈希缓存
321+
322+
`str`**不可变**的——一旦创建,内容就不会变。这不是个限制,而是很多便利的前提:正因为不可变,等值的字符串才能放心地共享同一对象(驻留),字符串才能作为字典的键。
323+
324+
不可变还带来一个优化:**哈希只需算一次**。回看结构体里的 `hash` 字段,它初始为 -1;第一次求哈希时算出来并缓存进去,以后直接返回缓存值:
325+
326+
`源文件:`[Objects/unicodeobject.c](https://github.com/python/cpython/blob/v3.7.0/Objects/unicodeobject.c#L11572)
327+
328+
```c
329+
// Objects/unicodeobject.c —— unicode_hash
330+
if (_PyUnicode_HASH(self) != -1)
331+
return _PyUnicode_HASH(self); // 已算过,直接返回缓存
332+
......
333+
x = _Py_HashBytes(PyUnicode_DATA(self),
334+
PyUnicode_GET_LENGTH(self) * PyUnicode_KIND(self));
335+
_PyUnicode_HASH(self) = x; // 算一次,存进 hash 字段
336+
return x;
337+
```
338+
339+
字符串频繁用作字典键、集合元素,哈希被反复用到,这个缓存省下了大量重复计算。
340+
341+
---
342+
343+
小结一下字符串对象的要点:
344+
345+
- `str` 是不可变的 Unicode 序列,内部采用 PEP 393 的**灵活表示**:按串中最宽的字符,统一用 **1 / 2 / 4 字节**存每个字符,既省内存又让按下标取字符保持 **O(1)**;
346+
- 结构上以 `PyASCIIObject` 为基础逐层扩展,`state` 位域记录 `kind`、`ascii`、`compact`、`interned` 等身份信息;常见字符串采用 **compact** 布局,对象头与字符数据共用一块内存;
347+
- **创建**字符串本质是把 UTF-8 字节**解码**成码点;**编码/解码**在 `str`(码点)与 `bytes`(字节)间转换,UTF-8 结果会缓存到 `utf8` 字段;
348+
- **拼接**:`+` 总是新建;`s += t` 在唯一引用时可原地扩展(CPython 实现细节,不保证),循环拼接应用 `''.join()` 获得稳定 O(n);
349+
- **查找**用 stringlib 的 `FASTSEARCH`(Boyer-Moore / Horspool 混合 + Bloom 过滤器),靠跳跃式匹配远快于逐位比对;
350+
- **驻留**机制让等值字符串(尤其是标识符)共享同一对象,使比较可走指针相等;
351+
- 不可变性使字符串可被驻留、可作字典键,并让**哈希得以缓存**(`hash` 字段,初始 -1)。

0 commit comments

Comments
 (0)