楔子

介绍完 bytes 对象在底层的数据结构之后,我们来研究一下它支持的操作,由于操作定义在类型对象中,显然我们需要查看 bytes 类型。

bytes 类型

根据解释器 API 的命名规则,bytes 类型在底层应该对应 PyBytes_Type。

// Objects/bytesobject.c
PyTypeObject PyBytes_Type = {
    PyVarObject_HEAD_INIT(&PyType_Type, 0)
    "bytes",
    PyBytesObject_SIZE,
    sizeof(char),
    // ...
    &bytes_as_number,                           /* tp_as_number */
    &bytes_as_sequence,                         /* tp_as_sequence */
    &bytes_as_mapping,                          /* tp_as_mapping */
    // ...
};

实例对象的大小信息保存在类型对象中,由 tp_basicsize 和 tp_itemsize 负责维护。

  • tp_basicsize:实例对象的基本大小,对于 bytes 对象来说就是 PyBytesObject_SIZE。
  • tp_itemsize:如果实例对象是变长对象,并且结构体本身还保存了具体的元素,那么该字段则表示每个元素的大小,否则为 0。对于 bytes 对象来说就是 sizeof(char),即 1 字节。

其中 PyBytesObject_SIZE 是一个宏,等于 offsetof(PyBytesObject, ob_sval) + 1,也就是结构体的起始位置到 ob_sval 字段的偏移量再加 1。显然这个大小是固定不变的基础大小,那么它等于多少呢?

typedef struct {
    PyObject_VAR_HEAD
    Py_hash_t ob_shash;
    char ob_sval[1];
} PyBytesObject;

很明显,大小是 32 + 1 = 33 字节,然后再加上 sizeof(char) * ob_size 就是整个 bytes 对象的大小。由于 sizeof(char) 等于 1,所以 bytes 对象的大小等于 PyBytesObject_SIZE 加上 ob_size,我们后面介绍 bytes 对象的创建时会看到。

然后看一下方法簇,我们发现这三个方法簇 bytes 对象居然都支持。

// Objects/bytesobject.c

static PyNumberMethods bytes_as_number = {
    0,              /*nb_add*/
    0,              /*nb_subtract*/
    0,              /*nb_multiply*/
    bytes_mod,      /*nb_remainder*/
};

static PySequenceMethods bytes_as_sequence = {
    // 计算长度,如 len(b"abc")
    (lenfunc)bytes_length,         /*sq_length*/
    // 将两个 bytes 对象相加
    // 比如 b"abc" + b"def"
    (binaryfunc)bytes_concat,      /*sq_concat*/
    // 将 bytes 对象重复 N 次
    // 比如 "abc" * 3
    (ssizeargfunc)bytes_repeat,    /*sq_repeat*/
    // 基于索引获取 bytes 对象的指定元素
    // 比如 b"abc"[1]
    (ssizeargfunc)bytes_item,      /*sq_item*/
    0,                             /*sq_slice*/
    0,                             /*sq_ass_item*/
    0,                             /*sq_ass_slice*/
    // 判断 bytes 对象是否包含某个元素
    (objobjproc)bytes_contains     /*sq_contains*/
};

static PyMappingMethods bytes_as_mapping = {
    // 获取 bytes 对象的长度
    (lenfunc)bytes_length,
    // 基于切片截取 bytes 对象
    (binaryfunc)bytes_subscript,
    0,
};
  • 对于数值型操作,bytes 对象实现了 nb_remainder,它居然支持求余数,这是怎么回事?好吧,我表现的有些刻意了,其实就是一个字节串的格式化操作。
  • 对于序列型操作,bytes 对象支持五个,从名字上看也知道是做什么的,不过这些我们一会儿都会说。
  • 对于映射型操作,bytes 对象支持两个。虽然 bytes 对象不是字典,但解释器允许它做一些类似于映射的操作,这种设计使得 bytes 对象具有更加丰富和灵活的操作接口。

下面我们就来详细介绍这些操作的底层实现,它们都位于 Objects/bytesobject.c 中。

bytes 对象的格式化

bytes 对象借用取模运算符 % 实现格式化,它对应 bytes_as_number->nb_remainder 字段,该字段被赋值为 bytes_mod。

