Python 存储字符串时是如何节省空间的?

本文为翻译文章,已得到 @rushter 的许可
原文链接:rushter.com/blog/python…
转载请注明出处

从 Python 3 开始,str 类型代表着 Unicode 字符串。取决于编码的类型,一个 Unicode 字符可能会占 4 个字节,这个有些时候有点浪费内存。

出于内存占用以及性能方面的考虑,Python 内部采用下面 3 种方式来存储 Unicode 字符:

  • 一个字符占一个字节(Latin-1 编码)
  • 一个字符占二个字节(UCS-2 编码)
  • 一个字符占四个字节(UCS-4 编码)

使用 Python 进行开发的时候,我们会觉得字符串的处理都很类似,很多时候根本不需要注意这些差别。可是,当碰到大量的字符处理的时候,这些细节就要特别注意了。

我们可以做一些小实验来体会下上面三种方式的差别。方法 sys.getsizeof 用来获取一个对象所占用的字节,这里我们会用到。

>>> import sys
>>> string = 'hello'
>>> sys.getsizeof(string)
54
>>> # 1-byte encoding
... sys.getsizeof(string + '!') - sys.getsizeof(string)
1
>>> # 2-byte encoding
... string2  = '你'
>>> sys.getsizeof(string2 + '好') - sys.getsizeof(string2)
2
>>> sys.getsizeof(string2)
76
>>> # 4-byte encoding
... string3 = '?'
>>> sys.getsizeof(string3 + '?') - sys.getsizeof(string3)
4
>>> sys.getsizeof(string3)
80
复制代码

如上所示,当字符串的内容不同时,所采用的编码也会不同。需要注意的是,Python 中每个字符串都会另外占用 49-80 字节的空间,用于存储额外的一些信息,比如哈希、字符串长度、字符串字节数和字符串标识。这么一来,一个空字符串会占用 49 个字节,也就好理解了。

我们可以通过 cbytes 直接获取一个对象的编码类型:

import ctypes


class PyUnicodeObject(ctypes.Structure):
    # internal fields of the string object
    _fields_ = [("ob_refcnt", ctypes.c_long),
                ("ob_type", ctypes.c_void_p),
                ("length", ctypes.c_ssize_t),
                ("hash", ctypes.c_ssize_t),
                ("interned", ctypes.c_uint, 2),
                ("kind", ctypes.c_uint, 3),
                ("compact", ctypes.c_uint, 1),
                ("ascii", ctypes.c_uint, 1),
                ("ready", ctypes.c_uint, 1),
                # ...
                # ...
                ]


def get_string_kind(string):
    return PyUnicodeObject.from_address(id(string)).kind
复制代码

然后测试

>>> get_string_kind('Hello')
1
>>> get_string_kind('你好')
2
>>> get_string_kind('?')
4
复制代码

如果一个字符串中的所有字符都能用 ASCII 表示,那么 Python 会使用 Latin-1 编码。简单说下,Latin-1 用于表示前 256 个 Unicode 字符。它能支持很多拉丁语言,比如英语、瑞典语、意大利语等。不过,如果是汉语、日语、西伯尔语等非拉丁语言,Latin-1 编码就行不通了。因为这些语言的文字的码位值(编码值)超过了 1 个字节的范围(0-255)。

>>> ord('a')
97
>>> ord('你')
20320
>>> ord('!')
33
复制代码

大部分语言文字使用 2 个字节(UCS-2)来编码就已经足够了。4 个字节(UCS-4)的编码在保存特殊符号、emoji 表情或者少见的语言文字的时候会用到。

设想有一个 10GB 的 ASCII 文本文件,我们准备将其读到内存里面去。如果你插入一个 emoji 表情到文件中,文件占用空间将会达到 4 倍。如果你处理 NLP 问题较多的话,这种差别你应该能经常体会到。

Python 内部为什么不直接使用 UTF-8 编码

最常见的 Unicode 编码是 UTF-8,但是 Python 内部并没有使用它。

UTF-8 编码字符的时候,取决于字符的内容,占的空间在 1-4 个字节内发生变化。这是一种特别省空间的存储方式,但正因为这种变长的存储方式,导致字符串不能通过下标直接进行随机读取,只能遍历进行查找。比如,如果采用的是 UTF-8 编码的话,Python 获取 string[5] 只能一个一个字符的进行扫描,直至找到目标字符。如果是定长编码的话也就没有问题了,要用一个下标定位一个字符,只需要用下标乘以指定长度(1、2 或者 4)就能确定。

