« 一道初等几何题 | 返回首页 | 游戏服务器组间的通讯 »

更健壮的 C++ 对象生命期管理

以下的这个 C++ 技巧是前段时间一个同事介绍给我的,而他是从 fmod 中看来。当时听过后没怎么在意,主要是因为这两年对 C++ 的奇技淫巧兴趣不大了。今天跟另一同事讨论一些设计问题时,突然觉得似乎在某些地方还有点用途,就向人介绍了一番。讲完了后觉得其实还是有点意思,不妨写在 blog 上。

问题的由来是这样的:音频播放的模块中比较难处理的一个问题是,波形(wave sample)数据对象的生命期管理问题。因为你拿到一个对象后,很可能只对它做一个播放(play)的操作,然后就不会再理会它了。但是这个对象又不能立刻被释放掉。因为声卡还在处理这些数据呢。我们往往希望在声音停止后,自动销毁掉这个对象。

另一些时候,我们还需要对正在播放的声音做更细致的控制。尤其在实现 3d 音效,或是做类似多普勒效应的声音效果的时候。

C++ 中传统的方法是用智能指针的方式来管理声音对象,但这依赖语言本身的一些特性。fmod 提供了诸多语言的接口,它在为 C++ 提供接口的时候利用了一个更为巧妙的方法。

声音的类工厂并不需要生产出一个真正的对象指针,而是返回一个唯一 ID 。但是在语法层面上看,它却是一个对象指针。也就是说,如果你用调试器去看这个指针的值,他很有可能是 1,2,3,4 这样的数字。

如果这个类不提供任何虚方法,也没有直接可以访问的成员变量的话,对这个指针做任何成员函数调用其实都是合法的。因为 C++ 的普通成员函数调用仅仅只是把对象指针做为一个特殊的叫做 this 的参数传入函数而已。比如这样的代码是完全可以正常运行的:

class A {
public:
    void test() {
        printf("%d",(int)this);
    }
};

int main()
{
   ((A*)1)->test();
    return 0;
}

那么库的实现只需要在每个成员函数的开始,利用一张 hash 表,把 this 表示的 id 转换成内部真正的对象指针即可。如果这个 id 对应的对象已经被销毁,则可以安全的退出函数调用。

这个技巧提高了库的健壮性,其代价是每次成员函数调用都需要多一次 hash 表查询操作。如果想优化一下性能的话,不妨 cache 住最近访问过的对象。

Comments

建议看看这篇文章对你会有帮助的 http://www.150it.cn/bianchengwendang/VC/865064948.html

在FMOD中怎样知道声音何时播放完呢?
我是菜菜

对大家的讨论不置可否,但是使用ID代替指针确实在某些方面是有益处的。
而某些时候我们无法使用智能指针。举个例子,完成端口中

GetQueuedCompletionStatus(
*pTHIS,
&dwNumberBytes,
(PULONG_PTR)&lpKey,
&lpEvent,
INFINITE
);

其中的lpKey是一个非常关键的变量,我们一般把它置为一个对象的指针。这样我们就可以通过这个指针去访问该对象。

但是事实上很多时候这个指针的生命周期是不固定的,而由于其传入的是PULONG_PTR类型的值,我们又不能直接使用智能指针。要么就要繁琐的执行addref 和 release 这样的操作而完全丢失智能指针的优越性。

可是,如果这里传入的是一个ID或者HANDLE,为其对象的访问增加一层间接性。那么我们就可以预防非法的指针访问,从而达到安全的生命周期管理。

另外,对于如何生成这个ID,我以前跟一位同事听说过一个自动列表模式,这里我贴出它的实现

//////////////////////////////
// .h
//////////////////////////////
#pragma once
#ifndef _AUTOLIST
#define _AUTOLIST
//--------------------------------------------------------------------------------------------------------//
// 自动列表
/*
write by albert 2007/7/19
自动列表的实现,自动列表需要一个列表管理类和一个Object元素类。
该实现使客户对一个指针的访问变为对一个ID的访问,通过这层间接性,可以使指针访问变得更为安全。
*/
//--------------------------------------------------------------------------------------------------------//
#define INVALID_OBJID -1;

class IObject
{
protected:
IObject();
virtual ~IObject();

LONG GetID() { return m_nID; }

private:
LONG m_nID;
};

class CAutoList
{
friend class IObject;
public:
static CAutoList& Instance();
void Initialize( size_t size );
IObject* GetByID( LONG nID );

private:
CAutoList();
~CAutoList(void);

LONG Append( IObject* pObj );
void Remove( LONG nID );

private:
IObject** m_ObjectArray;
volatile LONG m_nCurID;
LONG m_nMaxID;
};
#endif // _AUTOLIST

