面向对象编程与SOLID原则(一)

https://img-1256541035.cos.ap-shanghai.myqcloud.com/imgs/salmen-bejaoui-qWlTy3CaWKk-unsplash.jpg

面向对象出现的背景

在面向对象之前,主流的编程思想是面向过程。 面向过程是以“过程”或“事件”为核心,用函数来表示“事件”或“过程” 通过组合不同个函数调用来完成一个功能。 面向过程的语言,它的性能更好,但是随着软件的规模和复杂度的不断增加,这时候它的缺点也十分明显:

  • 代码复用性低
  • 系统规模庞大,内部耦合严重,开发效率低;
  • 系统耦合严重,牵一发动全身,后续修改和扩展困难;
  • 系统逻辑复杂,容易出问题,出问题后很难排查和修复。

为了解决这些问题,就出现了“模块化”、“面向对象”、“组件”等概念。“模块”、“对象”、“组件”,本质上都是对达到一定规模的软件进行拆分,差别只是在于随着软件的复杂度不断增加,拆分的粒度越来越粗,拆分的层次越来越高。

面向对象是以对象为核心,把要解决的问题分解成多个对象,并在对象上定义属性和行为,对象与对象之间通过方法来交互。 面向对象的代码,结构清晰,程序是模块化和结构化,更加符合人类的思维方式;并且易扩展,代码重用率高,可继承,可覆盖,可以设计出高内聚、低耦合的系统;

面向对象三大特征:

封装(Encapsulation)

所谓封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏。封装是面向对象的特征之一,是对象和类概念的主要特性。简单的说,一个类就是一个封装了数据以及操作这些数据的代码的逻辑实体。在一个对象内部,某些代码或某些数据可以是私有的,不能被外界访问。通过这种方式,对象对内部数据提供了不同级别的保护,以防止程序中无关的部分意外的改变或错误的使用了对象的私有部分。

继承(Inheritance)

继承是指这样一种能力:它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。通过继承创建的新类称为“子类”或“派生类”,被继承的类称为“基类”、“父类”或“超类”。继承的过程,就是从一般到特殊的过程。要实现继承,可以通过“继承”(Inheritance)和“组合”(Composition)来实现。继承概念的实现方式有二类:实现继承与接口继承。实现继承是指直接使用基类的属性和方法而无需额外编码的能力;接口继承是指仅使用属性和方法的名称、但是子类必须提供实现的能力;

多态(Polymorphism)

所谓多态就是指一个类实例的相同方法在不同情形有不同表现形式。多态机制使具有不同内部结构的对象可以共享相同的外部接口。这意味着,虽然针对不同对象的具体操作不同,但通过一个公共的类,它们(那些操作)可以通过相同的方式予以调用。

最常见的多态就是将子类传入父类参数中,运行时调用父类方法时通过传入的子类决定具体的内部结构或行为。

面向对象五大原则(SOLID):

单一职责原则(Single-Resposibility Principle)

一个类应该只做且只能做一件事情

举例来说,假如我们需要从网络上获取一些 JSON 数据,然后解析它,并把结果保存在本地数据库中。根据我们正在编码的平台,这种工作可以使用为数不多的代码来实现。由于代码量不多,我们可能会想把所有的逻辑全部扔到一个类中。但是,根据单一职责原则,这将会是一个糟糕的做法。我们可以清楚地区分 3 个不同的职责:

  1. 从网络上获取 JSON 数据
  2. 解析数据
  3. 保存解析的结果到数据库中

基于此,我们应该有 3 个类。 第 1 个类应该只处理网络。我们给它提供一个 URL,然后接收 JSON 数据或者在出现问题时,收到一个错误信息。 第 2 个类应该只解析它接收到的 JSON 数据并以相应的格式返回结果。 第 3 个类应该以相应的格式接收 JSON 数据,并把它保存在本地数据库中。

为什么非要这么麻烦呢?通过这样分离代码,我们能获得什么好处呢?其中一个好处就是可测试性。对于网络请求类,我们可以使用一个测试的 URL 分别在请求成功和发生错误的测试用例下来观察它的正确行为。为了测试 JSON 模块,我们可以提供一个模拟的 JSON 数据,然后查看它生成的正确数据。同样的测试原则也适用于数据库类(提供模拟数据,在模拟的数据库上测试结果)。

有了这些测试,如果我们的程序出了问题,我们可以运行测试并查看问题发生在哪个地方。可能是服务器上的某些部分发生了改变,导致我们接收了有损数据。或者数据是正常的,但是我们在 JSON 解析模块中遗漏了什么,致使我们不能正确地解析数据。又或者可能我们正尝试插入数据的数据库中不存在某个列。通过这些测试,我们不必猜测问题出在哪个地方。看到了问题所在,我们就努力地去解决它。

除了可测试性,我们还拥抱了模块化。如果项目需求变更,服务器返回数据的格式是 XML 或者其他的自定义格式而非 JSON,那么我们所要做的就是编写一个处理数据解析的新模块,然后用这个新的代替 JSON 模块。或者可能因为一些奇葩的理由,上述两者我们都需要,而网络模块再根据一些规则调用正确的模块。

如果我们有一些本地的 JSON 文件需要解析并将解析的数据发给其他模块时该怎么办呢?那么,我们可以把本地的 JSON 发送给我们的解析模块,然后获取结果并把它用在需要的地方。如果我们需要以 Flat File 的格式,而不是数据库的形式本地保存数据呢?同样没问题,我们可以用一个新的模块来替换数据库模块。

