《系统程序员成长计划》读书笔记

来源:互联网 发布:下载刷枪软件 编辑:程序博客网 时间:2024/05/16 06:40

第0章

语言、开发环境等,无需赘述

第1章

代码风格:

我觉得不一定要遵循作者的代码风格,但一定要和团队的习惯保持一致,自己的代码风格也要统一。代码要整洁、美观。

封装:

这是面向对象程序设计的基本原则之一,可以隔离变化,降低复杂度。封装的方法:隐藏数据结构,隐藏内部函数(用static修饰),禁用全局变量,这三个方法都是实现封装的必要手段。

通用链表:

  1. 存值还是存指针?
    存值时复制一份数据,保存数据的指针和长度。考虑到复制数据会带来性能开销,不考虑。只保存指向对象的指针,存取效率高。
  2. C++可以调用
    C++为了实现函数重载,编译器会把函数名重新编码。重新编码后就合原来的函数名不一致了,链接的时候就找不到相应的函数。为了让c++可以调用,需要在C的头文件中加上:
#ifdef __cplusplusextern "C"{#endif    ...#ifdef __cplusplus}#endif
  1. 通用链表的打印函数
    链表中可能存放多种数据类型,如何实现一个通用的打印函数?实现多个,爱用哪个用哪个?这种做法会导致大量的重复代码,并且每次添加新的类型时,都要改实现。
    比较好的方法:调用dlist的接口函数获取每一个位置的数据并打印出来。但是因为数据类型不确定,所以需要调用方自己提供一个打印的回调函数。
  2. 不要写重复的代码
    重复的代码更容易出错,且经不起变化。所以累加链表中的整数和找出链表中的最大值这两个函数是非常类似的,都可以通过写一个遍历链表的函数然后通过回调函数来解决。

第2章

主要讲了代码写的又好又快的方法。
作者讲了自己的经验之谈,比如阅读自己的代码,避免常见错误。

第4章 并发与同步

并发

  1. Linux下的多线程编程使用pthread函数库,使用时需要包含头文件pthread.h,还要链接共享库libpthread.so。这里顺便说一下gcc链接共享库的方式:-L用来指定共享库所在目录,不用指定系统库目录;-l用来指定要链接的共享库,只需要指定库的名字就行了,如应该是-lpthread而不是-llibpthread.so这样看起来有点怪,其原因是共享库通常带有版本号,指定全文件名就意味着你要绑定到特定版本的共享库上,而只指定名字则在可以运行时通过环境变量来选择要使用的共享库,这样能够给软件升级带来方便。
  2. 在加锁/解锁时,初学者常犯两类错误。
    一类错误是存在没有解锁的路径。初学者常见的做法是,在进入某个临界函数时加锁,在函数结尾的地方解锁,我甚至看到过这样的写法。
{    /*这里加锁*/    ...    return;    /*这里解锁*/}

有时候,return的地方太多,在某一处忘记解锁是可能的,就像内存泄漏一样,只是忘记解锁的后果更严重。
如果一个函数有五六个甚至更多的地方需要返回,那么遗忘一两处也是很常见的,即使没有忘记,载每个返回的地方都要去解锁和释放锁也是很麻烦的。在这种情况下,我们最好是实现单入口单出口的函数,常见的做法有以下两种。
- 使用goto语句。在Linux内核里大量使用了这种方式。
- 使用do{}while(0)语句,由于收到教科书的影响(不要用会破坏程序结构的goto语句)
另一类错误是加锁顺序问题,一定要按相同顺序加锁,相反顺序解锁。

同步

在很多情况下,由实现者来加锁是比较好的选择,那样对调用者更为友好,可以避免出现一些不必要的错误。
对双向链表做点改进
- 支持多线程和单线程的版本。对于多线程的版本,由实现者(在链表)加锁/解锁。对于单线程版本,其性能不受影响(或很小)。
- 区分单线程版本和多线程版本时,既不需要链接不同的库,也不需要用宏来控制,完全可以在运行时切换
- 保持双向链表的通用性,不依赖于特定的平台

