65.9K
CodeProject 正在变化。 阅读更多。
Home

以“简单”方式撤销和重做

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (39投票s)

2002年10月4日

CPOL

22分钟阅读

viewsIcon

302124

downloadIcon

8743

本文介绍了一种简单的内存事务处理方法,可用于实现撤销和重做功能。该技术使用SEH和虚拟内存,仅需要STL和Win32。

Sample Image

引言

本文回顾了一些关于实现撤销/重做的CodeProject文章,并介绍了一种使用结构化异常处理和虚拟内存保护的新方法。该方法体积小,性能高,并具有良好的强制执行策略(易于正确使用,难以错误使用)。工作原理可能有些难以理解,但其在速度和编程工作量方面的优势远远超过缺点。

由于实现撤销/重做功能(又称事务)通常并非易事,并且随着应用程序复杂性的增加会变得非常困难,因此很少能做得很好。支持插件(第三方扩展)的应用程序在这方面尤其糟糕。通常,其功能有限,存在bug,并且对开发人员来说支持起来很麻烦。

CodeProject上还有一些其他文章讨论了撤销/重做,并提出了实现策略。本文介绍了另一种方法——这种方法可能会为您节省大量编码工作,并带来更高性能的应用程序。

前期工作

Yingle Jia的文章C++基础撤销/重做框架采用了一种直接的程序化方法。Jia提出了一个具有语义ExecuteUnExecute方法的Command类。根据应用程序的需要,实现这些方法的责任落在客户端开发人员身上。例如,为了正确处理内存管理,Jia建议使用引用计数实现,在对象被语义“删除”后将其保留在内存中。实现ExecuteUnexecute可能并非易事。例如,为了避免冗余复制,开发人员必须小心只捕获最少足够的信息(只捕获更改的内容)。这就是我所说的语义或程序化撤销。

Keith Rule的简单易用的撤销/重做确实名副其实。Rule利用CMemFile对象列表在每次可撤销操作之前捕获文档的序列化版本。假设CDocument已实现序列化,则可以通过读取相应的CMemFile内容来恢复撤销历史中的任何状态。Rule对Mix-in的使用使得他的代码对于现有MFC应用程序的集成非常容易。这就是我所说的状态捕获。

Jen Nilsson的撤销管理器提供了一个有趣的视角,探讨了如何使用ATL与OLE框架集成以实现撤销/重做。OLE框架在概念上与Jia的程序化方法相似,但增加了跨应用程序的撤销语义(例如,用于OLE嵌入)。

Tom Morris在实现撤销/重做 - DocVars方法中提供了一个关于通过状态捕获实现撤销/重做的有趣教程。他的方法在概念上与Rule的方法相似,但侧重于内部文档数据结构而不是文档本身。Morris方法的主要优点是文档能够包含和持久化一系列文档状态,这允许撤销和重做操作跨越编辑会话。也就是说,在关闭文档之前,以及在文档重新打开之后(可能由原始编辑者以外的人),撤销上次执行的操作成为可能。这确实是一个很好的功能。我称之为持久化撤销。

动机

以上所有文章都假设使用某种类库(MFC、ATL、STL等),Nilsson还依赖于OLE的运行时支持。每种方法都需要修改应用程序的数据表示类(文档本身或文档中的离散对象)和/或操作。我感兴趣的是一种可以在纯Win32代码库中使用,并且不需要修改任何内部数据结构或数据操作代码的方法。

Jia和Nilsson的另一个缺点是要求对象在语义删除后仍保留在内存中,从而增加了行为约束以及额外的内存开销(例如,假设“已删除”对象不应与内存中的其他对象交互或继续处理事件)。在除琐碎应用程序之外的所有应用程序中,确保遵守这些规则可能非常困难。

Rule对行为的限制较少(除了序列化),但重复复制整个序列化文档,从而牺牲了简单性以换取性能和内存开销。Morris的方法由于文档数据的重复而具有相似的内存开销。

除了对开发人员施加的限制外,基于程序和序列化的方法的扩展性也值得怀疑。当然,当文档大小超过可用内存的50%时,Rule就无法继续运行(备份CMemFiles的压缩在一定程度上改善了这种情况)。Jia和Nilsson没有复制那么多数据(假设高效实现),但引用计数方法确实导致对象在“删除”后仍保留在内存中,并且可能需要额外的逻辑来将这些对象与其它数据结构“断开连接”。Morris要求在每次编辑之前创建一套重复的文档数据,从而增加了内存和处理开销。

