C++中的“结构体hack”等效方法

24

众所周知,在C90和C99中,有一种叫做结构体hack的技巧,可以在结构体的最后一个成员处使用长度为0的数组。随着C99引入灵活数组成员的方式,我们甚至可以使用[]以标准的方式来使用它。不幸的是,C++并没有提供这样的构造方法,并且(至少在Clang 3.4中),使用[0][]编译结构体将会产生编译警告,即使加上了--std=c++11 -pedantic选项也无济于事:

$ cat test.cpp 
struct hack {
  char filler;
  int things[0];
};
$ clang++ --std=c++11 -pedantic test.cpp
\test.cpp:3:14: warning: zero size arrays are an extension [-Wzero-length-array]
  int things[0];

同理

$ cat test.cpp 
struct fam {
  char filler;
  int things[];
};
$ clang++ --std=c++11 -pedantic test.cpp
\test.cpp:3:7: warning: flexible array members are a C99 feature [-Wc99-extensions]
  int things[];
我的问题是这样的:假设我想在C++中定义一个包含可变长度数组作为最后一项的结构体,在支持两种方法的编译器下应该怎么做?我应该选择使用结构体技巧中的[0](这是一个编译器扩展),还是FAM中的[](这是C99的特性)?据我所知,两者都能工作,但我想弄清楚哪个是较小的罪恶?此外,在人们开始建议在结构体中保留指向另行分配的内存块的int*之前,请注意这不是一个令人满意的答案。我想要分配一个单独的内存块来容纳我的结构体和数组元素。使用std::vector也属于同一类别。如果你想知道为什么我不想使用指针,另一个问题的答案提供了很好的概述。有一些类似的问题,但没有给出答案回答这个特定的问题:

3
我不太确定你在问什么,链接的问题明确指出,在标准C++中没有这种东西,所以你尝试的任何东西都是由实现定义的,只要它在所有你关心的实现上工作正常,那就选择你想要的任何选项。 - PlasmaHH
3
使用int things[1];? 对于不会受到结构体奇技淫巧影响的实现,如果有填充的话并没有固有问题,稍后访问元素是完全可以的。 如果你不想浪费一个元素加上可能的填充空间,那么在分配时你只需要多做一点工作。 - nos
4
如何在不违反指针算术规则的情况下访问后面的元素? - Ben Voigt
3
你使用支持结构体技巧的实现方式,从而允许你超出数组范围并进入额外分配的空间。当然,如果你的C++实现不保证这一点,或者需要遵循标准C++,那就无法这么做。这似乎有些合理,因为问问题的人已经在询问非符合C++标准的特性了。 - nos
2
你不能在结构体末尾使用指针,分配一个比必要的更大的块,并将结构体放置到该块中吗?指针可以指向自己的地址加一。 - Tim Seguine
显示剩余7条评论
6个回答

12

你可以使用成员函数和reinterpret_cast达到几乎相同的效果:

int* buffer() { return reinterpret_cast<int*>(this + 1); }

这种方法存在一个主要缺陷:它不能保证正确的对齐。例如,像这样的情况:

struct Hack
{
    char size;
    int* buffer() { return reinterpret_cast<int*>(this + 1); }
};

很可能会返回一个未对齐的指针。你可以通过将结构体中的数据与要返回指针的类型在联合中处理来解决此问题。如果您使用的是C++11,可以声明:

struct alignas(alignof(int)) Hack
{
    char size;
    int* buffer() { return reinterpret_cast<int*>(this + 1); }
};

(我认为。实际上我从未尝试过这个,而且我可能对语法的一些细节有所错误。)

该习惯用法还有第二个重要缺陷:它不能确保大小字段与缓冲区的实际大小相对应,更糟糕的是,在这里没有真正使用new的方法。为了在某种程度上纠正这个问题,您可以定义一个特定类的operator newoperator delete

struct alignas(alignof(int)) Hack
{
    void* operator new( size_t, size_t n );
    void operator delete( void* );
    Hack( size_t n );
    char size;
    int* buffer() { return reinterpret_cast<int*>(this + 1); }
};

客户端代码随后将需要使用放置new来分配:

Hack* hack = new (20) Hack(20);

客户仍需重复大小,但不能忽略它。

还有一些技术可用于防止创建未动态分配的实例等,以得到类似以下结果:

struct alignas(alignof(int)) Hack
{
private:
    void operator delete( void* p )
    {
        ::operator delete( p );
    }
    //  ban all but dynamic lifetime (and also inheritance, member, etc.)
    ~Hack() = default;

    //  ban arrays
    void* operator new[]( size_t ) = delete;
    void operator delete[]( void* p ) = delete;
public:
    Hack( size_t n );
    void* operator new( size_t, size_t n )
    {
        return ::operator new( sizeof(Hack) + n * sizeof(int) );
    }
    char size;
    //  Since dtor is private, we need this.
    void deleteMe() { delete this; }
    int* buffer() { return reinterpret_cast<int*>(this + 1); }
};

