《Effictive C++》学习笔记 — 定制 new 和 delete

本文详细介绍了C++中new和delete操作符的定制,包括new_handler的行为、处理特定类的new_handler、使用模板基类实现定制以及nothrownew的使用。此外,还探讨了何时替换new和delete的合理时机,以及编写new和delete时需遵循的规则。文章强调了内存管理的重要性,特别是对于错误检测、性能优化和统计分析的场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >


除非特别说明,本文中讨论的关于 new 的功能同样适用于 new[]

条款49 — 了解 new_handler 的行为

1、全局new_handler

我们知道new失败的时候会抛出bad_alloc异常;我们也可以通过指定noexcept设置new操作符不抛出异常而是返回空指针。然而,在执行上述操作之前,new在分配失败时会首先调用用户的异常处理函数即new_handler。其类型声明及设置方法如下:

typedef void (*new_handler)();
std::new_handler set_new_handler( std::new_handler new_p ) noexcept;(since C++11)

new操作符无法找到足够的空间时,它会尝试不断调用new_handler。因此,在new_handler中,我们必须做以下事情之一:

让更多内存可用 — 这样new操作失败后可以通过new_handler获取更多可用空间,进而使得该次操作成功。通常情况下,我们可以在程序运行时就申请分配一大块内存;当new_handler第一次被调用时,将它们返还给程序。
安装另一个new_handler — 如果当前的new_handler无法获取更多内存,但是它知道还有其他可用的new_handler有此能力,可以重新设置该new_handler函数指针。
卸载new_handler — 卸载new_handler会使对应的处理函数指针为空。在这种情况下,new操作符会抛出异常。
抛出bad_alloc
程序退出

从手册中拷贝一个例子:

#include <iostream>
#include <new>

void handler()
{
	std::cout << "Memory allocation failed, terminating\n";
	std::set_new_handler(nullptr);
}

int main()
{
	std::set_new_handler(handler);
	try {
		while (true) {
			new int[100000000ul];
		}
	}
	catch (const std::bad_alloc& e) {
		std::cout << e.what() << '\n';
	}
}

在这里插入图片描述

2、处理特定类的new_handler

有的时候,我们希望某个特定的new_handler被用于处理某个特定的类。显而易见,每个类肯定需要提供自己的new_handler函数。然而,我们需要注意我们设置完特定的new_handler应该保证不影响其他类。这意味着我们需要在全局new操作符执行之前设置new_handler,在其执行完恢复全局new_handler,因此需要重写new操作符;最后,我们不应该写死new_handler函数,即我们在new操作符设置的new_handler应该是由客户指定的。因此我们需要提供一个set_new_handler函数用于客户给当前类设置异常处理函数。当然,这个名字未必叫这个,但是它的功能和系统所提供的set_new_handler应该保持一致。

#include <iostream>
using namespace std;

class CLS_Test
{
public:
	static void* operator new(size_t size) noexcept(false);
	static new_handler set_new_handler(new_handler _handler);
private:
	static new_handler m_curHandler;
};

new_handler CLS_Test::m_curHandler = nullptr;
new_handler CLS_Test::set_new_handler(new_handler _handler)
{
	new_handler oldHandler = m_curHandler;
	m_curHandler = _handler;
	return oldHandler;
}

void* CLS_Test::operator new(size_t size) noexcept(false)
{
	new_handler oldHandler = ::set_new_handler(m_curHandler);
	void *pRet = ::operator new(size);
	::set_new_handler(oldHandler);
	return pRet;
}

这样做基本实现了我们的目标。然而我们却忽略了一个问题:全局的new操作符是有可能抛出异常的。那么此时我们该运用RAII的思想,增加管理原指针的类:

class CLS_HandlerHolder
{
public:
	explicit CLS_HandlerHolder(new_handler _handler) :
		m_handler(_handler)
	{
	}

	~CLS_HandlerHolder()
	{
		set_new_handler(m_handler);
	}

