11 文本与二进制序列: 从实现细节和操作方法认识字符串对象
Diego38 49 1

引言

在日常的开发中,我们需要经常和文本字符串打交道,比如用户输入的处理,文本的解析与提取、格式化等。在特定的专业领域,我们可能还需要对图像、音频等二进制数据、网络编程中打包的二进制数据等进行处理。除此之外,我们还会或多或少的接触到 Unicode 编码的相关知识。在本小节,我们旨在从一线开发的角度,结合概念、原理、应用,进一步认识和了解 Python 中字符串的重点内容与技巧。

Unicode 和 str、bytes

Unicode 和 UTF-8

我们把 Unicode 和编解码的主要概念在这一部分的最开始简要说明:

不同的字符要在计算机硬件中表示,需要转换为二进制值,二进制值和字符之间通过字符集进行映射,有多种字符集,包括 ASCII 字符集、Unicode 字符集等。

如果你编写仅包含英文字符的程序,ASCII 通常是够用的,它定义了共 128 个字符,其中一个字符对应一个字节,比如 A 对应 01000001(十进制中的 65)。

但当你在编码中需要使用诸如中文等非英文字符时,通常这些语言中所包含的字符数量远多于英文字符的数量,若每一个字符都只使用一个字节进行表示的话,显然是不足以表示这么多字符的,比如下面的例子所展示的那样。

>>> '你'.encode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode character '\u4f60' in position 0: ordinal not in range(128)

这时便需要 Unicode 字符集,Unicode 字符集涵盖的字符数量远大于一个 8 位的字节所能表示的字符数量。可以从下面的示例中看到,在 Unicode 中,“你”对应的整数(准确的说,是代表汉字“你”的 Unicode 码点值)为 20320,对应的十六进制为 4F60。

>>> ord('你')
20320

那我们常用的 UTF-8 和 Unicode 之间的关系是什么呢?UTF-8 是针对 Unicode 字符集的一种转换格式, 即将 Unicode 码点转换为字节的方式。UTF-8 采用可变长字节数的方案来进行转换,一个码点对应 1 至 4 个字节,具体的编码规则如下所示,可以看出我们上面的 4F60,在 00000800-0000FFFF 之间,对应三个字节。

Unicode 十六进制码点范围 UTF-8 二进制字节
00000000-0000007F 0xxxxxxx
00000080-000007FF 110xxxxx 10xxxxxx
00000800-0000FFFF 1110xxxx 10xxxxxx 10xxxxxx
00010000-0010FFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
那么 4F60 怎样对应到上面 UTF-8 编码中的三个字节呢?需要将 4F60 对应的二进制数 100111101100000 从右向左依次填入表格中省略的 x 处(未填满的 x 处用 0 补足):

1110xxxx 10xxxxxx 10xxxxxx -> 11100100 10111101 10100000

上面得到的结果对应的十六进制便是 E4BDA0,即是 4F60 对应的 UTF-8 编码。我们通过下面的 Python 代码进一步加深理解。在理解了上述从码点值到 UTF-8 编码的转换过程后,我们便可以理解下面示例中 b'\xe4\xbd\xa0' 这个输出的基本逻辑了。

>>> bin(20320)
'0b100111101100000'
>>> hex(0b11100100)
'0xe4'
>>> hex(0b10111101)
'0xbd'
>>> hex(0b10100000)
'0xa0'
>>> '你'.encode('utf-8')  # '\xe4' 为十六进制 e4 的转义 
b'\xe4\xbd\xa0'
>>> len('你')
1
>>> len('你'.encode('utf-8'))
3

另外,Unicode 字符集转换格式除了 UTF-8 以外,还有 UTF-16、UTF-32。

最后,基于上面的内容,不难理解编解码的基本概念,编码是指将字符串按照某个编码方案转换为对应的字节;解码则是指根据某个编码方案将字节转换为对应的字符串。

>>> b'\xe4\xbd\xa0'.decode('utf-8')
'你'

一个简单的分类

接下来,我们对 Python 中字符串类型的发展变化过程进行一个简单的梳理,在 Python 2.X 中:

  • str 用于表示两种类型的数据,分别为(可用一个 8 位字节表示的)文本字符串,以及字节串(二进制数据)。
  • unicode 用于表示 Unicode 文本字符串。
