0
点赞
收藏
分享

微信扫一扫

C++对象模型:执行期语义学


class Y {
public:
    Y() {}
    ~Y() {}
    bool operator==(const Y& other) const {
        // 假设这里有一些比较逻辑
        return true; // 示例返回值
    }
};

class X {
public:
    X() {}
    ~X() {}
    operator Y() const {
        // 转换逻辑
        return Y();
    }
    X getValue() {
        // 返回一个新的X对象
        return X();
    }
};

定义了两个类X和Y。

类Y中重载了等号运算符(operator==),可以使用==来比较两个Y类型的对象。

类X中定义了一个转换运算符operator Y(),允许将X类型的对象隐式转换为Y类型的对象。

X类还有一个名为getValue的方法,该方法返回一个X类型的对象。

表达式if(yy == xx.getValue())...,这里yy是Y类型的对象,而xx是X类型的对象。

当执行yy == xx.getValue()时,首先需要确定==运算符具体指的是哪个版本。由于yy是Y类型的对象,因此这里的==会被解析为Y类中的operator==成员函数。

但是,xx.getValue()返回的是一个X类型的对象,而Y::operator==期望接收一个Y类型的参数。这时,就需要利用X类提供的转换运算符operator Y(),将X类型的对象转换为Y类型的对象。

原始表达式被转换为:

if(yy.operator==(xx.getValue().operator Y()))

临时对象的创建与销毁

在实际执行过程中,C++编译器会自动创建一些临时对象来存储中间结果,并在不再需要这些对象时调用它们的析构函数进行清理。

创建一个临时的X类型的对象temp1,用于存放xx.getValue()的结果。使用temp1调用operator Y(),创建一个临时的Y类型的对象temp2。

使用temp2作为参数调用yy.operator==,得到一个布尔值,存储在一个临时的bool类型的对象temp3中。最后根据temp3的值决定if语句体是否被执行。当上述步骤完成后,所有临时对象(temp1, temp2, temp3)都会被销毁,相应的析构函数会被调用。

对象的构造和析构

一般情况对象的构造和析构通常按照预期的方式进行:

{
    Point point;
    // point的构造函数在这里被调用
    // point.Point::Point()
    // point的析构函数在这里被调用
    // point.Point::~Point()
}

多个离开点的情况

当一个代码块或函数中有多个离开点(例如多个return语句或goto语句)时,析构函数的调用位置会变得更加复杂。析构函数必须在每个离开点之前被调用,以确保对象在离开作用域时被正确销毁。

{
    Point point;
    // point的构造函数在这里被调用
    // point.Point::Point()
    switch (int(point.x())) {
        case -1:
            //代码逻辑
            //point的析构函数在这里被调用
            //point.Point::~Point()
            return;
        case 0:
            //代码逻辑
            //point的析构函数在这里被调用
            //point.Point::~Point()
            return;
        case 1:
            // 代码逻辑
            // point的析构函数在这里被调用
            // point.Point::~Point()
            return;
        default:
            // 代码逻辑
            // point的析构函数在这里被调用
            // point.Point::~Point()
            return;
    }
    // point的析构函数在这里被调用
    // point.Point::~Point()
}

point的析构函数必须在switch语句的每个return语句之前被调用。此外,即使程序分析结果显示不可能到达代码块的结束处,析构函数仍然会在代码块的结束处被调用。

goto语句的情况

goto语句也可能需要多个析构函数调用操作。例如:

{
    if (cache) {
        // 检查 cache;如果吻合就传回 1
        return 1; // 原书少了这一行,应为作者笔误
    }
    Point xx;
    // xx的构造函数在这里被调用
    // xx.Point::Point()
    while (cvs.iter(xx)) {
        if (xx == value) {
            goto found;
        }
    }
    // xx的析构函数在这里被调用
    // xx.Point::~Point()
    return 0;
found:
    // 缓存项
    // xx的析构函数在这里被调用
    // xx.Point::~Point()
    return 1;
}

xx的析构函数必须在最后两个return语句之前被调用,但不必在最初的return语句之前调用,因为在那时xx尚未被定义。

