一、类的六个默认成员函数

如果一个类中什么成员都没有,简称为空类。

空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下6个默认成员 函数。

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

class Date{};

【C++】类和对象(二)[六大默认成员函数]_构造函数

二、构造函数

2.1、概念

构造函数是一个六大默认成员函数之一,其用于初始化对象。

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;
};
int main()
{
   Date d1;
   d1.Init(2022, 7, 5);
   d1.Print();
   Date d2;
   d2.Init(2022, 7, 6);
   d2.Print();
   return 0;
}

现在我们有一个Date 日期类,其内部有年,月,日三个成员变量。如果我们直接通过这个类定义一个对象出来,那么这三个成员变量就会是随机值。此时我们就可以通过构造函数来完成初始化,让其初始值变为我们需要的初始值。

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次

那么我们要如何书写一个构造函数?

构造函数特性:

  1. 函数名与类名相同。
  2. 无返回值。(是没有数值,而不是指void)
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。
class Date
 {
    public:
        // 1.无参构造函数
        Date()
       {}

        // 2.带参构造函数
        Date(int year, int month, int day)
       {
            _year = year;
            _month = month;
            _day = day;
       }
    private:
        int _year;
        int _month;
        int _day;
 };
  
  void TestDate()
 {
      Date d1; // 调用无参构造函数
      Date d2(2025, 1, 1); // 调用带参的构造函数
      
      Date d3();
 }
  • 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
  • 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
  • //vs中调试 :warning C4930: “Date d3(void)”: 未调用原型函数(是否是有意用变量定义的?)

2.2、构造函数的重载与缺省

构造函数是可以进行重载和参数缺省的,可以根据我们输入的不同值进行初始化。

  • 重载:
Date()
{
	_year = 1970;
	_month = 1;
	_day = 1;
}

Date(int year)
{
	_year = year;
	_month = 1;
	_day = 1;
}

Date(int year, int month)
{
	_year = year;
	_month = month;
	_day = 1;
}

Date(int year, int month, int day)
{
	_year = year;
	_month = month;
	_day = day;
}

那么我们要如何调用这些函数,在实例化时将传输传进去?

我们只需要在实例化时,在变量名后加一个小括号,小括号内写入参数

int main()
{
	Date d1;
	Date d2(2024);
	Date d3(2024, 2);
	Date d4(2024, 2, 20);

	return 0;
}
  • 缺省:

以上四个重构可以化作一个缺省的函数:

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

再来看以下三个构造函数:

Date()
{
	_year = 1970;
	_month = 1;
	_day = 1;
}

Date(int year)
{
	_year = year;
	_month = 1;
	_day = 1;
}

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

首先,对于Date(int year)这个函数,我们一定要传一个值才可以调用到。

而对于Date()和Date(int year = 1970, int month = 1, int day = 1),我们都可以不传值就可以调用。这种无需传值就被调用的构造函数,叫做默认构造函数。而默认构造函数只有三种,一种是无参数的构造函数,一种是全缺省参数的构造函数,最后一种就是编译器自动生成的构造函数。

一个类中,必须存在且只能存在一个默认构造函数。如果一个类中没有默认构造函数,那么编译器就会报错。而上述两种默认构造函数中,也只能存在一种。

2.3、编译器自动生成的构造函数

  • 构造函数是类的六大默认成员函数之一,对于这六大默认成员函数,如果用户没有显式定义,那么编译器会自动生成,而当用户一旦定义了,则编译器不再自动生成,改用用户定义的函数

C++把类型分为内置类型(基本类型)和自定义类型。

基本类型:C++语言自己提供的数据类型,如:int,char…

自定义类型:我们使用class/struct/union等自己定义的类型。

而编译器自动生成的构造函数,对于基本类型,不做处理;对于自定义类型,去调用其相应的默认构造函数。例如:

class Date
{
public:
	Date()
	{
		cout << "我被调用了" << endl;
	}
	
private:
	int _year;
	int _month;
	int _day;
};

