继承和多态
1. 继承的优缺点
-
优点:
(1)子类可以灵活地改变父类中的已有方法;
(2)能够最大限度的实现代码重用。 -
缺点:
(1)子类无法在运行时改变与父类的继承关系;
(2)修改父类的某些方法,可能会影响到所有的子类;
(3)继承会使系统的架构层次增多,给开发和维护带来困难。
2. 子类不能继承父类的私有成员,只能通过父类的成员函数来访问父类的私有成员
3. 继承方式
在从父类派生一个子类时可以有 3 种派生方式。分别为 public、private 和 protected 。其中 public 派生方式表示父类中的公有方法和受保护方法仍然为私有方法、共有方法和受保护方法。private 派生方式表示父类中的公有方法、受保护方法在子类中都是私有的。protected 派生方式表示示父类中的公有方法、受保护方法在子类中都是受保护的。
4. 如何访问被隐藏的基类成员?
#include <iostream>
#include <cstring>
#define MAXLEN 128 //定义一个宏
using namespace std;
class CEmployee //定义员工类
{
protected: //定义 protected 数据成员
char m_szName[MAXLEN]; //定义员工姓名
public:
CEmployee() //定义默认构造函数
{
memset(m_szName, 0, MAXLEN); //初始化 m_szName
}
void SetName(const char* pszName) //设置员工姓名
{
strcpy(m_szName, pszName);
}
char* GetName()const //获取员工姓名
{
return (char*) m_szName;
}
void OutputName() //输出员工姓名
{
cout << "CEmployee-->员工姓名: " << m_szName << endl;
}
};
class COperator : public CEmployee //定义一个操作员类,从 CEmployee 类派生而来
{
private:
char m_szPassword[MAXLEN]; //定义密码
public:
COperator() //构造函数
{
memset(m_szPassword, 0, MAXLEN);
}
void SetPassword(const char* pszPassword) //设置密码
{
strcpy(m_szPassword, pszPassword);
}
char* GetPassword()const //获取密码
{
return (char*) m_szPassword;
}
bool Login() //定义登录方法
{
if (strcmp(m_szName, "MR")==0 //比较用户名
&& strcmp(m_szPassword, "KJ")==0) //比较密码
{
cout << "登录成功!" << endl; //输出信息
return true; //设置返回值
}
else
{
cout << "登录失败!" << endl; //输出信息
return false; //设置返回值
}
}
void OutputName() //输出员工姓名
{
cout << "COperator-->员工姓名: " << m_szName << endl;
}
};
int main(int argc, char* argv[])
{
COperator Operator;
Operator.SetName("sk");
Operator.OutputName();
return 0;
}
上述代码中 CEmployee 类和 COperator 类都定义了一个 OutputName() 方法。在 main 函数中执行 Operator.OutputName();
语句将访问的是子类(COperator 类)中的方法,请对该语句进行修改,使其能够访问父类(CEmployee 类)中的 OutputName() 方法。
在本题中,父类 CEmployee 定义了一个 OutputName() 公有方法。子类 COperator 又定义了一个 OutputName() 方法。那么在子类中将存在两个 OutputName() 方法。默认情况下,子类对象调用的 OutputName 方法将是子类中定义的方法。如果需要访问父类中的方法需要进行强制类型转换。
例如:
((CEmployee)Operator).OutputName();
5. 构造函数和析构函数的调用顺序
请写出下面代码的运行结果。
#include <iostream>
using namespace std;
class A
{
public:
A()
{
cout << "A 构造函数被调用!" << endl;
}
~A()
{
cout << "A 析构函数被调用!" << endl;
}
};
class B : public A
{
public:
B()
{
cout << "B 构造函数被调用!" << endl;
}
~B()
{
cout << "B 析构函数被调用!" << endl;
}
};
class C : public B
{
public:
C()
{
cout << "C 构造函数被调用!" << endl;
}
~C()
{
cout << "C 析构函数被调用!" << endl;
}
};
int main(int argc, char* argv[])
{
C object;
return 0;
}
输出结果为:
A 构造函数被调用!
B 构造函数被调用!
C 构造函数被调用!
C 析构函数被调用!
B 析构函数被调用!
A 析构函数被调用!
类 C 继承自类 B,而类 B 又继承自类 A 。当构建一个 C 对象时,将至顶向下执行基类的构造函数,最后执行自身的构造函数。因此,本题中将首先调用类 A 的构造函数,然后调用类 B 的构造函数,最后调用类 C 的构造函数。当 C 类对象释放时,将至下向上执行析构函数。本题中将首先调用 C 类的析构函数,然后调用 B 类的析构函数,最后调用 A 类的析构函数。
6. 子类和父类的关系
下面有关基类与其派生类的叙述中,正确的是:
A.派生类对象不能赋给基类对象
B.派生类对象的地址不能赋给其基类的指针变量
C.基类对象不能赋给派生类对象
D.基类对象的地址能赋给其派生类的指针变量
选 C
子类在继承基类时,通常会额外添加一些属性或方法。也就是子类除了具有基类的功能外,还添加了一些自己的功能。将子类对象赋值给基类对象是完全合法的,因为基类能够访问到它所定义的方法。与之相反,将一个基类赋值给子类对象是非法的,因为子类具有基类不具备的行为。上述描述中选项 A 是错误的,选项 C 是正确的。选项 B 和选项 D 围绕的对象的地址赋值。这其实与对象间的赋值原理是相同的。子类对象的地址是可以赋值给基类指针对象的,而基类对象的地址是不能够赋值给子类指针对象的。所以选项 B 和选项 D 都是错误的。
7. 动态绑定
请写出下面代码的运行结果。
#include <iostream>
using namespace std;
class Shape
{
public:
Shape()
{
cout << "Shape was invoked!" <<endl;
}
virtual void Draw()
{
cout << "Draw Shape!" << endl;
}
};
class Circle : public Shape
{
public:
Circle()
{
cout << "Circle was invoked!" << endl;
}
void Draw()
{
cout << "Draw Circle!" << endl;
}
};
int main(int argc, char* argv[])
{
Shape *shape = new Circle(); //定义一个基类指针对象
shape->Draw(); //调用 Draw 方法
delete shape; //释放对象
return 0;
}
输出结果是:
Shape was invoked!
Circle was invoked!
Draw Circle!
本题中关键代码是 main 函数中的前两行语句。
Shape *shape = new Circle();
shape->Draw();
第一行语句定义了一个 Shape 类型的指针对象,但是调用的是子类的构造函数构建对象。第二行语句调用 Draw 方法。由于 Shape 类中的 Draw 方法为虚方法(virtual),所以在执行 shape->Draw();
语句时将采用动态绑定的机制,也就是根据运行时 shape 对象的实际类型来确定具体调用哪一个方法。在本题中,将调用 Circle 类的 Draw 方法,因为 shape 对象是通过 Circle 类的构造函数创建的。此外,还需要注意一点,就是调用 Circle 类的构造函数时,会先调用父类 Shape 的构造函数,然后再调用 Circle 类的构造函数。
8. 简述虚函数的用法和作用
在定义类的成员函数时,如果在函数前使用 virutal 关键字,表示该成员函数为虚函数。虚函数采用动态绑定的机制,当调用虚函数时,它会根据运行时对象的实际类型来确定具体调用哪个函数,而不是根据对象定义时的数据类型来确定。虚函数是现实多态性的最佳方式。
注意:父类中定义为虚方法,子类中重新定义该方法( 函数名和参数列表相同)时,则该方法永远是虚方法,无论是否使用 virtual 关键字。此种情况就不是方法的隐藏了,而是方法的改写或覆盖。
9. 一个父类写了一个 virtual 函数,如果子类覆盖它的函数不加 virtual,也能实现多态? 在子类的空间里, 有没有父类的这个函数,或者父类的私有变量 ?
在父类中定义一个虚函数,子类在改写该函数时,可以不加 virtual 关键字,它默认也是虚函数,不影响多态的实现。在子类的空间里有父类的虚函数,也有父类的所有变量(静态成员变量除外)。
注意:此时所说的情况是子类的空间里,子类可以通过父类的成员函数访问父类的私有成员,所以子类的空间中是有父类的虚函数和所有变量。但是父类中的静态成员变量在内存中只有一份,所以子类的空间中是没有父类的静态成员变量的。
10. 隐藏父类重载的所有方法
请指出下面代码中的错误,并说明原因。
#include <iostream>
using namespace std;
class Animal
{
public:
void Cry()
{
cout << "Unname animal can cry!" << endl;
}
void Cry(char* szName)
{
cout << szName << " animal can cry!" << endl;
}
};
class Bird : public Animal
{
public:
void Cry()
{
cout<<"Bird can cry!" << endl;
}
};
int main(int argc, char* argv[])
{
Bird Bird;
Bird.Cry("bird");
return 0;
}
语句 Bird.Cry("bird");
编译错误。
上述代码中子类 Bird 隐藏了父类中的 Cry() 方法。但是在父类中有两个重载版本的 Cry() 方法。Bird 类将隐藏所有父类同名的方法,,因此语句 Bird.Cry("bird");
试图访问父类中的 void Cry(char* szName)
重载方法时出现编译错误。
当子类隐藏父类中的方法时,会连同父类中同名的重载方法一同隐藏,因此,子类对象无法访问父类中重载的其他方法。
11. 类成员函数的重载、覆盖和隐藏区别
- 重载:是指在同一个类中有多个同名的方法,这些方法参数类型、参数个数或者方法属性(const 属性)不同。
- 覆盖:是指父类中定义了一个虚方法,子类中又重新定义了的该方法。通过覆盖父类的虚方法,可以实现动态绑定。
-
隐藏:是指子类重新定义了父类中的非虚方法,此时,子类中的方法将隐藏父类中的方法。有两种情况会发生隐藏:
- 如果子类的函数与父类的函数同名,但是参数不同。此时,不论有无 virtual 关键字,父类的函数将被隐藏。
- 如果子类的函数与父类的函数同名,并且参数也相同,但是父类函数没有 virtual 关键字。此时,父类的函数被隐藏。
12. const 对象不能够调用非 const 方法
13. 在程序中,重载成员函数可以实现静态多态性,而虚函数可以实现动态多态性。它们在范围上有着明显的不同。重载成员函数发生在同一个类中;虚函数需要在父类和子类中才能得到体现。
14. 析构函数为什么要设计为虚函数?
析构函数设计为虚函数,在动态绑定时可以保证子类的析构函数能够被调用,有效阻止了内存泄露的产生。
考虑这样一种情况:定义一个基类类型的指针,调用子类的构造函数为其构建对象,当对象释放时,先调用父类的析构函数还是先调用子类的析构函数,再调用父类的析构函数呢?答案是如果析构函数是虚函数,则先调用子类的析构函数,然后再调用父类的析构函数,如果析构函数不是虚函数,则只调用父类的析构函数。可以想象,如果在子类中为某个数据成员在堆中分配了空间,父类中的析构函数不是虚方法,上述情况将使子类的析构函数不会被调用,其结果是对象不能被正确地释放,导致内存泄露的产生。
15. 动态多态的两个必要条件
- 父类需要定义虚函数,子类改写该函数
- 定义一个基类指针,调用子类构造函数构建对象
多态性分为静态多态性和动态多态性两种。其中静态静态性是指在编译期间确定具体执行哪一项操作,它主要是通过方法重载和运算符重载来实现的;动态多态性是指在运行时确定具体执行哪一项操作。它主要是通过虚函数来实现的。
16. 类占用的内存空间
关于 a 的定义,请判断 sizeof(a) 的结果。
class a
{
public:
virtual void funa( );
virtual void funb( );
void func( );
static void fund( );
static int si;
private:
int i;
char c;
};
sizeof(a) = 12
本题中虚拟方法表指针占 4 个字节,i 成员占 4 个字节,c 成员占 1 个字节。但是由于字节对齐,c 成员当前 “ 索引位置 ” 是 9 不是 4 的整数倍,需要额外在分配 3 个字节空间。因此 sizeof(a) 的结果为 12。
- 如果类中含有虚方法,则编译器需要为类构建虚拟方法表,类中需要有一个指针,指向这个虚拟方法表的地址。在 32 位的系统中,它占用 4个字节。
- 类中的静态成员是被类所有实例所共享的,它不计入 sizeof 计算的空间。
- 类成员采用字节对齐的方式分配空间。
17. 类的继承和多态
以下程序的输出结果是什么?
#include <iostream>
using namespace std;
class A
{
public:
void f(void)
{
cout << "A::f" << " ";
}
virtual void g(void)
{
cout << "A::g" << " ";
}
};
class B : public A
{
public:
void f(void)
{
cout << "B::f" << " ";
}
void g(void)
{
cout << "B::g" << " ";
}
};
int main()
{
A* pA = new B;
pA->f();
pA->g();
B* pB = (B*)pA;
pB->f();
pB->g();
return 0;
}
输出结果为:A::f B::g B::f B::g
main 函数中首先定义了一个类 A 的指针对象,调用子类 B 的构造函数进行构建。语句 pA->f();
将调用类 A 中的 f 方法,因为类 A 中的 f 方法是普通方法,不是虚方法,编译器将根据 pA 定义时的类型(类 A)确定调用哪一个类的方法。 pA->g();
语句将调用类 B 中的 g 方法,因为类 A 中的 g 方法为虚方法,编译器将根据 pA 运行时的类型(由类 B 的构造函数构建)来确定调用哪一个类的 g 方法。接着又定义了一个 B 指针对象 pB,将其指向 pA 对象。语句 pB->f();
将调用类 B 的 f 方法,因为 f 是普通方法,pB 定义的类型是 B 指针类型。语句 pB->g();
调用类 B 中的 g 方法。
18. 类的多层继承
请写出下面程序的运行结果:
#include <iostream>
using namespace std;
class A
{
public:
virtual void print(void)
{
cout << "A::print()" << endl;
}
};
class B : public A
{
public:
virtual void print(void)
{
cout << "B::print()" << endl;
}
};
class C : public B
{
public:
virtual void print(void)
{
cout << "C::print()" << endl;
}
};
void print(A a)
{
a.print();
}
int main()
{
A a, *pa, *pb, *pc;
B b;
C c;
pa = &a;
pb = &b;
pc = &c;
a.print();
b.print();
c.print();
pa->print();
pb->print();
pc->print();
print(a);
print(b);
print(c);
return 0;
}
输出结果为:
A::print()
B::print()
C::print()
A::print()
B::print()
C::print()
A::print()
A::print()
A::print()
第一组输出语句 a.print();b.print();c.print();
的输出结果为 A::print() B::print() C::print()
。因为对象 a、b、c 的类型分别为类 A、类 B 和类 C。它们会各自调用各自类中定义的 print 方法。
第二组输出语句 pa->print();pb->print();pc->print();
的输出结果为 A::print() B::print() C::print()
。因为pa、pb 和 pc 对象分别指向类 A 对象 a、类 B 对象 b 和类 C 对象 c。
第三组输出语句是本题的难点,也是本题的精华 print(a);print(b);print(c);
。他们都调用 print 函数来输出语句,而 print 函数包含了一个类A类型的参数a,该函数采用值传递方式。语句 print(a);
执行结果为 A::print()
,这没有任何疑问。关键是语句 print(b);
和 print(c);
的执行结果。调用 print(b);
语句时,由于 print 函数采用值传递,将调用类 A 的拷贝构造函数(系统默认提供)根据实际参数 b 来构建类 A 对象。在 print 函数体中参数 a 的实际类型为 A。因此调用 print(b);
语句输出结果为A::print()
,print(c);
语句也同样输出 A::print()
。
如果在本题中将 print 函数修改为引用方式传递,例如:
void print(A &a)
{
a.print();
}
则第三组的输出结果为:A::print() B::print() C::print()
。
19. 怎样定义一个纯虚函数?含有纯虚函数的类称为什么?
纯虚函数的定义是在定义虚函数的基础上,在虚函数末尾添加 “ = 0 ” ,同时函数没有函数体,也就是没有函数的实现部分。含有纯虚函数的类被称为抽象类,不能够实例化一个抽象类,即不能定义抽象类对象。
在 C++语言中,除了能够定义虚函数之外,还可以定义纯虚函数,也就是通常所说的抽象函数。一个包含纯虚函数的类被称为抽象类,抽象类是不能够被实例化的,通常用于实现接口的定义。
例如:
#define MAXLEN 128 //定义一个宏
class CEmployee //定义一个抽象类
{
protected:
int m_nID; //定义员工 ID
char m_szName[MAXLEN]; //定义员工姓名
char m_szDepart[MAXLEN]; //定义所属部门
public:
virtual void OutputName() = 0; //定义抽象方法
};
上述代码中为 CEmployee 类定义了一个纯虚方法 OutputName 。纯虚方法的定义是在虚方法定义的基础上在末尾添加 “ = 0 ” 。对于包含纯虚方法的类来说,是不能够实例化的。抽象类通常用于作为其他类的父类,从抽象类派生的子类如果不是抽象类,则子类必须实现父类中的所有纯虚函数。
例如:
class COperator : public CEmployee //定义一个操作员类,从 CEmployee 类派生而来
{
public:
COperator()
{
strcpy(m_szName, "MR");
}
virtual void OutputName() //实现纯虚方法
{
cout << "操作员姓名: " << m_szName << endl; //输出操作员姓名
}
};
class CSystemManager : public CEmployee //定义一个管理类,从 CEmployee 类派生而来
{
public:
CSystemManager()
{
strcpy(m_szName, "MRSoft");
}
virtual void OutputName() //实现纯虚方法
{
cout << "系统管理员: " << m_szName << endl; //输出操作员姓名
}
};
上述代码从 CEmployee 类派生了两个子类,分别为 COperator 和 CSystemManager 。这两个类分别实现了父类的纯虚方法 OutputName 。下面定义一个 CEmployee 类的指针,然后分别利用 COperator 类的构造函数和 CSystemManager 类的构造函数创建对象,并调用 OutputName 方法。
int main()
{
CEmployee *pWorker; //定义 CEmployee 类型指针对象
pWorker = new COperator(); //调用 COperator 类的构造函数为 pWorker 赋值
pWorker->OutputName(); //调用 COperator 类的 OutputName 方法
delete pWorker; //释放 pWorker 对象
pWorker = NULL; //将 pWorker 对象设置为空
//调用 CSystemManager 类的构造函数与为 pWorker 赋值
pWorker = new CSystemManager();
pWorker->OutputName(); //调用 CSystemManager 类的 OutputName 方法
delete pWorker; //释放 pWorker 对象
pWorker = NULL; //将 pWorker 对象设置为空
return 0;
}
运行结果为:
操作员姓名: MR
系统管理员: MRSoft
在抽象类中也可以定义普通的数据成员和成员函数,但是不能够实例化抽象类。一个类无论有多少个方法,只要有一个方法是抽象方法(纯虚函数),那么这个类就是抽象类。
20. 什么是多继承?它的格式是什么?
多继承是指一个子类能够从多个类派生,也就是它可以同时具有多个父类。它的语法格式与单继承类似。只是可以指定多个父类。
例如:
class CWaterBird : public CBird, public CFish
C++语言除了支持单继承外,还支持多继承,即允许一个子类同时从多个类派生。下面通过一个例子来介绍多继承的设计过程。我们需要设计一个鸟类,它具有飞翔功能,然后设计一个鱼类,它具有水里游的功能。如果我们设计既可以飞翔,又可以在水中游的水鸟类,则可以直接从鸟类和鱼类派生。
#include <iostream>
using namespace std;
class CBird //定义一个鸟类
{
public:
void FlyInSky()
{
cout << "鸟能够在天空中翱翔!" << endl;
}
void Breath()
{
cout << "鸟能够呼吸!" << endl;
}
};
class CFish //定义一个鱼类
{
public:
void SwimInWater()
{
cout << "鱼能够在水中游!" << endl;
}
void Breath()
{
cout << "鱼能够呼吸!" << endl;
}
};
class CWaterBird : public CBird, public CFish //定义水鸟类
{
public:
void Action()
{
cout << "水鸟既能飞又能游!" << endl;
}
};
int main()
{
CWaterBird waterbird;
waterbird.FlyInSky();
waterbird.SwimInWater();
waterbird.CBird::Breath();
waterbird.CFish::Breath();
return 0;
}
运行结果为:
鸟能够在天空中翱翔!
鱼能够在水中游!
鸟能够呼吸!
鱼能够呼吸!
上述代码定义了鸟类 CBird,定义了鱼类 CFish、然后从鸟类和鱼类派生了一个子类水鸟类 CWaterBird。水鸟类自然继承了鸟类和鱼类的所有共有和受保护的成员。因此 CWaterBird 类对象能够调用 FlyInSky 和 SwimInWater 方法。在 CBird 类中提供了一个 Breath 方法,在 CFish 类中同样提供了 Breath 方法,如果 CWaterBird 类对象调用 Breath 方法,需要在 Breath 方法前具体指定类名。
例如:
Waterbird.CFish::Breath(); //调用 CFish 类的 Breath 方法
Waterbird.CBird::Breath(); //调用 CBird 类的 Breath 方法
21. 虚继承的作用
在多继承中,子类可以同时拥有多个父类,如果这些父类还有相同的父类(祖先类),那么在子类中就会有两份祖先类。例如,类 B 和类 C 均继承于类 A,如果类 D 派生于类 B 和类 C,那么类 D 中将有两份类 A。为了防止在多继承中,子类存在重复的父类情况,可以在父类继承时使用虚继承。即在类 B 和类 C 继承类 A 时使用 virtual 关键字 。
例如:
class B : virtual public A
class C : virtual public A
在程序开发过程中,多继承虽然带来了很多方便,但是很少有人愿意使用它,因为多继承会带来很多复杂的问题,并且多继承能够完成的功能,通过单继承同样可以实现。因此,在开发应用程序时,如果能够使用单继承实现,尽量不要使用多继承。