	CLS_HandlerHolder(const CLS_HandlerHolder&) = delete; 
	CLS_HandlerHolder& operator=(const CLS_HandlerHolder&) = delete;
private:
	new_handler m_handler;
};

此时定制new的实现应该是这样的:

void* CLS_Test::operator new(size_t size) noexcept(false)
{
	CLS_HandlerHolder oldHandler(::set_new_handler(m_curHandler));
	return ::operator new(size);
}

3、使用模板基类实现定制new_handler

为了最大程度的复用这个类,我们可以使用minin风格的基类 — 把这个类作为模板基类供其他类使用。作为基类,是为了让所有的派生类都获得设置new_handler的功能。而且我们也很容易注意到由于CLS_Test所有变量和函数都为静态,所以其用在多继承中也并无任何问题。将其设置为模板,是为了让不同的类使用不同的静态m_handler。否则各基类的new_handler将会冲突。

class CLS_HandlerHolder
{
public:
	explicit CLS_HandlerHolder(new_handler _handler) :
		m_handler(_handler)
	{
	}

	~CLS_HandlerHolder()
	{
		set_new_handler(m_handler);
	}

	CLS_HandlerHolder(const CLS_HandlerHolder&) = delete; 
	CLS_HandlerHolder& operator=(const CLS_HandlerHolder&) = delete;
private:
	new_handler m_handler;
};

template<typename T>
class CLS_HandlerBase
{
public:
	static void* operator new(size_t size) noexcept(false);
	static new_handler set_new_handler(new_handler _handler);
private:
	static new_handler m_curHandler;
};

template<typename T>
new_handler CLS_HandlerBase<T>::m_curHandler = nullptr;

template<typename T>
new_handler CLS_HandlerBase<T>::set_new_handler(new_handler _handler)
{
	new_handler oldHandler = m_curHandler;
	m_curHandler = _handler;
	return oldHandler;
}

template<typename T>
void* CLS_HandlerBase<T>::operator new(size_t size) noexcept(false)
{
	CLS_HandlerHolder oldHandler(::set_new_handler(m_curHandler));
	return ::operator new(size);
}

其使用方式大致如下:

class CLS_HandlerTest : public CLS_HandlerBase<CLS_HandlerTest> 
{
	...
};

void myHandler()
{
	cout << "cannot allocate enough space" << endl;
	abort();
}

int main()
{
	CLS_HandlerTest::set_new_handler(myHandler);
	CLS_HandlerTest* pHandlerTest = new CLS_HandlerTest;
	string* pStr = new string;
	CLS_HandlerTest::set_new_handler(nullptr);
	CLS_HandlerTest* pHandlerTest2 = new CLS_HandlerTest;
	return 0;
}

这里有个很有趣的点:我们从来没用使用模板类的类型参数,并且我们实例化模板类的模板参数正是派生类。这正是因为我们前面所说,我们只是希望每个派生类都拥有独立的基类实例。

4、nothrow new

全局的new提供一个nothrow版本。然而其对异常的保证性并不高。因为new的过程中发生了两件事:分配空间(new操作符执行)和调用构造函数(编译器生成调用)。而new的异常保证仅针对空间分配过程而不包括对象的构造过程。因此实际上我们并没有使用nothrow new的必要。

条款50 — 了解 new 和 delete 的合理替换时机

1、替换 new 和 delete 的常用理由