static PyObject *
bytes_mod(PyObject *self, PyObject *arg)
{
    if (!PyBytes_Check(self)) {
        Py_RETURN_NOTIMPLEMENTED;
    }
    // PyBytes_AS_STRING 会返回 PyBytesObject 的 ob_sval 字段
    // PyBytes_GET_SIZE 会返回 PyBytesObject 的 ob_size 字段,即长度
    // arg 是传递的参数,它是一个元组
    return _PyBytes_FormatEx(PyBytes_AS_STRING(self), PyBytes_GET_SIZE(self),
                             arg, 0);
}

最终会调用 _PyBytes_FormatEx 进行格式化,我们再以 Python 为例。

info = b"name: %s, age: %d"
print(info % (b"satori", 17))  # b'name: satori, age: 17'

bytes 对象的格式化,在工作中其实用的不多。

计算 bytes 对象的长度

计算字节串的长度会执行 bytes_as_sequence->sq_length,该字段被赋值为 bytes_length。

static Py_ssize_t
bytes_length(PyBytesObject *a)
{
    return Py_SIZE(a);
}

Py_SIZE 是一个宏,我们之前说过,它会返回对象的 ob_size。

name = b"satori"
# 直接返回 ob_size
print(len(name))  # 6

ob_size 维护了 bytes 对象的有效字节个数,计算长度的时候直接返回。

将 bytes 对象重复 N 次

bytes 对象支持乘法操作,可以重复指定次数,举个例子。

word = b"abc"
# 重复 3 次
print(word * 3)  # b'abcabcabc'

我们看到这里使用了乘法运算符,所以很容易联想到 PyNumberMethods 的 nb_mul,但对于 bytes 对象而言却不是这样,它对应的是 PySequenceMethods 的 sq_repeat。所以 Python 的同一个操作符,在底层会对应不同的函数,比如 long_mul 和 float_mul、以及这里的 bytes_repeat,在 Python 的层面都是 * 这个操作符。

下面我们看一下具体逻辑,它由 bytes_repeat 函数实现。

