紧接着我们上一部分类和对象的讲解之后,我们再来学习一下类当中的几大特点,以及使用方法。
一:实例化对象赋初值
首先我们需要学习的就是该如何为我们实例化出的对象赋初值。
1.外部赋值
对于对象赋初值我们有很多的形式,我们甚至可以像结构体一样,对于我们类当中的数据进行一个一个赋值,(但是可能会 麻烦很多)所示代码如下:
#include<iostream>
//设置一个学生类,并对其在外部赋初值
class Stu
{
public:
void print()
{
std::cout << name << std::endl;
std::cout << age << std::endl;
}
int age;
char name[20];
};
int main()
{
Stu s1;
s1.age = 12;
strcpy(s1.name, "张三");
s1.print();
return 0;
}
我们需要在主函数当中实例化一个类的对象,之后通过对象使用 . 操作符进行进一步的对指定的属性进行赋初值。可能age看起来很正常,但是对于我们类中的数组,他们会自动退化成为一个指针,所以我们需要使用字符串拷贝函数对其进行赋初值,或者我们一个字节一个字节的手动赋值也是可以的,但是没有太大的必要,在类的外部进行赋初值还有一个弊端那就是:我们的成员变量的权限只能是public的时候才可以对其进行在外部赋值,否则就会报错。我们来检查以下程序执行的效果:
2.使用默认参数进行赋值
但是我们这样在外部一个一个赋值就会显得很麻烦,那么有没有什么更好的方法可以为对象赋值呢?我们就可以使用默认参数为我们的成员变量进行赋值,所示的代码如下:
#include<iostream>
//定义一个学生类。使用默认参数为成员变量进行赋初值
class Stu
{
public:
void print()
{
std::cout << age << std::endl;
std::cout << name << std::endl;
}
private:
int age=12;
char name[20]="张三";
};
int main()
{
Stu s1;
s1.print();
return 0;
}
我们可以在类的内部在声明成员变量的时候就给一个值作为我们成员变量的默认值,只要我们不主动修改那么系统就会自动使用默认的成员变量对其进行初始化。我们先来测试一下程序的运行效果:
3.设置对外接口进行修改(成员函数)
同样的,我们很快就会发现一个弊端,那就是使用默认参数赋值,是不是就是说无法在函数的外部对于我们的成员变量进行修改了呢?毕竟我们的成员变量已经被设置成为了private权限,在外部无法对其进行访问了。事实上也确实如此,我们先来介绍一种很麻烦的允许我们访问并修改私有成员变量的方式——设置成员函数作为与外部的接口进行对接的形式。代码如下:
#include<iostream>
//定义一个学生类。使用默认参数为成员变量进行赋初值
class Stu
{
public:
void print()
{
std::cout << _age << std::endl;
std::cout << _name << std::endl;
}
//定义一系列对外接口,方便修改私有化的成员变量
void ChangeName(const char name[20])
{
strcpy(_name, name);
}
void ChangeAge(int age)
{
_age = age;
}
private:
int _age=12;
char _name[20]="张三";
};
int main()
{
Stu s1;
std::cout << "使用默认值对成员变量进行初识化" << std::endl;
s1.print();
std::cout << "调用成员函数对默认初始化的值进行修改" << std::endl;
s1.ChangeAge(99);
s1.ChangeName("李四");
s1.print();
return 0;
}
就像是上面的代码所展示的那样,我们需要设置对外的函数接口之后才可以对内部的成员变量进行特定的访问,但是问题也就随之而来,假如我们的成员变量很多呢?我们需要一个一个设置接口吗?答案是当然不,事实上C++的语法虽然是支持的,但是这种形式并不常用,那么接下来就引出了在类和对象章节当中最为重要的一部分了——类的6个默认成员函数。我们先来测试一下以上代码的运行效果:
二:默认成员函数
在类和对象最重要的一部分就是了解认识并能够熟练使用类的六个默认成员函数。那么那么首先介绍以下什么是默认成员函数呢?
当我们的类当中不存在所指定的六个成员函数的时候系统会自动生成的六个成员函数,这样的函数就叫做默认成员函数。默认成员函数主要有以下的六种:
具体的六种默认成员函数如同我们上面所示。我们会依次进行解释。通常情况下默认成员函数会在我们的类当中进行定义的时候自动生成。但是当我们主动定义的时候,即使运用了运算符重载生成了其他类型的成员函数我们的编译器也不会在生成对应的默认成员函数了。我们可以从后面的例子中进行理解。
1.构造函数
第一个需要我们讲解的就是我们的构造函数。我们会发现每一个自定义在实例化对象使用的时候都需要进行各种初始化,要么是在外部进行各种初始化,这都有各种各样的弊端。所以我们就在想能不能创造一种函数,在创建对象的时候就可以进行指定传参,之后就生成对应的对象就好了。于是便有了构造函数。我们的构造函数就是用来方便我们进行对象初始化的。通过一段代码进行理解:
#include<iostream>
//定义一个学生类,使用构造函数进行特定的初始化
class Stu
{
public:
Stu(int age = 12, const char name[20] = "张三")
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _name<<" " << _age << std::endl;
}
private:
int _age;
char _name[20];
};
int main()
{
Stu s1;
s1.print();
Stu s2(17, "李四");
s2.print();
return 0;
}
构造函数要求函数名必须和我们的类名相同,并且没有返回值,我们可以对于构造函数设置默认参数方便我们初始化。在定义完成构造函数之后我们不需要主动调用构造函数,在每次使用类生成指定的对象的时候就会默认调用我们的构造函数。程序运行的结果如下:
但是我们的默认构造函数并不是如此,因为我们上面使用了默认参数所以在实例化无参对象的时候依旧可以正常的运行,但是假如我们把默认参数去掉,那么就会产生找不到构造函数的报错。
#include<iostream>
//定义一个学生类,使用构造函数进行特定的初始化
class Stu
{
public:
Stu(int age, const char name[20])
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _name<<" " << _age << std::endl;
}
private:
int _age;
char _name[20];
};
int main()
{
Stu s1;
s1.print();
Stu s2(17, "李四");
s2.print();
return 0;
}
我们会发现编译器给了我们详细的报错:没有合适的构造函数可用。这是因为我们已经自动生成了一个构造函数,编译器就不会在生成默认构造了。可以联想到编译器生成的默认构造函数并没有参数。因为我们在自己创建构造函数之前就可以无参的使用类实例化对象。但是我们的默认构造函数又会对我们的成员变量进行怎样的处理呢?我们可以通过测试得出结果:
通过打印出的效果我们很容易联想到:生成的不会是随机值吧?没错,确实是随机值。但是不是对象当中所有的内容都会生成随机值。那么我们直接先看结论:我们的默认构造函数不会对内置类型进行赋值,但是对于我们的自定义类型会调用相应的构造函数。(内置类型指的是我们系统自定义的类型,如:int,char,指针等)
简单的来说我们的编译器只会对自定义类型产生相应的反应,也就是调用相应的构造函数,我们可以通过一组代码进行测试:
#include<iostream>
//重新定义一个类,作为Stu类的私有变量,检测构造函数的调用
class Teacher
{
public:
Teacher(int age=23,const char name[20]="王老师")
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _name << std::endl;
std::cout << _age << std::endl;
}
private:
int _age;
char _name[20];
};
//定义一个学生类,使用构造函数进行特定的初始化
class Stu
{
public:
/*Stu(int age, const char name[20])
{
_age = age;
strcpy(_name, name);
}*/
void print()
{
std::cout << _name << std::endl;
std::cout<< _age << std::endl;
std::cout << "***************************" << std::endl;
t1.print();
}
private:
int _age;
char _name[20];
Teacher t1;
};
int main()
{
Stu s1;
s1.print();
return 0;
}
我们重新定义一个新的类,将其实例化后的对象作为Stu类的成员变量。之后可以验证我们的结论:默认构造函数不会对类当中的内置类型进行初始化,但是会调用自定义类型的构造函数,进而初始化自定义类型。程序验证的结果如下:
总结一下:构造函数的好处就是可以在生成对象的时候自动调用并匹配,进而达到初始化对象的作用。
2.析构函数
接下来就来介绍一下和构造函数相对应的析构函数。很容易可以想到有自动调用初始化对象的函数,那是不是就会有自动调用的销毁对象的函数呢?确实存在,也就是我们的析构函数。但是析构函数的作用实际上是针对动态开辟的空间的,也就是自动释放我们在堆区开辟的内存空间。但是与其说是自动释放不如说是在程序结束的时候会自动调用我们析构函数当中所编写好的代码,进而执行相应的操作而已。举一个简单的例子:还记得我们在数据结构章节当中编写过的栈的结构吗?当时是不是需要很多开辟内存的操作?所以在程序运行结束之后为了养成良好的编程习惯我们需要将我们申请的内存还回去。但是会有很多出口,很容易忘记,但是只要有了析构函数就不需要再考虑这个问题了。只要程序一结束那么就会自动的释放内存的空间。示例如下:
typedef int DataType;
class Stack
{
public:
Stack(size_t capacity = 3)
{
_array = (DataType*)malloc(sizeof(DataType) * capacity);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
CheckCapacity();
_array[_size] = data;
_size++;
}
~Stack()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _array;
int _capacity;
int _size;
};
这就是我们的析构函数,析构函数和我们的构造函数很相同,函数名都必须和类名相同。作为析构函数的标志是前面很明显的~号。有了析构函数之后我们就不需要再仔细考虑什么时候应该释放空间,以及空间重复释放的问题了。
作为6大默认构造之一,析构函数同样具有构造函数的特点。如果我们没有自动定义析构函数的话,系统就会自动生成一个默认的析构函数。默认生成的析构函数同样不会对内置类型进行处理,但是会调用我们其他类的析构函数,进行内存的释放操作。
需要注意的是:析构函数实质上对于内置类型并不会产生太大的影响,所以用的最多的地方就是当我们对内存空间进行动态的申请的时候,可以定义一个析构函数帮助我们释放申请的空间,其他情况下析构函数不定义也没有关系。
3.拷贝构造
接着就来认识一下拷贝构造函数:拷贝构造根据定义来说就是使用一个已经初始化好的对象,进行初始化另一个没有初始化的对象。这个过程就好像将我们的对象拷贝了一份一样,顾名思义拷贝构造。同样的我们通过代码进行理解拷贝构造函数:
#include<iostream>
//定义一个类,用于验证我们的拷贝构造函数
class Stu
{
public:
//定义一个默认构造,用于初始化对象
Stu(int age = 12, const char name[20] = "张三")
{
_age = age;
strcpy(_name, name);
}
//定义一个拷贝构造函数,用于拷贝对象并初始化
Stu(const Stu& s1)
{
_age = s1._age;
strcpy(_name, s1._name);
}
void print()
{
std::cout << _age << std::endl;
std::cout << _name << std::endl;
}
//由于我们没有动态开辟内存所以不需要定义析构函数
private:
int _age;
char _name[20];
};
int main()
{
Stu s1(14, "小明");
s1.print();
std::cout << "*****调用拷贝构造所产生的新对象*****" << std::endl;
Stu s2(s1);
s1.print();
return 0;
}
实质上拷贝构造其实还是一个构造函数,所以在定义的要求上和构造函数完全相同,都是函数名必须和类名完全相同,不需要返回值。我们可以认为拷贝构造是构造函数的重载。我们的形参必须是一个自定义类型。我们可以在自定义类型当中进行进一步的变量修改。
需要注意的是在定义拷贝构造的时候我们的形参必须使用自定义类型的引用,否则就会产生报错,这是因为我们在函数传参的时候会生成一个临时变量,在这个时候就相当于需要调用我们的拷贝构造,但是调用拷贝构造的时候有需要传参,那么就有出现了临时变量的拷贝,就有需要调用拷贝构造函数,进而形成死递归的现象。所以在定义的时候必须使用引用的形式传参。
同样的,作为默认成员函数。在没有定义的情况下系统同样会自动生成一个拷贝构造供我们使用。但是默认生成的拷贝构造只能进行浅拷贝。也就是对我们内置类型的值进行拷贝。而对于我们自定义类型的拷贝依旧会调用我们相应的拷贝构造。我们可以根据如下代码进行验证:
#include<iostream>
//定义一个类,用于验证我们的拷贝构造函数
class Stu
{
public:
//定义一个默认构造,用于初始化对象
Stu(int age = 12, const char name[20] = "张三")
{
_age = age;
strcpy(_name, name);
}
定义一个拷贝构造函数,用于拷贝对象并初始化
//Stu(const Stu& s1)
//{ //因为成员变量均为内置类型
// _age = s1._age; //所以可以省去直接调用默认的拷贝构造
// strcpy(_name, s1._name);
//}
void print()
{
std::cout << _age << std::endl;
std::cout << _name << std::endl;
}
//由于我们没有动态开辟内存所以不需要定义析构函数
private:
int _age;
char _name[20];
};
int main()
{
Stu s1(14, "小明");
s1.print();
std::cout << "*****调用拷贝构造所产生的新对象*****" << std::endl;
Stu s2(s1);
s1.print();
return 0;
}
我们上面的代码同样是可以达到我们拷贝的要求的。程序运行的结果如下:
4.赋值运算符重载函数
在介绍这一个默认成员函数的时候就不得不向大家提起一个新的概念了:那就是运算符重载函数。我们大家都知道自定义类型,比如数字是可以进行加减以及赋值等运算的。但是我们的自定义类型又该如何进行这一部分的操作呢?想要让我们的自定义类型也可以完成加减以及赋值的运算那么就得编写运算符重载函数了。那么接下来就先来认识一下运算符重载函数,我们照常通过一段代码进行理解:
#include<iostream>
//定义一个类,用于编写运算符重载函数
class Stu
{
//将在全局范围内定义的运算符重载函数定义成为Stu类的友元函数
//即该函数在全局的范围内也可以使用Stu的私有成员变量
friend int operator-(Stu s1, Stu s2);
public:
Stu(int age=12,const char name[20]="张三")
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _age << _name<<std::endl;
}
private:
int _age;
char _name[20];
};
//定义一个运算符重载函数
int operator-(Stu s1, Stu s2)
{
return abs(s1._age - s2._age);
}
int main()
{
Stu s1(18, "李四");
Stu s2(12,"王五");
s1.print();
s2.print();
std::cout << "使用运算符重载函数计算两个学生的年龄差" << std::endl;
std::cout << (s1-s2) << std::endl;
return 0;
}
运算符重载函数以operator为标志,后面紧跟着我们想要重载的运算符即可。之后我们在函数体当中编写我们想要进行的操作即可。需要我们注意的是:假如我们的运算符重载函数定义在全局的范围内但是我们又想访问类当中的私有的成员变量的时候我们就可以将该函数设置为友元函数。设置为友元函数之后我们就可以访问指定类当中的私有变量了。(友元函数我们会在下一篇博客当中详细讲解)假如我们不想设置友元函数我们还可以将运算符重载函数设置在类里面进行操作。这样我们就可以通过隐藏的this指针进行访问私有成员变量了。示例代码如下:
#include<iostream>
//定义一个类,用于编写运算符重载函数
class Stu
{
public:
Stu(int age=12,const char name[20]="张三")
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _age << _name<<std::endl;
}
int operator-(Stu& s2)
{
return abs(_age - s2._age);
}
private:
int _age;
char _name[20];
};
int main()
{
Stu s1(18, "李四");
Stu s2(12,"王五");
s1.print();
s2.print();
std::cout << "使用运算符重载函数计算两个学生的年龄差" << std::endl;
std::cout << (s1-s2) << std::endl;
return 0;
}
需要注意的是,将我们的运算符重载函数定义在类当中的时候因为编译器会自动传入一个this指针作为第一个类对象的参数,所以我们只需要将第二个需要比较的对象作为函数的参数传入即可。也就是我们上面的代码所示。程序运行的结果如下:
在了解过运算符重载函数之后想要理解赋值运算符重载函数也就很简单了。赋值运算符重载函数其实就是运算符重载函数的一种。也就是我们的赋值符号即 = 的重载。赋值运算符的重载写法和我们正常的运算符重载形式完全相同。所示代码如下:
#include<iostream>
//定义一个类,用于编写运算符重载函数
class Stu
{
public:
Stu(int age=12,const char name[20]="张三")
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _age << _name<<std::endl;
}
void operator=(Stu& s2)
{
_age = s2._age;
strcpy(_name, s2._name);
}
private:
int _age;
char _name[20];
};
int main()
{
Stu s1(18, "李四");
std::cout << "未赋值之前的s1" << std::endl;
s1.print();
Stu s2(12,"王五");
std::cout << "赋值之后的s1" << std::endl;
s1 = s2;
s1.print();
return 0;
}
使用相同的方法,将我们的赋值运算进行重载,之后就可以为自定义类型进行赋值了。代码所运行的效果如下:
赋值重载函数在没有主动定义的时候,其实也可以发挥作用,但是和其他的默认成员函数一样只会对其中的内置类型发挥作用,对于我们的自定义类型会默认调用其中定义好的赋值重载函数。将我们定义的运算符重载函数屏蔽掉再来测试代码的运行:
#include<iostream>
//定义一个类,用于编写运算符重载函数
class Stu
{
public:
Stu(int age=12,const char name[20]="张三")
{
_age = age;
strcpy(_name, name);
}
void print()
{
std::cout << _age << _name<<std::endl;
}
/*void operator=(Stu& s2)
{
_age = s2._age;
strcpy(_name, s2._name);
}*/
private:
int _age;
char _name[20];
};
int main()
{
Stu s1(18, "李四");
std::cout << "未赋值之前的s1" << std::endl;
s1.print();
Stu s2(12,"王五");
std::cout << "赋值之后的s1" << std::endl;
s1 = s2;
s1.print();
return 0;
}
通过结果我们会发现,即使没有自动定义赋值运算符重载函数程序也可以正常的运行,程序所运行的结果如下:
5&6普通对象取地址和const对象取地址
最后这两个默认成员函数其实不需要特别进行注意,不需要特定的进行定义。因为普通的对象取地址得到的是首地址依靠编译器给出的默认的成员函数使用即可。const对象也是相同的。所以我们只需要测试一下,针对自定义类型生成的对象可以正常的取地址并输出即可。测试结果如下:
那么我们的默认成员函数也就讲解完毕了,我们的类的重难点也就克服了大半,接下来剩下的关于类和对象的其他的使用的细节以及用法我们会在下一篇博客当中继续为大家讲解。