语言、开发环境等,无需赘述
我觉得不一定要遵循作者的代码风格,但一定要和团队的习惯保持一致,自己的代码风格也要统一。代码要整洁、美观。
这是面向对象程序设计的基本原则之一,可以隔离变化,降低复杂度。封装的方法:隐藏数据结构,隐藏内部函数(用static修饰),禁用全局变量,这三个方法都是实现封装的必要手段。
C++
可以调用 C++
为了实现函数重载,编译器会把函数名重新编码。重新编码后就合原来的函数名不一致了,链接的时候就找不到相应的函数。为了让c++可以调用,需要在C的头文件中加上: #ifdef __cplusplus
extern "C"{
#endif
...
#ifdef __cplusplus
}
#endif
主要讲了代码写的又好又快的方法。
作者讲了自己的经验之谈,比如阅读自己的代码,避免常见错误。
{
/*这里加锁*/
...
return;
/*这里解锁*/
}
有时候,return的地方太多,在某一处忘记解锁是可能的,就像内存泄漏一样,只是忘记解锁的后果更严重。
如果一个函数有五六个甚至更多的地方需要返回,那么遗忘一两处也是很常见的,即使没有忘记,载每个返回的地方都要去解锁和释放锁也是很麻烦的。在这种情况下,我们最好是实现单入口单出口的函数,常见的做法有以下两种。
- 使用goto语句。在Linux内核里大量使用了这种方式。
- 使用do{}while(0)语句,由于收到教科书的影响(不要用会破坏程序结构的goto语句)
另一类错误是加锁顺序问题,一定要按相同顺序加锁,相反顺序解锁。
在很多情况下,由实现者来加锁是比较好的选择,那样对调用者更为友好,可以避免出现一些不必要的错误。
对双向链表做点改进
- 支持多线程和单线程的版本。对于多线程的版本,由实现者(在链表)加锁/解锁。对于单线程版本,其性能不受影响(或很小)。
- 区分单线程版本和多线程版本时,既不需要链接不同的库,也不需要用宏来控制,完全可以在运行时切换
- 保持双向链表的通用性,不依赖于特定的平台
方法:
抽象出锁接口,锁接口有加锁、解锁、销毁这几个抽象接口函数。接口函数在不同平台有不同的实现。锁接口不能有创建锁的函数,但必须有销毁锁的函数。因为锁是一个抽象的概念,这个概念不能凭空创造出自己出来。所以创建锁的函数不应该放在锁接口中,而应该放在接口外部,不同的平台接口相同但实现不同。当需要销毁锁的时候,锁这个对象已经存在了,应该提供一个销毁它自身的接口。锁接口会用到一些具体的数据结构,和平台有关,这些上下文信息应该放在锁解口的一个变长的成员变量中。
当创建链表时,单线程版本合多线程版本的不同就是创建链表时给链表的构造函数参数不同。多线程版本在创建链表时同时创建一个锁,而单线程版本这个参数为空。当我们在链表实现时,需要加锁解锁的地方,先判断锁是否为空,锁为空的时候说明单线程,非空说明是多线程。
链表实现队列和栈
数组+链表实现散列表
贴一下散列表的部分实现
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
#include
#include
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 _Module
typedef 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: main
main:hello1.o hello2.o
g++ hello1.o hello2.o -o main
hello1.o:
g++ -c hello1.c
hello2.o
g++ -c hello2.c
clean:
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()系统调用并不是完全为了用于共享内存而设计的。它本身提供了不同于一般对普通文件的访问方式,进程可以像读写内存一样对普通文件的操作.
start代表共享内存映射的起始地址,一般设置为NULL由内核选择映射的地址。length代表映射的共享内存的长度,prot指定共享内存的权限。PROT_READ(可读),PROT_WRITE(可写),PROT_EXEC(可执行)。flags由以下几个常量指定:MAP_PRIVATE,MAP_SHARED,MAP_FIXED,一般设置为MAP_SHARED. fd代表被映射的文件的描述符,fd设为-1代表匿名映射,一般用于具有亲缘关系的进程之间的通讯。offset代表要映射的文件的起始地址,一般设为0代表从文件头开始映射。函数的返回值代表文件映射到进程空间的地址。
调用在进程地址空间中解除一个映射关系,start是调用mmap()时返回的地址,length是映射区的大小。当映射关系解除后,对原来映射地址的访问将导致段错误发生。
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块大小相等的小块内存,然后进行二次分配。由于这些小块内存的大小是固定的,内存管理的开销就非常小了,往往只要一个标识位用于标识该单元是否空闲甚至连标识位都不需要。
从应用程序、编译器和调试器三个层次来防止和排查内存错误。
有了以上两点保证,要检查内存错误就非常容易了。比如要检查++p是否有效,首先在全局表中查找p指向的内存块,如果没有找到,说明p是也指针。如果找到了,再检查p+1是否在这块内存范围内,如果不是,那就是越界访问,否则就是正常的。
变参函数的参数是不确定的,它允许同一个函数有多种不同的参数组合,编译器不会对可变部分的参数做类型检查,因而在使用的时候拥有较大的灵活性。
这需要libc库支持,要在头文件stdarg.h里提供一些必要的宏定义。
#include
#include
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使用了格式化字符串
在函数被调用时,先把被调函数的参数压入栈中,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。
下面几种写法才是比较正规的。
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函数的返回值是返回给父进程的,父进程调用下列函数来获取子进程的退出码。
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
在bash里,执行一个命令后,$?里存放的是这个命令的退出码。
这么一行简单的程序,有数十次系统调用。可见简单的程序并不简单,只是实现细节被操作系统和函数库封装起来罢了。
如果只有一个参数,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来兼容这两种系统调用。
用有穷状态机解一道面试题。
统计一篇英文文章里的单词个数。
解这道题的方法很多,这里选择用有穷状态机来解,做法如下:
先把这篇英文文章读如到一个缓冲区里,让一个指针从缓冲区的头部一直移到缓冲区的尾部,指针处于两种状态:“单词内”或“单词外”,加上后面提到的初始状态和接受状态,就是有穷状态机的状态集。缓冲区中的字符集合就是有穷状态机的字母表。
如果当前状态为“单词内”,移到指针时,指针指向的字符是非单词字符(如标点和空格),那状态会从单词内转换到单词外。如果当前状态为“单词外”,移到指针时,指针指向的字符是单词字符(如字母),那状态会从“单词外”转换到“单词内”。这些转换规则就是状态转换函数。
指针指向缓冲区的头部是初始状态。
指针指向缓冲区的尾部时是接受状态。
每次当状态从“单词内”转换到“单词外”时,单词计数加1.
除开统计单词个数,我们可以在状态转换时做一些其它的事情来扩展这个程序。比如,可以分割token。
本书还讲了两个比较复杂的状态机:INI解析器和XML解析器的实现。思路是类似的,先列出有多少状态,再考虑状态之间的转移。
前面学习了状态机,并利用它来解析各种格式的文本数据。对于特定格式的文本数据,它的解析过程是一样的,但是对解析出来的数据而言,却有着多种多样的处理方式。为了把解析过程能够重用,就需要把数据的解析和数据的处理分离开。
Builder模式的意图:讲一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。“构建”其实就是前面的解析过程,而“表示”就是前面说的对数据的处理。
管道负责数据的传递,它把原始数据传递给第一个过滤器,把一个过滤器的输出传递给下一个过滤器作为下一个过滤器的输入,并重复这个过程知道处理结束。
过滤器负责数据的处理,过滤器可以有多个,每个过滤器对数据做特定的处理,它们之间没有依赖关系,一个过滤器不必知道其它过滤器的存在。这种松耦合的设计,使得过滤器只需要实现单一的功能,从而降低了系统的复杂度,也使得过滤器之间依赖最小,从而以更加灵活的组合来实现新的功能。
优点:
1. 降低系统的复杂度
2. 隔离变化
3. 有利于自动测试
4. 有利于提高程序的可移植性
比如ISO七层网络模型
比如计算机的组成结构,最里层是硬件,中间层是操作系统,最外层是应用程序。
为了降低层与层之间的耦合,层与层之间的通信必须按一定的规则进行。
1. 上层可以直接调用下层提供的函数
2. 上层可以直接调用相邻下层或间接下层提供的函数,但最好只调用相邻下层所提供的函数。
3. 下层不能直接调用上层提供的函数
由于下层不能直接调用上层提供的函数,那下层需要传递数据给上层,需要其它的途径,常见的做法有两种。
1. 通过消息传递
2. 通过回调函数
出于某些原因,你不想或不能去修改原有的应用程序,但你又希望加上一个全新的用户界面,这时,外壳模式或许有帮助。
外壳模式基于这样一个假设:应用程序实现了基于终端的用户界面,即从标准输入中读取数据,向标准输出和标准错误输出显示结果。标准输入、标准输出和标准错误输出都是文件描述符,文件描述符是一个整数,具有天生的抽象能力。
我们把标准输入、标准输出和标准错误输出重定向到管道上,向管道里写数据来模拟应用程序的输入,从标准输出和标准错误输出里读取应用程序的输出。这样一来,就不需要修改原来的应用程序,而控制它的输入和输出,同时应用程序也不知道外壳的存在。
编写设计文档的好处:
- 有助于细化设计
- 充当交流的媒介
- 充当备忘录
好的设计文档应该具有下列特点:
1. 从读者的角度来写
2. 简洁明了,用词准确
3. 避免不必要的重复
4. 使用标准的符号和术语,自定义的符号和术语要有明确解释
5. 记录结果的同时记录想法