C++ 类和对象(中)

一、默认成员函数

在C++ 类和对象(上)中,我提到过空类,也就是什么成员都没有的类。但事实上,空类中真的什么都没有吗?并不是,在C++中,对于任意一个类,编译器都会为我们自动生成6个默认的成员函数(如果我们不显示地去声明)。这些函数在特定的情况下会被自动调用,但自动调用并不意味着它们能像用户所期望的那样能实现特定的功能或者完成特定的任务,更多的时候需要我们自己实现这些函数的功能。在这里插入图片描述

默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。

在使用C语言练习初阶数据结构,即线性表、链表、栈、队列、二叉树等内容时,大家可能经常会犯两个错误:

  1. 在使用数据结构创建变量时忘记对其进行初始化操作而直接进行插入等操作;
  2. 在使用完毕后忘记对动态开辟的空间进行释放而直接返回;

C++为了避免上面C语言存在的问题,设计出了默认成员函数,其中,构造函数和析构函数针对的是上面的两个问题,而其他四个函数则是适用了其他场景与需求。

二、构造函数

1. 基础知识

构造函数是一种特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的任务并不是创建对象,而是当对象被创建之后完成对象的初始化工作;同时构造函数不能由用户调用,而是在创建类类型对象时由编译器自动调用,并且在对象整个生命周期内只调用一次。

构造函数有如下特性:

  1. 函数名和类名相同;
  2. 无返回值,连 void 都没有
  3. 对象实例化时编译器自动调用对应的构造函数;
  4. 构造函数支持重载与缺省参数
  5. 如果类中没有显式定义构造函数,C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义,编译器将不再自动生成;
  6. 自动生成的无参构造函数对内置类型不处理,对自定义类型调用它自身的默认构造函数
  7. 无参构造函数、全缺省构造函数、我们没写编译器自动生成的构造函数,都可以认为是默认构造函数
class Date
{
public:
	Date()  //无参构造函数
	{
		_year = 1970;
		_month = 1;
		_day = 1;
	}

	Date(int year, int month = 1, int day = 1)  //带参构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;                //调用无参构造函数
	d1.Print();

	Date d2(2022, 10, 4);   //调用带参构造函数
	d2.Print();
}

在这里插入图片描述

可以看到,我们并没有显式地去调用构造函数,而是由编译器自动调用;

注意事项

  1. 构造函数虽然支持重载和缺省参数,但是无参构造函数和全缺省构造函数不能同时出现,因为在调用时会产生二义性

    在这里插入图片描述

    一般情况下我们只会显式定义一个全缺省的构造函数,因为全缺省的构造函数可以接收各种参数情况

  2. 当我们调用无参构造函数或者全缺省构造函数来初始化对象时,不要在对象后面带括号,因为这样编译器会把它当作函数声明

在这里插入图片描述

2. 特性分析:自动生成

构造函数的第五点特性是:如果类中没有显式定义构造函数,C++编译器会自动生成一个无参的默认构造函数,但一旦用户显式定义编译器将不再自动生成;下面我们来验证这个特性

class Date
{
public:
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;  //无参
	d1.Print();
}

可以看到上面的日期类中我们没有显式地定义构造函数,所以编译器应该会自己生成一个无参的默认构造函数完成初始化工作:

在这里插入图片描述

但是我们发现 d1对象中的_year, _month, _day仍然是随机值,默认的构造函数好像并没有完成初始化工作,看来编译器生成的默认构造函数并没有什么用,至于原因就在构造函数的第六个特性。

3. 特性分析:选择处理

C++ 把类型分成内置类型 (基本类型) 和自定义类型,内置类型就是语言本身提供的数据类型,如int/char/double/指针,自定义类型就是我们使用 class/struct/union 等自己定义的类型,如:Stack/Queue/Date;

构造函数的第六点特性是:自动生成的无参构造函数对内置类型不处理,对自定义类型调用它自身的默认构造函数;

