This commit is contained in:
chai2010 2016-01-02 21:17:21 +08:00
parent 8772a9c000
commit ba03c527c0
17 changed files with 63 additions and 29 deletions

View File

@ -15,7 +15,7 @@ func name(parameter-list) (result-list) {
func hypot(x, y float64) float64 {
return math.Sqrt(x*x + y*y)
}
fmt.Println(hypot(3,4)) //"5"
fmt.Println(hypot(3,4)) // "5"
```
x和y是形參名,3和4是調用時的傳入的實數函數返迴了一個float64類型的值。

View File

@ -44,6 +44,7 @@ fmt.Println(p) // "{2, 4}"
```
不過後面兩種方法有些笨拙。幸運的是go語言本身在這種地方會幫到我們。如果接收器p是一個Point類型的變量併且其方法需要一個Point指針作爲接收器我們可以用下面這種簡短的寫法
```go
p.ScaleBy(2)
```
@ -61,21 +62,23 @@ pptr.Distance(q)
(*pptr).Distance(q)
```
Lets summarize these three cases again, since they are a frequent point of confusion. In every valid method call expression, exactly one of these three statements is true.
這里的幾個例子可能讓你有些睏惑,所以我們總結一下:在每一個合法的方法調用表達式中,也就是下面三種情況里的任意一種情況都是可以的:
不論是接收器的實際參數和其接收器的形式參數相同比如兩者都是類型T或者都是類型`*T`
```go
Point{1, 2}.Distance(q) // Point
pptr.ScaleBy(2) // *Point
```
或者接收器形參是類型T但接收器實參是類型`*T`,這種情況下編譯器會隱式地爲我們取變量的地址:
```go
p.ScaleBy(2) // implicit (&p)
```
或者接收器形參是類型`*T`實參是類型T。編譯器會隱式地爲我們解引用取到指針指向的實際變量
```go
pptr.Distance(q) // implicit (*pptr)
```
@ -83,6 +86,7 @@ pptr.Distance(q) // implicit (*pptr)
如果類型T的所有方法都是用T類型自己來做接收器(而不是`*T`)那麽拷貝這種類型的實例就是安全的調用他的任何一個方法也就會産生一個值的拷貝。比如time.Duration的這個類型在調用其方法時就會被全部拷貝一份包括在作爲參數傳入函數的時候。但是如果一個方法使用指針作爲接收器你需要避免對其進行拷貝因爲這樣可能會破壞掉該類型內部的不變性。比如你對bytes.Buffer對象進行了拷貝那麽可能會引起原始對象和拷貝對象隻是别名而已但實際上其指向的對象是一致的。緊接着對拷貝後的變量進行脩改可能會有讓你意外的結果。
譯註:作者這里説的比較繞,其實有兩點:
1.不管你的method的receiver是指針類型還是非指針類型都是可以通過指針/非指針類型進行調用的,編譯器會幫你做類型轉換
2.在聲明一個method的receiver該是指針還是非指針類型時你需要考慮兩方面的內部第一方面是這個對象本身是不是特别大如果聲明爲非指針變量時調用會産生一次拷貝第二方面是如果你用指針類型作爲receiver那麽你一定要註意這種指針類型指向的始終是一塊內存地址就算你對其進行了拷貝。熟悉C或者C艹的人這里應該很快能明白。
@ -109,7 +113,6 @@ func (list *IntList) Sum() int {
下面是net/url包里Values類型定義的一部分。
```go
net/url
package url

View File

@ -1,6 +1,7 @@
## 6.3. 通過嵌入結構體來擴展類型
來看看ColoredPoint這個類型
```go
gopl.io/ch6/coloredpoint
import "image/color"
@ -33,6 +34,7 @@ p.ScaleBy(2)
q.ScaleBy(2)
fmt.Println(p.Distance(q.Point)) // "10"
```
Point類的方法也被引入了ColoredPoint。用這種方式內嵌可以使我們定義字段特别多的複雜類型我們可以將字段先按小類型分組然後定義小類型的方法之後再把它們組合起來。
讀者如果對基於類來實現面向對象的語言比較熟悉的話可能會傾向於將Point看作一個基類而ColoredPoint看作其子類或者繼承類或者將ColoredPoint看作"is a" Point類型。但這是錯誤的理解。請註意上面例子中對Distance方法的調用。Distance有一個參數是Point類型但q併不是一個Point類所以盡管q有着Point這個內嵌類型我們也必鬚要顯式地選擇它。嚐試直接傳q的話你會看到下面這樣的錯誤
@ -72,6 +74,7 @@ fmt.Println(*p.Point, *q.Point) // "{2 2} {2 2}"
```
一個struct類型也可能會有多個匿名字段。我們將ColoredPoint定義爲下面這樣
```go
type ColoredPoint struct {
Point
@ -118,6 +121,3 @@ func Lookup(key string) string {
```
我們給新的變量起了一個更具表達性的名字cache。因爲sync.Mutex字段也被嵌入到了這個struct里其Lock和Unlock方法也就都被引入到了這個匿名結構中了這讓我們能夠以一個簡單明了的語法來對其進行加鎖解鎖操作。

View File

@ -6,10 +6,11 @@
p := Point{1, 2}
q := Point{4, 6}
distanceFromP := p.Distance // method value
fmt.Println(distanceFromP(q)) // "5"
var origin Point // {0, 0}
fmt.Println(distanceFromP(origin)) // "2.23606797749979", ;5
distanceFromP := p.Distance // method value
fmt.Println(distanceFromP(q)) // "5"
var origin Point // {0, 0}
fmt.Println(distanceFromP(origin)) // "2.23606797749979", sqrt(5)
scaleP := p.ScaleBy // method value
scaleP(2) // p becomes (2, 4)
scaleP(3) // then (6, 12)
@ -30,6 +31,7 @@ time.AfterFunc(10 * time.Second, func() { r.Launch() })
```go
time.AfterFunc(10 * time.Second, r.Launch)
```
譯註:省掉了上面那個例子里的匿名函數。
和方法"值"相關的還有方法表達式。當調用一個方法時,與調用一個普通的函數相比,我們必鬚要用選擇器(p.Distance)語法來指定方法的接收器。
@ -41,7 +43,6 @@ p := Point{1, 2}
q := Point{4, 6}
distance := Point.Distance // method expression
//譯註這個Distance實際上是指定了Point對象爲接收器的一個方法func (p Point) Distance()但通過Point.Distance得到的函數需要比實際的Distance方法多一個參數卽其需要用第一個額外參數指定接收器後面排列Distance方法的參數。看起來本書中函數和方法的區别是指有沒有接收器而不像其他語言那樣是指有沒有返迴值。
fmt.Println(distance(p, q)) // "5"
fmt.Printf("%T\n", distance) // "func(Point, Point) float64"
@ -49,6 +50,11 @@ scale := (*Point).ScaleBy
scale(&p, 2)
fmt.Println(p) // "{2 4}"
fmt.Printf("%T\n", scale) // "func(*Point, float64)"
// 譯註這個Distance實際上是指定了Point對象爲接收器的一個方法func (p Point) Distance()
// 但通過Point.Distance得到的函數需要比實際的Distance方法多一個參數
// 卽其需要用第一個額外參數指定接收器後面排列Distance方法的參數。
// 看起來本書中函數和方法的區别是指有沒有接收器,而不像其他語言那樣是指有沒有返迴值。
```
當你根據一個變量來決定調用同一個類型的哪個函數時方法表達式就顯得很有用了。你可以根據選擇來調用接收器各不相同的方法。下面的例子變量op代表Point類型的addition或者subtraction方法Path.TranslateBy方法會爲其Path數組中的每一個Point來調用對應的方法

View File

@ -69,6 +69,7 @@ func (s *IntSet) String() string {
這里留意一下String方法是不是和3.5.4節中的intsToString方法很相似bytes.Buffer在String方法里經常這麽用。當你爲一個複雜的類型定義了一個String方法時fmt包就會特殊對待這種類型的值這樣可以讓這些類型在打印的時候看起來更加友好而不是直接打印其原始的值。fmt會直接調用用戶定義的String方法。這種機製依賴於接口和類型斷言在第7章中我們會詳細介紹。
現在我們就可以在實戰中直接用上面定義好的IntSet了
```go
var x, y IntSet
x.Add(1)
@ -86,14 +87,17 @@ fmt.Println(x.Has(9), x.Has(123)) // "true false"
```
這里要註意我們聲明的String和Has兩個方法都是以指針類型*IntSet來作爲接收器的但實際上對於這兩個類型來説把接收器聲明爲指針類型也沒什麽必要。不過另外兩個函數就不是這樣了因爲另外兩個函數操作的是s.words對象如果你不把接收器聲明爲指針對象那麽實際操作的是拷貝對象而不是原來的那個對象。因此因爲我們的String方法定義在IntSet指針上所以當我們的變量是IntSet類型而不是IntSet指針時可能會有下面這樣讓人意外的情況
```go
fmt.Println(&x) // "{1 9 42 144}"
fmt.Println(x.String()) // "{1 9 42 144}"
fmt.Println(x) // "{[4398046511618 0 65536]}"
```
在第一個Println中我們打印一個*IntSet的指針這個類型的指針確實有自定義的String方法。第二Println我們直接調用了x變量的String()方法這種情況下編譯器會隱式地在x前插入&操作符這樣相當遠我們還是調用的IntSet指針的String方法。在第三個Println中因爲IntSet類型沒有String方法所以Println方法會直接以原始的方式理解併打印。所以在這種情況下&符號是不能忘的。在我們這種場景下你把String方法綁定到IntSet對象上而不是IntSet指針上可能會更合適一些不過這也需要具體問題具體分析。
練習6.1: 爲bit數組實現下面這些方法
```go
func (*IntSet) Len() int // return the number of elements
func (*IntSet) Remove(x int) // remove x from the set

View File

@ -5,6 +5,7 @@
Go語言隻有一種控製可見性的手段大寫首字母的標識符會從定義它們的包中被導出小寫字母的則不會。這種限製包內成員的方式同樣適用於struct或者一個類型的方法。因而如果我們想要封裝一個對象我們必鬚將其定義爲一個struct。
這也就是前面的小節中IntSet被定義爲struct類型的原因盡管它隻有一個字段
```go
type IntSet struct {
words []uint64
@ -12,6 +13,7 @@ type IntSet struct {
```
當然我們也可以把IntSet定義爲一個slice類型盡管這樣我們就需要把代碼中所有方法里用到的s.words用*s替換掉了
```go
type IntSet []uint64
```
@ -35,7 +37,6 @@ type Buffer struct {
// Grow expands the buffer's capacity, if necessary,
// to guarantee space for another n bytes. [...]
func (b *Buffer) Grow(n int) {
if b.buf == nil {
b.buf = b.initial[:0] // use preallocated space initially

View File

@ -6,16 +6,15 @@
在早些的章節中我們已經使用了標準庫提供的一些方法比如time.Duration這個類型的Seconds方法
```
const day = 24 * time.Hour
fmt.Println(day.Seconds()) // "86400"
```Go
const day = 24 * time.Hour
fmt.Println(day.Seconds()) // "86400"
```
併且在2.5節中我們定義了一個自己的方法Celsius類型的String方法:
```go
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
```Go
func (c Celsius) String() string { return fmt.Sprintf("%g°C", c) }
```
在本章中OOP編程的第一方面我們會向你展示如何有效地定義和使用方法。我們會覆蓋到OOP編程的兩個關鍵點封裝和組合。

View File

@ -5,6 +5,7 @@
在Go語言中還存在着另外一種類型接口類型。接口類型是一種抽象的類型。它不會暴露出它所代表的對象的內部值的結構和這個對象支持的基礎操作的集合它們隻會展示出它們自己的方法。也就是説當你有看到一個接口類型的值時你不知道它是什麽唯一知道的就是可以通過它的方法來做什麽。
在本書中我們一直使用兩個相似的函數來進行字符串的格式化fmt.Printf它會把結果寫到標準輸出和fmt.Sprintf它會把結果以字符串的形式返迴。得益於使用接口我們不必可悲的因爲返迴結果在使用方式上的一些淺顯不同就必需把格式化這個最睏難的過程複製一份。實際上這兩個函數都使用了另一個函數fmt.Fprintf來進行封裝。fmt.Fprintf這個函數對它的計算結果會被怎麽使用是完全不知道的。
``` go
package fmt
func Fprintf(w io.Writer, format string, args ...interface{}) (int, error)
@ -17,10 +18,12 @@ func Sprintf(format string, args ...interface{}) string {
return buf.String()
}
```
Fprintf的前綴F表示文件(File)也表明格式化輸出結果應該被寫入第一個參數提供的文件中。在Printf函數中的第一個參數os.Stdout是*os.File類型在Sprintf函數中的第一個參數&buf是一個指向可以寫入字節的內存緩衝區然而它
併不是一個文件類型盡管它在某種意義上和文件類型相似。
卽使Fprintf函數中的第一個參數也不是一個文件類型。它是io.Writer類型這是一個接口類型定義如下
``` go
package io
// Writer is the interface that wraps the basic Write method.
@ -41,6 +44,7 @@ io.Writer類型定義了函數Fprintf和這個函數調用者之間的約定。
因爲fmt.Fprintf函數沒有對具體操作的值做任何假設而是僅僅通過io.Writer接口的約定來保證行爲所以第一個參數可以安全地傳入一個任何具體類型的值隻需要滿足io.Writer接口。一個類型可以自由的使用另一個滿足相同接口的類型來進行替換被稱作可替換性(LSP里氏替換)。這是一個面向對象的特徵。
讓我們通過一個新的類型來進行校驗,下面\*ByteCounter類型里的Write方法僅僅在丟失寫向它的字節前統計它們的長度。(在這個+=賦值語句中讓len(p)的類型和\*c的類型匹配的轉換是必鬚的。)
```go
// gopl.io/ch7/bytecounter
type ByteCounter int
@ -50,6 +54,7 @@ func (c *ByteCounter) Write(p []byte) (int, error) {
}
```
因爲*ByteCounter滿足io.Writer的約定我們可以把它傳入Fprintf函數中Fprintf函數執行字符串格式化的過程不會去關註ByteCounter正確的纍加結果的長度。
```go
var c ByteCounter
c.Write([]byte("hello"))
@ -59,7 +64,9 @@ var name = "Dolly"
fmt.Fprintf(&c, "hello, %s", name)
fmt.Println(c) // "12", = len("hello, Dolly")
```
除了io.Writer這個接口類型還有另一個對fmt包很重要的接口類型。Fprintf和Fprintln函數向類型提供了一種控製它們值輸出的途徑。在2.5節中我們爲Celsius類型提供了一個String方法以便於可以打印成這樣"100°C" 在6.5節中我們給*IntSet添加一個String方法這樣集合可以用傳統的符號來進行表示就像"{1 2 3}"。給一個類型定義String方法可以讓它滿足最廣泛使用之一的接口類型fmt.Stringer
```go
package fmt
// The String method is used to print values passed
@ -69,12 +76,15 @@ type Stringer interface {
String() string
}
```
我們會在7.10節解釋fmt包怎麽發現哪些值是滿足這個接口類型的。
練習7.1:使用來自ByteCounter的思路實現一個針對對單詞和行數的計數器。你會發現bufio.ScanWords非常的有用。
練習7.2:寫一個帶有如下函數籤名的函數CountingWriter傳入一個io.Writer接口類型返迴一個新的Writer類型把原來的Writer封裝在里面和一個表示寫入新的Writer字節數的int64類型指針
```go
func CountingWriter(w io.Writer) (io.Writer, *int64)
```
練習7.3:爲在gopl.io/ch4/treesort (§4.4)的*tree類型實現一個String方法去展示tree類型的值序列。

View File

@ -2,6 +2,7 @@
接口類型具體描述了一繫列方法的集合,一個實現了這些方法的具體類型是這個接口類型的實例。
io.Writer類型是用的最廣泛的接口之一因爲它提供了所有的類型寫入bytes的抽象包括文件類型內存緩衝區網絡鏈接HTTP客戶端壓縮工具哈希等等。io包中定義了很多其它有用的接口類型。Reader可以代表任意可以讀取bytes的類型Closer可以是任意可以關閉的值例如一個文件或是網絡鏈接。到現在你可能註意到了很多Go語言中單方法接口的命名習慣
```go
package io
type Reader interface {
@ -11,9 +12,10 @@ type Closer interface {
Close() error
}
```
在往下看,我們發現有些新的接口類型通過組合已經有的接口來定義。下面是兩個例子:
```go
在往下看,我們發現有些新的接口類型通過組合已經有的接口來定義。下面是兩個例子:
```go
type ReadWriter interface {
Reader
Writer
@ -25,24 +27,29 @@ type ReadWriteCloser interface {
}
```
上面用到的語法和結構內嵌相似我們可以用這種方式以一個簡寫命名另一個接口而不用聲明它所有的方法。這種方式本稱爲接口內嵌。盡管略失簡潔我們可以像下面這樣不使用內嵌來聲明io.Writer接口。
```go
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}
```
或者甚至使用種混合的風格:
```go
type ReadWriter interface {
Read(p []byte) (n int, err error)
Writer
}
```
上面3種定義方式都是一樣的效果。方法的順序變化也沒有影響唯一重要的就是這個集合里面的方法。
練習7.4:strings.NewReader函數通過讀取一個string參數返迴一個滿足io.Reader接口類型的值和其它值。實現一個簡單版本的NewReader併用它來構造一個接收字符串輸入的HTML解析器§5.2
練習7.5:io包里面的LimitReader函數接收一個io.Reader接口類型的r和字節數n併且返迴另一個從r中讀取字節但是當讀完n個字節後就表示讀到文件結束的Reader。實現這個LimitReader函數
```go
func LimitReader(r io.Reader, n int64) io.Reader
```

View File

@ -41,9 +41,11 @@ func fib(x int) int {
```
動畵顯示了幾秒之後fib(45)的調用成功地返迴,併且打印結果:
```
Fibonacci(45) = 1134903170
```
然後主函數返迴。當主函數返迴時所有的goroutine都會直接打斷程序退出。除了從主函數退出或者直接退出程序之外沒有其它的編程方法能夠讓一個goroutine來打斷另一個的執行但是我們之後可以看到可以通過goroutine之間的通信來讓一個goroutine請求請求其它的goroutine併讓其自己結束執行。
註意這里的兩個獨立的單元是如何進行組合的spinning和菲波那契的計算。每一個都是寫在獨立的函數中但是每一個函數都會併發地執行。

View File

@ -3,6 +3,7 @@
網絡編程是併發大顯身手的一個領域由於服務器是最典型的需要同時處理很多連接的程序這些連接一般來自遠彼此獨立的客戶端。在本小節中我們會講解go語言的net包這個包提供編寫一個網絡客戶端或者服務器程序的基本組件無論兩者間通信是使用TCPUDP或者Unix domain sockets。在第一章中我們已經使用過的net/http包里的方法也算是net包的一部分。
我們的第一個例子是一個順序執行的時鐘服務器,它會每隔一秒鐘將當前時間寫到客戶端:
```go
gopl.io/ch8/clock1
// Clock1 is a TCP server that periodically writes the time.
@ -92,6 +93,7 @@ func mustCopy(dst io.Writer, src io.Reader) {
}
}
```
這個程序會從連接中讀取數據併將讀到的內容寫到標準輸出中直到遇到end of file的條件或者發生錯誤。mustCopy這個函數我們在本節的幾個例子中都會用到。讓我們同時運行兩個客戶端來進行一個測試這里可以開兩個終端窗口下面左邊的是其中的一個的輸出右邊的是另一個的輸出
```
@ -124,6 +126,7 @@ for {
}
```
現在多個客戶端可以同時接收到時間了:
```

View File

@ -8,6 +8,7 @@ func handleConn(c net.Conn) {
c.Close()
}
```
一個更有意思的echo服務應該模擬一個實際的echo的“迴響”併且一開始要用大寫HELLO來表示“聲音很大”之後經過一小段延遲返迴一個有所緩和的Hello然後一個全小寫字母的hello表示聲音漸漸變小直至消失像下面這個版本的handleConn(譯註:笑看作者腦洞大開)
```go

View File

@ -56,6 +56,7 @@ https://golang.org/blog/
too many open files
...
```
最初的錯誤信息是一個讓人莫名的DNS査找失敗卽使這個域名是完全可靠的。而隨後的錯誤信息揭示了原因這個程序一次性創建了太多網絡連接超過了每一個進程的打開文件數限製旣而導致了在調用net.Dial像DNS査找失敗這樣的問題。
這個程序實在是太他媽併行了。無窮無盡地併行化併不是什麽好事情因爲不管怎麽説你的繫統總是會有一個些限製因素比如CPU覈心數會限製你的計算負載比如你的硬盤轉軸和磁頭數限製了你的本地磁盤IO操作頻率比如你的網絡帶寬限製了你的下載速度上限或者是你的一個web服務的服務容量上限等等。爲了解決這個問題我們可以限製併發程序所使用的資源來使之適應自己的運行環境。對於我們的例子來説最簡單的方法就是限製對links.Extract在同一時間最多不會有超過n次調用這里的n是fd的limit-20一般情況下。這個一個夜店里限製客人數目是一個道理隻有當有客人離開時才會允許新的客人進入店內(譯註:作者你個老流氓)。

View File

@ -109,7 +109,7 @@ Tick函數挺方便但是隻有當程序整個生命週期都需要這個時
```go
ticker := time.NewTicker(1 * time.Second)
<-ticker.C // receive from the ticker's channel
<-ticker.C // receive from the ticker's channel
ticker.Stop() // cause the ticker's goroutine to terminate
```
@ -126,6 +126,7 @@ default:
// do nothing
}
```
channel的零值是nil。也許會讓你覺得比較奇怪nil的channel有時候也是有一些用處的。因爲對一個nil的channel發送和接收操作會永遠阻塞在select語句中操作nil的channel永遠都不會被select到。
這使得我們可以用nil來激活或者禁用case來達成處理其它輸入或輸出事件時超時和取消的邏輯。我們會在下一節中看到一個例子。

View File

@ -32,7 +32,6 @@ ioutil.ReadDir函數會返迴一個os.FileInfo類型的sliceos.FileInfo類型
下面的主函數用了兩個goroutine。後台的goroutine調用walkDir來遍歷命令行給出的每一個路徑併最終關閉fileSizes這個channel。主goroutine會對其從channel中接收到的文件大小進行纍加併輸出其和。
```go
package main
@ -75,7 +74,9 @@ func printDiskUsage(nfiles, nbytes int64) {
}
```
這個程序會在打印其結果之前卡住很長時間。
```
$ go build gopl.io/ch8/du1
$ ./du1 $HOME /usr /bin /etc
@ -176,11 +177,8 @@ func dirents(dir string) []os.FileInfo {
sema <- struct{}{} // acquire token
defer func() { <-sema }() // release token
// ...
```
這個版本比之前那個快了好幾倍,盡管其具體效率還是和你的運行環境,機器配置相關。
練習8.9: 編寫一個du工具每隔一段時間將root目録下的目録大小計算併顯示出來。

View File

@ -63,7 +63,6 @@ func walkDir(dir string, n *sync.WaitGroup, fileSizes chan<- int64) {
// ...
}
}
```
在walkDir函數的循環中我們對取消狀態進行輪詢可以帶來明顯的益處可以避免在取消事件發生時還去創建goroutine。取消本身是有一些代價的想要快速的響應需要對程序邏輯進行侵入式的脩改。確保在取消發生之後不要有代價太大的操作可能會需要脩改你代碼里的很多地方但是在一些重要的地方去檢査取消事件也確實能帶來很大的好處。

View File

@ -24,7 +24,6 @@ func main() {
然後是broadcaster的goroutine。他的內部變量clients會記録當前建立連接的客戶端集合。其記録的內容是每一個客戶端的消息發出channel的"資格"信息。
```go
type client chan<- string // an outgoing message channel
@ -90,6 +89,7 @@ func clientWriter(conn net.Conn, ch <-chan string) {
另外handleConn爲每一個客戶端創建了一個clientWriter的goroutine來接收向客戶端發出消息channel中發送的廣播消息併將它們寫入到客戶端的網絡連接。客戶端的讀取方循環會在broadcaster接收到leaving通知併關閉了channel後終止。
下面演示的是當服務器有兩個活動的客戶端連接併且在兩個窗口中運行的情況使用netcat來聊天
```
$ go build gopl.io/ch8/chat
$ go build gopl.io/ch8/netcat3
@ -110,7 +110,6 @@ You are 127.0.0.1:64216 127.0.0.1:64216 has arrived
127.0.0.1:64211: Welcome. 127.0.0.1:64211: Welcome.
^C
127.0.0.1:64211 has left”
```
當與n個客戶端保持聊天session時這個程序會有2n+2個併發的goroutine然而這個程序卻併不需要顯式的鎖(§9.2)。clients這個map被限製在了一個獨立的goroutine中broadcaster所以它不能被併發地訪問。多個goroutine共享的變量隻有這些channel和net.Conn的實例兩個東西都是併發安全的。我們會在下一章中更多地解決約束併發安全以及goroutine中共享變量的含義。