我需要将一个类型定义为POD类型才能使用内存映射文件进行持久化吗?

15

指针不能直接持久化到文件中,因为它们指向绝对地址。为了解决这个问题,我编写了一个relative_ptr模板,它保存了偏移量而不是绝对地址。

基于只有平凡复制的类型才能安全地按位复制的事实,我做出了这种类型需要是平凡复制的假设,以便在内存映射文件中安全地持久化并在以后检索。

这种限制后来证明有点棘手,因为编译器生成的复制构造函数没有有意义的行为。我发现没有任何禁止我将复制构造函数默认设置为私有并使其成为私有的,以避免意外的复制导致未定义的行为。

后来,我发现了boost::interprocess::offset_ptr,它的创建也是出于同样的需求。然而,结果表明,offset_ptr并不是平凡复制的,因为它实现了自己的自定义复制构造函数。

我的假设是智能指针需要是平凡复制的才能安全地持久化吗?

如果没有这样的限制,我想知道是否也可以安全地执行以下操作。如果不行,那么类型必须满足哪些要求才能在上述场景中使用?

struct base {
    int x;
    virtual void f() = 0;
    virtual ~base() {} // virtual members!
};

struct derived : virtual base {
    int x;
    void f() { std::cout << x; }
};

using namespace boost::interprocess;

void persist() {
    file_mapping file("blah");
    mapped_region region(file, read_write, 128, sizeof(derived));
    // create object on a memory-mapped file
    derived* d = new (region.get_address()) derived();
    d.x = 42;
    d->f();
    region.flush();
}

void retrieve() {
    file_mapping file("blah");
    mapped_region region(file, read_write, 128, sizeof(derived));
    derived* d = region.get_address();
    d->f();
}

int main() {
    persist();
    retrieve();
}
感谢所有提供替代方案的人。由于我已经有一个可行的解决方案,所以我不太可能很快使用其他东西。正如您从上面的问号使用中可以看出的那样,我真的很想知道为什么 Boost 可以不使用平凡可复制的类型,并且你能走多远:很明显具有虚成员的类将无法工作,但是在哪里划线呢?
6个回答

8
为了避免混淆,让我重新陈述问题。
您想以这样的方式在映射内存中创建对象,以便在关闭并重新打开应用程序后,可以再次映射文件并使用对象而无需进一步反序列化。
POD对于您要做的事情有点误导性。 您不需要进行二进制复制(POD的含义); 您需要是地址独立的。
地址独立需要您:
- 避免所有绝对指针。 - 仅使用指向映射内存中地址的偏移指针。
从这些规则中可以得出一些推论。
  • 你不能使用virtual任何东西。C++虚函数是通过类实例中的隐藏vtable指针实现的。vtable指针是一个绝对指针,您无法控制。
  • 您需要非常小心处理独立于地址的对象使用的其他C++对象。基本上,如果您使用它们,标准库中的所有内容都可能会出错。即使它们不使用new,它们也可能在内部使用虚函数,或者仅存储指针的地址。
  • 您不能在独立于地址的对象中存储引用。引用成员只是绝对指针上的语法糖。

继承仍然可能,但由于禁止使用虚拟,其有限的用处。

只要遵循上述规则,任何和所有构造函数/析构函数都可以。

即使Boost.Interprocess也不完全适合您所尝试的内容。Boost.Interprocess还需要管理对对象的共享访问,而您可以假设只有您自己在处理内存。

最后,使用Google Protobufs和传统序列化可能更简单/更明智。


你说它不需要“可复制二进制”。这是否意味着我可以使用mmaped文件来绕过不能使用memcpy的限制?http://ideone.com/TK4SF - R. Martinho Fernandes
你的例子并没有复制对象,而是给对象起了别名。如果你改变 x.foo,那么 y.foo 也会改变。尽管它们由于 mmap 具有不同的地址,但它们是同一个对象。 - deft_code
从技术上讲,它们不是同一个对象。C++对象的标识由其地址定义。在所有mmapped文件实现中,更改一个文件很可能会更改另一个文件,因为两个区域都将映射到相同的底层页面。但是,如果这种情况没有发生,我认为我确实会因为实现细节而作弊。无论如何,我现在认为我已经得到了答案:没有POD类似的要求,只有我已经处理的地址独立性要求 :) - R. Martinho Fernandes
1
不,它们是同一个对象。它们可能有不同的地址,但示例使用了共享内存映射。因此,它们必须是彼此的别名;对 x 的更改必须显示在 y 中。任何实现都不能合法地做出其他选择。此外,您必须将 foo 声明为 volatile,否则编译器可能会进行不考虑别名的优化。最后,尽管 not_trivially_copyable 有一个构造函数,但它仍然是 trivially copyable。trivially copyable 的概念与是否可以正确执行对象的位拷贝有关。 - James Caccese
有关于trivially copyable的更多信息,请参见http://groups.google.com/group/comp.lang.c++.moderated/msg/bbfc2a3d9b1665f3 - James Caccese

