从这一章开始,我们就要开始讲入Python中另一个核心数据类型——字符串(str) 。在前面几章,我们花了很长时间把数字(整数、浮点数)在内存中的表示方式详细的讲述了一遍,我相信你对二进制、补码这些概念已经有了一定了解。
这一章,我们来聊字符串。还是秉承我一贯的理念:我们不仅仅是学习Python语言本身,还要深入底层,弄明白计算机和内存到底在背后做了什么。
首先对于初学者,我们可能得思考一下:什么字符串?它和我们之前说的整数有什么不同?
字符串我们可以理解为用来表示文本或者字节,比如英文字母,标点符号,或者我正在写的这篇文章,你都可以用字符串来表示。至于字节,目前我们还没有接触过二进制数据,二进制数据也可用字符串来表,这个更深奥的话题,我会在稍后的章节中展开来讲。
它和整数有什么不同么?其实最直接的不同是字符串需要用单引号,双引号或者三引号来扩起来表示,如下所示:
a = '123' b = "abf!" c = """abdc nnddd""" d = ''' aaaaaaaa aaaaaa aaaaa a. ''' e = 'a' f = "'a'" g = '"b"' h = ''' 'a',"b" ''' #i = " """baa""" " print("a={},b={},c={},d={},e={},f={},g={},h={}".format(a,b,c,d,e,f,g,h)) #结果 #a=123,b=abf!,c=abdc # nnddd,d= aaaaaaaa # aaaaaa #aaaaa a. ,e=a,f='a',g="b",h= 'a',"b"从上面的代码可以看出,用单引号,双引号,或者三个单引号以及三个双引号,都可以表示字符串,对于变量a,它表示的是“123”这个字符串,而不是整数123。我们发现c和d采用三个单引号或者三个双引号可以表示多行。对于e,由于python中没有表示单个字符的概念,所以单个字符也是用字符串表示。f,g,h,表明单引号,双引号,三引号可以互相嵌套,只要不引起歧义,都是正确的语法。对于i,会引发一个语法错误:
i = " """baa""" "
^
SyntaxError: unterminated triple-quoted string literal (detected at line 26)
从这个提示来看,python解释器,认为bba后面缺少一个""",因为python解释器将后面的四个双引号理解成了两个字符串,一个空字符串和一个包含一个空格的字符串。
通过上面的学习,你应该可以理解怎么书写一个字符串。接下来我们要谈谈字符串的一些特点。
1. 字符串是“不可变”的。
如果你学过Python,一定听过一句话:字符串是不可变的(immutable)。
很多教程会告诉你:“字符串一旦创建就不能修改”。这句话对,但只对了一半。因为它只说了“是什么”,没解释“为什么”。更关键的是,如果你不理解“为什么”,你就永远搞不懂下面这个现象:
s = "hello" print(id(s)) # 打印出一串数字,(4371813632) s = s + " world" # 我们“修改”了s print(id(s)) # 打印出一个全新的数字(4372022512),和刚才完全不同你看,同样是变量s,但它的“身份证号”(id)变了。id这个函数返回的是对象在内存中地址,我们看到,这背后的事实是:我们根本没有修改原来的字符串对象,而是创建了一个全新的字符串对象 "hello world",s指向了这个字符串对象。
原来的 "hello" 对象呢?如果没有任何变量引用它,它就会被Python的垃圾回收机制打扫掉,关于垃圾回收,这也是个很大的话题,我们回头再讨论,现在可以理解为,如果没有变量引用这个对象,这个对象会在垃圾回收的时候被清理掉。
1.1 为什么Python要把字符串设计成“不可变”的?
你可能会想:“这也太不方便了,每次拼接都要创建新对象,多浪费内存啊!” 没错,这确实是一种“代价”。但Python之所以这么设计,是因为它带来了三个巨大的好处。
第一,保证了字典(dict)的安全性。
我们后面会学到字典,你可以把它想象成一本“神奇的电话本”,你只要知道名字(键),就能瞬间找到电话号码(值)。它之所以这么快,是因为每个键都有一个唯一的“哈希值”(hash),相当于这个键的指纹。关于这个哈希值,可以理解为根据键生成的一串数字,一般不同的键生成的哈希值是不一样的,当然也可能相同,字典其实背后就是一个数组,只是这个组数的每个位置存放了三个值:键,值以及哈希值。当我们给字典一个键的时候,python会先计算该建的hash值,并且通过该哈希值找到字典中的数组下标,然后判断hash值是否相同,如果相同,在查看键是否相同,如果也相同,则取出对应的值。看看下面的代码:
a = {} b = "name" a[b] = "xiao" print("a[b]={}".format(a[b])) #a[b]=xiao上面的代码,定义了一个空字典,然后给字典添加了一个键为“name”,值为“xiao”的键值对,此时假如我们可以修改“name”这个字符串,比如修改为“nam”,那么下次我们再用“name”去字典查找的时候,就会发现找不到了。所以字符串不可变,保证了键的“指纹”永远不变。
第二,让“字符串驻留”成为可能。
Python为了节省内存,会把一些看起来一样的字符串对象“合并”成同一个。比如你写两个变量 a = "hello" 和 b = "hello",Python可能会让它们指向内存中的同一个字符串对象。因为是不可变的,这样a和b都不能随意改变其引用对象的值,从而节省了内存。
第三,多线程环境下更安全。
在多线程编程中,如果多个线程同时修改一个对象,很容易造成数据错乱,需要加锁,非常麻烦。而不可变对象天生就是线程安全的,就像一本只读的书,多少人同时看都没问题。目前我们还没有学到多线程,大家只需要知道如果多个人同时操作一个对象,如果这个对象不可变,那么每个人无论怎么操作这个对象,对其他人来讲这个对象数据都不会发生变化或者不会拿到过时的数据,这就可以称之为线程安全。
我们看一个基本基本的字符串操作,来看看看字符串的不可变性的特点。
s = "hello" t = s # t 和 s 现在指向同一个对象 print(s is t) # 输出 True,因为它们确实是同一个 s = s.upper() # upper() 方法返回一个新字符串 "HELLO" print(s) # 输出 HELLO print(t) # 输出 hello,t 指向的还是原来的 "hello" print(s is t) # 输出 False,s 和 t 已经“分道扬镳”了我们看到upper() 方法并没有修改原来的 "hello",而是造了一个新的 "HELLO" 出来。原来的 "hello" 依然完好无损,被变量 t 好好地指着。
所以,请记住这句话:操作字符串,不是修改对象,而是创建新对象。变量的作用,就是一个贴在对象上的“便利贴”,通过变量,你可以操作对象。关于变量和对象的关系,具体的可以参照之前的章节 python中变量,对象和引用详解。
2.字符串在内存中到底是什么样子的?
正常的情况我们并不关心到底内存中如何存储字符串的,要知道这个字符串的长度,只需要调用len函数就可以知道。如下代码所示:
a = "12345" print(len(a)) #长度是5但是这个len函数只是现实这个字符串的长度,但是这个字符串究竟在你内存中占几个字节呢?我们都知道,一般的字母,在内存中都是占用1个字节,所以这个字符串长度是5,那么就应该占用5个字节么?答案是否定,一个字符串占用的字节数远比你想象的要多,为什么呢?
我们将一个对象存储到内存中,你如何得知这个对象是字符串,整数或者是其他别的类型呢?所以肯定有个标识指出这是个什么类型的数据,另外还有这个对象是否被引用的表示,如果是字符串,字符串的长度等等。这只是简单的列举了几个可能的信息,实际上附加在对象上的信息比我们想象的要多,这些信息都需要占用空间,所以字符串的长度只是说明存储的字符的数量,并不能说明这个字符串在内存中占用多少空间。下面是在CPython的源码中,字符串对象结构体的一个简化版:
typedef struct { PyObject_HEAD // 所有Python对象共有的头信息 ssize_t ob_size; // 字符串的长度(几个字符) int ob_sstate; // 是否被“驻留”的标志 char ob_sval[1]; // 真正存放字符数据的柔性数组 } PyUnicodeObject;上面的代码是C语言,定义了一个字符串的结构体,我们可以看到包含了头信息,字符串的长度,是否被驻留的标志以及真正存放字符串的数组。上面的这些标记现在可以没必要去全部弄明白,这里列出来只是想说明字符串在内存中的存储不是我们想象的那么简单。
这里可以说明一下这个长度ob_size,为什么Python要单独存一个长度?
因为C语言的字符串靠\0结尾,但Python不这么干。原因有两个:
· 获取长度是O(1)操作,len()瞬间返回,如果我们不存长度,可能就需要一个一个去数了,那么len的操作就变成了O(n)
· Python字符串可以包含\0字符(比如"hello\0world"),靠\0结尾会错误截断,这恰恰是python和C语言对字符串设计的不同之处。
3.一个字符到底占几个字节?(Unicode与编码)
这是很多人搞不清楚的地方。我们现在说字符,而不是说字符串,我说的是这个字符真正编码占用的空间,而不是说字符串对象的内存表示。
在ASCII时代,1个字节走天下,只够表示英文字母。但中文有几万个汉字,1个字节根本不够。于是Unicode诞生了,关于Unicode,我们目前不展开讨论,因为Unicode讨论起来又是一个很大的篇幅,我们后面会单读拿出来说。现在我们理解Unicode,其实就是全世界所有的字符都分配了一个唯一的数字。
Python 3内部使用的是灵活字符串表示,根据内容自动选择最紧凑的编码:
· 如果全是ASCII字符,每个字符占1个字节。(可以理解为英文字母+数字+一些常见的符号)
· 如果包含其他Latin-1字符(比如é),每个字符占2个字节。
· 如果包含中文或Emoji,每个字符占4个字节。
参照下面的代码:
import sys s1 = "hello" s2 = "你好" s3 = "😊" s4 = "a" print(sys.getsizeof(s1)) # 46字节 print(sys.getsizeof(s2)) # 大约62字节 print(sys.getsizeof(s3)) # 大约64字节 print(sys.getsizeof(s4)) # 大约42字节其中sys.getsizeof()是返回给定的对象占用内存的大小,同样是1个字符,'a'和'😊'占用的内存可以差好几倍。
我们时常会听到字符编码,其实如果你只是python语言,不涉及到用外部或者网络去读文本,那么我们完全可以忽略编码的存在,因为python语言本身不存在所谓编码解码的过程,你写一个字符串,直接就可以这么写了,例如 a = “1123”,但是假如你需要将这个字符串写入到一个文件中,此时我们可能就需要用到编码了,当然你可能用过python去创建一个文本文件,并且写了一些内容进去,似乎也没有想到用什么编码或者解码,但是如果某一天你从别的地方拿到一个文件,通过open打开它,如果没有指定编码或者指定正确的编码,那就可能存在你读取这个文件后,打印出乱码的情况。原因是因为在写入时候的编码和你打开时候用的编码不同,所以就会导致乱码。
字符编解码的这个话题,目前不会展开说了,将来讲Unicode的时候会详细说明。记住一点,当你要写入数据到一个外部文件的时候,一定要指定明确的编码格式,以后打开这个文件的时候,也需要以相同解码格式打开。
4.字符串驻留机制——Python的“共享经济”
字符串驻留,就是Python缓存一部分字符串对象,当再次创建相同内容的字符串时,直接返回缓存中的对象,而不是创建新对象。这样就可以节省空间,因为字符串是不可改变的,这样多个变量引用同一个字符串对象是安全的,没有副作用的。
哪些字符串会被驻留?
· 编译时能确定的字符串常量,比如"hello"。
· 只包含字母、数字、下划线的字符串(看起来像合法标识符)。
· 长度为1的字符串。
哪些不会被驻留?
· 运行时动态生成的字符串,比如"".join(["h","e"])。
看个例子:
a = "hello" b = "hello" print(a is b) # True,复用同一个对象 c = "".join(["h", "e", "l", "l", "o"]) print(a is c) # False,运行时生成,不同对象 print(a == c) # True,内容相同上面的代码,我们可以看到a和b指向了同一个对象,is的意思是这两变量是否指向了同一个对象。c是通过jion语法生成的字符串,这个字符串和a的值是相等的,但是不是同一个对象,还记得前面小节中我们讲CPython字符串结构体中的那个驻留标记么?这个a和b指向的“hello”字符串现在就是内存驻留的,而c指向的“hello”字符串不是内存驻留的。
5.字符串拼接:+ vs join() vs f-string
字符串用的比较多的就是字符串的拼接,目前有三种方法。
.用+拼接,参照如下的代码:
result = "" for i in range(10000): result = result + str(i) # 每次循环都创建新对象每次拼接都会创建一个新字符串,把旧内容完整拷贝一遍。当字符串越来越长时,每次拷贝的时间也越来越长。时间复杂度是O(n²)。
.用join()拼接,参照如下代码:
parts = [] for i in range(10000): parts.append(str(i)) result = "".join(parts)join()先遍历所有片段,计算出总长度,一次性分配内存,然后拷贝所有内容。时间复杂度是O(n)。
用f-string,参照如下代码:
name = "张三" age = 25 s = f”姓名:{name},年龄:{age}“ print(s)#输出:姓名:张三,年龄:25适合少量字符串的格式化拼接,可读性好,性能也不错。但在循环中累积大量片段时,join()依然是首选。
原则:一次性拼几个用+或f-string;循环中拼大量片段,务必用join()。
字符串格式化目前有两种形式,一种是表达式,一种是函数,这里面的细节比较庞杂,细节会在别的章节讨论。
6.字符串比较:== 与 is 的本质区别
这是Python初学者最容易踩的坑。
· ==:比较的是内容是否相等。
· is:比较的是是不是同一个对象(内存地址是否相同)。
a = "hello" b = "hello" c = "".join(["h", "e", "l", "l", "o"]) print(a == b) # True,内容相同 print(a is b) # True,都是字面量,被驻留了 print(a == c) # True,内容相同 print(a is c) # False,不是同一个对象绝大多数情况下,我们用==就够了。只有在你确切想知道两个变量是否指向同一个对象时,才用is。
7.字符串的常用操作:strip()、split()、replace() 底层原理
这三个方法都是返回新字符串,不会修改原字符串。
strip(): 默认去除字符串两端的空白字符(空格、换行、制表符等)。它不会去掉字符串中间的空白。
s = " hello world " print(s.strip()) # "hello world"split(): 将字符串按指定分隔符拆分成列表。如果不指定分隔符,默认按空白字符拆分。
s = "apple,banana,orange" print(s.split(",")) # ['apple', 'banana', 'orange']replace(): 将字符串中的旧子串替换为新子串。
s = "hello world" print(s.replace("world", "Python")) # "hello Python"这三个方法的共同点是:它们都创建并返回一个新的字符串对象,原字符串纹丝不动。
这一章,我们从底层存储出发,搞清楚了:
· 字符串为什么是不可变的
· 字符串对象在内存中的结构
· Unicode编码与内存占用
· 字符串驻留机制
· 不同拼接方式的性能差异
· ==和is的区别
· 常用操作的底层原理
但是我们对字符串本身详细的相关操作没有做过深的讨论。下一章,我们将详细聊一聊常见字符串的字面量,转义字符,基本操作等。