ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

python-字符串全解(一):底层存储与内存表示

2026/8/27 20:36:28 拓冰建站 浏览量
python-字符串全解(一):底层存储与内存表示 从这一章开始我们就要开始讲入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)) #结果 #a123,babf!,cabdc # nnddd,d aaaaaaaa # aaaaaa #aaaaa a. ,ea,fa,gb,h a,b从上面的代码可以看出用单引号双引号或者三个单引号以及三个双引号都可以表示字符串对于变量a它表示的是“123”这个字符串而不是整数123。我们发现c和d采用三个单引号或者三个双引号可以表示多行。对于e由于python中没有表示单个字符的概念所以单个字符也是用字符串表示。fgh表明单引号双引号三引号可以互相嵌套只要不引起歧义都是正确的语法。对于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 worlds指向了这个字符串对象。原来的 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 helloPython可能会让它们指向内存中的同一个字符串对象。因为是不可变的这样a和b都不能随意改变其引用对象的值从而节省了内存。第三多线程环境下更安全。在多线程编程中如果多个线程同时修改一个对象很容易造成数据错乱需要加锁非常麻烦。而不可变对象天生就是线程安全的就像一本只读的书多少人同时看都没问题。目前我们还没有学到多线程大家只需要知道如果多个人同时操作一个对象如果这个对象不可变那么每个人无论怎么操作这个对象对其他人来讲这个对象数据都不会发生变化或者不会拿到过时的数据这就可以称之为线程安全。我们看一个基本基本的字符串操作来看看看字符串的不可变性的特点。s hello t s # t 和 s 现在指向同一个对象 print(s is t) # 输出 True因为它们确实是同一个 s s.upper() # upper() 方法返回一个新字符串 HELLO print(s) # 输出 HELLO print(t) # 输出 hellot 指向的还是原来的 hello print(s is t) # 输出 Falses 和 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 worldsplit() 将字符串按指定分隔符拆分成列表。如果不指定分隔符默认按空白字符拆分。s apple,banana,orange print(s.split(,)) # [apple, banana, orange]replace() 将字符串中的旧子串替换为新子串。s hello world print(s.replace(world, Python)) # hello Python这三个方法的共同点是它们都创建并返回一个新的字符串对象原字符串纹丝不动。这一章我们从底层存储出发搞清楚了· 字符串为什么是不可变的· 字符串对象在内存中的结构· Unicode编码与内存占用· 字符串驻留机制· 不同拼接方式的性能差异· 和is的区别· 常用操作的底层原理但是我们对字符串本身详细的相关操作没有做过深的讨论。下一章我们将详细聊一聊常见字符串的字面量转义字符基本操作等。