重要的是要考虑这些技术各自的“强制策略”。也就是说,有多少种方式会无意中破坏系统并引入错误?所有上述技术都要求开发人员对系统的正确使用有所了解,并理解其用途和局限性。我发现,无论意图如何,这都有些牵强。

最后,在应用程序使用来自外部库的数据元素的情况下,如果没有一定的困难,可能无法实现上述技术。例如,图形分析工具包可能包含通过指针相互引用的数据对象。由于这些类不太可能基于MFC,因此它们将不支持Rule基于序列化的方法。同样,添加Jia和Nilsson所需的语义行为可能无法实现。因此,通用撤销框架必须不要求修改现有数据对象类。

最后,基于MFC和COM的解决方案的可移植性值得怀疑。

新方法概述

程序在任何给定时刻的状态都严格由表示该应用程序的数字内存序列的状态定义——显而易见,不是吗?记住我们创建的代码不过是在某个芯片上翻转位,这让人感到谦卑。然而,这个观察也为我们提供了一个机会:如果我们能够检测到应用程序内存发生的变化并记录下来,那么我们就有很大机会随心所欲地逆转和重放它们。

这就是这个系统的基本前提——检测内存变化并在二进制级别记录它们,然后根据需要逆转和回放它们以恢复应用程序的给定状态。

这种方法有几个优点,阅读代码后应该会很明显

  1. 数据对象与撤销/重做实现分离。参与事务的对象无需更改。无需额外的代码来捕获对象状态。对象操作函数中无需额外的代码。
  2. 极低的内存开销。通过记录内存状态的压缩更改,此方法捕获最少的信息。
  3. 极高的运行时性能。
    1. 撤销和重做操作通过对内存进行顺序二进制操作来实现。这些操作开销很低,可以在许多新处理器(例如MMX)中加速,并可以在页面级别进行并行化。
    2. 事务开销(捕获和重放)主要与更改的数据量成比例,而不是与整个数据的大小成比例。
  4. 真正的事务。事务是原子性的、一致的、隔离发生的且持久的(考虑到此方法仅在内存中运行,因此有一些回旋余地)。
  5. 数据安全。由于应用程序数据在事务外部是写保护的,任何在事务外部更改数据的尝试都将导致异常。这使得识别“行为不当”的代码并进行纠正变得非常容易。然而,即使在事务之外,数据访问也允许不受阻碍。
  6. 简单的API。API中只有少数方法,大多数方法有零个或一个参数。

一些缺点包括

  1. 焦点狭窄。此技术无法为大多数应用程序提供完整的撤销/重做框架(但对许多应用程序来说可以)。由于许多操作不涉及跨进程和介质的内存(例如创建新窗口、文件或注册表项),因此需要使用Jia等系统封装此代码,以通过程序化方法处理非基于内存的操作。
  2. 不透明性。不执行对象代码而直接操作对象表示对于大多数开发人员来说是一个陌生的概念,可能难以完全掌握。它有时会导致有趣且具有挑战性的错误。

实现

哇。刚过引言,这篇文章就已经太长了。我将尽量简洁,但这里有很多需要解释的地方。我写这篇文章的目标是实现对外部代码库的最小依赖,但实际上我发现STL非常有用。我选择放弃MFC、WTL和ATL,但为了方便和代码可读性而使用STL。结果是一个非常简单的系统,可以与MFC和ATL项目以及纯Win32代码库一起使用。

它也可以移植到具有包含读/写违规数据指针的扩展SIG_INFO结构的POSIX平台(例如Linux 2.4+内核)。大多数平台都支持这一点,因为通常需要为动态加载的共享对象(DLL)进行写时复制。

SEH:即时写入检测

Windows包含一种机制,可以检测尝试更改内存的行为并通过结构化异常处理(SEH)进行响应。_try {} _except() {}块允许我们捕获异常并将其通过我们提供的过滤器传递。

int ExceptionFilter(LPEXCEPTION_POINTERS e);

int main()
{
    int retval = 0;
    __try {
            retval = DoWork();
    } __except(ExceptionFilter(GetExceptionInformation())) {
            printf("Opps, we have a problem...\n");
    };

    return retval;
}

利用此功能,我们可以检测到何时发生内存保护错误以及涉及的内存是哪个。

