访问修饰符
public: 公有的访问权限,在类外可以通过对象直接访问公有成员
protected: 保护的访问权限,在本类中和派生类中可以访问,在类外不能通过对象直接访问(后面学)
private: 私有的访问权限,在本类之外不能访问,比较敏感的数据设为private,类定义中可以访问。
class默认为private,而在c++中对struct做了扩展,变成了默认为public的class
成员函数的定义
-
形式
可在内部声明,外部定义
class Computer { public: //内部声明 void setBrand(const char * brand); private: char _brand[20]; }; //类外定义 void Computer::setBrand(const char *brand){ strcpy(_brand, brand); }
-
那么为什么要这么干呢?
当类中成员函数庞杂时,看着容易头昏眼花,若只在类中进行声明并加上注释,会方便理解。
-
-
多文件联合编译时可能出现的错误
-
为什么一般不在头文件中定义函数?
若在头文件中定义一个函数,同时多个源文件都包含该头文件,在联合编译时约等于一个源文件都定义了一次这个函数,即会发生重定义错误。
-
类的成员函数也存在这种问题
若在头文件中:类内声明,类外定义。也会发生该错误
解决方法:
- 类外定义前加上inline,这在头文件中使用是ok的
- 直接放类里面定义(实际上跟inline效果一样)
- 函数声明放头文件,函数定义放在实现文件中,就算有多个测试文件,也不会出现重定义(最常用)
-
对象的创建
C++ 为类提供了一种<span style=color:red;background:yellow>特殊的成员函数——构造函数</span>来完成真正的初始化。
-
构造函数的作用:就是用来初始化数据成员的
-
构造函数的形式:
-
<span style=color:red;background:yellow>没有返回值,即使是void也不能有;</span>
-
<span style=color:red;background:yellow>函数名与类名相同,再加上函数参数列表。</span>
-
构造函数在对象创建时**自动调用**,用以完成数据成员的初始化,及其他操作(如为指针成员动态申请内存等)
规则
-
若类中没有显式定义构造函数,编译器会默认自动生成一个无参的构造函数,但不会初始化数据成员
即如果不定义构造函数,直接Student stu1;然后stu1.print()打印信息,信息未被初始化,都会是不确定的值
-
显式定义构造函数后,编译器便不会再自动生成默认的构造函数
-
构造函数也可以接收参数,在对象创建时提供更大的自由度
//正常做法 class a{ public: a(int x, int y){ _x = x; _y = y; } private: int _x; int _y; }
-
若要重载(可以做到无参)
class a{ public: a(){//无参,直接赋值为0 _x = 0; _y = 0; } a(int x, int y = 0){//有参 _x = x; _y = y; } private: int _x; int _y; } //构造一下: a a1;//0,0 a a2(1,2);//1,2 a a3(10);//10,0
初始化
初始化列表:
a(int x, int y): _x(x), _y(y){...}
补充:数据成员的初始化取决于声明时的顺序(与声明顺序一致)。跟初始化列表中的顺序无关。如果初始化列表顺序:a,b。而声明顺序:b,a。流程是先初始化b,再初始化a。
- 构造函数的参数也可以按从右向左规则赋默认值,同样的,若又要在类内声明,类外定义,那么建议只在声明中设置默认值
//类内
Point(int ix, int iy = 0);//在声明时设置默认参数
//类外
Point::Point(int ix, int iy): _ix(ix), _iy(iy){
cout << "Point(int,int)" << endl;
}
//调用
Point pt(10);
- 不过还是建议在初始化列表中进行初始化
对象所占空间大小
对象大小仅是成员类型所占大小之和
又因为内存对齐,有时候值有变化,具体看成员定义的顺序(顺序到位则可以类似俄罗斯方块那样完美对齐)
如: int _num; double _price; int为4字节,double为8字节,但是为了内存对齐,int会单独放在8字节的一行
所以总共占16字节。
除数组外,其他类型的数据成员中,以较大的数据成员所占空间的倍数去对齐。
指针数据成员
类的数据成员中有指针时,意味着创建该类的对象时要进行指针成员的初始化,需要申请堆空间。
在初始化列表中申请空间,在函数体中复制内容。
Computer(const char * brand, double price)
: _brand(new char[strlen(brand) + 1]())
, _price(price)
{
strcpy(_brand,brand);
}
//还得回收空间,有请析构函数↓
对象的销毁
-
对象在销毁时,一定会调用析构函数(自动)
-
析构函数的作用:<span style=color:red;background:yellow>清理对象的数据成员申请的资源(堆空间)</span>;
不过析构函数并不负责清理数据成员(系统自动完成)
-
析构函数形式:
- 仅仅就是~xxx(){}
- 无返回值,无参数,函数名与类名相同
-
析构函数只能有一个,不能重载
-
析构函数默认情况下 ,系统也会自动提供一个
自定义析构函数
ps:析构函数(destructor),与构造函数(constructor)对应
实际上是一个清理“数据成员申请的堆空间”的接口
使用场景是,数据成员中包含指针时,默认的析构函数不够用了,得自定义该析构函数去堆空间上回收。
如:
//存在一个char *_brand;
~a(){
if(_brand){
delete [] _brand;
_brand = nullptr;//必须设为空指针,安全回收,否则发生double free错误
}
}
注意:对象被销毁,一定会调用析构函数;
**调用了析构函数,对象并不会被销毁。**小明接到了被开除的通知,但是他现在还坐在自己的座位上,别人还能找到他。 座位上有人 ≠ 没被开除 ≠ 座位上现在坐的就是小明
另外别作死手动调用析构函数!
析构函数的调用时机(重点)
- 对于全局对象,**整个程序结束时**,自动调用全局对象的析构函数。
- 对于局部对象,在**程序离开局部对象的作用域**时调用对象的析构函数。
- 对于静态对象,在**整个程序结束时**调用析构函数。
- 对于 堆对象,<span style=color:red;background:yellow>在使用 delete 删除该对象时,调用析构函数。</span>
- 如一个数组指针指向”apple”,会先做掉apple,再做掉数组指针(数据成员)
同类型对象的复制
拷贝构造函数
实际上是想实现类似于:
Student stu1(1,2);
Student stu2 = stu1;
定义
形式是固定的:
<span style=color:red;background:yellow>**类名(const 类名 &) **</span>
如 Student(const Student &)
- 这玩意也是个构造函数
- 这玩意用一个已经存在的同类型的对象,来初始化新对象,即对对象本身进行复制
编译器也会默认提供一个构造函数:
Point(const Point & rhs)
: _ix(rhs._ix)
, _iy(rhs._iy)
{}
但是涉及到数据成员有指针的情况,就不妙咯(反正跟指针相关总没好事)
如果是默认的拷贝构造函数,b会对a的_brand进行**浅拷贝**,指向同一片内存;b被销毁时,会调用析构函数,将这片堆空间进行回收;a再销毁时,析构函数中又会试图回收这片空间,出现double free问题
所以该情况,在自定义的拷贝构造函数中要换成深拷贝的方式,即: 先申请空间,再复制内容
a::a(const a & rhs)
: _brand(new char[strlen(rhs._brand) + 1]())//这里
, _price(rhs._price)
{
strcpy(_brand, rhs._brand);
}
拷贝构造函数的调用时机
-
使用一个已存在的对象,初始化另一个同类型的新对象(经典情况)
-
作为函数参数(实参形参都是对象),实参初始化形参,如:
void func(Point & p){//使用引用作为参数避免不必要的拷贝 p.print(); } //调用 Point p1(1,8); func(p1);//实际上就是p1作为参数传输,实现p1.print()
-
作为函数返回值时(编译器有优化)
Point p1(7,8);//避免多余拷贝,这里目的是确保返回值的生命周期大于函数的 Point & func(){ return p1; } func();
拷贝构造函数的形式探究
总结就是引用符号跟const都不能去掉。
-
引用符号
首先编译器不允许这样写,直接报错
如果拷贝函数的参数中去掉引用符号,进行拷贝时调用拷贝构造函数的过程中会发生“实参和形参都是对象,用实参初始化形参”(拷贝构造第二种调用时机),会再一次调用拷贝构造函数。形成递归调用,直到栈溢出,导致程序崩溃。
-
const
不会报错
-
不加的话,可以修改右操作数(rhs)的数据成员。且不能绑定临时变量(右值)
-
加的话(延长右值生命周期),能绑定左值,也能绑定右值
-
赋值运算符函数
形式:
<span style=color:red;background:yellow>类名 &operator=(const 类名 &rhs)</span>
括号内跟拷贝构造函数相同
编译器自动提供的赋值运算符函数:
Point & Point::operator=(const Point & rhs)
{
_ix = rhs._ix;
_iy = rhs._iy;
}
this指针
this指针的本质是一个常量指针 Type* const pointer
;
它储存了调用它的对象的地址,不可被修改。这样成员函数才知道自己修改的成员变量是哪个对象的。
this是一个隐藏的指针,可以在类的成员函数中使用,它可以用来指向调用对象。当一个对象的成员函数被调用时,编译器会隐式地传递该对象的地址作为 this 指针。
-
this指针存在位置
编译器在生成程序时加入了获取对象首地址的相关代码,将获取的首地址存放在了寄存器中,这就是this指针。
-
this指针的生命周期
this 指针的生命周期开始于成员函数的执行开始,结束于成员函数的执行结束(函数返回)。
-
如果成员函数是通过一个已经销毁或未初始化的对象调用的,this指针将是悬挂的,它的使用将会是未定义行为。
理解以下问题:
- 对象调用函数时,是如何找到自己本对象的数据成员的? —— 通过this指针
- this指针代表的是什么? —— 指向本对象
- this指针在参数列表中的什么位置? —— 参数列表的第一位(默认自动加入,不要手动写出)
- this指针的形式是什么? —— 类名 * const this (常量指针)
Point & operator=(const Point & rhs){
this->_ix = rhs._ix;
this->_iy = rhs._iy;
cout << "Point & operator=(const Point &)" << endl;
return *this;
}
成员函数中可以加上this指针,但是不要在参数列表中显式加上this指针,因为编译器一定会在参数列表第一位加上this指针。
定义
四步走:
- 考虑自赋值问题
- 内存泄漏:如果赋值操作涉及到动态分配的内存,直接覆盖当前的内存可能会导致原有的内存未被释放,从而引发内存泄漏。
- 对象状态损坏:如果对象内部状态的修改方式不适合自赋值,可能会使对象处于不一致或无效的状态。 解决方法:加上一个判断if(this != &rhs)
- 回收左操作数的数据成员原本申请的堆空间
- delete
- 不先delete会内存泄漏
- 深拷贝(以及其他的数据成员的赋值)new,strcpy
- 返回*this(本对象)
Computer & operator=(const Computer & rhs){
if(this != &rhs){
delete [] _brand;
_brand = new char[strlen(rhs._brand)]();
strcpy(_brand,rhs._brand);
_price = rhs._price;
}
return *this;
}
形式探究
- 赋值运算符函数的返回必须是一个引用吗?
- 不是的话会增加一次拷贝(符合拷贝构造函数的第三种调用时机)
- 赋值操作符函数的返回类型可以是void吗?
- 无法处理连续赋值
-
赋值操作符函数的参数一定要是引用吗?
- 不是的话会增加一次拷贝(符合拷贝构造函数的第二种调用时机)
-
赋值操作符函数的参数必须是一个const引用吗?
-
无法避免在赋值运算符函数中修改右操作数的内容,不合理
-
而且不能处理通过右值属性的对象进行赋值的情况
-
三合成原则
<span style=color:red;background:yellow>三合成原则</span>很容易在面试时被问到:
拷贝构造函数、赋值运算符函数、析构函数,如果需要手动定义其中的一个,那么另外两个也需要手动定义。
特殊的数据成员
常量,引用,类对象,静态
常量数据成员
- 一经初始化,该数据成员便具有“只读属性”,在程序中无法对其值修改。
- <span style=color:red;background:yellow>const数据成员需在初始化列表中进行初始化</span>。事实上,在构造函数体内对const 数据成员赋值是非法的(C++11之后也允许在声明时就初始化)。
class Point {
public:
Point(int ix, int iy): _ix(ix) , _iy(iy) {}
private:
const int _ix;
const int _iy;
};
引用数据成员
<span style=color:red;background:yellow>引用数据成员在初始化列表中进行初始化</span>(C++11之后允许在声明时初始化(绑定))
class Point {
public:
Point(int ix, int iy): _ix(ix), _iy(iy), _iz(_ix) {}
private:
int _ix;
int _iy;
int & _iz;
};
引用成员需要绑定一个已经存在的、且在这个引用成员的生命周期内始终有效的变量(对象)。就是不能用接收的参数初始化引用成员
对象成员
<span style=color:red;background:yellow>对象成员在初始化列表中进行初始化。</span>
注意:
- 初始化列表中写的是需要被初始化的对象成员的名称,而不是对象成员的类名。
- 不能在声明对象成员时直接使用有参构造去创建。
class Line {
public:
Line(int x1, int y1, int x2, int y2)
: _pt1(x1, y1) //Point _pt1(x1,y1)
, _pt2(x2, y2)//若无显式调用构造函数,即自动调用无参构造
{
cout << "Line(int,int,int,int)" << endl;
}
private:
Point _pt1;
Point _pt2;
};
注意:
如果在Line类的构造函数的初始化列表中没有显式地初始化Point类对象成员,编译器会自动去调用Point类型的默认无参构造;
如果不想用Point的无参构造,那么必须在Line类的初始化列表中对Point类的对象成员进行初始化
有对象成员的结构下,构造函数和析构函数的调用顺序:
此例子中,创建一个Line类的对象,会首先调用Line的构造函数,在此过程中调用Point的构造函数完成Point类对象成员的初始化;
Line对象销毁时会先调用Line的析构函数,析构函数执行完后,再调用Point的析构函数。 简单来说就是Line的构造函数调用了Point构造函数,所以先point再line。而Line对象销毁时析构是先line再point
如下:尽管Point构造函数的调用提示在Line的构造函数之前,但这仅仅是因为Point的构造函数在Line的初始化列表中调用,执行完Point的构造函数之后,才会执行Line构造函数的函数体中的内容。
**一个Line对象中包含两个Point对象,称为成员子对象。**
静态数据成员
静态数据成员存储在全局/静态区,并不占据对象的存储空间。
static成员是在创建对象之前被创建并初始化的。且其实例只有一个,被所有该类的对象共享,就像住在同一宿舍里的同学共享一个房间号一样。
静态成员规则:
- private的静态数据成员无法在类之外直接访问(显然)
- <span style=color:red;background:yellow>对于静态数据成员的初始化,必须放在类外</span>(一般紧接着类的定义,这是规则1的特殊情况)
- 静态数据成员初始化时不能在数据类型前面加static,在数据成员名前面要加上类名+作用域限定符
- 如果有多条静态数据成员,那么它们的初始化顺序需要与声明顺序一致(规范)
- 静态成员在访问时可以通过对象访问,也可以直接通过类名::成员名的形式(更常用)
类{
};
int 类::num = 0
特殊的成员函数
静态成员函数
静态成员函数
在某一个成员函数的前面加上static关键字,这个函数就是静态成员函数。静态成员函数具有以下特点:
(1)<span style=color:red;background:yellow>静态成员函数不依赖于某一个对象;</span>
(2)静态成员函数可以通过对象调用,但更常见的方式是**通过类名加上作用域限定符调用**;
(3)静态成员函数没有this指针;
(4)**静态成员函数中无法直接用成员的名字访问非静态的成员(数据成员、成员函数)**,只能访问静态数据成员或调用静态成员函数(因为没有this指针)。
构造函数、拷贝构造、赋值运算符函数、析构函数比较特殊,可以在静态成员函数中调用。
注:但是非静态的成员函数可以访问静态成员。
静态成员函数不能是构造函数/析构函数/赋值运算符函数/拷贝构造(因为这四个函数都会访问所有的数据成员,而static成员函数没有this指针)
const成员函数
形式:void func() const {}
特点:
-
const成员函数中,不能修改对象的数据成员;
-
当编译器发现该函数是const成员函数时,会自动将this指针设置为双重const限定的指针
对象的组织
const对象
类对象也可以声明为 const 对象,一般来说,能作用于 const 对象的成员函数除了构造函数和析构函数,就只有 const 成员函数了。因为 const 对象只能被创建、撤销和只读访问,写操作是不允许的。
const对象与const成员函数的规则:
- 当类中有const成员函数和非const成员函数重载时,const对象会调用const成员函数,非const对象会调用非const成员函数;
- 当类中只有一个const成员函数时,无论const对象还是非const对象都可以调用这个版本;
- 当类中只有一个非const成员函数时,const对象就不能调用非const版本。
总结:<span style=color:red;background:yellow>如果一个成员函数中确定不会修改数据成员,就把它定义为const成员函数。</span>
思考1:
一个类中可以有参数形式“完全相同”的两个成员函数(const版本与非const版本),既然没有报错重定义,那么它们必然是构成了重载,为什么它们能构成重载呢?
—— 参数(this指针)的类型是不同的。
思考2:
const成员函数中不允许修改数据成员,const数据成员初始化后不允许修改,其效果是否相同?举例,如果有一个普通的指针成员,在const成员函数中,它被如何限制?
对于普通类型的数据成员,const数据成员初始化后不允许修改,在const成员函数中无论是const数据成员还是非const数据成员,都不能修改值;
对于指针类型的数据成员:
const int * p,初始化之后在任何地方都不能修改其指向的值(无论在const成员函数中还是在非const成员函数中),在非const成员函数中可以修改指向,在const成员函数中不能修改指向;
int * p,在非const成员函数中可以修改指向,也可以修改值,在const成员函数中不能修改指向,可以修改指向的值。
指向对象的指针
类名 * 指针名 [初始化表达式];
Point pt(1, 2);
Point * p1 = nullptr;
Point * p2 = &pt;
Point * p3 = new Point(3, 4);
调用
p2->print();
(*p2).print();
对象数组
Point pts[2]
Point pts[] = {Point(1, 2), Point(3, 4)};
Point pts[5] = {Point(1, 2), Point(3, 4)};
//或者
Point pts[2] = {{1, 2}, {3, 4}};
Point pts[2] = {{1, 2}, {3, 4}};
pts->print(); //(1,2)
(pts + 1)->print(); //(3,4)
Point pts[] = {{1, 2}, {3, 4}};
Point pts[5] = {{1, 2}, {3, 4}};
堆对象
new/delete为对象分配动态存储区
new/delete表达式的工作步骤(了解)
new表达式的工作步骤
对于自定义类型而言:
<span style=color:red;background:yellow>使用new表达式时发生的三个步骤</span>:
-
调用operator new标准库函数申请未类型化的空间
-
在该空间上调用该类型的构造函数初始化对象
-
返回指向该对象的相应类型的指针
delete表达式的工作步骤
对于自定义类型而言:
<span style=color:red;background:yellow>使用delete表达式时发生的两个步骤</span>:
-
调用析构函数,回收数据成员申请的资源(堆空间)
-
调用operator delete库函数回收本对象所在的空间
//默认的operator new
void * operator new(size_t sz){
void * ret = malloc(sz);
return ret;
}
//默认的operator delete
void operator delete(void * p){
free(p);
}
创建对象的探究
对象自动提供默认的operator new / operator delete函数。
-
**创建堆对象需要什么条件?**
需要公有的operator new、operator delete、构造函数。
而对析构函数则没有要求,销毁堆空间时才会调用析构函数
Student *stu = new Student(100,"haha"); stu->print();
-
**创建栈对象需要什么条件?**
需要公有的构造函数、析构函数。
而对operator new/operator delete没有要求。
Student stu(100, "bob");
根据探究得出的结论,仍以Student类为例,想要实现以下需求
-
只能生成栈对象 , 不能生成堆对象
可以将operator new/operator delete 设为私有。(主要还是new与delete)
-
只能生成堆对象 ,不能生成栈对象
可以将析构函数设为私有。
在堆上创建单例对象
#include <iostream>
using std::cout;
using std::endl;
class Point {
public:
static Point *getInstance() {
if (_PInstance == nullptr) {
_PInstance = new Point(1, 2);
}
return _PInstance;//第一次后返回的都是指向第一次创建的对象
}
void init(int x, int y) {//只是赋值
_ix = x;
_iy = y;
}
static void destroy() {
if (_PInstance) {
delete _PInstance;
_PInstance = nullptr;
}
cout << "heap deleted" << endl;
}
void print(){
cout<<"Address: "<<this<<endl;
cout<<"x: "<<_ix<<endl<<"y: "<<_iy<<endl;
}
private:
Point(int x, int y) : _ix(x), _iy(y) { cout << "构造函数调用" << endl; }
~Point() { cout << "析构函数调用" << endl; }
// 将自带的成员函数从类中删除
Point(Point &rhs) = delete;
Point &operator=(const Point &rhs) = delete;
// 俩变量
int _ix;
int _iy;
static Point *_PInstance; // 用指针保存第一次创建的对象
};
Point *Point::_PInstance = nullptr;
int main(void) {
Point::getInstance()->init(1, 2);
Point::getInstance()->print();
Point::getInstance()->init(4,6);
Point::getInstance()->print();
Point::destroy();
return 0;
}
存在数组指针的话:
void init(const char* newName, int newPrice){
delete [] _name;//先delete
_name = new char[strlen(newName+1)]; //再开辟
strcpy(_name, newName);//赋值
_price = newPrice;
}
单例模式的应用场景
1、有频繁实例化然后销毁的情况,也就是频繁的 new 对象,可以考虑单例模式;
2、创建对象时耗时过多或者耗资源过多,但又经常用到的对象;
3、当某个资源需要在整个程序中只有一个实例时,可以使用单例模式进行管理(全局资源管理)。例如数据库连接池、日志记录器等;
4、当需要读取和管理程序配置文件时,可以使用单例模式确保只有一个实例来管理配置文件的读取和写入操作(配置文件管理);
5、在多线程编程中,线程池是一种常见的设计模式。使用单例模式可以确保只有一个线程池实例,方便管理和控制线程的创建和销毁;
6、GUI应用程序中的全局状态管理:在GUI应用程序中,可能需要管理一些全局状态,例如用户信息、应用程序配置等。使用单例模式可以确保全局状态的唯一性和一致性。
C++字符串
字符串处理在程序中应用广泛, C 风格字符串是以 ‘\0’ (空字符)来结尾的字符数组,在C++中通常用const char * 表示,用“ ”包括的认为是C风格字符串。
C风格字符串操作函数:
//字符检查函数(非修改式操作)
size_t strlen(const char *str);//返回str的长度,不包括null结束符
//比较lhs和rhs是否相同。lhs等于rhs,返回0; lhs大于rhs,返回正数; lhs小于rhs,返回负数
int strcmp(const char *lhs, const char *rhs);
int strncmp(const char *lhs, const char *rhs, size_t count);
//在str中查找首次出现ch字符的位置;查找不到,返回空指针
char *strchr(const char *str, int ch);
//在str中查找首次出现子串substr的位置;查找不到,返回空指针
char *strstr(const char* str, const char* substr);
//字符控制函数(修改式操作)
char *strcpy(char *dest, const char *src);//将src复制给dest,返回dest
char *strncpy(char *dest, const char *src, size_t count);
char *strcat(char *dest, const char *src);//concatenates two strings
char *strncat(char *dest, const char *src, size_t count);
不好用啊
C++风格字符串
string 作为一个类出现,其集成的成员操作函数功能强大,几乎能满足所有的需求。从另一个角度上说,<span style=color:red;background:yellow>完全可以把 string 当成是 C++ 的内置数据类型,放在和 int 、 double 等内置类型同等位置上。</span>
string的构造
string();//无参构造函数,生成一个空字符串
string(const char * rhs);//通过c风格字符串构造一个string对象
string(const char * rhs, size_type count);//通过rhs的前count个字符构造一个string对象
string(const string & rhs);//拷贝构造函数
string(const string & rhs,size_t pos, size_t count);//通过string对象的一部分创建新的string
string(size_type count, char ch);//生成一个string对象,该对象包含count个ch字符
string(InputIt first, InputIt last);//以区间[first, last)内的字符创建一个string对象
还能直接拼接
string str3 = str1 + str2;
string str4 = str2 + ',' + str3;
string str5 = str2 + ",world!";