C++入门:类与对象(1)

本篇作为学习类与对象后的一些记录与感想,不适用于第一次接触类和对象的朋友。

目录

1.面向过程和面向对象

2.类

2.1类的基础知识

2.2 类中的访问限定符

2.3类中的函数声明定义分离(如何在不同的文件中实现同一个类)

2.4类的封装

2.5类的实例化

2.5.1对象中成员的存储方式

2.5.2对象的大小

 2.5.3空类的大小

2.6this指针

3.类的默认成员函数

3.1构造函数

3.2析构函数

3.3拷贝构造

3.3.1显式实现拷贝构造及无穷递归之谜

3.3.2自动生成的拷贝构造 


1.面向过程和面向对象

C 语言是 面向过程 的, 关注 过程,分析出求解问题的步骤,通过函数调用逐步解决问题。
C++ 基于面向对象 的, 关注 的是 对象 ,将一件事情拆分成不同的对象,靠对象之间的交互完成。但由于CPP是兼容c语言的,所以c++又不是完全的面向对象语言,有时候会包含c语言的面向过程的逻辑。反观Java,就是完全的面向对象语言,甚至连main函数都是包含在类中的。

2.类

2.1类的基础知识

C语言中最适合的过渡知识点就是结构体struct

C++ 中,结构体内不仅可以定义变量,也可以定义函数。 比如:
    之前在数据结构初阶中,用 C 语言方式实现的栈,结构体中只能定义变量 ;现在以 C++ 方式实现, 会发现 struct 中也可以定义函数。
除此之外,cpp中的类名称可以直接作为这个类的名字而出现,不需要再重复书写struct。

                                           

但是在c++中,更喜欢用类(class)来替代结构体 

class className
{
// 类体:由成员函数和成员变量组成
};  

类比一个结构体可以实例化很多个结构体,1个类也可以实例化 N个对象。

类中既可以定义变量,也可以定义函数

这样就能有效缩短命名。不再需要区分是QueuePush还是StackPush,只需要在各个类中写一个相应的push函数即可。函数整体变短了就是好事。

2.2 类中的访问限定符

我们要对一个封装好的类按照“蛋图”的思想去理解。一些数据、函数是放在蛋黄中的,在类之外是无法访问这些数据和函数的;一些函数、接口是提供给你的可以使用的,在蛋的外层,在类之外是可以访问这些数据和函数的。 

首先,在命名方面,若我们初始化了一个类叫作Stack,

class stack st1或者stack st1均可以定义出一个新的该类型的对象。

(在后文中会了解到,stack st1;相当于调用了构造函数)

我们通过访问限定符来限制各个函数、变量等做接口或者“蛋黄”。控制哪些能访问,哪些数据不能访问。

如果不写访问限定符,默认class的访问权限是私有的(也就是无法通过  类  外来使用、调用)。因为要兼容c,struct默认的访问是共有的

一般情况下,成员变量设计成私有,成员函数设计成公有。


类中变量的命名: 

          

如果内域和外部参数的名字重合(如上图),就不太合适。因为会优先搜索局部域的内容,参数year作为局部域被使用,出栈帧后销毁,而private中的成员year没有被赋值。

所以cpp推荐将成员变量加一个特殊的标识符如 前置_ year  或 后加 year_   或者 m_year,其中m表示member,以此来避免歧义。

class Date {
public:
	void DateInit(int year, int month, int day) {
		_year = year;
		_month = month;
		_day= day;
	}
private:
	int _day;
	int _month;
	int _year;
};

2.3类中的函数声明定义分离(如何在不同的文件中实现同一个类)

类定义了一个新的作用域 ,类的所有成员都在类的作用域中 在类体外定义成员时,需要使用 :: 作用域操作符指明成员属于哪个类域。
比如Queue中有一个Push、Stack中有一个Push,类域就能很好的分辨这两个函数
#include "class.h"
using namespace std;

class Person {
private:
	int _age;
	char _name[20];
	char _gender[6];
public:
	void PrintPersonInfo();
};

      先在.cpp文件中做好准备工作   ,并在public中写出函数的声明。

      接着我们到相应的class.h中去实现该函数

由前文中域的概念我们可知,类有类域,类域中的成员函数或者成员变量只能在类中搜索。PrintPersonInfo是一个类域中的函数,应当使用 类名 :: 函数名,才能找到该函数。

      此处也能观察到在private中的gender/age/name的左下角都有一个锁的标识,表示这是一个被private修饰了的变量。

         

