为什么在C ++中必须在类外部分别定义静态数据成员(与Java不同)?


41
class A {
  static int foo () {} // ok
  static int x; // <--- needed to be defined separately in .cpp file
};

我看不到需要A::x在.cpp文件(或用于模板的同一文件)中分别定义。为什么不能同时A::x声明和定义?

是否出于历史原因被禁止使用?

我的主要问题是,如果static同时声明/定义数据成员(与Java相同)会影响任何功能吗?


作为最佳实践,通常最好将静态变量包装在静态方法中(可能作为局部静态方法),以避免初始化顺序问题。
陶Szelei

2
实际上,此规则在C ++ 11中有所放松。const静态成员通常不必再定义了。请参阅:en.wikipedia.org/wiki/...
MIRK

4
@afishwhoswimsaround:在所有情况下对通用规则进行指定不是一个好主意(最佳实践应与上下文一起应用)。在这里,您正在尝试解决不存在的问题。初始化顺序问题仅影响具有构造函数并访问其他静态存储持续时间对象的对象。由于'x'是int,因此第一个不适用于,因为'x'是私有的,因此第二个不适用于。第三,这与问题无关。
马丁·约克

1
属于堆栈溢出吗?
与莫妮卡(Monica)进行的轻度比赛

2
C ++ 17允许静态数据成员(即使对于非整数类型)的内联初始化:inline static int x[] = {1, 2, 3};。参见en.cppreference.com/w/cpp/language/static#Static_data_members
Vladimir Reshetnikov,

Answers:


15

我认为您所考虑的限制与语义无关(如果初始化是在同一文件中定义的,为什么要更改某些内容?)而是与C ++编译模型有关,由于向后兼容性,该模型无法轻易更改,因为它会要么变得太复杂(同时支持新的编译模型和现有的编译模型),要么不允许编译现有的代码(通过引入新的编译模型并删除现有的编译模型)。

C ++编译模型源于C的编译模型,在C中,您可以通过包含(头)文件将声明导入到源文件中。这样,编译器可以递归地看到一个大的源文件,其中包含所有包含的文件以及这些文件中包含的所有文件。这具有IMO的一大优势,即它使编译器更易于实现。当然,您可以在包含的文件中编写任何内容,即声明和定义。将声明放在头文件中并将定义放在.c或.cpp文件中只是一个好习惯。

另一方面,可能有一个编译模型,在该模型中,编译器非常了解是要导入在另一个模块中定义的全局符号的声明,还是正在编译由以下模块提供的全局符号的定义当前模块。仅在后一种情况下,编译器必须将此符号(例如,变量)放入当前目标文件中。

例如,在GNU Pascal中,您可以a在如下文件中写入一个单位a.pas

unit a;

interface

var MyStaticVariable: Integer;

implementation

begin
  MyStaticVariable := 0
end.

在同一源文件中声明和初始化全局变量的位置。

然后,您可以具有导入a并使用全局变量的不同单位 MyStaticVariable,例如单位b(b.pas):

unit b;

interface

uses a;

procedure PrintB;

implementation

procedure PrintB;
begin
  Inc(MyStaticVariable);
  WriteLn(MyStaticVariable)
end;
end.

和单位c(c.pas):

unit c;

interface

uses a;

procedure PrintC;

implementation

procedure PrintC;
begin
  Inc(MyStaticVariable);
  WriteLn(MyStaticVariable)
end;
end.

最后,您可以在主程序中使用单位b和c m.pas

program M;

uses b, c;

begin
  PrintB;
  PrintC;
  PrintB
end.

您可以分别编译以下文件:

$ gpc -c a.pas
$ gpc -c b.pas
$ gpc -c c.pas
$ gpc -c m.pas

然后使用以下命令生成可执行文件:

$ gpc -o m m.o a.o b.o c.o

并运行它:

$ ./m
1
2
3

这里的窍门是,当编译器在程序模块中看到一个uses指令(例如,在b.pas中使用a)时,它不包括相应的.pas文件,而是寻找一个.gpi文件,即预编译的文件。接口文件(请参阅文档)。这些.gpi文件由编译器与.o每个模块一起编译时生成。因此,全局符号MyStaticVariable仅在目标文件中定义一次a.o

