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

ESS:C++ 的极简序列化

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (13投票s)

2009 年 4 月 20 日

BSD

15分钟阅读

viewsIcon

91684

downloadIcon

1705

一篇关于持久化 C++ 对象的文章。包含几个控制台模式测试应用程序和一个 MFC GUI 演示。

引言

在本文中,我将描述一种用于将 C++ 对象持久化到 XML 或二进制格式的轻量级机制的实现。这类文章自然不会产生大量的视觉内容,因此穿插了许多代码片段。希望您觉得它有趣。

示例代码包含 VS2003 和 VS2008 项目,这些项目构建了一个控制台模式的单元测试应用程序。整个项目都使用了 /W4 警告级别。

此外,还有一个超简单的 联系人 MFC 应用程序,它在一个混合的网格-树状控件中显示内容。GUI 应用程序的目的是展示如何

  • 为您的自定义类扩展 ESS 编组
  • 使用简单的版本控制
  • ESS 如何应对实际代码 - 受保护的构造函数、虚函数等。
  • 动态对象创建 - 以及 ESS 如何处理错误

何时使用

我发现这项技术有很多应用——包括持久化程序选项、为撤销/重做操作保存状态、自动为应用程序数据启用 XML 文件格式、客户端-服务器通信(即线上的数据包)以及在 SQL 数据库中将非关系数据作为 XML 存储。

主要特点

  1. 所有 ISO C++ 兼容、可移植的代码
  2. 要求持久化类共享一个共同的基类
  3. 要求启用 RTTI 编译
  4. 尊重现有访问控制,包括构造函数和析构函数
  5. 将序列化可以序列化的类的指针
  6. 正确恢复指向多态对象的指针容器的内容
  7. 强调编译时检查以最大程度地减少运行时错误
  8. 宏仅用于简洁性,并转发到可调试的代码
  9. 实现完全内联 - 只需 #include ESS 头文件
  10. 添加新的存储格式非常简单 - 例如 JSON

约束

  1. 要求可序列化的类有一个 void 构造函数
  2. 当前实现假定序列化发生在单线程中 - 添加线程安全只需要很少的工作
  3. 明确不支持对“C”指针类型(尤其是 void*friend)的序列化
  4. XML 尚未实现 UNICODE 字符串存储
  5. 没有理论上的障碍阻止 ESS 与多重继承一起使用,但完全未经测试

约定

为了避免无休止的重复,让我们假设任何类 C0 都是任意层次结构中的基类,其中 C1 从 C0 派生,而 C2 又从 C1 派生。根类描述了一个“最不派生”的类。RTTI 是运行时类型信息,我将在讨论如何在运行时保留类名和派生信息记录时使用该缩写。因此,我们有

关于宏和模板的说明

简而言之,通常非常有用,如果以品味和审慎的方式应用。我非常喜欢可以正确调试的代码,这提供了一个所有宏都必须通过的简单测试:如果您在调试器中单步进入宏,您看到的是代码还是文本?

