18.1 C++内存高级话题-new、delete的进一步认识
18.2 C++内存高级话题-new内存分配细节探秘与重载类内operator new、delete
18.3 C++内存高级话题-内存池概念、代码实现和详细分析
18.4 C++内存高级话题-嵌入式指针概念及范例、内存池改进版
18.5 C++内存高级话题-重载全局new/delete、定位new及重载
4.嵌入式指针概念及范例、内存池改进版
4.1 嵌入式指针
(1)嵌入式指针概念
嵌入式指针,英文名字叫作embedded pointer,是一个挺巧妙的小东西,即便读者不使用,但读别人代码时可能会遇到,所以应该知道这方面的知识。
嵌入式指针其实也是一个指针,为什么多了“嵌入式”三个字,相信学习完本节内容后,读者就清楚了。
嵌入式指针常用于内存池的代码实现中,上一节中讲解了内存池,写了一段代码实现了内存池,回忆一下第一次分配内存时,内存池大概的样子如图所示。
在上一节的实现代码中,为了让空闲的内存块能够正确地分配出去,在类A中引入了一个成员变量next,如下:
A * next;
读者都知道,这是一个指针,在当前Visual Studio 的x86平台下,这个指针是占4字节的,每new一个类A对象,都会有这么一个4字节的next指针出现,这个多出来的4字节仔细分析一下,是属于内存空间的浪费。
通过编码能够把这4字节省下来,这就需要用到本节所讲的“嵌入式指针”技术。当然,该技术要成功地使用需要一个前提条件,不过这个前提条件比较容易满足,稍后会谈。
“嵌入式指针”的工作原理就是:借用类A对象所占的内存空间的前4字节(代替上面谈到的next指针),这4字节专门用来链住这些空闲的内存块。当一个空闲的内存块分配出去之后,这前4字节的内容就不需要了(因为上一节笔者曾经说过:对于已经分配出去的内存块的next指针指向什么已经没有实际意义),即便这4字节的内容被具体的对象内的数据所覆盖,也无所谓。
既然谈到这里,读者肯定就想到了,“嵌入式指针”技术要成功地使用需要一个前提条件:
那就是这个类A的sizeof(new一个该类对象时所占用的内存字节数)必须要不少于4字节。当然,在实际的项目中,一般来讲,一个类对象(或者说一个类)的sizeof肯定会超过4字节,不巧的是,在上一节的范例中类A的sizeof值正好是4字节,而这4字节恰好是next成员变量所占的4字节。现在的主要目的是要把next取消掉,那本类这个sizeof(A)的4字节,这next一取消掉,4-4变成0了,其实不是0,而是1字节(任何一个类或者类对象的sizeof都不可能是0字节,最少都是1字节,哪怕是一个空类。笔者会在《C++新经典:对象模型》书籍中专门论述这方面的知识,这里就不多谈)。
现在的情形下,如果拿掉类A中的next成员,就导致sizeof(A)不够4字节,没法使用“嵌入式指针”技术了。为了演示嵌入式指针技术,笔者向类A中随便增加两个public修饰的int成员变量,则sizeof(A)立即变成8,这个大小足够演示“嵌入式指针”技术了:
public:
int m_i;
int m_j;
引入“嵌入式指针”技术后,图18.14就演变成了图18.18:每个对象块中都借用该块前面的4字节来保存这个嵌入式指针值(这个值用来指向下一个空闲块)。
为方便查看,在图18.18中将指向箭头绘制在对象块的左侧(因为是借用块首的4字节来当嵌入式指针用),其实与图18.14中将指向箭头绘制在对象块右侧的效果是一样的。
(2)嵌入式指针演示代码
嵌入式指针的理论已经讲得比较详细。这里看一看嵌入式指针的实现代码。这里写一个类,名字叫作TestEP,为了保证该类的sizeof值不小于4,这里给该类两个int类型的成员变量,这样该类的sizeof值至少是8,即超过了4,可以安全地在其中使用嵌入式指针。类TestEP定义如下:
class TestEP
{
public:
int m_i;
int m_j;
public:
struct obj
{
struct obj* next; //next就是个嵌入式指针
};
};
这种代码,如果读者是第一次看到,可能会很不习惯。都有哪些不习惯呢?
(1)比如说类里突然多了一个结构的定义,其实,跟在类外定义这个结构没什么区别,只不过如果把这个结构定义在类TestEP外面的话,外界要用这个obj结构名时直接写成obj,如果定义在类TestEP里面,外界要用obj类名时就需要写成TestEP::obj,所以这个嵌入式指针只不过是嵌到类里面的一个类(结构)。这就是“嵌入式”这三个字的由来。为什么obj这个结构要嵌入到类TestEP里面,显然是因为只想在类TestEP里面使用这个obj结构,而不希望在TestEP类外面也能用obj这个名字,所以放到里面来(而且一般都用private修饰)。
(2)structobj*next;。
乍一看上去这种结构成员定义也可能没反应过来。其实这不过是一个指针变量,名字叫next。这个指针变量指向什么呢?只不过是指向一个obj结构。这就是一个链表,自己是一个obj结构对象,那么把自己这个对象的next指针指向另外一个obj结构对象,最终就是把多个自己这种类型的对象通过next指针(像一个铁链)串起来,如图18.19所示。
其实图18.19的感觉和图18.13是完全一样的。
有了上面这两点的认知,写几行测试代码看一看嵌入式指针是怎样使用的。在main主函数中写入如下代码:
{
TestEP mytest;
cout << sizeof(mytest) << endl; //8
TestEP::obj* ptmp; //定义一个指针
ptmp = (TestEP::obj*) & mytest; //把对象mytest的首地址给了这个指针,这个指针指向对象mytest首地址
ptmp->next = nullptr; //前四个字节给成了00 00 00 00
}
根据上面这几行代码,绘制一下对象的结构数据图,如图18.20所示(注意,是ptmp指向了mytest的首地址,而ptmp->next代表的是mytest首地址开始的4字节的内容)。
通过设置断点并跟踪调试观察可以注意到,ptmp->next=nullptr;对应着把mytest对象内存地址的前4字节清0。所以说这里的ptmp->next占用的是对象mytest的前4字节,这一点千万不要理解错。这就是前面探讨的借用对象的前4字节保存嵌入式指针指向的内容。
上面的测试代码是让这个嵌入式指针指向空了,如果想让它指向下一个内存池中内存块的地址,那最终不就把上一节讲的next指针省下来了嘛!(这里也涉及一个类对象在内存中的布局问题,笔者会在《C++新经典:对象模型》书籍中详细介绍这方面的知识)。这就是嵌入式指针的工作原理。
4.2 内存池代码的改进
有了“嵌入式指针”概念后,笔者希望对内存池进行改进,应用“嵌入式指针”这个技术来作为块与块之间的链,同时上一节的内存池是只针对一个类(类A)而写的,如果应用到别的类如类B中,还得在类B中写一堆代码,很不方便。为了把内存池技术更好地应用到其他类中,这里单独为内存池技术的使用写一个类。
注意下面的实现代码,尤其注意“嵌入式指针”技术是如何应用到内存池的设计中来,因为这个类的大多数代码内容和上一节很类似,所以相信读者都能够懂。
//专门的内存池类
class myallocator //必须保证使用本类的类 sizeof()不少于4字节,否则崩溃报错
{
public:
//分配内存接口
void* allocate(size_t size)
{
obj* tmplink;
if (m_FreePosi == nullptr)
{
//为空,我要申请内存,要申请一大块内存
size_t realsize = m_sTrunkCout * size; //申请m_sTrunkCout这么多倍的内存
m_FreePosi = (obj*)malloc(realsize);
tmplink = m_FreePosi;
//把分配出来的这一大块内存(5小块),彼此用链起来,供后续使用
for (int i = 0; i < m_sTrunkCout - 1; ++i) //0--3
{
tmplink->next = (obj*)((char*)tmplink + size);
tmplink = tmplink->next;
} //end for
tmplink->next = nullptr;
} //end if
tmplink = m_FreePosi;
m_FreePosi = m_FreePosi->next;
return tmplink;
}
//释放内存接口
void deallocate(void* phead)
{
((obj*)phead)->next = m_FreePosi;
m_FreePosi = (obj*)phead;
}
private:
//写在类内的结构,这样只让其在类内使用
struct obj
{
struct obj* next; //这个next就是个嵌入式指针
};
int m_sTrunkCout = 5;//一次分配5倍的该类内存作为内存池子的大小
obj* m_FreePosi = nullptr;
};
有了这个专用的内存池类或者说是内存分配类,怎样用起来?改造上一节讲解的类A中的代码。完整的类A代码现在如下:
class A
{
public:
//必须保证sizeof(A)凑够4个字节的,这里两个int成员8字节了,所以使用类myallocator毫无问题
int m_i;
int m_j;
public:
static myallocator myalloc; //静态成员变量,跟着类A走
static void* operator new(size_t size)
{
return myalloc.allocate(size);
}
static void operator delete(void* phead)
{
return myalloc.deallocate(phead);
}
};
上面的代码中定义了一个静态成员变量myalloc,然后直接改造了一下类A中的operator new和operator delete成员函数,整体比较简单。现在在main主函数中写入如下的测试代码:
int main()
{
cout << sizeof(myallocator) << endl;
A* mypa[100];
for (int i = 0; i < 15; ++i)
{
mypa[i] = new A();
printf("%p\n", mypa[i]);
}
for (int i = 0; i < 15; ++i)
{
delete mypa[i];
}
}
通过上面的结果不难看到,每5个分配的内存地址都是挨着的(间隔8字节),这说明内存池机制在发挥作用(因为内存池是一次分配5块内存,显然这5块内存地址是挨在一起的,因为这5块内存实际上是一次分配出来的一大块内存)。
当然,如果觉得在类A中加入的代码还是有点多,可以用宏来简化,分别定义两个宏。如下:
#define DECLARE_POOL_ALLOC()\
public:\
static void *operator new(size_t size)\
{\
return myalloc.allocate(size);\
}\
static void operator delete(void *phead)\
{\
return myalloc.deallocate(phead);\
}\
static myallocator myalloc;
#define IMPLEMENT_POOL_ALLOC(classname)\
myallocator classname::myalloc;
这样,整个类A的定义写成下面的样子即可:
//myallocator A::myalloc; //在类A之外定义一下这个静态成员变量
class A
{
public:
A()
{
cout << "类A的构造函数执行了" << endl;
}
~A()
{
cout << "类A的析构函数执行了" << endl;
}
DECLARE_POOL_ALLOC();
public:
//必须保证sizeof(A)凑够4个字节的,这里两个int成员8字节了,所以使用类myallocator毫无问题
int m_i;
int m_j;
};
IMPLEMENT_POOL_ALLOC(A)
其他的代码不需要改变。
本节主要是引入嵌入式指针的概念并希望读者知道,在设计类内内存池时往往都用到嵌入式指针技术来节省4字节的额外空间。另外,本节实现了一个独立的内存池类myallocator,这样所有的类都可以用这个myallocator类来分配内存了。同时,以后读者看到嵌入式指针的用法也就能够很自然地认识了。