2.19k likes | 2.52k Views
Object Oriented Design. 第 5 章 面向对象的设计. 面向分析. 面向设计. 做什么 ? 需求 领域的调查. 如何做 ? 确定逻辑的解决方案. Object-Oriented Design. 内容 5.1 面向对象设计概述 5.2 软件设计的体系结构 5.3 面向对象设计的软件体系结构 5.4 对象设计 5.5 数据管理的设计 5.6 人 - 机交互的设计 5.7 任务管理的设计. 5.1.3 面向对象的设计方法. 5.1 面向对象设计概述. 5.1.1 面向对象设计内容.
E N D
Object Oriented Design 第5章 面向对象的设计 面向分析 面向设计 做什么?需求领域的调查 如何做?确定逻辑的解决方案
Object-Oriented Design 内容 5.1 面向对象设计概述 5.2 软件设计的体系结构 5.3 面向对象设计的软件体系结构 5.4 对象设计 5.5 数据管理的设计 5.6 人-机交互的设计 5.7 任务管理的设计
5.1.3 面向对象的设计方法 5.1 面向对象设计概述 5.1.1 面向对象设计内容 5.1.2 面向对象分析与设计的制品
5.1 面向对象设计概述 5.1.1 面向对象设计内容 在已建立概念类图(对象分析模型)的基础上, 进一步优化类图,确定实现的逻辑模型。 面向对象的设计包括: • 体系结构的设计 • 对象的设计 • 数据管理的设计 • 人-机交互的设计 • 任务管理的设计
5.1.2 面向对象分析与设计的制品 要回答的问题 • 领域过程是什么 • 用例、活动图 分析阶段的制品 • 对象(概念)模型 • 领域中的概念和术语是什么 • 顺序图等 • 系统事件和操作是什么 • 功能模型 • 系统操作做了什么 设计阶段的制品 要回答的问题 • 协作图 • 状态图 • 对象间的通讯细节 • 设计类图 • 设计软件实现的类图
设计 软件类,不是 概念的一部分 分析 Dialer拨号器 Dialer拨号器 实体 类名 - digits:Vector - nDigits:int digits nDigits 实体 信息 属性/成员变量 + digit(n:int) # recordDigit ( n:int):boolean 实体 职责 操作/成员函数 概念记号 一个Dialer代表了一次拨号的事件,它有digits • 图5-2 设计的类图 public class Dialer { private Vector digits; int nDigits; public void digit(int:n); protected boolean recordDigit(int n); } 概念的内涵 概 念 的 外 延 概念应 用的一 组实例 Dialer1 Dialer 2 Dialer3 Dialer 4 图5-1 分析的类图
5.1.3 面向对象的设计方法 (1) Coad & yourdon 方法 (COA91) 问题域部分 数据管理部分 人机交互部分 任务管理部分 (2)Rumbaugh方法(RAM91) 系统设计(System Design) 对象设计(Object Design)
5.1.4 设计上的几个原则 1) 单一职责原则(SRP,Single-Responsibility Principle) 就一个类而言,应该仅有一个引起它变化的原因. 职责:“变化的原因”(a reason for change) (1) 分离类的职责 若一个类承担的职责过多,就等于把 这些职责耦合在一起. 这种耦合会导致脆弱的(fragile)设计 当变化发生时,设计会遭到破坏.如图:
Computationa Geometry Application Graphical Application Rectangle +draw() Geometric Rectangle +area():double GUI 图 5-4 分离的职责 Computationa Geometry Application Rectangle +draw() +area():double Graphical Application 绘图 计算几何形状 GUI 计算和绘图在一起. 计算包含GUI代码, C++把GUI代码链接起来,Java GUI的Class文件必须被部署到目标平台上. 图 5-3 多于一个的职责
(2) 分离接口中的职责 例 违反单一职责的程序: inteface Modem { //调制解调器的连接处理 public void dial ( string pno); public void hangup (); //发送、接收函数的数据通信 public void send ( char c); public void recv (); } 该接口声明的4个函数都是调制解调器具有的功能. 有问题?
该接口程序有两个职责: 要不要分离? 调制解调器的连接处理 两个函数的数据通信. • 若程序变化导致两个职责同时变化,就不要 分离. • 若程序变化影响连接函数签名(signature), 调用和类要重新编译,增加了部署的次数, 这两个职责应分离. • 变化实际发生了才有意义,若无征兆,去应 用单一职责,是不明智的.
分离调制解调器Modem的接口 <<interface>> Connection +dial(pno:String) +hangup() <<interface>> Data Channel +send(:char) +recv():char Modem Implementation 图 5-5 分离的Modem接口 ModemImplementation类耦合了两个职责,这不是所 希望的,但,如硬件等原因,这种耦合可能是必要的. 对于 应用的其余部分,通过对接口的分离己经解耦了概念。 可以把ModemImplementation类看成是一个杂凑物, 谁也不依赖它,除了Main外,谁也不知道它的存在.
又如,被耦合在一起的持久化职责 Employee +CalculatePay +Store Persistence Subsystem 图 5-6 被耦合在一起的持久化职责 违反单一职责。为什么? Employee类包含了业务规则和对于持久化的控制. 业务规则会多变,持久化的方式不会如此变化,且变化原因也不一样,不能将其放在一起。
思考怎样分离如下类的职责 Employee +calculatePay 计算薪水 +calculateTaxes 计算税金 +writeToDisk 在磁盘上续写自己 +readFromDisk +createXML 进行XML格式相互转换 +parseXML +displayOnEmployeeReport 显示各种报告 +displayOnPayrollReport +displayOnTaxReport
public class Employee { public double calculatePay(); public double calculateTaxes(); public void writeToDisk(); public void readFromDisk(); public string createXML(); public void parseXML(string xml); public void displayOnEmployeeReport( printStream stream); public void displayOnPayrollReport( printStream stream); public void displayOnTaxReport( printStream stream); }
一个可行的结构 Employee XMLConverter Employee +EmployeeTo XML+XMLToEmployee +calculatePay+ calculateTaxes PayrollReport Employee Database TaxReport +writeEmployee +readEmployee EmployeeReport 图 5-7 业务的隔离类图
2) 开放—封闭原则(OCP,The Open-Closed Principle) 典故:一个被分割的两删门,每一部分都可 以独立的开放或封闭. 遵循OCP原则设计的模块具有两个主要特征: 怎样的设计,才能面对需求的变化只是添加 新的代码,保持相对稳定,不改动正在运行的程序. 软件实体(类、模块、函数等)应该是可以扩 展的,但是不可修改.
对于扩展是开放的 (Open for extension) 表示模块的行为是可扩展的,即当需求 变更时,可以改变模块的功能. • 对于更改是封闭的 (Closed for modification) 对模块进行扩展时,不必改动模块的 源代码。 以上两点出现了矛盾
Server Client 图 5-8 违背OCP原则 <<interface>> Client Interface Client Server 图 5-9 STRATEGY模式:开放封闭的Client 关健是抽象 Client和Server类都是具体类, Client类使用Server类. 若Client类使用另一个Server 类,就要改动Client类使用 Server类的地方。 • c++,Java等OOPL语言具有抽象类,其任意行为 可能出现在派生类中。 因为抽象类和它们的客户Client关系要比实现它们的类关系更密切。 抽象接口命名为ClientInterface而不命名为Abstract Server?
例:一个数据库门面EmployeeDB处理对象 EmployeeDB 《API》 TheDatabase Employee +readEmployee +writeEmployee 门面直接处理 数据库的API 图 5-10 违背OCP原则 修改EmployeeDB类,必须重新编译Employee类,Employee和数据库ApI捆绑在一起。
把GUI管理和数据库操纵分开 《interface》 EmployeeDB Employee +readEmployee +writeEmployee 《API》 TheDatabase Employee Database Implementation UnitTest Database 图 5-11 遵守OCP原则
例:Shape(形状)应用程序. 在标准GUI上按照特定顺序绘制园和正方形. 创建一个列表,列表由按适当顺序排列的园和正方 形组成,程序遍历该列表,依次绘制每个园和正方形. 违反OCP实例 Circle/Square问题 shape.h enum ShapeType { circle,square }; Struct Shape { ShapeType itsType; } circle.h Struct Circle { ShapeType itsType; double itsRadius(半径); point itsCenter(中心点); };
square.h Struct Square { ShapeType itsType; double itsSide; (边) point itsTopLeft; (左边的顶点) }; drawAllShapes.cc Typedef struct Shape *ShapePointer; Void DrawAllShapes (ShapePointer list,int n) DrawAllShapes函数不符合OCP,它对于新的形 状类型添加是不封闭的.每增加添加一个新的形状 类型,就改变了这个函数,要对新类型判断.
在应用中switch函数重复 出现,但完成工作略有差异. 不同形状依赖于enum声明. 增加一个新成员都要重新编 译、部署(DLL、共享库、二 进制组件)一个简单行为导致连锁改动,是僵化的. { int i; for (i=0; i<n; i++) { struct Shape* s=list[i]; switch (s->itsType); { case square: DrawSquare ((struct Square*)s); break; case circle: DrawCircle ((struct Circle*)s); break; } } } 要想在另一个程序中复用DrawAllShapes函数 都要带上Square,Circle.方法是牢固的,也很槽糕.
遵循OCP规则: 定义一个抽象类Shape 及抽象方法Draw.Circle和Square都从Shape类派生. class Shape { public: virtual void draw() const = 0; }; class Square: public Shape { public: virtual void draw() const; };
class Circle: public Shape { public: virtual void draw () const; }; //不需要改动DrawAllShapes方法,增加Shape类的派生类, //扩展其行为. Void DrawAllShapes (vector<Shape*>& list) { vector<Shape*>::interator i; for (i=list.begin () ; i != list.end(); i++) (*i)->Draw(); }
模块可以操作一个抽象体.由于模块依赖于一 个固定的抽象体,所以它对于更改可以是封闭的. 同时,通过从这个抽象体派生,也可以扩展此 模块的行为. 上系统强调一个形状的顺序比强调类型更重 要,很难做到严格封闭. 一般而言,无论模块是多么的封闭,都会存在 无法对所有情况都适用的模型. 一般做法:找出一个模块易变化的部分,构造 抽象类,隔离其变化.
OCP(开放-封闭)是面向对象的核心,遵守这个原则会使设计具有灵活性、可重用性、可维护性.OCP(开放-封闭)是面向对象的核心,遵守这个原则会使设计具有灵活性、可重用性、可维护性. 但是,并不意味着对应用程序的每个部分都 要进行抽象. 正确的做法是,开发人员应该仅仅对程序中 呈现出频繁变化的那些部分做出抽象. 拒绝不成熟的抽象和 抽象本身一样重要.
3) 依赖倒置原则(DIP,Dependency-Inversion Principles) DIP原则 • 高层模块不应依赖低层模块,二者都应依赖 于抽象 • 抽象不应依赖于细节,细节应该依赖于抽象. 好处: • 底层模块改动不会影响到高层模块 • 增加各层模块的独立性
Policy Layer 层次化 Mechanism Layer Utility Layer 图 5-12 层次化方案 这种层次化(政策-机制-效用)方案高层依赖于底层,这种依赖关系是传递的.
<<interface>> Policy Service Interface Policy Layer <<interface>> Policy Service Interface Mechanism Layer Utility Layer 图 5-13 政策 机制 效用 倒置的层次化方案
上图中每个较高层次都为它的服务声明一个 抽象接口,较低层次实现抽象接口 • 高层类通过抽象接口使用下一层,高层不依赖 于低层,低层依赖于在高层声明中的抽象接口. • 倒置不仅是依赖关系的倒置,也是接口所有权 的倒置. • 依赖倒置可以应用于任何存在于一个类向另一 个类发送消息的地方.
依赖于抽象 又如,Button对象控制Lamp对象的一个模型 Button对象感 知外部环境变 化.收到poll消 息,判断是否被 用户按下。 Lamp +TurnOn() +TurnOff() Lamp对象 会影响外部环境. Button +Poll() 图 5-14 不成熟的Button和Lamp模型 Button .Java 代码 Public class Button { private Lamp itsLamp; public void poll () { if ( /* some condition */ ) itsLamp.turnOn(); } } 高层策略依赖于低层模块. 抽象依赖于具体细节.
<<interface>> ButtonServer +turnOff() +turnOn() Button +poll() # 程序中所有依赖关系都应 该终止于抽象类或接口。 # 任何类都不应当从具体类中派生。 # 任何方法都不应该覆写它的任何 基类中己经实现了的方法。 # 若一个具体类(如描述字符串的类) 是稳定的,也不会创建其他类似的派生类,直接依赖它不会造成损害。 • 找出潜在的抽象 启 发 Lamp 图 5-15 对Lamp应用 依赖倒置原则 接口没有所有者,可以被许多不同的客户、服户者使用。 这样接口需要放一个单独的组(group)中。C++中把接口放在一单独的namespace和库中。在Java中把接口放在一单独的package中
一个控制(Regulate)熔炉调节器软件.从IO通道中读取当前一个控制(Regulate)熔炉调节器软件.从IO通道中读取当前 的温度,并通过向另一个通道发送命令来指示熔炉的开或关。 #define TERMOMETER 0x86 //炉子两个通道 #define FURNACE 0x87 #define ENGAGE 1 //启动 #define DISENGAGE 0 //停止 Void Regulate(double minTemp,double maxTemp) { for (;;) { while (in(TERMOMETER) > minTemp) wait(1); out (FURNACE, ENGAGE); while (in(TERMOMETER) < maxTemp) wait(1); out (FURNACE, DISENGAGE); } } 熔炉示例 代码表示了底层 细节,不能重用
《 function 》 Regulate • 倒置这种关系: 调节器函数Regulate 接受两个接口参数, 温度计(Thermometer) 接口可以读取,加热器 (Heater)接口可以启动 和停止. 《interface》 Thermometer +read() 《interface》 Heater +engage() +disengage() IO Channel Thermometer IO Channel Heater 图 5-16 通用的调节器
Void Regulate(Thermometer& t,Heater& h, double minTemp, double maxTemp ) { for (;;) { while (t.read()> minTemp) wait(1); h.engage(); while (t.read() < maxTemp) wait(1); h.dinengage(); } } • 倒置依赖关系,高层的调节策略不再依赖于任何温 度计或熔炉的特定细节,该程序有很好的可重用性。
template <typename THERMOMETER,typename HEATER> class Regulate(Thermometer& t,Heater& h, double minTemp, double maxTemp ) { for (;;) { while (t.read()> minTemp) wait(1); h.engage(); while (t.read() < maxTemp) wait(1); h.dinengage(); } } • 上面使用动态的多态性(抽象类或接口)实现了通用的 调节器软件。同样 ,还可以使用C++模板(template) 提供静态形式的多态性。
在C++中,read ,engaged ,disengaged方法可 以是非虚的。任何声明了这些方法的类都可以作为模 板参数使用,不必从一个公共基类继承。 • 在作为模板Regulate不依赖于这些函数的特定实现。 只要出替换类HEADER,THERMPMETER中的方法。 • 模板的缺点是: HEADER,THERMPMETER的类型不能在运行中更改, 对于新类型的使用会重新编译和部署 。 • 应当先使用动态特性
使用传统的过程化程序设计所创建出来的依赖关系结使用传统的过程化程序设计所创建出来的依赖关系结 构是依赖于细节的。 • 程序的依赖关系没倒置就是过程化的设计。 程序的依赖关系倒置了,就是面向对象的设计。 • 正确应用依赖关系的倒置对于创建可重用的框架是 必须的。 对于构建在变化方面富有弹性的代码也是非常重要的。 抽象和细节分离,代码易维护。
4) 接口隔离原则(ISP, see Interface Segregation Principles) 目的: • 处理胖(fat)接口(类的接口不是内聚的cohesive), 把胖接口分解成多组方法. 这样一些客户可以使用一组成员函数. • 若有一些对象不需要内聚的接口,ISP建议 客户程序应看到具有内聚接口的抽象基类.
(1)接口污染 安全系统中的Door(门)对象,可以被加锁 和解锁,且 Door对象知道自己是开还是关. class Door { public: virtual void Lock() = 0; virtual void Unlock() = 0; Virtual bool IsDoorOpen() = 0; }; 该类是抽象的,客户程序可以使用那些 符合Door接口的对象,而无需依赖Door的实现.
如果门开着的时间过长,会发出警报声,设一个 TimedDoor对象和Timer(定时器)对象交互. class Timer { public: void Register (int timeout,TimerClient* client); }; class TimerClient { public: virtual void TimeOut () = 0; }; 希望得到超时的通知可以调用Register函数. TimeOut函数会在超时到达时被调用.
<<interface>> Timer Client +Timeout • 怎样将TimerClient类和TimedDoor类联系起来 ? 才能在超时时通知到TimedDoor中相应的处理? 给出一种方案: Timer 如果创建了无需定 时功能的Door派生 类,则在这些派生 类中就必须提供 TimeOut方法的退 化(degenerate)实 现,违反了LSP (替换原则). 0..* TimerClient可 以把自己注册到 Timer中,且可到 接收Timeout消息. Door Door类依赖于 TimerClient.可 是并不是所有的 种类的Door都需 要定时功能. TimedDoor 图 5-17 位于层次结构 顶部的TimerClient
此外使用这些派生类的应用程序即使不 使用TimerClient类定义,也要引入它,具有 复杂性和不必要重复的臭味. Door的接口被一个它不需要的方法污染了. Door中加入这个方法只是为子类带来方 便.若每次子类需要一个新方法时,就将其加 到基类中,使它变胖. C++、Java静态类型语言中是常见的.
(2) 分离客户就是分离接口 Timer使用TimerClient,而操作门的类 使用Door.既然客户程序是分离的,接口也 应分离.因为客户程序对于它们使用的接口 施加有作用力. 客户对接口施加的反作用力 一般考虑软件中引起变化的作用时,通 常的变化怎样影响其使用者. 考虑接口如果 TimerClient的接口改变了, TimerClient的 使用者要做什么改变?
有时接口改变的,正是它们的使用者. 如Timer的使用者会注册多个超时通知请求, 当检测到门打开发送一个注册消息,请求一个超 时通知.但在超时到达前,门又关上了,而后又被 打开,导致原先超时到达前又注册一个新的超时 请求,最后,最初的超时到达, TimedDoor的 TimeOut方法被调用, 错误发出报警. 怎样改正上面的错误?
增加一个标识码,以便知道该响应哪个超时请求增加一个标识码,以便知道该响应哪个超时请求 class Timer { public: void Register (int timeout,int timeOutID, TimeClient* client); }; class TimerClient { public: virtual void timeOut (int timeOutID) = 0; };
(3) 分离接口的方法 不应强迫客户依赖于它们不用的方法. • 使用委托分离接口 <<interface>> Timer Client +Timeout Door Timer 0..* 当TimedDoor要 向Tim对象注册一 个超时请求时,它就 创建一个 DoorTimerAdapter (适配器)并且把它 注册给Timer. 当Timer对象发送 TimeOut消息给 DoorTimerAdapter 时, DoorTimer Adapter把这个消 息委托给 TimedDoor. TimedDoor +DoorTimeout() Door Timer Adapter +Timeout() <<creates>> DoorTimerAdapter会将TimerClient接口转换成 TimedDoor接口. 图 5-18 定时器适配器
TimedDoor.cpp class TimedDoor: public Door { public: //注册一个超时请求 virtual void DoorTimeOut(int timeOutID); }; ClassDoorTimeAdapter: public TimerClient { public: doorTimerAdapter (TimedDoor& theDoor): itsTimedDoor(theDoor) { } vistual void TimeOut (int timeOutID) { itsTimedDoor.DoorTimeOut(ID); } private: TimedDoor& itsTimedDoor; }