目录
场景2:类对象作为另一个类的成员变量,同时该类没有提供无参构造函数
上节我们学习了C++开发概述,以及C和C++在语法上的一些区别,本节开始学习类和对象。
什么是面向对象?
object-oriented
面向将系统看成通过交互作用来完成特定功能的对象的集合,每个对象用自己的方法(这里的方法指的就是函数)来管理数据(这里的数据指的是结构体里面的成员)。也就是说只有对象内部的代码能够操作对象内部的数据(之前在C语言中我们是可以在结构体外操作成员的,但是在C++中是不可以的)。
所谓对象,我们可以把它理解为变量,比如int a;这个a就是一个对象,对象就是实实在在的一个东西,如果更具体一点,它其实指的是结构体变量,而这个s1在内存中是要占内存的,所以它就是一个实实在在的一个实例。
面向对象的特点
抽象
抽象是人们认识事物的一种方法;
抓住事物的本质,而不是内部具体细节或者具体实现。
(比如是做学生管理系统的时候,每个学生都有姓名和学号这些属性)
封装
封装是指按照信息屏蔽的原则,把对象的属性和操作结合在一起,构成一个独立的对象。
通过限制对属性和操作的访问权限,可以将属性“隐藏”在对象内部,对外提供一定的接口,在对象之外只能通过接口对对象进行操作。
封装增加了对象的独立性,从而保证了数据的可靠性。
外部对象不能直接操作对象的属性,只能使用对象提供的服务。
比如我们不用关心电视机内部的实现原理,我们只要学会使用遥控就行。
继承
继承表达了对象的一般与特殊的关系,特殊类的对象具有一般类的全部属性和服务。
当定义了一个类后,有需要定义一个新类,这个新类与原来的类相比,只是增加或者修改了部分属性和操作,这时可以使用原来的类派生出新类,新类中只需要描述自己特有的属性和操作。
继承大大简化了对问题的描述,大大提高了程序的可重用性,从而提高了程序的设计、修改、扩充的效率。
多态
同一个消息被不同的对象接收时产生不同的结果,即实现了同一接口,不同方法。
一般类中定义的属性和服务,在特殊类中不改变其名字,但通过各自不同的实现后,可以具有不同的数据类型或者具有不同的行为。
面向对象编程优点
易维护:可读性高,即使改变需求,由于继承的存在,修改也只是在局部模块,维护起来方便且成本低。
开发过程中可以使用现成的库,这些库经过长时间的验证,已经非常的成熟,从而可以写出更高质量的代码,出现更少的bug。
效率高:在软件开发时,根据设计需求对现实世界的事务进行抽象,产生类。用这样的方法解决问题,接近于日常生活和自然的思考方式,势必提高软件开发的效率和质量。
易扩展:由于继承、封装、多态的特性,自然设计出高内聚(比如一个函数里面尽量只实现一个功能)、低耦合(函数和函数之间应该是独立的,互相不影响)的系统钢结构,使得系统更灵活、更易扩展,而且成本更低。
面向对象编程的缺点:编码效率高,但是运行效率低于C语言。
类的封装
封装,是面向对象程序设计最基本的特性。把数据(属性)和函数(操作)合成一个整体,这在计算机世界中是用类与对象实现的。
封装,把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的类和对象进行信息隐藏。
C++中类的封装
成员变量,C++中用于表示类属性的变量
成员函数,C++中用于表示类行为的函数
演示:求圆的周长和面积。
以上是我们以前习惯的写法,现在我们可以这样写
类的访问控制
在C++中可以给成员变量和成员函数定义访问级别:
public修饰成员变量和成员函数可以在类的内部和类的外部被访问
private修饰成员变量和成员函数只能在类的内部被访问
protected用于继承,后面会讲到
没有权限修饰的成员和函数默认是private的
C++中类和结构体的区别
class & struct
在用struct定义类时,所有成员的默认属性为public
在用class定义类时,所有成员的默认属性为private
所以结构体和类的默认权限不一样。
类的实现方式(分成多个文件写)
类的声明放在头文件中
类的实现放在源文件中
在main函数中创建对象并且调用成员函数
代码演示:
Circle.h
Circle.cpp
Main.cpp
编译运行:
在C++里面,很多函数都是封装好了,我直接拿来用,封装在类里面,然后我们直接在主函数中按照事情解决的方法来写思维框架,这种思维逻辑就是面向对象的思维逻辑。
不像C语言很多东西都是我们自己去实现的,那是面向过程的思维。
实例:点和圆
判断圆和点的位置关系
两点之间的距离公式
点到圆心的距离公式:
完整代码:
#include <iostream>
using namespace std;
//抽象--属性
//struct circle //不再写成结构体
class circle //写成类
{
private: //私有属性,只能在类的内部访问,外部不能访问
//成员变量
int r;//半径
public: //公有属性,既能在类内部访问,也能在外部访问
//成员函数
void set_r(int m_r)//设置圆的半径
{
r=m_r;
}
void get_c()//求圆的周长
{
cout<<2*3.14*r<<endl;
}
void get_s()//求圆的面积
{
cout<<3.14*r*r<<endl;
}
};
int main()
{
//struct circle C;//创建结构体变量,可以不要"struct"
circle C;//创建对象(其实就是创建变量)
//初始化
//C.r=1;//不可以这样直接访问属性
C.set_r(1);
C.get_c();
C.get_s();
return 0;
}
运行结果:
构造函数和析构函数
我们前面写了点和圆的代码里边,每个类里面都有一个共同的特点,就是都有一个这样的做初始化工作的函数
然后我们再在类外边通过对象来调用它
如果类比较多的情况下这样就比较麻烦,有没有什么机制让每个类都能自动有这么一个初始化的程序在里面呢?
为了解决这个问题,于是就有了构造函数。
构造函数constructor
创建一个对象时,常常需要做某些初始化的工作,例如对数据成员赋初值。注意,类的数据成员是不能在声明类时初始化的。
为了解决这个问题,C++编译器提供了构造函数(constructor)来处理对象的初始化。构造函数是一种特殊的成员函数,与其他成员函数不同,不需要用户来调用它,而是在建立对象时自动执行。
所有的构造函数没有返回值,构造函数的名字要和类名保持一致
构造函数的分类
默认构造函数
无参构造函数
有参构造函数
拷贝构造函数
无参构造函数和有参构造函数
如果想要对象调用构造函数的时候输出不同的学生属性,可以用有参构造函数来实现
这两种写法都是正确的,但是我们经常写的是第一种
如果这样写的话打印出来的也是和s3的结果是一样的
那s4是怎么知道要调用我们类里面的无参构造函数还是有参构造函数的呢?
拷贝构造函数
其实它调用的是拷贝构造函数,拷贝构造函数我们前面没写,如果写出来的话是这样的:
这样就对上了。
在我们创建对象的时候都会调用构造函数,但是之前我们写的代码中,定义类的时候里面并没有写构造函数,而是通过对象来访问类的成员,那么我们创建对象的时候它是怎么解决的呢?
这就涉及到默认构造函数。
默认构造函数
就像这样我们没有写构造函数,编译后也没有报错,
因为编译器会提供默认的无参构造函数(只不过这个无参构造函数体里面是空的,只是一个壳子)。
如果我们的类里面自己写的构造函数,那么我们在创建对象的时候对象就会调用我们写的构造函数,如果不匹配就报错,不会给我们提供默认构造函数。
总结就是:一旦写了有参构造函数,不再提供默认的无参构造函数
所以我们这里必须得传一个参数过去
当我们写了有参构造函数后,那接下来这样写还会不会提供拷贝构造函数呢?
编译没有错误,说明这种情况会提供
总结就是即使写了有参构造函数,也会提供默认的拷贝构造函数
一旦写了拷贝构造函数,不再提供默认的无参构造函数。如果我们自己写了拷贝构造函数,对象在调用的时候就会匹配我们写的拷贝构造函数,如果不匹配就报错。
构造函数调用规则
当类中没有定义任何一个构造函数时,c++编译器会提供默认无参构造函数和默认拷贝构造函数
当类中定义了拷贝构造函数时,c++编译器不会提供无参数构造函数
当类中定义了任意的非拷贝构造函数(即:当类中提供了有参构造函数或无参构造函数),c++编译器不会提供默认无参构造函数
默认拷贝构造函数成员变量简单赋值
只要你写了构造函数,那么程序必须会调用!
完整代码:
跟构造函数相反,构造函数主要是做一些初始化的工作,析构函数主要做一些收尾的工作。
析构函数destructor
C++中的类可以定义一个特殊的成员函数清理对象,这个特殊的成员函数叫做析构函数
语法:
~ClassName()
析构函数没有参数也没有任何返回类型的声明
析构函数在对象销毁时自动被调用(也就是程序结束的时候,对象被释放,就会自动调用这个析构函数)
完整代码:
浅拷贝和深拷贝
浅拷贝copy-constructor
在以下这段代码中程序会有以下错误
a1和a2中的data都指向0x100,最后析构函数要释放的都是这个空间(a1的析构函数最后是将a1的data置为空,而a2的析构函数中判断的data是不为空的,还是存放的被释放的那块空间的地址),释放两次的话程序就出错,一个空间只能被释放一次。
要想解决这个问题我们应该保证a1和a2的数据是一样的,但是空间不应该是一样的。这就涉及到深拷贝deep-constructor的概念。
深拷贝deep-constructor
总结:凡是涉及到地址操作的时候,一般都要重新写拷贝构造函数,即用采用深拷贝。
用数据验证一下,完整代码
#include <iostream>
#include <cstring>
using namespace std;
class Array
{
private:
int*data;//这个空间要自己申请
int size;//这个变量需要外部传进来
public:
Array(int s);//初始化工作在构造函数中完成
Array(const Array &a);
void set_val(int idx,int val);
int get_val(int idx);
~Array();
};
Array::Array(int s)
{
cout<<"Array有参构造函数"<<endl;
size=s;
data=(int*)malloc(sizeof(int)*size);
}
Array::Array(const Array &a)//将a1作为a2拷贝构造函数的参数传过来
{
//浅拷贝:做简单的赋值操作
//size=a.size;
//data=a.data;
//cout<<"拷贝构造函数"<<endl;
//深拷贝:
size=a.size;
data=(int*)malloc(sizeof(int)*size);//先另外给a2的data开辟一块空间
memcpy(data,a.data,sizeof(int)*size);//再将a1的data的拷贝过来
}
Array::~Array()
{
cout<<"Array析构函数"<<endl;
if(data)
{
free(data);
}
data=NULL;
}
void Array::set_val(int idx,int val)
{
data[idx]=val;
}
int Array::get_val(int idx)
{
return data[idx];
}
int main()
{
int i;
Array a1(10);
for(i=0;i<10;i++)
{
a1.set_val(i,i+1);
}
Array a2=a1;//用a1这个对象构造a2,调用a2的拷贝构造函数,将a1传给a2,在a2的拷贝构造函数中将a1的成员赋值给a2
for(i=0;i<10;i++)
{
cout<<a2.get_val(i)<<endl;
}
return 0;
}
:
这样两个对象的值都是一样的,并且最后释放的空间的时候也没有错误
对象初始化列表
场景1:类成员变量被const修饰
前面我们提到过,在C++中如果是在类的外面用const修饰变量,它就类似于宏定义#define效果(一旦定义好就不能被后续的程序修改,就算通过地址修改也不行),这和C语言中的用const修饰变量的作用有所区别。
但是,在C++中如果是在类里面用const修饰成员变量,那这个作用就是C语言中一样了,就是把变量转变成只读变量(只读变量一旦被定义并初始化后就不能被再次赋值,但是可以通过地址修改)。
当类的成员变量被const修饰时,不能在构造函数中对其初始化。
创建对象的步骤是:
1、申请内存并初始化(随机值);
2、调用构造函数往内存中填入需要的值。
如果类的成员变量被const修饰,一旦被初始化(随机值),之后再调用构造函数时将不能往内存里面填入需要的值,所以在编译阶段就会出错。
但是这个const对于我们的程序来说是需要的,因为我们不希望学生的学好被修改,为了解决这个问题,C++提出了对象初始化列表。
Constructor::Contructor() : m1(v1), m2(v1,v2), m3(v3)
{
// some other assignment operation
}
我们要在构造函数的声明处或者构造函数实现的地方后面加上对象初始化列表:
: id( i )的作用就是告诉编译器,在申请内存并初始化的时候先不要往里面填随机值,而是将i填入这块内存里面,之后在调用构造函数的时候就不要id=i这句了。
运行结果
场景2:类对象作为另一个类的成员变量,同时该类没有提供无参构造函数
我们前面的代码上加上学生的年月日,单独抽象成一个类,然后把Date这个类的一个对象当成Student这个类的成员变量,如果这样写的话是错的
因为一个对象被创建的时候必须调用构造函数,而这个Date这个类并没有无参构造函数,所以必须要传参,但是由于这个birth这个对象是在类中创建的,所以不能写成比如Date birth(0,0,0);这种形式,怎么办?
同理我们可以在Student这个类的构造函数的定义后面加上对象初始化列表
运行结果:
构造顺序:先构造成员,再构造自己。
析构顺序,先析构自己,再析构成员,和构造的顺序相反
注意两个概念:
初始化:被初始化的对象正在创建
赋值:被赋值的对象已经存在
成员变量的初始化顺序与声明的顺序相关,与在初始化列表中的顺序无关
初始化列表先于构造函数的函数体执行
匿名对象
手动调用构造函数相当于是创建对象,创建的对象没有名字,所以叫匿名对象
匿名对象的生命周期只存在于手动调用的这一行
当我们调用两次的时候就能看出来,它被构造之后程序一旦往下走它就被释放了,而不是像别的对象那样等主函数结束后才一一被释放
那这个匿名对象有什么用处?
一般是在对象数组中应用,比如创建数组里面的对象挨个赋值的时候可以应用。
比如下面这段代码
很多人以为结果是1 2 100
但是结果是这样:
为什么?
首先Test t(1, 2)这行代码实现了赋值前两个成员变量
然后紧接着是手动调用构造函数Test
虽然匿名对象没有名字,但是它也占空间
然后赋值了a,b,c
此时匿名对象里的三个变量的确是1 2 100,但是我们在打印的时候调用的是t这个对象里的show打印,所以打印出来的就是1 2和一个随机值
这就是匿名对象导致的。
构造中调用构造是危险的行为!所以我们尽量不要手动调用构造函数。
New /delete关键字
这两个关键字类似于C语言中的malloc(申请堆内存)和free
在C++中用malloc和free也可以,但是有一种场景用malloc和free这两个函数就不太合适,比如:
首先以下两种创建对象的方式在内存中的位置是不一样的
但是在堆空间创建对象时并不会调用构造函数和析构函数,只有咋栈空间中创建对象时才调用了构造函数,所以只打印了一次:
因此,想要在堆空间创建对象的时候不能用malloc和free,然后malloc和free申请了内存,但是并不会调用构造函数和析构函数。
于是new和delete这两个关键字(注意不是函数)就派上了用场。
new会调用构造函数,delete会调用析构函数
调用有参构造函数:
new和delete还有其他用法
new int; //开辟一个存放整数的存储空间,返回一个指向该存储空间的地址(即指针)
new int(100); //开辟一个存放整数的空间,并指定该整数的初值为100,返回一个指向该存储空间的地址
new char[10]; //开辟一个存放字符数组(包括10个元素)的空间,返回首元素的地址
new int[5][4]; //开辟一个存放二维整型数组(大小为5*4)的空间,返回首元素的地址
float *p=new float (3.14159); //开辟一个存放单精度数的空间,并指定该实数的初值为//3.14159,将返回的该空间的地址赋给指针变量p
delete用法:
delete 指针变量
delete[] 指针变量
Static静态成员
Static在C语言中可以修饰全局变量、局部变量和函数。
在C++中除了以上三个作用之外,关键字 static还可以用于说明一个类的成员,静态成员提供了一个同类对象的共享机制。
把一个类的成员说明为 static 时,这个类无论有多少个对象被创建,这些对象共享这个 static 成员。
静态成员局部于类,它不是对象成员,在类的外部进行初始化。
代码演示:统计学生的个数
打印结果都是4
因为静态成员变量的特点是共享同一块内存,也就是说这里s1/s2/s3/s4里面的静态成员变量都是一样的,都在同一块空间
这也就是为什么静态成员变量要在类的外面初始化,因为它要创建之前就已经定义和初始化好了。
静态成员变量也可以通过类名去访问
但是要想这条语句编译通过的话还得把num变成公有成员
另外static不仅可以修饰成员变量,还可以修饰成员函数
并且静态成员函数也可以通过类名去访问
静态成员函数只能访问静态成员变量
这个接下来很快就有解释!
在面向对象编程里面有一个特别重要的概念:设计模式
在大型的项目里面,别人的设计思路已经搭建好了,我们只要在这个框架里面添加自己的东西。
所以我们在做项目之前首先把框架搭好。不同的项目都可以应用这个框架,可以避免很多问题。
设计模式在面向对象编程中大概有23种,其中有一种叫单例模式。
以socket为例,在服务器端只要创建一个socket就行了,但是在大型的项目中有可能在别的地方不小心又创建了一个socket。在面向对象的编程中,也有可能会被创建多个对象,那如何保证一个类只能创建一个对象?
我们可以设计成如果创建第二个对象的时候就提示或者报错,这种就是单例模式。
关键就是通过static来实现的。
既然要求一个类只能创建一个对象,那我们就不能在类的外部创建对象。
然后为了保证只能创建一个对象,还需要一个存放对象的指针
完整代码:
#include <iostream>
using namespace std;
class Single
{
private:
static Single *m_instance;//静态成员变量在外部初始化
private:
Single()//要保证一个类只能创建一个对象,所以将构造函数设为私有的
{
}
public:
//为了能够在外部调用这个函数创建一个对象所以就设为静态成员函数
static Single*get_instance()//返回值是对象的地址
{
if(m_instance==NULL)//如果m_intance指向的空间为空
{
m_instance=new Single;//new在堆空间创建了Single的对象
}
return m_instance;//如果不为空就直接返回已有对象
}
};
Single* Single::m_instance=NULL; //类型 作用域 ::变量名
int main()
{
Single*s1=Single::get_instance();//静态成员函数可以通过类名来调用
Single*s2=Single::get_instance();//静态成员函数可以通过类名来调用
Single*s3=Single::get_instance();//静态成员函数可以通过类名来调用
cout<<s1<<endl;
cout<<s2<<endl;
cout<<s3<<endl;
return 0;
}
有了这个指针之后我们无论创建多少对象,最后都只是同一个对象
面向对象模型
对象在内存中是如何存储的?
之前学习C语言的时候我们知道如何判断一个结构体的大小,但是类和结构体有所区别,因为类的内存还有成员函数,那类的大小如何计算?
如果我们把Student这个类当成一个结构体来计算大小的话,int id这个变量占4个字节,char占一个字节,再补上三个字节,一共八个字节,这个计算方法我们在学习C语言的时候学过了,不懂的可以去翻一下之前的博客,这里不再赘述。
问题来了,除了变量,类里面还有函数,那这个函数的占多少个字节呢?
我们看一下运行后的结果是8,也就是说它根本没有把函数的大小计算进去。
因此,结论是:类的大小只取决于成员变量。
那么函数到底放在哪里去了?
函数其实是共享的,就是无论一个类创建了多少个对象,这些对象的成员函数都是共享的。
问题又来了
S1和s2这两个对象初始化的值不一样的,而函数是共享的,那它打印出来的结果是怎么样的?
打印出来是不一样的
为什么?
其实show()作为成员函数是有参数的,它只是看起来是没参数的,
它的原型应该是:void show(Student*this)
所以我们下面可以这样写
编译器会自动给它一个this的参数,但是这个参数我们不能写出来。
然后下面我们调用show的时候,编译器会把它翻译成这样:
也就是说调用show的时候它会把s1和s2的地址传过去
然后在show里面又通过这个this指针访问了s1和s2里面的id和sex
所有的普通成员函数都隐含了参数this (这个this可写可不写)
注意:静态成员函数没有this指针,因为静态成员函数通过类名调用时,没有对象,this就没有可指向的对象。这也就是为什么静态成员函数只能访问静态成员变量的原因
因为静态成员函数没有this指针,所以它不能访问普通成员变量。它之所以能访问静态成员变量是因为静态成员变量是同类的对象们共享的。
以上就是所谓的面向对象模型,也就是关于对象在内存中是如何存储的问题。
const修饰的成员函数
const修饰的成员函数称为常成员函数,常成员函数只能访问数据,不能修改数据。
所以以后要养成习惯在函数中如果不需要修改数据的话就在函数后面加上const。
全局函数和成员函数
把全局函数转化成成员函数,通过this指针隐藏左操作数;
Test add(Test &t1, Test &t2)===》Test add(Test &t2)
把成员函数转换成全局函数,多了一个参数
void printAB()===》void printAB(Test *pthis)
把这个成员函数转换成全局函数:增加一个参数
本来show作为成员函数的时候,它就隐藏了一个this这个参数,而现在show作为全局函数,我们就把这个参数给显示出来了而已。
但是请注意以上这种访问成员变量的方法是错误的,因为成员变量是私有的,
如果直接把成员变量转换成公有的话,是不可取的做法。
怎么办?
这又涉及到一个新概念:friend
友元函数friend
我们可以在类中加上这一句,作用是告诉编译器这个函数是Student这个类的朋友,这样这个函数作为全局函数在外部就可以访问私有成员变量。
我们就把这个函数称为友元函数
友元函数不是类的内部函数,是一个全局函数,但是可以改变类的私有属性;
友元函破坏了类的封装性。
friend void test(Test *pTest);
友元类
若B类是A类的友员类,则B类的所有成员函数都是A类的友员函数;
友员类通常设计为一种对数据操作或类之间传递消息的辅助类 。
friend class B;
看一下下面这段代码,如果这样写的话是不允许的,因为a是私有的,不能在TestA外部被访问
怎么办?
转变成友元类,这样在TestB这个类的内部就可以访问TestA这个类的成员变量
小结
类通常用关键字class定义。类是数据成员和成员函数的封装。类的实例称为对象。
结构类型用关键字struct定义,是由不同类型数据组成的数据类型。
类成员由private, protected, public决定访问特性。public成员集称为接口。
构造函数在创建和初始化对象时自动调用。析构函数则在对象作用域结束时自动调用。
重载构造函数和复制构造函数提供了创建对象的不同初始化方式。
静态成员是局部于类的成员,提供一种同类对象的共享机制。
友员用关键字friend声明。友员是对类操作的一种辅助手段。一个类的友员可以访问该类各种性质的成员。
下节开始学习继承和派生!
如有问题可评论区或者私信留言,如果想要进扣扣交流群请私信!