方法:
抽象出锁接口,锁接口有加锁、解锁、销毁这几个抽象接口函数。接口函数在不同平台有不同的实现。锁接口不能有创建锁的函数,但必须有销毁锁的函数。因为锁是一个抽象的概念,这个概念不能凭空创造出自己出来。所以创建锁的函数不应该放在锁接口中,而应该放在接口外部,不同的平台接口相同但实现不同。当需要销毁锁的时候,锁这个对象已经存在了,应该提供一个销毁它自身的接口。锁接口会用到一些具体的数据结构,和平台有关,这些上下文信息应该放在锁解口的一个变长的成员变量中。
当创建链表时,单线程版本合多线程版本的不同就是创建链表时给链表的构造函数参数不同。多线程版本在创建链表时同时创建一个锁,而单线程版本这个参数为空。当我们在链表实现时,需要加锁解锁的地方,先判断锁是否为空,锁为空的时候说明单线程,非空说明是多线程。

第5章 组合的威力

链表实现队列和栈
数组+链表实现散列表
贴一下散列表的部分实现

散列表的数据结构

struct _HashTable{    DataHashFunc hash;    DList**      slots;    size_t       slot_nr;    DataDestroyFunc data_destroy;    void*       data_destroy_ctx;}

创建散列表

HashTable* hash_table_create(DataDestroyFunc data_destroy, void* ctx, DataHashFunc hash, int slot_nr){    HashTable* thiz = NULL;    return_val_if_fail(hash != NULL && slot_nr > 1, NULL);    thiz = (HashTable*)malloc(sizeof(HashTable));    if (thiz != NULL)    {        thiz->hash = hash;        thiz->slot_nr = slot_nr;        thiz->data_destroy_ctx = ctx;        thiz->data_destroy = data_destroy;        if ((thiz->slots = (DList**)calloc(sizeof(DList*)*slot_nr, 1)) == NULL)        {            free(thiz);            thiz = NULL;        }    }    return thiz;}

插入散列表

Ret hash_table_insert(HashTable* thiz, void* data){    size_t index = 0;    return_val_if_fail(thiz != NULL, RET_INVALID_PARAMS);    index = thiz->hash(data)%thiz->slot_nr;    if (thiz->slots[index] == NULL)    {        thiz->slots[index] = dlist_create(thiz->data_destroy, thiz->data_destroy_ctx);    }    return dlist_prepend(thiz->slots[index], data);}

第六章 算法与容器

容器

实现一个队列,要求如下:
1. 由调用者决定用双向链表实现还是用动态数组实现。
2. 在运行时决定,而不是在编译时决定(宏定义)。
3. 如果调用者乐意,他以后还可以选择用单向链表来实现,而不必修改队列的实现代码。
4. Don’t Repeat Yourself.
最常见的一种做法是用一个参数来决定是调用双向链表的函数还是动态数组的函数。这种方法满足了前两种要求,但无法满足第三个要求,它限定了只能使用双向链表或动态数组之一来实现队列,要添加其他实现方法,就要修改队列本身了。
另一种方法是抽象出一个队列的接口,分别用双向链表和动态数组来实现,这样就可以把调用者和队列的不同实现分开了。这个想法能通过队列接口解决前三个问题。但问题是,用双向链表实现的队列和用动态数组实现的队列,从逻辑上讲,完全是重复的,从代码的角度来看,它们又有些差别。队列的逻辑是不变的,变化的只是容器,也就是说应该抽象的是容器接口而不是队列接口。
“最小粒度抽象原则”。接口隔离了调用者和具体实现,不管抽象的粒度大小如何,接口都能起到隔离变化的作用。但是粒度越大,造成的重复就越多。

迭代器

对指针的抽象,对不同容器有不同的实现,而算法只用关心它提供的接口。

动态绑定

前面通过容器接口抽象了双向链表和动态数组,这样队列的实现就不依赖于具体的容器了。但是作为队列的使用者,它仍然要在编译时决定使用哪个容器。队列的测试程序就是队列的使用者之一,它的实现代码如下:

Queue* queue = queue_create(linear_container_dlist_create(NULL, NULL));...

linear_container_dlist_create还是linear_container_darray_create,这里需要我们明确的指定。而假设使用者想要换一种容器,那还是要修改代码并重新编译才行。现在我们思考另外一个问题,如何让使用者(如这里的测试程序)在想换用另一种容器时,既不需要修改代码也不需要重新编译。
这里需要采用一种新的技术:在运行时动态加载共享库,Linux下有dlopen系列函数实现运行时动态加载共享库。下面是使用dlopen的简单示例。

#include <stdio.h>#include <stdlib.h>#include <dlfcn.h>int main(int argc, char** argv){    void* handle = NULL;    double(*cosine)(double) = NULL;    /*加载共享库*/    handle = dlopen("libm.so", RTLD_LAZY);    /*通过函数名找到函数指针*/    *(void **) (&cosine) = dlsym(handle, "cos");    /*调用函数*/    printf("%f\n", (*cosine)(2.0));    dlclose(handle);    return 0;}

由于这些函数在每个平台的名称和参数都有所不同,直接使用这些函数会带来可移植性的问题,为此有必要对它们进行包装。我们要做的只是添加一个适配层,用它来隔离不同平台。
把这个加载函数的适配层称为Module,声明如下:

struct _Moduletypedef struct _Module Module;typedef enum _ModuleFlags{    MODULE_FLAGS_NONE,    MODULE_FLAGS_DELAY = 1}MODULEFLAGS;Module* module_create(const char* file_name, ModuleFlags flags);void* module_sym(Module* thiz, const char* func_name);void module_destroy(Module* thiz);

这里它们的实现只是对dl系列函数做个简单包装。由于不同平台有不同的实现,为了维护方便,我们把不同的实现放在不同的文件中,比如Linux的实现放在module_linux.c里。

Module* module_create(const char* file_name, ModuleFlags flags){    Module* thiz = NULL;    return_val_if_fail(file_name != NULL, NULL);    if ((thiz = malloc(sizeof(Module))) != NULL)    {        thiz->handle = dlopen(file_name, flags & MODULE_FLAGS_DELAY? RTLD_LAZY : RTLD_NOW);        if (thiz->handle == NULL)        {            free(thiz);            thiz = NULL;            printf("%s\n", dlerror());        }    }    return thiz;}

“接口+动态加载”的方式也称为插件式设计,它是软件可扩展性的基础。

第七章 工程管理

这章讲了如何使用automake编译项目。这个工具比较复杂,使用的机会也有限,没有深入了解,但借此机会学习了Makefile文件的编写。
当使用make构建项目时,make会在当前目录下寻找makefile文件作为编译的规则。
makefile的基本规则是:

target: dependencies[tab] system command

target是我们要编译生成的目标,dependencies是生成这个目标所需的依赖。下面一行开头是一个tab键,然后紧接着是生成这个target的命令。
比如

all: mainmain:hello1.o hello2.o    g++ hello1.o hello2.o -o mainhello1.o:    g++ -c hello1.chello2.o    g++ -c hello2.cclean:    rm -rf *.o main

all是makefile的默认目标,all可以指定多个依赖,all依赖的目标都会被生成,如果不写all,makefile会生成第一个目标。
clean这个命令是用来清除中间生成的.o文件和可执行文件。
makefile也支持宏定义,我们可以在文件开始处定义一些宏,然后用$()取变量的值,对于会在makefile中出现多次的变量,就可以用这种方式来简化编写,当修改的时候也只需要修改一次。

第八章 内存管理

共享内存

进程的地址空间是独立的,它们之间互不影响。比如同样地址的内存,在不同的进程中,它们的数据是完全不同的。这样做的好处有以下两点。
1. 每个进程的地址空间变大了,编写程序更容易。
2. 一个进程崩溃了,不会影响其他进程,提高了系统整体的稳定性。

