在 C 中创建 "classes",在堆栈上还是在堆上?

Creating "classes" in C, on the stack vs the heap?

每当我看到 C "class"(任何旨在通过访问将指向它的指针作为第一个参数的函数使用的结构)时,我看到它们是这样实现的:

typedef struct
{
    int member_a;
    float member_b;
} CClass;

CClass* CClass_create();
void CClass_destroy(CClass *self);
void CClass_someFunction(CClass *self, ...);
...

在这种情况下 CClass_create 总是 malloc 它是内存,returns 是指向它的指针。

每当我看到 new 不必要地出现在 C++ 中时,它通常似乎让 C++ 程序员发疯,但这种做法在 C 中似乎可以接受。是什么原因造成的?为什么堆分配结构 "classes" 如此常见背后有什么原因吗?

你的问题是"why in C it's normal to allocate memory dynamically and in C++ it's not"吗?

C++ 有很多使 new 变得多余的结构。 复制、移动和普通构造函数、析构函数、标准库、分配器。

但是在 C 中你无法绕过它。

一般来说,您看到 * 并不意味着它已 malloc。例如,您可以获得指向 static 全局变量的指针;在你的情况下,确实,CClass_destroy() 不接受任何参数,假设它已经知道有关被销毁对象的一些信息。

此外,指针,无论是否malloc,都是允许您修改对象的唯一方法。

我没有看到使用堆而不是堆栈的特殊原因:您不会使用更少的内存。但是,初始化这样的 "classes" 需要的是 init/destroy 函数,因为底层数据结构实际上可能需要包含动态数据,因此需要使用指针。

假设,如您的问题,CClass_createCClass_destroy 使用 malloc/free,那么对我来说,以下是不好的做法:

void Myfunc()
{
  CClass* myinstance = CClass_create();
  ...

  CClass_destroy(myinstance);
}

因为我们可以轻松避免 malloc 和 free:

void Myfunc()
{
  CClass myinstance;        // no malloc needed here, myinstance is on the stack
  CClass_Initialize(&myinstance);
  ...

  CClass_Uninitialize(&myinstance);
                            // no free needed here because myinstance is on the stack
}

CClass* CClass_create()
{
   CClass *self= malloc(sizeof(CClass));
   CClass_Initialize(self);
   return self;
}

void CClass_destroy(CClass *self);
{
   CClass_Uninitialize(self);
   free(self);
}

void CClass_Initialize(CClass *self)
{
   // initialize stuff
   ...
}

void CClass_Uninitialize(CClass *self);
{
   // uninitialize stuff
   ...
}

在 C++ 中,我们也宁愿这样做:

void Myfunc()
{
  CClass myinstance;
  ...

}

比这个:

void Myfunc()
{
  CClass* myinstance = new CCLass;
  ...

  delete myinstance;
}

为了避免不必要的new/delete.

C 缺少某些 C++ 程序员认为理所当然的东西,即

  1. public 和私有说明符
  2. 构造函数和析构函数

这种方法的最大优点是您可以将结构隐藏在 C 文件中,并使用创建和销毁函数强制执行正确的构造和销毁。

如果您在 .h 文件中公开该结构,这将意味着用户可以直接访问成员,这会破坏封装。也不强制创建允许错误构造您的对象。

在 C 语言中,当某些组件提供 "create" 函数时,组件实现者也可以控制组件的初始化方式。所以它不仅 模拟 C++' operator new 而且 class 构造函数。

放弃对初始化的这种控制意味着对输入进行更多的错误检查,因此保持控制可以更容易地提供一致且可预测的行为。

我也反对 malloc 总是 被用来分配内存。情况可能经常如此,但并非总是如此。例如,在某些嵌入式系统中,您会发现根本没有使用 malloc/freeX_create 函数可以用其他方式分配,例如来自一个大小在编译时固定的数组。

因为一个函数只能 return 一个堆栈分配的结构,如果它不包含指向其他分配的结构的指针。 如果它只包含简单的对象(int、bool、floats、chars 和它们的数组,但 没有指针 ),你可以在堆栈上分配它。但是你必须知道,如果你 return 它,它会被复制。如果你想允许指向其他结构的指针,或者想避免复制,那么使用堆。

但是如果您可以在 顶级单元 中创建结构并且只在被调用的函数中使用它而不是 return 它,那么堆栈是合适的

我会将 "constructor" 更改为 void CClass_create(CClass*);