用来检测和记录运用上的错误 — 有时我们会尝试delete栈上的内存或是尝试多次delete同一段内存。如果我们使用定制版的空间分配和释放函数,便可以维护一个new所得指针的列表并在delete时移除指针。除此之外,由于内存完全由我们自行管理,因此我们可能会越过某个指针的边界,覆盖其初始或结束部分的某些数据。如果我们自定义new操作符,我们可以多申请一段内存用于保存指针签名。在delete的时候我们通过检查签名是否完整决定是否销毁该内存或是记录错误。
为了强化效能 — 编译器所提供的new操作符是一种泛化的空间分配操作符。其必须处理一系列需求,既要支持大块内存也要支持小块内存;必须接纳各种分配形态,从程序存活期间的少量区块动态分配,到大数量短命对象的持续分配和归还;它们还必需考虑内存碎片化问题。这些都是垃圾收集机制所必须考虑的方向。然而,正如其他语言所示,这种空间分配和垃圾收集很难同时满足所有需求。我们定制newdelete可以满足我们在特定方向的需求,包括更快的速度,更高的空间利用率等。
为了收集使用上的统计数据 — 通过在newdelete中进行记录,我们可以知道我们的软件倾向于分配什么样的内存,其分布是怎么样的,它们的生存周期都是多长等等。

2、定制 new 中的对齐问题

观念上,写一个定制型的new操作符非常简单。例如,编写一个增加指针签名的new操作符:

#include <iostream>
using namespace std;

static const int signature = 0XCAFEBABE;
void* operator new(size_t size)
{
	size_t sizeReal = size + 2 * sizeof(int);
	void* pMem = malloc(sizeReal);
	if (!pMem)
	{
		throw bad_alloc();
	}

	*(static_cast<int*>(pMem)) = signature;
	*(reinterpret_cast<int*>(static_cast<char*>(pMem) + sizeReal - sizeof(int))) = signature;

	return static_cast<char*>(pMem) + sizeof(int);
}

这里我们先不考虑对new_handler的调用。这里有一个更微妙地问题 —— 对齐。许多计算机体系结构要求特定的类型必须存放在特定的内存地址上。例如它可能要求指针的地址必须是4的倍数或double的地址必须是8的倍数。如果不满足该条件,可能会导致运行时异常。即使对于不出现异常的硬件体系,它们也宣称对齐条件满足时,其执行效率将会更高。
C++要求所有new操作符返回的指针都有适当的对齐。malloc正是在这样的要求下工作。所以领new操作符直接返回malloc分配的内存是安全的(这正是全局new的实现)。然而,我们这里并没有直接返回该内存,而是增加了一段偏移。因此,我们无法保证该指针是可用的或者高效的。我在我的机器上做了以下测试:

#include <iostream>
using namespace std;

static const int signature = 0XCAFEBABE;
void* operator new(size_t size)
{
	size_t sizeReal = size + 2 * sizeof(int);
	void* pMem = malloc(sizeReal);
	if (!pMem)
	{
		throw bad_alloc();
	}

	*(static_cast<int*>(pMem)) = signature;
	*(reinterpret_cast<int*>(static_cast<char*>(pMem) + sizeReal - sizeof(int))) = signature;

	return static_cast<char*>(pMem) + sizeof(int);
}

void __CRTDECL operator delete(void* const block, size_t const)
{
	free(static_cast<char*>(block) - sizeof(int));
}

int main()
{
	time_t tStart = clock();
	for (int i = 0; i < 1000000000; i++)
	{
		double* pD = new double;
		*pD = 1.0;
		delete pD;
	}
	time_t tEnd = clock();
	cout << "time = " << (tEnd - tStart) << endl;
	return 0;
}

在这里插入图片描述
去掉定制的newdelete后:
在这里插入图片描述
当然,像齐位这类技术细节,我们没必要非得自己去实现。一方面,许多编译器提供了调试状态和日志状态的内存管理函数。许多平台上也提供了商业产品可以替代编译器提供的内存管理;另一方面,我们可以选择开放的源码。Boost程序库中就提供了Pool分配器用于分配大量小型对象。

3、其余使用定制 new 和 delete 的时机

为了增加分配和归还的速度 — 例如,你写的是单线程程序,而你的编译器所提供的内存管理器却是线程安全的。我们可以写个不具备线程安全的内存管理器以改善内存速度。
为了降低缺省内存管理器带来的空间额外开销
为了弥补缺省分配器中的非最佳齐位 — 编译器自带的new不保证对double的8位对齐 ,而定制版可以实现此功能。
为了将相关对象集中 — 如果你知道某个数据结构往往被一起使用,而你又希望在处理这些数据时将“内存页错误”的频率降至最低,那么为此数据结构创建一个单独的堆就很有意义。这样他们可以被保存在尽可能少的内存页上,其访问速度也可以得到提升。
为了获得非传统的行为

