标签 程序员 下的文章

C语言的现状

本文翻译自Dr. Dobb's Journal官网上的一篇由Brian W. Kernighan和Dennis M. Ritchie共同撰写的名为"The State of C"的文章。这里谨将此篇译文献给不久前刚刚离我们而去的C语言之父 – Dennis M. Ritchie,愿一代计算机科学巨匠一路走好。

不再只是为了系统级编程(system programming)

C是一门通用的计算机编程语言,它最初由贝尔实验室(Bell Labs)的Dennis Rithie于1972年左右设计并实现。C的早期发展与其被用于Unix系统的实现密不可分,终端以及Unix系统上运行的大多数程序都是由C开发的。近些年,C在更为广泛的环境中逐渐流行起来,并且它不再依赖任何操作系统或机器了。

C最初被设计成一门用于"系统级编程"的语言,也就是说它被用于编写诸如编译器,操作系统以及文本编辑器之类的程序。不过它也已被证明非常适合其他类型程序的开发,包括数据库系统,电话交换系统,数值分析,工程程序以及大规模的字处理软件。今天,C已经成为世界上使用最为广泛的编程语言之一,并且几乎在每台计算机上我们都能看到C语言编译器的身影。

C的起源

C源于BCPL语言,后者由Martin Richards于1967年左右设计实现。BCPL是一门"无类型"的编程语言:它仅能操作一种数据类型,即机器字(machine word)。也正因为如此,BCPL特别适合面向机器字的硬件。1970年,Ken Thompson为运行在PDP-7上的首个Unix系统设计了一个精简版的BCPL,这个语言被称为B。它也是无类型的。

随着PDP-11的出现,下一版Unix也在PDP-11上实现,一个无类型的语言愈发显得不再适合这一硬件。PDP-11提供了多种不同规格大小的基本对象 – 一字节长的字符,两字节长的整型数以及四字节长的浮点数 – B语言无法处理这些不同规格大小的对象,也没有提供单独的操作符去操作它们。

C语言最初尝试通过向B语言中增加数据类型的想法来处理那些不同类型的数据。和大多数语言一样,在C中,每个对象都有一个类型以及一个值;类型决定了可用于值的操作的含义,以及对象占用的存储空间大小。例如,像int i, j;double d;以及float x这些声明决定了适于变量的操作集以及变量的存储空间需求。在语句d = x + i * j中,编译器使用类型信息确定这个整数乘法适合表达式i * j,但是在其结果与x相加之前,这个结果值必须先转换成一个浮点类型,然后最终的结果在被赋值给d时也必须先转换为双精度浮点类型。

虽然C最初是在一台PDP-II上实现的,但其早在1975年就开始在其他机器上使用了。Steve Johnson实现了一套"可移植编译器",这套编译器修改起来相对容易,并且可以为不同的机器生成代码。从那时起,C就已经在大多数计算机上被实现,从最小的微型计算机到与CRAY-2(译注:一种庞大的超级计算机)规模大小相近的大型机器。C语言很规范,即使没有一份正式的标准,你也可以写出C程序,这些程序无须修改就可以运行在任何支持C语言和最小运行时环境的机器上。

C最初在小型机器上实现,并且继承了一系列小语种编程语言的特点;与功能相比,C的设计者更倾向于简单和优雅。此外,从一开始,C语言就是为系统级编程而设计,程序的运行效率至关重要。因此,C语言与真实机器能力的良好匹配也就不足为奇了。例如,C语言为典型硬件所直接支持的对象:字符,整数(也许有多种大小),以及浮点数字(同样可能有多种大小)提供了相应的基本数据类型。

你可以创建一些更为复杂的对象,诸如数组,结构体等,但是C语言几乎没有提供将这些对象作为一个整体进行操作的操作符;你必须自己编写函数来实现诸如字符串比较,将一个数组赋值给另一个数组等功能。

