进一步走进C++面向对象的世界

本文深入讲解了C++中的继承机制,包括公有、保护和私有继承,构造函数和析构函数的调用顺序,以及多继承中的二义性和虚继承。重点介绍了多态的概念及其在代码中的应用,展示了纯虚函数和抽象类的使用,以及如何避免内存泄漏问题。
摘要由CSDN通过智能技术生成

更多博文,请看音视频系统学习的浪漫马车之总目录

C++语法重点难点:
初尝C++的世界
进一步走进C++面向对象的世界
感受C++一些令人眼前一亮的语法

上一篇初尝C++的世界 虽然讲的很长,但是如同题目写的“初尝”一般,写的比较蜻蜓点水,简单讲了C++与C语言的一些不同点,这一篇将针对C++类与对象,开始探讨继承、多态相关内容。大家坐稳扶好,咱们继续出发~

继承

关于继承,C++程序员肯定是信手拈来了。 所谓继承,就是一个类从另一个类演化而来,在原来的类基础上添加一些成员和方法,并能访问原来的类中的成员和方法,被继承的类叫做父类或者基类,继承的类叫做子类或者派生类。继承的作用从代码角度来说就是实现了代码的复用,父类的代码可以被多个子类共用,从而节省了多余代码。从宏观的开发思想来说,继承是面向对象的一种升级,对原来对事物进行分类的详细化,更接近人的不仅擅长归类,而且又擅长对同一类事物进行衍生变化(或者更细分)的思维习惯。不仅仅划分为暴龙兽和加鲁鲁,暴龙兽还可以升级为机械暴龙兽和战斗暴龙兽即拥有升级前的战斗技能,又添加了新的技能,然而不管怎样变,它都是暴龙兽,所以这又为后面的多态做了基础。

比如有动物类:

class Animal {

private:
    char * name;

protected:
    int age;
    
public:
    char *getName();
    int getAge();

};

然后创建Dog类继承Animal :

class Dog : public Animal {
private:
    char *noseColor;
    
protected:
    char *tailColor;
    
public:
    void eat();
};

这样子,Dog不止可以调用自己的eat方法,还可以调用父类的getName方法。

Dog dog;
//调用父类的方法
dog.getName();
dog.eat();

当然不能说Dog就可以拥有Animal的所有成员的方法了,继承要注意的就是权限问题,拥有了不代表一定能用(对方法来准确来说是拥有其调用权),毕竟父类也是有隐私的,它自己有权利决定哪些可以给子类使用,这也是面向对面重要特点之一:封装。显然,父类的private修饰的成员或者方法就不给子类使用,毕竟是私有的,protected修饰的本身就是专门提供给子类用的,所以子类可以用,public是完全公开的,显然子类也是可以使用的。注意这里只是父类加这些修饰关键字的意图,C++和Java的不同在于子类本身还可以决定自己对父类的成员或者方法的控制力有多大,注意到类声明开始处的“class Dog : public Animal ”,中间多加了个“public”,这个就是继承方式,所以子类最终可以使用父类成员或者方法的权限由父类的修饰符和继承方式共同决定,具体看下方:

  1. public继承方式
    基类中所有 public 成员在派生类中为 public 属性;
    基类中所有 protected 成员在派生类中为 protected 属性;
    基类中所有 private 成员在派生类中不能使用。

  2. protected继承方式
    基类中的所有 public 成员在派生类中为 protected 属性;
    基类中的所有 protected 成员在派生类中为 protected 属性;
    基类中的所有 private 成员在派生类中不能使用。

  3. private继承方式
    基类中的所有 public 成员在派生类中均为 private 属性;
    基类中的所有 protected 成员在派生类中均为 private 属性;
    基类中的所有 private 成员在派生类中不能使用。

咋一看挺复杂,不知如何记忆,其实一言以蔽之:

继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的(比其高的则降为它,低的则保持原来的权限

是不是就豁然开朗了呢?

比如将Dog的继承方式改为private:

class Dog : private Animal {
private:
    char *noseColor;
    
protected:
    char *tailColor;
    
public:
    void eat();
};

则以下语句会报错,因为此时Animal的所有成员和函数都对于Dog来说在父类是private,所以getName函数对于Dog来说是不可调用的。

dog.getName();

继承中的构造函数和析构函数

子类是能够继承父类所有的方法(准确来说是获得其调用权),但是有个特殊方法是不能被继承的,它就是构造函数。构造函数是在创建对象的时候自动调用的,所以继承父类的构造函数是没有意义的。但是子类从父类继承过来的成员在子类创建对象的时候依然是需要初始化的,初始化逻辑依然在父类的构造函数中即使想要在子类中初始化也会可能因为权限问题而访问不到成员,所以为了使得父类的成员能够在子类创建对象的时候得到初始化,这里需要在子类的构造函数中手动调用父类的构造函数。

例如在上面例子中给Animal添加一个构造函数:

Animal(char * name);

再给Dog添加构造函数的时候,类似初尝C++的世界 所描述的初始化列表,这里要求要调用Animal的构造方法,不然编译会报错(clion会报错:“Constructor for ‘Dog’ must explicitly initialize the base class ‘Animal’ which does not have a default constructor”):

Dog::Dog(char *noseColor) : Animal(noseColor) {
   
}

没错,和Java构造方法第一行要主动调用super.父类构造方法其实一个道理,目的是一样的。

另外要注意的一点就是继承中构造方法和析构函数的调用顺序:

Dog *dog = new Dog("aass");
delete dog;

结果是:

Animal构造函数
Dog构造函数
Dog析构方函数
Animal析构方函数

从这个小例子可以清晰看出,构造函数是先调用父类再调用子类,而析构函数则相反。想想也有道理,子类继承父类,父类就是其依赖,肯定是先初始化被依赖的部分。而析构当然相反,肯定是被依赖的部分后面释放。

多继承

C++多继承看似强大,但因其复杂混乱坑多,所以实则广为程序员所诟病,以至于后世的Java、C#等后辈皆断然弃多继承而去,不过既然作为C++的一种特性,这里还是要有所提及。

多继承,顾名思义,即一个类继承多个类。

假设增加一个Cat类继承Animal:

class Cat : public Animal {

private:
    char *noseColor;
    
protected:
    char *tailColor;
    
public:
    void eat();
    
    Cat(char *noseColor);
    
    ~Cat();
    
};

在增加一个Pet类同时继承Dog和Cat:

class Pet : public Dog, Cat{
public:
    Pet(char *noseColor1, char *noseColor);

    void howOld();
};

是的,一个最简单的多继承类就写完了,因为是多继承,所以根据上面所说,这个类的构造方法应该要同时调用2个父类的构造方法

Pet::Pet(char *noseColor1, char *noseColor) : Cat(noseColor1), Dog(noseColor) {
}

当然这不是关键,多继承比较麻烦的是二义性,即使用了父类们同名的成员或者方法,以至于编译器不知道究竟是使用哪一个,所以还要专门特定指定是哪个父类的:

假设在Pet实现howOld方法:

void Pet::howOld() {
    std::cout << "age" << age << std::endl;
}

这里是编译不过的,因为现在Pet继承Dog和Cat,Dog和Cat又继承Animal,所以形成了一个菱形的继承关系:
菱形继承
因为在C++多继承中,即使是同个父类的成员也会存在多份,即基类的每条路径都会有一份成员数据,所以Pet中使用的age不知道是从Pet–Dog–Animal,还是Pet–Cat–Animal路径继承来的那一份。

解决方案也是so easy的,不知道就指明哪个父类呗~~

void Pet::howOld() {
    std::cout << "age" << Dog::age << std::endl;
}

加上类名和域解析符::就可以很清楚解决了。

虚继承

上面谈到多继承会存在二义性的问题,那C++创造者觉得不方便,索性也从语法角度消除了这个问题,那就是虚继承。

首先在Dog和Cat的定义处做一点小小的调整,在继承修饰符前加上“virtual”关键字:

class Dog : **virtual** public Animal ```

```cpp
class Cat : **virtual** public Animal

然后发现Pet即使没有指定age也不报错了:

void Pet::howOld() {
    std::cout << "age" << age << std::endl;
}

为啥呢?其实很简单,之前说二义性问题的根源是因为多继承使得基类的每条路径都会有一份成员数据,虚继承就是让不同的继承路径下共享基类的数据,即基类的成员数据保持一份

可以简单通过以前程序验证:

void Pet::howOld() {
    Cat::age = 10;
    Dog::age = 20;
    std::cout << "age:" << age << std::endl;
}

age:20

即不管是Cat::age还是Dog::age,其实是同一个age。

因为多继承的路径和层次一旦复杂,调试和维护工作会变得很困难,所以被很多高级语言放弃了,所以这里也只是简单介绍,还是进入下一个更加值得我们细细研究的课题吧。

多态

一直觉得多态是面向对面的精华之处,有了多态,可以非常灵活第在运行时对对象的指针进行赋值,可以通过抽象类(Java还可以用接口)实现对类的解耦,使得系统松耦合,扩展性强。

有了多态,我们就可以在被调用的代码块中持有基类指针去调用基类某个方法,但是实际上该基类指针指向的对象是在运行时才确定的,所以决定此时调用的是哪个对象的方法取决于外部怎么给这个基类指针赋值对象,也是典型的依赖注入。

还是上面的例子(Pet继承Dog和Cat,Dog和Cat又继承Animal),给Animal增加方法whoAmI:

  char* whoAmI();

实现为:

char *Animal::whoAmI() {
    return "Animal";
}

Pet、Dog、Cat分别重写该方法:

Cat:

char *Cat::whoAmI() {
    return "Cat";
}

Dog:

char *Dog::whoAmI() {
    return "Dog";
}

Pet:

char *Pet::whoAmI() {
    return "Pet";
}

假如这里这样写:

Animal *animal = new Cat("aa");
std::cout << animal->whoAmI() << std::endl;

打印结果会怎样呢?

熟悉Java的同学一定脱口而出:Cat

恭喜你,答错了~~

看下结果:

Animal

不是说好的多态么?这样调用的还是父类方法,那还有何意义?

在C++中,如果没有特别指定,通过指针访问函数时,编译器会根据指针的类型来确定要调用的函数;也就是说,指针指向哪个类就调用哪个类的函数。其实调用基类指针就调用基类方法,是一种很自然的方式,只是熟悉Java的童鞋思维已经习惯了,所以现在这样反而不习惯。所以C++要真正实现多态,务必使用一个关键字virtual 来指定方法是一个可以使用多态的方法,即虚函数

为啥Java不用指定virtual 就可以直接使用多态呢,可以参考下这篇文章Virtual Function in Java

其实这要看这一句就够了:

By default, all the instance methods in Java are considered as the Virtual function except final, static, and private methods as these methods can be used to achieve polymorphism.

我们知道Java是C++的精简版,所以Java觉得没有必要脱掉裤子放屁,调用方法的是什么对象就调用这个对象的方法,所以方法直接就默认为虚函数,除了 final, static, private修饰的函数以外,因为这些函数也无法被派生类重写,所以没有成为虚函数的必要。

所以上面例子如何让我们的小喵咪(Cat)的whoAmI方法能够被正确调用呢?很简单,在基类whoAmI方法的声明处加上virtual即可(在Cat的whoAmI方法加也一样,因为如果基类方法是虚函数,那派生类重写的方法也是虚函数):

virtual char* whoAmI();

运行代码:

Cat

这样子就实现了多态,即调用Animal指针的whoAmI方法,真正调用的是Cat对象的whoAmI方法(Pet、Dog亦然)。

多态好处:

再来看下多态具体的好处:

假如有个类People:

class People {
   
    Animal *animal = nullptr;
    //注入具体的动物品种
    void setAnimal(Animal* animal);
    //让动物告诉我它品类
    void animalTellMeYourName();
};

实现:

void People::setAnimal(Animal *animal) {
    this->animal = animal;
}

void People::animalTellMeYourName() {
    this->animal->whoAmI();
    std::cout << "I am:" << this->animal->whoAmI() << std::endl;
}

这里我们只知道人 持有一只动物,但不知道是什么动物。这样就为程序提供了灵活性,外部使用People类的时候,可以根据具体场景给他不同的动物,而不需要修改People类的代码。

假如具体场景需要给People一只小猫咪,则:

People *people = new People();
  Cat *cat = new Cat("aa");
  //给people一只小猫咪
  people->setAnimal(cat);
  //让people持有的动物告诉我它品类
  people->animalTellMeYourName();

运行结果:

I am:Cat

换为给People注入一只Dog,也是一样的,只要保证whoAmI方法是虚函数即可。这样以后外部只要确保给People的是一只Animal即可,具体是一只什么Animal,People不care,所以说这个People是和外部类松耦合的,并没有写固定什么Animal。

关于虚函数实现的原理,会在介绍完C++语法之后的原理博文中详细介绍,敬请期待哈哈。

虚析构函数

在上一篇初尝C++的世界 曾经说过,对象的销毁会自动调用析构函数,那么如果在父类指针指向子类对象的时候去释放父类指针,即delete 父类指针,会发生什么呢?

我们在以上用例程序的每个类(Animal 、Dog、Cat、Pet)的析构函数中加上该类类名的打印,然后在main函数中执行:

Animal *animal = new Cat("aa");
delete animal;

这样会怎么样呢?

运行结果:

Animal析构函数

只有Animal 的析构函数被调用,但是释放的是Cat对象呀,Cat的析构函数竟然不调用了??恭喜你,踩到了C++初学者最容易踩到坑之一,而且可能会造成严重的内存泄漏。

根据上面说的原则,当父类指针指向子类对象的时候,任何对父类指针的方法调用,都是对父类的方法进行调用,所以这里Cat的析构函数是非虚函数,所以在Cat对象销毁的时候,调用析构函数的时候只把Cat当做一只Animal。(是不是有种无力吐槽,又觉得还是有点道理的感觉= =)

解决方法很简单,把Cat的析构函数设置为虚函数即可,当然,为了以后其他子类的析构函数不用一一设置为虚函数,所以直接将Animal的析构函数设置为虚函数即可

virtual ~Animal();

这样不管是Cat、Dog、Pet的析构函数都自动成为虚函数了。

运行输出:

Cat析构函数
Animal析构函数

这样Cat的析构函数就正常调用了~

所以以后写一个需要被继承的类时,记得析构函数设置为虚函数,不然可能处内存泄漏的大锅哦。

纯虚函数

上面用了不少篇幅讲解虚函数,name接下来要介绍的是虚函数的特殊情况——纯虚函数。

第一次看到这个名字,我在想,这个虚函数到底是有多纯粹?是的,它确实很纯粹,纯粹到没有任何实现:

假设在上面的例子中的最底层的基类Animal加一个shut虚函数:

class Animal {

private:
    char * name;

protected:
    int age;
    
public:
    char *getName();
    int getAge();
    
    Animal(char * name);
    
    virtual ~Animal();
    
    virtual char* whoAmI();
    //新加上的纯虚函数shut
    virtual void shut() = 0;

};

可以看到,该函数没有实现,末加上“=0”,这就是传说中的纯虚函数。

纯虚函数可以干嘛,让我们回到上面讲多态例子中实例化Cat对象的地方:

Animal *animal = new Cat("aa");

此时这一行代码是编译不过的,提示:

Allocating an object of abstract class type ‘Cat’, unimplemented pure virtual method ‘shut’ in ‘Cat’

写过Java的各位应该有种熟悉的感觉了,这不就是Java实例化一个抽象类的报错么?

是的,C++的纯虚函数,相当于Java的抽象函数,作用很类似,就是当一个类拥有至少一个纯虚函数的时候,这个类就成为一个抽象类,因为存在没有现实的函数,所以不能实例化,所以只能延迟到派生类去实现。而且一个类,必须保证在整个在整个继承体系中必须有实现其所有纯虚函数,才可以被实例化

用上面的例子通俗易懂地说,就是Animal有一个函数shut(喊叫),但是因为这只Animal我们不知道具体是什么动物,所以具体怎么shut交由子类,即具体的动物去实现。我们在Cat和Dog分别实现shut方法:

Cat.h:

void shut() override;

Cat.cpp:

void Cat::shut() {
    std::cout << "miao miao ~~" << std::endl;
}

Dog.h:

void shut() override;

Dog.cpp:

void Dog::shut() {
    std::cout << "wang wang ~~" << std::endl;
}

main.cpp:

Animal *pCat = new Cat("aa");
  pCat->shut();
  Animal *pDog = new Dog("aa");
  pDog->shut();
  delete pCat;
  delete pDog;

运行:

Animal构造函数
Cat构造函数
miao miao ~~
Animal构造函数
Dog构造函数
wang wang ~~
~Cat析构函数
~Animal析构函数
~Dog析构函数
~Animal析构函数

结合之前谈到的构造函数、析构函数、多态,看起来打印结果没有任何问题~

总结

C++的继承和Java还是非常相似的,只是对权限的控制会比Java复杂一点,最大的不同是拥有多继承的能力,不过这也是导致其非常复杂容易出问题的地方,以至于后辈的语言基本抛弃了这个语法特性。

后面又谈到了多态,要注意的是在C++中,必须是虚函数才能支持多态的特性,而这里最容易踩到的坑,就是子类析构函数不是虚函数导致析构函数没有被调用的情况,使得程序出现内存泄漏的风险。

最后又谈到了纯虚函数,基本和Java的抽象函数很类似,相信这个对于Java开发者也是非常容易理解。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值