c/c++面试总结
1. NULL 和 nullptr 区别
int overLoadTest(int x) {
cout << __LINE__ << endl;
return 0;
}
int overLoadTest(char* x) {
cout << __LINE__ << endl;
return 0;
}
int main() {
char x[10] = {1,2,3,4,5};
overLoadTest(1);
overLoadTest(x);
overLoadTest(nullptr);
// overLoadTest(NULL); // ambiguous
return 0;
}
- 结论:
-
- NULL 的定义 实际是 0;在重载函数中传入 NULL 具有二义性,编译器无法对 0 和 空指针类型区分
-
- nullptr 是c++11引入的安全的空指针字面量,不会被隐式转换为其他非指针类型,将去除二义性,可以安全的赋值给指针类型。
2. static 作用
- static 修饰变量或函数改变该变量的作用域和生命周期。使其仅在该文件中可见,有效隐藏了static变量,可以避免命名冲突、降低代码耦合度。
static函数存储在代码段
static变量存储在全局数据段 - 底层实现
符号本地化:
` 编译:编译器生成的符号具有内部链接属性,只在当前编译单元可见
· 链接:链接器不会将static符号输出到其他模块,也不会将其他模块同名函数输入。
符号解析:
· 链接器在链接阶段会解析符号引用,对于static符号,只会查找当前模块内的定义,不会从其他模块寻找。
· 如果其他文件尝试引用static类型符号,链接器会报为解析符号引用,因为该符号不会出现在其他模块符号表中。
3. free()函数只传入一个地址,为什么知道释放空间大小
malloc分配的内存为一个个chunk,定义如下:
struct malloc_chunk {
size_t prev_size; /* 前一个内存块的大小(如果合并的话) */
size_t size; /* 当前内存块的大小,包括边界标记 */
struct malloc_chunk *fd; /* 指向前一个空闲内存块的指针(用于空闲内存列表) */
struct malloc_chunk *bk; /* 指向下一个空闲内存块的指针(用于空闲内存列表) */};
图片来自wx公众号“CppPlayer”
malloc分配内存后返回的指针不是指向Header,而是指向Payload的起始位置,所占用的空间大小记录在参数指针指向地址的前面,所以知道需要释放内存的大小
几个小知识:
malloc不是系统调用,通过 brk()从堆区分配内存 或 mmap()在文件映射区域分配虚拟内存。
只有当程序首次访问这片地址,操作系统通过MMU将虚拟地址转为物理地址,更新页表完成映射;如果这片内存实际没有使用过,不会分配实际的物理空间,节约内存。
调用free,内存不会马上被操作系统回收。为了减少与操作系统内存交互次数,降低系统开销。
首先会被内存管理器使用双链表等方式保存起来,有助于减少内存碎片和提高内存使用效率。
4. main()函数执行前还会执行什么代码
- 初始化全局变量
- 执行 全局对象 的构造函数
- 类内部声明的 静态成员对象 属于整个类而不是类的实例,因此提前初始化
- c运行时库初始化通常包括 设置缓冲区和初始化堆。c++可能进行初始化操作:设置运行环境、配置标准输入输出…
5. 堆和栈的数据访问速度
栈通常比堆块。
栈的数据存储在连续的内存单元,访问速度快;堆的数据存储在分散的存储空间,需要额外的指针解引用操作
栈特点:
自动管理:内存分配和回收由编译器自动处理
连续内存:栈内存一般连续,有助于优化cpu缓存
快速分配和回收:栈管理简单,分配和回收速度快
固定大小:栈大小通常在程序运行前就已经决定,超出后会发生栈溢出。
寻址方式:直接寻址
堆特点:
动态分配:显示分配
非连续内存:堆分配内存一般不连续,因为堆可以被分成多个部分,每部分可以独立回收和分配(内存碎片产生原因)
寻址方式:间接寻址
6. 构造函数不能为虚函数
- 从代码层面看
虚函数表中存储了一个类所有虚函数的地址,它允许通过基类的指针调用派生类函数,实现运行时多态。
虚表存储在只读数据段,虚函数表地址在编译时被确定,并链接到程序的二进制文件。
当一个类被创建时,才会有虚表指针指向虚函数表,对象在没有初始化完成的时候不知道虚表位置,自然不能访问虚表内容 - 从设计角度看
虚表的存在主要是为了解决 编译期间没办法确定具体调用对象的问题,但c++在编译期间就能确定要创建的对象具体类型,没有意义 - 使用角度看
虚函数主要用于父类调用,构造函数主要自己调用
除了构造函数还有哪些函数不能是虚函数?
1. 普通函数(不属于类的实例)
2. 内联函数(内联函数作用是将函数调用替换为函数体代码,和虚函数运行时动态绑定冲突)
3. 静态成员函数(不属于类的实例)
4. 友元函数(不属于类的实例)
5. 模板函数中的成员函数(模板中的成员函数默认内联)
6. 私有成员函数(没有使用意义)
7. 结构体可以直接赋值吗
声明时可以直接初始化,同一结构体的不同对象直接也可以直接赋值;
之前项目中清空结构体内容的方式就是赋空结构体值(无指针情况)
手撕c++string 类
class String {
char* m_data; // 字符串
public:
String(const char*str = nullptr); // 普通构造函数
String(const String& other); // 拷贝构造函数
~String(); // 析构函数
String & operator =(const String& other); // 赋值构造函数
String operator +(const String& other) const; // 重载运算符+
friend std::ostream& operator<<(std::ostream& os, const String& s); // 重载运算符<<
};
String::String(const char*str) {
if(str == nullptr) {
m_data = new char[1];
*m_data = '\0';
} else {
int len = strlen(str);
m_data = new char[len + 1];
strcpy_s(m_data, len + 1, str);
}
}
String::String(const String& other) {
int len = strlen(other.m_data);
m_data = new char[len + 1];
strcpy_s(m_data, len + 1, other.m_data);
}
String::~String() {
delete m_data;
}
String & String::operator =(const String& other) {
// 检查自赋值
if(this == &other) {
return *this;
}
delete[]m_data;
int len = strlen(other.m_data);
m_data = new char[len + 1];
strcpy_s(m_data, len+1, other.m_data);
return *this;
}
String String::operator+(const String& other) const{
if(other.m_data == '\0') {
return String(other);
}
int len = strlen(other.m_data);
int thisLen = strlen(m_data);
char* tmp = new char[len + thisLen + 1];
strcpy_s(tmp, thisLen + 1, m_data);
strcpy_s(tmp + thisLen, len + 1, other.m_data);
String s(tmp);
delete tmp;
return s;
}
std::ostream& operator<<(std::ostream& os, const String& s) {
os << s.m_data;
return os;
}
int main() {
String s("123");
String s1(s);
String s2 = s1;
String s3 = s + s1 + s2;
cout << s << " ";
cout << s1 << " ";
cout << s2 << " ";
cout << s3 << endl;
return 0;
}
8. explicit的作用
class Example {
public:
explicit Example(int x) {
cout << "Constructor called with" << x << endl;
}
};
void fun(Example e){
cout << "function suc" << endl;
return;
}
int main() {
fun(42); // error: could not convert '42' from 'int' to 'Example'
return 0;
}
如果不加 explicit ,这段程序是可以正常运行的
C++ 允许使用单参数构造函数来进行隐式类型转换
fun()会将 ‘42’ 隐式转换成Example类型
加上后就会出错,不能进行隐式转换
作用:防止隐式转换的发生,避免产生一些意外行为。
如下代码定义了类型转换函数operator int(),允许将对象直接转换成int类型,但如果加了 explicit修饰,此时隐式转换失效,必须使用显示转换static_cast;
class Example {
int value_;
public:
// explicit Example(int x) {
Example(int x):value_(x) {
cout << "Constructor called with " << x << endl;
}
explicit operator int() {
return value_;
}
};
void fun(Example e){
cout << "function suc" << endl;
return;
}
int main() {
fun(42);
Example obj(42);
// int intValue = obj; // 不存在从 "Example" 到 "int" 的适当转换函数
int intValue = static_cast<int>(obj);
cout << intValue << endl;
return 0;
}
9. 类型转换函数
1. static_cast
主要用于编译时的静态类型转换
适用场景:
- 基本数据类型之间的转换(int、double)
- 指针类型的上行转换(即从派生类指针转换为基类指针)
- 枚举类型转换为整数
- 可以用于任何不会改变对象底层二进制表示的转换
注意事项:
不能用于不同类型的指针之间(如 int* 转 double*)。
无法在运行时捕捉到转换是否失败,因此转换过程是基于类型信息的静态分析。
2. dynamic_cast
用于运行时的动态类型转换,会在运行时执行类型检查,因此它只能用于带有虚函数的多态类型。主要用于设涉及继承层次的指针和引用间的转换
适用场景:
- 用于基类指针(引用)向派生类指针(引用)的转换(即下行转换)
- 运行时安全地判断是否可以进行这种转换,若转换失败则返回 nullptr(对于指针)或抛出异常(对于引用)。
注意事项:
- 仅对带有虚函数的多态类有效。
- 运行时开销较大,因为需要检查类型安全性。
- 仅适用于指针或引用之间的转换。
class Base {
virtual void fun() {} // 必须多态类
};
class Derived : public Base {
public:
void derived_fun() {
cout << "Derived class function called." << endl;
}
};
class OtherClass : public Base{
};
void static_cast_test() {
Base* basePtr = new Derived;
Derived* derivedPtr = static_cast<Derived*>(basePtr);
if(derivedPtr){
derivedPtr->derived_fun();
}
delete basePtr;
}
void dynamic_cast_test() {
Base* basePtr = new Derived;
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr);
if(derivedPtr) {
derivedPtr->derived_fun();
} else {
cout << "dynamic_cast failed." << endl;
}
delete derivedPtr;
Base* otherBasePtr = new OtherClass;
Derived* failedCast = dynamic_cast<Derived*>(otherBasePtr);
if(!failedCast) {
cout << "dynamic_cast failed as expected" << endl;
}
failedCast = static_cast<Derived*>(otherBasePtr);
if(failedCast) {
cout << "static_cast suc" << endl;
failedCast->derived_fun();
} else {
cout << "static_cast failed." << endl;
}
delete otherBasePtr;
return;
}
int main() {
static_cast_test();
dynamic_cast_test();
return 0;
}
在chatgpt的帮助下学习static_cast和dynamic_cast的区别,又是被ai教育的一天
分析一下:
定义了三个类,其中Derived类、OtherClass都继承自Base类,且相互独立。
static_cast_test()主要展示了static_cast用法,其实一般用于上行转换,这里用了下行,也可以,编译器不会进行类型检查,只要类型在继承层次中存在关系就可以转换,默认我知道自己在做什么,然而,如果指针的实际类型不是派生类,使用 static_cast 进行类型转换会导致未定义行为。
dynamic_cast_test()使用了dynamic_cast进行类型转换,并在失败时输出错误信息,这里展示了dynamic可以用于安全的下行转换,并且在类型不匹配时(failedCast)返回nullptr,避免未定义行为。
此时再用static_cast对otherClass进行转换,发现可以正确输出信息,但OtherClass和Derived之间没有继承关系,编译器没有发现这个错误,仍然调用了fun函数,但此时发生的是未定义行为,并不能保证在所有环境下都会这样工作
未定义行为意味着程序可以表现出任何不确定的结果,通常情况下:
- 程序可能会崩溃。
- 可能会输出错误的或不可预期的结果。
- 甚至可能会成功输出正确的结果(但这只是表象,实际行为是不安全的)。
总结
- 代码中 static_cast 强制转换了不相关类型(OtherClass 转换为 Derived),尽管编译通过并且运行后输出了结果,但这属于未定义行为,不应被依赖。
- 输出 “static_cast suc” 和 “Derived class function called” 只是未定义行为的一种表现,不能保证程序在不同环境下的行为是一致的。在实际生产代码中,这样的代码会被认为是不安全的,可能导致崩溃或潜在的漏洞。
- dynamic_cast 在这种场景下是更安全的选择,因为它会在运行时进行检查,确保 Base* 确实指向 Derived 对象。
3. const_cast
const_cast一般用于移除(添加)对象的 const 和 volatile 属性,也是唯一可以修改对象 const 和 volatile 属性的类型转换
一般用于c++与旧的c语言API兼容。如下
void C_API_fun(char* str) {};
void Cpp_API_fun(const char* str) {
C_API_fun(const_cast<char*>(str)); // // 调用不支持 const 的旧 C API
}
主要用途
- 移除(添加)对象的 const 限定符。
- 移除(添加)对象的 volatile 限定符。
但是,const_cast 只能对指针或引用进行类型转换,不能直接改变普通变量的 const 性质,否则产生未定义行为。
void const_cast_test() {
int x = 10;
const int* ptr = &x;
// *ptr = 20; // error: 表达式必须是可修改的左值C/C++(137)
int* modifiablePtr = const_cast<int*>(ptr);
*modifiablePtr = 20; // 移除对象const属性,修改普通对象值
const int y = 10;
modifiablePtr = const_cast<int*>(&y);
*modifiablePtr = 20; // 未定义行为,不允许修改const对象
return;
}
4. reinterpret_cast
通常用于在不同类型之间进行底层表示的强制转换。与其他类型转换运算符相比,reinterpret_cast 并不会执行类型检查或类型安全转换,它仅仅是将对象的位模式重新解释为另一种类型。
目的是允许程序员对指针、整数和不同类型的指针之间进行低级别的强制转换,但这种转换可能会带来风险,因为它不保证类型安全。
void reinterpret_cast_test() {
// 1. 指针与整数间的转换,以便进行位操作或存储地址信息
int i = 1;
uintptr_t intPtr = reinterpret_cast<uintptr_t>(&i);
int* ptr = reinterpret_cast<int*>(intPtr);
*ptr = 2;
// 2. 将对象的位模式重新解释为不同类型,在对象序列化、反序列化或二进制数据处理时使用
float f = 3.14f;
int* p = reinterpret_cast<int*>(&f);
cout << "Bit pattern: " << *p << endl;
// 3. 指针类型间的转换,底层编程
int i = 42;
void* p = reinterpret_cast<void*>(&i);
int* ip = reinterpret_cast<int*>(p);
// 4. 兼容旧代码: reinterpret_cast 常用于与低级硬件或系统交互的代码中,
// 或者与遗留代码进行兼容时进行特殊的类型转换
return;
}
reinterpert_cast和static_cast很像,只不过static_cast多了静态类型检查,例如 两个毫无关联的类对象指针在做转换时,static_cast会报错,但reinterpret_cast不会检查,可以正常转换,有安全风险。
10. 智能指针
本来打算在c++11新特性总结的,但既然四种类型转换都写了,就刚好把智能指针也写写。
智能指针是c++11引入的新特性,用于自动化内存管理,避免手动管理动态分配的内存,防止内存泄漏和悬空指针等问题。智能指针通过RAII机制,确保对象在超出作用域时自动调用析构函数,释放资源。
RAII(Resource Acquisition Is Initialization,资源获取即初始化)是一种资源管理的编程惯用法,旨在确保资源的正确分配和释放。
核心思想:将资源的分配和释放绑定到对象的生命周期上。
RAII的优势之一是:确保即使发生异常,也可以保证资源被正确释放。例如:函数中抛出异常,对象仍会在销毁前调用析构函数释放资源。可以有效地避免因异常导致的资源泄露。
常见应用:
智能指针、ifstream文件管理、lock_guard…
10.1 shared_ptr
用于共享所有权的智能指针,允许多个智能指针指向同一个动态分配的对象,并在最后一个shared_ptr 销毁时,自动释放内存资源。
shared_ptr实现依赖于 “control block”机制该机制负责引用计数管理和资源释放
10.1.1 基础结构
分为两部分:
- 裸指针:指向所管理的动态对象
- 控制块:包括资源的引用计数和删除器
class control_block {
private:
int shared_count; // 强引用计数
int weak_count; // 弱引用计数
void* resource; // 指向资源的指针
std::function<void(void*)> deleter; // 删除器(可以是默认的或自定义的)
...
}
10.1.2 make_shared不能使用自定义删除器
通过 make_shared 创建智能指针提供了一种更高效的内存分配方式——将control block和实际对象合并到同一内存块进行分配,可以减少内存碎片;这种策略将控制器和对象的生命周期绑定在一起,因此,内存的分配和释放策略也统一。同时,控制块没有足够的空间存储一个自定义删除其对象;因此在销毁时,shared_ptr使用默认删除器销毁。
class Test {
int age_;
public:
Test(int age) : age_(age) {}
~Test() {}
void displsy() {
cout << "age is " << age_ << endl;
}
};
void shared_ptr_test() {
shared_ptr<Test> ptr1 = make_shared<Test>(10);
ptr1->displsy();
cout << ptr1.use_count() << endl;
shared_ptr<Test> ptr2 = ptr1;
ptr1->displsy();
cout << ptr1.use_count() << " ";
cout << ptr2.use_count() << endl;
ptr1.reset();
// ptr1->displsy(); // ptr1已销毁
ptr2->displsy();
cout << ptr1.use_count() << " ";
cout << ptr2.use_count() << endl;
return;
}
10.1.3 循环引用
为什么产生循环引用
shared_ptr 的引用计数机制:当两个或多个对象 shared_ptr 相互引用时,引用计数不会降为 0,导致对象无法销毁
void circular_reference() {
shared_ptr<A> a = make_shared<A>();
shared_ptr<B> b = make_shared<B>();
a->ptr_B = b;
b->ptr_A = a;
cout << "Before reset: " << endl;
cout << "a.use_count() = " << a.use_count() << " ";
cout << "b.use_count() = " << b.use_count() << endl;
// 调用 reset() 试图手动释放 a 和 b
a.reset();
b.reset();
cout << "After reset: " << endl;
// reset() 后 a 和 b 的引用计数依然为 1,循环引用仍然存在
if (a) cout << "a.use_count() = " << a.use_count() << endl;
if (b) cout << "b.use_count() = " << b.use_count() << endl;
// 在函数结束时,引用计数不会归零,A 和 B 都不会被销毁
return;
}
函数结束时,并没有调用析构函数释放内存;循环引用的问题确实存在。
当函数结束时,a和b的局部变量被清空,但此时a->pre_A仍然持有 b 的shared_ptr,此时 b 的引用计数减一后依然为1,同理 b->ptr_B持有a的shared_ptr, a 的引用计数也不会为0,资源无法释放
2. weak_ptr
引入 weak_ptr主要为了解决shared_ptr循环引用问题
weak_ptr不会增加强引用计数,当外部的shared_ptr销毁时,a和b的引用计数都会置为减为0,从而保证资源正确释放
weak_ptr本身不能直接访问管理的对象。为了访问weak_ptr指向的对象,必须使用 weak_ptr.lock()函数将其转换为 shared_ptr。如果源对象已经销毁,返回空,避免了悬空指针
为什么 weak_ptr 不能直接访问所管理的对象?
weak_ptr 主要作用是观察由 shared_ptr 所管理的对象,weak_ptr 不改变强引用计数,因此不能保证所观察的对象是否仍然存在,例如
void weak_ptr_test() {
shared_ptr<int> sptr = make_shared<int>(10);
weak_ptr<int> wptr = sptr;
sptr.reset(); // 释放 shared_ptr, 引用计数归 0, 对象销毁
// wptr仍然存在,但指向的对象已经被销毁
if(shared_ptr<int> sptr2 = wptr.lock()) {
cout << *sptr2 << endl; // 安全访问对象
} else {
cout << "sptr destroy" << endl;
}
return;
}
3. unique_ptr
相比于 shared_ptr 可以共享对象所有权,unique_ptr 保证在任何时刻,都只有一个指针拥有对象的所有权。当指针被销毁、转移、置空时,对象也会被自动销毁
使用场景:
- 适用于资源离开作用域被自动释放场景,如:文件句柄、网络连接、动态内存等…
- 不可共享的独占资源,避免错误的多次释放
- 避免裸指针,使用 unique_ptr 可以避免手动管理裸指针的复杂性和潜在的内存泄露风险
3.1 主要特性
- 轻量级——没有额外的引用计数等级制,比shared_ptr更轻量
- 独占所有权——无法赋值,只能通过转移所有权来移动
- 自动释放内存——当unique_ptr退出作用域或显示被reset、release时,自动销毁所管理的对象
3.2 实现原理
- 禁止拷贝
unique_ptr禁止了拷贝构造和拷贝赋值操作
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;
- 移动语义
可以通过移动语义转移所有权
unique_ptr(unique_ptr&& u) noexcept; // 移动构造
unique_ptr& operator=(unique_ptr&& u) noexcept; // 移动赋值
其他智能指针
auto_ptr: c++11被unique_ptr取代(赋值时所有权转移,而不是引用计数管理)
boost::scoped_ptr:类似unique_ptr,但不可转移;适用于简单的内存管理场景,没有所有权转移需求
boost::instrusive_ptr:允许自定义引用计数管理,适合高效管理自带引用计数的对象。
boost::shared_array:允许多个 shared_ptr 动态管理一个数组,引用计数为0释放。
11. 左值引用和右值引用
11.1 左值引用
左值引用是 C++ 中最早引入的引用类型。它指向左值(Lvalue),即有明确存储位置(可以取地址)并且持久存在的对象。左值是那些可以在多次操作中持续存在的变量,比如函数作用域内的局部变量、全局变量等。
特点
- 左值引用必须引用左值(可以是一个变量,也可以是返回左值的表达式)。
- 左值引用用于别名机制,可以通过引用名来操作原对象。
- 左值引用是可以修改引用对象的值的。
应用场景 - 函数参数传递:传递对象引用,避免对象拷贝
11.2 右值引用
右值引用是在 C++11 中引入的,它指向右值(Rvalue)。右值是那些没有持久地址、只能被临时使用的对象,通常是表达式的结果,如字面常量、临时对象、返回右值的函数调用等。
特点:
- 右值引用只能绑定到右值,右值通常是临时对象、字面量或临时表达式结果。
- 右值引用允许修改右值(通常右值是不可修改的)。
- 右值引用的主要应用是移动语义,它允许对临时对象进行资源转移,而不是资源复制,从而提高程序性能。
应用场景 - 移动语义
std::string str1 = "Hello";
std::string str2 = std::move(str1); // 将 str1 的资源转移到 str2
- 完美转发
void foo(int& x) {
cout << "foo called with lvalue" << endl;
return;
}
void foo(int&& x) {
cout << "foo called with rvalue" << endl;
return;
}
template<typename T>
// 引用折叠规则确定 T&& 实际类型
void wrapper(T&& arg) {
// 完美转发,保持arg的左值或右值特性
foo(std::forward<T>(arg));
}
int main() {
int a = 10;
wrapper(a);
wrapper(10);
return 0;
}