本文内容摘自:《写给大家看的面向对象编程书》,作者Matt Weisfield
——一、面向对象基本概念
OO和过程式程序设计的区别
在OO设计中,属性和行为都包含在一个对象中,而在过程式程序设计(或者说结构化设计)中,属性和行为是分开的。
OO的封装(对象同时包含属性和行为)限制了外部对某些属性/方法的访问(数据隐藏),以此保证了OO系统中高度的数据完整性。
每个类都包含三个基本部分:类名、属性(数据)和方法(行为)。
类和对象
类是对象的模板,对象是类的一个实例。
接口与实现
接口定义了对象间通信的基本途径,通常是那些指定为public的东西。 只有公共属性和方法被认为是接口。用户不应看到任何实现部分,与一个对象的交互只能通过类接口。 对于电视机来说,只用把插头插在电源上就能使用,不用关心电的来源是一个核电站还是一个本地发电站。
继承(Inheritance)
OO设计允许通过组织类并抽取各个类的共性来定义类之间的关系,这不仅有利于代码重用,还可以实现更好的整体设计。
如狗和猫都是哺乳动物,它们有共性:都有眼睛。哺乳动物是超类(父类),狗和猫则是子类。
继承体现了:is-a关系。圆是一种形状,矩形也是一种形状,它们都有一个共同的超类:形状。
多态(Polymorphism)
在继承层次结构中,所有子类都从其超类继承接口。由于每个子类都是一个单独的实体,它们可能需要对同一个消息作出不同的响应。例如,考虑Shaope类和行为Draw。没有人能画出一个形状,因为这是一个抽象概念,必须指定一个具体的形状。
超类无实现,子类实现;超类有实现,子类可覆盖实现。
组合(Composition)
对象可由其他对象构建或组合而成。
组合体现了:has-a关系。电脑包含视频卡、音效卡、驱动器等。
基于其他类来构建类只有两种方法:继承(is-a)和组合(has-a)。
UML工具:starUML
——二、如何以对象方式思考
OO设计是一个迭代的过程。
因此试图设计一个OO解决方案时,不要想着一上来就完成一个完美的设计(总会有需要改进的地方)。真正需要的是进行头脑风暴,让你能从不同方向展开你的思维过程。尝试解决一个问题时,不要试图遵照任何标准或约定,因为关键就是要有创造性。
实际上,这个过程开始时,甚至不要考虑特定的编程语言。首要的是找出并解决业务问题。
结构化和面向对象开发可以并存。实际上,编写一个OO应用时会大量使用结构化构造。
要培养善于完成OO思维过程的敏锐判断力,要注意三个重要的方面:
1、了解接口与实现的区别
弄清用户需要知道什么,不需要知道什么。
适当构造的类分两部分设计:接口与实现。
实现细节对用户是隐藏的,实现的一个目标是:改变实现不需要同时改变用户原来的代码。
对驾驶员来说,方向盘是接口的一部分,发动机是实现的一部分。
2、以更抽象的方式思考
出租车的两种接口:
(1)载我去机场
(2)左转、右转
显然第一种更可重用。
3、尽可能为用户提供最小接口
设计类时,一般经验是总是为用户尽可能少地提供类的内部知识。
只为用户提供他们确实需要的东西。实际上,这意味着类的接口要尽可能少。开始设计一个类时,首先只应有最小接口。类的设计是迭代性的,所以你很快会发现最小接口可能不满足需求,这很正常。
确定最小接口有一种极端方法:开始是不向用户提供任何公共接口。当然,这样的类没有用处;不过,这就要求用户回过来找你,告诉你“嘿,我需要这个功能”。然后你们可以协商。这样一来,就可以做到只在需要的情况下才增加接口。千万不要假想用户需要某个功能。
(1)确定用户:
乘客和出租车司机都是对象。
比如用户希望一个程序员提供某个功能,但如果程序员发现这个请求在技术上是不能实现的,这个请求就无法满足,无论程序员有多热心。
(2)明确用户行为
明确用户后,必须确定对象的行为。要从所有用户的角度考虑,明确每个对象的作用以及必须做什么才能正确执行。
(3)明确环境约束
计算机硬件可能限制软件的功能。
环境通常会限制对象能够做什么。
(4)明确公共接口
确定最终的接口是一个迭代的过程。对于每个接口,必须确定这个接口有助于对象的操作。如果没有,可能就是没必要的。
(5)明确实现
从技术上讲,不属于公共接口的所有部分都认为是实现。这说明,用户不会看到作为实现的任何方法,包括方法的签名(其中包括方法名和参数列表)以及方法中的具体代码。
——三、高级面向对象概念
构造函数
Object myobject = new Object();
new关键字创建Object类的一个新实例,并分配所需的内存。然后构造函数被调用,传入参数列表中的参数。构造函数为开发人员提供了一个机会来完成适当的初始化。
至少存在一个构造函数。如果类没有提供显式的构造函数,系统会提供一个默认的构造函数。除了创建对象本身,默认构造函数的唯一工作就是调用其超类的构造函数。但建议不要依赖默认构造函数。
在一个类中,可以提供多个构造函数。(函数重载)
重载允许多次使用一个方法名,只要每次方法的签名不同。签名由方法名和一个参数列表组成。(根据不同的语言,签名可能包含也可能不包含返回类型,如JAVA和C#不包含)
构造函数通常定义为public,因为构造函数是累接口的成员。如果构造函数是私有的,其他对象就不能访问它们,包括希望实例化该对象的对象。(如出租车例子中,Cabbie类定义中使用Cab)
超类如何构造
使用继承时,会从父类继承一切(包括构造函数和析构函数)。
在用new关键字分配对象后,会完成以下步骤:
(1)构造函数中首先要做的是调用该类的超类的构造函数(若无显式则调用默认)。
(2)然后初始化对象的各个类属性。这些属性都是类定义的属性(实例变量),而不是构造函数或任何其他方法中的属性(局部变量)。
(3)然后执行构造函数中的其余代码。
处理程序问题的一种方式:捕获异常。
在JAVA、C#、C++中,都通过关键字CATCH和THROW处理异常,听起来就像是一场棒球比赛。
try {
//possible nasty code
} catch(Exception e) {
//code to handle the exception
}
注:catch块中的Exception参数可以用来捕获try块中可能生成的任何异常。当然,也可以设定为捕获指定异常。
如果try块中抛出了一个异常,catch块就会处理这个异常。情况如下:
(1)try块的执行会终止。
(2)检查catch子句,确定对于出现的异常是否包括一个合适的catch块。(每个try块可能有不止一个catch子句)。
(3)如果没有能够处理所抛出异常的catch子句,则把异常传递到下一个更高层的try块。
(4)如果与某个catch子句匹配(遇到的第一个匹配),则执行catch子句中的语句。
(5)再从try之后的语句继续执行。
作用域概念
属性(和方法)都存在于特定的作用域中。在JAVA、C#和C++中,作用域由大括号{}界定。
对象的属性有三种:
(1)局部属性:属于某个特定的方法。
(2)对象属性:由同一对象中的多个方法共享。
(3)类属性:让两个或更多类共享属性。可通过将属性声明为静态(static)实现。
注:
系统会为静态属性(如count)分配一段内存用于由这个类实例化的所有对象。因此,这个类的所有对象是有count时都使用相同的内存位置。实际上,该类的count只有一个副本,它由该类的所有对象共享。
实际上,并非各对象的各个非静态方法都有一个物理副本。每个对象都将指向相同的物理代码。不过,从概念层次看,可以认为对象是完全独立的,它们有其自己的属性和方法。
public class Number{
static int countstatic; //类属性
int count; //对象属性,可通过this.count访问,关键字this是当前对象的一个引用。
public method1{
int count; //局部属性
}
public method2{
int countl //局部属性
}
}
多重继承
允许一个类继承多个类。
每个孩子都有父母(孩子要继承父亲和母亲两个人)。
在C++中,可以使用多重继承和操作符重载,而JAVA和.NET的设计者则不允许这些操作(认为它们带来的麻烦比好处多)。
对象操作
复制和比较对象并不简单,因为它们可能包含引用。
深复制与浅复制
深复制是指,沿着所有引用(指针)完成复制,从而为所引用的所有对象创建新的副本。
浅复制则只是复制引用,而不沿着各引用层继续向下。
可参考:OBJECT-ORIENTED DESIGN IN JAVA p265 "prefer a tree to a forest"的讨论。
——四、类剖析
类名有时会存在一些语言限制。如在java中,公共类名必须与文件名相同。如果两个名字不匹配,应用将无法工作。
例子:Cabbie类和Cab类
namespace ClassExample{
class TestPerson{
public static void main(){
Cabbie joe = new Cabbie("joe","1234");
cout << joe.Name << endl;
}
}
public class Cabbie{
private string strName;
private Cab myCab;
//不带参数的构造函数
public Cabbie(){
name = null;
myCab = null;
}
//带参数的构造函数
public Cabbie(string iName,string serialNumber){
name = iName;
myCab = new Cab(serialNumber);
}
//methods
public string Name{
get {return strName};
set {strName = value};
}
}
public class Cab{
private string serialNumber;
public Cab(string sn){
serialNumber = sn;
}
}
}
——五、类设计指导原则
面向对象程序设计的主要目标之一就是以类似于人们真正的思维方式对真实世界的系统建模。设计类就是创建这些模型的面向对象方式。
一个类不会独立存在。类必须设计为与其他类交互。彼此交互的一组类会作为系统的一部分。最后,再有这些系统为最终用户提供服务。
类设计的一个原则:所有对象都应当尽可能地自行负责自己的行为。
明确公共接口
设计类时,最重要的问题可能就是保证公共接口最小。构建类的根本目的就是要提供一些有用而且简洁的功能,为用户提供一个恰到好处的接口来完成任务。
即使一个类的公共接口不够,使用对象技术也能很容易地利用继承扩展并调整这个接口。
设计健壮的构造函数(和析构函数)
构造函数应当将对象置于一种安全的初始状态,这包括属性初始化和内存管理等问题。还需要确保在默认条件下适当地构造对象。
在支持析构函数的语言中,析构函数要包含适当的清理功能。在C++等语言中,开发人员必须在析构函数中包含代码,以适当地释放对象在其生存周期中获得的内存。如果没有这种清理,就会导致内存泄露(这种内存泄露会慢慢耗尽系统的所有可用内存)。
在类中设计错误处理
遇到错误时,系统应当自行修正并继续,或者妥善地退出,不要丢失任何对用户重要的数据。
设计时充分考虑重用和可扩展性
如果已经编写了一个Person类,需要考虑到你以后可能想编写Employee类或Vendor类。
抽出不可移植的代码,将它们单独放在一个类中,或者至少单独放在一个可覆盖的方法中。
让作用域尽可能小
这与抽象和将实现隐藏的概念是紧密相关的。这种思想是尽可能将属性和行为置于局部。采用这种方式,维护、测试和扩展类就会容易很多。
设计时充分考虑可维护性
松耦合,让类之间的依赖关系尽可能低。
——六、利用对象实现设计
一个可靠的OO设计过程往往包含以下步骤:
(1)完成适当的分析
(2)建立一份描述系统的工作陈述
(3)从这个陈述中搜集需求
需求文档描述了用户希望系统做什么。
需求必须足够具体,可以真正表示用户对最终产品的确切需求。需求文档必须提供足够的详细信息,使用户能对系统的完整性做出有根据的评判。需求文档还必须为使用这个文档完成设计阶段的设计小组提供具体的细节。
(4)开发一个用户界面原型
(5)明确类
根据需求,明确类的一种直观的方法就是重点标出所有名词,这些名词往往表示对象,如人、地点和事件。但并非只能从名词中发现类。
不要太追求完美,试图一次就找出所有的类是不太可能的。在设计的各个阶段中可能还需要删除类、增加类以及修改一些类。
记住:设计是一个迭代的过程。
(6)确定类的职责
职责与动作有关。一般可从需求总结中选出动词来确定职责。但并非只能从动词中发现职责。
(7)确定各个类如何相互交互
为了帮助发现协作关系,可以使用用例场景。创建用例场景的真正目的是帮助你改善所选择的类及其职责。
注:可使用CRC卡(class-responsibility-collaboration,类-职责-协作卡)。每个CRC卡表示一个类的数据属性、职责和协作关系。
(8)创建一个高层模型来描述要构建的系统
简单地说,要在设计早期就明确需求,并保证对设计的改动最小,有以下几个原因:
·在设计阶段修改需求/设计的成本相对较低;
·在实现阶段修改设计的成本会显著增加;
·与第一条相比,如果在部署阶段后再对设计做出修改,其成本将非常高
书参考:the object primer,by scott ambler
——七、深入讨论继承和组合
继承:is-a关系,如狗是哺乳动物;
组合:has-a关系,如车有方向盘、发动机等。
继承和组合是对象重用(利用其他类来构建类,最终成为对象)的两种方式,也是仅有的两种。
继承的思想是通过抽出共性实现从一般到特殊。
在继承树中,树的顶层是最一般的情况,逐步向下建立继承树时对象会越来越具体。
每个新的继承关系都会为继承方法的使用建立一个新的上下文,因此一定要测试新代码。
继承如何削弱封装
Peter Coad和Mark Mayfield提出:使用继承时,类层次结构中的封装本质会被减弱(超类与其子类之间的封装会被削弱)。
如果从一个超类继承一个实现,然后修改这个实现,那么超类的修改会对整个类层次结构产生涟漪作用。这种涟漪效应可能会影响所有子类。
为了减少这种情况产生的风险,使用继承时一定要遵循严格的is-a条件。
多态
多态是继承最精彩的用法之一。
“画什么形状”——必须指定你说的是什么形状。
如在一个shape类中有一个名为Draw的行为(实际上shape类的Draw方法不包含任何实现),那么需要在circle类和其他子类中提供具体的实现。
namespace TestShape{
public class TestShape{
public static void main(){
Circle circle = new Circle();
Rectangle rectangle = new Rectangle();
//方式1
circle.draw();
rectangle.draw();
//方式2
DrawMe(cicle);
DrawMe(rectangle);
static void DrawMe(Shape s){
s.draw();
}
}
};
public abstract class ShapeP{
public abstract void draw();
};
public class Circle extends Shape{
public override void draw(){
cout << "i am drawing a circle" << endl;
}
};
public class Rectangle extends Shape{
public override void draw(){
cout << "i am drawing a rectangle" << endl;
}
f
};
}
——八、框架与重用:使用接口和抽象类实现设计
代码重用
代码重用并不是OO设计所专有的,但OO方法确实提供了一些机制,有利于可重用代码的开发。
代码重用
——》
标准化(框架,微软OFFICE办公套件是一个经典的例子,如word和excel有很相似的界面)
——》
如何使用框架?遵循框架提供的规则。
——》
契约(contact),是要求开发人员遵守应用编程接口规范所需的机制。
——》
实现契约的方法:
1、抽象类
2、接口(C++没有接口构造)
定义契约的简单规则是提供一个未实现的方法,可以通过抽象类提供,也可以通过接口提供。因此,当设计一个子类来实现某个契约时,它必须为父类或接口中未实现的方法提供具体实现。
使用契约存在一个权衡。
抽象类:
抽象类包含一个或多个没有提供任何具体实现的方法,当然这说明抽象类也可以提供具体方法。
抽象类与契约有什么关系?假设我们希望创建一个应用来绘制各种形状。我们的目标是能够绘制当前设计中表示的每一种形状,另外还能绘制将来可能增加的其他形状。这里有两个条件必须遵守。
首先,我们希望所有形状都使用相同的语法来自行绘制。例如,希望系统中实现的每一种形状都包含一个名为draw()的方法。因此,要绘制一个形状,只需调用draw()方法,而不论是什么形状。
其次,要记住,每个类对自己的行为负责。因此,类不仅要提供一个名为draw()的方法,还必须提供它自己的代码实现。例如,类Circle和类Rectangle都有一个draw()方法。不过,Circle类显然包含绘制圆的代码;而Rectangle类要包含绘制矩形的代码。当最后创建Circle和Rectangle类时,他们都是Shape的子类,这些类必须实现其不同版本的draw()。
采用这种方式,我们就有了一个真正多态的Shape框架。这个系统中的每个形状都可以调用方法draw(),而调用每一个形状时都会得到不同的结果。在Circle对象上调用方法draw()会画一个圆,而在Rectangle对象上调用方法draw()会画一个矩形。实际上,向一个对象发送一个消息时,会根据对象的不同而产生不同的响应。这正是多态的根本所在。
public abstract class Shape{
public abstract void Draw(); //no implementation
}
public class Circle extends Shape{
public void Draw(){
cout << "Draw a circle" << endl;
}
}
public class Rectangle extends Shape{
public void Draw(){
cout << "Draw a rectangle" << endl;
}
}
注:
Circle和Rectangle类扩展(即继承)了Shape类,并提供了draw()方法具体的实现。这里就引入了契约。(子类中必须实现的方法只是那些在超类中声明为抽象的方法,这些抽象方法就是契约)
如果Circle继承了Shape,但没有提供一个draw()方法,Circle就无法提供编译。因此,Circle不能满足与Shape的契约
(实际上,如果Circle确实没有实现draw()方法,则Circle本身也被认为是抽象的,因此还必须有另外一个子类继承Circle,并实现一个draw()方法。这个子类则成为Shape和Circle的具体实现)
接口
与抽象类一样,接口也是一种保证框架契约的有效方法。
C++没有接口构造,对C++来说,抽象类就提供了接口的功能。
public interface nameable{
string GetName();
void SetName(string aName);
};
在这里,nameable没有声明为一个类,二是声明为一个接口。正因如此,这两个方法(GetName()和SetName())都被认为是抽象方法,而且这里没有提供任何实现。
接口与继承
与抽象类不同,接口不能提供任何实现(接口只能提供抽象方法)。因此,定义了接口的任何类都必须提供所有方法的实现。
“接口是一种特殊类型的继承”
继承是一种严格的is-a关系,但接口不是。接口只是抽取了多个类之间的一些共性,这些类并不一定是相关的(如可以对狗取名,也可以对蜥蜴取名),而有严格继承关系的类则必须是相关的(狗是哺乳动物,蜥蜴不是,因此没有继承关系)。
例如,如果要求Dog类、Car类和Planet类具有同样的命名语法,可以通过创建一个借口来实现,所有类都必须实现该接口中的所有方法(契约)。
——九、构建对象
组合
继承表示在现有的对象基础上创建新对象,而组合表示不同对象之间的交互。
组合的意义:分阶段构建
要构建高质量的软件,必须遵循一条最高原则:尽可能简单。
“
稳定的复杂系统通常采用一种层次结构的形式,其中各个系统都是由更简单的子系统构成,而每个子系统则是由更简单的下一级子系统构成。
稳定的复杂系统几乎都是可以分解的。
能正常工作的稳定几乎几乎总是由能正常工作的简单系统发展而来
”
摘自《The architecture of complexity》
对一个音响系统来说,如果CD播放器坏了,可以维修或更换CD播放器,而其他部件可以继续工作。
组合的两种类型:聚集和关联
这两种关系都表示对象之间的协作,划分界限模糊,但存在微妙的差别。
在聚集中,通常只关注整体(电视机);而在关联中,往往会关注构成整体的部分(计算机)。
如果类确实是另一个类的组件(如车轮是车的一个组件),这就表示为聚集;
如果一个类需要另一个类的服务(如客户需要服务器的服务),则表现为关联。
基数
参与关联的对象个数以及这种参与是可选的还是强制的。要确定基数,需要考虑几个问题:
·哪些对象与另外哪些对象交互
·每个协作中有多少对象参与
·这种协作是可选的还是强制的
常见基数:
0...1,如一个员工可以有0个或1个配偶
0...n,如一个员工可以有任意个孩子
注:具有一对多关系的类在代码中表示为数组:
private Child[] child;
——十、用UML创建对象模型
UML,unified model language,是一种建模语言。
“一种用于可视化表示、指定、构造和描述软件密集系统中部件的图形化语言”
尽管UML非常重要,但首先学习OO技巧则更为重要。如果在学习OO概念之前先学UML,就类似于在不值电为何物的前提下学习如何看电路图。
画法
类图由3部分组成:类名、属性和方法。
如一个Circle类中有draw()方法:
+draw:void
+表示是公共方法(-表示私有方法);
冒号后是方法的返回类型;
可以包含参数表,但为了类图更清晰可读,最好不要包括太多信息。
public、private、protected、internal
在.NET中,根据微软的MSDN:
protected:这种类型或成员只能由同一个类或结构或者派生类中的代码访问。
internal:这种类型或成员可以由同一个程序集中的任何代码访问,但是其他程序集中的代码不允许访问。
继承和接口
继承:带箭头的一条实线,箭头指向超类。
接口:与继承相似,区别为线是虚线。
组合
聚集:一端带菱形的线,菱形指向整体。
关联:一条普通的线,两端没有任何形状。
——十一、对象和可移植数据:XML
XML:extensible markup language.
XML是不同系统之间定义和传输数据的一种标准机制。
如在SQL SERVER与Oracle之间交换数据:
SQL SERVER—》解析器—》XML—》解析器—》Oracle
解析器(parser)是一个读入文档并抽取特定信息的程序。例如,编译器就包含解析器。
XML与HTML
源自一家:SGML(standard generalized markup language)
HTML表示数据,二XML描述定义数据(格式化数据)。两者可以结合使用。
XML的协助部件
1、DTD:ocument type definition,文档类型定义。
自定义数据时,通过DTD检查数据的合法性。
在DTD中定义用来描述数据的标记。当创建XML文档时,只能使用预定义的标记(包括XML预定义的标记和在DTD中定义的标记)。所有XML文档都要检查合法性。XML处理器读入DTD,并确定这个文档是否合法。如果不合法,则会产生一个语法错误。
关联DTD与XML文档
在XML文档首部附近加入一行命令:
<!DOCTYPE xx SYSTEM "xxx.dtd">,其中xxx.dtd就是预先定义的DTD。
2、CSS:cascading syle sheets,层叠样式表。
可以通过CSS来实现内容的格式化,如字体大小、颜色等。
关联CSS与XML文档
在XML文档首部附近加入一行命令:
<?xml-stylesheet href="xxx.css" type="text/css" ?>
xxx.css就是自定义的CSS。
xml示例
(1)定义DTD,保存为person.dtd
<!-- DTD for person document --> //注释
<!ELEMENT person (name,sex)>
<!ELEMENT name(#PCDATA)>
<!ELEMENT sex(#PCDATA)>
第2行标记定义了一个名为person的元素,并且指出一个person包含一个name和一个sex。因此,当XML解析器解析一个XML文档时,这个文档必须是一个person,而且其中包含一个name和一个sex。
PCDATA代表已解析字符数据(parsed character data),就是从文本文件解析的标准字符信息。
(2)定义CSS,保存为person.css
name{
font-family:Arial,sans-serif;
font-size:24;
color:blue;
display:block;}
age{
font-family:Arial,sans-serif;
font-size:24;
color:blue;
display:block;}
通过该CSS,对于name和sex,文本被格式化使用蓝色Arial Black字体,字体大小为24.属性display:block使每个属性都换行显示。
(3)定义XML文档,保存为person.xml
<?xml version="1.0" standalone="no"?>
<?xml=stylesheet href="person.css" type="text/css" ?>
<!DOCTYPE person SYSTEM "person.dtd">
<!-- The XML data -->
<person>
<name>clay wang</name>
<sex>male</sex>
</person>
注意:
标记是嵌套的,只有最终标记(如name、sex)才能包含数据,而聚集元素(如person)则不能,因为它们是抽象的。