1.3.1 简述一下什么是面向对象,面向对象与面向过程的区别
什么是面向对象
面向对象(Object-Oriented Programming,OOP)是一种编程范式,它通过将现实世界中的实体抽象为“对象”来组织代码。面向对象编程关注对象及其交互,而不仅仅是处理数据和函数。OOP的主要特征包括:
-
对象:对象是类的实例,封装了数据和操作这些数据的方法。每个对象都有自己的状态和行为。
-
类:类是对象的模板或蓝图,定义了对象的属性和方法。通过类,可以创建多个对象。
-
封装:封装是将数据(属性)和对数据的操作(方法)结合在一起,限制外部直接访问对象的内部状态,以保护数据。
-
继承:继承是允许新类从已有类继承属性和方法的机制,有助于代码复用和建立层次关系。
-
多态:多态允许不同类型的对象以相同的方式响应相同的消息,从而实现灵活性和可扩展性。
面向对象与面向过程的区别
面向对象编程和面向过程编程是两种不同的编程范式,它们在设计理念、结构和实现方式上存在显著区别:
特点 | 面向对象 (OOP) | 面向过程 (POP) |
---|---|---|
基本单位 | 对象(class和object的组合) | 函数和过程(procedure) |
关注点 | 数据及其操作的封装,强调对象之间的交互 | 功能和步骤的执行,强调过程的顺序和控制流 |
设计方法 | 通过创建对象来建模现实世界,通过对象间的交互完成任务 | 通过功能模块来处理数据,强调逻辑和控制流 |
代码重用 | 通过继承和多态实现代码的复用 | 通过函数调用和模块化实现代码的复用 |
状态管理 | 对象的状态封装在对象内部,外部通过方法访问和修改 | 状态由全局变量或参数传递,容易导致数据共享和状态混乱 |
灵活性和扩展性 | 易于扩展和修改,通过继承和多态支持变化 | 修改和扩展可能需要更改多个函数,灵活性较差 |
设计复杂度 | 对象和类的设计可以处理更复杂的系统 | 简单系统容易实现,但复杂系统可能导致代码混乱 |
总结
面向对象编程通过将数据和操作封装在对象中,提供了更灵活和可维护的方式来开发软件,而面向过程编程则更关注于实现具体的步骤和过程。根据项目的需求和复杂性,开发者可以选择适合的编程范式。
1.3.2 简述一下面向对象的三大特征
面向对象编程(OOP)的三大特征是封装、继承和多态。这些特征构成了面向对象编程的核心,帮助开发者更好地组织代码、提高代码的可重用性和可维护性。
总结
- 封装:隐藏内部实现,通过公开接口操作对象,保护数据并提高安全性。
- 继承:通过继承基类的属性和方法,重用代码并实现类的扩展。
- 多态:通过统一的接口表现不同的行为,增加程序的灵活性和可扩展性。
1. 封装(Encapsulation)
封装是指将对象的属性(数据)和方法(操作)组合在一起,并将这些细节隐藏起来,仅通过公开的接口(方法)来访问和修改对象的状态。封装的核心思想是“隐藏内部实现,暴露外部接口”,它主要通过访问控制(如private
、protected
、public
)来实现。
- 优点:
- 提高代码的安全性,避免外部直接修改对象的内部状态。
- 提供清晰的接口,简化了对象的使用,增强了模块化。
- 使代码更易于维护和修改,内部实现的改变不会影响外部代码。
示例:
class Car {
private:
int speed; // 速度为私有成员
public:
void setSpeed(int s) { // 通过公有方法修改速度
speed = s;
}
int getSpeed() { // 通过公有方法获取速度
return speed;
}
};
2. 继承(Inheritance)
继承是指通过从已有类(基类/父类)中派生出新类(派生类/子类),从而继承基类的属性和方法。继承允许代码的重用,避免重复编写相同的代码,并且可以通过派生类对基类进行扩展和定制。
- 优点:
- 实现代码复用,减少重复代码。
- 建立类之间的层次结构,体现“是一个”的关系(如“汽车是一个交通工具”)。
- 派生类可以通过重写基类方法实现多态。
示例:
class Vehicle {
public:
void start() { // 基类中的方法
cout << "Vehicle started" << endl;
}
};
class Car : public Vehicle { // Car继承自Vehicle
public:
void start() { // 子类重写基类方法
cout << "Car started" << endl;
}
};
3. 多态(Polymorphism)
多态是指同一操作在不同对象上可以表现出不同的行为。在C++中,多态主要通过函数重载、运算符重载和虚函数实现。多态允许我们用相同的接口处理不同类型的对象,提高了程序的灵活性和扩展性。
- 静态多态(编译时多态):通过函数重载和运算符重载实现,不同的参数类型会调用不同的函数。
- 动态多态(运行时多态):通过虚函数实现,派生类可以根据实际对象类型重写基类的方法,运行时根据对象的实际类型调用相应的方法。
优点:
- 增强程序的灵活性,使代码更具可扩展性。
- 提供统一接口,便于处理不同类型的对象。
示例:
class Animal {
public:
virtual void sound() { // 基类中定义虚函数
cout << "Some animal sound" << endl;
}
};
class Dog : public Animal {
public:
void sound() override { // 子类重写虚函数
cout << "Bark" << endl;
}
};
class Cat : public Animal {
public:
void sound() override { // 子类重写虚函数
cout << "Meow" << endl;
}
};
1.3.3 简述一下C++的重载和重写,以及它们的区别
在C++中,重载(Overloading)和重写(Overriding)是两种不同的编程技术,尽管它们在某些方面类似,但它们的用途和规则是不同的。
1. 重载与重写的区别
区别点 | 重载(Overloading) | 重写(Overriding) |
---|---|---|
作用域 | 发生在同一作用域中(即同一个类中)。 | 发生在继承关系中(即子类中重写父类的方法)。 |
函数签名 | 函数签名必须不同(参数类型、数量、顺序不同)。 | 函数签名必须完全相同(包括参数类型、数量、顺序和返回类型)。 |
关键字 | 不需要使用特殊关键字。 | 基类函数必须是虚函数(用virtual 关键字),子类可以用override 明示重写。 |
调用时的选择方式 | 编译时根据参数列表选择调用哪个函数(静态绑定)。 | 运行时根据对象类型选择调用哪个函数(动态绑定)。 |
应用场景 | 用于实现函数的多种不同实现,处理不同类型或数量的参数。 | 用于多态性,派生类提供基类虚函数的自定义实现。 |
返回类型 | 返回类型可以不同。 | 返回类型必须与被重写的基类虚函数相同。 |
2. 总结
- 重载:是在同一作用域中定义同名但参数不同的多个函数,它用于处理不同类型或数量的参数,是一种编译时的多态性。
- 重写:是在继承关系中,子类重写父类的虚函数,它用于实现运行时的多态性,使得通过基类指针或引用调用时执行派生类的函数。
重载和重写的主要区别在于作用范围、函数签名的要求和调用时的绑定方式。
3. 重载(Overloading)
重载指的是同一作用域中定义多个同名函数,但它们的参数类型、数量或顺序不同。编译器根据调用时提供的参数列表来选择调用哪个函数。这种技术可以提高代码的可读性和灵活性。
特点:
- 同一个作用域中,可以定义多个同名函数。
- 函数的参数数量、类型或顺序必须不同(与返回类型无关)。
- 可以应用于普通函数和运算符(运算符重载)。
示例:
class Calculator {
public:
int add(int a, int b) { // 参数为两个int
return a + b;
}
double add(double a, double b) { // 参数为两个double
return a + b;
}
};
在这个例子中,add
函数被重载,可以处理不同类型的参数(int
或double
)。
4. 重写(Overriding)
重写指的是在派生类中重新定义基类中的虚函数,以便派生类能够提供其自身的实现。重写通常用于多态性,使基类的指针或引用可以调用派生类中重写的函数。
特点:
- 基类中的函数必须用**
virtual
关键字**声明为虚函数。 - 派生类中的函数签名(函数名、参数列表和返回类型)必须与基类中的虚函数完全相同。
- 重写发生在继承关系中,即子类重写父类的虚函数。
- 通过基类的指针或引用调用重写的函数时,会执行派生类的实现(动态绑定)。
示例:
class Animal {
public:
virtual void makeSound() { // 基类中的虚函数
cout << "Animal sound" << endl;
}
};
class Dog : public Animal {
public:
void makeSound() override { // 派生类中重写虚函数
cout << "Bark" << endl;
}
};
在这个例子中,makeSound
函数在Dog
类中重写了基类Animal
的实现。如果通过基类指针调用makeSound
,运行时将执行Dog
类的版本。
1.3.4 说说C++的重载和重写是如何实现的
总结与对比
特性 | 重载(Overloading) | 重写(Overriding) |
---|---|---|
实现机制 | 编译时通过函数名修饰(Name Mangling)实现 | 通过虚函数表(vtable)和虚表指针(vptr)在运行时实现 |
绑定类型 | 静态绑定(编译时确定) | 动态绑定(运行时确定) |
作用范围 | 同一类内,不涉及继承 | 继承体系中,子类重写基类的虚函数 |
性能开销 | 无运行时开销,编译时决定 | 运行时有一定的性能开销(通过虚表指针查找虚函数表,动态调用) |
目的 | 提供同名函数的多个版本以支持不同的参数类型或数量 | 实现多态性,通过基类指针调用派生类的方法 |
重载(Overloading)
重载是通过编译时的静态绑定实现的,即在编译时,编译器通过函数名修饰(Name Mangling)来生成唯一的函数标识符,以区分同名的不同函数。重载的目的是在同一个作用域内提供同名函数的多个版本,支持不同的参数类型或数量,从而提高代码的灵活性和可读性。
- 静态绑定:重载函数的选择在编译时完成,编译器根据参数列表决定调用哪个版本的函数。
- 函数签名差异:函数名相同,但参数类型、数量或顺序不同。
- 无继承关系:重载发生在同一类的作用域内,不涉及继承关系。
重写(Overriding)
重写是通过运行时的动态绑定实现的。它依赖于C++的虚函数机制,即基类中的虚函数可以在派生类中被重新定义,从而实现多态性。虚函数表(vtable)和虚表指针(vptr)是重写的核心机制,通过它们在运行时决定实际调用派生类的函数版本。
- 动态绑定:在运行时,根据对象的实际类型(基类或派生类)来选择调用的函数版本。
- 继承关系:重写仅发生在派生类中对基类的虚函数进行重新定义时。
- 虚函数表:编译器为包含虚函数的类生成虚函数表,派生类会更新虚表中的函数指针指向自己的重写版本。
通过以上对比可以看到,重载和重写虽然允许使用相同的函数名,但它们的应用场景、实现机制和绑定方式都不同。
1.3.5 说说C语言如何实现C++语言中的重载
总结与对比
特性 | C++ 中的重载(Overloading) | C 语言实现重载的方式 |
---|---|---|
实现机制 | 编译时通过**函数名修饰(Name Mangling)**实现 | 通过函数名变化、宏、或函数指针手动实现 |
函数名 | 同名函数,编译器根据参数类型、数量选择不同的函数实现 | 需要人为改变函数名来模拟不同功能 |
编译器支持 | 编译器直接支持重载 | C 语言不支持,需要手动编码实现重载效果 |
灵活性 | 自动根据参数选择函数 | 需要根据实际需求手动编写多个不同的函数或使用宏来处理不同的参数 |
参数差异处理 | 自动处理不同的参数类型和数量 | 通过编写多个函数或使用宏来处理不同的参数类型或数量 |
C 语言如何实现 C++ 的重载
由于 C 语言不支持函数重载,因此需要通过手动的方法来模拟重载的效果。以下几种方法常用于在 C 中实现类似于 C++ 中函数重载的功能:
1. 通过不同的函数名称
在 C 中,无法使用同样的函数名称,但可以通过人为修改函数名,模拟重载的效果。每个函数可以有不同的参数类型或数量,只需要给这些函数不同的名字即可。
示例:
int add_int(int a, int b) {
return a + b;
}
double add_double(double a, double b) {
return a + b;
}
通过这种方式,可以实现对不同参数类型进行操作,但函数名需要人为区分,比如add_int
和add_double
。
2. 通过宏(Macros)
宏在 C 中可以用来简化代码并实现某种程度的函数重载效果。宏根据不同的参数类型或数量生成不同的函数调用。
示例:
#include <stdio.h>
#define add(x, y) _Generic((x), \
int: add_int, \
double: add_double \
)(x, y)
int add_int(int a, int b) {
return a + b;
}
double add_double(double a, double b) {
return a + b;
}
int main() {
printf("%d\n", add(3, 4)); // 调用 add_int
printf("%f\n", add(3.0, 4.0)); // 调用 add_double
return 0;
}
通过_Generic
宏,C 语言可以根据传入参数的类型选择不同的函数,实现类似重载的效果。
3. 通过函数指针(Function Pointers)
另一种实现函数重载的方式是通过函数指针,它允许在运行时根据具体情况调用不同的函数。这种方法可以提供一定程度的灵活性。
示例:
#include <stdio.h>
int add_int(int a, int b) {
return a + b;
}
double add_double(double a, double b) {
return a + b;
}
void add(void* a, void* b, char type) {
if (type == 'i') {
printf("%d\n", add_int(*(int*)a, *(int*)b));
} else if (type == 'd') {
printf("%f\n", add_double(*(double*)a, *(double*)b));
}
}
int main() {
int x = 3, y = 4;
double m = 3.0, n = 4.0;
add(&x, &y, 'i'); // 调用 add_int
add(&m, &n, 'd'); // 调用 add_double
return 0;
}
通过传递参数的指针和类型标识,可以在运行时根据需要选择不同的函数来执行,从而模拟函数重载。
总结
C 语言不直接支持函数重载,但可以通过改变函数名、使用宏、或函数指针来模拟 C++ 中的重载功能。这种实现方式虽然灵活,但代码编写复杂度和维护成本较高,因为程序员需要手动处理不同的函数调用逻辑。
1.3.6 说说构造函数有几种,分别什么作用?
总结与对比
构造函数类型 | 作用 | 示例 |
---|---|---|
默认构造函数 | 没有参数,初始化对象为默认状态。 | ClassName(); |
有参构造函数 | 允许通过参数初始化对象的成员变量。 | ClassName(int x); |
拷贝构造函数 | 使用已有对象初始化新对象,进行深拷贝或浅拷贝。 | ClassName(const ClassName& obj); |
移动构造函数 | 用于从临时对象移动资源而不是复制资源,提升性能。 | ClassName(ClassName&& obj); |
委托构造函数 | 允许一个构造函数调用另一个构造函数,减少代码重复。 | ClassName() : ClassName(10) {} |
析构函数 | 在对象销毁时调用,释放资源,清理内存。 | ~ClassName(); |
1. 默认构造函数
默认构造函数是没有参数的构造函数,它在对象创建时自动调用,用于将对象的成员变量初始化为默认值。如果用户没有显式定义任何构造函数,编译器会自动生成一个默认构造函数。
作用:
- 初始化对象为默认状态。
- 如果类中没有定义任何构造函数,编译器会生成一个隐式的默认构造函数。
示例:
class MyClass {
public:
MyClass() {
// 默认构造函数
cout << "Default constructor called" << endl;
}
};
2. 有参构造函数
有参构造函数允许通过传递参数来初始化对象的成员变量。用户可以根据需要定义多个有参构造函数来处理不同的初始化场景(这也是函数重载的一部分)。
作用:
- 允许在创建对象时通过参数初始化成员变量。
- 可以提供多个有参构造函数,支持不同的初始化需求。
示例:
class MyClass {
public:
int x;
MyClass(int a) : x(a) {
cout << "Parameterized constructor called" << endl;
}
};
3. 拷贝构造函数
拷贝构造函数用于通过另一个对象来初始化新对象。其主要作用是在对象需要复制时,比如通过值传递或返回对象时,进行深拷贝或浅拷贝。编译器会自动生成一个默认的浅拷贝拷贝构造函数,但用户可以自定义以实现深拷贝。
作用:
- 创建对象时,通过另一个对象的内容初始化新对象。
- 处理指针成员的深拷贝,避免共享同一内存地址。
示例:
class MyClass {
public:
int* ptr;
MyClass(const MyClass& obj) {
// 深拷贝示例
ptr = new int(*obj.ptr);
cout << "Copy constructor called" << endl;
}
};
4. 移动构造函数
移动构造函数用于从临时对象(比如右值引用)中移动资源,而不是复制资源。这种方式避免了不必要的深拷贝,提高了性能。C++11 引入了移动构造函数以支持资源的高效转移。
作用:
- 从另一个对象移动资源,而不是复制资源。
- 提升性能,特别是在涉及大量数据或内存分配时。
示例:
class MyClass {
public:
int* ptr;
MyClass(MyClass&& obj) noexcept {
// 移动构造函数,移动资源而非复制
ptr = obj.ptr;
obj.ptr = nullptr;
cout << "Move constructor called" << endl;
}
};
5. 委托构造函数
委托构造函数允许一个构造函数调用另一个构造函数,以减少代码重复并简化代码逻辑。这在复杂的初始化流程中非常有用。
作用:
- 简化代码,避免重复编写初始化逻辑。
- 通过调用其他构造函数来共享初始化代码。
示例:
class MyClass {
public:
int x;
MyClass() : MyClass(10) {
// 调用有参构造函数
cout << "Delegating constructor called" << endl;
}
MyClass(int a) : x(a) {
cout << "Parameterized constructor called" << endl;
}
};
6. 析构函数
虽然析构函数不是构造函数,但它与对象的生命周期密切相关。析构函数在对象销毁时自动调用,用于释放资源和清理动态分配的内存。它通常用于类中涉及到动态内存管理的场景。
作用:
- 在对象生命周期结束时清理资源(例如,释放动态分配的内存、关闭文件等)。
- 析构函数不能被重载。
示例:
class MyClass {
public:
~MyClass() {
// 析构函数
cout << "Destructor called" << endl;
}
};
总结
C++中的构造函数类型多样化,针对不同的初始化需求提供了灵活的选择。默认构造函数和有参构造函数用于基本的对象初始化,拷贝构造函数和移动构造函数则处理对象的复制和移动,委托构造函数简化了初始化流程,最后析构函数负责对象销毁时的资源清理。
1.3.7 只定义析构函数,会自动生成哪些构造函数
总结与对比
定义析构函数的情况 | 自动生成的构造函数 | 说明 |
---|---|---|
只定义析构函数 | 自动生成默认构造函数、拷贝构造函数、拷贝赋值运算符、移动构造函数和移动赋值运算符(在满足条件时) | 编译器会根据需要生成默认的构造和赋值函数,除非用户定义了其他构造函数 |
自动生成的构造函数
如果在 C++ 类中只定义了析构函数,编译器会自动生成以下几种构造函数:
1. 默认构造函数
编译器会生成一个隐式的默认构造函数,如果类没有手动定义任何构造函数。默认构造函数会将对象的成员变量初始化为默认值或未定义状态。
说明:
- 只有当没有定义其他构造函数时,编译器才会生成默认构造函数。
- 该函数是无参的,自动初始化类的成员。
示例:
class MyClass {
public:
~MyClass() {
// 析构函数定义
}
};
// 自动生成的默认构造函数:MyClass() {}
2. 拷贝构造函数
编译器会生成一个拷贝构造函数,用于通过已有对象来初始化新对象。这个拷贝构造函数执行浅拷贝,即逐成员复制对象中的数据。
说明:
- 如果类没有定义自定义的拷贝构造函数,编译器会生成一个隐式的拷贝构造函数。
- 自动生成的拷贝构造函数进行逐位复制(浅拷贝)。
示例:
class MyClass {
public:
~MyClass() {
// 析构函数定义
}
};
// 自动生成的拷贝构造函数:MyClass(const MyClass& other) {}
3. 拷贝赋值运算符
编译器还会自动生成一个拷贝赋值运算符,用于将一个对象赋值给另一个对象。它和拷贝构造函数类似,也执行浅拷贝。
说明:
- 如果没有手动定义拷贝赋值运算符,编译器会自动生成。
- 自动生成的版本会逐位复制对象的成员变量。
示例:
class MyClass {
public:
~MyClass() {
// 析构函数定义
}
};
// 自动生成的拷贝赋值运算符:MyClass& operator=(const MyClass& other) {}
4. 移动构造函数(条件生成)
如果类中包含支持移动的资源(例如指针、动态内存等),编译器在满足某些条件时会自动生成移动构造函数。移动构造函数允许对象的资源从临时对象(右值)中移动,而不进行复制操作。
说明:
- 如果类没有自定义拷贝构造函数或拷贝赋值运算符,且没有特殊操作阻止移动语义,编译器可能自动生成移动构造函数。
- 如果定义了拷贝构造函数,编译器不会自动生成移动构造函数,除非显示要求。
示例:
class MyClass {
public:
~MyClass() {
// 析构函数定义
}
};
// 如果没有定义拷贝构造函数,自动生成移动构造函数:MyClass(MyClass&& other) noexcept {}
5. 移动赋值运算符(条件生成)
类似于移动构造函数,编译器也会在特定条件下生成移动赋值运算符,用于通过右值引用赋值操作,将资源从一个对象移动到另一个对象。
说明:
- 如果类没有自定义赋值运算符且满足移动条件,编译器会生成移动赋值运算符。
- 定义了拷贝赋值运算符或拷贝构造函数时,移动赋值运算符不会被自动生成。
示例:
class MyClass {
public:
~MyClass() {
// 析构函数定义
}
};
// 如果没有定义赋值运算符,自动生成移动赋值运算符:MyClass& operator=(MyClass&& other) noexcept {}
总结
在只定义析构函数的情况下,编译器会自动生成默认构造函数、拷贝构造函数、拷贝赋值运算符,并在某些情况下生成移动构造函数和移动赋值运算符。
1.3.8 说说一个类,默认会生成哪些函数
总结与对比
默认生成的函数 | 作用 | 生成条件 |
---|---|---|
默认构造函数 | 初始化对象为默认状态。 | 如果类中没有手动定义任何构造函数,编译器自动生成。 |
拷贝构造函数 | 通过现有对象创建新对象,执行浅拷贝操作。 | 如果没有手动定义拷贝构造函数,编译器自动生成。 |
拷贝赋值运算符 | 将一个对象的内容复制给另一个对象,执行浅拷贝。 | 如果没有手动定义拷贝赋值运算符,编译器自动生成。 |
析构函数 | 在对象销毁时清理资源,释放内存等。 | 如果没有手动定义析构函数,编译器自动生成。 |
移动构造函数 | 移动对象的资源而不是复制资源,用于右值引用,提升性能。 | 如果没有手动定义拷贝构造函数或赋值运算符,且编译器认为移动构造函数适用。 |
移动赋值运算符 | 移动右值引用对象的资源,而不是进行复制操作,提升性能。 | 如果没有手动定义拷贝赋值运算符或拷贝构造函数,且满足移动语义条件。 |
1. 默认构造函数
如果类中没有手动定义任何构造函数,编译器会自动生成一个默认构造函数。该构造函数用于将对象初始化为默认状态,例如将成员变量设置为未定义的默认值或零值。
作用:
- 用于创建对象时进行默认的初始化操作。
- 如果类中定义了其他构造函数(如有参构造函数),则不会生成默认构造函数。
示例:
class MyClass {
int x;
};
// 自动生成的默认构造函数:MyClass() {}
2. 拷贝构造函数
编译器会生成一个拷贝构造函数,用于通过已有对象初始化新对象。拷贝构造函数执行浅拷贝,即逐个成员变量进行复制。如果类中没有定义自定义的拷贝构造函数,编译器会提供一个默认的版本。
作用:
- 通过已有对象创建新对象,执行成员的逐位复制操作。
- 如果类中定义了指针或动态内存管理,用户通常需要手动定义拷贝构造函数以执行深拷贝。
示例:
class MyClass {
int x;
};
// 自动生成的拷贝构造函数:MyClass(const MyClass& other) {}
3. 拷贝赋值运算符
拷贝赋值运算符用于将一个对象的内容复制给另一个已经存在的对象。编译器生成的默认版本也执行浅拷贝操作,逐成员复制源对象的值。
作用:
- 在赋值操作中,将一个对象的内容复制给另一个对象。
- 如果类有动态内存管理,通常需要自定义以避免内存泄漏或共享。
示例:
class MyClass {
int x;
};
// 自动生成的拷贝赋值运算符:MyClass& operator=(const MyClass& other) {}
4. 析构函数
析构函数用于在对象销毁时执行资源清理操作,例如释放动态分配的内存、关闭文件等。如果用户没有定义析构函数,编译器会自动生成一个默认的析构函数,该析构函数不会做任何特别的操作,除非类中包含需要手动管理的资源。
作用:
- 在对象生命周期结束时清理资源。
- 自动生成的析构函数是空的,不处理动态内存等复杂资源。
示例:
class MyClass {
int x;
};
// 自动生成的析构函数:~MyClass() {}
5. 移动构造函数
如果类中定义了指针或其他需要动态管理的资源,编译器在满足某些条件时会生成移动构造函数,以通过移动资源而不是复制资源来优化性能。该函数用于右值引用,特别是在临时对象的场景中提升效率。
作用:
- 通过移动而不是复制资源,减少不必要的深拷贝,提高性能。
- 编译器仅在适当的情况下自动生成移动构造函数。
示例:
class MyClass {
int* ptr;
};
// 自动生成的移动构造函数:MyClass(MyClass&& other) noexcept {}
6. 移动赋值运算符
类似于移动构造函数,编译器会生成移动赋值运算符,用于通过右值引用移动资源到另一个对象中。它用于避免拷贝,直接转移所有权,从而提高效率。
作用:
- 优化赋值操作,通过移动资源而不是复制资源,提高性能。
- 适用于右值引用场景。
示例:
class MyClass {
int* ptr;
};
// 自动生成的移动赋值运算符:MyClass& operator=(MyClass&& other) noexcept {}
总结
C++ 中,如果用户没有手动定义相关函数,编译器会默认生成默认构造函数、拷贝构造函数、拷贝赋值运算符、析构函数,并在合适的情况下生成移动构造函数和移动赋值运算符。这些函数默认执行浅拷贝操作,适用于类中没有动态资源管理的场景。如果类涉及动态内存管理,建议用户手动定义这些函数。
1.3.9 说说C++类对象的初始化顺序,有多重继承情况下的顺序
总结与对比
情况 | 初始化顺序 | 说明 |
---|---|---|
单继承情况下 | 1. 基类构造函数 → 2. 成员变量 → 3. 派生类构造函数 | 初始化从基类开始,然后是成员变量,最后是派生类构造函数。 |
多重继承情况下 | 1. 按继承顺序调用所有基类的构造函数 → 2. 成员变量 → 3. 派生类构造函数 | 按声明顺序依次初始化多个基类的构造函数,继承顺序在初始化顺序中起重要作用。 |
虚继承情况下 | 1. 虚基类的构造函数最先调用 → 2. 非虚基类 → 3. 成员变量 → 4. 派生类构造函数 | 虚基类的构造函数最先被调用,确保在多重继承中虚基类只初始化一次。 |
1. 单继承下的类对象初始化顺序
在 C++ 中,当类对象进行初始化时,初始化顺序是从基类开始,然后初始化成员变量,最后调用派生类的构造函数。这是因为在派生类构造函数中可能依赖于基类和成员变量的正确初始化。
初始化顺序:
- 基类构造函数:基类的构造函数在派生类的构造函数之前调用。
- 成员变量:类中定义的成员变量按声明顺序进行初始化(而非在构造函数中出现的顺序)。
- 派生类构造函数:最后调用派生类的构造函数。
示例:
class Base {
public:
Base() {
cout << "Base constructor" << endl;
}
};
class Derived : public Base {
int x;
public:
Derived() : x(10) {
cout << "Derived constructor" << endl;
}
};
// 输出顺序:
// Base constructor
// Derived constructor
2. 多重继承下的类对象初始化顺序
在多重继承的情况下,初始化顺序依然遵循从基类到派生类的顺序。但是,如果有多个基类,基类的构造函数按照类声明的顺序依次调用。
初始化顺序:
- 多个基类的构造函数:按基类的声明顺序调用每个基类的构造函数。
- 成员变量:类中的成员变量按声明顺序初始化。
- 派生类构造函数:最后调用派生类的构造函数。
示例:
class Base1 {
public:
Base1() {
cout << "Base1 constructor" << endl;
}
};
class Base2 {
public:
Base2() {
cout << "Base2 constructor" << endl;
}
};
class Derived : public Base1, public Base2 {
int x;
public:
Derived() : x(10) {
cout << "Derived constructor" << endl;
}
};
// 输出顺序:
// Base1 constructor
// Base2 constructor
// Derived constructor
3. 虚继承下的类对象初始化顺序
在涉及虚继承的情况下,初始化顺序稍微复杂一些。虚基类的构造函数总是最先被调用,因为虚基类在多重继承中只会被初始化一次。随后,非虚基类的构造函数按照声明顺序调用,然后依次初始化成员变量,最后调用派生类的构造函数。
初始化顺序:
- 虚基类的构造函数:虚基类构造函数最先被调用,确保虚基类只初始化一次。
- 非虚基类的构造函数:按声明顺序调用非虚基类的构造函数。
- 成员变量:成员变量按声明顺序进行初始化。
- 派生类构造函数:最后调用派生类的构造函数。
示例:
class Base {
public:
Base() {
cout << "Base constructor" << endl;
}
};
class VirtualBase : virtual public Base {
public:
VirtualBase() {
cout << "VirtualBase constructor" << endl;
}
};
class Derived : public VirtualBase {
int x;
public:
Derived() : x(10) {
cout << "Derived constructor" << endl;
}
};
// 输出顺序:
// Base constructor
// VirtualBase constructor
// Derived constructor
总结
- 单继承:基类先初始化,之后是成员变量,最后是派生类。
- 多重继承:基类按声明顺序依次初始化,之后是成员变量,最后是派生类。
- 虚继承:虚基类先初始化,确保只初始化一次,之后是非虚基类,最后是派生类和成员变量的初始化。
1.3.10 简述下 ‘向上转型’ 和 ‘向下转型’
总结与对比
类型 | 说明 | 安全性 | 使用场景 |
---|---|---|---|
向上转型(Upcasting) | 将派生类对象转换为基类类型 | 安全,自动进行,无需显式强制转换 | 通过基类指针或引用操作派生类对象 |
向下转型(Downcasting) | 将基类对象转换为派生类类型 | 不安全,必须显式强制转换,且需要运行时检查 | 需要调用派生类中特有的成员函数或访问派生类特定属性 |
1. 向上转型(Upcasting)
向上转型是指将派生类对象转换为基类类型。这种转换是隐式且安全的,因为派生类包含了基类的所有特性。编译器会自动进行这种转换,因此不需要任何强制类型转换操作。
说明:
- 在向上转型时,派生类对象会被视为基类对象,无法直接访问派生类中特有的成员,但可以访问基类中的成员。
- 这种转型在多态(polymorphism)中非常常见,尤其是使用基类指针或引用来操作派生类对象时。
使用场景:
- 通过基类指针或引用操作不同派生类对象,实现多态。
- 减少代码耦合,增强程序的灵活性和扩展性。
示例:
class Base {
public:
void show() {
cout << "Base class" << endl;
}
};
class Derived : public Base {
public:
void show() {
cout << "Derived class" << endl;
}
};
Derived d;
Base* basePtr = &d; // 向上转型
basePtr->show(); // 输出:Base class
在上述例子中,派生类 Derived
对象 d
被转换为基类 Base
类型的指针 basePtr
。虽然 d
是 Derived
类的对象,但在向上转型后,只能通过 basePtr
访问 Base
类的成员。
2. 向下转型(Downcasting)
向下转型是指将基类对象转换为派生类类型。与向上转型不同,向下转型是不安全的,必须进行显式强制转换,并且需要在运行时通过 dynamic_cast
检查转换是否成功。若转换不合法,dynamic_cast
返回 nullptr
或抛出异常。
说明:
- 向下转型不自动进行,必须通过
dynamic_cast
或static_cast
强制转换类型。dynamic_cast
安全,适用于多态类型转换,转换失败时返回nullptr
。static_cast
不进行类型检查,效率高但存在潜在的风险,容易导致未定义行为。
- 向下转型的目的是为了调用派生类中特有的成员函数或访问派生类特定的属性。
使用场景:
- 当需要调用派生类中特有的成员函数或访问派生类特有的成员时。
- 进行多态处理时,动态判断对象实际类型并进行类型转换。
示例:
class Base {
public:
virtual void show() {
cout << "Base class" << endl;
}
};
class Derived : public Base {
public:
void show() {
cout << "Derived class" << endl;
}
};
Base* basePtr = new Derived(); // 向上转型
Derived* derivedPtr = dynamic_cast<Derived*>(basePtr); // 向下转型
if (derivedPtr) {
derivedPtr->show(); // 输出:Derived class
} else {
cout << "Invalid downcast" << endl;
}
在上述例子中,dynamic_cast
用于将 Base
类型的指针 basePtr
向下转型为 Derived
类型的指针 derivedPtr
。如果 basePtr
实际上指向一个 Derived
对象,则向下转型成功,并可以访问 Derived
类中的成员;否则,返回 nullptr
。
总结
- 向上转型是隐式和安全的,不需要显式的类型转换,通常用于多态操作。
- 向下转型则是不安全的,必须通过显式强制转换来完成,并且需要运行时检查转换的合法性。
1.3.11 简述下深拷贝和浅拷贝,如何实现深拷贝
总结与对比
特性 | 浅拷贝(Shallow Copy) | 深拷贝(Deep Copy) |
---|---|---|
定义 | 只复制对象的基本数据类型和指针,不复制指针指向的数据。 | 复制对象的所有数据,包括指针指向的数据,确保每个指针指向的新内存。 |
内存管理 | 原对象和拷贝对象共享同一内存地址,容易导致资源管理问题(如双重释放)。 | 原对象和拷贝对象拥有各自独立的内存,避免了共享内存引发的问题。 |
实现方式 | 默认的拷贝构造函数和赋值运算符实现。 | 需要自定义拷贝构造函数和赋值运算符。 |
使用场景 | 适合数据成员都是基本数据类型的类。 | 适合数据成员含有动态分配内存的类,例如指针、数组等。 |
1. 浅拷贝(Shallow Copy)
浅拷贝是指复制对象时,只复制对象的基本数据类型和指针,但不复制指针指向的数据。这样会导致原对象和拷贝对象共享同一内存地址。
示例:
class Shallow {
public:
int* data;
Shallow(int value) {
data = new int(value);
}
// 默认拷贝构造函数(浅拷贝)
Shallow(const Shallow& other) {
data = other.data; // 只是复制指针,不复制数据
}
~Shallow() {
delete data; // 可能导致双重释放
}
};
int main() {
Shallow obj1(42);
Shallow obj2 = obj1; // 浅拷贝
// 释放 obj1 的内存后,obj2 的指针指向的内存也被释放
delete obj1.data;
// 访问 obj2 的数据会导致未定义行为
std::cout << *obj2.data; // 可能崩溃
}
2. 深拷贝(Deep Copy)
深拷贝是指在复制对象时,除了复制对象的基本数据类型外,还要复制指针指向的数据。这样,每个对象都有自己的内存,互不干扰。
实现深拷贝的方法:
- 自定义拷贝构造函数和赋值运算符,确保在复制时为指针分配新的内存并复制数据。
示例:
class Deep {
public:
int* data;
Deep(int value) {
data = new int(value);
}
// 自定义拷贝构造函数(深拷贝)
Deep(const Deep& other) {
data = new int(*other.data); // 分配新内存并复制数据
}
// 自定义赋值运算符(深拷贝)
Deep& operator=(const Deep& other) {
if (this != &other) { // 防止自我赋值
delete data; // 释放原有内存
data = new int(*other.data); // 分配新内存并复制数据
}
return *this;
}
~Deep() {
delete data; // 正确释放内存
}
};
int main() {
Deep obj1(42);
Deep obj2 = obj1; // 深拷贝
// 现在 obj1 和 obj2 的 data 指针指向不同的内存
std::cout << *obj1.data << " " << *obj2.data << std::endl; // 输出:42 42
*obj1.data = 100; // 修改 obj1 的数据
std::cout << *obj1.data << " " << *obj2.data << std::endl; // 输出:100 42
}
总结
- 浅拷贝:只复制对象的基本数据和指针,导致共享内存,容易造成内存问题。
- 深拷贝:复制对象的所有数据,包括指针指向的数据,确保每个对象都有独立的内存,避免内存管理问题。
- 实现深拷贝:需要自定义拷贝构造函数和赋值运算符,以确保正确处理动态分配的内存。
1.3.12 简述一下C++中的多态
总结与对比
特性 | 编译时多态(Compile-time Polymorphism) | 运行时多态(Runtime Polymorphism) |
---|---|---|
定义 | 在编译时决定调用哪个函数的多态性,通常通过函数重载和运算符重载实现。 | 在运行时决定调用哪个函数的多态性,通常通过虚函数实现。 |
实现方式 | 函数重载、运算符重载 | 虚函数、纯虚函数和继承 |
效率 | 效率较高,因为在编译时就已确定了调用的函数。 | 由于涉及到动态绑定,运行时性能相对较低。 |
类型 | 静态多态(static polymorphism) | 动态多态(dynamic polymorphism) |
1. C++ 中的多态
多态(Polymorphism)是面向对象编程的一个重要特性,允许不同的类以相同的接口进行交互。C++ 中的多态主要分为编译时多态和运行时多态。
1.1 编译时多态
编译时多态通过函数重载和运算符重载实现。编译器根据参数类型和数量来决定调用哪个函数。
示例:函数重载
#include <iostream>
class Poly {
public:
void print(int i) {
std::cout << "Integer: " << i << std::endl;
}
void print(double d) {
std::cout << "Double: " << d << std::endl;
}
};
int main() {
Poly poly;
poly.print(5); // 调用 print(int)
poly.print(5.5); // 调用 print(double)
return 0;
}
在这个示例中,print
函数被重载,编译器根据参数类型在编译时决定调用哪个版本。
1.2 运行时多态
运行时多态通过虚函数实现,允许在基类中声明虚函数,然后在派生类中重写这些虚函数。程序在运行时根据对象的实际类型来决定调用哪个函数。
示例:虚函数
#include <iostream>
class Base {
public:
virtual void show() { // 基类中的虚函数
std::cout << "Base class" << std::endl;
}
};
class Derived : public Base {
public:
void show() override { // 派生类中重写虚函数
std::cout << "Derived class" << std::endl;
}
};
int main() {
Base* basePtr = new Derived(); // 基类指针指向派生类对象
basePtr->show(); // 运行时多态,输出:Derived class
delete basePtr; // 清理内存
return 0;
}
在这个示例中,Base
类的 show
函数是一个虚函数。在 main
函数中,通过基类指针 basePtr
指向 Derived
对象。当调用 show
时,程序会在运行时根据 basePtr
实际指向的对象类型(Derived
)来决定调用哪个版本的 show
函数。
总结
- 多态允许对象以多种形式出现,增强了程序的灵活性和可扩展性。
- 编译时多态通过函数重载和运算符重载实现,决定在编译时调用哪个函数,效率较高。
- 运行时多态通过虚函数实现,决定在运行时调用哪个函数,适用于需要动态行为的场景。
1.3.13 说说为什么要虚析构,为什么不能虚构造。
为什么要虚析构
在 C++ 中,虚析构函数是为了确保在删除基类指针指向的派生类对象时,能够正确地调用派生类的析构函数。这样可以防止派生类资源没有被正确释放而导致内存泄漏。
示例:没有虚析构的情况
#include <iostream>
class Base {
public:
~Base() { // 非虚析构
std::cout << "Base Destructor" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "Derived Destructor" << std::endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 仅调用 Base 的析构函数
return 0;
}
输出:
Base Destructor
在这个示例中,Base
的析构函数不是虚函数,因此 delete ptr
只调用 Base
的析构函数,不会调用 Derived
的析构函数。这会导致 Derived
对象中的资源未被正确释放,可能会导致内存泄漏或其他资源管理问题。
使用虚析构函数
#include <iostream>
class Base {
public:
virtual ~Base() { // 虚析构
std::cout << "Base Destructor" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() {
std::cout << "Derived Destructor" << std::endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 会正确调用 Derived 和 Base 的析构函数
return 0;
}
输出:
Derived Destructor
Base Destructor
在这里,delete ptr
会先调用 Derived
的析构函数,再调用 Base
的析构函数,确保派生类的资源正确释放,防止了内存泄漏。
为什么不能虚构造
从存储角度来看,C++ 中不能将构造函数设为虚函数,原因如下:
-
虚函数表依赖对象的实例化:虚函数表(vtable)存储虚函数地址,对象构造时会在内存中存储一个指向虚函数表的指针(
vptr
)。然而,构造函数在实例化过程中调用,如果它是虚函数,对象还未完成构造,虚函数表指针也未设置,无法找到对应的虚函数表。 -
动态绑定依赖完整的对象:虚函数使用动态绑定来决定调用的具体函数,但在构造过程中,对象类型还未确定,无法执行动态绑定。
因此,构造函数不能设置为虚函数。
总结
- 虚析构函数:用于确保基类指针在删除派生类对象时,正确地调用派生类的析构函数,防止内存泄漏。
- 不能有虚构造函数:构造函数负责对象的初始化,而在对象完全存在前无法判断动态类型,因此虚构造函数不适用。
1.3.14 说说模板类是在什么时候实现的?
模板类的实现是在编译时完成的。当代码中使用模板类并指定具体的类型参数时,编译器会根据这些类型参数来实例化模板,生成对应的类或函数的具体代码。这一过程称为模板实例化(Template Instantiation)。
显式实例化和隐式实例化都是在编译阶段完成的。这两种实例化方式都属于编译期行为,因为模板的实例化是基于类型的静态绑定,C++ 编译器会在编译过程中生成具体的代码实现。
具体说明
-
隐式实例化:当编译器在编译过程中遇到模板的具体类型使用时,自动生成实例化代码。这一过程完全由编译器在编译阶段处理。
-
隐式实例化是指当模板在代码中被使用,并提供了具体的类型参数后,编译器会自动生成相应类型的实例化代码,而不需要开发者明确指定。这是模板实例化最常用的方式。
-
显式实例化:显式实例化语句明确指示编译器在指定位置实例化特定类型,无论这些类型是否在其他地方被直接使用。编译器在编译阶段根据显式实例化语句生成相应代码。
-
显式实例化是指开发者手动指明模板实例化的类型,使编译器生成特定类型的模板代码。在某些情况下,显式实例化可以用来避免重复生成代码,或者将模板定义和实例化分离,以加快编译速度。
小结
无论是显式还是隐式实例化,C++ 的模板实例化都是在编译阶段完成的,因此模板的实例化不会推迟到运行时。
1.3.15 说说类继承时,派生类对不同关键字修饰的基类方法的访问权限
。
- 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私
有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面
都不能去访问它。 - 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在
派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。 - 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他
成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected
private。
- 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过
最好显示的写出继承方式。 - 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡
使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里
面使用,实际中扩展维护性不强。
1.3.16 简述一下移动构造函数,什么库用到了这个函数?
移动构造函数
移动构造函数是 C++11 引入的一种构造函数,用于实现对象的移动语义,从而优化资源的转移而不是拷贝。移动构造函数通过转移资源的“所有权”来避免不必要的深拷贝,提升性能,特别适合处理大型资源对象(如动态分配的内存、文件句柄等)。
移动构造函数的定义
移动构造函数通常定义为接收一个右值引用(T&&
)参数:
class MyClass {
public:
MyClass(MyClass&& other) noexcept; // 移动构造函数
};
工作原理
- 移动构造函数在实例化一个对象时,将另一个临时对象(右值)的资源“移动”到新对象中,而不是进行深拷贝。
- 移动构造完成后,原对象的资源通常会被置为空或默认状态,避免重复释放资源。
示例
class MyClass {
public:
int* data;
// 普通构造函数
MyClass(int size) : data(new int[size]) {}
// 移动构造函数
MyClass(MyClass&& other) noexcept : data(other.data) {
other.data = nullptr; // 释放所有权
}
/*
MyClass(MyClass&& other) noexcept: 这是移动构造函数的声明,接受一个右值引用 other,指向将要移动的对象。noexcept 表示此函数不会抛出异常。
data(other.data): 在初始化列表中将当前对象的 data 指针初始化为 other.data,即将 other 对象的 data 资源转移到当前对象上。
*/
~MyClass() {
delete[] data;
}
};
使用移动构造函数的库
- 标准库(STL):C++ 标准库的容器(如
std::vector
、std::string
等)广泛使用了移动构造函数来提升性能。例如,当容器需要扩展空间或转移元素时,可以通过移动构造避免不必要的拷贝操作。 std::unique_ptr
和std::shared_ptr
:这些智能指针通过移动构造函数来实现所有权的转移,确保资源的独占访问。std::thread
:C++11 的多线程库std::thread
也使用移动语义来转移线程对象,避免拷贝线程资源。
总结
移动构造函数提升了对象在容器和资源管理中的操作效率,在 C++ 标准库的容器、智能指针和多线程库中得到了广泛应用。
1.3.17 C++类内可以定义引用数据成员吗?
C++ 类内定义引用数据成员
在 C++ 中,可以在类内定义引用数据成员,但必须在构造函数的初始化列表中对其进行初始化。引用必须在创建对象时绑定到一个有效的对象,并且引用一旦绑定后无法更改。
示例
class MyClass {
public:
int& ref; // 引用数据成员
// 构造函数,初始化引用数据成员
MyClass(int& r) : ref(r) {}
};
int main() {
int a = 10;
MyClass obj(a); // 创建 MyClass 对象,ref 绑定到 a
obj.ref = 20; // 修改 a 的值,通过引用
// 此时 a 的值为 20
return 0;
}
重要注意事项
-
必须初始化:引用数据成员必须在构造函数的初始化列表中进行初始化,无法像普通数据成员那样单独声明。
-
不可重新绑定:一旦引用数据成员被初始化为某个对象后,无法再更改为引用其他对象。
-
生命周期管理:引用所绑定的对象必须在引用的生命周期内有效,否则会导致未定义行为。
总结
虽然可以在 C++ 类中定义引用数据成员,但使用时要谨慎,确保在构造时进行初始化,并且注意引用对象的生命周期管理。
MyClass(int& r) : ref® {} 解释:
这行代码是 MyClass
类的构造函数,用于初始化引用数据成员 ref
。以下是详细的解释:
1. MyClass(int& r)
- 构造函数:这是
MyClass
类的构造函数。构造函数的名字与类名相同,没有返回值,负责在创建对象时初始化该对象。 - 参数:
int& r
是一个对int
类型的引用参数。这意味着在调用构造函数时,传入的实际参数必须是一个int
类型的变量的引用。引用的作用是允许构造函数直接操作传入的变量,而不需要拷贝其值。
2. : ref(r)
- 初始化列表:这部分是构造函数的初始化列表,用于初始化类中的数据成员。初始化列表在构造函数的主体代码执行之前执行。
ref(r)
:这里ref
是类MyClass
的引用数据成员,而r
是构造函数的参数。ref
被初始化为r
所引用的对象。由于r
是一个引用,因此ref
也会引用传入的int
类型变量,而不是进行值拷贝。
整体功能
- 当你创建
MyClass
的对象并传入一个int
变量的引用时,构造函数会将这个变量的引用赋值给ref
。 - 这样,
ref
在对象生命周期内将始终引用传入的变量,允许通过ref
修改这个变量的值。
1.3.18 构造函数为什么不能被声明为虚函数?
- 存储空间:构造期间对象的内存尚未完全分配,无法访问完整的虚表。
- 使用:构造函数无法提供多态性,且在未完全构造时使用虚函数会导致不一致和未定义行为。
- 实现:构造函数的主要职责是初始化状态,设计上与虚函数的动态绑定机制不兼容,增加了编译器实现的复杂度。
从存储空间角度:虚函数对应一个vtale,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable 中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
从实现上看,vbtl 在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。
1.3.19 简述什么是常函数,有什么作用?
常函数(const Member Function)
常函数是 C++ 中的一种成员函数,声明时使用 const
关键字修饰。常函数的主要作用是表明该函数不会修改其所属对象的成员变量。
1. 常函数的定义
常函数的定义形式如下:
class MyClass {
public:
void myFunction() const; // 常函数声明
};
在上面的例子中,myFunction
是一个常函数,const
关键字位于函数声明的末尾。
2. 常函数的作用
-
不修改对象状态:常函数承诺不修改对象的任何成员变量,因此可以安全地在常量对象上调用。这对于确保对象在某些情况下不会被修改非常重要。
-
可以用于常量对象:常函数可以被常量对象调用,这使得常函数适合用于只读操作,提供了更好的封装性和安全性。
-
提高代码可读性:通过使用常函数,代码的意图更明确,其他开发者可以清楚地知道哪些函数不会更改对象的状态,增强了代码的可维护性。
-
可以与常量引用和指针一起使用:常函数可以接受常量引用和指针作为参数,从而增强了函数的灵活性,避免不必要的拷贝操作。
3. 示例代码
class MyClass {
private:
int value;
public:
MyClass(int v) : value(v) {}
// 常函数
int getValue() const {
return value; // 只读,不能修改成员变量
}
};
int main() {
const MyClass obj(10);
int val = obj.getValue(); // 可以调用常函数
// obj.setValue(20); // 错误,无法调用非常函数
return 0;
}
总结
常函数是 C++ 中用于指示某个成员函数不修改对象状态的机制。它提高了代码的安全性和可读性,使得常量对象可以安全地调用相关的成员函数。常函数在设计 API 时尤为重要,能够确保接口的正确使用和数据的完整性。
1.3.20 说说什么是虚继承,解决什么问题,如何实现,根本原理是什么?
虚继承
虚继承是 C++ 中的一种继承方式,用于解决多重继承中的二义性问题,特别是在存在菱形继承结构时。
1. 什么是虚继承
虚继承允许派生类从基类中以一种“虚”的方式继承,确保通过多条路径继承相同的基类时,只有一个基类的实例被创建。虚继承通过在继承声明中使用 virtual
关键字来实现。
示例结构
考虑以下的菱形继承结构:
Base
/ \
Derived1 Derived2
\ /
Derived3
在这个结构中,Derived3
同时继承自 Derived1
和 Derived2
,而它们都继承自 Base
。如果没有虚继承,Derived3
会拥有两个 Base
的实例,导致二义性问题。
2. 虚继承解决的问题
- 消除二义性:通过虚继承,
Derived3
只会有一个Base
类的实例,避免了因多个基类实例引起的二义性。 - 统一数据访问:确保在派生类中访问基类成员时只有一份基类数据,简化了数据访问。
3. 如何实现虚继承
虚继承的实现主要通过在继承声明中使用 virtual
关键字。具体步骤如下:
4. 虚继承的注意事项
- 构造顺序:在虚继承中,基类的构造函数会在派生类的构造函数之前被调用。也就是说,
Base
的构造函数会先执行。 - 使用限制:虚继承可能会带来一些额外的复杂性,如增加对象的大小和运行时开销,因此在设计类层次结构时需要谨慎使用。
总结
虚继承是 C++ 中用于解决多重继承引发的二义性问题的一种机制。通过确保派生类只拥有一个基类的实例,虚继承使得数据访问更加清晰且一致。虚继承通过在继承声明中使用 virtual
关键字来实现,帮助简化类的设计和使用。
虚继承的根本原理
虚继承的根本原理是通过在内存布局中确保在多重继承的情况下,基类只被实例化一次,从而避免了因多条继承路径造成的数据冗余和二义性。这一机制涉及到几个关键概念:
1. 虚基类
- 定义:当一个类通过
virtual
关键字继承自另一个类时,该类成为“虚基类”。在任何派生类中,虚基类的数据成员和成员函数在内存中只有一份实例。 - 存储:虚基类的指针(即虚基指针)会被存储在派生类的对象中,以确保可以正确访问唯一的基类实例。
2. 虚指针和虚表
- 虚指针(vptr):每个对象(包括虚基类和派生类的实例)都有一个指向其虚表的指针(vptr)。在虚继承中,虚基类的实例会通过虚指针在内存中被引用。
- 虚表(vtable):每个类都有一个虚表,存放该类的虚函数指针。在虚继承的情况下,虚表中的信息会指向虚基类的实例。
3. 内存布局
-
单一实例:通过虚继承,编译器确保无论通过多少条路径继承,虚基类的实例只会存在一份。这使得派生类中的所有成员函数在访问虚基类的成员时,指向同一个基类实例,从而避免了冗余。
-
指针调整:在对象的构造过程中,编译器会调整虚指针,以确保在访问虚基类时能够正确引用到唯一的基类实例。这通常是在派生类的构造函数中进行的。
1.3.21 简述一下虚函数和纯虚函数,以及实现原理
虚函数和纯虚函数
1. 虚函数
虚函数是 C++ 中的一种成员函数,通过在基类中声明为 virtual
,允许在派生类中进行重写(override)。虚函数的主要目的是支持运行时多态性,使得通过基类指针或引用可以调用派生类的实现。
-
声明:
class Base { public: virtual void show() { std::cout << "Base class show" << std::endl; } };
-
重写:
class Derived : public Base { public: void show() override { // 重写虚函数 std::cout << "Derived class show" << std::endl; } };
-
运行时多态:
Base* obj = new Derived(); obj->show(); // 调用 Derived 类的 show 方法
2. 纯虚函数
纯虚函数是虚函数的一种特殊形式,表示该函数在基类中没有实现,必须在派生类中重写。纯虚函数通过在函数声明后加上 = 0
来定义。
-
声明:
class Base { public: virtual void show() = 0; // 纯虚函数 };
-
实现:
class Derived : public Base { public: void show() override { // 必须实现 std::cout << "Derived class show" << std::endl; } };
-
抽象类:包含纯虚函数的类称为抽象类,无法直接实例化。
实现原理
1. 虚函数的实现原理
- 虚表(vtable):每个包含虚函数的类都有一个虚表,虚表存放该类的虚函数指针。在创建对象时,编译器为每个对象分配一个指向其虚表的指针(vptr)。
- 动态绑定:在运行时,当通过基类指针调用虚函数时,程序会查找指针所指向的对象的虚表,从而找到并调用实际的函数实现。这个过程称为动态绑定。
2. 纯虚函数的实现原理
- 纯虚表:与虚函数类似,包含纯虚函数的类也有虚表,但虚表中对应的纯虚函数指针通常会指向一个实现为
nullptr
的函数。这表明该函数在基类中没有实现。 - 强制实现:派生类必须重写纯虚函数,否则编译时将会出错。这确保了每个派生类都有自己的实现,从而实现了多态性。
总结
- 虚函数允许在派生类中重写,并通过基类指针或引用实现多态性。
- 纯虚函数没有实现,必须在派生类中重写,使基类成为抽象类。
- 两者的实现依赖于虚表和虚指针,支持动态绑定,从而在运行时决定调用哪个函数实现。
1.3.22 纯虚函数能实例化么,为什么?派生类要实现么?为什么?
纯虚函数是不能实例化的,因为它没有实现(即没有具体的代码体),只能在派生类中被实现。这是设计上为了强制派生类提供特定的功能,使得基类提供了一种接口,而不关心具体的实现细节。
总结
事项 | 说明 |
---|---|
是否能实例化 | 不能实例化 |
派生类是否需要实现 | 需要实现 |
原因 | 确保派生类提供特定功能,并实现接口 |
解释
-
无法实例化:纯虚函数在基类中没有具体实现,编译器不允许基类的对象被创建。
-
派生类实现:派生类必须实现所有继承自基类的纯虚函数,否则它自身也会成为一个抽象类,不能被实例化。这种机制确保了任何使用基类指针或引用的代码都能在运行时正确地调用具体的实现。
1.3.23 说说C++中虚函数与纯虚函数的区别?
虚函数和纯虚函数都是用于实现多态性的机制,但它们之间有几个关键区别:
总结
特征 | 虚函数 | 纯虚函数 |
---|---|---|
定义 | 在基类中有实现 | 在基类中没有实现(只有声明) |
是否可实例化 | 可以实例化包含虚函数的类 | 不能实例化包含纯虚函数的类 |
派生类实现要求 | 可选,派生类可以选择重写或继承 | 必须实现,否则派生类也是抽象类 |
用途 | 提供默认实现,允许选择性重写 | 强制派生类提供实现,定义接口 |
解释
-
虚函数:在基类中有具体实现,派生类可以选择重写它。这使得基类的对象可以直接使用虚函数的默认实现,而派生类则可以提供特定的行为。
-
纯虚函数:没有具体实现,派生类必须实现它。这种方式通常用于定义接口,确保派生类实现特定的方法,以满足多态性需求。
通过这两者的组合,C++能够灵活地支持面向对象编程的设计原则。
1.3.24 说说C++中什么是菱形继承问题,如何解决?
菱形继承问题是指在多重继承中,派生类从两个基类继承了同一个基类,形成一个菱形的结构。这会导致数据成员和虚函数的不确定性,因为派生类可能有多个基类实例,且这些实例共享同一基类的数据。
示例
A
/ \
B C
\ /
D
在这个示例中,类 D 继承了类 B 和类 C,而类 B 和 C 都继承自类 A。这可能导致 D 中存在两个 A 的实例,造成数据不一致和不明确的函数调用。
解决方法
-
虚继承:
- 在基类声明时使用
virtual
关键字来继承,确保所有派生类共享同一基类实例。 - 示例:
class A { /* ... */ }; class B : virtual public A { /* ... */ }; class C : virtual public A { /* ... */ }; class D : public B, public C { /* ... */ };
- 在基类声明时使用
-
明确函数调用:
- 在需要调用基类的方法时,使用作用域解析运算符明确指定要调用哪个基类的函数。
- 示例:
D obj; obj.B::func(); // 调用 B 的函数 obj.C::func(); // 调用 C 的函数
通过虚继承,编译器保证 D 只有一个 A 的实例,解决了菱形继承问题,避免了二义性。
1.3.25 请问构造函数中能不能调用虚方法?
在构造函数中调用虚方法是可以的,但要注意它的行为和效果。
关键点
-
调用的基类方法:
- 当在基类的构造函数中调用虚方法时,实际调用的是基类的版本,而不是派生类的版本。这是因为虚函数机制在构造期间尚未完全建立,派生类的构造函数尚未执行,因此派生类的行为无法被调用。
-
潜在的问题:
- 如果在构造函数中调用虚函数,可能会导致意外行为,因为你期望的是派生类的实现,而得到的是基类的实现。这可能导致逻辑错误或不一致的状态。
示例
class Base {
public:
Base() {
// 这里调用虚函数,实际调用 Base::foo()
foo();
}
virtual void foo() {
std::cout << "Base foo" << std::endl;
}
};
class Derived : public Base {
public:
Derived() {
// Derived 的构造函数
}
virtual void foo() override {
std::cout << "Derived foo" << std::endl;
}
};
int main() {
Derived d; // 输出: Base foo
return 0;
}
在上面的例子中,当创建 Derived
对象时,输出将是 Base foo
,而不是 Derived foo
。因此,最好避免在构造函数中调用虚方法,以避免引起混淆。
1.3.26 请问拷贝构造函数的参数是什么传递方式,为什么?
在C++中,拷贝构造函数的参数通常是 按引用传递,并且是 const引用。形式为 ClassName(const ClassName& other)
。
原因
-
避免递归调用:
- 如果拷贝构造函数使用按值传递(即
ClassName other
),会导致递归调用,因为传值传递会试图创建参数的副本,从而再次调用拷贝构造函数,造成无限递归。
- 如果拷贝构造函数使用按值传递(即
-
提高效率:
- 按引用传递可以避免创建额外的副本,直接引用原对象的数据,减少了不必要的拷贝操作,从而提高效率。
-
确保安全性:
- 使用
const
确保拷贝构造函数不会修改原对象,符合拷贝构造的设计意图,保证了代码的安全性和可读性。
- 使用
总结
特征 | 说明 |
---|---|
传递方式 | 按引用传递 (const ClassName& ) |
避免递归 | 防止按值传递导致的递归调用 |
提高效率 | 避免不必要的拷贝,提高效率 |
安全性 | 使用 const 防止修改原对象 |
通过按引用传递,拷贝构造函数能够高效、安全地进行对象的复制操作。
1.3.27 说说类方法和数据的权限有哪几种
在C++中,类中的成员方法和数据成员的权限分为三种访问权限:public
、protected
和 private
。这些访问权限控制了类的外部代码和派生类对成员的访问权限。
总结
访问权限 | 描述 |
---|---|
public | 公有成员,类外部和派生类都可以访问。 |
protected | 受保护成员,仅派生类和本类的成员函数可以访问,类外部无法直接访问。 |
private | 私有成员,仅本类的成员函数可以访问,派生类和类外部均无法访问。 |
详细说明
-
public
(公有):- 访问范围:类外部、派生类、本类均可访问。
- 用途:常用于定义类对外公开的接口,使得外部代码能够调用。
-
protected
(受保护):- 访问范围:仅限于本类和派生类;类的外部无法访问。
- 用途:用于希望在派生类中共享,但不对外暴露的数据或方法。适用于需要让派生类重用的实现细节。
-
private
(私有):- 访问范围:仅限于本类的成员函数;派生类和类的外部均无法访问。
- 用途:用于封装类的实现细节,保护类的内部数据和方法不被直接访问和修改,保持数据完整性和安全性。
这些访问权限控制了类的封装性和继承关系,使得类可以更好地保护数据,提供良好的接口设计。
1.3.28 如何理解抽象类
在C++中,抽象类是一种不能直接实例化的类,通常用于定义一组派生类必须实现的接口。它包含至少一个纯虚函数(即没有实现的函数),从而强制派生类实现这些函数。
理解抽象类的关键点
-
不能实例化:抽象类不能创建对象,只有继承它并实现所有纯虚函数的派生类才能实例化。其设计目的是作为一个接口或基础类,为派生类提供一个共同的功能框架。
-
包含纯虚函数:纯虚函数用
= 0
表示,例如virtual void foo() = 0;
。这个纯虚函数没有实现,只定义了函数的接口,因此派生类必须实现它。 -
实现接口:抽象类常用于定义接口,使派生类具有一致的行为。例如,定义一个
Shape
抽象类,让所有具体的图形类(如Circle
、Rectangle
)都实现draw()
方法。这样,代码中可以使用Shape
指针或引用处理所有图形类型的对象,而不需关心它们的具体类型。 -
面向对象设计的关键:抽象类是面向对象设计中实现多态性和接口一致性的重要工具。它将函数的实现留给派生类,提供了灵活的架构和易扩展性。
示例
class Shape {
public:
virtual void draw() = 0; // 纯虚函数,使 Shape 成为抽象类
};
class Circle : public Shape {
public:
void draw() override { // 实现纯虚函数
std::cout << "Drawing Circle" << std::endl;
}
};
class Rectangle : public Shape {
public:
void draw() override { // 实现纯虚函数
std::cout << "Drawing Rectangle" << std::endl;
}
};
在这个例子中,Shape
是抽象类,不能直接实例化。Circle
和 Rectangle
继承了 Shape
,并实现了 draw()
方法,因此它们可以被实例化。通过抽象类,代码可以以多态的方式处理不同类型的图形对象。
1.3.30 简述一下虚析构函数,有什么作用
在C++中,虚析构函数是一种特殊的虚函数,用于在多态继承结构中正确销毁对象。当基类的析构函数被声明为虚函数时,通过基类指针删除派生类对象时,派生类的析构函数会被正确调用,以确保资源正确释放,防止内存泄漏。
作用和使用场景
-
确保派生类析构函数调用:
- 当基类的析构函数是虚的,且使用基类指针指向派生类对象时,
delete
操作会触发派生类的析构函数调用。如果析构函数不是虚的,则只会调用基类的析构函数,派生类中的资源不会被释放,可能导致内存泄漏或其他资源未释放的问题。
- 当基类的析构函数是虚的,且使用基类指针指向派生类对象时,
-
多态删除:
- 虚析构函数支持多态删除,即通过基类指针或引用删除派生类对象。在使用继承关系时,尤其是动态分配的派生类对象,虚析构函数可以确保在销毁对象时释放所有级别的资源。
-
防止内存泄漏:
- 如果基类析构函数不是虚函数,删除基类指针指向的派生类对象时,派生类的析构函数不会被调用,导致派生类中分配的资源(如堆内存、文件句柄等)无法正确释放,造成内存泄漏。
示例
class Base {
public:
virtual ~Base() { // 虚析构函数
std::cout << "Base Destructor" << std::endl;
}
};
class Derived : public Base {
public:
~Derived() override {
std::cout << "Derived Destructor" << std::endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 调用派生类的析构函数,然后调用基类的析构函数
return 0;
}
输出结果
Derived Destructor
Base Destructor
在这个例子中,通过 Base
指针删除 Derived
对象时,首先调用 Derived
的析构函数,然后调用 Base
的析构函数。这是因为 Base
的析构函数是虚的,确保了正确的析构顺序。
1.3.31 说说什么是虚基类,可否被实例化?
虚基类就是虚继承的类,可以被实例化。
虚基类是用于解决多重继承中的“菱形继承”问题的一种机制。当一个基类被多个派生类继承,而这些派生类又被进一步派生时,菱形继承结构会导致基类的多次拷贝。通过将基类声明为虚基类,可以确保在菱形继承结构中只有一个基类的实例,避免数据冗余和潜在的二义性。
菱形继承示例
假设一个类层次结构如下:
A
/ \
B C
\ /
D
在这个结构中,类 D
通过 B
和 C
继承了 A
,形成了菱形结构。此时,如果不使用虚基类,D
会包含两个 A
的实例,这可能导致不明确的访问和资源浪费。
虚基类的定义和作用
-
虚基类的定义:
- 使用
virtual
关键字来声明虚基类。例如:class A { /* 基类 */ }; class B : virtual public A { /* 派生类 */ }; class C : virtual public A { /* 派生类 */ }; class D : public B, public C { /* 最终派生类 */ };
- 使用
-
避免多次拷贝:
- 虚基类确保在派生类(如
D
)中只存在一个基类(A
)的实例,解决菱形继承中基类的多次拷贝问题。
- 虚基类确保在派生类(如
-
避免二义性:
- 虚基类通过只保留一个基类实例,防止了多重继承中出现的成员访问二义性问题,使派生类访问基类成员时更加明确。
虚基类能否被实例化?
- 虚基类本质上还是一个普通的类,因此如果它不是抽象类(即没有纯虚函数),就可以被实例化。
- 但是在多继承结构中,虚基类通常不直接实例化,而是通过派生类实例化来共享其唯一的实例。
示例
class A {
public:
void show() { std::cout << "A's show function" << std::endl; }
};
class B : virtual public A { /* 虚继承 A */ };
class C : virtual public A { /* 虚继承 A */ };
class D : public B, public C { /* 虚基类 A 只有一个实例 */ };
int main() {
D d;
d.show(); // 访问虚基类 A 的成员函数,没有二义性
return 0;
}
关键点总结
特性 | 描述 |
---|---|
虚基类用途 | 解决菱形继承问题,避免基类的多次拷贝 |
是否可以实例化 | 可以直接实例化,前提是没有纯虚函数 |
作用 | 保证菱形继承结构中只有一个基类实例,避免二义性 |
虚基类通过 virtual
关键字实现,保证多重继承中基类实例的唯一性,增强代码的可维护性和安全性。
1.3.32 简述一下拷贝赋值和移动赋值
在C++中,拷贝赋值和移动赋值是两种不同的赋值操作,主要用于对象的赋值。它们的区别在于对象资源的处理方式:
1. 拷贝赋值
- 定义:拷贝赋值运算符用于将一个对象的内容“拷贝”到另一个对象中,通常格式为
operator=(const ClassName& other)
。 - 传递方式:参数按常量引用传递,避免了不必要的拷贝。
- 操作:创建源对象的副本,将副本的内容复制到目标对象。这种操作适合于源对象内容需要保留或继续使用的情况。
- 典型使用场景:适用于已有对象的赋值操作,且对象持有的资源(如动态分配的内存)需要保留在源对象中。
2. 移动赋值
- 定义:移动赋值运算符用于将一个对象的资源“移动”到另一个对象中,通常格式为
operator=(ClassName&& other)
。 - 传递方式:参数按右值引用传递,允许在赋值时“窃取”源对象的资源。
- 操作:移动源对象的资源指针到目标对象,而无需复制资源。这种操作高效地避免了不必要的深拷贝,尤其适用于临时对象或不再需要源对象的场景。
- 典型使用场景:适用于已有对象的赋值操作,当源对象不再需要其资源时,例如在函数返回时对临时对象赋值。
拷贝赋值与移动赋值的区别
特征 | 拷贝赋值 | 移动赋值 |
---|---|---|
传递方式 | 常量引用 (const T& ) | 右值引用 (T&& ) |
是否保留源对象内容 | 保留 | 不保留(资源被转移) |
使用场景 | 源对象内容需保留 | 源对象为临时对象或不再使用 |
效率 | 较低,涉及深拷贝 | 较高,通过资源转移避免深拷贝 |
示例代码
class MyClass {
public:
MyClass& operator=(const MyClass& other) { // 拷贝赋值
if (this != &other) {
// 释放当前资源
// 复制 other 的资源
}
return *this;
}
MyClass& operator=(MyClass&& other) noexcept { // 移动赋值
if (this != &other) {
// 释放当前资源
// 窃取 other 的资源
// 将 other 的资源指针置空
}
return *this;
}
};
拷贝赋值适合资源复制需求的场景,而移动赋值则更高效,适合源对象为临时对象或不再使用的情况。
1.3.33 什么是仿函数?有什么作用?
仿函数(Functors,或函数对象)是指重载了 operator()
的类对象,使其可以像普通函数一样被调用。在 C++ 中,仿函数为函数指针和普通函数提供了灵活的替代方案。
仿函数的定义
仿函数是通过重载 ()
操作符的类实现的,形式如下:
class MyFunctor {
public:
int operator()(int x) {
return x * x; // 示例操作:返回 x 的平方
}
};
这里的 MyFunctor
类重载了 ()
操作符,因此可以将其实例化后的对象当作函数来调用。
仿函数的作用
-
提高灵活性:
- 仿函数是类对象,可以包含成员变量和状态,因此它们可以保存状态信息,而普通函数和函数指针无法做到这一点。
- 可以将配置或参数保存在仿函数的成员变量中,从而避免重复传递参数。
-
简化代码:
- 在使用标准模板库(STL)算法(如
std::sort
)时,可以将仿函数作为参数传递,简化代码,增强代码的可读性和可维护性。 - 仿函数比函数指针更灵活,可以与 STL 的容器和算法轻松结合使用。
- 在使用标准模板库(STL)算法(如
-
可定制行为:
- 仿函数可以通过模板参数进行定制,使其在不同情况下执行不同的操作。例如,可以根据需要创建不同的比较函数对象。
示例:在 STL 算法中使用仿函数
下面是一个使用仿函数的示例,它可以自定义排序方式:
#include <iostream>
#include <vector>
#include <algorithm>
class Compare {
public:
bool operator()(int a, int b) const {
return a > b; // 降序排序
}
};
int main() {
std::vector<int> vec = {3, 1, 4, 1, 5, 9};
std::sort(vec.begin(), vec.end(), Compare()); // 使用仿函数进行排序
for (int num : vec) {
std::cout << num << " ";
}
return 0;
}
在这里,Compare
类充当仿函数,为 std::sort
提供自定义的排序规则,使得 vec
以降序排序。
仿函数与普通函数的对比
特点 | 仿函数 | 普通函数 |
---|---|---|
状态 | 可以保持状态信息 | 不能保存状态 |
用法 | 类对象,可以重载多种行为 | 普通函数 |
灵活性 | 可以通过继承、模板等灵活定制 | 灵活性较低 |
使用场景 | 常用于 STL 算法、自定义行为等 | 适合无状态的通用操作 |
总结
仿函数提供了类似函数的调用方式,结合了类的特性,使得它们在需要传递行为、保存状态或在算法中定义自定义操作时非常实用。
1.3.34 C++中那些函数不能被声明为虚函数?
- 在 C++ 中,静态成员函数、构造函数、普通函数、友元函数等不能被声明为虚函数。析构函数在特定条件下(如纯虚函数或静态成员)也不能被声明为虚函数。虚函数的设计主要是为了支持多态性,因此需要通过对象的动态类型来实现。
1. 静态成员函数
- 说明:静态成员函数与类的实例无关,属于类本身。
- 原因:虚函数依赖于对象的动态类型,而静态成员函数不通过对象进行调用,因此不能是虚函数。
class MyClass {
public:
static void staticFunction() {} // 不能是虚函数
};
2. 构造函数
- 说明:构造函数用于初始化对象。
- 原因:在构造对象时,虚表尚未建立,因此无法实现多态性。构造函数不能被虚拟化。
class MyClass {
public:
MyClass() {} // 不能是虚函数
};
3. 析构函数(条件下)
- 说明:析构函数在对象销毁时调用,用于清理资源。
- 原因:虽然析构函数可以是虚函数,通常在基类中声明为虚函数,以确保派生类的析构函数被正确调用。但是如果它是静态成员函数或纯虚函数,则不能被声明为虚函数。
class Base {
public:
virtual ~Base() {} // 可以是虚函数
};
class Derived : public Base {
public:
~Derived() {} // 派生类的析构函数可以是虚函数
};
4. 普通函数
- 说明:普通函数是类的非成员函数。
- 原因:虚函数必须是类的成员函数,因此普通函数不能被声明为虚函数。
void myFunction() {} // 不能是虚函数
5. 友元函数
- 说明:友元函数是一个不属于类的外部函数,但可以访问类的私有成员。
- 原因:友元函数不是类的成员,因此不能被声明为虚函数。
class MyClass {
friend void friendFunction(MyClass& obj) {} // 不能是虚函数
};
6. 纯虚函数在构造函数中的调用
- 说明:纯虚函数是没有实现的虚函数,必须在派生类中重写。
- 原因:在基类构造函数中调用纯虚函数是不安全的,因为基类的部分构造尚未完成,派生类的实现还不可用。
class Base {
public:
virtual void pureVirtualFunction() = 0; // 纯虚函数
Base() {
pureVirtualFunction(); // 不安全,尽量避免
}
};
1.3.35 解释一下 C++中类模板和模板类的区别
- 类模板是一个蓝图,可以用于生成多个类,而模板类是通过类模板实例化得到的具体类型。类模板的主要目的是提高代码的复用性和灵活性,而模板类则是应用类模板后的具体实现。
在 C++ 中,类模板(Class Template)和模板类(Template Class)是相关但有区别的概念。下面将详细解释这两者的含义及其区别。
1. 类模板(Class Template)
类模板是一个模板定义,用于生成类的蓝图,允许在类的定义中使用占位符(类型参数或非类型参数)。通过类模板,可以创建与类型无关的类,使得同一份代码可以处理不同的数据类型。
定义方式:
template <typename T>
class MyClass {
public:
T data; // 使用模板参数 T
MyClass(T val) : data(val) {}
void display() {
std::cout << data << std::endl;
}
};
使用示例:
MyClass<int> intObj(5); // 实例化类模板,类型为 int
MyClass<double> doubleObj(3.14); // 实例化类模板,类型为 double
intObj.display(); // 输出:5
doubleObj.display(); // 输出:3.14
2. 模板类(Template Class)
模板类是指由类模板实例化出来的具体类。每次使用不同类型参数实例化类模板时,都会生成一个新的模板类。模板类是在编译时生成的,它与具体类型相结合,成为具体的类型。
示例:
- 从上述类模板
MyClass<T>
实例化出的MyClass<int>
和MyClass<double>
就是模板类。
区别总结
特征 | 类模板 | 模板类 |
---|---|---|
定义 | 是一种泛型设计的模板,用于生成类的蓝图 | 是从类模板实例化出的具体类 |
形式 | 使用 template 关键字定义 | 由模板参数生成的具体类型 |
使用 | 提供了一种创建与类型无关的类的方式 | 是具体的类,具有特定的数据类型 |
例子 | template <typename T> class MyClass { ... }; | MyClass<int> 和 MyClass<double> |
1.3.36 虚函数表里存放的内容是什么时候写进去的?
虚函数表(Virtual Table,简称 VTable)是 C++ 用于实现动态多态性的一个机制。虚函数表中存放的是类的虚函数指针,具体来说,它包含了该类的所有虚函数的地址。以下是有关虚函数表的详细解释及其内容写入的时机。
虚函数表的内容
-
虚函数指针:每个类的虚函数表中包含指向其虚函数实现的指针。当类具有虚函数时,编译器为该类生成一个虚函数表。
-
基类和派生类的关系:
- 每个类都有自己的虚函数表。
- 如果派生类重写了基类的虚函数,虚函数表中的指针将指向派生类的实现。
虚函数表的创建时机
-
编译时:
- 虚函数表的结构是在编译时生成的。编译器根据类的虚函数声明为每个类创建一个虚函数表。
- 虚函数表中的指针会在类的定义时被确定,并在编译时完成。
-
对象创建时:
- 每当创建一个对象时,编译器会为该对象分配一个指向其类的虚函数表的指针,通常称为 vptr(虚函数指针)。
- 该指针在对象构造时被初始化,指向与对象类型相对应的虚函数表。
-
运行时:
- 在运行时,当通过基类指针或引用调用虚函数时,程序会通过该对象的
vptr
查找虚函数表,以确定调用哪一个函数实现。这种机制实现了动态绑定。
- 在运行时,当通过基类指针或引用调用虚函数时,程序会通过该对象的
总结
- 虚函数表是在编译阶段创建的,内容包含类的虚函数的地址。
- 对象创建时,
vptr
被初始化为指向对象类型的虚函数表。 - 动态多态性在运行时通过
vptr
和虚函数表实现。
这种机制确保了在使用基类指针或引用时,可以正确调用到派生类的虚函数实现,从而实现动态绑定和多态性。