设计模式之美(面向对象)

理论一:当谈论面向对象的时候,我们到底在谈论什么?

考虑到各个水平层次的同学,并且保证专栏内容的系统性、全面性,我会循序渐进地讲解跟设计模式相关的所有内容。所以,专栏正文的第一个模块,我会讲一些设计原则、设计思想,比如,面向对象设计思想、经典设计原则以及重构相关的知识,为之后学习设计模式做铺垫。

在第一个模块中,我们又首先会讲到面向对象相关的理论知识。提到面向对象,我相信很多人都不陌生,随口都可以说出面向对象的四大特性:封装、抽象、继承、多态。实际上,面向对象这个概念包含的内容还不止这些。所以,今天我打算花一节课的时间,先大概跟你聊一下,当我们谈论面向对象的时候,经常会谈到的一些概念和知识点,为学习后面的几节更加细化的内容做一个铺垫。

特别说明一下,对于今天讲到的概念和知识点,大部分我都是点到为止,并没有展开详细讲解。如果你看了之后,对某个概念和知识点还不是很清楚,那也没有关系。在后面的几节课中,我会花更多的篇幅,对今天讲到的每个概念和知识点,结合具体的例子,一一做详细的讲解。

什么是面向对象编程和面向对象编程语言?

面向对象编程的英文缩写是OOP,全称是Object Oriented Programming。对应地,面向对象编程语言的英文缩写是OOPL,全称是Object Oriented Programming Language。

面向对象编程中有两个非常重要、非常基础的概念,那就是类(class)和对象(object)。这两个概念最早出现在1960年,在Simula这种编程语言中第一次使用。而面向对象编程这个概念第一次被使用是在Smalltalk这种编程语言中。Smalltalk被认为是第一个真正意义上的面向对象编程语言。

1980年左右,C++的出现,带动了面向对象编程的流行,也使得面向对象编程被越来越多的人认可。直到今天,如果不按照严格的定义来说,大部分编程语言都是面向对象编程语言,比如Java、C++、Go、Python、C#、Ruby、JavaScript、Objective-C、Scala、PHP、Perl等等。除此之外,大部分程序员在开发项目的时候,都是基于面向对象编程语言进行的面向对象编程。

以上是面向对象编程的大概发展历史。在刚刚的描述中,我着重提到了两个概念,面向对象编程和面向对象编程语言。那究竟什么是面向对象编程?什么语言才算是面向对象编程语言呢?如果非得给出一个定义的话,我觉得可以用下面两句话来概括。
1、面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
2、面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。
一般来讲, 面向对象编程都是通过使用面向对象编程语言来进行的,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。这里听起来是不是有点绕?不过没关系,我们在后面的第7节课中,会详细讲解这个问题。

除此之外,从定义中,我们还可以发现,理解面向对象编程及面向对象编程语言两个概念,其中最关键的一点就是理解面向对象编程的四大特性。这四大特性分别是:封装、抽象、继承、多态。不过,关于面向对象编程的特性,也有另外一种说法,那就是只包含三大特性:封装、继承、多态,不包含抽象。为什么会有这种分歧呢?抽象为什么可以排除在面向对象编程特性之外呢?关于这个问题,在下一节课详细讲解这四大特性的时候,我还会再拿出来说一下。不过,话说回来,实际上,我们没必要纠结到底是四大特性还是三大特性,关键还是理解每种特性讲的是什么内容、存在的意义以及能解决什么问题。

而且,在技术圈里,封装、抽象、继承、多态也并不是固定地被叫作“四大特性”(features),也有人称它们为面向对象编程的四大概念(concepts)、四大基石(cornerstones)、四大基础(fundamentals)、四大支柱(pillars)等等。你也发现了吧,叫法挺混乱的。不过,叫什么并不重要。我们只需要知道,这是前人进行面向对象编程过程中总结出来的、能让我们更容易地实现各种设计思路的几个编程套路,这就够了。在之后的课程讲解中,我统一把它们叫作“四大特性”。

如何判定某编程语言是否是面向对象编程语言?

如果你足够细心,你可能已经留意到,在我刚刚的讲解中,我提到,“如果不按照严格的定义来说,大部分编程语言都是面向对象编程语言”。为什么要加上“如果不按照严格的定义”这个前提呢?那是因为,如果按照刚刚我们给出的严格的面向对象编程语言的定义,前面提到的有些编程语言,并不是严格意义上的面向对象编程语言,比如JavaScript,它不支持封装和继承特性,按照严格的定义,它不算是面向对象编程语言,但在某种意义上,它又可以算得上是一种面向对象编程语言。我为什么这么说呢?到底该如何判断一个编程语言是否是面向对象编程语言呢?

还记得我们前面给出的面向对象编程及面向对象编程语言的定义吗?如果忘记了,你可以先翻到上面回顾一下。不过,我必须坦诚告诉你,那个定义是我自己给出的。实际上,对于什么是面向对象编程、什么是面向对象编程语言,并没有一个官方的、统一的定义。而且,从1960年,也就是60年前面向对象编程诞生开始,这两个概念就在不停地演化,所以,也无法给出一个明确的定义,也没有必要给出一个明确定义。

**实际上,面向对象编程从字面上,按照最简单、最原始的方式来理解,就是将对象或类作为代码组织的基本单元,来进行编程的一种编程范式或者编程风格,并不一定需要封装、抽象、继承、多态这四大特性的支持。**但是,在进行面向对象编程的过程中,人们不停地总结发现,有了这四大特性,我们就能更容易地实现各种面向对象的代码设计思路。

比如,我们在面向对象编程的过程中,经常会遇到is-a这种类关系(比如狗是一种动物),而继承这个特性就能很好地支持这种is-a的代码设计思路,并且解决代码复用的问题,所以,继承就成了面向对象编程的四大特性之一。但是随着编程语言的不断迭代、演化,人们发现继承这种特性容易造成层次不清、代码混乱,所以,很多编程语言在设计的时候就开始摒弃继承特性,比如Go语言。但是,我们并不能因为它摒弃了继承特性,就一刀切地认为它不是面向对象编程语言了。

实际上,我个人觉得,只要某种编程语言支持类或对象的语法概念,并且以此作为组织代码的基本单元,那就可以被粗略地认为它就是面向对象编程语言了。至于是否有现成的语法机制,完全地支持了面向对象编程的四大特性、是否对四大特性有所取舍和优化,可以不作为判定的标准。基于此,我们才有了前面的说法,按照严格的定义,很多语言都不能算得上面向对象编程语言,但按照不严格的定义来讲,现在流行的大部分编程语言都是面向对象编程语言。

所以,多说一句,关于这个问题,我们一定不要过于学院派,非要给面向对象编程、面向对象编程语言下个死定义,非得对某种语言是否是面向对象编程语言争个一清二白,这样做意义不大。

什么是面向对象分析和面向对象设计?

前面我们讲了面向对象编程(OOP),实际上,跟面向对象编程经常放到一块儿来讲的还有另外两个概念,那就是面向对象分析(OOA)和面向对象设计(OOD)。面向对象分析英文缩写是OOA,全称是Object Oriented Analysis;面向对象设计的英文缩写是OOD,全称是Object Oriented Design。OOA、OOD、OOP三个连在一起就是面向对象分析、设计、编程(实现),正好是面向对象软件开发要经历的三个阶段。

