C++类和对象

前言

面向对象是C++的重要特性之一,其解决问题时关注的是对象。初学者在学类时是非常容易产生困惑的,这也许是C++劝退很多小白的原因吧。

接下来这篇博文带读者学习使用类,踏进面向对象的大门。

一.结构体的升级

C语言使用的struct在C++中被升级成了类,并且C++习惯使用class代替struct创建类,那么结构体的升级体现在哪里呢?我们接着往下看:

在这里插入图片描述

使用struct也可以创建类的,和class有什么区别待会儿会讲。

在类里面可以定义成员函数,也可以直接使用类名Stack创建变量,这些都是C语言的结构体做不到的。

读者可能会想使用typedef重名命struct Stack为Stack就可以创建变量了,但这仅仅是形式上的改变,如果不理解可以看下面这个:

在这里插入图片描述

类的两个变化:类名直接就是类型;类里可以定义函数,叫做成员函数,与成员变量统称为类成员。

那么使用struct和class到底有什么区别?这里要讲讲访问限定符:

访问限定符有:public(公有)protected(保护)private(私有),我们尝试把创建栈的struct换成class,看看有什么变化:

在这里插入图片描述

无法访问class类中的成员变量和成员函数,这是因为class创建的类,默认的访问限定符是private(私有)。在类外面无法访问,在类里可以,比如Init函数里可以访问成员变量。

注意:在学继承前protected和private是相似的。但想要类外面无法访问一般使用private。

struct的默认访问限定符是public,是为了兼容C语言struct的那套用法。使用struct或class创建类, 除了默认限定符以外,基本没有太大区别。

但C++基本都使用class创建类。使用习惯是:成员变量私有、成员函数公有,以下代码就是一个例子。

class Stack
{	//在遇到另一个之前往下都是公开
public:
	void Init()
	{
		//...
	}

	void Push(int x)
	{
		//...
    }
    //往下到结束都是私有
private:
	int* _a;
	int _top;
	int _capacity;
};

想必读者一路看过来,一直好奇为什么成员变量前面加个杠,看以下代码就理解了:

class Date
{
public:
	void Init(int year, int month, int day)
	{
		//哪个是类里的成员year,哪个是形参year?
		year = year;
		month = month;
		day = day;
	}

private:
	int year;
	int month;
	int day;
};

答案是:Init如果这样写,里面都是year都是形参的year,month和day也都一样,这是因为局部优先。那么形参为什么不简写成y、m、d呢?

问就是有人觉得形参意义要明确一点好。于是在成员变量前加了个杠,渐渐形成习惯,并标识着它们是类部的。当然还有其它不同的写法,比如驼峰的,后杠的等等。

在成员变量前面加_是博主的习惯,读者也可以使用。接下来我们再讲讲,如何把类中的成员函数,实现声明和定义分离。

//Stack.h
#include <iostream>
using namespace std;

class Stack
{
public:
	void Init();
	void Push(int x);
	//类部直接定义的函数默认是内联函数
	bool Empty()
	{
		return _top == 0;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

//Stack.cpp
#include "Stack.h"
//类部函数要指定类,使用域作用限定符
void Stack::Init()
{
    //先是判断_a是不是局部变量,不是再到类里找
	_a = nullptr;
	_top = 0;
	_capacity = 0;
}

void Stack::Push(int x)
{
	//...
}

类成员函数声明定义分离时,函数定义需要指定类域,一旦指定后,类里的私有变量也是可以访问的。其次是定义在类里的函数默认是内联函数。

由于内联函数展不展开取决于编译器,编译器只展开代码量少的内联函数,所以正确的使用声明定义应是:短小的函数直接定义在类部,长函数则定义和声明分离。

从Stack.cpp文件实现类成员函数需要指定类以及使用域作用限定符,不难看出类用花括号({})括起来的部分是一个域,和命名空间域一样。在C++中,基本用{}括起来的都是一个域。

二.类的实例化

在设计好类后,为类的成员变量开空间,就是类的实例化:

//类的设计
class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};
//类本质是一张图纸

int main()
{
    //类的实例化,创建实体
	Date d1;
	Date d2;
	return 0;
}

为类的成员变量开空间就是类的实例化,类的成员函数Init有没有在对象d1、d2中开空间呢?

在这里插入图片描述

对象大小的结果表示对象没有存成员函数(注意内存对齐)。那不存成员函数的地址,对象怎么调用到Init函数呢?

我们看到不同对象在调用成员函数时,调用的都是同一个函数,同一个地址。如果这两个成员里都存上这个地址,就多存了8个或16个字节的空间,那如果有1000个或10000个对象呢?

于是,类的成员函数不存对象中,而是存公共代码区,供所有对象使用。类对象的大小只需要计算成员变量的大小即可(注意内存对齐)。

在这里插入图片描述

空类A的大小是1,空类本质说的是没有成员变量的类。A存了1个字节大小的数据是用来标识用A类创建过的对象a1、a2存在过。B类有成员函数但仍为1(成员函数存公共代码区,不算类型的大小)。

三.this指针

class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
    