最佳实践

为了节省不必要的对象构造和析构操作,应该尽可能将对象定义在其使用的地方附近。

例如在检查cache之前定义Point对象是不理想的,因为如果缓存命中,Point对象根本不会被使用。

如果对象在某些条件下不会被使用,那么在这些条件下提前定义对象会导致不必要的构造和析构操作,而且将对象定义在其使用的地方可以使代码更清晰,更容易理解和维护。

全局对象(Global Objects)

全局对象(即在任何函数之外定义的对象)具有特殊的生命周期。C++保证这些对象在main()函数开始之前被构造,并在main()函数结束后被析构。这种构造和析构操作被称为静态初始化和静态内存释放。

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

identity是一个全局对象,C++保证在main()函数开始之前调用Matrix的构造函数来初始化identity,并在main()函数结束后调用Matrix的析构函数来销毁identity。

静态初始化和内存释放

C++程序中的所有全局对象都被放置在程序的数据段(data segment)中。如果显式指定了初始值,对象将被初始化为该值;否则,对象所配置的内存内容为0。

int v1 = 1024;
int v2;

v1和v2都被配置在数据段中,v1的值为1024,v2的值为0。

虚基类的影响

虚基类(virtual base classes)的引入增加了静态初始化的复杂性。例如,通过派生类指针访问虚基类子对象的位置在编译时不可知,需要在运行时评估。

静态初始化的缺点

异常处理:静态初始化的对象不能放置在try块内,任何抛出的异常将触发默认的terminate函数。

依赖顺序:控制跨模块静态初始化对象的依赖顺序可能导致复杂性。

局部静态对象

局部静态对象具有以下特点:

构造:构造函数必须仅在函数首次被调用时执行一次。

析构:析构函数必须仅在程序结束时执行一次。

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

为了确保mat_identity的构造函数仅在identity()函数首次被调用时执行一次,cfront编译器采用了以下策略:引入一个临时对象来保护mat_identity的初始化操作。第一次调用identity()时,临时对象被评估为false,构造函数会被调用,然后临时对象被设置为true。析构函数需要有条件地调用,但只有在mat_identity已经被构造的情况下才调用。为了判断mat_identity是否被构造,可以检查临时对象是否为true。

新的C++标准

C++标准委员会正在考虑改变局部静态对象的析构语义。

构造:在需要时构造局部静态对象(例如,每个包含局部静态对象的函数首次被调用时)。

析构:以构造的相反顺序销毁局部静态对象。

为了支持新的规则可能需要在运行时维护一个链表来记录所有被构造的局部静态对象,并在程序结束时按相反顺序销毁它们。

对象数组(Array of Objects)

对象数组的初始化和析构

Point knots[10];

初始化:如果Point类没有定义构造函数和析构函数,只需要分配足够的内存来存储10个连续的Point元素。

析构:如果Point类定义了默认构造函数和默认析构函数,需要在数组的每个元素上调用构造函数和析构函数。

初始化过程

无构造函数和析构函数的情况

如果Point类没有定义构造函数和析构函数,只需要分配足够的内存来存储10个连续的Point元素。

Point knots[10];

有构造函数和析构函数的情况

如果Point类定义了默认构造函数和默认析构函数,需要在数组的每个元素上调用构造函数。

vec_new函数

void* vec_new(
    void* array,          // 数组起始地址
    size_t elem_size,     // 每个类对象的大小
    int elem_count,       // 数组中的元素个数
    void (*constructor)(void*), // 构造函数指针
    void (*destructor)(void*, char) // 析构函数指针
);

假设Point类定义了默认构造函数和默认析构函数,编译器可能会生成以下vec_new调用:

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

析构过程

当knots的生命结束时,需要在数组的每个元素上调用析构函数。这通常通过一个类似的vec_delete(或vec_vdelete,如果类包含虚基类)的运行时库函数来完成。

vec_delete函数

void* vec_delete(
    void* array,          // 数组起始地址
    size_t elem_size,     // 每个类对象的大小
    int elem_count,       // 数组中的元素个数
    void (*destructor)(void*, char) // 析构函数指针
);