static PyObject *
bytes_repeat(PyBytesObject *a, Py_ssize_t n)
{
    Py_ssize_t i;
    Py_ssize_t j;
    Py_ssize_t size;  // 新创建的 bytes 对象的 ob_size
    PyBytesObject *op;  // 指向新创建的 bytes 对象
    size_t nbytes;  // 应该为 bytes 对象内部的 char 数组(ob_sval)申请多大内存
    
    // 如果 bytes 对象乘上一个小于 0 的数,那么等价于乘以 0
    if (n < 0)
        n = 0;
    // Py_SIZE(a) * n 就是新创建的 bytes 对象的长度
    // 如果这个长度超过了 PY_SSIZE_T_MAX,那么报错,字节串过长
    // 另外注意这里的条件,正常思路是 Py_SIZE(a) * n > PY_SSIZE_T_MAX
    // 但为了避免 Py_SIZE(a) * n 发生溢出,所以改成了除法
    // 这个和我们写二分查找求平均值是一个道理
    if (n > 0 && Py_SIZE(a) > PY_SSIZE_T_MAX / n) {
        PyErr_SetString(PyExc_OverflowError,
            "repeated bytes are too long");
        return NULL;
    }
    // 将 Py_SIZE(a) * n 赋值给 size
    size = Py_SIZE(a) * n;
    // 如果 size 和 Py_SIZE(a) 相等,说明 n 为 1,或者 Py_SIZE(a) 为 0
    // 但不管哪一种,都意味着新创建的 bytes 对象和原始的 bytes 对象是相同的
    // 既然这样的话,就没必要创建了,直接给原始的 bytes 对象的引用计数加一,然后返回即可
    if (size == Py_SIZE(a) && PyBytes_CheckExact(a)) {
        Py_INCREF(a);
        return (PyObject *)a;
    }
    // 需要为内部的 char 数组申请的内存大小,一个元素一个字节
    // 注:严格意义上说,nbytes 应该等于 char 数组的内存大小减 1
    // 因为额外存储 '\0' 所需的一字节被算在了 PyBytesObject_SIZE 里面
    // 所以这个 nbytes 的含义其实和 bytes 对象的长度是等价的
    nbytes = (size_t)size;
    // PyBytesObject_SIZE + nbytes 便是 bytes 对象的内存大小了
    // 但如果两者相加的结果反而小于等于 nbytes,说明产生溢出了(发生了环绕)
    if (nbytes + PyBytesObject_SIZE <= nbytes) {
        // 那么报错,字节串太长了
        PyErr_SetString(PyExc_OverflowError,
            "repeated bytes are too long");
        return NULL;
    }
    // 为新创建的 bytes 对象申请 PyBytesObject_SIZE + nbytes 大小的内存
    op = (PyBytesObject *)PyObject_MALLOC(PyBytesObject_SIZE + nbytes);
    // 如果 op 为 NULL,说明内存不足
    if (op == NULL)
        return PyErr_NoMemory();
    // 初始化,将 op->ob_type 设置为 &PyBytes_Type,将 op->ob_size 设置为 size
    (void)PyObject_INIT_VAR(op, &PyBytes_Type, size);
    // 将 ob_shash 初始化为 -1,等到需要计算的时候,再更新 ob_shash
    // 一旦更新,后续就不用再计算了,会直接返回 ob_shash
    op->ob_shash = -1;
    // ob_sval 要额外存储一个 '\0',显然它位于索引为 size 的位置
    // 因此有效字符的数量为 size,但 ob_sval 的大小为 size + 1
    // 那么问题来了,既然这样的话,nbytes 应该等于 size + 1 才对,为啥等于 size 呢
    // 很简单,原因已经说过了,因为 PyBytesObject_SIZE 为 offsetof(PyBytesObject, ob_sval) + 1
    // offsetof(PyBytesObject, ob_sval) 表示从结构体起始位置到 ob_sval 的偏移量
    // 或者你也可以理解为 ob_sval 之前的所有字段的大小
    // 但它又额外加上了 1,所以 '\0' 需要的空间已经被申请了,因此没有问题
    op->ob_sval[size] = '\0';
    
    // 此时对象的内存就已经申请好了,类型、长度等元数据也设置好了,接下来就是拷贝元素了
    // 首先做一个快分支判断,如果原始的 bytes 对象的长度为 1,证明新的 bytes 对象的所有字节都是一样的
    // 直接将 op->ob_sval 里面的 n 个元素设置为 a->ob_sval[0] 即可
    if (Py_SIZE(a) == 1 && n > 0) {
        memset(op->ob_sval, a->ob_sval[0] , n);
        return (PyObject *) op;
    }
    // 否则将 a->ob_sval 里面除了 '\0' 之外的有效字符拷贝到 op->ob_sval 里面
    // 每次拷贝 Py_SIZE(a) 个字符,直到拷贝的字符个数达到 size
    i = 0;
    if (i < size) {
        memcpy(op->ob_sval, a->ob_sval, Py_SIZE(a));
        i = Py_SIZE(a);
    }
    while (i < size) {
        j = (i <= size-i)  ?  i  :  size-i;
        memcpy(op->ob_sval+i, op->ob_sval, j);
        i += j;
    }
    // 将 PyBytesObject * 类型的 op 转成 PyObject *,然后返回,交给变量保存
    return (PyObject *) op;
}

所以通过观察源码,你会发现 Python 里面的操作并没有什么神奇的,简单思考一下就知道它的底层逻辑。再比如后续介绍的切片截取,即使现在还没看具体源码,但也能猜到底层就是单纯的 for 循环。

基于索引和切片获取元素

bytes 对象也支持通过索引和切片截取指定部分的元素。

buf = b"abcde"
# 基于索引获取元素,拿到的是整数
print(buf[0])  # 97
# 基于切片获取元素,拿到的依旧是字节串
print(buf[0: 1])  # b'a'

那么底层是怎么做的呢?

