0%

第六章 执行期语意学

  • 有个简单的式子:
    1
    if (yy == xx.getValue())) ...
    其中xx和yy的定义:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class Y {
    public:
    Y();
    ~Y();
    bool oeprator==(const Y&) const;
    // ...
    };

    class X {
    public:
    X();
    ~X();
    operator Y() const; // conversion运算符
    X getValue();
    // ...
    };
    当使用equality(等号)运算符时,xx被resolves为“被overloaded的Y成员实例”。下面是该式子的转换:
    1
    2
    // resolution of intended operator
    if (yy.operator==(xx.getValue()))
    Y的等号运算符需要一个类型为Y的参数,而getValue()传回的是一个类型为X的object。这时提供conversion运算符,把一个X object转换为一个Y object,它施行于getValue()的返回值身上。下面是第二次转换:
    1
    2
    // conversion of getValue()'s return value
    if (yy.operator==(xx.getalue().operator Y()))

6.1 对象的构造和析构(Object Construction and Destruction)

  • 一般constructor和destructor的安插都如下:
    1
    2
    3
    4
    5
    6
    {
    Point point;
    // point.point::Point();
    ...
    // point.point::~Point();
    }
    如果一个区段或函数有一个以上的离开点,情况就会混乱一些,Destructor必须被放在每一个离开点(object还存活)之前。
全局对象(Global Objects)
  • C++保证在第一次使用global数据前之前,将它构造出来,而在结束前把identity摧毁掉。如果global object如果有constructor和destructor的话,它需要静态的初始化操作和内存释放操作。所有的global obejct都被放置在程序的data segment中。如果不显式指定值,则内存内容为0(C略有不同,C并不自动设定初值)。C语言中一个global object只能被一个常量表达式(可在编译时期求值)设定初值。虽然class object在编译时期可以被放置data segment中并且内容为0,但constructor一直要到程序启动(startup)时才会实施。而必须对放置program data segment中的object的初始化表达式做评估(evaluate),这就是一个object需要静态初始化的原因
  • munch是一个可移植但成本颇高的静态初始化(以及内存释放)方法。munch策略:
    1. 为每个需要静态初始化的文件产生__sti()函数(sti是static initialization),内含constructor调用操作或inline expansions。一个global对象identity会在matrix.c中产生:
      1
      2
      3
      __sti_matrix_c__identity() {
      identity.Matrix::Matrix();
      }
      matrix_c是文件名编码,_identity表示文件中所定义的static object。
    2. 在每个需要静态的内存释放操作(static deallocation)的文件中,产生一个__std()函数(std是static deallocation),内含必要的destructor调用操作,或是inline expansions。比如以上identity对象会调用Matrix destructor。
    3. **提供一组rumtime library “munch”函数:一个_mani()函数(call __sti(),一个exit()函数(call __std())**。
  • 最后一个需要解决的问题是,如何收集程序中各个object files的__sti()函数和__std()函数。解决方法是使用nm命令,nm会dump出object file的符号表格项目symbol table entries)。nm施行于可执行文件上,输出piped into到munch程序中。munch程序则搜寻__sti或__std开头的名称,然后把函数名称加到jump table中,然后把表格写到program text文件中,CC命令被重新激活,将内含表格的文件编译,整个可执行文件被重新链接。_main()和exit()在各个表格上走访一遍,轮流调用每一个项目(代表一个函数地址)。但这方法离正统计科太远了……
  • 当特性平台的C++编译器出现,更有效率的方法也随之出现,因为各平台上扩充链接器和目的文件格式(object file format),以直接支持静态初始化和内存释放操作。例如System V的Executable and Linking Format(ELF)被扩充以增加支持.init和.fini两个sections,两个sections内含的对象所需的信息,分别对应静态初始化和释放操作。编译器特定的startup函数会完成平台特定的支持。
  • 使用被静态初始化的objects,有缺点。如果exception handing被支持,那些objects将不能被放置于try区段之内。因为任何的throw操作必然将触发exception handing library的默认terminate()函数以致于结束程序,这对于被静态调用的constructors可能是特别无法接受的。另一个缺点是为了控制跨模块静态初始化的objects的依赖顺序,而形成的复杂度。建议根本不要用需要静态初始化的global objects(虽然这建议不普遍为C程序员接受)。
