语义压缩

我们都知道如何使用C++编程,不是吗 ?  我的意思是,我们都已经读过由以及热闹的留着胡子的家伙们精选的好书,是他们率先定义了编程语言,因此我们已经都学过了编写C++代码来解决真实世界问题的最好方式.

首先来看看现实世界中的问题——比方说一个工资系统—— 你会发现他里面有一些复数名词: “员工(employees)”, “经理(managers)”, 等等.  因此你需要做的第一件事情就是为这些名词创建类.  至少应该有一个 employee 类和一个 manager 类.

但是实际上,它们两个都是人(people). 因此我们可能需要一个叫做 “人(person)”的基类, 这样在我们的程序中那些不在意你到底是一个员工还是一个经理的部分,就可以值把你当做一个人对待. 这是非常人性化的,而且使得其他的类感觉上不像是一个企业及其中的齿轮!

不过这儿还有点问题.  经理就不也是一个员工吗?   因此经理应该可能要从员工继承, 而后员工从人继承.  现在我们真的达成了某些目标!  我们还没有真正想过要如何去写一些代码, 确实是,但我们已经对相关的对象进行了建模, 而一旦我们拥有了这些尸体,代码就只是在自己编写自己了.

等等,啊 — 你知道了什么吗 ?  我刚刚意识到,如果我们还有承包商( contractors)该如何呢?  我们肯定需要一个 contractor 类, 因为他们不是员工. contractor 类可以从person类继承, 因为所有的承包商都是人 (是不是 ?).  这都会是不言而喻的 .

但是这样的话manager类该从哪继承呢?  如果它从employee类继承,那么我们就不能有从事承包商工作的经理. 如果它从contractor类继承,那么我们就不能有全职的经理. 这将会是一个真正困难的编程问题, 像是 Simplex 算法之类的问题!

OK, 我们可以让manager对两个类都进行继承, 然后只使用它们之中一个的特征. 但那样就没有足够的类型安全性了.  这并不是某些随意编写的JavaScript的程序 ! 但是你知道吗 ?  砰!  我就此得到了解决方案 : 对manager类进行模板化.  我们可以在基类上对manager类进行模板化, 然后所有随着manager类一起运作的东西都在其上被模板化了!

这将会是最好的工资系统了 !  当我梳理出了所有这些类和模板 , 就开始启动编辑器,去创建UML图形了.

程序员发编程贴

如果我刚刚写的都是荒谬的就好了,但可悲的是,实际上世上有许许多多的程序员都这样想啊. 我不只是在说 “实习生Bob” — 而是指所有类型的程序员,包括那些讲课和写书的知名程序员.  我也要痛心的指出在我的生涯中的一段时期也是这样子思考的.  在我18岁的时候被介绍了解了“面向对象编程”, 而直到大概24岁的时候才意识到这一切都是胡说八道 (而这种认识的形成少不了要感谢我在工作时使用的RAD游戏工具, 我很庆幸,它没有让我整个陷入OOP的噩梦中去).

但是尽管外头许多的开发者事实上都已经经历过像这样的糟糕阶段,并且最终得出了有关如何真正高效的写出好代码这样的总结,但是现有的海量教材仍然势不可挡的沦落到“中看不中用”之类。我猜想这跟一个事实有关,这个事实就是一旦你知道怎么做了,那么好的编程方式看起来就会是非常直接的, 不像那种徒然注重外表,让你想要去耗费时间才捣鼓出来的技巧,比方说一个花哨的数学技巧。因此,尽管没有任何数据佐证,我仍强烈的猜想着,有经验的程序员很少会花时间去告诉别人他们是如何去编写程序的,因为他们根本不认为这是什么特别的事情.

但他们应该这样做!这可能并不特别,但却是有必要的,而如果好的程序员没有开始去写点有关“如何好好的去编写程序”的东西给其他人看,那么我们将永远不会逃离泥潭,那时每个人就都要在认识到他们在浪费时间之前的六年时间,编写可怕的面向对象程序了。因此我要在接下来的一辑见证(Witness)文章上所做的,就是花一些重要的文笔讲述将代码放入计算机里去,这一纯机械的过程, 我也真诚地希望其他有经验的程序员能够花点时间也像我这样做一做。就个人而言,我喜欢读到更多有关那些实际上很优秀的程序员坐下来写代码时所使用的技术。

