👍作者主页:进击的1++
🤩 专栏链接:【1++的C++初阶】
一,面向对象与面向过程
C语言是面向过程的语言,关注的是过程,解决问题的步骤;而C++是面向对象的语言,关注的是对象之间的交互。例如,泡泡面,在C语言中,我们关注的泡泡面的步骤----打开泡面-放入调料包-倒入热水-等待;在C++中关注的是:泡面,热水两个对象之间的交互。我们不需要关注泡泡面的具体步骤。
二,类
2.1 类的定义
class Person
{
int age;
char name;
int infor;
int* _a;
};
以上述代码为例:class为定义类的关键字,(也可以是struct, 在C++中,struct上升为类)Person为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省 略。类中的内容称为类的成员,类中定义的变量称为成员变量,类中的函数称为成员函数或方法。 成员函数的定义有两种方式:一种是定义在类中的,这时,编译器可能将其当作内联函数处理;另一种是定义与声明分离,声明在类中,定义在另一个.cpp文件中。
2.2 类的访问限定符
访问限定符有:public,private,protected。其中被public修饰的成员,能够在类外被直接访问,而被private,protected修饰的成员在类外不能直接被访问。要注意的是,struct定义的类的默认访问权限是public。而class默认的访问权限是private。
示例代码:
class Person
{
public:
void add()
{
int* a = (int*)malloc(sizeof(int) * 20);
assert(a);
_a = a;
cout <<"add" << endl;
}
private:
int age;
char name;
int infor;
int* _a;
};
2.3 封装
2.3.1 什么是封装
将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
2.3.2 封装的作用
封装的实质就是一种管理,方便对类的使用。
2.4 类与对象
类的实例化实际就是用类创建对象的过程。 类是对对象进行描述的,相当于在建房子时的一张图纸,而实例化出的对象则是建好的房子,因此。只有对象才占用实际的物理空间。 每个对象中成员变量是不同的,但调用的都是同一份函数,因此,成员函数会放在一个公共区域中,避免浪费空间,因此在计算对象的大小时,只需计算成员变量的大小,但要记得内存对齐。空类实例化的对象比较特殊,有一个字节大小。
三,this指针
3.1 什么是this指针
C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参 数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该
指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。通俗来说,就是编译器通过this指针将传对象地址这一步替我们做了。
3.2 this指针的特性
- this指针实际是一个指向对象的指针,因此它的类型为对象的类型+*const。即this指针是不能赋值的。
- this指针只能在成员函数内部使用。
- this指针是形参,所以它储存在函数栈帧里。
- 当在成员函数中不去访问成员变量时,this指针是可以为空的。
下图,我们可以看到程序崩溃
四,类的六个默认成员函数
4.1 什么叫默认成员函数
默认成员函数就是用户没有显式定义,编译器会生成的成员函数。有以下六个默认成员函数:1,构造函数:完成初始化工作;2,析构函数:完成销毁清理工作;3,拷贝构造:使用同类对象初始化创建对象;4,赋值重载:主要是把一个对象赋值给另一个对象;5,6,取地址及const取地址操作符重载。 因此,在空类中,并不是什么都没有,编译器会自动生成这些默认成员函数。
4.2 构造函数
构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有 一个合适的初始值,并且在对象整个生命周期内只调用一次。
以下代码为例:
class Date
{
public:
Date()
{
_year = 2023;
_month = 5;
_day = 3;
}
Date(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(2022, 4, 21);
return 0;
}
运行结果:
在上述代码中,我们定义了两个构造函数,一个是无参的,一个则是有参的,要注意的是对于无参的写法,不可以写为Date d1(),这样编译器会将其当做声明。并且,一但用户显式定义构造函数,编译器便不再自动生成,因此,不论你写了一个带参数还是不带参数的构造函数,就会认定为你显式定义了,编译器不会再生成。
如下图:
当我们不显式定义构造函数,而是让编译器去自动生成。在下图,我们就会发现,d1对象依旧是随机值。
这是为什么呢?在C++中,把类型分为了内置类型和自定义类型。内置类型就是语言本身提供的数据类型,就像int char float等,自定义类型,就是用class,struct,union自己定义的类型。在C++11之前,编译器生成的默认构造函数只对自定义类型成员会去自动调用它的构造函数,在C++11之后,打了一个补丁,内置类型成员,可以在类中声明时给默认值。
示例代码如下:
class Time
{
public:
Time()
{
tt = 2104;
}
private:
int tt;
};
class Date
{
public:
Date()
{
_year = 2023;
_month = 5;
_day = 3;
}
/*Date(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
}*/
private:
int _year;
int _month;
int _day;
Time t;
};
int main()
{
Date d1;
//Date d2(2022, 4, 21);
return 0;
}
运行结果:
无参的构造函数,全缺省的构造函数,编译器自己生成的默认函数。都称为默认函数。并且只能出现一种默认构造函数。不然将会出现下图的结果:
4.3 析构函数
析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。 析构函数没有返回值,没有参数,而且不能重载。
以下代码为例:
class SList
{
public:
SList()
{
a = (int*)malloc(sizeof(int)*4);
assert(a);
sz = 0;
capacity = 4;
}
~SList()
{
free(a);
a = NULL;
}
void SLPush(int x)
{
a[sz] = x;
sz++;
}
private:
int* a;
int sz;
int capacity;
};
int main()
{
SList l1;
l1.SLPush(1);
l1.SLPush(1);
l1.SLPush(1);
return 0;
}
在这段代码中,编译器在程序结束时自动调用析构函数,进行对象的清理。
再来看一段代码:
class Time
{
public:
~Time()
{
cout << "Time()" << endl;
}
};
class Date
{
private:
int _day;
int _month;
int _year;
Time t;
};
int main()
{
Date d1;
return 0;
}
运行结果:
在这段代码中,我们显式定义了Time类的析构函数,而没有显式定义Date类的析构函数,但是,我们通过观察运行结果发现,创建的Date类对象在销毁是,调用了Time类的析构函数。这是为什么呢?首先,对于Date对象里的内置变量来说,它们是不需要析构函数的,程序结束时,系统会直接回收其空间。对于自定义成员,编译器会调用Date类的析构函数,而Date类的析构函数则会调用Time类的析构函数。Date类的析构函数是未显式定义的,因此,编译器会为Date类创建一个默认的析构函数。
注意:
如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。
4.4 拷贝构造函数
4.4.1 什么是拷贝构造
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类的类型对象创建新对象时由编译器自动调用。拷贝构造是构造函数的一种重载。而且拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错。
4.4.2 为什么在传参数时不能传值
在函数进行传值调用时,会将实参进行拷贝形成形参。在拷贝构造中,若传值,则会引发对象的拷贝。因为,形参需要对实参拷贝。也就是说,我们本是想拷贝一个对象,但是在传值传参的时候又需要去拷贝实参这个对象,又绕了回去,所以,最终会形成死递归。
示例代码如下:
class Date
{
public:
Date()
{
}
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void modeify(int day)
{
_day = day;
}
private:
int _day=1;
int _month=2;
int _year=3;
};
int main()
{
Date d1;
Date d2(d1);
d1.modeify(2);
//Date d2(d1);
return 0;
}
若是将&去掉则为传值,运行结果如下:
若没有显式定义,则编译器也会生成默认拷贝构造函数,默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
再来看一段代码:
class SList
{
public:
SList()
{
a = (int*)malloc(sizeof(int) * 4);
sz = 0;
capacity = 4;
}
~SList()
{
free(a);
a = NULL;
}
void SLPush(int x)
{
a[sz] = x;
sz++;
}
private:
int* a;
int sz;
int capacity;
};
int main()
{
SList l1;
l1.SLPush(1);
l1.SLPush(2);
l1.SLPush(3);
SList l2(l1);
return 0;
}
在这段代码中,我们的拷贝构造函数是编译器自动生成的,运行这段代码后我们发现程序崩溃了,但将析构函数屏蔽后,发现能够运行。这是为什么呢?
因为默认拷贝是值拷贝,原封不动的将l1中的内容拷贝到l2中,所以,l1与l2两个对象的a,共同指向一片空间。因此,当程序结束时,析构函数先将l2的资源清理掉,当再清理l1的资源是会发现a已将被清理,一块空间多次被清理,程序便会崩溃。
类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,并且需要用到深拷贝。以后我们会说到。
4.5 赋值运算符重载
4.5.1 运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。其函数名为:operator后面接需要重载的运算符号。
我们以==这个运算符为例,代码如下:
class Date
{
public:
Date(int year = 2023, int month = 5, int day = 6)
{
_year = year;
_month = month;
_day = day;
}
bool operator==(const Date& d2)
{
return _year == d2._year && _month == d2._month
&& _day == d2._day;
}
private:
int _day;
int _month;
int _year;
};
int main()
{
Date d2;
Date d1;
cout << (d1 == d2) << endl;
return 0;
}
在这段函数中,我们将==赋予特殊的含义,使得其能够用作类之间的比较。
运算符重载还需注意以下这些:
- 不能通过连接其他符号来创建新的操作符:比如operator@。
- 重载操作符必须有一个类类型参数。
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义.
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐藏的this。
- .* :: sizeof ?: . 注意以上5个运算符不能重载。
4.5.2 赋值运算符
我们来看一段代码:
class Date2
{
public:
//构造函数,负责初始化
Date2(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//赋值运算符重载
Date2& operator=(const Date2& d)
{
if(this!=&d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
}
//拷贝构造
Date2(const Date2& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//打印
void Print()
{
cout << _year << endl;
cout << _month << endl;
cout << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date1 d2;
Date1 d1;
//cout << (d1 == d2) << endl;
Date2 d3(2023,4,4);
//Date2 d4(2023,5,5);
//d3 = d4;//赋值
Date2 d4(d3);//拷贝
Date2 d5 = d3;//拷贝
d3.Print();
d4.Print();
return 0;
}
在上段代码中,赋值运算符的重载函数,我们用引用返回来提高了代码的效率,并且避免了自己给自己赋值的情况。并且要区分拷贝与赋值。拷贝是用已存在的类型的对象,去创建一个相同的新对象;而赋值是两个都已经存在的对象,一个给另一个赋值。要注意的是,Date2 d5 = d3;不是赋值,而是拷贝。
赋值运算符只能重载成成员函数,不能重载成全局函数,因为当类中为显式定义赋值运算符重载时2,编译器会默认生成一个。就会发生冲突吧。
注意:编译器默认生成的赋值运算符重载是以值的方式逐字拷贝。其中内置类型是直接拷贝,而自定义是调用其对应的赋值运算符重载完成。但是如果涉及到资源的管理,就不能用默认生成的赋值运算符重载,而得用到深拷贝。
4.5.3 前置++与后置++
我们来直接看代码:
class Date2
{
public:
//构造函数,负责初始化
Date2(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//赋值运算符重载
Date2& operator=(const Date2& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
return *this;
}
//拷贝构造
Date2(const Date2& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
//打印
void Print()
{
cout << _year << endl;
cout << _month << endl;
cout << _day << endl;
}
//前置++
Date2& operator++()
{
_day += 1;
return *this;
}
//后置++
Date2 operator++(int)
{
Date2 tmp(*this);
_day++;
return tmp;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date2 d3(2023,4,4);
Date2 d4(2023,5,5);
d4=++d3;
d4 = d3++;
d4.Print();
d3.Print();
return 0;
}
运行结果:
通过对运行结果的观察,我们发现代码符合我们的预期,但要注意的是为了区别前置与后置,我们在后置时需要加一个整型的参数,这个参数的作用就是为例区别前置与后置,再无其他作用。
还要注意的是前置我们使用的是引用做返回值,因为*this指向的对象不会在函数结束后销毁。而,后置中在函数内定义的对象,会在函数结束后也销毁,因此不能用引用做返回值。
4.6 取地址及其const 取地址重载
4.6.1 const 成员
在讲取地址重载前,我们先需要补充const 成员的相关知识。
将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
我们来看下面这段代码:
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << " " << _month << " " << _day << endl;
}
void Print()const
{
cout << _year << " " << _month << " " << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 5, 9);
const Date d2(2023, 5, 5);
d1.Print();
d2.Print();
return 0;
}
当我们讲const成员函数屏蔽后,结果如下:
造成这样的结果是由于d2的类型为const Date,而要是没有const修饰成员函数Print(),则其隐含的this指针的类型为Date* ,const Date*—>Date* 是权限的扩大,是不允许的,因此会报错;权限的缩小和平移是允许的。
4.6.2 取地址及其const 取地址重载
我们来看代码:
Date* operator&()
{
return this;
}
const Date* operator&()const
{
return this;
}
上述两个重载函数,一般是不需要我们去写的,由编译器默认生成。若我们不想让别人获取某些内容,才需要我们去写。
五,再谈构造函数
5.1 构造函数体赋值
我们在前面所写的普通构造,虽然这种构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。
5.2 初始化列表
初始化列表才真正意义具有初始化功能,并且,初始化列表是构造函数的一部分。未显式定义时,会默认生成。
初始化列表:以一个冒号开始,接着是一个以逗号分隔的数据成员列表,每个"成员变量"后面跟一个放在括号中的初始值或表达式。
还需要注意的是:
- 每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次)
- 类中包含以下成员,必须放在初始化列表位置进行初始化:
引用成员变量
const成员变量
自定义类型成员(且该类没有默认构造函数时,有参数的构造都不是默认构造)
来看以下代码:
class A
{
public:
A(int a)//带有形参的构造函数,不是默认构造函数
:t(a)
{}
private:
int t=1;
};
class B
{
public:
B(int i, int& j)
:a(j)
, b(i)
, _t(i)//若自定义类型会自动调用其默认构造
{} //但由于上述显式的构造为非默认构造,则无法调用
private:
int& a;
const int b;
A _t;
};
int main()
{
int n = 10;
B b1(3,n);
return 0;
}
注意:初始化列表的初始化顺序是类中成员变量声明的顺序。
5.3 explicit关键字
构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值的构造函数,还具有类型转换的作用。
看以下这段代码:
class Date
{
public:
Date(int year,int month=5,int day=9)
:_year(year)
,_month(month)
,_day(day)
{}
void Print()
{
cout << _year << " " << _month << " " << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1 = 2023;
d1.Print();
return 0;
}
在这段代码中,我们注意到Date d1 = 2023;这样一句奇怪的代码,那么实际这句代码执行了什么呢?
在这句代码中,编译器自动将整型转换为Date类对象,实际等同于下面的操作:
Date d1(2023);
//或者
Date tmp(2023);
Date d1= tmp;
就是先用2023构造一个匿名的对象,然后再进行拷贝构造,但是,大多数编译器为了效率,会进行优化,就变为直接用2023构造。
加上explicit后,这种隐式转换就不能用了。
六,static成员
什么是static成员:
声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。且静态成员变量一定要在类外进行初始化。
来看以下代码:
class A
{
public:
A()
{
_count++;
cout << "A()" << endl;
}
A(const A& d)
{
_count++;
cout << "A(const A&d)" << endl;
}
~A()
{
_count--;
cout << "~A()" << endl;
}
static int Get_count()
{
return _count;
}
private:
int _a;
static int _count;
};
int A::_count = 0;
void func()
{
A d3;
}
int main()
{
A d1;
A d2(d1);
func();
cout <<A::Get_count() << endl;
return 0;
}
运行结果:
static成员特性:
- 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区。
- 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明。
- 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问。
- 静态成员函数没有隐藏的this指针,不能访问任何非静态成员。
- 静态成员也是类的成员,受public、protected、private 访问限定符的限制。
七,友元
来看下面这段代码:
class Date
{
friend ostream& operator<<(ostream& _cout, const Date& d);
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
//重载运算符<<
ostream& operator<<(ostream& _cout, const Date& d)
{
_cout << d._year << " " << d._month << " " << d._day << endl;;
return _cout;
}
int main()
{
Date d1(2023, 5, 11);
cout << d1 << endl;
return 0;
}
如果将<<的重载函数定义为成员函数,由于cout的输出流对象和隐含的this指针会抢占第一个参数的位置。因此不能这样定义,只能将重载函数定义在类外,但是,定义在类外后将无法访问私有成员变量,就有了友元函数,在类中将函数声明为友元函数,就能在类外访问类中的私有变量。
通过上述代码,我们能够有以下总结:
- 友元函数可访问类的私有和保护成员,但不是类的成员函数
- 友元函数不能用const修饰(无this指针)
- 友元函数可以在类定义的任何地方声明,不受类访问限定符限制
- 一个函数可以是多个类的友元函数
- 友元函数的调用与普通函数的调用原理相同
/
运行结果:
在上述图中,我们在类A中将Date声明为友元类,则在Date类中就可以访问A类中的私有成员。
友元类还要注意下述说明:
- 友元关系是单向的,不具有交换性。
比如上述Time类和Date类,在A类中声明Date类为其友元类,那么可以在Date类中直接访问Time - 类的私有成员变量,但想在A类中访问Date类中私有的成员变量则不行。
- 友元关系不能传递
如果B是A的友元,C是B的友元,则不能说明C时A的友元。 - 友元关系不能继承,在继承位置再给大家详细介绍
八,内部类
概念:如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外
部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问权限。
注意:内部类就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。
特性:
- 内部类可以定义在外部类的public、protected、private都是可以的。
- 注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名。
- sizeof(外部类)=外部类,和内部类没有任何关系。