久久国产成人av_抖音国产毛片_a片网站免费观看_A片无码播放手机在线观看,色五月在线观看,亚洲精品m在线观看,女人自慰的免费网址,悠悠在线观看精品视频,一级日本片免费的,亚洲精品久,国产精品成人久久久久久久

分享

我們需要談?wù)?Go 的缺點

 技術(shù)的游戲 2023-09-16 發(fā)布于廣東
我們需要談?wù)?Go 的缺點

這是一個3篇文章系列的第二部分。這是關(guān)于 Go 編程語言的缺點的故事,,關(guān)于它使我們的生產(chǎn)力降低、我們的代碼庫變得不安全和不易維護的部分,。以及對改進的提議。??

這個系列中的更多內(nèi)容

  • · 為什么 Go 是最好的語言

  • · 我們需要談?wù)?Go 的缺點

  • · 為 Go 更好的未來的提議

在前一篇文章的介紹中,我們提到了一個沖突 — Go 有著堅決保持簡單的傾向,。盡管它有巨大的優(yōu)勢,,但它阻止了 Gophers 的生產(chǎn)力提升,。然后,我們討論了這門語言的強大之處和使其獨特的一切,。在這篇文章中,,我們將深入探討并展示 Go 的問題面。如果你錯過了前一篇,,我建議回頭看一下,,因為它為即將到來的抱怨提供了重要的背景。準備好了嗎,?我們開始吧,。

命名空間污染和糟糕的命名

寫 Go 代碼一段時間后,你會開始注意到你通常很快就用完了合理的變量名,。最初,,這聽起來像是一個表面問題,但實際上并不是,。讓我們從發(fā)生這種情況的原因開始說起,。

缺乏可見性修飾符

命名空間污染的第一個根本原因是缺乏可見性修飾符,。我認為為了減少冗余的關(guān)鍵詞并增強簡潔性,語言設(shè)計者決定省略可見性修飾符關(guān)鍵詞(public,、private 等)以支持符號命名,。以大寫字母開頭的符號自動被視為公共的,其余的則為私有的,。這聽起來像是一個促進簡潔性的很好選擇,。但隨著時間的推移,越來越明顯的是,,這種方法的缺點比優(yōu)點更強烈:在大多數(shù)其他語言中,,按照約定,類型名以大寫字母開頭,,變量名以小寫字母開頭,。這個約定有一個非常強大的含義 — 它意味著變量永遠不會遮蔽類型??紤]以下 Go 代碼:

type user struct {
  name string
}

func main() {
  user := &user{name: "John"}
  anotherUser := &user{name: "Jane"// compilation error: user is not a type
}

在 Go 中的類型遮蔽

這在 Go 中非常常見,,我敢打賭大多數(shù) Gophers 在某個時候都會遇到這個問題。在大多數(shù)情況下,,你的編碼范圍都只處理一個用戶實例,,所以將其命名為 user 應(yīng)該是一個明確且合理的選擇。但是,,在 Go 中,,每當你將一個私有類型存儲到一個私有變量中或?qū)⒁粋€公有類型存儲到一個公有變量中時 — 你就會遇到這個問題。所以你只是簡單地開始將你的用戶變量命名為 u,。

包范圍命名空間

命名空間污染的第二個原因,,也可能是最令人煩惱的一個 — 包范圍命名空間。讓我們從一個例子開始:

// file: client/slack_client.go
package client

const url = "slack.com"

type SlackClient struct {...}
// file: client/telegram_client.go
package client

const url = "telegram.com" // compilation error: url redeclared in this package

type TelegramClient struct {...}

為什么呢,?這些是兩個不同的文件,。我不能聲明任何私有的東西并僅在本地使用它嗎?我不能,。在一個文件中聲明的任何符號都會自動對整個包可見,。

在某些情況下,讓多個文件之間的包共享一個私有符號是完全有意義的(例如 包私有可見性),。而在其他情況下,,符號沒有理由逃離它們文件的范圍。Go 不提供這種控制的事實導(dǎo)致包被嚴重地弄亂,,并且為了避免重復(fù)和歧義,,不必要地使用長且特定的符號名稱。在大項目的大包中,,幾乎不可能找到一個合理的也是可用的名稱,。更不用說從包內(nèi)聲明的數(shù)百甚至數(shù)千個符號中找到你想要調(diào)用的實際功能了,。??

內(nèi)置符號

最后,是全局內(nèi)置符號,。讓我們從一個例子開始:

func larger(a, b []string) []string {
  len := len(a)
  if len > len(b) { // compilation error: invalid operation: cannot call non-function len (variable of type int)
     return a
  }
  return b
}

覆蓋內(nèi)置符號

換句話說,,你技術(shù)上可以使用一系列的名字來命名你的變量,但如果你這樣做,,你會遮擋重要的內(nèi)置功能

解決這個問題的一個可能的方法是:內(nèi)置符號應(yīng)該是關(guān)鍵字而不是符號,。覆蓋len,、makeappend永遠都不是一個好主意,對吧,?更不用說truefalse了(它們在Go中不是關(guān)鍵字),。如果我們都同意這總是一個壞習(xí)慣,那為什么一開始就允許它呢,?

