面向对象
似乎我也没学过其他的编程思维方式了,面向对象是我编程时常用的思维方式,因为我觉得它更加贴近于我们的生活,更加容易地去理解和定义程序想要表达的内容。正是因为如此,每当项目要开启的时候,我都会使用该种思维来分析和设计程序。多年下来发现它确实有着它的魅力,帮助我解决了很多设计中的问题。于是我总结了一下,在下面章节中说说面向对象使用上的一些心得。
给你的程序分“类”
面向对象的基础就是“类”的设计,其设计好坏直接影响到程序的结构。那么,如何才能设计出合理的类型呢?本人觉得应该从实际需求出发,提取需求中各种实体对象,最终形成程序中使用的对象。某些同学喜欢为自己的程序埋点,添加很多为日后作扩展的类型,其实我是不建议过度设计,我觉得可以在需求中提及的实体预留一些功能性的扩展,但是不建议为需求中没有的功能点设计类型,因为,你永远都把握不准产品日后的发展会是什么样子的,最终形态是怎么样,也有可能你埋下的点还没来得及实现,程序就需要进行重构了等等的情况都会导致你的额外工作变成无用功。
假如公司要求你做一个附近的陌生人交友的App。从这个App的需求,可以提取出附近、陌生人以及交友三个关键字,附近描述的是一种相对的地理位置所以它应该是一个类的属性,而陌生人也是相对于某个人来说的一种人与人的关系,所以这里会被分解为一个人的实体,还有一个与其它人实体的关系。而最后一个交友关键字其实隐含了一种建立关系的行为,这里可以是聊天,也可以是其它的交友方式。我们再进一步分析,如果App里面提供了聊天功能,那么需求中还隐含了一个实体,那就是聊天的内容。所以,根据需求这些点,我们可以设计出两个类型:
//用户类型
class User
{
//地理位置
property location : Location;
//用户的好友
property friends : Array<User>;
//给某个用户发送消息
function sendMessage(user : User, message : Message) : void;
}
//聊天内容
class Message
{
}
这就由需求所驱动产生的类型,以这些类型为核心类围绕需求进行补充和扩展来不断满足后续的需求。
让类型也符合数据库实体设计
关系数据库设计中常会提及第三范式,其特点是去除表中直接依赖。这意思是说两个实体对象不应该被设计在同一个表中,如:评论表中有记录是某个用户发表的评论,可能会把用户名称等信息归类到评论表中,那这样是不符合第三范式要求的,正确的做法是把用户信息放入用户表,评论表中可以保留一个用户id来标识用户表中的用户记录,从而取消在评论表中直接依赖用户信息,导致用户信息的冗余。
其实我个人觉得这种范式要求很好地划分了实体之间的界线,对于类的设计也同样适用。譬如:你定义了一个人类,该类型有四肢属性和说话的行为。这样很好理解,因为现实中人就具备这些特性。但是在后续的开发过程中由于需求的变动,出现了人类因为有飞行道具可以进行飞行。而把飞行的行为直接赋予人类,这种图省事的做法其实是存在很多弊端的:
- 首先是不容易读懂,因为常识性的认为人是不会飞的,人会飞的话那肯定是个鸟人。所以会让人摸不着头脑。
- 再者就是这样的定义往往会限制其它跟你协作开发的同事。因为大多数情况下我们都会秉持着能尽量不动旧代码就不要去动,以免动出问题来。那么你这样的设计就让人不得不按照现有的思维去进行扩展。直到哪一天是在没有办法了,再进行彻底的重构。
所以更优的做法应该是定义出一个道具类。至于道具实现什么功能,由其子类实现:
// 道具基类
class Item
{
//道具的行为
function action(target : Person);
}
//飞行道具
class FlyingItem :Item
{
function action(target : Person)
{
//飞行的实现代码
}
}
类似这样的实现就可以分离人类和道具类型的耦合,而且也提高了道具类型的扩展。所以,在设计类型时一定要给实体分清界线。
另外一个数据库设计时使用较多的就是实体的关系描述。实体的关系不同,在设计表存储数据时也有所不同。通常有三种实体关系:
- 一对一,即两个实体之间的关系是一一对应的。如:一个中国公民跟他的身份证是唯一绑定的,知道身份证就能够找到对应的人,同样对应的人也能够找到与其对应的唯一的身份证。类似这种关系,通常是建立两张实体数据表,然后关系可以同时放两个实体表中。
- 一对多,即两种实体之间,第一种实体可以对应多个第二种实体,但是第二种实体只能对应唯一的第一种实体。如用户评论的例子,用户可以发多条评论,但是一条评论只能够是特定的一个用户发出来。对于这种关系,通常也是建立两张实体数据表,然后会在对应多个实体的数据表中记录关系,拿上面的例子来说,就是用户表和评论表,然后评论表会放入一个userId之类的标识来记录与用户的对应关系。
- 多对多,即两种实体之间,第一种实体可以对应多个第二种实体,第二种实体也可以对应多个第一种实体。例如作者和书籍的关系就是一种多对多的关系,一个作者可以写多本书,同时,一本书有可能有多个作者共同写成。对于这种关系,则需要建立两种实体数据表以及一张关系表来进行描述。
说了这么多实体关系的东西,其实最想说的一点就是,在进行面向对象分析和设计时,关系的分析也是不可缺少的。但是需要怎么来评估关系的划分和归属,我觉得按照数据库的关系设计标准来进行也是一种不错的方法。
- 一对一,在该种关系下,其实关系的描述可以同时放在两个类型上,如上面所说的中国公民与身份证的例子,用类可以描述为:
class Chinese
{
//姓名
property name:String;
//对应的身份证
property idCard:IDCard;
}
class IDCard
{
//身份证号
property id:String;
//对应的人
property person:Chinese;
}
- 一对多,该种关系下,稍微与数据库表设计有点不同,由于数据表可以通过查询的方式来找出实体对应的多个另一种实体,这已经超出了类设计阶段的范畴。因此,对应多个类实例的类型需要定义一个集合的属性来保留对应的多个类实例。这样在阅读代码时也能够很好理解,拿用户评论的例子来说:
class User
{
property comments:Set<Comment>;
}
class Comment
{
property user:User;
}
类似这样能够很好的描述用户进行过多少的评论,然后哪条评论是哪个用户评论的。
- 多对多,这是一种很复杂的关系,主要是很多情况下,我们都会忽略这样的一种关系,把它直接给变成了一对多的关系设计。其实对于种情况我们都可以将其关系的处理交由另外一个类型进行处理,与数据库表设计一样,两个实体类型,一个关系类型。拿刚才作者跟书籍的关系为例,可以设计成下面的样子:
//作者书籍关系管理类型
class AuthorBookRelationshipManager
{
//设置书籍的作者信息
static function setBookAuthor(book : Book, authors : Array<Author>) : void;
//获取指定书籍的作者
static function getAuthors(book : Book) : Array<Author>;
//获取指定作者的书籍
static function getBooks(author : Author) : Array<Book>;
}
//作者类型
class Author
{
//作者名称
property name : String;
//获取作者出版过的书籍
function getBooks() : Array<Book>
{
return AuthorBookRelationshipManager.getBooks(this);
}
}
//书籍类型
class Book
{
//书籍名称
property name : String;
//获取书籍的作者信息
function getAuthors():Array<Author>
{
return AuthorBookRelationshipManager.getAuthors(this);
}
}
上面的代码可以看出,使用了一个AuthorBookRelationshipManager类型来管理书籍和作者的关系。然后实体类型不再保存之间的关系,要获取相关的实体需要通过AuthorBookRelationshipManager类型来获取。
上面所说的实体划分和关系划分在面向对象中有着非常重要的意义。平时要多加练习才能够在面临实际项目开发时做出合理的分类,让自己开发的架构更加灵活更加强大。
以上对于类型的设计基本上是讲完了,是时候要说说继承方面的事情了,这是面向对象的一个很强大的特性。它可以改善代码结构的问题,节省代码的书写工作量。下面将会围绕它来聊聊我的看法。
万物的始祖
其实不管是写C++、C#还是写Java等等这些高级语言,我们都会发现只要是创建的类它们都会直接或者间接地继承自Object这个类。而这个Object类就是我这要说的万物的始祖,其被所有的事物所继承是一个根类型。Object这个名字也起得相当好,泛指了所有的物体,给予了一种无形态的抽象的定义。意味着我们作为程序中的造物主,需要将这种无形态的物体,变化成各种具体形态的事物,这就需要继承。
那么,Object中所做的功能其实并不多,因为它不涉及具体的功能,因此它很多时候在实现上面仅仅能区分是否是同一个对象isEqual
,又或者是告诉我们它的描述toString
。但是这样已经是有很大的引导意义,因为只要继承了Object类型,那么你的类型将会得到这样功能,阐述了继承功能的强大;同时,你还可以把你的子类型对象赋予给一个Object的实例,因为Object是根类,它可以保留任意的子类类型对象,这是面向对象多态的基本体现。
有了Object这样的一个例子,更加表明了我们在开发项目过程中也应该为项目的代码设计基类。而这里的基类要比Object的功能稍微具体一点。因为它涉及到具体要实现的功能需求,而且还应该不止是一个基类。下面来说一下我是怎么样设计基类的。
和基类来个约定
要怎么设计基类,其实还是要根据实际的项目需求而定。假设你所开发的项目包含很多的后台服务,那么就应该定义一个叫BaseService
的基类,然后通过这个基类来分化成多种服务类型。如果你的项目涉及到多种UI的处理,那么你就应该考虑可能需要一个基础的UI管理类型(通常UI组件有自己的基类,这里需要的是对UI进行管理和维护的基础类型),如:UIController
。然后根据这个基类分化出各种的UI展现。
基本上是根据如果程序存在多种特性相近或相似的功能模块,那么就应该为这些模块建立基类,以便日后更好的进行扩展。这里不建议要给未来有可能会有多种近似功能的模块定义基类,还是老话,你的假设不一定成立。而且等到出现这样的需求在进行定义也不迟,毕竟要改造的也只是一个类型而已。
上面我们知道如何去抽象出基类,那么基类应该要如何封装属性和方法,其实完全可以视其子类而定,因为这样是最贴近需求的。如前面所说的多种后台服务,假设有如下服务类型:
class ServiceA
{
//服务名称
property name:String;
//调用服务
function exec(config:Dicationary):void
{
//执行服务
}
}
class ServiceB
{
//服务名称
property name:String;
//调用服务
function call():void
{
//执行服务
}
}
上面所描述的服务从代码结构上面其实不大相同,特别是在执行服务的行为上接收参数也不一样。如果要从它们身上抽象出基类,确实会让人有所困惑。但是,我们既然要抽象基类,那么就肯定会改造子类。我自己总结一些抽象的规则:
- 子类中相同的属性和方法,要封装到基类中。
- 对于大多数子类存在的属性和方法(如5个子类中有3个以上类型存在相同的属性和方法),可以考虑封装到基类中,没用到类型可以对基类的属性和方法进行忽略。
- 对于意义相近的属性,可以考虑将属性合并或替换(如:子类中分别用id或者name属性来表示对象的唯一,那么基类可以考虑只取其中一项属性或者创建一个集合两者的属性定义)。
- 对于子类相同行为的方法,如果声明的方法参数的数量或类型不同时,可以考虑基类的方法集合子类该方法中的所有参数(即取方法参数的并集)或者考虑定义共有参数,特殊参数则由子类转换为属性来实现(一般可以持续持有的参数可以这样设计,如系统的配置)。
基于上面所说的,我们可以把ServiceA
和ServiceB
,通过抽象基类改成下面的样子:
class BaseService
{
//服务名称
property name:String;
//调用服务
function exec():void;
}
class ServiceA extends BaseService
{
//这里将参数设定为属性
property config:Dictionary;
function exec():void
{
//执行服务
}
}
class ServiceB extends BaseService
{
function exec():void
{
//执行服务
}
}
类似这样,我们就可以把基类给抽象出来了。
让类型进化
在开发和维护代码的过程里面,难免会碰到由于产品的迭代,导致需求发生重大的变更。那么可能会出现某些功能的废弃或者功能的合并或演进。这时候,之前定义的类型就要面临着重构或者调整的命运。
遇到这样的问题我们先别急着把之前的东西全盘否定,然后彻底重写。而是先考虑之前的定义中是否还存在可用的东西。要把可以重用的模块给提取出来,那么,模块的取舍就是我们需要评估的事情。
如果新的需求中已经废弃的功能,那么模块所涉及的类型都可以进行废弃。对于这些模块的处理,我个人比较喜欢对相关的类型进行直接删除,然后进行编译,报错后直接修改引用到该类型的代码,直到编译成功为止。对于缺失该类型后需要改造的方法,我会暂时不进行改造,而是打上标记(如TODO或者warning宏),等待正式开发新功能时再寻找会这些标记一一进行解决。
如果新需求中包含该类功能,但是又融合了一些新的元素,那么,我们的类型还不能直接拿来使用。要评估新的元素是否作为类型的一部分,还是作一种新的类型。例如:一款聊天应用,刚开始的时候只有私聊(一对一聊天),那么,发送消息就在User类型中:
//用户类型
class User
{
//给某个用户发送消息
function sendMessage(user : User, message : Message) : void;
}
到了第二个版本的时候,可能支持用户给好友群发消息。那么,明显上面的代码不能满足需求,这时候需要对类型进行改造:
//用户类型
class User
{
//给某个用户发送消息
function sendMessage(user : User, message : Message) : void;
//给多个用户发送消息
function sendMessage(users : Arrray<User>, message : Message) : void
{
for (User user in users)
{
this.sendMessage(user, message);
}
}
}
增加了一个sendMessage多态版本的方法用于群发消息。这样既能保证方法的出口统一,又能保证之前的方法不被修改,就可以轻松地解决群发问题。
那么,到了第三个版本的时候出现了聊天室的概念,这时候需求中多了一个聊天室的关键字,运用之前提到的方法,这里应该需要新增一个类型,不能直接在User类型中进行扩展。那么,可以设计为:
//聊天室
class ChatRoom
{
//聊天室名称
property name:String;
//聊天室用户
property users:Array<User>;
//发送消息
function sendMessage(user:User, message:Message):void;
}
这里增加了一个ChatRoom
的类型,主要是用来记录那些人在哪个聊天室中。该类型有一个sendMessage
的方法,用于表示哪个用户要在聊天室中发言,发言的内容是什么。
由上面的例子可以看出来,产品迭代有时候需要让类型进行,有时候也需要诞生新的类型。要做那种选择则需要根据产品需求来决定。
只要这种多态
多态在我的理解中就是多种状态。就好比我们用手拿东西的时候,如果拿的是球,那么我们可能是想要打球,如果拿的是苹果,那么我们可能要把它吃掉。根据不同东西我们可能会作出不同的反映。在面向对象中多态就是用于解决这类的问题。我们来假设一下没有多态的时候,我们的代码看起来会是多挫:
function doSomething(obj:Object):void
{
if (obj instanceof ObjectA)
{
//do something by ObjectA
}
else if (obj instanceof ObjectB)
{
//do something by ObjectB
}
}
要判断的类型越多,if的语句就越长。那么有了多态后,我们可以很优雅地设计:
function doSomething(obj:ObjectA):void
{
//do something by ObjectA
}
function doSomething(obj:ObjectB):void
{
//do something by ObjectB
}
多态可以保证代码设计的出口名字统一,不需要外部调用的人要根据不同的类型调用不同名字的方法,对于外部调用只需要传入的类型不同即可决定要调用哪个方法。
如果不是解决上面所说的问题,我不建议使用多态。因为多态会使到类型变得复杂,如果这个使用多态的类型被继承,然后继承的子类进行了多态处理,那么会影响到程序的质量,并且一旦出现问题,排查的难度会有所增加。
后话
以上说的都是我个人在这些年的开发中所理解的东西,面向对象这东西已经存在我脑海里面许多个日夜了,总想着写些什么,今天总算把它给完成了。后续我会继续写下其他的一些关于程序思维的文章,希望大家支持。
写这篇文章的时候没有参考其他资料,可能存在错漏的地方。如果你是一位好心的猿/媛,麻烦给我指正一下。