	void Print()
	{	//成员函数里的_year这些成员变量是哪里的?
		cout << _year << '/' << _month << '/' << _day <<  endl;
	}

private:
    //这里是成员变量的声明
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2;
    //我们知道d1、d2调用的都是同一个Init函数
    //为什么Init能将d1、d2初始化成我们设置的实参呢?
	d1.Init(2023, 1, 19);
	d2.Init(2023, 1, 20);
	d1.Print();
	d2.Print();
	return 0;
}

在这里插入图片描述

这是因为类成员函数默认多传一个参数,传的是调用对象的地址。在成员函数中,这个形参叫做this指针,指向调用对象。

class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
    
	void Print()
	{
		cout << _year << '/' << _month << '/' << _day << endl;
	}
    //void Print(Date* this)
    //{
    //    cout << this->_year << '/' << this->_month << '/' << this->_day << endl;
    //}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;
	Date d2;
	d1.Init(2023, 1, 19);//本质是d1.Init(&d1,2023,1,19);
	d2.Init(2023, 1, 20);
	d1.Print();
	d2.Print();
	return 0;
}

​ 编译器会帮我们做成员函数有关指针this形参和实参的传递工作,默认第一个参数位置是this指针。

​ 我们不能显示传递this指针,但是可以在成员函数中显示使用this指针。

在这里插入图片描述

实际情况下this指针是只读的,也就是(Date* const this):

在这里插入图片描述

在实现成员函数时,使用成员变量不显示使用this指针。

学完这些,以上就是类的基本使用。和C语言只是看起来不一样,但还是差不多的。这是由于传递对象地址、类中成员函数使用this指针访问成员变量由编译器承包了,所以写起来还是很方便的。

不一样的是,类的数据和方法是包装在一起的,而C语言数据和方法是分开的;类中的访问限定符,一般为成员变量设置私有,使得外部不能轻易访问数据,更加规范;

接下来我们学习类中最为复杂,需要多记的默认成员函数重点知识。

四.默认成员函数

默认成员函数是每一个类中如果程序员没有显示在类中定义,编译器会自动生成的成员函数,就叫做默认成员函数,有六个(其中主要用四个),我们一个一个讲。

注意:当我们显示定义了六个中某个默认成员函数,编译器便不再生成对应的默认成员函数。比如:我们定义了构造函数,编译器就不会生成构造函数。

4.1构造函数

我们在写一些数据结构的时候,难免有时候会忘记初始化,有一些忘记初始化并不会造成太大影响。

但有些会让程序崩掉。比如我们创建栈对象且忘记初始化了,此时成员变量有野指针,随机值。直接插入数据必定崩。

人非圣贤,在日常写代码中忘记初始化的情况并不少见。于是祖师爷想到,能不能在实例化对象的同时,让对象自动调用类似于初始化的函数?

这就是我们接下来讲的第一个默认成员函数:构造函数、注意构造函数不是字面意思,不是用来创建对象的,而是对象实例化的同时自动调用用来初始化对象的

