目录
一. 对于面向过程和面向对象的认识
- C语言:是面向过程的,注重分析解决问题的每个细节过程,通过调用函数逐步解决问题。
- C++:是面向对象的,关注的是对象,将一个问题拆分为不同的对象,依靠对象之间的交互来解决问题。
注意:严格来说,C++应该是面向对象和面向过程混编的语言,因为C++兼容C语言。
举个通俗的例子来理解什么是面向过程和面向对象。假设要设计一个简易的外卖系统:
- 面向过程:关注下单、接单、送餐等过程,体现的代码层面就是方法和函数。
- 面向对象:关注客户、骑手、商家等对象及他们之间的相互关系,体现到代码层面就是类的设计定义和类之间的关系。
二. 类
2.1 struct关键字定义类
2.1.1 C语言中的struct关键字
在C语言中,struct表示结构体,如演示代码2.1所示,通过struct声明了学生信息结构体类型变量,其中包含name、age两个结构体成员,在主函数中,定义结构体类型变量并初始化和打印。
演示代码2.1:
#include<stdio.h>
struct stu
{
char name[20];
int age;
};
int main()
{
struct stu s = { "zhangsan", 25 };
printf("name: %s, age: %d\n", s.name, s.age);
return 0;
}

2.1.2 C++中的struct关键字
在C++中,struct被升级为了类,同时,struct类兼容C语言中结构体的用法。与C语言结构体不同,在struct类中,我们不仅可以定义成员变量,还可以定义函数。struct中定义的函数可以直接使用类的成员变量,一般来说,为了区分类的成员变量和成员函数的形参,我们在成员变量名称的前面加_,如:int _age 和 char _name[20],函数形参则通过不加‘_’来区分,如func(int age, char* name)。
在演示代码2.2中,首先定义结构体类型变量s1,通过C语言初始化结构体成员变量的方法将struct类中的成员变量初始化,然后调用成员函数Print来打印每个成员变量,以此来证明C++中的struct兼容C语言结构体的用法。然后,定义对象s2(定义类可以省略struct),通过语句s.Init("lisi", 30)来调用成员函数Init为s2的成员变量赋值,之后再次调用Print函数打印成员变量信息。
调用类成员变量的语法格式为:对象名.类成员函数(传参列表)。
演示代码2.2:
struct stu
{
//成员变量
char _name[20];
int _age;
//成员方法
void Init(const char* name, int age) //初始化成员变量函数
{
strcpy(_name, name);
_age = age;
}
void Print() //打印成员变量函数
{
cout << "姓名:" << _name << endl;
cout << "年龄:" << _age << endl;
}
};
int main()
{
//采用C语言的方法定义结构体类型变量s1并初始化其成员变量
struct stu s1;
strcpy(s1._name, "zhangsan");
s1._age = 20;
s1.Print(); //打印结构体成员变量
//采用C++的方法定义对象s2,调用成员函数Init初始化成员变量
stu s2; //定义类可以省去struct
s2.Init("lisi", 30);
s2.Print();
return 0;
}

2.2 class关键字
2.1 使用class关键字定义类
在C++中,可以使用struct关键字定义类,也可以使用class关键字定义类。class定义的类与struct定义的类相似,其中可以定义成员变量和和成员函数。如演示代码2.3所示,定义了一个名为stu的类,其中包含成员变量name和age、成员函数Init和Print。
这是否说明在C++中,class定义类和struct定义类没有任何不同?显然不是。class定义的类的成员变量和成员方法的属性为私有属性,而struct定义的类为了兼容C语言结构体的用法,默认为公有属性。第三章中会对公有属性和私有属性进行讲解。
演示代码2.3:
class stu
{
//成员变量
char _name[20];
int _age;
//成员方法
void Init(const char* name, int age) //初始化成员变量函数
{
strcpy(_name, name);
_age = age;
}
void Print() //打印成员变量函数
{
cout << "姓名:" << _name << endl;
cout << "年龄:" << _age << endl;
}
};
三. 类的访问限定及封装
3.1 类的访问权限及访问限定符
3.1.1 访问权限
如果用演示代码3.1所示的主函数访问演示代码2.2中class定义的,会报出某些成员变量及成员函数无法访问的错误,这是为什么?那为什么演示代码2.2同样是在主函数中对类的成员进行访问,代码就能正常运行呢?这就涉及到访问权限的问题。
演示代码3.1:
class stu
{
//成员变量
char _name[20];
int _age;
//成员方法
void Init(const char* name, int age) //初始化成员变量函数
{
strcpy(_name, name);
_age = age;
}
void Print() //打印成员变量函数
{
cout << "姓名:" << _name << endl;
cout << "年龄:" << _age << endl;
}
};
int main()
{
stu s1;
strcpy(s1._name, "zhangsan");
s1._age = 20;
s1.Print();
return 0;
}