首先,我要开始细化一套简单的代码转换方案,我曾在《见证者》(The Witness)的编辑器代码中这么做过。在未来几周内,我将从一些比较大的案例中出来,在这些案例中我从头开始写了,但我用了全部的时间去痛苦地专注于代码和它的构建。我没什么要说的,在这其中根本就没有什么有趣的算法、数学或是其他的什么东西,一切都只是在“清理管道”。

乔恩(Jon)开始做事了

在《见证者》的内置编辑器中,有一块UI被称为“移动面板”(Movement Panel)。它是一个浮动的带有若干按钮的窗口,被用来展示一些如“旋转90度”的操作。最初这个窗口非常的小,只有几个按钮,但当我在编辑器上开展工作后,我加了一系列的特性,都需要用到移动面板。这将会在很大程度上扩大它的内容,并且也意味着我不得不学习如何向这个UI中添加元素,这是我之前从未做过的。我检查了现有的代码,这些代码看起来是这样的:

int num_categories = 4;
int category_height = ypad + 1.2 * body_font->character_height;
float x0 = x;
float y0 = y;
float title_height = draw_title(x0, y0, title);
float height = title_height + num_categories * category_height + ypad;
my_height = height;
y0 -= title_height;

{
    y0 -= category_height;
    char *string = "Auto Snap";
    bool pressed = draw_big_text_button(x0, y0, my_width, category_height, string);
    if (pressed) do_auto_snap(this);
}

{
    y0 -= category_height;
    char *string = "Reset Orientation";
    bool pressed = draw_big_text_button(x0, y0, my_width, category_height, string);
    if (pressed) {
    ...
    }
}
...

我首先注意到的事情就是原来的程序员Jon做得真的很不错,将我正要做成的事情打好的前阵. 许多许多次你一打开某些像这样如此简单的东西的代码时,你会发现它只是一大堆乱麻一样的不必要的架构和中间层.  而在这里我们发现的则是一系列非常直接的事情在发生着,读起来非常像你在那样子指导一个绘制出一个UI的面板: “首先,计算出标题条应该怎么摆放,然后,绘制这个标题条.  现在,在那个标题条下面,绘制自动捕捉(Auto Snap)按钮. 如果它被按下了,就做自动捕捉 . . .”   这正就是我们所设计的如何运行的.  我才任何的大多数的人都能从这段代码读到它在做的事情,而且可能不用除了这段摘录的代码之外的其它任何东西,就能凭直觉知道如何添加更多的按钮.

不过,这段代码虽然如此漂亮,但是它明显不是为了做大量UI操作而生的, 因为所有的布局工作仍然靠手工完成,一行一行的编写.  在上面的代码段中这有点轻微的不方便, 而且一旦你考虑到更加复杂的布局,事情就会变得更加的繁重, 就像这块UI的代码,在同一行出现了四个独立的按钮:

{
    y0 -= category_height;

    float w = my_width / 4.0f;
    float x1 = x0 + w;
    float x2 = x1 + w;
    float x3 = x2 + w;

    unsigned long button_color;
    unsigned long button_color_bright;
    unsigned long text_color;

    get_button_properties(this, motion_mask_x,
        &button_color, &button_color_bright, &text_color);
    bool x_pressed = draw_big_text_button(x0, y0, w, 
        category_height, "X", button_color,
        button_color_bright, text_color);

    get_button_properties(this, motion_mask_y,
        &button_color, &button_color_bright, &text_color);
    bool y_pressed = draw_big_text_button(x1, y0, w, 
        category_height, "Y", button_color, 
        button_color_bright, text_color);

    get_button_properties(this, motion_mask_z,
        &button_color, &button_color_bright, &text_color);
    bool z_pressed = draw_big_text_button(x2, y0, w, 
        category_height, "Z", button_color, 
        button_color_bright, text_color);

    get_button_properties(this, motion_local,
        &button_color, &button_color_bright, &text_color);
    bool local_pressed = draw_big_text_button(x3, y0, w, 
        category_height, "Local", button_color, 
        button_color_bright, text_color);

    if (x_pressed) motion_mask_x = !motion_mask_x;
    if (y_pressed) motion_mask_y = !motion_mask_y;
    if (z_pressed) motion_mask_z = !motion_mask_z;
    if (local_pressed) motion_local = !motion_local;
}

