参考自《C++ Primer Plus 6th Edition》
除了修改代码外,有两种方法能够用来扩展类的定义,一个是组合,另一个就是继承
组合: 使用类型为别类的成员变量
继承: 从已有的类派生出新类,在新类中加入新的成员
分为三部分: 第一部分讲述多态,第二部分讲述派生类的方法,第三部分讲一些额外补充的知识,比较杂
第一部分 多态
概念:
多态是针对类中的成员函数而言的。设有一个父类parent,和 其子类(child1 ...childN)若干。设这些类中都定义了一个使用“多态机制”的成员函数A。这个成员函数A在每个类中,名称相同,参数列表(argument-list)也相同。如果我们为每个类都创建了一个对象,那么使用这些对象调用方法A,自然调用的都是对象所属类的那个A。那如果,声明父类parent的引用/指针,并且让这些引用/指针指向这些对象,再使用这些引用/指针来调用方法A呢? 答案还是一样,每个引用/指针会使用它所指的那个对象来调用方法A。
若方法A没有使用“多态机制”,那么引用/指针调用方法A会发生什么情况呢? 那么,它们都将调用,引用/指针的类型--parent类中的方法A。
以上情况可以推广: “ 如果没有使用多态,将根据引用/指针的类型来选择方法。如果使用了多态,将根据引用/指针指向的对象的类型来使用方法 。”
优点:
前提: 需要创建子类 child1...childN的多个对象,并调用这些对象的A方法。
若不使用多态,那么创建这些对象后,你需要记住这每一个对象的名称,调用的时候,把这些名称,一个不落地写出,也就是说,记忆的任务交给了程序员。
若使用多态,你只需再创建对象后,把这些对象放到 类型为parent引用/指针的数组中,调用的时候,令数组元素访问方法A即可。即,可以使用一个数组来表示多种类型的对象。这就是多态性。
实现:
有两种方式可以实现 “多态公有继承”:
1. 在子类中重新定义父类的方法 ( 即override,函数名和参数列表均相同 )
2. 使用虚方法 ( 在子类和父类的那个要使用多态的方法名前加关键字 “virtual” )
更多:
我们知道,使用多态的时候,我们可以将对象赋给指针/引用。当使用指针/引用来访问多态方法时,程序会让所指向的那个对象调用它所在的类方法。但有时,所指向的那个对象的类型是不能在编译时确定的。因此,C++规定,当使用指针/引用来调用虚函数时,使用动态匹配(dynamic binding)。反之,使用静态匹配(static binding)。
举个简单的例子:
#include <iostream> using std::cin; using std::cout; class parent { public: virtual void print() { cout << "This is parent./n"; } }; class child1 : public parent { public: void print() { cout << "This is child one./n"; } }; class child2 : public parent { public: void print() { cout << "This is child two./n"; } }; int main() { int choice; parent* p; cin >> choice; if(choice == 0) { parent obj; p = &obj; } else if (choice == 1) { child1 obj; p = &obj; } else { child2 obj; p = &obj; } p->print(); return 0; }View Code
在上面的例子中,父类指针p最终指向哪个类型的对象,调用那个类中的函数版本,取决于程序员的输入
既然,无论何时,使用动态匹配,程序都不会错。为什么C++不默认使用它呢 ?
两个原因:
1. 效率,为了使用动态匹配在运行阶段进行决策,必定使用了一些方法来跟踪,这会有额外的开销。如果,子类不重新定义父类的任何方法(不使用多态), 那么根本不需要动态匹配。况且,就算子类重新定义了父类中的某些方法,所编写的代码还不一定需要动态匹配呢。
2. C++的指导原则之一是: “不要为不使用的特性付出代价”。
第二部分 派生类的方法
构造函数 (constructor)
子类构造函数的函数体,执行之前,会先调用父类构造函数构造父类的对象。然后,才执行子类构造函数体中的代码,完成子类新定义成员的初始化。最终“组合”成一个子类的对象。可以选择将父类构造函数的调用放在子类构造函数的成员初始化列表(member-initializer-list)中。否则将调用父类的默认构造函数。
复制构造函数(copy constructor)是特殊的构造函数。 当子类成员中的新成员需要使用new来进行动态内存分配的时候,需要显式定义子类的复制构造函数 。函数体中涉及具体的内存使用问题,由程序员自行解决。
此外,构造函数不可以是虚函数。这并没有意义。想一想,当一个对象被赋给父类的指针/引用之前,它必须已经被初始化(调用过确定的构造函数了)。因此,这并没有意义。
析构函数 (destructor)
和父子类构造函数调用的顺序相反。当一个对象析构的时候,将先调用子类的析构函数,然后才调用父类的析构函数。
当子类成员中的新成员需要使用new来进行动态内存分配的时候,需要显式定义子类的析构函数。函数体中涉及具体的内存使用问题,由程序员自行解决。然而,要注意,父类中的成员情况无需考虑,因为父类已经封装(encapsulate)好了自己的解决方法,子类只需关注于解决自己定义的新成员带来的问题就好。
析构函数可以是虚函数。而且父类的析构函数被定义为虚函数在某种情况下是必要的:
父类的指针/引用指向一个对象,且要释放这个指针或引用的时候:
如果,析构函数不是虚函数,那么将仅调用父类的析构函数,而不会调用子类的析构函数。当子类的析构函数涉及新成员内存的释放时,就会发生内存泄漏(memory leak)。
举例如下:
#include <iostream> using namespace std; class Parent { public: virtual ~Parent() { cout << "destructor of Parent./n"; } }; class Child: public Parent { public: ~Child() { cout << "destructor of Child./n"; } }; int main() { { Parent* p = new Child(); delete p; } return 0; }View Code
上述代码中,基类Parent中的析构函数是虚函数,所以调用析构函数的时候,会先调用子类的析构函数,再调用父类的析构函数。
输出如下:
当把virtual去掉的时候,输出如下:
不过上面的例子比较简单,只是突出没有调用到子类的析构函数,但是没有强调不调用析构函数带来的坏处。当子类成员中需要使用动态内存分配。则须在子类的析构函数中进行内存的释放。这时候,不调用析构函数的坏处就显而易见了。
解决方法:若一个类将要作为基类,则将它的析构函数定义为虚函数。
赋值运算符函数 (assignment operator)
当子类成员中的新成员需要使用new来进行动态内存分配的时候,需要显式定义子类的赋值运算符函数。函数体中涉及具体的内存使用问题,由程序员自行解决。如果需要调用父类的赋值运算符函数来复制对象中属于父类的成员部分,则可以使用域限定符来访问父类的赋值运算符函数(assignment operator),像这样: baseClass::operator=(xxx)。其中,xxx是类型为derivedClass的引用。有一点要注意的是: 你不能这样: *this = xxx。
第二种表达不仅包含了第一种表达(对对象的父类成员部分的更新),还包含了对当前类成员部分的更新。而且这个更新正是调用这条语句所在的赋值运算符函数。最终,这个赋值运算符函数就会反复调用自己,没有出口。
举例如下:
#include <iostream> #include <cstring> using namespace std; class Staff { private: int id; public: Staff() {} Staff(int ID) { id = ID; } }; class Teacher : public Staff { private: char* name; public: Teacher() : Staff(0) { name = nullptr; } Teacher(int ID , const char *Name) : Staff(ID) { name = new char [strlen(Name)+1]; strcpy(name,Name); } //copy constructor Teacher(const Teacher& te) : Staff(te) { //use implicit copy constructor name = new char[strlen(te.name)+1]; // of Staff strcpy(name,te.name); } //assignment operator Teacher& operator=(const Teacher& te) { if(this == &te) return *this; //*this = te; //错误语句 Staff::operator=(te); //正确语句 delete [] name; name = new char[strlen(te.name)+1]; strcpy(name,te.name); return *this; } //destructor ~Teacher() { delete [] name; } }; int main() { Teacher Sue(1000,"Sue"); Teacher Coco(1001,"Coco"); Sue = Coco; return 0; }View Code
转换函数 (transfer function)
1. 当需要从其他类型转换到本类型的时候,转换函数是一个带参数的构造函数。这时,转换函数不可以是虚函数。
2. 当需要从本类型转换到其他类型的时候,转换函数需要自定义。这时,转换函数可以是虚函数。
其他成员函数 (others except operator)
其他成员函数视具体需要而定,可以是虚函数。
友元函数 (friend)
友元函数不可以是虚函数,因为友元函数并不是类的成员,只是它可以访问类的成员而已。
很自然的,派生类的友元函数是可以访问基类的保护成员或公有成员的。但是如果想让派生类的友元函数访问基类的友元函数,则需要使用强制类型转换,以匹配友元函数的调用条件。
一个例子:
#include <iostream> using namespace std; class A { public: friend ostream& operator << (ostream& os , const A& a) { os << "This is A "; } }; class B : public A { public: friend ostream& operator << (ostream& os , const B& b) { os << (const A&) b; //使用强制类型转换,将调用A的友元函数 os << "and B/n"; } }; int main() { B b; cout << b; return 0; }View Code
小结
第二部分的重点在于:
1. 当子类成员中的新成员需要使用new来进行动态内存分配的时候,需要显式定义子类的复制构造函数,析构函数,赋值操作符函数。
第三部分 杂谈
好的习惯
如果在派生类中重新定义从基类继承的方法,将不仅是使用相同的argument-list覆盖基类的声明,无论argument-list是否相同,该操作将隐藏所有的同名基类方法。(不管是不是虚函数,只要在派生类中redefine了,都会覆盖)
因此,书中总结了两条经验规则:
1. 如果重新定义继承的方法,应和原来的原型prototype相同。但如果返回类型是基类引用/指针,则可以修改为派生类的引用/指针。这被成为返回类型协变(covariance of return type),因为允许返回类型随类的类型而变化。
2. 如果基类声明被重载了,则应在派生类中重新定义所有的基类版本。派生类中定义的版本的函数体中,可以使用 “基类名::函数名” 来访问父类的版本。
虚函数的工作原理
不同编译器中虚函数的工作原理可能不一样,这里仅仅给出一种实现机制,大家可以发挥自己的想像力,得出自己的可行的解决方案:
编译器会给每一个类的对象添加一个隐藏的成员,这个隐藏的成员中保存了一个指针,它指向这个类的虚函数地址数组。
比如基类对象的这个指针,将指向基类中虚函数的地址数组。派生类对象中也有一个这样的指针,如果派生类重新定义(redefine)了基类中的某个虚函数,那么(派生类的)虚函数地址数组中的某个位置保存新版本的地址。如果派生类添加了新的虚函数,那么这个函数的地址被加入(派生类的)虚函数地址数组中。
当把一个子类的对象赋给父类的引用/指针后,使用这个引用/指针访问虚函数的时候,做以下4件事情,先后顺序不严格区分
1. 首先拿到所指对象的指针vptr。
2. 获悉在该对象所属的类中,这个虚函数是第i个。
3. 根据这个指针vptr来到指向的虚函数地址数组。
4. 执行数组第i个位置存储的地址中的函数。
书上有详尽的例子,这里就不赘述了。 @《C++ Primer Plus 6th edition》-- 13.4.2.2
抽象基类
概念:
抽象基类常作为一个 通用的接口 。 它广泛用于这种情况: 两个类有共性,但是直接继承存在争议和麻烦,就可以考虑把共性提取出来,放在一个抽象基类中,然后让两个类继承和实现它。
特性:
1.不可以定义它的对象,但可以定义它的引用和指针。
2.抽象基类中至少要有一个纯虚函数。
3. 抽象基类的子类如果没有实现它的父类的所有纯虚函数,那么这个子类也是一个抽象基类。