默认成员函数

一、构造函数

构造函数是特殊的成员函数,需要注意的是,构造函数虽然名称叫构造,但是构造函数的主要任务并不是开空间创建对象,而是初始化对象

特征:

1. 函数名与类名相同。
2. 无返回值。(不是void,就是不写任何类型) 
3. 对象实例化时编译器自动调用对应的构造函数。

4. 构造函数可以重载。 

5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成。
6. C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...;自定义类型就是我们使用class/struct/union等自己定义的类型。

编译器生成默认的构造函数会对自定类型成员调用的它的默认成员函数。内置类型不做处理。

例如:

class MinStack {
public:
    MinStack() {

    }
    
    void push(int val) {

    }
    
    void pop() {

    }
    
    int top() {

    }
    
    int getMin() {

    }
private:
    stack dataStack;
    stack minStack;
};

当我们定义一个类时,如果没有显式声明构造函数,编译器会自动生成默认的构造函数。对于成员变量,编译器会调用它们各自的默认构造函数来进行初始化。

在这个例子中,我们使用了 std::stack 类作为 MinStack 的成员变量,因此编译器会自动调用 std::stack 的默认构造函数来初始化 dataStackminStack

需要注意的是,对于标准库提供的容器类(如 std::stack),它们都有自己的默认构造函数,用于初始化内部数据结构。所以在这里,我们不需要显式地定义构造函数来初始化 std::stack,而是依赖于编译器提供的默认构造函数。

注意

C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。

7. 无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认构造函数,并且默认构造函数只能有一个。

默认成员函数是指:

默认成员函数_第1张图片

  • 初始化列表

1)为什么要有初始化列表

class Date
{
public:
	// ……
private:
    // 这里只是对成员变量的声明
	int _year;
	int _month;
	int _day;
};
int main()
{
    // 类对象整体定义
	Date d(2024, 1, 15);
	return 0;
}

这里的成员变量的声明就相当于一个房子的图纸,类对象整体定义相当于开辟一个房子大小的空间,但是明确这个房子的卧室、厨房、客厅设计在哪里是有必要的。初始化列表就是明确成员变量定义的位置。

2)为什么要使用初始化列表进行初始化

在C++11之前,C++中的对象初始化有两种方式:默认初始化和值初始化。

其中,默认初始化是指对象在定义时没有被初始化,其值是未定义的;

而值初始化是指对象在定义时被初始化为0或空指针等初始值。值初始化时,也有两种方法:一是在函数体内部进行赋值初始化;二是利用初始化列表进行初始化。

// 函数体内部进行赋值初始化
Date(int year, int month, int day)
{
	_year = year;
	_month = month;
	_day = day;
}

// 使用初始化列表进行初始化
Date(int year, int month, int day)
	:_year(year), _month(month), _day(day)
{}
  • 有些变量必须在初始化列表中进行初始化

在构造函数中,对于常量成员变量引用类型成员变量自定义类型成员(且该类没有默认构造函数时),它们必须通过初始化列表来进行初始化。

因为这些成员变量只能在定义时进行赋值,不能在构造函数中再次赋值(类中写的成员变量只是声明并不是定义,成员变量在初始化列表中定义

class A
{
public:
	A(int a) // 不是默认构造函数
		:_a(a)
	{}
private:
	int _a;
};

class B 
{
public: 
	B(int a, int ref) 
		:_aobj(a), _ref(ref), _n(10) 
	{} 
private:
	A _aobj;      // 没有默认构造函数
	int& _ref;    // 引用
	const int _n; // const 
};
  • 想要自己设置自定义对象的成员变量的值

3)特征

1、尽量使用初始化列表初始化,因为不管你是否在初始化列表显式地处理成员变量,成员变量也会被处理。如果没有显式的写:

对于自定义类型成员变量,一定会调用它的默认构造函数进行初始化这个自定义对象;对于内置类型默认给随机值声明时的缺省值

例如:

class Stack
{
public:
	Stack(int n = 0)
	{
		// ……
	}
};

class MyQueue
{
public:
	// ……
private:
	Stack s1;
	Stack s2;
};

对于这个MyQueue类,我们是不需要写它的构造函数的。因为,如果我们如果不写这个函数时,编译器会默认生成一个缺省的构造函数,并且会在这个自己生成的初始化列表中调用Stack的默认构造函数对s1和s2对象进行初始化。

