likes
comments
collection
share

Python C语言API系列教程(三、Python内置对象C语言接口)

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

上一篇文章介绍了PyObject对象,并实现了date的对象。在这篇文章中,我会介绍Python内置类型对应的PyObject对象,并对date模块做修改以更符合标准库的datetime.date的特性。同样,源码repo在这里

高能预警!本章要点讲解会涉及部分CPython虚拟机的知识,操作实践也需要扎实的C语言编程基础。如果精读源码的话编程能力一定会有所提高!

要点讲解

PyLongObject及其相关函数

相信很多小伙伴都是从int对象开始学习Python的,但在学习的过程中有没有想过Python的int类型支持的范围是多少呢?其实这个问题的答案因版本而异,就当前版本来说(>=3.5)CPython是用long数组来表示一个int类型,但由于数组长度不定,所以理论上这个范围即是当前内存。而表示这个表示int的对象就是PyLongObject

先看一下PyLongObject是怎么定义的——

// 定义
typedef uint32_t digit;

struct _longobject {
    PyObject_VAR_HEAD
    digit ob_digit[1];
};

typedef struct _longobject PyLongObject;

//初始化
PyLongObject *
_PyLong_New(Py_ssize_t size)
{
    PyLongObject *result;
    ...
    result = PyObject_MALLOC(offsetof(PyLongObject, ob_digit) + size*sizeof(digit)); // int的范围根据传入的size决定
    ...
    return (PyLongObject*)PyObject_INIT_VAR(result, &PyLong_Type, size);
}

来自Python 3.9

这一段源自CPython虚拟机源码,首先要知道这是一个四字节对象(uint32_t类型)的数组。我们暂且着重了解相关的API。

  • PyLong_Check:这个函数可以检查对象是否是一个PyLongObject对象或者是其子类。
  • PyLong_FromLong:这个函数可以将C语言的long类型转换成Python的int类型,即PyLongObject。在之前的文章中也介绍过类似的函数。
  • PyLong_AsLong:这个函数将Python的int类型转换成C语言的long类型。 类似的API有很多,具体可以参考官方文档

PyUnicodeObject及其相关函数

类似PyLongObject,Python的str类型是用PyUnicodeObject表示的。值得注意的是原来CPython是用ASCII码来存储str类型的字符串的,在3.3版本以后改用Unicode码来存储。这导致于str类型相关的API有两套,但是我们主要了解Unicode的一套。

typedef struct {
    PyCompactUnicodeObject _base; // 兼容原本的ASCII的字符串
    union {
        void *any;
        Py_UCS1 *latin1;
        Py_UCS2 *ucs2;
        Py_UCS4 *ucs4;
    } data;                     // 最小Unicode buffer,真正用来表示Unicode的字符串
} PyUnicodeObject;

来自Python 3.9

上面代码展示了PyUnicodeObject内部结构,其分别用Py_UCS1(8位,UTF8)、Py_UCS2(16位,UTF16)和Py_UCS4(32位,UTF32)来表示单个字节长度,而any则表示任意内容数据。主要看API——

  • PyUnicode_FromString:将C语言风格的字符串转化成PyUnicodeObject对象。
  • PyUnicode_FromFormat:根据给定格式将C语言字符串转化为PyUnicodeObject对象。具体格式参考官方文档
  • PyUnicode_AsUTF8:将PyUnicodeObject对象按照UTF-8的格式转换成C语言风格字符串。另外还有按照UTF-16和UTF-32等其他编码格式的API。 更多API可以参考官方文档

PyFloatObject及其相关函数

相比于其他Python类型,PyFloatObject的实现稍微简单直白点。它用一个double类型存储Python程序中的浮点数,因此它的精度和范围都是与C语言保持一致的。

typedef struct {
    PyObject_HEAD
    double ob_fval;
} PyFloatObject;

来自Python 3.9

与前面介绍的类型类似,PyFloatObject也有相应的API——

  • PyFloat_Check:检查一个类型是否为float或者是其子类。
  • PyFloat_FromDouble:将一个C语言的double类型转化成Python的float类型。
  • PyFloat_AsDouble:将Python的float类型转化成C语言的double类型。 更多API可以参考官方文档

几个单例

