65.9K
CodeProject 正在变化。 阅读更多。
Home

良好面向对象编程的10条黄金法则

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (107投票s)

2014年5月3日

CPOL

11分钟阅读

viewsIcon

214159

以下指南并非详尽无遗,旨在在 SOLID 原则和正确使用 OO 设计模式的基础上应用。

引言

良好的架构意味着在学习、维护、测试、修复、扩展和扩展源代码方面节省了金钱。这需要在初始开发中花费更多的时间和精力,但会以丰厚的回报迅速收回投资。

不可避免的是,即使是最好的架构设计也需要随着时间的推移进行调整和重构。

补丁、修改和最后期限的更改是 IT 业务的一部分,因此,当需要时,“快速而肮脏”的解决方案应始终尽量将其限制在隔离的位置,以便将来可以轻松重构,并且对所有其他组件的影响尽可能小。

以下指南并非详尽无遗,旨在在 SOLID 原则和正确使用 OO 设计模式的基础上应用。

1. 避免全能类,使用有意义的名称

在设计新类或重构现有类时,开发人员应列出该类执行的所有任务,并起一个能够轻松简洁地代表该类功能的名称。

如果一个类做了太多事情,一个有代表性的名字通常会非常冗长,例如:PageInfoBuilderAndConfigurationLoaderAndLinkAnalizerAndCacheManager。这意味着该类承担了过多的职责,应该将其分解为多个组件,最好是每个组件只有一个职责。

在使用 OO 设计模式时,起一个有意义的名字会容易得多,因为设计模式本身经常能识别出职责的类型。

例如

  • 如果该类创建的对象使用后缀 FactoryBuilder
  • 如果该类负责协调和其他业务类之间的通信,则使用后缀 MediatorFaçade
  • 如果该类用于控制资源类的使用,那么 Proxy 将是一个不错的后缀
  • 如果一个类包装另一个类以调整其对消费者类的使用,则使用 Adapter 后缀

2. 避免过多的 If 或 Switch 语句

过多的条件逻辑会让开发人员的头脑像《驱魔人》中的 Regan 一样旋转。当相同的条件逻辑应用在应用程序的许多地方时,情况会变得更糟。这意味着不同的业务行为是通过 ifswitch 进行管理的,而不是通过继承或组合进行妥善管理,将不同的行为分离到通用 abstract 类或接口的不同实现中。例如,有一个配置变量指示数据应该存储在数据库还是文件中。那么代码的任何地方,当我们必须保存某些内容时,我们都会使用条件逻辑。

If (ConfigParameters.SaveTo == StorageType.DataBase) 
           //Save something in the db
Else 
           //Save something to file

想象一下,我们需要添加一种不同的行为(例如,保存到分布式缓存):现在我们必须在代码的各个地方扩展条件逻辑。再想象一下,多个配置变量决定了多种行为,导致无法维护和复杂的条件语句的混乱。

在上面的例子中,前进的方向应该是

  1. 创建一个表示 Storage 对象的 abstract 类或接口(例如,IStorage),包含 save 和 load 方法。
  2. 创建该抽象的不同实现,例如 DBStorageFileStorageCacheStorage 等。
  3. 创建一个工厂,该工厂根据配置实例化正确的存储实现,并将其作为抽象(IStorage)返回。
  4. 当需要保存数据时,只需编写:myStorage.SaveSomething(…),其中 myStorage 是通过工厂创建的 IStorage 变量。

当然,这需要大量的工作,但仍然比维护条件逻辑的混乱要少得多。它还有一个巨大的优点,将在以下指南中解释。

滥用条件逻辑的开发人员正在挖掘自己的坟墓,通常会变得过度劳累。

3. 使用“缝隙”:扩展优于修改

“缝隙”是源代码中可以在不编辑代码的情况下更改行为的区域。“缝隙”利用了灵活性,它们之所以伟大,原因很简单:它们鼓励扩展架构而不是修改它。

