纯虚函数相关的讨论

#include <iostream>
class Abstract_base {
public:
    virtual ~Abstract_base() = 0; // 纯虚析构函数
    virtual void interface() const = 0; // 纯虚函数
    virtual const char* mumble() const { return _mumble; } // 非纯虚函数
protected:
    char* _mumble;
    // 提供一个带参数的构造函数来初始化 _mumble
    Abstract_base(char* mumble_value = nullptr) : _mumble(mumble_value) {}
};
// 实现纯虚析构函数
Abstract_base::~Abstract_base() {}
class Concrete_derived : public Abstract_base {
public:
   Concrete_derived(const char* mumble_value):
    Abstract_base(const_cast<char*>(mumble_value)){}
   void interface() const override{
        std::cout << "Concrete_derived::interface()" << std::endl;
    }
};
void foo() {
    // 创建 Concrete_derived 对象,传入初始值
    Concrete_derived trouble("Hello, World!");
    std::cout << trouble.mumble() << std::endl; // 输出 "Hello, World!"
}

Abstract_base 中的成员变量 _mumble 是一个指针,如果不在构造函数中初始化它,那么在创建派生类对象时,_mumble 将指向不确定的位置,这可能导致未定义行为。

解决:提供一个带参数的构造函数来初始化 _mumble,确保每个派生类在构造时都能正确设置 _mumble。派生类的构造函数需要调用基类的构造函数来初始化 _mumble。这样可以确保 _mumble 在派生类对象创建时已经被正确初始化。

纯虚函数的存在(Presence of a Pure Virtual Function)
class Abstract_base {
public:
    virtual ~Abstract_base() = 0; // 纯虚析构函数
    virtual void interface() const = 0; // 纯虚函数
protected:
    char* _mumble;
    // 提供一个带参数的构造函数来初始化 _mumble
    Abstract_base(char* mumble_value = nullptr) : _mumble(mumble_value) {}
};

//实现纯虚析构函数
Abstract_base::~Abstract_base() {}
//定义纯虚函数
inline void Abstract_base::interface() const {
    //纯虚函数的具体实现
    std::cout << "Abstract_base::interface()" << std::endl;
}
class Concrete_derived : public Abstract_base {
public:
    Concrete_derived(const char* mumble_value) : Abstract_base(const_cast<char*>(mumble_value)) {}
    void interface() const override {
        // 静态调用纯虚函数
        Abstract_base::interface();
        std::cout << "Concrete_derived::interface()" << std::endl;
    }
};
void foo() {
    //创建 Concrete_derived 对象,传入初始值
    Concrete_derived trouble("Hello, World!");
    trouble.interface(); //调用interface方法
}

纯虚函数的存在

定义:纯虚函数通常用于定义一个接口,要求派生类必须实现该函数。

调用:尽管纯虚函数不能通过虚拟机制调用,但可以在派生类中静态地调用基类的纯虚函数实现。

静态调用纯虚函数:

合法性:在派生类中,可以通过 BaseClass::function() 的方式静态调用基类的纯虚函数。

为什么纯虚函数可以有具体实现?

根据 C++ 标准,纯虚函数可以有具体的实现。声明为纯虚函数的主要目的是强制派生类实现该函数,但并不禁止基类提供一个默认实现。纯虚函数的声明形式为 virtual void func() = 0;但这并不妨碍在类体外提供一个实现。提供纯虚函数的具体实现可以增加设计的灵活性。基类可以提供一个默认行为,派生类可以选择覆盖这个行为或使用默认实现。

纯虚析构函数:

必要性:纯虚析构函数必须提供一个定义。这是因为派生类的析构函数会被编译器扩展,以静态调用的方式调用其每一个虚基类和上一层基类的析构函数。

示例:Abstract_base 的纯虚析构函数必须提供一个定义。

virtual ~Abstract_base() = 0;
Abstract_base::~Abstract_base() {}

为什么纯虚析构函数必须有定义:

编译器会在派生类的析构函数中插入对基类析构函数的调用。如果基类的析构函数没有定义,会导致链接失败。

替代方案:

不要把虚析构函数声明为纯虚:一个更好的设计是将虚析构函数声明为普通虚函数,并提供一个默认实现。

