Learning a Part of C++(for ACM/ICPC) (2) 类的封装

接下来,我们将先面对面向对象这个概念所带来的一些特性。 
在这篇和下一篇有关运算符重载的内容里,我们将围绕设计一个能表示点和向量的结构体的基础上开始扩充,扩充成一个有一定实用意义的简单类,感兴趣的同学可以去继续加强,然后成为自己几何模板的基础。 
(从这里开始,如果没有特殊说明,代码均只保证C++编译器下编译通过,请保存成.cpp文件再进行尝试)

一、类的初接触

首先从一个熟悉的结构体开始。 
如果让你定义一个结构体,用来表示点或者向量,那肯定挥手就来:

 
 
  1. struct Point;
  2. typedef Point Vector;
  3. struct Point{
  4. double x;
  5. double y;
  6. };

如果让你写个函数求某个向量的长度,仍然很简单:

 
 
  1. double getLength(const Vector a){
  2. return sqrt(a.x*a.x+a.y*a.y);
  3. }
  4. int main(){
  5. Vector sample{3.0,5.0};
  6. printf("%f\n",getLength(sample));
  7. return 0;
  8. }

这样看上去很美好,至少在C语言中,是的。 
有没有想过一个问题:对一个向量来说,长度不是它根据x和y计算得到的一种属性吗?用一个函数去求的行为并不自然。这个计算过程可不可以也是像x和y一样,是这个向量的一种属性?

Exp 2.01 
编译执行以下代码,观察结果,理解其中的length函数。

 
 
  1. #include <stdio.h>
  2. #include <math.h>
  3. struct Point;
  4. typedef Point Vector;
  5. struct Point{
  6. double x;
  7. double y;
  8. double length()const{
  9. return sqrt(x*x+y*y);
  10. }
  11. };
  12. int main(){
  13. Vector sampleA{3.0,5.0};
  14. Vector sampleB{6.0,8.0};
  15. printf("%f\n%f\n"
  16. ,sampleA.length()
  17. ,sampleB.length());
  18. return 0;
  19. }

在C语言中,我们从没有见到过在一个结构体内还定义函数的行为。 
在C++中,支持这样的行为。这些结构体内的函数,我们叫做这个结构体(或者说,类)的成员函数。 
成员函数,顾名思义,是一个函数,是这个结构体的一个成员。

注意到我们在length()里并没有具体指明是哪个具体的x和y。那可能有一个问题了:那我调用他的时候,他怎么知道我具体指哪个x,哪个y? 
观察下面的调用的例子,你会发现,它需要通过一个结构体实体(比如上面的sampleA、sampleB)来调用。 
这里也明确一下调用格式:<对象名>.<成员函数名>(参数列表) 
当调用到他的时候,这里的x和y是前面指名的那个结构体实体里的x和y,所以你可以看到相应的结果。

幕后: 
实际上,调用的时候编译器会实现成,传入这个对象的指针,名为this,访问这个结构体内的成员会隐式地通过这个this指针访问。(当然你可以选择显式地写出来) 
上面length()的代码等价于:

  
  
  1. double length()const{
  2. return sqrt(this->x*this->x+this->y*this->y);
  3. }

注意到,成员函数定义的()之后出现了上篇中反复提到的const。 
const什么时候可以放在函数的声明之后了?这有什么用? 
答:这里的const,将这个函数变成了  成员函数。 
这个常成员函数表示,这个函数不能修改任何成员变量,不论是直接的还是间接的,所以:

  • 不仅 常成员函数里不能通过直接赋值来修改成员变量,
  • 而且 常成员函数不能调用其他的非常成员函数。

Exp 2.02 
修改前面定义的Point结构体/类如下,编译,观察错误提示:

 
 
  1. struct Point{
  2. double x;
  3. double y;
  4. void init(){
  5. x=y=0.0;
  6. }
  7. double length()const{
  8. x=0.0;
  9. init();
  10. return sqrt(x*x+y*y);
  11. }
  12. };