int ExceptionFilter(LPEXCEPTION_POINTERS e)
{
    // we are only interested in access violations (memory faults)
    if (e->ExceptionRecord->ExceptionCode != EXCEPTION_ACCESS_VIOLATION)
        return EXCEPTION_CONTINUE_SEARCH;

    // the exception information includes the type of access (read
    // or write), and the address of the fault
    bool writing = (e->ExceptionRecord->ExceptionInformation[0] != 0);
    void* addr = (void*)e->ExceptionRecord->ExceptionInformation[1];

    // TODO: okay, now what?
         
    return EXCEPTION_CONTINUE_EXECUTION;
}

异常过滤器可以返回以下三个值之一

  • EXCEPTION_CONTINUE_SEARCH表示过滤器对异常未采取任何行动,并且传播应继续到其他处理程序(在嵌套try语句的情况下)。
  • EXCEPTION_CONTINUE_EXECUTION表示过滤器已采取行动来解决异常,并且执行应在异常点继续。
  • 这里我们不使用的一个选项。

这很棒,但我们通常不参与决定应用程序可以读写哪些内存。出于显而易见的原因,操作系统通常会为我们处理这个问题。如果我们使用newmalloc分配一块内存,它会自动启用读写,并且我们的过滤器永远不会被调用。值得庆幸的是,Windows的虚拟内存功能提供了一种方法,让我们明确控制我们分配的内存权限。

虚拟内存:页面保护和管理

Win32虚拟内存函数可以在虚拟内存空间中分配、保护和释放地址块。有关VirtualAllocVirtualProtectVirtualFree的更多信息,请参阅MSDN。

我们可以通过VirtualAlloc分配内存,然后使用VirtualProtect将保护设置为PAGE_READONLY。任何尝试写入该内存的操作都将生成访问冲突,并传递给我们的异常过滤器。在ExceptionFilter中,我们可以检查地址是否在我们管理的页面上,如果是,则执行以下操作

  1. 复制页面
  2. 将复制的保护设置为PAGE_READONLY
  3. 将原始的保护设置为PAGE_READWRITE
  4. 返回EXCEPTION_CONTINUE_EXECUTION

从过滤器返回EXCEPTION_CONTINUE_EXECUTION允许执行内存访问的代码继续操作,就像什么都没发生一样。同时,我们在任何更改之前复制了页面以保存其状态。这种技术通常被称为“写时复制”,您可以在网络上的其他地方阅读更多相关信息。

为了跟踪我们管理的页面,我们需要一些简单的数据结构

typedef std::vector<void*> Pages;
typedef std::map<void*, void*> PageMap;

static Pages pages;
static PageMap backups;

每次我们分配一个页面,就将其添加到pages中。每次我们进行备份,就将其添加到backups中,以原始页面的指针作为键。有了这些结构,异常过滤器的实现开始成形。

int ExceptionFilter(LPEXCEPTION_POINTERS e)
{    
    if (e->ExceptionRecord->ExceptionCode != EXCEPTION_ACCESS_VIOLATION)
        return EXCEPTION_CONTINUE_SEARCH;

    bool writing = (e->ExceptionRecord->ExceptionInformation[0] != 0);
    void* addr = (void*)e->ExceptionRecord->ExceptionInformation[1];
    void* page = (void*)((unsigned long)addr & ~(PAGE_SIZE - 1));

    Pages::iterator i = std::find(pages.begin(), pages.end(), page);
    if (i == pages.end())
        return EXCEPTION_CONTINUE_SEARCH;

    void* backup = ::VirtualAlloc(0, PAGE_SIZE, MEM_COMMIT, PAGE_READWRITE);
    memcpy(backup, page, PAGE_SIZE);
    backups[page] = backup;

    DWORD old = 0;
    BOOL ok = FALSE;
    ok = ::VirtualProtect(backup, PAGE_SIZE, PAGE_READONLY, &old);
    if (ok == FALSE)
        return EXCEPTION_CONTINUE_SEARCH;

    ok = ::VirtualProtect(page, PAGE_SIZE, PAGE_READWRITE, &old);
    if (ok == FALSE)
        return EXCEPTION_CONTINUE_SEARCH;

    return EXCEPTION_CONTINUE_EXECUTION;
}

太棒了!

然而,我们仍然有一个问题:Windows 的虚拟内存函数只处理页面大小的块(4096 字节)。为了使其在一般情况下有用,我们需要一个内存管理器,它能操作来自 VirtualAlloc 的内存块。幸运的是,这个问题以前已经解决了。源代码包含一个非常简单的内存管理器,可以从虚拟内存块中切出任意大小的分配。请参阅 API 函数 AllocateDeallocate。如果您需要更工业级的解决方案,请参阅 谷歌。就这样。