假设Point类定义了默认析构函数,编译器可能会生成以下vec_delete调用:

vec_delete(&knots,sizeof(Point),10,&Point::~Point);

显式初始化的数组

如果程序员提供了一个或多个显式初值给一个由类对象组成的数组,情况会有所不同。

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

对于显式初始化的元素,vec_new不再必要。对于未初始化的元素,vec_new的调用方式与未提供显式初始化列表的数组相同。

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);

new 和 delete 运算符

使用new运算符来创建一个新的对象:

(1)内存分配:new首先调用operator new函数(通常是全局的)来分配足够的内存来存放对象。

(2)对象初始化:一旦内存被成功分配,new接着调用对象的构造函数来初始化这块内存。

如果构造函数抛出了异常,那么new会确保已经分配的内存被释放。

delete运算符负责清理之前由new分配的内存:

(1)调用析构函数:delete首先调用对象的析构函数来清理对象。

(2)释放内存:接着,它调用operator delete函数来释放内存。

如果指针是nullptr,delete不会执行任何操作。

自定义new和delete操作:

可以自定义new和delete操作符来改变内存管理的行为,比如设置自己的内存分配策略或者错误处理机制。

void* operator new(size_t size) {
    if (size == 0) size = 1;  // 至少分配1字节
    void* last_alloc;
    while (!(last_alloc = malloc(size))) {
        // 可以在这里添加自己的内存不足处理逻辑
        if (_new_handler) {
            (*_new_handler)();
        } else {
            return nullptr;  // 或者抛出异常
        }
    }
    return last_alloc;
}
void operator delete(void* ptr) noexcept {
    if (ptr) {
        free(ptr);
    }
}

针对数组的 new语意

数组的new语义

当使用new运算符来创建一个数组时

int *p_array = new int[5];

这行代码实际上做了以下几件事情:

内存分配:分配足够的内存来容纳5个整数。

构造:对于基本类型,不需要调用构造函数,但对于用户定义的类型,每个元素都会调用相应的构造函数。如果数组元素是用户定义的类类型,且该类有构造函数,那么每个元素都会调用构造函数。

Point3d *p_array = new Point3d[10];  // 创建一个Point3d对象的数组

new运算符不仅分配了内存,还会为每个Point3d对象调用构造函数。这通常会调用一个内部函数vec_new,它负责分配内存并调用每个元素的构造函数。

数组的delete语义

当使用delete[ ] 来释放数组时

delete[] p_array; // 释放 Point3d 对象数组

析构:对于每个元素,调用相应的析构函数。

内存释放:释放分配给数组的内存。

如果数组元素是用户定义的类类型,且该类有析构函数,那么每个元素都会调用析构函数。这通常会调用一个内部函数vec_delete,它负责调用每个元素的析构函数并释放内存。

(1)构造函数:用于初始化对象。对于数组中的每个元素,构造函数都会被调用。

(2)析构函数:用于清理对象。对于数组中的每个元素,析构函数都会被调用。

(3)特殊情况:基类指针指向派生类数组

如果有一个基类Point和一个派生类Point3d,并且使用基类指针来指向派生类对象数组,那么在删除数组时可能会遇到问题。

Point *ptr = new Point3d[10];  // 使用基类指针指向派生类数组

在这种情况下,如果你直接使用delete[] ptr;,那么只会调用Point类的析构函数,而不会调用Point3d类的析构函数。此外,由于Point3d对象比Point对象大,析构函数可能会访问超出Point对象范围的内存,从而导致未定义行为。

解决方案

为了避免这种情况,你应该手动遍历数组并对每个元素调用delete,以确保正确的析构函数被调用。例如:

for (int i = 0; i < 10; ++i) {
    Point3d *p = &((Point3d*)ptr)[i];
    p->~Point3d();  // 显式调用析构函数
}
operator delete[](ptr);  // 释放内存

或者更简单地,直接使用派生类指针来管理数组:

Point3d *ptr = new Point3d[10];  // 使用派生类指针
// 使用完毕后
delete[] ptr;  // 正确地调用Point3d的析构函数

