【最全】C++面试题 (第三篇)

1.C++中的访问修饰符

  • public(公有):public成员在类内外均可访问。它们可以被类的对象、派生类和其他类的对象访问。通常情况下,公有成员用于定义类的接口,即其他对象可以通过这些公有成员访问类的数据和功能。

  • protected(保护):protected成员在类内部和派生类中可访问,但在类外部不可访问。它们可以用于实现类的继承和派生,派生类可以继承和访问基类中的protected成员。通常情况下,protected成员用于定义类的内部实现细节,限制了外部访问

  • private(私有):private成员只能在类内部访问,对于类的对象和派生类都是不可见的。private成员用于封装类的实现细节,隐藏了类的内部数据和功能,只允许通过公有接口访问

2.深拷贝和浅拷贝的区别

深拷贝和浅拷贝是在编程中用于描述对象(或数据结构)复制过程的重要概念。它们之间的主要区别体现在复制的程度、对原始对象和复制对象之间关系的影响,以及适用场景等方面。

  1. 复制程度

    • 深拷贝:创建一个完全独立的新对象,并递归地复制原始对象及其所有子对象。这意味着对象的所有层级,包括属性、嵌套对象、引用等,都会被复制。

    • 浅拷贝:创建一个新对象,并将原始对象的内容逐个复制到新对象中。但浅拷贝仅复制最外层对象,而内部的嵌套对象只是引用而已,没有被递归复制。

  2. 原始对象和复制对象之间的关系

    • 深拷贝:原始对象和复制对象是完全独立的。修改其中一个对象不会影响另一个对象。

    • 浅拷贝:原始对象和浅拷贝对象之间共享内部对象。修改其中一个对象的内部对象会影响到另一个对象。

3.C++中四种强制类型转换

  1. static_cast:用于基本类型之间的转换,以及具有继承关系的类之间的转换。它可以处理隐式转换和非const转换。但是,它不能用于处理没有继承关系的类之间的转换。

  2. dynamic_cast:用于具有继承关系的类指针之间的转换。它在运行时执行类型检查,只能用于处理指针或引用类型。如果转换是合法的,dynamic_cast返回目标类型的指针或引用;如果转换不合法,dynamic_cast返回空指针(对于指针转换)或抛出std::bad_cast异常(对于引用转换)。

  3. reinterpret_cast:用于将一个指针或引用转换为另一种类型的指针或引用,甚至可以将一个指针类型转换为整数类型,或者将一个整数类型转换为指针类型。reinterpret_cast是一种非常强大但也非常危险的转换,需要谨慎使用。

  4. const_cast:用于去除变量的const属性,可以将const类型转换为非const类型。参数类型必须是指针或引用类型。它通常用于解决函数重载或函数参数类型不匹配的问题。

以下是这四种转换的示例:

1. static_cast

int a = 10;  
double b = static_cast<double>(a); // 基本数据类型之间的转换  
  
class Base {};  
class Derived : public Base {};  
Derived* d = new Derived();  
Base* b_ptr = static_cast<Base*>(d); // 类层次间的上行转换(upcasting)  
// 注意:下行转换(downcasting)虽然可以使用static_cast,但通常不推荐,因为它不会在运行时检查转换是否有效。

2. dynamic_cast

class Base {  
public:  
    virtual ~Base() {} // 必须有虚析构函数  
};  
class Derived : public Base {};  
  
Derived* d = new Derived();  
Base* b_ptr = d;  
Derived* d_ptr = dynamic_cast<Derived*>(b_ptr); // 类层次间的下行转换(downcasting),会在运行时检查转换是否有效  
  
if (d_ptr) {  
    // 转换成功  
} else {  
    // 转换失败  
}

3. const_cast

const int a = 10;  
int* p = const_cast<int*>(&a); // 去除const属性,但这样做是危险的,因为它可能会修改一个不应该被修改的值  
  
// 注意:不要尝试通过const_cast来修改字符串字面量或其他常量数据,这是未定义行为。

4. reinterpret_cast

int a = 10;  
int* p_a = &a;  
char* p_char = reinterpret_cast<char*>(p_a); // 指针类型之间的低级别转换  
  
// 注意:reinterpret_cast是非常危险的,因为它不会执行任何类型的检查。使用它时要格外小心。

以上示例仅用于演示这四种强制类型转换的基本用法。在实际编程中,应谨慎使用这些转换,特别是const_castreinterpret_cast,因为它们可能导致未定义行为或运行时错误。

4.可以用memset清空一个类对象的内容吗

