likes
comments
collection
share

Python C语言API系列教程(二、PyObject是什么)

作者站长头像
站长
· 阅读数 16

上一篇文章中,我们讲了Python的C语言API的总体概念,并且实现了一个非常简单的Python模块,但是还遗留了一些问题。在这一章中,我们会讲解PyObject这个对象及其相关的方法,并引入内存管理相关内容。同样,我们也会继续完善datetimecpy项目(repo戳这里),难度也会变大哦。

要点讲解

PyObject介绍

在学习Python的时候你肯定会听过一句话——“在Python的世界中,一切皆对象”,然而有不少读者认为这只是一门面向对象语言的说辞,因为其他面向对象语言(比如Java,Object-C和C#等)都是这么描述自己的语言的。但是Python并不止步于此,除了语法层面的事物(比如listintgenerator等一切)是对象以外,其解释器内部结构(比如帧栈、字节码和异常堆栈等)也是对象,而这个对象在Python层面是object类,在C层面就是PyObject

在C语言API中PyObject是一个结构体,其包含两个成员ob_refcntob_type。前者记录了每个对象的引用次数(本章后续会讲到),而后者是一个指向当前对象描述(PyTypeObject)的指针(暂且称之为对象描述)。也就是说,所有对象都是PyObject对象,区别只是在于描述的指针不同罢了。

PyTypeObject和相关插槽

PyTypeObject是对象描述,同样也是一个对象。在Python的C语言编程中,每一个对象几乎都有这样一个对象描述与其对应。

在使用过程中,PyTypeObject可以静态初始化,也可以动态初始化。其成员变量即为Python对象的种种属性(部分是魔法函数的平替),比如tp_name表示Python对象的名称、tp_str表示Python的__str__函数、tp_call表示Python的__call__函数、tp_init表示Python的__init__函数。这些属性可以按需初始化,只要在集成进Python解释器之前确保调用Py_READY宏可对未初始化的成员进行默认值初始化。因此,这些属性又可以被称为“插槽”。

引用计数

Python是一门有垃圾回收机制(GC)的语言,和Java的GC原理不同,其采用简单的引用计数作为内存跟踪方法。

目前主流的垃圾回收机制的内存跟踪方法有两种,一是引用计数,二是可达性分析。引用计数的思想是当创建一个对象的实例并在堆上申请内存时,对象的引用计数就为1,在其他对象中需要持有这个对象时,就需要把该对象的引用计数加1,需要释放一个对象时,就将该对象的引用计数减1,直至对象的引用计数为0,对象的内存会被立刻释放。Python就是采用这种方式作为内存跟踪方法。

之前讲过PyObject有一个字段ob_refcnt记录了引用次数,但用户不需要直接操作这个变量,而是通过API提供的宏。Py_INCREF宏可以简单的使对象的引用次数加一,而Py_DECREF宏可以使对象的引用次数减一,并在引用次数达到0时销毁对象。

由于引用计数的存在,CPython还引入了引用所有权的概念,即规定了谁来负责操作对象的引用次数。有两种引用所有权:

  • 强引用(strong reference):这个对象的所有权归用户,即用户需要调用Py_INCREFPy_DECREF来控制其引用次数。
  • 借引用(borrowed reference):即“借”来的引用,用户不需要操作其引用次数,API帮用户实现了引用次数的操作。用户只是借来用一下。比如PyList_getItemPyTuple_getItem等API方法。

此外,还引入了一个新的行为,叫偷引用(steal reference),意思是将原本由API负责的对象转为由用户负责,类似“偷”的意思。

一些常用的基本方法

除了一些基本概念外,这里再补充一些常用的API方法——

  • PyArg_ParseTupleAndKeywords:该方法将传入函数的参数(Python对象)转化为C语言类型。方法的参数列表长度不固定,包括传入函数的参数(Python对象)、参数格式等,方法返回一个int值表示是否成功,0表示成功否则失败。用户对其构造的对象是borrowed reference。

  • PyUnicode_FromFormat:该方法将C语言类型对象转变为Python的str对象。方法的参数列表长度不固定,包括待转变的C语言类型变量、参数格式等,方法返回一个PyUnicode对象。用户拥有这个PyUnicode对象的所有权。

  • PyObject_New:该方法新建一个Python对象。方法参数为PyObjectPyTypeObject*,表示需要新建的Python对象类型,其返回值为新建的Python对象。用户拥有这个Python对象的所有权。

操作实践

现在我们继续完善我们的datetimecpy项目——

同样,本系列文章代码的repo在这里,本章代码在Ch-2分支上。

与上一篇一样,我们同样需要先用Python打一个草稿,它需要实现以下功能:

>>> import datetimecpy
>>> date1 = datetimecpy.date()                      # __new__函数和__init__ 函数,默认传参
>>> date1                                           # __repr__函数
datetimecpy.date(1683644224)
>>> date2 = datetimecpy.date(timestamp=1683644000)  # __init__函数,timestamp传参
>>> date2
datetimecpy.date(1683644000)
>>> date1 < date2                                   # __lt__函数、__le__函数,__gt__函数等
False
>>> datetimecpy.date.today()                        # today函数(类函数)
datetimecpy.date(1683644345)
>>> date1.totimestamp()                             # totimestamp函数(成员函数)
1683644224

首先引入头文件,由于需要刻画方法描述,所以在上一张基础上多引入一个头文件——

#define PY_SSIZE_T_CLEAN
#include <Python.h>
#include "structmember.h"

然后就是我们本期的主角,date对象,是一个PyObject结构体——

typedef struct {
    PyObject_HEAD

    long long timestamp;

} Date;

在这个结构体中,首先用宏PyObject_HEAD来表明这是一个PyObject,然后在里面定义一个成员timestamp,它是一个long long类型的C变量,用于存放当前时间戳。

然后实现该对象三个基本函数,tp_dealloc函数、tp_new函数和tp_init函数,其中tp_dealloc函数是必选的析构函数,另外两个函数分别对应Python的__new__函数和__init__函数,是可选的。

void Date_dealloc(PyObject* self) {
    Py_TYPE(self)->tp_free(self);
}

tp_dealloc函数只做了一件事,通过Py_TYPE宏获取当前实例的PyTypeObject,然后调用它的tp_free方法将其释放。那么PyTypeObject怎么定义呢?

static PyTypeObject Date_type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "datetimecpy.date",
    .tp_basicsize = sizeof(Date),
    .tp_dealloc = (destructor) Date_dealloc,
    .tp_free = PyObject_Del
};

