循序渐进:增量编程

2020-10-22 22:33:31

有一件事真正提高了我的工作效率(也让我头脑清醒),那就是学会了如何接受一项大任务,并将其分解成更小、更容易管理的步骤。大任务可能会令人恐惧和不堪重负,但如果我只是继续做小任务的清单,那么不知何故,就像变魔术一样,大任务完成了。

在编程时,我对这种分解采取了非常具体的方法。我确保每个步骤都是编译、运行、通过所有测试并为代码库增加价值的步骤。确切地说,“增加价值”的意思是故意保持模糊的,但它可以是添加一个小功能,修复一个错误,或者朝着将代码重构为更好的形状迈出一步(即,减少技术债务)。

没有增加价值的一个例子是添加了一个新功能,但也引入了十个新的bug。目前还不清楚这一功能的价值是否超过了漏洞的成本,因此可能是净亏损。

另一个没有增值的例子是让UI更美观,但也让应用程序的运行速度降低了10倍。再说一次,还不清楚更漂亮的外观是否值得性能上的打击,所以这可能是一个净损失。

当然,我不能总是确信我没有引入bug,而且“值”本身就是主观的(新特性的性能值有多高)。重要的部分是意图。我的意图是始终通过每一次提交来增加价值。

基本上,我想要避免的是“必须先变得更糟,然后才会变得更好”的态度。也被称为:“新系统是做这件事的现代方式。当然,它现在有一些漏洞,运行速度有点慢,但一旦我们修复了这一点,它将比以前好得多。“。我见过太多的案例,这些所谓的修复从来没有发生过,而新的系统本来应该更好,但只会让事情变得更糟。

另外,你知道,增加价值的感觉很好。如果我每天都能做出承诺,而这个承诺在某种程度上让引擎变得更好,那我就会很高兴。

除了通过一系列小提交实现更改之外,我还将这些小提交中的每一个都推回主分支。

请注意,这与功能分支工作流正好相反,相反,它是一种基于干线的开发形式:

在功能分支工作流中,开发人员在独立的、独立的代码分支中处理新功能,并且在它们“完成”之前不会将它们合并回主功能:完全工作、调试、记录、代码审查等。

在基于主干的方法中,功能被实现为对主分支本身的一系列小的个体提交。必须小心,以便即使在功能仅“部分实现”的情况下也能正常工作:

功能分支方法的支持者声称,这是一种更安全的工作方式,因为对功能分支的更改不会扰乱主程序并导致错误。就我个人而言,我认为这种安全性是虚幻的,特性分支中的bug只是被隐藏起来,直到它被合并回master,这时我们突然得到了所有的bug。

特性分支也违背了我的理念,即每次提交都应该增加价值。功能分支背后的整个想法是:“我们会在这里打破一堆狗屎,但别担心,我们会在合并回主站之前把它修好的。”最好一开始就不要弄坏东西。

合并冲突更少。随着功能分支的长时间运行,分支中的代码会越来越远离master中的代码,导致越来越多的合并冲突。处理这些对于程序员来说是一项非常忙碌的工作,而且还有引入错误的风险。其中一些错误在分支合并之前是看不到的。

减少了释放日的混乱。通常,为某个版本安排的所有功能都有相同的截止日期。这会导致所有功能分支恰好在截止日期之前合并。这意味着就在发布日期之前,我们同时得到了所有的合并和集成错误。同时得到很多错误比让它们均匀分布要糟糕得多。而在发布日期前拿到它们绝对是最糟糕的时机。

不用担心合并的合适时机。因为每个人都知道合并一个功能分支往往会导致不稳定,这会导致人们担心合并的“合适时机”。您希望避免在发布之前进行合并(除非该功能是发布所必需的),以避免在发布中引入错误。那么也许就在发布之后?但是如果我们需要一个补丁来发布呢?在进行合并的同时,程序员宝贵的时间也被浪费了。

不急于合并。在处理功能分支时,我经常感到急于合并分支。有时是因为特定版本需要分支。但也经常是因为开发人员厌倦了处理合并冲突,想要结束它,然后转到下一件事上。因此,仅在功能分支“完成”时才合并它们的目标经常会受到影响。(当然,没有什么是真正“完整”的。)。

