文章目录
一、面向过程 VS 面向对象
1. 面向过程
面向过程是一种以事件为中心的编程思想,编程的时候把解决问题的步骤分析出来,然后用函数把这些步骤实现,在一步一步的具体步骤中再按顺序调用函数。
2. 面向对象
在日常生活或编程中,简单的问题可以用面向过程的思路来解决,直接有效,但是当问题的规模变得更大时,解决问题的步骤不确定时,用面向过程的思想是远远不够的,所以慢慢就出现了面向对象的编程思想。世界上有很多人和事物,每一个都可以看做一个对象,而每个对象都有自己的属性和行为,对象与对象之间通过方法来交互。面向对象是一种以“对象”为中心的编程思想,把要解决的问题分解成各个对象,建立对象的目的不是为了完成一个步骤,而是为了描叙某个对象在整个解决问题的步骤中的属性和行为。
二、类的概念
在C语言部分学习了结构体,结构体可以把姓名、学号、身高和体重等结构变量放到一起,定义结构体类型,以下面的结构体为例:
struct Student
{
char name[20];
char id[11];
int weight;
int height;
};
int main()
{
struct Student stu1 = { "zhangsan", "2202101001", 60, 180 };
}
在C++中,结构体不叫结构体,而叫“类”;在C语言中定义的结构体变量,在C++中称为“对象”。类比结构体更强大的地方在于:类体内不仅可以定义成员变量,还可以定义成员函数(方法)。
比如用C语言方式实现的栈,结构体中只能定义 top、capacity、a 这些变量,而入栈、出栈、初始化这些函数只能在结构体外部定义;而使用C++我们就可以直接将这些函数定义在结构体内部:
//成员函数与成员变量都定义在结构体中
struct Stack
{
//成员函数(方法)
//初始化
void Init(int N = 4) //缺省参数 -- 初始化空间大小
{
_data = (int*)malloc(sizeof(int) * N);
if (_data == nullptr)
{
perror("malloc fail\n");
exit(-1);
}
_top = 0;
_capacity = N;
}
//入栈
void Push(int x)
{
if (_top == _capacity)
{
int* tmp = (int*)realloc(_data, sizeof(int) * _capacity * 2);
if (tmp == nullptr)
{
perror("realloc fail\n");
exit(-1);
}
_data = tmp;
_capacity *= 2;
}
_data[_top++] = x;
}
//取栈顶的数据
int Top()
{
return _data[_top - 1];
}
//销毁栈
void Destroy()
{
free(_data);
_data = NULL;
}
//成员变量(属性)
int* _data;
int _top;
int _capacity;
};
int main()
{
struct Stack s1; //定义一个属于该类的变量(对象)
s1.Init(); //通过对象调用成员函数
s1.Push(1);
S1.Destroy();
return 0;
}
注意事项
C++类可以直接使用结构体名称代表类名,省略 struct 关键字。比如
Stack s1;
不管C语言还是C++中,结构都用 struct 定义;在C++中定义类更喜欢用 class 代替 struct,但是大体上它们极其类似。
三、类的定义
class ClassName
{
//...... 类的成员:成员变量和成员函数
};
1. 成员函数的定义方式
- 函数声明和定义全部放在类体中,这样编译器可能会将函数当成内联函数处理
- 函数声明和定义分离
函数声明放在类体里,位于头文件中,函数定义放在 .cpp 文件中。由于类定义了一个新的作用域,类的所有成员都在类的作用域中,所以在类体外定义成员时,需要使用作用域限定符::
指明成员属于哪个类域。
注意事项
类域和命名空间域不同,命名空间域中存放的是变量和函数的定义,而类域中虽然可以定义函数,但只能声明变量,并没有为变量开辟空间,只有用这个类实例化出对象才会开辟空间;这也就是为什么结构体和类中的成员变量都不能直接初始化,而是必须先定义出变量的原因。
2. 成员变量的命名潜规则
由于成员函数可以访问成员变量,也可以接收函数参数,所以这两者命名很可能会发生冲突,比如:
class Date
{
void Init(int year, int month, int day)
{
year = year; //这谁知道谁是谁啊?
month = month;
day = day;
}
int year;
int month;
int day;
};
为了解决这种赋值时的不确定性,在C++中有一个潜规则:成员变量使用某种修饰符来修饰,其中常见的有四种:_menber、menber_ 、m_menber、mMenber
,我选择的是第一种,但不管你选的哪种,尽量选定之后就长期保持这种风格。
3. 类的版式
为什么第二种版式中成员函数依旧可以访问成员变量:C语言编译器寻找变量的规则是先到局部域去找,然后再到全局域去找,所以C语言中规定变量必须定义在函数前面,才可以在函数中使用该变量;但是C++编译器不一样,C++编译器会把类看作一个整体,当我们在成员函数中使用一个变量时,它会先到整个类中去寻找,然后再到全局域去寻找;所以在C++中,我们是可以将成员变量定义到成员函数后面的;
四、封装
访问限定符
在结构和类中,有三个重要的权限修饰符,分别是 public(公有)、 private(私有)、 protected(保护),这里只介绍公有和私有。
- public:用这个修饰符修饰的成员,可以被外界访问。被public修饰的成员就像是类的外部接口一样。
- private:用这个修饰符修饰的成员,只能被类内部定义的成员函数使用。
class 定义的类所有成员默认访问权限为 private,struct 默认为 public ( 因为struct要兼容C );一般情况下,类的成员变量用private修饰,成员函数用public修饰,作为对外接口。
封装
封装的定义:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。比如:对于电脑这样一个复杂的设备,提供给用户的就只有开关机键、通过键盘输入,显示器,USB插孔等,让用户和计算机进行交互,完成日常事务。但实际上电脑真正工作的却是CPU、显卡、内存等一些内部元件。
由于C语言没有访问限定符,也没有封装的概念,所以用户可以轻易访问数据、更改数据,这样很不安全,操作对用户也不友好。但是C++就不会出现这种情况,因为C++类成员变量通常都会用 private 修饰,用户不能直接访问类中的数据,只能通过特定的接口 (用 public 修饰的函数) 来操作对象,这样既保证了安全性,又减低了操作难度。
五、类的实例化
用类类型创建对象的过程,称为类的实例化。
类就像是建筑设计图,只设计出需要什么东西,但是并没有实体的建筑存在,定义出一个类并没有分配实际的内存空间;类实例化出对象就像现实中使用建筑设计图建造出房子,实例化出的对象才会占用物理空间,存储成员变量。
六、类对象存储方式
在C语言阶段我们学习了如何计算一个结构体类型的大小,那么对于与结构体类似的类来说,怎么计算类的大小?
要想计算类的大小,必须知道类对象的存储方式,早期关于类对象的存储方式一共有三种设计方式;
方式一:对象中存储类的所有成员,包括成员变量和成员函数
成员函数经过编译后变成一段指令,这段指令存储在一个公共区域:常量区 (代码段) ,我们把这段指令中第一条指令的地址作为函数的地址,存储在类对象中,所以同一个类实例化出的不同对象存储的是同一个地址,调用的也是同一份指令。但是同一个类的不同对象不能共用同一个成员变量,因为每一个对象的成员变量的值都是不同的;
缺点:当一个类创建多个对象时,每个对象中都会保存一份函数的地址,相同的地址保存多次,浪费空间。
方式二:对象中存储成员变量和一份存放代码的区域的首地址
也就是说,我们不单独保存每一个函数的地址,而是保存类中所有函数所在的代码段的起始地址,我们通过这个地址就可以找到各个函数
方式三:对象中只存储成员变量
经过对比,方式二似乎才是最高效可用的,因为方式三连成员函数的地址都没存储,怎么调用成员函数?而实际上方式三是可行的,因为函数经过编译后形成的指令是由编译器放置到代码段中去的,所以编译器在调用该函数时也能轻松的找到指令在公共代码段中所处的位置。
基于上面这个原理,方式二存储代码段的地址也显得多余了,所以最终我们选用方式三来存储类对象,这样计算一个类的大小就没有难度了,和结构体的计算方式一模一样。
空类的大小
在面试时经常会考察空类的大小,也就是没有成员变量的类,它的结果和我们的常识并不相符,因为空结构体的大小为0,按理说空类的大小应该也是0,但其实不是的:
实际上,这是类实例化的原因,C++规定:任何不同的对象不能拥有相同的内存地址。 如果空类大小为0,若我们声明一个这个类的对象数组,那么数组中的每个对象都拥有了相同的地址,这显然是违背标准的。所以,为了保证每个对象在内存中都有一个独一无二的地址,编译器往往会给一个空类隐含地加一个字节,这样空类在实例化后在内存中就得到了独一无二的地址,所以空类所占的内存大小是1个字节。
七、this指针
以下面这个日期类来引出this指针:
class Date
{
public:
void Init(int year = 2003, int month = 10, int day = 19)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Init(2023, 9,9);
d1.Print();
Date d2;
d2.Init(2023,9,9);
d2.Print();
return 0;
}
对于上述类,很多人都有这样一个问题:虽然Date类中的成员函数是由具体的对象调用的,但是并没有传入对象的值或者地址,在函数体内函数是怎么知道那些成员变量是存储在哪个对象的?
实际上,C++是隐藏地传了对象的地址的,也就是this指针。C++编译器给每个 “非静态的成员函数“ 增加了一个隐藏的指针参数作为第一个参数,让该指针指向当前对象 (函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问;只不过所有的操作对用户是透明的,即用户不需要来传递 this 指针,编译器会自动完成。
上面的代码经过编译器处理后会变成下面这样:
class Date
{
public:
void Init(Date* const this, int year = 1970, int month = 1, int day = 1)
{
this->_year = year;
this->_month = month;
this->_day = day;
}
void Print(Date* const this)
{
cout << this->_year << "-" << this->_month << "-" << this->_day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Init(&d1, 2022, 10, 3);
d1.Print(&d1);
Date d2;
d2.Init(&d2, 2022, 10, 4);
d2.Print(&d2);
return 0;
}
this 指针参数是由编译器自动传递的,当用户主动传递时编译器会报错,但是我们可以在成员函数内部显示地去使用 this 指针。
注意事项
- this 指针只能在 “成员函数” 的内部使用;
- this 指针使用 const 修饰,且 const 位于 * 的后面,表示 this 指针的指向不能被修改,但可以修改其指向的对象。
- this 指针是“成员函数”第一个隐含的形参,一般情况下由编译器在建立“成员函数”的函数栈帧时压栈传递,不能由用户主动传递。(注:由于this指针在成员函数中需要被频繁调用,所以VS对其进行了优化,将this指针存储在 ecx 寄存器内,可以加快访问速度)
- this 指针作为参数传递时是可以为空的,但是如果成员函数中使用到了 this 指针,那么就会造成对空指针的解引用;
//下面两段程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A //程序1
{
public:
void Print()
{
cout << "Print()" << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->PrintA(); //传递空指针
return 0;
}
//***********************************//
class A //程序2
{
public:
void PrintA()
{
cout << _a << endl;
}
private:
int _a;
};
int main()
{
A* p = nullptr;
p->Print(); //同样传递空指针
return 0;
}
结果是程序1正常运行,程序2运行崩溃。
在程序1中虽然我们用空指针 p 访问了成员函数Print,但是由于成员函数并不存在于对象中,而是存在于代码段中,所以编译器只是把指针p传给了成员函数,并不会对 p 进行解引用;
而程序 2 在把指针 p 传给了成员函数后,对指针 p 进行了解引用来访问成员变量 _a,出现了对空指针进行解引用。
八、用类实现栈
typedef int DataType;
class Stack
{
public:
void Init(int N = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * N);
if (NULL == _array)
{
perror("malloc fail\n");
exit(-1);
}
_capacity = N;
_top = 0;
}
void Push(DataType data)
{
CheckCapacity();
_array[_top] = data;
_top++;
}
void Pop()
{
if (Empty())
return;
_top--;
}
DataType Top()
{
return _array[_top - 1];
}
int Empty()
{
return 0 == _top;
}
int Size()
{
return _top;
}
void Destroy()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_top = 0;
}
}
void CheckCapacity()
{
if (_top == _capacity)
{
int newcapacity = _capacity * 2;
DataType* temp = (DataType*)realloc(_array, newcapacity *sizeof(DataType));
if (temp == NULL)
{
perror("realloc fail\n");
exit(-1);
}
_array = temp;
_capacity = newcapacity;
}
}
private:
DataType* _array;
int _capacity;
int _top;
};
int main()
{
Stack S1;
S1.Init();
S1.Push(1);
S1.Push(2);
S1.Push(3);
S1.Push(4);
S1.Push(5);
S1.Push(6);
int n = 6;
while (n--)
{
cout << S1.Top() << endl;
S1.Pop();
}
S1.Destroy();
return 0;
}
在用C语言实现时,每个函数的第一个参数都是Stack*,而且在函数中必须要对指针进行检测,因为指针可能会是NULL;在C++中,每个方法都不需要传递 Stack * 的参数了,因为编译器会自动传入对象的地址。
相比C语言而言,C++中通过类可以将数据以及操作数据的方法进行完美结合,通过访问权限修饰可以控制哪些方法在类外可以被调用,即封装,在使用时只需以对象为中心,通过对象的方法和其他对象进行交互,更符合人类对一件事物的认知。