和之前的PyModuleDef一样,我们采用静态初始化的方法定义一个PyTypeObject对象,并同样用一个宏PyVarObject_HEAD_INIT(NULL, 0)来初始化它。除此以外,还需要规定Python对象的名称tp_name、基本大小tp_basicsize、刚刚定义的析构函数tp_dealloc以及析构函数中用到的释放函数tp_free。释放函数是用了API提供的PyObject_Del方法。

这里采用C99的指定初始化(designated initialization),目前已经被主流C编译器支持了。

然后我们再实现另外两个函数,tp_new函数和tp_init函数——

PyObject* Date_new(PyTypeObject* type, PyObject* args, PyObject* kwds) {
    Date* self;
    self = (Date*)type->tp_alloc(type, 0);
    if (self) {
        time_t now = time(NULL);
        self->timestamp = (long long)now;
    }
    return (PyObject*)self;
}

tp_new函数的签名和其他函数稍许不同,其第一个参数是PyTypeObject。我们同样利用它的tp_alloc函数初始化它的内存空间。tp_alloc函数可以不需要在PyTypeObject中定义,因为后续的Py_READY宏可以帮助我们自动填写缺失的成员。内存分配好以后将timestamp成员变量初始化为当前时间戳并返回。

int Date_init(PyObject* self, PyObject* args, PyObject* kwds) {
    static char* kwlist[] = {"timestamp", NULL};
    if (!PyArg_ParseTupleAndKeywords(args, kwds, "|L", kwlist, &((Date*)self)->timestamp)) {
        return -1;
    }
    return 0;
}

tp_init函数是__init__函数的平替,参数是三个PyObject*self就是Python的self代表当前对象。args代表参数列表,相当于Python的*argskwds代表参数键值对,相当于Python的**kwargs。初始化函数本质通过PyArg_ParseTupleAndKeywords函数实现一次赋值。前面已经讲过这个函数的用法,具体到这个场景就是:将__init__函数的参数以列表或键值对的形式转换成C语言的long long类型并赋值给成员变量timestamp。需要注意的是这里有一个参数格式|L,代表转换成一个可选的long long 类型。除此以外还有多种表示,比如i代表ints代表const char*O代表PyObject*等等,具体可以参考官方文档

同样,我们也需要将实现好的tp_new函数和tp_init函数放入描述内——

static PyTypeObject Date_type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "datetimecpy.date",
    .tp_basicsize = sizeof(Date),
    .tp_dealloc = (destructor) Date_dealloc,
    .tp_init = Date_init,
    .tp_new = Date_new,
    .tp_free = PyObject_Del
};

接下来,我们实现另外两个魔术函数__repr__函数和richcompare函数(包括__lt____gt____ne__等)——

PyObject* Date_repr(PyObject* self) { 
    return PyUnicode_FromFormat("%s(%lld)", Py_TYPE(self)->tp_name, ((Date*)self)->timestamp); }
    
PyObject* Date_richcompare(PyObject* self, PyObject* other, int op) { 
    long long diff = ((Date*)self)->timestamp - ((Date*)other)->timestamp; 
    Py_RETURN_RICHCOMPARE(diff, 0, op);
}

__repr__直接调用了PyUnicode_FromFormat方法将对象以Pythonstr的形式展现出来。这个方法前文已经讲过,类似于sprintf函数将参数格式化返回,这里参数格式是%s(%lld),其中%s代表字符串%lld代表长整型,与给定对象相结合就是这种效果datetimecpy.date(1683644224)。这个方法是返回一个用户管理的引用对象,但是由于直接返回了,所以我们不需要对其Py_INCREF或者Py_DECREF