如果我们写了MyQueue的构造函数,但是没有在函数体内部进行对s1和s2初始化,这样会不会出现问题?

答案是不会有问题。

因为,如果内部没有写任何内容,那么所有的成员变量都会在初始化列表中进行初始化(调用栈的构造函数)。

2、成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后
次序无关。

3、优点:

  1. 初始化列表可以调用基类的构造函数,从而确保派生类对象中包含了正确的基类对象。

  2. 使用初始化列表可以减少代码的冗余和提高执行效率,因为它可以避免在构造函数体中进行多余的赋值操作。

  3. 初始化列表使得代码更加易读和维护,因为在同一个位置就可以看到所有的成员变量的初始化方式,而不需要在构造函数体中进行查找。

  • 构造函数能不能只要初始化列表,不要函数体初始化

不能,因为有些初始化或者检查的工作,初始化列表也不能全部搞定。例如:

class Stack
{
public:
	Stack(int n = 2)
		:_a((int*)malloc(sizeof(int)* n))
		, _top(0)
		, _capacity(n)
	{
		// 检查工作,初始化列表是做不了的
		if (_a = nullptr)
		{
			perror("malloc fial");
			exit(-1);
		}
		// 其他工作
		memset(_a, 0, sizeof(int) * n);
	}
};

二、析构函数

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

析构函数是特殊的成员函数,其特征如下: 
1. 析构函数名是在类名前加上字符 ~。 
2. 无参数无返回值类型。 
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。

注意:析构函数不能重载
4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。

5. 编译器生成的默认析构函数,虽然不能释放开辟的空间,但是其价值在于对自定类型成员能够自动调用它的析构函数(与构造函数功能相似,它对内置类型不做处理)。

三、拷贝构造函数

引入:

默认成员函数_第2张图片

对于上述函数传值过程中,将d1值拷贝给d时,不会出现什么问题;但是当一个自定义类型含有指针的成员变量时,再使用值拷贝,会导致st1和st的_a都指向同一块空间,这样会出现问题。(在C语言中函数传参是值拷贝)

所以C++规定:对于自定义类型对象拷贝的时候,需要调用一个函数,这个函数就是拷贝构造函数以便我们自己实现深拷贝,解决浅拷贝问题。

1、定义

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

  • 为什么一定要使用对象的引用作为拷贝构造函数的参数?
#include 

class MyClass {
public:
    MyClass() {
        std::cout << "Default constructor called." << std::endl;
    }

    MyClass(const MyClass obj) {  // 错误的拷贝构造函数
        std::cout << "Copy constructor called." << std::endl;
    }
};

int main() {
    MyClass obj1;
    MyClass obj2 = obj1;  // 调用拷贝构造函数
    return 0;
}

在上面的示例中,我们定义了一个名为 MyClass 的类,它有一个默认构造函数和一个错误的拷贝构造函数。在 main() 函数中,我们创建了两个对象 obj1obj2,并尝试将 obj1 复制给 obj2

当执行 MyClass obj2 = obj1 这一行代码时,编译器会尝试调用 MyClass 类的拷贝构造函数来初始化 obj2。但由于我们定义的拷贝构造函数使用了值传递(即传递参数时直接复制对象),它会将 obj1 的副本作为参数传递给拷贝构造函数。

然而,在进行拷贝构造时,由于使用了值传递,会要求调用拷贝构造函数来复制 obj1 的副本,而这个复制过程又需要调用拷贝构造函数,如此无限递归下去,导致程序陷入死循环,并最终崩溃。

为了避免这种情况,我们应该使用引用传递,即将拷贝构造函数的参数改为 const MyClass& obj 这样的形式,以避免无限递归。

总结一句就是:当调用一个拷贝构造时,需要先将参数传过去,如果是传值传参,因为形参是实参的一个拷贝,在传参的时候需要调用一个拷贝构造进行拷贝,这样当我们调用一个拷贝构造函数过程中(还没有调用成功),还要再调用一个拷贝构造函数进行拷贝参数,这样就陷入死循环了。

  • 为什么要使用const进行修饰?

在拷贝构造函数中,使用const修饰有两个好处:

1. 防止用于拷贝的变量被修改;

2. 如果用于拷贝的变量被const修饰,而拷贝构造函数中没有加const,这样拷贝会使拷贝后变量的权限被放大(可能会出现类似的报错信息:由const Date类型转化为 Date);加过const后,无论被拷贝变量有没有加const都是可以的,因为变量的权限是可以被缩小的。

