C++对象之谜(封装篇)

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++对象之谜(封装篇)相关的知识,希望对你有一定的参考价值。

C++学了之后长期不用就会忘掉,本文简要记录C++对象封装的相关内容,以便需要时查阅。

这篇博客简要记录下C++对象的相关内容,以便回顾时使用。

C++类的定义

我们使用C++定义一个矩形(Rectangle)类,它的基本属性有:长(width),宽(width), 对矩形的基本操作有:计算其周长(circumference), 计算其面积(area). 矩形类的定义如下:

class Rectangle 
public:
	Rectangle(unsigned int width, unsigned int height) 
		m_width = width;
		m_height = height;
	;  // 构造函数
	~Rectangle() = default;  // 析构函数
	unsigned int get_circumference();
	unsigned int get_area();
private:
	unsigned int m_width;  // 矩形的宽
	unsigned int m_height;  // 矩形的高
;

unsigned int Rectangle::get_circumference() 
	return 2 * (m_width + m_height);


unsigned int Rectangle::get_area() 
	return m_width * m_height;

一般来说,类由一系列数据以及对数据的一系列操作构成,我们希望隐藏数据,而提供对数据的操作。C++publicprivate正起着这样的作用,使用public修饰的成员可以被外界访问,而使用private修饰的成员不可以被外界访问。

小提示:有的人习惯将类的成员变量名以m_开头,这样写看起来比较直观,但这也只是一种编程风格,我们使用了这样的风格,例如m_widthm_height.

对于类中的方法,可以将方法的具体实现直接写在类的声明中,例如这里的Rectangle(unsigned int width, unsigned int height); 也可以在类的声明外另写,不过此时需要在方法名的前面加上类的名称后跟两个冒号,例如这里的Rectangle::get_area, 表示这个方法是类Rectangle中的方法。

构造函数

如何根据类的声明创建一个对象(或者说这个类的一个实例)?这就是构造函数需要做的事情。构造函数是一个特殊的函数,在对象被创建时调用一次,通常用于初始化类中的数据成员或者做一些其它初始化的工作,它没有返回值,也没有返回值类型。它的声明由类名和参数列表构成:

ObjectName(type1 param1, type2 param2, ...);

例如这里的Rectangle(unsigned int width, unsigned int height).

可以定义多个构造函数,只要它们的参数列表不同,在调用时编译器会选择合适的构造函数。

析构函数

对象在销毁之前,会调用一次析构函数。因为有的对象可能占用了某些资源,例如占用了堆中的一部分空间(在销毁前应当释放空间)、打开了文件(在销毁前应当关闭文件)等等,或是完成其它的工作。它在对象被销毁前调用一次,它的声明通常由一个波浪号~和对象名构成,而且析构函数没有参数:

~ObjectName();

例如这里的~Rectangle(). 在C++中,如果某些特殊方法没有被定义,例如构造函数和析构函数,C++编译器会为这些特殊方法创建一个默认的版本。这里定义的矩形类很简单,在对象销毁前并不需要做额外的操作,因此可以直接使用默认的版本。为了使用C++编译器创建的默认版本,你可以什么都不写,或是像这里的写法:

~Rectangle() = default;

在后面加上 = default 从而告诉编译器我们将使用默认的版本。

创建第一个对象

好了,现在可以创建一个矩形对象。在此之前,我们更改下矩形类的实现,在调用构造函数和析构函数时,输出此函数被调用的语句:

	// 其它部分保持不变
	Rectangle(unsigned int width, unsigned int height) 
		std::cout << "调用构造函数\\n";
		m_width = width;
		m_height = height;
	;  // 构造函数
	
	~Rectangle() 
		std::cout << "调用析构函数\\n";
	;  // 析构函数

现在,在main()中创建一个矩形对象:

int main() 
	Rectangle rect(2, 3);
	std::cout << "矩形的面积为" << rect.get_area() << std::endl;

你会看到这样的输出结果:

调用构造函数
矩形的面积为6
调用析构函数

看到了吧,对象被创建时会调用构造函数,对象被销毁时会调用析构函数。

拷贝构造函数

C++中,可以根据一个已经存在的对象构造一个新的对象,例如:

Rectangle rect1(2, 3);
Rectangle rect2(rect1);
Rectangle rect3 = rect1;

可以利用rect1构造rect2, 这是通过拷贝构造函数实现的。
拷贝构造函数的声明如下:

ObjectName(const ObjectName& obj);

例如,矩形类的拷贝构造函数声明如下:

Rectangle(const Rectangle& rect);

可能你已经看出来了,从形式上来看,它和拷贝函数的形式是一样的,只不过参数是一个矩形对象罢了。通常来说,对象通过引用传递更高效(接下来我们将会看到),而且从概念上来说,一个拷贝构造函数不应当修改传入的参数(也就是用于构造新对象的原对象),所以这里的参数有&const修饰。

我们为矩阵类的拷贝构造函数添加具体实现:

Rectangle::Rectangle(const Rectangle& rect) 
	std::cout << "调用拷贝构造函数\\n";
	m_width = rect.m_width;
	m_height = rect.m_height;

下面用一个例子看一下拷贝构造函数何时被调用:

int main() 
	Rectangle rect1(2, 3);
	std::cout << "矩形1的面积为" << rect1.get_area() << std::endl;

	std::cout << "\\n";

	Rectangle rect2(rect1);
	std::cout << "矩形2的面积为" << rect2.get_area() << std::endl;

	std::cout << "\\n";

输出为:

调用构造函数
矩形1的面积为6

调用拷贝构造函数
矩形2的面积为6

调用析构函数
调用析构函数

总之一句话,将拷贝构造函数看作构造函数的一种,只不过这种函数比较特殊,我们单独拿出来强调下而已。

小提示:如果你需要实现自己的析构函数或是拷贝构造函数,那么绝大多数情况下你需要同时实现这两个,至于其中的原因,应该很容易想到。

赋值运算符重载

下面的这种写法也很常见:

Rectangle rect1(2, 3);
Rectangle rect2(1, 1);
rect2 = rect1;

要想支持这种写法,必须实现用于运算符重载的函数,这里我们需要重载赋值运算符,也就是=, 对于矩形类,其声明如下:

Rectangle& operator=(const Rectangle& rect);

这里,返回值的类型为Rectangle&是为了支持多重赋值,也就是rect3 = rect2 = rect1这样的写法。下面给出此函数的一个实现:

Rectangle& operator=(const Rectangle& rect) 
	std::cout << "调用赋值运算符重载函数\\n";
	m_width = rect.m_width;
	m_height = rect.m_height;
	return *this;

这里出现了一个新的关键词this. this是一个指向当前对象的指针,例如this->m_width表示使用此对象的m_width成员。因此,*this自然就是this指向的对象本身,这也是C++为我们提供的引用对象自身的方法。
好了,现在我们写一个用例测试下:

int main() 
	Rectangle rect1(2, 3);
	Rectangle rect2(1, 1);
	rect2 = rect1;

这个例子的输出为:

调用构造函数
调用构造函数
调用赋值运算符重载函数
调用析构函数
调用析构函数

小提示:Rectangle rect2 = rect1;没有调用赋值运算符重载函数,而是直接调用拷贝构造函数,相当于Rectangle rect2(rect1); . 由于这里的rect2此时刚被定义,如果有两个选择:(1)直接调用拷贝构造函数,通过rect1构造rect2;(2)首先调用构造函数构造rect2, 再调用赋值运算符重载函数通过rect1重新设置rect2. 你会怎么选?

禁止复制

如果你不希望创造的对象被复制,可以在拷贝构造函数和赋值运算符重载的声明中添加= delete, 表示你想要删除这个函数。例如:

Rectangle(const Rectangle& rect) = delete;
Rectangle& operator=(const Rectangle& rect) = delete;

从而,任何尝试调用此函数的行为,都会引发编译器报错。

慎用友元

正常情况下,外界无法访问对象的private成员,但是,如果被类认为是朋友(friend)的外部对象或是函数是可以访问private成员的。比如下面这个函数:

void print_size(const Rectangle& rect) 
	std::cout << "Rectangle(" << rect.m_width << ", " << rect.m_height << ")\\n";