void Person::PrintPersonInfo() {
	cout << "_name" << "_gender" << _age << endl;
}

有了Person::之后,就相当于是在类的内部写函数了,不需要再在"_name"  "_gender"等前面再写一次Person:: 

tip:任何一个花括号里的部分都是类域,包括if、while等语句或一个函数的花括号。

至此为止,我们了解到的域的概念有:类域、全局域、局部域、命名空间域

2.4类的封装

在编程语言中,如果函数和对象不限制不封装,非常要求使用者的“素质”,就比如此处的top到底是指向栈顶的下一个还是栈顶?初始化时让top=-1还是0?

                                                   

C语言就像这样,是“露天的”,无约束的;而c++经过封装之后,就会更有秩序、更便于使用者管理

2.5类的实例化

定义类的时候,其实是在“声明”

用类类型创建对象的过程,称为类的实例化
类和对象是一对多的关系

类就像是设计图, 类实例化出对象就像现实中使用建筑设计图建造出房子

类就是一个模型,类实例化出对象就是根据这个模型来产出的。因此,类和对象是一对多的关系

2.5.1对象中成员的存储方式

    每个对象中成员变量是不同的,但是调用同一份函数,如果按照每个实例化的类中都有所有的函数的方式存储,当一 个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。
    所以将类成员函数都保存到公共代码段中,类成员变量保存到每一个对象中

2.5.2对象的大小

可通过sizeof证明, 当计数一个对象的大小时,只算了成员变量,未计算成员函数
(被static修饰的成员变量也不会被计入大小)
                                   
(根据内存对齐规则,最大的为int,四个字节,数组按照元素类型计算,_gender中6个char需要2(1.5)个int类型的空间,_name刚好五个,共5+2+1=8个,8*4=32个字节)
“通过建筑房屋的图纸也能计算出房屋的大小”
 为什么要对齐:
由于硬件问题,为了减少读取的次数,只能整数倍整数倍的读。 对齐的本质是一种空间换时间的做法,可以通过#prama 修改默认对齐数:
# prama pack(1)

 我们在用同一个类Stack定义两个对象:st1和st2,Stack类里面实现一个Init函数,通过汇编代码观察st1和st2在调用Init函数时call的函数地址:

            
对象是两个对象,但是大家调用的函数地址是一样的。
成员变量都会被各当做各的,但是成员函数会存到公共区域。
不要将此概念和访问限定符中的概念混合 , 是否是类成员函数或类成员变量与是否被private或public修饰无关。只要是函数就会被放入公共代码段,只要是变量就会放在实例化的类中,计数类大小时也只会计数该部分。


 2.5.3空类的大小

空类的大小是1:

定义成功就得开空间,就得有地址

给他开一个空间但是不存有效数据,有这一个空间,表示这个对象被开出来了。
class A3 {

};

比如我们用A3定义了一个a3和b3,如果类的大小是0,那么如何说明a3和b3到底是被开出来了还是没有开出来?


class A2{
public:
    void f(){
       ;  
   }
}

同理,A2的大小也是1,因为成员函数不会被计入大小的计算

2.6this指针

class Date {
public:
	void Init(int year=2000, int month=1, int day = 1);
	void Print() {
		cout << _year << "." << _month << "." << _day << "." << endl;
	}
private:
	int _day;
	int _month;
	int _year;
};

我们以Date为例,引出this的概念。

做以下调用:

                                              

前文中我们提到,Print函数和Init函数是存放在公共代码段的,请问两次调用是如何分别找到d1和d2的_day、_month、_year的呢?

隐含的this指针:

    C++ 中通过引入 this 指针解决该问题,即: C++ 编译器给每个 非静态的成员函数(没有被static修饰的函数) 增加了一个隐藏 指针参数this ,让该指针指向当前对象 ( 函数运行时调用该函数的对象 ) ,在函数体中所有需要访问 成员变量 的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递该指针,编 译器自动完成 。类成员函数在使用时,都会默认再传一个“调用该函数对象的指针”的 形参 ,即this指针(一般是第一个传)

注释部分是编译器实际上的运行逻辑: 

                                                   

cout语句中涉及成员变量的也有一个this指针(指针应该使用符号:-> 来找到对应的成员):

 总体来看:

this是指向对象,用来访问成员变量的,因为对象中也只有成员变量。

为什么叫“隐含的this指针”?:

首先,实参和形参的位置不能自己手动写上一个this,否则会编译不通过。编译器会自己加上this。但是在对象里面可以使用this指针来找到自己。 