类的访问权限分三种:公有、私有、保护。
struct关键字定义的类默认访问权限是公有,可以在类的外部直接被访问。class定义的关键字默认访问权限为私有,无法在类的外部直接进行访问。
3.1.2 访问限定符
三种访问权限,对应三个访问限定符:private -- 私有、public -- 公有、protect -- 保护。我们可以通过使用三个访问限定符,来改变类的成员的属性,来决定类成员是否可以在类外部进行访问。

演示代码3.2对2.2中定义的stu类使用访问限定符更改类成员的属性,将成员变量全部设为私有属性,将成员函数全部设为共有属性。这样,在主函数中,就能访问成员函数了,但还是不能访问成员变量。
演示代码3.2:
class stu
{
private:
//成员变量
char _name[20];
int _age;
public:
//成员方法
void Init(const char* name, int age) //初始化成员变量函数
{
strcpy(_name, name);
_age = age;
}
void Print() //打印成员变量函数
{
cout << "姓名:" << _name << endl;
cout << "年龄:" << _age << endl;
}
};
int main()
{
stu s1;
s1.Init("zhangsan", 20);
s1.Print();
return 0;
}

关于访问限定符的几点说明:
- private修饰的成员可以在类外面直接被访问,public和protect修饰的成员不能在类外面被直接访问。
- 一个访问限定符限定的访问权限从这个访问限定符开始,到下一个访问限定符出现结束。如果该访问限定符后面没有访问限定符,则限定区域为该访问限定符出现到右括号 } 。
- class的默认权限为私有private,struct的访问权限默认为公有public。
- 当使用默认访问权限时,不建议省去访问限定符,也就是说,class中要通过private来显示的说明成员为私有类型,struct中也要显示的说明成员为公有,这样能增强代码的可读性。
3.2 封装
面向对象的三大特性:封装、继承、多态。
在类和对象阶段,主要研究封装特性。所谓封装,就是将数据和方法进行有机结合,对外隐藏属性和实现细节,仅通过对外提供接口来实现与对象的交互。
封装是一种更为严格的管理,C语言不支持封装,所有可以认为C++相对于C语言更加严谨。通过演示代码3.3,可以说明封装管理的价值。演示代码3.3定义了一个栈类,名称设为Stack,其中包含三个成员变量:int* _a -- 指向存储数据的内存空间、int _size -- 栈中现有数据个数、int _capacity -- 栈容量,同时还有三个成员函数:Init -- 栈初始化、Push -- 压栈、Print -- 打印栈中数据、Destroy -- 栈销毁。
将成员变量设为private属性,将成员函数设为public属性,这些,成员函数就充当了对外接口,来实现与类stack的交互,不能在类的外部随意对成员变量进行操控。如果采用C语言实现栈,由于无法实现封装,那么在主函数中就可以单独操控某个成员变量,造成不可预料的后果。
演示代码3.3:
class Stack
{
public:
void Init(int capacity = 4) //栈初始化函数
{
_a = (int*)malloc(capacity * sizeof(int));
if (_a == NULL)
{
printf("malloc fail\n");
exit(-1);
}
_size = 0;
_capacity = capacity;
}
void Push(int x) //压栈函数
{
if (_size == _capacity)
{
//检查栈容量是否足够,不够则重新开辟空间(省略)
}
_a[_size] = x;
_size++;
}
void Print() //打印栈数据函数
{
for (int i = 0; i < _size; ++i)
{
printf("%d ", _a[i]);
}
printf("\n");
}
void Destroy() //栈销毁函数
{
free(_a);
_a = NULL;
_size = _capacity = 0;
}
private:
int* _a;
int _size;
int _capacity;
};
int main()
{
Stack s;
s.Init(); //初始化栈
s.Push(1); //压栈
s.Print(); //打印栈
s.Push(2); //压栈
s.Print(); //打印栈
s.Push(3); //压栈
s.Print(); //打印栈
s.Destroy(); //销毁栈
//s._size = 5; //企图直接更改类中成员变量的值,报错
return 0;
}

四. 类的作用域
类会定义一个新的作用域,如果需要在类的外面定义类成员,就需要使用作用域限定操作符::。如演示代码4.1所示,在head.h头文件中声明一个名为stu的类,其中定义了两个成员变量_name和_age,声明了两个成员函数Init和Print。如果想要在stu.cpp源文件中定义Print函数,就需要使用::操作符。
演示代码4.1:
//head.h
#pragma once
#include<iostream>
#include<string.h>
using namespace std;
class stu
{
private:
char _name[20];
int _age;
public:
void Print();
void Init(const char* name, int age);
};
//stu.cpp
#include "head.h"
void stu::Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
void stu::Init(const char* name, int age)
{
strcpy(_name, name);
_age = age;
}
//test.cpp
#include "head.h"
int main()
{
stu s;
s.Init("lisi", 20);
s.Print();
return 0;
}

