本文中出现的GD_TABLE, GD_STR_POOL等数据结构参考我的上一篇博客 :发布一个纯C语言的开发平台1(数据结构和运行系统) . 代码下载和配置编译也都在上一篇博客中说明了。
简介
面向对象的编程思想在很多的场合下都是非常有用的,以对象的观点看待计算机需要解决的某些问题,可以设计出更符合实际的编程模型,简化程序设计。众所周知,对象就是把相关的数据和处理数据的函数绑定在一起看作一个实体。因为许多相同的对象都有相同的数据和处理函数,所以用类型来定义一类对象; 管理对象最重要的是创建和销毁,所以引入了构造函数和析构函数; 函数的重载也是一个比较好的发明,根据传入函数的参数不同调用合适的函数,方便了程序编写。不同类型的对象之间也有关系,所以引入了继承机制,其实也是为了减少重复代码。调用对象的函数和函数重载都是在编译期间由编译器根据指针所指对象的类型确定的,但是父类的指针可以指向一个子类的对象,所以这时调用的也是父类的函数,为了能够在运行时根据实际的对象来确定函数调用,所以引入了虚函数。为了更进一步的简化类型定义和代码复用,引入了更复杂的模板技术。这就是C++语言,语言越来越复杂,需要越来越多的精力来学习和使用编程语言本身,我们越来越不清楚一行代码的确切含义,越来越容易犯错并难于纠错,越来越依赖别人并迷失方向。
在实际的开发应用中,当我们需要调用第三方提供的代码库并使用其中的对象时,出现两个问题:1.需要对方库的头文件,里面必须有对象的完整定义,这就导致调用者可以看到对象内部的细节,并可能会修改对象的数据破坏对象内部的一致性; 并且也违背了模块封装的原则。2.对于相同的头文件,不同的编译器也可能产生不同的内存布局,并且对于函数的名字改编(函数重载),不同的编译器采用的方案也可能不同。为了解决这两个问题,引入了面向组件的编程技术和接口的概念。对象实现了一个或多个接口,接口就是对象需要实现的一组函数,这些函数可以映射到对象的虚函数上,假定不同的编译器对虚函数的实现都是相同的(通过虚函数表)。并且用户看不到对象内部的数据。在软件开发中也出现了对象的引用计数和事件回调等有用的编程技术。
我所设计的这个对象系统就是要吸取上述编程思想的精华并创造自己的特色,能够简单灵活,语意明确。下面将首先讲述对象类型的定义,函数调用,引用计数,事件回调机制等基本原理和用法; 然后讲述怎么在模块内部实现继承; 最后介绍一个远程消息通讯对象的设计。
对象的定义和生存管理
首先看对象类型的定义,用一个结构表示一个对象的类型,见下:
struct gd_object_type {
char *description;/*该对象类型的描述*/
GD_ID type_id;/*该类型的id*/
Gd_Create_Function create_func;/*初始化函数,即构造函数*/
Gd_Destroy_Function destroy_func;/*销毁函数,即析构函数*/
Gd_AddEvent_Notify_Function addevent_notify_func;/*用户注册事件的监控函数, 后面会讲*/
DINT obj_size;/*该对象类型的内存大小*/
GD_INTERFACE *static_interface;/*对象实现的静态函数接口,只支持一个*/
DINT interface_num;/*对象实现的函数接口个数*/
GD_INTERFACE *interfaces[];/*具体的函数接口*/
};
其中GD_INTERFACE表示一个接口,见下:
typedef struct gd_interface GD_INTERFACE;
struct gd_interface {
char *description;/*interface的描述*/
GD_ID id;/*interface的 id*/
DINT method_num;/*该接口中的函数个数*/
GD_METHOD methods[];/*具体的函数*/
};
其中GD_METHOD表示一个函数,见下:
typedef struct {
char *description;/*method的描述*/
GD_ID id;/*method的id*/
Gd_Method_Function func;/*函数指针*/
} GD_METHOD;
下面举一个完整的例子, 比如用户有一个数据结构
struct example = {
... /* data of example */
};
以及这个数据结构的若干函数:
example_func1(struct example *this, .../*参数*/);
example_func2(struct example *this, .../*参数*/);
example_func3(struct example *this, .../*参数*/);
怎样把这些绑定成一个对象呢?需要定义下面的两个结构类型:
GD_INTERFACE example_interface = {
"description of example interface, can be NULL"
None, /* interface id */
3, /*method num*/
{{"description of func1, can be NULL", None, example_func1_wrap},
{"description of fucn2, can be NULL", None, example_func2_wrap},
{"description of fucn3, can be NULL", None, example_func3_wrap}}
};
struct gd_object_type example_type = {
.description = "description of example, can be NULL",
.create_func = example_create, /*初始化函数, can be NULL*/
.destroy_func = example_destroy, /*销毁函数,can be NULL*/
.obj_size = sizeof(struct example),
.interface_num = 1,
.interfaces = {&example_interface},
}
在以上数据结构的定义里,所有的GD_ID字段都没有赋值, 初始为None, 这是因为一个id是通过一个全局字符串池GD_STR_POOL把一个字符串转换成对应的id,相同的字符串返回相同的id,但是无法在编译时预先知道一个字符串对应的id。所以需要在运行时给各种id赋值:
example_interface.id = GdUniStringGetID("example", -1);
example_interface.methods[0].id = GdUniStringGetID("func1", -1);
example_interface.methods[1].id = GdUniStringGetID("func2", -1);
example_interface.methods[2].id = GdUniStringGetID("func3", -1);
最后调用一个对象类型注册函数:
GdObjectRegisterType(GdUniStringGetID("example", -1), &example_type);
这样就定义了一个对象类型。用户创建对象调用函数:
void * GdObjectCreate(GD_ID type, GD_TABLE *in);
该函数内部会首先查找之前注册的id为type的类型定义,然后分配该对象的内存空间并填0,如果类型定义中的初始化函数不为NULL,就调用它,然后返回该对象的指针。因为不同对象的初始化函数的参数是不同的,甚至同一个对象也可能存在参数不同的多个初始化函数,但是在C语言中不存在函数重载,并且初始化函数的原型也只能有一个。虽然可以通过函数变参的技术解决这个问题,但是这会导致函数编写复杂, 并且不利于整个系统的发展(比如脚本语言接口, 后面会讲到)。这里我通过另一种比较简单的方式解决这个问题:在之前介绍的数据结构中有一个GD_TABLE,把函数的参数都打包进一个GD_TABLE结构,然后就可以在初始换函数中去查找相应的参数,初始化函数的原型为:
typedef DINT (*Gd_Create_Function)(void *obj, GD_TABLE *in); 成功就返回GD_SUCCESS /*0*/
这里的参数in就是GdObjectCreate函数的参数in。
创建时对象的引用计数为1, 可以调用下面的函数引用计数增加1和减少1:
void GdObjectHold(void *object);
void GdObjectDrop(void *object);
当引用计数减少到0时,自动销毁该对象。也可以调用下面函数主动销毁:
void GdObjectDestroy(void *object); /*也会把引用计数减少1 */
无论是自动销毁还是主动销毁,都只有在引用计数减少到0时才释放内存,否则都只是调用类型定义中的destroy_func函数。
例如针对上面example的例子:
static DINT example_Create_Function(struct example *obj, GD_TABLE *in)
{
/*对象自己的初始化工作*/
return GD_SUCCESS;
}
static void example_Destroy_Function(struct example *obj)
{
/*对象自己的清理工作*/
return;
}
#define GD_STRING_ID(name) GdUniStringGetID(#name, -1)
void *example_obj = GdObjectCreate(GD_STRING_ID(example), NULL);
GdObjectHold(example_obj);
GdObjectDrop(example_obj);
GdObjectDestroy(example_obj);
对象的方法调用
对象的方法函数和初始化函数一样,也会有参数不同和重载的问题,所以采用同样的办法,这样对象所有的函数调用都可以用一个函数实现:
DINT GdObjectMethod(void *object, GD_ID method, GD_TABLE *in_out);
该函数内部会查找object的类型定义中的GD_INTERFACE中是否存在id为method的GD_METHOD, 如果有就调用它的函数指针, in_out是该函数调用的输入和输出参数,可以为NULL表示该函数没有参数。针对上面example的例子,细心的读者可能已经注意到在example的类型定义中GD_METHOD里的函数并没有直接填example_func1,而是example_func1_wrap,因为example_func1是用户的原始函数接口,并没有采用GD_TABLE做为参数,所以需要一个封装函数,函数的原型为
typedef DINT (*Gd_Method_Function)(void *obj, GD_ID method, GD_TABLE *in_out)。
具体工作如下:
DINT example_func1_wrap(struct example *obj, GD_ID method, GD_TABLE *in_out)
{
/*把example_func1函数的参数从in_out中取出*/
example_func1(obj, ...) /*调用*/
/*把example_func1函数的返回值填入in_out中*/
return GD_SUCCESS;
}
同理,example的其他函数也做同样的封装。这样用户调用example的函数就只需要这样调用
GdObjectMethod(example, GD_STRING_ID(func1), in_out);
GdObjectMethod(example, GD_STRING_ID(func2), in_out);
GdObjectMethod(example, GD_STRING_ID(func3), in_out);
讲到这里,这个简单对象系统的基本功能设计就完成了,希望读者能够结合前面的类型定义和example的例子真正理解这套系统。读者可能已经领悟到:这套系统做了这么多工作,其实也就是在struct example结构定义和example_func1, example_func2, example_func3等函数的基础上加了一层封装的壳,并且函数调用的参数封装还影响了性能。如果没有这层壳,也是一个完整可用的模块。说的非常好!确实是这样,我只说一句:如果都是在C语言的开发环境下,那么除了对象的创建,引用计数和销毁操作,建议提供C语言的原始函数接口并直接调用,而不是调用GdObjectMethod。GdObjectMethod主要用于在脚本语言中调用。C语言本身就具有开发面向对象思想程序的潜质,这套对象系统目前只是方便对象的生存周期管理。后面将继续讲述这套系统的事件机制,以及如何利用这个基本的系统实现面向对象中其他特性,比如继承,多态(虚函数)。
对象的事件回调
现在的程序开发模式都是事件驱动的,特别是开发图形用户界面的程序,我们经常创建一个控件对象,比如button,然后在用户点击这个button时触发执行一个函数,点击就是一个事件,这个函数就是事件处理函数。上面讲述的对象的方法调用是用户程序调用对象的函数,而事件处理是对象内部调用用户的函数,所以叫事件回调。因为对象的事件是很普遍和重要的,所以qt就在c++语言中增加了信号和槽的概念,gtk也在它的gobject对象中支持信号。综合考虑事件的本质,本着简洁灵活的原则,设计了下面的事件处理机制:
DINT GdObjectAddEventHandler(void *object, GD_ID event, Gd_Event_Function func, void *arg);
调用该函数增加一个指定事件(event)的处理函数(func),arg是回调该处理函数的最后一个参数。该函数内部处理了以下两点:
1 对于相同的event,func和arg只能添加一次,相同event的多个func和arg在内部的一个链表上。
2 正在处理中的事件不能添加处理函数,避免了可能发生的事件死循环。
DINT GdObjectDeleteEventHandler(void *object, GD_ID event, Gd_Event_Function func, void *arg);
调用该函数删除指定事件的处理函数,func和arg必须都匹配。正在处理中的事件也可以被删除。
当用户第一次注册某个事件时,会调用类型定义中的addevent_notify_func 函数来通知对象。当删除了对象上某个事件的所有处理函数时,也会调用addevent_notify_func 函数来通知对象。
void GdObjectEvent(void *object, GD_ID event, GD_TABLE *in);
调用该函数触发该事件(event),in是该事件的参数,可以为NULL。该函数内部会按照添加事件处理函数的先后顺序,回调这些函数。
事件是不允许重入的,即在事件回调函数中又触发相同对象的相同事件是不允许的,该函数会直接放回,避免了死循环。
对象的继承和多态
在面向对象的编程思想中,对象继承是很重要并且常用的,但是继承会带来前面简介中提到的封装问题和编译器不一致问题,所以又出现了组件和接口的概念,接口可以继承。几经周折,深思熟虑之后,决定在对象系统中实现支持多个接口,这样也可以简单的实现接口的继承。系统本身不考虑对象的继承,对象的继承可以放到模块内部通过c语言的特性实现。在前面讲的类型定义中,可以指定该对象类型支持的多个接口。在GdObjectMethod函数调用时会顺序在多个接口中查找指定id的函数,并执行找到第一个函数。
如何利用上面多接口的机制实现接口继承呢? 还是上面example的例子,假如有一个inherit的对象实现了inherit的接口,inherit 接口继承了example接口,但是增加了一个函数inherit_func1,并且还重新实现了example_func1函数,那么就可以按下面的方式定义inherit类型:
GD_INTERFACE inherit_interface = {
"description of inherit interface, can be NULL"
None, /* interface id */
2, /*method num*/
{{"description, can be NULL", None, inherit_func1_wrap},
{"description, can be NULL", None, inherit_example_func1_wrap},
};
struct gd_object_type inherit_type = {
.description = "description of inherit, can be NULL",
.create_func = inherit_create, /*初始化函数, can be NULL*/
.destroy_func = inherit_destroy, /*销毁函数,can be NULL*/
.obj_size = sizeof(struct inherit),
.interface_num = 2,
.interfaces = {&inherit_interface, &example_interface},
}
inherit_interface.id = GD_STRING_ID(inherit);
inherit_interface.methods[0].id = GD_STRING_ID(inherit_func1);
inherit_interface.methods[1].id = GD_STRING_ID(func1);
GdObjectRegisterType(GD_STRING_ID(inherit), &inherit_type);
这样就实现了接口继承,并且也实现了多态,func1函数就相当于虚函数,会根据实际的对象类型调用相应的函数。如果还要实现类的继承,可以这样定义inherit的数据结构:
struct inherit {
struct example;
... /*inherit data*/
}
因为struct exmaple是struct inherit的第一个成员,所以inherit的指针也可以转换成example的指针。还可以在struct example结构中定义函数指针,总之利用c语言本身的语法可以实现更丰富的功能。接口的继承和对象的继承最好是限制在一个模块的若干个对象之间,这样就对模块的用户屏蔽对象的细节。当然如果允许用户对该模块进行扩展就需要提供对象的完整定义了。
该对象系统主要部分就已经讲完了,所有的代码都在object.c文件中,不超过500行。利用上面已有的内容还可以实现其他一些小技巧,比如:
1 查询一个对象的类型id; 查询一个对象是否支持某个接口。
2 可以给一个对象设置一个名字,这样就可以在其他地方根据名字找到对象,系统保证不存在不同的对象名字相同。
3 所有的模块用动态链接库的形式提供,借助gcc的函数属性__attribute__((constructor)),在动态链接库加载时就自动执行类型的注册。
4 基本上所有的脚本语言都有和GD_TABLE对应的数据类型,这样只需要在实现少数几个函数的脚本调用c语言的绑定,就可以在脚本中使用所有的对象,而不需要为每个对象的每个函数都写绑定调用代码。
最后引用C++的发明者Bjarne Stroustrup的一句话: 并不是每一个对象都自然地有效地适合继承,并不是每一个对象间的关系都是继承,也并不是每一个问题的最佳解决途径需要主要地通过对象。