透明性原则





5.00/5 (15投票s)
软件设计中透明性的介绍,重点关注面向对象语言
引言
透明性原则是软件设计的一个重要特征,它源自几十年前的 Unix 社区。这一概念由开发者和开源软件倡导者 Eric Raymond 提出并得到了很好的阐述。他首先研究了开源项目的运作模式,以及它们如何克服重重困难,通过动员全球开发者大军来生产可靠且可维护的软件。
背景
本文旨在介绍软件设计中的透明性,仅触及它诸多含义的表面。主要目的是为读者提供关于该主题的一些方向,以及一系列广为人知的、最能实现透明性设计的技术和策略的高层地图。
什么是透明性原则?
透明性原则是设计中两个可观察到的质量的结合,它们可以按如下方式描述:
“当一个软件系统能够让你一眼看过去就立即理解它在做什么以及如何做时,它就是透明的。当它提供了监控和显示内部状态的设施,使得你的程序不仅能良好运行,而且能被看到其良好运行时,它就是可发现的。”
不幸的是,在商业软件中,透明性原则作为一个整体很少被考虑,通常被简化为安全或法律问题。然而,它的最初目的是为持续监控和保证软件工艺质量创造有利条件,即使在充满挑战的情况下。
为什么透明性很重要
设计问题和不稳定的解决方案常常隐藏在复杂性背后。
虽然透明性本身不能保证良好的设计,但能够首先清晰明确地看到设计的能力,就像一个天然的聚光灯,使得问题更难被忽视。
代码透明性是实现质量的第一步,是能够长久存在于功能、技术和组织变革之上的软件的先决条件。
为透明性设计软件
透明性在于设计软件,使其代码的工作方式显而易见,以便于代码审查、理解、监控和调试。在我们深入细节之前,有几个前提值得一提:
- 在面向对象设计中,透明性不是封装的反义词。事实上,内部实现细节可以同时被隐藏,但又可以被检查。透明性不仅可以与封装共存,而且在许多方面通过促进组件之间的隔离来支持封装。
- 透明性是一个明确的目标,但软件设计的许多方面都可以为实现这一目标做出贡献。我们将回顾这些方面,并探讨其中每一个方面如何直接为实现目标做出贡献,特别是面向对象语言。
简单
简单的代码有助于透明性,因为它易于理解和推理。简洁的基本建议是设计小型、专注且独立的组件,并通过薄薄的粘合代码层连接起来。虽然这个建议很好,但关于这个话题还有很多值得探讨的地方。在他的一次演讲中,Rich Hickey(Clojure 语言的创造者)强调了简洁的一些深刻观点:
- 简洁不等于容易或熟悉。简洁很难实现,需要高超的分析能力,目标是便于变更和验证正确性。简洁与让开发者感到舒适或让他们的工作变得枯燥易被取代无关。
- 简洁/复杂性与我们有多聪明或多愚蠢无关。与我们能创造的复杂性相比,我们所有人精神上的局限性都很大。
- 简洁性与系统的组件数量关系不大,更多地与组件之间相互作用的方式有关。组件之间的纠缠越多,我们在思考它们时需要考虑的因素就越多:这会削弱我们快速理解代码中发生情况的能力,更不用说变更带来的影响会增加。
那么,哪些具体的原则和技术可以帮助我们设计出简洁的代码呢?
以下是一些技术技巧:
技术技巧 | 优势 |
单一职责原则 | 内聚性和正交性有助于孤立地思考组件。 |
抽象(接口) | 减少组件之间的亲密关系(纠缠)。 |
规则引擎、多态、通用算法 | 可以替代复杂的特异性、错综复杂的条件逻辑。 |
偏好数据复杂性而非代码复杂性,声明式数据操作(LINQ、Lambda 表达式等)。 | 处理和思考纯数据比处理代码容易得多。 |
消息队列和发布/订阅模式 | 打破组件之间复杂的交互,产生更简单、解耦的通信。 |
扁平且显式的组合优于继承层次结构。 | 深层继承链使代码难以跟踪和调试。仅为了代码重用而继承实际上是一个非常值得怀疑的设计选择。 |
将所有形式的状态(文件、数据库、共享内存、日期和时间等)与业务逻辑分离开/隔离。 | 混合状态和逻辑可能导致副作用,因此产生复杂性。 |
通过在整个系统中遵循相同的策略、约定和模式来创建一致性。 | 一致性减少了我们需要在头脑中考虑的因素数量。 |
最后,领域驱动设计教会了我们一个非常重要的经验:复杂性并非总是由技术因素引起。特别是,DDD 的两个基本概念有助于在阅读代码时进行更自然、更直接的思考:
- 采用通用语言,一种由工程师(并在代码中使用)和业务人员共享的清晰、无歧义的术语。
- 投入时间进行迭代分析和问题探索,以构建清晰、富有表现力的软件模型,有效反映需要管理的业务领域各个方面。
Eric Raymond 还有一条非常实用的建议:不要耍小聪明。
在设计软件时,把你的自尊心留在大门外。世界可以很好地生活,而无需那些开发者和架构师通过炫耀他们最酷的技巧而产生的海量不必要复杂性。
可预测性
如果我们不依赖于代码具有可预测的行为,我们就无法立即看出代码在做什么。我们有时过于专注于“把事情做完”,而忽略了其他开发者(有时甚至是自己)在阅读和使用我们的代码时的合理期望。
如何使我们的代码更具可预测性?以下是一些建议:
可预测的流程
可预测性的一个重要部分与操作调用的顺序有关,尤其是在设计 API 和接口时。为了使流程更具可预测性,以下两条规则可以有所帮助:
- 如果一个组件公开了操作,但没有任何机制强制要求操作必须按特定顺序调用,那么按任何顺序调用方法都应该是有效的。
- 如果一个组件公开的操作需要按特定顺序调用,那么必须通过要求每个操作的输入参数是必须在其之前的操作的结果,或者通过将该组件包装在一个仅公开有效调用序列的外观(Façade)中来强制执行顺序。
有时,当方法名称的后缀或前缀(例如,Init
、Cleanup
、Load
、Unload
、Begin
、End
、Open
、Close
等)隐式暗示顺序时,我们可以猜到这些规则被违反了。流程不可预测性的另一个常见迹象是,一个类公开了许多不返回任何值但会改变内部状态的方法。
可预测的逻辑
我们应用程序的核心业务逻辑通常是我们花费最多时间进行更改的地方,也是最难发现故障的地方。这里的可预测性收益最大,而且在设计阶段就予以考虑时,实现起来并不难。
- 逻辑透明性最强大的原则之一是引用透明性。如果一个函数对于相同的输入,在任何时候总是返回相同的结果,那么它就是引用透明的。这种独立于时间性可以让任何需要理解和修改代码的开发者获得高度信心,从而消除了非确定性常常带来的讨厌的意外和难以管理的复杂性。
- 获得逻辑可预测性的一个有效 OOP 技术是使用不可变对象。不可变对象的一个特性是,它具有一个只读的内部状态,该状态在实例创建时确定,并且在其整个生命周期中都不会改变。因此,不可变对象没有 setter,也不公开任何可能改变其内部状态的方法。如果需要新的状态,旧的不可变对象将被丢弃,并创建一个具有新状态的新对象。验证不可变对象通常非常简单,因为只需在创建时进行一次验证。不可变对象天然是线程安全的,并且没有状态转换极大地减少了逻辑错误的几率。
可预测的交互
组件之间的交互可能因多种原因而不可预测,其中大多数与糟糕的设计选择有关。
- 通用代码被放置在特定组件中,反之亦然,因此组件会在最令人意外的地方被调用:通用代码需要被重构为独立且易于重用的组件来支持特定功能,而不是吸收它们。
- 做同一件事的方式太多,改变同一变量的方式太多,方法重载太多:为通用灵活性设计的低级组件不应被直接使用,而应包装在安全、上下文感知的代理中,这些代理会折叠和限制组件的使用和可访问性,仅限于在每个特定场景中真正需要且有意义的部分。
- 缺乏良好封装的架构层和服务。一个良好封装的层的基本特征是它使用高层 API 与其上方或下方的层进行通信,这些 API 只交换数据结构(弱耦合),并使用任何更方便的表示形式(XML、JSON、POJOS/POCOS 等)。
协作者透明性
查看应用程序的一个组件时,需要多长时间才能找出它所依赖的其他组件?在找到答案之前,需要阅读多少代码?
当协作者隐藏在实现细节中时,一个重要的信息就会从我们眼中隐藏起来,迫使我们深入代码来理解与系统的其他部分的交互。
- 依赖注入是最有效的揭示类协作者的设计模式,通过将它们作为构造函数或方法的参数暴露出来。结果是,仅通过查看签名和 API,我们就能更多地了解一个类是如何工作的,以及我们需要什么才能使用它、重用它或测试它。
- 另一个保证真正协作者透明性的基本原则是迪米特法则,也称为“不要和陌生人说话”规则。该原则禁止欺骗那些并非直接使用的协作者,而是充当中介者与其他组件交互,从而模糊了元素之间的关系和交互。
内省
透明性原则还强调了可发现性的质量,即软件在其运行时提供有用内部状态信息的内省能力,以帮助开发者进行监控和调试。在内省方面投入时间和精力可以节省大量的故障排除时间。
以下是关于该主题的一些要点建议:
- 准确的错误消息:错误处理的目标之一是提供有意义的错误消息,其中包含大量信息以帮助我们查明原因。例如,许多异常消息(如 `null` 指针异常)并没有太大用处;因此,尽早捕获这些异常,然后抛出更具信息量和上下文相关的异常类型会很有帮助。
- 详细的日志记录:让应用程序记录其正在执行的重要步骤和操作。良好的日志模块允许定义不同级别的详细程度(特别是,记录一切的调试级别),并轻松更改日志的存储介质。生产日志文件通常是找出由数据问题引起的故障的唯一方法,而这些故障很难在其他地方复制。
- 文本化:创建应用程序状态和流程的可读文本表示,以便可以轻松地将其转储到文件、Shell、控制台等。例如,重写对象的 `toString()` 方法以输出其内部状态的格式良好的文本摘要,在调试器控制台中非常有用,可以避免耗时的检查分层对象结构。
- 调试选项:为您的应用程序添加调试模式,允许您对其进行修改,以提供额外的信息和故障排除功能。例如,调试模式可以允许模拟以不同安全角色登录 Web 应用程序,以便轻松重现不同的授权场景,直接在 UI 中显示丰富的调试信息,启用测试控制台(类似于我们在许多视频游戏中看到的作弊 Shell)等。
文档
文档是透明性的关键方面,但如果做得不正确,它也可能是一种巨大的时间浪费,甚至具有误导性并适得其反。文档不仅必须有用;它必须在现实中易于随着时间的推移进行更新和维护。
- 一些开发者为了遵守组织标准而写大量完全无用的注释。其他人则真心讨厌注释,并拒绝编写它们,认为代码本身已经足够好且自解释。这两种极端都有其缺点。注释在补充代码本身无法表达的内容方面非常有用,而且事实是,世界上没有任何开发者能够编写出 100% 自我解释的代码。注释通常有助于提供类目的摘要,解释变通方法或问题的历史以便更好地理解解决方案;它们可以揭示设计的可扩展点,明确关于方法输入和输出参数的假设,并为进一步改进提供建议。
- 外部文档(以非正式和友好的风格编写的教程)在帮助开发者在使用代码之前快速上手也非常有用,而无需打开源代码文件。
- 自动化测试也可以是一种很好的文档形式,有助于识别代码应遵守的形式规范和需求,同时提供有用的使用示例。
文化
激情、积极的态度以及开放分享知识和协作的文化是生产高质量软件必不可少的环境要素。在某些组织和团队中,创建正确的文化可能非常困难:需要逐步、谨慎地进行变革,以确保这些核心价值观被理解和接受。特别是透明性,需要开发者能够给予和接受建设性的批评,并愿意相互学习。
在正确的文化背景下,代码审查成为实现透明性的主要教育工具:共享代码以供阅读和理解是识别不良命名、过度设计以及上述所有模糊源代码的特征的最终考验。
总而言之,成功的态度或许可以从《Unix 编程艺术》这本书的几行文字中得到更好的描述:
“[...] 你必须相信软件设计是一门值得你付出所有才智、创造力和激情的技艺。否则,你将无法超越简单、刻板的设计和实现方法;你会在应该思考时匆忙编码。你会在应该无情简化时随意复杂化——然后你会奇怪为什么你的代码会膨胀,调试会如此困难。[...]”
参考文献
- Eric Steven Raymond - Unix 编程艺术
http://www.catb.org/esr/writings/taoup/html/ - Rich Hickey - Simple Made Easy
http://www.infoq.com/presentations/Simple-Made-Easy - Miško Hevery - The Clean Code Talks - Don't Look For Things!
https://www.youtube.com/watch?v=RlfLCWKxHJ0 - Eric Evans - Domain Driven Design: Tackling Complexity in the Heart of Software
http://www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215 - GoF - Design Patterns: Elements of Reusable Object-Oriented Software
http://www.amazon.com/exec/obidos/tg/detail/-/0201633612 - Brenton Alker - Applying Functional Concepts to OOP: Referential Transparency / Command-Query Separation
http://blog.tekerson.com/2013/08/12/applying-functional-concepts-to-oop-referential-transparency-slash-command-query-separation/ - Java Tutorials - Immutable Objects
https://docs.oracle.com/javase/tutorial/essential/concurrency/immutable.html - Giovanni Scerra - The Unwanted Inheritance
https://codeproject.org.cn/Reference/627369/The-Unwanted-Inheritance