在Python中,除了有特定类型的对象还有几个单例对象。它们在整个CPython解释器的生命周期中以单例的形式存在,而我们操作它实际上是操作它的引用次数。典型的单例对象有NoneFalseTrue,它们分别对应的C语言对象为Py_NonePy_TruePy_False

PyObject _Py_NoneStruct = {
    _PyObject_EXTRA_INIT
    1, &_PyNone_Type
};

#define Py_None (&_Py_NoneStruct)

来自Python 3.9

以上是Py_None对象的实现,可以看出它就是一个对象的指针。

struct _longobject _Py_FalseStruct = {
    PyVarObject_HEAD_INIT(&PyBool_Type, 0)
    { 0 }
}; 
struct _longobject _Py_TrueStruct = {
    PyVarObject_HEAD_INIT(&PyBool_Type, 1)
    { 1 }
};

#define Py_False ((PyObject *) &_Py_FalseStruct)
#define Py_True ((PyObject *) &_Py_TrueStruct)

来自Python 3.9

以上分别是Py_FalsePy_True对象的实现,本质上是0和1两个对象的指针。

我们对单例的使用往往都是对其引用次数的就改。比如说一个函数要返回None,通常的做法是先增加其引用次数,再返回。

Py_INCREF(Py_None);
return Py_None;

这两行可以用一个宏代替

Py_RETURN_NONE

Py_TruePy_False也是如此。

操作实践

继续完善datetimecpy项目,这次我们丰富一些date对象的成员。

代码仓库在这里,本章对应的代码在Ch-3分支上。

本章我们要实现一下功能——

>>> import datetimecpy
>>> date1 = datetimecpy.date()
>>> date1
datetimecpy.date(year=2023, month=5, day=16)
>>> date2 = datetimecpy.date(year=2023, month=5, day=1)
>>> date2
datetimecpy.date(year=2023, month=5, day=1)
>>> date1 < date2
False
>>> date1.strftime('%m/%d')
'05/16'
>>> datetimecpy.date.fromtimestamp(0)
datetimecpy.date(year=1970, month=1, day=1)

与之前不同,date对象需要包括三个成员yearmonthday。它们都是int类型,但我们暂时都当作PyObject声明——

typedef struct {
    PyObject_HEAD
    
    PyObject* year;         // year

    PyObject* month;        // month

    PyObject* day;          // day
} Date;

相应的,其析构函数、__new__函数以及__init__函数也要更新。这里挑选两个函数展示一下。

void Date_dealloc(PyObject* self) {
    Py_CLEAR(((Date*)self)->year);
    Py_CLEAR(((Date*)self)->month);
    Py_CLEAR(((Date*)self)->day);
    Py_TYPE(self)->tp_free(self);
}

在析构函数中,我们需要对每一个成员函数都解除引用(减少引用次数),然后对其自身调用tp_free函数析构。这里我们用Py_CLEAR来代替Py_DECREF是因为前者可以析构后让其赋值为NULL,这样如果碰到重复析构的话就不会出现引用次数为负数导致解释器崩溃了。

int Date_init(PyObject* self, PyObject* args, PyObject* kwds) { 
    ...
    time_t now = time(NULL);
    struct tm tm;
    if (_Date_timestamp2tm(now, &tm) != 0) {
        errorn = -1;
    }
    
    static char* kwlist[] = {"year", "month", "day", NULL};
    PyObject* year = NULL;
    PyObject* month = NULL;
    PyObject* day = NULL;
    if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOO", kwlist, &year, &month, &day)) {
        return -2;
    }
    if (year == NULL && errorn != -1) {
        year = PyLong_FromLong((long)(tm.tm_year + 1900));
    }
    ...
    
    if (_Date_check_year(year) < 0) {
        PyErr_SetString(PyExc_OverflowError, "The year argument must be in [1, 9999]");
        return -3;
    }
    ...
    
    PyObject* tmp;
    tmp = ((Date*)self)->year;
    Py_XINCREF(year);
    ((Date*)self)->year = year;
    Py_XDECREF(tmp);
    ...
    
    return 0;

__init__函数中需要考虑三个问题,用户可能缺省参数,参数可能溢出以及初始化之前的参数的析构。下面分别考察这三个问题——

