类的定义
类的定义分为两个部分:数据(相当于属性)和对数据的操作(相当于行为)。
类的形式
class 类名 {
public:
//公有数据成员和成员函数
protected:
//保护数据成员和成员函数
private:
//私有数据成员和成员函数
}; // 千万不要忘了这个分号
class内部可以拥有的是数据成员(属性)和成员函数(行为),他们可以分别用三个不同的关键字进行修饰,public、protected、private. 其中public进行修饰的成员表示的是该类可以提供的接口、功能、或者服务;protected进行修饰的成员,其访问权限是开放给其子类;private进行修饰的成员是不可以在类之外进行访问的,只能在类内部访问,可以说封装性就是由该关键字来体现。
class和struct的区别
在 C++ 中,与 C 相比, struct 的功能已经进行了扩展。 class 能做的事儿, struct 一样能做,他们之间唯一的区别,就是默认访问权限不同。 class 的默认访问权限是 private , struct 的默认访问权限是 public。
对象的创建
构造函数
C++ 为类提供了一种特殊的成员函数----构造函数来完成相同的工作。构造函数有一些独特的地方:
- 函数的名字与类名相同
- 没有返回值
- 没有返回类型,即使是 void 也不能有
构造函数在对象创建时自动调用,用以完成对象成员变量等的初始化及其他操作(如为指针成员动态申请内存等);
如果程序员没有显式定义它,系统会提供一个默认构造函数。下面我们用一个点 Point 来举例:
编译器自动生成的缺省(默认)构造函数是无参的,实际上,构造函数可以接收参数,在对象创建时提供更大的自由度。我们在上面的 Point 类中可以加入一个新的构造函数。
现在假设 Point 类中只显式定义了一个有参构造函数,则编译器不会再自动提供默认构造函数,如果还希望通过默认构造函数创建对象,则需要显式定义一个默认构造函数。
初始化表达式
在上面的例子中,构造函数对数据成员进行初始化时,都是在函数体内进行的。除此以外,还可以通过初始化列表完成。初始化列表位于构造函数形参列表之后,函数体之前,用冒号开始,如果有多个数据成员,再用逗号分隔,初始值放在一对小括号中。例子如下:
如果没有在构造函数的初始化列表中显式地初始化成员,则该成员将在构造函数体之前执行默认初始化。
注意:每个成员在初始化列表之中只能出现一次,其初始化的顺序不是由成员变量在初始化列表中的顺序决定的,而是由成员变量在类中被声明时的顺序决定的。
对象的销毁
构造函数在创建对象时被系统自动调用,而析构函数(Destructor)在对象被撤销时被自动调用,相比构造函数,析构函数要简单的多。
析构函数有如下特点:
- 与类同名,之前冠以波浪号,以区别于构造函数。
- 析构函数没有返回类型,也不能指定参数。因此,析构函数只能有一个,不能被重载。
- 对象超出其作用域被销毁时,析构函数会被自动调用。
析构函数在对象撤销时自动调用,用以执行一些清理任务,如释放成员函数中动态申请的内存等。如果程序员没有显式的定义它,系统也会提供一个默认的析构函数。例如:
由于 Point 类比较简单,数据成员中没有需要进行清理的资源,所以即使不显式定义析构函数,也没关系。我们再举一个例子:
以上的 Computer 中,有一个数据成员是指针,而该指针在构造函数中初始化时已经申请了堆空间的资源,则当对象被销毁时,必须回收其资源。此时,编译器提供的默认析构函数是没有做回收操作的,因此就不再满足我们的需求,我们必须显式定义一个析构函数,在函数体内回收资源。
注意:一般不建议手动调用析构函数。
析构函数的调用时机:
- 对于全局定义的对象,每当程序开始运行,在主函数 main 接受程序控制权之前,就调用构造函数创建全局对象,整个程序结束时,自动调用全局对象的析构函数。
- 对于局部定义的对象,每当程序流程到达该对象的定义处就调用构造函数,在程序离开局部对象的作用域时调用对象的析构函数。
- 对于关键字 static 定义的静态局部对象,当程序流程第一次到达该对象定义处调用构造函数,在整个程序结束时调用析构函数。
- 对于用 new 运算符创建的对象,每当创建该对象时调用构造函数,当用 delete 删除该对象时,调用析构函数。
拷贝构造函数
用一个变量初始化另一个变量是经常发生的事情,就比如:
int x = 1;
int y = x;
在类的使用中,这样的操作同样存在,比如:
Point pt1 (1, 2);
Point pt2 = pt1;
这两组操作比较相似。第一组比较常见,第二组只是将类型换成了 Point 类型,执行 Point pt2 = pt1; 语句时, pt1 对象已经存在,而 pt2 对象还不存在,所以也是这句创建了 pt2 对象,涉及到对象的创建,就必然需要调用构造函数,而这里会调用的就是复制构造函数,又称为拷贝构造函数。
当我们进行测试时,会发现我们不需要显式给出拷贝构造函数,就可以执行第二组测试。这是因为如果类中没有显式定义拷贝构造函数时,编译器会自动提供一个缺省的拷贝构造函数。
Point这个类十分简单,所以默认的拷贝构造函数就可以满足要求,对于复杂的类,可能默认拷贝构造函数不能满足要求。
举个例子:
从上面的定义来看, pc1 与 pc2 对象的数据成员 _brand 都会指向同一个堆空间的字符串,这种只拷贝指针的地址的方式,我们称为浅拷贝。当两个对象被销毁时,就会造成 double free 的问题。显然,缺省拷贝构造函数不再满足需求,此时需要显式定义拷贝构造函数:
这种拷贝方式所指空间内容的方式,成为深拷贝,因为两个对象都各自拥有自己独立的内存空间,一个对象销毁时就不会影响另一个对象。
拷贝构造函数的调用时机:
- 当把一个已经存在的对象赋值给另一个新对象时,会调用拷贝构造函数。
- 当实参和形参都是对象,进行实参与形参的结合时,会调用拷贝构造函数。
- 当函数的返回值是对象,函数调用完成返回时,会调用拷贝构造函数。//-fno-elide-constructors注意编译器的优化选项会省略拷贝构造函数的打印,添加这个选项会显示出来。
**注意:**拷贝构造函数的形参形式可以改变吗?
- 形参中的引用符号&不可以改变
理由:当用一个已存在的对象初始化另一个对象的时候,要调用拷贝构造函数,此时,拷贝构造函数的形式是:
类名(const 类名 rhs);
当形参和实参进行结合的时候,又会调用另外一个拷贝构造函数,循环往复,源源不断。所以必须加上引用符号。 - 形参中的const不能去掉
解释理由之前,需要理解左值和右值的差别。
int number = 100;
int &ref = number;//正确, nmber是左值
int &ref1 = 100;//错误
&number;//正确, 左值可以加引用符号
.
&func3();//错误, 临时对象不能加引用符
Point &rhs = func3();//错误, func3是右值
.
const int &ref1 = 100; //正确
const Point &rhs = fun3();//正确
左值可以取地址
右值不能取地址, 临时对象就是右值的一种形式,字面值常亮也是右值。回到问题本身,const是不能去掉的,如果要传递一个临时变量或者字面值时候,必须加const。
隐含的this指针
在类中定义的非静态成员函数中都有一个隐含的this指针,它代表的就是当前对象本身,它作为成员函数的第一个参数,由编译器自动补全。比如 print 函数的完整实现是:
对于类成员函数而言,并不是一个对象对应一个单独的成员函数体,而是此类的所有对象共用这个成员函数体。 当程序被编译之后,此成员函数地址即已确定。而成员函数之所以能把属于此类的各个对象的数据区别开, 就是靠这个this指针。函数体内所有对类数据成员的访问, 都会被转化为this->数据成员的方式。
this指针的特点:
- 可以通过this指针修改成员变量
- this不能修改,指针常量
- 隐藏在每个函数的第一个参数的位置
- this指向对象本身
赋值运算符函数
赋值和初始化是不一样的,在执行 pt1 = pt2; 该语句时, pt1 与 pt2 都存在,所以不存在对象的构造,这要与 Point pt2 = pt1; //(初始化操作)语句区分开,这是不同的。
在这里,当 = 作用于对象时,其实是把它当成一个函数来看待的。在执行 pt1 = pt2; 该语句时,需要
调用的是赋值运算符函数。其形式如下:
这里发现赋值运算函数没有写,也可以正常运行。那么如果类中没有显式定义赋值运算符函数时,编译器会自动提供一个缺省的赋值运算符函数。所以pt1 = pt2;是能够运行正确的。对于上述Point类来说,其实是:
这里需要解释几个常见的问题:
Q:赋值运算符函数返回值的有鸟用可以去掉吗?
A:不能去掉,根据拷贝构造函数的调用时机,在执行return语句的时候会调用拷贝构造函数增加开销。
Q:拷贝构造函数的返回值可以不是对象吗?
分析:上式有
pt2 = pt1;
那么如果有以下的式子:
pt3 = pt2 = pt1;连等情况。
我们如果令返回值类型是类意外的其他类型,那么这里就会出现错误,因为类型不匹配。
A:因为有连等的情况,所以要保证返回值的类型是类类型。
重新回到内容上,如果有字符串变量赋值出现的时候,依然会出现浅拷贝的现象。比如:
这里的_brand是一个字符串,进行赋值的时候,两个变量会指向同一片地址空间。当两个对象被销毁时,同样会造成double free 的问题,因此缺省赋值运算符函数不再满足需求,此时需要显式定义赋值运算符函数:
//那么正常思路,重新申请堆空间
Computer & Cpmputer::operator=(const Computer & rhs){
char *ptmp = new char[strlen(rhs._brand + 1)]();
strcmp(ptmp, rhs._brand);
delete [] _brand;
_brand = nullptr;
_brand = ptmp;
_price = rhs._price;
return *this;
}
那么如果出现自复制的情况,delete的时候就会把成员变量置为nullptr,这个时候就会出现错误。
调整代码顺序:
主要分为四步骤:
- 自复制
- 释放左操作数
- 深拷贝
- 返回*this
特殊数据成员的初始化
在 C++ 的类中,有4种比较特殊的数据成员,他们分别是常量成员、引用成员、类对象成员和静态成员,他们的初始化与普通数据成员有所不同。
常量数据成员
当数据成员用 const 关键字进行修饰以后,就成为常量成员。一经初始化,该数据成员便具有“只读属性”,在程序中无法对其值修改。事实上,在构造函数体内初始化 const 数据成员是非法的,它们只能在构造函数初始化列表中进行初始化。如:
引用数据成员
和常量成员相同,引用成员也必须在构造函数初始化列表中进行初始化,否则编译报错。
引用的底层实现是指针,在64位系统指针就是八个字节,所以引用占用八个字节。
类对象成员
当数据成员本身也是自定义类类型对象时,比如一个直线类 Line 对象中包含两个 Point 类对象,对Point对象的创建就必须要放在 Line 的构造函数的初始化列表中进行。如:
当 Line 的构造函数没有在其初始化列表中初始化对象 _pt1 和 _pt2 时,系统也会自动调用 Point 类的默认构造函数,此时就会与预期的构造不一致。因此需要显式在 Line 的构造函数初始化列表中初始化_pt1 和 _pt2 对象。
静态数据成员
C++ 允许使用 static (静态存储)修饰数据成员,这样的成员在编译时就被创建并初始化的(与之相比,对象是在运行时被创建的),且其实例只有一个,被所有该类的对象共享,就像住在同一宿舍里的同学共享一个房间号一样。
静态数据成员和之前介绍的静态变量一样,当程序执行时,该成员已经存在,一直到程序结束,任何该类对象都可对其进行访问,静态数据成员存储在全局/静态区,并不占据对象的存储空间。
下面以 Computer 为例,模拟购买电脑的过程,为了获取总价,我们定义一个静态变量 _totalPrice :
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类对象时被定义的。这意味着它们不是由类的的构造函数初始化的,一般来说,我们不能在类的内部初始化静态数据成员,必须在类的外部定义和初始化静态数据成员,且不再包含 static 关键字,格式如下:
静态数据成员初始化的特点:
- 位于全局静态区,初始化的位置在全局的位置,需要放在类外进行初始化,初始化要在实现文件中,放在头文件会出现重定义的情况。
- 被类所共有的。
- 静态数据成员不占用类的大小。
特殊成员函数
除了特殊的数据成员以外, C++ 类中还有两种特殊的成员函数:静态成员函数和 const 成员函数。
静态成员函数
成员函数也可以定义成静态的,静态成员函数的特点:
- 静态成员函数内部不能使用非静态的成员变量和非静态的成员函数
- 静态成员函数内部只能调用静态数据成员和静态的成员函数
原因在于静态成员函数的参数列表中不含有隐含的 this 指针。但是非静态的成员函数可以访问静态数据成员和静态成员函数。
可以通过传参的方式使用非静态的成员变量,如下:
静态成员函数是被所有对象所共享的,可以用任意一个对象进行调用。但通常的,我们通过类名加作用于限定符的形式来调用静态成员函数。 这是静态成员函数特有的调用方式。
一个有意思的题目
这里会core dumped, 因为指针为空。
const成员函数
把const 关键字放在函数的参数表和函数体之间(与之前介绍的 const 放在函数前修饰返回值不同),称为 const 成员函数。
比如当给 Computer 类添加一个 const 的打印函数后,则其实现如下:
const版本的成员函数与非const版本的成员函数是可以重载的。
const成员函数的特点:
- 非const版本的成员函数中this指针本身是不能够修改的
- const版本的成员函数中的this指针本身且指向的内容都是不能修改的
- 默认情况下,非const对象调用非const成员函数,const对象调用const成员函数
- 非const对象可以调用const成员函数
- const对象只能调用 const 成员函数,不能调用非 const 成员函数。
- 只能读取类数据成员,而不能修改之。
建议:先写const版本的成员函数,再写非const版本的,当数据成员不会发生改变时,必须使用const版本的成员函数
对象的组织
有了自己定义的类,或者使用别人定义好的类创建对象,其机制与使用 int 等创建普通变量几乎完全一致,同样可以创建 const 对象、创建指向对象的指针、创建对象数组,还可使用 new 和 delete 等创建动态对象。
const对象
类对象也可以声明为 const 对象,一般来说,能作用于 const 对象的成员函数除了构造函数和析构函数,便只有 const 成员函数了,因为 const 对象只能被创建、撤销以及只读访问,改写是不允许的。
指向对象的指针
对象占据一定的内存空间,和普通变量一致, C++ 程序中采用如下形式声明指向对象的指针:
初始化表达式是可选的,既可以通过取地址(&对象名)给指针初始化,也可以通过申请动态内存给指针初始化,或者干脆不初始化(比如置为 nullptr ),在程序中再对该指针赋值。指针中存储的是对象所占内存空间的首地址。针对上述定义,则下列形式都是合法的:
对象数组
对象数组和标准类型数组的使用方法并没有什么不同,也有声明、初始化和使用3个步骤。
- 对象数组的声明:
这种格式会自动调用默认构造函数或所有参数都是缺省值的构造函数。 - 对象数组的初始化:可以在声明时进行初始化。
堆对象
和把一个简单变量创建在动态存储区一样,可以用 new 和 delete 表达式为对象分配动态存储区,在复制构造函数一节中已经介绍了为类内的指针成员分配动态内存的相关范例,这里主要讨论如何为对象和对象数组动态分配内存。如:
注意:delete [ ] 删除时, 将new [ ] 返回的地址在往前移四个字节便可以拿到要析构的对象个数了。但是,new type[ ], 只有type显式定义析构函数时,编译器才会多开四个字节来保存对象个数。所以像new、int、char这样的内置类型编译器不会多开这四个字节,编译器自行优化。
单例模式
单例模式是23种 GOF 模式中最简单的设计模式之一,属于创建型模式,它提供了一种创建对象的方式,确保只有单个对象被创建。这个设计模式主要目的是想在整个系统中只能出现类的一个实例,即一个类只有一个对象。
单例模式的用途:全局唯一的资源,全局唯一的对象(变量)、字典库、词典库、网页库、日志系统记录日志的对象
其实现步骤有如下三步:
- 将构造函数私有化
- 在类中定义一个静态的指向本类型的指针变量
- 定义一个返回值为类指针的静态成员函数
单例模式的要求:一个类只能创建一个对象
- 创建的对象不能是栈对象,因为栈对象可以创建多个
- 创建的对象不能是全局对象,全局对象也可以生成多个
那么首先栈对象和全局对象在单例模式中是不能够被创建的,这可以通过构造函数私有化来实现,因为私有成员函数在类的外面不能被调用,这样就无法生成栈对象和全局对象。
堆空间完全是由程序员来控制的,我们可以在堆空间创建对象,因为构造函数是私有成员函数,所以创建堆对象需要在类内部创建,类外无法创建, 通过类中的共有成员函数里创建堆对象。
class Sigleton{
public:
Singleton *getInstance(){
return new Singleton;
}
private:
Sigleton(){
cout << "Sigleton()" << endl;
}
}
虽然可以通过getInstance成员函数来创建堆对象,但是在类的外面无法创建对象来调用getInstance成员函数。通过设置静态函数使得可以从类的外部创建对象。
class Sigleton{
public:
static Singleton *getInstance(){
return new Singleton;
}
private:
Sigleton(){
cout << "Sigleton()" << endl;
}
}
此时可以在类外创建对象,为了保证单例模式的性质,需要保证对象的唯一性。**可以标记一个变量,变量为空说明对象没有被创建,此时创建对象;对象不为空,说明对象已经创建,返回此对象即可。**注意静态的数据成员函数只能调用静态的数据成员。并且静态的数据成员要在类外初始化。
class Sigleton{
public:
static Singleton *getInstance(){
if(nullptr == _pInstance){
_pInstance = new Sigleton();
}
return _pInstance;
}
private:
Sigleton(){
cout << "Sigleton()" << endl;
}
static Singleton *_pInstance;
}
Singleton *Singleton::_pInstance = nullptr;
此时就已经满足了单例模式的基本要求,一个类只能创建一个对象,但是调用new必然要有delete表达式,防止出现内存泄漏的情况。所以要写析构函数。除此之外,必须也要把析构函数私有化,虽然只能创建一个对象,但是可以new多个指针指向这个对象,此时会出现一个对象,多次释放的情况,所以析构函数也必须私有化。销毁对象时通过destory()函数来释放堆空间,并置为静态。
class Sigleton{
public:
static Singleton *getInstance(){
if(nullptr == _pInstance){
_pInstance = new Sigleton();
}
return _pInstance;
}
static void destory(){
delete _pInstance;
}
private:
Sigleton(){
cout << "Sigleton()" << endl;
}
~Sigleton(){
cout << "~Sigleton()" << endl;
}
static Singleton *_pInstance;
}
Singleton *Singleton::_pInstance = nullptr;
int main(void){
Singleton *ps1 = Singleton::getInstance;
Singleton *ps2 = Singleton::getInstance;
return 0;
}
如果多次执行destory()函数,就会出现double free的问题。所以要先判断_pInstance是否为空。
static void destory(){
if(nullptr != _pInstance){
delete _pInstance;
_pInstance = nullptr;
}
}