4

是的,但原因不同于你所关心的。

你有虚函数和虚基类。这会导致编译器在背后创建大量指针。你不能将它们转换为偏移量或其他任何东西。

如果你想要进行这种持久化风格,你需要避免使用“virtual”。之后,一切都是语义问题。实际上,就像你在C中做这个一样。


1
那么,我的类型需要满足哪些精确的要求才能像这样被持久化呢? - R. Martinho Fernandes

2

这不是一个答案,而是一个变得太长的评论:

我认为这将取决于您愿意为速度/易用性交换多少安全性。在您有如下struct的情况下:

struct S { char c; double d; };

你需要考虑填充和某些体系结构可能不允许您访问未对齐的double。添加访问器函数并修复填充可以解决此问题,而且该结构仍然可以进行memcpy,但现在我们正在进入一个领域,在这个领域中,使用内存映射文件并没有带来太多好处。
由于看起来你只会在本地和固定设置中使用它,所以稍微放松一下要求似乎是可以的,因此我们又回到了通常使用上述struct的情况。现在,这个函数必须是可平凡复制的吗?我并不一定认为是这样的,请考虑这个(可能破损的)类:
   1 #include <iostream>
   2 #include <utility>
   3 
   4 enum Endian { LittleEndian, BigEndian };
   5 template<typename T, Endian e> struct PV {
   6         union {
   7                 unsigned char b[sizeof(T)];
   8                 T x;
   9         } val;  
  10         
  11         template<Endian oe> PV& operator=(const PV<T,oe>& rhs) {
  12                 val.x = rhs.val.x;
  13                 if (e != oe) {
  14                         for(size_t b = 0; b < sizeof(T) / 2; b++) {
  15                                 std::swap(val.b[sizeof(T)-1-b], val.b[b]);
  16                         }       
  17                 }       
  18                 return *this;
  19         }       
  20 };      

这个类不是简单的可复制的,通常不能使用memcpy来移动它,但是如果文件与本地字节顺序匹配,在内存映射文件的上下文中使用这样的类似乎没有什么明显的问题。

更新:
你在哪条线上划分?

我认为一个好的经验法则是:如果等价的C代码是可以接受的,而C++只是用作方便、强制类型安全或正确访问,那么应该是可以的。

这将使得boost::interprocess::offset_ptr是可以的,因为它只是一个特殊语义规则的ptrdiff_t的有用包装器。同样,像上面的struct PV一样,它只是自动字节交换,所以在C中一样要小心保持字节顺序并假设结构体可以被简单复制。虚函数是不可以的,因为C等效的函数指针在结构体中不起作用。然而,像以下(未经测试)的代码再次是可以的:

struct Foo { 
    unsigned char obj_type;
    void vfunc1(int arg0) { vtables[obj_type].vfunc1(this, arg0); }
};

实际上,你的例子仍然可以轻松地进行复制。模板并不能防止编译器生成默认的复制赋值运算符。事实上,默认运算符总是会胜出重载决议。http://www.ideone.com/qMdED。但我理解你的意思。另外,感谢你回答我所提出的问题 :) - R. Martinho Fernandes
哦,我差点忘了(离题了):不要告诉任何人,即使它在几乎所有地方都有效,使用联合进行类型转换是未定义的行为 ;) - R. Martinho Fernandes
这只是一个快速示例,不适用于生产代码,想象一下它已经实现了复制构造函数,以赋值运算符为基础 :) 我试图表达的观点是,在大多数情况下,如果您可以接受特殊规则来管理使用C++访问mmaped值的情况,那么充分利用C++来表达意图是可以的,并且在我看来更可取,而不是尝试强制执行约定(例如,“必须使用get/set_uint32_le访问此变量”)。希望我的论点有道理。 - user786653
是的,你的答案也符合我的直觉。我还不会接受它,因为我希望有人能提供权威的答案,我可能会设置悬赏,但再次感谢 :) - R. Martinho Fernandes

2

即使您有兴趣在不同系统或时间之间进行互操作,普通对象存储(PoD)也可能存在问题。

您可以查看Google Protocol Buffers,以一种便携式的方式来解决这个问题。


谢谢。但在这种情况下,文件永远不会离开这台机器,所以这不是问题。 - R. Martinho Fernandes
1
你会升级机器吗?你会换编译器吗?你会改变SDK,以至于你的结构打包发生变化吗?编译器标志呢?这里有很多问题可能会出现。 - Steve Dispensa
不用担心,该文件是本地的、临时的,不会被共享。而且未来版本不兼容也不是问题。 - R. Martinho Fernandes
@R. Martinho Fernandes,你应该在你的原始问题中提到这一点。 - DuckMaestro
1
@Duck:也许是吧,但这与问题无关。他问的是将对象持久化到内存映射文件所需的要求。可移植性、编译器升级和协议缓冲区都与此无关。 - jalf
1
不想听起来像是在抱怨,但是一次踩?他没有明确说明文件永远不会被移动,在问题中的隐含假设显然是 POD 是安全的,而在我提到的情况下并非如此。我提供了一个与 POD 一样安全的替代方案,这也是问题所假定的。"我需要将类型设置为 POD..." 意味着提问者可能忽略了一个重要的点,至少在没有 DuckMaestro 所要求的澄清的情况下是这样。 - Steve Dispensa

