类的六个默认成员函数
如果一个类中什么成员都没有,那么它就是一个空类。
但是空类中真的什么都没有吗?并不是的,类在什么都不写的时候,会生成六个默认成员函数
。
① [ - 构造函数 - ]
构造函数是一个六大默认成员函数之一,其用于初始化对象。
构造函数有以下基本特征:
- 函数名与类名相同
- 函数没有返回值
构造函数不需要我们调用,在创建对象时,编译器会自动调用这个函数
构造函数重载与缺省
构造函数是可以进行重载和参数缺省的,可以根据我们输入的不同值进行初始化。
重载:
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 d2(2024);,其变量名为d2,小括号内写入了一个值2024,也就是只有一个值,此时调用第一个构造函数Date(int year),日期初始化为2024/1/1。
对于Date d3(2024, 2);,其变量名为d3,小括号内写入了(2024, 2),也就是传入了两个参数,此时调用第二个构造函数Date(int year, int month),日期初始化为2024/2/1。
对于Date d4(2024, 2, 20);,其变量名为d4,小括号内写入了(2024, 2, 20),也就是传入了三个参数,此时调用第三个构造函数Date(int year, int month, int day),日期初始化为2024/2/20。
最后对于Date d1;这就是一个基本的初始化对象的方式,但是要注意不能写成Date d1();。
当对象不需要传值进行初始化,不能()内为空,而是将括号也省略。
缺省:
我们的构造函数还可以缺省,我们刚刚的代码很明显是冗余的,完全可以用缺省参数来代替。
以上四个重构可以化作一个缺省的函数:
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)
,我们都可以不传值就可以调用。这种无需传值就被调用的构造函数,叫做默认构造函数。而默认构造函数只有三种,一种是无参数的构造函数,一种是全缺省参数的构造函数,最后一种就是编译器自动生成的构造函数。
一个类中,必须存在且只能存在一个默认构造函数。如果一个类中没有默认构造函数,那么编译器就会报错。而上述三种默认构造函数中,也只能存在一种。(写了全缺省就不要写无参数的构造函数了)
构造函数是类的六大默认成员函数
之一,对于这六大默认成员函数
,如果用户没有显式定义,那么编译器会自动生成,而当用户一旦定义了,则编译器不再自动生成,改用用户定义的函数。
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日期类定义了一个构造函数,当这个函数被调用,会输出”我被调用了“。
我们又定义了一个类A,A内有一个内置类型的abc,以及一个自定义类型Date的d。
当我们再main函数中创建了A a1;变量,此时会调用A的构造函数,而此时A的构造函数我们没有写出来,那么就是编译器自动生成的构造函数,此时这个A的构造函数会调用Date的构造函数,最后输出”我被调用了“。
成员变量默认值
成员变量默认值是指:
内置类型在类中声明时,可以设置一个默认值,如果后续构造函数没有给这个内置类型赋值,那么这个内置类型就得到默认值。
class Date
{
private:
int _year = 1970;
int _month = 1;
int _day = 1;
};
这样一来,默认值就设置好了,只要后续构造函数不对这些成员赋值,那么这些成员变量就会得到默认值。
编译器自动生成的构造函数
不就是一个不会对内置类型做处理的构造函数吗?所以这个特性搭配编译器自动生成的构造函数
一起使用,就可以在不写构造函数的情况下,也完成内置类型的初始化了。
类型转换
构造函数不仅可以构造与初始化对象,还具有类型转换的作用。
在不同的内置类型进行赋值时,C++底层会进行一个隐式类型转化:
class A
{
public:
A()
{
_a1 = 0;
_b1 = 1.0;
}
A(int a)
{
_a1 = a;
}
A(double b)
{
_b1 = b;
}
private:
int _a1;
double _b1;
};
int main()
{
A abc;
abc = 3;
abc = 5.0;
return 0;
}
以上代码中,我们先定义了一个A类的对象abc,随后abc = 3就是一个转化过程,本来abc作为一个A类对象,int类型的1是无法转化为这个A类型的。但是由于我们有一个只需一个int参数的构造函数,所以也就有了把数字1转化为A类型的相应规则,此时abc = 3就会调用函数A(int a)从而完成转化。
而对于abc = 5.0;
,则是调用A(double b)
这个构造函数,将double类型的5转化为A类型的过程。
那么如果是有多个参数的构造函数也可以进行类型转化吗?
在C++98标准是不允许的,但是C++11又新增了特性,允许多个参数的构造函数进行类型转化。
int main()
{
A abc;
int x = 1;
double y = 3.0;
abc = {x, y};
return 0;
}
对于多个参数的构造,只需要把参数用花括号括起来,类型一一对应即可。
explicit
explicit
关键字是一种禁止类型转化的关键字。如果你不希望你写出的构造函数允许类型转化,可以把这个关键字放在函数名前,那么此时这个构造函数的类型转化就被禁止了。
class A
{
public:
A()
{
_a1 = 0;
_b1 = 1.0;
}
A(int a)
{
_a1 = a;
}
explicit A(double b)
{
_b1 = b;
}
private:
int _a1;
double _b1;
};
上述代码中,A(double b)
这个构造函数被加上了explicit
,那么double
类型的数据就无法转化为A类型了。但是A(int a)
这个构造函数前面没有,int
类型的数据的转化是不受影响的。
初始化列表
C++的初始化列表是一种语法特性,用于在对象的构造函数中初始化成员变量。初始化列表使用冒号(:)和逗号(,
)来分隔成员变量和初始值。
在C++中,对象的成员变量可以在构造函数的函数体中进行初始化或者利用参数默认值。然而,初始化列表提供了一种更加简洁和高效的方式来初始化成员变量,尤其是对于复杂的对象或者const常量成员变量。
使用初始化列表的语法如下:
Classname(parameters)
: member1(value)
, member2(value)
, ...
{
// 构造函数函数体
}
下面是一个示例:
class Example {
public:
Example(int n, double d)
: num1(n)
, num2(d)
{
//
}
private:
int num1;
double num2;
};
构造函数使用初始化列表来对这两个成员变量进行初始化,而不是在构造函数的函数体中进行赋值。
我们区分一下成员变量的默认值
和初始化列表
的区别。
首先:成员变量的默认值
只能指定数据,它是不可变的。初始化列表
则可以在括号中写入表达式,变量等等,这给了第一次赋值极强的灵活性。
其次:
成员变量的默认值并没有完成真正的赋值,它是处于对变量的声明阶段,只是告诉编译器我将要使用一个名字为xxx的变量,还没有正式开辟空间。
初始化列表则是对变量的定义阶段,也就是完成了变量的第一次赋值。
也正是这个区别,导致对于const常量以及引用类型的成员变量,只能用初始化列表来进行初始化。
class Example {
public:
Example(int& x, int y)
: ref(x)
, _b(y)
{
//
}
private:
int& ref; //引用
const int _b; //常量
};
这个类有两个成员,一个ref
是引用类型int&
,另一个_b
是常量类型const int
。这两个类型的共同点就是:定义时必须初始化,后续无法改变内容。
既然要在定义时就初始化,此时就只有
最后再说一个小小的注意点:初始化列表的执行顺序是类中从上到下的顺序,而非初始化列表中从上到下的顺序。
② [ - 析构函数 - ]
C++的析构函数是一个特殊的成员函数,用于在对象被销毁时进行内部的清理工作。它的名称是在类的名称前面加上一个波浪符号~。
当一个对象的生命周期结束时,即对象不再被使用时,析构函数会自动调用。通过析构函数,可以释放对象使用的资源,如动态分配的内存、打开的文件等。析构函数的工作是清理对象所拥有的资源,以防止内存泄漏或资源泄漏。
析构函数的定义在类的声明中。它不需要任何参数,也没有返回值。通常,析构函数的定义放在类的实现文件中。
与构造函数不同的是:一个函数只允许存在一个析构函数,不允许重载。
而与构造函数相同的是:当我们不写析构函数,编译器会默认生成一个析构函数,这也是六大默认成员函数的特性。
对于默认的析构函数:如果成员变量中存在其它类的对象,调用其它类的析构函数。
这个过程是在释放其它类的对象调用的资源,而非这个对象。对象是创建在栈帧中的,会随着程序结束一起销毁,但是不能销毁的是动态内存之类的空间。
③ [ - 拷贝构造 - ]
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
作为参数传入函数中,此时s
就是s1
的一份拷贝。
但是,如果按照直接拷贝,那么形参s
的成员_array
会与实例s1
的成员_array
值一样,此时形参和实参就指向了同一块空间了。
我们既然没有传引用传参,当然是希望形参改变不要影响实参,但是此时两个对象指向同一块空间,就会互相影响了。甚至由于我们存在一个析构函数~Stack,析构函数会free掉_array指向的空间,而s和s1离开生命周期时,都会调用一次析构函数,此时_array指向的空间会被释放两次,这是不允许的。
可见,如果我们让编译器直接进行拷贝,是有可能会发生错误的。所以类中设立了一个拷贝函数,每当对象需要进行拷贝操作时,都会调用这个拷贝函数。
也就是说,我们每次把对象作为函数形参传递时,都会调用拷贝函数来进行拷贝(这句话很重要)。
拷贝函数是构造函数的一种重载,所以称为拷贝构造函数,简称拷贝构造。既然拷贝构造是构造函数的重载,那么其函数名也就是类名,与构造函数一致。
ClassName(const ClassName& obj)
{
// 拷贝构造函数的实现逻辑
}
拷贝构造有要求:必须只有一个参数,且参数类型必须是ClassName&,因为
我们每次把对象作为函数形参传递时,都会调用拷贝函数来进行拷贝(这句话很重要)。如果我们此处的参数类型是ClassName
,那么就会发生无限递归。
我们调用了func
函数,需要传入一个类的拷贝,此时调用这个类的拷贝构造,由于拷贝构造需要传入类的拷贝,于是又要调用第二个拷贝构造,而第二个拷贝构造又要调用这个类的拷贝,此时调用第三个拷贝构造…
以此类推,永远递归下去。程序必然会崩溃。 所以我们的拷贝构造必须传递引用来调用,防止无限递归。而一般而言,传入的被拷贝对象,我们是不希望其被修改的,所以还会加一个
const
修饰。最后我们的第一个参数就写为了const ClassName&
拷贝构造是六大默认函数之一,所以如果没有显式地定义拷贝构造函数,C++编译器会默认生成一个默认的拷贝构造。默认的拷贝构造对于内置类型,会直接拷贝,对于自定义类型,则会调用相应的拷贝构造。
运算符重载
基本运算符重载
运算符重载是指对已有的运算符进行重新定义,使其可以用于自定义的数据类型或者实现不同的操作逻辑。通过运算符重载,可以为用户自定义的类型创建与内置类型相似的运算操作。
也就是说我们可以通过运算符重载,来设置某些情况下的运算符的功能。
运算符重载的一般语法为:
返回值类型 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
中的公有成员变量,但是其也可以放在类中:
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
指针无需显式表达,所以对于第一个操作数,其成员变量可以直接访问,无需.
点操作符。
对于操作符重载有以下注意点:
- 不能通过操作符重载创建新的操作符。
- 操作符重载必须有一个类类型的参数。
- 内置类型的运算符,不能被重载。比如将
int+int
重载,这是不允许的。 .*
,::
,sizeof
,:?
,.
这五个操作符不允许重载。(单个*可以重载)
自增自减运算符重载
自增自减运算符的重载比较特别,由于++a
和a++
的操作符都是++。我们无法在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 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;
则是在定义新变量,此时调用的是拷贝构造。
也就是说=
这个操作符,在定义时调用拷贝构造,在赋值时调用赋值重构。
⑤ [ - 取地址重载 - ]
Date* operator&()
{
return this;
}
⑥ [ - const取地址重载 - ]
const Date* operator&() const
{
return this;
}