MyTinySTL学习之内存分配器:allocator.h(二)

688 阅读10分钟

MyTinySTL学习之内存分配器:allocator.h(二)源码阅读

引言

  • 在allocator.h(一)中,我们知道了allocator是什么(一种two-stage内存模型),allocator在STL中的作用(容器背后的无名英雄,当我们使用容器申请内存时,内部就是STL帮我们合理的调用allocator),在知道了allocator的一些基本原理后,在这一节我们就来进行allocator.h的源码阅读。
  • 本系列博客所学习的MyTinySTL为Github上基于C++11的开源项目,项目地址为:(github.com/Alinshans/M…) 这个项目在Github上已经有6.2k的stars,感谢Alinshans大佬开源这个优质的学习项目。

源码阅读

allocator.h

  • allocator类的实现分为两个部分:①提供STL标准化的类型接口 ②提供4组功能函数接口,下面分别从allocator类的整体定义、STL标准化类型接口和4组功能函数的定义来学习整个头文件。
  1. allocator类的定义

    2-1.png

  • 由上图可见,allocator是一个模板类,在类中不仅实现了上一篇文章提到的用来管理内存的4组接口函数,还使用typedef定义了一堆“奇怪的类型别名”,接下来就分别介绍这两组成员。
  1. STL标准类型接口
  • 为什么要在allocator类中定义这些接口?:STL是由6大部件构成的,其中容器和算法是相互独立的两个部分,它们只关注于自己的实现,而并不了解对方,所以就需要一个组件来成为它们中间的桥梁,而iterator就是这个桥梁。当算法想要通过iterator去操控容器中的元素,就要先知道这个容器中的iterator有哪些性质,然后根据不同的性质,做出最佳选择(补充说明:iterator的associated type分为元素相关、迭代器范围相关和5种迭代器类型,前两种都在allocator中定义,然后由容器通过typedef获取,最后一种则直接由各个容器实现自己的迭代器移动规则,所以在allocator中只需要实现前两种)。--------->此时问题来了,每个容器的实例都有自己的类型,当算法询问时,iterator如何知道当前容器中iterator的这些具体的associated type呢?那只能制定一个共同的接口,让各个类类型将各自的associated type都typedef为这些共同的接口,这样iterator就可以通过这些接口,获取各个实例容器中实际的associated type实参。这样迭代器在被算法询问时,就可以回答算法对当前容器中iterator的assocaited type的提问。又因为一块内存上可存放的元素类型和内存范围是allocator决定的,所以这方面相关的内容由allocator实现,在各个容器的实现中,只需要再用一次typedef用allocator中的标准接口来初始化自己的标准接口即可。而各个类的Iterator class中也都有这些同名的类型接口,容器实例中的别名(此时成为了实例的类型)作为实参传递给Iterator里的别名

  • 关于I::标准别名的补充说明(iterator_traits):细心的你可能发现,并不是所有迭代器都有能力实现I::标准别名的。例如vector的iterator就是C++内置指针,即它是一个non-class iterator,所以它没有能力定义自己的5个associated type,这时候它是如何回答算法的问题的呢?这里提前了解一下iterator_traits。在为一个容器定义Iterator class时,该Iterator class中必须提供5种associated type(定义格式如下),用于回答算法的提问(定义格式如下)。

    以list容器的Iterator class为例:
    
    template<typename _Tp>
    struct iterator{
        typedef std::bidirectional_iterator_tag    iterator_category;//迭代器类型(5种中的哪个)
        typedef _Tp          value_type;//迭代器所指的元素类型(实参为当前实例容器中的元素类型)
        typedef _Tp*         pointer;//元素指针
        typedef _Tp&         reference;//元素引用
        typedef ptrdiff_t    difference;//C++内置的数据类型(一般为long int),用来保存两个指针相减的结果
        ·····
    };
    
    算法对class iterator的associated type的提问方式:I::类型接口名
    
    template<typename I>
    inline void algorithm(I first,I last){
        ·····
        I::iterator_category
        I::pointer
        I::reference
        I::value_type
        I::difference
        ·····        
    }
    
  • 看到这里也许你会觉得好像没有traits啥事,但如果当iterator不是一个类,而是C++内置指针,这时候怎么办呢?很明显没法用I::类型名接口的方式来提问,此时就需要traits来发挥作用!间接访问iterator的类型,访问方式如下。

        算法对non-class iterator的associated type的提问方式:
        template<typename I,...>
        void algorithm{
             typename iterator_traits<I>::value_type v1; 
        }
    
  • iterator_traits在接受I之后,告诉value_type它是什么值,这样就得到了non-class iterator的associated type。iterator_traits的实现和工作原理是模板偏特化,具体源码等看到具体iterator.h时再讨论。

  1. 功能函数接口的实现
  • 3.1 allocate()的定义:

    2-2.png

  • ①从上图可以看出,作者为allocate实现了两个重载版本,如果没有传入实参就分配一个T类型的大小的空间,如果传入参数,就分配n*sizeof(T)大小的内存空间。

  • operator new():可以看出allocate()在内部调用的是operator new(),这是个什么函数呢?看一下gcc的源码。 2-3.png

  • 从上面的源码可以看到,operator new()接受一个实参sz(表示需要分配的字节数)后,首先对sz进行一个是否为0的判断,如果sz为0就将其置1,因为malloc(0)是不可预期的,必须避免,然后在while循环中调用malloc(sz)申请内存,直到申请成功或抛出异常或ABORT。所以operator new()的功能就是调用malloc()分配内存,然后返回一个指向该内存首地址的void型指针

    • 补充说明:①_builtin_expect(express,res)函数就是告诉编译器express最可能的取值是res,这样将”分支转义信息告诉编译器,编译器可以对代码进行优化“。②handler,当malloc()分配失败时就会进入while执行new_handler函数,来提示分配错误的信息。
  • static_cast<target>(source)函数:C++提供的类型转换函数,static_cast用于进行低风险的类型转换(eg.低精度int到高精度double),即如果你想要执行的转换是高风险的(eg.高进度double到低精度int、整型和指针间的相互转换),它就会直接编译错误,降低类型转换带来的风险。这样就可以将void类型的指针转换为T*类型返回

  • 所以allocate()的功能就是调用malloc()分配内存,然后将malloc()返回的void*指针强制转换为T*指针返回。

  • 3.2 deallocate()的定义2-4.png

  • 从上图可以看出,deallocate()比较简单,其实就是判断指针非空后调用operator delete(),所以去看一下其在gcc中的定义。 2-5.png

  • 从上图可以看出,operator delete()就是在调用free()。 所以deallocate()的功能就是,接受一个T*指针,如果非空就调用free()释放指针所指的这块内存(这块内存的长度在malloc时,由cookie存储,所以释放时不再需要传入长度)。

  • 3.3 construct()的定义 2-6.png

  • 由上图可知,作者对construct()进行了一层封装,construct()具体的实现在construct.h中,我们来看一下该头文件中的具体实现。 2-7.png

  • 由construct()的实现,我们可以看出:①construct()至少接收一个参数(指针Ty),Ty指向allocate()分配的可存放Ty类型对象的一块原始内存的首地址。②第二参数即可以传入构造函数实参,也可以不传,对应的会调用T类对象不同的ctor在Ty所指向的内存上构造对象。③construct()其实就是在调用operator new()这个函数,而根据其参数中含有指针可以知道,调用的是其重载版本placement new(),源码如下。 2-8.png

  • 由上图可知,placement new()只是简单的返回了这个实参指针,那么它有什么用呢?事实上它可以通过以下方式在实参指针所指的内存上,调用Ty类对象的构造函数,在上面生成对象。关于placement new()的解释和使用方法可见cppreference,如下图:
    2-9.png

  • 从reference的解释中我们可以看到,placement new()仅仅是简单地、原封不动的返回它的第二参数(指向一块未初始化内存空间首地址的指针),而这个指针正是用来在allocate()得到的内存上construct object。那具体怎么在这块内存上构造对象呢?那就是在new(placement)后面接上要构造对象所属类的构造函数,其中placement就是这个指针。所以作者所实现的三种construct(),都是在间接调用同样的placement new(),只不过3个重载形式,分别调用Ty类对象的默认构造函数、含参构造函数和移动构造(减少不必要的深拷贝)。

    • 【NOTE1】其实,在看完operator new()的源码后,我们可以发现直接new一个对象的效果相当于调用operator new()的两个重载函数,那什么场景下,需要先operator new(size)一块内存,再placement new(p)在上面构造对象呢? 那就是当需要频繁构造和析构对象时,为了避免重复的内存分配和释放,我们可以使用这种方法,一个很常见的应用场景就是内存池的实现。
    • 【NOTE2】使用placement new()在原始内存上构造对象时,我们可以发现,其实每调用一次函数,只调用了一次构造函数,所以在构造对象时,需要一对迭代器来记录当前已经使用的内存块区间。
  • 3.4 destory()的定义 2-10.png

  • 由上图可知,allocator类中的destory函数是对mystl命名空间下destroy()函数的调用。并且有两种情况:①析构一个对象 ②析构迭代器范围内的所有对象。接下来我们去construct.h中看一看mystl::destroy()的具体定义。 2-11.png

  • 从上图可以看出,destroy的两个重载函数分别调用destroy_one()函数和destroy_cat()函数,分别用来析构单个对象/迭代器范围内的所有对象。在这两个函数中,我们发现都有一个type_traist:std::is_trivially_destructible来作其实参,这个type_traits用来询问当前类T的析构函数是否是微不足道的,如果是就返回true,如果不是就返回false。那么什么叫微不足道的呢?你可以理解成如果执不执行都一样的,那就是微不足道的,否则就是重要的。所以这个type_traits放在这里就用来询问当前class是否有自定义的析构函数,如果有就返回false,否则返回true。这样我们就知道了这两个函数的最后一个实参是true/false。接下来我们看这两个函数的具体实现。 2-12.png

  • 由上图我们可以发现,这两个函数分别由最后一个参数进行了重载,显而易见,这样做的目的就是:如果要析构的类没有自定义的析构函数,那就不执行它的默认析构。如果要析构的类有自定义的析构函数,就执行它的析构函数,来析构对象。由源码我们可以发现,destroy()其实就是通过指向(有自定义析构函数的)对象的指针,来调用该对象的析构函数,从而达到析构该对象的目的。

  • 总结:至此,MyTinySTL中关于空间分配器allocator的源码就分析完毕了,通过源码的分析,就能够很轻易的理解为什么new不够灵活,以及为什么allocator可以避免operator new()和placement new()合为整体执行带来的浪费(这种浪费在对象需要频繁构造析构的情况下是非常大的 eg.内存池)

最后:这是一篇记录学习疑惑和想法的帖子,里面的内容、表述并不一定正确,只是自己当下的理解,如有错误,还望提出您宝贵的意见。