2、浅拷贝问题

需要注意的是,如果类中没有定义拷贝构造函数,编译器会自动生成一个默认的拷贝构造函数,该函数执行的是浅拷贝,即简单地将源对象的成员变量值复制到目标对象中。如果类中有指针等动态资源,需要自定义拷贝构造函数来实现深拷贝,确保资源的正确管理。

补充:在C语言中,虽然语法可以实现传递结构体等自定义类型,但是这种传参也是浅拷贝,也会出现一定的问题,所以我们在C语言阶段,都是传结构体指针进行操作。

  • 浅拷贝的方式拷贝自定义类型变量

如果自定义类型中包含指针变量,这样进行浅拷贝会导致多个对象共享同一个指针变量,进而可能在释放内存时出现重复释放。

默认成员函数_第3张图片

st2会先释放(栈帧结构的特性),st2使用析构函数将这块空间  this(st2)->_a  释放后,st1还会使用析构函数来释放这块空间  this(st1)->_a  ,从而引发程序崩溃或者不可预期的错误。

默认成员函数_第4张图片

  • 深拷贝

要想避免上述拷贝的对象和被拷贝的对象的指针指向同一块空间,就需要在拷贝构造函数中,自己为拷贝的对象开辟一块与被拷贝对象同样大小的空间,并将空间内的值拷贝到新开辟的空间中。

stack(const stack& st)
{
	_arr = (int*)malloc(sizeof(int) * st._capacity);
	if (_arr == nullptr)
	{
		perror("malloc failed!");
		exit(-1);
	}
	memcpy(_a, st._a, sizeof(int) * st._top);
	_capacity = st._capacity;
	_top = st.top;
}

3、特征

1. 拷贝构造函数是构造函数的一个重载形式。

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

3. 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

注意:在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的
4. 类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。

4、被调用场景

在以下几种场景中,拷贝构造函数都会被调用:

  1. 对象通过值传递给函数时,会触发拷贝构造函数的调用。
  2. 通过值返回对象时,会触发拷贝构造函数的调用。
  3. 一个对象用另一个对象进行初始化时,会触发拷贝构造函数的调用。
  4. 在创建对象数组时,会调用拷贝构造函数来初始化数组中的每个元素。
  5. 当一个对象被另一个对象赋值时,如果赋值操作符重载函数使用了拷贝构造函数,那么拷贝构造函数会被调用。

补充:

对于一个传值返回的函数来说,在函数完成后需要调用拷贝构造函数,传递返回值,这样有一定的消耗;而传引用返回的函数就不需要调用拷贝构造函数。

四、赋值运算符重载

1)赋值运算符重载格式

  1. 参数类型:const T&,传递引用可以提高传参效率
  2. 返回值类型:T&,返回引用可以提高返回的效率(有返回值目的是为了支持连续赋值
  3. 检测是否自己给自己赋值
  4. 返回*this:要复合连续赋值的含义
Date& operator=(const Date& d)
{
	if (this != &d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	return *this;
}

2) 赋值运算符只能重载成类的成员函数不能重载成全局函数

原因:赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。

3)浅拷贝 

用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符重载完成赋值。

默认成员函数_第5张图片

注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。

五、取地址及const取地址操作符重载

这两个默认成员函数一般不用重新定义   ,编译器默认会生成。

class Date
{
public:
	Date* operator&()
	{
		return this;
	}
	const Date* operator&()const
	{
		return this;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日
};

六、辨析:

1)

// 1、
Date(const Date& d)
{
	cout << "Date(Date&d)" << endl;
	_year = d._year;
	month = d.month;
	_day = d._day;
}
// 2、
Date(const Date* d)
{
	_year = d->_year;
	month d->month;
	_day d->_day;
}

第一个函数是拷贝构造函数;

第二个是一个构造函数,构造函数的参数是一个类的指针。

2)赋值重载和拷贝构造

赋值重载用于两个操作数是已经被实例化好的对象;

拷贝构造函数是在创建一个对象时,以已存在的同类型对象作为初始化参数,从而创建一个新对象的构造函数,初始化这个要被创建的对象。

默认成员函数_第6张图片


今天的分享就到这里了,如果,你感觉这篇博客对你有帮助的话,就点个赞吧!感谢感谢……

你可能感兴趣的:(c++,开发语言)