面向对象编程——类与对象

电子说

1.3w人已加入

描述

周立功教授数年之心血之作《程序设计与数据结构》以及《面向AMetal框架与接口的编程(上)》,电子版已无偿性分享到电子工程师与高校群体,书本内容公开后,在电子行业掀起一片学习热潮。经周立功教授授权,本公众号特对《程序设计与数据结构》一书内容进行连载,愿共勉之。

第四章为面向对象编程,本文为4.2 类与对象。

4.2 类与对象

亚里士多德可能是第一个研究类型概念的人,他提到了“鱼类和鸟类”。将具有共同的行为和特征的所有对象归为一个类的思想,在第一个面向对象语言Simula-67中得到了直接应用,其目的是为了解决模拟问题。比如,银行的出纳业务,包括出纳部门、顾客、业务、货币的单位等大量的对象,将具有相同数据结构(属性)和行为(操作)的对象归在一起为一个类,属于类的任何对象都共享该类的所有属性,这就是类的来源。

创建抽象数据类型是OOP的基本思想,几乎能象完全内建类型一样使用。程序员可以创建类型的变量和操作这些变量。每个类的成员都有共性,每个账户有余额,每个出纳员都能接收存款等。同时每个成员都有自己的状态,每个账户有不同的余额,每个出纳员都有名字。通常在计算机中出纳员、客户、账户和交易等都被描述为唯一的实体,这个实体就是对象,每个对象都属于一个定义了它的行为和特性的特定类。

由此可见,类和类的对象不是相同的概念,与图纸和建筑的关系类似,对象的描述依赖描述它的类。因此可以通过创建类的实例创建对象,即定义类的变量,这个过程叫做实例化。

>>>  4.2.1 对象

从人类认知的抽象角度来看,对象可以是下列事物之一:

  • 一个可以触摸或可以看见的东西;

  • 在智力上可以理解的东西;

  • 可以指导思考或行动的东西。

显而易见,一个对象反映了某一部分的真实存在,因此对象是在时间和空间中存在的某种东西。软件中的“对象”术语首先出现在Simula语言中,对象存在于Simula程序中,用于模拟现实世界的某个方面。

某些对象可能有明确的概念边界,但代表的是不可触摸的事件或过程。比如,一个立方体和一个球相交,它们的相交线是一条不规则的曲线。虽然它离开了球体或立方体就不存在了,但这条线仍然是一个对象,因为它有明确定义的概念边界。

某些对象可能是可触摸的,但物理边界不太清晰。比如,河流、雾和人群等就属于这种类型的对象。虽然类似于美和色彩这样的属性不是对象,爱和恨这样的感情也不是对象,但这些东西有可能成为其它对象的属性,比如,一个男人(一个对象)爱他的妻子(另一个对象),或者说某只猫(又一个对象)是灰色的。由此可见,属性表示对象记忆的信息,且只能通过对象的操作来访问和修改。

当传统的过程模块或函数返回调用者时,不会带来任何副作用,模块运行结束,只将其结果返回。当同一模块再次被调用时,就象是第一次诞生一样。模块对以前的存在没有任何记忆,就象人类一样对以前的存在一无所知。

就对象而言,对象的一个重要特征是它们充当数据的容器,因此对象具有记忆功能,对象知道它的过去,通常也将包含在对象属性中的数据值称为对象的状态。当一个对象的调用者给该对象一个信息后,如果该调用者或其它调用者要求该对象再次提供这一信息,则该对象执行结束后并没有死,因此对象具有如何保持其状态(状态即对象拥有值的集合)的能力。

假设你在看一个人,肯定会将这个人当作一个对象。显然,每个人都有数据,比如,name、birthdate和weight等;一个人还有行为,比如,走路、说话和呼吸等,因此可以说对象是由“数据和行为”构成的。在现实世界里,由于每个对象的状态不一样,因此可以用存储在一个对象中的数据表示对象的状态,数据包含了能够区分不同对象的信息。

在OO程序设计中,每个对象都有唯一的标识,标识是一个对象的属性,用于区分这个对象与其它所有对象。而这个唯一的标识可以通过句柄机制提供,因此可以借助这个句柄引用对象。不同的语言实现句柄的方式不一样,比如,地址、数组下标或人为编号。

