类对象模型
计算类的大小
类里面又能写变量又能写函数,那么一个类的大小该如何计算呢?
class Person { public: void Init() { } private: char _name[20]; char _gender[3]; int _age; }; int main() { cout << sizeof(Person) << endl; return 0; }
这样一个类的大小是多少呢?如果只有成员变量占空间,那么大小就是28(类也是需要内存对齐的),如果成员函数也要占空间那么就是32(28+4,函数中有多条指令,一般都是存储函数的地址)。事实胜于雄辩,运行一下就能知道结果:
发现,这个类的大小是28也就是说成员函数并没有占空间。其实在设计类存储时有三个方案:
类对象的存储方式
方式一:类中又放成员变量又放成员函数
这种就是将成员变量和成员方法都放在类的空间中,这是最容易想到也是最容易理解的方案,但是这样写的话空间的损失就太大了,虽然函数经过编译后变为一道指令存放在代码段中,通常将函数的第一条指令的地址作为函数的地址,类中也存放的是这个地址,不过一个函数四个字节大小,但是每个对象的变量是不同的,如果按照这种方式存储的话,每实例化一个对象就要保存一份代码,空间损失太大。我想你作为用户来说,肯定也是希望一个应用在保证功能的前提下越小越好,所以这种方案没有被采纳。
方案二:类中放成员变量,找一块区域存放成员函数,并把这个区域的地址存放到类中,可以通过这个区域找到函数。
这种方式在我们看来已经相当不错了,不用再将每个函数的地址都单独存储起来,除了成员变量以外只是多存放了一个地址不过是四个字节而已。但是这种方式在大佬看来还是不够完美,所以还有第三种方法。
方案三:类中只放成员变量,也不放任何地址,将成员函数放到公共代码段,由编译器去查找
【补充】
有没有想过一个空类的大小是多少?空类的大小是零吗?
class A { }; int main() { cout << sizeof(a) << endl; return 0; }
运行结果表示空类的大小并不是零字节,而是用一个字节的大小来占位。其实这也是可以预料到的,毕竟如果空类是零字节的话,实例化出来的对象就无法分辨了。其实主要原因是,C++有默认的成员函数,就算我们不写编译器也会自动生成,这个后面会提到。
【结构体内存对齐规则】
- 第一个成员在与结构体偏移量为0的地址处。
- . 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。VS中默认的对齐数为8- . 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
- . 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
【几个问题】
- 结构体怎么对齐? 为什么要进行内存对齐?
解答:结构体的对齐规则在前面已经说过了。内存对齐明明会造成空间浪费,那么为什么还存在内存对齐?主要有以下几个原因:
1.平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据的;某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。
2.性能原因:数据结构(尤其是栈)应该尽可能地在自然边界上对齐。 原因在于:为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。所以内存对齐能够提高访问效率。
大家都知道,我们的机器分为32位机器和64位机器,这里的32位和64位其实指的是CPU的位数,而CPU的位数对应着CPU的字长,而字长又决定着CPU读取数据时一次访问多大即空间,即一次读取几个字节,我们以32位机器为例:
总体来说:结构体的内存对齐是拿空间来换取时间的做法
- 如何让结构体按照指定的对齐参数进行对齐?能否按照3、4、5即任意字节对齐?
解答:我们可以使用 “#pragma pack(num)” 命令来修改VS中的默认对齐数,使用该命令可以将对齐数修改为0以后的任意值。
- 什么是大小端?如何测试某台机器是大端还是小端,有没有遇到过要考虑大小端的场景
解答:大小端是机器针对非单字节的一种存储方式,大端存储是将数据的高位存储在内存的低地址处,小端存储是将数据的低位存储在内存的低地址处。测试机器是大端还是小端只需要取第一个字节就可以判断。
this指针
我们已经知道在类的存储方式上编译器选择了方案三,也就是说我们无论实例化多少个对象,这些个对象用的都是同一份函数。那么问题又来了,既然用的是同一个函数,而且我们也并没有将对象的地址传给函数,函数中也并没有区分对象的方法,那为什么却能输出出不同的结果呢?
class Date { public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << " " << endl; } private: int _year; int _month; int _day; }; int main() { Date d1, d2; d1.Init(2022, 10, 28); d2.Init(2023, 1, 23); d1.Print(); d2.Print(); return 0; }
其实C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成
也就是说上述代码其实长这样:
class Date { public: void Init(Date *const this,int year, int month, int day) { this->_year = year; this->_month = month; this->_day = day; } void Print() { cout << this->_year << "-" << this->_month << "-" << this->_day << " " << endl; } private: int _year; int _month; int _day; };
但是 this 指针参数以及对象的地址都是由编译器自动传递的,当用户主动传递时编译器会报错;不过在成员函数内部我们是可以显示的去使用 this 指针的:
this指针的特性
this指针有如下一些特性:
1.this 指针只能在 “成员函数” 的内部使用;
2.this 指针使用 const 修饰,且 const 位于指针*的后面;即 this 本身不能被修改,但可以修改其指向的对象 (我们可以通过 this 指针修改成员变量的值,但不能让 this 指向其他对象)
3.this 指针本质上是“成员函数”的一个形参,当对象调用成员函数时,将对象地址作为实参传递给 this 形参,所以对象中不存储this 指针;
4.this 指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过建立“成员函数”的函数栈帧时压栈传递,不需要用户主动传递。(注:由于this指针在成员函数中需要被频繁调用,所以VS对其进行了优化,由编译器通过ecx寄存器传递)
相关面试题
1.this指针存在哪里?
解答:this 指针作为函数形参,存在于函数的栈帧中,而函数栈帧在栈区上开辟空间,所以 this 指针存在于栈区上;不过VS这个编译器对 this 指针进行了优化,使用 ecx 寄存器保存 this 指针
2.this指针可以为空吗 ?
解答:this指针作为参数传递时是可以为空的,但是如果成员函数用到了this指针,可能会造成空指针的解引用。
3.下面两段代码的运行结果分别是什么?
//下面两段代码编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A //代码1 { public: void Print() { cout << "Print()" << endl; } private: int _a; }; int main() { A* p = nullptr; p->Print(); return 0; } //***********************************// class A //代码2 { public: void PrintA() { cout << _a << endl; } private: int _a; }; int main() { A* p = nullptr; p->PrintA(); return 0; }
解答:代码1可以正常运行,因为虽然我们用空指针A访问了成员函数Print,但是由于成员函数并不存在于对象中,而是存在于代码段中,所以编译器并不会通过类对象p去访问成员函数,即并不会对p进行解引用。而this指针作为参数传递时是允许为空的,在成员函数里也没有对this指针进行解引用。
代码2运行崩溃,因为在成员函数中对this指针进行解引用了,而this指针是一个空指针。
默认成员函数
如果类中什么成员也不写,就称之为空类,空类中真的什么都没有吗?其实并不是,任何类在什么都不写的情况下编译器会自动生成六个默认成员函数。(默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数 )
下面将对这六个默认成员函数进行讲解。
构造函数
基础知识
构造函数是一个特殊的成员函数,名字与类名相同并且不需要写返回类型。构造函数并不需要用户自己调用,而是在创建类类型对象后由编译器自动调用,并且在对象生命周期内只能调用一次。(注意:构造函数虽然叫构造,但它并不是用来给对象开辟空间的,而是在对象实例化以后,给对象初始化用的,相当于Init函数)。
【构造函数的特性】
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对应的构造函数。
- 构造函数可以重载也支持缺省参数
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;
- 构造函数对内置类型不处理,对自定义类型调用自定义类型自身的默认构造;
- 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个
以日期类来讲解构造函数的一些特性:
class Date { public: Date()//无参构造函数 { } Date(int year, int month=2,int day=3) { _year = year; _month = month; _day = day; } void Print() { cout << _year << "-" << _month << "-" << _day << " " << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; //d1.Print(); Date d2(1949, 1, 03); d2.Print(); return 0; }
编译成功,也就是说构造函数是支持重载和缺省参数的。但是有一点需要注意的是,当构造函数是无参时,对象后面不要跟括号,否则会产生二义性,也就是说编译器无法确定这个是函数声明还是无参的构造函数。
虽然说构造函数支持重载,但一般只需要显示定义一个全缺省的构造函数即可(选择缺省是因为这个比较灵活有多种调用方式)。
自动生成
构造函数第五点特性提到,如果没有显示定义构造函数,编译器就会自动生成一个无参的默认构造函数。
可以看到,我们不写编译器确实会有一个构造函数来初始化,不过这个初始化出来的数太随机值了,看起来就像乱码一样。这是为什么?这就要用构造函数的第六个特性来解释了;
选择处理
C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char…,自定义类型就是我们使用class/struct/union等自己定义的类型 ,**构造函数对内置类型并不处理,而面对自定义类型则会调用自定义类型的构造函数。**解下来看这样一段代码:
class Stack { public: Stack(int capacity = 4) { _a = (int*)malloc(sizeof(int) * capacity); if (_a == nullptr) { perror("malloc fail\n"); exit(-1); } _top = 0; _capacity = capacity; cout << "Stack 构造" << endl; } void Push(int x) { _a[_top++] = x; } private: int* _a; int _top; int _capacity; }; class MyQueue { public: void Push() { _pushST.Push(2); } private: Stack _pushST; Stack _popST; }; class Date { public: void Print() { cout << _year << "-" << _month << "-" << _day << " " << endl; } private: int _year; int _month; int _day; }; int main() { Date d1; d1.Print(); //Date d2(1949, 1, 03); //d2.Print(); Stack st1; MyQueue q; return 0; }
日期类都是内置类型,不处理:
Stack栈都是内置类型,但我写了构造函数,所以可以将栈初始化我想要的结果:
MyQueue虽然没写构造函数,但是MyQueue都是自定义类型,会去调用Stack的构造函数,而我写了Stack的构造函数:
其实MyQueue不写构造函数,然后Stack也不写构造函数,最后MyQueue得到的还是随机值,因为最后还是全部都是由内置类型组成的。只要有内置类型就是要写构造函数的。 这样很麻烦,所以到了C++11的时候,大佬们针对这个问题又打了一个补丁,也就是说在声明内置类型的时候可以给一个缺省值。
这个缺省值功能可以说十分强大,甚至可以给定一块空间:
但是这里有一点要注意就是,虽然调用了malloc看起来像是开辟了空间,但其实没有,前面就提到了,类并不会开辟空间相当于一个函数的声明而已,只有在实例化对象的时候才会开辟空间。这里的malloc只是相当于我在设计图纸上标注了某个房间的面积是多大,但是在建造出这个房间之前,这个房间并不会占用任何实际的空间。
默认构造
构造函数的第七个特性是:无参的构造函数和全缺省的构造函数都称之为默认构造函数,并且默认构造函数只能有一个。构造函数虽然可以重载,但是无参和全缺省是不能构成重载的,因为在调用的时候这两种函数都可以不传参会产生二义性。
如果我们写了构造函数,并且不是默认构造函数,那就必须要传参数