考虑到这样一个类的基本危险性,是否需要如此多的保护措施还存在争议。即使有了这些措施,只有完全理解所有约束条件并仔细关注的人才能真正使用它。在除极端情况和非常低级别的代码外,你只需将缓冲区设置为std::vector<int>即可完成。在大多数情况下,性能的差异都不值得冒险和努力。

编辑:

以g++实现的std::basic_string为例,其使用与上述类似的东西,包括一个结构体,其中包含引用计数、当前大小和当前容量(三个size_t),直接跟随字符缓冲区。由于它是在C++11和alignas/alignof之前编写的,因此像std::basic_string<double>这样的东西在某些系统上会崩溃(例如Sparc)。尽管技术上是一个错误,但大多数人不认为这是一个关键问题。


2
为什么 +1 最好用 sizeof(Hack) 替换?而且关于 new(20) Hack(20); 很容易出错,因为你需要两次输入相同的值,更好的解决方案是 newMe(int),它返回 new(20) Hack(20); - Yankes
1
@Yankes 好观点:将ctor和operator new也设为私有,同时提供一个静态成员函数来替代new。 (作为借口,我要指出我最后一次使用这种技术已经有20年了。实际上,在实践中,我会避免使用它,除非在像std::basic_string这样的特殊情况下,其使用是实现细节,且对于任何人都是隐藏的,只有作者能看到。) - James Kanze
1
根据[dcl.align]/3,alignas(alignof(T))可以缩写为alignas(T) - Casey

10

这是C++,因此模板(template)是可用的:

template <int N>
struct hack {
    int filler;
    int thing [N];
};

将不同实例的不同指针之间进行转换将是一个困难的问题。


1
+1,不过 std::size_t 会是更好的模板参数类型选择。 - Zac Howland
12
思路清晰,但编译时需要已知变量N的值,这并非总是可行。例如,如果我有一个结构体,在其中为机器上每个用户存储一个双精度浮点数,这只能在运行时确定。 - Jon Gjengset
1
@Jonhoo 实际上,由于N必须是一个常量,并且每个不同的大小都是完全不相关的类型,这意味着您不能简单地传递指针来解决问题,因此这确实无法解决问题。在某些特殊情况下,它可能有用,但仅限于此。 - James Kanze
1
很遗憾,它并不能解决这个问题。不过,如果在编译时已知 N 的值,这是一种不错的解决方法,因为它还可以强制执行类型安全! - Jon Gjengset

8
首先要注意的是,请不要在C++中写C代码,这种hack在99.99%的情况下是不必要的,与仅持有一个std::vector相比,它不会显著提高性能,反而会使您和其他项目维护者的生活更加复杂。
如果您想采用符合标准的方法,请提供一个包装类型,动态分配足够大的内存块来容纳hack(减去数组)加上等效于数组的N*sizeof(int)(不要忘记确保正确的对齐方式)。该类将具有访问器,将成员和数组元素映射到内存中的正确位置。
为了使界面友好且实现安全,忽略对齐和模板代码:
template <typename T>
class DataWithDynamicArray {
   void *ptr;
   int* array() {
      return static_cast<int*>(static_cast<char*>(ptr)+sizeof(T)); // align!
   }
public:
   DataWithDynamicArray(int size) : ptr() {
      ptr = malloc(sizeof(T) + sizeof(int)*size); // force correct alignment
      new (ptr) T();
   }
   ~DataWithDynamicArray() { 
      static_cast<T*>(ptr)->~T();
      free(ptr);
   }
// copy, assignment...
   int& operator[](int pos) {
       return array()[pos];
   }
   T& data() {
      return *static_cast<T*>(ptr);
    }
};

struct JustSize { int size; };
DataWithDynamicArray<JustSize> x(10);
x.data().size = 10
for (int i = 0; i < 10; ++i) {
    x[i] = i;
}

现在,我绝不会以那种方式实现它(最好根本不要实现!),例如,大小应该是 DataWithDynamicArray 状态的一部分...

这个答案仅作为练习提供,以解释可以通过不使用扩展来完成相同的事情,但请注意,这只是一个玩具示例,有许多问题,包括但不限于异常安全或对齐(但仍然比强制用户使用正确大小的 malloc 更好)。能做并不意味着应该做,而真正的问题在于您是否需要此功能以及您尝试执行的操作是否设计良好。