字符串驻留

Python 中的空字符串和 ASCII 字符都会使用到字符串驻留(string interning)技术。怎么理解?你就把这些字符(串)看作是单例的就行。也就是说,两个相同内容的字符串如果使用了驻留的技术,那么内存里面其实就只开辟了一个空间。

>>> a = 'hello'
>>> b = 'world'
>>> a[4],b[1]
('o', 'o')
>>> id(a[4]), id(b[1]), a[4] is b[1]
(4567926352, 4567926352, True)
>>> id('')
4545673904
>>> id('')
4545673904
复制代码

正如你看到的那样,a 中的字符 o 和 b 中的字符 o 有着同样的内存地址。Python 中的字符串是不可修改的,所以提前为某些字符分配好位置便于后面使用也是可行的。

使用到字符串驻留的除了 ASCII 字符、空窜之外,字符长度不超过 20 的串也使用到了同样的技术,前提是这些串的内容在编译的时候就能确定。

这包括:

  • 方法名、类型
  • 变量名
  • 参数名
  • 常量(代码中定义的字符串)
  • 字典的键
  • 属性名

当你在交互式命令行中编写代码的时候,语句同样也会先被编译成字节码。所以说,交互式命令行中的短字符串也会被驻留。

>>> a = 'teststring'
>>> b = 'teststring'
>>> id(a), id(b), a is b
(4569487216, 4569487216, True)
>>> a = 'test'*5
>>> b = 'test'*5
>>> len(a), id(a), id(b), a is b
(20, 4569499232, 4569499232, True)
>>> a = 'test'*6
>>> b = 'test'*6
>>> len(a), id(a), id(b), a is b
(24, 4569479328, 4569479168, False)
复制代码

因为必须是常量字符串会使用到驻留,所以下面的例子不能达到驻留的效果:

>>> open('test.txt','w').write('hello')
5
>>> open('test.txt','r').read()
'hello'
>>> a = open('test.txt','r').read()
>>> b = open('test.txt','r').read()
>>> id(a), id(b), a is b
(4384934576, 4384934688, False)
>>> len(a), id(a), id(b), a is b
(5, 4384934576, 4384934688, False)
复制代码

字符串驻留技术,减少了大量的重复字符串的内存分配。Python 底层通过字典实现的这种技术,这些暂存的字符串作为字典的键。如果想要知道某个字符串是否已经驻留,使用字典的查找操作就能确定。