static PyObject*
bytes_subscript(PyBytesObject* self, PyObject* item)
{
    // 如果是 buf[0] 这种,那么 self 就是 buf,item 就是 0
    // 如果是 buf[0: 3] 这种,那么 self 就是 buf,item 就是 slice(0, 3)
    // 所以要对 item 进行检测,判断它到底是索引还是切片
    if (PyIndex_Check(item)) {
        // 如果 item 是整数或者实现了 tp_as_number->nb_index
        // 那么表示索引,会转成 Py_ssize_t,如果转换失败,那么会设置异常回溯栈
        Py_ssize_t i = PyNumber_AsSsize_t(item, PyExc_IndexError);
        // 如果 PyErr_Occurred() 为真,那么表示有异常发生
        // 但问题是这里为什么要多一个 i == -1 呢?我们稍后再说
        if (i == -1 && PyErr_Occurred())
            return NULL;
        // 如果转换之后发现 i 小于 0,表示使用的是负数索引
        // 那么要加上长度,变成正数索引,因此负数索引本质上也是 Python 的一个语法糖
        if (i < 0)
            i += PyBytes_GET_SIZE(self);
        // 到这里发现 i 如果还小于 0,或者本身大于等于长度,那么索引越界
        if (i < 0 || i >= PyBytes_GET_SIZE(self)) {
            PyErr_SetString(PyExc_IndexError,
                            "index out of range");
            return NULL;
        }
        // 否则说明索引 i 是合法的,那么获取数组 ob_sval 中索引为 i 的元素
        // 但拿到的是 C 的整数,所以还要基于 C 整数创建 Python 整数,然后返回
        return PyLong_FromLong((unsigned char)self->ob_sval[i]);
    }
    // 如果 item 是切片
    else if (PySlice_Check(item)) {
        // 我们知道切片有三个属性,分别是起始位置、终止位置、步长
        Py_ssize_t start, stop, step, slicelength, i;
        size_t cur;
        char* source_buf;
        char* result_buf;
        PyObject* result;
        // 解析切片,将内部属性赋值给 start、stop、step
        // 关于 PySlice_Unpack 我们在介绍切片的时候说过
        if (PySlice_Unpack(item, &start, &stop, &step) < 0) {
            return NULL;
        }
        // 这个函数在介绍切片的时候也说过
        // 它会调整 start 和 stop 的值,比如将负数转成正数,然后返回应该遍历的元素个数
        slicelength = PySlice_AdjustIndices(PyBytes_GET_SIZE(self), &start,
                                            &stop, step);
        // 如果小于等于 0,说明截取不到任何元素,因此直接返回空字节串
        if (slicelength <= 0) {
            // 基于 C 的字符数组和拷贝的字节数,创建 Python 的字节串
            return PyBytes_FromStringAndSize("", 0);
        }
        // 如果 start 为 0,step 为 1,并且截取的元素个数和原始字节串的长度相等
        // 比如 buf[::] 这种,说明截取之后的字节串和原始字节串相同
        // 那么直接给原始字节串增加一个引用计数,然后返回即可
        else if (start == 0 && step == 1 &&
                 slicelength == PyBytes_GET_SIZE(self) &&
                 PyBytes_CheckExact(self)) {
            Py_INCREF(self);
            return (PyObject *)self;
        }
        // 如果步长为 1,那么从索引为 start 的位置截取 slicelength 个字节即可
        // PyBytes_AS_STRING(self) 会返回字节串的 ob_sval,即 C 字符数组
        // ob_sval + start 会将指针偏移到索引为 start 的位置
        // 然后通过 C 的 memcpy 函数从 start 开始拷贝 slicelength 个字节
        else if (step == 1) {
            return PyBytes_FromStringAndSize(
                PyBytes_AS_STRING(self) + start,
                slicelength);
        }
        // 否则说明步长不为 1,此时只能使用循环了
        else {
            // 拿到原始字节串的 ob_sval
            source_buf = PyBytes_AS_STRING(self);
            // 创建长度为 slicelength 的字节串
            // 注意:此时内部字符数组的容量已经有了,但还没有元素
            result = PyBytes_FromStringAndSize(NULL, slicelength);
            if (result == NULL)
                return NULL;
            // 拿到新创建的字节串的 ob_sval
            result_buf = PyBytes_AS_STRING(result);
            // 从 start 开始遍历,遍历 slicelength 次,指针每次跳 step 个元素
            for (cur = start, i = 0; i < slicelength;
                 cur += step, i++) {
                // 将元素设置进去
                result_buf[i] = source_buf[cur];
            }
            // 返回
            return result;
        }
    }
    // 如果 item 既不是整数也不是切片,那么报错
    else {
        PyErr_Format(PyExc_TypeError,
                     "byte indices must be integers or slices, not %.200s",
                     Py_TYPE(item)->tp_name);
        return NULL;
    }
}

虽然代码有点长,但逻辑一点都不难。还是那句话,对于 C 这样朴素的语言来说,就是 if 判断加循环。

聊一聊异常

在看 bytes_subscript 源码时,我们提出了一个问题。

上面代码中为什么要有一个 i == -1 判断呢?要解释这一点,首先必须要理解 Python 的异常是怎么抛出来的。

所谓的抛异常,本质上就是底层的某个 C 函数的代码逻辑出现了问题,不能继续执行了,于是将异常信息设置到回溯栈中,并给出一个表示错误的返回值。当解释器发现返回值不对时,就知道程序出错了,于是将回溯栈的异常写入到 stderr(标准错误输出)中。