其实Date* this写作Date* const this更为贴切。

const在*右边,表示this指向的空间是被锁死了的this是一个固定指向的指针的名字,this不可修改。


 经典试题:

// 1.下面程序编译运行结果是? A、编译报错 B、运行崩溃 C、正常运行
class A
{
public:
 void Print()
 {
 cout << "Print()" << endl;
 }
private:
 int _a;
};
int main()
{
 A* p = nullptr;
 p->Print();
 return 0;
}

提示:空指针存在时不报编译错误,而是在需要对空指针解引用时发生运行错误(而不是编译错误)。

单纯的对this赋空是不可以的(因为存在的this都被const修饰过),可以强转直接赋空,不过一般不进行这样的操作


我们先通过p!=nullptr时的情况来看: 

                                                    

p的意义:1.让编译器知道p是A类型的指针,去A类型的公共函数代码段中找成员函数。(而不是通过this指针去对象中找)

                 2.将p的指传给this

所以,并不是所有的“->”都会执行解引用。如步骤2,单独传一个空指针是不会报错的

综上所述,并没有真正的通过p这个空指针去找对象中的成员变量,甚至可以打印出this的值:

选c,可以正常运行。

对题目稍加修改:

class A
{ 
public:
    void PrintA() 
   {
        cout<<_a<<endl;
   }
private:
 int _a;
};
int main()
{
    A* p = nullptr;
    p->PrintA();
    return 0;
}

此时会运行崩溃,欢迎将原因写在评论区!


this指针存放在哪里?

 
通过观察Init函数的调用的汇编代码,我们发现this会被当作形参传入。
所以this会被放在栈或者寄存器上(不同编译器的实现不同)

3.类的默认成员函数

如果一个类中什么成员都没有,简称为空类。
空类中真的什么都没有吗?并不是,任何类在什么都不写时,编译器会自动生成以下 6 个默认成员函数:初始化、清理、拷贝、复制、取地址、重载
默认成员函数:用户没有显式实现,编译器会生成的成员函数称为默认成员函数。

3.1构造函数

       大家在使用C语言实现各种数据结构时,是否经常出现忘记调用Init来初始化创建的变量这一情况?

不初始化,会导致程序崩溃或变量中是随机值,发生错误。

为此,我们使用构造函数来解决、优化这个问题。

从此,Init可以退出历史舞台了。

特点:自动调用,无返回值(不需要写void),函数名与名相同。

构造函数虽然名字中带构造二字,但其本意为初始化成员变量(类似于之前Init函数的功能)

而不是给变量开空间。

但由于在用类的名称去实例化一个对象时就会有分配空间的作用,所以在结果上,是既开了空间又初始化了变量。

使用构造函数:

     在主程序中我们只用类名实例化一个类,没有调用与Init有关的其他函数:

                                       

构造函数的重载:

可以写多个构造函数,可以有多种初始化的方法。

如上文中我们实现了无参的构造函数(尽管是不需要传参的函数调用,但我们依旧不使用括号),现在还可以实现带参的构造函数

class Date {
public:
	Date() {
		_year = 2000;
		_month = 1;
		_day = 1;
	}
	Date(int year, int month, int day) {
		_year = year;
		_month = month;
		_day = day;
	}
	void DatePrint() {
		cout << _year << "." << _month << "." << _day << endl;
	}
private:
	int _day;
	int _month;
	int _year;
};

 带参的构造函数在使用时非常特殊:

                                          

d1是被无参构造函数初始化的,d2是被带参构造函数初始化的。 

带参的构造函数在使用时非常特殊,类型+对象名+参数列表。

那为什么无参构造函数在调用时不能加一个空括号呢?

第一,不加空括号,直接类型名+对象名更符合我们印象中的对一个对象的实例化。

第二,加了括号:Date d1();会与定义一个叫作d1、返回类型是Date、没有参数的函数的指令发生冲突。

我们再对构造函数进行优化:

          

使用全缺省的参数设置方式,这样,只需要显式实现一个函数就既满足了无参调用,也满足了含参调用。

两种函数自然构成重载。

不过此处的语句:Date d1;会发生歧义,因为当你决定不传参数时,编译器会不知道该调用哪一个(无参还是不传参数的全缺省)。

所以,一般的惯例下,每一个类都使用全缺省的构造,非常好用。提醒:若将全缺省函数的声明和定义分离,请只在声明处写全缺省的参数,否则会报错。

 如果我们没有显式写构造函数呢?