局部静态对象(Local Static Objects)
  • 假设有以下程序片段:
    1
    2
    3
    4
    5
    const Matrix& identity() {
    static Matrix mat_identity;
    // ...
    return mat_identity;
    }
    Local static class object保证了什么样的语意?
    • mat_identity的constructor只能执行一次,虽然函数可能被调用多次
    • mat_identity的destructor只能施行一次,虽然函数可能会被调用多次
  • 编译器的无条件构造对象会导致即使函数不曾被调用过也被程序起始时被初始化。cfont的做法是,用一个临时性对象保护mat_identity的初始化操作。第一次调用时,临时对象被评估为false,constructor被调用,然后临时对象改为true。而destructor判断则反过来。困难的是,没办法在静态的内存释放函数中存取local对象。取出local object的地址就好了……
对象数组(Array of Objects)
  • 假如有数组定义:
    1
    Point knots[10];
    如果Point没有定义constructor也没有定义destructor,那么工作不会比(build-in)类型的数组更多。然而它的确定义了一个default destructor,所以这个操作轮流施行每个元素上。cfont中,使用一个叫vec_new()的函数,产生以class object构造成的数组。一些新编译器则提供了两个函数,一个分别处理有和没有内含virtual base class的class。vec_vnew()用于内含virtual base class函数的class。当然不同平台有差异:
    1
    2
    3
    4
    5
    6
    7
    void* vec_new (
    void *array, // 数组起始地址
    size_t elem_size, // class object的大小
    int elem_count, // 元素个数
    void (*constructor)(void*), // 函数指针
    void (*destructor)(void*, char)
    )
    参数array如果持有不具名数组地址(knots),就是0,意味着数组经由应用程序new运算符动态配置于heap中
    下面是编译器可能针对10个Point元素所做的vec_new()调用操作:
    1
    2
    Point knots[10];
    vec_new(&knots, sizeof(Point), 10, &Point::Point, 0);
    如果Point定义了一个destructor,当knots结束时destructor也施行于10个Point元素身上。这时经由一个类似vec_delete()的runtime library
    1
    2
    3
    4
    5
    6
    void* vec_delete(
    void *array,
    size_t elem_size,
    int elem_count,
    void (*destructor)(void*, char)
    )
    对于以下的组成方式:
    1
    2
    3
    4
    5
    Point knots[10] = {
    Point(),
    Point(1.0, 1.0, 0.5),
    -1.0
    };
    对于明显获得初值的元素,vec_new()不再有必要。对于尚未初始化的元素,施行方式则像没有explicit initialization list的class elemnts一样:
    1
    2
    3
    4
    5
    6
    7
    // C++伪码
    Point knots[10];
    Point::Point(&knots[0]);
    Point::Point(&knots[1], 1.0, 1.0, 0.5);
    Point::Point(&knots[2], -1.0, 0.0, 0.0);
    // 初始化剩余7个元素
    vec_new(&knots+3, sizeof(Point), 7, &Point:Piont, 0);
Default Constructors和数组