休斯顿,我们有撤销功能了

现在我们可以检测内存更改并只复制正在更改的内存。这很棒,对吧?假设我们分配了亿万字节,但在任何给定事务中只会更改其中几个字节。使用这种技术,我们只需复制发生更改的页面,并且进行更改的代码甚至不需要知道我们正在这样做。

要撤销更改,我们只需将备份页面复制到目标页面上方即可。很酷,对吧?不幸的是,此时我们无法重做撤销,因为我们没有保留更改之后的页面副本。嗯。这种技术的另一个缺点是,即使页面的一部分发生更改,我们也会复制整个页面。这有点浪费。为了做得更好,我们可以压缩备份,但我们可以通过一些XOR技巧做得更好,并且也可以实现重做。

XOR 技巧:记录更改和重做

与其深入讨论XOR及其多种用途,我将推荐您阅读Daniel Turini关于该主题的优秀文章

使用XOR,我们可以找出原始页面和页面副本之间的差异。一些非常快的代码可以对页面及其备份进行XOR操作,并将结果重写到备份中。

// psuedo-code. A real-world implementation should use SMID
for (unsigned short j = 0; j < PAGE_SIZE; ++j) {
    const BYTE a = backup[j];
    const BYTE b = page[j];
    backup[j] = (a ^ b);
}

任何未更改的字节在XOR操作后都将返回零。已更改的字节将为非零。这看起来不像节省空间,除非稀疏更改的页面会有很多零,并且压缩效果非常好。此外,正如Daniel所说,通过存储XOR,我们始终可以从“之前”或“之后”状态获取“其他”内存状态——因此,我们现在可以重做我们撤销的操作。太棒了!

RLE:Delta 压缩

一旦我们决定保留更改(参见CommitTransaction),我们就可以计算XOR增量,压缩它们,并保留它们,同时丢弃备份页面。这种技术大大降低了系统的内存开销。

为了演示增量压缩(页面和备份的XOR)的概念,我实现了一个非常简单的行程编码(RLE)算法。该代码只是查找重复字节的长时间运行,并将其编码为计数和字节。因此,例如,100个“0”的运行被压缩到2个字节。然而,随机数据的压缩效果不佳,在生产环境中,您会希望使用zlib之类的工具来获得更好的最坏情况性能。

RLE 压缩的细节在 Compress.hCompress.cpp 中分离出来。如果您能提供一个完美地适配这两个文件(并且快速)的实现,我将将其添加到本文中并在此处为您署名。这是一个有趣的问题,因为我们知道要压缩的块始终是 PAGE_SIZE 字节。

STL 集成,MFC 危险

STL 的设计者们(愿他们安息)决定包含控制容器(list、vector、map 等)内存分配的能力。这非常酷。它允许我们编写一个使用我们内存管理器将 STL 容器放置在事务性页面上的分配器。翻译一下:我们可以执行涉及 STL 容器和对象操作的事务。

有关分配器的详细信息(不那么血腥),请参阅 Allocator.h,并参阅 MemTest.cpp 进行演示。

在MFC类上做同样的事情会很酷,但这通常是一个坏主意。MFC类通常会做一些非内存相关的事情。派生自CObjectCCommandTarget的类可以使用这种技术进行事务处理,但请注意避免在事务处理对象中持有指向非事务处理对象的指针,反之亦然。这些指针在撤销或重做后可能会失效。

演示应用程序

本文附带了三个演示应用程序。第一个是一个简陋的控制台应用程序,用于测试引擎的大部分功能。由于它有详细的注释,我将直接引导您查看 MemTest 目录,而不是在本文中包含200多行重复代码。

第二个演示应用程序 DrawIt 更有趣,因为它将这种撤销方法与一个简单的基于 MFC 的应用程序集成。该应用程序执行简单的形状绘制(基于向量)。它处理鼠标按下、移动和释放消息,并根据输入交互式地创建随机形状。两个工具栏命令通过以下方式自动创建大量形状:

  1. 打开1000个事务,每个事务在随机位置添加一个随机形状。
  2. 打开一个事务并在随机位置添加1000个随机形状。

我这样做是为了展示大量小事务和少量大事务之间的差异。两种情况都运行良好。在这两种情况下,事务开销与操作列表和渲染项目的时间相比都很小。

