了解c语言不完整类型与封装的概念。
使程序设计更加高内聚、低耦合
减少对结构体成员的直接访问,防止用户随意破坏模块内部的抽象数据类型。
本文档适用于C语言开发的人员。
介绍
封装(Encapsulation)是一个在现代程序设计里无处不在的手段。对过程的封装,我们称其为函数(Function),对某个对象的属性及行为的封装我们称其为类(Class)。很多高级程序设计语言都提供了足够的特性来支持封装。
封装的目的是信息隐藏(Information Hiding),这也是操作系统及计算机体系结构领域经常提到的最小特权原则(Least Privilege Principle)的一种特殊体现,它规定了程序的目标用户只应该知道他所需要的信息,对于他不该知道的信息或没有必要知道的信息,多一点儿也不让他知道。许多面向对象的程序设计语言都提供了较好的信息隐藏的手段,例如C++,JAVA。他们通过在类(Class)中,对访问权限进行限制,以公有(Public),私有(Private)等标签规定了一套访问规则(当然,还有protected),从而实现了信息隐藏。
在实践中,我们也把上面的技术称为接口(Interface)与实现(Implementation)的分离,用户看到的(或者说,能访问的,能调用的……)是接口,而用户看不见的,在接口之下默默运行着的是实现。
利用这样一套机制,我们可以极大地丰富程序设计语言的语义,其方法就是编写许多完备的用户自定义类型,或者说抽象数据类型。而这些抽象数据类型有着这样的特点:它们的含义远比内置类型(比如int,float,char……),而在使用的时候却又应该与内置类型一样方便。它们有着自己的属性与行为,比如一个栈(Stack),可以有栈顶(Top),长度(Length)等属性,可以有压栈(Push),出栈(Pop)等操作
不完整类型的概念
C语言将类型分为三类(C99 6.2.5):
(1)对象类型(object types):对象的大小(size)、内存布局(layout)和对齐方式(alignment requirement)都很清楚的对象。
(2)不完整类型(incomplete types):与对象类型相反,包括那些类型信息不完整的对象类型(incompletely-defined object type)以及空类型(void)。
(3)函数类型(function types):这个很好理解,描述函数的类型 -- 描述函数的返回值和参数情况。
这里我们详细了解下不完整类型。先看哪些情况下一个类型是不完整类型:
(1)具体的成员还没定义的结构体(共用体)
(2)没有指定维度的数组(不能是局部数组)
(3)void类型(it is an incomplete type that cannot be completed)
不完整类型也就是不知道变量的所有的类型信息。
比如可以声明一个数组,但是不给出该数组的长度;声明一个指针,但是不给出该指针的类型;声明一个结构体类型,但是不给出完整的结构体定义,只说它是一个结构体。
但是最终你必须得给出完整的类型信息。要不然编译会报错的。编译器在编译某个单元时,如果遇到一个不完整类型的定义的类型或变量(假设它叫p),它会把这当作正常现象,然后继续编译该单元,如果在本单元内找不到p完整的类型信息,它就去其它编译单元找。如果把整个编译过程分为编译、链接两个过程。在编译阶段遇到不完全类型是正常的,但是在链接过程中,所有的不完整类型必须存在对应的完整类型信息,否则报错。
举个例子,下面的代码先声明了一个不完全类型的变量字符数组str,没有给出它的长度信息。然后再定义了一次str数组,这次给出的长度信息。
char str[];//不完全类型定义
char str[10];//终于遇到了str数组的完整类型信息,编译器松了一口气
注意:不完全类型定义不适合局部变量,如果把上面两行代码放在一个函数体中,会出现符号重定义错误。
再举一个结构体的例子。下面的代码先声明了一个不完全类型的结构体s。然后又定义了该结构体。
struct s;
struct s{
int a;
int b;
};
C语言提供的唯一封装工具就是不完整类型。
将具体数据实现方式隐藏起来的数据类型称为抽象数据类型(Abstract Data Type,ADT)。
客户模块可以使用该类型来声明变量,但不会知道这些变量的具体数据结构。如果客户模块需要对这种变量进行操作,则必须调用抽象数据类型模块所提供的函数。C语言中的抽象数据类型可以简单的理解为C++、JAVA中的类(Class)。
比如有一个Stack.h如下:
#define STACK_SIZE 100
typedef struct {
int contents[STACK_SIZE];
int top;
} Stack;
void make_empty(Stack *s);
bool is_empty(const Stack *s);
bool is_full(const Stack *s);
void push(Stack *s, int i);
int pop(Stack *s);
在客户模块中就可以使用这个Stack类型了。
Stack s1, s2;
make_empty(&s1);
make_empty(&s2);
push(&s1, 1);
push(&s2, 2);
if (!is_empty(&s1)) {
printf("%d\n", pop(&s1));
}
遗憾的是,上面的Stack不是抽象数据类型,因为Stack.h暴露了Stack类型的具体实现方式,因此无法阻止客户讲Stack变量作为结构直接使用。
Stack s1;
s1.top = 0;
s1.contents[top++] = 1;
因此抽象数据类型需要利用不完整数据类型进行封装。
继续用Stack类型来举例。
首先我们需要一个定义抽象数据类型的头文件
//****stackADT.h****//
#ifndef STACKADT_H
#define STACKADT_H
#include
typedef struct stack_type *Stack;
Stack create(void);
void destroy(Stack s);
void make_empty(Stack s);
bool is_empty(Stack s);
bool is_full(Stack s);
void push(Stack s, int i);
int pop(Stack s);
#endif
这样包含头文件stackADT.h的客户可以声明Stack类型的变量,这些变量都可以指向stack_type结构,就可以调用在stackADT.h中声明的函数来对栈变量进行操作。但是客户不能访问stack_type结构的成员,因为该结构的定义在另一个文件中。
//****stackclient.c****//
#include
#include "stackADT.h"
int main(void)
{
Stack s1, s2;
int n;
s1 = create();
s2 = create();
push(s1, 1);
push(s1, 2);
n = pop(s1);
printf("pop %d from s1\n", n);
push(s2, n);
n = pop(s1);
printf("pop %d from s1\n", n);
push(s2, n);
destroy(s1);
destroy(s2);
}
//下面就实现抽象数据类型
//****stackADT.c****//
#include
#include
#include "stackADT.h"
#define STACK_SIZE 100
struct stack_type {
int contents[STACK_SIZE];
int top;
};
static void terminate (const char *message) {
printf("%s\n", message);
exit(EXIT_FAILURE);
}
Stack create (void) {
Stack s = malloc(sizeof(struct stack_type));
if (s == NULL) {
terminate ("error");
}
s->top = 0;
retrun s;
}
void destroy (Stack s) {
free(s);
}
void make_empty (Stack s) {
s->top = 0;
}
bool is_empty (Stack s) {
return s->top == 0;
}
bool is_full (Stack s) {
return s->top == STACK_SIZE;
}
void push (Stack s, int i) {
if (is_full(s)) {
terminate ("error");
}
}
int pop(Stack s) {
if (is_empty(s))
{
terminate ("error");
}
return s->contents[--s->top];
}
现在已经有抽象数据类型的一个版本了,当之后需要对Stack类型进行改进时,不需要对stackADT.h和stackclient.c进行修改,仅仅需要修改stackADT.c就可以了,比如将Stack类型中使用的数组改成链表。
//****stackADT.c****//
#include
#include
#include "stackADT.h"
struct node {
int data;
struct node *next;
};
struct stack_type {
struct node *top;
};
static void terminate (const char *message) {
printf("%s\n", message);
exit(EXIT_FAILURE);
}
Stack create (void) {
Stack s = malloc(sizeof(struct stack_type));
if (s == NULL) {
terminate ("error");
}
s->top = 0;
retrun s;
}
void destroy (Stack s) {
make_empty(s); //释放链表中结点所占的内存
free(s); //释放stack_type结构所占的内存
}
void make_empty (Stack s) {
while (!is_empty(s))
pop(s);
}
bool is_empty (Stack s) {
return s->top == NULL;
}
bool is_full (Stack s) {
return false;
}
void push (Stack s, int i) {
struct node *new_node = malloc(sizeof(struce node));
if(new_node == NULL)
terminate("error");
new_node->data = i;
new_node->next = s->top;
s->top = new_node;
}
int pop(Stack s) {
struct node *old_top;
int i;
if (is_empty(s))
terminate("error");
old_top = s->top;
i = old_top->data;
s->top = old_top->next;
free(old_top);
return i;
}
最后总结一下封装的优点:
1. 隐藏了内部实现细节,强制用户按接口规则访问。减少沟通成本。
2. 便于修改。
上面两点都是实现模块化编程所必须的。而且个人认为,站在客户的角度,知道的细节越少越好,知道的越多,要记忆和思考的东西也越多。就像电视剧中的经典台词:“有时候知道的太多并不是好事”。角色被人干掉的理由也是“你知道的太多了”,或者“你知道了你不该知道的事情”。我只按照既定的规则来访问别人提供的代码,有什么问题直接问别人,而不是看实现代码。按照既定规则来访问也便于职责划分。如果非要总结延迟定义带来的第3个好处,个人认为是:便于职责划分,清晰职责边界。