更容易还原。如果在功能分支合并后发现重大问题(通常会发生),通常会有很多人不愿意恢复合并。另一个大的功能分支可能已经在其上面合并了(因为很多功能分支经常同时合并,就在发布之前),恢复它会导致完全的合并混乱。因此,团队必须在发布之前拼命修复问题,而不是冷静、明智地回滚。使用基于主干的开发,很可能已经发现了任何重大问题。使新特性“上线”的最终提交通常是一个简单的单行更改,恢复起来很容易。

部分工作是共享的。在基于主干的开发中,所有开发人员(在主分支中)都可以看到在特性上所做的部分工作。因此,每个人都对发动机的去向了如指掌。错误、设计缺陷和其他问题可以及早发现。其他人也更容易修改他们的代码来使用新功能。当每个人都能看到它已经进展到什么程度时,也更容易估计完成一项功能需要做多少工作。

稍后更容易暂停和接听。有时,由于各种原因,某个功能的工作可能不得不暂停。可能还有更关键的问题需要解决。或者该功能的主要开发人员可能会生病,或者即将休假。对于功能分支来说,这是一个问题,因为随着代码库漂移得越来越远,它们往往会随着时间的推移而“腐烂”,从而导致越来越多的与分支的合并冲突。签入到MASTER中的代码不会以同样的方式“腐烂”。

更容易同时解决其他错误/重构。在处理特性或问题时,经常会发现您正在做的工作暴露出的其他相关问题。在基于主干的方法中,这不是问题。您只需向主干提交一个或多个单独的命令即可解决这些问题。使用功能分支方法,就会更加棘手。我想正确的做法应该是从master中分离出一个新的单独的bug修复分支,修复该分支中的问题,将该分支合并到您当前正在处理的分支中,然后(一旦通过代码审查)合并到master中(这样其他人就可以在合并您的功能分支之前获得bug修复,因为谁知道什么时候会发生这种情况)。但是谁有时间做这些狗屎呢?取而代之的是,人们只需在功能分支中修复问题,如果他们今天过得不错,也许可以将其挑选到主功能中。因此,现在,特性分支不再是单个孤立的特性,而是不同特性、错误修复和重构的混杂在一起。

基于主干的方法的主要挑战是如何将大任务分解为单独的任务。特别是要求每件作品都要编译好、运行好、增值好,随时可以推入大师手中。我们如何才能推送部分工作,而不会让用户接触到不成熟、还没有完全工作的功能呢?

适用于新功能的一种方法是使用标志来控制功能对最终用户是否可见。

让我们看一个例子。我最近添加到引擎中的一个功能是一个下载选项卡,它允许用户从引擎内部下载新的引擎版本和示例项目:

有很多不同的方法可以将其分解为更小的步骤。这里有一个例子:

将下载选项卡添加到菜单中,并在打开时显示一个新的空白选项卡。

通常情况下,我不会像这样预先做一个全面的分析。取而代之的是,我只是边走边想出下一个合乎逻辑的步骤。我只有在任务特别棘手的情况下才会坐下来认真地做计划,而且像这样的增量步骤不是自然而然的。

为了防止最终用户在选项卡实际工作之前看到它,我将其隐藏在一面旗帜后面。这可以简单到如下所示:

要获得显示下载选项卡的菜单选项,必须将download_tab_enabledflag更改为true并重新编译。

我们称这些标志为功能标志,因为它们选择性地启用或禁用应用程序的各个功能。一旦功能完成,我们就可以删除该标志,只留下真正的代码路径。

通过从配置文件、菜单选项或内部调试控制台初始化的动态布尔变量。

在这些选择中,我认为第三个是最好的。您希望向尽可能多的人公开您的新代码。这样,他们就可以发现你代码中的错误,如果他们重构了代码库,他们就会把你的代码考虑在内,等等。

如果您使用#DEFINE标志,您团队中的其他人甚至不会编译您的代码。因此,它们的某个更改很容易破坏您的代码。常量标志更好,因为您的代码仍将被编译,但是由于人们不能在不重新编译您的代码的情况下尝试新功能,所以大多数人不会费心。

有了动态标志,不想为应用程序重建而烦恼的艺术家、制作人或最终用户只需修改配置文件,然后对您的新功能进行测试运行即可。

