这篇文章讲的是C++编程里的一个大坑,就是那些一上来就画UML图、设计类继承树的书呆子行为。
作者Casey Muratori是个游戏开发老司机,参与过《The Witness》这款游戏。整篇文章用工资系统当例子,嘲讽那种"先建模、后写代码"的教条思维,然后祭出自己的独门绝技——"语义压缩编程"。
核心逻辑简单粗暴:先写重复的代码,等重复两次以上再抽出来复用,这样写出来的代码既干净又好用。最后拿游戏编辑器的UI代码实战演示,把一大坨手动计算坐标的东西,一步步压缩成清晰可读的结构化代码。
这哥们开场就给你整了个大型翻车现场
想象一下,你刚入职一家公司,老板让你写个工资系统。你心里美滋滋,翻开那本厚厚的C++圣经,准备大展拳脚。首先映入眼帘的是"员工"和"经理"这两个词,都是复数名词对吧,那必须得建两个类啊!Employee类、Manager类,一个都不能少。
然后你突然灵光一闪:不对啊,这俩不都是人吗?得搞个基类叫Person,这样以后写代码的时候,那些不关心你是员工还是经理的地方,直接把你当人看就行。这设计太人性化了!员工类和经理类瞬间感觉自己是独立个体,不再是冷冰冰的企业齿轮了!
正当你陶醉在自己的架构天赋中时,一个致命问题砸过来:经理本身也是员工啊!那Manager应该继承自Employee,Employee再继承自Person。完美!虽然你一行业务代码都没写,但你已经在建模的道路上狂奔,坚信只要模型搭得好,代码会自动从键盘里长出来。
等等,合同工!你们公司肯定有合同工!Contractor类必须安排上,因为他们不是正式员工。Contractor继承自Person,毕竟合同工也是人嘛(大概吧)。这设计甜到掉牙了。
然后噩梦来了:Manager该继承谁?如果继承Employee,那就没法表示合同制经理;如果继承Contractor,又没法表示正式编制的经理。这编程难度直接飙升到Simplex算法级别!你开始怀疑人生,头发大把大把地掉。
解决方案来了:让Manager同时继承两个类,用不到的那个放着就行。但这不够类型安全,咱写的又不是那种随便糊弄的JavaScript!然后你一拍大腿:模板!把Manager类模板化,基于它的基类做模板参数!所有用到Manager的地方也全部模板化!
这将会是史上最牛逼的工资系统!等我把这些类和模板全部设计完,就打开编辑器开始画UML图!
这种脑子进水的人居然真实存在
刚才那段要是纯搞笑就好了,但悲剧在于,世界上真有一堆程序员是这么想的。不是那种"实习生小明",而是各种大牛,包括那些到处演讲、写书的名人。Casey Muratori悲伤地承认,自己18岁初学面向对象编程时也这么干过,直到24岁才醒悟过来这整套都是马粪。这个觉醒很大程度上要感谢他加入了RAD Game Tools,这家公司 从来没跳进OOP的大坑。
虽然很多程序员都经历过这种黑历史,最终摸索出了写高效代码的正确姿势,但市面上的教材依然充斥着这种"客观上很烂"的教学内容。Casey怀疑这是因为:一旦你真懂了怎么写好代码,你会觉得这事太 直接了,不像什么高深的数学技巧那样值得炫耀发帖。所以老司机们懒得分享,结果就是新人必须踩六年OOP的屎坑才能醒悟。
于是Casey决定挺身而出,用一系列文章专门聊怎么把代码塞进电脑这个纯机械过程。他希望其他老司机也能出来分享经验。接下来他要拆解自己在《The Witness》编辑器代码里做的一系列改造,全是干货,没有花哨算法,就是实打实的代码结构。
原始代码居然写得像人话
《The Witness》内置编辑器里有个叫"移动面板"的浮动窗口,上面有些按钮用来对实体做操作,比如"旋转90度"。最开始这面板很小,只有几个按钮。Casey接手后开始加功能,面板内容要暴增,他得学会怎么往UI里加元素,以前从来没干过。
他先看了现有代码,长这样:
int num_categories = 4; |
Casey的第一反应是:Jon(原程序员)这代码写得真漂亮,给后续开发铺好了路。很多时候你打开这种简单功能的代码,看到的是一团乱七八糟的结构和间接层。但这段代码极其直白,读起来就像你在教一个人怎么画UI面板:"先算标题栏位置,然后画标题栏,下面画自动对齐按钮,如果被按下了就执行自动对齐..."
这就是编程该有的样子。Casey赌五毛钱,任何人都能看懂这段代码在干嘛,甚至不用看其他部分就能直觉知道怎么加新按钮。
手写布局这活儿太糙了
代码虽然漂亮,但明显不是为了大规模UI准备的,所有布局计算都是手工内联的。上面那段还算 mild,但看看这段更复杂的,一行放四个按钮:
{ |
所以在疯狂加新按钮之前,Casey觉得该先花点时间改造底层代码,让加新东西更简单。问题是:为什么他觉得该这么做?"更简单"在这里又是什么意思?
语义压缩这词儿听着高大上其实特朴素
Casey把编程分成两部分:搞清楚CPU到底要干啥才能完成任务,以及用当前语言最高效地表达这个意图。 increasingly,程序员花时间的地方变成了后者:把那些算法和数学公式组织成不会自己塌掉的 coherent whole。
所以任何有点水平的老司机都得琢磨出一套思考方式——哪怕是直觉层面的——来理解什么叫"高效编程"。这里的"高效"不只是代码跑得快,而是开发过程本身被优化:代码结构要最小化人类打字、调试、修改、发布所需的工作量。
Casey喜欢尽可能 holistic 地看效率。把代码开发的整个过程当成一个整体,你就不会漏掉隐藏成本。从代码诞生到最后一次被任何人使用,目标是最小化人类工作量。包括打字时间、调试时间、修改时间、适配其他用途的时间、为了让别的代码配合这段代码而做的额外工作。代码整个生命周期里的所有工作都算进去。
基于这种视角,Casey的经验告诉他:最高效的编程方式是把自己当成一个字典压缩器。 literally,假装自己是超级加强版PKZip,持续运行在代码上,寻找让代码语义上更小的方法。注意是语义上更小,即减少重复或相似的代码,而不是物理上更少字符,虽然两者经常同时发生。
这是一种非常 bottom-up 的编程方法论,最近被贴了个标签叫"重构",虽然Casey觉得这词儿蠢得要死,原因懒得细说。他也觉得 formal refactoring 那套错过了重点,但这也懒得细说。总之两者有点关系,看完这系列文章你会明白相似点和区别。
压缩导向编程到底长啥样
像好的压缩器一样,Casey不会在出现至少两次重复之前复用任何东西。很多程序员不懂这有多重要,一上来就写"可复用"代码,这可能是你能犯的最大的错误之一。他的口头禅是:"先让代码能用,再考虑让它可复用"。
他总是先直接打字,精确描述每个具体案例要发生什么,完全不考虑"正确性"、"抽象"这些 ,先把功能跑通。然后在第二个地方需要做同样的事情时,才把可复用部分抽出来共享,有效 "压缩"代码。他喜欢"压缩"这个类比,因为它意味着有用的东西;而常用的"抽象"其实没暗示任何有用的东西。谁在乎代码抽不抽象?
等到至少有两个代码实例才复用,不仅节省了提前思考怎么复用的时间,而且在尝试让它可复用之前,你始终至少有两个不同的真实案例说明这代码要干什么。这对效率至关重要。
类似地,像那种神奇的全局优化压缩器(可惜PKZip不是),当你遇到新地方可以复用之前的代码时,你要做决策:如果现有可复用代码已经合适,直接用;如果不合适,决定是修改它的工作方式,还是在它上面或下面加一层。多分辨率入口点是让代码可复用的重要部分,但这话题太大,留到以后文章再说。
这一切背后的假设是:如果你把代码压缩到紧凑形式,它很容易读,因为代码量最小,而且语义往往反映需求中问题的真实"语言"——就像真实语言一样,表达最频繁的东西会有自己的名字并被一致使用。(名可名非常名)
压缩好的代码也容易维护,因为做相同事情的地方都走相同路径,而独特的代码不会被无谓地复杂化或与使用场景分离。
最后,压缩好的代码容易扩展,因为写更多相似操作的代码很简单,所有必要代码都以可重组的方式准备好。
这些都是大多数编程方法论声称要做的(画UML图、建类层次、搞对象系统),但总是失败,因为代码的难点在于把细节做对。从细节不存在的地方开始, 过早抽象会忘记或忽略某些东西,导致计划失败或结果次优。
从细节开始,通过反复压缩得到最终架构,能避免所有提前构思架构的陷阱。
实战开始:共享栈帧这招太爽了
带着这些思路,看看怎么应用到《The Witness》的简单UI代码上。
Casey对UI代码做的第一个压缩恰好是他最喜欢的之一,因为平常却极其 令人满意。
C++的函数很自私,局部变量全锁在自己屋里,你拿它没辙(虽然C++规范像癌细胞一样 metastasize,开始加更多选项,但这是另一个话题)。所以当他看到《The Witness》UI代码里这样干的时候:
int category_height = ypad + 1.2 * body_font->character_height; |
他觉得该搞个共享栈帧了。
意思是,在《The Witness》里任何有面板UI的地方,这种事都会发生。他看了编辑器里的其他面板,有好几个,都有和刚才那段实质上完全相同的代码——相同的初始化、相同的按钮计算等等。所以很明显要把这些压缩,每个操作只在一个地方发生,然后被所有人使用。
但纯用函数包装不太可行,因为有一堆变量系统互相交互,在多个需要连接的地方交互。所以他第一步把这些变量抽出来放进结构体,作为这些操作的共享栈帧,如果需要的话可以让它们变成独立函数:
struct Panel_Layout |
简单吧?把那些看到被重复使用的变量抓出来塞进结构体。通常Casey用InterCaps给变量命名,lowercase_with_underscores给类型,但既然在《The Witness》代码库里,他尽量遵守它的惯例:Uppercase_With_Underscores给类型,lowercase_with_underscores给变量。
替换结构体后,代码变成这样:
Panel_Layout layout; |
还没变好,但这是必要的第一步。接下来他把冗余代码抽成函数:一个初始化用,一个每次新UI行用。通常他可能不会做成成员函数,但《The Witness》比他自己代码更C++风格,他觉得这样更一致(而且他也没有强烈偏好):
Panel_Layout::Panel_Layout(Panel *panel, float left_x, float top_y, float width) |
有了结构体,把原代码里这两行:
float title_height = draw_title(x0, y0, title); |
于是代码变成:
Panel_Layout layout(this, x, y, my_width); |
虽然如果只有一个面板这步没必要(代码只执行一次),但《The Witness》所有UI面板都这么干,所以抽出来意味着可以压缩所有这些代码(他确实这么做了,但这里不展开)。
看起来好多了,但他还想干掉那个诡异的"num_categories"和高度计算。深入研究后,他发现这代码其实就是在预计算面板用完所有行后的总高度。既然没有实际理由必须提前设置,他想:为啥不在所有行加完后再做,直接数实际加了多少行?这样更少出错,因为两者不会不同步。于是他加了个"complete"函数在面板布局结束时运行:
void Panel_Layout::complete(Panel *panel) |
他回到构造函数确保保存了"top_y"作为起始y坐标,这样只需相减就行。噗!不需要预计算了:
Panel_Layout layout(this, x, y, my_width); |
代码简洁多了,但从反复出现的draw_big_text_button调用看,还有很大压缩空间。于是下一步:
bool Panel_Layout::push_button(char *text) |
代码变得相当 nice 和 compact:
Panel_Layout layout(this, x, y, my_width); |
他决定美化一下,减少不必要的啰嗦:
Panel_Layout layout(this, x, y, my_width); |
啊!和原始代码比像呼吸新鲜空气对吧!看看多漂亮!接近定义移动面板独特UI所需的最小信息量了,这说明压缩做得不错。加新按钮也变得超简单——没有内联数学计算,只需一行创建行、一行创建按钮。
这才是"对象"的正确出生方式
Casey要指出非常重要的一点:刚才这些步骤看起来都很 直接 吧?他赌你们没有任何一步觉得"卧槽这怎么做到的"。他希望每一步都很明显,每个人都能轻松完成类似的抽取公共代码到函数的步骤。
既然如此,他指出:这才是"对象"的正确诞生方式。我们做了一个真正可用的代码和数据 绑定:Panel_Layout结构体和它的成员函数。它完全满足需求,完美契合,极易使用,设计 简单。
对比那些面向对象"方法论"的绝对荒谬:让你在索引卡上写东西(比如"类责任协作者"方法论),或者打开Visio用框和线展示事物怎么"交互"。你可以花几小时在这些方法论上,结果比开始时更困惑。但如果你忘掉这些,写简单代码,你总能在事后创建对象,而且会发现它们正是你想要的。
如果你不习惯这么编程,可能觉得Casey在夸张,但你得信他,这是真的。他花零秒思考"对象"或什么东西该放哪。"面向对象编程"的谬误正在于此:代码根本不是"面向对象"的。代码是过程导向的,"对象"只是让过程可复用的构造。所以如果你让这事自然发生,而不是强行倒着来,编程会变得 愉快。
下次继续:更多压缩然后扩展
因为需要花时间介绍压缩导向编程的概念,再加上Casey喜欢喷面向对象编程,这篇文章已经很长,但只展示了《The Witness》UI代码改造的一小部分。所以下次再继续,聊怎么处理那段多按钮代码,以及怎么用新压缩的UI语义来扩展UI本身的功能。
关于Casey Muratori这哥们
Casey Muratori是游戏开发圈的老炮,90年代就在这个行业摸爬滚打。他最出名的身份是RAD Game Tools的员工,这家公司专门做游戏中间件,业界地位很高。Casey参与过不少项目,但更出名的是他的技术写作和演讲风格——直白、幽默、爱喷人,尤其对面向对象编程深恶痛绝。
他写这篇博客时正在做《The Witness》的开发,这是Jonathan Blow(吹哥)的作品,就是那个做《Braid》的独立游戏大神。Casey在RAD Game Tools的工作经历对他影响巨大,这家公司以实用主义著称,从不搞那些花里胡哨的OOP架构,这让Casey彻底醒悟过来。
Casey的写作风格独树一帜:技术细节讲得透彻,但绝不枯燥;观点鲜明到近乎偏激,但论证扎实。他这种"先写重复代码再压缩"的理念,本质上是对软件工程中过度设计文化的反叛。在2014年写这篇文章时,"重构"和"设计模式"正被奉为圭臬,Casey却敢大声说"这些都是马粪",需要相当的勇气和底气。
这篇文章的独特价值在于:它不提供放之四海而皆准的"最佳实践",而是展示了一个老司机在真实项目中的具体决策过程。从原始代码到最终版本的每一步改造都有清晰的理由,读者可以看到代码是如何"生长"出来的,而不是从架构图里"画"出来的。
这种从具体到抽象、从重复到压缩的思路,比任何UML教程都更能教会人怎么写出好代码。
总之:
程序员别再画UML图自嗨了,先写重复代码再压缩才是正道,Casey Muratori用游戏编辑器实战演示什么叫真正的面向对象编程
Casey Muratori批判过早抽象的设计教条,提出"语义压缩"编程法:先写具体重复代码,出现两次以上再抽取复用,用《The Witness》编辑器UI改造实战展示如何自然生长出清晰架构。