class Abstract_base {
public:
    virtual ~Abstract_base() {} // 普通虚析构函数
    virtual void interface() const = 0; // 纯虚函数
};
虚拟规格的存在(Presence of a Virtual Specification)

虚函数允许在派生类中重写基类中的函数,从而实现多态性。

例如,Abstract_base::mumble() 被声明为虚函数:

virtual const char* mumble() const;

如果 mumble() 的实现与类型无关,那么将其声明为虚函数是没有必要的。

类型无关(type-independent)是指函数的行为和结果不依赖于具体对象的类型。

无论对象属于哪个具体的派生类,函数的行为和返回结果都是相同的。在这种情况下,将函数声明为虚函数是没有必要的,因为虚函数的主要目的是为了实现多态性,即不同的派生类可以有不同的实现。

虚函数的性能影响

如果 mumble() 是虚函数,那么每次调用都需要通过虚函数表(vtable)查找实际的函数地址,这会引入额外的开销。

编译器优化的局限性

编译器可以通过静态分析确定某个虚函数在类层次结构中只有一个实例。例如,如果 mumble() 在整个类层次结构中只有 Abstract_base 提供了实现,编译器理论上可以将其优化为静态调用。

如果类层次结构中后来加入了新的派生类,并且这些派生类提供了 mumble() 的新实现,那么之前的优化将不再有效。这意味着编译器需要重新编译相关代码,或者生成多个实例(多态实例),并在运行时决定调用哪个实例。如果函数以二进制形式存在于库中,那么编译器很难进行这种优化,因为库中的函数实现是固定的。

设计理念:合理使用虚函数

将所有成员函数都声明为虚函数,然后依赖编译器优化来去除不必要的虚函数调用,并不是一个好的设计理念。这种做法不仅增加了编译器的负担,还可能导致性能下降。

只有在确实需要多态性的情况下,才应该将函数声明为虚函数。

例如,如果一个函数的行为在派生类中会有显著不同,那么将其声明为虚函数是有意义的。

虚拟规格中 const 的存在

const 修饰符用于声明一个成员函数不会修改对象的状态。声明为 const 的函数可以被 const 对象、const 引用和 const 指针调用。如果一个函数没有声明为 const,则不能被 const 对象、const 引用或 const 指针调用。但是在设计抽象基类时,需要考虑子类实例可能被使用的各种情况。一个 const 函数可能会被频繁调用,而且可能被 const 对象调用。如果一个函数被声明为 const,但后来发现子类实例需要修改某个数据成员,这就成为一个问题。修改数据成员的操作违反了 const 修饰符的语义,会导致编译错误或需要使用 mutable 关键字。

重新考虑 class 的声明

经过上面讨论,重新设计class。

class Abstract_base(
public:
  virtual ~Abstract_base(); 
  // 译注:不再是 pure virtual
  virtual void interface()= 0; 
   // 译注:不再是 const
   const char* mumble() const { return _mumble;} //译注:不再是 virtual
protected:
   Abstract_base( char *pc-0); // 新增一个带有唯一参数的 constructor
   char*_mumble;
);
无继承情况下的对象构造
typedef struct {
    float x, y, z;
} Point;
Point global;
Point foobar() {
    Point local;
    Point *heap = new Point;
    *heap = local; // 潜在的未初始化问题
    // ... stuff ...
    delete heap;
    return local;
}
int main() {
    Point p = foobar();
    return 0;
}

对象的构造和生命周期

全局对象 (global)

Point global;

Point 的 动生成的默认构造函数和析构函数会被调用。由于 Point 被标记为 Plain Old Data (POD),编译器不会生成或调用这些 trivial 成员函数。全局对象的行为类似于 C 语言中的行为。

C 语言中的行为:

全局对象可以多次定义,链接器会折叠这些定义,只保留一个实例(强符号弱符号)。这个实例被放在 BSS 段中,BSS 段是用于存放未初始化的全局对象的空间。

局部对象 (local)

Point local;

Point的默认构造函数在 local 定义时被调用。默认析构函数在 local 离开作用域时被调用。

由于 Point 是 POD,编译器不会生成或调用这些 trivial 成员函数(这些函数包括默认构造函数、析构函数、复制构造函数和复制赋值操作符)。局部对象的行为类似于C语言中的行为。