不可以,memset只是简单的将内存段的每个字节赋值为0,而不会调用对象的析构函数,也不会处理指针内存释放。假如类中有指针类型的变量或者其他类的对象,那么可能会导致内存泄漏。

5.main函数执行前后发生了什么?

  • 执行前:操作系统初始化相关资源。

  1. 设置栈指针

  2. 初始化静态变量和全局变量(data段内容)

  3. 将未初始化的全局变量赋初值。数值型:short、int、long为0;布尔型:bool为false;指针:nullptr等(bss段内容)

  4. 全局对象的初始化,调用其构造函数。

  5. 将main函数中的参数传递给main,然后执行main函数。

  6. 如果有函数被声明为attribute((constructor))属性,则会先执行该函数。

#include <iostream>
​
void myConstructor() __attribute__((constructor));
​
void myConstructor() {
 std::cout << "Constructor called!" << std::endl;
}
​
int main() {
 std::cout << "Main function" << std::endl;
 return 0;
}
/*
输出:
Constructor called!
Main function
*/
/*
当运行程序时,会先执行myConstructor函数,输出"Constructor called!",然后再执行main函数。
__attribute__((constructor))修饰的函数没有指定执行顺序,如果有多个被修饰的函数,它们的调用顺序不能被明确保证。
*/

  • 执行后:进行资源回收以及额外处理。

  1. 全局对象的析构,释放内存

  2. 使用atexit注册函数,使其在main运行结束后执行。

  3. __attribute__((destructor))

#include <stdio.h>
​
void cleanup() __attribute__((destructor));
​
void cleanup() {
 printf("Cleanup function called.\n");
}
​
int main() {
 printf("Hello, World!\n");
 return 0;
}
/*
输出:
Hello, World!
Cleanup function called.
*/
/*
cleanup函数被修饰为__attribute__((destructor)),它将在程序退出时自动调用。当运行此代码时,最后会输出 “Cleanup function called.” 作为程序结束时的清理信号
__attribute__((destructor))修饰的函数没有指定执行顺序,如果有多个被修饰的函数,它们的调用顺序不能被明确保证。
*/

6.在传递函数参数时,什么时候该使用指针,什么时候该使用引用呢?

  • 需要返回函数内局部变量的内存的时候用指针。使用指针传参需要开辟内存,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的

  • 对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小

  • 类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式

 

7.C++程序的内存模型

C++程序的内存模型可以大致分为几个不同的部分,这些部分反映了C++程序如何与底层硬件(特别是内存)进行交互。以下是C++程序内存模型的主要组成部分:

  1. 栈(Stack)

    • 栈是一个后进先出(LIFO)的数据结构,用于存储局部变量、函数调用的参数和返回地址等信息。

    • 栈由编译器自动管理,包括分配和释放内存。

    • 当函数被调用时,它的局部变量和参数会被推送到栈上。当函数返回时,这些变量和参数会从栈上弹出。

    • 栈内存的大小通常是有限的,并且由操作系统和编译器共同决定。

  2. 堆(Heap)

    • 堆是用于动态内存分配的区域,程序员可以使用newdelete操作符(或在C++11及更高版本中,使用智能指针如std::unique_ptrstd::shared_ptr)来在堆上分配和释放内存。

    • 堆的大小取决于操作系统的内存管理和程序的需求。

    • 与栈不同,堆上的内存分配和释放需要程序员显式管理,这可能会导致内存泄漏或悬挂指针等问题。

  3. 全局/静态存储区(Global/Static Storage Area)

    • 全局变量和静态变量(包括函数内的静态局部变量)存储在这个区域。

    • 这些变量的生命周期是整个程序的执行期间。

    • 它们的初始化在程序开始之前由编译器完成,而销毁则在程序结束时由操作系统完成。

  4. 常量存储区(Constant Storage Area)

    • 字符串常量和其他字面常量(如const修饰的常量)存储在这个区域。

    • 这个区域是只读的,并且生命周期与全局/静态存储区相同。

  5. 代码段(Code Segment/Text Segment)

    • 代码段包含程序的机器代码,即CPU执行的指令。

    • 这个区域是只读的,以防止程序意外地修改其指令。

  6. 其他内存区域

    • 根据操作系统和编译器的不同,可能还存在其他类型的内存区域,例如寄存器、共享库和动态链接的库等。

C++程序在运行时,其内存布局会反映这些不同的内存区域。了解这些内存区域如何工作以及如何与它们交互,对于编写高效、安全的C++程序至关重要。特别是,正确管理堆内存是避免内存泄漏和悬挂指针等常见问题的关键。、