# Python 2.X
>>> 'hello'
'hello'
>>> '你好'
'\xe4\xbd\xa0\xe5\xa5\xbd'
>>> u'你好'
u'\u4f60\u597d'
>>> type(u'你好')
<type 'unicode'>
>>> type('你好')
<type 'str'>

在 Python 3.X 中:

  • str 用于表示文本字符串,将 Python 2.X 中 “割裂” 的(可用一个 8 位字节表示的)文本字符串和 Unicode 文本字符串进行了合并;
  • bytes 用于表示字节串,并增加了 bytearray 作为可变的 bytes 。
>>> 'hello'
'hello'
>>> '你好'
'你好'
>>> type('你好')
<class 'str'>
>>> b'hello'
b'hello'
>>> type(b'hello')
<class 'bytes'>

不可变序列

在上一节类型层级的划分中,我们提到字符串类型是一种不可变序列。首先,Python 中的字符串是一种序列类型,因此它支持常见的序列操作,比如索引、切片、len() 等。

>>> s = 'hello world!'
>>> s[0]
'h'
>>> s[3:6]
'lo '
>>> s[1:8:2]
'el o'
>>> s[::-1]  # 反转字符串
'!dlrow olleh'
>>> len(s)
12

其次,不可变是指对象在创建之后具有固定值,不能对其进行原位置改变。比如不能通过赋值改变字符串某一索引处的值。

>>> s[0] = 'H'
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'str' object does not support item assignment

在这里需要注意的是,对于包含其他对象引用的不可变的复合对象来说,其包含的对象引用集是不可变的,但并不代表所包含的对象不能被改变。比如下面所示的元组中包含可变对象,该可变对象仍是可以改变的:

>>> t = (s, [1,2])
>>> t[1] = [3, 2]
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>> t[1][0] = 3
>>> t
('hello world!', [3, 2])

str、bytes、bytearray

常用的基本技巧

在这一部分,我们对需要注意的一些知识点分别进行讨论。

Python 中并没有单字符的 char 类型,可以使用只包含单独字符的字符串,这也表示对字符串进行索引后得到的也是一个字符串。

>>> s = 'hello world!'
>>> s[0]
'h'
>>> s[:1]
'h'
>>> s[:1] is s[0]
True

字符串可以被单引号、双引号或者三重引号包裹,可以使用单双引号互相包裹来代替反斜杠转义。

>>> 'hello \'world\''
"hello 'world'"
>>> "hello 'world'"
"hello 'world'"

可以使用原始字符串 r'' 来避免转义,这常用于正则表达式、Windows 下的目录路径编写等场合。

>>> print(r'D:\test.txt')
D:\test.txt
>>> print('D:\test.txt')
D:    est.txt

表达式中的字符串之间若无逗号隔开,Python 会隐式的将其进行拼接。将这种形式放置在圆括号中进行折行可以避免某个表达式过长而超过 PEP8 的要求。

>>> 'hello'  ' world!'
'hello world!'
s = "This is a very, very, very, very, " \
    "very, very, very long sentence"
s = ("This is a very, very, very, very, "
     "very, very, very long sentence")

字节串 bytes

bytes 是由若干个 8 位字节组成的不可变序列,其中每个字节由 [0, 255] 之间的整数表示。bytes 支持除字符串格式化外的大多数 str 对象具有的方法。如下所示,当字节串或者字节串切片被打印时,其会被显示为字符串。

>>> s = b'hello world!'
>>> s
b'hello world!'
>>> s[:5]
b'hello'
>>> s[0] = b'H'
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'bytes' object does not support item assignment

当 bytes 进行索引操作或者使用 list() 时,会显示对应的二进制整数或者二进制整数组成的序列。

>>> s[3]
108
>>> list(s)
[104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, 33]

创建 bytes 可以通过 b'' 这种字面量方式,也可以通过内置函数 bytes() 来创建。

>>> bytes([104, 101, 108, 108, 111])
b'hello'
>>> bytes('hello', 'ascii')
b'hello'

可变字节串 bytearray

Python 3.X 在 str 和 bytes 之外还增加了 bytearray 类型,bytearray 是 bytes 可变形式,同时 bytearray 也被向后移植到了 Python 2.6 及之后的 Python 2.X 版本中。除支持可变(可原地修改)外,bytearray 和 bytes 的接口和功能也是一致的。

