c++-class
C++类和对象
1. 面向过程和面向对象
面向过程:以函数为中心,将问题分解为一系列步骤,通过调用函数来解决问题。关注程序的执行流程,强调怎么做。
面向对象:以对象为中心,将问题分解为一系列互相交互的对象,每个对象包含数据和操作数据的方法,强调谁来做。
2. 类的定义
类相当于对象的模板,定义了对象的属性和方法,在代码编译后,类本身不占用内存空间。
2.1 语法
-
class
为定义类的关键字,ClassName
为类的名字,{}
中为类域。 -
类中可以定义变量(成员变量)和函数(成员函数)。
-
类中定义的函数默认是inline函数。
-
类中声明,类外实现,在实现时需要指定类域
ClassName::
。
class ClassName {};
2.2 封装与访问限定符
封装是面向对象编程的三大特性之一。核心思想是将数据和操作数据的方法有机结合,隐藏内部的细节,仅对外提供公开解开接口来和对象进行交互。
作用:
-
数据保护:防止外部代码随意修改对象内部数据,避免数据被破坏。
-
降低使用成本:使用者无需关心对象内部如果实现,只需调用提供的接口即可。
-
提高维护性:内部实现可自由修改,只要接口保持不变,外部代码不受影响。
C++中,封装通常使用访问限定符实现。
访问限定符 | 作用 | 说明 |
---|---|---|
public | 公开 | 修饰的成员可以在类外直接访问 |
private | 私有 | 仅类内部可访问 |
protected | 保护 | 类内部及子类可访问 |
访问限定符只限定类外访问,类内不限制。
访问限定符作用域从该访问限定符出现的位置开始到下一个访问限定符出现位置结束。
2.3 struct和class的区别
在C语言中,结构体(struct)仅能用于定义数据成员(成员变量);而在C++中,结构体被扩展为具有类的特性,不仅可以包含数据成员,还能定义成员函数,实质上与class关键字定义的类功能相同。
-
class 默认访问限定符 private,struct 默认访问限定符 public(兼容C)。
-
struct 默认继承时是 public 继承,class默认继承时 priavte 继承。
-
struct StructName
在C中是结构体类型,通常需要 typedef,C++中可以把struct
省略。
3. 对象
3.1 对象实例化
使用类在内存中创建对象的过程,称为类实例化对象。对象是类的具体实现,占用实际的内存空间。一个类可以实例化多个对象。
class ClassName {}; // 定义类
ClassName obj; // 使用类实例化出具体的对象
3.2 对象大小
3.2.1 计算对象大小规则
类实例化出的每个对象,都拥有独立的内存空间。
C++中,对象的大小(sizeof)由多因素决定。
-
对象的大小为所有非静态数据成员的大小总和(需要考虑内存对齐)。
-
成员函数不计入对象大小(存储在代码区)。
-
静态成员变量不计入对象大小(存储在全局数据区)
注意:C++中,空类或没有成员变量的类实例化时,编译器会为其开辟1字节的内存空间,用来标识这个对象的存在。
3.2.2 内存对齐
内存对齐是计算机系统中一项重要的优化机制,它确保数据在内存的存储位置符合特定的要求,以提高访问效率。
基本规则:
-
第一个成员偏移量为0
-
后续成员偏移量对齐到 对齐数 的整数倍地址处。
- 对齐数:min(编译器默认对齐数 ,该成员大小)
-
结构体总大小为 最大对齐数 的整数倍地址处。
- 最大对齐数:min(结构体中变量类型最大者,编译器默认对齐数)
-
嵌套结构体的情况:嵌套结构对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是 所有最大对齐数(含嵌套结构体的对齐数) 的整数倍。
- 嵌套结构体的最大对齐数:min(结构体中变量类型最大者,编译器默认对齐数,嵌套结构体的最大对齐数)
3.3 this指针
this指针是C++类成员函数中的一个隐含参数,它是一个指针常量,指向当前调用该成员函数的对象实例。每个非静态成员函数都可以通过this指针访问调用它的对象。
默认的成员函数中也存在 this 指针。
ClassName * const this; // 代表指针不能修改指向
this指针通常使用寄存器存储,因为是形参也可以在栈上。
编译器编译后,类的成员函数默认会在第一个形参位置,增加⼀个当前类类型的指针;编译器会在对象调用成员函数时,自动传递当前对象的地址。
4. 类的默认成员函数
默认成员函数指的是用户没有显示实现,但是编译器会自动生成的成员函数称为默认成员函数。
4.1 构造函数
构造函数是特殊的成员函数,用于创建对象时初始化对象的状态。
4.1.1 构造函数的特点
-
函数名与类名相同。
-
无返回值。
-
对象实例化时会自动调用构造函数。
-
构造函数可以重载。
-
如果类中没有显示定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显示定义编译器将不再生成。
默认构造函数包括:1. 无参数构造函数 2. 全缺省的构造函数 3.我们不写 编译器自动生成的构造函数。默认构造函数有且只能有一个存在。无参数构造和全缺省构造虽然构成函数重载,但是调用时会存在歧义。
编译器自动生成的构造函数 对内置类型的初始化是不确定的行为。对于自定义类型的成员变量,调用该成员变量的默认构造函数初始化,若这个自定义成员对象没有默认构造函数,则需要在 初始化列表 中显示调用。
delete
与 default
关键字
ClassName() = delete; // 禁止构造函数
ClassName() = default; // 强制编译器生成构造函数
4.1.2 初始化列表
初始化列表以一个冒号开始,逗号分隔的数据成员列表,每个成员变量后面跟一个放在括号中的初始化或表达式。
-
语法上可以理解为初始化列表是成员变量定义的地方。
-
引用成员变量、const成员变量、没有默认构造的自定义类型(class)成员变量,必须在初始化列表中初始化。
-
C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显示在初始化列表初始化的成员使用的。
-
初始化列表的顺序是与类中声明成员变量的顺序保持一致的,跟初始化列表的先后顺序无关。
-
即使不在初始化列表中初始化的成员,编译器也会默认走初始化列表。对于成员变量,有缺省值的使用缺省值初始化,没有缺省值的初始化是未定义的,成员函数调用默认构造函数。(即便用户显式定义了构造函数但未在构造函数体内执行任何操作,编译器仍会执行上述必要的隐式操作)。
class Date{
public:Date(int year = 2000, int month = 1, int day = 1) :_year(year) , _month(month) , _day(day) {}
private:int _year = 1; // 缺省值int _month = 1;int _day = 1;
};
4.2 析构函数
析构函数用于完成对象中资源的清理释放工作。
析构函数的特点:
-
析构函数名是
~ClassName
。 -
无参数无返回值。
-
对象销毁时会自动调用析构函数。
-
跟构造函数类似,类中没有显示定义,编译器会自动生成一个析构函数,对内置类型不做处理,自定义类型会调用它们的析构函数(即使显示定义析构函数,对于自定义类型也会调用它的析构函数 )。
-
析构函数的顺序满足栈的要求,先定义的后析构;先析构局部的再析构全局的。
- 逆序的析构规则是为了防止,后定义的对象依赖先前定义对象的数据,防止依赖的数据已经被释放。
~ClassName();
4.3 拷贝构造函数
拷贝构造函数是一个特殊的(重载形式的)构造函数。拷贝构造函数第一个参数必须是自身类类型对象的引用。
拷贝构造的特点:
-
拷贝构造是构造函数的重载。
-
拷贝构造第一个参数必须是自身类类型对象的引用,使用传值方式会陷入无穷递归。
-
C++规定自定义类型对象进行拷贝行为必须调用拷贝构造。
-
用一个已经存在的对象初始化另一个新对象时,会调用拷贝构造函数。
-
如果类中没有显示定义拷贝构造函数,则C++编译器会自动生成一个拷贝构造,会对内置类型完成浅拷贝,自定义类型变量会调用它的拷贝构造。
className(const className&);
4.4 赋值运算符重载
4.4.1 运算符重载
当运算符被作用于自定义类型对象时,C++允许我们通过 运算符重载 的形式定义自定义类型 运算符的行为。
运算符重载的特点:
-
运算符重载时具有特殊名字的函数,由
operator
关键字和要定义的运算符共同组成。和普通函数一样,具有形参和返回值。 -
运算符重载函数可以分为两种形式:1. 作为类的成员函数实现 2. 作为全局函数实现。运算符重载函数的参数数量应当与该运算符原本的操作数数量保持一致。
-
二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数
-
当作为类的成员函数实现时,则左侧运算对象默认传给隐式的this指针,因此参数会比运算对象少一个
-
-
自定义类型使用运算符时,其优先级与结合性与内置运算符一致。
-
运算符重载不能使用不存在的运算符。
.*
、::
、sizeof
、?:
、.
以上5个运算符不能重载
-
区分前置++和后置++时,C++规定,后置++需添加一个int形参,来与前置++函数构造函数重载。
-
当运算符重载函数既在成员函数内实现,又在全局中实现,优先调用成员函数内的运算符重载函数。
-
重载
<<
和>>
时,需要重载为全局函数。因为 this 指针会抢占第一个形参位置,调用时不符合习惯和可读性。重载为全局函数将ostream/istream
放在第一个形参位置,支持连续<<
或>>
,将返回值设置为ostream&/istream&
即可。
4.4.2 赋值运算符重载
赋值运算符重载的特点:
-
赋值运算符重载规定必须重载为成员函数。
-
返回值为当前自定义类型的引用,引用可以提高效率,返回值的目的是为了支持连续赋值。
-
赋值重载用于实现 两个已经存在对象 之间的拷贝赋值。
-
如果类中没有显示定义赋值运算符重载函数,则C++编译器会自动生成一个赋值运算符重载,会对内置类型完成浅拷贝,自定义类型变量会调用它的赋值重载函数。
ClassName& operator=(const ClassName&);
4.5 取地址运算符的重载
4.5.1 const成员函数
使用 const
修饰的成员函数称为const成员函数。
class ClassName {
public:// `const ClassName * const this`void func() const {} // const成员函数
};
const实际修饰的该成员函数的this指针,表明在该成员函数中不能对类的任何成员进行修改。
const成员函数允许const对象调用。
4.5.2 取地址运算符和const取地址运算符的重载
class ClassName {public: ClassName* operator&() { return this;}const ClassName* operator&() const { return this; }
};
5. 类型转换
C++支持内置类型隐式转换为自定义类型对象,需要构造函数支持。
构造函数前添加 explicit
关键字则不再支持隐式类型转换。
class Test {public:// explicit Test(int num) :count(num) {}Test(int num) :count(num) {} private:int count;
};int main () {// 常量1构造一个Test临时对象,再用这个临时对象拷贝构造test对象,编译器遇到连续的构造 + 拷贝构造 会优化为直接构造 Test test = 1;const Test& ref = 1;return 0;
}
6. static成员
static 既可以修饰成员变量又可以修饰成员函数。
static
成员的特点:
-
静态成员变量在类外初始化。
-
静态成员变量被所有类对象共享,不属于某个具体的独享,不存在对象中,存放在静态区(数据区)。
-
静态成员函数没有this指针,因此不能访问非静态成员,但是可以访问其他的静态成员。
-
可以通过
ClassName::
或对象.
的方式访问静态成员变量/函数。 -
静态成员也是类的成员,受访问限定符的限制。
class Test{public:static int count;
};
int Test::count = 0; // 初始化静态成员变量
7. 友元
友元提供了一种突破访问限定符封装的方式,友元关键字 friend
。
友元的特点:
-
友元分为友元函数和友元类。
-
友元函数声明:
friend void test();
-
友元类声明:
friend ClassName;
-
-
外部友元函数/友元类可访问该类中的私有和保护成员。
-
友元关系是单向且不能传递。
8. 内部类
内部类是在一个类内部定义的类。
内部类的特点:
-
内部类在本质上与外部定义的类并无差异,只会受到外部类的类域和访问限定符限制。此外,
sizeof
计算外部类大小时,不会包含内部类的成员。 -
内部类默认是外部类的友元类。
class Outer {public:class Inner {};
};
9. 匿名对象
匿名对象是在创建时没有命名的对象,通常用于一次性使用场景。
匿名对象特点:
-
匿名对象的生命周期只在当前行。
-
const
引用会延长匿名对象生命周期至引用作用域结束。const ClassName& ref = ClassName(1);
-
匿名函数主要用于作为函数参数、调用成员函数、函数返回匿名对象。
ClassName(args); // 创建匿名对象
ClassName(args).func(); // 匿名函数调用成员方法
Test test(); 不能这么定义对象,因为编译器无法识别是函数声明还是对象定义。Test test;
10. 编译器优化
现代编译器为了尽可能提高程序的效率,在不影响正确性的情况下,编译器可能 将临时对象直接构造在目标内存位置,尽可能减少一些可以省略的拷贝。
如何优化C++标准并没有严格规定,不同编译器可能会有不同的行为,对于一个表达式中连续的构造和拷贝构造,编译器可能会优化为直接构造。优化等级更高的情况可能会跨行跨表达式优化。
常见的优化:
-
匿名对象或字面量传参。
-
函数返回局部对象或匿名对象。
-
匿名对象或字面量构造对象。
/* vs 2019 debug */
#include<iostream>
using namespace std;class Test{
public:Test(int v) :variable(v) { cout << "Test(int v)" << endl; }Test(const Test& test) :variable(test.variable) { cout << "Test(const Test& test)" << endl; }Test& operator=(const Test& test) {if (this != &test){variable = test.variable ;} cout << "Test& operator=(const Test& test)" << endl;return* this;}~Test() { cout << "~Test()" << endl; }
private:int variable;
};Test func1() {Test test(1);return test;
}void func2(Test test) {}int main() {// 隐式类型转换 1.常量1构造一个临时的Test对象 2.临时的对象拷贝构造test1对象// 优化:构造 + 拷贝构造 -> 直接构造Test test1(1);// 1.构造匿名对象 2.匿名对象拷贝构造test2对象// 优化:构造 + 拷贝构造 -> 直接构造Test test2(Test(1));// 1.构造局部变量test对象 2.构造临时对象 3.局部test对象拷贝构造临时对象 4.临时对象拷贝构造test3对象// 优化:1.直接使用1构造临时对象 2.临时对象拷贝构造test3对象Test test3 = func1();// 1.常量1构造一个临时的Test对象 2.临时的对象拷贝构造函数形参test对象// 优化:构造 + 拷贝构造 -> 直接构造func2(Test(1));// 为什么中间会有临时对象,可以通过引用验证,普通引用会报错,而const引用可以。// 说明中间会产生临时对象,而临时对象具有常性。// Test& ref = 1; errorconst Test& ref = 1;return 0;
}
临时对象为什么具有常性?
悬空引用:临时对象销毁后,引用会指向无效内存。
逻辑错误:修改一个即将销毁的对象无意义。