使用 Go 设计双向链表,遵循接口与实现分离原则

csdn_myx 2022-04-06 23:20:56

一、设计链表节点

定义链表节点结构体本内容(位于 LinkTable.go 中):

// 链表节点的值类型
type ValType = int

// 链表节点
type Node struct {
    val  ValType // 节点值
    prev *Node   // 前节点
    next *Node   // 后节点
}

func NewNode(v ValType) *Node {
    return &Node{v, nil, nil}
}

我将 LinkTable 定义为双向链表,因此每个节点中包含前向指针和后向指针。

由于不太了解 Go 如何更好实现泛型,我采用类型别名的方式将节点的值类型定义为 ValType,暂时实现为 int类型。

然后节点的构造利用工厂方法 NewNode 完成,前向指针和后向指针初始为空指针。

二、 设计 LinkTable 接口

为了实现接口与实现分离,首先定义 LinkTable 接口,该接口定义了的所有需要完成的功能接口。本内容位于 LinkTable.go 中。

Go 接口的语法如下:

/* 接口语法 */
type interface_name interface {
   method_name1 [return_type]
   method_name2 [return_type]
   method_name3 [return_type]
   ...
   method_namen [return_type]
}

LinkTable 接口定义如下:

// 链表接口设计
type LinkTable interface {
  /* 基本操作 */
    Empty() bool
    Size() int
    Head() (*Node, error) // 获取头节点
    Tail() (*Node, error) // 获取尾节点
  Clear() // 清空链表
    ToString() string

    /* 插入节点 */
    Insert(*Node)                               // 在尾节点后插入给定节点
    InsertVal(ValType)                          // 插入一个给定值的节点
    InsertBefor(node1 *Node, node2 *Node) error // 在 node1 后插入 node2
    InsertAfter(node1 *Node, node2 *Node) error // 在 node1 前插入 node2

    /* 删除节点 */
    Delete(*Node) error
    DeleteFirst(ValType) error // 删除第一个等于给定值的节点

    /* 查找节点 */
    FindFirst(ValType) (*Node, error) // 返回第一个等于给定值的节点
}

包括如下几类功能模块:

  1. 基本功能函数

    包括获取链表长度、头尾节点等基本操作。为了方便打印 LinkTable 的内容,我还实现了一个 ToString 方法,方便调试测试时输出链表中节点值。

  2. 插入节点

    实现了四种不同接口,分别是:按指针插入,按值插入,给定节点前插入,给定节点后插入。

  3. 删除节点

    实现了两种不同接口,分别是:按指针删除,删除从头到尾首个等于给定值的节点。

  4. 查找节点

    查找从头到尾首个等于给定值的节点。

注意事项:

  • 定义接口时,遵循 Go 常用风格,返回错误码,例如尝试对空链表调用Head()获取头节点时将返回非空错误码。
  • 由于节点的修改操作可以直接利用查找操作完成,因此没有额外定义修改方法。

三、 定义 LinkTable 实现

Go 实现面向对象设计的方式与 python 相似,都是采用所谓 "duck type" 的设计哲学。具体来说,就是只要一个类实现了某接口中定义的所有方法,就可以称这个类实现该接口。

在 Go 中结构体即为类,后面不做区分。我们需要完成的就是定义一个类,实现上节所述的所有接口方法。

  • 我们将具体实现类定义为 ConcretLinkTable,其数据成员如下:

    /* 定义具体实现 */
    type ConcretLinkTable struct {
        count int   // 节点数
        head  *Node // 头节点
        tail  *Node //尾节点
    }
    

    注意到我们使用 count 来统计节点个数以便实现 O(1) 的长度计算,故在插入和删除节点操作时,我们需要维护该计数的正确性。

    head 和 tail 分别指向链表的头节点和尾节点。当链表为空时,头节点和尾节点都为 nil。头节点的前向指针和尾节点的后向指针永远为 nil。

  • Go 的方法与普通函数相比,虽然也是定义在结构体外面,但其语法定义有所不同,因为我们需要额外传递 ”this“ 指针参数,其语法如下:

    func (this_name data_type) function_name() [return_type]{
       /* 方法体*/
    }
    