它不会 return 结构的 instance/reference,但会在一个结构上调用。

至于是在"stack"上分配还是动态分配,完全看你的使用场景需求。无论您如何分配它,您只需调用 CClass_create() 将分配的结构作为参数传递。

{
    CClass stk;
    CClass_create(&stk);

    CClass *dyn = malloc(sizeof(CClass));
    CClass_create(dyn);

    CClass_destroy(&stk); // the local object lifetime ends here, dyn lives on
}

// and later, assuming you kept track of dyn
CClass_destroy(dyn); // destructed
free(dyn); // deleted

注意不要 return 对局部(分配在堆栈上)的引用,因为那是 UB。

无论您如何分配它,您都需要在正确的位置(即该对象生命周期的末尾)调用 void CClass_destroy(CClass*);,如果是动态分配的,还需要释放该内存。

区分allocation/deallocation和construction/destruction,它们不一样(即使在C++中它们可能会自动耦合在一起)

这有几个原因。

  1. 使用 "opaque" 指针
  2. 缺少析构函数
  3. 嵌入式系统(栈溢出问题)
  4. 容器
  5. 惯性
  6. "Laziness"

让我们简单地讨论一下。

对于不透明指针,它使您能够执行如下操作:

struct CClass_;
typedef struct CClass_ CClass;
// the rest as in your example

因此,用户看不到 struct CClass_ 的定义,将她与对它的更改隔离开来,并启用其他有趣的东西,例如针对不同平台以不同方式实现 class。

当然,这里禁止使用CClass的栈变量。但是,OTOH,可以看到这并不禁止静态分配 CClass 对象(从某个池中)- return 由 CClass_createCClass_create_static 等其他函数编辑。

缺少析构函数 - 因为 C 编译器不会自动析构你的 CClass 堆栈对象,你需要自己做(手动调用析构函数)。所以,剩下的唯一好处就是堆栈分配通常比堆分配快。 OTOH,你不必使用堆——你可以从池、竞技场或类似的东西分配,这可能几乎和堆栈分配一样快,没有下面讨论的堆栈分配的潜在问题。

嵌入式系统 - 堆栈不是 "infinite" 资源,你知道的。当然,对于当今 "Regular" 操作系统(POSIX、Windows...)上的大多数应用程序,它几乎是。但是,在嵌入式系统上,堆栈可能低至几 KB。这是极端的,但即使 "big" 嵌入式系统也有以 MB 为单位的堆栈。因此,如果过度使用,它将 运行 淘汰。当它发生时,大多数情况下无法保证会发生什么 - AFAIK,在 C 和 C++ 中都是 "Undefined behaviour"。 OTOH,当你内存不足时,CClass_create() 可以 return NULL 指针,你可以处理它。

Containers - C++ 用户喜欢堆栈分配,但是,如果您在堆栈上创建一个 std::vector,其内容将被分配到堆上。当然,你可以调整它,但这是默认行为,它让人们的生活更容易说 "all members of a container are heap-allocated" 而不是试图弄清楚如果他们不是的话如何处理。

Inertia - 嗯,面向对象来自 SmallTalk。那里的一切都是动态的,因此,"natural" 到 C 的翻译是 "put everything on the heap" 方式。所以,第一个例子就是这样,他们激励了其他人很多年。

"懒惰" - 如果你知道你只想要堆栈对象,你需要这样的东西:

CClass CClass_make();
void CClass_deinit(CClass *me);

但是,如果你想同时允许堆栈和堆,你需要添加:

CClass *CClass_create();
void CClass_destroy(CClass *me);

实施者需要做更多的工作,但也会让用户感到困惑。一个人可以制作略有不同的界面,但这不会改变您需要两套功能的事实。

当然,"containers"原因也是部分"laziness"原因。

这产生了很多答案,因为它有点 基于意见。我仍然想解释为什么我个人更喜欢将 "C Objects" 分配在堆上。原因是让我的字段全部隐藏(说:private)以防止使用代码。这称为 不透明指针 。实际上,这意味着您的头文件没有定义正在使用的 struct ,它只是声明了它。直接后果是,使用代码无法知道 struct 的大小,因此堆栈分配变得不可能。

好处是:使用代码 永远 取决于 struct 的定义,这意味着您不可能以某种方式呈现 struct 与外部 不一致,当 struct 发生变化时,您可以避免不必要的重新编译使用代码。