还有一些不同寻常的是,C语言本身并没有提供输入输出操作。当然,这并不是说C程序无法进行I/O操作,只不过C语言中的IO操作是通过用户定义函数或库中的函数来完成的,而不是通过语言内置的语句。这与作为语言一部分的FORTRAN的READ和WRITE,BASIC的INPUT和PRINT恰恰相反。

C语言本身未提供的功能还包括:它没有存储管理,比如像Pascal语言的new函数那样;它没有提供并行处理的基础设施,比如像Ada的rendezvous机制那样。你可以很容易地用C实现这些功能,不过它们已经通过函数库提供了,并且同样并非语言本身的一部分。在符号记法方面,函数调用要比直接使用操作符显得更加笨拙,例如,BASIC中的字符串比较语句如下:

IF A$ = B$ THEN

使用C语言,你可能会像这样实现这一功能:

if (equal(a, b))…

与内嵌代码相比,函数调用还会带来更多的额外性能开销。

不管怎样,C中省略的特性的程度也是它的显著特点之一。

语言元素

控制流:C语言中的控制流相当传统,但比FORTRAN或BASIC语言提供的更为丰富。C提供了两种决策语句:if … else和switch。在下面语句中

if (expr) statl else stat2

expr被求值;如果求值结果为真(非零),语句stat1会被执行;否则,语句stat2会被执行。整个语句中的else部分是可选的。在下面语句中

switch (expr) {
   case const1: stat1
   case const2: stat2
   …
   default: stat
}

expr被求值,求值结果再与各个case中的常量相比较。如果找到一个匹配的case,那么对应的stat就会被执行。如果没有找到可以匹配的case,default部分对应的stat将会被执行。default部分是可选的。C语言中的switch语句有些类似Pascal中的case语句,只是后者没有default部分。

C同样也提供了三种循环:while,for和do。在下面语句中

while (expr) stat

expr被求值;如果求值结果为真,stat将会被执行,并且expr会被再次求值。当expr求值结果为假时,这个循环结束。语句:

for (stat1; expr; stat3) stat2

等价于下面的while循环:

stat1
while (expr) {
  stat2
  stat3
}

除了结束条件测试的含义不同之外,do语句与Pascal中的repeat…until语句很相似,在下面的语句中:

do stat while(expr)

stat被执行,并且expr被求值和测试。如果求值结果为真,这个循环将重复执行。

break语句执行的结果是从一个封闭的循环或switch语句立即跳出;而continue语句执行结果则是使得一个循环的下一次迭代立即开始。C还提供了goto语句,但这个语句很少使用。

在上述所有例子中,stat可以是一个单一的语句,比如x = 3或是包含在括号内的一组语句,这里提到的括号类似于其他语言中的begin…end。语句以分号结束。

数据类型:C语言提供的基本类型包括char(一个字节);int,short和long(不同长度的整型);以及float和double(两种不同长度的浮点数)。字符和不同的整型数可以是有符号的或者无符号的。

使用数组,结构体,联合体以及指针,你可以将这些对象组合成一个"派生"数据类型的无限集合(原则上),我们常见的数组:

char mesg[100];

定义了一个100个字节的数组mesg,通过mesg[0]到mesg[99]我们可以访问到数组中的每个元素。C没有提供字符串数据类型;而是用结尾字节为0的字符数组代替字符串。这就是编译器生成诸如字符串常量"hello world\n"的方法。在一个字符串中,某些"转义序列",诸如\n,用于表示特定的字符,比如换行符。"hello world\n"这个字符串包含了12个字符以及一个结尾0字节。

结构体是一些不必具有相同数据类型的相关变量的集合(类似Pascal中的record)。例如,

struct object {
   int x, y;   /* position */
   float v;    /* velocity */
   char id[10]; /* identification */
};
struct object obj;

声明了一个名为object的结构体并且定义了一个该结构体类型的变量obj。引用结构体内的个体成员可以通过类似obj.v这样的语句进行。注意,object结构体包含了一个数组类型成员id,我们可以通过obj.id[0]到obj.id[9]来访问该数组成员中的各个元素。你也可以定义结构体数组。

