Go 語言實踐(一)

騰訊雲+社群發表於2018-11-29

本文由Austin發表

指導原則

我們要談論在一個程式語言中的最佳實踐,那麼我們首先應該明確什麼是“最佳”。如果您們聽了我昨天那場講演的話,您一定看到了來自 Go 團隊的 Russ Cox 講的一句話:

軟體工程,是您在程式設計過程中增加了工期或者開發人員之後發生的那些事。 — Russ Cox

Russ 是在闡述軟體“程式設計”和軟體“工程”之間的區別,前者是您寫的程式,而後者是一個讓更多的人長期使用的產品。軟體工程師會來來去去地更換,團隊也會成長或者萎縮,需求也會發生變化,新的特性也會增加,bug 也會被修復,這就是軟體“工程”的本質。

我可能是現場最早的 Go 語言使用者,但與其說我的主張來自我的資歷,不如說我今天講的是真實來自於 Go 語言本身的指導原則,那就是:

  1. 簡單性
  2. 可讀性
  3. 生產率

您可能已經注意到,我並沒有提效能或者併發性。實際上有不少的語言執行效率比 Go 還要高,但它們一定沒有 Go 這麼簡單。有些語言也以併發性為最高目標,但它們的可讀性和生產率都不好。 效能併發性都很重要,但它們不如簡單性可讀性生產率那麼重要。

簡單性

為什麼我們要力求簡單,為什麼簡單對 Go 語言程式設計如此重要?

我們有太多的時候感嘆“這段程式碼我看不懂”,是吧?我們害怕修改一丁點程式碼,生怕這一點修改就導致其他您不懂的部分出問題,而您又沒辦法修復它。

這就是複雜性。複雜性把可讀的程式變得不可讀,複雜性終結了很多軟體專案。

簡單性是 Go 的最高目標。無論我們寫什麼程式,我們都應該能一致認為它應當簡單。

可讀性

Readability is essential for maintainability. — Mark Reinhold, JVM language summit 2018 可讀性對於可維護性至關重要。

為什麼 Go 程式碼的可讀性如此重要?為什麼我們應該力求可讀性?

Programs must be written for people to read, and only incidentally for machines to execute. — Hal Abelson and Gerald Sussman, Structure and Interpretation of Computer Programs 程式應該是寫來被人閱讀的,而只是順帶可以被機器執行。

可閱讀性對所有的程式——不僅僅是 Go 程式,都是如此之重要,是因為程式是人寫的並且給其他人閱讀的,事實上被機器所執行只是其次。

程式碼被閱讀的次數,遠遠大於被編寫的次數。一段小的程式碼,在它的整個生命週期,可能被閱讀成百上千次。

The most important skill for a programmer is the ability to effectively communicate ideas. — Gastón Jorquera ^1 程式設計師最重要的技能是有效溝通想法的能力。

可讀性是弄清楚一個程式是在做什麼事的關鍵。如果您都不知道這個程式在做什麼,您如何去維護這個程式?如果一個軟體不可用被維護,那就可能被重寫,並且這也可能是您公司最後一次在 GO 上面投入了。

如果您僅僅是為自己個人寫一個程式,可能這個程式是一次性的,或者使用這個程式的人也只有您一個,那您想怎樣寫就怎樣寫。但如果是多人合作貢獻的程式,或者因為它解決人們的需求、滿足某些特性、執行它的環境會變化,而在一個很長的時間內被很多人使用,那麼程式的可維護性則必須成為目標。

編寫可維護的程式的第一步,那就是確保程式碼是可讀的。

生產率

Design is the art of arranging code to work today, and be changeable forever. — Sandi Metz 設計是一門藝術,要求編寫的程式碼當前可用,並且以後仍能被改動。

我想重點闡述的最後一個基本原則是生產率。開發者的生產率是一個複雜的話題,但歸結起來就是:為了有效的工作,您因為一些工具、外部程式碼庫而浪費了多少時間。Go 程式設計師應該感受得到,他們在工作中可以從很多東西中受益了。(Austin Luo:言下之意是,Go 的工具集和基礎庫完備,很多東西觸手可得。)