对于这个特性,我们使用 Date、Stack 和 Myqueue 三个类来对比理解(Myqueue 即 用两个栈实现一个队列

class Date
{
public:
	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};
class Stack
{
public:
	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};
class MyQueue
{
public:
	void Push(int x)
	{
		_pushST.Push(x);
	}

	Stack _pushST;
	Stack _popST;
};

当我们不显式定义构造函数时,编译器会自动生成一个无参的默认构造函数,但是对内置类型不处理,由于Stack 和 Date 的成员变量全部为内置类型,所以自动生成的构造函数在这里没有任何作用,必须我们手动定义自己的默认构造函数。

class Stack
{
public:
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a == nullptr)
		{
			perror("malloc fail\n");
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}

	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

而对于 MyQueue 来说,它的成员变量全部为自定义类型,即使我们不提供构造函数,编译器自动生成的构造函数也会去调用自定义类型的默认构造函数,完成初始化的需求

在这里插入图片描述

总结

那么,到底什么时候需要我们自己定义构造函数,什么时候使用编译器默认生成的构造函数呢?答案是:面向需求 – 当编译器默认生成的构造函数就能满足我们的需求时我们就不需要自己提供构造函数,如MyQueue;当编译器提供的构造函数不能满足我们的需求时就需要我们自己定义,如Date/Stack;

4. C++11新特性

经过上面的学习我们发现,自动生成的默认构造函数对内置类型不处理,对自定义类型要处理的特性使得构造函数变得很复杂,因为大多数类都有需要初始化的内置类型成员变量,这就使得编译器默认生成的构造函数看起来没什么作用;

C++11 针对内置类型成员不初始化的缺陷,打了个补丁:内置类型成员变量在类中声明时可以给缺省值,意思就是如果构造函数没有对该成员变量进行初始化,那么该变量就会使用缺省值。

在这里插入图片描述

这里给成员变量缺省值并不是对其初始化,因为类中的成员变量只是声明,还没有定义,只有当实例化对象之后它才具有物理空间,才能进行初始化。

三、析构函数

1. 基础知识

和构造函数相反,析构函数完成对象中资源的清理工作,并且在对象销毁时由编译器自动调用(如同构造函数不是创建一个对象一样,析构函数也不是销毁一个对象,对象的销毁工作由编译器完成)。

析构函数的特性如下:

  1. 析构函数名是在类名前加上字符 ~ (表示与构造函数功能相反)
  2. 析构函数无参数无返回值;
  3. 析构函数不支持重载,一个类只能有一个析构函数,若未显式定义,系统会自动生成一个默认析构函数;
  4. 对象生命周期结束时,C++ 编译器会自动调用析构函数
  5. 自动生成的析构函数对内置类型不处理,对自定义类型调用它自身的析构函数
  6. 如果定义了多个对象,后定义的对象会被先析构。

在这里插入图片描述

2. 特性分析:选择处理

析构函数的选择处理和构造函数的一样,对内置类型不处理(本来除指针以外的内置类型就不需要清理),对自定义类型调用它自身的析构函数。

  1. 对于成员变量都是内置类型,没有申请资源的类 (malloc 内存、fopen 文件等操作),比如 Date 类,我们不用显式定义析构函数,直接使用编译器自动生成的构造函数即可;

  2. 对于申请了资源的类,比如 Stack 类(成员变量_a指向了一块动态开辟的空间),如果我们使用自动生成的析构函数,那么对内置类型 int* _a 就不会进行清理,造成内存泄露;所以我们需要显式定义析构函数;

  3. 对于成员变量都是自定义类型的类,比如 MyQueue,我们也不用显式定义析构函数,编译器会调用自定义类型的析构函数。

总结

如果类中没有申请资源,析构函数可以不写,直接使用编译器自动生成的默认析构函数,比如 Date 类;如果类中申请了资源,必须写析构函数,否则会造成资源泄漏,比如Stack类;其中自定义类型的成员变量一律看作没有申请资源。

四、拷贝构造

1. 基础知识

我们知道创建对象时,可以通过传值调用构造函数初始化对象,那能否通过传对象初始化一个与已存在对象一模一样的新对象呢?答案是可以的,C++设计了拷贝构造来实现这个功能,拷贝构造可以看作是构造函数的函数重载,参数是对象。

拷贝构造的特性如下:

  1. 拷贝构造函数的参数只有一个且必须是类类型对象的引用(一般常用 const 修饰),使用传值方式编译器直接报错,因为会引发无穷递归调用;
  2. 若未显式定义,编译器会自动生成默认的拷贝构造函数,在用已存在的类类型对象创建新对象时由编译器自动调用
  3. 自动生成的拷贝构造函数对内置类型以字节为单位进行值拷贝(浅拷贝),相当于 memcpy ,对自定义类型调用其自身的拷贝构造函数
class Date
{
public:
	Date(int year = 1970, int month = 1, int day = 1) //构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}

	Date(const Date& d)                     //拷贝构造
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
    Date d1;         //调用构造函数
    Date d2(d1);     //调用拷贝构造函数
    return 0;
}

2. 特性分析:引用传参

拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器会直接报错,因为会引发无穷递归调用。

原理如下:当我们使用 d1 来拷贝构造 d2 时,首先会传参,而传值传参时实参会拷贝一份给形参,也就是拷贝构造,编译器就会自动调用拷贝构造函数,调用拷贝构造函数的第一步又是传参,如此下去就会引发无穷递归;

在这里插入图片描述

但是如果拷贝构造函数的参数是引用的话,形参作为实参的别名,不需要拷贝,也就不会再自动调用拷贝构造函数,从而使得函数功能顺利实现;另外,拷贝构造函数的参数通常使用 const 修饰,这是为了避免在函数内部修改传进来的对象。

3. 特性分析:选择处理

编译器自动生成的拷贝构造函数对内置类型以字节为单位进行值拷贝(浅拷贝),对自定义类型调用其自身的拷贝构造函数。

值拷贝对于大部分类型都是可行的,但是对于指针类型就会引发错误,下面以Stack类为例:

class Stack
{
public:
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a == nullptr)
		{
			perror("malloc fail\n");
			exit(-1);
		}
		_top = 0;
		_capacity = capacity;
	}

	~Stack()  
	{
		free(_a);
		_a = NULL;
		_top = _capacity = 0;
	}
    
	void Push(int x)
	{
		_a[_top++] = x;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
    Stack s1;
    Stack s2(s1);   //调用自动生成的拷贝构造函数
    return 0;
}