关于什么是面向对象编程,我们前面已经讲过了。我们现在再来讲一下,什么是面向对象分析和设计。这两个概念相对来说要简单一些。面向对象分析与设计中的“分析”和“设计”这两个词,我们完全可以从字面上去理解,不需要过度解读,简单类比软件开发中的需求分析、系统设计即可。不过,你可能会说,那为啥前面还加了个修饰词“面向对象”呢?有什么特殊的意义吗?

之所以在前面加“面向对象”这几个字,是因为我们是围绕着对象或类来做需求分析和设计的。分析和设计两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法,类与类之间如何交互等等。它们比其他的分析和设计更加具体、更加落地、更加贴近编码,更能够顺利地过渡到面向对象编程环节。这也是面向对象分析和设计,与其他分析和设计最大的不同点。

看到这里,你可能会问,那面向对象分析、设计、编程到底都负责做哪些工作呢?简单点讲,**面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做,面向对象编程就是将分析和设计的的结果翻译成代码的过程。**今天,我们只是简单介绍一下概念,不展开详细讲解。在后面的面向对象实战环节中,我会用两节课的时间,通过一个实际例子,详细讲解如何进行面向对象分析、设计和编程。

什么是UML?我们是否需要UML?

讲到面向对象分析、设计、编程,我们就不得不提到另外一个概念,那就是UML(Unified Model Language),统一建模语言。很多讲解面向对象或设计模式的书籍,常用它来画图表达面向对象或设计模式的设计思路。

实际上,UML是一种非常复杂的东西。它不仅仅包含我们常提到类图,还有用例图、顺序图、活动图、状态图、组件图等。在我看来,即便仅仅使用类图,学习成本也是很高的。就单说类之间的关系,UML就定义了很多种,比如泛化、实现、关联、聚合、组合、依赖等。

要想完全掌握,并且熟练运用这些类之间的关系,来画UML类图,肯定要花很多的学习精力。而且,UML作为一种沟通工具,即便你能完全按照UML规范来画类图,可对于不熟悉的人来说,看懂的成本也还是很高的。

所以,从我的开发经验来说,UML在互联网公司的项目开发中,用处可能并不大。为了文档化软件设计或者方便讨论软件设计,大部分情况下,我们随手画个不那么规范的草图,能够达意,方便沟通就够了,而完全按照UML规范来将草图标准化,所付出的代价是不值得的。

所以,我这里特别说明一下,专栏中的很多类图我并没有完全遵守UML的规范标准。为了兼顾图的表达能力和你的学习成本,我对UML类图规范做了简化,并配上了详细的文字解释,力图让你一眼就能看懂,而非适得其反,让图加重你的学习成本。毕竟,我们的专栏并不是一个讲方法论的教程,专栏中的所有类图,本质是让你更清晰地理解设计。

思考

1.什么是面向对象编程?

面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。

2.什么是面向对象编程语言?

面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。

3.如何判定一个编程语言是否是面向对象编程语言?

如果按照严格的的定义,需要有现成的语法支持类、对象、四大特性才能叫作面向对象编程语言。如果放宽要求的话,只要某种编程语言支持类、对象语法机制,那基本上就可以说这种编程语言是面向对象编程语言了,不一定非得要求具有所有的四大特性。

4.面向对象编程和面向对象编程语言之间有何关系?

面向对象编程一般使用面向对象编程语言来进行,但是,不用面向对象编程语言,我们照样可以进行面向对象编程。反过来讲,即便我们使用面向对象编程语言,写出来的代码也不一定是面向对象编程风格的,也有可能是面向过程编程风格的。

5.什么是面向对象分析和面向对象设计?

简单点讲,面向对象分析就是要搞清楚做什么,面向对象设计就是要搞清楚怎么做。两个阶段最终的产出是类的设计,包括程序被拆解为哪些类,每个类有哪些属性方法、类与类之间如何交互等等。

讨论

1、在文章中,我讲到UML的学习成本很高,沟通成本也不低,不推荐在面向对象分析、设计的过程中使用,对此你有何看法?
2、有关面向对象的概念和知识点,除了我们今天讲到的,你还能想到其他哪些吗?

理论二:封装、抽象、继承、多态分别可以解决哪些编程问题?

上一节课,我简单介绍了面向对象的一些基本概念和知识点,比如,什么是面向对象编程,什么是面向对象编程语言等等。其中,我们还提到,理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承、多态。不过,对于这四大特性,光知道它们的定义是不够的,我们还要知道每个特性存在的意义和目的,以及它们能解决哪些编程问题。所以,今天我就花一节课的时间,针对每种特性,结合实际的代码,带你将这些问题搞清楚。

这里我要强调一下,对于这四大特性,尽管大部分面向对象编程语言都提供了相应的语法机制来支持,但不同的编程语言实现这四大特性的语法机制可能会有所不同。所以,今天,我们在讲解四大特性的时候,并不与具体某种编程语言的特定语法相挂钩,同时,也希望你不要局限在你自己熟悉的编程语言的语法思维框架里。

封装(Encapsulation)

首先,我们来看封装特性。封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。这句话怎么理解呢?我们通过一个简单的例子来解释一下。

下面这段代码是金融系统中一个简化版的虚拟钱包的代码实现。在金融系统中,我们会给每个用户创建一个虚拟钱包,用来记录用户在我们的系统中的虚拟货币量。对于虚拟钱包的业务背景,这里你只需要简单了解一下即可。在面向对象的实战篇中,我们会有单独两节课,利用OOP的设计思想来详细介绍虚拟钱包的设计实现。

public class Wallet {
  private String id;
  private long createTime;
  private BigDecimal balance;
  private long balanceLastModifiedTime;
  // ...省略其他属性...

  public Wallet() {
     this.id = IdGenerator.getInstance().generate();
     this.createTime = System.currentTimeMillis();
     this.balance = BigDecimal.ZERO;
     this.balanceLastModifiedTime = System.currentTimeMillis();
  }

  // 注意:下面对get方法做了代码折叠,是为了减少代码所占文章的篇幅
  public String getId() { return this.id; }
  public long getCreateTime() { return this.createTime; }
  public BigDecimal getBalance() { return this.balance; }
  public long getBalanceLastModifiedTime() { return this.balanceLastModifiedTime;  }

  public void increaseBalance(BigDecimal increasedAmount) {
    if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) {
      throw new InvalidAmountException("...");
    }
    this.balance.add(increasedAmount);
    this.balanceLastModifiedTime = System.currentTimeMillis();
  }

  public void decreaseBalance(BigDecimal decreasedAmount) {
    if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
      throw new InvalidAmountException("...");
    }
    if (decreasedAmount.compareTo(this.balance) > 0) {
      throw new InsufficientAmountException("...");
    }
    this.balance.subtract(decreasedAmount);
    this.balanceLastModifiedTime = System.currentTimeMillis();
  }
}

