C++八股(一)
一、new和malloc ⭐
- new是C++的关键字,用于动态分配内存并创建对象。它可以根据类型自动计算所需内存空间,并调用对象的构造函数进行初始化。在使用new分配内存后,需要使用delete来释放这些内存空间,以防止内存泄漏。
- malloc是C语言的库函数,用于动态分配一块指定大小的内存块,并返回其地址。需要注意的是,使用malloc分配内存后,需要使用free来释放这些内存空间,以防止内存泄漏。
#include <iostream> #include <cstdlib> int main() { // 使用new进行动态内存分配和释放 int* newPtr = new int(10); std::cout << "Value allocated with new: " << *newPtr << std::endl; delete newPtr; // 使用malloc进行内存分配和释放 int* mallocPtr = (int*)malloc(sizeof(int)); if (mallocPtr != nullptr) { *mallocPtr = 20; std::cout << "Value allocated with malloc: " << *mallocPtr << std::endl; free(mallocPtr); } return 0; }
二、class和struct的区别 ⭐
struct StructExample { int publicMember; // 默认public,可以直接访问 }; class ClassExample { int privateMember; // 默认private,不能直接访问 public: // 公共接口,允许外部访问privateMember int getPrivateMember() const { return privateMember; } void setPrivateMember(int value) { privateMember = value; } }; int main() { StructExample se; se.publicMember = 10; // 直接访问 ClassExample ce; ce.setPrivateMember(20); // 通过公共成员函数设置私有成员 int value = ce.getPrivateMember(); // 通过公共成员函数获取私有成员 }
三、char和int之间的转换
char c = 'A'; int i = c; // 将字符'A'的ASCII码值赋给i int i = 65; char c = static_cast<char>(i); // 将整数65转换为对应的字符'A'
需要注意的是,对于转换为char的int值,如果超出了char类型的范围(-128至127),将会发生溢出,只保留最低位字节的值。
四、什么是野指针和悬挂指针 ⭐
- 野指针(Dangling Pointer):指的是没有初始化过的指针,它指向的地址是未知的、不确定的、随机的。
产生野指针的原因主要是指针未初始化,防止的措施就是指针初始化(包括及时初始化或置空)。
int main() { int* ptr; // 未初始化的指针,成为野指针 // 使用野指针会导致未定义的行为 *ptr = 5; // 解引用野指针,可能导致程序崩溃 return 0; }
- 悬挂指针(Dangling Reference):指针最初指向的内存已经被释放了的一种指针。指针指向的内存已释放,但指针的值没有被清零,对悬空指针操作的结果不可预知。
int* createInt() { int value = 5; int* ptr = &value; return ptr; // 返回指向局部变量的指针 } int main() { int* danglingPtr = createInt(); // 指向已释放的内存 // 对悬挂指针操作的结果不可预知 int value = *danglingPtr; // 解引用悬挂指针,可能导致未定义的行为 return 0; }
局部变量 value 会在 createInt
函数执行完毕后,离开其作用域时被销毁。此时,value
占用的内存将不再被 value
所拥有,但是通过 ptr
返回的指针依然指向这块内存。由于这块内存已经被释放,再通过 ptr
访问它将会导致未定义行为(Undefined Behavior),可能引起程序崩溃或其他不可预知的错误。
重要的是要理解,局部变量 value
的生命周期仅限于 createInt
函数的作用域内。一旦函数返回,value
的生命周期结束,它所占用的内存可以被系统回收或重新分配给其他变量。
在C++中,正确处理这类情况的一种方法是使用动态内存分配,例如使用 new
关键字分配内存,并在适当的时候使用 delete
来释放内存:
int* createInt() { int* ptr = new int(5); // 使用new分配内存,并初始化为5 return ptr; // 返回指向这块内存的指针 } // 使用完毕后,需要使用delete释放内存 int* ptr = createInt(); // ... 使用ptr ... delete ptr; // 释放ptr指向的内存 ptr = nullptr; // 将ptr设置为nullptr,避免悬挂指针问题
五、NULL和nullptr区别⭐
- 在C++11之前,NULL 是一个宏,通常被定义为 0 或 (void*)0。在C++11及以后,NULL 被定义为 nullptr。
- nullptr 是C++11引入的一个更现代、更安全的方式来表示空指针。尽管 NULL 在旧代码中仍然广泛使用,但在新的C++代码中,推荐使用 nullptr。
六、指针常量和常量指针有何区别⭐
- 指针常量:指针本身的值不可改变(即不能让它指向另一个地址),但可以改变它所指向的数据(除非数据本身是常量)。(指针地址不可变,值可变。注:指针常量在定义时要赋初值。)
int a = 0,b = 0; int* const p = &a; *p = 1; //正确,可以修改值 *p = &b; //错误,不可改变地址
- 常量指针:指针可以改变指向的地址,但它所指向的数据是不可修改的(即数据是常量)。
(指针地址可以变,值不能变)
int a = 0,b = 0; const int *p = &a; *p = 1; //错误,不可修改常量值 *p = &b; //正确,可以指向另一个常量
区分记忆:比较const和*谁离ptr更近,如果const更近则表示指针本身是常量,是指针常量;如果*离ptr更近则表示指向的值value不能更改,是常量指针;否则是指向常量的指针常量(const int * const p = &a)
七、物理内存和虚拟内存的区别⭐
- 本质:物理内存是实际存在的硬件资源,而虚拟内存是操作系统提供的一种抽象和扩展。
- 大小:物理内存的大小受限于硬件,虚拟内存的大小受限于物理内存和磁盘空间。
- 访问速度:物理内存的访问速度通常远快于虚拟内存,因为硬盘的读写速度远低于RAM。
- 目的:物理内存用于存储当前活跃的程序和数据,虚拟内存用于扩展可用的内存空间,允许更多的程序并发运行或处理更大的数据集。
- 管理:物理内存由硬件直接管理,而虚拟内存由操作系统通过复杂的内存管理算法进行管理。
虚拟内存的使用可以提高系统的多任务处理能力,但过度依赖虚拟内存可能导致性能下降,因为频繁的页面交换(也称为“页面抖动”或“抖动”)会增加对硬盘的访问,从而降低程序的响应速度。
八、重载、重写和隐藏的区别⭐
1、重载(Overloading)
- 重载是在同一个作用域内定义多个相同名称但参数列表不同的函数或方法。
- 重载提供相同功能但接受不同参数的函数版本。
#include <iostream> void printNumber(int num) { std::cout << "Integer number: " << num << std::endl; } void printNumber(double num) { std::cout << "Floating-point number: " << num << std::endl; } int main() { printNumber(10); printNumber(3.14); return 0; }
2、重写(override)
- 重写是指子类重新定义从父类继承的虚函数,使其具有不同的实现。
- 重写的函数签名(函数名、参数列表和返回类型)必须与被重写函数相同。
#include <iostream> class Base { public: virtual void sayHello() { std::cout << "Hello from Base class!" << std::endl; } }; class Derived : public Base { public: void sayHello() override { // 使用 override 关键字表明重写了父类的函数 std::cout << "Hello from Derived class!" << std::endl; } }; int main() { Base* basePtr = new Derived(); basePtr->sayHello(); // Output: "Hello from Derived class!" delete basePtr; return 0; }
3、隐藏(Hiding)
- 隐藏发生在派生类中,当派生类中的一个成员(可以是函数或变量)与基类中的成员名称相同,但又不是重写(即函数签名不匹配)时,就会发生隐藏。
- 隐藏的成员可以通过基类类型的引用或指针来访问,或者在派生类中使用 using 声明来解决名称冲突。
#include <iostream> class Base { public: void sayHello() { std::cout << "Hello from Base class!" << std::endl; } }; class Derived : public Base { public: void sayHello() { std::cout << "Hello from Derived class!" << std::endl; } }; int main() { Base baseObj; Derived derivedObj; baseObj.sayHello(); // Output: "Hello from Base class!" derivedObj.sayHello(); // Output: "Hello from Derived class!" Base* basePtr = new Derived(); basePtr->sayHello(); // Output: "Hello from Base class!" delete basePtr; return 0; }
4、总结
重载和重写是多态性的重要体现,而隐藏更多是名称冲突的一种情况。正确理解这些概念有助于更好地设计和实现面向对象的程序。
九、简述面向对象(OOP)的三大特性 ⭐
- 封装(Encapsulation):封装是将数据(属性)和操作这些数据的代码(方法)捆绑在一起的过程。它隐藏了内部细节,只暴露出一个可以被外界访问和使用的接口。封装使得代码模块化,提高了代码的安全性和易于维护。
- 继承(Inheritance):继承是一种机制,允许一个类(称为子类或派生类)继承另一个类(称为基类或父类)的属性和方法。子类可以扩展或修改基类的行为,也可以添加新的属性和方法。继承支持代码复用,可以创建层次结构,使得程序结构更加清晰。
- 多态(Polymorphism):多态是指允许不同类的对象对同一消息做出响应,但具体的行为会根据对象的实际类型而有所不同。多态可以通过虚函数在基类中实现,使得派生类可以重写(Override)基类中的方法,以提供特定的实现。多态性使得代码可以对不同类型的对象执行不同的操作,而不需要知道对象的具体类。
封装提供了数据和操作的保护,继承支持了代码的层次结构和复用,而多态则允许编写更加通用和灵活的代码。
十、什么是多态 ⭐
利用虚函数,基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员。 基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。
#include <iostream> class Base { public: virtual void print() { std::cout << "This is the Base class" << std::endl; } }; class Derived : public Base { public: void print() override { std::cout << "This is the Derived class" << std::endl; } }; int main() { Base* basePtr; Base baseObj; Derived derivedObj; basePtr = &baseObj; basePtr->print(); // 此时使用基类的成员函数来打印消息 basePtr = &derivedObj; basePtr->print(); // 此时使用派生类的成员函数来打印消息 return 0; }
十一、静态链接库和动态链接库的区别 ⭐
- 链接时间:静态链接在编译时完成,而动态链接在程序运行时完成。
- 部署:静态链接生成的可执行文件是自包含的,易于部署;动态链接需要确保运行时库文件的可用性。
- 内存使用:静态链接可能会导致多个副本的相同代码存在于内存中(如果多个程序运行),而动态链接允许共享同一份库代码。
- 更新和维护:静态链接的程序更新需要重新编译,动态链接的库更新只需替换DLL或共享库文件。
- 性能:静态链接可能提供更好的性能,因为它避免了运行时链接的开销;动态链接可能在启动时有额外的开销。
十二、C++和C在编译时有什么区别?如何在C++中用C?⭐
C++与C在编译时的主要区别有以下几点:
- 由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。
- 语法和功能:C++相比C具有更多的语法和功能。C++引入了面向对象编程的概念,包括类、继承、多态等。此外,C++还提供了更多的库和工具,如标准模板库(STL)和异常处理机制等。
- 兼容性:C++是C的超集,这意味着C的源代码可以直接在C++中编译和运行。C++编译器会自动识别和处理C的语法,因此可以使用C代码编写的功能和库。
在C++中使用C代码有多种方式,其中常见的几种方式包括:
使用extern "C"进行函数声明:在C++中,使用extern "C"修饰C代码的函数声明,以告诉编译器使用C的名称重载规则。
extern "C" { // C函数声明 int add(int a, int b); }
十三、为什么要少使用宏?C++有什么解决方案? ⭐
在C++中,推荐尽量避免过多使用宏的原因有以下几点:
- 可读性差:宏通常使用简单的文本替换机制,在代码中展开为复杂的表达式或语句,导致代码可读性降低。
- 潜在的副作用:宏的使用可能导致潜在的副作用,比如多次求值、修改变量等,这可能导致意外行为和错误。
- 缺乏类型检查:宏不进行类型检查,因此在使用宏时需要自行确保类型匹配,否则可能导致运行时错误。
C++中的解决方案:
- 内联函数(Inline Functions):使用内联函数代替宏,可以提供类型安全检查和更多的调试信息。
inline int max(int a, int b) { return a > b ? a : b; }
- 模板函数(Template Functions):模板提供了一种类型安全的通用编程方式,可以用来替代一些宏。
template<typename T> T max(const T& a, const T& b) { return a > b ? a : b; }
- constexpr:对于编译时常量,可以使用 constexpr 关键字定义,这提供了类型安全和编译时求值。
constexpr int pi = 3.14159;
十四、内联函数的作用及注意事项 ⭐
内联函数是C++中的一种特殊函数,它可以在编译时被插入到每个调用该函数的地方,而不是通过常规的函数调用机制执行。这通常用于小的、频繁调用的函数,以减少函数调用的开销。因为函数的调用会涉及栈帧的创建和销毁、参数传递等操作,而将函数体直接插入调用点则无需进行这些操作。
为什么使用内联函数?
- 减少调用开销:对于小函数,内联可以避免函数调用的额外开销,如参数传递、栈帧的创建和销毁等。
- 提高执行效率:内联函数的代码直接嵌入到调用点,可能提高程序的执行速度。
- 避免多态开销:内联函数可以用于避免虚函数的动态调度开销。
- 代码优化:编译器可以对内联函数的代码进行更深入的优化。
如何声明内联函数?
在C++中,使用 inline 关键字来声明内联函数:
inline int max(int a, int b) { return a > b ? a : b; }
需要注意什么?
- 内联函数适用于函数体简单、调用频繁的情况。如果函数体较大或调用频率较低,使用内联函数可能会导致代码膨胀,产生更多的代码复制,甚至可能导致性能下降。
- 内联函数的声明通常放在头文件中,因此需要注意内联函数的定义和声明应该一致,遵循内联函数的定义规则,在同一个编译单元中只能有一个定义。
- 虚函数不能使用内联函数,因为虚函数的调用是通过虚表进行的,无法在编译时确定调用的具体函数。
十五、简述C++从代码到可执行二进制文件的过程 ⭐
C++ 从源代码到可执行二进制文件的过程通常包括以下几个主要步骤:
- 预处理(Preprocessing):源代码文件(.cpp 文件)首先通过预处理器进行处理。预处理器执行宏定义的展开、条件编译指令(如 #if, #ifdef, #ifndef, #endif)、包含头文件(#include)等操作。
- 编译(Compilation):预处理后的代码被编译器转换成汇编语言。编译器进行语法分析、语义分析、生成中间代码、优化等。
- 汇编(Assembly):编译生成的汇编代码随后被汇编器转换成机器代码。这个过程通常由编译器自动调用汇编器完成。
- 链接(Linking):汇编后的机器代码通常还不能直接执行,因为它可能依赖于库文件或其他对象文件。链接器将所有的机器代码(包括程序的代码和库代码)链接在一起,生成一个单一的可执行文件。链接过程包括静态链接和动态链接。静态链接在编译时将所有依赖的库代码复制到可执行文件中;动态链接则在运行时解析库。
- 优化(Optimization):在编译和汇编的过程中,编译器和汇编器会进行代码优化,以提高程序的执行效率。优化可能包括消除冗余代码、循环优化、指令重排等。
- 生成可执行文件(Executable File):链接后的输出是一个可执行文件,它包含了程序的所有机器代码和必要的元数据。
- 执行(Execution):用户可以在操作系统中运行这个可执行文件,操作系统加载这个文件到内存中,并将控制权交给程序的入口点(通常是 main 函数)。
这个过程可以通过命令行工具或集成开发环境(IDE)来完成。例如,在Unix-like系统中,可以使用 g++
或 clang++
等编译器来编译和链接C++程序:
g++ -o my_program my_program.cpp
这个命令会编译 my_program.cpp
并链接生成名为 my_program
的可执行文件。如果使用了动态链接库,链接器会确保在执行时可以找到相应的库。
十六、继承和虚继承 ⭐
继承是面向对象编程中的一个重要概念,它允许一个类(派生类或子类)继承另一个类(基类或父类)的属性和方法。通过继承,派生类可以重用基类的代码,并可以在此基础上进行扩展和修改。
虚继承
虚继承是C++特有的一种继承方式,用于解决多继承中出现的菱形继承问题。当两个或多个类继承自同一个基类,然后又有一个派生类继承自这两个类时,如果没有虚继承,基类的成员会被复制多次到派生类中,导致冗余。
特点:
- 虚继承通过关键字 virtual 来声明,表明继承是虚拟的。
- 使用虚继承时,基类的成员只会在内存中保存一份,即使多个父类都继承自同一个基类。
- 虚继承确保了派生类中只有一个基类子对象的实例,避免了数据冗余。
class Base { // ... }; class Derived1 : virtual public Base { // ... }; class Derived2 : virtual public Base { // ... }; class MostDerived : public Derived1, public Derived2 { // MostDerived 只有一个 Base 的实例 };
在这个例子中,Derived1
和 Derived2
都虚继承自 Base
。MostDerived
继承自 Derived1
和 Derived2
,由于使用了虚继承,MostDerived
中只有一个 Base
的实例,避免了重复。
十七、多态的类,内存布局是怎么样的 ⭐
在C++中,多态类的内存布局包含两个主要部分:非静态成员变量的内存和虚函数表(vtable)的内存。
- 非静态成员变量的内存:这部分内存包含了类的实例数据,即所有非静态成员变量。这些成员变量按照它们在类中声明的顺序在内存中连续存储。
- 虚函数表(vtable):虚函数表是一个指向虚函数实现的指针数组,用于支持运行时多态。每个包含虚函数的类都有自己的虚函数表,包括所有通过 virtual 关键字声明的函数。当创建一个对象时,编译器会在对象的内存布局中嵌入一个指向该类虚函数表的指针。
内存布局示例:
假设有一个简单的多态类层次结构:
class Base { public: virtual void virtualFunc() { /* ... */ } void nonVirtualFunc() { /* ... */ } }; class Derived : public Base { public: void virtualFunc() override { /* ... */ } };
内存布局:
- 对于 Base 类的对象,内存布局将包括:非静态成员变量(如果有的话)。一个指向 Base 类的虚函数表的指针。
- 对于 Derived 类的对象,内存布局将包括:继承自 Base 的非静态成员变量(如果有的话)。Derived 类自己的非静态成员变量(如果有的话)。一个指向 Derived 类的虚函数表的指针。
虚函数表的工作原理:
- 当你通过基类指针或引用调用虚函数时,程序会使用对象的虚函数表指针来查找正确的函数地址。
- 如果基类指针指向的是派生类对象,调用虚函数时会调用派生类中重写的版本。
- 如果基类指针指向的是基类对象,调用虚函数时会调用基类中的版本。
注意事项:
- 虚函数表的存在是透明的,由编译器自动管理。
- 虚函数表的大小取决于类中虚函数的数量。
- 虚函数表指针通常位于对象内存布局的起始位置,但具体位置可能因编译器而异。
多态类的内存布局设计使得C++能够有效地支持运行时多态,同时保持了对象的封装性和类型安全。
十八、被隐藏的基类函数如何调用?子类怎么调用父类的同名函数和父类成员变量? ⭐
调用父类的同名函数:可以使用基类名加上作用域解析运算符来调用父类的同名函数。
class Base { public: void function() { cout << "This is the Base class function" << endl; } }; class Derived : public Base { public: void function() { cout << "This is the Derived class function" << endl; } void callBaseFunction() { Base::function(); // 调用父类的同名函数 } }; Derived d; d.function(); // 调用子类的同名函数 d.callBaseFunction(); // 调用父类的同名函数 在上述示例中,Derived类中定义了一个与Base类同名的函数function。通过使用Base::function(),我们可以在子类中调用父类的同名函数。
访问父类的同名成员变量:可以使用作用域解析运算符来访问父类的同名成员变量。
class Base { public: int x; void print() { cout << "Base x: " << x << endl; } }; class Derived : public Base { public: int x; void print() { cout << "Derived x: " << x << endl; // 访问子类的同名成员变量 cout << "Base x: " << Base::x << endl; // 访问父类的同名成员变量 } }; Derived d; d.x = 10; d.Base::x = 20; d.print(); 在上述示例中,Derived类和Base类都定义了一个同名的成员变量x。通过使用Base::x,我们可以在子类中访问父类的同名成员变量。
十九、多态实现的条件和原理是什么? ⭐
多态性是面向对象编程(OOP)的一个核心概念,它允许不同类的对象对同一消息做出响应,但具体的行为会根据对象的实际类型而有所不同。在C++中,实现多态主要依赖于虚函数和动态绑定机制。以下是实现多态的三个基本条件和原理:
实现多态的三个条件:
- 虚函数(Virtual Functions):基类中需要声明至少一个虚函数,使用 virtual 关键字。这是实现运行时多态的基础。
- 派生类重写(Overriding):派生类需要重写基类中的虚函数,即使用 override 关键字明确指出该函数是对基类虚函数的重写。
- 基类指针或引用(Base Class Pointer/Reference):需要通过基类的指针或引用来操作派生类对象。这样,编译器在运行时能够根据对象的实际类型调用相应的函数。
多态实现的原理:
- 虚函数表(Virtual Table, vtable):每个包含虚函数的类都有一个虚函数表,它是一个函数指针数组,存储了类中所有虚函数的地址。
- 虚函数指针(Virtual Pointer, vptr):每个包含虚函数的对象都有一个虚函数指针,指向其类的虚函数表。
- 动态绑定(Dynamic Binding)或晚期绑定(Late Binding):在程序运行时,通过基类指针或引用调用虚函数时,编译器会使用对象的虚函数指针找到正确的虚函数表,并调用相应的函数实现。
- 构造函数初始化列表:对于虚继承的情况,使用 virtual 关键字声明的基类确保了每个类只有一个基类的子对象副本,避免了因多继承引起的二义性。
- 编译器支持:编译器负责生成和管理虚函数表和虚函数指针,以及在运行时进行正确的函数调用。
示例:
class Base { public: virtual void show() { std::cout << "Base show" << std::endl; } }; class Derived : public Base { public: void show() override { std::cout << "Derived show" << std::endl; } }; int main() { Base* basePtr = new Derived(); // 基类指针指向派生类对象 basePtr->show(); // 动态绑定调用Derived的show函数 delete basePtr; return 0; }
在这个例子中,尽管 basePtr
是 Base
类型的指针,但它实际上指向了一个 Derived
类的对象。当调用 show()
函数时,编译器会根据对象的实际类型(Derived)调用相应的函数实现,这是通过虚函数表和虚函数指针实现的动态绑定过程。
二十、拷贝构造函数作用及用途?什么时候需要定义拷贝构造函数?⭐
拷贝构造函数的作用是创建一个对象的副本。它在以下情况下被调用:
- 对象的复制:当使用一个同类对象来初始化另一个同类对象时,拷贝构造函数被调用。例如,通过复制一个对象来创建一个新对象。
- 参数传递:当将对象作为参数传递给函数时,拷贝构造函数用于创建参数的副本。
- 返回值:当函数返回一个对象时,拷贝构造函数用于创建返回值的副本。
需要自定义拷贝构造函数的情况:
浅拷贝不够:如果类中有指针成员或资源(如文件句柄)需要进行深度拷贝,以防止多个对象共享同一资源。否则,当一个对象销毁时,共享的资源可能会被释放,从而导致其他对象的资源变为无效。
防止浅拷贝:如果类没有指针成员或资源,但是你希望禁止浅拷贝操作,以确保每个对象都有其自己的独立副本,而不是共享相同的数据。
高效率要求:有时候默认的拷贝构造函数可能不够高效,例如当类中有大量的数据或复杂的操作时。在这种情况下,自定义拷贝构造函数可以实现更高效的对象复制。
#include <iostream> class MyClass { public: int* data; // 指针成员 // 默认构造函数 MyClass() : data(nullptr) {} // 自定义拷贝构造函数 MyClass(const MyClass& other) { // 执行深拷贝 if (other.data != nullptr) { data = new int(*other.data); } else { data = nullptr; } } // 析构函数 ~MyClass() { delete data; // 释放堆内存 } }; int main() { MyClass obj1; obj1.data = new int(10); MyClass obj2(obj1); // 使用拷贝构造函数进行深拷贝 std::cout << *obj1.data << std::endl; // 输出: 10 std::cout << *obj2.data << std::endl; // 输出: 10 delete obj1.data; std::cout << *obj2.data << std::endl; // 输出: 10,仍然有效 return 0; } 上述代码中,MyClass类中包含了一个指针成员data。为了避免多个对象共享同一个内存资源,我们在拷贝构造函数中进行了深拷贝操作,即创建一个新的内存副本并将指针指向新的内存位置。 这样,obj2对象将拥有独立的data指针和副本,而不会与obj1对象共享。至于析构函数中的delete操作,则用于释放堆内存,避免内存泄漏
二十一、静态绑定和动态绑定的区别⭐
静态绑定(Static Binding)和动态绑定(Dynamic Binding)是面向对象编程中的两个重要概念,它们描述了函数调用或方法调用在程序执行期间是如何与相应的代码段关联的。
静态绑定(Static Binding)
- 定义:静态绑定,也称为早绑定(Early Binding),是在编译时确定函数调用和相应代码段的关联。
- 特点:由于关联在编译时就已经确定,因此执行速度较快。静态绑定通常用于非虚函数调用。它不检查对象的实际类型,只依赖于对象类型的声明。
- 示例:
动态绑定(Dynamic Binding)
- 定义:动态绑定,也称为晚绑定(Late Binding),是在运行时确定函数调用和相应代码段的关联。
- 特点:由于关联在运行时确定,因此可以提供更大的灵活性。动态绑定通常用于虚函数调用。它检查对象的实际类型,并调用相应的函数实现。
- 实现机制:动态绑定通过虚函数表(Virtual Table, vtable)和虚函数指针(Virtual Pointer, vptr)实现。
- 示例:
区别
- 绑定时间:静态绑定在编译时发生,而动态绑定在运行时发生。
- 调用方式:静态绑定通常用于非虚函数,动态绑定用于虚函数。
- 性能:静态绑定由于在编译时确定,可能更快;动态绑定涉及运行时查找,可能稍慢。
- 灵活性:动态绑定提供了更大的灵活性,允许多态性;静态绑定则没有这种灵活性。
- 类型检查:动态绑定在调用时会检查对象的实际类型,而静态绑定只依赖于声明类型。
理解静态绑定和动态绑定的区别对于掌握面向对象编程和设计模式非常重要。
二十二、析构函数为什么不能抛出异常?解决方法是什么?⭐
在C++中,析构函数是不建议抛出异常的。原因如下:
- 资源泄漏:如果析构函数抛出异常,而这个异常没有被捕获和处理,程序将调用 std::terminate 来终止执行。这种情况下,已经析构的对象所占用的资源将无法得到释放,导致资源泄漏。
- 不可预测性:析构函数通常用于清理和释放对象占用的资源,如内存、文件句柄或网络连接。如果在析构过程中抛出异常,将使得资源的清理变得不可预测,增加了程序出错的风险。
- 异常安全性:在异常安全性方面,析构函数应该遵循“no-throw”保证,即保证不抛出异常。这样,当对象被销毁时,可以确保资源被正确释放,即使在异常情况下也是如此。
- 调用栈展开:当析构函数抛出异常时,调用栈需要展开,以销毁所有局部对象。如果析构函数本身抛出异常,这将导致调用栈展开过程中再次抛出异常,这通常是不被允许的。
- 标准库约定:C++标准库中的容器和智能指针等组件都假定析构函数不会抛出异常。如果析构函数违反了这一假设,可能会导致未定义行为。
- 最佳实践:作为最佳实践,应该在析构函数中避免执行可能抛出异常的操作。如果确实需要执行这样的操作,应该在资源释放之前捕获并处理异常,以确保资源被正确清理。
如果必须在析构函数中执行可能抛出异常的代码,应该使用异常捕获和处理机制,如 try-catch
块,来确保异常被妥善处理,避免因异常而导致的资源泄漏或其他问题。
class MyResource { public: ~MyResource() { try { // 可能抛出异常的清理代码 } catch (...) { // 异常处理代码,确保资源被正确释放 std::cerr << "Exception caught in destructor and handled." << std::endl; } } };
通过这种方式,即使在析构函数中发生异常,也可以确保资源得到正确处理,避免程序因异常而中断。
二十三、哪些情况需要手写虚构函数?⭐
在许多情况下,C++ 类的默认析构函数足以满足需求,特别是当类只包含内置类型成员或没有动态分配资源时。编译器会自动生成一个默认的析构函数来处理这些情况。然而,有几种情况需要手动编写析构函数:
- 动态内存分配:如果类中包含用 new 分配的动态内存,需要在析构函数中使用 delete 来释放这些内存,以避免内存泄漏。
- 释放资源:除了内存之外,如果类分配了其他资源,如文件句柄、网络连接或数据库连接,析构函数应该释放这些资源。
- 清理非标准资源:对于非标准资源,如COM对象或OpenGL资源,可能需要特定的清理代码,这些通常不是由编译器自动生成的。
- 多态类:如果类包含虚函数,并且你希望在对象销毁时执行特定的清理逻辑,应该手动实现一个虚析构函数。
- 基类的析构函数:如果类被设计为基类,并且你知道派生类可能会重写析构函数,应该将基类的析构函数声明为 virtual,以确保通过基类指针安全地删除派生类对象。
- 异常安全性:如果你需要确保在对象销毁过程中不抛出异常,或者需要处理可能抛出异常的资源清理,应该手动编写析构函数来实现异常安全。
- 调试目的:有时为了调试,可能需要在析构函数中添加日志输出或其他调试代码,以帮助追踪对象的生命周期。
- 控制对象的生命周期:在某些设计模式中,如单例模式或工厂模式,可能需要特定的析构逻辑来控制对象的生命周期。
以下是一个简单的示例,展示手动编写析构函数来释放动态分配的内存:
class MyClass { public: MyClass() { // 动态分配内存 resource = new int[100]; } ~MyClass() { // 释放内存 delete[] resource; } private: int* resource; };
在这个例子中,MyClass
分配了一个包含100个整数的数组。析构函数确保当 MyClass
对象被销毁时,分配的内存被正确释放。
虽然编译器可以自动生成默认析构函数,但当涉及到资源管理和特定的清理逻辑时,手动编写析构函数是必要的。
二十四、什么情况下需要调用拷贝构造函数?⭐
拷贝构造函数是C++中的一个特殊成员函数,其作用是使用一个已存在的对象来初始化一个新对象。以下是调用拷贝构造函数的几种情况:
- 对象直接初始化:
- 当使用一个已存在的对象来初始化一个新对象时,会调用拷贝构造函数。
- 对象赋值:
- 当一个对象通过复制赋值操作符(`=)从一个已存在的对象初始化时,拷贝构造函数也会被调用(如果复制赋值操作符没有被重写)。
- 函数参数传递:
- 当对象作为函数参数按值传递时,拷贝构造函数被用来复制对象。
- 函数返回对象:
- 当函数按值返回一个对象时,拷贝构造函数被用来复制返回的对象。
- 容器中的元素复制:
- 当使用复制初始化列表来初始化标准容器中的元素时,会调用拷贝构造函数。
- 按值传递给构造函数:
- 当一个类的构造函数接受一个与类类型相同的参数按值传递时,会调用拷贝构造函数。
- 标准库算法:
- 使用某些标准库算法时,如 std::copy,std::fill_n 等,可能会隐式调用拷贝构造函数。
- 临时对象的复制:
- 在某些表达式中,如果创建了临时对象,拷贝构造函数会被调用来复制这些临时对象。
在编写类时,如果需要自定义对象的复制行为,应考虑重写拷贝构造函数。默认的拷贝构造函数通常执行成员逐一复制(member-wise copy),这可能不适用于包含指针或动态分配资源的类。
二十五、mutable关键字和volatile关键字
mutable
和 volatile
是C++中的两个关键字,它们都用于修改变量的属性,但用途和行为完全不同:
mutable 关键字:
- 用途:mutable 关键字用于类或结构体中的成员变量,允许这些变量在 const 成员函数中被修改。
- 目的:它主要用于多线程编程中,允许变量在保持对象外部状态不变的情况下被修改。
- 示例:
volatile 关键字:
- 用途:volatile 关键字用于指示一个变量可能被程序的其它部分以一种未预见的方式改变,比如硬件或操作系统。
- 目的:它阻止编译器进行某些优化,确保每次访问变量时都会从内存中读取最新值,而不是使用寄存器中的副本。
- 示例:
功能对比:
- 作用域:mutable 主要用于多线程环境中修改对象的内部状态,而 volatile 主要用于与硬件交互或处理中断服务例程。
- 线程安全性:mutable 不提供线程安全性,它只允许在 const 成员函数中修改变量。volatile 也不提供线程安全性,它只确保变量的每次访问都直接与内存交互。
- 优化:mutable 允许编译器在多线程环境中对变量进行修改,而 volatile 阻止编译器进行某些优化,以确保变量的每次读写都直接与内存交互。
- 使用场景:mutable 通常用于计数器、日志记录等需要在 const 函数中修改的变量。volatile 通常用于嵌入式编程、硬件接口、信号处理等场景。
总的来说,mutable
和 volatile
解决的问题完全不同。mutable
允许在 const
上下文中修改变量,而 volatile
确保变量的访问不受编译器优化影响。
二十六、栈溢出一般是由哪些原因导致?⭐
栈溢出(Stack Overflow)通常是由于程序的调用栈超出其分配的内存空间引起的。以下是一些常见的原因:
- 递归调用过深:递归函数在每次调用时都会使用栈空间来保存局部变量和返回地址。如果递归没有适当的终止条件或递归层数过多,将导致栈空间耗尽。
- 大型局部变量:在函数中声明的较大尺寸的局部变量(如大型数组或对象)会占用栈空间。如果这些变量在许多小函数中被频繁创建,可能会迅速填满栈。
- 异常处理:异常处理可能会增加栈的使用量,因为每个异常堆栈帧都需要存储额外的信息。
- 大量函数调用:函数调用链过长,尤其是在没有优化的情况下,每个调用都会在栈上添加一个新的帧。
- 缓冲区溢出:如果程序中有缓冲区溢出(如通过使用 strcpy 而不是 strncpy 导致的),可能会覆盖栈内存,导致栈溢出。
- 缺乏尾递归优化:尾递归优化是一种编译器优化技术,可以减少递归调用的栈使用。如果编译器没有进行这种优化,即使是尾递归也可能消耗大量栈空间。
- 线程栈大小不足:在多线程程序中,如果为线程分配的栈空间过小,可能会在线程执行过程中发生栈溢出。
- 内存碎片:在某些情况下,内存碎片可能导致栈空间不足,尽管实际上可能还有足够的可用内存。
- 系统资源限制:操作系统可能对栈的大小有限制。如果程序的栈使用量超过了这个限制,将导致栈溢出。
- 编程错误:编程错误,如无限循环或错误地管理内存,也可能导致栈溢出。
解决栈溢出的方法可能包括优化递归逻辑、减少不必要的函数调用、增加栈大小、使用迭代而不是递归、确保编译器优化开启(如尾递归优化),以及使用静态分析工具来检测潜在的栈使用问题。在设计程序时,应该考虑这些因素,以避免栈溢出的发生。
二十七、什么是字节对齐?为什么要字节对齐?
字节对齐(Byte Alignment)
字节对齐是指在计算机内存中,数据按照特定的边界(通常是2、4、8字节等)对齐存储。这意味着数据的起始地址是其大小的整数倍。例如,一个4字节对齐的整数,其在内存中的地址应该是4的倍数。
为什么要字节对齐:
- 提高访问速度:许多处理器访问对齐的内存地址比非对齐地址更快。对齐的数据可以直接被处理器一次性读取,而非对齐的数据可能需要多次内存访问和额外的处理步骤。
- 满足硬件要求:某些硬件平台对数据对齐有特殊要求。如果数据没有按照要求对齐,硬件可能无法正确处理数据,或者性能会受到影响。
- 避免额外的处理开销:对齐的数据可以直接映射到CPU的寄存器。如果数据包没有对齐,CPU可能需要执行额外的工作来处理这些数据,例如,通过软件来调整数据的边界。
- 保证数据结构的紧凑性:在某些情况下,编译器会使用填充(padding)来确保数据结构中的成员变量符合特定的对齐要求。这有助于保持数据结构的紧凑性,避免不必要的内存浪费。
- 跨平台兼容性:不同的系统架构可能有不同的数据对齐要求。使用字节对齐可以提高程序在不同平台间的兼容性。
- 符合特定编程接口:某些编程接口或库可能要求数据按照特定的对齐方式存储,以确保最大的性能和兼容性。
- 避免数据损坏:在多线程环境中,对齐的数据可以减少由于缓存行分裂导致的数据不一致问题。
实现字节对齐:
在C和C++中,可以使用 alignas
关键字来指定变量或类型的对齐要求:
alignas(16) int myVariable; // 确保myVariable在16字节边界上对齐
#include <stdio.h> struct Sample { char a; // 1字节 int b; // 4字节 double c; // 8字节 }; 我们以4字节对齐为例那么上述的Sample就是 4+4+8 =16字节
编译器和链接器通常会自动处理大多数对齐问题,但有时手动指定对齐要求是必要的,特别是在进行系统编程或优化性能关键部分的代码时。
二十八、静态成员函数与普通成员函数的区别?⭐⭐
1.调用方式:
- 静态成员函数可以直接通过类名来调用,无需创建类的对象。
- 普通成员函数必须通过类的对象或指针来调用。
class MyClass { public: static void staticFunc() { // 静态成员函数的实现 } void normalFunc() { // 普通成员函数的实现 } }; int main() { // 调用静态成员函数 MyClass::staticFunc(); MyClass obj; // 调用普通成员函数 obj.normalFunc(); return 0; }
2.访问权限:
- 静态成员函数只能直接访问静态成员变量和静态成员函数,不能直接访问非静态成员变量和非静态成员函数。
- 普通成员函数可以直接访问类的所有成员,包括静态成员和非静态成员。
class MyClass { private: static int staticNum; // 静态成员变量 int num; // 非静态成员变量 public: static void staticFunc() { // 静态成员函数可以访问静态成员变量 staticNum = 10; // 编译错误,静态成员函数不能访问非静态成员变量 // num = 20; } void normalFunc() { // 普通成员函数可以访问静态成员变量 staticNum = 30; // 普通成员函数可以访问非静态成员变量 num = 40; } }; int MyClass::staticNum = 0; int main() { MyClass obj; obj.normalFunc(); // 静态成员函数可以直接访问静态成员变量 MyClass::staticFunc(); return 0; }
3.存储方式:
- 静态成员函数不会影响类的对象的大小,它们存储在类的命名空间中,所有对象共享同一个静态成员函数。
- 普通成员函数被存储在类的对象中,每个对象都有自己的成员函数。
class MyClass { public: static void staticFunc() { // 静态成员函数的实现 } void normalFunc() { // 普通成员函数的实现 } }; int main() { MyClass obj1, obj2; // 所有对象共享同一个静态成员函数 MyClass::staticFunc(); // 每个对象有自己的普通成员函数 obj1.normalFunc(); obj2.normalFunc(); return 0; }
4.this 指针:
- 普通成员函数有一个隐含的指向当前对象的指针,即 this 指针,可以在函数中使用 this 指针访问对象的成员。
- 静态成员函数没有 this 指针,无法直接访问非静态成员。
class MyClass { private: int num; // 非静态成员变量 public: static void staticFunc() { // 静态成员函数没有 this 指针,无法访问非静态成员变量 // num = 10; } void normalFunc() { // 普通成员函数可以通过 this 指针访问非静态成员变量 this->num = 20; } }; int main() { MyClass obj; obj.normalFunc(); // 静态成员函数没有 this 指针,不能直接访问对象成员 return 0; }
5.类作用域:
- 静态成员函数属于类的作用域,可以直接访问类的静态成员变量和静态成员函数,无需通过对象或指针。
- 普通成员函数属于对象的作用域,可以直接访问类的静态成员和非静态成员,需要通过对象或指针来访问。
class MyClass { public: static int staticNum; // 静态成员变量 static void staticFunc() { // 可以直接访问静态成员变量 staticNum = 10; // 可以直接调用静态成员函数 staticFunc(); } void normalFunc() { // 可以直接访问静态成员变量 staticNum = 20; // 可以直接访问非静态成员变量 num = 30; } int num; // 非静态成员变量 }; int MyClass::staticNum = 0; int main() { // 静态成员函数可以直接访问静态成员变量和静态成员函数 MyClass::staticFunc(); MyClass obj; // 普通成员函数需要通过对象访问静态成员变量和静态成员函数 obj.normalFunc(); return 0; }
二十九、为什么静态成员函数不能访问非静态成员?⭐
静态成员函数不能直接访问非静态成员变量和非静态成员函数,这是因为静态成员函数并不隶属于任何具体的对象,而是属于整个类。这导致在静态成员函数中没有隐含的指向对象的 this 指针。由于非静态成员是在对象的上下文中存在的,因此在没有对象的情况下,无法直接访问非静态成员。
class MyClass { public: static void staticFunc() { // 静态成员函数无法直接访问非静态成员变量和非静态成员函数 // num = 10; // 错误:无法访问非静态成员变量 // normalFunc(); // 错误:无法访问非静态成员函数 } void normalFunc() { // 普通成员函数可以直接访问非静态成员变量和非静态成员函数 num = 20; normalFunc2(); } private: int num; // 非静态成员变量 void normalFunc2() { // 非静态成员函数可以直接访问非静态成员变量 num = 30; } }; int main() { MyClass obj; obj.normalFunc(); // 静态成员函数无法直接访问非静态成员变量和非静态成员函数 // MyClass::staticFunc(); // 错误:无法访问非静态成员变量和非静态成员函数 return 0; }
在上述示例中,静态成员函数 staticFunc() 试图访问非静态成员变量 num 和非静态成员函数 normalFunc(),但由于没有对象的上下文,因此无法直接访问它们。相反,普通成员函数 normalFunc() 和 normalFunc2() 可以直接访问非静态成员变量和非静态成员函数,因为它们在对象的上下文中被调用。
要访问非静态成员,需要使用对象或指针进行访问,例如在静态成员函数内部通过对象调用非静态成员函数或访问非静态成员变量。
三十、说说原子操作?⭐
原子操作(Atomic Operations)是指在多线程环境中,一个操作在执行过程中不会被其他线程中断,要么完全执行,要么完全不执行,不存在中间状态。这种操作的特点是它保证了操作的完整性和一致性,对于保证并发程序的正确性至关重要。
原子操作的关键特性:
- 不可分割性:原子操作是不可分割的,即在执行过程中不会被其他线程的操作打断。
- 线程安全:原子操作保证了在多线程环境中,即使多个线程同时访问共享数据,也不会导致数据不一致的问题。
- 锁自由:原子操作通常不依赖于锁(Lock-free),这意味着它们可以减少锁带来的开销和潜在的死锁问题。
常见的原子操作:
- 原子变量:如原子计数器,它们提供了一系列原子操作,如原子增加(atomic increment)和原子减少(atomic decrement)。
- 原子比较并交换(Compare-and-Swap, CAS):这是一种常用的原子操作,用于实现无锁数据结构。它比较内存中的值与预期值是否相同,如果相同,则将其更新为新值。
- 原子加载和存储:原子地从内存中加载数据到寄存器,或从寄存器存储数据到内存。
- 原子交换(Atomic Exchange):原子地交换两个值,通常用于锁和条件变量的实现。
- 原子累加(Atomic Fetch-and-Add):原子地将一个值加到另一个值上,并返回原始值。
C++中的原子操作:
C++11标准引入了对原子操作的支持,通过 <atomic>
头文件提供了一系列原子类型和操作。例如:
#include <atomic> #include <thread> std::atomic<int> counter(0); // 定义一个原子整型变量 void increment() { counter.fetch_add(1, std::memory_order_relaxed); // 原子增加 } int main() { std::thread t1(increment); std::thread t2(increment); t1.join(); t2.join(); std::cout << "Counter: " << counter << std::endl; // 输出结果应该是2 return 0; }
在这个例子中,fetch_add
是一个原子操作,它将 counter
的值原子地增加1,并返回增加前的值。
原子操作在构建高性能并发应用程序时非常有用,因为它们可以减少锁的使用,提高程序的可伸缩性和响应性。然而,过度依赖原子操作可能会导致程序复杂度增加,因此应当在确实需要保证操作原子性时才使用。
三十一、静态变量什么时候初始化?⭐
在C语言中,全局变量和静态变量的初始化发生在编译期。这意味着它们的初始化在程序执行之前就已经完成,无论是否真正使用这些变量。
#include <stdio.h> int globalVar = 10; // 全局变量,在编译期初始化 void function() { static int staticVar = 20; // 静态变量,在编译期初始化 printf("Static variable: %d ", staticVar); } int main() { printf("Global variable: %d", globalVar); function(); return 0; }
而在C++中,全局变量和静态变量的初始化行为有所不同。全局变量和静态变量在C++中的初始化推迟至它们"首次用到"时才进行,这是C++标准规定的行为。
#include <iostream> int globalVar = 10; // 全局变量,推迟初始化 static int staticVar = 20; // 静态变量,推迟初始化 void function() { static int staticVarFunction = 30; // 静态变量,推迟初始化 std::cout << "Static variable in function: " << staticVarFunction << std::endl; } int main() { std::cout << "Global variable: " << globalVar << std::endl; std::cout << "Static variable: " << staticVar << std::endl; function(); return 0; }#自动驾驶##机器人##C++##八股#
在自动驾驶和机器人领域,C++因其高性能、内存管理高效和跨平台兼容性等特性,被广泛应用。本专栏整理了C++面试中常遇到的八股问题,可私信作者要飞书文档,不论是嵌入式软开、算法、软件开发都可以阅读,包括了C++的虚函数、C++11新特性、C++的STL库、Linux常见命令......