在现实世界中一些对象有对等物,比如,ZLG公司,另一些对象则是概念实体,比如,解一元一次方程,还有一些其它的对象,比如,栈、数组变量名a,都是为了实现而引入的,没有对应的物理实体。

许多开发人员可能会认为“一个包含了另一个对象的对象”,其本质上与“一个具有纯数据成员的对象”是完全不同的,但是那些看起来不是对象的数据成员实际上也是对象,比如,整数和双精度数。在真正的面向对象语言中,万事万物都是对象,甚至内置数据也是对象,即使其行为只是运算。

由此可见,虽然对象是具有明确定义的边界的东西,但还不足以区分不同的对象。因为同一个类的每个对象具有不同的句柄,在任何特定时刻,每个对象可能有不同的状态(指存储在变量中的不同的“值”),因此对象是一个具有状态、行为和标识的实体。

对象又分持久对象和主动对象,持久对象是指生存期可以超越程序的执行时间,而长期存在的且所有的操作都是被动执行的对象。在主动对象概念出现之前,人们所理解的对象概念只是被动对象,即对象的每个操作都是被动地响应从外部发来的消息才可以执行。

在开发一个具有多任务并发执行的系统时,如果仅有被动对象的概念,则很难描述系统中的多个任务。其实并发不仅仅存在操作系统中,如今多个任务并发可以说无处不在。每个任务在实现时应该成为一个可以并发执行的主动程序单位,那么如何描述呢?

如果用被动对象将无法描述那些不接收任何消息也要主动工作的对象,比如,交通灯控制系统中的信号灯,温控器中的传感器,它们的行为都是主动发起的,即主动对象至少有一个操作不需要接收消息就就能主动执行的对象。

尽管发现对象的活动是从具体事物出发分析和认识问题的,但人们在进行这种活动时实际上并不局限于对个别事物的认识,而是寻找一类事物的共同特征,将对象抽象为类。

>>>  4.2.2 类

类的概念早在柏拉图之前就出现了,面向对象编程就像柏拉图之后的西方哲学家一样延续了这种思维。类的概念与对象的概念是紧密交织在一起,因为在讨论一个对象时不得不提到它的类,但是这两个术语又存在重要的差别。对象是存在于时间和空间中的具体实体,而类仅代表一种抽象,因此可以说Validator类代表了所有校验器的共同特征。要确定这个类中的某个具体的校验器,则必须说“范围值校验器”或“奇偶校验器”。

在面向对象分析与设计的上下文中,将类定义为——类是对现实世界中事物的描述,类描述了拥有相同属性、行为和关系类别的一组对象,一个对象就是类的一个实例,因此没有共同的属性和行为的对象不能划分为一个类。比如,一个相当高层的抽象,一个GUI框架,一个数据库和整个系统在概念上都是独立的对象,因此不能将它们表示为一个单独的类。相反应该将这些抽象表示为一组类,通过这些类的实例互相协作,提供我们期望的功能。

通常将这样的一组类称为一个组件,而组件是预先创建好的程序模块,可与其它模块一起构成一个程序。通常组件以二进制形式发布,其实现对使用者来说是隐藏的。如果组件设计良好,使用者甚至不需要知道这个组件使用什么语言编写的。但组件必须至少暴露一个接口才能使用,通常组件会有暴露多个接口。从使用者的角度来看,一个组件是一些前端接口的后端服务者,程序员通过组件接口所暴露的函数操作该组件。由此可见,组件扩展了面向对象中对象作为服务提供者通过高层接口提供服务的概念。

在现实世界里,饼干也是对象,必须先有模子(类),才能做出你想要的形状的饼干,因此可以认为类是对象的模板。比如,只有符合一定条件的数值才能push到栈中,那么Validator校验器类就是由RangeValidator范围值校验器类、OddEvenValidator奇偶校验器类和PrimerValidator质数校验器类等具体校验器类的对象构成的一个集合体。属于类的任何对象都共享该类的所有属性,比如,所有的具体校验器都有这样的属性——校验参数。

