差分执行是如何工作的?

87

我在Stack Overflow上看到了几篇提到这个问题的帖子,但是查看维基百科(相关页面已被删除)和MFC动态对话框演示并没有让我有所启发。请问有人可以解释一下吗?学习一个基本不同的概念听起来很不错。


基于回答:我想我对此有了更好的感觉。我猜第一次只是没有仔细看源代码。目前,我对差分执行有些矛盾的感受。一方面,它可以使某些任务变得更加容易。另一方面,将其运行起来(也就是,在所选择的语言中设置它)并不容易(如果我更好地理解它,我相信这将会很容易)...即使如此,我猜工具箱只需要制作一次,然后根据需要进行扩展。我认为,为了真正理解它,我可能需要尝试在另一种语言中实现它。


3
谢谢你的关注,Brian。对我来说,有趣的是,某些简单的东西似乎有些令人失望。对我来说,最美的东西就是简单的。保重。 - Mike Dunlavey
1
我觉得我漏掉了一些重要的东西。现在我在想,“这很简单。”如果我真正理解它,我想我会认为,“这很简单。而且非常令人惊奇和有用。” - Brian
6
我仍然看到人们把MVC呈现得像是一个非常伟大的东西,但我宁愿退休也不想再做那个了。 - Mike Dunlavey
1
对于“撤销”操作,您需要将数据进行序列化/反序列化,并生成一个文件,该文件是两个文件的异或值,大部分为零,因此可以轻松压缩。使用它来恢复先前的数据。现在将其推广到任意数据结构。 - Mike Dunlavey
1
不想给你增加工作量,@MikeDunlavey,但如果你错过了,Source Forge因为可疑的商业行为而失宠了。Github.com是现在酷孩子们聚集的地方。他们有一个非常好的Windows客户端适用于W7,网址是https://desktop.github.com/。 - Prof. Falken
显示剩余16条评论
4个回答

98

巴林,我希望早点看到你的问题。因为这个技术基本上是我的“发明”(不管好坏),我可能能够提供帮助。

插入:我能给出的最简单的解释是,如果正常的执行就像将球抛起并接住它,那么差分执行就像是杂耍。

@windfinder的解释与我的不同,这没关系。这个技术不容易理解,我用了大约20年(断断续续)来找到可行的解释。让我在这里再试一次:

  • 它是什么?

我们都理解计算机通过程序逐步执行,根据输入数据进行条件分支,并执行各种操作的简单思想。(假设我们只处理没有goto和return语句的简单结构化代码。)该代码包含一系列陈述,基本的结构化条件,简单循环和子程序调用。(现在暂时忘记有返回值的函数。)

现在想象两台计算机同时运行相同的代码,并能够互相比较。计算机1使用输入数据A运行,计算机2使用输入数据B运行。它们并排逐步运行。如果它们遇到条件语句,例如IF(test)....ENDIF,并且它们对于测试条件的真假意见不同,那么说测试条件为false的那台计算机将跳过ENDIF并等待另一台计算机追上。(这就是为什么代码结构化,因此我们知道另一台计算机最终会到达ENDIF。)

由于两台计算机可以相互通信,因此它们可以比较笔记,并详细说明两组输入数据和执行历史记录的不同之处。

当然,在差分执行(DE)中,只需使用一台计算机来模拟两台。

现在,假设你只有一个输入数据集,但想看看它从时间1到时间2发生了什么变化。假设你正在执行的程序是序列化器/反序列化器。当你执行时,你同时对当前数据进行序列化(写出)和反序列化(读取)以前的数据(上次执行时写入的)。现在,您可以轻松查看上次数据和本次数据之间的区别。

您正在写入的文件和从中读取的旧文件加起来构成队列或FIFO(先进先出),但这不是一个非常深刻的概念。

  • 它有什么用处?