有一個笑話是說,Go 是在 C++ 程式編譯過程中被設計出來的。快速的編譯是 Go 語言用以吸引新開發者的關鍵特性。編譯速度仍然是一個不變的戰場,很公平地說,其他語言需要幾分鐘才能編譯,而 Go 只需要幾秒即可完成。這有助於 Go 開發者擁有動態語言開發者一樣的高效,但卻不會面臨那些動態語言本身可靠性的問題。

Go 開發者意識到程式碼是寫來被閱讀的,並且把閱讀放在編寫之上。Go 致力於從工具集、習慣等方面強制要求程式碼必須編寫為一種特定樣式,這消除了學習專案特定術語的障礙,同時也可以僅僅從“看起來”不正確即可幫助開發者發現潛在的錯誤。

Go 開發者不會整日去除錯那些莫名其妙的編譯錯誤。他們也不會整日浪費時間在複雜的構建指令碼或將程式碼部署到生產中這事上。更重要的是他們不會花時間在嘗試搞懂同事們寫的程式碼是什麼意思這事上。

當 Go 語言團隊在談論一個語言必須擴充套件時,他們談論的就是生產率。

識別符號

我們要討論的第一個議題是識別符號。識別符號是一個名稱的描述詞,這個名稱可以是一個變數的名稱、一個函式的名稱、一個方法的名稱、一個型別的名稱或者一個包的名稱等等。

Poor naming is symptomatic of poor design. — Dave Cheney 拙劣的名稱是拙劣的設計的表徵。

鑑於 Go 的語法限制,我們為程式中的事物選擇的名稱對我們程式的可讀性產生了過大的影響。良好的可讀性是評判程式碼質量的關鍵,因此選擇好名稱對於 Go 程式碼的可讀性至關重要。

選擇清晰的名稱,而不是簡潔的名稱

Obvious code is important. What you can do in one line you should do in three. — Ukiah Smith 程式碼要明確這很重要,您在一行中能做的事,應該拆到三行裡做。

Go 不是專注於將程式碼精巧優化為一行的那種語言,Go 也不是致力於將程式碼精煉到最小行數的語言。我們並不追求原始碼在磁碟上佔用的空間更少,也不關心錄入程式碼需要多長時間。

Good naming is like a good joke. If you have to explain it, it’s not funny. — Dave Cheney 好的名稱就如同一個好的笑話,如果您需要去解釋它,那它就不搞笑了。

這個清晰度的關鍵就是我們為 Go 程式選擇的識別符號。讓我們來看看一個好的名稱應當具備什麼吧:

  • 好的名稱是簡潔的。一個好的名稱未必是儘可能短的,但它肯定不會浪費任何無關的東西在上面,好名字具有高訊雜比。
  • 好的名稱是描述性的。一個好的名稱應該描述一個變數或常量的使用,而非其內容。一個好的命名應該描述函式的結果或一個方法的行為,而不是這個函式或方法本身的操作。一個好的名稱應該描述一個包的目的,而不是包的內容。名稱描述的東西越準確,名稱越好。
  • 好的名稱是可預測的。您應該能夠從名稱中推斷出它的使用方式,這是選擇描述性名稱帶來的作用,同時也遵循了傳統。Go 開發者在談論慣用語時,即是說的這個。

接下來讓我們深入地討論一下。

識別符號長度

有時候人們批評 Go 風格推薦短變數名。正如 Rob Pike 所說,“Go 開發者想要的是合適長度的識別符號”。^1

Andrew Gerrand 建議通過使用更長的識別符號向讀者暗示它們具有更高的重要性。

The greater the distance between a name’s declaration and its uses, the longer the name should be. — Andrew Gerrand ^2 識別符號的宣告和使用間隔越遠,名稱的長度就應當越長。

