一、指针
1.1 C++的双指针
C++的双指针是指一个指针指向另一个指针的指针,如int **p
,其指向的是一个指针的地址,也就是说,p=某个指针的地址
,*p=这个指针指向的地址
,而p
本身也是一个int类型的数据
简单的例子如下:
int a = 10;
int *p = &a;
//p
指向a的地址&a
,
*p
指向a的值
,这是一级指针,它的值是内存中存放变量a的地址
int **pp = &p;
//pp
指向p
的地址&p
,*pp
指向p
的值,即&a
,这是二级指针,它的值是内存中存放变量p的地址
1 |
|
- 运行结果:
- 原因:
- p本身也是一个int型变量,只是存的值是地址;对所存地址进行*p解引用就可以实现取值;
- 单指针函数的话传的是p,所以其实传进去的是a的地址,然后该地址赋值给新的指针p1,所以已经没有原先p指针的信息了,无法实现修改原先p指针的指向对象,只能改变a的值;
- 双指针传的是&p,也就是p的地址,所以该地址赋值给新的指针p2,可以实现修改原先p的指向对象
1.2 指针和引用的区别
- 指针是一个变量,它存储的是一个地址;而引用是一个别名,它是一个常量,它本质上跟被引用的变量是同一个东西。
- 指针有多级而引用只有一级;指针可以指向空,而引用不行;通过指针传参时会重新拷贝到另一个指针中,而引用会直接传实参
- sizeof时,指针占用的空间是固定的(8bytes),而引用占用的空间是被引用变量的大小;
指针和引用的使用方式不同:
- 指针:
int *p = &a;
- 通过赋值变量
a
的地址给指针p
- 通过
*p
来解引用访问a
的值 - 非指针常量的话,指针变量可以重新赋值改变指向的地址,即
p = &b;
- 通过赋值变量
- 引用:
int &r = a;
- 通过
&
符号来取地址,然后赋值给引用r
- 直接通过
r
来访问a
的值 - 引用不可以重新赋值,即
r = b;
是不允许的,所以引用必须在定义时初始化
- 通过
什么时候用指针,什么时候用引用?
- 使用引用:当需要减少拷贝时;当传递类对象时标准方式是使用引用
- 使用指针:当传入基础类型的数组时使用指针;当需要在函数中改变指向的对象时;当需要返回函数内局部变量的内存地址时
代码举例:
1 | class Test { |
1.3 野指针和悬空指针
野指针
指尚未初始化的指针,既不指向合法的内存空间,也没有使用 NULL/nullptr 初始化指针。
出现野指针举例:
1 | int *p;//未初始化,野指针 |
悬空指针
指向已经释放的内存地址的指针(释放前是合法的指针)
出现悬空指针的原因主要有三种:
- 指针释放资源后没有被重新赋值 OR
指针释放后没有置为
nullptr
1
2
3int *p = new int(10);
delete p;
//p没有被重新赋值或者置为nullptr - 超出变量作用域
1
2
3
4
5
6int *p;
{
int a = 10;
p = &a;
}
//a是局部变量,超出作用域,p成为悬空指针 - 指向函数返回的局部变量的指针或者引用
1
2
3
4
5int* fun() {
int a = 10;
return &a;
}
int *p = fun();1
2
3
4
5int& fun() {
int a = 10;
return a;
}
int &p = fun();
上述提到的野指针和悬空指针都是危险的,因为它们可能会访问到非法内存,导致程序崩溃。
1.4 智能指针
1.4.1 内存溢出 Out of Memory
内存溢出是指程序申请的内存超过了系统能提供的内存,导致程序崩溃。
内存溢出的原因主要是因为申请内存过大:程序申请的内存超过了系统能提供的内存
- 比如,程序申请了一个很大的数组,但是系统内存不足,导致内存溢出
- 再比如,申请了一个
int
变量,但是实际给它赋值了一个很大的数(如long才能存下的数),导致内存溢出 - 内存泄漏最终会导致内存溢出
1.4.2 内存泄漏 Memory Leak
内存泄漏是指程序分配了一块内存空间,但由于某种原因,程序没有释放或者无法释放这块内存空间,导致这块内存空间永远无法被使用,这就是内存泄漏。
内存泄漏的原因主要有两种:
- 堆内存泄漏:程序在堆上分配了内存,但是没有释放,导致内存泄漏。通常是因为程序员使用
new
或者malloc
分配内存,但是忘记使用delete
或者free
释放内存。 - 资源泄漏:程序在使用资源时,没有释放,导致资源泄漏。比如打开文件、打开数据库连接等,但是没有关闭,久而久之会导致其它程序无法使用它。
如何避免内存泄漏:
- 首先是记得及时释放,一般会在析构函数中释放内存类的资源(但是如果类的对象也是用
new
分配的内存,那么还是要手动释放对象才能调用析构函数) - 其次是使用智能指针,实现自动管理内存。(智能指针过期后会自动调用析构函数,释放内存)
- 可以使用RAII(资源获取即初始化,智能指针就是采用的RAII实现的资源管理)技术,即在构造函数中申请资源,在析构函数中释放资源,这样可以保证资源的及时释放。
- 用工具检查内存泄漏,如
BoundsChecker
、Valgrind
等 - 调用DEBUG版程序的
CRT
堆栈提示分析泄漏原因
题外话说一下缓冲区溢出:缓冲区溢出是指如vector
、string
等这种带索引的容器,当索引超出容器的范围时,会导致程序崩溃。
1.4.3 智能指针
智能指针是C++11引入的一种内存管理方式,它是一个类模板,可以自动管理内存,避免内存泄漏。智能指针在<memory>
头文件中。
智能指针的主要作用是解放程序员,实现自动释放内存,当智能指针超出作用域时,会自动调用析构函数,释放内存。
常用的智能指针有unique_ptr
、shared_ptr
和weak_ptr
是C++11标准,而auto_ptr
是C++98标准,已经被C++17废弃。
1.4.3.1 unique_ptr
通常一块内存可以被多个普通指针指向,但是unique_ptr
是独占的,即一块内存只能有一个unique_ptr
指向它。
首先来产生用unique_ptr
管理普通的指针,实现没有delete
也能自动释放普通指针
1 | class TestSmartPtr { |
结果:
1 | 调用了Test的构造函数 |
但实际上,有了智能指针之后,我们很少再使用普通指针了,所以我们一般是直接在构造函数中new
一个对象,或者使用make_unique
函数来创建对象。因此初始化时主要有三种方法:
unique_ptr<TestSmartPtr> uni_ptr(new TestSmartPtr("Test"));
unique_ptr<TestSmartPtr> uni_ptr = make_unique<TestSmartPtr>("Test");
auto uni_ptr = make_unique<TestSmartPtr>("Test");
unique_ptr<TestSmartPtr> uni_ptr(p);
不建议使用这种方式,会暴露原始指针
unique_ptr
怎么保证独占?
unique_ptr
是通过在其模板类的定义中禁止拷贝构造函数和赋值运算符来保证独占的。
禁止拷贝构造函数:
unique_ptr(const unique_ptr&) = delete;
1
2
3unique_ptr<TestSmartPtr> uni_ptr1(new TestSmartPtr("Test1"));
unique_ptr<TestSmartPtr> uni_ptr2 = uni_ptr1; //赋值,编译报错
unique_ptr<TestSmartPtr> uni_ptr3(uni_ptr1); //拷贝,编译报错禁止赋值运算符:
unique_ptr& operator=(const unique_ptr&) = delete;
1
2
3unique_ptr<TestSmartPtr> uni_ptr1(new TestSmartPtr("Test1"));
unique_ptr<TestSmartPtr> uni_ptr2(new TestSmartPtr("Test2"));
uni_ptr2 = uni_ptr1; //编译报错
为什么需要保证unique_ptr
的独占?
如果不独占的话,当我们用多个智能指针指向同一个对象时,当多个智能指针过期时会调用多次析构函数,这样除了第一次调用析构函数是正常的,其它的调用都是对野指针的操作,会导致程序崩溃。
1.4.3.2 shared_ptr
shared_ptr
是共享的智能指针,它可以多个shared_ptr
指向(关联)同一个对象,在内部采用引用计数来实现共享管理:
- 当有一个
shared_ptr
与对象关联时,引用计数加1(内部的引用计数是线程安全的) - 当最后一个
shared_ptr
超出作用域时,表示没有任何与其关联的对象了,才会调用析构函数(其它情况的超出作用域只会将引用计数值减1)。此时shared_ptr
自动变为nullptr
,防止出现悬空指针。
相比于unique_ptr
,shared_ptr
的拷贝构造函数和赋值运算符是允许的,同时还多了一个use_count
函数,用来获取当前引用计数的值。
1 | class TestSmartPtr { |
运行结果:
1 | 调用了Test的构造函数 |
在使用左右值引用时,左值的引用计数会减1,右值的引用计数会加1。
1 | int main() { |
运行结果:
1 | 调用了Test的构造函数 |
用unique_ptr好,还是shared_ptr好?
一般情况下,能用unique_ptr
就用unique_ptr
,因为unique_ptr
的效率更高,而且更安全。
而如果有需要共享的情况,那么就用shared_ptr
。
给unique_ptr和shared_ptr自定义删除器
三种自定义删除器的方式:普通函数、仿函数、lambda表达式
普通函数
1
2
3
4void deleteFunc(TestSmartPtr* t) {
cout << "使用普通函数方式自定义删除器(全局函数)\n";
delete t;
};仿函数
1
2
3
4
5
6struct deleteClass {
void operator()(TestSmartPtr* t) {
cout << "使用仿函数的方式自定义删除器\n";
delete t;
}
};lambda表达式
1
2
3
4auto deleteLamb = [](TestSmartPtr* t) {
cout << "使用Lambda表达式的方式自定义删除器\n";
delete t;
};
给unique_ptr
和shared_ptr
添加自定义删除器并进行测试:
1 | int main() { |
1.4.3.3 weak_ptr
weak_ptr
是shared_ptr
的弱引用,是为了解决shared_ptr
的循环引用问题。它不控制对象的生命周期,但是可以判断对象是否存在。
由于weak_ptr
只做引用不做计数,所以当指向的对象被释放时,weak_ptr
是不知道的,因此需要用lock
函数将weak_ptr
提升为shared_ptr
,这样就可以判断对象是否存在。
1 | int main() { |
当两个shared_ptr
相互引用时,会导致引用计数永远不为0。如下所示:
1 | class TestSmartPtr2; |
运行结果:没有调用两个类的析构函数
1 | 调用了第一个类的构造函数 |
解决方法:
- 将两个类中的
shared_ptr
改为weak_ptr
- 在需要使用时通过
expired
函数判断是否过期(线程不安全) weak_ptr
不能直接访问资源、不控制对象生命周期,但是可以通过lock
函数提升为shared_ptr
,该函数同时也可以判断是否过期。(线程安全的)
1 | int main() { |
运行结果:
1 | 调用了第一个类的构造函数 |
1.5 this指针
this
指针是一个隐式的指向当前对象首地址的指针。this指针只有在对象中的成员函数中才有定义,因此,你获得一个对象后,也不能通过对象使用此指针。(只能在类内部使用,且静态成员函数不能使用)
静态成员函数与一般成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员。this指针不能访问全局函数和静态函数。而编译器会给普通成员函数隐式传参传入this指针(所以成员函数的作用周期就是this的作用周期)
由于成员函数是在代码区的,所以this指针隐式传入成员函数的作用就是让成员函数直到是那个实例化对象在调用该函数。所以delete this
的话会释放掉该实例化对象的内存空间。而由于delete this
本身就是释放内存会调用析构函数,所以如果在析构函数中调用delete this
会导致无限递归调用析构,最终堆栈溢出从而导致程序崩溃。
要注意,this
指针是常量指针,不能被赋值,也不能被删除。
- 它会在构造函数和析构函数中自动被创建和销毁。
- 如果在析构函数中显式删除
this
指针,由于delete this
的操作本身就会调用析构函数,所以会导致无限递归调用析构,最终堆栈溢出从而导致程序崩溃。
this指针存在栈或者全局区或者寄存器中。标准情况下this为右值,不能通过取地址符号返回this指针的地址,但是可以返回this指针指向的地址。
1.6 new和delete
1.6.1 实现机制
new和delete是C++中用来动态分配和释放内存的运算符。
- new:首先执行标准库函数
operator new
分配内存 -> 然后调用构造函数初始化对象 -> 最后返回对象的指针(如果内存分配失败,会抛出std::bad_alloc
异常) - delete:首先调用析构函数 ->
然后执行标准库函数
operator delete
释放内存
1.6.2 new和malloc的区别
new底层是通过malloc实现的,所以理论上是可以直接free掉new出来的实例化对象,但是这样的话不会调用实例化对象的析构函数
- new是C++运算符,malloc是C标准库函数
- 内存分配:new是自动计算大小的,malloc是需要手动输入分配的内存大小;new使用
operator new
分配内存 - 内存分配失败:new分配失败时会抛出异常报错,而malloc会直接返回空指针
- 初始化和释放:new初始化会调用构造函数、delete时会调用析构函数再释放,而malloc会直接释放内存
- 数据类型检查:new会有数据类型检查,而malloc直接申请一块内存,需要做类型强制转换,也不进行类型安全性检查
1.7 辨别几种指针的区别
1 | int *p[10]; |
- 指针数组:存放多个指针的数组
int *p[10]
- 数组指针:某个普通数组的指针,如
int (*p)[10]
表示一个int型数组的地址 - 函数返回指针:
int * p(int)
表示函数返回一个int型的指针,传参为int - 函数指针:
int (*p)(int)
其中p
表示某个函数的指针,返回值是int,传参是int
二、多态
C++的多态有两种:静态多态和动态多态。
其中,静态多态是通过函数重载和运算符重载实现的,而动态多态是通过虚函数实现的。
除此以外,C++还有模板多态,即通过模板实现的多态。(CRTP)
2.1 动态多态:
C++的动态多态是通过父类定义虚函数实现和子类重写来实现。
- 基类定义至少一个虚函数,此时该基类就会在编译时确定一个静态数组(也就是虚函数表),里面存放了所有虚函数的地址,每个类的所有类对象共享这个虚函数表。
- 其中,对于普通虚函数,父类可以选择声明实现or不实现虚函数
- 但是如果是纯虚函数,那么父类不能实现具体函数,只能由子类实现,且此时父类为抽象类,不能实例化。如果子类没有实现纯虚函数,那么子类也是抽象类,不能实例化。
接下来来具体从虚函数、虚函数表、虚函数指针这几个方面来进行讲解。
2.1.1 虚函数
虚函数是在基类中使用virtual
关键字声明的函数。在基类中声明虚函数后,派生类可以覆盖(重写)该函数,实现多态。虚函数在内存中存储于代码区,而虚函数表存储于常量区
- 虚函数表:每个带有虚函数的类都有一个虚函数表
- 虚函数指针:每个实例化对象都有一个虚函数8bytes的指针,指向虚函数表
1 | class Base { |
如上述代码,fun
用virtual
关键字声明,那么fun
就是一个虚函数。结合下文将说到的虚函数指针,没有声明虚函数的类根本不会有虚表指针,也不支持多态(动态)。
2.1.2 虚函数表vtbl
虚函数表中存储了该类所有的虚函数的地址。一个类的所有对象共享一个虚函数表,这个表是在编译时就已经生成的。
如果有一个Base
类,其中有一个虚函数fun
,那么它的虚函数表中就会存储fun
的地址。
Base中的函数 | 虚函数表地址 |
---|---|
fun1 | 0x00f21569 |
fun2 | 0x00f21596 |
虚析构 | 0x00f21573 |
虚函数表是在编译时确定的,且每个类的所有对象共享一个虚函数表。
2.1.3 虚函数指针vptr
虚函数指针是实例化对象中指向虚函数表的地址的指针。含有虚函数的类的对象或者继承了含有虚函数的类的对象都会有一个虚函数指针。
普通类:无虚函数,无虚函数表
1
2
3
4class Base {
public:
void fun();
};如上面的代码,
Base
类中没有虚函数,所以Base
类是一个普通类,在编译时不会有虚函数表。也因此,当我们创建一个
Base
类的对象时,该对象自然没有虚函数指针这个变量存在所以当我们用
sizeof
函数查看对象的大小,得到的结果是1
(因为对象的大小是由成员变量决定的,而Base
类中没有虚函数也没有成员变量,所以对象的大小是1
byte,因为对象的大小至少要为1
)。这也说明了普通函数的地址不存储在类对象中,而是存储在代码段中。
1
2
3
4
5int main() {
Base b;
cout << sizeof(b) << endl; // 1
return 0;
}虚函数类:有虚函数,有虚函数表
1
2
3
4class Base {
public:
virtual void fun();
};如上面的代码,
Base
类中有虚函数,所以Base
类是一个虚函数类,在编译时会有虚函数表。当我们创建一个Base
类的对象时,用sizeof
函数查看对象的大小,得到的结果是4
(因为对象的大小是由成员变量决定的,而Base
类中有虚函数,所以对象的大小是4
byte,因为对象的大小至少要为1
)。这也说明了虚函数的地址存储在类对象中,而不是存储在代码段中。
1
2
3
4
5int main() {
Base b;
cout << sizeof(b) << endl; // 8(64位系统)
return 0;
}
2.1.4 虚函数的工作原理和多态体现
多态原理:
- 常规的多态是指在基类中定义一个虚函数,然后在派生类中重写这个虚函数
- 这样在基类指针或者引用绑定派生类对象时,调用这个虚函数时,会调用派生类的虚函数。
1 | class Base { |
内存布局
拥有虚函数的基类会在内存中共享一个虚函数表,而这个虚函数表中存储了所有虚函数的地址。
当我们创建一个基类的对象时,这个对象会有一个虚函数指针,指向这个虚函数表。
而虚函数表和虚函数指针都是在编译时就已经生成的。
存在继承关系时的内存布局
假设基类Base
有三个虚函数f
、g
、h
,那么在编译时,Base
类的虚函数表中会存储这三个虚函数的地址。
当派生类Derived
继承Base
类时,由于Derived
类重写了其中的f
函数,所以Derived
类的虚函数表中会存储f
函数的地址,而g
和h
函数在虚函数表中的值跟Base
类一样。
当用Base
类的指针指向Derived
类的对象时,构造函数会先调用Base
类的构造函数,然后调用Derived
类的构造函数。由于虚函数指针是跟对象绑定的,所以此时其实用的还是Derived
对象的内存空间,所以虚函数指针指向的是子类Derived类的虚函数表。
2.1.5 为什么说继承属于动态的多态?
- 函数静态绑定:在编译期间绑定的,一般非虚函数都是静态绑定
- 函数动态绑定:在运行期间绑定的,一般虚函数都是动态绑定
动态多态之所以被称为动态的原因在于,它是在程序运行时(而非编译时)确定对象的类型和应该调用的函数的。
虽然虚函数表和虚函数指针都是在编译时确定的(但是并没有分配内存,运行时才会分配内存),但是编译期间虚函数指针是没有具体指向的。
只有当构造函数调用时,虚函数指针才会指向具体的虚函数表,而构造函数的调用是在程序运行时才会发生的,这也是继承属于动态绑定的原因。
继承下构造函数的调用顺序:自上而下:自基类到派生类
虚析构函数的调用顺序:自下而上:自派生类到基类
总结:编译时查看的是 Shape
类有没有这个接口, 而在运行时会查虚函数表,
才决定具体调用哪个(动态多态)
2.2 静态多态
静态多态是通过函数重载和运算符重载实现的。具体不多说了
2.3 模板多态(CRTP)
模板多态是通过模板实现的多态,即CRTP(Curiously Recurring Template Pattern)。(也称为奇异递归模板模式)
CRTP
将模板和继承相结合,形成一种新的设计模式。
(1)通过继承实现的多态是绑定的和动态的:
- 绑定的含义是:对于参与多态行为的类型,它们(具有多态行为)的接口是在公共基类的设计中就预先确定的(有时候也把绑定这个概念称为入侵的或者插入的)。
- 多态的含义是:接口的绑定是在运行期(动态)完成的。
(2)通过模板实现的多态是非绑定的和静态的:
- 非绑定的含义是:对于参与多态行为的类型,它们的接口是没有预先确定的(有时也称这个概念为非入侵的或者非插入的)。
- 静态的含义是:接口的绑定是在编译期(静态)完成的。
2.3.1 模板
模板是C++中的一种泛型编程技术,通过模板可以实现类型参数化,即可以将类型作为参数传递给类或者函数。我们常用的vector <int>
或
vector <string>
等都是通过模板实现的。C++中编译器会从函数模板通过具体类型产生不同的函数
模板在函数中的使用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16template <typename T>
T getMax(T a, T b) {
return a >= b ? a : b;
}
int main() {
int a = 2;
int b = 10;
cout << "getMax(2, 10):" << getMax(a, b) << endl;//getMax(2, 10):10
double a1 = 44.2;
double b1 = 10.2;
cout << "getMax(44.2, 10.2):" << getMax(a1, b1) << endl;//Max(44.2, 10.2):44.2
return 0;
}从上面的代码例子可以看出,通过模板类的实现使
getMax
可以接受任意类型的输入参数。模板在类中的使用
#### 2.3.2 CRTP的实现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
27template <typename T>
class testTemp {
public:
testTemp(T a1, T b1) :a(a1), b(b1) {}
T a;
T b;
void printMax() {
cout << "printMax:" << getMax() << endl;
}
T getMax();
};
template <typename T>
T testTemp<T>::getMax() {
return (a >= b ? a : b);
}
int main() {
testTemp<int> m_test(12, 22);
m_test.printMax();//printMax:22
testTemp<double> m_test2(12.23, 22.45);
m_test2.printMax();//printMax:22.45
return 0;
}
CRTP是通过模板实现的多态,它的实现原理是通过模板实现继承。也就是创建一个模板类,然后其它类通过继承这个模板类来实现多态。
- 首先创建一个模板基类
1 | template <typename T> |
- 然后创建两个继承这个模板基类的类
1 | //通过模板多态的方式基础模板类Base,此时模板传入的参数为类Child1 |
- 接着再提供一个函数,用来调用基类中的
foo
函数(委托函数printTest作为中间代理)
1 | template <typename T> |
- 最后在
main
函数中调用
1 | int main() { |
- 运行结果
1 | Child1: Hello |
2.4 C++面向对象三大特性:封装、继承、多态
2.4.1 封装
封装是指将数据和操作数据的函数封装在一起,形成一个类。封装可以隐藏类的实现细节,只提供公共接口给外部使用。
2.4.2 继承
继承是让子类继承父类的属性和方法,修饰符public
、protected
、private
用来控制继承的访问权限。
public
:公有继承,子类可以访问父类的public
成员protected
:保护继承,子类可以访问父类的protected+public
成员- 但是再次继承该子类的类不能访问父类的
protected
成员 - 其实例化对象也不能访问父类的
protected
成员
- 但是再次继承该子类的类不能访问父类的
private
:私有继承,子类可以访问父类的private+protected+public
成员
2.4.3 多态
多态如上面说的分为静态多态和动态多态,其中动态多态是通过虚函数实现的。
- 静态多态是通过函数重载和运算符重载实现的,是编译时的多态
- 动态多态是通过虚函数实现的,是运行时的多态
- 将基类指针或者引用绑定派生类对象时,调用虚函数时,会调用派生类的虚函数
三、虚基类
虚基类是指在多重继承中,子类采用: virtual public Base
的方式继承父类,是为了解决二义性问题而引入的
二义性是指在菱形继承中,子类继承了两个父类,而这两个父类又继承了同一个父类,那么子类就会继承两份相同的父类,这就会导致二义性问题。
虚基类的使用可以有效防止菱形继承下调用两次(或多次)父类的构造函数。
参考:C++ 虚基类
四、深拷贝和浅拷贝
4.1 浅拷贝
浅拷贝是指拷贝对象时,只是拷贝对象的值,而对于指针类型变量,不会重新分配内存,而是拷贝指针的地址。
移动构造函数通常就是浅层拷贝,传参是右值引用(将亡值)。我们用对象a去实例化b时,实例化完后a其实就没用了,所以我们可以将a的资源直接转移到b,这样就不用再重新分配内存。因此传入右值或者将亡值时才会触发移动构造函数
4.2 深拷贝
深拷贝相比浅拷贝,对于指针类型变量,会重新分配内存,并将指针指向的地址拷贝到新的内存空间,然后使用memcpy
函数将原对象的值拷贝到新的内存空间。
深拷贝可以通过拷贝构造函数和赋值运算符重载实现。传参是左值参数。下面将展示这两种实现方法
1 | class testCopy { |
最终输出结果如下:
1 | t1 data: |
五、常用的string、vector、map、set
5.1 string VS char*
char*
是C语言中的字符串,指向一个字符数组,以\0
结尾,其内存是由程序员分配和释放的,所以可能会有空间不足的问题。通过const char *str = "hello";
的方式创建的字符串是只读的,不能修改。1
2const char* str = "hello";
str[0] = 'H'; //编译报错但是通过
char str[] = "hello";
的方式创建的字符串是可读写的,可以修改。1
2char str[] = "hello";
str[0] = 'H'; //编译通过string
是C++中的字符串,是标准库类型STL中的一个类,内部封装了很多字符串操作的方法(比如查找、替换、删除等),而且内存是由系统自动分配和释放的,所以除非内存不足,否则不会出现空间不足的问题。通过string str = "hello";
的方式创建的字符串是可读写的,可以修改。1
2string str = "hello";
str[0] = 'H'; //编译通过
**1)string和char*的关系**
string底层是通过char*
实现的,所以string和char*
之间可以相互转换。
**2)string和char*的相互转换**
string
转char*
:string
的c_str()
函数可以将string
转换为char*
。通过转换可以在printf
等函数中使用string
。1
2
3string str = "hello";
printf("%s\n", str.c_str());
char* cstr = const_cast<char*>(str.c_str());//str.c_str()返回的是const char*,所以需要转换为char*char*
转string
:string
的构造函数可以将char*
转换为string
。以及通过赋值运算符=
也可以将char*
转换为string
。1
2
3const char* cstr = "hello";
string str(cstr);//构造函数
string str2 = cstr;//赋值运算符
5.2 vector VS 数组
5.2.1 vector的初始化
vector
的初始化可以通过构造函数、赋值运算符、列表初始化的方式进行。
通过构造函数初始化
1
2vector<int> v0(5);//初始化5个元素,每个元素的值为0
vector<int> v1(5, 1);//初始化5个元素,每个元素的值为1通过赋值运算符初始化
1
2vector<int> v2 = {1, 2, 3, 4, 5};//初始化5个元素,每个元素的值为1, 2, 3, 4, 5
vector<int> v3 = v2;//将v2的值赋给v3通过列表初始化初始化
1
2vector<int> v4{1, 2, 3, 4, 5};//初始化5个元素,每个元素的值为1, 2, 3, 4, 5
vector<int> v5 = {1, 2, 3, 4, 5};//初始化5个元素,每个元素的值为1, 2, 3, 4, 5二维vector的初始化
1
vector<vector<int>> v6(3, vector<int>(4, 1));//初始化3行4列,每个元素的值为1
5.2.2 vector的迭代器遍历
vector
的迭代器遍历可以通过迭代器、auto关键字、范围for循环的方式进行。
通过迭代器遍历:迭代器是一种指针,可以通过
begin()
和end()
函数获取vector
的首地址和尾地址。1
2
3
4
5
6
7
8vector<int> v = {1, 2, 3, 4, 5};
for (vector<int>::iterator it = v.begin(); it != v.end(); it++) {
cout << *it << " ";//通过*解引用
}
for (auto it = v.begin(); it!=v.end(); i++){
cout << *it << " ";
}
cout << endl;通过范围for循环遍历
1
2
3
4for (auto i : v) {
cout << i << " ";
}
cout << endl;
5.2.3 vector与数组的区别
- 相同点
vector
和数组都是线性结构,都是连续的内存空间(这里的连续空间是指虚拟内存,在物理内存中不保证连续)。vector
和数组都是有序的,都可以通过下标访问元素。
- 不同点
vector
是动态数组,可以动态增加和删除元素,而数组是静态数组,长度是固定的。vector
是STL中的容器,提供了很多成员函数,可以方便的进行插入、删除、查找等操作,而数组没有这些功能。vector
是类,而数组是基本数据类型,所以vector
可以继承,而数组不能继承。vector
是可以通过v1=v2
的方式进行赋值的,而数组不能直接通过这种方式进行赋值。
5.3 set 和 map容器
5.3.1 set
set
是集合,是一种关联式容器,它的主要特点如下:
set
底层是红黑树RBTree,是一种平衡二叉树,所以查找、插入、删除的时间复杂度都是O(logn)。- 其元素只有key,没有value(或者说value就是key)
set
中的元素是唯一的,不允许重复。set
中的元素是有序的,是按照key的升序排列的。set
中的元素是不可修改的,如果要修改元素,需要先删除再插入。(否则的话修改会破坏红黑树的平衡性)
set
的基本函数操作有:
insert
:插入元素erase
:删除元素find
:查找元素count
:统计元素个数(因为元素是唯一的,所以相当于判断元素是否存在)begin
:返回指向第一个元素的迭代器end
:返回指向最后一个元素的迭代器size
:返回元素个数empty
:判断是否为空
操作举例:
1 | set<int> s; |
5.3.2 map
map
跟set
类似,也是一种关联式容器,底层也是由红黑树RBTree实现的,key
具有唯一性和有序性。但是map
中的元素是pair键值对的形式存在的,key
是唯一的,而value
可以重复。
map
的基本函数操作有:
insert
:插入元素erase
:删除元素find
:查找元素count
:统计元素个数(因为元素是唯一的,所以相当于判断元素是否存在)begin
:返回指向第一个元素的迭代器end
:返回指向最后一个元素的迭代器size
:返回元素个数empty
:判断是否为空
操作举例:
1 |
|
5.3.3 multiset和multimap
扩展讲一下multiset
和multimap
,multiset
和multimap
是set
和map
的多重集合版本,特点是允许key
重复。底层也是由红黑树RBTree实现的。
六、自动类型推导
自动类型推导有两种方式:auto
和decltype
。
6.1 auto
auto
是C++11新特性,可以自动推导变量的类型,可以用于变量声明、函数返回值、模板参数等。
auto是编译器通过初始值去判断类型的
6.2 decltype
decltype
是C++11新特性,可以获取表达式的类型,可以用于变量声明、函数返回值、模板参数等。
decltype是编译器通过变量或表达式去判断类型的
使用decltype
有两种方式:decltype(表达式)和decltype(变量)。
变量方法
1
2
3
4
5
6int main() {
int a = 10;
decltype(a) b = 20;//b的类型和a的类型相同
auto c = a;//c的类型和a的类型相同
return 0;
}表达式方法
1
2
3
4
5
6
7
8int main() {
int a = 10;
decltype(a) b = 20;//b的类型和a的类型相同
auto c = a;//c的类型和a的类型相同
decltype(a + 1) d = 30;//d的类型和a+1的类型相同
auto e = a + 1;//e的类型和a+1的类型相同
return 0;
}
七、C++11新特性
auto
:自动类型推导decltype
:获取表达式的类型lambda
:是一种内嵌的匿名函数(闭包实例)- 可以捕获变量(值分为捕获和引用捕获两种)、传递参数、返回值等
nullptr
:用来代替NULL
,NULL
在C++中是一个宏定义,跟0是一样的,所以在重载函数时会出现二义性问题,而nullptr
是一个关键字智能指针
:shared_ptr
、unique_ptr
、weak_ptr
初始化列表
:{}
,用来初始化数组、结构体、类等,其中list列表变量初始化的顺序是由参数声明的顺序决定的,不是初始化列表的顺序决定的基于范围的for循环
:for(auto i : v)右值引用
:&&
,用来实现移动语义- 右值(将亡值/纯右值)不能取地址,左值可以取地址
- 右值引用不能绑定到任何左值,如果想实现绑定,需要使用
std::move
函数 - 左值引用就是普通的引用
1
2
3
4
5int a = 10;//左值是a,右值是10
int &b = a;
//int &c = 10;//编译报错,10是右值
int &&c = 10;
c = 20;//修改右值
noexcept
:用来指定函数是否抛出异常explicit
:用来修饰构造函数,取消隐式转换
九、C++ STL
C++中STL是指:
- 算法:排序、查找、替换等方法
- 容器:vector、list、deque、set、map等数据结构的容器,可以容纳不同类型的数据
- 迭代器:指针用来遍历容器的数据
顺序式容器有:vector
、list
、deque
(双向开口线性空间)、queue
、stack
、priority_queue
- 除了list外,插入删除(insert/erase)都会使原先取出的迭代器失效
关联式容器有:set
、map
、multiset
、multimap
- 插入删除不会使迭代器失效(只有删除的那个元素的迭代器失效)
- set和map是红黑树实现的,所以查找、插入、删除的时间复杂度都是O(logn)
各种容器的迭代器类别如下表:
容器 | 迭代器类别 | 说明 |
---|---|---|
vector deque | 随机访问迭代器 | 支持+ 、- 、+= 、-= 、[] 、< 、> 、<= 、>= |
stack queue priority_queue | 不支持迭代器(不能被遍历) | 无法遍历 |
list (multi)map/set | 双向迭代器 | 支持++ 、-- 、* 、-> |
unordered_map/set forward_list | 前向迭代器(只能向前移动) | 支持++ 、* 、-> |
9.1 STL动态分配空间
从堆上分配空间的话有一级配置器和二级配置器:
- 一级配置器是通过
malloc
和free
实现的 - 二级配置器是默认的分配方式(需要分配的空间小于128Byte时使用的),是通过维护内存池+自由链表实现的
9.2 vector
1)基础知识
vector
是分配的连续地址空间,所以假设我们提前获取了vector中某个元素的地址,然后再插入元素,由于要保证vector的连续性,所以这个地址就会失效。
- 而
deque
是由一段一段连续的空间拼接起来的
2)vector的双倍扩容
vector中有效元素个数是size()
,而分配的空间大小是capacity()
,当插入元素且有size()==capacity()
时,说明vector中的空间已经分配完了,这时就需要重新分配空间
- 而
dequeue、stack、queue
也需要提前分配内存,但是是一次分配固定大小的内存,且不用重新拷贝,而是通过指针指向下一段的内存。
vector当分配的空间不足时,新分配的空间会是原来的2倍,因为每次扩容就需要拷贝原先的数据,所以多分配内存的话能够减少内存分配和拷贝的次数,提高效率。
扩容有两种方法:
resvser(n)
:预先分配空间,但是不会改变size()
,只会改变capacity()
,将capacity()
预先扩大到nresize(n,t)
:同时改变size()
和capacity()
都扩大到n,同时将新增的元素初始化为t
3)vector释放内存
vector有两种方式清楚数组:
clear()
:清空数组,但是不会释放内存,size()
变为0,capacity()
不变swap(vector<int>())
:清空数组,释放内存,size()
和capacity()
都变为0
9.3 list
list
是双向链表实现的,所以各元素之间地址空间不连续,因此可以在任意位置插入元素,不会影响其他元素的地址。(这与vector不同)
vector和list的区别:
- vector随机访问效率更高(可以通过下标访问),而list只能通过迭代器访问O(n)
- list在中间插入时效率高,因为vector插入时需要挪动后面的元素,且插入后后面的地址失效,而list依然有效
9.4 Hash(unordered_map)
unordered_map
是C++11中引入的新容器,是哈希表,是一种关联式容器,底层是哈希表实现的,key
具有唯一性和无序。哈希表查询时间复杂度是O(1)。
其中解决哈希冲突的方式是开链法,C++中哈希表中的哈希槽bucket
是用vector
实现的,但是每个bucket
中存储的是链表,因此unordered_map
插入新元素时原先的迭代器指针也不会失效。当然也可以使用其他方法解决哈希冲突问题:
- 线性探测
- 再散列:维护多个不同的哈希函数,当发生冲突时,再使用另一个哈希函数,直到找到空槽
九、C++、C、Python的区别
1. C++和C的区别
- 编译链接不同:C++因为有重载,所以函数名会在C++编译器中进行名称改编,而C语言不会。C++编译后会生成
.obj
文件,C语言编译后会生成.o
文件 - C++支持面向对象,而C语言不支持
- C++中内存分配和释放是通过
new
和delete
,而C语言只能通过malloc
和free
- C++中有引用,而C语言没有
- C++中有try/catch/throw的异常处理
- C++中有模板,而C语言没有
- C++新增了关键字:namespace、bool、true、false、new等
2. C++和Python的区别
- C++是编译型语言,Python是解释型语言
- Python支持的库很多,但是运行速度比C++慢
- Python有严格的缩进规则,用缩进代表代码块,而C++用花括号{}代表代码块
- C++变量需要声明,而Python不需要