8.堆和栈的区别

  • 申请方式不同。栈由操作系统管理,自动申请和释放;堆需要手动申请和释放。

  • 申请大小限制不同。栈的空间是连续的且大小固定(编译期确定),空间从栈顶向栈底增加(从高地址到低地址);堆从低地址向高地址扩展,大小可变(运行时动态变化)。

  • 效率不同。

内存分配效率:

  • 栈内存的分配是静态和自动的,由编译器自动为函数调用创建和释放。栈的分配效率非常高,仅需移动栈指针即可完成。由于栈上的数据是按照栈帧结构组织的,所以栈的内存分配速度非常快。

  • 堆内存的分配通常是动态的,由程序员手动申请和释放。这种动态性导致了堆内存分配的效率相对较低,需要进行内存管理器的搜索和处理(系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删 除空闲结点链表中的该结点,并将该结点空间分配给程序)。而且,堆上的内存分配可能存在内存碎片化问题,进一步影响了效率。

内存访问效率:

  • 栈内存的访问效率非常高。栈上的数据存储在连续的内存位置上,通过栈指针直接访问数据,无需间接操作和额外的内存访问。

  • 堆内存的访问效率相对较低。由于使用指针间接访问堆上的数据,需要多次内存访问,包括读取指针以获取目标地址,然后再读取目标地址的数据。此外,由于堆上的内存地址可能分散,涉及更多的内存访问开销。

内存管理效率:

  • 栈上的内存管理机制简单而高效。编译器负责栈空间的分配和释放,使用栈指针进行内存操作,不需要额外的管理操作。

  • 堆上的内存管理相对复杂,涉及动态内存分配和释放,可能需要进行内存合并和垃圾回收等操作来处理内存碎片化。这些操作会带来额外的开销和时间消耗,降低了堆的管理效率。

9.垃圾回收算法

  1. 引用计数:该算法通过在每个对象中维护一个引用计数器,记录该对象被引用的次数。当引用计数器为0时,表示该对象不再被引用,可以被回收。但是该算法无法解决循环引用的问题,即两个对象互相引用,但没有其他对象引用它们。

  2. 标记-清除:该算法通过标记所有活动对象,即程序能够访问到的对象,然后清除所有未标记的对象。这样会造成内存碎片的问题,需要进行进一步的整理操作。

  3. 标记-整理:该算法在标记-清除的基础上,进一步将活动对象整理到一端,然后清除剩余的对象。这样可以避免内存碎片的问题。

 

10.堆和栈谁更快?

栈更快。

  • 操作系统在底层对栈提供支持,分配专门的寄存器保存栈的地址,栈的申请和释放操作仅仅通过栈顶指针的移动就能完成,而且还有专门的指令执行,效率比较高。

  • 堆的申请和释放需要调用函数,而且需要一定的算法寻找合适的内存块。获取堆内存的内容需要两次访问,第一次访问指向内存块的指针,第二次访问内存,即真正的数据,因此堆比栈要慢。

11.new/delete和malloc/free的区别

  1. new/delete是操作符,而malloc/free是函数;前者只支持在C++中使用,后者在C/C++均可。

  2. new会自动计算出所需空间大小,而malloc需要手动计算。

  3. new返回值是该类型的指针,无需强转;而malloc返回值void*,需要强转。

  4. new/delete会调用类的构造和析构函数,而malloc/free不会。

  5. new分配失败会抛出异常或者返回nullptr,而malloc分配失败会返回nullptr。

  6. new的执行过程:先调用operator new函数分配内存(一般底层通过malloc实现),然后调用类的构造函数,最后返回类的指针。

  7. delete执行过程:先调用析构函数,然后调用operator delete释放内存(一般底层通过free实现)

12.new和delete的实现原理

在C++中,newdelete操作符用于在堆上动态地分配和释放内存。这两个操作符的实现原理通常与底层的内存分配器和释放器紧密相关,但我们可以从高级别上理解它们的工作原理。

new操作符的实现原理

  1. 分配内存: 当使用new操作符为一个对象分配内存时,它会首先调用一个底层的内存分配函数(通常是malloccalloc或类似函数,这取决于编译器和库的实现)。这个函数会从堆上分配足够大小的内存来存储对象。

  2. 构造对象: 在成功分配内存后,new操作符会调用对象的构造函数(如果有的话)来初始化这块内存区域。这一步是确保对象在使用前处于有效状态的关键。

  3. 返回指针: 一旦对象被成功构造,new操作符会返回一个指向新分配并构造好的对象的指针。