C语言提供了指针,或叫作机器地址作为这门语言本身不可或缺的一部分。指针的形式没有Pascal和Ada中约束地那么严格。下面的语句

char *pc;
struct object *pobj;

声明了一个指向字符的指针pc,以及一个指向object结构体的指针pobj。通过声明语句中使用的形式*pc或*pobj,我们可以访问到指针指向的数据的值;这个"解引用"操作符*等价于Pascal中的脱字符号(^)。结构体中的单独成员可以通过,例如pobj->v的形式访问。

如果p是一个指向T类型对象的指针,并且当前指向一个T类型数组中的一个元素,那么p+1则是指向该数组下一个元素的指针。同样,如果p和q是指向同一数组中元素的两个指针,并且p小于q,那么q-p则为p到q之间元素的个数。总之,指针的算术操作会按照指针所指向的对象的大小进行缩放;对象的实际大小通常在你编写程序时是不相关的。当与对象的实际大小相关时,C提供了sizeof操作符用于计算对象的大小,这样程序本身就无须为特定机器显式指定对象的大小了。C所整合的完整的指针和地址计算是这门语言的一个优势。

操作符与表达式:与多数传统编程语言相比,C语言拥有一套丰富的操作符。除了普通算术操作符+,-,*,/和%(取余)之外,其他几组操作符也值得给予特殊的关注。

首先,C提供了用于操作一个字内部的比特位的操作符(见表1)。

&       bitwise AND(按位与)
|       bitwise OR(按位或)
^       bitwise exclusive-OR(按位异或)
~       one's complement(按位反)
<<      left shift(左移)
>>      right shift(右移)
表1: 操作字内部比特位的C操作符; 对于许多系统级编程的程序来说,这些操作符十分必要。

例如,列表1中的函数计算其参数中值为1的比特位的个数,它通过重复测试参数最右侧比特位的值,并每次把参数右移一位,直到参数值为0为止。声明中的unsigned意为函数将n视为逻辑数量,而不是一个算术变量。

bitcount(n)        /* count 1 bits in n */
   unsigned int n;
{
   int b;
   for (b = 0; n != 0; n >>= 1)
   if (n & 1)
      ++b;
   return b;
}
列表1:bitcount函数计算其参数中值为1的比特位的个数,它通过重复测试参数最右侧比特位的值,并每次把参数右移一位,直到参数值为0为止。

函数bitcount阐释了第二组操作符。任何类似>>这样的接受两个操作数的操作符都有一个对应的"赋值操作符",比如>>=,因此下面的语句

v = v >> expr

可以被简化为:

v >>= expr

这个符号更易读,特别是当v是一个复杂的表达式而不是一个单字母的变量时。

第三组操作符用于处理逻辑条件。操作符&&和||自左向右求值,表达式的值一经得到,求值过程立即停止。在类似下面的结构中

if (i 0)…

如果i大于或等于N(假定N是数组x的大小),那么包含x[i]的值测试将不会执行。逻辑操作符的这种行为被称为”短路求值“。

函数:一个C程序的整体结构是一组变量和函数的声明和定义。如果程序规模较大,这些定义常常被放到独立的文件中;你可以单独编译它们,并且使用链接器将它们链接在一起。

在一个函数内部,变量通常是"自动的"- 也就是说,它们在程序执行进入函数时出现,在离开函数后消失,就像在bitcount函数中那样。不过,如果你将一个变量声明为static,那么这个变量的值将从一次函数调用保留到下一次函数调用。在任何函数外面声明的变量是全局的,在程序的任何位置都可以访问到它们。

函数是支持递归调用的;标准(并且有些老套)的例子是阶乘函数(见列表2)。