从代码中,我们可以发现,Wallet类主要有四个属性(也可以叫作成员变量),也就是我们前面定义中提到的信息或者数据。其中,id表示钱包的唯一编号,createTime表示钱包创建的时间,balance表示钱包中的余额,balanceLastModifiedTime表示上次钱包余额变更的时间。

我们参照封装特性,对钱包的这四个属性的访问方式进行了限制。调用者只允许通过下面这六个方法来访问或者修改钱包里的数据。

String getId()
long getCreateTime()
BigDecimal getBalance()
long getBalanceLastModifiedTime()
void increaseBalance(BigDecimal increasedAmount)
void decreaseBalance(BigDecimal decreasedAmount)

之所以这样设计,是因为从业务的角度来说,id、createTime在创建钱包的时候就确定好了,之后不应该再被改动,所以,我们并没有在Wallet类中,暴露id、createTime这两个属性的任何修改方法,比如set方法。而且,这两个属性的初始化设置,对于Wallet类的调用者来说,也应该是透明的,所以,我们在Wallet类的构造函数内部将其初始化设置好,而不是通过构造函数的参数来外部赋值。

对于钱包余额balance这个属性,从业务的角度来说,只能增或者减,不会被重新设置。所以,我们在Wallet类中,只暴露了increaseBalance()和decreaseBalance()方法,并没有暴露set方法。对于balanceLastModifiedTime这个属性,它完全是跟balance这个属性的修改操作绑定在一起的。只有在balance修改的时候,这个属性才会被修改。所以,我们把balanceLastModifiedTime这个属性的修改操作完全封装在了increaseBalance()和decreaseBalance()两个方法中,不对外暴露任何修改这个属性的方法和业务细节。这样也可以保证balance和balanceLastModifiedTime两个数据的一致性。

对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。例子中的private、public等关键字就是Java语言中的访问权限控制语法。private关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问。如果Java语言没有提供访问权限控制语法,所有的属性默认都是public的,那任意外部代码都可以通过类似wallet.id=123;这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。

如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下,在某段代码中“偷偷地”重设了wallet中的balanceLastModifiedTime属性,这就会导致balance和balanceLastModifiedTime两个数据不一致。

封装特性的定义讲完了,我们再来看一下,封装的意义是什么?它能解决什么编程问题?

除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。

抽象(Abstraction)

讲完了封装特性,我们再来看抽象特性。 封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

在面向对象编程中,我们常借助编程语言提供的接口类(比如Java中的interface关键字语法)或者抽象类(比如Java中的abstract关键字语法)这两种语法机制,来实现抽象这一特性。

这里我稍微说明一下,在专栏中,我们把编程语言提供的接口语法叫作“接口类”而不是“接口”。之所以这么做,是因为“接口”这个词太泛化,可以指好多概念,比如API接口等,所以,我们用“接口类”特指编程语言提供的接口语法。

对于抽象这个特性,我举一个例子来进一步解释一下。

public interface IPictureStorage {
  void savePicture(Picture picture);
  Image getPicture(String pictureId);
  void deletePicture(String pictureId);
  void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}

public class PictureStorage implements IPictureStorage {
  // ...省略其他属性...
  @Override
  public void savePicture(Picture picture) { ... }
  @Override
  public Image getPicture(String pictureId) { ... }
  @Override
  public void deletePicture(String pictureId) { ... }
  @Override
  public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
}

在上面的这段代码中,我们利用Java中的interface接口语法来实现抽象特性。调用者在使用图片存储功能的时候,只需要了解IPictureStorage这个接口类暴露了哪些方法就可以了,不需要去查看PictureStorage类里的具体实现逻辑。

实际上,抽象这个特性是非常容易实现的,并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说,并不是说一定要为实现类(PictureStorage)抽象出接口类(IPictureStorage),才叫作抽象。即便不编写IPictureStorage接口类,单纯的PictureStorage类本身就满足抽象特性。

之所以这么说,那是因为,类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑,这本身就是一种抽象。调用者在使用函数的时候,并不需要去研究函数内部的实现逻辑,只需要通过函数的命名、注释或者文档,了解其提供了什么功能,就可以直接使用了。比如,我们在使用C语言的malloc()函数的时候,并不需要了解它的底层代码是怎么实现的。

除此之外,在上一节课中,我们还提到,抽象有时候会被排除在面向对象的四大特性之外,当时我卖了一个关子,现在我就来解释一下为什么。

抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。

抽象特性的定义讲完了,我们再来看一下,抽象的意义是什么?它能解决什么编程问题?

实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。

除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。我们在讲到后面的内容的时候,会具体来解释。

换一个角度来考虑,我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。举个简单例子,比如getAliyunPictureUrl()就不是一个具有抽象思维的命名,因为某一天如果我们不再把图片存储在阿里云上,而是存储在私有云上,那这个命名也要随之被修改。相反,如果我们定义一个比较抽象的函数,比如叫作getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。

继承(Inheritance)

学习完了封装和抽象两个特性,我们再来看继承特性。如果你熟悉的是类似Java、C++这样的面向对象的编程语言,那你对继承这一特性,应该不陌生了。继承是用来表示类之间的is-a关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。

为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如Java使用extends关键字来实现继承,C++使用冒号(class B : public A),Python使用parentheses (),Ruby使用<。不过,有些编程语言只支持单继承,不支持多重继承,比如Java、PHP、C#、Ruby等,而有些编程语言既支持单重继承,也支持多重继承,比如C++、Python、Perl等。

为什么有些语言支持多重继承,有些语言不支持呢?这个问题留给你自己去研究,你可以针对你熟悉的编程语言,在留言区写一写具体的原因。

继承特性的定义讲完了,我们再来看,继承存在的意义是什么?它能解决什么编程问题?

继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。

如果我们再上升一个思维层面,去思考继承这一特性,可以这么理解:我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种is-a关系。我们通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。

继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。

所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。关于这个问题,在后面讲到“多用组合少用继承”这种设计思想的时候,我会非常详细地再讲解,这里暂时就不展开讲解了。

多态(Polymorphism)

学习完了封装、抽象、继承之后,我们再来看面向对象编程的最后一个特性,多态。多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。对于多态这种特性,纯文字解释不好理解,我们还是看一个具体的例子。

public class DynamicArray {
  private static final int DEFAULT_CAPACITY = 10;
  protected int size = 0;
  protected int capacity = DEFAULT_CAPACITY;
  protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
  
  public int size() { return this.size; }
  public Integer get(int index) { return elements[index];}
  //...省略n多方法...
  
  public void add(Integer e) {
    ensureCapacity();
    elements[size++] = e;
  }
  
  protected void ensureCapacity() {
    //...如果数组满了就扩容...代码省略...
  }
}

public class SortedDynamicArray extends DynamicArray {
  @Override
  public void add(Integer e) {
    ensureCapacity();
    int i;
    for (i = size-1; i>=0; --i) { //保证数组中的数据有序
      if (elements[i] > e) {
        elements[i+1] = elements[i];
      } else {
        break;
      }
    }
    elements[i+1] = e;
    ++size;
  }
}