上面并没有显式定义 Stack 的拷贝构造函数,那么编译器会自动生成一个拷贝构造函数,把 s1 的内容按字节拷贝到 s2,看似没有任何问题,但是程序一运行就崩溃了,调试发现程序是在调用析构函数时崩溃的。

在这里插入图片描述

在这里插入图片描述

原来是因为 s1 和 s2 的成员变量 _a 指向了同一块空间,程序结束后这两个对象分别调用了两次析构函数对这块空间进行了释放,而这是不允许的。

在这里插入图片描述

浅拷贝在拷贝指针时,不仅不能正确地完成拷贝任务,还会造成程序崩溃,所以在拷贝有指针的对象时,不能依赖于自动生成的拷贝构造函数,而是应该自己手动定义具有深拷贝功能的拷贝构造函数,做法是为 s2 的 _a 单独开辟一块空间,并将 s1 中_a指向空间的内容拷贝到新空间中,其余内置成员变量再按字节拷贝

Stack(const Stack& st)  //拷贝构造
{
    _a = (int*)malloc(sizeof(int) * st._capacity);
    if (_a == nullptr)
    {
        perror("malloc fail\n");
        exit(-1);
    }
    memcpy(_a, st._a, sizeof(int) * st._capacity);
    _top = st._top;
    _capacity = st._capacity;
}

在这里插入图片描述

对于 MyQueue 类来说,它的成员变量全部是自定义类型,所以编译器会去调用其自身的拷贝构造函数,一如既往地不用我们操心。

在这里插入图片描述

总结

如果类中没有申请资源,拷贝构造可以不写,直接使用编译器自动生成的默认拷贝构造函数,比如 Date 类;如果类中申请了资源,必须写拷贝构造函数,否则就可能出现浅拷贝以及同一块空间被析构多次的情况,比如Stack类;其中自定义类型的成员变量一律看作没有申请资源。

拷贝构造和析构函数在资源管理方面有很大的相似性,可以认为这两个成员函数总是同时出现。

拷贝构造的三种使用场景:

  1. 使用已存在对象创建新对象
  2. 函数参数类型为类类型对象
  3. 函数返回值类型为类类型对象

五、运算符重载

对于 C/C++ 编译器来说,它知道内置类型的运算规则,比如整形 + 整形、指针 + 整形、浮点型 + 整形;但是它不知道自定义类型的运算规则,比如自定义的日期类的运算规则,日期 + 天数 、日期比较大小、日期 - 日期,这些编译器都不能自己计算,需要我们自己去定义相应的函数,比如 AddDay、SubDay,但是这些函数的可读性始终是没有 + - > < 这些符号的可读性高的,而且调用也不如运算符方便;