///////////////////////////////
// .cpp
//////////////////////////////
#include "StdAfx.h"
#include ".\autolist.h"

IObject::IObject()
{
m_nID = CAutoList::Instance().Append( this );
}

IObject::~IObject()
{
// 此处存在的问题
// 虽然释放内存是在析构函数完成后
CAutoList::Instance().Remove( m_nID );
}

CAutoList::CAutoList(void)
: m_ObjectArray( NULL )
{
}

CAutoList::~CAutoList(void)
{
if( m_ObjectArray )
{
delete[] m_ObjectArray;
}
}

void CAutoList::Initialize( size_t size )
{
m_nMaxID = (ULONG)size;
m_ObjectArray = new IObject*[size];
}

IObject* CAutoList::GetByID( LONG nID )
{
ASSERT( nID >= 0 && nID < m_nMaxID );

if( nID < 0 || nID > m_nMaxID )
{
return NULL;
}
ASSERT( m_ObjectArray[nID]);
return m_ObjectArray[nID];
}

LONG CAutoList::Append( IObject* pObj )
{
LONG nCount = 0;;
while( m_ObjectArray[m_nCurID] != NULL )
{
++m_nCurID;
++nCount;
if( nCount >= m_nMaxID )
{
return INVALID_OBJID;
}

if( m_nCurID >= m_nMaxID )
{
m_nCurID = 0;
}
}
m_ObjectArray[m_nCurID] = pObj;
return m_nCurID;
}

void CAutoList::Remove( LONG nID )
{
ASSERT( nID >= 0 && nID < m_nMaxID );

if( nID < 0 || nID > m_nMaxID )
{
return;
}
m_ObjectArray[nID] = NULL;
}

这里最重要的是,ID的生成和释放完全封装在IObject接口中,在对象的生命周期管理中我们并没有和以前有什么不同。区别在于,对于对象的访问,我们则可以使用另外一种增加了间接性的途径,而且可以保证其有效性。

但是这样做也有隐患,如果一个ID经过了很长一段时间后才开始访问对象,此时原先的对象已经被释放了,而且一个新对象占据了ID所指的位置。那么可能导致一个错误的访问,也许是致命的(比如强制转换为不同的类型,更完善的方法是进行类型识别,这样问题就不是致命的了。)对于时间上的缺陷,确实无法避免,如果大家友好的方法也请告诉我。这里我使用了轮寻的方法来缓解这个问题。List的空间越大,出问题的可能性就越小。

欢迎大家一起讨论,我的email:windxu@126.com

晕,什么呀,不就是Handle吗,如此普通的技术有什么好说的

感觉几个人完全是对不同的东西在 发表看法...

又吵起来了,
呵呵.

我觉得审美观是独立于语言存在的,C 的文件操作用的 FILE * ,posix 的 api 是用的 handle 。它们各有各的理由。

只是为使用方便而做的 C++ wrapper 意义不大。加了个 cookie 在 smartptr 中,并没有完全解决健壮性问题,只是部分的让程序员用的时候少犯错误。

而我写这篇 blog 并不是想说明用 handle 更为健壮,(我以为在这个应用中这样做是不言自明的),而是想介绍一种利用 C++ 的语言特性对 handle 做强类型检查的方案。

btw,至于用handle接口好还是用对象接口好,这个属于审美观的问题而并没有太多本质上的差别,C++程序员会觉得handle是丑陋的,而C程序员则非常习惯使用handle。windows的很多底层功能是用handle接口提供的,但是更多的高层服务使用COM接口形式提供。我前面只是从个人审美的角度提供了一种采用对象接口形式的实现方法,既然是用C++,自然就应该用C++的方式思考问题了,我并不是说用handle有什么不对,只是因为它在C++程序员眼里是丑陋的实现。

多线程有多线程的设计,单线程有单线程的设计,当然不能混为一谈的。我只是为单线程做的设计,如果非要用到多线程里,那就完全不是这样的设计了。

至于是不是应该减少击键数,那纯粹是个人价值观的问题了,不过像云风这么"勤奋节俭"的程序员还是比较少见的。

寒死,线程安全是首先就要考虑的,这可不是事后扩展的问题域。fmod 是一个通用库,怎么可能不考虑线程安全呢?

这个设计是 fmod 给出的,我只是写篇 blog 介绍这个技巧。一个劲维护自己观点的是谁?