它会访问矩形对象的m_widthm_height成员,C++编译器是不会允许这种情况的,因此会编译失败,但是,如果我们将函数print_size声明为友元,使用friend关键词,就可以了。在Rectangle类的定义中添加如下声明:

friend void print_size(const Rectangle& rect);

但是,这种行为会破坏数据的封装,使用时一定要谨慎!

小坑:小心潜在的类型转换

正方形也是长方形的一种,但正方形的长和宽相同,因此构造函数只需要一个参数即可,具体实现如下:

Rectangle::Rectangle(unsigned int width) 
	std::cout << "调用构造函数[正方形]\\n";
	m_width = m_height = width;

现在再看以下代码:

int main() 
	print_size(1);

这段代码的输出为:

调用构造函数[正方形]
Rectangle(1, 1)
调用析构函数

什么情况?print_size接收的不是一个Rectangle对象吗?这里传入的是1, 为什么也可以?通过输出我们可以看到,C++通过调用我们刚才定义的用于初始化正方形的构造函数,将1转化为Rectangle(1), 这里发生了隐式的类型转换。
对于具有一个参数的构造函数,都有可能出现上述的情况,可以通过在函数前添加关键词explicit禁止这种类型转换。在这里,也就是:

explicit Rectangle(unsigned int width);

现在重新编译这段代码,编译是不会通过的。

静态成员

简单地说,静态成员是与类绑定的成员,与具体的对象无关。通过关键词static定义静态成员,静态数据成员只能通过由static修饰的方法访问。
使用静态成员可以实现很好玩的事情。例如,记录目前存在多少个矩形对象,具体实现如下:

class Rectangle 
public:
	Rectangle(unsigned int width, unsigned int height) 
		m_id = get_id();
		print_id();
		std::cout << "调用构造函数[矩形]\\n";
		
		m_width = width;
		m_height = height;
	  // 构造函数

	explicit Rectangle(unsigned int width) 
		m_id = get_id();
		print_id();
		std::cout << "调用构造函数[正方形]\\n";

		m_width = m_height = width;
	

	Rectangle(const Rectangle& rect) 
		m_id = get_id();
		print_id();
		std::cout << "调用拷贝构造函数\\n";
		
		m_width = rect.m_width;
		m_height = rect.m_height;
	

	~Rectangle() 
		print_id();
		std::cout << "调用析构函数\\n";  // 析构函数
		num_rects -= 1;
	

	Rectangle& operator=(const Rectangle& rect) 
		print_id();
		std::cout << "调用赋值运算符重载函数(传入矩形对象的id=" << rect.m_id << ")\\n";

		m_width = rect.m_width;
		m_height = rect.m_height;
		return *this;
	

	void print_area() 
		print_id();
		std::cout << "面积为" <<  m_width * m_height << std::endl;
	

	static unsigned int get_rects()  return num_rects; ;

private:
	static unsigned int num_rects;  // 目前有多少个矩形对象
	static unsigned int get_id() 
		num_rects += 1;
		return num_rects;
	
	unsigned int m_width;  // 矩形的宽
	unsigned int m_height;  // 矩形的高
	unsigned int m_id;  // 矩形对象的id
	void print_id()
		std::cout << "ID(" << m_id << "): ";
	
;

unsigned int Rectangle::num_rects = 0;

这里,我们在矩形类中定义了一个static类型的变量num_rects用于记录创建了矩形对象的数量,在每个构造函数中,将num_rects1, 且在析构函数中,将num_rects1. 此外,使用static修饰的方法get_rects返回num_rects的大小。

注意:这里的静态变量num_rects在类的外部初始化,如果有头文件和对应的具体实现文件,那么静态变量应当在具体实现文件中初始化,这是为了防止头文件被多次包含,从而静态变量被多次初始化。不过,在C++17及以后,可以在类的内部初始化静态变量,不过要这样写:

class Rectangle 
private:
	static inline unsigned int num_rects = 0;

具体可以在网上查查其它资料。
有了静态属性,我们可以在每个矩形对象创建时为每个矩形分配一个id, 这样在输出时可以知道具体是哪个矩形对象输出的。总的实现如下:

class Rectangle 
public:
	Rectangle(unsigned int width, unsigned int height) 
		m_id = get_id();
		print_id();
		std::cout << "调用构造函数[矩形]\\n";
		
		m_width = width;
		m_height = height;
	  // 构造函数

	explicit Rectangle(unsigned int width) 
		m_id = get_id();
		print_id();
		std::cout << "调用构造函数[正方形]\\n";

		m_width = m_height = width;
	

	Rectangle(const Rectangle& rect) 
		m_id = get_id();
		print_id();
		std::cout << "调用拷贝构造函数\\n";
		
		m_width = rect.m_width;
		m_height = rect.m_height;
	

	~Rectangle() 
		print_id();
		std::cout << "调用析构函数\\n";  // 析构函数
		num_rects -= 1;
	

	Rectangle& operator=(const Rectangle& rect) 
		print_id();
		std::cout << "调用赋值运算符重载函数(传入矩形对象的id=" << rect.m_id << ")\\n";

		m_width = rect.m_width;
		m_height = rect.m_height;
		return *this;
	
	static unsigned int get_rects()  return num_rects; ;

private:
	static unsigned int num_rects;  // 目前有多少个矩形对象
	static unsigned int get_id() 
		num_rects += 1;
		return num_rects;
	
	unsigned int m_width;  // 矩形的宽
	unsigned int m_height;  // 矩形的高
	unsigned int m_id;  // 矩形对象的id
	void print_id() 
		std::cout << "ID(" << m_id << "): ";
	
;

unsigned int Rectangle::num_rects = 0;

有了这个类,我们就可以探究许多有意思的事情了。

连续赋值

C++中的连续赋值(例如rect1 = rect2 = rect3)具体是怎么工作的?下面这个例子:

int main() 
	Rectangle rect1(2, 3), rect2(1), rect3(3);
	rect1 = rect2 = rect3;

它的输出为:

ID(1): 调用构造函数[矩形]
ID(2): 调用构造函数[正方形]
ID(3): 调用构造函数[正方形]
ID(2): 调用赋值运算符重载函数(传入矩形对象的id=3)
ID(1): 调用赋值运算符重载函数(传入矩形对象的id=2)
ID(3): 调用析构函数
ID(2): 调用析构函数
ID(1): 调用析构函数

从输出可以看出,rect1 = rect2 = rect3具体可以分为以下两步:

rect2.operator=(rect3);
rect1.operator=(rect2);

执行是从右往左进行的,而且表达式rect2 = rect3的返回值是rect2, 从而可以继续执行调用rect1 = rect2, 这也是在赋值运算符重载函数中,需要返回*this的原因。

对象传参

对象作为函数的参数,按值传参与按引用传参有何区别呢?下面我们看一个例子:

void call_rect_by_value(Rectangle rect) 
	rect.print_area();

在如下的程序中:

int main() 
	Rectangle rect(2, 3);
	call_rect_by_value(rect);

输出为:

ID(1): 调用构造函数[矩形]
ID(2): 调用拷贝构造函数
ID(2): 面积为6
ID(2): 调用析构函数
ID(1): 调用析构函数

可以看出,按值传参时,调用了一次拷贝构造函数。而按引用传递的参数:

void call_rect_by_ref(Rectangle& rect) 
	rect.print_area();

同样使用上面的测试程序,只不过将call_rect_by_value换成call_rect_by_ref, 输出为:

ID(1): 调用构造函数[矩形]
ID(1): 面积为6
ID(1): 调用析构函数

按引用传递时,没有创建多余的对象,因此效率更高。

事实上,引用的底层必然是通过指针实现的,不过相较于指针,用户可以采用更简洁方便的写法。因此,引用是个好语法!

返回对象的值也会调用一次拷贝构造函数,而返回对象的引用并不会,可以自己试一下,在此不再赘述。

C++程序编译之谜——简单还是复杂?编译到底有哪些步骤?

通常我们用IDE写完一个程序后,点击编译按钮的时候,内部到底发生了什么?为什么会生成一个可执行文件?这个过程到底有哪些步骤呢?是很简单还是很复杂呢?这篇文章,我们把这些事情讲清楚。