为了让自定义类型支持由运算符计算,C++为自定义类型引入了运算符重载,运算符重载是具有特殊函数名的函数 ,其函数名为关键字 operator + 需要重载的运算符符号,也具有返回值和参数列表。

下面以 Date 类的 += 运算符重载为例:

class Date
{
public:
	Date(int year = 1970, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	//获取每个月的天数
	int GetMonthDay(int year, int month)
	{
		static int day[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
		if ((month == 2) && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0))) //闰年
			return 29;
		return day[month];
	}

	//打印
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

    void operator+=(Date& d, int day)
    {
        d._day += day;
        while (d._day > GetMonthDay(d._year, d._month))
        {
            d._day -= GetMonthDay(d._year, d._month);
            d._month++;
            if (d._month > 12)
            {
                d._month -= 12;
                d._year++;
            }
        }
    }
    
private:
	int _year;
	int _month;
	int _day;
};

上面的实现看似没有问题,实则犯了一个很低级的错误,就是把对象的值也传进成员函数里面了。

在这里插入图片描述

程序报错说函数参数太多,因为一个 += 运算符共两个操作数,所以成员函数 operator+= 应该只能接收两个参数,第一个参数是隐含自动传递的 this 指针,第二个参数才是留给我们显式传递的 day。

//+= 运算符重载
void operator+=(int day)  //只传递右操作数,左操作数通过this指针自动传递
{
    _day += day;  
    while (_day > GetMonthDay(_year, _month))
    {
        _day -= GetMonthDay(_year, _month);
        _month++;
        if (_month > 12)
        {
            _month -= 12;
            _year++;
        }
    }
}

在这里插入图片描述

运算符重载函数有如下特性:

  1. 不能通过连接其他符号来创建新的操作符:比如operator@
  2. 重载操作符必须有一个类类型操作数 (因为运算符重载只能对自定义类型使用);
  3. 运算符重载函数作为成员函数时,其形参看起来比操作数数目少1,是因为成员函数的第一个参数为隐藏的 this;
  4. 以下5个运算符不能重载: .* :: sizeof . ?:

特殊的运算符重载

在常见的运算符重载中,operator++ 和 operator-- 有一些特殊的地方。拿++举例,++ 分为前置++和后置++,二者如果都用运算符重载实现,除了返回值不同,其他都相同,这样的两个函数是不能构成函数重载的。所以C++规定:后置++/--重载时多增加一个int类型的参数,此参数由编译器自动传递,目的就是为了和前置 ++/--构成函数重载。

六、赋值重载

1. 基础知识

赋值运算符重载函数是C++的六个默认成员函数之一,它也是运算符重载的一种,它的作用是在两个已存在的对象之间进行赋值。

赋值运算符重载的特性如下:

  1. 赋值运算符只能重载成类的成员函数,不能重载成全局函数,毕竟这是六个默认成员函数之一。
  2. 若未显式定义,编译器会自动生成默认的赋值重载函数,并在两个已定义的对象之间进行赋值的时候自动调用。
  3. 自动生成的赋值重载函数对内置类型以字节为单位进行值拷贝(浅拷贝),对自定义类型调用其自身的赋值重载函数;

2. 特性分析:选择处理

赋值重载函数的选择处理特性和拷贝构造函数非常类似:自动生成的赋值重载函数对内置类型以字节为单位进行值拷贝,对自定义类型会去调用其自身的赋值重载函数。

对于没有申请资源的类来说,不用自己定义赋值重载函数,直接使用默认生成的即可,因为这种类只需要进行浅拷贝 (值拷贝),比如 Date 类;而对于申请了资源的类来说,我们必须自己手动实现赋值重载函数,来完成深拷贝工作,比如 Stack 类。

在这里插入图片描述