要做到进程的地址空间独立,还需要硬件MMU(内存管理单元)的帮助。访问内存数据时,由MMU根据页表把虚拟内存地址转换成对应的物理内存地址。
MMU把各个进程的虚拟内存映射到不同的物理内存上,这样就能保证进程的虚拟内存地址是独立的。由于物理内存远远少于各个进程的虚拟内存的总和,操作系统会把暂时不同的内存数据写到硬盘上去,把腾出来的物理内存分配给有需要的进程使用。
从虚拟内存到物理内存的映射并不是逐字节映射的,而是以页为最小单位进行映射的。当应用程序访问的虚拟内存的页面不在物理内存里时,MMU产生一个缺页中断,并挂起当前进程,缺页中断处理函数负责把相应的数据从磁盘读入内存,然后唤醒挂起的进程。
有时我们需要在进程间共享数据,就需要共享内存。实现内存的共享非常容易,只要把两个进程的虚拟内存页面映射到同一个物理内存页面就行了。
Linux提供的API是:

void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);  int munmap(void *start, size_t length);

mmap

实际上,mmap()系统调用并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作.
start代表共享内存映射的起始地址,一般设置为NULL由内核选择映射的地址。length代表映射的共享内存的长度,prot指定共享内存的权限。PROT_READ(可读),PROT_WRITE(可写),PROT_EXEC(可执行)。flags由以下几个常量指定:MAP_PRIVATE,MAP_SHARED,MAP_FIXED,一般设置为MAP_SHARED. fd代表被映射的文件的描述符,fd设为-1代表匿名映射,一般用于具有亲缘关系的进程之间的通讯。offset代表要映射的文件的起始地址,一般设为0代表从文件头开始映射。函数的返回值代表文件映射到进程空间的地址。

munmap

调用在进程地址空间中解除一个映射关系,start是调用mmap()时返回的地址,length是映射区的大小。当映射关系解除后,对原来映射地址的访问将导致段错误发生。

线程局部存储(TLS)

Linux下实现线程局部存储的方法:使用pthread的函数

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));int pthread_key_delete(pthread_key_t key);void *pthread_getspecific(pthread_key_t key);int pthread_setspecific(pthread_key_t, const void *value)

内存管理器

内存管理器的基本功能:分配内存,释放内存,扩展/缩小已经分配的内存,分配清零的内存。
分配过程:所有空闲的内存块放在一个双向链表中,最初只有一块,分配时使用首次匹配算法,在第一个空闲块上进行分配。
释放时把内存块放回空闲链表,然后对相邻邻居的内存块进行合并。
书里有详细的代码,就不贴了。

惯用手法

预分配

扩展缓冲区的过程如下:
1. 先分配一块更大的缓冲区
2. 把数据从老的缓冲区复制到新的缓冲区
3. 释放老的缓冲区

由此可见,扩展缓冲区的代价是很高的。此时我们可以采用预分配机制,每次扩展时。预先分配一大块内存。这一大块可以供后面较长一段时间使用。

对象引用计数

参照C++ shared_ptr的引用计数

写时复制

父进程fork子进程的时候会采用这种方法来提升性能。

固定大小分配

频繁地分配大量小块内存是设计内存管理器的挑战之一。
首先是空间利用率上的问题,由于内存管理器本身需要一些辅助内存,假设每块内存需要16字节用作辅助内存,那么即使只要分配4个字节这样的小块内存,仍然要分配16字节的内存,一小块内存不重要,若存在大量小块内存,所浪费的空间就不容忽视了。
其次是内存碎片问题:频繁分配大量小块内存,会造成内存碎片问题。这不但降低内存管理器的效率,同时由于这些内存不连续,即使处于空闲也无法使用。
此时可以采用固定大小分配,这种方式通常也叫做缓冲池分配。缓冲池先分配一块或多块连续的大块内存,把它们分成N块大小相等的小块内存,然后进行二次分配。由于这些小块内存的大小是固定的,内存管理的开销就非常小了,往往只要一个标识位用于标识该单元是否空闲甚至连标识位都不需要。

调试手段及原理