public class Example {
  public static void test(DynamicArray dynamicArray) {
    dynamicArray.add(5);
    dynamicArray.add(1);
    dynamicArray.add(3);
    for (int i = 0; i < dynamicArray.size(); ++i) {
      System.out.println(dynamicArray.get(i));
    }
  }
  
  public static void main(String args[]) {
    DynamicArray dynamicArray = new SortedDynamicArray();
    test(dynamicArray); // 打印结果:1、3、5
  }
}

多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。

1、第一个语法机制是编程语言要支持父类对象可以引用子类对象,也就是可以将SortedDynamicArray传递给DynamicArray。
2、第二个语法机制是编程语言要支持继承,也就是SortedDynamicArray继承了DynamicArray,才能将SortedDyamicArray传递给DynamicArray。
3、第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法,也就是SortedDyamicArray重写了DynamicArray中的add()方法。

通过这三种语法机制配合在一起,我们就实现了在test()方法中,子类SortedDyamicArray替换父类DynamicArray,执行子类SortedDyamicArray的add()方法,也就是实现了多态特性。

对于多态特性的实现方式,除了利用“继承加方法重写”这种实现方式之外,我们还有其他两种比较常见的的实现方式,一个是利用接口类语法,另一个是利用duck-typing语法。不过,并不是每种编程语言都支持接口类或者duck-typing这两种语法机制,比如C++就不支持接口类语法,而duck-typing只有一些动态语言才支持,比如Python、JavaScript等。

**接下来,我们先来看如何利用接口类来实现多态特性。**我们还是先来看一段代码。

public interface Iterator {
  boolean hasNext();
  String next();
  String remove();
}

public class Array implements Iterator {
  private String[] data;
  
  public boolean hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //...省略其他方法...
}

public class LinkedList implements Iterator {
  private LinkedListNode head;
  
  public boolean hasNext() { ... }
  public String next() { ... }
  public String remove() { ... }
  //...省略其他方法... 
}

public class Demo {
  private static void print(Iterator iterator) {
    while (iterator.hasNext()) {
      System.out.println(iterator.next());
    }
  }
  
  public static void main(String[] args) {
    Iterator arrayIterator = new Array();
    print(arrayIterator);
    
    Iterator linkedListIterator = new LinkedList();
    print(linkedListIterator);
  }
}

在这段代码中,Iterator是一个接口类,定义了一个可以遍历集合数据的迭代器。Array和LinkedList都实现了接口类Iterator。我们通过传递不同类型的实现类(Array、LinkedList)到print(Iterator iterator)函数中,支持动态的调用不同的next()、hasNext()实现。

具体点讲就是,当我们往print(Iterator iterator)函数传递Array类型的对象的时候,print(Iterator iterator)函数就会调用Array的next()、hasNext()的实现逻辑;当我们往print(Iterator iterator)函数传递LinkedList类型的对象的时候,print(Iterator iterator)函数就会调用LinkedList的next()、hasNext()的实现逻辑。

**刚刚讲的是用接口类来实现多态特性。现在,我们再来看下,如何用duck-typing来实现多态特性。**我们还是先来看一段代码。这是一段Python代码。

class Logger:
    def record(self):
        print(“I write a log into file.)
        
class DB:
    def record(self):
        print(“I insert data into db.)
        
def test(recorder):
    recorder.record()

def demo():
    logger = Logger()
    db = DB()
    test(logger)
    test(db)

从这段代码中,我们发现,duck-typing实现多态的方式非常灵活。Logger和DB两个类没有任何关系,既不是继承关系,也不是接口和实现的关系,但是只要它们都有定义了record()方法,就可以被传递到test()方法中,在实际运行的时候,执行对应的record()方法。

也就是说,只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的duck-typing,是一些动态语言所特有的语法机制。而像Java这样的静态语言,通过继承实现多态特性,必须要求两个类之间有继承关系,通过接口实现多态特性,类必须实现对应的接口。

多态特性讲完了,我们再来看,多态特性存在的意义是什么?它能解决什么编程问题?

多态特性能提高代码的可扩展性和复用性。为什么这么说呢?我们回过头去看讲解多态特性的时候,举的第二个代码实例(Iterator的例子)。

在那个例子中,我们利用多态的特性,仅用一个print()函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当再增加一种要遍历打印的类型的时候,比如HashMap,我们只需让HashMap实现Iterator接口,重新实现自己的hasNext()、next()等方法就可以了,完全不需要改动print()函数的代码。所以说,多态提高了代码的可扩展性。

如果我们不使用多态特性,我们就无法将不同的集合类型(Array、LinkedList)传递给相同的函数(print(Iterator iterator)函数)。我们需要针对每种要遍历打印的集合,分别实现不同的print()函数,比如针对Array,我们要实现print(Array array)函数,针对LinkedList,我们要实现print(LinkedList linkedList)函数。而利用多态特性,我们只需要实现一个print()函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。

除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的if-else语句等等。关于这点,在学习后面的章节中,你慢慢会有更深的体会。

思考

1.关于封装特性

封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持,例如Java中的private、protected、public关键字。封装特性存在的意义,一方面是保护数据不被随意修改,提高代码的可维护性;另一方面是仅暴露有限的必要接口,提高类的易用性。

2.关于抽象特性

封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但也并不需要特殊的语法机制来支持。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。

3.关于继承特性

继承是用来表示类之间的is-a关系,分为两种模式:单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类。为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。

4.关于多态特性

多态是指子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现,比如继承、接口类、duck-typing。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。

讨论

1、你熟悉的编程语言是否支持多重继承?如果不支持,请说一下为什么不支持。如果支持,请说一下它是如何避免多重继承的副作用的。
2、你熟悉的编程语言对于四大特性是否都有现成的语法支持?对于支持的特性,是通过什么语法机制实现的?对于不支持的特性,又是基于什么原因做的取舍?

理论三:面向对象相比面向过程有哪些优势?面向过程真的过时了吗?

在上两节课中,我们讲了面向对象这种现在非常流行的编程范式,或者说编程风格。实际上,除了面向对象之外,被大家熟知的编程范式还有另外两种,面向过程编程和函数式编程。面向过程这种编程范式随着面向对象的出现,已经慢慢退出了舞台,而函数式编程目前还没有被广泛接受。

在专栏中,我不会对函数式编程做讲解,但我会花两节课的时间,讲一下面向过程这种编程范式。你可能会问,既然面向对象已经成为主流的编程范式,而面向过程已经不那么推荐使用,那为什么又要浪费时间讲它呢?

那是因为在过往的工作中,我发现很多人搞不清楚面向对象和面向过程的区别,总以为使用面向对象编程语言来做开发,就是在进行面向对象编程了。而实际上,他们只是在用面向对象编程语言,编写面向过程风格的代码而已,并没有发挥面向对象编程的优势。这就相当于手握一把屠龙刀,却只是把它当作一把普通的刀剑来用,相当可惜。

