类的访问限定符及封装
访问限定符
在面向对象的编程中,封装是一个核心概念,它隐藏了对象的内部实现细节,只对外提供必要的接口。封装通过访问限定符来控制类成员的访问权限,从而实现数据的隐藏和保护。
C++实现封装的方式:用类将对象的属性与方法结合在一块,让对象更加完善,通过访问权限选择性的将其接口提供给外部的用户使用。
C++ 中有三种访问限定符:
public(公有):
成员在类的内部和外部都可以被访问。
protected(保护):
成员在类的内部和派生类(子类)中可以被访问,但不能在类的外部直接访问。
private(私有):
成员只能在类的内部被访问,不能在类的外部或派生类中直接访问。
【访问限定符说明】
1. public修饰的成员在类外可以直接被访问
2. protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的)
3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
4. 如果后面没有访问限定符,作用域就到 } 即类结束。
5. class的默认访问权限为private,struct为public(因为struct要兼容C)
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
封装
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。
案例:
计算机作为复杂设备,其设计体现了高度的封装性。
用户只需通过开关机键、键盘输入、显示器和USB插孔等外部接口与计算机交互,完成日常任务。计算机内部的核心部件如CPU、显卡、内存等,则隐藏在机壳内部,用户无需关心其详细设计或工作原理。这种设计使得计算机易于使用,同时保护了内部复杂结构的安全性和稳定性。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
类的作用域
类定义了一个新的作用域,类的所有成员都在类的作用域中。
在类体外定义成员时,需要使用 ::作用域操作符指明成员属于哪个类域。
class Person { public: void PrintPersonInfo(); private: char _name[20]; char _gender[3]; int _age; }; // 这里需要指定PrintPersonInfo是属于Person这个类域 void Person::PrintPersonInfo() { cout << _name << " "<< _gender << " " << _age << endl; }
类的作用域还涉及到类的成员在何处可以访问和使用。具体来说,某个类A中某个成员M在以下情况下具有类A的作用域:
- 该成员(M)出现在该类的某个成员函数中,并且该成员函数没有定义同名标识符。
- 该类(A)的某个对象的该成员(M)的表达式中。例如,a是A的对象,则在表达式a.M中,M具有类A的作用域。
- 在该类(A)的某个指向对象指针的该成员(M)的表达式中。例如,Pa是一个指向A类对象的指针,则在表达式Pa->M中,M具有类A的作用域。
- 在使用作用域运算符所限定的该成员中。例如,在表达式A::M中,M具有类A的作用域。
类的实例化
用类类型创建对象的过程,称为类的实例化
类是对象的模板或定义,它描述了对象的属性(成员变量)和方法(成员函数),但不分配实际内存来存储实例化的数据。
通过类可以创建多个具有相同结构和行为的对象。这些对象会占用实际的物理空间来存储它们各自的属性值。
例如:
- 学生信息表可以被视为一个类,定义了学生应具有的基本信息字段。而每个具体的学生记录就是该类的一个对象,它包含了这个学生的具体信息并占用内存空间。
- 谜语和谜底的关系是一个很好的类比,谜语描述了谜底的特征,而谜底则是符合这些特征的具体实例。
在代码中,我们不能直接通过类名来访问或修改对象的成员变量,因为类本身并不存储具体的实例数据。
我们需要先创建类的实例(即对象),然后通过该对象来访问或修改其成员变量。
类与对象的关系可以比作建筑设计图与实际建筑的关系。
设计图(类)定义了建筑的结构和样式,但没有实际的建筑存在。只有当按照设计图进行建造(实例化)时,才会产生实际的建筑(对象),它占用物理空间并具有具体的形态和功能。
类-->对象 —— 1-->多
类对象模型
如何计算类对象的大小
类中既可以有成员变量,又可以有成员函数,那么一个类的对象中包含了什么?如何计算一个类的大小?
运行结果:
类对象的存储方式
类中既有成员变量,又有成员函数
class A1 { public: void f1(){} private: int _a; };
对于类 A1
,它有一个私有成员变量 _a
(类型为 int
)和一个公有成员函数 f1()
。由于成员函数不占用类实例的内存空间(它们通常存储在代码段中,而不是数据段中),所以 A1
类实例的大小只与成员变量有关。在大多数系统上,一个 int
类型的成员变量通常占用 4 个字节(但这不是绝对的,取决于平台和编译器)。因此,sizeof(A1)
应该是 4(或可能是 4 的倍数,取决于内存对齐)。
类中仅有成员函数
class A2 { public: void f2() {} };
类中什么都没有---空类
class A3 {};
对于类 A2
和 A3
,它们没有成员变量,只有成员函数。如前所述,成员函数不占用类实例的内存空间。然而,对于空类,编译器通常会为其分配至少一个字节的大小,以确保每个对象在内存中都有一个唯一的地址。因此,sizeof(A2)
和 sizeof(A3)
都应该是 1(或可能是 1 的倍数,取决于内存对齐和编译器的实现)。但在实践中,某些编译器可能会为空类分配更大的大小,以确保对象之间的内存地址有足够的间隔,这被称为“空基类优化”。
结论:一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。
结构体内存对齐规则
1. 第一个成员在与结构体偏移量为0的地址处。
2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值。 VS中默认的对齐数为8
3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍
现在,关于结构体内存对齐的问题:
- 结构体怎么对齐?:结构体对齐是为了满足处理器访问内存时的效率问题。 当处理器从对齐的地址处读取数据时,通常比从非对齐的地址处读取数据要快。此外,某些硬件平台可能根本不支持非对齐的内存访问。
- 为什么要进行内存对齐?:如上所述,内存对齐可以提高处理器访问内存的效率,并避免在某些硬件平台上出现错误。
- 如何让结构体按照指定的对齐参数进行对齐?:在 C++ 中,可以使用
alignas
关键字或特定的编译器指令(如 GCC 的__attribute__((aligned(n)))
)来指定结构体的对齐参数。 - 能否按照3、4、5即任意字节对齐?:是的,但需要注意的是,对齐参数应该是 2 的幂,并且小于或等于平台支持的最大对齐值。此外,过小的对齐值可能不会带来性能上的好处,而过大的对齐值可能会浪费内存。
- 什么是大小端?:大小端是指多字节数据在内存中的存储顺序。大端模式(Big-Endian)是指数据的高位字节存储在内存的低地址处,而数据的低位字节存储在内存的高地址处。小端模式(Little-Endian)则相反,数据的低位字节存储在内存的低地址处,而数据的高位字节存储在内存的高地址处。
- 如何测试某台机器是大端还是小端?:可以通过检查一个整数类型(如
int
)的字节顺序来测试机器的大小端。一种常见的方法是创建一个整数,其高位字节设置为 1,其他字节设置为 0,然后检查该整数在内存中的地址处存储的值。 - 有没有遇到过要考虑大小端的场景?:在处理跨平台的数据交换、网络通信或文件存储时,经常需要考虑大小端问题。因为不同的硬件平台可能使用不同的大小端模式,所以必须确保数据在发送和接收时的大小端一致性。
class A4 { //private: char _ch;、 int _i; }; int main() { cout<< sizeof(A4)<< endl; return 0; }
不对齐就是五个字节,对齐就是八个字节
this指针
this指针的引出
class Date { public: void Init(int year, int month, int day) { _year = year; _month = month; _day = day; } void Print() { cout <<_year<< "-" <<_month << "-"<< _day <<endl; } private: int _year; // 年 int _month; // 月 int _day; // 日 }; int main() { Date d1, d2; d1.Init(2022,1,11); d2.Init(2022, 1, 12); d1.Print(); d2.Print(); return 0; }
对于上述类,有这样的一个问题:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
在C++中,当你有一个类(比如Date
类)并且这个类有成员函数(比如Init
和Print
),编译器确实为每个非静态成员函数增加了一个隐藏的指针参数this
。这个this
指针指向调用该成员函数的对象的地址。
当你创建Date
类的两个对象d1
和d2
,并分别调用它们的Init
函数时,编译器会自动将this
指针设置为指向当前对象(d1
或d2
)的地址。这样,在Init
函数的函数体内,所有对成员变量的操作都是通过这个this
指针来完成的,从而确保了对正确对象的操作。
这个过程对用户(即程序员)来说是透明的,你不需要显式地传递this
指针或进行任何特殊的操作。编译器会自动处理这一切。
在C++中,编译器为每个非静态成员函数隐式地传递一个名为
this
的指针,该指针指向调用该函数的对象。这使得成员函数能够知道它们应该操作哪个对象的数据成员。这个过程对用户是透明的。
this指针的特性
1. this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。
2. 只能在“成员函数”的内部使用
3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针。
4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
案例分析:
class A { public: void Print() { cout<<"printf()"<<endl; //正常运行 } private: int _a; }; int main() { A* p = nullptr; p->Print(); return 0; }
cout<<"printf()"<<endl; //正常运行
在这段代码示例中,尽管 p
是一个指向 A
类对象的空指针(nullptr
),但调用 p->Print();
似乎可以成功执行,并且不会立即导致程序崩溃。这是因为 Print
函数是一个不依赖于 this
指针中存储的对象状态(即不访问任何成员变量)的成员函数。
在 C++ 中,成员函数通常通过 this
指针隐式地访问对象的成员。然而,如果成员函数不访问任何成员变量(也不调用其他访问成员变量的成员函数),那么实际上并不需要有效的 this
指针。在大多数现代编译器和硬件上,这样的调用可能不会立即导致崩溃,因为 this
指针通常只在函数内部需要访问成员变量时才会被使用。
但是,这并不意味着通过空指针调用成员函数是安全的或推荐的做法。尽管在的例子中 Print
函数能够执行,但这样做是未定义行为(Undefined Behavior, UB),并且可能导致不可预测的结果,包括(但不限于)程序崩溃、数据损坏或安全漏洞。
未定义行为意味着 C++ 标准没有规定在这种情况下程序应该如何表现。不同的编译器、不同的编译器设置、不同的操作系统或硬件架构都可能导致不同的结果。因此,我们应该始终避免通过空指针调用成员函数。
此外,一些编译器或编译器的优化设置可能会检测到这种潜在的未定义行为,并发出警告或错误。例如,使用某些静态分析工具或编译器的更严格的警告级别可能会帮助识别这种问题。
class A { public: void PrintA() { cout<<_a<<endl; //运行崩溃 } private: int _a; }; int main() { A* p = nullptr; p->PrintA(); return 0; }
而在这段代码中,程序崩溃的原因是解引用了一个空指针 p
来调用 PrintA
成员函数。即使 PrintA
函数不直接访问 _a
成员(实际上它是通过 this
指针隐式访问的),但调用成员函数本身就需要一个有效的对象实例。
在 C++ 中,当你有一个指向对象的指针,并试图通过该指针调用成员函数时,编译器会生成代码来隐式地传递一个指向该对象的 this
指针给成员函数。然而,如果指针是 nullptr
(或称为空指针),那么 this
指针就会是无效的,尝试通过它访问成员会导致未定义行为,通常表现为程序崩溃。
在这段代码中,p
被初始化为 nullptr
,这意味着它并不指向任何有效的 A
类对象。
然后,尝试通过 p->PrintA();
调用 PrintA
成员函数。
由于 p
是空的,this
指针也是无效的,因此程序崩溃。
this指针存在哪里?
this
指针是 C++ 编译器在调用成员函数时自动添加的一个隐式参数。它实际上是一个指向调用该成员函数的对象(或类的实例)的指针。这个指针并不是真正存储在对象本身的内存布局中,而是在成员函数被调用时,由编译器在函数调用栈帧(stack frame)中创建并管理的。
所以this指针是存在栈(stack)里的!
在成员函数内部,你可以通过 this
指针来访问或修改对象的成员变量。尽管在源代码中你并不会显式地看到 this
指针的传递和使用,但编译器会在编译时为你处理这些细节。
this指针可以为空吗?
this
指针本身在成员函数被调用时总是指向一个有效的对象(除非是通过某种非常规的方式调用成员函数,比如直接通过函数指针调用且没有正确的对象上下文)。然而,你不能显式地将 this
指针设置为 nullptr
或其他无效地址,因为 this
指针是由编译器管理的,而不是由程序员直接控制的。
但是,有一种情况需要注意:当你通过空指针(nullptr
)来调用成员函数时,虽然技术上你并没有直接操作 this
指针,但这种行为是未定义的,并且很可能导致程序崩溃。这是因为即使函数体内不直接访问任何成员变量,成员函数被调用时仍然需要一个有效的 this
指针来作为上下文。当这个上下文不存在(即你试图通过一个空指针来调用成员函数)时,程序的行为就是未定义的。
所以,虽然不能直接设置 this
指针为空,但必须确保在调用成员函数时所使用的对象指针是有效的。
希望对你有帮助!加油!
若您认为本文内容有益,请不吝赐予赞同并订阅,以便持续接收有价值的信息。衷心感谢您的关注和支持!