敏捷编程






4.80/5 (42投票s)
2003年1月4日
23分钟阅读

229883
它是什么,为什么你需要它,以及如何向你的老板推销?
引言
我希望为日常编码者提供全面的信息——没有那种“我想推销东西,所以必须看起来格外聪明”的模糊层。这些是我通过分析自己的开发“挑战”、浏览网页、在CP和其他地方讨论以及亲自尝试而获得的个人观点。这不会是一个“简要介绍”,所以这里有一个概述,以防你想跳过一些内容。
我们从何而来
我们都知道这一点:你的项目有一个你引以为傲的巧妙设计。你考虑到了早期设计研究中出现的所有可能性,时间表已获批准,甚至还有额外的一周“意外缓冲”时间——你很高兴终于可以开始编码
了。两周后,第一批变更请求来了。没什么特别的,只是常见的“我们能把这个也做了吗?”——“是的,没大问题,我们只需要将一个 Carbunkulator 插入到 Arglebargle 中。”
项目进行到一半时,事情看起来就不那么光鲜了。又进行了一些功能调整,出现了一些错误,你最优秀的程序员在医院里呆了一周——项目进度严重落后。你的老板与客户谈完话后回来了,客户已经玩过第一个测试版。结果发现他们从来不需要一个 arglebarge,它只是因为他们的旧系统有一个非常昂贵的大型 arglebarge 才被写入规范中。他们真正需要的是一个大型 gonkulator,而且必须快——比现在快得多。哦,还有那个在设计时让你头疼的功能——你可以把它删掉了:唯一坚持要这个功能的人(尽管没人明白为什么)已经去了更好的地方。
无论原因是什么——应用程序最终与最初设想的不同。很有可能,它是一个混乱不堪、充满捷径,并且建立在一个复杂、极其低效的基础设施之上的烂摊子。你甚至可能害怕触碰它——因为这里的一点小改动会破坏那里的东西。每次你试图修复一些糟糕的行为时,你都必须费力地穿过大量相互依赖的代码,你看到的每一个函数、每一个类都在尖叫“重写我”。这与你想要的相去甚远。
有趣的是,你也可以通过完全跳过正式设计过程来达到同样的目的:你有一个想法,一个粗略的实现计划,然后开始编码。它开始得很好,但一段时间后,事情变得棘手:一个重要的库没有按预期工作,有些事情没有像你想象的那样成功,你被迫维护比你头脑中能处理的更多的分布式状态信息。
整个事情变得有点脆弱,尽管它大部分时间都能做你想做的事情,但使用起来却很痛苦。对于用户来说它很脆弱,对于你来说代码也很脆弱,可能没有人能够或愿意继续在这个项目上工作,你自己也不愿意做任何修改,因为一旦你开始清除那些不规范的地方,你会希望自己有力量重新开始。
哪里出错了?在第一种情况中,设计(可能非常适合初始需求)未能跟上项目过程中不可避免的变更。在第二种情况中,合理的设计未能演进。
我在这里讨论的解决方案旨在解决项目过程中出现的问题,帮助你避免此类情况。一旦你陷入庞大且难以维护的代码库中,就更难保持成功(或重新回到成功轨道)。至少,即使你觉得自己陷入困境,这里的许多技术也能帮助你在经济和精神上都不放弃。
敏捷编程
敏捷编程是一系列旨在克服严格基于设计开发周期的不灵活性的原则和技术集合。有三点使得敏捷编程非常强大
- 你不必将整个开发过程都按照敏捷编程模型来构建(当然你可以这样做)。你可以缓慢而渐进地改变项目管理,并且只需要采纳真正对你有帮助的部分。
- 敏捷流程不需要在设计或开发方面达到极致的卓越——相反,它针对的是具有
一些
经验的普通团队。 - 这些技术很简单,简单到大多数老手都认为它们是“常识”——要是真是如此就好了!
以下是我理解的核心规则
|
简单设计和随行设计
我认为这是敏捷编程方法的核心,也是最有可能改变开发过程的一点。
与其为所有细枝末节提前规划,不如确保“版本 0.1”运行良好。专注于你的下一个任务,并选择最简单的设计来实现它。这并不意味着忘记设计
!设计仍然是整个过程中重要的一部分,经典的优劣规则依然适用。额外的规则是:实现你的下一步,而不是第 10 步。不要为了你认为以后需要的东西而走得太远。当你真正需要它时,新的可能性将会出现,并且优先级肯定会改变。
为了让设计随着项目发展而演进,你需要时刻关注代码库。只用一些简单的技术并相信你的直觉,你大部分时间都能做得很好——这样当你不得不面对真正的挑战时,你的负担会减轻。锻炼你正在处理的部分意味着:随着时间的推移,你应用程序的“热点”会自动获得最多的关注。
尽管像重命名变量这样的单个事情本身可能看起来很傻,但累积效应却令人印象深刻。当理解你的代码库的感觉涌上心头时,那真是太棒了——不要错过它!
初始设计也将对你的项目产生巨大影响(尽管你最终通常会比采用严格基于设计的方法更灵活)。但不要担心太多:不同的设计可以支持相同的产品,一个简单的设计会给你一些可以着手工作的东西,而重构将确保你的设计随着应用程序的发展而成长。
如果允许类比:敏捷建模是用一个进化的过程来取代“完美创造”的意图:尽管7个颈椎对于老鼠和长颈鹿来说都不是完美的设计,但在两种情况下它都很好地完成了它的工作。
优点
- 你可以更灵活地应对需求变更和新增功能
- 整体设计几乎“自然而然”地保持简洁——华丽的装饰通常在它们变得庞大之前就被早期剔除了。
- 通过精简你正在处理的代码,最重要的部分会得到最多的关注,你不需要额外投入时间去修改不需要修改的地方。
- 当代码清理成为开发过程的技术性组成部分时,你更有可能得到一个注释良好、文档齐全、结构清晰、易于阅读的代码库。
- 你可能会更早开始编码(尽管整体上你不会因此变得更快)
- 你不会陷入死胡同
一个优秀的设计师/开发者可以通过“不那么敏捷”的方法取得同样的效果。但是,如果任由他自由发挥,一个高手很可能会非常接近敏捷方法。对于我们这些非高手来说,我们都容易受到开发压力和紧张的影响,在那些可怕的通宵达旦中,会忘记遵循那些被奉为圭臬的“良好实践”。
增量式、独立变更
增量式变更是幸福的关键,也是重构的核心思想。然而,我想将原则与技术分开,这就是为什么它有自己的段落。重复这两个规则
向你想要的方向迈出最小的一步。然后编译并进行基本测试,确保它仍然正常工作。并且始终只迈出一步——不要试图在重构时偷偷加入一个功能——尽管这可能很诱人。
对我来说,这些规则仍然需要一些纪律和自觉的努力。有时,直接废弃一个类并重新编写似乎是最简单的。然而,当我被打断时,说“五分钟”并完成手头的搜索和替换要容易得多;或者草草记下我在做什么。当我回到办公桌时,我可以继续工作,而无需前后查看我停下的地方,也不用担心会忘记什么。
优点:你始终拥有可以交付的可用代码。不要照字面意思理解并跳过QA——但在紧急情况下(例如客户现场的错误),你更容易在工作状态下
离开当前任务。内部测试可以随时获得新版本。你对新需求反应更快:不再是“我需要完成 Gonkulator 的重写才能添加每个人似乎突然急需的图形功能”。
此外,你的代码通过编译器的次数会多得多,并且会进行基本的“它能工作吗?”测试——特别是如果你进行自动化单元测试的话。这会增加对复杂代码的信心,并且可能是真正的救命稻草。
这条规则背后有一个人为原因:你的大脑中只能保留有限的状态信息(常说的“七件事”)。有意识地将任务分解为状态信息最少的步骤,旨在避免“短期记忆溢出”,这会让你忘记想做的事情,并对代码的复杂性感到不知所措。还有一个墨菲定律的原因:你采取的每一步都会比你预期的更复杂一点,有更多的依赖和副作用。例如,当将两个类重写为一个时,头文件包含顺序问题可能会让你偏离太远,以至于你只是忘记了再次初始化一个重要变量。
了解你的工具,了解你的原因
除了编写代码,许多事情也属于开发过程:设计、文档、质量保证……
第一个问题应该是:我为什么要这样做?这些成果的重要性众所周知,并且存在大量的技术和方法,它们往往声称或至少暗示自己是唯一的。但是,举个例子:你为什么要实际地编写代码文档?你还希望在6个月后理解你的代码吗?第三方应该能够根据你的API编写插件吗?它是为了告知同事接口或实现细节的变更吗?是因为你计划退休到巴哈马,所以代码库需要移交给一个待聘人员吗?这些是截然不同的目标,针对每个目标,都有不同的技术适用。
在这个例子中,文档有多种形式。UML图表,可由解析器提取的正式代码注释,内联注释,描述意图的独立Word文件,源代码控制变更日志等。如果你理解并使用多种工具,你会做得更好。关注新工具,不要忘记你现有工具中未使用的功能。
优点:花在非编码任务上的时间使用得更有效,不会感觉被浪费。再说一遍:这只是为了让你开心!
重构技术
重构并非魔术,重构是代码清理的一个花哨说法。更正式的定义是
重构意味着在可审查的小范围内,持续地改进代码库的设计和外观。 |
所有以下技术都是允许的
- 提高代码质量、可读性、设计
- 简单,甚至“笨拙”(例如对变量名进行自动查找和替换)
- 小而独立的步骤
重构主要有两个用途:首先,保持应用程序设计良好,以实现“边设计边进行”的原则。其次,你可以将一个较大的模块或类重构得更好,而不是完全重写它。这比重写需要更多的纪律(控制自己热情
使其更好),但通常风险更低,回报更高。
我将讨论分为两部分——一个基本技术的正式列表,以及一个包含较少自动化建议的真实生活示例。
基本重构技术
- 重命名变量/类型/类/函数
每个开发者或团队都有自己的编码标准——通常既有正式定义也有非正式的。将它们应用到你的代码中!如果你有一个函数ReadData
,还有一个配套函数DataWrite
——重命名其中一个,使它们保持一致。如果你有一个成员缺少其他人都在使用的 m_ 前缀,花一两分钟时间来修改它。当你计划修改多个标识符时,每一步只修改一个,然后编译并运行。使用独特的名称——这样当你忘记重命名某个地方时,编译器会捕捉到它。(哦,如果该函数位于你的10人开发项目的所有模块共享的接口声明中,请在执行前询问你的同事!)
- 重新格式化函数以符合你的编码标准
我们想要可读的代码——让它变得可读!通宵达旦容易写出有趣、几乎能用但难以理解的代码。我们不想扔掉它,所以先对其进行一些形式化的美化,然后再深入研究。
- 将代码序列转换为函数
如果一个方法的复杂性超出了你的舒适范围,或者你发现类似的功能在不同地方使用,就把它变成一个函数。
- 将多个类共享的功能移动到公共基类(或辅助类)
这可以将单个类的复杂性分解到合理水平。
- 将独立功能分离到不同的类/函数中
与上述相反。
注意 c-e 中的一个主题:我们只在必要时才引入基类。早期的设计决策通常是刻意不变的:因为设计大师如此说,因为它是激烈讨论的结果等等。在此过程中,决策的技术原因常常被遗忘,随之而来的还有:简洁性。
所有这些步骤大约需要 5 到 10 分钟——通常包括“编译和测试”。你可以在任何时候进行:当你感到无聊时,当你等待另一个项目编译时,当你不想在老板离开前离开时。随便什么。即使只采取一个步骤,也会让你的代码库变得更好一点,而且你将拥有可工作的代码。它们很容易撤销(假设你知道如何使用你的工具:编辑器和源代码控制)。
虽然决定做什么
需要你理解正在处理的代码结构,但执行
它却不需要:它们是简单的查找和替换或复制和粘贴任务,并且在 VS.NET 下有巧妙的工具可以安全地自动化它们。
而且,敏捷编程不会告诉你如何设计,只会告诉你何时设计。你仍然需要知道什么是好的设计,并自己找到它。
其他重构技术——一个真实生活示例
当我计划重构一个复杂的类或模块时,我从上面提到的事情开始。这有两个目的:首先,代码变得更容易阅读,更紧凑,不必要的冗余在这些步骤中被删除,这样我以后会轻松得多。其次,我再次相当熟悉这个类,刷新我的记忆。我发现哪些成员是热点,发现了告诉我我想做什么的旧注释等等。
只有当我完成了基础工作后,我才开始进行实际的更改。再次强调,我尝试采取能让我更接近目标并保持代码正常运行的最小步骤。在这里你需要更有创意——这些技术不再那么直截了当,你需要提前规划。这就是为什么我将使用一个真实示例来说明一些可能性。
最近,我重构了一个模拟map<int, struct>
的类实现,它使用了两个数组:一个数据数组保存值,一个键数组保存每个值的键,其索引与数据数组相同。为了加快速度,我尝试将值存储在它们的“原生”位置:例如,键17的值我会首先尝试插入到索引17。要查找一个值,我首先检查“原生位置”,然后我必须在键数组中搜索存储键的索引,然后从数据数组的相同索引中检索值。整个过程看起来像这样
if (keyArray.size() > key && keyArray[key] == key) // look up "native" position return dataArray[key]; else { int index = FindKeyInKeyArray(key); // linear search! (ugh) if (index >= 0) return dataArray[index]; }
(这种违背常识的暴行是从一个快速的侧边修补发展成一个通用数据守护类。我为此深感羞愧——好吧,不再羞愧了。)
第一步是重命名数组(最初命名为data
和map
)为上面提到的名称,这样以后在代码中和我的头脑中都不会出现名称冲突。
最终,我必须完全移除`keyArray`索引查找,并替换`dataArray`查找。因此,我做了“在文件中查找”`keyArray[`和`dataArray[`,只是想看看它们被使用了多少次。我震惊了——每个都超过20次。我需要在“注入”`map<>`之前,将这个问题进一步分解。
因此,我将一些不常使用的额外功能(影响大多数函数)移动到一个派生类中——由于之前的用法,这不会破坏任何客户端代码。虽然这没有将“热点”移出类,但热点本身的复杂性大大降低了。编译并运行——仍然正常工作。(后来我发现我在这一步引入了一个错误,甚至我的快速编写的单元测试都没有发现。但由于新
的更清晰的代码结构,它很快就被发现了)。
剩余的查找复杂性,尤其是在插入/更改值时,主要由“原生位置”处理支配——它可能没有太大帮助,并且使一切都变得丑陋。我决定完全删除它。虽然代码仍然可以工作,但性能可能会受到影响——这是一个我必须承担的小风险。最糟糕的情况是回滚到此步骤之前
(所以我在此处进行了提交)。
在移除额外的查找后,大部分热点函数都做了类似的事情
int index = MapID(key); // lookup the key if (index >= 0) { // when found... // do something to dataArray[index] } else { // when not found... // do something else }
我想,要用一个map替换它,它不会是一个简单的`m_map[key]`——`dataArray[index]`经常被多次使用,但我希望map查找只发生一次,而且我不需要operator[]静默插入新元素的特性。所以我写了一个辅助函数,包含了所有我打算更改的功能。
ValueType * GetValPtr(int key) { int index = MapID(key); // lookup the key if (index >= 0) { // when found... return dataArray[index]; else return NULL; }
并开始将查找替换为
ValueType * pVal = GetValPtr(key); if (pVal) { // when found... // do something to *pVal } else { // when not found... // do something else }
同样,非常简单的替换,尤其是我之前已经确保了局部变量和参数名称的一致性。我重命名了类声明和`GetValPtr`实现中的`dataArray`和`MapID()`,这样编译器就能捕获所有我仍然依赖它们的地方。我选择“pVal”作为新局部变量的名称,因为这个名称在类中没有被使用过。
完成这一步后,我对一个糟糕的想法进行了简洁的实现。这是一个相当大的改进。
一切都运行良好,所以我采取了最后一步:在类中引入一个`std::map<int, ValueType>`成员,注释掉`dataArray`和`keyArray`的声明,并用`std::map.find`调用替换`GetValPtr`的实现。
ValueType * GetValPtr(int key) { std::map<int, ValueType>::iterator it = m_map.find(key); if (it == m_map.end()) return NULL; else return &(it->second); }
当然,用map替换这两个数组还有其他一些副作用,暂时破坏了存储函数(需要遍历所有值),并将数组分配/清理函数变成了语法错误。这是一个很大的单一步骤,我不知道如何进一步分解(也许开始有点不耐烦了)。但由于所有的准备工作,只花了不到40分钟就完成了更改,用map迭代器替换了`keyArray`迭代,并使代码再次编译和运行。现在一切正常,我感觉非常高兴,睡得也更好了。
在整理代码时,我用一个特殊的注释标签标记了被注释掉的代码序列,这样我就可以搜索这些地方。因此,删除所有死代码(我最初为了参考和回滚而保留的)只花了一两分钟。
当然,还有一些事情可以做。在“插入新项”的实现中仍然存在命名不一致,并且`GetValPtr`函数可以完全删除,将`ValueType *`替换为`map::iterator`。但手头的任务已经完成,新任务正在等待第二天,所以我暂且搁置了。
示例中使用的重构技术
我所用的一些技巧的简要概述
- 将要修改的“热点”功能移动到一个辅助函数中,该函数为旧实现和期望的新实现提供相同的调用语法——这样你就可以将多个地方的语法更改(半自动化且可被编译器捕获)与功能更改(需要测试它们是否仍然做相同的事情)分开。
- 将要替换的“内联”功能移动到一个临时辅助函数中,你可以稍后将其删除
- 使用“在文件中查找”来查找项目中某个构造的出现次数——这样你就能找到热点,并知道是否可以一步替换它。
- 选择能让编译器捕获错误或你忘记更改的地方的名称
- 使用简单的重构技术,例如泛化变量名,直到你觉得能够处理更复杂步骤的复杂性。
向你的老板推销
好的,既然你还没睡着,你可能在思考一个问题:如何说服你的老板,重命名变量是值得你的薪水的?
- 最好的卖点是成功。
只需小范围地尝试这里介绍的一些技术和想法。在理想情况下,它们能帮助你高效地解决一个棘手问题——也许是一个长期困扰你团队的问题。你的老板可能会问你:“太棒了!你是怎么做到的?”你只需提到你“最近尝试了一些新学到的技术”……
- 重构是清理代码的一个花哨说法
使用花哨的词汇是有原因的:它听起来很新,很聪明,让你从不同的角度思考“寻常事物”。“敏捷编程”和重构都是流行词,你的老板可能已经听说过一些,并想知道他是否错过了什么。
- 除非你有非常严格的开发流程,否则敏捷技术可以逐步引入。所有敏捷技术的一个关键点是:只做对你有效的事情。你不需要彻底革新整个开发流程。从你分配到的任务的“增量变更”开始。如果你的任务是重写某个东西,考虑改为重构它。尝试新工具,以及现有工具中未使用的功能,首先用于次要的设计和文档任务。
- 记住敏捷编程的首要优势和最初意图:对需求变更的额外灵活性。需求会随着时间而变化。客户在尝试第一个测试版后可能会大幅改变他们的优先级。需要添加新功能。第三方组件(库或操作系统组件)会随着时间而变化。
敏捷流程的局限性
敏捷编程也不是万能的。它需要满足一些条件才能奏效
- 你需要一个开放、友好的团队。
如果程序员之间存在心理战,如果沟通不畅,或者你的同事将对“他们”代码的更改视为人身侮辱,那么它就无法奏效。
- 你需要团队中具备一些经验*
虽然你可能不需要设计超凡脱俗的大师,但你的团队需要相当丰富的实际开发经验。敏捷编程可以容忍一定比例的新手,但如果你的10人团队由9名新人和一个经验丰富的开发者指导组成,那么你可能更适合采用更正式的方法。
- 仅靠敏捷编程是不够的
你将需要其他技术。如果仅仅专注于敏捷编程技术,你可能会很快失去应用程序的“大局”。这不是好事——你仍然需要知道你在做什么,事物如何相互作用等等。敏捷编程是一种
工具,可以使其中一些任务变得更容易。
- 重构不会改变施工计划
通过重构,你的代码库的基本结构很少能被改变。通常,你可以朝着一个甚至可能看起来完全不同的目标努力,但它使用的仍然是与旧代码相同的基本机制。如果实现良好但结构错误,重写可能
会更快。仅依赖敏捷编程可能会阻碍时不时必要的大规模更改。
- 敏捷编程不会告诉你如何设计
尽管某些技术与敏捷编程一起流行(围绕用户故事设计、设计模式等),但并没有正式的机制。正如我所说,旧的设计原则仍然适用,但有些设计比其他设计更适合敏捷编程。**
*) 敏捷技术的一个流派(极限编程,也是备受关注的一个)强烈强调知识传播。如果你的团队技能多样,绝对值得一看。
**) 根据我(仍然很小)的经验,以数据为中心、分层良好的设计最适合敏捷技术——但这可能因为我个人偏爱它们。
附录
链接
- 在 CodeProject 上,Marc Clifton 的有机编程环境和自动化应用层非常值得一读,如果你正在寻找设计概念的话。据 Marc 说,它们与敏捷技术非常契合。(抱歉,对于那些有相关宝贵文章的 CodeProject 会员——我只是不了解它们。如果你知道相关文章,为什么不留下评论呢?)
- WIKI - 一个有趣的“开放数据库”,主要关注现代设计和开发技术——一个很好的起点是什么是重构。
- 敏捷建模 - 一个非常好的网站,包含比我这里能(或想)展示的更多信息,文笔流畅,不失深度。
为什么重构被称为重构?
尽管有不同的解释,但对我来说最自然的一个是:重构源于数学家“渴望”将一个像
F = xyz + 2xy -7xz + 3yz - 14x + 6y - 21z - 42
的表达式重组为它的因式
F = (x+3)*(y-7)*(z+2)
虽然两者完全相同,但第二个一眼就能展现其内部结构和重要信息。而且,这两个过程之间存在相似之处。