C++静态成员变量及其初始化

51
在C++类中,静态成员变量的初始化是在类外部完成的。我想知道为什么?这是否有任何逻辑上的限制或要求呢?还是说这只是一种传统的实现方式 - 而标准并不想进行更正?
我认为,在类内进行初始化更具“直观性”和少些混淆。这也给人以变量既是静态的又是全局的感觉。例如,如果你查看静态常量成员。
5个回答

40

基本上这是因为静态成员必须在一个翻译单元中定义,以避免违反C++的One-Definition Rule规则。如果语言允许如下代码:

struct Gizmo
{
  static string name = "Foo";
};

然后 name 会在每个包含该头文件的翻译单元中被定义。

C++确实允许您在声明中定义整数静态成员,但仍然需要在一个翻译单元中包含定义,但这只是一种快捷方式或语法糖。所以,这是允许的:

struct Gizmo
{
  static const int count = 42;
};

只要a)表达式是“const”整数或枚举类型,b)表达式可以在编译时评估,并且c)仍然有某个定义不违反单一定义规则:

文件:gizmo.cpp

#include "gizmo.h"

const int Gizmo::count;

一个定义规则是:“任何翻译单元都不得包含任何变量、函数、类类型、枚举类型或模板的多个定义”。如果您的第一个 Gizmo 示例是合法的,我认为它不会违反一个定义规则,因为每个翻译单元具有 Gizmo::name 的单个定义。 - Daniel Trebbien
@Daniel Trebbien:这并不是完整的ODR。这只是3.2/1 - ODR的第一层大致覆盖(以处理最明显的违规行为)。完整的ODR对每种实体都有更详细的要求。对于具有外部链接的对象(以及外部链接的函数),ODR在3.2/3中进一步限制为整个程序仅允许一个定义 - AnT stands with Russia
1
@Daniel Trebbien:将3.2/1的要求与其余内容分开的原因是违反3.2/1的要求需要编译器进行诊断,而对于违反3.2/3的要求则不需要。 - AnT stands with Russia
因为不准确和夸张的说法而被踩。 - Cheers and hth. - Alf

13

自C++诞生以来,初始化器的存在一直是对象定义的专属属性,也就是说,带有初始化器的声明始终是一个定义(几乎总是如此)。

正如你所知,在C++程序中使用的每个外部对象都必须在一个翻译单元中定义一次且仅一次。允许为静态对象使用类内初始化器将立即违反此约定:初始化器将进入头文件(通常存放类定义的位置),从而生成同一静态对象的多个定义(对于包含头文件的每个翻译单元都会生成一个)。这当然是不可接受的。因此,对于静态类成员,声明方法保持完全“传统”:只需在头文件中声明它(即不允许初始化器),然后在您选择的翻译单元中定义它(可能带有初始化器)。

唯一的例外是针对整数或枚举类型的const静态类成员,因为这样的条目可以用于整数常量表达式(ICE)。 ICE的主要思想是它们在编译时进行评估,因此不依赖于涉及的对象的定义。这就是为什么对于整数或枚举类型可以做出此异常处理,但对于其他类型而言,这将违反C++的基本声明/定义原则。


3

这是由于代码编译的方式导致的。如果你在类中初始化它(通常在头文件中),每次包含头文件时都会得到静态变量的一个实例。这绝对不是意图。将其在类外初始化,可以让你有可能在cpp文件中初始化它。


7
这是现代编译器/链接器组合可以轻松解决的问题,不足以成为如此笨拙限制的充分理由。 - martona
我猜只有现代的C++链接器才能解决方法(成员函数)的多重定义问题。(即,我上一次尝试多次定义一个方法是几年前了,链接失败了。)在那之前,所有在头文件中定义的方法都需要是内联或静态的,而后者会导致链接文件中出现多个副本。 - Mike DeSimone
@Daniel: "为什么不使用静态成员变量" 因为编译器无法确定将定义放在哪个翻译单元中。 - John Dibling
@Daniel:在成员函数多次定义的情况下,这并不是一个问题,因为这些成员函数会有多个定义。虽然每个翻译单元仍然只有一个定义,但每个翻译单元使用的是不同的定义。static的要求是所有翻译单元都使用一个定义。 - John Dibling
@kumar:是的,这是一种可能性。定义仍然必须放在某个地方,并且整个程序只能有一个副本,否则它就不会是“静态”的。因此,如果您不告诉编译器将定义放在哪个TU中,它就不知道该把它放在哪里。它可能会尝试将其放在每个TU中(这将违反ODR),或者只是输出错误消息,说您需要告诉它在哪里定义它(这实际上就是它所做的)。 - John Dibling
显示剩余2条评论

2

C++标准的第9.4.2节“静态数据成员”规定:

如果一个static数据成员是const整数或枚举类型,它在类定义中的声明可以指定一个const-initializer,该initializer必须是一个整数常量表达式。

因此,静态数据成员的值可以被包含在“类内部”(我想你的意思是在类的声明内部)。然而,静态数据成员的类型必须是一个const整数或枚举类型。其他类型的静态数据成员的值不能在类声明中指定,因为可能需要进行非平凡的初始化(即需要运行构造函数)。

请想象以下情况是否合法:

// my_class.hpp
#include <string>

class my_class
{
public:
  static std::string str = "static std::string";
//...

每个对应包含此头文件的CPP文件的对象文件不仅会有一个存储my_class::str的空间副本(由sizeof(std::string)字节组成),还会有一个“ctor section”,调用接受C字符串的std::string构造函数。每个存储my_class::str的空间副本将由一个公共标签标识,因此链接器理论上可以将所有存储空间的副本合并为一个。但是,链接器将无法隔离对象文件的ctor部分中的所有构造函数代码的所有副本。这就像要求链接器在以下编译中删除初始化str的所有代码一样:
std::map<std::string, std::string> map;
std::vector<int> vec;
std::string str = "test";
int c = 99;
my_class mc;
std::string str2 = "test2";

编辑 查看以下代码的g++汇编输出是很有启示性的:

// SO4547660.cpp
#include <string>

class my_class
{
public:
    static std::string str;
};

std::string my_class::str = "static std::string";

可以通过执行以下命令获取汇编代码:

g++ -S SO4547660.cpp

浏览通过g++生成的SO4547660.s文件,您会发现对于如此小的源文件,有很多代码。 __ZN8my_class3strE是存储空间my_class::str的标签。还有一个名为__Z41__static_initialization_and_destruction_0ii的汇编源代码__static_initialization_and_destruction_0(int, int)函数。该函数对于g++来说是特殊的,但请知道g++将确保在执行任何非初始化程序代码之前调用它。请注意,该函数的实现调用__ZNSsC1EPKcRKSaIcE。这是std::basic_string<char, std::char_traits<char>, std::allocator<char> >::basic_string(char const*, std::allocator<char> const&)的符号名称。
回到上面的假设示例并使用这些细节,与包括my_class.hpp的每个CPP文件对应的对象文件都将具有标签__ZN8my_class3strE,大小为sizeof(std::string)字节,并且包含汇编代码以调用__ZNSsC1EPKcRKSaIcE在其实现中的__static_initialization_and_destruction_0(int, int)函数。链接器可以轻松合并__ZN8my_class3strE的所有出现,但它不可能隔离调用__ZNSsC1EPKcRKSaIcE的代码在对象文件实现的__static_initialization_and_destruction_0(int, int)函数中。

为什么以下代码是不被允许的:class my_class { public: static const double pi = 3.14; }; - John Dibling
@John:我认为应该允许这样做,原因与可以在声明中指定const整数或const枚举类型的静态数据成员的值相同。我不知道为什么它不被允许。 - Daniel Trebbien
这让我想到,“非平凡”的初始化可能不是不允许非整数类型的唯一原因。 - John Dibling
@John:我认为我知道为什么不支持const doubleconst float。如果支持这些类型,那么C++编译器必须能够评估“浮点常量表达式”。例如,static const int i = 44 << 6 ^ 0x63ab9900;是允许的,因此编译器必须能够评估常量整数表达式。如果也允许static const float f = 24.382f * -999.283f,那么C++编译器就必须有计算浮点运算的函数。这可能被C++委员会视为不必要的复杂性。 - Daniel Trebbien

0

我认为将初始化操作放在class块之外的主要原因是允许使用其他类成员函数的返回值进行初始化。如果你想用b::some_static_fn()初始化a::var,你需要确保每个包含a.h.cpp文件先包含b.h。这样会很麻烦,特别是当你(早晚)遇到只能通过一个不必要的interface来解决的循环引用问题时。同样的问题也是将类成员函数实现放在.cpp文件中而不是把所有代码都放在主类的.h中的主要原因。

至少对于成员函数,你可以选择在头文件中实现它们。但对于变量,你必须在.cpp文件中进行初始化。我不太同意这种限制,也不认为有什么好理由这么做。


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