因此,在我们开始添加许多的新按钮之前,我已经觉得自己应该花一点点时间在底层的代码上,以使其能更容易的添加新的东西进去.  为什么我会这样子觉得呢,而我又如何知道在这种情况下“更简单”意味着什么呢?

语义压缩

我把编程从本质上分为两个部分:找出处理器在完成任务中真正需要做的事情,然后以我所使用的语言,用最有效的方式来表达.而事实上,程序员在后者上花费越来越多的时间:在将所有这些算法和数学形式连成一个整体,并且不会因其自身的重量而这件事情上进行缠斗.

所以任何有经验的优秀程序员一定会考虑高效地编程意味着什么——即便只靠直觉.说起 “高效 “,并不仅仅指代码优化.更准确地说,这意味着代码的开发过程也是优化的——即代码得是结构化的,这种结构化能够最小化敲代码,跑起程序,修改代码,进行足够的debug使其能够交付使用所付出的人类劳动力.

我喜欢尽量从整体上考虑效率性. 如果你将一块代码作为整体考察开发的过程,你不会对任何隐藏的花费视而不见. 在代码被使用的地方给定其性能和质量的等级, 从有了它开始到最后一次被任何人因为任何原因而使用到结束,我们的目标就是讲它所要耗费的人力成本最小化. 这包括了将其输入的时间,包含了修改它的时间,包含了将其适配到其它用途的时间. 它包含为了让它能与其运行起来的,而假如是被用不同方式写出来的,则可能不会有那么必要的代码的任何工作量. 使用这份代码的整个生命周期的所有工作都被包含了进来.

当以这种方式进行考虑的时候,我的经验让我总结出,编程最有效率的方式就是让自己就像是一个字典压缩器一样的处理你的代码. 从字面意思上看,你假装自己是PKZip的很棒的版本,它不断的在你的代码上运行,不断地寻找让它(在语义上)更加小的方式.  而要明白,我的意思是语义上更加的小,如重复和相似的代码少,而不是物理上更加的小,如文本少,尽管这两者经常如影随形.

这是一个非常地自下而上的编程方法, 它是一个最近被冠上绰号“重构”的伪变体 , 尽管它同时也是因为许多原因而被视为不值得进行的荒谬过程.  我也认为正式的“重构”这个概念丢失了要点,而并不值得我们去实践.  要点会是,它们是某类相关的,而你有希望将在阅读这一系列文章的过程中理解到的异同.

那么面向压缩编程会是什么样子呢,而它为什么是有效的呢?

就像一个好的压缩机,我不会重用任何东西,除非我至少需要两个它这样的东西.  许多程序员不理解这有多重要,并且立马就尝试去写“可重用的”代码,但那可能就是你会犯下的最大的错误之一. 我提倡的是,“在让你代码可以重用之前,先让它是可以用的”.

在每一个特定的情况中,我总是只去写出我确切的想要发生的东西, 没有任何有关”正确性“或者”抽象性“的东西,或者任何其它的流行词, 而我也让其运行了起来. 然后,当我发现自己在其它的某中情况下正在第二次做相同的事情, 那就是时候我该把可重复使用的部分拉出来进行共享,从而有效地”压缩“代码.  我更喜欢拿”压缩“来做比喻, 因为它意味着某些有用的东西,而不像常用来做比喻的“抽象”,它并没有表达出任何有用的东西. 谁会关心代码是否抽象了?