虚析构函数允许在通过基类指针删除派生类对象时,调用正确的派生类析构函数。这对于单个对象的删除是非常有用的。

Point* p = new Point3d;  // 使用基类指针指向派生类对象
delete p;  // 正确地调用 Point3d 的析构函数

Point类的析构函数是虚函数,所以在删除Point3d对象时,会调用Point3d的析构函数,而不是只调用Point的析构函数。然而,处理的是一个数组时,即使析构函数是虚函数,delete[]仍然不会按预期工作。

数组删除的特殊性:delete[]期望数组中的所有元素都是同一种类型。它会根据基类指针的类型来确定每个元素的大小,并且只会调用基类的析构函数。

如果派生类对象比基类对象大,delete[]只会按照基类对象的大小来计算数组的总大小。这会导致内存访问越界,从而引发未定义行为。

int main() {
    Point* ptr = new Point3d[10];  // 使用基类指针指向派生类数组
    delete[] ptr;  // 错误:只会调用 Point 的析构函数,且可能导致内存访问越界
}

delete[]会按照Point对象的大小来计算数组的总大小,如果Point3d对象比Point对象大,那么delete[]会访问超出分配内存的范围,从而导致未定义行为。

Placement Operator new 的语意

placement new 是一种特殊的new运算符,它接受一个额外的void*参数,该参数指向已经分配好的内存。placement new 的主要用途是在特定的内存位置上构造对象,而不是分配新的内存。

void* operator new(size_t, void* ptr) {
    return ptr;
}
char memory[sizeof(Point2w)];  // 预先分配的内存
Point2w* ptw = new (memory) Point2w;  // 在预分配的内存上构造Point2w对象

placement new 的作用不仅仅是返回一个指针,更重要的是它会调用对象的构造函数。

// C++ 伪码
Point2w* ptw = (Point2w*)memory;
if (ptw != 0) {
    ptw->Point2w::Point2w();  // 调用构造函数
}

使用placement new时的注意事项

1. 析构函数的调用

如果你在一个已经存在的对象上构造新的对象,而旧对象有析构函数,那么析构函数不会自动被调用。你需要手动调用析构函数。

void fooBar() {
    Point2w* p2w = new (arena) Point2w;
    // ... do something ...
    p2w->~Point2w();  // 显式调用析构函数
    p2w = new (arena) Point2w;  // 重新构造对象
}

2. arena指针的真实类型

arena指针应该指向一个足够大的内存块,以容纳要构造的对象。这个内存块可以是静态分配的,也可以是动态分配的。

动态分配
char* arena = new char[sizeof(Point2w)];  // 动态分配内存
Point2w* ptw = new (arena) Point2w;  // 在预分配的内存上构造对象
静态分配
char arena[sizeof(Point2w)];  // 静态分配内存
Point2w* ptw = new (arena) Point2w;  // 在预分配的内存上构造对象

3. 不支持多态

placement new 不支持多态。也就是说,你不应该在基类的内存上构造派生类的对象,因为这可能会导致内存越界或其他未定义行为。

错误的用法
Point2w* arena = new Point2w;  // 基类对象
Point2w* p2w = new (arena) Point3w;  // 错误:派生类比基类大

4. 虚函数的调用

在placement new的情况下,虚函数的调用可能会出现问题。

struct Base {
    int j;
    virtual void f() { /* ... */ }
    virtual ~Base() {}
};
struct Derived : Base {
    void f() override { /* ... */ }
};
void fooBar() {
    Base b;
    b.f();  // 调用Base::f()
    b.~Base();  // 显式调用析构函数
    new (&b) Derived;  // 在b的位置上构造Derived对象
    b.f();  // 哪一个f()被调用?
}

在这个例子中,b.f()的调用结果是不确定的。尽管Derived对象已经在b的位置上构造,但由于虚函数表可能没有更新,大多数编译器仍然会调用Base::f()。

临时性对象(Temporary Objects)

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

