对于不完整的结构使用堆栈内存的C最佳实践

时间:2014-04-02 02:48:08

标签: c memory-management struct

有些时候我想要一个不完整的结构(只有一个C文件知道它的成员), 所以我可以为任何操作定义API,因此开发人员不能轻易地在API之外操作它。

执行此操作的问题,通常意味着您需要一个构造函数,该函数分配数据并在之后释放(使用mallocfree)。

在某些情况下,从内存管理的角度来看,这一点毫无意义,特别是如果struct很小,并且分配和释放了很多。

所以我想知道什么是可移植的方法来保持成员本地的C源文件,并仍然使用堆栈分配。

当然这是C,如果有人想弄乱struct内部人员,但是如果可能的话,我希望它能够发出警告或错误。

示例,简单随机数生成器(为简洁起见,仅包括新/自由方法)。

标题:rnd.h

struct RNG;
typedef struct RNG RNG;

struct RNG *rng_new(unsigned int seed);
void        rng_free(struct RNG *rng);

资料来源:rnd.c

struct RNG {
    uint64_t X;
    uint64_t Y;
};

RNG *rng_new(unsigned int seed)
{
    RNG *rng = malloc(sizeof(*rng));
    /* example access */
    rng->X = seed;
    rng->Y = 1;
    return rng;
}

void rng_free(RNG *rng)
{
    free(rng);
}

其他来源:other.c

#include "rnd.h"
void main(void)
{
    RND *rnd;

    rnd = rnd_new(5);

    /* do something */

    rnd_free(rnd);
}

可能的解决方案

我有两个想法可以做到这一点,两者都觉得有点像kludge。

仅声明大小(在标题中)

将这些定义添加到标题中。

标题:rnd.h

#define RND_SIZE      sizeof(uint64_t[2])
#define RND_STACK_VAR(var) char _##var##_stack[RND_SIZE]; RND *rnd = ((RND *)_##var##_stack)

void rnd_init(RND *rnd, unsigned int seed);

确保尺寸同步。

资料来源:rnd.c

#include "rnd.h"

struct RNG {
    uint64_t X;
    uint64_t Y;
};

#define STATIC_ASSERT(expr, msg) \
    extern char STATIC_ASSERTION__##msg[1]; \
    extern char STATIC_ASSERTION__##msg[(expr) ? 1 : 2]

/* ensure header is valid */
STATIC_ASSERT(RND_SIZE == sizeof(RNG))

void rng_init(RNG *rng, unsigned int seed)
{
    rng->X = seed;
    rng->Y = 1;
}

其他来源:other.c

#include "rnd.h"

void main(void)
{
    RND_STACK_VAR(rnd);

    rnd_init(rnd, 5);

    /* do something */

    /* stack mem, no need to free */
}

保持大struct成员的大小同步可能很麻烦,但对于小结构来说,这不是一个问题。

有条件地隐藏struct成员(在标题中)

使用GCC的弃用属性,但是如果有更便携的方法可以做到这一点,那就很好。

标题:rnd.h

#ifdef RND_C_FILE
#  define RND_HIDE /* don't hide */
#else
#  define RND_HIDE __attribute__((deprecated))
#endif

struct RNG {
    uint64_t X RND_HIDE;
    uint64_t Y RND_HIDE;
};

资料来源:rnd.c

#define RND_C_FILE
#include "rnd.h"

void main(void)
{
    RND rnd;

    rnd_init(&rnd, 5);

    /* do something */

    /* stack mem, no need to free */
}

这样,您可以使用RND作为在堆栈上定义的常规结构,只是在没有警告/错误的情况下不访问其成员。但它只是GCC。

5 个答案:

答案 0 :(得分:4)

你可以用类似于你的第一个例子的方式在标准C中完成这个,不过没有经历过很多痛苦来逃避别名违规。

现在让我们来看看如何定义类型。为了使其完全不透明,我们需要使用在运行时从函数中获取大小的VLA。与大小不同,对齐不能动态完成,因此我们必须最大程度地对齐类型。我使用的是stdalign.h中的C11对齐说明符,但如果需要,您可以替换您喜欢的编译器对齐扩展名。这允许类型在不破坏ABI的情况下自由更改,就像典型的堆分配的opaque类型一样。

//opaque.h
size_t sizeof_opaque();
#define stack_alloc_opaque(identifier) \
    alignas(alignof(max_align_t)) char (identifier)[sizeof_opaque()]

//opaque.c
struct opaque { ... };
size_t sizeof_opaque(void) { return sizeof(struct opaque); }

然后,要创建我们的虚假类型的实例blackbox,用户将使用stack_alloc_opaque(blackbox);

