继承、封装、多态是面向对象编程的三大基本特性,前面章节通过学习类和对象的使用我们已经掌握了封装的概念,而继承允许子类继承父类的属性和方法从而实现代码的复用和扩展;多态则是指在不同对象上调用相同的函数接口能表现出不同的行为。这篇笔记我们对C++中如何实现继承和多态进行介绍。
C++中,子类可以通过: 继承修饰符 父类名
的方式实现类继承。下面例子代码中,子类Dog
继承父类Animal
。
#include <iostream>
#include <string>
class Animal {
public:
Animal(std::string name) : name(name) {}
std::string name;
void eat() { std::cout << name << " is eating." << std::endl; }
};
class Dog : public Animal {
public:
Dog(std::string name) : Animal(name) {}
void bark() { std::cout << name << ": Woof! Woof!" << std::endl; }
};
int main() {
Dog spike = Dog("Spike");
spike.bark();
spike.eat();
return 0;
}
和其它OOP编程语言不同,C++的继承可以指定修饰符。public
继承的表现和其它语言类似,但C++还支持protected
继承和private
继承。
继承方式 | 父类成员的访问权限在子类中的表现 |
---|---|
public | public → public, protected → protected |
protected | public/protected → protected |
private | public/protected → private |
至于私有成员在子类中无法直接访问,但可以通过父类的公有方法间接访问。这里我们不要理解错了,私有成员并不是不继承,只是在C++语法层面被访问控制修饰符“隐藏”了,不能直接访问,从底层的内存分配角度观察,它实际上还是继承到了子类中的。
C++中子类调用父类构造函数需要在子类构造函数的初始化列表中进行,之前代码就是这样实现的。
class Dog : public Animal {
public:
Dog(std::string name) : Animal(name) {}
void bark() { std::cout << name << ": Woof! Woof!" << std::endl; }
};
父类构造函数只能在子类构造函数的初始化列表中调用,不能在子类的函数体中调用,这是C++的语法规定,构造函数只能在对象构造的那一刻调用,而这发生在进入构造函数体之前,也就是在初始化列表中。不过不用担心,即使你需要在子类对父类构造参数进行一些加工也是可以实现的,我们将其包装为一个函数或是使用C++11的Lambda都可以实现,下面例子演示了这一写法。
class Dog : public Animal {
std::string calcName(std::string name) { return "Mr. " + name; }
public:
Dog(std::string name) : Animal(calcName(name)) {}
void bark() { std::cout << name << ": Woof! Woof!" << std::endl; }
};
C++中,子类访问父类的成员时需要使用作用域解析运算符::
,不过如果子类没有同名变量时它是可以省略的,因此我们之前的代码在子类Dog
中访问父类Animal
的name
成员变量时就将其省略了,如果明确写出,其实它可以写作Animal::name
。
class Dog : public Animal {
public:
Dog(std::string name) : Animal(name) {}
void bark() { std::cout << Animal::name << ": Woof! Woof!" << std::endl; }
};
C++支持多继承,一个类可以继承多个类,和单继承类似,如果多继承中成员标识符有冲突,我们在子类中访问成员时需要明确写出作用域解析运算符::
。实际开发中,我们应该少用多继承,虽然C++提供了这样的功能,但后来的经验告诉我们这绝不是个好用的功能,尤其是要尽力避免菱形继承。
菱形继承会产生二义性问题,举例来说,基类动物有name
成员变量,猫类和狗类都继承动物类,现在我们又定义了宠物类同时继承猫类和狗类,此时宠物类包含了两个继承自基类的name
成员变量,这显然从逻辑上就产生了歧义。我们应该尽量避免这种代码的出现。
C++中多态其实可以分为两种:
静态多态:函数重载和运算符重载属于静态多态,它们实现了函数名的复用
动态多态:基于派生类和虚函数可以实现动态多态,这种多态之所以叫动态多态是因为行为的差异在运行时体现
函数重载和运算符重载前面我们已经介绍过了,这里我们主要关注动态多态。动态多态通常结合虚函数使用,虚函数允许我们通过指针或引用调用派生类中重写的函数而不是基类中的函数,从而表现出运行时多态。虚函数使用virtual
关键字声明,派生类中重写时则需要使用override
声明。下面例子演示了这一过程,Cat
类和Dog
类都派生自Animal
类,它们都重写了虚函数bark
。随后我们调用时,虽然操作的对象类型被声明为Animal
,但它们表现出了不同的行为。
#include <iostream>
#include <string>
class Animal {
public:
Animal(std::string name) : name(name) {}
std::string name;
void eat() { std::cout << name << " is eating." << std::endl; }
virtual void bark() {};
};
class Dog : public Animal {
public:
Dog(std::string name) : Animal(name) {}
void bark() override {
std::cout << Animal::name << ": Woof! Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
Cat(std::string name) : Animal(name) {}
void bark() override { std::cout << Animal::name << ": Meow!" << std::endl; }
};
int main() {
Dog dog("Spike");
Animal &spike = dog;
spike.bark();
Cat cat("Tom");
Animal &tom = cat;
tom.bark();
return 0;
}
注意:override
关键字是C++11引入的特性,即使不加程序也没有问题,override
关键字对程序实际的逻辑没有影响,它的存在意义是用于告知编译器该函数重写了虚函数,如果编译器检查发现实际情况不是这样就抛出错误,这样可以避免意外的函数隐藏等潜在Bug,因此建议涉及重写虚函数时都添加上该关键字。
虚函数是怎么实现的?实际上,编译器在处理虚函数时会生成一张虚函数表(vtable),对象中会有一个指向vtable的指针叫作vptr。在运行时,该表会动态绑定真正应该执行的函数地址,当通过基类指针或引用调用虚函数时,程序会通过vptr查找vtable中对应函数的地址并调用,这样多态就实现了。
之前代码其实还是有一些瑕疵,我们的基类Animal
中的bark()
包含了一个空的函数体,它根本没有意义。实际上,除了虚函数,C++还有一种没有实现的虚函数,被称为纯虚函数。纯虚函数没有函数体,它必须在派生类中重写,包含一个或多个纯虚函数的类称为抽象类,抽象类不能实例化。下面例子代码中,Animal
是一个抽象类,它包含的bark()
成员函数没有实现,是一个纯虚函数。
#include <iostream>
#include <string>
class Animal {
public:
Animal(std::string name) : name(name) {}
std::string name;
void eat() { std::cout << name << " is eating." << std::endl; }
virtual void bark() = 0;
};
class Dog : public Animal {
public:
Dog(std::string name) : Animal(name) {}
void bark() override {
std::cout << Animal::name << ": Woof! Woof!" << std::endl;
}
};
class Cat : public Animal {
public:
Cat(std::string name) : Animal(name) {}
void bark() override { std::cout << Animal::name << ": Meow!" << std::endl; }
};
int main() {
Dog dog("Spike");
Animal &spike = dog;
spike.bark();
Cat cat("Tom");
Animal &tom = cat;
tom.bark();
return 0;
}
在使用多态时,如果子类有属性在堆区申请了内存,使用父类指针或引用时无法调用到子类的析构函数。这种情况下,我们需要在父类中添加虚析构函数或纯虚析构函数。下面例子代码中,父类Animal
中添加了虚析构函数,当delete spike
释放对象时,我们可以看到子类的析构函数被调用了。
#include <iostream>
#include <string>
class Animal {
public:
Animal(std::string name) : name(name) {}
std::string name;
void eat() { std::cout << name << " is eating." << std::endl; }
virtual void bark() = 0;
virtual ~Animal() {};
};
class Dog : public Animal {
public:
Dog(std::string name) : Animal(name) {}
void bark() override {
std::cout << Animal::name << ": Woof! Woof!" << std::endl;
}
~Dog() { std::cout << "Dog destructor called for " << name << std::endl; }
};
int main() {
Animal *spike = new Dog("Spike");
spike->bark();
spike->eat();
delete spike;
return 0;
}
至于纯虚析构函数,它是一种特殊的纯虚函数,其语法略显奇怪,纯虚析构函数的使用目的是使一个类成为抽象类同时确保派生类的析构函数能被正确调用。最为诡异的是虽然名义上是纯虚函数,但析构函数一定要有定义,因为在析构对象时仍然需要调用它,所以纯虚析构函数也要有定义,否则程序链接时会报错。总而言之这也是C++中一个比较怪异的语法,它的存在有其历史原因,我们记住即可。下面例子我们继续改造之前的代码,Animal
的虚析构函数被我们改为了纯虚析构函数。
class Animal {
public:
Animal(std::string name) : name(name) {}
std::string name;
void eat() { std::cout << name << " is eating." << std::endl; }
virtual void bark() = 0;
virtual ~Animal() = 0;
};
Animal::~Animal() {}
注意代码的最后一行,纯虚析构函数的定义我们将其放在了类的外部。