参考引用
- 阿秀的学习笔记
- 本博客对上述笔记进行较大程度的整合、精简与补充
61. 内存泄露
- 一般常说的内存泄漏是指堆内存泄漏,堆内存是指程序从堆中分配的、大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用 malloc、realloc、 new 等函数从堆中分配到块内存,使用完后,程序必须调用 free 或 delete 释放该内存块,否则这块内存就不能被再次使用,就说这块内存泄漏
- 造成内存泄漏的几种情况
- 指针重新赋值
- 错误的内存释放
- 返回值的不正确处理
- new 和 delete 没有配对使用
- 解决方法
- 有 new 就有 delete,有 malloc 就有 free,保证它们一定成对出现
- 对象数组的释放一定要用 delete []
- 将基类的析构函数声明为虚函数
- 使用智能指针
62. this 指针详解
- 定义
- this 指针是类的指针,指向对象的首地址,this 指针只能在非静态成员函数中使用,用来指向调用该函数的对象的地址(在全局函数、静态成员函数(属于类)中都不能用 this)
- 用处
- 一个对象的 this 指针并不是对象本身的一部分,不会影响 sizeof(对象) 的结果
- this 作用域在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数、静态函数中不能使用 this),编译器会自动将对象本身的地址(this 指针)作为一个隐含参数传递给函数,也就是说:即使没写 this 指针,编译器在编译时也会加上 this,它作为非静态成员函数的隐含形参,对各成员的访问均通过 this 进行
- 使用场景
- 1、在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this;
- 2、当形参数与成员变量名相同时用于区分,如 this->n = n(不能写成 n = n)
- this 指针什么时候创建的?
- this 指针在非静态成员函数开始执行前构造,在非静态成员函数执行结束后清除
- this 指针存放在何处?
- this 指针会因编译器不同而有不同的放置位置:可能是栈,也可能是寄存器,甚至全局变量
- this 指针如何访问类中的变量?
- C++ 中类和结构体是只有一个区别的:类的成员默认是 private,而结构体是 public
- this 是类的指针,如果换成结构体,那 this 就是结构体的指针
63. C++11 有哪些新特性?
- nullptr 替代 NULL
- 引入了 auto 和 decltype 这两个关键字实现了类型推导
- 基于范围的 for 循环 for (auto& i : res){}
- 类和结构体中的初始化列表
- Lambda 表达式(匿名内联函数)
- 引入智能指针实现自动释放动态分配的对象,防止堆内存泄漏
64. auto 和 decltype 的用法
- auto
- 和原来那些只对应某种特定的类型说明符(例如 int)不同,auto 让编译器通过初始值来进行类型推演,从而获得定义变量的类型,所以 auto 定义的变量必须有初始值
- decltype
- 有时希望从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量,这时 auto 无力解决
- 所以 C++11 又引入了第二种类型说明符 decltype,它的作用是选择并返回操作数的数据类型,在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值
65. NULL 和 nullptr 区别
- NULL 来自 C 语言,一般由宏定义实现,而 nullptr 则是 C++11 的新增关键字
- 在 C 语言中,NULL 被定义为 (void*)0,而在 C++ 中,NULL 则被定义为整数 0,在 C++ 中指针必须有明确的类型定义,但是将 NULL 定义为 0 带来一个问题是无法与整数 0 区分,nullptr 被引入解决这一问题
- nullptr 可以明确区分整型和指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误
66. 智能指针详解
- 原理
- 智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源
- 常用智能指针
- shared_ptr
- 采用引用计数器的方法,允许多个智能指针指向同一个对象,每多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加 1,每减少一个智能指针指向对象时(析构),引用计数会减 1,当计数为 0 的时候会自动释放动态分配的资源
- unique_ptr
- 采用独享所有权语义,一个非空的 unique_ptr 总是拥有它所指向的资源
- 转移一个 unique_ptr 将会把所有权全部从源指针转移给目标指针,源指针被置空,所以 unique_ptr 不支持普通的拷贝和赋值操作,不能用在 STL 标准容器中,局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁)
- weak_ptr
- 引用计数有一个问题就是互相引用形成环(环形引用),这样两个指针指向的内存都无法释放,需要使用 weak_ptr 打破环形引用
- weak_ptr 是一个弱引用,它是为了配合 shared_ptr 而引入的一种智能指针,它指向一个由 shared_ptr 管理的对象而不影响所指对象的生命周期,也就是说,它只引用而不计数
- 如果一块内存被 shared_ptr 和 weak_ptr 同时引用,当所有 shared_ptr 析构之后,不管还有没有 weak_ptr 引用该内存,内存也会被释放。所以 weak_ptr 不保证它指向的内存一定是有效的,在使用之前使用函数 lock() 检查 weak_ptr 是否为空指针
- auto_ptr
- 主要是为解决 “有异常抛出时发生内存泄漏” 的问题,因为发生异常而无法正常释放内存
- auto_ptr 有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题,而 unique_ptr 则无拷贝语义,但提供了移动语义,这样的错误不再可能发生,因为很明显必须使用 std::move() 进行转移
- auto_ptr 不支持拷贝和赋值操作,不能用在 STL 标准容器中,因为 STL 容器中的元素经常要支持拷贝、赋值操作,在这过程中 auto_ptr 会传递所有权,所以不能在 STL 中使用
- shared_ptr
- 智能指针的循环引用问题
- 循环引用是指使用多个 share_ptr 时,出现了指针之间相互指向,从而形成环的情况,有点类似于死锁的情况,这种情况下,智能指针往往不能正常调用对象的析构函数,从而造成内存泄漏
- 在实际编程过程中,应该尽量避免出现智能指针之前相互指向的情况,如果不可避免,可以使用使用弱指针 weak_ptr,它不增加引用计数,只要出了作用域就会自动析构
67. Lambda 表达式详解
- lambda 表达式的语法定义
- [capture-list]:捕获列表(必写项)
- 该列表总是出现在 lambda 函数的开始位置,编译器根据 [] 来判断接下来的代码是否为 lambda 函数
- 捕获列表能够捕获上下文中的变量供 lambda 函数使用
- 捕获列表可有多个捕捉项构成,可以混合捕获,以逗号分割,如 [=,&a,b]
- 捕获列表不允许变量重复传递:如 [=,a] 重复传递了变量 a
[var]:表示值传递捕获变量 var [=]:表示值传递方式捕获所有父作用域中的变量,lambda 上面的变量(父作用域是指包含 lambda 函数的语句块) [&var]:表示引用传递捕获变量 var [&]:表示引用传递捕获所有父作用域中的变量 [this]:表示值传递捕获当前的 this 指针
- (parameters):参数列表(非必写项)
- 与普通函数的参数列表一致,如果不需要参数传递,则可以连同 () 一起省略
- mutable
- 默认情况下,lambda 函数总是一个 const 函数,mutable 可以取消其常量性
- 使用该修饰符时,参数列表不可省略(即使参数为空),一般不需要该参数
- -> return-type:返回值类型(非必写项)
- 用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分可省略
- 返回值类型明确情况下,也可省略,由编译器对返回类型进行推导
- {statement}:函数体(必写项)
- 在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量
[capture-list] (parameters) mutable -> return-type { statement };
- [capture-list]:捕获列表(必写项)
- lambda 表达式是一个匿名函数,无法直接调用,可以利用 auto 将其值赋给一个变量,这时候该变量就可像函数一样使用,如下例所示:利用捕获列表以引用传参的方式交换两个值
int main() { int a = 10, b = 20; auto swap = [&a,&b]() { int tmp = a; a = b; b = tmp; }; swap(); cout << a << endl; cout << b << endl; }
- lambda 表达式底层原理
- 编译器在底层对于 lambda 表达式的处理方式,完全就是按照函数对象的方式处理的,就是对()进行了重载
- 定义一个 lambda 表达式后,编译器会自动生成一个匿名类,在该类中对 operator () 运算符进行重载
- lambda 表达式之间不能赋值,因为每个 lambda 表达式的类型都是不同的
68. 虚函数、纯虚函数
虚函数
-
虚函数是指在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将根据所指对象的实际类型来调用相应的函数:如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数
- 只有类的成员函数才能说明为虚函数
- 普通/友元函数不能写为虚函数
- 普通/友元函数不属于类的成员函数,不具有继承特性,对于没有继承特性的函数没有虚函数的说法
- 静态函数不能写为虚函数
- 静态函数不属于任何类对象或类实例,且静态成员函数没有 this 指针,而虚函数依靠虚函数表指针 vptr 来处理,且 vptr 只能用 this 指针来访问它
- 内联函数不能写为虚函数
- 内联函数在编译阶段进行函数体的替换操作,而虚函数则在运行期间进行类型确定
- 为什么构造函数不能写为虚函数?
- 构造函数在对象创建时自动调用,派生类的构造函数是在基类构造函数完成时才被调用,因此在构造函数中使用虚函数没有意义,因为虚函数的动态绑定机制还未生效,无法保证调用正确的派生类函数
- 虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚构造函数
- 为什么析构函数一般写成虚函数?
- 如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏
- 析构函数还可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化,但派生类中可以根据自身需求重新改写基类中的纯虚函数
-
虚函数的代价
- 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类
- 带有虚函数的类的每一个对象,都会有一个指向虚函数表的指针,会增加对象的空间大小
虚表和虚表指针
-
虚函数表(Virtual Function Table)
- C++ 中每个类(包括基类和派生类)都包含一个虚函数表,也称为虚表(vtable)。虚表是一个包含虚函数指针的数组,每个虚函数指针指向相应虚函数的地址,虚表是在编译时创建的,并且对于每个类只有一个
- 虚表是类的元数据,它记录了该类的虚函数及其地址,当类的对象被创建时,一个指向虚表的指针会添加到对象的内存布局中
- 虚函数表存放于只读数据段(.rodata),也就是 C++ 内存模型中的常量区,而虚函数则位于代码段(.text),也就是 C++ 内存模型中的代码区
-
虚表指针
- 在含有虚函数的类实例化对象时,对象地址的前四个字节存储指向虚表的指针,在构造函数中被初始化
- 如果子类没有重写虚函数,那么子类对象仍然有虚表指针,虚表指针指向的是基类的虚表
- 虚表指针的初始化时间:对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面
纯虚函数和抽象基类
- 纯虚函数(Pure Virtual Function)是 C++ 中的一个特殊类型的虚拟函数,它在基类中声明但没有定义
- 纯虚函数的声明使用 virtual 关键字,并在函数声明的末尾添加 = 0 来表示它是一个纯虚函数
- 子类(派生类)必须提供纯虚函数的实际实现,否则子类也会被标记为抽象类,无法创建对象
// Shape 类包含一个纯虚函数 draw(),因此 Shape 类是一个抽象基类,不能创建它的对象 class Shape { public: // 声明纯虚函数 // 使用 virtual 关键字告诉编译器将该函数视为虚函数,它可以在派生类中被覆盖(重写) virtual void draw() = 0; // 普通成员函数 void displayInfo() { // 这里可以包含一些通用的代码 std::cout << "This is a shape." << std::endl; } }; class Circle : public Shape { public: // 子类必须提供纯虚函数的实现 // 必须使用 override 关键字,以确保正确的函数被覆盖 void draw() override { std::cout << "Drawing a circle." << std::endl; } }; class Square : public Shape { public: // 子类必须提供纯虚函数的实现 void draw() override { std::cout << "Drawing a square." << std::endl; } }; int main() { Circle circle; Square square; circle.displayInfo(); // 调用基类函数 circle.draw(); // 调用派生类函数 // 允许不同的派生类以不同的方式实现相同的虚函数 square.displayInfo(); // 调用基类函数 square.draw(); // 调用派生类函数 return 0; }
- 抽象基类
- 定义
- 当类中有了纯虚函数,这个类也称为抽象类
- 作用
- 将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作
- 特点
- 无法实例化对象
- 子类必须重写抽象类中的纯虚函数,否则也属于抽象类
- 抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出
- 如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类,它是一个可以建立对象的具体的类
- 抽象基类为什么不能创建对象(实例化)?
- 抽象方法是在抽象基类中定义的方法,但并未提供具体实现,而是由子类来实现。因此,由于抽象方法没有具体实现,实例化抽象基类将导致无法为这些抽象方法提供具体实现
- 定义
69. 多态
- 多态的原理基于虚函数和虚表,它允许在运行时根据对象的实际类型来选择要调用的函数版本,从而实现了面向对象编程中的灵活性和可扩展性,多态原理/流程如下
- 1、在基类指针或引用上调用虚函数
- 2、运行时系统会查找对象的虚表指针
- 3、使用虚表指针找到虚表
- 4、从虚表中获取正确的函数指针
- 5、调用相应的函数
虚函数的调用关系:this -> vptr -> vtable -> virtual function
- 多态满足条件:有继承关系、子类重写父类中的虚函数
- 多态使用方式:父类指针或引用指向子类对象
class Animal { public: // 函数前加上 virtual 关键字变成虚函数,那么编译器在编译时就不能确定函数调用了 virtual void speak() { cout << "动物在说话" << endl; } }; class Cat : public Animal { public: void speak() { cout << "小猫在说话" << endl; } }; class Dog :public Animal { public: void speak() { cout << "小狗在说话" << endl; } }; // 如果函数地址在编译阶段就能确定,那么静态联编 // 如果函数地址在运行阶段才能确定,就是动态联编 void DoSpeak(Animal & animal) { animal.speak(); } void test01() { Cat cat; DoSpeak(cat); Dog dog; DoSpeak(dog); } int main() { test01(); system("pause"); return 0; }
70. 虚析构和纯虚析构
- 背景:多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码
- 解决方式:将父类中的析构函数改为虚析构或者纯虚析构
- 虚析构和纯虚析构共性
- 可以解决父类指针释放子类对象
- 都需要有具体的函数实现
- 虚析构和纯虚析构区别
- 如果是纯虚析构,该类属于抽象类,无法实例化对象
如果子类中没有堆区数据,可以不写为虚析构或纯虚析构
- 虚析构和纯虚析构共性
71. 什么是虚拟继承
- 菱形继承(多重继承)
- 两个派生类继承同一个基类
- 又有某个类同时继承两个派生类
- 这种继承被称为菱形继承,或者钻石继承
- 菱形继承(多重继承)优缺点
- 多重继承的优点很明显,就是对象可以调用多个基类中的接口
- 子类继承两份相同的数据,导致资源浪费以及毫无意义
- 若派生类所继承的多个基类有相同的基类,而派生类对象需要调用这个祖先类的接口方法,则出现二义性
- 利用虚继承可以解决菱形继承(多重继承)问题
class Animal { public: int m_Age; }; // 继承前加 virtual 关键字后,变为虚继承 // 此时公共的父类 Animal 称为虚基类 class Sheep : virtual public Animal {}; class Tuo : virtual public Animal {}; class SheepTuo : public Sheep, public Tuo {};
72. 移动构造函数
- 有些情况:用对象 a 初始化对象 b 后对象 a 就不再使用了,但是对象 a 的空间还在(析构前),既然拷贝构造函数,实际上就是把 a 对象的内容复制一份到 b 中,那为什么不直接使用 a 的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本,这就是移动构造函数设计的初衷
- 拷贝构造函数中对于指针一定要采用深层复制,而移动构造函数中对于指针则采用浅层复制
- C++ 引入了移动构造函数,专门处理这种:用 a 初始化 b 后,就将 a 析构的情况
73. 泛型编程与模板
- C++ 另一种编程思想称为泛型编程:主要利用的技术就是模板
- C++ 提供两种模板机制:函数模板和类模板
- 函数模板和类模板的区别
- 函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定
- 即函数模板允许隐式调用(自动类型推导)和显式调用而类模板只能显式调用:在使用时类模板必须加 <T>,而函数模板不必
- 类模板在模板参数列表中可以有默认参数
函数模板
-
作用
- 建立一个通用函数,其函数返回值类型和形参类型可以不具体制定,用一个虚拟的类型来代表
-
语法
// template --- 声明创建模板 // typename --- 表面其后面的符号是一种数据类型,可以用 class 代替 // T --- 通用的数据类型,名称可以替换,通常为大写字母 template<typename T> // 函数声明或定义
-
示例
// 交换整型函数 void swapInt(int& a, int& b) { int temp = a; a = b; b = temp; } // 交换浮点型函数 void swapDouble(double& a, double& b) { double temp = a; a = b; b = temp; } // 利用模板提供通用的交换函数 template<typename T> void mySwap(T& a, T& b) { T temp = a; a = b; b = temp; } int main() { int a = 10; int b = 20; //swapInt(a, b); // 使用模板实现交换两种方式 // 1、自动类型推导,必须推导出一致的数据类型 T 才可使用 mySwap(a, b); // 2、显式指定类型 //mySwap(); // 错误,模板不能独立使用,必须确定出 T 的类型 mySwap<int>(a, b); cout << "a = " << a << endl; cout << "b = " << b << endl; system("pause"); return 0; }
-
普通函数与函数模板区别
- 普通函数调用时可以发生自动类型转换(隐式类型转换)
- 函数模板调用时,如果利用自动类型推导,不会发生隐式类型转换,如果利用显式指定类型的方式,可以发生隐式类型转换
建议使用显式指定类型的方式调用函数模板,因为可以自己确定通用类型 T
-
普通函数与函数模板的调用规则
- 如果函数模板和普通函数都可以实现,优先调用普通函数
- 可以通过空模板参数列表来强制调用函数模板
- 函数模板也可以发生重载
- 如果函数模板可以产生更好的匹配,优先调用函数模板
- 如果提供了函数模板,最好就不要提供普通函数,否则容易出现二义性
-
注意事项
- 利用具体化的模板,可以解决自定义类型(如类)的通用化
- 学习模板并不是为了写模板,而是在 STL 能够运用系统提供的模板
类模板
-
作用
- 建立一个通用类,类中的成员 数据类型可以不具体制定,用一个虚拟的类型来代表
-
语法
// template --- 声明创建模板 // typename --- 表面其后面的符号是一种数据类型,可以用 class 代替 // T --- 通用的数据类型,名称可以替换,通常为大写字母 template<typename T> // 类
-
示例
#include <string> // 类模板 template<class NameType, class AgeType> class Person { public: Person(NameType name, AgeType age) { this->mName = name; this->mAge = age; } void showPerson() { cout << "name: " << this->mName << " age: " << this->mAge << endl; } public: NameType mName; AgeType mAge; }; int main() { // 指定 NameType 为 string 类型,AgeType 为 int 类型 Person<string, int>P1("孙悟空", 999); P1.showPerson(); system("pause"); return 0; }
-
类模板中成员函数和普通类中成员函数创建时机
- 普通类中的成员函数一开始就可以创建
- 类模板中的成员函数在调用时才创建
-
模板和实现可以不写在一个文件里面吗?
- 因为在编译时模板并不能生成真正的二进制代码,而是在编译调用模板类或函数的 .cpp 文件时才会去找对应的模板声明和实现,在这种情况下编译器是不知道实现模板类或函数的 .cpp 文件的存在,所以它只能找到模板类或函数的声明而找不到实现,所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义
- 解决方式 1:直接包含 .cpp 源文件
- 解决方式 2:将声明和实现写到同一个文件中,并更改后缀名为 .hpp
- 因为在编译时模板并不能生成真正的二进制代码,而是在编译调用模板类或函数的 .cpp 文件时才会去找对应的模板声明和实现,在这种情况下编译器是不知道实现模板类或函数的 .cpp 文件的存在,所以它只能找到模板类或函数的声明而找不到实现,所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义
-
其他注意事项
- 当子类继承的父类是一个类模板时,子类在声明的时候,要指定出父类中 T 的类型,如果不指定,编译器无法给子类分配内存,如果想灵活指定出父类中 T 的类型,子类也需变为类模板
- 类模板中成员函数类外实现时,需要加上模板参数列表
74. 什么是 STL
- C++ STL 从广义来讲包括了三类:算法,容器和迭代器,STL 几乎所有的代码都采用了类模板或函数模板
- STL 大体分为六大组件,分别是:容器、算法、迭代器、仿函数、适配器(配接器)、空间配置器
-
容器:各种数据结构,如 vector、list、deque、set、map 等,用来存放数据
- 序列式容器:强调值的排序,序列式容器中的每个元素均有固定的位置(list、vector 等)
- 关联式容器:二叉树结构,各元素之间没有严格的物理上的顺序关系(set、map 等)
-
算法:各种常用的算法,如 sort、find、copy、for_each 等
- 质变算法:是指运算过程中会更改区间内的元素的内容,例如拷贝,替换,删除等
- 非质变算法:是指运算过程中不会更改区间内的元素内容,例如查找、计数、遍历、寻找极值等
-
迭代器:扮演了容器与算法之间的胶合剂,在不暴露容器内部结构的情况下对容器遍历,迭代器 ++it、it++ 比较
- 前置返回一个引用,后置返回一个对象
- 前置不会产生临时对象,后置必须产生临时对象,临时对象会导致效率降低
-
仿函数:行为类似函数,可作为算法的某种策略
-
适配器:一种用来修饰容器或者仿函数或迭代器接口的东西
-
空间配置器:负责空间的配置与管理
-
75. 什么是 RAII
- RAII(Resource Acquisition is Initialization,资源获取即初始化),也就是说在构造函数中申请分配资源,在析构函数中释放资源,智能指针(std::shared_ptr 和 std::unique_ptr)即 RAII 最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记 delete 造成的内存泄漏
76. C++ 左值引用和右值引用
- C++11 通过引入右值引用来优化性能,即通过移动语义来避免无谓的拷贝问题,通过 move 语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去,通过完美转发来解决不能按照参数实际类型来转发的问题
- 在 C++11 中所有的值必属于左值、右值两者之一
- 可以取地址的、有名字的就是左值
- 不能取地址的、没有名字的就是右值(又可细分为纯右值、将亡值)
- 无论是声明一个左值引用还是右值引用,都必须立即进行初始化
- 引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名
- 左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名
- 左值引用通常也不能绑定到右值,但常量左值引用是个 “万能” 的引用类型
- 右值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要 std::move() 将左值强制转换为右值
- 左值引用通常用于传递可修改的参数和返回引用值,右值引用通常用于实现移动构造函数和移动赋值运算符
- 左值具有持久性、可寻址性,而右值一般是临时的、不可寻址的
#include <bits/stdc++.h> using namespace std; template<typename T> void fun(T&& t){ cout << t << endl; } int getInt() { return 5; } int main() { int a = 10; int& b = a; // b 是左值引用 int& c = 10; // 错误,c 是左值不能使用右值初始化 int&& d = 10; // 正确,右值引用用右值初始化 int&& e = a; // 错误,e 是右值引用不能使用左值初始化 const int& f = a; // 正确,左值常引用相当于是万能型,可以用左值或者右值初始化 const int& g = 10; // 正确,左值常引用相当于是万能型,可以用左值或者右值初始化 const int&& h = 10; // 正确,右值常引用 const int& aa = h; // 正确 int& i = getInt(); // 错误,i 是左值引用不能使用临时变量(右值)初始化 int&& j = getInt(); // 正确,函数返回值是右值 fun(10); // 此时 fun 函数的参数 t 是右值 fun(a); // 此时 fun 函数的参数 t 是左值 return 0; }
77. STL 容器总结
-
STL 每种容器对应的迭代器
-
STL 各容器使用场景示例
- 需要随机访问,则使用 vector 单端数组 或 deque 双端数组容器
- 需要在尾部频繁插入/删除,则使用 vector 单端数组
- 需要在首部和尾部频繁插入/删除,则使用 deque 双端数组
- 需要在任意位置频繁插入/删除,则使用 list 双向链表
- 需要保持元素有序且不重复,则使用 set 集合容器
- 需要保持元素有序且可重复,则使用 multiset 多重集合容器
78. vector 和 deque
vector
-
vector 是一个能够存放任意类型的动态数组,能够增加和压缩数据,vector 之所以被认为是一个容器,是因为它能够像容器一样存放各种类型的对象,deque 也是一个 STL 动态数组类,与 vector 非常类似,但支持在数组开头和末尾插入或删除元素
-
vector 相比数组的优缺点
- 优点
- 使用的时候无须声明上限,随着元素的增加,vector 的长度会自动增加
- vector 类提供额外的方法来增加、删除元素,比数组操作高效
- 缺点
- 时间:运行速度比数组慢
- 空间:clear() 无法清空内存
- 优点
-
vector 大小和容量
- vector 的大小指的是实际存储的元素数,vector 的容量指的是在重新分配内存以存储更多元素前,vector 能够存储的元素数,vector 的大小 <= 容量
- size() 返回的是已用空间大小,capacity() 返回的是总空间大小,capacity() - size() 则是剩余的可用空间大小,当 size() 和 capacity() 相等,说明 vector 目前的空间已被用完,如果再添加新元素,则会引起 vector 空间的动态增长
- 由于动态增长会引起重新分配内存空间、拷贝原空间、释放原空间,这些过程会降低程序效率。因此,可以使用 reserve(n) 预先分配一块较大的指定大小的内存空间,这样当指定大小的内存空间未使用完时,是不会重新分配内存空间的,这样便提升了效率
// 要查询 vector 当前存储的元素数,可调用 size() cout << "Size: " << integers.size () << endl; // 要查询 vector 的容量,可调用 capacity() cout << "Capacity: " << integers.capacity () << endl;
vector 为什么通常是两倍扩容?
- 性能考虑:在实际使用中,vector 需要频繁地添加元素。如果每次只扩容一格,那么频繁的内存分配和拷贝操作会增加时间开销,而一次性扩容两倍可以减少这种开销,因为扩容次数更少
- 连续内存分配:vector 内部的元素是在连续的内存空间中存储的。当需要扩容时,如果直接在当前内存空间后面分配新空间,这会导致内存碎片化。而一次性扩大为两倍的大小,可以更好地保证获取连续内存,避免频繁的内存分配和释放导致的内存碎片化问题
- 内存使用效率:相对于每次扩容 1 个单位,两倍扩容可以更高效地利用内存,减少内存碎片化的可能性,提高内存的使用效率
-
vector 使用示例
#include <vector> #include <algorithm> using namespace std; void MyPrint(int val) { cout << val << endl; } void test01() { // 创建 vector 容器对象 v,并通过模板参数指定容器中存放的数据类型 vector<int> v; // 向容器中存放数据 v.push_back(10); v.push_back(20); v.push_back(30); v.push_back(40); // 每一个容器都有自己的迭代器,迭代器是用来遍历容器中的元素 // v.begin() 返回迭代器,这个迭代器指向容器中第一个数据 // v.end() 返回迭代器,这个迭代器指向容器元素的最后一个元素的下一个位置 // vector<int>::iterator 拿到 vector<int> 这种容器的迭代器类型 // 第一种遍历方式 for (vector<int>::iterator it = v.begin(); it != v.end(); it++) { cout << *it << endl; } cout << endl; // 第二种遍历方式 // 使用 STL 提供标准遍历算法,需提供头文件 <algorithm> for_each(v.begin(), v.end(), MyPrint); } int main() { test01(); system("pause"); return 0; }
给 vector 添加元素时,应首选 push_back()
- 因为将元素插入 vector 时,insert() 可能是效率最低的(插入位置不是末尾时),因为在开头或中间插入元素时,将导致 vector 类将后面的所有元素后移(为要插入的元素腾出空间),这种移动操作可能需要调用复制构造函数或赋值运算符,因此开销可能很大
- 如果需要频繁地在容器中间插入元素,应选择使用 std::list
- 手写 vector 实现
#include <iostream> template <class T> class MyVector { private: T* elements; // 存储元素的数组指针 int capacity; // 容量 int currentSize; // 当前大小 public: // 构造函数:列表初始化 MyVector 对象 MyVector() : elements(nullptr), capacity(0), currentSize(0) {} // 获取向量的大小 int size() { return currentSize; } // 在 MyVector 尾部添加元素 void push_back(const T& element) { // 如果当前大小等于容量,则需要扩展数组 if (currentSize == capacity) { // 扩展数组容量为原来的两倍 int newCapacity = (capacity == 0) ? 1 : capacity * 2; T* newElements = new T[newCapacity]; // 将元素从旧数组复制到新数组 for (int i = 0; i < currentSize; ++i) { newElements[i] = elements[i]; } // 释放旧数组的内存,并更新指针和容量 if (elements != nullptr) { delete[] elements; } elements = newElements; capacity = newCapacity; } // 在末尾添加新元素并更新当前大小 elements[currentSize] = element; currentSize++; } // 获取指定索引位置的元素 T& at(int index) { if (index < 0 || index >= currentSize) { // 如果索引超出范围 throw std::out_of_range("Index out of range"); } return elements[index]; // 返回索引处的元素引用 } // 析构函数,释放动态分配的内存 ~MyVector() { if (elements != nullptr) { delete[] elements; } } }; int main() { MyVector<int> myVector; // 创建整型向量对象 myVector.push_back(10); // 添加元素 myVector.push_back(20); // 输出向量元素 std::cout << "Elements in myVector: "; for (int i = 0; i < myVector.size(); ++i) { std::cout << myVector.at(i) << " "; } std::cout << std::endl; return 0; }
deque
- deque(双端队列)底层实现原理是使用一段连续的存储空间,被分配为多个内存块,每个内存块独立分配,内部使用指针互相连接
- deque中包含一个中控器,中控器中存放着指向第一块内存块和最后一块内存块的迭代器,以及指向每个内存块的迭代器,中控器的作用是管理内存块的分配和释放,并提供访问内存块的接口
- deque 的元素在内存中并不是连续存储的,而是分散存储在不同的内存块中,但是中控器提供的接口使得 deque 的使用者可以像访问连续存储空间一样访问 deque 的元素,即:可以使用迭代器、下标操作符等对 deque 进行遍历和访问
- 对于 deque 的插入和删除操作,由于元素在内存中是分散存储的,因此需要在中控器中进行内存块的分配和释放,并更新迭代器指向。由于 deque 的实现比较复杂,因此相比于 vector 等容器,deque 的效率相对较低,但是 deque 在某些场景下具有优势,比如插入和删除操作比较频繁、需要在 deque 的两端进行操作等
- deque 与 vector 的差异
- deque 允许于参数时间内对头端进行元素的插入或移除操作
- deque没有所谓容量(capacity)概念,因为 deque 是动态地以分段连续空间组合而成。
- deque 的迭代器并不是普通的指针,比 vector 复杂多,因此除非必要,尽可能使用 vector 而非 deque
79. stack 和 queue
stack
- stack(栈)是一种先进后出的数据结构,只有一个入口和出口,那就是栈顶,除了获取栈顶元素外,没有其他方法可以获取到内部的其他元素
- stack 除了默认使用 deque 作为其底层容器之外,也可以使用双向开口的 list,只需要在初始化 stack 时,将 list 作为第二个参数即可,由于 stack 只能操作顶端的元素,因此其内部元素无法被访问,也不提供迭代器
queue
- queue(队列)是一种先进先出的数据结构,只有一个入口和一个出口,分别位于最底端和最顶端,除了出口元素外,没有其他方法可以获取到内部的其他元素
- queue 除了默认使用 deque 作为其底层容器外,也可使用 list 作为底层容器,不具有遍历功能,没有迭代器
- stack 和 queue 都不是真正意义上的容器,而是容器适配器
priority_queue
- priority_queue(优先队列),是一个拥有权值观念的 queue,它跟 queue 一样是顶部入口,底部出口,在插入元素时,元素并非按照插入次序排列,它会自动根据权值(通常是元素的实值)排列,权值最高,排在最前面
- priority_queue 是一种容器适配器,底层是一个完全二叉树的大堆(堆总是一颗完全二叉树,根结点最大的堆叫做大堆;根结点最小的堆叫做小堆),通常使用堆(heap)数据结构来实现,堆是一种具有特定性质的二叉树,可以高效地插入新元素和取出优先级最高的元素
80. map
-
map 的所有元素会根据键值自动排序,map 中所有元素都是 pair,拥有键值(key)和实值(value)两个部分,并且不允许元素有相同的 key,一旦 map 的 key 确定,那么是无法修改的,但可修改这个 key 对应的 value
-
标准 STL map 的底层机制是 RB-tree(红黑树)
- 红黑树是自平衡的二叉搜索树
- 若左子树不空,则左子树上所有结点的值均小于或等于它的根结点的值
- 若右子树不空,则右子树上所有结点的值均大于或等于它的根结点的值
- 因为 map 要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低
- 红黑树是自平衡的二叉搜索树
-
unordered_map 和 map 的区别
- map 支持键值的自动排序,底层机制是红黑树,红黑树的查询和维护时间复杂度均为 O ( l o g n ) O(logn) O(logn),但是空间占用比较大,因为每个节点要保持父节点、子节点及颜色信息
- unordered_map 是 C++11 新添加的容器,底层机制是哈希表,通过 hash 函数计算元素位置,其查询时间复杂度为 O ( 1 ) O(1) O(1),维护时间与 bucket 桶所维护的 list 长度有关,但是建立 hash 表耗时较大
- map 适用于有序数据的应用场景,unordered_map 适用于高效查询的应用场景