演示中一个或许微妙的地方是,创建对象的代码是在外部 DLL (DrawFunc.dll) 中提供的,该 DLL 对主应用程序中的事务机制一无所知。这使得为定制或扩展目的实现替换 DLL 变得非常非常简单。值得查看子项目,特别是草图对象,以注意插件提供商的生活可以多么简单。

演示应用程序的另一个有趣方面是长期事务支持。您可以打开一个事务,通过交互式添加项目或使用滥用命令对文档进行大量更改,然后将这些更改作为一个大块提交或取消。如果更改被提交,它们将作为单个单元进行撤销和重做。如果更改被取消,文档将返回到以前的状态(包括完整的重做堆栈),就像这些更改从未发生过一样——很棒,对吧?

第二个演示应用程序是使用AppWizard生成的。然后我对CDrawItApp::InitInstaceRunExitInstanceOnUndoOnRedo进行了少量修改。这些修改是通用的,通常适用于使用事务的MFC应用程序。其他修改可以在CDrawItView.h.cppCDrawItDoc.h.cpp中找到。这些修改是特定于此应用程序的,您应该用自己的代码替换它们。

第三个也是最后一个演示,DrawItMDI,展示了如何使用事务的多空间功能来构建一个MDI应用程序,该应用程序为每个文档维护独立的撤销/重做流。它还实现了异常过滤以捕获崩溃并进行报告。

这些演示展示了框架的灵活性,我希望它们能鼓励您自己尝试一些新的想法。请注意,演示应用程序使用了CodeProject和其他地方其他项目中的代码。感谢Hans Dietrich、Bruce Dawson和Jonathan de Halleux。

API

API中只有少数方法,其中一种应该每个应用程序只使用一次。这给我们留下了大约七个需要记住的东西,这个数字差不多正好。

SPACEID CreateSpace(size_t initial_size)

创建一个可请求分配的事务堆。在使用事务空间之前,必须先打开一个事务。

结果 Destroy(SPACEID sid)

销毁先前创建的空间,释放与其关联的所有内存。请记住,空间中剩余的任何对象都不会调用析构函数。对该空间进行未来的分配请求将导致未定义行为。

结果 DestroyAll()

销毁所有先前创建的空间,释放与其关联的所有内存。请记住,空间中剩余的任何对象都不会调用析构函数。对现有空间进行未来的分配请求将导致未定义行为。

结果 Clear(SPACEID sid)

释放与空间关联的所有内存。请记住,空间中剩余的任何对象都不会调用析构函数。允许对该空间进行未来的分配请求。

结果 ClearAll()

释放与所有空间关联的所有内存。请记住,空间中剩余的任何对象都不会调用析构函数。允许对这些空间进行未来的分配请求。

int ExceptionFilter(LPEXCEPTION_POINTERS* e)

此方法提供了与目标应用程序代码集成的主要点。为了捕获内存访问冲突,您必须将一个使用此异常过滤器的_try {} _except() {}块放置在调用堆栈中所有对托管内存(通过Allocate分配的内存)的修改之上。放置try块的逻辑位置是在main()WinMain()或应用程序的主消息循环周围。

结果 OpenTransaction(SPACEID sid)

调用此方法以打开新事务。任何打开的事务都必须在另一个事务开始之前关闭(提交或取消)。在事务外部,尝试写入受保护内存将导致未处理的异常。

结果 CancelTransaction(SPACEID sid)

如果您想关闭事务而不提交任何更改,请调用此方法。调用此方法的结果是,自事务开始以来对内存进行的所有更改都将被还原,并且事务将从撤销列表中删除。该事务将不可用于撤销。

重做堆栈上的事务仍然可用于重做。就好像什么都没发生过一样。想象一下尝试使用程序化撤销框架来做到这一点。很酷,是吧?

Result CommitTransaction(SPACEID sid)

调用此方法以提交自上次调用OpenTransaction以来所做的更改,并将事务添加到撤销列表。事务期间所做的更改将被保留。如果重做堆栈上有事务,它们将被清除,并且它们包含的任何页面都将添加到空闲列表中。

TXNID GetLastTransactionId(SPACEID sid)

此方法返回上次提交事务的标识符。结合Undo/Redo使用此方法,可将应用程序状态回滚到其历史中的特定点。如果需要将MM封装在程序化框架中,此方法应该很有用,因为它唯一地标识了每个事务。

TXNID GetNextTransactionId(SPACEID sid)

此方法返回重做堆栈中下一个事务的标识符。结合Undo/Redo使用此方法,可将应用程序状态回滚到其历史中的特定点。如果需要将MM封装在程序化框架中,此方法应该很有用,因为它唯一地标识了每个事务。