首先用户可能传入缺省参数。为了应对这种情况,C语言API提供了一种缺省语义的符号|,在所有涉及格式化的函数中|后面的参数都是可以缺省的。因此,在声明变量的时候,我们直接将变量赋值为NULL。后续通过判断变量是否为NULL来判断用户是否缺省,如果缺省则为它赋值,比如在这个例子中缺省的year就是当前年份。

其次是参数溢出,或者说非法参数。检测这种非法参数本身很简单,但是怎么表现出来?如果单纯的返回一个非零的值解释器不会做出任何处理,推荐的做法是将其作为异常抛出,比如调用PyErr_SetString或者PyErr_Format函数。其实CPython虚拟机在解释指令的时候会初始化一个解释器状态表,在这个状态表里存放着异常标志来表示当前代码是否存在异常。虚拟机会不断的检测这个异常标志并打印异常堆栈(如果有)。我们也可以通过PyErr_Occurred来获取当前可能存在的异常或者通过PyErr_Clear来清除异常标志。

最后,由于__init__函数在调用前成员变量可能已经赋值了,为了不让这些变量重新赋值导致内存泄漏,我们通常会用一个临时变量去获取之前的赋值并Py_XDECREF它。

__repr__函数和richcompare函数也需要同步修改,原理和上一章一样,不再重复赘述。

这里重点来了!如何实现格式化输出时间这个功能? 这里选择了一个讨巧的办法,利用C语言提供的APIstrftime实现的,而且官方也是这么做的。

PyObject* Date_strftime(PyObject* self, PyObject* args, PyObject* kwds) {
    PyObject* format; // Unicode类型的str
    static char* keywords[] = {"format", NULL};
    if (! PyArg_ParseTupleAndKeywords(args, kwds, "U:strftime", keywords, &format)) {
        return NULL;
    }
    
    PyObject* ret = NULL;
    PyObject* format_ascii; // ASCII类型的str
    struct tm tm; // 用于格式化的tm

    const char* fmt; // 输入的format的C的字符串
    char* outbuf = NULL; // 输出的C的字符串
    size_t fmtlen, buflen;
    size_t i;

    // 将Unicode类型的str转化为ASCII类型的str
    format_ascii = PyUnicode_EncodeLocale(format, "surrogateescape");
    if (format_ascii == NULL)
        return NULL;
    // 将Python的字符串str转换为C的char*
    fmt = PyBytes_AS_STRING(format_ascii);
    ...

    for (i = 1024;; i += i) {
        outbuf = (char*)PyMem_Malloc(i * sizeof(char));
        if (outbuf == NULL) {
            PyErr_NoMemory();
            break;
        }
        buflen = strftime(outbuf, i, fmt, &tm);
        if (buflen > 0 || i >= 256 * fmtlen) {
            ret = PyUnicode_DecodeLocaleAndSize(outbuf, buflen, "surrogateescape");
            PyMem_Free(outbuf);
            break;
        }
        PyMem_Free(outbuf);
    }
    Py_DECREF(format_ascii);
    return ret;
}

在此之前需要将用户输入的Unicode编码的字符串转换成ASCII编码字符串,具体API可以通过官方文档了解。然后构造好tm对象后通过for循环不断遍历用户的输入字符串,并通过PyMem_Malloc构建一块存放格式化完成的字符串。最后将该字符串转化为Unicode编码格式返回。

看完代码可能有的小伙伴会问为什么需要一个for循环来遍历用户的输入,原因很简单,就是不知道用户的输入具体有多长需要用1024个字节慢慢“丈量”用户的输入,从而节约内存。这种方法在很多场景下都会用到。

这里还涉及到CPython内存相关的API,这个会在第七章着重讲解。

fromtimestamp函数也是同样的原理,在理解好strftime函数后再理解起来不难,也是通过构造一个tm对象来存储当前的时间并最终转化成对应的date对象。

如果按照代码一步一步实现最终的效果是这样的——

Python C语言API系列教程(三、Python内置对象C语言接口)

小结

本章内容对date对象做了彻底的改造以符合标准库的datetime中的dateAPI。同时,本篇文章也稍微深入的带着讲了一点虚拟机的实现(其实深入下去可以讲很多)以及一些C语言使用技巧。下一章会介绍一些Python内置的容器及其C语言API。