C++学习笔记
-
C++学习
本文中记录了一些个人在学习C++过程中的一些疑惑和对这些疑惑的探究过程。在此作一个总结,由于个人能力不足,可能在其中可能会存在一些问题,欢迎交流。
C++中的引用
引用是C++新增的一项重要机制,它使得C++中本生需要指针实现的功能有了另一种实现方法。但引用的本质是什么呢?它跟指针之间有什么联系呢?
下面给出一段代码:
int main() { int a = 1; int& p1 = a; int* const p2 = &a; int b1 = p1; int b2 = *p2; p1 = 8; *p2 = 10; }
将上面这段代码在VS2019Debug模式下进行反汇编,得到如下(仅截取部分相关代码):
3: int a = 1; 00FD17F2 C7 45 F4 01 00 00 00 mov dword ptr [a],1 ;将1存到a所在的空间 4: int& p1 = a; 00FD17F9 8D 45 F4 lea eax,[a] ;将a的地址存进eax 00FD17FC 89 45 E8 mov dword ptr [p1],eax ;将eax的值(即a的地址)存入p1 5: int* const p2 = &a; 00FD17FF 8D 45 F4 lea eax,[a] ;将a的地址存进eax 00FD1802 89 45 DC mov dword ptr [p2],eax ;将eax的值(即a的地址)存入p2 6: ;读取 7: int b1 = p1; 00FD1805 8B 45 E8 mov eax,dword ptr [p1] ;将p1的值(a的地址)放入eax 00FD1808 8B 08 mov ecx,dword ptr [eax] ;将eax的值,即a的值放入ecx 00FD180A 89 4D D0 mov dword ptr [b1],ecx ;将ecx的值存到b1 8: int b2 = *p2; 00FD180D 8B 45 DC mov eax,dword ptr [p2] ;将p1的值(a的地址)放入eax 00FD1810 8B 08 mov ecx,dword ptr [eax] ;将eax的值,即a的值放入ecx 00FD1812 89 4D C4 mov dword ptr [b2],ecx ;将ecx的值存到b2 9: ;修改 10: p1 = 8; 00FD1815 8B 45 E8 mov eax,dword ptr [p1] ;将p1的值(a的地址)放入eax 00FD1818 C7 00 08 00 00 00 mov dword ptr [eax],8 ;修改eax值对应的地址处的数据为8 11: *p2 = 10; 00FD181E 8B 45 DC mov eax,dword ptr [p2] ;将p2的值(a的地址)放入eax 00FD1821 C7 00 0A 00 00 00 mov dword ptr [eax],0Ah ;修改eax值对应的地址处的数据为10
注:
lea指令和mov指令的区别:
lea eax,[ebx+8] ;该语句表示把ebx的地址加8存到eax,(存进去的可以理解为还是一个地址) mov eax,[ebx+8] ;该语句表示把ebx+8后对应地址的值存进eax
由上可以看出,在底层实现中,引用被当作指针来进行处理,读取和修改的操作均与指针无差异。
但由于引用不能重新指向另一个变量,所以在理解上可以将其当作指针常量。
函数的重载
C++与C的一个重要区别就在于,C++可以根据参数的不同来进行重载,但C++是如何实现重载的呢?
函数重载的汇编实现
下面给出一段代码:
int Fun(int i) { i = i + 1; return i; } double Fun(double i) { i = i + 1; return i; } int main() { int a = 1; double b = 1.5; int a2 = Fun(a); double b2 = Fun(b); }
将上面这段代码在VS2019下进行反汇编,得到结果如下:
15: int a = 1; 00FE1808 C7 45 F8 01 00 00 00 mov dword ptr [a],1 16: double b = 1.5; 00FE180F F2 0F 10 05 D8 7B FE 00 movsd xmm0,mmword ptr [__real@3ff8000000000000 (0FE7BD8h)] 00FE1817 F2 0F 11 45 E8 movsd mmword ptr [b],xmm0 17: 18: int a2 = Fun(a); 00FE181C 8B 45 F8 mov eax,dword ptr [a] 00FE181F 50 push eax 00FE1820 E8 A1 FA FF FF call Fun (0FE12C6h) ;call Fun函数,地址为(0FE12C6h) 00FE1825 83 C4 04 add esp,4 00FE1828 89 45 DC mov dword ptr [a2],eax 19: double b2 = Fun(b); 00FE182B 83 EC 08 sub esp,8 00FE182E F2 0F 10 45 E8 movsd xmm0,mmword ptr [b] 00FE1833 F2 0F 11 04 24 movsd mmword ptr [esp],xmm0 00FE1838 E8 0B FB FF FF call Fun (0FE1348h) ;call Fun函数 ,地址为(0FE1348h) 00FE183D 83 C4 08 add esp,8 00FE1840 DD 5D CC fstp qword ptr [b2]
可以看出,这两个函数所指向的地址已然不同,但在这之中并不明白它是如何实现的。
在此我们使用gcc -O0 -S 指令,对该cpp文件进行编译,得到最终编译结果如下:
.file "test1.cpp" .text .globl _Z3Funi .type _Z3Funi, @function _Z3Funi: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl %edi, -4(%rbp) addl $1, -4(%rbp) movl -4(%rbp), %eax popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size _Z3Funi, .-_Z3Funi .globl _Z3Fund .type _Z3Fund, @function _Z3Fund: .LFB1: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movsd %xmm0, -8(%rbp) movsd -8(%rbp), %xmm1 movsd .LC0(%rip), %xmm0 addsd %xmm1, %xmm0 movsd %xmm0, -8(%rbp) movsd -8(%rbp), %xmm0 popq %rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE1: .size _Z3Fund, .-_Z3Fund .section .rodata .align 8 .LC0: .long 0 .long 1072693248 .ident "GCC: (Ubuntu 7.5.0-3ubuntu1~18.04) 7.5.0" .section .note.GNU-stack,"",@progbits
从上述代码可以看出,代码中的Fun函数,在汇编中被重命名为了_Z3Funi 和 _Z3Fund。
不难发现,在gcc中,是通过将函数根据参数重命名来实现函数重载,i表示int,d表示double。如果是自定义的结构体,从测试结果来看,gcc是使用全称来进行重命名。(重命名的方式可能随着编译器的不同有一些不同,但机制应该是相同的)
cpp中调用c中函数的问题
此时不免会引出一个问题,那么 .cpp 文件中如何调用 .c 文件中的函数呢?C++中编译时对于函数的命名方式不同,可想而知对于函数的调用方式来说也会不同,但C语言中对于函数的编译并无相应的处理,这之间必然产生冲突。
//test2.h #pragma once int Fun(int i); //test2.c #include "test2.h" int Fun(int i) { i = i + 1; return i; } //test.cpp int main() { int a = Fun(10); return 0; }
将以上代码编译调试运行会导致报错一个无法解析的外部符号:
可以看出,这种冲突确实存在。
对此,C++引入了
extern "C"
用于解决这类型的冲突。对上方代码的头文件进行修改,即可使得对应的函数按照C函数的方式编译。
//test2.h #pragma once #ifdef __cplusplus extern "C" { #endif int Fun(int i); #ifdef __cplusplus } #endif //test2.c #include "test2.h" int Fun(int i) { i = i + 1; return i; } //test.cpp int main() { int a = Fun(10); return 0; }
对于该头文件,添加对应的extern “C” { ……},将需要按C语言编译方式的函数框选进去,即可实现.cpp 调用 .c 中的函数。
对象内存模型
C++面向对象的三大特征:封装、继承、多态。在个人看来多态可以说是最难但也同时是最能体现其优势的特征了。
提及多态,常会说:子类指针可以直接赋给父类类型的指针。也就是说,向上的类型转换(子类转换成父类)是类型安全的。
但同时,C++中存在一种private 关键词,它可以定义类的私有属性,这些属性是不可被子类继承的,那么为什么C++可以保障其向上的类型转换是类型安全的呢?
简单类继承中的类型安全
如上图,如果是非private属性,可以很轻松的理解,子类指针指向的空间只有可能比父类大,而不存在比父类小的可能,而且对应的属性位置也同样相同,所以对于指针的操作来说也同样可行。
但此时如果父类中存在private属性呢(实际上大多数属性都是设置为private属性),为什么还是可以保证类型安全呢?
使用如下代码进行测试:
#include <iostream> class Parent { public: char* name; int age; private: int weight; }; class Child :public Parent { private: int some_field; }; using namespace std; int main() { cout << sizeof(Parent) << endl; cout << sizeof(Child) << endl; }
该代码中,子类继承了父类的public属性,没有继承其private属性,同时自己增加了一个新的属性,故应当有三个属性,大小均为4字节,最后输出为12字节。
最后的输出结果:
与最初设想的不同,子类的大小为16字节而不是12字节。
故此,可以猜想到实际上该属性被继承了下来,仅仅是编译器拒绝了你对其的访问。
实际上,在vs监视中可以看到:
可以看出,实际上子类中维护一个父类的对象。这样也更好的理解了为什么在调用子类构造函数时,需要先调用父类的构造函数——因为实际上子类中就维护了一个父类对象。同样也可以理解,为什么在子类析构时,先进行子类的析构函数,再进行父类的析构函数,因为需要先析构自己的属性,再析构父类的对象,在析构父类对象时,必然需要调用父类的析构函数。
多继承中的类型安全
由上面,我们可以看出,在单继承中,子类先在内存中维护了一个父类对象,然后再开辟自己新增属性的空间,将指向子类型的指针,转换成指向父类型完全是可以实现的而且不会有任何访问越界问题。
但如果是多继承呢?
看下面这段代码:
#include <iostream> class A { public: int a; }; class B { public: int b; }; class C: public A,public B { public: int c; }; using namespace std; int main() { C* p = new C; //测试1 cout << (A*)p << endl; cout << (B*)p << endl; cout << p << endl; //测试2 A* pA = (A*)p; B* pB = (B*)pA; cout << pA << endl; cout << pB << endl; return 1; }
该段代码中,C类继承A类和B类。
通过监视:
可以得知C类对象的内存结构如图:
那么,如果将该对象类型转换为A类的指针,可以很容易实现,那么如果希望其转换为B类的指针呢,C++中又会如何实现呢?
我们运行上述程序,得到输出结果如下图:
前三行输出对应的是 (A*)p (B*)p p 的值,可以看到,在将p指针强行转换为B*类型时,指针的数值自动增加了4,即增加了1个A对象的大小,故在此可以看出,在多继承中,子类指针转换为父类时,他会进行一些相关的指针偏移运算。
再看4,5行的输出结果,这两行表示的是将p强行转换为(A*),再将A* 转换为 B*.
可以看出,在对于(A*)类型的指针来说,将其转换为(B*)是不会进行一些相关的指针偏移的,即不能在两个父类之间转换。
在此时就可能会发生一些有意思的事情,比如 A类中有一个虚函数表,其中第一个函数对应的函数为FunA() , 同时在B类中同样内存偏移的位置也有一个虚函数表,此时由于类型转换后,会导致尽管看上去是B类型指针,而且成功调用了B类中的函数,但实际上调用的为FunA(),因为这两者的内存访问方式相同。
//class A class A{ public: virtual void FunA(); //other method or field } class B{ public: virtual void FunB(); //other method or field } class C:public A,public B { } int main() { C* p = new C; A* pa1 = (A*)p; B* pb1 = (B*)p; pa1->FunA(); pb1->FunB(); //上述两者访问的是各自的函数 A* pa2 = (A*)p; B* pb2 = (B*)pa2; pa2->FunA(); pb2->FunB(); //上述两者访问的是同一个函数FunA() }
那么如何解决这个容易发生的冲突呢。在C++ 中引入了 动态交叉类型转换函数:
B* pb = dynamic_cast<B*>(pa)
将上述代码修改:
int main() { C* p = new C; A* pa1 = (A*)p; B* pb1 = (B*)p; pa1->FunA(); pb1->FunB(); //上述两者访问的是各自的函数 A* pa2 = (A*)p; B* pb2 = (B*)pa2; pa2->FunA(); pb2->FunB(); //上述两者访问的是同一个函数FunA() A* pa3 = (A*)p; B* pb3 = dynamic_cast<B*>(pa3); pa3->FunA(); pb3->FunB(); //此时两者访问的又为各自的函数 }
注:
dynamic_cast<>()
函数直接对于多态类进行使用,即被转换的类必须继承自其他类或者其必须含有虚函数。菱形继承(虚基类)
网上常说 C++ 设定的多继承不好,为什么不好呢,大部分人都会提及 会导致菱形继承问题,那么,菱形继承会导致什么问题呢,C++中又是如何实现解决的呢?
所谓菱形继承,就是在多次继承中,某一个类继承了多个类,而这多个类又同时继承了同一个类,导致可能存在的数据冲突的问题。
如下算是一种经典的菱形继承关系:
如果动物中定义了一个属性 age ,那么马和驴都会有这个属性,若骡继承了马和驴的话,会导致其内部维护了两个 age 属性(如下图)。这就会导致内存的浪费,同时访问上也会更加复杂。
那么C++中是如何解决这个问题的呢?
看下面的代码:
#include <iostream> using namespace std; class Animal { public: int age; }; class Horse:virtual public Animal { }; class Donky:virtual public Animal { }; class Mule:public Horse,public Donky { }; int main() { Mule* p = new Mule; return 1; }
在这段代码中新增加了2个关键词:
virtual
新增后,可以发现:类Mule 的内存结构发生了一些改变:
相比于上面的结构,改结构中可以看到多了一个Animal类,那么为什么会多这个Animal类呢?这不是会占用更多的内存吗?
在原代码的基础上再进行进一步测试。
class Animal { public: int age; }; class Horse:virtual public Animal { int H; }; class Donky:virtual public Animal { int D; }; class Mule:public Horse,public Donky { }; int main() { cout << sizeof(Animal) << endl; cout << sizeof(Horse) << endl; cout << sizeof(Donky) << endl; cout << sizeof(Mule) << endl; return 1; }
增加了一些属性,同时输出各个类的大小,可以得到结果:
- Animal:4个字节
- Horse:12个字节
- Donky:12个字节
- Mule:20个字节
以上输出结果有两个异常,相比于常规的没有增加virtual关键字的类来说,马和驴类多了4个字节,同时,在结构上来看骡类应该为28个字节,但此处只有20个字节。
通过vs2019开发者命令行工具,查询到了Mute类的结构如下图:
可以发现多出了一个叫做 vbptr 的东西,这个东西称作虚基类指针,指向基类。
故此,可以很好的理解,为什么Horse 和 Donky 是12个字节,而Mule 是20个字节了。
Horse和Donky 中 前4个字节为一个虚基类指针,指向Animal类,再加上自己属性的4个字节,共计12个字节。
对于Mule来说,他前16个字节维护了Horse 和Donky 中的虚基类指针和 自身的属性,再在最后维护了一个Animal类,故共有20个字节。
此时,由于Horse 和 Donky 中的虚基类指针指向同一个对象,故此时不会造成在一个Mule中维护了两个age属性,此时也不用通过作用域来访问对应的age属性,消除了继承的同名冲突。
虚继承和虚基类指针的设计,是为了解除多继承中产生的这些菱形继承问题,但实际上使用起来还是较为不便,因为大部分情况你无法得知哪些类需要设计为虚继承,哪些类不需要,所以实际编写C++中还是尽量避免这种类型的继承方式。
-
很久很久之前的笔记了hhhh应该有很多错误大家伙见谅了
-
好家伙,这也很详细了,就看你这个学习C++特性了
-
还给出了代码例子,很友好呀,mark一下
-
mark了