在之前我们学的C++入门主要是为现在学习类与对象打基础,今天我们才算真正开始学习C++了。因为类与对象的知识点比较多,所以我们将它分为三部分讲解,今天我们学习类与对象的上篇。
面向过程顾名知义,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。例如:C语言。
面向对象,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。例如:C++,java(注意:C++兼容C,所以C++并不是纯面向对象的语言,是支持面向对象和面向过程的“混编”)。
外卖系统分别用面向过程与面向对象是怎样实现的?如下图所示:
tip: 现在大家先对概念理解一下,随着以后的学习会慢慢理解面向对象的思想。
在C语言中,数据与方法是分离的。而在C++中,数据与方法没有分离。所以在C++中struct被升级成了类——结构体内不仅可以定义变量,也可以定义函数。
代码示例:写一个简易的栈类
#include
#include
//展开命名空间
using namespace std;
//栈类
typedef int DataType;//栈的元素类型
struct Stack
{
//成员函数
//栈的初始化
void Init(int capacity = 4)
{
//在堆区开辟capacity个栈空间
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (nullptr == _array)
{
perror("malloc fail");
return;
}
//初始化栈的容量与栈顶位置
_capacity = capacity;
_top = 0;//指向栈顶位置的下一个
}
//入栈
void push(const DataType& x)
{
//判断是否扩容
if (_top == _capacity)
{
//扩容
}
//入栈
_array[_top] = x;
_top++;
}
//访问栈顶元素
DataType Top()
{
return _array[_top - 1];
}
//销毁
void Destroy()
{
free(_array);
_array = nullptr;
_capacity = 0;
_top = 0;
}
//成员变量
DataType* _array;//指向堆区开辟的数组
int _capacity;//栈的容量
int _top;//栈顶位置
};
int main()
{
//C++兼容C,struct以前的用法都可以继续使用
struct Stack s1;
//C++类名就是类型,所以可以直接写Stack
Stack s2;
s2.Init();//缺省参数——没有传实参,使用缺省值
s2.push(1);
s2.push(1);
cout << s2.Top() << endl;
s2.Destroy();
return 0;
}
tip:
①C++将struct升级成了类,不仅可以定义变量,还可以定义函数。
②C++中类名就是类型,在C里面struct 类名组合在一起才是类型。因为C++兼容C所以两种写法都可以,struct以前的用法都可以继续使用。
③我们发现在类中变量成员在声明前面可以使用,这是因为类域是一个整体,所以变量写在后面,也不用声明。
⑤虽然struct被升级成了类,但是在C++中更喜欢用class来代替。
class className
{
//类体:由由成员变量和成员函数组成
}; //注意与struct一样后面要有分号
解读:
①class是定义类的关键字,className为类名,{}中为类的主体,注意类定义结束时后面分号不能省略。
②类体中的内容称为类的成员:类中的变量称为类的属性或成员变量;类中的函数称为类的方法或成员函数。
①声明和定义全部放在类体中 ,需要注意:成员函数如果在类中定义,默认为内联函数,最后由编译器决定。
class Person
{
//函数的声明与实现都在类中
void showInfo()
{
cout << _name << " " << _age << endl;
}
//成员函数
char* _name;
int _age;
};
②类声明放在.h文件中,成员函数定义放在.cpp文件中。如下图:
tip:类成员函数定义时,注意要成员函数名前面要类名::,表明它是那个类的。
总结:对于这两种方法,平时我博客讲解的时候为了方便使用方法1定义类,但是建议大家在以后写项目和工作时使用方法2。
为了避免成员变量与成员函数的参数同名,我们一般可以①成员变量加前缀_;②成员变量加前缀my_;③成员变量加后缀_等方法。
代码示例:
class Date
{
public:
void Init(int year)
{
//因为成员变量加了前缀_,所以这里我们能很好的区分该语句是给对象的_year赋值
_year = year;
}
private:
int _year;
};
我们先来看两段代码:
代码1:能运行吗?
struct Person
{
char _name[10];
int _age;
};
int main()
{
//定义对象p1
Person p1;
//直接修改对象p1的年龄
p1._age = 18;
return 0;
}
代码2:能运行吗?
class Person
{
char _name[10];
int _age;
};
int main()
{
//定义对象p1
Person p1;
//直接修改对象p1的年龄
p1._age = 18;
return 0;
}
答案是:代码1能运行,代码2不能运行,出现语法错误,这是为什么呢?这就是我们接下来要讲解的类的访问限定符。
C++实现封装的方式:用类将对象的属性与方法结合在一起,让对象更加完善,通过访问权限选择性的将接口提供给外部的用户使用。
①public修饰的成员在类外可以直接被访问。
②protected和private修饰的成员在类外不能直接被访问。(在C++初阶protected和private类似,在后面进阶讲继承的时候才能体现他们的区别)
③访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现为止。
④如果后面没有访问限定符,作用域就到“}”即类的结束。
⑤class的默认访问权限为private,struct为public(因为struct要兼容C)。
注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。
C++需要兼容C,所以C++中struct可以当作结构体使用。另外C++中struct还可以用来定义类。和class定义类一样,区别是struct定义的类默认访问权限是public,class定义的类默认权限是private。注意:在继承和模板参数列表位置,struct和class也有区别,后序再给大家讲解。
面向对象的三大特性:封装、继承、多态。
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交换。
在我们生活中有许多封装的实例,例如:你家的房子,就是一个封装。如果不封装的话,那谁都可以进你家了。
封装的本质是一种管理,让用户更方便使用类。
在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来隐藏对象内部实现细节,控制那些方法可以在类外直接被使用。
①类定义了一个新的作用域,类的所有成员都在类的作用域中。
②在类体外定义成员时,需要使用::作用域操作符指明成员属于哪个类域。
③类域是一个整体,成员变量不是一定写在成员函数后面的。
代码示例:
#include
using namespace std;
class Person
{
public:
void PrintPersonInfo();
private:
char _name[20];
char _gender[3];
int _age;
};
//这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
cout << _name << " " << _gender << " " << _age << endl;
}
①我们已经学习了四种域:局部域、全局域、命名空间域、类域。
②同一个域不能定义同名变量,不同域可以定义同名变量。
③域都会影响访问,但只有局部域和全局域影响生命周期。
④编译器访问变量规则:一般默认先在局部找,找不到再去全局找(都找不到则报错);特殊类方法先局部找,找不到去类域找,最后再去全局找。
⑤命名空间域与全局域平行,但是如果不展开就不会访问。
⑥::作用域操作符,指定访问某个域的变量。指定方式:域名::变量名。
用类类型创建对象的过程,称为类的实例化。
①类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它。
②一个类可以实例化出多个对象,实例化出的对象才占用实际的物理空间,存储类的成员变量。
③类就像是设计图,类实例化出对象就像现实中使用建筑设计图建造出的房子。 图纸并没有实体,同样类也只是一个设计,只有实例化出的对象才能实际存储数据,占用物理空间。
代码示例:
#include
using namespace std;
class Person
{
public:
void PrintPersonInfo()
{
cout << _name << " " << _sex << " " << _age << endl;
}
public:
char* _name;
char* _sex;
int _age;
};
int main()
{
//下面语句是否正确?
//Person::_age = 18;//错误,因为类没有实例化,并没有开辟空间。只有类实例化出的对象才有具体的年龄。
//tip:类的对象要整体实例化才可以。
//类实例化对象/对象定义
Person man;
//tip:只有类实例化,开辟了空间,才能存储数据
man._name = (char*)"zhangsan";
man._sex = (char*)"男";
man._age = 18;
man.PrintPersonInfo();
return 0;
}
问题: 类中既有成员变量,也有成员函数,那么一个类的对象中包含什么?如何计算一个类的大小?
我们先来一段代码示例,用编译器运行计算看类A的大小是多少。
#include
using namespace std;
class A
{
public:
//成员函数
void f()
{}
private:
//成员变量
int _a;
char _ch;
};
int main()
{
//实例化对象
A a;
//打印类对象的大小
cout << sizeof(a) << endl;
return 0;
}
运算结果:类A的大小
解读:
①类对象存储: 类对象只保存类的成员变量,不保存类的成员函数。(为什么会这样呢?详细讲解在后面类对象存储猜测)
②类的大小计算: 与C语言计算结构体的方式一样,需要注意内存对齐。
③回顾内存对齐的规则:
1、结构体的第一个成员,对齐到结构体变量在内存中存放位置的0偏移量。
2、从第二个成员开始,每个成员变量都要对齐到(一个对齐数)的整数倍。
- 对齐数=编译器默认的一个对齐数与一个结构体成员自身大小的较小值。
- VS默认对齐数为8;Linux gcc没有默认对齐数,对齐数就是结构体成员自身大小。
3、结构体总大小,必须是所有成员变量的对齐数中最大对齐数的整数倍。
4、如果是嵌套结构体的情况,嵌套的结构体对齐到自己最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
④内存对齐的意义:
1、平台原因(移植原因):不是所有的硬件平台都能访问任意地址上的任意数据;某些硬件平台只能在某些地址处取某些特定的数据,否则抛出硬件异常(例:int——>对齐到4的整数倍)。
2、性能原因:计算机读取数据时,并不是想访问哪个字节就访问哪个字节,而是从结构体的对齐边界开始按照访问倍数去访问。假设CPU一次访问4个字节(具体与硬件有关),对齐访问_a只要访问一次,不对齐_a要访问两次,如下图所示:
如图可知,内存对齐是拿空间换时间。
⑤类A的内存大小为8,如图所示:
①对象中包含类的各个成员:
缺陷: 不同对象中成员变量不同,但是调用同一个函数,如果按照此方式存储,当一个类创建多个对象时,**每个对象都会保存一份代码,相同代码保存多次,浪费空间。
②只保存成员变量,成员函数存放在公共的代码段:
tip:关于上述两种存储方式,计算机按照方式二来存储。
代码示例:
#include
using namespace std;
//类中仅有成员函数
class A1
{
public:
void f()
{}
};
//类中什么都没有——空类
class A2
{};
int main()
{
//输出A1,A2的大小
cout << sizeof(A1) << endl;
cout << sizeof(A2) << endl;
return 0;
}
运行结果:
总结:
①没有成员变量的类对象,需要一个字节,是为了占位(不存储有效数据),表示对象存在。
②一个类的大小,实际就是该类中成员变量之和,还需要注意内存对齐。
我们来定义一个日期类Data:
#include
using namespace std;
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(2022, 1, 11);
d2.Init(2022, 1, 12);
d1.Print();
d2.Print();
return 0;
}
运行结果:
问题: d1与d2调用同一个函数print,为什么打印结果不一样呢?(在类的存储模型我们知道成员函数与对象无关,它存储在公共区域。)
答案是: C++中引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数”添加了一个隐藏的this指针参数,让该this指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成。如下图所示:
①调用成员函数时,不能显示传递对象的地址给this,因为编译器会自动传递了,不需要用户传递。
tip: 既然编译器会自动传递对象地址,为什么不通过类来调用函数——因为通过类来调用函数,不会传this指针;通过对象调用函数,虽然不再对象里找函数,但是会传this指针。
②定义成员函数时,也不能显示定义this指针,因为编译器会自动定义。
tip: this指针是一个关键字,指向当前对象地址。
③this指针可以在函数体内部显示使用,如下代码所示:
void Print()
{
//语法规定:this指针不能在形参和实参显示传递,但是可以在函数内部显示使用
//函数体中所有成员变量都要通过this指针访问
//1、在函数体中,访问成员变量,你不写他会自动添加this
cout << _year << "-" << _month << "-" << _day << endl;
//2、在函数体内可以自己显示使用this指针
cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
tip: 在函数内部,你访问对象的成员变量你不写this指针它会自动添加,你也可以显示使用。
tip:左定值,右定向。
const在*的左边,则指针指向的变量的值不能通过指针改变;在 * 的右边,则指针的指向不能改变。
代码示例:
int main()
{
int a = 10;
//右定向
int* const pa = &a;
int b = 9;
//pa = &b;//报错,左值不可修改,即指针指向不能改变
*pa = b;//可以修改指向变量的值
return 0;
}
问题1: this指针存在哪里?
答案是:this指针本质上是“成员函数”的形参,所以this指针与普通参数一样存在函数调用的栈帧里面。如下图汇编代码:
tip: 在VS集成开发环境下,对this指针进行了优化,对象地址是放在ecx寄存器,ecx存储this指针的值。
问题2: this指针可以为空吗?
我们先来看两段代码,判断A、编译报错 B、运行崩溃 C、正常运行
代码1:
class A
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print();
return 0;
}
代码2:
class A
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA();
return 0;
}
答案是: 代码1:C、运行成功;代码2:B、运行崩溃。解析如下图所示:
tip: C就像手动挡需要自己控制变速箱,C++有了封装,引入this指针就像自动挡电脑程序控制变速箱。简单来说,就是更简单,不易出错了