據此,我們可以歸納一些指導意見:

  • 短變數名稱在宣告和上次使用之間的距離很短時效果很好。
  • 長變數名需要證明其不同的合理性:越長的變數名,越需要更多的理由來證明其合理。冗長、繁瑣的名稱與他們在頁面上的權重相比,攜帶的資訊很低。
  • 不要在變數名中包含其型別的名稱。
  • 常量需要描述其儲存的值的含義,而不是怎麼使用它。
  • 單字母變數可用於迴圈或邏輯分支,單詞變數可用於引數或返回值,多詞短語可用於函式和包這一級的宣告。
  • 單詞可用於方法、介面和包
  • 請記住,包的命名將成為使用者引用它時採用的名稱,確保這個名稱更有意義。

讓我們來看一個示例:

type Person struct {
  Name string
  Age  int
}

// AverageAge returns the average age of people.
func AverageAge(people []Person) int {
  if len(people) == 0 {
    return 0
  }

  var count, sum int
  for _, p := range people {
    sum += p.Age
    count += 1
  }

  return sum / count
}

在這個示例中,範圍變數p在定義之後只在接下來的一行使用。p在整頁原始碼和函式執行過程中都只生存一小段時間。對p感興趣的讀者只需要檢視兩行程式碼即可。

與之形成對比的是,變數people在函式引數中定義,並且存在了 7 行,同理的還有sumcount,這他們使用了更長的名稱,讀者必須關注更廣泛的程式碼行。

我也可以使用s而不是sum,用c(或n)而不是count,但這會將整個程式中的變數都聚集在相同的重要性上。我也可以使用p而不是people,但是這樣又有一個問題,那就是for ... range迴圈中的變數又用什麼?單數的 person 看起來也很奇怪,生存時間極短命名卻比匯出它的那個值更長。

Austin Luo:這裡說的是,若陣列people用變數名p,那麼從陣列中獲取的每一個元素取名就成了問題,比如用person,即使使用person看起來也很奇怪,一方面是單數,一方面person的生存週期只有兩行(很短),命名比生存週期更長的ppeople)還長了。 小竅門:跟使用空行在文件中分段一樣,使用空行將函式執行過程分段。在函式AverageAge中有按順序的三個操作。第一個是先決條件,檢查當people為空時我們不會除零,第二個是累加總和和計數,最後一個是計算平均數。

上下文是關鍵

絕大多數的命名建議都是根據上下文的,意識到這一點很重要。我喜歡稱之為原則,而不是規則。

iindex 這兩個識別符號有什麼不同?我們很難確切地說其中一個比另一個好,比如:

for index := 0; index < len(s); index++ {
  //
}

上述程式碼的可讀性,基本上都會認為比下面這段要強:

for i := 0; i < len(s); i++ {
  //
}

但我表示不贊同。因為無論是i還是index,都是限定於for迴圈體的,更冗長的命名,並沒有讓我們更容易地理解這段程式碼。

話說回來,下面兩段程式碼那一段可讀性更強呢?

func (s *SNMP) Fetch(oid []int, index int) (int, error)

或者

func (s *SNMP) Fetch(o []int, i int) (int, error)

在這個示例中,oidSNMP物件 ID 的縮寫,因此將其略寫為 o 意味著開發者必須將他們在文件中看到的常規符號轉換理解為程式碼中更短的符號。同樣地,將index簡略為i,減少了其作為SNMP訊息的索引的含義。

小竅門:在引數宣告中不要混用長、短不同的命名風格。

命名中不要包含所屬型別的名稱

正如您給寵物取名一樣,您會給狗取名“汪汪”,給貓取名為“咪咪”,但不會取名為“汪汪狗”、“咪咪貓”。出於同樣的原因,您也不應在變數名稱中包含其型別的名稱。

變數命名應該體現它的內容,而不是型別。我們來看下面這個例子:

var usersMap map[string]*User

這樣的命名有什麼好處呢?我們能知道它是個 map,並且它與*User型別有關,這可能還不錯。但是 Go 作為一種靜態型別語言,它並不會允許我們在需要標量變數的地方意外地使用到這個變數,因此Map字尾實際上是多餘的。

現在我們來看像下面這樣定義變數又是什麼情況:

var (
  companiesMap map[string]*Company
  productsMap map[string]*Products
)

現在這個範圍內我們有了三個 map 型別的變數了:usersMapcompaniesMap,以及 productsMap,所有這些都從字串對映到了不同的型別。我們知道它們都是 map,我們也知道它們的 map 宣告會阻止我們使用一個代替另一個——如果我們嘗試在需要map[string]*User的地方使用companiesMap,編譯器將丟擲錯誤。在這種情況下,很明顯Map字尾不會提高程式碼的清晰度,它只是程式設計時需要鍵入的冗餘內容。(Austin Luo:陳舊的思維方式)

我的建議是,避免給變數加上與型別相關的任何字尾。

小竅門:如果users不能描述得足夠清楚,那usersMap也一定不能。

這個建議也適用於函式引數,比如:

type Config struct {
  //
}

func WriteConfig(w io.Writer, config *Config)

*Config引數命名為config是多餘的,我們知道它是個*Config,函式簽名上寫得很清楚。

在這種情況建議考慮conf或者c——如果生命週期足夠短的話。

如果在一個範圍內有超過一個*Config,那命名為conf1conf2的描述性就比originalupdated更差,而且後者比前者更不容易出錯。

NOTE:不要讓包名佔用了更適合變數的名稱。 匯入的識別符號是會包含它所屬包的名稱的。 例如我們很清楚context.Context是包context中的型別Context。這就導致我們在我們自己的包裡,再也無法使用context作為變數或型別名了。 func WriteLog(context context.Context, message string) 這無法編譯。這也是為什麼我們通常將context.Context型別的變數命名為ctx的原因,如: func WriteLog(ctx context.Context, message string)

使用一致的命名風格

一個好名字的另一個特點是它應該是可預測的。閱讀者應該可以在第一次看到的時候就能夠理解它如何使用。如果遇到一個約定俗稱的名字,他們應該能夠認為和上次看到這個名字一樣,一直以來它都沒有改變意義。

例如,如果您要傳遞一個資料庫控制程式碼,請確保每次的引數命名都是一樣的。與其使用d *sql.DBdbase *sql.DBDB *sql.DBdatabase *sql.DB,還不如都統一為:

db *sql.DB

這樣做可以增進熟悉度:如果您看到db,那麼您就知道那是個*sql.DB,並且已經在本地定義或者由呼叫者提供了。

對於方法接收者也類似,在型別的每個方法中使用相同的接收者名稱,這樣可以讓閱讀者在跨方法閱讀和理解時更容易主觀推斷。

Austin Luo:“接收者”是一種特殊型別的引數。^2 比如func (b *Buffer) Read(p []byte) (n int, err error),它通常只用一到兩個字母來表示,但在不同的方法中仍然應當保持一致。 注意:Go 中對接收者的短命名規則慣例與目前提供的建議不一致。這只是早期做出的選擇之一,並且已經成為首選的風格,就像使用CamelCase而不是snake_case一樣。 小竅門:Go 的命名風格規定接收器具有單個字母名稱或其派生型別的首字母縮略詞。有時您可能會發現接收器的名稱有時會與方法中引數的名稱衝突,在這種情況下,請考慮使引數名稱稍長,並且仍然不要忘記一致地使用這個新名稱。

最後,某些單字母變數傳統上與迴圈和計數有關。例如,ij,和k通常是簡單的for迴圈變數。n通常與計數器或累加器有關。 v通常是某個值的簡寫,k通常用於對映的鍵,s通常用作string型別引數的簡寫。

與上面db的例子一樣,程式設計師期望i是迴圈變數。如果您保證i始終是一個迴圈變數——而不是在for迴圈之外的情況下使用,那麼當讀者遇到一個名為i或者j的變數時,他們就知道當前還在迴圈中。

小竅門:如果您發現在巢狀迴圈中您都使用完ijk了,那麼很顯然這已經到了將函式拆得更小的時候了。

使用一致的宣告風格

Go 中至少有 6 種宣告變數的方法(Austin Luo:作者說了 6 種,但只列了 5 種)

  • var x int = 1
  • var x = 1
  • var x int; x = 1
  • var x = int(1)
  • x := 1

