標準化之路:Go 1.23 中的迭代器

標準化之路:Go 1.23 中的迭代器

Ian Lance Taylor 在 "Range Over Function Types" 這篇文章聊到 iterator 誕生的原因。如果我們有兩個容器,稱為集合(Set),想要取得這兩個集合中的不重複元素,加到新的集合中形成聯集,我們可以寫個 Union 函式來執行

// Set holds a set of elements.
type Set[E comparable] struct {
    m map[E]struct{}
}

// Union returns the union of two sets.
func Union[E comparable](s1, s2 *Set[E]) *Set[E] {
    r := New[E]()
    // Note for/range over internal Set field m.
    // We are looping over the maps in s1 and s2.
    for v := range s1.m {
        r.Add(v)
    }
    for v := range s2.m {
        r.Add(v)
    }
    return r
}

顯然,Union 函式要可以存取容器元素,換言之,容器(Set)的開發者需要設想使用者可能使用的「任何」情境,例如交集 / 互斥聯集 / 篩選,然後在函式庫中實現它們--這當然不現實。或者,開發者需要曝露容器元素,讓使用者自行存取。

不是所有開發者都願意曝露底層,從設計上來講也顯得多餘,明明使用者需要的只是迭代,沒道理要求開發者曝露結構。理想方案是,開發者能在隱藏容器元素的同時,也讓使用者進行操作。於是我們來到了一個問題:是不是有辦法將儲存資料的方式視為單純的容器,分離算法與結構?

用函式參數來分離

最簡單的方式,是讓容器的 method 支援迭代,而實際的動作由外面傳入。改寫 Taylor 的範例,變成

func (s *Set[E]) Visit(f func(E) bool) {
    for v := range s.m {
        if !f(v) {
            return
        }
    }
}

Visit 負責訪問元素,實際的操作則交給 f。在 Go 的標準庫中,sync.Map.Range 就是用這樣的方法實現

func (ht *HashTrieMap[K, V]) iter(i *indirect[K, V], yield func(key K, value V) bool) bool {
	for j := range i.children {
		n := i.children[j].Load()
		if n == nil {
			continue
		}
		if !n.isEntry {
			if !ht.iter(n.indirect(), yield) {
				return false
			}
			continue
		}
		e := n.entry()
		for e != nil {
			if !yield(e.key, e.value) {
				return false
			}
			e = e.overflow.Load()
		}
	}
	return true
}

有個 for 迴圈會不斷取出元素,交給 yield 進行處理。

在 1.23 版以前,Go 的函式庫開發者需要自行設計類似的函式,不同人設計的函式會有不同的參數形式,多少會帶來一點混亂,例如,flag.Visit 的用法是

// Visit visits the flags in lexicographical order, calling fn for each.
// It visits only those flags that have been set.
func (f *FlagSet) Visit(fn func(*Flag)) {
	for _, flag := range sortFlags(f.actual) {
		fn(flag)
	}
}

與 sync.Map.Range 不同,flag 中不存在中止條件,因此它的函式參數 fn 不需要有回傳值。在 flag.Visit 中,每個元素都會被迭代到。

標準化的形式

我猜想形式上的分歧為生態系帶來一些困擾,這應該是 1.23 加入 iterator 的主要原因,Taylor 在文章中提到

We want to improve the Go ecosystem by developing standard approaches for looping over containers.

我們希望通過開發標準方法來遍歷容器,以改進 Go 生態系。

如果 iterator 是個常見的模式,與其讓開發者自行設計,不如從語言上原生支援。有意思的是,Go 團隊選擇擴展 for/range 的表達式,讓它能支援容器迭代的語意

As we all know, Go has container types that are built in to the language: slices, arrays, and maps. And it has a way to access the elements of those values without exposing the underlying representation: the for/range statement.

如我們所知,Go 語言內建了容器類型:切片、陣列和映射。它也有一種方法可以存取這些值的元素,而不暴露底層的表示方式:for/range 語句。

讓我們想想 map 等原生容器,這好像是個很自然的選擇,map 的使用者無需知道底層是 hash table,資料存放在一個一個的 bucket 中,他只要用 for/range 迭代 map 中的元素即可。當然,map 能有語法支援的原因,是因為它是原生容器,但要解決這問題也不難,只要擴展原本的 for/range 表達式,開發者定義的容器也能被納入支援範圍。

從 1.23 版開始,for/range 可以對 function 進行迭代,這個可迭代的 function 稱為迭代器函式。迭代器函式的參數是另一個 function,依照慣例,稱為 yield 函式

用個範例來說明,假設函式庫的開發者想讓使用者能迭代元素,它可以提供一個迭代器函式

// All is an iterator over the elements of s.
func (s *Set[E]) All() func(yield func(E) bool){
    return func(yield func(E) bool) {
        for v := range s.m {
            if !yield(v) {
                return
            }
        }
    }
}

而使用者則能使用 for/range 來迭代

  for e := range set.All() {
      fmt.Println(e)
  }

