深度解读《深度探索C++对象模型》之数据成员的存取效率分析(二)

2024-04-21 11:49:27 浏览数 (1)

接下来的几篇将会讲解非静态数据成员的存取分析,讲解静态数据成员的情况请见上一篇:《深度解读《深度探索C 对象模型》之数据成员的存取效率分析(一)》。

普通数据成员的访问方式

接下来的几节讨论的都是非静态数据成员的情况,非静态数据成员都是存放在对象中的,类的定义中相同名称的数据成员在每个对象中都是相互独立存在的。访问非静态数据成员必须通过隐式的或者显示的类对象来访问,否则将没有权限访问。如通过显示的方式访问:

代码语言:cpp复制
class Object {
public:
	int x;
	int y;
};
void foo(const Object& obj) {
    int a = obj.x   obj.y;
}

或者通过隐式的方式访问:

代码语言:cpp复制
class Object {
public:
	void print();
private:
	int x;
	int y;
};
void Object::print() {
    printf("x=%d, y=%dn", x, y);
}

print函数中可以直接访问数据成员xy,其实它是通过一个隐式的对象来访问的,这个隐式的对象编译器会把它插入到参数中,真实的函数声明会被编译器转换为下面的方式:

代码语言:cpp复制
void Object::print(Ojbect* const this) {
    printf("x=%d, y=%dn", this->x, this->y);
}

普通数据成员在对象中的偏移值

在《深度解读《深度探索C 对象模型》之C 对象的内存布局》一文中知道了对象的非静态成员的布局,由此也可以知道访问非静态数据成员是通过对象的首地址(基地址)加上非静态数据成员的偏移值得到的地址。C 标准规定,对象中的成员排列顺序必须按照类中声明的数据成员的顺序,声明在前面的将排在前面,但没有规定不同的访问权限层级(public, protected, private)哪个在前,哪个在后。这个由编译器的实现者自己决定,只要保证在同一层级中先声明的排在前面即可。如果在一个类中有声明了多个的层级,如出现多个public和多个private层级,是否将多个相同的层级合并在一起也并没有强制规定,在我的测试的编译器中,是不区分不同的层级的,是根据类中的声明顺序来排列,不管将它声明在哪个层,或者分布在不同的层级中,统统按照声明的顺序来排列。

数据成员的偏移值可以通过静态的分析方法来得到,也可以通过动态的方法来获取,如下面的程序中,我们将每个非静态数据成员的偏移值打印出来:

代码语言:cpp复制
#include <cstdio>

class Base {
public:
    void print() {
        printf("&Base::a1 = %dn", &Base::a1);
        printf("&Base::b1 = %dn", &Base::b1);
        printf("&Base::c1 = %dn", &Base::c1);
        printf("&Base::a2 = %dn", &Base::a2);
        printf("&Base::b2 = %dn", &Base::b2);
        printf("&Base::c2 = %dn", &Base::c2);
    }
public:
    int a1;
    static int s1;
protected:
    int b1;
    static int s2;
private:
    int c1;
    static int s3;
private:
    char a2;
    static int s4;
protected:
    char b2;
    static int s5;
public:
    char c2;
    static int s6;
};

int main() {
    Base b;
    b.print();
    return 0;
}

程序输出结果:

代码语言:plaintext复制
&Base::a1 = 0
&Base::b1 = 4
&Base::c1 = 8
&Base::a2 = 12
&Base::b2 = 13
&Base::c2 = 14

从中可以看出:

  • 静态数据成员不影响非静态数据成员的偏移值,因为他们不存储在对象中,它们也没有偏移值,获取到的只有具体的内存地址值。
  • 类中的非静态数据成员的排列是按照它们的声明顺序来的,跟声明在哪个层级没有关系,相同的层级中的成员也不会合并在一起。

通过&Base::a1这种方式得到的是成员在对象中的偏移值,而通过&b.a1这种方式得到的将是它的具体的内存地址值,这个内存地址也可以通过偏移值得到,即对象b的地址&b &Base::a1

存取普通数据成员在编译器中的实现

独立的类即是不继承其它任何类的类,现在来分析一下独立类的非静态数据成员存取方法及效率,通过对象来存取数据成员和通过指针来存取数据成员有没有效率上的差别?从上面的分析我们已经知道,非静态数据成员在类中的声明顺序决定了它在类中的偏移值,通过偏移值可以计算出它的内存地址,所以对象的非静态数据成员在编译期间就可以获得它的内存地址,这样就相当于跟访问一个普通的局部变量一样,不需要通过在运行期间接地去计算它的内存地址,从而导致运行时的效率损失。那如果是通过指针来访问又如何呢?下面通过一个例子,生成对应的汇编代码来分析一下,假设有一个表示三维坐标的类,类中包含有三个坐标值x,y,z

代码语言:cpp复制
class Point {
public:
    int x;
    int y;
    int z;
};

void bar(Point* pp) {
    pp->x = 4;
    pp->y = 5;
    pp->z = 6;
}
int main() {
    Point p;
    p.x = 1;
    p.y = 2;
    p.z = 3;
    bar(&p);

    return 0;
}

生成对应的汇编代码:

代码语言:plaintext复制
bar(Point*):                   # @bar(Point*)
    push    rbp
    mov     rbp, rsp
    mov     qword ptr [rbp - 8], rdi
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax], 4
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax   4], 5
    mov     rax, qword ptr [rbp - 8]
    mov     dword ptr [rax   8], 6
    pop     rbp
    ret
main:                          # @main
    push    rbp
    mov     rbp, rsp
    sub     rsp, 16
    mov     dword ptr [rbp - 4], 0
    mov     dword ptr [rbp - 16], 1
    mov     dword ptr [rbp - 12], 2
    mov     dword ptr [rbp - 8], 3
    lea     rdi, [rbp - 16]
    call    bar(Point*)
    xor     eax, eax
    add     rsp, 16
    pop     rbp
    ret

从汇编代码中可以看到,在main函数中,汇编代码的第18到第20行就是对应上面C 代码的第15到第17行,[rbp - 16]存放的是局部变量Point p的地址,也是成员x的地址,因为成员x是排在最前面,偏移值为0,也就是跟对象p的地址是一样的。成员y的偏移值是4,所以基地址加上4即[rbp - 12],以此类推,成员z的地址是[rbp - 8],可见成员变量的地址在编译期间就已确定了的。然后在第21行代码将对象p的地址存放在rdi寄存器中,将它作为调用bar函数的参数,传递给bar函数,第22行即调用bar函数。

然后看下通过指针的方式来访问数据成员是怎样的?在bar函数的汇编代码中,将传递过来的参数rdi寄存器(存放着对象p的地址)的值先存放在栈空间中的[rbp - 8]位置,然后再加载到rax寄存器(第4、5行),之后的第6到第10行是分别给数据成员赋值,可以看到通过指针存取数据成员也是通过偏移值来算出成员的具体地址的,地址在编译期间就已确定,所以跟通过对象来存取是一样的,所以两者的效率是一样的,不存在差别。

0 人点赞