五. 类对象的存储方式和大小计算
5.1 类对象在内存中的存储方式
我们猜测,类对象有两种可能的存储方式:
- 每个对象中保存一份成员变量、保存一份成员函数地址,成员函数代码只保存一份。
- 对象中只保存成员函数,代码存储在公共代码区。
为了验证上面两种可能的存储方式那种正确,我们编写了演示代码5.1,在class定义的类c中,包含三个int型成员变量和两个成员函数,在主函数中通过sizeof计算类的大小并打印。结果表明,类c的大小和类实例化出来的对象c1的大小均为12bytes,如果方法1正确,那么类的和类实例化出来的对象的大小都应该是20bytes,可见,2才是类对象的正确存储方式。
演示代码5.1:
class C
{
//成员变量
private:
int _i1;
int _i2;
int _i3;
//成员函数
public:
void func1() { };
void func2() { };
};
int main()
{
C c1;
cout << "sizeof(C) = " << sizeof(C) << endl; //类的大小 -- 12
cout << "sizeof(c1) = " << sizeof(c1) << endl; //类对象的大小 -- 12
return 0;
}


类对象的成员,在内存中的存储规则与C语言中结构体内存对齐规则一致,C语言结构体的内存对齐规则为:
- 结构体首个成员存储在偏移量为0的位置。
- 其他成员变量要对齐到这个成员变量对齐数的整数倍处。对齐数为默认对齐数和该成员变量的类型大小中较小的那个。在VS编译环境下默认对齐数的大小为8。
- 结构体的总大小需要为最大对齐数的整数倍。
5.2 空类的大小
结论:空类的大小为1bytes。
演示代码5.2:
class c1
{
public:
void func1() {};
};
class c2 {};
int main()
{
cout << "sizeof(c1) = " << sizeof(c1) << endl;
cout << "sizeof(c2) = " << sizeof(c2) << endl;
return 0;
}

为什么空类中什么都没有,还要占1bytes的内存空间呢?这1bytes的内存空间要起到一个占位作用,因为如果空类的大小为0,那么如果用这个类初始化出来两个对象,那么就会存在如何区分这两个类对象的问题,如果对两个对象取地址得到的都是nullptr,那么就无法对类对象进行区分。为空类分配的这1bytes的内存空间不存储任何有效数据。
六. this指针
6.1 什么是this指针
在上一章中讲到,同一个类的所有成员函数都只在公共代码区域存储一份,那么,在调用类成员函数操控成员变量是,怎样确定是操控哪一个类对象的成员变量呢?
这里就涉及到类成员函数隐藏的一个形参:this指针。演示代码6.1定义了一个日期类Date,在主函数中创建了两个类对象d1和d2。通过调用Init函数和Print函数,分别打印类成员信息。我们可以看到,d1和d2成员变量的值被正确区分,因为,Init函数和Print函数都存在一个隐藏的形参:Data* this。从表面上看,Init函数有三个参数,Print函数没有参数。但实际上,在调用函数时,都将指向类对象的this指针作为参数传给了成员函数。
演示代码6.1:
class Date
{
private:
int _year;
int _month;
int _day;
public:
//本质上为:void Init(Date* this, int year, int month, int day)
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//本质上为:void Print(Date* this)
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
};
int main()
{
Date d1;
Date d2;
d1.Init(2023, 2, 24); //d1.Init(&d1, 2023, 2, 24)
d1.Print(); //d2.Print(&d1)
d2.Init(2023, 2, 25); //d2.Init(&d2, 2023, 2, 25)
d2.Print(); //d2.Print(&d2)
return 0;
}

6.2 this指针的特性和使用规则
- this指针的参数类型为:类名* const this,在类成员函数内部,不能改变this指针的指向,但可以通过解引用this指针控制成员变量
- 在声明和定义成员函数时,不能显示的将this指针作为函数形参。如:void Print(Date* this)是不被允许的。
- 在调用成员函数时,不能将this指针类型的数据作为形参显示的传递给成员函数。如:d1.Print(&d1)这样的调用方式是不被允许的。
- 在成员函数内部,可以显示的使用this指针。
在演示代码6.2中,调用成员函数Print,其中:cout << _year << endl 和 cout << this->_year << endl的意义是完全一致的。
演示代码6.2:
class Date
{
private:
int _year;
int _month;
int _day;
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << endl;
cout << this->_year << endl;
}
};
int main()
{
Date d1;
d1.Init(2023, 2, 24);
d1.Print();
return 0;
}

6.3 this指针的存储位置
一般情况下,this指针作为形式参数,会存储在栈中。但是,由于this指针在成员函数被调用阶段会被频繁大量的使用,所以,一些编译器会对this指针的存储位置进行优化:将this指针存储在寄存器中,以此来提高读取和访问数据的效率。
- 数据读取速度:寄存器 > 高速缓存 > 内存
- 空间大小:寄存器 < 高速缓存 < 内存
- 制造成本:寄存器 > 高速缓存 > 内存
在VS2019编译环境中对演示代码6.2进行调试,打开汇编代码进行观察(如图6.3),可以看到,在调用函数之前,将类对象d1的地址载入到ecx寄存器中(汇编指令lea的意思为加载有效地址 -- load effective address),这就证实了VS2019编译器将this指针存储到了寄存器中。
