12 Native方法:打通底层软件与高层应用的关键
你好,我是海纳。
在过去的几节课中,我们实现了函数的基本功能。实际上,在 Python 中有很多内建函数,例如,range、print、len 等都是内建函数。这些内建函数往往是使用 C/C++ 在虚拟机内部实现的,所以我们把这些函数也称为 native 函数。
这节课我们就通过实现 len 方法来讲解如何在虚拟机里实现 native 函数。
实现内建函数(Builtin Function)
在 Python 开发者看来,内建函数和普通函数是一样。也就是说,开发者使用自定义的函数和使用 len、print 等内建函数时,并不需要感知它们之间有什么不同。
这就决定了,在虚拟机层面,我们希望内建函数与 FunctionObject 所代表的自定义函数也是统一的。所以我们选择继续使用 FunctionObject 来代表内建函数,不同点在于,普通的FunctionObject 是由 MAKE_FUNCTION 字节码使用 CodeObject 主动创建的。前边两节课,我们重点介绍了这个机制。
但是内建函数并没有对应的字节码,它的所有实现都在虚拟机内部,也就是说,内建函数都是使用 C++ 来实现的。这里需要一种方法把 CALL_FUNCTION 与虚拟机内部的实现联系起来。我们还是要在 FunctionObject 身上想办法。
我们可以在 FunctionObject 里增加一个方法,名为 call。当调用一个 FunctionObject 时,如果虚拟机检查到当前的 FunctionObject 是内建函数的话,就通过 call 方法去调用相关的逻辑。
要完成以上功能,第一个要解决的问题就是如何判断一个 FunctionObject 所代表的函数是不是内建函数。
这个问题的本质是判断对象的类型,虚拟机使用对象的 Klass 来判断一个对象的类型。在这里,我们继续使用这种方法,引入一个 NativeFunctionKlass 来代表内建函数。
// runtime/functionObject.hpp
class NativeFunctionKlass : public Klass {
private:
NativeFunctionKlass();
static NativeFunctionKlass* instance;
public:
static NativeFunctionKlass* get_instance();
};
// runtime/functionObject.cpp
NativeFunctionKlass* NativeFunctionKlass::instance = NULL;
NativeFunctionKlass* NativeFunctionKlass::get_instance() {
if (instance == NULL)
instance = new NativeFunctionKlass();
return instance;
}
NativeFunctionKlass::NativeFunctionKlass() {
set_super(FunctionKlass::get_instance());
}
如果要创建一个内建函数,只需要把它的 Klass 指向 NativeFunctionKlass 的实例即可。
第二个要解决的问题,是如何实现 call 方法。
print、len、range 等都是内建函数,它们 call 方法的逻辑各不相同。一种策略是像之前实现 print 方法一样,为每一种不同的对象实现不同的 print 方法。这样做的话,就是为 len 这个函数对象创建一个独立的 Klass,为 range 这个函数对象也创建一个独立的 Klass,在 Klass 内部实现不同的 call 的逻辑。这样做当然可以,但是太复杂了。
我们回到问题的本源,len 和 range 都是内建函数,不同的只是它们被调用时要执行什么功能。那我们其实可以使用函数指针来完成这个需求。
HiObject* len(ObjList args);
typedef HiObject* (*NativeFuncPointer)(ObjList args);
class FunctionObject : public HiObject {
private:
...
NativeFuncPointer _native_func;
public:
FunctionObject(NativeFuncPointer nfp);
...
HiObject* call(ObjList args);
};
在 FunctionObject 的定义里,引入一个函数指针,这个指针指向的函数可以接受 ObjList 作为参数,并且返回值类型是 HiObject*(第 2 行和第 7 行)。
我们把所有的参数都放入到参数列表 args 中去了,而 args 是不定长的,所以理论上这种类型可以接受任意多的参数,用于实现内建函数是绰绰有余的。
接下来就是上面的声明所对应的具体实现:
FunctionObject::FunctionObject(NativeFuncPointer nfp) {
_func_code = NULL;
_func_name = NULL;
_flags = 0;
_globals = NULL;
_native_func = nfp;
set_klass(NativeFunctionKlass::get_instance());
}
HiObject* FunctionObject::call(ObjList args) {
return (*_native_func)(args);
}
在实现了 NativeFunctionKlass 以后,就可以在 FunctionObject 中使用它了,将FunctionObject 的 Klass 设为 NativeFunctionKlass 的实例,这个 FunctionObject 就代表一个内建函数了(第 8 行)。同时,要把函数指针指向具体的函数实现(第 6 行)。在 call 方法里,通过函数指针调用到具体的方法。
接下来我们就以一个具体的例子来说明如何实现内建函数。
实现 len 方法
我们以 len 方法为例,在 len 的具体实现中,只需要调用对象的 len 方法即可。目前虚拟机里只有 String 类型,所以我们在 StringKlass 中添加 len 的实现。
// runtime/functionObject.cpp
HiObject* len(ObjList args) {
return args->get(0)->len();
}
// object/hiObject.cpp
HiObject* HiObject::len() {
return klass()->len(this);
}
// object/hiString.cpp
HiObject* StringKlass::len(HiObject* x) {
return new HiInteger(((HiString*)x)->length());
}
列表、元组、字典等类型也支持 len 方法。我们这里先略过,等到后面实现列表类和字典类的时候,会把相应类型的 len 方法都正确地实现。
最后,还要把 len 函数加到 _builtins 表中去。建立起 Python 中“len”符号与内建函数的联系。
#define PUSH(x) _frame->stack()->add((x))
Interpreter::Interpreter() {
_builtins = new Map<HiObject*, HiObject*>();
...
_builtins->put(new HiString("len"), new FunctionObject(len));
}
void Interpreter::build_frame(HiObject* callable, ObjList args) {
if (callable->klass() == NativeFunctionKlass::get_instance()) {
PUSH(((FunctionObject*)callable)->call(args));
}
else if (callable->klass() == FunctionKlass::get_instance()) {
FrameObject* frame = new FrameObject((FunctionObject*) callable, args);
frame->set_sender(_frame);
_frame = frame;
}
}
在 build_frame 里,我们要对 native 函数和普通函数加以区分。前边已经分析过了,区分的关键就在于 Klass 的类型,如果是 FunctionKlass,就仍然走原来的路径,创建 FrameObject。如果是 NativeFunctionKlass,就调用 FunctionObject 的 call 方法,并且把 call 的返回值放到栈里。
我们通过一个测试用例来验证我们的实现。
这里有一点需要注意的是,在某些情况下,py_len 中对 len 函数的调用会被翻译成LOAD_GLOBAL。而我们之前在实现 LOAD_GLOBAL 的时候,只去检查了 FrameObject 里的全局变量表。实际上这是不够的,我们还应该在查找失败以后,继续查找内建变量表。这个逻辑与 LOAD_NAME 是一样的,代码实现也相对简单,你可以自己实现,这里就不再列出源代码了。
实现print函数
实现了第一个内建函数以后,我们再来修改 print 函数就很简单了。从第 4 节课开始,我们就一直在使用各种方法对 print 方法进行规避,现在终于到了正面解决它的时候了。
第一步,在 builtin 里增加一项,将 "print"
字符串与 object_print 方法关联起来。
Interpreter::Interpreter() {
_builtins = new Map<HiObject*, HiObject*>();
// ...
_builtins->put(new HiString("print"), new FunctionObject(object_print));
_builtins->put(new HiString("len"), new FunctionObject(len));
}
第二步,实现 object_print 方法。
HiObject* object_print(ObjList args) {
HiObject* arg0 = args->get(0);
arg0->print();
printf("\n");
return Universe::HiNone;
}
最后,把 CALL_FUNCTION 里的规避方法直接去掉就可以了,这个字节码的实现也变得非常简洁了。
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::CALL_FUNCTION:
if (op_arg > 0) {
args = new ArrayList<HiObject*>(op_arg);
while (op_arg--) {
args->set(op_arg, POP());
}
}
fo = static_cast<FunctionObject*>(POP());
build_frame(fo, args);
if (args != NULL) {
delete args;
args = NULL;
}
break;
...
}
}
}
上述两个例子详细地展示了如何新增内建函数。从例子中可以看到,在打通了第一个例子以后,新增内建函数就变得非常简单了。
接下来,我们再看内建类型的方法是如何实现的。
实现方法(Method)
我们使用函数(function)和方法(method)这两个名词来区分一个函数是否与类绑定。在 C++ 这种面向对象编程语言中,如果一个函数不与类相关,在类的外部独立定义,就会被称为函数。
如果一个函数在类中定义,只有通过类的实例才能调用,这种函数就被称为方法。这里我们是严格区分函数和方法的。例如下面的两个例子。
def foo():
print("hello")
class A(object):
def func(self):
print(self)
print("world")
a = A()
a.func()
foo 不和任何对象相联系,独立定义,它就是一个函数,而 func 则必须通过 A 的实例 a 进行调用,而且 a 还会继续作为实参传入到 func 中去,也就是说,第一个参数 self 实际上就是对象 a。
通过这两个例子我们展示了函数和方法的区别。由于我们的虚拟机现在还不能支持 class 语句定义类,所以你可以使用标准 Python 虚拟机来执行这个例子,便于观察这个例子的执行结果。
虽然目前的虚拟机还不能支持自定义类型,但也实现了多种内建类型,例如整数、字符串和列表等,所以我们仍然可以实现某些内建类型的方法。接下来我们就以 String 类型的 upper 方法来说明如何实现一个内建类型的方法。
我们从最简单的例子开始。Python 中的 String 类型,定义了一个方法 upper,它的作用是返回一个新的字符串,新字符串中的所有字母都变成大写的。通过下面的例子来观察 upper 方法的效果。
使用标准虚拟机执行这个例子,结果是 hello 和 HELLO,这说明对字符串 s 调用 upper 方法,并不会改变 s 的内容,而是会返回一个新的字符串。我们再来看这一段代码所对应的字节码是什么。
1 0 LOAD_CONST 0 ('hello')
2 STORE_NAME 0 (s)
2 4 LOAD_NAME 0 (s)
6 LOAD_METHOD 1 (upper)
8 CALL_METHOD 0
10 STORE_NAME 2 (t)
4 12 LOAD_NAME 3 (print)
14 LOAD_NAME 0 (s)
16 CALL_FUNCTION 1
18 POP_TOP
5 20 LOAD_NAME 3 (print)
22 LOAD_NAME 2 (t)
24 CALL_FUNCTION 1
26 POP_TOP
28 LOAD_CONST 1 (None)
30 RETURN_VALUE
consts
'hello'
None
names ('s', 'upper', 't', 'print')
这段字节码里的大多数指令我们都已经见过了。新的指令只有两条:LOAD_METHOD 和 CALL_METHOD(第 5 行和第 6 行)。
LOAD_METHOD 是一个带有参数的字节码,它的参数是一个整数,这是一个 names 表中的序号。在这个具体的例子里,参数为 1,也就是 names 表中的第 1 项,正是字符串 "upper"
, 这就意味着 LOAD_METHOD 的真实参数其实是方法名 upper。
在 LOAD_METHOD 之前,已经通过 LOAD_NAME 把字符串 s 加载到栈顶了。而 LOAD_METHOD 是一个需要两个操作数的字节码,一个是调用方法的目标对象,另一个是方法的名称。
目标对象通过预先加载到操作数栈来提供,方法的名称则通过 names 表的序号,以字节码参数的形式提供。
另外一个字节码 CALL_METHOD 的实现和 CALL_FUNCTION 的实现是一样的,它可以直接复用 CALL_FUNCTION 的代码,这里就不再赘述了。
由于 upper 是字符串类的一个方法,我们自然会想到在代表字符串类的 StringKlass 中增加这个方法。其实,不仅仅是 StringKlass 中会增加新的方法,其他所有类型的 Klass 都有定义新的方法,例如列表对象的 append 方法、字典对象的 update 方法等等。所以我们可以在 Klass 中引入一个 Map,专门用于记录某一种类型上的所有属性和方法。
class Klass {
private:
...
HiDict* _klass_dict;
public:
...
void set_klass_dict(HiDict* dict) { _klass_dict = dict; }
HiDict* klass_dict() { return _klass_dict; }
...
};
我们可以在 StringKlass 的 klass_dict 中,以字符串 "upper"
为 key,以 string_upper 方法为 value。这样一来,我们就可以把方法与其名称联系起来了。
// runtime/hiString.cpp
HiObject* string_upper(ObjList args) {
HiObject* arg0 = args->get(0);
assert(arg0->klass() == StringKlass::get_instance());
HiString* str_obj = (HiString*)arg0;
int length = str_obj->length();
if (length <= 0)
return Universe::HiNone;
char* v = new char[length];
char c;
for (int i = 0; i < length; i++) {
c = str_obj->value()[i];
// convert to upper
if (c >= 'a' && c <= 'z')
v[i] = c - 0x20;
else
v[i] = c;
}
HiString* s = new HiString(v, length);
delete[] v;
return s;
}
// runtime/universe.cpp
void Universe::genesis() {
HiTrue = new HiInteger(1);
HiFalse = new HiInteger(0);
HiNone = new HiObject();
// initialize StringKlass
HiDict* klass_dict = new HiDict();
StringKlass::get_instance()->set_klass_dict(klass_dict);
klass_dict->put(new HiString("upper"), new FunctionObject(string_upper));
}
string_upper 函数实现了 upper 方法的逻辑(第 2 行至第 26 行)。upper 方法的逻辑比较简单,思路就是对字符串里的所有字符进行遍历,如果该字符是小写字母,就将其变成大写字母。做法就是直接将字符减去 32,即 16 进制的 20,因为大写字母的 ASCII 码值比相应的小写字母的值小 32。
genesis 函数又把它封装成了一个内建方法。以字符串 "upper"
为 key,将这个 function 放到了 klass_dict 中(第 35 行至 37 行)。
这里要多解释一下 genesisi 方法。这个方法只在虚拟机初始化的时候调用一次。为什么要把 StringKlass 的初始化放到这个地方呢?能不能直接在 StringKlass 的构造函数里完成初始化呢?
答案是不能,因为我们在初始化 StringKlass 的 klass_dict 时,会使用字符串 "upper"
,它是一个 HiString 对象。而 HiString 对象又依赖于 StringKlass。这种循环依赖会使程序陷入无限递归调用中。
为了解决这个问题,我们只能把 StringKlass 的初始化逻辑搬到外面来,而最合适做初始化的地方,自然就是这个只在虚拟机启动时执行一次的“创世纪”函数。
然后,我们来实现 LOAD_METHOD 这条指令。
// [runtime/interpreter.cpp]
void Interpreter::run(CodeObject* codes) {
_frame = new FrameObject(codes);
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::LOAD_ATTR:
case ByteCode::LOAD_METHOD:
v = POP();
w = _frame->_names->get(op_arg);
PUSH(v->getattr(w));
break;
...
}
}
}
// object/hiObject.cpp
HiObject* HiObject::getattr(HiObject* x) {
HiObject* result = Universe::HiNone;
result = klass()->klass_dict()->get(x);
return result;
}
查找属性和方法的逻辑都被封装到对象的 getattr 方法里了。好像一切都是正常的,但我们思考一下,upper 方法的参数是怎么传递的,问题就出现了。
upper 方法看上去是不用传任何参数的,但实际上,它却有一个隐含的参数:调用方法时的那个目标对象,在这节课的例子中,就是字符串“hello”。
虚拟机需要一种机制来传递这个隐式的参数。在之前的讲解中,我们已经明确了函数和方法的不同。函数没有隐含的参数,但是方法有,所以我们可以为方法定义一种新的类型,让它完成传递隐式参数的功能。这个新的类型就是 MethodObject。
// runtime/functionObject.hpp
// Method objects.
class MethodKlass : public Klass {
private:
MethodKlass();
static MethodKlass* instance;
public:
static MethodKlass* get_instance();
};
class MethodObject : public HiObject {
friend class MethodKlass;
private:
HiObject* _owner;
FunctionObject* _func;
public:
MethodObject(FunctionObject* func) : _owner(NULL), _func(func) {
set_klass(MethodKlass::get_instance());
}
MethodObject(FunctionObject* func, HiObject* owner) : _owner(owner), _func(func) {
set_klass(MethodKlass::get_instance());
}
void set_owner(HiObject * x) { _owner = x; }
HiObject* owner() { return _owner; }
FunctionObject* func() { return _func; }
};
// runtime/functionObject.cpp
/*
* Operations for methods
* Method is a wrapper for function.
*/
MethodKlass* MethodKlass::instance = NULL;
MethodKlass* MethodKlass::get_instance() {
if (instance == NULL)
instance = new MethodKlass();
return instance;
}
MethodKlass::MethodKlass() {
set_klass_dict(new HiDict());
}
上述代码定义了 MethodObject 和 MethodKlass。定义一种新的 Object 以及它所对应的 Klass 对于我们来说已经算得上是轻车熟路了。
在这段代码里,我们看到 MethodObject 不过是 FunctionObject 的一层封装而已。MethodObject 与 FunctionObject 的唯一区别就是 MethodObject 多了一个 _owner 属性。
定义完了 MethodObject 以后,我们终于可以把 getattr 的逻辑补全了。如果从 klass_dict 中得到的是一个 FunctionObject,那么我们应该构建一个 MethodObject,把 FunctionObject 与目标对象绑定在一起。
HiObject* HiObject::getattr(HiObject* x) {
HiObject* result = Universe::HiNone;
result = klass()->klass_dict()->get(x);
if (result == Universe::HiNone)
return result;
// Only klass attribute needs bind.
if (MethodObject::is_function(result)) {
result = new MethodObject((FunctionObject*)result, this);
}
return result;
}
LOAD_METHOD 终于完成了,这条字节码执行成功以后,加载到栈顶的就是一个正确的 MethodObject 了。
最后一步,CALL_FUNCTION 处还要增加对 MethodObject 的支持。所以我们要在 build_frame 中对 MethodObject 加以处理。
void Interpreter::build_frame(HiObject* callable, ObjList args) {
if (callable->klass() == NativeFunctionKlass::get_instance()) {
PUSH(((FunctionObject*)callable)->call(args));
}
else if (callable->klass() == MethodKlass::get_instance()) {
MethodObject* method = (MethodObject*) callable;
if (!args) {
args = new ArrayList<HiObject*>(1);
}
args->insert(0, method->owner());
build_frame(method->func(), args);
}
else if (callable->klass() == FunctionKlass::get_instance()) {
FrameObject* frame = new FrameObject((FunctionObject*) callable, args);
frame->set_sender(_frame);
_frame = frame;
}
}
栈顶那个被调用的对象如果是 MethodObject 时,我们就将其 owner 放到参数列表的第一位(第 5 至 11 行)。我们正是通过这种方式将隐式参数与实参一起传给方法的。编译运行,就会发现这节课开始的那个 test_upper 的例子可以正确执行了。
到此为止,虚拟机中已经有了基本的内建类型、整数和字符串,也有了基本的函数和方法的功能。函数和方法还有更多的机制,但这需要虚拟机中支持了更多的内建对象以后才能实现。在完善函数的全部特性之前,我们下一节课需要先实现列表和字典这两种重要的内建类型。
总结
我们这节课重点介绍了如何实现内建函数和内建方法。我们使用 NativeFunctionKlass 来标识内建函数。一个 FunctionObject 的类型是内建函数,那么它就会包含一个函数指针,指向 C++ 所实现的函数。
在虚拟机的初始化阶段,builtin 结构中把字符串与 FunctionObject 关联起来,这样就可以使 LOAD_NAME 字节码正确地将 FunctionObject 加载到栈顶。
当函数被调用时,可以通过函数指针调用到相关的逻辑里。你可以看一下整体的流程图。
在这节课的第二部分,我们讲解了如何实现一个内建方法。函数和方法的不同就在于是否绑定对象。所以内建方法需要使用 LOAD_METHOD 字节码进行加载,在加载的过程中,虚拟机将方法与对象进行绑定。
思考题
你是否能向虚拟机中添加更多的内建函数呢?欢迎你把你的想法分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!
- 有风 👍(0) 💬(0)
学习到这里,忍不住给海纳老师点个赞。
2025-01-05 - ifelse 👍(0) 💬(0)
学习打卡
2024-10-27 - 冯某 👍(0) 💬(0)
这里没有留言
2024-10-24