# Python 2.X
>>> s = 'hello world!'
>>> bytearray(s)
bytearray(b'hello world!')
# Python 3.X
>>> s = 'hello world!'
>>> bytearray(s)  # 注意 Python 3.X 和上面 2.X 的区别
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: string argument without an encoding

bytearray 可以支持原地修改的相关操作,比如索引赋值、append、pop、remove 等。

>>> s = bytearray(b'hello world!')
>>> s
bytearray(b'hello world!')
>>> s[0] = b'H'  # 需要 [0,255] 之间的整数进行赋值
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: 'bytes' object cannot be interpreted as an integer
>>> s[0] = 72
>>> s
bytearray(b'Hello world!')
>>> s[6] = ord('W')  # 可通过 ord() 进行转换
>>> s
bytearray(b'Hello World!')
>>> s.append(ord('!'))  # bytearray 支持 append、pop、remove 等操作
>>> s
bytearray(b'Hello World!!')
>>> s.pop()
33
>>> s
bytearray(b'Hello World!')
>>> s.remove(33)
>>> s
bytearray(b'hello world')

字符串的常用方法

我们可以使用 dir() 来查看 str 的属性列表,使用 help() 来查看某个方法更多的细节。

>>> dir(str)
[..., 'capitalize', 'casefold', 'center', 'count', 'encode', 'endswith', 'expandtabs', 'find', 'format', 'format_map', 'index', 'isalnum', 'isalpha', 'isascii', 'isdecimal', 'isdigit', 'isidentifier', 'islower', 'isnumeric', 'isprintable', 'isspace', 'istitle', 'isupper', 'join', 'ljust', 'lower', 'lstrip', 'maketrans', 'partition', 'replace', 'rfind', 'rindex', 'rjust', 'rpartition', 'rsplit', 'rstrip', 'split', 'splitlines', 'startswith', 'strip', 'swapcase', 'title', 'translate', 'upper', 'zfill']
>>> help(str.isalnum)
Help on method_descriptor:
isalnum(self, /)
    Return True if the string is an alpha-numeric string, False otherwise.
    A string is alpha-numeric if all characters in the string are alpha-numeric and there is at least one character in the string.

下面我们结合代码,对 str 对象的一些常用方法进行简要说明。

>>> s = 'hello world!'
# str.center(width[, fillchar])
# 将原字符串置于长为 width 的字符串中间,两边空位由 fillchar 补足,默认为空格,若 width 小于字符串长度则返回原字符串
>>> s.center(20)
'    hello world!    '
>>> s.center(20, '>')
'>>>>hello world!>>>>'
>>> s.center(1)
'hello world!'
>>> s.center(1) is s
True
# str.count(sub[, start[, end]]) 
# 统计 sub 不重叠出现的次数,start 和 end 的使用方法和切片类似
>>> s.count('ll', 2, 8)
1
>>> 'hellllll'.count('ll')
3
# str.endswith(suffix[, start[, end]])
# 是否以 suffix 结尾,suffix 可为由多个供查找的后缀构成的元组
# 同理 str.startswith(prefix[, start[, end]]) 返回是否以 prefix 开始,同样 prefix 也可为一个元组
>>> s.endswith('!')
True
>>> s.endswith('!', 2, 8)
False
>>> s.endswith(('o', '!'), 2, 8)
True
# str.find(sub[, start[, end]]) 
# 返回 sub 首次出现时(由左至右)的索引值,当不需要索引值时也可以用 in 判断成员关系
# 同理 str.rfind(sub[, start[, end]]) 返回 sub 末次出现时(由左至右,同由右至左的首次出现)的索引值
>>> s.find('o')
4
>>> s.rfind('o')
7
>>> s.find('L')  # 查找的元素不存在时
-1
>>> 'l' in s 
True
# str.replace(old, new[, count]) 
# 将字符串中 old 替换为 new,若无 count 值,则全部替换
>>> s.replace('l', 'L')
'heLLo worLd!'
>>> s.replace('l', 'L', 1)
'heLlo world!'
# str.split(sep=None, maxsplit=-1),同理 str.rsplit(sep=None, maxsplit=-1) 从右侧开始拆分
# 以 sep 为分隔符将字符串拆分为列表,maxsplit 表示最大拆分次数,默认 -1 表示不限制次数,以指定分隔符拆分空字符串则返回 ['']
>>> 'hello,world!'.split(','), 'hello,,world!'.split(','), 'hello,,world!'.split(',,')
(['hello', 'world!'], ['hello', '', 'world!'], ['hello', 'world!'])
>>> 'hello,,world!'.split(',', maxsplit=1)
['hello', ',world!']
>>> ''.split('h')
['']
# sep 默认为 None ,此时分隔符则为连续的空格,并忽略首尾的空字符串
>>> s.split()
['hello', 'world!']
>>> '  hello  world   '.split()
['hello', 'world']
# 以 None 拆分空字符串、或以 None 拆分仅包含空格的字符串则返回 []
>>> '   '.split(), ''.split()
([], [])
# str.strip([chars])
# 移除字符串首尾的指定字符,省略 chars 或 chars 为 None 时则移除首尾的空格
>>> '  hello world  '.strip()
'hello world'
# 需要注意的是,若指定 chars 时,移除的是 chars 中的所有组合,在移除字符过程中,首部的字符在遇到第一个非 chars 中的字符时结束移除,尾部的字符移除与首部类似
>>> '***>>>>>>   hello world~*>>>!!!!!!!'.strip('*>hd! ')
'ello world~'
# str.lstrip([chars]) 只移除首部字符;str.rstrip([chars]) 只移除尾部字符