在上面的例子中,All() 回傳迭代器函式,你可能會奇怪,yield 函式在哪呢?答案是在 for/range 的兩個括號中。Go 編譯器遇到 for/range 後面接迭代器函式時,會將迴圈主體的部分,編譯成一個 func,並傳給迭代器函式。它等義於

  // compile to yield func 
  yield := func(e int) bool {
      fmt.Println(e)
      return true
  }

  // pass to iterator func
  for e := range set.m {
      if !yield(e) {
          return
      }
  }

可以想像編譯器需要多做很多事來支援該語法,但使用者不需要理會複雜的實現細節,只要知道,這是個類似語法糖的設計,當 for/range 後面接 iterator 函式時,迴圈主體會被打包成 yield 函式。

true or false,這是個問題

在前面的討論中,我們提到 yield 函式會返回 bool,而迭代器可以用這個 bool 來判斷是否繼續迭代。可是,依照原本的語意,如果直接在 for/range 中 return true/false,它的作用域(scope)應該是 for/range 所在的函式,而非 yield 函式。白話點講,如果 for/range 的 statement 會被打包成 yield 函式,那當其中存在 continue / break 時,會發生什麼呢?

Taylor 的敘述有些模糊,但可以理解

When used with a for/range statement, the for/range statement will ensure that if the loop exits early, through a break statement or for any other reason, then the yield function will return false.

當與 for/range 陳述式一起使用時,for/range 陳述式會確保如果迴圈提前結束,無論是透過 break 陳述式或其他原因,yield 函式都會回傳 false。

更明確的解釋要看 rsc 在 Github 中的討論串

In fact, the Go compiler would effectively rewrite the second form into the first form, turning the loop body into a synthesized function to pass to t.All. However, that rewrite would also preserve the “on the page” semantics of code inside the loop like breakcontinuedefergoto, and return, so that all those constructs would execute the same as in a range over a slice or map.

事實上,Go 編譯器會將第二種形式有效地重寫為第一種形式,將迴圈主體轉換成一個合成函式傳遞給 t.All。然而,這種重寫也會保留迴圈內部程式碼的「頁面上」語意,如 break 、 continue 、 defer 、 goto 和 return ,使得所有這些結構的執行結果與對切片或映射使用 range 時相同。

是的,透過編譯器的魔法,持續執行的迴圈將被視為 true,而中斷執行的迴圈則會是 false,同時,所有迴圈內的關鍵字保留原本的語意。break 意思是中斷迴圈,這會導致 yield 返回 false,進而讓迭代不再執行;而 continue,會跳過 yield 函式中沒執行的段落,開始下一次迭代。

小結:分歧的社群意見

Go 1.23 引入 iterator,讓容器可以用同樣的方式來迭代,開發者有標準可以依循,對生態系來說可能是件好事,但它同時也引發一些爭議。fasthttp 的作者 Aliaksandr Valialkin 發文指出