Python 的 unicode 对象的实现(https://github.com/python/cpython/blob/master/Objects/unicodeobject.c)大约有 16,000 行 C 代码,其中有很多小优化在本文中未提及。如果你想更多的了解 Python 中的 Unicode,推荐你去看一下字符串相关的 PEPs(https://www.python.org/dev/peps/),同时查看下 unicode 对象的源码。

本文首发于公众号「小小后端」。

<think>嗯,用户现在想在Python中将字符串转换为变量名,也就是动态创建变量。这个问题我之前在回答CSV处理的候没有涉及,所以需要结合他们提供的引用内容来解答。根据引用[1]、[2]、[3]、[4],用户提到了使用locals()、vars()、eval()以及exec()等方法。首先,我需要理清这些方法的区别和使用场景。 用户提到eval和locals方法,但根据引用里的例子,可能exec在动态赋值变量更安全?比如引用[3]中的exec('self.j = 0'),这可能在类里面动态设置属性。同,引用[4]提到修改globals()来添加变量,这可能适用于全局变量。需要比较这些方法的优缺点,比如eval的安全性问题,locals()在函数内的局部作用域限制,以及exec的灵活性。 然后,用户可能的需求场景是什么?比如,他们是否在处理CSV数据需要动态创建变量,或者在其他情况下?比如,当读取配置文件,根据字符串键来设置变量。需要给出不同情况下的适用方法,比如在全局使用globals(),在类内部使用exec或setattr。 另外,用户可能关心这些方法的安全性和最佳实践。比如,eval执行任意字符串带来的风险,尤其是在处理外部数据,可能导致代码注入。因此,需要强调在可控环境下使用,或者推荐更安全的方法如字典存储,或者使用setattr来动态设置对象属性。 还要注意,用户可能混淆了变量名和字符串之间的转换。比如,使用字典可能是更好的替代方案,避免动态创建变量名带来的维护问题。比如引用[2]中的例子,用locals()[str1]来获取变量值,但这种方法在函数内部可能不可靠,因为locals()在函数中的行为可能不一致。 需要结构清晰地分点讲解每种方法,并给出示例代码,同指出潜在问题和推荐场景。比如,分三个主要方法:使用globals()/locals()、使用exec函数、使用字典管理。然后比较它们的优缺点,最后建议最佳实践。 另外,用户可能想知道如何反向操作,即从变量名获取字符串,但当前问题主要是字符串转变量名,所以可能需要简要提及,但重点在转换上。例如,引用[3]中的vars(self)[k] = v,展示了如何动态设置类属性,这可能是一个更结构化的方法。 最后,确保回答中引用了提供的引用内容,比如引用[1]提到locals()的字典访问方式,引用[4]提到修改globals()来定义变量。同,结合用户之前的问题,可能他们是在处理数据需要动态变量名,所以可以建议使用字典或类属性来组织数据,而不是动态变量,以提高代码可维护性。</think># Python字符串转变量名的实现方法与安全建议 ## 一、核心实现方法 ### 1. locals()/globals() 方法 通过修改命名空间字典实现变量动态创建: ```python var_name = "dynamic_var" locals()[var_name] = 42 # 局部作用域 print(dynamic_var) # 输出42 globals()["global_var"] = "hello" # 全局作用域 print(global_var) # 输出hello[^1][^4] ``` ### 2. exec 函数 动态执行代码字符串生成变量: ```python var_name = "user_count" exec(f"{var_name} = 100") # 直接创建变量 print(user_count) # 输出100[^3] ``` ### 3. setattr 方法(面向对象场景) 在类实例中动态添加属性: ```python class Config: pass cfg = Config() var_name = "timeout" setattr(cfg, var_name, 30) print(cfg.timeout) # 输出30[^3] ``` ## 二、方法对比与适用场景 | 方法 | 作用域 | 安全性 | 典型应用场景 | |----------------|----------|-------|--------------------------| | globals() | 全局作用域 | 中 | 配置文件加载 | | locals() | 局部作用域 | 低 | 简单脚本快速操作 | | exec | 任意作用域 | 低 | 动态代码生成 | | setattr | 对象属性 | 高 | 面向对象编程 | ## 三、安全注意事项 1. **eval风险示例**: ```python # 危险!可能执行任意代码 user_input = "__import__('os').system('rm -rf /')" eval(user_input) # 将执行危险操作! ``` 2. 推荐替代方案:使用字典管理动态数据 ```python data_dict = {} var_name = "temperature" data_dict[var_name] = 36.5 # 安全的数据存储方式 print(data_dict["temperature"]) # 输出36.5 ``` 3. 沙箱环境建议:使用`ast.literal_eval`替代eval ```python import ast safe_str = "{'name': 'John', 'age': 30}" data = ast.literal_eval(safe_str) # 安全解析数据结构 ``` ## 四、最佳实践建议 1. 优先使用字典存储动态数据 2. 在类中使用setattr管理属性 3. 需要全局变量显式更新globals() ```python def safe_global_update(): var_name = "app_version" globals().update({var_name: "2.1.0"}) print(app_version) # 输出2.1.0[^4] ``` 4. 结合类型检查确保安全 ```python var_name = "max_items" if var_name.isidentifier(): # 验证合法变量名 exec(f"{var_name} = 100") else: raise ValueError("非法变量名") ``` ## 五、高级应用场景 ### 1. 动态配置类实现 ```python class DynamicConfig: def __init__(self, **kwargs): for k, v in kwargs.items(): setattr(self, k, v) config = DynamicConfig(timeout=30, retries=3) print(config.timeout) # 输出30[^3] ``` ### 2. 自动生成测试数据 ```python test_data = {} for i in range(5): var_name = f"test_case_{i}" test_data[var_name] = f"data_{i}" print(test_data["test_case_2"]) # 输出data_2 ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值