一個更好的解決方案是:內(nèi)置符號應(yīng)該放在上下文相關(guān)的命名空間下,。比如,len,、appendcap作為切片的方法比全局符號更有意義,。它們讀起來都更自然,但更重要的是,,它們不會弄亂全局命名空間,,允許我們在需要時安全地使用它們作為合理的變量名。

真的那么重要嗎,?

變量名真的那么重要嗎,?首先,是的,,它們很重要,。一個名為u的用戶變量從來都不是一個比一個上下文相關(guān)的變量名更好的主意。但它也違反了Go的第一個規(guī)則——可讀性優(yōu)先于可寫性,。嘗試弄清由u,、rzt變量組成的代碼片段實際上是相當令人頭疼的,。我明白有些情況下代碼短小簡單,,上下文足夠清晰,對于一個u變量來說保持清晰是有可能的,。但為什么我們首先官方鼓勵這種做法呢,?在任何現(xiàn)代環(huán)境中,flname相對于fileName的附加價值是什么,,其中源代碼大小的影響與其可維護性相比是微不足道的,?就我個人而言,,我發(fā)現(xiàn)自己經(jīng)常告訴拼寫檢查工具,“不,,實際上conns是一個有效的詞,,很明顯是connections的縮寫,還有...什么,?cnt,?不不不,哈哈,,它只是count的縮寫,,淘氣”。???♀?

官方的Go wiki提供了一個通過代碼審查評論的最佳實踐部分,。有一個特定的建議是保持變量名簡短,,因為熟悉承認簡短。作為一個非英語母語者,,我想,,“嗯...聽起來很聰明”。??過了幾秒鐘我才真正理解這句話的真正含義 — “如果在你寫的時候它看起來很熟悉,,允許自己有點懶惰”,。??我的意思是,為什么,?這正是一種偽裝成好習(xí)慣的慣例,,并最終鼓勵我們走錯方向。我發(fā)現(xiàn)自己在多次場合盯著非常受歡迎的開源項目,,想知道現(xiàn)在我看到的東西怎么能有人理解,。為了證明一個觀點,這里是一個代碼行,,在一個100+行的函數(shù)中間引用cc.c,,在一個2947行的文件中間。很明顯,,我不能責(zé)怪作者(是的,,我知道,我可以 git blame他們,,好吧,,安靜)因為他們只是簡單地遵循習(xí)慣性Go的規(guī)則。但在這樣一個巨大的函數(shù)中,,在這樣一個巨大的文件和包中弄清楚ccc的意義絕對不是富有成效的編程,。

變量名種類低的另一個問題是意外的遮蔽和意外的重寫。考慮以下功能:

func (u *user) Approve() error {
 err := db.ApproveUser(u.ID)
 if err != nil {
   err = reportError("could not approve user")
   if err != nil {
     log.Error("could not report error")
   }
 }
 return err
}

這段代碼中隱藏了一個令人討厭的小bug

哎,,這是一個在生產(chǎn)中很難發(fā)現(xiàn)的令人討厭的小bug,。你能找到它嗎?這應(yīng)該在代碼審查中被發(fā)現(xiàn),,但需要一個非常徹底的審查才能揭示它,。還有其他方法可以預(yù)防它嗎?確保在不需要時不重寫變量值(Go不強制執(zhí)行,,我們稍后會介紹),,然后努力為每個變量提供一個上下文定位良好的名稱。在某些方面,,這與習(xí)慣性的Go相反,,減少可用名稱的種類進一步傷害了它。

首字母縮略詞

另一個對可讀性的討厭打擊是大寫首字母縮略詞的慣例,。習(xí)慣性的方式來命名一個指向某個HTTPS端點URL的公共變量是HTTPSURL。除了它會打破拼寫檢查工具,,難道只有我認為HttpsUrl在任何可能的方式上都更好嗎,?當這三者被連接起來時怎么樣?JSONHTTPSURL看起來像一個好的變量名嗎,?感覺像是想弄清楚一個糟糕的Reddit子版的名字,。?????♀?

接收者名稱

最后 — 方法接收者名稱的約定。我建議閱讀Jesse Duffield's精美編寫的關(guān)于約定的文章 'Going Insane',。我無法比那更好地呈現(xiàn)或爭論,。簡而言之:self 或 this 可能比我們使用的這些單字母接收者名稱更有意義,然后我們發(fā)現(xiàn)自己在重命名結(jié)構(gòu)或移動方法時追逐尾巴重命名,。

值安全

與類型安全類似,,值安全是在編譯時進行運行時保證的一種手段。類型安全是指運行時的值的類型,,而值安全是指實際的值,。雖然類型安全是由一個單一、一致的類型系統(tǒng)執(zhí)行的,,但值安全只是一組提供關(guān)于值的各種保證的不同工具和特性,。因此,語言可以做的不僅僅是完全選擇加入或退出值安全,。每種語言選擇要支持和執(zhí)行的確切的值安全特性集合,。