​ 其特殊之处在于,func关键字后面额外多了一个变量定义,该变量即为我们的类对象 (C++ 中的 this)。对象方法的调用采用常见的obj.func()的形式。

接下来看具体的方法实现(位于 ConcretLinkTable.go 中):

  1. 基本函数

    func (lt *ConcretLinkTable) Empty() bool {
        return lt.head == nil
    }
    
    func (lt *ConcretLinkTable) Size() int {
        return lt.count
    }
    
    func (lt *ConcretLinkTable) Head() (*Node, error) {
        if !lt.Empty() {
            return lt.head, nil
        }
        return nil, errors.New("LinkTable: No head node exists!")
    }
    
    func (lt *ConcretLinkTable) Tail() (*Node, error) {
        if !lt.Empty() {
            return lt.tail, nil
        }
        return nil, errors.New("LinkTable.Tail: No head node exists!")
    }
    
    func (lt *ConcretLinkTable) Clear() {
        lt.count = 0
        lt.head = nil
        lt.tail = nil
    }
    
    func (lt *ConcretLinkTable) ToString() string {
        s := "LinkTable: [ "
        if !lt.Empty() {
            for cur := lt.head; cur != lt.tail; cur = cur.next {
                s += fmt.Sprintf("%d, ", cur.val)
            }
            s += fmt.Sprintf("%d", lt.tail.val)
        }
        s += " ]"
        return s
    }
    

    基本功能函数的实现较为简单,只是需要注意错误处理即可。

  2. 插入节点

    /* 插入节点 */
    
    func (lt *ConcretLinkTable) Insert(node *Node) {
        if lt.Empty() { // 原链表为空
            node.prev = nil
            lt.head = node
        } else {
            node.prev = lt.tail
            lt.tail.next = node
        }
        lt.tail = node
        lt.tail.next = nil
        lt.count++ // 节点数自增
    }
    
    func (lt *ConcretLinkTable) InsertVal(val ValType) {
        lt.Insert(&Node{val, nil, nil})
    }
    
    // 注意:node1 应该是 lt 中的节点!
    func (lt *ConcretLinkTable) InsertBefor(node1 *Node, node2 *Node) error {
        if node1 != nil && node2 != nil {
            node2.prev = node1.prev
            node1.prev = node2
            node2.next = node1
            if node2.prev != nil {
                node2.prev.next = node2
            }
            lt.count++
            return nil
        }
        return errors.New("LinkTable.InsertBefor: node is nil!")
    }
    
    // 注意:node1 应该是 lt 中的节点!
    func (lt *ConcretLinkTable) InsertAfter(node1 *Node, node2 *Node) error {
        if node1 != nil && node2 != nil {
            node2.next = node1.next
            node1.next = node2
            node2.prev = node1
            if node2.next != nil {
                node2.next.prev = node2
            }
            lt.count++
            return nil
        }
        return errors.New("LinkTable.InsertAfter: node is nil!")
    }
    

    注意事项:

    • 成功插入节点后需要将 count 自增

    • 注意向空链表插入首个节点的边界条件,需要对头尾指针进行特殊处理

    • InsertVal(按值插入)的实现可以直接借助Insertvet(按节点插入)间接完成

    • 对于InsertBeforInsertAfter需要特别注意的是,第一个参数节点必须是该链表中的节点,否则将会导致异常行为。举个例子(伪码表示):

      L1: [1,2,3], L2: [3,4,5]
      L1.size() == 3, L2.size() == 3
      
      L1.InsertAfter(L2.tail, node(6)) // 异常行为
      
      L1: [1,2,3], L2: [3,4,5,6]
      L1.size == 4(), L2.size() == 3
      

      我们在链表对象 L1 上调用 InsertAfter 之后,出现诡异的结果:L1的实际结构没变,L2却多了一个节点,但我们的 count 计数却是在 L1 上进行了自增。

      虽然这样设计很不好,对使用者带来负担,但我不知道该如何禁止这种异常的出现,而且 C++ STL 中的 list.insert 也会出这种情况。因此,虽然我们采取了一定的错误检测机制,但仍然可能出现异常情况。

  3. 删除节点

    /* 删除节点 */
    
    // 注意:node 应该是 lt 中的节点!
    func (lt *ConcretLinkTable) Delete(node *Node) error {
        if node != nil {
            if node.prev != nil {
                node.prev.next = node.next
            } else { // node 为头节点
                lt.head = lt.head.next
            }
    
            if node.next != nil {
                node.next.prev = node.prev
            } else { // node 为尾节点
                lt.tail = lt.tail.prev
            }
    
            lt.count--
            return nil
        }
        return errors.New("LinkTable.Delete: node is nil!")
    }
    
    func (lt *ConcretLinkTable) DeleteFirst(val ValType) error {
        cur, err := lt.FindFirst(val)
        if err == nil {
            lt.Delete(cur)
            return nil
        }
        return errors.New("LinkTable.DeleteFirst: No node with the given value!")
    }
    

    注意事项

    • 和插入方法类似,我们需要对 count 进行自减操作,同时用户需要保证Delete的参数为本链表中的节点。
    • 注意删除头节点、删除尾节点、输出最后一个节点等边界条件时的处理,需要对头指针和尾指针的值进行特殊处理
    • 删除操作可以调用查找操作实现
  4. 查找节点

    /* 查找节点 */
    
    func (lt *ConcretLinkTable) FindFirst(val ValType) (*Node, error) {
        cur := lt.head
        for i := 0; i < lt.count; i++ {
            if cur.val == val {
                return cur, nil
            }
            cur = cur.next
        }
        return nil, errors.New("LinkTable.FindFirst: No node with the given value!")
    }
    

    注意事项:

    • 注意链表为空的边界条件处理

四、测试

Go 集成了testing测试模块,定义了了一些测试用例,完成单元测试。

testing使用注意事项:

  • 测试文件名必须以_test尾后缀(大小写敏感),否则无法被识别

  • 测试用例函数名均以Test为前缀,形式为:func TestLinkTable(t *testing.T)

  • 测试断言函数包括 Error/ErrorF 和 Fatal/FatalF,前者为非致命错误,检测到错误后仍继续执行其它测试用例;后者为致命错误,检测到后直接终止。

  • 注意所有测试用例的执行顺序是不确定的,因此不要在测试用例之间存在顺序依赖。

  • 测试执行命令:go test -v

  • 测试示例如下(完整内容位于 LinkTable_test.go 中):

    // Insert
    for _, num := range nums {
      lt.InsertVal(num)
    }
    if lt.ToString() != "LinkTable: [ 2, 1, 2, 2, 5, 3, 5, 7 ]" {
      t.Error(lt.ToString())
    }
    if lt.Size() != 8 {
      t.Error(lt.Size())
    }
    

    最终测试结果,测试用例均通过:

五、总结

为了使得程序具有较好的扩展性,遵循面向接口编程原则,将接口与实现相分离。

接口的定义需要考虑使用的易用性与实现的复杂性。

实现时需要考虑到注意错误的检测处理,以及一些边界条件(例如插入节点到空链表)的处理。

最后还需要进行一些必要的单元测试以验证我们的实现的正确性。

待改进之处:

  1. 尝试更好的 ”泛型“ 支持
  2. 补充和完善功能函数
  3. 构建更多边界条件的测试样例,提高测试覆盖率
  4. 利用互斥锁等机制实现线程安全

六、项目地址

文件目录:

  1. LinkTable.go:定义接口
  2. ConcretLinkTable.go:定义实现类
  3. LinkTable_test.go:测试文件

仓库地址:https://github.com/csmyx/LearnGo

作者:357

...全文
215 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

571

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