在C语言中创建“类”,使用栈还是堆?

48

每当我看到 C 语言中的“类”(指任何结构体,通过访问以它为第一个参数的指针作为参数的函数来使用)时,我会看到它们是这样实现的:

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 分配内存并返回指针。

每当我看到 C++ 中不必要地出现 new 时,它通常似乎会让 C++ 程序员发疯,但这种做法在 C 中是可接受的。为什么堆分配的结构体“类”如此常见?


8
有趣的是,我倾向于在堆栈上创建大部分的对象,包括结构体。 - EOF
7
可能的原因之一是能够隐藏接口(即前向声明结构体)。然而,我个人更喜欢避免不必要的动态分配 - 最近,当我在编写一个 C 接口到脚本语言时,我必须决定使用堆还是栈分配“上下文”对象,并选择了自动分配,因为没有真正需要动态分配,也没有尝试隐藏其实现的真正好处。 - The Paramagnetic Croissant
5
好的,你必须使用这个设计。不是必需的,只是缺少void CClass_initialize(CClass* self)使它更通用。 - Hans Passant
1
@TheParamagneticCroissant 你说得对,记录下什么是私有的应该就足够了(只要你不担心消费代码的不必要重新编译)。这只是不透明指针将设置障碍,使意外操作变得更加困难(即:指针转换和其他代码异味)。编辑:毕竟,这仍然是一个设计决策 - user2371524
4
请注意,这个问题实际上是关于创建对象,而不是类。这种方式称呼对象是一种常见的简写方式,但在基础讨论中经常会引起困惑。 - Pete Becker
显示剩余4条评论
12个回答

51

这其中有几个原因:

  1. 使用“不透明”指针
  2. 缺乏析构函数
  3. 嵌入式系统(堆栈溢出问题)
  4. 容器
  5. 惯性
  6. “懒惰”

让我们简要地讨论它们。

对于不透明指针,它使您可以执行类似以下的操作:

struct CClass_;
typedef struct CClass_ CClass;
// the rest as in your example
因此,用户看不到struct CClass_的定义,使其与它的更改隔离开来,并启用其他有趣的功能,例如为不同平台实现类的不同方式。
当然,这禁止使用CClass的堆叠变量。但是,另一方面,人们可以看到这并不禁止静态分配CClass对象(从某个池返回)-由CClass_create或者像CClass_create_static这样的另一个函数返回。 缺乏析构函数 - 由于C编译器不会自动销毁您的CClass堆栈对象,因此您需要自己进行处理(手动调用析构函数)。因此,唯一剩下的好处是堆栈分配通常比堆分配更快。另一方面,您不必使用堆 - 您可以从池、竞技场或类似的地方进行分配,这可能几乎与堆栈分配一样快,而且没有下面讨论的堆栈分配的潜在问题。 嵌入式系统 - 堆栈不是“无限”的资源,您知道。当然,在今天的“常规”操作系统(POSIX、Windows等)上大多数应用程序几乎是如此。但是,在嵌入式系统上,堆栈可能只有几 KB。这很极端,但即使是“大型”嵌入式系统的堆栈也在MB范围内。因此,如果过度使用,它将耗尽。当它这样做时,大多数情况下没有保证会发生什么 - 就我所知,在C和C++中都是“未定义行为”。另一方面,当内存不足时,CClass_create()可以返回空指针,您可以处理它。 容器 - C++用户喜欢堆栈分配,但是,如果您在堆栈上创建一个std::vector,它的内容将被分配到堆上。当然,您可以进行调整,但这是默认行为,说“容器的所有成员都是堆分配的”会使人们更容易处理,而不是试图弄清楚如何处理空间不足的情况。 惯性 - 嗯,OO来自SmallTalk。那里一切都是动态的,所以,“自然”的翻译成C的方式是“把所有东西都放在堆上”。因此,最初的示例就是这样的,并在许多年后启发了其他人。
"懒惰" - 如果您知道自己只想要堆栈对象,则需要类似于:
CClass CClass_make();
void CClass_deinit(CClass *me);

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

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

对于实施者来说,这意味着更多的工作,但对用户来说也很困惑。虽然可以创建略有不同的接口,但这并不能改变你需要两组函数的事实。