等待着,直到一块代码(至少)有了两个用到它的实例,意思是要直到我知道自己真的需要考虑如何去重用它,同时它的意思也是在我尝试去使其可以重用之前,我总是会有至少两个真正的,代码所要做的实例.  这一点对于效率至关重要,因为如果你只有一个实例,或者更加糟糕,(在没有想好就开始写代码的情况下)没有实例, 那么很有可能会在编写它的时候犯错误,并且会产生不那么方便拿来重用的代码.  这会导致你在使用它时更多时间浪费, 因为它要么将会很麻烦,要么你将为了让它能按你需要的方式运行而不得不重新编写它. 因此我努力尝试从不去使得代码“过早的可重用”, 导致要用到高纳德(Kruth)的那些麻烦东西.

同样的,像神妙的全局优化压缩器(可惜PKZip不是), 当你遇到可以将之前重用过的一个代码块再次被重用的新地方, 你要做决定: 如果可重用代码已经适用了,你就只要那它来用了,但如果它还不适用,你就要决定是否要去修改它是如何运行的,或者是否应该在它的上面或者下面引入一个新的层.   多个解决入口点是让代码可重用的一个大的组成部分, 但我要为之后的文章保留这个内容的讨论,因为它本身就是一个话题.

最后,这一切最基本的假设是,如果你把代码压缩到一种漂亮紧凑的形式,它就是容易解读的,因为它是最小量的,并且语义上也趋向于是反映出问题的真实”语言“, 因为像一个真实语言,那些最常被描述到东西都被给定了它们本身一直在被使用的名称.  被很好的压缩过的代码也是很容易维护的,因为所有的做相同事情的地方都采取的是同样的途径,而独特的代码没有不必要的复杂性,或者从它的使用处分离. 最后,被很好的压缩过的代码很容易被扩展,因为产生更多做类似操作的代码是简单的,所有必要的代码都以一种漂亮的可分解的方式存在.

所有这些都是大多数编程方法声称要在一个抽象方式中做的 (构建 UML 图形,创建类层次,创建系统的对象,等等.), 但总是没有做到过, 因为代码中困难的部分就是如何正确的处理细节.  从一个不必要存在的细节处开始就意味着你将会忘记或者忽略掉某些东西,造成你的计划失败或者导致产生不够好结果.从细节处开始并且重复地压缩以达成最终的架构则避免了尝试去提前进行架构会遇到的陷阱.

了解了这一点,让我们看看所有这些是如何应用到这个简单的Witness的UI代码中去的.

共享的栈帧

第一块我在其上做了代码压缩的UI代码出现在恰好是我最喜欢的一块,因为它非常容易做到,并且效果也令我非常满意.

在C++里的函数基本上都非常自私。它们把所有的本地变量都禁锢起来,以至于你真的不能去用它们做任何事情(尽管癌变的C++规范还在继续转移,它也开始为此增加更多的选择,但这又是另一个问题了)。所以说,当我看到像《见证》UI那样的代码时,我发现它们在做这些东西:

int category_height = ypad + 1.2 * body_font->character_height;
float y0 = y;
...
y0 -= category_height;
...
y0 -= category_height;
...
y0 -= category_height;
...

我想是时候由我来创建一个共享堆栈框架(shared stack frame)了。

我的意思是,在《见证》中,无论你希望哪里需要UI面板,这个想法都能付诸实现。我看了编辑器中其他的一些面板,它们都有实质上相同的代码,就像我在原始代码段中展示的那些一样-一样的启动、一样的按钮计算,等等。现在,我想我要压缩这一切的想法就很清楚了:每个东西都只会在一个固定的位置出现,并可以被所有人使用。

但是纯粹地在一个函数中所进行的封装并不是真的那么轻松, 因为要同诸多系统的变量交互,并且这些变量它们互相之间也需要在多个地方进行联系.  因此我在代码上做的第一件事,就是把那些变量都放到一个结构体中,那样如果我想要它们成为独立的功能,就可以将结构体作为针对所有这些操作的一类共享的栈帧来进行管理:

struct Panel_Layout
{
    float width; // renamed from "my_width"
    float row_height; // rename from "category_height"
    float at_x; // renamed from "x0"
    float at_y; // renamed from "y0"
};