(虽然一般情况下我们不用,甚至不应该定义一些成员变量为常成员函数,但是,在和STL打交道的时候,定义常成员函数很有必要,将在之后的篇章中具体介绍)

看到现在可能有人要问了:不是这里要说类吗?怎么好像直接在结构体里塞函数就完事了?没有什么新的特别的关键词、语法什么的? 
是有一个关键词,class,在C++中专门用来表示类的。 
问题是,这个关键词,在C++中,与struct相比,在内存管理机制、成员变量与成员函数的声明与定义的方法、使用成员变量与调用成员函数等方面没有区别,只有一个小地方:

struct里所有成员默认是公有(public)成员,而class里所有成员默认是私有(private)成员 
这里公有与私有的区别,只是,公有的是除了这个类里的成员函数以外,其他任何函数(包括main函数等)都可以访问;而私有的是有且仅有这个类里的成员函数可以访问。 
而在做算法竞赛方面的题的时候,一般情况下,你不需要设计一个工程上科学且健壮的类,大多时候我们所有成员函数和部分成员变量还要直接在计算过程中使用,所以直接struct就好了。

只能求一个长度太没用了,肯定要继续往下扩充。 
比如说,不妨加一个向量旋转?

Exp 2.03 
编译执行以下代码,观察结果:

 
 
  1. #include <stdio.h>
  2. #include <math.h>
  3. struct Point;
  4. typedef Point Vector;
  5. struct Point{
  6. double x;
  7. double y;
  8. double length()const{
  9. return sqrt(x*x+y*y);
  10. }
  11. Point rotate(double c){//逆时针旋转c度,c为弧度
  12. Point ans;
  13. ans.x=x*cos(c)-y*sin(c);
  14. ans.y=x*sin(c)+y*cos(c);
  15. return ans;
  16. }
  17. };
  18. int main(){
  19. Vector sample{3.0,0.0};
  20. Vector afterRotate=sample.rotate(acos(-1.0)/2);
  21. printf("%f %f\n"
  22. ,afterRotate.x
  23. ,afterRotate.y);
  24. return 0;
  25. }

上面的例子完整展示了,如何返回一个相同类型的实例(对象),还有带参数的调用。 
问题是,这个rotate函数写的有点冗长,可不可以写的短一点? 
花括号的解决方案太C语言了,也不灵活,接下来我们将介绍C++里引入的解决方案:

二、构造函数

构造函数,这是一种特殊的成员函数。他特殊在: 
1、没有返回值; 
2、在创建这个类的实例的时候自动触发。

我们首先看下面这个例子:

Exp 2.04 
1、编译执行以下代码,观察结果 
2、删除代码中的第12行Point(){},编译,出现了什么错误?

 
 
  1. #include <stdio.h>
  2. #include <math.h>
  3. struct Point;
  4. typedef Point Vector;
  5. struct Point{
  6. double x;
  7. double y;
  8. int quadrant;//表示这个点处于第几象限
  9. Point(){}
  10. Point(double _x,double _y):x(_x),y(_y){
  11. if(_x>=0&&_y>=0)quadrant=1;
  12. else if(_x<0&&_y>=0)quadrant=2;
  13. else if(_x<0&&_y<0)quadrant=3;
  14. else quadrant=4;
  15. }
  16. double length()const{
  17. return sqrt(x*x+y*y);
  18. }
  19. Point rotate(double c){//逆时针旋转c度,c为弧度
  20. return Point(x*cos(c)-y*sin(c),x*sin(c)+y*cos(c));
  21. }
  22. };
  23. Point aSample;
  24. int main(){
  25. Vector sample(3.0,3.0);
  26. printf("%f %f %d\n"
  27. ,sample.x
  28. ,sample.y
  29. ,sample.quadrant);
  30. Vector afterRotate=sample.rotate(acos(-1.0)/2);
  31. printf("%f %f %d\n"
  32. ,afterRotate.x
  33. ,afterRotate.y
  34. ,afterRotate.quadrant);
  35. return 0;
  36. }

