用类机制实现封装
抽象是将同类事物的共有属性和行为提取出来并将其用变量和函数表达出来,封装机制则是将它们捆绑在一起形成一个完整的类。
比如老师这类事物就可以封装为:
// 用 Teacher 类封装老师的属性和行为
class Teacher
{
// 构造函数
public:
// 根据名字构造老师对象
Teacher(string strName)
{
m_strName = strName;
};
// 用成员函数描述老师的行为
public:
void PrepareLesson(); // 备课
void GiveLesson(); // 上课
void ReviewHomework(); // 批改作业
// 其它成员函数…
// 用成员变量描述老师的属性
protected:
string m_strName; // 姓名
int m_nAge; // 年龄
bool m_bMale; // 性别
int m_nDuty; // 职务
private:
};
从现实的老师到 Teacher 类,是一个从具体到抽象的过程,现在有了抽象的 Teacher类,就可以用它来定义某个对象,进而用这个对象来描述某位具体的老师,这又是一个从抽象到具体的过程。例如:
// 定义 Teacher 类对象描述学校中的某位陈老师
Teacher MrChen("ChenLiangqiao");
// 学校中的某位王老师
Teacher MrWang("WangGang");
封装好的类通过特定的外部接口(公有的成员函数)向外提供服务。在这个过程中,外界看到的只是服务接口的名字和需要的参数,而并不知道类内部这些接口到底是如何具体实现的。这就很好地对外界隐藏了接口的具体实现细节,而仅仅把外界最关心的服务接口直接提供给它。通过这种方式,类实现了对行为的隐藏。
用基类和派生类实现继承
我们把表示父类别的类称为基类或者父类,而把从基类继承产生的表示子类别的类称为派生类或子类。 继承允许我们在保持父类原有特性的基础上进行更加具体的说明或者扩展,从而形成新的子类。
通过继承,子类可以轻松拥有父类的成员。而更重要的是,通过继承可以对父类的成员进行进一步的细化或者扩充来满足新的需求形成新的类。
在 C++中,派生类的声明方式如下:
class 派生类名 : 继承方式 基类名 1, 继承方式 基类名 2…
{
// 派生类新增加的属性和行为…
};
其中,派生类名就是我们要定义的新类的名字,而基类名是已经定义的类的名字。一个类可以同时继承多个类,如果只有一个基类,这种情况称为单继承,如果有多个基类,则称为多继承,这时派生类可以同时得到多个基类的特征,多继承可能会带来成员的二义性,因为两个基类可能拥有同名的成员,如果都遗传到派生类中,则派生类中会出现两个同名的成员,这样在派生类中通过成员名访问来自基类的成员时,就不知道到底访问的是哪一个基类的成员,从而导致程序的二义性。所以,多继承只在极少数必要的时候才使用,更多时候我们使用的是单继承。
继承方式也有 public、 protected 和 private 三种
- public 继承被称为类型继承,它表示派生类是基类的一个子类型,而基类中的公有和保护类型成员连同其访问级别直接遗传给派生类,不做任何改变。在基类中的 public 成员在派生类中也同样是public 成员,在基类中的 protected 成员在派生类中也是 protected 成员。 public 继承反映了派生类和基类之间的一种“is-a”的关系,也就是父类别和子类别的关系。
- private 继承被称为实现继承,它把基类的公有和保护类型成员都变成自己的私有( private)成员,这样,派生类将不再支持基类的公有接口,它只希望可以重用基类的实现而已。 private 继承所反映的是一种“用…实现”的关系,如果 A 类 private 继承自 B 类,仅仅是因为 A 类当中需要用到 B 类的某些已经存在的代码但又不想增加 A 类的接口,并不表示 A 类和 B 类之间有什么概念上的关系。从这个意义上讲, private 继承纯粹是一种实现技术,对设计而言毫无意义。
- protected 继承把基类的公有和保护类型成员变成自己的 protected 类型成员,以此来保护基类的所有公有接口不再被外界访问,只能由自身及自身的派生类访问。所以,当我们需要继承某个基类的成员并让这些成员可以继续遗传给下一代派生类,而同时又不希望这个基类的公有成员暴露出来的时候,就可以采用 protected 继承方式。
// 定义基类 Human
class Human
{
// 人类共有的行为,可以被外界访问,
// 访问级别设置为 public 级别
public:
void Walk(); // 走路
void Talk(); // 说话
// 人类共有的属性
// 因为需要遗传给派生类同时又防止外界的访问,
// 所以将其访问级别设置为 protected 类型
protected:
string m_strName; // 姓名
int m_nAge; // 年龄
bool m_bMale; // 性别
private: // 没有私有成员
};
// Teacher 跟 Human 是“is-a”的关系,
// 所以 Teacher 采用 public 继承方式继承 Human
class Teacher : public Human
{
// 在子类中添加老师特有的行为
public:
void PrepareLesson(); // 备课
void GiveLesson(); // 上课
void ReviewHomework(); // 批改作业
// 在子类中添加老师特有的属性
protected:
int m_nDuty; // 职务
private:
};
// 学生同样是人类, public 继承方式继承 Human 类
class Student : public Human
{
// 在子类中添加学生特有的行为
public:
void AttendClass(); // 上课
void DoHomework(); // 做家庭作业
// 在子类中添加学生特有的属性
protected:
int m_nScore; // 考试成绩
private:
};
// 小学生是学生,所以 public 继承方式继承 Student 类
class Pupil : public Student
{
// 在子类中添加小学生特有的行为
public:
void PlayGame(); // 玩游戏
void WatchTV(); // 看电视
public:
// 对“做作业”的行为重新定义
void DoHomework();
protected:
private:
};
// 定义一个 Teacher 对象
Teacher MrChen;
// 老师走进教室
// 我们在 Teacher 类中并没有定义 Walk()成员函数,
// 这里是通过继承从基类 Human 中得到的成员函数
MrChen.Walk();
// 老师开始上课
// 这里调用的是 Teacher 自己定义的成员函数
MrChen.GiveLesson();
整个继承的过程就是类的不断具体化、不断传承基类的属性和行为,同时发展自己特有属性和行为的过程。
类的进化遵循的规则:
- 保留基类的属性和行为。
- 改进基类的属性和行为。
- 添加新的属性和行为。
继承既很好地解决了设计和代码复用的问题——派生类继承保留了基类的属性和行为,同时又提供了一种扩展的方式来轻松应对新的需求——派生类可以改变基类的行为同时根据需要添加新的属性和行为,而这正是面向对象思想的魅力所在。
继承使用的几条规矩:
- 拥有相关性的两个类才能发生继承。
- 不要把组合当成继承。
如果类 B 有必要使用类 A 提供的服务,则要分两种情况考虑:- B 是 A 的“一种”。若在逻辑上 B 是 A 的“一种”( a kind of),则允许 B 继承 A。例如,老师( Teacher)是人( Human)的一种,是对人的特殊化具体化,那么 Teacher 就可以继承自Human。
- A 是 B 的“一部分”。若在逻辑上 A 是 B 的“一部分”( a part of),虽然两者也有相关性,但不允许 B 继承 A。例如,键盘、显示器是电脑的一部分。
如果 B 不能继承 A,但 A 是 B 的“一部分”, B 又需要使用 A 提供的服务,那又该怎么办呢?
让 A 的对象成为 B 的一个成员,用 A 和其他对象共同组合成 B。这样在 B 中就可以访问 A 的对象,自然就可以获得 A 提供的服务了。例如,一台电脑需要键盘的输入服务和显示器的输出服务,而键盘和显示器是电脑的一部分,电脑不能从键盘和显示器派生,那么我们就把键盘和显示器的对象作为电脑的成员变量,同样可以获得它们提供的服务:
// 键盘
class Keyboard
{
public:
// 接收用户键盘输入
void Input()
{
cout<<"键盘输入"<<endl;
}
};
// 显示器
class Monitor
{
public:
// 显示画面
void Display()
{
cout<<"显示器输出"<<endl;
}
};
// 电脑
class Computer
{
public:
// 用键盘、显示器组合一台电脑
Computer( Keyboard* pKeyboard, Monitor* pMonitor )
{
m_pKeyboard = pKeyboard;
m_pMonitor = pMonitor;
}
// 电脑的行为
// 其具体动作都交由其各个组成部分来完成
// 键盘负责用户输入
void Input()
{
m_pKeyboard->Input();
}
// 显示器负责显示画面
void Display()
{
m_pMonitor->Display();
}
// 电脑的各个组成部分
private:
Keyboard* m_pKeyboard = nullptr; // 键盘
Monitor* m_pMonitor = nullptr; // 显示器
// 其他组成部件对象
};
int main()
{
// 先创建键盘和显示器对象
Keyboard keyboard;
Monitor monitor;
// 用键盘和显示器对象组合成电脑
Computer com(&keyboard,&monitor);
// 电脑的输入和输出,实际上最终是交由键盘和显示器去完成
com.Input();
com.Display();
return 0;
}
这种把几个类的对象结合在一起构成新类的方式就是组合。虽然电脑没有继承键盘和显示器,但是通过组合这种方式,电脑同样获得了键盘和显示器提供的服务,具备了输入和输出的功能。
还需要注意的是,这里使用了对象指针作为类成员变量来把各个对象组合起来,是因为电脑是一个可以插拔的系统,键盘和显示器都是可以更换的。键盘可以在这台电脑上使用,也可以在另外的电脑上使用,电脑和键盘的生命周期是不同的各自独立的。所以这里采用对象指针作为成员变量,两个对象可以各自独立地创建后再组合起来,也可以拆分后另作他用。
如果遇到整体和部分密不可分的情况,两者具有相同的生命周期,比如一个人和组成这个人的胳膊、大腿等,这时就该直接采用对象作为成员变量了。例如:
// 胳膊
class Arm
{
public:
// 胳膊提供的服务,拥抱
void Hug()
{
cout<<"用手拥抱"<<endl;
}
};
// 脚
class Leg
{
public:
// 脚提供的服务,走路
void Walk()
{
cout<<"用脚走路"<<endl;
}
};
// 身体
class Body
{
public:
// 身体提供的服务,都各自交由组成身体的各个部分去完成
void Hug()
{
arm.Hug();
}
void Walk()
{
leg.Walk();
}
private:
// 组成身体的各个部分,因为它们与 Body 有着共同的生命周期,
// 所以这里使用对象作为类的成员变量
Arm arm;
Leg leg;
};
int main()
{
// 在创建 Body 对象的时候,同时也创建了组成它的 Arm 和 Leg 对象
Body body;
// 使用 Body 提供的服务,这些服务最终由组成 Body 的 Arm 和 Leg 去完成
body.Hug();
body.Walk();
// 在 Body 对象销毁的同时,组成它的 Arm 和 Leg 对象也同时被销毁
return 0;
}
用虚函数实现多态
比如可以问“教室里有多少人”,实际上问的是“教室里有多少学生”,用基类指代派生类的关系反映到 C++中,就是基类指针可以指向派生类的对象,而派生类的对象也可以当成基类对象使用。
// “上车买票”演示程序
// 定义 Human 类,这个类有一个接口函数 BuyTicket()表示买票的动作
class Human
{
// Human 类的行为
public:
// 买票接口函数
void BuyTicket()
{
cout<<"人买票。 "<<endl;
}
};
// 从“人”派生两个类,分别表示老师和学生
class Teacher : public Human
{
public:
// 对基类提供的接口函数重新定义,适应派生类的具体情况
void BuyTicket()
{
cout<<"老师投币买票。 "<<endl;
}
};
class Student : public Human
{
public:
void BuyTicket()
{
cout<<"学生刷卡买票。 "<<endl;
}
};
// 在主函数中模拟上车买票的场景
int main()
{
// 车上上来两个人,一个是老师,另一个是学生
// 基类指针指向派生类对象
Human* p1 = new Teacher();
Human* p2 = new Student();
// 上车的人请买票
p1->BuyTicket(); // 第一个人是老师,投币买票
p2->BuyTicket(); // 第二个人是学生,刷卡买票
// 销毁对象
delete p1;
delete p2;
p1 = p2 = nullptr;
return 0;
}
首先分别创建了 Teacher 和 Student 对象,并用基类 Human 的两个指针分别来指代这两个对象,然后通过 Human 类型的指针调用接口函数 BuyTicket()函数来表达“上车的人请买票”的意思,完成 Teacher 和 Student 对象的买票动作。最后,程序的输出结果是:
人买票。
人买票。
虽然 Teacher 和 Student 都各自重新定义了表示买票动作的 BuyTicket()函数,虽然基类的指针指向的实际是派生类的对象,可是在用基类的指针调用这个函数时,得到的动作却是相同的,都是来自基类的动作。这显然是不合适的。
C++提供了虚函数( virtual function)的机制。在基类的函数声明前加上 virtual 关键字,这个函数就成为了虚函数,而派生类中对这个虚函数的重新定义,无论是否显式地添加了 virtual 关键字,也仍然是虚函数。
在类中拥有虚函数的情况下,如果通过基类指针调用类中的虚函数,那将调用这个指针实际所指向的具体对象(可能是基类对象,也可能是派生类对象,根据运行时情况而定)的虚函数。像这种在派生类中利用虚函数对基类的成员函数进行重新定义,并在运行时刻根据实际的对象来决定调用哪一个函数的机制,被称为函数重写( override) 。
重载和重写的区别
- 重载(overload)是一个编译时概念,它发生在代码的同一层级。它表示在代码的同一层级(同一名字空间或者同一个类) 中,一个函数因参数类型与个数不同可以有多个不同的实现。在编译时刻,编译器会根据函数调用的实际参数类型和个数来决定调用哪一个重载函数版本。
- 重写(override)是一个运行时概念,它发生在代码的不同层级(基类和派生类之间)。它表示在派生类中对基类中的虚函数进行重新定义,两者的函数名、参数类型和个数都完全相同,只是具体实现不同。而在运行时刻,如果是通过基类指针调用虚函数,它会根据这个指针实际指向的具体对象类型来选择调用基类或是派生类的重写函数。
// 同一层级的两个同名函数因参数不同而形成重载
class Human
{
public:
virtual void Talk()
{
cout<<"Ahaa"<<endl;
}
virtual void Talk(string msg)
{
cout<<msg<<endl;
}
};
// 不同层级的两个同名且参数相同的函数形成重写
class Baby : public Human
{
public:
virtual void Talk()
{
cout<<"Ma-Ma"<<endl;
}
};
int main()
{
Human MrChen;
// 根据参数的不同来决定具体调用的重载函数,在编译时刻决定
MrChen.Talk(); // 调用无参数的 Talk()
MrChen.Talk("Balala"); // 调用以 string 为参数的 Talk(string)
Human* pBaby = new Baby();
// 根据指针指向的实际对象的不同来决定具体调用的重写函数,在运行时刻决定
pBaby->Talk(); // 调用 Baby 类的 Talk()函数
delete pBaby;
pBaby = nullptr;
return 0;
}
在这个例子中, Human 类当中的两个 Talk()函数是重载函数,因为它们位于同一层级,拥有相同的函数名但是参数不同。而 Baby 类的 Talk()函数则是对 Human 类的 Talk()函数的重写了,因为它们位于不同层级(一个在基类,一个在派生类),但是函数名和参数都相同。可以记住这样一个简单的规则:相同层级不同参数是重载,不同层级相同参数是重写。
// 经过虚函数机制改写后的“上车买票”演示程序
// 定义 Human 类,提供公有接口
class Human
{
// Human 类的行为
public:
// 在函数前添加 virtual 关键字,将 BuyTicket()函数声明为虚函数,
// 表示其派生类可能对这个虚函数进行重新定义以满足其特殊需要
virtual void BuyTicket()
{
cout<<"人买票。 "<<endl;
}
};
// 在派生类中对虚函数进行重新定义
class Teacher : public Human
{
public:
// 根据实际情况重新定义基类的虚函数以满足自己的特殊需要
// 不同的买票方式
virtual void BuyTicket()
{
cout<<"老师投币买票。 "<<endl;
}
};
class Student : public Human
{
public:
// 不同的买票方式
virtual void BuyTicket()
{
cout<<"学生刷卡买票。 "<<endl;
}
};
// …
虚函数机制的改写,只是在基类的 BuyTicket()函数前加上了 virtual 关键字(派生类中的 virtual关键字是可以省略的),使其成为了一个虚函数,其他代码没做任何修改,但是代码所执行的动作却发生了变化。 Human 基类的指针 p1 和 p2 对 BuyTicket()函数的调用,不再执行基类的这个函数,而是根据这些指针在运行时刻所指向的真实对象类型来动态选择,指针指向哪个类型的对象就执行哪个类的 BuyTicket()函数。例如,在执行“p1->BuyTicket()”语句的时候, p1 指向的是一个 Teacher 类对象,那么这里执行的就是 Teacher 类的 BuyTicket()函数,输出“老师投币买票”的内容。经过虚函数的改写,这个程序最后才输出符合实际的结果:
老师投币买票。
学生刷卡买票。
纯虚函数
Human 基类的 BuyTicket()虚函数虽然定义了但从未被调用过。而这也恰好体现了虚函数“虚”的特征:虚函数是虚( virtual)的,不实在的,它只是提供一个公共的对外接口供派生类对其重写以提供更具体的服务,而一个基类的虚函数本身却很少被调用。更进一步地,我们还可以 在虚函数声明后加上“= 0”的标记而不定义这个函数,从而把这个虚函数声明为纯虚函数。纯虚函数意味着基类不会实现这个虚函数,它的所有实现都留给其派生类去完成。 在这里, Human 基类中的 BuyTicket()虚函数就从未被调用过,所以我们也可以把它声明为一个纯虚函数,也就相当于只是提供了一个“买票”动作的接口,而具体的买票方式则留给它的派生类去实现。例如:
// 使用纯虚函数 BuyTicket()作为接口的 Human 类
class Human
{
// Human 类的行为
public:
// 声明 BuyTicket()函数为纯虚函数
// 在代码中,我们在函数声明后加上“= 0”来表示它是一个纯虚函数
virtual void BuyTicket() = 0;
};
当类中有纯虚函数时,这个类就成为了一个抽象类( abstract class),它仅用作被继承的基类,向外界提供一致的公有接口。同普通类相比,抽象类的使用有一些特殊之处。首先,因为抽象类中包含有尚未完工的纯虚函数,所以不能创建抽象类的具体对象。如果试图创建一个抽象类的对象,将产生一个编译错误。例如:
// 编译错误,不能创建抽象类的对象
Human aHuman;
其次,如果某个类从抽象类派生,那么它必须实现其中的纯虚函数才能成为一个实体类,否则它将继续保持抽象类的特征,无法创建实体对象。例如:
class Student : public Human
{
public:
// 实现基类中的纯虚函数,让 Student 类成为一个实体类
virtual void BuyTicket()
{
cout<<"学生刷卡买票。 "<<endl;
}
};
使用 virtual 关键字将普通函数修饰成虚函数以形成多态的很重要的一个应用是,我们通常用它修饰基类的析构函数而使其成为一个虚函数,以确保在利用基类指针释放派生类对象时,派生类的析构函数能够得到正确执行。
class Human
{
public:
// 用 virtual 修饰的析构函数
virtual ~Human()
{
cout<<"销毁 Human 对象"<<endl;
}
};
class Student : public Human
{
public:
// 重写析构函数,完成特殊的销毁工作
virtual ~Student()
{
cout<<"销毁 Student 对象"<<endl;
}
};
// 将一个 Human 类型的指针,指向一个 Student 类型的对象
Human* pHuman = new Student();
// …
// 利用 Human 类型的指针,释放它指向的 Student 类型的对象
// 因为析构函数是虚函数,所以这个指针所指向的 Student 对象的析构函数会被调用,
// 否则,会错误地调用 Human 类的析构函数
delete pHuman;
pHuman = nullptr;
不要在构造函数或析构函数中调用虚函数
在创建一个派生类的对象时,基类的构造函数是先于派生类的构造函数被执行的,如果我们在基类的构造函数中调用派生类重写的虚函数,而此时派生类对象尚未创建完成,其数据成员尚未被初始化,派生类的虚函数执行或多或少会涉及到它的数据成员,而对未初始化的数据成员进行访问,无疑是一场恶梦的开始。
在基类的析构函数中调用派生类的虚函数也存在相似的问题。基类的析构函数后于派生类的析构函数被执行,如果我们在基类的析构函数中调用派生类的虚函数,而此时派生类的数据成员已经被释放,如果虚函数中涉及对派生类已经释放的数据成员的访问,就成了未定义行为,后果自负。
override 关键字
修饰一个重写的虚函数,从而让程序员可以在代码中更加清晰地表达自己对虚函数重写的实现意图,增加代码的可读性。override 关键字仅能对派生类重写的虚函数进行修饰,表达程序员的实现意图,而不能对普通成员函数进行修饰以形成重写。
final 关键字
与 override 相对的,有的时候,我们还希望虚函数不被默认继承,阻止某个虚函数被派生类重写。在这种情况下,我们可以为虚函数加上 final 关键字来达到这个目的。例如:
// 学生类
class Student : public Human
{
public:
// final 关键字表示这就是这个虚函数的最终(final)实现,
// 不能够被派生类重写进行重新定义
virtual void BuyTicket() final
{
cout<<"学生刷卡买票。 "<<endl;
}
// 新增加的一个虚函数
// 没有 final 关键字修饰的虚函数,派生类可以对其进行重写重新定义
virtual void DoHomework() override
{
cout<<"完成家庭作业。 "<<endl;
}
};
// 小学生类
class Pupil : public Student
{
public:
// 错误:不能对基类中使用 final 修饰的虚函数进行重写
// 这里表达的意义是,无论是 Student 还是派生的 Pupil,买票的方式都是一样的,
// 无需也不能通过虚函数重写对其行为进行重新定义
virtual void BuyTicket()
{
cout<<"学生刷卡买票。 "<<endl;
}
// 派生类对基类中没有 final 关键字修饰的虚函数进行重写
virtual void DoHomework() override
{
cout<<"小学生完成家庭作业。 "<<endl;
}
};
如果某人重新定义了一个派生类并重写了基类的某个虚函数,那么会产生语义上的错误吗?
如果会,则需要使用 final 关键字来阻止虚函数被重写。
终极用例-多态理解
#include <string>
#include <stdio.h>
#include <iostream>
using namespace std;
class Human
{
public:
Human()
{
cout << "human born" << endl;
}
virtual void BuyTicket() = 0;
virtual ~Human()
{
cout << "human delete" << endl;
}
void eat()
{
cout<<"eat"<<endl;
}
};
class Teacher : public Human
{
public:
Teacher()
{
cout << "teacher born" << endl;
}
virtual void BuyTicket()
{
cout << "teacher put coins" << endl;
}
virtual ~Teacher()
{
cout << "teacher delete" << endl;
}
};
class Student : public Human
{
public:
Student()
{
cout << "student born" << endl;
}
virtual void BuyTicket()
{
cout << "student shuaka" << endl;
}
virtual ~Student()
{
cout << "student delete" << endl;
}
};
int main()
{
Human* p1 = new Teacher();
Human* p2 = new Student();
p1->BuyTicket();
p2->BuyTicket();
p1->eat();
p2->eat();
delete p1;
delete p2;
p1 = nullptr;
p2 = nullptr;
return 0;
}
output:
human born
teacher born
human born
student born
teacher put coins
student shuaka
eat
eat
teacher delete
human delete
student delete
human delete