cs107 编程范式(六)

254 阅读3分钟

更加通用的栈类型

typedef struct {
    void *elems;
    int elemSize;
    int logLength;
    int allocLength;
} Stack;

void StackNew(Stack *s, int elemSize);
void StackDispose(Stack *s);
void StackPush(Stack *s, void *value);
void StackPop(Stack *s, void *value);

我们来看一个场景

const char* example[] = {"AI", "Bob", "Alice"};
Stack stringStack;
StackNew(&stringStack, sizeof(char*));
for(int i = 0; i < 3; i++) {
    char *name = strdup(example[i]);
    StackPush(&stringStack, &name);
}

for(int i = 0; i < 3; i++) {
   char *value = NULL;
   StackPop(&stringStack, &value);
   free(value);
}

StackDispose(&stringStack);

目前的做法是在堆空间中进行动态分配,然后将字符串数组中的元素(即字符串首地址)拷贝至堆空间。之后,在pop操作的过程,我们将获得栈顶的元素,通过free的方式将分配的内存进行释放。最后使用StackDispose操作将栈中动态分配的空间进行释放。如下图所示:

新建 Microsoft PowerPoint 演示文稿.png

但是如果我们进行如下操作:

for(int i = 0; i < 2; i++) {
    char *value = NULL;
    StackPop(&stringStack, &value);
    free(value);
}

没有将栈中的所有元素弹出,这样StackDispose在执行的过程中只会释放elems指向的空间,而elems中剩余元素指向的字符串空间并没有被释放,从而导致内存泄漏。

因此,我们目前就面临一个问题,如果栈结构体中存储的是指针类型,其可能是指向动态分配的内存空间。如果用户在pop操作的过程中每将栈中所有元素完全释放,那么在StackDispose的操作中我们需要帮助用户去进行释放。我们对栈结构体进行进一步改进。

typedef struct{
    void *elems;
    int elemSize;
    int logLength;
    int allocLength;
    void (*freeFn)(void *pointer);
} Stack;

void StackNew(Stack *s, int elemSize, void (*freeFn)(void *pointer));
void StackDispose(Stack *s);
void StackDispose(Stack *s) {
    if(s->freeFn != NULL && s->logLength > 0) {
        for(int i = 0; i < s->logLength; i++) {
            freeFn((char*)s->elems + i * elemSize);
        }
    }
    free(s->elems);
}
void freeFn(void *pointer) {
    free(*(char**)pointer);
}

这里我们实现方式是依次对栈空间中的元素进行处理,释放其动态分配的空间。另一方面,我们这里并不清楚栈中每个元素具体的分配方式,因此释放函数需要由用户来进行指定。

rotate函数

void rotate(void *front, void *middle, void *end);

捕获.PNG

如图所示,该函数的作用就是将front到middle之间的元素,与middle到end之间的元素进行交换。

这里补充两个函数的知识点

void memcpy(void *dst, const void *src, size_t n);
void memmove(void *dst, const void *src, size_t n);

这两个函数的作用都是从src拷贝n个字节的数据到dst,区别在于在目标区域与源区域有重叠的情况下,memmove会进行处理,而memcpy是未定义的。

捕获.PNG

我们将1, 2, 3, 4拷贝到destination的位置处,如果从source开始拷贝,那么1就会覆盖4导致拷贝的过程中出现问题。在这种情况下,memcpy是不会进行考虑的,但是memmove会进行处理,比如从后向前进行拷贝。由于此处我们操作的是同一个数组,所以有可能会发生覆盖的情况,所以这里使用memmove,但是通常情况下推荐使用memcpy,因为其性能更好,在编写底层代码的时候,我们更考虑性能因素。

void rotate(void *front, void *middle, void *end) {
    int fsize = (char*)middle - (char*)front;
    int bsize = (char*)end - (char*)middle;
    
    char buffer[fsize];
    memcpy(buffer, front, fsize);
    memmove(front, middle, bsize);
    memcpy((char*)front+bsize, buffer, fsize);
}

程序内存布局

捕获.PNG

我们进行的各种函数操作(函数调用、声明和使用临时变量等)都是发生在stack segment中的,栈指针会不断地向下移动,只有当函数调用返回时,栈指针才会上移。而一个函数中声明的变量,只在当前函数中是可见的。栈的内存管理是由操作系统进行的,我们不需关心。而heap segment中的操作是软件进行管理的(malloc、realloc和free等操作,具体实现在标准库中)。

对于heap segment来说,我们可以将其想象成一个线性的字节数组。该处的实现与实际有一定的差距,仅仅是为了帮助后续理解。

捕获.PNG

捕获.PNG

void *a = malloc(40);
void *b = malloc(60);

在进行内存分配的过程中,首先会从头部开始进行搜索,如果有空闲位置就直接进行分配。

free(a);

捕获.PNG

释放操作表现前40字节可以进行使用,其中的内容并没有被抹去。后续一些操作,从图中我们可以很清楚的理解。

void *c = malloc(44);

捕获.PNG

void *d = malloc(20);

捕获.PNG

在真实情况下,空闲空间是由空闲空间链表进行管理的,如下图所示(实心部分表示已经被分配的区域),每一块空闲空间相当于一个节点,该节点的头部由两部分组成,一是当前空间大小,另一个是指针(指向下一个空闲区域)。

捕获.PNG

内存管理器通过链表来依次进行判断,每个节点是否满足空间需求。本节课只是稍微介绍一下,详细内容在下一节课进行介绍。