在OO程序设计中,一个类就是一种抽象数据类型,用户也可以创建一个自己的类,而且可以将这个类当做数据类型使用。一旦有了类,就可以象使用普通的数据类型那样用类定义变量,如果定义了RangeValidator类,即可用它定义变量rangeValidator。RangeValidator类的变量rangeValidator可以拥有成员变量或域,代表不同校验器的属性或特性,通常将这些成员变量称为数据成员。

(1)值和属性

值是一段数据,属性描述了类的每个对象都拥有的一个值,可以这样类比——对象之于类如同值之于属性。比如,name、birthdate和weight都是Person对象的属性,color、modelYear和weight都是Car对象的属性。对于每个对象,每个属性都有一个值,比如,对象ZhangSan的属性birthdate的值是“21 October 1983”,也就是说,ZhangSan生于1983年10月21日。对于一个特定的属性,不同的对象可能会有相同或不同的取值。在一个类中,虽然每个属性的名字都是唯一的,但在所有的类中不一定是唯一的,比如,类Person和类Car都可能有一个名为weight的属性。

下面将介绍一种通过属性详细描述类的UML建模语言,一种用于可视化表示、指定、构造和描述软件密集系统中部件的图形化语言,它提供了一种以图形化方式表示和管理面向对象软件系统的方法。其不仅是系统设计的表示,而且是一种有助于完成系统设计的工具。类图定义了3个不同的部分,即类名、属性和方法,用于解释所构建的类。当用UML创建对象模型时,尽可能不要在类图中包含太多的信息,这样就能集中注意力于整体设计,而不会将重点放在细节上。

如图 4.1所示展示了类建模表示法,显示了一个类(左图)和它所描述的对象(右图),对象ZhangSan和LiMing都是类Person的实例。对象的UML表示法是一个方框,方框里面是对象名后加冒号和类名,对象名和类名都有下划线,并约定用黑体字表示对象名和类名。类的UML表示法也是一个方框,也约定用黑体字表示类名,将名字放在方框的正中央,首字符大写,且用单数名词表示类名。类Person有属性name和birthdate,name是string(字符串),birthdate是date(日期)。类Person中一个对象的名字取值是"Zhang San",生日取值是“21 October 1983”;另一个对象的名字取值是"Li Ming",生日取值是“16 March 1950”。

对象

图 4.1 属性和值的UML表示法

UML表示法会在框的第二格里列举属性,每个属性后面都可以有可选项,比如,类型和默认值。在类之前有一个冒号,在默认值之前有一个等号。约定以常规字体显示属性名,方框中的名称左对齐,首字母使用小写。在对象方框的第二格里,也可能会包含属性值,其表示法是列出每个属性名,之后跟着等号和取值,同样属性值也是左对齐,使用常规字体。虽然有些实现要求对象有唯一的标识符,但这些标识符在类模型中是隐含的,即不需要也不应该显式地将它们列举出来,比如,PersonID:ID。因为大多数OO开发语言会自动生成标识符,可以使用这些标识符来引用对象;反之,则可能需要显式地列举出来,否则无法引用对象。但是不要将内部标识符和现实世界的属性混淆了,内部标识符纯粹是一种便于实现的做法,没有应用意义。相反,纳税人编号、汽车牌照号码和电话号码都不是内部标识符,因为它们在实现世界有真实的意义,属于合法的属性。

(2)操作与方法

操作是一个函数或过程,比如,open和close都是Windows类的操作,类中所有的对象都共享相同的操作,因此将对象能够做什么的行为称为操作,通常将相同的操作应用于许多不同的类称为多态。

方法是对操作的实现,其表现为OOP某个类的成员函数。比如,类Validator有一个操作validate,其校验过程是通过validate调用不同的函数实现的。比如,范围值校验和奇偶校验。虽然这些方法在逻辑上都执行相同的任务——数据校验,但是每种方法的实现代码会有所不同。

如图 4.2所示RangeValidator类有min和max属性,以及validate操作,min、max和validate都是RangeValidator的特征。特征是描述属性或操作的类属词汇,类似地OddEvenValidator有isEven属性和validate操作。