在我们进一步研究之前,我们需要确定API如何能够与伪装成结构的数组进行交互。据推测,我们还希望我们的API接受堆分配struct opaque*,但在函数调用中,我们的堆栈对象衰减为char*。有一些可以想到的选择:

  • 强制用户使用等效的-Wno-incompatible-pointer-types
  • 进行编译
  • 强制用户手动投放每个调用,例如func((struct opaque*)blackbox);
  • 尝试重新定义stack_alloc_opaque()以使用数组的一次性标识符,然后将其分配给宏中的struct opaque指针。但是现在我们的宏有多个语句,我们用一个用户不知道的标识符来污染命名空间。

所有这些都以他们自己的方式非常不受欢迎,并且没有一个解决了char*可能为任何类型别名的根本问题,反之则不正确。即使我们的char[]完全对齐并且为struct opaque调整大小,但通过指针强制转换为一个被重新解释为verboten。我们无法使用联合来执行此操作,因为struct opaque是不完整的类型。不幸的是,这意味着唯一的别名安全解决方案是:

  • 让我们的API中的每个方法都接受char*或typedef到char*而不是struct opaque*。这允许API接受两种指针类型,同时在过程中丢失所有类型安全性。更糟糕的是,在 API中的任何操作都需要memcpy将函数的参数放入本地struct opaque并退出本地alloca

这是相当可怕的。即使我们忽略严格的别名,在这种情况下为堆和堆栈对象维护相同API的唯一方法是第一项(不要这样做)。

关于无视标准的问题,还有另一件事:

  • malloc

这是一个坏词,但我不应该提及它。与char VLA不同,allocamalloc不同,{{1}}返回指向无类型空格的void指针。由于它与{{1}}具有大致相同的语义,因此它的使用并不需要上面列出的任何体操。堆栈和堆栈API可以愉快地并存,仅在对象(de)分配方面有所不同。但是alloca是非标准的,返回的对象与VLA的生命周期略有不同,并且它的使用几乎被普遍阻止。不幸的是,它非常适合这个问题。