ESS_REGISTERESS_RTTIESS_STREAM 宏都使用字符串化运算符 (#) 从类名和实例名生成字符串。我认为这是好事,因为它减少了出错的可能性。ESS_RTTI 还声明了负责在堆上创建新实例的模板工厂类为 friend,因此它可以访问受保护/私有的构造函数和析构函数。这使得将 ESS 应用于现有代码更加简单。我认为这些是明确的优势。

当可执行代码包含在宏中时,它总是被转发到一个模板内联函数,因此您可以正确地调试。例如

// the trivial
#define ESS_ROOT(rootname) typedef ess::root<rootname> ess_root;

// forwarded to a template function
#define ESS_STREAM(stream_adapter,class_member)        \
    ess::stream(stream_adapter,class_member,#class_member)

// and the slightly hairier mix ...
#define ESS_RTTI(classname,rootname)\
friend ess::CFactory<classname,rootname>; \
virtual const char* get_name()\
{ return ess::get_name_impl<classname>(#classname); }\
static ess::class_registry<classname>* get_registry()\
{ return ess::get_registry_root<classname,rootname>(#rootname); }

ESS_RTTI 是 ESS 宏中最复杂的一个。

再做一次哲学声明:模板很棒,但模板元编程则不然。为什么?模板元编程无法通过调试器测试。

一个最小的 ESS 示例

让我们从一个简单的例子开始。C0 是我们想要序列化的根类,下面是一个超简单的内联实现。

// primary header file
#include "ess_stream.h"
// use this header for XML storage
#include "ess_xml.h"
class C0
{
    // so we can differentiate
    short m_id;
    // vector of pointers to C0
    std::vector<C0*> m_children;
    // here is the serialization function -
    // it is symmetric working for both reading and writing
    virtual void serialize(ess::archive_adapter& adapter)
    {
        ESS_STREAM(adapter,m_id);
        ESS_STREAM(adapter,m_children);
    }
public:
    // specify the inheritance root
    ESS_ROOT(C0)
    // set up RTTI
    ESS_RTTI(C0,C0)
};

class C1: public C0
{
    // for illustration - real class
    // would probably have more code
    ESS_RTTI(C1,C0)
};

这里是执行双向序列化的代码

int version = 1;
std::string xml_root = "root";
std::string instance_name = "x";
// always use try/catch blocks as any problems use throw()
try
{
    // register the class
    ess::Registry registry;
    // macro'ised variety for brevity and no spelling errors
    registry << ESS_REGISTER(C0,C0);

    // where data is stored ...
    ess::xml_medium storage;
    {
        // instance to serialize
        C0 c0;
        C1 c1;
        // this version hides an XML parser...
        ess::xml_storing_adapter adapter(the_storage,xml_root,version);
        // store root C0
        ess::stream(adapter,c0,"c0");
        // store derived C1
        ess::stream(adapter,c1,"c1");
    }
    // deserialize to p0
    {
        //
        C0* p0 = 0;
        // restore from XML storage
        Chordia::xml_source xmls(storage.c_str(),storage.size());
        // and an adaptor
        ess::xml_loading_adapter adapter(xmls,xml_root,version);
        // stream into C0 pointer ...
        ess::stream(adapter,p0,instance_name);
        // p0 is now ready to use ...
        // we own this
        delete p0;
    }
}
catch(...)
{
}

example() 中生成的 XML 是这样的

<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<root version="1"/>
<class derived_type="C0" name="c0">
    <signed_short name="m_id" value="1"/>
    <vector name="m_children" count="0">
    </vector>
</class>
<class derived_type="C1" name="c1">
    <signed_short name="m_id" value="1"/>
    <vector name="m_children" count="0">
    </vector>
</class>
</root>

详细信息

  • RTTI
  • 注册
  • 适配器
  • 错误处理
  • 单元测试

让我们深入细节。持久化基本 C++ 类并不难——MFC 自诞生以来就有了这样的机制。我们以类似的方式开始;系统的基础是通过分解进行的序列化——类被分解为原子元素,然后在运行时进行读写。读写使用对称的 serialize() 函数,该函数减少了编程需求和潜在错误。真正棘手的部分来自于以下方面

  1. 没有共同的基类
  2. 正确重构多态类实例的指针
  3. 保持编译器友好
  4. 保持程序员友好

RTTI 及相关

假设 1:如果我们想正确地恢复多态类型,那么

  1. 我们必须能够以某种方式区分派生类型,并且
  2. 我们必须能够在运行时做到这一点。

直奔主题,最简单的方法是为每个符合 ESS 的类配备一个虚函数 get_name()。然后我们可以这样做

// identifying instances at runtime
std::vector<C0*> vec;
vec.push_back(new C0);    // base class
vec.push_back(new C1);    // derived class
std::string n0 = vec[0]->get_name(); // gives us "C0"
std::string n1 = vec[1]->get_name(); // gives us "C1"

假设 1 暗示我们需要能够(以类型安全的方式)根据字符串创建不同类型的任意实例。我们还有一个额外的难题,我们在这里通过引入一个不相关的、以“D”为前缀的层次结构来展示

// Example 1.1
// not C++ ?
C0* pc0 = hey_presto("C0");
C1* pc1 = hey_presto("C1");
D0* pd0 = hey_presto("D0");
D1* pd1 = hey_presto("D1");

好了,我们可以通过在每个基类 C0D0 中有一个静态函数来实现类似示例 1.1 的效果,即

C0* pc0 = C0::hey_presto("C0");
D0* pd0 = D0::hey_presto("D0");

引入模板。我们可以通过拥有一个模板化的 hey_presto() 版本来摆脱限定类型名称的负担

template <typename T>
inline
T*
hey_presto(const std::string& classname)
{
    // find the classname in something
    return new T_Or_Derivative_Of_T;
}

// i.e.
C0 pc0 = hey_presto<C0>>("C0");
D0 pd0 = hey_presto<D0>("C0");

实际上,解决方案要复杂一些。为了实现类型安全、灵活和高效,我们采用了通用的间接方法。我们将屈服于再添加一个宏的诱惑——它已经揭示了,但让我们仔细看看

// simplify for the sake of example by removing the
// ess:: namespace qualifier
static class_registry<classname>* get_registry()
{
    return get_registry_root<classname,rootname>(#rootname);
}

// thus the macro invocation ESS_RTTI(C0,C0) becomes:
class C0
{
    // static function returns a templated type
    static class_registry<C0>* get_registry()
    {
        return get_registry_root<C0,C0>("C0");
    }
};

如果我们继续沿着调用路径,我们会很快看到以下结果

//-----------------------------------------------------------------------------
// Snippet 1:
// Simplified get_registry_root
template <typename Derived,typename Root>
inline
class_registry<Derived>* get_registry_root(const char* rootname)
{
    return
        reinterpret_cast<class_registry<Derived>*>
            (get_registry_impl<Root>(rootname));
}

//-----------------------------------------------------------------------------
// Snippet 2:
// templated inline function that is called by the ESS_ROOT macro implementation
template <typename Root>
inline
class_registry<Root>* get_registry_impl(const char* rootname)
{
    // when this function is called the registry for the
    // hierarchy based on T is created and will last
    // for the duration of the program run.
    static ess::class_registry<Root> s_registry(rootname);
    return &s_registry;
}

//-----------------------------------------------------------------------------
// Snippet 3:
// Finally we reach the ground floor! Details elided here.
template <typename Root>
class class_registry
{
    public:
    // register a factory capable of creating a Root thing
    bool Register(const char* classname,IFactory<Root>* pFactory) {}
    // point of creation for Root derived instances
    Root* Create(const std::string& classname) {}
};

换句话说,上面的代码片段中的代码为每个根类配备了一个静态的、模板化的 class_registry 实例。正如您可能从成员函数名中猜到的那样,class_registry<C0>->Create("C0") 确实会返回一个新的 C0 实例。我们将在下一节中更详细地介绍 Register() 成员函数——足以说,我们离之前想要的 hey_presto() 函数非常近了。

一如既往,C++ 的细节很重要。上面的代码片段 2 中的 get_registry_impl() 返回一个指向静态类实例的指针。这意味着

  1. 只有一个 class_registry 实例
  2. class_registry仅在调用 get_registry_impl() 时创建
  3. class_registry 可被所有派生自 Root 的类访问

反过来,这意味着以下是可能的

// this gives us a C0
C0* p0 = C0::get_registry()->Create("C0");
// this gives us a derived C1 but only accessible via root type
C0* p0 = C0::get_registry()->Create("C1");

虽然这对于当前任务几乎足够了,但(最终)会导致代码笨拙。为了真正将其完善,我们希望能够做到这一点

// yep - as expected
C0* p0 = C0::get_registry()->Create("C0");
// hey presto!
C1* p1 = C1::get_registry()->Create("C1");

虽然这看起来足够简单,但请记住,C++ 静态函数不是虚函数。实际上,您不能在两个不同但相关的类中拥有同名的静态函数。或者,您可以吗?让我们再看看

// yep - as expected
class_registry<C0>* rc0 = C0::get_registry();
C0* p0 = rc0->Create("C0");
// hey presto!
class_registry<C1>* rc1 = C1::get_registry();
C1* p1 = rc1->Create("C1");

这里的代码模糊了一个事实,即尽管静态函数具有相同的名称,但它们实际上具有不同的签名,因为它们返回不同但相关的类型。这实际上是一个“魔法时刻”,因为我们现在可以编写一个单一的模板内联函数,该函数可以从字符串创建一个任意实例

template<typename Type>
inline
Type*
instance_from_name(const std::string& classname)
{
    // since get registry is a static with a different signature
    // at each level of inheritance we can overload the function name
    ess::class_registry<Type>* p = Type::get_registry();
    // creates correct derived type or throws ...
    return p->Create(classname);
}

现在,我们有一个单一的函数可以处理这两种情况——请注意,模板参数类型在 (3) 中是不同的。

// 1. get root from root
C0* p0 = instance_from_name<C0>("C0");
// 2. get derived via root - fine for reloading containers
C1* p1a = instance_from_name<C0>("C1");
// 3. but now we can get derived from derived too so
// we can access member functions of C1 directly
C1* p1b = instance_from_name<C1>("C1");

现在,为了尝试结束这个有些复杂的章节,我们将跟随编译器在我们实际从存储中流回东西时进行。这是 ess_stream.h 中相关的内联函数;它是一个模板函数,其签名与类型指针匹配

template<typename Type>
inline
void
stream(stream_adapter& adapter,Type*& pointer,const std::string& name)
{
    std::string derived_type = get_class_name(adapter);
    // simplified
    pointer = instance_from_name<Type>(derived_type);
    //arg = instance_from_name(derived_type);
    // deserialize the instance
    pointer->serialize(adapter);
}

// example usage
C0* p0 = 0;
ess_stream(...,p0,...);
C1* p1 = 0;
ess_stream(...,p1,...);

现在,这段代码最 remarkable 的地方在于,它都返回相同的东西,即很久以前在 C0 的根部声明的静态注册表类。模板化意味着编译器可以通过多种方式建立类型安全的方式来访问注册表,从而能够创建任意类型的实例。但是请注意,这种便利是有代价的。现在理论上可能实例化部分完成的类!我(相信)在不使用编译器生成的 RTTI 的情况下,无法在编译时防范此错误。事实上,在运行时也很难防范。任何关于此的建议都将受到欢迎。

// pathological
C1* p1 = instance_from_name<C1>("C0");

注册重述

注册的目的是确保在尝试任何构造之前,每个类注册表都被调用以创建自身。实现的理想副作用是,做到这一点相当困难——毕竟,任何序列化类的代码最终都会访问注册表。但是,想象一下您打开了全新的 XML 启用持久对象应用程序并选择了文件 >>:打开 - 运行时将开始 throw,因为它试图实例化尚未映射到系统中的类。我还认为显式注册是一个好主意,因为它易于发现持久化过程从哪里开始,从而简化了调试或问题诊断。注册本身很简单,只需要做一次。

// use the long-hand
ess::registry_manager registry;
    registry
        << ess::class_registrar<C0,C0>("C0")
        << ess::class_registrar<C1,C0>("C1")
        << ess::class_registrar<C2,C0>("C2");
// macro short-hand
ess::registry_manager registry;
    registry
        << ESS_REGISTER(C0,C0)
        << ESS_REGISTER(C1,C0)
        << ESS_REGISTER(C2,C0);

另请注意,注册表对象不必保留。注册实际上做了三件事——

  • 强制静态 class_registry 实例存在,
  • 创建一个模板工厂类来创建所讨论的类型,
  • 将工厂类实例插入注册表中,以类名作为键,

多次注册不是错误,除非您尝试使用不同的工厂注册一个类名——对我来说,这至少暗示了一些潜在的编程错误。系统将 throw——这也是为什么将注册保留在代码的一个地方的原因。虽然我没有特别尝试过(我的编码信念反对这样做),但该系统应该适用于从动态链接库导出的类。

我发现了一个 registry_manager 的变体,它在几年前我使用 CodeProject 上发布的 Diagram Editor 时很有用。我有一个经过大量修改的版本,它使用 ESS 进行撤销/重做和另存为 XML。

// typed registry with CDiagramEntity as root
ess::typed_registry_manager<CDiagramEntity> registry;
// register the 3 classes of interest
registry
    << ESS_REGISTER(CEditor,CDiagramEntity)
    << ESS_REGISTER(CListBox,CDiagramEntity)
    << ESS_REGISTER(CStatic,CDiagramEntity);

// typed_registry_manager exposes instance_from_name()

适配器

archive_adapter 的目的是方便存储为新格式。一个从(例如)JSON 或某些专有二进制格式加载的适配器类只需要实现 archive_adapter 类中重载的 read() 函数。对于写入的适配器也是如此。源代码显示了对适配器的一种完全不同的处理方式——看看 ess_binary.h 中的 binary_debug_adapter 类。它的作用是在流式传输时实时将二进制存档转储到文本文件中;如果您想更详细地了解二进制存储,这很有用。

XML 是首选的存储格式——我认为 XML 生成的内容包含足够的信息以进行手工检查和调试是可取的,而该格式的普遍性意味着交换和互操作性很简单。除了存储类成员的内容,我们还想存储它们的名称。对于每个内置类型以及支持的容器类型,我们有许多称为 stream 的内联自由函数,它们具有特定的类型签名,并且它们都做同样的事情——接受 argname 参数,然后

  1. 如果 archive_adapter 正在存储,则将参数数据及其名称写入底层存储
  2. 如果 archive_adapter 正在加载,则将命名项的值读回 arg 参数
namespace ess
{
// for each intrinsic
inline void
    stream(archive_adapter& adapter,bool& arg,const std::string& name)    {...}
// ... more free functions as above
inline void
    stream(archive_adapter& adapter,GUID& arg,const std::string& name)    {...}
// now we have a generic templating. The following is for references
template<typename Type> inline void
    stream(archive_adapter& adapter,Type& arg,const std::string& name) {...}
// and one for pointer types
template<typename Type> inline void
stream(archive_adapter& adapter,Type*& arg,const std::string& name)    {...}
// and specializations for std::vector
template<class Type> inline void
    stream(archive_adapter& adapter,std::vector<Type>& arg,const std::string& name)    {...}
// and for std::map
template<typename Key,typename Value> inline void
    stream(archive_adapter& adapter,std::map<Key,Value>& arg,const std::string& name) {...}
}

错误检测

虽然 ESS 使为 C++ 类添加运行时持久化变得容易,但我们不想牺牲语言提供的任何编译时检查。事实上, wherever possible,我们想警告程序员,如果指向脚的枪即将开火。考虑以下

ESS_ROOT(C0)
ESS_RTTI(C0,C0)
ESS_RTTI(C1,C0)
ESS_RTTI(C2,C1) <- wrong...

虽然很容易做到。C1 不是根类。我们如何在编译时检测到这一点?有一些困难!ess_rtti 头文件中有一个叫做 compile_time_checker 的东西。它仅用于确保声明为 ESS_ROOT 的类始终在 ESS_RTTI 宏中用作根。换句话说,如果出现上述类型的错误,编译将失败。

template <typename Derived,typename Root>
struct compile_time_checker

以下错误类别不需要支持代码——因为它们最终会成为语法错误或(更有可能)无法解析的、不相关的类型错误。

  • 尝试序列化不支持的类型
  • 向具有非 void 构造函数的类型添加持久化
  • 尝试序列化未实现 get_name() 的类或结构
  • 尝试序列化未实现 get_registry() 的类或结构
  • 尝试序列化未在层次结构中实现 serialize() 的类或结构
  • 不存在的派生
    class CD3 : public CD2 { ESS_RTTI(CD3,CDX) }

结果是可预见的运行时错误是

  • 尝试序列化未注册的类型——即加载编译器不知道的类。这会失败,因为 class_registry 实例将抛出异常。
  • 尝试反序列化布局已更改的实例。此模式失败的方式很重要——如果布局“顺序错误”,则运行时应检测到该错误并 throw()

单元测试

这些相当直接,都包含在 ess_main.cpp 源文件中。其思想是组装一个测试用例,以验证(或不验证)关键的实现预期。代码显然必须满足基本要求才能编译——并且尽可能多地在编译时检查的错误都会被检查。然而,有一组条件只能在运行时进行测试。最基本的测试是

  1. 持久化类能否存储自身?
  2. 持久化一个类生成的数据是否足以创建一个新实例?
  3. 如果新实例本身被序列化,那么生成的数据是否等于初始数据(即,从 1. 开始)?
  4. 运行时能否支持检测编程错误,例如不正确的派生?

适应 ESS:42 行指南

以下是使任何类都符合 ESS 标准所需的步骤。所有代码都包含在 ess 命名空间中,因此到处都有 ess:: 限定符。

// main ESS include file - will pull in ess_rtti.h
#include "ess_stream.h"
// For XML storage.
#include "ess_xml.h"
// or for binary storage
#include "ess_binary.h"

// the base class of any persistent hierarchy uses the ESS_ROOT macro
class persistent_base
{
    // example persistent member
    some_type class_member;
    public:
    // use ESS_ROOT in the 'least-derived' class
    ESS_ROOT(persistent_base)
    ESS_RTTI(persistent_base,persistent_base)
    // serialization function - virtual
    virtual void serialize(ess::archive_adapter& adapter)
    {
        //
        ESS_STREAM(adapter,class_member);
    }
}

// any subsequent descendents use the ESS_RTTI macro
class persistent_derived : public persistent_base
{
    some_type class_member;
    public:
    // note the ESS_RTTI arguments - name of this class
    // then the name of the *root* class
    ESS_RTTI(persistent_derived,persistent_base)
    // ensure serialize in the base class is called ...
    virtual void serialize(ess::archive_adapter& adapter)
    {
        // stream members of this class
        ESS_STREAM(adapter,class_member);
        // stream members of the base class
        persistent_base::serialize(adapter);
    }
}

就是这样。所有实现都是内联的,除了 CoCreateGuid() 之外,运行时支持仅使用 std:: 命名空间中的构造,即 std::stringstd::mapstd::vector。如果您想了解有关如何扩展 ESS 以编组自定义类型的更多详细信息,请参阅 ESS_GUI 项目中的 ess_class.h 文件。它展示了如何持久化 COleDateTime

源代码

VS2003 和 VS2008 存档都包含两个文件夹

  • ./codeproject/ess_code/ess_0X
  • ./include/...

解压缩时确保创建这些路径,因为这样项目就可以直接构建,而无需设置新的 #include 路径等。如果有人发现情况并非如此,请告知我。MFC GUI 项目应该以完全相同的方式解压缩。

未来项目

我在当前实现中故意避免了以下问题。

  • 支持不太常用的容器,例如 std::liststd::stack。我的代码很少使用这些类——添加支持很容易。
  • 智能指针及相关——这些只是最近才出现在 VS2008 的 TR 更新中,并且尚未在标准 C++ 库中找到。我不想自己实现。
  • 二进制存储系统中的字节序问题——现在,一切都是 Intel 顺序。在二进制存储中使用网络字节序会很好。

其他事项

  • XML 存储的读写版本存在一些恼人的反对称性。我想将其平滑掉。
  • 效率。XML 读取器/写入器的上层可能需要一些简化。

结论

这几乎就结束了。我希望我已经确凿地证明了类型安全、标准合规且可移植的持久化 C++ 代码可以以最少的编程工作量创建。与 C# 代码的比较很有趣。放弃效率较低但自动化的反射持久化,使用 XML 标签手动指定成员进行序列化,其代码开销与 ESS 相当。欢迎任何建设性的讨论,我很乐意听取关于改进和美化的反馈。

我没有机会用 Visual C++ 6.0 编译,因为它在这里不再使用。我怀疑它缺乏 ESS 工作所需的模板机制。我很想证明我是错的。此外,尽管尽了最大努力,我还是无法说服 Cygwin 自带的 GCC 找到正确的标准头文件。我现在对不配合的命令行工具的耐心非常有限,所以测试被搁置了。

致谢与参考文献

  1. XML 解析器是 DLib 中包含的解析器的一个精简版本。该库包含一些有趣的东西,包括一种序列化方式——感谢该团队。
  2. the BOOST library offers extensive, heavy-weight, C++ serialisation support.
  3. 模板 (1) 现代 C++ 设计,Andrei Alexandrescu,Addison-Wesley 2002:Amazon
  4. 模板 (2) C++ 模板,D Vandervoorde,N.M Josuttis,Addison-Wesley 2003:Amazon UK
  5. C++ 对象数据库在 90 年代初风靡一时,它们都必须解决编组问题。请参阅 GigabasePOET/VersantObjectivity 以了解当时流行的 FOSS 和商业产品。
  6. 感谢 Johan 提供的 UML 编辑器
  7. 感谢 Michal Mecinski 提供的原始树/网格 (www.mimec.org)。

    我无情地扩展了这个控件,以支持任意列数、多重选择、单元格寻址、单元格着色、项目数据和复选框支持。任何错误都是我自己的。有关详细信息,请参阅 view.cppColumnTreeWnd.h

  8. ESS 更新的标准 URL 将是 NovaDSP.com

脚注:我正在找工作。如果您有任何有趣的职位机会,请与我联系。谢谢。

历史

  • 版本 1.01 - 2009 年 3 月 14 日
  • 版本 1.02 - 2009 年 3 月 17 日
© . All rights reserved.