目录
4.1.2 声明放在头文件(.h)中,定义放在(.cpp)文件中
学习C语言的小伙伴都知道,C语言是面向过程的,分析问题找到解决步骤,通过函数调用逐步解决问题。
面向过程编程也叫结构化编程。虽然结构化编程的理念提高了程序的清晰度,可靠性,并且方便维护。但它再编写大型的程序时,仍然面临这巨大的挑战,OOP(面向对象编程)提供了一种新的方法。与强调算法的过程性编程不同的是,OOP强调的是数据。
——引自《C++ Primer Plus(第六版)》
1. 面向对象编程
C++是面向对象的,关注的是对象,将一件事情分解成不同的对象,然后通过对象之间的交互来解决问题。
在C++中,类是一种规范,描述的是一种新型数据格式,对象是根据这个规范构造的数据结构。那么有小伙伴会问,什么是类?(这个我们在 3. 类的引入 中会详细讲解)
2. 面向对象编程和面向过程编程
举下面这个例子能够更形象展示OPP与面向过程性编程的区别:
此举例改变自《C++ Primer Plus(第六版)》:
曼联足球俱乐部的一名新成员被要求记录球队的统计数据。很自然他会借助计算机来完成这项任务。
如果这个新成员是过程性程序员,可能会这样考虑:
我要输入每名运动员的姓名,进球数,助攻数,登场数等其他重要的基本统计数据。之所以使用计算机,是为了简化统计工作,因此让他来计算某些数据。另外,我还希望程序能够显示这些结果。应该如何组织呢?我让main()调用一个函数来获取输入,调用另外一个函数来进行计算,然后调用第三个函数来显示结果。那么,获得下一场比赛的数据后,又改怎么做呢?当然不想从头开始,可以添加一个函数来更新统计数据。可能需要在main函数中添加一个菜单,选择是输入,计算,更新还是显示数据等。则如何表示这些数据呢。可以使用一个字符串来存储选手的姓名,用另外一个数组来存储每位球员的进球数,再用一个数组存储助攻数等等。这种方法太不灵活了。因此可以设计一个结构体来存储每位球员的所有信息,然后用这种结构组成的数组来表示整个球队。
总之,采用过程性编程时,首先要考虑遵守的步骤,然后考虑如何表示这些数据。
如果换成一个OOP程序员,又将如何考虑呢?
首先要考虑数据——不仅要考虑如何表示数据,还要考虑如何使用数据:
OOP程序员会想,我要跟踪的是什么?当然是球员。因此要有一个对象表示整个球员的各个方面(不仅仅是进球数或助攻数)。因此这将是基本数据单元——一个表示球员的姓名和统计数据的对象。我需要一些处理该对象的方法。首先需要一种将基本信息加入到该单元中的方法;其次,计算机应计算一些东西,如进球率。因此要添加一些执行计算的方法。程序应自动完成这些计算,而无需用户的干扰。另外,还需要一些更新和显示信息的方法。所以,用户与数据交互的方式有三种:初始化,更新和报告——这就是用户接口。
总之,采用OOP方法时,首先从用户的角度考虑对象——描述对象所需的数据以及描述用户与数据交互所需的操作。完成对接口的描述之后,需要确定如何实现接口和数据存储。最后,使用寻得设计方案创建出程序。
3. 类的引入
在面向过程化编程中,我们通常使用结构体来描述对象(以C语言为例)。在C语言中,结构体中只能定义变量,但是在C++中,结构体中也可以定义函数:
struct Student { void SetStudentInfo(const char* name, const char* gender, int age) { strcpy(_name, name); strcpy(_gender, gender); _age = age; } void PrintStudentInfo() { cout << _name << " " << _gender << " " << _age << endl; } char _name[20]; char _gender[3]; int _age; }; int main() { Student s; s.SetStudentInfo("Peter", "男", 18); return 0; }
但是在C++中,我们更喜欢使用Class来代替。
4. 类的定义
class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号
-
class是定义一个类的关键字,classname是类名,{}中是类的主体,注意类的定义后有分号。
-
类中的元素称为类的成员:类中的数据又叫类的属性/类的成员变量、类的函数又叫做类的方法/类的成员函数。
4.1 类的两种定义方式
4.1.1 类的声明和定义全部放在类体中
class Student { public: void SetStudentInfo(const char* name, const char* gender, int age) { strcpy(_name, name); strcpy(_gender, gender); _age = age; } void PrintStudentInfo() { cout << _name << " " << _gender << " " << _age << endl; } public: char _name[20]; char _gender[3]; int _age; };
注意:
如果成员函数在类中定义, 编译器有可能会将其当成内联函数处理。
知识点补充:
类中的成员函数都有内联属性 。
4.1.2 声明放在头文件(.h)中,定义放在(.cpp)文件中
//student.h //学生 class Student { public: void SetStudentInfo(const char* name, const char* gender, int age); void PrintStudentInfo(); public: char _name[20]; char _gender[3]; int _age; }; //test.cpp #include "student.h" void Student::SetStudentInfo(const char* name, const char* gender, int age) { strcpy(_name, name); strcpy(_gender, gender); _age = age; } void Student::PrintStudentInfo() { cout << _name << " " << _gender << " " << _age << endl; }
5. 类的访问限定符和封装
5.1 访问限定符
刚才有细心的小伙伴已经发现了刚才那段代码中出现了public,这个关键字是什么呢?这个就是我么要接下来要说的访问限定符,C++中除了public(公有)外,还有private(私有)和protected(保护)限定符。
那么C++中引入访问限定符的意义是什么?这个就要补充一下实现类的封装的方式:
用类将对象的属性和方法结合在一起,使得对象的信息更加完整,通过访问限定符将接口提供给外部用户使用。
这些访问限定符有什么含义呢?
访问限定符说明:
- public修饰的成员,在类外也可以访问
- private和protected修饰的成员,在类外不能访问(在对于类内和类外而言private和protected没有区别,但是针对后面要讲的子类是有区别的)
5.2 封装
封装:将类的属性和方法进行有机结合,隐藏对象的属性和实现细节,仅仅对外公开接口与对象进行交互。所以说封装的本质是一种管理机制:我们将类的数据和方法都封装到一起,不想让别人看到,也就是使用private/protected把成员封装起来,开放一些公有的成员函数对成员的合理访问。
下面举一个比较直观地例子来进行说明:
//C语言中数据和方法是分离的 struct Stack { int* _a; int _top; int _capacity; }; void StackInit(struct Stack* ps) { assert(ps); ps->_a = NULL; ps->_capacity = 0; ps->_top = 0; } void StackPush(struct Stack* ps, int x) { } struct Stack StackTop(struct Stack* ps) { } int main() { struct Stack st; StackInit(&st); StackPush(&st, 1); StackPush(&st, 2); StackPush(&st, 3); printf("%d\n", StackTop(&st)); printf("%d\n", st._a[st._top]); //可能就存在误用 printf("%d\n", st._a[st._top - 1]); //可能就存在误用 }
上面的代码是我们学习数据结构时,使用C语言实现的一个栈。如果我们想访问栈顶元素,通常情况下,我们只需要调用已经写好的函数StackTop()即可。但是因为C语言中数据和方法是分离的,所以我也可以通过访问数组下标拿到栈顶元素,但是如果调用者并不清楚程序员的定义方式,就有可能造成误用。例如,这个代码中我们定义,_top是栈顶元素下一个元素的下标,因此栈顶元素的下标就是_top-1,但是调用者不清楚就可能导致误用成_top。
为了解决这一个问题,我们将函数定义在类中,然后同时将成员变量一起封装在类中,这样外界就无法调用。此时我们如果想访问栈顶元素,就只能通过调用公用函数的接口。也就避免了上述问题的发生。
class Stack { private: void Checkcapacity() { } public: void Init() { } void Push(int x) { } void Top() { } private: int* _a; int _top; int _capacity; };
6. 类的作用域
类的定义就意味着创建了一个新的作用域,类的所有成员都在这个类作用域中。此时,如果要在类的作用域外定义一个成员,就需要使用::作用域解析符。
下面举一个例子:
#include <iostream> #include "student.h" using namespace std; void Student::SetStudentInfo(const char* name, const char* gender, int age) { strcpy(_name, name); strcpy(_gender, gender); _age = age; } void Student::PrintStudentInfo() { cout << _name << " " << _gender << " " << _age << endl; }
7. 类的实例化
使用类类型来创建对象,称为类的实例化:
类只是一个模型,限定了类中有哪些成员,但是定义了一个类,并没有分配实际的内存空间来储存成员变量
一个类可以实例化多个对象,实例化对象占用物理空间,储存成员变量
打一个比方,类实例化对象,就像使用设计图建造房子一样。类就是设计图,只是设计建筑需要什么东西,但是并没有实体建筑。类也只是一个设计,在实例化之前并没有实际的储存数据,占用物理空间。
继续使用前面讲到的栈举一个例子:
class Stack { //成员函数 private: void Checkcapacity() { } public: void Init() { } void Push(int x) { } void Top() { } //成员变量 private: int* _a; int _top; int _capacity; }; int main() { Stack st; //类的实例化,st就是一个对象 st.Init(); st.Push(1); st.Top(); return 0; }
8. 类对象模型
8.1 如何计算类对象的大小
我们在学习C语言的时候知道,结构体中只定义变量,因此我们是可以计算得到结构体的大小的,只要使用sizeof()即可:
注意:这里的结果可能是12,因为不同位数操作系统int*的占用字节数不同。
但是我们刚才讲过了,C++中结构体中不仅可以定义变量,也可以定义函数,那么此时结构体的大小又应该怎么算呢?
我们发现似乎不可思议的事情,因为求得这样定义的Stack类的大小居然同样也是16!!!
我们似乎可以做一个大胆但又很合理的猜测:类对象的储存方式是,只储存成员变量,成员函数存放在公共的代码段。
那么为什么要这样存放呢?
在上述中说到,类就像是一份建筑图纸,而所建造的每一个房子中的name,capacity,top应当是不一样的。但是所调用的方法Init(),Top()应当是同一个方法。因此没有必要把函数在对象中存一份。我们也可以通过汇编看看不同的对象是否调用同一个函数。
int main() { Stack st1; //类的实例化,st就是一个对象 st1.Init(); Stack st2; st2.Init(); return 0; }
我们这里实例化两个对象,并且分别了调用两个对象的初始化函数,然后通过汇编代码看一下两个函数调用的地址:
PS:这里如果看不到两个call的小伙伴可以去看我的另一篇文章C++内联函数,然后将按照其中编译器设置的第三步骤,优化inline函数扩展改为默认值即可。
我们能够发现st1和st2所调用得Init()函数是同一份。因此如果都把函数存在类中,就会造成浪费。因此我们可以把函数放在一个公共的区域,这个区域叫做代码段。
结论:一个类的大小,实际就是该类中”成员变量”之和,当然也要进行内存对齐,注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类。注意:最小内存单元是1.操作系统规定都要有地址记录,就像sizeof(void) = 1。
(本篇完)