从应用程序、编译器和调试器三个层次来防止和排查内存错误。

从应用程序的层次

  • 对付内存泄露。重载内存管理函数,在分配时,把这块内存记录到一条链表中,在释放时,从链表删除掉,在程序退出时,检查链表是否为空,如果不为空,则说明有内存泄漏,否则说明没有泄漏。当然,为了查出是哪里的泄漏,在链表还要记录是谁分配的,通常记录文件名和行号就行了。
  • 对付内存越界/野指针。方法如下:
    (1). 首尾再加保护边界值。
    内存分配时,在首尾加上固定的数据。内存释放的时候,内存管理器检查这些guard数据是否被修改。
    (2). 填充空闲内存。
    内存被释放之后,它的内容填充成固定的值。这样,从指针指向的内存的数据,可以大致判断这个指针是否是野指针。

从编译器的层次

  • 管理所有内存块。无论是堆、栈、还是全局变量,只要有指针引用它,它就被记录到一个全局表中。记录的信息包括内存块的起始地址和大小等。
  • 拦截所有的指针运算。对指针进行乘除等运算其实意义不大,最常见运算是对指针加减一个偏移量,如++p、p=p+n和p=a[n]等。所有这些有意义的指针操作都要受到检查,检查操作不再由一条简单的汇编指令来完成,而是由一个函数来完成。

有了以上两点保证,要检查内存错误就非常容易了。比如要检查++p是否有效,首先在全局表中查找p指向的内存块,如果没有找到,说明p是也指针。如果找到了,再检查p+1是否在这块内存范围内,如果不是,那就是越界访问,否则就是正常的。

第九章 从计算机的角度思考问题

变参函数的实现原理

变参函数的参数是不确定的,它允许同一个函数有多种不同的参数组合,编译器不会对可变部分的参数做类型检查,因而在使用的时候拥有较大的灵活性。

使用变参函数

这需要libc库支持,要在头文件stdarg.h里提供一些必要的宏定义。

#include <stdarg.h>#include <stdio.h>

实现变参函数

int accumulate(int nr, ...){    int i = 0;    int result = 0;    va_list arg = NULL;    va_start(arg, nr);    for (i = 0; i < nr; i++)    {        result += va_arg(arg, int);    }    va_end(arg);    return result;}

来看看这几个宏是如何实现的,调用C语言函数时,参数按值传递,并从最后一个参数开始压栈,先压入最后一个参数,再压入倒数第二个参数…由于栈是向下增长的,也就是先压入的参数放在高地址,后压入的参数放在低地址。这样一来,只要我们知道可变参数的前一个参数,就可以依次取出后面的参数了。
在前面的例子中,这两行代码让arg指向了第一个可变参数。

va_list arg = NULL;va_start(arg, nr);

va_list是一个指针,由于参数的类型是不定的,它可以指向任意类型的指针,我们这样定义它。

#define va_list void*

要让arg指向第一个可变参数,用nr的地址加上nr的数据类型大小就行了,我们这样定义va_start.

#define va_start(arg, start) arg = (va_list)((char *)&(start)) + sizeof(start))  

在前面的例子中,这行代码让arg指向了下一个可变参数。

result += va_arg(arg, int)

要让arg指向下一个可变参数,用当前可变参数的地址加上当前可变参数的数据类型大小就行了。我们这样定义va_arg.

#define va_arg(arg, type) *(type*)arg; arg = (char*)arg + sizeof(type)

关于参数结束标识的问题

变参函数的参数个数是变化的,怎么知道实际参数的个数呢?通常的做法有三种。
1. 指定参数的个数
2. 用固定值表示最后一个参数
3. 用格式化字符串,比如printf使用了格式化字符串

backtrace的实现原理

