【问题标题】:C++ std features and Binary sizeC++ 标准特性和二进制大小
【发布时间】:2022-06-23 03:12:02
【问题描述】:

我最近在一次工作面试中被告知,他们的项目致力于为他们的应用程序构建最小大小的二进制文件(运行嵌入式),因此我将无法使用模板或智能指针等东西,因为这些会增加二进制文件的大小,他们通常似乎暗示使用 std 中的东西通常是不可行的(并非所有情况)。

面试后,我尝试在网上进行有关编码的研究,以及标准库中的哪些功能导致二进制文件变大,但我基本上找不到任何相关信息。有没有办法量化使用某些特性以及它们可能产生的大小影响(例如,无需在代码库中编写 100 个智能指针与自我管理)。

【问题讨论】:

  • 不是我的专业领域,但我想搜索c++ code bloat 会为您带来大量意见
  • 就此而言,无论是否使用 STL 或任何框架,您都可以编写臃肿的代码。即使你自己编写了每一段 [库] 代码,你仍然可以让它膨胀。对嵌入式应用程序使用 STL、模板或智能指针没有任何问题。事实上,我什至建议使用它们,因为那样你就不会自己开枪了。保持你的代码干燥,你应该很高兴。
  • 根据我的经验(在 1990 年代将 C++ 用于嵌入式应用程序),异常处理系统、运行时类型识别 (RTTI) 和动态内存 (new/delete, @ 987654324@/delete[]malloc/free,因为我们没有堆)被禁用。模板很好,但没有使用那么多。智能指针在那时还不是一个东西,但由于我们没有堆,所以它已经无关紧要了。我们没有使用 I/O Stream 工具,但它可能也会被禁止。
  • 对于某些功能,您可以尝试使用 Godbolt 或仅查看汇编器来衡量它。例如,对于模板,您必须根据具体情况判断使用它们会导致代码更小(间接性更少,函数调用更少)还是更大。
  • 对于其他内容,您必须查看链接器输出。当我做一个这样的项目时,我添加了一个功能,没有意识到它作为依赖项将 iostreams 拉入,并且仅标准库的那部分就比我的整个内存大

标签: c++ embedded


【解决方案1】:

这个问题可能比它可能得到的更多关注,尤其是对于那些试图从事嵌入式系统职业的人。到目前为止,讨论已经按照我的预期进行,特别是关于使用 C++ 构建的项目究竟如何以及何时可能比用纯 C 或受限 C++ 子集编写的项目更臃肿的细微差别的讨论。

这也是为什么您无法从老式的谷歌搜索中找到明确答案的原因。因为如果你只问“C++ 比 X 更臃肿吗?”,答案总是“视情况而定”。

所以让我从一个稍微不同的角度来处理这个问题。我都曾在实施此类限制的公司工作过,也曾在公司面试过,我什至自己也自愿实施了这些限制。它真的归结为这一点。当您管理一个工程组织时,计划继续招聘的不止一个人,假设您的团队中的每个人都将完全理解使用语言的每个功能的含义是非常不切实际的。编码标准和语言限制是防止人们在不知道自己在做“坏事”的情况下做“坏事”的一种廉价方法。

您如何定义“坏事”也因上下文而异。在桌面平台上,使用大量代码空间并不是严格执行的“坏”事情。在一个微型嵌入式系统上,它可能是。

C++ 的设计使工程师无需明确输入即可轻松生成大量代码。我认为这句话是不言而喻的,它是元编程的全部意义所在,我怀疑有人会挑战它,事实上这是该语言的优势之一。

那么回到组织方面的挑战,如果您的主要优化变量是代码空间,您可能不希望允许人们使用使生成不明显代码变得微不足道的功能。有些人会负责任地使用该功能,有些人不会,但您必须围绕最小公分母进行标准化。 C 编译器非常简单。是的,你可以用它编写臃肿的代码,但如果你这样做了,看它可能会很明显。

【讨论】:

  • 我理解迎合最小公分母背后的情绪,但我不同意。雇用更好的人和/或教育你拥有的人。
  • 好点。花在保姆语言功能上的时间始终是您可以花在开发产品上的时间。