class A
{
	int abc;
	Date d;
};

int main()
{
	A a1;

	return 0;
}

我们为 Date 日期类定义了一个构造函数,当这个函数被调用,会输出” 我被调用了 “

我们又定义了一个类 内有一个内置类型的 abc ,以及一个自定义类型 Date 的 

当我们再 main 函数中创建了 A a1 ;变量,此时会调用 的构造函数,而此时 的构造函数我们没有写出来,那么就是编译器自动生成的构造函数,此时这个 的构造函数会调用 Date 的构造函数,最后输出”我被调用了“。

2.4、成员变量默认值

内置类型在类中声明时,可以设置一个默认值,如果后续构造函数没有给这个内置类型赋值,那么这个内置类型就得到默认值。

例如:(日期类)

class Date
{	
private:
	int _year;
	int _month;
	int _day;
};

现在对这些成员变量设置默认值:

class Date
{	
private:
	int _year = 1970;
	int _month = 1;
	int _day = 1;
};

这样一来,默认值就设置好了,只要后续构造函数不对这些成员赋值,那么这些成员变量就会得到默认值。

编译器自动生成的构造函数不就是一个不会对内置类型做处理的构造函数吗?所以这个特性搭配编译器自动生成的构造函数一起使用,就可以在不写构造函数的情况下,也完成内置类型的初始化了。

2.5、初始化列表

C++的初始化列表是一种语法特性,用于在对象的构造函数中初始化成员变量。初始化列表使用冒号(:)逗号(,)来分隔成员变量初始值

在C++中,对象的成员变量可以在构造函数的函数体中进行初始化或者利用参数默认值。然而,初始化列表提供了一种更加简洁和高效的方式来初始化成员变量,尤其是对于复杂的对象或者const常量成员变量。

使用初始化列表的语法如下:

Classname(parameters)
 : member1(value)
 , member2(value)
 , ...
{
    // 构造函数函数体
}

其中,Classname是类的名称,parameters是构造函数的参数列表。冒号后面的部分就是初始化列表,其中每个成员变量都包含成员变量名称和初始值,用逗号分隔。

举例:

class Example {
public:
    Example(int n, double d) 
    : num1(n)
    , num2(d)
     {
        // 
    }
private:
    int num1;
    double num2;
};

在这个示例中,Example类有两个成员变量num1num2。构造函数使用初始化列表来对这两个成员变量进行初始化,而不是在构造函数的函数体中进行赋值。

成员变量的默认值与初始化列表的区别

class Example {
public:
    Example(int n, double d) 
    : num1(n)														//初始化列表
    , num2(d)														//
     {
        // 
    }
private:
    int num1 = 5;												//设置成员默认值
    double num2 = 3.0;									//
};
  • 成员变量的默认值只能指定数据,它是不可变的。
  • 成员变量的默认值并没有完成真正的赋值,它是处于对变量的声明阶段,只是告诉编译器我将要使用一个名字为xxx的变量,还没有正式开辟空间
  • 初始化列表则可以在括号中写入表达式,变量等等,这给了第一次赋值极强的灵活性。
  • 初始化列表则是对变量的定义阶段,也就是完成了变量的第一次赋值。

也正是这个区别,导致对于const常量以及引用类型的成员变量,只能用初始化列表来进行初始化。例如:

class Example {
public:
    Example(int& x, int y) 
    : ref(x)
    , _b(y)
     {
        // 
    }
private:
    int& ref; //引用
    const int _b; //常量
};

在类中是ref在上方,_b在下方。所以在初始化列表中,虽然_b排在前面,但是ref会先初始化。


3、析构函数

3.1、概念

通过前面构造函数的学习,我们知道一个对象是怎么来的,那一个对象又是怎么没呢的?

这就要用到析构函数了。

析构函数:与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由 编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

当一个对象的生命周期结束时,即对象不再被使用时,析构函数会自动调用。通过析构函数,可以释放对象使用的资源,如动态分配的内存、打开的文件等。析构函数的工作是清理对象所拥有的资源,以防止内存泄漏或资源泄漏。