和前面的Exp 2.03相比,我们主要修改了这样两处: 
1、新增Point()Point(double _x,double _y)这两个成员函数 
2、rotate函数里使用了Point()的写法,主函数里也把花括号{}换成了圆括号()

这里,我们称Point()Point(double _x,double _y)这两个成员函数为构造函数

在构造函数的声明中,最前面不要标注任何类型,连void都不能有。 
函数名必须和类名保持一致。 
之后一对圆括号(),中间像正常函数一样写入要传入的参数。

第一个构造函数的定义里,什么语句也没有,也不传入任何参数, 
而第二个构造函数的定义里,完成了计算这个点属于哪个象限的事情,并存入了quadrant这个成员变量。


那第二个构造函数的参数里的_x_y呢?在定义的花括号{}里,好像并没有看到明显的赋值,他们怎么正确赋值到相应的成员变量的?那个圆括号后面的冒号又是什么鬼?

答:注意到Point(double _x,double _y)之后还有::x(_x),y(_y) 
这个写法本意是继承类中用来正确调用父类的构造函数的(这里略过不展开),但是这个同样也可以用来对结构体内的成员变量进行赋值。 
具体是,在参数列表的括号后加上一个冒号,然后写上成员变量名(相应的值的表达式),如果有多个,用逗号隔开再追加,就像上面的例子一样。 
其实Point(double _x,double _y):x(_x),y(_y)这么写,在这里等价于:

 
 
  1. Point(double _x,double _y){
  2. x=_x;
  3. y=_y;
  4. //同上
  5. }

把构造函数使用起来也是很简单的,你可以 
1.在需要返回一个这个类型的对象的时候,return <类名>(构造函数的参数); 
2.声明一个对象的时候:

  • Vector sample(3.0,3.0);,或者
  • Vector sample2=Vector(-3.0,-3.0);

请注意,上面这两种形式调用的是同一个构造函数:Point(double _x,double _y),且都只是调用了这同一个构造函数,对第二个这种写法来讲,并不是字面意义上的创建了一个临时对象,然后赋值。

也许有同学会觉得难以置信了。我们仍然写代码做实验。

 
 
  1. #include <stdio.h>
  2. #include <math.h>
  3. struct Point;
  4. typedef Point Vector;
  5. struct Point{
  6. double x;
  7. double y;
  8. int quadrant;
  9. Point(){}
  10. Point(double _x,double _y):x(_x),y(_y){
  11. printf("here addr=%p\n",this);
  12. quadrant=0;
  13. }
  14. Point(const Point & b){
  15. puts("Copy Constructor");
  16. x=b.x;y=b.y;quadrant=b.quadrant;
  17. }
  18. Point& operator=(const Point &b){
  19. puts("Assignment operator");
  20. x=b.x;y=b.y;quadrant=b.quadrant;
  21. return *this;
  22. }
  23. };
  24. int main(){
  25. Vector sample1(3.0,3.0);
  26. printf("addr of sample1=%p\n",&sample1);
  27. Vector sample2=Vector(-3.0,-3.0);
  28. printf("addr of sample2=%p\n",&sample2);
  29. return 0;
  30. }

执行发现,上面的代码中并没有执行到Copy Constructor(复制构造函数)和Assignment operator(赋值运算符),sample2的构造函数中this指针的地址,与sample2的地址一致。 
在C++中,sample2这种形式其实也是直接调用相应构造函数的,不会调用别的函数了。 
(复制构造函数因为在做题方面没什么实用性可言,这里略过不讲,赋值运算符也是类似的,不过这两个具体写的时候涉及到C++的一个重要考点,深复制与浅复制,请上课的时候听一下讲解,或者看看其他靠谱的书)


注意到,这个构造函数的执行是在我们创建sample这个实例的时候自动执行的。声明sample之后,我们马上访问sample.quadrant,可以发现,已经填入了相应的结果,afterRotate同理。


如果删去Exp 2.04中的第12行Point(){},就出现了编译错误,这是什么情况? 
答:事实上,在C++中,对每个类(结构体),都有默认的普通构造函数,这个默认构造函数没有参数,定义里也是什么事情都不做。 
但是如果你自己手工定义了其他的普通构造函数(比如第13行那个),那么编译器将不会帮你定义默认的构造函数。 
而在这种情况下,如果要创建一个像aSample这样的,没有任何初始化参数的对象,必须自己手工实现一个没有任何参数的构造函数


那,这么定义一个无参数构造函数太麻烦了,不是前面提到了,在C++中函数参数可以有默认值吗?我可以利用函数参数默认值,让所有参数都有默认值,来让编译器自动创建一个无参数的构造函数吗? 
答:可以,但是我个人并不推荐,因为有时候会出现一些意想不到的情况,比如下面的例子:

Exp 2.05 
1、先观察代码,自己猜测一下,能否编译通过,会发生什么? 
2、编译执行以下代码,观察结果 
3、试通过调试,解释这个现象的原因

 
 
  1. #include <stdio.h>
  2. struct Point{
  3. double x;
  4. double y;
  5. Point(double _x=0.0,double _y=0.0):x(_x),y(_y){}
  6. };
  7. struct Circle{
  8. Point o;
  9. double r;
  10. Circle(Point _o=Point(0.0,0.0),double _r=0.0):o(_o),r(_r){}
  11. };
  12. int main(){
  13. Circle test(1.5);
  14. printf("o.x=%f\n",test.o.x);
  15. printf("o.y=%f\n",test.o.y);
  16. printf("o.r=%f\n",test.r);
  17. return 0;
  18. }

当时比赛中发现这“bug”的时候,很奇怪。 
这里本意是创建一个圆心在原点,半径为r的圆,的确写错了,不应该这样写,但是—— 
嗯?我明明没有创建一个Circle中参数是一个double的构造函数啊?怎么编译通过了?好像还完全没Warning,开了-Wall、-Wextra都没有? 
然后冷静下来一分析,才发现,这个锅可以给隐式类型转换背,也可以算是偷懒的代价…… 
这里整体的执行流程是:先执行Point(double)这个构造函数,创建一个Point对象,然后执行Circle(Point)这个构造函数,“顺利”创建了一个Circle的对象。

之后,为避免这种情况再次发生,我们队还是把几何模板改掉了,不再选择偷懒,还是老老实实,手工写2个构造函数,无参数和2个参数的。


我们来整理一下这里我们所学到的,顺带我们来加强一下这个Point/Vector类的能力:只是旋转,不够用啊,向量的加、减、数乘、点乘、叉乘可以有的吧?

那让我们整理并实现一下:

 
 
  1. #include <stdio.h>
  2. #include <math.h>
  3. struct Point;
  4. typedef Point Vector;
  5. struct Point{
  6. double x;
  7. double y;
  8. Point(){}
  9. Point(double _x,double _y):x(_x),y(_y){}
  10. double length()const{
  11. return sqrt(x*x+y*y);
  12. }
  13. Point rotate(double c){//逆时针旋转c度,c为弧度
  14. return Point(x*cos(c)-y*sin(c),x*sin(c)+y*cos(c));
  15. }
  16. Point add(Point b){
  17. return Point(x+b.x,y+b.y);
  18. }
  19. Point minus(Point b){
  20. return Point(x-b.x,y-b.y);
  21. }
  22. Point num_multiply(double b){
  23. return Point(x*b,y*b);
  24. }
  25. Point dot_multiply(Point b){
  26. return Point(x*b.x,y*b.y);
  27. }
  28. double cross_multiply(Point b){
  29. return x*b.y-b.x*y;
  30. }
  31. };
  32. int main(){
  33. Vector A(3.0,3.0);
  34. Vector B(2.5,-1.9);
  35. Vector C=(A.add(B)).num_multiply(10.0);
  36. printf("%f %f\n"
  37. ,C.x
  38. ,C.y);
  39. return 0;
  40. }

