C++:面向对象

继承与多态

继承

  • 继承一个父类之后,该类的空间是父类的内存+本类中多出的属性空间即为本类的总内存空间。
  • 继承的时候以什么权限将父类的属性继承下来,继承的权限影响父类中继承下来的属性,假如:
    • 我们以public的权限进行继承父类,继承下来的父类成员最大的权限就是public,小于public的不变
    • protected的权限继父类,继承下来的成员最大的权限就是protected,即public的下降为protected,而小于或等于protected的不变
    • private的权限继承父类,继承下来的成员最大的权限就是private,即public和protected下降为private,等于private的成员不变。
    • 总结:以什么权限继承的父类,那么就是我们将父类成员继承到我们这里的时候的最大权限。

这里的继承名字起的很好,当我们链式继承的时候,即A继承B,B继承C,A-B-C-D-E…这样一直继承下去的时候,当全部都使用public继承那么就代表大家都毫无私心毫无保留的公开继承父类的所有属性大家都知道,但凡有层使用了private继承,那么这一层后面的属性都不能直接的访问到爷爷辈的所有除了private外的属性了。因此就像传授武林秘籍一样,我继承的时候不打算分享给子孙们就私有化祖先继承下来的武林秘籍,那么在这一层后武林秘籍可能就会以某种形式慢慢消失在大众眼中。

  • 子类父类同名属性访问方式
    访问子类this.属性名 / this->属性名
    访问父类this.父类名::属性名 / this->父类名::属性名
    访问父类的方式就是给一个属于哪一个父类的作用范围,就跟函数在类内声明类外定义一样的方式告知是哪一个类的成员。

  • 访问父类的静态成员

    • 学过Java的都知道我们访问静态成员是通过类名点出来的即可,那么在C++中肯定也是类似,但是我们C++中不是点出来,而是继续使用类名::静态成员名,即是通过类名访问的。表示通过父类的方式访问静态成员。
    • 左边第一个双冒号的第一个类名表示通过该类名访问的成员
    • 静态成员也是成员,依旧可以使用访问普通成员属性一样访问该静态属性。(即:访问子类this.属性名 / this->属性名,访问父类this.父类名::属性名 / this->父类名::属性名
    • 当我们要访问父类的静态成员的时候可以
      1:父类名::静态成员名,这种方式叫做直接通过父类访问父类的静态成员
      2:子类名::父类名::静态成员名,这种方式叫做通过子类访问父类的静态成员。(其实很好理解,只要从左往右读就行,即:访问子类的父亲的静态成员)
  • 子类的静态成员函数与父类的任意一个函数重名

    • 使用子类::静态函数的时候,只能访问到子类的静态成员函数,子类的同名静态成员函数覆盖了父类的所有同名成员函数,想要访问到父类的成员函数就必须要通过父类名进行访问,即使你非得使用子类名,你也要使用子类名作用下的父类名来访问:
      子类名.父类名::成员函数
      子类名->父类名::成员函数
      子类名::父类名::成员函数

      or
      父类名::成员函数
      • 注意:在子类中静态的成员函数名与父类的任何一个函数发生函数名相同,子类的静态的成员函数名相同之后会直接将父类的所有同名成员函数都覆盖掉。
    • 子类静态属性与父类同名属性也是如此,同名的父类成员属性,子类直接访问属性就是直接访问自己的,希望访问到父类的属性就必须通过父类名的作用才能访问到。

    只有这个父子类之间, 子类覆盖父类的条件为子类的静态的函数或者属性与父类的重名了就会把父类的覆盖掉,子类想要访问就只能通过父类名作用下访问。
    这里主要是解释子类静态函数与父类函数发生同名的时候,因为很多时候会以为二者发生重载,但其实不会,子类已经把父类同名的隐藏掉了,发生不了重载。

    总结:

    • 父子类同名静态函数,在子类范围中只能访问到子类的,因为子类里面把父类与之同名的所有函数都隐藏掉了
    • 若子类希望访问到父类的,就必须通过父类作用范围才能访问到父类的
      同类可以发生静态的函数重载,但是父子类不可以,可一旦同名,子类希望访问到父类的静态同名的函数就只能通过父类名访问。

继承的构造与析构

开门见山:先构造父类再构造子类,先子类触发析构销毁再销毁父类

  • 构造顺序就很好理解了,先有父亲再有儿子
  • 析构顺序可以理解成香火延续,香火是否断续就取决于晚辈那一代,就是说子类如果销毁了,那就代表父类也销毁了,香火就销毁了,所以销毁这个子类的时候是先销毁子类再销毁父类,因此析构函数是先子类析构后父类析构

虚继承

虚继承是为了解决菱形继承,就是像菱形的样子的继承链。
Animal
↓ --------------↓
Sheep -----Tuo
||
SheepTuo
假设羊和骆驼结合成为羊驼
Animal中的一个属性m_Age继承给Sheep和Tuo后这俩都被SheepTuo继承了,那么Animal中的属性就出现了二义性,不知道羊驼的m_Age应该是从Sheep中来还是从Tuo中来的,这就是二义性。

  • 解决办法就是:通过虚继承
  • 虚继承
    虚继承就是Sheep和Tuo中虚继承了Animal的时候,Animal中的所有成员在分配给虚继承的子类中都是将其成员的指针分配给子类。
    那么这些指针如何维护:在每一个虚继承子类中都会有一个虚继承指针,这个指针指向的就是一个虚继承表,这个虚继承表维护的就是从基类继承下来的属性,用指针指向该属性,指针类型占4个字节。然后我们的虚继承表中记录的是每个虚继承指针到虚基类继承下来的属性的偏移。(本人目前还没搞懂偏移是怎么算出来的)。
    整体来说,虚继承表记录偏移然后找到指向虚基类的成员地址空间,不用像普通继承一样继承一份拷贝的值下来,虚继承子类在被别人继承的时候,虚继承子类的子类在菱形继承中就是继承了一份,因为两个虚继承了虚基类的地址都是指向同一份,因此,无论是修改哪一个虚继承子类范围下的属性都是同一份,都是属于基类的同一份,在虚继承下来的子类后再继承该子类就会把他的指针继承下来,即将该虚继承指针继承下来。

总结:虚继承是用来解决菱形继承的,一般不推荐在开发中多继承开发,比如Java语言就直接舍弃了该特性,我目前没有找到菱形继承的妙用,暂且理解为是C++的一个缺陷把,毕竟人家Java用C++开发的也就直接舍弃了这个多继承特性了。

多态

整个面向对象中多态是最妙哉的出现,前面感叹面向对象的继承妙用,这里的多态真的是神一般的存在了,可谓是面向对象的一个最最重要的特性。开发过程中使用多态能够表现出多种多样的现象解决多种需求。

❀虚/纯虚函数❀

  • 虚函数是用来给子类重写的

  • 虚函数: 一个类中有一个虚函数就代表子类中如果将父类中该函数进行重写了在实例化该子类的时候用父类调用的就不是父类的虚函数而是在子类中重写的该函数。(即:父类指针 变量 = new 实例化子类)
    注意:虚函数是可以实例化的

  • 纯虚函数: 一个类中有一个纯虚函数的时候该类就不能够实例化,子类中也必须要将其纯虚函数重写才能把该子类实例化。总的来说,不管子类如何操作,只要子类将父类中的纯虚函数进行重写了,那么该子类就可以实例化,但是如果说子类中忘记实现了父类中的一个纯虚函数都不行都不能进行实例化了,代表继承下来的这个纯虚函数还是在你儿子手上并没有得到实现,所以如果没有特别需求一定要有多少就实现多少。

    当然从我理解层面上说,比如我要设计一个层级关系的系统就可能需要这种一级一级的进行放行某些函数,一个类一个类这样进行继承下去的时候慢慢实现纯虚函数实现层级功能的分配。

  • 细节:虚函数需要实现体,纯虚不用实现体只要=0即可,虚函数的类可以实例化,纯虚函数的类不能实例化。

    class Animal{
    public:
    	virtual void show() = 0; //纯虚函数千万别加实现体
    	virtual void show2(){
    	} //虚函数需要实现体
    };
    class dog:public Animal{
    public:
    	void shwo(){} //实现体
    	//void show2(){}//虚函数可以不实现,不影响dog的实例化
    };
    
    • 父类Animal有纯虚函数,那么父类不可以进行实例化,如果子类继承父类但是没有重写纯虚函数,那么该类也属于不可实例化的类对象。

虚析构/纯虚析构

  • 虚析构
    虚析构的作用是当我们实现多态的时候,即:父类指针接受子类对象时,我们只能delete父类指针变量,代码只会走父类的析构函数,不会走子类的析构函数。因为父类指针虽然指向子类的对象,但是你delete的意思其实本质还是delete父类指针,所以我们没有用虚析构的话,就代表我们没有让子类重写我们的这个析构函数,这个函数就直接会从父类开始找,就只有走了父类的析构,子类没有碰到。
    (简单理解就是:父类没将析构写成虚的,子类无法重写,那么多态的时候就会直接走父类的函数(析构),走不到子类的析构)

    • 解决办法:父类中将析构函数写成虚析构,在释放指针的时候就会走子类的析构。(注意:这里说的是才会走,意思是delete父类指针,虽说父类指针接受的是子类对象,但是你并没有重写析构函数,所以只会走父类的析构,但是如果虚析构就会先走子类的析构再走父类的析构,前提是你父类写了虚析构,否则只会走父类的析构,造成子类的某些指针无法释放地址空间)
  • 纯虚析构
    有了虚析构还要纯虚析构干啥?我当时也是很懵的状态。
    解释:纯虚的意识首先当然是不可实例化该类的意思,一般用于父类。其实虚析构和纯虚的本意都是为了解决多态中无法走子类的析构函数问题,那么纯虚其实是为了强制的让子类们都实现析构,必须显示的实现析构函数而已(当然肯定有其他更恰到好处的用途,纯虚析构存在即合理。)

    • 重点:纯虚析构必须要有实现体,即类内声明类外实现。
      否则的话:编译不出错,但是运行出错
  • 总结:纯虚无法实例化对象且不用写实现体,除非是纯虚析构函数就必须写实现体,但是纯虚析构的实现体必须是类内声明类外实现。虚函数要写实现体。不管是哪种虚,带一个虚字的函数都是为了子类在继承后重写该父类的函数,唯一的区别就是纯虚带有更强的权利,必须子类实现该函数,否则无法将该类实例化(当然除非你本身就不希望他的子类实例化的时候就无须实现可以只继承不实现)

对象的生命周期

实例化对象

在C++ 中,实例化一个类对象就和定义一个数据类型一样,
假如说有一个学生类class student
想要实例化这个学生类:

  • 1:student s1;
    注意:该方法是对应于无参构造函数,因为只有无参构造函数可以省略括号,一旦要使用有参的,就要使用括号法,就是使用括号的形式将其实例化,下面有说括号法。
  • 2:student *s1 = new student()
  • 3:如果构造函数有参数
    • student *s1 =new student(参数...)
      没有参数的话甚至可以不用括号:student *t1 = student; 记住new一定是指针变量接受地址空间
    • student s1 = student(); (显示法)
    • student student(参数...) (括号法)
      在第二种实例化方法中student()使用构造方法实例化的时候变量必须是指针。
      在第三种初始化中,有参数的时候我们可以不使用指针的方法实例化,可以使用显示法或者括号法,括号法就是直接使用定义变量的形式实例化一个对象即可,如果习惯要使用new的方式就必须要记住使用指针,因为new出来的空间都是一份地址,地址要用指针保存。(显示法就很显而易见的一种实例化方法。)
      注意:显示法中当你使用student()而不用变量存起来就叫做匿名对象,因为没有变量名即匿名,创建之后就无法使用该对象
    • 隐式法
      这个要着重理解:
      • student s1 = 10;(利用参数的方法进行对象实例化,我测试了一下,这种方法只适用于一个参数,多个参数我的编译器不通过直接报错,不推荐使用这种,可读性差!)
        切记!这种直接用参数的,不可以用多个参数,这边试过都初始化未遂,都是处于一个初始化值没有给到这些属性(如果有人可以请麻烦私信或者评论区留言
      • student s2; student s1 = s2(拷贝构造,也是隐式创建对象的一种)
  • 常用的构造方法
    • student s1; / student s1(参数列表)
      这种实例化方法不可以用默认构造,比如错误示范:student s1(),编译器出现二义性,分不清s1()到底是一个返回student对象的公共函数还是你的student的构造函数。
    • student *p_s1 = new student([参数列表,.])
    • student s1 = s2 —这种是拷贝构造函数,用于复制对象的,拷贝构造也属于构造初始化一种,只不过是用别人的数据初始化自己的数据。

构造函数与析构函数

  • 构造函数
    • 构造函数就是一个对象的诞生状态要做的事情(函数)。
    • 构造函数的函数名就是类名,并且没有返回值,也不能写返回值。
    • 构造函数可以重载
      (其实构造函数就是一个标准的重载函数,因为没有返回值其实也侧面说明了函数的返回值不能做重载的条件,构造函数中也是参数类型、顺序、个数不同进行重载。)
    • 构造函数作用就是初始化对象,每一个类都会有一个默认无参构造函数,初始化某些参数动作都会在实例化对象的时候自动调用该函数初始化该对象。
  • 析构函数
    • 析构函数就是一个对象死亡的时候要做的事情(函数)
    • 析构函数的函数名是:~类名
      用波浪号+类名就是析构函数的函数名
    • 析构函数不写的时候也会默认有一个析构函数,在一个对象要消亡的时候就会调用该函数。
      如果使用默认的就是空函数,所以我们一般都会将其显示的写出来,将自己要释放的一些空间或者数据在该析构函数中实现。
  • 在匿名对象创建完即可调用析构函数进行释放。(C++就是这么的扣,不错就喜欢这种分毫空间都拿捏这么精确的语言)

拷贝构造函数

  • 格式类名(const 引用类型 变量名)例子:const int& a;
  • 拷贝构造函数的细节
    • 拷贝构造函数顾名思义是一个构造函数
    • 拷贝构造的参数必须是:const 引用类型 变量名
    • 拷贝构造在进行对象拷贝复制的时候会调用
  • 什么是拷贝,怎样才算拷贝复制
  • 这个问题非常值得深究:
    • 首先拿一个对象变量进行直接复制给一个空对象,比如:student s1 = s2;s2是已经存在的对象了,然后s1是还未被实例化的,所以是一个空变量,然后这时候使用s2对s1进行赋值(复制),这时候就会调用拷贝构造函数,这种形式其实非常眼熟,就是我们使用隐式法中使用参数进行实例化的方法,这样其实就是一个拷贝过程了(OK这样是使用一个参数的时候或者说直接使用赋值的时候)
    • 其次,其实拷贝构造还有一个很容易忽视的地方,就是作为参数的时候,当我们任意写一个函数的时候,传入参数的时候其实也是发生了拷贝的过程,因为我们的函数传参一直都是一个复制实参的动作,所以其实无论是不是参数形式进行拷贝的,其本质都是student s1 = s2;s2这种操作进行,函数传参就是用这种方法来进行赋值不仅仅是类对象,其他类型也是这种形式,只不过我们的类有一个拷贝构造函数能够人为的进行手动改我们应该复制哪些数据而已。因此,当我们随便写一个函数,进行对象的传参的时候我们的类都会触发到拷贝构造函数
    • 最后 ,拷贝构造函数有一个特别要注意的 细节
      • 默认拷贝,是默认把你所有类中有的属性值都完全复制给另一个新的对象中
      • 由于有默认拷贝的这个操作,所以当我们的属性中有指针的时候,我们可能会发生一些不可描述的事情,首先是可能会发生一个对象s1的属性全部复制给了s2,但是其中有一个指针类型的值,在赋值完之后s1不用了,析构函数可能将其指针释放掉了,然后我们的s2还存在着s1赋值过来的那个指针,但其实这份指针已经失效了,没有用了,我们在s2中使用这份指针的时候就会爆炸,意思就是非法访问地址操作了属于是,因为地址已经释放被回收了,还继续访问就是非法。
      • 拷贝函数由于上述所说的问题,由此产生一个写类必须要写的函数就是我们的拷贝函数必须手写出来,就是我们应该这么做的意思,(如果没有产生拷贝操作就可以不写,但其实一般都要显示写出来不会手动写。)
      • 有指针赋值的时候我们在拷贝构造函数中不能将复制对象的指针值赋值过来,只能重新new一份空间后,将其值进行赋值过去,否则直接回导致内存空间泄漏。
      • 有指针属性的类中析构函数与拷贝构造函数二者是紧密相连,当进行二者的对象进行复制的时候,指针的值是不能直接进行复制的,需要在拷贝构造中开辟新的空间后再进行值赋值,析构函数释放的一般都是地址空间,而且很多时候都是在进行拷贝完成之后就将其对象释放掉,所以一个存一个释放,这俩通常都是一写就俩都写,建议是析构函数与拷贝构造函数都要一起写,还有一点就是我们类中一旦有指针属性,则必须在析构函数中将其delete掉,也就是必须把该指针空间将其释放掉,这就是规范的代码。
      • 结论:我们一旦参数含有一个类参数,我们就会触发他的拷贝构造函数,因为我们要给参数赋值就是拷贝过程。类中含有指针类型的属性必须要在拷贝构造函数中新创建一个空间再赋值。
  • 比较秀的操作:
    在下面的student myStu = reStu();中,我们使用了拷贝,原本我们是不允许返回函数内部开辟的空间,但是由于我们返回的是对象对象之间互相赋值是通过拷贝函数,然而拷贝函数只是将我们的属性值进行值交换而已,所以我们在这里是可以返回函数内的对象值
    (当然我们写的拷贝构造函数要符合我们的对象属性,有指针的时候就要另外开辟空间再进行值复制,否则就会出现空间泄漏)
    student reStu(){
    	student stu;
    	return stu;
    }
    student myStu = reStu();
    

防止拷贝

既然如此,我们出现拷贝的原因是因为出现了实参到形参的复制,那我们想要防止这种情况就是直接不使用形参传递就好了,使用实参传递,那对于类的实参传递有两种

  • 引用传递
  • 指针传递
    其实引用传递的本质就是指针传递
    使用了指针传递后就能够减少拷贝函数发生的空间损耗!就这么简单就解决问题了。

总结

在一个对象的生命中,三个默认就存在的函数。

  • 构造函数(空实现)
  • 析构函数(空实现)
  • 拷贝构造函数(值拷贝)
    构造函数是一个对象出现的时候必须要调用的函数、析构函数是在一个对象销毁的时候调用,拷贝构造函数也属于构造函数的一类,但是他是在对象之间进行拷贝的时候才会触发的函数。

收获:其实在学习这个C++面向对象相比Java粒度变得十分的细致,能够十分自如的掌握一个对象的整个生命周期,但同时也感到C++十分重,单单一个实例化对象就涵盖几种多种方法,当然我肯定是不够火候,还没悟道这多种实例化对象所应用的场景,我相信他这多种实例化对象有实际的应用场景的。

深拷贝与浅拷贝

  • 一个带有指针的规范的类
    • 析构函数必须要有释放指针内存空间的代码
    • 拷贝构造函数中不可以将指针直接赋值过去,必须要开辟一个新的空间,然后将原本的值将其拷贝过去新的空间里保存。
  • 浅拷贝
    浅拷贝即编译器中默认实现的拷贝构造函数就是浅拷贝的意思,不管是不是默认实现的拷贝构造函数,本质上就是值拷贝。
  • 深拷贝
    深拷贝就是我们在对指针进行赋值的时候不使用浅拷贝(其实根本不可以这样做),我们在拷贝函数中做手脚,我们自己开辟新的空间属于该类的地址空间,然后我们仅仅将其地址的值给到我们自己新的空间中保存就叫做深拷贝。(对指针做手脚就是深拷贝)
  • 深拷贝的目的
    防止被拷贝的对象中的地址被释放,新对象中拿到的是旧对象中的地址,该地址原则上讲并不属于自己,对方随时都可能将其地址空间释放掉,即使对方没有释放掉,我们也有随时释放掉对方地址空间的可能性,这对双方都是极其不利的。

初始化列表

在构造函数的参数列表右边直接给属性赋值。

class Person{
	int m_A;
	int m_B;
	Person():m_A(10),m_B(20){} //允许空实现构造函数,只用来初始化赋值
};

在实例化的时候就是默认将我们写进去的两个属性初始化为10和20,当然可以只写一个,不一定全部属性都要写。

  • 注意:这个是依附在构造函数中,所以无论是实例化还是拷贝,触发这个写了初始化列表的构造函数就会将其默认初始化值。(换句话说,拷贝构造函数也可以写默认初始化列表)
  • !!静态变量不可以作为初始化列表!!
    原因很简单:由于静态变量是全局的,属于这个类的,所有对象共享的数据,不可以让多个对象在创建的时候都将其静态变量修改掉 (后面部分继续解释静态变量的含义)

静态

在整个程序运行开始到结束都存在,静态俩字就说明不论是变量还是函数都是存在于一个存储区内,并且整个程序运行起来就开辟好存储在一个地址块内,然后程序运行的时候都存储在这个区内,只有一份,且用的时候都是用这个区的这一份数据。
编译阶段就会为所有静态的东西分配内存,且在同一个内存区内。
全局范围的静态变量和函数的作用域仅仅在本源文件,出了该文件就访问不到。(因为这是全局的静态,不同于类内的全局,固定在一个了类中,能够将其头文件与源文件分开声明定义,我们的全局静态一旦出了本源文件,就无法在另外的文件链接到。

  • 静态变量
    静态变量分为全局静态变量,类的静态变量。但其实质都是一样的。
    • 全局静态变量:作用范围是整个程序,在其他函数体内使用该变量都是这个。
    • 类静态成员变量:作用范围在该类中,即属于这个类的实例化对象都共享同一个数据。
      作用范围属于该类,如果是public的静态变量那就可以通过 类名::静态变量 进行访问,如果是private私有的静态变量就只能在类内访问(静态的本质说白了就是共享一份数据)
      (从这个也反映了我们初始化列表的时候不可以初始化静态变量的原因了)
  • 静态函数
    • 全局静态函数
      全局的静态函数与普通函数最大的区别就是静态俩字定义了该函数在整个程序中一直存在并且只保留一份,且使用的时只能够使用这一份。(速度快,因为该静态函数一直存在于一个存储区,调用的时候不用耗费时间进行拷贝复制压栈出栈)
      相比于普通函数一旦被调用就会复制一份,进行压栈出栈操作。(相比于静态函数速度慢)
    • 类静态成员函数
      类内的静态函数,作用范围属于该类,如果是public的静态函数就可以用 类名::静态函数名 进行调用,private私有函数变量只能在类内进行调用。

      题外话:私有的静态变量我第一时间想到的就是我们玩游戏的时候隐藏的属性,不会明摆出来告诉你的事情,比如说你抽奖,当你充一次钱就会调用一个私有静态函数,将其抽奖中奖概率提高,这个概率就是私有的静态变量,我们无法再外部进行访问。(临时想到的,写下来。)

      • 所有的静态成员函数只能访问静态变量,不能访问普通成员变量。
      • 静态成员函数是public的时候可以通过类名访问到,也就是外部能够通过类名访问到
      • 静态成员函数是private的时候值能在类内进行调用,也就是外部不能够通过类名访问到
  • 类内声明,类外初始化
    这个很重要,在C++中,类的静态 变量 都是类内声明,类外初始化,在类内不可以进行初始化,会直接报错。
    静态成员函数没有一定要类外定义,可以类内定义也可以类定义。
    看下面的例子:

    通过下面的例子还学习到当我们类内声明,类外定义的时候,类外定义的范围还属于类内部,因此可以调用到私有成员。

class Person{
public:
	static void count();
private:
	static void static_fun(){
		cout << "调用静态函数" << endl;
	}
};
void Person::count(){
	static_fun(); //同时可以调用到类内的私有成员
}
  • 总结:
    类的公有静态成员可以在类外通过类名访问:Person::count();//访问静态函数,同理变量也是这样子访问。
    静态是编译即存在
    仅存有一份数据在同一块地址空间。
    类的静态变量必须在类内声明,类外初始化。类的静态函数就可以随意点,类内类外都行。但是一般在C++代码中都是类内声明类外定义居多

注意:全局的静态函数或者变量的作用域都是在本源文件中能够访问到。

❀内存对齐原则❀

在结构体和类中有一个内存对齐原则。
下面的例子中可以很直白的说明问题了。

  • 在空类中,占的内存是1,为何不是0原因是因为必须要给这个类创建出来的实例化对象分配一个空间,而再次创建另一个对象的时候不会还是同一份(意思是1的内存空间是为了区别不同的实例化对象)
  • 在P2中,加了int,占的内存是4(我是x86编译,即32位),所以我们就可以使用int的内存空间来区分不同对象了,不是5,我们第一个空对象是必要的才给1个空间,而我们这里有了int4字节的地址空间就可以使用它来将不同对象进行区分了。
  • 在P3、P4中,我们加了静态static和函数
    从这里我们可以看出静态和函数不是和类存在一个地址空间内的,所以所占的内存大小依旧是P2的int4字节空间。
  • 在P5中,我们加了一个char=1字节和一个int=4字节,有4字节+1字节+4字节=9字节由于对齐原则,我们的类中最大的属性类型占的空间是4个字节,即int是该类中占空间最大的类型,对齐原则必须是:我们加空间的时候必须是最大空间类型所占的字节为倍数增加,因此最终我们的P5占空间大小是1212字节是4字节的倍数。
  • 在P6中,我们加了一个double类型8字节,有12字节+8字节=20字节由于对齐原则,我们类中最大的属性类型占的空间是8个字节,即double是该类中占空间最大的类型,对齐原则必须是:我们加空间的时候必须是最大空间类型所占的字节为倍数增加,因此最终我们的P6占空间大小是2424字节是8字节的倍数。
#include<iostream>
using namespace std;
class P1 {};
class P2 {
	int m_Num;
};
class P3 {
	int m_Num;
	static int m_StaticNum;
};
class P4 {
	int m_Num;
	static int m_StaticNum;
	void print() {}
};
class P5 {
	int m_Num;
	static int m_StaticNum;
	void print() {}
	char ch;
	int m_Num2;
};
class P6 {
	int m_Num;
	static int m_StaticNum;
	void print() {}
	char ch;
	int m_Num2;
	double money;
};
int main() {
	P1 p1;
	P2 p2;
	P3 p3; 
	P4 p4;
	P5 p5;
	P6 p6;
	cout << "sizeof:P1=" << sizeof(p1) << endl;
	cout << "sizeof:P2=" << sizeof(p2) << endl;
	cout << "sizeof:P3=" << sizeof(p3) << endl;
	cout << "sizeof:P4=" << sizeof(p4) << endl;
	cout << "sizeof:P5=" << sizeof(p5) << endl;
	cout << "sizeof:P6=" << sizeof(p6) << endl;
	return 0;
}

核心

内存对齐就是找到类或结构体中某个类型属性所占的空间最大,然后按照该类型的空间大小作为该类扩展空间的倍数,然后这些成倍数扩展好的空间就是该类的最终内存空间大小

  • 注意:不是用最大类型空间的大小作为该类的空间大小,而是用这个为倍数进行类的空间扩展,最终空间大小肯定要装得下该类的所有成员变量(当然除了固定在外面存储的变量:比如静态变量、函数)

成员常函数

this指针

  • 用来区分哪一个类的对象实例。每个实例化对象所属的指针都是用this指针区分。
  • this是一个指针,使用的时候用 –> 访问成员变量
  • 可以通过返回this指针解引用的方式实现链式编程
    比如下面实现的是自增的链式编程。
  • this指针是一个指针常量,即this指针不可以修改地址,只能表示本对象的地址,即Person const* p; 在类中,我们的this就相当于一个对象的指针常量。
class Person{
	Person& returnP(){
		return *this
	}
};

const常函数

  • 格式:类成员函数名 const
  • 常函数只能访问成员属性,但是不能修改属性值,即不能将成员属性作为左值。
  • 常函数不能够访问普通成员函数,常函数只能够访问常函数。(因为我们普通函数中是允许修改属性变量的,因此不可以访问普通成员函数。)

解释:首先常函数的本意其实是当我们不希望在该函数内将其中的成员属性值修改,代码量非常庞大或者放着被别人修改的时候我们就可以加上const就知道该成员函数有没有将某个属性修改过,在写之前加上const又能够防止自己不小心将其某个属性修改掉,所以常函数在我角度看来还是非常有用的。

  • mutable:当我们可能会出现非得在const中修改某个属性的可能性的时候我们就可以将该属性加上mutable关键字
#include<iostream>
using namespace std;

class Person {
public:
	int m_Age;
	int mutable m_mutablAge;
	void fun() {
		cout << "普通函数" << endl;
	}
	void count(){}
	void fun() const { //加上const的本意是不希望我们在该函数中修改属性值,常函数
		// 常函数谁都可以访问,但是常变量只能够访问常函数,
		// 原因是我们将其变为常变量目的就是不希望修改其中的成员属性值
		// 普通函数既然能够随意修改属性变量值,那我们常变量固然不可以访问
		// 就代表我们的常函数不能够访问普通函数,即只能够访问常函数
		
		int age = this->m_Age; //不修改成员变量是正常的
		//this->m_Age = 100 //报错,不可以修改成员变量值
		cout << "const成员函数" << endl;
		
		//尝试访问普通成员函数
		this.count(); //必然报错
		//尝试访问mutable修饰的成员属性
		this->m_mutablAge = 100;
	}

};
int main() {
	Person const* p;
	return 0;
}

友元

  • 友元的意思是希望某个函数/类能够访问到自己的私有成员

  • 我的理解:本类把某个函数或者类当作朋友,那么对方就能肆无忌惮的闯入你的生活(bushi),对方能够随意的访问你的私有的成员。但是,一个残酷的现实就是,你把人家列入了friend名单,但是对方可不一定把你列入friend,所有你把人家列入friend名单的时候,对方没有把你列入朋友那你可不能访问对方的私有成员,因为你把人当朋友,别人却不把你当朋友。
    我只是举一个反例,也很贴合现实生活,但是我们代码中的关系是可以自己操控的, 因此如果希望双方都能互相访问对方的私有属性的时候就可以两两都互相把对方列入friend列表。

  • 友元是写在类中的,可理解成该类给别人的一种特权。

  • 用两个类作为例子讲解三种友元


class Girl;
class Boy;

void G_Fun(Boy& boy) {
	cout << "全局函数" << endl;
	cout << "访问男生的私有属性:" << boy.m_Money << endl;
}

class Boy {
	friend void G_Fun(Boy& boy);
	friend Girl;
public:
	Girl* girl;
	Boy() {
		girl = new Girl();
	}
	int m_Age;
	void showGirl();
private:
	int m_Money;

};

class Girl {
	friend void Boy::showGirl();
public:
	Boy* boy;
	Girl() {
		boy = new Boy();
	}
	void show();
private:
	int m_Age;
	int m_Money;
};
void Boy::showGirl() {
		girl->m_Age = 100;
}
void Girl::show() {
	boy->m_Money = 100;
}

1:友元全局函数

void G_Fun(Boy& boy)
在上述两个类中,有一个全局函数被Boy类列为了友元函数,即Boy将该函数作为了自己的朋友关系,那么该函数就能够访问Boy的私有属性了。
在Boy类中写上:friend void G_Fun(Boy& boy);,该函数就能够随意访问Boy的属性了。
这就是友元全局函数。(理解了这个格式就知道后面的套路是如何写友元的了。)

2:友元类

  • 对于Girl来说,他想要访问Boy的私有属性的前提就是必须要Boy把Girl当作友元类才可以访问,就是说Boy必须要把Girl当作朋友才可以。
    在Boy中写上:friend Girl;就表示Boy把Girl类当作朋友了,那么对于Girl来说就可以随意访问Boy,但是Girl并没有把Boy当作友元,因此Boy这时候并不能够访问Girl的私有属性。

3:友元成员函数

  • 友元成员函数就和全局函数的声明方法一样,就是因为他是成员函数,所以我们要在该函数中加上作用域,让编译器知道该类是哪一个类中的函数。
  • 友元成员函数比友元类的权限分的粒度更小,因为友元类是整个类都能够被别人访问,友元函数只是把该函数细分出去,让该函数能够访问自己的私有属性。

必须在类内部声明,在类外部定义

友元成员函数:必须在类内部声明,在类外部定义

总结细节

在C++中使用友元最坑的点不是忘记具体的格式怎么写,是我们的C++在书写代码的顺序,一旦写了友元,那么类与类之间的关系就变得很密切,因此我们就需要按照一种:先声明再定义and类内声明类外定义 的习惯进行书写C++代码,这样就能够确保我们在写友元的时候不会发生未定义的错误。

  • 在C++中其实一直都是先声明再定义的书写顺序,这样也方便管理,然后C++中能够将其定义单独分离出来也就代表了C++也提倡将声明与定义分离开来,不然一个类中很庞大的时候我们单凭一个类的函数实现就非常多的代码堆积。
    当我们将定义分离出去后,我们类中就变得十分好看和容易管理了,类中的功能也一目了然。
  • 但是对于友元成员函数:C++中指定了必须要类内声明,然后类外定义才可让友元成员函数访问到对方的私有属性。

重载运算符

首先要明白我们重载运算符主要的需求就是希望对象之间能够使用运算符进行运算。
operator运算符即我们写函数的时候的名字,运算符决定了你重载的函数的主要作用
在重载运算符中有两种形式,一种在全局重载,另一种是在类内重载。
区别:

  • 全局的涉及到左边参数是左值,右边参数是右值,因此一般有两个参数
  • 类内的设计到某对象调用该函数的时候,本体对象作为函数的左值,参数作为右值
  • 在书写对象参数类型的时候,记住如果我们要改变参数的值一般传入引用类型,打印输出对象某个属性的时候,直接使用形参就行了,不建议或者说不要使用引用类型,没有必要且可能发生某些错误。

需要注意的一点就是,一般的,写全局重载运算符函数的时候,我们类与类之间可能需要运算的属性是私有属性,那我们就需要将该函数作为运算某个类的友元函数,这样就可以在全局函数内访问到该类的私有属性了。

多种运算符重载

  • operator+
    重载加号运算符

    • MyInteger operator+(MyInteger t1,MyInteger t2)全局
      加号必须要有两个参数,所以全局情况下要有两个参数,所以我们的重载运算符函数要有两个参数,调用的时候: t1 + t2,也就是说我们重载之后两个对象就能够使用加号运算符进行两个对象之间运算,但是具体对象之间的加号实现的是哪些值进行相加就要看你的函数内如何实现了。
      由此可知我们加完之后必须要有一个返回值,并且是MyInteger 类相加,那么返回值必定是一个MyInteger 类。
    • MyInteger operator+(MyInteger t)类内
      由于是在类内实现的,所以可以直接访问到该类不同对象之间的私有属性。调用的时候:t1 + t2,实质是一样的操作,只不过一个是全局,一个是类内的成员函数,一定要注意的是,全局重载加号运算符函数在实现的时候如果要访问类内的私有属性的时候一定要将该函数作为该类的友元函数。
  • operator++

    • 前置++:++num
    • 后置++:num++
    • 注意前置与后置的写法,我们后置一定返回的是未自增之前的对象,因此我们需要创建一个临时对象将未自增之前的对象存下来进行返回,因为后置自增是执行完自增之后才发生数值改变,直接打印num++出来的值还是num原来的数值。

首先这个不管是前置还是后置都能在类内或者全局来重载实现,所以我们首先最简单的就是类内实现
前置:operator++(),后置:operator++(int)

说明:这里的后置中写了一个int占位类型,仅仅是用来占位的,因为我们前置和后置的函数名是一样的,所以我们需要实现重载就需要用参数来区分函数实现重载,至于为什么后置是有一个int而前置不用的原因:可以这么一想,由于我们是写类内的看不太出来,我们后面写全局的时候就知道了。

上述是类内的写法,下面说全局的写法
前置:operator++(MyInteger t1) 后置:operator(MyInteger t1,int)
后置在这里可以看出为什么要加int了,是因为我们的第一个参数t1是要进行++运算的,所以在后面加上int占位就可以理解为将++后置的意思,之所以在类内看不出来是因为我们直接把本类该对象作为了第一个参数,所以看不出来为啥要用参数int进行占位操作,在operator全局中就很明显看出来意思是后置++

  • operator=
    等号运算符中需要特别注意的是:他与拷贝函数不一样,不一样不一样!!拷贝函数是发生在对象实例化中,等号运算符重载是发生在等号双方对象都已经存在的情况下进行属性赋值,并且默认的我们不写重载等号运算符的时候系统会自动帮我们生成该函数,并且作用是将类中所有的属性值进行值赋值,所以在这里我们一定一定要注意类中是否有指针类型的属性,一旦有的话我们一定一定要自己重载等值运算符,否则会造成内存泄漏。
    出现这种情况那我们在重载等号运算符中写什么:解决办法和拷贝构造函数写法一样,在堆区重新开辟一个新的空间地址给左值对象,不能直接将被拷贝的对象的地址赋值过去,否则必定会发生内存泄漏现象。
  • operator<<
    说明:左移运算符如果你希望模仿的像cout一样使用的话,就必须写全局的重载函数,不能在类内重载,因为类内规定了该类对象作为第一个参数即左值,因为我们知道真正的左移运算符的左值是cout<<num而不是num<<cout,所以我们在这里希望输出的是对象就要在全局中写重载函数。
    operator<<(ostream cout, MyInteger t1)等价于cout<<t1
    然后我们在函数体内就可以控制我们要输出MyInteger对象中哪些属性,如果涉及到私有属性的话就要将该函数列为MyInteger的全局友元函数。
  • operator<和operator>
    用法很简单,上述几个都学会了这个也就轻而易举了。(省略)
  • operator()
    • 类内
      operator()(参数,…)
      这里的运算符重载的是括号,使用的时候:类名(参数列表),一般情况下都是有参数的。
      如果没有参数直接给出重载函数,那么我们在调用没有参数的括号重载运算符函数的时候我们可以直接使用已存在的对象名+()即可。
      匿名类调用就要用两个括号,因为要创建出一个对象才能够调用空括号运算符函数:匿名类()()

下面给出验证代码

#include<iostream>
using namespace std;

class MyInteger {
	friend ostream& operator<<(ostream& cout, MyInteger myint);
public:
	MyInteger() {
		m_Num = 10;
		m_height = new int(10);
	}
	MyInteger(const MyInteger& myint) { //拷贝函数
		cout << "拷贝构造发生" << endl;
		this->m_Num = myint.m_Num;
		this->m_height = new int(*(myint.m_height));
	}
	~MyInteger() {//析构函数
		if (this->m_height != NULL) {
			delete this->m_height;
			this->m_height = NULL;
		}
	}
	void fun(MyInteger myint) {
		cout << ++myint.m_Num << endl;
	}

	MyInteger operator+(MyInteger& myint) { //加号运算符 
		//看情况是否返回引用类型,这里返回局部
		MyInteger temp;
		 temp.m_Num = this->m_Num + myint.m_Num;
		 return temp;
	}

	MyInteger& operator++() { //前置++
		this->m_Num++;
		return *this;//因为返回的是引用类型,所以我们解引用解出来即可
	}

	MyInteger operator++(int) { //后置自增
		//后置++就没必要实现链式编程了,
		//因为我们后置自增就是不是立马自增,而是执行了之后下一行才将其原本的数自增,因此仿要仿的像才行
		MyInteger temp = *this;
		this->m_Num++;
		return temp;
	}

	MyInteger& operator=(MyInteger& myint) {
		//重载等号运算符
		cout << "执行重载等号运算符" << endl;
		cout << "等号两边都已经存在的对象的时候就会执行重载等号运算符。" << endl;
		this->m_height = new int(*(myint.m_height));
		this->m_Num = myint.m_Num;
		return *this;
	}


	bool operator<(MyInteger myint) { 
		//重载小于运算符
		if (this->m_Num < myint.m_Num) {
			return true;
		}
		else return false;

	}

	bool operator>(MyInteger myint) {
		//重载大于运算符
		if (this->m_Num > myint.m_Num) {
			return true;
		}
		else return false;

	}
	void operator()(string str) {
		cout << str << endl;
	}
private:
	int m_Num;
	int* m_height;
};
//左移运算符,Java的toString方法底层估计就是这个
ostream& operator<<(ostream& cout, MyInteger myint) {
	cout << myint.m_Num;
	return cout;
}


int main(int) {
	MyInteger myint;
	MyInteger myint2;
	MyInteger myint3 = myint + myint2;
	cout << "myint=" << myint << endl;
	/* myint3 = myint + myint2;*/
	cout << "myint3=" << myint3 << endl;
	cout << "前置自增后myint=" << ++myint << endl;
	cout << "执行前置自增myint=" << myint++ << endl;
	cout << "执行后myint=" << myint << endl;

	cout << "测试拷贝构造与等号重载符号哪个执行" << endl;
	MyInteger a;
	MyInteger b;
	a = b;

	cout << "myint:" << myint << ",myint3:" << myint3 << endl;
	cout << "myint是否小于myint3:" << (myint < myint3) << endl;
	cout << "myint是否大于myint3:" << (myint > myint3) << endl;

	MyInteger()("555"); //使用匿名类调用
	b("555");//直接使用实例化出来的对象调佣
	return 0;
}
posted @ 2023-07-24 17:29  竹等寒  阅读(12)  评论(0编辑  收藏  举报  来源