主题
字符串与编码
在前面我们探讨了基本张量数据类型,现在来认识一个在人工智能和大模型开发中无处不在的数据类型——字符串。如果你要从事大模型相关的工作,字符串几乎是你每天都要打交道的数据类型:
- 提示词是字符串
- API 的请求体是字符串
- 模型返回的结果是字符串
- 日志记录的是字符串
- JSON本质上也是字符串
在 Python 中,字符串就是用引号括起来的文本序列:
python
greeting = "你好,世界" # 双引号
name = 'Alice' # 单引号也行
multi = """这是
多行字符串""" # 三引号支持换行但很多人对字符串的理解只停留在"文本"这个层面,一旦遇到乱码、编码报错、签名校验失败、跨语言数据传输这些实际问题,就完全不知道从何下手。
要解决这些问题,必须深入理解字符串在 Python 中的本质,以及字符编码背后那些看似神秘但其实很有规律的机制。
从字符到数字:编码的本质
在说字符串之前,先要理解一个更基础的问题:计算机是如何表示字符的?
我们都知道计算机内部只有 0 和 1,一切信息在底层都是二进制数字。那么问题来了:字符"A"在计算机里是怎么表示的?答案是:为每一个字符分配一个唯一的数字编号。这个分配规则就是"编码"。
最早也是最简单的编码方案叫 ASCII。ASCII 用 7 位二进制来表示一个字符,一共可以表示 128 个不同的字符。这 128 个位置涵盖了英文字母的大小写、数字、标点符号以及一些控制字符。比如大写字母 A 的编号是 65,二进制是 1000001;小写字母 a 的编号是 97。可以用 Python 代码来验证这个映射关系:
python
print(ord("A")) # 65
print(chr(65)) # 'A'ord 函数返回字符对应的编号,chr 函数则相反,根据编号返回字符。这两个函数揭示了字符和数字之间最本质的映射关系。当你在代码里写下一个字符串的时候,Python 内部其实在存储一串数字编号。
ASCII 的设计是合理的,但它只覆盖了英文字母和少量符号。当计算机需要处理其他语言的文字时,128 个位置显然不够用了。于是各个国家开始制定自己的编码方案,比如中国的 GB2312、日本的 Shift-JIS、繁体中文的 Big5 等等。这些编码方案都基于同一个思想:为各自的字符分配编号。但问题随之而来,同一个数字编号在不同编码方案下可能对应完全不同的字符。比如某个字节序列在 GB2312 下是"中文",在 Big5 下可能显示为乱码。这就是早期跨语言文本处理中乱码问题的根源。
Unicode:统一天下的编号方案
乱码问题的根本原因是编码方案不统一。为了解决这个问题,国际标准化组织推出了 Unicode。Unicode 的核心思想很简单:为全世界所有字符分配一个唯一的编号。无论你用的是中文、日文还是阿拉伯文,每个字符在 Unicode 中都有且只有一个编号。
Unicode 为每个字符分配了一个叫做码点的编号,格式是 U+XXXX,比如:
python
print(ord("A")) # 65,对应 U+0041
print(ord("中")) # 20013,对应 U+4E2D
print(ord("😊")) # 128522,对应 U+1F60AU+0041 就是 A 的 Unicode 码点,U+4E2D 是中文"中"的码点,U+1F60A 则是表情符号的码点。通过这种方式,全世界的字符终于有了一套统一的编号规则。
但 Unicode 只解决了"编号"问题,并没有规定"如何存储这些编号"。这就是 UTF-8、UTF-16、UTF-32 等编码方案存在的意义。其中 UTF-8 是目前互联网最广泛使用的编码方式,它有以下几个特点:英文字符只需要 1 个字节存储,和 ASCII 完全兼容;中文等字符通常用 3 个字节存储;字符占用的字节数是可变的,频繁使用的字符用较少的字节,不常用的字符用较多的字节。可以用代码验证:
python
print("中".encode("utf-8"))当执行 "中".encode("utf-8") 时,返回的是一串字节 \xe4\xb8\xad,这正是"中"字的 UTF-8 编码。
Python 3 的字符串:Unicode 字符序列
Python 3 中的字符串是 Unicode 字符序列,这意味当你写下一行代码 s = "你好" 时,Python 内部存储的是这两个汉字对应的 Unicode 码点序列,而不是任何特定的字节序列。只有在特定场景下,比如写入文件、发送网络请求、调用某些底层接口时,字符串才会被转换成字节序列,而这个转换过程必须指定具体的编码方式,通常默认是 UTF-8。
字符串在 Python 中有一个核心特性:是不可变对象。这意味对字符串的任何"修改"操作,实际上都是创建了一个新的字符串对象。举例来说:
python
s = "hello"
s = s + " world"第二行并不是在原来的字符串后面追加内容,而是在内存中创建了一个全新的字符串对象 "hello world",然后让 s 指向这个新对象。原来的 "hello" 如果没有其他变量引用它,就会被垃圾回收器清理掉。
字符串不可变性看似是一种限制,但实际上带来了三个重要好处。第一是安全性:字符串经常被用作字典的键和集合的元素,如果字符串可以被随意修改,那么以它为键存储的数据就会丢失或者行为异常。不可变性保证了字符串在任何场景下都可以安全使用。第二是可哈希:由于字符串的值永远不会变,它的哈希值也是固定的,这使得字符串可以作为字典的键使用。第三是性能优化:Python 可以在内部对字符串进行驻留优化,让内容相同的字符串字面量共享同一个对象,既节省内存又加快比较速度。
str 与 bytes:文本与二进制
在 Python 中,字符串 str 和字节序列 bytes 是两种完全不同的数据类型,它们之间有明确的界限。str 用来表示文本,bytes 用来表示二进制数据。当需要在两者之间转换时,必须显式指定编码方式:
python
s = "你好"
b = s.encode("utf-8") # str -> bytes
print(b) # b'\xe4\xb8\xad\xe5\xa5\xbd'
s2 = b.decode("utf-8") # bytes -> str
print(s2) # 你好网络传输的本质是字节流。当你的 Python 程序通过 HTTP 请求发送数据,或者接收 API 返回的结果时,传输的都是字节序列。如果发送端用 UTF-8 编码,接收端必须用同样的 UTF-8 解码,否则就会出现乱码或者解析错误。这就是为什么在处理网络请求时,明确指定编码方式是如此重要。
还有一个容易踩的坑:读取文件时的编码问题。当用 open 函数打开文件时,如果不指定 encoding 参数,Python 会使用平台默认编码。在 Windows 上默认编码可能是 GBK,在 Linux 和 macOS 上通常是 UTF-8。这就导致同一个程序在不同操作系统上可能产生不同的结果:
python
# 明确指定 UTF-8 编码读取
with open("data.txt", "r", encoding="utf-8") as f:
content = f.read()
# 写入时同样要明确指定编码
with open("output.txt", "w", encoding="utf-8") as f:
f.write(content)显式指定编码虽然看起来麻烦,但这是避免跨平台兼容性问题的最佳实践。
字符串的常用操作
字符串作为最常用的数据类型之一,Python 为它提供了丰富的方法。理解这些方法的关键在于记住一点:字符串是不可变对象,所以任何"修改"操作都返回一个新的字符串,原字符串保持不变。
首先是拼接操作。少量字符串的拼接可以直接用加号:
python
greeting = "Hello" + ", " + "Alice"但如果在循环中频繁拼接字符串,加号的效率会变得很低,因为每次拼接都会创建新的字符串对象。正确的做法是先把所有部分收集到列表中,最后用 join 方法合并:
python
parts = ["Hello", "from", "Python", "world"]
message = " ".join(parts)join 方法只会创建一次新字符串,效率远高于循环中的加号拼接。
查找和替换是另一个常用场景:
python
text = "Hello, world. Welcome to Python."
print(text.find("world")) # 7,返回子串的起始位置,找不到返回 -1
print(text.index("world")) # 7,与 find 类似,但找不到会抛出异常
print(text.count("o")) # 3,统计出现次数
print(text.replace("world", "Python")) # Hello, Python. Welcome to Python.注意 find 和 index 的区别:find 找不到时返回 -1,index 则会抛出 ValueError。在不确定字符串是否存在时,用 find 更安全。
大小写转换也是高频操作:
python
text = "Hello, World!"
print(text.upper()) # HELLO, WORLD!
print(text.lower()) # hello, world!
print(text.capitalize()) # Hello, world!
print(text.title()) # Hello, World!切片操作在处理文本时非常强大:
python
s = "Hello, world!"
print(s[0:5]) # Hello,取索引 0 到 4
print(s[7:]) # world!,从索引 7 到末尾
print(s[::2]) # Hlo ol!,步长为 2
print(s[::-1]) # !dlrow ,olleH,字符串反转切片语法的完整形式是 s[start:end:step],其中 start 是起始索引(包含),end 是结束索引(不包含),step 是步长。省略 start 默认从 0 开始,省略 end 默认到末尾,省略 step 默认是 1。
字符串格式化的演进
Python 提供了多种字符串格式化的方式,随着版本演进,这些方式也在不断优化。
最古老的方式是百分号格式化:
python
name = "Alice"
age = 25
print("Name: %s, Age: %d" % (name, age))这种方式来源于 C 语言的 printf 风格,对于简单的格式化还算清晰,但涉及到多个变量或者复杂格式时就变得难以阅读。
后来 Python 2.6 引入了 str.format 方法:
python
print("Name: {}, Age: {}".format(name, age))
print("Name: {0}, Age: {1}".format(name, age))
print("Name: {name}, Age: {age}".format(name="Alice", age=25))format 方法的优势是可以使用位置参数或者关键字参数,灵活度更高。
Python 3.6 引入了 f-string,这是目前最推荐的格式化方式:
python
name = "Alice"
age = 25
print(f"Name: {name}, Age: {age}")
print(f"Age next year: {age + 1}")
print(f"Pi: {3.14159:.2f}") # 保留两位小数
print(f"Hex: {255:08x}") # 8位十六进制,不足前面补0f-string 在可读性和性能上都是最优的选择,建议在所有新代码中使用 f-string 进行字符串格式化。
编码问题的本质与解决
理解了编码的原理之后,乱码问题的本质就变得很清楚了:编码方式和解码方式不一致。
当一段文本用某种编码(比如 UTF-8)转换成字节序列之后,必须用同样的编码(UTF-8)才能正确还原。如果解码时使用了不同的编码(比如 GBK),就会产生乱码或者解析错误。
解决编码问题的关键在于两点。第一,永远明确指定编码方式,不要依赖系统默认编码。读取文件指定 encoding="utf-8",写入文件同样指定,打开网络连接时也要明确编码。第二,当出现乱码时,首先确定数据的真实编码。可以通过检查字节序列的特征来初步判断编码:UTF-8 编码的中文通常以 \xe4、\xe5、\xe6 开头,GBK 编码的中文通常以 \xb4、\xd7 等开头。如果不确定原始编码,可以尝试用 chardet 库来检测。
在人工智能和大模型开发中,编码问题尤其常见。处理文本数据时,必须确保整个数据处理 pipeline 使用统一的编码标准。建议在数据入口处就明确指定 UTF-8 编码,并贯穿整个处理流程,避免在中途切换编码导致的问题。
不可变性的实际意义
前面多次提到字符串的不可变性,这不仅是理论上的概念,更在实际编程中产生重要影响。
首先,不可变性使得字符串可以作为字典的键和集合的元素。字典要求键必须可哈希,而哈希值依赖于对象的值。如果字符串可以被修改,那么修改后的字符串的哈希值就会改变,用它作为键的数据就会丢失。不可变性保证了字符串的哈希值在整个程序生命周期内保持一致。
其次,不可变性使得多线程访问字符串时是安全的。由于字符串不会被任何线程修改,多个线程可以同时读取同一个字符串而不需要任何同步机制。这在编写并发程序时是一个重要的优势。
最后,不可变性使得 Python 可以对字符串进行驻留优化。解释器可以在内部缓存常用的字符串对象,让相同内容的字符串共享同一块内存。这不仅节省了内存,也加快了字符串比较的速度,因为比较两个内容相同的字符串只需要比较它们的身份标识(id),而不需要逐字符比较。
理解字符串的不可变性和编码机制,是在 Python 中处理文本数据的基础。无论是编写大模型的数据预处理脚本,还是开发 Web API 的文本处理逻辑,这些知识都会反复派上用场。