一、基础知识
1、C++编译流程
以Unix系统编译中间文件为说明:
.cpp—(编译预处理)—>.ii—(编译)—>.s—(汇编)—>.o—(ld,连接)—>.out
2、#include
作用于编译预处理阶段,将被include文件抄送在include所在位置,并会在相应位置写出调用栈,生成中间文件.ii,该中间文件可读
include文件加引号表示先从当前目录寻找索引,加尖括号表示从编译器指定根目录索引,Unix默认为"~//usr/include"目录
3、定义、声明、头文件
.h头文件中只应存放三种代码:
函数声明:没有大括号,形如void fun()
变量声明:extern 变量名
class、结构体定义
extern表示声明一个全局变量
声明只是提示编译器,存在这个东西,并没有定义出实体,不定义直接调用会报错。
4、标准头文件结构
#ifndef HEADER_FLAG
#define HEADER_FLAG
/*头文件*/
#endif
这是为了防止多次include同一个头文件时,每次都抄送到预编译文件中,造成文件过大、循环导入或者结构体定义重复以致报错(声明重复问题不大)。
5、默认参数
在声明中写默认参数,不在定义中给默认参数。
6、调用函数过程
本地变量进入堆栈(未必初始化)
函数参数进入堆栈
返回地址计入堆栈
返回值进入寄存器(运行函数)
pop掉参数
返回值进入堆栈(返回地址,所以要pop掉参数,堆栈先进后出)
7、内联函数
在编译阶段优化,省略上一小结中复杂的堆栈操作,效果如下,
汇编(伪)优化如下,
注意,inline 函数名实际是一个声明,而非定义所以不需要额外声明。实际上以空间换时间(编译会将函数插入调用位置),编译器如果发现函数递归或者过于巨大,可能会拒绝inline操作,函数较小可能被自动inline,建议就是小函数inline(2-3行),超过20行的就不要inline了。
相比于宏,inline可以做类型检查,给出debug提示,下图中C++会提示double的f(a)和%d不匹配,C会直接给出一个奇怪的值,
8、const
初始化之后不可修改,值得注意的是下图这种,指针和const,到底是地址(指针)还是地址中的内容(对象)是const
重点在于const和*的位置顺序,下述代码中2、3两句等价,
且const变量不能传给其他非const的指针(因为这样有可能造成修改),
函数和const
函数虚参加const表示函数内部不可修改该变量,对输入无要求
return const 对接收函数返回的类型无要求
class和const
const 对象,此时我们不能保证class方法是否修改成员变量,又不能限制函数不能使用(class就没有意义了)
const 对象 or 成员变量是const,要求成员变量必须有初始值,因为事后无法赋值
main文件(编译时可以感知类声明文件),类声明文件,类定义文件:声明、定义(两个位置都需要添加)函数时后面添加const关键字如,int fun() const。
下图运行结果为"f() const",
实际构成了重载,
void f(A* this)
void f(const A* this)
9、字符串
char *s = "Hello World"; // 将代码段的字符串地址直接付给指针,所以后面尝试修改会报错(代码段不可修改),
// 应该在开头改写为const char *s
char s[] = "Hello World"; // 数组被写入堆栈,将代码段的字符串拷贝到堆栈
10、引用
char& r = c; // 引用可以做左值
相当于给c取了一个别名,此时c、r绑定到同一实体。
int x;int y;
int& a = x;
int& b = y;
a = b; // 等价x=y
注意,引用无法取地址,即 int&* r 的写法是错误的,不过相对的,int*& p 是没问题的,指针可以被引用。
class的成员变量是引用时
此时只能使用initializer list的方式初始化引用对应的变量,如果在{}中使用m_y=a则表示将a复制给m_y对应的变量。
函数返回引用时
return一个全局变量,
这个引用表示变量,不表示值,所以最后一句表示赋值。
11、中间结果
相当于Python中的“_”,i*3这样的结果会作为const int类型临时保存。
二、class入门
1、变量
Field,成员变量,作用域为class的对象,类的函数中可以直接使用;class本身不能拥有变量,理解为声明一个变量(函数和变量不同,函数属于class而不是对象);
parameters,函数参数;
local variables,本地变量,作用域为本函数;
后两者完全相同,本地存储,出来作用域则不存在该变量。
关键字 this:一个指针,为当前对象的指针(指该次调用成员函数的对象的指针),
经由指针this区分调用成员函数的不同实例,其原理如下:使用'实例对象.成员函数'来调用等价于直接调用该函数并将对象指针作为首个参数输入,即:成员函数(this),原理和python一致,成员函数实际上有一个默认存在的参数输入,接收实例指针
2、构造和析构
在C++中,class实例化时成员变量不会初始化,仅仅寻找到一块足够大的地址(java会清空地址内数据)。VS会在debug时为未初始化空间填充0xcd,用于排查(0xcd0xcd在国标码中为‘烫’)。
constructor:构造函数,初始化对象时自动执行(相当于python中的__init__)
函数名和class名相同
没有返回类型
destructor:析构函数,退出对象所在scope时自动执行
函数名为'~'加构造函数名
没有返回类型
不可以有参数
有关‘{}’,表示scope,如下代码中,进入‘{’后会执行Tree的构造函数,退出‘}’前,本scope内资源回收,会自动执行析构函数
数组、结构体、使用构建函数的class初始化方式对比:
Y经由构建函数Y()间接将f、i赋值,顺便一提,数组b后面未指定元素会被初始化为0
default constructor:无参数构建函数,见下右的第二行会报错,因为构建y2有两个元素,而第二个元素会调用default constructor,但实际上constructor需要参数,所以会报错:
:
3、scope和存储空间
编译器在‘{}’开始的位置会分配好空间,而在运行到相关定义时才会真正的运行构造函数。
如下图,某些情况下编译会出错,因为一旦goto成功,则x1不会被构建,相应的退出‘{}’时,析构函数执行会失败。
4、动态分配空间
new:制造对象,类似malloc;分配空间、调用构造函数(对于class),返回地址
使用一张表,记录下每次申请的内存大小和对应的地址:
delete:收回空间,类似free;析构对象(对于class)、回收空间;它有两种用法,如下:
delete p :普通用法
delete[] p :一般来说new p[]时,需要使用这个,会将所有对象的析构函数分别调用,否则回收内存正常,但只调用指针直接指向的对象的析构
5、访问控制
public:任何人可以访问
private:成员函数可以访问 ,注意对class来讲,同一个class不同对象可以互相访问私有变量,如下代码,p[0]是可以访问b的私有变量的
friends:声名一个函数/class等,使之可以访问自己(本class的任何实例)的私有变量
下面代码涉及两个知识点:1、friends声明在class内部;2、结构体可以前向声明(开头的X),用于在结构体Y定义中占位。
protected:自己及子类可以访问
6、struct vs class
未指定访问控制属性的变量、函数,class默认为private,struct默认public
7、初始化list
初始化后才执行构造函数(大括号中语句)
在大括号中赋值的话会先默认初始化变量,然后赋值;初始化list的方式直接用目标值初始化变量
8、成员函数和inline
在class内部给出了body的成员函数,视为内联函数。
三、父类子类
1、组合和继承
组合:已有类作为新的类的成员
继承:改造类,class B: public A {},意为B类为A类子类
父类的private,在子类中存在,但是不能直接访问(需要使用父类的public方法),需要使用protected声明。
另一点值得注意的是,由于构造函数不可以直接调用, 调用父类的构造函数方式需要使用初始化list方法,而且必须最先构造父类(如果父类构造函数有参数),构造先父后子,析构先子后父:
2、覆盖(override)、重载(overload)、隐藏
overload
在同一作用域中,函数名相同,参数列表不同,返回值可同可不同的函数,编译器会根据传入参数决定调用哪个函数,注意仅返回值不同不能构成overload关系。
override
又叫覆盖,是指不在同一个作用域中(分别在父类和子类中),函数名,参数个数,参数类型,返回值类型都相同,并且父类函数必须有virtual关键字的函数,就构成了重写(协变除外)。协变:协变也是一种重写,只是父类和子类中的函数的返回值不同,父类的函数返回父类的指针或者引用,子类函数返回子类的指针或者引用。
virtual:子类的同名同参函数之间有联系(继承树中某一个函数是virtual的,子类的该方法都是virtual的)。
重定义
又叫隐藏,是指在不同的作用域中(分别在父类和子类中),函数名相同,不能构成重写的都是重定义(重定义的不光是函数,还可以是成员变量),隐藏和覆盖不同,被隐藏的父类成员可以通过子类.父类::成员的方式调用。
3、向上造型upcasting
子类对象可以被传给父类对象指针,如下图所示,
这是由于C++的class类似于C的结构体,实际上是一个指针指向一块有特定内容排列顺序的内存,子类只会在父类的内存规划上向后扩充,不会更改父类已经规划好的部分。如果有子类方法隐藏了父类方法,向上造型后会隐藏失效,此时的对象指针仅能识别父类原有的模块。
类似地,也有向下造型,不过可能会出错。
Employee是Manager的父类
4、多态
本小节摘抄自文章:C++ 多态的实现及原理
想要理解多态,需要区分函数和虚函数的区别(内存上的位置差异),并要理解向上造型的概念,了解了前面两点,就了解了动态绑定、静态绑定的区别,对于多态产生的种种现象就能够从机理上给出自己的解释。
virtual虚函数内存机制
上面提到过,virtual是让子类与父类之间的同名函数有联系,这就是多态性,实现动态绑定。
任何类若是有虚函数就会比正常类大一点,所有有virtual的类的对象里面最头上会自动加上一个隐藏的,不让我知道的指针,它指向一张表,注意,该表对于同一个class的不同对象是同一个,不同class(指父类子类)的表不同。这张表叫做vtable(虚表),vtable里是所有virtual函数的地址,对于下面代码,
class Shape { public: Shape(); virtual ~Shape(); virtual void render(); void move(const pos&); virtual void resize(); protected: pos center; };
其内存分布如下:
我们看一下其子类的内存分布:
class Ellipse : public Shape{ public: Ellipse (float majr, float minr); virtual void render(); protected: float major_axis; float minor_axis; };
这里的resize沿用了shape的成员函数。
多态实现逻辑
看如下代码,
#include "stdafx.h" #include <iostream> #include <stdlib.h> using namespace std; class Father { public: void Face() { cout << "Father's face" << endl; } void Say() { cout << "Father say hello" << endl; } }; class Son:public Father { public: void Say() { cout << "Son say hello" << endl; } }; void main() { Son son; Father *pFather=&son; // 隐式类型转换 pFather->Say(); }
输出的结果为:
我们在main()函数中首先定义了一个Son类的对象son,接着定义了一个指向Father类的指针变量pFather,然后利用该变量调用pFather->Say().估计很多人往往将这种情况和c++的多态性搞混淆,认为son实际上是Son类的对象,应该是调用Son类的Say,输出"Son say hello",然而结果却不是.
从编译的角度来看:
c++编译器在编译的时候,要确定每个对象调用的函数(非虚函数)的地址,这称为早期绑定,当我们将Son类的对象son的地址赋给pFather时,c++编译器进行了类型转换,此时c++编译器认为变量pFather保存的就是Father对象的地址,当在main函数中执行pFather->Say(),调用的当然就是Father对象的Say函数
从内存角度看:
Son类对象的内存模型如上图
我们构造Son类的对象时,首先要调用Father类的构造函数去构造Father类的对象,然后才调用Son类的构造函数完成自身部分的构造,从而拼接出一个完整的Son类对象。当我们将Son类对象转换为Father类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是上图中“Father的对象所占内存”,那么当我们利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法,因此,输出“Father Say hello”,也就顺理成章了。
正如很多人那么认为,在上面的代码中,我们知道pFather实际上指向的是Son类的对象,我们希望输出的结果是son类的Say方法,那么想到达到这种结果,就要用到虚函数了。
前面输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用晚绑定,当编译器使用晚绑定时候,就会在运行时再去确定对象的类型以及正确的调用函数,而要让编译器采用晚绑定,就要在基类中声明函数时使用virtual关键字,这样的函数我们就称之为虚函数,一旦某个函数在基类中声明为virtual,那么在所有的派生类中该函数都是virtual,而不需要再显式地声明为virtual。
代码稍微改动一下,看一下运行结果
#include "stdafx.h" #include <iostream> #include <stdlib.h> using namespace std; class Father { public: void Face() { cout << "Father's face" << endl; } virtual void Say() { cout << "Father say hello" << endl; } }; class Son:public Father { public: void Say() { cout << "Son say hello" << endl; } }; void main() { Son son; Father *pFather=&son; // 隐式类型转换 pFather->Say(); }
我们发现结果是"Son say hello"也就是根据对象的类型调用了正确的函数,那么当我们将Say()声明为virtual时,背后发生了什么。
编译器在编译的时候,发现Father类中有虚函数,此时编译器会为每个包含虚函数的类创建一个虚表(即 vtable),该表是一个一维数组,在这个数组中存放每个虚函数的地址,
那么如何定位虚表呢?编译器另外还为每个对象提供了一个虚表指针(即vptr),这个指针指向了对象所属类的虚表,在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向了所属类的虚表,从而在调用虚函数的时候,能够找到正确的函数,对于第二段代码程序,由于pFather实际指向的对象类型是Son,因此vptr指向的Son类的vtable,当调用pFather->Son()时,根据虚表中的函数地址找到的就是Son类的Say()函数.
正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的,换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数,那么虚表指针是在什么时候,或者什么地方初始化呢?
答案是在构造函数中进行虚表的创建和虚表指针的初始化,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表,当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。