【C++】(万字)一文看懂“类与对象”

一、类的定义

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形参,跟前置++构成函数重载,方便区分。** ![](https://i-blog.csdnimg.cn/direct/402498cd9ae946fb89ea9f62e6cd857d.png)

前置-- 和后置-- 也是一样的道理,就不多赘述了。

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++没有严格规定,各个编译器会自行处理。

本篇完,感谢阅读。\

相关推荐
斯是 陋室9 分钟前
在CentOS7.9服务器上安装.NET 8.0 SDK
运维·服务器·开发语言·c++·c#·云计算·.net
tju新生代魔迷1 小时前
C++:list
开发语言·c++
HHRL-yx1 小时前
C++网络编程 5.TCP套接字(socket)通信进阶-基于多线程的TCP多客户端通信
网络·c++·tcp/ip
tomato091 小时前
河南萌新联赛2025第(一)场:河南工业大学(补题)
c++·算法
每一天都要努力^4 小时前
C++拷贝构造
开发语言·c++
NoirSeeker5 小时前
在windows平台上基于OpenHarmony sdk编译三方库并暴露给ArkTS使用(详细)
c++·windows·arkts·鸿蒙·交叉编译
闻缺陷则喜何志丹6 小时前
【带权的并集查找】 P9235 [蓝桥杯 2023 省 A] 网络稳定性|省选-
数据结构·c++·蓝桥杯·洛谷·并集查找
EJoft7 小时前
WCDB soci 查询语句
开发语言·c++
HHRL-yx7 小时前
C++网络编程 2.TCP套接字(socket)编程详解
网络·c++·tcp/ip