1
绝对不是这样的。序列化是一个被广泛应用于许多情况下的成熟功能,当然不需要 PODs。它所需要的是您指定一个明确定义的序列化二进制接口(SBI)。
无论何时您的对象离开运行时环境,包括共享内存、管道、套接字、文件和许多其他持久性和通信机制,都需要序列化。
PODs 的帮助在于您知道您不会离开处理器架构。如果您永远不会在对象的编写者(序列化程序)和读者(反序列化程序)之间更改版本,并且您不需要动态大小的数据,则 PODs 允许基于 memcpy 的简单序列化程序。
通常,您需要存储诸如字符串之类的东西。然后,您需要一种方法来存储和检索动态信息。有时候,使用 0 结尾的字符串,但这对字符串来说非常特定,并且不能用于向量、映射、数组、列表等。您经常会看到字符串和其他动态元素被序列化为 [size][element 1][element 2]… 这是 Pascal 数组格式。此外,在处理跨机器通信时,SBI 必须定义整数格式以处理潜在的字节序问题。
现在,指针通常是通过ID而不是偏移量来实现的。每个需要被序列化的对象都可以被赋予一个递增的数字作为ID,并且这可以成为SBI中的第一个字段。通常不使用偏移量的原因是因为你可能无法轻松地计算未来的偏移量,除非经过一次大小调整或第二遍扫描。ID可以在第一次执行序列化例程时计算。
使用文本序列化器的其他方法包括使用类似XML或JSON的某种语法。这些是使用标准文本工具解析的,用于重构对象。这些保持了SBI的简单性,但代价是性能和带宽的悲观。
最终,通常会构建一种架构,其中构建序列化流,将您的对象逐个成员地转换为SBI的格式。对于共享内存,在获取共享互斥锁后,它通常直接将成员推送到内存中。
这通常看起来像:
void MyClass::Serialise(SerialisationStream & stream)
{
  stream & member1;
  stream & member2;
  stream & member3;
  // ...
}

在你的不同类型中,& 运算符被重载了。你可以查看 boost.serialize 获取更多示例。


3
他没有在谈论序列化。 - GManNickG
2
@Kevin,这个问题中还包括关于描述场景所需的要求(在问题标记附近)的提及。这就是我感兴趣的地方。我已经有一个可行的解决方案,并且我知道它是安全的。我想知道我可以走多远,仍然保持安全。如果这一点没有从原始文本和我发布的评论中清楚地表达出来,那我很抱歉。 - R. Martinho Fernandes
2
@R. Martinho Fernandes:我真的不明白所有这些反对意见。我的回应当然适用于所描述的情况。事实上,多年前我曾将其精确地用于操作系统扩展中共享内存的数据存储,该扩展注入了DLL到每个进程以拦截系统API。函数“persist”和“retrieve”实际上与我的非常相似,只是我的数据结构是序列化的,而不是位重新解释的。满足了功能要求,并且我提出的方案更加强大,明确地扩展了所请求的功能。 - ex0du5
2
@R. Martinho Fernandes:如果您担心切换的时间,这里提供了完整的实现。http://bit.ly/oqWFRH - ex0du5
3
我不反对你在这里提出的建议。事实上,如果我最终需要比现在更复杂的东西(即一堆原始未解释的字节、位域和相对指针之一),我可能会转向真正的序列化方案。我的不情愿改变源于当前代码非常简单且轻巧。顺便说一下,我没有对这里提供的任何答案进行投票。也许我不应该提供太多背景信息来回答问题,而是更专注于 boost:: interprocess ::offset_ptr - R. Martinho Fernandes
显示剩余9条评论

1

那行不通。你的 class Derived 不是一个POD(平凡标量类型),因此它依赖于编译器如何编译你的代码。换句话说,不要这样做。

顺便问一下,你在哪里释放你的对象?我看到你正在原地创建你的对象,但你没有调用析构函数。


1
Boost的offset_ptr也不是POD类型。这是怎么回事?(在这个例子中,我故意忽略了清理工作。如果没有资源需要清理,我可以跳过调用析构函数,对吧?) - R. Martinho Fernandes
1
清理工作是小问题。另一方面,我们可以谈论整夜 UB(即上面的示例)。最有可能它会运行,但不是百分之百保证。 - BЈовић
3
好的,它不能运行,但并非因为它不是POD。我认为在Boost中使用 offset_ptr 不会导致未定义行为,因为它在Boost中,嗯,这就是原因。offset_ptr 不是POD,因此在此并非要求它是POD。我想知道这里的要求是什么 - R. Martinho Fernandes

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