当然,“容器”原因部分上也是“懒惰”的原因。


3
完整性加一,这里没有看到任何遗漏。要了解不透明指针的好处,请参阅我的回答。 - user2371524
这并不一定很令人困惑,因为您可以非常严格地为您编写的所有“类”提供相同的接口:使用上述术语,“create”执行“malloc”和“make”,而“destroy”执行“deinit”和“free”。但是确实,这需要额外的一点样板代码,直到需要证明才会有人费心去做。 - Steve Jessop
你的 make/deinit 范式并不一定强制你将对象保留在堆栈上 - 它只是意味着调用者负责分配/释放内存,无论是堆栈还是堆。你可以这样做:CClass *p = malloc(...); *p = CClass_make(); ...; CClass_deinit(p); free(p); - Nate Eldredge

14

假设您的问题中,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

9

在C语言中,当某个组件提供了“create”函数时,组件实现者也控制组件如何初始化。因此,它不仅模拟了C++的operator new,还模拟了类构造函数。

放弃这种对初始化的控制意味着需要更多的输入错误检查,因此保持控制使得提供一致和可预测的行为更加容易。

我也不同意malloc总是被用来分配内存。尽管这经常是情况,但并非总是如此。例如,在某些嵌入式系统中,根本不使用malloc/free。而X_create函数可以使用其他方式进行分配,例如从在编译时大小固定的数组中分配。


8
这个问题的答案因为有些基于个人观点而各不相同。但我想解释一下,为什么我个人更喜欢在堆上分配我的“C对象”。原因是让我的字段全部隐藏(即私有)不被消费代码所使用。这被称为“不透明指针”。实际上,这意味着您的头文件不定义正在使用的结构体,它只声明它。直接的结果是,消费代码无法知道结构体的大小,因此无法进行堆栈分配。
好处是:消费代码永远不能依赖于结构体的定义,这意味着你无法从外部使结构体的内容不一致,并且当结构体发生变化时,你避免了消费代码的不必要重新编译。
第一个问题通过将字段声明为private中得以解决。但是您的class的定义仍会被所有使用它的编译单元导入,这使得即使只有private成员更改,也需要重新编译它们。中常用的解决方法是pimpl模式:在实现文件中仅定义所有私有成员的第二个struct(或:class)。当然,这需要在堆上分配您的pimpl

此外,现代面向对象编程语言(例如 )有一些方法来分配对象(通常会内部决定是堆栈还是堆),而不需要调用代码知道它们的定义。


1
一个历史上有时有用的技术是使用一个“公共”结构类型,其中包含一个适当大小的int[]和一个内部使用的结构类型,其中包含实际数据。客户端代码仍然需要知道结构的大小,但内部细节将被屏蔽。然而,我不知道有什么方法可以在不需要标准规定的行为的情况下实现这一点,而且今天的一些编译器试图“优化掉”标准没有要求保留的任何内容。 - supercat
我曾经看到的使用该方法的代码使用int [],因为在C99和Strict Aliasing规则出现之前的1990年代存在对齐问题。然而,除非客户端代码实际上将数组用作访问底层结构的手段,否则我认为结构体中数组的类型并不重要。此外,如果构建系统能够识别到在不同的翻译单元中声明了不同的结构,则根据C标准它就没有任何义务;如果它不能识别到,那么它可能无法... - supercat
@supercat 嗯,至少这很“容易打破”......如果它真的有时使用过,我想那是在 malloc 的开销比现在更加重要的日子里。 - user2371524
同时查看客户端和实现代码,以便注意任何别名违规。我有一种感觉,负责C语言方向的许多人在1980年代和1990年代不是程序员。当时,获得良好的性能意味着应用程序需要使用应用程序编程和系统编程方法的混合体,并且严重依赖于标准未强制执行但仍然广泛支持的行为。不幸的是,推动语言标准的人似乎认为代码... - supercat
那些依赖于任何半现代机器上的每个体面编译器都能正常工作的行为,但并非标准所规定的,应被视为“有缺陷”,无论标准是否定义了任何严格符合要求的方式来满足相同的行为需求。 - supercat
显示剩余4条评论