如果 local 没有被初始化,可能出现野指针(*heap = local)

堆对象

Point *heap = new Point;
*heap = local;
delete heap;

new Point 会调用 new 运算符,分配内存并返回一个指针。默认构造函数会在 new 运算符返回的 Point 对象上被调用。*heap = local; 会触发默认拷贝构造函数,但实际上只是位搬移操作。delete heap; 会调用 delete 运算符,释放内存。默认的析构函数会在 delete 运算符调用时被调用。

由于 Point 是 POD,编译器不会生成或调用这些 trivial 成员函数。堆对象的行为类似于 C 语言中的行为。

POD 类型

C++ 中的 POD 类型是指那些没有用户定义的构造函数、析构函数、复制构造函数、复制赋值操作符、虚函数等的简单类型。对于 POD 类型,编译器不会生成或调用这些 trivial 成员函数,而是直接进行位搬移操作。

抽象数据类型(Abstract Data Type)

Point 类的声明

class Point {
public:
    Point(float x = 0.0, float y = 0.0, float z = 0.0) : _x(x), _y(y), _z(z) {}
private:
    float _x, _y,_z;
};

Point类有三个私有成员变量 _x _y 和 _z,定义一个带有默认参数的构造函数,用于初始化Point对象。

默认构造函数的行为

当一个Point对象被声明但未显式初始化时,如全局变量Point global;,默认构造函数会被调用,初始化所有成员变量为0.0(默认构造函数是 Point,构造函数是一个带默认参数的构造函数,可以作为默认构造函数使用)。对于全局变量,其初始化会在程序启动时发生。

显式初始化列表 vs. 构造函数内联扩展

class Point {
public:
    Point(float x=0,float y=0,float z=0): _x(x),_y(y), _z(z){}//显式初始化列表
    Point() {
        _x = 0.0;
        _y = 0.0;
        _z = 0.0;
    } // 构造函数内联扩展
private:
    float _x, _y, _z;
};
void mumble() {
    Point locall(1.0, 1.0, 1.0);  // 使用显式初始化列表
    Point local2;  // 使用构造函数内联扩展
}

使用显式初始化列表(如Point locall(1.0, 1.0, 1.0);)通常比通过构造函数内联扩展(如Point local2;后手动设置成员变量)更高效,因为初始化值可以直接写入对象的内存中,避免了额外的赋值操作。由于需要手动编写,如果忘记初始化某个成员变量,或者初始化顺序错误,可能会导致未定义行为或错误。

编译器优化

编译器可以识别内联构造函数,并将其优化为类似显式初始化列表的方式处理,即直接将常量值写入对象的内存中,而不是生成一系列赋值指令。

复制构造函数和赋值运算符

Point类没有显式定义复制构造函数和赋值运算符,这意味着编译器会提供默认的位复制(bitwise copy),这对于包含基本类型成员的类来说通常是足够的。

为继承做准备
class Point {
public:
    Point(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}
    // 虚拟函数
    virtual float z();
    // 没有定义拷贝构造函数、拷贝赋值运算符或析构函数
    // 因为默认的位语义已经足够
protected:
    float _x, _y;
};

引入虚拟函数是为了支持继承和多态。这样,派生类可以重写这些虚拟函数,实现不同的行为。动态决议:虚拟函数允许在运行时动态地决定调用哪个函数,这是多态的基础。

虚函数的代价

虚拟表指针(vptr):每个对象都会有一个虚拟表指针,会增加对象的大小。如每个 Point 对象会多一个指针的大小(通常是4字节)。

构造函数的膨胀:构造函数需要额外的代码来初始化虚拟表指针。

合成的拷贝构造函数和拷贝赋值运算符:编译器会合成这些函数,因为默认的位复制操作可能对虚拟表指针带来非法设定。

构造函数的内部膨胀

Point* Point::Point(Point* this, float x, float y) : _x(x), _y(y) {
    // 设置对象的虚拟表指针(vptr)
    this->__vptr_Point = _vtbl_Point;
    // 扩展成员初始化列表
    this->_x = x;
    this->_y = y;
    // 返回 this 对象
    return this;
}

拷贝构造函数的内部合成

