目录
1. 语言对比
1.1 C++ 11 新特性
2.2 C 和 C++ 的区别
2.3 Python 和 C++ 的区别
2. 编译内存相关
2.1. C++ 程序编译过程
2.2. C++ 内存管理
2.3. 栈和堆的区别
2.4. 变量的区别
2.5. 全局变量定义在头文件中有什么问题?
2.6. 内存对齐
2.7. 什么是内存泄露
2.8. 怎么防止内存泄漏?内存泄漏检测工具的原理?
2.9. 智能指针有哪几种?智能指针的实现原理?
2.10 智能指针应用举例
2.11 一个 unique_ptr 怎么赋值给另一个 unique_ptr 对象?
2.12 使用智能指针会出现什么问题?怎么解决?
2.13 VS检测内存泄漏,定位泄漏代码位置方法
2.14 深拷贝与浅拷贝
2.15 虚拟内存
2.16 简单说一下大端、小端
3. 类相关
3.1 什么是虚函数?什么是纯虚函数?
3.2 虚函数和纯虚函数的区别?
3.3 虚函数的实现机制
3.4 单继承和多继承的虚函数表结构
3.5 为什么构造函数不能为虚函数?
3.6 为什么析构函数可以为虚函数,如果不设为虚函数可能会存在什么问题?
3.7 .不能声明为虚函数的有哪些
4. 语言特性相关
4.1 左值和右值的区别?左值引用和右值引用的区别,如何将左值转换成右值?
4.2 std::move() 函数的实现原理
4.3 什么是指针?指针的大小及用法?
4.5 C++ 11 nullptr 比 NULL 优势
4.6 指针和引用的区别?
4.7 常量指针和指针常量的区别
4.8 函数指针和指针函数的区别
4.9 强制类型转换有哪几种?
4.10 如何判断结构体是否相等?能否用 memcmp 函数判断结构体相等?
4.11 参数传递时,值传递、引用传递、指针传递的区别?
4.12 什么是模板?如何实现?
4.13 函数模板和类模板的区别?
4.14 什么是可变参数模板?
4.15 什么是模板特化?为什么特化?
4.16 include " " 和 <> 的区别
4.17 泛型编程如何实现?
4.18 C++命名空间
4.19 C++ STL六大组件
5. 面向对象
5.1 什么是面向对象?面向对象的三大特性
5.2 重载、重写、隐藏的区别
5.3 如何理解 C++ 是面向对象编程
5.4 什么是多态?多态如何实现?
5.5 静态多态与动态多态:
6. 关键字库函数
6.1 sizeof 和 strlen 的区别
6.2 lambda 表达式(匿名函数)的具体应用和使用场景
6.3 explicit 的作用(如何避免编译器进行隐式类型转换)
6.4 C 和 C++ static 的区别
6.5 static 的作用
6.6 static 在类中使用的注意事项(定义、初始化和使用)
6.7 static 全局变量和普通全局变量的异同
6.8 const 作用及用法
6.9 define 和 const 的区别
6.10 define 和 typedef 的区别
6.11 用宏实现比较大小,以及两个数中的最小值
6.12 inline 作用及使用方法
6.13 inline 函数工作原理
6.14 宏定义(define)和内联函数(inline)的区别
6.15 new 的作用?
6.16 new 和 malloc 如何判断是否申请到内存?
6.17 delete 实现原理?delete 和 delete[] 的区别?
6.18 new 和 malloc 的区别,delete 和 free 的区别
6.19 malloc 的原理?malloc 的底层实现?
6.20 C 和 C++ struct 的区别?
6.21 为什么有了 class 还保留 struct?
6.22 struct 和 union 的区别
6.23 class 和 struct 的异同
6.24 volatile 的作用?是否具有原子性,对编译器有什么影响?
6.25 什么情况下一定要用 volatile, 能否和 const 一起使用?
6.26 返回函数中静态变量的地址会发生什么?
6.27 extern C 的作用?
6.28 sizeof(1==1) 在 C 和 C++ 中分别是什么结果?
6.29 memcpy 函数的底层原理?
6.30 strcpy 函数有什么缺陷?
6.31 auto 类型推导的原理
6.32 malloc一次性最大能申请多大内存空间
6.33 public、protected、private的区别
7 git 分布式版本控制系统
7.1 什么是git?
7.2 为什么要用git?
7.3 简述集中式版本控制库和分布式版本控制库的区别
8 RAII机制
8.1 mmap基本原理和分类
8.2 RAII机制介绍
8.3 使用RAII机制的原因
8.4 RAII机制的使用方法
1. auto 类型推导
auto 关键字:自动类型推导,编译器会在 编译期间 通过初始值推导出变量的类型,通过 auto 定义的变量必须有初始值。
2. decltype 类型推导
decltype 关键字:decltype 是“declare type”的缩写,译为“声明类型”。和 auto 的功能一样,都用来在编译时期进行自动类型推导。如果希望从表达式中推断出要定义的变量的类型,但是不想用该表达式的值初始化变量,这时就不能再用 auto。decltype 作用是选择并返回操作数的数据类型。
区别:
auto var = val1 + val2;
decltype(val1 + val2) var1 = 0;
3. lambda 表达式
lambda 表达式,又被称为 lambda 函数或者 lambda 匿名函数。
lambda匿名函数的定义:
[capture list] (parameter list) -> return type
{
function body;
};
其中:
#include
#include
using namespace std;
int main(){
int arr[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行升序排序
sort(arr, arr+4, [=](int x, int y) -> bool{ return x < y; } );
for(int n : arr){
cout << n << " ";
}
return 0;
}
4. 范围 for 语句
for (declaration : expression){
statement
}
参数的含义:
5. 左值和右值,左值引用和右值引用
左值和右值
通俗理解:左值是指具有对应的可由用户访问的存储单元,并且能由用户改变其值的量。如一个变量就是一个左值,因为它对应着一个存储单元,并可由编程者通过变量名访问和改变其值。
左值(Lvalue) →→ Location
表示内存中可以寻址,可以给它赋值(const类型的变量例外)
右值Rvalue) →→ Read
表示可以知道它的值(例如常数)
通俗的讲,左值就是能够出现在赋值符号左面的东西,而右值就是那些可以出现在赋值符号右面的东西, 比如int a = b + c;
,a 就是一个左值,可以对a取地址,而b+c 就是一个右值,对表达式b+c 取地址会报错。
一个典型的例子
a++: 先使用a的值,再给a加1,作为右值
// a++的实现
int temp = a;
a = a + 1;
return temp;
++a : 先加再用,作为 左值
a = a + 1;
return a;
在C++中,临时对象不能作为左值,但是可以作为常量引用,const &。
C++ 11中的std::move可将左值引用转化成右值引用。
C++11中右值又由两个概念组成:将亡值和纯右值。
纯右值和将亡值
在C++98中,右值是纯右值,纯右值指的是临时变量值、不跟对象关联的字面量值。包括非引用的函数返回值、表达式等,比如 2、‘ch’、int func()等。将亡值是C++11新增的、与右值引用相关的表达式。
将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。
右值引用和左值引用
#include
#include
using namespace std;
int main()
{
int var = 42;
int &l_var = var;
int &&r_var = var; // 错误:不能将右值引用绑定到左值上
int &&r_var2 = var + 40; // 正确:将 r_var2 绑定到求和结果上
return 0;
}
引用本身不拥有所绑定对象的内存,只是该对象的一个别名,左值引用就是有名变量的别名,右值引用是不具名变量的别名。因此无论左值引用还是右值引用都必须立即进行初始化。
通过右值引用,这个将亡的右值又“重获新生”,它的生命周期与右值引用类型变量的生命周期一样,只要这个右值引用类型的变量还活着,那么这个右值临时量就会一直活着,这是一重要特性,可利用这一点会一些性能优化,避免临时对象的拷贝构造和析构。
左值引用包括常量左值引用和非常量左值引用。非常量左值引用只能接受左值,不能接受右值;常量左值引用是一个“万能”的引用类型,可以接受左值(常量左值、非常量左值)、右值。不过常量左值所引用的右值在它的“余生”中只能是只读的。
int &a = 2; // 非常量左值引用 绑定到 右值,编译失败
int b = 2; // b 是非常量左值
const int &c = b; // 常量左值引用 绑定到 非常量左值,编译通过
const int d = 2; // d 是常量左值
const int &e = d; // 常量左值引用 绑定到 常量左值,编译通过
const int &f =2; // 常量左值引用 绑定到 右值,编译通过
右值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值。比如:
int a;
int &&r1 = a; // 编译失败
int &&r2 = std::move(a); // 编译通过
简单总结:
代码如下
#include
#include
int main (void) {
int i = 42;
int &lr = i;
int &&rr = i*42;
const int &lr1 = i*42;
int &&rr1 = 42;
int &&rr2 = std::move(lr);
std::cout << "i = " << i << std::endl;
std::cout << "lr = " << lr << std::endl;
std::cout << "rr = " << rr << std::endl;
std::cout << "lr1 = " << lr1 <
6. 标准库 move() 函数
move() 函数:通过该函数可获得绑定到左值上的右值引用,该函数包括在 utility 头文件中。该知识点会在后续的章节中做详细的说明。
7. 智能指针
相关知识已在第一章中进行了详细的说明,这里不再重复。
8. delete 函数和 default 函数
#include
using namespace std;
class A
{
public:
A() = default; // 表示使用默认的构造函数
~A() = default; // 表示使用默认的析构函数
A(const A &) = delete; // 表示类的对象禁止拷贝构造
A &operator=(const A &) = delete; // 表示类的对象禁止拷贝赋值
};
int main()
{
A ex1;
A ex2 = ex1; // error: use of deleted function 'A::A(const A&)'
A ex3;
ex3 = ex1; // error: use of deleted function 'A& A::operator=(const A&)'
return 0;
}
首先说一下面向对象和面向过程:
举个例子:(玩五子棋)
(1)用面向过程的思想来考虑就是:开始游戏,白子先走,绘制画面,判断输赢,轮到黑子,绘制画面,判断输赢,重复前面的过程,输出最终结果。
(2)用面向对象的思想来考虑就是:黑白双方(两者的行为是一样的)、棋盘系统(负责绘制画面)、规定系统(规定输赢、犯规等)、输出系统(输出赢家)。
面向对象就是高度实物抽象化(功能划分)、面向过程就是自顶向下的编程(步骤划分)
区别和联系:
面向过程语言:
优点:性能比面向对象高,因为类调用时需要实例化,开销比较大,比较消耗资源;比如单片机、嵌入式开发、Linux/Unix等一般采用面向过程开发,性能是最重要的因素。
缺点:没有面向对象易维护、易复用、易扩展
面向对象语言:
优点:易维护、易复用、易扩展,由于面向对象有封装、继承、多态性的特性,可以设计出低耦合的系统,使系统 更加灵活、更加易于维护
缺点:性能比面向过程低
区别:
编译过程分为四个过程:编译(编译预处理、编译、优化),汇编,链接。
编译预处理:处理以 # 开头的指令,产生 .i 文件;
主要的处理操作如下:
ps:经过预处理后的 .i 文件不包括任何宏定义,由于全部的宏已经被展开。而且包括的文件也已经被插入到 .i 文件里。
编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;
编译会将源代码由文本形式转换成机器语言,编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析以及优化后生成相应的汇编代码文件。编译后的.s是ASCII码文件。
汇编:将汇编代码 .s 翻译成机器指令的 .o 或.obj 目标文件;
链接:产生 .out 或 .exe 可运行文件
汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe文件。
详细来说,链接是将所有的.o文件和库(动态库、静态库)链接在一起,得到可以运行的可执行文件(Windows的.exe文件或Linux的.out文件)等。它的工作就是把一些指令对其他符号地址的引用加以修正。链接过程主要包括了地址和空间分配、符号决议和重定向。
最基本的链接叫做静态链接,就是将每个模块的源代码文件编译、汇编成目标文件(Linux:.o 文件;Windows:.obj文件),然后将目标文件和库一起链接形成最后的可执行文件(.exe或.out等)。库其实就是一组目标文件的包,就是一些最常用的代码变异成目标文件后打包存放。最常见的库就是运行时库,它是支持程序运行的基本函数的集合。
链接分为两种:
静态链接:代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中。
把目标程序运行时需要调用的函数代码直接链接到了生成的可执行文件中,程序在运行的时候不需要其他额外的库文件,且就算你去静态库把程序执行需要的库删掉也不会影响程序的运行,因为所需要的所有东西已经被链接到了链接阶段生成的可执行文件中。
Windows下以.lib为后缀,Linux下以.a为后缀。
动态链接:代码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间。
动态 “动” 在了程序在执行阶段需要去寻找相应的函数代码,即在程序运行时才会将程序安装模块链接在一起
具体来说,动态链接就是把调⽤的函数所在⽂件模块(DLL )和调⽤函数在⽂件中的位置等信息链接进目标程序,程序运⾏的时候再从 DLL 中寻找相应函数代码,因此需要相应 DLL ⽂件的⽀持 。(Windows)
包含函数重定位信息的文件,在Windows下以.dll为后缀,Linux下以.so为后缀。
二者的区别:
静态链接是 将各个模块的obj和库链接成一个完整的可执行程序;而动态链接是程序在运行的时候寻找动态库的函数符号(重定位),即DLL不必被包含在最终的exe文件中;
链接使用工具不同:
库包含限制:
运行速度:
二者的优缺点:
静态链接:浪费空间,每个可执行程序都会有目标文件的一个副本,这样如果目标文件进行了更新操作,就需要重新进行编译链接生成可执行程序(更新困难);优点就是执行的时候运行速度快,因为可执行程序具备了程序运行的所有内容。
动态链接:节省内存、更新方便,但是动态链接是在程序运行时,每次执行都需要链接,相比静态链接会有一定的性能损失。
C++的内存分布模型:
从高地址到低地址,一个程序由 内核空间、栈区、堆区、BSS段、数据段(data)、代码区组成。
常说的C++ 内存分区:栈、堆、全局/静态存储区、常量存储区、代码区。
可执行程序在运行时会多出两个区域:
在linux下size命令可以查看一个可执行二进制文件基本情况:
此题总结:
1、申请方式的不同。 栈由系统自动分配,而堆是人为申请开辟;
2、申请大小的不同。 栈获得的空间较小,而堆获得的空间较大;
3、申请效率的不同。 栈由系统自动分配,速度较快,而堆一般速度比较慢;
4、 存储的内容不同。
栈在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。 当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。
全局变量、局部变量、静态全局变量、静态局部变量的区别:
全局变量就是定义在函数外的变量。
局部变量就是函数内定义的变量。
静态变量就是加了static的变量。 例如:static int value = 1
各自存储的位置:
注意: 因为静态变量都在静态存储区(常量区),所以下次调用函数的时候还是能取到原来的值。
各自初始化的值:
从作用域看:
C++ 变量根据定义的位置的不同的生命周期,具有不同的作用域,作用域可分为 6 种:全局作用域,局部作用域,语句作用域,类作用域,命名空间作用域和文件作用域。
从分配内存空间看:
各自的应用场景:
例子:
void func(){
static int count;
count ++;
}
int main(int argc, char** argv){
for(int i = 0; i < 10; i++)
func();
}
说说静态变量在代码执行的什么阶段进行初始化?
static int value //静态变量初始化语句
对于C语言: 静态变量和全局变量均在编译期进行初始化,即初始化发生在任何代码执行之前。
对于C++: 静态变量和全局变量仅当首次被使用的时候才进行初始化。
助记: 如果你使用过C/C++你会发现,C语言要求在程序的最开头声明全部的变量,而C++则可以随时使用随时声明;这个规律是不是和答案类似呢?
如果在头文件中定义全局变量,当该头文件被多个文件 include 时,该头文件中的全局变量就会被定义多次,导致重复定义,因此不能再头文件中定义全局变量。
什么是内存对齐?内存对齐的原则?为什么要进行内存对齐,有什么优点?
内存对齐:编译器将程序中的每个“数据单元”安排在字的整数倍的地址指向的内存之中
内存对齐的原则:
进行内存对齐的原因:(主要是硬件设备方面的问题)
内存对齐的优点:
内存泄漏:由于疏忽或错误导致的程序未能释放已经不再使用的内存。
进一步解释:
char *p = (char *)malloc(10);
char *p1 = (char *)malloc(10);
p = np;
开始时,指针 p 和 p1 分别指向一块内存空间,但指针 p 被重新赋值,导致 p 初始时指向的那块内存空间无法找到,从而发生了内存泄漏。
防止内存泄漏的方法:
VS下内存泄漏的检测方法(CRT):
在debug模式下以F5运行:
#define CRTDBG_MAP_ALLOC
#include
#include
//在入口函数中包含 _CrtDumpMemoryLeaks();
//即可检测到内存泄露
//以如下测试函数为例:
int main(){
char* pChars = new char[10];
_CrtDumpMemoryLeaks();
return 0;
}
智能指针是为了解决动态内存分配时忘记释放内存导致的内存泄漏以及多次释放同一块内存空间而提出的。C++11 中封装在了 #include < memory >
头文件中。
C++11 引入了 3 个智能指针类型:
std::unique_ptr :独占资源所有权的指针。
std::shared_ptr :共享资源所有权的指针。
std::weak_ptr :共享资源的观察者,需要和 std::shared_ptr 一起使用,不影响资源的生命周期。
注:std::auto_ptr 已被废弃。
共享指针(shared_ptr):资源可以被多个指针共享,使用计数机制表明资源被几个指针共享。通过 use_count() 查看资源的所有者的个数,可以通过 unique_ptr、weak_ptr 来构造,调用 release() 释放资源的所有权,计数减一,当计数减为 0 时,会自动释放内存空间,从而避免了内存泄漏。
独占指针(unique_ptr):独享所有权的智能指针,资源只能被一个指针占有,该指针不能拷贝构造和赋值。但可以进行移动构造和移动赋值构造(调用move() 函数),即一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,可以通过该方法进行赋值。
弱指针(weak_ptr):指向 shared_ptr 指向的对象,能够解决由shared_ptr带来的循环引用问题。
智能指针的实现原理: 计数原理。
unique_ptr
unique_ptr 的使用比较简单,也是用得比较多的智能指针。当我们独占资源的所有权的时候,可以使用 unique_ptr 对资源进行管理——离开 unique_ptr 对象的作用域时,会自动释放资源。这是很基本的 RAII 思想。
使用裸指针时,要记得释放内存。
{
int* p = new int(100);
// ...
delete p; // 要记得释放内存
}
使用 unique_ptr 自动管理内存。
{
std::unique_ptr uptr = std::make_unique(200);
//...
// 离开 uptr 的作用域的时候自动释放内存
}
{
std::unique_ptr uptr = std::make_unique(200);
std::unique_ptr uptr1 = uptr; // 编译错误,std::unique_ptr 是 move-only 的
std::unique_ptr uptr2 = std::move(uptr);
assert(uptr == nullptr);
}
{
std::unique_ptr uptr = std::make_unique(10);
for (int i = 0; i < 10; i++) {
uptr[i] = i * i;
}
for (int i = 0; i < 10; i++) {
std::cout << uptr[i] << std::endl; //0 1 4 9 ...81
}
}
也可以用向量:
unique_ptr> p (new vector(5, 6)); //n = 5, value = 6
std::cout << *p->begin() << endl;//6
shared_ptr
assert(p);
用于判断指针内容是否非空,空指针nullptr 与什么未指向的野指针过不了assert{
std::shared_ptr sptr = std::make_shared(200);
assert(sptr.use_count() == 1); // 此时引用计数为 1
{
std::shared_ptr sptr1 = sptr;
assert(sptr.get() == sptr1.get());
assert(sptr.use_count() == 2); // sptr 和 sptr1 共享资源,引用计数为 2
}
assert(sptr.use_count() == 1); // sptr1 已经释放
}
// use_count 为 0 时自动释放内存
和 unique_ptr 一样,shared_ptr 也可以指向数组和自定义 deleter。
{
// C++20 才支持 std::make_shared
// std::shared_ptr sptr = std::make_shared(100);
std::shared_ptr sptr(new int[10]);
for (int i = 0; i < 10; i++) {
sptr[i] = i * i;
}
for (int i = 0; i < 10; i++) {
std::cout << sptr[i] << std::endl;
}
}
附:
一个 shared_ptr 对象的内存开销要比裸指针和无自定义 deleter 的 unique_ptr 对象略大。
无自定义 deleter 的 unique_ptr 只需要将裸指针用 RAII 的手法封装好就行,无需保存其它信息,所以它的开销和裸指针是一样的。如果有自定义 deleter,还需要保存 deleter 的信息。
shared_ptr 需要维护的信息有两部分:
所以,shared_ptr 对象需要保存两个指针。shared_ptr 的 的 deleter 是保存在控制信息中,所以,是否有自定义 deleter 不影响 shared_ptr 对象的大小。
当我们创建一个 shared_ptr 时,其实现一般如下:
std::shared_ptr sptr1(new T);
最好使用make_shared实现:
shared_ptr p1 = make_shared(10, '9');
shared_ptr p2 = make_shared(42);
复制一个 shared_ptr :
std::shared_ptr sptr2 = sptr1;
为什么控制信息和每个 shared_ptr 对象都需要保存指向共享资源的指针?可不可以去掉 shared_ptr 对象中指向共享资源的指针,以节省内存开销?
答案是:不能。 因为 shared_ptr 对象中的指针指向的对象不一定和控制块中的指针指向的对象一样。
来看一个例子。
struct Fruit {
int juice;
};
struct Vegetable {
int fiber;
};
struct Tomato : public Fruit, Vegetable {
int sauce;
};
// 由于继承的存在,shared_ptr 可能指向基类对象
std::shared_ptr tomato = std::make_shared();
std::shared_ptr fruit = tomato;
std::shared_ptr vegetable = tomato;
此外,在使用 shared_ptr 时,会涉及两次内存分配:一次分配共享资源对象;一次分配控制块。C++ 标准库提供了 make_shared 函数来创建一个 shared_ptr 对象,只需要一次内存分配,所以推荐用make_shared 函数来创建对象。
weak_ptr
weak_ptr 要与 shared_ptr 一起使用。 一个 weak_ptr 对象看做是 shared_ptr 对象管理的资源的观察者,它不影响共享资源的生命周期:
void Observe(std::weak_ptr wptr) {
if (auto sptr = wptr.lock()) {
std::cout << "value: " << *sptr << std::endl;
} else {
std::cout << "wptr lock fail" << std::endl;
}
}
std::weak_ptr wptr;
{
auto sptr = std::make_shared(111);
wptr = sptr;
Observe(wptr); // sptr 指向的资源没被释放,wptr 可以成功提升为 shared_ptr
}
Observe(wptr); // sptr 指向的资源已被释放,wptr 无法提升为 shared_ptr
当 shared_ptr 析构并释放共享资源的时候,只要 weak_ptr 对象还存在,控制块就会保留,weak_ptr 可以通过控制块观察到对象是否存活。
借助 std::move() 可以实现将一个 unique_ptr 对象赋值给另一个 unique_ptr 对象,其目的是实现所有权的转移。
// A 作为一个类
std::unique_ptr ptr1(new A());
std::unique_ptr ptr2 = std::move(ptr1);
智能指针可能出现的问题:循环引用
比如定义了两个类 Parent、Child,在两个类中分别定义另一个类的对象的共享指针,由于在程序结束后,两个指针相互指向对方的内存空间,导致内存无法释放。
循环引用的解决方法: weak_ptr
循环引用:该被调用的析构函数没有被调用,从而出现了内存泄漏。
weak_ptr 对被 shared_ptr 管理的对象存在非拥有性(弱)引用,在访问所引用的对象前必须先转化为 shared_ptr;
weak_ptr 用来打断 shared_ptr 所管理对象的循环引用问题,若这种环被孤立(没有指向环中的外部共享指针),shared_ptr 引用计数无法抵达 0,内存被泄露;令环中的指针之一为弱指针可以避免该情况;
weak_ptr 用来表达临时所有权的概念,当某个对象只有存在时才需要被访问,而且随时可能被他人删除,可以用 weak_ptr 跟踪该对象;需要获得所有权时将其转化为 shared_ptr,此时如果原来的 shared_ptr 被销毁,则该对象的生命期被延长至这个临时的 shared_ptr 同样被销毁。
检查方法:
在main函数最后面一行,加上一句_CrtDumpMemoryLeaks()。调试程序,自然关闭程序让其退出(不要定制调试),查看输出:
Detected memory leaks!
Dumping objects ->
{453} normal block at 0x02432CA8, 868 bytes long.
Data: <404303374 > 34 30 34 33 30 33 33 37 34 00 00 00 00 00 00 00
{447} normal block at 0x024328B0, 868 bytes long.
Data: <404303374 > 34 30 34 33 30 33 33 37 34 00 00 00 00 00 00 00
{441} normal block at 0x024324B8, 868 bytes long.
Data: <404303374 > 34 30 34 33 30 33 33 37 34 00 00 00 00 00 00 00
{435} normal block at 0x024320C0, 868 bytes long.
Data: <404303374 > 34 30 34 33 30 33 33 37 34 00 00 00 00 00 00 00
{429} normal block at 0x02431CC8, 868 bytes long.
Data: <404303374 > 34 30 34 33 30 33 33 37 34 00 00 00 00 00 00 00
{212} normal block at 0x01E1BF30, 44 bytes long.
Data: <` > 60 B3 E1 01 CD CD CD CD CD CD CD CD CD CD CD CD
{204} normal block at 0x01E1B2C8, 24 bytes long.
Data: < > C8 B2 E1 01 C8 B2 E1 01 C8 B2 E1 01 CD CD CD CD
{138} normal block at 0x01E15680, 332 bytes long.
Data: < > 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
{137} normal block at 0x01E15628, 24 bytes long.
Data: <(V (V (V > 28 56 E1 01 28 56 E1 01 28 56 E1 01 CD CD CD CD
Object dump complete.
取其中一条详细说明:{453} normal block at 0x02432CA8, 868 bytes long.
被{}包围的453就是我们需要的内存泄漏定位值,868 bytes long就是说这个地方有868比特内存没有释放。
在main函数第一行加上:_CrtSetBreakAlloc(453);意思就是在申请453这块内存的位置中断。然后调试程序,……程序中断了。查看调用堆栈
双击我们的代码调用的最后一个函数,这里是CDbQuery::UpdateDatas(),就定位到了申请内存的代码:
好了,我们总算知道是哪里出问题了,这块内存没有释放啊。改代码,修复好这个。然后继续…………,直到调试输出中没有normal block ,程序没有内存泄漏了。
记得加上头文件:#include
最后要注意一点的,并不是所有normal block一定就有内存泄漏,当你的程序中有全局变量的时候,全局变量的释放示在main函数退出后,所以在main函数最后_CrtDumpMemoryLeaks()会认为全局申请的内存没有释放,造成内存泄漏的假象。如何规避呢?我通常是把全局变量声明成指针在main函数中new 在main函数中delete,然后再调用_CrtDumpMemoryLeaks(),这样就不会误判了。
请自行查阅 Linux检测内存泄漏,定位泄漏代码位置方法
浅拷贝就是对象的数据成员之间的简单赋值,如你设计了一个类而没有提供它的复制构造函数,当用该类的一个对象去给另一个对象赋值时所执行的过程就是浅拷贝。当数据成员中没有指针时,浅拷贝是可行的;但当数据成员中有指针时,如果采用简单的浅拷贝,则两类中的两个指针将指向同一个地址,当对象快结束时,会调用两次析构函数,而导致指针悬挂现象,所以,此时,必须采用深拷贝。
物理内存:
物理内存实际上是CPU中能直接寻址的地址线条数。由于物理内存是有限的,例如32位平台下,寻址的大小是4G,并且是固定的。内存很快就会被分配完,于是没有得到分配资源的进程就只能等待。当一个进程执行完了以后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率的。
虚拟内存:
关于虚拟内存与物理内存的联系,下面这张图可以帮助我们巩固。
虚拟内存机理及优点:
虚拟内存是如何工作的?
利用虚拟内存机制的优点 ?
换句话说 :存在字符串:
高有效位 → 12 34 56 78 → 低有效位<br/>小端: 低地址位 → 7 8 56 34 12 → 高地址位<br/>大 端: 低地址位 → 12 34 56 78 → 高地址位
助记 :
大端模式:符合阅读习惯, 高字节存放在低地址,低字节存放在高地址;类似于把数据当作字符串顺序处理
小端模式:低字节存放在低地址,高字节存放在高地址;即高地址部分权值高,低地址部分权值低
虚函数:被 virtual 关键字修饰的成员函数,就是虚函数。
纯虚函数:
说明:
实现机制:虚函数通过虚函数表来实现。虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数
虚函数表相关知识点:
注:虚函数表和类绑定,虚表指针和对象绑定。即类的不同的对象的虚函数表是一样的,但是每个对象都有自己的虚表指针,来指向类的虚函数表。
实例:
无虚函数覆盖的情况:
#include
using namespace std;
class Base
{
public:
virtual void B_fun1() { cout << "Base::B_fun1()" << endl; }
virtual void B_fun2() { cout << "Base::B_fun2()" << endl; }
virtual void B_fun3() { cout << "Base::B_fun3()" << endl; }
};
class Derive : public Base
{
public:
virtual void D_fun1() { cout << "Derive::D_fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
virtual void D_fun3() { cout << "Derive::D_fun3()" << endl; }
};
int main()
{
Base *p = new Derive();
p->B_fun1(); // Base::B_fun1()
return 0;
}
主函数中基类的指针 p 指向了派生类的对象,当调用函数 B_fun1() 时,通过派生类的虚函数表找到该函数的地址,从而完成调用。
编译器处理虚函数表:
虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数——构造函数了。
防止内存泄露,delete p(基类)的时候,它很机智的先执行了派生类的析构函数,然后执行了基类的析构函数。
如果基类的析构函数不是虚函数,在delete p(基类)时,调用析构函数时,只会看指针的数据类型,而不会去看赋值的对象,这样就会造成内存泄露。
举例说明:
子类B继承自基类A;A *p = new B; delete p;
1) 此时,如果类A的析构函数不是虚函数,那么delete p;将会仅仅调用A的析构函数,只释放了B对象中的A部分,而派生出的新的部分未释放掉。
2) 如果类A的析构函数是虚函数,delete p; 将会先调用B的析构函数,再调用A的析构函数,释放B对象的所有空间。
补充: B *p = new B; delete p;时也是先调用B的析构函数,再调用A的析构函数。
1、静态成员函数; 2、类外的普通函数; 3、构造函数; 4、友元函数
虚函数是为了实现多态特性的。虚函数的调用只有在程序运行的时候才能知道到底调用的是哪个函数,其是有有如下几点需要注意:
(1) 类的构造函数不能是虚函数
构造函数是为了构造对象的,所以在调用构造函数时候必然知道是哪个对象调用了构造函数,所以构造函数不能为虚函数。
(2) 类的静态成员函数不能是虚函数
类的静态成员函数是该类共用的,与该类的对象无关,静态函数里没有this指针,所以不能为虚函数。
(3)内联函数
内联函数的目的是为了减少函数调用时间。它是把内联函数的函数体在编译器预处理的时候替换到函数调用处,这样代码运行到这里时候就不需要花时间去调用函数。inline是在编译器将函数类容替换到函数调用处,是静态编译的。而虚函数是动态调用的,在编译器并不知道需要调用的是父类还是子类的虚函数,所以不能够inline声明展开,所以编译器会忽略。
(4)友元函数
友元函数与该类无关,没有this指针,所以不能为虚函数。
左值:指表达式结束后依然存在的持久对象。
右值:表达式结束就不再存在的临时对象。
左值和右值的区别:左值持久,右值短暂
右值引用和左值引用的区别:
std::move可以将一个左值强制转化为右值,继而可以通过右值引用使用该值,以用于移动语义。
#include
using namespace std;
void fun1(int& tmp)
{
cout << "fun1(int& tmp):" << tmp << endl;
}
void fun2(int&& tmp)
{
cout << "fun2(int&& tmp)" << tmp << endl;
}
int main()
{
int var = 11;
fun1(12); // error: cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'
fun1(var);
fun2(1);
}
std::move() 函数原型:
template
typename remove_reference::type&& move(T&& t)
{
return static_cast::type &&>(t);
}
说明:引用折叠原理
小结:通过引用折叠原理可以知道,move() 函数的形参既可以是左值也可以是右值。
remove_reference 具体实现:
//原始的,最通用的版本
template struct remove_reference{
typedef T type; //定义 T 的类型别名为 type
};
//部分版本特例化,将用于左值引用和右值引用
template struct remove_reference //左值引用
{ typedef T type; }
template struct remove_reference //右值引用
{ typedef T type; }
//举例如下,下列定义的a、b、c三个变量都是int类型
int i;
remove_refrence::type a; //使用原版本,
remove_refrence::type b; //左值引用特例版本
remove_refrence::type b; //右值引用特例版本
举例:
int var = 10;
转化过程:
1. std::move(var) => std::move(int&& &) => 折叠后 std::move(int&)
2. 此时:T 的类型为 int&,typename remove_reference::type 为 int,这里使用 remove_reference 的左值引用的特例化版本
3. 通过 static_cast 将 int& 强制转换为 int&&
整个std::move被实例化如下
string&& move(int& t)
{
return static_cast(t);
}
总结:
std::move() 实现原理:
指针: 指向另外一种类型的复合类型。
指针的大小: 在 64 位计算机中,指针占 8 个字节空间。
#include
using namespace std;
int main(){
int *p = nullptr;
cout << sizeof(p) << endl; // 8
char *p1 = nullptr;
cout << sizeof(p1) << endl; // 8
return 0;
}
指针的用法:
1.指向普通对象的指针
#include
using namespace std;
class A
{
};
int main()
{
A *p = new A();
return 0;
}
2.指向常量对象的指针:常量指针
#include
using namespace std;
int main(void)
{
const int c_var = 10;
const int * p = &c_var;
cout << *p << endl;
return 0;
}
3.指向函数的指针:函数指针
#include
using namespace std;
int add(int a, int b){
return a + b;
}
int main(void)
{
int (*fun_p)(int, int);
fun_p = add;
cout << fun_p(1, 6) << endl;
return 0;
}
4.指向对象成员的指针,包括指向对象成员函数的指针和指向对象成员变量的指针。
特别注意:定义指向成员函数的指针时,要标明指针所属的类。
#include
using namespace std;
class A
{
public:
int var1, var2;
int add(){
return var1 + var2;
}
};
int main()
{
A ex;
ex.var1 = 3;
ex.var2 = 4;
int *p = &ex.var1; // 指向对象成员变量的指针
cout << *p << endl;
int (A::*fun_p)();
fun_p = A::add; // 指向对象成员函数的指针 fun_p
cout << (ex.*fun_p)() << endl;
return 0;
}
5.this 指针:指向类的当前对象的指针常量。
#include
#include
using namespace std;
class A
{
public:
void set_name(string tmp)
{
this->name = tmp;
}
void set_age(int tmp)
{
this->age = age;
}
void set_sex(int tmp)
{
this->sex = tmp;
}
void show()
{
cout << "Name: " << this->name << endl;
cout << "Age: " << this->age << endl;
cout << "Sex: " << this->sex << endl;
}
private:
string name;
int age;
int sex;
};
int main()
{
A *p = new A();
p->set_name("Alice");
p->set_age(16);
p->set_sex(1);
p->show();
return 0;
}
6.什么是野指针和悬空指针?
悬空指针:
若指针指向一块内存空间,当这块内存空间被释放后,该指针依然指向这块内存空间,此时,称该指针为“悬空指针”。
void *p = malloc(size);
free(p);
// 此时,p 指向的内存空间已释放, p 就是悬空指针。
野指针:
“野指针”是指不确定其指向的指针,未初始化的指针为“野指针”。
void *p;
// 此时 p 是“野指针”。
nullptr 的优势:
常量指针:
常量指针本质上是个指针,只不过这个指针指向的对象是常量。
特点:const 的位置在指针声明运算符 * 的左侧。只要 const 位于 * 的左侧,无论它在类型名的左边或右边,都表示指向常量的指针。(可以这样理解,* 左侧表示指针指向的对象,该对象为常量,那么该指针为常量指针。)
const int * p;
int const * p;
注意 1:指针指向的对象不能通过这个指针来修改,也就是说常量指针可以被赋值为变量的地址,之所以叫做常量指针,是限制了通过这个指针修改变量的值。
#include
using namespace std;
int main()
{
const int c_var = 8;
const int *p = &c_var;
*p = 6; // error: assignment of read-only location '* p'
return 0;
}
注意 2:虽然常量指针指向的对象不能变化,可是因为常量指针本身是一个变量,因此,可以被重新赋值。
例如:
#include
using namespace std;
int main(){
const int c_var1 = 8;
const int c_var2 = 8;
const int *p = &c_var1;
p = &c_var2;
return 0;
}
指针常量:
指针常量的本质上是个常量,只不过这个常量的值是一个指针。
特点:const 位于指针声明操作符右侧,表明该对象本身是一个常量,* 左侧表示该指针指向的类型,即以 * 为分界线,其左侧表示指针指向的类型,右侧表示指针本身的性质。
const int var;
int * const c_p = &var;
注意 1:指针常量的值是指针,这个值因为是常量,所以指针本身不能改变。
#include
using namespace std;
int main()
{
int var, var1;
int * const c_p = &var;
c_p = &var1; // error: assignment of read-only variable 'c_p'
return 0;
}
注意 2:指针的内容可以改变。
#include
using namespace std;
int main(){
int var = 3;
int * const c_p = &var;
*c_p = 12;
return 0;
}
指针函数:
指针函数本质是一个函数,只不过该函数的返回值是一个指针。相对于普通函数而言,只是返回值是指针。
#include
using namespace std;
struct Type
{
int var1;
int var2;
};
Type * fun(int tmp1, int tmp2){
Type * t = new Type();
t->var1 = tmp1;
t->var2 = tmp2;
return t;
}
int main()
{
Type *p = fun(5, 6);
return 0;
}
函数指针:
函数指针本质是一个指针变量,只不过这个指针指向一个函数。函数指针即指向函数的指针。
举例:
#include
using namespace std;
int fun1(int tmp1, int tmp2)
{
return tmp1 * tmp2;
}
int fun2(int tmp1, int tmp2)
{
return tmp1 / tmp2;
}
int main()
{
int (*fun)(int x, int y);
fun = fun1;
cout << fun(15, 5) << endl;
fun = fun2;
cout << fun(15, 5) << endl;
return 0;
}
/*
运行结果:
75
3
*/
函数指针和指针函数的区别:
本质不同
1.指针函数本质是一个函数,其返回值为指针。
2.函数指针本质是一个指针变量,其指向一个函数。
定义形式不同
1.指针函数:int* fun(int tmp1, int tmp2); ,这里* 表示函数的返回值类型是指针类型。
2.函数指针:int (fun)(int tmp1, int tmp2);,这里 表示变量本身是指针类型。
用法不同
1.用于基本数据类型的转换。
2.用于类层次之间的基类和派生类之间 指针或者引用 的转换(不要求必须包含虚函数,但必须是有相互联系的类),进行上行转换(派生类的指针或引用转换成基类表示)是安全的;进行下行转换(基类的指针或引用转换成派生类表示)由于没有动态类型检查,所以是不安全的,最好用 dynamic_cast 进行下行转换。
3.可以将空指针转化成目标类型的空指针。
4.可以将任何类型的表达式转化成 void 类型。
1.其他三种都是编译时完成的,动态类型转换是在程序运行时处理的,运行时会进行类型检查。
2.只能用于带有虚函数的基类或派生类的指针或者引用对象的转换,转换成功返回指向类型的指针或引用,转换失败返回 NULL;不能用于基本数据类型的转换。
3.在向上进行转换时,即派生类类的指针转换成基类类的指针和 static_cast 效果是一样的,(注意:这里只是改变了指针的类型,指针指向的对象的类型并未发生改变)。
4.在下行转换时,基类的指针类型转化为派生类类的指针类型,只有当要转换的指针指向的对象类型和转化以后的对象类型相同时,才会转化成功。
#include
#include
using namespace std;
class Base
{
public:
virtual void fun()
{
cout << "Base::fun()" << endl;
}
};
class Derive : public Base
{
public:
virtual void fun()
{
cout << "Derive::fun()" << endl;
}
};
int main()
{
Base *p1 = new Derive();
Base *p2 = new Base();
Derive *p3 = new Derive();
//转换成功
p3 = dynamic_cast(p1);
if (p3 == NULL)
{
cout << "NULL" << endl;
}
else
{
cout << "NOT NULL" << endl; // 输出
}
//转换失败
p3 = dynamic_cast(p2);
if (p3 == NULL)
{
cout << "NULL" << endl; // 输出
}
else
{
cout << "NOT NULL" << endl;
}
return 0;
}
需要重载操作符 == 判断两个结构体是否相等,不能用函数 memcmp 来判断两个结构体是否相等,因为 memcmp 函数是逐个字节进行比较的,而结构体存在内存空间中保存时存在字节对齐,字节对齐时补的字节内容是随机的,会产生垃圾值,所以无法比较。
利用运算符重载来实现结构体对象的比较:
#include
using namespace std;
struct A
{
char c;
int val;
A(char c_tmp, int tmp) : c(c_tmp), val(tmp) {}
friend bool operator==(const A &tmp1, const A &tmp2); // 友元运算符重载函数
};
bool operator==(const A &tmp1, const A &tmp2)
{
return (tmp1.c == tmp2.c && tmp1.val == tmp2.val);
}
int main()
{
A ex1('a', 90), ex2('b', 80);
if (ex1 == ex2)
cout << "ex1 == ex2" << endl;
else
cout << "ex1 != ex2" << endl; // 输出
return 0;
}
参数传递的三种方式:
模板:创建类或者函数的蓝图或者公式,分为函数模板和类模板。
实现方式:模板定义以关键字 template 开始,后跟一个模板参数列表。
template
函数模板:通过定义一个函数模板,可以避免为每一种类型定义一个新函数。
#include
using namespace std;
template
T add_fun(const T & tmp1, const T & tmp2){
return tmp1 + tmp2;
}
int main(){
int var1, var2;
cin >> var1 >> var2;
cout << add_fun(var1, var2);
double var3, var4;
cin >> var3 >> var4;
cout << add_fun(var3, var4);
return 0;
}
类模板:类似函数模板,类模板以关键字 template 开始,后跟模板参数列表。但是,编译器不能为类模板推断模板参数类型,需要在使用该类模板时,在模板名后面的尖括号中指明类型。
#include
using namespace std;
template
class Complex{
public:
//构造函数
Complex(T a, T b)
{
this->a = a;
this->b = b;
}
//运算符重载
Complex operator+(Complex &c){
Complex tmp(this->a + c.a, this->b + c.b);
cout << tmp.a << " " << tmp.b << endl;
return tmp;
}
private:
T a;
T b;
};
int main(){
Complex a(10, 20);
Complex b(20, 30);
Complex c = a + b;
return 0;
}
#include
using namespace std;
template
T add_fun(const T & tmp1, const T & tmp2){
return tmp1 + tmp2;
}
int main(){
int var1, var2;
cin >> var1 >> var2;
cout << add_fun(var1, var2); // 显式调用
double var3, var4;
cin >> var3 >> var4;
cout << add_fun(var3, var4); // 隐式调用
return 0;
}
可变参数模板:接受可变数目参数的模板函数或模板类。将可变数目的参数被称为参数包,包括模板参数包和函数参数包。
用省略号来指出一个模板参数或函数参数表示一个包,在模板参数列表中,class… 或 typename… 指出接下来的参数表示零个或多个类型的列表;一个类型名后面跟一个省略号表示零个或多个给定类型的非类型参数的列表。当需要知道包中有多少元素时,可以使用 sizeof… 运算符。
template // Args 是模板参数包
void foo(const T &t, const Args&... rest); // 可变参数模板,rest 是函数参数包
#include
using namespace std;
template
void print_fun(const T &t){
cout << t << endl; // 最后一个元素
}
template
void print_fun(const T &t, const Args &...args){
cout << t << " ";
print_fun(args...);
}
int main(){
print_fun("Hello", "wolrd", "!");
return 0;
}
/*运行结果:
Hello wolrd !
*/
说明:可变参数函数通常是递归的,第一个版本的 print_fun 负责终止递归并打印初始调用中的最后一个实参。第二个版本的 print_fun 是可变参数版本,打印绑定到 t 的实参,并用来调用自身来打印函数参数包中的剩余值。
模板特化的原因:模板并非对任何模板实参都合适、都能实例化,某些情况下,通用模板的定义对特定类型不合适,可能会编译失败,或者得不到正确的结果。因此,当不希望使用模板版本时,可以定义类或者函数模板的一个特例化版
模板特化:模板参数在某种特定类型下的具体实现。分为函数模板特化和类模板特化
特化分为全特化和偏特化:
说明:要区分下函数重载与函数模板特化
定义函数模板的特化版本,本质上是接管了编译器的工作,为原函数模板定义了一个特殊实例,而不是函数重载,函数模板特化并不影响函数匹配。
#include
#include
using namespace std;
//函数模板
template
bool compare(T t1, T t2){
cout << "通用版本:";
return t1 == t2;
}
template <> //函数模板特化
bool compare(char *t1, char *t2){
cout << "特化版本:";
return strcmp(t1, t2) == 0;
}在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
int main(int argc, char *argv[]){
char arr1[] = "hello";
char arr2[] = "abc";
cout << compare(123, 123) << endl;
cout << compare(arr1, arr2) << endl;
return 0;
}
/*
运行结果:
通用版本:1
特化版本:0
*/
include<文件名> 和 #include"文件名" 的区别:
泛型编程实现的基础:模板。模板是创建类或者函数的蓝图或者说公式,当时用一个 vector 这样的泛型,或者 find 这样的泛型函数时,编译时会转化为特定的类或者函数。
泛型编程涉及到的知识点较广,例如:容器、迭代器、算法等都是泛型编程的实现实例。面试者可选择自己掌握比较扎实的一方面进行展开。
使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突。在C++中,变量、函数和类都是大量存在的。如果没有命名空间,这些变量、函数、类的名称将都存在于全局命名空间中,会导致很多冲突。比如,如果我们在自己的程序中定义了一个函functionA(),这将重写标准库中的functionA()函 数,这是因为这两个函数都是位于全局命名空间中的。
为了建立数据结构和算法的一套标准,并且降低他们之间的耦合关系,以提升各自的独立性、弹性、交互操作性(相互合作性,interoperability),诞生了STL。
STL提供了六大组件,彼此之间可以组合套用,这六大组件分别是:容器、算法、迭代器、仿函数、适配器(配接器)、空间配置器。
容器:各种数据结构,如vector、list、deque、set、map等,用来存放数据,从实现角度来看,STL容器是一种class template。
算法:各种常用的算法,如sort、find、copy、for_each。从实现的角度来看,STL算法是一种function tempalte.
迭代器:扮演了容器与算法之间的胶合剂,共有五种类型,从实现角度来看,迭代器是一种将operator* , operator-> , operator++,operator–等指针相关操作予以重载的class template. 所有STL容器都附带有自己专属的迭代器,只有容器的设计者才知道如何遍历自己的元素。原生指针(native pointer)也是一种迭代器。
仿函数:行为类似函数,可作为算法的某种策略。从实现角度来看,仿函数是一种重载了operator()的class 或者class template
适配器:一种用来修饰容器或者仿函数或迭代器接口的东西。
空间配置器:负责空间的配置与管理。从实现角度看,配置器是一个实现了动态空间配置、空间管理、空间释放的class tempalte.
STL六大组件的交互关系,容器通过空间配置器取得数据存储空间,算法通过迭代器存储容器中的内容,仿函数可以协助算法完成不同的策略的变化,适配器可以修饰仿函数。
面向对象:对象是指具体的某一个事物,这些事物的抽象就是类,类中包含数据(成员变量)和动作(成员方法)。
面向对象的三大特性:
重载:是指同一可访问区内被声明几个具有不同参数列(参数的类型、个数、顺序)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
class A
{
public:
void fun(int tmp);
void fun(float tmp); // 重载 参数类型不同(相对于上一个函数)
void fun(int tmp, float tmp1); // 重载 参数个数不同(相对于上一个函数)
void fun(float tmp, int tmp1); // 重载 参数顺序不同(相对于上一个函数)
int fun(int tmp); // error: 'int A::fun(int)' cannot be overloaded 错误:注意重载不关心函数返回类型
};
隐藏:是指派生类的函数屏蔽了与其同名的基类函数,主要只要同名函数,不管参数列表是否相同,基类函数都会被隐藏。
#include
using namespace std;
class Base
{
public:
void fun(int tmp, float tmp1) { cout << "Base::fun(int tmp, float tmp1)" << endl; }
};
class Derive : public Base
{
public:
void fun(int tmp) { cout << "Derive::fun(int tmp)" << endl; } // 隐藏基类中的同名函数
};
int main()
{
Derive ex;
ex.fun(1); // Derive::fun(int tmp)
ex.fun(1, 0.01); // error: candidate expects 1 argument, 2 provided
return 0;
}
说明:上述代码中 ex.fun(1, 0.01); 出现错误,说明派生类中将基类的同名函数隐藏了。若是想调用基类中的同名函数,可以加上类型名指明 ex.Base::fun(1, 0.01);,这样就可以调用基类中的同名函数。
重写(覆盖):是指派生类中存在重新定义的函数。函数名、参数列表、返回值类型都必须同基类中被重写的函数一致,只有函数体不同。派生类调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有 virtual 修饰。
#include
using namespace std;
class Base
{
public:
virtual void fun(int tmp) { cout << "Base::fun(int tmp) : " << tmp << endl; }
};
class Derived : public Base
{
public:
virtual void fun(int tmp) { cout << "Derived::fun(int tmp) : " << tmp << endl; } // 重写基类中的 fun 函数
};
int main()
{
Base *p = new Derived();
p->fun(3); // Derived::fun(int) : 3
return 0;
}
重写和重载的区别:
隐藏和重写,重载的区别:
说明:该问题最好结合自己的项目经历进行展开解释,或举一些恰当的例子,同时对比下面向过程编程。
面向对象编程进一步说明:
面向对象编程将数据成员和成员函数封装到一个类中,并声明数据成员和成员函数的访问级别(public、private、protected),以便控制类对象对数据成员和函数的访问,对数据成员起到一定的保护作用。而且在类的对象调用成员函数时,只需知道成员函数的名、参数列表以及返回值类型即可,无需了解其函数的实现原理。当类内部的数据成员或者成员函数发生改变时,不影响类外部的代码。
多态:多态就是不同继承类的对象,对同一消息做出不同的响应,基类的指针指向或绑定到派生类的对象,使得基类指针呈现不同的表现方式。在基类的函数前加上 virtual 关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
实现方法:多态是通过虚函数实现的,虚函数的地址保存在虚函数表中,虚函数表的地址保存在含有虚函数的类的实例对象的内存空间中。
实现过程:
静态多态:也称为编译期间的多态,编译器在编译期间完成的,编译器根据函数实参的类型(可能会进行隐式类型转换),可推断出要调用那个函数,如果有对应的函数就调用该函数,否则出现编译错误。
动态多态(动态绑定):即运行时的多态,在程序执行期间(非编译期)判断所引用对象的实际类型,根据其实际类型调用相应的方法。:
● 基类中必须包含虚函数,并且派生类中一定要对基类中的虚函数进行重写。
● 通过基类对象的指针或者引用调用虚函数。
#include
using namespace std;
class Base
{
public:
virtual void fun() { cout << "Base::fun()" << endl; }
virtual void fun1() { cout << "Base::fun1()" << endl; }
virtual void fun2() { cout << "Base::fun2()" << endl; }
};
class Derive : public Base
{
public:
void fun() { cout << "Derive::fun()" << endl; }
virtual void D_fun1() { cout << "Derive::D_fun1()" << endl; }
virtual void D_fun2() { cout << "Derive::D_fun2()" << endl; }
};
int main()
{
Base *p = new Derive();
p->fun(); // Derive::fun() 调用派生类中的虚函数
return 0;
}
简单解释:当基类的指针指向派生类的对象时,通过派生类的对象的虚表指针找到虚函数表(派生类的对象虚函数表),进而找到相应的虚函数 Derive::f() 进行调用。
strlen 源代码:
size_t strlen(const char *str) {
size_t length = 0;
while (*str++)
++length;
return length;
}
#include
#include
using namespace std;
int main()
{
char arr[10] = "hello";
cout << strlen(arr) << endl; // 5
cout << sizeof(arr) << endl; // 10
return 0;
}
3.若字符数组 arr 作为函数的形参,sizeof(arr) 中 arr 被当作字符指针来处理,strlen(arr) 中 arr
依然是字符数组,从下述程序的运行结果中就可以看出。
#include
#include
using namespace std;
void size_of(char arr[])
{
cout << sizeof(arr) << endl; // warning: 'sizeof' on array function parameter 'arr' will return size of 'char*' .
cout << strlen(arr) << endl;
}
int main()
{
char arr[20] = "hello";
size_of(arr);
return 0;
}
/*
输出结果:
8
5
*/
4.strlen 本身是库函数,因此在程序运行过程中,计算长度;而 sizeof 在编译时,计算长度;
5.sizeof 的参数可以是类型,也可以是变量;strlen 的参数必须是 char* 类型的变量
lambda 表达式的定义形式如下:
[capture list] (parameter list) -> reurn type
{
function body
}
其中:
常见使用场景:排序算法
bool compare(int& a, int& b)
{
return a > b;
}
int main(void)
{
int data[6] = { 3, 4, 12, 2, 1, 6 };
vector testdata;
testdata.insert(testdata.begin(), data, data + 6);
// 排序算法
sort(testdata.begin(), testdata.end(), compare); // 升序
// 使用lambda表达式
sort(testdata.begin(), testdata.end(), [](int a, int b){ return a > b; });
return 0;
}
作用:用来声明类构造函数是显示调用的,而非隐式调用,可以阻止调用构造函数时进行隐式转换。只可用于修饰单参构造函数,因为无参构造函数和多参构造函数本身就是显示调用的,再加上 explicit 关键字也没有什么意义。
隐式转换:
#include
#include
using namespace std;
class A
{
public:
int var;
A(int tmp)
{
var = tmp;
}
};
int main()
{
A ex = 10; // 发生了隐式转换
return 0;
}
上述代码中,A ex = 10;
在编译时,进行了隐式转换,将 10 转换成 A 类型的对象,然后将该对象赋值给 ex,等同于如下操作:
为了避免隐式转换,可用 explicit 关键字进行声明:
#include
#include
using namespace std;
class A
{
public:
int var;
explicit A(int tmp)
{
var = tmp;
cout << var << endl;
}
};
int main()
{
A ex(100);
A ex1 = 10; // error: conversion from 'int' to non-scalar type 'A' requested
return 0;
}
作用:
static 定义静态变量,静态函数。
static 静态成员变量:
#include
using namespace std;
class A
{
public:
static int s_var;
int var;
void fun1(int i = s_var); // 正确,静态成员变量可以作为成员函数的参数
void fun2(int i = var); // error: invalid use of non-static data member 'A::var'
};
4.静态数据成员的类型可以是所属类的类型,而普通数据成员的类型只能是该类类型的指针或引用。
#include
using namespace std;
class A
{
public:
static A s_var; // 正确,静态数据成员
A var; // error: field 'var' has incomplete type 'A'
A *p; // 正确,指针
A &var1; // 正确,引用
};
static 静态成员函数:
相同点:
不同点:
作用:
在类中的用法:
const 成员变量:
const 成员函数:
区别:
const 的优点:
#include
#define INTPTR1 int *
typedef int * INTPTR2;
using namespace std;
int main()
{
INTPTR1 p1, p2; // p1: int *; p2: int
INTPTR2 p3, p4; // p3: int *; p4: int *
int var = 1;
const INTPTR1 p5 = &var; // 相当于 const int * p5; 常量指针,即不可以通过 p5 去修改 p5 指向的内容,但是 p5 可以指向其他内容。
const INTPTR2 p6 = &var; // 相当于 int * const p6; 指针常量,不可使 p6 再指向其他内容。
return 0;
}
#include
#define MAX(X, Y) ((X)>(Y)?(X):(Y))
#define MIN(X, Y) ((X)<(Y)?(X):(Y))
using namespace std;
int main ()
{
int var1 = 10, var2 = 100;
cout << MAX(var1, var2) << endl;
cout << MIN(var1, var2) << endl;
return 0;
}
/*
程序运行结果:
100
10
*/
作用:
inline 是一个关键字,可以用于定义内联函数。内联函数,像普通函数一样被调用,但是在调用时并不通过函数调用的机制而是直接在调用点处展开,这样可以大大减少由函数调用带来的开销,从而提高程序的运行效率。
使用方法:
类内定义成员函数默认是内联函数
在类内定义成员函数,可以不用在函数头部加 inline 关键字,因为编译器会自动将类内定义的函数(构造函数、析构函数、普通成员函数等)声明为内联函数,代码如下:
#include
using namespace std;
class A{
public:
int var;
A(int tmp){
var = tmp;
}
void fun(){
cout << var << endl;
}
};
类外定义成员函数,若想定义为内联函数,需用关键字声明
当在类内声明函数,在类外定义函数时,如果想将该函数定义为内联函数,则可以在类内声明时不加 inline 关键字,而在类外定义函数时加上 inline 关键字。
#include
using namespace std;
class A{
public:
int var;
A(int tmp){
var = tmp;
}
void fun();
};
inline void A::fun(){
cout << var << end
另外,可以在声明函数和定义函数的同时加上 inline;也可以只在函数声明时加 inline,而定义函数时不加 inline。只要确保在调用该函数之前把 inline 的信息告知编译器即可。
#include
#define MAX(a, b) ((a) > (b) ? (a) : (b))
using namespace std;
inline int fun_max(int a, int b){
return a > b ? a : b;
}
int main(){
int var = 1;
cout << MAX(var, 5) << endl;
cout << fun_max(var, 0) << endl;
return 0;
}
/*
程序运行结果:
5
1
*/
new 是 C++ 中的关键字,用来动态分配内存空间,实现方式如下:
int *p = new int[5];
delete 的实现原理:
delete 和 delete [] 的区别:
在使用的时候 new、delete 搭配使用,malloc、free 搭配使用。
malloc 的原理:
malloc 的底层实现:
brk() 函数实现原理:向高地址的方向移动指向数据段的高地址的指针 _enddata。
mmap 内存映射原理:
1.进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域;
2.调用内核空间的系统调用函数 mmap(),实现文件物理地址和进程虚拟地址的一一映射关系;
3.进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。
说明:union 是联合体,struct 是结构体。
区别:
struct A{};
class B : A{}; // private 继承
struct C : B{}; // public 继承
volatile 的作用:当对象的值可能在程序的控制或检测之外被改变时,应该将该对象声明为 violatile,告知编译器不应对这样的对象进行优化。
volatile不具有原子性。
volatile 对编译器的影响:使用该关键字后,编译器不会对相应的对象进行优化,即不会将变量从内存缓存到寄存器中,防止多个线程有可能使用内存中的变量,有可能使用寄存器中的变量,从而导致程序错误。
使用 volatile 关键字的场景:
volatile 关键字和 const 关键字可以同时使用,某种类型可以既是 volatile 又是 const ,同时具有二者的属性。
#include
using namespace std;
int * fun(int tmp){
static int var = 10;
var *= tmp;
return &var;
}
int main() {
cout << var * fun(5) << endl;
return 0;
}
/*
运行结果:
50
*/
说明:上述代码中在函数 fun 中定义了静态局部变量 var,使得离开该函数的作用域后,该变量不会销毁,返回到主函数中,该变量依然存在,从而使程序得到正确的运行结果。但是,该静态局部变量直到程序运行结束后才销毁,浪费内存空间。
extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言(而不是C++)的方式进行编译。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。
举例:
// 可能出现在 C++ 头文件中的链接指示
extern "C"{
int strcmp(const char*, const char*);
}
C 语言代码:4
C++ 代码:1(布尔值大小)
void *memcpy(void *dst, const void *src, size_t size)
{
char *psrc;
char *pdst;
if (NULL == dst || NULL == src)
{
return NULL;
}
if ((src < dst) && (char *)src + size > (char *)dst) // 出现地址重叠的情况,自后向前拷贝
{
psrc = (char *)src + size - 1;
pdst = (char *)dst + size - 1;
while (size--)
{
*pdst-- = *psrc--;
}
}
else
{
psrc = (char *)src;
pdst = (char *)dst;
while (size--)
{
*pdst++ = *psrc++;
}
}
return dst;
}
strcpy 函数的缺陷:strcpy 函数不检查目的缓冲区的大小边界,而是将源字符串逐一的全部赋值给目的字符串地址起始的一块连续的内存空间,同时加上字符串终止符,会导致其他变量被覆盖。
说明:从上述代码中可以看出,变量 var 的后六位被字符串 “hello world!” 的 “d!\0” 这三个字符改变,这三个字符对应的 ascii 码的十六进制为:\0(0x00),!(0x21),d(0x64)。
原因:变量 arr 只分配的 10 个内存空间,通过上述程序中的地址可以看出 arr 和 var 在内存中是连续存放的,但是在调用 strcpy 函数进行拷贝时,源字符串 “hello world!” 所占的内存空间为 13,因此在拷贝的过程中会占用 var 的内存空间,导致 var的后六位被覆盖。
auto 类型推导的原理:
编译器根据初始值来推算变量的类型,要求用 auto 定义变量时必须有初始值。编译器推断出来的 auto 类型有时和初始值类型并不完全一样,编译器会适当改变结果类型使其更符合初始化规则。
malloc并不是系统调用。因此并不是内存空间的终极管理者。最大能够申请多大空间,并不是malloc一个人能说了算的。
malloc有多种实现,不同的实现有不同的特点。一般情况下,malloc是从系统获取内存分页,然后将这些分页组织为不同大小的“块”。当用户程序申请内存的时候,如果大小没有超过“块”的大小,则malloc在内部匹配尺寸最为接近的,不小于申请的尺寸的块,记录分配信息之后,返回地址。这样做的目的是加快分配的速度,减少系统调用。
如果申请的空间很大,则malloc会直接向系统申请多个分页,将其map到用户程序的虚拟地址空间当中的一个连续的区间(这个同样是系统调用),然后返回地址。
分页的管理,是由操作系统完成的。现代操作系统大多支持虚拟内存,也就是不仅仅使用RAM,还使用磁盘上的交换文件作为内存空间。操作系统将这些空间按照一定大小切分成页,在一个被称为是页表的结构当中进行维护。操作系统级别的内存管理,都是以页为单位的。
页的实际存储位置由操作系统管理。当多个应用程序同时执行时,操作系统会将不活动的页从RAM当中移动到磁盘文件当中。当应用程序重新尝试访问该页的时候,再从磁盘文件读入到RAM当中,这个称为页交换。
也是因为页的这个特性,操作系统完全可以分配实际上不存在的页给malloc,直到该页被真正使用。所以说理论上malloc 1TB可能都没有问题,虽然RAM和磁盘上的交换文件(或者交换分区)加起来往往也就是几十个GB。这就是因为操作系统实际上并没有分配完整的空间给这些页,只是在页表当中记了一笔:A程序需要XXXX页,并留出相应的行,在其中记录“未确保空间”。
当应用程序真的开始尝试对malloc返回的地址进行读写的时候,如果操作系统检测到页其实还没有分配实际的空间,这个时候才会去寻找合适的空间填写进页表。如果找不到合适的空间,那么就会触发页错误(overcommit错误)。
在Linux当中,我们是可以关闭overcommit功能,要求操作系统每次分配分页的时候都确保分页可用,那么malloc可分配的最大大小就受实际RAM+交换文件/分区大小的限制。在Linux当中,为保障操作系统自身的运行,通常这个大小是RAM的一半+交换区的大小。(当然可改变设置)。
另外,单次可分配的大小和多次合计可分配的大小也不是同一个概念,因为多次分配存在内存碎片化的问题。就好像一个饭店虽然有40个座位,但是也许只需要几个人你就没办法找到一张8人桌一样。
所以,在当代操作系统当中,malloc可以分配的空间的最大大小,首先当然受机器的bit数,也就是可以直接寻址的空间大小制约;在这个基础上进一步受操作系统的策略制约;然后受实际可用资源量的制约;再者还受malloc本身算法的制约;最后还受应用程序申请方式和次数的制约。
第一: private,public,protected的访问范围:
第二:类的继承后方法属性变化:
Git 是一个开源的分布式版本控制系统,用于快速高效地处理任何或小或大的项目。最初是为了帮助管理Linux内核开发的一个开放源码的版本控制软件。
Git应用十分广泛,小到我们平时使用github网站,大到公司中多人合作的大型项目开发。
它速度快,完全分布式,允许成千上万个并行开发的分支,容灾性能强。
①工作区(Workspace)是电脑中实际的目录。
②暂存区(Index)类似于缓存区域,临时保存你的改动。
③仓库区(Repository),分为本地仓库和远程仓库。
① git add 从工作区提交到暂存区
② git commit 从暂存区提交到本地仓库③ git push 或 git svn dcommit 从本地仓库提交到远程仓库
让你回答为什么用git,其实就是让你说之前的版本控制系统为什么不好。
最开始的版本控制方法一般都是采用 人工手动控制。
千人千面,不同版本命名随意
有时无法快速辨别版本新旧
无法快速知道版本1相比版本2自己改了什么
如果要是多人开发,那命名和版本控制更为可怕
备份
服务器压力
安全性
工作模式
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
映射关系可以分为两种
1、文件映射
磁盘文件映射进程的虚拟地址空间,使用文件内容初始化物理内存。
2、匿名映射
初始化全为0的内存空间。
而对于映射关系是否共享又分为
1、私有映射(MAP_PRIVATE)
多进程间数据共享,修改不反应到磁盘实际文件,是一个copy-on-write(写时复制)的映射方式。
2、共享映射(MAP_SHARED)
多进程间数据共享,修改反应到磁盘实际文件中。
因此总结起来有4种组合
1、私有文件映射
多个进程使用同样的物理内存页进行初始化,但是各个进程对内存文件的修改不会共享,也不会反应到物理文件中
2、私有匿名映射
mmap会创建一个新的映射,各个进程不共享,这种使用主要用于分配内存(malloc分配大内存会调用mmap)。
例如开辟新进程时,会为每个进程分配虚拟的地址空间,这些虚拟地址映射的物理内存空间各个进程间读的时候共享,写的时候会copy-on-write。
3、共享文件映射
多个进程通过虚拟内存技术共享同样的物理内存空间,对内存文件 的修改会反应到实际物理文件中,他也是进程间通信(IPC)的一种机制。
4、共享匿名映射
这种机制在进行fork的时候不会采用写时复制,父子进程完全共享同样的物理内存页,这也就实现了父子进程通信(IPC).
这里值得注意的是,mmap只是在虚拟内存分配了地址空间,只有在第一次访问虚拟内存的时候才分配物理内存。
在mmap之后,并没有在将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096)加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多。
RAII全程为Resource Acquisition Is Initialization(资源获取即初始化),RAII是C++语法体系中的一种常用的合理管理资源避免出现内存泄漏的常用方法。以对象管理资源,利用的就是C++构造的对象最终会被对象的析构函数销毁的原则。RAII的做法是使用一个对象,在其构造时获取对应的资源,在对象生命期内控制对资源的访问,使之始终保持有效,最后在对象析构的时候,释放构造时获取的资源。
RAII是合理管理资源避免出现内存泄漏的常用方法。那么所谓的资源是如何进行定义的呢?在计算机系统中,资源是数量有限且对系统正常运行具有一定作用的元素。比如:网络套接字、互斥锁、文件句柄和内存等等,它们属于系统资源。由于系统的资源是有限的,就好比自然界的石油,铁矿一样,不是取之不尽,用之不竭的,所以,我们在编程使用系统资源时,都必须遵循一个步骤:
如果在申请和使用资源后未进行资源的释放,此时就造成了资源的泄漏。资源使用后必须要将其释放。
简单举例:
#include
using namespace std;
int main()
{
int *arr = new int [10];
// Here, you can use the array
delete [] arr;
arr = nullptr ;
return 0;
}
上述的的申请、使用、释放资源的程序较为简单,但是如果程序很复杂的时候,需要为所有的new 分配的内存delete掉,导致极度臃肿,效率下降,更可怕的是,程序的可理解性和可维护性明显降低了,当操作增多时,处理资源释放的代码就会越来越多,越来越乱。如果某一个操作发生了异常而导致释放资源的语句没有被调用,怎么办?这个时候,RAII机制就可以派上用场了。
使用RAII机制的优点
由于系统的资源不具有自动释放的功能,而C++中的类具有自动调用析构函数的功能。如果把资源用类进行封装起来,对资源操作都封装在类的内部,在析构函数中进行释放资源。当定义的局部变量的生命结束时,它的析构函数就会自动的被调用,如此,就不用程序员显示的去调用释放资源的操作了。
使用RAII机制的代码示例
#include
#include
#include
using namespace std;
CRITICAL_SECTION cs;
int gGlobal = 0;
class MyLock
{
public:
MyLock()
{
EnterCriticalSection(&cs);
}
~MyLock()
{
LeaveCriticalSection(&cs);
}
private:
MyLock( const MyLock &);
MyLock operator =(const MyLock &);
};
void DoComplex(MyLock &lock )
{
}
unsigned int __stdcall ThreadFun(PVOID pv)
{
MyLock lock;
int *para = (int *) pv;
// I need the lock to do some complex thing
DoComplex(lock);
for (int i = 0; i < 10; ++i)
{
++gGlobal;
cout<< "Thread " <<*para<
这个例子可以说是实际项目的一个模型,当多个进程访问临界变量时,为了不出现错误的情况,需要对临界变量进行加锁;上面的例子就是使用的Windows的临界区域实现的加锁。但是,在使用CRITICAL_SECTION时,EnterCriticalSection和LeaveCriticalSection必须成对使用,很多时候,经常会忘了调用LeaveCriticalSection,此时就会发生死锁的现象。当我将对CRITICAL_SECTION的访问封装到MyLock类中时,之后,我只需要定义一个MyLock变量,而不必手动的去显示调用LeaveCriticalSection函数。
RAII总结