這是一個3篇文章系列的第二部分。這是關(guān)于 Go 編程語言的缺點的故事,,關(guān)于它使我們的生產(chǎn)力降低、我們的代碼庫變得不安全和不易維護的部分,。以及對改進的提議。?? 這個系列中的更多內(nèi)容
在前一篇文章的介紹中,我們提到了一個沖突 — Go 有著堅決保持簡單的傾向,。盡管它有巨大的優(yōu)勢,,但它阻止了 Gophers 的生產(chǎn)力提升,。然后,我們討論了這門語言的強大之處和使其獨特的一切,。在這篇文章中,,我們將深入探討并展示 Go 的問題面。如果你錯過了前一篇,,我建議回頭看一下,,因為它為即將到來的抱怨提供了重要的背景。準備好了嗎,?我們開始吧,。 命名空間污染和糟糕的命名寫 Go 代碼一段時間后,你會開始注意到你通常很快就用完了合理的變量名,。最初,,這聽起來像是一個表面問題,但實際上并不是,。讓我們從發(fā)生這種情況的原因開始說起,。 缺乏可見性修飾符命名空間污染的第一個根本原因是缺乏可見性修飾符,。我認為為了減少冗余的關(guān)鍵詞并增強簡潔性,語言設(shè)計者決定省略可見性修飾符關(guān)鍵詞(
在 Go 中的類型遮蔽 這在 Go 中非常常見,,我敢打賭大多數(shù) Gophers 在某個時候都會遇到這個問題。在大多數(shù)情況下,,你的編碼范圍都只處理一個用戶實例,,所以將其命名為 包范圍命名空間命名空間污染的第二個原因,,也可能是最令人煩惱的一個 — 包范圍命名空間。讓我們從一個例子開始:
為什么呢,?這些是兩個不同的文件,。我不能聲明任何私有的東西并僅在本地使用它嗎?我不能,。在一個文件中聲明的任何符號都會自動對整個包可見,。 在某些情況下,讓多個文件之間的包共享一個私有符號是完全有意義的(例如 包私有可見性),。而在其他情況下,,符號沒有理由逃離它們文件的范圍。Go 不提供這種控制的事實導(dǎo)致包被嚴重地弄亂,,并且為了避免重復(fù)和歧義,,不必要地使用長且特定的符號名稱。在大項目的大包中,,幾乎不可能找到一個合理的也是可用的名稱,。更不用說從包內(nèi)聲明的數(shù)百甚至數(shù)千個符號中找到你想要調(diào)用的實際功能了,。?? 內(nèi)置符號最后,是全局內(nèi)置符號,。讓我們從一個例子開始:
覆蓋內(nèi)置符號 換句話說,,你技術(shù)上可以使用一系列的名字來命名你的變量,但如果你這樣做,,你會遮擋重要的內(nèi)置功能。 解決這個問題的一個可能的方法是:內(nèi)置符號應(yīng)該是關(guān)鍵字而不是符號,。覆蓋 一個更好的解決方案是:內(nèi)置符號應(yīng)該放在上下文相關(guān)的命名空間下,。比如, 真的那么重要嗎,?變量名真的那么重要嗎,?首先,是的,,它們很重要,。一個名為 官方的Go wiki提供了一個通過代碼審查評論的最佳實踐部分,。有一個特定的建議是保持變量名簡短,,因為熟悉承認簡短。作為一個非英語母語者,,我想,,“嗯...聽起來很聰明”。??過了幾秒鐘我才真正理解這句話的真正含義 — “如果在你寫的時候它看起來很熟悉,,允許自己有點懶惰”,。??我的意思是,為什么,?這正是一種偽裝成好習(xí)慣的慣例,,并最終鼓勵我們走錯方向。我發(fā)現(xiàn)自己在多次場合盯著非常受歡迎的開源項目,,想知道現(xiàn)在我看到的東西怎么能有人理解,。為了證明一個觀點,這里是一個代碼行,,在一個100+行的函數(shù)中間引用 變量名種類低的另一個問題是意外的遮蔽和意外的重寫。考慮以下功能:
這段代碼中隱藏了一個令人討厭的小bug 哎,,這是一個在生產(chǎn)中很難發(fā)現(xiàn)的令人討厭的小bug,。你能找到它嗎?這應(yīng)該在代碼審查中被發(fā)現(xiàn),,但需要一個非常徹底的審查才能揭示它,。還有其他方法可以預(yù)防它嗎?確保在不需要時不重寫變量值(Go不強制執(zhí)行,,我們稍后會介紹),,然后努力為每個變量提供一個上下文定位良好的名稱。在某些方面,,這與習(xí)慣性的Go相反,,減少可用名稱的種類進一步傷害了它。 首字母縮略詞另一個對可讀性的討厭打擊是大寫首字母縮略詞的慣例,。習(xí)慣性的方式來命名一個指向某個HTTPS端點URL的公共變量是 接收者名稱最后 — 方法接收者名稱的約定。我建議閱讀Jesse Duffield's精美編寫的關(guān)于約定的文章 'Going Insane',。我無法比那更好地呈現(xiàn)或爭論,。簡而言之: 值安全與類型安全類似,,值安全是在編譯時進行運行時保證的一種手段。類型安全是指運行時的值的類型,,而值安全是指實際的值,。雖然類型安全是由一個單一、一致的類型系統(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,、Kotlin和TypeScript提供的功能)允許我們明確定義何時允許空值,。當允許時,在取消引用它們之前需要進行空檢查,。其他語言提供總和類型(如Rust和Scala中的類型),,首先防止空值。 我真的不能理解為什么一個程序員會提倡類型安全而忽視空安全。如果你希望你的編譯器執(zhí)行不調(diào)用未定義的方法,,當你試圖取消引用一個空值時,,同樣的邏輯不也應(yīng)該適用嗎?我相信,,不管怎樣,,現(xiàn)代語言必須配備這樣的執(zhí)行機制。 這里是一個提議向Go添加可空類型支持,。 枚舉不管你如何模型化你的代碼,,最終你總是會需要枚舉。一個變量可以有幾種可能的值中的一個,。如果你只有兩個可能的值 —— 布爾值會工作得很好,。如果你有三個或更多的選項,你將需要一個支持枚舉的機制,。 在Go中,,你可以做到……但其實并不真正可以。你可以定義常量,,但那只是它們的全部 —— 簡單的常量值,。它們并不保證一個宿主變量必須持有其中之一。即使你定義了一個自定義類型,。以下是一個例子:
帶有無效值的枚舉 在主函數(shù)中聲明的 別忘了46也可能來自外部,、非硬編碼的來源,。比如HTTP請求或文件。我們應(yīng)該總是記得手動驗證它。直到我們忘記了,。?? 這種方法的另一個問題是缺乏封裝。枚舉相關(guān)的行為不能在枚舉本身內(nèi)部定義,,只能通過switch case來定義,。然而,Go中的switch case并不強制覆蓋所有可能的值。下面是它的樣子:
未處理的枚舉情況 看起來不是那么糟糕,,是嗎,?但實際上確實很糟糕 - 每次你添加一個新的枚舉值,你都必須搜索你的代碼庫找到那些switch語句,。如果你漏掉了一個,,就像上面的例子沒有正確處理 缺少原生枚舉支持的其他問題是什么?如何遍歷一個枚舉的所有可能值呢,?你偶爾需要這樣做,,也許你需要將它發(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ù)的返回值,。例如:
嘗試在結(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”,。于是我這樣做了。
使用 sarama 配置的默認值 “*...現(xiàn)在我的配置肯定允許5次重試,” 我自言自語道,。?? 但直到幾天后,,當我再玩了一會兒,我突然想:“等等,,它們怎么知道的,?*” Sarama的代碼如何能夠區(qū)分使用 起初,我對這個庫的設(shè)計者非常生氣,,因為他們沒有強制防止這樣的錯誤,,“如果這發(fā)生在我身上” 和其他原因。然后我再思考了一下,,意識到,,他們不能。Go語言簡單地不提供這樣的功能,。乍一看,,結(jié)構(gòu)體字面量看起來像是配置參數(shù)用例的完美候選,允許你傳入你需要的內(nèi)容,,并省略其余內(nèi)容,。但事實證明,情況恰恰相反,。 這里有一個提議來支持類型的初始化器,。 常量賦值在花了一些時間與區(qū)分常量和變量值的語言打交道后,你開始注意到你的大多數(shù)賦值都是常量,。非常量賦值主要用于需要計數(shù),、連接或其他形式的聚合的計算。在大多數(shù)其他使用場景中,,變量的賦值是一次性操作,。例如,我們以 reactjs 的 GitHub 倉庫為例,,比較
變量遮蔽可能導(dǎo)致隱式行為 還記得這個嗎,?這里的 bug 是對 常量賦值不僅僅是關(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)建它們。例如 這里有一個不可變性的提議,。 錯誤處理錯誤處理可能是 Go 社區(qū)最大的爭議,,現(xiàn)在泛型的爭議已經(jīng)全部解決。隨著時間的推移,,我熟悉了許多錯誤處理的方法,,無論是在其他語言中還是在 Go 的提議中。我不敢說我自己可以想出最好的錯誤處理機制,。但我確實想說,,Go 的錯誤處理有許多優(yōu)點和優(yōu)勢。然而,,我們在這里是為了討論缺點,。首先,我想再次提及 Jesse Duffield 的關(guān)于錯誤處理的瘋狂之作,,他很好地描述了自己的痛點,。然后我會自己補充兩點。 首先,,我最近聽到很多 Gophers 爭論說 Go 中的顯式錯誤處理方法是一個好主意,,因為它迫使我們處理每一個錯誤。我們永遠不應(yīng)該簡單地傳播錯誤,。換句話說,,這種情況永遠不應(yīng)該發(fā)生:
錯誤處理不帶包裝 相反,我們應(yīng)該總是用新的錯誤包裝錯誤,,為調(diào)用者提供更多的上下文,,如下所示:
帶有包裝的錯誤處理 這種觀點我完全不接受。我首先聽到支持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)時都生成新的對象。使用單例對象來表示錯誤,。我們稱之為哨兵錯誤,。它們防止了不必要的分配和垃圾收集。除了性能提升,,錯誤比較也變得更簡單,。由于這些錯誤值是單例的,所以你可以簡單地使用一個簡單的等號運算符比較它們的值,,而不是比較它們的類型,。
哨兵錯誤 注意這種比較方式并不需要類型檢查。這工作得很好——它讀寫都很好,,性能也很好,。???? 然而,有時候我們不能使用哨兵錯誤,。有時我們希望錯誤消息包含與錯誤的特定出現(xiàn)相關(guān)的信息,。(例如,
偷懶地創(chuàng)建非哨兵錯誤 確實是個意外,。???♀?因為這個錯誤沒有特定的類型或?qū)嵗?/strong>,我們現(xiàn)在失去了有效檢查此類型錯誤的能力,。
我們?nèi)绾螀^(qū)分網(wǎng)絡(luò)錯誤,? 我們唯一的選擇是檢查錯誤消息本身上的
我們處理錯誤的唯一選擇太糟糕了 這在性能方面是很糟糕的。但更可怕的是,,它并不是保證性的,。很明顯, 無論是通過強烈的,、堅實的慣例,,還是通過編譯器強制: 這里有一個關(guān)于錯誤處理的語言提議 ,,它提出了添加一個 異步返回值Go中有許多同步機制,,其中一些是原生的,,一些是由Go SDK提供的。其他的則可以在許多開源庫中找到,。盡管如此,,你可以說Go代碼通常由兩個主要的同步機制主導(dǎo)——通道(原生)和 每個機制都有其固定的用途,,但還有另一個用途尚未被涵蓋,,我認為這第三種用途通常是最佳實踐。為了看到它的實際應(yīng)用,,讓我們考慮這個非常受歡迎的例子:我們想要并發(fā)地獲取幾個資源,,然后合并結(jié)果。讓我們首先使用
首次嘗試:使用 sync.WaitGroup 這有點明確,,像Go通常那樣,但它確實有效,。我們必須自己處理錯誤的事實對Go團隊來說似乎過于明確,,所以他們發(fā)布了一個名為
第二次嘗試:使用 errgroup.Group
上述的第一個缺點可以并且應(yīng)該用泛型來解決,,第二個仍然存在,。對于我們在并發(fā)函數(shù)中既不接受也不返回任何東西的情況,這是一個完美的機制,,但這些情況非常少見,。 我們繼續(xù)討論通道:
第三次嘗試:使用通道 對于這樣一個常見的用例,它仍然相當明確,。有很大的機會犯錯誤,,導(dǎo)致競態(tài)條件和死鎖。 現(xiàn)在想象一下,,如果
虛構(gòu)的 promise 語法 啊,這更好了。這類對象通常被稱為 這最后一個代碼片段是虛構(gòu)的,。它在 Go 中不存在,。但如果它存在的話,它不僅會更加易于閱讀,,而且會更安全,。由于 首先,, 第二個優(yōu)勢在于純函數(shù)編程。純函數(shù)使用返回值而不產(chǎn)生副作用,。這意味著我們可以依賴編譯器來確保我們不創(chuàng)建死鎖,。例如,當使用通道時,,我們必須接收一個通道參數(shù)并顯式地用結(jié)果調(diào)用它,。例如:
不能在處理顯式同步機制時編寫純函數(shù) 六個月后,另一個工程師(顯然,,永遠不是我們,,總是另一個工程師 ??)很容易犯一個錯誤,添加一個if語句然后返回,,忘記顯式調(diào)用通道,。這導(dǎo)致死鎖。顯然,這里沒有編譯錯誤,。
非純函數(shù)用于并發(fā)是死鎖陷阱 使用純函數(shù)時,,你必須在控制流分支時指定返回值。換句話說,,其他工程師簡單地不能造成死鎖,。
使用純函數(shù)不可能發(fā)生死鎖 此外,純函數(shù)形式是完全可復(fù)用的,。這只是一個執(zhí)行操作并返回結(jié)果的函數(shù),。它對任何特定的同步機制都不熟悉。它不接收通道或 在我看來,,缺乏堅固的社區(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與 對于執(zhí)行不返回任何值的異步或并發(fā)操作,, 這里有一些建議(1, 2)添加這樣的機制到語言中,。后者提議將通道別名為futures,。老實說,我不在乎我們叫它們什么,。我甚至喜歡重用通道而不是引入一個新概念的想法,。我只是想 總結(jié)如果你將社區(qū)的約定和命名問題與異步返回值問題結(jié)合起來,,你會得到一些非常受歡迎的庫,它們使用復(fù)雜的,、超過100行的函數(shù),、使用一個字母的未記錄的變量,,這些變量被聲明在包的另一側(cè),。這是非常難以閱讀和維護的,而且出奇地常見,。此外,,與其他現(xiàn)代語言不同,Go不提供任何類型的運行時值安全性,。這導(dǎo)致了許多與值相關(guān)的運行時問題,,這些問題很容易避免。 在系列的最后一篇文章中,,我們將討論我們可以做些什么來改進它,,以及為Go設(shè)計一個更好的未來的提議。 |
|
來自: 技術(shù)的游戲 > 《待分類》