inline Point* Point::Point(Point* this, const Point& rhs) {
    // 设置对象的虚拟表指针(vptr)
    this->__vptr_Point = _vtbl_Point;
    // 将 rhs 坐标中的连续位拷贝到 this 对象
    this->_x = rhs._x;
    this->_y = rhs._y;
    // 返回 this 对象
    return this;
}

(1) Point global;
(2)
(3) Point foobar() {
(4)     Point local;
(5)     Point *heap = new Point;
(6)     *heap = local;
(7)     // ... stuff ...
(8)     delete heap;
(9)     return local;
(10) }

全局对象初始化:

Point global;

全局对象 global 在程序启动时被初始化,调用默认构造函数。

局部对象初始化:

Point local;

局部对象 local 在函数 foobar 中被初始化,调用默认构造函数。

堆对象初始化:

Point *heap = new Point;

堆对象 heap 被动态创建,调用默认构造函数。

成员赋值:

*heap = local;

这里会触发拷贝赋值运算符的合成,将 local 的值复制到 heap 指向的对象中。

删除堆对象:

delete heap;

删除堆对象 heap,调用默认析构函数。

按值返回局部对象:

return local;

返回局部对象 local 时,会触发拷贝构造函数的合成,将 local 的值复制到返回值中。

NRV 优化

如果编译器支持命名返回值优化(Named Return Value, NRV),函数 foobar 会被优化为:

// C++ 伪码:foobar()的转化,以支持 NRV 优化

Point foobar(Point& __result) {
    __result.Point::Point(0.0, 0.0);
    // heap 的部分与前相同……
    return;
}

NRV 优化编译器会直接在返回值的位置构造对象,避免了额外的拷贝操作。

继承体系下的对象构造

当定义一个对象 T object; 时,构造函数会被调用。

构造函数调用步骤:

(1)成员初始化列表

成员初始化列表中指定的成员变量会被初始化,并按照成员变量在类中声明的顺序进行。如果成员变量没有在初始化列表中出现,但有默认构造函数,则默认构造函数会被调用。

(2)虚指针初始化

如果类有虚拟表指针(vptr),它必须被初始化,指向适当的虚拟表(vtable)。

(3)基类构造函数调用

所有基类的构造函数必须按基类声明的顺序被调用。如果基类在成员初始化列表中列出,显式指定的参数会传递给基类构造函数。如果基类没有在成员初始化列表中列出,但有默认构造函数,则默认构造函数会被调用。

虚基类构造函数调用

所有虚基类的构造函数必须按从左到右、从最深到最浅的顺序被调用。如果虚基类在成员初始化列表中列出,显式指定的参数会传递给虚基类构造函数。如果虚基类没有在成员初始化列表中列出,但有默认构造函数,则默认构造函数会被调用。虚基类子对象的偏移位置必须在运行时可访问。

class Point {
public:
    Point(float x = 0.0, float y = 0.0);
    Point(const Point& other); // 拷贝构造函数
    Point& operator=(const Point& other); // 拷贝赋值运算符
    virtual ~Point(); // 虚析构函数
    virtual float z() { return 0.0; }
protected:
    float _x, _y;
};

Line 类

class Line {
    Point _begin, _end;
public:
    Line(float x1 = 0.0, float y1 = 0.0, float x2 = 0.0, float y2 = 0.0);
    Line(const Point& begin, const Point& end);
    void draw();
    // ...
};

Line 类的构造函数

Line::Line(const Point& begin, const Point& end)
:_end(end), _begin(begin){}

隐式合成的构造函数和析构函数

编译器会将上述构造函数扩充为:

Line* Line::Line(Line* this, const Point& begin, const Point& end) {
    this->_begin.Point::Point(begin);
    this->_end.Point::Point(end);
    return this;
}

隐式合成的析构函数:

// C++ 伪码:合成出来的 Line destructor
inline void Line::~Line(Line* this) {
    this->_end.Point::~Point();
    this->_begin.Point::~Point();
}

隐式合成的拷贝构造函数/拷贝赋值运算符:

inline Line& Line::operator=(Line& this, const Line& rhs) {
    this->_begin.Point::operator=(rhs._begin);
    this->_end.Point::operator=(rhs._end);
    return *this;
}

自我赋值检查

在用户提供的拷贝赋值运算符中,忘记检查自我赋值(self-assignment)是一个常见的错误。

String& String::operator=(const String& rhs) {
    if (this == &rhs) return *this; // 检查自我赋值
    delete[] str; // 释放旧资源
    str = new char[strlen(rhs.str) + 1]; // 分配新资源
    strcpy(str, rhs.str); // 拷贝字符串
    return *this;
}

有的编译器在生成拷贝赋值运算符时不会自动添加自我赋值检查。

// 编译器生成的拷贝赋值运算符

inline Line& Line::operator=(Line& this, const Line& rhs) {
    this->_begin.Point::operator=(rhs._begin);
    this->_end.Point::operator=(rhs._end);
    return *this;
}
虚拟继承(Virtual Inheritance)

虚继承用于解决多重继承中基类被多次实例化的问题的一种机制。当一个类通过多个路径从同一个基类派生时,使用虚拟继承可以确保这个基类只被实例化一次,从而避免了所谓的菱形问题。

错误的构造函数扩充内容:原始提供的Point3d构造函数的伪代码,并指出其中的错误。

Point3d* Point3d::Point3d(Point3d *this, float x, float y, float z) {
    this->Point::Point(x, y);  // 错误:直接调用了Point的构造函数
    this->__vptr_Point3d = _vtbl_Point3d; // 错误:直接修改了vptr
    this->__vptr_Point3d__Point = _vtbl_Point3d__Point; //错误:直接修改了vptr
    this->z = rhs._z; // 错误:这里应该是设置_z成员变量
    return this;
}

直接调用了Point的构造函数:这在虚拟继承的情况下是不允许的,因为最派生类需要负责调用虚拟基类的构造函数。

直接修改了vptr:这是不安全的操作,通常应该由编译器自动处理。

错误地设置了z成员变量:这里应该是this->_z = z;而不是this->z = rhs._z;。

正确的构造函数扩充内容

为了正确处理虚拟继承,我们需要引入一个布尔参数_most_derived来指示当前是否是最派生类。如果是,那么才调用虚拟基类的构造函数。

// 在 virtual base class 情况下的 constructor 扩充内容
Point3d* Point3d::Point3d(Point3d *this, bool _most_derived, float x, float y, float z){
   if(_most_derived){
       this->Point::Point(x, y); // 如果是最派生类,调用Point的构造函数
   }
   this->__vptr_Point3d = _vtbl_Point3d; // 编译器会处理vptr的设置
   this->__vptr_Point3d__Point = _vtbl_Point3d__Point; // 编译器会处理vptr的设置
   this->_z = z; // 正确设置_z成员变量
   return this;
}

在更深层的继承情况下,例如Vertex3d,需要确保在调用基类的构造函数时传递_most_derived参数为false,以避免重复调用虚拟基类的构造函数。

// 在 virtual base class 情况下的 constructor 扩充内容

Vertex3d* Vertex3d::Vertex3d(Vertex3d *this, bool _most_derived, float x, float y, float z) {
    if (_most_derived) {
        this->Point::Point(x, y); // 如果是最派生类,调用Point的构造函数
    }
    this->Point3d::Point3d(false, x, y, z); // 调用Point3d的构造函数,传递false
    this->Vertex::Vertex(false, x, y); // 调用Vertex的构造函数,传递false
    this->__vptr_Vertex3d = _vtbl_Vertex3d; // 编译器会处理vptr的设置
    this->__vptr_Vertex3d__Point = _vtbl_Vertex3d__Point; // 编译器会处理vptr的设置
    this->__vptr_Vertex3d__Point3d = _vtbl_Vertex3d__Point3d; // 编译器会处理vptr的设置
    this->__vptr_Vertex3d__Vertex = _vtbl_Vertex3d__Vertex; // 编译器会处理vptr的设置
    return this;
}

构造函数的分裂

某些现代编译器会将构造函数分裂为两个版本:

完整对象版本:无条件地调用虚拟基类的构造函数,设置所有vptr。

子对象版本:不调用虚拟基类的构造函数,可能也不设置vptr。

这种分裂可以提高程序的效率,因为避免了不必要的条件判断和vptr设置。

vptr 初始化语意学(The Semantics of the vptr Initialization)

当定义一个派生类对象时,构造函数的调用顺序是从基类到派生类。每个类的构造函数都会调用其基类的构造函数,直到最顶层的基类构造函数被执行。

