本篇文章将带你开始学习C++中类的知识,由于类的知识比较多,并且难度较大,我将其分成三篇来讲。本篇为第一篇,在本篇文章我将从C语言结构体(当然,如果你对此还不够熟悉可以看看我之前写的这篇文章)切入,从两者之间的相似之处再到相异之处,从表面区别再到深层区别,一步一步剖析,从而使你能够对类有初步的了解和认识。
在C语言中,我们使用struct结构体来描述一个复杂的对象。例如,当我们想使用C语言实现一个顺序表、链表或者栈这些容器,又或者我们想要描述一个学生、老师对象,我们可以定义一个结构体struct,为这个结构体命名,在结构体中定义变量用于描述这个复杂的对象。
struct Stu//描述一个学生
{
char name[20];
char sex[5];
int age;
int weight;
int heigh;
};
struct sequence_list//描述一个顺序表
{
int _data;
int _size;
int _capacity;
};
除了使用结构体struct来描述这些对象,我们还会味这些对象量身定制一系列的函数专门用于管理这些对象,例如用于初始化的函数,用于修改(增删查改)内部数据的函数,用于退还结构体成员变量所申请空间的函数等等。
//用于管理Stu对象的函数
void Stu_Init()
{}
void Stu_Copy()
{}
//...
//用于管理sequence_list对象的函数
void Slist_Init()
{}
void Slist_Push_Back()
{}
void Slist_Pop_Back()
{}
void Slist_Destroy()
{}
//...
当然,对于C语言这样的处理方式是有一定缺陷的,例如封装不严密,用于管理的函数可能被其他类型的对象访问,函数命名与函数调用不够便利,初始化与销毁函数可能忘记调用,甚至可以说(对象及其管理函数)有一股分割感。但是由于C语言的语法,也没什么好的解决方式了。
而为了解决这个问题,C++设计了类(struct和class)。在C++中,我们可以将成员变量和管理用的函数同时定义在类中。
struct Stu
{
//用于管理Stu对象的函数 也叫成员函数
void Init()
{}
void Copy()
{}
//...
//成员变量
char name[20];
char sex[5];
int age;
int weight;
int heigh;
};
struct sequence_list
{
//用于管理sequence_list对象的函数 也叫成员函数
void Init()//函数命名可以不再需要因需避免命名冲突而特别加上类名
{}
void Push_Back()
{}
void Pop_Back()
{}
void Destroy()
{}
//...
//成员变量
int _data;
int _size;
int _capacity;
};
而这些定义在类中的函数,我们通常称之为成员函数或者成员方法。除此之外,在C++中通常更喜欢用class代替struct。
class Classname//类名
{
//在这里定义类的成员变量和成员函数
};//注意这里和struct一样有个分号
class为定义类的关键字,Classname为类的名字,{}中为类的主体,注意类定义结束时后面带分号(与struct一样)。
类体中内容称为类的成员:类中的变量称为类的属性或成员变量,类中的函数称为类的方法或者成员函数。
两种定义方式:
声明与定义不分离,即全部放入类体中,需注意:成语函数如果在类中定义,编译器可能会将其当成内联函数处理。
//全部放进类体中
class sqlist
{
public:
void Init()
{
//...
}
void Push_Back()
{
//...
}
void Pop_Back()
{
//...
}
void Destroy()
{
//...
}
private:
int _data;
int _size;
int _capacity;
};
声明与定义分离,可将声明放入.h文件,成员函数定义放入.cpp文件,注意:成员函数名前需要加类名::
//声明与定义分离
struct sqlist
{
public:
void Slist_Init();
void Slist_Push_Back();
void Slist_Pop_Back();
void Slist_Destroy();
private:
int _data;
int _size;
int _capacity;
};
//下面定义可放至头文件
void sqlist::Slist_Init()
{
//...
}
void sqlist::Slist_Push_Back()
{
//...
}
void sqlist::Slist_Pop_Back()
{
//...
}
void sqlist::Slist_Destroy()
{
//...
}
一般情况下,更建议使用第二种方式(好的编程习惯,易于阅读函数接口,封装…)。本文为方便演示,有些地方就不做分离处理了。
成员变量命名规则建议:
//成员变量这样直接的定义方式会出现的问题
class date
{
public:
void Init(int year)
{
//与形参名冲突了,使用起来很别扭
//命名形参名时还需注意是否与成员变量名冲突
year = year;
}
private:
int year;
};
//所以建议这样
class date
{
public:
void Init(int year)
{
_year = year;
}
private:
int _year;
};
//其他方式也可,依据你的个人编程习惯或公司编程规范而定
上文中提到过,C++类解决了C中结构体的一些问题,其中就包括封装。而解决的方式在上文中的代码块中就已经出现过,那就是类域和访问限定符。
C++用类将对象的属性和方法结合在一块(即都在类域中),让对象更有整体性更加完善,通过访问权限选择性地将其接口提供给外部地用户使用。
访问限定符说明:
}
)结束注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别
类定义了个新的作用域,类的所有成员都在类的作用域中。 在类体外定义成员时,需要使用::作用域操作符指明成员属于哪个类域。
class date
{
public:
void Print();
private:
int _year;
int _month;
int _day;
};
//这里需要指定Print是属于date类域的
void date::Print()
{
cout << _year << "." << _month << "." << _day;
}
我们常常说C语言是面向过程的语言,C++是面向对象的语言。而作为面向对象的语言,C++自然有面向对象的三大特性:封装、继承、多态。
在类和对象阶段,主要是研究类的封装特性,那什么是封装呢?
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互
**封装本质上是一种管理,让用户更方便使用类。**比如:对于电脑这样一个复杂的设备,提供给用户使用的就只有开关机键,键盘,显示器实现,USB插孔等接口,每个接口都有自己特定的调用方式和功能,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些硬件元件。
对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道怎么开机,阅读显示器显示的信息,知道如何通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。
用类类型来创建对象的过程,称为类的实例化。我们需要对类有这样的认知:
类是对对象进行描述的,是一个模具一样的东西, 限定了类有哪些成员,定义出个类并没分配实际的内存空间来存储它。
class date
{
public:
int _year;
int _month;
int _day;
};
int main()
{
date._year = 2022;//error C2059: 语法错误:“.”
return 0;
}
一个类可以实例化出多个对象,实例化出的对象会占有实际的物理空间,存储类成员变量。
类实例化出对象生活中使用建筑设计图建造出房子,类就像是设计图,只需设计出建造一个房子需要什么结构和物质,但并没有实体的建筑存在,而类也只需设计描述这种对象需要的属性和方法,最后实例化出来才实际存储数据,占用物理空间。
在聊这个知识之前,如果你对C中如何计算结构体(对象)大小还不是很了解的话,(不是的话可以跳过这点)我们就先简单聊聊如何计算结构体大小。计算结构体内存大小有一个很重要的规则,那就是内存对齐:
而由于C++要兼容C,C++中的类(结构体)大小计算方式自然是与C中的一致的。这里只是简单聊聊,如果你想深入了解内存对齐的话可以看看我写的这篇文章。
那么如果你已经足够了解计算结构体大小的话,我们再来看看C++中,类(对象)是如何计算的。与结构体不同,类不仅仅有成员变量,还有成员函数。而C++为了兼容C,一个类只有成员变量时,计算方式以及大小显然是要一致的。那就意味着对于成员变量,我们依旧能够使用C计算结构体(对象)大小的方式去计算类(对象)的大小。类中存在成员函数时呢?这个类的大小是不需要考虑函数呢?还是算含有一个存放所有成员函数指针的函数指针数组?还是干脆算含所有成员函数指针呢?我们直接上手做个实验:
经过对比发现,相同成员变量的类(对象)不管有没有成员函数大小都是一样的。从以上实验可以暂且得出一个结论:计算C++类的大小,规则是与C计算结构体大小一样的(内存对齐),并且不用考虑成员函数。
那么现在看来,为什么C++要采用这样的设计呢?为什么不是我刚刚提到的另外两个存储方式呢?既然类内部没有存储函数,那成员函数存储到哪去了呢?如果是你会想要采用哪种设计呢?为什么?
先来看看三种方式对比:
这个设计的缺陷很明显,每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次, 浪费空间。 那么如何解决呢?
解决了设计一的问题,使用一个指针存储付出的代价貌似也不怎么高,但是有一个问题,就是不能兼容C。有没有更好的解决方式?
这样看来设计三就是最好和最符合上面实验现象的设计方案,那么最后再来验证一下。
结论:一个类(对象)的大小,实际就是该类在成员变量使用内存对齐规则后计算出来的和。
注意空类的大小,空类比较特殊,编译器给了空类一个字节来标识这个类的对象。
先来看看一组实验:
有没有觉得哪里怪怪的,好像有些东西被我们忽视了。其实,你仔细想想,就会发现,这两个对象都在调用相同的成员函数,但是,并没有使用传参来区分不同对象所属的成员变量,类内部以及函数体内部好像也没什么用来加以区分的东西哦。并且从这个打印数据来看,确实是成功给不同对象对应的成员变量成功赋值并且打印出对应数据了。那么类究竟是如何解决的呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个非静态的成员函数(即没有static修饰的成员函数)增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量“的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
最后,关于this指针还有两个问题:
this指针存在哪里?
this指针只是在调用成员函数作为形参传参,所以只有调用成员函数时this指针才存在,并且是跟普通形参一样,存在新开的函数栈帧中,也就是栈区。
this指针可以为空吗?
用下面代码测试一下:
class demo4
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
demo4* p = nullptr;
p->Print();
//编译正常,运行正常
return 0;
}
class demo4
{
public:
void Print()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
demo4* p = nullptr;
p->Print();
//编译正常,运行崩溃
return 0;
}
按理说对空指针解引用的错误应该会在编译阶段就会被检查出来,那为什么编译阶段不报对空指针解引用的错呢?不妨此时再回头看看先前提到的成员函数的存储方式,你会发现其实成员函数根本不在对象当中,这个解引用的操作其实早已被编译器转换了调用方式,事实上根本没有解引用的过程,只是套了个解引用的壳。
简单对比一下C的结构体与C++的类。
结构体中只能定义存放数据的结构,操作数据的方法不能放在结构体中, 即数据和操作数据的方式是分离开的, 而且实现上相当复杂一点,涉及到大量指针操作,稍不注意可能就会出错。
C++中通过类可以将数据以及操作数据的方法进行完美结合,通过访问权限可以控制那些方法在类外可以被调用,即封装,在使用时就像使用自己的成员一样,更符合人类对一件事物的认知。而且每个方法不需要传递指针参数了,编译器编译之后该参数会自动还原,即C++中指针参数(this)是编译器维护的,C语言中需用用户自己维护。
以上就是关于类的第一篇讲解了,恭喜你能够看到这里,完成了对C++类的初步认识。如果你觉得做的还不错的话请点赞收藏加分享,当然如果发现我写的有错误或者有建议给我的话欢迎在评论区或者私信告诉我。