by declaring fields to be private. But the definition of your class is still imported in all compilation units that use it, making it necessary to recompile them, even when only your private members change. The solution often used in 中解决的第一个问题是 pimpl 模式:让所有私有成员在第二个 struct(或:class)中仅被定义在实现文件中。当然,这需要你的pimpl分配在堆上。

添加:现代 OOP 语言(例如 or )有分配对象的方法(并且通常决定它是内部堆栈还是堆)而无需调用知道它们定义的代码。

如果需要同时存在的某种类型对象的最大数量是固定的,系统将需要能够对每个 "live" 实例做一些事情,而有问题的项目不会太费钱了,最好的办法一般不是堆分配,也不是栈分配,而是静态分配数组,加上"create"和"destroy"方法。使用数组将避免维护对象链表的需要,并且可以处理对象不能立即销毁的情况,因为它是 "busy" [例如如果数据通过中断或 DMA 到达通道,当用户代码决定它不再对通道感兴趣并处理它时,用户代码可以设置 "dispose when done" 标志和 return 而不必担心具有不再分配给它的挂起中断或 DMA 覆盖存储。

使用固定大小的对象池使得分配和取消分配比从混合大小的堆中获取存储更容易预测。在需求可变并且对象占用大量 space(单独或共同)的情况下,这种方法不是很好,但是当需求基本一致时(例如,应用程序始终需要 12 个对象,有时最多需要 3 个)它比其他方法效果更好。一个弱点是任何设置都必须在声明静态缓冲区的地方执行,或者必须由客户端中的可执行代码执行。无法在客户端站点使用变量初始化语法。

顺便说一句,使用这种方法时,不需要让客户端代码接收指向任何内容的指针。相反,可以使用任何方便的整数大小来标识资源。此外,如果资源的数量永远不必超过 int 中的位数,让一些状态变量对每个资源使用一位可能会有所帮助。例如,可以有变量 timer_notifications(仅通过中断处理程序编写)和 timer_acks(仅通过主线代码编写)并指定 (timer_notifications ^ timer_acks) 的位 N 将在定时器 N 需要时设置服务。使用这种方法,代码只需要读取两个变量来确定任何定时器是否需要服务,而不是必须为每个定时器读取一个变量。

你这么频繁地看到它有点奇怪。您一定一直在寻找一些 "lazy" 代码。

在 C 中,您描述的技术通常保留给 "opaque" 库类型,即其定义对客户端代码有意隐藏的结构类型。由于客户端无法声明此类对象,因此该习语必须真正用于 "hidden" 库代码中的动态分配。

当不需要隐藏结构的定义时,典型的 C 习语通常如下所示

typedef struct CClass
{
    int member_a;
    float member_b;
} CClass;

CClass* CClass_init(CClass* cclass);
void CClass_release(CClass* cclass);

函数 CClass_init 初始化 *cclass 对象和 returns 相同的指针作为结果。 IE。为对象分配内存的负担放在调用者身上,调用者可以以任何它认为合适的方式分配它

CClass cclass;
CClass_init(&cclass);
...
CClass_release(&cclass);

这个习语的一个经典例子是 pthread_mutex_tpthread_mutex_initpthread_mutex_destroy

同时,将前一种技术用于非不透明类型(如在您的原始代码中)通常是一种有问题的做法。在 C++ 中随意使用动态内存是完全值得怀疑的。它有效,但同样,在不需要时使用动态内存在 C 中和在 C++ 中一样不受欢迎。

它实际上是对 C++ 的强烈反对,使 "new" 太容易了。

从理论上讲,在 C 中使用此 class 构造模式与在 C++ 中使用 "new" 相同,因此应该没有区别。但是,人们倾向于思考语言的方式不同,因此人们对代码的反应方式也不同。

在 C 中,考虑计算机为实现您的目标必须执行的确切操作是很常见的。这不是普遍的,但它是一种非常普遍的心态。假定您已花时间对 malloc/free.

进行 cost/benefit 分析

在 C++ 中,编写对您有很大帮助的代码行变得更加容易,您甚至都没有意识到。很常见的是有人写了一行代码,甚至没有意识到它恰好调用了 100 或 200 new/deletes!这引起了强烈反对,C++ 开发人员会狂热地挑剔新闻和删除,因为担心他们会被意外地到处调用。

这些当然是概括性的。整个 C 和 C++ 社区绝不适合这些模型。但是,如果您对使用 new 而不是将东西放在堆上感到不满,这可能是根本原因。