在上面我们已经使用过 str.encode(encoding="utf-8", errors="strict") 方法,它的作用是返回字符串编码后的字节串,这里需要说明的是其 encoding 默认参数为 utf-8,另外一个默认参数 errors 可以用来设置编码错误处理方案,默认为 strict,即编码错误时会引发 UnicodeEncodeError,除 strict 之外,还可以使用ignore、replace 等。同样,bytes.decode(encoding="utf-8", errors="strict") 被用于返回字节串解码后的字符串。

>>> '你'.encode()
b'\xe4\xbd\xa0'
>>> '你'.encode('ascii')
Traceback (most recent call last):
  File "<input>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode character '\u4f60' in position 0: ordinal not in range(128)
>>> '你'.encode('ascii', 'ignore')
b''

在 Python 2.X 中,str 和 unicode 类型同时可以支持文本字符串(当然,前者支持范围有限,即上面提到的 8 位文本字符串),所以 Python 2.X 中 str 和 unicode 之间并没有非常严格的区分。

按道理来说,我们只能在一个已编码的字节串对象上调用 decode 方法,在一个已解码的文本字符串对象上调用 encode 方法。但在 Python 2.X 中,我们仍拥有在字节串上调用 encode 的可能(显然这样做是不对的),这就会造成 encode 过程中引发 UnicodeDecodeError 的困惑,这是因为在你试图编码一个字节串时,Python 会隐式的先将其以默认的 ascii 进行解码,从而引发解码错误。在 Python 3.X 中对于文本字符串和字节串进行了严格的区分,不会再有这样困惑的场景出现。

# Python 2.X
>>> u'你'
u'\u4f60'
>>> '你'
'\xe4\xbd\xa0'
>>> '你'.encode()  # 隐式调用'你'.decode(),即'你'.decode().encode()
Traceback (most recent call last):
  File "<input>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)
>>> '你'.decode('utf-8').encode('utf-8')
'\xe4\xbd\xa0'

对于 str.join(iterable) 和 str.format(args, *kwargs) 方法,我们将在下面讲解。

为什么 ''.join() 优于 += ?

+= 和 str.join(iterable) 的基本用法

在拼接字符串时,常用的方法有 += 和 str.join(iterable) ,前者的使用方法非常简单:

>>> s = 'hello'
>>> s += ' world'
>>> s += '!'
>>> s
'hello world!'

对于 str.join(iterable) ,它把可迭代对象(比如列表)中的字符串使用指定的分隔符拼接为新的字符串,这个分隔符是调用该方法的字符串 str 。

>>> ''.join(['hello', ' world', '!']), ''.join(('hello', ' world', '!'))
('hello world!', 'hello world!')
>>> ''.join([1, 2, 3, 4])  # 当可迭代对象中包含非字符串对象时会引发 TypeError
Traceback (most recent call last):
  File "<input>", line 1, in <module>