class Date
{
public:
    //构造函数: 1.函数名和类名相同; 2.不用写返回值(也不是空void),就是不需要写;
    //3.构造函数在类对象实例化时自动调用; 4.构造函数可以重载
	Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}
	//与上面的构造函数构成重载
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << '-' << _month << '-' << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1;//创建d1的时候,自动调用构造函数,这个无参的构造函数叫默认构造,后面会讲
	d1.Print();

	Date d2(2024, 1, 20);//学习使用给带参的构造函数传参的方式
	d2.Print();
	return 0;
}

这里要说明一下,不管是构造函数还是接下来要学的其它默认成员函数,它们都是祖师爷特别定义的函数,和普通的函数不一样,它们的定义、调用、传参都不同与普通函数。

在这里插入图片描述

普通的函数调用是函数名加圆括号的方式。d1在创建的时候,自动调用构造函数,然后打印,我们并没有d1.Date()这样显示调用,就把d1里的成员变量给初始化了。

调用带参数的构造函数,在实例化对象后面加圆括号传参数,记住就是这样使用的。

在这里插入图片描述

​ 在定义构造函数的时候,通常实现全缺省。不过这两个函数虽然能构成重载,但在实例化无参对象的时候存在调用歧义,因此只写全缺省的构造函数。

在这里插入图片描述

既然构造函数默认成员函数,那么我们不显示写构造函数的时候,编译器会不会生成一个默认的构造函数呢?答案是会生成的,那么编译器默认生成的构造函数是怎样的呢?我们往下看:

在这里插入图片描述

d1里的成员都还是随机值,这是因为默认构造对C++规定的内置类型不做处理,对自定义类型才做处理。

内置类型是:int、char、double这些计算机内置的类型,包括指针,任何类型的指针都是内置类型,比如Date* p也属于内置类型;自定义类型是:class、struct、联合等类型。