fact (n)      /* returns n! (n >= 0) */
   int n;
{
   if (n <= 0)
      return 1;
   else
   return n * fact(n-1);
}
列表2: 递归函数的经典例子 – 用C实现的阶乘函数。

C语言使用传值的方式将参数传递给函数,即函数收到的是一份参数的拷贝,而不是原来的数据对象。(注意,函数bitcount修改了它的参数变量,不过这是安全的,因为它实际上是一个参数的拷贝。)通过传递指向数据对象的指针,你也可以获得与传引用一样的效果。函数的参数和返回值可以是任何基本数据类型 – 指针,结构体,或者联合体。如果要传递数组给函数,你需要传递一个指向这个数组第一个元素的指针。

ANSI标准

很多年来,C语言的定义就是《C程序设计语言》第一版中的参考手册。1983年,ANSI(美国国家标准协会)成立了一个委员会以提供一版最新的,全面的C语言定义。其结果是,C语言的ANSI标准,或叫作ANSI C,预计将在1988年年底得到批准。最新的编译器已经提供了对这一标准中绝大部分特性的支持。

自从1978年以来,这门语言变化甚少;这版标准的目标之一就是确保目前大多数现存的程序依旧是有效的,或者失败,但编译器可以产生有关新行为的警告。

基本上,有关C语言的最重要的改变就是一种声明和定义函数的新语法。现在一个函数的声明可以包含有关函数参数的描述了;定义语法也为了匹配参数而作出了改变。额外的信息让编译器更加容易地检测到因为参数不匹配而导致的错误;根据我们的经验,这是一个非常有用的补充。

为了说明这一点,考虑下面这个典型的C代码片断:

int n;
double x, sqrt();
x = sqrt(n);

函数sqrt期望一个double类型的参数,但是n却是一个整型。这个错误无法被检测出来,并且这个执行的结果也肯定没有任何意义。而采用ANSI C的新函数原型语法,你可以用如下方式重新编写这段代码:

int n;
double x, sqrt(double);
x = sqrt(n);

这里编译器已经被告知函数sqrt期望的参数类型,所以编译器生成代码将整型数n转换为浮点数。如果你不小心编写了一个无法被转换为double类型的表达式,比如 x = sqrt(&n),编译器将捕捉到这个错误。

函数定义的语法为了匹配参数而作出了改变;形式参数列在函数名字后面的括号内。因此,函数bitcount的定义就变成了:

bitcount(unsigned int n)
{
 …
}

还有一些其他小规模的语言变化。结构体赋值,枚举,以及void数据类型,所有那些被广泛使用的特性,现在都正式成为了语言的一部分。你可以进行自动结构体和数组的初始化,你也可以作单精度浮点运算;这些在小机器上会获得更好的计算性能。

标准更加详细地阐述了算术转换的属性。并且支持十六进制常量,转义序列以及八进制常量。用于做文本宏替换的C预编译器也变得愈加精致了;它为宏生成过程提供了更多的控制。这里的大多数改变只是对你的编程有轻微的影响。

这个标准的第二个显著的贡献就是C标准库的定义。它详细说明了访问操作系统的函数(比如,读写文件),格式化输入输出(scanf和printf),内存分配(malloc),字符串操作(比如,strcmp),数学计算(比如sin和lag),等等诸如此类的函数。

一些被包含在用户编写的程序中的标准头文件为函数和数据类型声明提供了统一的访问。使用这个标准库与主机系统交互的程序可以确保其行为是兼容的。库中的大多数函数都是仔细地仿照了Unix系统的"标准I/O库",并且在其他系统上也具备相似的程序。同样,你不会看到太大的变化。

由于大多数计算机都直接支持C提供的数据类型以及控制结构,因此实现一个自包含程序所需要的运行时库是极其微小的。标准库函数只是被显式调用,所以如果你不需要,你可以避开它们。大多数标准库函数都是用C实现的,并且除了隐藏的操作系统细节外,库函数自身是可移植的。

C的评价

