C++内存管理
C++的构造函数,复制构造函数,和析构函数
构造函数:用于在创建对象时初始化对象。它没有返回类型,也不带返回语句。构造函数可以有参数,允许重载。
复制构造函数:用于创建一个新对象作为另一个同类型对象的副本。它接受一个指向同类型对象的引用(通常为const引用)作为参数。如果没有显式定义,编译器会生成一个默认的复制构造函数。
析构函数:用于在对象生命周期结束时进行清理工作,如释放分配的内存。析构函数也没有返回类型,也不带返回语句,且不能被重载。
深复制与浅复制:
浅复制:仅复制对象的指针成员,不复制指针指向的内容。因此,原对象和副本对象中的指针成员指向相同的内存地址。
深复制:不仅复制对象的指针成员,还复制指针指向的内容,确保原对象和副本对象完全独立。
构造函数和析构函数哪个能写成虚函数,为什么?
- 析构函数通常是虚的,以支持通过基类指针安全地删除派生类对象,从而避免资源泄露
- 构造函数不能是虚的,因为虚函数是通过虚函数表(vtable)实现的,而vtable是在对象构造过程中设置的,这意味着在构造函数执行时,对象的类型可能还不完全确定,因此不能调用虚函数。
C++数据结构内存排列
数组:在连续的内存区域中分配。每个元素紧挨着前一个元素,便于通过下标访问。
链表:每个节点通常包含数据和指向下一个节点的指针(双向链表则包含两个指针),节点在内存中不必连续。
二叉树:节点可能包含数据和两个指向子节点的指针,节点在内存中也不必连续。
结构体和类占用的内存:
结构体或类占用的内存是其所有成员(包括基类和成员变量)占用内存的总和,还需考虑对齐(padding)。
空类通常占用至少一个字节,以支持对象在内存中的唯一性。
虚函数和虚表的原理
虚函数
定义:虚函数是在基类中声明,并在派生类中可能被重写的成员函数。通过在函数声明前加上virtual关键字来标记一个函数为虚函数。
作用:虚函数允许在派生类中提供特定于该类的实现,同时保持通过基类指针或引用调用这些函数的灵活性。
虚表(Vtable)
定义:虚表是一个由编译器为每个包含虚函数的类自动生成的表。这个表存储了类中所有虚函数的地址。
结构:每个包含虚函数的类的对象(或派生类的对象)都有一个指向其类虚表的指针(通常称为vptr)。这个指针在对象创建时由编译器设置,并指向正确的虚表。
工作原理:当通过基类指针或引用调用虚函数时,程序首先查找该指针或引用所指向对象的vptr,然后通过vptr找到虚表,并在虚表中查找要调用的虚函数的地址。最后,程序使用这个地址来调用相应的函数。
虚函数和虚表的实现细节
构造函数和析构函数:构造函数不能是虚的,因为对象在构造过程中其类型尚未完全确定,且虚表是在构造函数执行期间设置的。析构函数通常是虚的,以确保通过基类指针删除派生类对象时能够调用正确的析构函数。
虚表继承:当派生类继承自基类时,如果基类有虚函数,派生类会继承基类的虚表,并在需要时扩展它以包含自己的虚函数。如果派生类重写了基类的虚函数,则派生类的虚表中相应函数的地址会被替换为派生类函数的地址。
纯虚函数和抽象类:纯虚函数是没有函数体的虚函数(用= 0标记)。包含至少一个纯虚函数的类被称为抽象类,它不能被实例化。纯虚函数用于在基类中定义一个接口,该接口必须由派生类实现。
虚函数和性能:虽然虚函数提供了强大的多态性支持,但它们可能会引入一些性能开销,因为每次调用虚函数时都需要通过vptr和虚表来查找函数地址。然而,在大多数现代编译器和处理器上,这种开销通常是可以接受的。
内存泄漏
时机和原因:
内存泄漏通常发生在动态分配了内存但未释放
分配的内存超出了程序的控制范围(如指针丢失、覆盖)。
如何避免:
使用智能指针(如std::unique_ptr, std::shared_ptr)自动管理内存,确保每次 new 后都有相应的 delete。
仔细检查所有内存分配和释放操作。
使用工具(如Valgrind)检测内存泄漏。
指针的工作原理
指针是一个变量,其存储的是另一个变量的内存地址。通过指针,可以间接访问和操作内存中的数据。
函数的传值和传址
传值:函数接收参数的副本。在函数内部对参数所做的任何修改都不会影响原始数据。
传址:函数接收指向实际参数的指针或引用。在函数内部对参数所做的修改会反映到原始数据上。
new和delete与malloc和free
new/delete:是C++操作符,用于动态内存分配和释放。new分配内存并调用构造函数(如果是对象),delete释放内存并调用析构函数(如果是对象)。
malloc/free:是C语言标准库函数,仅用于分配和释放内存,不调用构造函数或析构函数。
C++内存区域划分
栈(Stack):用于存储局部变量和函数调用信息。分配和释放速度快,但空间有限。
堆(Heap):动态存储期,使用new或malloc等函数分配的内存,容量大,分配和释放速度较慢。堆内存需要程序员手动管理(使用delete或free)。
全局/静态存储区:存储全局变量、静态变量等,在程序运行期间一直存在。
常量存储区:存储常量数据。
代码区:存储程序代码。
C++11新特性
C++常见新特性
C++自C++11以来,引入了许多重要的新特性,以下是一些主要的特性:
C++11:
auto 和 decltype:用于自动类型推导。
范围for:简化对容器的遍历。
移动语义和右值引用:减少数据拷贝,提高性能。
lambda 表达式:支持匿名函数对象。
智能指针(如std::unique_ptr和std::shared_ptr):自动管理内存。
并发支持:包括thread、mutex、condition_variable等。
C++14:
泛型 lambda:支持在 lambda 表达式中使用模板参数。
变量模板:允许模板不仅是类或函数,还可以是变量。
C++17:
结构化绑定:允许直接对元组或结构体进行解构赋值。
if constexpr:在编译时进行条件判断。
std::string_view:提供对字符串的高效只读访问。
std::optional 和 std::variant:提供更灵活的变量表示。
C++20:
模块:支持将代码组织成独立的模块,提高编译速度和封装性。
概念:允许对模板参数进行约束。
协程:支持协程的编写,简化异步编程。
三向比较操作符(spaceship operator,<=>):自动推导比较操作符。
C++23:
多维下标运算符:支持多维数组的直接下标访问。
显式对象形参:允许在成员函数中使用推导的this指针。
智能指针
智能指针是C++11引入的,用于自动管理内存的对象,防止内存泄漏。常见的智能指针包括:
std::unique_ptr:
独占所有权的智能指针,同一时间只有一个std::unique_ptr可以指向特定对象。
优点:安全,防止多个指针指向同一内存。
缺点:不支持拷贝构造和赋值,只能通过std::move转移所有权。
std::shared_ptr:
允许多个智能指针共享对同一对象的所有权。
优点:支持拷贝和赋值,自动处理生命周期。
缺点:可能导致循环引用,需要通过std::weak_ptr解决。
std::weak_ptr:
不控制对象生命周期,只提供对std::shared_ptr的弱引用。
优点:解决std::shared_ptr的循环引用问题。
缺点:不能直接访问对象,需要通过lock方法获取std::shared_ptr。
auto关键字
auto 关键字用于自动类型推导,在声明变量时根据初始化表达式的类型自动为变量选择合适的类型。使用auto可以简化代码,提高可读性,特别是在处理复杂类型时。
是否全部用auto声明变量:
优点:简化代码,避免冗长的类型名称。
缺点:在某些情况下可能降低代码的可读性,特别是当自动推导的类型与预期不符时。因此,建议仅在类型冗长复杂或变量使用范围专一时使用auto。
Lambda表达式
Lambda表达式是C++11引入的一种匿名函数对象,可以捕获外部变量并在函数体内使用。Lambda表达式的基本语法为[捕获列表] (参数列表) -> 返回类型 { 函数体 }。
用法示例:
auto lambda = [](int x, int y) -> int { return x + y; };
std::cout << lambda(1, 2) << std::endl; // 输出 3
override关键字
override 关键字在C++11中引入,用于明确表示派生类中的成员函数覆盖了基类中的虚函数。使用override可以帮助编译器检查是否正确地覆盖了基类中的虚函数,从而提高代码的安全性和可维护性。
是否必须:不是必须的,但强烈建议使用,因为它可以增加代码的可读性和可维护性,并帮助编译器在编译时检查潜在的错误。
右值引用
右值引用是C++11中引入的一种新特性,用于表示即将被销毁的临时对象或不可修改的值。右值引用通过类型后加&&表示,例如int&&。
应用场景:
移动语义:通过右值引用和移动构造函数/移动赋值操作符,可以实现资源的高效转移,避免不必要的拷贝。
完美转发:结合模板和引用折叠规则,可以实现参数的完美转发,保持参数的左值/右值属性不变。
实际应用:
在实现容器类、智能指针等需要管理资源的类时,可以使用右值引用来优化资源转移。
在编写模板代码时,可以使用右值引用来实现完美转发,提高代码的通用性和灵活性。
面试题总结
右值引用的应用与优点
应用:
- 右值引用用于实现移动语义,避免不必要的数据复制。它特别适合于处理临时对象和大型资源管理(如动态分配内存、文件句柄等)。
- 使用右值引用的主要场景是在自定义类的移动构造函数和移动赋值运算符中。
优点:
- 性能提升:通过移动资源而不是拷贝资源,减少了内存分配和数据复制的开销。
- 支持完美转发:结合
std::forward
可以在模板中实现参数的完美转发,从而保持传入参数的值类别。 - 简化资源管理:右值引用使得编写高效的资源管理类变得更加容易,比如实现一个自定义的容器或智能指针。
Lambda表达式
定义:Lambda表达式是一种能够定义匿名函数对象的方式,可以用来捕获外部变量并形成闭包。
基本语法:
[capture](parameters) -> return_type { // function body }
- 示例:
auto add = [](int a, int b) { return a + b; };
用途:
- 常用于 STL 算法,如
std::sort
、std::for_each
等。 - 用作回调函数,提高代码的可读性和简洁性。
- 常用于 STL 算法,如
空类的占用的内存
- 在C++中,一个空类至少占用1个字节的内存。这是为了确保每个对象都有独特的地址,即便它不包含任何数据成员。这也符合C++标准中定义的对象大小至少为1字节的要求。
结构体的占用的字节数
- 和类一样,C++中的结构体默认占用至少1个字节,其实际占用的字节数会因对齐和填充的需要而有所不同。如果结构体中有成员,则其大小为所有成员大小之和加上可能的填充字节。
虚函数与虚表
虚函数:允许子类重写父类的方法,实现多态。只有当基类中定义了虚函数,派生类才能重写该函数。
虚表:每个含有虚函数的类都有一个虚表(vtable),其中存储着指向该类的虚函数的指针。每个类的实例有一个指向该虚表的指针(即虚指针 vptr),用于在运行时确定调用哪个函数。
虚析构函数的作用
- 作用:
- 当通过基类指针删除派生类对象时,如果基类的析构函数不是虚的,那么只会调用基类的析构函数,这可能导致派生类的资源没有被释放,产生内存泄漏。
- 使用虚析构函数可以确保当删除基类指针指向的派生类对象时,会正确地调用派生类的析构函数,从而释放派生类所占用的资源。
传值和传址,深复制和浅复制
传值:
- 将对象的副本传递给函数。在此情况下,函数内部对参数的修改不会影响原对象。
传址:
- 传递对象的地址(指针或引用),允许函数直接操作原对象,函数内部的修改会影响原对象。
深复制:
- 复制对象及其指向的动态分配内存,确保两个对象之间完全独立。通常在类中实现自定义的拷贝构造函数和赋值运算符。
浅复制:
- 只复制对象的基本信息,指向同一块内存。当一个对象被修改或析构时,另一个对象也可能受到影响,导致未定义行为或资源冲突。
以下是有关C++的一些面试问题的详细回答:
前置++和后置++的本质区别与原理
前置++ (
++i
):- 操作:首先将
i
的值增加1,然后返回i
的新值。 - 原理:直接在原对象上进行操作,通常通过引用或指针返回。
- 操作:首先将
后置++ (
i++
):- 操作:首先返回
i
的当前值,然后将i
的值增加1。 - 原理:为了实现这个功能,后置++ 通常会创建一个临时对象来保存原始值,返回该对象,并在完成后再增加
i
的值。
- 操作:首先返回
总结:
- 前置++更高效,因为它不需要创建临时对象,而后置++则需要额外的内存开销。
map与set的原理及其他容器
map:
- 通常实现为红黑树(平衡二叉搜索树),存储键值对,提供O(log n)的查找、插入和删除性能。
- 关键特点:每个键唯一,自动排序。
set:
- 类似于map,但只存储唯一的元素,通常也是基于红黑树实现。
- 提供O(log n)的查找、插入和删除,且元素自动排序。
其他容器:
- vector:动态数组,支持随机访问,增长时可能涉及内存重新分配。
- deque:双端队列,支持两端插入和删除。
- list:双向链表,支持快速插入和删除,但不支持随机访问。
C++内存空间划分,值类型和引用类型,C#的垃圾回收原理
内存空间划分:
- 堆:用于动态分配的内存,通过
new
和delete
管理。 - 栈:用于局部变量的存储,自动管理内存的分配和释放。
- 全局/静态存储区:存放全局变量和静态变量,程序运行期间存在。
- 常量存储区、代码区。
- 堆:用于动态分配的内存,通过
值类型和引用类型:
- 值类型:直接存储数据,例如基本数据类型(int, char等)和结构体。
- 引用类型:存储对数据的引用,如指针和引用。对引用类型的修改会影响原始对象。
C#的垃圾回收原理:
- C#的垃圾回收(GC)自动管理堆上的内存,通过可达性分析算法确定哪些对象不再被使用,并回收它们占用的内存。GC使用代际假说来优化性能,优先回收较年轻代中的对象。这一机制减少了内存泄漏的风险,并简化了内存管理。
new与delete的本质及与malloc和free的区别
new:
- 在堆上分配内存,同时调用构造函数。
- 示例:
MyClass* obj = new MyClass();
delete:
- 调用析构函数并释放内存。
- 示例:
delete obj;
malloc:
- 仅申请指定大小的内存,不调用构造函数。
- 示例:
void* ptr = malloc(sizeof(MyClass));
free:
- 仅释放内存,不调用析构函数。
- 示例:
free(ptr);
区别总结:
new
和delete
是C++运算符,可以自动调用构造/析构函数;而malloc
和free
是C标准库函数,仅负责内存分配和释放,不处理对象生命周期。
内存泄漏的原因及避免方法
造成原因:
- 动态分配内存但未及时释放,例如忘记调用
delete
或free
,造成程序占用内存逐渐增大。
- 动态分配内存但未及时释放,例如忘记调用
避免方法:
- 使用智能指针(如
std::unique_ptr
和std::shared_ptr
)自动管理资源的生命周期,确保在超出作用域时自动释放内存。 - 定期检查代码中是否存在动态分配的内存未释放的情况,可以使用工具如Valgrind进行内存使用分析。
- 使用智能指针(如
以下是针对C++面试问题的详细回答:
智能指针有哪些
C++11引入了几种智能指针,主要包括:
std::unique_ptr
:- 独占式智能指针,每个指针只能由一个
unique_ptr
拥有,不能被复制,可以通过std::move
进行转移。 - 会自动在指针超出作用域时释放内存。
- 独占式智能指针,每个指针只能由一个
std::shared_ptr
:- 共享式智能指针,允许多个
shared_ptr
共享同一块内存。使用引用计数管理资源的生命周期,当最后一个shared_ptr
被销毁时,内存才会释放。
- 共享式智能指针,允许多个
std::weak_ptr
:- 弱引用智能指针,与
shared_ptr
配合使用,不增加引用计数,防止循环引用的问题。可以从一个shared_ptr
获得,其目的在于观察而不控制对象的生命周期。
- 弱引用智能指针,与
动态链接库和静态链接库的区别
静态链接库(Static Library):
- 在编译时将库的代码直接与应用程序的代码连接到一起。生成的可执行文件(.exe)包含所有必要的库代码。
- 优点:无需依赖外部库文件,部署过程简单。
- 缺点:可执行文件体积较大,更新库时需重新编译应用程序。
动态链接库(Dynamic Link Library, DLL):
- 在运行时加载,程序可以在运行时调用DLL中的函数。DLL文件与可执行文件分开,多个应用可以共享同一个DLL。
- 优点:可减小可执行文件体积,便于版本更新和共享。
- 缺点:需确保DLL文件存在且版本兼容。
关于EXE与链接:
- 一般情况下,生成的
.exe
文件是静态链接的,而.dll
文件是动态链接的。这意味着EXE在编译时已将所需的库代码集成,但在运行时可能仍然可以调用动态库。
- 一般情况下,生成的
二维指针
- 二维指针通常表示一个指向指针的指针,例如:
int **p
可以用来表示一个二维数组或数组的数组。 - 实际上,二维指针在内存中是一个指向指针数组的指针,每个指针又指向一个具体的行数据。
int rows = 5;
int cols = 4;
int **array = new int*[rows]; // 创建一维指针数组,用于指向每行
for (int i = 0; i < rows; i++) {
array[i] = new int[cols]; // 为每行分配列数
}
// 使用完后记得释放内存
for (int i = 0; i < rows; i++) {
delete[] array[i];
}
delete[] array;
指针和引用的区别
定义方式:
- 指针使用
*
符号定义,如int *ptr;
- 引用则使用
&
符号定义,如int &ref = var;
- 指针使用
空值:
- 指针可以为
nullptr
或指向某个有效地址。 - 引用必须在初始化时绑定到某个对象,不能为null。
- 指针可以为
重新赋值:
- 指针可以重新赋值,指向不同的对象。
- 引用一旦绑定到某个对象后就不可改变,如要改变需要使用新的引用。
语法:
- 使用指针时需要解引用操作符
*
,如*ptr
。 - 使用引用时不需要解引用,直接使用引用名即可。
- 使用指针时需要解引用操作符
宏定义,常见的宏指令
#define
:- 定义宏常量或宏函数。例如:
#define PI 3.14
或#define SQUARE(x) ((x)*(x))
- 定义宏常量或宏函数。例如:
#ifdef
/#ifndef
:- 用于条件编译,即根据是否定义了某个宏来决定是否编译某段代码。
- 例如:
#ifdef DEBUG std::cout << "Debug mode" << std::endl; #endif
#if
/#else
/#elif
:- 也是用于条件编译,根据表达式的值来选择编译哪部分代码。
#include
:- 引入头文件
#undef
:- 用于取消之前定义的宏。例如:
#undef PI
取消对PI
的定义。
- 用于取消之前定义的宏。例如:
#pragma
:- 用于向编译器发送特殊指令,可以控制编译器的某些行为,如警告等级、优化选项等。例如:
#pragma once // 防止头文件被多次包含
- 用于向编译器发送特殊指令,可以控制编译器的某些行为,如警告等级、优化选项等。例如:
#line
:- 用于改变编译器报告的行号和文件名,主要用于调试目的。
#error
:- 触发编译错误,可以在特定条件下提示开发者。例如:
#ifdef DEBUG #error "Debug mode is enabled!" #endif
- 触发编译错误,可以在特定条件下提示开发者。例如:
null 与 nullptr 的区别
null
:null
是一个常量,通常用于表示空指针。在 C++ 中,null
实际上是一个宏,代表整数常量 0。使用null
时可能会引发类型不明确的问题,因为它可以隐式转换为任何指针类型。
nullptr
:- C++11 引入的新关键字,用于表示空指针。与
null
不同的是,nullptr
是一个类型安全的指针常量,不能隐式转换为整数或其他类型。这使得在函数重载中更清晰,加上nullptr
使得代码在处理空指针时更加安全。
- C++11 引入的新关键字,用于表示空指针。与
int* p1 = nullptr; // 正确
int* p2 = null; // 在C++标准中,推荐使用nullptr,null可能会导致警告