简单吧, 对不对?  你只是将你发现的将会被重复使用到的变量掌握,并把它们放到了一个结构体struct中.  一般,我针对变量名使用的是内驼峰(InterCaps)方式,而针对类型使用的是小写结合下划线的形式, 但因为我是在Witness代码库中进行工作,所以我会尽可能的遵循它原有的编码约定, 针对类型使用大写结合下划线的形式、针对变量使用小写结合下划线的形式.

在我更换了局部变量结构后,代码会变成下面这样:

Panel_Layout layout;
int num_categories = 4;
layout.row_height = ypad + 1.2 * body_font->character_height;
layout.at_x = x;
layout.at_y = y;
float title_height = draw_title(x0, y0, title);
float height = title_height + num_categories * layout.row_height + ypad;
my_height = height;
layout.at_y -= title_height;

{
    layout.at_y -= layout.row_height;
    char *string = "Auto Snap";
    bool pressed = draw_big_text_button(layout.at_x, layout.at_y,
        my_width, layout.row_height, string);
    if (pressed) do_auto_snap(this);
}

{
    layout.at_y -= category_height;
    char *string = "Reset Orientation";
    bool pressed = draw_big_text_button(layout.at_x, layout.at_y,
        my_width, layout.row_height, string);
    if (pressed) {
    ...
    }
}
...

虽然还看不出进步,但这是必要的一步。接下来我把剩下的代码加入到函数中:一段添加在启动的时候,一段添加到每次 UI 产生新行的时候。通常情况下,我可能不会做去写这些成员函数,但自从“见证”变成一个更像 C++(C++-ish)的代码库,我认为这样更符合它的风格(不管怎样,我并没有对此有某种强烈的偏好):

Panel_Layout::Panel_Layout(Panel *panel, float left_x, float top_y, float width)
{
    row_height = panel->ypad + 1.2 * panel->body_font->character_height;
    at_y = top_y;
    at_x = left_x;
}

void Panel_Layout::row()
{
    at_y -= row_height;
}

当我有一个架构后,还需要带上下面两行琐碎的代码

float title_height = draw_title(x0, y0, title);
y0 -= title_height;

这两行是从原来的代码中获取的,我把它们包装成这样:

void Panel_Layout::window_title(char *title)
{
    float title_height = draw_title(at_x, at_y, title);
    at_y -= title_height;
}

现在代码看上去就是这样了:

Panel_Layout layout(this, x, y, my_width);
layout.window_title(title);

int num_categories = 4;
float height = title_height + num_categories * layout.row_height + ypad;
my_height = height;


{
    layout.row();
    char *string = "Auto Snap";
    bool pressed = draw_big_text_button(layout.at_x, layout.at_y,
        layout.my_width, layout.row_height, string);
    if (pressed) do_auto_snap(this);
}

{
    layout.row();
    char *string = "Reset Orientation";
    bool pressed = draw_big_text_button(layout.at_x, layout.at_y,
        layout.my_width, layout.row_height, string);
    if (pressed) {
        ...
    }
}

...

如果说这是唯一的面板(当代码只被执行一次),那它就不是必要的。但“见证”的 UI 面板都做着同样的事情,所以说把他们摘出来意味着我可以压缩全部的代码(我是这么做的,但我不会涉及这里)。

看起来更好一点了,但是我还想要避免掉那个怪怪的“数字类别(num_categories)”位以及高度计算.  进一步研究代码,我确定了它所做的全部就是在所有的行被使用了之后,提前计算了面板有多高.  因为没有任何实际的原因表明为什么这必须在前端被设置, 为什么不在所有的行被创建之后再做这事儿呢, 因此我只是数了下有多少实际被添加了,而不是强制程序去进行提前声明? 这使得它更不容易出错,因为两者不会脱离同步. 因此我添加了”complete“函数,他会在面板布局的最后运行:

void Panel_Layout::complete(Panel *panel)
{
    panel->my_height = top_y - at_y;
}

我回到了构造器,确信我已经将”top_y“作为开始的y进行了保存, 因而我所有要做的只是去掉这两个.  哈哈! 再也不需要什么提前计算了:

Panel_Layout layout(this, x, y, my_width);
layout.window_title(title);