C是一门紧凑,高效且极富表现力的语言。事实上,C的确足够优秀,以至于它在很多系统上几乎完全取代了汇编语言编程。一个简洁,可读性强的高级语言的使用具有压倒性的优势;它仅仅使阅读程序变成可能,这在使用其他一些语言时是极其困难的。

C是一门相对"低级"的语言。这种定性是没有贬义的;只是说C语言与大多计算机一样处理着相同类型的对象,即字符,数字,以及地址。这些类型的数据可以由真实计算机实现的算术和逻辑操作符结合和移动。

由于C语言相对小巧,因此我们可以用较小的篇幅描述这门语言,并且快速的学习它。你可以合理地期待去认识,理解,并且适当地使用整门语言。

C语言的另外一个优点是其可移植性。虽然C语言与很多计算机的能力相匹配,但其实现是与任何特定的机器体系结构无关的。稍微谨慎一些,你就可以很容易地编写出无须修改即可运行在多种机器上的可移植的程序。C标准显式地明确了可移植性问题,并且规定了一组常量,用于描述运行程序的机器的特性。

C的另外一个优势在于其缺乏约束。编程语言的一个流行的趋势是"强类型",其大致含义是由语言进行细致的检查,并保证程序只包含合法的数据类型组合。强类型有些时候可以尽早地捕捉到bug,不过它也意味着一些程序无法被编写出来,因为它们本质上违反了类型组合的规则。

一个存储分配器就是一个很好的例子:你无法用Pascal编写出Pascal的new函数 – 返回指向一块存储的指针,因为在Pascal中没有办法定义一个返回任意类型的函数。不过使用C语言你就可以轻松且安全地实现这个函数,因为C语言允许你阐明对类型规则的特定违背是有意为之的。

C不是一门强类型语言,不过随着它的演化,它的类型检查已经得到了加强。最初的C定义不赞成再使用,但仍然被允许。指针和整数的互换,这早已被淘汰,而现在的标准需要适当的声明以及显式的转换,这也是一些优秀编译器已经强制要求的了。new函数的声明则是在这一方向上迈出的另外一步。编译器会给出大多数类型错误的警告,并且不会对不兼容的数据类型进行自动转换。尽管如此,C保留了一条其基本的设计哲学,即程序员知道他们在做些什么;它只是需要你显式地阐述你的意图即可。

C已经被证明是一种优秀语言,甚至其他语言也可以编译为C语言。一个最好的例子就是Yacc编译器,它可以将一门语言的语法规范转换为一个用于解释这门语言语句的C程序。自然而然,C语言本身也可以用这种方法实现。

C语言出现什么问题了?在低级别上,存在一些关于操作符优先级的低劣的选择。一些用户感觉switch语句应该做出一些变化,不要让控制流像现在那样从一个case走到下一个case。简洁的语法有时会让新手畏缩;复杂的声明时常难于阅读。《C程序设计语言》第二版中的一个新例子就是这样的一对程序:它们在C声明与自然语言单词之间进行相互转换。

如果你依赖未定义属性或实现定义的属性时,可移植性问题有时就会发生。例如,函数参数的求值次序是未指定的,因此你编写出来的依赖求值次序的代码在不同机器上很可能得到不同的执行结果。这不是一个严重的问题,因为很容易检测到依赖关系。但人们有时仍然忽视它,导致产生不幸的影响。

接下来是什么?

在过去的十年中,C语言不断演化,虽然变化的速率很缓慢。ANSI标准正式地接受了这些改变,并且也加入了一些其自身的特性。由编译器进行的错误检查的数量一直在稳步上升:虽然语言中对你的所作所为的约束依旧不多,但现在当你做出一些奇怪的事情前,你需要更多显式地确认你的操作。

在接下来的若干年,C语言可能走向何方?最有可能的演化就是继续目前这种缓慢但稳定的改进,谨慎地添加一些新的特性。谨慎是必要的,因为与目前已经存在的庞大数量的C代码保持兼容是极其重要的。我们不能无缘无故地做出改变。

