一、类的定义
1. 定义方式
和结构体类似,类是C++中的一种很重要的自定义类型。 `class`**是定义类的关键字,随后接上类的名字,再接{ },内部是类的主体。}后的分号不能省略。类体中的内容称之为类的成员:类中的变量成为类的属性或成员变量,类中的函数称为类的方法或成员函数**。 为了区分成员变量,一般习惯会给成员变量加一个特殊标识,比如变量名前或后面加_或m开头。这个并不是C++强制的,具体看企业要求。 除了class,C++中struct也可以定义类。C++兼容C中struct的用法,同时struct也可以定义类,里面还可以定义函数,但一般情况下我们还是用class定义类。
C语言中,"struct+类的类型"才是结构体的类型,而C++中,结构体或类的名字即它们的类型。
如有class MyClass{......};
,则Myclass就是这个类的类型。
2. 类域
类定义了一个新的作用域,即类域。**类的所有成员都在类域中,在类体外定义成员时,需要用**`::`**指明成员来自哪个类域。**
刚才说了,类中有成员函数。如果这个函数定义比较简短,我们就可以直接把它定义在函数中。而如果这个函数定义很复杂,我们就需要将声明和定义分离:声明在类中、定义在类外。定义时也要用 ::
指明函数来自哪个类域。
tip:定义在类中的成员函数默认为inline函数
3. 访问限定符
C++类的最大特点就是能通过访问权限,选择性地将接口提供给外部的用户使用。这一过程,要用到访问限定符:
- public修饰的成员可以在类外直接被访问,如其名,"公共的"。
- protected和private修饰的成员在类外不能直接被访问,一般用来给其类的成员函数使用,如其名,"被保护的"、"私有的"。目前的学习中它们是一样的,以后的学习才能体现出它们的区别。
- 访问限定符的作用域:从该访问限定符出现的位置开始,直到下一个访问限定符出现为止,如果后面没有访问限定符,作用域就就到 } 为止。
- class定义的成员没有被访问限定符修饰时,默认为private修饰,struct定义的则默认为public修饰。
- 一般情况下,成员变量都会被限制为private/protected,需要给别人使用的成员函数则放在public中。
4. 实例:栈类的定义
我们之前在C语言中实现过了栈,是用结构体定义的。在C++中,我们就用类来实现:
cpp
#include<iostream>
#include<stdlib.h>
using namespace std;
typedef int STDataType;
//定义Stack类
class Stack
{
//访问限定符
public:
//这是初始化栈的函数,内容很短,就直接定义在类中
//给一个缺省参数,如果调用Init不传参,n=4
void Init(int n = 4)
{
arr = (int*)malloc(sizeof(STDataType) * n);
if (arr == nullptr)
{
perror("malloc fail!");
exit(1);
}
capacity = n;
top = 0;
}
//这是入栈的函数,内容较长,可以把声明和定义分离
void Push(STDataType x);
//访问限定符
private:
STDataType* arr;
int capacity;
int top;
};
//Stack的Push函数的定义
//类外定义,用Stack::指明类域
void Stack::Push(STDataType x)
{
if (top == capacity) //如果空间不够,要增容
{
int newcapacity = capacity == 0 ? 4 : 2 * capacity;
STDataType* tmp = (STDataType*)realloc(arr, newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail!");
exit(1);
}
arr = tmp;
capacity = newcapacity;
}
//空间足够
arr[top++] = x;
}
二、类实例化出对象
1. 对象的概念
用类类型在内存中创建类变量的过程,称为类实例化出对象,创建的类变量也叫做对象。类是对象的一种抽象描述,是模板一样的存在,类限定了它的对象有哪些成员变量,这些成员变量只是声明,还没有分配空间。直到用类实例化出对象的时候,才会分配空间。一个类可以实例化出多个对象,对象占用内存,存储类的成员变量。 打个比方,类是"建筑设计图",对象是"建筑"。建筑设计图里面记录了这种建筑的规格、房间数量、功能等信息,通过它我们能建造出许多建筑,每个建筑都要占用一块地方。
实际使用也很简单:类类型 对象名;
就实例化出了这个对象。调用对象的类成员函数,用.
操作符(或类类型指针用->
操作符)。举个例子,在刚才的Stack类的public区中再加一个打印栈内容的方法:
cpp
void Print()
{
for (int i = 0; i < top; i++)
{
cout << arr[i] << ' ';
}
cout << endl;
}
我们将刚才定义的Stack类放在Stack.h头文件中,在test.cpp里测试一下:
可以看出来,C语言和C++实现结构或类的区别在于,C++能将类的成员变量及这种类的有关操作实现都封装在类中,被类"严格管理"。但C语言的定义结构、有关操作实现是分开写的,不能把它们结合在一起,相对显得"没有规矩"。
2. 实例:日期类
一个日期由三个数组成:年月日
cpp
class Date
{
public:
void Init(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
测试:

三、类对象的大小
计算一个类对象的大小,规则和结构体变量的大小计算方法几乎一模一样,详见结构体的内存对齐规则。再复习一下:
- 类的第一个成员变量,对齐到偏移量为0的地址处。
- 其他成员变量,依次对齐到偏移量为对齐数的整数倍的地址处
- 每个成员变量的对齐数是,编译器的默认对齐数和该成员变量的大小两者中的较小值。VS的默认对齐数是8;Linux中gcc没有默认对齐数,对齐数即是成员自身大小。
- 类的总大小是:所有成员变量的对齐数中的最大值的整数倍。
相比计算结构体大小,只有一点点特殊的地方:
- 计算类对象的大小,只计算成员变量,忽略成员函数、内部类、内部结构体等。
- 如果这个类没有成员变量,有0个或若干个成员函数或内部类等,则这个类对象的大小是1字节。(这个1并不是指类的大小就是1,而是作为一种"标识"的存在,象征着这个类没有成员变量)
举三个类的例子,分别是既有成员变量也有成员函数、只有成员函数、没有成员:
cpp
class c1
{
int a; //对齐:0~4
char b; //对齐:4~5
double c; //对齐:8~12
//大小:8的倍数:16
int test1()
{
return 1;
}
int test2()
{
return 2;
}
};
class c2
{
int test3()
{
return 3;
}
int test4()
{
return 4;
}
};
class c3
{
};
再看看它们的大小:
冇问题~
四、成员函数中隐藏的this指针
刚才我们举例的Date类中,有Init和Print两个成员函数,函数体中没有关于不同对象的区分,那么当date1调用Init函数和Print函数时,该函数是如何知道该访问date1还是date2对象呢?这是因为C++给了成员函数一个隐藏的this指针。 在C++中,**编译器编译后,类的成员函数都会默认在第一个形参前增加一个当前类类型的指针(没有形参则直接增加),叫做this指针**。比如Date类中,我们写了`void Init(int year, int month, int day)`,**实则真实原型为**`void Init(Date* const this, int year, int month, int day)`**。如其名,this指针指向的是这个对象自己。**
类中成员函数访问成员变量,本质上都是通过this指针访问的。通过this指针,函数能找到要访问的成员变量。比如Init函数中 _year = year;
,本质上是 this->_year = year;
。这样调用date1.Init
时,this指针指向date1,this->_year
就能确定访问的是date1的_year成员。
看,我们在Date中写一个Print_this方法,和其类对象的地址比一下,确实是同一个地址:
this指针是编译器自动生成的,C++规定不能在实参或形参的位置显式地写出this指针 ,说白了就是不用我们自己写出来。但是在函数体里我们可以显式使用this指针,比如刚才的cout << this << endl
,或后面马上会见到的return *this;
。
cpp
class Date
{
public:
//本质原型为:void Init(Date* const this, int year, int month, int day)
void Init(int year, int month, int day)
{
//_year = year;
//_month = month;
//_day = day;
//本质上是这样:
this->_year = year;
this->_month = month;
this->_day = day;
}
//本质原型为:void Print(Date* const this)
void Print()
{
//cout << _year << '-' << _month << '-' << _day << endl;
//本质上是这样:
cout << this->_year << '-' << this->_month << '-' << this->_day << endl;
}
//本质原型为:void Print_this(Date* const this)
void Print_this()
{
cout << this << endl;
}
private:
int _year;
int _month;
int _day;
};
tip:this指针存放在内存的堆区,并不是存在对象里面的。
实际上,类的成员函数存放在了内存中的一个公共区域,一个类的多个对象调用其成员函数时,是去这个区域找相应函数的。这些函数在类的定义时就已经创建好了,不论最后这个类有没有实例化出对象,这些函数都已存在。而成员变量不一样,它只有在实例化出对象时才被创建,在此之前内存中不存在。中一个经典的例子是:
cpp
class A1
{
public:
void Print()
{
cout << "1" << endl;
}
private:
int _a;
}
int main()
{
A1* p = nullptr;
p->Print();
return 0;
}
这个程序可以正常运行,即便p是空指针,即便这里没有实例化出对象,Print函数已经存在。p->Print()实际上不是解引用,它代表找A1类类型的Print函数。这个函数已经存在,也就能打印出"1"。
cpp
class A2
{
public:
void Print()
{
cout << _a << endl;
}
private:
int _a;
}
int main()
{
A2* p = nullptr;
p->Print();
return 0;
}
这个程序会运行崩溃,原因是p是空指针,这里没有实例化出对象,p指向的空间没有创建出_a变量。虽然Print函数已经存在,但函数内_a无法被找到,系统会在内存空间中一直向后寻找_a,直到程序崩溃。
顺带一提:成员函数也可以被const修饰,称为const成员函数。const修饰成员函数,要把const放在函数的 )
后面。const实际修饰的是该成员函数隐含的this指针,表明该函数中不能对类的任何成员进行修改。
cpp
class A
{
public:
//本质: void Print(const A* const this)
void func() const
{
//......
}
//......
}

五、类的默认成员函数
1. 默认成员函数的概念
类有默认成员函数的概念。**默认成员函数就是用户没有显式实现,编译器会自动生成的成员函数**。一个类,我们不写的情况下,编译器会默认生成六个默认成员函数:构造函数、析构函数、拷贝构造函数、赋值运算符重载、取地址运算符重载、const 修饰的取地址运算符重载。前四个是最重要的,后两个了解即可。C++11之后新增加了两个默认成员函数:移动构造、移动赋值,我们以后再学习。

有人问:编译器都默认生成了,我们还管这些函数干什么呢?答案是,编译器默认生成的函数不确定能否满足用户的需求,所以很多时候还是需要我们自己实现这些函数的。下面我们就来依次学习它们:
2. 构造函数
2.1 构造函数的概念与特点
构造函数是一种很特殊的函数,**作用是实例化出对象时,初始化对象成员变量**。构造函数的本质是替代我们以前Stack类和Date类中的Init函数,但是它能自动调用,比手动写的Init函数更好。构造函数有多特殊呢?
- 构造函数在实例化出对象时自动调用
- 构造函数名与类名相同
- 无返回值(不用写任何类型,直接是一个函数名加括号)
- 构造函数可以重载
- 如果类中没有显式定义构造函数,C++编译器会自动生成一个无参的默认构造函数 ①。只要用户自己显式定义了构造函数,编译器就不再自动生成。
- 用户不写,编译器自己生成的无参默认构造函数,对内置类型②的成员变量的初始化没有要求,也就是这些变量有没有初始化是不确定的,具体看编译器,可能就是随机数;而对于自定义类型的成员变量,要求再调用这个自定义类型的默认构造函数进行初始化,如果这个自定义类型成员变量没有默认构造函数,编译器就会报错。
注①:默认构造函数,指不需要用户传实参就能调用的构造函数,包括无参构造函数、全缺省构造函数、用户不写构造编译器默认生成的构造函数。这三个函数只能存在一个,不能同时存在。因为无参构造函数和全缺省构造函数调用时会存在歧义(学函数重载时讲过),而这两者是用户显式定义的,它们存在其一就不会存在编译器默认生成的构造函数。
注②:C++把类型分为内置类型(或基本类型)和自定义类型,内置类型就是语言提供的原生数据类型,如int、char、double、指针等,自定义类型是我们用class、struct等关键字自己定义的类型。
我们不自己写构造函数的话,内置类型的成员变量的初始化是未知的,这是一个很不好的结果。所以,绝大多数的类都需要我们自己写构造函数。
2.2 构造函数的定义与使用
构造函数通常定义在public区,以下我们用Date类演示构造函数的定义与使用:
cpp
class Date
{
public:
//无参的构造函数
Date()
{
_year = 2006;
_month = 5;
_day = 24;
cout << "调用了无参构造函数" << endl;
//打印这句话只是我们为了证明这个函数被调用了,实际上和构造函数的功能无关
}
//需要用户传参的构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
cout << "调用了传参构造函数" << endl;
//打印这句话只是我们为了证明这个函数被调用了,实际上和构造函数的功能无关
}
//全缺省的构造函数
/*Date(int year = 2006, int month = 5, int day = 24)
{
_year = year;
_month = month;
_day = day;
}*/
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
调用构造函数的方式也相当特殊:
cpp
//要调用无参的构造函数,什么都不用写,也不用加():
//如果写成Date d1();编译器会无法区别这是不是函数声明
Date d1;
//要调用需要传参的构造函数,用()传参:
Date d2(2006, 5, 24);
//写法看起来有点奇怪,但这就是规定

类似于Date类的类,成员变量全是内置类型的,都要我们自己实现构造函数来满足初始化需求。
但是,还记不记得我们之前接触过的"用栈实现队列"中的MyQueue类?这个类的两个成员变量是自定义的Stack类类型。因此,MyQueue类实例化出对象时,不会调用它自己的构造函数,而是要求调用其成员的类的构造函数。所以,MyQueue类中不需要写构造函数,但Stack类中就一定要写构造函数了。验证一下:
cpp
class Stack
{
public:
Stack()
{
arr = NULL;
top = 0;
capacity = 0;
cout << "调用了Stack类的构造函数" << endl;
}
//Stack类应该再写一个析构函数,我们这里演示先不写,一会再讲
private:
int* arr;
int top;
int capacity;
};
class MyQueue
{
private:
Stack pushST;
Stack popST;
};

3. 析构函数
3.1 析构函数的概念与特点
析构函数的功能与构造函数相反,**析构函数的功能是完成对象中内存资源的清理释放工作(如malloc出的内存空间),并不是销毁对象本身,对象有自己的生命周期,结束会自动销毁的**。如果说构造函数的功能类似于Init函数,那么析构函数就类似于Destroy函数。而像Date类中没有内存资源需要释放,所以准确来说Date是不需要析构函数的。 析构函数有以下特点:
- 析构函数名是
~类名
- 没有参数
- 没有返回(和构造函数一样)
- 一个类只能有一个析构函数,若没有显式定义,系统会自动生成默认析构函数。
- 对象的生命周期结束时,系统会自动调用析构函数。
- 我们不写,编译器自动生成的析构函数,对内置类型成员变量不做任何处理,对自定义类型成员变量会再调用它的析构函数。
- 如果一个类中没有申请内存资源,析构函数可以不写,如Date类,直接使用编译器生成的默认析构函数就可以,但实际上这个函数什么都不做。但是如果有资源申请时,我们一定要自己写析构函数,否则会造成资源泄漏,如MyQueue类可以不写析构函数,因为自动生成的MyQueue析构函数中也会调用Stack类的析构函数,而涉及开辟空间的Stack类一定要写析构函数。
- 同一个局部域中的多个对象,C++规定后定义的对象先析构。
3.2 析构函数的定义与使用
析构函数的定义和使用与构造函数很像,它们都是**自动调用**的:
cpp
class A
{
public:
//构造函数
A()
{
p = (int*)malloc(10*sizeof(int));
cout << "调用了构造函数" << endl;
}
//析构函数
~A()
{
free(p);
cout << "调用了析构函数" << endl;
}
private:
int* p;
};

cpp
class Stack
{
public:
Stack()
{
arr = NULL;
top = 0;
capacity = 0;
cout << "调用了Stack类的构造函数" << endl;
}
~Stack()
{
free(arr);
cout << "调用了Stack类的析构函数" << endl;
}
private:
int* arr;
int top;
int capacity;
};
class MyQueue
{
private:
Stack pushST;
Stack popST;
};

cpp
class A
{
public:
~A()
{
cout << "调用了A类型的析构函数" << endl;
}
};
class B
{
public:
~B()
{
cout << "调用了B类型的析构函数" << endl;
}
};

可以看出来,C++的构造函数和析构函数的"自动调用"是最大的优点。C语言中我们只能手动调用初始化和销毁函数,而且如果忘了的话后果很严重。C++的这一点就方便了不少。

4. 拷贝构造函数
4.1 拷贝构造函数的概念与特点
**如果一个构造函数的第一个参数是自身类类型的引用,且任何额外的参数都有默认值,则此构造函数就叫做拷贝构造函数**(也叫复制构造函数),这个引用其实就是要拷贝的对象。**拷贝构造函数是一种特殊的构造函数,当想用一个已存在对象初始化另一个对象时,就需要调用拷贝构造函数。** 拷贝构造函数有以下特点:
- 拷贝构造函数是构造函数的一个重载
- 拷贝构造函数的第一个参数必须是其类类型对象的引用类型,若直接使用传值的方式会编译报错 ,原因我们稍后讲。拷贝构造可以有多个参数,但是后面的参数必须有缺省值。
- C++规定,自定义类型对象进行拷贝行为时,必须调用拷贝构造函数 。若未显示定义拷贝构造函数,则编译器会自动生成拷贝构造函数。自动生成的拷贝构造函数,对内置类型的成员变量会进行值拷贝(浅拷贝),对自定义类型成员变量会再调用它的拷贝构造函数。
像Date类这样,变量全是内置类型且没有额外开辟空间的类,编译器自动生成的拷贝构造函数就可以完成需要的值拷贝,所以不需要我们自己显式写拷贝构造函数;对于Stack这样的类,里面存在额外开辟的空间资源,编译器自动生成的拷贝构造函数只能完成浅拷贝,只能拷贝地址的值,所以需要我们自己实现拷贝构造函数来完成深拷贝,即在被拷贝对象中也开辟出相同大小的空间资源;像MyQueue这样成员都是自定义类型的类,编译器自动生成的拷贝构造函数会调用Stack类的拷贝构造函数,所以也不需要我们自己显式写拷贝构造。
4.2 拷贝构造函数的定义与使用
**最常见的拷贝行为之一,是把已存在的对象赋值给新的对象**,以Date类为例:
cpp
class Date
{
public:
//构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
//拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
cout << "调用了拷贝构造函数" << endl;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};

当然,Date类中不自己写拷贝构造函数也可以,编译器会自动完成任务:
在Stack类中,我们必须自己显式实现拷贝构造函数,完成对内存空间的深拷贝:
cpp
class Stack
{
public:
//构造函数
Stack()
{
arr = NULL;
top = 0;
capacity = 0;
}
//拷贝构造函数
Stack(const Stack& st)
{
//在被拷贝对象中开辟出和st的arr一样大小的空间
arr = (int*)malloc(sizeof(int) * st.capacity);
if (arr == nullptr)
{
perror("malloc fail!");
return;
}
//完成深拷贝,即内存资源中内容的拷贝
memcpy(arr, st.arr, sizeof(int) * st.top);
capacity = st.capacity;
top = st.top;
cout << "调用了拷贝构造函数" << endl;
}
//析构函数
~Stack()
{
free(arr);
}
private:
int* arr;
int top;
int capacity;
};

当然,拷贝行为不仅仅至有赋值这一种,函数传参也是一种拷贝 !因为我们说过,形参是实参的拷贝,函数传参也是一种拷贝行为。
这就是为什么,拷贝构造函数的第一个参数必须是其类类型对象的引用类型,若直接使用传值的方式会编译报错。
从语法逻辑上分析,若调用拷贝构造函数直接使用传值的方式,就有实参拷贝到形参的过程,出现拷贝行为就要调用拷贝构造函数。调用拷贝构造函数,又有传参拷贝的过程,又要调用拷贝构造函数......无限递归。所以,为了规避这种情况,C++一开始就规定了拷贝构造函数必须传引用,因为引用不是拷贝,创建引用类型时不会有拷贝行为。
除此之外,函数返回其实也是一种"拷贝"。函数返回值时,本质上是将中间结果(临时对象)暂存在寄存器,再将这个结构赋值给接受者,这一过程就是"拷贝",也会调用拷贝构造函数:
我们也可以传引用返回,这样返回时就没有拷贝的过程了。但是如果返回的引用对象是一个当前函数局部域的局部对象,函数结束时它会销毁,那么使用引用返回是有问题的,这时的引用相当于一个"野引用",像野指针一样。所以使用传引用返回一定要保证返回对象在函数结束后还存在。
5. 赋值运算符重载
5.1 运算符重载的概念
C语言中,有各种各样的运算符,比如`+ - = * / ==`等等,但是它们几乎只能被作用于内置类型的数据。自定义类型是不行的,**就像你不能写出**`if(结构体 == 结构体)`**或**`结构体 + 结构体`**这样的代码,否则编译报错。** 但在C++中,**允许我们进行运算符重载,给运算符指定新的含义**。对类类型对象使用运算符时,必须转换成调用对应的运算符重载,若没有对应的运算符重载,也会编译报错。
使用运算符重载,要知道这些:
- 运算符重载是具有特殊名字的函数,它的名字是
operator 要被定义的运算符
,operator是C++中的一个关键字,这个函数也具有参数、返回类型、函数体。 - 重载运算符函数的参数个数和该运算符作用的运算对象个数一样多 。一元运算符重载(如
++ --
)有一个参数;二元运算符重载(如+ - /
)有两个参数,二元运算符的左侧运算对象传给第一个参数,右侧运算对象传给第二个参数。 - 运算符重载可以定义在类内或类外。当它定义在类内时,它的第一个运算对象默认传给隐式的this指针,因此运算符重载在类内定义时,参数个数要比运算对象个数少一个。
- 运算符重载后,其优先级和结合性和原来保持一致。
- 不能用原来语法中没有的符号创建新的操作符,只能用已有的操作符,比如不能有
operator @
。 - 这五个运算符不能被重载:
.*
、::
、sizeof
、? :
、.
- 重载操作符必须至少有一个类类型参数:定义在类外时要记得自己加上,定义在类内会有隐式的this指针。不能通过运算符重载改变运算符计算内置类型的方式,换句话说,运算符重载只为自定义类型服务。
- 一个类要重载哪些操作符,是看我们重载后的使用有没有意义,比如Date类中我们想计算两日期之差,得到它们差多少天,那么重载
-
就有意义了。相反,计算两日期相加的结果没什么意义,重载+
就没必要了。
.*
操作符是对象调用成员函数指针的操作符,比如指针p指向了对象obj的fun函数,那么就可以通过obj.*p
调用这个fun函数。了解即可。
5.2 运算符重载的定义和使用
别忘了刚才讲的拷贝构造,给运算符传参时为了提高效率,我们还是用引用传参。
以重载-
为例,先看看类内重载操作符:-
有两个操作对象,所以类内重载只要传一个参数
cpp
class Date
{
public:
//重载-
//本质为:int operator-(Date* const this, const Date& d)
int operator-(const Date& d)
{
return this->_day - d._day;
}
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
重载了-后,我们就可以这样写代码了:
乂,逐渐感受到了运算符重载的力量。
再看看运算符重载写在类外。但问题出现了:我们在类外无法访问类的私有成员
为解决这个问题,我们可以使用友元(后面讲),也可以在类内public区再写一个方法:
cpp
//写在类内
int Get_day() const
{
return _day;
}
然后在类外重载运算符写成这样,我们就能变相使用私有成员的值了
cpp
int operator-(const Date& d1, const Date& d2)
{
return d1.Get_day() - d2.Get_day();
}

5.3 前置++和后置++的重载(或 --)
前置++和后置++的符号相同,那么重载时怎么区别他俩呢? C++规定,**后置++重载时,增加一个int形参,跟前置++构成函数重载,方便区分。** 
前置-- 和后置-- 也是一样的道理,就不多赘述了。
5.4 <<和>>的重载
**流插入<<和流提取>>其实有两个操作数,<<是cout和一个变量,>>是cin和一个变量**。**cout本质上是ostream类的一个对象,cin本质上是一个istream类的对象**。 **重载<<和>>时,必须重载为全局函数**,如果重载为成员函数,this指针默认占据了第一个形参的位置,第一个形参是操作符的左侧运算对象,调用时就变成了`对象 << cout`或`对象 >> cin`,不符合使用习惯和可读性。**所以重载为全局函数,把ostream或istream放到第一个形参的位置就好了,第二个形参放当前类类型对象。**
举个简单的例子:
cpp
class A
{
public:
A()
{
x = 0;
}
int Get_x()
{
return x;
}
void Set_x(int tmp)
{
x = tmp;
}
private:
int x;
};
ostream& operator<<(ostream& out, A& a)
{
out << a.Get_x();
return out;
}
istream& operator>>(istream& in, A& a)
{
int tmp;
in >> tmp;
a.Set_x(tmp);
return in;
}
因为我们原本就有
cout << x << y << endl;
、cin >> x >> y;
这样的连续提取插入提取的写法,所以我们重载后的>>和<<也最好有返回值,无返回值就满足不了这样的连续用法了。具体的写法,参考上面的代码,记住就好。

我们学习<<和>>时,说过它们可以自己识别变量类型。其实,这就是靠运算符重载实现的,在iostream中我们能看到:

<<和>>把每一种数据都重载了一次,所以我们的使用本质就是调用对应函数重载,匹配对应的数据类型的。
5.5 赋值运算符重载
**赋值运算符(=)重载是一个默认成员函数,用于完成两个已存在对象的拷贝赋值**。**这里注意和拷贝构造函数区别,拷贝构造是用于一个对象拷贝初始化给另一个要创建的对象。** 赋值运算符重载的特点:
- 赋值运算符重载是一个运算符重载,必须被重载为成员函数。
- 赋值运算符重载应有一个参数,建议写成const引用类型,减少传值传参的拷贝。
- 赋值运算符重载最好写返回值,返回类型建议写成当前类类型的引用,引用返回可以提高效率,有返回值的目的是支持连续赋值的用法。
- 赋值运算符重载没有显式实现时,编译器会自动生成一个默认赋值运算符重载,这个默认的函数的行为和默认拷贝构造函数类似,会对内置类型成员变量完成值拷贝(浅拷贝),对自定义类型成员变量会调用它的赋值运算符重载函数。
- 像Date这样的类,成员变量都是内置类型且没有额外指向的资源,不用我们自己写赋值运算符重载了;Stack这样的类,成员变量都是内置类型,但指针指向了额外的开辟资源,默认生成的赋值运算符重载无法完成开辟空间的深拷贝,所以就需要我们自己显式实现赋值运算符重载;MyQueue这样的类,成员变量都是自定义类型,所以也不需要自己写赋值运算符重载了。有一个记忆的规律:如果一个类需要显式写析构函数并释放资源,那么它也一定要显式写赋值运算符重载,否则就不需要。
我们还是以Date为例:
cpp
class Date
{
public:
Date(int year = 2000, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
//赋值运算符重载
Date& operator=(const Date& d)
{
//避免自己给自己赋值的情况
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
cout << "调用了赋值运算符重载" << endl;
return *this;
}
//拷贝构造函数
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
cout << "调用了拷贝构造函数" << endl;
}
private:
int _year;
int _month;
int _day;
}

5.6 取地址运算符重载
**取地址运算符(&)重载分为普通取地址运算符重载和const取地址运算符重载**,一般这两个函数编译器自动生成的就够我们用了,不必显式实现。除非一些很特殊的场景,比如我们不想让别人取到当前类对象的地址,就可以写成:
cpp
A* operator&()
{
return nullptr;
}
这部分简单了解即可。

六、初始化列表
之前我们实现构造函数时,初始化成员变量主要使用函数来赋值。其实构造函数还有一种形式,就是初始化列表。它和我们刚才学的构造函数又不太一样:
- 初始化列表的函数体是以一个
:
开始,接着是一个以,
分隔的数据成员列表,每个成员变量后跟着一个()
,里面是一个值或表达式。最后要加一对{},里面可以写其他操作或什么都不写。 - 每个成员变量在初始化列表中最多出现一次。
- 引用成员变量、const成员变量、没有默认构造的类类型成员变量,必须放在初始化列表中进行初始化,否则会编译报错。
- C++11支持在成员变量声明的位置给缺省值,这个缺省值主要是给没有显式在初始化列表的成员使用的。
- 我们尽量使用初始化列表进行初始化,无论是否在初始化列表显式初始化成员变量,每一个成员变量都是要走初始化列表初始化的。
- 如果这个成员在声明处给了缺省值且不在初始化列表中,就用这个缺省值;如果这个成员在声明处给了缺省值且在初始化列表中也初始化了,就用初始化列表中的值;如果这个成员没有给缺省值且没有在初始化列表中初始化,内置类型成员是否初始化取决于编译器,自定义类型成员还会调用它的默认构造函数,如果没有则编译报错。
- 初始化列表中,按成员变量在类中的声明顺序进行初始化,与成员在初始化列表中出现的先后顺序无关。我们建议变量的声明顺序和在初始化列表中的顺序保持一致。

以Date类为例:
cpp
class Date
{
public:
//初始化列表
Date(int year = 2000, int month = 1, int day = 1)
: _year(year)
, _month(month)
, _day(day)
{
cout << "使用了初始化列表" << endl;
}
int Getyear() const
{
return _year;
}
int Getmonth() const
{
return _month;
}
int Getday() const
{
return _day;
}
private:
int _year;
int _month;
int _day;
};
ostream& operator<<(ostream& out, Date& d)
{
out << d.Getyear() << ' ' << d.Getmonth() << ' ' << d.Getday();
return out;
}

七、隐式类型转换
**C++支持将内置类型隐式转换为类类型对象,这需要类内有相应内置类型为参数的构造函数。而在构造函数前加上关键字explicit,这个构造函数就不再支持隐式转换了。不仅如此,不同类类型的对象之间也可以进行转换,也需要相应的构造函数支持。**
说出来可能有点不好理解,我们举两个例子就好了:
cpp
//内置类型转换为类类型对象
class A
{
public:
//前面加上explicit,这个构造函数就不支持类型转换
A(int a, char b)
{
_a = a;
_b = b;
}
A(int a)
{
_a = a;
_b = 'x';
}
A(char b)
{
_a = 6;
_b = b;
}
int Geta()
{
return _a;
}
char Getb()
{
return _b;
}
private:
int _a;
char _b;
};

tip:多参数的隐式转换,是C++11以后才支持的
cpp
//不同类类型对象转换
class B
{
public:
B(int c, int d)
:_c(c)
,_d(d)
{}
int Getc()
{
return _c;
}
int Getd()
{
return _d;
}
private:
int _c;
int _d;
};
class A
{
public:
//因为A中用了B类型,所以最好B类型定义在前面,要不然就提前声明
A(B& b)
{
_a = b.Getc();
_b = 'a';
}
int Geta()
{
return _a;
}
char Getb()
{
return _b;
}
private:
int _a;
char _b;
};

其实,隐式类型转换的过程,是先用要被转换的变量构造一个类的临时对象,再用这个临时对象拷贝构造给转换的类对象,临时对象被销毁。所以这一过程中也会调用构造函数、拷贝构造函数、析构函数。但编译器遇到连续构造和拷贝构造的过程,会优化成直接构造。
八、static成员(静态成员)
**静态成员包括静态成员变量和静态成员函数:**
- 成员变量可以用static修饰,称为静态成员变量。这种变量为这种类的所有对象所共享,不属于某个具体的对象,存放在静态区。静态成员变量必须在类外初始化,不能在声明处给缺省值初始化,静态成员变量也不会走初始化列表。
- 成员函数也可以用static修饰,称为静态成员函数,静态成员函数没有this指针。这种函数中可以访问其他静态成员,但是由于没有this指针,不能访问非静态成员。而非静态的成员函数,可以任意访问静态成员和非静态成员。
- 在类域外可以访问静态成员,用
类名::静态成员
或对象.静态成员
的方式。 - 静态成员也是类的成员,仍受public、private等的限制。
使用实例:计算一个类实例化出了多少个对象:
cpp
class A
{
public:
//构造函数被调用时,说明一个新对象被创建了
A()
{
++count;
}
//拷贝构造函数被调用时,说明一个新对象被创建了
A(const A& a)
{
++count;
}
//析构函数被调用时,说明一个对象被销毁了
~A()
{
--count;
}
//用于计数的变量
static int count;
};
//在类外初始化
int A::count = 0;

九、友元
友元提供了一种突破访问限定符封装的方式,**分为友元函数和友元类,即在函数声明或类声明的前面加上关键字**`friend`**,它们的声明统称为友元声明**。**友元声明要放在类的里面。一个友元声明放在了某个类中,则此友元就可以访问该类的所有成员了**。这也很好理解,你是我的friend,我就允许你使用我的private了。 举个栗子:
cpp
class A
{
//友元声明
friend void func(const A& a);
friend class B;
//func是A的友元函数,func函数可以访问A类型对象的所有成员了
//B是A的友元类,B类型对象的成员可以访问A类型对象的所有成员了
private:
int x = 5;
int y = 24;
};
void func(const A& a)
{
cout << a.x << ' ' << a.y << endl;
}
class B
{
public:
B(const A& a)
{
//B类型对象可以访问A的private成员
n = a.x;
m = a.y;
}
private:
int n;
int m;
};

友元还有一些重要的特点:
- 友元声明仅仅是声明,并不是类的成员。
- 友元声明可以放在类的任意地方,不受访问限定符限制。
- 一个函数可以是多个类的友元函数。
- 一个类的友元类中的成员函数,都是这个类的友元函数。
- 友元类的关系是单向的,如只在A中声明了友元类B,则B是A的友元,而A不是B的友元。
- 友元类的关系不能传递,如果A只声明了友元类B,B声明了友元类C,但C不是A的友元。
- 友元一定程度上破坏了类的封装,增加了耦合度,不宜多用。
十、内部类
如果类A定义在了类B的内部,类A就称为类B的内部类,类B称为外部类。**内部类是一个独立的类,只受外部类类域和访问限定符限制,所以外部类的对象中不包含内部类。而内部类默认为外部类的友元类。** 内部类的本质也是一种封装,当B类设计出来就是为了专门给A类使用,就可以考虑把B类作为A类的内部类了。
cpp
class A
{
//内部类
class B
{
int c;
int d;
};
int a;
int b;
};
int main()
{
A a;
//外部类的大小不包含内部类
cout << sizeof(a) << endl;
//结果是8
return 0;
}
十一、匿名对象与拷贝的编译器优化
这两个小知识点了解即可:
- 匿名对象:
之前我们都是写成类型 对象名(实参)
实例化出对象,这样定义的对象叫做有名对象。我们也可以写类型(实参)
,这样定义出来的对象叫做匿名对象。
匿名对象的声明周期只有当前一行,一般如果我们需要定义一个对象仅供当前用一下,就可以定义匿名对象。当这行代码结束后,匿名对象销毁,也就会调用类的析构函数了。
它的使用场景,我们以后会见到的。 - 对象拷贝时的编译器优化:
现代编译器为了尽可能提高程序的效率,在不影响正确性的情况下,会尽可能减少一些传参或返回过程中可以省略的拷贝。关于如何优化,C++没有严格规定,各个编译器会自行处理。

本篇完,感谢阅读。\