C++面向对象基础——对象&构造函数&拷贝构造函数

对象模型的探索

占用内存

对象所占用的空间至少1个字节,空类也会占用一个字节,这里根结构体是几乎一样的。类也和结构体一样,异构的数据类型就是会存在数据对齐,即当前位置所在的地址不在该类大小的整数倍则需要向后寻找合适的地址。

如果对象有静态变量,则静态变量不计入对象的空间,因为静态变量不属于对象,属于全体类共有,成员方法同理,也属于类,不会占用对象的空间。

如果有虚函数存在,那么对象会额外存一个虚表指针,又多 4 个字节或者是 8 个字节。

内存分布

我们先来看一个例子。

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
34
35
36
37
38
39
#pragma once
#include<iostream>

class A
{
public:
A() {};
~A() {};
int a;
void TestA() { printf("A:%p\r\n", this); };
private:

};
class B
{
public:
B() {};
~B() {};
int b;
void TestB() { printf("B:%p\r\n", this); };
private:

};
class C:public A,public B
{
public:
C() {};
~C() {};
int c;
void TestC() { printf("C:%p\r\n", this); };
private:

};
int main() {
C obj;
obj.TestA();
obj.TestB();
obj.TestC();
}

运行结果

结果分析

我们可以发现,C类继承了 A,B两个类,输出 this 指针的地址时候,用 TestA 输出的地址与 TestC 输出的地址是一致的TestB输出的地址和 A,C 刚好差了 4。

先给出结论:派生类的对象,包含基类的子对象,如果派生类是单继承,那么派生类的对象和基类的子对象一致,指向同一空间。如果是派生类多继承,那么派生类对象和最左侧继承的基类的子对象地址一致。

我们可以理解一下原理:对于 A 类来说,它是基类,有一个成员 a 在它的地址空间头四个字节,这肯定没话说,而且需要在自己 +0 的地址偏移上面。B 类同理,但是因为后被继承,所以对于 C 类实例化的对象来说,成员 a 在前面,b 在 a 之后,而对于 A 实例化的对象和 B 实例化的对象来说,肯定都是在自己(this)偏移 0 的位置处,因此我们在调用 A 类的成员函数时,this 会指向 a 所在的位置,调用 B 类的成员函数时,this 指向 b 所在的位置,a 和 b 差一个 sizeof(A)==4。然后派生类对基类是包含的关系的,所以派生类的 this 要能访问前面说到的两个对象,那么和 A 重合是最好的,此时 C 自己定义的成员变量在 this 往后偏移 sizeof(A)+sizeof(B)=8 处。

所以我们在调用继承的函数时,为了找到指定的成员,我们需要把 this 适当地调整位置。比如我们实例化了一个 B 类,那么我调用 this->B 的时候肯定找的就是 this 的0偏移位置处,这个函数,不能说我继承出去了去调用这个函数同样访问 this->B 就得到偏移 4 的位置处吧,这显然不合理,所以我们只能把 this 移动到 b 的位置处保证它函数不管是它自己对象执行还是继承出去过来执行访问的成员都是正确的。

但是当我们重写了基类函数的时候,this 指针会调整过来,因为是派生类,所以我们在 访问 this->b 的时候会访问偏移 4 的位置处。

构造函数

我们知道类定义的时候会有默认构造,默认析构和默认拷贝函数,需要才有,并不是必然的!如果在编译之前能确定构造函数一定会被调用则才有默认构造,否则就不会有。

这里做个实验就是使用 VS 自带的 dumpbin 去 dump 它的中间文件(.obj),也就是刚做完汇编那一步产生的文件。

然后使用命令

1
$dumpbin /ALL xxx.obj 

我们发现根本找不到默认构造函数,说明在编译过程中没有产生默认构造函数。

而我们在成员中有其它类的情况,因为生成这个对象需要调用这个类的构造函数,所以需要有默认构造来调用生成这个类,因此此时我们就会生成默认构造函数。

所以我们对 C 类重新定义,添加成员 A 类我们再看看效果。

变得可以找到,sym表示了它函数的原始名称,用于调试的时候使用,后面的一堆乱码是它被粉碎之后的名称。

总结来说:只有确定构造函数一定需要的才会出现默认构造函数,否则没有(当然前提我们没有定义和声明任何构造函数的情况下)。

拷贝构造函数

默认拷贝构造函数

默认构造拷贝函数也是需要才有,并不是一定有。如果对象的成员都为基础类型,而不是复杂类型,那么编译器对它可以直接进行简单的赋值拷贝,就不需要默认拷贝构造函数。并且这个过程中,编译器很聪明,会递归复杂类,如果复杂类是简单类组成的那么同样不需要默认的拷贝构造函数,但是它可能只会递归一层,具体看编译器。

包含的类如果存在拷贝构造函数,或者说父类存在拷贝构造函数,为了调用这个拷贝构造,也会给当前类也生成一个拷贝构造函数,但是如果没有拷贝过,则也不会生成,因为编译器认为用不到。

如果存在虚函数虚函数表,则为了拷贝虚函数表,我们要定义默认的拷贝构造函数。

如果继承的时候存在虚基类的话,则也会生成默认的拷贝构造函数。

总结

以下情况一定有拷贝构造函数

  • 父类如果有拷贝构造,则子类必定有拷贝构造。
  • 若包含了一个带有拷贝构造的类作为成员,则也可能会有拷贝构造,这要看当前的代码中有无拷贝这个类的操作。
  • 如果有虚函数则一定有拷贝构造
  • 如果有虚基类继承则也一定有拷贝构造
  • 如果成员有复杂类那么也会有拷贝构造

什么时候被调用

函数传参需要调用拷贝构造函数。

然后如果返回值为一个类对象的时候,它相当于在函数中多了一个引用参数,class_name &temp,然后调用了,拷贝构造函数把返回值给了 temp,然后返回之前调用析构函数把里面产生的类析构了。所以如果返回为一个对象,则会在函数内部调用一次拷贝构造。

匿名对象:指函数返回对象时,没有用变量或者是指针保存,那么这个对象在用完就会马上释放。