上篇链接:C++ 类和对象(上)_chihiro1122的博客-CSDN博客
类的6个默认成员函数
我们在C当中,在写一些函数的时候,比如在栈的例子:
如上述例子,用C++ 返回这个栈是否为空,直接返回的话,这栈空间没有被释放,就会内存泄漏,如果我们直接释放这个栈,那么我们就拿不到这个栈是否为空的 bool 类型值。那么我们在C中就是用 一个 bool 类型变量来接收这函数返回的布尔值 ,然后再释放,最后返回这个 bool 类型变量的值。
这样做非常的麻烦,C++的祖师爷肯定也想到的这些,这就有了类当中的 6 个默认成员函数。
我们在 上篇中提到了 空类,那么空类当中真的是什么都不存储吗,并不是。其实在空类当中,编译器会帮我们自动的去创造 6 个默认成员函数:
其中的 构造函数和析构函数就可以帮我们自动进行 一些 如上述的初始化和 销毁的操作。
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。
构造函数
构造函数是一个特殊的函数,它的名字和类的名字相同,而且在创建类的类型对象的时候调用,一保证每一个成员都有一个初始值,并且,构造函数在对象的整个生命周期当中值调用一次。
特性
需要注意的是,虽然构造函数是帮助对象当中的成员初始化,但是仅仅只是初始化,并不是给这个成员创建空间。
其特性如下:
- 构造函数名和类名一样
- 没有返回值,也不需要 如 void 这样规定返回值类型
- 实例化对象时编译器会自动的调用构造函数
- 构造函数可以重载,也就是说我们可以创建多个构造函数。
定义构造函数
我们以上篇中的 栈的初始化来举例子,如果我们要用函数来实现栈的初始化的话,应该这样写:
void StackInit(Stack* ps)
{
assert(ps);
ps->array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == ps->array)
{
assert(0);
return;
}
ps->capacity = 3;
ps->size = 0;
}
如果我们用构造函数来实现话,就这样来实现:
class Stack
{
public:
Stack(int capacity = 4)
{
_array = (DataType*)malloc(sizeof(DataType) * 3);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = 3;
_size = 0;
}
};
我们就可以直接把在C++当中 实现的 初始化函数 copy 在这个构造函数中。
这个构造函数在创建 对象的时候就可以自动的去调用,也就是说,可以帮我们在创建对象的时候自动的去 初始化栈。这样我们在使用 这个栈的时候就不需要再去初始化一遍栈了。
而且一般构造函数都是public的,如果是private 私有的就会报错:
当然我们上述说的是一般,也就是说不是必须的,构造函数也可以是 私有的,但是这是比较 高级的玩法。
如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦
用户显式定义编译器将不再生成。
如果我们在类当中没有 定义 构造函数,那么编译器自动的的给我们创建一个 无参数的默认构造函数。
在C++当中分为基本类型 / 内置类型,这些是语言本身定义的类型,如 int / double / char / 指针;自定义类型,如 用 class / struct 等等来创建的类型。
如果我们不写构造函数的定义,编译器会默认生成构造函数,内置类型不做处理,自定义类型会去调用他的默认构造:
class A
{
public:
void Print()
{
cout << _a << " >>" << _b << " >> " << _c << endl;
}
protected:
int _a;
int _b;
int _c;
};
int main()
{
A a;
a.Print();
return 0;
}
输出:
我们发现没有初始化为0,这是在C++当中的语法规定的,但是现在有些新的编译器就会把这个成员默认值初始化为 0。 像VS2019 在类当中 如果在类当中定义了自定义类型的成员,那么其中的内置类型和自定义类型都会进行处理。但是 在 VS2013 就会处理。
也就是说,内置类型处不处理是编译器个性化的行为,但是在C++当中是规定 内置类型是不进行处理的,我们要默认认为他是不处理的。
所以,让编译器自己生成构造函数这种操作我们是不建议的,因为指不定这个编译器既不会对 内置类型进行 初始化0,所以,如果类当中有内置成员,我们就要自己实现构造函数来初始化 类当中的 内置成员。
如果全部都是自定义类型的成员,可以考虑让编译器自己生成。
类当中的成员的缺省值
注意:在C++11 当中,C++的开发团队也觉得,内置成员不处理,自定义成员处理,这样当时不妥当,所以大了一个补丁,不是把之前的语法改了,让函数在声明的时候可以给缺省值。
class A
{
public:
void Print()
{
cout << _a << " >>" << _b << " >> " << _c << endl;
}
protected:
int _a = 1;
int _b = 1;
int _c = 1;
};
int main()
{
A a;
a.Print();
return 0;
}
输出:
我们发现上述就输出的是我们 缺省值。注意:此处不是初始化,是在声明的时候给的 缺省值。
这里的 成员的 缺省值 是给编译器默认的 构造函数用的。
因为是编译器默认使用 构造函数生成的缺省值,所以当我们在 类当中实现了 构造函数,这个缺省值就没有用了:
class A
{
public:
A(int a, int b, int c)
{
_a = a;
_b = b;
_c = c;
}
void Print()
{
cout << _a << " >>" << _b << " >> " << _c << endl;
}
protected:
int _a = 1;
int _b = 1;
int _c = 1;
};
int main()
{
A a(9,9,9);
a.Print();
return 0;
}
输出:
此时我们Print()函数就输出的是, 9 >> 9 >> 9 ,不是 1 >> 1 >> 1 了。
构造函数重载需要注意的点
class A
{
public:
A()
{
_a = 2;
_b = 2;
_c = 2;
}
A(int a, int b, int c)
{
_a = a;
_b = b;
_c = c;
}
void Print()
{
cout << _a << " >>" << _b << " >> " << _c << endl;
}
protected:
int _a = 1;
int _b = 1;
int _c = 1;
};
如上述的两个构造函数就构成了函数重载,构造函数的形参,同样是可以有缺省参数的:
A(int a = 0, int b = 0, int c = 0)
{
_a = a;
_b = b;
_c = c;
}
但是,构造函数中使用缺省参数会出现一些问题,如这个例子:
A()
{
_a = 2;
_b = 2;
_c = 2;
}
A(int a = 0, int b = 0, int c = 0)
{
_a = a;
_b = b;
_c = c;
}
比如这例子,在语法上是支持的,也就是说,上述两个构造函数是支持语法的,但是在调用无参数的构造函数的时候会出现一些问题:
我们发现此时编译器就对该调用哪一个构造函数产生了歧义。
当然我也不能 " A a1(); " 这样写,这样写与函数声明冲突,我们在下面会讲解。
当然,我们给一个,或者两个参数都是可以的 ,因为他不会和 无参数的构造函数冲突。
所以,我们总结一下, 其实无参数构造函数和 全缺省 构造函数都属于是 默认构造函数,再加上 我们不写编译器就会生成的 默认构造函数,这三者只能有一个,因为这三者在外部的调用都是一样的编译器不能识别。
构造函数的调用
构造函数的调用跟普通函数也不太一样,普通函数代用是函数名 + 参数列表,构造函数调用是对象名 + 参数列表。
像上述的例子:
int main()
{
A a2(9,9,9);
a2.Print();
return 0;
}
在类当中定义的 构造函数 是 以 类名为函数名来创建的,但是调用的时候,使用的是 创建的对象的名字。
当这个构造函数没有参数的时候,如下:
A()
{
_a = 2;
_b = 2;
_c = 2;
}
那么,就直接创建这个对象,不需要加 () 参数列表,都会自己调用 类当中 不带参数的 构造函数,如果这个例子:
class A
{
public:
A()
{
_a = 2;
_b = 2;
_c = 2;
}
A(int a, int b, int c)
{
_a = a;
_b = b;
_c = c;
}
void Print()
{
cout << _a << " >>" << _b << " >> " << _c << endl;
}
protected:
int _a = 1;
int _b = 1;
int _c = 1;
};
int main()
{
A a1;
a1.Print();
return 0;
}
输出:
我们发现此时输出的是,无参数 构造函数 当中初始化的值。
注意:有参数是需要加 "()" 在 "()" 当中传参,而如果我们想调用 无参数的构造函数,那么我们是不能 加 "()" 的,如果加 "()" 就会报错:
这是因为,如果这样写,会跟函数声明冲突,编译器不好识别。
如上述的 " A a1(); " 是不是和 不带参数的 函数的 声明是一样,这样编译器就不好识别;但是如果是 带参数的 ,如: " A a2(9,9,9); " 这样的就不会报错了,因为这样编译器就可识别了,因为我们在声明有参数函数的时候,参数是要加 参数的类型的: " void func ( int a, int b , int c); " 这样的。而我们在调用有参数的构造函数的时候,是函数的传参,不需要 写 参数的类型。
我们还可以这样来给 对象当中的成员初始化:
typedef struct TreeNode
{
struct TreeNode* left;
struct TreeNode* right;
int val;
}TreeNode;
class Tree
{
public:
Tree(int val)
{
_root->left = nullptr;
_root->right = nullptr;
_root->val = val;
}
protected:
TreeNode* _root = nullptr;
};
int main()
{
Tree Tr(1);
return 0;
}
如上,我们就创建了一个二叉树树结点,这个树结点的左右孩子指针都指向空,并且我们在主函数中创建这个对象的时候,可以个这个树结点当中的val 赋值。
析构函数
析构函数和构造函数的作用相反,它并不是完全的对 对象 进行销毁,局部对象进行销毁是由编译器来执行。所谓析构函数是对象在销毁的时候,才会执行的操作,来完成对象当中 一些成员的销毁工作。
特性
- 析构函数的名字和类名相同,只是在最前面加上 " ~ " 这个符号
- 没有参数和返回值
- 一个类只能有一个析构函数,若没有对析构函数进行定义,那么编译器会自动进行创建。注意:析构函数不能进行重载。
- 在对象的生命周期结束的时候,编译器会自动调用析构函数。
析构函数的定义
如上篇当中的 栈的销毁:
class Stack
{
~Stack()
{
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
}
如上述的~Stack() 函数就是 析构函数,他在 栈的对象销毁的时候就会自动调用。
也就是说,在上篇中提到的,内存泄漏问题,在程序运行的最后,只需要销毁 这个对象就会自动的销毁创建的栈空间。
有了析构函数和构造函数的时候就不在怕初始化和清理函数了,也简化了。
我们上述也提到了,析构函数不写编译器也会生成默认的 析构函数,那么这个 默认的析构函数和构造函数是一样的,也是内置成员不处理,自定义成员就要按照自定义类型进行处理。
在比如上述栈的例子,如果类当中都是 自定义类型,那么我们也可以不写析构函数,编译器自动生成的 析构函数就会帮我们 清理销毁 对象空间。
拷贝构造函数
当我们像通过某个对象当中的成员的值,在实现某些功能,但是实现过程有需要修改对象成员里的值,我们又不想修改这个对象当中成员的值,我们就可以创建一个 拷贝构造函数来帮助我们拷贝这个对象当中成员的值。
定义:
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存
在的类类型对象创建新对象时由编译器自动调用。
例子:
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
protected:
int _year;
int _month;
int _day;
};
int main()
{
Date date(); // 创建日期对象
}
如上述例子,我们想把date 这个对象进行拷贝,我们可以在 Data 类当中实现一个 拷贝构造函数;
Date(const Date& date)
{
_year = date._year;
_month = date._month;
_day = date._day;
}
上述就是拷贝构造函数,我们可以发现,这个其实就是构造函数的一种重载,只是这个函数只有一个形参,而且这个形参是 我们需要拷贝 的 对象的 引用,一般我们在这个函数中,不想去修改外部的我们 拷贝的对象,所以这个引用是 const 不允许 修改的。在构造函数中对成员的值的修改也容易搞混,容易写反,写入const 也能防止这样的事情发生。
注意:一下代码是编译报错的;
我们发现,我们在构造函数的创建的时候,他不给我们创建 本类 类型的参数的构造函数的重载函数,我们只能在此传入 本类 类型的 引用:
这是因为,在C++当中规定:在函数传参的时候,如果是内置类型就是直接拷贝;如果是自定义类型就必须先调用 拷贝构造函数 之后去完成拷贝。
如下图:
这个例子,当调用 func(1) 时候就是直接拷贝,当调用 func(d1) 这个函数的时候,我们来调试来看看过程:
此时程序运行到 func(d1) 函数这一行,当我们 按下 F11 (或者是 fn + f11 看电脑) 单步执行程序,执行下一步时,我们发现,不是直接跳到 func(Date d1) 函数这一行,而是直接跳到 Date(const Date date) 拷贝构造函数这一行了:
这也就印证了,在函数传入 自定义类型的时候,先是传参,自定义类型传参就要先 调用拷贝构造函数。
当我们在按下 f11 才进入到 func(Date date)这个函数。
那么,如果自定义类型传参是按照上述过程来实施的,那么如果我们在构造函数中,直接像下图一样写,那么就会出现无限函数递归的可怕结果:
其过程也不难理解,如下图所示:
那么,如果我们在传参的时候,传的不是自定义类型,传入的自定义类型的引用,他是对象的一个别名,在传参的时候都不会开空间,那么他就不会调用拷贝构造函数。
若未显式定义,编译器会生成默认的拷贝构造函数
如果我们在类当中没有写 拷贝构造函数,那么编译器会自动给我们生成 拷贝构造函数。
此处默认的 拷贝构造函数 ,对于其中不同类型成员的拷贝是这样的:
- 对于内置类型成员进行值拷贝/浅拷贝。
- 对于自定义类型则会调用它的构造拷贝函数。
所以实际上,对于简单的对象的拷贝,默认的拷贝构造函数是可以自动来完成的,如下例子:
但是对于像栈,队列等等这些 对象当中动态开辟空间了的对象,那么默认的就不能拷贝成功了。
比如我们拷贝栈,栈当中存储了,动态开辟空间的空间的指针,top栈当中存储的有效数据的位置。那么如果我们使用默认 拷贝构造函数,只能拷贝上述两个 变量,不能开辟空间,那么函数当中拷贝的对象,和外部主函数中的对象,两个对象将使用同一块空间:
这样就违背了我们使用 拷贝构造函数的初衷。
而且,假设我们不在使用这两个对象的时候,就会调用对象的析构函数,来销毁栈空间,那么当其中一个对象调用析构函数销毁了空间之后,另一个对象再次销毁就会有问题。在两个操作之间没有对这个空间进行重新的声明,同一块空间不能销毁两次。
比如这个例子,后定义的会先析构,那么st1 这个对象在析构的时候就有问题了。
而且,这只是其中之一个问题,我们以后再 push pop 等等操作都会有问题,两个对象使用的是一块空间,任何操作修改了其中以对象当中空间,都会影响另一个对象。
所以我们就要自己创建 拷贝构造函数,来拷贝其中的 栈空间,如下例子,我们采用深拷贝:
class Stack
{
public:
Stack(int capacity = 4)
{
_array = (int*)malloc(sizeof(int) * 3);
if (NULL == _array)
{
perror("malloc申请空间失败!!!");
return;
}
_capacity = 3;
_top = 0;
}
Stack(const Stack& st)
{
_array = (int*)malloc(sizeof(int) * _capacity);
if (_array == NULL)
{
perror("malloc fail");
return;
}
memcpy(_array, st._array, sizeof(int) * _capacity);
_top = st._top;
_capacity = st._capacity;
}
protected:
int* _array = nullptr;
int _capacity = 0;
int _top = 0;
};
如上就是是 Stack的构造函数 和 拷贝构造函数的创建方式,上述采用的是深拷贝。
至此,我们就明白,为什么祖师爷在自定义类型的传参的时候,为什么不直接值传递,不直接把值传进来了。
因为,就像我们上述说的,如果我们直接把 值 传入,那么会导致,两个对象使用的是一个栈空间。
所以拷贝构造是为深拷贝而生的,值传递都是使用 C++ 当中 默认 生成的 拷贝构造函数就可以实现了。
那么在函数的传参和 返回的时候,如果返回的是自定义类型,那么再传参的时候会 先去调用 这个自定义类型的 拷贝构造函数;在 函数返回的时候,会创建一个临时变量来暂时存储 这个返回值,这个临时变量的大小也是根据 自定义类型大小来的定的;如果这个自定义类型当中 的数据量很大,比如栈空间中存储了 10000 个数据,那么上述两种情况,我们都要先 拷贝 ,那么这个拷贝是消耗空间和时间的,我们程序的效率会下降。
像上述的两种情况,在数据量很大的情况是下是非常明显的。
所以一般情况下,我们能使用引用来进行 传参和 返回 尽量使用引用来 传参和返回。
但是我们在使用引用传参和引用返回的时候,需要注意是,我们要用空间是否还存在有没有被销毁:
Stack& func()
{
static Stack st;
return st;
}
比如这种情况是可以返回的,因为 创建的 st 是静态的,函数执行完,函数栈帧销毁,他不会销毁。
Stack& func()
{
Stack st;
return st;
}
像这个例子,st 就不会静态的,也不是全局的,是函数中的局部变量,那么函数执行完之后,他就会销毁,那么此时我们在返回这个已经销毁了的空间的引用,就会很危险:
Stack& func()
{
Stack st;
return st;
}
int main()
{
printf("-------\n");
Stack st = func();
printf("-------\n");
return 0;
}
比如这个例子,在 main 函数中,我们使用 st 来接收,此时的st 就作为是 func ()函数中 st 的引用(别名) ,那么func()函数中的st 在函数销毁的时候就已经销毁了,那么我们在main函数中创建的 st 栈也要进行销毁,销毁之后,自动创建的析构函数会这个指针给置空,那么我们在main函数中的 st 也要进行释放,销毁,对空指针的销毁就会出现问题。
const成员
如这个例子,第一个可以调用,但是第二个就不能调用了。
如下图所示:
第二中种中的传入的指针是const 的,但是在 Print()函数中是 Date* 的,这是权限的放大,当然就不能使用。而第一种是权限的平移,就可以使用。
那么像上述的 const Date d2() 这种如何调用其中的成员函数呢?
那么我们就考虑把 Print() 当中的 this 指针 变成 const 的,这样第一种情况就是权限的缩小,而第二种是权限的平移,我们就可以调用了。
但是 this 是 隐含的,我们不能显示的去写,所以祖师爷想了一个位置,就是在 成员函数定义的时候,在()后加一个 const 代表 this 指针是 const 的。这个const 修饰的是 *this。既然修饰的是*this,那么就代表这个 this 指针指向的内容不能被修改。
当我们在成员函数后面加上 const 修饰 this 指针之后,在外的无论是 普通的对象还是 const 修饰的对象都可以调用。
问题:我们发现上述 在成员函数 之后 加上 const 修饰很香,那么是不是 所有的成员函数都 加 const 修饰呢?
答案是否定的,我们上述 Print() 成员函数只是打印 对象当中的成员的值,没有对 对象当中的成员进行修改,但是如果这个成员函数是 需要修改 成员属性的,那么这个函数就不能 在函数后面加 const修饰。
比如上述实现的 += 重载函数,上述的 _day 是Date类当中的成员,而上述的 _day += day ;这个代码会被编译器 转化为 this->_day += day; 但是 *this 是被 const 修饰的,意思就是说,this指针指向的内容不能被修改,所以我们发现,当我们加了 const 之后,上述代码编译报错了。
其实这里就是解决 权限的放大缩小平移的问题,如下例子:
这个在Date类当中重载的成员函数:
Date d1(2019,2,2);
const Date d2(2019,3,3);
d1 < d2; // 1
d2 < d1; // 2
上述代码 1 能编译通过,但是 代码2 不能编译通过。代码2 的 d2 在传参的时候其实就是权限的放大,所以不会编译通过。
而当我们 把这个 < 重载函数 后加一个 const ;代码2 就可以编译通过了。
而,向上述的,其中没有修改 成员 属性的成员函数,我们都建议加上 const 修饰,如果成员函数中修改了 对象当中的成员属性,那么就不能加 const 修饰。
理解const修饰词
const int a = 10;
int b = a; // 正确
int& c = a; //错误
第一种不涉及权限的放大和缩小,因为这里只是一个拷贝,只是把 a 的值拷贝给 b,在拷贝之后,b 的改变不影响 a ;而第二种c 是a 的别名,c 的改变会改变 a ,这里就设计权限的放大。
要想引用 c 也是不能改变的,此时是权限的平移。
那么指针也是和 引用类似的,如果按照上述的错误操作,也是权限的放大,会报错:
- 1. const对象可以调用非const成员函数吗?
- 2. 非const对象可以调用const成员函数吗?
- 3. const成员函数内可以调用其它的非const成员函数吗?
- 4. 非const成员函数内可以调用其它的const成员函数吗?
取地址及const取地址操作符重载
Date* operator&() // 普通对象的取地址重载函数
{
return this;
}
const Date* operator&()const // const 对象的取地址重载函数
{
return this;
}
其他的操作符,对于自定义类型该如何实现具体功能,编译器是无法判断的,但是取地址不同,他只需要返回这个对象的地址就行了。所以我, 使用编译器默认生成的 取地址 重载函数也可以实现取地址功能。
除非你不想要别人取到 这个 普通对象的地址,那么就直接返回 nullptr。