一、为什么在使用String之前需要使用 using namespace std这样一句? Essential C++告诉我标准程序库所提供的任何事物,都被封装在命名空间std内。这样子可以避免产生命名冲突。你看到这句话一定想要挖开里面的东东,看看Microsoft是如何做的 很奇怪的我没有看到我所想要找的 namespace std { ... } 但是我注意到有 _STD_BEGIN 和 _STD_END 在几乎每个标准库头文件中。如是我想Microsoft的有宏定义嗜好的程序员一定又在用#define这个法宝。我只需查找_STD_BEGIN的定义就好了。再一次感谢Microsoft在VC中提供的 Find in Files...功能,听到硬盘一阵狂响后我看到了在Yvals.h中定义的STD家族的几行。(当然你也可以用GREP这个程序来查找,只是没有VC这个方便吧了!) #if defined(__cplusplus) #define _STD std:: #define _STD_BEGIN namespace std { #define _STD_END }; #define _STD_USING #else #define _STD :: #define _STD_BEGIN #define _STD_END #endif /* __cplusplus */
很明显,Microsoft已经将标准库封在命名空间std中了,所以下次我们要使用时一定不要忘记了加上 using namespace std; 呵呵
二、关于list::list的定义说明几点(Micrsoft VC 6.0版本) 对于list的使用大家一定不会陌生,可是一定会对如何实现这些是一些疑惑的,我也是如此。我一直想知道list是如何实现其定义的。好的,首先我们看到MSDN中给出list::list的实现 list::list explicit list(const A& al = A()); explicit list(size_type n, const T& v = T(), const A& al = A()); list(const list& x); list(const_iterator first, const_iterator last, const A& al = A()); 说明list有四种初使化方式,但我对于这四个定义有几个问题 1. explicit 是作什么用的? explicit 的中文意义是"外在的" ,其反意词是 implicit 那么它有什么特点,让我们来看一个例子(MSDN中有) class X { public: explicit X(int); file://正确 explicit X(double) { file://正确 // ... } };
explicit X::X(int) {} file://不正确 ... 说明:explicit 仅能用于类定义的内部 还有一个例子(定义为以上正确定义的类)
void f(X) {} void g(int I) { f(i); // 错误 } void h() { X x1(1); // 正确 } 如果没有explicit定义,f(i)是可以通过的,因为编译器可以实现一个隐式转换 int->(implicit) X 用i来构造一个匿名X类,作为f()的参数。 而由于有了explicit定义,故而此一步无法实现。 MSDN的原文是: The function call f(i) fails because there is no available implicit conversion from int to X.
另外:需要注意的是 explict 不参定义多参数构造函数,否则引起其他构造函数不能隐式转换。 2.我的第二疑问是explicit list(size_type n, const T& v = T(), const A& al = A()); 中 const T & v= T() 代表什么? 当我问自已这问题时,我注意到我们声明一个list变量时可以用 list<int> ilist1(10) file://声明内含10个元素,元素值为默认值。 list<int> ilist2(10,5) file://10个元素,元素值均为5 两种方式来定一个初使元素一定的list变量 注意到 T() 把 T 用int代替,得到 const int & v=int(); 哈哈, 原来如此简单! 3.list(const list& x)的意义 这是一个典型的拷贝构造函数。 因为我们可以用 list <string> slist; ... list<string> slist2(slist) file://将slist复制给slist2 至于为什么要用拷贝构造函数,你不会说你不知道吧!
CString
CString是对于原来标准c中字符串类型的一种的包装。因为,通过很长时间的编程,我们发现,很多程序的bug多和字符串有关,典型的有:缓冲溢出、内存泄漏等。而且这些bug都是致命的,会造成系统的瘫痪。因此c++里就专门的做了一个类用来维护字符串指针。标准c++里的字符串类是string,在microsoft MFC类库中使用的是CString类。通过字符串类,可以大大的避免c中的关于字符串指针的那些问题。
这里我们简单的看看Microsoft MFC中的CString是如何实现的。当然,要看原理,直接把它的代码拿过来分析是最好的。MFC里的关于CString的类的实现大部分在strcore.cpp中。
CString就是对一个用来存放字符串的缓冲区和对施加于这个字符串的操作封装。也就是说,CString里需要有一个用来存放字符串的缓冲区,并且有一个指针指向该缓冲区,该指针就是LPTSTR m_pchData。但是有些字符串操作会增建或减少字符串的长度,因此为了减少频繁的申请内存或者释放内存,CString会先申请一个大的内存块用来存放字符串。这样,以后当字符串长度增长时,如果增加的总长度不超过预先申请的内存块的长度,就不用再申请内存。当增加后的字符串长度超过预先申请的内存时,CString先释放原先的内存,然后再重新申请一个更大的内存块。同样的,当字符串长度减少时,也不释放多出来的内存空间。而是等到积累到一定程度时,才一次性将多余的内存释放。
还有,当使用一个CString对象a来初始化另一个CString对象b时,为了节省空间,新对象b并不分配空间,它所要做的只是将自己的指针指向对象a的那块内存空间,只有当需要修改对象a或者b中的字符串时,才会为新对象b申请内存空间,这叫做写入复制技术(CopyBeforeWrite)。
这样,仅仅通过一个指针就不能完整的描述这块内存的具体情况,需要更多的信息来描述。
首先,需要有一个变量来描述当前内存块的总的大小。 其次,需要一个变量来描述当前内存块已经使用的情况。也就是当前字符串的长度 另外,还需要一个变量来描述该内存块被其他CString引用的情况。有一个对象引用该内存块,就将该数值加一。
CString中专门定义了一个结构体来描述这些信息: struct CStringData { long nRefs; // reference count int nDataLength; // length of data (including terminator) int nAllocLength; // length of allocation // TCHAR data[nAllocLength]
TCHAR* data() // TCHAR* to managed data { return (TCHAR*)(this+1); } };
实际使用时,该结构体的所占用的内存块大小是不固定的,在CString内部的内存块头部,放置的是该结构体。从该内存块头部开始的sizeof(CStringData)个BYTE后才是真正的用于存放字符串的内存空间。这种结构的数据结构的申请方法是这样实现的: pData = (CStringData*) new BYTE[sizeof(CStringData) + (nLen+1)*sizeof(TCHAR)]; pData->nAllocLength = nLen; 其中nLen是用于说明需要一次性申请的内存空间的大小的。
从代码中可以很容易的看出,如果想申请一个256个TCHAR的内存块用于存放字符串,实际申请的大小是: sizeof(CStringData)个BYTE + (nLen+1)个TCHAR
其中前面sizeof(CStringData)个BYTE是用来存放CStringData信息的。后面的nLen+1个TCHAR才是真正用来存放字符串的,多出来的一个用来存放’\0’。
CString中所有的operations的都是针对这个缓冲区的。比如LPTSTR CString::GetBuffer(int nMinBufLength),它的实现方法是: 首先通过CString::GetData()取得CStringData对象的指针。该指针是通过存放字符串的指针m_pchData先后偏移sizeof(CStringData),从而得到了CStringData的地址。 然后根据参数nMinBufLength给定的值重新实例化一个CStringData对象,使得新的对象里的字符串缓冲长度能够满足nMinBufLength。 然后在重新设置一下新的CStringData中的一些描述值。 最后将新CStringData对象里的字符串缓冲直接返回给调用者。
这些过程用C++代码描述就是: if (GetData()->nRefs > 1 || nMinBufLength > GetData()->nAllocLength) { // we have to grow the buffer CStringData* pOldData = GetData(); int nOldLen = GetData()->nDataLength; // AllocBuffer will tromp it if (nMinBufLength < nOldLen) nMinBufLength = nOldLen; AllocBuffer(nMinBufLength); memcpy(m_pchData, pOldData->data(), (nOldLen+1)*sizeof(TCHAR)); GetData()->nDataLength = nOldLen; CString::Release(pOldData); } ASSERT(GetData()->nRefs <= 1);
// return a pointer to the character storage for this string ASSERT(m_pchData != NULL); return m_pchData;
很多时候,我们经常的对大批量的字符串进行互相拷贝修改等,CString 使用了CopyBeforeWrite技术。使用这种方法,当利用一个CString对象a实例化另一个对象b的时候,其实两个对象的数值是完全相同的,但是如果简单的给两个对象都申请内存的话,对于只有几个、几十个字节的字符串还没有什么,如果是一个几K甚至几M的数据量来说,是一个很大的浪费。 因此CString 在这个时候只是简单的将新对象b的字符串地址m_pchData直接指向另一个对象a的字符串地址m_pchData。所做的额外工作是将对象a的内存应用CStringData:: nRefs加一。 CString::CString(const CString& stringSrc) { m_pchData = stringSrc.m_pchData; InterlockedIncrement(&GetData()->nRefs); }
这样当修改对象a或对象b的字符串内容时,首先检查CStringData:: nRefs的值,如果大于一(等于一,说明只有自己一个应用该内存空间),说明该对象引用了别的对象内存或者自己的内存被别人应用,该对象首先将该应用值减一,然后将该内存交给其他的对象管理,自己重新申请一块内存,并将原来内存的内容拷贝过来。
其实现的简单代码是: void CString::CopyBeforeWrite() { if (GetData()->nRefs > 1) { CStringData* pData = GetData(); Release(); AllocBuffer(pData->nDataLength); memcpy(m_pchData, pData->data(), (pData- >nDataLength+1)*sizeof(TCHAR)); } } 其中Release 就是用来判断该内存的被引用情况的。 void CString::Release() { if (GetData() != _afxDataNil) { if (InterlockedDecrement(&GetData()->nRefs) <= 0) FreeData(GetData()); } }
当多个对象共享同一块内存时,这块内存就属于多个对象,而不在属于原来的申请这块内存的那个对象了。但是,每个对象在其生命结束时,都首先将这块内存的引用减一,然后再判断这个引用值,如果小于等于零时,就将其释放,否则,将之交给另外的正在引用这块内存的对象控制。
CString使用这种数据结构,对于大数据量的字符串操作,可以节省很多频繁申请释放内存的时间,有助于提升系统性能。
通过上面的分析,我们已经对CString的内部机制已经有了一个大致的了解了。总的说来MFC中的CString是比较成功的。但是,由于数据结构比较复杂(使用CStringData),所以在使用的时候就出现了很多的问题,最典型的一个就是用来描述内存块属性的属性值和实际的值不一致。出现这个问题的原因就是CString为了方便某些应用,提供了一些operations,这些operation可以直接返回内存块中的字符串的地址值,用户可以通过对这个地址值指向的地址进行修改,但是,修改后又没有调用相应的operations1使CStringData中的值来保持一致。比如,用户可以首先通过operations得到字符串地址,然后将一些新的字符增加到这个字符串中,使得字符串的长度增加,但是,由于是直接通过指针修改的,所以描述该字符串长度的CStringData中的nDataLength却还是原来的长度,因此当通过GetLength获取字符串长度时,返回的必然是不正确的。
存在这些问题的operations下面一一介绍。
1. GetBuffer
很多错误用法中最典型的一个就是CString:: GetBuffer ()了.查了MSDN,里面对这个operation的描述是: Returns a pointer to the internal character buffer for the CString object. The returned LPTSTR is not const and thus allows direct modification of CString contents。 这段很清楚的说明,对于这个operation返回的字符串指针,我们可以直接修改其中的值: CString str1("This is the string 1");――――――――――――――――1 int nOldLen = str1.GetLength();―――――――――――――――――2 char* pstr1 = str1.GetBuffer( nOldLen );――――――――――――――3 strcpy( pstr1, "modified" );――――――――――――――――――――4 int nNewLen = str1.GetLength();―――――――――――――――――5
通过设置断点,我们来运行并跟踪这段代码可以看出,当运行到三处时,str1的值是”This is the string 1”,并且nOldLen的值是20。当运行到5处时,发现,str1的值变成了”modified”。也就是说,对GetBuffer返回的字符串指针,我们将它做为参数传递给strcpy,试图来修改这个字符串指针指向的地址,结果是修改成功,并且CString对象str1的值也响应的变成了” modified”。但是,我们接着再调用str1.GetLength()时却意外的发现其返回值仍然是20,但是实际上此时str1中的字符串已经变成了” modified”,也就是说这个时候返回的值应该是字符串” modified”的长度8!而不是20。现在CString工作已经不正常了!这是怎么回事?
很显然,str1工作不正常是在对通过GetBuffer返回的指针进行一个字符串拷贝之后的。
再看MSDN上的关于这个operation的说明,可以看到里面有这么一段话: If you use the pointer returned by GetBuffer to change the string contents, you must call ReleaseBuffer before using any other CString member functions.
原来在对GetBuffer返回的指针使用之后需要调用ReleaseBuffer,这样才能使用其他CString的operations。上面的代码中,我们在4-5处增建一行代码:str2.ReleaseBuffer(),然后再观察nNewLen,发现这个时候已经是我们想要的值8了。
从CString的机理上也可以看出:GetBuffer返回的是CStringData对象里的字符串缓冲的首地址。根据这个地址,我们对这个地址里的值进行的修改,改变的只是CStringData里的字符串缓冲中的值, CStringData中的其他用来描述字符串缓冲的属性的值已经不是正确的了。比如此时CStringData:: nDataLength很显然还是原来的值20,但是现在实际上字符串的长度已经是8了。也就是说我们还需要对CStringData中的其他值进行修改。这也就是需要调用ReleaseBuffer()的原因了。
正如我们所预料的,ReleaseBuffer源代码中显示的正是我们所猜想的: CopyBeforeWrite(); // just in case GetBuffer was not called
if (nNewLength == -1) nNewLength = lstrlen(m_pchData); // zero terminated
ASSERT(nNewLength <= GetData()->nAllocLength); GetData()->nDataLength = nNewLength; m_pchData[nNewLength] = ‘\0‘; 其中CopyBeforeWrite是实现写拷贝技术的,这里不管它。
下面的代码就是重新设置CStringData对象中描述字符串长度的那个属性值的。首先取得当前字符串的长度,然后通过GetData()取得CStringData的对象指针,并修改里面的nDataLength成员值。
但是,现在的问题是,我们虽然知道了错误的原因,知道了当修改了GetBuffer返回的指针所指向的值之后需要调用ReleaseBuffer才能使用CString的其他operations时,我们就能避免不在犯这个错误了。答案是否定的。这就像虽然每一个懂一点编程知识的人都知道通过new申请的内存在使用完以后需要通过delete来释放一样,道理虽然很简单,但是,最后实际的结果还是有由于忘记调用delete而出现了内存泄漏。 实际工作中,常常是对GetBuffer返回的值进行了修改,但是最后却忘记调用ReleaseBuffer来释放。而且,由于这个错误不象new和delete人人都知道的并重视的,因此也没有一个检查机制来专门检查,所以最终程序中由于忘记调用ReleaseBuffer而引起的错误被带到了发行版本中。
要避免这个错误,方法很多。但是最简单也是最有效的就是避免这种用法。很多时候,我们并不需要这种用法,我们完全可以通过其他的安全方法来实现。 比如上面的代码,我们完全可以这样写: CString str1("This is the string 1"); int nOldLen = str1.GetLength(); str1 = "modified"; int nNewLen = str1.GetLength();
但是有时候确实需要,比如: 我们需要将一个CString对象中的字符串进行一些转换,这个转换是通过调用一个dll里的函数Translate来完成的,但是要命的是,不知道什么原因,这个函数的参数使用的是char*型的: DWORD Translate( char* pSrc, char *pDest, int nSrcLen, int nDestLen ); 这个时候我们可能就需要这个方法了: CString strDest; Int nDestLen = 100; DWORD dwRet = Translate( _strSrc.GetBuffer( _strSrc.GetLength() ), strDest.GetBuffer(nDestLen), _strSrc.GetLength(), nDestlen ); _strSrc.ReleaseBuffer(); strDest.ReleaseBuffer(); if ( SUCCESSCALL(dwRet) ) { } if ( FAILEDCALL(dwRet) ) { }
的确,这种情况是存在的,但是,我还是建议尽量避免这种用法,如果确实需要使用,请不要使用一个专门的指针来保存GetBuffer返回的值,因为这样常常会让我们忘记调用ReleaseBuffer。就像上面的代码,我们可以在调用GetBuffer之后马上就调用ReleaseBuffer来调整CString对象。
2. LPCTSTR
关于LPCTSTR的错误常常发生在初学者身上。 例如在调用函数 DWORD Translate( char* pSrc, char *pDest, int nSrcLen, int nDestLen ); 时,初学者常常使用的方法就是: int nLen = _strSrc.GetLength(); DWORD dwRet = Translate( (char*)(LPCTSTR)_strSrc), (char*)(LPCTSTR)_strSrc), nLen, nLen); if ( SUCCESSCALL(dwRet) ) { } if ( FAILEDCALL(dwRet) ) { }
他原本的初衷是将转换后的字符串仍然放在_strSrc中,但是,当调用完Translate以后之后再使用_strSrc时,却发现_strSrc已经工作不正常了。检查代码却又找不到问题到底出在哪里。
其实这个问题和第一个问题是一样的。CString类已经将LPCTST重载了。在CString中LPCTST实际上已经是一个operation了。对LPCTST的调用实际上和GetBuffer是类似的,直接返回CStringData对象中的字符串缓冲的首地址。 其C++代码实现是: _AFX_INLINE CString::operator LPCTSTR() const { return m_pchData; }
因此在使用完以后同样需要调用ReleaseBuffer()。 但是,这个谁又能看出来呢?
其实这个问题的本质原因出在类型转换上。LPCTSTR返回的是一个const char*类型,因此使用这个指针来调用Translate编译是不能通过的。对于一个初学者,或者一个有很长编程经验的人都会再通过强行类型转换将const char*转换为char*。最终造成了CString工作不正常,并且这样也很容易造成缓冲溢出。
第一章:关于对象(Object Lessons)
读完这一章使我想到了一个很久以前看到的一个笑话,编写一个HELLO WORLD的程序,随着水平和职务的不一样,程序代码也随着变化。当初看时完全当作笑话来看,现在看来写此笑话的人水平不一般。如果要使你的代码能够最大限度的适应不同的运行环境,和最大限度的复用,则在设计和编写的过程中需要考虑的问题很多,因此代码已变的不在具有C语言的简洁,高效。而牺牲了这些优势换来的是更好的封装。当然如果你只是要打印Hello World则不必这样做了。
以C++的思维方式解决问题,对于对C语言已经很熟悉的人来说会很不能适应。需要一段时间来适应,不然会将代码写的似是而非。而且不能邯郸学步,必须从思想上彻底的C++(OO),如果只是依葫芦画瓢,那结果很可能是用C++的语法编写C式的程序。本人曾经犯的典型的低级的错误之一,就是无意识的一个类无限制的扩充,完全没有考虑到类的多层结构(基类-派生类),需要属性或方法便在类中增加,虽然也用到了多态、重载等一些OO的设计方式,但最后这个类庞大无比,除了在当前系统中任劳任怨的工作外,一点复用的可能都没有,如果另一个系统还需要一个类似的东西,那只能重新设计实现一个新的类。并且最致命的是在维护更新时带来得麻烦,需要不断全部编译不说,而且代码在用了大量注释后,在过一段时间读起来也是一件重脑力劳动。及失去了C的简洁清晰和高效,也不完全具备C++的面向对象的特性。这根本不能叫C++程序。(我想有时间重写一下以前代码也会有很多收获,温故而知新吗)C和C++在编程思想上是相互矛盾的。这也就是说如果你想学C++,完全可以不学C,只需要一本好书和一个不太笨的大脑再加上努力就可以了,如果你已有C的经验在一定的情况下反而会捣乱。
本章是对对象模型的一个大略浏览。既然我们选择了C++而不是C作为开发工具,那我们的编程思想也应该转为C++的,而不能再延续C的Procedural方式。我们必须学会C++的思考方式。采用抽象数据类型或用一个多层的class体系对数据以及数据处理函数进行封装,只有摆脱C程序的使用全局数据的惯性,才能充分发挥出C++对象模型的强大威力。
在C++中有两种数据成员static和nonstatic,以及三种成员函数static、nonstatic和virtual。C++对象模型对内存空间和存取时间做了优化,nonstatic的数据成员被置于类对象之内,而static数据成员被置于类对象之外。static和nonstatic成员函数被放在类对象之外。而virtual函数是由类对象的一个指向vtbl(虚函数表)的指针vptr来进行支持。而vptr的设定和重置由类的构造函数、析构函数以及copy assignment运算符自动完成。
我们设计的每一个类几乎都要有一个或多个构造函数、析构函数和一个Assignment运算符。他们的作用是构造函数产生一个新的对象并确定它被初始化。析构函数销毁一个对象并确定它已经被适当的清理(避免出现内存泄露的问题),Assignment运算符给对象一个新值。
这是第一章的第一部分,由于雷神最近几天在做模式小组的主页,时间周转不开了。本想写完整个一章再发,考虑一下还是先发一部分吧。原因有2。1、第一章的后半部可能又要拖上10天半个月的。2、笔记实在难写,我不愿意将笔记做成将书上的重点再抄一边,而是喜欢尽量将自己的理解描述出来,谁知第一章便如此的难以消化,已经反复读了3遍,还是有些夹生。所以本着对大家和自己负责的态度,雷神准备再看它3遍在说。突然发现自己的C++还差的很远,好可怕呀。
深度探索C++对象模型(2) |
出自:雷神 2002年11月18日 13:06 |
深度探索C++对象模型(2)
笔记贴出后,有朋友便给我提出了一个很好的建议,原文如下: 史列因:我刚看了你写的“深度探索C++对象模型(1)”,感觉很不错。不过我有一个建议:你说“谁知第一章便如此的难以消化,已经反复读了3遍,还是有些夹生”是很自然的。第一章是一个总览,如果你能全看懂,后面的就没什么看的必要了。第一章的内容后面都有详细介绍,开始只要有个大概印象就可以了。这本书中很多内容都是前后重复的。我建议你先不管看懂看不懂,只管向后看,之后再从头看几遍,那样效果好得多。 我想史列因说的应该是一种非常好的阅读方式,类似《深度探索C++对象模型》这样的技术书籍,需要的是理解,和学习英文不同,不能靠死记硬背,如果出现理解不了的情况,那你不妨将书放下,打一盘红警(俺骄傲的说,我是高手)。或者跳过去也是一个不错的方法。好了,我们还是继续研究C++的对象模型吧。
简单的对象模型 看书上的例子(注释是表示solt的索引) Class Point { public: Point(float xval); //1 virtual ~Point(); //2
float x() const; //3 static int PointCount(); //4 protected: virtual ostream& print(ostream &os) const; //5 float _x; //6 static int _point_count; //7 } 每一个Object是一系列的Slots,每一个Slots指向一个members。
表格驱动对象模型
当构造对象时便会有一个类似指针数组的东西存放着类数据成员在内存中位置的指针,还有指向成员函数的指针。为了对一个类产生的所有对象实体有一个标准的表达,所以对象模型采用了表格,把所有的数据成员放在数据成员表中,把所有的成员函数的地址放在了成员函数表中,而类对象本身有指向这两个表的指针。
为了便于理解,雷神来举个不恰当的例子说明一下,注意是不很恰当的例子
我们把写字楼看成一个类,写字楼中的人看成是类的数据成员,而每一个租用写字楼的公司看成类的成员函数。我们来看一个实体,我们叫它雷神大厦。雷神大厦的物业管理部门需要登记每个出入写字楼的人,以便发通行证,并且需要登记每个公司的房间号,并制作了一个牌子在大厅的墙上。实际上这便是类的对象构造过程。你可以通过大厅墙上的公司列表找到任何一家在雷神大厦租房的公司,也可以通过物业提供的花名册找到任何一个出入雷神大厦的人。
真是一个考验大家想象力的例子。(如果你有更好例子的别忘了和雷神交流一下)。
C++的对象模型 C++对象模型是从简单对象模型派生得来,并对内存空间和存取时间做了优化。它引入了虚函数表(virtual table)的方案。每个类产生一堆指向虚函数的指针,放在表格中。每个类的对象被添加了一个指针(vptr),指向相关的虚函数表(virtual table)。而这个指针是由每一个类的constructor、destructor和copy assignment运算符自动完成。
我们还用上面的雷神大厦举例,物业管理为了提高效率,对长期稳定的公司和人员不再登记,指对不稳定或不能确定的公司进行登记,以便于管理。 再次考验大家的想象力。
得出结论,C++对象模型和双表格对象模型相比,提高了空间和存储时间的效率,却失去了弹性。
试想一下,没有整个雷神大厦人员和公司的名录,如果他们发生变化,则需要物业管理部门做很多工作。重新确定长期稳定的公司和人员是那些。对应应用程序则需要重新编译。(这次更离谱,但为了保持连贯,大家请进行理解性的思考,不要局限字面的意思)
这篇笔记是分成多次一点点写的,甚至每天抽出一个小时都不能保证(没办法最近实在忙),因此可能会有不连贯,如果你读起来很不爽认为雷神的思维短路了,那属于正常。不过雷神还是再上传之前努力的将思路进行了一下整理。希望能把这些支言片语串起来。
最后说一句阅读《深入C++对象模型》一书感觉没有什么可以被成为重点的东西,感觉每一个字都不应该放过,全是重点。经过反复阅读,雷神好象有些开窍,继续努力呀,我和大家都是。 |
深度探索C++对象模型(3) |
出自:雷神 2002年11月18日 13:08 |
介绍 多态是一种威力强大的设计机制,允许你继承一个抽象的public接口之后,封装相关的类型,需要付出的代价就是额外的间接性--不论是在内存的获得,或是在类的决断上,C++通过class的pointer和references来支持多态,这种程序风格就称为"面向对象".
正文 深度探索C++对象模型(3) 雷神: http://www. 大家好,雷神关于《深度探索C++对象模型》笔记终于又和大家见面了,速度慢的真是可以。好了不浪费时间了,直接进入主题。 这篇笔记主要解决了几个常常被人问到的问题。 1、C++支持多重继承吗? 2、结构和类的区别是什么? 3、如何设计一个面向对象的模型?
C++支持多重继承(JAVA和C#不支持多重继承),虽然我想我可能一辈子用不到它这一特性(C++是雷神的业余爱好),但至少我要知道它可以。典型的多重继承是下面这个: //iostream 从istream 和 ostream 两个类继承。 class iostream:public istream,public ostream {......};
结构struct和类class到底有没有区别?VCHELP上前几天还看到一个帖子在讨论这个问题。其实结构和类真的没什么区别,不过我们需要掌握的是什么时候用结构好,什么时候用类好,当然这没有严格的规定。通常我们混合使用它们,从书上的例子,我们可以看出为什么还需要保留结构,并且书上给出了一个方法: struct C_point{.......}; //这是一个结构 class Point { public: operator C_point(){return _c_point;} //.... private: C_point _c_point; //.... } 这种方法被成为组合(composition).它将一个对象模型的全部或部分用结构封装起来,这样做的好处是你既可以在C++中应用这个对象模型,也可以在C中应用它。因为struct封装了class的数据,使C++和C都能有合适的空间布局。
面向对象模型是有一些彼此相关的类型,通过一个抽象的base class(用来提供接口),被封装起来。真正的子类都是通过它派生的。当然一个设计优秀的对象模型还必须考虑很多的细节问题,雷神根据自己的理解写出一个面向对象模型的代码,大家可以看看,高手请给指出有没有问题。雷神先谢了。 思路:我想要实现一个人员管理管理的对象模型,雷神一直在思考一个人员管理的组件(当然最终它会用C#实现的一个业务逻辑对象,并通过数据库控制对象和数据库进行交互,通过WEB FORM来显示界面)。这里借用一下自己的已经有的的想法,用C++先进行一下实验,由于只是为了体会面向对象的概念,我们采用面向对象的方法实现一个链表程序,而且没有收集信息的接口。信息从mina()函数显式给出。 这个对象模型应该可以实现对人员的一般性管理,要求具备以下功能: 创建一个人员信息链表 添加、删除人员信息 显示人员信息
//************************************************* //PersonnelManage.cpp //创建人:雷神 //日期:2002-8-30 //版本: //描述: //*************************************************
#include <iostream.h> #include <string.h> //基类,是此对象模型的最上层父类 class Personnel { friend class point_list; //用来实现输出链表,以及插入或删除人员的功能. protected: char serial_number[15];//编号 char name[10];//名称 char password[15]//口令 Personnel *pointer; Personnel *next_link; public: Personnel(char *sn,char *nm,char *pwd) { strcpy(serial_number,sn); strcpy(name,sm); strcpy(password,pwd); next_link=0; } Personnel() { serial_number[0]=NULL; name[0]=NULL; password[0]=NULL; next_link=0; } void fill_serial_number(char *p_n) { strcpy(serial_number,p_n); } void fill_name(char *p_nm) { strcpy(name,p_nm); } void fill_password(char *p_pwd) { strcpy(password,p_pwd); } virtual void addnew(){} virtual void display() { cout<<"\n编号:"<<serial_number<<"\n"; cout<<"名字:"<<name<<"\n"; cout<<"口令:"<<password<<"\n" } }; //下面是派生的子类,为了简单些我在把子类进行了成员简化。 //思路:由父类派生出成员子类,正式成员要求更详细的个人资料,这里省略了大部份. //并且正式成员可以有一些系统的操作权限,这里省略了大部份。 //正式成员子类 class Member:public Personnel { friend class point_list; private: char member_email[50]; char member_gender[10]; double member_age; public: Member(char *sn,char *nm,char *pwd,char *em,char *gd,double ag):Personnel(sn,nm,pwd) { strcpy(member_email,em); strcpy(member_gender,gd); member_age=age; } Member():Personnel() { member_email[0]=NULL; member_gender=NULL; member_age=0.0; } void fill_email(char *p_em) { strcpy(member_email,p_em); } void fill_gender(char *p_gd) { strcpy(member_gender,p_gd); } void fill_age(double ages) { member_age=ages; }
void addnew() { pointer=this; } void display() { Personnel::display() cout<<"电子邮件:"<<member_email<<"\n"; cout<<"性别:"<<member_gender<<"\n"; cout<<"年龄"<<member_age<<"\n"; } };
//好了,我们还需要实现一个超级成员子类和一个项目经理的子类. //这是超级成员类 class Supermember:public Member { friend class point_list; private: int sm_documentcount;//提交的文档数 int sm_codecount;//提交的代码段数 public: Supermember(char *sn,char *nm,char *pwd,char *em,char *gd,double ag,int dc,int cc):Member(sn,nm,pwd,gd,ag) { sm_documnetcount=0; sm_codecount=0; } Spupermember():Member() { sm_documentcount=0; sm_codecount=0; } void fill_documentcount(int smdc) { sm_documentcount=smdc; } void fill_codecount(int smcc) { sm_codecount=smcc; }
void addnew() { pointer=this; } void display() { Member::display() cout<<"提交文章数:"<<sm_documentcount<<"\n"; cout<<"提交代码段数"<<sm_codecount<<"\n"; } };
//实现友元类 class point_list { private: Personnel *location; public: point_list() { location=0; } void print(); void insert(Personnel *node); void delete(char *serial_number); } //显示链表 void point_list::print() { Personnel *ps=location; while(ps!=0) { ps->display(); ps=ps->next_link; } } //插入链表 void point_list::insert(Personnel *node) { Personnel *current_node=location; Personnel *previous_node=0; while(current_node!=0 && (strcmp(current_node->name,node->name<0) { previous_node=current_node; current_node=current_node->next_link; } node->addnew() node->pointer->next_link=current_node; if(previous_node==0) location=node->pointer; else previous_node->next_link=node->pointer; }
//从链表中删除 void point_list::delete(char *serial_number) { Personnel *current_node=location; Personnel *previous_node=0; while(current_node!=0 && strcmp(current_node->serial_number,serial_number)!=0) { previous_node=current_node; current_node=current_node->next_link; } if(current_node !=0 && previous_node==0) { location=current_node->next_link; } else if(current_node !=0 && previous_node!=0) { previous_node->next_link=current_node->next_link; } }
//这是主函数,我们显式的增加3个Supermember信息,然后在通过编号删除一个 //我们没有从成员再派生出管理成员,所以没有办法演示它,但我们可以看出要实现它并不难 //注意:此程序没有经过验证,也许会有BUG. main() { point_list pl; Supermember sm1("000000000000001","雷神","123456","lsmodel@","男",29.9,10,10); Supermember sm1("000000000000002","木一","234567","MY@","男",26.5,20,5); Supermember sm1("000000000000003","落叶夏日","345678","LYXR@","男",24.8,5,15); //如果我们还派生了管理人员,可能的方式如下: //Managemember mm1("000000000000004","ADMIN","888888","webmaster@","男",30,5,15,......);
//下面是将上面的3个人员信息加到链表中 pl.insert(&sm1); pl.insert(&sm2); pl.insert(&sm3); //对应管理人员的 pl.insert(&mm1);
//下面是显示他们 //下面是显示人员列表 pl.print();
//下面是删除一个人员信息 pl.delete("000000000000001"); //我们再显示一次看看. cout<<"\n删除后的列表:\n"; pl.print(); }
程序没有上机验证,在我的脑子里运行了一下,我想输出结果应该是这样的:
编号:000000000001 名称:雷神 口令:123456 电子邮件:lsmodel@ 性别:男 年龄:29.9 提交文章数:10 提交代码数:10
编号:000000000002 名称:木一 口令:234567 电子邮件:MY@21CN.com 性别:男 年龄:26.5 提交文章数:20 提交代码数:5
编号:000000000003 名称:落叶夏日 口令:345678 电子邮件:LYXR@163.com 性别:男 年龄:24.8 提交文章数:5 提交代码数:15
删除后的列表:
编号:000000000002 名称:木一 口令:234567 电子邮件:MY@21CN.com 性别:男 年龄:26.5 提交文章数:20 提交代码数:5
编号:000000000003 名称:落叶夏日 口令:345678 电子邮件:LYXR@163.com 性别:男 年龄:24.8 提交文章数:5 提交代码数:15
*****************************************************************************************
通过上面的例子,我想我们能够理解对象模型的给我们带来的好处,我们用了大量的指针和引用,来完成多态的特性.和书上的资料库的例子不同,我们多了一层,那是因为我考虑人员可能是匿名,也可能是注册的,所以为了区别他们,用了两层来完成接口,然后所有注册的正式成员才都由Member类派生出不同的权限的人员,例如超级成员和管理人员.
最后用书上的一段话总结一下吧.P34 总而言之,多态是一种威力强大的设计机制,允许你继承一个抽象的public接口之后,封装相关的类型,需要付出的代价就是额外的间接性--不论是在内存的获得,或是在类的决断上,C++通过class的pointer和references来支持多态,这种程序风格就称为"面向对象".
|
深度探索C++对象模型(4) |
出自:雷神 2002年11月19日 13:09 |
介绍 这本书真的是雷神所看过的书中,看的最慢的一本了。但这些深层的知识有必要了解的很清楚吗,我们不知道编译器如何合成缺省的构造函数不也能写程序吗?雷神用侯大师的话来回答这个问题:练从难处练,用从易处用。知其然而不知其所以然,不是一个严谨的学习态度。
正文 深度探索C++对象模型(4) 雷神 http://www. 雷神跌跌撞撞的读完了《深度探索C++对象模型》的第一章,虽然还是有些疑惑,但是已经感到收获很大。按照朋友的说法,第一章是一个概括的介绍,具体的细节会在以后的章节阐述,如果没有通读本书,第一章还是比较不容易理解的。雷神听过之后信心倍增,也不在有初看此书时的“世界末日”的感觉了(在第2篇雷神感到学了近一年的C++,居然水平如此之差),并且通过自己的努力,还是摸到了些门道,所以让我们继续快乐的出发,踏上深度探索C++对象模型的旅程。记住我们在第一篇的小文《坚持不懈,直到成功》,这可是获得成功的不二法门。 第二章主要讲的的构造函数语意(Semantics),这是一个什么意思?我的英文和中文学的都不好,但我想是书上弄错了(也许只是一个笔误),也许应该翻译成语义比较恰当。The study or science of meaning in anguage forms. 语义学以语言形式表示意思的研究或科学。我们要研究构造函数的,并且以语言的形式将它描述清楚。
看完题目我的第一个感觉,构造函数我知道。构造函数是一个类的成员函数,构造函数和析构函数是进行对象数据的创建,初始化,清除工作的成员函数,可以重载构造函数,使一个类不止具备一个构造函数,因有时需要以这些方法中的某一种分别创建不同的对象。不能重载析构函数。构造函数作为成员函数和类有相同的名字。例:一个类名为:aClass,构造函数就是aClass()。构造函数没有返回值,而且不能定义其返回类型,void也不行。析构函数同样使用这一点。当编写重载函数时,只有参数表不同,通过比较其参数个数或参数类型可以区分两个重载函数。但是我读完第一小段后就知道这一章要告诉我们什么了。 这一章并不是要告诉我们什么是构造函数,它的作用是什么。而是要告诉我们的是构造函数是如何工作的。我的。在得知这点后我很兴奋,因为我确实不知道构造函数是如何构造一个类的对象的,并且一直想知道。我一直对面向对象神奇的功能很感兴趣。为什么一个类在被实例化时,可以自动的完成很多工作,使我们的主函数清晰,简单,稳健,高效。以前只看到了表面,没有深入,这会我们有机会去皮剔肉深入骨髓了。
书上主要讨论了几种情况: 带有缺省构造函数的成员对象。如果一个类没有任何的构造函数,但他有一个成员对象,这个对象的类有一个缺省的构造函数,那么编译器会在需要的时候为这个类合成一个构造函数。 举个例子: 我们有以下几个类。它们都有一个构造函数。 猫{public:猫(),......}; 狗{public:狗(),......}; 鸟{public:鸟(),......}; 鱼{public:鱼(),......}; 我们又有一个类。宠物,我们将猫作为它的成员之一。并且没有给它声明构造函数。 宠物{ public: 猫 一只猫; 狗 一只狗; 鸟 一只鸟; 鱼 一只鱼; private: int ival; ...... } 则当需要的时候编译器会为它合成一个构造函数,并且采用内联方式。大概象下面的样子。 inline 宠物::宠物() { 猫.猫::猫(); 狗.狗::狗(); 鸟.鸟::鸟(); 鱼.鱼::鱼(); ival=0; } 为什么会这样,我们来看看编译器的行动。编译器开始执行用户的代码,准备生成宠物对象之前,会首先调用必要的构造函数,来初始化类的成员,以便为对象分配合适的内存空间。结果编译器会合成上面的构造函数,如果程序员为宠物类写了一个构造函数。 宠物::宠物(){ival=0;}那编译器也会将这个构造函数扩张成上面的那样。编译器是怎样实现的呢?原来当一个类没有任何用户定义的构造函数,而是由编译器自动生成的话,则这个被暗中生成的构造函数将会是一个没有什么用处的构造函数。但是通过编译器的工作能够为我们合成一个nontrivial default constructor. 好象香港电影中演的,如果你惹上官司(你要设计一个类),你又没有钱去请高级的律师(没有给出构造函数),那会给你分配一个律师(缺省的构造函数),当然这个律师的能力也许和那些大律师比起来有差距(trivial)。不过我们要知道他们也不是一点用都没有。但是由于有律师行的督导,可以使这些律师能够努力做到最好(nontrivial)。
同样的道理,我们可以理解另外的几种nontrivial default constructor的情况。 如果你的类没有任何的构造函数,并且它派生于一个有着缺省构造函数的基类,那这个派生类的缺省构造函数会被视为nontrivial,因此需要被合成出来,他的合成步骤是调用上一层基类的缺省构造函数,并根据它们的声明次序为派生类合成一个构造函数。
如果类声明或继承了一个虚函数,或者类派生于一个继承串链,其中有一个或更多的虚拟基类。由于缺少使用者声明的构造函数,则编译器会合成一个缺省的构造函数,以便正确的初始化每一个类对象的vptr。
最后说一点,在合成的缺省构造函数中,只有基类的子对象和类的成员对象会被初始化,所有其他的非静态数据成员都不会被初始化,因为这些操作是需要程序员来做的。编译器没有必要连这些工作都做了。
好了,这篇就写到这里吧。这本书真的是雷神所看过的书中,看的最慢的一本了。但这些深层的知识有必要了解的很清楚吗,我们不知道编译器如何合成缺省的构造函数不也能写程序吗?雷神用侯大师的话来回答这个问题:练从难处练,用从易处用。知其然而不知其所以然,不是一个严谨的学习态度。 |
深度探索C++对象模型(5) |
出自:雷神 2002年11月19日 13:10 |
介绍 我们这篇学习的内容是:当一个对象以另一个对象作为初始值时,会发生什么事情.
正文 深度探索C++对象模型(5) 雷神
上一篇我们对合成确省的构造函数做了一个了解,这一篇我们继续看看构造函数这个有趣的东西. Copy Constructor是什么?我们经常看到代码中有一些这样的函数调用方式X(X&) (“X of X ref”). 这个函数用用户自定义类型作为参数,那它的参数的构造便是由Copy Constructor负责的. 可见这个玩意非常重要,实际上Copy Constructor是由编译器自动合成的,不需要你去作任何事情,但编译器都做了些什么呢?我们的问题出来了.
我们有三种情况需要用一个对象的内容作为另一个类对象的初值.也就是需要编译器来为我们自动合成Copy Constructor.一种是我们在编程中肯定回用到的由类生成对象例如以下形式: class ClassA{......} ClassA a; ClassA b=a; //一个Class对象以另一个对象做初值 另外的一种情况是以对象为参数在函数中传递看下面的伪码: //例如我们有一个CUser类 CUser{ CUser(); ...... }; //我们还有一个CDatabase类,它有一个AddNew的方法 CDatabase{ ...... public: AddNew(CUser userone); ......} //我们用CUser类产生了一个对象实例.userone,并将他作为AddNew函数的参数,以便 //AddNew函数能够完成在数据库中增加一条记录,用来记录一个用户的信息 CDatabase db=new CDatabase(); db.AddNew(CUser userone) //在这里,你不用将你的用户类的成员全部展开. 还有一种当然是用做函数的return,例如你可以在CDatabase类中添加一个函数用来读取一个用户的信息例如这样CUser GetUserOne(int userID),通过一个用户的唯一的编号可以获得一个用户的信息,并返回一个CUser类的对象.
我们来看看Copy Constructor是如何工作的.首先Copy Constructor和Default Constructor一样都是在需要的时候由编译器产生出来,一个类如果没有声明一个Copy Constructor就会存在一个隐含的声明(或定义).它也被分为trivial和nontrivial两种.
我们来看书上的例子: Class Word { public: Word(const char*); ~Word(){delete [] str;} private: int cnt; Char *str; } 这个类的声明不需要合成出Default Copy Constructor.但当进行如下应用时: #include "Word.h" Word noun("lsmodel"); void foo() { Word verb=noun; } 结果将会出现灾难性的后果.为什么?因为我们的逻辑对象verb和全局对象noun都指向了相同的字符串,在退出函数foo()之前verb会执行析构,则字符串被删除,从此全局对象nonu指向了一堆无意义的东西.你可以声明一个explicit copy constructor来解决这个问题,当然还可以让编译器来自动的给你合成一个Copy construct. 我们将上面的Word类改写成下面的样子: Class Word { public: Word(const String&);//注意这里和我们开始的X(X&)形式一样 ~Word(); //...... private: int cnt; String str; // 这个成员是String类的对象,String是我们自定义的类型 }; Class String { public: String(const char*); String(const String&);//这里声明了一个Copy constructir ~String(); //...... } 这时在执行我们的代码 #include "Word.h" Word noun("lsmodel"); void foo() { Word verb=noun; } 编译器会为我们的Word类合成一个Copy Constructor,用来调用它的str(member class String object)的Copy Constructor.象下面伪码表示的这样: inline Word::Word(const Word &wd) { str.String::String(wd.str); cnt=wd.cnt; } 当这个类中有一个或多个虚函数时,或者这个类是派生于一个继承串链,并且这个串中有一个或多个虚拟的基类时.这个类在进行拷贝时便不会展现逐次拷贝(bitwise copy).并且会通过合成的Copy Constructor来重新明确的设定vptr来指向虚函数表,而不是将右边对象的vprt直接拷贝过来.书上的ZooAnimal例子的图可以很清晰的描述出这点.
如果一个对象以另一个对象做初值,而后者有一个Virtual Base Class Subobject,那会怎样呢?任何一个编译器都会做到在派生类对象中的virtual base class Subobject的位置在执行期就准备妥当,但bitwise copy可能会破坏这一位置,因此也需要由编译器合成出一个copy constructor,来安插一些代码来设定virtual base class pointer/offset,对每一个成员执行必要的memberwise初始化操作,以及执行内存相关的工作.
最后我们来总结一下上面说的内容,确实有些乱.雷神越来越觉得自己的缺乏文字描述能力. 我们这篇学习的内容是:当一个对象以另一个对象作为初始值时,会发生什么事情. 分成了两种情况,一种是我们声明了explicit copy constructor,这个不是这篇文章需要搞明白的(我想大家也都很明白了).我们想知道的是我们没有为class声明explicit copy constructor函数时编译器都干了些什么.编译器会为我们合成一个copy constructor.以便适应任何时候的对象被正确的初始化.并且我们了解了有以下四种情况class不在按位逐一进行拷贝. 1.当你设计的类声明了一个explicit copy constructor函数时. 2.当你设计的类是由一个具有explicit copy constructor的基类派生的时. 3.当你设计的类声明了一个或多个虚函数时. 4.当你设计的类派生自一个继承串链,这个继承串链中有一个或多个virtual base classes时.
|
深度探索C++对象模型(6) |
出自:雷神 2002年11月19日 13:11 |
介绍 在第三章一开始,雷神就吃了一惊
正文 深度探索C++对象模型(6) 雷神
这是这个系列笔记的第7篇了,我们还在和构造函数打交道,以前写程序时怎么根本没有考虑过构造函数的事情呢?原来编译器为我们做了这么多的事情,我们都不知道.,要想完全搞明白,看来还需要一段时间.我们继续向下走,进入一个新的章节.每当雷神看完一章后,总是期盼下一章节,因为这意味又一个新的里程开始了.对于这本书更是感觉强烈,因为全书总共才7章. 在第三章一开始,雷神就吃了一惊..书上给出了一个例子: class X{}; class Y:public virtual class X{}; class Z:public virtual class X{}; class A:public Y,public Z{}; 下面的结果会因为机器,以及编译有关,不同的情况会产生不同的结果.(怎么会是这样?) sizeof X; //结果为1 sizeof Y; //结果为8 sizeof Z; //结果为8 sizeof A; //结果为12 一个没有任何成员的类,大小居然不是0. 为什么? 首先一个没有明显的含有成员的类,它的大小不是0,因为实际上它不是空的,它被编译器安插了一个char,为的是使这个类的两个对象能够在内存中被分配独一无二的地址.至于两个派生的类Y和Z,因为语言本身造成的负担,还有编译器对于特殊情况进行的优化处理,再有Alignment的限制,因此结果变成了8.这个8是怎么组成的? 4个bytes用来存放指针,什么指针?指向virtual base class subobject的指针呀. 一个同class X一样的char.它占了1 个bytes. 然后受到Alignment的限制,所以填补了3个bytes. 4+1+3=8 不过需要注意的是不同的编译器Y和Z大小的结果也会不同.因为新的编译器会将一个空的virtual base class看做是派生类对象的开头部分,因此派生类有了member,因此也就不必分配char的那一个bytes.也就用不到填补的3个bytes,因此有可能在某些编译器中,class Y和class Z的大小为4. 最后看看A.根据我们对class Y的分析可以得出以下算式: 4+4+1+3=12; 不是我们想象的16,而是12.如果换成我们上面说的新的编译器来编译,结果很有可能是8. 雷神1、4、8……的说了一堆,也不知大家明白与否,但是这第三章,读起来确实比前两章顺多了。我们继续 我们来看Data Member 的Binding,现在我们对数据成员的绑定只需要记住一个防御性风格:始终把嵌套类型的声明放在class的开始部分,这样做可以确保非直觉绑定的正确性。看下面的一个例子:
typedef int length; //zai class point3d { public: //length被决议成global typedef 也就是int //_val被决议成Point3d::_val void mumble(length val){_val=val;} length mumble(){return _val;} //…… private: //length必须在这个class对它的第一个参考操作之前被看见 //这样声明将使先前的参考操作不合法 typedef float length; length _val; //…… }; 怎么成了抄书了,雷神也不知不觉,可能是在这章的理解上比较容易些吧,不用去想个看的见摸的着的东西比划。好象小朋友学算术,一位数的计算不用掰手指头,可是两位数或者三位数的计算,手指头加上脚指头还是不够。学习就是这么回事。理解力和抽象能力很重要。回来继续学习。 通过这一章我还知道了。数据成员的布局。数据成员的存取。并且对Static data members有了进一步的了解,在class的生命周期中,静态成员被看作是全局变量,每一个member的存取不会导致任何空间或效率上的额外负担。不论是从一个复杂的继承关系中继承还是直接声明的,Static data member都只会有一个实体。并且有着非常直接的存取路径。另外如果两个类都声明了一个相同名字的静态成员变量,那么编译器会通过一种算法,为我们解决名字冲突的问题。而非静态的成员变量的存去实际上是通过implicit class object(this指针)来完成的。例如 Point3d Point3d::translate(const Point3d &pt) { x+=pt.x; y+=pt.y; z+=pt.z; } 被编译器经过内部转换成为了下面这个样子: Point3d Point3d::translate(Point3d *const this,const Point3d &pt) { this->x+=pt.x; this->y+=pt.y; this->z+=pt.z; } 如果要对一个非静态的成员变量进行存取,编译器会把类对象的起始地址加上数据成员的偏移量。例如: Point3d origin; origin._y=0.0; //地址&origin._y将等于 &origin+(&Point3d::_y-1); 目的是使编译系统能够区分出以下两种情况: 一个指向数据成员的指针,用来指出类的第一个成员。 一个指向数据成员的指针,没有指出任何成员。 这是什么意思?什么是指向数据成员的指针。书上的例子: class Point3d { public: virtual ~Point3d(); //…… protected: static Point3d origin;//静态的数据成员,位置在class object之外 float x,y,z;//每个float是4bytes } &Point3d::z; //这个值是什么? 我们在这篇文章开始的时候已经知道了还有一个vptr,不过vptr的位置也许在对象的开始,也许在对象的结尾部。所以上面的操作的值应该是8或者12(如果vptr在前面的话)。但实际上取会的值被加上了1。原因是必须要区别一个不指向任何成员的指针,和一个指向第一个成员的指针。又有点不好理解了,举个例子: 想象你和你的另外两个朋友合住一个三室一厅的房子,你住在第一间。如果你给一个你们三个人共同的朋友的地址你可以给房号就行了。不用给出你们的任意一个人的那间房子号(不指向任何成员)。但如果你给你的一个私人朋友地址,你会给出房间号和你的那个房间号。为了使这个地址有区别,你必须有一个厅来作为偏移量(offset)。不知道大家明白这个例子吗,也许这个例子会影响你的正确思维。那就太糟糕了。不过我还是喜欢这样想问题,也许不太准确,但可以帮助我,因为想象一个内存空间比想象一个三居室要难好几点儿。 |
深度探索C++对象模型(7) |
出自:雷神 2002年11月19日 13:12 |
介绍 在单一继承的体系中,虚函数机制是一种很有效率的机制。我们判断一个类是否支持多态,只需要看它有没有虚函数便可以了。
正文 深度探索C++对象模型(7) 雷神
关于《深度探索C++对象模型》停顿了半个月,今天继续啃这个骨头,我的学习进入了第四章,函数的语意学。先做个复习C++支持三种成员函数:静态、虚、和非静态。每一种函数的调用方式都不同,当然他们的作用也会有区别,一般来说我们只要掌握根据我们的需要正确的使用这三种类型的成员函数便可以了,至于内部是如何运做的我们可以不知。但是《深度探索C++对象模型》正是让我们对这些不知道的东西进行深度探索的一本书。通过前面的学习,我想我知道了一些以前不知道的东西,但是感觉并没有提高多少,也许是我对此书的学习还停留在一个比较肤浅的层次上吧。我想我应该会抽时间再看几遍。有些跑题了,因为雷神想说明一下,这些笔记只是雷神看书是的一些想法的记录,如果你再看仅供参考,因为我本人好象也只探索了不是很深的程度。
我们的在设计和使用类时最常用的便是非静态成员函数,使用成员函数是为了封装和隐藏我们的数据,我想这是成员函数和外部函数的最明显的区别。但是他们的效率是否有不同呢?我们不会想为了保护我们的数据而使用成员函数,最后确导致效率降低的结果。让我们看看非静态成员函数在实际的执行时被编译器搞成了什么样子。
float magnitude3d(const Point3d *_this){…} //这是一个外部函数,它有参数。表示它间接的取得坐标(Point3d)成员。 float Point3d::mangnitude3d() const {…} //这是一个成员函数,它直接取得坐标(Point3d)的成员。 表面上看,似乎成员函数的效率高很多,但实际上他们的效率真的想我们想象的那样吗?非也。实际上一个成员函数被内部转化成了外部函数。 1、 一个this指针被加入到成员函数的参数中,为的是能够使类的对象调用这个函数。 2、 将对所有非静态数据成员的存取操作改为由this来存取。 3、 对函数的名称进行重新的处理,使它成为程序中独一无二的。 这时后,经过以上的转换,成员函数已经成为了非成员函数。 float Point3d::mangnitude3d() const {…}//成员函数将被变成下面的样子 //伪码 mangnitude3d__7Point3dFv(register Point3d * const this) { return sqrt(this->_x * this->x+ this->_y * this->y+ this->_z * this->z); }
调用此函数的操作也被转换 obj. mangnitude3d() 被转换成: mangnitude3d__7Point3dFv(*obj); 怎么样看出来了吧,和我们开始声明的非成员函数没有区别了。因此得出结论:两个铁球同时落地。
一般来说,一个成员的名称前面会被加上类的名称,形成唯一的命名。实际上在对成员名称做处理时,除了加上了类名,还会将参数的链表一并加上,这样才能保证结果是独一无二的。
我们在来看看静态成员函数。我们有这样的概念,成员函数的调用必须是用类的对象,象这样obj.fun();或者这样ptr->fun().但实际上,只有一个或多个静态数据成员被成员函数存取时才需要类的对象。类的对象提供一个指针this,用来将用到的非静态数据成员绑定到类对象对应的成员上。如果没有用到任何一个成员数据,就不需要用到this指针,也就没有必要通过类的对象来调用一个成员函数。而且我们还知道静态数据成员是在类之外的,可以被视做全局变量的,只不过它只在一个类的生命范围内可见。(参考前面的笔记)。而且一般来说我们会将静态的数据成员声明为一个非Public。这样我们便必须提供一个或多个成员函数用来存取这个成员。虽然我们可以不依靠类的对象存取静态数据成员,但是这个可以用来存取静态成员的函数确实必须绑定在类的对象上的。为了更加好的解决这个问题,cfront2.0引入了静态成员函数的概念。
静态成员函数是没有this指针的。因为它不需要通过类的对象来调用。而且它不能直接存取类中的非静态成员。并且不能够被声明为virtual,const,volatile.如果取得一个静态成员函数的地址,那么我们获得的是这个函数在内存中的位置。(非静态成员函数的地址我们获得的是一个指向这个类成员函数的指针,函数指针)。可以看到由于静态成员函数没有this指针,和非成员函数非常的相似。
有了前面几章的基础,好象这些描述理解起来也不很费劲,而且我们的思路可以跟着书上所说的一路倾泻下来,这便是读书的乐趣所在了,如果一本书读起来都想读第一章时那样费劲,我想我读不下去的可能性会很高。
继续我们的学习,下面书上开始将虚函数了。我们知道虚函数是C++的一个很重要的特性,面向对象的多态便是由虚函数实现的。多态的概念是一个用一个public base class的指针(或者引用),寻址出一个派生类对象。虚函数实现的模型是这样。每一个类都有一个虚函数表,它包含类中有作用的虚函数的地址,当类产生对象时会有一个指针,指向虚函数表。为了支持虚函数的机制,便有了“执行期多态”的形式。
下面这样。 我们可以定义一个基类的指针。 Point *ptr; 然后在执行期使他寻址出我们需要的对象。可以是 ptr =new Point2d; 还可以是 ptr=new Pont3d; ptr这个指针负责使程序在任何地方都可以采用一组由基类派生的类型。这种多态形式是消极的,因为它必须在编译时期完成。与之对应的是一种多态的积极形式,即在执行期完成用指针或引用查找我们的一个派生类的对象。 象下面这样: ptr->z(); 要想达到我们目的,这个函数z()应该是虚函数,并且还应该知道ptr所指的对象的真实类型,以便我们选择z()的实体。以及z()实体的位置,以便我们能够调用它。这些工作编译器都会为我们做好,编译器是如何做的呢? 我们已知每一个类会有一个虚函数表,这个表中含有对应类的对象的所有虚函数实体的地址,并且可能会改写一个基类的虚函数实体。如果没有改写基类存在的虚函数实体,则会继承基类的函数实体,这还没完,还会有一个pure_virtual_called()的函数实体。每一个虚函数不论是继承的还是改写的,都会被指派一个固定的索引值,这个索引在整个继承体系中保持与特定的虚函数关联。 说明:当没有改写基类的虚函数时,该函数的实体地址是被拷贝到派生类的虚函数表中的。
这样我们便实现了执行期的积极多态。这种形式的特点是,我们从头到尾都不知道ptr指针指向了那一个对象类型,基类?派生类1?派生类2?我们不知道,也不需要知道。我们只需要知道ptr指向的虚函数表。而且我们也不知道z()函数的实体会被调用,我们只知道z()函数的函数地址被放在虚函数表中的位置。
总结:在单一继承的体系中,虚函数机制是一种很有效率的机制。我们判断一个类是否支持多态,只需要看它有没有虚函数便可以了。
|
深度探索C++对象模型(8) |
出自:雷神 2002年11月19日 13:12 |
介绍 但是构造函数和析构函数和new和delete不同,他们并非必须成对的出现。决定是否为一个类写构造函数或者析构函数,是取决于这个类对象的生命在哪里结束(或开始)。需要什么操作才能保证对象的完整。
正文 深度探索C++对象模型(8) 雷神
书的第四章后半部分详细的讲解内联函数,由于比较容易理解,雷神做一个简单总结便过去吧。 内联函数和其他的函数相比是一种效率很高的函数,未优化的情况下效率可以提高25%,优化以后简直是数量级的变化,书上的给出的数据是0.08比4.43。简直没法比了。内联函数对于封装提供了一种必要的支持,可以有效的存去类中的非共有数据成员,同时可以替代#define(前置处理宏)。但是它也有缺点,程序会随着调用内联函数次数的增多,而产生大量的扩展码。 在内联函数的扩展时每一个形式参数被对应的实参取代,因此会有副作用。通常需要引入临时对象解决多次对实际参数求值的操作产生的副作用。
第五章的开始给出了一个不恰当的抽象类的声明: class Abstract_base { public: virtual ~Abstract_base()=0;//纯虚析构函数 virtual void interface() const=0; //纯虚函数 virtual const char* mumble() const{return _mumble;} protected: char *_mumble; }; 这是一个不能产生实体的抽象类,因为它有纯虚函数。为什么说它存在不合适的地方呢?以下逐一进行说明。 1、 它没有一个明确的构造函数,因为没有构造函数来初始化数据成员则它的派生类无法决定数据成员的初值。类的成员数据应该在构造函数或成员函数中被指定初值,否则将破坏封装性质。 2、 每一个派生类的析构函数会被编译器进行扩展以静态调用方式调用其上层基类的析构,哪怕是纯虚函数。但是编译器并不能在链接时找到纯虚的析构函数,然后合成一个必要的函数实体,因此最好不要把虚的析构函数声明成纯虚的。 3、 除非必要,不要把所有的成员函数都声明为虚函数。这不是一个好设计观念。 4、 除非必要,不要使用const声明函数,因为很多派生的实体需要修改数据成员。
有了以上的观点上面的抽象类应该改为下面这种样子: class Abstract_base { public: virtual ~Absteact_base(); //不在是纯虚 virtual void interface()=0; //不在是const const char * mumble() const{return _mumble;} //不在是虚函数 protected: Abstract_base(char *pc=0); //增加了唯一参数的构造 Char *_mumble; };
下一个问题,对象的构造。构造一个对象出来很简单,这是我们在编程时经常要做的事情。我理解书上的意思是为我们分析了各种不同的类,例如一个没有Copy constructor,Copy operator的类,或者有私有变量但是没有定义虚函数的类等等,当他们构造对象时也有多种情况,global,local,还有在new时,编译器都做了什么,内存的分配情况如何。搞清楚它们也很有意思。另外这好象是前面几章学到的东西的一个进一步的研究。我们找出最复杂的虚拟继承来进行一下研究。当一个类对象被构造时,实际上这个类的构造函数被调用,不论是我们自己写的,还是由编译器为我们合成的。并且编译器会背着我们做很多的扩充工作,将记录在成员初始化列表中的数据成员的初始化工作放进构造函数,如果一个数据成员没有在成员初始化列表中出现,则会调用默认的构造函数,这个类的所有基类的构造都会被调用,以基类的声明顺序。所有的虚拟基类的构造也会被调用。还要为virtual table pointers设定初始值,指向适当的virtual tables。好家伙,编译器还真累。好象说的不是很清楚,抄一段书上的代码。
已知一个类的层次结构和派生关系如下图:
 见书上P211。 这是程序员给出的PVertex的构造函数: PVertex::PVertex(float x,float y,float z):_next(0),Vertex3d(x,y,z),Point(x,y) { if(spyOn) cerr<<”within PVertex::PVertex()”<<”size:”<<size()<<endl; }
它可能被扩展成为: //C++伪码 // PVertex构造函数的扩展结果 PVertex * PVertex::PVertex(PVertex * this,bool most_derived,float x,float y,float z) { //条件式的调用虚基类的构造函数 if(_most_derived!=false) this->Point::Point(x,y); //无条件的调用上层基类的构造函数 this->Vertex3d::Vertex3d(x,y,z); //将相关的vptr初始化 this->_vptr_PVertex=_vtbl_PVertex; this->_vptr_Point_PVertex=_vtbl_Point_PVertex;
//原来构造函数中的代码 if(spyOn) cerr<<”within PVertex::PVertex()”<<”size:” //经虚拟机制调用 <<(*this->_vptr_PVertex[3].faddr )(this)<<endl; //返回被构造的对象 return this; } 通过上面的代码我们可以比较清晰的了解在有多重继承+虚拟继承的时候构造一个对象时,编译会将构造函数扩充成一个什么样子。以及扩充的顺序。知道了这个相对于无继承,或者不是虚拟继承时对象的构造应该也可以理解了。与构造对象相对应的是析构。但是构造函数和析构函数和new和delete不同,他们并非必须成对的出现。决定是否为一个类写构造函数或者析构函数,是取决于这个类对象的生命在哪里结束(或开始)。需要什么操作才能保证对象的完整。象构造函数一样析构函数的最佳实现策略是维护两份destructor实体。一个complete object实体,总是设定好vptrs,并调用虚拟基类的析构函数。一个base class subobject实体。除非在析构函数中调用一个虚函数,否则绝不会调用虚拟基类的析构函数,并设定vptrs。 一个对象生命结束于析构函数开始执行的时候。它的扩展形式和构造函数的扩展顺序相反。
|
深度探索C++对象模型(9) |
出自:雷神 2002年11月19日 13:13 |
介绍 当编译一个C++程序时,计算机的内存被分成了4个区域,一个包括程序的代码,一个包括所有的全局变量,一个是堆栈,还有一个是堆(heap),我们称堆是自由的内存区域,我们可以通过new和delete把对象放在这个区域。你可以在任何地方分配和释放自由存储区。但是要注意因为分配在堆中的对象没有作用域的限制,因此一旦new了它,必须delete它,否则程序将崩溃,这便是内存泄漏。(C#已经通过内存托管解决了这一令人头疼的问题)。C++通过new来分配内存,new的参数是一个表达式,该表达式返回需要分配的内存字节数,这是我以前掌握的关于new的知识,下面看看通过这本书,使我们能够更进一步的了解到些什么。
读者评分 15 评分次数 5
正文 深度探索C++对象模型(9) 雷神
这一章主要是说Runtime Semantics执行期语义学。
这是我们平时写的程序片段: Matrix identity; //一个全局对象 Main() { Matrix m1=identity; …… return 0; } 很常见的一个代码片段,雷神从来没有考虑过identity如何被构造,或者如何被销毁。因为它肯定在Matrix m1=identity之前就被构造出来了,并且在main函数结束前被销毁了。我们不用考虑这些问题,好象C++就应该这样。但这本书是研究C++底层机制的。既然我们在看这本书,说明我们希望了解C++的编译器又做了那些大量的工作,使得我们可以这样使用对象。
在C++程序中所有的全局对象都被放在data segment中,如果明确赋值,则对象以该值为初值,否则所配置到内存内容为0。也就是说,如果我们有以下定义 Int v1=1024; Int v2; 则v1和v2都被配置于data segment,v1值为1024,v2值为0。(雷神在VC6环境用MFC编程时中发现如果int v2;v2的值不为0,而是-8,不知为什么?编译器造成的?)。
如果有一个全局对象,并且这个对象有构造函数和析构函数的话,它需要静态的初始化操作和内存释放工作,C++是一种跨平台的编程语言,因此它的编译器需要一种可以移植的静态初始化和内存释放的方法。下面便是它的策略。 1、 为每一个需要静态初始化的档案产生一个_sit()函数,内带构造函数或内联的扩展。 2、 为每一个需要静态的内存释放操作的文件中,产生一个_std()函数,内带析构函数或内联的扩展。 3、 提供一个_main()函数,用来调用所有的_sti()函数,还有一个exit()函数调用所有的_std()函数。 侯先生说: Sit可以理解成static initialization的缩写。 Std可以理解成static deallocation的缩写。 那么main函数会被编译器变成这样: Matrix identity; //一个全局对象 Main() { _main();//对所有的全局对象做static initialization动作。 Matrix m1=identity; …… exit();//对所有的全局对象做static deallocation动作。 } 其中_main()会有一个对identity对象的静态初始化的_sti函数,象下面伪码这样: // matrix_c是文件名编码_identity表示静态对象,这样能够保证向执行文件提供唯一的识别符号 _sti__matrix_c_identity() { identity.Matrix:: Matrix(); //这就是静态初始化 } 相应的在exit()函数也会有一个_std_matrix_c_identity(),来进行static deallocation动作。 但是被静态初始化的对象有一些缺点,在使用异常时,对象不能被放置在try区段内。还有对象的相依顺序引出的复杂度,因此不建议使用需要静态初始化的全局对象。
局部静态对象在C++底层机制是如何构造和在内存中销毁的呢? 1、 导入一个临时对象用来保护局部静态对象的初始化操作。 2、 第一次处理时,临时对象为false,于是构造函数被调用,然后临时对象被改为true. 3、 临时对象的true或者false便成为了判断对象是否被构造的标准。 4、 根据判断的结果决定对象的析构函数是否执行。
如果一个类定义了构造函数或者析构函数,则当你定义了一个对象数组时,编译器会通过运行库将你的定义进行加工,例如: point knots[10]; //我们的定义 vec_new(&knots,sizeof(point),10,&point::point,0); //编译器调用vec_new()操作。
下面给出vec_new()原型,不同的编译器会有差别。 void * vec_new( void *array, //数组的起始地址 size_t elem_size, //每个对象的大小 int elem_count, //数组元素个数 void(*constructor)(void*), void(*destructor)(void* ,char) ) 对于明显获得初值的元素,vec_new()不再有必要,例如: point knots[10]={ Point(), //knots[0] Point(1.0,1.0,0.5), //knots[1] -1.0 //knots[2] }; 会被编译器转换成: //C++伪码 Point::Point(&knots[0]); Point::Point(&knots[1],1.0,1.0,0.5); Point::Point(&knots[2],-1.0,0.0,0.0); vec_new(&knots,sizeof(point),10,&point::point,0); //剩下的元素,编译器调用vec_new()操作。 怎么样,很神奇吧。
当编译一个C++程序时,计算机的内存被分成了4个区域,一个包括程序的代码,一个包括所有的全局变量,一个是堆栈,还有一个是堆(heap),我们称堆是自由的内存区域,我们可以通过new和delete把对象放在这个区域。你可以在任何地方分配和释放自由存储区。但是要注意因为分配在堆中的对象没有作用域的限制,因此一旦new了它,必须delete它,否则程序将崩溃,这便是内存泄漏。(C#已经通过内存托管解决了这一令人头疼的问题)。C++通过new来分配内存,new的参数是一个表达式,该表达式返回需要分配的内存字节数,这是我以前掌握的关于new的知识,下面看看通过这本书,使我们能够更进一步的了解到些什么。 Point3d *origin=new Point3d; //我们new 了一个Point3d对象 编译器开始工作,上面的一行代码被转换成为下面的伪码: Point3d * origin; If(origin=_new(sizeof(Point3d))) { try{ origin=Point3d::Point3d(origin); } catch(…){ _delete(origin); throw; } } 而delete origin; 会被转换成(雷神将书上的代码改为exception handling情况): if(origin!=0){ try{ Point3d::~Point3d(origin); _delete(origin); catch(…){ _delete(origin); //不知对否? throw; } } 一般来说对于new的操作都直截了当,但语言要求每一次对new的调用都必须传回一个唯一的指针,解决这个问题的办法是,传回一个指针指向一个默认为size=1的内存区块,实际上是以标准的C的malloc()来完成。同样delete也是由标准C的free()来完成。原来如此。
最后这篇笔记再说说临时对象的问题。 T operator+(const T&,const T&); //如果我们有一个函数 T a,b,c; //以及三个对象: c=a+b; //可能会导致临时对象产生。用来放置a+b的返回值。然后再由 T的copy constructor把临时对象当作c的初值。也有可能直接由拷贝构造将a+b的值放到c中,这时便不需要临时对象。另外还有一种可能通过操作符的重载定义,经named return value优化也可以获得c对象。这三种方法结果一样,区别在于初始化的成本。对临时对象书上有很好的总结: 在某些环境下,有processor产生的临时对象是有必要的,也是比较方便的,这样的临时对象由编译器决定。 临时对象的销毁应该是对完整表达式求值过程的最后一个步骤。 因为临时对象是根据执行期语义有条件的产生,因此它的生命规则就显得很复杂。C++标准要求凡含有表达式执行结果的临时对象,应该保留到对象的初始化操作完成为止。当然这样也会有例外,当一个临时对象被一个引用绑定时,对象将残留,直到被初始化的引用的生命结束,或者超出临时对象的作用域。
|
|