例如:

class Example {
public:
	//构造函数
	Example()
	{
		a = (int*)malloc(sizeof(int) * 4);
		size = 4;
	}
    // 析构函数
    ~Example() {
        free(a);
        a = nullptr;
        size = 0;
    }
    
private:
    int* a;
    int size;
};

类中有一个成员a,指向了一块空间,在创建Example类的对象时,构造函数被调用,自动初始化了一块空间给a。如果这块空间不处理,那么当程序结束,就会造成内存泄漏。所以我们要在析构函数~Example()中释放a指向的空间。而析构函数的优点就是无需我们自己调用,当对象离开生命周期,这个函数会被自动调用。

  • 与构造函数不同的是:一个函数只允许存在一个析构函数,不允许重载。
  • 而与构造函数相同的是:当我们不写析构函数,编译器会默认生成一个析构函数,这也是六大默认成员函数的特性。

那么默认的析构函数会做什么事情呢?

  • 对于默认的析构函数:如果成员变量中存在其它类的对象,调用其它类的析构函数。
  • 这个过程是在释放其它类的对象调用的资源,而非这个对象。对象是创建在栈帧中的,会随着程序结束一起销毁,但是不能销毁的是动态内存之类的空间。所以我们如果开辟了动态内存,需要自己编写析构函数,将其free掉。如果没有,那么直接用编译器默认生成的析构函数即可。


3.2、特点

  1. 析构函数名是在类名前加上字符 ~
  2. 无参数无返回值类型
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。


4、拷贝构造函数

4.1、概念

拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

拷贝初始化构造函数的作用是将一个已知对象的数据成员值拷贝给正在创建的另一个同类的对象

其实我们在很多时候已经在不知不觉的拷贝对象了,比如函数传参。

例如:

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

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

void func(Date d)
{
	//函数体
}

int main()
{
	Date d1;
	func(d1);
	return 0;
}

以上代码中,我们定义了一个日期类Date,内部有三个成员_year_month_day。这三个都是内置类型的成员。

随后我们实例化了一个对象d1,并调用func函数,将d1作为参数传递了进去。形参是实参的一份临时拷贝,那么此时形参d就是实参d1的一份拷贝,那么d1d的过程就会发生一次拷贝。

举例:(拷贝如何实现)

class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		_array = (int*)malloc(capacity * sizeof(int));
		if (nullptr == _array)
		{
			perror("malloc fail!");
			return;
		}

		_size = 0;
		_capacity = capacity;
	}
	
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}
	
private:
	int* _array;
	size_t _size;
	size_t _capacity;
};

void func(Date s)
{
	//函数体
}

int main()
{
	Stack s1;
	func(s1);
	return 0;
}

在这个代码中,我们有一个栈类stack,在实例化时,会调用构造函数初始阿虎,malloc一段动态内存。此时我们调用函数func,将对象 s1 作为参数传入函数中,此时 就是 s1 的一份拷贝。 但是,如果按照直接拷贝,那么形参 的成员 _array 会与实例 s1 的成员 _array 值一样,此时形参和实参就指向了同一块空间了

我们既然没有传引用传参,当然是希望形参改变不要影响实参,但是此时两个对象指向同一块空间,就会互相影响了。甚至由于我们存在一个析构函数~Stack,析构函数会free_array指向的空间,ss1离开生命周期时,都会调用一次析构函数,此时_array指向的空间会被释放两次,这是不允许的。

可见,如果我们让编译器直接进行拷贝,是有可能会发生错误的。所以类中设立了一个拷贝函数,每当对象需要进行拷贝操作时,都会调用这个拷贝函数。

也就是说,我们每次把对象作为函数形参传递时,都会调用拷贝函数来进行拷贝。

拷贝函数是构造函数的一种重载,所以称为拷贝构造函数,简称拷贝构造。既然拷贝构造是构造函数的重载,那么其函数名也就是类名,与构造函数一致。