到了发布特性的时候,您只需将特性标志的默认值从false反转为true,每个人都会看到新特性。如果有问题,您需要恢复,您只需将旗帜翻转回来。稍后,当特性看起来稳定时,您可以去掉标志,只保留代码中的真实路径。

您甚至可以进行部分、分阶段的推出。例如,您可以将1%的用户的标志设置为true,然后在监控崩溃日志和论坛中的任何问题的同时缓慢增加该标志。这样,如果遇到问题,只会影响一小部分用户,您可以快速恢复。

上述方法适用于新功能,但是如果您主要是重写现有系统,您应该怎么做呢?

在这种情况下,可能更难找到一种逐步递增的方法,因为您可能需要拆除旧系统的大部分,并且需要一段时间才能使替换的代码缓存具有相同的功能。

在这种情况下,使用功能分支方法可能很有诱惑力,但同样,我认为这不是正确的策略。一个大问题是你会有两个相互冲突的目标。一方面,您希望尽早合并,这样人们就可以看到改进后的代码,而您可以对重写代码进行一些测试;另一方面,您希望尽可能地推迟合并,这样您就可以达到特性一致性并消除所有错误。通常情况下,这两者的结合并不令人满意。

更好的方法是进行并行实现,让引擎拥有同一系统的两个副本。

如果您正在进行大修,您可以从只将整个系统代码复制到新文件夹开始。如果您要从头开始重写,您可以从一个空文件夹开始。

根据您要更换的系统的性质,您可以让两个系统(旧系统和新系统)并行运行,也可以有一个功能标志来选择哪个系统应该是默认系统。例如,如果要重写物理模拟,可能需要一个标志来选择是使用旧的还是使用新的,因为您希望所有物理对象都位于同一模拟中(否则它们不会交互)。另一方面,如果您正在重写粒子效果系统,您可能会同时运行两个系统,并且只需为每个播放的效果选择在旧系统还是新系统中播放。

拥有并行实现可以让您更顺利地从旧系统过渡到新系统。团队中的每个人都可以很容易地测试新系统。您可以将其与旧系统进行功能完整性、稳定性、性能等方面的比较。如果适用,您甚至可以运行自动测试来验证新系统产生的输出是否与旧系统完全相同。一旦彻底验证了新系统,您就可以更改标志并开始将其用作默认值。

并行实现还为最终用户提供了更为温和的升级途径。您可以只将系统选择标志暴露给最终用户,而不是将每个人都绑定到相同的“合并日期”。渴望在新系统中试用改进功能的用户可以提前打开旗帜,而喜欢稳定性或依赖旧系统某些怪癖的用户可以决定继续使用它,即使在新系统成为默认系统之后也是如此。

而且,一旦新系统成为默认系统,就不会立即急于淘汰旧系统,一旦维护它的负担超过了保留它的价值,你最终可以反对并逐步淘汰它。

对于我们的最后一个问题,让我们考虑一些更棘手的问题-对整个代码库进行重大重构更改。可能是这样的:

更改常用类型,例如从std::string类型切换到内部字符串类型。

它们往往会触及大量代码,从而增加合并冲突的风险。

要了解如何渐进地完成它们通常是很棘手的。例如,如果更改函数的参数,则必须更新所有调用点。否则,代码根本无法编译。

在某些情况下,如何逐步解决问题可能相当简单。例如,当我们添加一个新的警告(如-WShadow)时,当编译没有警告的代码时,我们为使带有警告的协反编译所做的修复不会导致任何问题。因此,我们只需打开警告,修复我们收到的一大堆错误(无论多少错误都会进行适当大小的提交),再次关闭警告,然后提交结果。冲洗并重复,直到所有警告都已修复,然后执行最后一次提交,将-Wdow标志打开为Everybody。

在其他情况下,我们可以采用并行实现策略。假设我们有一个在整个代码中用于分配内存的函数:

现在我们要添加一个系统参数,以便可以将所有内存分配标记为属于特定系统(游戏、图形、声音、动画等):

如果我们像这样添加参数,直到我们修复了所有调用点,代码才会编译。也就是说,渐进式的方法不会奏效。

现在,我们可以增量地将所有代码转换为使用mem_alloc_new(),而不是mem_alloc()。一旦我们转换了所有代码,就可以删除mem_alloc(),然后执行全局搜索并替换,将mem_alloc_new()重命名为mem_alloc()→mem_alloc()。

