数据结构学习笔记之链表分析与实现(三)

 

        在前面2篇文章,我们简单介绍了单链表的基本运算及其实现。同时,我们还介绍了回调函数。并将公共接口函数抽象出来,具体应用由用户提供回调函数来实现。此外,我们也注意到,采用上节提到的回调机制,当要释放链表所有元素的内存空间时,将带来些不方便或者无法直接使用。

       有没有办法让这种回调机制进行更大一步的改善呢?当然有。本文参考了李先静前辈的《系统程序员成长计划》一书,并加入些自己的见解。与该书一致,本文也以双向链表的实现为载体进行深入探讨和分析。

       本文引用的C语言代码采用了封装机制。为什么要封装?总体来说,封装主要有以下两大好处(具体影响后面再说):

       隔离变化。程序的隐私通常是程序最容易变化的部分,比如内部数据结构,内部使用的函数和全局变量等等,把这些代码封装起来,它们的变化不会影响系统的其它部分。

      降低复杂度。接口最小化是软件设计的基本原则之一,最小化接口容易被理解和使用。封装内部实现细节,只暴露最小的接口,会让系统变得简单明了,在一定程度上降低了系统的复杂度。

     如何封装?

隐藏数据结构

      暴露内部数据结构,会使头文件看起来杂乱无章,让调用者发蒙。其次是如果调用者图方便,直接访问这些数据结构的成员,会造成模块之间紧密耦合,给以后的修改带来困难。隐藏数据结构的方法很简单,如果是内部数据结构,外面完全不会引用,则直接放在C文件中就好了,千万不要放在头文件里。如果该数据结构在内外都要使用,则可以对外暴露结构的名字,而封装结构的实现细节,做法如下:

在头文件中声明该数据结构。

如:
struct _LrcPool;
typedef struct _LrcPool LrcPool;

在C文件中定义该数据结构。

      struct _LrcPool
      {
          size_t unit_size;
          size_t n_prealloc_units;
      };
      

提供操作该数据结构的函数,哪怕只是存取数据结构的成员,也要包装成相应的函数。

如:
void* lrc_pool_alloc(LrcPool* thiz);
void lrc_pool_free(LrcPool* thiz, void* p);

提供创建和销毁函数。因为只是暴露了结构的名字,编译器不知道它的大小(所占内存空间),外部可以访问结构的指针(指针的大小的固定的),但不能直接声明结构的变量,所以有必要提供创建和销毁函数。

如:
这样是非法的:LrcPool lrc_pool;
应该对外提供创建和销毁函数。
LrcPool* lrc_pool_new(size_t unit_size, size_t n_prealloc_units);
void lrc_pool_destroy(LrcPool* thiz);

任何规则都有例外。有些数据结构纯粹是社交型的,为了提高性能和方便起见,常常不需要对它们进行封装,比如点(Point)和矩形(Rect)等。当然封装也不是坏事,MFC就对它们作了封装,是否需要封装要根据具体情况而定。

隐藏内部函数

       内部函数通常实现一些特定的算法(如果具有通用性,应该放到一个公共函数库里),对调用者没有多大用处,但它的暴露会干扰调用者的思路,让系统看起来比实际的复杂。函数名也会污染全局名字空间,造成重名问题。它还会诱导调用者绕过正规接口走捷径,造成不必要的耦合。隐藏内部函数的做法很简单:

在头文件中,只放最小接口函数的声明。

在C文件上,所有内部函数都加上static关键字。

禁止全局变量

      除了为使用单件模式(只允许一个实例存在)的情况外,任何时候都要禁止使用全局变量。这一点我反复的强调,但发现初学者还是屡禁不止,为了贪图方便而使用全局变量。请读者从现在开始就记住这一准则。

全局变量始终都会占用内存空间,共享库的全局变量是按页分配的,那怕只有一个字节的全局变量也占用一个page,所以这会造成不必要空间浪费。全局变量也会给程序并发造成困难,想把程序从单线程改为多线程将会遇到麻烦。重要的是,如果调用者直接访问这些全局变量,会造成调用者和实现者之间的耦合。

在整个系统程序员成长计划中,我们都是以面向对象的方式来设计和实现的(封装就是面向对象的主要特点之一。

 

     下面,我们具体进行双向链表的实现进行分析.在头文件中,李先静老师是这么做的:

#include

#ifndef DLIST_H
#define DLIST_H

#ifdef __cplusplus
extern "C" {
#endif/*__cplusplus*/

typedef enum _DListRet
{
 DLIST_RET_OK,
 DLIST_RET_OOM,
 DLIST_RET_STOP,
 DLIST_RET_PARAMS,
 DLIST_RET_FAIL
}DListRet;

struct _DList;
typedef struct _DList DList;

typedef void (*DListDataDestroyFunc)(void* ctx, void* data);

。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。

#ifdef __cplusplus
}
#endif/*__cplusplus*/

#endif/*DLIST*/

          可见,在头文件中定义了双向链表的基本操作。包括链表结点的创建、链表结点的插入、删除、查找以及链表的建立和销毁。

          我们先看下双向链表的结构:  

typedef struct _DListNode
{
    struct _DListNode* prev;
    struct _DListNode* next;

    void* data;
}DListNode;

 

struct _DList
{
    DListNode* first;
    DListDataDestroyFunc data_destroy;
    void* data_destroy_ctx;
};

1. 双向链表中结点的插入

            设P指向双向链表中某结点, s指向待插入的值为X的新结点,将*s插入到*p的前面:

步骤如下:

(1)s->prev = p->prev

(2)p->prev->next =s

(3)s->next = p

(4)p->prev = s

指针操作的顺序不是唯一的。但是第一步必须放到第四部前完成,否者p的前驱结点的指针就丢掉了。

下面我们按照上述原理分析李先静前辈的代码:

DListRet dlist_insert(DList* thiz, size_t index, void* data)
{
          DListNode* NodeToInsert = NULL; 
//定义待插入结点
         DListNode* NodeToInsertBefore= NULL;  //在该指定结点之前插入,也就是待插入结点的后继结点

          if( ( NodeToInsert= dlist_create_node(thiz, data ) ) == NULL ) //根据传入数据,创建待插入结点
         {
               return DLIST_RET_OOM;   
//创建待插入结点失败!

        }

        if(thiz->first == NULL)  

        {
             thiz->first = NodeToInsert; //如果第一个结点为空,把该结点插入到第一个结点

             return DLIST_RET_OK;
        }

        NodeToInsertBefore= dlist_get_node(thiz, index, 1);  //查找要插入的结点的指针位置,也就是说在该结点指针之前插入

        if(index < dlist_length(thiz))  //如果索引序号值小于链表长度,则是在链表中插入

       {
              if(thiz->first == NodeToInsertBefore) //如果第一个结点就是插入位置
             {
                    thiz->first = NodeToInsert;  //则把第一个结点替换为待插入结点            

             }
            else
            {
                   NodeToInsertBefore->prev->next = NodeToInsert; 
//前面讲述规则的第二步,使得指定结点的前驱的后继指向待插入结点
                   NodeToInsert->prev = NodeToInsertBefore->prev;
//前面讲述规则的第一步,使得待插入结点的前驱指向指定结点的前驱
            }
           NodeToInsert->next = NodeToInsertBefore;  //前面讲述规则的第三步,使得待插入结点的后继指向指定结点

          NodeToInsertBefore->prev = NodeToInsert;  //前面讲述规则的第四步,使得指定结点的前驱指向待插入结点
      }
     else  //如果索引序号值大于或等于链表长度,说明是在链表末尾插入
    {
         NodeToInsertBefore->next = NodeToInsert;
         NodeToInsert->prev = cursor;
     }

    return DLIST_RET_OK;
}

 

1. 双向链表中结点的删除

            设P指向双向链表中某结点, 将P从链表从删除:

步骤如下:

(1)p->prev->next = p->next, 修改前驱结点的后继结点

(2)p->next->prev =p->prev,修改后继结点的前驱结点

   (3) free(P) 释放内存空间

下面我们按照上述原理分析李先静前辈的代码:

 

DListRet dlist_delete(DList* thiz, size_t index)
{
         DListNode* NodeToDel= dlist_get_node(thiz, index, 0); //查找待删除结点

        if(  NodeToDel!= NULL )
       {
               if( NodeToDel == thiz->first ) //如果待删除结点是第一个结点
              {
                     thiz->first = NodeToDel->next; //那么直接使得第一个结点指针指向待删除结点
              }

              if( NodeToDel->next != NULL ) //如果待删除结点有后继结点

              {
                    NodeToDel->next->prev = NodeToDel->prev;  //前面所述规则的第二步,修改后继结点的前驱结点
              }

              if( NodeToDel->prev != NULL )  //如果待删除结点有前驱结点
              {
                       cursor->prev->next = cursor->next; //前面所述规则的第一步,修改前驱结点的后继结点
               }

                dlist_destroy_node(thiz, cursor);   //释放内存空间, 前面所述规则的第三步
         }

         return DLIST_RET_OK;
}

 

     

      

你可能感兴趣的:(数据结构学习笔记之链表分析与实现(三))