软件工程师的问题解决






4.90/5 (32投票s)
应用于软件工程的问题解决策略和启发法
引言
软件工程师的工作是一项永无止境的努力,需要使用不断变化的工具和技术来解决复杂的逻辑问题。我们花费大量时间学习热门技术,并跟进新的框架和方法论。然而,我们却经常忽视培养我们职业最核心的技能,即批判性地、创造性地思考问题及其解决方案的能力。
1945年,匈牙利数学家乔治·波利亚(George Pólya)出版了《如何解决问题》(How to solve it)一书,这是一本关于启发式、即解决问题的艺术的非常独特且富有洞察力的著作。尽管这本书主要关注数学,但其中提供的许多指导方针本质上是通用的,可以应用于任何逻辑问题。以下是我根据作为一名开发者的经验,尝试“调味”这些关键问题解决方法的过程。
在解决问题的过程中,有四个主要阶段是清晰可辨的:
理解问题、制订计划、执行计划和回顾。
理解问题
要解决问题,首先需要理解它,这似乎显而易见。但在 IT 行业,现实情况并非如此。在我所从事的行业中,由于对问题或需求存在初步的误解,导致整个应用程序和架构出现缺陷和瘫痪的情况并不少见。花时间深入理解我们需要构建什么,听起来可能不是最“敏捷”的做法,但错误开端的代价可能非常高昂。
我们通常在分析软件需求时开始了解问题,这些需求从用户的角度解释了事物应该如何工作。
a. 理解问题陈述
这是为了确保软件规范陈述正确而进行的健全性检查。
- 规范是否足够精确,可以进行编码和测试?
- 输入和输出之间是否存在清晰的关系?
- 是否涵盖了所有用例?
- 是否可以看到约束或目标的潜在矛盾/冲突?
- 是否存在可被解释的任意概念或句子?需求的术语是否可衡量?你能看到像“可能”、“通常”、“快速”、“许多”、“几乎”等模糊的词语吗?
b. 理解目标
在这里,我们质疑项目的目标和产出,以确保它们是合理的,同时也要揭示和明确我们在承担项目时可能面临的所有问题。
- 哪些目标是强制性的,哪些是期望的?
- 从业务角度来看,哪些目标是任务关键型的,哪些是辅助性的?
- 哪些目标是最具挑战性的?是什么使它们难以实现?
- 我们如何衡量目标是否已成功达成?
- 哪些风险可能危及目标?缺陷或停机会带来什么影响?
- 软件的目标预期会随着时间的推移如何扩展或改变?
- 哪些外部因素或依赖项可能阻碍目标的实现?
c. 理解数据
在这里,我们质疑软件的输入以及在软件正常运行之前提供或预期可用的所有数据。
- 是否提供了任何未使用的输入/数据?如果是,为什么提供?是否可以推导、计算或推断某些数据?
- 关于输入数据的正确性和完整性,可以做出哪些安全可靠的假设?
- 访问或更改数据的并发级别是多少?交易的边界和数量是多少?
- 数据元素之间的关系是否易于使用?数据的格式是否便于处理?
- 数据的价值是什么?预期的相关安全威胁是什么?
- 开发期间数据是否可用?能否以逼真的方式模拟?
- 数据的规模预计会如何随时间增长?旧数据可以被清除吗?
- 由于维护或紧急情况导致数据暂时不可用时,会发生什么?
- 数据是否可以分布式或集中式?如果不能,为什么?
d. 理解条件
在这里,我们质疑软件的所有假设、约束和条件,例如验证、业务规则、质量、可用性、安全性和性能要求等。
- 约束是否现实?它们为何都必要?它们是否隐藏了其他问题?
- 是否可以从现有约束中推导出其他约束(功能之间的依赖关系、外部依赖关系、不可避免的步骤顺序等)?
- 是否存在基于错误或未经证实的假设的条件(例如,客户可能认为通过添加一些限制可以使某个功能更容易或更便宜)?
- 是否可以添加更多约束,即使不是必需的,以简化某些场景?
- 是否可以通过修改流程或工作流程来移除某些约束?
- 是否存在不必要的、自我施加的条件,这些条件可能在隐式或显式中使用,并且可能基于对流程或功能的错误心理模型?
e. 构建模型
如果我们能通过图或 3D 模型来可视化几何问题,它们会更容易理解和推理。同样,如果构建简化的模型、线框图或原型来帮助我们可视化相关方面,软件功能也会更容易理解和推理。这可以让我们对最具挑战性或关键性的任务充满信心,并对我们将要通过更艰苦的工作在实际解决方案中解决的问题产生有利的亲近感。模型是与技术团队或所有利益相关者一起推理问题和解决方案的绝佳辅助工具。
制订计划
在充分理解问题之后,我们进入问题解决的核心阶段:规划。这是我们评估和设计不同解决方案策略的阶段;这时就是时候集思广益,孕育出使我们能够生产高质量软件并实现项目目标的想法了。
在此阶段,波利亚提醒我们,没有万无一失的方法来解决问题,他提出了以下发现规则:
- 要有头脑和好运气
- 坐下来,等待一个绝妙的想法出现
虽然没有机械的规则来解决问题,但波利亚也观察到存在启发式程序、心智操作、刻板印象式问题和建议,可以为聪明人提供解决方案的线索。在本文中,我将考察一个非详尽的列表,其中包含四种策略:类比、分解与重组、问题变体和逆向工作。虽然出于清晰度的考虑,这些策略将单独呈现,但在实际场景中,它们可能会被结合起来以导出解决方案。
类比
类比是一种利用先前已解决的、与手头问题密切相关或至少有一些共同之处的问题的知识的策略。为了能够使用已知问题的解决方案来解决相关问题,经常需要引入辅助元素,这些元素可以使该解决方案适应我们的目标和需求。
提示
你知道一个相关的问题吗?
你能想到一个具有相同或相似解决方案的熟悉问题吗?你该如何利用它?
在软件工程中…
在处理复杂问题之前,一个好的软件工程师应该花一些时间研究属于同一类别的众所周知的问题的众所周知的解决方案。我们很可能会找到书籍、博客和文章讨论不同的想法和方法、代码片段、开源项目、商业组件等。即使我们的问题是这样的,以至于我们无法完全使用我们找到的任何解决方案,我们仍然可以改编某些算法或代码片段来很好地满足我们的需求。即使一无所获,我们至少也能获得更多关于问题的知识,并为我们的设计选择提供一个比较的参照。
分解与重组
要解决一个复杂的问题,我们可以尝试将其分解成其他更容易解决的问题,并可作为达到我们原始目标的垫脚石。
提示
你能解决问题的一部分吗?你能分离条件的各个部分吗?
在软件工程中…
软件工程师是这种心智操作的宗师,他们一直在进行这种操作。我们将复杂的应用程序分解成小的、集中的组件,然后将它们聚合和连接起来,形成一个有机复杂的解决方案。面向对象分析、功能分解和设计模式(例如 MVC、MVVM 等)都是分解问题和重组解决方案的例子。
我们的大部分设计原则和过程都可以看作是分解问题复杂性的实践:关注点分离、状态与行为分离(函数式编程)、依赖倒置、迪米特法则等。
分解与重组不仅有助于控制复杂性,还有助于通过将以前解决方案中使用的组件以不同的方式聚合来解决不同的问题,从而促进重用,就像乐高积木一样。其他值得注意的例子是 map-reduce 大数据模式,这是并行计算的分解;可测试性分解,这是 TDD/BDD 所鼓励的;以及 SDLC 本身的分解成小的迭代,以降低项目风险。
问题变体
当一个问题看起来太复杂而无法解决时,我们就会通过不同类型的修改来解决一个源于原始问题的辅助问题。修改的目的是获得一个不同、更简单或更熟悉的问题,希望它的解决方案能帮助我们解决原始问题,或者至少提供一些有用的见解。
提示
你能想到更易于访问的目标吗?你能通过改变输入或约束来使目标更易于访问吗?
以下是一些典型的问题变体类型:
- 辅助元素
当我们不知道如何解决某个问题时,我们可以尝试解决一个相关的问题。添加辅助元素可以帮助将相关问题的解决方案桥接到更复杂问题的解决方案。
提示
我们能否引入辅助元素来利用已知的解决方案或更易于解决的问题?
在软件工程中…
继承是我们如何扩展基本功能以执行更具体或更复杂的任务的常见示例。应用诸如代理或装饰器之类的设计模式也是通过在更简单的解决方案之上添加元素来实现复杂解决方案的示例。例如,我们可以通过实现简单的功能(更简单的问题),然后用数据加密装饰器(辅助元素)将其包装起来,从而获得安全的功能。适配器也可以被视为能够适应我们解决方案设计的其他组件的辅助元素。 -
移除元素
当我们被一个阻碍我们前进的困难障碍所困扰时,我们可能会假装用想象中的魔杖将障碍移走。伟大的问题解决者使用这种心智操作来跳出思维定势,探索如果障碍突然解决,接下来会发生什么。在脑海中移除障碍可能会迫使我们的大脑退后一步,并在更广泛的背景下审视这些障碍。
提示
如果障碍不存在,你会怎么做?
在软件工程中…
开发人员很容易纠结于优化程度高等的障碍,而不考虑他们实际的收获(或损失)。如果我们被要求制造一个易于携带的行李箱,我们可能会花费所有时间和精力来获取轻便耐用的建筑材料。但如果我们暂时假装我们已经拥有了最轻便、最耐用的材料,那么我们就不会纠结于这个方面,也许会发现行李箱的重量至少是其内容的重量。我们可以继续考虑其他选择,例如在行李箱下面安装轮子。 - 泛化
更宏伟的计划可能成功机会更大。这个想法也被称为发明家悖论。通过泛化,我们不是解决一个具体的问题,而是解决一个更广泛、更通用的问题,其解决方案包含了我们需要解决的具体问题的解决方案。数学归纳法是通过泛化问题来解决问题的流行示例。
提示
眼前问题的复杂性是由其过度具体性引起的吗?
在软件工程中…
在某些情况下,通用解决方案可能更简单。例如,当具体性导致嵌套、复杂的条件逻辑和易变的代码,需要频繁且显著地修改以适应新的业务规则,并且有较高的出错风险时。
例如,让我们考虑一个基于数百个配置参数实现的验证引擎。每个参数可能有不同的可能值,表示应应用或跳过哪种验证规则。此外,还识别出规则之间的某些依赖关系:如果跳过验证规则 A,则也应跳过规则 B 等。如果我们尝试为特定解决方案构建一个**原型**,只实现一小部分配置规则,我们很可能会被我们必须编写的大量开关和 if 语句所震惊。然后,我们可以使用**类比**软件防火墙,通过将问题**分解**为可动态定义的优先级级联规则(而不是所有可能的变体都硬编码为静态配置值)来验证多个复杂条件。通过实现一个**通用的**验证引擎,复杂度得以降低。
- 特例化
与泛化相反,这种心智操作对于探索性目的非常有用。当问题的复杂性由其通用性和大量变量引起时,我们可能会决定尝试解决特殊的、极端的情况。我们这样做是为了探索问题的边界,希望获得更多知识或一些解决方案的线索。如果运气好的话,我们可能会重用特殊问题的解决方案,或者至少找出我们是否走错了方向。
提示
你能想象一个更容易访问的相关问题吗?一个特殊的问题?
你能用它吗?你能用它的结果吗?也许通过引入辅助元素?
在软件工程中…
QA 和测试工程师通常会考虑可能破坏代码或导致不正确结果的特殊输入。压力测试也是构建极端情况以揭示应用程序性能瓶颈和弱点的例子。在设计软件时,考虑恶意特殊情况总是很有用的,例如安全漏洞或访问共享资源时可能出现的竞争条件问题。总的来说,每个优秀的软件工程师都应该通过考虑所有可能出错的事情来利用代码,检查不期望的副作用、导致无效状态的原因等。
逆向工作
当我们不知道如何从给定的数据/条件得出解决方案时,我们可以尝试检查分析中达到的最后一点,然后倒退我们的步骤,直到发现一条从数据到目标的路径。反向寻找解决方案并不直观,并且存在一些心理上的困难,因为我们制定的步骤是让我们远离目标(起点),而不是朝向它们。
提示
你在目标中看到了什么特征?它们是如何得出的?
你能想到一个可以实现相同目标但使用不同数据和条件的解决方案吗?
在软件工程中…
有许多复杂的问题,其输入和目标都非常清晰,但没有明显确定的解决方案。专家系统通过模拟主题专家(人类)在不同情况下的判断来解决问题。同样,遗传算法的设计从最终结果开始,以确定使用哪个适应度函数。对于这类广泛的工程问题,逆向思考是一种常规做法,有时是唯一的选择。考虑这个问题:给定任何一家公司网站的主页(HTML 文件),找到公司的 Logo 图片。如果没有关于输入的假设,我们可能没有直接的解决方案策略,所以我们从最后开始思考。通过检查许多由真人手动识别的公司 Logo,我们可以收集许多有用的可测量属性:几何属性(位置、大小和比例)、标记属性(图片的名称和属性)、图形属性(格式、文件大小、颜色数量)等。使用定量分析,我们可以回溯步骤,以确定有效的规则集来推导出已知结果。最后,我们计划第一步从 HTML 中提取所需的属性。
关于视角的一些补充思考
解决问题的最绝妙的想法通常来自正确的视角。托勒密模型通过复杂的方程和人造构造(本轮)来描述行星的轨道。哥白尼模型通过将视角从地球转移到太阳,以惊人的简洁性描述了完全相同的轨道。最佳视角通常是最自然的(最接近现实),也是极大地简化我们思考问题方式的视角。
执行计划
制订计划需要分析能力、好想法和启发式推理。波利亚将计划称为我们建造一座桥梁以解决复杂问题的“脚手架”。脚手架是必不可少的,但本质上是临时的,我们在计划中使用的所有直觉、假设和合理的论证现在都需要被稳固工作的软件慢慢取代。
执行计划是一项综合工作,需要严谨和一丝不苟。我们需要仔细地验证和证明每一步,同时不忽视所有步骤之间的联系和关系。
a. 自顶向下执行
没有人比软件工程师更清楚“细节决定成败”。
当深入研究解决方案的细节时,自顶向下的顺序非常重要。在处理次要方面之前,我们需要先处理主要方面,以确保它们是合理的。立即开始编码虽然诱人,但在大方向模糊时风险也很高;因此,我们应该首先解决所有重要疑虑,并验证可能显著影响我们工作成果的主要假设。编码细节可能非常耗时;如果后来发现我们出色的代码实现了错误或不需要的功能,那将是昂贵的。
b. 重要性和挑战
风险是确定执行顺序的另一个基本因素。有些任务在大局观中比其他任务更重要。有些任务也比其他任务更具挑战性。如果一个步骤既重要又具挑战性,那么我们应该努力优先处理它,因为它可能对整个计划产生重大影响。
c. 打破依赖关系
步骤之间可能自然地相互依赖。每个软件组件通常依赖其他组件来完成其目标。在软件工程中,有时可以通过创建模拟或假的依赖关系来欺骗似乎是自然执行顺序的方法,这允许跳过不太相关的细节(稍后可以处理),而专注于给我们整个解决方案最高信心水平的高优先级任务。这种策略也有助于组织目的(例如,并行编码相互依赖的模块)。
d. 整合工作成果
复杂的软件解决方案可以由许多开发人员和团队执行,最终可能分布在不同的地理位置。这种情况会增加独立解决类似问题的工作量成倍增加的风险。
为了整合执行成果,可以采取以下步骤:
- 架构师/技术负责人应让每位开发人员了解其工作在高层解决方案中的背景。通常,这通过启动演示和提供解决方案背景文件来完成。
- 源代码的主体结构应预留特定的物理位置来存储除创建它们所针对的特定任务之外,还可以具有更广泛用途的组件。
- 通用组件应进行文档化并提交代码审查。准备就绪后,应向所有开发人员宣传它们,以让他们了解它们的存在。
e. 自下而上测试
数学家通过形式上证明每一步,从假设到结论来证明一个定理。同样,工程师通过编写正式的测试来证明软件解决方案有效。测试通常是一个自下而上的过程,从编写单元测试开始,然后向上移动到模块的函数测试,集成测试,直到整个解决方案。自下而上测试的优点是,如果低级测试失败,我们可以立即查明缺陷;另一方面,如果高级测试失败,我们可以专注于查找主要组件之间连接和交互的缺陷。低级测试实际上应该尽早创建(由开发人员完成),以避免通常与逻辑缺陷相关的耗时且昂贵的 bug 修复。编写的测试通常会自动执行,以确保在未来更改时(回归测试)的正确性。
f. 循规蹈矩与精通
波利亚描述了两种截然相反的态度,这两种态度在现代软件设计和开发方法论的背景下都非常适用。
循规蹈矩的软件工程师有良心地、不加区分地依赖于一套有限的、广为人知的、被证明在大多数情况下都成功的工具、模式和实践。这种类型的工程师严格遵守标准并逐字遵循方法论。
相反,精通的工程师则专注于模式和方法论的目的,抓住机会,并根据具体情况判断最适合每种情况的工具。
回顾
波利亚教导我们,复杂的问题永远不会被完全耗尽。问题解决的最后阶段是回顾我们已完成的解决方案,以拓展其潜力并巩固我们的知识。
在软件工程中,这个过程通常从代码审查、敏捷回顾会议和事后分析会议开始。以下是可以通过回顾实现的一些主要目标:
a. 清理
为了使我们的解决方案更精炼,我们需要消除重复、冗余和代码的冗长。简洁和清晰也是清理阶段的重要目标:删除死代码和不必要的步骤,用等效但更直接的算法替换复杂的算法,为类、模块等选择更有意义的名称。这有助于使解决方案更直观,一目了然。
b. 维护和可伸缩性
良好的软件解决方案需要能够应对未来的变化。因此,我们需要验证合理预期的维护影响是否符合初始预期。回顾也是利用可能影响我们可伸缩性计划的性能瓶颈的合适阶段。
c. 重用
模块化软件的主要目标是能够尽可能多地在不同环境中重用其组件。回顾可以揭示将解决方案的某些部分泛化或改编以用于其他项目,执行类似任务的机会。
d. 比较和改进
编写解决方案为我们提供了解决已解决问题的视角。
我们的视角不一定是最优的;然而,它总是与其他解决方案非常有价值的比较参照。审视解决方案可以巩固我们对业务领域的知识,并确定我们解决方案的哪些领域可以进一步改进。
撰写一份非正式文档可以成为记录所采用策略的高层描述、优点、已识别的限制以及在回顾期间出现的任何有趣想法或建议的异常有用的方法。