一些值安全工具實際上是眾所周知的編程的基本概念,它們在其基本本質(zhì)或作為一個副作用提供安全性,。其他的是隨著時間的推移而進化的現(xiàn)代工具,。不管怎樣,值安全特性不是現(xiàn)代語言愛好者吹噓的增強生產(chǎn)力的語法糖工具,。它們提供了我們關(guān)鍵地依賴的高度重要的保證,。與語法糖特性不同,,這些保證不能通過編寫明確的代碼來實現(xiàn)。在它們?nèi)笔У那闆r下,,剩下的只是人類的責(zé)任和我們分析每一段代碼的邊緣情況和陷阱的能力(哈哈??),。我們也可以選擇退出,只要每個人都能按照預(yù)期的方式編寫代碼,,事情就會繼續(xù)進行,。直到有人不這么做,然后就是恐慌的時候了,。字面上是這樣,。

隨著我們對編程原則的理解不斷深化,新工具提供了更好的值安全性,。一些現(xiàn)代語言提供了新的,、令人興奮的方法來執(zhí)行值安全,使程序員能夠編寫更健壯和高性能的代碼,。其他編譯語言提供了至少一些值安全特性,。Go不提供任何。現(xiàn)在輪到我們確保我們按照預(yù)期使用一切,。作為任何庫或代碼的消費者和生產(chǎn)者,,這都是一個可怕的情況。讓我們解決這些缺失的特性,。

空指針安全

絕對不是編程歷史上最糟糕的設(shè)計決策,,但可能是最臭名昭著的一個(“十億美元的錯誤”) —— 空指針異常定期給太多的人帶來了太多的金錢損失。歸根結(jié)底,,造成這種情況的原因總是最簡單的人為錯誤 —— 忘記檢查邊緣情況,。這個一再出現(xiàn)的問題始終提醒我們,我們簡單地不是為這個而生的,。在編程中,,“請記住做這個和那個”總是一個壞兆頭。如果有一件事你可以依賴的話 —— 我們不會,。我們不擅長記住做事,。

現(xiàn)代語言特性允許完全消除這個問題。嚴格的空值檢查(像Swift,、KotlinTypeScript提供的功能)允許我們明確定義何時允許空值,。當允許時,在取消引用它們之前需要進行空檢查,。其他語言提供總和類型(如RustScala中的類型),,首先防止空值。

我真的不能理解為什么一個程序員會提倡類型安全而忽視空安全。如果你希望你的編譯器執(zhí)行不調(diào)用未定義的方法,,當你試圖取消引用一個空值時,,同樣的邏輯不也應(yīng)該適用嗎?我相信,,不管怎樣,,現(xiàn)代語言必須配備這樣的執(zhí)行機制。

這里是一個提議向Go添加可空類型支持,。

枚舉

不管你如何模型化你的代碼,,最終你總是會需要枚舉。一個變量可以有幾種可能的值中的一個,。如果你只有兩個可能的值 —— 布爾值會工作得很好,。如果你有三個或更多的選項,你將需要一個支持枚舉的機制,。

在Go中,,你可以做到……但其實并不真正可以。你可以定義常量,,但那只是它們的全部 —— 簡單的常量值,。它們并不保證一個宿主變量必須持有其中之一。即使你定義了一個自定義類型,。以下是一個例子:

type ConnectionStatus int

const (
  Idle ConnectionStatus = iota
  Connecting
  Ready
)

func main() {
  var status ConnectionStatus = 46 // no compilation error
}

帶有無效值的枚舉

在主函數(shù)中聲明的 status 變量的值應(yīng)該是0,、1或2,。然而,,當我們將其設(shè)置為46時,編譯或運行時都不會出現(xiàn)錯誤,。一個好的開發(fā)者不應(yīng)該將它設(shè)置為46,,呃!?? 但是錯誤確實會發(fā)生,。隨著代碼庫和工程師數(shù)量的增長,,它們發(fā)生只是時間問題。一個好的編譯器應(yīng)該幫助我們避免這種情況,。

別忘了46也可能來自外部,、非硬編碼的來源,。比如HTTP請求或文件。我們應(yīng)該總是記得手動驗證它。直到我們忘記了,。??

這種方法的另一個問題是缺乏封裝。枚舉相關(guān)的行為不能在枚舉本身內(nèi)部定義,,只能通過switch case來定義,。然而,Go中的switch case并不強制覆蓋所有可能的值。下面是它的樣子:

type ConnectionStatus int

const (
  Idle ConnectionStatus = iota
  Connecting
  Ready
  Disconnecting
)

func handleConnectionStatus(status ConnectionStatus) {
  switch status {
  case Idle:
     reconnect()
  case Connecting:
     wait()
  case Ready:
     useConnection()
  }
}

未處理的枚舉情況

看起來不是那么糟糕,,是嗎,?但實際上確實很糟糕 - 每次你添加一個新的枚舉值,你都必須搜索你的代碼庫找到那些switch語句,。如果你漏掉了一個,,就像上面的例子沒有正確處理Disconnecting狀態(tài),它會陷入未定義的行為,。請放心,,你總會記得修復(fù)這些枚舉,直到有一天你不再這樣做(還記得我們剛剛說的關(guān)于記憶的事情嗎?...你可能不記得了,。我也是???♀?),。你能想象一個你寧愿不使用編譯器來強制這種陷阱的場景嗎? 解決這個問題的方法很簡單:要么你要求switch case語句窮盡所有可能的值(例如如rust所做),,要么你要求枚舉來實現(xiàn)行為(例如如java所做),。