class Stack
{
public:
    //显示定义了构造函数,编译器不为Stack类生成默认构造
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (nullptr == _a)
		{
			perror("malloc fail");
		}
		_top = 0;
		_capacity = capacity;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

class MyQueue
{
public:
	//没有显示定义构造函数,编译器生成

private:
	Stack in_stack;
	Stack out_stack;
};

int main()
{
	MyQueue mq;//使用编译器默认生成的构造函数进行初始化
	return 0;
}

在这里插入图片描述

我们看到MyQueue类的实例化对象mq里的两个自定义类型成员能够被Myqueue类的默认构造进行很好的初始化,并且如果读者留意初始化内容:top等于0、capacity等于4…

是的,对于自定义类型成员,类的默认构造会去调用这个自定义类型的默认构造进行初始化。也就是说mq的两个成员in_stack、out_stack都去调用了Stack类里的默认构造。

那么读者可能又有疑惑了,什么是默认构造?依博主前说的:我们没有写构造函数,编译器生成的构造函数才叫默认构造,我们在Stack类里写了构造函数,编译器不会生成的话,也就没有默认构造呀!

这里来说一下默认构造的含义:默认构造是指无需传参的构造函数。分别是:编译器默认生成的、程序员显示定义的不带参数的、程序员显示定义全缺省的

这三个构造函数在类中只能存在一个。比如我们定义了默认构造,编译器的默认构造便不会生成。

这样顶多有不带参数和带全省参数这两种默认构造,但是前面讲过的Date()、Date(int year = 1, int month = 1, int day = 1)这两种程序员自己定义的默认构造会存在无参调用歧义。

因此我们只写全缺省的默认构造函数,到这里希望读者能在理顺一番,博主也将在下面进行总结:

总结:构造函数是祖师爷想在对象实例化自动调用的特殊函数,完成初始化工作

分两种情况:1.程序员没有定义构造函数、编译器会自动生成一个默认构造:对内置类型成员不做处理,对自定义类型成员调用它自己类里面的默认构造。

补充:如果它自己类里面没有默认构造(无需传参调用),那么就会报错。

在这里插入图片描述

2.程序员若自己定义构造函数,需要知道构造函数的知识:a.构造函数的函数名与类名相同;b.没有返回值指的是不需要写返回类型;c.自己定义的构造函数也会在对象实例化时被自动调用;d.构造函数可以实现重载;

在这里插入图片描述

以上就是构造函数的总结学习,读者可以多看几遍。

最后,C++委员会意识到构造函数不处理内置类型是不太好的,于是C++11打了一个补丁:

在这里插入图片描述

允许通过在声明加缺省值的方法改变构造函数对内置类型的处理。

在这里插入图片描述

关于构造函数还有一点初始化列表的知识,我们在后面讲。休息一下,接下来进入析构函数!

4.2析构函数

既然对象创建的时候自动进行初始化,那么在对象生命周期结束时自动进行资源清理也是不过分的吧。

析构函数的特点是:1.函数名是类名前面加个~;2.不需要写函数返回值,并且没有参数,也就不支持函数重载,析构函数只有一个;3.在对象生命周期结束时自动调用;4.没有显示定义,编译器会自动生成一个析构函数(默认成员函数都会);

在这里插入图片描述

当对象d1生命周期快结束时,按f11会进入d1对象的析构函数,请看下图:

在这里插入图片描述

最后在屏幕上打印~Date()证明确实会调用析构函数。

在这里插入图片描述

实际上对于日期类不需要显示定义析构函数,因为没有资源清理,而对于日常使用的数据结构那些会使用堆空间的,特别需要定义析构函数,我们接下来看看编译器生成的析构函数会做些什么。

class Stack
{
public:
	Stack(int capacity = 4)
	{
		cout << "Stack(int capacity = 4)" << endl;
		_a = (int*)malloc(sizeof(int) * capacity);
		if (nullptr == _a)
		{
			perror("malloc fail");
		}
		_top = 0;
		_capacity = capacity;
	}

	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_top = _capacity = 0;
		_a = nullptr;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

class MyQueue
{
public:
	//没有显示定义构造函数,编译器默认生成构造函数
	//没有显示定义析构函数,编译器生成析构函数
private:
	Stack in_stack;
	Stack out_stack;
};

int main()
{
	MyQueue mq;
	return 0;
}

编译器生成的析构函数和构造函数类似,如果是内置类型就不处理,对自定义类型调用它的析构函数。

在这里插入图片描述

此时如果Stack类没有显示定义析构函数,那么Stack类的析构函数由编译器生成,编译器生成的不会处理_a、_top、_capacity这些内置类型,就导致_a指针那块动态开辟的空间被泄漏了。

因此对于常见的数据结构类型都需要写析构函数。

总结:析构函数和构造函数有许多地方很像:1.如果没有显示定义,编译器会自动生成一个,因为它们都是类中的默认成员函数;2.编译器生成的析构函数对内置类型不做处理,对自定义类型调用它的析构;

析构函数的函数名是类名前面加上~,无参数无返回值,不支持函数重载(只有一个),在对象生命周期结束时自动调用,用来清理对象所开辟的空间资源,一般是堆上开的空间。

4.3拷贝构造

拷贝构造是构造函数的一个重载,关于拷贝构造博主学的时候也是被搞得很头痛,接下来进入知识学习环节。

class Date
{
public:
	Date(int year = 1, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	//拷贝构造是构造函数的重载
    //1.类名和函数名相同;2.没有返回值;3.参数必须是同类型对象的引用,且只有这个参数(不算this指针)
	Date(const Date& d)
	{
        cout << "Date(const Date& d)" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	void Print()
	{
		cout << _year << '-' << _month << '-' << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

void Func(Date d)
{
	d.Print();
}

int main()
{
	Date d1(2024, 1, 24);
	Date d2(d1);//使用同类型参数的构造函数进行初始化,取名叫拷贝构造
    Func(d2);//将d2传值给d这个参数,根据规定调用拷贝构造
	return 0;
}

d1使用全缺省的构造函数初始化,而d2使用同类型的d1进行拷贝构造初始化。

注意:拷贝构造没写,编译器也会生成,生成的拷贝构造默认实现浅拷贝(值拷贝),这是因为兼容C语言自定义类型的值拷贝。

C++规定:自定义类型在传值的时候都会调用拷贝构造。这是为了解决析构函数出来后,浅拷贝带来的问题。

因此Func函数调用之前先传参,这里是自定义类型的传值传参,那么就会调用拷贝构造。

在这里插入图片描述

这里我们就关于为什么拷贝构造的参数是const Date&类型讲一下:

我们前面也说过C++的规定,自定义类型传值必须调用拷贝构造,如果我们实现的拷贝构造参数是类对象而不是类对象的引用,就会造成无穷递归!

在这里插入图片描述

所以传类对象的引用,就不会造成递归调用。

在这里插入图片描述

那么为什么要加const呢?请看下图:

在这里插入图片描述

将拷贝构造中的赋值方向不小心弄反后,本来是使用d1拷贝构造d2的,反而让d1自己的年份变成了d2的1,所以为了避免失误对d1这个源内容进行修改,使用常引用。

在这里插入图片描述

弄清楚以上这些知识后,关于拷贝构造如何创建和使用,以及拷贝构造在自定义类型传值是会被调用等都讲解完后。接着我们要说说拷贝构造不显示定义,编译器生成的拷贝构造会有哪些行为?

前面提过一嘴说,编译器生成的拷贝构造会进行浅拷贝,也就是值拷贝。有浅就有深,什么是深拷贝,看完下面读者就知道为什么析构函数出来后,浅拷贝就会带来问题,需要使用深拷贝解决。

class Stack
{
public:
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (nullptr == _a)
		{
			perror("malloc fail");
		}
		_top = 0;
		_capacity = capacity;
	}
	//深拷贝:连同资源空间及其内容拷贝生成一份
	Stack(const Stack& st)
	{
		cout << "Stack(const Stack& st)" << endl;
		_a = (int*)malloc(sizeof(int) * st._capacity);
		if (_a == nullptr)
		{
			perror("malloc fail");
			exit(-1);
		}
		memcpy(_a, st._a, sizeof(int) * st._top);
		_top = st._top;
		_capacity = st._capacity;
	}
	~Stack()
	{
		cout << "~Stack()" << endl;
		free(_a);
		_top = _capacity = 0;
		_a = nullptr;
	}

private:
	int* _a;
	int _top;
	int _capacity;
};

int main()
{
	Stack st1;
	Stack st2(st1);
	return 0;
}

这段代码写了拷贝构造,假设我们还没有实现,那么编译器生成的拷贝构造将会进行浅拷贝,就会有以下问题:

在这里插入图片描述

在这里插入图片描述

当st2生命周期结束的时候,会调用析构函数,释放st1构造函数生成那块空间。然后到st1调用析构函数了,_a指向的那块空间已经被释放了,再次释放相当于释放了两次,报错。

但深拷贝是这样的:

在这里插入图片描述

于是到这里我们就理解了为什么析构函数出来后,浅拷贝会带来问题。而C语言没有这个会在对象生命结束时自动调用清理资源的析构函数,也就不会有这个问题。

接下来的问题是:是不是所有类都需要显示写拷贝构造,其实不是的,对于日期类Date就不需要写拷贝构造,完成值拷贝就可以了。但栈这些常见的数据结构就需要写。

好的,看完前面两个成员函数,按照惯例读者应该知道博主又会搬出什么样的类来说明编译器默认生成的拷贝构造会对内置类型进行值拷贝,对自定义类型调用它的拷贝构造了

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

以上就是默认拷贝构造做的事情。我们再来看最后一点,在C++入门篇引用那里说引用返回有什么价值,体现在这里:

对于出了Func函数不会销毁的对象st,使用引用返回那就只有构造函数和析构函数:

在这里插入图片描述

如果使用传值返回:

在这里插入图片描述

自定义类型传值需要调用拷贝构造,而且这个拷贝出来的生命周期结束后也调用析构函数,是不是效率就大打折扣了呢?如果是个非常非常大的树,传值返回更笋。

注意:当然传引用返回的前提是对象生命周期还没结束。

到这里拷贝构造讲完啦,是不是看完需要花费点时间理解记忆呢?接下来要学的是第四个默认成员函数:赋值重载知识,涉及运算符重载,内容偏多,我们下篇博文见。

希望读者看完有所收获!

  • 26
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

啊苏要学习

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

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

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

打赏作者

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

抵扣说明:

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

余额充值