这篇文章是我去年3月份发在哈工大微软俱乐部邮件列表中的科普帖。
这个帖子将会和大家一起讨论一下面向对象编程的一些基础知识,旨在为大家深入学习提供基础。大二同学正好在做软设一,需要对面向对象与泛型有一些了解,大一同学也学过了python,时机正宜。欢迎大家积极回复讨论。
一、关于面向对象编程
一般来讲,对于面向过程的编程手法,一个程序是:
程序 = 数据结构 + 算法
整个过程就是解决相关问题的过程,使用合适的数据结构存储信息,合适的算法解决问题。 比如,对于一个采用面向过程的方式编写的学打酱油系统v0.1,大致如下:
struct 酱油瓶 {
string 酱油牌子
integer 酱油量
}
void 打酱油(){
打开车门()
上车()
开车到粮油店()
打开车门()
下车()
酱油瓶1.酱油牌子 = 康师傅
酱油瓶1.酱油量 = 100
//……
}
我们使用酱油瓶数据结构保存数据,打酱油函数实现打酱油的算法,最后成功地解决了打酱油这一历史性问题!
然而,这种面向过程式的编程方式随着软件架构越来越庞大,变得更加难以满足需求。进而,为了使软件结构易于设计、易于维护、方便扩展,越来越多的人选择了面向对象的方式来设计和实现比较庞大的系统。对于面向对象编程思想来讲,一个程序是:
程序 = 对象 + 对象 + 对象 + ……
所以面向对象编程的前提是要有妹子,然后你就可以面对着他编程了!
在详细讨论之前,首先要区分两个概念:类和对象。
类是一种类型、类别,它是一种抽象。而对象是这种抽象的实例。比如说我是人,他是人,她也是人。这里“人”就是一个类,我、她、他就是“人”这个类的实例。例如下面的C++<代码定义了人类:
class Human{
//xxxxxxxxx
};
而如果要定义Human这个类的对象,则是:
Human lishengqiu;
二、面向对象编程语言的特征
支持面向对象的编程语言普遍拥有以下三(四)个特征:
- 封装
- 继承
- 多态
- (抽象)
对于不同的编程语言,其实现机制可能不同,但思想应基本相似。下面我们就分别谈谈各个特征的具体内容。
1. 封装
封装其实是比较容易理解的。比如:CPU把内部复杂的实现全部封装在一块小芯片上,只留出诸多引脚提供给外部硬件进行访问;微波炉、电冰箱都把内部复杂的控制电路封装起来,只留下几个按钮提供傻瓜式的服务……
从上面两个例子我们总结出封装的特点:
- 对象的数据和操作都是这个对象的提供的。 – 也就是说,编程语言要提供数据和操作捆绑的能力。
- 提供操作接口给用户使用,而不是用户直接控制内部数据。 – 我们并不控制微波炉内部的任何结构与属性,而是通过操作面板对属性(比如每次加热多长时间,光波还是微波加热)进行控制;查看这些属性,也只能通过面板来查看。也就是说,编程语言要提供对成员的访问权限的控制。一般不会将对象的数据直接暴露给用户,而是让使用者通过我们提供的共有操作来对对象的属性进行改变。
- 封装的目的在于隐藏实现细节,提高安全性。对于用户的每一次操作,我们都可以有足够的能力自动对输入进行检查,以防止不合法的数据或操作。 在C++、Java、C#等这一大家子语言中,使用public/private关键字来对访问权限进行控制。以C++为例:
class Car{
public:
int GetCarNo(){return carNo;}
void SetCarNo(int number){if(carNo
上面这段代码首先定义了一个叫做Car的类, 它拥有一个属性叫做carNo(车牌号?车型号?随便啦╮(╯▽╰)╭)。这个属性是私有的(private),不允许外部代码对其进行访问。同时提供了GetCarNo和SetCarNo公有成员函数来提供对carNo的修改。
在SetCarNo中,我们还对号码的合法性进行了判断。如果小于零,我们就认为输入有误,抛出错误(throw);无误的话就就将number赋值给carNo。
再考虑以下代码:
class Time{
public:
int GetTimeBySec(){return hour*3600+minute*60+second;}
double GetTimeByMinute(){return hour*60 + minute + second / 60.0;}
private:
int hour, minute, second;
};
当我们使用这个Time类时,我们不需要知道它内部数据的存储方式(是以小时、分钟、秒数分开存储,还是以只秒的总数存储,还是以分钟?毫秒?微秒?……);同时,Time类还提供了两种获得时间的方法:以秒为单位,以分钟为单位。调用者只需要按需调用接口函数就可以了,而不必关心内部的实现方式。
封装隐藏了内部的实现细节,这些是类的编写者所需要考虑的问题,而不是使用者。使用者不应该拥有涉及类的内部实现的能力,因为这将提高耦合,降低软件的可维护性。
不过,依然有人对封装的必要性持相反意见,有兴趣的同学可以搜索一看。
2. 继承
很多时候我们需要继承来表示对象之间的关系,有点类似父母与孩子。 在继承中,被继承的类(父亲)叫做父类、基类,而孩子类就叫做子类、派生类。C++中使用如下方式进行类之间的继承:
class Base{
public:
int BaseMethod(){return 1;}
};
class Sub : public Base{
public:
int SubMethod(){return 0;}
};
上述代码定义了一个基类Base,和一个方法BaseMethod;子类Sub采用公有继承方式继承Base,以及自己的SubMethod。这时对于子类来说,他将同时拥有BaseMethod和SubMethod两个方法,这就是继承最基本的作用。比如:
Sub sub;
sub.BaseMethod(); //将返回1
sub.SubMethod(); //将返回2
继承提高了代码的复用程度,我们可以将一些公共的操作或属性抽象到父类中,以减少代码量。比如:
class Actor{
protected:
string name;
unsigned int hp;
unsigend int mp;
};
class Luna : public Actor{
};
class Kael : public Actor{
};
上述代码中的protected关键字表示其数据与操作对外部不可访问,而对子类可以访问。
但是,继承也可能造成可读性的降低。如果继承的深度过高,很多属性和方法将很难确定究竟是由那一层子类提供的。
3. 多态
1. 没有多态的继承
考虑以下代码:
class Actor{
public:
void attack(){}
protected:
string name;
unsigned int hp;
unsigend int mp;
};
class Luna : public Actor{
public:
void attack(){
cout << "Luna attack!" <<endl;
}
};
class Kael : public Actor{
public:
void attack(){
cout << "Kael attack!" <<endl;
}
};
基类Actor和子类Luna、Kael的attack函数实现并不相同。
同时,由于Luna和Kael是两个不同的类型,此时如果我们有一个函数希望调用luna和kael的attack方法,不得不这么做:
void letKaelAttack(Kael* kael){
//do something...
kael->attack();
//do something...
}
void letLunaAttack(Luna* luna){
//do something...
luna->attack();
//do something...
}
Luna luna;
Kael kael;
letLunaAttack(&luna);
letKaelAttack(&kael);
也就是说,我们无法写出一个通用的函数来调用不同子类的attack。如果我们有上百个英雄,就必须要写上百个letXXXAttack函数——即使他们都是Actor,有着相同的方法定义,而且也许函数中除了XXX->attack()
之外所有语句都一样!
我们期望的解决方式也许是这样的,使用一个统一的函数来实现这种统一的功能。一个自然而然的想法是使用父类的指针作为形参。然而,直接使用actor->attack()
将调用Actor类的attack函数(就是什么也不做的版本),因此我们需要对父类的指针做出一些转换。如果能够判断一个父类指针所指向的是哪一个子类对象就好了,这样我们就可以写出如下代码:
void letActorAttack(Actor* actor){
//do something...
if(actor is Luna)
((Luna*)actor)->attack();
else if(actor is Kael)
((Kael*)actor)->attack();
//do something...
}
在C++中,actor is Luna这样的功能使用dynamic_cast关键字实现。即:
if(dynamic_cast<luna>(actor))
如果actor不是Luna, dynamic_castLuna*>(actor)将返回NULL。
然而,这样的解决方案仍然不完美。且不说依然要为每一个英雄写一个判断语句,如果我们需要新添加一个英雄的话,也需要修改letActorAttack函数。
2. 多态
继承使得子类可以表现的像父类一样,而多态则让父类表现的像子类。在C++中要使用多态,需要使用virtual关键字,将父类中的函数定义为虚函数。一旦声明虚函数,这个函数将一直是虚函数,不论子类中是否再有virtual关键字。
class Actor{
public:
virtual void attack(){}
protected:
string name;
unsigned int hp;
unsigend int mp;
};
class Luna : public Actor{
public:
void attack(){
cout << "Luna attack!" <<endl;
}
};
class Kael : public Actor{
public:
void attack(){
cout << "Kael attack!" <<endl;
}
};
此时,我们的letActorAttack函数将非常简单:
void letActorAttack(Actor* actor){
//do something...
actor->attack();
//do something...
}
Luna luna;
Kael kael;
letActorAttack(&luna);
letActorAttack(&kael);
如果我们有一位新的英雄,只要它继承了Actor,实现了attack(),就可以把它的地址传给letActorAttack,而letActorAttack不需要任何改动。
另外需要特别说明的是,父类的析构函数应声明为虚函数,否则当使用父类指针delete子类时,子类的析构函数将不会被调用。这并不特殊,因为所有没有声明为虚函数的函数都如此;只是这个函数的地位有些特殊罢了。
4. 抽象
抽象类
还用我们上一个例子。对于Actor这个类来讲,它其实并没有实际意义,因此下面这句话其实是没有任何意义的:
Actor actor
而且,actor中的attack函数并没有实际意义,如果一个英雄继承了Actor但是却没有实现自己的attack,依旧不是我们想要的。
为了解决上述问题,C++采用纯虚函数的方式来解决。而当一个类中包含了纯虚函数,他将成为一个抽象类,并无法实例化。
我们将代码修改如下:
class Actor{
public:
virtual void attack() = 0; //Note Here!
protected:
string name;
unsigned int hp;
unsigend int mp;
};
class Luna : public Actor{
public:
void attack(){
cout << "Luna attack!" <<endl;
}
};
class Kael : public Actor{
public:
void attack(){
cout << "Kael attack!" <<endl;
}
};
virtual void attack() = 0;
这段代码声明了一纯虚函数,他将Actor这个类变成了一个抽象类。此时的Actor将无法实例化,仅可以作为指针使用。如果Sniper继承自Actor,但是没有实现attack函数,那么他将也无法实例化,即无法创建出Sniper实例。
抽象类做接口
我们现在希望做一个音乐播放器。播放部分比较重要的就是解码器了,但是每个不同的音频解码库的使用方法都不一样。目前已经准备好APE、MP3和OGG的解码器了(MACLib, mpg123, OggVorbis),如何才能有效地使用这些解码器呢?
利用我们目前已经了解的继承、多态、抽象类,可以很容易想到解决方案:
- 我们可以为每一个解码器做一层封装,将每一个解码器的使用方式封装起来。这样上层使用者不需要知道我们内部的具体实现,只需要调用我们的公开函数即可。
- 我们对每一个封装好的类进行抽象,提取出公共的接口,使用一个抽象类作为基类,不同的解码器封装继承自抽象基类。
- 得益于多态,我们使用抽象基类指针即可操作不同的子类对象。
下面的代码取自我曾经实际做过的项目,适当做了省略和修改:
class Decoder{
public:
virtual int CheckSupport(const char*) = 0;
virtual int Open(const char*) = 0;
virtual int Seek(double) = 0;
virtual void GetFileExt(char* ,char*) = 0;
virtual int GetFormat(long*,int*) = 0;
virtual int GetFileInfo(Info*) = 0;
virtual int GetFileInfo(const char*, Info*) = 0;
virtual int ReadAndDecode(unsigned char*,int,unsigned int*) = 0;
virtual double GetPosition(int buffered = 0) = 0;
virtual double GetLength() = 0;
virtual int Close() = 0;
virtual ~Decoder(){}
};
class ApeDecoder : public Decoder{
private:
//省略数据成员
public:
ApeDecoder();
~ApeDecoder();
int CheckSupport(const char* fname);
int Open(const char* fname);
int Seek(double sec);
void GetFileExt(char* Description, char* FileExt);
int GetFormat(long* rate,int* channel);
int GetFileInfo(Info* info);
int GetFileInfo(const char* filename, Info* info);
int ReadAndDecode(unsigned char* buf,int size,unsigned int* read);
double GetPosition(int buffer_size = 0);
double GetLength();
int Close();
};
class MP3Decoder : public Decoder{//...}
class OggDecoder : public Decoder{//...}
Decoder是一个抽象类,他甚至什么数据都没有,只有成员函数。我们使用这个类作为接口,来操作子类:
Decoder* decoder = new MP3Decoder();
不过很多时候,我们并不像让用户知道子类的名字,因为这意味着需要提供子类的声明。例如,我们想把我们写的这些代码封装成库,提供给其他用户调用,尽量隐藏细节。这时我们通常会采用一些合适的设计模式,例如简单工厂模式,将子类的制造过程也抽象出来:
Decoder* CreateDecoder(string name){
if(name == "mp3")
return new MP3Decoder();
else if(name == "ape")
return new APEDecoder();
else if(name == "ogg")
return new OggeDcoder();
else
return NULL;
}
此时,用户只需要Decoder的声明和CreateDecoder的声明就可以了,以及我们提供的库文件。调用时也非常简单:
Decoder* decoder = CreateDecoder("mp3");
所有的操作都被隐藏在抽象之下。而Decoder类的作用,仅仅是提供接口;由于具有多态性,decoder变量将表现的像他的孩子一样,而用户完全不需要知道任何细节,例如我们的MP3子类究竟是叫MP3Decoder还是叫Decoder_MP3?或是我们的MP3Decoder究竟使用的是哪一种mp3解码库(是mpg123还是ffmpeg)?同时,只要接口不做改变,我们所有的类的实现方式都可以随意更改,而用户不需要修改他们的任何代码就可以直接使用我们修改过的库。
在一些其他语言中,使用独立的关键字(例如interface)来提供对接口的定义。
三、尾声
这篇帖子简单介绍了面向对象编程语言的几大特征,很多支持面向对象方式编程的编程语言基本上会都在不同程度上支持这些特性。有一些语言会有所缺失,我们也可以认为他们是面向对象编程语言;而有一些语言则支持更加广义的特性。对于这些特性多多地加以合理利用,往往会使代码更加易于组织和构造,也更加易于编写和维护。