C++ 类和对象(中)构造函数 和 析构函数 ,const成员

 上篇链接: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成员函数吗?

 容易出错的地方:

 如上所示,npos是在类当中声明的全局变量,类当中只是声明,npos的定义在全局当中。此时我们是不能给 npos赋上缺省值的,因为类当中成员变量的缺省值是给 初始化列表看的,而对于npos全局变量,他的定义不在初始化列表,而是在全局当中定义,如下所示(编译报错):

 但是如果把这个npos变量用const进行修饰,就可以在类当中定义缺省值了,而且不用再全局当中进行定义

 此处的语法比较怪,并不是说类似上述的所以const修饰的全局变量都可以像上述一样定义缺省值,而是只有size_t类型才可以,如下所示,我们换成 double 类型就不行了

        

 所以,我们建议 size_t 类型的全局变量(static)不想上述一样使用,还是统一和其他类型一样声明和定义分离

 

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

Date* operator&()  // 普通对象的取地址重载函数
{
    return this;
}
const Date* operator&()const  // const 对象的取地址重载函数
{
    return this;
}

其他的操作符,对于自定义类型该如何实现具体功能,编译器是无法判断的,但是取地址不同,他只需要返回这个对象的地址就行了。所以我, 使用编译器默认生成的 取地址 重载函数也可以实现取地址功能。

除非你不想要别人取到 这个 普通对象的地址,那么就直接返回 nullptr。

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chihiro1122

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值