如果使用了浅拷贝,造成的问题比拷贝构造函数更多,如上图,不仅会导致 st2._a 指向的空间被析构两次,同时,``st1._a `原本指向的空间还没有被释放,导致内存泄漏

至于深拷贝的实现也和拷贝构造函数有所不同,除了正常的开辟新空间,拷贝值进去,还要求不能自己给自己赋值。

原因如下图:当我们使用st2 = st2给自己赋值时,operator= 函数首先会将 st2._a 指向的空间释放,然后再为其申请新空间,但是由于 st2 自己给自己赋值,所以用来赋值的数组已经被释放了,里面是随机值,再赋值就会得到错误的结果。

在这里插入图片描述

Stack 类的赋值重载函数的实现:

Stack& operator=(const Stack& st) 
{
    //检查自我赋值
    if (this == &st)
    {
        return *this;
    }

    free(_a);
    _a = (int*)malloc(sizeof(int) * st._capacity);
    if (_a == nullptr)
    {
        perror("malloc fail\n");
        exit(-1);
    }

    memcpy(_a, st._a, sizeof(int) * st._capacity);
    _top = st._top;
    _capacity = st._capacity;

    return *this;
}

说明

  1. 函数采用传引用传参是为了避免在传参的时候调用拷贝构造函数,提高函数运行效率
  2. 同时用 const 关键字进行修饰是为了避免赋值对象st在函数内部被修改
  3. 为了自定义类型支持连续赋值,拷贝构造函数必须有返回值,同时又为了避免在传值返回时调用拷贝构造函数,采用了传引用返回。

另外,和拷贝构造一样,对于成员变量为自定义类型的 MyQueue 类,不用自己定义赋值重载,自动生成的赋值重载函数就够用了。

总结:拷贝构造,析构函数和赋值重载函数,这三者总是同时出现

七、取地址重载

1. const 成员函数

我们将 const 修饰的 “成员函数” 称之为 const 成员函数,const 修饰成员函数实际上是修饰该成员函数隐含的 this 指针,表明在该成员函数中不能对 this 指向的对象中的任何成员变量进行修改

  • 我们建议不需要改变 *this 的成员函数都用 const 修饰,这是为了能够接收更多类型的 this 指针。
在这里插入图片描述

我们看到,当我们定义了一个只读的 Date 对象 d1 时,我们再去调用 d1 的成员函数 Print 时编译器会报错;原因在于类成员函数的第一个参数默认是 this 指针,而 this 指针的类型是 Date* const,而我们传进去的类型是 const Date*;将一个只读变量赋值给一个可读可写的变量时权限扩大,导致编译器报错;

实际上我们很少在成员函数外面定义只读类型的对象,更多的是在成员函数里面。比如实现运算符重载函数operator–(const Date& d) 时,当运算符重载的两个参数都是类对象时,如果我们不会改变类的内容,我们通常会将函数形参定义为 const Date& 类型,这时在成员函数内部,对象 d 就不能调用一般成员函数,否则就会报错。

所以我们应该把能用 const 进行修饰的成员函数全部用 const 进行修饰,这是为了让常对象也能调用,以Date 类为例:

class Date
{
public:
	//构造
	Date();
	//获取每一个月的天数
	int GetMonthDay(int year, int month) const;
	//获取日期对应天数
	int GetDateDay() const;
	//打印
	void Print() const;

	//运算符重载
	//+=
	Date& operator+=(int day);
	//+
	Date operator+(int day) const;
	//-=
	Date& operator-=(int day);
	//-
	Date operator-(int day) const;
	//前置++
	Date& operator++();
	//后置++
	Date operator++(int);
	//前置--
	Date& operator--();
	//后置--
	Date operator--(int);
	//日期-日期
	int operator-(const Date& d) const;
	//>
	bool operator>(const Date& d) const;
	//==
	bool operator==(const Date& d) const;
	//>=
	bool operator>=(const Date& d) const;
	//<
	bool operator<(const Date& d) const;
	//<=
	bool operator<=(const Date& d) const;
	//!=
	bool operator!=(const Date& d) const;

private:
	int _year;
	int _month;
	int _day;
};

2. 普通对象取地址重载

普通对象取地址重载函数是C++的六个默认成员函数之一,同时它也是运算符重载的一种,它的作用是返回普通对象的地址;

Date* operator&()
{
    return this;
}

3. const 对象取地址重载

const 对象取地址重载函数也是C++的六个默认成员函数之一,它是普通对象取地址重载的重载函数,其作用是返回 const 对象的地址;

const Date* operator&() const
{
    return this;
}

如果我们没有显式定义取地址重载和 const 对象取地址重载函数,那么编译器会自动生成,因为这两个默认成员函数十分固定,所以大多数情况下我们直接使用编译器默认生成的即可,不必自己定义;

八、总结

C++的六个默认成员函数中前面四个默认成员函数非常重要,也非常复杂,需要我们根据具体情况判断是否需要显式定义,大部分情况下,构造函数都需要我们自己定义,其他三个在类中有指针类型的成员变量时同时出现,而最后两个函数通常不需要显示定义,使用编译器默认生成的即可。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值