1. Pig Latin:一个模块化开发的案例研究
- 模块化开发(modular development)
把一个程序分成多个模块的技术 。
为了阐述这种技术,本章给出了一个利用模块化开发能够提供明显优势的问题。
这个问题是写一个程序用来从终端读入一行文本,并把这行文本中的英文转换成Pig Latin。
Pig Latin是按照如下简单规则转换每个英文单词的一种自发明语言。
(1) 如果单词以辅音开头,那么把起始辅音字符串(即直到第一个元音字母的所有字母)从单词开始移到单独尾部,并加上后缀ay。
(2) 如果单词以元音开头,则加后缀way。
例如,假设单词是scram,以辅音开头,这样就创建了Pig Latin单词amscray;
对于以元音开头的单词,如apple,只要在末尾加上way,从而得到appleway。
1.1 应用自顶向下的设计
既然问题是把整行英文翻译成Pig Latin,那么,程序应该能够生成如下运行示例:
![](https://i-blog.csdnimg.cn/blog_migrate/256954cf0169c57e4273df8a0ee78584.png)
通常,仅仅在比较了不同的解决方法以后,才可以决定一个程序是否需要一个特定模块分解。
因此,最好的方法常常是先应用「自顶向下」的设计方法:
从主程序的层次着手,然后依次写下一系列函数,每个函数解决整个问题中的一个部分。
在初始阶段,可以用一系列有待具体实现的高层步骤来定义main
函数。通常,先描述出其功能。
如,初稿为:
![](https://i-blog.csdnimg.cn/blog_migrate/5ac628726ae433b5870a8e1256d178f5.png)
- 伪代码(pseudocode)
由英文和C语言混合构成的程序。
1.2 使用伪代码
伪代码的作用在于,记录逐步精化的过程。
在用一系列英文步骤写出整个程序之后,可以再回到伪代码语句,用实际的C语言代码来实现它们。
在用代码替换伪码的过程中,对于难编码的部分,最好的方法是实施逐步精化的策略:
用一个效果相当的新的函数来替换这行伪代码。
例如,1.1中的main函数的实现如下:
#include <stdio.h>
#include <stdbool.h>
#include <ctype.h>
#include "strlib.h"
main () {
string line;
line = malloc(1000);
printf("Enter a line: ");
scanf("%[^\n]", &line[0]);
TranslateLine(line);
}
为TranslateLine
定义描述和原型:
/*
* Function: TranslateLine
* Usage: TranslateLine(line);
* ---------------------------
* This function takes a line of text, translates it to Pig Latin and displays the result.
*/
void TranslateLine(string line);
1.3 实现TranslateLine
实现TranslateLine
,需要进一步把它分解。
在大多数情况下,没有一个特定的分解策略是绝对正确的。
因此,通常需要考虑几种分解问题的方法,然后看哪一种策略最好。
实现TranslateLine
时,需要先解决如何把一个字符串分为单词,把每个单词翻成Pig Latin,然后在屏幕上显示每个Pig Latin单词的问题。
根据这个问题的陈述,可以写出如下的概念分解:
![](https://i-blog.csdnimg.cn/blog_migrate/70ef4826e3287a37f21ea5cdbd28e3dd.png)
理论上分解合理,但会导致某些实际的问题:
在第一步中,将语句行分解为单词,如何存储结果呢?实现这个概念的函数返回的不是一个单词,而是一个单词列表。
仔细考虑会发现,不需要立即记录所有的单词。一旦找到一个单词,就可以马上翻译并且正确显示。一旦显示出这个单词,就可以丢开它继续处理下一个单词。
由此,可以提出第二种策略:
![](https://i-blog.csdnimg.cn/blog_migrate/3d18dfffb0c1cd852c26f0308619df37.png)
1.4 考虑空格和标点符号的问题
上述伪代码版本所用的策略中有个小问题:没有把空格和标点符号考虑进去。
程序设计中的一个现实问题是问题的描述通常是不完整的。在某些情况下,被省略的部分是非常重要的。在很多时候,必须自己选择一种合理的策略。
判断什么是合理的策略也有一些技巧。
在这个例子中,因为标点符号和空格都能表达出某种意义,所以最好让它们在输出中的位置和在输入中的位置相同。
因此,需要的输出为:
![](https://i-blog.csdnimg.cn/blog_migrate/8de0d74b0b33eccda3cfeb0308307b55.png)
可以用许多方法重新设计该程序,使得标点符号正确地出现在输出中。
例如,一种方法是改变主循环,使得它按字符而不是按单词为单位来处理。
如果使用这种策略,伪代码可以按如下结构实现:
![](https://i-blog.csdnimg.cn/blog_migrate/ee89cf748e441c3e5cd50c2479d7353a.png)
不过,在分解中暴露出一个更严重的问题:
前一节显示出来的伪代码版本包含的语句:
Get the next word.
在刚才的版本中消失了。
如果从程序员的角度考虑,会认为“get the next word”这个操作是一个非常有用的工具,它的应用范围远远超出简单Pig Latin程序。许多问题都需要把文本分割成单词。如果能开发出一个通用的函数来执行这个操作,那么将来解决此类问题都将从此受益。
另一方面,要用到当前的应用中,返回下一个词的函数还必须能够返回空格和标点符号,以使输出行中能包括这些符号。
1.5 精化单词的定义
当前需要做的是对一个单词的概念进行精化。如果看到这样的输入行:
this is pig latin.
可以将这个输入行看作由以下8个单独的片段组成:
![](https://i-blog.csdnimg.cn/blog_migrate/6de216838b56b3baa060ab39d9d50dd5.png)
和单词一样,上述方法把空格和标点解释为单独的实体。
- 记号(token)
在计算机科学中,作为一个相关单元的字符序列被称为一个记号。
上图中,每个方框都表示一个记号。
把空格和标点符号作为独立的记号使得可以修改TranslateLine
这个方案,使这些符号也显示出来。
修改过的伪代码策略如下:
![](https://i-blog.csdnimg.cn/blog_migrate/99a6837a5c98215d426b8a5b47d2043e.png)
![](https://i-blog.csdnimg.cn/blog_migrate/c28ae2e4d7804079bf0e9740e8aabc2a.png)
把一行分成一些独立的记号的想法在计算机科学中非常常见。
例如,当C编译器将一段程序翻译成机器代码的时候,此过程的第一步就是将输人文件分割成C语言所用的记号,即变量名、数字、运算符等等。
- 词法分析(lexical analysis)/记号扫描(token scanning)
将输入分解成记号的过程称为词法分析,或者记号扫描。
1.6 设计记号扫描器
要使用新的策略来完成TranslateLine
的实现,必须首先设计能够分割输入行的记号扫描器。
由于它很可能是个通用的工具,因此,将记号扫描器设计成一个独立的模块就十分有意义。
在前一节最新的TranslateLine
伪代码版本中,扫描器模块有两个不同的作用:
第一,扫描器必须提供一个函数返回当前行中的下一个记号,可以命名为GetNextToken
;
第二,当扫描完最后一个记号时要让客户知道,一种选择是定义一个叫AtEndOfLine
的谓词函数,它在读到最后一个记号时返回TRUE
。
必须将扫描器模块设计为能够追踪划分行的进度:
在GetNextToken
从行返回一个记号后,必须记住已经扫描过的那些记号,以便在下次调用时返回一个不同的结果。
- 内部状态(internal state)
一个模块中的多次函数调用之间的信息称为内部状态。
当模块维护内部状态时,模块的接口通常导出一个用来初始化此状态信息的函数。
例如,在扫描器模块中,提供一个函数InitScanner(line)
,它使得客户能够告诉扫描器在字符串line
的开头开始返回记号。为了得到这些记号,客户只要调用GetNextToken
即可,不需要参数。
哪个记号该被返回的信息是扫描器模块要维护的内部状态的一部分:
第一次调用GetNextToken
返回第一个记号,下次调用返回第二个记号,如此继续直到所有的记号都被读取为止。
现在,可以用函数InitScanner
、GetNextToken
和AtEndofLine
来更新TranslaterLine
的实现。如下所示:
![](https://i-blog.csdnimg.cn/blog_migrate/f1696fc1831f1250ea26717e0e59b1c7.png)
1.7 完成TranslateLine的实现
如果继续进行逐步精化的策略,可以用函数调用替换剩下的英文语句来完成这个实现。
因此,完成后的TranslaterLine是:
void TranslateLine(string line) {
string token;
InitScanner(line);
while (!AtEndOfLine()){
token = GetNextToken();
if (IsLegalWord(token)) token = TranslateWord(token);
printf("%s", token);
}
printf("\n");
}
然而,这个解决方案中还有两个函数仍然没有实现:IsLegalWord
和TranslateWord
。
谓词函数IsLegalword
确定GetNextToken
返回的记号是一个需要转换成Pig Latin的单词还是一个简单的标点符号。
Pig Latin规则仅当一个单词全部由字母组成时才有意义。因此,如果token
中的每个字符都是字母,IsLegalWord(token)
返回TRUE
。
代码实现如下:
bool IsLegalWord(string token) {
int i;
for (i = 0; i < StringLength(token); i++)
{
if (!isalpha(IthChar(token, i)))
return false;
}
return true;
}
在伪代码中,TranslateWord
的结构对应了Pig Latin的规则。
![](https://i-blog.csdnimg.cn/blog_migrate/5bd586406587f17fa500c50ef71932a6.png)
函数FindFirstVowel(word)
返回word
的第一个元音的下标位置。
这样,在TranslateWord
实现中的第一条语句,可以写成如下形式:
vp=FindFirstVowel(word);
其中,vp
是个用来记录元音位置的整型变量。
那么,如果word
中没有元音怎么办?
库函数FindChar
和FindString
使用-1作为一个特殊的标志,用来指出函数查询的值在字符串中不存在。这里也可以采用相同的策略。
当FindFirstVowel
返回-1时,简单的办法是让TranslateWord
返回原来的单词。
由此,TranslateWord
的代码实现如下:
string TranslateWord(string word) {
int vp;
string head, tail;
vp = FindFirstVowel(word);
if (vp == -1) {
return word;
} else if (vp==0) {
return Concat(word, "way");
} else {
head = SubString(word, 0, vp - 1);
tail = SubString(word, vp, StringLength(word) - 1);
return Concat(Concat(tail, head), "ay");
}
}
其中,FindFirstVowel
的实现为:
int FindFirstVowel(string word) {
int i;
for (i = 0; i < StringLength(word); i++) {
if (IsVowel(IthChar(word, i))) return i;
}
return -1;
}
bool IsVowel(char ch) {
switch (tolower(ch)) {
case 'a': case 'e': case 'i': case 'o': case 'u':
return true;
default:
return false;
}
}
1.8 定义扫描器模块接口
扫描器模块的接口,要输出三个函数:InitScanner
、GetNextToken
和AtEndofLine
。
产生scanner.h
接口的工作主要包括给每个函数编写注释,如下图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/58a9c3a0124f5e638949b0526e5eb967.png)
![](https://i-blog.csdnimg.cn/blog_migrate/403415b7462a823ff02736c9e3711cf5.png)
参考
《C语言的科学和艺术》 —— 第10章 模块化开发