{
    layout.row();
    char *string = "Auto Snap";
    bool pressed = draw_big_text_button(layout.at_x, layout.at_y,
        layout.my_width, layout.row_height, string);
    if (pressed) do_auto_snap(this);
}

{
    layout.row();
    char *string = "Reset Orientation";
    bool pressed = draw_big_text_button(layout.at_x, layout.at_y,
        layout.my_width, layout.row_height, string);
    if (pressed) {
        ...
    }
}

...
layout.complete(this);

代码变得更加简洁了点,而很明显还是有经常会重复进行的 draw_big_text_button 调用为大量的压缩留下了空间. 因此下一步我将那些都挪了出来:

bool Panel_Layout::push_button(char *text)
{
    bool result = panel->draw_big_text_button(
        at_x, at_y, width, row_height, text);
    return(result);
}

这样剩下的代码看起来更加的漂亮和紧凑了:

Panel_Layout layout(this, x, y, my_width);
layout.window_title(title);

{
    layout.row();
    char *string = "Auto Snap";
    bool pressed = layout.push_button(string);
    if (pressed) do_auto_snap(this);
}

{
    layout.row();
    char *string = "Reset Orientation";
    bool pressed = layout.push_button(string);
    if (pressed) {
    ...
    }
}

...
layout.complete(this);

而我也决定通过减少一些没必要的细节处理,来是的它更加的好看:

Panel_Layout layout(this, x, y, my_width);
layout.window_title(title);

layout.row();
if(layout.push_button("Auto Snap")) {do_auto_snap(this);}

layout.row();
if(layout.push_button("Reset Orientation"))
{
    ...
}

...
layout.complete(this);

哈!  跟原来的样子相比它像是一口清新的空气,是不是?  瞧它看起来有多漂亮!  它越来越接近实际定义移动面板的独立UI所必要的最小量信息, 就此我们知道压缩工作进行得不错. 而添加新的按钮变得非常简单 — 里面再没有数学计算,只有一个创建一行的调用,以及另外一个创建按钮的调用.

现在,我想要支出一些真正重要的事情. 所有的东西是不是看起来相当的直接呢? 我猜里面没有任何东西是你曾今喜欢的, “哦我的天哪,他是怎么做到的??”  我希望着如果只用把通用的代码处理到函数中,就使得每一个步骤都真正清楚起来,而每个人也都可以很简单地做到类似的这几个步骤.

因而鉴于此,我想要指出的就是这个: 这是创生出”对象“的正确方式. 我们创建了一个真正的,有用的代码和数据包: Panel_Layout 结构体及其成员函数. 它就是我们想要的,完美适合我们的需要, 真的容易使用, 它就是一点微不足道的设计.

可以把这同你在面向对象“方法学”中看到的绝对荒谬的东西做下对比,它们会告诉你一切先从(像“类的责任与合作者”方法这样的)索引卡开始, 或者是倒腾Visio来使用框框和条条去连接和展示“交互”是如何进行的.  你可以花它个几小时在这些方法上面,最后得到的是比你开始的时候更多的迷惑. 但是如果你只是忘掉所有那些没用的,而是谢谢简单的代码,你总是能够在这之后创建出你的对象,而你也将会发现它们就是你想要的.

如果你从未像这样去编程,你可能会想我说得有点夸张了, 但是你只要相信我就好了,因为这是事实. 我一点也没花时间考虑“对象”或者因为所以什么的.  “面向对象编程”的谬误就在于: 代码全是“面向对象”的。真是这样么. 代码是面向过程的,而“对象”将其进行了简单的构造,将其升级到让过程可以被重复使用. 因此,如果你只是顺其自然的让其发生,而不是刻意而为之, 编程会变得更加不甚愉快.

更多的压缩,然后进行扩展

因为我需要花些时间引入面向压缩编程这个概念, 也因为我吐槽面向对象编程,所以本文已经变得很长,而不只是展示我在Witness UI代码上做的代码转换这个小过程. 因此我会在下个星期的后续文章中节约篇幅, 里面我会聊聊有关如何处理我所展示的对个按钮的代码, 而后讲述的是我如何开始使用新的压缩过的UI语义来扩展UI自身可以做的事情.

本文文字及图片出自 OSchina

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注