技术交流
周立功教授数年之心血之作《程序设计与数据结构》,电子版已无偿性分享到电子工程师与高校群体。书本内容公开后,在电子行业掀起一片学习热潮。经周立功教授授权,特对本书内容进行连载,愿共勉之。
第一章为程序设计基础,本文为1.5.2/1.5.3共性与可变性分析:建立抽象和建立接口。
>>>> 1.5.2 建立抽象
抽象化的目的是使调用者无需知道模块的内部细节,只需要知道模块或函数的名字,因此将其称为黑盒化。调用者只需要知道黑盒子的输入和输出,而过程的细节是隐藏的。由于建立了一个由黑盒子组成的系统,因此复杂的结构就被黑盒子隐藏起来了,则理解系统的整体结构就变得更容易了。
从概念的视角来看,建立抽象关注的不是如何实现,而是函数要做什么,过早地关注实现细节,将实现细节隐藏起来,进而帮助我们构建更易于修改的软件。因此,我们首先应该选择一个具有描述性的符合需求的名字,虽然可以选择的名字有swapByte、swapWord和swap,但swap更简洁更贴切。其次,可以用一句话概念性地描述swap的数据抽象——swap是实现两个数据交换的函数。
显然,调用者仅需一般性地在概念层次上与实现者交流,因为调用者的意图是如何使用swap()实现两个数据的交换,所以无需准确地知道实现的细节。而具体如何完成数据的交换,这是在实现层次进行的。由此可见,将模块的目的与实现分离的抽象揭示了问题的本质,并没有提供解决方案。只说明需要做什么,并不会指出如何实现某个模块。只要概念不变,调用者与实现细节的变化就彻底隔离了。当某个模块完成编码后,只要说明该模块的目的和参数就可以使用它,无需知道具体的实现。
函数抽象对团队项目非常重要,因为在团队中必须使用其他成员编写的模块。比如,编程语言本身自带的库函数,由于已经被预编译,因此无法访问它的源代码。同时库函数不一定是用C编写的,因此只要知道其调用规范,就可以在程序中毫无顾忌地使用这个函数。实际上,在使用scanf()函数的过程中,我们考虑过scanf()是如何实现的吗?无关紧要。尽管不同系统实现scanf()的方法可能不一样,但其中的不同对于程序员来说是透明的。
>>>> 1.5.3 建立接口
接口是由公开访问的方法和数据组成的,接口描述了与模块交互的唯一途径。最小化的接口只包含对于接口的任务非常重要的参数,最小化的接口便于学习如何与之交互,且只需要理解少量的参数,同时易于扩展和维护,因此设计良好的接口是一项重要的技能。
>>> 1. 函数调用
(1)传值调用
如何调用swap()函数呢?实参将值从主调函数传递给被调函数,也许其调用形式是下面这样的:
swap(a, b);
从黑盒视角来看,形参和其它局部变量都是函数私有的,声明在不同函数中的同名变量是完全不同的变量,而且函数无法直接访问其它函数中的变量,这种限制访问保护了数据的完整性,黑盒发生了什么对主调函数是不可见的。
一个变量的有效范围称作它的作用域,变量的作用域指可以通过变量名称引用变量的区域,在函数内部声明的变量只在该函数内部有效。当主调函数调用子函数时,主函数内声明的变量在子函数内无效,子函数内声明的变量也只在该子函数内部有效。
由于传递给函数的是变量的替身,因此改变函数参数对原始变量没有影响。当变量传递给函数时,变量的值被复制给函数参数。由此可见,通过“传值调用”方式交换a、b的值,无法改变主调函数相应变量的值。
(2)传址调用
如果希望通过被调函数将更多的值传回主调函数而改变主调函数中的变量,则使用“传址调用”——将&a、&b作为实参传递给形参。其调用形式如下:
swap(&a, &b);
利用指针作为函数参数传递数据的本质,就是在主调函数和被调函数中,通过不同的指针指向同一内存地址访问相同的内存区域,即它们背后共享相同的内存,从而实现数据的传递和交换。
>>> 2. 函数原型
函数原型是C语言的一个强有力的工具,它让编译器捕获在使用函数时可能出现的许多错误或疏漏。如果编译器没有发现这些问题,就很难察觉出来。函数原型包括函数返回值的类型、函数名和形参列表(参数的数量和每个参数的类型),有了这些信息,编译器就可以检查函数调用与函数原型是否匹配?比如,参数的数量是否正确?参数的类型是否匹配?如果类型不匹配,编译器会将实参的类型转换成形参的类型。
(1)函数形参
通过程序清单 1.15可以看出,其相同的处理部分是2个int类值的交换代码,因此可以将数据交换代码移到swap()函数的实现中,其可变的数据由外部传进来的参数应对。由于&a是指向int类型变量a的指针,&b是指向int类型变量b的指针,因此必须将p1、p2形参声明为指向int *类型的指针变量,即必须将存储int类型值变量的地址作为实参赋给指针形参,实参与形参才能匹配。其函数原型进化如下:
swap(int *p1, int *p2);
(2)返回值的类型
声明函数时必须声明函数的类型,带返回值的函数类型应该与其返回值类型相同,而没有返回值的函数应该声明为void。类型声明是函数定义的一部分,函数类型指的是返回值的类型,不是函数参数的类型。
虽然可以使用return返回值,但return只能返回一个值给主调函数。比如,如果返回值为整数,则函数返回值的类型为int。当返回值为int类型时,如果返回值为负数,则表示失败;如果返回值为非负数,则表示成功。当返回值为bool类型时,如果返回值为false,则表示失败,如果返回值为true,则表示成功。当返回值为指针类型时,如果返回值为NULL,则表示失败,否则返回一个有效的指针。
如果利用指针作为参数传递给函数,不仅可以向函数传入数据,而且还可以从函数返回多个值。因为函数的调用者和函数都可以使用指向同一内存地址的指针,即使用同一块内存,所以使用指针作为函数参数时就是对同一数据进行读写操作。这样不仅可以传入数据,还可以通过在函数内部修改这些数据,将函数的结果传出给调用者。
当函数的实参是指针变量时,有时希望函数能通过指针指向别处的方式改变此变量,则需要使用指向指针的指针作为形参。
由于swap()无返回值,因此swap()返回值的类型为void,其函数原型如下:
void swap(int *p1, int *p2);
其被解释为swap是返回void的函数(参数是int *p1,int *p2)。
这是一个不断迭代优化的过程,用户只需要知道“函数名、传入函数的参数和函数返回值的类型”,就知道如何有效地调用相应的函数。
>>> 3. 依赖倒置原则
在面向过程编程中,通常的做法是高层模块调用低层模块,其目的之一就是要定义子程序层次结构。当高层模块依赖于低层模块时,对低层模块的改动会直接影响高层模块,从而迫使它们依次做出修改。如果高层模块独立于低层模块,则高层模块更容易重用,这就是分层架构设计的核心原则,即依赖倒置原则(Dependence Inversion Principle,DIP):
● 高层模块不应该依赖低层模块,两者都应该依赖于抽象接口;
● 抽象接口不应该依赖于细节,细节应该依赖抽象接口。
当在分层架构中使用依赖倒置原则时,将会发现“不再存在分层”的概念了。无论是高层还是低层,它们都依赖于抽象接口,好像将整个分层架构推平一样。
其实从“Hello World”程序开始,我们就已经在使用stdio.h包含的“抽象接口”了,即以后凡是用#include文件的扩展名叫.h(头文件)。如果源代码中要用到stdio标准输入输出函数时,那么就要包含这个头文件,比如,“scanf("%d",&i);”函数,其目的是告诉编译器要使用stdio库。库是一种工具的集合,这些工具是由其它程序员编写的,用于实现特定的功能。尽管实现者无需关心用户将如何使用库,且不会直接开放源代码给用户使用,但必须给用户提供调用函数所需要的信息。显然只要将头文件开放给用户,即可让用户了解接口的所有细节,详见程序清单 1.16。
程序清单 1.16 swap数据交换接口(swap.h)
1 #ifndef _SWAP_H
2 #define _SWAP_H
3 // 前置条件:实参必须是int类型变量的地址
4 // 后置条件:p1、p2作为输出参数,改变主调函数中相应的变量
5 void swap(int *p1, int *p2);
6 // 调用形式:swap(&a, &b)
7 #endif
其中,每个头文件都指出了一个用户可见的外部函数接口,主要包括函数名、所需的参数、参数的类型和返回结果的类型。其中,swap是库的名字,程序清单 1.16(1~2)与(8)是帮助编译器记录它所读取的接口,当写一个接口时,必须包含#ifndef、#define和#ednif。#include行部分仅当接口本身需要其它库时才使用,它由标准的#include行组成。程序清单 1.16(6)接口项表示库输出的函数的原型、常量和类型等。不管你是否理解,这些行是接口的模板文件,这就是信息隐藏。
>>> 4. 前/后置条件
处理信息隐藏还涉及到另一个技术,那就是使用前置条件和后置条件描述函数的行为。在编写一个完整的函数定义时,需要描述该函数是如何执行计算的。但在使用函数时,只需考虑该函数能做什么,无需知道是如何完成的。当不知道函数是如何实现时,就是在使用一种名为过程抽象的信息隐藏形式,它抽象掉的是函数如何工作的细节。计算机科学家使用“过程”表示任意指令集,因此使用术语过程抽象。过程抽象是一种强大的工具,使得我们一次只考虑一个而不是所有的函数,从而使问题求解简单化。
为了使描述更准确,则需要遵循固定的格式,它包含两部分信息:函数的前置条件和后置条件。前置条件就是调用该函数必须成立的条件,当函数被调用时,该语句给出要求为真的条件。除非前置条件为真,否则无法保证函数能正确执行。在调用swap()函数时,实参必须是int类型变量的地址,这是调用者的职责。通常在函数开始处检查是否满足?如果不满足,说明调用代码有问题,抛出一个异常。
后置条件就是该操作完成后必须成立的条件,当函数调用时,如果函数是正确的,而且前置条件为真,那么该函数调用将可以执行完成。当函数调用完成后,后置条件为真。如果不满足后置条件,则说明业务逻辑有问题。
当满足调用swap()函数的前置条件时,必须同时确保其结束时满足它的后置条件,其后置条件是被调函数将返回值传回主调函数,改变主调函数中变量的值。
前后置条件不只是概括地描述函数的行为,声明这些条件应该是设计任何函数的第一步。在开始考虑某个函数的算法和代码之前,应该写出该函数的原型,其中包括函数的返回类型、名称和参数列表,最后紧跟一个分号。直接来自于用户的输入不能作为前置条件,通常前/后置条件都可以转化为assert语句。编写函数原型时,应该以注释的形式描述该函数的前置条件和后置条件。
事实上,前置条件和后置条件在使用函数的程序员和编写函数的程序员之间形成了一个契约,也就是为什么需要这个函数?接口通过前置条件和后置条件以契约的形式表达需求,承诺在满足前置条件时开始,按照程序的流程运行,系统就能到达后置条件。
虽然注释是一种很好的沟通形式,但在代码可以传递意图的地方不要写注释。因为代码解释做了什么,再注释也没有什么用处,相反注释要说明为什么会这样写代码?
>>> 5. 开闭原则
接口仅需指明用户调用程序可能调用的标识符,应尽可能地将算法以及一些与具体的实现细节无关的信息隐藏起来,这样用户在调用程序时也就不必依赖特定的实现细节了。当接口一旦发布后,也就不能改变了,因为改变接口势必引起用户程序的改变。如果此前定义的接口满足不了需求,怎么办?只能扩展新的接口,但不能修改或废除原有的接口,这就是“对修改关闭,对扩展开放”的开闭原则(Open-Closed Princple,OCP)。显然,依赖倒置原则更加精确的定义就是面向接口的编程,它是实现开闭原则的重要途径。如果DIP依赖倒置原则没有实现,就别想实现对扩展开放,对修改关闭。