其结构:

ClassName(const ClassName& obj)
{
    // 拷贝构造函数的实现逻辑
}

拷贝构造有要求:必须只有一个参数,且参数类型必须是ClassName&,也就是对这个类的引用。如果我们此处的参数类型是ClassName,那么就会发生无限递归

比如:我们调用了func函数,需要传入一个类的拷贝,此时调用这个类的拷贝构造,由于拷贝构造需要传入类的拷贝,于是又要调用第二个拷贝构造,而第二个拷贝构造又要调用这个类的拷贝,此时调用第三个拷贝构造…

以此类推,永远递归下去。程序必然会崩溃。

【C++】类和对象(二)[六大默认成员函数]_构造函数_02

所以我们的拷贝构造必须传递引用来调用,防止无限递归。而一般而言,传入的被拷贝对象,我们是不希望其被修改的,所以还会加一个const修饰。最后我们的第一个参数就写为了const ClassName&


如何调用拷贝构造

  • 首先就是函数传参,表达式中等的隐式调用,会在底层偷偷调用拷贝构造函数。
  • 另一种就是我们主动调用:

由于是构造函数的重构,所以可以在创建对象时用该构造函数的语法:

  1. 即用一个小括号传参,把被拷贝的对象传入。
Stack s1;
Stack s2(s1);
  1. 另一种则是在创建对象时用赋值语句:
Stack s1;
Stack s2 = s1;

拷贝构造是六大默认函数之一,所以如果没有显式地定义拷贝构造函数,C++编译器会默认生成一个默认的拷贝构造。默认的拷贝构造对于内置类型,会直接拷贝,对于自定义类型,则会调用相应的拷贝构造。

也就是说,默认的拷贝构造是无法解决我们刚刚两个指针指向同一块空间的问题的,所以我们要自己写一个拷贝构造函数,才可以让被拷贝出来的对象与原对象独立

如下:

Stack(const Stack& other)
{
    _array = (int*)malloc(other._capacity * sizeof(int));
    if (nullptr == _array)
    {
        perror("malloc fail!");
        return;
    }
    
    memcpy(_array, other._array, other._size * sizeof(int));
    
    _size = other._size;
    _capacity = other._capacity;
}

上述拷贝构造函数将创建一个新的堆栈对象,其容量与参数中的堆栈对象容量相同。然后,它将使用memcpy函数将参数中的堆栈对象数组的内容复制到新的数组中。最后,它将设置新对象的大小和容量与参数对象相同。

这样拷贝出来的对象与原对象就独立了。


5、运算符重载

根据C++的基本语法,我们知道int与int被+操作符处理,是两者相加,被==操作符处理是两者判等。

那么我们是不是可以让这些操作符,在自定义类型中也发挥作用,比如一个Date+int可不可以实现某个日期加几天,Date == Date实现两个日期判等?这就需要通过运算符重载,给这些运算符赋予规则。

5.1、基本运算符重载

运算符重载是指对已有的运算符进行重新定义,使其可以用于自定义的数据类型或者实现不同的操作逻辑。通过运算符重载,可以为用户自定义的类型创建与内置类型相似的运算操作。

也就是说我们可以通过运算符重载,来设置某些情况下的运算符的功能。

运算符重载的一般语法为:

返回值类型 operator 运算符(参数列表)
{
    // 实现运算符功能的代码
}

其中,operator是关键字,用于标识运算符重载函数;运算符是要重载的运算符;返回值类型是运算符重载函数的返回类型;参数列表是运算符重载函数的参数。

举例:

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

	int _year;
	int _month;
	int _day;
};

bool operator==(const Date& d1, const Date& d2)
{
	return d1._year == d2._year
		&& d1._month == d2._month
		&& d1._day == d2._day;
}

这个operator==operator代表这是一个运算符重载的函数,而==是被重载的运算符。我们希望实现两个日期判等,所以我们此时的参数是两个日期类。

注意:操作符原本可以处理几个参数,那么就要传入几个参数,其中第一个参数为左操作数,第二个参数为右操作数。

上述代码逻辑:将==这个操作符重载,当左右的类型都为Date时,执行代码中的逻辑,只要两者的_year_month_day都相等,那么返回true,否则返回false

后续我们就可以直接用了:

int main()
{
	Date d1(2024, 1, 24);
	Date d2(2024, 1, 24);
	Date d3(2023, 12, 25);

	d1 == d2;//true
	d1 == d3;//false
	return 0;
}

也可以用正常调用函数的方式:函数名(参数)

int main()
{
	Date d1(2024, 1, 24);
	Date d2(2024, 1, 24);
	Date d3(2023, 12, 25);

	operator==(d1, d2);//true
	operator==(d1, d3);//false
	return 0;

刚刚运算符重载被放在了全局中,此时只能访问Date中的公有成员变量,但是其也可以放在类中:

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

	bool operator==(const Date& d2)
	{
		return _year == d2._year
		&& _month == d2._month
			&& _day == d2._day;
	}

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

此时这个运算符重载就可以访问到Date中的私有变量了。

由于成员函数会自带一个this指针作为第一个参数,所以当运算符重载放在类内部时,只需要写右操作数,左操作数默认为this;如果是单目操作符,则无需参数,默认为this作为操作数。

而在操作符重载函数的内部,由于this指针无需显式表达,所以对于第一个操作数,其成员变量可以直接访问,无需 .  点操作符。

使用操作符重载要注意:

  1. 不能通过操作符重载创建新的操作符。
  2. 操作符重载必须有一个类类型的参数。
  3. 内置类型的运算符,不能被重载。比如将int+int重载,这是不允许的。
  4. .* :: sizeof:?. 这五个操作符不允许重载。

5.2、自增自减运算符重载

自增自减运算符的重载比较特别,由于++aa++的操作符都是++。我们无法在operator后很好的区别前置和后置自增。为此C++特别规定,对于++--操作符,可以额外传入一个int类型的参数,如果有int类型参数,那么就是后置的,如果没有就是前置的。

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

	Date& operator++()
	{
		//代码
	}

	Date& operator++(int)
	{
		//代码
	}

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

类中Date& operator++()就是前置++的重载,而Date& operator++(int)就是后置++的重载。


5.3、赋值重载

赋值重载是一个比较特殊的重载,它属于操作符重载,但是属于六大默认函数之一,也就是说对于所有的类,如果不定义赋值重载的函数,编译器会自动为我们定义一个赋值重载。

其内部规则和拷贝构造几乎一致,也就是说,对于编译器生成的赋值重载,其会拷贝内置类型,对于自定义类型,则会调用对应的赋值重载。

赋值重载的调用规则,和操作符重载的调用规则是一致的:

Date d1(2024, 1, 24);
Date d2(2023, 12, 25);

d1 = d2;
operator=(d1, d2);

拷贝构造的调用方式:

Date d1(2024, 1, 24);
Date d2(d1);
Date d3 = d1;

对比两者,有一个地方容易搞混:d1 = d2;和Date d3 = d1;。

  • d1 = d2;是在后续赋值,调用的是赋值重构。
  • Date d3 = d1;则是在定义新变量,此时调用的是拷贝构造。

也就是说 这个操作符,在定义时调用拷贝构造,在赋值时调用赋值重构。

5.4、取地址重载

对于取地址重载和const取地址重载,就是对&操作符的重载,使用这个操作符可以取出对象的地址。

它是六大默认成员函数之一,也就是默认生成的函数。而这个重构在默认生成时就已经可以完成取地址的操作了,所以大部分情况下我们不会对其重构,用编译器生成的即可

Date* operator&()
{
	return this;
}


5.5、const取地址重载

const取地址重载,是对取地址重载的const版本,其用于返回某些const对象的地址。其也是六大默认成员函数之一,编译器自动生成的就已经够用了。

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