6.2 new和delete运算符

  • 运算符new的使用,看起来似乎是单一运算,像这样:
    1
    int *pi = new int(5);
    它由两个步骤完成
    1. 通过new运算符函数实例,分配内存
    2. 为配置的内存初始化
      1
      2
      3
      int *pi;
      if (pi = __new(sizeof(int)))
      *pi = 5;
      pi所指对象的生命因delete而结束。所有后继对pi的参考操作不再保证有良好的行为。虽然地址上的对象不再合法,地址本身仍然代表一个合法的程序空间。因此pi能继续被使用,但不是个合法的编程风格。
  • 以constructor分配一个class object,情况类似:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    Ponit3d *origin = new Point3d;
    // 转换
    Point3d *origin;
    if (origin = __new(sizeof(Point3d)))
    origin = Point3d::Point3d(origin);
    // 如果有exception hading,情况会复杂
    if (origin = __new(sizeof(Point3d))) {
    try {
    origin = Point3d::Point3d(origin);
    } catch ( ... ) {
    __delete(origin);
    throw; // 继续抛出
    }
    }
    Destructor情况类似:
    1
    2
    3
    4
    5
    6
    7
    8
    delete origin;
    // 变成
    if (origin != 0) {
    Point3d::~Point3d(origin);
    __delete(origin);
    }
    // 如果有exception handing
    // ...
    new运算符实现:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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;
    }
    虽然这么写是合法的:
    1
    new T[0];
    但语言要求每次new调用都返回独一无二的指针。解决问题的传统方法是传回一个指针,指向一个默认为1-byte的内存区块(这就是为什么程序代码中size被设为1的原因)。这个程序的有趣之处在于,它允许使用者提供一个属于自己的_new_handler()函数。
    1
    2
    3
    4
    5
    extern vodi operator edlete(void *ptr)
    {
    if (ptr)
    free((char*)ptr);
    }
针对数组的new语意
  • new一个数组;
    1
    2
    3
    int *p_array = new int[5];
    // 转化
    int *p_array = (int*)__new(5 * sizeof(int));
    vec_new()不会被调用,因为它主要功能是把default constructor施行于class objects所组成的数组的每个元素身上。
    1
    2
    3
    4
    Point3d *p_array = new Point3d[10];
    // 编译
    Point3d *p_array;
    p_array = vec_new(0, sizeof(Point3d), 10, &Point3d::Point3d, &Point3d::~Point3d);
  • 应该如何记录元素个数?一个方法是为vec_new()传回的每个内存块配置一个额外的word,然后元素个数藏在word中,这叫cookie。原始编译器有两个用来存取cookie:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    typedef void *pv;
    extern int __insert_new_array(PV array_key, int elem_count);
    // 表格中取出并移除array_key,传回不是elem_count则传回-1
    extern int __remove_old_array(PV array_key);
    // cfront vec_new()
    PV __vec_new(PV ptr_array, int elem_count, int size, PV construct)
    {
    // 如果ptr_array是0,从heap中配置数组。
    int alloc = 0;
    int array_sz = elem_count == 0)
    ptr_array = PV(new char[array_sz]);
    if (ptr_array == 0)
    return 0;
    // 把数组元素个数放到cache中
    int status = __insert_new_array(ptr_array, elem_count);
    if (status === -1) {
    if (alloc)
    delete ptr_array;
    return 0;
    }
    if (construct) {
    register char* elem = (char*)ptr_array;
    register char* lim = elem + array_sz;
    // PF是typedef,代表函数指针
    register PF fp = PF(constructor);
    while (elem < lim) {
    (*fp)((void*)elem);
    // 下一个元素
    elem += size;
    }
    }
    return PV(ptr_array);
    }
Placement Operator new的语意
  • 有一个预先定义好的重载的(overloaded)new运算符,称为placement oeprator new。它需要第二个参数,类型为void*,调用方式如下:
    1
    Point2w *ptw = new(arena) Point2w;
    arena指向内存中的区块,用来放置新的Point2w object。placement operator new的实现方法很平凡:
    1
    2
    3
    4
    void* operator new(size_t, void *p)
    {
    return p;
    }
    placement new operator的强大之处在于,编译系统保证object的constructor会施行于其上:
    1
    2
    3
    Point2w *ptw = (Point2w*)arena;
    if (ptw != 0)
    ptw->Point2w::Point2w();
    对于arena表现的真正指针的类型,derived class很明显不在支持之列。对于derived class,或是其他没有关联的类型,行为虽然并非不合法,却也未经定义:
    1
    2
    3
    4
    5
    6
    7
    // 可以这么配置
    char *arena = new char[sizeof(Point2w)];
    // 相同object可以这么获得
    Point2sw *arena = new Point2w;
    // 一般placement new operator并不支持多态。
    // 如果derived class比base class大,Point3w的constructor会导致严重破坏。
    Point2w *p2w = new (arena) Point3w;
    Placement new operator引入C++2.0时,最晦涩难懂的问题就是:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    struct Base { int j; virtual void f(); };
    struct Derived : Base { void f(); };
    void fooBar() {
    Base b;
    b.f(); // Base::f()被调用
    b.~Base();
    new(&b) Derived;
    b.f(); // 哪个f()被调用
    }