条款51 — 编写 new 和 delete 时需固守常规

1、new 规则1 — 内存不足时必须得调用 new-handling 函数

在内存不足时,new操作符需要不断调用new-handling函数直到该函数返回空或抛出异常。

2、new 规则2 — 处理零内存申请

C++规定,即使客户申请的内存大小为0bytes,new操作符也得返回一个合法的指针。这种行为是为了简化语言其它部分的处理。gcc的实现如下:

_GLIBCXX_WEAK_DEFINITION void *
operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc)
{
  void *p;

  /* malloc (0) is unpredictable; avoid it.  */
  if (sz == 0)
    sz = 1;

  while (__builtin_expect ((p = malloc (sz)) == 0, false))
    {
      new_handler handler = std::get_new_handler ();
      if (! handler)
	_GLIBCXX_THROW_OR_ABORT(bad_alloc());
      handler ();
    }

  return p;
}

这里我们也可以看出规则1和前面所学的new_handler必须做的。如果我们不那样做,该函数将陷入无限循环。
如果我们查看msvc源码,会发现其对零内存申请并没有做处理。

_CRT_SECURITYCRITICAL_ATTRIBUTE
void* __CRTDECL operator new(size_t const size)
{
    for (;;)
    {
        if (void* const block = malloc(size))
        {
            return block;
        }
		...
    }
}

那么什么时候会发生零内存申请呢?当我们尝试创建一个长度为0的数组时。按照gcc的注释,这种变0为1的行为是为了防止 malloc(0) 的不确定行为。然而为什么 msvc 没有这种实现我确实不理解。也许和二者底层malloc的实现有关系。如果有朋友知道请帮忙指出。

3、new 规则3 — 避免遮掩正常的形式的new

某特定作用域重写的new操作符是有可能遮掩全局new操作符的。其原因正如我们前面所讨论过的,派生类的函数将遮掩基类的同名函数。条款50中提到,写出定制内存管理的一个常见理由是针对特定大小的对象分配做优化,却不是为了该类的任何派生类。然而一旦该操作符被继承下去,编译器将会调用此操作符重载分配派生类的空间,而非全局new操作符!
为了解决这个问题,我们必须让派生类的内存分配能够直接调用全局操作符。最直接的办法是在定制new中直接判断对象大小:

#include <iostream>
using namespace std;

class CLS_Base
{
public:
	void* operator new(size_t size) noexcept(false)
	{
		if (size != sizeof(CLS_Base))
		{
			::operator new(size);
		}
	}
};

class CLS_Derived : public CLS_Base{};

这里我们没有处理size == 0的情况下。那是因为 sizeof(CLS_Base) 一定不为0。所以该问题将被移交给全局new操作符。

4、new[] 规则 — 分配未加工内存

如果我们尝试针对operator new[]进行定制化。那是很困难的。一方面,我们无法根据size确定到底想要构建的是基类对象数组还是派生类对象数组;另一方面,size可能会比我们想要构建的内存数量更多,因为要保存数组长度等信息。

5、delete 规则 — 删除 null 指针永远安全

C中的free函数已经保证了这点,因此C++中的delete直接调用的free函数实现此规则。对于定制版delete的处理办法同new,如果想要delete的内存空间大小不等于当前对象大小,则直接调用全局delete函数。这里还有个值得我们注意的点:当我们使用释放基类指针指向的派生类对象时,如果析构函数不为虚函数,则将无法得到正确的空间释放大小:

#include <iostream>
using namespace std;

class CLS_Base
{
	int m_iMem;
public:
	void* operator new(size_t const size) noexcept(false)
	{	
		void* ptr = ::operator new(size);
		cout << "new ptr = " << ptr << " size = " << size << endl;
		return ptr;
	}