所以,我打算详细对比一下面向过程和面向对象这两种编程范式,带你一块搞清楚下面这几个问题(前三个问题我今天讲解,后三个问题我放到下一节课中讲解):

  1. 什么是面向过程编程与面向过程编程语言?
  2. 面向对象编程相比面向过程编程有哪些优势?
  3. 为什么说面向对象编程语言比面向过程编程语言更高级?
  4. 有哪些看似是面向对象实际是面向过程风格的代码?
  5. 在面向对象编程中,为什么容易写出面向过程风格的代码?
  6. 面向过程编程和面向过程编程语言就真的无用武之地了吗?
    话不多说,带着这几个问题,我们就正式开始今天的学习吧!

什么是面向过程编程与面向过程编程语言?

如果你是一名比较资深的程序员,最开始学习编程的时候,接触的是Basic、Pascal、C等面向过程的编程语言,那你对这两个概念肯定不陌生。但如果你是新生代的程序员,一开始学编程的时候,接触的就是面向对象编程语言,那你对这两个概念可能会比较不熟悉。所以,在对比面向对象与面向过程优劣之前,我们先把面向过程编程和面向过程编程语言这两个概念搞清楚。

实际上,我们可以对比着面向对象编程和面向对象编程语言这两个概念,来理解面向过程编程和面向过程编程语言。还记得我们之前是如何定义面向对象编程和面向对象编程语言的吗?让我们一块再来回顾一下。

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
  • 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。

类比面向对象编程与面向对象编程语言的定义,对于面向过程编程和面向过程编程语言这两个概念,我给出下面这样的定义。

  • 面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能。
  • 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。

不过,这里我必须声明一下,就像我们在之前讲到的,面向对象编程和面向对象编程语言并没有官方的定义一样,这里我给出的面向过程编程和面向过程编程语言的定义,也并不是严格的官方定义。之所以要给出这样的定义,只是为了跟面向对象编程及面向对象编程语言做个对比,以方便你理解它们的区别。

定义不是很严格,也比较抽象,所以,我再用一个例子进一步解释一下。假设我们有一个记录了用户信息的文本文件users.txt,每行文本的格式是name&age&gender(比如,小王&28&男)。我们希望写一个程序,从users.txt文件中逐行读取用户信息,然后格式化成name\tage\tgender(其中,\t是分隔符)这种文本格式,并且按照age从小到大排序之后,重新写入到另一个文本文件formatted_users.txt中。针对这样一个小程序的开发,我们一块来看看,用面向过程和面向对象两种编程风格,编写出来的代码有什么不同。

首先,我们先来看,用面向过程这种编程风格写出来的代码是什么样子的。注意,下面的代码是用C语言这种面向过程的编程语言来编写的。

struct User {
  char name[64];
  int age;
  char gender[16];
};

struct User parse_to_user(char* text) {
  // 将text(“小王&28&男”)解析成结构体struct User
}

char* format_to_text(struct User user) {
  // 将结构体struct User格式化成文本("小王\t28\t男")
}

void sort_users_by_age(struct User users[]) {
  // 按照年龄从小到大排序users
}

void format_user_file(char* origin_file_path, char* new_file_path) {
  // open files...
  struct User users[1024]; // 假设最大1024个用户
  int count = 0;
  while(1) { // read until the file is empty
    struct User user = parse_to_user(line);
    users[count++] = user;
  }
  
  sort_users_by_age(users);
  
  for (int i = 0; i < count; ++i) {
    char* formatted_user_text = format_to_text(users[i]);
    // write to new file...
  }
  // close files...
}

int main(char** args, int argv) {
  format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}

然后,我们再来看,用面向对象这种编程风格写出来的代码是什么样子的。注意,下面的代码是用Java这种面向对象的编程语言来编写的。

 public class User {
  private String name;
  private int age;
  private String gender;
  
  public User(String name, int age, String gender) {
    this.name = name;
    this.age = age;
    this.gender = gender;
  }
  
  public static User praseFrom(String userInfoText) {
    // 将text(“小王&28&男”)解析成类User
  }
  
  public String formatToText() {
    // 将类User格式化成文本("小王\t28\t男")
  }
}

public class UserFileFormatter {
  public void format(String userFile, String formattedUserFile) {
    // Open files...
    List users = new ArrayList<>();
    while (1) { // read until file is empty 
      // read from file into userText...
      User user = User.parseFrom(userText);
      users.add(user);
    }
    // sort users by age...
    for (int i = 0; i < users.size(); ++i) {
      String formattedUserText = user.formatToText();
      // write to new file...
    }
    // close files...
  }
}

public class MainApplication {
  public static void main(String[] args) {
    UserFileFormatter userFileFormatter = new UserFileFormatter();
    userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_users.txt");
  }
}

从上面的代码中,我们可以看出,面向过程和面向对象最基本的区别就是,代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。

看完这个例子之后,你可能会说,面向对象编程和面向过程编程,两种风格的区别就这么一点吗?当然不是,对于这两种编程风格的更多区别,我们继续往下看。

面向对象编程相比面向过程编程有哪些优势?

刚刚我们介绍了面向过程编程及面向过程编程语言的定义,并跟面向对象编程及面向对象编程语言做了一个简单对比。接下来,我们再来看一下,为什么面向对象编程晚于面向过程编程出现,却能取而代之,成为现在主流的编程范式?面向对象编程跟面向过程编程比起来,到底有哪些优势?

1.OOP更加能够应对大规模复杂程序的开发
看了刚刚举的那个格式化文本文件的例子,你可能会有这样的疑问,两种编程风格实现的代码貌似差不多啊,顶多就是代码的组织方式有点区别,没有感觉到面向对象编程有什么明显的优势呀!你的感觉没错。之所以有这种感觉,主要原因是这个例子程序比较简单、不够复杂。

对于简单程序的开发来说,不管是用面向过程编程风格,还是用面向对象编程风格,差别确实不会很大,甚至有的时候,面向过程的编程风格反倒更有优势。因为需求足够简单,整个程序的处理流程只有一条主线,很容易被划分成顺序执行的几个步骤,然后逐句翻译成代码,这就非常适合采用面向过程这种面条式的编程风格来实现。

但对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了。

面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰。

除此之外,面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式。比如,我们开发一个电商交易系统,业务逻辑复杂,代码量很大,可能要定义数百个函数、数百个数据结构,那如何分门别类地组织这些函数和数据结构,才能不至于看起来比较凌乱呢?类就是一种非常好的组织这些函数和数据结构的方式,是一种将代码模块化的有效手段。

你可能会说,像C语言这种面向过程的编程语言,我们也可以按照功能的不同,把函数和数据结构放到不同的文件里,以达到给函数和数据结构分类的目的,照样可以实现代码的模块化。你说得没错。只不过面向对象编程本身提供了类的概念,强制你做这件事情,而面向过程编程并不强求。这也算是面向对象编程相对于面向过程编程的一个微创新吧。

实际上,利用面向过程的编程语言照样可以写出面向对象风格的代码,只不过可能会比用面向对象编程语言来写面向对象风格的代码,付出的代价要高一些。而且,面向过程编程和面向对象编程并非完全对立的。很多软件开发中,尽管利用的是面向过程的编程语言,也都有借鉴面向对象编程的一些优点。

2.OOP风格的代码更易复用、易扩展、易维护
在刚刚的那个例子中,因为代码比较简单,所以只用到到了类、对象这两个最基本的面向对象概念,并没有用到更加高级的四大特性,封装、抽象、继承、多态。因此,面向对象编程的优势其实并没有发挥出来。

