C++标准特性和二进制大小



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

采访结束后,我试图在网上研究编码,以及标准库中的哪些功能导致了大的二进制大小,但我基本上找不到任何关于这方面的信息。有没有一种方法可以量化使用某些功能及其对大小的影响(例如,与自我管理相比,不需要在代码库中编码100个智能指针)。

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

这也是为什么你不能从一个好的老式谷歌搜索中找到一个明确的答案。因为如果你只是问"C++比X更臃肿吗?",答案总是"取决于情况">

所以让我从一个稍微不同的角度来看待这个问题。我都曾为实施这些限制的公司工作过,也曾在这些公司面试过,我甚至自己也自愿实施了这些限制。归根结底就是这个。当你经营一个工程组织,有不止一个人计划继续招聘时,假设团队中的每个人都会完全理解使用语言的每一个功能的含义是不切实际的。编码标准和语言限制是一种廉价的方式,可以防止人们在不知道自己在做"坏事"的情况下做出"坏事"。

你如何定义一件"坏事"也是特定于上下文的。在桌面平台上,使用大量代码空间并不是一件"糟糕"到足以严格执行的事情。在一个微小的嵌入式系统上,它可能是。

C++的设计使工程师非常容易生成大量代码,而不必显式地键入它。我认为这句话是不言自明的,这就是元编程的全部意义,我怀疑有人会质疑它,事实上这是该语言的优势之一。

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

(部分摘自我之前写的评论)

我认为没有一个全面的答案。这在很大程度上也取决于具体的用例,需要根据具体情况进行判断。

模板

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

它们至少不会造成伤害的另一个方面是与类型擦除一起使用。这里的想法是编写通用代码,然后在它周围放置一个小的模板包装器,它只提供类型安全性,但实际上不会发出任何新代码。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); }); }
};

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

(注意:这只是一个类型擦除的演示,而不是一个真正好的矢量类型)

智能指针

如果仔细编写,它们不会生成太多无论如何都不会存在的代码。内联函数是生成delete语句,还是程序员手动生成,其实并不重要。

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

另一个问题是调用约定。具有析构函数的对象通常在堆栈上传递,即使您声明它们是按值传递的。返回值也是如此。因此,函数unique_ptr<foo> bar(unique_ptr<foo> baz)将比foo* bar(foo* baz)具有更高的开销,这仅仅是因为指针必须在堆栈上和堆栈外放置。

更可怕的是,例如在Linux上使用的调用约定使调用者而不是被调用者清理参数。这意味着,如果函数按值接受一个复杂对象(如智能指针),则对该参数的析构函数的调用将在每个调用站点复制,而不是在函数中放置一次。特别是对于unique_ptr,这是非常愚蠢的,因为函数本身可能知道对象已经被移走,析构函数是多余的;但打电话的人不知道这一点(除非你有LTO)。

共享指针是完全不同的野兽,因为它们允许很多不同的权衡。它们应该是原子的吗?它们是否应该允许类型转换、弱指针、使用什么间接方法进行销毁?您真的需要每个共享指针两个原始指针吗?还是可以通过共享对象访问引用计数器?

例外情况,RTTI

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

库组件

在裸金属系统上,引入标准库的部分可能具有显著的影响,只有在连接步骤之后才能测量。我建议任何这样的项目都使用持续集成,并将跟踪代码大小作为度量。

例如,我曾经添加了一个小特性,我不记得是哪个,在它的错误处理中,它使用了std::stringstream。这把整个iostream库都拉了进来。生成的代码超出了我的全部RAM和ROM容量。IIRC的问题是,即使异常处理被停用,异常消息仍在设置中。

移动构造函数和析构函数

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

例如,Qt在其元类型系统中解释了这种简单的情况。

最新更新