20 创建对象:支持对象创建和访问属性以完成基本对象系统
你好,我是海纳。
上一节课,我们通过 class 关键字定义了类型对象。同时,第 18 课也讲过可以通过 list、dict、str 等类型对象来创建实例。自定义类型对象也应该与内建类型对象具有同样的功能,开发者应该可以像函数一样调用类型对象,创建类型实例。
这一节课,我们就来实现这个功能。
通过自定义类型创建实例
下面这个例子展示了如何像函数一样调用类型对象来创建对应的实例。
这个字节码和之前通过 list、int 等创建实例时的字节码是相同的,都是生成了 CALL_FUNCTION 指令,这里我就不再重复了。
ListKlass 里已经实现了 allocate_instance 方法,用来创建新的列表对象。和它差不多,我们也可以在 Klass 中实现这个方法,从而让普通的自定义类型对象也可以创建实例。你可以看一下对应的代码。
HiObject* Klass::allocate_instance(HiList* args) {
HiObject* inst = new HiObject();
inst->set_klass(this);
return inst;
}
Klass 在创建实例的时候,只要把实例对象的 klass 指针指向自己就完成了最简单的创建对象功能。增加了这个方法以后,刚刚那个例子就能正确执行了。
动态设置对象属性
自定义类型的对象和内建类型的对象不一样,它可以动态添加属性,而内建类型对象是不行的。你可以看一下我给出的这个例子。
class A(object):
value = 1
a = A()
print(a.value)
#this is OK
a.field = "hello"
# 44 LOAD_CONST 2 ('hello')
# 47 LOAD_NAME 2 (a)
# 50 STORE_ATTR 4 (field)
print(a.field)
# this is wrong
#lst = []
#lst.field = "hello"
b = A()
# this is wrong, too
#print(b.field)
这段代码展示了如何为对象 a 设置一个它本来没有属性,因为对象 a 是由自定义类型 A 实例化而得到的(第 8 行)。这一行代码所对应的字节码我以注释的形式写在里面了,其中出现了一个新的字节码:STORE_ATTR。它的作用是把 a 的 field 属性设置成字符串hello。
请注意,这个字节码仅仅修改 a 这一个对象,它不会对类型 A 起作用。也就是说,在修改对象 a 以后,代码再通过 A 创建另外一个对象 b, b 不会出现 field 这个属性(第 20 行)。这也就提示了我们,需要在对象上添加一个属性字典,用来记录这些动态添加的属性。
我们在 HiObject 的定义里添加 _obj_dict,之后就可以实现 STORE_ATTR 这条字节码了。
// [runtime/interpreter.cpp]
void Interpreter::eval_frame() {
while (_frame->has_more_codes()) {
unsigned char op_code = _frame->get_op_code();
...
switch (op_code) {
...
case ByteCode::STORE_ATTR:
u = POP();
v = _frame->_names->get(op_arg);
w = POP();
u->setattr(v, w);
break;
...
}
}
}
// [object/hiObject.cpp]
HiObject* HiObject::setattr(HiObject* x, HiObject* y) {
return klass()->setattr(this, x, y);
}
// [object/klass.cpp]
// setattr for normal object.
HiObject* Klass::setattr(HiObject* obj, HiObject* x, HiObject* y) {
obj->obj_dict()->put(x, y);
return Universe::HiNone;
}
对于普通对象,setattr 方法的实现比较简单,只需要把 key 和 value 放到属性字典里就可以了。但是 TypeOjbect 的做法有些不一样。因为 TypeObject 代表的是一个类型,对它进行设置就应该放到代表类型的 Klass 的 klass_dict 里去,这样才能保证所有的实例都可以通过访问自己的 Klass 来获取类型中定义的属性,比如例子里 class A 的 value 属性。所以我们可以来实现 TypeObject 的 setattr 方法,你可以看一下对应的代码。
HiObject* TypeKlass::setattr(HiObject* obj, HiObject* x, HiObject* y) {
obj->as<HiTypeObject>()->own_klass()->klass_dict()->put(x, y);
return Universe::HiNone;
}
这段代码的作用是在 TypeObject 对应的 own_klass 中修改类的属性,这就保证了设置 TypeObject 属性的时候,修改的是类属性。
同时,getattr 方法也要做相应地修改。
// [object/hiObject.cpp]
HiObject* HiObject::getattr(HiObject* x) {
return klass()->getattr(this, x);
}
// [object/klass.cpp]
// getattr for normal object.
// a = A()
// a.b = 1
HiObject* Klass::getattr(HiObject* x, HiObject* y) {
if (x->obj_dict()->has_key(y)) {
return x->obj_dict()->get(y);
}
HiObject* result = _klass_dict->get(y);
// Only klass attribute needs bind.
if (!MethodObject::is_method(result) &&
MethodObject::is_function(result)) {
result = new MethodObject(result->as<FunctionObject>(), x);
}
return result;
}
getattr 方法要先从对象的属性字典里查找(第 11 至 13 行),如果找不到结果,再从类属性字典里查找(第 15 行)。
在实现 LOAD_METHOD 指令的时候,我们讲过 Python 语言中方法和函数的区别。如果从 klass 的 dict 里找到的目标对象是一个函数的话,要把函数与调用对象绑定在一起,合成一个 MethodObject(第 17 至 20 行)。
如果一个函数没有绑定对象,我们就叫它 unbound function,如果绑定了对象,它就是一个方法,我们叫它 bound method。
接下来我们通过测试用例来体验一下它们的不同,先看 bound method。
class A(object):
pass
a = A()
lst = []
lst.append(2)
print(lst) # [2,]
a.foo = lst.append
a.foo(3)
print(lst) # [2, 3]
在这段代码里,获取列表的 append 方法的时候会得到一个与 lst 对象相绑定的方法对象(第 8 行)。然后程序把它设置为对象 a 的 foo 属性。
通过 a 的 foo 属性再去访问这个方法对象时(第 9 行),尽管看上去程序是通过 a 对象进行调用的,但是这里获取到的方法对象绑定的目标对象还是 lst。所以这次方法调用的结果仍然是向 lst 对象中添加数字 3。
接下来我们再看 unbound function 的例子。
class A(object):
pass
a = A()
a.value = 1
b = A()
b.value = 2
def func(self, s):
print(self.value)
a.bar = func
A.bar = func
a.bar(a, "hello")
b.bar("world")
func 是一个函数对象,把它设置为 a 对象的 bar 属性(第 12 行)。经过 a.bar 调用时,由于这个属性是设置在 a 对象上的,所以在查找的过程并不会发生绑定(第 15 行)。在传参的时候,就必须传两个参数,显式地将 a 作为实参的第一位传递到函数里。
如果 func 这个函数对象设置为类型 A 的属性(第 13 行),调用的时候就会发生绑定,将对象 b 与 func 函数绑定为一个方法(第 16 行),调用时就不必再显式地将 b 作为参数,b 会被隐式地传递到函数中。
到这里,在对象上设置属性的功能就全部完成了。接下来,我们就可以在初始化方法里设置对象属性了。
初始化方法
在前边的例子中,我们在定义类的时候,都没有显式地定义 __init__
方法。
如果我们显式地定义了 __init__
方法,在创建对象的时候,Python 虚拟机就默认会调用 __init__
方法。很多人按照C++的习惯把这个方法叫做构造方法,但我们这里还是采用 Python 社区的名称,称之为初始化方法。
在上一节课,internal_exec 函数中通过 call_virtual 实现了执行 CodeObject 的功能。这里的初始化方法也是由 C++ 代码调用 Python 代码,所以我们也使用 call_virtual 来达成同样的目标。
// [runtime/stringTable.cpp]
StringTable::StringTable() {
...
init_str = new HiString("__init__");
}
// [object/klass.cpp]
HiObject* Klass::allocate_instance(HiList* args) {
HiObject* inst = new HiObject();
inst->set_klass(this);
HiObject* constructor = inst->getattr(ST(init));
if (constructor != Universe::HiNone) {
Interpreter::get_instance()->call_virtual(constructor, args);
}
return inst;
}
在创建对象的时候,先创建一个 HiObject(第 9 行),然后设置它的 klass(第 10 行),再去新创建的对象上查找 __init__
方法并执行(第 11 至 14 行)。
这里我们使用对象的 getattr 去查找,是为了自动地将对象与初始化方法绑定起来。
完成了这些修改,这个测试用例就可以正确执行了。
class Vector(object):
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
def say(self):
print(self.x)
print(self.y)
print(self.z)
a = Vector(1, 2, 3)
b = Vector(4, 5, 6)
a.say()
b.say()
对象的初始化和普通的属性访问,方法调用的功能已经全部实现了。下面我们来研究 Python 3 中非常不容易理解的一个特性:元类。我们先从 type 对象开始分析。
再论 type 对象
你可能已经发现了,Python 3 虚拟机中的 type 符号和我们的 type 符号是不同的。你可以看一下Python 3 中执行 print(type) 的结果。
而我们的虚拟机中现在还不能打印出有用的信息,因为 type 指向的是一个函数,用于打印对象类型。
从刚刚的结果中可以看到,type 实际上是一个类型对象,对应到我们的虚拟机中就是 TypeObject,而不应该是一个 FunctionObject。
但是同时,type 又可以像函数一样被调用,而且它有两种用法。第一种是接受一个参数,返回参数的类型,例如:
这种用法我们已经实现过了,只不过当时是把 type 作为一个内建函数来实现的。
第二种用法是接受三个参数,用于定义一种新的类型,例如:
>>> attrs = {"value" : 1}
>>> A = type("A", (object,), attrs)
>>> A
<class '__main__.A'>
>>> a = A()
>>> a.value
1
这种写法和使用 class 关键字定义一个名为 A 的类是完全等价的。type 接受的第一个参数是类名称,第二个参数是父类列表,第三个参数是类的属性定义。
本质上,Python 中的 type 关键字应该是 TypeKlass 所对应的 TypeObject,用来代表 type 这种类型。这里我们可以把调用虚拟机对象统一到 HiObject 的 call 方法,然后由各自对应的 Klass 来决定具体应该做什么事情。所以我们先做一次重构,把除了 FunctionObject 之外的调用路径全部归到 call 方法里。
void Interpreter::build_frame(HiObject* callable, HiList* args, HiList* kwargs) {
if (MethodObject::is_method(callable)) {
//...
}
else if (callable->klass() == FunctionKlass::get_instance()) {
//...
}
else { // 除了函数对象之外,全部统一在这里处理
PUSH(callable->call(args, nullptr));
}
}
// vm/object/hiObject.cpp
HiObject* HiObject::call(HiList* args, HiDict* kwargs) {
return klass()->call(this, args, kwargs);
}
Interpreter 中的 build_frame 是处理 CALL_FUNCTION 指令的方法。在这个方法里除了函数对象之外,其他的所有对象都使用这个对象的 call 方法来处理(第 8 至 10 行)。
在 HiObject 的 call 方法,则进一步调用对象相应的 klass 中的 call 方法来执行具体的逻辑(第 14 至16 行)。
接下来我们就可以实现 TypeKlass 的 call 方法了。你看一下我给出的代码。
HiObject* TypeKlass::call(HiObject* x, HiList* args, HiDict* kwargs) {
// The type object.
HiTypeObject* to = x->as<HiTypeObject>();
if (to->klass() != to->own_klass()) {
return to->own_klass()->allocate_instance(args);
}
if (args->length() == 1) {
return type_of(args, nullptr);
}
else if (args->length() == 3) {
HiString* name = args->get(0)->as<HiString>();
HiList* supers = args->get(1)->as<HiList>();
HiDict* attrs = args->get(2)->as<HiDict>();
HiTypeObject* inst = new HiTypeObject();
inst->set_klass(this);
inst->set_own_klass(new Klass());
if (supers->length() > 0) {
inst->own_klass()->set_super(supers->get(0)->as<HiTypeObject>()->own_klass());
}
inst->own_klass()->set_klass_dict(attrs);
return inst;
}
return nullptr;
}
请注意,调用 list、int、dict 等 TypeObject 的时候,也会走到 TypeKlass 的 call 方法里,因为这些对象的 _klass 属性都是 TypeKlass。对它们进行调用,实际上是为了创建对象。
在 call 方法开始的地方,我们就做了一个判断,确定当前参数 x 是不是 TypeKlass 的 TypeObject (或者说就是 type 类型)。如果不是的话,就说明 x 是一个普通的类型对象,只需要转而调用它的 own_klass 的 allocate_instance 来创建对象就可以了(第 4 至 6 行)。
如果是 type 类型,那么就根据参数的个数来决定要调用什么函数进行处理。如果参数的个数为 1,那就返回参数类型(第 8 至 10 行)。如果参数个数为 3,就创建新的类型(第 11 至 24 行)。
创建新类型的逻辑和 build_class 函数非常像,所以这里我就不再过多解释了。而作为普通自定义对象的 Klass 类,它的 allocate_instance 要注意在创建对象以后,还要检查类型里是否定义了 __init__
方法。如果定义了,就需要进一步调用这个方法来初始化一个对象。
HiObject* Klass::allocate_instance(HiList* args) {
HiObject* inst = new HiObject();
inst->set_klass(this);
if (_klass_dict->has_key(ST(init))) {
Interpreter::get_instance()->call_virtual(_klass_dict->get(ST(init)), args);
}
return inst;
}
type 类型还有更多的高级用法,比如用于创建元类等。这会涉及类型的继承体系,所以我们会在整个课程的最后再来实现这个功能。到此为止, type 类型的重构工作就可以告一段落了。
你应该注意到了,__init__
方法的开始和结尾处都有两个下划线,代表它是一个特殊函数,只在特定的时机起作用。接下来,我们研究另一类特殊函数,它们可以用于实现操作的重载功能。
特殊函数和操作符重载
很多语言都有操作符重载的概念,比如在 C++ 中,我们可以这样实现操作符重载。
class Int {
private:
int _value;
public:
Int(int v) : _value(v) {}
int value() {
return _value;
}
Int operator + (Int& t) {
return Int(_value + t.value());
}
};
int main() {
Int a(1);
Int b(2);
Int c = a + b;
printf("%d\n", c.value()); // 3
return 0;
}
这段代码先为 Int 类型重载了加法操作符(第 10 至 12 行),然后在 main 函数中使用加法操作符进行运算(第 18 行)。可见操作符重载可以让 Int 类型的加法运算表现得像普通整数。
Python 中也有像 C++ 一样的操作符重载的功能,那就是可以通过在类型里定义一个特殊函数来实现。比如,通过在类型里添加 __add__
方法,可以让 Python 对象支持加法运算。你可以参考我给出的这个例子。
class A(object):
def __init__(self, v):
self.value = v
def __add__(self, a):
print "executing operator +"
return A(self.value + a.value)
a = A(1)
b = A(2)
c = a + b # executing operator +
print(a.value) # 1
print(b.value) # 2
print(c.value) # 3
我使用注释的形式把代码的执行结果标记出来。如果使用 show_file 查看这段代码的字节码,就会发现加法运算会被翻译成 BINARY_ADD(第 11 行)。实际上,对于 BINARY_ADD 指令,虚拟机的真实动作是把对象 b 作为参数,调用对象 a 的 __add__
方法。
字节码 BINARY_ADD 原本的实现是调用了 HiObject 的 add 方法,然后再分派到这个对象所对应的 klass 上,调用 klass的 add 方法。比如,整数对象就会调用 IntegerKlass 的 add 方法,字符串对象就会调用 StringKlass 的 add 方法。
自定义类型的 klass 就是 Klass 类,所以我们可以在 Klass 类里增加调用 __add__
方法的逻辑。
// [runtime/stringTable.cpp]
StringTable::StringTable() {
...
add_str = new HiString("__add__");
}
// [object/klass.cpp]
#define ST(x) StringTable::get_instance()->STR(x)
#define STR(x) x##_str
HiObject* Klass::add(HiObject* lhs, HiObject* rhs) {
ObjList args = new ArrayList<HiObject*>();
args->add(rhs);
return find_and_call(lhs, args, ST(add));
}
HiObject* Klass::find_and_call(HiObject* lhs, ObjList args, HiObject* func_name) {
HiObject* func = lhs->getattr(func_name);
if (func != Universe::HiNone) {
return Interpreter::get_instance()->call_virtual(func, args);
}
printf("class ");
lhs->klass()->name()->print();
printf(" Error : unsupport operation for class ");
assert(false);
return Universe::HiNone;
}
为了使代码更简洁,这里我们使用了宏来代替某些字符输入(第 8 行和第 9 行)。使用 C++ 编程尽量不要使用宏,因为宏带来的问题难以调试。这里我们只用宏来减少简单代码的输入。STR 宏里使用了一个宏的技巧:双井号代表字符串的拼接,所以 STR(add) 就会被替换成 add_str。
Klass 类里, add 方法的逻辑是在对象 lhs 上查找 __add__
方法,然后调用就可以了(第 17 至 27 行)。从虚拟机中调用 Python 代码,使用 call_virtual 就可以实现。代码的最后是出错以后的处理,打印错误信息并退出(第 23 至 27 行)。
支持了加法操作,我们还可以继续支持其他类型的运算符,比如减法、乘法、与操作等,你可以看一下这些操作所对应的方法定义。
这部分我们只实现了加法的操作符重载,表中的操作符,不论是一元操作符还是二元操作符,实现都与 add 操作十分相似,这里就不再展开了,留给你自己实现吧。
总结
这节课我们重点实现了通过类型来创建对象的功能。
实现的整体逻辑链是这样的:
- HiTypeObject 的 own_klass 是类型对象所代表的真正类型。所以对 HiTypeObject 执行 CALL_FUNCTION 指令时,实际上就是要使用 own_klass 来创建一个新的对象。
- 创建新对象以后,要检查相对应的类型是否定义了
__init__
这个特殊的函数,如果定义了的话,就需要再调用这个函数初始化对象。 - 初始化对象一般就要在对象上设置属性。而且对象属性的类属性完全不相同,即使是同名的属性也不会造成覆盖。这就要求每个自定义对象都有一个自己的专属属性字典。
完成了这些工作以后,自定义类型的功能就基本完成了。
这节课的第二部分我们重构了 type 对象,把它从 FunctionObject 变成了 TypeObject,并且统一了对象的 call 方法,引入了 __call__
这个特殊方法。
第三部分我们介绍了更多的特殊方法,尤其是与操作符重载相关的特殊方法。在原有的对象体系里增加特殊方法是比较容易的,我们使用加法操作符说明了这一点。
思考题
操作符重载是一个重要的语法特性,但也有很多语言选择不实现它。你知道它有哪些缺点吗?欢迎你把你的想法分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!
- 冯某 👍(0) 💬(0)
记录一下
2024-12-09 - ifelse 👍(0) 💬(0)
操作符重载 优点:增加代码可读性,提高复用度。 缺点:增加理解难度,潜在可能错误。
2024-11-04 - 骨汤鸡蛋面 👍(0) 💬(0)
老师,最近学习python元类的概念很懵逼, 可否结合python对象的创建过程介绍下python元类呢?
2024-08-21