我敢肯定還有更多我沒想到的。這是 Go 的設計師認識到可能是一個錯誤的地方,但現在改變它為時已晚。有這麼多不同的方式來宣告變數,那麼我們如何避免每個 Go 程式設計師選擇自己個性獨特的宣告風格呢?

我想展示一些在我自己的程式裡宣告變數的建議。這是我儘可能使用的風格。

  • 只宣告,不初始化時,使用*var*在宣告之後,將會顯式地初始化時,使用var關鍵字。

var players int // 0

var things []Thing // an empty slice of Things

var thing Thing // empty Thing struct

json.Unmarshall(reader, &thing)

var關鍵字表明這個變數被有意地宣告為該型別的零值。這也與在包級別宣告變數時使用var而不是短宣告語法(Austin Luo::=)的要求一致——儘管我稍後會說您根本不應該使用包級變數。

  • 既宣告,也初始化時,使用*:=*當同時要宣告和初始化變數時,換言之我們不讓變數隱式地被初始化為零值時,我建議使用短宣告語法的形式。這使得讀者清楚地知道:=左側的變數是有意被初始化的。

為解釋原因,我們回頭再看看上面的例子,但這一次每個變數都被有意初始化了:

var players int = 0

var things []Thing = nil

var thing *Thing = new(Thing)
json.Unmarshall(reader, thing)

第一個和第三個示例中,因為 Go 沒有從一種型別到另一種型別的自動轉換,賦值運算子左側和右側的型別必定是一致的。編譯器可以從右側的型別推斷出左側所宣告變數的型別。對於這個示例可以更簡潔地寫成這樣:

var players = 0

var things []Thing = nil

var thing = new(Thing)
json.Unmarshall(reader, thing)

由於0players的零值,因此為players顯式地初始化為0就顯得多餘了。所以為了更清晰地表明我們使用了零值,應該寫成這樣:

var players int

那第二條語句呢?我們不能忽視型別寫成:

var things = nil

因為nil根本就沒有型別^2。相反,我們有一個選擇,我們是否希望切片的零值?

var things []Thing

或者我們是否希望建立一個沒有元素的切片?

var things = make([]Thing, 0)

如果我們想要的是後者,這不是個切片型別的零值,那麼我們應該使用短宣告語法讓閱讀者很清楚地明白我們的選擇:

things := make([]Thing, 0)

這告訴了讀者我們顯式地初始化了things

再來看看第三個宣告:

var thing = new(Thing)

這既顯式地初始化了變數,也引入了 Go 程式設計師不喜歡而且很不常用的new關鍵字。如果我們遵循短命名語法的建議,那麼這句將變成:

thing := new(Thing)

這很清楚地表明,thing被顯式地初始化為new(Thing)的結果——一個指向Thing的指標——但仍然保留了我們不常用的new。我們可以通過使用緊湊結構初始化的形式來解決這個問題,

thing := &Thing{}

這和new(Thing)做了同樣的事——也因此很多 Go 程式設計師對這種重複感覺不安。不過,這一句仍然意味著我們為thing明確地初始化了一個Thing{}的指標——一個Thing的零值。

在這裡,我們應該意識到,thing被初始化為了零值,並且將它的指標地址傳遞給了json.Unmarshall

var thing Thing
json.Unmarshall(reader, &thing)

注意:當然,對於任何經驗法則都有例外。比如,有些變數之間很相關,那麼與其寫成這樣: var min int max := 1000 不如寫成這樣更具可讀性: min, max := 0, 1000

綜上所述:

  • 只宣告,不初始化時,使用var
  • 既宣告,也顯式地初始化時,使用:=

小竅門:使得機巧的宣告更加顯而易見。 當某件事本身很複雜時,應當使它看起來就複雜。 var length uint32 = 0x80 這裡的length可能和一個需要有特定數字型別的庫一起使用,並且length被很明確地指定為uint32型別而不只是短宣告形式: length := uint32(0x80) 在第一個例子中,我故意違反了使用var宣告形式和顯式初始化程式的規則。這個和我慣常形式不同的決定,可以讓讀者意識到這裡需要注意。