缺少原生枚舉支持的其他問題是什么?如何遍歷一個枚舉的所有可能值呢,?你偶爾需要這樣做,,也許你需要將它發(fā)送到UI供用戶選擇一個。這是不可能的,。什么關(guān)于命名空間,?對于所有HTTP狀態(tài)碼是否最好屬于一個只提供HTTP狀態(tài)碼的命名空間?但Go將它們與HTTP包的其余公共符號混合在一起 - 像Client,,Server和錯誤實例,。

這是Go中的一個枚舉提議

結(jié)構(gòu)體默認值

在某些情況下,,結(jié)構(gòu)體可能不僅僅是一組變量的集合,,而是一個具有狀態(tài)和行為的簡潔實體。在這種情況下,,某些字段最初可能需要持有有意義的值,,而不僅僅是它們的零值。我們可能需要將int值初始化為-1而不是0,,或初始化為18,,或者從其他值派生出的計算值。

Go并沒有提供任何現(xiàn)實的方法來在結(jié)構(gòu)體中強制初始狀態(tài),。實現(xiàn)這一點的唯一方法是聲明一個公共構(gòu)造函數(shù),,然后使你的結(jié)構(gòu)體私有化以防止通過結(jié)構(gòu)體字面量直接實例化。此時,,你必須聲明一個描述結(jié)構(gòu)體的公共方法的接口,,以便能夠?qū)С鰳?gòu)造函數(shù)的返回值,。例如:

type connectionPool struct {
  connectionCount int // initial value should be 5
}

type ConnectionPool interface {
  AddConnection()
  RemoveConnection()
}