a + b可能会创建一个临时对象来存储 operator+ 的返回值。然后这个临时对象会被用来初始化 c。然而,现代编译器通常会进行优化,比如返回值优化(Return Value Optimization, RVO)或命名返回值优化(Named Return Value Optimization, NRVO),从而避免创建临时对象。

RVO/NRVO: 如果 operator+ 返回一个 T 类型的对象,并且这个对象可以直接构造在 c 的位置,编译器可以省略临时对象的创建,这不会调用额外的复制构造函数和析构函数。

Copy Elision: 即使没有RVO/NRVO,编译器也可以通过复制省略(Copy Elision)来避免临时对象的创建。在这种情况下,a + b 的结果可以直接构造到 c 中。

临时对象的生命周期

临时对象的生命周期至少持续到包含它的完整表达式结束。

if (s + t || u + v) {
    // ...
}

s+t 和u+v都会创建临时对象。u + v 的临时对象只会在 s + t 为假时创建,并且它的生命周期会持续到整个条件表达式结束。

C++标准明确规定了临时对象的生命周期,确保了跨编译器的一致性。临时对象的生命周期至少持续到包含它的完整表达式结束,即使在条件表达式中也是如此。

假设有一个 String 类,并且定义了 operator+ 和 operator const char*():

class String {
public:
    String(const char* str) {/* ... */}
    String operator+(const String& other) const { /* ... */ }
    operator const char*() const { return _str; }
private:
    char* _str;
};
String s("hello"),t("world");
printf("%s\n",s+t);

s + t 会创建一个临时对象,这个临时对象会被转换为 const char* 并传递给 printf。根据C++标准,这个临时对象的生命周期会持续到 printf 调用完成。

时对象的生命周期规则

临时对象的生命周期通常至少持续到包含它的完整表达式结束。有两个例外情况:

(1)初始化对象时的临时对象生命周期:当一个表达式用于初始化一个对象时,临时对象的生命周期可能会有所不同。

bool verbose;
String progNameVersion = verbose ? "" : progName + progVersion;

在这个例子中,progName + progVersion 会产生一个临时对象。根据 verbose 的值,这个临时对象可能会被用来初始化 progNameVersion。如果 verbose 为 false,临时对象将被用来初始化 progNameVersion。临时对象必须在 ?: 表达式评估完成后尽快被销毁。

如果 progNameVersion 的初始化需要调用复制构造函数(copy constructor),临时对象的销毁时间就会变得复杂。根据C++标准,即使在 ?: 表达式结束后,临时对象也必须保持存活,直到 progNameVersion 的初始化完成。

if (!verbose) {
    String temp = operator+(progName, progVersion);
    progNameVersion.String::String(temp);  // 复制构造函数
    // 临时对象 temp 应该在 progNameVersion 初始化完成后被销毁
}

临时对象被引用绑定时的生命周期:

当一个临时对象被一个引用绑定时,临时对象的生命周期会延长,直到引用的生命结束或临时对象的作用域结束。

const String& space = "";

在这个例子中,临时对象 "" 会被绑定到引用 space,并且临时对象的生命周期会延长到 space 的生命周期结束。

生命周期规则:

临时对象的生命周期会延长,直到引用 space 的生命周期结束或临时对象的作用域结束,以先到达者为准。

// C++ 伪码

String temp;
temp.String::String("");  // 临时对象的构造
const String& space = temp;  // 临时对象被引用绑定
// 临时对象 temp 的生命周期延长到 space 的生命周期结束
if (!verbose) {
    String temp = operator+(progName, progVersion);  // 临时对象的构造
    progNameVersion.String::String(temp);  // 复制构造函数
    // 临时对象 temp 在 progNameVersion 初始化完成后被销毁
}

总结

初始化对象时的临时对象生命周期:临时对象的生命周期会延长,直到对象的初始化完成。

临时对象被引用绑定时的生命周期:临时对象的生命周期会延长,直到引用的生命结束或临时对象的作用域结束,以先到达者为准。

这些规则确保了临时对象在需要的时候保持存活,避免了未定义行为和潜在的错误。

举报

相关推荐

0 条评论