每个构造函数都可以调用虚函数。这些虚函数调用会被解析为当前正在构造的类的虚函数版本,而不是最终派生类的版本。当基类构造函数正在执行时,派生类的部分还未被构造,因此虚函数表(vtable)还不能指向派生类的虚函数实现。

vptr初始化的时机

在任何操作之前 - 如果vptr在构造函数的任何操作之前被初始化,那么当构造函数尝试调用虚函数时,vptr已经指向了正确的虚函数表。但是,这样做可能会导致在构造函数体中调用的成员初始化列表中的虚函数调用出现问题,因为那时vptr可能还没有被正确设置。

在基类构造函数调用之后,但在成员初始化列表之前,这是最常用的策略当基类构造函数执行完毕后,vptr被设置为指向当前类的虚函数表。这样可以确保在成员初始化列表中调用的虚函数是正确的。

如果vptr在构造函数的所有操作之后被初始化,那么在构造函数中调用的任何虚函数都将不会被视为虚函数,而是静态绑定的。这显然不是我们想要的行为。

class Point {
public:
    Point(float x, float y) : _x(x), _y(y) {}
    virtual int size() const { return sizeof(Point); }
private:
    float _x, _y;
};

class Point3d : public Point {
public:
    Point3d(float x, float y, float z) : Point(x, y), _z(z) {
        if (spyOn) cerr << "Within Point3d::Point3d(), size: " << size() << endl;
    }
    virtual int size() const { return sizeof(Point3d); }
private:
    float _z;
};

class Vertex : public Point3d {
public:
    Vertex(float x, float y, float z) : Point3d(x, y, z) {}
    virtual int size() const { return sizeof(Vertex); }
};

class Vertex3d : public Vertex {
public:
    Vertex3d(float x, float y, float z) : Vertex(x, y, z) {}
    virtual int size() const { return sizeof(Vertex3d); }
};

class PVertex : public Vertex3d {
public:
    PVertex(float x, float y, float z) : Vertex3d(x, y, z), _next(0) {
        if (spyOn) cerr << "Within PVertex::PVertex(), size: " << size() << endl;
    }
    virtual int size() const { return sizeof(PVertex); }
private:
    PVertex* _next;
};

bool spyOn = true; // 用于调试输出

构造函数调用顺序

Point 构造函数被调用。Point3d 构造函数被调用,Point 的部分已经构造完毕。

Vertex构造函数被调用,Point3d 的部分已经构造完毕。Vertex3d 构造函数被调用,Vertex 的部分已经构造完毕。PVertex 构造函数被调用,Vertex3d 的部分已经构造完毕。

vptr的初始化

在每个构造函数中,vptr会在基类构造函数调用之后被初始化,以确保虚函数调用的正确性。例如,在Point3d构造函数中,vptr会被设置为指向Point3d的虚函数表,因此调用size()时会返回Point3d的大小。

在构造函数的成员初始化列表中调用虚函数:这是安全的,因为vptr已经在成员初始化列表之前被正确设置。

在构造函数体中调用虚函数:这是安全的,但需要注意虚函数可能依赖于尚未初始化的成员变量。

(留着以后看)

对象复制语意学(Object Copy Semantics)

在C++中,当我们设计一个类并进行对象赋值时,有三种选择:

(1)什么都不做:使用默认行为。

(2)提供一个显式的复制赋值操作符。

(3)显式地拒绝将一个类对象赋值给另一个类对象。

如果要禁止将一个类对象赋值给另一个类对象,可以通过将复制赋值操作符声明为私有(private),并且不提供其定义。这样除了类的成员函数和友元之外,其他地方都不能进行赋值操作。如果某个成员函数或友元试图进行赋值操作,程序在链接时会失败。

如果类中没有虚函数、虚基类、具有复制赋值操作符的成员对象或基类,那么默认的复制赋值操作符会执行逐成员复制(memberwise copy)。对于简单的类(如Point),默认行为通常是足够的且效率高。

class Point {
public:
    Point(float x = 0.0, float y = 0.0) : _x(x), _y(y) {}
    // 默认的复制赋值操作符
private:
    float _x, _y;
};

自定义复制赋值操作符