Java以类似的方式工作:当编译器将类A导入类B时,它会在类文件中查找A而不需要该文件A.java。因此,可以将A类的所有定义和初始化都放在一个源文件中。

回到C ++,为什么在C ++中必须在单独的文件中定义静态数据成员的原因,与C ++编译模型的关系更多,而不是与链接器或编译器使用的其他工具施加的限制有关。在C ++中,导入一些符号意味着将其声明构建为当前编译单元的一部分。除其他外,这非常重要,因为模板的编译方式。但这意味着您不能/不应该在包含的文件中定义任何全局符号(函数,变量,方法,静态数据成员),否则这些符号可以在编译的目标文件中被多重定义。


42

由于静态成员在一个类的所有实例之间共享,因此必须在一个且仅一个地方定义它们。实际上,它们是具有一些访问限制的全局变量。

如果尝试在标头中定义它们,则将在包含该标头的每个模块中对其进行定义,并且在链接过程中会发现所有重复的定义,因此会出现错误。

是的,这至少部分是来自cfront的历史问题;可以编写一个编译器,该编译器将创建一种隐藏的“ static_members_of_everything.cpp”并链接到该编译器。但是,这将破坏向后兼容性,并且这样做没有任何真正的好处。


2
我的问题不是当前行为的原因,而是这种语言语法的合理性。换句话说,假设如果static变量是在同一位置(例如Java)声明/定义的,那会出什么问题?
iammilind

8
@iammilind我想由于这个答案的说明,您不理解语法是必需的。现在为什么呢?由于C(和C ++)的编译模型:c和cpp文件是真实的代码文件,像单独的程序一样分别进行编译,然后将它们链接在一起以形成完整的可执行文件。标头不是真正的编译器代码,它们只是要复制和粘贴在c和cpp文件中的文本。现在,如果多次定义某些内容,它将无法编译,如果您有多个具有相同名称的局部变量,则将无法编译。
Klaim

1
@Klaim,那么static成员template呢?所有头文件中都允许使用它们,因为它们需要可见。我没有反对这个答案,但是也与我的问题不符。
iammilind

@iammilind模板不是真正的代码,它们是生成代码的代码。模板的每个实例在编译器提供的每个静态声明中都只有一个静态实例。如上所述,您仍然必须定义实例,但是在定义实例的模板时,它不是真实的代码。从字面上看,模板是供编译器生成代码的代码模板。
Klaim

2
@iammilind:模板通常在每个目标文件中实例化,包括它们的静态变量。在带有ELF目标文件的Linux上,编译器将实例标记为 符号,这意味着链接程序将同一实例的多个副本组合在一起。可以使用相同的技术在头文件中定义静态变量,因此未完成的原因可能是历史原因和编译性能考虑的结合。一旦下一个C ++标准合并了模块,整个编译模型将有望得到修复。
2012年

6

可能的原因是,这使C ++语言在目标文件和链接模型不支持合并多个目标文件中的多个定义的环境中仍可实现。

一个类声明(有充分理由被称为声明)被拉入多个翻译单元。如果声明包含静态变量的定义,那么您将在多个转换单元中得到多个定义(请记住,这些名称具有外部链接。)

这种情况是可能的,但是需要链接程序处理多个定义而不会抱怨。

(并且请注意,这与“一个定义规则”冲突,除非可以根据符号的种类或放置在其中的节的类型来完成)。


6

C ++和Java之间有很大的区别。

Java在自己的虚拟机上运行,​​该虚拟机将所有内容创建到自己的运行时环境中。如果一个定义碰巧不止一次被看到,它将仅作用于运行时环境最终知道的同一对象。

在C ++中,没有“最终知识所有者”:C ++,C,Fortran Pascal等都是从源代码(CPP文件)到中间格式(OBJ文件或“ .o”文件)的“翻译器”,具体取决于操作系统),其中语句被转换为机器指令,名称变成由符号表介导的间接地址。

一个程序不是由编译器创建的,而是由另一个程序(“链接器”)创建的,该程序通过将指向符号的所有地址,指向它们的所有地址重新链接在一起(无论它们来自何语言)有效定义。