TypeError: sequence item 0: expected str instance, int found
>>> ','.join(['1', '2', '3', '4'])
'1,2,3,4'

+= 和 str.join(iterable) 的对比

在 Python 的官方文档的 Programming Recommendations 中有这样一条建议:

代码的编写方式不应影响其他 Python 实现( PyPy、Jython、IronPython、Cython、Psyco 等 )。比如,不要依赖 CPython 对 a += b 或 a = a + b 形式的原位置字符串拼接语句的优化实现。即使在 CPython 中这种优化也并不普适,并且这种优化并不存在于不使用 refcounting(引用计数) 的 Python 实现中。在代码的性能敏感部分,应使用 str.join(iterable) 形式,这将确保在字符串拼接以线性时间运行。

并且在 Python 官方文档 Built-in Types 中也提到了:

拼接不可变序列总是会生成新的对象。 这意味着通过重复拼接来构建序列的运行时开销将会基于序列总长度的乘方。

我们对上面的内容进行一定的分析和说明:

  • 首先,在使用 += 对字符串进行拼接时,由于字符串为不可变对象,不能对其进行原地的修改,所以在拼接多个字符串时, 需要分配多次内存空间。对于每个被拼接的子字符串来说,分配次数至少为一次(具体地说,比如 n 个字符串,则对于第一个子字符串来说会被重复分配 n 次,最后一个子字符串分配一次);

  • 而对于 str.join(iterable) ,则不需要多次分配,会对所需的内存空间计算后进行一次分配,再把每个需要拼接的子字符串依次复制,对于每个子字符串需要复制一次;

  • CPython 在内部会对 += 操作在某些场景下进行实现上的优化,但这种优化并非完全可靠,同时这种优化并非具有跨解释器兼容性。鉴于以上,应尽可能使用 str.join(iterable) 来完成字符串拼接。

对于这部分的实现细节感兴趣的读者可以查看源码 unicodeobject.c 中 PyUnicode_Concat 、unicode_join、PyUnicode_Join、_PyUnicode_JoinArray 等部分。

字符串格式化

字符串格式化在日常开发中是一种非常常用的编码技巧,目前在 Python 中常用的字符串格式化相关方法主要包含 printf 风格的字符串格式化表达式、str.format() 字符串格式化方法调用、格式化字符串字面值 f-string。

printf 风格

printf 风格的字符串格式化表达式基于 format % values 形式,位于 format 中的 %(转换标记符) 将会被 values 按照一定规则替换。

>>> 'My name is %s, I am %d years old' % ('zhangsan', 25)
'My name is zhangsan, I am 25 years old'
>>> 'My name is %(name)s, I am %(age)d years old' % {'name': 'zhangsan', 'age': 25}
'My name is zhangsan, I am 25 years old'

str.format(args, *kwargs)

str.format() 方法是一种更符合 Python 风格的格式化方法,它基于方法调用而不是表达式,以 {} 代表被替换字段,未在 {} 内的字符会直接显示在输出中。在 {}(可称为替换域)中,可以为索引值、关键字、空(空代表通过相对位置进行替换)。

>>> 'My name is {}, I am {} years old'.format('zhangsan', 25)
'My name is zhangsan, I am 25 years old'
>>> 'My name is {0}, I am {1} years old'.format('zhangsan', 25)
'My name is zhangsan, I am 25 years old'
>>> 'My name is {name}, I am {age} years old'.format(name='zhangsan', age=25)
'My name is zhangsan, I am 25 years old'

f-string

f-string 也可称为格式化字符串字面值,它的形式基于带有前缀 f 或 F 并可包含 {}(代表可替换字段)的字符串。

>>> name = 'zhangsan'
>>> age = 25
>>> f"My name is {name}, I am {age} years old"
'My name is zhangsan, I am 25 years old'
>>> f"My name is {name.capitalize()}, I am {age} years old"
'My name is Zhangsan, I am 25 years old'

总结

字符串对象是 Python 对象系统中的核心内置对象,本小节我们分别从 Unicode、UTF-8、编解码、文本字符串、字节串、可变字节串、字符串格式化等多个方面对 Python 字符串相关的知识点进行了讲解。对于字符串的每一个方法,希望大家可以尽量阅读官方文档进行详细的学习和了解,这样可以更深入的了解字符串具体方法的详细用法和含义。

预览图
评论区

索引目录