
C/C++面试八股至臻总结版,一文带你彻底搞懂C/C++面试!
泛型编程 -> 提供代码复用编译时计算:可以在编译时进行复杂的计算,减少运行时的开销。代码生成:通过模板生成代码,避免手动编写重复的代码,提高代码的可维护性。类型安全:模板元编程在编译时进行类型检查,确保类型安全。模板元编程(Template Metaprogramming,TMP)是一种利用C++模板的编程技术,用于在编译期间执行计算、生成代码、进行类型转换等一系列的元编程操作,从而实现更高效、
一、基本语法与数据结构
01 float 两个数比大小与0比较
浮点数不能直接与0判断,应该设法转化成 “>=” 和 “<="
float类型存储方式
在C++中,浮点数类型(如float
)的存储方式遵循IEEE 754标准。IEEE 754定义了两种浮点数表示形式:单精度浮点数和双精度浮点数,分别对应float
和double
类型。
单精度浮点数(float
)使用32位(4字节)内存进行存储,其中包括以下组成部分:
- 符号位(1位):用于表示浮点数的正负号。0表示正数,1表示负数。
- 指数位(8位):用于表示浮点数的阶码(指数)部分。采用偏置指数表示法,其中的真实指数值需要减去一个偏置值(127),以支持正、负、零等不同的范围。
- 尾数位(23位):也称为有效数字或小数部分。它表示浮点数的尾数(即有效数字)部分,决定了浮点数的精度。
通过这样的表示方式,单精度浮点数可以表示大约7个有效数字,并且能够表示的范围约为±1.17549e-38到±3.40282e+38。
需要注意的是,由于二进制浮点数无法精确表示一些十进制数(如0.1),在某些情况下,浮点数运算可能会出现精度损失和舍入误差。因此,在进行浮点数比较时,应该考虑使用适当的容差或其他方法来处理精度问题。
02 如何判断两个浮点数相等
不能直接用判断
所以判断浮点数是否相等的常用方法是:
取两数差值的绝对值判断其是否在某一范围内(作差)
追问1 为什么会出现精度问题
计算机用二进制计算 十进制转二进制的时候 浮点数位数不够,会舍去一些,造成精度误差
由于浮点数的位数有限,所以在进行某些运算时,可能会出现舍入误差(rounding error),从而导致精度损失。
03 下面四行代码含义
int p[10]:存放了10个int 类型的数组
int(*p)[10]:指向整型数组的指针
int *p(int);
p(int) 说明p为函数,返回值为 int类型的指针 (指向int的指针)。
int ( *p)(int);
( p)说明 p 为指针,(p)() 说明该指针指向函数, 函数的返回值为int
04 static和const 特性和作用 静态局部变量存在哪
const就是只读的意思,只在声明中使用,意即其所修饰的对象为常量((immutable)),它不能被修改,并存放在常量区。
static一般有两个作用,规定作用域和存储方式(静态存储)。对于局部变量,static规定其为静态存储方式每次调用的初始值为上一次调用后的值,调用结束后存储空间不释放;对于全局变量,如果以文件划分作用域的话,此变量只在当前文件可见,对于static函数也是如此。static修饰的变量如果没有初始化,则默认为0.
static修饰类的局部变量 也不属于某个实例对象 而是被一个类的所有对象所共有 也叫类变量
静态成员变量
1). 静态成员变量需要在类内声明(加static),在类外初始化(不能加static),如下例所示;
2). 静态成员变量在类外单独分配存储空间,位于全局数据区,因此静态成员变量的生命周期不依赖于类的某个对象,而是所有类的对象共享静态成员变量;
3). 可以通过类名和对象名直接访问公有静态成员变量;
静态成员函数
1). 静态成员函数是类所共享的;
2). 静态成员函数可以访问静态成员变量,但是不能直接访问普通成员变量(需要通过对象来访问);需要注意的是普通成员函数既可以访问普通成员变量,也可以访问静态成员变量;
3). 可以通过对象名和对象名直接访问公有静态成员函数;
05 short、int、long、long long类型
变量类型 |
32位 |
64位 |
char |
1 |
1 |
short |
2 |
2 |
int |
4 |
4 |
float |
4 |
4 |
double |
8 |
8 |
long |
4 |
8 |
long long |
8 |
8 |
06 volatile使用
作用
- 易变性 数据十分容易改变
- 防止编译器优化内存读取 保证了内存的可见性 简单来说就是 定义为volatile变量后 每次都会直接从内存中读取变量 而不是读缓存在寄存器中的数据
- 保证执行的顺序性 多线程
使用场景
- 一般在多线程中 某一个全局变量被所以线程共享 被频繁修改 定位为volatile变量 保证每次都是从内存都 而不是读寄存器的缓存
- 在嵌入式系统中 用的也比较多 一些硬件寄存器 还有 中断触发的变量设置为volatile变量
能否保证原子性
不能保证
底层实现
lock汇编指令 lock指令会锁住操作的缓存行。首先将本处理器的缓存写入内存,然后这个写入操作会引起其他处理器或者内核对应的缓存失效,这样指令重排就无法越过内存屏障
07 extern C的使用/extern'C'详细过程,在什么时期做了什么,为什么要这样
extern c的作用是告诉C++编译器用C规则编译指定的代码
C调用C++中的方法
在 C 中调用 C++ 代码,则需要使用 extern "C" 包裹 C++ 函数,以使其能够按照 C 的调用方式进行调用。
例如,在 C++ 文件中定义一个函数:
extern "C" void say_hello_from_cpp()
{
std::cout << "Hello from C++!" << std::endl;
}
然后在 C 文件中声明并调用该函数:
extern void say_hello_from_cpp();
int main()
{
say_hello_from_cpp();
// ...
}
C++调用C中的方法
在 C++ 文件中使用 extern C来声明该函数,以便在 C++ 中调用:
extern "C" int add(int a, int b);
int main()
{
int sum = add(1, 2);
// ...
}
08 四种类型强转、dynamic_cast底层实现、类型强转失败会怎么样
- static_cast 静态类型强转
- const_cast 常量转换
- dynamic_cast 动态转换
- reinterpret_cast 重新解释转换
追问1 类型强转失败如何表现
- 编译报错 警告
09 程序从main开始执行么
和 attribute 中的constructor和destructor有关 有点类似c++的构造函数和析构函数
attribute 主要可以设置函数属性、变量属性和类型属性,主要是函数属性(设置为constructor 在main函数之前执行、destructor 在main函数之后执行)
#include <stdio.h>
#include <stdlib.h>
static int * g_count = NULL;
__attribute__((constructor)) void load_file()
{
printf("Constructor is called.\n");
g_count = (int *)malloc(sizeof(int));
if (g_count == NULL)
{
fprintf(stderr, "Failed to malloc memory.\n");
}
}
__attribute__((destructor)) void unload_file()
{
printf("destructor is called.\n");
if (g_count)
free(g_count);
}
int main()
{
return 0;
}
10 delete和delete[]的区别
如果是针对*自定义的类对象指针的内存和开辟和释放(类中没有内存的开辟时),单个对象的内存的开辟必须用delete来释放,多个对象内存的开辟必须用delete[]来释放。*
在 C++ 中,delete 和 delete[] 都是用于释放动态分配的内存的操作符。它们之间的区别在于:
- 使用方式:delete 用于释放单个对象的内存,而 delete[] 用于释放数组对象的内存。也就是说,如果通过 new 分配了单个对象的内存,则应该使用 delete 将其释放;如果通过 new[] 分配了数组对象的内存,则应该使用 delete[] 将其释放。
- 操作对象的数量:delete 操作符只能释放一个对象的内存,而 delete[] 操作符可以释放一个数组对象的内存,且会调用数组中每个元素的析构函数。
- 内存释放的方式:delete 只会释放分配的单个对象的内存,而 delete[] 则会释放分配的数组的内存以及数组中所有元素的内存。这也就意味着,如果使用 delete 释放数组对象的内存,只有第一个元素的内存被释放,其他元素的内存则不会被释放,从而导致内存泄漏。
因此,当需要释放动态分配的数组对象的内存时,必须使用 delete[] 操作符,而不能使用 delete 操作符。反之,当需要释放动态分配的单个对象的内存时,则应该使用 delete 操作符。
总之,delete 和 delete[] 的区别在于操作对象的数量和内存释放的方式。在使用过程中,需要根据具体的情况选择使用哪个操作符,以确保内存的正确释放。
补充:
delete[] 如何知道delete多少元素
11 explicit关键字作用
explicit关键字的作用就是防止类构造函数的隐式自动转换.
若只有构造函数和拷贝构造,只能调用这两个方法,不能赋值构造
class CxString // 使用关键字explicit声明
{
public:
int _age;
int _size;
explicit CxString(int age, int size = 0)
{
_age = age;
_size = size;
// 代码同上, 省略...
}
CxString(const char *p)
{
// 代码同上, 省略...
}
};
// 下面是调用:
CxString string1(24); // 这样是OK的
CxString string2 = 10; // 这样是不行的, 因为explicit关键字取消了隐式转换
CxString string3; // 这样是不行的, 因为没有默认构造函数
string1 = 2; // 这样也是不行的, 因为取消了隐式转换
string2 = 3; // 这样也是不行的, 因为取消了隐式转换
string3 = string1; // 这样也是不行的, 因为取消了隐式转换, 除非类实现操作符"="的重载
12 C与C++区别(面向对象和面向过程的区别)
C 和 C++ 是两种不同的编程语言,虽然它们在语法和一些基础概念方面有很多共同点,但也存在许多区别。其中一些重要的区别如下:
- 面向对象编程:C++ 支持面向对象编程,而 C 不支持。面向对象编程的核心思想是将数据和处理数据的方法封装到一个类中,从而实现模块化的程序设计。
- 标准库:C++ 提供了丰富的标准库,例如 STL(标准模板库)、iostream、fstream 等,这些库提供了很多易于使用的数据结构和算法。C 也有标准库,但是比起 C++ 来说相对少和简单。
- 函数重载和默认参数:C++ 支持函数重载和默认参数,而 C 不支持。函数重载允许在一个类中定义多个函数,这些函数具有相同的名字但是参数列表不同。默认参数允许在函数定义时指定默认参数值,当调用该函数时,如果没有传递参数,则使用默认参数值。
- this 指针:C++ 中的类成员函数可以使用 this 指针访问对象的成员变量和成员函数,而 C 中没有这样的概念。
- 异常处理:C++ 支持异常处理机制,可以捕获和处理程序中的异常,从而实现更加健壮的程序设计。C 中没有异常处理机制。
- 命名空间:C++ 支持命名空间机制,可以将函数、变量、类型等定义在不同的命名空间中,避免名称冲突。C 中没有命名空间的概念。
- bool 类型和 true/false:C++ 引入了 bool 类型,可以表示逻辑真和逻辑假,同时引入了 true 和 false 关键字。在 C 中使用 0 表示假,非 0 表示真。
总之,C++ 在许多方面扩展了 C 的功能,特别是引入了面向对象编程、标准库、函数重载和默认参数、异常处理等概念,使得 C++ 成为更加强大和灵活的编程语言。当然,对于一些嵌入式系统等对资源限制较多的场合,依然可能选择使用 C 作为更轻量级的编程语言。
面向对象编程(Object-Oriented Programming, OOP)和面向过程编程(Procedural Programming)是两种不同的编程范式。
- 面向对象编程:面向对象编程重点关注对象的行为和状态,强调封装、继承和多态。每个对象都有自己的属性和方法,对象之间通过消息传递进行相互交互,这种方式能够更好地体现真实世界中事物的本质特性。常见的面向对象编程语言有 Java、C++、Python 等。
- 面向过程编程:面向过程编程以步骤为中心,从头到尾按照一定的顺序完成某个任务。面向过程编程以函数为基本单位,每个函数封装了一些特定的操作,通过调用不同的函数来组成程序。与面向对象编程不同的是,在面向过程编程中,数据与函数是分离的,没有封装和继承的概念,更加注重程序的执行效率和性能。常见的面向过程编程语言有 C、Fortran、Pascal 等。
从开发角度来看,面向对象编程相对于面向过程编程更加灵活和符合人类思考的习惯,其拥有高内聚、低耦合的优良特性,使得程序更加易于维护和扩展。而面向过程编程则适用于一些简单、流程性的任务,其开发速度往往会快于面向对象编程。
总之,面向对象编程和面向过程编程是两种不同的编程范式,每种范式都有自己的优势和适用场景。在实际开发中,可以根据任务性质、开发需求和个人偏好等因素选择不同的编程范式。
13 赋值初始化和初始化列表(初始化成员变量的两种方式)
在 C++ 中,初始化列表是构造函数的一部分,它用于初始化类的成员变量。在构造函数中使用初始化列表进行成员变量的初始化,相比于在构造函数体内使用赋值操作进行初始化,有以下优点:
- 更高效:使用初始化列表可以避免在构造函数体内对成员变量进行赋值,这样可以减少生成的临时对象的数量,提高程序的效率。在使用初始化列表时,编译器会将成员变量的赋值操作转化为直接在对应的内存位置上进行赋值,因此可以减少一些不必要的内存拷贝操作。
- 初始化顺序:使用初始化列表可以显式指定成员变量的初始化顺序,而在构造函数体中进行初始化时,初始化顺序会受到声明顺序的影响,容易出现意外错误。
- 对 const 成员变量的支持:使用初始化列表可以对 const 成员变量进行初始化,而在构造函数体内无法对 const 成员变量进行赋值操作。
例如,我们有如下定义的一个类:
class Person {
public:
Person(string name, int age) : name_(name), age_(age) {}
private:
string name_;
int age_;
};
在该类的构造函数中使用了初始化列表,可以看到在构造函数中直接对成员变量进行初始化,而没有进行赋值操作。与直接在构造函数体内进行赋值操作相比,使用初始化列表的方式效率更高,同时可以显式指定成员变量的初始化顺序。
总之,在 C++ 中使用初始化列表是一个优秀的编程习惯,可以提高程序的效率和可靠性。
14 struct在c和c++中的区别
C中的struct没有继承 也没有public private protected这些访问权限
c++中struct和class区别:
总的来说,struct 更适合看成是一个数据结构的实现体,class 更适合看成是一个对象的实现体。
区别:
最本质的一个区别就是默认的访问控制
默认的继承访问权限。struct 是 public 的,class 是 private 的。
struct 作为数据结构的实现体,它默认的数据访问控制是 public 的,而 class 作为对象的实现体,它默认的成员变量访问控制是 private 的。
15 inline 和 define(宏)的区别,有什么优势、劣势
- define只是在预处理阶段定义的一个宏 inline则是在编译阶段完成
- define是没有类型检查的 只是简单的替换 inline则会对参数类型进行检查 通常会把一些短小的函数展开(执行时间很短调用频繁的函数) 以空间换时间的观念
追问1 内联函数的缺点
- 虽然可以减少函数调用的开销,但是会导致代码量变大
- 导致编译时间增长
16 override的作用
在程序中加override 关键字,可以避免派生类中忘记重写虚函数的错误
C++11 中的 override 关键字,可以显式的在派生类中声明,哪些成员函数需要被重写,如果没被重写,则编译器会报错。
17 常见的段错误有哪些
- 访问NULL指针:当程序尝试使用一个指向空地址的指针时,就会发生段错误。
- 栈溢出:当程序使用了超出栈空间的内存,或者在递归调用时没有正确终止递归,也会导致段错误。
- 访问已释放的内存:当程序试图访问已经释放的内存地址时,就会发生段错误。
- 数组越界:当程序访问数组时,如果数组的下标越界,就会导致段错误。
18 class、struct和union的区别
struct
和union
都是在C和C++中用来创建自定义数据类型的关键字,但它们有不同的用途和特点:
struct
(structure)是一种复合数据类型,允许程序员定义一组不同类型的变量作为一个单独的实体来使用。struct
中的各个成员在内存中是按照定义的顺序依次存储的,每个成员都有自己的地址。struct
中的各个成员占据的内存空间是累加的,因此struct
的总大小等于各个成员占据空间的总和。
示例代码:
struct Student {
char name[20];
int age;
float gpa;
};
在这个示例中,Student
是一个自定义的结构体,包含了三个成员:一个字符串数组name
、一个整数age
和一个浮点数gpa
。
union
(union)也是一种复合数据类型,不同于struct
的是,union
中的各个成员是共享同一块内存空间的,它们的地址是相同的。union
中只有一个成员是活跃的,即最近一次被赋值的成员,其他成员都是无效的。这种特性使得union
能够节省内存空间,但也带来了一些安全问题,因为程序员需要自己确保在使用union
时不会出现访问无效成员的情况。
示例代码:
cCopy codeunion Number {
int i;
float f;
};
在这个示例中,Number
是一个自定义的联合体,包含了两个成员:一个整数i
和一个浮点数f
,它们共享同一块内存空间。当我们对i
进行赋值时,f
的值就变得不可预测,反之亦然。
19 include <> 和 "" 的区别
在 C++ 中, 用于引入头文件。在使用
时,头文件的名称可以用
<>
或 ""
括起来。它们之间的区别如下:
<>
用于引入标准库头文件,编译器会在标准库的路径中查找该头文件。""
用于引入用户自定义的头文件,编译器会先在当前目录中查找该头文件,如果没有找到,再到系统库路径中查找。
<>
中的头文件名称不需要指定文件的路径,编译器会自动在标准库的路径中查找该头文件。而""
中的头文件名称可以指定相对路径或绝对路径。
- 使用
<>
时,编译器会先搜索标准系统目录,然后搜索环境变量INCLUDE
所指定的目录。而使用""
时,编译器会先搜索当前目录,然后搜索环境变量INCLUDE
所指定的目录。
- 在使用
<>
时,编译器不会在当前目录中查找头文件。因此,如果要引用当前目录中的头文件,必须使用""
。
综上所述,<>
用于引入标准库头文件,而 ""
用于引入用户自定义的头文件,并且 ""
更灵活,可以指定相对路径或绝对路径。
20 数组实现栈、队列
#include "iostream"
#include "vector"
using namespace std;
template<typename T,typename act = vector<T>>
class Stack {
private:
//如果没有显示给出act的类型
// act == vector<T>
//创建act类型的变量arr
//如果act是类类型,那么arr是对象
act arr;
public:
//无参构造
Stack() {}
//元素入栈
void Push(const T& value = T()) {
arr.push_back(value);
}
//元素出栈
void Pop() {
arr.pop_back();
}
//返回栈中元素个数
size_t Size()const {
return arr.size();
}
//判断栈是否是空
bool Empty() const {
return arr.empty();
}
//普通栈 返回栈顶元素
T& Top() {
return arr.back();
}
};
int main() {
Stack<int> s;
s.Push(1);
s.Push(2);
cout << s.Top() << endl;
cout << s.Size() << endl;
s.Pop();
cout << s.Empty() << endl;
}
#include "iostream"
#include "vector"
using namespace std;
template<typename T,typename act = vector<T>>
class Queue {
private:
act arr;
public:
//无参构造
Queue() {}
//元素入队列
void Push(const T& value = T()) { // const 不能少
arr.push_back(value);
}
//队头元素出队列
void Pop() {
arr.erase(arr.begin());
}
//返回队列元素个数
size_t Size() const {
return arr.size();
}
//判断队列是否为空
bool Empty() {
return arr.empty();
}
//普通对象 返回队头元素的引用
T& Front() {
return arr.front();
}
//普通对象 返回队尾元素的引用
T& Back() {
return arr.back();
}
};
int main() {
Queue<int> q;
q.Push(1);
q.Push(2);
cout << q.Size() << endl;
cout << q.Front() << endl;
cout << q.Back() << endl;
q.Pop();
cout << q.Empty() << endl;
}
21 strcpy、memcpy与memmove区别
- 功能:
strcpy
用于将字符串从源地址复制到目标地址;而memcpy
和memmove
则用于将任意类型的内存复制到另一个内存地址。
memcpy
和 memmove
都是 C++ 中用于复制内存数据的函数,它们的功能非常类似,但有以下几个区别:
- 处理重叠区域的能力:当源地址和目标地址存在重叠区域时,
memcpy
的行为是未定义的,可能会导致意外的结果。而memmove
则保证在复制过程中不会产生错误,即使源地址和目标地址存在重叠区域也能正常工作。
- 效率:由于
memmove
要处理重叠区域,需要进行额外的判断和操作,所以通常情况下会比memcpy
的效率略低一些。
- 返回值:
memcpy
函数返回指向目标内存区域的指针,而memmove
函数返回指向目标内存区域的指针。
- 可移植性:
memcpy
和memmove
在不同的编译器和平台上的实现可能会有所不同,需要我们根据具体情况选择适当的函数。
综上所述,如果我们需要复制的内存区域不存在重叠,可以使用 memcpy
函数,由于其效率高,适用于大量数据的复制;如果需要处理的内存区域存在重叠,或者不确定是否存在重叠,应该尽量使用 memmove
函数,由于其能够处理重叠区域,具有更好的可靠性。
22 define和const区别
define和const都可以用来定义一个常量
区别在于
define只是简单的替换 没有类型检查
const是有类型检查的
// 定义常量
#define PI 3.14159
const double E = 2.71828;
23 typedef和define
typedef
和define
都是C++中用来定义别名的关键字。
typedef
可以定义新类型的别名,它本质上是对已有类型的重新命名。通过typedef
定义的别名会被编译器作为一种新的类型进行处理。例如:
int myint;
myint num = 10;
上述代码定义了一个myint
类型,其本质上是int
类型的别名,因此num
的类型为myint
,即int
。
define
定义的是预处理器宏,它是一种纯文本替换机制,将程序中所有出现该宏名称的地方都替换为宏定义的内容。例如:
#define MAX_VALUE 100
int arr[MAX_VALUE];
上述代码中,define
定义了一个名为MAX_VALUE
的宏,其值为100
。在后面的代码中,程序会将arr[MAX_VALUE]
替换为arr[100]
,即预处理器会在编译前处理代码,将MAX_VALUE
替换为100
。
总的来说,typedef
和define
都可以用来定义别名,但是它们的作用不同,前者可以定义新类型,后者则是进行文本替换。typedef
关键字被编译器所支持,而define
则是预处理器功能,因此在使用时需要注意二者的区别。
24 AVL和红黑树特点
AVL 树和红黑树都是常用的自平衡二叉查找树,它们保证了在最坏情况下的时间复杂度为 O(log n),但在实现细节上有一些区别。
- 平衡条件不同。AVL 树通过要求任何节点的左右子树高度之差不超过 1 来保持平衡,而红黑树则通过要求满足几个特定的性质来保持平衡。具体来说,红黑树要求每个节点都是红色或黑色,并满足以下特定性质:根节点为黑色,每个叶子节点(NIL 节点)为黑色,红色节点的两个子节点必须是黑色,从任意节点到其每个叶子节点的所有路径都包含相同数目的黑色节点。
- 节点结构不同。AVL 树的节点需要维护左右子树的高度信息,因此需要额外的空间来存储高度信息。而红黑树只需要记录节点颜色和指向左右儿子的指针即可。
- 插入和删除操作的平衡方法不同。对于 AVL 树的插入和删除操作,需要通过旋转来保持树的平衡,而红黑树也需要进行旋转操作,但还需要通过颜色变换来保持平衡。
综上所述,AVL 树和红黑树都是高度平衡的二叉查找树,但在实现细节和性质上有所差异。AVL 树更适用于插入、删除操作较少而搜索操作频繁的场景,而红黑树则更适用于插入、删除操作较多的场景,并且红黑树的实现更加简单,适用范围更广。
25 memcpy、strcpy底层实现
- memcpy(dest, src, n)
- strcpy(dest, src)
26 指针常量和常量指针
指针常量和常量指针是两种不同的概念。
指针常量是指一个指针,始终指向同一个地址 int* const p
常量指针是指一个指针,值不变 const int* p
27 全局变量什么时候初始化
全局变量是在程序运行期间动态初始化的,而不是在编译时初始化。当程序启动时,操作系统会为全局变量分配内存空间,并进行初始化。对于基本数据类型,全局变量的初始值通常为 0 或者其默认值;而对于自定义类型,它们将调用默认构造函数进行初始化。
需要注意的是,全局变量的初始化顺序在不同编译器和平台上可能有所不同。因为全局变量的初始化涉及到多个源文件和库文件,有时候可能会出现初始化顺序的问题,导致程序出现未定义行为。因此,在编写程序时应该尽可能避免过度使用全局变量,并且注意不同变量之间的初始化顺序。
除此之外,全局变量还存在一些其他问题,如安全性问题和可维护性问题。因此,在实际编程中,应该尽可能使用局部变量和参数来传递数据,以提高程序的可读性和可维护性。
28 重复引用头文件怎么办
在C/C++中,重复引用相同的头文件可能会导致编译错误,因为编译器会认为同一个头文件被重复定义了多次。为了避免这种情况发生,可以使用以下两种方法:
- 使用预处理器指令#ifndef、#define和#endif实现头文件保护
预处理器指令#ifndef、#define和#endif可以实现头文件保护,避免头文件被重复包含。例如:
#ifndef EXAMPLE_H
#define EXAMPLE_H
// 头文件内容
#endif // EXAMPLE_H
在上述代码中,首先使用#ifndef指令判断EXAMPLE_H是否已经被定义过了,如果没有定义,就使用#define指令将其定义为一个非零值,同时将头文件的内容写入其中。最后使用#endif指令来结束#ifdef和#ifndef区块的定义。如果EXAMPLE_H已经被定义过了,那么#ifndef和#define语句就不会执行,头文件的内容也不会被重复定义,从而避免了编译错误的问题。
- 使用#pragma once指令实现头文件保护
#pragma once是一种编译器特有的预处理器指令,用于实现头文件保护。它可以确保同一个头文件只被编译一次,从而避免头文件被重复包含的问题。例如:
#pragma once
// 头文件内容
在上述代码中,使用#pragma once指令来实现头文件保护,从而避免头文件被重复引用的问题。需要注意的是,虽然#pragma once指令不是标准C/C++的一部分,但它已经被广泛支持,在大多数编译器中都可以使用。
29 指针数组和数组指针
// 指针数组
int* p[4];
// 数组指针
int (*p)[4]
int* p[4]
和 int (*p)[4]
的主要区别在于指针的位置和语义。
int* p[4]
: 这个声明创建了一个包含 4 个int*
类型元素的数组p
。这意味着p
是一个数组,每个元素都是指向整数的指针。
int (*p)[4]
: 这个声明创建了一个指向包含 4 个整数的数组的指针p
。这意味着p
是一个指针,指向一个包含 4 个整数的数组。
下面是两者声明的示例:
int* p[4]; // 声明一个含有 4 个指向整数的指针的数组
int arr[4]; // 整数数组
p[0] = &arr[0]; // 将第一个元素指向整数数组的首地址
int (*p)[4]; // 声明一个指向整数数组的指针
int arr[4]; // 整数数组
p = &arr; // 将指针 p 指向整数数组的地址
总结起来,int* p[4]
是一个指向指针的数组,而 int (*p)[4]
是一个指向整数数组的指针。
30 sizeof和strlen的区别
sizeof
和 strlen
是 C 语言中的两个不同的操作符,它们具有不同的功能和用法。
sizeof
是一个运算符,用于计算类型或变量的大小(字节数)。它返回结果作为size_t
类型。使用sizeof
可以获取一个类型、结构体、联合体或对象所占用的字节数。例如,可以使用sizeof(int)
来获取int
类型的大小,或者使用sizeof
操作符来获取结构体定义的变量的大小。sizeof
运算符是在编译时求值的,因此计算结果是静态的。
strlen
是一个函数,用于计算一个以 null 字节终止的字符串的长度,即字符串中字符的数目。它返回结果作为size_t
类型。例如,对于字符串"Hello"
,strlen
返回5以表示该字符串包含5个字符。strlen
函数会遍历字符串,直到找到 null 字节为止,然后返回字符的数量。由于每次都需要遍历整个字符串,因此strlen
的操作时间与字符串的长度成正比。
总结:
sizeof
是一个运算符,用于计算类型的大小(在编译时);
strlen
是一个函数,用于计算字符串的长度(在运行时)。
"hello"
// strlen 5
// sizeof 6 带有终止符null
31 char* 和 char[]的区别
char*
和char[]
是C语言中表示字符类型的两种不同方式。
char*
是指向字符的指针,也被称为字符串指针。它存储了一个内存地址,该地址指向一个或多个连续的字符。
char[]
是字符数组,也被称为字符串数组。它由一系列连续的字符元素组成,可以在声明时指定长度或者根据初始化的内容自动确定长度。
以下是一些区别:
- 内存分配方式:
-
char*
通常使用动态内存分配(例如,通过调用malloc()
函数),这意味着可以在运行时动态地分配和释放内存。
-
char[]
则在编译时或静态时分配内存,其大小在声明时就已经确定,无法再改变大小。
- 空间需求:
-
char*
只需要存储一个指针变量的内存空间,通常为4字节(32位系统)或8字节(64位系统)。
-
char[]
根据数组的大小而变化,在堆栈上占用实际字符数据所需的内存空间。
32 auto和decltype的区别
decltype
和auto
都是C++11引入的关键字,用于类型推导。它们的主要区别如下:
- 类型推导方式:
-
auto
是用于自动推导变量的类型,让编译器在编译时根据变量的初始化值来确定其类型。可以将其视为一个占位符,表示变量的类型将由编译器自动推导。
-
decltype
用于从表达式中提取类型信息,并在编译时获取该表达式的类型。decltype(expr)
返回表达式expr
的静态类型。
- 初始化要求:
-
auto
要求变量在定义时必须进行初始化,通过初始化值进行类型推导。
-
decltype
则不要求表达式进行初始化,它只根据表达式的类型来做类型推导。
- 表达式推导范围:
-
auto
可以推导任意类型的表达式,包括基本数据类型、复杂对象、函数指针、lambda等。
-
decltype
可以推导表达式的类型,包括变量、函数调用、成员访问等。
- 引用类型推导:
-
auto
对于变量声明的类型,如果初始化值是引用,则去除引用,得到纯粹的类型。例如,int
会被推导为int
。
-
decltype
推导变量的类型时,保留引用性质。例如,int
会被推导为int
。
- 表达式中的变量名推导:
-
auto
无法从表达式中推导出变量名的类型或成员名的类型。
-
decltype
可以通过使用变量名或成员名来推导出相应的类型。
总结而言,auto
用于自动推导变量的类型,根据初始化值确定类型;而decltype
从表达式中推导类型,返回表达式的静态类型。它们在初始化要求、表达式推导范围和引用类型推导等方面也有所不同。在实际使用时,根据需求选择合适的关键字进行类型推导。
33 const和constexpr的区别
C++:别再背 const 和 constexpr 的区别了 - 知乎 (zhihu.com)
constexpr
和const
都用于声明常量,但它们有几个重要的区别:
- 求值时机:
const
修饰的变量在运行时是只读的,不能修改。而constexpr
修饰的变量或函数,在编译时可以进行计算并产生一个常量结果。也就是说,constexpr
变量或函数在编译时求值,而const
变量只在运行时起作用。
- 编译时优化:
constexpr
关键字更倾向于编译时优化,允许在编译时进行常量折叠等优化,从而提高程序的性能。而const
关键字没有这种优化效果。
- 上下文:
constexpr
可以用于标记函数、变量和类成员函数,以指示它们可以在编译期间进行求值。而const
主要用于修饰变量,表示该变量是只读的。
- 值的初始化:
const
修饰的变量在定义时必须进行初始化,而constexpr
变量在定义时也可以不进行初始化,只需确保在编译完成前能确定其值。
- 可用性:
const
可以用于任何类型的对象(包括基本数据类型、用户自定义类型等),而constexpr
主要用于字面值类型(如整数、浮点数、指针)或具有特定条件的函数。
总结而言,const
用于声明只读变量,在运行时起作用,而constexpr
用于声明在编译时求值的常量,允许在编译期间进行优化。constexpr
更偏向于编译时的常量计算和优化,而const
主要表示只读性质。
二、面向对象
01 多态的实现方法
多态,就是指当完成某个行为时,不同的对象去完成会产生出不同的状态。(定义)
多态分为静态多态和动态多态。
静态多态主要指编译时期的多态,例如函数重载和模板。
动态多态是指运行时期的多态,比如虚函数。
比如,将基类中的类成员函数定义成一个虚函数,在派生类中可以定义这个函数的不同实现,然后通过基类的指针或者引用指向派生类对象。
出现了虚函数,那个它会生成一个虚函数指针,存储在对象的头4个字节(vfptr 32位 4字节 64位 8字节),然后通过这个虚函数指针指向虚函数表(编译阶段产生的,运行时加载到.rodata段),虚函数表里存储了重写后虚函数的地址,最后进行动态绑定调用实现动态多态
每个对象都有自己的虚函数指针
虚函数=》vfptr=》vftable=》重写=》动态绑定调用=》动态多态
追问1 C语言如何实现多态
函数指针 + 结构体
一文搞懂怎么用C实现封装、继承、多态 - 知乎 (zhihu.com)
追问2 vptr被类所共享还是对象所共享,vtable呢
vptr 类对象所拥有
vtable 类共享
02 智能指针
利用栈上的对象出作用域自动析构的特征,来做到资源的自动释放,能够很好的解决内存泄漏问题。
但是普通智能指针解决不了浅拷贝问题,因为在析构的时候只析构了一次,将拷贝的智能指针析构掉,原先的指针没有被析构,造成了野指针的存在。
不带引用计数的智能指针
auto_ptr scoped_ptr unique_ptr
带引用计数的智能指针,使用资源的时候引用计数加1,不使用资源的时候引用计数减1,使用多个智能指针管理同一个资源
shared_ptr 可以改变资源的引用计数
weak_ptr 不可以改变资源的引用计数
避免只使用强指针导致的循环引用(只使用强指针交叉引用,会导致new出来的资源无法释放),定义对象的时候用强指针,引用对象的时候用弱指针
强弱指针一起用可以保证线程安全,通过引用计数避免已经析构的对象仍然访问了他的方法
循环引用:
SPA->pb = SPB; SPB->pa = SPA
shared_ptr是C++11中一个重要的智能指针类,用于解决动态内存管理和资源回收问题。shared_ptr通过引用计数的方式来管理动态内存的生命周期,通过多个shared_ptr对象共享同一块内存,避免了内存泄漏和野指针的问题。
但是,在使用shared_ptr时,循环引用会导致内存泄漏或程序崩溃等问题。这种循环引用指的是两个或多个对象通过共享同一个shared_ptr对象,从而形成一个环形结构。在这种情况下,虽然每个对象都有一个shared_ptr指向它,但其引用计数永远不可能为0,导致无法释放所占的内存。
假设存在两个类A和B,其中A类中有一个成员变量指向B类的shared_ptr,B类中也有一个成员变量指向A类的shared_ptr。此时如果A类和B类对象相互引用,就会形成一个循环引用,代码示例如下:
#include <memory>
class B;
class A
{
public:
std::shared_ptr<B> pb_;
~A() { std::cout << "A deleted" << std::endl; }
};
class B
{
public:
std::shared_ptr<A> pa_;
~B() { std::cout << "B deleted" << std::endl; }
};
int main()
{
std::shared_ptr<A> spA(new A());
std::shared_ptr<B> spB(new B());
spA->pb_ = spB;
spB->pa_ = spA;
return 0;
}
在上述代码中,当spA和spB离开作用域时,它们指向的对象都不会被正确地释放,导致内存泄漏。这是因为A和B对象互相引用,形成了一个循环引用,使得它们的引用计数永远不会为0,从而无法自动删除。
为解决循环引用问题,C++11提供了weak_ptr类。weak_ptr也是智能指针类,但与shared_ptr不同的是,weak_ptr不参与引用计数,只是对管理对象的一个弱引用。当需要使用所管理的对象时,可以通过调用lock()函数得到一个shared_ptr对象,并在使用后及时释放,避免了循环引用导致的内存泄漏问题。
追问1 智能指针和管理的对象分别在哪个区
智能指针本身在栈区,托管的资源在堆区,利用了栈对象超出生命周期后自动析构的特征,所以无需手动delete释放资源
追问2 shared_ptr线程安全么
C++ : shared_ptr是线程安全的吗? - 知乎 (zhihu.com)
- 如果多个线程同时拷贝同一个
shared_ptr
对象,不会有问题,因为shared_ptr
的引用计数是线程安全的。 - 如果多个线程同时修改同一个
shared_ptr
对象,不是线程安全的。 - 如果多个线程同时读写
shared_ptr
指向的内存对象,不是线程安全的。
03 指针操作还会出现哪些问题
(1)指针未初始化
(2)数组指针越界
(3)释放内存后没有把指针指向null
解决方法:
1. 定义的时候就初始化;
- 严格限定变量范围,防止数组指针越界;
- 内存释放后将指针指向空。
04 虚函数用法,动态绑定与静态绑定的区别
虚函数:
虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态覆盖(Override)
静态类型:对象在声明时采用的类型,在编译期既已确定; 动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的; 静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期; 动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
虚表是与类紧密关联的,每个类都有自己的虚表。在C++中,一个类的所有实例都共享同一份虚表,因为虚表是由该类的编译器生成并存储在静态存储区域中的,而不是在每个实例中动态生成的。因此,每个类的虚表只有一份,所有该类的实例共享同一份虚表。
05 共享指针(shared_ptr)
shared_ptr 主要的功能是,管理动态创建的对象的销毁。它的基本原理就是记录对象被引用的次数,当引用次数为 0 的时候,也就是最后一个指向某对象的共享指针析构的时候,共享指针的析构函数就把指向的内存区域释放掉。
shared_ptr 的构造要求比较高,如果对象在创建的时候没有使用共享指针存储的话,之后也不能用共享指针管理这个对象了。如果有引用循环 (reference cycle), 也就是对象 a 有指向对象 b 的共享指针,对象 b 也有指向对象 a 的共享指针,那么它们都不会被析构。
06 模板
函数模板和类模板
函数模板是一类函数的抽象,代表了一类函数,这一类函数具有相同的功能,代表一 具体的函数,能被类对象调用,而函数模板绝不能被类对象调用.
类模板是对类的抽象,代表一类类,这些类具有相同的功能,但数据成员类型及成员函数返回类型和形参类型不同
追问1 介绍模版元编程
泛型编程 -> 提供代码复用
模版元编程:
- 编译时计算:可以在编译时进行复杂的计算,减少运行时的开销。
- 代码生成:通过模板生成代码,避免手动编写重复的代码,提高代码的可维护性。
- 类型安全:模板元编程在编译时进行类型检查,确保类型安全。
07 强制类型转换,用过怎样的转换
const_cast
1、常量指针被转化成非常量的指针,并且仍然指向原来的对象;
2、常量引用被转换成非常量的引用,并且仍然指向原来的对象;
3、const_cast一般用于修改指针。如const char *p形式。
static_cast
static_cast 作用和C语言风格强制转换的效果基本一样,由于没有运行时类型检查来保证转换的安全性,所以这类型的强制转换和C语言风格的强制转换都有安全隐患。
用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换。注意:进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性需要开发者来维护。
static_cast不能转换掉原有类型的const、volatile、或者 __unaligned属性。(前两种可以使用const_cast 来去除)
在c++ primer 中说道:c++ 的任何的隐式转换都是使用 static_cast 来实现
reinterpret_cast: C风格的类型转换
reinterpret_cast是强制类型转换符用来处理无关类型转换的,通常为操作数的位模式提供较低层次的重新解释!
dynamic_cast:支持RTTI信息识别的类型转换
dynamic_cast强制转换,应该是这四种中最特殊的一个,因为他涉及到面向对象的多态性和程序运行时的状态,也与编译器的属性设置有关.所以不能完全使用C语言的强制转换替代,它也是最常有用的,最不可缺少的一种强制转换.
08 内联函数
在c/c++中,为了解决一些频繁调用的小函数大量消耗栈空间(栈内存)的问题,特别的引入了inline修饰符,表示为内联函数。
使用限制:
inline的使用时有所限制的,inline只适合函数体内部代码简单的函数使用,不能包含复杂的结构控制语句例如while、switch,并且不能内联函数本身不能是直接递归函数(即,自己内部还调用自己的函数)。
如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
09 引用和指针区别
- 内存分布上 引用不占内存空间 相当于是变量的别名 指针是占内存的
- 引用必须初始化,并且不能够被改变。 指针可以不初始化,同时指向空间可变
- 参数传递时 引用传递是直接传递变量的地址 指针传递传递的是指针的地址
- sizeof 引用得到的是 变量本身的大小 sizeof 指针 得到的是 指针的大小
追问1 左值引用和右值引用的区别
面试题:什么是右值引用?右值引用与左值引用的区别c++ 左值引用和右值引用区别面试回答clw_18的博客-CSDN博客
左值引用,就是绑定到左值的引用,通过&来获得左值引用。
那么,什么是左值呢?
左值,就是在内存有确定存储地址、有变量名,表达式结束依然存在的值。
右值引用,就是绑定到右值的引用,通过&&来获得右值引用。
那么,什么又是右值呢?
右值,就是在内存没有确定存储地址、没有变量名,表达式结束就会销毁的值。
int a=10; //非常量左值(有确定存储地址,也有变量名)
const int a1=10; //常量左值(有确定存储地址,也有变量名)
const int a2=20; //常量左值(有确定存储地址,也有变量名)
//非常量左值引用
int &b1=a; //正确,a是一个非常量左值,可以被非常量左值引用绑定
int &b4=a1+a2; //错误,(a1+a2)是一个常量右值,不可以被非常量左值引用绑定
区别
(1)左值引用绑定到有确定存储空间以及变量名的对象上,表达式结束后对象依然存在;右值引用绑定到要求转换的表达式、字面常量、返回右值的表达式等临时对象上,赋值表达式结束后就对象就会被销毁。
(2)左值引用后可以利用别名修改左值对象;右值引用绑定的值不能修改。
引入右值引用的原因
(1)替代需要销毁对象的拷贝,提高效率:某些情况下,需要拷贝一个对象然后将其销毁,如:临时类对象的拷贝就要先将旧内存的资源拷贝到新内存,然后释放旧内存,引入右值引用后,就可以让新对象直接使用旧内存并且销毁原对象,这样就减少了内存和运算资源的使用,从而提高了运行效率;
(2)移动含有不能共享资源的类对象:像IO、unique_ptr这样的类包含不能被共享的资源(如:IO缓冲、指针),因此,这些类对象不能拷贝但可以移动。这种情况,需要先调用std::move将左值强制转换为右值,再进行右值引用。
追问2 有了指针为什么还需要引用的存在
指针和引用都是C++中用于处理对象的工具,它们有各自的优点和用途:
- 指针的灵活性:指针可以被重新分配,并且可以在运行时指向不同的对象。这使得指针非常适合需要动态操作对象的情况,如动态内存分配和数据结构的实现。此外,指针可以指向空(nullptr),在某些情况下允许表示无效或未初始化的状态。
- 引用的简洁性和安全性:引用提供了一种更简洁、易于使用的方式来访问对象,并且在语法上更符合自然语言的表达方式。引用本质上是对象的别名,可以像使用对象一样使用引用,而无需使用星号(
*
)解引用。此外,引用在创建时就必须初始化,并且无法更改绑定的对象,从而确保了安全性和避免了悬空引用的问题。
通过将指针和引用结合使用,可以充分发挥它们的优势,根据具体的需求选择适当的工具。例如,在函数参数传递方面,使用引用可以避免拷贝开销或用作输出参数,而使用指针则可以处理可能为空的情况或进行动态数据结构的修改。因此,指针和引用在不同的情况下都具有独特的价值,并且二者通常并存于代码中。
引用相比于指针传递,具有以下几个优点:
- 简洁性和易读性:引用的语法更加简洁直观,不需要使用星号(
*
)解引用操作符。引用本身就是对象的别名,使得代码更加清晰易读,更接近自然语言的表达方式。
- 安全性:引用在创建时必须初始化,并且不能重新绑定到其他对象。这意味着引用不会为空,也不会产生空指针异常。它提供了更高的安全性保证,避免了潜在的空指针问题。
- 不需要手动解引用:使用引用作为函数参数时,无需手动解引用来访问对象。函数内部对引用的操作就像对原始对象一样,非常方便而且不易出错。
- 语义明确:通过使用引用传递参数,可以明确地表示函数不仅仅是读取对象的值,还可能修改对象的状态。这提供了更好的代码可读性和函数意图的清晰性。
需要注意的是,引用传递也有一些限制,例如不允许为空、无法重新绑定和一些特殊情况下的行为。因此,在使用引用传递时需要谨慎考虑,并确保存在一个有效的对象供引用。在选择使用指针还是引用传递时,可以根据具体的需求和情况来进行权衡。
追问3 引用的底层
(43条消息) 底层剖析引用实现原理(引用是占有内存空间的)c++ 引用的底层clw_18的博客-CSDN博客
常量指针 大小取决于系统 32位 4个字节 64位 8个字节
sizeof 引用对象 大小为 被引用对象的大小
10 菱形继承 虚继承
两个派生类同时继承同一个基类,然后又有某个类同时继承两个派生类
如何解决: 虚继承
将两个派生类与父类的继承关系设置为虚继承 这样两个派生类的虚基表指针 指向同一张表 叫 虚基表 虚基表中存的偏移量。可以通过偏移量找到
虚继承 =》 虚基表指针 =》 虚基表 =》 偏移量 =》 A类变量
追问1 讲一讲菱形继承的内存分布
【C++】c++单继承、多继承、菱形继承内存布局(虚函数表结构) - tomato-haha - 博客园 (cnblogs.com)
11 多态
- 静态多态 重载 模板
- 动态多态 虚函数 =》 虚函数指针 =》 虚表 =》 重写后成员函数的地址 =》 动态绑定调用
12 模板template场景题
比如说你传了一个template T,你的下面写了一个FunctionA里面传了一个p,传p的时候可能传的是它的引用,也有可能传的是它的右值引用,这在你的使用方面会有影响,你要怎么解决?
假如说在定义模板的时候,引用和右值引用,你要怎么处理他们?
13 析构顺序
构造:
先父亲 后朋友 再自己
析构:
先自己 后朋友 再父亲
可以简记为:先构造的后析构,后构造的先析构,它相当于一个栈,先进后出
14 构造函数不能是虚函数、析构函数一定要是虚函数
- 构造函数不能是虚函数
虚函数依赖虚函数指针 虚表进行重写 还没构造自然也没有虚函数指针 虚表了
虚函数指针依赖于构造函数
- 析构函数一定要是虚函数
如果父类的析构函数不设置为虚函数 那么不会触发多态 也就是说析构的时候只会调用父类的析构函数 而不会调用子类的析构函数 有可能会导致内存泄漏(如果子类析构函数存在free delete 等释放内存操作时)
如果父类的析构函数设置为虚函数 那么会发生多态 默认子类析构函数也设置为虚函数 那么在父类指针指向子类的时候 会触发动态绑定 也就是多态 析构的时候先析构子类再析构父类
15 虚函数与纯虚函数的区别在于
纯虚函数不用定义具体实现
虚函数需要定义
同时 纯虚函数也不能被实例化
16 重写、覆盖、重载、隐藏
重写(Override)是指派生类中定义了一个与基类中同名、同参数列表的虚函数,并且它的访问权限不低于基类的虚函数,此时派生类中的虚函数将重写(覆盖)基类中的虚函数。当我们通过基类指针或引用调用该虚函数时,实际调用的是派生类中重写的虚函数。
覆盖(Overwrite)和重写是同义词,常常用来表示派生类中定义的虚函数将基类中同名虚函数覆盖掉的情况。
重载(Overload)是指在同一个作用域内,定义了多个同名函数,但它们的参数列表不同(参数个数、参数类型或参数顺序不同)。通过重载,我们可以根据不同的参数类型或个数,让同名函数具有不同的行为。
隐藏(Hide)是指派生类中定义了一个与基类中同名的非虚函数(或静态成员函数),此时派生类中的函数将隐藏基类中的函数。当我们通过基类指针或引用调用该函数时,实际调用的是基类中的函数,因为它们的函数名和参数列表完全一致。需要注意的是,隐藏只会发生在非虚函数(或静态成员函数)上,而对于虚函数,派生类中的同名虚函数将自动覆盖基类中的虚函数。
17 模板元编程介绍一下
模板元编程(Template Metaprogramming,TMP)是一种利用C++模板的编程技术,用于在编译期间执行计算、生成代码、进行类型转换等一系列的元编程操作,从而实现更高效、更灵活的编程。
模板元编程的核心思想是将计算和运算过程转移到编译期间,通过模板和模板特化来实现。在模板元编程中,我们可以利用模板的特化和递归调用等特性,来实现循环、条件分支、类型推导、类型转换等一系列的编程操作,从而生成更加高效的代码。
模板元编程的应用场景非常广泛,例如实现各种算法、元函数库、类型推导、编译期计算、编译期优化等。通过模板元编程,我们可以在编译期间进行大量的计算和运算,从而减少运行时的开销,提高程序的性能和效率。
需要注意的是,模板元编程技术在使用时需要谨慎,因为其代码可读性和可维护性较差,容易出现难以调试的问题。因此,在实际编程中,应该根据具体的场景和需求,选择合适的技术和方法来实现。
18 C++继承和组合的区别
继承 提高代码的复用性 a kind of
组合 a part of
19 一个空类,默认会产生哪些函数
- 构造
- 析构
- 拷贝构造
- 赋值操作符重载
- 取地址操作符重载以及const取地址操作符重载
追问1 为什么空类占一个字节
空类在C++中被视为具有至少一个字节的大小。这是为了确保每个对象都具有独特的地址,以便它们可以在内存中正确地分配和访问。
当创建一个空类时,编译器会为该类分配一个字节的内存空间。这是因为在某些情况下,例如在数组中使用空类对象时,每个对象需要占用一个地址。如果没有为空类分配至少一个字节的空间,那么在多个空类对象组成的数组中,由于它们没有¥¥的地址,无法引用或访问每个对象。
此外,一个字节的空间还可以用于其他目的,如辅助实现C++的类型安全性或对齐要求等方面。
20 类的大小
空类的大小为1
因为空类也进行了实例化,每个实例在内存中都有一个独一无二的地址,为了达到这个目的,编译器一般会给一个空类隐含的加一个字节,这样就可以达到空类在实例化后得到了独一无二的地址的目的。
- 类的大小是非静态成员数据的类型大小之和;
- 为了优化存取效率,进行字节对齐;
- 由编译器额外加入的成员变量的大小,用来支持语言的某些特性;
- 与类中的构造函数、析构函数、拷贝构造函数、赋值运算符重载、取地址运算、const取地址运算无关。
21 模板可以嵌套模板么
可以
template<typename T, typename act = vector<T>>
22 template < int N> 可以么
模板偏特化
模板偏特化的主要目的是在某些特定的参数类型下,提供不同于通用模板的实现。例如,通用模板可能无法满足某些特定类型的需求,这时就需要针对这些特殊类型进行偏特化处理。
在C++中,模板参数不仅可以是类型,还可以是非类型参数,例如整数。你可以使用template<int N>
来定义一个模板,其中N
是一个整数参数。这种非类型模板参数在编译时必须是一个常量表达式
23 虚表被类所拥有还是类的实例所拥有
虚函数指针被每个类的对象所拥有
虚表被类所拥有 每个类在编译时都会生成一个虚函数表
24 为什么模板声明和定义都要放在头文件中
C++中的模板通常需要在头文件中进行声明和定义。这是因为,模板类或函数在编译时需要进行实例化(即根据具体类型创建出对应的类或函数),而如果只将模板的声明放在头文件中,那么编译器在编译调用模板的代码时就无法得知模板的具体实现,从而无法进行实例化。
因此,在使用模板时,我们通常需要将模板的所有声明和定义都放在头文件中,以便在编译时能够正确地实例化该模板。例如,如果定义了一个包含模板函数的类,那么通常会将该类的声明和定义都放在同一个头文件中,如下所示:
// header.h
#ifndef HEADER_H
#define HEADER_H
template <typename T>
class MyClass
{
public:
void myFunc(T value);
};
template <typename T>
void MyClass<T>::myFunc(T value)
{
// function implementation
}
#endif
在上述代码中,MyClass类中包含一个模板函数myFunc,该函数的声明和定义均放在头文件中。这样,当其他源文件需要使用该模板时,只需要包含该头文件即可。
需要注意的是,有些编译器可能会对模板外部定义的参数类型进行特殊处理,从而允许将模板的声明和定义分别放置在不同的源文件中。但是这种行为并不被C++标准所支持,且不同编译器的实现方式可能会有所不同,因此为了保证代码的可移植性和兼容性,通常还是将模板的声明和定义都放在头文件中。
C++中模板声明和定义分开会有什么问题
如果在C++中将模板的声明和定义分开,那么编译时就无法进行模板的实例化,导致链接时出现多种错误。
这是因为,模板的声明只是告诉编译器该类或函数在某个作用域中存在而已,并不会将其实例化。而模板的定义则包含了实际的代码实现,用于根据实际类型创建出对应的类或函数。如果只提供了声明而没有提供定义,编译器就无法知道如何具体实现该模板,从而无法将模板实例化为对应的类或函数。
在编译时,当一个源文件调用了该模板的成员函数或创建了该模板的对象时,编译器会查找该模板的定义。如果无法找到定义,就会报错,例如:
error LNK2019: unresolved external symbol "void MyClass<int>::myFunc(int)" (??$myFunc@H@MyClass@@QEAAXH@Z) referenced in function _main
这种错误指示编译器找不到针对特定类型(例如int)的模板实例,导致链接失败。因此,为了避免这种问题,建议将模板的声明和定义统一放在同一个头文件中,并在需要使用该模板的源文件中包含该头文件。
25 函数模板和类模板的区别 函数模板内的参数什么阶段会被替换掉
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。
- 实例化的时机不一样 函数模板在被调用的时候实例化,类模板是直接定义对象的时候 就会实例化
26 默认的拷贝构造和自己写的拷贝构造有什么区别
浅拷贝和深拷贝的区别
27 C++构造函数、拷贝构造、移动构造的区别
默认构造函数的作用是,给类中的变量分配内存空间。
析构函数的作用是 当对象被销毁时 自动调用析构函数 进行资源的释放
默认拷贝构造 传入了一个类对象的引用的参数 进行拷贝构造 主要做了类成员内存空间的分配以及简单的值拷贝 (自定义的拷贝构造是 深拷贝)
移动构造 说白了就是通过移动语义(也就是把临时对象的某一块内存资源移为己用)而不是深拷贝的方式初始化类对象
28 this指针是什么,有什么用,struct里面有this指针吗,this指针存储在哪
this指针是一个指向当前对象的指针,它是C++中的一个关键字。在成员函数内部使用this指针可以访问当前对象的成员变量和成员函数。
this指针主要用于解决成员变量和参数变量同名的情况下,访问成员变量的二义性问题。通过this指针,我们可以明确指定访问成员变量而不是参数变量。
在struct中定义的成员函数也可以使用this指针,因为struct本质上也是一种类,只是默认的访问权限是public。使用this指针可以访问struct中成员变量和成员函数。
this指针存储在当前对象所在的栈空间内,它指向当前对象的首地址。在调用成员函数时,编译器会默认将当前对象的地址作为隐含参数传递给成员函数,成员函数内部使用this指针接收这个地址,并通过this指针来访问对象的成员。
总之,this指针在C中起着非常重要的作用,它解决了成员变量和参数变量同名、访问成员变量的二义性问题,是C面向对象编程中的一个重要概念。
29 面向对象和面向过程的区别 CPP、C、Python的区别
面向过程是一种以过程为中心的编程思想,强调解决问题的步骤和流程。在这种思想下,程序员需要分析出解决问题所需的步骤,然后用函数将这些步骤依次实现。面向过程的优点是效率高,适合处理简单的任务,但缺点是代码重用性低,扩展性差,维护难度大。
面向对象则是一种以对象为中心的编程思想,强调对象之间的交互。程序员将问题分解为各个对象,然后分析每个对象在解决问题中的行为。面向对象的优点是结构清晰,代码重用率高,易于维护和扩展,但缺点是开销较大,性能相对较低。
关于C++、C和Python的区别:
C是一种低级编程语言,具有高度的灵活性和底层访问能力,适用于系统级编程和高性能计算。C++是C语言的扩展,增加了面向对象编程的特性,使代码更加结构化,提高了可读性和可维护性。
Python是一种高级编程语言,以其简洁易懂的语法和广泛的应用领域而闻名。Python是一种解释型语言,适合初学者和快速原型设计。与C和C++相比,Python的性能较低,但其高可读性高和有丰富的库支持。
30 讲一讲自己对继承、封装、多态的理解
继承、封装和多态是面向对象编程的三大基本特性,用于构建更加灵活、可扩展、易于维护的软件系统。我对这三个概念的理解如下:
- 继承(Inheritance):继承是指一种类可以从另外一个类中获取属性和方法,并且可以对这些属性和方法进行重用和修改的机制。通过继承,我们可以将代码复用和扩展性结合起来,减少冗余的代码,并且使得代码的维护更加方便和高效。在C++中,继承关系可以分为单继承、多继承和虚继承等。
- 封装(Encapsulation):封装是指将数据和行为打包到一个类中,并限制外部程序对数据的访问方式。这样做可以增强代码的保密性以及稳定性,同时也方便系统的调试和维护。在C++中,我们常常使用==访问控制符(public、private、protected)==来实现类的封装。
- 多态(Polymorphism):多态是指同一个函数或方法可以被不同的对象调用,并且有着不同表现形式的特性。它可以使程序更具灵活性和可扩展性,而且降低了代码的耦合度和复杂性。在C++中,多态可以通过虚函数和重载来实现。虚函数允许我们在派生类中重新定义基类中的函数,并且可以使用基类指针或者引用来调用派生类中的函数;而重载则可以使得同一个函数名可以有多种不同的参数类型和个数。
总之,继承、封装和多态是面向对象编程的基本特性,这些特性使得程序更加灵活、可扩展、易于维护。理解这些概念对于高效编写面向对象的代码非常重要。
31 类继承中父类和子类的内存布局
在类继承中,父类和子类的内存布局是由编译器决定的,具体取决于继承关系的类型(单继承、多继承、虚继承等)。
对于单一继承情况,子类的内存布局通常包含两个部分:子类自己定义的数据成员和从父类继承而来的数据成员。在内存中,这些成员按照它们在类定义中声明的顺序排列,其中先是父类的成员,后是子类自己的成员。例如:
class Base {
public:
int base_member;
};
class Derived : public Base {
public:
int derived_member;
};
对于上述代码中,Derived类的内存布局为:base_member (4 bytes) + derived_member (4 bytes),其中base_member位于内存地址较低处,derived_member位于内存地址较高处。在访问阶段,可以通过指针或者引用访问父类和子类中的成员。
对于多重继承情况,由于一个子类可能同时继承自多个父类,因此内存布局会更加复杂。在多重继承中,每个父类都会有自己的一块内存区域,并且子类也有自己的内存区域。这些区域按照派生类中父类的出现顺序从前往后排列,并且每个父类区域的大小取决于该父类的数据成员和虚函数表的大小。例如:
class Base1 {
public:
int base1_member;
virtual void base1_func() {}
};
class Base2 {
public:
int base2_member;
virtual void base2_func() {}
};
class Derived : public Base1, public Base2 {
public:
int derived_member;
virtual void derived_func() {}
};
在上述代码中,Derived类的内存布局为:Base1区域 (8 bytes) + Base2区域 (8 bytes) + derived_member (4 bytes) + 虚函数表指针 (4 bytes),其中Base1区域和Base2区域位于内存地址较低处,derived_member位于内存地址较高处。
总之,类继承中父类和子类的内存布局是由编译器自动处理的,按照继承关系类型和类定义中声明顺序来排列内存区域。对于访问父类和子类成员的操作,通常可以通过指针或者引用来实现。
32 介绍一下函数指针
函数指针是指向函数的指针,它可以让我们在程序运行时动态的调用不同的函数。在C/C++中,函数指针的类型与所指向的函数的返回值类型和形参列表相关联。
函数指针的定义方式与指向变量的指针类似,以*号表示指针类型。例如,下面是一个指向函数的指针类型的定义:
int (*pFunc)(int, int);
上述定义中,pFunc是一个指向返回类型为int,参数列表为(int, int)的函数指针。可以通过分配函数地址来将其指向某个具体的函数,例如:
int add(int a, int b) {
return a + b;
}
int main() {
int (*pFunc)(int, int);
pFunc = &add;
int result = pFunc(1, 2); // 调用add函数
return 0;
}
上述代码中,我们首先定义了一个指向函数的指针pFunc,然后将其指向add函数。最后,通过调用pFunc来调用add函数,并获取结果。
除了上述方式外,也可以使用typedef来定义函数指针类型。例如:
typedef int (*pFunc)(int, int);
pFunc pAdd = &add;
这样做可以使代码更加易读易理解。
函数指针在实际应用中非常广泛,比如回调机制、函数指针数组、函数指针作为函数参数传递等。掌握函数指针的使用可以提高程序的灵活性和功能性。
33 工程问题抛出异常后如何处理
windows:
- 看log
- VS打断点 debug 访问空指针/下标越界/栈溢出
Linux:
- gdb
- 系统性能问题(pstack、strace分析)
34 为什么拷贝构造是传引用而不是传值
C++中拷贝构造函数的参数为何一定要用引用类型_为什么复制构造函数的形参必须是类的引用类型-CSDN博客
拷贝构造函数(Copy Constructor)通常以传引用的方式来定义,这是因为拷贝构造函数需要创建一个新对象并将其初始化为已有对象的副本。如果不使用引用传递,在函数调用时会发生对象的拷贝操作,导致递归无限循环,最终导致栈溢出。
具体来说,如果一个类的拷贝构造函数形参使用传值(pass by value)的方式来定义,那么当该函数被调用时,会创建一个新的对象,并将原对象作为实参传递给拷贝构造函数。在函数内部,该实参会被复制一份作为形参,从而产生了递归调用,导致堆栈空间不断增加,最终导致栈溢出。
为了避免这种问题,通常使用引用传递的方式来定义拷贝构造函数。通过引用传递,函数形参仅包含指向原对象的地址,而不是对整个对象的拷贝,从而避免了额外的复制操作,同时确保函数被正确调用,从而正确地生成新对象的副本。例如:
class MyClass {
public:
MyClass(const MyClass& other) // copy constructor
{
// Copy data members from other to this object
}
};
在上述代码中,拷贝构造函数接受一个MyClass类型的常引用作为形参,用于初始化该类的新对象。由于使用了引用传递,避免了不必要的拷贝操作,同时确保了正确的生成新对象的副本,从而保证了函数的正常执行。
因此,拷贝构造函数通常需要使用引用传递的方式来定义,以避免出现栈溢出的风险,并确保函数能正确地复制生成新对象的副本。
35 引用传递、指针传递、值传递的区别
引用传递、指针传递和值传递是C++中常用的函数参数传递方式,它们之间的区别如下:
- 值传递:函数调用时将实际参数的值复制一份传递给形式参数,函数对形参进行操作不会影响实参。也就是说,在函数内部修改形参的值不会影响到函数外的实参。
- 指针传递:函数调用时将实际参数的地址传递给形式参数,函数对形参进行操作会影响到实参。如果想要在函数内部修改实参的值,可以通过在函数中操作指针指向的变量来实现。
- 引用传递:函数调用时将实际参数的引用传递给形式参数,函数对形参进行操作会影响到实参。引用传递的实现机制与指针传递相似,但是使用起来更加简洁,可以避免指针操作带来的麻烦。
总体而言,值传递速度较快,但是不会改变实参的值。指针传递速度稍慢,但可以改变实参的值。引用传递速度与指针传递类似,但使用起来更加方便和直观,且可以避免指针空悬的问题。
在实际使用中,需要根据具体情况选择最适合的传递方式,以达到更好的效果。例如,如果想要在函数内部修改实参的值,可以选择指针传递或引用传递;如果不关心是否改变实参的值,可以选择值传递。
引用传递和指针传递都是传递地址的方式,但是它们传递的地址不同:
- 引用传递:传递的是实参的别名或引用,也可以理解为形参是实参的一个别名,它们在内存中占用同一块空间,因此对形参的修改就是对实参的修改。实现上,引用传递使用的是编译器底层的机制,实际上并没有将引用作为函数参数传递。
- 指针传递:传递的是实参的地址,也就是将实参的地址赋值给了形参指针,通过指针间接访问实参的值,实现对实参进行读写操作。
36 move和forward
C++中的move和forward是用于实现完美转发(perfect forwarding)的两个工具函数。
- move:将一个对象强制转换为右值引用,从而可以使用移动语义来进行赋值或构造。它通常用于避免不必要的拷贝操作,提高程序的效率。例如:
std::vector<int> a = {1, 2, 3};
std::vector<int> b = std::move(a); // 将a移动到b中
// move 底层实现 类型提取 + 类型强转为右值引用
static_cast<typename remove_reference<T>::type&&>
- forward:在函数模板中将参数以原始类型进行传递,从而实现完美转发。它通常用于将原始类型的参数在调用时转发给其他函数,具有保持原参与转发参数类型相同的特点。例如:
template<typename T>
void func(T&& t) {
other_func(std::forward<T>(t));
}
在上述代码中,对于形参t采用了右值引用,通过使用std::forward将t以原始类型进行转发,保证在转发过程中参数类型的不变性。
这个模板函数接受一个参数并返回一个右值引用,同时利用引用折叠保留参数的左值或右值属性。调用std::forward时,根据参数的左值或右值属性,编译器会选择适当的模板实例进行转发。如果参数是一个左值引用,std::forward将返回一个左值引用。如果参数是一个右值引用,std::forward将返回一个右值引用。
例如:
1、如果T为std::string&,那么std::forward(t) 返回值为std::string&& &,折叠为std::string&,左值引用特性不变。
2、如果T为std::string&&,那么std::forward(t) 返回值为std::string&& &&,折叠为std::string&&,右值引用特性不变。
总的来说,move和forward都是C++11之后新增的工具函数,它们的出现主要是为了优化程序性能,并提供更加灵活、方便的编程方式。
37 仿函数
让对象以类似函数的语法执行一个功能,也就是仿函数。
greater<int>
的实现可以简化为:
template<typename T>
struct greater {
constexpr bool operator()(const T &left, const T &right) const {
return left > right;
}
};
换句话说,任何
greater
的对象都有一个运算符
()
,它能让对象以类似函数的语法执行一个功能,也就是仿函数。
greater<int> a; // 创建实例
bool result = a(1, 2); // 调用重载的 () 方法
example:
#include <iostream>
#include <functional> // 包含 std::function
// 普通函数
int add(int a, int b) {
return a + b;
}
// 仿函数
struct Multiply {
int operator()(int a, int b) const {
return a * b;
}
};
int main() {
// 封装普通函数
std::function<int(int, int)> func = add;
std::cout << "Add: " << func(2, 3) << std::endl; // 输出 5
// 封装 Lambda 表达式
func = [] (int a, int b) { return a - b; };
std::cout << "Subtract: " << func(5, 2) << std::endl; // 输出 3
// 封装仿函数
func = Multiply();
std::cout << "Multiply: " << func(3, 4) << std::endl; // 输出 12
return 0;
}
38 weak_ptr底层实现原理
std::weak_ptr
提供了一种方式来持有对对象的弱引用,不会增加对象的引用计数。当与 std::shared_ptr
一起使用时,你可以通过 std::weak_ptr
安全地访问对象,而不会导致循环引用。当 std::shared_ptr
指向的对象被删除时,所有相关的 std::weak_ptr
对象都会变得空无,你可以通过调用 expired()
函数来检测这一点。
example:
#include <iostream>
#include <memory>
class B; // 前向声明
class A {
public:
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A deleted\n"; }
};
class B {
public:
std::weak_ptr<A> a_ptr; // 使用 std::weak_ptr 而不是 std::shared_ptr
~B() { std::cout << "B deleted\n"; }
};
int main() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
return 0;
}
在这个例子中,即使 A
和 B
互相引用,它们也会被正确地删除,因为 B
中的 a_ptr
是一个 std::weak_ptr
,不会增加 A
的引用计数。
在使用 std::shared_ptr
时,如果你预见到两个或多个对象之间可能会形成循环引用,最好是从一开始就使用 std::weak_ptr
来防止这个问题的发生。
使用 std::weak_ptr
的情况:
- 解决循环引用问题:std::weak_ptr最常见的使用场景是解决循环引用问题,如上面的C++示例,在一个图或树的数据结构中,节点可能互相拥有指向彼此的std::shared_ptr。这种情况下,即使外部没有引用这些节点,它们也不会被销毁,因为它们相互持有的引用计数永远不会降到零。使用std::weak_ptr可以打破这种循环,允许对象被正确地销毁。
- 缓存机制实现:当对象由一个中心管理并可能被多个客户端访问,但不需要由客户端维持其生命周期时,
std::weak_ptr
可以用来实现缓存机制。客户端可以尝试通过std::weak_ptr
获取std::shared_ptr
来访问对象,如果对象已经不存在,则可以重新创建。 - 观察者模式:在观察者模式中,主题(Subject)持有对观察者(Observer)的引用,而观察者也需要访问主题。为了避免循环引用,主题可以持有对观察者的
std::weak_ptr
引用。这样,主题不会阻止观察者的销毁,同时观察者可以在需要时获取对主题的访问权。 - 跟踪资源的生命周期:在某些情况下,我们可能想知道一个资源是否仍然存在,但又不想拥有对它的所有权。
std::weak_ptr
提供了一种方法来监控资源的生命周期,而不会阻止其被销毁。 - 线程安全的资源共享:当多个线程需要访问同一个资源,但是该资源可能在任何时间点被某个线程销毁时,
std::weak_ptr
可以用来安全地尝试访问资源。线程可以尝试将std::weak_ptr
转换为std::shared_ptr
,如果转换成功,则资源仍然存在。 - 当你想要观察或访问一个对象,但是不想拥有它(即你不想你的访问会阻止这个对象被删除)时,也可以使用
std::weak_ptr
。
39 循环引用的时候,使用weak_ptr和裸指针有什么区别
循环引用问题通常发生在使用 std::shared_ptr
的对象之间形成了相互依赖的情况。这种相互依赖导致引用计数永远不会达到零,因此涉及的对象永远不会被销毁。std::weak_ptr
能够帮助打破这种循环,从而使得对象可以被适时地销毁。
40 基类和派生类的虚函数表是共享的么
基类和派生类的虚函数表并不是共享的。每个使用虚函数的类都有一个虚函数表(vtable),该表是一个函数指针数组,存储了指向类的虚函数的指针。类的每个实例都包含一个指向其虚函数表的指针(vptr),通过这个指针可以找到并调用正确的虚函数实现
41 静态绑定和动态绑定区别
【C++】 理解 多态 之 静态绑定与动态绑定-CSDN博客
- 静态类型 和 动态类型
- 虚函数 动态绑定(运行时期)
- 缺省参数 静态绑定(编译时期)
42 String字符串和vector< char > 区别
String 和 vector<char>
都是 C++ 中用于处理字符序列的数据结构,但它们有一些区别:
- 语法和用法不同: String 是 C++ 标准库提供的类,而
vector<char>
是标准库中的容器。String 使用更简洁,支持字符串操作的成员函数,如连接、查找、子串等。vector<char>
则是通用的数组容器,提供了更灵活的元素存储和遍历方式。
- 可变性: String 对象是不可变的(immutable),即一旦创建就不能直接修改其内容。对 String 对象进行拼接、删除或修改操作时,实际上会创建一个新的 String 对象。相比之下,
vector<char>
是可变的,可以通过索引直接访问和修改其中的元素。
- 存储方式: String 使用动态分配的字符数组来存储字符串,因此它的长度可以根据需要自动调整。而
vector<char>
使用动态数组来存储字符序列,以便能够按需增加或减少元素。
- 操作的复杂性: 由于 String 定位于字符串操作上,它具有许多方便的成员函数,例如
substr()
、find()
、replace()
等,用于简化字符串处理。相比之下,vector<char>
的接口更为通用,需要通过迭代器或索引来进行访问和修改。
- 内存开销: String 对象的内存开销会略高于
vector<char>
,因为 String 需要维护字符串的元数据(如长度、容量等)。另外,String 在进行拼接等操作时可能会频繁地动态分配和释放内存,导致一定的运行时开销。
43 继承、封装、多态的优点和缺点
优点
继承的优点:
- 代码重用:通过继承可以从已存在的类派生出新的类,并且可以访问父类的属性和方法,避免了重复编写相似功能的代码。
- 可扩展性:继承具有层次结构,可以在已存在的类基础上添加新的特性和行为,使得代码更加灵活和可扩展。
- 维护性和可理解性:通过继承,能够构建清晰的类层次结构,提高代码的可读性和可维护性,使得代码更易于理解和修改。
封装的优点:
- 数据隐藏和安全性:通过封装,将数据和相关操作封装在类内部,隐藏了实现细节,防止外部直接访问和修改数据,提高了数据的安全性。
- 代码复用和模块化:封装将复杂的实现细节封装成简洁的对外接口,提供了模块化的设计,使得代码更易于复用和组织。
- 简化调用和纠错:封装提供了统一的接口来执行特定的操作,简化了对外使用代码的调用方式,同时也降低了错误发生的可能性。
多态的优点:
- 灵活性和可替代性:多态允许通过父类的引用或指针调用子类的方法,根据具体情况选择不同的实现版本,使得代码更具灵活性和可替代性。
- 扩展性和可维护性:通过添加新的子类,可以扩展现有的功能而无需修改已存在的代码,降低了对现有代码的影响,提高了代码的可维护性。
- 接口统一性和可读性:多态通过共享父类的公共接口规范,使得不同的子类对象能够以统一的方式被处理,提高了代码的可读性和可理解性。
缺点
继承的缺点:
- 紧耦合:子类与父类之间存在紧密的耦合关系,一旦父类发生变化,可能会影响到子类的正确性。
- 层次复杂性:过度使用继承可能导致层次复杂的类结构,增加了代码的理解和维护难度。
- 增加依赖关系:子类依赖于父类的实现细节,限制了子类的¥¥性和灵活性。
封装的缺点:
- 访问受限:封装可能会导致某些类成员无法被外部直接访问,需要通过公共接口进行间接访问,增加了访问的间接成本。
- 性能损失:封装有时会引入额外的函数调用开销,可能会在性能敏感的场景下带来一定的性能损失。
- 难以预测:封装将代码进行了隔离和隐藏,使得其他开发人员难以准确预测封装对象的内部实现和具体行为。
多态的缺点:
- 运行时开销:多态实现涉及虚函数表和动态绑定等机制,可能会带来一定的运行时开销。
- 理解和调试复杂性:多态引入了代码中不同的运行状态,增加了代码的理解和调试难度。
- 潜在的误用风险:过度使用多态可能导致代码难以维护和理解,在设计上需要权衡好可拓展性和复杂性之间的平衡。
44 异常常见的用法与种类
try catch + throw
异常种类:
std::exception
:所有标准 C++ 异常的父类。std::bad_alloc
:通过new
抛出。std::bad_cast
:通过dynamic_cast
抛出。std::bad_typeid
:通过typeid
抛出。std::bad_exception
:处理 C++ 程序中无法预期的异常。std::logic_error
:理论上可以通过读取代码来检测到的异常。std::domain_error
:使用了无效的数学域时抛出。std::invalid_argument
:使用了无效的参数时抛出。std::length_error
:创建了太长的std::string
时抛出。std::out_of_range
:通过方法抛出,例如std::vector
和std::bitset<>::operator [] ()
。std::runtime_error
:理论上不可以通过读取代码来检测到的异常。std::overflow_error
:发生数学上溢时抛出。std::range_error
:尝试存储超出范围的值时抛出。std::underflow_error
:发生数学下溢时抛出。
45 emplace_back的底层实现
主要利用了可变参数模板和完美转发来实现高效的对象构造。以下是 emplace_back
的大致实现原理:
emplace_back
函数接受可变数量的参数,这些参数将用于构造新元素。- 接着,
emplace_back
函数将参数通过完美转发传递给容器内部的emplace_back
实现。 emplace_back
实现内部会先调用vector
的allocator
分配器分配内存空间来存储新元素。- 最后,
emplace_back
使用完美转发的参数在分配的内存空间中直接构造新元素。
效果:避免了不必要的复制或移动操作,从而提高了性能。
46 C语言实现继承
- 结构体嵌套
- 函数指针
#include <stdio.h>
// 基类 - 动物
typedef struct {
void (*move)();
} Animal;
void animalMove() {
printf("Animal moves.\n");
}
// 派生类 - 狗
typedef struct {
Animal base; // 继承基类
void (*bark)();
} Dog;
void dogMove() {
printf("Dog moves.\n");
}
void dogBark() {
printf("Dog barks.\n");
}
int main() {
// 创建动物对象
Animal animal;
animal.move = &animalMove;
// 创建狗对象
Dog dog;
dog.base.move = &dogMove;
dog.bark = &dogBark;
// 调用基类和派生类的方法
animal.move();
dog.base.move();
dog.bark();
return 0;
}
三、STL模板库
01 deque的底层原理
deque:双端队列容器
底层数据结构:动态开辟的二维数组
扩容方式:一维数组从2开始,以2倍的方式进行扩容,每次扩容后,原来第二维的数组,从新的第一维数组的下标oldsize/2开始存放,上下都预留相同的空行,方便支持deque的首尾元素添加
eque 没有所谓的容量,因为它是动态动态以分段连续空间组合而成,随时可以增加一段新的空间并链接起来。一旦有必要在 deque 的前端或尾端增加新空间,便配置一段定量连续空间,串接在整个 deque 的头端或者尾端。虽然这样避开了“重新配置、复制、释放”的操作,但是却增加了迭代器架构的复杂性。
因为既需要分段,又需要连续,则需要一个中央控制,而为了维持整体的连续假象,数据结构的设计及迭代器前进后退等操作都非常繁琐。deque 的代码分量远比 vector 或 list 要多的多。
deque 采用一块所谓的 map(不是 STL 里的 map)作为中央控制,这里的 map 是一小块连续的空间,其中每个节点都是指针,指向另一块连续性空间,称为缓冲区。缓冲区才是 deque 的储存空间主体。
deque deq;
增加:
deq.push_back(20); 从末尾添加元素 O(1)
deq.push_front(20); 从首部添加元素 O(1) // vec.insert(vec.begin(), 20) O(n)
deq.insert(it, 20); it指向的位置添加元素 O(n)
删除:
deq.pop_back(); 从末尾删除元素 O(1)
deq.pop_front(); 从首部删除元素 O(1)
deq.erase(it); 从it指向的位置删除元素 O(n)
查询搜索:
iterator(连续的insert和erase一定要考虑迭代器失效的问题)
02 array/vector/list/map/deque的区别和应用场景
- 底层数据结构
- 增删查改 随机访问
- 内存扩容方式
vector:底层数据结构为数组 ,支持快速随机访问。
list:底层数据结构为双向链表,支持快速增删。
deque:底层数据结构为一个中央控制器和多个缓冲区,支持首尾(中间不能)快速增删,也支持随机访问。
stack:底层一般用deque/list实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时。
queue:底层一般用deque/list实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时。
priority_queue:的底层数据结构一般为vector为底层容器,堆heap为处理规则来管理底层容器实现。
set:底层数据结构为红黑树,有序,不重复。 multiset:底层数据结构为红黑树,有序,可重复。
map:底层数据结构为红黑树,有序,不重复。 multimap:底层数据结构为红黑树,有序,可重复。
unordered_set:底层数据结构为hash表,无序,不重复。
unordered_multiset:底层数据结构为hash表,无序,可重复 。
unordered_map:底层数据结构为hash表,无序,不重复。 unordered_multimap:底层数据结构为hash表,无序,可重复。
03 链表和数组的区别
数组:增加删除O(n) 查找元素 O(n) 随机访问O(1)
链表:(考虑搜索的时间)增加删除O(1) 查询O(n) 随机访问O(n)
1.底层数据结构:数组 双向循环链表
04 C++标准库了解么,用的多么
05 介绍STL容器
06 map相关操作及其底层红黑树
插入删除效率高:
对于关联容器来说,不需要做内存拷贝和内存移动。map和set容器内所有元素都是以节点的方式来存储,其节点结构和链表差不多,指向父节点和子节点
(底层数据结构)红黑树性质:
1.每个节点都是红色或黑色
2.根节点为黑色
3.叶节点为黑色的NULL结点。
4.如果结点为红,其子节点必须为黑
5.任一节点到NULL的任何路径,所含黑结点数必须相同
相关操作:
1.构造(默认构造 拷贝构造)
赋值
2.插入
删除
清空
3.查找 find(key)
统计 count(key)
(1) 他们的底层都是以红黑树的结构实现,因此插入删除等操作都在O(logn)时间内完成,因此可以完成高效的插入删除;
(2) 在这里我们定义了一个模版参数,如果它是key那么它就是set,如果它是key+value,那么它就是map;底层是红黑树,实现map的红黑树的节点数据类型是key+value,而实现set的节点数据类型是value
(3) 因为map和set要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低。
07 lambda表达式咋用,在哪用,有什么好处(类似于inline)
(1) 利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
(2) 每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,其实一个右值。所以,我们上面的lambda表达式的结果就是一个个闭包。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。
(3) lambda表达式的语法定义如下:
[capture] (parameters) mutable ->return-type {statement};
(4) lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体;
语法:
(1) [函数对象参数]
标识一个 Lambda 表达式的开始,这部分必须存在,不能省略。函数对象参数是传递给编译器自动生成的函数对象类的构造函数的。函数对象参数只能使用那些到定义 Lambda 为止时 Lambda 所在作用范围内可见的局部变量(包括 Lambda 所在类的 this)。函数对象参数有以下形式:
函数对象参数有以下形式:
空。没有任何函数对象参数。
=。函数体内可以使用 Lambda 所在范围内所有可见的局部变量(包括 Lambda 所在类的 this),并且是值传递方式(相当于编译器自动为我们按值传递了所有局部变量)。
&。函数体内可以使用 Lambda 所在范围内所有可见的局部变量(包括 Lambda 所在类的 this),并且是引用传递方式(相当于是编译器自动为我们按引用传递了所有局部变量)。
this。函数体内可以使用 Lambda 所在类中的成员变量。
a。将 a 按值进行传递。按值进行传递时,函数体内不能修改传递进来的 a 的拷贝,因为默认情况下函数是 const 的,要修改传递进来的拷贝,可以添加 mutable 修饰符。
&a。将 a 按引用进行传递。
a,&b。将 a 按值传递,b 按引用进行传递。
=,&a,&b。除 a 和 b 按引用进行传递外,其他参数都按值进行传递。
&,a,b。除 a 和 b 按值进行传递外,其他参数都按引用进行传递。
(2) (操作符重载函数参数)
标识重载的 () 操作符的参数,没有参数时,这部分可以省略。参数可以通过按值(如: (a, b))和按引用 (如: (&a, &b)) 两种方式进行传递。
(3)mutable 或 exception 声明
这部分可以省略。按值传递函数对象参数时,加上 mutable 修饰符后,可以修改传递进来的拷贝(注意是能修改拷贝,而不是值本身)。exception 声明用于指定函数抛出的异常,如抛出整数类型的异常,可以使用throw(int)。
(4)返回值类型
标识函数返回值的类型,当返回值为 void,或者函数体中只有一处 return 的地方(此时编译器可以自动推断出返回值类型)时,这部分可以省略。
(5) {函数体}
底层实现原理
C++进阶(八) :Lambda 表达式及底层实现原理【详解】_lambda表达式底层c_Chiang木的博客-CSDN博客
其中,类名 lambda_xxxx 的 xxxx 是为了防止命名冲突加上的。
lambda 表达式中的捕获列表,对应 lambda_xxxx 类的 private 成员
lambda 表达式中的形参列表,对应 lambda_xxxx 类成员函数 operator() 的形参列表
lambda 表达式中的 mutable,表明 lambda_xxxx 类成员函数 operator() 的是否具有常属性 const,即是否是 常成员函数
lambda 表达式中的返回类型,对应 lambda_xxxx 类成员函数 operator() 的返回类型
lambda 表达式中的函数体,对应 lambda_xxxx 类成员函数 operator() 的函数体
另外,lambda 表达 捕获列表的捕获方式,也影响 对应 lambda_xxxx 类的 private 成员 的类型
值捕获:private 成员的类型与捕获变量的类型一致
引用捕获:private 成员 的类型是捕获变量的引用类型
08 emplace_back和push_back区别
push_back()方法要调用构造函数和复制构造函数,这也就代表着要先构造一个临时对象,然后把临时的copy构造函数拷贝或者移动到容器最后面。
而emplace_back()在实现时,则是直接在容器的尾部创建这个元素,省去了拷贝或移动元素的过程。
emplace_back() 函数在原理上⽐ push_back() 有了⼀定的改进,包括在内存优化⽅⾯和运⾏效率⽅⾯。内存优化主要体现在使⽤了就地构造(直接在容器内构造对象,不⽤拷⻉⼀个复制品再使⽤) + 强制类型转换 的⽅法来 实现,在运⾏效率⽅⾯,由于省去了拷⻉构造过程
结论:在C++11情况下,果断用emplace_back代替push_back
09 C++11五个特性
(1)关键字
(2)智能指针
(3)多线程
(4)函数对象、绑定器、lamda表达式
(5)容器
set和map:红黑树 O(lgn)
unordered_set和unordered_map:哈希表 O(1) 提高增删查的效率
array:数组 vector
forward_list:前向链表 list
10 迭代器、如何实现迭代器++
// 定义一个迭代器类
class Iterator {
public:
// 自增运算符重载 - 成员函数
Iterator& operator++() {
// 将指针指向容器中的下一个元素
++ptr;
return *this;
}
private:
int* ptr; // 指向容器中元素的指针
};
11 排序算法(时间复杂度、稳定性)
快排、堆排、归并排序、冒泡排序的底层实现原理以及稳定性
三种排序算法的底层实现原理以及稳定性如下:
- 快速排序(QuickSort)是一种基于分治思想的高效排序算法,其核心思想是通过选取一个基准元素(pivot),将数组划分为两个子序列,左侧子序列中所有元素均小于等于基准元素,右侧子序列中所有元素均大于等于基准元素。然后对左右子序列分别进行递归处理,最终将整个数组排序。快速排序的平均时间复杂度为O(nlogn),但是最坏情况下时间复杂度为O(n^2)。
快排是不稳定的算法,因为在排序过程中,对于相同的元素可能会进行交换,导致相对位置发生变化。
2.堆排序(HeapSort)是一种使用堆的数据结构来实现的排序算法。堆排序的核心思想是先将待排序的数组构建成一个二叉堆,然后从堆顶开始逐个取出元素,重建堆并按顺序存储已经取出的元素,最终得到有序的数组。堆排序的时间复杂度为O(nlogn)。
堆排序是不稳定的算法,因为在构建堆的过程中,可能会将相同的元素放到不同的堆节点中,导致相对位置发生变化。
3.归并排序(MergeSort)是一种基于分治思想的递归算法,其核心思想是将待排序的数组分成两个子序列,分别对其进行递归排序,并将已排序的子序列合并起来,最终得到有序的数组。归并排序的时间复杂度为O(nlogn)。
归并排序是稳定的算法,因为在排序过程中,如果存在相等元素,则会将左侧子序列中的元素先放入有序序列中,从而保证了相同元素的相对位置不变。
综上所述,三种排序算法的底层实现原理和稳定性各有异同,在实际使用中需要根据具体情况选择合适的算法。
12 vector底层实现
在向vector插入元素时,空间够,正常插入,如果空间不足则调用insert_aux辅助函数(不只被push_back调用,在实现时不仅仅满足vector需求)
insert_aux的实现
保存原来空间的大小,在扩充时以两倍扩充。
8->16;finsh = new_start;
然后将原来的数据拷贝到新vector,原来的vector销毁(涉及到大量的拷贝构造和析构函数,花费大量的开销)
push_back和emplace_back区别
emplace_back() 和 push_back() 的区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
两个接口内部实现一摸一样,在于构造方式的不同,push_back构造的时候 提供了两种方式 分别是 拷贝构造(事后销毁)和移动构造(优先) 而emplace_back是传入的可变参,可以参数包可以直接构造出对象,这样就少了一次拷贝
emplace_back可以更好的避免内存的拷贝和移动,同时提升插入元素的性能
1.5倍扩容和2倍扩容的区别
面试题:C++vector的动态扩容,为何是1.5倍或者是2倍
扩容原理为:申请新空间,拷贝元素,释放旧空间,**理想的分配方案是在第N次扩容时如果能复用之前N-1次释放的空间就太好了
- 高于两倍 会造成内存空间的浪费
- linux 2倍 windows 1.5倍
- 2倍 每次扩容后的新内存大小必定大于前面的总和 Linux的伙伴系统 要求内存分区链采用2的整数幂次方
- 1.5倍 在几次扩展以后可以重用之前的内存空间了 windows的堆管理系统会对释放的堆块进行合并
如何避免扩容导致效率低
如果要避免扩容而导致程序效率过低问题,其实非常简单:如果在插入之前,可以预估vector存储元素的个数,提前将底层容量开辟好即可。如果插入之前进行reserve,只要空间给足,则插入时不会扩容,如果没有reserve,则会边插入边扩容,效率极其低下。
vector的resize和reserve有何区别
resize 是会分配内存的 如果resize更小 那么会删除元素 释放内存 但是这块内存依然保留
reserve只是保存了一个预留空间
vector如何清空所占内存,如何清空size,如何清空capacity
- clear函数
- assign函数
vector申请的内存在哪里
堆上
13 迭代器失效
vector的插入删除操作,会使得操作位置之后的迭代器全部失效。
需要更新迭代器,重新赋值
14 vector、list、deque区别
- 底层使用数据结构
- 内存扩容方式
- 增删查时间复杂度
15 右值引用
16 STL有什么不好的地方
虽然STL具有许多优点,但也存在一些不足之处:
- 学习曲线较陡峭:STL提供的数据结构和算法非常丰富,但是由于其模板元编程的特点,学习曲线比较陡峭,需要掌握一定的模板编程知识才能熟练使用。
- 代码可读性较差:STL中的一些代码使用了复杂的模板嵌套和元编程技巧,导致代码可读性较差,对于新手来说理解起来比较困难。
- 对内存的占用较大:STL中的容器和算法通常会涉及到动态内存的分配和管理,因此对于大型数据集合,STL的内存占用可能较大。
- 编译速度较慢:由于STL中大量使用了模板元编程技巧,导致编译速度比较慢,特别是在使用大型数据结构和复杂算法时。
- 不适合某些特定的应用场景:STL中的一些数据结构和算法虽然已经非常优秀,但并不适合所有的应用场景,有些特定的应用场景需要使用自己实现的数据结构和算法来满足需求。
总之,STL是C++中非常重要的一个组成部分,具有丰富的功能和优秀的性能,但是也存在一些不足之处,需要程序员根据实际情况进行选择和应用。
17 STL里面随机删除很多元素,如何提高效率(removeif 和 erase的区别)
remove_if和erase都是STL容器中常用的删除元素的方法,它们的区别如下:
- remove_if:是一个算法,它用于在容器中删除满足某个条件的元素,返回值是一个迭代器,指向被删除元素的末尾,该方法并不真正地删除元素,而是将要删除的元素移到了容器的末尾,并返回一个指向新的逻辑结尾的迭代器。这种方法常用于与算法函数结合使用,例如remove_copy_if、partition等。
- erase:是容器中的一个成员函数,它用于从容器中删除指定位置或指定范围的元素。该方法会真正地删除元素,不会保留被删除元素的副本,返回值是一个迭代器,指向被删除元素的下一个元素。此外,它还有多种重载形式,可以按照条件或值删除元素。
总体来说,remove_if用于删除满足某个条件的元素,而erase用于删除指定位置或范围的元素。需要注意的是,使用remove_if后需要调用容器的erase方法才能真正地删除元素。另外,remove_if操作后不会改变容器的大小,只是改变了容器中元素的位置,需要调用容器的erase方法才能真正地删除元素。
18 vector和queue的区别
vector和queue都是STL库中的容器,它们的主要不同点在于它们的数据存储方式和对数据的访问方式。
Vector是动态数组,使用连续的内存空间来存储数据。由于内存空间是连续的,因此可以随机访问其中的元素,并且支持快速的尾部插入和删除操作。但是,如果需要在中间或头部插入或删除元素,则需要进行大量的数据移动,这会导致效率较低。
Queue是队列,使用链表或数组来存储数据。由于元素在队列末尾添加,在队列头部删除,因此只支持顺序访问(即只能访问第一个元素),并且不支持随机访问。但是,由于使用链表或数组来存储数据,因此可以在任意位置快速添加或删除元素(即头部或中部)。
因此,当需要进行随机访问时,应该选择vector;当需要在头部或中部进行快速添加或删除时,应该选择queue。需要注意的是,队列也有优先队列和双端队列等变体,根据实际需求选择不同的容器。
19 STL库的内存配置?二级内存配置,为啥是二级内存配置?为啥是128bytes?
- 一级内存 用malloc
- 二级内存 用内存池
STL库在进行内存配置时,使用了两级内存配置。一级内存配置直接调用malloc()函数进行内存分配,而二级内存配置使用了内存池技术,以提高程序的内存使用效率。
为什么STL库要采用二级内存配置呢?因为在STL中,对象的创建和销毁非常频繁,如果每次都通过调用malloc()来分配内存,会产生较大的时间和空间开销。而使用内存池技术可以避免这种情况的发生,因为内存池可以预分配一定数量的内存块,当需要分配内存时,直接从内存池中获取,当不需要时则将其放回内存池中,避免了频繁的内存分配和释放。
至于为什么STL采用128字 节的内存块作为一级内存配置的默认值,是因为在实践中证明,对于大部分应用场景,128字节的内存块大小是最适合的选择。如果内存块过小,容易产生内存碎片,降低内存使用效率,而如果内存块过大,则会浪费大量内存空间。因此,STL库选择了128字节作为一级内存配置的默认值。
20 实现一个vector类
#include<iostream>
using namespace std;
template<typename T>
class Vector{
public:
Vector() : data_(nullptr), size_(0), cap_(0) {}
~Vector() {
delete[] data_;
}
void reserve(int new_cap) {
if (new_cap <= cap_) return;
T* new_data = new T[new_cap];
for (int i = 0; i < size_; i++) {
new_data[i] = data_[i];
}
delete[] data_;
data_ = new_data;
cap_ = new_cap;
}
void push_back(const T& value) {
if (size_ >= cap_) {
reserve(cap_ == 0 ? 1 : cap_ * 2);
}
data_[size_] = value;
size_++;
}
void pop_back() {
if (size_ > 0) size_--;
}
int size() const {
return size_;
}
int cap() const {
return cap_;
}
T& operator[] (int index) {
return data_[index]; // 值
}
private:
T* data_;
int size_;
int cap_;
};
int main() {
Vector<int> vec;
vec.push_back(1);
vec[0] = 2;
cout << vec[0] << endl;
system("pause");
return 0;
}
21 为什么push_back的平均时间复杂度是O(1)
push_back
方法在 C++ 的标准容器中(如 std::vector
)的平均时间复杂度为 O(1),即常数时间复杂度。这是因为这些容器使用动态数组(dynamic array)作为底层数据结构。
当我们向动态数组的末尾添加一个元素时,如果此时动态数组的内存空间还足够容纳新元素,那么它会将新元素直接放入数组末尾,并更新动态数组的大小。
这种操作只涉及到两个步骤,即分配新的内存空间和复制元素,这两个步骤的时间都与容器中已有元素的数量无关。因此,在这种情况下,时间复杂度是常数级别的。
需要注意的是,如果动态数组的内存空间不足以容纳新元素,那么就会触发重新分配内存空间的操作。该操作可能会消耗较多的时间,其中包括分配新的更大的内存空间、复制现有元素到新的内存区域,然后释放旧的内存空间。但是这种情况下并不属于平均时间复杂度的考虑范围。
综上所述,由于动态数组在连续内存空间的支持下,push_back
操作可以在平均情况下以常数时间复杂度 O(1) 完成。
22 输入迭代器和输出迭代器区别
23 模板的底层实现
查找模板参数 =》 实参推导 =》 实参替换 =》 实例化(函数模板 类模板)
24 forward底层实现
完美转发是为了解决传递参数时的临时对象(右值)被强制转换为左值的问题。在C03中,可以使用泛型引用来实现完美转发,但是需要写很多重载函数,非常繁琐。而在C11中,引入了std::forward,可以更简洁地实现完美转发。
C++ Move与Forward实现原理_genius-x的博客-CSDN博客
C++编程系列笔记(3)——std::forward与完美转发详解 - 知乎 (zhihu.com)
#include<iostream>
#include<string>
#include<vector>
using namespace std;
template<typename T>
void print(T& t) {
cout << "lvalue" << endl;
}
template<typename T>
void print(T&& t) {
cout << "rvalue" << endl;
}
template<typename T>
void TestForward(T && v) {
print(std::forward<T>(v));
}
int main() {
TestForward(1);//rvalue
int x = 1;
TestForward(x);//lvalue
return 0;
}
四、内存管理
01 堆和栈的区别,栈的静态分配和动态分配?
- 分配与释放
- 内存访问速度
- 存储内容
- 生长方向
栈由操作系统分配释放,用于存放函数的参数值、局部变量等,栈中存储的数据的生命周期随着函数的执行完成而结束。
堆由开发人员分配和释放,若开发人员不释放,程序结束时由操作系统回收。
区别:
(1)管理方式不同。栈由操作系统分配释放,无需我们手动控制。堆的申请和释放工作由程序员控制,容易产生内存泄露。
(2)空间大小不同。每个进程拥有的栈大小要远远小于堆大小。
(3)生长方向不同。堆的生长方向向上,内存地址由低到高;栈的生长方向向下,内存地址由高到低。
(4)分配方式不同。堆都是动态分配的,没有静态分配的堆。栈有 2 种分配方式:静态分配和动态分配。静态分配是由操作系统完成的,比如局部变量的分配。动态分配由alloca()
函数分配,但是栈的动态分配和堆是不同的,它的动态分配是由操作系统进行释放,无需我们手工实现。
(5)分配效率不同。栈由操作系统自动分配,会在硬件层级对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是由C/C++提供的库函数或运算符来完成申请与管理,实现机制较为复杂,频繁的内存申请容易产生内存碎片。显然,堆的效率比栈要低得多。
(6)存放内容不同。栈存放的内容,函数返回地址、相关参数、局部变量和寄存器内容等。当主函数调用另外一个函数的时候,要对当前函数执行断点进行保存,需要使用栈来实现,首先入栈的是主函数下一条语句的地址,即扩展指针寄存器的内容(EIP),然后是当前栈帧的底部地址,即扩展基址指针寄存器内容(EBP),再然后是被调函数的实参等,一般情况下是按照从右向左的顺序入栈,之后是被调函数的局部变量,注意静态变量是存放在数据段或者BSS段,是不入栈的。出栈的顺序正好相反,最终栈顶指向主函数下一条语句的地址,主程序又从该地址开始执行。堆,一般情况堆顶使用一个字节的空间来存放堆的大小,而堆中具体存放内容是由程序员来填充的。
从以上可以看到,堆和栈相比,由于大量malloc()/free()或new/delete的使用,容易造成大量的内存碎片,并且可能引发用户态和核心态的切换,效率较低。栈相比于堆,在程序中应用较为广泛,最常见的是函数的调用过程由栈来实现,函数返回地址、EBP、实参和局部变量都采用栈的方式存放。虽然栈有众多的好处,但是由于和堆相比不是那么灵活,有时候分配大量的内存空间,主要还是用堆。
无论是堆还是栈,在内存使用时都要防止非法越界,越界导致的非法内存访问可能会摧毁程序的堆、栈数据,轻则导致程序运行处于不确定状态,获取不到预期结果,重则导致程序异常崩溃,这些都是我们编程时与内存打交道时应该注意的问题。
追问1 为什么堆的生长方向向上,栈的生长方向向下
堆和栈是两种不同的内存分配方式,它们的生长方向不同是因为它们在内存空间中的位置和使用方式不同。
堆通常是由程序员通过动态分配内存进行申请和释放的,它的生长方向是向上的,也就是从低地址到高地址的方向。这是因为堆的内存是在运行时动态申请的,而操作系统将可用内存空间划分成了多个不同的段,其中有一个用于堆的空间段,通常是从高地址往低地址分配的。当需要申请一块新的堆内存时,系统会将新的内存块分配在当前堆的最高地址处,然后再将堆的最高地址指针向上移动,指向刚刚申请的内存块的顶部,以便下一次申请。
而栈通常用于函数调用时存储函数的参数、局部变量、返回地址等信息,它的生长方向是向下的,也就是从高地址到低地址的方向。这是因为栈的内存是在编译时就分配好的,编译器在编译代码时就已经决定了每个变量在栈中的相对位置,通常是从高地址往低地址分配的。当函数被调用时,栈指针会指向栈的最高地址,然后依次将参数、局部变量等数据压入栈中,栈指针随之向下移动,指向新压入的数据。
总之,堆和栈的生长方向是由它们的内存使用方式所决定的,这种区别反映了堆和栈在内存分配和管理方面的不同。
追问2 栈的静态分配和动态分配
栈是一种很常见的数据结构,用于存储函数调用的上下文信息、局部变量等数据。在 C 语言中,栈可以通过静态分配和动态分配两种方式来实现。
静态分配是指在程序编译时就分配好了栈空间的大小,在程序运行期间无法改变。静态分配的栈常常是在函数或代码块开始时被创建,在函数或代码块结束时被销毁。静态分配的栈由编译器负责管理,它通常使用一个指向当前栈顶的指针来记录栈空间的使用情况,当栈空间不足时会引发栈溢出错误。
动态分配是指在程序运行期间,根据需要动态地分配和释放栈空间。动态分配的栈通常是以链表的形式实现的,每个节点表示栈中的一个元素,包含一个指向下一个节点的指针和该节点的值。在动态分配的栈中,新增元素时,只需要在链表的头部插入一个节点,并更新指向栈顶的指针即可;删除元素时,只需要将栈顶指针指向下一个节点即可。动态分配的栈由程序员负责管理,需要手动进行内存的申请、释放和管理。
区别:
- 静态分配的栈空间大小在编译时就被确定了,因此无法动态地调整栈的大小,而动态分配的栈可以根据需要动态地调整大小。
- 静态分配的栈由编译器负责管理,程序员无需手动管理栈的内存;而动态分配的栈由程序员手动管理,需要进行内存的申请、释放和管理。
- 静态分配的栈通常是在函数或代码块开始时被创建,在函数或代码块结束时被销毁,而动态分配的栈生命周期由程序员控制,可以在任何时候创建、销毁或修改栈的数据结构。
- 静态分配的栈有固定的空间限制,当栈空间不足时会引发栈溢出错误;而动态分配的栈可以根据需要动态分配内存,因此不会出现栈溢出的情况。
02 栈溢出的场景举例,嵌套调用函数会出现什么问题?
定义:栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致栈中与其相邻的变量的值被改变。
发生栈溢出的情况:
(1)最常见的就是递归。每次递归就相当于调用一个函数,函数每次调用时都会将局部数据放入栈中。递归10000次,就将10000份这样的数据放入栈中。然而只有当递归结束时,这些数据占用的内存才会被释放。如果递归次数过多,并且局部数据也多,那么将占用大量的栈内存,很容易造成栈溢出。
(2)在函数内部定义超大数组也会导致栈溢出。
解决方法:
(1)不要静态分配,用new动态创建,从堆中分配,堆的空间足够大
(2)改变默认栈的空间大小
可以嵌套调用函数,不能嵌套定义函数,嵌套调用函数也容易导致栈溢出。
协程可以解决栈溢出的问题么
协程(Coroutine)不是解决栈溢出问题的主要手段,但它在某些情况下可以减轻栈溢出的影响。
协程是一种用户态线程,相比于操作系统线程,它的特点是轻量级、资源消耗少,可以快速切换上下文,因此多个协程可以在同一个线程内共享栈空间,从而减小了栈溢出的风险。
但需要注意的是,协程并不能从根本上解决栈溢出问题。栈溢出是由于程序在运行过程中使用了太多的栈空间,导致栈指针越界访问了其他内存区域,从而引发了错误。协程共享栈空间的做法只是用来减小栈空间的使用量,而并没有真正解决栈溢出的问题。
因此,在编写程序时,仍然需要考虑如何避免栈溢出问题。一些方法包括:
- 合理控制递归深度,避免无限递归;
- 合理管理栈空间,避免一次性申请过多的局部变量;
- 使用堆数据结构来代替栈数据结构,通过链表等方式管理数据存储;
总之,协程可以通过共享栈空间的方式减轻栈溢出的影响,但并不能解决栈溢出问题,程序员仍然需要遵循编码规范和安全原则来防止栈溢出等错误。
03 内存泄露,出现内存泄漏如何检测,valgrind的原理
定义:
内存泄漏(memory leak)是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
C++中的内存泄露,总的来说,就是new出来的内存没有通过delete合理的释放掉!
后果:
从性能不良(并且逐渐降低)到内存完全用尽。
更糟的是,泄漏的程序可能会用掉太多内存,以致另一个程序失败,而使用户无从查找问题的真正根源。
此外,即使无害的内存泄漏也可能是其他问题的征兆。
内存泄露类型:
(1)堆内存泄漏 (Heap leak)。对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生Heap Leak.
(2)系统资源泄露(Resource Leak).主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
解决方法:
智能指针!!!
因为智能指针可以自动删除分配的内存
追问1 valgrind原理
Valgrind是一个用于检测程序内存错误的工具,其中包括了检查内存泄漏的能力。下面是Valgrind检查内存泄漏的原理:
- Valgrind会将待测试的程序运行在一个特殊的模拟器中,称为Valgrind虚拟机。
- 在虚拟机中,Valgrind会重定向程序的所有内存分配和释放操作。意思是,当程序调用标准库函数如malloc等分配内存时,实际上是调用了Valgrind提供的对应函数。同理,释放内存也是如此。
- 每次程序进行内存操作时,Valgrind都会记录下来,并跟踪每个内存块的状态、大小和地址等信息。
- 当程序运行完毕时,Valgrind会分析这些操作记录,找出所有分配的内存块,如果这些内存块没有被释放,即发生了内存泄漏,Valgrind就会给出相应的警告。
- 如果内存泄漏被发现,Valgrind还会输出泄漏的详细信息,包括泄漏的内存大小、地址、分配的代码位置等等,帮助开发人员快速定位并修复问题。
综上所述,Valgrind检查内存泄漏的原理是通过重定向程序的内存操作,并记录并分析这些操作的方式来实现的。通过这种方式,Valgrind可以帮助开发人员及时发现内存泄漏问题,并提供详细的泄漏信息,方便进行修复。
04 深拷贝和浅拷贝
浅复制仅仅是指向被复制的内存地址,如果原地址中对象被改变了,那么浅复制出来的对象也会相应改变,拷贝了基本类型的数据
深复制----在**计算机中开辟了一块新的内存地址用于存放复制的对象。
05 new和malloc
- 属性不一样
- 内存分配方式
- 释放内存方式
- 分配失败
[(8条消息) 【C++】C/C++ 内存管理 —— new和delete底层实现原理c++ new底层实现浮光 掠影的博客-CSDN博客](https://blog.csdn.net/qq_54851255/article/details/123226376?ops_request_misc={"request_id"%3A"167911621116800184149566"%2C"scm"%3A"20140713.130102334.pc_all."}&request_id=167911621116800184149566&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2allfirst_rank_ecpm_v1~rank_v31_ecpm-2-123226376-null-null.142v74control,201v4add_ask,239v2insert_chatgpt&utm_term=new delete底层源码&spm=1018.2226.3001.4187)
(1) new/delete是C++关键字,需要编译器支持。malloc/free是库函数,需要头文件支持;
(2) 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。
(4) new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
(5) new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
追问1 malloc的底层实现
- sbrk
- mmap
追问2 free怎么知道释放多大的内存
malloc
分配的每个内存块都会添加一个额外的 sizeof(block_t)
字节用来存储内存块的大小信息。因此,在调用 free
函数时,可以通过给定指针的前一个字节位置来访问到这个大小信息。
具体来说,在 malloc
函数中,我们将分配到的内存块的起始地址强制类型转换为 block_t*
类型,并给其 size
字段赋值为实际分配的内存大小。而 void free(void* ptr)
函数中,则需要通过回退一个字节的偏移量来获取到保存大小信息的位置,然后读取该值,即 (block_t*)(ptr - sizeof(block_t))
。
追问3 new底层实现
06 内存对齐
内存对齐是指计算机在存储数据时,按照一定规则把数据存放在内存中的方式。在 C++ 中,内存对齐是由编译器来负责的,主要是为了优化 CPU 对内存访问的速度,以及保证数据的正确性。
C++ 的内存对齐规则如下:
- 基本数据类型的对齐要求为其大小和其所在结构体或类的最大对齐值的最小值。
- 结构体或类的对齐要求为其中最大数据成员的对齐值的最小值。
- 位域变量的对齐要求为其类型的大小和其所在结构体或类的最大对齐值的最小值。
- 数组的对齐要求为数组元素的对齐值。
- 指针的对齐要求为机器字长。
- 类的虚函数表指针的对齐要求为机器字长。
内存对齐可以提高程序的性能,因为它可以减少 CPU 在访问内存时需要进行的数据对齐操作。同时,内存对齐也可以保证数据的正确性,因为如果数据没有正确地对齐,可能会导致数据的部分位被误读或被丢失。在 C++ 中,可以通过 alignas
关键字来设置自定义的对齐规则。
07 预处理、编译、汇编、链接
- 预处理阶段
预处理器(cpp)根据以字符#开头的命令修给原始的C程序,结果得到另一个C程序,通常以.i作为文件扩展名。主要是进行文本替换、宏展开、删除注释这类简单工作。
对应的命令:linux> gcc -E hello.c hello.i
- 编译阶段
编译器将文本文件hello.i翻译成hello.s,包含相应的汇编语言程序
对应的命令:linux> gcc -S hello.c hello.s
把一个源程序翻译成目标程序的工作过程分为五个阶段:词法分析;语法分析;语义检查和中间代码生成;代码优化;目标代码生成。主要是进行词法分析和语法分析,又称为源程序分析,分析过程中发现有语法错误,给出提示信息。
- 汇编阶段
将.s文件翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件.o中(把汇编语言翻译成机器语言的过程)。
在汇编阶段,汇编器需要将汇编代码分成不同的段,包括代码段、数据段、堆栈段等。
对应的命令:linux> gcc -c hello.c hello.o
- 链接阶段
链接器将所有用来构建程序的目标文件和库文件合并成一个单一的可执行程序。在链接阶段,有静态链接和动态链接两种方式。静态链接将所有目标文件和库文件组合成一个单一的可执行程序,而动态链接则允许程序在运行时动态加载共享库。
08 CPP内存分区
- 堆
- 栈
- 常量存储区
- 代码区
- 全局静态存储区
09 ELF头文件结构
ELF(Executable and Linkable Format)是一种用于可执行文件、共享库、目标文件等的标准格式。而ELF头文件则是指在ELF格式文件中位于文件开头的一段头信息,主要用于描述文件的结构和属性。
具体来说,ELF头文件的作用包括以下几个方面:
- 描述整个文件的结构和属性。ELF头文件中包含了很多文件属性的描述信息,如文件类型、机器架构、入口点地址、程序头部表和节头部表等等,这些信息对于解析文件非常重要。
- 提供文件的调试和诊断信息。ELF头文件还包含了调试和诊断信息的位置和大小等相关信息,这些信息可以帮助开发人员定位和修复程序中的错误或者异常。
- 实现动态链接。在Linux系统中,动态链接是一种广泛应用的技术,可以实现代码和数据的共享,从而降低内存开销。而ELF头文件中的动态链接器信息就是为了支持动态链接而设计的,可以告诉系统哪些库需要被加载和链接。
综上所述,ELF头文件是一个非常重要的文件部分,它提供了文件结构和属性的描述信息,以及动态链接和调试信息等功能。同时,ELF头文件也为文件的解析和执行提供了必要的支持。
xv6 ELF结构
10 内存分区
C中,内存分为5个区:堆(malloc)、栈(如局部变量、函数参数)、程序代码区(存放二进制代码)、全局/静态存储区(全局变量、static变量)和常量存储区(常量)。此外,C++中有自由存储区(new)一说。
全局变量、static变量会初始化为缺省值,而堆和栈上的变量是随机的,不确定的。
追问1 介绍一下内存屏障
11 静态库和共享库的区别
- 静态库(Static Library)是在编译时被链接到程序中的,而动态库(Dynamic Library)是在程序运行时被加载的。
- 静态库的代码被复制到生成的可执行文件中,因此可执行文件比较大。而动态库的代码不会被复制到可执行文件中,因此可执行文件比较小,但在运行时需要动态加载库。
- 静态库的更新需要重新编译链接整个程序,而动态库的更新只需要替换动态库文件,无需重新编译程序。
- 静态库在程序中只存在一个副本,而动态库可以被多个程序共享。
- 静态库中的代码可以被静态链接器(Static Linker)优化,因此静态库中的代码可以运行得更快。而动态库中的代码需要在运行时被动态链接器(Dynamic Linker)加载,因此可能会稍微慢一些。
综上所述,静态库适用于小型应用程序,而动态库适用于大型应用程序和系统。
追问1 运行的时候生成的新的动态库,可以运行么
可以
如果你在运行时生成了一个动态库(.so 文件),你仍然可以使用动态链接器的相关函数来加载和调用该动态库。
在这种情况下,你需要确保生成的动态库文件已经存在,并且位于正确的路径中。然后,你可以使用dlopen
函数来加载该动态库并获取对应的函数指针,然后进行调用。
12 栈手动申请栈上的内存吗
编译器自动分配的
13 malloc,在函数里定义一个很大的数组,应该直接定义还是用malloc?
用malloc
小于 128 brk
大于128 mmap
14 static关键字,函数里用static定义的变量,存在哪个区
静态存储区 和全局变量存在一起
15 C++代码如何优化,时间和空间上
- 移位优化乘除
- 二分代替遍历
- 代码复用性 继承 多态
16 交叉编译和本地编译区别
交叉编译和本地编译是两种不同的编译方式。
本地编译是指在计算机本地进行编译,即使用与目标平台相同的处理器类型、操作系统及相关工具链进行编译。本地编译的优点是编译速度较快,且编译结果可以直接在本地执行和调试。但是,本地编译的局限性比较大,无法生成在其他平台上可执行的程序。
而交叉编译是指使用一种与目标平台不同的处理器类型、操作系统及相关工具链进行编译。交叉编译的优点是可以在一台计算机上为多个平台编译程序,既节省了编译时间,也扩展了程序的可移植性。此外,由于最终生成的可执行文件是针对特定的平台进行编译的,因此它的效率通常比本地编译的程序更高。
总之,交叉编译适用于需要在多个平台上进行部署的应用程序开发,而本地编译则更适合在单个平台上进行开发和调试。但需要注意的是,交叉编译需要额外的配置和工具支持,因此对于一些非常规的开发环境或者开发人员经验较少的情况,可能会更加困难。
17 C++如何进行内存对齐,为什么需要内存对齐
追问1 如何进行内存对齐
C++结构体内存对齐的规则如下:
- 结构体的首地址必须是其成员中占用内存最大的数据类型的整数倍。
- 每个数据成员相对于结构体首地址的偏移量必须是其自身大小或者自身大小的倍数,如果不是,则需要进行对齐补齐。
- 结构体的总大小必须是最大成员变量大小的整数倍。
例如,有以下结构体定义:
struct MyStruct {
char c;
short s;
int i;
};
我们可以根据上述规则,计算出该结构体在内存中的大小和成员变量的偏移量:
char c
占用 1 个字节,偏移量为 0
short s
占用 2 个字节,偏移量为 2 (需要进行对齐补齐)
int i
占用 4 个字节,偏移量为 4
因此该结构体总共占用 8 个字节的内存空间。
注意:不同的编译器对于结构体内存对齐的规则可能会略有不同,一般来说,编译器都提供了一些特定的编译选项来控制内存对齐方式。在实际开发中,如果需要保证结构体在不同平台上的兼容性,通常需要手动指定内存对齐方式。
因此,该结构体在内存中的分布为:
+----+----+----+----+----+----+----+----+
| c |pad | s | s | i | i | i | i |
+----+----+----+----+----+----+----+----+
0 1 2 3 4 5 6 7
其中,pad
表示填充位。
追问2 为什么需要内存对齐
内存对齐的目的主要是为了提高CPU读取内存的效率。当数据结构没有严格对齐时,CPU需要进行额外的内存访问操作,这会降低程序的运行效率。
具体来说,内存对齐的原因和优点如下:
- 对齐后可以减少内存碎片,提高空间利用率。
- 对于未对齐的数据类型,CPU读取它们时需要进行特殊的处理,这会增加额外的指令数和时间开销;而对于对齐的数据类型,则可以直接读取内存地址上的数据,减少了CPU访问内存的次数,提高了程序的运行速度。
- 一些硬件平台(如ARM)对非对齐的内存访问会导致硬件异常,这会影响程序的正确性和稳定性。
- 处理器有缓存机制,对齐后可以更好地利用缓存,提高程序的执行效率。
因此,在设计数据结构时,需要注意内存对齐的问题,遵循一定的内存对齐规则,能够最大化地提高程序的运行效率。
18 介绍一下写时拷贝
写时拷贝(Copy-on-write,COW)是一种在计算机系统中经常使用的技术,它可以降低内存分配和复制数据的成本。
在使用写时拷贝技术时,通常会先将一块内存空间共享给多个进程或线程,这些共享的内存空间都指向同一个物理内存地址。当某个进程或线程需要修改这个内存空间时,它会先创建一个副本,并将数据复制到新的副本中,再进行修改操作。同时,该进程或线程的指针也会指向这个新的副本。
这样,原来共享的内存空间就被修改过了,而其他进程或线程仍然指向原来的那块内存空间,直到有进程或线程需要修改这块内存空间时,才会重复上述过程,创建一个新的副本,复制数据,并将指针指向新的副本。
写时拷贝技术的优点是避免了不必要的内存分配和数据复制,从而提高了程序的运行效率和性能。它在诸如父子进程共享内存、虚拟内存和文件系统等方面得到广泛的应用。
需要注意的是,写时拷贝技术虽然可以提高程序的性能,但同时也带来了一些风险。因为多个进程或线程同时共享同一块内存空间,如果有一个进程误操作导致数据被破坏,那么所有共享这块内存空间的进程都会受到影响。因此,在使用写时拷贝技术时需要谨慎,并采取必要的安全措施,以避免出现不必要的错误和风险。
对于CopyOnWrite(写时复制)的一点研究_Sy_LeeChan的博客-CSDN博客
19 delete和free区别
delete
和 free
是用于释放内存的关键字,但在不同编程语言中具有不同的含义和用法。
- C++ 中的
delete
:
-
delete
是用于释放动态分配的内存(使用new
运算符进行分配)。
-
- 它会调用对象的析构函数来清理资源,并将分配的内存返回给操作系统。
-
delete
用于释放单个对象或对象数组。如果删除了指向对象数组的指针,则应使用数组形式的delete[]
运算符。
-
- 使用
delete
后,指针变量仍然存在,但其值不再有效,因此最好将其设置为nullptr
避免悬挂指针的问题。
- 使用
- C 语言中的
free
:
-
free
是用于释放通过malloc
、calloc
或realloc
运算符分配的内存。
-
- 它不会调用任何析构函数,只是简单地将分配的内存返回给操作系统。
-
free
用于释放单个内存块,无论该块是否代表一个对象。
-
- 使用
free
后,指针变量仍然存在,但其值不再有效,因此最好将其设置为NULL
避免悬挂指针的问题。
- 使用
总结:delete
是 C++ 中用于释放对象的内存的关键字,它会调用对象的析构函数。而 free
是 C 语言中用于释放内存的函数,它仅将分配的内存返回给操作系统,不会调用任何析构函数。
20 如何申请内存到栈上
21 kmalloc和vmalloc区别
kmalloc和vmalloc是Linux内核中用于动态分配内存的两个不同函数:
- kmalloc:kmalloc函数(Kernel Malloc)是Linux内核中用于分配小块(通常小于页面大小)连续物理内存的函数。它通过管理页框(Page Frame)列表来实现内存的分配和释放。kmalloc返回的内存地址是物理连续的,适用于访问硬件设备、DMA(直接内存访问)等需要物理连续内存的场景。
- vmalloc:vmalloc函数(Virtual Malloc)则用于在Linux内核中动态分配大块(可以跨越多个页面)的虚拟内存。与kmalloc不同,vmalloc分配的内存并不一定是物理连续的。它将所需的虚拟地址空间映射到物理内存的不连续区域,可以处理大于一个页面大小的内存分配请求。vmalloc适用于需要大量内存并且不要求物理连续性的场景,如内核模块、驱动程序等。
因此,主要区别在于:
- kmalloc用于分配小块连续物理内存,而vmalloc用于分配大块不连续虚拟内存。
- kmalloc返回的内存是物理连续的,适用于需要物理连续内存的场景,如访问I/O设备和DMA等。而vmalloc返回的内存并不一定是物理连续的,适用于不要求物理连续性但需要大块内存的场景。
- kmalloc使用页框(Page Frame)列表来进行内存管理,而vmalloc将虚拟地址映射到不连续的物理内存区域。
在Linux内核中,开发者可以根据具体需求选择合适的函数来分配所需内存。
五、C++11、14、17、20新特性
每个特性 原理 + 用法 + 优点
C++11:
- 关键字、成员方法 auto lambda move
- STL unordered_set unordered_map(set map) emplace_back (类型强转 可变参)
- 内存管理 智能指针(auto_ptr scoped_ptr unique_ptr shared_ptr weak_ptr)
- 多线程 引入thread join、detach mutex lock_guard condition_variable
C++14:
- 读写锁
- auto 推导 lambda
C++17:
- 万能容器 any
C++ 20:
- 协程
01 手写shared_ptr、智能指针
#include<iostream>
using namespace std;
template<typename T>
class RefCnt{
public:
RefCnt(T* p = nullptr) : _ptr(p) {
if (_ptr != nullptr) count = 1;
}
void addRef() {
count++;
}
int delRef() {
count--;
return count;
}
private:
T* _ptr;
int count;
};
template<typename T>
class SmartPtr {
public:
SmartPtr(T* p = nullptr) : _ptr(p) {
if (p != nullptr) {
_refcnt = new RefCnt<T>(_ptr);
}
}
~SmartPtr() {
if (0 == _refcnt->delRef()) { // 指针应该用 ->
delete _ptr;
_ptr = nullptr;
}
}
T* operator-> () //传指针 传_mptr;
return _ptr;
}
T& operator* () //传引用 传值 传*_mptr
return *_ptr;
}
SmartPtr(const SmartPtr<T> &src) {
_ptr = src._ptr;
_refcnt = src._refcnt;
_refcnt->addRef(); // 指针应该用 ->
}
SmartPtr& operator= (const SmartPtr<T> &src) {
if (this == &src) {
return *this;
}
if (0 == _refcnt->delRef()) {
delete _ptr;
}
_ptr = src._ptr;
_refcnt = src._refcnt;
_refcnt->addRef(); // 指针应该用 ->
return *this;
}
private:
T* _ptr;
RefCnt<T>* _refcnt; // 定义为指针
};
int main() {
SmartPtr<int> p1(new int);
SmartPtr<int> p2(p1);
SmartPtr<int> p3;
p3 = p2;
*p3 = 20;
cout << *p2 << " " << *p3 << endl;
system("pause");
return 0;
}
02 智能指针循环引用
以下是一个关于循环引用的程序例子:
#include <iostream>
#include <memory>
class Person {
public:
std::shared_ptr<Person> spouse;
std::string name;
Person(const std::string& name) : name(name) {
std::cout << name << " is created!\n";
}
~Person() {
std::cout << name << " is destroyed!\n";
}
};
int main() {
auto alice = std::make_shared<Person>("Alice");
auto bob = std::make_shared<Person>("Bob");
alice->spouse = bob;
bob->spouse = alice;
std::cout << "Ready to delete...\n";
return 0;
}
在这个例子中,我们定义了一个名为Person的类,它有一个叫做spouse的std::shared_ptr成员变量,表示该人是否结婚,并持有配偶的智能指针。在主函数中,我们创建了两个Person对象,Alice和Bob,并将他们的spouse指针相互指向对方,形成了一个循环引用。
如果我们运行这个程序,会发现它会一直卡在"Ready to delete..."这一行,无法退出。这是因为Alice和Bob对象互相持有对方的智能指针,导致引用计数始终大于1,即使超过了它们的作用域,它们也不会被自动销毁,从而导致内存泄漏。
解决这个问题的方法之一是使用std::weak_ptr来实现spouse成员变量,因为它可以避免建立强引用关系,从而避免循环引用。另外,我们还可以考虑手动断开某一个Person对象的指向另一个Person对象的指针,或者使用std::enable_shared_from_this类来共享所有权。
03 经常用哪些新特性
- 基本语法 auto move lambda
- STL umap uset
- 内存管理 shared_ptr
- 多线程 thread
04 auto底层实现
类型推断是指在编译时,根据变量的初始化表达式自动推断该变量的类型。C++11引入了auto关键字,它可以用于让编译器自动推断变量的类型,从而使代码更加简洁、易读,并且可以避免类型重复定义的错误。
在进行类型推断时,编译器会根据初始化表达式的类型来推断对应的变量类型。如果初始化表达式是一个常量,则推断出来的变量类型也是常量。如果初始化表达式是一个右值引用,则推断出来的变量类型也是右值引用。如果初始化表达式是一个数组或函数类型,则推断出来的变量类型会被转换为指针类型。
下面是一些例子,展示了变量类型的自动推断:
auto i = 42; // 推断为int类型
auto d = 3.14; // 推断为double类型
auto& r = i; // 推断为int&类型
const auto& cr = i; // 推断为const int&类型
auto* p = &i; // 推断为int*类型
auto f() -> int; // 函数返回值类型推断为int类型
需要注意的是,auto并不是一个新的数据类型,它只是一个类型推断机制。在编译期间,auto会被替换为被推断出的实际类型,因此auto定义的变量与显式指定类型的变量在使用时并没有任何区别。
auto的引入可以减少代码中的重复,提高代码可读性和可维护性。另外,在使用auto的时候,可以充分利用C++的强类型系统,避免类型隐式转换和其他类型相关的错误。需要注意的是,在一些特殊情况下,比如涉及到模板函数或者函数重载时,auto可能会产生编译问题,需要特别小心。
template<typename T>
void func_for_x(T param);
func_for_x(27); // auto x = 27
template<typename T>
void func_for_cx(const T param); // const auto cx = x;
func_for_cx(x);
template<typename T>
void func_for_rx(const T& param); // const auto& rx ;
func_for_rx(x);
Effective Modern C++之Item 2 理解auto的类型推导
05 move底层实现
std::move是将对象的状态或者所有权从一个对象转移到另一个对象,只是转移,没有内存的搬迁或者内存拷贝。
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type &&>(t);
}
- 引用折叠
由于存在T&&这种万能引用类型,当它作为参数时,有可能被一个左值引用或右值引用的参数初始化,这是经过类型推导的T&&类型,相比右值引用(&&)会发生类型的变化,这种变化就称为引用折叠。
1.所有右值引用折叠到右值引用上仍然是一个右值引用。(A&& && 变成 A&&)
2.所有的其他引用类型之间的折叠都将变成左值引用。 (A& & 变成 A&; A& && 变成 A&; A&& & 变成 A&)
追问1 move可以被嵌套么
06 lambda表达式
可以看到上述的函数调用只用了一行就搞定了,这就是Lambda表达式的好处 – 简洁
而且 使用匿名函数,可以免去函数的声明和定义。这样匿名函数仅在调用函数的时候才会创建函数对象,而调用结束后立即释放,所以匿名函数比非匿名函数更节省空间
lambda表达式书写格式:[capture-list] (parameters) mutable -> return-type { statement }
好长一串,还都是英文,我来翻一下
lambda表达式书写格式:[捕获列表] (参数) 可变的-> 返回类型{ 陈述,声明 }
各部分说明:
1.[capture-list] : 捕捉列表,该列表总是出现在lambda函数的开始位置,编译器根据[ ]来判断接下来
的代码是否为lambda函数,捕捉列表能够捕捉上下文中的变量供lambda函数使用。
2.(parameters):参数列表。与普通函数的参数列表一致,如果不需要参数传递,则可以连同()一起
省略
3.mutable:默认情况下,lambda函数总是一个const函数,mutable可以取消其常量性。使用该修
饰符时,参数列表不可省略(即使参数为空)。
4.->return-type:返回值类型。用追踪返回类型形式声明函数的返回值类型,没有返回值时此部分
可省略。返回值类型明确情况下,也可省略,由编译器对返回类型进行推导。
5.{statement}:函数体。在该函数体内,除了可以使用其参数外,还可以使用所有捕获到的变量。
注意:在lambda函数定义中,参数列表和返回值类型都是可选部分,而捕捉列表和函数体可以为空。
因此C++11中最简单的lambda函数为:[]{}; 该lambda函数不能做任何事情。
07 智能指针缺点
- 循环引用
08 智能指针赋值
5.比如说申明了一个
classA
{
A1,A2,
}
A1=A2这个情况下发生了什么?(shared_ptr)
09 shared_ptr引用计数在什么情况下会计数减一?
- 析构
- 赋值
10 详细讲一下shared_ptr具体是怎么做的?
shared_ptr是C++中的一种智能指针,它能够自动管理动态分配的内存,并且能够在多个指针之间共享这块内存,避免内存泄漏和多次释放同一块内存等问题。
在底层实现上,shared_ptr主要依靠计数器(reference count)来实现内存管理和共享。每个shared_ptr对象都有一个计数器,用于记录当前有多少个指针指向同一块内存。当一个新的shared_ptr对象指向该内存时,计数器加1;当一个shared_ptr对象被销毁时,计数器减1。当计数器的值为0时,表示没有任何指针指向该内存,此时该块内存会被自动释放。
在多个shared_ptr之间共享同一块内存时,它们都会持有一个相同的计数器,通过计数器来记录内存的使用情况。这样,当一个shared_ptr对象被销毁时,计数器减1,如果计数器的值变为0,则表示该块内存不再被任何shared_ptr对象所持有,此时内存会被自动释放。
当创建一个shared_ptr对象时,它会通过一个模板类来管理指向的内存,该模板类包含了指向内存的指针和一个计数器指针,计数器指针指向一个动态分配的整数计数器,该计数器用于跟踪该内存块的引用计数。当有一个新的shared_ptr对象指向该内存块时,计数器增加;当一个shared_ptr对象被销毁时,计数器减少。当计数器为0时,该内存块被释放。
需要注意的是,shared_ptr在多线程环境下的使用需要注意线程安全问题。在多线程环境下,当多个线程同时操作同一个shared_ptr对象时,需要使用互斥锁等机制来保证共享内存的正确性和可靠性。同时,在使用shared_ptr时也需要注意避免循环引用等问题,以免造成内存泄漏。
11 unordered_set和unordered_map底层实现
哈希表 O(1)
unordered_set和unordered_map都是哈希表(hash table)的实现,其底层实现方式大致相同。
具体来说,unordered_set和unordered_map的底层实现都是基于==拉链式哈希表(chaining hash table)==的。哈希表本质上是一个数组,数组中每个元素称为哈希槽(hash slot),每个哈希槽中存储着0个或多个元素。当我们需要在哈希表中查找某个元素时,将对该元素哈希后得到一个索引值(即哈希值),然后在哈希表中该索引值所对应的哈希槽中进行查找。
具体来说,插入某个元素时,会首先根据该元素的哈希值计算出该元素应该存放在哈希表的哪个哈希槽中,然后将该元素插入到对应的哈希槽中。如果该哈希槽中已经有其他元素了,那么该元素就会被插入到链表的末尾。而查找某个元素时,也是首先根据该元素的哈希值计算出该元素应该在哈希表的哪个哈希槽中,然后遍历该哈希槽中的链表,查找是否存在对应的元素。
当然,在实现中,还需要解决一些哈希冲突(hash collision)的问题。在哈希表中,由于哈希算法的不完美,可能会导致多个元素被映射到同一个哈希槽中,这种情况称为哈希冲突。为了解决哈希冲突,通常有两种方式:链地址法和开放地址法。unordered_set和unordered_map都采用了链地址法,即将哈希表中每个哈希槽实现成一个链表。当发生哈希冲突时,新元素就被插入到该哈希槽所对应的链表的末尾。
总之,unordered_set和unordered_map底层使用了基于拉链式哈希表的实现方式,通过计算元素的哈希值并将元素插入到对应的哈希槽中,实现了快速的查找和插入操作。
map底层、unorder_map底层,二者区别,为什么这样设计
哈希表是一种用于快速查找的数据结构,其核心思想是根据给定的键值将数据映射到一个索引位置,从而实现O(1)时间复杂度的查找和插入操作。在哈希表中,数据元素存储在一个数组中,每个数组元素称为“桶”,每个桶中可以存储多个数据元素,当多个数据元素映射到同一位置时,它们会被存储在同一个桶中。
哈希表的底层实现原理主要包括两个方面:哈希函数和解决冲突的方法。
哈希函数是将键值映射到数组索引位置的重要组成部分,它的设计决定了哈希表的性能和效率。常见的哈希函数包括除留余数法、乘法散列法、平方取中法等,不同的哈希函数适用于不同的场景,需要根据具体情况进行选择。当哈希函数设计得不好时,可能会出现哈希冲突的情况,即不同的键值映射到了同一个索引位置,这时需要使用解决冲突的方法进行处理。
解决哈希冲突的方法有多种,比较常见的有链地址法和开放地址法。其中链地址法是将每个桶设计为一个链表,当多个数据元素映射到同一索引位置时,它们会被添加到此索引位置对应的链表中;开放地址法则是在出现冲突时,不断探测下一个空闲的桶来存储数据元素。开放地址法又分为线性探测、二次探测和双重散列等几种方式。
下面是一个简单的哈希表实现代码,使用了链地址法进行解决冲突。其中,使用了STL标准库中的list容器来实现链表,使用了unordered_map来实现底层的哈希表。注意,这个代码只是一个基本的框架,没有进行性能优化和错误处理,仅供参考。
#include <iostream>
#include <list>
#include <unordered_map>
using namespace std;
class HashMap {
public:
void put(int key, int value) {
int bucket = hashFunction(key);
for (auto& p : data[bucket]) {
if (p.first == key) {
p.second = value;
return;
}
}
data[bucket].push_back(make_pair(key, value));
}
int get(int key) {
int bucket = hashFunction(key);
for (auto& p : data[bucket]) {
if (p.first == key) {
return p.second;
}
}
return -1;
}
private:
static const int TABLE_SIZE = 10007;
list<pair<int, int>> data[TABLE_SIZE];
int hashFunction(int key) {
return key % TABLE_SIZE;
}
};
int main() {
HashMap map;
map.put(1, 100);
map.put(2, 200);
map.put(3, 300);
cout << map.get(2) << endl; // output: 200
cout << map.get(4) << endl; // output: -1
return 0;
}
12 thread_local介绍一下
thread_local
是 C++11 引入的关键字,用于在多线程程序中声明线程局部存储的变量。它的作用可以总结如下:
- 线程局部存储:通过使用
thread_local
关键字声明的变量,每个线程都会拥有其自己的独立副本。这意味着变量在不同的线程之间是隔离的,每个线程对该变量的修改不会影响其他线程的副本。
- 线程特定数据:
thread_local
变量存储的数据在其生命周期内与线程关联。每个线程都可以访问和修改自己的副本,但无法直接访问其他线程的副本。
- 线程安全性:通过
thread_local
可以实现线程安全的编程方式,将数据封装在线程的本地副本中,避免了对全局数据的竞争和互斥操作。
- 高效性:使用
thread_local
比全局数据或互斥锁来管理状态更有效,因为每个线程都可以直接访问自己的副本,无需进行同步操作。
- 生命周期控制:线程局部变量的生命周期受到线程的创建和销毁控制。当一个线程终止时,它关联的线程局部变量也会被销毁。
thread_local
的底层实现原理是通过操作系统提供的线程局部存储(Thread Local Storage,TLS)机制来实现的。每个线程都被分配TLS 存储空间,可以将 thread_local
变量存储在这个存储空间中。
具体的实现方式可能因操作系统和编译器而异,但下面是一个常见的实现过程:
- 编译器根据
thread_local
关键字标记的变量分配额外的 TLS 存储空间。
- 对于声明为
thread_local
的静态变量、全局变量或静态成员变量,编译器会生成对应 TLS 存储区的访问代码。
- 在程序启动时,操作系统为每个线程分配¥¥的 TLS 存储空间。这样,每个变量都有自己的副本,并且可以通过 TLS 存储空间进行访问。
- 当线程创建时,TLS 存储空间会自动初始化为默认值或用户指定的初始值。
- 每个线程可以直接访问自己的 TLS 存储空间,读取和修改其中的
thread_local
变量,而不会干扰其他线程的副本。
- 当线程终止时,TLS 存储空间会被释放,并销毁变量的副本。
需要注意的是,TLS 存储空间的分配和访问是由操作系统提供的,编译器负责生成相应的代码来与操作系统进行交互。因此,底层的实现方式可能会因不同的操作系统和编译器而有所差异。
总之,thread_local
的底层实现利用了操作系统的线程局部存储机制,为每个线程分配¥¥的存储空间,使得 thread_local
变量在每个线程中都有自己的副本,并且可以安全地读取和修改,实现了线程隔离的效果。
13 shared_ptr 直接用裸指针和make_shared定义有什么区别
new的次数不一样
14 constexptr作用
constexpr
是 C++11 引入的关键字,用于声明可以在编译时求值的常量表达式。它的作用可以总结如下:
- 编译时求值:
constexpr
可以用于声明变量、函数或者构造函数,在编译时会根据输入值计算表达式的结果,并将其替换为编译时常量。这样可以在编译阶段进行优化,提高程序的执行效率。
- 常量表达式:
constexpr
可以用于声明常量,这些常量在编译时被确定并且不能再修改。只有当表达式满足一定的条件时,才能被声明为constexpr
,如仅包含常量表达式、类对象的构造函数等。
- 提供编译期常量:通过将一个变量或函数声明为
constexpr
,可以使编译器在编译时求出其值,并在编译过程中使用这个值。这样可以减少运行时的计算消耗,同时提供更好的代码可读性和维护性。
- 数组长度:在 C++14 之前,数组的长度必须是常量表达式,而
constexpr
可以用于声明数组的长度,使得数组长度在编译期间可以被确定。
总而言之,constexpr
的主要作用是在编译期对表达式进行求值,从而提供编译期常量和进行编译时优化,提高代码的效率和可读性。
const和constexpt区别
constexpr
和 const
都用于声明常量,但在C++中有几个关键区别:
- 修饰对象的类型:
-
const
可以用于修饰对象、指针和引用,表示所修饰的对象为不可修改的常量。它可以应用于任何数据类型。
-
constexpr
可以用于修饰对象或函数,表示所修饰的对象或函数在编译时求值,并且具有常量表达式的特性。它通常用于声明和定义常量值。
- 编译时求值:
-
const
声明的变量只表示其值不可修改,无法保证在编译时进行求值。编译器会在运行时处理对该变量的访问。
-
constexpr
声明的变量(及其相关的函数)必须在编译时求值,其值可以被视为常量并直接用于编译期间的计算和优化。
- 使用场景:
-
const
主要用于定义不可更改的常量,适用于各种常量值的情况。
-
constexpr
尤其适合在编译期间求值,并用于需要在编译时确定结果的情况,例如数组大小、模板参数等。
需要注意的是,constexpr
对象和函数必须满足一些限制条件,比如在编译期间能够计算出值,并且具有常量表达式的特征。
总结起来,const
用于修饰不可修改的常量,而 constexpr
用于在编译期间求值和定义常量。它们之间的关键区别在于编译时求值和适用范围的不同。
15 右值引用解决了什么问题
在右值引用出现之前,通常可以采用以下几种方法来解决右值引用相关的问题:
- 使用const引用:将被临时对象初始化的常量引用作为函数参数。这样做可以避免不必要地创建副本,并提高效率。不过,这仅适用于常量引用,无法保证对非常量引用的操作,因为无法修改被引用对象。
- 返回指针:如果需要在函数内部创建一个局部对象并返回它(例如,从函数中构建一个动态分配的对象),则可以使用指针来间接传递该对象。但是,这可能会使代码更加复杂,并且需要调用方负责释放动态分配的内存。
- 使用类似于C98的方法:在C98中,没有右值引用,因此需要使用传统的拷贝或移动构造函数来传递临时对象。这种方式可能不如右值引用那样高效和方便,但可以实现类似的功能。
然而,这些方法都是权宜之计,在C11中引入的右值引用和移动语义极大地改善了处理临时对象和性能优化的能力。因此,从C11开始,推荐使用右值引用来处理右值相关的问题。
下面是一个使用C++98的方法来解决临时对象问题的示例代码:
#include <iostream>
// 假设有一个类MyClass
class MyClass {
public:
MyClass() { std::cout << "Constructor" << std::endl; }
MyClass(const MyClass& obj) { std::cout << "Copy Constructor" << std::endl; }
~MyClass() { std::cout << "Destructor" << std::endl; }
};
// 接受常量引用参数的函数
void foo(const MyClass& obj) {
std::cout << "Inside foo()" << std::endl;
}
int main() {
foo(MyClass()); // 通过临时对象调用foo()
return 0;
}
在上面的示例中,我们定义了一个简单的MyClass
类,并在main
函数中通过临时对象调用foo
函数。foo
函数接受一个常量引用参数,可以防止临时对象进行拷贝构造而影响性能。当运行这段代码时,输出将是:
Constructor
Inside foo()
Destructor
该示例演示了如何在C++98中处理临时对象的问题,通过传递常量引用参数避免了不必要的拷贝构造函数的调用,从而提高了效率。
如果在foo
函数中不使用常引用,而是使用普通的引用参数,代码将会进行拷贝构造。这可能导致以下问题:
- 频繁的拷贝构造:当将临时对象传递给非常引用参数时,编译器会生成一个临时的拷贝构造函数调用。这会导致额外的开销和性能下降,特别是当临时对象很大或需要频繁创建时。
- 语义上的误解:一般来说,将参数定义为非常引用意味着被调用函数希望通过引用修改原始对象的状态。然而,对于一个临时对象,并没有必要或者是安全的去修改它,因为它没有存储在具名变量中。因此,使用非常引用可能会给调用方提供了一个错误的印象,即函数可以修改临时对象的状态。
- 创建不必要的副本:如果函数内部需要修改传递的对象,而又没有使用常引用,那么就必须在函数内部创建一个新的副本。这样做会导致不必要的资源开销和浪费。
综上所述,如果不使用常引用而直接传递临时对象,代码可能会产生额外的开销、语义错误和不必要的副本。因此,在处理临时对象时,建议使用常引用来避免这些问题。
16 unique_ptr底层实现
- 禁用拷贝 赋值
- 采用移动拷贝 移动赋值
- 构造和析构的时候 申请和释放资源
独占所有权
17 forward底层实现
18 C14新特性
19 C17新特性
20 C20新特性
六、高并发多线程
01 原子变量
通过硬件的原子操作(CAS),适应于一些需要频繁修改的场景
互斥锁是比较重的,临界区代码做的事情比较多
简单的加加 减减 系统理论:CAS保证原子特性 无锁操作 提高多线程的效率
exchange CAS来实现
详述CAS操作
CAS(Compare and Swap)操作是一种原子操作,通常由硬件提供支持,在并发编程中常用于实现锁和线程安全的数据结构等。
CAS 操作分为三个参数,即:要修改的内存位置指针 p、旧值 expected 和新值 desired。操作流程如下:
- 比较指针 p 指向的内存位置的值是否等于期望的旧值 expected,如果相等,则进入步骤 2,否则跳过本次操作。
- 将指针 p 指向的内存位置的值设为新值 desired,操作完成。
CAS 操作的原子性是由硬件提供支持的。在进行比较和赋值的过程中,CPU 会锁定总线保证原子性,同时使用缓存一致性协议来保证多核 CPU 上不同核间的内存一致性。
CAS 操作可用于实现原子递增、原子递减、锁和线程安全的数据结构等,在并发编程中有着广泛的应用。但需要注意的是,CAS 操作虽然可以高效地实现原子性,但对于复杂的操作,如复合操作和涉及多个变量的操作,并不能保证完全的原子性,此时需要使用更加复杂的锁机制来确保线程安全。
02 条件变量
在Linux操作系统中,条件变量(Condition Variable)是一种线程同步机制,用于在多个线程之间传递信号以及协调线程的行为。条件变量通常与互斥锁(Mutex)一起使用,以实现线程之间的同步。
条件变量本质上是一个对象,线程可以在该对象上等待某个特定的条件成立,当条件成立时,其他线程可以通过该对象来通知等待线程。在Linux系统中,条件变量的定义如下:
typedef struct {
pthread_mute_t mutex;
pthread_cond_t cond;
} pthread_condattr_t;
其中,mutex是一个互斥锁对象,用于保护条件变量的访问,cond是一个条件变量对象。
使用条件变量时,通常会遵循以下步骤:
- 初始化条件变量和互斥锁。
- 在等待条件成立的线程中,使用pthread_cond_wait函数来等待条件变量。
- 在满足条件时,使用pthread_cond_signal或pthread_cond_broadcast函数来通知等待线程。
- 在更新共享资源时,使用互斥锁来保护共享资源的访问。
具体来说,等待条件变量的线程会在调用pthread_cond_wait函数时阻塞,并释放对互斥锁的持有。当条件变量被其他线程修改并且满足等待条件时,等待线程会被唤醒,重新获取互斥锁并继续执行。使用pthread_cond_signal函数可以唤醒等待条件变量的一个线程,而使用pthread_cond_broadcast函数可以唤醒所有等待条件变量的线程。
条件变量可以用于解决线程间的竞争和同步问题,避免线程之间的忙等待和资源浪费。在Linux系统中,条件变量通常与互斥锁一起使用,以实现线程的同步和互斥。
03 join、detach用法
join() 阻塞当前线程,直到join的线程结束了才开始执行下面的程序
若下一个线程任然是join阻塞的,则依然会阻塞等待线程执行结束
主线程等待子线程执行结束,才会回收资源
detach会与主线程分离,子线程单独运行
04 mutex、lock_guard、unique_lock
mutex
mutex 保证线程同步,防止不同的线程操作同一个数据
mutex
是一个互斥量,用于保护共享资源不被多线程同时访问,并提供线程同步和互斥的机制。
但是使用mutex
是不安全的,当一个线程在解锁之前异常退出了,那么其它被阻塞的线程就无法继续下去。
lock_guard
lock_guard
是一个轻量级的封装类,用于在构造函数中通过调用mutex
的lock()
方法锁定互斥量,在析构函数中通过调用mutex
的unlock()
方法释放互斥量,从而保证对共享资源的独占式访问。
unique_lock
unique_lock
是另外一种封装类,相比于lock_guard
,它提供了更灵活的锁定和解锁操作,支持将锁定操作延迟到某个时刻执行,以及在锁定期间释放锁定,等待其他线程完成一些操作后再重新锁定。除此之外,unique_lock
还支持线程间转移所有权,即把已经获得的锁的所有权转移给其他线程,以达到避免死锁、增加并发性的目的。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex g_mutex;
int g_data = 0;
void thread_func()
{
std::unique_lock<std::mutex> lock(g_mutex, std::defer_lock); // 延迟获取互斥量的独占锁
// 这里可以新增其他代码,此时仍未获取锁定
lock.lock(); // 手动获取锁定
++g_data; // 修改共享资源
std::cout << "Thread id: " << std::this_thread::get_id()
<< ", g_data: " << g_data << std::endl;
lock.unlock(); // 手动释放锁定
// 在这里可以添加其他线程不需要获取锁的代码,因为锁已经释放了
lock.lock(); // 再次手动获取锁定
++g_data; // 继续修改共享资源
std::cout << "Thread id: " << std::this_thread::get_id()
<< ", g_data: " << g_data << std::endl;
} // 离开作用域时,unique_lock自动析构并释放锁定
int main()
{
std::thread t1(thread_func);
std::thread t2(thread_func);
t1.join();
t2.join();
return 0;
}
总的来说,mutex
用于实现线程安全的互斥访问,lock_guard
是 mutex
的一种简单封装,较为方便使用,适用于对共享资源进行短暂加锁,并且具有 RAII 语义,能够自动释放互斥量。而 unique_lock
则在 lock_guard
的基础上提供了更灵活的控制和管理方式,在一些复杂场景下会更为实用。
05 多线程通信
- volatile 定义全局变量
- join 主线程等子线程结束
- 条件变量 同步通信
06 多线程参数传递
在多线程编程中,参数传递可以通过以下几种方式实现。
- 通过全局变量共享参数。
可以使用全局变量在不同的线程之间进行参数传递。但是需要注意的是,多个线程同时访问共享的全局变量时需要进行同步操作,避免出现竞态条件和数据不一致等问题。
- 将参数作为线程函数的参数传递。
当创建一个新的线程时,可以将相关参数传递给线程函数,以便在执行过程中使用。这种方式的优点是可以避免多个线程同时访问共享变量的问题。缺点是线程函数的参数数量受到了限制,如果参数很多,代码可读性和维护性会变得较差。
- 将参数打包成结构体或对象,并将其作为参数传递。
将所有参数打包成结构体或对象,然后将其作为线程函数的参数传递。这种方式可以将相关参数封装成一个单独的对象,让代码更易于维护和扩展。同时也可以避免多线程之间的共享变量问题。
- 使用线程本地存储(Thread Local Storage, TLS)来保存参数。
在某些情况下,如果某些参数只有在特定的线程中才有意义,那么可以考虑使用线程本地存储来保存这些参数。这种方式可以将线程私有数据与线程状态关联起来,避免了多线程之间的共享变量问题,同时也提高了程序的性能。需要注意的是,TLS 使用时需要特别小心,因为它可能会导致内存泄漏或其他问题。
07 生产者消费者模型
单生产者单消费者
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue> // C++ STL所有的容器都不是线程安全
using namespace std;
/*
C++多线程编程 - 线程间的同步通信机制
多线程编程两个问题:
1.线程间的互斥
竟态条件 =》 临界区代码段 =》 保证原子操作 =》互斥锁mutex 轻量级的无锁实现CAS
strace ./a.out mutex => pthread_mutex_t
2.线程间的同步通信
生产者,消费者线程模型
*/
std::mutex mtx; // 定义互斥锁,做线程间的互斥操作
std::condition_variable cv; // 定义条件变量,做线程间的同步通信操作
// 生产者生产一个物品,通知消费者消费一个;消费完了,消费者再通知生产者继续生产物品
class Queue
{
public:
void put(int val) // 生产物品
{
//lock_guard<std::mutex> guard(mtx); // scoped_ptr 不能同时使用两把锁
unique_lock<std::mutex> lck(mtx); // unique_ptr
while (!que.empty())
{
// que不为空,生产者应该通知消费者去消费,消费完了,再继续生产
// 生产者线程进入#1等待状态,并且#2把mtx互斥锁释放掉
cv.wait(lck); // lck.lock() lck.unlock
}
que.push(val);
/*
notify_one:通知另外的一个线程的
notify_all:通知其它所有线程的
通知其它所有的线程,我生产了一个物品,你们赶紧消费吧
其它线程得到该通知,就会从等待状态 =》 阻塞状态 =》 获取互斥锁才能继续执行
*/
cv.notify_all();
cout << "生产者 生产:" << val << "号物品" << endl;
}
int get() // 消费物品
{
//lock_guard<std::mutex> guard(mtx); // scoped_ptr
unique_lock<std::mutex> lck(mtx); // unique_ptr
while (que.empty())
{
// 消费者线程发现que是空的,通知生产者线程先生产物品
// #1 进入等待状态 # 把互斥锁mutex释放
cv.wait(lck);
}
int val = que.front();
que.pop();
cv.notify_all(); // 通知其它线程我消费完了,赶紧生产吧
cout << "消费者 消费:" << val << "号物品" << endl;
return val;
}
private:
queue<int> que;
};
void producer(Queue *que) // 生产者线程
{
for (int i = 1; i <= 10; ++i)
{
que->put(i);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void consumer(Queue *que) // 消费者线程
{
for (int i = 1; i <= 10; ++i)
{
que->get();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
int main()
{
Queue que; // 两个线程共享的队列
std::thread t1(producer, &que);
std::thread t2(consumer, &que);
t1.join();
t2.join();
system("pause");
return 0;
}
/*
线程间互斥 : 临界区 原子类型 互斥锁 信号量
线程间同步 : 条件变量 信号量
*/
08 unique_lock的底层实现原理
unique_lock 是 C++11 中用于实现线程同步的一种锁类型,与传统的锁不同的是,它提供了更多的灵活性和安全性,能够在需要时自动释放锁,并支持不同的锁策略。
unique_lock 的底层实现原理主要涉及到两个方面:
- 基础锁类型:unique_lock 本身不是一个锁类型,它依赖于基础锁类型的实现。可以使用 mutex、recursive_mutex、timed_mutex 和 recursive_timed_mutex 等 C++11 标准库提供的锁类型作为其基础锁类型。同时,unique_lock 也支持自定义的锁类型,只要该类型提供了相应的 lock() 和 unlock() 成员函数即可。
- RAII(Resource Acquisition Is Initialization)技术:unique_lock 的另一个重要特点是采用了 RAII 技术,即资源获取即初始化技术。当 unique_lock 对象被创建时,它会自动获取锁;当对象离开作用域时,锁也会自动释放。这样可以避免手动调用 lock() 和 unlock() 函数带来的风险,同时也保证了锁在异常情况下的正确处理。
由于 unique_lock 依赖于基础锁类型实现,因此其底层实现原理也与不同的基础锁类型有关。例如,对于 mutex 基础锁类型,unique_lock 的实现通常会使用互斥量和条件变量等机制来保证线程同步和唤醒。而对于 timed_mutex 基础锁类型,unique_lock 则会采用定时器等机制来实现超时等待。
总之,unique_lock 通过采用基础锁类型和 RAII 技术,提供了更为灵活、安全和易用的线程同步方式,是 C++11 中值得推荐使用的一种锁类型。
09 读写锁、自旋锁和互斥锁的底层实现原理
读写锁
读写锁是一种特殊的锁,允许多个线程同时读取共享数据,但只允许一个线程写入共享数据。读写锁的底层实现通常采用原子操作、条件变量等机制来实现。在 Linux 系统下,读写锁的具体实现是基于 futex(fast userspace mutex)技术实现的,通过使用比互斥锁更为高效的底层原语实现了更快速的读访问。
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
class ReadWriteLock {
public:
ReadWriteLock() : read_count(0), write_count(0) {}
void lockRead() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this]() { return write_count == 0; }); // 有写入操作时等待
++read_count;
}
void unlockRead() {
std::lock_guard<std::mutex> lock(mtx);
--read_count;
if (read_count == 0) {
cv.notify_all();
}
}
void lockWrite() {
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this]() { return read_count == 0 && write_count == 0; }); // 没有读取和写入操作时等待
++write_count;
}
void unlockWrite() {
std::lock_guard<std::mutex> lock(mtx);
--write_count;
cv.notify_all();
}
private:
std::mutex mtx;
std::condition_variable cv;
int read_count;
int write_count;
};
ReadWriteLock rwLock;
int data = 0;
void readData(int thread_id) {
rwLock.lockRead(); // 获取读取权限
std::cout << "Read Thread " << thread_id << " - Data: " << data << std::endl;
rwLock.unlockRead(); // 释放读取权限
}
void writeData(int thread_id) {
rwLock.lockWrite(); // 获取写入权限
data++; // 进行写入操作
std::cout << "Write Thread " << thread_id << " - Data: " << data << std::endl;
rwLock.unlockWrite(); // 释放写入权限
}
int main() {
std::thread t1(readData, 1);
std::thread t2(readData, 2);
std::thread t3(writeData, 3);
t1.join();
t2.join();
t3.join();
return 0;
}
自旋锁
自旋锁是一种基于忙等待的锁,它通过循环不停地检查锁变量的值来实现锁的功能。当一个线程请求获得锁时,如果发现锁已经被其他线程持有,则它会一直循环等待,直到锁被释放。这种方式相比于传统的阻塞锁来说,减少了线程上下文的切换和系统调用的开销,从而提高了系统的并发能力。
自旋锁的底层实现通常涉及到两个关键的操作:原子比较和交换(Atomic Compare and Swap, CAS)和等待指令。CAS是一种原子操作,它可以在同一时刻完成读取、修改、写入三个操作,通常用于实现线程间的同步和互斥。等待指令通常是一种死循环指令,在基于x86架构的CPU中,一般使用汇编指令pause或者rep nop来实现忙等待的效果。
下面是自旋锁的一个简单实现:
#include <atomic>
#include <thread>
class SpinLock {
public:
SpinLock() : state_(0) {}
void lock() {
while (state_.exchange(1, std::memory_order_acquire)) {
// 自旋等待
while (state_.load(std::memory_order_relaxed)) {
std::this_thread::yield(); // 等待指令
}
}
}
void unlock() {
state_.store(0, std::memory_order_release);
}
private:
std::atomic<int> state_; // 0表示未上锁,1表示已上锁
};
在上面的代码中,lock函数使用std::atomic类的exchange函数,通过CAS操作来获取锁。如果state的值为0,则将其设置为1,并返回原来的值。如果state的值已经为1,则exchange函数不会修改state_的值,直接返回原来的值。当exchange函数返回1时,说明锁已经被其他线程持有,则当前线程会执行wait循环,在wait循环中,使用std::this_thread::yield()等待指令暂停线程,让CPU调度其他线程运行,防止发生无限忙等待。
unlock函数则使用std::atomic的store函数,将state_的值设置为0,表示锁已经释放。
总之,自旋锁的底层实现关键在于使用原子操作和等待指令实现忙等待的效果。实现方式可以根据平台和硬件特性进行优化,例如使用CPU提供的pause指令或者采用自适应自旋等技术来提高自旋锁的性能。
互斥锁
一般情况下,在实际应用中,我们可以使用操作系统提供的互斥量(mutex)或者锁(lock)等机制来实现互斥锁。
不过如果你想自己手写一个互斥锁,可以考虑以下步骤:
- 定义互斥锁对象。在C++中,可以使用类或者结构体来定义互斥锁对象。例如,可以定义一个结构体Mutex,包含一个布尔类型的locked成员变量,用于记录锁的状态。
struct Mutex {
bool locked;
};
- 加锁操作。加锁操作是指在获取锁时,判断锁是否处于可用状态,如果是,则将锁置为已占用状态;否则等待其他线程释放锁后再进行竞争。可以使用循环CAS操作(Compare-And-Swap)来完成这一步骤。
void lock(Mutex& mutex) {
while(true) {
if(!mutex.locked && !CAS(&mutex.locked, false, true)) {
break;
}
// 等待其他线程释放锁
yield();
}
}
以上代码中,CAS函数用于比较和交换locked的值。如果locked为false,则将其设置为true,并返回true表示成功;否则返回false,表示失败。
- 解锁操作。解锁操作是指在释放锁时,将锁的状态置为未占用状态,并唤醒其他等待锁的线程。这一步可以直接使用布尔类型的赋值操作来实现。
void unlock(Mutex& mutex) {
mutex.locked = false;
}
- 利用RAII技术,封装互斥锁。RAII(Resource Acquisition Is Initialization)是C++的一种资源管理技术,将资源的获取和释放封装在类对象的构造函数和析构函数中,当对象离开作用域时,程序会自动调用析构函数释放资源。
class MutexGuard {
public:
explicit MutexGuard(Mutex& mutex) : _mutex(mutex) {
lock(_mutex);
}
~MutexGuard() {
unlock(_mutex);
}
private:
Mutex& _mutex;
};
在上面的代码中,MutexGuard类用于封装互斥锁。在构造函数中,调用lock函数将锁置为已占用状态;在析构函数中,调用unlock函数将锁置为未占用状态。这样就能够保证在任何情况下,都能正确地释放锁。
总之,在手写互斥锁时,需要考虑多线程访问时的原子性和竞争问题,保证锁的正确性和效率。同时,也需要注意避免死锁和饥饿等问题。因此,建议还是优先使用操作系统提供的互斥量或者锁等机制来实现互斥锁。
代码:
#include <iostream>
#include <thread>
#include <atomic>
#include <condition_variable>
class MutexLock {
public:
MutexLock() : is_locked(false) {}
void lock() {
bool expected = false;
while (!is_locked.compare_exchange_strong(expected, true)) {
expected = false;
std::this_thread::yield();
}
}
void unlock() {
is_locked.store(false);
cv.notify_one();
}
private:
std::atomic<bool> is_locked;
std::condition_variable cv;
};
MutexLock mutex;
int count = 0;
void increment(int thread_id) {
for (int i = 0; i < 10000; ++i) {
mutex.lock(); // 获取互斥锁
++count; // 临界区代码
mutex.unlock(); // 释放互斥锁
}
}
int main() {
std::thread t1(increment, 1);
std::thread t2(increment, 2);
t1.join();
t2.join();
std::cout << "Final Count: " << count << std::endl;
return 0;
}
10 unique_ptr和lock_guard的区别
unique_lock
是另外一种封装类,相比于 lock_guard
,它提供了更灵活的锁定和解锁操作,支持将锁定操作延迟到某个时刻执行,以及在锁定期间释放锁定,等待其他线程完成一些操作后再重新锁定。除此之外,unique_lock
还支持线程间转移所有权,即把已经获得的锁的所有权转移给其他线程,以达到避免死锁、增加并发性的目的。
C++多线程unique_lock详解-腾讯云开发者社区-腾讯云 (tencent.com)
unique_lock用来解决lock_guard的锁的粒度太大的问题
11 unique_ptr怎么给另外一个unique_ptr赋值
move移动语义
12 线程池参数及底层实现
线程池参数
线程池是一种用于管理线程的技术,可以把一些可以并行执行的任务提交到线程池中,由线程池中的线程进行执行。线程池通常包括以下参数:
- 管理的线程数量:线程池中线程的数量,一般根据系统资源情况和线程负载来确定。
- 任务队列容量:线程池中的任务队列容量,如果任务数量超过队列容量,则可能需要排队或丢弃一些任务。
- 线程池类型:例如固定大小线程池、可变大小线程池等。
- 策略:例如任务拒绝策略、核心线程超时策略等。
线程池底层实现
c11最简单的线程池实现_c实现线程池_osDetach的博客-CSDN博客
ThreadPool.h
#pragma once
#include <iostream>
#include<stdlib.h>
#include<thread>
#include<mutex>
#include<condition_variable>
#include<vector>
#include<functional>
#include<queue>
#define N 10
using namespace std;
class ThreadPool{
public:
//自定义void()的函数类型
typedef function<void()>Task;
ThreadPool();
~ThreadPool();
public:
size_t initnum;
//线程数组
vector<thread>threads ;
//任务队列
queue<Task>task ;
//互斥锁条件变量
mutex _mutex ;
condition_variable cond ;
//线程池工作结束时为真
bool done ;
//队列是否为空
bool isEmpty ;
//队列是否为满
bool isFull;
public:
void addTask(const Task&f);
void start(int num);
void setSize(int num);
void runTask();
void finish();
};
ThreadPool.cpp
#include"ThreadPool.h"
ThreadPool ::ThreadPool():done(false),isEmpty(true),isFull(false){
}
//设置池中初始线程数
void ThreadPool::setSize(int num){
(*this).initnum = num ;
}
//添加任务
void ThreadPool::addTask(const Task&f){
if(!done){
//保护共享资源
unique_lock<mutex>lk(_mutex);
//要是任务数量到了最大,就等待处理完再添加
while(isFull){
cond.wait(lk);
}
//给队列中添加任务
task.push(f);
if(task.size()==initnum)
isFull = true;
cout<<"Add a task"<<endl;
isEmpty = false ;
cond.notify_one();
}
}
void ThreadPool::finish(){
//线程池结束工作
for(size_t i =0 ;i<threads.size();i++){
threads[i].join() ;
}
}
void ThreadPool::runTask(){
//不断遍历队列,判断要是有任务的话,就执行
while(!done){
unique_lock<mutex>lk(_mutex);
//队列为空的话,就等待任务
while(isEmpty){
cond.wait(lk);
}
Task ta ;
//转移控制快,将左值引用转换为右值引用
ta = move(task.front());
task.pop();
if(task.empty()){
isEmpty = true ;
}
isFull =false ;
ta();
cond.notify_one();
}
}
void ThreadPool::start(int num){
setSize(num);
for(int i=0;i<num;i++){
threads.push_back(thread(&ThreadPool::runTask,this));
}
}
ThreadPool::~ThreadPool(){
}
main.cpp
#include <iostream>
#include"ThreadPool.h"
void func(int i){
cout<<"task finish"<<"------>"<<i<<endl;
}
int main()
{
ThreadPool p ;
p.start(N);
int i=0;
while(1){
i++;
//调整线程之间cpu调度,可以去掉
this_thread::sleep_for(chrono::seconds(1));
auto task = bind(func,i);
p.addTask(task);
}
p.finish();
return 0;
}
13 多线程传输大量的数据怎么办
在多线程中一次性传输大量的数据可以通过以下两种方式实现:
- 使用缓存。可以将需要传输的数据分为多个块,每个线程读取一部分数据,并写入到一个共享的缓存中。等所有线程都将数据写入缓存后,再使用一个线程将缓存中的数据一次性写入到目标文件或目标地址中去。
这种方法可以减少磁盘IO和网络IO操作的次数,从而提高传输效率。
- 利用管道(Pipe)。可以使用系统提供的管道(Pipe)机制,在多个线程之间建立起管道通信的连接。其中一个线程负责从文件或源地址中读取数据,并将数据写入到管道中;另一个线程则从管道中读取数据,并将数据写入到目标文件或目标地址中去。这样就可以一次性传输大量的数据,而不用拆分成多个块进行传输。
需要注意的是,第二种方式适合于数据量较大、但并不是特别巨大的情况,如果数据量过于庞大,则使用管道方式可能会导致进程之间的通信延迟较大,从而降低了传输效率。
14 单线程什么情况下 需要加锁
进行一些文件操作
15 多进程和多线程的区别
多进程和多线程都是并发编程的方式,但它们有以下区别:
- 进程是资源分配的最小单位,而线程是程序执行的最小单位。一个进程可以包含多个线程,而一个线程只能属于一个进程。
- 进程间的通信需要通过特定的IPC(Inter-Process Communication)机制来完成,如管道、消息队列、共享内存等。线程之间共享进程的内存和资源,因此线程间通信相对简单,可以使用锁、条件变量等同步工具。
- 进程间切换的代价比线程大,因为进程间切换必须保存和恢复更多的上下文信息,如地址空间、文件描述符、进程ID等。而线程切换时只需保存和恢复少量寄存器即可。
在应用场景上,多进程适合于CPU密集型、独立任务的处理,如图像处理、科学计算等;多线程适合于IO密集型、并发性较高的任务,如Web服务器、数据库等。一般来说,多进程模型更稳定、可靠,但开销也更大,而多线程模型更轻量、灵活,但对程序员编写代码的质量要求更高。
16 协程的底层实现原理
协程 数字生成器
#include <iostream>
#include <coroutine>
using namespace std;
struct Generator {
struct promise_type;
using handle_t = coroutine_handle<promise_type>;
Generator(const Generator&) = delete;
Generator& operator=(const Generator&) = delete;
~Generator() {
if (m_handle) {
m_handle.destroy();
}
}
struct promise_type {
int value;
auto get_return_object() {
return Generator(handle_t::from_promise(*this));
}
auto initial_suspend() {
return suspend_always();
}
auto final_suspend() noexcept {
return suspend_always();
}
void return_void() {}
auto yield_value(int v) {
value = v;
return suspend_always();
}
void unhandled_exception() {
terminate();
}
};
bool next() {
if (m_handle) {
m_handle.resume();
return !m_handle.done();
}
return false;
}
int value() const {
return m_handle.promise().value;
}
private:
Generator(handle_t h)
: m_handle(h) {}
private:
handle_t m_handle;
};
Generator f(int n) {
int value = 1;
while (value <= n) {
co_yield value++;
}
}
int main() {
Generator g(f(10));
while (g.next()) {
cout << g.value() << ' ';
}
cout << '\n';
system("pause");
return 0;
}
17 线程join和detach的应用场景
detach
是线程的一种状态,它将一个线程从主线程中分离出来,使其能够在后台运行而不会影响主线程。detach
后的线程将变得脱离于主线程,主线程无需等待该线程的结束。
下面是一些使用 detach
的常见应用场景:
- 日志记录:在大多数情况下,日志记录可以作为一个后台任务进行,主线程不需要等待日志写入或处理完成。通过将日志逻辑放在一个¥¥的线程中,并将其
detach
,可以确保日志记录不会对主线程的性能产生显著影响。
- 定时任务:如果你有一些定时任务需要执行,但与主线程的执行没有关联,可以将这些任务放到一个¥¥的线程中进行处理,并
detach
该线程。这样主线程就可以继续执行其他任务,而不会受到定时任务的阻塞。
- 数据处理:当进行长时间的数据处理,例如读取大型文件、网络请求或复杂的计算任务时,可以将这些耗时任务放在¥¥的线程中进行,并
detach
以避免主线程被阻塞,从而提高程序的响应性。
- 并发任务:在并发编程中,有时候我们需要创建一个新线程去执行某个函数,但主线程不关心该线程的返回结果,也不需要等待其结束。在这种情况下,可以将新线程
detach
,使其能够¥¥运行,以便并发地处理任务。
总之,使用 detach
可以将一个线程与主线程分离,并使其在后台¥¥运行。这对于一些后台任务、定时任务、数据处理和并发编程场景非常有用,能够提高程序的并发性和响应性。请注意,在 detach
之后,你不再具有对该线程的控制权,因此需谨慎使用,避免可能的资源泄漏或竞态条件。
18 weak_ptr的接口函数
- lock接口
- reset接口
- expired接口
19 thrad_local作用
七、设计模式
01 手写单例模式
饿汉式单例和懒汉式单例是两种常见的单例模式实现方式,它们的应用场景有所不同。
饿汉式单例是指在类加载的时候就已经创建好了单例对象,因此不存在线程安全问题,可以直接使用。适用于单例对象较小且在系统运行期间必然会被多次调用的情况。例如,日志记录器、配置文件管理器等。
懒汉式单例是指单例对象在第一次被使用时才创建,因此需要进行线程安全考虑。适用于单例对象较大且不一定每次都会被调用的情况。例如,数据库连接池、网络连接池等。
需要注意的是,在多线程环境下,懒汉式单例可能会出现线程安全问题,需要采取相应的措施进行保护,比如使用 synchronized 或 Lock 等机制来保证线程安全。而饿汉式单例由于在类加载的时候就创建了实例,不存在线程安全问题,因此更加简单易用。但是由于实例对象在系统启动时就会创建,可能会增加系统的启动时间,也会占用一定的内存空间,因此需要根据实际情况选择合适的实现方式。
懒汉式
延迟加载 当函数被首次访问才加载
#include<iostream>
using namespace std;
class SingleTon{
public:
static SingleTon* getIntance() {
if (instance == nullptr) {
instance = new SingleTon;
return instance;
}
}
private:
SingleTon() {
cout << "SingleTon" << endl;
}
static SingleTon* instance;
};
SingleTon* SingleTon::instance = nullptr;
int main() {
SingleTon* t1 = SingleTon::getIntance();
SingleTon* t2 = SingleTon::getIntance();
system("pause");
return 0;
}
饿汉式
一开始就实例化
#include <iostream>
using namespace std;
//饿汉式单例模式
class Singleton {
public:
static Singleton* Getinstance() {
return instance;
}
private:
Singleton() {
cout << "Singleton mode" << endl;
}
static Singleton* instance;
};
Singleton* Singleton::instance = new Singleton;
int main() {
Singleton* test1 = Singleton::Getinstance();
Singleton* test2 = Singleton::Getinstance();
system("pause");
return 0;
}
02 观察者模式
观察者模式(Observer Pattern)是一种常见的设计模式,用于实现对象之间的发布-订阅关系。在观察者模式中,当一个对象的状态发生改变时,所有依赖于它的对象将自动得到通知并更新。这种模式提供了一种简洁的方式,用于解耦发布者和订阅者之间的耦合关系。
以下是观察者模式的几个角色:
- 主题(Subject):也称为可观察对象或发布者,负责维护一组观察者,并在状态发生变化时通知观察者。
- 观察者(Observer):订阅主题的对象,在主题状态发生变化时接收通知并进行相应的更新操作。
- 具体主题(Concrete Subject):是主题的具体实现,维护观察者对象列表,并在状态变化时通知观察者。
- 具体观察者(Concrete Observer):是观察者的具体实现,定义了在接收到通知时所采取的具体行为。
观察者模式的工作流程如下:
- 主题对象维护一个观察者列表,并提供方法用于添加、删除和通知观察者。
- 观察者对象订阅感兴趣的主题,并提供一个更新方法,用于接收主题的通知,并根据需要进行相应的处理。
- 当主题的状态发生变化时,它会依次通知所有订阅的观察者对象。
- 每个观察者在接收到通知后会执行对应的更新操作。
观察者模式的优点包括:
- 解耦性:主题和观察者之间的关系被解耦,使得它们可以¥¥进行修改和扩展,而不会影响彼此。
- 扩展性:可以方便地添加或删除观察者对象,且主题对象不需要知道具体的观察者实现。
- 灵活性:观察者对象可以根据需要自由选择订阅感兴趣的主题。
观察者模式在很多场景中都有应用,例如图形界面的事件处理、消息传递系统、价格变动通知等。它可以帮助我们实现松耦合的软件系统,提高代码的可维护性和可扩展性。
Subject => Observer(Observer1 Observer2 Observer3)
首先,定义抽象基类Observer,然后写出它的派生类具体的观察者实现,提供回调的接口
其次,在Subject主题类中添加观察者和对应msgid的map绑定关系(unordered_map<int, list<Observer*>> _subMap;)
最后,根据不同的msgid,发送到对应的Observer
#include <iostream>
#include <string>
#include <unordered_map>
#include <list>
using namespace std;
// 观察者抽象类
class Observer
{
public:
// 处理消息的接口
virtual void handle(int msgid) = 0;
};
// 第一个观察者实例
class Observer1 : public Observer
{
public:
void handle(int msgid)
{
switch (msgid)
{
case 1:
cout << "Observer1 recv 1 msg!" << endl;
break;
case 2:
cout << "Observer1 recv 2 msg!" << endl;
break;
default:
cout << "Observer1 recv unknow msg!" << endl;
break;
}
}
};
// 第二个观察者实例
class Observer2 : public Observer
{
public:
void handle(int msgid)
{
switch (msgid)
{
case 2:
cout << "Observer2 recv 2 msg!" << endl;
break;
default:
cout << "Observer2 recv unknow msg!" << endl;
break;
}
}
};
// 第三个观察者实例
class Observer3 : public Observer
{
public:
void handle(int msgid)
{
switch (msgid)
{
case 1:
cout << "Observer3 recv 1 msg!" << endl;
break;
case 3:
cout << "Observer3 recv 3 msg!" << endl;
break;
default:
cout << "Observer3 recv unknow msg!" << endl;
break;
}
}
};
// 主题类
class Subject
{
public:
// 给主题增加观察者对象
void addObserver(Observer* obser, int msgid)
{
_subMap[msgid].push_back(obser);
/*auto it = _subMap.find(msgid);
if (it != _subMap.end())
{
it->second.push_back(obser);
}
else
{
list<Observer*> lis;
lis.push_back(obser);
_subMap.insert({ msgid, lis });
}*/
}
// 主题检测发生改变,通知相应的观察者对象处理事件
void dispatch(int msgid)
{
auto it = _subMap.find(msgid);
if (it != _subMap.end())
{
for (Observer *pObser : it->second)
{
pObser->handle(msgid);
}
}
}
private:
unordered_map<int, list<Observer*>> _subMap;
};
int main()
{
Subject subject;
Observer *p1 = new Observer1();
Observer *p2 = new Observer2();
Observer *p3 = new Observer3();
subject.addObserver(p1, 1);
subject.addObserver(p1, 2);
subject.addObserver(p2, 2);
subject.addObserver(p3, 1);
subject.addObserver(p3, 3);
int msgid = 0;
for (;;)
{
cout << "输入消息id:";
cin >> msgid;
if (msgid == -1)
break;
subject.dispatch(msgid);
}
system("pause");
return 0;
}
03 工厂模式的核心是什么
工厂模式的核心是将对象的创建和使用分离开来。它通过引入一个工厂类,负责创建对象并隐藏对象的实例化过程,使客户端代码与具体对象的创建逻辑解耦。
八、项目构建——cmake用法
- cmake_minimum_required 最低版本号确定
- project 项目名称
- add_subdirectory 加载子目录下的CMakeLists
- set 一些设置选项 设置路径等
- include_directories 头文件搜索路径
- link_directories 库文件搜索路径 local/inclue local/lib
- add_executable 指定生成可执行文件
- target link_libraries 执行可执行文件所需要依赖的库
01 cmake如何生成动态共享库
追问1 so库可以在运行时被代码调用么
更多推荐
所有评论(0)