Linux 上 C++ 动态类加载
Linux 作为一个开发平台,具有诸多优势:一个健壮的操作系统环境,并配备了经过测试的工具。Linux 还拥有几乎所有可用编程语言的实现。我认为可以肯定地说,在编译型语言中,C 语言是大多数 Linux 开发人员的首选语言。因此,像 C++ 这样的其他语言在大多数关于 Linux 开发的讨论中似乎被有些忽视了。
动态类加载技术为开发人员的设计提供了极大的灵活性。动态类加载是一种在不牺牲健壮性的前提下提供可扩展性的方法。
在本文中,我们将设计一个简单的应用程序,该应用程序定义一个简单的类,一个我们希望在绘图包中使用的形状类。正如我们将看到的,动态类加载允许我们提供平滑的扩展路径,通过该路径,应用程序的用户可以添加新的形状类型,而无需修改原始应用程序代码。
动态类加载背后的基本思想是多态的概念。任何熟悉 C++ 的人都应该熟悉这个概念,因此我在这里只做简要讨论。简而言之,多态性是属于派生类的对象充当属于基类的对象的能力。这就是 OOP(面向对象编程)术语中熟悉的“is a”关系。例如,在以下代码片段中,circle 是从基类 shape 派生的类(见列表 1),因此对象 my_circle 可以充当 shape 对象,调用 shape 成员函数 draw。
class shape { public: void draw(); }; class circle : public shape { }; int main(int argc, char **argv){ circle my_circle; my_circle.draw(); }
虽然这具有所有常见的优点,例如,代码重用,但当 draw 被声明为 virtual 或 pure virtual 时,多态性的真正威力就发挥出来了,如下所示
class shape{ public: virtual void draw()=0; }; class circle : public shape { public: void draw(); }在这里,circle 声明了自己的 draw 函数,该函数可以定义适合 circle 的行为。类似地,我们可以定义从 shape 派生的其他类,这些类提供它们自己的 draw 版本。现在,由于所有类都实现了 shape 接口,我们可以创建对象集合,这些对象可以以一致的方式(调用 draw 成员函数)调用不同的行为。这里展示了一个例子。
shape *shape_list[3]; // the array that will // pointer to our shape objects shape[0] = new circle; // three types of shapes shape[1] = new square; // we have defined shape[2] = new triangle; for(int i = 0; i < 3; i++){ shape_list[i].draw(); }当我们为列表中的每个对象调用 draw 函数时,我们不需要了解每个对象的任何信息;C++ 处理调用正确 draw 版本的细节。这是一种非常强大的技术,允许我们在设计中提供可扩展性。现在我们可以添加从 shape 派生的新类来提供我们想要的任何行为。这里的关键是我们将接口(shape 的原型)与实现分开了。
虽然这项技术非常强大,但它确实存在一个缺点,即当我们添加新的派生类时,我们必须重新编译(或至少重新链接)我们的代码。如果我们可以在运行时简单地加载新类,那将会更方便。然后,任何使用我们代码库的人都可以提供新的 shape 类(带有新的 draw 函数),甚至不需要我们的原始源代码。好消息是,也是本文的主题,我们可以做到这一点。
虽然 C++ 在 Linux 下没有直接的机制在运行时加载类,但有一个直接的机制在运行时加载 C 库:dl 函数 dlopen、dlsym、dlerror 和 dlclose。这些函数提供对动态链接器 ld 的访问。这些函数的完整描述在相应的 man 页面中提供,因此这里仅简要介绍。
函数的原型如下
void *dlopen(const char void *dlsym(void *handle, char *symbol); const char *dlerror(); int dlclose(void *handle);
dlopen 函数打开 filename 中给出的文件,以便可以通过 dlsym 函数访问文件中的符号。flag 可以取两个值之一:RTLD_LAZY 或 RTLD_NOW。如果 flag 设置为 RTLD_LAZY,则 dlopen 返回,而不尝试解析任何符号。如果 flag 设置为 RTLD_NOW,则 dlopen 尝试解析文件中的任何未定义符号。无法解析符号会导致调用失败,返回 NULL。dlerror 可用于提供解释失败的错误消息。dlsym 函数用于获取指向库提供的函数(或其他符号)的指针。handle 是指向被引用事物的指针,而 symbol 是被引用项目的实际字符串名称,因为它存储在文件中。
鉴于我们可以使用这些函数来访问 C 库中的函数,我们如何使用它们来访问 C++ 库中的类?有几个问题需要克服。首先,我们必须能够找到我们在库中需要的符号。这比看起来要棘手,因为 C 和 C++ 文件中存储符号的方式存在差异。其次,我们如何创建属于我们加载的类的对象?最后,我们如何以有用的方式访问这些对象?我将倒序回答这三个问题。
由于我们没有动态加载的类的原型,我们如何在我们的代码中访问它们?这个问题的答案在于前面章节中对多态性的描述。我们通过基类提供的公共接口访问新类的功能。按照上面的示例,任何新的 shape 类都将提供一个 draw 函数,该函数允许该类的对象呈现自身。
很好;我们可以使用指向基类的指针来访问来自派生类的对象。我们首先如何创建对象?除了它们符合 shape 接口之外,我们对可能加载的类一无所知。例如,假设我们动态加载一个提供名为 hexapod 的类的库。我们不能写
shape *my_shape = new hexapod;
如果我们事先不知道类名。
解决方案是我们的主程序不创建对象,至少不是直接创建对象。提供从 shape 派生的类的同一个库必须提供一种创建 new 类对象的方法。这可以使用 factory 类来完成,如工厂设计模式(见参考资料)中所示,或者更直接地使用单个函数来完成。为了保持简单,我们将在这里使用单个函数。此函数的原型对于所有 shape 类型都是相同的
shape *maker();
maker 不接受任何参数,并返回指向构造对象的指针。对于我们的 hexapod 类,maker 可能如下所示
shape *maker(){ return new hexapod; }我们完全可以使用 new 来创建对象,因为 maker 与 hexapod 定义在同一个文件中。
现在,当我们使用 dlopen 加载库时,我们可以使用 dlsym 获取指向该类的 maker 函数的指针。然后我们可以使用这个指针来构造类的对象。例如,假设我们要动态链接一个名为 libnewshapes.so 的库,该库提供了 hexapod 类。我们按如下步骤进行
void *hndl = dlopen("libnewshapes.so", RTLD_NOW); if(hndl == NULL){ cerr << dlerror() << endl; exit(-1); } void *mkr = dlsym(hndl, "maker");
指向 maker 的指针必须是 void * 类型,因为这是 dlsym 返回的类型。现在我们可以通过调用 mkr 来创建 hexapod 类的对象
shape *my_shape = static_cast<shape *()>(mkr)();当我们调用 mkr 时,我们需要将其强制转换为返回 shape * 的函数的指针。
一些读者可能会看到迄今为止编写的代码存在问题:dlsym 调用很可能会失败,因为它无法解析 "maker"。问题在于 C++ 函数名称被篡改以支持函数重载,因此 maker 函数在库中可能具有不同的名称。我们可以弄清楚篡改方案并搜索篡改后的符号,但幸运的是,有一个更简单的解决方案。我们只需要告诉编译器使用 C 风格的链接,使用 extern "C" 限定符,如列表 2 所示。
将 maker 函数加载到数组中会将数组中的位置与每个 maker 关联起来。虽然这在某些情况下可能很有用,但我们可以使用关联数组来保存 maker 以获得更大的灵活性。标准模板库 (STL) map 类非常适合此目的,因为我们可以为 maker 分配键值,并通过这些值访问它们。例如,我们可能希望为每个类分配字符串名称,并使用这些名称来调用适当的 maker。在这种情况下,我们可以创建一个像这样的 map
typedef shape *maker_ptr(); map <string, maker_ptr> factory;
现在,当我们想要创建一个特定的形状时,我们可以使用形状名称调用正确的 maker
shape *my_shape = factory[我们可以扩展这项技术,使其更加灵活。与其加载类 maker 并显式地为它们分配键值,为什么不让类设计者为我们完成这项工作呢?通过一点巧妙的方法,我们可以让 maker 使用类设计者选择的任何键值自动向工厂注册自己。(这里有一些警告。键的类型必须与所有其他键的类型相同,并且键值必须是唯一的。)
实现此目的的一种方法是在每个 shape 库中包含一个注册 maker 的函数,然后在每次打开 shape 库时调用此函数。(根据 dlopen man 页面,如果您的库导出一个名为 _init 的函数,则在打开库时将执行此函数。这似乎是注册我们的 maker 的理想场所,但目前该机制在 Linux 系统上已损坏。问题是与标准链接器目标文件 crt.o 的冲突,该文件导出一个名为 _init 的函数。)只要我们与此函数的名称保持一致,该机制就可以很好地工作。我更喜欢放弃这种方法,而选择一种仅通过打开库来注册 maker 的方法。这种方法被称为“自注册对象”,由 Jim Beveridge 引入(见参考资料)。
我们可以创建一个代理类,专门用于注册我们的 maker。注册发生在类的构造函数中,因此我们只需要创建代理类的一个实例来注册 maker。该类的原型如下
class proxy { public: proxy(){ factory["shape name"] = maker; } };
在这里,我们假设 factory 是主程序导出的全局 map。使用 gcc/egcs,我们将需要使用 rdynamic 选项链接,以强制主程序将其符号导出到使用 dlopen 加载的库。
接下来,我们声明代理的一个实例
proxy p;
现在,当我们打开库时,我们将 RTLD_NOW 标志传递给 dlopen,导致 p 被实例化,从而注册我们的 maker。如果我们想创建一个 circle,我们像这样调用 circle maker
shape *my_circle = factory["circle"];自动注册过程非常强大,因为它允许我们设计主程序,而无需显式了解我们将支持的类。例如,在主程序动态加载任何 shape 库之后,它可以使用工厂中注册的所有键创建一个 shape 选择菜单。现在用户可以从菜单列表中选择“circle”,程序会将该选择与正确的 maker 关联起来。只要该类支持 shape API 并且其 maker 定义正确,主程序就不需要任何关于 circle 类的信息。
列表 1 到 5 汇集了迄今为止提出的概念。列表 1 中定义的 shape 类是所有形状的基类。列表 2 和 3 是动态可加载库的源代码,分别提供 circle 和 square 形状。
列表 4 是主程序,它通过动态加载的库进行扩展。它扫描当前目录中的任何 .so 文件(库)并打开它们。然后,库将其 maker 注册到主程序提供的全局工厂。然后,程序为用户动态构建一个菜单,其中包含库注册的形状名称。使用菜单,用户可以构造形状、绘制构造的形状或退出程序。列表 5 是用于构建项目的 makefile。
最近,我有两次机会使用这项技术。在第一种情况下,我正在开发一个运动物体模拟。我希望为用户提供添加新型运动物体的能力,而无需访问主要源代码。为了实现这一点,我定义了一个名为 entity 的基类,它为模拟中的任何运动物体提供接口定义。下面显示了 entity 原型的简化版本。
class entity { private: float xyz[3]; // position of the object public: activate(float)=0; // tell the object to move render()=0; // tell the object to draw itself };
因此,所有实体都至少在三维空间中有一个位置,并且所有实体都可以绘制自身。大多数实体除了位置之外还会有许多其他状态变量,并且除了 activate 和 render 之外还可能具有许多其他成员函数,但这些都无法通过 entity 接口访问。
可以定义新的实体类型,并结合用户想要的任何运动动力学。在运行时,程序加载 Entity 子目录中的所有库,并使它们可用于模拟。
第二个例子来自最近的一个项目,在该项目中,我们想要创建一个可以加载和保存各种格式图像的库。我们希望该库是可扩展的,因此我们为加载和保存图像创建了一个基 image_handler 类。
class image_handler{ public: virtual Image loadImage(char *)=0; virtual int saveImage(char *, Image &)=0; };
image_handler 有两个公共函数,分别用于加载和保存图像。Image 类是该库用于图像的基本对象类型。它提供对图像数据和一些基本图像操作函数的访问。
在这种情况下,我们对创建每种 image_handler 类型的多个对象不感兴趣。相反,我们想要每个 image_handler 的一个实例,它将处理该类型的图像的加载和保存。我们没有为每个 handler 注册 maker,而是在其库中创建了 handler 的单个实例,并将指向它的指针注册到一个全局 map 中。该 map 不再是工厂,因为它本身不生成对象;它更像是一个通用的图像加载器/保存器。这里使用的键是表示文件扩展名(tif、jpg 等)的字符串。由于文件格式可以有多个不同的扩展名(例如,tiff、TIFF),因此每个 handler 都使用全局 map 多次注册自身,每个扩展名注册一次。
使用该库,主程序只需通过文件扩展名调用正确的 handler 即可加载或保存图像
map <string, handler, less<string>> handler_map;
char *filename = "flower.tiff";
char ext[MAX_EXT_LEN];
howEverYouWantToParseTheExtensions(filename, ext);<\n>
// after parsing "flower.tiff" ext = "tiff"<\n>
Image img1 = handler_map[ext]->loadImage(filename);
// process data here
handler_map[ext]->saveImage(filename, img1);