为什么扩展优于修改?假设你是一名新开发人员,你需要为应用程序添加新的存储选项。

你愿意浏览代码并在保存内容的地方用新选项修改它吗?在你不完全理解的代码中摸索,冒着破坏某些东西或错过需要更改的百个地方的风险?

还是你宁愿创建一个全新的类,从头开始编写,实现 IStorage 接口,然后就完成了,甚至无需查看别人的代码,无风险?

在这种情况下,开发人员的信心程度更高,这并不奇怪。

4. 避免全局状态和非确定性行为

80 年代的 COBOL 开发人员早已知道滥用 global 变量是一种非常糟糕的做法。Global 变量很少有正当理由,其毁灭性的副作用会导致精神错乱。这个明智的旧规则在现代面向对象语言(如 C#)中仍然非常宝贵。

众多问题之一是它们创建了应用程序的全局状态,这会损害函数/方法的确定性行为;换句话说,使用相同的参数两次调用同一个函数可能会得到完全不同的结果。因此,代码是脆弱的,难以调试,并且在多线程上运行极其困难。

因此,强烈不鼓励使用任何形式的 global 变量/对象。

理想情况下,良好的架构是无状态的。但 M 实用地说,几乎所有架构都需要某种状态(例如,数据库、文件等)。

解决方案是**状态隔离**,它由两条简单的规则组成:

  1. 保持状态的生命周期和范围尽可能短(这意味着,例如,类成员应该是封装的)。
  2. 通过将状态包装到呈现为抽象(基类或接口)的独立层中来隔离状态。

例如,数据库可以通过数据访问层的明确定义的接口以业务实体的形式提供数据。隔离将使测试或故障排除问题时的工作更加轻松。

5. 避免将 Helper、Utility 等设为静态类

开发人员经常 tempted 创建 static 类,尤其是在处理助手、实用程序等时。开发人员诉诸于 static 类的原因与 COBOL 开发人员在其代码中使用全局变量的原因差别不大:它提供了对代码中任何位置数据的即时访问,无需任何努力。

不幸的是,static 类经常充当全局状态并产生应避免的非确定性。但情况更糟。由于 static 类可以在代码的任何地方使用,而无需显式作为参数传递,因此它们会创建 API 文档中未显示的秘密依赖项。代码行为因此变得越来越不具声明性,代码的清晰度和可维护性急剧下降。Static 类与全局变量和依赖项一起,在所有使用者中创建了紧密的耦合(耦合是可传递的)。

最后但同样重要的是,使用 static 类的代码无法独立进行测试,这使得单元测试成为一场噩梦。

除非出于性能原因严格需要,否则应避免使用 static 类。Static 变量仍然适用于常量对象(尽管不带 setter 的 static 属性会更好)或用于保存工厂类中对象的 private 引用。

6. 分离创建逻辑与业务逻辑

良好架构设计的主要原则之一是,创建对象和业务逻辑(对象实际做什么)是两个不同的关注点,应该尽可能分开。

对象的创建是属于专用类(如工厂或构建器)的关注点。它们应该唯一拥有创建对象的垄断权。

另一方面,对象应该只关注执行业务逻辑(理想情况下,只关注一项业务),而不必担心创建其他对象(依赖项)。

例如,一个名为 HTMLAnalyzer(用于分析 HTML 链接)的对象需要一个 LinkAnalyzer 才能工作。这意味着它依赖于 LinkAnalyzer 类,因此,LinkAnalyzer 的抽象应该作为参数显式传递给 HTMLAnalyzer 的构造函数,或者作为使用它的方法的参数。开发人员可能会考虑使用 new 语句在 HTMLAnalyzer 中创建 LinkAnalyzer

双重错误

  • 首先,new 语句创建了对特定类型的依赖(没有抽象);因此,行为被固化,并且在没有重大重构的情况下无法更改。
  • 现在无法独立测试 HTMLAnalyzer,因为我们将不得不同时测试 LinkAnalyzer

正确的方法是使用依赖注入,在需要时以及在需要的地方(例如,在构造函数中)显式且声明性地注入所有必要的依赖项。

在这种情况下,使用 HTMLAnalyzer 的对象将调用一个工厂来获取 LinkAnalyzer 对象的抽象(接口或基类),并将其注入 HTMLAnalyzer 构造函数。

在任何时候,都可以通过创建替代实现来更改 LinkAnalyzer 的行为,唯一需要的更改将在工厂中,而不是在实际的业务逻辑代码中,因为那里的更改成本高且危险。

一个简单的推论是,Singleton 设计模式本质上是错误的,永远不应该使用它。它将创建逻辑(它创建自身)和业务逻辑混合在一起,更不用说它将自身保持为 static global 对象,它永远不会被垃圾回收器收集(单例就像爱一样,永恒的)并且构成了我们已经谈过的讨厌的全局状态。

工厂是冗长、简单、易于控制且无聊的代码块,所有更改都很简单。

业务对象包含所有魔法技巧和复杂性,因此我们改变这里的次数越少,就越不容易出错。

将它们分开将导致更少的业务逻辑修改和更多的业务逻辑扩展。

7. 将构造函数排除在业务之外

作为上一点的推论,**业务逻辑永远不应编码到对象的构造函数中**。构造函数的目的只是分配一些属性、初始化变量、执行简单的参数验证,以及最终挂钩事件。如果构造函数主动执行与业务相关的操作,那么我们将永远无法将创建逻辑与业务分开,架构将永远是混乱的。

8. 迪米特法则

这个原则在开发人员中大多不为人知,当然,也是最有用的原则之一。不深入正式定义,该原则规定一个类应该只且严格地依赖于它正在使用的东西;因此,只有真正使用的东西才应该注入到一个类中。

我在 Google TechTalk 中找到的一个很好的解释(http://www.youtube.com/watch?v=RlfLCWKxHJ0)如下:

在一个电子商务系统中,每个用户都有一个 Wallet 对象,其中包含一组 CreditCard 对象和其他财务信息。负责在线支付的类有一个类似这样的方法:

bool Pay(Wallet wallet, string ccNumber, double amount) {
                CreditCard cCard = wallet.GetCreditCards().GetCard(ccNumber);
                return ProcessPayment(cCard, amount);
}

你会如何看待这样的付款方式?当你到梅西百货购买新衬衫时,你会把你的钱包里装满信用卡和现金交给收银员,让他/她从里面挑选正确的信用卡或钞票吗?当然不会。所以,如果一个支付类只需要一个 CreditCard 对象,那么就应该只给它一个 CreditCard——不多也不少。无需通过传递装有不必要对象的巨大容器对象(Wallet)来创建依赖项,这会产生紧密的耦合和安全风险。

容器对象(通常命名带有模糊的后缀,如 ContainerContextServiceServiceLocatorPortalEnvironment 等)不应作为构造函数或方法的参数传递(除非需要全部内容)。

9. 保持低复杂度水平

嵌套循环、switchif 的最内层是衡量方法复杂度的指标。例如,在另一个 foreach 循环中的 foreach 循环中的 if 被评为复杂度为 3。方法的复杂度是其代码的最大复杂度,并且永远不应超过 4。如果超过 4,那么就是时候重构了!

圈复杂度易于衡量,但它不是唯一需要注意的复杂度类型。
如果您想了解更多关于代码复杂度的信息,我在文章 “透明度法则” 中更深入地讨论了这个主题。

10. 不要有长方法

经验法则认为,每个方法都应该在屏幕上显示,而无需垂直滚动。如果不是这样,那么就是时候重构了。长方法通常是职责过多和过程式编程的标志。
重构长方法会让开发人员非常感激没有 global 变量。

参考文献

© . All rights reserved.