Go was known as easy-to-read-and-understand code with explicit code execution paths. This property breaks irreversibly in Go1.23 :( What we get in exchange? Yet another way to iterate over types, which has non-trivial implicit semantics.

Go 以易讀易懂且具有明確代碼執行路徑著稱。這一特性在 Go1.23 中被不可逆地破壞了 :( 我們換來了什麼?又一種迭代類型的方法,具有隱晦的隱式語義。

Valialkin 的批評確實有道理,如果你在閱讀 for/range 語句時跟我有同樣的困惑,覺得它太像魔法了,問題大概在於

It implicitly wraps the loop body into an anonymous function and implicitly passes this function to the push iterator function.

隱式地將迴圈主體包裝成匿名函式,並隱式地將此函式傳遞給推送迭代器函式。

It implicitly calls the anonymous pull function and passes the returned results to the loop body.

隱式調用匿名的 pull 函數,並將返回的結果傳遞給循環體。

It implicitly transforms returncontinuebreakgoto and defer statements into another non-obvious statements inside the anonymous function passed to the push iterator function.

隱式地將 return、continue、break、goto 和 defer 語句轉換為傳遞給 push 迭代器函數的匿名函數內的其他不明顯的語句。

在程式設計領域,優美有兩種意思,一種是語意精確,能明確傳達意圖;另一種是語法簡潔,用最少字數表達複雜概念。iterator 的確讓語法顯得更簡潔,但因為它有許多隱式轉換,降低了語意的精確性。使用者通常會認為 for/range 是命令式語句,可是實際上,當它後面放置 iterator 函式時,它更像是函數式語言--要正確理解 iterator 的行為,會需要付出更多認知成本。

這篇文章中,我將 Go 的目標群眾分為「開發者」與「使用者」,這是從函式庫的角度來區分,因為兩者受到的影響有明顯差別。如果你是一名函式庫開發者,特別是跟資料結構有關的函式庫,iterator 為你帶來可供依循的方向,你將可以用更低的成本,支援更多的行為;而如果你是函式庫的使用者,可能會在閱讀時遇到一些問題,希望看完這篇文章後,能更知道該如何理解它。

Reference

Read more

CDN 的快取失效設計:內容平台場景

CDN 的快取失效設計:內容平台場景

Phil Karlton 有句名言:「計算機科學中只有兩件難事:快取失效和命名。」 想像你在管理網站,因為傳輸速度與伺服器效能問題,網站讀取速度很差,特別是當你的使用者來自地球另外一端,常常需要等待幾秒才能看到畫面,這讓他們的使用體驗大打折扣。身為一名重視使用體驗的開發者,你肯定知道該如何解決這問題,沒錯,答案就是 CDN(內容傳遞網路)。 CDN 可以看成是服務商在全球各地建置伺服器,當你的網站內容(例如圖片、CSS、JavaScript、影片等)流經這些伺服器時,它會保留一份複本(稱為快取),等到下次有人讀取同樣的內容,CDN 會拿出複本給使用者。因為全球各地都有 CDN 節點,美國的使用者可以由美國節點提供,日本的使用者可以由日本節點提供。這樣既加速網路傳遞效率,也降低來源伺服器的效能壓力,可謂一舉兩得。 當然這是有條件的。CDN 會使用網址來判斷快取是否是相同檔案,假設你的內容以圖片為主,通常來說,當你更換圖片,新舊兩張圖片會有不同網址,被當成兩個不同的檔案,新圖使用新快取,舊快取留著也沒差;但如果你的內容是文字,新舊版文字很可能有相同網址,

By Ken Chen
收拾行李搬家去:從 Medium 到 Ghost

收拾行李搬家去:從 Medium 到 Ghost

想搬家想很久,連身邊的朋友都搬完了,我還沒動工。 原因是我懶,我討厭麻煩,每次有人問我吃什麼,我都回答麥當勞。搬家是一件麻煩事,我已經有一份很讚的工作了,全副精神都放在工作上,偶爾才會想起來,反正家什麼時候都能搬,一點也不急,有什麼好急的呢對吧。這樣一拖,就拖到現在。 繼續用 Medium 不好嗎? 跟男女朋友分手一樣,通常被問到:「對方不好嗎?」得到回答是:「也沒有不好啦,只是……(以下開放填空)。」 從優點開始講吧!Medium 的編輯器很棒,它是 WYSIWYG(所見即所得)類型的編輯器,能讓創作者快速發佈內容,也因為它讓內容發佈更容易了,它開始吸引一批優秀的創作者,這批創作者持續創作內容,又吸引來更多讀者,更多讀者激勵創作者產出內容,內容又再吸引讀者……這形成一個增強迴圈。Medium 還能支援多人協作,拜它時尚簡約的風格所賜,科技公司會使用 Medium 來打造品牌形象,例如我前公司的 Tech Blog

By Ken Chen
OpenTelemetry 的可觀察性工程:以 Sentry 為例

OpenTelemetry 的可觀察性工程:以 Sentry 為例

點進 OpenTelemetry 的官方文件,它最先映入眼中的句子是「什麼是 OpenTelemetry」。例如,它是套可觀察性框架,用於檢測、蒐集與導出遙測數據;它是開源且供應商中立,能搭配其他的開源工具,像 Jaeger 或 Prometheus;它能將應用程式與系統儀表化,無關是用 Go 還是 .NET 開發,也無關部署在 AWS 還是 GCP 上。 但是身為一名開發者,當下我們想的是:「公司常開發一些沒人要用的功能,聽說 OpenTelemetry 可以提高可觀察性,也許我們應該放棄開發功能,轉頭建立更好的開發環境。」「AWS 常常要不到需要的數據,也許我們應該改用另一套工具,像是 OpenTelemetry,來解決這件事。」我們想像 OpenTelemetry 「應該」要能解決目前面臨到的一些問題,就像在技術的鏡像中尋找願望一樣。 如果已經有在用 Sentry,還需要導入 OpenTelemetry

By Ken Chen
OAuth 2.0 的身份認證:OpenID Connect

OAuth 2.0 的身份認證:OpenID Connect

OAuth 2 讓網路服務可以存取第三方的受保護資源,因此,有些開發者會進一步利用 OAuth 2 來進行使用者認證。但這中間存在著一些語義落差,因為 OAuth 2 當初設計目的是「授權」而不是「認證」,兩者關注的焦點會有些不同。OpenID Connect 是基於 OAuth 2 的一套身份認證協定,讓開發者可以在 OAuth 2 授權的基礎上,再加入標準的認證流程。在這篇文章中,我會說明授權跟認證的場景有何差異,並講解 OpenID Connect 如何滿足認證需求。 因為 OpenID Connect 是建構在 OAuth 2 的基礎上,我會假設這篇文章的讀者已經知道 OAuth 2 的組件與流程,如果你不熟悉,可以先閱讀另外兩篇文章 * OAuth 2.0:

By Ken Chen