通过链接程序的工作方式,定义(为变量创建物理空间的定义)必须是唯一的。

请注意,C ++本身并不链接,并且链接器不是由C ++规范发布的:由于存在构建OS模块的方式(通常在C和ASM中),链接器存在。C ++必须按原样使用它。

现在:头文件是要“粘贴”到几个CPP文件中的东西。每个CPP文件都独立于其他文件进行翻译。编译器翻译不同的CPP文件(全部接受相同的定义)将在所有生成的OBJ中放置已定义对象的“ 创建代码 ”。

编译器不知道(也永远不会知道)所有这些OBJ是否会一起用于形成单个程序,还是分别用于形成不同的独立程序。

链接器不知道定义的方式和原因以及它们的来源(它甚至不了解C ++:每种“静态语言”都可以生成要链接的定义和引用)。它只知道在给定的结果地址处有对“定义的”给定“符号”的引用。

如果给定符号有多个定义(不要将定义与引用混淆),则链接程序不知道(与语言无关)如何处理它们。

这就像将多个城市合并成一个大城镇一样:如果您发现有两个“ 时代广场 ”,并且有很多人从外面来询问要进入“ 时代广场 ”,那么您不能仅凭技术来做出决定(没有有关分配这些名称的政治知识,并将负责管理这些名称)在何处发送。


3
就全局符号而言,Java和C ++之间的差异与具有虚拟机的Java无关,而与C ++编译模型有关。在这方面,我不会将Pascal和C ++放在同一类别中。相反,我将C和C ++组合为“包含导入的声明并与主源文件一起编译的语言”,而将Java和Pascal(以及OCaml,Scala,Ada等)归为“其中的语言”。编译器会在包含有关导出符号信息的预编译文件中查找导入的声明。”
乔治

1
@Giorgio:可能不欢迎引用Java,但我认为Emilio的答案基本上是正确的,因为要了解问题的实质,即在单独编译后的目标文件/链接器阶段。
ixache

5

这是必需的,因为否则编译器将不知道将变量放在何处。每个cpp文件都是单独编译的,彼此不知道。链接器解析变量,函数等。我个人不知道vtable和静态成员之间有什么区别(我们不必选择vtable定义在哪个文件中)。

我主要假设编译器作者更容易以这种方式实现它。存在类/结构之外的静态变量,可能是出于一致性原因,或者是因为对于编译器编写者来说“更易于实现”,他们在标准中定义了该限制。


2

我想我找到了原因。static在单独的空间中定义变量可以将其初始化为任何值。如果未初始化,则默认为0。

在C ++ 11之前,C ++中不允许进行类内初始化。所以不能这样写:

struct X
{
  static int i = 4;
};

因此,现在要初始化变量,必须将其写为类外:

struct X
{
  static int i;
};
int X::i = 4;

正如在其他答案中所讨论的那样,int X::i现在是全局文件,在许多文件中声明全局文件会导致多个符号链接错误。

因此,必须static在一个单独的翻译单元中声明一个类变量。但是,仍然可以争论的是,以下方式应指示编译器不要创建多个符号

static int X::i = 4;
^^^^^^

0

A :: x只是一个全局变量,但命名空间为A,并且具有访问限制。

像任何其他全局变量一样,仍然有人必须声明它,甚至可以在与包含其余A代码的项目静态链接的项目中进行声明。

我认为这些都是不好的设计,但是您可以通过这种方式利用一些功能:

  1. 构造函数调用顺序...对于int而言并不重要,但是对于可能访问其他静态或全局变量的更复杂的成员而言,这可能至关重要。

  2. 静态初始化程序-您可以让客户端决定应将A :: x初始化为什么。

  3. 在c ++和c语言中,由于您可以通过指针完全访问内存,因此变量的物理位置很重要。根据变量在链接对象中的位置,您可以利用很多顽皮的东西。

我怀疑这些是“为什么”出现这种情况。它可能只是C演变为C ++的结果,并且是向后兼容的问题,这使您现在不必更改语言。


2
在先前的6个答案中,这似乎并没有提供实质性的要点和解释
gnat
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.