对象

图 4.2 操作的UML表示法

注意,validate()省略了括号中的输入参数,即“validate(pThis:void *, value:int):bool”。validate的一个参数是pThis,其类型是void *;它的另一个参数value,其类型是int。当一项操作在几个类上都有方法时,这些方法都要有相同的签名,即相同的参数数量和类型,以及返回值的类型。

UML的方框表示类,最多有三格,从上到下每个格里分别包含了类名、属性列表和操作列表。类方框中的属性和操作的框格可以选择显示或隐藏,缺少属性说明没有指定属性,缺少操作框说明没有指定操作。相反,空框格意味着属性是指定的,只是没有显示属性而已。

操作列表约定用常规字体列出操作名,左对齐,首字母小写。比如,参数列表和操作结果的类型,用括号将参数列表括起来,并用逗号分隔参数。结果类型之前有一个冒号,除非括号中空的参数列表明确表示没有参数,否则就不能下结论。

(3)客户和服务器模式

在OOP中,如果一个类公开了一些方法供其它类调用,那么这个类被称为服务器,公开的这些方法被称为服务,而调用这些服务的类就是客户。理论上客户类调用服务器类的服务,即客户向服务器发送了一条消息。而客户和服务器的概念是相对而言的,当A类向B类提供了功能接口时,则类A是服务器,B类是客户;如果类B也同时为类A提供了功能接口,则类B是服务器,类A是客户。

设计良好的服务器应该将其实现细节隐藏起来,客户仅需知道服务器提供的接口即可。接口就是客户所能调用的那些函数,这些函数将消息发给服务器,那么服务器就知道客户需要什么样的服务,服务器会返回一些数据给客户,或执行客户所需的任务等。

(4)消息传递和方法调用

在OOP中,类和对象表现为服务器,使用类和对象的模块表现为客户。客户通过特殊的方式请求服务。那么到底如何让对象为我们做有用的事情呢?必须有一种方法能向对象做出请求,使得它能做某件事情,比如,完成交易、在屏幕上画图或打开开关。可以向对象发出的请求是由它的接口定义的,而接口是由类型定义的。

虽然接口规定了我们能向特定的对象发出什么请求,但必须有代码满足这种请求,再加上隐藏的数据就组成了实现。类型对每个可能的请求都有一个相关的函数,当向对象发出请求时,就调用这个函数。这个过程被概括为向对象“发送消息”(提出请求),对象根据这个消息确定做什么(执行代码)。

对象之间的逻辑接口通过消息传递实现,消息是对对象之间通信的的抽象。常见的消息传递方法是直接调用定义于接收方对象中的操作,比如,当对象A调用对象B的一个方法时,对象A就是在向对象B发送一个消息,对象B的响应由其返回值定义,但只有对象的公共方法才能由另一个对象调用。

使用消息传递可以实现松耦合,特别在分析阶段,不用指定接口的细节,比如,同步、函数调用格式和超时等。当全面理解了所有的问题后,接下来就可以决定设计和实现的细节了。通常对象接口可以看成对象与外部世界之间制定的契约,契约是由一组协议定义的,对象参与到这些协议中。接口协议包括前置条件、后置条件和不变量。

前置条件是当该操作被调用时,必须成立的条件。即在调用之前应该校验传入参数是否正确,只有正确才能执行该方法。也就是说,必须在消息发送或接收之前保证为真的条件,这是消息发送者的职责。一旦通过前置条件的校验方法必须执行,且必须保证执行结果符合契约,这就是后置条件。也就是说,后置条件是当该操作完成时,必须成立的条件。即在处理消息时必须保证为真,这是消息接收者的职责。不变量是指在任何时刻都必须成立的条件,包括操作执行前、执行时和执行后。

(5)属性抽象与行为抽象

OO程序设计思想可以采用抽象的方法,对现实世界中的多个具体对象进行概括分析,得到这类对象所具有的共同属性和行为,加以描述就形成了类。虽然都是同一个类的对象,但每个对象的属性不同,于是就形成了不同的具体对象实体。