实事求是地说,C语言本身不可能进行较大程度的改变了;反而一些新语言将源自于C语言。C++就是一个例子,它提供了数据抽象以及面向对象编程的设施,而且几乎完整地保留了与C的兼容性(参见"更好的C?")。与此同时,随着你使用C语言经验的增加,C本身依然经久耐用。伴随着15年的C语言使用经验,我们仍然有这样的感觉。

Observer模式的C实现

设计模式 (Design Pattern,以下简称DP)的定义有很多种。我个人的理解:DP是人们在软件开发过程中所总结出来的一些典型问题的经验解决方法模板。使用它们可以使我们的代码更易被复用,更易扩展,更好地适应变化以及更便于后期维护。

人们都说设计模式是独立于语言的,但这里的"语言"更多的是指面向对象语言,比如Java、C++、C#、Python和Ruby等。使用面向对象语言(OO)在实现设计模式时更为自然而然。GoF的经典书籍《Design Patterns》 的副标题就是"Elements of Reusable Object-Oriented Software",显然DP主要针对面向对象的软件开发,书中的内容也主要是用C++表述的。

相比于OO语言,关于C语言 等面向过程的语言与设计模式结合使用的资料和例子都甚少,它们就像是走在两条互相平行的马路上的路人,老死不相往来。难道我们真地找不到C与设计模式的交集吗,非也!设计模式强调高内聚,低耦合 ,一切面向接口!这种思想其实也有助于C程序员写出更加模块化、更加灵活以及更为优美的代码来。只是用C语言实现后所展现出来的形式与支持继承、多态的OO语言相比可能不是那么自然。

我相信在现实使用C语言的开发过程中,有些C程序员已经在不知不觉中使用了模式的思想,但DP对于多数C程序员还是略显生分的,虽然DP已经诞生10多年了,也许这与C语言诞生在设计模式之前不无关系^_^。

如何在日常开发中融入模式的思想呢?GoF在《Design Patterns》一书的第一章就告诉了我们,大致是一切从问题出发:弄清楚你遇到的问题,浏览模式,找到适合解决你的问题的模式。

言归正传。我们的系统常常有这样的业务情景:某种数据对象发生变化,与其相关的其他数据对象也需要一并做出改变。为了便于理解,这里举一个大家都比较易懂的例子:Tony是一个中国移动全球通(GoTone)用户,他订购了中国移动提供的139邮箱和手机报业务。中国移动有一个系统用于管理全球通用户信息、各种移动业务以及全球通用户的各种业务订购关系。Tony这个人记性不大好,每天丢三落四,同时也经常忘记及时缴纳手机话费,导致系统中Tony这个用户的状态经常在"正常"和"停机"间变来变去。为了防止Tony在停机的状态下依旧可以使用邮箱和手机报业务,移动公司提出了一个新需求:当用户处于"停机"状态时,用户应该无法使用139邮箱和手机报业务。如果你是负责开发这个需求的程序员,你如何在系统中满足这个需求呢?

将客户提出的需求翻译为程序员的行话就是当Tony的用户信息由"正常"变为"停机"时,系统需要同时修改139邮箱订购关系数据和手机报订购关系数据,将两类数据记录中Tony的订购关系由"开通"变为"暂停";当Tony的用户信息由"停机"变为"正常"时,系统则需要将两类数据记录中Tony的订购关系由"暂停"改为"开通"。总之,当全球通用户信息发生变化时,139邮箱订购关系与手机报订购关系数据就需要随着进行变更。

从例子中已有的描述可以看出,现有的系统内部至少有三套数据集以及相应的数据操作接口,分别是全球通用户数据、139邮箱订购关系数据和手机报订购关系数据,这也是最基本的数据封装与抽象。现在我们就在此基础上来满足新的需求。

