简单的Python源码分析——StringIO
简单的py源码分析——StringIO
近几日在架构KolaWriter时,涉及到了相关的内存文本缓冲功能的实现,因此尝试着参考了一下Python中StringIO的实现方式。于是有了这篇文章www
StringIO是一个来自Python标准库io的类。它会在内存中模拟一个以w+方式打开的文件对象。你应该也听说过这样的传说,那就是对于大量的字符串拼接操作来说,使用StringIO是快于直接进行字符串相加的。这里我也进行了一个小测试:
def str_stringio(epoch: int) -> str:
s = StringIO()
for _ in range(epoch):
s.write('a' * np.random.randint(1, 100))
return s.getvalue()
def str_stringio_r(epoch: int) -> str:
s = StringIO('a')
for _ in range(epoch):
s.write('a' * np.random.randint(1, 100))
return s.getvalue()
def str_concat(epoch: int) -> str:
s = ''
for _ in range(epoch):
s += 'a' * np.random.randint(1, 100)
return s
def str_ck(epoch: int) -> str:
for _ in range(epoch):
'a' * np.random.randint(1, 100)
return ''
测试环境为Python3.8.12,这里我取epoch=number=100,利用timeit计算100次运行时间。我得到了如下的结果:
E(X) | S(X) | min(X) | max(X) | |
---|---|---|---|---|
stringio | 0.0223633 | 3.907e-07 | 0.0213613 | 0.0235901 |
stringio-realized | 0.0239235 | 1.50646e-06 | 0.0226005 | 0.028129 |
concat | 0.0243483 | 5.96924e-07 | 0.0228622 | 0.0267949 |
CK | 0.0214586 | 3.95554e-07 | 0.0204597 | 0.0230739 |
取各组平均值,减去空白对照组可以得到平均净用时,再除以净用时最长的concat组,可以得到下表:
类型 | stringio | stringio-realized | concat |
---|---|---|---|
净用时之比 | 0.313066 | 0.853 | 1 |
可以看到,两者的差距确实存在,利用StringIO进行字符串拼接的用时仅为使用字符串相加用时的三成。当然,我们今天的主要目的不是为了测试StringIO有多好用。相信各位也注意到了一个有趣的事情,我们的stringio-r组,在测试代码上仅比stringio组多了一个初始值,而它的净运行时间比stringio组足足多了一倍不止,这又是为什么呢?
要回答这个问题,就需要我们把目光投向cpython解释器的源码部分,来看一看stringio是如何工作的。
基础结构
StringIO
的源码位于Modules/_io/stringio.c。作为一个C级对象,我们首先来看StringIO
的object struct定义:
接下来的代码来自 https://github.com/python/cpython 的main分支,本文写作时的版本号为Python 3.12.0 Alpha 4。下同
typedef struct {
PyObject_HEAD
Py_UCS4 *buf;
Py_ssize_t pos;
Py_ssize_t string_size;
size_t buf_size;
/* stringio 对象可以处于两种状态:正在积累或已实现。
在累积状态下,内部缓冲区不包含任何内容,
并且内容由嵌入式 _PyUnicodeWriter 结构给出。
在实现状态下,内部缓冲区是有意义的,并且
_PyUnicodeWriter 被销毁。
*/
int state; /* ACCUMULATING / REALIZED */
_PyUnicodeWriter writer;
/* 以下几个char类型的字段实际上均被视为布尔型 */
char ok; /* 是否已经初始化 */
char closed; /* 文件是否已经关闭 */
char readuniversal; /* 是否使用decoder */
char readtranslate; /* 是否翻译换行符 */
PyObject *decoder; /* type: IncrementalNewlineDecoder */
PyObject *readnl; /* type: str */
PyObject *writenl; /* type: str */
PyObject *dict; /* __dict__ */
PyObject *weakreflist; /* __weakref__ */
} stringio;
这里我给出了一个大概的字段说明。其中靠后的几个字段,如decoder
等主要用来进行换行符的处理,日常使用中涉及较少,不作为本次的主要研究内容。我们主要关注的是以下两个字段:buf
及writer
。
CPython中的字符与字符串
你可能会对这两个字段的类型有些陌生。这里我简单介绍一下CPython中的字符串表示形式。对于Python3来说,Python中的str
类型在C中的名称不是str
,也不是PyStr
,而是PyUnicode
。其object struct如下:
这部分的声明代码位于Include/cpython/unicodeobject.h,不需要特地看源码,在cpython解释器所在的文件夹中即可找到
/* Object format for Unicode subclasses. */
typedef struct {
PyCompactUnicodeObject _base; /* 这里是继承了CompactUnicode这个结构体 */
union {
void *any;
Py_UCS1 *latin1;
Py_UCS2 *ucs2;
Py_UCS4 *ucs4;
} data; /* Canonical, smallest-form Unicode buffer */
} PyUnicodeObject;
在这个我们似乎看到了一个有点熟悉的类型,和buf
字段相同,就是这个Py_UCS4*
。Py_UCS4
其实就是CPython所使用的字符类型。在虽然Python中没有单独的字符类型,但在C级还是存在这个概念的。CPython定义了3种字符类型,Py_UCS1
、Py_UCS2
和Py_UCS4
,这里的UCS是Universal Multiple-Octet Coded Character Set的简称,后面的数字为字符编码的字节数,有1、2、4三种。Py_UCS1
也就是我们一般在C语言中所使用的char类型。
_PyUnicodeWriter
结构体
到这里,我们就可以理解了,buf
字段的类型类似于char*
,实际上就是一个C级的字符串,只不过每个字符都是是wide char(宽字符)类型。那么writer
的类型_PyUnicodeWriter
又是什么呢?
从字面意思简单理解的话,_PyUnicodeWriter
是一个可以写入PyUnicode
的接口类型。它是一个非常高效的字符串生成器,在CPython内部广为使用,是各种内部对象的repr方法及包括字符解码、格式化等字符串操作的常客。
初始化过程
在初步了解了buf
和writer
两个字段的作用后,我们可以发现一个奇怪的事情。一个是字符数组,另一个则是C级的字符串生成器,二者在功能上有一些重叠之处。那么,二者在StringIO
中是如何相互配合工作的呢?我们把目光放到StringIO
的__init__
上,让我们看一看初始化的过程。
__init__
在C中的具体实现函数为_io_StringIO___init___impl
,签名如下:
/*[clinic input]
_io.StringIO.__init__
initial_value as value: object(c_default="NULL") = ''
newline as newline_obj: object(c_default="NULL") = '\n'
Text I/O implementation using an in-memory buffer.
The initial_value argument sets the value of object. The newline
argument is like the one of TextIOWrapper's constructor.
[clinic start generated code]*/
static int
_io_StringIO___init___impl(stringio *self, PyObject *value,
PyObject *newline_obj);
忽略掉不关心的换行符处理部分,我们可以看以下的内容:
/* Now everything is set up, resize buffer to size of initial value,
and copy it */
self->string_size = 0;
if (value && value != Py_None)
value_len = PyUnicode_GetLength(value);
else
value_len = 0;
if (value_len > 0) {
/* This is a heuristic, for newline translation might change
the string length. */
if (resize_buffer(self, 0) < 0)
return -1;
self->state = STATE_REALIZED;
self->pos = 0;
if (write_str(self, value) < 0)
return -1;
}
else {
/* Empty stringio object, we can start by accumulating */
if (resize_buffer(self, 0) < 0)
return -1;
_PyUnicodeWriter_Init(&self->writer);
self->writer.overallocate = 1;
self->state = STATE_ACCUMULATING;
}
self->pos = 0;
根据value
的情况,初始化被分为了两种情况。第一种情况是在value
为非空字符串的情况下,此时StringIO
的状态字段state
被设置为STATE_REALIZED
。之后的函数调用write_str
,实际上是StringIO.write
的C级实现函数。这里是将字符串value
写入了当前实例中。
而另一种情况则是调用了_PyUnicodeWriter_Init
对writer
字段进行了一次初始化,并将状态设置为了STATE_ACCUMULATING
。
到这里,我们之前测试中的关于stringio-realized组的问题有了一些初步的解释,那就是非空的初始值会让s
处于一个与stringio组的s
不同的状态。我简单的将这两个状态称为积累态(ACCUMULATING)与实现态(REALIZED)。要了解两个状态在实现上的差异,我们还需要往后看。
积累态与实现态
StringIO.write
方法
作为StringIO
中使用频率相当高,且参与初始化过程的方法,write_str
这个函数理所当然的成为了我们下一步的目标。源码如下:
/* Internal routine for writing a whole PyUnicode object to the buffer of a
StringIO object. Returns 0 on success, or -1 on error. */
static Py_ssize_t
write_str(stringio *self, PyObject *obj)
{
Py_ssize_t len;
PyObject *decoded = NULL;
/* 一些前置准备工作 */
if (self->state == STATE_ACCUMULATING) {
if (self->string_size == self->pos) {
/* 这里是ACCUMULATING状态下的实现,decoded就是被处理过的obj */
if (_PyUnicodeWriter_WriteStr(&self->writer, decoded))
goto fail;
goto success;
}
if (realize(self))
goto fail;
}
/* 这里是REALIZED状态下的实现 */
if (self->pos + len > self->string_size) {
if (resize_buffer(self, self->pos + len) < 0)
goto fail;
}
if (self->pos > self->string_size) {
/* In case of overseek, pad with null bytes the buffer region between
the end of stream and the current position.
0 lo string_size hi
| |<---used--->|<----------available----------->|
| | <--to pad-->|<---to write---> |
0 buf position
*/
memset(self->buf + self->string_size, '\0',
(self->pos - self->string_size) * sizeof(Py_UCS4));
}
/* Copy the data to the internal buffer, overwriting some of the
existing data if self->pos < self->string_size. */
if (!PyUnicode_AsUCS4(decoded,
self->buf + self->pos,
self->buf_size - self->pos,
0))
goto fail;
success:
/* Set the new length of the internal string if it has changed. */
self->pos += len;
if (self->string_size < self->pos)
self->string_size = self->pos;
Py_DECREF(decoded);
return 0;
fail:
Py_XDECREF(decoded);
return -1;
}
这里的逻辑还是比较明确的。对于积累态(ACCUMULATING)的实现非常简单,就是调用_PyUnicodeWriter_WriteStr
向writer
中写入了一个Python字符串decoded
,这个decoded
也就是经过了前置步骤处理了换行符的obj
。
实现态(REALIZED)部分,也就是在有非空字符串作为初始值的__init__
函数中实际使用的部分,其实现分为了3步,第一步是在需要写入新区域是进行一个resize_buffer
。这个函数的作用是调整buf
缓冲区的大小使其满足需要。第二步则是对overseek的部分填充为0,第三部才是真正的通过PyUnicode API PyUnicode_AsUCS4
将decoded
写入缓冲区buf
中。
结合之前的初始化过程,我们可以得到一个初步的结论,那就是在积累态下,StringIO
的内部操作对象为writer
,而在实现态下则使用buf
为操作对象。
StringIO.getvalue
方法
为了验证以上结论,我们可以再看一个之前在测试中使用到的方法,就是这个getvalue
。C级实现的函数名为_io_StringIO_getvalue_impl
。源代码如下:
/*[clinic input]
_io.StringIO.getvalue
Retrieve the entire contents of the object.
[clinic start generated code]*/
static PyObject *
_io_StringIO_getvalue_impl(stringio *self)
/*[clinic end generated code: output=27b6a7bfeaebce01 input=d23cb81d6791cf88]*/
{
CHECK_INITIALIZED(self);
CHECK_CLOSED(self);
if (self->state == STATE_ACCUMULATING)
return make_intermediate(self);
return PyUnicode_FromKindAndData(PyUnicode_4BYTE_KIND, self->buf,
self->string_size);
}
先看实现态的实现,就是调用PyUnicode_FromKindAndData
将字符数组buf
转为一个Python字符串。而积累态则转到了另一个函数make_intermediate
:
static PyObject *
make_intermediate(stringio *self)
{
PyObject *intermediate = _PyUnicodeWriter_Finish(&self->writer);
self->state = STATE_REALIZED;
if (intermediate == NULL)
return NULL;
_PyUnicodeWriter_Init(&self->writer);
self->writer.overallocate = 1;
if (_PyUnicodeWriter_WriteStr(&self->writer, intermediate)) {
Py_DECREF(intermediate);
return NULL;
}
self->state = STATE_ACCUMULATING;
return intermediate;
}
这个函数还是非常有意思的,首先是_PyUnicodeWriter_Finish
将writer中的内容生成字符串。同时,这个函数会结束writer的生命周期,使其会处于一个被销毁的状态,所以接下来又是一个_PyUnicodeWriter_Init
重新初始化writer,然后将之前读出来的intermediate
重新写回了writer中。通过这个过程,我们在不改变writer内容的情况下完成了一次读取。
这里我们就可以基本肯定之前的结论,两个状态下的读取与写入的确是依赖于不同的字段进行的。
状态转换
那么什么时候StringIO
的状态才会发生更改呢?由累积态变为实现态可以在如下情况发生,涉及到的代码部分比较多,我做一个简单的总结:
- 写入时
self->string_size != self->pos
- 读取时
self->pos != 0
- 调用
truncate
、readline
或__next__
- 函数
make_intermediate
出错
这里的pos
字段是StringIO
的写入位置。换言之,1、2的意思就是,积累态的StringIO
只允许在末尾写入并从头读取,否则就会进入实现状态。
具体的转换实现函数,在之前的write_str
函数源码中已经出现了一次,就是这个realize
函数:
static int
realize(stringio *self)
{
Py_ssize_t len;
PyObject *intermediate;
if (self->state == STATE_REALIZED)
return 0;
assert(self->state == STATE_ACCUMULATING);
self->state = STATE_REALIZED;
intermediate = _PyUnicodeWriter_Finish(&self->writer);
if (intermediate == NULL)
return -1;
/* Append the intermediate string to the internal buffer.
The length should be equal to the current cursor position.
*/
len = PyUnicode_GET_LENGTH(intermediate);
if (resize_buffer(self, len) < 0) {
Py_DECREF(intermediate);
return -1;
}
if (!PyUnicode_AsUCS4(intermediate, self->buf, len, 0)) {
Py_DECREF(intermediate);
return -1;
}
Py_DECREF(intermediate);
return 0;
}
这个函数的过程就是从writer
中读取出完整的字符串,并通过PyUnicode_AsUCS4
写入字符数组buf
中,完成了内容从嵌入式 _PyUnicodeWriter
结构到内部缓冲区的转移
至于从实现态转为积累态的办法,我并没有在源码中找到。换言之,从积累态到实现态就是一张单程票,一旦转变就无法再回来了。
为什么会有这两种状态
对于这个问题,我想这也是由StringIO
本身的功能决定的。首先,作为字符串拼接的工具类,其实际上是C级对象_PyUnicodeWriter
在Python层面的封装,为字符串的拼接提供了高效接口,也就对应了StringIO
中的积累态。另一方面,作为一个IO类,其实现了文件io接口,这也意味其需要实现文件操作位置移动的功能,而这是_PyUnicodeWriter
无法做到的,因此又引入了buf
字符数组来处理指针移动的问题,也就是StringIO
中的实现态。
总结
在最后,在源码中其实有一段我之前没有放出的注释,我就将其简单翻译一下作为本篇的总结:stringio 对象可以处于两种状态:正在积累或已实现。在累积状态下,内部缓冲区不包含任何内容,并且内容由嵌入式 _PyUnicodeWriter
结构给出。在实现状态下,内部缓冲区是有意义的,并且 _PyUnicodeWriter
被销毁。
到此,希望本文能够带给你一点新的对于StringIO
的认识。