Posts tagged C++
从C++到C#——穿行于Native与.Net 托管代码之间
0微软在.Net平台中提供了大量丰富的语言,库,并为之提供了非常强大的开发工具。借用.Net平台,我们可以非常容易的开发基于Windows的应用,与采用C++相比,使用C#语言开发相同功能的软件可以极大的提升开发效率,缩短开发周期,减少Bug。
然而,往往现存有大量的积累代码是基于原生语言开发的,当我们决定采用C#时,必须考虑到与现存的软件组件交互的能力,具体而言——如何在一个以C++语言为主的项目中引入.NET组件?如何在.NET组件中使用C++代码库提供的功能?
在游戏项目中,比较理想的情况是底层的3D引擎采用C++编写,而上层编辑器则采用C#编写,这样可以兼顾引擎的运行效率和编辑器的开发效率。一个值得一提的案例是UDK(Unreal引擎的开发包),其中就采用了C++语言与.NET组件相结合的模式,其界面上相当一部分新功能是采用.NET开发的。
编写一个DLL时应当注意什么#1
6库与代码重用
1、静态库vs动态库
静态库的优势和劣势
动态库的优势与问题
2、静态C/C++运行库 vs 动态C/C++运行库 & manifest
3、关于动态库接口设计
库与代码重用
对于像C,C++这样的语言来说,库是扩展语言功能的重要手段,对于项目来说,代码库是节约开发时间,缩减成本,划分项目模块以利于多人合作,并通过重用已有代码而避免重复劳动的必要工具。开发一个稍具规模的项目,总免不了设计和划分模块,如何设计和划分,采取什么方案解决问题,总要面临诸多取舍。
最近跟动态库/静态库/动态静态CRT/MFC打了诸多交道。本文是对这段时间遇到的问题与思考的总结。
1. 静态库 vs. 动态库
静态库本质上就是编译好的目标代码(使用vc的话,编译.cpp文件或者.c文件之后会得到目标文件.obj,编译结束后,link程序会按照指定的要求将目标文件链接成最终的输出文件——常见的有.lib/.dll/.exe)的一份合集。
相比于动态库,静态库没有入口函数,不能独立执行,不能动态链接,一旦被链接到目标文件中,静态库的代码段/数据段都会被合并到目标文件当中去。这是静态库与动态库的本质区别。
由于上述差别,那么我们就很容易理解下面的情况:如果你在静态库中定义一个静态或全局对象,最终该对象会被链接到目标文件的数据段中(目标文件可能是某个dll也可能是exe)。
静态库的优势在于,简单易用,你无需考虑内存的申请与释放问题,因为静态库的代码段作为目标模块的一部分(EXE或DLL),在其中所调用的new/delete在链接阶段会被重定位到该目标模块的CRT函数地址。
对应的,如果你使用DLL,由于DLL中会自行初始化一份CRT堆,因此它的内存申请与释放是在有别于在EXE的CRT堆中进行的(两个不同的CRT堆),如果你从DLL中申请一块内存出来使用,最后也必须交还给DLL模块内部去释放。否则会产生Heap Collision,在Debug模式下,你会收到一个系统位于malloc中的断言,并了解到发生了什么。*
此处的叙述存在错误,更正如下:
一个DLL或许会,或许不会拥有一份自行初始化的CRT堆,这取决于DLL是通过动态链接MSVCRT还是静态链接LIBCMT。由于CRT是通过一个全局变量_crtheap维护着一个Heap(Windows环境下),并且会在CRT初始化的时候初始化CRT堆,因此,如果整个应用程序都是通过链接动态的msvcrt,使用同一份crt,则不会有问题。但是如果有任一二进制模块链接了静态crt,则会拥有一份自己的crt堆。严格来说,你应当小心的保证来自于某个CRT的堆内存最后也被交还给该CRT堆,否则会发生错误,在Debug模式下,当你调用free函数释放来自于另一个CRT堆的内存时,会有一个_ASSERTE(_CrtIsValidHeapPointer(pUserData));断言失败,提醒你用错了堆。
如果我们的DLL是链接了一个静态的CRT并且发布出去了,那么我们还是应当保证所有来自于该DLL的内存都由该DLL回收。其原因如上述分析所言,是由于该DLL拥有一份自己的CRT堆。
再有,如果你的静态库A依赖于静态库B的头文件,而目标应用程序EXE依赖了静态库A,那么你在链接静态库A的时候,也必须链接静态库B。否则会有静态库A中的某些符号无法被找到,从而导致链接失败。而动态库A如果依赖于一个静态库B,那么你无需在依赖该动态库的EXE中再次包含该静态库,因为该动态库A已经将该静态库B链接到A模块当中了,在DLL A中已经有了一份,如果EXE模块没有直接依赖于静态库B的话,那么就无需在EXE中再次link该静态库了。
上述问题也通常是混用静态库和动态库时可能遇到的一个问题:如果有一个静态库A,被动态库B和EXE C都用到了,而EXE C又需要使用动态库B,那么这份A的代码和全局数据就在动态库B和EXE C中存在了两份。第一,这是一种对空间的浪费;其次,这可能会引发问题,比如,当我在EXE C中new一份A中的某对象X的指针,并将该指针传入到动态库B中使用,表面上看似乎没有问题,但是如果这个对象X引用了A库中的全局/静态数据,就可能会引发问题,因为此时动态库B中存在有一份A库中的全局/静态数据,而EXE C中有另一份,如果这个全局/静态数据被X使用的话,那么就会得到前后不一致的上下文环境。具体可能产生什么问题要看具体的逻辑是如何组织的。在这种情况下,最好能将静态库A重新组织成一个动态库,这样就不存在多份代码以及多套全局数据的问题了;如果不能这么做,你可以根据具体的应用程序组织的形式,采取DLL导出A库接口,EXE使用DLL导出接口去访问A库而不直接链接A库本身的方法来变通,但通常这不是根本的解决方案。
另一方面,在DLL中可以包含自己的资源文件,而LIB中不能。因为DLL是一份完整的PE文件,而LIB只是若干个OBJ打包。这并不是说,使用LIB的时候不能使用资源(RC),你可以将LIB中使用的RC文件,resource.h都include到目标应用程序当中,只要没有资源ID冲突就可以正常使用,就像是你在目标应用程序的资源当中定义了这些资源一样。而DLL中的资源则隶属于DLL自身,不会与EXE中的资源在ID号上产生冲突,只是在使用的时候还需要针对Resource做一些Handle上的设置(参考AfxSetResourceHandle),才能使得API访问DLL中的资源而非EXE中的。如果要编写一个带有资源的库模块,DLL确实是不二选择。
2. 静态&动态C/C++ runtime 以及 manifest
静态的C-runtime库叫做LIBC.lib,我们通常使用多线程的版本叫LIBCMT.lib,debug下的版本叫LIBCMTD.lib。而动态的C-runtime,在VC的环境下,我们使用的是MSVCRT.lib,以及debug下的MSVCRTD.lib(这两个lib实际是两个导出符号文件,实际的运行库位于msvcrt.dll/msvcrtd.dll中(VC6),如果是VC2005的版本则是msvcr80.dll/msvcr80d.dll)
C++的runtime库,静态版的是LIBCPMT.lib/LIBCPMTD.lib。动态版的是MSVCPRT.lib以及MSVCPRTD.lib。(类似的,实际的运行库位于<VC2005中>msvcp80.dll/msvcp80d.dll)
在第一点的对比中已经提到过,如果同时又DLL模块依赖一个静态库,而又有一个依赖该DLL模块的EXE还依赖这个静态库,那么最终的内存中就会存在两份该静态库的代码以及全局数据。这一点对于C/C++运行库也是一样的。所需要注意的是,只要是基于C语言以及C++语言的程序,几乎所有的都会用到C/C++运行库,因此如果在规划一个项目的时候,发现有多个dll模块,那么最好是使用动态版的运行库,以避免最终的内存中出现多份不必要的运行库代码。
再有一点是关于XP之后的系统中,以及在VC2005之后的编译环境中,微软添加的manifest机制。该机制是为了解决同名但版本错误的DLL加载问题而提出的。比如,我们手上有两份VC编译器,分别是不同的版本,其中携带的MSVCR80.dll也是不同的版本,但是都叫MSVCR80.dll,如果我们在应用程序安装时,简单粗暴的将系统目录下替换成我们应用程序所依赖的MSVCR80.dll,那么可能会造成以往的应用程序无法正确运行的问题,因为他们可能依赖的是其他版本的MSVCR80.dll。
解决这个问题的途径是将一个dll的运行环境,版本,以及校验值等信息编码到一个目录名当中,而在安装应用程序时,将该版本的dll放置到对应名字的目录下,这样尽管dll名称一致,我们也可以找到所需要正确版本的dll。但是应用程序怎么知道需要依赖哪个版本的dll呢?如果应用程序不知道,即使系统中有正确版本的dll,操作系统也不知道应该去加载哪一个dll。
因此manifest文件就把该模块所依赖的模块都标明了,通过名称,校验码,版本等等信息以确保所指明的模块不会有歧义(用记事本打开一个manifest文件,你就可以看到上述信息),当该应用程序EXE或者DLL被操作系统加载时,系统就会根据该信息去查找对应模块,首先会在C:\Windows\WinSxS目录下找(打开这个目录,你就能看到大量不同版本的CRT/CPRT/MFC/ATL等等运行库),如果没有找到那么也会应用程序当前目录下找。
这就是为什么使用VC2005编译一个dll也好,一个exe也好,总会多生成一个manifest的文件的原因。所以如果使用了动态版本的CRT运行库,或者其他任何DLL,那么最好就不要关闭manifest生成的编译选项。
3. 关于动态库接口设计
如果动态库可能经常会更新版本,提供新的功能接口,那么如果使得动态库的升级无需改动依赖于他的可执行模块就是一个值得思考的问题。
具体要达到上述目标,需要保证的有以下几点:
a. 动态库中的类的内存布局不暴露给外界,也就是说动态库提供给用户的.h中,不应当有类的成员信息。这是因为,如果类的内部数据修改了,增加了一个变量或者减少了,那么这个类所占用的内存大小也就修改了,倘若有exe使用上一版本的类定义,new了一个类对象,那么一旦发生类定义修改的情况,这部分代码就必须要重新编译链接了。比较标准的做法是dll只向外界提供接口定义,而不提供实现定义,即提供的是一个类,但是类中只包含纯虚函数,而没有数据<这种做法有点类似于COM的思路>;或者是只提供一系列普通函数以及一个实现类的指针,这种做法有一个好处,如果添加新的函数接口,也无需重新更新外部程序,但是如果使用虚函数接口则不然。
b. 动态库中的类应当不给外界提供构造析构的能力,同样是基于上述原因,如果外界可以new/delete该类,那么当类的定义修改时就会出现不得不重新编译使用该库的应用程序的情况。倘若只通过dll中定义好的借口进行交互,则不存在这个问题。比如外部不能直接new一个对象,而是必须从一个dll导出函数中获取一个对象的指针,外部同样不能delete这个对象,而是必须将之送入另一个导出函数交给dll去删除。这样只要dll保持着两个接口不变,更新dll时是无需更新依赖该dll的应用程序的。(一个比较好的做法是将dll提供外界的接口类的构造析构函数设置为private)
c. Dll应当尽可能使用动态crt,如果用到了mfc应当尽可能使用动态mfc。这是为了避免dll与外部模块中存在多份重复代码的情况。同时如果有dll需要依赖的其他静态库,也应当尽可能使用该库的动态版本。
p.s. 如果想了解更多的关于静态库,动态库,C运行库方面的知识,可以参考《程序员的自我修养——链接装载与库》一书。
p.s.2 果然如pongba老大所言,将自己认为没有问题的知识写下来是一次很好的学习过程,写下本篇文章的过程中,我将原先认为明白的,但是实际并不见得就真的明白的问题,梳理清楚了很多。写的过程有助于把原先大脑中下意识存在的假设显现出来,在思维中再次加工,将原先不明确的概念搞清楚。
#1 更正了关于DLL与CRT堆内存的一处错误,感谢coldfall热心指正
小议Command模式 抽象在实际编程中的应用
0在许多编辑器中,大都实现了Undo / Redo操作。从界面出发,Undo/Redo仅仅只是一种抽象的”提供单一接口行为“的操作,尽管实际的Undo/Redo工作可能会涉及各种不同的具体操作。举个例子,在3dMax这样的软件中,我们可以创建3D实体,可以编辑其自身的各种属性(譬如立方体有长宽高,球有半径),可以移动实体的位置,对其做旋转,缩放操作,乃至对个别的网格顶点,面片的细微调节。其中的每一步,都可以撤销或重做。
我们仔细思考一下,就会明白,撤销和重做实际上是需要保存每一步操作的”被操作实体的两个状态“(分别是该实体在操作发生前和发生后的状态)。
譬如对一个物体做以移动,我们实际上是保存了移动前的位置,和移动后的位置,这样在Undo/Redo时,只需要分别将其置于所需的状态即可。
而不管具体的状态是如何转变的,对于用户来说,撤销/重做都只是一个简单的命令,表现为一个按钮,或者一次Ctrl+Z。这其实就是一种具有同一性表现的动作,在软件实现中,非常适合使用Command模式对其进行抽象。
譬如,我们只需要定义这样的一个基类即可明晰撤销/重做操作的概念接口:
class Command {
public:
virtual ~ Command() = 0 {}
virtual bool Do() = 0;
virtual bool Undo() = 0;
bool Redo() { Do(); }
};
所有的Undo/Redo的状态,都派生自这个Command抽象类,它定义了两个纯虚函数,用于做和撤销。Redo函数只是简单的调用Do()实现其功能。
有了上面这个接口,当我们想添加更多类型操作的Undo/Redo支持的时候,只需要这样做:
class MoveBoxCommand{
public:
MoveBoxCommand(VECTOR3 vNewPos, int boxid)
: m_iID(boxid), m_vNewPos(vNewPos) {}
~MoveBoxCommand() {}
bool Do()
{
m_vOldPos = SomeManager->GetObject(m_iID)->GetPos();
return SomeManager->GetObject(m_iID)->SetPos(m_vNewPos);
}
bool Undo()
{
return SomeManager->GetObject(m_iID)->SetPos(m_vOldPos);
}
private:
int m_iID;
VECTOR3 m_vNewPos;
VECTOR3 m_vOldPos;
};
现在我们就可以对任意的对象支持Move操作的撤销/重做了,事实上,旋转,缩放等其他操作大都与此类似,对添加删除之类的操作需要做一点特别的处理(因为我们不能简单的把实际对象释放掉,否则其他引用了该对象的Command就会出现问题,一种良好的做法是设置删除标志)。
除此之外,我们需要将所有的操作产生的Command实例放在一个管理器中统一管理,假设其被命名为CommandManager。则CommandManager需要负责维护当前的Command,需要在用户调用Undo时,判断是否能够Undo,如果能够,则取出下一个Undo的Command,调用其Undo,并将当前Command的指针或者索引更新位置。
如果用户在某个点做出了新的操作,则需要将当前Command之后的所有Command清空。(如果用户没有做新操作,则可以使用当前Command之后的Command来实现Redo操作的,但是新操作会冲掉位于Redo序列中的元素)
如果撤销/重做的实现有了以上理解,就不难理解为什么几乎所有的软件在用户Undo多步,再采取新动作之后,就无法Redo了。
通过这种方式,我们就可以实现一个具有可扩展性的撤销重做机制了,不论再加入何种类型的新操作,我们都可以设法将其纳入到上述的框架当中。
具体的撤销重做实现还牵扯到许多细节,不过那都与主题关系不大,我们再来看一个例子:线程池。
有时我们可能会想编写一个线程池,用于处理各种并发的任务。线程池从概念上说是一个很简单的东西,有一个线程的集合,里面的很多线程都睡着,当有任务来的时候,一个线程跳出来把任务领走并执行,执行结束后,线程继续回到池子里睡觉。如果有多个任务,那么就会有许多个线程都跳出来,直到池子里没有更多的空闲线程或者任务队列被领空为止。
在Win32平台下编写多线程程序,大致会像这样:
void ThreadProc(PVOID param)
{
// do something
}
// somewhere in some func, we create the thread
int handle = __beginthreadex(ThreadProc, valuePassin);
问题来了:
线程处理函数一般只有一个通用的模板,类似于上述的ThreadProc,线程池中的线程在创建的时候,需要传递一个处理函数地址,但是由于线程池中有许多线程,他们是批量被创建出来的,在创建这些线程的时候,我们无法立即获知所需处理的内容。因此,经济适用的方法是使用一个通用的线程处理函数,所有的线程被创建时传递的都是这个公共的处理函数。(而不是对每一个任务创建一个采用不同线程处理函数的新线程–这就不是线程池了,而是普通的多线程处理)
具体一点,一段提供如上功能的代码如下:(这个简陋的实现仅为演示上述思想,真正的实现需要对线程同步处理得更加规范,对错误意外情况做处理,完善其他必要的功能。P.s.这段代码是在word中编写,不能保证可以编译通过)
class ThreadPool;
void ThreadProc(PVOID param)
{
ThreadPool * pThreadPool = reinterpret_cast< ThreadPool*>(param);
ThreadTaskPool* pTaskPool = pThreadPool->GetTaskPool();
while (true) {
Task* pTask = pTaskPool->PopTask();
If (!pTask)
Sleep(100);
else
pTask->Do();
}
}
class Task {
public:
virtual void Do() = 0;
};
class ThreadTaskPool {
public:
ThreadTaskPool() { ::InitializeCriticalSection(&m_cs); }
Task* PopTask();
void PushTask(Task* pTask);
private:
CriticalSection m_cs;
std::queue<Task*> m_TaskQueue;
};
Task* ThreadTaskPool::PopTask()
{
Lock<CriticalSection> _lock_; // lock is a scope wrapper
if (m_TaskQueue.empty())
return NULL;
Task* pTask = m_TaskQueue.front();
m_TaskQueue.pop_front();
return pTask;
}
void ThreadTaskPool::PushTask(Task* pTask)
{
Lock<CriticalSection> _lock_;
m_TaskQueue.push_back(pTask);
// we may do something else here(raise some event to notify the thread to wake up).
}
class ThreadPool {
public:
void Init();
ThreadTaskPool* GetTaskPool() const;
private:
HANDLE m_hThreads[MAX_THREADS];
ThreadTaskPool* m_pTasks;
};
void ThreadPool::Init()
{
m_pTasks = new ThreadTaskPool;
for(int nIdx = 0; nIdx < MAX_THREADS; ++nIdx)
{
m_hThreads[nIdx] = CreateThread(NULL, 0, ThreadProc, this, 0, NULL);
}
}
上述代码实现了一个简单的线程池,Task接口用于定义任务,一切需要交给线程池处理的任务都派生自该接口。而线程池中的所有线程只在其线程处理函数中做一件事:检查任务管理器中是否能取出新的任务来,如果能就干活,不能就调用Sleep交出时间片。(这里有一个可以优化的地方,我们可以在任务管理器中定义一个事件对象,如果线程取不出任务,则等待该事件对象,如果有新任务被PushTask,则该事件对象被激活,并唤醒一个等待中的线程)
上面的两个例子:编辑器中的撤销/重做,以及线程池处理任务队列,都是实际编程当中对Command模式的典型应用。他们的共同点在于:都有一个统一的动作接口,而接口背后的工作可能多种多样。在实际编程当中遇到具有这种特点的问题,便可以采用这种思想进行抽象,有助于我们编写具有良好扩展性的程序。
Ogre源码剖析1:任意类型类Any
3有些时候我们可能想做这样一件事:
float f = 1.f;
int n = 2;
std::vector<X> myContainer; // X是一个虚构的用户定义类型
myContainer.pushback(X(f));
myContainer.pushback(X(n));
我们想在一个容器里保存两种乃至多种不同的数据类型。
但是,显然普通的模板参数如std::vector<int>,或者std::vector<float>都无法满足我们的需求。
也可能存在下面这样的情况:
float fVal = 1.f;
X xMsg = fVal;
PushMessage(xMsg); // PushMessage发送了一个异步消息
// 另一个地方(可能是另一个线程),回调函数被调用:
OnMessageXXX(X xMsg)
{
float f = static_cast<float>(xMsg);
// do something with value f
}
针对这种需求,我们想要一种类型,它可以接受任意的类型,并在需要的时候把我们放入的真正的类型取出来,它可以被放置到容器中,可以被拷贝,以及串行化(前提要求被置入其中的类型可以被输出到标准输出流–std::ostream当中)。
如何设计这种类型呢?
一种容易想到的方案如下:
class Any {
public:
enum {
ANYTYPE_INT,
ANYTYPE_FLOAT,
ANYTYPE_LONG,
// … other data types
};
union {
int nIntData;
float fFloatData;
long lLongData;
// … other data types
};
Any(int nData) : nIntData(nData), m_type(ANYTYPE_INT) {}
Any(long lData) : lLongData(lData), m_type(ANYTYPE_LONG) {}
Any(float fData) : fFloatData(fData), m_type(ANYTYPE_FLOAT) {}
// … other constructors
};
现在我们可以这样写:
Int nData = 3;
Any myVal(nData);
为了让Any可以被赋值,我们需要给Any提供一个operator =。
Any& Any::operator = (const Any& rhs)
{
if (&rhs == this)
return *this;
m_type = rhs.m_type;
switch(rhs.m_type)
{
case ANYTYPE_INT:
nIntData = rhs.nIntData;
break;
case ANYTYPE_LONG:
lLongData = rhs.lLongData;
break;
case ANYTYPE_FLOAT:
fFloatData = rhs.fFloatData;
break;
default:
ASSERT(0);
};
}
这样Any就有了相互之间被赋值的能力了,如下:
Any myVal1(1);
Any myVal2(5.6f);
myVal1 = myVal2;
然而我们还希望Any可以直接用各种数据类型赋值。因此,针对需要支持的每一种数据类型,都应该重载一个operator =。如下:
Any& operator = (int inData)
{
nIntData = inData;
m_type = ANYTYPE_INT;
return *this;
}
Any& operator = (long inData)
{
lLongData = inData;
m_type = ANYTYPE_LONG;
return *this;
}
等等。
当然如果嫌这种写法过于冗长,出于节省代码的考虑,我们可以使用一个宏来代替:
#define OPEQUAL(type, typeID, varName)
Any& operator = (type nData)
{
varName = paramName;
m_type = typeID;
return *this;
}
这样就可以将上述代码转换成:
OPEQUAL(int, ANYTYPE_INT, nIntData);
OPEQUAL(long, ANYTYPE_LONG, lLongData);
OPEQUAL(float, ANYTYPE_FLOAT, fFloatData);
此外,作为一种数据的承载,我们还希望在需要的时候把实际的数据取出来,因此我们需要重载一系列获取函数:
operator int() const { return nIntData; }
operator long() const { return lLongData; }
operator float() const { return fFloatData; }
有了这些类型operator之后,当需要从Any中取出我们想要的数据时,即可以通过:
Any myAnyVal(25);
int nIntData = myAnyVal;
这种形式得到想要的值。但是需要注意的是,这里不可以对Any的隐式转换做正确性的假定。即,我们不能写下如下代码:
Any myAnyVal(25);
float fData = myAnyVal;
我们不能指望这里返回正确的值。因为Any中保存的数据实际为int,而当这个赋值发生时,根据重载决议,编译器会调用Any的operator float()返回一个float值。而由于float在内存中的放置是基于IEEE浮点格式的,int则是2进制的数据,最后返回的数据就不可能正确了。
基于此,我们要求在使用Any的时候,存放数据的位置和取出数据的位置,都必须由程序员指定对应好的数据类型,并且寄希望于程序员知道自己在做什么。
上述以上的实现方法有2个好处:
1、 节省内存,对于不同大小的数据类型,通过union的形式共用了存储空间。
2、 速度快,没有使用动态分配内存的模式,而是直接将对象放置在union空间中。
但是如果我们想让这个Any支持std::string,就不能像上述实现得那么直接了。
因为union中不能支持non-trivial的数据类型(因为对于non-trivial的数据类型,编译器在为其分配内存空间之后,还要调用其构造函数)。我们必须另外想一种方法。
一种简单的容易想到的方法是保存指针,如下:
union {
int nIntData;
std::string * ptrStr;
};
这样,我们需要在operator =以及copy constructor中手动管理内存。当Any初始化为String类型时,需要动态申请内存,而如果构建好的Any中所含的类型在operator =中由String转为其他类型时,需要将其动态释放,在由其他类型转为String时,则需要动态申请。如果这里的其他类型是另外的动态类型,且也是通过指针保存在union结构中的,则也需要做相应的释放&申请处理。
如下:
Any& operator = (int inData)
{
switch (m_type)
{
case ANYTYPE_STRING:
delete ptrStr;
nIntData = inData;
break;
// other cases
};
m_type = ANYTYPE_INT;
}
Any& operator = (std::string& inData)
{
switch (m_type)
{
case ANYTYPE_INT:
ptrStr = new std::string(inData);
break;
// other cases
};
m_type = ANYTYPE_STRING;
}
除了以上所述的operator = 中所增加的代码之外,constructor以及copy constructor针对std::string重载的版本也需要做内存分配。destructor中也需要根据数据的实际类型判断是否释放相应的指针。
现在,这种做法对于C++内置类型像之前一样是直接支持的。但对于用户自定义类型,则需要通过指针的形式,动态的创建以及释放。当Any类中包含的数据从基本类型切换到non-trivial类型或者反向切换的时候,需要释放或者分配内存;但是在基本类型之间切换的时候,不需要做动态内存分配&释放。
这种不一致性导致了代码需要根据类型不同做出不同的处理,随着Any支持类型的增多,不可避免的代码膨胀发生了,而且每增加一种新类型,需要修改所有重载版本的operator =,以及新增一份constructor及copy constructor,并在destructor中增加对应类型的判断。
最为让人恼火的是,Any在处理trivial类型和non-trivial类型时的行为不一。这非常容易导致错误。
下面,我们尝试把问题简化一下,通过让Any始终保存指针来避免行为不统一的问题。无论从何种类型切换到另一种类型,我们都确保必须释放先前的内存,并为目标类型分配新的内存。
新的数据结构可以设置如下:
class Any {
public:
void * m_pointer;
int m_nType;
// supporting types
enum {
ANYTYPE_INT,
ANYTYPE_FLOAT,
ANYTYPE_STRING,
//… other supporting types
};
Any(int inData) : m_nType(ANYTYPE_INT), m_pointer(new int(inData)) {}
Any(float inData) : m_nType(ANYTYPE_FLOAT), m_pointer(new float(inData) {}
Any(const std::string& inData) :
m_nType(ANYTYPE_STRING), m_pointer(new std::string(inData)) {}
Any(const Any& rhs) : m_nType(rhs.m_nType) {
switch (m_nType)
{
case ANYTYPE_INT:
m_pointer = new int(*((int *)rhs.m_pointer));
break;
case ANYTYPE_FLOAT:
m_pointer = new float(*((float *)rhs.m_pointer));
break;
case ANYTYPE_STRING:
m_pointer = new std::string(*((std::string *)rhs.m_pointer));
break;
}
}
Any& Operator = (int inData) {
if (m_nType == ANYTYPE_INT)
*(int*)m_pointer = inData;
else {
switch (m_nType)
{
case ANYTYPE_FLOAT:
delete (float*)m_pointer;
break;
case ANYTYPE_STRING:
delete (std::string*)m_pointer;
break;
}
m_pointer = new int(inData);
};
}
// other operator equals…
};
现在的情况比之前好很多,我们不用再为Any在对待trivial类型数据与non-trivial数据时的行为不一而头痛了。
但是问题依旧很麻烦,因为使用void指针消除了存储时的类型信息,所以当delete指针时,我们需要人为的指定每一处指针所指代的类型,从而使编译器得以调用正确的析构函数。
从而,每一个重载版本的operator =当中,我们都需要判断当前的类型是否与传入参数的类型相符,若不相符,需要根据存储的类型标识符m_nType对m_pointer转型,并使用delete operator完成内存释放的工作,而后再为传入参数分配新的内存。
来回转型可能令你觉得厌烦。或许你会想到将不同种类的指针放在union当中,这样就不必为void指针转型了。但这样做行不通,原因是我们依旧需要根据m_nType的类型决定使用union中的哪个成员,实际上依旧等价于上面的做法,只不过省却了转型操作符(将转型操作符的工作移交到union的定义当中了)。
难道没有更好的方法吗?
在思考如何实现Any的过程中,我们发现了它的两个特点,譬如:
1、 我们需要保存类型信息:m_nType以及相应的enum定义;
2、 在使用Any时,我们需要明确的指出其contain的数据类型,以此得到正确的数据。(例如,使用int构建的Any,从Any中把数据拿出来时,目标也应当是一个int,而不能是float,否则会调用到错误的重载函数,从而得出错误结果)
3、 我们需要针对Any支持的所有类型实现constructor & operator = & operator T的重载版本。
为什么不利用C++自身的设施完成这种工作呢?
由第1、2点,我们发现,Any在存数和取数的时候,需要使用对应的数据类型,从而调用匹配正确的构造函数/operator =以及operator T() (这里的T指代各种类型如int, float)的重载版本。而且,Any被赋予一个值之后,再未被再次赋予其他数据类型的值之前,类型信息是始终保存在Any当中的。而当Any保存的数据类型变动时,对应的类型ID也需要更新。在这里,C++的运行时类型信息(runtime type info)正是用武之地。
在实现针对不同类型的重载函数时,我们发现几乎绝大多数工作都是重复或者类似的,在早期的版本中,甚至可以用宏来节省代码编写的工作量。C++中的模板正暗合了这里的需求。
下面,我们开始随着Ogre::Any的设计思路前行。(Ogre::Any使用了Boost::Any,但在数值Any以及stream操作上做了扩展)
如果利用模板,我们就不必再为每一种需要支持的类型写一个重载版本的constructor & operator = & operator T了。
C++的成员函数模板使我们可以写下类似下面这样的代码:
class Any {
public:
template <typename ValueType>
Any(const ValueType& v);
template <typename ValueType>
Any& operator = (const ValueType& v);
template <typename ValueType>
operator ValueType() const; // 实际实现中并没有定义这个
};
但是operator ValueType()的约束太过于宽泛了,有了它的存在,现在Any可能被用在任何我们意想不到的地方。所以实际的实现中Ogre::Any并没有定义转型操作符,而是使用了名为any_cast的函数,当我们需要在某个地方从Any中取出我们想要的数据时,我们必须清楚这个Any里放着的是什么,并且明确的把它cast出来。
any_cast的声明如下:
template <typename ValueType>
ValueType* any_cast(Any* anyVal);
template <typename ValueType>
const ValueType* any_cast(const Any* anyVal);
template <typename ValueType>
ValueType any_cast(const Any& anyVal);
这样我们就可以用像使用static_cast一样的方法,使用any_cast,如下:
Any anyVal(3246);
int nVal = any_cast<int>(anyVal);
OK,模板现在节约了我们大量的重复劳动,一个模板就涵盖了所有的可能,我们不必再为以后需要新增加的数据类型而头痛了。
基于成员函数模板的Any看起来似乎不错。但是究竟应该如何存储数据呢?
首先,想到的是在Any中置放一个模板成员变量,像这样:
class Any {
public:
template <typename ValueType>
Any(const ValueType& v);
private:
T m_val;
};
但是这样做显然行不通,因为这样一来Any类就必须是一个模板类了。这不符合我们对Any的期望。况且,一旦确定了Any的模板参数,他也就成了一个只能承载确定类型的wrapper了。这不是我们想要的。
那么如果不保存模板成员,而是使用模板成员指针是否可行呢?答案依旧是不可行。因为形如T* m_val;的定义依旧需要在编译期获知T的准确类型。一旦在编译期确定了准确的类型,我们就无法在运行期动态改变他了。
我们需要的是一个运行期可以动态变化的模板成员。
由于前面的分析,直接将一个模板成员存储于Any当中是行不通的,但是我们却可以保存一个确定类型的指针,并让这个指针应该指向实际存储我们需要的模板数据的实例。
通过类似于以下这样的继承关系:
class placeholder;
class holder<T> : public placeholder;
我们拥有了承载无穷种数据类型的可能性。
该继承关系如下图:
有了这样的数据承载类之后,Any中只需保存一个placeholder接口的指针即可。而在constructor / operator = 的时候,只需删除此前的数据,并为新的数据类型创建对应的holder<T>实例即可。
placeholder需要定义clone接口,用于在copy constructor中生成数据的拷贝。
需要定义getType接口,用于在any_cast中识别当前保存的数据类型与目标类型是否一致。(getType的实现可以采用此前的enum + m_nType的方法,但是这种方法的局限性在于我们需要为每一个可能的类型增加一个标识符,因此更好的做法是使用C++的运行时类型识别信息RTTI:Runtime type info / Runtime type identify)
实际的placeholder定义如下:
class placeholder {
public:
virtual ~placeholder() {} // 虚析构函数用以保证派生类的析构函数得以调用
virtual placeholder * clone() const = 0;
virtual const std::type_info& getType() const = 0; // 返回rtti信息
virtual void writeToStream(std::ostream& o) = 0; // 串行化支持
};
真正的承载数据的类模板holder定义很简单,实现相应的基类接口即可,如下:
template<typename ValueType>
class holder : public placeholder
{
public: // structors
holder(const ValueType & value) : held(value) { }
virtual const std::type_info & getType() const { return typeid(ValueType); }
virtual placeholder * clone() const { return new holder(held); }
virtual void writeToStream(std::ostream& o) { o << held; }
ValueType held;
};
这样一来,Any的定义就自然而生了:
class Any {
public:
Any() : mContent(0) {}
template<typename ValueType>
explicit Any(const ValueType & value)
: mContent(new holder<ValueType>(value)) { }
Any(const Any & other)
: mContent(other.mContent ? other.mContent->clone() : 0) { }
virtual ~Any() { delete mContent; }
Any& swap(Any & rhs)
{
std::swap(mContent, rhs.mContent);
return *this;
}
template<typename ValueType>
Any& operator=(const ValueType & rhs)
{
Any(rhs).swap(*this);
return *this;
}
Any & operator=(const Any & rhs)
{
Any(rhs).swap(*this);
return *this;
}
bool isEmpty() const { return !mContent; }
const std::type_info& getType() const
{ return mContent ? mContent->getType() : typeid(void); }
inline friend std::ostream& operator <<
( std::ostream& o, const Any& v )
{
if (v.mContent) v.mContent->writeToStream(o);
return o;
}
protected:
placeholder* mContent;
template<typename ValueType>
friend ValueType * any_cast(Any *);
};
以上就是Any的几乎所有的定义了。此前介绍的holder以及placeholder由于只在Any中被使用到,因此在实做中被定义为Any的嵌套类。
any_cast的定义具体如下:
template<typename ValueType>
ValueType * any_cast(Any * operand)
{
return operand && operand->getType() == typeid(ValueType)
? &static_cast<Any::holder<ValueType> *>(operand->mContent)->held
: 0;
}
需要判断被cast的Any中所贮存的数据的类型是否与目标类型一致,判断采用了C++的RTTI中的typeid。当类型不一致时,返回的结果为0。这一点的行为与dynamic_cast类似(dynamic_cast在正常的转型失败时会返回0)。使用any_cast而不是用类型转换操作符的原因,一方面在于基于模板的类型转换操作符过于随意,另一方面在于any_cast的使用方式与static_cast等几乎完全一致,符合C++的使用习惯,且当需要查找程序中有多少地方使用了any_cast时,一个grep就可以简单的给出结果。
另外两个重载版本的any_cast皆是调用上述指针版本的any_cast完成的。
至此,关于Any的实现的探讨告一段落。
最终Ogre::Any利用C++的模板特性,RTTI特性实现了一个非常具有实用价值的任意类型构件。由于采用了指针存储数据,如果大量的使用Any,会比基于union以及基本类型的实现方式速度缓慢一些。
然而对于期待高速度的多类型组合结构,在Boost中存在另一个构件可以达到相应目的,即Boost::Variant。
Boost::Variant可以像这样使用:Boost::Variant<int, float, std::string, vector<int> > myVariant;
具体Boost::Variant的使用以及实现方式,不在本文的探讨范围内。如果对此感兴趣,可以参阅Boost的文档:http://www.boost.org/doc/libs/1_37_0/doc/html/variant.html
另外,关于Boost::Any的实现,刘未鹏还撰写过一篇非常优秀的文章:
http://blog.csdn.net/pongba/archive/2004/08/24/82811.aspx
附:Ogre中除了使用Boost::Any之外,还对Any在数值上的应用做了扩展:
Ogre在Any的基础上,实现了+-*/的数学运算操作符,构成了AnyNumberic类。
Any::placeholder接口实现了getType,clone,writeToStream以实现对于实际数据的取类型,拷贝,写流操作。
在AnyNumberic中,numplaceholder的实现则需附加定义一套加减乘除的操作接口,从而使得AnyNumberic可以实现+-*/等运算符。(Any的结构此前已经分析过了,通过调用placeholder的接口实现对实际数据的操作,而placeholder接口背后的实现,则是一个根据初始化Any的实际类型实例化的模板类,在AnyNumberic中,这一结构相同)
class numplaceholder : public Any::placeholder {
virtual ~numplaceholder() {} // override virtual destructor
virtual placeholder add(placeholder* rhs) = 0;
virtual placeholder subtract(placeholder* rhs) = 0;
virtual placeholder multiply(placeholder* rhs) = 0;
virtual placeholder multiply(float rhs) = 0; // override version
virtual placeholder divide(placeholder* rhs) = 0;
};
template<typename ValueType>
class numholder : public numplaceholder {
//…
};
利用这一实现,AnyNumberic即可采用如下方式实现operator运算符:
AnyNumeric AnyNumeric::operator+(const AnyNumeric& rhs) const
{
return AnyNumeric(
static_cast<numplaceholder*>(mContent)->add(rhs.mContent));
}
其中mContent为继承自Any的数据成员,类型为Any::placeholder的指针。
AnyNumberic没有定义新的数据成员,仅仅是提供了一些新的接口,并通过派生Any::placeholder在不改动旧有功能的基础上,提供了新的数学运算的能力。