richcompare方法参数依次是比较对象、被比较对象以及比较符号,因此它对应着以下几个魔法函数:

  • __lt__,小于
  • __le__,小于等于
  • __eq__, 等于
  • __ne__,不等于
  • __gt__,大于
  • __ge__。大于等于

类似Python的实现方式,该方法直接通过两个数值相减并调用宏实现的。同样,我们需要把上面两个函数放到描述内——

static PyTypeObject Date_type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "datetimecpy.date",
    .tp_basicsize = sizeof(Date),
    .tp_dealloc = (destructor) Date_dealloc,
    .tp_repr = (reprfunc) Date_repr,
    .tp_init = Date_init,
    .tp_new = Date_new,
    .tp_richcompare = (richcmpfunc)Date_richcompare,
    .tp_free = PyObject_Del
};

然后是两个函数,一个是类函数today,它用来返回当前日期的时间戳;另一个是成员函数totimestamp,它用来返回给定日期的时间戳。现在来看一下具体实现——

PyObject* Date_today(PyObject* self, PyObject* Py_UNUSED(args)) {
    Date* retval = PyObject_New(Date, &Date_type);
    if (!retval) {
        return NULL;
    }
    time_t now = time(NULL);
    retval->timestamp = (long long)now;
    return retval;
}

同样非常直白,直接调用C语言相关API,并返回。这里调用了一次PyObject_New新建了一个对象,同样由于直接返回的,所以不需要我们对其引用次数操作。

PyObject* Date_totimestamp(PyObject* self, PyObject* Py_UNUSED(args)) {
    long long _timestamp = ((Date*)self)->timestamp;
    return PyLong_FromLongLong(_timestamp);
}

这个函数的实现过程也无需多言。但是关键来了,怎么区分是类函数还是成员函数呢?我们通过方法描述实现——

static PyMethodDef date_methods[] = {
    {"today", Date_today, METH_NOARGS | METH_CLASS, PyDoc_STR("today\n-\n\n Get the current date.")},
    {"totimestamp", Date_totimestamp, METH_NOARGS, PyDoc_STR("totimetamp\n-\n\n Get the current timestamp.")},
    {NULL, NULL}
};

将刚刚定义好的函数放入PyMethodDef的列表内,列表元素按照函数名,函数指针,函数标志和文档说明排列,并在最后加上{NULL, NULL}哨兵。函数标志有很多种,用来描述不同的性质,比如METH_NOARGS代表函数不传参而METH_CLASS代表是一个类方法。具体说明可以参考官方文档

最后,把这个描述放在PyTypeObject内,最终的效果是这样的——

static PyTypeObject Date_type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    .tp_name = "datetimecpy.date",
    .tp_basicsize = sizeof(Date),
    .tp_dealloc = (destructor) Date_dealloc,
    .tp_repr = (reprfunc) Date_repr,
    .tp_str = (reprfunc) Date_repr,
    .tp_getattro = (getattrofunc) PyObject_GenericGetAttr,
    .tp_setattro = (setattrofunc) PyObject_GenericSetAttr,
    .tp_flags = (Py_TPFLAGS_DEFAULT|Py_TPFLAGS_BASETYPE),
    .tp_doc = doc_date,
    .tp_init = Date_init,
    .tp_new = Date_new,
    .tp_richcompare = (richcmpfunc)Date_richcompare,
    .tp_methods = date_methods,
    .tp_free = PyObject_Del
};

这里面我又补充了部分插槽,比如tp_getattrotp_setattro等。

到目前为止,我们已经完成了datetimecpydate对象的设计。要能够在程序中使用我们还必须在import的过程中成功加载这个对象。回到第一章讲过的模块初始化函数,增加以下内容——

PyMODINIT_FUNC PyInit_datetimecpy() {
    if (PyType_Ready(&Date_type) < 0) {
        return NULL;
    }
    PyObject* m = PyModule_Create(&datetimecpy_def);
    if (!m)
    {
        return NULL;
    }
    PyModule_AddStringConstant(m, "__author__", "littlebutt");
    PyModule_AddStringConstant(m, "__version__", "1.0.0");
    Py_INCREF(&Date_type);
    if (PyModule_AddObject(m, "date", (PyObject*)&Date_type) < 0)
    {
        Py_DECREF(&Date_type);
        Py_DECREF(m);
        return NULL;
    }
    return m;
}

像先前所讲的那样PyType_Ready初始化对象,然后通过PyModule_AddObject方法将对象加载到模块内,以使它在被导入的时候添加对象,最后再返回模块。

现在我们看一下效果——

Python C语言API系列教程(二、PyObject是什么)

如果能这样就说明全部正确,离我们目标又进了一步!

小结

本篇文章介绍了C语言API的重要对象PyObject,并展示了如何用C语言定义一个Python对象。下一章开始会讲解Python内部对象的API,并会涉及到部分虚拟机的讲解。