• 深入探索C++对象模型(六)


    执行期语意学(Runtime Semantics)

    对象的构造和析构(Object Constructor and Destructor)

    一般而言,constructor和destructor的安插都如你所预期:

    {
        Point point;
        //point.Point::Point()  一般而言会被安插在这里
        ...
        //point.Point::~Point() 一般而言会被安插在这里
    }
    

    如果一个区段(译注:以{}括起来的区域)或函数中有一个以上的离开点,情况会稍微混乱一些。Destructor必须被放在每一个离开点(当时object还存活)之前。例如:

    {
        Point point;
        //constructor在这里行动
        switch(int(point.x())){
            case -1 : 
                //mumble;
                //destructor在这里行动
                return;
            case 0:
                //mumble
                //destructor在这里行动
                return;
            default:
                //mumble
                //destructor在这里行动
                return;
        }
        //destructor在这里行动
    }
    

    一般而言我们会把object尽可能放置在使用它的那个程序区段附近,这么做可以节省非必要的对象产生操作和摧毁操作。

    全局对象(Global Objects)

    如果我们有以下程序片段:

    Matrix identity;
    
    main(){
        //identity必须在此处被初始化
        Matrix m1 = identity;
        ...
        return 0;
    }
    

    C++ 保证,一定会在main()函数中第一次用到identity之前,把identity构造出来,而在main()函数结束之前把identity摧毁掉。像identity这样的所谓global object如果有constructor和destructor的话,我们说它需要静态的初始化操作和内存释放操作。

    C++程序中所有的global objects都被放置在程序的data segment中。如果显式指定给它一个值,此object将以该值为初值。否则object配置到的内存内容为0。如下:

    int v1 = 1024;
    int v2;
    

    v1和v2都被配置于程序的data segment,v1值为1024,v2值为0(这和C略有不同,C并不设定初值)。在C语言中一个global object只能够被一个常量表达式(可在编译期求其值的那种)设定初值。当然,constructor并不是常量表达式。虽然class object在编译时期可以被放置于data segment中并且内容为0,但constructor一直要到程序启动(startup)时才会实施。必须对一个“放置于program data segment中的object的初始化表达式”做评估(evaluate),这正是为什么一个object需要静态初始化的原因。

    当cfront还是唯一的C++编译器,而且跨平台移植性比效率的考虑更重要的时候,有一个可移植但成本颇高的静态初始化(以及内存释放)方法,我把它成为munch。其策略如下:

    1. 为每一个需要静态初始化的档案产生一个_ sti()函数,内带必要的constructor调用操作或inline expansions。例如前面所说的identity对象会在matrix.c中产生出下面的_sti()函数(sti就是static initialization的缩写):
      _sti_matrix_c_identity(){
          identity.Matrix::Matrix();  
          initialization;
      }
      
    2. 类似情况,在每一个需要静态的内存释放操作(static deallocation)的文件,产生一个_std()函数(static deallocation),内带必要的destructor操作,或是其inline expansions。在我们的例子中会有一个_ std()函数被产生出来,针对identity对象调用Matrix destructor。
    3. 提供一组runtime library "munch"函数:一个_main()函数(用以调用可执行文件中的所有 _sti()函数),以及一个exit(函数)(以类似方法调用所有的 _std()函数)。

    以一个derived class的pointer或reference来存取virtual base class subobject,是一种nonconstant expression,必须在执行期才能加以评估求值。例如,尽管下列程序片段在编译时期可知:

    //constant expression
    Vertex3d *pv = new PVertex;
    Point3d *p3d = pv;
    

    其virtual base class Point的subobject在每一个derived class中的位置却可能会变动,因此不能够在编译时期设定下来,下面的初始化操作:

    //Point是Point3d的一个virtual base class
    //pt的初始化操作需要某种形式的执行期评估(runtime evaluation)
    Point *pt = p3d;
    

    需要编译器提供内部扩充,以支持class object的静态初始化(至少涵盖class objects的指针和references),例如:

    //Initial support of virtual base class conversion requires
    //non-constant initialization support
    Point *pt = p3d->vbcPoint;
    

    使用被静态初始化的objects有一些缺点,例如,如果exception handling被支持,那些objects将不能够被放置于try区段之内。这对于被静态调用的constructors可能是特别无法接受的,因为任何的throw操作将必然触发exception handling library默认的terminate()函数。另一个缺点是为了控制"需要跨越模块做静态初始化"objects的相依顺序而扯出来的复杂度。

    局部静态对象(Local Static Objects)

    假设有如下程序片段:

    const Matrix& identity(){
        static Matrix mat_identity;
        //...
        return mat_identity;
    }
    

    Local static class objects保证了什么样的语意?

    • mat_identity的constructor必须只能施行一次,虽然上述函数可能会被调用多次。
    • mat_identity的destructor必须只能施行一次,虽然上述函数可能会被调用多次。

    编译器的策略之一就是,无条件地在程序起始(startup)时构造出对象来。然而这会导致所有的local static class objects都在程序起始时被初始化,即使它们所在的那个函数从不曾被调用过。因此,只在identity()函数被调用时才把mat_identity构造起来,是比较好的做法(现在的C++ Standard已经强制要求这一点)。那我们应该怎么做呢?

    cfront之中的做法是:首先,导入一个临时性对象以保护mat_identity的初始化操作。第一次处理identity时,这个临时性对象被评估为false,于是constructor会被调用,然后临时性对象被改写为true。这样就解决了构造的问题。而在相反的一端,destructor也需要有条件施行于 mat_identity是否被构造起来,很简单,如果那个临时性对象为true,就表示构造好了。困难的是,由于cfront产生C码,mat_identity对函数而言仍然是local,因此我没办法在静态的内存释放函数(static deallocation function)中存取它。解决办法是:取出local object的地址。(当然,由于object是static,其地址再downstream compoent中被转换到程序内用来放置global object的data segment中)。下面是cfront的输出:

    //被产生出来的临时对象,作为戒护之用
    static struct  Matrix *_0_F3 = 0;
    
    //C++的reference在C中是以pointer来代替identity()的名称会被mangled
    struct Matrix* identity_Fv(){
        static struct Matrix _1mat_identity;
        
        //如果临时性的保护对象已被设立,那就什么也别做,否则
        //调用constructor: _ct_6MatrixFv
        //设定保护对象,使它指向目标对象
        _0_F3 
         ? 0 : (_ct_6MatrixFv(& _1mat_identity), 
                (_0_F3 = (&_lmat_identity)));
        ...
    }
    

    最后,destructor必须在"与text program file(也就是本例中的stat_0.c)有关了的静态内存释放函数(static deallocation function)"中被有条件的调用:

    char _std_stat_0_c(){
        _0_F3 ? _dt_6MatrixFv(_0_F3, 2) : 0 ;
        ...
    }
    

    请记住,指针的使用时cfront所特有的;然而条件式解构则是所有编译器都需要的。

    对象数组(Array of Objects)

    假设有以下的数组定义:

    Point knots[10];
    

    需要完成什么东西呢? 如果Point既没有定义一个constructor也没有定义一个destructor,那么我们的工作不会比建立一个"内建(build-in)类型所组成的数组"更多,也就是说,我们只需配置足够的内存以存储10个连续的Point元素。

    然而Point的确定义了一个default destructor,所以这个destructor必须轮流施行于每一个元素之上。一般而言这是经由一个或多个runtime library函数达成。在cfront中,我们使用一个被命名为vec_new()函数,产生出以class objects构造而成的数组。比较晚近的编译器,包括Borland/Microsoft/Sun,则是提供两个函数,一个用以处理“没有virtual base class”的class,另一个用来处理“内带virtual base class”的class。后一个函数通常被称为vec_vnew()。函数类型通常如下:

    void* vec_new(
        void* array,    //数组起始地址
        size_t elem_size,  //每一个class object的大小
        int elem_count,    //数组中的元素数目
        void (*constructor)(void*) ,
        void (*destructor)(void*, char)
    )
    

    其中的constructor和destructor参数是这个class的default constructor和default destructor的函数指针。参数array带有的若不是具名数组(本例为knots)的地址,就是0。如果是0,那么数组将经由应用程序的new运算符,被动态配置于heap中。

    在vec_new()中,constructor施行于elem_count个元素之上。对于支持exception handling的编译器而言,destructor的提供是必要的。下面是编译器可能针对我们的10个Point元素所做的vec_new()调用操作:

    Point knots[10];
    vec_new(&knots, sizeof(Point), 10, &Point::Point, 0);
    

    如果Point也定义了一个destructor,当knots的生命结束时,该destructor也必须施行于那10个Point元素身上。这也是经由一个类似的vec_delete()(或是vec_vdelete()——如果classes拥有virtual base classes的话)的runtime library函数完成,其函数原型如下:

    void *vec_delete(
        void *array,     //数组起始地址
        size_t elem_size,    //每一个class object的大小
        int elem_count,      //数组中的元素数目
        void (*destructor)(void*, char) 
    )
    

    有些编译器会另外增加一些参数,用以传递其它数值,以便能够有条件地导引vec_delete()逻辑,在vec_delete()中,destructor被施行于elem_count个元素身上。

    如下例子:

    Point knots[10] = {
        Point(),
        Point(1.0, 1.0, 0.5),
        -1.0
    };
    

    对于那些明显获得初值的元素,vec_new()不再有必要。对于那些尚未被初始化的元素,vec_new()的施行方式就像面对“由class elements组成的数组,而该数组没有explicit initialization list”一样,因此上一个定义很可能被转换为:

    Point knots[10];
    
    //明确初始化前3个元素
    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初始化后7个元素
    vec_new(&knots + 3, sizeof(Point), 7, &Point::Point, 0);
    

    new和delete运算符

    运算符new的使用,看起来似乎是个单一运算,像这样:

    int *pi = new int(5);
    

    但事实上它由以下两个步骤完成:

    1. 通过适当的new运算符函数实体,配置所需的内存:
      //调用函数库中的new运算符
      int *pi = _new(sizeof(int));
      
    2. 给配置得来的对象设立初值:
      *pi = 5;
      
      更进一步地,初始化操作应该在内存配置成功(经由new运算符)后才执行:
      //new运算符的两个分离步骤
      //given: int *pi = new int(5)
      
      //重写声明
      int *pi;
      if(pi = _new(sizeof(int)))
          *pi = 5;  //成功了才初始化
      

    delete运算符的情况类似。当程序员写下:

    delete pi;
    

    时,如果pi的值是0,C++ 语言会要求delete运算符不要有操作。因此编译器必须为此调用构造一层保护膜:

    if(pi != 0)
        _delete(pi);
    

    请注意pi并不会因此被自动清除为0。所以后续的pi的任何操作是没有定义的。这是因为对于pi所指向之内存的变更或再使用,可能发生也可能不发生。

    以constructor来配置一个class object,情况类似。例如:

    Point3d *origin = new Point3d;
    

    被转换为:

    Point3d *origin;
    if(origin  = _new(sizeof(Point3d)))
        origin = Point3d::Point3d(origin);
    

    一般的library对于new运算符的实现操作都很直接了当,但有两个精巧之处值得斟酌。

    extern void* operator new(size_t size){
        if(size == 0)
            size = 1;
            
        void *last_alloc;
        while(!(last_alloc = malloc(size))){
            if(_new_handler)
                (*_new_handler)();
            else return 0;
        }
        return last_alloc;
    }
    

    虽然这样写是合法的:

    new T[0];
    

    但语言要求每一次对new的调用都必须传回一个独一无二的指针。解决该问题的传统方法是传回一个指针,指向一个默认为1-byte的内存区块(这就是为什么吧size设为1的原因)。这个实现技术的另一个有趣之处是,它允许使用者提供一个属于自己的_new_handler()函数。这正是为什么每一次循环都调用_new_handler()之故。

    new运算符实际上总是以标准的C malloc()完成,虽然并没有规定一定得这么做不可。相同情况,delete运算符总是以标准的C free()完成。

    extern void operator delete(void *ptr){
        if(ptr)
            free((char*)ptr);
    }
    

    针对数组的new语意

    当我们这样写:

    int *p_array = new int[5];
    

    时,vec_new()不会真正被调用,因为它的主要功能是把default constructor施行于class objects所组成的数组的每一个元素身上。倒是new运算符会被调用:

    int *p_array = (int*)_new(5 * sizeof(int));
    

    相同情况下,如果我们写:

    //struct simple_aggr { float f1, f2; }
    simple_aggr *p_aggr = new simple_aggr[5];
    

    vec_new()也不会被调用,为什么?因为simple_aggr并没有定义一个constructor或destructor,所以配置数组以及清除p_aggr数组的操作,只是单纯地获得内存和释放内存而已,这些操作由new和delete运算符来完成就绰绰有余了。

    然而如果class定义有一个default constructo,某些版本的vec_new()就会被调用,配置并构造class objects所组成的数组。例如这个算式:

    Point3d *p_array = new Point3d[10];
    

    通常会被编译为:

    Point3d *p_array;
    p_array = vec_new(0, sizeof(Point3d), 10, 
                    &Point3d::Point3d, &Point3d::~Point3d);
    

    在个别的数组元素构造过程中,如果发生exception,destructor就会被传递给vec_new(),只有已经构造妥当的元素才需要destructor的施行,因为它们的内存已经被配置出来了,vec_new()有责任在exception发生时把那些内存释放掉。

    寻找数组维度,对于delete运算符的效率带来极大的冲击,所以才导致这样的妥协:只有在中括号出现时,编译器才寻找数组的维度,否则它便假设只有单独一个objects要被删除。如果程序员没有提供必须的中括号,像这样:

    delete p_array;  
    

    那么就只有第一个元素会被解析,其他的元素仍然存在——虽然相关的内存已经被要求归还了。

    Placement Operator new的语意

    有一个预先定义好的重载的(overloaded) new运算符,称为placement operator new,它需要第二个参数,类型为void*,调用方式如下:

    Point2w *ptw = new(arena) Point2w;
    

    其中arena指向内存中的一个区块,用以放置新产生出来的Point2w object。这个预先定义好的placement operator new的实现方法简直是出乎意料的平凡,它只要将“获得的指针”(上例为arena)所指的地址传回即可:

    void *operator new(size_t void *p){
        return p;
    }
    

    当然,以上只是真正所发生的操作的一半而已。另外一半无法由程序员产生出来。想想这些问题:

    1. 什么是使placement new operator能够有效运行的另一半部扩充(而且是"arena的明确指定操作"所没有提供的)
    2. 什么是arena的真正类型?该类型暗示了什么?

    Placement new operator所扩充的另一半边是将Point2w constructor自动实施于arena所指的地址上:

    Point2w *ptw = (Point2w*)arena;
    if(pt2 != 0)
        ptw->Point2w::Point2w();
    

    这正是使placement operator new威力如此强大的原因。这一份码决定objects被配置在哪里;编译系统保证object的constructor会施行于其上。

    另一个问题关系到arena所表现的真正指针类型。C++ Standard说它必须指向相同类型的class,要不就是一块"新鲜"内存,足够容纳该类型的object。注意,derived class很明显并不在被支持之列。对于一个derived class,或是其他没有关联的类型,其行为虽然并非不合法,却也未经定义:

    //新鲜的存储空间可以这样配置而来
    char *arena = new char[sizeof(Point2w)];
    
    //相同类型的object可以这样获得
    Point2w *arena = new Point2w;
    

    不论哪一种情况,新的Point2w的存储空间的确是覆盖了arena的位置,而此行为已在良好控制之下。然而,一般而言,placement new operator并不支持多态(polymorphism)。被交给new的指针,应该适当地指向一块预先配置好的内存。如果derived class比其base class大,例如:

    Point2w *p2w = new(arena) Point3w;
    

    Point3w的constructor将会导致严重的破坏。

    临时性对象(Temporary Objects)

    如果我们有一个函数,形式如下:

    T operator+(const T&, const T&);
    

    以及两个T objects,a和b,那么:

    a + b;
    

    可能会导致一个临时性对象,以放置传回的对象。是否会导致一个临时性对象,视编译器的进取性(aggressiveness)以及上述操作发生时的程序上下关系而定。例如:

    T a, b;
    T c = a + b;
    

    编译器会产生一个临时性对象,放置 a+b的结果,然后再使用T的copy constructor,把该临时性对象当作 c 的初值。然而比较更可能的转换是直接以拷贝构造的方式,将 a+b的值放到c中。于是就不需要临时性对象,以及对其constructor和destructor的调用了。

    临时性对象的被摧毁,应该是对完整表达式(full-expression)求值过程中的最后一个步骤。该完整表达式造成临时对象的产生。

    什么是一个完整表达式(full-expression)?非正式地说,它是被涵括的表达式中最外围的那个。下面这个式子:

    //tertiary full expression with 5 sub-expressions
    ((objA > 1024) && (objB > 1024) ? objA + objB : foo(objA, objB));
    

    一个有五个子算式,内带一个"? : 完整表达式"中。任何一个子表达式所产生的任何一个临时对象,都应该在完整表达式被求值完成后,才可以毁去。

    当临时性对象是根据程序的执行期语意有条件地产生出来时,临时性对象的生命规则就显得有些复杂了。举个例子,像这样的表达式:

    if (s + t || u + v)
    

    其中u+v 子算式只有在s+t 被评估为false时,才会开始被评估。与第二个子算式有关的临时性对象必须被摧毁。但是,很明显地,不可以无条件地摧毁,也就是说,我们希望只有在临时性对象被产生出来的情况下才去催毁它。

    临时性对象的生命规则有两个例外。第一个例外发生在表达式被用来初始化一个object时,例如:

    bool verbase;
    ...
    String progNameVersion = !verbase ? 0 : progName + progVersion;
    

    其中progName和progVersion都是String objects。这时候会生出一个临时对象,放置加法运算符的运算结果:

    String operator+(const String&, const String&);
    

    临时对象必须根据对verbase的测试结果有条件地解构。在临时对象的生命规则之下,它应该在完整表达的"? : 表达式"结束评估之后尽快地被摧毁。然而,如果progNameVersion的初始化需要调用一个copy constructor:

    progNameVersion.String::String(tmp);
    

    那么临时性对象的解构(在"? : 完整表达式"之后)当然就不是我们所期望的。C++ Standard要求说:凡含有表达式执行结果的临时性对象,应该存留到object的初始化操作完成为止。

    临时性对象的生命规则的第二个例外是"当一个临时性对象被一个reference绑定"时,例如:

    const String &space = " ";
    

    产生出这样的程序代码:

    String tmp;
    tmp.String::String(" ");
    const String &space = tmp;
    

    如果一个临时性对象被绑定于一个reference,对象将残留,直到被初始化之reference的生命结束,或直到临时对象的生命范畴(scope)结束——视哪一种情况先到达而定。

  • 相关阅读:
     selenium webdriver test
    V8 初次接触(Qt5) 1+1=2 博客频道 CSDN.NET
    C++11 FAQ中文版
    做技术的,因为年龄和颈椎问题,想逐渐脱离码农状态,大家对3035岁职业规划有什么好的建议? 知乎
    一些idea
    如何来区分是我写的还是我转载的,
    firecurl
    python为什么叫好不叫座
    QTextCodec中的setCodecForTr等终于消失了 (Qt5) 1+1=2 博客频道 CSDN.NET
    Charles Web Debugging Proxy • HTTP Monitor / HTTP Proxy / HTTPS & SSL Proxy / Reverse Proxy
  • 原文地址:https://www.cnblogs.com/lengender-12/p/6991960.html
Copyright © 2020-2023  润新知