只有在默认行为导致不安全或不正确的行为时,才需要提供自定义的复制赋值操作符。例如,如果类中包含动态分配的资源(如指针),默认的浅复制可能导致资源泄露或双重释放问题。

class Point {
public:
    Point(float x = 0.0, float y = 0.0) :_x(x), _y(y) {}
    Point& operator=(const Point& p) {
        if (this != &p) { // 防止自赋值
            _x = p._x; _y = p._y;
        }
        return *this;
    }
private:
    float _x, _y;
};

继承下的复制赋值操作符

虚拟继承

当类继承自虚基类时,复制赋值操作符的行为变得复杂。编译器会合成一个复制赋值操作符,但可能会导致多次调用虚基类的复制赋值操作符。

class Point3d : virtual public Point {
public:
    Point3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point(x, y), _z(z) {}
    // 编译器合成的复制赋值操作符
private:
    float _z;
};

编译器合成的复制赋值操作符可能这样:

inline Point3d& Point3d::operator=(const Point3d& p) {
    this->Point::operator=(p); // 调用基类的复制赋值操作符
    _z = p._z; // 逐成员复制派生类的成员
    return *this;
}

多重继承

当类从多个基类继承时,复制赋值操作符需要调用所有基类的复制赋值操作符。

class Vertex : virtual public Point {
public:
    Vertex(float x = 0.0, float y = 0.0) : Point(x, y), _next(nullptr) {}
    Vertex& operator=(const Vertex& v) {
        this->Point::operator=(v); //调用基类的复制赋值操作符
        _next = v._next; //逐成员复制派生类的成员
        return *this;
    }
private:
    Vertex* _next;
};

class Vertex3d : public Point3d, public Vertex {
public:
    Vertex3d(float x = 0.0, float y = 0.0, float z = 0.0) : Point3d(x, y, z), Vertex(x, y) {}
    Vertex3d& operator=(const Vertex3d& v) {
        this->Point3d::operator=(v); // 调用 Point3d 的复制赋值操作符
        this->Vertex::operator=(v); // 调用 Vertex 的复制赋值操作符
        return *this;
    }
};

C++中没有类似于构造函数的成员初始化列表的成员赋值列表。因此不能写:

inline Point3d& Point3d::operator=(const Point3d& p3d):
Point(p3d),_z(p3d._z){}

必须写成

inline Point3d& Point3d::operator=(const Point3d& p3d) {
    this->Point::operator=(p3d);
    _z = p3d._z;
    return *this;
}

虚基类的多次调用

在虚拟继承的情况下,编译器可能会多次调用虚基类的复制赋值操作符。为了避免这个问题,可以在派生类的复制赋值操作符中显式调用虚基类的复制赋值操作符。

inline Vertex3d& Vertex3d::operator=(const Vertex3d& v) {
    this->Point3d::operator=(v);
    this->Vertex::operator=(v);
    this->Point::operator=(v); // 显式调用虚基类的复制赋值操作符
    return *this;
}

建议

避免在虚基类中声明数据成员:可以减少复制赋值操作符的复杂性。

显式调用虚基类的复制赋值操作符:确保虚基类的复制赋值操作符只被调用一次。

对象的效能(Object Efficiency)

测试的主要目的是评估不同类声明和继承方式对对象构造和拷贝效率的影响

(1)Plain Old Data (POD) 结构体和公开数据成员的类

struct Point3d { float x, y, z; };
class Point3d { public: float x, y, z; };

初始化和拷贝操作:通过显式的初始化列表进行。

性能:表现最好,因为这些操作是按位复制(bitwise copy),效率最高。

(2)封装数据成员并使用内联构造函数

class Point3d {
public:
    float x, y, z;
    Point3d(float x, float y, float z) : x(x), y(y), z(z) {}
};

初始化和拷贝操作:通过内联构造函数进行。

性能:稍有下降,因为内联构造函数的扩展带来了额外的指令。

(3)单一继承

class Point2d : public Point1d {};
class Point3d : public Point2d {};

性能:基本保持不变,因为仍然支持按位复制语意。

(4)多重继承

class Point1d {};
class Point2d {};
class Point3d : public Point1d, public Point2d {};

性能:基本保持不变,因为仍然支持按位复制语意。

