C++练习题3
C++练习题3
前言:
7-1 比较类的3种继承方式public(公有继承)、protcted(保护继承)、Private(私有继承)之间的差别。
在C++中,类可以通过三种继承方式来派生新的类:公有继承(public inheritance)、保护继承(protected inheritance)和私有继承(private inheritance)。这些继承方式之间有着不同的访问权限和继承特性。
- 公有继承(public inheritance):
- 子类继承了父类的公有成员和保护成员,但不继承私有成员。
- 在公有继承中,父类的公有成员和保护成员在子类中仍然保持其原有的访问权限。
- 公有继承体现了"is-a"关系,即子类是父类的一种类型。
- 公有继承用于建立一种接口继承关系,使子类可以访问父类的接口。
- 保护继承(protected inheritance):
- 子类继承了父类的保护成员,但不继承公有成员和私有成员。
- 在保护继承中,父类的公有成员在子类中被降级为保护成员。
- 保护继承用于建立一种实现继承关系,子类可以访问父类的实现,但不会暴露给外部接口。
- 私有继承(private inheritance):
- 子类继承了父类的私有成员,但不继承公有成员和保护成员。
- 在私有继承中,父类的公有成员和保护成员在子类中都被降级为私有成员。
- 私有继承用于建立一种实现继承关系,并且完全隐藏了父类的接口,使其不能被子类的客户端访问。
综上所述,这三种继承方式在继承的访问权限和继承特性上有所不同,选择合适的继承方式取决于需求和设计目标。通常情况下,公有继承用于"is-a"关系,保护继承用于实现继承,而私有继承则用于实现继承并完全隐藏父类的接口。
7-2 派生类构造函数执行的次序是怎样的?
派生类构造函数执行的次序遵循以下规则,这些规则确保了派生类对象正确地初始化:
基类构造函数的执行:在派生类构造函数执行之前,基类的构造函数会首先被调用,按照继承层次从顶层基类开始逐级调用,直到最底层的基类。这样确保了基类的成员在派生类构造函数执行之前得到正确的初始化。
派生类成员的初始化:在基类构造函数调用完成后,派生类构造函数执行。派生类的成员变量按照其声明的顺序进行初始化,不受基类构造函数调用顺序的影响。
派生类构造函数的执行:派生类构造函数的执行体中的代码会按照程序中的顺序依次执行,完成派生类特定的初始化操作。
总结起来,派生类构造函数的执行顺序是先调用基类构造函数,再初始化派生类的成员变量,最后执行派生类构造函数的执行体。这样的顺序确保了派生类对象在构造时正确地初始化,从而保证了程序的正确性和可靠性。
7-3 如果派生类B已经重载了基类A的一个成员函数fn1(),没有重置基类的成员函数fn2(),如何在派生类的函数中调用基类的成员函数n1(),fn2()?
在派生类中调用基类的成员函数,无论是已经重载了的成员函数还是没有重载的成员函数,都可以通过作用域解析运算符
::
来实现。具体步骤如下:
对于已经重载的成员函数,如果想要在派生类的成员函数中调用基类的版本,可以通过在函数名前加上基类名和作用域解析运算符
::
来调用。对于没有重载的成员函数,直接在派生类的成员函数中调用即可,无需使用作用域解析运算符。
下面是一个示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using namespace std;
class A {
public:
void fn1() {
cout << "A::fn1()" << endl;
}
void fn2() {
cout << "A::fn2()" << endl;
}
};
class B : public A {
public:
void fn1() {
cout << "B::fn1()" << endl;
// 调用基类的 fn1()
A::fn1();
}
void callBaseFn2() {
cout << "Calling base class fn2() from derived class" << endl;
// 调用基类的 fn2()
A::fn2();
}
};
int main() {
B objB;
objB.fn1(); // 调用派生类 B 的 fn1()
objB.callBaseFn2(); // 调用派生类 B 的 callBaseFn2(),其中调用了基类 A 的 fn2()
return 0;
}在这个示例中,类
A
中有两个成员函数fn1()
和fn2()
,类B
继承自类A
。在派生类B
中重载了成员函数fn1()
,并且在成员函数callBaseFn2()
中调用了基类A
的成员函数fn2()
。通过作用域解析运算符::
,我们可以明确地调用基类的成员函数,无论它是否被派生类重载。在派生类中调用基类的成员函数,无论是已经重载了的成员函数还是没有重载的成员函数,都可以通过作用域解析运算符::
来实现。具体步骤如下:
对于已经重载的成员函数,如果想要在派生类的成员函数中调用基类的版本,可以通过在函数名前加上基类名和作用域解析运算符
::
来调用。对于没有重载的成员函数,直接在派生类的成员函数中调用即可,无需使用作用域解析运算符。
下面是一个示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using namespace std;
class A {
public:
void fn1() {
cout << "A::fn1()" << endl;
}
void fn2() {
cout << "A::fn2()" << endl;
}
};
class B : public A {
public:
void fn1() {
cout << "B::fn1()" << endl;
// 调用基类的 fn1()
A::fn1();
}
void callBaseFn2() {
cout << "Calling base class fn2() from derived class" << endl;
// 调用基类的 fn2()
A::fn2();
}
};
int main() {
B objB;
objB.fn1(); // 调用派生类 B 的 fn1()
objB.callBaseFn2(); // 调用派生类 B 的 callBaseFn2(),其中调用了基类 A 的 fn2()
return 0;
}在这个示例中,类
A
中有两个成员函数fn1()
和fn2()
,类B
继承自类A
。在派生类B
中重载了成员函数fn1()
,并且在成员函数callBaseFn2()
中调用了基类A
的成员函数fn2()
。通过作用域解析运算符::
,我们可以明确地调用基类的成员函数,无论它是否被派生类重载。
7-4 什么叫做虚基类?它有何作用?
虚基类是在多重继承中用来解决菱形继承问题的一种机制。菱形继承问题是指一个派生类继承了同一个基类的两个实例,间接形成了一个菱形的继承结构。这种情况下,派生类会包含两份来自基类的成员,导致了二义性和资源浪费。
虚基类通过将基类标记为虚基类来解决这个问题。当一个类被声明为虚基类时,它的派生类中的其他类只继承一份虚基类的成员,而不是继承多份。这样可以消除多份继承带来的二义性,也减少了资源的浪费。
虚基类的作用包括:
消除二义性:通过只继承一份虚基类的成员,避免了派生类中存在多份相同成员的情况,从而消除了二义性。
节省资源:避免了多重继承中基类成员的重复继承,节省了内存空间和资源。
维护继承关系:虚基类的存在可以清晰地表明继承关系,有助于维护代码的结构和可读性。
虚基类的使用通常是在多重继承场景中,当一个类需要作为多个派生类的公共基类时,可以考虑将其声明为虚基类。通过虚基类,可以更好地管理继承关系,避免了菱形继承问题带来的困扰,提高了代码的健壮性和可维护性。
7-5 定义一个基类Shape,在此基础上派生出Rectangle和Cirele,二者都有getArea()函数计算对象的面积。使用Rectangle类创建一个派生类Square。
在这个问题中,我们首先定义一个基类
Shape
,然后派生出两个类Rectangle
和Circle
,它们分别表示矩形和圆形。每个类都有一个成员函数getArea()
用于计算对象的面积。接着,我们再创建一个派生类Square
,它是从Rectangle
类继承而来的。下面是相应的代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using namespace std;
// 基类 Shape
class Shape {
public:
virtual double getArea() const = 0; // 声明一个纯虚函数,表示面积
};
// 派生类 Rectangle
class Rectangle : public Shape {
private:
double length;
double width;
public:
Rectangle(double l, double w) : length(l), width(w) {}
// 重写基类的虚函数,计算矩形的面积
virtual double getArea() const override {
return length * width;
}
};
// 派生类 Circle
class Circle : public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {}
// 重写基类的虚函数,计算圆形的面积
virtual double getArea() const override {
return 3.14 * radius * radius;
}
};
// 派生类 Square,从 Rectangle 类继承而来
class Square : public Rectangle {
public:
Square(double side) : Rectangle(side, side) {} // 正方形的边长相同,直接调用 Rectangle 的构造函数即可
};
int main() {
Rectangle rect(5, 4); // 创建一个矩形对象
Circle circle(3); // 创建一个圆形对象
Square square(6); // 创建一个正方形对象
// 分别计算各个形状的面积并输出
cout << "Rectangle area: " << rect.getArea() << endl;
cout << "Circle area: " << circle.getArea() << endl;
cout << "Square area: " << square.getArea() << endl;
return 0;
}在这个示例中,基类
Shape
声明了一个纯虚函数getArea()
,表示面积的计算方法。派生类Rectangle
和Circle
分别重写了这个虚函数,实现了矩形和圆形的面积计算方法。然后,派生类Square
继承自Rectangle
,通过直接调用Rectangle
类的构造函数来创建正方形对象。最后,在main()
函数中,我们创建了一个矩形对象、一个圆形对象和一个正方形对象,并分别调用它们的getArea()
函数来计算面积并输出。
7-6 定义一个哺乳动物类Mammal,再由此派生出狗类Dog,定义一个Dog类的对象,观察基类与派生类的构造函数和析构函数的调用顾序。
在这个问题中,我们首先定义一个基类
Mammal
表示哺乳动物,然后派生出一个狗类Dog
。我们将观察基类和派生类构造函数、析构函数的调用顺序。下面是相应的代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using namespace std;
// 基类 Mammal,表示哺乳动物
class Mammal {
public:
Mammal() {
cout << "Mammal constructor called" << endl;
}
~Mammal() {
cout << "Mammal destructor called" << endl;
}
};
// 派生类 Dog,表示狗
class Dog : public Mammal {
public:
Dog() {
cout << "Dog constructor called" << endl;
}
~Dog() {
cout << "Dog destructor called" << endl;
}
};
int main() {
Dog dog; // 创建一个 Dog 对象
return 0;
}在这个示例中,我们定义了一个基类
Mammal
和一个派生类Dog
,分别表示哺乳动物和狗。在main()
函数中创建了一个Dog
对象。观察运行结果,会发现构造函数和析构函数的调用顺序是:
- 首先调用基类
Mammal
的构造函数,然后调用派生类Dog
的构造函数,创建对象;- 对象的生命周期结束时,先调用派生类
Dog
的析构函数,然后调用基类Mammal
的析构函数,销毁对象。这说明在派生类对象的生命周期中,基类的构造函数会先于派生类的构造函数被调用,而析构函数的调用顺序则相反,派生类的析构函数会先于基类的析构函数被调用。这种顺序确保了基类和派生类的成员都能得到正确的初始化和清理,保证了对象的正确性和完整性。
7-8 定义一个Document类,有数据成员name,从Document派生出Book类,增加数据成员pagcCount.
在这个问题中,我们首先定义了一个基类
Document
,它有一个数据成员name
,表示文档的名称。然后从Document
派生出一个Book
类,增加了一个额外的数据成员pageCount
,表示书籍的页数。这种设计方式利用了面向对象编程的继承特性,通过派生类
Book
扩展了基类Document
,使得Book
类具有了更多的属性和功能,同时又能继承并复用基类Document
中的成员。下面是相应的代码实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using namespace std;
// 基类 Document
class Document {
protected:
string name; // 文档名称
public:
Document(const string& n) : name(n) {} // 构造函数
void setName(const string& n) { name = n; } // 设置文档名称
string getName() const { return name; } // 获取文档名称
};
// 派生类 Book
class Book : public Document {
private:
int pageCount; // 书籍页数
public:
Book(const string& n, int count) : Document(n), pageCount(count) {} // 构造函数
void setPageCount(int count) { pageCount = count; } // 设置书籍页数
int getPageCount() const { return pageCount; } // 获取书籍页数
};
int main() {
Book myBook("The Great Gatsby", 250); // 创建一个书籍对象
cout << "Book Name: " << myBook.getName() << endl;
cout << "Page Count: " << myBook.getPageCount() << endl;
return 0;
}在这个示例中,我们定义了一个基类
Document
和一个派生类Book
。Document
类有一个数据成员name
,表示文档的名称;Book
类继承了Document
类,并增加了一个额外的数据成员pageCount
,表示书籍的页数。通过派生类Book
扩展了基类Document
,我们可以在Book
类中使用Document
类中的成员函数和数据成员,同时又能够拥有Book
类特有的属性和行为。
7-12 组合与继承有什么共同点和差异?通过组合生成的类与被组合的类之间的逻辑关系是什么?继承呢?
组合和继承是面向对象编程中常用的两种关系,它们都可以用来实现类之间的关联和复用,但在使用时有一些共同点和差异:
共同点:
- 都是用来建立类之间的关系,实现代码的复用和组织。
- 都可以通过在一个类中使用另一个类来扩展功能或者描述更复杂的对象。
- 都能够实现类之间的关系,如“是一个”(继承)和“有一个”(组合)。
差异:
- 继承是一种 “是一个” 的关系,表示一个类是另一个类的特殊形式,继承会继承基类的接口和实现,派生类拥有基类的所有成员和方法。而组合是一种 “有一个” 的关系,表示一个类包含了另一个类作为其一部分,但并不是同一种类型的关系。
- 继承会导致派生类和基类之间高耦合性,基类的修改会影响到所有派生类,而组合则更加灵活,组合的类之间的耦合性较低,修改一个类不会影响另一个类。
- 继承可以实现代码的重用和扩展,但容易导致类之间的复杂关系,增加系统的耦合度;而组合可以实现更灵活的对象组织和关联,但需要更多的类和对象之间的交互。
通过组合生成的类与被组合的类之间的逻辑关系是一种“包含”关系。例如,如果一个类
A
包含了另一个类B
的对象作为其成员,那么类A
就拥有了类B
的功能和特性,可以通过类B
的对象来实现类A
的功能。在代码实现上,类A
可以通过调用类B
的成员函数或者访问类B
的数据成员来实现自身的功能。而继承呢,它建立的是一种“是一个”关系,表示派生类是基类的一种特殊形式,继承会继承基类的所有成员和方法,并且派生类可以添加新的成员或者重写基类的成员函数来扩展功能。
7-14 基类与派生类的对象、指针或引用之间,哪些情况下可以隐含转换,哪些情况下可以显示转换?在涉及多重继承或虚继承的情况下,在转换时会面临哪些新问题?
这确实是一个复杂且容易引起混淆的问题,我们来一步步解释。
隐式转换与显式转换:
隐式转换:
- 当派生类的对象被赋值给基类的对象时,会发生隐式转换。
- 当派生类的对象传递给函数参数为基类的引用或指针时,也会发生隐式转换。
显式转换:
- 当需要将基类的指针或引用转换为派生类的指针或引用时,需要使用显式转换,即使用
static_cast
或dynamic_cast
等转换操作符来进行类型转换。- 当需要将基类对象转换为派生类对象时,同样需要使用显式转换。
多重继承和虚继承带来的新问题:
多重继承:
在多重继承中,如果派生类同时继承了多个基类,可能会导致对象内存布局的不确定性,以及可能出现菱形继承问题,因此在进行指针或引用的类型转换时,需要格外小心。
虚继承:
虚继承是为了解决菱形继承问题而提出的,它可以防止同一基类被不同路径继承多次。但虚继承会引入虚基类表(vtable)和虚基类指针(vptr),导致对象内存布局更加复杂,可能会影响对象的大小和性能。在进行指针或引用的类型转换时,需要考虑虚继承带来的额外开销和影响。
示例:
考虑以下情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using namespace std;
class Base {
public:
virtual void foo() {
cout << "Base::foo()" << endl;
}
};
class Derived : public Base {
public:
void foo() override {
cout << "Derived::foo()" << endl;
}
};
int main() {
Derived d;
Base* ptr_base = &d; // 隐式转换
ptr_base->foo(); // Derived::foo()
Base& ref_base = d; // 隐式转换
ref_base.foo(); // Derived::foo()
// 显式转换
Derived* ptr_derived = static_cast<Derived*>(ptr_base);
ptr_derived->foo(); // Derived::foo()
return 0;
}在这个示例中,派生类
Derived
继承自基类Base
。在main()
函数中,我们首先将派生类的对象地址赋值给基类指针ptr_base
和基类引用ref_base
,发生了隐式转换。然后,我们使用static_cast
将基类指针ptr_base
转换为派生类指针ptr_derived
,进行了显式转换。