Golang Iterator 設計與實現

【導讀】go 語言實現迭代器模式有什麼思路?本文介紹了 4 種迭代器實現。

在學習《算法》這本書時,第一章有一個 Bag 的數據結構,它定義爲不能刪除元素的集合。之前看到一篇文章,說數據結構底層結構其實只有數組與鏈表,所以在實現 Bag 時想使用數組和鏈表分別實現。

但是在設計 Bag API 時發現,該如何提供一個通用的遍歷方式給第三方調用?因爲不像 Java,Golang 並未提供一個迭代接口,只是原生語法上支持對一些容器的遍歷,如下所示:

// 如果 container 是數組或者 Slice,那麼 k => index,v => 數組元素;
// 如果 container 是 Map,那麼 k => key,v => value;
for k, v := range container {
}

// 對於 channel 也是使用 range 方式,但是返回值不同
for item := range channel {
}

所以對於自定義結構,需要設計一個通用的 API 進行遍歷,即 Iterator 模式。對於 Golang 來實現一些數據結構,Iterator 模式是比較重要的。

這裏先設定 Bag 兩種不同底層存儲結構的定義:

// 使用數組做底層存儲
type arrBag struct {
    data []interface{}
    size int
}

// 使用鏈表做底層存儲
type node struct {
    data interface{}
    next *node
}
type linkBag struct {
    node *node
    size int
}

在參考了幾篇博主的文章後,基於自身理解想到以下幾種實現方式。

模式 1:回調機制

定義一個 Iterable 接口,如下:

// if need to break, return error
type IterableCallback func(interface{}) error

type Iterable interface {
    Iterator(IterableCallback)
}

然後在對象中實現對應的 Iterator() 方法,改方法接受 callback 函數作爲參數,默認會把 Bag 的每個數據迭代然後作爲 callbak 參數傳入,如果 callback 返回 error,那麼就會中斷迭代。

// Array Bag
func (b *arrBag) Iterator(cb IterableCallback) {
    var err error
    for i := 0; i < len(b.data); i++ { // b.data =[]interface{}
        if err = cb(b.data[i]); err != nil {
            break
        }
    }
}

// Link Bag
func (b *linkBag) Iterator(cb IterableCallback) {
    var err error
    for item := b.first; item != nil; item = item.next {
        if err = cb(item); err != nil {
            break
        }
    }
}

那麼對於調用方來說,把對迭代操作通過 callback 函數傳遞過來即可。

b := NewBag()

// case 1: find a item
var found interface{}
b.Iterator(func(item interface{}) error {
    if item == matched {
        found = item
        return errors.New("iterator break")
    }
    return nil
})

// case 2: sum
var sum int
b.Iterator(func(i interface{}) error {
    item := i.(int)
    sum += item
    return nil
})

這種實現方式簡單明瞭,很好的利用 Golang 的函數編程特性,但有一個不好的點是需預先定義好函數類型以及參數類型,不太好擴展。不過整體看這個無傷大雅,畢竟大部分情況是可以滿足的。

模式 2:閉包

類似 Python 或 JavaScript 的 generator 方式,使用閉包來控制迭代進度。

// For Array underlying implementation
func (b *arrBag) Iterator() (func() (interface{}, bool), bool) {
    index := 0
    var item interface{}
    return func() (interface{}, bool) {
        if index < a.size {
            item = a.data[index]
            index++
        } else {
            item = nil
        }
        return item, index < a.size
    }, index < a.size
}

// For Link underlying implementation
func (b *linkBag) Iterator() (func() (interface{}, bool), bool) {
    item := b.node
    return func() (interface{}, bool) {
        current := item
        if current != nil {
            item = current.next
        }
        return current, item != nil
    }, item != nil && item.next != nil
}

使用示例:

it, hasNext := a.Iterator()
for hasNext {
    item, hasNext = it()
    fmt.Printf("%v\n", item)
}

模式 3:使用 chan

從開頭我們知道,除了 slice 和 map 的 for-range 形式外,Golang 也提供對 channel 的 for-range 語句操作。根據此特性,也可以通過 channel 來實現迭代。

首先定義一個通用的 interface:

type ChanIterable interface {
    Chan(context.Context) chan interface{}
}

這裏的 context 主要是爲了控制終止迭代,也可以使用特定的 chan,譬如 stop := make(chan struct{}),把 stop 傳入。雖然 Golang 推薦使用 context 作爲 API 設計的第一個參數,但是這裏感覺其實使用 chan 更好,因爲調用者會有意識的做 close(chan) 動作,而對於 context,估計會漏掉 cancel context 操作。

方法定義:

// For Array underlying implementation
func (b *arrBag) Chan(ctx context.Context) <-chan interface{} {
    buf := make(chan interface{})
    go func() {
        defer close(buf)
        for _, item := range b.data {
            select {
            case <-ctx.Done():
                return
            default:
                buf <- item
            }
        }
    }()
    return buf
}

