Code-Reuse-in-OOP
Code Reuse in OOP
C++ Primer Plus Tutorial-15
面向对象编程教程——Section④
Chapter 15 Code Reuse in OOP
面向对象编程中的代码重用
【写在前面的话】
Abstract
C++的一个主要目标是促进代码重用。公有继承是实现这种目标的机制之一,但并不是唯一的机制。本章将介绍其他方法,其中之一是使用这样的类成员:本身是另一个类的对象。这种方法称为包含(containment)、组合(composition)或层次化(layering)。另一种方法是使用私有或保护继承。通常,包含、私有继承和保护继承用于实现 has-a 关系,即新的类将包含另一个类的对象。多重继承使得能够使用两个或更多的基类派生出新的类,将基类的功能组合在一起。
同时,本章将介绍类模板——另一种重用代码的方法。类模板使我们能够使用通用术语定义类,然后使用模板来创建针对特定类型定义的特殊类。
One of the main goals of C++ is to promote code reuse. Public inheritance is one of the mechanisms to achieve this goal, but not the only one. This chapter will introduce other methods, one of which is to use class members that are themselves objects of another class. This method is called containment, composition, or layering. Another method is to use private or protected inheritance. Generally, containment, private inheritance, and protected inheritance are used to implement the has - a relationship, that is, the new class will contain an object of another class. Multiple inheritance enables new classes to be derived from two or more base classes, combining the functionality of the base classes.
At the same time, this chapter will introduce class templates - another method of reusing code. Class templates enable us to define classes in general terms and then use the templates to create special classes defined for specific types.
Table of Contents
- Containment Composition Layering
- Multiple Inheritance
- Class Template
- Friend Class and Friend Functions
Containment Composition Layering
valarray
valarray
被定义为一个模板类,能够处理不同的数据类型。
下面来看valarray
的基本操作:
1 |
|
输出结果:8个10
valarray
的基本使用和数组完全一样,但是valarray
在数值计算方面具有更大的优势。
- 构造函数
- 默认构造函数:可以创建一个空的
valarray
。例如std::valarray<int> va;
,这样就创建了一个空的valarray
,其中元素类型为int
。 - 大小指定构造函数:可以指定
valarray
的大小和初始值。例如std::valarray<double> va1(10, 3.14);
,这个构造函数创建了一个包含 10 个元素的valarray
,每个元素初始值为 3.14。 - 拷贝构造函数:用于从另一个
valarray
创建一个新的valarray
。例如std::valarray<int> va2(va1);
,这里va2
是va1
的一个拷贝,它们具有相同的元素个数和对应位置相同的元素。 - 初始化列表构造函数:可以使用花括号初始化列表来创建
valarray
。例如std::valarray<int> va3 = {1, 2, 3, 4, 5};
,这种方式方便快捷地创建并初始化一个valarray
。
- 默认构造函数:可以创建一个空的
- 元素访问方法
[]
操作符:用于访问valarray
中的单个元素。例如:
1 |
|
size()
方法:返回valarray
中元素的个数。例如:
1 |
|
- 数学运算方法
- 算术运算符重载
- 加法:可以将两个
valarray
相加,或者将一个valarray
与一个标量相加。例如:
- 加法:可以将两个
- 算术运算符重载
1 |
|
- 减法、乘法、除法等运算与之类似
- 减法:
std::valarray<int> result3 = va2 - va1;
(对应元素相减) - 乘法:
std::valarray<int> result4 = va1 * 3;
(每个元素乘以 3) - 除法:
std::valarray<double> va3 = {4.0, 8.0, 12.0};
,std::valarray<double> result5 = va3 / 2.0;
(每个元素除以 2.0)
- 减法:
- 数学函数应用
std::pow
函数:用于计算valarray
中每个元素的幂。例如
1 |
|
std::sin
、std::cos
等三角函数也可以应用于valarray
元素- 例如
std::valarray<double> va1 = {0.0, 3.14159 / 2.0, 3.14159};
,std::valarray<double> sin_result = std::sin(va1);
,会分别计算va1
中每个元素的正弦值。
- 例如
- 切片操作(
slice
)相关方法valarray
支持切片操作:可以通过定义slice
对象来提取valarray
中的一部分元素。例如:
1 |
|
cshift
方法(循环移位):可以对valarray
中的元素进行循环移位操作。例如:
1 |
|
如何操作has-a
关系?
公有继承比较适合的是is-a-king-of
关系的建立,即构建一种种类上子集的关系。那如果是是一种has-a关系呢?举个简单的例子,我们现在又string类(姓名)和valarry类(分数)两个底层的基类,我想从这两个基类派生出我的student类(每个学生都有自己的姓名和分数)。
如果使用公有继承(在这里是多重公有继承),显然不合适。因为学生和姓名不属于同类事物。通常,我们使用包含(containment)的技术来构建一种has-a关系,即创建一个包含其他类对象的类。
下文给出student
类的代码示例:
1 |
|
复习一下:
explicit
关键词可以避免编译器进行隐式类型转换。在这种情况下,Student类的成员函可以使用string类的共用接口来修改和访问name对象,但是在类的外部只能通过Student类的成员函数对私有数据进行访问。
换句话说,在has-a关系的继承中,类对象不能自动获得被包含对象的接口,而应该通过定义成员函数来实现。举个例子,在string类中重载了加法运算符用于字符串的拼接,但是student类继承这种方法是没有任何意义的(你不可以把两个学生拼在一起)。
1 |
|
私有继承与保护继承(Additional)
在C++中,私有继承也是实现has-a关系的重要实现方式之一,在这里不再展开涉及具体的细节(毕竟第一种方法已经够用了,不是吗),只给出头文件的语法规范示例:
1 |
|
Multiple Inheritance
从单继承,我们很容易的推理出多重继承(Multiple Inheritance),简称MI。
注意!!!多重继承在一定程度上大大提高了继承的复杂性!一定要慎用!(笔者建议能使用单继承就使用单继承)
小故事:钻石问题
钻石问题(Diamond Problem)是多重继承中的一个经典问题,特别是在面向对象编程语言中。这个问题发生在一个类继承自两个或更多有共同父类的类时,导致继承结构中产生二义性和不明确的继承路径。它的名字来源于继承关系图形的形状,通常呈现一个钻石形状。
钻石问题的例子:
假设有一个类 A
,它是两个类 B
和 C
的父类,而 B
和 C
又被一个类 D
继承。继承关系可以表示为一个钻石形状:
1 |
|
问题的具体情况:
在这个继承关系中,类 B
和类 C
都继承了类 A
,并且类 D
同时继承了 B
和 C
。假设类 A
中有一个成员函数 foo()
,而类 B
和类 C
都没有覆盖(override)该函数。那么,类 D
在继承时会遇到以下问题:
- 二义性:类
D
继承了B
和C
,它同时会继承来自A
的foo()
函数。但是,当你尝试调用foo()
时,编译器无法确定应该调用B
中的foo()
,还是C
中的foo()
(假设B
和C
都没有覆盖这个函数)。如果B
和C
都没有foo()
,那么D
也会得到二义性错误。 - 重复继承:由于
B
和C
都继承自A
,在类D
中可能会包含A
的多个副本,导致同一个基类成员被多次继承,这种冗余的继承可能会导致不必要的资源浪费和代码混乱。
实际上,C++ 引入了 虚拟继承(Virtual Inheritance) 来解决钻石问题。虚拟继承可以确保在多重继承时,
A
类的子对象只会被继承一次。
Class Template
面向对象的程序设计提供了一种称为泛型程序设计的机制,即允许将类中成员的类型设置为一个可变的参数,使多个类变成一个类。泛型程序设计可以以独立于任何特定类型的方式编写代码。使用泛型程序时,必须提供具体所操作的类型或值。第 5 章介绍的函数模板就是泛型机制的一种实现方法,本章将介绍类模板,即用泛型机制设计的类。
在函数章节,我们学习了模版函数,实现了一个模版在多个不同数据类型之间的通用,大大提高了代码重用的效率。在类的定义中同样也是如此,模板提供参数化(parameterized)类型,即能够将类型名作为参数传递给接收方来建立类或函数。例如,将类型名 int
传递给 Queue
模板,可以让编译器构造一个对 int 进行排队的 Queue
类。
在接下来的内容中,我们将以非常重要的数据结构——栈(Stack)为示例,向读者演示模板类的强大之处。
The Implementation of Stack
1 |
|
在第十章书中给出了针对
unsigned long
类型的栈数据结构示例,此处不再解释具体代码的含义。
The Definition
我们先给出模板类的头文件声明,读者可以自行比较有哪边进行了修改:
1 |
|
采用模板时,将使用模板定义替换 Stack
声明,使用模板成员函数替换 Stack
的成员函数。和模板函数一样,模板类以下面这样的代码开头 :template <class Type>
。
剩下的操作基本上没什么区别了。(因为定义类在很大程度上就是定义成员函数和方法。那定义模版类就是定义模版函数的过程)
接下来我们来看类方法的定义:
1 |
|
类模板的成员函数本身不是一个函数,故不可以单独编译,需要在指定模版形式参数的值后(即实现模版的实例化后)才能被编译成一个程序。因此,对于类模版而言,往往将函数定义和类模板的定义写在同一个头文件中。
The Use of Pointer Stack
细心的读者可能会发现,在模版类的函数定义中,多出了这样一个函数(对赋值运算符的运算符重载)
1 |
|
同时,对构造函数和析构函数的定义也做出了相关修改。
为什么要做这样一个修改?模板类和模版函数非常重要的一点就是模版必须具有通用性(也可以使用显示具体化来为特殊类型打补丁,后文会讲到)。如果模板类型是指针(即指针栈),那么使用默认的赋值运算符会产生比较严重的问题。
什么严重的问题?欢迎参考第十二章有关动态内存分配的知识,此处不再展开。
正确使用指针栈
使用指针栈的方法之一是,让调用程序提供一个指针数组,其中每个指针都指向不同的字符串。把这些指针放在栈中是有意义的,因为每个指针都将指向不同的字符串。注意,创建不同指针是调用程序的职责,而不是栈的职责。栈的任务是管理指针,而不是创建指针。
Advanced Usage
非类型参数
在使用模板类的时候,我们通过构造函数在堆上动态分配内存,这样的效率往往比较低。如果在确定具体类型之后再栈上开辟空间,将会变得更加的高效。
1 |
|
template <class T, int n>
是 C++ 中的一种模板声明方式,其中包含了两种不同类型的模板参数:
T
:这是一个类型模板参数(Type Template Parameter),表示一个类型,通常用来代表类、结构体、函数等的类型。n
:这是一个非类型模板参数(Non-type Template Parameter),表示一个常量值,通常是整数、指针、引用或其他常量值。
缺点是,在n
不相同的情况下,编译器会生成两种不同的模版(哪怕非类型模版参数是相同的)。非类型参数的使用本质是更改了内存的存储方式,在提升效率的同时牺牲了部分的通用性。(无法创建数组大小可变的类了)
递归使用模板
ArrayTP< ArrayTP<int,5>, 10> twodee;
pair类
std::pair
是 C++ 标准库中的一个模板类,用于存储两个不同类型的元素。它可以将两个数据组合成一个对象,这两个数据可以是任意类型,因此 std::pair
是一种非常常见的通用容器,特别适用于需要将两个相关数据关联在一起的场景。
std::pair
定义在 <utility>
头文件中,其模板定义如下:
1 |
|
std::pair
存储两个值:first
和 second
,它们可以是不同类型的数据。T1
是第一个元素的类型,T2
是第二个元素的类型。
默认构造函数:
pair
的两个成员first
和second
都会被默认初始化。1
std::pair<int, double> p1; // p1.first 和 p1.second 都是默认初始化
带参构造函数:通过提供两个值初始化
first
和second
。1
std::pair<int, double> p2(1, 3.14); // p2.first = 1, p2.second = 3.14
拷贝构造函数:允许使用另一个
pair
来初始化。1
std::pair<int, double> p3 = p2; // p3.first = p2.first, p3.second = p2.second
移动构造函数:允许通过右值引用来初始化(C++11 引入的特性)。
1
std::pair<int, double> p4 = std::make_pair(10, 20.5);
操作:
访问成员:
pair
提供了两个公共成员变量first
和second
,可以直接访问。1
2std::pair<int, double> p(1, 3.14);
std::cout << p.first << ", " << p.second << std::endl; // 输出: 1, 3.14**
std::make_pair
**:make_pair
是一个方便的函数模板,用于创建pair
对象,避免手动指定类型。1
auto p = std::make_pair(1, 3.14); // p 是 pair<int, double>
比较操作符:
pair
支持常见的比较操作符(==
,!=
,<
,>
,<=
,>=
)。比较时,pair
会首先比较first
,如果相等,则再比较second
。1
2
3
4std::pair<int, double> p1(1, 3.14), p2(1, 2.718);
if (p1 < p2) {
std::cout << "p1 < p2" << std::endl;
}交换元素:
std::pair
提供了一个swap
方法用于交换first
和second
。1
2std::pair<int, double> p1(1, 3.14), p2(2, 2.718);
p1.swap(p2); // 交换 p1 和 p2 的值
使用场景
std::pair
经常用于以下场景:
返回多个值:有时我们需要返回多个相关的值(例如,函数返回两个值),
std::pair
是一个非常方便的方式来做到这一点。1
2
3std::pair<int, double> get_coordinates() {
return std::make_pair(10, 20.5);
}在容器中存储键值对:例如,
std::map
和std::unordered_map
等容器使用std::pair
来存储键值对。1
2
3std::map<int, std::string> m;
m[1] = "apple";
m[2] = "banana";配对数据:在某些算法或数据结构中,两个相关的数据项经常被配对在一起。
std::pair
允许你简洁地存储和访问它们。
1 |
|
1 |
|
tuple和tie(补充知识)
打包函数:std::tie
std::tie
是 C++11 引入的一个非常强大且方便的工具,它可以将多个值打包成一个元组,并使得这些值能够像元组一样被进行比较、解构或传递。它在多个方面提供了便利,以下是其强大之处:
- 简化多重比较
std::tie
可以将多个变量组合成一个元组,然后直接进行比较。这样,我们可以避免手动写出冗长的多重 if
语句来比较多个值。例如,比较结构体中的多个成员时,std::tie
可以将多个成员打包,按字典顺序(lexicographical order)进行比较。
1 |
|
这里的比较会首先比较 x
,如果 x
相同,再比较 y
,如果 y
也相同,则比较 z
。这是一个典型的按字典顺序的比较。
- 简化排序操作
通过 std::tie
,你可以在排序操作中直接比较多个字段,而无需手动指定每个字段的比较方式。比如在 std::sort
中进行排序时,使用 std::tie
让代码更加简洁且易于维护。
1 |
|
这样,排序时就不需要多个嵌套的 if
语句来分别比较 x
, y
, z
。std::tie
会自动按字段顺序进行比较。
- 方便的结构体解构
std::tie
不仅可以用于比较,还可以用于解构,特别是在函数中返回多个值时,或者从一个元组中提取值时。它让你可以直接将多个值赋给多个变量。
1 |
|
这样,你就可以将一个元组中的元素直接解构到不同的变量中,而不需要手动访问每个元素。
- 元组和非元组类型的统一接口
std::tie
的强大之处还在于它能够与普通类型(如 int
,double
等)和标准库类型(如 std::tuple
)无缝结合。这使得你在处理多个返回值时非常方便。
1 |
|
- 更灵活的函数返回值
当你需要一个函数返回多个值时,std::tie
可以帮助你轻松解构并同时返回多个值,而不需要使用 std::pair
或 std::tuple
来包裹它们。
1 |
|
std::tuple
std::tuple
是 C++11 引入的一个标准库容器,它允许你存储多个不同类型的元素,类似于数组或结构体,但与这两者不同的是,std::tuple
的元素可以是不同类型的。换句话说,tuple
可以看作是一个异构的容器,即它可以存储多个不同类型的值,而不像数组那样只能存储相同类型的元素。
- 异构性:
std::tuple
可以包含不同类型的元素(如int
、double
、std::string
等),而不像std::vector
或std::array
这样的容器只能包含相同类型的元素。 - 固定大小:
std::tuple
的大小在编译时就确定了,无法动态改变大小。也就是说,它的大小(即元素的个数)是固定的。 - 元素访问:可以通过索引或者
std::get
来访问tuple
中的元素。
你可以通过 std::make_tuple
或直接使用构造函数来创建 tuple
。
1 |
|
在这个例子中,t
是一个包含三个元素的 tuple
,它的元素分别是 int
、double
和 std::string
类型。
获取 tuple
中的元素:
使用 std::get<index>(tuple)
来访问特定位置的元素。索引是编译时确定的,因此使用时需要指定元素的类型或位置。
1 |
|
获取 tuple
的大小:
你可以使用 std::tuple_size
来获取 tuple
中元素的个数。
1 |
|
解构 tuple
:
你可以使用 std::tie
来解构 tuple
中的值,或者直接通过 std::get
提取多个值。
1 |
|
scenario:
函数返回多个值:使用
tuple
可以方便地在函数中返回多个不同类型的值,而无需创建自定义结构体。1
2
3
4
5
6
7
8
9
10std::tuple<int, double> getValues() {
return std::make_tuple(10, 3.14);
}
int main() {
auto values = getValues();
std::cout << "First value: " << std::get<0>(values) << std::endl;
std::cout << "Second value: " << std::get<1>(values) << std::endl;
return 0;
}存储异构数据:当你需要存储异构数据(不同类型的元素)时,
tuple
是一个非常方便的选择。例如,可以用它来存储一个人的年龄、名字和身高等不同类型的数据。元组解构和泛型编程:
std::tuple
常用于模板编程中,特别是在处理多类型的返回值或元组解构时,能够提供强大的灵活性。
模版的实例化和具体化
隐式实例化
函数模板的实例化通常由编译器自动完成, 编译器根据函数调用时的实际参数类型推断出模板参数的值,将模板参数的值代入函数模板,从而生成一个可执行的模板函数。类模板没这么幸运。编译器无法根据对象定义确定模板实际参数值。例如,定义 Array
类对象时给出的是代表数组下标范围的两个整数, 从中无法推断数组元素的类型,因而需要定义对象时明确指出模板实际参数的值。
以std::vector
为例:
1 |
|
在初始化一个vector
对象的时候,我们需要给出类模板的实际参数表(即class Type
)到底是什么,编译器才会隐式的根据我们的提示生成对应的示例类和对象。
如果使用vector thetest(5,3);
的方式进行定义,会产生如下报错:
1 |
|
显示实例化
当使用关键词template
并指出所需类型来声明类的时候,编译器会生成类声明的显式实例化。显式实例化通知编译器生成类模板的完整实例。
1 |
|
显示具体化(Explicit Specialization)
显式具体化是指特定类型(用于替换模版中的泛型)的定义。这一种操作为泛型模版提供了更灵活的可操作性。
1 |
|
部分具体化(Partial Specialization)
部分特化是指对模板参数的某些部分进行特化,而不需要完全特化整个模板。它允许针对一些特定的类型组合或模板参数条件进行特定实现。
1 |
|
输出:
1 |
|
成员模版
模板可用作结构、类或模板类的成员。要完全实现 STL 的设计,必须使用这项特性。
成员模板(Member Template)是 C++ 中的一种功能,允许在类中定义模板成员函数。这样,类的成员函数可以根据调用时的类型自动实例化,具备模板的灵活性。
成员模板的主要作用是实现根据不同类型提供不同实现的功能,而不需要为每种类型手动编写多个成员函数。成员模板与类模板的结合使用,可以让类对不同类型的对象和数据进行通用处理。
1 |
|
MyClass
是一个类模板,接受一个类型T
。print
是一个成员模板函数,接受任意类型U
的参数。- 在
main
函数中,分别用int
和double
类型调用print
,成员模板会根据传入类型进行实例化。
- 模板参数化成员函数:成员模板使得同一个成员函数可以根据调用的类型实例化不同的实现。
- 灵活性:可以使类的成员函数适应多种不同的数据类型,而不需要手动为每种类型编写重载函数。
- 与类模板配合使用:通常成员模板是与类模板配合使用的,使得类和其成员函数都可以泛化。
成员模版相当于对类中的成员函数使用函数模版。C++泛型的两种模板机制在此刻得到了统一。
我们来看一个更加复杂的例子:
1 |
|
输出示例
1 |
|
模板类hold
是beta
类的私有成员,在此处一共定义了三个模版参数T,V和U,在main函数中,语句 beta<double> guy(3.5, 3);
将T设置为double
类型,因此在guy(是一个beta类)中有两个数据成员:hold<double>
和hold<int>
。(在这里模版参数V先后被隐式实例化为int
和double
)接下来执行语句guy.blab(10, 2.3)
,模版参数U被隐式实例化为int
(guy.blab(10, 2.3)
时也类似)。
将模板用作参数
将模板用作参数(Template as a Parameter)是 C++ 中的一种技术,允许将模板本身作为函数、类或其他模板的参数。这种技术通常被称为 模板模板参数。它使得可以灵活地将一个模板传递给另一个模板,从而实现高度的泛化和定制化。
当你将模板用作参数时,你可以传递一个模板的类型,而不是实际的类型。这对于处理模板类或函数更加灵活,允许模板参数不仅是类型,还可以是其他模板类型。
有一点套娃的感觉
示例 1:将模板作为函数参数
下面的例子展示了如何将一个模板类作为另一个模板函数的参数:
1 |
|
1 |
|
MyClass
是一个模板类,接受一个类型T
。callTemplateClass
是一个模板函数,接受一个模板类作为参数。template <template <typename> class T>
表示T
是一个模板模板参数。- 在
main
函数中,我们调用callTemplateClass<MyClass>()
,将MyClass
作为模板传递给callTemplateClass
函数。
这种规则本质上增添了模版参数的多样性和灵活性。
示例 2:将模板作为类参数
模板也可以作为类的成员,允许类在定义时接受模板作为参数。
1 |
|
Wrapper
是一个模板类,接受一个模板类作为参数(template <template <typename> class T>
)。Wrapper
类使用T<int>
来实例化模板类,并通过obj.print()
来调用它的成员函数。- 在
main
函数中,我们创建了Wrapper<MyClass>
对象,将MyClass
作为模板传递给Wrapper
。
使用模板作为参数的优点
- 高度灵活性:允许传递不同的模板类或函数,增强代码的复用性。
- 避免代码重复:通过接受模板模板参数,可以减少对同类逻辑的多次实现,只需一个模板就能处理不同的类型或类。
- 泛化:通过使用模板模板参数,可以让代码对更多的类型和模板更加通用。
Friend Functions and Friend Class
友元函数
友元函数(Friend Function)是 C++ 中的一种机制,允许一个函数(或类)访问另一个类的私有成员和保护成员。虽然友元函数可以访问类的私有和保护成员,但它本身并不是类的成员函数。友元函数通常用于操作一些类内部的细节,但它可能会引入一些需要注意的问题。
- 让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限(访问private)
- 例:将运算符重载编写成一个非成员函数
- 友元函数具有成员函数的权限,但作为非成员函数不能使用成员运算符进行调用
- 使用成员函数,可以使用构造函数,这更加高效
- 只有在函数声明的时候需要加上friend关键词,在函数定义时不可以
友元函数和在成员函数中运算符重载的区别
在成员函数中实现运算符重载
1 |
|
在非成员函数中实现运算符重载(友元函数)
1 |
|
友元函数相比于成员函数的优势
友元函数相对于成员函数具有一些特定的优势,尽管它们打破了类的封装原则,但这些优势在某些情境下是非常有用的:
- 操作符重载
友元函数常用于操作符重载,特别是当操作符需要访问两个不同类或类型的对象时。成员函数只能通过 this
指针访问当前对象的成员,而友元函数可以直接访问两个对象的私有成员。例如:
1 |
|
- 提高代码的可读性和简洁性
- 全局函数:友元函数可以作为全局函数,这在某些情况下可以使代码更易于理解和维护。例如,上面的
operator+
作为友元函数,使得加法操作看起来更自然,不需要通过对象调用。 - 避免不必要的成员函数:有时,某些操作并不适合作为类的一部分,但仍然需要访问类的私有成员。友元函数可以提供这种访问,而不需要增加类的成员函数。
- 对称性
- 友元函数可以提供操作的对称性。例如,在
operator==
的情况下,如果是成员函数,a == b
和b == a
可能需要不同的实现,而友元函数可以使这两个操作等价。
- 减少this指针的使用
- 友元函数没有隐式的
this
指针,这在某些情况下可以提高效率,因为不需要额外的参数传递。
- 访问私有成员而不改变类的接口
- 如果你需要一个函数访问类的数据,但不想将这个函数作为类的成员(因为它不属于类的逻辑部分),友元函数可以让你实现这一点,而不改变类的公共接口。
- 跨类访问
- 友元函数可以被多个类声明为友元,从而允许这些类之间共享数据,而不需要通过公共接口或继承。
- 设计模式的实现
- 在一些设计模式中,如桥接模式、适配器模式等,友元函数可以帮助实现跨类协作,而无需暴露类的内部实现细节。
注意事项:
尽管友元函数有这些优势,但它们也有一些潜在的缺点:
- 打破封装:友元函数允许非成员函数访问类的私有成员,可能会破坏类的封装性。
- 代码维护:友元关系可能会使代码的维护变得复杂,因为它增加了类的依赖性。
- 滥用:如果不谨慎使用,友元函数可能会导致代码的可读性和结构性下降。
因此,在使用友元函数时,应该谨慎考虑是否真的需要这种访问权限,并尽量限制友元函数的数量和范围,以保持类的封装性和代码的清晰度。
友元类
在 C++ 中,friend
是一种特殊的机制,它允许某些函数或类访问其他类的私有成员。friend
主要有两种形式:友元函数 和 友元类。这两种机制在设计某些复杂系统时非常有用,可以让不同类之间进行密切的合作,同时保持类内部的封装性。
友元类 是一个类,它被声明为某个类的友元类。友元类的成员函数可以访问该类的私有成员。这种机制通常用于设计两个紧密相关的类,它们需要互相访问私有数据,但又不希望暴露给外部。
友元类的基本语法
1 |
|
友元类的应用场景
- 设计复杂系统:友元类的使用可以让两个类之间共享数据和功能,而不暴露这些数据给其他类。例如,在某些库中,可能有一个类负责管理资源(如内存管理),另一个类负责使用这些资源。为了实现高效和紧密的合作,这两个类可能需要互相访问对方的私有成员,这时可以将其中一个类声明为友元类。
- 实现成员共享:友元类通常用于需要相互访问成员数据的类,特别是在某些算法和数据结构的实现中,如链表、树、图等。
友元类的设计示例
1 |
|
在这个例子中,Engine
类被声明为 Car
类的友元类,使得 Engine
类能够访问 Car
类的私有成员。
友元类与继承
友元类与继承之间有一些特别的关系。虽然子类继承了父类的公共和保护成员,但友元关系不被继承。也就是说,如果某个类是另一个类的友元类,它并不能自动成为其子类的友元类。
1 |
|
在上面的例子中,B
是 A
的友元类,但 C
并没有继承 B
对 A
的友元关系,因此 B
无法访问 C
的私有成员。
友元与封装
尽管 friend
允许类外部的函数和类访问私有成员,但它仍然保持了一定的封装性。在设计时,应注意不要过度使用友元关系,因为过多的友元可能会破坏类的封装性,增加类之间的耦合度,导致维护困难。
- 适度使用友元:只有在确实需要类之间紧密合作时,才应考虑使用友元关系。尤其是当某些函数需要访问类的内部细节时,友元函数和友元类可以提供非常强大的功能。
- 减少友元的使用:不推荐随意将大量的类或函数声明为友元,尽量保持类的封装性,使其更具独立性和可维护性。
更加复杂的友元关系
在上文我们介绍了两种友元的使用:友元类和友元函数。友元函数具有作为外部函数访问类内私有数据成员的特权,而友元类具有更多的特权。可以实现类之间的数据共享。但是,类友元的实现在一定程度上也抹杀了类之间数据的安全性,不符合OOP中封装的基本理念。因此,我们可以不将整个类设置为友元,而是只将另一个类的成员函数设置为友元,限制这种特权的使用,保证安全性。
我们来看下面的示例,下文代码给出了电视机和遥控器的类实现。
1 |
|
遥控器可以访问电视机的信息,因此我们自然想到可以把遥控器类设置为电视机类的友元类,但是这样的操作使安全性降低(毕竟电视机还有很多遥控器完成不了的操作),我们可以优化,将Remote::set_chan
作为Tv类的友元函数,使其作为唯一需要友元的方法实现。
基于这个想法,我们需要对友元的定义和声明做一些修改。首先,就像函数声明一样,我们需要class Tv
语句来实现前置声明(forward declaration)。同时,在Remote
类的函数定义中包含Tv
类的参数列表,因此需要将Tv类的声明提前到Remote类的函数定义之前。
内联函数的链接性是内部的,这意味着函数定义必须在使用函数的文件中。在这个例子中,内联定义位于头文件中,因此在使用函数的文件中包含头文件可确保将定义放在正确的地方。也可以将定义放在实现文件中,但必须删除关键字 inline
,这样函数的链接性将是外部的
总之就一个原则,在使用对应的类的时候编译器应该已经看到对应的内容!
互为友元
除本章前面讨论的,还有其他友元和类的组合形式,下面简要地介绍其中的一些。
假设由于技术进步,出现了交互式遥控器。例如,交互式遥控器让您能够回答电视节目中的问题,如果回答错误,电视将在控制器上产生嗡嗡声。忽略电视使用这种设施安排观众进入节目的可能性,我们只看 C++的编程方面。新的方案将受益于相互的友情,一些 Remote 方法能够像前面那样影响 Tv 对象,而一些 Tv 方法也能影响 Remote 对象。这可以通过让类彼此成为对方的友元来实现,即除了 Remote 是 Tv 的友元外, Tv还是 Remote 的友元。需要记住的一点是,对于使用 Remote 对象的 Tv 方法,其原型可在 Remote 类声明之前声明,但必须在 Remote 类声明之后定义,以便编译器有足够的信息来编译该方法。
共同的友元
需要使用友元的另一种情况是,函数需要访问两个类的私有数据。从逻辑上看,这样的函数应是每个类的成员函数,但这是不可能的。它可以是一个类的成员,同时是另一个类的友元,但有时将函数作为两个类的友元更合理。例如,假定有一个 Probe 类和一个 Analyzer 类,前者表示某种可编程的测量设备,后者表示某种可编程的分析设备。
模板类和友元
模板类声明也可以有友元。模板的友元分 3 类:
- 非模板友元;
- 约束(bound)模板友元,即友元的类型取决于类被实例化时的类型;
- 非约束(unbound)模板友元,即友元的所有具体化都是类的每一个具体化的友元。
非模版友元
在 C++ 中,模板类的非模板友元函数是指一个非模板函数被声明为一个模板类的友元。这样,非模板友元函数可以访问模板类的私有和保护成员。与普通的友元函数类似,非模板友元函数允许特定的函数访问类的内部实现,但在模板类中,它们并不依赖于模板参数。
假设我们有一个模板类 MyClass
,其中包含一个私有成员 value
,并希望定义一个非模板函数来访问它。
1 |
|
MyClass
是一个模板类,接受类型T
作为模板参数,并包含一个私有成员value
。printValue
是一个非模板函数,它被声明为MyClass<int>
的友元函数。由于printValue
只与MyClass<int>
相关,因此它的参数类型为MyClass<int>
,而不是模板类型T
。- 在
main
函数中,我们创建了一个MyClass<int>
类型的对象,并通过printValue
访问其私有成员。
注意点
- 非模板友元函数的类型限定:非模板友元函数只能访问特定模板实例化的类的成员。在上述例子中,
printValue
只能作为MyClass<int>
的友元函数,而无法访问其他类型实例化的MyClass
对象(例如MyClass<double>
)。 - 模板类和非模板友元函数的结合:非模板友元函数与模板类的结合需要对特定类型的模板实例进行访问,因此它通常不如模板友元函数灵活。但它在某些场景中非常有用,例如当你只希望特定类型的模板类暴露内部实现时。
一般来说,非模版友元函数和普通的友元函数没有区别(针对特定实例化后的友元函数),这样做一定程度上牺牲了泛型编程的通用性,但可以对特定类型做更精细的操作。
约束友元
为约束模板友元作准备,要使类的每一个具体化都获得与友元匹配的具体化。这比非模板友元复杂些。包含以下三步:
- 在类定义的前面声明每个模板函数
template <typename T> void counts();
template <typename T> void report(T &);
- 在函数中再次将模板声明为友元。这些语句根据类模板参数的类型声明具体化
template <typename TT>
(类模版的模版参数声明)friend void counts<TT>();
- 声明中的
<>
指出这是模板具体化。对于report()
,<>
可以为空,因为可以从函数参数推断出。
- 为友元提供模板定义
template <typename T> void counts()
1 |
|
1 |
|
非约束友元
前一节中的约束模板友元函数是在类外面声明的模板的具体化。 int 类具体化获得 int 函数具体化,依此类推。通过在类内部声明模板,可以创建非约束友元函数,即每个函数具体化都是每个类具体化的友元。对于非约束友元,友元模板类型参数与模板类类型参数是不同的。
非约束友元指的是模板类的友元函数或类没有任何类型约束,它们可以接受任何类型的模板实例化,并且不对模板参数施加限制。
特点:
- 友元函数可以访问模板类的私有成员。
- 友元函数没有模板参数的约束,因此可以接受任何类型的模板实例化。
- 没有额外的限制条件,适用性广泛。
1 |
|
1 |
|
非约束友元 vs 约束友元
特性 | 非约束友元 | 约束友元 |
---|---|---|
定义 | 不对模板参数进行类型约束 | 对模板参数进行类型约束(如通过概念、enable_if ) |
适用性 | 可以接受任何类型的模板实例 | 只能接受符合约束条件的模板实例 |
类型安全性 | 没有类型限制,可能出现不安全的用法 | 强制类型安全,只有符合特定条件的类型才能使用 |
灵活性 | 更加灵活,不限制类型 | 更加严格,只能与特定类型一起使用 |
示例 | friend void printValue(const MyClass<T>& obj); |
friend void printValue(const MyClass<T>& obj) requires IntegralType<T>; |
Conclusion
C++提供了几种重用代码的手段。第 13 章介绍的公有继承能够建立 is-a 关系,这样派生类可以重用基类的代码。私有继承和保护继承也使得能够重用基类的代码,但建立的是 has-a 关系。使用私有继承时,基类的公有成员和保护成员将成为派生类的私有成员;使用保护继承时,基类的公有成员和保护成员将成为派生类的保护成员。无论使用哪种继承,基类的公有接口都将成为派生类的内部接口。这有时候被称为继承实现,但并不继承接口,因为派生类对象不能显式地使用基类的接口。因此,不能将派生对象看作是一种基类对象。由于这个原因,在不进行显式类型转换的情况下,基类指针或引用将不能指向派生类对象。
还可以通过开发包含对象成员的类来重用类代码。这种方法被称为包含、层次化或组合,它建立的也是has-a 关系。与私有继承和保护继承相比,包含更容易实现和使用,所以通常优先采用这种方式。然而,私有继承和保护继承比包含有一些不同的功能。例如,继承允许派生类访问基类的保护成员;还允许派生类重新定义从基类那里继承的虚函数。因为包含不是继承,所以通过包含来重用类代码时,不能使用这些功能。另一方面,如果需要使用某个类的几个对象,则用包含更适合。例如, State 类可以包含一组 County对象。
多重继承(MI)使得能够在类设计中重用多个类的代码。私有 MI 或保护 MI 建立 has-a 关系,而公有MI 建立 is-a 关系。 MI 会带来一些问题,即多次定义同一个名称,继承多个基类对象。可以使用类限定符来解决名称二义性的问题,使用虚基类来避免继承多个基类对象的问题。但使用虚基类后,就需要为编写构造函数初始化列表以及解决二义性问题引入新的规则。
类模板使得能够创建通用的类设计,其中类型(通常是成员类型)由类型参数表示。
END For OOP