btw . 我一向不赞成为了节省键击数量而做的任何封装。之所以介绍这个,是我认为它可以借助 C++ 的语言特性对 handle 本身做强类型检查,而不是把 C 代码转换成 C++ 带来的更少的键击数。

用 handle 带来的健壮性的好处本身跟这篇 blog 原本无关。

扯到线程安全上那就没完没了了,云风为了维护自己的观点可以任意扩展问题域啊,那就没什么好讨论的了。

""""to mike, 为了保证健壮性,id/handle 是不可以复用的。因为你无法保证库的使用者不会一直拿着一个可能早就被干掉了的 handle 。""""

用指针做ID方便调试.
调试完后再改为句柄.

与此相对的,stl, boost 的库大多数都是 header only ,使用模板是一个原因,另一原因则在于可以更易于使用。boost 的邮件列表经常有人抱怨一个库因为依赖于另一个非 header only 的库而做不到 header only 。那帮家伙甚至把 boost thread 里面的互斥体代码抠出来放到匿名命名空间里面,以避免依赖于需要编译的库。

现在才发现这篇文章的用意在于给 c++ 一个和 c 相同的二进制接口,而又可以用成员函数的语法。

二进制复用真的那么重要吗?

看来我还是受微软毒害太多了, gcc 的 thiscall 本来就是 cdecl 。刚才看到和谐百科对 thiscall 的说明:

This calling convention is used for calling C++ non-static member functions. There are two primary versions of thiscall used depending on the compiler and whether or not the function uses variable arguments.

For the GCC compiler, thiscall is almost identical to cdecl: the calling function cleans the stack, and the parameters are passed in right-to-left order. The difference is the addition of the this pointer, which is pushed onto the stack last, as if it were the first parameter in the function prototype.

On the Microsoft Visual C++ compiler, the this pointer is passed in ECX and it is the callee that cleans the stack, mirroring the stdcall convention used in C for this compiler and in Windows API functions. When functions use a variable number of arguments, it is the caller that cleans the stack (cf. cdecl).

The thiscall calling convention can only be explicitly specified on Microsoft Visual C++ 2005 and later. On any other compiler thiscall is not a keyword. (Disassemblers like IDA, however, have to specify it anyway. So IDA uses keyword __thiscall__ for this)

gcc 坚持用最通用的调用方式而不做任何优化,很值得佩服。

thiscall 只是一个优化协议而已。在 C 里,默认只有一种调用协议。

你可以把你的所有成员函数都强制为 C 调用协议。这样,this 就会作为第一个参数压栈了。C 这边要做的只是多写一个原型而已。

ps. COM 不是用的 thiscall ,一般用的是 stdcall

是像COM那样定义__thiscall的函数指针吗?gcc支持__thiscall吗?还是说用“跨平台汇编”来解决?

只要暴露裸指针,都是不安全的。尤其是线程安全的问题。

前面我指的不安全,就在于构造这个 smartptr 时传入的裸指针 p, 并无法保证 p 的有效和 getcookie 的成功。

同理,以后 smartptr 的传递,也必须永远跟着 cookie 一起。因为在你暴露出 p 的那一刻,就是线程不安全的了。

当然,通过修改这个 smartptr 的实现,总可以得到一个跟正文中功能相同的等价物。比如,把 isvalid 的行为改为 lock ,然后在调用完毕后增加一个 unlock 。并在给出同时返回 cookie 的构造函数……

但是这些,需要库的实现暴露更多的接口。而且增加了 warpper 的复杂度,再给很多不同的语言做 warpper 时,工作重复了。

to atry, C 和 C++ 可以共享成员函数,只需要他们遵守一定的调用规则。fmod 本身是用 C 实现的。

to mike, 为了保证健壮性,id/handle 是不可以复用的。因为你无法保证库的使用者不会一直拿着一个可能早就被干掉了的 handle 。

每次一讨论语言,大家就集体性的亢奋。

我不明白,智能指针不能跨语言使用,但是成员函数也不能用在C里面。既然用了成员函数就不用考虑兼容C吧?

在接口部分可以用裸指针来传递,传递之前先检查一下,如果无效则传递空指针即可。

这个代码是可以保证安全的,我写的不太完整,还要再加个函数。
template<class T> class SmartPtr
{

T* _impl; int _cookie;

SmartPtr(T* p) { _impl = p; _cookie = p->getCookie(); }


SmartPtr(const SmartPtr& p)
{
p.CheckValid();
_impl = p._impl;
_cookie = p._cookie;
}


bool CheckValid(){
if(!T::IsValid(_cookie))
{
_impl = 0;
return false;
}
return true;
}


T* oprator ->() { assert(T::IsValid(_cookie)); return _impl; }

}