3
一般来说,看到*并不意味着它已经被malloc分配了内存。你可能得到的是指向static全局变量的指针;在你的情况下,确实,CClass_destroy()不需要任何参数,这表明它已经知道要销毁的对象的某些信息。
此外,指针(无论是否使用了malloc)是唯一允许修改对象的方式。
我不认为使用堆而不是栈有特别的原因:你不能减少使用的内存量。但是,需要初始化/销毁函数来初始化这样的“类”,因为底层数据结构可能实际上需要包含动态数据,因此使用指针。

3
我会将 "constructor" 更改为 void CClass_create(CClass*)
它不会返回结构体的实例/引用,而是在其中调用一个。关于它是否在 "堆栈" 上分配还是动态分配,完全取决于您的使用场景要求。无论如何分配,只需将分配的结构体作为参数调用 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

请注意不要返回指向本地变量(分配在堆栈上)的引用,因为那是未定义行为。

无论你如何分配内存,都需要在正确的时机调用void CClass_destroy(CClass*);(即该对象生命周期的结束),如果是动态分配的,则还需要释放该内存。

区分分配/释放和构造/析构,它们并不相同(即使在C++中它们可能会自动耦合在一起)。


2

C语言缺少C++程序员所熟知的一些东西,即:

  1. 公共和私有修饰符
  2. 构造函数和析构函数

这种方法的主要优点是可以在C文件中隐藏结构体,并通过创建和销毁函数强制正确的构造和析构。

如果在.h文件中暴露结构体,则意味着用户可以直接访问成员,这会破坏封装性。同时,不强制创建会导致对象的错误构造。


2
因为一个函数只能返回一个栈分配的结构体,如果它不包含指向其他分配的结构体的指针。如果它只包含简单对象(int、bool、float、char和它们的数组,但没有指针),则可以在堆栈上分配它。但是,您必须知道,如果您返回它,它将被复制。如果您想允许指向其他结构体的指针,或者想避免复制,则使用堆栈。
但是,如果您可以在一个顶级单元中创建这个结构体,并且仅在调用函数中使用它,而且从不返回它,则堆栈是适当的。

即使结构体包含指针也没问题,只要这些指针指向使用malloc分配的内存。 - Giorgi Moniava

2
如果某种类型的对象需要同时存在的最大数量是固定的,系统将需要能够处理每个“活”实例,并且相关项目不会消耗太多资金,那么通常最好的方法既不是堆分配也不是栈分配,而是静态分配数组,以及“创建”和“销毁”方法。使用数组将避免维护对象的链表,并使处理无法立即销毁对象的情况变得可能,因为它很“忙”[例如,如果数据通过中断或DMA从通道到达时,用户代码决定不再对通道感兴趣并处置它,则用户代码可以设置一个“完成时处置”标志并返回,而不必担心有待处理的中断或DMA覆盖不再分配给它的存储空间]。
使用固定大小的对象池使分配和释放比从混合大小堆中获取存储空间更可预测。该方法在需求量变化大且对象占用大量空间(单独或集体)的情况下不是很好,但当需求基本保持一致时(例如应用程序始终需要12个对象,并且有时需要多达3个),它比其他方法更好。唯一的弱点是任何设置都必须在静态缓冲区声明的地方执行,或者必须由客户端的可执行代码执行。无法在客户端站点使用变量初始化语法。
顺便说一下,使用这种方法时,没有必要让客户端代码接收指针。相反,可以使用方便的整数大小来标识资源。此外,如果资源的数量永远不会超过int中的位数,则有些状态变量使用每个资源的一位可能会有所帮助。例如,一个人可以拥有变量timer_notifications(仅通过中断处理程序编写)和timer_acks(仅通过主线代码编写),并指定(timer_notifications ^ timer_acks)的第N位将在计时器N需要服务时设置。使用这种方法,代码只需要读取两个变量即可确定任何计时器是否需要服务,而不必为每个计时器读取一个变量。

1

你的问题是:“为什么在C中动态分配内存很常见,而在C++中不是?”

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

但在C中,你无法绕过动态分配内存。


2
在C++中你可以使用内存做的所有事情,在C语言中同样也可以做到。正如其他答案所指出的,这些函数没有要求一定由malloc/free支持,假设它们是这样的想法是一个大错误。它们可能很容易地使用和栈分配一样快速的东西。 - Alex Celeste

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