【解决方案2】:

(部分摘自我之前写的cmets)

我认为没有一个全面的答案。很多也取决于具体的用例,需要根据具体情况来判断。

模板

模板可能会导致代码膨胀,是的,但它们也可以避免这种情况。如果您的替代方案是通过函数指针或虚方法引入间接,那么模板化函数本身的代码大小可能会变得更大,因为函数调用需要多条指令并消除优化潜力。

它们至少不会受到伤害的另一个方面是与类型擦除结合使用时。这里的想法是编写通用代码,然后在其周围放置一个小型模板包装器,该包装器仅提供类型安全性,但实际上并不发出任何新代码。 Qt 的 QList 就是一个在某种程度上做到这一点的例子。

这个简单的向量类型说明了我的意思:

class VectorBase
{
protected:
    void** start, *end, *capacity;

    void push_back(void*);
    void* at(std::size_t i);
    void clear(void (*cleanup_function)(void*));
};

template<class T>
class Vector: public VectorBase
{
public:
    void push_back(T* value)
    { this->VectorBase::push_back(value); }

    T* at(std::size_t i)
    { return static_cast<T*>(this->VectorBase::at(i)); }

    ~Vector()
    { clear(+[](void* object) { delete static_cast<T*>(object); }); }
};

通过小心地将尽可能多的代码移动到非模板化的基础中,模板本身可以专注于类型安全并提供必要的间接性,而不会发出任何本来不会出现在这里的代码。

(注意:这只是作为类型擦除的演示,并不是真正好的向量类型)

智能指针

如果仔细编写,它们不会生成很多本来就不存在的代码。内联函数是生成删除语句还是程序员手动生成并不重要。

我看到的主要问题是程序员更擅长推理代码和避免死代码。例如,即使在 unique_ptr 被移走之后,指针的析构函数仍然必须发出代码。程序员知道该值为 NULL,而编译器通常不知道。

另一个问题是调用约定。具有析构函数的对象通常在堆栈上传递,即使您声明它们是按值传递的。返回值也一样。因此,函数unique_ptr&lt;foo&gt; bar(unique_ptr&lt;foo&gt; baz) 的开销将比foo* bar(foo* baz) 更高,因为指针必须在堆栈中放入和取出。

更令人震惊的是,例如在 Linux 上使用的调用约定使调用者清理参数而不是被调用者。这意味着如果一个函数通过值接受一个复杂对象(如智能指针),则对该参数的析构函数的调用会在每个调用点复制,而不是将其放在函数内一次。尤其是unique_ptr,这太愚蠢了,因为函数本身可能知道对象已被移走,而析构函数是多余的;但调用者不知道这一点(除非您有 LTO)。

共享指针是完全不同的野兽,仅仅是因为它们允许许多不同的权衡。它们应该是原子的吗?他们应该允许类型转换,弱指针,什么间接用于破坏?每个共享指针真的需要两个原始指针,还是可以通过共享对象访问引用计数器?

例外,RTTI

通常通过编译器标志避免和删除。

库组件

在裸机系统上,拉入标准库的部分内容可能会产生重大影响,只有在链接器步骤之后才能衡量。我建议任何此类项目都使用持续集成并将代码大小作为衡量标准。

例如我曾经添加了一个小功能,我不记得是哪个,并且在它的错误处理中使用了std::stringstream。这拉入了整个 iostream 库。结果代码超出了我的整个 RAM 和 ROM 容量。 IIRC 的问题是,即使异常处理被停用,异常消息仍在设置中。

移动构造函数和析构函数

遗憾的是,C++ 的移动语义与例如 Rust 的移动语义不同,后者可以使用简单的 memcpy 移动对象,然后“忘记”它们的原始位置。在 C++ 中,移动对象的析构函数仍会被调用,这需要移动构造函数/移动赋值运算符和析构函数中的更多代码。

例如,Qt 在其meta type system 中说明了这种简单的情况。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2010-12-01
    • 2022-01-23
    • 2012-09-28
    • 1970-01-01
    • 2010-12-08
    • 1970-01-01
    • 2011-04-22
    • 1970-01-01
    相关资源
    最近更新 更多