比如这里,当检测到索引小于 0 或大于等于长度时,就知道索引越界了,不能再执行了。于是会通过 PyErr_SetString 将异常设置到回溯栈中,并返回了 NULL,表示错误的返回值。

因为对于当前函数来说,它的返回值类型是 PyObject *,如果正常执行,那么返回值应该指向一个合法的 PyObject。但当逻辑出现错误时,就意味着函数不能再执行了,于是设置异常并返回 NULL。而解释器在看到返回值为 NULL 时,就知道该函数执行出现错误了,那么会将回溯栈里的异常信息输出到 stderr 中,

data = []
# data[1] 在底层会执行 bytes_subscript(data, 1)
# 但是发现索引 1 大于等于长度,于是会将异常信息写入到 stderr,并返回 NULL
# 解释器发现返回的是 NULL,就知道执行出错了,否则返回值一定会指向一个合法的 PyObject
# 于是解释器会将回溯栈里的异常写入到 stderr,并终止运行(暂时不考虑异常捕获)
print(data[1])

在 Python 中看到的就是下面这个样子。

所以这就是抛异常的本质,如果 C 函数执行逻辑有问题,那么会以异常的形式将信息写入到回溯栈,并给出一个表示错误的返回值。外界发现返回值有问题时,就知道执行出错了,最终会由解释器将回溯栈里的异常写入到 stderr 当中。

既然 C 函数执行出现问题会设置异常,那么判断函数执行是否有问题,除了看它的返回值是否正常之外,还可以通过检测异常回溯栈。如果回溯栈里面有异常,调用 PyErr_Occurred 会返回真,否则返回假。

但 PyErr_Occurred 的效率稍微低一些,而直接判断返回值会更快,因为只是一个比较操作。比如这里的 bytes_subscript 函数,只需要检测它的返回值是否等于 NULL 即可判断函数执行是否出现异常。因为返回值类型是 PyObject *,如果正常执行,返回值一定不是 NULL,否则说明程序出问题了。

但 PyNumber_AsSsize_t 函数则不同,该函数的返回值类型是整型。

索引要么是整数,要么是实现了 __index__ 的类的实例对象,内部调用的 _PyNumber_Index 会统一将 item 转成 Python 整数并返回,如果转换失败则设置异常并返回 NULL。

而 PyNumber_AsSsize_t 函数则负责将 Python 整数转成 C 的 ssize_t 整数,但当它发现返回的 value 没有指向一个合法的 PyObject,而是 NULL,就知道 _PyNumber_Index 执行失败了。那么对于 PyNumber_AsSsize_t 而言,它也不能再执行了,应该返回一个表示错误的返回值。但 PyNumber_AsSsize_t 的返回值类型不是 PyObject *,而是整型,所以它不能返回 NULL。那么返回多少呢?解释器使用 -1 来充当表示错误的哨兵返回值。

但 -1 除了可以表示出现错误,也可能是函数正常执行,而返回值本身就是 -1。所以和指针类型不同,对于指针类型,通过返回值是否为 NULL 即可判断函数是否执行正常,而返回整型则需要借助 PyErr_Occurred。

比如发现 i 不等于 -1,说明 PyNumber_AsSsize_t 执行正常,不用再判断了。如果 i 等于 -1,那么则需要进一步检查异常回溯栈,如果栈不为空,证明确实发生错误了。如果栈为空,证明此时返回的 -1 不是因出现错误而返回的哨兵值,而是函数正常执行、但返回值本身就是 -1。

假设 PyNumber_AsSsize_t 因为索引类型不合法而返回了 -1,那么 bytes_subscript 就知道调用出现了错误,于是也不能再执行了,于是立即返回。由于它的返回值类型是 PyObject *,所以会返回 NULL。而最后当解释器看到 bytes_subscript 返回的是 NULL 时,就知道程序应该报错,于是会将回溯栈里的异常抛出来,并结束进程。

小结

以上我们就介绍了 bytes 对象(字节串)的一些操作的底层实现,不过还没结束,下一篇文章我们来聊一聊 bytes 对象的加法运算。

当然加法运算本身很简单,之所以单独摘出来是因为背后涉及一个非常重要的概念:缓冲区,它也是 Numpy 得以实现的关键,下一篇文章细聊。


 

欢迎大家关注我的公众号:古明地觉的编程教室。

如果觉得文章对你有所帮助,也可以请作者吃个馒头,Thanks♪(・ω・)ノ。