Tips 
1、结构体的成员函数当然是可以对这个结构体的临时对象(或者说,计算的中间结果)使用的。 
2、不是之前说了引用是个好东西吗?如果参数要传一个结构体,传引用还传的快吗?为什么这里都没用引用呢? 
答:问题是,这里我们的计算完全可以,而且很可能经常要传入一个临时对象的,而临时对象并不能作为引用类型参数传入

嗯,一切看上去挺好,可是有个问题:有没有觉得这个式子写起来太麻烦了,或者说,读起来很不自然?

下一篇,我们将来介绍一下C++里一个(对做一些题来讲)很有用的功能,运算符重载。

Bonus:析构函数

既然我们都讲到这里了,那往下顺带讲个相对应构造函数的析构函数吧。 
为什么说是相对应构造函数?

因为析构函数同样是 
1、由编译器编译后,自动调用的。 
2、没有返回值类型的

但是和构造函数肯定有区别: 
1、析构函数是在出了这个对象作用域,要自动销毁,或者你手工销毁的时候,自动调用的 
2、析构函数还没有参数列表这个东西

在做题的时候,用析构函数的地方其实几乎不存在的说,因为为了小常数,高效率,我们一般都采用,全局变量中开数组,静态分配内存,数组模拟链表,一个存数组下标的int当指针使用的方法的。

当然,如果犯贱用了,另当别论。 
比如我最初的一个字典树模板:

 
 
  1. #include <stdio.h>
  2. #include <stdlib.h>
  3. #include <string.h>
  4. struct Node{
  5. int count;
  6. Node* next[26];
  7. Node(){
  8. memset(next,0,sizeof(next));
  9. }
  10. void insert(char * str){
  11. ++count;
  12. if(*str==0)return;
  13. if(next[*str-'a']==NULL){
  14. next[*str-'a']=new Node();
  15. }
  16. next[*str-'a']->insert(str+1);
  17. }
  18. };
  19. int main(){
  20. int T;
  21. for(scanf("%d",&T);T--;){
  22. Node * root=new Node();
  23. //具体处理代码
  24. delete root;
  25. }
  26. return 0;
  27. }

注意到这里有一对新关键词:newdelete。 
new的含义是,向堆动态申请一个这种变量的空间,返回相应的地址。 
你当然可以选择申请不止一个,而是一个数组:

 
 
  1. int* test=new test[15];

申请过来以后,用完了还得释放,不然会造成内存泄露。在C++里,用new申请过来的空间,相对应的,需要用delete来释放。如果只是一个元素,那直接delete <指针名>;就够了,如果是上面申请的一个数组,则需要delete[] <指针名>;比如:

 
 
  1. delete root;
  2. delete[] test;

但是对上面的这个字典树模板,这么delete够了吗? 
不够啊!直接用这个,你会得到,MLE。因为,里面next数组指向的那些Node没释放呢!然后还要递归往下删除……

这怎么办?专门写一个递归delete的函数? 
实际上不需要。这里析构函数就派上用场了。 
在这个Node结构体里加一个成员函数:

 
 
  1. ~Node(){
  2. for(int i=0;i<26;i++){
  3. if(next[i]) delete next[i];
  4. }
  5. }

编译器会在delete的时候自动先触发析构函数,析构函数执行结束后,再回收这一块的内存。 
这样,在删除这个字典树的时候,会先触发根节点的析构函数,然后往下,找到某个儿子节点非空,那么去先delete掉这个儿子,此时会先触发这个儿子的析构函数,如此递归往下,顺利将整棵字典树的内存全部回收了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值