指针全部通过智能指针来传递就可以确保有效性,而用裸指针构造仅在对象创建之初使用,只要不在使用过程中用裸指针就可以保证安全。

另外我用assert当然也是有道理的,你获得一个指针以后可能进行多个方法调用,没有必要每次调用之前都去检查,完全可以先检查一次,之后再决定如何处理,每次方法调用之前都检查显得非常笨拙。

需要掌握的技巧越多,越说明这门语言的不完备。
C是个反面典型,C++模板元是另一个。

""至于直接把指针值当成ID用是不行的,不能保证指针值的唯一性。""

通过一点技巧,也可以保证指针值的唯一性.

""至于直接把指针值当成ID用是不行的,不能保证指针值的唯一性。""

fmod 这种情况,应该不需要保证 ID 的唯一性。释放后的ID可以重新分配使用的.

至于直接把指针值当成ID用是不行的,不能保证指针值的唯一性。
---------------------------
fmod 这种情况,应该不需要保证 ID 的唯一性。
释放后的ID可以重新分配使用的.

to analyst, 你的代码是不安全的。

因为无法保证在构造 SmartPtr 的时候 T* p 还没有被删掉,构造函数中的 p->getCookie() 就无法保证。

而且,这样的 wrapper object 不利于指针的传递。调用库的人再设计上层结构的时候,非 POD 的数据类型频繁出现在接口中总是件另人恼火的事情。

至于 handle 到指针的转换,在 T::IsValid(_cookie) 还是要做的。这里用 assert 是不对的。就声音库而言,使用无效指针并不一定是编码的 bug 。

如果你真只是想在调试时方便查看对象内部细节的话,还不如做个 wrapper class 去调用 handle2ptr 的 api (假设暴露出来给调试者用的话),把实际指针绑在一起呢。即反过来用你上面的 class 。

至于直接把指针值当成ID用是不行的,不能保证指针值的唯一性。

看来我们对智能指针的定义有些出入,我说的智能指针是指对象指针的wrapper类,即有值的特性又表现的像个指针,所以你的这个class A也属于我所说的智能指针范畴。我说的是你的这个实现比较丑陋,完全可以用cookie来优雅的实现,还可以避免Handle到指针的转换,效率更高。

比方像这样:
template<T>
class SmartPtr
{
T* _impl;
int _cookie;

SmartPtr(T* p)
{
_impl = p;
_cookie = p->getCookie();
}

T* oprator ->()
{
assert(T::IsValid(_cookie));
return _impl;
}
}

我的想法是:
直接把指针值变成整数返回,当成ID用.
成员函数验证指针值是否有效.


我讨厌handle,调试不爽.

看不懂,也顶你!

to analyst: 这不是我的方法,这是 fmod 用的方法。至于为什么没用智能指针,慢慢体会吧。很容易想通的。

不是很懂,需要更多代码才能体会

你的方法不也是用C++来实现的,这跟跨语言有什么关系?智能指针本来就是个wrapper类,只是为了方便C++使用者而已,跟跨语言本来就没什么关系。
“调试时看对象的内容是库的实现者干的事情,对使用者没有意义”,这个结论太过武断。

这个技巧跟“自动”销毁声音对象无关,只是在声音播放完后可以由掌控它的代码把对象安全的销毁掉。

ps. 在 DirectSound 中,是用 Notify 通知用户的。当然你也可以周期性的主动检查。

那这个技巧怎样用到自动销毁声音对象上呢?怎样知道声音何时播放完呢?

智能指针这个东西不能跨语言使用的。通用库的提供者,不可能这么干。

把 handle 转化为 C++ 对象指针,可以利用的是 C++ 的强类型检查。

调试时看对象的内容是库的实现者干的事情,对使用者没有意义。

这个方法...实在太丑陋了点。尤其是调试的时候看到不对象内部的值了。如果想知道对象是否被释放了,可以在只能指针对象里多记一个cookie,调用之前查询一下cookie是否有效。

第一次看到这样的技巧,但看不明白为什么可以写成这样?
((A*)1)->test()

如果依赖C++语言本身的特性,做到这些倒是还有好多种办法,比如智能指针,比如代理对象。

C++的ABI并没有一个规范。一个实现可以把this当成一个参数传递,而不做多余的事情。也可以在进入真正的代码以前对this进行一些操作,至少VC在debug模式就会做一些检测,传空指针给成员函数就会崩掉。

这方法没什么希奇的,好象觉得也和 C++ 没什么关系

Post a comment

非这个主题相关的留言请到:留言本