完整版:资深程序员都了解的代码复用法则
编写代码最重要一条,是怎样复用其他程序员的代码和思路来解决问题。
通过修改他人的代码来解决复杂问题是种错误的做法,不仅成功的机率很低,就算成功也不会提供什么经验。按照这种方式进行编程,无法成长为一名真正的程序员,在软件开发领域,前景也是非常有限。
一旦问题达到了一定规模,期望程序员从头开发一个解决方案不太现实,这会导致程序员大量时间浪费在低效率工作中,并且极大地依赖程序员精通各个方面的知识。另外,这种做法也容易导致程序充满缺陷或难以维护。
良好的复用和不良的复用
良好的复用帮助我们编写更好的程序,并且提高程序的编写速度。不良的复用可能短时间内帮助我们借用其他程序员的思维,但最终会导致不良的开发。下面表格对它们之间的区别进行了总结。
左边一列显示了良好复用的属性,右边一列显示了不良复用的属性。在考虑是否对代码进行复用时,要考虑它很可能会产生左边一列的属性还是右边一列的属性。
表:良好的复用和不良的复用
值得注意的是,良好的复用和不良的复用之间的区别,并不是我们复用了什么代码或者我们怎样复习它们,而是在于我们所借用的代码和概念之间的关系。
按照编程的术语,良好的复用是指我们通过阅读某个人对一个基本概念的描述后编写代码或者利用自己以前所编写的代码。
注意表格的最后一行,不良的复用常常会导致失败,因为有可能是一位程序员在复用自己实际上并不理解的代码。在有些情况下,被借用的代码一开始能够工作,但是当程序员试图对借用的代码进行修改或者扩展时,由于缺乏深入的理解,很难用有组织的方式完成这样的任务。程序员必须通过不断的尝试和失败来进行试验,这样就违背了我们的基本问题解决规则的第一条也是最重要一条:先规划再开发。
可复用组件的 5 种类型
知道了我们打算采用的复用类型之后,现在可以对代码的不同复用方法进行分类了。
在本文中,组件表示由一位程序员所创建的、可以被其他人复用以帮助解决编程问题的任何东西。
组件可以在任何地方出现,它可以是抽象的,也可以是具体的。它可以是一个思路,也可以是一段完整实现的代码。如果我们把解决一个编程问题看成是处理一个手工项目,我们所学会的解决问题的技巧就像工具一样,而组件就像是专用的零件。下面的每种组件都是复用程序员的以前工作的不同方式。
1. 代码块 Code Block
代码块就是将一块代码从一个程序清单复制到另一个程序清单。按照更通俗的说法,我们可以称为复制粘贴工作。这是最低级形式的组件用法,常常代表了不良的复用,会出现不良复用可能导致的所有问题。当然,如果被复制的代码是自己所编写的,那就不会有实际的危害,但最好还是把一段现有的代码包装成为一个类库或其他结构,允许它以一种更清晰和更容易维护的方式被复用。
2. 算法 Algorithm
算法是一种编程配方,它是完成一个目标的特定方法,是以日常语言或流程图的形式表达的。算法是一种高级形式的复用,一般具有良好的复用属性。算法在本质上只是思路。
3. 模式 Pattern
在编程中,模式(或设计模式)表示具有一种特定编程技巧的模板,比如 singleton。这个概念与算法有关,但又存在区别。算法就像解决特定问题的配方,而模式是在特定的编程情况下所使用的基本技巧。模式所解决的问题一般是在代码本身的结构内部。
和算法一样,模式也是高级形式的组件复用。
4. 抽象数据类型 Abstract Data Type
抽象数据类型是由它的操作而不是由这些操作的实现方法所定义的类型。堆栈类型就是一个很好的例子。抽象数据类型与模式的相似之处在于它们定义了操作的效果,但并没有特别地定义这些操作的实现方式。但是,和算法一样,这些操作存在一些众所周知的实现技巧。
5. 库 Library
在编程中,库表示一些相关代码片段的集合。库一般包含了已编译形式的代码以及所需的源代码声明。库可以包含独立的函数、类、类型声明以及任何可以出现在代码中的东西。在C++中,最显而易见的例子就是标准库。
一般而言,库的使用是良好的代码复用。代码被包含在库中,因为它提供了各种程序一般需要使用的功能。库的代码可以帮助程序避免“重新发明轮子”。然而,作为程序开发人员,当我们使用库代码时,必须从中学到些什么,而不是单纯走捷径。
创建可复用组件的方法
组件非常有用,因此程序员应该尽可能地利用组件。
优秀的程序员必须养成习惯向他的工具箱中不断添加组件。
对组件的收集可以通过两种不同的方式进行:程序员可以明确分配时间学习新组件,把它作为一项基本任务,或者可以搜索一个组件来解决一个特定的问题。我们把第一种方法称为探索式学习,把第二种方法称为根据需要学习。
1. 探索式学习组件
我们首先从一个探索式学习的例子开始。假设我们想学习关于设计模式的更多知识,对于频繁使用的设计模式,人们已经达成了广泛的共识。因此我们能接触大量的资源。
通过简单地查找一些设计模式并对它们进行研究,我们就可以从中受益。如果实现了其中一些模式,就可以得到更多的收获。
我们在典型的模式列表中可以找到的一种模式叫策略。这是一种思路,允许一种算法(或算法的一部分)在运行时才被选择。在策略模式的最基本形式中,它允许更改函数或方法的操作方式,但不允许对结果进行更改。
例如,一个对类的数据进行排序(或者参与了排序过程)的类方法可能允许对排序的方法做出选择(如选择快速排序或插入排序)。不管选择什么排序方式,其结果(排序后的数据)是相同的,但是允许客户选择排序方法可能使代码的执行效率更高。
客户对于具有很高重复率的数据可以避免选择快速排序。根据策略的形式,客户的选择会对结果产生影响。例如,有一个表示一手牌的类,排序策略可能会决定 A 被认为是最大(比 K 大)还是最小(比 2 小)。
开始把学习融入到可复用实践中
通过上文我们知道什么是策略模式,但还没有运用于创建自己的实现。在工具商店浏览工具和实际购买并使用工具之间还是存在显著区别的。因此,我们现在从货架上取出这个设计模式,并把它投入到使用中。
尝试新技巧的最快方法是把它融入到已经编写完成的代码中。让我们设计一个可以用这种模式解决的问题,它建立在我们已经编写完成的代码基础之上。
班长
在一所特定的学校中,每个班级具有一名指定的“班长”,如果教师离开了教室,就由这名学生负责维持课堂秩序。最初,这个称号授予班里学习成绩最好的学生。但是,现在有些教师觉得班长应该是具有最深资历的学生,也就是学生 ID 最小的那个学生,因为学生 ID 是按照进入班级的先后顺序依次分配的。还有一部分教师觉得指定班长这个传统是件非常愚蠢的事情。为了表示抗议,他们简单地选择按照字母顺序排列的班级花名册中所出现的第 1 个学生。我们的任务是修改学生集合类,添加一个方法从集合中提取班长,同时又能适应不同教师的选择标准。
正如我们所见,这个问题将要采用策略形式的模式。我们需要让这个方法根据不同的选择标准返回不同的班长。
为了在 C++ 中实现这一点,需要使用函数指针。我们已经简单地从 qsort 函数中了解过这个概念。qsort 函数接受一个函数指针,它所指向的函数对需要进行排序的数组中的两个数据项进行比较。
在这个例子中,我们完成类似的任务。我们将创建一组比较函数,接受 2 个 studentRecord 对象为参数并分别根据成绩、学生 ID 值或姓名确定第 1 个学生是否“好于”第 2 个学生。
首先,我们需要为比较函数定义一个类型:
这个声明创建了一个称为 firstStudentPolicy 的类型,它是个函数指针,它所指向的函数返回一个 bool 值并接受两个 studentRecord 类型的参数。
*firstStudentPolicy 号两边的括号 ❶ 是必要的,这是为了防止这个声明被解释为返回一个 BOOL 类型的指针的函数。有了这个声明之后,我们就可以创建 3 个策略函数了:
前两个函数非常简单:
higherGrade 在第 1 条记录的成绩值大于第 2 条记录时返回 true;
lowerStudentNumber 在第 1 条记录的学生 ID 值小于第 2 条记录时返回 true。
第 3 个函数 nameComesFirst 在本质上与前两个函数相同,但它需要使用 strcmp 库函数。这个函数接受 2 个“C 风格”的字符串,即以 null 结尾的字符数组而不是 string 对象。因此我们必须对两条学生记录的姓名字符串使用 c_str() 方法。strcmp 函数在第 1 个字符串按照字母顺序出现在第 2 个字符串之前时返回一个负数,因此我们检查它的返回值以判断它是否小于 0 ❸。
现在,我们就可以修改 studentCollection 类本身了:
这个类声明增加了一些新的成员:一个私有数据成员 _currentPolicy,它存储了指向其中一个策略函数的指针、一个用于修改策略的 setFirstStudentPolicy 方法,以及根据当前策略返回班长的 firstStudent 方法本身。
setFirstStudentPolicy 的代码非常简单:
我们还需要修改默认构造函数对当前策略进行初始化:
现在,我们可以编写 firstStudent 方法:
这个方法首先检查特殊情况。如果没有需要检查的链表或者不存在策略❶ ,就返回一条哑记录。否则,就使用本书中广泛使用的基本搜索技巧,对这个链表进行遍历并寻找最适当地匹配当前策略的学生。
我们把链表开始位置的那条记录赋值给 first ❷,使循环变量从链表的第 2 条记录开始 ❸,然后执行遍历。
在遍历循环中,对当前策略函数的调用 ❹ 告诉我们目前所查看的学生根据当前标准是否“好于”到现在为止所找到的最佳学生。当这个循环结束时,我们就返回“班长” ❺。
班长解决方案的分析
使用策略模式解决了一个问题之后,我们很可能想确认这种技巧可以适用的其他场合,而不是一次了解了这个技巧之就将其束之高阁。我们还可以对示例问题进行分析,形成对这个技巧的价值的认识,明白什么时候使用它比较合适,什么时候使用它则是个错误,或至少它带来的价值应多于麻烦。对于这个特定的模式,读者可能会看到它弱化了封装和信息隐藏。
例如,如果客户代码提供了策略函数,它就需要访问通常属于类内部的类型,在这个例子中也就是 studentRecord 类型。这意味着如果我们修改了这个类型,客户代码就有可能失败。把这个模式应用于其他项目之前,必须在这个顾虑与它可能带来的好处之间进行权衡。通过对自己的代码进行检查,可以对这个关键问题获得深入的体会。
至于进一步的实践,我们可以检查已完成项目的库,搜索可以使用这种技巧进行重构的代码。记住,很多“现实世界”的编程涉及到对现有的代码进行补充或修改,因此这是进行这类修改的一种非常好的实践,还能发展自己运用某种特定组件的技能。而且,良好的代码复用的一个优点是我们可以从中进行学习,而实践能够最大限度地提升学习的效果。
2. 根据需要寻找可复用组件
前一节描述了“漫游式学习”的过程。虽然这种类型的学习旅程对于程序员而言是极具价值的,但有时候我们必须直接针对一个特定的目标学习。
如果我们正在着手处理一个特定的问题,特别是当这项工作面临极大的时间压力时,我们会猜测某个组件可能会为我们提供极大的帮助。我们不想通过随机漫游编程世界来碰到自己所需要的东西,而是想尽可能快地找到直接适用于自己所面临问题的组件。
但是,这听起来似乎有些挑战,当我们并不准确地知道自己所寻找的是什么时,怎样才能找到自己所需要的东西呢?思考下面这个示例问题:
高效的遍历
一个编程项目将使用我们的 studentCollection 类。客户代码需要做到能够遍历集合中的所有学生。显然,为了维护信息隐藏,客户代码不能直接访问这个链表,但要求高效地对其进行遍历。
由于这段描述中的关键词是高效,让我们精确地分析它在这个例子中的含义。我们假设 studentCollection 类的一个特定对象具有 100 条学生记录。如果我们直接访问这个链表,可以编写一个迭代 100 次的循环。这是所有的链表遍历中最高效的做法。任何要求我们迭代超过 100 次的循环都可以认为其结果是不够高效的。
如果没有高效这个需求,我们可以在这个类中添加一个简单的 recordAt 方法来解决这个问题。这个方法返回集合中特定位置的学生记录,第1条记录的位置编号为 1:
在这个方法中,我们使用了一个循环 ❶ 对链表进行遍历,直到找到了所需的位置或者到达了链表的尾部。当这个循环结束时,如果已经到达了链表的尾部,我们就创建并返回一条哑记录 ❷。如果是在指定的位置就返回这条记录 ❸。问题在于我们执行遍历只是为了寻找一条学生记录。这并不一定是完整的遍历,因为当我们到达所需的位置时就会终止循环,但它终归还是进行了遍历。假设客户代码试图求学生成绩的平均值:
对于这段代码,假设 sc 是个以前所声明并生成的 studentCollection 对象,recNum 是个表示记录数量的整数。假设 recNum 变量值为 100。当我们初步扫视这段代码时,可能觉得计算平均成绩只需要迭代这个循环 100 次,但由于每次调用 recordAt 函数本身就要执行一次不完整遍历,因此这段代码总共涉及 100 次遍历,每次遍历平均需要进行 50 次迭代。因此,它的结果并不是非常高效的 100 个步骤,而是大约需要 5000 个步骤,这是极为低效的。
什么时候搜索可复用组件
现在,我们触及到真正的问题。让客户访问集合成员对其进行遍历是非常容易的,但高效地提供这种访问却是非常困难的。当然,我们可以尝试只用自己的能力来解决这个问题。但是,如果我们可以使用一个组件,就能够很快实现一个解决方案。
为了寻找一个适用于我们的解决方案的未知组件,第 1 个步骤是假设这个组件实际上存在。换句话说,如果我们不开始搜索,就肯定无法找到这样一个组件。因此,为了最大限度地获得组件的优点,需要使自己处于能够让组件发挥作用的场合。发现自己陷在问题的某个方面而无法自拔时,可以尝试下面这些方法:
以通用的方式重新陈述这个问题。
向自己提问:这是否可能成为一个常见的问题?
第 1 个步骤非常重要,因为我们把问题陈述为“允许客户代码高效地计算一个类所封装的记录链表中的平均学生成绩”,它听上去特定于我们所面临的情形。但是,如果我们把这个问题陈述为“允许客户代码高效地遍历一个链表,并且不需要提供对链表指针的直接访问”,我们就开始理解这可能成为一个常见的问题。
显然,我们可以想象,由于程序常常需要在类中存储链表和其他线性访问的数据结构,因此其他程序员肯定已经想出了允许高效地访问数据结构中的每个数据项的办法。
寻找可复用组件
既然我们已经同意进行观察,现在就可以寻找组件了。为了清晰起见,我们把原来的编程问题重新陈述为一个搜索问题:“寻找一个组件,可以用它修改我们的studentCollection类,允许客户代码高效地遍历内部的链表。”
那么怎样解决这个问题呢?我们首先可以观察任意类型的组件:模型、算法、抽象数据类型或库。
假设我们首先在标准 C++ 库中进行寻找。我们没有必要寻找一个可以“插入”到自己的解决方案中的类,而是挖掘一个与自己的 studentCollection 类相似的库类,以借鉴思路。这就用到了我们用于解决编程问题的类比策略。以前对 C++ 库的探索已经使我们与诸如 vector 这样的容器类有了一定程度的接触,因此我们应该寻找一种与学生集合类最为相似的容器类。
如果求助于自己所喜欢的 C++ 参考资料,例如一本相关的书籍或网络上的一个站点并查看C++容器类,将会发现有一个称为 list 的“线性容器”符合这个要求。
list 类是否允许客户代码对它进行高效的遍历呢?它能够做到这一点,只要使用一个称为迭代器的对象。我们看到 list 类提供了产生迭代器的 begin 和 end 方法。迭代器是一种对象,它可以引用 list 类中的一个特定数据项,并且可以增加自己的值,使它引用list类中的下一个对象。如果 integerList 是一个包含了整数的 list<int> 并且 iter 是个 list<int>::iterator,我们就可以用下面的代码显示这个 list 中的所有整数:
通过使用迭代器,list 类向客户代码提供了一种机制高效地对 list 进行遍历,从而解决了这个问题。此时,我们可能会想到把 list 类本身吸收到我们的 studentCollection 类中,替换原先所使用的链表。然后,我们可以为这个类创建 begin 和 end 方法,对它所包含的 list 对象的方法进行包装,这样问题就解决了。
但是,这种做法就涉及到良好的复用和不良的复用的问题。
一旦我们完全理解了迭代器的概念并且可以在自己的代码中生成它,再把标准模板库中的一个现有的类插入到自己的代码中就是非常好的选择,甚至是最好的选择。但是,如果我们没有能力做到这一点,对 list 类的这种偷懒用法就不会帮助自己成长为优秀的程序员。
当然有时候我们必须使用那些自己无法开发的组件,但是如果我们养成了让其他程序员为自己解决问题的习惯,就很难成长为真正的问题解决专家。因此,让我们自己实现迭代器。
在此之前,我们先简单地观察一下寻找迭代器方法的其他途径。我们是在标准模板库中进行搜索的,但也可以从其他地方开始搜索。
例如,我们也可以在一组常用的设计模式中进行搜索。在“行为模式”这个标题的下面,我们可以找到迭代器模式。在这个模式中,客户允许对集合中的数据项进行线性访问,而不需要直接接触集合的底层结构。这正是我们所需要的。我们可以通过搜索一个模式列表找到它,也可以通过以前对模式的研究想到它。
我们还可以从抽象数据类型开始搜索,因为通用意义上的列表(以及特定意义上的链表)是常见的抽象数据类型。但是,对列表抽象数据类型的许多讨论和实现并没有考虑到把客户对列表的遍历作为一种基本操作,因此不会引发迭代器的概念。
最后,如果我们是在算法领域开始搜索的,很可能无法找到适用的东西。算法倾向于描述技巧性的代码,而创建迭代器则相当简单,正如我们稍后将要看到的那样。在这个例子中,在类库中搜索使我们以最快的速度找到了目标,其次是模式。但是,作为一个基本规则,在搜索一个有用的组件时,必须考虑所有的组件类型。
应用可复用组件
现在,我们准备为 studentCollection 类创建一个迭代器,但是标准模板库的 list 类向我们所展示的只是怎样在客户代码中使用迭代器的方法。
如果我们不知道该怎样实现迭代器,可以考虑对 list 类以及它的祖先类的代码进行研究,但是阅读大量不熟悉的代码无疑具有很大的难度,是万般无奈的情况下不得已采用的办法。
其实,我们可以用自己的方式来对它进行思考。把以前的代码例子作为参考,我们可以认为迭代器是由 4 个核心操作所定义的:
1.集合类提供了一个方法 ,提供了引用集合第 1 个元素的迭代器。在 list 类中,这个方法是 begin。
2.测试迭代器是否越过了集合最后一个元素的机制。在 list 类中,这个方法是 end,它针对这个测试产生了一个特殊的迭代器对象。
3.迭代器类中使迭代器向前推进一步的方法,是使它引用集合中的下一个元素。在 list 类中,这个方法是重载的 ++ 操作符。
4.迭代器类中返回集合当前所引用的元素的方法。在 list 类中,这个方法是重载的 *(前缀形式)操作符。
站在编写代码的角度,上面这些并没有困难之处,唯一的问题就是把所有的东西放在正确的位置。因此,我们现在就开始处理这个问题。
根据上面的描述,我们的迭代器(称为 scIterator)需要存储一个指向 studentCollection 中的一个元素的引用,并且能够推进到下一个元素。因此,这个迭代器应该存储一个指向 studentNode 的指针,这样就允许它返回集合中的studentRecord对象并允许它推进到下一个 studentNode 对象。
因此,这个迭代器类的私有部分将具备以下这个数据成员:
我们马上就遇到了一个问题。studentNode 类型是在 studentCollection 类的私有部分声明的,因此上面这行代码是行不通的。我们首先想到的是不应该把 studentNode 声明为私有部分,但这并不是正确的答案。节点类型在本质上是私有的,因为我们并不希望任何客户代码依赖节点类型的某种特定性质实现,不想因为这个类进行了修改而导致客户代码的失败。然而,我们还是需要让 scIterator 类能够访问自己的私有类型。
我们通过一个友元声明来解决这个问题。在 studentCollection 类的公共部分,我们添加了下面这一行:
现在,scIterator 可以访问 studentCollection 类的私有声明,包括 studentNode 的声明。我们还可以声明一些构造函数,如下所示:
我们稍微观察一下 studentCollection 类再编写 begin 方法,这个方法返回一个引用集合第 1 个元素的迭代器。根据本书所使用的命名方案,这个方法应该用名词来表示,例如 firstItemIterator:
正如所看到的那样,我们需要完成的任务就是把链表的头指针塞到一个 scIterator 对象中并返回它。如果读者的做事风格与我相似,看到指针飞临此处可能会觉得有点紧张,但是注意 scIterator 正要保存一个指向 studentCollection 列表中的一个元素的引用。它不会为自己分配任何内存,因此我们并不需要担心深拷贝和重载的赋值操作符。
现在我们返回到 scIterator 并编写其他方法。我们需要一个方法推进迭代器,使它引用下一个元素,还需要编写一个方法测试它是否越过了集合的尾部。我们应该同时考虑这两个操作。
在推进迭代器之前,我们需要知道当迭代器越过了列表的最后一个节点之后应该具有什么值。如果不想搞什么特殊,迭代器在这个时候很自然应该是 NULL 值,这也是最容易使用的值。注意,我们已经在默认构造函数中把迭代器初始化为 NULL,因此当我们用 NULL提示越过集合尾部时,就会在这两种状态之间产生混淆。但是,对于当前的问题而言,这并不会造成什么麻烦。这个方法的代码如下:
记住,我们只是用迭代器概念来解决原先的问题。我们并不需要复制C++标准模板库的迭代器类的准确规范,因此无需使用相同的接口。在这个例子中,我们并非对++操作符进行重载,而是选择了一个称为 advance ❶ 的方法,它判断当前的指针是否为 NULL ❷,然后再把它推进到下一个节点 ❸。类似地,我发现创建一种特殊的“尾”迭代器并与之进行比较是种很笨拙的做法,因此决定只选择一个称为 pastEnd ❹ 的 bool 方法,用于确定是否已经遍历完了节点。
最后,我们需要一种方法获取当前所引用的 studentRecord 对象:
正如我们之前所做的那样,为了安全起见,如果指针的值为NULL,我们就创建并返回一条哑记录 ❶。否则,我们就返回当前所引用的记录 ❷。这样我们就完成了 studentCollection 类的迭代器概念的实现。
为了清晰起见,以下是 scIterator 类的完整声明:
完成了所有的代码之后,我们可以用一个示例遍历对代码进行测试。下面我们实现平均成绩计算以进行比较。
这段代码使用了所有与迭代器相关的方法,因此可以对我们的代码进行很好的测试。我们调用 firstItemIterator 函数对 scIterator 对象进行初始化 ❶,调用 pastEnd 函数作为循环终止测试 ❷。我们还调用迭代器对象的 student 方法获取当前的studentRecord以便提取成绩 ❸。最后,为了把迭代器移动到下一条记录,我们调用了 advance 方法 ❹。
当这段代码顺利运行时,我们可以合理地确信自己已经正确地实现了各个方法,而且对迭代器的概念有了坚实的理解。
高效遍历解决方案的分析
和以前一样,代码能够工作并不意味着这个事件的学习潜力就到此为止了。我们还应该仔细考虑完成了什么任务、它的正面效果和负面影响,并对我们所实现的基本思路的相应扩展进行思考。
在这个例子中,我们可以认为迭代器的概念确实解决了客户代码对集合的低效遍历这个最初问题。一旦实现了迭代器之后,它的使用就变得非常优雅并容易理解。从负面的角度考虑,基于 recordAt 方法的低效方法显然要容易编写得多。在决定是否为一种特定的情况实现迭代器时,必须考虑遍历的发生频率、列表中一般会出现多少个元素等问题。
如果很少进行对列表的遍历并且列表本身很短,那么低效问题很可能并不严重。但是,如果我们预期列表将会增长或者无法保证它不会增长,那么就应该使用迭代器方法。
当然,如果我们已经决定使用标准模板库的一个 list 类对象,就不需要再担心迭代器的实现难度这个问题,因为我们用不着自己实现它。下次再遇到类似的情况时,我们就可以使用 list 类,而不必感觉自己是在偷懒,也不必认为以后会在这方面遇到困难。因为我们已经对列表和迭代器进行了研究,理解了它们幕后的工作原理,即使自己从来没有研究过它们的实际源代码。
把话题再深入一步,我们可以考虑迭代器的更广泛应用以及它们可能存在的限制。
例如,假设我们需要一个迭代器,不仅希望它能够高效地推进到 studentCollection 中的下一个元素,而且能够同样高效地退回到前一个元素。既然我们已经理解了迭代器的工作原理,就很容易明白在当前的 studentCollection 实现上是没有办法完成这个任务的。如果迭代器维护一个指向列表中某个特定节点的链(即next字段),把它推进到下一个节点只需要访问节点中的这个链。但是,撤回到前一个节点则要求反向遍历列表。我们可以采用双链表,每个节点维护两个分别指向前一个节点和下一个节点的指针。
我们可以对这个思路进行归纳,开始考虑不同的数据结构以及它们可以向客户提供的高效的遍历类型或数据访问。
考虑这样的类似问题可以帮助我们成为更优秀的程序员。我们不仅能够学习新的技巧,还能够了解不同组件的优点和缺点。了解组件的优缺点可以帮助我们合理地使用组件。没有考虑到一种特定方法所存在的限制可能会导致悲惨的结果。对自己所使用的组件了解越多,发生这种事件的概率也就越低。
选择可复用组件类型
正如我们在这些例子中所看到的那样,示例问题可以通过不同类型的组件来解决。一个模式可能表达了一种解决方案的思路,一种算法可能规划了思路的一种实现或者解决同一个问题的另一种思路,一种抽象数据类型可能封装了某个概念,类库中的一个类可能包含了一种抽象数据类型的完整的、经过测试的实现。如果它们都是对于解决我们的问题所需要的同一个概念的一种表达,那么我们怎样才能知道哪种组件类型放进我们的工具箱是最适合的呢?
一个主要的考虑是把组件集成到自己的解决方案需要多大的工作量。把一个类库链接到自己的代码常常是解决问题最迅速的方法,从一段伪码描述实现一种算法可能需要大量的时间。
另一个重要的考虑是组件所提供的灵活性有多大。组件常常是以一种漂亮的、预包装的形式出现,但是当它集成到解决方案时,程序员发现虽然这个组件具有他所需要的大多数功能,但它并不能完成所有的任务。
例如,也许一个方法的返回值格式不正确,需要额外的处理。如果坚持使用这个组件,在使用过程中可能会出现更多的问题,最后还是不得不放弃,只能从头寻找新的方案。如果程序员选择了一种位于更高概念层次的组件(如模式),最终的代码实现将会完美地适合需要解决的问题,因为它就是根据这个问题而创建的。
下图对这两个因素的相互影响进行了总结。一般而言,来自类库的代码马上就能被使用,但它无法被直接修改。它只能通过间接的方式修改,或者使用C++模板,或者让解决问题的代码实现本文前面所提到的策略模式之类的东西。另一方面,模式所表示的东西可能仅仅是个思路(如“这个类只能具有1个实例”),它提供了最大的实现灵活性,但是对于程序员而言则需要大量的工作。
当然,这并不是一个基本的指导方针,每个人所面临的情况可能各不相同。也许我们从类库中所使用的类在自己的程序中位于相当低的层次,它的灵活性并不重要。例如,我们可能想自己设计一个集合类,包装了类似list这样的基本容器类。由于list类所提供的功能相当广泛,因此即使我们必须对这个集合类的功能进行扩展,预计作为底层容器的list类也完全能够胜任。在使用模式之前,也许过去已经实现了一个特定的模式,我们可以对以前所编写的代码进行适配,这样就不需要创建太多的新代码。
(图:组件类型的灵活性与所需要的工作量)
在使用组件方面的经验越丰富,对于选择正确的组件就会有更大的自信。在积累足够的经验之前,可以把灵活性和所需工作量之间的权衡作为粗略的指导方针。对于每种特定的情况,可以提出下面这几个问题:
能不能直接使用这个组件?还是需要额外的代码才能让它应用于自己的项目?
我是否确信已经从各个方面理解了问题,或者理解了与这个组件相关联的问题,并且确定它在未来也不会发生变化?
通过选择这个组件,是不是能够扩展我的编程知识?
这些问题的答案可以帮助我们评估选择某个组件所需要的工作量以及自己能够从每种可能的方法中获得多大的益处。
可复用组件选择的实战
现在我们已经理解了基本的思路,下面可以通过一个简单的例子来说明具体的细节了。
对某些数据进行排序,但其他数据保持不变
一个项目要求我们对一个 studentRecord 对象数组按成绩进行排序,但是存在一个难点:这个程序的另一部分使用 −1 这个特殊的成绩值表示那些无法移动记录的学生。因此,尽管所有其他记录必须移动,但那些成绩值为 −1 的记录必须保留在原先的位置。最终所产生的结果是一个排好序的数组,但其间散布着一些成绩值为 −1 的记录。
这是一个需要技巧的问题,我们可以尝试用多种方法来解决这个问题。为了简单起见,我们把选项减为 2 个:
选择一种算法,例如像插入排序这样的算法并对它进行修改,忽略那些成绩值为 −1 的 studentRecord 对象。
想出一种方法,用 qsort 库函数来解决这个问题。
这两个选择都是可行的。由于我们已经熟悉了插入排序的代码,在它的里面插入几条 if 语句,显式地检查并跳过那些成绩值为 −1 的记录应该不会太困难。让 qsort 为我们完成工作就需要一些变通。我们可以把具有真正成绩值的学生记录复制到一个单独的数组中,用 qsort 对它们进行排序,然后再复制回原先的数组,并保证在复制时不会覆盖原先成绩值为 −1 的记录。
让我们对这两个选项进行分析,观察组件类型的选择是怎样影响最终代码的。我们首先从算法组件开始,编写经过修改的插入排序算法来解决这个问题。和往常一样,我们将分几个阶段来解决这个问题。
首先,我们通过去掉 −1 成绩这个阶段性问题来削减这个问题,对 studentRecord 对象数组进行排序时不考虑任何特殊规则。如果 sra 是包含了arraysize个studentRecord 类型的对象数组,它的代码应该如下所示:
这段代码与整数的插入排序非常相似。唯一的区别是它在执行比较时调用了 grade 方法 ❶,另外还更改了用于交换空间的临时对象的类型 ❷。这段代码能够顺利完成任务,但是对它以及本节后面的代码段进行测试的时候,有一个需要警惕的地方:正如以前所编写的那样,studentRecord 类会对数据执行验证,它不会接受 −1 作为成绩值,因此需要进行必要的修改。现在我们就可以完成这个版本的解决方案了。
我们需要让插入排序忽略成绩值为 −1 的记录。这个任务并不像听上去那么简单。在基本的插入排序算法中,我们总是在数组中交换相邻的位置,如上面代码中的 j 和 j – 1。但是,如果我们让有些记录的成绩值保留为 −1,那么需要与当前记录进行交换的下一条记录的位置可能相隔甚远。
下图用一个例子描述了这个问题。它显示了最初配置下的数组,并用箭头提示第 1 条记录需要被交换到的位置,它们并不是相邻的。而且,最后一条记录(表示 Art)最终将从位置 [5] 交换到位置 [3] ,然后再从 [3] 交换到 [0],因此对这个数组排序所进行的所有交换都涉及到非相邻的记录(至少对于我们所排序的那些记录是这样的)。
图:修改后的排序算法中需要被交换的记录之间的任意距离
在考虑怎样解决这个问题时,我设法寻找一个类比。我在处理链表的问题的选择中找到了一个类比。在许多链表算法中,我们在链表遍历时不仅需要维护一个指向当前节点的指针,还需要维护一个指向前一个节点的指针。因此在循环体结束的时候,我们经常把当前节点指针赋值给前一节点指针,然后再把当前节点指针指向下一个节点。
这个例子也需要类似的做法。当我们按照线性顺序遍历这个数组寻找下一条“真正的”学生记录时,还需要追踪前一条“真正的”的学生记录。把这个思路投放到实践中就产生了如下的代码:
在基本的插入排序算法中,我们反复地把未排序的元素插入到数组中一块不断增长的已排序区域中。外层的循环选择下一条需要被放到排序区的未排序元素。
在这个版本的代码中,我们首先在外层循环体中判断位置i的成绩值是不是 −1 ❶。如果是,我们就简单地跳到下一条记录,保留这个位置不变。
当我们确定位置 i 的学生记录可以被移动时,就把 rightswap 初始化为这个位置 ❷。然后我们就进入内层循环。在基本的插入排序算法中,内层循环的每次迭代都把一个元素与它相邻的元素进行交换。
但是,在这个版本的插入排序中,由于我们让成绩值为 −1 的学生记录保持不动,所以只有当位置 j 的学生记录的成绩值不是 −1 时才执行交换 ❹。
然后,我们在 leftswap 和 rightswap 这两个位置之间执行交换并把 leftswap 赋值给 rightswap ❺,如果还有要执行的交换就设置下一次交换。
最后,我们必须修改内层循环的终止条件。正常情况下,插入排序的内层循环是在到达了数组的前端或者找到了小于需要被插入值的元素的时候终止。在这个例子中,我们必须用逻辑或操作符创建一个复合条件,使循环能够跳过成绩值为 −1 的记录 ❸。(由于−1小于所有合法的成绩值,因此会永久地停止循环。)
这段代码解决了我们的问题,但它很可能会散发出某种“不良的气味”。标准的插入排序算法很容易理解,尤其是当我们理解了它的主旨时。但是,这个经过修改的版本就很难读懂,如果我们想在以后还能看懂这段代码,很可能需要添加几条注释。
也许可以对它进行重构,但我们先试试用其他方法来解决这个问题并观察其结果。
我们所需要的第一样东西就是一个在 qsort 中使用的比较函数。在这个例子中,我们将比较两个 studentRecord 对象,并且这个函数将把一个成绩值减去另一个成绩值:
现在,我们就可以对记录进行排序了。我们将分为 3 个阶段完成这项任务。首先,我们把所有成绩值不是 −1 的记录复制到第 2 个数组,元素之间没有空隙。接着,我们调用 qsort 对第 2 个数组进行排序。
最后,我们把第 2 个数组的记录复制回原来的数组,跳过那些成绩值为 −1 的记录。最终的代码如下所示:
尽管这段代码的长度和前面那个解决方案差不多,但它更加简明易懂。
我们首先声明第 2 个数组 sortArray ❶,它的长度与原先的数组相同。sortArrayCount 变量被初始化为 0 ❷。在第1个循环中,我们将用这个变量追踪检查有多少条记录已经被复制到第2个数组中。在这个循环的内部,每次遇到一条成绩值不是 −1 的记录时 ❸,我们就把它赋值给 sortArray 中的下一个空位置并将 sortArrayCount 的值增加 1。
当这个循环结束时,我们就对第 2 个数组进行排序 ❹。sortArrayCount 变量被重置为 0 ❺。我们将在第 2 个循环中用它追踪检查有多少条记录已经从第 2 个数组复制回原先的数组。注意,第 2 个循环对原先的数组进行遍历 ❻,寻找需要被填充的位置 ❼。
如果我们用其他方法来完成这个任务,可以尝试对第 2 个数组进行遍历,并把记录推回到原来的数组。那样,我们将需要一个双重循环,其中内层循环搜索原先的数组中下一个具有真正成绩值的位置。这是问题的难易程度取决于它的概念化层次的又一个例子。
比较结果
这两个解决方案都可以完成任务并且都采用了合理的方法。对于大多数程序员而言,对插入排序进行修改并在排序时使部分记录保持不动的第 1 个解决方案很难编写和读懂。但是,第 2 个解决方案似乎有些低效,因为它需要把数据复制到第 2 个数组并复制回来。
下面对这两种算法进行简单的分析。假设我们对 10,000 条记录进行排序,如果需要排序的次数极少,那就无需太关注效率问题。我们无法确切地知道 qsort 所采用的底层算法是什么,但是对于通用目的的排序,最坏的情况是要执行 1 亿次的记录交换,最佳的情况只需要执行 13 万次。
不管实际的交换次数是这个范围内的哪个数字,来回复制 10,000 条记录相对于排序而言并不会对性能产生非常大的影响。另外,还必须考虑 qsort 所采用的排序算法可能比我们所采用的简单排序更为高效,这样使第 1 个解决方案不需要把数据来回复制到第 2 个数组的优点也化为乌有。
因此在这个场景中,使用 qsort 的第 2 种方法要更好一点。它更容易实现、更容易读懂,因此也更容易维护。并且,我们可以预期它的执行效率不逊于第1个解决方案,甚至更胜一筹。
第 1 个解决方案的最大优点是我们可以把学会的技巧应用于其他问题,而第 2 个解决方案由于过于简单而缺乏这一点。
作为基本规则,当我们处在需要最大限度地提高自己的编程能力的阶段时,应该优先选择高层次的组件,例如算法或模式。当我们处在需要最大限度地提高编程效率(或者时间期限非常紧张)的阶段时,应该优先考虑低层次的组件,尽可能选择预创建的代码。
当然,如果时间允许,可以尝试不同的方法,就像我们刚刚所完成的那样,这样可以得到最大的收获。
思考题
尽可能多地对可复用组件进行试验,一旦掌握了怎样学习新组件,将会极大地提高自己的编程能力。
对策略模式的一个反对意见是它需要暴露类的一些内部实现,例如类型。修改本文前半部分的“班长”程序,使策略函数都存储在这个类中,并通过传递一个代码值(例如,一种新的枚举类型)来进行选择,而不是传递策略函数本身。
考虑一个 studentRecord 对象的集合。我们想要根据学生编号寻找一条特定的记录。把学生记录存储在一个数组中,根据学生编号对数组进行排序,并研究和实现插值搜索算法。
对于上面的问题,通过一个抽象数据类型来实现一个解决方案。这个抽象数据类型允许存储任意数量的数据项,并可以根据键值提取单独的记录。对于能够根据一个键值高效地存储和提取数据项的结构,它用基本术语表示就是符号表,符号表思路的常见实现是散列表和二叉搜索树。
本文节选自人民邮电出版社《像程序员一样思考》第 7 章,部分内容有简化,感兴趣的读者可以在各大书店购买。
人邮的公众号「人邮 IT 书坊」长期提供最新 IT 图书资讯,欢迎关注。
更多精彩,关注人邮IT书坊
一个为程序员提供干货、书讯、赠书活动的地方
往期精彩文章,点击图片可阅读
一个为留长发做了程序员并做到CTO的法学毕业生
关于烂代码的那些事 - 为什么每个团队存在大量烂代码
苏武:一个工程师在蘑菇街4年的架构感悟
Redis实战:如何构建类微博的亿级社交平台
本文由人民邮电出版社信息技术分社授权「高可用架构」发表。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。
高可用架构
改变互联网的构建方式
长按二维码 关注「高可用架构」公众号