在函数被调用时,先把被调函数的参数压入栈中,C语言的压栈方式是:先压入最后一个参数,再压入倒数第二参数,按此顺序入栈,最后才压入第一个参数。
然后压入EIP和EBP,其中EIP指向完成本次调用后下一条指令的地址,我们可以近似地认为这个地址是函数调用者的地址。EBP是调用者和被调函数之间的分界线,分界线之上是调用者的临时变量、被调函数的参数、函数返回地址(EIP),和上一层函数的EBP,分界线之下是被调函数的临时变量。
最后压入被调函数本身,并为它分配临时变量的空间。不同版本gcc的处理方式是不一样的,对于老版本的gcc(如gcc3.4),第一个临时变量放在最高的地址,第二个其次,依次顺序分布。对于新版本的gcc(如gcc4.3),临时变量的位置是反的,即最后一个临时变量在最高的地址,倒数第二个其次,依次逆序分布。
为了实现backtrace,我们需要:
1. 获取当前函数的EBP
2. 通过EBP获得调用者的EIP
3. 通过EBP获得调用者的EBP
4. 重复这个过程,直到结束

为了获得当前函数的EBP,对于gcc3.4生成的代码,我们知道当前函数第一个临时变量的下一个位置就是EBP。而对于gcc4.3生成的代码,我们知道当前函数最后一个临时变量的下一个位置就是EBP。
拿到当前的EBP后,EBP的下一个位置就是EIP,EBP的值就是调用者的EBP的位置。通过一个循环,重复取上一层的EBP和EIP,最终得到所有调用者的EIP,从而实现了backtrace。

helloworld不得不说的十个秘密

秘密一:main函数的原型

下面几种写法才是比较正规的。

int main(void)int main(int argc, char* argv[])int main(int argc, char* argv[], char* env[])

argc是命令行参数的个数
argv是命令行参数,以NULL结束
env是环境参数,以NULL结束
通过setenv和getenv等函数可以存取环境变量,但对环境变量的修改只会影响当前进程及子进程,而不会影响父进程。

秘密二:main函数的返回值

main函数的返回值是返回给父进程的,父进程调用下列函数来获取子进程的退出码。

pid_t wait(int *status);pid_t waitpid(pid_t pid, int *status, int options);

在bash里,执行一个命令后,$?里存放的是这个命令的退出码。

秘密三:被隐藏的细节

这么一行简单的程序,有数十次系统调用。可见简单的程序并不简单,只是实现细节被操作系统和函数库封装起来罢了。

秘密四:printf不见了

如果只有一个参数,printf的功能和puts一致,于是gcc就用puts代替了它。

秘密五:链接了哪些共享库

libc.so.6是glibc,它实现了像printf这类标准C的函数。
ld-linux.so.2是ELF可执行文件的解释器,Linux内核在执行ELF可执行文件时,其实是执行ld-linux-so.2,然后由ld-linux-so.2去加载可执行文件及其所依赖的共享库。
linux-gate.so.1这个文件又称为虚拟动态共享库(VDSO),linux-gate.so.1文件是不存在的,Linux内核根据CPU类型,动态决定使用哪个共享库,它的功能主要是加速系统调用。系统调用需要跨越用户空间和内核空间之间的门。在x86系列CPU上,Linux传统的做法是使用80中断(int 0x80)实现系统调用,不过它的效率较低。有的CPU提供了高效的sysenter指令,但不是所有CPU都支持,Linux通过VDSO来兼容这两种系统调用。

第10章 文本处理

状态机

用有穷状态机解一道面试题。
统计一篇英文文章里的单词个数。
解这道题的方法很多,这里选择用有穷状态机来解,做法如下:
先把这篇英文文章读如到一个缓冲区里,让一个指针从缓冲区的头部一直移到缓冲区的尾部,指针处于两种状态:“单词内”或“单词外”,加上后面提到的初始状态和接受状态,就是有穷状态机的状态集。缓冲区中的字符集合就是有穷状态机的字母表。
如果当前状态为“单词内”,移到指针时,指针指向的字符是非单词字符(如标点和空格),那状态会从单词内转换到单词外。如果当前状态为“单词外”,移到指针时,指针指向的字符是单词字符(如字母),那状态会从“单词外”转换到“单词内”。这些转换规则就是状态转换函数。
指针指向缓冲区的头部是初始状态。
指针指向缓冲区的尾部时是接受状态。
每次当状态从“单词内”转换到“单词外”时,单词计数加1.
除开统计单词个数,我们可以在状态转换时做一些其它的事情来扩展这个程序。比如,可以分割token。
本书还讲了两个比较复杂的状态机:INI解析器和XML解析器的实现。思路是类似的,先列出有多少状态,再考虑状态之间的转移。

