29道C++ 面向对象高频题整理(附答案背诵版)
1、什么是类?
在C++中,类是一种用户定义的数据类型,它可以包含数据成员和函数成员。数据成员用于存储与类相关的状态,而函数成员可以定义对这些数据进行操作的方法。可以把类想象为一个蓝图,根据这个蓝图可以创建对象,这些对象在内存中是类的实例。
比如说,我们可以定义一个Car
类来表示汽车。这个类可以有数据成员如brand
、color
和maxSpeed
来存储汽车的品牌、颜色和最高速度等属性。同时,Car
类可能有函数成员如accelerate()
和brake()
来定义汽车加速和刹车的操作。
在现实生活中,每辆汽车都是根据汽车制造商设计的蓝图制造出来的,蓝图定义了汽车的特性和功能,类似地,在编程中,我们根据类创建对象来表示现实世界中的各种事物和概念。
2、面向对象的程序设计思想是什么?
面向对象程序设计(OOP)是一种编程范式,它使用“对象”来设计软件。在OOP中,对象是类的实例,类包含数据(属性)和可以对数据执行操作的方法(行为)。面向对象的核心概念包括封装、继承和多态性。
-
封装:是指将数据(属性)和操作数据的代码(方法)打包在一起,形成一个独立的对象。这样可以隐藏对象的内部细节,只暴露必要的操作接口。比如,一个汽车对象封装了引擎、变速器等细节,只提供加速和刹车等接口。
-
继承:允许新的类(子类)继承现有类(父类)的属性和方法。继承可以复用代码,并且可以创建层次结构。例如,可以有一个基本的车辆类,然后有子类如汽车、摩托车等,它们继承基本类的共同特性。
-
多态性:指的是不同类的对象可以通过同一接口调用,具有不同的行为。例如,如果有一个函数接受车辆类的对象,那么任何车辆的子类对象,如汽车或摩托车,都可以使用该函数,但具体的行为会根据对象的实际类型而有所不同。
OOP的思想是通过模仿现实世界来组织和设计代码,使得代码更加模块化、易于理解和维护。通过把现实世界的实体映射成程序中的类和对象,开发者可以在更高的层次上思考问题,这样可以更容易地解决复杂的软件问题。
3、面向对象的三大特征是哪些?
面向对象编程(OOP)的三大特征是封装、继承和多态。它们是OOP中最核心的概念,每个特征都解决了软件开发中的一些常见问题。
-
封装:封装是隐藏对象内部复杂性的过程,同时暴露出必要的功能。这可以防止外部代码直接访问对象内部的状态,减少了外部干扰和错误使用的可能性。在C++中,通常通过访问修饰符(private、protected、public)来实现封装。
应用场景示例:银行账户类(BankAccount)可能包含私有数据成员来存储账户余额,并提供公共方法来进行存款和取款,而不允许直接修改账户余额。
-
继承:继承允许新创建的类(称为子类)继承父类的属性和方法。继承可以实现代码复用,并且可以形成一个类的层次结构。
应用场景示例:可以有一个通用的
Vehicle
类,它包含所有交通工具的共通特征,然后可以有子类如Car
、Truck
和Motorcycle
,它们继承Vehicle
类并添加特定于它们的属性和方法。 -
多态:多态性意味着可以通过基类的指针或引用来调用派生类的方法。这使得程序可以在不知道对象确切类型的情况下对对象进行操作,从而使程序可以在运行时动态决定对象的行为。
应用场景示例:可以定义一个
Shape
基类,并且有多个派生类如Circle
、Rectangle
和Triangle
。每个派生类都有一个draw()
方法的实现。如果有一个Shape
类型的数组,程序可以遍历这个数组,并调用每个形状的draw()
方法,具体调用哪一个实现,取决于数组元素的实际类型。
这三个特性共同支撑起面向对象编程的基础结构,使得OOP成为了一个强大和灵活的编程范式。
4、C++中struct和class有什么区别?
在C++中,struct
(结构体)和class
(类)在语法上非常相似,但它们有一个主要的默认访问权限和默认继承类型的区别:
-
默认访问权限:在
class
中,默认的成员访问权限是私有的(private),而在struct
中,默认的是公共的(public)。这意味着除非你明确指定,否则class
的成员和继承类型都是私有的,而struct
的成员和继承类型默认是公开的。 -
默认继承类型:当从
struct
或class
继承时,如果没有显式指定继承类型(public、protected或private),struct
会默认采用public继承,而class
会默认采用private继承。
除了这些默认行为的差异,struct
和class
在C++中是几乎相同的,它们都可以包含数据成员、成员函数、构造函数、析构函数、成员函数重载、运算符重载等。
在实际使用中,struct
通常用于包含数据的简单的聚合类型,而class
通常用于需要封装和复杂行为的对象。但这更多是编程风格和传统的选择,而不是强制的规则。
例如,如果你有一个只包含数据的点结构,你可能会选择使用struct
:
struct Point {
int x;
int y;
};
如果你有一个更复杂的数据结构,可能需要封装和方法来操作数据,你可能会选择使用class
:
class Car {
private:
int speed;
int gear;
public:
void accelerate(int increment);
void decelerate(int decrement);
// 更多的成员函数和构造函数
};
在现代C++编程中,选择struct
还是class
更多是基于你想要表达的意图,而不是它们的技术区别。
5、动态多态有什么作用?有哪些必要条件?
动态多态是面向对象编程中的一个核心特性,它允许在运行时通过指向基类的指针或引用来调用派生类的方法,使得相同的操作可以作用于不同类型的对象上,从而表现出不同的行为。
动态多态的作用非常广泛,它允许程序代码更加通用和灵活。例如,你可以设计一个函数,它接受一个基类的引用,然后在运行时,这个函数可以用不同派生类的对象来调用,而且不需要修改函数本身的代码。这种能力使得代码重用更加容易,可以构建更加抽象和动态的系统。
动态多态的实现有几个必要条件:
-
继承:必须有两个类,一个基类和一个从基类派生出来的子类。
-
基类中的虚函数:在基类中必须有至少一个函数被声明为虚函数(使用
virtual
关键字)。派生类通常会重写(override)这个虚函数来提供特定的功能。 -
基类的指针或引用:需要通过基类的指针或引用来调用虚函数,这样C++运行时才能利用虚函数表(v-table)来动态决定调用哪个函数。
-
动态绑定:当通过基类的指针或引用调用虚函数时,发生的是动态绑定,这意味着直到程序运行时,才决定调用对象的哪个方法。
举个例子,假设有一个基类Shape
和两个派生类Circle
和Square
。基类中有一个虚函数draw()
。那么你可以通过Shape
的指针或引用来调用draw()
,在运行时,如果指向的是Circle
对象,则调用的是Circle
的draw()
实现,如果是Square
对象,则调用Square
的draw()
实现。
这使得程序能够对不同类型的对象进行操作,而无需知道对象的确切类型,从而增加了程序的灵活性和可扩展性。
6、C++中类成员的访问权限
在C++中,类成员的访问权限是通过访问修饰符来控制的,主要有三种:public
、protected
和private
。
-
Public(公共):
public
成员在任何地方都可以访问。- 如果一个类的成员被声明为
public
,那么这个成员可以在类的内部被访问,类的对象可以直接访问它,继承该类的子类也可以访问。
-
Protected(受保护):
protected
成员在类内部和派生类中可以访问,但是不能通过类的对象直接访问。- 这意味着如果一个成员声明为
protected
,那么它对于任何从该类派生的类都是可访问的,但是不可以通过对象来直接访问。
-
Private(私有):
private
成员只能在类内部被访问。- 这是最严格的访问级别,如果成员被声明为
private
,那么它只能被类的成员函数、友元函数访问,即使是子类也无法访问私有成员。
下面是一个简单的类定义,展示了如何使用这些访问修饰符:
class MyClass {
public: // 公共成员
int publicVariable;
void publicFunction() {
// ...
}
protected: // 受保护成员
int protectedVariable;
void protectedFunction() {
// ...
}
private: // 私有成员
int privateVariable;
void privateFunction() {
// ...
}
};
访问权限是面向对象设计的一个重要方面,它帮助我们实现封装。封装不仅仅是将数据和行为包装在一起,还包括对数据的保护,确保只有通过类提供的接口才能访问和修改数据,防止了外部的非法访问,降低了代码的复杂性,并使得维护和扩展更加容易。
7、多态的实现有哪几种?
在C++中,多态主要通过以下两种方式实现:
-
编译时多态(静态多态):
- 这种多态在编译时发生,主要通过函数重载和运算符重载实现。
- 函数重载是在同一作用域内有多个同名函数,但它们的参数类型或数量不同,编译器根据函数调用时传入的参数类型和数量来决定调用哪个函数。
- 运算符重载是一种特殊的函数重载,它允许为类定义新的操作符函数,使得可以使用传统操作符来操作对象。
-
运行时多态(动态多态):
- 这种多态在程序运行时发生,主要通过虚函数实现。
- 虚函数:当一个函数在基类中被声明为虚函数时,它可以在任何派生类中被重写。通过基类的指针或引用调用虚函数时,会根据对象的实际类型来调用相应的函数,即使是在基类类型的引用或指针下也是如此。
- 纯虚函数和抽象类:当在类中声明一个虚函数但不提供实现,只提供其声明的时候,这个函数就是纯虚函数(使用
= 0
语法),包含纯虚函数的类称为抽象类。抽象类不能被实例化,只能被继承,并且派生类必须提供纯虚函数的实现。
动态多态是通过虚函数表(也称为V-Table)来实现的,这是一种在运行时用来解析函数调用的机制。当类中包含虚函数时,每个对象会包含一个指向虚函数表的指针,虚函数表中存储了对应于该对象实际类型的函数地址。这样,当调用虚函数时,程序能够动态地决定应该调用哪个函数实现。
这两种多态的方式都允许同一接口使用不同的实现,使得程序可以在不完全知道对象类型的情况下,对对象进行操作。静态多态的优点是效率高,因为函数调用在编译时就已经解析了;而动态多态的优点是灵活性高,可以在运行时决定调用哪个函数。
8、动态绑定是如何实现的?
在C++中,动态绑定是通过虚函数来实现的。虚函数允许在派生类中重写基类的行为。在基类中声明虚函数时,使用关键字virtual
,这样在派生类中就可以覆盖这个函数以实现不同的行为。
当我们使用基类的指针或引用来调用一个虚函数时,C++运行时会根据对象的实际类型来决定应该调用哪个函数,这个过程是在运行时发生的,因此被称为“动态绑定”。
举个例子,假设我们有一个Animal
基类和两个派生类Dog
和Cat
。Animal
类中有一个虚函数makeSound()
。Dog
和Cat
类分别覆盖了这个函数,提供了各自的实现。
class Animal {
public:
virtual void makeSound() {
std::cout << "Some generic animal sound\n";
}
};
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "Woof!\n";
}
};
class Cat : public Animal {
public:
void makeSound() override {
std::cout << "Meow!\n";
}
};
当我们这样调用时:
Animal* myAnimal = new Dog();
myAnimal->makeSound(); // 输出 "Woof!"
即使myAnimal
是一个Animal
类型的指针,它也会调用Dog
类中的makeSound()
函数,因为myAnimal
实际指向的是一个Dog
对象。这就是动态绑定的工作原理。如果将myAnimal
指向Cat
类的对象,那么调用myAnimal->makeSound()
将输出"Meow!"。这种机制使得我们可以写出更加灵活和可扩展的代码。
9、动态多态有什么作用?有哪些必要条件?
动态多态在C++中主要用于允许在运行时选择使用哪个函数,即使我们在编写代码时不知道确切的对象类型。它使得程序可以更加灵活,可以编写出既通用又可扩展的代码。通过动态多态,同一个接口可以对应多种不同的实现,这有助于减少代码冗余和增强代码的可维护性。
动态多态的实现有以下必要条件:
- 继承:必须有一个基类和一个或多个派生类。
- 虚函数:在基类中必须有虚函数,派生类中可以重写这些虚函数。
- 指针或引用:使用基类类型的指针或引用来操作派生类的对象。
应用场景的例子:考虑一个图形编辑器,我们可以定义一个Shape
基类,并且有多个派生类如Circle
、Rectangle
等。Shape
类中有一个虚函数draw()
,每个派生类都有自己的实现。
class Shape {
public:
virtual void draw() const = 0; // 纯虚函数,使得Shape成为抽象类
};
class Circle : public Shape {
public:
void draw() const override {
// 绘制圆形的代码
}
};
class Rectangle : public Shape {
public:
void draw() const override {
// 绘制矩形的代码
}
};
在图形编辑器中,我们可能有一个Shape
类型的列表,其中包含了各种形状的对象。在运行时,我们可以遍历这个列表,调用每个形状的draw()
函数来绘制它们。这样,无论列表中有什么类型的形状,都会调用正确的绘制函数,这就是动态多态的作用。
10、纯虚函数有什么作用?如何实现?
纯虚函数在C++中用于创建抽象类,这种类不能直接实例化,而是用来定义派生类应遵循的接口。当类中至少有一个纯虚函数时,这个类就成为了抽象类。纯虚函数定义了一个接口,派生类需要覆盖这个接口提供具体的实现。
纯虚函数的作用主要有两个:
- 定义接口规范:它规定了派生类必须实现的函数,确保所有派生类都遵循同一接口规范。
- 阻止基类实例化:它使得不能创建基类的对象,只能创建派生类的对象,这样可以确保客户代码不会错误地使用不完整的基类对象。
纯虚函数的声明在C++中是在函数声明末尾加上= 0
。这里的= 0
并不表示函数返回值为0,而是C++语法中表示函数为纯虚函数的特殊标记。
下面是一个纯虚函数的例子:
class Base {
public:
virtual void doSomething() = 0; // 纯虚函数
};
class Derived : public Base {
public:
void doSomething() override {
// 提供具体的实现
}
};
在这个例子中,Base
是一个抽象类,因为它有一个纯虚函数doSomething()
。Derived
类继承自Base
并提供了doSomething()
的具体实现。这样,不能直接创建Base
类的对象,但可以创建Derived
类的对象。
在设计模式中,纯虚函数经常用来定义接口或者抽象基类,以便不同的派生类可以提供多样化的实现,这是实现多态的关键部分。
11、虚函数表是针对类的还是针对对象的?同一个类的两个对象的虚函数表是怎么维护的?
答:虚函数表,或者称为vtable,是针对类的。虚函数表是一个存储类中所有虚函数地址的数组。当我们定义一个类,并在其中声明了虚函数时,编译器就会为这个类生成一个虚函数表。
每一个对象(或者说是实例),只要它的类有虚函数,那么它就会有一个指向这个类的虚函数表的指针。这意味着,同一个类的各个对象,它们的虚函数表指针都指向同一个虚函数表。所以,虽然每个对象都有自己的虚函数表指针,但是同一个类的所有对象共享同一个虚函数表。
举个例子,假设我们有一个基类Animal
,它有一个虚函数makeSound()
。那么,Animal
就有一个虚函数表,其中包含了makeSound()
的地址。然后我们创建了两个Animal
对象,cat
和dog
。这两个对象都有一个指针指向Animal
的虚函数表,即使是两个不同的对象,但是它们的虚函数表是相同的。
然后,如果我们有一个子类Cat
继承自Animal
,并且重写了makeSound()
函数。那么,Cat
也会有一个虚函数表,其中makeSound()
的地址被替换为Cat
类中的makeSound()
函数的地址。当我们创建一个Cat
对象kitty
时,kitty
的虚函数表指针就会指向Cat
的虚函数表。
12、为什么基类的构造函数不能定义为虚函数?
在C++中,基类的构造函数不能被定义为虚函数,原因有两个:
-
构造函数的目的是初始化对象。当我们创建一个对象时,构造函数被调用来初始化对象的数据成员。在这个阶段,对象才刚刚开始被构建,还没有完全形成,因此它还不具备执行虚函数调用的条件(即,动态绑定)。因为执行虚函数调用需要通过对象的虚函数表指针,而这个指针在构造函数执行完毕后才会被设置。
-
虚函数通常在有继承关系的类中使用,用于实现多态。在子类对象的构造过程中,首先会调用基类的构造函数,然后才是子类的构造函数。如果基类的构造函数被定义为虚函数,那么在执行基类的构造函数时,由于子类的部分还没有被构造,所以无法正确地执行子类构造函数中对虚函数的重写。这就破坏了虚函数的目的,即允许子类重写基类的行为。
因此,基于以上原因,C++不允许构造函数为虚函数。但是,析构函数可以(并且通常应该)被声明为虚函数,以确保当删除一个指向派生类对象的基类指针时,派生类的析构函数能被正确调用,避免资源泄露。
13、为什么基类的析构函数需要定义为虚函数?
在C++中,基类的析构函数应该被定义为虚函数,主要是为了能正确地释放动态分配的资源,避免内存泄漏。
当我们使用基类指针指向派生类对象,并使用delete
删除这个指针时,如果基类的析构函数不是虚函数,那么只有基类的析构函数会被调用。这样,派生类的析构函数就没有机会被调用,导致派生类中的资源没有被正确释放,造成内存泄漏。
而如果我们将基类的析构函数定义为虚函数,那么在删除基类指针时,就会根据这个指针实际指向的对象类型,调用相应的析构函数,先调用派生类的析构函数,然后再调用基类的析构函数。这样就能确保所有的资源都被正确释放,避免内存泄漏。
举个例子,假设我们有一个基类Animal
和一个派生类Cat
,Cat
类在堆上分配了一些资源。如果我们用一个Animal
指针指向一个Cat
对象,然后用delete
删除这个指针,如果Animal
的析构函数不是虚函数,那么只有Animal
的析构函数会被调用,Cat
的析构函数不会被调用,Cat
在堆上分配的资源就没有被释放,造成内存泄漏。而如果Animal
的析构函数是虚函数,那么就会先调用Cat
的析构函数,释放Cat
的资源,然后再调用Animal
的析构函数,这样就避免了内存泄漏。
14、构造函数和析构函数能抛出异常吗?
在C++中,构造函数和析构函数都可以抛出异常,但这并不是一个被推荐的做法,原因如下:
构造函数抛出异常:
如果在构造函数中抛出异常,那么对象的构造过程就会被中断。这就意味着对象可能处于一个部分初始化的状态,其成员可能没有被正确初始化。如果你试图在后续的代码中使用这个对象,可能会出现未定义的行为。
举个例子,你有一个DatabaseConnection
类,其构造函数试图连接到数据库。如果连接失败,构造函数就抛出一个异常。这个时候,如果你在后续的代码中试图使用这个DatabaseConnection
对象,就可能出现问题,因为它并没有正确地初始化。
析构函数抛出异常:
如果在析构函数中抛出异常,情况就更复杂了。析构函数通常在对象生命周期结束时被调用,或者在释放动态分配的内存时被调用。如果在这个过程中析构函数抛出了异常,而你又没有正确地捕获这个异常,那么程序就可能会中断,并可能导致资源泄露。
更糟糕的是,如果析构函数是在处理另一个异常时被调用,并在这个过程中又抛出了一个新的异常,那么C++会立即调用std::terminate
,程序会立即终止。
因此,虽然构造函数和析构函数都可以抛出异常,但是在大多数情况下,我们应该尽量避免在这两个函数中抛出异常,或者至少确保这些异常被正确地捕获和处理,以避免未定义的行为
15、如何让一个类不能实例化?
在C++中,如果你希望一个类不能被实例化,也就是不能创建该类的对象,你可以通过以下两种方式来实现:
- 声明类的构造函数为protected或private: 如果一个类的构造函数被声明为protected或private,那么在类的外部就不能直接调用这个构造函数来创建类的对象。只有类本身和它的友元函数或类可以访问它的私有或保护成员。
class NonInstantiable1 {
private:
NonInstantiable1() {} // private constructor
};
- 将类声明为抽象基类(Abstract Base Class, ABC): 如果一个类至少有一个纯虚函数,那么这个类就是抽象基类,无法被实例化。纯虚函数是在基类中声明但不定义的虚函数,它在基类中的声明形式如下:
virtual void func() = 0;
。纯虚函数使得派生类必须提供自己的实现,否则派生类也将成为抽象基类。
class NonInstantiable2 {
public:
virtual void func() = 0; // pure virtual function
};
上述两种方式都可以让一个类不能直接实例化,但是可以作为基类被继承。在派生类中,你可以提供构造函数的实现或者实现基类中的纯虚函数,使得派生类可以被实例化。
由于内容太多,更多内容以链接形势给大家,点击进去就是答案了