• 第6章 执行期语意学


    第6章 执行期语意学

    6.1 对象的构造和析构

    constructor和destructor的安排

    {
    	if (cache)
    		// 检查cache; 如果温和就传回1
    		return 1;
    	Point point;
    	// constructor在这里行动
    	switch(int(point.x())) {
    		case -1:
    		// numble;
    		// destructor在这里行动
    		return;
    		case 0:
    		// numble;
    		// destructor在这里行动
    		return;	
    		case 1:
    		// numble;
    		// destructor在这里行动
    		default:
    		// numble;
    		// destructor在这里行动
    		return;
    	}
    }
    

    另外也很有可能在这个区段的结束符号(右大括号)之前被生出来, 即使程序分析的结构发现绝不会进行到那里, 一般而言会把object尽可能放置在使用它的那个程序区段附近, 这么做可以节省非必要的对象产生操作或摧毁操作

    全局对象

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

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

    C++程序中所有的global objects都被放置在程序的data segment中. 如果显式指定给它一个值, 此object将以该值为初值. 否则object所配置到的内存内容为0(这和C略有不同, C并不自动设定初值). 在C语言中一个global object只能够被一个常量表达式(可在编译时期求其值的那种)设定初值. 当然constructor并不是常量表达式. 虽然class object在编译时期可以被放置于data segment中并且内容为0, 但constructor一直要到程序启动(startup)时才会实施. 必须对一个"放置于program data segment中的object的初始化表达式"做评估(evaluate), 这正是为什么object需要静态初始化的原因.

    我的理解是data segment(包括object)中的值全为0, 只是在main函数执行时, 设定了值(object 执行constructor操作)

    当cfront还是唯一的C++编译器, 而且跨平台移植性比效率的考虑更重要的时候,有一个可移植但成本颇高的静态初始化(以及内存释放)方法, 称为munch策略:
    (1) 为每一个需要静态初始化的文件产生一个_sti()函数, 内含必要的constructor调用操作或是inline expansions. 例如起前面所说的identify对象会在matrix.c中产生出下面的_sti()函数(可能是static initialization的缩写):

    __sti__matrix_c__identity() {
    	// C++伪码
    	identify.Matrix::Matrix; // 这就是static initialization
    }
    

    (2) 在每一个需要静态的内存释放操作的文件中, 产生一个__std()函数(可能是static deallocation的缩写), 内含必要的destructor调用操作, 或是其inline expansions
    (3) 提供一组runtime library "munch"函数: 一个_main()函数(用以调用可执行文件中的所有__sti()函数), 以及一个exit()函数(以类似方式调用所有的_std()函数)

    cfront 2.0版之前并不支持nonclass object的静态初始化操作; 也就是说C语言的限制仍然残留着. 所以下面的每一个初始化操作都被标记为不合法:

    extern int i;
    
    // 全部都要求静态初始化(static initialization)
    // 在2.0版之前的C和C++中, 这些都是不合法的
    int j = i;
    int *pi = new int(i);
    double sal = compute_sal(get_employee(i));
    

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

    作者建议根本就不要用那些需要静态初始化的global objects(虽然这项建议几乎普遍不为C程序员所接收)

    局部静态对象

    const Matrix& identity() {
    	static Matrix mat_identity;
    	// ...
    	return mat_identity;
    }
    
    • mat_identity的constructor必须只能实施一次, 虽然上述函数可能被调用多次
    • mat_identify的destructor必须只能实施一次, 虽然上述函数可能会被调用多次

    编译器的策略之一就是, 无条件地在程序起始(startup)时构造出对象来. 然而这会导致所有的local static class objects都在程序起始时被初始化, 即使它们所在的那个函数从不曾被调用过

    实际上identify()被调用时才把mat_identity构造起来是一种更好的做法, 现在的C+标准已经强制要求这一点

    类中static数据成员未初始化时, 在第一次使用该值时会报错, 很难定位错误位置

    cfront的做法: 首先导入一个临时性对象以保护mat_identity的初始化操作. 第一次处理identify()时, 这个临时对象被评估为false, 于是constructor会被调用, 然后临时对象被改为true. 这样就解决了构造的问题. 而在相反的那一端, destructor也需要有条件地施行于mat_identity身上, 但只有mat_identity已经被构造起来才算数, 可以通过那个临时对象是否为true来判断mat_identity是否已经构造

    对象数组

    Point knots[10];	// 没有明显初值
    

    如果Point没有定义一个constructor也没有定义一个destructor, 那么上面代码所执行的工作不会比"内建(build-in)类型所组成的数组"更多(即不会调用下面所要讲到的vec_new()), 也就是说我们只要配置足够内存以存在10个连续的Point元素即可

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

    void* vec_new(
    	void 	*array,			// 数组起始地址
    	size_t	elem_size;		// 每一个class object的大小
    	int		elem_count;		// 数组中的元素个数
    	void 	(*constructor)(void *),
    	void	(*destructor)(void *, char)
    )
    
    • constructor是class的default constructor的函数指针
    • destructor是class的default destructor的函数指针
    • array持有的若不是具名数组(本例中为knots)的地址, 就是0. 如果是0, 那么数组将经由应用程序的new运算符, 被动态配置于heap中
    • 在vec_new()中, constructor施行于elem_cout个元素之上

    下面是编译器可能对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,
    	int 	elem_count,
    	void 	(*destructor)(void *, char)
    )
    

    如果程序员提供一个或多个明显初值给一个由class objects组成的数组, 像下面这样:

    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];
    
    // C++伪码
    
    // 显示地初始化前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);
    

    Default Constructors和数组

    如果想要在程序中取出一个constructor的地址, 是不可以的. 当然, 这是在编译器支持vec_new()时该做的事情. 然而, 经由一个指针来启动constructor, 将无法(不被允许)存取default argument values

    举个例子, 在cfront2.0之前, 声明一个由class objects所组成的数组, 意味着这个class必须没有声明constructs或一个default constructor(没有参数那种)----> 有还是没有一个default constructor(没有参数那种)???. 一个constructor不可以取一个或一个以上的默认参数值. 这违反直觉的, 会导致以下的大错

    class complex {
    	complex(double = 0.0, double = 0.0);
    };
    

    在当时的语言规则下, 此复数函数库的使用者没办法声明一个由complex class objects组成的数组.

    我的理解是在2.0版本之前, 这样带有默认参数的构造函数无法区分无参构造函数

    然而在2.0版, 修改了语言本身, 为支持句子:complex::complex(double = 0.0, double = 0.0), 当程序员写出complex c_array[10]时, 而编译器最终需要调用vec_new(&c_array, sizeof(complex), 10, &complex::complex, 0);, 默认的参数如何能够对vec_new()而言有用?

    cfront所采用的方法是产生一个内部的stub construct, 没有参数. 在其函数内调用由程序员提供的constructor, 并将default参数值显式地指定过去(由于construct的地址已经被取得, 所以它不能够成为一个inline):

    // 内部产生的stub constructor
    // 用以支持数组的构造
    complex::complex() {
    	complex(0.0, 0.0);
    }
    

    编译器自己又一次违反了一个明显的语言规则: class如今支持了两个没有带参数的constructs. 当然, 只有class objects数组真正被产生出来时, stub实例才会被产生以及被使用

    6.2 new和delete运算符

    int *pi = new int(5);
    

    实际上是由两个步骤完成:
    (1) 通过适当的new运算符函数实例, 配置所需内存: int *pi = __new(sizeof(int));
    (2) 将配置得来的对象设置初值: *pi = 5;

    更进一步, 初始化操作应用在内存配置成功后才执行:

    int *pi;
    if (pi = __new(sizeof(int)))	// (__new即下面会说到的operator new)
    	*pi = 5;
    
    delete  pi;
    

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

    if (pi != 0)
    	__delete(pi);	// (__delete即下面会说到的operator delete)释放内存, 但是pi并不会设为0
    

    以constructor来配置一个class object:

    Point3d *origin = new Point3d;
    
    // 转换为:
    // C++伪码
    if (origin = __new(sizeof(Point3d)))
    	origin = Point3d::Point3d(origin);
    
    // 出现exception handling情况:
    // C+++伪码
    if (origin = __new(sizeof(Point3d))) {
    	try {
    		origin = Point3d::Point3d(origin);
    	}
    	catch(...) {
    		// 调用delete library function以释放因new而配置的内存
    		__delete(origin);
    		
    		// 将原来的exception上传
    		throw;
    	}
    }
    

    destructor的应用:

    delete origin;
    
    // 会变成
    // C++伪码
    if (origin != 0) {
    	Point3d::~Point3d(origin);
    	__delete(origin);
    }
    
    // 如果在exception handling的情况下, destructor应该被放在一个try区段中
    // exception handler会调用delete运算符, 然后再一次抛出该exception
    

    一般的library对于new运算符的实现操作都很直接了当, 担忧两个精巧之处值得斟酌(以下版本并未考虑exception handling):

    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所组成的数组的每一个元素身上(这里并不需要调用constructor). 到是operatoror 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数组的操作, 只是单纯地获得内存和释放内存而已. 由operator new和operator delete来完成绰绰有余

    如果class定义了一个default constructor, 某些版本的vec_new()就会被调用, 配置并构造class objects所组成的数组, 例如:

    Point3d *p_array = new Point3d[10];
    
    // 通常会被编译为:
    Point3d *p_array;
    // 与前面的数组有区别, 前面在析构的地方传的是0, 这里是&Point3d::~Point3d
    p_array = vec_new(0, sizeof(Point3d), 10, &Point3d, &Point3d::~Point3d);	
    

    在个别的数组元素构造过程中, 如果发生exception, destructor就会被传给vec_new(). 只有已经构造妥当的元素才需要destructor的施行, 因为它的内存已经被配置出来(所以这里析构位置不在为0?), vec_new()有责任在exception发生的时机把那些内存释放掉

    当delete一个指向数组的指针时, C++2.0版之前, 需要提供数组的大小. 而2.1版后, 不需要提供数组大小, 只有在[]出现时, 编译器才寻找数组的维度. 否则它便假设只有单独一个object要被删除:

    // 正确的代码应该是delete[] p_array;
    delete p_array;		// 只有第一个元素会被析构. 其他元素仍然存在, 虽然相关的内存已经被要求归还了
    

    由于新近的编译器不提供数组大小, 那么如何记录数组的元素, 以便在delete[] arr;时使用?
    (1) 一个明显的方法是为vec_new()所传回的每一个内存区块配置一个额外的word, 然后把元素个数包藏在这个word之中, 通常这种被包藏的数值称为cookie
    (2) Jonathan和Sun编译器决定维护一个"联合数组", 放置指针及大小. Sun也把destructor的地址维护于此数组之中

    cookie策略有一个普遍引起忧虑的话题, 如果一个坏指针被交给delete_vec(), 取出来的cookie自然是不合法的. 一个不合法的元素个数和一个坏指针的起始地址, 会导致destructor以非预期的次数被实施于一段非预期的区域. 然而在"联合数组"的策略下, 坏指针的可能结果就只是取出错误的元素个数而已

    **避免一个base class指针指向一个derived class objects所组成的数组: **
    Point *ptr = new Point3d[10];

    实施于数组上的destructor, 是根据交给vec_delete()函数的"被删除的指针类型的destructor", 在本例中正是Point destructor, 并非所期望那样. 此外, 每一个元素的大小也一并被传递过去, 本例中是Point class object的大小, 而不是Point3d的大小. 这就是vec_delete()如何迭代走过每一个元素的方式. 因此整个过程失败了, 不只是因为执行了错误的destructor, 而且自若第一个元素之后, 该destructor即被施行于不正确的内存区块中(因为元素大小不对)

    测试程序(执行结果与书上有出入):

    #include <iostream>
    using namespace std;
    
    class base {
    public:
        base() { cout << "base constructor" << endl; }
        virtual ~base() { cout << "base destructor" << endl; }
    };
    
    class derived : public base{
    public:
        derived() { cout << "derived constructor" << endl; }
        virtual ~derived() { cout << "derived destructor" << endl; }
    };
    
    int main() {
        base *arr = new derived[2];
        delete[] arr;
        /*  正确做法应该强制类型转换后delete, vs, g++都报错
        for (int i = 0; i < 2; i++) {
            derived *p = &((derived *)arr)[i];
            delete p;
        } 
        */
        
        return 0;
    }
    
    /* vs执行结果
    base constructor
    derived constructor
    base constructor
    derived constructor
    derived destructor
    base destructor
    derived destructor
    base destructor
    请按任意键继续. . .
    */
    
    /* g++执行结果
    base constructor
    derived constructor
    base constructor
    derived constructor
    derived destructor
    base destructor
    derived destructor
    base destructor
    */
    
    /* 按书上的结果应该是
    base constructor
    derived constructor
    base constructor
    derived constructor
    base destructor
    base destructor
    */
    

    Placement Operator new的语意

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

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

    详情略

    6.3 临时性对象

    如果有一个函数T operator+(const T&, const T&);, 分析下列3个语句产生的临时对象:
    (1)T c = a + b;
    (2)c = a + b;
    (3)a + b;

    对于T c = a + b;, 有三种方式获得c对象, C++标准允许编译器厂商有完全的自由度, 以下三种方式所获得的c对象结果都一样, 期间的差异在于初始化的成本:

    • 编译器可以产生一个临时对象, 放置a+b的结果, 然后再使用T的copy constructor, 把该临时性对象当作C的初始值
    • 编译器也可以直接以拷贝构造的方式, 将a+b的值放到c中(2.3节对于加法运算符的转换曾有讨论), 于是不需要临时对象, 以及对其constructor和destructor的调用
    • 此外视operator+()的定义而定, NRV(named return value)优化也可能实施起来, 这将导致直接在上述c对象中求表达式结果, 避免执行copy constructor和具名对象(named object)的destructor

    实际上, 由于市场竞争, 几乎包装任何表达式T c = a + b;背后的T operator+(const T&, const T&)T T::operator+(const T&)的实现都不会产生一个临时对象

    对于c = a + b, 不能忽略临时对象, 它将导致下面的结果:

    // T temp = a + b;
    T temp; 
    // c = tmep
    c.operator+(temp);
    temp.T::~T();
    

    直接传递C到运算符函数中都是有问题的. 由于运算符函数并不为其外加参数调用一个destructor(它期望一块"新鲜的"内存), 所以必须在此调用之前先调用destructor

    对于a + b;, 没有目标对象, 这时候有必要产生一个临时对象以外置运算后的结果. 这种情况在子表达式中十分普遍. 对这种情况下的一个问题时何时销毁临时对象, C++标准规定, 临时对象的被销毁, 应该是对完整表达式求值过程中的最后一个步骤, 该完整表达式造成临时对象的产生, 但是, 这个规则也存在2个例外:

    1. 发生在表达式被用来初始化一个object时, 此时object初始化完成后才销毁临时对象
    bool verbose;
    //...
    String pogNameVersion = !verbose ? 0 : progName + progVersion;
    

    如果在完整的"?:表达式"结束后就销毁临时的progName + progVersion对象, 那么就无法正确初始化progNameVersion
    但是, 即使遵守这个规则, 程序员还是可能让一个临时对象在控制中被下偶hi, 最终初始化操作失败:
    const char *progNameVersion = progName + progVersion;
    产生的临时对象会调用转换函数转换为char*, 然后赋值给progNameVersion, 在初始化完成后, 临时对象的销毁会使得指针指向未定义的内存
    2. "当一个临时对象被一个reference绑定"时, 临时对象应该在reference的生命结束后才销毁
    const String &space = " ";
    如果临时对象在初始化space后就销毁, 那么reference也就没用了

    在类似if (s + t || u + v)这种表达式中, 临时对象是根据程序的执行期语意, 有条件地被产生出来的, 如果把临时对象的destructor放进每一个子算式的求值过程中, 刻一个免除"努力追踪第二个子算式是否真的需要被评估". 然后现在C++标准以及要求这类表达式在整个完整表达式结束后才销毁对象, 因此某些形式的测试会被安插进来, 以决定是否要摧毁和第二算式有关的临时对象

  • 相关阅读:
    css3实现渐变进度条
    从实际项目出发,浅淡什么是设计空间
    消失的Controller
    深入理解Go系列一之指针变量
    48个国际音标简述
    【PyTorch】按照 steps 训练和保存模型
    用C/python手写redis客户端,兼容redis集群 (-MOVED和-ASK),快速搭建redis集群
    jsoncpp安装与使用 cmake安装 升级g++ gcc支持c++11
    【Android】解决Android Studio初次配置可能会出现的Unkown Host问题
    【数据结构】时间复杂度和空间复杂度计算
  • 原文地址:https://www.cnblogs.com/hesper/p/10629644.html
Copyright © 2020-2023  润新知