成為團隊合作者

我談到了軟體工程的目標,即生成可讀,可維護的程式碼。而您的大部分職業生涯參與的專案可能您都不是唯一的作者。在這種情況下我的建議是遵守團隊的風格。

在檔案中間改變編碼風格是不適合的。同樣,即使您不喜歡,可維護性也比您的個人喜好有價值得多。我的原則是:如果滿足gofmt,那麼通常就不值得再進行程式碼風格審查了。

小竅門:如果您要橫跨整個程式碼庫進行重新命名,那麼不要在其中混入其他的修改。如果其他人正在使用 git bisect,他們一定不願意從幾千行程式碼的重新命名中“跋山涉水”地去尋找您別的修改。

程式碼註釋

在我們進行下一個更大的主題之前,我想先花幾分鐘說說註釋的事。

Good code has lots of comments, bad code requires lots of comments. — Dave Thomas and Andrew Hunt, The Pragmatic Programmer 好的程式碼中附帶有大量的註釋,壞的程式碼缺少大量的註釋。

程式碼註釋對 Go 程式的可讀性極為重要。一個註釋應該做到如下三個方面的至少一個:

  1. 註釋應該解釋“做什麼”。
  2. 註釋應該解釋“怎麼做的”。
  3. 註釋應該解釋“為什麼這麼做”。

第一種形式適合公開的符號:

// Open opens the named file for reading.
// If successful, methods on the returned file can be used for reading.

第二種形式適合方法內的註釋:

// queue all dependant actions
var results []chan error
for _, dep := range a.Deps {
        results = append(results, execute(seen, dep))
}

第三種形式,“為什麼這麼做”,這是獨一無二的,無法被前兩種取代,也無法取代前兩種。第三種形式的註釋用於解釋更多的狀況,而這些狀況往往難以脫離上下文,否則將沒有意義,這些註釋就是用來闡述上下文的。

return &v2.Cluster_CommonLbConfig{
  // Disable HealthyPanicThreshold
  HealthyPanicThreshold: &envoy_type.Percent{
    Value: 0,
  },
}

在這個示例中,很難立即弄清楚把HealthyPanicThreshold的百分比設定為零會產生什麼影響。註釋就用來明確將值設定為0實際上是禁用了panic閾值的這種行為。

變數和常量上的註釋應當描述它的內容,而非目的

我之前談過,變數或常量的名稱應描述其目的。向變數或常量新增註釋時,應該描述變數的內容,而不是定義它的目的

const randomNumber = 6 // determined from an unbiased die

這個示例的註釋描述了“為什麼”randomNumber被賦值為 6,也說明了 6 這個值是從何而來的。但它沒有描述randomNumber會被用到什麼地方。下面是更多的例子:

const (
    StatusContinue           = 100 // RFC 7231, 6.2.1
    StatusSwitchingProtocols = 101 // RFC 7231, 6.2.2
    StatusProcessing         = 102 // RFC 2518, 10.1

    StatusOK                 = 200 // RFC 7231, 6.3.1

如在 RFC 7231 的第 6.2.1 節中定義的那樣,在 HTTP 語境中 100 被當做StatusContinue

小竅門:對於那些沒有初始值的變數,註釋應當描述誰將負責初始化它們 // sizeCalculationDisabled indicates whether it is safe // to calculate Types` widths and alignments. See dowidth. var sizeCalculationDisabled bool 這裡,通過註釋讓讀者清楚函式dowidth在負責維護sizeCalculationDisabled的狀態。 小竅門:隱藏一目瞭然的東西 Kate Gregory 提到一點^3,有時一個好的命名,可以省略不必要的註釋。 // registry of SQL drivers var registry = make(mapstringsql.Driver) 註釋是原始碼作者加的,因為registry沒能解釋清楚定義它的目的——它是個登錄檔,但是什麼的登錄檔? 通過重新命名變數名為sqlDrivers,現在我們很清楚這個變數的目的是儲存 SQL 驅動。 var sqlDrivers = make(mapstringsql.Driver) 現在註釋已經多餘了,可以移除。

總是為公開符號寫文件說明

因為 godoc 將作為您的包的文件,您應該總是為每個公開的符號寫好註釋說明——包括變數、常量、函式和方法——所有定義在您包內的公開符號。

這裡是 Go 風格指南的兩條規則:

  • 任何既不明顯也不簡短的公共功能必須加以註釋。
  • 無論長度或複雜程度如何,都必須對庫中的任何函式進行註釋。
package ioutil

// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call returns err == nil, not err == EOF. Because ReadAll is
// defined to read from src until EOF, it does not treat an EOF from Read
// as an error to be reported.
func ReadAll(r io.Reader) ([]byte, error)

對這個規則有一個例外:您不需要為實現介面的方法進行文件說明,特別是不要這樣:

// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

這個註釋等於說明都沒說,它沒有告訴您這個方法做了什麼,實際上更糟的是,它讓您去找別的地方的文件。在這種情況我建議將註釋整個去掉。

這裡有一個來自io這個包的示例:

// LimitReader returns a Reader that reads from r
// but stops with EOF after n bytes.
// The underlying implementation is a *LimitedReader.
func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} }

// A LimitedReader reads from R but limits the amount of
// data returned to just N bytes. Each call to Read
// updates N to reflect the new amount remaining.
// Read returns EOF when N <= 0 or when the underlying R returns EOF.
type LimitedReader struct {
  R Reader // underlying reader
  N int64  // max bytes remaining
}

func (l *LimitedReader) Read(p []byte) (n int, err error) {
  if l.N <= 0 {
    return 0, EOF
  }
  if int64(len(p)) > l.N {
    p = p[0:l.N]
  }
  n, err = l.R.Read(p)
  l.N -= int64(n)
  return
}

請注意,LimitedReader的宣告緊接在使用它的函式之後,並且LimitedReader.Read又緊接著定義在LimitedReader之後,即便LimitedReader.Read本身沒有文件註釋,那和很清楚它是io.Reader的一種實現。

小竅門:在您編寫函式之前先寫描述這個函式的註釋,如果您發現註釋很難寫,那就表明您正準備寫的這段程式碼一定難以理解。

不要為壞的程式碼寫註釋,重寫它

Don’t comment bad code — rewrite it — Brian Kernighan 不要為壞的程式碼寫註釋——重寫它

為粗製濫造的程式碼片段著重寫註釋是不夠的,如果您遭遇到一段這樣的註釋,您應該發起一個問題(issue)從而記得後續重構它。技術債務只要不是過多就沒有關係。

在標準庫的慣例是,批註一個 TODO 風格的註釋,說明是誰發現了壞程式碼。

// TODO(dfc) this is O(N^2), find a faster way to do this.

註釋中的姓名並不意味著承諾去修復問題,但在解決問題時,他可能是最合適的人選。其他批註內容一般還有日期或者問題編號。

與其為一大段程式碼寫註釋,不如重構它

Good code is its own best documentation. As you’re about to add a comment, ask yourself, `How can I improve the code so that this comment isn’t needed?` Improve the code and then document it to make it even clearer. — Steve McConnell 好的程式碼即為最好的文件。在您準備新增一行註釋時,問自己,“我要如何改進這段程式碼從而使它不需要註釋?”優化程式碼,然後註釋它使之更清晰。

函式應該只做一件事。如果您發現一段程式碼因為與函式的其他部分不相關因而需要註釋時,考慮將這段程式碼拆分為獨立的函式。

除了更容易理解之外,較小的函式更容易單獨測試,現在您將不相關的程式碼隔離拆分到不同的函式中,估計只有函式名才是唯一需要的文件註釋了。

此文已由作者授權騰訊雲+社群釋出,更多原文請點選

搜尋關注公眾號「雲加社群」,第一時間獲取技術乾貨,關注後回覆1024 送你一份技術課程大禮包!

相關文章