C++面试经验总结:面向对象编程
前言
本文旨在学习在求职面试过程中发现自身对于所学知识理解的不足,学习过程中借鉴大量网上文章,如理解存在不当之处或有所遗漏,还望各位大佬提点指教
什么是面向对象编程
面向对象编程的定义
面向对象编程(Object Oriented Programming,OOP)是许多编程语言(包括java、C++)的基础编程范式,它围绕数据或对象而非函数和逻辑组织软件设计,对象可以是具有唯一属性和行为的数据字段
面向对象编程专注于开发人员希望操作的对象,而非操作对象所需的逻辑。这种编程思想非常适合大型、复杂且主动更新或维护的程序
面向过程VS面向对象
-
面向过程是以事件为中心的编程思想,强调的是功能行为,一般以函数为单位,分析主体为解决问题的行为步骤
-
面向对象是以对象为中心的编程思想,强调的是具备功能的对象,一般以对象/类为单位,分析主体为问题中的执行者和被执行者
-
面向过程的性能比面向对象高 面向对象在类的调用时需要实例化,该过程比较耗费资源
-
面向对象比面向过程更容易维护、拓展、复用 面向对象具有的继承、封装、多态特性使其可以设计出低耦合的系统
面向对象编程的结构
- 类 用户定义的数据类型,充当各个对象、属性和方法的蓝图
- 就其本身而言,类不进行任何操作,它是一种用于创建该类型具体对象的模板
- 对象 使用专门定义的数据创建的类的实例
- 方法 在类中定义的函数,用于描述对象的行为
- 类定义中包含的每个方法都以对实例对象的引用开头
- 对象中包含的子例程(方法)称为实例方法
- 属性 在类模板中定义,表示对象的状态
- 类属性属于类本身
面向对象的主要原则
- 封装 隐藏对象的内部状态和功能,仅允许通过一组公共函数进行访问
- 抽象 将实体的相关特性和交互建模为类,以定义系统的抽象表示
- 继承 根据现有抽象创建新抽象的能力
- 多态 跨多个抽象以不同方式实现继承属性或方法的能力
面向对象编程的优势
- 模块性 封装使得对象能够自包含,从而使故障排除和协作开发更加容易
- 可重用 代码可以通过继承重用,意味着团队不用多次编写相同代码
- 提高效率 程序员可以通过使用多个库和可重用的代码更快地构建新程序
- 易于升级和扩展 程序员可以独立实现系统功能
- 接口说明 基于对象通信的消息传递技术使得外部接口的描述很简单
- 安全 使用封装和抽象,隐藏复杂的代码,软件维护更容易
- 灵活性 多态使得单个函数能够兼容多个类,不同的对象可以通过调用同一个接口实现功能
封装
封装指利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体,数据被保护在抽象数据类型的内部,尽可能隐藏其中的细节,只保留一些外部接口用来与外部产生联系。用户无需知道对象内部的细节,只能通过提供的外部接口来访问该对象
使用封装的好处
- 良好的封装能减少耦合
- 类内部的结构可以自由修改
- 可以对成员进行更精确的控制
- 隐藏信息、实现细节
访问修饰符
类主体内部通过访问修饰符来进行类成员的访问限制。访问修饰符主要有public、private、protected三个(C#中增加了internal、protected internal)。一个类可以有多个public、private或protected标记区域,每个标记区域在下一个标记区域开始前或在遇到类主体结束右括号前都是有效的
- public 可以被该类中的函数、子类的函数、其友元函数访问,也可以由该类的对象访问
- private 只能由该类中的函数、其友元函数访问,不能被任何其他访问,该类的对象也不能访问
- protected 可以被该类中的函数、子类的函数、以及其友元函数访问,但不能被该类的对象访问
- internal 同一程序集中的任意代码都可以访问该类型或成员,但其他程序集中的代码不可以
- protected internal 该类型或成员可由对其进行声明的程序集或另一程序集中的派生class中的任何代码访问
- private protected 该类型或成员可以通过从class派生的类型访问,这些类型在其包含程序集中进行声明
程序集
一个或多个类型定义文件及资源文件的集合
程序集包括
- 资源文件
- 类型元数据(描述在代码中定义的每一类型和成员,二进制形式)
- IL代码(封装在exe或dll中)
最终生成的程序集既可以是可执行的应用程序,也可以是DLL
使用程序集的好处
- 程序中只引用必须的程序集,减小程序的尺寸
- 程序集可以封装一些代码,只提供必要的访问接口
- 方便扩展
友元函数
- 使用友元函数的目的:让一些设定的函数可以访问类中的private或protected数据并进行操作
- 友元函数的三种实现
- 全局函数做友元
- 类做友元
- 成员函数做友元
- 友元函数的缺点:破坏了类的封装特性。对外声明为友元后,类的所有细节都对友元开放
//全局函数做友元函数
class House
{
//告诉编译器全局函数visit()是house类的友元函数,可以访问house对象的私有成员
friend void visit1(House *house);
friend void visit2(House &house);
friend void visit3(House house);
public:
house()
{
bedroom = "卧室";
kitchen = "厨房";
}
string bedroom;
private:
string kitchen;
};
void visit1(House *house) //地址传递
{
cout<<"visiting(地址传递)"<<house->bedroom<<endl;
cout<<"visiting(地址传递)"<<house->kitchen<<endl;
}
void visit2(House &house) //引用传递
{
cout<<"visiting(引用传递)"<<house.bedroom<<endl;
cout<<"visiting(引用传递)"<<house.kitchen<<endl;
}
void visit3(House house) //值传递
{
cout<<"visiting(值传递)"<<house.bedroom<<endl;
cout<<"visiting(值传递)"<<house.kitchen<<endl;
}
void test()
{
House house;
visit1(House &house);
visit2(House house);
visit3(House house);
}
int main()
{
test();
}
//输出结果
visiting(地址传递)卧室
visiting(地址传递)厨房
visiting(引用传递)卧室
visiting(引用传递)厨房
visiting(值传递)卧室
visiting(值传递)厨房
继承
继承指使用已存在的类的定义作为基础建立新类的技术,新类的定义可以增加新的数据或新的功能,也可以使用父类的功能,但不能选择性地继承父类
- 派生类拥有基类非private的属性和方法(事实上基类的private成员也被继承,且占用派生类对象的内存,只是在派生类中不可见也不能使用)
- 派生类可以拥有自己属性和方法,可以对基类进行扩展
- 派生类可以用自己的方式实现基类的方法
派生类可以继承所有基类的方法,以下情况除外:
- 基类的构造函数、析构函数和拷贝析构函数
- 基类的重载运算符
- 基类的友元函数
三种继承方式
- public继承方式
- 基类中所有public成员在派生类中为public属性
- 基类中所有protected成员在派生类中为protected属性
- 基类中所有private成员在派生类中不能使用
- protected继承方式
- 基类中的所有public成员在派生类中为protected属性
- 基类中的所有protected成员在派生类中为protected属性
- 基类中的所有private成员在派生类中不能使用
- private继承方式
- 基类中的所有public成员在派生类中为private属性
- 基类中的所有protected成员在派生类中为private属性
- 基类中的所有private成员在派生类中不能使用
根据以上三种继承方式可得:基类成员在派生类中的访问权限不得高于继承方式中指定的权限
使用using关键字可以改变基类成员在派生类中的访问权限(只能改变基类中的public和protected成员的访问权限)
//基类People
class People {
public:
void show();
protected:
char *m_name;
int m_age;
};
//派生类Student
class Student : public People {
public:
void learning();
public:
using People::m_name; //将protected改为public
using People::m_age; //将protected改为public
float m_score;
private:
using People::show; //将public改为private
};
构造函数与析构函数
构造函数
构造函数用于初始化类对象的数据成员,即类的实例(对象)被创建时,编译系统对该对象分配空间,并自动调用构造函数,完成类成员的初始化
构造函数的特点
- 没有返回值,不写void
- 函数名称与类名相同
- 可以有参数,可以重现(一个类可以有多个构造函数)
- 无需手动调用,系统自动调用,且只调用一次
- 必须定义在public中才能使用
常用的构造函数
- 无参构造函数
- 如果类中没有声明构造函数,则编译器会隐式默认inline默认构造函数,默认构造函数通常没有参数,但可以具有带默认值的参数
- 如果在类中声明构造函数,则不会自动生成默认构造函数,显式声明的构造函数也可以没有参数
class Student {
public:
int m_age;
int m_score;
//无参构造函数
Student() {
m_age = 18;
m_score = 99;
}
};
- 如果依赖于系统隐式构造函数,需要确保在类定义中初始化成员,否则可能会出现调用生成垃圾值等情况
- 可以通过将隐式构造函数定义为已删除来阻止编译器生成。如果有任何类成员不是默认可构造,则编译器生成的默认构造函数会定义为已删除
- 复制构造函数
- 复制构造函数通过从相同类型的对象复制成员值来初始化对象
- 如果不声明复制构造函数,编译器将生成成员的复制构造函数;如果不声明复制赋值运算符,编译器将生成成员的复制赋值运算符
class Student {
public:
int m_age;
int m_score;
//复制构造函数
Student(Student& s) {
m_age = s.m_age;
m_score = s.m_score;
}
};
当类中有指针成员时,由系统默认创建的复制构造函数会存在“浅拷贝”的风险,因此必须显式声明复制构造函数
- 移动构造函数
- 移动构造函数可以实现指针的移动,将一个对象中的指针成员转移给另一个成员。指针成员转移后,原对象的指针一般要被设置为NULL防止再被使用
- 移动构造函数是移动语义的具体实现
移动语义,指以移动而非深拷贝的方式初始化含有指针成员的类对象
class A {
public:
int x;
//构造函数
A(int x) : x(x)
{
cout << "Constructor" << endl;
}
//拷贝构造函数
A(A& a) : x(a.x)
{
cout << "Copy Constructor" << endl;
}
//移动构造函数
A(A&& a) : x(a.x)
{
cout << "Move Constructor" << endl;
}
};
析构函数
析构函数是一个成员函数,在对象超出范围或通过调用delete显式销毁对象时会自动调用析构函数。如果未定义析构函数,编译器将提供默认的析构函数,通常情况下,只有当类存储了需要释放的系统资源的句柄,或拥有其指向的内存的指针时,才需要自定义析构函数
析构函数的特点
- 没有返回值,不写void
- 函数名称为“~”+类名
- 不可以有参数,不能重载
- 程序在对象销毁前自动调用析构函数
- 必须定义在public中才能使用
- 无法声明为const、volatile或static,但可以为声明为这些类的对象的析构调用它们
- 可以声明为virtual。使用虚拟析构函数,可以在不知道类对象类型的情况下销毁对象
多态
多态指用相同的接口去表示不同的实现
举个例子,假设有三个类:自行车(bicycle)、汽车(car)、卡车(truck),三个类中分别有三个实现:bicycle::ride()、car::run()、truck::launch(),三个实现的作用一样,都是让它们发动起来。如果没有多态的话,我们需要分别使用这三个实现
// 实现
Bicycle bicyle = new Bicycle();
Car car = new Car();
Truck truck = new Truck();
// 使用
bicyle.Ride();
car.Run();
truck.Launch();
如果其中某个类的接口修改了,比如car的接口修改了,则对应的使用代码也需要更改,非常影响工作效率。为了提高工作效率,我们可以这样设计:
- 自行车(bicycle)、汽车(car)、卡车(truck)都是交通工具(vehicle),因此可以创建一个交通工具的基类,自行车、汽车、卡车为其派生类
- 交通工具中声明启动函数run(),在三个派生类中重写该方法
class Vehicle { // 新增抽象类
virtual void Run() {}
};
class Bicycle: Vehicle {
virtual void Run() {......}
};
class Car: Vehicle{
virtual voie Run() {......}
};
class Truck: Vehicle {
virtual void Run() {......}
};
// 实现部分
List<Vehicle> vehicles = { new Bicycle(),
new Car(),
new Truck() };
// 使用部分
for (v : vechicles)
v.Run();
这样一来,实现部分的代码改动就不会影响到使用部分的代码
另举一例,比如我们在ATM机办理存款和取款都需要检查银行卡对应的账户是否存在,但在存款时不需要检查密码,而在取款时需要检查密码,对同样的检查步骤,我们可以将其统一成一个函数check_in()
class ATM
{
void check_in(userid){......} //存款检查账户
void check_in(userid,password){......} //取款检查账户
};
多态的实现
重载(编译时多态)
编译时多态又称静态多态,基于模板编程(C++11新特性)的具现化与函数的重载解析,这种多态在编译期进行,因此被称为编译时多态
重载,指函数名相同,函数的参数个数、参数类型或参数顺序至少有一个不相同。函数的返回值可以相同也可以不同。函数重载发生在一个类内部,不能跨作用域
class Animal
{
public:
void self_introduction(int tmp)
{
cout << "I'm an animal -" << tmp << endl;
}
void self_introduction(const char *s)//函数的重载
{
cout << "(overload)I'm an animal -" << s << endl;
}
};
重写
运行时多态,也称动态多态,基于虚函数机制实现多态的功能,在不同但是具有继承关系的类中有相同的函数名,这种实现方式也称为重写
重写,也称为覆盖,一般发生在派生类和基类继承关系之间,派生类重新定义基类中有相同名称和参数的虚函数
C++实现重写的方式与编译器有关。编译器在实例化一个具有虚函数的类时会生成一个vptr指针,指向虚函数表的表头,在虚函数表里按声明顺序存放了虚函数的函数指针。如果在派生类中重写了,则在派生类的内存空间
重写需要注意:
- 被重写的函数不能是static的,必须是virtual的
- 重写函数必须有相同的类型,名称和参数列表
- 重写函数的访问修饰符可以不同
class Animal
{
public:
void self_introduction(int tmp)
{
cout << "I'm an animal -" << tmp << endl;
}
};
class Fish :public Animal
{
public:
void self_introduction(int tmp) //函数的重写
{
cout << "(override)I'm an fish -" << tmp << endl;
}
};
重定义
重定义,也称为隐藏,子类重新定义父类中有相同名称的非虚函数(参数列表可以不同),指派生类的函数屏蔽了与其同名的基类函数,可以理解成在继承关系中发生了重载
如果一个派生类存在重定义的函数,则该类会隐藏其父类的方法,除非在调用的时候强制转换为父类,否则对子类和父类做类似重载的调用是不能成功的
重定义的实现原理与继承树中的寻找方式有关,它会从当前对象的类作用域中开始查找同名函数,若无则向上查找直到基类为止,并不对参数列表是否相同进行判断
重定义需要注意:
- 如果派生类的函数和基类的函数同名,但是参数不同,此时,不管有无virtual,基类的函数被隐藏
- 如果派生类的函数与基类的函数同名,并且参数也相同,但是基类函数没有vitual关键字,此时,基类的函数被隐藏(如果有Virtual则为重写)
class Animal
{
public:
void self_introduction(int tmp)
{
cout << "I'm an animal -" << tmp << endl;
}
};
class Fish :public Animal
{
public:
void self_introduction(char *s) //函数的重定义
{
cout << "(override)I'm an fish -" << s << endl;
}
};