面向过程编程是一种非常简单的编程风格,并没有像面向对象编程那样提供丰富的特性。而面向对象编程提供的封装、抽象、继承、多态这些特性,能极大地满足复杂的编程需求,能方便我们写出更易复用、易扩展、易维护的代码。为什么这么说呢?还记得我们在上一节课中讲到的封装、抽象、继承、多态存在的意义吗?我们再来简单回顾一下。

首先,我们先来看下封装特性。封装特性是面向对象编程相比于面向过程编程的一个最基本的区别,因为它基于的是面向对象编程中最基本的类的概念。面向对象编程通过类这种组织代码的方式,将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程编程那样,数据可以被任意方法随意修改。因此,面向对象编程提供的封装特性更有利于提高代码的易维护性。

其次,我们再来看下抽象特性。我们知道,函数本身就是一种抽象,它隐藏了具体的实现。我们在使用函数的时候,只需要了解函数具有什么功能,而不需要了解它是怎么实现的。从这一点上,不管面向过程编程还是是面向对象编程,都支持抽象特性。不过,面向对象编程还提供了其他抽象特性的实现方式。这些实现方式是面向过程编程所不具备的,比如基于接口实现的抽象。基于接口的抽象,可以让我们在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可扩展性。

再次,我们来看下继承特性。继承特性是面向对象编程相比于面向过程编程所特有的两个特性之一(另一个是多态)。如果两个类有一些相同的属性和方法,我们就可以将这些相同的代码,抽取到父类中,让两个子类继承父类。这样两个子类也就可以重用父类中的代码,避免了代码重复写多遍,提高了代码的复用性。

最后,我们来看下多态特性。基于这个特性,我们在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类中重写原来的功能逻辑,用子类替换父类。在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。除此之外,利用多态特性,不同的类对象可以传递给相同的方法,执行不同的代码逻辑,提高了代码的复用性。

所以说,基于这四大特性,利用面向对象编程,我们可以更轻松地写出易复用、易扩展、易维护的代码。当然,我们不能说,利用面向过程风格就不可以写出易复用、易扩展、易维护的代码,但没有四大特性的帮助,付出的代价可能就要高一些。

3.OOP语言更加人性化、更加高级、更加智能
人类最开始跟机器打交道是通过0、1这样的二进制指令,然后是汇编语言,再之后才出现了高级编程语言。在高级编程语言中,面向过程编程语言又早于面向对象编程语言出现。之所以先出现面向过程编程语言,那是因为跟机器交互的方式,从二进制指令、汇编语言到面向过程编程语言,是一个非常自然的过渡,都是一种流程化的、面条式的编程风格,用一组指令顺序操作数据,来完成一项任务。

从指令到汇编再到面向过程编程语言,跟机器打交道的方式在不停地演进,从中我们很容易发现这样一条规律,那就是编程语言越来越人性化,让人跟机器打交道越来越容易。笼统点讲,就是编程语言越来越高级。实际上,在面向过程编程语言之后,面向对象编程语言的出现,也顺应了这样的发展规律,也就是说,面向对象编程语言比面向过程编程语言更加高级!

跟二进制指令、汇编语言、面向过程编程语言相比,面向对象编程语言的编程套路、思考问题的方式,是完全不一样的。前三者是一种计算机思维方式,而面向对象是一种人类的思维方式。我们在用前面三种语言编程的时候,我们是在思考,如何设计一组指令,告诉机器去执行这组指令,操作某些数据,帮我们完成某个任务。而在进行面向对象编程时候,我们是在思考,如何给业务建模,如何将真实的世界映射为类或者对象,这让我们更加能聚焦到业务本身,而不是思考如何跟机器打交道。可以这么说,越高级的编程语言离机器越“远”,离我们人类越“近”,越“智能”。

这里多聊几句,顺着刚刚这个编程语言的发展规律来想,如果一种新的突破性的编程语言出现,那它肯定是更加“智能”的。大胆想象一下,使用这种编程语言,我们可以无需对计算机知识有任何了解,无需像现在这样一行一行地敲很多代码,只需要把需求文档写清楚,就能自动生成我们想要的软件了。

思考

1.什么是面向过程编程?什么是面向过程编程语言?

实际上,面向过程编程和面向过程编程语言并没有严格的官方定义。理解这两个概念最好的方式是跟面向对象编程和面向对象编程语言进行对比。相较于面向对象编程以类为组织代码的基本单元,面向过程编程则是以过程(或方法)作为组织代码的基本单元。它最主要的特点就是数据和方法相分离。相较于面向对象编程语言,面向过程编程语言最大的特点就是不支持丰富的面向对象编程特性,比如继承、多态、封装。

2.面向对象编程相比面向过程编程有哪些优势?

面向对象编程相比起面向过程编程的优势主要有三个。

  • 对于大规模复杂程序的开发,程序的处理流程并非单一的一条主线,而是错综复杂的网状结构。面向对象编程比起面向过程编程,更能应对这种复杂类型的程序开发。
  • 面向对象编程相比面向过程编程,具有更加丰富的特性(封装、抽象、继承、多态)。利用这些特性编写出来的代码,更加易扩展、易复用、易维护。
  • 从编程语言跟机器打交道的方式的演进规律中,我们可以总结出:面向对象编程语言比起面向过程编程语言,更加人性化、更加高级、更加智能。

讨论

在文章中我讲到,面向对象编程比面向过程编程,更加容易应对大规模复杂程序的开发。但像Unix、Linux这些复杂的系统,也都是基于C语言这种面向过程的编程语言开发的,你怎么看待这个现象?这跟我之前的讲解相矛盾吗?

理论四:哪些代码设计看似是面向对象,实际是面向过程的?

上一节课,我们提到,常见的编程范式或者说编程风格有三种,面向过程编程、面向对象编程、函数式编程,而面向对象编程又是这其中最主流的编程范式。现如今,大部分编程语言都是面向对象编程语言,大部分软件都是基于面向对象编程这种编程范式来开发的。

不过,在实际的开发工作中,很多同学对面向对象编程都有误解,总以为把所有代码都塞到类里,自然就是在进行面向对象编程了。实际上,这样的认识是不正确的。有时候,从表面上看似是面向对象编程风格的代码,从本质上看却是面向过程编程风格的。

所以,今天,我结合具体的代码实例来讲一讲,有哪些看似是面向对象,实际上是面向过程编程风格的代码,并且分析一下,为什么我们很容易写出这样的代码。最后,我们再一起辩证思考一下,面向过程编程是否就真的无用武之地了呢?是否有必要杜绝在面向对象编程中写面向过程风格的代码呢?

好了,现在,让我们正式开始今天的学习吧!

哪些代码设计看似是面向对象,实际是面向过程的?

在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。下面我就通过三个典型的代码案例,给你展示一下,什么样的代码看似是面向对象风格,实际上是面向过程风格的。我也希望你通过对这三个典型例子的学习,能够做到举一反三,在平时的开发中,多留心一下自己编写的代码是否满足面向对象风格。

