17. 面向对象的特征
一、面向对象的三大特征
面向对象的三大特征指的是 封装、继承、多态。
封装(encapsulation,有时称为数据隐藏)是处理对象的一个重要概念。从形式上看,封装就是将数据和行为组合在一个包中,并对对象的使用者隐藏具体的实现方式。
继承(inheritance)的基本思想是,可以基于已有的类创建新类。继承以存在的类就是复用(继承)这些类的方法,而且可以增加一些新的属性和方法,使新类能够适应新的情况。
多态(polynirphic)就是一个类的多种形态。
二、封装性
所谓的封装,就是把客观事物封装成抽象概念的类,并且类可以把自己的数据和方法只向可信的类或对象开放,向没必要开放的类或对象异常信息。封装性就是隐藏内部对象的复杂性,只对外公开简单的接口。便于外部调用,从而提高系统的可扩展性、可维护性。通俗的来说,把该隐藏的隐藏起来,该暴露的暴露出来。
当我们创建一个类时,我们可以通过“对象.属性”的方式,对对象的属性进行赋值。此时,赋值操作要受到属性的数据类型和存储范围的制约。除此之外,没有其它制约条件。但是,在实际问题中,我们往往需要给属性赋值加入额外的限制条件。这个条件就不能在属性声明时体现,只能通过方法进行限制条件的添加。同时,我们需要避免用户在使用“对象.属性”的方式对属性进行赋值,则需要将属性声明为私有的(private)。此时,针对于属性就体现了封装性。
使用封装后,确实增加了类的定义的复杂程度,但是它也确保了数据的安全性。我们隐藏了属性名,使调用者无法任意修改对象中的属性。并且我们增加了 getter() 和 setter() 方法,可以很好的控制属性是否为只读的。如果希望属性是只读的,则可以直接去掉 setter() 方法,如果希望属性不能被外部访问,则可以直接去掉 getter() 方法。而且在使用 setter() 方法设置属性时,可以增加数据的验证,确保数据的值是正确的。在使用 getter() 方法获取属性,使用 setter() 方法设置属性时,可以在读取属性和修改属性的同时做一些其它的处理。
我们将类的属性(xxx)隐藏,同时,提供的公共的方法类获取(getter())和设置(setter(self))此属性的值。在 C++ 中,我们可以使用不同的访问权限来实现封装的效果。
访问权限 | 含义 | 作用 |
---|---|---|
public | 公共权限 | 类内和类外都可以访问成员 |
protected | 保护权限 | 类内可以访问成员,类外不可以访问成员,但子类可以访问父类中的保护内容 |
private | 私有权限 | 类内可以访问成员,类外不可以访问成员,并且子类不可以访问父类中的私有内容 |
#include <iostream>
using namespace std;
class Person
{
// 属性
string name;
int age;
public: // 公共权限
// 行为
string getName()
{
return name;
}
void setName(string n)
{
name = n;
}
int getAge()
{
return age;
}
void setAge(int a)
{
if (a < 0)
{
cout << "年龄不合法" << endl;
return;
}
age = a;
}
void showInfo()
{
cout << "{name: " << name << ", age: " << age << "}\n";
}
};
如果我们直接访问类中的私有属性,编译器会报错误。此时,我们可以通过公共的 getter() 方法和 setter() 方法调用类的私有属性:
int main(void)
{
// 通过类创建一个具体的对象
Person p1;
// 给对象的属性进行赋值
p1.setName("Sakura");
p1.setAge(10);
cout << "{name: " << p1.getName() << ", age: " << p1.getAge() << "}\n";
p1.showInfo();
}
类中,成员的默认权限是私有权限。
三、继承性
3.1、什么是继承性
继承,其基本思想就是基于某个父类的扩展,制定出一个新的子类,子类可以继承父类原有的属性和方法,也可以增加原来父类所不具备的属性和方法,或者直接重写父类中的某些方法。通过继承可以直接让子类获取父类的方法和属性,避免编写重复性的代码,并且也符合 OCP 原则。所以,我们经常需要通过继承来对一个类进行扩展。继承的出现让类与类之间产生了 is-a
的关系。
在 C++ 中,继承性的格式如下:
class 子类名 : 权限修饰符 父类名
{
// 类的成员
}
一旦子类继承父类之后,子类就获取父类中声明的所有的结构:成员变量和成员函数;子类继承父类以后,还可以声明自己特有的成员变量和成员函数,实现功能的拓展;我们可以在子类中使用 using 类名::构造函数名;
来使用父类中的构造函数简化代码。
新建一个 animal.h 头文件用来保存父类的声明。
#pragma once
#include <iostream>
using namespace std;
class Animal
{
private:
string name;
public:
Animal(void);
Animal(string name);
void run(void);
void sleep(void);
string getName(void);
void setName(string name);
};
新建一个 animal.cpp 文件用来实现 animal.h 头文件中声明的方法。
#include "animal.h"
Animal::Animal(void) : name("") {}
Animal::Animal(string name) : name(name) {}
void Animal::run(void)
{
cout << "动物在跑" << endl;
}
void Animal::sleep(void)
{
cout << "动物在睡觉" << endl;
}
string Animal::getName(void)
{
return name;
}
void Animal::setName(string name)
{
this->name = name;
}
新建一个 dog.h 头文件用来保存子类的声明。
#pragma once
#include <iostream>
#include "animal.cpp"
using namespace std;
class Dog : public Animal
{
public:
// 使用父类的构造函数
using Animal::Animal;
void bark(void);
};
新建一个 dog.cpp 文件用来实现 dog.h 头文件中声明的方法。
#include "dog.h"
void Dog::bark(void)
{
cout << getName() << "在叫:汪汪汪" << endl;
}
在包含 main() 函数的文件中包含刚才定义的头文件,然后使用。
#include <iostream>
#include "dog.cpp"
using namespace std;
int main(void)
{
Dog dog("旺财");
dog.run();
dog.sleep();
dog.bark();
return 0;
}
3.2、继承方式
在 C++ 中,继承主要分为三种:
#include <iostream>
using namespace std;
// 父类A
class A
{
public:
int a;
protected:
int b;
private:
int c;
};
// 类B公共继承类A
class B: public A
{
public:
void func(void)
{
// 父类中公共权限成员到子类中依然是公共权限
a = 10;
// 父类中保护权限成员到子类中依然是保护权限
b = 10;
// 父类中的私有权限成员子类访问不到
// c = 10;
}
};
// 类C保护继承类A
class C: protected A
{
public:
void func(void)
{
// 父类中公共权限成员到子类中变成保护权限
a = 20;
// 父类中保护权限成员到子类中依然是保护权限
b = 20;
// 父类中的私有权限成员子类访问不到
// c = 20;
}
};
// 类D私有继承类A
class D: private A
{
public:
void func(void)
{
// 父类中公共权限成员到子类中变成私有权限
a = 30;
// 父类中保护权限成员到子类中变为私有权限
b = 30;
// 父类中的私有权限成员子类访问不到
// c = 30;
}
};
// 类E公共继承类C,而类C又保护继承类A
class E: public C
{
public:
void func(void)
{
// 子类中可以访问父类中保护权限的内容
a = 40;
b = 40;
// 父类中的私有权限成员子类访问不到
// c = 40;
}
};
// 类F公共继承类D,而类D又私有继承类A
class F: public D
{
public:
void func(void)
{
// 子类中无法访问父类中私有的内容
// a = 20;
// b = 20;
// c = 20;
}
};
int main(void)
{
B b;
b.a = 100;
// 到B类中b是保护权限,类外无法访问
// b.b = 100;
C c;
// 到C类中a是保护权限,类外无法访问
// c.a = 200;
D d;
// 到D类中a是私有权限,类外无法访问
// d.a = 300;
cout <<"size of A: " << sizeof(A) << endl;
cout <<"size of B: " << sizeof(B) << endl;
cout <<"size of C: " << sizeof(C) << endl;
cout <<"size of D: " << sizeof(D) << endl;
return 0;
}
父类中所有非静态成员属性都会被子类继承下去,编译器私有成员的属性被编译器隐藏了,因此是访问不到的,但是确实被继承下去了。
3.3、子类创建的流程
当创建子类对象时,先调用父类中的构造函数,再调用子类中的构造函数。销毁子类对象时,先调用子类中的析构函数,再调用父类中的析构函数。
#include <iostream>
using namespace std;
// 父类A
class SuperClass
{
public:
SuperClass(void)
{
cout << "SuperClass()" << endl;
}
~SuperClass(void)
{
cout << "~SuperClass()" << endl;
}
};
class SubClass : public SuperClass
{
public:
SubClass(void)
{
cout << "SubClass()" << endl;
}
~SubClass(void)
{
cout << "~SubClass()" << endl;
}
};
int main(void)
{
SubClass subClass;
return 0;
}
3.4、同名成员的处理
如果子类和父类有同名的变量。子类对象调用的是子类中的同名成员。如果需要子类对象访问父类中同成员,需要加作用域。如果子类中出现和父类中同名的成员函数,子类的同名函数会隐藏父类中所有同名的成员函数。如果想访问父类中被隐藏的同名成员函数,需要加作用域。
#include <iostream>
using namespace std;
// 父类A
class SuperClass
{
public:
int num;
SuperClass(void) : num(100) {}
void func(void)
{
cout << "SuperClass类中的num属性:" << num << endl;
}
void func(int num)
{
this->num = num;
cout << "SuperClass类中的num属性:" << this->num << endl;
}
};
class SubClass : public SuperClass
{
public:
int num;
SubClass(void) : num(200) {}
void func(void)
{
cout << "SubClass类中的num属性:" << num << endl;
}
};
int main(void)
{
SubClass subClass;
// 子类对象调用的是子类中的同名成员变量
cout << "SubClass中的num属性:" << subClass.num << endl;
// 如果需要子类对象访问父类中同名成员变量,需要加作用域
cout << "SubClass中访问父类中的num属性:" << subClass.SuperClass::num << endl;
// 字节调用的是子类中的同名成员函数
subClass.func();
// 如果需要子类对象访问父类中同名成员,需要加作用域
subClass.SuperClass::func();
// 如果子类中出现和父类中同名的成员函数,子类的同名成员会隐藏父类中所有同名的成员函数
// 如果想访问父类中被隐藏的同名成员函数,需要加作用域
// subClass.func(300);
subClass.SuperClass::func(300);
return 0;
}
3.5、多重继承
在 C++ 中是支持多重继承的,也就是我们可以为一个类同时指定多个父类。我们可以在类名的 : 后面添加多个类,来实现多重继承。多重继承会使子类同时拥有多个父类,并且或获取所有父类中的方法。如果多个父类中有同名的成员,需要使用作用域加以区分。
#include <iostream>
using namespace std;
// 父类A
class SuperClass1
{
public:
int num;
SuperClass1(void) : num(100) {}
void func(void)
{
cout << "SuperClass1类中的num属性:" << num << endl;
}
};
class SuperClass2
{
public:
int num;
SuperClass2(void) : num(200) {}
void func(void)
{
cout << "SuperClass2类中的num属性:" << num << endl;
}
};
class SubClass : public SuperClass1, public SuperClass2
{
};
int main(void)
{
SubClass subClass;
cout << "size of SubClass: " << sizeof(SubClass) << endl;
cout << "size of SuperClass1: " << sizeof(SuperClass1) << endl;
cout << "size of SuperClass2: " << sizeof(SuperClass2) << endl;
// 当父类中出现同名成员,需要加作用域区分
// cout << "subClass.num: " << subClass.num << endl;
cout << "subClass.SuperClass1::num: " << subClass.SuperClass1::num << endl;
cout << "subClass.SuperClass2::num: " << subClass.SuperClass2::num << endl;
return 0;
}
3.6、菱形问题
在 C++ 中子类可以同时继承多个父类的属性,这样可以最大限度地重用代码,但是使用多继承会导致扩展性变差,并且有可能会导致棱形问题(钻石问题)。棱形问题(钻石问题)指的是一个子类继承的多个父类汇集到一个父类的身上。
当发生菱形继承并且多个父类拥有相同的数据,需要加以作用域区分。这份数据,我们只需要一份即可,菱形继承导致数据有两份,造成资源浪费。利用虚继承可以解决菱形问题。
#include <iostream>
using namespace std;
class A
{
public:
int num;
};
// 利用虚继承可以解决菱形问题
// 在继承之前加上关键字virtual变为虚继承
// 变成虚继承后,A类为虚基类
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};
int main(void)
{
D d;
// 当菱形继承时,两个父类拥有相同的数据,需要加以作用域区分
// 菱形继承导致资源有两份,资源浪费
d.B::num = 10;
d.C::num = 20;
cout << "d.B::num: " << d.B::num << endl;
cout << "d.C::num: " << d.C::num << endl;
cout << "d.num: " << d.num << endl;
return 0;
}
3.7、final关键字的使用
C++ 中新增加了 final
关键字来 限定某个类不能被继承,或者 某个虚函数不能被重写。final 关键字 只能用来修饰类或者虚函数。
如果使用 final 修饰 虚函数,表示 该虚函数不能被重写。这时,我们只需要把 final 关键字放到函数后面即可。
class Base
{
public:
virtual void test(void);
};
void Base::test(void)
{
cout << "Base::test()" << endl;
}
class Child : public Base
{
public:
// 使用final修饰父类中的虚函数,表示该类的子类该不能重写该函数
void test(void) final;
};
void Child::test(void)
{
cout << "Child::test()" << endl;
}
如果使用 final 修饰 类,表示 该类不能被继承。这时,我们只需要把 final 关键字放到类名后即可。
class Base
{
public:
virtual void test(void);
};
void Base::test(void)
{
cout << "Base::test()" << endl;
}
class Child final: public Base
{
public:
// 使用final修饰父类中的虚函数,表示该类的子类该不能重写该函数
void test(void) override final;
};
void Child::test(void)
{
cout << "Child::test()" << endl;
}
3.8、override关键字的使用
override 关键字确保在派生类中声明的函数与基类函数有相同的签名,同时也明确表明将会重写基类的虚函数,这样就可以保证重写的虚函数的正确性,也提高了代码的可读性。和 final 关键字一样,override 关键字要写在方法的后面。
class Base
{
public:
virtual void test(void);
};
void Base::test(void)
{
cout << "Base::test()" << endl;
}
class Child final: public Base
{
public:
void test(void) override;
};
void Child::test(void)
{
cout << "Child::test()" << endl;
}
四、多态性
4.1、什么是多态性
多态性指的是一个事物的多种形态;同样的行为(函数),传入不同的对象,得到不同的状态;多态性指的是可以在不考虑对象具体类型的情况下而直接使用对象。多态分为两类:静态多态 和 动态多态。
- 静态多态:函数重载和运算符重载属于静态多态,复用函数名。
- 动态多态:派生类和虚函数实现运行时多态。
静态多态和动态多态的区别如下:
- 静态多态的函数地址早绑定 —— 编译阶段确定函数地址。
- 动态多态的函数地址晚绑定 —— 运行阶段确定函数地址。
动态多态的需要满足如下条件:
- 有继承关系。
- 子类重写父类中的虚函数。
如果我们想要使用动态多态,需要 父类的指针或引用指向子类对象。基类指针可以在不进行显示类型转换的情况下指向派生类对象。基类指针只能调用基类方法。这是因为,派生类继承了基类的方法,所以这样做没有问题。
#include <iostream>
using namespace std;
class Animal
{
public:
// 虚函数,可以实现地址晚绑定
virtual void speak(void)
{
cout << "动物在叫!" << endl;
}
};
class Dog : public Animal
{
public:
void speak(void)
{
cout << "汪汪汪!" << endl;
}
};
class Cat : public Animal
{
void speak(void)
{
cout << "喵喵喵!" << endl;
}
};
// 动态多态的使用:父类的指针或引用指向子类对象
void make_noise(Animal & animal)
{
animal.speak();
}
int main(void)
{
Animal animal;
Dog dog;
Cat cat;
make_noise(animal);
make_noise(dog);
make_noise(cat);
}
4.2、虚函数的工作原理
C++ 规定了虚函数的定义,但将实现方法留给了编译器开发者。通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保留了一个指向函数地址数据的指针。这种数组称为 虚函数表(virtual function table, vtbl)。虚函数表中存储了为类对象进行声明的虚函数的地址。
例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表中将保存新函数的地址。如果派生类没有重新定义虚函数,该虚函数表将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到虚函数表中。
无论类中包含的虚函数是 1 个还是 10 个,都需要在对象中添加 1 个地址成员,指向虚函数表,只是指向的虚函数表的大小不同。
在调用虚函数时,程序将查看存储在对象中的虚函数表,然后转向相应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使用数据中的第一个函数地址,并执行具有该地址的函数。如果使用类声明中第三个虚函数,程序将使用地址为数据中第三个元素的函数。
总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:
- 每个对象都将增加,增大量为存储地址空间。
- 对于每个类,编译器都创建一个虚函数地址表(数组)。
- 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址。
4.3、纯虚函数与抽象类
在多态中,通常父类中虚函数的实现是无意义的,主要都是调用子类重写的内容。因此,可以将虚函数改为 纯虚函数。纯虚函数的语法如下:
virtual 返回值类型 函数名(参数列表) = 0;
当类中有了虚函数后,这个类也称为 抽象类。抽象类无法实例化对象,并且子类必须重写抽象类中的纯虚函数,否则子类也属于抽象类。
#include <iostream>
using namespace std;
// 只要有一个纯虚函数,这个类就称为抽象类
class Animal
{
public:
// 纯虚函数
virtual void speak(void) = 0;
};
// 子类继承抽象类后,必须重写纯虚函数,否则这个子类也是抽象类
class Dog : public Animal
{
public:
void speak(void)
{
cout << "汪汪汪!" << endl;
}
};
class Cat : public Animal
{
void speak(void)
{
cout << "喵喵喵!" << endl;
}
};
// 动态多态的使用:父类的指针或引用指向子类对象
void make_noise(Animal & animal)
{
animal.speak();
}
int main(void)
{
// 抽象类无法实例化对象
// Animal animal;
Dog dog;
Cat cat;
// make_noise(animal);
make_noise(dog);
make_noise(cat);
}
4.4、虚析构和纯虚析构
多态使用时,如果子类由属性开辟到堆区,那么父类指针在释放时无法调用子类的析构代码,此时,需要将父类中的析构函数改为虚析构或者纯虚析构。虚析构和纯虚析构都可以解决父类指针释放子类对象,都需要具体的函数实现。如果是纯虚析构,该类属于抽象类,无法实例化对象。
虚析构:
virtual ~类名(){}
纯虚析构:
virtual ~类名() = 0;
#include <iostream>
using namespace std;
// 只要有一个纯虚函数,这个类就称为抽象类
class Animal
{
public:
Animal(void)
{
cout << "Animal的构造函数调用" << endl;
}
// 父类的指针在析构的时候,不会调用子类中的析构函数,导致子类如果有堆区属性,出现内存泄露
// 利用虚析构可以解决父类指着释放子类对象时不干净的问题
virtual ~Animal(void)
{
cout << "Animal的析构函数调用" << endl;
}
// 纯虚函数
virtual void speak(void) = 0;
};
// 子类继承抽象类后,必须重写纯虚函数,否则这个子类也是抽象类
class Cat : public Animal
{
public:
string * name;
Cat(void) {}
Cat(string name)
{
cout << "Cat的构造函数调用" << endl;
this->name = new string(name);
}
~Cat(void)
{
if (name != nullptr)
{
cout << "Cat的析构函数调用" << endl;
delete name;
name = nullptr;
}
}
void speak(void)
{
cout << *name << ": 喵喵喵!" << endl;
}
};
int main(void)
{
Animal *animal = new Cat("Tom");
animal->speak();
delete animal;
}
如果使用纯虚析构,既要声明,也要具体实现,这点与纯虚函数不同。有了纯析构函数之后,这个类也属于抽象类,无法实例化对象。
#include <iostream>
using namespace std;
// 只要有一个纯虚函数,这个类就称为抽象类
class Animal
{
public:
Animal(void)
{
cout << "Animal的构造函数调用" << endl;
}
// 纯虚析构
virtual ~Animal(void) = 0;
// 纯虚函数
virtual void speak(void) = 0;
};
Animal::~Animal(void)
{
cout << "Animal的纯虚析构函数调用" << endl;
}
// 子类继承抽象类后,必须重写纯虚函数,否则这个子类也是抽象类
class Cat : public Animal
{
public:
string * name;
Cat(void) {}
Cat(string name)
{
cout << "Cat的构造函数调用" << endl;
this->name = new string(name);
}
~Cat(void)
{
if (name != nullptr)
{
cout << "Cat的析构函数调用" << endl;
delete name;
name = nullptr;
}
}
void speak(void)
{
cout << *name << ": 喵喵喵!" << endl;
}
};
int main(void)
{
Animal *animal = new Cat("Tom");
animal->speak();
delete animal;
}
虚析构和纯析构都是用来解决通过父类指针释放子类对象的问题。
如果子类中没有堆区数据,可以不写虚析构和纯虚析构。
用于纯虚析构函数的类也属于抽象类,无法实例化对象。