我在做一个图形项目时想到了这个问题,用户可以构建小型显示处理程序例程,称为“符号”,将它们组装成更大的例程以绘制像管道、储罐、阀门等图表之类的东西。我们希望这些图表是“动态”的,也就是说,它们可以增量更新自己,而不必重绘整个图表。(按今天的标准来看,硬件速度很慢)。我意识到(例如)绘制柱状图的例程可以记住其旧高度并进行增量式更新。

这听起来像面向对象编程,不是吗?然而,我可以利用图表过程执行序列的可预测性。我可以在顺序字节流中写入条形的高度。然后,为了更新图像,我可以以一种模式运行该过程,其中它会顺序读取其旧参数,同时写入新参数,以便准备下一次更新通行证。

这似乎非常显然,而且似乎只要例程包含条件语句,它就会失效,因为新流和旧流将不同步。但后来我意识到,如果还串行化了条件测试的布尔值,它们就可以重新同步。

需要遵循一个简单的规则(“擦除模式规则”),这将总是奏效。

结果是用户可以设计这些“动态符号”并将它们组装成更大的图表,而无论显示多么复杂或结构变化,都不必担心如何动态更新。

在那些日子里,我确实要担心视觉对象之间的干扰,以使擦除一个对象不会损坏其他对象。但现在我使用这种技术与Windows控件一起使用,并让Windows处理渲染问题。

它有什么用处?这意味着我可以通过编写一个过程来绘制控件来构建对话框,而且我不必担心实际记住控件对象或处理增量更新它们,或者根据条件要求使它们出现/消失/移动。结果是,对话框源代码要小得多,约为数量级,并且动态布局或更改控件数量或具有控件数组或网格是微不足道的。此外,例如,编辑字段可以轻松地绑定到它正在编辑的应用程序数据上,并且它始终是可证明正确的,而且我永远不必处理它的事件。为应用程序字符串变量添加编辑字段只需进行一次编辑即可。

  • 为什么很难理解?

对我来说最难解释的是需要以不同的方式思考软件。程序员们坚信着对象-操作的观点,他们想知道有哪些对象、有哪些类、如何“构建”显示界面以及如何处理事件,只有使用“樱桃炸弹”才能将他们赶出这种模式。我试图传达的是真正重要的是你需要说什么?假设你正在构建一个领域特定语言(DSL),你只需告诉它:“我想在这里编辑变量A,在那里编辑变量B,并在下面编辑变量C”,它就会自动为您处理。例如,在Win32中有一种用于定义对话框的“资源语言”。它是一个完美的DSL,但没有深入到足够程度。它不“生活在”主过程语言中,也不会为您处理事件,也不包含循环/条件/子例程。但它是好意,而Dynamic Dialogs试图完成这项工作。

所以,不同的思考方式是:编写程序时,首先找到(或发明)适当的DSL,并尽可能多地在其中编码程序。让处理所有仅为实现而存在的对象和操作。

如果您想真正了解差分执行并使用它,则有一些棘手的问题可能会让您遇到困难。我曾经在Lisp宏中编写过它,其中这些棘手的部分可以为您处理,但在“普通”语言中,需要一些程序员纪律来避免陷阱。

抱歉表达得如此冗长。如果我说的不清楚,请指出来,我会尽力修正。

补充:

Java Swing中,有一个示例程序称为TextInputDemo。它是一个静态对话框,占用270行(不包括50个州的列表)。在MFC中的Dynamic Dialogs中仅需约60行:

#define NSTATE (sizeof(states)/sizeof(states[0]))
CString sStreet;
CString sCity;
int iState;
CString sZip;
CString sWholeAddress;

void SetAddress(){
    CString sTemp = states[iState];
    int len = sTemp.GetLength();
    sWholeAddress.Format("%s\r\n%s %s %s", sStreet, sCity, sTemp.Mid(len-3, 2), sZip);
}

void ClearAddress(){
    sWholeAddress = sStreet = sCity = sZip = "";
}