// For Link underlying implementation
func (b *linkBag) Chan(ctx context.Context) <-chan interface{} {
    buf := make(chan interface{})
    node := b.node
    go func() {
        defer close(buf)
        for node != nil {
            select {
            case <-ctx.Done():
                return
            default:
                buf <- node.data
                node = node.next
            }
        }
    }()
    return buf
}

那麼使用上,可以這樣:

ctx, cancel := context.WithCancel(context.Background())
// defer cancel() // 如果需要遍歷所有,可以把 cancel() 放到 defer 處
for item := range ab.Chan(ctx) {
    if item == matched {
        cancel() // 保證中斷遍歷,資源回收
        return
    }
}

這裏的實現是無緩衝的 chan,爲了提高性能,也可以使用有緩衝。但是對於有緩衝的 chan,我們在控制內部 close 後需要消費掉緩衝區內的數據,否則依然有內存浪費。所以對於有緩衝,雖然性能提升(做了 benchmark,可以看到性能提升的不高),但需要多餘的循環,同時不好控制何時結束,所以這裏不建議使用有緩衝 channel。

模式 4:有狀態的遍歷

有狀態其實是參考 Java 這種面向對象模式設計,創建一個單獨指向源數據區的指針,並且記錄遍歷的進度。

type IteratorObj interface {
    HasNext() bool
    Next() interface{}
}

然後,定義一個 Iterable 接口,對於對象就需要實現此接口,它會生成一個新的對象。使用上,每次需要遍歷時,都需要創建一個新的 IteratorObj

type Iterable interface {
    GenIterator() IteratorObj
}

代碼示例如下:

// 底層採用數組存儲數據
type objArrayIterator struct {
    index int
    data  []interface{}
}

func (b *arrBag) GenIterator() iterable.IteratorObj {
    return &objArrayIterator{
        index: 0,
        data:  b.data,
    }
}

func (it *objArrayIterator) HasNext() bool {
    return it.index < len(it.data)
}

func (it *objArrayIterator) Next() interface{} {
    var result interface{}
    if it.index < len(it.data) {
        result = it.data[it.index]
        it.index++
    }
    return result
}

// 底層採用鏈表存儲數據
type objLinkIterator struct {
    iNode *node
}

func (b *linkBag) GenIterator() iterable.IteratorObj {
    return &objLinkIterator{
        iNode: b.first,
    }
}

func (it *objLinkIterator) HasNext() bool {
    return it.iNode != nil
}

func (it *objLinkIterator) Next() interface{} {
    result := it.iNode
    if it.iNode != nil {
        it.iNode = it.iNode.next
    }
    return result
}

使用示例如下:

ab := newArrBag(10)
it := ab.GenIterator()
for it.HasNext() {
    fmt.Printf("%v\t", it.Next())
}

這種方式實現相對前三種有點囉嗦,不過從 Java 這類編程語言過來的同學可能會感覺比較熟悉。

總結

從最初的目的:實現統一的 iterator API 來看,以上四種實現方式從 API 設計上都無可挑剔。

在參考的幾篇文章裏,有些實現方法都有一個通用問題,就是該如何保證中斷遍歷時能很好的回收資源,譬如使用 chan 模式的時候,內部實現改如何在最後 close 掉。但在上面實現的幾種模式裏,都保證最後能資源回收,不會出現此類問題。

最後通過 Golang 的 benchmark 來對比四種實現方式哪種更優。

#  go test -benchmem -bench ^Benchmark -run ^$
BenchmarkIterateBag_Callback_Array-8             429       2500652 ns/op           0 B/op           0 allocs/op
BenchmarkIterateBag_Callback_Link-8              375       3479282 ns/op           0 B/op           0 allocs/op
BenchmarkIterateBag_Channel_Array-8                6     220628550 ns/op         176 B/op           1 allocs/op
BenchmarkIterateBag_Channel_Link-8                 5     207023700 ns/op         200 B/op           2 allocs/op
BenchmarkIterateBag_Closure_Array-8              243       4789662 ns/op          56 B/op           3 allocs/op
BenchmarkIterateBag_Closure_Link-8               351       3945431 ns/op          24 B/op           2 allocs/op
BenchmarkIterateBag_Objective_Array-8            278       4335142 ns/op          32 B/op           1 allocs/op
BenchmarkIterateBag_Objective_Link-8             240       4364060 ns/op           8 B/op           1 allocs/op

從性能上看,使用 callback 方式更優,且沒有額外的內存分配,並且 interface 設計更爲簡單、優雅。

對於使用 chan 性能更差,覺得主要原因是雖然只有兩個 goroutine 存在,但是對於 chan 的分配以及 goroutine 調度,對性能還是有點影響。

轉自:

segmentfault.com/a/1190000022293531

Go 開發大全

參與維護一個非常全面的 Go 開源技術資源庫。日常分享 Go, 雲原生、k8s、Docker 和微服務方面的技術文章和行業動態。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/R_7eN-moXiLVHAECPxwKXA