1.滥用getter、setter方法
在之前参与的项目开发中,我经常看到,有同事定义完类的属性之后,就顺手把这些属性的getter、setter方法都定义上。有些同事更加省事,直接用IDE或者Lombok插件(如果是Java项目的话)自动生成所有属性的getter、setter方法。

当我问起,为什么要给每个属性都定义getter、setter方法的时候,他们的理由一般是,为了以后可能会用到,现在事先定义好,类用起来就更加方便,而且即便用不到这些getter、setter方法,定义上它们也无伤大雅。

实际上,这样的做法我是非常不推荐的。它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。我通过下面这个例子来给你解释一下这句话。

public class ShoppingCart {
  private int itemsCount;
  private double totalPrice;
  private List<ShoppingCartItem> items = new ArrayList<>();
  
  public int getItemsCount() {
    return this.itemsCount;
  }
  
  public void setItemsCount(int itemsCount) {
    this.itemsCount = itemsCount;
  }
  
  public double getTotalPrice() {
    return this.totalPrice;
  }
  
  public void setTotalPrice(double totalPrice) {
    this.totalPrice = totalPrice;
  }

  public List<ShoppingCartItem> getItems() {
    return this.items;
  }
  
  public void addItem(ShoppingCartItem item) {
    items.add(item);
    itemsCount++;
    totalPrice += item.getPrice();
  }
  // ...省略其他方法...
}

在这段代码中,ShoppingCart是一个简化后的购物车类,有三个私有(private)属性:itemsCount、totalPrice、items。对于itemsCount、totalPrice两个属性,我们定义了它们的getter、setter方法。对于items属性,我们定义了它的getter方法和addItem()方法。代码很简单,理解起来不难。那你有没有发现,这段代码有什么问题呢?

我们先来看前两个属性,itemsCount和totalPrice。虽然我们将它们定义成private私有属性,但是提供了public的getter、setter方法,这就跟将这两个属性定义为public公有属性,没有什么两样了。外部可以通过setter方法随意地修改这两个属性的值。除此之外,任何代码都可以随意调用setter方法,来重新设置itemsCount、totalPrice属性的值,这也会导致其跟items属性的值不一致。

而面向对象封装的定义是:通过访问权限控制,隐藏内部数据,外部仅能通过类提供的有限的接口访问、修改内部数据。所以,暴露不应该暴露的setter方法,明显违反了面向对象的封装特性。数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。

看完了前两个属性,我们再来看items这个属性。对于items这个属性,我们定义了它的getter方法和addItem()方法,并没有定义它的setter方法。这样的设计貌似看起来没有什么问题,但实际上并不是。

对于itemsCount和totalPrice这两个属性来说,定义一个public的getter方法,确实无伤大雅,毕竟getter方法不会修改数据。但是,对于items属性就不一样了,这是因为items属性的getter方法,返回的是一个List 集合容器。外部调用者在拿到这个容器之后,是可以操作容器内部数据的,也就是说,外部代码还是能修改items中的数据。比如像下面这样:

ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车

你可能会说,清空购物车这样的功能需求看起来合情合理啊,上面的代码没有什么不妥啊。你说得没错,需求是合理的,但是这样的代码写法,会导致itemsCount、totalPrice、items三者数据不一致。我们不应该将清空购物车的业务逻辑暴露给上层代码。正确的做法应该是,在ShoppingCart类中定义一个clear()方法,将清空购物车的业务逻辑封装在里面,透明地给调用者使用。ShoppingCart类的clear()方法的具体代码实现如下:

public class ShoppingCart {
  // ...省略其他代码...
  public void clear() {
    items.clear();
    itemsCount = 0;
    totalPrice = 0.0;
  }
}

你可能还会说,我有一个需求,需要查看购物车中都买了啥,那这个时候,ShoppingCart类不得不提供items属性的getter方法了,那又该怎么办才好呢?

如果你熟悉Java语言,那解决这个问题的方法还是挺简单的。我们可以通过Java提供的Collections.unmodifiableList()方法,让getter方法返回一个不可被修改的UnmodifiableList集合容器,而这个容器类重写了List容器中跟修改数据相关的方法,比如add()、clear()等方法。一旦我们调用这些修改数据的方法,代码就会抛出UnsupportedOperationException异常,这样就避免了容器中的数据被修改。具体的代码实现如下所示。

public class ShoppingCart {
  // ...省略其他代码...
  public List<ShoppingCartItem> getItems() {
    return Collections.unmodifiableList(this.items);
  }
}

public class UnmodifiableList<E> extends UnmodifiableCollection<E>
                          implements List<E> {
  public boolean add(E e) {
    throw new UnsupportedOperationException();
  }
  public void clear() {
    throw new UnsupportedOperationException();
  }
  // ...省略其他代码...
}

ShoppingCart cart = new ShoppingCart();
List<ShoppingCartItem> items = cart.getItems();
items.clear();//抛出UnsupportedOperationException异常

不过,这样的实现思路还是有点问题。因为当调用者通过ShoppingCart的getItems()获取到items之后,虽然我们没法修改容器中的数据,但我们仍然可以修改容器中每个对象(ShoppingCartItem)的数据。听起来有点绕,看看下面这几行代码你就明白了。

ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List<ShoppingCartItem> items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 这里修改了item的价格属性

这个问题该如何解决呢?我今天就不展开来讲了。在后面讲到设计模式的时候,我还会详细地讲到。

getter、setter问题我们就讲完了,我稍微总结一下,在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义setter方法。除此之外,尽管getter方法相对setter方法要安全些,但是如果返回的是集合容器(比如例子中的List容器),也要防范集合内部数据被修改的危险。

2.滥用全局变量和全局方法
我们再来看,另外一个违反面向对象编程风格的例子,那就是滥用全局变量和全局方法。首先,我们先来看,什么是全局变量和全局方法?

如果你是用类似C语言这样的面向过程的编程语言来做开发,那对全局变量、全局方法肯定不陌生,甚至可以说,在代码中到处可见。但如果你是用类似Java这样的面向对象的编程语言来做开发,全局变量和全局方法就不是很多见了。

在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个Constants类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种Utils类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。

在刚刚介绍的这些全局变量和全局方法中,Constants类和Utils类最常用到。现在,我们就结合这两个几乎在每个软件开发中都会用到的类,来深入探讨一下全局变量和全局方法的利与弊。

我们先来看一下,在我过去参与的项目中,一种常见的Constants类的定义方法。

public class Constants {
  public static final String MYSQL_ADDR_KEY = "mysql_addr";
  public static final String MYSQL_DB_NAME_KEY = "db_name";
  public static final String MYSQL_USERNAME_KEY = "mysql_username";
  public static final String MYSQL_PASSWORD_KEY = "mysql_password";
  
  public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
  public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
  public static final int REDIS_DEFAULT_MAX_IDLE = 50;
  public static final int REDIS_DEFAULT_MIN_IDLE = 20;
  public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";
  
  // ...省略更多的常量定义...
}

在这段代码中,我们把程序中所有用到的常量,都集中地放到这个Constants类中。不过,定义一个如此大而全的Constants类,并不是一种很好的设计思路。为什么这么说呢?原因主要有以下几点。