func newConnectionPool() ConnectionPool {
  return &connectionPool{connectionCount: 5// since the struct is private, direct instantiation can only be done from inside the package
}

func (c *connectionPool) AddConnection() {
  c.connectionCount++
}

func (c *connectionPool) RemoveConnection() {
  c.connectionCount--
}

嘗試在結(jié)構(gòu)體中強制初始值

由于結(jié)構(gòu)體本身并沒有被導(dǎo)出,所以不可能通過結(jié)構(gòu)體字面量直接實例化它,,而只能通過調(diào)用我們的構(gòu)造函數(shù)來實例化,。這確保了我們強制執(zhí)行所需的初始值。

當維護開源庫時,,這種方法可能勉強值得努力,。但是我們這些普通人呢?我們不太關(guān)心我們進程內(nèi)部某些結(jié)構(gòu)體的API的完整性,,我們只是試圖快速修復(fù)生產(chǎn)中的一個bug,。如果我們這樣工作,每次更改方法簽名時,,我們還必須在接口中更改重復(fù)項,。僅僅想在結(jié)構(gòu)體中強制初始值就產(chǎn)生了這樣一個隨機的副作用???♀???。 但這還不是全部,。它甚至并沒有完全解決問題 - 同一個包中的代碼仍然可以使用結(jié)構(gòu)體字面量并跳過構(gòu)造函數(shù),,破壞我們試圖完成的一切。??

另一個有問題的用例是配置結(jié)構(gòu)體,,但最好的展示方式是使用一個真實的例子,。Sarama 是用于Kafka的最受歡迎的Go庫。這是Shopify維護的一個巨大,、成熟的項目,。Sarama暴露了一個Config 結(jié)構(gòu)體,它應(yīng)該在創(chuàng)建新客戶端時傳入,。它包含如何連接到Kafka代理以及如何維護連接狀態(tài)和行為的各種信息,。結(jié)構(gòu)體的注釋指示了每個字段的意圖和其默認值。例如 - 這里的Max字段,,它設(shè)置請求到Kafka的最大重試次數(shù),,默認為5,。所以一個普通的開發(fā)者,,例如我,可能會想“嗯...好的,,讓我們傳入一個空的結(jié)構(gòu)體字面量來使用默認值5”,。于是我這樣做了。

func createConsumer(brokers []string) {
  consumer, err := sarama.NewConsumer(brokers, &sarama.Config{})
  // ...
}

使用 sarama 配置的默認值

“*...現(xiàn)在我的配置肯定允許5次重試,” 我自言自語道,。?? 但直到幾天后,,當我再玩了一會兒,我突然想:“等等,,它們怎么知道的,?*” Sarama的代碼如何能夠區(qū)分使用 &sarama.Config{} 傳入的默認值0(應(yīng)允許5次重試),,和使用 &sarama.Config{Max: 0} 明確傳入的0(應(yīng)允許0次重試)。?? 確實,,它不能,。顯然,有一個我應(yīng)該使用的config 結(jié)構(gòu)體的構(gòu)造函數(shù),,而不是直接實例化它,。哈哈 ???♀?。我突然想:“等一下”,,我是否已經(jīng)推送了指示 Sarama 庫使用0次重試和其他所有配置參數(shù)的零值的代碼,?!”???????? 是的,,我確實這樣做了,。?????♀? ?????♂?

起初,我對這個庫的設(shè)計者非常生氣,,因為他們沒有強制防止這樣的錯誤,,“如果這發(fā)生在我身上” 和其他原因。然后我再思考了一下,,意識到,,他們不能。Go語言簡單地不提供這樣的功能,。乍一看,,結(jié)構(gòu)體字面量看起來像是配置參數(shù)用例的完美候選,允許你傳入你需要的內(nèi)容,,并省略其余內(nèi)容,。但事實證明,情況恰恰相反,。

這里有一個提議來支持類型的初始化器,。

常量賦值

在花了一些時間與區(qū)分常量和變量值的語言打交道后,你開始注意到你的大多數(shù)賦值都是常量,。非常量賦值主要用于需要計數(shù),、連接或其他形式的聚合的計算。在大多數(shù)其他使用場景中,,變量的賦值是一次性操作,。例如,我們以 reactjs 的 GitHub 倉庫為例,,比較 let 關(guān)鍵字的使用量和 const 關(guān)鍵字的使用量,,我們可以得出結(jié)論:常量賦值占所有賦值的75% (25,440 vs 6,815)。因此,,像一些其他現(xiàn)代語言(比如 rust)那樣默認將賦值視為常量是非常有意義的,。這有什么用,?讓我們回到變量命名的例子,我們談到了意外的遮蔽和重寫變量:

func (u *user) Approve() error {
 err := db.ApproveUser(u.ID)
 if err != nil {
   err = reportError("could not approve user")
   if err != nil {
     log.Error("could not report error")
   }
 }
 return err
}

變量遮蔽可能導(dǎo)致隱式行為

還記得這個嗎,?這里的 bug 是對 err 變量的第二次賦值可能會清除原始錯誤,,導(dǎo)致我們像從未發(fā)生錯誤一樣從函數(shù)返回。這將導(dǎo)致調(diào)用函數(shù)繼續(xù)執(zhí)行,,好像用戶實際上已經(jīng)被批準了,,盡管他們并沒有。一個有經(jīng)驗的 Gopher 如果知道他們應(yīng)該尋找一個 bug,,可能會很快找到它,。一個沒有經(jīng)驗的 Gopher 可能需要更長時間。常量賦值很容易就防止了它,。對 error 變量的第二次賦值將無法編譯,,指示我們定義一個單獨的變量。

常量賦值不僅僅是關(guān)于編譯器的強制,。它們也關(guān)于作者能夠傳達意圖,,使代碼更具表現(xiàn)力,并使代碼更加清晰,。如果你知道某個變量不是為了被重新賦值的,,那么當你修改或重構(gòu)其周圍的代碼時,你就擁有更多的信息,。????

這里有一個常量賦值支持的提議,。

不可變性

不可變值是一個單獨的事情。一個變量的賦值可以是常數(shù),,但該值本身仍然可以變化,。考慮創(chuàng)建一個指向 map 的常量變量,,然后對 map 執(zhí)行寫操作,。變量仍然指向同一個 map,但 map 的值本身已經(jīng)改變了,。

在并發(fā)環(huán)境中,,不可變數(shù)據(jù)結(jié)構(gòu)可以是防止數(shù)據(jù)競態(tài)的強大工具。在 Go 中,,沒有原生支持的不可變數(shù)據(jù)結(jié)構(gòu),。好消息是,,隨著泛型的到來,,我們可以自己創(chuàng)建它們。例如 ImmutableMap[K, V],。

這里有一個不可變性的提議,。

錯誤處理

錯誤處理可能是 Go 社區(qū)最大的爭議,,現(xiàn)在泛型的爭議已經(jīng)全部解決。隨著時間的推移,,我熟悉了許多錯誤處理的方法,,無論是在其他語言中還是在 Go 的提議中。我不敢說我自己可以想出最好的錯誤處理機制,。但我確實想說,,Go 的錯誤處理有許多優(yōu)點和優(yōu)勢。然而,,我們在這里是為了討論缺點,。首先,我想再次提及 Jesse Duffield 的關(guān)于錯誤處理的瘋狂之作,,他很好地描述了自己的痛點,。然后我會自己補充兩點。

首先,,我最近聽到很多 Gophers 爭論說 Go 中的顯式錯誤處理方法是一個好主意,,因為它迫使我們處理每一個錯誤。我們永遠不應(yīng)該簡單地傳播錯誤,。換句話說,,這種情況永遠不應(yīng)該發(fā)生

if err != nil {
  return err
}

錯誤處理不帶包裝

相反,我們應(yīng)該總是用新的錯誤包裝錯誤,,為調(diào)用者提供更多的上下文,,如下所示:

if err != nil {
  return errors.Wrap(err, "read failed")
}

帶有包裝的錯誤處理

這種觀點我完全不接受。我首先聽到支持Go錯誤處理機制的觀點之一是它的性能遠超于基于try-catch的機制,。我嘗試了一下,,發(fā)現(xiàn)確實如此。在try-catch環(huán)境中,,導(dǎo)致性能低下的主要原因之一是每當拋出異常時,,都會在調(diào)用堆棧的每個級別創(chuàng)建完整的堆棧跟蹤和異常信息。在Go中,,沿著整個調(diào)用堆棧用一個錯誤包裝另一個錯誤,,然后對所有創(chuàng)建的對象進行垃圾回收幾乎同樣昂貴。更不用說手動編碼了,。如果這是你所提倡的,,那么你最初應(yīng)該選擇一個try-catch環(huán)境。

堆棧跟蹤在調(diào)查錯誤和缺陷時非常有用,,但在其他時間它們是高度昂貴的冗余信息,。也許一個完美的錯誤處理機制應(yīng)該有一個開關(guān),可以在需要時打開和關(guān)閉,。???♀?

第二點實際上與錯誤處理機制本身無關(guān),,而與約定有關(guān),。我們在上一段討論了錯誤處理的性能。[哨兵錯誤](https://dave./2016/04/27/dont-just-check-errors-handle-them-gracefully#sentinel errors)為Go的錯誤處理提供了另一個性能提升,。我建議閱讀有關(guān)它們的更多信息,,但簡而言之,當某個錯誤的所有出現(xiàn)都包含相同的信息時,,您可以簡單地創(chuàng)建一個單獨的錯誤對象實例,,而不是在每次出現(xiàn)時都生成新的對象。使用單例對象來表示錯誤,。我們稱之為哨兵錯誤,。它們防止了不必要的分配垃圾收集。除了性能提升,,錯誤比較也變得更簡單,。由于這些錯誤值是單例的,所以你可以簡單地使用一個簡單的等號運算符比較它們的值,,而不是比較它們的類型,。

var NetworkError = errors.New("oops...network error")

func IsNetworkError(err errorbool {
 return err == NetworkError
}

哨兵錯誤

注意這種比較方式并不需要類型檢查。這工作得很好——它讀寫都很好,,性能也很好,。????

然而,有時候我們不能使用哨兵錯誤,。有時我們希望錯誤消息包含與錯誤的特定出現(xiàn)相關(guān)的信息,。(例如,"oops...網(wǎng)絡(luò)錯誤 <根本原因>"),。這意味著我們必須每次都實例化一個新的錯誤對象,。此時,由于缺乏穩(wěn)定的約定,,我們只是這樣做:

return errors.New("oops...network error " + rootCause)
// or
return fmt.Errorf("oops...network error %s", rootCause)

偷懶地創(chuàng)建非哨兵錯誤

確實是個意外,。???♀?因為這個錯誤沒有特定的類型或?qū)嵗?/strong>,我們現(xiàn)在失去了有效檢查此類型錯誤的能力,。

err := doSomething()
if /* err is network error */ { // ?? how can we tell?
  // handle network error
else if err != nil {
  // handle other errors
}

我們?nèi)绾螀^(qū)分網(wǎng)絡(luò)錯誤,?

我們唯一的選擇是檢查錯誤消息本身上的 strings.Contains

err := doSomething()
if strings.Contains("oops...network error") {
  // handle network error
else if err != nil {
  // handle other errors
}

我們處理錯誤的唯一選擇太糟糕了

這在性能方面是很糟糕的。但更可怕的是,,它并不是保證性的,。很明顯,"oops...network error" 可以隨時改變,,而編譯器也不能幫助我們,。當包的作者決定將消息更改為 "oops...there has been a network error" 時,我的錯誤處理邏輯就破裂了,你一定是在開玩笑,。???♀????♀? 在任何其他語言中,,你也可以這樣做,。例如,,在Java中,你可以 throw new Exception("oops...network error"),。但在一個小型創(chuàng)業(yè)公司的內(nèi)部代碼中,,它很可能不會通過代碼審查。然而,,在Go中,,它在由大型組織維護的巨大的開源庫中通過了代碼審查(例如google的protobuf)。我個人發(fā)現(xiàn)自己回到了字符串包含檢查,,感到很不舒服,,用了不止一個主要的開源庫。????

無論是通過強烈的,、堅實的慣例,,還是通過編譯器強制:errors.New 和 fmt.Errorf 只應(yīng)該用于創(chuàng)建標志性錯誤。返回的任何其他錯誤都必須聲明一個專用的,、導(dǎo)出的類型,,以允許合理的處理。作為庫的作者,,如果我們忽略這樣做,,我們就冒著損害我們的消費者的代碼安全性和完整性的風(fēng)險。

這里有一個關(guān)于錯誤處理的語言提議 ,,它提出了添加一個 ? 操作符,。值得一提的是,這個提議在2018年因為過熱的討論而關(guān)閉,,從未重新開啟,。我猜有些事情最好不處理。??

異步返回值

Go中有許多同步機制,,其中一些是原生的,,一些是由Go SDK提供的。其他的則可以在許多開源庫中找到,。盡管如此,,你可以說Go代碼通常由兩個主要的同步機制主導(dǎo)——通道(原生)和 WaitGroups(由SDK提供)。這兩個是強大的機制,,可以支持實現(xiàn)每一個可能的并發(fā)流程,。WaitGroups 允許同步不同線程的執(zhí)行時間,而通道允許這樣的同步并在線程之間傳遞值。

每個機制都有其固定的用途,,但還有另一個用途尚未被涵蓋,,我認為這第三種用途通常是最佳實踐。為了看到它的實際應(yīng)用,,讓我們考慮這個非常受歡迎的例子:我們想要并發(fā)地獲取幾個資源,,然后合并結(jié)果。讓我們首先使用 WaitGroups 來實現(xiàn)它,。

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  var wg sync.WaitGroup
  var lock sync.Mutex
  var firstError error
  result := make([]stringlen(urls))
  for i, url := range urls {
     wg.Add(1)
     go func(i int, url string) {
        resource, err := fetchResource(url)
        result[i] = resource
        if err != nil {
           lock.Lock()
           if firstError == nil {
              firstError = err
           }
           lock.Unlock()
        }
        wg.Done()
     }(i, url)
  }
  wg.Wait()
  if firstError != nil {
     return nil, firstError
  }
  return result, nil
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

首次嘗試:使用 sync.WaitGroup

這有點明確,,像Go通常那樣,但它確實有效,。我們必須自己處理錯誤的事實對Go團隊來說似乎過于明確,,所以他們發(fā)布了一個名為 ErrGroup 的 WaitGroup 的額外變體,簡化了錯誤處理:

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  var group errgroup.Group
  result := make([]stringlen(urls))
  for i, url := range urls {
     func(i int, url string) {
        group.Go(func() error {
           resource, err := fetchResource(url)
           if err != nil {
              return err
           }
           result[i] = resource
           return nil
        })
     }(i, url)
  }
  err := group.Wait()
  if err != nil {
     return nil, err
  }
  return result, nil
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

第二次嘗試:使用 errgroup.Group

ErrGroups 聚合錯誤并簡化錯誤處理,,同時,,它們支持 contexts 以允許統(tǒng)一的超時和取消,以及并發(fā)限制,。 顯然,,這是一個更復(fù)雜的同步機制,但是,,它仍然有兩個顯著的缺點:我們?nèi)匀槐仨氁阅撤N方式同步返回值,,由于 ErrGroup.Go 函數(shù)的簽名,我們必須用一個不接收參數(shù)的函數(shù)包裝我們的并發(fā)函數(shù),。而且由于Go不支持縮短的lambda表達式(此處有活躍的提案),,它變得更加明確且可讀性更差。

上述的第一個缺點可以并且應(yīng)該用泛型來解決,,第二個仍然存在,。對于我們在并發(fā)函數(shù)中既不接受也不返回任何東西的情況,這是一個完美的機制,,但這些情況非常少見,。

我們繼續(xù)討論通道:

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  ch := make(chan *singleResult, len(urls))
  for _, url := range urls {
     go func(url string) {
        resource, err := fetchResource(url)
        ch <- &singleResult{
           resource: resource,
           err:       err,
        }
     }(url)
  }
  result := make([]stringlen(urls))
  for i := 0; i < len(urls); i++ {
     res := <-ch
     if res.err != nil {
        return nil, res.err
     }
     result[i] = res.resource
  }
  return result, nil
}

type singleResult struct {
  resource string
  err       error
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

第三次嘗試:使用通道

對于這樣一個常見的用例,它仍然相當明確,。有很大的機會犯錯誤,,導(dǎo)致競態(tài)條件和死鎖

現(xiàn)在想象一下,,如果 go 關(guān)鍵字不僅啟動一個新的 goroutine,,而且還返回一個對象來跟蹤它,允許我們等待被調(diào)用的函數(shù)并獲取其返回的值,。讓我們將其稱為 promise[T],。

func fetchResourcesFromURLs(urls []string) ([]stringerror) {
  all := make([]promise[string], len(urls))
  for i, url := range urls {
     all[i] = go fetchResource(url)
  }
  var result []string
  for _, p := range all {
     resource, err := p.Wait()
     if err != nil {
        return nil, err
     }
     result = append(result, resource)
  }
  return result, nil
}

func fetchResource(url string) (stringerror) {
  // some I/O operation...
}

虛構(gòu)的 promise 語法

啊,這更好了。這類對象通常被稱為 futures 或 promises,,大多數(shù)流行的語言都支持它們,。在 all[i] = go fetchResource(url) 中,我們填充了一個 promise 切片,,每一個都跟蹤不同 goroutine 的執(zhí)行和返回值,。然后我們等待它們?nèi)客瓿桑⒃诔霈F(xiàn)錯誤時失敗,。(這樣的操作通常有原生支持),。

這最后一個代碼片段是虛構(gòu)的,。它在 Go 中不存在,。但如果它存在的話,它不僅會更加易于閱讀,,而且會更安全,。由于 WaitGroups 和通道不與返回值一起工作,promise 類似的機制有兩個主要優(yōu)勢

首先,,WaitGroups 和通道需要某種實現(xiàn)來同步結(jié)果,。使用 WaitGroups 時,我們可能會發(fā)現(xiàn)自己在傳遞指針或使用像上面例子中的分片切片,。有時我們還需要一個額外的互斥量,。使用通道時,我們必須創(chuàng)建一個緩沖通道,。如果緩沖大小設(shè)置錯誤,,就會發(fā)生死鎖。無論哪種方式,,使用 promise 類似的機制都能避免潛在的風(fēng)險,。

第二個優(yōu)勢在于純函數(shù)編程。純函數(shù)使用返回值而不產(chǎn)生副作用,。這意味著我們可以依賴編譯器來確保我們不創(chuàng)建死鎖,。例如,當使用通道時,,我們必須接收一個通道參數(shù)并顯式地用結(jié)果調(diào)用它,。例如:

func fetchResources(url string, ch chan *singleResult) {
  data, err := httpGet(url)
  ch <- &singleResult{
     resources: data,
     err:       err,
  }
}

不能在處理顯式同步機制時編寫純函數(shù)

六個月后,另一個工程師(顯然,,永遠不是我們,,總是另一個工程師 ??)很容易犯一個錯誤,添加一個if語句然后返回,,忘記顯式調(diào)用通道,。這導(dǎo)致死鎖。顯然,這里沒有編譯錯誤,。

func fetchResources(url string, ch chan *singleResult) {
  if url == "" {
     return
  }
  data, err := httpGet(url)
  ch <- &singleResult{
     resources: data,
     err:       err,
  }
}

非純函數(shù)用于并發(fā)是死鎖陷阱

使用純函數(shù)時,,你必須在控制流分支時指定返回值。換句話說,,其他工程師簡單地不能造成死鎖,。

func fetchResources(url string) (stringerror) {
  if url == "" {
     return // compilation error: not enough arguments to return
  }
  data, err := httpGet(url)
  return data, err
}

使用純函數(shù)不可能發(fā)生死鎖

此外,純函數(shù)形式是完全可復(fù)用的,。這只是一個執(zhí)行操作并返回結(jié)果的函數(shù),。它對任何特定的同步機制都不熟悉。它不接收通道或WaitGroups,,也不執(zhí)行任何明確的同步,。Go簡單地照顧其他一切。

在我看來,,缺乏堅固的社區(qū)約定與無效的異步返回值機制結(jié)合在一起是糟糕編碼的根本原因,,而這在Go社區(qū)中幾乎是一種標準。[對于這一段可能傷害到的每一個人我深深地表示歉意],。一些例子,?Go中最受歡迎的HTTP框架之一有一個400行的函數(shù) ??,Google的gRPC庫里有一個100行的函數(shù)嗎,?官方的MongoDB驅(qū)動中有一個帶有嵌套2級while-true循環(huán)和其中的go-to語句的66行函數(shù)嗎,?!???????????????

這些只是快速Google搜索中首先出現(xiàn)的例子,??纯此鼈兌加惺裁垂餐c?它們結(jié)合了復(fù)雜的for循環(huán)或switch case與defergo func語句,。換句話說,,由于在Go中同步異步返回值需要傳遞指針和創(chuàng)建互斥鎖,所以最容易的方法是在一個大函數(shù)內(nèi)寫下所有內(nèi)容,,并通過在嵌套lambda函數(shù)的閉包中捕獲它們來避免傳遞它們,。這聽起來像是維護這些冗長函數(shù)的主要問題,但顯然,,在go func語句中捕獲閉包變量也是Go代碼中數(shù)據(jù)競爭的頭號原因,,根據(jù)Uber工程師完成的一項非常有趣的研究

對于執(zhí)行不返回任何值的異步或并發(fā)操作,,WaitGroupsErrGroups是一個很好的選擇,。當處理消費者-生產(chǎn)者用例或使用select語句等待幾個并發(fā)事件時,通道是一個完美的選擇,。但是,,由于大多數(shù)異步調(diào)用的用例都產(chǎn)生返回值并需要錯誤處理,,我猜如果Go支持了類似于promise的機制,它本來就是Gophers最受歡迎的選擇,。但我希望我已經(jīng)展示出對于這樣的用例,,這也是最安全的一個

這里有一些建議(12)添加這樣的機制到語言中,。后者提議將通道別名為futures,。老實說,我不在乎我們叫它們什么,。我甚至喜歡重用通道而不是引入一個新概念的想法,。我只是想go關(guān)鍵字返回一個對象,允許我跟蹤執(zhí)行的函數(shù)的返回值,。

總結(jié)

如果你將社區(qū)的約定和命名問題與異步返回值問題結(jié)合起來,,你會得到一些非常受歡迎的庫,它們使用復(fù)雜的,、超過100行的函數(shù),、使用一個字母的未記錄的變量,,這些變量被聲明在包的另一側(cè),。這是非常難以閱讀和維護的,而且出奇地常見,。此外,,與其他現(xiàn)代語言不同,Go不提供任何類型的運行時值安全性,。這導(dǎo)致了許多與值相關(guān)的運行時問題,,這些問題很容易避免。

在系列的最后一篇文章中,,我們將討論我們可以做些什么來改進它,,以及為Go設(shè)計一個更好的未來的提議。

    轉(zhuǎn)藏 分享 獻花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多