這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。## 前序(Prelude)本系列文章總共四篇,主要協助大家理解 Go 語言中一些文法結構和其背後的設計原則,包括指標、棧、堆、逃逸分析和值或者指標傳遞。這是最後一篇,重點介紹在代碼中使用值和指標的資料和語義的設計哲學。以下是本系列文章的索引:1. [Go 語言機制之棧與指標](https://studygolang.com/articles/12443)2. [Go 語言機制之逃逸分析](https://studygolang.com/articles/12444)3. [Go 語言機制之記憶體剖析](https://studygolang.com/articles/12445)4. [Go 語言機制之資料和文法的設計哲學](https://studygolang.com/articles/12487)## 設計哲學(Design Philosophies)"在棧上儲存值,這減少了垃圾收集器(GC)的壓力。然而,卻要求儲存、跟蹤和維護給定值的多個副本。將值放在堆上,這會給 GC 增加壓力。但是它也是有用的,因為只需要針對一個值進行儲存、跟蹤和維護。" - Bill Kennedy對於給定類型的資料,想在整個軟體中保持完整性和可讀性,使用值或者指標要保持一致。為什嗎?因為,如果你在函數間傳遞資料時修改資料語義,將很難維護一個清晰一致的心智模型。程式碼程式庫和團隊越大,越多的 bug、對資料的競爭和其他副作用就會悄悄地潛入到程式碼程式庫中。我想從一組設計哲學開始討論,它將指導(我們如何)選擇一種語義而不是另外一種語義的方法。## 心智模型(Mental Models)(譯者註:心智模型是經由經驗及學習,腦海中對某些事物發展的過程,所寫下的劇本。可以當成對代碼整體的把控)"讓我們想象有這樣一個項目,它包含一百萬行以上的代碼量。這些項目當前在美國能成功的可能性很低,遠低於 50%。或許有人不同意這個說法。" - Tom Love (inventor of Objective C)Tom 還說一盒複印紙可以容納 10 萬行代碼。稍微想一下。你能掌控這個盒子中的代碼的百分之多少呢?我相信要一個開發人員維護一張紙上的代碼的心智模型(大約 1 萬行代碼)已經是個問題。但是,我們還是假設每個開發人員開發 1 萬行代碼,那麼需要由 100 位開發人員組成的團隊來維護一個包含 100 萬行代碼的程式碼程式庫。也就是說 100 人需要協調,分組,跟蹤和不斷溝通。現在,再看看你們 1 到 10 名開發人員組成的團隊。你們在這個小得多的規模做得如何?假設每人 1 萬行代碼,(你們)團隊規模與程式碼程式庫的大小是否相符?## 調試(Debugging)"最大的問題是你的心智模型是錯誤的,所以你根本找不到問題所在。" - Brian Kernighan我不相信,你能在沒有心智模型的基礎上,使用調試器解決問題,你只不過是在浪費時間精力嘗試理解問題。如果你在生產環境中遇到問題,你能問誰?沒錯,日誌。如果日誌在你開發過程中對你沒有用,那麼當生產環境上出問題,它也一定對你沒有用。日誌應該基於代碼的心智模型,這樣才能通過閱讀代碼找到問題所在。## 可讀性(Readability)C 語言是我見過的在效能和表達性上平衡得最好的。你可以通過簡單的編程實現任何你想要做的事情,並且你會對機器即將要發生的事情擁有一個非常好的心智模型。你可以非常合理地預測它的速度,你知道即將要發生什麼..." - Brian Kernighan我相信 Brian 這句話也適用於 Go。保持這種 "心智模型" 就是一切。它驅動完整性,可讀性和簡單性。這些是精心編寫的軟體的基石,使得它可以保持正常並持續運行下去。編寫保證給定類型資料的值或者指標語義一致的代碼是實現這一點的重要方法。## 面向資料設計(Data Oriented Design)"如果你不瞭解這些資料,你就不明白這個問題。因為所有的問題都是獨特的,並且與你所使用的資料關係緊密。當資料發生變化時,你的問題也會跟著變化。但問題發生變化時,你的演算法(資料轉換)也需要跟著變化。" - Bill Kennedy想一想。你解決問題的方法實際上是解決資料轉換的問題。你寫的每個函數,啟動並執行每個程式,(只不過)都是擷取一些輸入資料,產生一些輸出資料。從這個角度看,你的軟體的心智模型就是對這些資料轉換的理解(例如,如何在代碼中組織和使用它們)。"少即是多" 的原則對於解決問題時實現較少的層數,代碼量,迭代次數,以及降低複雜性和減少工作量非常重要。## 類型(就是生命)(Type (Is Life))"完整性意味著每次分配記憶體,讀取記憶體和寫入記憶體都是準確,一致和高效的。類型系統對於我們具有這種微觀完整性至關重要。" - William Kennedy如果資料驅動你所做的一切,那麼代表資料的類型就十分地重要。在我的觀點裡面 "類型就是生命",因為類型為編譯器提供了確保資料完整性的能力。類型也驅動並指示語義規則,程式必須遵循其所操作的資料的語義。這是正確地使用值或者指標語義的開始:使用類型。## 資料(的能力)"當資料是實際和合理的,方法才是有效。" - William Kennedy值或者指標語義的思想不會直接影響 Go 開發人員,除非他們需要決定方法接收值還是指標。這是我遇到的一個問題:我應該使用值作為參數還是指標?一聽到這個問題,我就知道這個開發人員沒有理解好這些(類型的)語義。方法的目的是使這些資料具有某種能力。想象一下,資料有能力做某些事情。我總是希望把重點放在資料上,因為它驅動程式的功能。資料驅動你寫的演算法,封裝和能達到的效能。## 多態(Polymorphism)"多態意味著你寫了一個特定的程式,但它的行為有所不同,具體取決於它所操作的資料。" - Tom Kurtz (inventor of BASIC)我很喜歡 Tom 上面說的話。函數的行為可以根據操作的資料的不同而不同。這個資料的行為是將函數從它們可以接受和使用的具體資料類型中分離出來的,這是資料可以具有某種能力的原因。這個觀點是使得架構和設計可以適應變化的系統的基石。## 原型的第一種方法(Prototype First Approach)"除非開發人員對軟體會被如何使用有一個很好的瞭解,否則軟體很可能會出問題。如果開發人員不是很瞭解或者對軟體不是很理解,那麼獲得儘可能多的使用者輸入和使用者級測試就相當的重要。" - Brian Kernighan我希望你始終專註於理解具體的資料和為瞭解決問題所需要的資料轉換的演算法。採用這種原型的第一種方法,編寫也可以在生產環境中部署的具體實現(如果這樣做是合理和實際的話)。一旦一個具體的實現已經能夠工作,一旦你已經知道哪些工作起作用,哪些不起作用,就應該關注於重構,將實現與具體資料分離,將之賦予資料以能力(譯者註:我的理解,簡單地說,就是抽象為資料類型的一個方法)。## 語義原則(Semantic Guidelines) 你在宣告類型時,必須決定特定資料類型將使用哪種語義,值或者指標。接收或返回該類型資料的 API 必須遵循為該類型選擇的語義。API 不允許(使用者)指定或改變語義。他們必須知道資料使用什麼語義,並符合這一點。這是實現大型程式碼程式庫一致性的起碼要求。以下是基本指導原則:- 當你聲明一個類型時,你必須決定所使用的語義- 函數和方法必須遵循給定類型所選擇的語義- 避免讓方法接收與給定類型相對應的不同語義- 避免函數接收或者返回與給定類型相對應的不同語義- 避免改變給定類型的語義這些指導原則有一些例外的情況,最大的是 unmarshaling。Unmarshaling 總是需要使用指標語義。Marshaling 和 unmarshaling 似乎總是例外的規則。你如何選擇一種給定類型的一種語義而不是另外一種?這些指導方針將回答這個問題。以下我們將在具體的情況下使用指導原則:## 內建類型Go 語言中內建類型包括數字,文本和布爾類型。這些類型應該使用值語義進行處理。除非你有非常好的理由,否則不要使用指標來共用這些類型的值。作為一個例子,從 strings 包中查看這些函數的聲明。### 代碼清單 1```gofunc Replace(s, old, new string, n int) stringfunc LastIndex(s, sep string) intfunc ContainsRune(s string, r rune) bool```所有這些函數在 API 設定中都使用值語義。## 參考型別Go 語言中參考型別包括切片,map,介面,函數和 channel。這些類型建議使用值語義,因為它們被設計成待在棧中以最小化堆的壓力。它們允許每個函數都有自己的值副本,而不是每個函數都會造成潛在的分配。這是可能的,因為這些值包含一個在調用之間共用底層資料結構的指標。除非你有很好的理由,否則不要用指標共用這些類型的值。將調用棧中的 map 或 slice 共用給 Unmarshal 函數可能是一個例外。作為一個例子,看看 net 庫上聲明的這兩種類型。### 代碼清單 2```gotype IP []bytetype IPMask []byte```IP 和 IPMask 都是位元組切片。這意味著它們是參考型別,並且它們應該要符合值語義。下面是一個名叫 Mask 的方法,它被聲明為接收一個 IPMask 值的 IP 類型。### 代碼清單 3```gofunc (ip IP) Mask(mask IPMask) IP {if len(mask) == IPv6len && len(ip) == IPv4len && allFF(mask[:12]) {mask = mask[12:]}if len(mask) == IPv4len && len(ip) == IPv6len && bytesEqual(ip[:12], v4InV6Prefix) {ip = ip[12:]}n := len(ip)if n != len(mask) {return nil}out := make(IP, n)for i := 0; i < n; i++ {out[i] = ip[i] & mask[i]}return out}```請注意,此方法是一種轉變操作,並使用值語義的 API 樣式。它使用 IP 值作為接收方,並根據傳入的 IPMask 值建立一個新的 IP 值並將其返回給調用方。該方法遵循對參考型別使用值語義(的基本指導原則)。這跟系統預設的 append 函數有點相似。### 代碼清單 4```govar data []stringdata = append(data, "string")```append 函數的轉變操作使用值語義。將切片值傳遞給 append,並在變化之後返回一個新切片值。總是除了 unmarshaling,它需要使用指標語義。### 代碼清單 5```gofunc (ip *IP) UnmarshalText(text []byte) error {if len(text) == 0 {*ip = nilreturn nil}s := string(text)x := ParseIP(s)if x == nil {return &ParseError{Type: "IP address", Text: s}}*ip = xreturn nil}```UnmarshalText 實現 encoding.TextUnmarshaler 介面。如果沒有使用指標語義,根本無法實現。但這是可以的,因為共用值通常是安全的。除了 unmarshaling 之外,如果為一個參考型別使用指標語義,你應該三思。### 使用者定義型別(User Defined Types)這是你最多需要作出決定的地方。你必須在你宣告類型的時候決定使用什麼語義。如果我要求你給 time 包編寫 API 介面,給你這種類型。### 代碼清單 6```gotype Time struct {sec int64nsec int32loc *Location}```你會使用什麼語義?在 Time 包中查看此類型的實現以及工廠函數 Now。### 代碼清單 7```gofunc Now() Time {sec, nsec := now()return Time{sec + unixToInternal, nsec, Local}}```工廠函數對於類型來說是一種非常重要的函數,因為它告訴你(這種類型)所選擇的語義。Now 函數就很清晰地(向我們)表明使用了值語義。該函數建立一個類型為 Time 的值並將該值的副本返回給調用者。 共用 Time 值不是必要的,(因為)他們的生命週期內不需要一直存在於堆上。再看一下 Add 方法,它也是一個轉變操作。### 代碼清單 8```gofunc (t Time) Add(d Duration) Time {t.sec += int64(d / 1e9)nsec := t.nsec + int32(d%1e9)if nsec >= 1e9 {t.sec++nsec -= 1e9} else if nsec < 0 {t.sec--nsec += 1e9}t.nsec = nsecreturn t}```你可以再次看到 Add 方法遵循類型所選擇的語義。Add 方法使用一個值接收器來操作它自己的 Time 值副本。其中,Time 值副本在調用中使用。它將修改自己的副本,並將 Time 值的新副本返回給調用者。以下是一個接受 Time 值的函數:### 代碼清單 9```gofunc div(t Time, d Duration) (qmod2 int, r Duration) {```再一次,接受 Time 類型的值使用值語義。唯一使用指標語義的 Time API 介面,是這些 Unmarshal 相關的函數:### 代碼清單 10 ```gofunc (t *Time) UnmarshalBinary(data []byte) error {func (t *Time) GobDecode(data []byte) error {func (t *Time) UnmarshalJSON(data []byte) error {func (t *Time) UnmarshalText(data []byte) error {```大多數情況下,使用值語義的能力是有限的。將值從一個函數傳遞到另一個函數,(通常)使用值拷貝的方法是不正確或者不合理的。修改資料需要將其隔離成單個值再進行共用。這時,應該使用指標語義。如果你沒辦法 100% 確定拷貝值是正確並且合理的,那就使用指標語義吧。查看 os 包中的 File 類型的生產函數。### 代碼清單 11```gofunc Open(name string) (file *File, err error) {return OpenFile(name, O_RDONLY, 0)}```Open 函數返回一個 File 類型的指標。這意味著,對於 File 類型值,你應該使用指標語義來共用 File 的值。將指標語義修改為值語義,可能會對你的程式造成破壞性影響。當你與一個函數共用值時,最好假定你不允許拷貝值的指標並使用這個指標。否則,不知道將會出現什麼樣的異常情況。查看更多的 API, 你將會看到更多使用指標語義的例子。### 代碼清單 12```gofunc (f *File) Chdir() error {if f == nil {return ErrInvalid}if e := syscall.Fchdir(f.fd); e != nil {return &PathError{"chdir", f.name, e}}return nil}```雖然 File 值永遠不會被修改,但是 Chdir 方法還是使用指標語義。該方法必須遵循該類型的語義約定。### 代碼清單 13```gofunc epipecheck(file *File, e error) {if e == syscall.EPIPE {if atomic.AddInt32(&file.nepipe, 1) >= 10 {sigpipe()}} else {atomic.StoreInt32(&file.nepipe, 0)}}```這是一個名為 epipecheck 的函數,它使用指標來接收 File 值。再次注意一下,對於 File 值,一致使用指標語義。## 結論我在做代碼 review 時,會尋找值或者指標語義是否使用一致。它可以協助你保證代碼的一致性和可預測性。它還使每個人能保持清晰和一致的心智模型。隨著程式碼程式庫和團隊變得越來越大,值或者指標語義的一致性使用將會越來越重要。Go 語言令人不解的地方在於指標和值語義之間的選擇早已超出了接收器和函數參數的聲明範圍。介面的機制,函數值和切片都在語言的工作範圍內。在將來的文章中,我將在這些語言的不同部分中展示值或者指標語義。
via: https://www.ardanlabs.com/blog/2017/06/design-philosophy-on-data-and-semantics.html
作者:William Kennedy 譯者:gogeof 校對:polaris1119
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
848 次點擊