首先要明确一点,编译只是一个统称,编译的整个过程有预处理、编译、汇编和链接的过程

我们给出一个特别简单的程序

//test.c#include <stdio.h>#define max 5int main(){ printf("max = %d
", max); return 0;}

1、预处理

预处理阶段的指令一般都是以#来开头的,替换#include包含的头文件,替换#define定义的宏,删除注释,去掉#ifdef不符合条件的那一部分,所有#开头的代码都会在预处理阶段完成处理。

预处理命令:gcc -E test.c -o test.i

这里-E的作用是让程序在预处理完成之后就停止,为了方便我们后面的观察。我们在当前目录下ls,就可以看见多了一个test.i的文件,打开它可以看到很多变量、函数等等的声明,这些都是stdio.h这个头文件展开的结果,拉到最后,可以看到我们定义的宏max被替换成5了。

C++程序编译之谜(三)——简单还是复杂?编译到底有哪些步骤?

2、编译

大学如果学的是计算机专业的童鞋一定会学过一门《编译原理》的课,这门课几乎会把很多大学生折腾得死去活来。而这个编译的过程也正式编译原理里面介绍的内容,包括词法分析、语法分析、语义分析、程序优化等等一系列的过程,这些都是编译器的核心内容,如果你想开发编译器,这个过程你要非常非常的精通!这个过程就是把程序编译成更接近机器语言的汇编语言。平时我们用IDE编译的时候,经常看见的错误和警告,一般都是在过程发出的。

编译命令:gcc -S test.i -o test.s

这里-S的作用是让程序在编译完成之后就停止,为了方便我们后面的观察。我们在当前目录下ls,就可以看见多了一个test.s的文件,打开它看到的一大堆汇编指令。这些指令,我根本看不懂,说实话,没有接触过汇编语言的人,几乎都是看不懂的。但是如果你是想在编译器这个底层领域翻江倒海的话,汇编语言是必须要懂的。

3、汇编

汇编语言有些专业人员看得懂,但是计算机是根本就看不懂的。计算机看得懂的仅仅只有010101这种机器语言,所以我们还要将汇编语言转换成机器语言,至于这个过程怎么转的,不在本文的讨论范围,也讨论不了,因为我也不知道。这些都是那些非常厉害的大神的研究领域,真不是我夸大这个难度,能开发出商用编译器的人,至少在计算机领域绝对都是逆天的天选之子。

汇编命令:gcc -C test.i -o test.o

我们在当前目录下ls,就可以看见多了一个test.o的文件,打开它看到的一大堆乱码,实际上这些都是二进制命令,而这些命令才是计算机能看得懂的。

4、链接

二进制文件虽然计算机可以看懂了,但是如果你的源文件中用到了其他自己写的头文件的函数,或者是第三方静态库动态库,这时候还需要进行把它们链接起来生成可执行文件,才可以正确的被执行。

链接命令:gcc test.o -o test

但是如果引用的头文件是C/C++语言级别自带的话,换种说法就是,只有一个源文件,貌似不需要进行链接这一步,直接运行上面编译生成的.o文件也可以。反而进行链接操作的话会报这个错误,原因我暂时也没找到,如果知道的朋友欢迎留言评论。

/opt/rh/devtoolset-9/root/usr/libexec/gcc/x86_64-redhat-linux/9/ld: error in test.o(.eh_frame); no .eh_frame_hdr table will be created

以上就是编译的几个步骤,只有比较清晰地掌握好每个步骤,才能真正地把编译的整个流程搞清楚。当然,你也可以用一步到位的方式进行编译:

gcc test.c -o test

这样可以直接生成可执行文件。

更多精彩内容,请关注公众:一点月光


相关编译的文章请阅读:




以上是关于C++对象之谜(封装篇)的主要内容,如果未能解决你的问题,请参考以下文章

Spring AOP失效之谜

C++ 封装,继承,多态总结

大括号之谜:C++的列表初始化语法解析

C++入门篇之类和对象上

大括号之谜:C++的列表初始化语法解析

C++程序编译之谜——简单还是复杂?编译到底有哪些步骤?