很多人首先想到的是修改全球通用户数据操作接口,实现两类订购关系的随动变更。
void update_gotone_customer_state(const char *number, int state) {
    /* 根据number查找出对应的用户信息,并更新其state */
    … …

    /* 新增如下操作 */
    update_mailbox_order(number, state);
    update_newspaper_order(number, state);
}

我们可以看出这种方法是在全球通用户数据操作接口中直接调用两种订购关系的数据操作接口来修改数据状态,这种方法显然是耦合最高的方法,它在本无耦合的数据对象之间建立了耦合。update_mailbox_order和update_newspaper_order的变化将直接导致update_gotone_customer_state接口的变化。我们也无法独立地对update_gotone_customer_state接口进行单元测试了,除非对新增的两个依赖接口进行mock — 显然这种mock是被动的,也是不合理的。

为了去除这一方法引入的耦合,我们可以引入一个新函数来作为用户状态变更时的处理函数,如下:
void gotone_customer_state_switch(const char *number, int state) {
    update_gotone_customer_state(number, state);
    update_mailbox_order(number, state);
    update_newspaper_order(number, state);
}

这种方法依次调用三个数据集各自的操作接口更新用户状态。显然这种方法去除了数据集操作接口之间的耦合,但也存在着另外一个更严重的问题,那就是这种方法难以适应移动业务日新月异的变化。

假定此时用户Tony又订购了一个移动业务 -手机电视,那么如何让手机电视订购关系感知到用户状态的变化呢?你可能会这样来改。
void gotone_customer_state_switch(const char *number, int state) {
    update_gotone_customer_state(number, state);
    update_mailbox_order(number, state);
    update_newspaper_order(number, state);
    update_mobiletv_order(number, state);
}

你知道这样的修改意味着什么吗?意味着程序要重新编译,重新发布以及重新部署上线。一个用户新订购了一个业务就带来如此大的变动,这是不能忍受的,不是吗!

关于这类问题,DP给出了一个解决模式,即Observer模式,中文叫作观察者模式。观察者模式中有两个主要元素:主题(Subject)和观察者(Observer),主题的变更引发诸多相应观察者的随动更新。在这个例子中,全球通用户数据显然是一个Subject,而139邮箱、手机报以及手机电视等业务订购数据则是Observer的角色。

全球通用户数据是一个Subject,但是却是一个具体的Subject,如果让其与139邮箱等具体业务Observer直接关联,势必会像最初的解决方法那样在不同数据对象间引入耦合。DP始终在告诉我们要面向接口,要依赖抽象,所以我们需要建立抽象的Subject和Observer接口来。在C语言中没有interface关键字,也没有abstract class(抽象类),C语言只有struct和函数指针。将struct和函数指针结合,我们就有了C语言接口的概念:

/* isubject.h */
struct iobserver_t;

struct isubject_t {
    void (*attach)(struct isubject_t *subject, struct iobserver_t *observer);
    void (*detach)(struct isubject_t *subject, struct iobserver_t *observer);
    void (*notify)(struct isubject_t *subject, void *arg);
};

/* iobserver.h */
struct iobserver_t {
    void (*update)(void *arg);
};

isubject_t声明了三个函数指针字段,attach用于增加监视这个Subject的Observer的;detach用于卸载监视这个Subject的某个Obsever;而notify则是在Subject发生变化时用于通知各个Observer进行更新的。iobserver_t相对比较简单,只有一个update函数指针字段,该字段指向的函数在Subject发生变化时被调用,完成Observer自身的更新。

isubject_t只是一个抽象的接口,我们还需要isubject_t的一个具体实现,包括attach, detach以及notify等函数的具体实现算法。下面是isubject_t的一个具体实现isubject_imp_t的相关数据类型与操作接口:

/* isubject_imp.h */
struct isubject_t* isubject_imp_new();
void isubject_imp_free(struct isubject_t **subject);

/* isubject_imp.c */
typedef struct _iobserver_t _iobserver_t;