如果我们不写,编译器会任然以类名为构造函数名,自动生成一个无参构造函数。

class Person {
private:
	int _age;
	char _name[20];
	char _gender[6];
public:
	void PrintPersonInfo();
};

 观察一下初始化成什么样了:

 原因如下:

C++ 把类型分成内置类型 ( 基本类型 ) 和自定义类型。内置类型就是语言提供的数据类
型,如: int/char/指针... ,自定义类型就是我们使用的 class/struct/union 等自己定义的类型。
编译器自己生成的构造函数没有规定是否要处理自定义类型成员变量(有的编译器会处理,有的编译器不会处理),对于自定义类型变量才会调用他的 “不传参就能使用的构造(默认构造),包括无参构造和全缺省参数构造或者该自定义类型自己生成的构造”

注意!!!

        如果没有“不传参就能使用的构造(包括无参构造、全缺省参数构造、自动生成的构造函数)”,会报错。也就是说,只要不是自己只写了显式的需要传参的构造即可。  如果你写了显式的需要传参的构造,就会报错,除非使用“初始化列表”,这在之后的篇章中会讲解
  这是一个类似于递归的方法(但是不需要回溯),最小子问题就是调用无参构造或者只剩内置类型从而不再做处理。
       由此我们可以得出结论,内置类型无论怎么写都不会报错。

自定义类型自动初始化的实例:

之前的C语言实现了用两个栈实现一个队列。

如果我们的类Stack中是有合适的“不传参就能使用的构造”,我们将两个栈当作自己的成员变量                                              

那么,我们只需要实例化一个MyQueue

MyQueue q;

 MyQueue会自动调用其编译器自动生成的无参构造,_pushst和_popst都是自定义类型,所以编译器又可以继续自动调用他们两的“不需要参数的构造”,从而成功初始化两个栈。

(不传参数的构造函数就是默认构造)

自定义类型的尽头是内置类型,为了弥补内置类型类型任然不能有效初始化的问题,C++组委会作出如下补丁:

                                    

如果变量处和构造函数处都写,则:

       每一个被实例化的类,会先将自己的变量按照private中的补丁写法赋值,接着再按照构造函数中的数值去赋值。   这样的根本原因也与初始化列表有关,补丁中的参数都会传给初始化列表,但是是先走初始化列表、再走花括号内容(这一部分内容会在之后讲解)

       

总之:不要偷懒,多自己实现构造函数


3.2析构函数

同理,在C语言中,经常忘记调用Destroy(),导致内存泄漏,内存泄漏危害很大,但是不会报错。对象生命周期结束时,C++编译系统系统自动调用析构函数

析构函数特点:

1. 析构函数名是在类名前加上字符 ~
2. 无参数无返回值类型。
3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载( 析构没有参数,不能重载(生成的被修饰过的函数名都是一样的,详见主页中的 C++学习笔记(二)),所以只能有一个析构函数 )
4. 对象生命周期结束时, C++ 编译系统系统自动调用析构函数

栈上的变量在栈帧结束时会自动销毁,析构函数的作用主要是资源清理,如(malloc或者new开出的堆上的空间和fopen打开的文件)

我们用以括号匹配的问题来举例:

如果没有使用malloc或者fopen等开辟空间,如我们上述的Date类,就不需要使用析构函数(上文中一直使用的Date类,在销毁时就不需要使用析构函数,变量都开在栈上,栈上开出的这些变量会自动销毁)

   析构函数相对于构造函数就简单很多。没有参数、没有返回值,在生命周期结束之后自动调用。相似于构造函数,如果我们没有自己实现析构函数,编译器会自动生成析构函数,不会处理内置类型(内置类型本身也会自动释放),调用自定义类型的析构函数。如果有需要显式清理的资源,如在栈或队列中malloc了一个数组,链表中malloc了一个节点,就需要显示清理(注意判空防止对空对象进行free)。


3.3拷贝构造

现在我希望创建一个与d1的值一样的d2,希望进行一次"CV"

拷贝构造,即拷贝初始化,用同类型的对象初始化新对象。

            

1. 拷贝构造函数 是构造函数的一个重载形式(一种特殊的构造)
2. 拷贝构造函数的 参数只有一个 必须是类类型对象的引用 ,使用 传值方式编译器直接报错
因为会引发无穷递归调用(会在后文中解释)
拷贝构造函数 只有单个形参 ,该形参是对本 类类型对象的 引用 ( 一般常用 const 修饰 ) ,在用 已存
在的类类型对象创建新对象时由编译器自动调用