首先,这样的设计会影响代码的可维护性。

如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。

其次,这样的设计还会增加代码的编译时间。

当Constants类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改Constants类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。不要小看编译花费的时间,对于一个非常大的工程项目来说,编译一次项目花费的时间可能是几分钟,甚至几十分钟。而我们在开发过程中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到我们的开发效率。

最后,这样的设计还会影响代码的复用性。

如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖Constants类。即便这个类只依赖Constants类中的一小部分常量,我们仍然需要把整个Constants类也一并引入,也就引入了很多无关的常量到新的项目中。

那如何改进Constants类的设计呢?我这里有两种思路可以借鉴。

第一种是将Constants类拆解为功能更加单一的多个类,比如跟MySQL配置相关的常量,我们放到MysqlConstants类中;跟Redis配置相关的常量,我们放到RedisConstants类中。当然,还有一种我个人觉得更好的设计思路,那就是并不单独地设计Constants常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig类用到了Redis配置相关的常量,那我们就直接将这些常量定义在RedisConfig中,这样也提高了类设计的内聚性和代码的复用性。

讲完了Constants类,我们再来讨论一下Utils类。首先,我想问你这样一个问题,我们为什么需要Utils类?Utils类存在的意义是什么?希望你先思考一下,然后再来看我下面的讲解。

实际上,Utils类的出现是基于这样一个问题背景:如果我们有两个类A和B,它们要用到一块相同的功能逻辑,为了避免代码重复,我们不应该在两个类中,将这个相同的功能逻辑,重复地实现两遍。这个时候我们该怎么办呢?

我们在讲面向对象特性的时候,讲过继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A类和B类并不一定具有继承关系,比如Crawler类和PageAnalyzer类,它们都用到了URL拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现Crawler类和PageAnalyzer类继承同一个父类,而父类中定义的却是URL相关的操作,会觉得这个代码写得莫名其妙,理解不了。

既然继承不能解决这个问题,我们可以定义一个新的类,实现URL拼接和分割的方法。而拼接和分割两个方法,不需要共享任何数据,所以新的类不需要定义任何属性,这个时候,我们就可以把它定义为只包含静态方法的Utils类了。

实际上,只包含静态方法不包含任何属性的Utils类,是彻彻底底的面向过程的编程风格。但这并不是说,我们就要杜绝使用Utils类了。实际上,从刚刚讲的Utils类存在的目的来看,它在软件开发中还是挺有用的,能解决代码复用问题。所以,这里并不是说完全不能用Utils类,而是说,要尽量避免滥用,不要不加思考地随意去定义Utils类。

在定义Utils类之前,你要问一下自己,你真的需要单独定义这样一个Utils类吗?是否可以把Utils类中的某些方法定义到其他类中呢?如果在回答完这些问题之后,你还是觉得确实有必要去定义这样一个Utils类,那就大胆地去定义它吧。因为即便在面向对象编程中,我们也并不是完全排斥面向过程风格的代码。只要它能为我们写出好的代码贡献力量,我们就可以适度地去使用。

除此之外,类比Constants类的设计,我们设计Utils类的时候,最好也能细化一下,针对不同的功能,设计不同的Utils类,比如FileUtils、IOUtils、StringUtils、UrlUtils等,不要设计一个过于大而全的Utils类。

3.定义数据和方法分离的类
我们再来看最后一种面向对象编程过程中,常见的面向过程风格的代码。那就是,数据定义在一个类中,方法定义在另一个类中。你可能会觉得,这么明显的面向过程风格的代码,谁会这么写呢?实际上,如果你是基于MVC三层结构做Web方面的后端开发,这样的代码你可能天天都在写。

传统的MVC结构分为Model层、Controller层、View层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为Controller层、Service层、Repository层。Controller层负责暴露接口给前端调用,Service层负责核心业务逻辑,Repository层负责数据读写。而在每一层中,我们又会定义相应的VO(View Object)、BO(Business Object)、Entity。一般情况下,VO、BO、Entity中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的Controller类、Service类、Repository类中。这就是典型的面向过程的编程风格。

实际上,这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种Web项目的开发模式。看到这里,你内心里应该有很多疑惑吧?既然这种开发模式明显违背面向对象的编程风格,为什么大部分Web项目都是基于这种开发模式来开发呢?

关于这个问题,我今天不打算展开讲解。因为它跟我们平时的项目开发结合得非常紧密,所以,更加细致、全面的讲解,我把它安排在面向对象实战环节里了,希望用两节课的时间,把这个问题给你讲透彻。

在面向对象编程中,为什么容易写出面向过程风格的代码?

我们在进行面向对象编程的时候,很容易不由自主地就写出面向过程风格的代码,或者说感觉面向过程风格的代码更容易写。这是为什么呢?

你可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。我们在上一节课讲到了,这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。

除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。

所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。

面向过程编程及面向过程编程语言就真的无用武之地了吗?

前面我们讲了面向对象编程相比面向过程编程的各种优势,又讲了哪些代码看起来像面向对象风格,而实际上是面向过程编程风格的。那是不是面向过程编程风格就过时了被淘汰了呢?是不是在面向对象编程开发中,我们就要杜绝写面向过程风格的代码呢?

前面我们有讲到,如果我们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?

除此之外,面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。

不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。

思考

重点内容是三种违反面向对象编程风格的典型代码设计。
1.滥用getter、setter方法

在设计实现类的时候,除非真的需要,否则尽量不要给属性定义setter方法。除此之外,尽管getter方法相对setter方法要安全些,但是如果返回的是集合容器,那也要防范集合内部数据被修改的风险。

2.Constants类、Utils类的设计问题

对于这两种类的设计,我们尽量能做到职责单一,定义一些细化的小类,比如RedisConstants、FileUtils,而不是定义一个大而全的Constants类、Utils类。除此之外,如果能将这些类中的属性和方法,划分归并到其他业务类中,那是最好不过的了,能极大地提高类的内聚性和代码的可复用性。

3.基于贫血模型的开发模式

关于这一部分,我们只讲了为什么这种开发模式是彻彻底底的面向过程编程风格的。这是因为数据和操作是分开定义在VO/BO/Entity和Controler/Service/Repository中的。今天,你只需要掌握这一点就可以了。为什么这种开发模式如此流行?如何规避面向过程编程的弊端?有没有更好的可替代的开发模式?相关的更多问题,我们在面向对象实战篇中会一一讲解。

讨论

1.今天我们讲到,用面向对象编程语言写出来的代码,不一定是面向对象编程风格的,有可能是面向过程编程风格的。相反,用面向过程编程语言照样也可以写出面向对象编程风格的代码。尽管面向过程编程语言可能没有现成的语法来支持面向对象的四大特性,但可以通过其他方式来模拟,比如在C语言中,我们可以利用函数指针来模拟多态。如果你熟悉一门面向过程的编程语言,你能聊一聊如何用它来模拟面向对象的四大特性吗?

2.看似是面向对象实际上是面向过程编程风格的代码有很多,除了今天我讲到的这三个,在你工作中,你还遇到过哪些其他情况吗?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值