C++对象之内存(无继承)

从内存的角度考虑,不同情况下的C++类有什么区别呢?在空类、具有不同变量/函数、具有静态变量、继承、多态、虚拟继承等情况下,C++对象的内存空间大小和内存布局是什么样的呢?本文讨论没有继承的情况,下一篇讨论有继承的情况
如无特别说明,本文代码均在64位机器上的VS2019运行。

无继承

一、内存布局

大多数情况下,非静态成员变量在类中的声明顺序就是它们在内存中的排列顺序。但是成员之间可能会被插入一些字节,用于调整实例大小、存放某些指针等用途。下面深入看一下各种情况。
概念介绍:access section包括public, private和protected三种段落,如果一个类的定义中有2个public和1个private,那么它就有3个access sections。

(一)同一access section内

C++ Standard要求,在同一个access section内,只要较晚出现的成员在类对象中有较高的地址即可。因此成员在内存中未必是连续的,中间可能会被填充一些字节(上面提到的padding);也可能会有编译器合成的一些内部使用的成员(data member),以支持整个对象模型(比如指向虚函数表或虚拟继承中指向父类的指针)。
代码测试如下:

class Point {
public:
    char x;
    char y;
    char z;
};
//main函数中:
printf("&Point::x = %p\n", &Point::x);//00000000
printf("&Point::y = %p\n", &Point::y);//00000001
printf("&Point::z = %p\n", &Point::z);//00000002

这里用的&Point::x是表示x在Point对象中的相对位置,即偏移量(offset),而&x是表示x的内存地址。这里输出成员变量的地址也可以,但输出偏移量会更直观一些。另外,注意这里要用printf,不要用cout。cout的输出都是1,无论位置和变量。
通过代码测试发现,x, y, z确实是按照声明顺序在内存中排列的。那么如果还有其他access section呢?

(二)有多个access sections时

C++ Standard允许编译器将多个access sections中的成员自由排列而不必在乎它们在类中的声明顺序。但目前大部分编译器都是讲一个以上的access sections连锁在一起,依照声明顺序成为一个连续区块。而且access sections的数量不会带来额外负担,在3个public中声明3个int和在1个public中声明3个int,得到的对象大小是一样的。
代码测试如下:

class Point {
public:
    char x;
    char y;
private:
    char a;
public:
    char z;
    static void where_is_a() {
	printf("&Point::a = %p\n", &Point::a);
    }
};
//main函数中:
printf("&Point::x = %p\n", &Point::x);//00000000
printf("&Point::y = %p\n", &Point::y);//00000001
printf("&Point::z = %p\n", &Point::z);//00000003
Point::where_is_a();                  //00000002

可见编译器是按照各个成员的声明顺序将它们排列在了一起。

二、内存空间占用情况

(一)空类

class Test {

};

输出sizeof(Test)得到的结果为1。
空类的大小并不是0。这是因为空类也可以被实例化,而且它的每个实例也和其他实例一样在内存中拥有独一无二的地址。因此编译器会给空类加一个字节,这样在实例化时就可以给它的实例分配内存地址了。

(二)有函数的类

class Test {
public:
    //一般需要定义构造函数、复制构造函数和重载操作符时,类是有成员变量的,
    //但这里仅为测试类的函数的内存占用情况,因此不设成员变量,且让函数为空
    Test() {} //constructor
    Test(const Test& t) {} //copy constructor
    void func(){ //inline function
        cout<<"hey hacker\n";
    }
    void func2(); //non-inline function
    Test& operator =(const Test& t){} //operator
    ~Test() {} //destructor
};

void Test::func2() {
    cout << "haha\n";
}

输出sizeof(Test)得到的结果为1,说明成员函数并不占用对象的内存空间。我们再用另一种方法证明这一点。下面这段代码会打印Point类的不同实例的radius函数的地址:

class Point {
public:
    void radius() { }
    void print_address() {
	printf_s("%p\n", &Point::radius);
    }
};
//main函数中:
Point a, b, c;
cout << "a: ";
a.print_address();
cout << "b: ";
b.print_address();
cout << "c: ";
c.print_address();

输出结果如下:
a: 00381695
b: 00381695
c: 00381695
可见不同实例调用的函数地址都是相同的。
我们测试了各种各样的非静态函数,最终输出结果表明它们都不占用对象的内存空间。事实也确实如此,C++对象模型中,函数不占用对象的内存。调用函数只需要知道函数的地址即可,而函数与类的实例无关。
其实将函数封装并不需要额外的成本。每个non-inline函数只会产生一个实例,而每个“只产生零个或一个定义”的inline函数只在它的使用者(模块)处产生一个实例。C++在布局和存取时间上主要的额外负担是由virtual引起的。(下一篇会讨论有virtual的情况)

(三)有成员变量的类

绝大多数现代C/C++编译器会将类的大小调整为8字节的整数倍,这被称为边界调整,被填充的字节叫做padding。之所以要进行边界调整,是因为CPU在对8字节或16字节的数据块寻址时效率更高。可以通过编译选项禁用边界调整。但是,通过代码测试发现,当类中只有char类型的成员变量时,编译器并不会进行边界调整。
代码如下:

class Test {
public:
    char c1;
    char c2;
};
//main函数中:
cout << sizeof(Test); //2 = 2*1(char)

输出为2,说明实例没有被填充其他字节。
而当成员变量既有char类型又有其他类型时,就会进行边界调整了。代码测试如下:

class Test {
public:
    int a;
    char c;
};
//main函数中:
cout << sizeof(Test); //8 = 4(int) + 1(char) + 3(padding)

输出为8,说明填充了3字节。

(四)有静态成员变量的类

class Test {
public:
    static int a;
    static void func() {}
};
//main函数中:
cout << sizeof(Test);

输出为1。这是因为静态成员变量只与类有关而与实例无关;C++对象模型中,静态成员不占用对象的内存空间

posted @ 2019-11-24 15:11  Irene_f  阅读(263)  评论(0编辑  收藏  举报