void CDDDemoDlg::deContentsTextInputDemo(){
    int gy0 = P(gy);
    P(www = Width()*2/3);
    deStartHorizontal();
    deStatic(100, 20, "Street Address:");
    deEdit(www - 100, 20, &sStreet);
    deEndHorizontal(20);
    deStartHorizontal();
    deStatic(100, 20, "City:");
    deEdit(www - 100, 20, &sCity);
    deEndHorizontal(20);
    deStartHorizontal();
    deStatic(100, 20, "State:");
    deStatic(www - 100 - 20 - 20, 20, states[iState]);
    if (deButton(20, 20, "<")){
        iState = (iState+NSTATE - 1) % NSTATE;
        DD_THROW;
    }
    if (deButton(20, 20, ">")){
        iState = (iState+NSTATE + 1) % NSTATE;
        DD_THROW;
    }
    deEndHorizontal(20);
    deStartHorizontal();
    deStatic(100, 20, "Zip:");
    deEdit(www - 100, 20, &sZip);
    deEndHorizontal(20);
    deStartHorizontal();
    P(gx += 100);
    if (deButton((www-100)/2, 20, "Set Address")){
        SetAddress();
        DD_THROW;
    }
    if (deButton((www-100)/2, 20, "Clear Address")){
        ClearAddress();
        DD_THROW;
    }
    deEndHorizontal(20);
    P((gx = www, gy = gy0));
    deStatic(P(Width() - gx), 20*5, (sWholeAddress != "" ? sWholeAddress : "No address set."));
}

新增内容:

以下是一个关于如何编辑医院病人数组的示例代码,共约40行代码。第1至6行定义了“数据库”。第10至23行定义了UI的总体内容。第30至48行定义了编辑单个患者记录所需的控件。请注意,该程序形式几乎不考虑时间上的事件,好像它所要做的只是创建一次显示。然后,如果添加或删除主题或进行其他结构性更改,它会被简单地重新执行,就好像它从头开始重新创建,除了DE会引起增量更新的发生。优点在于,作为程序员,您无需关注或编写任何代码来使UI的增量更新发生,并且它们是保证正确的。可能看起来这种重新执行会成为性能问题,但实际上并不是,因为更新不需要更改的控件大约需要数十纳秒的时间。

1  class Patient {public:
2    String name;
3    double age;
4    bool smoker; // smoker only relevant if age >= 50
5  };
6  vector< Patient* > patients;

10 void deContents(){ int i;
11   // First, have a label
12   deLabel(200, 20, “Patient name, age, smoker:”);
13   // For each patient, have a row of controls
14   FOR(i=0, i<patients.Count(), i++)
15     deEditOnePatient( P( patients[i] ) );
16   END
17   // Have a button to add a patient
18   if (deButton(50, 20, “Add”)){
19     // When the button is clicked add the patient
20     patients.Add(new Patient);
21     DD_THROW;
22   }
23 }

30 void deEditOnePatient(Patient* p){
31   // Determine field widths
32   int w = (Width()-50)/3;
33   // Controls are laid out horizontally
34   deStartHorizontal();
35     // Have a button to remove this patient
36     if (deButton(50, 20, “Remove”)){
37       patients.Remove(p);
37       DD_THROW;
39     }
40     // Edit fields for name and age
41     deEdit(w, 20, P(&p->name));
42     deEdit(w, 20, P(&p->age));
43     // If age >= 50 have a checkbox for smoker boolean
44     IF(p->age >= 50)
45       deCheckBox(w, 20, “Smoker?”, P(&p->smoker));
46     END
47   deEndHorizontal(20);
48 }

增加内容:Brian提出了一个好问题,我认为答案应该在这里的主要文本中:

@Mike:我不清楚“if (deButton(50, 20,“Add”)) {”语句实际上是做什么的。 deButton函数是什么?此外,您的FOR/END循环是否使用某种宏之类的东西? - Brian。

@Brian:是的,FOR/END和IF语句都是宏。 SourceForge项目有完整的实现。deButton维护一个按钮控件。当进行任何用户输入操作时,代码以“控件事件”模式运行,在该模式下,deButton检测到它被按下并通过返回TRUE表示它被按下。因此,“if(deButton(...)){...操作代码...}是一种将操作代码附加到按钮的方法,而无需创建闭包或编写事件处理程序。DD_THROW是一种在执行操作时终止传递的方式,因为操作可能已修改应用程序数据,因此继续通过例程的“控件事件”传递是无效的。如果将其与编写事件处理程序进行比较,它可以节省您编写此类代码,并让您拥有任意数量的控件。

添加:对不起,我应该解释一下我所说的“维护”这个词的含义。当首次执行该过程(在SHOW模式下)时,deButton创建一个按钮控件并在FIFO中记住其ID。在后续传递(在UPDATE模式下),deButton从FIFO获取ID,如有必要则对其进行修改,并将其放回FIFO。在ERASE模式下,它从FIFO中读取它,并销毁它,不会将其放回,从而“垃圾收集”它。因此,deButton调用管理控件的整个生命周期,使其与应用程序数据保持一致,这就是我说它“维护”它的原因。

第四种模式是EVENT(或CONTROL)。当用户键入字符或单击按钮时,该事件被捕获并记录,然后在EVENT模式下执行deContents过程。 deButton从FIFO获取其按钮控件的ID,并询问是否点击了该控件。如果是,则返回TRUE,以便可以执行操作代码。如果没有,则只返回FALSE。另一方面,deEdit(...,&myStringVar)检测事件是否针对它,并将其传递给编辑控件,然后将编辑控件的内容复制到myStringVar中。在此和正常的UPDATE处理之间,myStringVar始终等于编辑控件的内容。就是这样实现“绑定”的。相同的思想适用于滚动条,列表框,组合框,任何一种允许您编辑应用程序数据的控件。

这是我在维基百科上的编辑链接:http://en.wikipedia.org/wiki/User:MikeDunlavey/Difex_Article


4
很抱歉打扰您回答问题,但是如果我理解正确,您基本上是将计算越来越靠近处理器,远离输出硬件。这是一个非常有见地的想法,因为我们非常注重编程对象和变量的思想,认为通过编程可以很容易地将它们转换成最佳的机器码以实现相同的输出,但事实上并非如此!虽然我们可以在编译时优化代码,但无法优化时间相关的操作。拒绝时间依赖,让基元去做工作! - sova
2
@Joey:既然你提到了,基于FIFO运行的控制结构和基于作业队列运行的并行协程有很多共同点。 - Mike Dunlavey
2
我想知道差异执行和react.js库使用的方法有多接近。 - Brian
2
@Brian:从浏览信息来看,react.js使用差异函数向浏览器发送增量更新。我无法确定差异函数是否真正具有差分执行的能力。例如,它是否可以处理任意更改,并声称简化绑定。我不知道它是否以同样的程度完成了这项工作。无论如何,我认为它走在了正确的轨道上。[这里有几个视频](https://www.youtube.com/channel/UCGwyNGICQ4RHmcYcQIG9gxw)。 - Mike Dunlavey
3
@MikeDunlavey,我使用OpenGL/IMGUI和反应式编程结合的方式编写我的工具,涵盖了模型、模型-视图和视图层面。现在我再也不会回到旧的编程风格了。谢谢你提供视频链接。 - Cthutu
显示剩余17条评论

13

差分执行是一种根据外部事件改变代码流程的策略。通常通过操作某种数据结构来记录这些更改。它主要用于图形用户界面,但也可用于序列化等事情中,其中你需要将更改合并到现有“状态”中。

基本流程如下:

Start loop:
for each element in the datastructure: 
    if element has changed from oldDatastructure:
        copy element from datastructure to oldDatastructure
        execute corresponding subroutine (display the new button in your GUI, for example)
End loop:
Allow the states of the datastructure to change (such as having the user do some input in the GUI)

这样做的好处有几点。一是将您的更改的执行与支持数据的实际操作分离开来。这对于多处理器非常有用。二是它提供了一种低带宽的方法来传递程序中的更改。


12

想一下监视器的工作原理:

它以60 Hz更新 - 每秒更新60次。闪烁闪烁闪烁60次,但你的眼睛很慢,无法真正看出来。监视器显示输出缓冲区中的任何内容;无论您做什么,它都会在每1/60秒拖出这些数据。

现在,如果图像不应该经常更改,为什么要让程序每秒更新整个缓冲区60次?如果您只更改图像的一个像素,是否应该重写整个缓冲区?


这是基本思想的抽象:您希望根据要显示在屏幕上的信息更改输出缓冲区。您希望尽可能节省CPU时间和缓冲区写入时间,因此您不编辑不需要在下一屏幕拉取时更改的缓冲区部分。

监视器与您的计算机和逻辑(程序)是分开的。它以它更新屏幕的速率从输出缓冲区读取。我们希望我们的计算机停止不必要的同步和重绘。我们可以通过改变我们与缓冲区的工作方式来解决这个问题,这可以通过多种方式完成。他的技术实现了一个FIFO队列,该队列有延迟 - 它保存我们刚刚发送到缓冲区的内容。延迟的FIFO队列不保存像素数据,它保存“形状基元”(在您的应用程序中可能是像素,但也可以是线条、矩形、易于绘制的东西,因为它们只是形状,不允许不必要的数据)。

所以你想从屏幕上画/擦除东西?没问题。根据FIFO队列的内容,我知道监视器此时的外观。我将我的期望输出(擦除或绘制新的基元)与FIFO队列进行比较,并仅更改需要更改/更新的值。这就是给它命名差分评估的步骤。

我欣赏这种方法的两种不同方式:

第一种: Mike Dunlavey使用条件语句扩展。FIFO队列包含大量信息(“先前状态”或当前显示在监视器或基于时间的轮询设备上的内容)。您只需添加要出现在屏幕上的状态即可。

每个可以容纳FIFO队列中基元的插槽都添加了一个条件位。

0 means erase
1 means draw

然而,我们有先前的状态:

Was 0, now 0: don't do anything;
Was 0, now 1: add it to the buffer (draw it);
Was 1, now 1: don't do anything;
Was 1, now 0: erase it from the buffer (erase it from the screen);

这是优雅的,因为当您更新内容时,您只需要知道要绘制到屏幕上的原始图形 - 这种比较将确定它是否应该擦除一个基本图形或将其添加/保留到/在缓冲区中。

第二点: 这只是一个例子,我认为Mike真正想表达的是设计所有项目时应该根本上减少(计算)复杂性,通过将最耗费计算资源的操作编写为计算机脑食物或尽可能接近。尊重设备的自然时间。

重新绘制整个屏幕的方法非常昂贵,在其他应用程序中,此见解也非常有价值。

我们从不在屏幕上“移动”对象。“移动”是一项昂贵的操作,如果我们要模仿“移动”的物理动作来设计计算机监视器之类的代码。相反,对象基本上只是在监视器上闪烁开和关。每次对象移动时,它现在是一组新的原始图形,并且旧的原始图形会闪烁关闭。

每次监视器从缓冲区中提取时,我们都有像下面这样的条目

Draw bit    primitive_description
0           Rect(0,0,5,5);
1           Circ(0,0,2);
1           Line(0,1,2,5);

一个对象从不直接与屏幕(或时间敏感的轮询设备)交互。我们可以比对象更智能地处理它,当它贪婪地请求更新整个屏幕以显示仅针对自身的更改时。

假设我们有一个程序可以生成的所有可能图形基元的列表,并将每个基元绑定到一组条件语句。

if (iWantGreenCircle && iWantBigCircle && iWantOutlineOnMyCircle) ...

当然,这只是一个抽象的概念,实际上,代表特定原始开闭状态的条件集可能很大(可能有数百个标志,所有标志都必须计算为 true)。
如果我们运行程序,我们可以以几乎相同的速度向屏幕绘制,就像我们可以评估所有这些条件语句一样。 (最坏情况:评估最大条件语句集所需的时间。)
现在,对于程序中的任何状态,我们只需评估所有条件并迅速输出到屏幕即可! (我们知道我们的形状基元及其相关 if 语句。)
这就像购买一个图形密集型游戏。 只不过,您没有将其安装到硬盘驱动器并通过处理器运行它,而是购买了一个全新的板子,该板子包含整个游戏,并作为输入接收:鼠标、键盘,并作为输出接收:监视器。 非常简洁的条件评估(因为最基本的条件形式是电路板上的逻辑门)。 这自然会非常响应,但几乎没有修复错误的支持,因为当您进行微小设计更改时,整个板子设计都会更改(因为“设计”与电路板的本质相去甚远)。 在牺牲内部数据表示的灵活性和清晰度的情况下,我们获得了显着的“响应性”,因为我们不再在计算机中进行“思考”;一切都只是基于输入的电路板的反射作用。
我的理解是要将劳动分工,使系统的每个部分(不仅仅是计算机和显示器)都能够做得很好。 “计算机思考”可以以对象等概念形式完成...计算机大脑会很乐意尝试并为您考虑所有这些,但如果您能够让计算机以数据更新和条件评估的形式进行思考,那么您可以大大简化任务。 我们人类将概念抽象成代码是理想化的,在内部程序绘制方法的情况下有点过于理想化。 当您只需要一个结果(具有正确颜色值的像素数组),并且您有一台机器可以每秒钟轻松输出那么多的数组时,请尽可能地消除计算机大脑中的花哨思维,以便您可以关注您真正想要的内容:将图形更新与(快速的)输入和监视器的自然行为同步。
这如何映射到其他应用程序? 我想听听其他例子,但我确信有很多。 我认为任何提供实时“窗口”以查看您信息状态(变量状态或类似数据库的内容...监视器只是窗口,可以查看您的显示缓冲区)的东西都可以受益于这些见解。

2
++ 我很感激你对此的看法。对我来说,一开始是想在慢速设备(比如9600波特的远程文本终端)上进行程序描述的显示,它基本上会自动执行 diff 并传输最小更新。然后有人问我为什么不直接用蛮力编写代码。答案是:因为如果代码的表面形式就像简单的 画图 一样,它会更短,几乎没有错误,并且可以在开发时间的一小部分内完成。(这就是我认为 DSL 的好处所在。) - Mike Dunlavey
释放出来的开发工作可以重新投资于更复杂和动态的显示,用户会觉得反应迅速且令人愉悦。因此,您可以用同样的开发成本获得更多的用户界面效果。 - Mike Dunlavey
这是一个关于10年前的应用程序的例子:http://www.pharsight.com/products/prod_pts_using_dme.php - Mike Dunlavey
1
这让我明白了你谈论电脑游戏时的话。实际上,许多游戏都像Mike编写用户界面一样编码。更新列表在每个帧中循环。 - Prof. Falken
你所说的一些相关示例之一似乎是检测按键/按钮是否被按住或刚刚释放。知道按钮是否被按下很容易,这是来自低级API的true/false值。要知道按键是否被按住,您必须知道它先前的状态。如果是从0->1,则刚刚按下。如果是1->1,则一直按住,如果是从1->0,则刚刚释放。 - Joshua Hedges

3
我发现这个概念与经典数字电子学的状态机非常相似,尤其是那些记住上一个输出的状态机。
这种机器的下一个输出取决于当前输入和先前的输出,根据(YOUR CODE HERE)。这个当前输入只是先前的输出+(USER,INTERACT HERE)。
用这样的机器填满一个表面,它将是用户交互的,同时代表一层可变数据。但在这个阶段,它仍然是愚蠢的,只是反映用户交互到底层数据。
接下来,根据(YOUR CODE HERE)将你表面上的机器互相连接并共享笔记,现在我们将其智能化。它将成为一个交互式计算系统。
所以你只需要在上述模型中提供你的逻辑在两个地方;其余的由机器设计本身负责处理。这就是它的优点。

1
我似乎记得当这个想法出现在我脑海中时,我确实有一个硬件模型在脑海里。 - Mike Dunlavey

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接