抽象一般分为属性抽象和行为抽象两种,属性抽象是寻找一类对象共有的属性,比如,在范围值校验器RangeValidator类中,使用整型变量min和max来描述push到栈中的数值范围,然后将min和max变量作为类的成员变量描述对象的属性,即“属性是包含在对象中的变量”。而行为抽象则是寻找这类对象所具有的共同行为特征,比如,对push到栈中的值进行范围值校验,同样,也可以为这个类添加相应的函数,最终将该函数作为类的成员函数描述对象的行为,即“方法是包含在对象中的函数”。

在面向过程的编程中,程序是由模块组成的,一个模块就是一个过程,通常采用自顶而下的设计方法。而面向对象的编程与设计着眼于解决面向过程的编程和自顶而下设计中出现的一些问题,由于在面向对象的编程中构成模块的基本单元是类,而不是过程,因此面向对象设计是面向对象编程的设计方法,它着重于类的设计,通过类的设计完成对实体的建模任务,类建模的目的是描述对象。

在面向过程的编程中,描述一个物体时,数据和方法是分开的。比如,当通过网络发送信息时,则只会发送相关的数据,并认为网络另一端的程序知道如何进行处理。也就是说,如果两者之间没有握手协议,则网络另一端的程序不知道如何处理。而对象可以定义为“同时包含”数据和行为的一个实例,即通过封装机制将数据和行为捆绑在一起,形成一个完整的、具有属性和行为的对象。比如,当通过网络传送对象时,则传送的是整个对象。因此使用OO技术的程序实际上就是多个对象的集合,这里的“同时包含”正是OO程序设计与面向过程程序设计方法的重要区别。

由此可见,以后在分析新的对象时,都要从属性和行为两个方面进行抽象和概括,提取对象的共同特征,而整个抽象过程是一个从具体到一般的过程。如果说抽象是将很多对象的共有特征提取出来成为类的成员属性和成员函数,那么封装机制则是将这些特征进行有机地结合形成一个完整的类。

>>>4.2.3 封装

类和对象既是独立的概念,又密切相关。每个对象都是某个类的一个实例,每个类都有0或多个实例。对于所有的应用来说,类几乎都是静态的。这就意味着,对象一旦被创建,它的类就确定了。

虽然最具挑战的是如何确定类和对象,但只要正确使用面向对象分析(Ogject Oriented 

Analysis,OOA)和面向对象设计(Object Oriented Design,OOD)就能得到具有价值的领域模型和设计模型。OOA、OOD与OOP到底是什么关系?OOA的结果可以作为OOD开始的模型,OOD的结果可以作为蓝图,利用OOP方法实现一个系统。

在OOA和OOD中,不需要考虑特定的语言机制,“关键是寻找并解决业务问题,完成概念分析和设计。在OOA和OOD的早期,开发者的主要任务有两项:

  • 从需求的词汇表中确定类;

  • 创建一些结构,让多组对象一起工作,提供满足需求的行为。

通常我们将这样的类和对象统称为问题域的关键抽象,即关键抽象反映了问题域的词汇表,可以从问题域中发现,也可以作为设计的一部分发明;将这些协作结构称为实现的机制,其考虑的是许多不同类型的对象之间的协作活动。

确定关键抽象包括两个过程:发现和发明,通过与领域专家(用户)交流,将会发现领域专家所使用的抽象。如果领域专家提及它,那么这个抽象通常是很重要的,比如,范围值校验器RangeValidator。而发明就是创造新的类和对象的过程,虽然它们不一定是问题域的组成部分,但在设计或实现中也是很重要的。比如,微型数据库、链表、栈、队列等。这些关键抽象是具体设计的结果,不属于问题域。因此在设计过程中,开发者不仅需要考虑单个类的设计,还要考虑这些类的实例如何一起工作,并使用场景驱动分析过程。由此可见,关键抽象反映了业务领域的抽象,机制是设计的灵魂。

假设希望对push到栈中的值,既可以进行范围值校验,也可以进行偶校验。从面向对象的角度来看,首先要从问题的描述中发现对象,当找到对象后,接着开始通过共性和差异性分析这些对象所具有的属性和行为,然后利用面向对象的封装机制将其封装成类。

