一、面向对象和面向过程
之前我们学习的C语言是基于面向过程的,它关注的是过程。
而C++是基于面向对象的,它关注的是对象。
举个例子:
比如有一个外卖系统。面向过程所关注的内容是:商品上+架、点餐、派单、送餐、评价等等。
面向过程关注某件事情的进行过程。面向对象所关注的内容是:商家、骑手、用户等等。
面向对象关注对象之间的关系和交互,它将现实世界中的对象映射到虚拟的计算机系统中。面向对象是比面向过程更加高级的开发方式。
二、类的引入
在C语言中,我们通常用struct来创建一个自定义的类型,其内部可以定义不同的变量。C++也是支持struct的,不同的是,C++中的struct升级了,它不仅可以自定义成员变量,还可以自定义成员函数。此时的struct就是一个类。但我们一般不用struct,而是用class。(尽管它们是一样的)
不同的类里面可以定义同名的变量、函数,这叫类域。C++中有花括号的都是域。
struct Stack { // 成员函数 void Init(int defaultCapacity = 4) // 缺省参数 { a = (int*)malloc(sizeof(int) * capacity); if (nullptr == a) { perror("malloc fail"); return; } capacity = defaultCapacity; top = 0; } void Push(int x) { //... } void Destory() { free(a); a = nullptr; top = capacity; } // 成员变量 int* a; int top; int capacity; }; int main() { struct Stack st1; Stack st2; st1.Init(); st2.Init(20); st2.Push(1); st2.Push(2); st2.Push(3); st2.Destory(); }
三、类的定义
-
类的写法
类和c语言中的struct的写法是一样的,示例如下:class ClassName { // 类体:由成员函数和成员变量组成 }; // 最后要加上封号
其中,class是定义类的关键字,跟struct类似。
ClassName是类的名字,可以随便取。
花括号中是类的主体,可以写成员函数和成员变量。 -
类的定义
类的定义有两种方法(主要是说成员函数)-
声明和定义都在类体中
strcut Stack { // 成员函数 void class::Init(int defaultCapacity = 4) // 缺省参数在声明处给,不在定义处给 { a = (int*)malloc(sizeof(int) * capacity); if (nullptr == a) { perror("malloc fail"); return; } capacity = defaultCapacity; top = 0; } //... void class::Destory() { free(a); a = nullptr; top = capacity; } // 成员变量 int* a; int top; int capacity; };
-
声明在
.h
文件中,定义在.cpp
文件中// Stack.h 声明 struct Stack { // 成员函数 void Init(int defaultCapacity = 4); // 缺省参数在声明处给,不在定义处给 void Push(int x); void Destory(); // 成员变量 int* a; int top; int capacity; }; // Stack.cpp 定义 void class::Init(int defaultCapacity) // 缺省参数在声明处给,不在定义处给 { a = (int*)malloc(sizeof(int) * capacity); if (nullptr == a) { perror("malloc fail"); return; } capacity = defaultCapacity; top = 0; } //... void class::Destory() { free(a); a = nullptr; top = capacity; }
成员函数在类外面定义的方法:在定义处的成员函数名前加上类名和作用域限定符。参考上面的代码。
-
注意:
-
如果要写内联函数,就不能把定义和声明分离。
原因:C++规定,在类里面定义的函数,默认就是inline,所以一般较长的函数在类外定义,短的函数就在类里面定义。如果长的函数也在类里定义,它虽然是inline的,但也不会成为内联函数,因为是否是内联函数是由编译器决定的。 -
成员函数声明和定义分离的时候,如果要写缺省参数,必须写在声明处。
原因:在预处理阶段会展开头文件,因为一般类(函数)声明都写在头文件处,如果把缺省参数写在定义处,那么展开头文件的时候就找不到这个缺省值,程序编译的时候就会出错。 -
一般类的成员变量的名字不会直接写成
top
、capacity
这种,因为这么写可能会导致一些误解,请看下面的代码:#include<iostream> using namespace std; class A { public: void Date(int year, int month, int day) { cout << "year = " << year << endl; cout << "month = " << month << endl; cout << "day = " << day << endl; } int year; int month; int day; }; int main() { A a; a.year = 2000; a.month = 1; a.day = 1; a.Date(2023, 12, 19); return 0; }
请问:在上面的代码中,函数Date会调用形参的year、month、day呢,还是会调用类的成员变量year、month、day呢?经过测试,上述代码的输出结果是2023 12 19,说明在成员函数会先使用局部域里的变量,也就是函数的形参。
类实例化时,各个成员变量的创建是不分前后的,只要它们都在类里面,先出现的成员变量是可以用后出现的成员变量的,它们被视作一个整体。成员函数也是如此,也就是说,如果类里的成员函数的参数叫做
year
,并且类的某个成员变量也叫作year
,那么岂不是很容易混淆?所以一般都会在成员变量前面或后面加一个下划线,或者在前面加一个m(表示member)。我们使用哪种都可以。// 下面都是成员变量 int _year; int year_; int myear; int _myear;
你或许会疑问,编译器是如何区分形参的
year
和成员变量的year
的,实际上,在调用函数的时候,并不仅仅是传来了一个实参,我们还是以日期来举例:// 这里调用成员函数 A a; a.Date(2023,12,19); // 实际上,这行代码等效于这一句:a.Date(&a,2023,12,19);
编译器会自动帮我们把这个类的地址也当作实参传进去。
// 这里是成员函数的内部 void Date(int year, int month, int day) { cout << "year = " << year << endl; cout << "month = " << month << endl; cout << "day = " << day << endl; } // 实际上,此时调用的成员函数就变成下面这样了: void Date(A* this, int year, int month, int day) { cout << "year = " << this->year << endl; cout << "month = " << this->month << endl; cout << "day = " << this->day << endl; }
编译器会自动帮我们在我们用形参的地方加上一个
this->
。这涉及到this指针的知识,我们在类和对象(中)这篇文章中会对其有详细的讲解,这里就先卖个关子,不多赘述了。
四、类的访问限定符及封装
对于一个类,其内部可以被下面三个访问限定符划分:
-
public(公有)
只有共有的内容才能在类的外面访问。 -
protected(保护)
-
private(私有)
不能在类外面访问,只能在类的内部被访问。protected和private在初学C++时作用是一样的,至于具体的区别,以后再作讲解。我们使用初学者使用时,两者都能用。
- 类就像一个社区,私有的就是每家的私人房子,公有的就是公园。
- struct和class的区别在于:不写访问限定符的情况下,struct默认是共有的,class默认是私有的。
- 访问限定符的作用域是从该访问限定符出现的位置开始到下一个访问限定符出现的位置。如果后面没有访问限定符,就到类结束。
举个例子:
class Stack { public: // 成员函数 void Init(int defaultCapacity = 4) { a = (int*)malloc(sizeof(int) * capacity); if (nullptr == a) { perror("malloc fail"); return; } capacity = defaultCapacity; top = 0; } void Push(int x) { //... } void Destory() { free(a); a = nullptr; top = capacity; } private: // 成员变量 int* a; int top; int capacity; }; int main() { struct Stack st1; Stack st2; st1.Init(); st2.Init(20); st2.Push(1); st2.Push(2); st2.Push(3); st2.top = 0; // 这行代码是错误的!top被private修饰,不能在类外直接访问。 st2.Destory(); }
上述的类中,成员函数是被
public
修饰的,因此可以在main函数中直接对这些成员函数进行访问,而成员变量是被private
修饰的,不能够直接访问它。
面向对象最重要的三大特性:封装、继承、多态。
继承和多态后面会讲解。
封装:把数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。封装可以更好的管理,让用户更方便使用类。可以杜绝一些不规范、不文明的行为。灵活使用访问限定符就可以实现类的封装。
五、类的作用域
目前已经学习到的作用域有:局部域、全局域、命名空间域、类域。其中局部域和全局域会影响生命周期,但是命名空间域和类域不会。(关于这几个作用域的先后访问顺序,请参考《C++入门》这一篇博客)
当我们在类外面用到类里面的变量时,需要加上类名和作用域操作符::
,比如上面提到的类的成员函数的声明和定义分离时的代码,其中在函数定义的时候就用到了类名+作用域操作符的形式。
六、类的实例化
类的实例化,就是用类这个数据类型创建一个对象的过程。
- 类和对象的关系:
类是一种抽象的数据类型,和以前学过的struct相似,其是用来描述对象的。
对象是一个具体的变量。
创建一个类并没有开辟空间,它就是一张图纸。只有实例化的时候,就是真正创建一个对象的时候,才会开辟空间,实例化就是拿着类这张图纸去建造对象这个东西。
七、类对象模型
-
如何计算类对象的大小?
struct Stack { // 成员函数 void Init(int defaultCapacity = 4) { //... } void Destory() { //... } // 成员变量 int a; // 声明 int top; // 声明 int capacity; // 声明 };
这个类的大小是多少?经过编译,得到其大小为12个字节,这是怎么回事?12个字节是成员变量的大小,也就是说成员函数的大小为零,成员函数没有存放在类中,为什么呢?
原因如下:因为每创建一个类对象,都要开辟一次空间,对于成员变量来说,每个对象的成员变量都不一样,所以每个对象都要开辟一遍所有的成员变量,这是无可厚非的,但是对于成员函数就不一样了。尽管创建很多个不同的类对象,但是它们的成员函数都是一样的,并不会改变,所以就没有必要在每个类对象中都去创建一遍所有的成员函数,这会造成空间浪费。于是C++把类的成员函数放在公共代码区,当哪个对象需要调用成员函数的时候,来代码区调用即可。所以类的大小只看成员变量的大小。既然成员函数不计算大小,那如果类里面没有成员变量呢,此时类的大小是多少?
经过测试,我们得到空类的大小为1字节。这是因为我们创建了一个类,编译器为了让我们创建的类存在,主动给了它1个字节的空间以表示它是存在的。
但是别忘了,类和结构体一样,也是要遵守内存对齐规则的。
-
结构体内存对齐规则
- 第一个成员在与结构体偏移量为0的地址处。
- 其他成员变量要对齐到对齐数的整数倍的地址处。
对齐数=编译器默认的一个对齐数 与 该成员大小 两者取小的。
VS中默认对齐数为8 - 结构体总大小为:最大对齐数(所有变量类型最大的那个 与 默认对齐数 两者取小的)的整数倍。
- 如果嵌套结构体,先算嵌套的结构体的大小,再把嵌套的结构体当作一个普通的类型,算整个结构体的大小,方法和上面一样。
八、This指针
-
this指针的引出
在前面《类的定义》一节中我们讲到过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; } // 实际上编译器会自动帮我们把函数变成这样: // void Print(Data* const this) // const在*后,修饰this,表示不能改变指向,若const在*前,则修饰*this,表示不能改变解引用的值 // { // cout << this->_year << "-" << this->_month << "-" << this->_day << endl; // } private: int _year; // 年 int _month; // 月 int _day; // 日 int a; }; int main() { Date d1, d2; d1.Init(2022,1,11); // 实际上传的是d1.Init(&d1,2022,1,11); d2.Init(2022,1,12); d1.Print(); // 实际上传的是d1.Print(&d1); 但是不显示 d2.Print(); return 0; }
对于上述类,有这样的一个问题:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用 Init 函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?
C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。注意:this指针不能在实参和形参处显示传递,也就是说,我们不能自己手动加一个
&d1
或者Date* const this
进去,我们可以直接在函数内部用this->
来访问该对象的内部,但是我们不能自己创建一个this。 -
this指针的特性
-
this指针的类型:
类的类型* const
,他是一个这个类的类型的指针。 -
this指针存在内存哪个位置?
this指针是形参,和普通的形参一样,都是存储在函数调用的栈帧中的。 -
this指针不能为空:
// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void Print() { cout << "Print()" << endl; } private: int _a; }; int main() { A* p = nullptr; p->Print(); return 0; } // 正常运行 // 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行 class A { public: void PrintA() { cout<<_a<<endl; } private: int _a; }; int main() { A* p = nullptr; p->PrintA(); return 0; } // 运行崩溃
解析:
p调用Print函数,不会发生解引用,因为Print函数的地址不在对象中。p会作为实参传递给this。
第一个程序中:this指针是空的,但是函数内部并没有去对this指针解引用访问。因此可以运行。
第二个程序中:this指针是空的,函数内部对this指针解引用访问p->_a。因此运行崩溃。深入了解:
P是空指针,也就是说我的对象并没有实例化,p仅仅只是A*这个类型的而已。p->Print();其实并没有进入这个空指针内部去调用函数,为什么呢?很简单,因为类的成员函数并不在类里边,它们是放在公共代码区域的,所以尽管并不存在这么一个实例化的对象,但也能够访问这个函数。this指针传来的是nullptr,也算是传来了指针,而且在当前函数中,并没有去访问对象内的成员变量(比如说_a),所以尽管是nullptr,也不影响,照样能够打印"Print()"。 -
const修饰:
刚才说到,编译器默认为我们加的this指针类型是Date* const
的,此时const修饰的是this,this指针的指向不能被改变,但是通过this->
解引用的内容是可以改变的。当我们不想this->
解引用的内容也不被改变时,就要再加一个const修饰。但是问题是:this指针是编译器自动添加的,Date* cosnt this
已经写死了,我们根本没有办法再塞一个const进去。此时引出一个针对this指针的新的const修饰方法:在函数第一行的末尾加上const。请看下面的例子:class A { public: void Print() const { cout << "Print()" << endl; } private: int _a; };
注意:新的const加在第4行后面,看似是修饰函数,其实是修饰this指针的,此时this指针就从
A* const this
变成了const A* const this
,this指针不仅指向不可被改变,内容也不可以被改变了。 -
this指针不能手动传递:
class A { public: void PrintA() { cout<<_a<<endl; } private: int _a; }; int main() { A* p = nullptr; //A::Print(nullptr); - 错误! //A::Print(p); - 错误! p.Print(); // 正确~ return 0; }
- this指针是不显示实参和形参的,编译器会自动生成,所以我们不能自己手动去传个指针进去。
- 一切类似
A::Print()
的写法都是错误的,只能通过p->Print();
的形式来访问成员函数。(你给个A::
是个啥意思?尽管类的成员函数是存在公共代码区的,但是也只能通过某个具体的对象来访问,必须要实例化才可以访问。)
-