(5)虚拟继承

class Point1d {};
class Point2d : public virtual Point1d {};
class Point3d : public Point2d {};

性能:显著下降,因为虚拟继承不再允许按位复制语意,编译器生成的内联复制构造函数和复制赋值操作符导致了额外的开销。

(6)包含虚函数的类

class Point3d {
public:
    float x, y, z;
    virtual void someFunction() {}
};

性能:下降,因为包含虚函数的类不再支持按位复制语意,编译器生成的内联复制构造函数和复制赋值操作符增加了开销。

结论

(1)按位复制(Bitwise Copy)

当类的数据成员是简单的POD类型且没有虚函数、虚基类或复杂的继承关系时,按位复制是最高效的。

使用公开数据成员的结构体或类,通过显式的初始化列表进行初始化和拷贝操作,性能最佳。

(2)封装和内联构造函数

封装数据成员并使用内联构造函数会导致 slight overhead,因为内联构造函数的扩展带来了额外的指令。尽管有轻微的性能损失,但封装提供了更好的封装性和维护性,值得在大多数情况下使用。

(3)单一继承和多重继承

单一继承和多重继承在不引入虚函数和虚基类的情况下,对性能的影响不大,因为仍然支持按位复制语意。

(4)虚拟继承

虚拟继承显著降低了性能,因为不再支持按位复制语意,编译器生成的内联复制构造函数和复制赋值操作符增加了额外的开销。

虚拟继承适用于需要解决菱形继承问题的场景,但在性能敏感的应用中应谨慎使用。

(5)包含虚函数的类

包含虚函数的类不再支持按位复制语意,编译器生成的内联复制构造函数和复制赋值操作符增加了开销。如果性能是关键因素,应尽量避免在频繁拷贝的对象中使用虚函数。

析构语意学(Semantics of Destruction)

在C++中,类(class)的析构函数(destructor)主要用于清理资源,比如释放动态分配的内存、关闭文件等。析构函数是在对象生命周期结束时自动调用的。

1. 编译器自动生成析构函数的情况

如果一个类没有定义析构函数,并且它的成员对象或基类也没有析构函数,则编译器不会为该类生成析构函数。这是因为在这种情况下,没有特别的清理工作需要做。

成员对象或基类有析构函数:如果类中的某个成员对象或者基类定义了析构函数,那么编译器会为该类生成一个析构函数,这个析构函数会负责调用成员对象的析构函数以及基类的析构函数。

#include <iostream>
class Point {
public:
    Point(float x = 0.0f, float y = 0.0f) : x(x), y(y) {}
    // 没有定义析构函数
private:
    float x, y;
};
class Line {
public:
    Line(const Point& begin, const Point& end) : _begin(begin), _end(end) {}
    // 没有定义析构函数
protected:
    Point _begin, _end; // Point没有析构函数,因此Line也不需要
};
class Vertex {
public:
    ~Vertex() {
        std::cout << "Vertex destructor called" << std::endl;
    }
};

class Vertex3d : public Vertex {
public:
    // 如果不显式定义析构函数,编译器会生成一个默认的析构函数
    // 这个默认的析构函数会调用基类Vertex的析构函数
};
int main() {
    Point pt; // 构造Point对象
    Point* p = new Point3d(); // 动态构造Point3d对象
    // 使用对象
    delete p; // 销毁Point3d对象,调用析构函数
    return 0;
}

当销毁一个派生类的对象时,析构函数的调用顺序是从最派生类到基类,最派生类的析构函数被调用。按照继承层次从下到上,调用每个基类的析构函数。如果存在虚基类,它们的析构函数也会按照适当的顺序被调用。如果一个类设计为基类,特别是当这个基类的指针可能用来指向派生类的对象时,应该将析构函数声明为虚函数。这样做可以确保通过基类指针删除派生类对象时,派生类的析构函数能够正确调用,从而避免资源泄露。

class Base {
public:
    virtual ~Base() { /* 清理工作 */ }
};

class Derived : public Base {
public:
    ~Derived() override { /* 更多的清理工作 */ }
};

在这个例子中,Base 类的析构函数被声明为虚函数,这样当通过 Base* 指针删除 Derived 对象时,Derived 的析构函数会被调用,然后是 Base 的析构函数。

Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