前言:C++语法知识繁杂,要考虑的细节很多,要想学好C++一上来就啃书并不是一个很好的方法,书本的内容一般是比较严谨的,但对于初学者来说,很多概念无法理解,上来就可能被当头一棒。因此建议在学习C++之前学好C语言,再听听入门课程,C++有很多的语法概念是对C语言的一种补充,学习过C语言能更好的理解为什么要这样设计,笔者也是初学者,写的这类文章仅是用于笔记总结及对一些概念进行分析探讨,方便以后回忆,因知识有限,错误难以避免,欢迎大佬阅读指教
写类与对象这篇时,我深知要想理解类与对象,是要通过大量实践的,凭我这个初学者的三言两句,必定是错误百出,后来想想,不妨写写初学者对类与对象的理解,描述一下目前自己眼中的类与对象,分享一下自己的看法,或许能给大家提供不一样的视角,欢迎大家指教
前面一篇文章我们简单的了解了类的思想和类的定义,如果创建一个类之后什么也不加,那么这个类就什么都没有吗?事实上并不是这样的,因为创建一个空类时,编译器会默认创建几个特殊的成员函数,分别是构造函数,拷贝构造,析构函数,赋值运算符重载,const成员函数,取地址及const取地址操作符重载,为什么说它们特殊呢?原因之一是这些函数要么被我们手动实现,要么编译器自动实现,那这些函数具体是用来干什么的呢?我们先从析构函数和构造函数说起
构造函数
构造函数的用途
编写程序在创建一种数据类型时,我们知道,第一件事就是对这个已创建的类型进行初始化处理,否则会导致程序崩溃,比如我们用C语言的结构体创建一个栈的数据类型,里面有一个size变量来记录栈中元素个数,刚开始我们肯定要把size的值初始化为0,我们可以写一个初始化函数来解决
在类中也不例外,假如我们创建一个栈类的对象,那么肯定也要对这个对象进行初始化操作,我们可以像C语言那样,写一个初始化函数,但是问题来了,如果我忘了写初始化函数该怎么办,可能这个错误不容易犯,要命的是,我们写了初始化函数,但是忘了调用,调试了半天,最后才发现原来是初始化函数没有调用。只要你代码写的足够多,那么你一定犯过这样的错误。大佬当然也不例外,可真是烦死人了,那干脆就让类的对象在创建时自动初始化不就好了,而这个初始化的工作就是由构造函数来实现的,在创建一个类的对象后,编译器会自动调用该类的构造函数,将对象进行初始化操作,如果你忘了写初始化构造函数,那么编译器会自动生成一个无参的默认构造函数,如果你自己写了构造函数,那么编译器就不会再实现,而是自动调用你写好的构造函数
构造函数的定义
编译器可以自动调用构造函数,那么编译器怎么区分你到底是普通成员函数还是构造函数呢?这就要说到构造函数第二个特殊点——构造函数的定义,编译器能区分普通成员函数与构造函数,自然是构造函数的定义与众不同,那么构造函数的定义特殊在哪呢?
1. 函数名与类名相同
2. 无返回值
3. 构造函数可以重载
一个类的构造函数在该对象的生命周期内只会调用一次
class stack { public: stack() { _pos = (int*)malloc(sizeof(int) * 4); _size = 0; _capacity = 0; } stackpush(); stackpop(); stacktop(); private: int* _pos; int _size; int _capacity; };
如上面的代码,笔者写了一个该类的无参的构造函数,函数名与类名相同,没有返回值,构造函数内部是对类的变量进行初始化,这个函数不需要我们调用,在创建一个类的对象时,编译器会自动调用这个构造函数,完成初始化
class stack { public: stack(int PosSize , int size, int capacity) { _pos = (int*)malloc(sizeof(int) * PosSize); _size = size; _capacity = capacity; } stackpush(); stackpop(); stacktop(); private: int* _pos; int _size; int _capacity; }; int main() { stack T(4, 0, 0); }
可以定义一个无参的构造函数,当然也可以定义有参的构造函数,如上面的代码,我们定义了有参的构造函数,在创建类的对象时,将参数传给构造函数就可,具体操作如上面的代码
class stack { public: stack(int PosSize = 4, int size = 0, int capacity = 0) { _pos = (int*)malloc(sizeof(int) * PosSize); _size = size; _capacity = capacity; } stackpush(); stackpop(); stacktop(); private: int* _pos; int _size; int _capacity; }; int main() { stack T(4, 0, 0);//使用自己传过去的值 stack T;//使用缺省值 //注意,使用全缺省构造时,如果不传参数,不要写成 stack T(); 这种形式 //因为编译器无法区分这究竟是一个返回值是类的函数的声明,还是一个不传参的实体类的创建 }
当然还可以进行缺省构造,上面我写的是全缺省构造,其他的玩法,大家可以试试 ,需要注意的是无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数
编译器自己生成的默认构造函数
或许你有这样的疑惑,自己不写构造函数,编译器不是可以自己生成嘛,那我干嘛还费力自己写,让编译器自己生成去吧
编译器确实会自动生成一个,但是吧,在制定规则时,可能出于一些原因的考虑,C++规则将类中变量的类型分为了内置类型和自定义类型,内置类型就是语言体系提供的最基本的类型,如char int float等等,自定义类型则是用结构体或类自行定义的类型
C++语法规则没有规定,编译器自己生成的无参默认构造函数要对内置类型做处理,对于自定义类型则是调用自定义类型的默认构造函数
简单点说就是编译器自己生成的构造函数不对内置类型做处理,至于自定义类型嘛,就帮你调用一下你这个自定义类型的默认构造函数,下面举几个例子来看看
class stack { public: void print() { std::cout << _size << ' ' << _capacity << std::endl; } private: int* _pos; int _size; int _capacity; }; int main() { stack T; T.print(); }
看上面一段代码,我们没有对stack编写构造函数,也就是说此时这个类的构造函数是编译器自己生成的,运行后可以看到,对于内置类型,编译器默认生成的构造函数并没有对其进行处理 ,打印出来的是垃圾值。是不是感觉编译器自己生成的这个默认构造函数挺没用的,C++委员会也意识到这个问题,后面通过打补丁的方式,允许给类中内置类型赋初始值,如果没有自己没有写构造函数,那么就用这个初始值,我们仍用上面的代码演示一下
class stack { public: void print() { std::cout << _size << ' ' << _capacity << std::endl; } private: int* _pos = nullptr; //可以给这些内置类型赋初始值 int _size = 0; int _capacity = 0; }; int main() { stack T; T.print(); }
现在我们没有写构造函数,但是给内置类型赋了初始值,编译器自己生成的默认构造函数虽然不会处理内置类型,但是我们可以通过赋初始值的方法自己解决
当然这个编译器自己生成的默认构造函数并非一无是处,当你写的这个类里没有内置类型,且包含其他的类时(这个类必须有默认构造函数,再次提醒一下,无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数),这个时候,编译器默认生成的就足够用,编译器自己生成的默认构造函数会调用包含的类的默认构造函数
析构函数
析构函数的用途
在编写程序时,不仅是忘了初始化让人头疼,还有忘了释放内存,导致内存泄漏的风险,那么可以自动完成释放内存空间的函数就叫析构函数,析构函数与构造函数都是由编译器自动调用的,当一个对象的生命周期要结束时,编译器会调用该类的析构函数,释放该对象申请的内存,如果我们自己实现析构函数,那么编译器就不会生成,如果我们不自己实现,那么编译器就会自己生成一个
析构函数的定义
1. 析构函数名是在类名前加上字符 ~
2. 析构函数无返回值,无参数
3. 一个类只能有一个析构函数。若未自己定义,编译器会自动生成默认的析构函数,析构函数不能重载
4. 对象生命周期结束时,C++编译系统自动调用析构函数
下面看一下析构函数的代码,仍以栈类为例
class stack { public: stack() { _pos = (int*)malloc(sizeof(int)*4); _size = 0; _capacity = 4; } ~stack() { free(_pos); } private: int* _pos; int _size; int _capacity; };
上面我们利用析构函数来释放_pos指向的空间,内置类型出了对象生命周期会被自动销毁,不需要我们自己销毁。如果我们没有自己写,编译器会自动生成一个,这个自动生成的析构函数会调用自定义类型的析构函数,以确保每一个类型都被释放完全,如果当前对象里还有申请的内存未释放,那就不能省略析构函数的编写
拷贝构造
拷贝构造的用途
前面说到的构造函数是对类中的变量进行初始化处理,如果我用该类创建了一个对象T1,进行一段操作后,我需要再创建一个对象T2,并且所有的值都要和T1的值相同,就相当于拷贝一份T1,如果我一个一个的把T1的值赋给T2,只拷贝一份还好,拷贝多了就真够麻烦的了,不仅写起来没意义,看着一大堆代码也不好看
C++给我们提供了一种解决方案,就是拷贝构造,拷贝构造是构造函数的重载
如果你没有显示的定义拷贝构造函数,那么编译器会自动实现一个,但是需要注意,编译器实现的是逐字节拷贝的,如果该类中包含指针,且指向其它资源,会导致浅拷贝,可能造成程序错误
拷贝构造的定义
拷贝构造函数的参数是被拷贝的那个对象,这个参数必须要传引用,不能传值,否则会导致无限递归,程序崩溃
class stack { public: stack(const stack & tmp) { _pos = (int*)malloc(sizeof(int)*tmp._capacity); memcpy(_pos, tmp._pos, sizeof(int) * tmp._size); _size = tmp._size; _capacity = tmp._capacity; } ~stack() { free(_pos); } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2(T1); //如此,便可以将T1的值拷贝给T2 }
通过上面的代码可以知道,T1就是拷贝构造的参数,需要注意的是对于_pos指向的内容,可不能直接把T1的 _pos 直接赋值给T2的_pos,那这两个_pos就指向同一块空间了,那这拷贝就没有意义了,正确的做法就是给T2的_pos重新开一块和T1的_pos同样大小的空间,并把T1的_pos所指向的内容,一个字节一个字节的拷贝给T2的_pos
传值拷贝导致无限递归的原因
接下来,我们讨论另一个问题,为什么说参数使用传值调用,而不使用传引用调用会导致无限递归呢?我们接下来分析一下,首先我们先看看传值的代码,const就是表示传过去的T1不能被修改,为了更简洁,我们暂时就将const给去掉了
class stack { public: stack(stack tmp) { _pos = (int*)malloc(sizeof(int)*tmp._capacity); memcpy(_pos, tmp._pos, sizeof(int) * tmp._size); _size = tmp._size; _capacity = tmp._capacity; } ~stack() { free(_pos); } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2(T1); }
上面的代码就是传值调用,首先我们要牢记构造函数就是进行初始化处理,虽然叫拷贝构造,但还是干着构造函数的活,把T1拷贝给T2,其实就是用T1去初始化T2,那就要调用T2的拷贝构造,以T1为参数,把T1传给tmp,这里就是理解的关键,我们知道传值调用函数,就是把要传的参数做一份临时拷贝,就是说把T1的值拷贝给tmp。等等,T1是一个对象,tmp也是一个对象,tmp和T1同属于一个类,把T1拷贝给tmp岂不是就调用了tmp的拷贝构造,那就可以写成 stack tmp(T1),tmp可是和T2是同一个类,然后要想把T1拷贝给tmp,就得把T1传给tmp的拷贝构造函数,然后就又回到了上述过程,程序就这样一直调用下去,直到栈溢出
运算符重载
什么是运算符重载?
在日常的编码过程中,我们会遇到类的两个对象比较大小,判断两个对象是否相等,把一个对象赋值给另一个对象等等情况。就拿之前的栈类来说,如果两个栈是相等的,则要判断两个对象中_pos指向的空间的内容都相等,且size 和 capacity大小都相等,这样才能说这两个对象相等,我们看一下写法
class stack { public: stack(stack tmp) { _pos = (int*)malloc(sizeof(int)*tmp._capacity); memcpy(_pos, tmp._pos, sizeof(int) * tmp._size); _size = tmp._size; _capacity = tmp._capacity; } bool stack_if_equal(const stack& T2) { if (_size != T2._size || _capacity != T2._capacity) { return false; } for (int i = 0; i < _size; i++) { if (_pos[i] != T2._pos[i]) { return false; } } return true; } ~stack() { free(_pos); } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2; /* 假设中间两个栈分别进行了一系列不同的操作*/ bool tmp = T1.stack_if_equal(T2); 这样我们可以判断T1与T2是否相等 }
不过,大家有没有觉得这种写法并不是很好看,自己写觉得还行,别人阅读你的代码会是什么感受呢?可能觉得云里雾里,还要猜半天这个stack_if_equal()到底什么意思,甚至看不出来的只能去看源码,大大加深了代码的不可阅读性,就不能直接用 ”==“ 符号来表示两个对象是否相等嘛?
还真可以,C++给我们提供了运算符重载,就是解决这个问题的
运算符重载的使用
因为用函数名来进行操作不便于阅读,C++提供了运算符重载,我们在日后遇到要对某类的对象进行运算符操作的情况都可以用运算符重载,运算符重载的标志就是含有operator操作符,如果要判断两个对象是否相等,可以写成 bool operator==(const stack &T2),这是在定义函数的时候的写法,当我们想比较的时候,直接就可以写成bool tmp = T1 == T2;
class stack { public: //为了更清晰,这里我就暂时把构造函数和析构函数删掉了 bool operator==(const stack& T2) { if (_size != T2._size || _capacity != T2._capacity) { return false; } for (int i = 0; i < _size; i++) { if (_pos[i] != T2._pos[i]) { return false; } } return true; } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2; /* 假设中间两个栈分别进行了一系列不同的操作*/ bool tmp = T1 == T2; //本质上就是bool tmp = T1.operator==(T2); //这是运用运算符重载的写法,是不是更清晰的表达出意思了呢 }
除了 .* , :: ,?: ,sizeof ,. , 除了这五个运算符不能进行运算符重载,其他合法的操作符都是可以的,接着看判断两对象大小的例子,熟悉一下运算符重载的操作
比如我们要比较栈类的两个对象的大小,规定用栈中元素的个数来确定大小,元素多的栈就大,我们就重载运算符 >
class stack { public: bool operator>(const stack& T2) { if (_size > T2._size) return true; else return false; } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2; /* 假设中间两个栈分别进行了一系列不同的操作*/ bool tmp = T1 > T2; //本质上就是bool tmp = T1.operator>(T2); }
现在我们再阅读代码,一眼就能看出这是某类的两个对象在比较大小,可见,运算符的重载可以帮助我们清晰的阅读代码,是一个能经常用到的功能,大家是一定要掌握的
赋值运算符重载
听名字我们就可以猜到,这是对赋值运算符进行重载,即把一个对象的值赋给另一个对象,听着是不是很熟悉,不就是我们前面说到的拷贝构造嘛,既然有了拷贝构造是不是就说明赋值运算符重载是没有什么必要呢?
虽说功能是一样的,但还有很大区别,赋值运算符重载随时可以将一个对象的值赋给另一个对象,但是拷贝构造只能在创建对象之时进行赋值,因为构造函数在对象的生命周期内只能调用一次,且是在创建对象的时候自动调用的,可见,赋值运算符重载是很有作用的
赋值运算符重载的定义
参数类型:const T&,传递引用可以提高传参效率
返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值,检测是否自己给自己赋值
需要注意的是,如果没有手动实现赋值运算符重载,那么编译器会自动生成一个,但是吧,编译器自动生成的是逐字节拷贝的,只能进行浅拷贝,什么意思呢?前面提到过栈的类,像_size,_capacity,确实能原模原样的拷贝过去,但是_pos不能啊,你把T1的_pos拷贝给T2的_pos,那这两个_pos不就指向同一块空间了嘛,像这种只能进行单纯的赋值拷贝,无法对有深度的空间层次(如指针指向的空间)实现拷贝的就是浅拷贝,要想实现深拷贝,还是得手动编写,接下来看一下赋值运算符重载的代码
class stack { public: stack& operator=(const stack &tmp) { _pos = (int*)realloc(_pos, sizeof(int)*tmp._capacity); memcpy(_pos, tmp._pos, sizeof(int) * tmp._size); _size = tmp._size; _capacity = tmp._capacity; return *this; } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2; /*假设进行一些列操作后,把T1赋值给T2*/ T2 = T1; }
大家可能会觉得上面的代码没有什么问题,不过没问题的是建立在T1是比T2大的,用realloc是在扩充空间,如果T2的空间比T1的空间大,那还怎么搞
所以我们不能这样写,我们无法确定T1和T2空间的大小,那么干脆就将T2给直接释放了,重新开辟和T1一样大的空间,然后将T1的值赋给T2,接下来看看改过的程序
class stack { public: stack& operator=(const stack &tmp) { free(_pos); _pos = (int*)malloc(sizeof(int)*tmp._capacity); memcpy(_pos, tmp._pos, sizeof(int) * tmp._size); _size = tmp._size; _capacity = tmp._capacity; return *this; } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2; /*假设进行一些列操作后,把T1赋值给T2*/ T2 = T1; }
可这就对了吗?如果出现T2 = T2 这种情况,上来就把T2给free了,然后重新开一块空间,把T2空间的值拷贝过去,等等!原先T2已经被释放了,你还去拷贝就属于越界访问了,看来,想正确实现一个程序真不是什么简单的事啊!困难来了,解决了能力就上涨了,解决不了,问题就还在那里,莫慌,干它就完了
好在这里还是比较好解决的,判断一下赋值与被赋值方不能相等就可以了,下面的就是正确的代码了
class stack { public: stack& operator=(const stack &tmp) { if ( this != &tmp) { free(_pos); _pos = (int*)malloc(sizeof(int)*tmp._capacity); memcpy(_pos, tmp._pos, sizeof(int) * tmp._size); _size = tmp._size; _capacity = tmp._capacity; } return *this; } private: int* _pos; int _size; int _capacity; }; int main() { stack T1; stack T2; /*假设进行一些列操作后,把T1赋值给T2*/ T2 = T1; }
const成员函数
什么是const成员函数
我们知道,类中的成员函数之所以能访问类中的私有变量,是因为成员函数的参数中都隐藏了一个this指针,通过这个this指针我们能访问类中的私有变量。
在日常写代码的时候我们会遇到这样的情况,写一个类的成员函数,仅仅想查看类中成员变量的值,不想修改成员变量的值,但是我又怕一段时间后,自己或者他人在修改该成员函数时不小心更改了类中成员变量的值。
这样就很麻烦,因为一个小失误,可能导致整个程序崩溃,那该怎样预防这样的事发生呢?前面我们知道,成员函数是靠 this 指针来访问成员变量的,那给这个 this 指针加一个const修饰,约束一下不就可以了,有道理,我们先看看这个隐藏的this指针的写法
类类型* const this
这个const是修饰 this 这个指针本身的,表示 this 指针不能够更改指向的对象,这个很容易理解,this指向的对象肯定是不能够被修改的
现在我们的需求是让 this 指向的对象的值不能被修改,按照我们刚才的想法可以写成
const 类类型* const this
这两个const修饰的对象不一样,一个是修饰this指针本身,一个是修饰this指向的对象
这样问题似乎被完美的解决了,可惜我们一开始就忽略了一个问题,this指针是隐藏的,隐藏就代表我们看不到,看不到怎么加const
而这就用到了const成员函数,说简单点,const成员函数就是为了解决给this加个const 的问题,解决方法也很简单,既然我看不到隐藏的this指针,那我就把const加到函数的定义或声明处,而这个被加了const的成员函数就是const成员函数
笔者这里以查看栈类中空间大小为例,写一个成员函数查看已开辟的空间数
class stack { public: stack(); //这是const成员函数的正确写法 int Check_capacity() const { return _capacity; } ~stack(); private: int* _pos; int _size; int _capacity; };
需要注意的点
在使用 const 约束时,就要特别注意权限的问题,权限可以被缩小,但是不能被放大,例如,const成员函数不能够调用其他的非 const 成员函数,这是因为,const 成员函数的this指针指向的对象被 const 约束了,权限是只读,而非 const 成员函数的 this指针指向的对象未被约束,权限是读写,如果调用,那就导致权限被扩大,这是不被允许的
而非const成员函数调用const成员函数是可以的,因为权限是可以被缩小的
取地址运算符重载
取地址运算符重载就是获得调用对象的地址,这个重载函数不需要我们自己去实现,编译器会自动实现,这下可以放心,因为编译器真的会实现,哈哈,看了前面的内容,一提到编译器自己实现,可能就会引起我们的警觉,哈哈哈,这个函数没太多可说的,接下来看看这个函数的实现
//还是以栈类为例 stack* operator&() { return this; }
const取地址运算符重载
这个和上面那个稍微有些区别,这个函数是用来取被 const 修饰过的对象的地址的,被const修饰过的对象是不能直接调用取地址运算符重载的
因为我们前面提到过,权限不可以被放大,被 const 修饰过的对象的权限是只读,this指针指向的对象的权限是读写,传过去就会导致权限被放大,所以要给被const 修饰过的对象专门写一个取地址运算符重载函数,这个也不需要大家手动写,编译器自动实现,大家了解即可
const stack* operator&()const { return this; }