提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
咱们都知道C++语言在创建类的时候data member(数据成员)
和fuchtion member(函数成员)
,在访问权限上有3个access sections
分别是private
、protected
和public
,我们都知道声明为private的成员只能在类内部被使用,但是这是一定的吗?其实这个策略上有一个漏洞
,要了解这个漏洞你要理解C++的对象模型
,这篇文章只是给你演示怎么理解对象模型,绝不是教你学坏
,这种方法在实际开发中不可取!
一、C++对象模型
C++对象模型是C++语言中描述如何创建、存储和操作对象的一套规则和结构。在C++中,对象是类的实例,而类定义了对象的属性(数据成员)和行为(成员函数)。C++的对象模型主要关注以下几个方面:
布局:每个对象在内存中都有一个特定的布局,这包括数据成员的顺序和对齐方式,以及虚函数表(如果有的话)的指针。C++标准并不精确规定对象的内存布局,但通常数据成员按照声明的顺序存放,同时考虑到处理器的对齐要求。
构造与析构:对象的生命周期始于构造函数的调用,结束于析构函数的执行。构造函数负责初始化对象的状态,而析构函数则负责清理对象占用的资源。在C++中,可以定义自己的构造函数和析构函数,也可以使用默认提供的。
继承与虚函数:C++支持继承,允许子类扩展或重写基类的行为。当一个类继承自另一个类时,子类对象会包含基类对象的完整状态。虚函数机制允许在派生类中重写基类的函数,实现多态性。
多态性:通过虚函数和抽象基类,C++支持运行时多态,即通过基类指针或引用来调用派生类的方法。这需要使用虚函数表(vtable)机制,每个含有虚函数的类都会有一个虚函数表,对象中包含指向这个表的指针。
内存管理:对象可以存储在不同的存储区中,包括栈、堆和静态存储区。栈上的对象在函数调用结束时自动销毁,堆上的对象需要手动管理其生命周期,而静态对象在程序整个运行期间都存在。
访问控制:C++中的类成员可以被声明为
public
、protected
或private
,分别控制了类成员的可访问性。这有助于封装和数据隐藏,是面向对象编程的重要原则之一。运算符重载:C++允许对基本运算符进行重载,使其适用于用户定义的类型。这可以使得自定义类型的对象像内置类型一样使用标准的运算符。
类型转换:C++支持多种类型转换,包括隐式转换、显式转换(通过
static_cast
、dynamic_cast
、const_cast
和reinterpret_cast
)以及用户定义的转换。
C++对象模型的复杂性在于它允许程序员在低级和高级抽象之间自由切换,从而能够编写高效且灵活的代码。然而,这也要求程序员对底层细节有深入的理解,以避免常见的陷阱和错误。
C++对象模型本身是非常复杂的概念,我们今天不去完全讲开,只是描述其中一个概念。
二、演示
下面的代码展示了一个类层次结构,所有的数据成员都是private的,理论上你是不能在类外部访问的,甚至派生类也不能直接访问。
1.类层次
C3.h
//
// Created by anold on 2024-07-22.
//
#ifndef CLASS_C3_H
#define CLASS_C3_H
#include <iostream>
class C1 {
private:
int val = 200;
char c1 = '1';
};
class C2 : public C1 {
private:
char c2 = '2';
};
class C3 : public C2 {
private:
char c3 = '3';
};
#endif //CLASS_C3_H
想像一下子这个C3类在内存里是怎么样存储的?想象一下C1、C2和C3对象各占多少字节的内存空间?
下面的代码会为你揭晓答案,这个地方我有必要贴出我的系统和编译器的信息,因为它确实在不同的环境下会展现出不一样的结果,但是今天我们只在我的环境下讨论。
OS:Windows 11 64bits
G++:GNU 13.1.0
GCC:GNU 13.1.0
IDE:CLion
#include <iostream>
#include "C3.h"
int main() {
C1 c1{};
C2 c2{};
C3 c3{};
std::cout << sizeof(c1) << std::endl;
std::cout << sizeof(c2) << std::endl;
std::cout << sizeof(c3) << std::endl;
return 0;
}
执行得到的3个对象都占8字节。
首先从C1看,C1作为基类没有虚函数,所以实际大小因该是int的4字节
+char的1字节
,但是由于要对齐
,所以补上3字节
共8字节。
C2也占8字节,那是为什么呢?那是因为C2=C1的subobject+char+对齐=int的4字节
+char的1字节(C1)
+char的1字节(C2)
+补齐的2字节
共8字节。
C3继承了C2,C3也占8字节那是为什么呢?那是因为C3=C2的subobject+int的4字节
+char的1字节(C1)
+char的1字节(C2)
+char的1字节(C3)
+补齐的1字节
共8字节。
这下就清楚了吧,当然这种应该是最简单的对象模型,还有复杂一些的,比如带虚函数的,因为编译器会生成虚指针指向虚表,虚指针是编译器生成的插入对象的,本身也占空间。还有更复杂的多继承+虚继承内对象型更复杂,以后有机会再说,现在说说怎么绕过private。
2.内存排列
至少在我的环境下,我的这个代码中内存排列是确定的,请看下图:
所以类数据成员的排列是int+char+char+char
,在C++的类对象模型中,数据成员的内存地址就是类的内存地址+偏移量(offset)。在这个范例里int的偏移量是0
,也就是C3的地址
;由于int在64位系统中占4字节,所以char的偏移量
分别是4,5,6个字节。
虽然我们在IDE或编译器里面尝试从外部读取private修饰的变量会报错,但是有一种借助偏移量的方法可以间接
读到,绕过这种安全机制。请看下面的代码:
auto val = std::addressof(c3);
auto ch_1 = reinterpret_cast<long long>(std::addressof(c3)) + 4;
auto ch_2 = reinterpret_cast<long long>(std::addressof(c3)) + 5;
auto ch_3 = reinterpret_cast<long long>(std::addressof(c3)) + 6;
auto ch_1_c3 = reinterpret_cast<C3 *>(ch_1);
auto ch_2_c3 = reinterpret_cast<C3 *>(ch_2);
auto ch_3_c3 = reinterpret_cast<C3 *>(ch_3);
printf("%d\n", *val);
printf("%c\n", *ch_1_c3);
printf("%c\n", *ch_2_c3);
printf("%c\n", *ch_3_c3);
为什么要用reinterpret_cast?因为用std::addressof得到的类型是C3*,而C3*+4实际上偏移了4*sizeof(C3)个字节,也就是32字节,这显然是不对的。reinterpret_cast转换的结果和源对象拥有同样的位模式
,这一点我有一篇文章单独讲透reinterpret_cast的,感兴趣的可以去看下。前提是目的类型和源类型必须至少等长
,要不然会出现截断的问题。比如:转换成int就会转不回来了,这一点要特别注意!
我写的方法或许不是唯一的方法,但是原理是一样的,要准确找出偏移量
。
结果:
8
8
8
200
1
2
3
正好对应了类里面的几个数据。
理论上private修饰
的元素是不能在类外部直接使用的,IDE不允许,编译器也不允许这么做。我们只是取巧绕过了这个机制罢了,本身有很大的风险,一不小心可能就超出边界了,而且这种布局不一定是一成不变的,会随着数据成员的改变而改变,所以不能用来正常开发
。
总结
1、C++的对象模型是相对复杂的概念,如果你想了解原理又绕不过去。
2、再一次重申,这种取巧的方法是非法的,虽然看起来有点意思,但是不能用来开发,它看起来特别像是一种设计缺陷
,实际不过是一种取舍罢了,还是要靠程序员自己纠正。