/* 这里用apache apr库中的apr_ring作为存储observers的数据结构 */
typedef APR_RING_HEAD(_iobserver_head_t, _iobserver_t) _iobserver_head_t;

struct isubject_imp_t {
    struct isubject_t subject; /* 这里务必将subject放在第一个字段的位置 */
    _iobserver_head_t observers;
};

struct isubject_t* isubject_imp_new() {
    struct isubject_imp_t *p = NULL;

    p = (struct isubject_imp_t*)malloc(sizeof(*p));
    if (!p) return NULL;

    memset(p, 0, sizeof(*p));
    APR_RING_INIT(&(p->observers), _iobserver_t, link);
    p->subject.attach = isubject_imp_attach;
    p->subject.detach = isubject_imp_detach;
    p->subject.notify = isubject_imp_notify;

    return (struct isubject_t*)p;
}

static void isubject_imp_attach(struct isubject_t *subject, struct iobserver_t *observer) {
    struct isubject_imp_t *p = (struct isubject_imp_t *)subject;

    //将observer插入ring,这里代码省略
}

static void isubject_imp_detach(struct isubject_t *subject, struct iobserver_t *observer) {
    struct isubject_imp_t *p = (struct isubject_imp_t *)subject;
   
    //将observer从ring中移出,这里代码省略
}
   
static void isubject_imp_notify(struct isubject_t *subject, void *arg) {
    struct isubject_imp_t *imp = (struct isubject_imp_t *)subject;

    //遍历ring,调用每个observer的update接口,并传入参数arg,这里代码省略
}

注意以上操作都是面向isubject_t这个接口类型的,而不是isubject_imp_t这个具体类型的,isubject_imp_t对于外部是不可见的。下面我们尝试用Observer模式解决一下上面例子中的问题。

首先建立tony这个用户的subject,并初始化其当前的业务Observer:
struct isubject_t *tony_subj = isubject_imp_new();
struct iobserver_t mailbox_observer = {
        .update = update_mailbox_order
};
struct iobserver_t newspaper_observer = {
        .update = update_newspaper_order
};

tony_subj->attach(tony_subj, &mailbox_observer);
tony_subj->attach(tony_subj, &newspaper_observer);

恰巧Tony上个月又忘记缴纳话费了,月初Tony的手机被停机了。中国移动的系统进行了Tony这个账户的状态变更操作:
tony_subj->notify(tony_subj, tony_account_info); /* tony_account_info表示Tony的基本账户信息 */

这行代码的执行结果是update_mailbox_order和upudate_newspaper_order被调用,139信箱以及手机报订购关系中有关Tony的订购信息状态变为"暂停"。也许上面的代码还没有让你看到Observer模式的好处,Ok,让我们进行一些变化!

移动为了推广3G业务,允许用户免费试用3个月的手机电视业务。有便宜不能不占,Tony遂订购了手机电视业务。前面的两种方法对于此种情况都无能为例,但采用了Observer模式之后,对于手机电视的订购,系统只需要在相应的处理函数中执行:
struct iobserver_t mobiletv_observer = {
        .update = update_mobiletv_order
};
tony_subj->attach(tony_subj, &mobiletv_observer);

这样一来我们无需修改Tony被停机时的处理代码,手机电视订购关系也可以感知到Tony的账户变更。

同样,Tony使用了移动的手机上网套餐,打开手机浏览器,天下信息便可一览无余。于是Tony决定退订有些鸡肋的手机报业务,对于这一退订事件,系统只需要在相应的处理函数中执行:
tony_subj->detach(tony_subj, &newspaper_observer);

可以看到Tony停机的处理无需因手机报退订而做出改变,同时Tony停机也不会导致手机报订购关系数据的变更,这正是我们期望的。

以上是Observer模式的一种C语言实现,同时也解决了我们遇到的实际问题。关于Observer模式的详细描述还是参见GoF的《Design Patterns》一书吧。用过程式语言实现DP的确不那么自然,但这就是C语言的方式。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats