将标准 C++ 视为一个新语言
Learning Standard C++ as a New Language
作者 Bjarne Stroustrup
译者 陈崴
就别再把 C++ 视为 C 的後一个语言了吧。这个问题问 C++ 之父就对了。
侯捷注:本文系北京《程序员》杂志 2001/04 的文章。译笔顺畅,技术饱满。
承译者陈崴先生与《程序员》杂志负责人蒋涛先生答允,
转载於此,以飨台湾读者,非常感谢。
未得陈崴先生与蒋涛先生二人之同意,任何人请勿将此文再做转载。
C/C++ User's Journal May,1999
Learning Standard C++ as a New Language
by Bjarne Stroustrup
导入
想要获得标准 C++ [叁考资料 1] 的最大优点,我们必须重新思考 C++ 程式的撰写方式。重新思考的方式之一就是,想想 C++ 应该如何学习(和教育)。我们应该强调什麽样的编程技术?我们应该先学习这个语言的哪一部份?在真正程式码中我们想要突显的又是哪一部份?
这篇文章把几个简单的 C++ 程式拿来比较,其中有些以现代化风格(使用标准程式库)撰写,有些以传统的 C 语言风格撰写。从这些简单例子所学到的功课,对大程式而言仍然具有重要意义。大体而言,这篇文章主张将 C++ 视为一个更高阶的语言来使用,这个语言依赖抽象性提供简练与优雅,但又不失低阶风格下的效率。
我们都希望程式容易撰写,正确执行,易於维护,并且效率可被接受。这意味我们应该以最接近此一理想的方式来使用 C++(或任何其他语言)。我猜想 C++ 族群尚未能够消化标准 C++ 所带来的种种设施;重新思考我们对 C++ 的使用方式,可获得一些重要的改善并进而达到上述理想。本文所重视的编程风格,焦点在於充份运用标准 C++ 所支援的设施,而不在那些设施本身。
主要的改善关键就是,透过对程式库的运用,降低我们所写的码的大小和复杂度。以下我要在一些简单例子中,示范并量化这些降低程度。这类简单实例可能出现在任何 C++ 导入性课程中。
由於降低了大小和复杂度,我们也就减少了开发时间,减轻了维护成本,并且降低了测试成本。另一个重点是,透过程式库的运用,我们还可以简化 C++ 的学习。对於小型程式以及只求获得好成绩的学生而言,这样的简化应该是相当充裕的了。然而专业程式员对效率极为要求,只有在效率不被牺牲的情况下,我们才能期许自己提升编程风格,以满足现代化服务和商务之於资料和即时回应的严格需求。为此我展示了一份量测结果,证明复杂度的降低并不会损失效率。最後我还讨论了这种观点对於学习和教育 C++ 所带来的影响。
复杂度 Complexity
试考虑一个题目,它很适合做为程式语言课程的第二道练习(译注:第一道练习当然是 "hello world" 啦):
输出一个提示句 “Please enter your name”
读入名字
输出“Hello <name>”在标准 C++ 中,明显的解答是:
#include<iostream> // 取得标准 I/O 设施 #include<string> // 取得标准 string 设施 int main() { // 获得对标准程式库的取用权利 using namespace std; cout << "Please enter your name: /n"; string name; cin >> name; cout << "Hello" << name << '/n'; }对一个真正的初学者,我们必须解释整个架构。什麽是 main()?#include 做了些什麽事?using 做什麽用?此外我们还得对所有的细琐规榘有所了解,例如 /n 的意义,哪里应该加上分号┅等等。
然而这个程式的主要成份,观念非常简单,和题目的文字叙述之间只是表示法的不同。我们必须学习这种表示法,那很简单:string 就是一个 string(字串),cout 就是一个 output(输出设备),<< 就是一个我们用来写到输出设备去的运算子┅等等等。
为了进行比较,下面是传统的 C-style 解法 [注 1]:
#include<stdio. h> // 取得标准的 I/O 设施 int main() { const int max = 20; // name 的最大长度为 19 char name[max]; printf("Please enter your name: /n"); // 将字元读入 name 之中 scanf( "%s" , name); printf( "Hello %s/n" ,name); return 0; }很明显,主要逻辑有了轻微的 ─ 只是轻微的 ─ 改变,比 C++-style 版本复杂一些,因为我们必须解释阵列和怪异符号 %s。主要的问题在於,这个简单的 C-style 解答没什麽价值。如果有人输入一个长度大於 19 的名字(所谓 19,是上述指定的 20 再减 1 ,扣除的那个 1 用来放置 C-style 字串的结束字元),这个程式就完蛋了。
有人认为这种劣质品其实不会造成伤害,只要运用「稍後介绍」的某种适当解法。然而就算如此,引起争议的那一行也只不过是「可接受」而已,还达不到「良好」的境界。理想上我们不应该让一个菜鸟使用者面对一个容易当机的程式。
这个 C-style 程式如何才能够像 C++-style 程式一样地举止合宜呢?首先我们可以适度运用 scanf 来避免阵列溢位(array overflow):
#include<stdio. h> // 取得标准的 I/O 设施 int min() { const int max 20; char name [max]; printf( "Please enter your first name: /n"); scanf( "%19s", name); // 读入最多 19 个字元 printf( "Hello %s/n", name); return 0; }没有什麽标准方法可以直接在 scanf 的格式字串中直接使用符号型式的缓冲区大小,所以我必须像上面那样地使用整数字面常数。那是一种不良风格,也是日後维护时的一颗不定时炸弹。下面是专家级的作法,但实在难以对初学者启口:
char fmt[10]; // 产生一个格式字串,如使用单纯的 %s 可能会造成溢位(overflow) sprintf(fmt, "%%%ds", max-1); // 读入至多 max-1 个字元到 name 之中。 scanf(fmt, name);犹有进者,这个程式会把 "超过缓冲区大小" 的字元砍掉。然而我们却希望字串能随着输入而成长。为了做到这一点,我们必须将抽象性下降到一个较低层次,处理个别的字元:
#include<stdio.h> #include<ctype.h> #include<stdlib.h> void quit() { // 写出错误讯息并离开 fprintf( stderr, "memory exhausted/n"); exit (1); } int main() { int max= 20; // 配置缓冲区: char* name = (char*) malloc(max); if (name ==0) quit(); printf( "Please enter your first name: /n"); // 跳过前导的空白字元 while (true) { int c = getchar(); if (c = EOF) break; // 档案结束 if (!isspace(c)) { ungetc (c, stdin); break; } } int i = 0; while (true) { int c = getchar(); if (c == '/n' || c == EOF) { // 在尾端加上结束字元 0 name[i] = 0; break; } name[i] = c; if (i == max-1) { // 缓冲区填满了 max = max+max; name = (char*) realloc(name, max); if (name == 0) quit(); } itt; } printf( "Hello %s/n", name); free(name); // 释放记忆体 return 0; }和先前的版本比较,这个版本明显复杂得多。加上一段「跳过前导空白字元」的处理,使我感觉有些罪恶,因为我并未在题目叙述中明白提出这项需求。不过「跳过前导空白字元」是很正常的行为,稍後其他版本也都会这麽做。
可能有人认为这个例子并不是那麽糟糕。大部份有经验的 C 程式员和 C++ 程式员在真正的应用程式中或许(顺利的话)已经写过某些这样的东西。我们甚至可能认为,如果你写不出那样的程式,你就不能算是一个专业程式员。然而,想想这些东西加诸於初学者的观念负担吧。上面这个程式使用七个不同的 C 标准函式,在非常琐屑的层次上处理字元层面的输入,运用了指标,并自行处理自由空间(free store,译注:通常即是 heap)。为了使用 realloc,我必须采用 malloc(而不是 new)。这把我们带入了大小和型别转换 [注 2] 的议题。在一个如此的小程式中,什麽才是处理可能发生之记忆体耗尽问题的最佳作法呢?答案并不明显。这里我只是做某些事情,以杜绝这个讨论变质为另一个毫不相干的主题。惯用 C-style 作法的人必须谨慎地想想,哪一种作法对於更深一层的教学和最後的实际运用能够形成良好的基础。
总结来说,为了解决原本那个简单问题,除了问题核心本身,我还得介绍回圈,测试,储存空间之大小,指标,转型,以及自由空间之显式管理。而且这种编程风格充满了出错的机会。感谢长久累积下来的经验,我才能够避免出现任何明显的大小差一错误( off-by-one)或记忆体配置错误。我在面对 stream I/O 时,一开始也犯了典型的初学者错误:读入一个 char(而不是一个 int)并忘记检查 EOF。在 C++ 标准程式库尚未出现的那个年代,一点也不令人惊讶,许多教师无法摆脱这些不值钱的东西,暂时搁置它们稍後再教。不幸的是,许多学生也仅仅注意到这种劣等风格 "够好",写起来比其 C++ style 兄弟快。於是他们养成了一种很难打破的习惯并留下一条容易犯错的轨迹。
最後那个 C-style 程式有 41 行,而功能相当的 C++-style 程式只有 10 行。扣除基本架构之後,比值是 30 : 4。更重要的是, C++-style 的那几行不但较短,其本质也比较容易被了解。C++-style 和 C-style 两种版本的行数及观念复杂度很难客观量测,但我认为 C++-style 版本有 10 : 1 的优势。
效率 Efficiency
对一个无关痛痒如上述小例子的程式而言,效率算不上是个议题。面对这类程式,简化和(型别)安全才是重点所在。然而,真正的系统往往由一些极重视效率的成份组成。对於这类系统,问题就变成了 "我们能够给予较高阶的抽象性吗?"
考虑这类重视效率的程式,下面是个简单的例子:
读入未知数量的元素
对每个元素做某些动作
做某些涉及所有元素的动作我能够想到的最简单而明确的例子就是,在程式中计算来自输入装置的一系列双精度浮点数的平均值(mean)和中间值( median)。下面是传统的 C-style 解法:
// C-style 解法: #include<stdlib.h> #include<stdio.h> // 一个比较函式,稍後将给 qsort() 使用。 int compare (const void* p, const void* q) { register double p0 = * (double* )p; register double q0 = * (double*)q; if (p0 > q0) return 1; if (pO < qO) return -1; return 0; } void quit() { fprintf(stderr, "memory exhausted/n"); exit(1); } int main(int argc, char*argv[]) { int res = 1000; // 最初的配置量 char* file = argv[2]; double* buf= (double*) malloc(sizeof(double) * res); if (buf == 0) quit(); double median = 0; double mean = 0; int n = 0; FILE* fin = fopen(file, "r"); // 开档做为输入用(reading) double d; while (fscanf(fin, "%lg", &d) == 1) { if(n == res) { res += res; buf = (double*) realloc(buf, sizeof(double) * res); if (buf == 0) quit(); } buf[n++] = d; // 有 rounding errors 的倾向 mean = (n==1) ? d : mean+(d-mean)/n; } qsort(buf, n, sizeof(double), compare); if (n) { int mid=n/2; median = (n%2) ? buf[mid] : (buf[mid-1]+buf[mid])/2; } printf( "number of elements=%d, median=%g, mean=%g/n", n, median, mean); free(buf); }下面是常见的 C++ 解法:
// 使用 C++ 标准程式库的解法: #include <vector> #include <fstream> #include <algorithm> using namespace std; main(int argc, char*argv[]) { char* file = argv[2]; vector<double> buf; double median = 0; double mean = 0; fstream fin(file,ios::in); double d; while (fin >> d) { buf.push_back(d); mean = (buf.size() == 1) ? d : mean+(d-mean)/buf.size(); } sort(buf.begin(),buf.end()); if (buf.size()) { int mid = buf.size() /2; median = (buf.size() % 2) ? buf[mid] : (buf[mid-1] + buf[mid] )/2; } cout << "number of elements = " << buf.size() << ", median = " << median << ", mean = " << mean << '/n'; }这两个程式的大小,不再像前一个例子有那麽悬殊的差异(43 : 24,空行不计)。扣除无法删减的共同元素,例如 main() 的宣告和中间值的计算(共 13 行),两者的行数差异是 20 : 11。关键性的「输入并储存」回圈和排序动作,在 C++-style 程式中都有显着的缩短(「输入并储存」回圈的行数差异是 9 : 4,排序动作的行数差异是 9 : 1)。更重要的是,在 C++ 版本中,每一行所包含的逻辑远远简单得多 ─ 获得正确性的机会当然也就多得多。
再一次,记忆体管理在 C++-style 程式中隐喻实施;当元素以 push_back 加入,vector 便自动成长。C-style 程式则是以 realloc 做记忆体显式管理。出现在 C++-style 程式中的 vector 建构式和 push_back 函式会做掉 C-style 程式中的 malloc, realloc 动作,以及对於「被配置之记忆体大小」的追踪动作。在 C++-style 程式中,我依赖异常处理(exception handling)来记录记忆体的耗尽。在 C-style 程式中,我明白地测试以避免可能的记忆体耗尽问题。
一点也不令人惊讶,C++ 版本比较容易获得正确。我以剪贴的方式从 C-style 版本产生出这个 C++-style 版本。我忘记含入<algorithm>;我留下了 n 而忘了使用 buf.size;此外,我的编译器不支援局域( local)内的 using 指令,迫使我必须把它移到 main 之外。修正了这四个错误之後,程式就可以正确执行了。
对一个初学者而言,qsort 很是诡异。为什麽你必须给予元素个数?(因为阵列不知道它自己有多少个元素)为什麽你必须给予 double 的大小?(因为 qsort 不知道它要排序的单位是 doubles.)为什麽你必须写那个丑陋的、用来比较 doubles 数值的函式?(因为 qsort 需要一个指标指向某个函式,因为它不知道它所要排序的元素型别)为什麽 qsort 所使用的比较函式接受的是 const void* 引数而不是 char* 引数?(因为 qsort 可以对非字串的数值排序)void* 是什麽意思?前面加上 const 又是什麽意思?(唔,稍後我们再来谈这个话题)对初学者解释这些问题,恐怕很难不使他们两眼发直。相较之下解释 sort(v.begin( ), v.end()) 就容易得多:「单纯的 sort(v) 比较简单,但有时候我们想要对容器的某一部份做排序,所以更一般化的方式就是指定排序运作范围」。
为了比较效率,我首先必须决定多少笔输入才能使效率的比较饶富意义。由於 50,000 笔资料也不过是用了此程式半秒钟不到, 因此我选择以 500,000 笔输入和 5,000,000 笔输入来做比较。结果显示於表一。
表一 / 读入、排序、输出 浮点数
最佳化前 最佳化後 C++ C C/C++ 比值 C++ C C/C++ 比值 500,000 笔资料 3.5 6.1 1.74 2.5 5.1 2.04 5,000,000 笔资料 38.4 172.6 4.49 27.4 126.6 4.62 关键数字在於比值。比值大於 1 表示 C++-style 版本比较快。语言、程式库、编程风格之间的比较,众所周知十分棘手,所以请不要根据这些简单的测试就做出彻底的结论。这些比值是不同机器上数次执行结果的平均值。同一个程式的不同执行环境,其间差异低於 1 个百分比。我也执行了我这个 C-style 程式的 ISO C 严格相容版本,一如预期,其间并没有效率上的差异。
我预期 C++-style 程式会稍微快一点点。检验不同的 C++ 编译器实作品後,我发现执行结果有着令人惊讶的变化。某些时候, C-style 版本在小资料量的情况下表现优於 C++- style 版本。然而本例的重点在於,我们可以面对目前已知的技术,提供一个较高阶的抽象性和一个针对错误的较佳保护。我所使用的 C++ 编译器既普遍又便宜 ─ 不是研究室里的玩具。那些宣称可以提供更高效率的编译器,当然也适用本结果。
要找到一些人,愿意在方便性和较佳的错误防范上面付出 3, 10 或甚至 50 的比值,倒也还不罕见。但如果把这些效益放在一起,再加上两倍或四倍的速度,那就非常壮观而吸引人了。这些数字应该是一个 C++ 程式库供应商乐意接受的最小值。为了知道时间花在什麽地方,我又进行了一些额外测试(见表二)。
表二 / 读入浮点数并排序。为了解输入动作所耗费的成本,我加上一个 "generate" 函式,用来产生随机数值。
500,000 笔资料:
最佳化前 最佳化後 C++ C C/C++ 比值 C++ C C/C++ 比值 读入资料 read 2.1 2.8 1.33 2.0 2.8 1.4 产生资料 generate 0.6 0.3 0.5 0.4 0.3 0.75 读入并排序 read & sort 3.5 6.1 1.75 2.5 5.1 2.04 产生并排序 generate & sort 2.0 3.5 1.75 0.9 2.6 2.89 5,000,000 笔资料:
最佳化前 最佳化後 C++ C C/C++ 比值 C++ C C/C++ 比值 读入资料 read 21.5 29.1 1.35 21.3 28.6 1.34 产生资料 generate 7.2 4.1 0.57 5.2 3.6 0.69 读入并排序 read & sort 38.4 172.6 4.49 27.4 126.6 4.62 产生并排序 generate & sort 24.4 147.1 6.03 11.3 100.6 8.9 当然,"read" 仅仅只是读入资料,"read&sort" 仅仅只是读入资料并排序,它们都不会产生任何输出。为了对输入成本获得比较好的感觉,"generate" 用来产生随机数值,而非从输入设备读入资料。
在其他的例子和其他的编译器身上,我料想 C++ stream I/O 会比 stdio 稍稍慢一些。本程式的前一版本使用 cin 而非 file stream,情况的确如此。在某些 C++ 编译器上,档案的 I/O 确实远比 cin 快速得多,其理由至少有一部份是因为 cin 和 cout 之间的系结的拙劣处理。然而,以上数值显示,C++-style I/O 可以像 C-style I/O 一样地有效率。
如果改变这些程式,使它们读入并排序的对象是整数而非浮点数,并不会改变相对效率 ─ 虽然我们可以惊喜地发现,这种改变对 C++-style 程式而言实在非常简单(只需两个改变,C-style 程式需要 12 个改变)。这对於易维护性是一个好兆头。 "generate" 测试所呈现的差异显示出配置所花费的成本。一个 vector 加上 push_back 应该就像一个阵列加上 malloc/free 一样快,但实际却非如此。其原因是难以在最佳化过程中将「什麽事都没做的初值设定列( initializers)」的呼叫动作去除。幸运的是,配置所引发的成本,在输入(造成配置需求)所引发的成本面前,几乎总是显得渺小。至於 sort,一如预期远比 qsort 快得多,主要原因是 sort 内的比较动作是行内展开(inlines),而 qsort 必须呼叫某个函式。
实在很难选择一个例子可以好好说明效率议题。我从同事身上获得的意见是,读入并比较「数值」还不够写实,应该读入「字串」并排序。所以我写了以下程式:
#include<vector> #include<fstream> #include<algorithm> #include<string> using namespace std; int main(int argc, char* argv[]) { char* file = argv[2]; // 输入档的档名 char* ofile = argv[3]; // 输出档的档名 vector<string> buf; fstream fin (file,ios::in); string d; while (getline (fin, d)) buf.push_back (d); sort(buf.begin(), buf.end()); fstream fout (ofile, ios: out); copy(buf.begin(), buf.end(), ostream_iterator<string> (fout, "/n")); }我把它改写为 C 的型式,并设法让字元的读入得以最佳化。C++-style 版本执行得很好 ─ 即使是面对经过手动调整而达到最佳化效果的 C-style 版本(後者消除了字串的拷贝动作)。对於小量输出而言,没有什麽显着差异,但对於大量资料而言,sort 再一次击败了 qsort,因为其较佳的行内展开(inlines),见表三。
表三 / 读入、排序、输出 字串
C++ C C/C++
比值C,去除
字串拷贝动作最佳化後的
C/C++ 比值500,000 笔资料 8.4 9.5 1.13 8.3 0.99 2,000,000 笔资料 37.4 81.3 2.17 76.1 2.03 我采用两百万笔字串,因为我没有足够的主记忆体来容纳五百万个字串而不引起分页置换(paging)。
为了知道时间花费在哪里,我也执行了刻意遗漏 sort 的程式(见表格四)。我所准备的字串相对较短(平均由七个字元构成)。
表四 / 读入并输出字串 ─ 刻意遗漏 sort
C++ C C/C++
比值C,去除
字串拷贝动作最佳化後的
C/C++ 比值500,000 笔资料 2.5 3.0 1.2 2 0.8 2,000,000 笔资料 9.8 12.6 1.29 8.9 0.91 注意,string 是一个很完美的使用者自定型别,而它只不过是标准程式库的一部份而已。如果我们能够因为使用 string 而获得效率和精致,我们也能够因为使用其他使用者自定型别而获得效率和精致。
为什麽我要在编程风格和教学的文章中讨论效率呢?因为,编程风格以及我们所教导的技术,必须为真实世界的问题服务。 C++ 的创造是为了运用於大规模系统以及对效率有严格规范的系统。因此我认为,如果 C++ 的某种教育方式会导致人们所使用的编程风格和技术只在玩具程式中才有效率可言,那是令人无法 同的,那会使人们挫败并因而放弃学习。以上的量测结果显示,如果你的 C++ 风格极为依赖泛型编程(generic programming)和具象型别,以此提供更简单更达到「型别安全(type-safe)」的码,其效率可以和传统的 C 风格一较长短。类似的结果在物件导向(object-oriented)风格中也可获得。
不同的标准程式库实作品的效率表现,有戏剧性的差异,这是一个重要问题。对一个决定大量依赖标准程式库(或广为流传的非标准程式库)的程式员而言,很重要的一点是,你所采用的编程风格应该能够在不同的系统上都有至少可被接受的效率。我很惊骇地发现,我的测试程式在某个系统上,C++ style 和 C style 相比有两倍快,而在另一个系统上却只有一半快。如果系统间的变动因素超过 4,程式员就不该接受。就我所能理解,这种变异性并非由於基本因素而形成,所以不需要程式库实作者过份夸张的努力,就应该可以达到效率的一致性。采用优化程度较佳的程式库,或许是改善对标准 C++ 的认知和实际效率表现的最轻易方式。是的,编译器实作者很努力地消除各个编译器之间的微小效率差异;我估量在效率方面,标准程式库的实作者影响较大。
很明显,上述 C++-style 解法相较於 C-style 解法所带来的编程与逻辑上的简化,可以藉由 C++ 标准程式库而达到。这样的比较是否不够实在或不够公平呢?我不这麽认为。C++ 的一个关键形貌就是,它对程式库的支援能力,精致而且高效。上述简单程式所展现的种种优点,在任何应用领域中都可以保持 ─ 只要其间存在着精致而高效率的程式库。C++ 族群的挑战在於扩充领域,让一般程序员也都享受得到这些利益。也就是说,我们必须针对更多应用领域,设计并实作精致而富有效率的程式库,并让这些程式库被广泛运用。
学习 C++
即使是专业程序员,也不可能一开始就先将整个语言的全貌学习完毕,然後才开始使用它。程式语言应该要分段学习,以小型的练习来试验其种种设施。所以我们总是以分段精通的方式来学习一个语言。真正的问题不在於 "我应该先学习语言的一部份吗?" 而在於 "我应该先学习语言的哪一部份?"
关於这个问题,传统的回答是 "先学习 C++ 中与 C 相容的子集"。但是从我所思考的观点来看,这不是一个好答案。这种学习法会导致过早专注於低阶细节。它也会因为强迫学生过早面对许多技术难点而模糊了编程风格与设计上的议题,进而压抑了许多有趣的东西。本文先前的两个例子已经说明这一点。C++ 拥有较佳的程式库支援,较佳的表示法,较佳的型别检验,无疑地在在对於 "C 优先" 的作法投了一张反对票。然而,注意,我也并不是说要 "纯粹的物件导向编程风格为最优先"。我认为那又是另一种极端。
对於编程初学者而言,学习一个编程语言,应该涵盖具有实际效益的编程技术。对一个编程经验丰富但对 C++ 陌生的程式员而言,其学习应该专注於如何在 C++ 中表现具有实际效益的编程技术,以及对他自己而言崭新的技术。经验丰富的程式员所面临的最大陷阱往往在於企图以 C++ 来表现其他语言的效益。不论对初学者或有经验的程式员而言,重点都应该是观念和技术。了解 C++ 的语法和语意细节,相对於了解 C++ 所支援的设计和编程技术,是次要的。
教学最好是从经过良好挑选的具象实例开始,然後往更一般化更抽象的方向走去。这是孩童的学习方式,也是我们大部份人领悟新观念的方式。语言特性应该总是表现在他们所运用的环境上。否则程式员的焦点便会从产品本身移转到技术的艰涩面。专注於语言技术细节,可能很有趣,但却不是高效益的教育方式。
从另一方面说,仅仅把编程工作视为分析和设计之後的一种劳力行为,也是不对的。搁置实际程式码的讨论,直到每一个高阶议题以及工程主题都已彻底呈现,这种作法对许多人而言将会是一种成本高昂的错误。这种作法会驱使人们远离编程实际工作并导致许多人严重低估产生一个高品质程式的智力挑战。
「设计优先」的极端反面就是,拿起一个 C++ 编译器来就开始写码干活。遭遇一个问题,就点选一下萤幕,看看线上说明提供了什麽帮助。这种作法的问题在於其重心完全倾斜,只着重个别特性和个别设施的了解。泛用性的概念和技术不容易以这种方式学习得到。对於有经验的程式员,这种方式带来的额外问题就是,它会扩大某种倾向,在运用 C++ 语法和程式库所提供的函式时,无可避免地联想先前用过的语言。对於初学者,那会造成许多 if-then-else 码,混合着某些节录自厂商提供的范例的片段。节录并进来的程式码,其原始目的对初学者而言往往朦胧而晦暗,为了达到效果,其所采用的手法亦可能完全超出可理解的范围。即使你才智过人,恐怕也难逃此下场。这种改写而後截用的学习方式,做为一个好课程或一本好教科书的附添物,可能最为有用,但它本身其实很容易导致灾难。
简略地说,我推荐的方式是:
- 先具象,再抽象
- 以语言所支援的编程技术和设计技术,来表现语言的特性。
- 在走向低阶细节之前(那对於建立程式库是有必要的),先仰赖相对高阶的程式库。
- 避免那些无法纳入真实世界的技术。
- 进入细节之前,先认识共通而有用的技术和性质
- 专注於概念和技术(而不是语言本身的性质)
喔不,我并不认为这样的教学方式特别新奇或带有明显的革新。我视它们为一种常识。然而,常识往往在更激昂的讨论中被大家舍弃了,例如讨论是否应该在学习 C++ 之前先学习 C、是否你必须写过 Smalltalk 才能真正了解物件导向编程精神、是否你一定得从一个纯粹 OO 的方式(不管那究竟代表什麽意思)开始学习编程、是否有必要在尝试写任何码之前先对软体开发程序做一个彻底的了解。
幸运的是,C++ 族群已经有了某些经验,他们的学习方式符合我的标准。我最喜欢的方式是,一开始先教导基本的语言概念如变数,宣告,回圈等等,以及一个优良的程式库。这个程式库能够让学生把心力集中在编程身上,而非纷纷乱乱的杂务像是 C-style 字串等等。我推荐使用 C++ 标准程式库或是其中某个子集。这种作法正被美国高中的计算机科学资优班课程所采用 [叁考资料 2]。瞄准经验丰富的程式员而设计的更先进教育方式也已经实证成功,实例请看 [叁考资料 3]。
这些特定教学法的罩门在於,无法早期就提供一个简单的图形程式库,和一个标准程式库图形使用介面。这一点很容易弭补,只要我们有一个很简单的程式库商业化介面就行了。所谓很简单,我意思是在 C++ 课程的第二天,学生就能够上手。不过,目前并没有这样简单而又被广泛使用的图形程式库以及 C++ 程式库图形使用介面。
通过最初的教学(极度倚重程式库)之後,课程可以根据学生的需要和兴趣,往不同的方向进行。某些时候某些场合,即使是 C++ 那些令人头皮发麻的低阶性质也有必要验证之。教导(或学习)指标,转型,配置等等的方法之一,就是验证那些被用来做为学习基础的 classes 究竟如何实作。例如 string, vector 和 list classes 的实作细节,对於探讨「从 C 到 C++ 的语言设施进化」课程而言就是绝佳题材与内容。但这最好不要安排在课程的第一阶段。
像 vector 和 string 这样的类别 classes,用来管理数量不定的资料,必须在其实作码中使用自由空间和指标。在导入那些特性之前,某些并不需要该特性的 classes ─ 例如具象的 Date, Point 和 Complex ─ 也可以被用来做为 class 实作技术的入门。
我倾向於在讨论过容器和其实作技术之後再来介绍抽象的 classes 和 classes 的继承,但这里面有许多方向与选择。主题的实际安排次序应该视程式库的运用而定。例如,一个运用了图形程式库(这种东西必然大量仰赖类别继承)的课程,就需要在较早的时候引入对多型(polymorphism)以及衍生类别(derived classes)的介绍。
最後,请记住,学习和教育 C++(及其相应的设计与编程技术),并没有什麽唯一正确的法门。毕竟,学生的目标与背景大不相同,老师和教科书作者的背景和经验也大不相同。
摘要
我们希望我们的程式容易撰写,执行正确,易於维护,而且其效率表现可被接受。为了达到这些目的,我们必须在较高的抽象层次上进行设计和编程。透过程式库的运用,这样的想法可以达成,无需损失低阶风格所享有的效率。因此,请站在程式库的肩膀上,站在被广泛使用并具有一致性的更多程式库(例如 C++ 标准程式库)肩膀上。程式库愈被更广泛运用,愈可以为 C++ 族群带来更大的利益。
移往更乾净更高阶的编程风格的过程中,教育必须扮演主要角色。C++ 社群不需要那种总使用最低阶语言特性和最低阶程式库设施的程式员 ─ 他们时时把自己误放在效率不足的恐惧之中。C++ 初学者,以及有经验的 C++ 程式员,都必须在历经一些课程训练之後,把标准 C++ 当做一个更新而更高阶的语言,只在绝对必要的时候才将抽象性下降到较低层次。把标准 C++ 拿来当做一个美化後的 C 或美化後的 C with Classes 来耍弄,只是浪费了标准 C++ 所提供的美好机会。
致谢
感谢 Chuck Allison 建议我写一篇如何学习标准 C++ 的文章。感谢 Andrew Koenig 和 Mike Yang 对初稿提供了建设性的意见。我的程式以 Cygnus 的 EGCS 1.1 编译,执行於 Sun Ultrasparc 10。文章中的程式可以从我的网页取得:
http://www.research.att.com/~bs.注释
[注 1] 为了美学的理由,我采用 C++ 风格的符号常数和 C++ 风格的 // 注解。如果要严格服从 ISO C 的规范,应该使用 #define 和 /* */ 注解。
[注 2] 我知道,在这里,C 允许我们不做显式转型动作。然而其所带来的成本是,可以将一个 void* 隐式转换为任意指标型别,而那是不安全的。所以 C++ 要求必须有明白的转型动作。
叁考资料
[1] X3 Secretariat. Standard ─ The C++ Language. ISO/IEC 14882:1998(E). Information Technology Council (NCITS). Washington, DC, USA. (见 http://www.ncits.org/cplusplus.htm).
[2] Susan Horwitz. Addison-Wesley's Review for the Computer Science AP Exam in C++ (Addison-Wesley, 1999). ISBN 0-201-35755-0.
[3] Andrew Koenig and Barbara Moo. "Teaching Standard C++," Parts 1-4, Journal of Object-Oriented Programming, Vol 11 (8,9) 1998 and Vol 12 (1,2) 1999.
[4] Bjarne Stroustrup. The C++ Programming language (Third Edition) (Addison-Wesley, 1997). ISBN 0-201-88954-4.
作者:Bjarne Stroustrup 是 C++ 语言设计者和第一位实作者。他是 The C++ Programming Language 和 The Design and Evolution of C++ 的作者。他的研究兴趣包括分散式系统,作业系统,模拟,设计,以及编程。他是 AT&T 研究员,也是 AT&T 实验室的「大型编程研究」部门领导人。他的活动涉及 C++ 的 ANSI/ISO 标准化。他是 1993 ACM Grace Murray Hopper award 的得主,也是一位 ACM 特别研究员。
译者:陈崴,自由撰稿人,专长 C++/Java/OOP/Genericity。惯以热情的文字表现冰冷的技术,以冷冽的文字表现深层的关怀。