Builder模式

前面学习了状态机,并利用它来解析各种格式的文本数据。对于特定格式的文本数据,它的解析过程是一样的,但是对解析出来的数据而言,却有着多种多样的处理方式。为了把解析过程能够重用,就需要把数据的解析和数据的处理分离开。
Builder模式的意图:讲一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。“构建”其实就是前面的解析过程,而“表示”就是前面说的对数据的处理。

管道过滤器模式

管道负责数据的传递,它把原始数据传递给第一个过滤器,把一个过滤器的输出传递给下一个过滤器作为下一个过滤器的输入,并重复这个过程知道处理结束。
过滤器负责数据的处理,过滤器可以有多个,每个过滤器对数据做特定的处理,它们之间没有依赖关系,一个过滤器不必知道其它过滤器的存在。这种松耦合的设计,使得过滤器只需要实现单一的功能,从而降低了系统的复杂度,也使得过滤器之间依赖最小,从而以更加灵活的组合来实现新的功能。

第十一章 分离用户界面和内部实现

分层设计

优点:
1. 降低系统的复杂度
2. 隔离变化
3. 有利于自动测试
4. 有利于提高程序的可移植性

上下层表示

比如ISO七层网络模型

内外层表示

比如计算机的组成结构,最里层是硬件,中间层是操作系统,最外层是应用程序。

层与层之间的通信

为了降低层与层之间的耦合,层与层之间的通信必须按一定的规则进行。
1. 上层可以直接调用下层提供的函数
2. 上层可以直接调用相邻下层或间接下层提供的函数,但最好只调用相邻下层所提供的函数。
3. 下层不能直接调用上层提供的函数

由于下层不能直接调用上层提供的函数,那下层需要传递数据给上层,需要其它的途径,常见的做法有两种。
1. 通过消息传递
2. 通过回调函数

MVC架构

  • 模型(Model):它负责实现程序的具体功能,包括核心数据结构和逻辑处理。
  • 视图(View):负责向用户显示处理结果。
  • 控制器(Controller):接收用户的输入,然后调用模型的处理函数进行处理。
    MVC架构是分层架构的特例,它的主要特征是同一个模型可以支持多个视图。

外壳模式

出于某些原因,你不想或不能去修改原有的应用程序,但你又希望加上一个全新的用户界面,这时,外壳模式或许有帮助。
外壳模式基于这样一个假设:应用程序实现了基于终端的用户界面,即从标准输入中读取数据,向标准输出和标准错误输出显示结果。标准输入、标准输出和标准错误输出都是文件描述符,文件描述符是一个整数,具有天生的抽象能力。
我们把标准输入、标准输出和标准错误输出重定向到管道上,向管道里写数据来模拟应用程序的输入,从标准输出和标准错误输出里读取应用程序的输出。这样一来,就不需要修改原来的应用程序,而控制它的输入和输出,同时应用程序也不知道外壳的存在。

第十二章 撰写设计文档

编写设计文档的好处:
- 有助于细化设计
- 充当交流的媒介
- 充当备忘录

好的设计文档应该具有下列特点:
1. 从读者的角度来写
2. 简洁明了,用词准确
3. 避免不必要的重复
4. 使用标准的符号和术语,自定义的符号和术语要有明确解释
5. 记录结果的同时记录想法

设计文档模板

  1. 文档的基本信息
  2. 术语与缩写
  3. 背景
  4. 需求简述
  5. 与外部模块的交互
  6. 分析
  7. 对象模型
  8. 动态模型
  9. 数据文件
  10. 其他考虑
  11. 主要风险及未决事项
  12. 测试指南
  13. 设计回顾
原创粉丝点击