6.3 临时性对象(Temporary Objects)

  • 如果有函数:

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

    那么两个此对象的a和b进行a+b;。会产生临时变量放置传回的对象。详细过程是产生临时性对象,放置结果,使用T的copy constructor,把临时性对象当作c的初始值。当然,视operator+()定义而定,NRV优化可能实施起来。这导致直接求表达式结果,避免执行copy constructor和具名对象的destructor。至于会用哪种方式初始化,C++ Standard允许编译器有临时性对象的完全自由度:

    1
    2
    3
    4
    5
    T c = a + b;
    // 而其中的加法运算符被定义为
    T operator+(const T&, const T&);
    // 或
    T T::operator+(const T&);

    那么实现根本不产生临时性变量。如果以:

    1
    c = a + b;

    不能够忽略,它会导致下面结果:

    1
    2
    3
    4
    T temp;
    temp.operator+(a, b); (1
    c.operator=(temp); (2
    temp.T::~T();

    (1)那行意味着为构造的临时对象赋值给operator+()。意思是要么表达式结果被copy constructed到临时对象中,要么以临时对象取代NRV。然而不管哪一种情况都有问题。运算符函数并不为外加参数调用destructor(它期望新内存),所以需要在调用前先调用destructor。然而,转换语意被用来把copy assignment运算符的隐式调用操作和destructor和copy constructor来代替assignment操作

    1
    2
    c.T::~T();
    c.T::T(a + b);

    而以上操作都可以由使用者供应,而且它还会产生临时变量。

    1
    2
    3
    T c = a + b;
    // 上面的比下面的操作更有效率被编译器转化
    c = a + b;
  • 第三种形式是,没有目标对象:

    1
    2
    3
    4
    5
    6
    a + b
    // 不论怎么种情况
    String v;
    v = s + t + u;
    // 或
    printf("%s\n", s + t);

    都会产生临时对象,与s+t相关联。上述的printf可能不保证安全,因为它的正确性和s+t何时被摧毁有关。(malloc(0))。例如对于该算式的一个可能的pre-Standard转换,可能造成重大灾难:

    1
    2
    3
    4
    5
    6
    // C++伪码
    // 临时性对象被摧毁得太快(太早)了。
    String temp1 = operator+(s, t);
    const char *temp2 = temp1.operator const char*();
    temp1.~String();
    printf("%s\n", temp2); // temp2来自何方?

    一种转换方式是在调用printf()之后实施String destructor。标准规格上说:临时性对象的被摧毁,应该是对完整表达式(full-expression)求值过程种的最后一个步骤。该完整表达式造成临时对象的产生完整表达式是被包裹的表达式种最外围的那个表达式

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

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

  • 临时性对象的生命规则有两个例外:

    • 第一个例外发生在表达式被用来初始化一个object时。如果某个对象progNameVersion的初始化需要调用copy constructor,那么临时性对象的析构就不是我们期望的。C++ Standard要求说:凡持有表达式执行结果的临时性对象,应该存留到object的初始化操作完成为止
      1
      2
      3
      4
      5
      6
      const char* progNameVersion = progName + progVersion;
      // C++ pseudo Code
      String temp;
      operator+(temp, progName, progVersion);
      progNmaeVersion = temp.String::operator char*();
      temp.String::~String();
      progNameVersion指向未定义的heap内存。
    • 第二个例外是当一个临时性对象被一个reference绑定时,例如:
      1
      2
      3
      4
      5
      6
      const String &space = " ";
      // 产生代码
      // C++ pseudo Code
      String temp;
      temp.String::String(" ");
      const String &space = temp;
      很明晰那,临时性对象被摧毁,reference就没什么用了。所以Standard说:
      如果临时性对象被绑定与reference,对象将残留,直到reference的生命结束,直到临时对象的生命范畴(scope)结束
临时性对象的迷思(神话、传说)