Lưu ý: Tài liệu này được viết cho bản phát hành Go năm 2009 và không còn được cập nhật thường xuyên. Nó vẫn là một hướng dẫn tốt để sử dụng phần lõi của ngôn ngữ, nhưng không đề cập tới những thay đổi quan trọng của ngôn ngữ (generics), của hệ sinh thái (modules), hay các thư viện được bổ sung từ đó tới nay. Xem issue 28782 để biết bối cảnh. Để có danh sách thay đổi đầy đủ, xem ghi chú phát hành.
Giới thiệu
Go là một ngôn ngữ lập trình mã nguồn mở chú trọng sự đơn giản, độ tin cậy và hiệu quả, được thiết kế riêng để việc xây dựng phần mềm ở quy mô lớn trở nên dễ dàng. Dù vay mượn ý tưởng từ những ngôn ngữ đã có, Go mang những đặc tính khác thường khiến các chương trình Go hiệu quả có bản chất khác hẳn những chương trình viết bằng các ngôn ngữ họ hàng của nó. Việc dịch thẳng một chương trình C++ hay Java sang Go khó lòng cho ra kết quả thoả đáng—chương trình Java được viết bằng Java, chứ không phải bằng Go. Ngược lại, suy nghĩ về bài toán từ góc nhìn Go có thể sinh ra một chương trình thành công nhưng khá khác biệt. Nói cách khác, để viết Go cho tốt, điều quan trọng là hiểu các đặc tính và lối viết đặc trưng của nó. Cũng quan trọng không kém là nắm được những quy ước đã được thiết lập khi lập trình bằng Go, chẳng hạn cách đặt tên, định dạng, cách dựng chương trình, v.v., để những chương trình bạn viết dễ hiểu đối với các lập trình viên Go khác.
Tài liệu này đưa ra những lời khuyên để viết mã Go sáng sủa, đúng chất Go. Nó bổ trợ cho đặc tả ngôn ngữ, Tour of Go, và How to Write Go Code, tất cả những tài liệu đó bạn nên đọc trước.
Ví dụ
Mã nguồn các package của Go được thiết kế để phục vụ không chỉ như thư viện lõi mà còn như ví dụ về cách dùng ngôn ngữ. Hơn nữa, nhiều package chứa các ví dụ chạy được, khép kín, có thể thực thi mà bạn có thể chạy trực tiếp từ trang web go.dev, chẳng hạn ví dụ này (nếu cần, hãy bấm vào chữ “Example” để mở ra). Nếu bạn có thắc mắc về cách tiếp cận một bài toán hay cách hiện thực một thứ gì đó, tài liệu, mã nguồn và các ví dụ trong thư viện có thể cho bạn câu trả lời, ý tưởng và bối cảnh.
Định dạng
Các vấn đề về định dạng là thứ gây tranh cãi nhiều nhất nhưng lại ít hệ quả nhất. Người ta có thể thích nghi với những phong cách định dạng khác nhau, nhưng sẽ tốt hơn nếu họ không phải làm vậy, và thời gian dành cho chủ đề này sẽ ít đi nếu mọi người cùng theo một phong cách. Vấn đề là làm sao đạt tới thế giới lý tưởng đó mà không cần một bản hướng dẫn phong cách dài dòng đầy mệnh lệnh.
Với Go, chúng tôi chọn một cách tiếp cận khác thường và để máy móc lo phần lớn các vấn đề định dạng. Chương trình gofmt (cũng có dưới dạng go fmt, hoạt động ở mức package thay vì mức tệp mã nguồn) đọc một chương trình Go rồi xuất ra mã nguồn theo một phong cách thụt lề và căn dọc chuẩn, giữ lại và nếu cần thì định dạng lại các chú thích. Nếu bạn muốn biết nên xử lý một tình huống bố cục mới ra sao, hãy chạy gofmt; nếu kết quả trông không ổn, hãy sắp xếp lại chương trình của bạn (hoặc báo lỗi về gofmt), đừng tìm cách lách qua nó.
Ví dụ, bạn không cần mất thời gian căn thẳng hàng các chú thích trên những trường của một cấu trúc. Gofmt sẽ làm việc đó cho bạn. Với khai báo sau
type T struct {
name string // name of the object
value int // its value
}
gofmt sẽ căn thẳng các cột:
type T struct {
name string // name of the object
value int // its value
}
Toàn bộ mã Go trong các package chuẩn đều đã được định dạng bằng gofmt.
Vẫn còn một số chi tiết định dạng. Rất ngắn gọn:
- Thụt lề
- Chúng tôi dùng tab để thụt lề và
gofmtmặc định xuất ra tab. Chỉ dùng dấu cách khi buộc phải dùng. - Độ dài dòng
- Go không giới hạn độ dài dòng. Đừng lo tràn khỏi một tấm bìa đục lỗ. Nếu một dòng có vẻ quá dài, hãy ngắt xuống và thụt vào thêm một tab.
- Dấu ngoặc đơn
- Go cần ít dấu ngoặc đơn hơn C và Java: các cấu trúc điều khiển (
if,for,switch) không có dấu ngoặc đơn trong cú pháp của chúng. Ngoài ra, thứ tự ưu tiên toán tử cũng ngắn và rõ ràng hơn, nênx<<8 + y<<16
mang đúng ý nghĩa mà khoảng trắng gợi ra, không như ở các ngôn ngữ khác.
Chú thích
Go cung cấp chú thích khối kiểu C /* */ và chú thích dòng kiểu C++ //. Chú thích dòng là chuẩn mực thông thường; chú thích khối chủ yếu xuất hiện dưới dạng chú thích cho package, nhưng cũng hữu ích bên trong một biểu thức hoặc để vô hiệu hoá những mảng mã lớn.
Những chú thích xuất hiện ngay trước các khai báo ở cấp cao nhất, không có dòng trống xen giữa, được coi là tài liệu cho chính khai báo đó. Các “doc comment” này là tài liệu chính yếu cho một package hay một lệnh Go. Để biết thêm về doc comment, xem “Go Doc Comments”.
Đặt tên
Tên gọi trong Go cũng quan trọng như trong mọi ngôn ngữ khác. Chúng thậm chí còn mang hiệu ứng ngữ nghĩa: khả năng nhìn thấy một tên từ bên ngoài package được quyết định bởi việc ký tự đầu tiên của nó có viết hoa hay không. Vì vậy rất đáng dành chút thời gian bàn về các quy ước đặt tên trong chương trình Go.
Tên package
Khi một package được import, tên package trở thành lối truy cập tới nội dung của nó. Sau câu lệnh
import "bytes"
package thực hiện import có thể nói tới bytes.Buffer. Sẽ rất tiện nếu mọi người dùng package đều có thể dùng chung một cái tên để chỉ nội dung của nó, điều đó hàm ý rằng tên package nên tốt: ngắn, súc tích, gợi hình. Theo quy ước, package được đặt tên viết thường, một từ; không cần dùng dấu gạch dưới hay mixedCaps. Hãy nghiêng về phía ngắn gọn, vì ai dùng package của bạn cũng sẽ phải gõ cái tên đó. Và đừng lo về chuyện trùng tên a priori. Tên package chỉ là tên mặc định khi import; nó không cần là duy nhất trên toàn bộ mã nguồn, và trong trường hợp hiếm hoi xảy ra trùng tên thì package thực hiện import có thể chọn một tên khác để dùng cục bộ. Dù sao đi nữa, nhầm lẫn là chuyện hiếm vì tên tệp trong câu lệnh import xác định chính xác package nào đang được dùng.
Một quy ước khác là tên package chính là tên cơ sở của thư mục mã nguồn của nó; package nằm ở src/encoding/base64 được import là "encoding/base64" nhưng mang tên base64, không phải encoding_base64 và cũng không phải encodingBase64.
Người import một package sẽ dùng tên đó để chỉ nội dung bên trong, nên các exported name trong package có thể tận dụng điều này để tránh lặp lại. (Đừng dùng ký pháp import ., thứ có thể làm đơn giản những bài test bắt buộc phải chạy bên ngoài package chúng đang kiểm thử, nhưng ngoài ra thì nên tránh.) Chẳng hạn, kiểu reader có bộ đệm trong package bufio được gọi là Reader, không phải BufReader, vì người dùng nhìn thấy nó là bufio.Reader, một cái tên rõ ràng và súc tích. Hơn nữa, vì các thực thể được import luôn được gọi kèm tên package, bufio.Reader không xung đột với io.Reader. Tương tự, hàm tạo thể hiện mới của ring.Ring—vốn chính là định nghĩa của một constructor trong Go—theo lẽ thường sẽ được gọi là NewRing, nhưng vì Ring là kiểu duy nhất được package export, và vì package tên là ring, nên nó chỉ được gọi là New, và phía người dùng package sẽ thấy nó là ring.New. Hãy dùng cấu trúc package để giúp bạn chọn những cái tên tốt.
Một ví dụ ngắn khác là once.Do; once.Do(setup) đọc lên nghe xuôi và sẽ chẳng hay hơn nếu viết thành once.DoOrWaitUntilDone(setup). Tên dài không tự động làm mọi thứ dễ đọc hơn. Một doc comment hữu ích thường có giá trị hơn một cái tên dài ngoằng.
Getter
Go không hỗ trợ tự động cho getter và setter. Chẳng có gì sai khi bạn tự cung cấp getter và setter, và thường thì làm vậy là hợp lý, nhưng đưa Get vào tên của getter thì vừa không đúng chất Go vừa không cần thiết. Nếu bạn có một trường tên là owner (viết thường, không export), method getter nên được gọi là Owner (viết hoa, có export), chứ không phải GetOwner. Việc dùng tên viết hoa để export tạo ra điểm tựa để phân biệt trường với method. Hàm setter, nếu cần, nhiều khả năng sẽ được gọi là SetOwner. Cả hai tên đều đọc lên xuôi tai trong thực tế:
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}
Tên interface
Theo quy ước, các interface chỉ có một method được đặt tên bằng tên method cộng hậu tố -er hoặc một biến đổi tương tự để tạo thành danh từ chỉ tác nhân: Reader, Writer, Formatter, CloseNotifier v.v.
Có khá nhiều cái tên như vậy và rất đáng tôn trọng chúng cùng những tên hàm mà chúng ghi lại. Read, Write, Close, Flush, String v.v. đều có chữ ký và ý nghĩa chuẩn mực. Để tránh nhầm lẫn, đừng đặt cho method của bạn một trong những cái tên đó trừ khi nó có cùng chữ ký và cùng ý nghĩa. Ngược lại, nếu kiểu của bạn hiện thực một method mang cùng ý nghĩa với một method trên một kiểu nổi tiếng, hãy đặt cho nó cùng tên và cùng chữ ký; hãy gọi method chuyển sang chuỗi của bạn là String chứ không phải ToString.
MixedCaps
Cuối cùng, quy ước trong Go là dùng MixedCaps hoặc mixedCaps thay vì dấu gạch dưới để viết những tên gồm nhiều từ.
Semicolon
Giống C, văn phạm hình thức của Go dùng dấu chấm phẩy để kết thúc câu lệnh, nhưng khác C, những dấu chấm phẩy đó không xuất hiện trong mã nguồn. Thay vào đó, bộ phân tích từ vựng dùng một quy tắc đơn giản để tự động chèn dấu chấm phẩy trong lúc quét, nên văn bản đầu vào gần như không có chúng.
Quy tắc như sau. Nếu token cuối cùng trước một dòng mới là một identifier (bao gồm cả những từ như int và float64), một literal cơ bản như một số hay một hằng chuỗi, hoặc một trong các token
break continue fallthrough return ++ -- ) }
thì bộ phân tích từ vựng luôn chèn một dấu chấm phẩy sau token đó. Có thể tóm gọn thành: “nếu dòng mới xuất hiện sau một token có thể kết thúc một câu lệnh, hãy chèn dấu chấm phẩy”.
Dấu chấm phẩy cũng có thể được bỏ ngay trước một dấu ngoặc nhọn đóng, nên một câu lệnh như
go func() { for { dst <- <-src } }()
không cần dấu chấm phẩy nào. Các chương trình Go đúng chất chỉ có dấu chấm phẩy ở những chỗ như mệnh đề của vòng lặp for, để tách phần khởi tạo, điều kiện và phần tiếp diễn. Chúng cũng cần thiết để tách nhiều câu lệnh trên cùng một dòng, nếu bạn viết mã theo kiểu đó.
Một hệ quả của các quy tắc chèn dấu chấm phẩy là bạn không thể đặt dấu ngoặc nhọn mở của một cấu trúc điều khiển (if, for, switch, hay select) xuống dòng kế tiếp. Nếu làm vậy, một dấu chấm phẩy sẽ được chèn trước dấu ngoặc nhọn, và điều đó có thể gây ra những hệ quả không mong muốn. Hãy viết chúng như thế này
if i < f() {
g()
}
chứ không phải như thế này
if i < f() // wrong!
{ // wrong!
g()
}
Cấu trúc điều khiển
Các cấu trúc điều khiển của Go có họ hàng với của C nhưng khác biệt ở những điểm quan trọng. Không có vòng lặp do hay while, chỉ có một dạng for được tổng quát hoá đôi chút; switch linh hoạt hơn; if và switch nhận thêm một câu lệnh khởi tạo tuỳ chọn giống như của for; các câu lệnh break và continue nhận một nhãn tuỳ chọn để xác định cái gì sẽ bị ngắt hay tiếp tục; và có những cấu trúc điều khiển mới, gồm type switch và một bộ ghép kênh giao tiếp đa nhánh, select. Cú pháp cũng hơi khác: không có dấu ngoặc đơn và thân lệnh luôn phải được bao bằng dấu ngoặc nhọn.
If
Trong Go, một câu lệnh if đơn giản trông như thế này:
if x > 0 {
return y
}
Dấu ngoặc nhọn bắt buộc khuyến khích việc viết các câu lệnh if đơn giản trên nhiều dòng. Dù sao thì đó cũng là phong cách tốt, nhất là khi thân lệnh chứa một câu lệnh điều khiển như return hay break.
Vì if và switch nhận một câu lệnh khởi tạo, thường thấy người ta dùng nó để thiết lập một biến cục bộ.
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
Trong các thư viện Go, bạn sẽ thấy rằng khi một câu lệnh if không chảy tiếp sang câu lệnh kế—tức là thân lệnh kết thúc bằng break, continue, goto, hay return—thì phần else không cần thiết sẽ được lược bỏ.
f, err := os.Open(name)
if err != nil {
return err
}
codeUsing(f)
Đây là ví dụ cho một tình huống phổ biến khi mã nguồn phải phòng vệ trước một chuỗi các điều kiện lỗi. Mã đọc lên xuôi nếu luồng điều khiển thành công chạy dọc xuống trang, loại bỏ dần các trường hợp lỗi khi chúng phát sinh. Vì các trường hợp lỗi thường kết thúc bằng câu lệnh return, mã thu được không cần câu lệnh else nào.
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
Khai báo lại và gán lại
Nói thêm một chút: Ví dụ cuối ở mục trước minh hoạ một chi tiết về cách dạng khai báo ngắn := hoạt động. Khai báo gọi os.Open viết là,
f, err := os.Open(name)
Câu lệnh này khai báo hai biến, f và err. Vài dòng sau đó, lời gọi f.Stat viết là,
d, err := f.Stat()
trông như thể nó khai báo d và err. Tuy nhiên hãy để ý rằng err xuất hiện trong cả hai câu lệnh. Sự trùng lặp này là hợp lệ: err được khai báo bởi câu lệnh thứ nhất, nhưng chỉ được gán lại ở câu lệnh thứ hai. Nghĩa là lời gọi f.Stat dùng lại biến err đã được khai báo ở trên, và chỉ gán cho nó một giá trị mới.
Trong một khai báo :=, một biến v có thể xuất hiện ngay cả khi nó đã được khai báo, với điều kiện:
- khai báo này nằm cùng phạm vi với khai báo hiện có của
v(nếuvđã được khai báo ở một phạm vi bên ngoài, khai báo này sẽ tạo ra một biến mới §), - giá trị tương ứng trong phần khởi tạo có thể gán được cho
v, và - có ít nhất một biến khác được tạo ra bởi khai báo đó.
Đặc tính khác thường này thuần tuý là thực dụng, giúp dễ dàng dùng chung một giá trị err duy nhất, chẳng hạn, trong một chuỗi if-else dài. Bạn sẽ thấy nó được dùng thường xuyên.
§ Ở đây cũng đáng lưu ý rằng trong Go, phạm vi của tham số hàm và giá trị trả về trùng với phạm vi thân hàm, dù về mặt từ vựng chúng nằm ngoài cặp ngoặc nhọn bao quanh thân hàm.
For
Vòng lặp for của Go giống—nhưng không hoàn toàn giống—của C. Nó hợp nhất for với while và không có do-while. Có ba dạng, và chỉ một dạng dùng dấu chấm phẩy.
// Like a C for
for init; condition; post { }
// Like a C while
for condition { }
// Like a C for(;;)
for { }
Khai báo ngắn giúp dễ dàng khai báo biến chỉ số ngay trong vòng lặp.
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
Nếu bạn lặp qua một array, slice, chuỗi hay map, hoặc đọc từ một channel, mệnh đề range có thể quản lý vòng lặp cho bạn.
for key, value := range oldMap {
newMap[key] = value
}
Nếu bạn chỉ cần phần tử thứ nhất trong range (khoá hoặc chỉ số), hãy bỏ phần thứ hai:
for key := range m {
if key.expired() {
delete(m, key)
}
}
Nếu bạn chỉ cần phần tử thứ hai trong range (giá trị), hãy dùng blank identifier, một dấu gạch dưới, để bỏ đi phần thứ nhất:
sum := 0
for _, value := range array {
sum += value
}
Blank identifier có nhiều công dụng, như được mô tả trong một mục sau.
Với chuỗi, range làm giúp bạn nhiều việc hơn: nó tách ra từng code point Unicode riêng lẻ bằng cách phân tích UTF-8. Những đoạn mã hoá sai sẽ tiêu thụ một byte và sinh ra rune thay thế U+FFFD. (Cái tên rune (cùng kiểu dựng sẵn đi kèm) là thuật ngữ Go chỉ một code point Unicode đơn lẻ. Xem đặc tả ngôn ngữ để biết chi tiết.) Vòng lặp
for pos, char := range "日本\x80語" { // \x80 is an illegal UTF-8 encoding
fmt.Printf("character %#U starts at byte position %d\n", char, pos)
}
in ra
character U+65E5 '日' starts at byte position 0
character U+672C '本' starts at byte position 3
character U+FFFD '�' starts at byte position 6
character U+8A9E '語' starts at byte position 7
Cuối cùng, Go không có toán tử dấu phẩy, còn ++ và -- là câu lệnh chứ không phải biểu thức. Vì thế nếu bạn muốn cho nhiều biến chạy trong một vòng for, bạn nên dùng phép gán song song (tuy điều đó loại trừ ++ và --).
// Reverse a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
Switch
switch của Go tổng quát hơn của C. Các biểu thức không nhất thiết phải là hằng số hay thậm chí là số nguyên, các case được lượng giá từ trên xuống dưới cho tới khi tìm thấy một case khớp, và nếu switch không có biểu thức thì nó switch trên true. Do đó, có thể—và cũng là đúng chất Go—viết một chuỗi if-else-if-else thành một switch.
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}
Không có chuyện tự động rơi xuống case kế tiếp, nhưng các case có thể được trình bày dưới dạng danh sách ngăn cách bằng dấu phẩy.
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
Dù không phổ biến trong Go bằng ở một số ngôn ngữ kiểu C khác, câu lệnh break vẫn có thể dùng để kết thúc sớm một switch. Tuy nhiên, đôi khi cần thoát ra khỏi vòng lặp bao quanh chứ không phải khỏi switch, và trong Go điều đó có thể thực hiện bằng cách gắn một nhãn lên vòng lặp rồi “break” tới nhãn đó. Ví dụ sau cho thấy cả hai cách dùng.
Loop:
for n := 0; n < len(src); n += size {
switch {
case src[n] < sizeOne:
if validateOnly {
break
}
size = 1
update(src[n])
case src[n] < sizeTwo:
if n+1 >= len(src) {
err = errShortInput
break Loop
}
if validateOnly {
break
}
size = 2
update(src[n] + src[n+1]<<shift)
}
}
Dĩ nhiên, câu lệnh continue cũng nhận một nhãn tuỳ chọn nhưng nó chỉ áp dụng cho vòng lặp.
Để khép lại mục này, đây là một hàm so sánh cho các slice byte, dùng hai câu lệnh switch:
// Compare returns an integer comparing the two byte slices,
// lexicographically.
// The result will be 0 if a == b, -1 if a < b, and +1 if a > b
func Compare(a, b []byte) int {
for i := 0; i < len(a) && i < len(b); i++ {
switch {
case a[i] > b[i]:
return 1
case a[i] < b[i]:
return -1
}
}
switch {
case len(a) > len(b):
return 1
case len(a) < len(b):
return -1
}
return 0
}
Type switch
switch cũng có thể dùng để phát hiện kiểu động của một biến interface. Một type switch như vậy dùng cú pháp của một phép khẳng định kiểu với từ khoá type đặt trong cặp ngoặc đơn. Nếu switch khai báo một biến trong biểu thức, biến đó sẽ mang kiểu tương ứng trong từng nhánh. Trong những trường hợp như vậy, việc dùng lại cùng một tên cũng là đúng chất Go: trên thực tế nó khai báo một biến mới cùng tên nhưng khác kiểu ở mỗi case.
var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T\n", t) // %T prints whatever type t has
case bool:
fmt.Printf("boolean %t\n", t) // t has type bool
case int:
fmt.Printf("integer %d\n", t) // t has type int
case *bool:
fmt.Printf("pointer to boolean %t\n", *t) // t has type *bool
case *int:
fmt.Printf("pointer to integer %d\n", *t) // t has type *int
}
Hàm
Nhiều giá trị trả về
Một trong những tính năng khác thường của Go là hàm và method có thể trả về nhiều giá trị. Dạng này có thể dùng để cải thiện một vài lối viết vụng về trong chương trình C: trả về lỗi lẫn trong dải giá trị hợp lệ, chẳng hạn -1 cho EOF, và sửa đổi một đối số truyền theo địa chỉ.
Trong C, lỗi ghi được báo hiệu bằng một số đếm âm, còn mã lỗi thì được giấu ở một chỗ dễ biến đổi nào đó. Trong Go, Write có thể trả về một số đếm và một lỗi: “Vâng, bạn đã ghi được một số byte nhưng không phải tất cả vì bạn đã làm đầy thiết bị”. Chữ ký của method Write trên tệp thuộc package os là:
func (file *File) Write(b []byte) (n int, err error)
và như tài liệu nói, nó trả về số byte đã ghi và một error khác nil khi n != len(b). Đây là một phong cách phổ biến; xem mục về xử lý lỗi để có thêm ví dụ.
Một cách tiếp cận tương tự giúp loại bỏ nhu cầu truyền một pointer tới giá trị trả về để mô phỏng tham số tham chiếu. Dưới đây là một hàm đơn sơ để lấy một số từ một vị trí trong một slice byte, trả về số đó và vị trí kế tiếp.
func nextInt(b []byte, i int) (int, int) {
for ; i < len(b) && !isDigit(b[i]); i++ {
}
x := 0
for ; i < len(b) && isDigit(b[i]); i++ {
x = x*10 + int(b[i]) - '0'
}
return x, i
}
Bạn có thể dùng nó để quét các số trong một slice đầu vào b như thế này:
for i := 0; i < len(b); {
x, i = nextInt(b, i)
fmt.Println(x)
}
Tham số kết quả có tên
Các “tham số” trả về hay kết quả của một hàm Go có thể được đặt tên và dùng như biến thông thường, y như các tham số đầu vào. Khi được đặt tên, chúng được khởi tạo về giá trị zero của kiểu tương ứng khi hàm bắt đầu; nếu hàm thực thi một câu lệnh return không có đối số, giá trị hiện tại của các tham số kết quả sẽ được dùng làm giá trị trả về.
Việc đặt tên không bắt buộc nhưng nó có thể làm mã ngắn gọn và rõ ràng hơn: bản thân chúng chính là tài liệu. Nếu chúng ta đặt tên cho các kết quả của nextInt thì sẽ rõ ngay int trả về nào là cái nào.
func nextInt(b []byte, pos int) (value, nextPos int) {
Vì các kết quả có tên được khởi tạo sẵn và gắn với câu lệnh return trần, chúng vừa có thể làm mã đơn giản hơn vừa làm mã sáng rõ hơn. Đây là một phiên bản của io.ReadFull dùng chúng rất khéo:
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}
Defer
Câu lệnh defer của Go lên lịch cho một lời gọi hàm (hàm được defer) chạy ngay trước khi hàm đang thực thi câu lệnh defer đó trả về. Đây là cách khác thường nhưng hiệu quả để xử lý những tình huống như tài nguyên phải được giải phóng bất kể hàm trả về theo nhánh nào. Những ví dụ kinh điển là mở khoá một mutex hay đóng một tệp.
// Contents returns the file's contents as a string.
func Contents(filename string) (string, error) {
f, err := os.Open(filename)
if err != nil {
return "", err
}
defer f.Close() // f.Close will run when we're finished.
var result []byte
buf := make([]byte, 100)
for {
n, err := f.Read(buf[0:])
result = append(result, buf[0:n]...) // append is discussed later.
if err != nil {
if err == io.EOF {
break
}
return "", err // f will be closed if we return here.
}
}
return string(result), nil // f will be closed if we return here.
}
Việc defer một lời gọi tới hàm như Close có hai lợi thế. Thứ nhất, nó bảo đảm bạn sẽ không bao giờ quên đóng tệp, một sai lầm rất dễ mắc nếu sau này bạn sửa hàm để thêm một nhánh return mới. Thứ hai, nó có nghĩa là lệnh đóng nằm gần lệnh mở, điều đó sáng rõ hơn nhiều so với việc đặt nó ở cuối hàm.
Các đối số của hàm được defer (bao gồm cả receiver nếu hàm đó là một method) được lượng giá khi defer thực thi, chứ không phải khi lời gọi thực thi. Ngoài việc tránh phải lo biến thay đổi giá trị trong lúc hàm chạy, điều này còn có nghĩa là một chỗ gọi defer duy nhất có thể defer nhiều lần thực thi hàm. Đây là một ví dụ ngớ ngẩn.
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
Các hàm được defer thực thi theo thứ tự LIFO, nên đoạn mã này sẽ khiến 4 3 2 1 0 được in ra khi hàm trả về. Một ví dụ hợp lý hơn là cách đơn giản để lần dấu quá trình thực thi hàm xuyên suốt chương trình. Chúng ta có thể viết vài hàm lần dấu đơn giản như sau:
func trace(s string) { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }
// Use them like this:
func a() {
trace("a")
defer untrace("a")
// do something....
}
Chúng ta có thể làm tốt hơn bằng cách khai thác sự thật rằng đối số của các hàm được defer sẽ được lượng giá khi defer thực thi. Hàm lần dấu có thể chuẩn bị sẵn đối số cho hàm kết thúc lần dấu. Ví dụ này:
func trace(s string) string {
fmt.Println("entering:", s)
return s
}
func un(s string) {
fmt.Println("leaving:", s)
}
func a() {
defer un(trace("a"))
fmt.Println("in a")
}
func b() {
defer un(trace("b"))
fmt.Println("in b")
a()
}
func main() {
b()
}
in ra
entering: b
in b
entering: a
in a
leaving: a
leaving: b
Với những lập trình viên đã quen quản lý tài nguyên theo khối từ các ngôn ngữ khác, defer có thể trông kỳ quặc, nhưng những ứng dụng thú vị và mạnh mẽ nhất của nó lại đến chính từ chỗ nó không dựa trên khối mà dựa trên hàm. Trong mục về panic và recover, chúng ta sẽ thấy một ví dụ khác về những khả năng của nó.
Dữ liệu
Allocation với new
Go có hai nguyên thuỷ allocation, đó là hai hàm dựng sẵn new và make. Chúng làm những việc khác nhau và áp dụng cho những kiểu dữ liệu khác nhau, điều này có thể gây bối rối, nhưng luật lệ thì đơn giản. Hãy nói về new trước. Đó là một hàm dựng sẵn cấp phát bộ nhớ, nhưng khác với những hàm cùng tên trong vài ngôn ngữ khác, nó không khởi tạo vùng nhớ đó, nó chỉ đưa về không. Nghĩa là, new(T) cấp phát vùng lưu trữ đã được đưa về không cho một biến mới kiểu T và trả về địa chỉ của nó, một giá trị kiểu *T. Theo thuật ngữ của Go, nó trả về một pointer trỏ tới một giá trị không (zero value) mới được cấp phát của kiểu T.
Kể từ Go 1.26, new cũng chấp nhận một biểu thức (giá trị) làm đối số, chỉ định giá trị khởi tạo của biến. Ví dụ, new(int64(300)) cấp phát một biến mới kiểu int64, được khởi tạo bằng 300, và trả về địa chỉ của nó.
Vì bộ nhớ mà new trả về đã được đưa về không, sẽ rất hữu ích nếu khi thiết kế cấu trúc dữ liệu, bạn sắp xếp sao cho giá trị không của mỗi kiểu có thể dùng được mà không cần khởi tạo thêm. Điều này nghĩa là người dùng cấu trúc dữ liệu đó có thể tạo ra một thể hiện bằng new và bắt tay vào việc ngay. Ví dụ, tài liệu của bytes.Buffer nói rằng “giá trị không của Buffer là một buffer rỗng sẵn sàng dùng.” Tương tự, sync.Mutex không có constructor tường minh hay method Init. Thay vào đó, giá trị không của sync.Mutex được định nghĩa là một mutex chưa bị khoá.
Tính chất “giá trị không dùng được ngay” có tính bắc cầu. Hãy xem khai báo kiểu này.
type SyncedBuffer struct {
lock sync.Mutex
buffer bytes.Buffer
}
Các giá trị kiểu SyncedBuffer cũng sẵn sàng dùng ngay sau khi được cấp phát hay chỉ đơn thuần khai báo. Trong đoạn mã sau, cả p lẫn v đều hoạt động đúng mà không cần sắp xếp gì thêm.
p := new(SyncedBuffer) // type *SyncedBuffer
var v SyncedBuffer // type SyncedBuffer
Constructor và composite literal
Đôi khi giá trị không là chưa đủ và cần đến một constructor khởi tạo, như trong ví dụ sau được rút ra từ package os.
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := new(File)
f.fd = fd
f.name = name
f.dirinfo = nil
f.nepipe = 0
return f
}
Ở đó có khá nhiều mã rườm rà. Chúng ta có thể đơn giản hoá nó bằng một composite literal, là một biểu thức tạo ra một thể hiện mới mỗi lần nó được định trị.
func NewFile(fd int, name string) *File {
if fd < 0 {
return nil
}
f := File{fd, name, nil, 0}
return &f
}
Lưu ý rằng, khác với C, việc trả về địa chỉ của một biến cục bộ là hoàn toàn ổn; vùng lưu trữ gắn với biến đó vẫn tồn tại sau khi hàm trả về. Thực tế, việc lấy địa chỉ của một composite literal sẽ cấp phát một thể hiện mới mỗi lần biểu thức được định trị, nên chúng ta có thể gộp hai dòng cuối này lại.
return &File{fd, name, nil, 0}
Các trường của một composite literal được sắp theo thứ tự và phải có mặt đầy đủ. Tuy nhiên, bằng cách gán nhãn tường minh cho các phần tử dưới dạng cặp trường:giá trị, các phần khởi tạo có thể xuất hiện theo thứ tự bất kỳ, những trường thiếu sẽ nhận giá trị không tương ứng. Vậy nên ta có thể viết
return &File{fd: fd, name: name}
Ở trường hợp giới hạn, nếu một composite literal không chứa trường nào cả, nó tạo ra một giá trị không của kiểu đó. Hai biểu thức new(File) và &File{} là tương đương.
Composite literal cũng có thể được tạo cho array, slice và map, với nhãn của trường là chỉ số hoặc khoá của map tuỳ trường hợp. Trong các ví dụ sau, việc khởi tạo vẫn đúng bất kể giá trị của Enone, Eio và Einval là gì, miễn là chúng khác nhau.
a := [...]string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
s := []string {Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
m := map[int]string{Enone: "no error", Eio: "Eio", Einval: "invalid argument"}
Allocation với make
Quay lại chuyện allocation. Hàm dựng sẵn make(T, args) phục vụ một mục đích khác với new(T). Nó chỉ tạo ra slice, map và channel, và nó trả về một giá trị đã được khởi tạo (chứ không phải đưa về không) kiểu T (không phải *T). Lý do của sự phân biệt này là ba kiểu ấy, bên dưới lớp vỏ, biểu diễn các tham chiếu tới những cấu trúc dữ liệu phải được khởi tạo trước khi dùng. Chẳng hạn một slice là một bộ mô tả ba phần chứa một pointer tới dữ liệu (nằm trong một array), độ dài, và capacity; chừng nào những phần ấy chưa được khởi tạo thì slice còn là nil. Với slice, map và channel, make khởi tạo cấu trúc dữ liệu bên trong và chuẩn bị giá trị để sẵn sàng dùng. Ví dụ,
make([]int, 10, 100)
cấp phát một array gồm 100 số int rồi tạo một cấu trúc slice có độ dài 10 và capacity 100 trỏ vào 10 phần tử đầu tiên của array đó. (Khi tạo một slice, có thể bỏ qua capacity; xem phần nói về slice để biết thêm.) Ngược lại, new([]int) trả về một pointer tới một cấu trúc slice mới được cấp phát và đưa về không, tức là một pointer tới một giá trị slice nil.
Những ví dụ sau minh hoạ sự khác nhau giữa new và make.
var p *[]int = new([]int) // allocates slice structure; *p == nil; rarely useful
var v []int = make([]int, 100) // the slice v now refers to a new array of 100 ints
// Unnecessarily complex:
var p *[]int = new([]int)
*p = make([]int, 100, 100)
// Idiomatic:
v := make([]int, 100)
Hãy nhớ rằng make chỉ áp dụng cho map, slice và channel, và nó không trả về pointer. Để có được một pointer tường minh, hãy cấp phát bằng new hoặc lấy địa chỉ của một biến một cách tường minh.
Array
Array hữu ích khi bạn cần hoạch định bố cục bộ nhớ chi tiết và đôi khi giúp tránh được allocation, nhưng chủ yếu chúng là viên gạch nền cho slice, chủ đề của mục kế tiếp. Để đặt nền cho chủ đề đó, đây là vài lời về array.
Có những khác biệt lớn giữa cách array hoạt động trong Go và trong C. Trong Go,
- Array là giá trị. Gán một array cho một array khác sẽ sao chép toàn bộ các phần tử.
- Cụ thể hơn, nếu bạn truyền một array vào một hàm, hàm đó sẽ nhận được một bản sao của array chứ không phải pointer tới nó.
- Kích thước của một array là một phần kiểu của nó. Hai kiểu
[10]intvà[20]intlà khác nhau.
Tính chất “là giá trị” có thể hữu ích nhưng cũng tốn kém; nếu bạn muốn hành vi và hiệu năng kiểu C, bạn có thể truyền một pointer tới array.
func Sum(a *[3]float64) (sum float64) {
for _, v := range *a {
sum += v
}
return
}
array := [...]float64{7.0, 8.5, 9.1}
x := Sum(&array) // Note the explicit address-of operator
Nhưng ngay cả lối viết này cũng không phải Go thuần thục. Hãy dùng slice thay thế.
Slice
Slice bọc quanh array để đem lại một interface tổng quát hơn, mạnh hơn và thuận tiện hơn cho các dãy dữ liệu. Ngoại trừ những thứ có số chiều tường minh như ma trận biến đổi, phần lớn việc lập trình với mảng trong Go đều dùng slice chứ không dùng array thuần.
Slice giữ tham chiếu tới một array nền, và nếu bạn gán một slice cho một slice khác thì cả hai cùng trỏ tới một array. Nếu một hàm nhận đối số là slice, những thay đổi nó gây ra lên các phần tử của slice sẽ hiện ra với phía gọi, tương tự như truyền một pointer tới array nền. Bởi vậy một hàm Read có thể nhận đối số là slice thay vì một pointer kèm số đếm; độ dài của slice đặt ra giới hạn trên cho lượng dữ liệu cần đọc. Đây là chữ ký của method Read của kiểu File trong package os:
func (f *File) Read(buf []byte) (n int, err error)
Method này trả về số byte đã đọc và một giá trị lỗi, nếu có. Để đọc vào 32 byte đầu của một buffer lớn hơn buf, hãy slice (ở đây dùng như một động từ) buffer đó.
n, err := f.Read(buf[0:32])
Cách cắt slice như vậy rất phổ biến và hiệu quả. Thực tế, tạm gác hiệu năng sang một bên, đoạn mã sau cũng đọc 32 byte đầu của buffer.
var n int
var err error
for i := 0; i < 32; i++ {
nbytes, e := f.Read(buf[i:i+1]) // Read one byte.
n += nbytes
if nbytes == 0 || e != nil {
err = e
break
}
}
Độ dài của một slice có thể thay đổi miễn là nó vẫn nằm trong giới hạn của array nền; chỉ cần gán cho nó một slice của chính nó. Capacity của một slice, truy cập được qua hàm dựng sẵn cap, cho biết độ dài lớn nhất mà slice có thể nhận. Đây là một hàm nối thêm dữ liệu vào một slice. Nếu dữ liệu vượt quá capacity, slice sẽ được cấp phát lại. Slice kết quả được trả về. Hàm này tận dụng việc len và cap vẫn hợp lệ khi áp dụng lên slice nil, và trả về 0.
func Append(slice, data []byte) []byte {
l := len(slice)
if l + len(data) > cap(slice) { // reallocate
// Allocate double what's needed, for future growth.
newSlice := make([]byte, (l+len(data))*2)
// The copy function is predeclared and works for any slice type.
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:l+len(data)]
copy(slice[l:], data)
return slice
}
Chúng ta phải trả về slice sau đó bởi vì, tuy Append có thể sửa các phần tử của slice, bản thân slice (cấu trúc dữ liệu lúc chạy chứa pointer, độ dài và capacity) lại được truyền theo giá trị.
Ý tưởng nối thêm vào một slice hữu dụng đến mức nó được gói gọn trong hàm dựng sẵn append. Tuy nhiên, để hiểu thiết kế của hàm đó, chúng ta cần thêm chút thông tin nữa, nên sẽ quay lại với nó sau.
Slice hai chiều
Array và slice của Go là một chiều. Để tạo thứ tương đương một array hay slice 2 chiều, cần định nghĩa một array-of-array hoặc slice-of-slice, như thế này:
type Transform [3][3]float64 // A 3x3 array, really an array of arrays.
type LinesOfText [][]byte // A slice of byte slices.
Vì slice có độ dài thay đổi được, mỗi slice bên trong có thể mang một độ dài khác nhau. Đó có thể là tình huống thường gặp, như trong ví dụ LinesOfText của chúng ta: mỗi dòng có độ dài độc lập.
text := LinesOfText{
[]byte("Now is the time"),
[]byte("for all good gophers"),
[]byte("to bring some fun to the party."),
}
Đôi khi cần cấp phát một slice 2 chiều, tình huống có thể nảy sinh khi xử lý các dòng quét điểm ảnh chẳng hạn. Có hai cách để làm điều này. Một là cấp phát từng slice độc lập; cách kia là cấp phát một array duy nhất rồi cho các slice riêng lẻ trỏ vào đó. Dùng cách nào là tuỳ ứng dụng của bạn. Nếu các slice có thể phình ra hay co lại, chúng nên được cấp phát độc lập để tránh ghi đè lên dòng kế tiếp; nếu không, dựng đối tượng bằng một lần cấp phát duy nhất có thể hiệu quả hơn. Để tham khảo, đây là phác thảo của hai cách. Trước tiên, từng dòng một:
// Allocate the top-level slice.
picture := make([][]uint8, YSize) // One row per unit of y.
// Loop over the rows, allocating the slice for each row.
for i := range picture {
picture[i] = make([]uint8, XSize)
}
Và bây giờ là một lần cấp phát duy nhất, rồi cắt thành các dòng:
// Allocate the top-level slice, the same as before.
picture := make([][]uint8, YSize) // One row per unit of y.
// Allocate one large slice to hold all the pixels.
pixels := make([]uint8, XSize*YSize) // Has type []uint8 even though picture is [][]uint8.
// Loop over the rows, slicing each row from the front of the remaining pixels slice.
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
}
Map
Map là một cấu trúc dữ liệu dựng sẵn tiện lợi và mạnh mẽ, liên kết các giá trị của một kiểu (khoá) với các giá trị của một kiểu khác (phần tử hay giá trị). Khoá có thể thuộc bất kỳ kiểu nào có định nghĩa toán tử so sánh bằng, chẳng hạn số nguyên, số dấu chấm động và số phức, chuỗi, pointer, interface (miễn là kiểu động hỗ trợ so sánh bằng), struct và array. Không dùng được slice làm khoá của map, vì chúng không có định nghĩa so sánh bằng. Giống như slice, map giữ tham chiếu tới một cấu trúc dữ liệu nền. Nếu bạn truyền một map vào một hàm làm thay đổi nội dung của map, những thay đổi đó sẽ hiện ra với phía gọi.
Map có thể được dựng bằng cú pháp composite literal thông thường với các cặp khoá-giá trị ngăn cách bởi dấu hai chấm, nên rất dễ xây dựng chúng lúc khởi tạo.
var timeZone = map[string]int{
"UTC": 0*60*60,
"EST": -5*60*60,
"CST": -6*60*60,
"MST": -7*60*60,
"PST": -8*60*60,
}
Về mặt cú pháp, gán và lấy giá trị trong map trông y hệt như làm điều đó với array và slice, chỉ khác là chỉ số không nhất thiết phải là số nguyên.
offset := timeZone["EST"]
Nếu bạn lấy giá trị trong map bằng một khoá không có trong map, kết quả sẽ là giá trị không của kiểu các mục trong map. Chẳng hạn, nếu map chứa số nguyên, tra một khoá không tồn tại sẽ trả về 0. Có thể hiện thực một tập hợp bằng một map với kiểu giá trị là bool. Đặt mục của map thành true để đưa giá trị vào tập hợp, rồi kiểm tra nó bằng phép lập chỉ số đơn giản.
attended := map[string]bool{
"Ann": true,
"Joe": true,
...
}
if attended[person] { // will be false if person is not in the map
fmt.Println(person, "was at the meeting")
}
Đôi khi bạn cần phân biệt một mục không tồn tại với một giá trị không. Có mục nào cho "UTC" không, hay đó là 0 bởi vì nó hoàn toàn không có trong map? Bạn có thể phân biệt bằng một dạng gán nhiều giá trị.
var seconds int
var ok bool
seconds, ok = timeZone[tz]
Vì những lý do hiển nhiên, cách này được gọi là thành ngữ “comma ok”. Trong ví dụ này, nếu tz có mặt, seconds sẽ được đặt đúng giá trị và ok sẽ là true; nếu không, seconds sẽ được đặt bằng không và ok sẽ là false. Đây là một hàm ghép mọi thứ lại cùng một thông báo lỗi tử tế:
func offset(tz string) int {
if seconds, ok := timeZone[tz]; ok {
return seconds
}
log.Println("unknown time zone:", tz)
return 0
}
Để kiểm tra sự có mặt trong map mà không bận tâm tới giá trị thực, bạn có thể dùng blank identifier (_) thay cho biến thông thường dành cho giá trị.
_, present := timeZone[tz]
Để xoá một mục trong map, hãy dùng hàm dựng sẵn delete, với các đối số là map và khoá cần xoá. Làm vậy vẫn an toàn ngay cả khi khoá đó vốn không có trong map.
delete(timeZone, "PDT") // Now on Standard Time
In ấn
Việc in có định dạng trong Go dùng lối tương tự họ hàm printf của C nhưng phong phú và tổng quát hơn. Các hàm nằm trong package fmt và có tên viết hoa: fmt.Printf, fmt.Fprintf, fmt.Sprintf và vân vân. Các hàm trả chuỗi (Sprintf v.v.) trả về một chuỗi thay vì điền vào một buffer được cung cấp sẵn.
Bạn không nhất thiết phải cung cấp chuỗi định dạng. Ứng với mỗi hàm Printf, Fprintf và Sprintf còn có một cặp hàm khác, chẳng hạn Print và Println. Những hàm này không nhận chuỗi định dạng mà sinh ra một định dạng mặc định cho từng đối số. Các phiên bản Println còn chèn một khoảng trắng giữa các đối số và thêm một ký tự xuống dòng vào cuối output, trong khi các phiên bản Print chỉ thêm khoảng trắng nếu toán hạng ở cả hai phía đều không phải chuỗi. Trong ví dụ sau, mỗi dòng đều cho ra cùng một output.
fmt.Printf("Hello %d\n", 23)
fmt.Fprint(os.Stdout, "Hello ", 23, "\n")
fmt.Println("Hello", 23)
fmt.Println(fmt.Sprint("Hello ", 23))
Các hàm in có định dạng fmt.Fprint và đồng bọn nhận đối số đầu tiên là bất kỳ đối tượng nào hiện thực interface io.Writer; các biến os.Stdout và os.Stderr là những thể hiện quen thuộc.
Đến đây mọi thứ bắt đầu khác với C. Trước hết, các định dạng số như %d không nhận cờ về dấu hay kích thước; thay vào đó, các thủ tục in dùng kiểu của đối số để quyết định những tính chất ấy.
var x uint64 = 1<<64 - 1
fmt.Printf("%d %x; %d %x\n", x, x, int64(x), int64(x))
in ra
18446744073709551615 ffffffffffffffff; -1 -1
Nếu bạn chỉ muốn phép chuyển đổi mặc định, chẳng hạn hệ thập phân cho số nguyên, bạn có thể dùng định dạng vạn năng %v (nghĩa là “value”); kết quả đúng bằng những gì Print và Println sẽ tạo ra. Hơn nữa, định dạng đó có thể in bất kỳ giá trị nào, kể cả array, slice, struct và map. Đây là một câu lệnh in cho map múi giờ đã định nghĩa ở mục trước.
fmt.Printf("%v\n", timeZone) // or just fmt.Println(timeZone)
cho ra output:
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
Với map, Printf và đồng bọn sắp xếp output theo thứ tự từ điển của khoá.
Khi in một struct, định dạng biến thể %+v chú thích các trường của cấu trúc kèm tên của chúng, và với bất kỳ giá trị nào, định dạng thay thế %#v in giá trị đó theo cú pháp Go đầy đủ.
type T struct {
a int
b float64
c string
}
t := &T{ 7, -2.35, "abc\tdef" }
fmt.Printf("%v\n", t)
fmt.Printf("%+v\n", t)
fmt.Printf("%#v\n", t)
fmt.Printf("%#v\n", timeZone)
in ra
&{7 -2.35 abc def}
&{a:7 b:-2.35 c:abc def}
&main.T{a:7, b:-2.35, c:"abc\tdef"}
map[string]int{"CST":-21600, "EST":-18000, "MST":-25200, "PST":-28800, "UTC":0}
(Hãy để ý các dấu &.) Định dạng chuỗi có nháy đó cũng dùng được qua %q khi áp dụng lên một giá trị kiểu string hay []byte. Định dạng thay thế %#q sẽ dùng dấu nháy ngược nếu có thể. (Định dạng %q cũng áp dụng cho số nguyên và rune, tạo ra một hằng rune trong nháy đơn.) Ngoài ra, %x hoạt động với chuỗi, mảng byte và slice byte cũng như với số nguyên, sinh ra một chuỗi thập lục phân dài, và nếu có một khoảng trắng trong định dạng (% x) thì nó chèn khoảng trắng giữa các byte.
Một định dạng tiện lợi khác là %T, in ra kiểu của một giá trị.
fmt.Printf("%T\n", timeZone)
in ra
map[string]int
Nếu bạn muốn điều khiển định dạng mặc định cho một kiểu tự định nghĩa, tất cả những gì cần làm là định nghĩa một method với chữ ký String() string trên kiểu đó. Với kiểu T đơn giản của chúng ta, nó có thể trông như sau.
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)
để in ra theo định dạng
7/-2.35/"abc\tdef"
(Nếu bạn cần in cả giá trị kiểu T lẫn pointer tới T, receiver của String phải là kiểu giá trị; ví dụ này dùng pointer vì như vậy hiệu quả hơn và thuần thục hơn với các kiểu struct. Xem mục pointer receiver và value receiver ở dưới để biết thêm.)
Method String của chúng ta gọi được Sprintf bởi vì các thủ tục in hoàn toàn khả tái nhập (reentrant) và có thể được bọc lại như thế. Tuy vậy, có một chi tiết quan trọng cần hiểu về cách làm này: đừng dựng một method String bằng cách gọi Sprintf theo lối sẽ đệ quy vô hạn vào chính method String của bạn. Điều này xảy ra khi lời gọi Sprintf cố in trực tiếp receiver dưới dạng chuỗi, việc đó lại gọi method một lần nữa. Đây là một sai lầm phổ biến và dễ mắc phải, như ví dụ sau cho thấy.
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // Error: will recur forever.
}
Nhưng cũng dễ sửa: hãy chuyển đối số về kiểu chuỗi cơ bản, vốn không có method đó.
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // OK: note conversion.
}
Trong mục về khởi tạo, chúng ta sẽ thấy một kỹ thuật khác giúp tránh đệ quy này.
Một kỹ thuật in khác là truyền thẳng các đối số của một thủ tục in sang một thủ tục in khác. Chữ ký của Printf dùng kiểu ...interface{} cho đối số cuối cùng để chỉ rằng một số lượng tham số tuỳ ý (thuộc kiểu tuỳ ý) có thể xuất hiện sau chuỗi định dạng.
func Printf(format string, v ...interface{}) (n int, err error) {
Bên trong hàm Printf, v hành xử như một biến kiểu []interface{}, nhưng nếu nó được truyền cho một hàm variadic khác, nó hành xử như một danh sách đối số bình thường. Đây là phần hiện thực của hàm log.Println mà chúng ta đã dùng ở trên. Nó truyền thẳng các đối số của mình cho fmt.Sprintln để thực sự định dạng.
// Println prints to the standard logger in the manner of fmt.Println.
func Println(v ...interface{}) {
std.Output(2, fmt.Sprintln(v...)) // Output takes parameters (int, string)
}
Chúng ta viết ... sau v trong lời gọi lồng tới Sprintln để báo cho compiler biết hãy xem v như một danh sách đối số; nếu không, nó sẽ chỉ truyền v như một đối số slice đơn lẻ.
Chuyện in ấn còn nhiều điều nữa ngoài những gì đã nói ở đây. Xem tài liệu godoc của package fmt để biết chi tiết.
Nhân tiện, một tham số ... có thể thuộc một kiểu cụ thể, chẳng hạn ...int cho một hàm min chọn ra số nhỏ nhất trong một danh sách số nguyên:
func Min(a ...int) int {
min := int(^uint(0) >> 1) // largest int
for _, i := range a {
if i < min {
min = i
}
}
return min
}
Append
Giờ chúng ta đã có mảnh ghép còn thiếu để giải thích thiết kế của hàm dựng sẵn append. Chữ ký của append khác với hàm Append tự viết ở trên. Sơ lược, nó như thế này:
func append(slice []T, elements ...T) []T
trong đó T là placeholder cho một kiểu bất kỳ. Thực tế bạn không thể viết trong Go một hàm mà kiểu T do phía gọi quyết định. Đó là lý do append được dựng sẵn: nó cần sự hỗ trợ từ compiler.
Việc append làm là nối các phần tử vào cuối slice rồi trả về kết quả. Kết quả cần được trả về bởi vì, giống như với hàm Append viết tay của chúng ta, array nền có thể thay đổi. Ví dụ đơn giản này
x := []int{1,2,3}
x = append(x, 4, 5, 6)
fmt.Println(x)
in ra [1 2 3 4 5 6]. Vậy append hoạt động hơi giống Printf, gom một số lượng đối số tuỳ ý.
Nhưng nếu ta muốn làm điều mà hàm Append của ta làm, tức là nối một slice vào một slice thì sao? Dễ thôi: dùng ... tại chỗ gọi, y như ta đã làm trong lời gọi tới Output ở trên. Đoạn mã sau cho ra output giống hệt đoạn trên.
x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)
Nếu thiếu ... đó, mã sẽ không biên dịch được vì các kiểu sẽ sai; y không thuộc kiểu int.
Khởi tạo
Dù nhìn bề ngoài không khác lắm so với việc khởi tạo trong C hay C++, khởi tạo trong Go mạnh hơn nhiều. Các cấu trúc phức tạp có thể được dựng lên trong lúc khởi tạo, và những vấn đề về thứ tự giữa các đối tượng được khởi tạo, kể cả giữa các package khác nhau, đều được xử lý đúng đắn.
Hằng số
Hằng số trong Go đúng nghĩa là hằng—bất biến. Chúng được tạo ra lúc biên dịch, kể cả khi được định nghĩa như biến cục bộ trong hàm, và chỉ có thể là số, ký tự (rune), chuỗi hoặc giá trị boolean. Vì ràng buộc lúc biên dịch, các biểu thức định nghĩa chúng phải là biểu thức hằng, định trị được bởi compiler. Chẳng hạn, 1<<3 là một biểu thức hằng, còn math.Sin(math.Pi/4) thì không, vì lời gọi hàm math.Sin cần xảy ra lúc chạy.
Trong Go, các hằng liệt kê được tạo bằng bộ liệt kê iota. Vì iota có thể là một phần của biểu thức và biểu thức có thể được lặp lại ngầm, việc dựng nên những tập giá trị tinh vi là rất dễ.
type ByteSize float64
const (
_ = iota // ignore first value by assigning to blank identifier
KB ByteSize = 1 << (10 * iota)
MB
GB
TB
PB
EB
ZB
YB
)
Khả năng gắn một method như String vào bất kỳ kiểu tự định nghĩa nào giúp các giá trị bất kỳ có thể tự định dạng chính mình khi in ra. Dù bạn sẽ thấy kỹ thuật này được dùng nhiều nhất với struct, nó cũng hữu ích cho các kiểu vô hướng như các kiểu dấu chấm động, ví dụ ByteSize.
func (b ByteSize) String() string {
switch {
case b >= YB:
return fmt.Sprintf("%.2fYB", b/YB)
case b >= ZB:
return fmt.Sprintf("%.2fZB", b/ZB)
case b >= EB:
return fmt.Sprintf("%.2fEB", b/EB)
case b >= PB:
return fmt.Sprintf("%.2fPB", b/PB)
case b >= TB:
return fmt.Sprintf("%.2fTB", b/TB)
case b >= GB:
return fmt.Sprintf("%.2fGB", b/GB)
case b >= MB:
return fmt.Sprintf("%.2fMB", b/MB)
case b >= KB:
return fmt.Sprintf("%.2fKB", b/KB)
}
return fmt.Sprintf("%.2fB", b)
}
Biểu thức YB in ra thành 1.00YB, còn ByteSize(1e13) in ra thành 9.09TB.
Việc dùng Sprintf ở đây để hiện thực method String của ByteSize là an toàn (tránh được đệ quy vô hạn) không phải nhờ một phép chuyển đổi mà bởi vì nó gọi Sprintf với %f, vốn không phải định dạng chuỗi: Sprintf chỉ gọi method String khi nó cần một chuỗi, mà %f thì cần một giá trị dấu chấm động.
Biến
Biến có thể được khởi tạo y như hằng số, nhưng biểu thức khởi tạo có thể là một biểu thức tổng quát được tính lúc chạy.
var (
home = os.Getenv("HOME")
user = os.Getenv("USER")
gopath = os.Getenv("GOPATH")
)
Hàm init
Cuối cùng, mỗi tệp mã nguồn có thể định nghĩa hàm init không đối số của riêng nó để thiết lập bất kỳ trạng thái nào cần thiết. (Thực ra mỗi tệp có thể có nhiều hàm init.) Và cuối cùng nghĩa là cuối cùng thật: init được gọi sau khi mọi khai báo biến trong package đã định trị xong các biểu thức khởi tạo của chúng, và những biểu thức đó chỉ được định trị sau khi mọi package được import đã khởi tạo xong.
Ngoài những khởi tạo không thể diễn đạt bằng khai báo, một công dụng phổ biến của hàm init là kiểm tra hoặc sửa chữa tính đúng đắn của trạng thái chương trình trước khi việc thực thi thật sự bắt đầu.
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath may be overridden by --gopath flag on command line.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}
Method
Pointer và giá trị
Như đã thấy với ByteSize, method có thể được định nghĩa cho bất kỳ kiểu có tên nào (trừ pointer và interface); receiver không nhất thiết phải là một struct.
Trong phần bàn về slice ở trên, chúng ta đã viết một hàm Append. Thay vào đó, ta có thể định nghĩa nó như một method trên slice. Để làm vậy, trước hết ta khai báo một kiểu có tên để gắn method vào, rồi lấy receiver của method là một giá trị thuộc kiểu ấy.
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// Body exactly the same as the Append function defined above.
}
Cách này vẫn đòi hỏi method phải trả về slice đã cập nhật. Ta có thể loại bỏ sự vụng về đó bằng cách định nghĩa lại method để nhận một pointer tới ByteSlice làm receiver, nhờ vậy method có thể ghi đè lên slice của phía gọi.
func (p *ByteSlice) Append(data []byte) {
slice := *p
// Body as above, without the return.
*p = slice
}
Thực ra, ta còn có thể làm tốt hơn nữa. Nếu ta sửa hàm của mình sao cho nó trông giống một method Write chuẩn, như thế này,
func (p *ByteSlice) Write(data []byte) (n int, err error) {
slice := *p
// Again as above.
*p = slice
return len(data), nil
}
thì kiểu *ByteSlice thoả mãn interface chuẩn io.Writer, điều này rất tiện. Chẳng hạn, ta có thể in vào một giá trị như vậy.
var b ByteSlice
fmt.Fprintf(&b, "This hour has %d days\n", 7)
Chúng ta truyền địa chỉ của một ByteSlice bởi vì chỉ *ByteSlice mới thoả mãn io.Writer. Quy tắc về pointer và giá trị đối với receiver là: method nhận giá trị có thể được gọi trên cả pointer lẫn giá trị, còn method nhận pointer chỉ có thể được gọi trên pointer.
Quy tắc này nảy sinh vì method nhận pointer có thể sửa đổi receiver; gọi chúng trên một giá trị sẽ khiến method nhận được một bản sao của giá trị, nên mọi sửa đổi đều bị bỏ đi. Vì thế ngôn ngữ ngăn không cho mắc sai lầm này. Tuy nhiên có một ngoại lệ tiện lợi. Khi giá trị là lấy-địa-chỉ-được, ngôn ngữ lo giúp trường hợp phổ biến là gọi một method nhận pointer trên một giá trị, bằng cách tự động chèn toán tử lấy địa chỉ. Trong ví dụ của chúng ta, biến b là lấy-địa-chỉ-được, nên ta có thể gọi method Write của nó chỉ bằng b.Write. Compiler sẽ viết lại thành (&b).Write giúp ta.
Nhân tiện, ý tưởng dùng Write trên một slice byte là cốt lõi trong phần hiện thực của bytes.Buffer.
Interface và các kiểu khác
Interface
Interface trong Go cho ta cách chỉ định hành vi của một đối tượng: nếu một thứ làm được việc này, thì nó dùng được ở đây. Chúng ta đã thấy vài ví dụ đơn giản rồi; có thể hiện thực bộ in tuỳ biến bằng một method String, còn Fprintf có thể sinh output ra bất cứ thứ gì có method Write. Interface chỉ có một hoặc hai method là chuyện thường gặp trong mã Go, và thường được đặt tên dẫn xuất từ method đó, chẳng hạn io.Writer cho thứ hiện thực Write.
Một kiểu có thể hiện thực nhiều interface. Chẳng hạn, một tập hợp có thể được sắp xếp bởi các thủ tục trong package sort nếu nó hiện thực sort.Interface, gồm Len(), Less(i, j int) bool và Swap(i, j int), và nó cũng có thể có một bộ định dạng riêng. Trong ví dụ hơi gượng ép sau, Sequence thoả mãn cả hai.
type Sequence []int
// Methods required by sort.Interface.
func (s Sequence) Len() int {
return len(s)
}
func (s Sequence) Less(i, j int) bool {
return s[i] < s[j]
}
func (s Sequence) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
// Copy returns a copy of the Sequence.
func (s Sequence) Copy() Sequence {
copy := make(Sequence, 0, len(s))
return append(copy, s...)
}
// Method for printing - sorts the elements before printing.
func (s Sequence) String() string {
s = s.Copy() // Make a copy; don't overwrite argument.
sort.Sort(s)
str := "["
for i, elem := range s { // Loop is O(N²); will fix that in next example.
if i > 0 {
str += " "
}
str += fmt.Sprint(elem)
}
return str + "]"
}
Chuyển đổi kiểu
Method String của Sequence đang làm lại công việc mà Sprint vốn đã làm cho slice. (Nó còn có độ phức tạp O(N²), khá tệ.) Chúng ta có thể chia sẻ công sức (và cũng tăng tốc) nếu chuyển Sequence thành một []int thuần trước khi gọi Sprint.
func (s Sequence) String() string {
s = s.Copy()
sort.Sort(s)
return fmt.Sprint([]int(s))
}
Method này là một ví dụ khác về kỹ thuật chuyển đổi kiểu để gọi Sprintf một cách an toàn từ một method String. Bởi vì hai kiểu (Sequence và []int) là như nhau nếu bỏ qua tên kiểu, việc chuyển đổi giữa chúng là hợp lệ. Phép chuyển đổi không tạo ra giá trị mới, nó chỉ tạm thời hành xử như thể giá trị sẵn có mang một kiểu mới. (Có những phép chuyển đổi hợp lệ khác, như từ số nguyên sang dấu chấm động, thì có tạo ra một giá trị mới.)
Chuyển đổi kiểu của một biểu thức để truy cập một tập method khác là một thành ngữ trong các chương trình Go. Ví dụ, chúng ta có thể dùng kiểu sẵn có sort.IntSlice để rút gọn toàn bộ ví dụ còn như sau:
type Sequence []int
// Method for printing - sorts the elements before printing
func (s Sequence) String() string {
s = s.Copy()
sort.IntSlice(s).Sort()
return fmt.Sprint([]int(s))
}
Bây giờ, thay vì để Sequence hiện thực nhiều interface (sắp xếp và in ấn), chúng ta dùng khả năng một dữ liệu được chuyển sang nhiều kiểu (Sequence, sort.IntSlice và []int), mỗi kiểu đảm nhiệm một phần công việc. Cách đó ít gặp hơn trong thực tế nhưng có thể rất hiệu quả.
Chuyển đổi interface và type assertion
Type switch là một dạng chuyển đổi: nó nhận một interface và, với mỗi case trong switch, theo một nghĩa nào đó chuyển interface ấy sang kiểu của case đó. Đây là phiên bản rút gọn của cách mà mã bên dưới fmt.Printf biến một giá trị thành chuỗi bằng type switch. Nếu nó vốn đã là chuỗi, ta muốn lấy giá trị chuỗi thực sự mà interface đang giữ; còn nếu nó có method String, ta muốn lấy kết quả của việc gọi method đó.
type Stringer interface {
String() string
}
var value interface{} // Value provided by caller.
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
Case đầu tìm ra một giá trị cụ thể; case thứ hai chuyển interface thành một interface khác. Trộn lẫn các kiểu theo cách này là hoàn toàn ổn.
Sẽ thế nào nếu chỉ có một kiểu duy nhất ta quan tâm? Nếu ta biết giá trị đang giữ một string và ta chỉ muốn rút nó ra? Một type switch một case sẽ làm được, nhưng một type assertion cũng vậy. Type assertion nhận một giá trị interface và rút ra từ đó một giá trị thuộc kiểu tường minh đã chỉ định. Cú pháp mượn từ mệnh đề mở đầu một type switch, nhưng với một kiểu tường minh thay cho từ khoá type:
value.(typeName)
và kết quả là một giá trị mới với kiểu tĩnh typeName. Kiểu đó phải hoặc là kiểu cụ thể mà interface đang giữ, hoặc là một kiểu interface thứ hai mà giá trị có thể chuyển sang. Để rút ra chuỗi mà ta biết nằm trong giá trị, ta có thể viết:
str := value.(string)
Nhưng nếu hoá ra giá trị không chứa một chuỗi, chương trình sẽ sụp đổ với một lỗi lúc chạy. Để phòng điều đó, hãy dùng thành ngữ “comma, ok” để kiểm tra một cách an toàn xem giá trị có phải chuỗi hay không:
str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
} else {
fmt.Printf("value is not a string\n")
}
Nếu type assertion thất bại, str vẫn tồn tại và vẫn thuộc kiểu string, nhưng nó sẽ mang giá trị không, tức chuỗi rỗng.
Để minh hoạ cho khả năng này, đây là một câu lệnh if-else tương đương với type switch đã mở đầu mục này.
if str, ok := value.(string); ok {
return str
} else if str, ok := value.(Stringer); ok {
return str.String()
}
Tính tổng quát
Nếu một kiểu tồn tại chỉ để hiện thực một interface và sẽ không bao giờ có exported method nào ngoài interface đó, thì không cần export bản thân kiểu ấy. Chỉ export interface làm rõ rằng giá trị không có hành vi gì đáng chú ý ngoài những gì đã được mô tả trong interface. Điều đó cũng tránh việc phải lặp lại tài liệu trên mọi thể hiện của một method chung.
Trong những trường hợp như vậy, constructor nên trả về một giá trị interface thay vì kiểu hiện thực. Ví dụ, trong các thư viện hash, cả crc32.NewIEEE lẫn adler32.New đều trả về kiểu interface hash.Hash32. Thay thuật toán CRC-32 bằng Adler-32 trong một chương trình Go chỉ đòi hỏi đổi lời gọi constructor; phần mã còn lại không bị ảnh hưởng bởi sự thay đổi thuật toán.
Một cách tiếp cận tương tự cho phép các thuật toán mã hoá dòng trong các package crypto khác nhau được tách khỏi các block cipher mà chúng xâu chuỗi lại. Interface Block trong package crypto/cipher chỉ định hành vi của một block cipher, thứ cung cấp việc mã hoá một khối dữ liệu đơn lẻ. Rồi, tương tự như package bufio, các package cipher hiện thực interface này có thể được dùng để dựng nên các stream cipher, biểu diễn bởi interface Stream, mà không cần biết chi tiết của việc mã hoá khối.
Các interface của crypto/cipher trông như sau:
type Block interface {
BlockSize() int
Encrypt(dst, src []byte)
Decrypt(dst, src []byte)
}
type Stream interface {
XORKeyStream(dst, src []byte)
}
Đây là định nghĩa của stream chế độ đếm (CTR), thứ biến một block cipher thành một stream cipher; hãy để ý rằng chi tiết của block cipher đã được trừu tượng hoá đi:
// NewCTR returns a Stream that encrypts/decrypts using the given Block in
// counter mode. The length of iv must be the same as the Block's block size.
func NewCTR(block Block, iv []byte) Stream
NewCTR áp dụng được không chỉ cho một thuật toán mã hoá và một nguồn dữ liệu cụ thể mà cho mọi hiện thực của interface Block và mọi Stream. Vì chúng trả về giá trị interface, việc thay mã hoá CTR bằng các chế độ mã hoá khác là một thay đổi cục bộ. Phải sửa các lời gọi constructor, nhưng vì mã xung quanh chỉ được phép đối xử với kết quả như một Stream, nó sẽ không nhận ra sự khác biệt.
Interface và method
Vì gần như mọi thứ đều có thể gắn method vào, gần như mọi thứ đều có thể thoả mãn một interface. Một ví dụ minh hoạ nằm trong package http, nơi định nghĩa interface Handler. Bất kỳ đối tượng nào hiện thực Handler đều có thể phục vụ các HTTP request.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ResponseWriter bản thân nó là một interface cung cấp quyền truy cập tới các method cần thiết để trả response về cho client. Những method đó bao gồm method Write chuẩn, nên một http.ResponseWriter có thể dùng ở bất cứ đâu dùng được một io.Writer. Request là một struct chứa biểu diễn đã phân tích của request từ client.
Cho ngắn gọn, hãy bỏ qua POST và giả định các HTTP request luôn là GET; sự đơn giản hoá đó không ảnh hưởng tới cách thiết lập handler. Đây là một hiện thực tối giản của handler đếm số lần trang được ghé thăm.
// Simple counter server.
type Counter struct {
n int
}
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctr.n++
fmt.Fprintf(w, "counter = %d\n", ctr.n)
}
(Bám theo chủ đề của chúng ta, hãy để ý cách Fprintf có thể in vào một http.ResponseWriter.) Trong một server thật, việc truy cập ctr.n sẽ cần được bảo vệ khỏi truy cập đồng thời. Xem các package sync và atomic để có gợi ý.
Để tham khảo, đây là cách gắn một server như vậy vào một nút trên cây URL.
import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)
Nhưng tại sao phải làm Counter thành một struct? Chỉ cần một số nguyên là đủ. (Receiver cần là pointer để phép tăng hiện ra được với phía gọi.)
// Simpler counter server.
type Counter int
func (ctr *Counter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
*ctr++
fmt.Fprintf(w, "counter = %d\n", *ctr)
}
Nếu chương trình của bạn có trạng thái nội bộ nào đó cần được thông báo rằng một trang vừa được ghé thăm thì sao? Hãy gắn một channel vào trang web.
// A channel that sends a notification on each visit.
// (Probably want the channel to be buffered.)
type Chan chan *http.Request
func (ch Chan) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ch <- req
fmt.Fprint(w, "notification sent")
}
Cuối cùng, giả sử chúng ta muốn trình bày tại /args các đối số đã dùng khi gọi chạy tệp nhị phân của server. Viết một hàm in các đối số thì dễ.
func ArgServer() {
fmt.Println(os.Args)
}
Làm sao biến nó thành một HTTP server? Chúng ta có thể làm ArgServer thành một method của một kiểu nào đó mà ta bỏ qua giá trị, nhưng có cách gọn hơn. Vì ta có thể định nghĩa method cho mọi kiểu trừ pointer và interface, ta có thể viết một method cho một hàm. Package http chứa đoạn mã này:
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler object that calls f.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}
HandlerFunc là một kiểu có một method, ServeHTTP, nên các giá trị thuộc kiểu đó có thể phục vụ HTTP request. Hãy nhìn phần hiện thực của method: receiver là một hàm, f, và method gọi f. Điều đó trông có vẻ lạ nhưng chẳng khác mấy so với, chẳng hạn, receiver là một channel và method gửi lên channel đó.
Để biến ArgServer thành một HTTP server, trước hết ta sửa nó để có chữ ký đúng.
// Argument server.
func ArgServer(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, os.Args)
}
Giờ ArgServer có cùng chữ ký với HandlerFunc, nên nó có thể được chuyển sang kiểu đó để truy cập các method của kiểu ấy, y như chúng ta đã chuyển Sequence sang IntSlice để truy cập IntSlice.Sort. Mã để thiết lập nó rất cô đọng:
http.Handle("/args", http.HandlerFunc(ArgServer))
Khi ai đó ghé thăm trang /args, handler được cài tại trang đó có giá trị ArgServer và kiểu HandlerFunc. HTTP server sẽ gọi method ServeHTTP của kiểu ấy, với ArgServer làm receiver, việc đó đến lượt nó sẽ gọi ArgServer (qua lời gọi f(w, req) bên trong HandlerFunc.ServeHTTP). Các đối số khi ấy sẽ được hiển thị.
Trong mục này, chúng ta đã tạo ra một HTTP server từ một struct, một số nguyên, một channel và một hàm, tất cả chỉ vì interface đơn thuần là những tập method, mà method thì có thể định nghĩa cho (gần như) mọi kiểu.
Blank identifier
Chúng tôi đã nhắc tới blank identifier vài lần rồi, trong ngữ cảnh vòng lặp for range và map. Blank identifier có thể được gán hoặc khai báo với bất kỳ giá trị nào thuộc bất kỳ kiểu nào, còn giá trị đó thì bị loại bỏ một cách vô hại. Nó hơi giống việc ghi vào tệp /dev/null của Unix: nó biểu diễn một giá trị chỉ-ghi dùng làm chỗ giữ chỗ ở nơi cần một biến nhưng giá trị thực sự lại không quan trọng. Nó còn có những công dụng khác ngoài những gì chúng ta đã thấy.
Blank identifier trong phép gán nhiều giá trị
Việc dùng blank identifier trong vòng lặp for range là một trường hợp đặc biệt của một tình huống tổng quát: phép gán nhiều giá trị.
Nếu một phép gán cần nhiều giá trị ở vế trái, nhưng một trong các giá trị đó sẽ không được chương trình dùng tới, thì một blank identifier ở vế trái của phép gán giúp bạn khỏi phải tạo một biến giả và làm rõ rằng giá trị ấy sẽ bị bỏ đi. Chẳng hạn, khi gọi một hàm trả về một giá trị và một error, nhưng chỉ error là quan trọng, hãy dùng blank identifier để loại bỏ giá trị không liên quan.
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}
Thỉnh thoảng bạn sẽ thấy mã nguồn loại bỏ giá trị error để phớt lờ lỗi; đó là một thói quen rất tệ. Luôn kiểm tra error trả về; chúng được cung cấp là có lý do.
// Bad! This code will crash if path does not exist.
fi, _ := os.Stat(path)
if fi.IsDir() {
fmt.Printf("%s is a directory\n", path)
}
Import và biến không dùng đến
Việc import một package hoặc khai báo một biến mà không dùng đến là một lỗi. Import không dùng đến làm phình chương trình và làm chậm quá trình biên dịch, còn một biến được khởi tạo nhưng không dùng thì ít nhất cũng là một phép tính lãng phí và có lẽ là dấu hiệu của một lỗi lớn hơn. Tuy nhiên, khi một chương trình đang được phát triển tích cực, các import và biến không dùng đến thường xuất hiện, và sẽ khá phiền nếu phải xoá chúng chỉ để việc biên dịch trôi qua, để rồi lát nữa lại cần đến chúng. Blank identifier cho ta một cách giải quyết.
Chương trình viết dở này có hai import không dùng đến (fmt và io) và một biến không dùng đến (fd), nên nó sẽ không biên dịch được, nhưng sẽ rất hay nếu xem được liệu phần mã nguồn đã viết đến đây có đúng hay không.
package main
import (
"fmt"
"io"
"log"
"os"
)
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
}
Để dập tắt các than phiền về import không dùng đến, hãy dùng blank identifier để tham chiếu tới một ký hiệu từ package được import. Tương tự, việc gán biến không dùng đến fd cho blank identifier sẽ dập tắt lỗi biến không dùng đến. Phiên bản này của chương trình thì biên dịch được.
package main
import (
"fmt"
"io"
"log"
"os"
)
var _ = fmt.Printf // For debugging; delete when done.
var _ io.Reader // For debugging; delete when done.
func main() {
fd, err := os.Open("test.go")
if err != nil {
log.Fatal(err)
}
// TODO: use fd.
_ = fd
}
Theo quy ước, các khai báo toàn cục dùng để dập tắt lỗi import nên đặt ngay sau phần import và có chú thích, vừa để dễ tìm vừa để nhắc nhở dọn dẹp về sau.
Import vì tác dụng phụ
Một import không dùng đến như fmt hay io trong ví dụ trước rốt cuộc nên được dùng hoặc bị xoá bỏ: các phép gán blank đánh dấu mã nguồn là đang dang dở. Nhưng đôi khi việc import một package chỉ vì tác dụng phụ của nó lại hữu ích, mà không dùng đến nó một cách tường minh. Ví dụ, trong hàm init của mình, package net/http/pprof đăng ký các HTTP handler cung cấp thông tin gỡ lỗi. Nó có một API được export, nhưng hầu hết client chỉ cần phần đăng ký handler và truy cập dữ liệu qua một trang web. Để import package chỉ vì tác dụng phụ của nó, hãy đổi tên package thành blank identifier:
import _ "net/http/pprof"
Dạng import này làm rõ rằng package đang được import vì tác dụng phụ của nó, bởi vì không còn cách nào khác để dùng package: trong tệp này, nó không có tên. (Nếu nó có tên, và chúng tôi không dùng tên đó, compiler sẽ từ chối chương trình.)
Kiểm tra interface
Như đã thấy ở phần thảo luận về interface phía trên, một kiểu dữ liệu không cần khai báo tường minh rằng nó hiện thực một interface. Thay vào đó, một kiểu hiện thực interface chỉ bằng cách hiện thực các method của interface đó. Trên thực tế, hầu hết các phép chuyển đổi interface đều là tĩnh và do đó được kiểm tra lúc biên dịch. Ví dụ, việc truyền một *os.File cho một hàm mong đợi một io.Reader sẽ không biên dịch được trừ khi *os.File hiện thực interface io.Reader.
Tuy vậy, một số kiểm tra interface đúng là diễn ra lúc chạy. Một trường hợp là ở package encoding/json, vốn định nghĩa một interface Marshaler. Khi bộ mã hoá JSON nhận được một giá trị hiện thực interface đó, nó gọi method marshaling của giá trị ấy để chuyển sang JSON thay vì thực hiện phép chuyển đổi tiêu chuẩn. Bộ mã hoá kiểm tra tính chất này lúc chạy bằng một type assertion như sau:
m, ok := val.(json.Marshaler)
Nếu chỉ cần hỏi xem một kiểu có hiện thực một interface hay không, mà không thực sự dùng đến bản thân interface đó, chẳng hạn như một phần của việc kiểm tra lỗi, hãy dùng blank identifier để bỏ qua giá trị thu được từ type assertion:
if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}
Một nơi tình huống này nảy sinh là khi cần bảo đảm, ngay trong package hiện thực kiểu đó, rằng nó thực sự thoả mãn interface. Nếu một kiểu—ví dụ, json.RawMessage—cần một biểu diễn JSON tuỳ biến, nó nên hiện thực json.Marshaler, nhưng lại không có phép chuyển đổi tĩnh nào khiến compiler tự động kiểm tra điều này. Nếu kiểu đó vô tình không thoả mãn interface, bộ mã hoá JSON vẫn hoạt động, nhưng sẽ không dùng phần hiện thực tuỳ biến. Để bảo đảm phần hiện thực là đúng, có thể dùng một khai báo toàn cục với blank identifier trong package:
var _ json.Marshaler = (*RawMessage)(nil)
Trong khai báo này, phép gán có liên quan đến việc chuyển đổi một *RawMessage thành một Marshaler đòi hỏi *RawMessage phải hiện thực Marshaler, và tính chất đó sẽ được kiểm tra lúc biên dịch. Nếu interface json.Marshaler thay đổi, package này sẽ không biên dịch được nữa và chúng tôi sẽ được báo rằng nó cần được cập nhật.
Sự xuất hiện của blank identifier trong cấu trúc này cho thấy rằng khai báo tồn tại chỉ để kiểm tra kiểu, chứ không phải để tạo ra một biến. Tuy nhiên, đừng làm vậy với mọi kiểu thoả mãn một interface. Theo quy ước, những khai báo như thế chỉ được dùng khi trong mã nguồn chưa có sẵn phép chuyển đổi tĩnh nào, và đó là chuyện hiếm gặp.
Embedding
Go không cung cấp khái niệm kế thừa lớp theo kiểu thông thường, dựa trên kiểu dữ liệu, nhưng nó có khả năng “mượn” những mảnh của một phần hiện thực bằng cách embedding các kiểu vào trong một struct hoặc interface.
Embedding interface rất đơn giản. Chúng tôi đã nhắc tới các interface io.Reader và io.Writer trước đó; đây là định nghĩa của chúng.
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
Package io cũng export vài interface khác mô tả những đối tượng có thể hiện thực nhiều method như vậy. Chẳng hạn, có io.ReadWriter, một interface chứa cả Read lẫn Write. Chúng tôi có thể mô tả io.ReadWriter bằng cách liệt kê hai method một cách tường minh, nhưng embedding hai interface để tạo nên interface mới thì dễ hơn và gợi ý hơn, như sau:
// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
Reader
Writer
}
Nó nói đúng như vẻ ngoài của nó: một ReadWriter có thể làm những gì một Reader làm và những gì một Writer làm; nó là hợp của các interface được embed. Chỉ interface mới có thể được embed bên trong interface.
Cùng một ý tưởng cơ bản áp dụng cho struct, nhưng với những hệ quả sâu rộng hơn. Package bufio có hai kiểu struct, bufio.Reader và bufio.Writer, mỗi kiểu dĩ nhiên đều hiện thực các interface tương ứng từ package io. Và bufio còn hiện thực một reader/writer có bộ đệm, bằng cách kết hợp một reader và một writer thành một struct nhờ embedding: nó liệt kê các kiểu bên trong struct nhưng không đặt tên trường cho chúng.
// ReadWriter stores pointers to a Reader and a Writer.
// It implements io.ReadWriter.
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}
Các phần tử được embed là pointer tới struct và dĩ nhiên phải được khởi tạo để trỏ tới các struct hợp lệ trước khi có thể dùng. Struct ReadWriter có thể được viết là
type ReadWriter struct {
reader *Reader
writer *Writer
}
nhưng khi đó, để thăng cấp các method của những trường này và để thoả mãn các interface của io, chúng tôi sẽ còn phải cung cấp các method chuyển tiếp, như thế này:
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p)
}
Bằng cách embed trực tiếp các struct, chúng tôi tránh được việc ghi chép thủ công này. Các method của kiểu được embed đi kèm miễn phí, nghĩa là bufio.ReadWriter không chỉ có các method của bufio.Reader và bufio.Writer, mà còn thoả mãn cả ba interface: io.Reader, io.Writer, và io.ReadWriter.
Có một khác biệt quan trọng giữa embedding và kế thừa lớp. Khi chúng tôi embed một kiểu, các method của kiểu đó trở thành method của kiểu bên ngoài, nhưng khi chúng được gọi thì receiver của method là kiểu bên trong, chứ không phải kiểu bên ngoài. Trong ví dụ của chúng tôi, khi method Read của một bufio.ReadWriter được gọi, hiệu quả của nó y hệt method chuyển tiếp viết ra ở trên; receiver là trường reader của ReadWriter, chứ không phải bản thân ReadWriter.
Embedding cũng có thể chỉ là một sự tiện lợi đơn giản. Ví dụ này cho thấy một trường được embed bên cạnh một trường thông thường, có tên.
type Job struct {
Command string
*log.Logger
}
Kiểu Job giờ đây có các method Print, Printf, Println và các method khác của *log.Logger. Dĩ nhiên chúng tôi có thể đặt cho Logger một tên trường, nhưng không nhất thiết phải làm vậy. Và bây giờ, sau khi khởi tạo, chúng tôi có thể ghi log vào Job:
job.Println("starting now...")
Logger là một trường thông thường của struct Job, nên chúng tôi có thể khởi tạo nó theo cách thường lệ bên trong hàm khởi tạo của Job, như thế này,
func NewJob(command string, logger *log.Logger) *Job {
return &Job{command, logger}
}
hoặc bằng một composite literal,
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
Nếu cần tham chiếu trực tiếp tới một trường được embed, tên kiểu của trường, bỏ qua phần định danh package, đóng vai trò tên trường, như nó đã làm trong method Read của struct ReadWriter của chúng tôi. Ở đây, nếu chúng tôi cần truy cập *log.Logger của một biến Job tên là job, chúng tôi sẽ viết job.Logger, điều này sẽ hữu ích nếu chúng tôi muốn tinh chỉnh các method của Logger.
func (job *Job) Printf(format string, args ...interface{}) {
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}
Embedding các kiểu làm nảy sinh vấn đề xung đột tên, nhưng các quy tắc giải quyết chúng thì đơn giản. Thứ nhất, một trường hoặc method X che khuất bất kỳ mục X nào khác nằm ở phần lồng sâu hơn của kiểu. Nếu log.Logger chứa một trường hoặc method tên là Command, thì trường Command của Job sẽ lấn át nó.
Thứ hai, nếu cùng một tên xuất hiện ở cùng mức lồng nhau, thường thì đó là một lỗi; sẽ là sai nếu embed log.Logger trong khi struct Job chứa một trường hoặc method khác tên là Logger. Tuy nhiên, nếu cái tên trùng lặp đó không bao giờ được nhắc tới trong chương trình bên ngoài phần định nghĩa kiểu, thì không sao. Điều kiện này đem lại chút bảo vệ trước những thay đổi ở các kiểu được embed từ bên ngoài; không có vấn đề gì nếu một trường được thêm vào mà xung đột với một trường khác trong một kiểu con khác, miễn là cả hai trường không bao giờ được dùng.
Tính đồng thời
Chia sẻ bằng cách giao tiếp
Lập trình đồng thời là một chủ đề lớn và ở đây chỉ đủ chỗ cho một số điểm nhấn đặc thù của Go.
Lập trình đồng thời trong nhiều môi trường trở nên khó khăn vì những sự tinh tế cần có để hiện thực việc truy cập đúng đắn vào các biến dùng chung. Go khuyến khích một hướng tiếp cận khác, trong đó các giá trị dùng chung được truyền qua channel và, trên thực tế, không bao giờ bị chia sẻ chủ động giữa các luồng thực thi riêng biệt. Tại mỗi thời điểm chỉ có một goroutine truy cập được vào giá trị. Data race không thể xảy ra, ngay từ thiết kế. Để khuyến khích lối tư duy này, chúng tôi đã cô đọng nó thành một khẩu hiệu:
Đừng giao tiếp bằng cách chia sẻ bộ nhớ; thay vào đó, hãy chia sẻ bộ nhớ bằng cách giao tiếp. (Do not communicate by sharing memory; instead, share memory by communicating.)
Cách tiếp cận này có thể bị đẩy đi quá xa. Chẳng hạn, đếm tham chiếu có lẽ được làm tốt nhất bằng cách đặt một mutex quanh một biến số nguyên. Nhưng ở tầm tiếp cận mức cao, việc dùng channel để kiểm soát truy cập khiến việc viết những chương trình rõ ràng, đúng đắn trở nên dễ hơn.
Một cách để hình dung mô hình này là xét một chương trình đơn luồng điển hình chạy trên một CPU. Nó không cần tới các nguyên thuỷ đồng bộ hoá. Giờ hãy chạy thêm một bản như vậy nữa; nó cũng không cần đồng bộ hoá. Bây giờ hãy để hai bản đó giao tiếp với nhau; nếu chính việc giao tiếp là bộ đồng bộ hoá, thì vẫn không cần tới đồng bộ hoá nào khác. Chẳng hạn, pipeline của Unix khớp với mô hình này một cách hoàn hảo. Mặc dù cách tiếp cận tính đồng thời của Go bắt nguồn từ Communicating Sequential Processes (CSP) của Hoare, nó cũng có thể được xem là một dạng tổng quát hoá an toàn kiểu của pipe trong Unix.
Goroutine
Chúng được gọi là goroutine bởi vì những thuật ngữ sẵn có—thread, coroutine, tiến trình, v.v.—mang những hàm ý không chính xác. Một goroutine có mô hình đơn giản: nó là một hàm thực thi đồng thời với các goroutine khác trong cùng một không gian địa chỉ. Nó nhẹ, tốn kém không hơn bao nhiêu so với việc cấp phát không gian stack. Và các stack bắt đầu nhỏ, nên chúng rẻ, rồi lớn dần bằng cách cấp phát (và giải phóng) bộ nhớ heap khi cần.
Goroutine được ghép kênh lên nhiều luồng của hệ điều hành, nên nếu một goroutine bị chặn, chẳng hạn khi chờ I/O, các goroutine khác vẫn tiếp tục chạy. Thiết kế của chúng giấu đi phần lớn sự phức tạp của việc tạo và quản lý luồng.
Hãy đặt từ khoá go trước một lời gọi hàm hoặc method để chạy lời gọi đó trong một goroutine mới. Khi lời gọi hoàn tất, goroutine kết thúc, một cách âm thầm. (Hiệu quả tương tự ký hiệu & của shell Unix để chạy một lệnh dưới nền.)
go list.Sort() // run list.Sort concurrently; don't wait for it.
Một function literal có thể rất tiện khi gọi goroutine.
func Announce(message string, delay time.Duration) {
go func() {
time.Sleep(delay)
fmt.Println(message)
}() // Note the parentheses - must call the function.
}
Trong Go, function literal là closure: phần hiện thực bảo đảm rằng các biến mà hàm tham chiếu tới vẫn tồn tại chừng nào chúng còn được dùng.
Những ví dụ này chưa thật thực tế vì các hàm không có cách nào báo hiệu là đã hoàn tất. Để làm điều đó, chúng ta cần channel.
Channel
Giống như map, channel được cấp phát bằng make, và giá trị thu được đóng vai trò một tham chiếu tới một cấu trúc dữ liệu nằm bên dưới. Nếu có thêm một tham số số nguyên tuỳ chọn, nó đặt kích thước bộ đệm cho channel. Mặc định là 0, tức là một unbuffered channel hay channel đồng bộ.
ci := make(chan int) // unbuffered channel of integers
cj := make(chan int, 0) // unbuffered channel of integers
cs := make(chan *os.File, 100) // buffered channel of pointers to Files
Unbuffered channel kết hợp việc giao tiếp—trao đổi một giá trị—với việc đồng bộ hoá—bảo đảm rằng hai phép tính (goroutine) đang ở một trạng thái đã biết.
Có rất nhiều thành ngữ hay dùng channel. Đây là một cái để bắt đầu. Ở phần trước chúng tôi đã khởi chạy một phép sắp xếp dưới nền. Một channel có thể cho phép goroutine khởi chạy nó chờ phép sắp xếp hoàn tất.
c := make(chan int) // Allocate a channel.
// Start the sort in a goroutine; when it completes, signal on the channel.
go func() {
list.Sort()
c <- 1 // Send a signal; value does not matter.
}()
doSomethingForAWhile()
<-c // Wait for sort to finish; discard sent value.
Bên nhận luôn bị chặn cho tới khi có dữ liệu để nhận. Nếu channel là unbuffered, bên gửi bị chặn cho tới khi bên nhận đã nhận được giá trị. Nếu channel có bộ đệm, bên gửi chỉ bị chặn cho tới khi giá trị đã được sao vào bộ đệm; nếu bộ đệm đầy, điều đó nghĩa là phải chờ tới khi có bên nhận nào đó lấy ra một giá trị.
Một buffered channel có thể được dùng như một semaphore, chẳng hạn để giới hạn thông lượng. Trong ví dụ này, các request đến được chuyển tới handle, hàm này gửi một giá trị vào channel, xử lý request, rồi nhận một giá trị từ channel để sẵn sàng “semaphore” cho người tiêu thụ tiếp theo. Sức chứa của bộ đệm channel giới hạn số lượng lời gọi đồng thời tới process.
var sem = make(chan int, MaxOutstanding)
func handle(r *Request) {
sem <- 1 // Wait for active queue to drain.
process(r) // May take a long time.
<-sem // Done; enable next request to run.
}
func Serve(queue chan *Request) {
for {
req := <-queue
go handle(req) // Don't wait for handle to finish.
}
}
Một khi đã có MaxOutstanding handler đang thực thi process, mọi handler thêm nữa sẽ bị chặn khi cố gửi vào bộ đệm channel đã đầy, cho tới khi một trong các handler hiện có kết thúc và nhận từ bộ đệm.
Tuy nhiên thiết kế này có một vấn đề: Serve tạo một goroutine mới cho mỗi request đến, dù chỉ MaxOutstanding trong số đó có thể chạy tại bất kỳ thời điểm nào. Hệ quả là chương trình có thể ngốn tài nguyên không giới hạn nếu request đến quá nhanh. Chúng tôi có thể khắc phục khiếm khuyết đó bằng cách sửa Serve để kiểm soát việc tạo goroutine:
func Serve(queue chan *Request) {
for req := range queue {
sem <- 1
go func() {
process(req)
<-sem
}()
}
}
(Lưu ý rằng ở các phiên bản Go trước 1.22 đoạn mã này có lỗi: biến vòng lặp được dùng chung giữa tất cả các goroutine. Xem Go wiki để biết chi tiết.)
Một cách tiếp cận khác quản lý tài nguyên tốt là khởi chạy một số goroutine handle cố định, tất cả đều đọc từ channel request. Số lượng goroutine giới hạn số lời gọi đồng thời tới process. Hàm Serve này cũng nhận một channel mà qua đó nó sẽ được báo là phải thoát; sau khi khởi chạy các goroutine, nó bị chặn khi nhận từ channel ấy.
func handle(queue chan *Request) {
for r := range queue {
process(r)
}
}
func Serve(clientRequests chan *Request, quit chan bool) {
// Start handlers
for i := 0; i < MaxOutstanding; i++ {
go handle(clientRequests)
}
<-quit // Wait to be told to exit.
}
Channel của channel
Một trong những tính chất quan trọng nhất của Go là channel là một giá trị hạng nhất, có thể được cấp phát và truyền đi như bất kỳ giá trị nào khác. Một công dụng phổ biến của tính chất này là hiện thực việc phân kênh song song một cách an toàn.
Trong ví dụ ở phần trước, handle là một handler lý tưởng hoá cho một request nhưng chúng tôi chưa định nghĩa kiểu mà nó xử lý. Nếu kiểu đó bao gồm một channel để trả lời, mỗi client có thể cung cấp đường đi riêng cho câu trả lời. Đây là định nghĩa phác thảo của kiểu Request.
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
Client cung cấp một hàm cùng các đối số của nó, cũng như một channel bên trong đối tượng request để nhận câu trả lời.
func sum(a []int) (s int) {
for _, v := range a {
s += v
}
return
}
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
// Send request
clientRequests <- request
// Wait for response.
fmt.Printf("answer: %d\n", <-request.resultChan)
Ở phía server, hàm handler là thứ duy nhất thay đổi.
func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}
Rõ ràng còn nhiều việc phải làm để nó thực tế hơn, nhưng đoạn mã này là một khung sườn cho một hệ thống RPC song song, không chặn, có giới hạn tốc độ, mà chẳng thấy bóng dáng mutex nào.
Song song hoá
Một ứng dụng khác của những ý tưởng này là song song hoá một phép tính trên nhiều lõi CPU. Nếu phép tính có thể được chia thành những mảnh riêng biệt có thể thực thi độc lập, nó có thể được song song hoá, với một channel để báo hiệu khi mỗi mảnh hoàn tất.
Giả sử chúng ta có một thao tác tốn kém cần thực hiện trên một vector các phần tử, và giá trị của thao tác trên mỗi phần tử là độc lập, như trong ví dụ lý tưởng hoá này.
type Vector []float64
// Apply the operation to v[i], v[i+1] ... up to v[n-1].
func (v Vector) DoSome(i, n int, u Vector, c chan int) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
c <- 1 // signal that this piece is done
}
Chúng tôi khởi chạy các mảnh một cách độc lập trong một vòng lặp, mỗi CPU một mảnh. Chúng có thể hoàn tất theo bất kỳ thứ tự nào nhưng điều đó không quan trọng; chúng tôi chỉ đếm các tín hiệu hoàn tất bằng cách rút cạn channel sau khi đã khởi chạy tất cả các goroutine.
const numCPU = 4 // number of CPU cores
func (v Vector) DoAll(u Vector) {
c := make(chan int, numCPU) // Buffering optional but sensible.
for i := 0; i < numCPU; i++ {
go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
}
// Drain the channel.
for i := 0; i < numCPU; i++ {
<-c // wait for one task to complete
}
// All done.
}
Thay vì tạo một hằng số cho numCPU, chúng tôi có thể hỏi runtime xem giá trị nào là phù hợp. Hàm runtime.NumCPU trả về số lõi CPU vật lý trong máy, nên chúng tôi có thể viết
var numCPU = runtime.NumCPU()
Còn có một hàm runtime.GOMAXPROCS, hàm này báo cáo (hoặc đặt) số lõi do người dùng chỉ định mà một chương trình Go có thể chạy đồng thời. Nó mặc định bằng giá trị của runtime.NumCPU nhưng có thể bị ghi đè bằng cách đặt biến môi trường shell cùng tên hoặc bằng cách gọi hàm với một số dương. Gọi nó với 0 thì chỉ đơn thuần truy vấn giá trị. Do đó nếu chúng tôi muốn tôn trọng yêu cầu tài nguyên của người dùng, chúng tôi nên viết
var numCPU = runtime.GOMAXPROCS(0)
Hãy cẩn thận đừng lẫn lộn giữa ý niệm tính đồng thời—cấu trúc một chương trình thành các thành phần thực thi độc lập—và tính song song—thực thi các phép tính song song nhau để đạt hiệu quả trên nhiều CPU. Mặc dù các tính năng đồng thời của Go có thể khiến một số bài toán dễ cấu trúc thành các phép tính song song, Go là một ngôn ngữ đồng thời, không phải một ngôn ngữ song song, và không phải mọi bài toán song song hoá đều khớp với mô hình của Go. Để bàn về sự khác biệt này, hãy xem bài nói được trích dẫn trong bài viết blog này.
Một leaky buffer
Các công cụ của lập trình đồng thời thậm chí có thể khiến những ý tưởng không đồng thời trở nên dễ diễn đạt hơn. Đây là một ví dụ trừu tượng hoá từ một package RPC. Goroutine client lặp đi lặp lại việc nhận dữ liệu từ một nguồn nào đó, có lẽ là mạng. Để tránh cấp phát và giải phóng buffer, nó giữ một danh sách rỗi, và dùng một buffered channel để biểu diễn danh sách đó. Nếu channel rỗng, một buffer mới sẽ được cấp phát. Một khi buffer thông điệp đã sẵn sàng, nó được gửi tới server trên serverChan.
var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)
func client() {
for {
var b *Buffer
// Grab a buffer if available; allocate if not.
select {
case b = <-freeList:
// Got one; nothing more to do.
default:
// None free, so allocate a new one.
b = new(Buffer)
}
load(b) // Read next message from the net.
serverChan <- b // Send to server.
}
}
Vòng lặp server nhận từng thông điệp từ client, xử lý nó, rồi trả buffer về danh sách rỗi.
func server() {
for {
b := <-serverChan // Wait for work.
process(b)
// Reuse buffer if there's room.
select {
case freeList <- b:
// Buffer on free list; nothing more to do.
default:
// Free list full, just carry on.
}
}
}
Client cố lấy một buffer từ freeList; nếu không có cái nào, nó cấp phát một cái mới. Việc server gửi vào freeList đặt b trở lại danh sách rỗi trừ khi danh sách đã đầy, khi đó buffer bị bỏ rơi để garbage collector thu hồi. (Các nhánh default trong các câu lệnh select được thực thi khi không có case nào khác sẵn sàng, nghĩa là các selects không bao giờ bị chặn.) Phần hiện thực này xây dựng một danh sách rỗi kiểu leaky bucket chỉ trong vài dòng, dựa vào buffered channel và garbage collector để lo phần ghi chép.
Lỗi
Các hàm thư viện thường phải trả về một dạng chỉ dấu lỗi nào đó cho bên gọi. Như đã đề cập trước đó, việc trả về nhiều giá trị của Go khiến việc trả về một mô tả lỗi chi tiết bên cạnh giá trị trả về thông thường trở nên dễ dàng. Dùng tính năng này để cung cấp thông tin lỗi chi tiết là một phong cách tốt. Ví dụ, như chúng ta sẽ thấy, os.Open không chỉ trả về một pointer nil khi thất bại, nó còn trả về một giá trị error mô tả chuyện gì đã sai.
Theo quy ước, lỗi có kiểu error, một interface dựng sẵn đơn giản.
type error interface {
Error() string
}
Người viết thư viện được tự do hiện thực interface này với một mô hình phong phú hơn ở bên dưới, khiến ta không chỉ thấy được lỗi mà còn có thêm chút ngữ cảnh. Như đã nói, bên cạnh giá trị trả về *os.File thường thấy, os.Open cũng trả về một giá trị error. Nếu tệp được mở thành công, error sẽ là nil, nhưng khi có vấn đề, nó sẽ chứa một os.PathError:
// PathError records an error and the operation and
// file path that caused it.
type PathError struct {
Op string // "open", "unlink", etc.
Path string // The associated file.
Err error // Returned by the system call.
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
Method Error của PathError sinh ra một chuỗi như thế này:
open /etc/passwx: no such file or directory
Một lỗi như vậy, bao gồm tên tệp có vấn đề, thao tác, và lỗi hệ điều hành mà nó gây ra, là hữu ích ngay cả khi được in ra ở nơi xa lời gọi đã gây ra nó; nó cung cấp nhiều thông tin hơn hẳn câu “no such file or directory” trơ trọi.
Khi khả thi, chuỗi lỗi nên chỉ ra nguồn gốc của chúng, chẳng hạn bằng cách có một tiền tố nêu tên thao tác hoặc package đã sinh ra lỗi. Ví dụ, trong package image, biểu diễn chuỗi của một lỗi giải mã do định dạng không rõ là “image: unknown format”.
Bên gọi nào quan tâm tới chi tiết chính xác của lỗi có thể dùng type switch hoặc type assertion để tìm những lỗi cụ thể và trích xuất chi tiết. Với PathErrors điều này có thể bao gồm việc xem xét trường Err bên trong để tìm những thất bại có thể khắc phục.
for try := 0; try < 2; try++ {
file, err = os.Create(filename)
if err == nil {
return
}
if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
deleteTempFiles() // Recover some space.
continue
}
return
}
Câu lệnh if thứ hai ở đây là một type assertion nữa. Nếu nó thất bại, ok sẽ là false, và e sẽ là nil. Nếu nó thành công, ok sẽ là true, nghĩa là lỗi có kiểu *os.PathError, và do đó e cũng vậy, và chúng ta có thể xem xét nó để biết thêm thông tin về lỗi.
Panic
Cách thông thường để báo lỗi cho bên gọi là trả về một error như một giá trị trả về bổ sung. Method Read kinh điển là một ví dụ nổi tiếng; nó trả về số byte và một error. Nhưng nếu lỗi là không thể khắc phục thì sao? Đôi khi chương trình đơn giản là không thể tiếp tục.
Vì mục đích này, có một hàm dựng sẵn panic mà trên thực tế tạo ra một lỗi lúc chạy làm dừng chương trình (nhưng hãy xem phần tiếp theo). Hàm này nhận một đối số duy nhất thuộc kiểu bất kỳ—thường là một chuỗi—để in ra khi chương trình chết. Nó cũng là một cách để cho biết rằng điều gì đó bất khả thi đã xảy ra, chẳng hạn như thoát khỏi một vòng lặp vô hạn.
// A toy implementation of cube root using Newton's method.
func CubeRoot(x float64) float64 {
z := x/3 // Arbitrary initial value
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// A million iterations has not converged; something is wrong.
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
Đây chỉ là một ví dụ, còn các hàm thư viện thực thụ thì nên tránh panic. Nếu vấn đề có thể được che đi hoặc có cách đi vòng, thì luôn tốt hơn nếu để mọi thứ tiếp tục chạy thay vì hạ gục cả chương trình. Một phản ví dụ khả dĩ là trong lúc khởi tạo: nếu thư viện thực sự không thể tự thiết lập được, thì có lẽ panic là hợp lý, có thể nói vậy.
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
Recover
Khi panic được gọi, kể cả khi được gọi ngầm cho các lỗi lúc chạy như truy cập slice ngoài phạm vi hay type assertion thất bại, nó lập tức dừng việc thực thi hàm hiện tại và bắt đầu tháo dỡ stack của goroutine, chạy mọi hàm được defer trên đường đi. Nếu việc tháo dỡ đó chạm tới đỉnh stack của goroutine, chương trình chết. Tuy nhiên, có thể dùng hàm dựng sẵn recover để giành lại quyền kiểm soát goroutine và tiếp tục thực thi bình thường.
Một lời gọi recover dừng việc tháo dỡ và trả về đối số đã được truyền cho panic. Bởi vì mã duy nhất chạy trong lúc tháo dỡ nằm bên trong các hàm được defer, recover chỉ hữu ích bên trong các hàm được defer.
Một ứng dụng của recover là tắt một goroutine bị lỗi bên trong một server mà không giết các goroutine khác đang thực thi.
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work)
}
}
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
Trong ví dụ này, nếu do(work) panic, kết quả sẽ được ghi log và goroutine sẽ thoát gọn gàng mà không làm phiền các goroutine khác. Không cần làm gì khác trong closure được defer; việc gọi recover xử lý trọn vẹn tình huống.
Bởi vì recover luôn trả về nil trừ khi được gọi trực tiếp từ một hàm được defer, mã được defer có thể gọi các hàm thư viện mà bản thân chúng dùng panic và recover mà không bị hỏng. Chẳng hạn, hàm được defer trong safelyDo có thể gọi một hàm ghi log trước khi gọi recover, và mã ghi log đó sẽ chạy mà không bị ảnh hưởng bởi trạng thái đang panic.
Với mẫu khôi phục đã sẵn sàng, hàm do (và mọi thứ nó gọi) có thể thoát khỏi mọi tình huống xấu một cách gọn gàng bằng cách gọi panic. Chúng tôi có thể dùng ý tưởng đó để đơn giản hoá việc xử lý lỗi trong phần mềm phức tạp. Hãy xem một phiên bản lý tưởng hoá của package regexp, package này báo lỗi phân tích cú pháp bằng cách gọi panic với một kiểu error cục bộ. Đây là định nghĩa của Error, một method error, và hàm Compile.
// Error is the type of a parse error; it satisfies the error interface.
type Error string
func (e Error) Error() string {
return string(e)
}
// error is a method of *Regexp that reports parsing errors by
// panicking with an Error.
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// Compile returns a parsed representation of the regular expression.
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
// doParse will panic if there is a parse error.
defer func() {
if e := recover(); e != nil {
regexp = nil // Clear return value.
err = e.(Error) // Will re-panic if not a parse error.
}
}()
return regexp.doParse(str), nil
}
Nếu doParse panic, khối khôi phục sẽ đặt giá trị trả về thành nil—các hàm được defer có thể sửa đổi các giá trị trả về có tên. Sau đó, trong phép gán cho err, nó sẽ kiểm tra rằng vấn đề là một lỗi phân tích cú pháp bằng cách khẳng định rằng nó có kiểu cục bộ Error. Nếu không phải, type assertion sẽ thất bại, gây ra một lỗi lúc chạy tiếp tục việc tháo dỡ stack như thể không có gì làm gián đoạn nó cả. Phép kiểm tra này nghĩa là nếu có điều gì bất ngờ xảy ra, chẳng hạn như chỉ số ngoài phạm vi, mã nguồn vẫn sẽ thất bại dù chúng ta đang dùng panic và recover để xử lý lỗi phân tích cú pháp.
Với việc xử lý lỗi đã sẵn sàng, method error (bởi vì nó là một method gắn với một kiểu, nên việc nó trùng tên với kiểu dựng sẵn error là ổn, thậm chí là tự nhiên) khiến việc báo lỗi phân tích cú pháp trở nên dễ dàng mà không phải lo tháo dỡ stack phân tích bằng tay:
if pos == 0 {
re.error("'*' illegal at start of expression")
}
Dù mẫu này hữu ích, nó chỉ nên được dùng bên trong một package. Parse biến các lời gọi panic nội bộ của nó thành các giá trị error; nó không phơi bày panics ra cho client của mình. Đó là một quy tắc tốt nên theo.
Nhân tiện, thành ngữ re-panic này thay đổi giá trị panic nếu một lỗi thực sự xảy ra. Tuy nhiên, cả thất bại gốc lẫn thất bại mới đều sẽ được trình bày trong báo cáo sự cố, nên nguyên nhân gốc rễ của vấn đề vẫn sẽ nhìn thấy được. Vì vậy cách re-panic đơn giản này thường là đủ—dù sao thì cũng là một vụ sập—nhưng nếu bạn muốn chỉ hiển thị giá trị gốc, bạn có thể viết thêm chút mã để lọc những vấn đề bất ngờ và re-panic với lỗi gốc. Điều đó xin dành làm bài tập cho bạn đọc.
Một web server
Hãy kết thúc bằng một chương trình Go hoàn chỉnh, một web server. Cái này thực ra là một kiểu web re-server. Google cung cấp một dịch vụ tại chart.apis.google.com tự động định dạng dữ liệu thành biểu đồ và đồ thị. Tuy nhiên nó khó dùng một cách tương tác, vì bạn phải đặt dữ liệu vào URL dưới dạng một query. Chương trình ở đây cung cấp một giao diện dễ chịu hơn cho một dạng dữ liệu: cho một đoạn văn bản ngắn, nó gọi tới chart server để tạo ra một mã QR, một ma trận các ô vuông mã hoá đoạn văn bản đó. Ảnh đó có thể được chụp bằng camera điện thoại của bạn và được diễn giải, chẳng hạn, thành một URL, giúp bạn khỏi phải gõ URL trên bàn phím tí hon của điện thoại.
Đây là chương trình hoàn chỉnh. Phần giải thích nằm ngay sau đó.
package main
import (
"flag"
"html/template"
"log"
"net/http"
)
var addr = flag.String("addr", ":1718", "http service address") // Q=17, R=18
var templ = template.Must(template.New("qr").Parse(templateStr))
func main() {
flag.Parse()
http.Handle("/", http.HandlerFunc(QR))
err := http.ListenAndServe(*addr, nil)
if err != nil {
log.Fatal("ListenAndServe:", err)
}
}
func QR(w http.ResponseWriter, req *http.Request) {
templ.Execute(w, req.FormValue("s"))
}
const templateStr = `
<html>
<head>
<title>QR Link Generator</title>
</head>
<body>
{{if .}}
<img src="http://chart.apis.google.com/chart?chs=300x300&cht=qr&choe=UTF-8&chl={{.}}" />
<br>
{{.}}
<br>
<br>
{{end}}
<form action="/" name=f method="GET">
<input maxLength=1024 size=70 name=s value="" title="Text to QR Encode">
<input type=submit value="Show QR" name=qr>
</form>
</body>
</html>
`
Các phần cho tới main hẳn là dễ theo dõi. Cờ duy nhất đặt một cổng HTTP mặc định cho server của chúng tôi. Biến template templ là nơi điều thú vị diễn ra. Nó dựng một template HTML sẽ được server thực thi để hiển thị trang; chút nữa sẽ nói thêm về chuyện đó.
Hàm main phân tích các cờ và, dùng cơ chế chúng tôi đã bàn ở trên, gắn hàm QR vào đường dẫn gốc của server. Sau đó http.ListenAndServe được gọi để khởi động server; nó bị chặn trong lúc server chạy.
QR chỉ nhận request, vốn chứa dữ liệu form, và thực thi template trên dữ liệu nằm trong giá trị form tên là s.
Package template html/template rất mạnh mẽ; chương trình này chỉ chạm tới bề nổi khả năng của nó. Về bản chất, nó viết lại một đoạn văn bản HTML ngay lúc chạy bằng cách thay thế các phần tử suy ra từ các mục dữ liệu được truyền cho templ.Execute, trong trường hợp này là giá trị form. Bên trong văn bản template (templateStr), các đoạn được bao bởi ngoặc nhọn kép biểu thị các hành động template. Đoạn từ {{if .}} tới {{end}} chỉ được thực thi nếu giá trị của mục dữ liệu hiện tại, gọi là . (dấu chấm), khác rỗng. Nghĩa là, khi chuỗi rỗng, đoạn này của template bị bỏ qua.
Hai đoạn {{.}} bảo hiển thị dữ liệu được đưa tới template—chuỗi query—lên trang web. Package template HTML tự động cung cấp việc escape thích hợp để văn bản an toàn khi hiển thị.
Phần còn lại của chuỗi template chỉ là HTML để hiển thị khi trang được tải. Nếu giải thích như vậy là quá nhanh, hãy xem tài liệu của package template để có phần thảo luận kỹ lưỡng hơn.
Và thế là xong: một web server hữu ích trong vài dòng mã cộng thêm chút văn bản HTML hướng dữ liệu. Go đủ mạnh để làm được nhiều điều chỉ trong vài dòng.