据我所知,只有一个正确的解决方案(#4),只有一个清洁解决方案(#5),并没有好的解决方案。定义API其余部分的方式取决于您选择的那些。

答案 1 :(得分:1)

  

在某些情况下,从内存管理的角度来看,这没什么意义,特别是如果结构很小,并且分配和释放了很多。

我在这里看不到问题。在您的示例中,某人可能只会在其程序的生命周期中使用一个RND,或者至少使用少量{{p>}。

如果结构被分配并释放了很多,那么无论你的库是否进行所有分配和释放,或者它们的代码是否完成,它都没有性能差异。

如果要允许自动分配,则调用者必须知道结构的大小。没有办法解决这个问题。此外,这有点违背了隐藏实现的目的,因为这意味着您可以在不破坏客户端代码的情况下更改结构的大小。

此外,他们必须为你的结构分配正确对齐的内存(即由于对齐问题,他们不能只去char foo[SIZE_OF_RND]; RND *rng = (RND *)foo;)。您的RND_STACK_VAR示例忽略了此问题。

也许您可以发布一个实际尺寸的SIZE_OF_RND,以及一些对齐余量。然后你的"新"函数使用一些hacks在该内存中找到正确的对齐位置并返回一个指针。

如果它感觉像kludgey,那是因为它是。而且无论如何都没有什么能阻止它们在RND中写入字节。我会使用你对RND_new()等的第一个建议,除非有很强的理由说明它不合适。

答案 2 :(得分:1)

在基于C的API的设计中,没有将分配和初始化的默认功能捆绑在一起,随时可以使用 - 就像在C ++中一样。不提供它作为“获取”对象实例的默认方式使得使用未初始化的存储非常容易。如果坚持这个规则,就根本不需要暴露任何尺寸。

Atomic堆栈分配和初始化非常适合不需要销毁的类。对于此类对象,除了基于“默认”alloca的工厂函数之外,基于malloc的工厂函数是一个可行的选项。

使用需要破坏的类不太明显,因为使用alloca-allocated变量的“本能”不必释放它们。至少如果一个人坚持使用“工厂”API来进行对象构建和破坏,那么通过策略和代码检查确保破坏发生或者对象泄漏是相当容易的。 alloca - 对象的内存不会泄漏,但可能会忘记被破坏,其资源(包括额外的内存!)肯定会泄漏

假设我们有一个24位算术类型的接口,以C interfaces and Implementations的样式编写。

#ifndef INT24_INCLUDED
#define INT24_INCLUDED
#define T Int24_T
typedef struct T *T;
extern T Int24_new(void);
extern void Int24_free(T**);
extern void Int24_set_fromint(T, int);
extern void Int24_add(T a, T b);
extern int Int24_toint(T);
...
#undef T
#endif

Int24_new函数返回在堆上分配的新的24位整数,并且在释放它时不需要做任何破坏它的事情:

struct T {
  int val:24;
};    

T Int24_new(void) {
  T int24 = malloc(sizeof(struct T));
  int24->val = 0;
  return int24;
}

void Int24_free(T ** int24) {
  assert(int24);
  free(*int24);
  *int24 = NULL;
}

我们可以使用Int24_auto宏执行相同的操作,但会在堆栈上进行分配。我们不能在函数内调用alloca(),因为我们返回它的那一刻,它是一个悬空指针 - 从函数“释放”内存返回。在这样的对象上使用Int24_free将是一个错误。

#define Int24_auto() Int24_auto_impl(alloca(sizeof(struct T)))
T Int24_auto_impl(void * addr) {
  T int24 = addr;
  int24->val = 0;
  return int24;
}

使用很简单,没有任何破坏可以忘记,但API不一致:我们必须 free对象通过Int24_auto

void test(void) {
  Int24_T a = Int24_auto();
  Int24_T b = Int24_auto();
  Int24_set_fromint(a, 1);
  Int24_set_fromint(b, 2);
  Int24_add(a, b);
  assert(Int24_toint(a) == 3);
}

如果我们可以忍受开销,那么最好在实现中添加一个标志,让free方法破坏实例,而不必将其视为在堆上分配。

struct T {
  int val:24;
  int is_auto:1;
};    

T Int24_new(void) {
  T int24 = malloc(sizeof(struct T));
  int24->val = 0;
  int24->is_auto = 0;
  return int24;
}

#define Int24_auto() Int24_auto_impl(alloca(sizeof(struct T)))
T Int24_auto_impl(void * addr) {
  T int24 = addr;
  int24->val = 0;
  int24->is_auto = 1;
  return int24;
}

void Int24_free(T ** int24) {
  assert(int24);
  if (!(*int24)->is_auto) free(*int24);
  *int24 = NULL;
}

这使得堆和堆栈分配的用法保持一致:

void test(void) {
  Int24_T a = Int24_auto();
  ...
  Int24_free(&a);
  a = Int24_new();
  ...
  Int24_free(&a);
}

当然,我们可以使用返回opaque类型大小的API,并公开构造对象的initrelease方法,并分别对其进行破坏。这种方法的使用更加冗长,需要更多的关注。假设我们有一个数组类型:

#ifndef ARRAY_INCLUDED
#define ARRAY_INCLUDED
#define T Array_T
typedef struct T *T;
extern size_t Array_alloc_size(void);
extern void Array_init(T, int length, int size);
extern void Array_release(T);
...
#undef T
#endif

这样可以灵活地选择我们想要的分配器,代价是每个使用过的对象有1或2个额外的代码行。

void test(void) {
  Array_T a = alloca(Array_alloc_size());
  Array_init(a, 10, sizeof(int));
  ...
  Array_release(a);

  a = malloc(Array_alloc_size());
  Array_init(a, 5, sizeof(void*));
  ...
  Array_release(a);
  free(a);
}

我认为这样的API太容易出错,特别是它会使某些未来的实现细节变化相当麻烦。假设我们通过一次性分配所有存储来优化我们的阵列。这需要alloc_size方法采用与init相同的参数。当newauto工厂方法可以一次完成它并保持二进制兼容性时,这似乎是完全愚蠢的。

答案 3 :(得分:0)

第三种解决方案:将头文件拆分为公共和私有部分,并在公共部分声明结构,并在私有的,不可导出的部分中定义。

因此,您的库的外部用户将无法获得准确的实现,而您的库内部将使用通用定义而无需额外的努力。

答案 4 :(得分:0)

这是另一种方法。就像在您的第一个解决方案中一样,保持大小同步或者发生非常糟糕的事情非常重要。


的main.c

#include <stdio.h>
#include "somestruct.h"

int main( void )
{
    SomeStruct test;

    InitSomeStruct( &test );
    ShowSomeStruct( &test );
}

somestruct.h

#define SOME_STRUCT_SIZE ((sizeof(int) * 2 + sizeof(long double) - 1) / sizeof(long double))

typedef struct
{
    union
    {
        long double opaque[SOME_STRUCT_SIZE];

#ifdef _SOME_STRUCT_SOURCE_
        struct
        {
            int a;
            int b;
        };
#endif

    };
}
    SomeStruct;

void InitSomeStruct( SomeStruct *someStruct );
void ShowSomeStruct( SomeStruct *someStruct );

somestruct.c

#include <stdio.h>
#define _SOME_STRUCT_SOURCE_
#include "somestruct.h"

void InitSomeStruct( SomeStruct *someStruct )
{
    someStruct->a = 55;
    someStruct->b = 99;
}

void ShowSomeStruct( SomeStruct *someStruct )
{
    printf( "a=%d b=%d\n", someStruct->a, someStruct->b );
}