TXNID GetOpenTransactionId(SPACEID sid)

此方法返回当前打开事务的标识符。使用此方法来确定Undo/Redo是否可用,或者事务是否已在进行中。如果需要将MM封装在程序化框架中,此方法应该很有用,因为它唯一地标识了每个事务。

结果 Undo(SPACEID sid)

调用此方法以撤销上次事务中所做的更改。

结果 Redo(SPACEID sid)

调用此方法以重新撤销上次撤销中所做的更改。

结果 TruncateUndo(SPACEID sid)

丢弃撤销堆栈中的所有事务。这在某些情况下很有用,例如,避免用户撤销文档或应用程序初始化。

结果 TruncateRedo(SPACEID sid)

丢弃重做堆栈中的所有事务。当重做堆栈不为空时提交事务时,此操作会自动发生(思考一下)。我想不出为什么您希望在应用程序级别执行此操作。

void* Allocate(size_t size, SPACEID sid)

在指定空间中分配size字节的事务内存。所有需要进行事务处理的对象都必须使用此方法分配。为了简化在事务内存中创建C++对象,您可以尝试以下方法之一:

  1. 使用Allocate获取一块大小合适的内存,然后使用placement new将类的实例放入该内存中。例如:

    Foo* foo = new(Mm::Allocate(sizeof(Foo), mySpaceId)) Foo();
    
    
    
  2. 在类上重写operator new(),其实现使用Allocate获取内存。推荐此方法。例如:
    class Foo {
      void* operator new(size_t size) { return Mm::Allocate(size, mySpaceId); }
    };
    
    
    
  3. 创建自定义分配方法(请参阅 MemTest.cpp 中的 MyNewMyDelete)。也推荐。

void* Allocate(void* hint, size_t size)

如果适当的空间 ID 不方便访问,可以向分配 API 提供一个提示。当提供了提示时,事务将尝试查找提示地址所在的空间。如果成功,将在该空间中进行分配。如果失败,分配将失败。显然,使用提示而不是空间 ID 会增加一些性能开销。

void Deallocate(void* p)

释放使用Allocate()分配的块p。由于参数列表中未指明空间,因此该方法需要检查该地址最初是否由Transactions分配,如果是,则在哪个空间中。这显然会使该方法变慢,因此更倾向于下面的形式:

void Deallocate(void* p, SPACEID sid)

这是首选的解除分配方法,但请注意,不会尝试再次检查空间ID。如果错误,解除分配将简单地失败。这是三种解除分配签名中速度更快的一种。

void Deallocate(void* p, size_t size)

主要由Mm::Allocator使用,否则没有太多理由使用它。

void* Reallocate(void* p, size_t size)

尝试将p处的空间重新分配到新大小。如果新大小小于现有分配,它将始终有效。如果请求的大小大于现有大小,它可能会(实际上很可能会)失败。不要传递空p,因为需要它来确定要使用的空间。

void* Reallocate(void* p, size_t size, SPACEID sid)

同上,但空指针p可以接受,因为指定了空间。这是两种形式中较快的一种。

结论

我希望本文能让您深入了解另一种实现撤销/重做的方法。这里介绍的方法有一些显著的优点,但也可能难以理解和调试。其中优点包括:

  • 低内存开销
  • 高性能和可伸缩
  • 易于使用

最后,为了激发您的思考,请考虑这种技术足够快,可以让您“回溯时间”。也就是说,如果了解某个对象在之前的事务中的位置、颜色或值很有用,您可以轻松修改此代码来实现这一点。嗯,这可能会很有用。

尽情享用!

版本历史

  • 更新日期:2004年6月18日:
    • 增加了对多个独立事务“空间”(又称堆)的支持。
    • 多处bug修复,已移植到VC7.1
    • 代码更具可读性
    • MDI 示例应用程序
  • 版本 1.3 发布于 2003年1月7日,CodeProject
  • 版本 1.2 发布于 2002年12月17日,CodeProject
    • 根据Tom Morris的教程添加了注释。
  • 版本 1.1 发布于 2002年10月7日,CodeProject
    • 新的API方法Mm::Clear()清除所有VM并重置撤销和重做堆栈
    • 新增演示DrawIt以展示MFC集成。
    • 删除了导致构建问题的Result.h引用。
  • 版本 1.0 发布于 2002年10月4日,CodeProject
© . All rights reserved.