类与对象 中
1、类的6个默认成员函数
在C++中,空类其实并不是什么都没有,它里面会自动生成6个默认成员函数。
2、构造函数
2.1 概念
我们在定义一个类之后,用类创建一个对象,需要将对象进行初始化。比如下面的日期类:
class Date
{
public:
void Display()
{
cout << "this -> d1" << this << endl;
cout << _year << "-" << _month << "-" << _day << endl;
}
void SetDate(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
//C++命名风格,最好加上_
int _year;
int _month;
int _day;
};
int main()
{
Date d1, d2;
d1.SetDate(2021, 5, 27);
d2.SetDate(2021, 5, 26);
d1.Display();
d2.Display();
cout << "d1:" << &d1 << endl;
cout << "d2:" << &d2 << endl;
return 0;
}
对于对象d1和d2,我们需要通过SetDate公有函数将其初始化,但是这样每次创建对象都需要进行设置,会很麻烦;并且有时候我们会忘记初始化,带来不必要的麻烦。因此在定义类可以通过构造函数来对对象初始化,为什么说构造函数会省去很多麻烦呢,下面进行详细的解释。
构造函数是一个特殊的成员函数,它的名字与类名同名,创建类类型的对象时由编译器自己调用,保证每个数据成员有一个初值,并且在对象的生命周期中只调用一次。
2.2 特性
这里要明确一点,构造函数并不是开空间创建对象,而是对对象初始化。
它具有如下的特征:
(1) 构造函数的函数名与类名相同。
(2) 构造函数无返回值(注意:不是void,void指的是有返回值,只是返回值为空)。
(3) 对象实例化时编译器自动调用对应的构造函数,这一点保证了每个对象都会被初始化,这也是构造函数存在的意义。
(4) 构造函数可以重载,也就是说可以有不同的初始化方式。
class Date
{
public:
//1、无参构造函数
Date()
{}
//2、带参构造函数
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
//C++命名风格,最好加上_
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
Date d2(2021, 5, 27);
//这样写是错误的
//通过无参构造函数创建对象时,对象后面不同跟括号
//否则就成了函数声明,即声明了d3函数,该函数无参,返回日期类对象
Date d3();
return 0;
}
d1调用的是无参的构造函数,这个函数中什么也没有,也就是对d1不做任何改变,里面存储的还是随机值。
注意:调用无参的构造函数,后面不要加括号,否则就成了函数声明。
d2调用的是带参的构造函数,创建对象之后初始化为对应的实参。
(5)如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数。用户显式定义后,编译器将不再生成。
class Date
{
private:
//C++命名风格,最好加上_
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
return 0;
}
没有定义构造函数,对象也能成功创建,只不过里面还是随机数。此处调用的是编译器生成的默认构造函数。
那么有人会有疑问了,这样看起来好像默认的构造函数什么都没有做,依旧是随机值。它的具体原因在特性7中会详细介绍。
(6) 无参的构造函数、全缺省的构造函数和编译器默认生成的构造函数都可以称为默认构造函数,但是这三个只能存在一个,因为这三个在创建对象的时候都可以不写参数,如果都存在,编译器会不明确你调用的是哪一个。
在这三个之中,全缺省的构造函数是最好的,可以适用于各种场景。不想传参数就用默认值,想传参数就传对应的参数。
class Date
{
public:
//全缺省构造函数
Date(int year = 0, int month = 0, int day = 0)
{
_year = year;
_month = month;
_day = day;
}
private:
//C++命名风格,最好加上_
int _year;
int _month;
int _day;
};
int main()
{
//不想传参数就用默认值
Date d1;
//也可以传想要初始化的参数
Date(2021, 5, 27);
return 0;
}
(7)
这个特性就是刚才特性5提出的问题的解答。
C++把类型分为内置类型(基本类型)和自定义类型。内置类型就是我们在类中通过语法定义的变量,比如用int\char\double…定义的变量;自定义类型就是我们使用class\struct定义的类型。
class Time
{
public:
Time()
{
//这一行代码目的是验证对于自定义类型有没有调用这个构造函数
cout << "Time()" << endl;
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
private:
//C++命名风格,最好加上_
//内置类型
int _year;
int _month;
int _day;
//自定义类型
Time t;
};
通过最后的结果我们可以看到,最后打印出了Time()函数,说明编译器的默认构造函数对于自定义变量t是进行了初始化的。
那么在这里可以做一个总结:
如果类中有内置类型的变量,就需要自己写构造函数;如果只有自定义类型,就不用自己写(自定义的类中要有构造函数)。
(8) 成员变量的命名风格
在C++中,对于变量的命名要带有自己的风格,最常用的是变量名前加上" _ "。
我们来看一段代码:
class Date
{
public:
Date(int year, int month, int day)
{
year = year;
month = month;
day = day;
}
private:
int year;
int month;
int day;
};
按照以前的方式定义变量,那么在构造函数中可以看到,等号左右的变量名是一样的,那么到底是成员变量还是形参,就会引发歧义;而且编译器对于这种情况都是采用的就近原则,也就是说构造函数中,其实是把形参赋值给了形参。
因此我们一般建议在变量名前加上" _ ",如下:
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
这样就很容易区分哪个是成员变量,哪个是形参。
3、析构函数
3.1 概念
析构函数用于类开辟的对象销毁的时候自动调用,去销毁对象内部通过动态内存开辟的空间,防止内存泄漏。析构函数不能销毁局部对象,只是去销毁动态开辟的内存,避免程序员忘记释放内存所带来的不必要的麻烦。
比如说我们之前学的栈,如果写成类的话,就需要析构函数;而上面所讲的日期类并不需要,因为日期类中并没有内存的开辟。
3.2 特性
(1) 析构函数名是 ~ + 类名,如~Stack。
(2) 析构函数没有参数,也没有返回值。
(3) 一个类有且只能由一个析构函数,如果有多个就会造成多次释放的错误。如果没有显式定义析构函数,系统会自动生成一个默认的析构函数。
(4) 用类定义的对象生命周期结束后,C++编译器会自动调用析构函数。
从上图可以看到,用Stack类创建的对象s在结束生命周期后会自动调用析构函数,释放开辟的动态内存,可以防止我们忘记释放造成的后果。
(5) 编译器生成的默认析构函数,对于内置类型是不做什么事的,对自定义类型会调用它自己的析构函数。
class String
{
public:
String(const char* str = "hello world")
{
_str = (char*)malloc(strlen(str) + 1);
strcpy(_str, str);
}
~String()
{
cout << "~String()" << endl;
free(_str);
}
private:
char* _str;
};
class Stack
{
public:
Stack(int capacity = 4)
{
if (capacity == 0)
{
_a = nullptr;
_size = _capacity = 0;
}
else
{
_a = (int*)malloc(sizeof(int)*capacity);
if (_a == nullptr)
exit(-1);
_size = 0;
_capacity = capacity;
}
}
private:
//内置类型
int* _a;
int _size;
int _capacity;
//自定义类型
String _name;
};
int main()
{
Stack s;
return 0;
}
这里写了两个类型,一个是String类,另一个是Stack类,在Stack类中定义了一个_name变量,那么编译器的做法是这样的:
从动图的最后可以看到,生命周期结束之后,_name开辟的空间销毁了,但是s对象的空间依然存在。这也就得到了一个结论,编译器对自定义类型会调用它自己的析构函数(这个自定义类型必须有析构函数),对于内置类型是不做什么事的。
4、拷贝构造函数
4.1 概念
拷贝构造函数是指只有单个形参,该形参是对本类对象的引用(一般用const修饰),在用已存在的类类型对象创建新的对象时由编译器调用。
4.2 特性
(1) 拷贝构造函数是构造函数的一个重载形式,也就是函数名与构造函数相同,参数不同。
(2) 拷贝构造函数的参数只有一个,并且该参数必须使用引用传参。
下面解释一下为什么要用引用传参:
以日期类为例:
class Date
{
public:
Date(int year = 1900, 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;
};
如果传值调用的话,形参是对实参的一份临时拷贝,那么也就是说在传值的过程中会创建一个临时变量。
那么传值的过程中就会不断形成临时变量,而临时变量又会调用拷贝构造,引发无穷递归调用。
(3) 如果没有显式定义,系统会生成默认的拷贝构造函数
依然是用日期类举例:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
在类中没有定义拷贝函数,但是依然可以成功完成拷贝。默认的拷贝构造函数对象是按照内存存储中字节序的方式完成拷贝的,我们称这种拷贝为浅拷贝(或者值拷贝)。
但是默认的拷贝构造函数能够完成所有的拷贝吗?来看下面这个String类的默认拷贝构造:
class String
{
public:
String(const char* str = "jack")
{
_str = (char*)malloc(strlen(str) + 1);
strcpy(_str, str);
}
~String()
{
cout << "~String()" << endl;
free(_str);
}
private:
char* _str;
};
可以看到,在进行拷贝构造的时候程序会崩溃,这与内存开辟有关,等讲到深拷贝的时候会详细解释。
5、赋值运算符重载
5.1 运算符重载
运算符重载能够增加代码的可读性,由于类创建的对象之间不能直接使用运算符来实现一些操作,那么为了使得代码更加易于读懂,C++引入了运算符重载。
函数名:关键字operator + 需要重载的运算符符号
函数原型:返回值类型 operator操作符(参数列表)
运算符的重载可以在全局实现,但是这样写就需要将成员变量定义为公有的,这样在类的外面才能访问,那么就会打破类的封装,具有一定的不安全性。
//但是这样写就需要将成员变量定义为公有的,这样在类的外面才能访问
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
那么我们就可以在类中实现运算符的重载,反正重载的运算符也是针对这个类实现的。这样写就不需要将成员变量改为公有,保证了安全性和封装性。
(在类中实现运算符重载看起来只有一个参数,实际上还是两个,因为第一个参数是隐藏的this指针)
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
对于运算符重载,有以下几点需要注意:
1、运算符的重载必须是常见的操作符,不能够创建新的操作符,比如:operator@
2、重载操作符必须有一个类类型或者枚举类型的操作数,否则重载运算符就没有了意义
3、内置类型的变量本身就可以直接使用操作符,其含义不能通过重载改变
4、作为类成员的重载函数,第一个参数有一个隐藏的this指针
5、.* 、 :: 、 sizeof 、 ?: 、. 这5个运算符不能重载
5.2 赋值运算符重载
赋值运算符重载和运算符重载是类似的,只不过赋值运算符重载是属于类的默认成员函数。
Date& operator=(const Date& d)
{
if(this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
}
主要有四点:
1、参数类型,在类中定义第一个参数是隐藏的this指针,而第二个参数一般不修改,可以用const修饰。
2、返回值,赋值运算符的返回值一般是类的引用类型,主要是为了能够连续赋值,如果是空返回值的话,只能进行单次的赋值。
3、检测是否给自己赋值,如果是自己给自己赋值,就不需要再一个一个进行,直接返回即可。
4、返回 *this,赋值运算符重载中,赋值之后改变的就是this指针指向的对象,返回this指针就是把结果返回。
在类中如果没有显式定义赋值重载函数,编译器会默认生成一个,与拷贝构造相同,也是完成对象按照字节序的值拷贝。
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
可以看到默认的赋值重载函数能够完成d2对d1的赋值。
但是默认的赋值重载函数并不能完成所有的赋值工作,还是拿String类举例:
class String
{
public:
String(const char* str = "jack")
{
_str = (char*)malloc(strlen(str) + 1);
strcpy(_str, str);
}
~String()
{
cout << "~String()" << endl;
free(_str);
}
private:
char* _str;
};
会出现与拷贝构造同样的错误,这些都放在后面的深拷贝详细讲解。
7、const成员
7.1 const修饰的成员函数
将const修饰的类成员函数称为const成员函数,这里的const实际修饰的是该成员函数隐藏的his指针,表明该成员函数不能对类的任何成员进行修改。
那么const修饰的成员函数有什么用呢?看下面的代码:
以刚才写的日期类运算符重载函数为例
bool operator==(const Date& d2)
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
这里的参数d2是用const修饰的,说明d2是不可修改的,那么如果在写的时候写成d2._year == _year这样的话就会报错,表明d2是不可修改的。
但是其实本意上我们也不想让this指针所指向的对象被修改,也就是说如果不小心写成_year = d2._year,这样子在当前的函数中是不会发现错误的,但是对于结果是不正确的,而且也缺乏安全性。此时我们就可以用const:
bool operator==(const Date& d2) const
{
return _year == d2._year
&& _month == d2._month
&& _day == d2._day;
}
如图,加上const之后,我们如果不小心将this指针指向的对象修改了,就会出现错误,这样就避免了很多不必要的麻烦。