Object-Oriented Programming
OOP,即面向对象,基本每场程序员的面试都会涉及到的问题,毫无疑问,一些久经面试沙场的程序员,对OOP分哪几个部分都已经能倒背如流,即封装继承和多态,同时本文还是 CppCon 2021 Back To Basics笔记的第五篇
封装
即Class(从c版本的struct 迭代而来)用来封装内部成员变量和成员方法,又名信息隐藏
继承(inheritance)
从被继承的类中获取他全部的成员变量和成员方法,使用被继承类的成员和方法并且添加新的方法。
但有一点需要注意,不能为了代码复用而使用继承,而是在扩展逻辑结构的时候使用继承
polymorphism(多态)
多态,是一种对象在运行是多样性的特征,继承是多态的基础,多态允许接口和实现进行分离,但涉及到一个很小的开销(虚函数表)
virtuality(虚函数)
C++实现多态的最主要的方法就是虚函数:
class Account {
public:
virtual void deposit(double) {};
};
class BackAccount :public Account {
public:
void deposit(double) override {};
};
int main() {
BackAccount bankAccount;
Account* aPtr = &bankAccount;
aPtr->deposit(50.5);
Account& aRef = bankAccount;
aRef.deposit(50.5);
return 0;
}
实例化一个子类对象,通过声明基类指针,指向子类对象的地址,可以实现在运行时多态,即通过基类对象调用子类的方法
也可以声明基类对象的引用,用. 的形式访问子类方法
虚函数的具体实现方式:
虚函数在底层中最主要的实现形式是通过维护一张虚函数表:
可以通过Developer Command Prompt for VS2019来查看虚函数表的相关信息,
命令行中输入下面的形式来输出虚函数表
cl /d1 reportSingleClassLayout类名 "文件名"
可以看到在类中多了一行vfptr 同时类的大小也发生了变化,即需要维护一个虚表指针(vfptr)来指向虚函数表的地址,
基类的虚表中存放了基类的deposit方法,子类的虚函数表中放了自己重载的deposit方法,在运行时,会根据当前对象的虚表指针去寻找需要运行的虚函数,而这种需要一个新的指针如何影响类对象的大小呢?这也是面试时非常容易考察的问题:
类大小相关知识
在理解类的大小之前,先来看下内存对齐相关的知识:
内存对齐
为什么要进行内存对齐:
因为不同的硬件平台对储存空间的处理上存在很大的不同,某些特定的平台对特定类型的数据只能从特定位置开始存取,不允许随机存放,因此需要内存对齐。
对齐准则:
1、数据类型自身的对齐值,char为1字节,short 2字节,int,float 4字节,long在32为中为4字节 64位中位8字节,double为8字节。
2、结构体或类自身的对齐值:类中非静态成员的最大对齐值
3、在C语言中还可以通过#pragma pack (value)来指定内存对齐值为value
首先按照自身数据类型对齐指:自己类型占用多少字节,那么就需要以多少的整数倍来作为起始地址,如int在32为中为4字节,那么他的起始地址必须是4的倍数,因此如果int前面有空缺的位置则需要补齐
其次按照结构体或类自身的对齐值来补齐尾部,因此,结构体的大小必定是自身最大成员的整数倍。
具体请看下面的例子:
struct temp
{
char a; // 1byte
double b; // 16byte
char d; // 24byte
};
因为double大小为8,因此b自身的起始地址必须是8的倍数,所以double之前需要补齐7字节的占位。
而类自身的对齐值为所有类型的最大值8,所以需要在d之后再补上7字节,总大小为24字节。
类对象的大小
在了解了内存对齐原则后,我们可以来看一下类对象大小都和什么有关:
1、首先空类是1字节,为什么是1自己而不是像空struct一样为0字节:为了让对象的实例能够互相区别,因为空类也可以被实例化,并且每个实例在内存中的地址不同,因此编译器会给空类添加一个隐含的一字节,但如果继承了空类,那么该类的大小就会被优化为0
2、成员函数不占类的大小(因为成员函数的地址,在编译器就已经确定,并且通过静态或动态的方式绑定到对应的对象上,对象在调用成员函数时,编译器可以告诉他函数的地址,并通过this指针和其他参数来进行函数的调用,所以类中没有必要储存成员函数的信息)
3、静态成员也不占类的大小,因为静态成员存储在静态区;
4、类对象的大小同样遵循内存对齐原则,与结构体的内存对齐原则相同;
5、如果类中有虚函数,那么会在类的对象中插入一个指针,这个指针称为虚表指针vfptr,这个指针会指向虚函数表,用于存储虚函数的入口地址和运行时信息。也就是通过虚函数表的机制,才实现了类的多态,而是指针就要占用空间,所以类对象的大小需要加上当前环境下指针大小(4或8字节)
如果是多继承(类C继承了A和B,且A和B都有虚函数)则需要复制两个虚函数表,因此有了两个vfptr所以继承了几个带虚函数的类,就需要添加多少指针倍数的内存。
7、如果有虚继承:
派生类对象中会添加一个指针vbptr指向虚继承的基类。
虚继承的指针情况
假设派生类D同时继承了B和C,且两个父类同时继承子类A,则根据派生类D对BC继承方式不同,vbptr也不同
1、若BC都虚继承自A,D普通继承自B和C,则有两个vbptr分别从BC两个类中继承过来,且指向虚继承A因此多8字节
using namespace std;
class Aclass {
public:
Aclass(int x = 0) {
cout << "A" << x << endl;
}
};
class Bclass : virtual public Aclass {
public:
Bclass(int x = 0) {
cout << "B" << x << endl;
}
};
class Cclass : virtual public Aclass {
public:
Cclass() {
cout << "C" << endl;
}
};
class Dclass : public Bclass, public Cclass {
public:
Dclass() {
cout << "D" << endl;
}
};
查看类中信息也可以看到如下结果:D的大小为8
2、若C虚继承,B普通继承,D虚继承B和C,则B因为已经继承过A有了一个vbptr,因此在D继承C时,C虚继承的A类就不会再次继承,因此不会有第二个vbptr所以一共有两个vbptr,因此多8字节
#include <iostream>
using namespace std;
class Aclass {
public:
Aclass(int x = 0) {
cout << "A" << x << endl;
}
};
class Bclass : virtual public Aclass {
public:
Bclass(int x = 0) {
cout << "B" << x << endl;
}
};
class Cclass : virtual public Aclass {
public:
Cclass() {
cout << "C" << endl;
}
};
class Dclass : virtual public Bclass, public Cclass {
public:
Dclass() {
cout << "D" << endl;
}
};
查看类内部信息如下两个vbptr各自占了4字节:
3、若BC均虚继承自A,D也虚继承自BC,则指向A类的vbptr仍然只有一个,而另外有两个vbptr指向B和C,所以一共有3个vbptr
代码如下:
#include <iostream>
using namespace std;
class Aclass {
public:
Aclass(int x = 0) {
cout << "A" << x << endl;
}
};
class Bclass : virtual public Aclass {
public:
Bclass(int x = 0) {
cout << "B" << x << endl;
}
};
class Cclass : virtual public Aclass {
public:
Cclass() {
cout << "C" << endl;
}
};
class Dclass : virtual public Bclass,virtual public Cclass {
public:
Dclass() {
cout << "D" << endl;
}
};
可以看到当前情况下D类大小为12字节:
4、如果情况再复杂一些,添加上虚函数则:
#include <iostream>
using namespace std;
class Aclass {
public:
Aclass(int x = 0) {
cout << "A" << x << endl;
}
virtual void printA() {
cout << "Hello A" << endl;
}
};
class Bclass :virtual public Aclass {
public:
Bclass(int x = 0) {
cout << "B" << x << endl;
}
virtual void printB() {
cout << "Hello B" << endl;
}
};
class Cclass :virtual public Aclass {
public:
Cclass() {
cout << "C" << endl;
}
virtual void printC() {
cout << "Hello C" << endl;
}
};
class Dclass : public Cclass, public Bclass {
public:
Dclass() {
cout << "D" << endl;
}
virtual void printD() {
cout << "Hello D" << endl;
}
};
class Eclass :virtual public Cclass, public Bclass {
public:
Eclass() {
cout << "E" << endl;
}
virtual void printE() {
cout << "Hello E" << endl;
}
};
class Fclass :virtual public Cclass, virtual public Bclass {
public:
Fclass() {
cout << "F" << endl;
}
virtual void printF() {
cout << "Hello F" << endl;
}
};
上面代码中,BC均复制了A类的vfptr,但因为都是虚继承自类A且自己新添加的虚函数与类A中并不同名,所以需要产生新的vfptr和虚表,再加上各自的vbptr,所以BC大小均为12.
类D和E都有普通继承,因此不需要产生新的vfptr,但D直接继承BC,E虚继承C直接继承B,但D中包含两个指向A类的vbptr三个vfptr(指向ABC的三个虚表)因此大小为20,E中包含两个vbptr指向A和C,三个vfptr指向ABC的三个虚表(且E自身的虚函数放在了B的vfptr指向的虚表中)
类F虚继承子BC,因此包括了三个vbptr指向ABC,还有四个vfptr分别为ABC的虚表,和为了自身的新虚函数创建的新虚表,共7个指针,所以大小为28.
类的对象及成员变量在内存中的存储位置
类的成员变量并不能决定自身的存储位置,而是由类对象创建方式决定:
- 对象是函数内的非静态局部变量,则对象和对象的成员变量保存在栈中
- 如果对象是全局变量,则二者保存在静态区
- 如果是函数内的静态全局变量,则也保存在静态区
- 如果对象是new出来的,则保存在堆中
override and final关键字
override 声明的函数表示该函数重写基类的虚函数
final 声明的函数表示这个函数不能被重写,使用final可以为编译器提供优化的机会
析构destructors
析构函数一般有两种定义方式
可以被定义为 public virtual ,这种定义方式下,基类的指针和引用销毁子类的对象
也可以定义为protect non-virtual,这种定义方式则不能销毁子类对象
interfaces接口
Liskov Substitution Principle(里氏替换原则)
派生类的对象可以在程序中替代其基类对象,比如人类继承自动物类,那么人类对象必须是动物
因为提到了里氏替换原则,因此在这里再总结下设计模式的六大原则:
- 单一职责原则:一个类不应该有过多的职责,即一个类应该有且仅有一个引起他变化的原因否则需要被拆分
- 开闭原则:对扩展开放,对修改关闭,即不建议修改原有代码,而是在原有代码上进行添加
- 里氏替换原则:派生类可以扩展基类的功能,但不能改变基类原有的功能,派生类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法
- 依赖倒置原则:每个类尽量提供接口或抽象类,变量声明类型尽量是接口或者抽象类,任何类都不应该从具体类派生,
- 接口分离原则:客户端不应该被迫依赖于他不使用的方法,尽量将接口拆分成更小的和更具体的接口
- 迪米特法则:如果两个类无需直接通信,那么就不应该发生直接的相互调用,而应该使用第三方转发该调用。
接口继承(interface inheritance)和实现继承(implementation inheritance)
接口继承使用public 继承 ,基类是接口,将用户从实现中分离出来,从而允许添加和更改派生类,而不影响基类的用户。
实现继承一般使用private 继承,通常,派生类通过调整基类的功能来提供其功能。
能将这两个继承形式最好的配合起来的设计模式就是适配器模式:
#include <iostream>
typedef int Coordinate;
typedef int Dimension;
using namespace std;
class Rectangle {
public:
virtual void draw() = 0;
};
class LegacyRectangle {
public:
LegacyRectangle(Coordinate x1, Coordinate y1, Coordinate x2, Coordinate y2) : x1_(x1), y1_(y1), x2_(x2), y2_(y2) {};
void oldDraw() {
std::cout << "LegacyRectangle:oldDraw.\n";
}
private:
Coordinate x1_;
Coordinate y1_;
Coordinate x2_;
Coordinate y2_;
};
class RectangleAdapter : public Rectangle, private LegacyRectangle {
// 一个适配器,与rectangle 类关系为接口继承,与LegacyRectangle关系为实现继承
public:
RectangleAdapter(Coordinate x, Coordinate y, Dimension w, Dimension h) :
LegacyRectangle(x, y, x + w, y + h) {
}
void draw() override {
std::cout << "RectangleAdapter:draw." << '\n';
oldDraw();
}
};
int main() {
cout << endl;
Rectangle* r = new RectangleAdapter(120, 200, 60, 40);
r->draw();
cout << endl;
}
适配器模式可以担任两个对象间的封装器,接受一个对象的调用,并将其转换为另一个对象可识别的格式和接口,在C++中基于一些遗留的代码会常常使用这种设计模式,让遗留代码可以与现代类进行合作。
因为C++可以支持多继承,所以使用适配器同时继承两个对象的接口
类图如下:
Covariant Return Type(协变返回类型)
协变返回类型即:不允许在构造和析构函数中调用虚方法
如果调用了纯虚函数,则是一种未定义行为
如果调用了普通虚函数,则会出现下面的情况,如下:
#include <iostream>
class Base {
public:
Base() {
f();
}
virtual void f() {
std :: cout << "Base called" << '\n';
}
};
class Derived :Base {
void f()override {
std::cout << "Derived called" << '\n';
}
};
int main() {
std::cout << '\n';
Derived d;
std::cout << '\n';
}
上面的代码中,基类的构造函数中调用了虚函数f,在派生类Derived中重写了f方法,并且在主函数中实例化了一个derived类型的对象,理论上声明子类对象调用的应该是子类的f方法,但实际上运行中却调用的是基类的f方法
因为在C++中,构造是从基类向子类进行构造,而析构则是从子类向基类析构,因此在父类构造的阶段,子类重写的f方法还没有实现,所以只能唤起父类的虚方法,因此错误
如果把上面的代码base中的f方法改为纯虚函数,则会编译报错
Slicing对象切割
当一个子类对象被拷贝给基类,则那个子类会变为基类
要对多态类进行深度复制,最好使用虚成员函数克隆,而不是复制构造函数或复制赋值操作符。
class Base {};
class Derived : Base {};
int main() {
Derived d;
Base b(d);
Base b2;
b2 = d;// 发生对象切割
}
推荐的写法 :额外实现一个方法来进行子类拷贝
#include <iostream>
using namespace std;
class Base {
public:
virtual Base* clone()const {
std::cout << "Base:clone" << '\n';
return new Base(*this);
}
};
class Derived : public Base {
Derived* clone()const override {
std::cout << "Derived:clone" << '\n';
return new Derived(*this);
}
};
Base* cloneMe(const Base* base) {
return base->clone();
}
int main() {
std::cout << '\n';
Base* base = new Base;
auto* baseClone = cloneMe(base);
Derived* derived = new Derived;
auto* derivedclone = cloneMe(derived);
std::cout << '\n';
}
shadowing (阴影)
阴影指子类的同名方法,会覆盖掉父类的方法,让父类的方法隐藏在子类方法的阴影下:
#include <iostream>
using namespace std;
struct Base {
void func(double d) {
std::cout << "f(double)\n";
}
};
struct Derived :public Base {
void func(int i) {
std::cout << "f(int)\n";
}
};
int main() {
Derived der;
der.func(2020.5);//这里想调用f.double ()
}
直接编译运行会发现,调用的是子类的func(int类型的),并且发生了强制类型转换,并没有向我们预想中的一样调用父类的double类型的func
如果想改正,则可以在子类中添加一行:
struct Derived :public Base {
void func(int i) {
std::cout << "f(int)\n";
}
using Base::func;
};
总结:
以上是按照CppCon 2021 Back To Basics 第五讲的内容进行的整理,其中可能对C++ 的OOP不能做完全的描述,但也包括了很大一部分的内容,因此也需要后续进行补充和优化,希望能将这篇博客继续完善