声明:
写本篇博客的目的是为了整理自己在找工作时学习的C++相关知识点,博客整体内容会分为两种风格,第一章基础部分是以常见C++面试问题+解答的形式呈现;其余部分是知识点层层递进的方式展现,比较系统。其中,在第一章中有很多问题的解答是由多篇现有博客的内容结合形成,由于时间久远,没有标注引用信息,如果有侵犯请作者联系本人删除或引用。
数组:数组是用于储存多个相同类型数据的集合。
指针:指针相当于一个变量,但是它和不同变量不一样,它存放的是其它变量在内存中的地址。
区别:
(1)vector
vector是一种动态数组,在内存中具有连续的存储空间,支持快速随机访问。由于具有连续的存储空间,所以在插入和删除操作方面,效率比较慢。vector有多个构造函数,默认的构造函数是构造一个初始长度为0的内存空间,且分配的内存空间是以2的倍数动态增长的,即内存空间增长是按照20,21,22,23…增长的,在push_back的过程中,若发现分配的内存空间不足,则重新分配一段连续的内存空间,其大小是现在连续空间的2倍,再将原先空间中的元素复制到新的空间中,性能消耗比较大,尤其是当元素是非内部数据时(非内部数据往往构造及拷贝构造函数相当复杂)。
(2)deque
deque和vector类似,支持快速随机访问。二者最大的区别在于,vector只能在末端插入数据,而deque支持双端插入数据。deque的内存空间分布是小片的连续,小片间用链表相连,实际上内部有一个map的指针。deque空间的重新分配要比vector快,重新分配空间后,原有的元素是不需要拷贝的。
API:
deque<T>deqT; //默认构造形式
assign(begin,end); //将【begin,end】区间的数据拷贝赋值给自身
deque.size(); //返回容器中元素的个数
push_back(elem); //在容器尾部添加一个数据
(3)List
双向链表,快速任意位置插入,
(4)map
关联容器,内部使用红黑树(自平衡二叉树)实现,Map内部有序
(5)Set
关联容器,内部红黑树实现
(6)queue
queue是队列,内部是deque实现,deque在重新分配空间的时候不需要拷贝所有元素。
(7)Stack
Stack先进后出,内部deque实现。
(1)vector和deque支持随机访问
(2)vector和string存储在连续空间上,中间位置删除元素比较耗时。
(3)list和forward_list设计目的为任何位置快速插入删除。
(4)array固定大小,不支持添加删除元素
(5)forward_list没有size()函数
迭代器:
只有在对一个const对象操作时才返回const_iterator。const_iterator 对象可以用于const vector 或非 const vector,它自身的值可以改(可以指向其他元素),但不能改写其指向的元素值。
static和const的作用在描述时主要从类内和类外两个方面去讲:
static关键字的作用:
(1)函数体内static变量的作用范围为该函数体,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值,生命周期为整个程序的周期;
(2)在模块内的static全局变量和函数可以被模块内的函数访问,但不能被模块外其它函数访问;
(3)在类中的static成员变量属于整个类所拥有,对类的所有对象只有一份拷贝;
(4)在类中的static成员函数属于整个类所拥有,这个函数不接收this指针,因而只能访问类的static成员变量。
const关键字的作用:
(1)阻止一个变量被改变;
(2)声明常量指针和指针常量;
(3)const修饰形参,表明它是一个输入参数,在函数内部不能改变其值;
(4)对于类的成员函数,若指定其为const类型,则表明其是一个常函数,不能修改类的成员变量(const成员一般在成员初始化列表处初始化);
(5)对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为”左值”。
extern关键字的作用:
(1)extern可以置于变量或者函数前,以标示变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数时在其他模块中寻找其定义(主要为了提示链接器)。
(2)extern "C"的作用是让 C++ 编译器将extern "C"声明的代码当作 C 语言代码处理,可以避免 C++ 因符号修饰导致代码不能和C语言库中的符号进行链接。
(1)C++面向对象,C为面向过程
(2)C++具有封装、继承、多态三种特性。
(3)C++更安全,强转
(4)C++支持泛型编程,模板类和函数模板
(1)全局静态变量
存储在静态存储区,整个程序运行期间存在。
初始化:未经初始化的全局静态变量会自动被初始化为0,(自动对象的值是任意的);
作用域:在声明其文件之外不可见,准确地为,从定义到文件结尾;
static 全局变量:只在定义它的源文件有效,其他源文件不可以直接访问 ,即使在引用了该文件的文件内也是看不到该变量的,因此引用了这个文件,但本文件内还是可以定义同名变量的,不会引起冲突。
普通全局函数如果被其他文件引入头文件就可以使用。但静态全局变量不行。
(2)局部静态变量
静态存储区;
局部作用域,离开作用域不可用,但并未销毁;
static局部变量:静态局部变量存在静态存储区,在整个程序生命周期中存在;
静态局部变量只能被其作用域内的变量或者函数访问;
静态全局变量如果没有被初始化,会被编译器自动赋值为 0。
(3)静态函数
在函数返回类型前加static则定义为静态函数。静态函数在其他文件不可用。即使别的文件引用了这个头文件,也不可以访问。
如果写一个函数需要满足可以被其他文件引用之后使用,则写成普通全局函数,否则写成static的。
(4)类静态成员
多个对象共享
(5)类静态函数
应用场景:例如同类A的多个对象共享这个函数。但是如果其他类组合了这个类A,也就是在其他类中有个成员变量为A a;但是不想让这个类对象访问a的这个函数,就可以使用静态函数。
static_cast,dynamic_cast,const_cast,reinterpret_cast
(1) const_cast
顾名思义,const_cast将转换掉表达式的const性质(必须是转指针或引用)。
(2)static_cast
用于各种隐式转换,如非const转const,void*转指针等。
(3)dynamic_cast
主要应用于继承体系, 可以由 “指向派生类的基类部分的指针”, 转换“指向派生类"或"指向兄弟类”;static_cast只能转换为"指向派生类";动态类型转换,只能用于含有虚函数的类。
(4)interpret_cast
几乎什么都可以转,比如将int转为指针,会出问题,尽量少用。
主要是对2进制数据进行重新解释(re-interpret),不改变格式, 而static_cast会改变格式进行解释;
如由派生类转换基类, 则重新解释转换, 不改变地址, static_cast改变地址;
struct rA { int m_a; };
struct rB { int m_b; };
struct rC : public rA, public rB {};
void CastReinterpret (void)
{
int *i= new int;
*i = 10;
std::cout << "*i = " << *i << std::endl;
std::cout << "i = " << i << std::endl;
double *d=reinterpret_cast<double*> (i);
std::cout << "*d = " << *d << std::endl;
std::cout << "d = " << d << std::endl;
rC c;
std::cout << "&c = " << &c << std::endl
<< "reinterpret_cast(&c) = " <<reinterpret_cast<rB*>(&c) << std::endl
<< "static_cast (&c) = " << static_cast <rB*>(&c) << std::endl
<< "reinterpret_cast(&c) = " <<reinterpret_cast<rA*>(&c) << std::endl
<< "static_cast (&c) = " << static_cast <rA*>(&c) << std::endl
<< std::endl;
}
/**************************打印输出*****************************/
重新解释转型:
*i = 10
i = 0x471718
*d = 2.55917e-307
d = 0x471718
&c = 0x22feb0
reinterpret_cast<rB*>(&c) = 0x22feb0//重新解释地址不变
static_cast <rB*>(&c) = 0x22feb4 //静态转换地址变了,这是因为子类指针转为父类指针时将指针地址转到了原父类指针的位置。
reinterpret_cast<rA*>(&c) = 0x22feb0
static_cast <rA*>(&c) = 0x22feb0
static_cast 和 reinterpret_cast 操作符修改了操作数类型,它们不是互逆的。static_cast 在编译时使用类型信息执行转换, 在转换执行必要的检测(诸如指针越界计算, 类型检查)。其操作相对是安全的。另一方面, reinterpret_cast 仅仅是重新解释了给出的对象的比特模型而没有进行二进制转换, 例子如下:
int n=9; double d=static_cast < double > (n);
上面的例子中, 我们将一个变量从 int 转换到 double。这些类型的二进制表达式是不同的。要将整数 9 转换到 双精度整数 9, static_cast 需要正确地为双精度整数 d 补足比特位。其结果为 9.0,而reinterpret_cast 的行为却不同:
int n=9;
double d=reinterpret_cast<double & > (n);
这次, 结果有所不同。在进行计算以后, d 包含无用值. 这是因为 reinterpret_cast 仅仅是复制 n 的比特位到 d, 没有进行必要的分析。
(5)为什么不用C的强转
Shared_ptr,unique_ptr,weak_ptr, auto_ptr(被C++17弃用)
(1)unique_ptr
独占式拥有,保证同一时间只有一个智能指针指向该对象。
#include
#include
using namespace std;
int main()
{
unique_ptr<int> up_x(new int(10));
//unique_ptr up_x2(up_x); //没有左值拷贝构造函数
//unique_ptr up_x2=up_x; //没有左值拷贝构造函数
unique_ptr<int> up_x2;
//up_x2 = up_x; //没有左值拷贝赋值运算符,因为已被delete
unique_ptr<int> up_x3(unique_ptr<int>(new int(9))); //有右值拷贝构造
up_x2 = std::move(up_x);//可以借助move将up_x赋值给up_x2.此操作完成后up-x不可用,有右值拷贝复制
*up_x2 = 99;//
//*up_x = 22;
return 0;
}
(2)shared_ptr
多个智能指针共享指向一个对象,所指对象在最后一个引用被销毁时释放,
可以使用make_shared函数通过构造函数传入普通指针,get函数获得普通指针。
关于数组的使用和指派删除器 看到这里,我们所有的例子都是单个对象,那数组是不是也可以像这样
cpp shared_ptr
sp(new int[10]);使用shread_ptr?
这样是错误的。我们要使用shared_ptr管理数组的话,必须给其制定一个删除器(函数):
cpp shared_ptr
sp(new int[10], [](int *p) {delete[] p; });
这里的匿名函数即是删除器。
如果没有提供删除器,这段代码就是未定义的。默认情况下,shared_ptr使用delete销毁它所指的对象。如果这个对象是个动态数组,对其使用delete所产生的问题和释放一个动态数组忘记加[]的后果相同(即造成内存泄漏)。
#include
#include
using namespace std;
namespace demo63 //演示智能指针循环引用的问题
{
class Parent;
typedef std::shared_ptr<Parent> ParentPtr;
class Child
{
public:
ParentPtr father; //如果换成weak_ptr的就可以打破僵局
Child() {
cout << "hello Child" << endl;
}
~Child() {
cout << "bye Child\n";
}
};
typedef std::shared_ptr<Child> ChildPtr;
class Parent {
public:
ChildPtr son; //如果换成weak_ptr的就可以打破僵局
Parent() {
cout << "hello parent\n";
}
~Parent() {
cout << "bye Parent\n";
}
};
}
int main()
{
using namespace demo63;
//shared_ptr sp();
//shared_ptr sp(new int[10], [](int *p) {delete[] p; });
{
ParentPtr p(new Parent());
cout << "p count :" << p.use_count() << endl;
ChildPtr c(new Child());
cout << "c count :" << c.use_count() << endl;
p->son = c;
cout << "c count :" << c.use_count() << endl;
c->father = p;
cout << "p count :" << p.use_count() << endl;
}
//退出循环后,p和c释放,他们只是指针而已,所以会调用一次智能指针的析构函数
//当p要析构的时候,发现本身还被c->father指着,所以对Parent()的引用只是减一,p就析构了,不存在了
//当c要析构的时候,发现本身被先前p指向的Child()对象的son指针引用这,所以,只是引用减一,然后c被销毁。
//而此时,对象Parent和Child都没有被释放,内部的fater指针和son指针还在相互指向着对方。导致内存泄露
return 0;
}
#include
#include
using namespace std;
int main()
{
auto_ptr<int> pInt1(new int(10) );//显式初始化
*pInt1 = 5;
int* pi = new int(88);
//auto_ptr pInt2=pi;//因为构造函数是explicit的,所以不存在这种隐式类型转换
//auto_ptr pInt2 = new int(0);//因为构造函数是explicit的,所以不存在这种隐式类型转换 auto_ptr pInt3 = pInt1; //拷贝构造
*pInt3 = 5;
*pInt1 = 1;//error:在赋值过程中会进行所有权传递,在赋值给pInt3后pInt1就变为empty
return 0;
}
(1)函数指针
函数指针:指向函数的指针变量,本质上是一个指针变量
定义式:
type (*func)(type , type )
如:int (*max)(int a, int b)
(2)指针函数
指针函数:顾名思义就是带有指针的函数,即其本质是一个函数,只不过这种函数返回的是一个对应类型的地址。
定义式
type *func (type , type)
如:int *max(int x, int y)
重载:函数名相同,函数的参数个数、参数类型或参数顺序三者中必须至少有一种不同。函数返回值的类型可以相同,也可以不相同。发生在一个类内部。
重定义:也叫做隐藏,子类重新定义父类中有相同名称的非虚函数 ( 参数列表可以不同 ) ,指派生类的函数屏蔽了与其同名的基类函数。可以理解成发生在继承中的重载。
重写:也叫做覆盖,一般发生在子类和父类继承关系之间。子类重新定义父类中有相同名称和参数的虚函数。(override)
如果一个派生类,存在重定义的函数,那么,这个类将会隐藏其父类的方法,除非你在调用的时候,强制转换为父类类型,才能调用到父类方法。否则试图对子类和父类做类似重载的调用是不能成功的。
重定义规则如下:
1 、如果子类的函数和父类的函数同名,但是参数不同,此时,不管有无virtual,父类的函数被隐藏。
2 、如果子类的函数与父类的函数同名,并且参数也相同,但是父类函数没有vitual关键字,此时,父类的函数被隐藏(如果相同有Virtual就是重写覆盖了)。
重写需要注意:
1、 被重写的函数不能是static的,必须是virtual的;
2 、重写函数必须有相同的类型,名称和参数列表
3 、重写函数的访问修饰符可以不同。
C++:
(1)使用lambda 表达式
#include
int a=[](){写被调用函数; return 0;}();
int main()
{
Return 0;
}
(2)使用全局类对象,在构造函数中调用
class t()
{
Public: t(){调用函数}
写被调用函数;
}
t t_obj;//定义一个全局类对象
int main(){return 0;}
参考博文:关于C/C++中全局变量初始化问题的深入思考
结论:
C和C++中的一般全局变量(不包括类class)是在编译期确定的初始值,而不是在程序运行时,在main函数之前初始化的。
C++中的类的全局变量是在程序运行时,在main函数之前初始化的。
#include
using namespace std;
class CExample {
private:
int a;
public:
//构造函数
CExample()
{
a = 5;
}
CExample(int b)
{
a = b;
}
//拷贝构造函数
CExample(const CExample& obj)
{
this->a = obj.a;
cout << "Call copy costructor" << endl;
}
//拷贝复制运算符
CExample& operator =(const CExample & obj)
{
this->a = obj.a;
cout << "Call Assignment operator" << endl;
return *this;
}
~CExample() {}
//一般函数
int Show()
{
return a;
}
};
int main17()
{
CExample A(100);
cout << "A: a=" << A.Show() << endl;
CExample B = A; //注意这里的对象初始化要调用拷贝构造函数,而非赋值
cout << "B: a=" << B.Show() << endl;
CExample D(10);
cout << "D: a=" << D.Show() << endl;
CExample C(D); //调用拷贝构造
cout << "C: a=" << C.Show() << endl;
CExample E;
E = C;//此种情况下才会调用赋值运算符
cout << "E: a=" << E.Show() << endl;
Char drr[]=”123”保存在栈上,可以使用drr对其修改。
常量定义时必须初始化。局部常量存放在栈区,全局常量存放在全局区或静态存储区。字面值常量,存放在常量存储区。
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用了实际子类的成员函数。这种技术可以让父类的指针有“多种形态”。即子类中重写了父类中的虚函数时,则指向子类的父类指针会调用子类重写的虚函数。而子类未重写的虚函数,则无法调用。
虚函数通过虚函数表实现的。在这个表中,主要是一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。虚函数表的指针在内存中存放于对象实例的最前面。
例如:
类型1:父类
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
Void i(){cout<<”Base::h”<<endl;}
};
类型2: 一般继承(无虚函数覆盖)子类
虚函数按照其声明顺序放于表中,父类的虚函数在子类的虚函数前面。
类型3:有虚函数覆盖子类
只覆盖f(),覆盖的f()函数被放到了虚表中原来父类虚函数的位置。没有被覆盖的函数依旧。
类型4: 多继承无覆盖(多重继承下,会为每个基类创建一个虚函数表)子类
类型5: 多继承有覆盖子类
有了上述关于各种继承类型虚函数表的直观理解,则使用多态变得很简单了:
Derive d; //一个子类对象
Base1 *b1 = &d; //父类指针指向子类对象
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f() 父类指针调用重写的虚函数
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g() 父类指针调用未重写的虚函数
b2->g(); //Base2::g()
b3->g(); //Base3::g()
不行,值传递会从实参向形参拷贝生成临时变量,期间又调用到了拷贝构造函数,因此不行。
虚析构函数作用:总的来说虚析构函数是为了避免内存泄露,而且是当子类中会有指针成员变量时才会真正体现它的价值。也就是说虚析构函数使得在删除指向子类对象的父类指针时可以调用子类的析构函数达到释放子类中堆内存的目的,而防止内存泄露的。
用C++开发的时候,基类的析构函数一般都是虚函数。
例子:
#include
using namespace std;
class ClxBase
{
public:
ClxBase() {};
virtual ~ClxBase() { cout<<"delete ClxBase"<<endl; };
virtual void DoSomething() { cout << "Do something in class ClxBase!" << endl; };
};
class ClxDerived : public ClxBase
{
public:
ClxDerived() {};
~ClxDerived() { cout << "Output from the destructor of class ClxDerived!" << endl; };
void DoSomething() { cout << "Do something in class ClxDerived!" << endl; };
};
int main(int argc, char const* argv[])
{
ClxBase* pTest=new ClxDerived ();
pTest->DoSomething();
delete pTest;
return 0;
}
此时,父类虚析构函数和子类析构函数,执行结果:
Do something in class ClxDerived!
Output from the destructor of class ClxDerived!
delete ClxBase
调用了子类析构函数和父类析构函数。其实在构造的时候也是先构造基类再构造子类。
如果把类ClxBase析构函数前的virtual去掉,那输出结果就是下面的样子了:
Do something in class ClxDerived!
delete ClxBase
不会调用子类的析构函数,如果在实际的开发中,如果子类中有动态内存分配,那么这就会造成内存泄漏。
(1)x86cpu是按照4字节为基本单位进行字节对齐的,同时也按照元素本身大小的整数倍对齐;
(2)x86cpu是按照4字节为基本单位进行字节对齐的,同时数据结构本身按照最大元素大小的整数倍对齐;
例1:
struct A{
char c;
int a;
double b;
};
按照两条规则实施的内存对齐,该结构体所占内存大小为16字节,如图所示:
例2:
struct B{
char c;
double b;
int a;
};
注:结构体大小为24字节,既要保证b以8的倍数对齐,也要保证结构体以8的整数倍对齐。
结构体内存如图所示:最后一行空白为了保证结构体的对齐而补充的空白字节。
位域: 把一个字节的二进制位划分为几个不同的区域,说明每个区域的位数。每个域有一个域名,允许程序按域名操作。
对齐规则:
(1)如果相邻位域字段类型相同,且位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段的位置。
(2)若相邻位域字段相同,但其位宽之和大于类型sizeof大小,则后面字段从新的存储单元开始,其偏移量为其字段类型大小的整数倍。
(3)如果相邻位域字段的类型不同,则有两种方式,vc6采用不压缩方式,即按照字段类型大小的倍数存放。
//例如:
struct bs
{
int a:8; //字节占1个字节,b和c占用一个字节。
int b:2;
int c:6;
};
注:bs总共占用2个字节大小,但实际sizeof(bs)=4字节,因为cpu按4字节对齐读取。
(1)简单类
class A
{
char c;
int i;
};
int main()
{
int x;
char y;
cout<<sizeof(x)<<" "<<sizeof(y)<<endl;
A tpa;
cout<<sizeof(tpa);
}
类中包含int和char类型,但是simple占用空间大小为8字节。内存分布图如右图所示,c占1个字节,i占用4个字节。输出结果和内存结构如下:
(2)带成员函数的类
struct B {
public:
int bm1;
protected:
int bm2;
private:
int bm3;
static int bsm;
void bf();
static void bsf();
typedef void* bpv;
struct N { };
};
B中,为何static int bsm不占用内存空间?因为它是静态成员,该数据存放在程序的数据段中,不在类实例中。
(3)单继承
struct C
{
int c1;
void cf();
};
struct D : C
{
int d1;
void df();
};
子类保存了基类的所有属性和行为,每个子类都保存了一份完整的基类的实例,在D中,并不是说基类C的数据一定要放在D的数据之前,只不过这样放的话,能够保证D中的C对象地址,恰好是D对象地址的第一个字节。这种安排之下,有了派生类D的指针,要获得基类C的指针,就不必要计算偏移量了。几乎所有知名的C++厂商都采用这种内存安排。在单继承类层次下,每一个新的派生类都简单地把自己的成员变量添加到基类的成员变量之后。看看上图,C对象指针和D对象指针指向同一地址。
(4)多继承
struct E {
int e1;
void ef();
};
struct F : C, E {
int f1;
void ff();
};
在多重继承下,内嵌的两个基类的对象指针不可能全都与派生类对象指针相同:
图中说明C对象指针与F类指针相同,E类指针与F对象指针不同。
观察类布局,可以看到F中内嵌的E对象,其指针与F指针并不相同。正如后文讨论强制转化和成员函数时指出的,这个偏移量会造成少量的调用开销。