说来惭愧,我一直记不太清 GObject 到底是怎么用的,毕竟作为一个写过 C++ 和 Python 然后常用 JS 的人来说,面向对象的实现是非常自然的,不需要考虑为什么。所以我总是看着一大堆类型转换和分散的定义以及各种 chain up 感到眩晕。而 GObject 的文档写的也相当分散,有种管中窥豹之感。
有同学觉得其实把函数变成指针放在结构体里看起来就面向对象了,以前我也这么觉得,但是显然这样不能实现继承封装等等特性,GObject 独特之处在于它实现了这些,并且是和语言独立的。当然,要想在一个没有这些概念的语言里面做到这些,就有很多需要自己手动处理和函数库处理的地方,就是因为有些隐藏了起来有些又要自己做,才让它看起来像古老的黑魔法。
最近我尝试做了一个小项目,以便搞清楚如何按照 GObject 的模式编写一些继承封装的代码。
首先得给项目起名字,因为 GObject 要求你的命名符合一定的约定,其中一个前缀是项目名,不过这个 简单,就叫 test
好了,然后做一个基类叫 animal
,于是就有了 test-animal.h
:
因为代码里写了很多注释所以我这里就不再啰嗦一遍了,说点里面没写的。
G_DECLARE_DERIVABLE_TYPE
表示你声明了一个可以继承的类,也就是说你需要自己弄一个虚函数表出来。有时候你会在某个 GObject 的项目里定义了一大堆宏(比如 GTK 就不爱用这个而是手动定义),其实它们和 G_DECLARE_DERIVABLE_TYPE
做了一样的工作,因为总要做,就写了个宏实现。这个并不会给你定义具体的类结构体(其实就是虚函数表,用来存放所有可以继承重载的函数),所以我们要按照约定自己写一个 _TestAnimalClass
的结构体,在类型名字前面加下划线作为结构体名也是约定俗成的,G_DECLARE_DERIVABLE_TYPE
会展开出一句 typedef struct _TestAnimalClass TestAnimalClass
。
这个类结构体约定第一个元素是它的父类型的类结构体——这其实意味着我们复制了一份父类型的虚函数表出来,于是我们就可以覆盖父类型的方法而不修改原本的父类型。这一句可能比较难懂,不过后面还有关联。
这个类结构体存放的并不是实例的变量,它有点类似于 JS 里面的原型,这样我们就不需要给每一个生成的实例复制一份虚函数了,它们共用一个虚函数表。
然后会有一个 test-animal.c
:
理论上来说我们直接往实例结构体里添加成员作为私有成员就可以,但是在头文件里定义实例结构体,其它文件是可以看到实例结构体的成员的,所以 G_DECLARE_DERIVABLE_TYPE
不允许我们这么做。
但有一个省事的宏 G_DEFINE_TYPE_WITH_PRIVATE
,它要求我们做一个 Private 类型,里面存放我们想要的任何私有元素,它不会被继承,并且因为定义在 .c
文件里所以也不会被其它文件看到。它还会提供一些 GObject 内置类型系统的实现函数(不然 GObject 系统怎么知道你建了哪些类型!),还有一些只在实现里面用到的定义。当然 Private 类型的名字也是约定好的。
多说一句,按照 GTK 的代码实现,其实 Private 最后就是在实例结构体里面定义了一个叫 priv
的指针,类型是自己定义的 Private 类型,因为 GTK 是手写的头文件定义然后用的 G_DEFINE_TYPE_WITH_PRIVATE
同时又没产生冲突,其实你也可以完全不理会这两个宏,全都自己写,只要保证类结构体和实例结构体的第一个成员是父类结构体和父实例结构体就可以了。但是 G_DEFINE_TYPE_WITH_PRIVATE
提供了一个 项目名_类型名_get_instance_private
的函数,我们就不用直接访问 priv
成员了。
在使用 g_object_new
新建一个对象的时候可以通过 key-value 的方式设置一些初始值,这个其实是通过 GObject 提供的 property 功能实现的,因为 C 并没有哈希表这种东西。这个过程很繁琐但也很固定,其实就是实现其它语言里面传一个对象作为构造函数参数然后以此设置私有成员初始值的功能。property 就是参数,具体的内容还是存在初始值里面的,不过其实你也可以通过 property 访问具体的值。这里很容易理解成 property 有自己单独的存储空间,其实不是。property 是可以继承的,所以子类可以同时设置父类和自己的 property。
然后我们要接触到第一个重载的部分了,因为 TestAnimal
继承了 GObject,所以我们要重载掉它类结构体里面的父类结构体的 set_property
和 get_property
函数。过程也很简单,GObject 要求我们实现两个函数 项目名_类型名_class_init
和 项目名_类型名_class_init
,其中前者就是让我们初始化类结构体用的。首先进行一个类型转换把 TestAnimalClass
转换成 GObjectClass
(为什么可以强转?那你先思考一下为什么定义类结构体时候第一个元素是父类结构体?就是因为要这样才能进行类型转换,本质上是个套娃),然后直接赋值。这样假如有人对我们这个类型执行 g_object_set_property
,实际上是调用的我们重载过的函数(为什么?怎么做到的?往下看)。
那其实我们知道如何覆盖父类的方法,但运行的时候是如何动态重载到我们自己的函数的也不清楚,我们先搞定我们自己的那个虚函数,其实很简单,我们定义具体的方法的时候(指 test_animal_print
),不要让它实现具体的逻辑,而是让它通过参数的实例执行虚函数表里的函数就可以了。这需要一个自动生成的 项目名_类型名_GET_CLASS
的宏,它的作用是通过一个实例查找到 这个实例本身 对应的类结构体,然后就可以运行虚函数了。比如我给 g_object_set_property
传一个 TestAnimal
,那我们调用的其实是 TestAnimalClass
的第一个成员那个 GObjectClass
的 set_property
,这个已经被我们改成自己的了,于是就实现了一个 不那么直观的 重载。
当然假如我们不想写一个纯虚函数,可以在 项目名_类型名_class_init
里面设置一个初值,这样假如子类没有重载,调用的就是这个。
那你可能要问假如我有一个不想被重载的函数呢,那你就不要跳虚函数表了,直接写逻辑就可以了。
接下来我们终于可以写子类的,首先就是 test-cat.h
:
TestCat
是继承 TestAnimal
的,并且我们不想让它被继承,所以它就不需要写类结构体了(因为虚函数表是用来重载的,没有继承当然也没有重载)。
所以接下来就直接到实现部分了,在 test-cat.c
:
Final 类型没有 Private,因为它自己就是 Private,所以这个定义实例结构体的权力交给了你,直接在里面写私有成员即可,但是不要忘了套娃(指第一个成员一定要是父实例结构体)。
然后就是设置我们自己的 property 并重载 GObject 的虚函数,当然你可能会说这样父类型的 property 不就丢了吗?其实 property 是放在 GObject 系统单独的一个表里,需要的时候它去查询,并且会考虑父类型的继承链,所以我们不需要考虑重载时候覆盖掉父类型的问题。
接下来就是重载父类型提供给我们的虚函数也就是 TestCatClass.print
了!当然首先是把我们自己的类结构体里面的父类结构体的虚函数换掉。
在写我们自己重载的函数的时候,一个比较重要的事情是 chain up,也就是最后一行那个 TEST_ANIMAL_CLASS(test_cat_parent_class)->print(animal);
,有时候你重载只是做了一些子类自己的处理,然后还是需要父类提供的处理函数的,这该怎么办?你可能会说类结构体里面不是有个父类结构体了吗?但那个已经被我们覆盖了!你这样不就是咬自己尾巴的猫吗?
当我们自己解决不了的时候就得 GObject 解决了,G_DEFINE_TYPE
会提供一个 项目名_类型名_parent_class
的指针定义,这个指向的是那个真正的唯一的 不是我们自己包含的 那个父类结构体!也就是说对于子类和父类继承而言,有两个父类结构体,一个是父类自己作为一个类的那个,另一个是子类为了实现重载包含的那个,这个访问的就是前者。
当然这个类型其实是个 gpointer
所以我们还得自己转换一下类型,然后只要调用方法就可以了(这不就是 super 指针嘛)。
最后我们写一个测试的程序 main.c
:
然后用这个 Makefile
:
运行的结果是这样的:
你可以看到第一行是父类也就是 TestAnimalClass
的函数的输出。第二行是 TestCatClass
重载的函数的输出,第三行则是 TestCatClass
重载的函数 chain up 到 TestAnimalClass
的输出,但是输出的是子类继承的父类的属性!
最后有关我到底该声明成子类还是父类以及传参时候要不要类型转换:其实在这个 GObject 的样例里面看起来是无所谓的,类型转换可行不可行其实取决于具体 new 的是什么,而不是声明的指针是什么,所以其实声明成父类和子类都没有关系,类型转换也主要是为了满足 C 语言对指针类型和函数声明的要求罢了。