文章目录
类和对象的初步认识
在上一个部分我们已经初步认识了c++中的一些概念。这个章节将开始c++的第一个重要内容——类和对象进行介绍。由于这个部分内容非常多,我将用多篇文章进行讲解。本文是对类和对象的初步认识部分
类的定义
对于类的理解,其实我们可以把他理解为一个变量。即c++中引入了一个新的变量类,而我们现在就是需要对这个特殊的变量进行了解学习并应用。
类定义格式
定义一个类,需要用到关键字class。就好比我们以往实现过的栈这个数据结构类型,定义方式为:
typedef int STDataType;
class Stack{
public:
void StackInit(int n = 4){
//栈的初始化实现
}
//...一系列栈的方法
void StackDestroy(){
//栈的销毁实现
}
private:
STDataType* _a;
int _top;
int _capacity;
};//分号不能省略
上述就是类的定义方式,里面有很多新的东西。但是不用着急,我们慢慢来讲。
这个方式和结构体类型的变量的定义方式有一些类似,只不过在c语言中,结构体变量内部是不能定义函数的。而类变量是可以。我们称这些函数为成员函数。内部的变量就是成员变量
但是:在这里先说一个结论,c++中规定了struct也是可以内部定义函数的。
但是一般情况下还是会更多的使用类class。
为了区分成员变量,一般习惯上成员变量会加⼀个特殊标识(如上面写的 _a等 ),如成员变量前面或者后面加 _ 或者 m开头,注意C++中这个并不是强制的,只是⼀些惯例。避免和成员函数的参数相冲。
还有要注意的是,对于成员函数,c++默认这些为内联函数(inline)。这是需要注意的。也就是说,这些成员函数在调用的时候是不会开辟函数栈帧的,而是会直接将语句展开调用。
访问限定符
我们又看到,类的定义中又有两个关键字:public和private。这是干什么用的呢?
这两个是访问限定符。从它们的中文意思就可以看的出来了。
public即公共的,是可以访问的。
private即私有的,即不能访问。
当然还有一个访问限定符是protected,而当前的知识无法对这个进行更深入的讲解,当前就暂且认为它和private一样是私有的即可。
这个是很好理解的,对于有些类,其成员变量肯定是有一些是不想被外界直接进行访问的。这样是十分不安全的,就必须要通过类提供的成员函数进行访问。这样子不仅安全,且更加的规范。所以c++对于类的使用进行了访问限定
类的默认访问限定
那么如果一个类中没有写任何的访问限定符时会如何处理呢?
结论:对于没有访问限定符的类,c++默认这些成员为私有的(private)。
这样就不太合适了。大部分情况下还是需要对类中的变量进行操作的。全部为私有成员还是不太合适。所以还是需要对public区域和private进行区分的。
访问限定符的操作范围
当然,一个类中可以写多个访问限定符。我们得直到访问限定符的操作范围。
c++规定,访问限定符的限定范围是:当前访问限定符与下一个访问限定符之间的位置。如果后续没有访问限定符,那就一直到类定义结束的位置(即末尾的}位置)。
这样子我们就可以对多个成员进行有效的分区了。但是,这样写是非常麻烦且没必要的,所以一般来说都是将公共区的成员放在一起写在类的上半部分,私有的写在下半部分直到结束。
类与结构体的区别
在前面提到过,c++其实是对结构体的使用进行改进,甚至可以说是升级了。在c语言中,结构体内部只能存储变量,如int、double、数组、甚至是结构体的嵌套。无法存定义函数。
而c++中则对这个用法进行了改进,结构体中也可以定义函数。
二者是有区别的:class定义成员没有被访问限定符修饰时默认为private,struct默认为public。
类域
以往提到过,c++中有四个域:全局域、函数局部域、命名空间、类域
其实就是{ }包含的范围内就是一个域。c++是支持不同域中的命名相同的,而同一个域中不行。只不过访问的时候需要使用域作用限定符::进行访问使用。
当然,类中是可以只定义函数而不进行实现的。可以将函数的实现放在其他地方实现,即让成员函数的定义和实现分离。但是需要注意的是,在外部实现,需要使用域作用限定符访问。
typedef int STDataType;
class Stack{
public:
void StackInit(int n = 4);
//...一系列栈的方法
void StackDestroy();
private:
STDataType* _a;
int _top;
int _capacity;
};//分号不能省略
void Stack::StackInit(int n = 4){
STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * n);
if (!tmp) {
cout << "内存开辟失败" << endl;
exit(-1);
}
_a = tmp;
_top = 0;
_capacity = n;
}
//...一系列方法的实现
void Stack::StackDestroy(){
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
实例化应用
我们先来理解一下什么是实例化。
我们已经基本讲述完一个类的创建。但是这毕竟是一个变量的类型,我们不能直接使用。就好比我们在c语言学习结构体的时候,直接把struct进行使用。这是不对的。
那我们就应该声名变量。这就是实例化。
我们可以举一个浅显易懂的例子:
创建这个类对象就好比建房子的设计图纸,上面只是规定了哪里应该怎么做,房间多大,排线如何等。而真正要住的时候就是要根据这个图纸建造出房子才能使用。不可能直接住在房子里面吧。而类的实例化就是这样,我们虽然创建了类对象,但是没办法用。需要以此为基础来创建一系列的类,为其内部的变量开辟空间存储。
计算类的内存大小
既然实例化是为类中的成员开辟内存空间进行存储,那该如何计算类的大小呢?
因为类中有声名的成员函数,那成员函数在类中是以什么方式存在呢?
带着这些问题,我们来研究一下。
类在内存中的大小计算方式其实和c语言中结构体的内存大小计算方式是一模一样的。即需要通过内存对其的方式来进行计算大小。然后需要注意的是,类中虽然定义了函数,但是函数其实并不是存储在类当中的。即函数是类进行调用的。
对于内存对齐早已讲过,这里就不再赘述,感兴趣的读者可以去这篇文章看看:c语言自定义类型——结构体、联合、枚举——结构体在内存中的存储
所以我们只需要计算一下内部的成员变量,如整形数据、浮点型数据、数组等的总对齐内存大小。而对于函数我们不做计算。
其实函数不存储在类当中就是为了效率考虑的。这是c++的特点,记住就行。
对成员的调用
那我们现在创建了一个实例化对象,又应该如何调用呢?
这里我们创建一个简单的日期类进行举例:
class Date {
public:
void InitDate(int year = 0, int month = 0, int day = 0);
void Print();
private:
int _year;
int _month;
int _day;
};
void Date::Print() {
cout << _year << "/" << _month << "/" << _day << endl;
}
void Date::InitDate(int year, int month , int day ) {
//带有缺省值的函数定义 实现的时候不能写缺省值
_year = year;
_month = month;
_day = day;
}
现在我们就创建了一个日期类 ,比较简单,但仅仅只是举例子。
int main() {
//实例化
Date d1;
d1.InitDate(2025, 4, 3);
d1.Print();
Date* pd1 = &d1;
d1->InitDate();
d1->Print();
return 0;
}
其实调用和结构体还是很像的。如果当前实例化的是一个类变量,使用操作符.就可以了。如果是指针,就是用->调用。
来看看效果:
很明显符合预期的效果。
this指针
但现在有一个问题,为什么实例化一个对象后,调用成员函数就能直接访问其内部成员变量呢?这是如何识别到的呢?
这个时候就得提到一个特殊的指针:this指针。
编译器编译后,类的成员函数默认都会在形参第⼀个位置,增加⼀个当前类类型的指针,叫做this指针。比如Date类的Init的真实原型为:
void Init(Date* const this, int year,int month, int day)
第一个参数是隐含的,是编译器在编译的时候就自行处理了的。不需要我们写。写了会报错!
这个this指针就是存放着调用成员函数的那个类的地址,然后这样子就直到是如何处理的了。虽然this不能自行写入参数,但是我们可以在成员函数的内部进行使用:
void Date::Print() {
cout << this->_year << "/" <<this-> _month << "/" <<this-> _day << endl;
}
void Date::InitDate(int year, int month , int day ) {
//带有缺省值的函数定义 实现的时候不能写缺省值
this->_year = year;
this->_month = month;
this->_day = day;
}
我们可以这样子写函数。但是这个this指针编译器自己会处理,所以写不写都可以。但是this指针也是有用处的,有时候会作为返回值。这个我们后面部分再讲。
也就是说,对于上面的调用,都是讲地址传入给this指针然后进行成员函数的调用,这样就很简单地能知道调用的是哪个对象的的成员了。
注意事项
这里我们来讲一下注意事项,举两个例子:
例子1:
#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.编译错误 B.程序崩溃 C.正常运行
首先,对空指针的引用是不会编译错误的,只是会导致程序崩溃。
很多人这么想之后一看,欸,这不就是对空指针解引用了嘛。引用内部的成员函数。那这么想就大错特错了。虽然确实是将nullptr传给了this指针。
但是:要注意的是,我们在前面提到过,类中是不存放函数的,所以虽然看上去像是解引用,但其实是去调用函数了。而且print函数中并没有调用任何的内部成员,只是进行打印操作,所以肯定是正常执行的:
有了这个基础,我们来看看下一个例子就简单多了:
<font color=“pink">例子2:
#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;
}
有了上面的例子做铺垫就简单的多了,问题就在于:cout << _a << endl;
虽然调用函数没有对空指针解引用,但是我们知道,在成员函数内部,调用是通过this指针,所以也可以写成cout << this->_a << endl;
而此时的this指针又是空指针,那这个时候就对空指针解引用了。这就会导致程序崩溃。
还需要注意的是,由于this指针只存在于成员函数内部,一旦出了局部域就不存在了,根据这个特点很容易知道,this指针是存放在栈区的,仅在函数栈帧内存在。
类的封装性——用栈来引例
这个部分主要还是展示一下c++和c语言的区别。主要在于c++的多态性、继承性、封装性。
先来展示以下其封装性:
我们以栈的实现为例子:
c语言版本:
//Stack.h
#define _CRT_SECURE_NO_WARNINGS
#pragma once
#include<stdio.h>
#include<assert.h>
#include<stdlib.h>
#include<errno.h>
#include<string.h>
#include<stdbool.h>
typedef char STDataType;
typedef struct Stack
{
STDataType* a;
int top; // 栈顶
int capacity; // 容量
}Stack;
void StackInit(Stack* ps);//栈的初始化
void StackPush(Stack* ps, STDataType data); //入栈
void StackPop(Stack* ps);//出栈
STDataType StackTop(Stack* ps);//获取栈顶元素
int StackSize(Stack* ps);//获取栈中有效元素个数
int StackEmpty(Stack* ps);//检测栈是否为空,如果为空返回非零结果,如果不为空返回0
void StackDestroy(Stack* ps);//销毁栈
//Stack.c
#include"stack.h"
void StackInit(Stack* ps) {
assert(ps);
ps->a = NULL;
ps->capacity = ps->top = 0;
//top指向栈顶 如果一开始定义了top为0 此时没有元素 所以top指向的是栈顶元素的后一个位置
}
void StackPush(Stack* ps, STDataType data) {
assert(ps);
if (ps->capacity == ps->top) {
//需要扩容
int tmpsize = (ps->capacity == 0) ? 4 : ps->capacity * 2;
STDataType* tmp = (STDataType*)realloc(ps->a, tmpsize*sizeof(STDataType));
if (!tmp) {
perror("realloc");
exit(-1);
}
ps->a = tmp;
ps->capacity = tmpsize;
}
ps->a[ps->top] = data;
ps->top++;
}
void StackPop(Stack* ps) {
assert(ps);
ps->top--;
}
STDataType StackTop(Stack* ps) {
assert(ps);
return ps->a[ps->top - 1];
}
int StackSize(Stack* ps) {
assert(ps);
return ps->top;
}
int StackEmpty(Stack* ps) {
assert(ps);
if (ps->top==0) return 1;
else return 0;
}
void StackDestroy(Stack* ps) {
assert(ps);
free(ps->a);
ps->a = NULL;
ps->capacity = ps->top = 0;
}
这是标准的c语言实现过程。
我们来看看c++版本(注:由于这里没有设计类的构造函数等,所以只是简单展示)
#include<iostream>
#include<stdlib.h>
#include<assert.h>
#include<stdbool.h>
using namespace std;
typedef char STDataType;
class Stack {
public:
void StackInit(int n = 4) {
STDataType* tmp = (STDataType*)malloc(sizeof(STDataType) * n);
if (!tmp) {
cout << "内存开辟失败" << endl;
exit(-1);
}
_a = tmp;
_top = 0;
_capacity = n;
}
void Push(STDataType x) {
if (_top == _capacity) {
int tmpnum = _capacity * 2;
STDataType* tmp = (STDataType*)realloc(_a,tmpnum);
if (!tmp) {
cout << "内存开辟失败" << endl;
exit(-1);
}
_a = tmp;
_capacity = tmpnum;
}
_a[_top++] = x;
}
void pop() {
assert(_top > 0);
_top--;
}
bool StackEmpty() {
return _top == 0;
}
int StackSize() {
assert(_top > 0);
return _top - 1;
}
STDataType StackTop() {
assert(_top > 0);
return _a[_top - 1];
}
void StackDestroy() {
free(_a);
_a = nullptr;
_top = _capacity = 0;
}
private:
STDataType* _a;
int _top;
int _capacity;
};
我们发现最大的特点就是对于传参部分是极大简化了。且初始化阶段可以通过缺省参数对需要的空间进行一次性的开辟。避免多次的扩容。
我们以之前Leetcode上的有效的括号为例子看看c++的实现:
int Is_Match(char s1, char s2) {
if (s1 == '(' && s2 == ')')return 1;
else if (s1 == '[' && s2 == ']')return 1;
else if (s1 == '{' && s2 == '}')return 1;
else return 0;
}
bool isValid(char* arr) {
Stack st;
st.StackInit();
while (*arr != '\0') {
if (*arr == '(' || *arr == '[' || *arr == '{') {
st.Push(*arr);
}
else {
if (st.StackEmpty()) {
st.StackDestroy();
return false;
}
STDataType top = st.StackTop();
st.pop();
if (!Is_Match(top, *arr)) {
st.StackDestroy();
return false;
}
}
arr++;
}
bool flag = st.StackEmpty();
st.StackDestroy();
return flag;
}
我们发现最大的有点就是代码更好理解了,且对应的函数不需要传参了。
当然在这还没有学习构造函数和析构函数,否则对代码的简化将会更优,这一点放在下一篇文章来讲。
到此本篇文章就结束了。