根据问题的描述,范围值校验器就是一个RangeValidator具体类,其属性是范围值校验参数min和max,其行为就是将符合范围要求的数值push到栈中。因此只要将RangeValidator的属性和行为作为成员封装到结构体中,就形成了RangeValidator类,这是面向过程编程的C程序员最容易想到,也最容易理解的方法。

为了支持这种风格,C允许将方法作为某个结构体的一部分来声明,那么操作存储在结构体中的数据就很容易了,详见程序清单 4.1。

程序清单 4.1 范围值校验器类接口

对象

其中,类名字的首字母为大写,对象名字的首字母为小写。由此可见,通过扩展已有结构体的概念创造了一个全新的概念——类,类如同种类一样,定义一个类就是在创造一个新的数据类型。虽然声明一个类的变量如同声明一个结构体的变量一样,但声明一个类的变量被称为对象,因此有了类即可声明一个RangeValidator类的对象rangeValidator。通常也称rangeValidator对象是RangeValidator类的一个实例,就是创建类的一个实例的过程。

在进行范围值校验时,首先需要判断value值是否符合要求?validateRange()函数接口的实现详见程序清单 4.2。

程序清单 4.2 范围值校验器接口函数的实现

对象

偶校验器OddEvenValidator具体类和对象oddEvenValidator的定义详见程序清单 4.3。

程序清单 4.3 偶校验器类接口

对象

在进行偶校验时,同样需要判断value值是否符合要求?validateOddEven()函数接口的实现详见程序清单 4.4。

程序清单 4.4 偶校验器接口函数的实现

对象

显然,无论是什么校验器,其共性是value值合法性判断,因此可以共用一个函数指针,即特殊的函数指针类型RangeValidate和OddEvenValidate被泛化成了一般的函数指针类型Validate。其次,由于每个函数都有一个指向当前对象的pThis指针,因此特殊的结构体类型RangeValidator *和OddEvenValidator *被泛化成了void *类型,即可接受任何类型的数据:

对象

校验器泛化接口的实现详见程序清单 4.5。

程序清单 4.5 通用校验器接口的实现(validator.c)

对象

为了便于阅读,程序清单 4.6展示了范围值校验器和奇偶校验器的接口。

程序清单 4.6 通用校验器接口(validator.h)

对象

这个接口主要由所有的操作声明构成,这些操作适用于这个类的所有对象,详见图 4.3。

对象

图 4.3 类图

以范围值校验器为例,假设min=0,max=9,使用名为newRangeValidator的宏将结构体初始化的使用方法如下:

对象

注意,RangeValidator类是在编译时定义的,而rangeValidator对象是在运行时作为类的实例创建的。宏展开后如下:

对象

其相当于:

对象

如果有以下定义:

对象

即可通过pValidator引用RangeValidator的min和max。校验函数的调用方式如下:

对象

以上调用形式的前提是已知pValidator指向了确定的结构体类型,如果pValidator将指向未知的校验器,显然以上调用形式无法做到通用,那么如何调用?

虽然pValidator与&rangeValidator.validate的类型不一样,但它们的值相等,因此可以利用这一特性获取validateRange()函数的地址。即:

对象

其调用形式如下:

对象

根据OCP开闭原则,由于不允许修改push()函数,因此需要编写一个通用的扩展push功能的pushWithValidate()函数,详见程序清单 4.7。

程序清单 4.7 pushWithValidate()

对象

其中,stack是指向当前对象(栈)的指针,用于请求对象对自身执行某些操作,而结构体的成员变量就是通过stack指针找到自己所属的对象的。pValidator为指向校验器的指针,如果无需校验,则将pValidator置NULL并返回true。

使用validator.h接口的通用校验器范例程序详见程序清单 4.8。

程序清单 4.8 通用校验器使用范例程序

对象

由此可见,虽然在结构体内置函数指针也可以创建类,但其中的每个类都是一个独立的单元,每个都要从头开始。且不同类之间没有任何关系,因为每个类的开发者都根据自己的选择提供方法。

打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分