从 static 到对象的内存布局

2016/9/8 17:07:08

最近对于在类中使用的 static 有几个疑问,逐一列举并给出解惑。

问题一

问题一:我们都知道 C++ 类的静态成员变量在使用前必须要初始化。可是为什么一定要初始化呢?如果不初始化,为什么报 ld 链接错误?

先强调一点,其中的使用包括在类的成员函数中对静态成员变量的访问。

如果对于类的内存模型稍微理解那么一点点,这个问题其实很简单。

  1. 类的定义是在 POD 结构体的基础上进行了的升级,定义类本质上是对其普通成员变量(不包括静态成员变量)的封装,就是 C 中普通的结构体。

  2. 对于其静态成员变量,可以理解为在此结构体外声明的 C 语言静态变量,并且在结构体与静态此变量之间建立了某种映射关系(绑定)。

  1. 对于其普通成员函数(非静态成员函数),可以理解为此结构体外声明的 C 语言普通函数,并且在结构体和此函数之间建立了某种映射关系(绑定),此函数只能通过此结构体(指此结构体类型的变量)访问(见补充1),且其访问结构体成员(及与之绑定的结构体外成员)时不用显式地指明结构体变量。

    补充1:为了表示其映射关系(从属关系),代码书写方式由 func(structVar, para1, para2) 的形式改进为 structVar.func(para1, para2) 的形式。

    我们会发现,上述普通函数必须传一个结构体类型的变量进去,这就是一个局限点。我们还需要一种没有此限制的函数,这就是静态成员函数的生态位了。

  2. 而中的静态成员函数,可以理解为结构体外的 C 语言普通函数,并且在函数和与结构体对应的变量之间建立了映射(绑定)关系,使得函数只能通过此结构体(包括结构体类型及此类型的变量)访问,通过变量访问其实还是转换成通过类型访问。因为通过类型访问所以并不需要变量,func(para1, para2) ,同时也因为并无实例化的变量,所以限制此种函数只能访问与结构体类型绑定的变量,而不能访问结构体成员。

备注:以上理解并未考虑 virtual 动态绑定等复杂概念。

所以 C++ 类中static 关键字与 C 中其原本的意义大相径庭;但 C++ 类外static 和 C 语言中 static 意义相同。

由此,我们就能理解类里面的静态成员变量只是个声明,并没有定义,没有分配内存,等同于 C 语言中只声明未定义的变量 extern int value,所以其使用方法也就与后者完全相同:

  1. 使用前必须先定义,分配内存。否则,
  2. 编译时不会报错,编译器发现有声明,假设其定义在了别处,但链接时如果还找不到就会报错;

问题二

问题二:既然静态成员变量在使用前都需要初始化,那 private 修饰的私有静态成员变量怎么初始化呢?

先说结果:同 public,两者的初始化方式完全一致。见 Initializing private static members

虽然我们可能困惑于其明明 private,可是为什么依然能够在类外访问定义?

When we declare a static member variable inside a class, we’re simply telling the class that a static member variable exists (much like a forward declaration). Because static member variables are not part of the individual class objects (they get initialized when the program starts), you must explicitly define the static member outside of the class, in the global scope.

In the example above, we do so via this line:

1
int Something::s_value = 1; // defines the static member variable

This line serves two purposes: it instantiates the static member variable (just like a global variable), and optionally initializes it. In this case, we’re providing the initialization value 1. If no initializer is provided, C++ initializes the value to 0.

Note that this static member definition is not subject(征服,控制) to access controls: you can define and initialize the value even if it’s declared as private (or protected) in the class. 引用来源

define/initialization 是允许的,但普通访问(modify/access)的确是不可以的。

Private members of a class can only be accessed inside the class member functions, the same rule applies even to static members. To be able to modify/access your static members you will have to add a member function to your class and then modify/access the static member inside it. 引用来源

问题三

问题三:静态类是不是不会执行初始化函数?

我知道这个问题很蠢,问题本身也有错(C++ 没有静态类的概念)。但还是放在了这里,毕竟问题不是重点,重点是由此引出来的理解以及探索过程中掌握的知识。

C# 中有静态类的概念;但 C++ 中没有。再具体说,就是 C++ 中不存在使用 static 修饰类的情况。C++ 中倒是可以将类的所有成员(变量和函数)全部用 static 修饰,但这种情况把类设计成 singleton(单例)模式更好一点。另外,也可以考虑使用 namespace,虽然在隐藏成员变量上有那么一点点区别。

C# 提供了静态构造函数,但 C++ 中没有这个概念。所以,在 C++ 中如果 static 成员的初始化比较复杂,步骤很多,甚至需要调用某个函数来完成,难么我们如何初始化它呢?

其实很多问题,前人都已经做了优美的解决方法,主动学习要好于闭门造车。所以Google一下,在stackoverflow高手们就给了一个更加接近于静态构造函数的方法:引用来源

To get the equivalent of a static constructor, you need to write a separate ordinary class to hold the static data and then make a static instance of that ordinary class.

(将需要使用static的数据用一个普通类来进行封装, 在该类的构造函数中进行所需的初始化步骤。然后在原来的类中定义一个该类的静态对象。)

对象的内存布局

上文中的对 C++ 对象的内存模型,是一种朴素的理解。

详细见 C++对象模型之简述C++对象的内存布局 及作者后续笔记 C++对象模型之详述C++对象的内存布局

另有 c++对象内存模型【内存布局】

当对于继承、virtual 虚函数有运用的基础之后,回头重新学习。

[图说C++对象模型:对象内存布局详解][cnblog]

陈皓专栏:C++ 对象的内存布局(上)C++ 对象的内存布局(下)

异议

一方面在互联网上我们可以找到大量的关于“对象在 C++ 中如何布局”的帖子,系统性的,可验证的。另一方面《EC++》 条款27:尽量少做转型动作(P119) 中写到“应该避免做出‘对象在 c++ 中如何布局’的假设”。摘抄如下:

1
2
3
4
class Base{...}; 
class Derived : public Base{...};
Derived d;
Base* pb = &d;//隐喻的将derived*转换成Base*

这里我们只是建立一个 base class 指针指向一个 derived class 对象,但有时候上述的两个指针值并不相同。这种情况下会有个偏移量在运行期被施行于 Derived* 指针身上,用于取得正确的 Base* 指针值。

上述例子表明,单一对象(例如一个类型为 Derived 的对象)可能拥有一个以上的地址(例如“以 Base* 指向它”时的地址和“以 Derived* 指向它”时的地址)。c,java,c# 不可能发生这种事,但 c++ 可能!实际上一旦使用多重继承,这事几乎一直发生着。即使是在单一继承中也可能发生。虽然这还有其他意涵,但至少意味着你通常应该避免做出“对象在 c++ 中如何布局”的假设,更不应该以此假设为基础执行任何转型动作。例如将对象地址转型成 char* 指针,然后在他们身上进行指针运算,几乎总是导致无意义(不明确)行为。

但请注意,我说的是有时候需要一个偏移量。对象的布局方式和他们的地址计算方式随编译器的不同而不同,那意味着“由于知道对象如何布局”而设计的转型,在某一平台行的通,在其他平台并不一定行得通。这个世界上有许多悲惨的程序员,他们历经千辛万苦才学到这堂课。