2
这与@JamesKanze的答案非常相似,只是它还添加了一层间接性,而我想避免这种情况。我喜欢它保持数组索引语义的事实。另一个被丢弃的东西是,在结构体中有[][0]会告诉任何阅读代码的人,实际上会有与结构体直接相关的数据跟随结构体(甚至有一个名称)。 - Jon Gjengset
@Jonhoo 如果我理解他的解决方案正确的话,他的类DataWithDynamicArray实际上是指针的包装器,而不是类本身。 - James Kanze
1
@Jonhoo:这个解决方案与James的非常相似,尽管没有那么发展。如果我读过他的话,我就不会加入这个了。在没有额外间接引用(至少在生成的代码中)的情况下,主要区别在于James的方法中用户可以使用更好的接口访问初始成员集合,但是我的解决方案可以与任何初始成员集合(或其不同版本)一起使用,而他的则需要为每种情况编写不同的类型。当然,这也意味着这里的解决方案只完成了一半。 - David Rodríguez - dribeas
"在99.99%的情况下,这种黑客攻击是不必要的" - 我不同意;如果你足够聪明知道什么是结构黑客攻击,那么你也足够聪明知道何时使用它。我估计,对于将结构黑客攻击视为解决特定问题的解决方案的人来说,使用结构黑客攻击的时间百分比为99.99%。" - hoodaticus
@hoodaticus:这种编程语言中有许多功能,其中许多功能是为了与先前版本的语言或C兼容而设计的。一些旧功能具有更安全的替代方案,应避免使用。这就是评论所说的。您知道它的作用并不意味着这是解决问题的正确工具。我可以用锤子拧螺丝,但螺丝刀更好,甚至电动螺丝刀更好(如果您没有对扭矩进行严格的限制,那么您需要其他工具...)您需要了解工具,但这并不意味着您应该使用全部 - David Rodríguez - dribeas

4
如果你真的觉得需要使用hack,为什么不直接使用?
struct hack {
  char filler;
  int things[1];
};

跟随着

之后


hack_p = malloc(sizeof(struct hack)+(N-1)*sizeof int));

或者你也可以不考虑-1并且多留一些额外的空间。


(注:此句话涉及上下文语境,翻译仅供参考)

抱歉,刚看到@nos在评论中建议了同样的事情... 但我不明白反对意见是什么。 - AShelly

3
C++没有“灵活数组”的概念。在C++中,唯一拥有灵活数组的方法是使用动态数组,这将导致您使用"int *things"。如果您要尝试从文件中读取此数据,则需要一个大小参数,以便您可以创建适当大小的数组(或者使用std :: vector并继续读取直到达到流的末尾)。
“灵活数组”技巧保留了空间局部性(即在分配的内存块中与其余结构具有连续性),而当您被迫使用动态内存时,您会失去它。实际上没有一个优雅的方法来解决这个问题(例如,您可以分配一个大缓冲区,但您必须使其足够大以容纳任何您想要的元素数量 - 如果实际读入的数据小于缓冲区,则会分配浪费空间)。
此外,在人们开始建议在结构体中保持对单独分配的内存块的int*之前,这不是一个令人满意的答案。我想分配一个单一的内存块来容纳我的结构和数组元素。使用std :: vector也属于同一类别。
当您转移到不支持它的编译器时,非标准扩展将无法工作。如果遵循标准(例如避免使用特定于编译器的技巧),则更不容易遇到这些类型的问题。

8
不,它不等同于指针。这个(在C语言中的)技巧允许你将最后一个成员视为任意大小的数组,无需额外的间接层,只要你为它分配了足够的内存。在标准的C++中没有相应的技巧。 - Mike Seymour
4
这里涉及到的关键点是(1)空间局部性,(2)分配数量和(3)是否为指针占用空间......这些关键点与R的答案讨论的完全不同。 - Ben Voigt
1
我已经编辑了问题,使我的问题更加清晰明了。基本上,我不是很关心我可以做什么,而是在编译器支持两种扩展的情况下,哪种方法更合理。 - Jon Gjengset
1
@ZacHowland 我完全同意,但在这种情况下,实际上并没有标准的替代方案。使用指针或 std::vector 不是一种替代方案,而是一种完全不同的解决问题的方法。我在我的代码中其他地方很高兴地使用它们,但对于我目前正在处理的特定问题,使用类似 [] 的东西会得到更清晰的解决方案。 - Jon Gjengset
1
可变长数组成员是C99的标准部分,据我所知并不是扩展功能? - Jon Gjengset
显示剩余14条评论

1

当编译器为clang时,灵活数组成员相对于零长度数组至少有一个优势。

struct Strukt1 {
    int fam[];
    int size;
};

struct Strukt2 {
    int fam[0];
    int size;
};

这里,如果clang看到Strukt1,它会报错,但如果看到Strukt2,则不会报错。gcc和icc都可以接受任意一个而不报错,而msvc在任何情况下都会出错。如果将代码编译为C,gcc也会报错。
对于这个类似但不太明显的例子,也是一样的情况:
struct Strukt3 {
    int size;
    int fam[];
};

strukt Strukt4 {
    Strukt3 s3;
    int i;
};

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