面试题总结(二) – 面向对象篇(封装、继承、多态)
<1> 请阐述 C++ 中面向对象的三大特性(封装、继承、多态)。
- 封装是将数据和操作数据的方法封装在一个类中,隐藏内部实现细节。
- 继承允许创建新类基于现有类,复用其属性和方法。
- 多态则通过基类指针或引用调用派生类的函数,实现同一接口不同实现。
封装(Encapsulation)
概念:封装是将数据和操作数据的方法封装在一个类中,对外隐藏内部的实现细节,只提供公共的接口供外部访问。通过封装,可以保护数据的安全性和完整性,防止外部直接访问和修改内部数据,同时也提高了代码的可维护性和可扩展性。
实现方式:
使用访问修饰符(如public、private和protected)来控制类成员的访问权限。private成员只能在类内部访问,public成员可以在类外部访问,protected成员可以在类内部和派生类中访问。
提供公共的成员函数作为接口,外部代码通过调用这些接口来操作类的内部数据。例如:
class MyClass {
private:
int privateData;
public:
void setData(int value) {
privateData = value;
}
int getData() const {
return privateData;
}
};
作用:
- 数据隐藏:保护类的内部数据不被外部直接访问,防止意外的修改和破坏。
- 代码模块化:将数据和操作封装在一个类中,使得代码更加模块化,易于理解和维护。
- 提高可维护性:如果内部实现需要修改,只需要修改类的内部代码,而不会影响外部代码的使用。
继承(Inheritance)
概念:继承是一种建立类之间层次关系的机制,允许一个类(派生类)继承另一个类(基类)的属性和方法。派生类可以扩展和修改基类的功能,同时继承基类的特性。通过继承,可以实现代码的复用,减少重复代码的编写,提高开发效率。
实现方式:使用冒号(:)后跟基类名称来表示继承关系。例如:
class Base {
public:
void baseFunction() {
std::cout << "Base function." << std::endl;
}
};
class Derived : public Base {
public:
void derivedFunction() {
std::cout << "Derived function." << std::endl;
}
};
作用:
- 代码复用:派生类可以继承基类的成员变量和成员函数,避免重复编写相同的代码。
- 层次结构:建立类之间的层次关系,使得代码更加清晰和易于理解。
- 多态性基础:继承是实现多态性的基础,通过基类指针或引用可以指向派生类对象,从而实现不同的行为。
多态(Polymorphism)
概念:多态是指同一个操作作用于不同的对象可以有不同的表现形式。在 C++ 中,多态主要通过虚函数和函数重载来实现。通过多态,可以提高代码的灵活性和可扩展性,使得程序能够根据不同的对象类型自动选择合适的函数进行调用。
实现方式:
1)动态多态
通过基类指针或引用调用虚函数时,会根据实际指向的对象类型来调用相应的函数。例如:
class Base {
public:
virtual void virtualFunction() {
std::cout << "Base virtual function." << std::endl;
}
};
class Derived : public Base {
public:
void virtualFunction() override {
std::cout << "Derived virtual function." << std::endl;
}
};
2)静态多态
函数重载:在同一个作用域内,可以有多个函数具有相同的函数名,但参数列表不同。根据不同的参数类型,编译器会自动选择合适的函数进行调用。例如:
void print(int value) {
std::cout << "Integer: " << value << std::endl;
}
void print(double value) {
std::cout << "Double: " << value << std::endl;
}
作用:
- 灵活性:多态使得程序能够根据不同的对象类型自动选择合适的函数进行调用,提高了代码的灵活性。
- 可扩展性:可以在不修改现有代码的情况下,通过添加新的派生类来扩展程序的功能。
- 代码复用:通过虚函数和函数重载,可以实现代码的复用,减少重复代码的编写。
<2> 什么是类的构造函数和析构函数?它们的作用分别是什么?
在 C++ 中,构造函数是一种特殊的成员函数,用于在创建对象时进行初始化操作。析构函数也是特殊的成员函数,在对象销毁时执行清理工作,释放资源。
构造函数
定义:构造函数是一种特殊的成员函数,它的名称与类名相同,没有返回类型。构造函数可以有参数,用于在创建对象时初始化对象的成员变量。
作用:
- 对象初始化:构造函数在对象创建时被自动调用,用于初始化对象的成员变量,确保对象在使用之前处于一个合理的状态。
- 参数传递:构造函数可以接受参数,允许在创建对象时传递初始值,以便根据不同的情况创建具有不同状态的对象。
- 资源分配:构造函数可以负责分配对象所需的资源,如动态内存分配、打开文件、建立数据库连接等。
例如:
class MyClass {
private:
int data;
public:
MyClass() : data(0) {
std::cout << "Default constructor called." << std::endl;
}
MyClass(int value) : data(value) {
std::cout << "Constructor with parameter called." << std::endl;
}
};
在这个例子中,MyClass
类有两个构造函数。一个是默认构造函数,它没有参数,用于创建一个初始状态为data = 0
的对象。另一个是带参数的构造函数,它接受一个整数参数,用于创建一个具有特定初始值的对象。
析构函数
定义:析构函数也是一种特殊的成员函数,它的名称是在类名前加上波浪线(~)。析构函数没有参数,也没有返回类型。析构函数在对象被销毁时自动调用。
作用:
资源释放:析构函数负责释放对象在生命周期中分配的资源,如释放动态内存、关闭文件、断开数据库连接等。
清理工作:析构函数可以执行一些清理工作,如保存数据、释放锁等,确保对象在销毁时不会留下任何未处理的状态。
例如:
class MyClass {
private:
int* data;
public:
MyClass() : data(new int[10]) {
std::cout << "Constructor called." << std::endl;
}
~MyClass() {
delete[] data;
std::cout << "Destructor called." << std::endl;
}
};
在这个例子中,MyClass
类的构造函数分配了一块动态内存,用于存储一个整数数组。析构函数在对象被销毁时释放了这块动态内存,以避免内存泄漏。
<3> 如何实现 C++ 中的继承?继承有哪些类型?
在 C++ 中,通过在派生类的声明中指定基类来实现继承。继承类型包括公有继承(public)、私有继承(private)和保护继承(protected)。公有继承时,基类的公有和保护成员在派生类中保持原有访问级别;私有继承时,基类的所有成员在派生类中都变为私有;保护继承时,基类的公有和保护成员在派生类中变为保护成员。
- 公有继承(public inheritance):使用关键字public指定基类和派生类之间的访问权限。公有继承意味着派生类可以访问基类的公有成员,但不能直接访问基类的私有成员。
- 私有继承(private inheritance):使用关键字private指定基类和派生类之间的访问权限。私有继承意味着派生类无法直接访问基类的成员,包括公有和保护成员。
- 保护继承(protected inheritance):使用关键字protected指定基类和派生类之间的访问权限。保护继承与私有继承相似,但允许派生类访问基类的保护成员。
实现继承时,需要在派生类声明中使用冒号后跟着关键字 public
, private
, 或者 protected
,然后再加上要继承的基类名称。例如:
class BaseClass {
// 基类定义
};
class DerivedClass : public BaseClass {
// 公有继承
};
class AnotherDerivedClass : private BaseClass {
// 私有继承
};
class YetAnotherDerivedClass : protected BaseClass {
// 保护继承
};
通过继承,派生类可以获得基类的成员变量和成员函数,并且可以在派生类中添加新的成员或重写基类的成员函数。
<4> 解释多态性在 C++ 中的实现方式(虚函数、纯虚函数等)。
在 C++ 中,多态性主要通过虚函数和纯虚函数来实现。
- 虚函数允许在派生类中重写,通过基类指针或引用调用时能实现动态绑定。
- 纯虚函数则用于定义抽象类,强制派生类必须实现该函数。
虚函数(Virtual Function)
概念:虚函数是在基类中用关键字virtual声明的成员函数。当一个类中包含虚函数时,这个类就被称为多态类型。虚函数允许在派生类中重写基类的函数,以实现不同的行为。
实现方式:
在基类中声明虚函数:使用virtual关键字在基类中声明一个函数为虚函数。例如:
class Base {
public:
virtual void print() {
std::cout << "Base class print function." << std::endl;
}
};
通过基类指针或引用调用虚函数:使用基类指针或引用来指向派生类对象,并调用虚函数。根据实际指向的对象类型,会调用相应的重写后的函数。例如:
int main() {
Base* basePtr = new Derived();
basePtr->print(); // 调用 Derived 类中的 print 函数
delete basePtr;
return 0;
}
作用:
- 实现运行时多态性:在程序运行时,根据对象的实际类型来确定调用哪个版本的虚函数,而不是在编译时根据指针或引用的类型来确定。
- 提高代码的可扩展性:可以在不修改现有代码的情况下,通过添加新的派生类并重写虚函数来扩展程序的功能。
纯虚函数(Pure Virtual Function)
概念:纯虚函数是在基类中声明为virtual并在函数声明后加上= 0的成员函数。含有纯虚函数的类被称为抽象类,不能被实例化。纯虚函数为派生类提供了一个必须实现的接口。
实现方式:
在基类中声明纯虚函数:使用virtual关键字声明函数,并在函数声明后加上= 0。例如:
class Base {
public:
virtual void print() = 0;
};
在派生类中实现纯虚函数:派生类必须实现基类中的所有纯虚函数,否则派生类也将成为抽象类。例如:
class Derived : public Base {
public:
void print() override {
std::cout << "Derived class print function." << std::endl;
}
};
作用:
- 定义接口:抽象类通过纯虚函数定义了一个接口,派生类必须实现这个接口才能被实例化。这有助于确保派生类具有特定的行为,提高代码的规范性和可维护性。
- **实现部分抽象:**可以在基类中实现一些通用的功能,而将一些特定的功能留给派生类通过实现纯虚函数来完成。
<5> 谈谈 C++ 中类成员的访问权限(public、private、protected)。
- public:公共访问权限,所有代码均可访问该成员。公共成员可以在类的内部和外部被访问,没有访问限制。
- private:私有访问权限,只有在类的内部可以访问该成员。私有成员只能在所属类的成员函数内部使用,无法从类的外部或派生类中直接访问。
- protected:保护访问权限,只有在所属类及其派生类内部可以访问该成员。受保护的成员与私有成员相似,但允许派生类继承并访问。
通过控制类成员的访问权限,可以实现封装性(Encapsulation)原则。公共接口通过public关键字提供给外界使用,而私有和受保护数据则对外隐藏起来。这样可以有效地控制数据的安全性和灵活性,并确保代码的健壮性和可维护性。
<6> 如何在 C++ 中实现动态绑定?
在 C++ 中,通过使用虚函数可以实现动态绑定。当使用基类的指针或引用调用虚函数时,会根据实际指向或引用的对象类型来决定调用哪个具体的实现。
使用虚函数实现动态绑定的步骤
**(1)在基类中声明虚函数:**在基类中,使用virtual关键字声明一个成员函数为虚函数。例如:
class Base {
public:
virtual void print() {
std::cout << "Base class print function." << std::endl;
}
};
**(2)在派生类中重写虚函数:**在派生类中,使用相同的函数签名重写基类的虚函数。例如:
class Derived : public Base {
public:
void print() override {
std::cout << "Derived class print function." << std::endl;
}
};
**(3)通过基类指针或引用调用虚函数:**创建基类指针或引用,使其指向派生类对象。当通过基类指针或引用调用虚函数时,会根据实际指向的对象类型在运行时确定调用哪个版本的函数,实现动态绑定。例如:
int main() {
Base* basePtr = new Derived();
basePtr->print(); // 调用 Derived 类中的 print 函数
delete basePtr;
return 0;
}
动态绑定的原理
虚函数表(VTable):
- 当一个类包含虚函数时,编译器会为该类创建一个虚函数表(VTable)。虚函数表是一个存储函数指针的数组,每个指针指向类中的一个虚函数。
- 对于每个包含虚函数的对象,编译器会在对象的内存布局中添加一个隐藏的指针,称为虚函数表指针(VTable Pointer),该指针指向对象所属类的虚函数表。
运行时确定函数调用:
- 当通过基类指针或引用调用虚函数时,实际上是通过虚函数表指针找到虚函数表,然后根据函数在表中的位置调用相应的函数。
- 由于虚函数表是在运行时根据对象的实际类型确定的,所以可以实现动态绑定,即根据对象的实际类型来调用正确的函数版本。
注意事项
**虚函数的开销:**使用虚函数会带来一定的运行时开销,因为需要通过虚函数表指针进行间接调用。在性能敏感的代码中,需要考虑虚函数的开销。
**纯虚函数和抽象类:**如果一个类中至少有一个纯虚函数,那么这个类就是抽象类,不能被实例化。抽象类通常用于定义接口,派生类必须实现所有的纯虚函数才能被实例化。
**虚函数的继承:**派生类继承基类的虚函数表。如果派生类重写了基类的虚函数,那么在派生类的虚函数表中,相应的函数指针会指向派生类的函数实现。
<7> 讲讲 C++ 中对象的生命周期。
在 C++ 中,对象的生命周期包括创建、使用和销毁。对象可以在栈上、堆上或全局 / 静态存储区创建。
- 在栈上创建的对象,其生命周期在所在的作用域结束时结束。
- 堆上创建的对象,需要通过 delete 手动释放来结束其生命周期。
- 全局 / 静态存储区创建的对象,其生命周期从程序开始到程序结束。
对象的创建
全局对象和静态对象:
全局对象和静态对象在程序启动时创建,在程序结束时销毁。它们的生命周期贯穿整个程序的运行过程。
全局对象在所有函数和类之外定义,而静态对象可以在函数内部或类的静态成员中定义。例如:
int globalVariable; // 全局对象
void function() {
static int staticVariable; // 静态对象
}
**局部对象:**局部对象在函数或代码块被执行时创建,在函数或代码块执行完毕后销毁。局部对象的生命周期仅限于它们所在的函数或代码块的执行范围。
void function() {
int localVariable; // 局部对象
}
**动态分配的对象:**使用new运算符动态分配的对象在堆上创建,它们的生命周期由程序员手动管理。可以通过delete运算符显式地销毁动态分配的对象。例如:
int* ptr = new int; // 动态分配的对象
delete ptr; // 销毁动态分配的对象
对象的初始化
对象在创建时可以通过构造函数进行初始化。构造函数是一种特殊的成员函数,用于在对象创建时设置对象的初始状态。例如:
class MyClass {
public:
MyClass() {
std::cout << "Object created." << std::endl;
}
};
int main() {
MyClass obj; // 创建对象并调用构造函数
return 0;
}
对象的使用
**成员函数调用:**对象可以通过成员函数调用来执行特定的操作。成员函数可以访问对象的成员变量,并对其进行修改或执行其他操作。例如:
class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {}
void setData(int value) {
data = value;
}
int getData() const {
return data;
}
};
int main() {
MyClass obj(10);
obj.setData(20);
int value = obj.getData();
return 0;
}
**成员变量访问:**对象的成员变量可以在对象的生命周期内被访问和修改。成员变量的访问可以通过成员函数或直接访问来实现。例如:
class MyClass {
private:
int data;
public:
MyClass(int value) : data(value) {}
int getData() const {
return data;
}
void setData(int value) {
data = value;
}
};
int main() {
MyClass obj(10);
int value = obj.getData();
obj.setData(20);
return 0;
}
对象的销毁
**局部对象的销毁:**局部对象在函数或代码块执行完毕后自动销毁。对象的析构函数会被自动调用,以释放对象占用的资源。例如:
void function() {
MyClass obj; // 创建局部对象
// 对象在函数执行完毕后自动销毁,析构函数被调用
}
**动态分配对象的销毁:**动态分配的对象必须通过delete运算符显式地销毁。如果不及时销毁动态分配的对象,会导致内存泄漏
。例如:
int* ptr = new int; // 动态分配对象
delete ptr; // 销毁动态分配的对象
**全局对象和静态对象的销毁:**全局对象和静态对象在程序结束时自动销毁。它们的析构函数会在程序退出时被自动调用。例如:
int globalVariable; // 全局对象
void function() {
static int staticVariable; // 静态对象
}
int main() {
// 程序执行过程中
return 0;
} // 程序结束时,全局对象和静态对象自动销毁,析构函数被调用
**对象的析构函数:**析构函数是一种特殊的成员函数,用于在对象销毁时执行清理操作。析构函数在对象的生命周期结束时自动调用。例如:
class MyClass {
public:
MyClass() {
std::cout << "Object created." << std::endl;
}
~MyClass() {
std::cout << "Object destroyed." << std::endl;
}
};
int main() {
MyClass obj; // 创建对象
// 对象在 main 函数执行完毕后自动销毁,析构函数被调用
return 0;
}
<8> 什么是虚函数表?它在多态实现中的作用是什么?
虚函数表(Virtual Function Table,简称VTable)是用于实现C++中多态特性的一种机制。每个含有虚函数的类都会有一个对应的虚函数表。
虚函数表是一个指向虚函数的指针数组,其中存储了该类所有虚函数的地址。当一个类被定义为带有虚函数时,编译器会在该类对象中插入一个隐藏成员变量——指向其对应虚函数表的指针。通过这个指针,程序可以在运行时动态地确定要调用哪个派生类中的虚函数。
在多态实现中,当基类指针或引用指向派生类对象,并通过该基类指针或引用调用虚函数时,根据指针或引用所指向的对象类型,在运行时将动态地解析到正确的派生类实现。这样就实现了基于对象类型而不是变量类型来选择适当的函数实现,从而实现多态性。
通过使用虚函数和虚函数表,C++能够支持运行时多态性,允许程序根据具体对象类型来决定执行哪个版本的虚函数。这是C++面向对象编程中重要的特性之一。
<9> 如何避免 C++ 中类的成员函数的重定义问题?
在 C++ 中,要避免类的成员函数重定义问题,需确保函数的声明和定义在签名(包括参数类型和返回类型)上完全一致。同时,注意不要在不同的编译单元中重复定义同一个成员函数。
(1)明确函数签名
**参数类型和数量:**确保每个成员函数的参数类型和数量是唯一的。如果两个函数具有相同的名称但不同的参数列表,它们被称为重载函数,这是合法的 C++ 语法,但不是重定义。例如:
class MyClass {
public:
void myFunction(int x);
void myFunction(double y);
};
在这个例子中,myFunction被重载了两次,分别接受一个整数参数和一个双精度浮点数参数。这不是重定义问题,因为函数签名不同。
**返回类型:**仅返回类型不同不能区分两个函数。C++ 不允许仅通过返回类型的不同来重载函数。例如,以下代码会导致编译错误:
class MyClass {
public:
int myFunction();
double myFunction();
};
(2)正确使用作用域解析运算符
**在派生类中调用基类函数:**当从一个基类派生一个类时,如果派生类中定义了与基类中同名的函数,需要使用作用域解析运算符::来明确调用基类的函数。例如:
class Base {
public:
void myFunction();
};
class Derived : public Base {
public:
void myFunction();
void callBaseFunction() {
Base::myFunction(); // 明确调用基类的 myFunction
}
};
在Derived类的callBaseFunction函数中,使用Base::myFunction()来调用基类的myFunction函数,避免了与派生类中的myFunction函数的混淆。
**在类的成员函数中调用其他成员函数:**如果一个类中有多个同名的成员函数,也可以使用作用域解析运算符来明确调用特定的函数。例如:
class MyClass {
public:
void myFunction();
void myFunction(int x);
void callSpecificFunction() {
myFunction(); // 调用无参数的 myFunction
this->myFunction(10); // 调用有参数的 myFunction
}
};
在callSpecificFunction函数中,通过直接调用myFunction()和this->myFunction(10)来明确调用不同版本的myFunction函数。
(3)避免意外的宏扩展
**宏定义:**在 C++ 中,宏定义可能会导致意外的函数重定义。如果一个宏定义与一个成员函数的名称相同,并且在代码中被扩展,可能会导致编译错误。为了避免这种情况,应该避免使用与成员函数名称相同的宏定义。例如:
#define myFunction(x) (x + 1)
class MyClass {
public:
void myFunction();
};
在这个例子中,宏定义myFunction(x)与类MyClass中的成员函数myFunction()同名,可能会导致编译错误。应该避免使用这样的宏定义,或者在使用宏定义时选择不同的名称。
**包含头文件顺序:**如果多个头文件中都定义了同名的宏,并且这些头文件以不同的顺序被包含,可能会导致不同的宏扩展顺序,从而引发重定义问题。为了避免这种情况,应该尽量避免在头文件中定义宏,或者确保头文件的包含顺序不会导致宏的冲突。
(4)使用命名空间
命名空间可以帮助避免函数重定义问题,特别是在大型项目中,当多个模块中可能有同名的函数时。将类和函数放在命名空间中可以提供额外的作用域,避免名称冲突。例如:
namespace MyNamespace {
class MyClass {
public:
void myFunction();
};
}
在这个例子中,MyClass和它的成员函数myFunction都在命名空间MyNamespace中。这样可以避免与其他命名空间中的同名函数冲突。
在使用命名空间中的函数时,需要使用命名空间限定符或者使用using声明来引入命名空间中的名称。
命名空间讲述详见:命名空间(namespace)及其应用技巧-CSDN博客
例如:
int main() {
MyNamespace::MyClass obj;
obj.MyNamespace::myFunction(); // 使用命名空间限定符
using MyNamespace::MyClass;
MyClass obj2;
obj2.myFunction(); // 引入命名空间中的名称后,可以直接调用
return 0;
}
<10> 举例说明C++ 中友元函数和友元类的使用场景。
在 C++ 中,友元函数和友元类常用于需要访问类的私有成员以实现特定功能的场景。例如,在实现一个数学计算库时,可能需要定义友元函数来直接访问类中的私有数据进行复杂计算。又如,在实现一个日志系统时,可能会将日志类设为某些关键类的友元类,以便记录其私有状态。
(1)友元函数的使用场景
访问私有成员:当需要在一个外部函数中访问另一个类的私有成员时,可以将该外部函数声明为友元函数。例如,考虑一个表示矩形的类Rectangle,需要计算两个矩形的总面积。可以定义一个友元函数来访问矩形的私有成员变量(长度和宽度)以进行计算。
class Rectangle {
private:
int length;
int width;
public:
Rectangle(int l, int w) : length(l), width(w) {}
friend int totalArea(Rectangle r1, Rectangle r2);
};
int totalArea(Rectangle r1, Rectangle r2) {
return r1.length * r1.width + r2.length * r2.width;
}
输入输出操作符重载:为了能够方便地对自定义类进行输入和输出操作,可以将流插入(<<)和流提取(>>)操作符重载为友元函数。这样可以直接访问类的私有成员来进行输入输出操作。例如:
class Complex {
private:
double real;
double imag;
public:
Complex(double r = 0, double i = 0) : real(r), imag(i) {}
friend std::ostream& operator<<(std::ostream& os, const Complex& c);
friend std::istream& operator>>(std::istream& is, Complex& c);
};
std::ostream& operator<<(std::ostream& os, const Complex& c) {
os << c.real << " + " << c.imag << "i";
return os;
}
std::istream& operator>>(std::istream& is, Complex& c) {
is >> c.real >> c.imag;
return is;
}
(2)友元类的使用场景
**紧密合作的类:**当两个类之间存在紧密的合作关系,并且一个类需要访问另一个类的私有成员时,可以将其中一个类声明为另一个类的友元类。例如,考虑一个汽车类Car和一个引擎类Engine。汽车类可能需要访问引擎类的私有成员来获取引擎的状态信息。
class Engine {
private:
int horsepower;
bool isRunning;
public:
Engine(int hp) : horsepower(hp), isRunning(false) {}
friend class Car;
};
class Car {
private:
std::string model;
Engine engine;
public:
Car(const std::string& m, int hp) : model(m), engine(hp) {}
void startEngine() {
engine.isRunning = true;
std::cout << "Car " << model << " engine started." << std::endl;
}
int getHorsepower() {
return engine.horsepower;
}
};
**实现特定功能:**在某些情况下,为了实现特定的功能,可能需要一个类能够访问另一个类的私有成员。例如,考虑一个图形库中的形状类Shape和一个绘制类Drawer。绘制类需要访问形状类的私有成员来确定如何绘制形状。
class Shape {
private:
int x, y;
public:
Shape(int xPos, int yPos) : x(xPos), y(yPos) {}
friend class Drawer;
};
class Drawer {
public:
void drawShape(Shape s) {
// 由于 Drawer 是 Shape 的友元类,可以访问 Shape 的私有成员 x 和 y
std::cout << "Drawing shape at (" << s.x << ", " << s.y << ")." << std::endl;
}
};
{
engine.isRunning = true;
std::cout << "Car " << model << " engine started." << std::endl;
}
int getHorsepower() {
return engine.horsepower;
}
};