	void operator delete(void* const block, size_t const size) noexcept(false)
	{
		cout << "delete ptr = " << block << " size = " << size << endl;
		::operator delete(block);
	}
};

class CLS_Derived : public CLS_Base
{
	int m_iMem;
};

int main()
{
	CLS_Base* pBase = new CLS_Derived;
	delete (pBase);
	CLS_Derived* pDerived = new CLS_Derived;
	delete (pDerived);
}

在这里插入图片描述

条款52 — 写了 placement new 也要写 placement delete

定位new操作符和定位delete操作符。我们在《C++ Primer Plus》曾简要学过它们的使用。

1、定位new操作符

一个狭义上的定位new操作符为:

void* operator new(std::size_t size, void* pMemory)'

然而,定位new操作符实际上并不限制参数的类型和个数。因此我们可以任意重载该操作符以在指定空间,指定对象等条件下分配内存。
定位操作符的一个典型使用场景为与Allocator共同使用,以进行连续内存空间的分配。

2、成对 new 和 delete

当我们尝试去new一个对象时,如果在构造过程中发生异常,操作系统有责任调用delete重置分配的空间。然而,这是有前提的,即参数匹配的new和delete是成对出现的。因此下面代码将招致内存泄漏:

#include <iostream>
using namespace std;

class CLS_Test
{
public:
	CLS_Test()
	{
		throw runtime_error("test error");
	}

	void* operator new(size_t const size, void* pMemory) noexcept(false)
	{	
		void* ptr = ::operator new(size);
		cout << "new ptr = " << ptr << " size = " << size << endl;
		return ptr;
	}

	void operator delete(void* const block, size_t const size) noexcept(false)
	{
		cout << "delete ptr = " << block << " size = " << size << endl;
		::operator delete(block);
	}
};

int main()
{
	try
	{
		CLS_Test* pTest = new(nullptr) CLS_Test;
	}
	catch (exception &e)
	{
		cout << e.what() << endl;
	}
}

在这里插入图片描述
虽然我们捕获了异常,但是操作系统并没有找到与new相对应的delete操作符。这将导致内存溢出。因此我们可以这样实现delete操作符:

void operator delete(void* const block, void* pMemory) noexcept(false)
{
	...
}

在这里插入图片描述

3、定位new分配与普通 delete

如果我们在构造过程中没有发生任何异常,那么当内存的使用结束时,我们就需要回收该内存。那么此时我们面临一个问题:使用哪个delete进行内存回收?按照手册上所说,我们该将调用普通的delete函数,也就是和全局delete操作符参数一致的版本。问题是根据名称遮掩规则,我们现在已经看不到它了。因此,我们还需实现重载的普通delete操作符。除了为了让delete时能够找到对应的操作符重载,一个更好的理由去重载它的理由也许是定位new分配的内存通常不必手动删除。

4、定位操作符与名称遮掩

定位newdelete将遮掩其全局版本,这是显而易见的。我们可以使用继承以提供更好的解决:

class CLS_StandardNewDeleteForms
{
public:
	// normal new and delete
	static void* operator new(size_t const size) noexcept(false);
	static void operator delete(void* const block, size_t const size) noexcept(false);

	// placement new and delete
	static void* operator new(size_t const size, void* pMemory) noexcept(false);
	static void operator delete(void* const block, size_t const size) noexcept(false);
	
	// noexcept new and delete
	static void* operator new(size_t const size) noexcept(true);
	static void operator delete(void* const block, size_t const size) noexcept(true);
};

class CLS_Test : public CLS_StandardNewDeleteForms
{
public:
	using CLS_StandardNewDeleteForms::operator new;
	using CLS_StandardNewDeleteForms::operator delete;
};

至于为什么不直接引入全局的newdelete。那是因为using声明用于类作用域时,其所声明的嵌套名称只能用于声明基类成员。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值