1. 类的定义
1.1. 类的格式
class className { };
这是类的基本定义,和C语言
结构体定义类似, C++
中struct
也可以定义类,C++
兼容C
中struct
的⽤法,同时struct
升级成了类,明显的变化是struct
中可以定义函数,⼀般情况下我们还是推荐⽤class
定义类
- 类体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的⽅法或者成员函数
- 为了区分成员变量,一般会在变量名开头或者结尾加上
_
进行标识 - 定义在类⾯的成员函数默认为
inline
// 定义一个类
class stuInfo
{
public:
// 将实现接口公开
// 成员函数
private:
// 将信息数据隐藏
unsigned int _age;
unsigned int _numArr[8];
char* _nameArr[10];
};
再比如:实现一个简单的栈
// 实现一个简单的栈
class Stack
{
public:
// 成员函数
void Init(int n = 4)
{
_valArr = (int*)malloc(sizeof(int) * n);
if (_valArr == nullptr)
{
perror("malloc err!");
return;
}
_top = 0;
_capacity = n;
}
void push(int inPut = 1)
{
_valArr[_top++] = inPut;
}
size_t top()
{
assert(_top > 0);
return _valArr[_top - 1];
}
void Destroy()
{
assert(_valArr);
free(_valArr);
_valArr = nullptr;
_capacity = _top = 0;
}
private:
size_t _top;
size_t _capacity;
int* _valArr;
};
int main()
{
Stack st;
st.Init(12);
st.push(0);
st.push(1);
st.push(2);
st.push(3);
st.top();
st.Destroy();
return 0;
}
1.2. 访问限定符
在上面的代码中,已经出现了public
和private
public:
修饰的成员在类外可以访问
private/protected:
受保护,修饰的成员不能在类外访问- 访问权限作用域:从第一个访问限定符出现的位置到下一个访问限定符出现的位置为一个作用域,若没有下一个访问限定符,则到
}
结束 class
的类没有public
默认都是被保护的,struct
的类默认是公开的,是为了向下兼容C
- 这体现了封装的思想:将数据和操作数据的方法结合,隐藏对象的属性和实现细节,仅对外公开接口和对象进行交互。通俗的说:数据和方法包在一起,然后通过访问限定符,保护不想让别人看得到,公开可以让别人看到的
1.3. 类域
- 类定义了⼀个新的作⽤域,类的所有成员都在类的作⽤域中,在类体外定义成员时,需要使⽤
::
作⽤域操作符指明成员属于哪个类域。 - 类域影响的是编译的查找规则,下⾯程序中
Init
如果不指定类域Stack
,那么编译器就把Init
当成全局函数,那么编译时,找不到_valArr
等成员的声明/定义在哪⾥,就会报错。指定类域Stack
,就是知道Init
是成员函数,当前域找不到的_valArr
等成员,就会到类域中去查找。
class Stack
{
public:
// 成员函数
void Init(int n = 4);
void push(int inPut = 1);
size_t top();
void Destroy();
private:
size_t _top;
size_t _capacity;
int* _valArr;
};
// 声明和定义分离
// 缺省参数声明给
void Stack::Init(int n)
{
_valArr = (int*)malloc(sizeof(int) * n);
if (_valArr == nullptr)
{
perror("malloc err!");
return;
}
_top = 0;
_capacity = n;
}
void Stack::push(int inPut)
{
_valArr[_top++] = inPut;
}
size_t Stack::top()
{
assert(_top > 0);
return _valArr[_top - 1];
}
void Stack::Destroy()
{
assert(_valArr);
free(_valArr);
_valArr = nullptr;
_capacity = _top = 0;
}
int main()
{
Stack st;
st.Init(12);
st.push(0);
st.push(1);
st.push(2);
st.push(3);
st.top();
st.Destroy();
return 0;
}
2. 实例化
2.1. 实例化概念
- 用类类型在物理内存中创建对象的过程,称为类实例化出对象
- 类是对象进⾏⼀种抽象描述,是⼀个模型⼀样的东西,限定了类有哪些成员变量,这些成员变量只是声明,没有分配空间,⽤类实例化出对象时,才会分配空间
class Date
{
public:
void Init(size_t year, size_t month, size_t day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "/" << _month << "/" << _day << endl;
}
private:
// 声明
size_t _year;
size_t _month;
size_t _day;
};
int main()
{
// 实例化
Date d1;
d1.Init(2025, 7, 3);
d1.Print();
return 0;
}
- ⼀个类可以实例化出多个对象,实例化出的对象占⽤实际的物理空间,存储类成员变量。举个例子:小米YU7的设计图纸是一个类,而每台小米YU7就是设计图纸实例化出的对象,1个类对应n个对象。
2.2. 类的大小
分析一下类对象里的成员:
- 成员变量: 类实例化出的每个对象,都有独立的内存空间,计算类的大小时一定会考虑到
- 成员函数:函数被编译后时一段指令,对象中没办法存储,这些指令单独存储在代码段,若非要存储,则可以通过指针。
但是有用指针的必要吗?
Person
实例化出两个对象p1
和p2
,p1
和p2
都有各自独立的成员变量_name
_gender
_age
存储各自的数据,但是p1
和p2
成员函数PrintPersonInfo
SetPersonInfo
的指针却是一样的
这里调用的是同一个函数,如果用Person
实例化出100个对象,按照方式一,指针存储相同的地址存储100次,太浪费了,所以采用方式二,将函数地址存到公共代码区,所以得出结论:成员函数不参与类大小的计算
类的大小J计算遵循结构体内存对齐原则:
- 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处
- 其他成员变量要对齐到对齐数的整数倍的地址处
- 对齐数=编译器默认的一个对齐数与该成员变量大小的较小值
- VS默认为8
- Linux中没有默认对齐数 gcc默认对齐数就是成员自身大小
- 结构体总大小为最大对齐数(结构体中每个成员变量都有一个对齐数,所有对齐数中最大的)的整数倍
- 如果嵌套了结构体的情况,嵌套的结构体成员对齐到自己的成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体中成员的对齐数)的整数倍。
// 计算⼀下A/B/C实例化的对象是多⼤?
// 8
class A
{
public:
void Print()
{
cout << _ch << endl;
}
private:
char _ch;
int _i;
};
// 1
class B
{
public:
void Print()
{
//...
}
};
// 1
class C
{
};
int main()
{
A a;
B b;
C c;
cout << sizeof(a) << endl;
cout << sizeof(b) << endl;
cout << sizeof(c) << endl;
return 0;
}
注意:空类大小默认为1字节,这个字节不存储有效数据,只表明类被定义了
3. this指针
Date
类实例化出两个对象d1
和d2
,d1
和d2
怎么区分成员函数Print()
和Init()
来进行调用呢,C++
引入了this指针
来解决- 编译器编译之后,会在成员函数形参的第一个位置增加⼀个当前类类型的指针,称为
this指针
即void Print(Date* const this)
,注意:这个指针无法修改;形参和实参的位置不能显式写出this指针
,编译器会默认生成,但是在函数体内可以显式写 - 类的成员函数访问成员变量,通过
this指针
来实现,例如:this->_year = year
using namespace std;
class Date
{
public:
// void Init(Date* const this, size_t year, size_t month, size_t day)
void Init(size_t year, size_t month, size_t day)
{
// this->_year = year
_year = year;
_month = month;
_day = day;
}
// void Print(Date* const this)
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
size_t _year;
size_t _month;
size_t _day;
};
int main()
{
Date d1;
d1.Init(2025, 7, 5);
Date d2;
d2.Init(2008, 1, 1);
d1.Print();
d2.Print();
}
再来看两个程序,问该程序是正常运行,还是编译错误,还是运行崩溃
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
分析:A
类型实例化出一个p指针
,这个指针为空,然后p指针
调用Print()
,回到Print()
执行函数体,正常输出A::Print()
,这里没有解引用空指针,所以程序正常运行
#include<iostream>
using namespace std;
class A
{
public:
void Print()
{
cout << "A::Print()" << endl;
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
分析:A
类型实例化出一个p指针
,这个指针为空,然后p指针
调用Print()
,回到Print()
执行函数体,正常输出A::Print()
,而继续下一行p->_a
空指针解引用了,运行崩溃
this指针
存在栈区和ecx
寄存器中
4. 六大默认成员函数
默认成员函数就是⽤⼾没有显式实现,编译器会⾃动⽣成的成员函数称为默认成员函数。⼀个类,我们不写的情况下编译器会默认⽣成以下6个默认成员函数。
我们需要从从两方面入手:
第⼀:我们不写时,编译器默认⽣成的函数⾏为是什么,是否满⾜我们的需求。
第⼆:编译器默认⽣成的函数不满⾜我们的需求,我们需要⾃⼰实现,那么如何⾃⼰实现?
4.1. 构造函数
构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象(我们常使⽤的局部对象在栈帧创建时,空间就开好了),⽽是对象实例化时初始化对象。构造函数的本质是要替代我们以前Stack
和Date类
中写的Init
函数的功能,构造函数⾃动调⽤的特点就完美的替代的了Init
。
构造函数有如下特点:
1. 函数名与类型同名
2. 可以重载
3. 没有返回值(不用写void
)
4. 如果用户没有显式写构造函数,编译器会生成一个默认的无参构造函数,⼀旦⽤⼾显
式定义编译器将不再⽣成。
// 构造函数
// 1. 函数名和类名同名 2. 可以重载 3. 没有返回值 4. 用户不写编译器会默认生成无参的构造函数
class Date
{
public:
// 无参构造
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
// 带参数构造
Date(size_t year, size_t month, size_t day)
{
_year = year;
_month = month;
_day = day;
}
//// 全缺省构造
//Date(size_t year = 1, size_t month = 1, size_t day = 1)
//{
// _year = year;
// _month = month;
// _day = day;
//}
//
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
size_t _year;
size_t _month;
size_t _day;
};
int main()
{
// 调用带参数的构造
Date d1(2025,7,5);
d1.Print();
//// 无参构造和全缺省构造会产生调用歧义
//Date d2;
//d2.Print();
// 无参的不能这么写 会和函数声明搞混 eg: void func
// 这是函数声明还是函数定义呢?
/*Date d2();
d2.Print();*/
//// 如果注释掉无参的构造和全缺省构造,会报错
//// C2512 没有合适的默认构造函数可用
//Date d2;
//d2.Print();
// 调用无参的构造函数
Date d3;
d3.Print();
return 0;
}
默认构造函数分为三类:
- 全缺省构造函数
- 无参构造函数
- 编译器默认生成的构造函数
总结一下:不传参的构造函数就是默认构造函数,这三个函数不能同时存在
而全缺省构造函数和无参构造函数虽然构成函数重载,但是调用时会产生调用歧义
我们不显式写构造函数,编译器默认生成的构造函数会如何处理数据?
using namespace std;
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
private:
size_t _hour;
size_t _minute;
size_t _second;
};
class Date
{
public:
// 不写构造函数 编译器会自动生成默认构造函数
// 对于内置类型 编译器是否处理没有明确要求
// 对于自定义类型 调用该类型的默认构造函数
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
size_t _year;
size_t _month;
size_t _day;
Time _t;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
观察调试结果,我们可以得到如下结论:
对于编译器默认生成的构造函数,处理不同类型数据有不同行为:
- 对于内置类型,编译器没有特别要求,对于
VS
环境,给出随机值 - 对于自定义类型,该类型会调用它默认的构造函数
如果把Time
类的无参构造函数注释掉,会有如下现象:
Time
类调用它的默认构造函数,而Time
类的默认构造函数是编译器生成的,又是处理内置类型,所以VS
不做处理,给出随机值
针对这个问题C++11
打了个补丁:内置类型成员变量在声明时给缺省值,用缺省值初始化
using namespace std;
class Time
{
public:
/*Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}*/
private:
// C++11 在声明时给缺省值
size_t _hour = 1;
size_t _minute = 1;
size_t _second = 1;
};
class Date
{
public:
// 不写构造函数 编译器会自动生成默认构造函数
// 对于内置类型 编译器是否处理没有明确要求
// 对于自定义类型 调用该类型的默认构造函数
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
// C++11 在声明时给缺省值
size_t _year = 1;
size_t _month = 1;
size_t _day = 1;
Time _t;
};
int main()
{
// 此时 Time类和Date类只有编译器默认生成的构造函数
Date d1;
d1.Print();
return 0;
}
总结:什么时候要显式定义构造函数?
- 一般情况构造函数都要显式实现
- 只有成员全为自定义类型的类不用显式实现
4.2. 析构函数
析构函数与构造函数功能相反,析构函数不是完成对对象本⾝的销毁,⽐如局部对象是存在栈帧的,函数结束栈帧销毁,他就释放了,不需要我们管,C++
规定对象在销毁时会⾃动调⽤析构函数,完成对象中资源的清理释放工作。析构函数的功能类⽐我们之前Stack
实现的Destroy
功能,⽽像Date
没有Destroy
,其实就是没有资源需要释放,所以严格说Date
是不需要析构函数的
析构函数有如下特点:
1. 函数名和类名相同,在函数名前加~
2. 没有返回值
3. 不能重载,意味着一个类只有一个析构函数
4. 如果用户没有显式写,编译器会默认生成析构函数
5. 对象的生命周期结束,编译器自动调用析构函数
class Stack
{
public:
Stack(size_t n = 4)
{
cout << "Stack(size_t n = 4) 析构" << endl;
_arr = (int*)malloc(sizeof(int) * n);
if (_arr == nullptr)
{
perror("malloc err!");
return;
}
_capacity = n;
_top = 0;
}
~Stack()
{
cout << "~Stack() 析构" << endl;
assert(_arr);
free(_arr);
_arr = nullptr;
_capacity = _top = 0;
}
private:
int* _arr;
int _capacity;
int _top;
};
int main()
{
Stack st1;
return 0;
}
和构造函数一样,如果我们不显式实现析构函数,编译器生成的析构函数对于内置类型不做处理,对于定义类型会调用它的析构函数,值得一提的是,是我们显⽰写析构函数,对于⾃定义类型成员也会调⽤他的析构,也就是说⾃定义类型成员⽆论什么情况都会⾃动调⽤析构函数
class tmp
{
public:
~tmp()
{
cout << "~tmp() 析构" << endl;
}
private:
int _num;
};
class Stack
{
public:
Stack(size_t n = 4)
{
cout << "Stack(size_t n = 4) 构造" << endl;
_arr = (int*)malloc(sizeof(int) * n);
if (_arr == nullptr)
{
perror("malloc err!");
return;
}
_capacity = n;
_top = 0;
}
/*~Stack()
{
cout << "~Stack() 析构" << endl;
assert(_arr);
free(_arr);
_arr = nullptr;
_capacity = _top = 0;
}*/
private:
int* _arr;
int _capacity;
int _top;
tmp _t;
};
int main()
{
Stack st1;
return 0;
}
我们可以通过调试观察:
总结:什么时候需要显式实现析构函数?
- 有资源需要清理,就必须写析构函数,例如:
Stack
List
… - 无资源要清理,可以不写
- 内置类型成员没有资源要清理,剩下全是自定义类型,可以不写
还有一个重要的点:一个局部域的多个对象,后定义的先析构
设已经有A,B,C,D4个类的定义,程序中A,B,C,D析构函数调用顺序为?( )
C c;
int main()
{
A a;
B b;
static D d;
return 0;
}
- 类的析构函数调用一般按照构造函数调用的相反顺序进行调用,但是要注意static对象的存在, 因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放对象
- 全局对象先于局部对象进行构造
- 部对象按照出现的顺序进行构造,无论是否为
static
- 所以构造的顺序为
c a b d
- 析构的顺序按照构造的相反顺序析构,只需注意static改变对象的生存作用域之后,会放在局部 对象之后进行析构
- 因此析构顺序为
B A D C
5. 拷贝构造函数
拷贝构造函数的第一个参数是自身类型的引用,且任何额外的参数都有缺省值,这样的函数叫做拷贝构造函数,用于同类对象的拷贝初始化,是构造函数的重载。
本文以最常规情况的拷贝构造函数展开,即有且仅有一个参数:类类型对象的引用
拷贝构造函数有如下特点:
- 拷贝构造函数是构造函数的一个重载
- 拷贝构造函数的第一个参数必须是类类型对象的引用,使用传值方式编译器会报错(会引发无穷递归调用),拷⻉构造函数也可以多个参数,但是第⼀个参数必须是类类型对象的引⽤,后⾯的参数必须有缺省值
// 拷贝构造函数
// 构造函数的重载,第一个参数必须是类类型对象的引用
// 用于同类对象的拷贝初始化
class Date
{
public:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(Date& d)
{
cout << "call Date(Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
size_t _year;
size_t _month;
size_t _day;
};
int main()
{
Date d1;
// 两种写法都可以
Date d2 = d1;
// d是d1的别名,d3是this指针
Date d3(d1);
d1.Print();
d2.Print();
d3.Print();
return 0;
}
再来看一段代码:
Date(Date& d)
{
cout << "call Date(Date& d)" << endl;
// 如果不小心写反了会发生什么?
d._year = _year;
d._month = _month;
d._day = _day;
}
其余部分不变
初始的d1
也被修改成随机值了,我们进行拷贝构造,提供拷贝值的对象是不能被修改的,所以为了防止这样的情况发生,我们做如下处理:Date(const Date& d)
保证d
的只读性
// 拷贝构造函数
// 构造函数的重载,第一个参数必须是类类型对象的引用
// 用于同类对象的拷贝初始化
class Date
{
public:
Date()
{
_year = 1;
_month = 1;
_day = 1;
}
Date(const Date& d)
{
cout << "call Date(Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
size_t _year;
size_t _month;
size_t _day;
};
int main()
{
Date d1;
// 两种写法都可以
Date d2 = d1;
// d是d1的别名,d3是this指针
Date d3(d1);
d1.Print();
d2.Print();
d3.Print();
return 0;
}
- 自定义类型对象进行拷贝行为必须调用拷贝构造,所以自定义类型传值传参和传值返回都会调用拷贝构造
// 自定义类型对象进行拷贝行为必须调用拷贝构造
// 自定定义类型传值传参和传值返回都会调用拷贝构造完成
class Date
{
public:
Date(size_t year, size_t month, size_t day)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& d)
{
cout << "调用 Date(const Date& d)" << endl;
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
size_t _year;
size_t _month;
size_t _day;
};
void func(Date d)
{
d.Print();
}
int main()
{
Date d1(2025, 7, 9);
func(d1);
return 0;
}
调试看一下函数行为:
而传指针和传引用可避免拷贝构造:
C++
推荐使用传引用的方式,因为引用语义更清晰、不能为 null、更安全也更简洁,适合绝大多数函数参数传递场景,除非参数可以为空或需要修改指针本身,否则优先使用引用传参
为什么传值会引发无穷递归?
- 如果不显式写拷贝构造,编译器会默认生成拷贝构造,⾃动⽣成的拷⻉构造对内置类型成员变量会完成值拷⻉/浅拷⻉(⼀个字节⼀个字节的拷⻉),对⾃定义类型成员变量会调⽤它的拷⻉构造
// 如果不显式写拷贝构造,编译器会默认生成拷贝构造,
// 自动⽣成的拷贝构造对内置类型成员变量会完成值拷贝/浅拷贝(⼀个字节⼀个字节的拷贝),
// 对自定义类型成员变量会调用它的拷贝构造
class Stack
{
public:
Stack(size_t n = 4)
{
// cout << "Stack(size_t n = 4) 构造" << endl;
_arr = (int*)malloc(sizeof(int) * n);
if (_arr == nullptr)
{
perror("malloc err!");
return;
}
_capacity = n;
_top = 0;
}
void Push(int data)
{
_arr[_top++] = data;
}
~Stack()
{
// cout << "~Stack() 析构" << endl;
assert(_arr);
free(_arr);
_arr = nullptr;
_capacity = _top = 0;
}
private:
int* _arr;
int _capacity;
int _top;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(1);
st1.Push(1);
Stack st2 = st1;
return 0;
}
崩溃了
完成了拷贝,程序却崩溃了,为什么?我们没有显式写拷贝构造,默认生成的拷贝构造调用栈对象的拷贝构造,进行了浅拷贝。
而深拷贝,会复制一个相同的空间,这样就不会冲突
class Stack
{
public:
Stack(size_t n = 4)
{
// cout << "Stack(size_t n = 4) 构造" << endl;
_arr = (int*)malloc(sizeof(int) * n);
if (_arr == nullptr)
{
perror("malloc err!");
return;
}
_capacity = n;
_top = 0;
}
// 深拷贝
// Stack st2 = st1;
Stack(const Stack& st)
{
_arr = (int*)malloc(sizeof(int) * st._capacity);
if (_arr == nullptr)
{
perror("malloc err!");
return;
}
memcpy(_arr, st._arr, sizeof(int)* st._capacity);
_capacity = st._capacity;
_top = st._top;
}
void Push(int data)
{
_arr[_top++] = data;
}
~Stack()
{
// cout << "~Stack() 析构" << endl;
assert(_arr);
free(_arr);
_arr = nullptr;
_capacity = _top = 0;
}
private:
int* _arr;
int _capacity;
int _top;
};
int main()
{
Stack st1;
st1.Push(1);
st1.Push(1);
st1.Push(1);
Stack st2 = st1;
return 0;
}
完成深拷贝,二者有自己的独立空间
总结:什么时候需要显式写拷贝构造?
- 若无资源管理,不用显式写拷贝构造,eg:
Date
- 若类的成员变量都是自定义类型,并且内置类型的的成员没有指向的资源,不用显示写拷贝构造
tips: 不显式写析构,就不用写拷贝 - 若类内部有指针或者一些值指向资源,要写析构释放,就需要显式写深拷贝,eg:
Stack
未完待续~