delete操作符的实现原理

  1. 析构对象: 当使用delete操作符释放一个对象时,它会首先调用对象的析构函数(如果有的话)。析构函数用于执行任何必要的清理操作,如释放对象可能拥有的其他资源(如动态分配的内存、文件句柄等)。

  2. 释放内存: 在析构函数执行完毕后,delete操作符会调用一个底层的内存释放函数(通常是free或类似函数)来将对象占用的内存块归还给堆。这一步确保了内存不会被泄漏,并且可以被重新分配给其他对象。

注意事项

  • 内存泄漏:如果程序员忘记使用delete来释放使用new分配的内存,那么这块内存就会一直被占用,直到程序结束。这就是所谓的内存泄漏。

  • 异常安全:在new操作符调用构造函数时,如果构造函数抛出异常,那么new会自动调用底层的内存释放函数来释放已经分配的内存。这是为了确保在出现异常时,内存不会被泄漏。

  • 性能考虑:虽然newdelete提供了方便的内存管理方式,但它们也引入了一定的开销。因此,在需要频繁进行内存分配和释放的场景中,可能需要考虑使用更高效的内存管理策略,如内存池。

  • 自定义new和delete:C++允许程序员为特定类型或全局范围自定义newdelete的行为。这可以用于实现特定的内存管理策略或优化性能。

13.plain new,nothrow new和placement new

  • “plain new” 通常用于描述 new 运算符的常规使用方式,“plain new” 与 “new operator” 指的是同一个概念。可以说 “plain new” 是使用 new 运算符进行内存分配和对象初始化的常规方式,而 “new operator” 是这个运算符的名称。内存分配失败会抛出异常,而不是返回空指针。

  • nothrow new:内存分配失败会返回空指针,而不是抛出异常。

  • placement new:用于在预先分配的内存位置上构造对象。不分配空间,只是执行对象的构造函数,并将其放到已经分配好的内存中。通过提供指定的内存地址作为参数,可以调用placement new来构造对象。注意:使用placement new手动调用构造函数的同时,需要手动调用析构函数进行对象的销毁和释放内存

在C++中,new操作符实际上有几种不同的变种,它们提供了不同的内存分配行为。以下是关于plain newnothrow newplacement new的详细解释:

1. Plain new(普通new)

这是最常见的new操作符用法。当使用plain new为一个对象动态分配内存时,如果内存分配成功,则返回一个指向新分配并构造好的对象的指针。如果内存分配失败(例如,由于堆内存耗尽),则抛出std::bad_alloc异常。

示例:

int* p = new int(10); // 分配内存并初始化为10

2. nothrow new(不抛出异常的new)

nothrow newnew操作符的一个变种,它在内存分配失败时不会抛出异常,而是返回一个空指针(nullptr)。这使得程序员能够更精细地控制错误处理,而不是依赖于异常。

示例:

int* p = new(std::nothrow) int(10); // 如果分配失败,返回nullptr  
if (p == nullptr) {  
    // 处理内存分配失败的情况  
}

3. placement new(定位new)

placement newnew操作符的一个特殊版本,它允许程序员在已分配的内存上构造对象。这通常用于在预先分配的内存区域(如栈上或已分配的堆内存)中构造对象。使用placement new不会分配任何内存,它只是调用对象的构造函数。

placement new通常与operator new[](或自定义的内存分配器)结合使用,以在自定义的内存块上构造对象数组。

示例:

char buffer[sizeof(int)]; // 在栈上分配内存  
int* p = new(buffer) int(10); // 在buffer上构造一个int对象并初始化为10  
// 注意:此时不能使用delete p来释放内存,因为buffer是在栈上分配的  
// 应该使用对象的析构函数来清理资源  
p->~int(); // 显式调用析构函数

注意事项

  • 使用placement new时,必须确保提供的内存块足够大,以容纳要构造的对象。

  • 使用placement new构造的对象必须显式调用其析构函数来清理资源,因为内存不是通过new分配的,所以不能使用delete来释放。

  • nothrow new在C++11及更高版本中更常见地通过std::nothrow标签与new结合使用,但在C++17及更高版本中,建议使用std::nothrow_t类型或省略标签,因为std::nothrow已经被弃用。

  • 当使用自定义的内存分配器或需要精细控制内存管理时,placement new非常有用。然而,在大多数情况下,使用plain newdelete就足够了。

  • 19
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

爱编程的小猴

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值