照片由Drew Hays在Unsplash上拍摄
我是一名高级研发工程师和前技术艺术家。自2005年以来,我一直从事专业编程。在这篇文章中,我将告诉你我编写大型程序的方法。这套技术是我在构建大型视觉效果生产管道多年经验后所融合的。
我自己对“大型程序”的任意定义是任何超过1000行代码的程序。你可能会说,1000行不是很大,但它通常足够大,可以开始将程序分解为多个源文件。当你开始考虑程序设计时,它通常也足够大。

你如何组织数千行代码?如何将大型程序分解为可重用组件?为了回答这些问题,我将向您展示两种适合大规模软件开发的具体技术:模块化编程和数据流编程。然后,我将尝试说明静态类型是一件好事,编程范式与大型程序结构无关。
模块化编程2016年,我开始在家里从事一个业余电子游戏项目。有一段时间,事情进展顺利。但随着项目规模的扩大,复杂性变得无法管理。最后,很明显,我根本不知道如何组织大型项目!
在我的职业生涯中,我写了大量代码,但大多数代码要么是3D艺术家工具,要么是仅由数据耦合的自包含脚本的整个管道。我的视频游戏项目是我第一次遇到单个大型程序。我知道我必须以某种方式将我的程序分解为可重用的部分,但我通常的分解方法,数据流编程,只是不适合游戏。编程范式也没有帮助,因为正如您将看到的,范式是微观的,而不是宏观的。最后,经过大量研究,我偶然发现了模块化编程。如果你只学习一种大型编程技术,这应该是那个。
典型的依赖关系图
模块化编程将大型程序分解为模块。模块通常是一个源文件或一小组源文件,在逻辑上对一些相关代码进行分组。模块有很多名字。在Python和JavaScript中,它们被称为模块。在Golang和Java中,它们被称为软件包。在Dart中,它们被称为图书馆。一些语言,如C和C++,没有模块系统,但模块化编程通常通过惯例完成。事实上,我发现的一些关于模块化编程的最佳文献来自关于C和C++的书籍。
现在让我们进一步定义模块化编程:
模块定义了一组导入——该模块所依赖的其他模块。以及一组导出——作为该模块公共接口的变量、常量、数据类型、函数和类的定义。模块也可以有它不会导出的私人定义。通过这种方式,模块实现了信息隐藏。程序是模块的集合,这些模块(理想情况下)形成依赖关系的有向非循环图(DAG)。在这个DAG中,节点是模块,边缘是“使用”关系。模块位于从高级(特定)到低级(通用)的光谱上。最高级别的模块包含程序的入口点,而最低级别的模块通常是通用库。在超级令人困惑的术语中,给定模块所依赖的模块称为上游模块。而依赖于给定模块的模块称为下游模块。因此,高水平=下游和低水平=上游。如果平均而言,给定模块使用许多其他模块,则模块依赖关系图是水平的。如果平均而言,给定模块很少使用其他模块,它是垂直的。甚至有一个软件指标来计算名为标准化累积成分依赖性(NCCD)的图表的水平度与垂直度。在图形理论意义上,水平图的耦合比垂直图低。几乎每种语言都有可用的工具,可帮助您可视化模块依赖图。例如,JavaScript/TypeScript有dependency-cruiser,C/C++有cinclude2dot,Dart有lakos(无耻的插头)。我鼓励您在大型项目上利用这些工具。例如,我设置了依赖巡洋舰,每次构建时都会运行。它真的帮助我了解我程序的宏观结构!由于DAG结构,模块分支可以“切断”并单独测试。要测试给定的模块,您只需要其所有上游依赖项。在我看来,这种独立开发和测试模块的能力是模块化编程的主要好处。如果模块接口定义明确,那么多个开发人员可以并行工作。
那么,既然我们已经定义了模块化编程,做好它的规则是什么?
模块之间没有依赖周期。这个设计规则将防止您的代码变成一个大泥球。当人们说他们的代码是“模块化”时,他们通常意味着他们的模块依赖性形成了DAG。依赖性周期很糟糕,因为它们增加了图论意义上的耦合。构成依赖周期的模块也可能是一个大模块,因为它们不能单独测试。这条规则可以通过编程方式强制执行,一些语言,如Golang,完全禁止循环依赖。稳定性在较低水平上增加。许多其他模块所依赖的模块最好有一个稳定的接口(或有一个版本号),因为如果该接口发生变化,一些下游模块也必须改变。正因为如此,低级模块比高级模块更重要。关于低级模块的仓促决策往往会导致技术债务。应该先开发(或从货架上购买)低级模块。在风险管理方面,该系统应该自下而上构建。可重用性在较低水平上增加。低级模块应该是通用库,以便在其他项目中重复使用。一段时间以来,我在家里和工作中都成功地使用模块化编程。这是构建大型程序甚至大型硬件系统的强大技术。我可以以自下而上的方式开发和测试每个模块,逐个组装我的程序。如果模块接口需要更改,我可以通过查看依赖关系图一目了然地知道哪些下游模块可能会受到影响。如果一个模块承担了太多的责任,我可以将一些责任推给较低级别的模块。如果我需要重新连接我的依赖关系图,我可以直观地看到我将如何做到这一点。
真正奇怪的是,我花了多长时间才发现模块化编程。当然,我知道Python模块。但除了将实用程序函数组合在一起的简单情况外,我从未真正使用过Python模块。我从未想过我可以递归地重复这个分组过程。只有当我回到过去了解编程的历史时,我才发现Modula-2、Oberon和C等语言中的“深度”模块化编程。据我所知,模块化编程在70年代末、80年代和90年代初很流行,在那里你会发现大多数文献。然后,面向对象编程开始主导编程世界,模块化编程概念似乎在25年的时间里基本上被遗忘了。这是一个耻辱,因为面向对象编程和模块化编程并不相互排斥!
(信息隐藏是唯一的重叠。)在我看来,模块化编程是宏观的,并概括了所有范式。
今天,模块化编程正在被重新发现,几乎每种语言都支持它或增加了官方支持(ES6模块)。在受C和Oberon启发的Golang中,模块(软件包)几乎是构建代码的唯一方法。当然,像Haskell这样的函数式语言一直都有模块。
不幸的是,我上面列出的一些更深层次的图论概念很少被讨论了。现代模块化编程文献似乎很少,好像每个人都应该从出生起就理解这些概念。还有人认为模块化编程值得更多关注吗?
进一步阅读:
John Lakos的大型C++软件设计。模块化编程圣经。强烈推荐。关于David Parnas将系统分解为模块的标准。1971年的这篇论文介绍了信息隐藏的概念。Project Oberon:Niklaus Wirth和Jürg Gutknecht的操作系统和编译器的设计。一本罕见的书,列出了使用模块化编程构建的整个操作系统的完整源代码。罗伯特·C的《C#中的敏捷原则、模式和实践》。马丁和弥迦·马丁。见第28章:包装和组件设计原则。Yelp如何模块化Sanae Rosen的Android应用程序。模块化编程的成功故事。数据流编程虽然模块化编程可以帮助您构建单个大型程序,但数据流编程可以帮助您构建许多互连程序的大型管道。作为一名前技术艺术家,数据流编程非常贴近和珍贵。我写过的所有代码中有一半以上都属于这个类别。数据流编程在VFX、3D动画和视频游戏行业中非常普遍。在这些行业中,如果不击中在某种数据流程序中工作的3D艺术家,你就无法扔石头(我的意思是字面意思)。流行的数据流程序是Maya、Nuke、Substance Designer和Houdini。这些程序通常被称为“基于节点”、“非破坏性”或“程序性”。
典型的数据流管道
让我们确切地定义什么是数据流编程:
在数据流编程中,程序被分解为称为节点的黑盒进程。这些节点有一组输入和一组输出。节点以某种方式将输入转换为输出。例如,在Nuke中,您可以使用读取节点加载图像,然后使用Reformat节点将该图像大小调整为四分之一分辨率,然后使用写入节点保存较小的图像。原始输入图像永远不会被覆盖,这就是为什么数据流编程被称为非破坏性编辑。一个简单的核管道来调整图像大小
节点被安排到“管道和过滤器”管道中,类似于制造装配线,管道携带数据,过滤器是过程节点。数据流管道总是形成有向非循环图(DAG)。节点从上游到下游以拓扑排序顺序执行。更改上游节点中的任何输入都会自动重新计算所有下游节点。通过这种方式,我们说数据正在流经节点。虽然数据流编程和函数式编程相似,但有一些重要的区别。首先,在数据流编程中,DAG的结构在某些运行时环境中外部指定。节点彼此不知之,而在函数式编程中,函数可以调用其他函数。其次,数据流编程不允许递归。第三,数据流编程通常设置为并行执行,而在函数式编程中,并行执行不是给定的。节点通常有自己的参数。这些参数通常与DAG一起存储在外部。有时参数是使用其他节点或表达式计算的。节点仅由数据耦合,这使得它们可以无休止地重新配置。艺术家可以随时检查特定节点的输出。这导致对过程的每一步都有深刻的理解。熟练的艺术家可以构建复杂性令人难以置信的节点网络,而无需编写一行代码。艺术家进行“视觉编程”的这种能力可能是数据流编程对他们如此有吸引力的原因。数据流编程超出了像Maya这样的桌面应用程序运行时。更大的运行时称为渲染农场管理软件,用于编排渲染器和其他命令行工具的大规模分布式管道。(如果您对Web技术比VFX技术更熟悉,请查看Apache Airflow。)这些是我经常负责设计和写作的管道类型。
但是,您如何编写可以插入数据流管道的命令行工具?写好这样一个工具的规则是什么?以下是我遵循的规则列表:
该工具不应该有交互式提示。该工具不能是交互式的,因为将运行该工具的渲染农场管理软件是一个自动化过程。因此,该工具只能接受命令行参数。该工具必须像一个纯粹的功能。唯一的区别是数据存在于磁盘上,而不是内存中。例如,如果您要编写命令行工具来在图像B上合成图像A,它可以具有以下规范:over <pathToImageA> <pathToImageB> <unpremultiply> <pathToOutputImage>。工具必须优雅地失败。错误处理可以通过两种方式完成:退出代码和日志记录。退出代码是编程的,而日志记录是针对人类的。退出代码0总是意味着成功。应该记录其他退出代码的含义。在上面的图像合成示例中,退出代码可能是:0: OK, 1: IMAGE_A_HAS_NO_ALPHA_CHANNEL, 2: INCOMPATIBLE_IMAGE_DIMENSIONS, 3: INVALID_IMAGE_FORMAT, 4: IMAGE_DOES_NOT_EXIST, 5: CANNOT_WRITE_OUTPUT, 6: CANNOT_OVERWRITE_INPUTS, etc.该工具必须是幂等的。使用相同的输入多次运行该工具应始终产生相同的输出。您应该假设由于重试,该工具将运行不止一次。该工具永远无法覆盖输入!它应该总是覆盖输出!
这些都没有--overwrite标志BS。这个工具应该尽可能地愚蠢。它永远不应该试图按摩无效的输入来使它们发挥作用。该工具必须始终退出。我曾经使用过第三方工具,该工具最后显示“按任意键退出”。请不要那样做。该工具不应了解任何其他正在运行的进程或框架。这取决于渲染农场管理软件来管理流程之间的依赖关系。将第三方命令行工具(或多个)包装成一个进程是可以的。产卵线也可以。避免数据冲突。这些工具被安排在渲染农场管理软件中的DAG管道中。DAG本身通常被参数化,以便同一DAG的不同实例可以并行运行。重要的是,一个DAG实例中的数据与另一个DAG实例中的数据完全隔离。您所要做的就是将每个DAG实例的数据放入一个单独的文件夹中。此外,您应该防止具有相同参数的DAG实例创建两次。避免数据损坏。如果您有一个以某种方式触及所有DAG实例文件夹中数据的批处理过程,该怎么办?在这种情况下,您必须停止所有正在运行的DAG实例,运行批处理进程,然后再次重新启动DAG实例。把它想象成一条人行横道。DAG实例是汽车,批处理过程是希望过马路的行人。如果行人不等汽车停下来,坏事就会发生。更新上游节点必须自动更新所有下游节点。切勿在上游(具有不同参数)的情况下重新运行单个节点,除非按拓扑排序顺序重新运行所有下游节点。如果您的渲染农场管理软件没有附带“重新排队下游作业”功能,请务必自己编写此功能。
仅此而已!
我可以从来之不易的经验告诉你,违反这些规则中的任何一项都会导致数据损坏。但通过实践,这些规则将成为你的第二天性。
每当数据流从一个进程流向下一个进程时,数据流编程就是我的去分解技术。在设计管道时,我做的第一件事就是绘制数据流图。一旦我确信我了解了数据和所涉及的流程,就可以收集测试数据,并开始实施流程。如果数据定义明确,那么多个开发人员(使用潜在的不同语言)可以并行工作。
进一步阅读:
完整的Maya编程:David Gould的MEL和C++ API的广泛指南。请参阅第2章:基本玛雅概念,以巧妙解释玛雅DAG的工作原理。静态打字是你的朋友今天,我更喜欢大型程序的静态类型语言。情况并非总是如此。长期以来,我的主要语言是Python。作为一名前技术艺术家,Python是我完成工作所需要的一切。事实上,我非常喜欢Python,以至于我回避了学习其他语言。因为,你知道,我已经可以用Python做所有事情了!
一切,也就是说,除了写一个大程序。
这是我的故事。2016年,我开始用Python开发一款视频游戏。有一段时间,我取得了良好的进展。然后,在大约2500行代码时,发生了一些奇怪的事情。我撞到了某种墙。发展放缓到爬行。重构变得非常痛苦。为什么?好吧,事实证明,我应该在写测试!
哎呀。所以我开始写测试,这绝对有帮助,但我仍然觉得我可以以某种方式提高工作效率。Python本身就是问题的一部分吗?
我决定休息一下,找出答案。这导致我踏上了一次旅程(更像是一种痴迷),尽可能多地学习不同的编程语言。以下是我学到的一些语言:Java、Kotlin、Dart、C#、F#、OCaml、Haskell、JavaScript、TypeScript、Elm、Clojure、Golang、C、C++和Rust。我还学习了旧语言,如Pascal、Modula-2和Oberon。这个想法是充分了解每种语言,以了解其起源、一些成语和一些用例。那些熟悉这些语言的人,以及它们有多么不同,可能会想象我的思想在Python-land之外有多远!
在尝试用许多不同的语言编程几个月后,我开始形成一个观点:
对于大型程序来说,静态类型比动态类型更好。
在自我记录代码、易于重构、减少认知负载、IDE支持和性能方面“更好”。
我开始认为语言类似于具有不同抗拉强度的材料。JavaScript和Python就像塑料。Java和C#就像铝。Haskell和Rust就像钢铁。
我不是说用动态语言编写一个大型程序是不可能的,只是它可能更难。此外,我并不是说用动态语言编写经过战斗测试的生产级代码是不可能的。显然,人们总是这样做。我只谈论非常大的程序,长达数千行,我相信静态打字会对你有很大帮助。相反,静态类型语言对小程序和原型来说可能太过分了。基本上,语言的选择应该与程序的大小直接相关。
我目前正在用TypeScript重写我的游戏,进展顺利。对我来说,在Python这样的动态语言和像TypeScript这样的静态类型语言中工作之间的真正区别归结为无所畏惧的重构。在一个大型Python程序中,我害怕重构,克服这种恐惧的唯一方法是编写(和维护)大量测试。然而,在TypeScript中,我可以更有信心地重构。如果我做出突破性的改变,我的IDE会像圣诞树一样亮起。我在TypeScript中编写的测试在编译器完成的静态分析之上提供了正确性保证。这给了我快速尝试新想法的信心。
进一步阅读:
我不需要Dimitri Merejkowsky的类型。一个和我相似的故事。意识形态。加里·伯恩哈特解释了为什么你需要类型和测试。范式是微观的,不是宏观的今天有3种流行的编程范式:面向对象、函数式和程序化。你会听到很多关于一个范式在编写大型程序方面如何优于另一个范式的炒作。当你在互联网上听到这种说法时,或者更糟糕的是,当你从你的老师那里听到这种说法时,我鼓励你保持怀疑!
以下是我所相信的:
任何可以在一个范式中表达的东西也可以在另一个范式中表达。一些程序员发现最“自然”的范式之一,因为它最接近他们的思维方式。但并非所有程序员都有同样的想法。一些程序员喜欢将名词(数据)和动词(函数)分开,而另一些程序员更喜欢在名词下分组动词。有些人对递归感到满意,而另一些人更喜欢循环。有些人试图将所有州推到郊区,而另一些人则更喜欢平均分布的州口袋。没有糟糕的范式,只有糟糕的程序员。一些范式可以说比其他范例更适合某些情况。用于计算的函数式编程。用于模拟的面向对象编程。自动化的程序编程。因此,使用健康的范式组合可能是最好的方法。老实说,在编程的背景下,即使是“范式”这个词听起来也很夸张。我会把它降级为“风格”之类的东西。实际上,3个主要范式只是小型中3种不同的编程风格——组织单个源文件的3种不同方式。编程范式是微观的,而不是宏观的。因此,就大型编程而言,你倾向于哪种范式并不重要。
进一步阅读:
Cristina Videira Lopes的编程风格练习。一个用Python编写的33种不同样式的程序。看乌龟的十几种方式。Scott Wlaschin描述了在F#中实现乌龟图形API的13种方法。