Effective C++读书笔记~8 定制new和delete

条款49:了解new-handle的行为

Understand the behavior of the new-handler.

operator new:分配例程;
operator delete:归还例程。
new-handler:operator new无法满足客户的内存需求时所调用的函数。

由于heap是一个可写的全局资源,多线程环境下可能会出现多线程争用情况。因此,多线程环境下,需要适当的同步控制(synchronization),加锁等手段防止并发访问(concurrent access)内存。

operator new,operator delete适合分配单一对象。Array所有的内存需要用operator[] new分配,由operator[] delete归还。

STL容器使用的heap内存是由容器所拥有的分配器对象(allocator object)管理,不是被new和delete直接管理。

new-handler错误处理函数

当operator new无法满足某一内存分配需求时,会抛出异常。以前返回null指针,现在,在抛出异常前,会先调用一个客户指定的错误处理函数,即所谓new-handler。

客户调用set_new_handler指定这个错误处理函数,其原型:

// Effective C++描述
namespace std {
    typedef void (*new_handler)();
    new_handler set_new_handler(new_handler p) throw();
}

// MSVC 2017中的声明
// handler for operator new failures
typedef void (__CLRCALL_PURE_OR_CDECL * new_handler) ();
              // FUNCTION AND OBJECT DECLARATIONS
_CRTIMP2 new_handler __cdecl set_new_handler(_In_opt_ new_handler) noexcept;

new_handler是一个由typedef定义的函数指针类型,没有返回值也不返回任何东西。
set_new_handler 是获得一个new_handler类型参数p并返回一个new_handler函数。尾端throw()是一份异常说明,表明该函数不抛出任何异常。

可以这样使用set_new_handler:

// 当operator new无法分配足够内存时,该函数被调用
void outOfMem()
{
    std::cerr << "Unable to satisfy request for memory" << endl;
    std::abort();
}

// 客户端测试代码
int main()
{
       set_new_handler(outOfMem); //设置 new无法满足客户内存分配申请需求时,调用的错误处理函数
       int **p = new int*[1000];
       for (int i = 0; i < 1000; i++) {
              p[i] = new int[100000000L];
       }
       cout << "return from main" << endl;
       return 0;
}

在24GB内存机器上,报错:"Unable to satisfy request for memory"。

new-handler函数的规范

当operator new无法满足内存申请时,会不断调用new-handler函数,直到找到足够内存。一个设计良好的new-handler函数必须做以下事情:

  • 让更多内存可被使用
    以便operator new的下一次内存分配动作可能成功。一个实现策略:程序一开始执行分配一大块内存:而后当new-handler第一次被调用,就释放以归还给程序用。

  • 安装另一个new-handler
    如果当前new-handler没有取的更多可用内存能力,但知道哪个new-handler有这个能力,可以安装另一个new-handler替换自己(调用set_new_handler即可)。

  • 卸除new-handler
    传递null指针给set_new_handler,operator new内存分配不成功时,不会抛出任何异常。

  • 抛出bad__alloc(或派生自bad_alloc)的异常
    该异常不会被operator new捕捉,因此会被传播到内存申请处。

  • 不返回
    通常调用abort或exit。

new-handler的使用

有时,希望根据不同class以不同的方式处理内存分配失败情况

class X {
public:
    static void outOfMemory();
    ...
};

class Y {
public:
    static void outOfMemory();
    ...
};

X *p1 = new X; // 如果分配不成功,调用X::outOfMemory

Y *p2 = new Y; // 如果分配不成功,调用Y::outOfMemory

C++不支持class专属new-handler,只支持global new-handler。如果需要,就需要自行实现。方法是:令每个class提供自己的set_new_handler和operator new的static函数即可。
例如, class Widget使用operator new分配内存失败时,利用辅助类NewHandlerHolder的析构函数帮助恢复原来的new-handler。

// RAII对象管理new_handler, 对象创建时保存原来的global new_handler到handler, 析构时还原global new_handler
class NewHandlerHolder {
public:
       explicit NewHandlerHolder(std::new_handler nh) : handler(nh) {}
       ~NewHandlerHolder() { std::set_new_handler(handler); }
private:
       std::new_handler handler;
       NewHandlerHolder(const NewHandlerHolder&); // 阻止copying constructor
       NewHandlerHolder& operator=(const NewHandlerHolder&); // 阻止copying  assignment
};

// 假设要处理Widget class内存分配失败情况
class Widget
{
public:
       static std::new_handler set_new_handler(std::new_handler p) throw();
       static void* operator new(std::size_t size) throw(std::bad_alloc);
private:
       static std::new_handler currentHandler; // currentHandler用来保存当前要传入的错误处理函数, 是在对象生成之前就有的, 所以是static
};
std::new_handler Widget::currentHandler = nullptr;

std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
       std::new_handler oldHandler = currentHandler;
       currentHandler = p;
       return oldHandler;
}
void* Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
       // 安装Widget的new-handler, 分配内存或抛出异常就恢复global new-handler
       NewHandlerHolder h(std::set_new_handler(currentHandler)); // 创建局部变量, 退出local作用域时自动析构, i.e. 用户自定义new-handler只有operator new申请分配内存期间有效
       return ::operator new(size);
}

// 客户端测试代码
void outOfMem()
{
       cerr << "out of memory" << endl;
       std::abort();
}
int main()
{
       Widget::set_new_handler(outOfMem);
       // 会导致out of memory
       for (size_t i = 0; i < LLONG_MAX; i++) {
              Widget *pwl = new Widget;
       }
       std::string* ps = new std::string;
       Widget::set_new_handler(0);
       Widget *pw2 = new Widget;
       cout << "return from main" << endl;
       return 0;
}

上面代码巧妙之处,就是利用RAII方式,恢复Widget用operator new申请内存发生时的错误处理函数。
operator new中的临时对象 NewHandlerHolder h会在调用全局operator new之后,自动恢复global new-handler(不论成功与否)。

奇特的循环模板模式 CRTP

上面代码只适用于具体的class,然而每个要这样处理operator new异常的class都会这样写。于是,我们改写成class template形式:

template<typename T>
class NewHandlerSupport {
public:
       static std::new_handler set_new_handler(std::new_handler p) throw();
       static void* operator new(std::size_t size) throw(std::bad_alloc);

       ~NewHandlerSupport() { std::set_new_handler(olderHandler); }
private:
       static std::new_handler currentHandler;
       static std::new_handler olderHandler;
};

template<typename T>
std::new_handler NewHandlerSupport<T>::currentHandler;

template<typename T>
std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw()
{
       olderHandler = currentHandler;
       currentHandler = p;
       return olderHandler;
}

template<typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc)
{
       NewHandlerHolder h(std::set_new_handler(currentHandler));
       return ::operator new(size);
}

class Widget : public NewHandlerSupport<Widget> {
       // 已经拥有了NewHandlerSupport<T> 那部分成员
       // ... 和先前一样, 但不必声明
};

为什么使用template?

我们并没有使用NewHandlerSupport template中的参数T,只是希望继承自NewHandlerSupport的每个class,都拥有不同的NewHandlerSupport复件,明确说,是其static成员currentHandler,参数T只是用来区分不同derived class。Template机制会为每个T生成一份currenHandler。

循环模板模式 CRTP - Do It For Me

Widget继承自一个模板化的templated base class,而后者又以Widget作为类型参数。这种技术被称为 奇特的循环模板模式(curiously recurring template pattern;CRTP)。有了NewHandlerSupport这样的template,为任何class添加一个专属new-handler成为易事。模板化的NewHandlerSupport,更像是为了Widget而存在的专属template,因此另一种理解这种模式为Do It For Me。

1993年以前,旧的operator new在无法分配足够内存时,返回null。新operator new,则应该抛出bad_alloc异常。由于要兼容新规范以前的程序,C++提供另一种形式operator new:负责供应传统的“分配失败便返回null”,称为“nothrow”形式 -- 因为在new的使用场合用了nothrow对象(头文件):

class Widget { ... };
Widget* pw1 = new Widget; // 如果分配失败,抛出bad_alloc
if (pw1 == 0) ... // 该测试一定失败,因为pw1不会为null
Widget* pw2 = new (std::nothrow) Widget; // 如果分配失败,则返回0
if (pw2 == 0) ... // 该测试可能成功

nothrow new对异常强制保证性并不高。表达式“new(std::nothrow) Widget”发生两件事:1)nothrow 版的operator new被调用,用来分配足够内存给Widget对象。2)如果分配失败,返回null;如果分配成功,接下来调用Widget构造函数。nothrow new只能保证operator new不抛出异常,无法保证构造函数的调用不抛出异常

小结

1)set_new_handler允许客户指定一个函数,在内存分配无法获得满足时被调用;
2)Nothrow new是有很多局限的工具,因为它只适用于内存分配,后继的构造函数调用还是可能抛出异常;

[======]

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

Understand when it makes sense to replace new and delete.

为什么会需要替换编译器提供的operator new或operator delete?
常见三个理由:

  • 用来检测运用上的错误。
    如果new所得内存,delete失败(或者没有delete),会导致内存泄漏。
    如果new所得内存,多次调用delete,会导致不确定行为。
    如果程序很可能导致数据“overruns”(写入点在分配区块尾端之后)或“underruns”(写入点在分配区块之前)。
    重写operator new可超额分配内存,提供额外空间用于签名,重写operator delete变可以检查是否有越界操作。如果有,operator delete可以log发生问题的指针。

  • 为了强化效能
    编译器提供的operator new和operator delete主要用于一般目的,但对于特定问题,定制版本修改内存的分配和回收策略,可能更有效。

  • 为了收集使用上的统计数据
    在定制new和delete前,如何得知动态内存的使用情况?比如,分配区块大小分布,FIFO or LIFO or 随机分配和归还?自定义operator new和operator delete能轻松收集到这些信息。

定制operator new示例

例,定制简单operator new,协助检测“overruns”和“underruns”。

static const int signature = 0xDEADBEEF; // 签名
typedef unsigned char Byte;

// 定制operator new
// 这段代码还有若干小错误
void* operator new(std::size_t size) throw(std::bad_alloc)
{
       using namespace std;
       size_t realSize = size + 2 * sizeof(int);
       void *pMem = malloc(realSize);
       if (!pMem) throw bad_alloc();
       
       // 将signature写入内存的最前段落和最后段落
       *(static_cast<int*>(pMem)) = signature;
       *(reinterpret_cast<int*>(static_cast<Byte*>(pMem)
              + realSize - sizeof(int))) = signature;
       // 返回指针,指向第一个signature之后的内存位置
       return static_cast<Byte*>(pMem) + sizeof(int);
}
// 定制配套operator delete
void operator delete(void* p) throw()
{
       void *start = (static_cast<Byte*>(p) - sizeof(int));
       free(start);
}

// 客户端测试
int main()
{
       int* p = new int;
       *p = 1;
       cout << *p << endl;
       delete p;
       return 0;
}

该定制版operator new缺点:
1)没有遵循条款51,未内含一个无穷循环并在其中尝试分配内存,调用new-handler。也没有处理0byte申请。
2)没有考虑对齐问题。
3)还有可移植性,线程安全等问题。

对齐问题

这里,我们主要探讨对齐(alignment):有些计算机体系结构要求特定类型必须放在特定内存地址上。如指针地址必须是4倍数(four-byte aligned)或double地址必须是8倍数(eight-byte aligned)。如果没有遵循这个约束条件,可能导致运行期硬件异常。而有些并没有这么严格要求,对于如double,只要是byte对齐即可,但如果是8byte对齐,则访问速度会快很多。
malloc返回的指针是安全的,但我们在程序里面对其偏移了一个int大小的位置,而int是固定4byte,如果我们申请8byte的double,就可能导致对齐问题。

何时替换new/delete

何时在“全局性的”或者“class专属的”基础上,合理替换缺省的new和delete?

  • 为了检测运用错误;
  • 为了收集动态分配内存之使用统计信息;
  • 为了增加分配和归还的速度
    针对特定类型定制new和delete的速度,往往快于编译器提供的缺省new和delete
  • 为了降低缺省内存管理器带来的空间额外开销
    泛用型内存管理器往往比定制性慢,还使用更多内存,因为它们常常在每个分配区块上招引某些额外开销。针对小型对象开发的分配器(如Boost的Pool程序库)本质上消除了这样的额外开销。
  • 为了弥补缺省分配器中的非最佳对齐
    将不保证对齐的new替换为对齐的版本,可能导致程序效率大幅提升。
  • 为了将相关对象成簇集中
    如果指定某个数据结构往往一起使用,而你有希望处理这些数据时,将“内存页错误”(page fault)的频率降至最低,那么为此数据结构创建另一个heap就有意义,这样它们就可以被成簇集中在尽可能少的内存页(page)上。见条款52。
  • 为了获得非传统的行为
    有时希望operator new和delete做编译器提供的缺省版本没做的事情,如将C API封装成C++ API,将归还内存覆盖为0。

小结

1)有许多理由写个自定义的new和delete,包括改善效能、对heap运用错误进行调试、收集heap使用信息。

[======]

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

Adhere to conversion when writing new and delete.

条款50解释何时需要编写自己的operator new和operator delete。但如果定制自己的new和delete,应当遵守什么规则呢?

自定义new需要遵循的规则

1)内存不足时,必调用new-handler函数,必须有对付零内存需求的准备,需避免不慎掩盖正常形式的new(接口要求)。

2)operator new的返回值十分单纯。如果有能力提供客户申请的内存,就返回一个指针指向那块内存;如果没有能力,就遵循条款49,抛出bad_alloc异常。不过,operator new实际上不止一次尝试分配内存,并在每次失败后调用new-handling函数。这里假设new-handling函数也许能做某些动作,将某些内存释放。只有当指向new-handling函数的指针是null,operator new才会抛出异常。

3)处理零内存申请:即使客户要求0byte,operator new也得返回一个合法指针。这个看似诡异的行为,是为了简化语言的其他部分。

例,适用于单线程

void *operator new(std::size_t size) throw(std::bad_alloc)
{
       using namespace std;
       if (size == 0) { // 处理0byte申请
              size = 1; // 将其视为1byte申请
       }
       while (true) {
              尝试分配size bytes;
              if (分配成功)
                     return  (一个指针,指向分配得来的内存);

              // 分配失败:找出目前的new-handling函数
              new_handler globalHandler = set_new_handler(0);
              set_new_handler(globalHandler);

              if (globalHandler) (*globalHandler)();
              else throw std::bad_alloc();
       }
}

当上述operator new作为一个class的专属operator new时,存在一个问题:class作为Base可能会被继承,而针对class Base设计的operator new可能只刚好只为sizeof(Base)大小对象而设计,对于继承自Base的Derived,其对象大小很可能大于Base对象大小,这样就会导致“内存申请量错误”的问题。

class Base {
public:
       static void* operator new(std::size_t size) throw(std::bad_alloc);
       ...
};
class Derived: public Base { ... }; //假设Derived未重写operator new

// 客户端
Derived *p = new Derived; // 这里调用Base::operator new

客户端调用operator new时,传入的是Derived对象大小sizeof(Derived),而Base::operator new中考虑申请对象大小是sizeof(Base),该参数由编译器自动生成并传入,Base对象大小通常大于Derived对象大小,这样就产生了问题。

解决办法:

void* Base::operator new(std::size_t size) throw(std::bad_alloc)
{
     if (size != sizeof(Base)) //如果大小错误, 就令标准的operator new处理
          return ::operator new(size);
     ... // 否则,在这里处理
}

撰写operator new时,不能保证要申请元素大小一定是当前class对象大小。传递给operator newp[]的大小,也不一定是每个元素大小 * 元素个数,因为可能还包含其他信息,如元素个数。

自定义delete需要遵循的规则

撰写operator delete时,需要记住:C++保证“删除null指针永远安全”。
1)non-member operator delete伪码(pseudocode)

void operator delete(void *rawMemory) throw()
{
     if (rawMemory == 0) return; // 如果被删除的是null指针,那就什么都不做
     现在,归还rawMemory所指内存;
}

2)member operator delete伪码

class Base {
public:
       static void* operator new(std::size_t size) throw(std::bad_alloc);
       static void operator delete(void* rawMemory, std::size_t size) throw();
       ...
};

void Base::operator delete(void* rawMemory, std::size_t size) throw()
{
       if (rawMemory == 0) return; // 如果被删除的是null指针,那就什么都不做
       if (size != sizeof(Base)) {
              ::operator delete(rawMemory);
              return;
       }
       现在,归还rawMemory所指内存;
       return;
}

需要注意的是:如果即将被删除的对象派生自某个base class,而后者欠缺virtual析构函数,那么C++传给operator delete的size_t数值可能不正确。

小结

1)operator new应该内含一个无穷循环,并在其中尝试分配内存。无关无法满足内存需求,就应该调用new-handler(错误处理)。应该有能力处理0byte申请。Class专属版本还应该处理“比正确大小更大的(错误)申请”。
2)operator delete应该在收到null指针时,不做任何处理。Class专属版本应该处理“比正确大小更大的(错误)申请”。

[======]

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

Write placement delete if you write placement new.

什么是placement new,placement delete?

默认情况下,我们使用operator new给对象分配存储空间并调用其构造函数

void* operator new(std::size_t) throw(std::bad_alloc); // 缺省new

// 客户端 对应调用方式
Widget* pw = new Widget;
// delete 对应正常的operator delete
void operator delete(void* )

客户端调用了2个函数:1)operator new分配内存,2)Widget的default构造函数。
假设第一个函数(operator new分配内存)调用成功,第二个函数(default构造函数)调用失败。那么第一步申请分配的内存就必须释放并恢复到旧的状态,否则会造成内存泄漏(memory leak)。但此时,客户还没用能力归还内存,因为Widget构造函数抛出了异常,也就是说,pw指针并没有被赋值,客户也就没有指向这块内存的指针。因此,归还内存就成了运行期系统的责任。

典型的正常形式operator new和delete:

void* operator new(std::size_t) throw(std::bad_alloc); // global和class作用域正常签名式

void operator delete(void* rawMem) throw(); // global作用域的正常签名式
void operator delete(void* rawMem, std::size_t size) throw(); // class作用域的典型签名式

placement new与placement delete

如果operator new接受的参数除了默认size_t外,还有其他参数,那么就称该operator new为placement new。
其中,有个特别的placement new版本,接受一个指针指向对象被构造之处,也就是说,pMem指向一块已经分配得到的内存,调用该placement new可以在指定内存(参数pMem指向的)上创建对象。大多数情况下,人们所指的placement new就是特定版本的operator new(唯一额外参数是void*),少数情况指包含任意额外参数。

与placement new相对地,也存在placement delete。

#include <new>

void* operator new(std::size_t, void* pMem) throw(std::bad_alloc); // placement new
void operator delete(void* pMem, std::size_t size) throw(); // placement delete

// Widget class 声明式
class Widget {
public:
    static void* operator new(std::size_t, std::ostream& logStream) throw(std::bad_alloc); // placement new
    static void operator delete(void* pMem, std::size_t size) throw();  // 正常class的专属delete
    static void operator delete(void *pMem) throw();
    static void operator delete(void* pMem, std::ostream& logStream) throw(); // 与placement new配套的placement delete
};