为什么必须使用引用?

若不使用引用,会导致无穷递归:

3.3.1显式实现拷贝构造及无穷递归之谜

Date(Date& d) {
	_year = d._year;
	_month = d._month;
	_day = d._day;
}

  (执行拷贝构造之前,依然会先执行声明成员变量时(也就是一般情况下在private处写的)缺省的参数,可通过调试观察顺序)

如果不使用&,而是直接传值:

编译器非常聪明,将这样可能发生的无穷递归视为语法错误,所以有红线报错:

我们如果不加&,那么就涉及一个传值传参的问题,又由于传值传参对于自定义类型要调用拷贝构造,因此无穷递归产生 

自定义类型的传值传参需要调用拷贝构造,这是一种规定,我们也可以检查如下。

class Date
{
public:
	/*Date()
	{
		_year = 1900;
		_month = 1;
		_day = 1;
	}*/
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(Date& d) {
		cout << "调用拷贝构造" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	void Print() {
		cout << _year;
	}
private:
	int _year = 1;
	int _month = 1;
	int _day = 1;
};

void f(Date d) {
	cout << "f()" << endl;
}

int main() {

	Date d1;
	f(d1);//传值调用f()
	return 0;
}

                            

因此,在调用f()之前确实调用了拷贝构造,证明了使用传值调用需要先调用拷贝构造。

规定:自定义传值传参需要调用拷贝构造

                              

假设我们希望使用拷贝构造将d3按照d2的值进行实例化,那么就需要先将d2的值传给d。为了将d2的值拷贝给d3,新的拷贝构造产生:先要将d按照d2的值进行实例化,再次调用新的拷贝构造.........

因此,无穷递归产生。

为什么用引用而不是用指针?

使用指针会造成无穷递归吗?

指针当然不会造成无穷递归(其底层与引用相同)

规定:使用引用而不用指针。拷贝构造的定义就是使用引用,使用指针当然没有问题,但由于定义,使用指针就是一个普通的构造函数(并且你还需要自己实现这样一个普通的构造)

为什么说经常规定要在形参中加const? 

                              

    为了避免赋值顺序给反的问题,我们使用const来进行限制(不要与学习笔记二中的隐式转换带来的权限问题因此必须用const混淆,拷贝构造接受参数时不使用const修饰也没有发生权限的放大)。

                               

我们在Date(const Date& d)的实现后,用d2实例化d3

Date d3(d2);   由此,又用到了C++入门(以c为基础)——学习笔记2-CSDN博客中所讲到的关于权限的知识:

d2是可读可写的,用const修饰d就缩小了d的权限因而d只是可读的,因此在权限层面也是符合标准的

并且,d3就是此处的this指针:

                               


为了方便使用,也可以直接使用等号来执行拷贝构造(本质是一种运算符重载):

                           


3.3.2自动生成的拷贝构造 

如果没有手动实现拷贝构造,会自动生成拷贝构造。

默认的拷贝构造函数对象按内存存储按
字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
类似于memset的逻辑,自动生成的拷贝构造会按照一个字节一个字节的拷贝,如果一个即将被拷贝构造的对象中有需要管理的资源(如一个顺序表、一个链表、一棵树等),这样的资源通常只存一个指针在对象中,这样就会把一个同样的指针指向的同一块空间交给两个不同的对象,两个对象都可以分别访问这一个空间,对其增删查改,最后还会调用两次析构,埋下非常大的坑。

导致析构两次:(对同一个空间free两次会导致崩溃)

tips:深拷贝与浅拷贝是cpp中非常经典的概念,正在学习的各位应该当熟练理解。


因此,需要深拷贝的类的拷贝构造需要我们自己显式实现

一定有同学会问:如果该对象中存放的不是int*  ,  而是一个int arr[100]这样的数组呢?

这样的数组是开在每一个对象中的,所以也只需要浅拷贝(只是这种行为本身不太聪明)。 

在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定
义类型是调用其拷贝构造函数完成拷贝的。因此MyQueue等只有两个栈作为变量的类,也不需要自己实现拷贝构造。  

所以,一般情况下:需要写析构的类(有资源管理),就需要显式实现拷贝构造

由于类可能出现的层层嵌套,针对每一层,若当前层有需要直接管理的资源而没有实现相应的深层拷贝构造函数,就会出问题。

  • 32
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值