一次好玩的扯淡——在C++中实现Objective-C的categories

**说明啊说明:**本人纯属小菜,对Objective-C神马的完全的不懂,这篇文章纯属扯淡,各位大大路过,欢迎各种指教~

今天一个同事对我说,他花了两个晚上的时间,啃完了Objective-C。我表示非常震惊,这种速度,应该是为了避免跟不上工作进度,穿越回来的时候选早了几天。于是接着我们就围着Objective-C侃了起来。

他说他看到Objective-C里面的一些特性非常好玩,于是说给我听,比如:

这是一个很神奇的特性,虽然我完全没有看过Objective-C,但是听大致的描述是这个意思:只要我们拥有某个类的头文件和其对应的静态链接库,那么我们就可以对这个类进行扩展,比如,给这个类加一个成员函数。

这种用法,对于基本只在C++里面转圈的我来说,确实也是第一次听到,觉得相当新鲜,于是就开始想了:C++里面是不是也能做到这件事情呢?

让我们来开始尝试吧!给一个名为Test的类添加名为Func1的成员函数。

1. 静态链接下的类扩展

我们知道,静态链接生成的.lib文件,实际上可以看成是一个.o文件的集合。而编译器在链接的时候,实际本质上只是在尝试连接名称匹配的Symbol

那么这样看起来加一个成员函数似乎就变成一件很容易的事情了。

我们首先来建两个工程:

  • SimpleStaticLib:Static Library,用于生成我们要扩展的静态链接库。
  • ExpandStaticLib:Console类型的Exe,用于实验类扩展的结果。他依赖于SimpleStaticLib工程。

最终样子如下:
expand-static-lib-sln

然后给SimpleStaticLib添加一个简单的Test类:

test.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
##pragma once


class Test
{
public:
Test();
~Test();

void Func0();

private:
int a;
};

test.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
##include "stdafx.h"

##include "test.h"

##include <stdio.h>


Test::Test()
: a(1)
{

}

Test::~Test()
{

}

void Test::Func0()
{
printf_s("Test::Func0! a = %d\n", a);
}

在ExpandStaticLib中里面,我们按照如下方法给Test类添加一个Func1的函数,代码如下:

main.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
##include "stdafx.h"

##include <stdio.h>


class Test
{
public:
Test();
~Test();

void Func0();
void Func1();

private:
int a;
};

void Test::Func1()
{
printf_s("Test::Func1! a = %d\n", a);
}

int main()
{
Test o;
o.Func0();
o.Func1();

return 0;
}

编译,运行,我们可以发现,成功了!
expand-static-lib-result

2. 原因分析

刚刚我们也提到,Link的本质是链接同名的符号,那么这些符号在真正编译器里面是个什么样子呢?我们来一探究竟。

在Windows上,我们可以使用dumpbin来查看Obj和Lib文件中的具体内容。

1
dumpbin /SYMBOLS SimpleStaticLib.lib

在SimpleStatictest.lib中,我们可以找到Test类的成员函数的符号。
simple-static-lib-symbols

在ExpandStaticLib工程的Debug目录下,我们可以找到main.obj,在里面我们可以找到对Test::Func1的引用。
main-symbols-1

我们再来看对Test其他函数的引用。
main-symbols-2

我们可以看出来,这两个符号是不同的,因为Test::Func0的实现是在main.o的外部实现的,所以就没有后面的Section信息,而Test::Test则相反,是一个内部实现,在后续的Section中就可以看到其最后编译出的代码长度。

在链接的时候,链接器会加载所有的.lib和.obj文件,然后在其中查找各自的符号和其对应的实现,最后将他们链接在一起并最终输出成可执行文件。

当然,我们也可以使用link命令简单的来试着生成最后的可执行文件。

1
link SimpleStaticLib.lib ../ExpandStaticLib/Debug/main.obj

3. 动态链接下的类扩展

那如果我们使用动态链接还可以实现这种效果吗?二话不说来试一试吧。

首先,再建立两个工程:

  • SimpleDynamicLib:和SimpleStaticLib一样,只是是一个DLL的工程。
  • ExpandDynamicLib:和ExpandStaticLib一样,只是依赖于SimpleDynamicLib。