最后一个挑战是最棘手的挑战之一--改变代码库中的一个基本类型。

事实上,我最近与此发生了冲突,这就是促使我写整篇博客文章的原因。在我的例子中,我想重构我们的代码库,引入一种新的类型来表示Truth中的对象的ID。这是我们在机器上的主要数据存储。编辑器处理的所有数据都存储在Truth中,Truth中的每个对象都由其ID引用。

我们过去只用uint64_t来表示Truth ID,但是因为我们有很多其他的值也由uint64_ts来表示,这就造成了越来越多的混乱。为了更好地记录Truth ID的预期位置,以及防止传递其他uint64_t值的一些类型安全性,我们决定从使用普通uint64_t切换到将其包装在struct中:

请注意,由于到处都使用Truth ID,因此此更改会影响代码中的数千行。

有点傲慢的是,我仍然开始将这种重构作为一次集中的推动。我知道我应该循序渐进,这就是这篇博客的全部意义所在,但有时我会搞砸。

当我工作的时候,我意识到这个变化比我原来想象的要大得多,我不可能一下子就能完成它。我开始有一种情绪低落的感觉,觉得事情正在失控。我更改了数千行代码。我真的确定我没有在任何地方引入打字错误吗?

取而代之?运行一些测试会增加我的信心,但是因为代码只有在我修复了所有东西之后才会编译,所以我不能运行任何测试。

然后,我开始与其他人的更改发生合并冲突,我必须手动解决这些冲突,希望我对他们的代码理解得足够好,不会破坏任何东西。描绘所有这些变化的后果变得越来越难。我引入了多少个错误?

在过去,我经常试图通过“强力克服”来应对这些“恐慌”情绪。开始进行长时间的深夜编码会议,试图摆脱这件正在我掌控中的事情。在其他人有机会推送任何东西之前,让我的所有更改生效,这样我就不必处理合并冲突了。(当然,这仅仅意味着他们必须改为处理合并冲突。哈哈!)。

但这几天我可能更聪明了一点?我意识到,这些感觉是一个迹象,表明我已经吃了太多东西,正确的反应不是向前推进,而是后退一步,反思并尝试重新阐述问题,这样就可以通过一系列较小的步骤来解决问题。根据我给自己带来了多大的麻烦,我可能能够挽救一些更改,也可能干脆把它们全部扔掉,用迭代的方法重新开始。即使当我不得不扔掉所有东西的时候,我也从来没有后悔过。最后,在一系列小的、可控的步骤中工作效率要高得多,以至于我觉得我总是能找回我“失去的”时间。

回到手头的问题上来。我知道我想逐步改变类型。也就是说,在一些文件中更改它,而不是在其他文件中更改,并且仍然能够编译和测试代码。但是,当一切都依赖于其他一切的时候,我怎么可能做到这一点呢?一旦我将一些参数更改为使用tm_tt_id_t,任何使用uint64_t调用它的操作都将产生错误。

我的主要见解是,这种情况与Enabling-Wdow非常相似。这种转换很容易完成,因为我们将代码从仅在-WNO-SHADOW下编译改为在-WNO-SHADOW和-WNO-SHADOW下编译。我们可以递增地进行此更改,直到我们最终准备好打开-wdow。

让我们在这里做同样的事情。我们将ID对象的类型称为ID_TYPE。现在,我们的代码在ID_TYPE为uint64_t时进行编译。我们的目标是重写该代码,以便在ID_TYPE为uint64_t时和为tm_tt_id_t时都编译。如果我们可以增量地转换到该状态,那么一旦ID_TYPE为tm_tt_id_t时所有代码都编译完毕,我们就可以将其设为默认值。

Uint64_t get_data(uint64_t资产){const tm_the_true_object_o*Asset_r=tm_tt_read(TT,ASSET);uint64_t data=api->;get_subbobject(TT,ASSET_r,TM_TT_PROP__ASSET_OBJECT);返回数据;}。

ID_TYPE GET_DATA(ID_TYPE ASSET){const tm_the_true_object_o*ASSET_r=tm_tt_read(TT,ASSET);ID_TYPE DATA=api-&g。

.