目录
一. 前言
前几期我们介绍了C++相比C语言新增的一些语法,相信大家已经对C++有了一定的认知。而从本期开始,我们将正式进入C++类和对象的学习,感受C++基于面向对象编程的魅力。在学习过程中,我们将接触到面向对象的三大特性之一:封装。
二. 面向对象与面向过程
在学习编程的过程中,各位想必或多或少都听说过这两个概念。都知道C语言是面向过程的,C++、Jave等语言是面向对象的,那么,究竟什么是面向过程?而面向对象又是什么意思呢?
2.1 面向过程
C语言是面向过程的,关注的是实现的过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
就好像我们要洗衣服,从面向过程的角度来洗衣服的流程图就像下面所示
又或者我们要设计一个外卖点餐系统,从面向过程的角度我们应该设计类似下面的流程:
总结:面向过程关注的是一个个步骤,例如放衣服、手搓以及用户下单等等,通过将这些具体的步骤一步步在函数中实现,使用时再依次进行调用即可。
2.2 面向对象
而C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。
回到洗衣服,面向对象关注的就只有四个对象:人、衣服、洗衣粉和洗衣机。人只需将衣服和洗衣服放入洗衣机中即可。至于洗衣机是如何洗衣服、是如何甩干的,我们无需关心。
而对于外卖点餐系统,我们关注的也不是分配骑手、骑手送餐这些具体的步骤,而是关注骑手、商家和用户这三个对象之间的交互,对用户如何下单、骑手如何送餐并不关心。
总结:面向过程关注的完成某件事的对象,例如衣服、洗衣机以及骑手等等。通过描叙这些对象在整件事中的关系和行为,最终得以解决问题。
三. 类的基础知识
3.1 类的引入
在C++中,类是用来描述对象的,是一种用户自定义的数据类型。在C语言中,结构体就是种自定义类型,但其只能用来定义变量。而在C++中,结构体被升级成了类,其不仅可以定义成员变量,还可以定义成员函数。如下:
//实现一个栈类
typedef int DataType;
struct Stack
{
void Init(size_t capacity)
{
//栈初始化
}
void Push(const DataType& data)
{
//栈的插入
}
DataType Top()
{
//取栈顶元素
}
void Destroy()
{
//栈空间销毁
}
DataType* _array;
size_t _capacity;
size_t _size;
};
而在C++中,我们更喜欢用class关键字来替代struct
typedef int DataType;
class Stack //用class来定义一个类
{
//成员函数、类方法
void Init(size_t capacity){}
void Push(const DataType& data){}
DataType Top(){}
void Destroy(){}
//成员变量、类属性
DataType* _array;
size_t _capacity;
size_t _size;
};
3.2 类的定义
类的结构如下所示:
class className //class关键字+类名
{
// 类主体:由成员函数和成员变量组成
}; // 后面的分号不要漏
- class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分
号不能省略。 - 类主体中内容称为类的成员:类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。
类的定义方式有两种:声明和定义结合、声明和定义分离。
声明和定义结合
即声明和定义都放在类主体中,如下:
class Date
{
//成员函数的声明+定义
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
//成员变量的声明
int _year;
int _month;
int _day;
};
注意:成员函数如果在类中定义,编译器可能会将其当成内联函数处理。
声明和定义分离
即类声明放在.h文件中,成员函数定义放在.cpp文件中。一般我们会更推荐采用这种分文件编程的方式
//class.h文件
class Date
{
//成员函数的声明
void Print();
//成员变量的声明
int _year;
int _month;
int _day;
};
//class.cpp文件
void Date::Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
注意:类外定义的成员函数名前需要加类名+类作用限定符::
3.3 成员变量的命名规则
我们先来看看一个别扭的代码
class Date
{
void Init(int year)
{
// 这里的year到底是成员变量,还是函数形参?
year = year;
}
int year;
};
由于Init函数的形参也为year,编译器会优先将year认为是函数形参,最终相当于将自身的值赋给自身,与本意违背。
为了避免上面命名冲突的情况发生,我们通常会给成员变量加上前缀或者后缀,加以区分。如下所示:
class Date
{
void Init(int year)
{
_year = year;
}
int _year; //前缀
};
// 或者这样
class Date
{
void Init(int year)
{
year_ = year;
}
int year_; //后缀
};
// 其他方式也可以的,只要可以加以区分即可,一般都是加个前缀或者后缀就行。
3.4 封装
面向对象具有三大特性:封装、继承、多态。在类和对象中,我们主要接触到的就是封装,那么究竟什么是封装呢
在类的设计时,我们通常不希望使用者直接访问类中的成员变量,而是仅通过使用我们在类中设计的接口函数来对对象进行交互。这种隐藏对象的属性和实现细节,将数据和操作数据的方法进行有机结合,仅对外公开接口来和对象进行交互就称作封装。
封装本质上是一种管理,是为了让用户更方便地使用类,无需关注复杂的底层实现细节。
举个栗子:对于电脑这样一个复杂的设备,对于计算机使用者而言,不用关心内部核心部件,比如主板上线路是如何布局的,CPU内部是如何设计的等,用户只需要知道,怎么开机、怎么通过键盘和鼠标与计算机进行交互即可。因此计算机厂商在出厂时,对计算机进行了封装,在外部套上壳子,将内部实现细节隐藏起来,仅仅对外提供开关机、鼠标以及键盘插孔等,让用户可以与计算机进行交互即可
3.5 类的访问限定符
在C++中实现封装,我们可以通过类将数据和操作数据的方法进行有机结合,再通过访问权限来隐藏对象内部实现细节,并控制哪些方法可以在类外部直接使用。
C++可以通过访问限定符来控制访问权限,访问限定符有如下三种:
- public修饰的成员在类外可以直接被访问
- protected和private修饰的成员在类外不能直接被访问,二者的区别要在后面学习继承时才会体现,这里可以粗略认为它们是类似的。
具体使用方式如下所示:
class Date
{
public: //使用访问限定符加冒号限定变量或函数的访问权限
void Print()
{
cout << _year << "年" << _month << "月" << _day << "日" << endl;
}
int _year = 10; //C++11支持给成员变量缺省值
protected:
int _month = 10;
private:
int _day = 10;
};
int main()
{
Date d; //类的实例化,类名+变量名
d._year = 2023; //_year是共有的,类外可以访问
//无法通过编译,保护和私有变量不能在类外访问
//d._month = 10;
//d._day = 10;
d.Print(); //Print函数是共有的,类外可以访问
}
注意事项:
1、访问限定符的作用域是从该访问限定符出现的位置开始直到下一个访问限定符出现时为止
2、如果后面没有访问限定符,作用域就到 } ,即类结束处。3、在类内对成员进行访问不受访问限定符限制
4、 使用class定义的类的默认访问权限为private,而使用struct为public(因为struct要兼容C,C语言的结构体成员是允许外部访问的)
3.6 类的作用域
类定义了一个新的作用域,简称类域,类的所有成员都在当前类域中。在类体外定义成员时,需要加上::作用域操作符指明成员属于哪个类域,如果没有加上作用域操作符,则编译器默认只会在全局进行定义。
class Person
{
public:
void PrintPerson();
private:
char _name[20];
char _gender[3];
int _age;
};
//这里定义的PrintPerson()是全局函数
void PrintPerson()
{
cout << "void PrintPersonInfo()" << endl;
}
//这里定义的PrintPerson()是Person类中的成员函数
void Person::PrintPersonInfo()
{
cout << "void Person::PrintPerson()" << endl;
}
int main()
{
Person p;
PrintPerson(); //调用全局的
p.PrintPerson(); //调用类域中的
}
3.7 类的实例化
用类类型来创建对象的过程,称作类的实例化。类是对对象进行描述的,是一个像模型一样的东西,限定了类有哪些成员,定义一个类并没有分配实际的内存空间来存储它,类中的成员变量仅仅只是声明。
一个类可以实例化出多个对象,实例化出的对象会占用实际的内存空间,用来存储类中的成员变量。举例如下:
class Person //Person类的定义
{
public:
void PrintPerson()
{
cout << _name << " " << _gender << " " << _age << endl;
}
char _name[20]; //这里的成员变量都是声明
char _gender[3];
int _age;
};
int main()
{
Person p; //实例化一个对象p
p._age = 20; //p是类实例化出来的对象,占用内存空间,顾可以对成员变量_age进行操作
//下面的写法均错误,类中的_age只是声明,没有内存空间
Person::_age = 20;
Person._age = 20;
return 0;
}
做个比方:类就好比一张建筑设计图,类实例化对象就好比现实中使用建筑设计图建造房子,每栋房子就相当于一个对象,一张建筑设计图可以建造出许多栋房子。建筑图纸本身没有空间,无法住人,只有用建筑图纸建造出来的房子才具有空间用来住人。同样类也只是设计,实例化出的对象才能实际存储数据,占用内存空间。
四. 类的对象模型
4.1 类对象的大小
一个类中既可以有成员变量,也可以有成员函数,那么一个类实例化出来的对象中究竟包含了什么?我们可以用sizeof操作符来计算一个类对象的大小
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
char _a;
};
int main()
{
A a;
cout << sizeof(a) << endl;
return 0;
}
我们看到最终结果为1,为什么呢?这就要谈到类对象在内存中的存储方式了。
4.2 类对象的存储方式
一种最简单的方式就是将成员变量和成员函数全部包含在对象中,但是这也会引来一个问题:
int main()
{
A a;
A b;
A c;
a.PrintA();
b.PrintA();
c.PrintA();
return 0;
}
当我们实例化出多个对象时,每个对象中的成员变量是不同的,但调用的是同一个函数。如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份函数代码,相同代码保存多次,浪费空间。
我们在4.1的例子可以看出C++并不是以这种方式来存储的,很明显PrintA()并没有存储在类对象中,类对象中只有一个大小为1字节的成员变量_a。
我们说类就像建筑设计图,类对象就像一栋栋房子。类中的成员变量可以看做居民,居民需要居住在房子中,占据房子空间,每栋房子里的居民不同;而成员函数就像小区中的娱乐设施,如游泳池、篮球场等等,它们是小区中所有住户的公共资源,只有一份,相互共享。
娱乐设施建造在小区之中,而我们的成员函数,保存的地方就是内存中的公共代码区,所有对象共享这一份代码,大大节省了内存空间。存储方式如下图所示
结论:一个类的大小,实际就是该类中”成员变量”之和,与成员函数无关。而成员变量的存储方式和结构体一样,需要遵循内存对齐。
有关内存对齐的知识,可以参考往期文章【C语言】你真的了解结构体吗http://t.csdn.cn/sqzTO
4.3 空类的大小
我们先来看看如下代码
// 类中既有成员变量,又有成员函数
class A1 {
public:
void f1() {}
private:
int _a;
};
// 类中仅有成员函数
class A2
{
public:
void f2() {}
};
// 类中什么都没有---空类
class A3
{
};
int main()
{
cout << sizeof(A1) << endl; //既有成员变量,又有成员函数
cout << sizeof(A2) << endl; //仅有成员函数
cout << sizeof(A3) << endl; //什么也没有
return 0;
}
A1有一个int类型的成员变量,占四个字节,这毫无疑问。但我们发现A2和A3尽管它们没有成员变量,它们却也占了1个字节的存储空间,这和上面说的结论不一样呀,这一个字节的空间到底从何而来?难道是成员函数?不不不,这实际上是编译器对空类的特殊处理
特殊处理:空类也可以实例化出对象,为了标识对象的存在,编译器会给这个空对象分配一个字节的存储空间用于占位。故空类实例化出的对象大小为1个字节。
五. this指针
5.1 this指针的引出
上面我们说过类中的成员函数保存在公共代码区中,那么当一个对象调用成员函数,成员函数又是如何识别对象并进行操作呢?下面我们先来定义一个日期类
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
Date d1, d2;
d1.Init(2023, 8, 21);
d2.Init(2024, 8, 21);
d1.Print();
d2.Print();
return 0;
}
上面的代码中,Date类有 Init 与 Print 两个成员函数,但是函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++中通过引入this指针来解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有对成员变量的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。
例如上面的 Init 成员函数实际上是如下的形式
//this指针作为隐藏参数指向调用的对象
void Init(Date* const this, int year, int month, int day)
{
this->_year = year; //通过this指针找到对象对其内容进行修改
this->_month = month;
this->_day = day;
}
5.2 this指针的特性
- this指针的类型:类类型* const,即成员函数中,不能修改this指针。
- this指针只能在“成员函数”的内部使用
- this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象的地址作为实参传递给
this形参。所以对象中不存储this指针 - this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传
递,不需要用户传递。顾this指针存储在ecx寄存器中
5.3 小试牛刀
学了this指针的特性,我们来两道题目来练练手
Q1:下面程序编译运行结果是?A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
答案是C,程序正常运行。由于Print()是个成员函数,存放在公共代码区,因此编译器不会到p所指向的对象中去调用函数,而是直接调用公共代码区中的函数,然后将p作为this指针传入Print()函数。在Print()函数中,由于只有一条输出语句,故程序可以正常运行。
Q2:下面程序编译运行结果是?A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
答案是B,程序运行崩溃。与前一个程序不同的是:PrintA()函数输出的是成员变量。由于p调用PrintA()函数时传入的this指针为nullptr,而访问成员变量_a实际上是通过this->_a来进行访问,编译器只是将this进行了隐藏,这无疑是一种对空指针的解引用,故程序运行时会崩溃。
以上,就是本期的全部内容啦🌸
制作不易,能否点个赞再走呢🙏