最后样子如下:
expand-dynamic-lib-sln

然后,我们将Test的代码复制到SimpleDynamicLib工程中,将Main的代码复制到ExpandDynamicLib工程中。

接着,将两个工程中的Test类都加入__declspec(dllimport),如下:

1
class __declspec(dllimport) Test

最后,编译,执行。结果还是通过了!这里为了区分和上面的结果,我在输出中加入了”Dynamic”的文字。
expand-dynamic-lib-result

为什么呢?其实原因大家肯定都能猜个大概了。

在dll中导出类的实现,实际上是将类的成员函数全部变成导出函数,在生成的lib中实际上只保存了类的大小信息和成员函数的符号定义。

我们在depends中可以看到SimpleDynamicLib.dll的导出函数和导出变量。
simple-dynamic-lib-exports

所以,在链接的时候,链接器根据同名符号进行链接,就依然是成功的了。

4. 成员变量的扩展

最后,我们在来想想,我们能否对Test类中间的成员变量进行扩展呢?我们来修改一下我们的程序,给Test类加入一个int型变量b。

刚刚我们看到在动态链接和静态链接的时候,链接器做的事情本质上基本是一样的,所以为了方便观察和描述原理,我们这里修改动态链接的工程:ExpandDynamicLib中的Test类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class __declspec(dllimport) Test
{
public:
Test();
~Test();

void Func0();
void Func1();

private:
int a;
int b;
};

void Test::Func1()
{
b = 2;
printf_s("Dynamic Test::Func1! a = %d, b = %d\n", a, b);
}

编译,运行,可以看到,这次也正常执行了!那这次实际上到底发生了什么事情呢?
expand-dynamic-lib-var-result

我们上面说到链接器的工作原理实际上是链接相同名称的符号,换句话说,就是他并不会修改除了符号信息之外的代码,在编译的过程中,编译出来的最终代码主要是由编译器来生成的。我们刚刚也看到了SimpleDynamicLib的导出函数,其中我们并没有发现这样的一个导出函数:申请类的空间。通过查看汇编代码,我们可以从中看到C++是怎样来处理的。

为了更好的看到C++的行为,在这里请使用Release来编译工程,并且禁用堆栈检查,避免他们对最后汇编代码的影响。
expand-dynamic-lib-var-rt

我们可以看到,C++实际上是在ExpandDynamicLib.exe中申请了8个字节的内存,然后对其调用在SimpleDynamicLib.dll中的Test类的构造函数。

既然原理是这样,那么我们就可以想到扩展类成员变量的缺陷了:

  • 扩展的类成员变量无法进行正常的构造。
  • 如果我们在已有的类成员之前添加类成员,而不是添加到类成员的末尾,那么可能会引起对象构造/析构混乱。
  • 扩展类成员时,千万不可以删除原有的类成员,否则会造成构造/析构错误,操作对象以外的内存,造成更多的问题。
  • 无法扩展除了基本类型以外的类成员,因为这些对象是需要在构造函数中加入其成员变量的构造函数的调用的,而这些函数已经被编译进了动态/静态链接库中,无法修改了。
  • 多重继承和虚继承会让对象的内存结构变得更加复杂,所以在进行类成员扩展时,需要对类的内存分布有绝对的把握,否则很容易出问题。

5. 总结

经过一番折腾,我们可以大致了解到一些C++编译器的一些工作原理,但是这个技术本身我们在平时写代码的时候最好不要使用。

如果需要增强其他人提供的Lib,其实还有很多的方式可供我们选择,在《重构——改善既有代码的设计》中,我们就可以找到两种较为简单的方式:

  • Introduce Foreign Method:引入外加函数
  • Introduce Local Extension:引入本地扩展

当然一切都是TradeOff,具体用什么方法来增强外部的代码,在平时写代码时,只要符合工程的需要就行。

原创文章,转载请标明出处:Soul Orbit
本文链接地址:一次好玩的扯淡——在C++中实现Objective-C的categories