如你所见,这个看似简单的原则有很多优势。通过遵守这个原则,我们已经能够想象的到我们的代码库在可维护性方面会有重大改进。

 

开放封闭原则(Open-Closed principle)

对扩展开放,对修改封闭

我们的类应该对扩展开放,对修改关闭。什么意思呢?我的理解是,我们应该以插件式的方式来编写类和模块。如果我们需要额外的功能,我们不应该修改类,而是能够嵌入一个提供这个额外功能的不同类。为了解释我的理解,我将使用一个经典的计算器示例。这个计算器在最开始只能执行两种运算:加法和减法。计算器类看起来像下面这样:

1
2
3
4
5
6
7
8
9
class Calculator {
  public float add(float a, float b) {
    return a + b
  }
  public float subtract(float a, float b) {
    return a — b
  }
}

我们像下面这样使用这个类:

1
2
3
4
5
Calculator calculator = new Calculator()

float sum = calculator.add(10, 2) //the value of sum is 12
float diff = calculator.subtract(10, 2) //the value of diff is 8

现在,我们假设客户希望为这个计算器添加乘法功能。为了添加这个额外的功能,我们必须编辑计算器类并添加乘法方法:

1
2
3
4
public float multiply(float a, float b) {
  return a * b
}

如果需求又一次改变,客户又需要除法,sin,cos,pow以及众多的其他数学函数,我们不得不一次又一次编辑这个类来添加这些需求。根据开闭原则,这并不是一个明智的做法。因为这意味着我们的类可以修改。我们需要让它屏蔽修改,而对扩展开放,那么我们该怎么做呢?

首先,我们定义一个名为 Operation 的接口,这个接口只有一个名为 compute 的方法:

1
2
3
interface Operation {
  float compute(float a, float b)
}

之后,我们可以通过实现 Operation 接口来创建操作类(本文中提供的大多数示例也可以通过继承和抽象类来完成,但我更喜欢使用接口)。为了重建简单的计算器示例,我们将编写加法和减法类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class Addition implements Operation {
  public float compute(float a, float b) {
    return a + b
  }
}
class Subtraction implements Operation {
  public float compute(float a, float b) {
    return a — b
  }
}

我们的新计算器类只有一个名叫 calculate 的方法,在这个方法中,我们可以传递操作数与操作类:

1
2
3
4
5
class Calculator {
  public float calculate(float a, float b, Operation operation) {
    return operation.compute(a, b)
  }
}

我们将像下面这样使用我们的新类:

1
2
3
4
5
6
7
Calculator calculator = new Calculator()

Addition addition = new Addition()
Subtraction subtraction = new Subtraction()

float sum = calculator.calculate(10, 2, addition) //the value of sum is 12
float diff = calculator.calculate(10, 2, subtraction) //the value of diff is 8

现在如果我们需要添加乘法,我们将创建这样的一个乘法运算类:

1
2
3
4
5
class Multiplication implements Operation {
  public float compute(float a, float b) {
    return a * b
  }
}

然后通过添加以下内容在上面的示例中使用它:

1
2
Multiplication multiplication = new Multiplication()
float prod = calculator.calculate(10, 2, multiplication) // the value of prod is 20

我们终于可以说我们的计算器类对修改关闭,对扩展开放了。看一下这个简单的例子,你可能会说将这些额外的方法添加到原始的计算器类中也没什么大问题,还有就是可能更好的实现也就意味着编写更多的代码。诚然,在这个简单的情景中,我更赞同你的说法。但是,在现实生活里的复杂情景下,遵守开闭原则编码将大有裨益。也许你需要为每个新功能添加远不止那三个方法,也许这些方法非常复杂。然而通过遵循开闭原则,我们可以用不同的类外化新的功能。它将有助于我们以及他人更好地理解我们的代码,而这主要是因为我们必须专注于较小的代码块而不是滚动无休止的文件。

为了更好地可视化这个概念,我们可以把计算器类视为第三方库的一部分,并且无法访问其源码。好的实现就是编写它的善良的人们遵守了开闭原则,使它对扩展开放。因此,我们可以使用自己的代码扩展其功能,并轻松地在我们的项目中使用它。

如果这听起来仍让人犯傻,那就这样想吧:你刚刚为客户编写了一个很棒的软件,它完成了客户想要的一切。你尽最大能力编写了所有的内容,并且代码质量令人惊叹。数周后,客户想要新的功能。为了实现它们,你必须潜心投入到你的漂亮代码中,修改各种文件。这样做,有可能代码质量会受到影响,特别是在截止日期紧张时。如果你已经为你的代码编写了测试(这也是你应该做的),那么这些修改可能会破坏一些测试,你还必须修改这些测试。

这与遵守了开闭原则编写的代码形成了鲜明的对比。要实现新功能,你只需编写新代码即可。旧代码保持不变。你所有的旧测试仍然有效。因为我们不是生活在一个完美的世界中,所以在某些跑偏的情况下,你可能仍然会对旧代码的某些部分进行细微的更改,但这与非开闭原则带来的修改相比则可以忽略不计。

除此之外,遵循开闭原则的编码方式还能让你在心理上获得极大的愉悦体验。其中一个就是,你只需要编写新代码,而无须为了实现新功能对你引以为傲的代码痛下杀手。通过为新功能编写新代码,而不是修改旧代码,高涨的团队士气将随之而来。这可以提高生产效率,从而减少工作压力,改善生活质量。

comments powered by Disqus
The LatestT