如果Widget构造函数抛出异常,调用哪个版本operator delete?

Widget构造函数抛出了异常,运行期系统有责任取消operator new的分配并恢复到旧状态,不过,运行期系统无法知道真正被调用的那个operator new如何运作(如在构造函数中又做了哪些事情),因此它无法取消分配、恢复旧状态。取而代之的是,运行期系统寻找“参数个数和类型都与operator new相同的某个operator delete”。如果找到,调用之;如果没有,就不会有任何operator delete被调用。

因此,Widget class的operator new抛出异常时,对应版本placement delete会被自动调用,让Widget有机会确保不泄漏任何内存。

// 客户端
Widget* pw = new (std::cerr) Widget; // 调用Widget::operator new(sizeof(Widget), cerr);
// 出现异常时,运行期系统选择调用operator new配套的operator delete(2者额外参数相同)
void Widget::operator delete(void*, std::ostream&) throw();

// 正常的释放内存操作
delete pw;
// delete pw调用Widget::operator delete(void*, size_t)

注意:对一个指针施行delete绝不会调用placement delete。

placement与名称遮掩问题

1)class专属placement new会遮掩正常的global new

class B
{
public:
       ...
       // 该placement new会遮掩正常形式的global new
       static void* operator new(std::size_t size, std::ostream& logStream)  throw(std::bad_alloc);
}

B* pb = new B; // 错误:正常形式operator new会被遮掩
B* pb = new (std::cerr) B; // OK:调用B::operator new(size_t, ostream&)

2)derived class的专属operator new会这样global new和继承而来的operator new

class C : public B {
public:
       ...
       // 该placement new会遮掩正常形式的global new和从B继承的placement new
       static void* operator new(std::size_t size) throw(std::bad_alloc);
};

C* pc = new(std::clog) C; // 错误:B的placement new被遮掩
C* pc = new C; // OK:调用C::operator new(size_t)

如何解决placement名称遮掩问题?

除非确定就是想遮掩global new和Base class的placement版本,否则,可以使用using,或者在当前class明确定义专属placement new和placement delete。还有一个简便办法,就是建立一个base class,内含所有normal new和delete:

/* 标准形式new/delete */
class StandardNewDeleteForms {
public:
       // normal new/delete
       static void* operator new(std::size_t size) throw(std::bad_alloc)
       {
              return ::operator new(size);
       }
       static void operator delete(void* pMem) throw()
       {
              ::operator delete(pMem);
       }
       // placement new/delete
       static void* operator new(std::size_t size, void* ptr) throw()
       {
              return ::operator new(size, ptr);
       }
       static void operator delete(void* pMem, void* ptr) throw()
       {
              return ::operator delete(pMem, ptr);
       }
       // nothrow new/delete
       static void* operator new(std::size_t size, const std::nothrow_t& nt)  throw()
       {
              return ::operator new(size, nt);
       }
       static void operator delete(void* pMem, const std::nothrow_t) throw()
       {
              ::operator delete(pMem);
       }
};
class MyWidget : public StandardNewDeleteForms { // 继承标准形式
public:
       // 避免基类的new/delete名称被遮掩
       using StandardNewDeleteForms::operator new;
       using StandardNewDeleteForms::operator delete;
       // 添加一个自定义placement new
       static void* operator new(std::size_t size, std::ostream& logStream)  throw(std::bad_alloc);
       // 添加一个自定义placement delete
       static void operator delete(void* pMem, std::ostream& logStream) throw();
       // ...
};

小结

1)当你写一个placement operator new,请确定也写出了对应的placement operator delete。如果没有这样做,程序可能会发生隐蔽的内存泄漏问题;
2)当你声明placement new和placement delete,请确定不要无意识(非故意)地遮掩了它们的正常版本。

[======]

posted @ 2021-12-13 10:43  明明1109  阅读(192)  评论(0编辑  收藏  举报