Effective Go
Giới thiệu
Go là một ngôn ngữ mới. Mặc dù nó vay mượn ý tưởng từ các ngôn ngữ hiện có, Go có những đặc điểm khác biệt khiến các chương trình Go hiệu quả mang bản sắc riêng, khác hẳn với các chương trình viết bằng những ngôn ngữ họ hàng. Việc dịch thẳng một chương trình C++ hay Java sang Go khó có thể cho kết quả tốt—chương trình Java được viết bằng Java, không phải Go. Ngược lại, tiếp cận bài toán theo góc nhìn của Go có thể tạo ra một chương trình thành công nhưng hoàn toàn khác biệt. Nói cách khác, để viết Go tốt, cần hiểu rõ các đặc tính và idiom của ngôn ngữ. Ngoài ra, cần nắm được các quy ước đã được thiết lập khi lập trình bằng Go, như cách đặt tên, định dạng code, cấu trúc chương trình, v.v., để các chương trình bạn viết dễ hiểu đối với những lập trình viên Go khác.
Tài liệu này cung cấp các gợi ý để viết code Go rõ ràng và chuẩn idiom. Nó bổ sung cho đặc tả ngôn ngữ, Tour of Go, và Cách viết code Go, tất cả những tài liệu mà bạn nên đọc trước.
Ghi chú thêm vào tháng 1 năm 2022: Tài liệu này được viết vào thời điểm Go ra mắt năm 2009 và chưa được cập nhật đáng kể kể từ đó. Mặc dù đây là tài liệu hướng dẫn tốt để hiểu cách sử dụng bản thân ngôn ngữ, nhờ tính ổn định của Go, tài liệu này nói rất ít về các thư viện và không đề cập đến những thay đổi quan trọng trong hệ sinh thái Go kể từ khi được viết, chẳng hạn như hệ thống build, testing, modules và polymorphism. Hiện không có kế hoạch cập nhật tài liệu này, vì đã có rất nhiều thay đổi và một tập hợp tài liệu, bài blog và sách ngày càng phong phú đang mô tả cách sử dụng Go hiện đại một cách rất tốt. Effective Go vẫn hữu ích, nhưng người đọc cần hiểu rằng đây chưa phải là một hướng dẫn đầy đủ. Xem issue 28782 để biết thêm bối cảnh.
Ví dụ
Mã nguồn của các gói Go không chỉ đóng vai trò là thư viện cốt lõi mà còn là ví dụ về cách sử dụng ngôn ngữ. Hơn nữa, nhiều gói chứa các ví dụ thực thi độc lập hoàn chỉnh mà bạn có thể chạy trực tiếp từ trang web go.dev, chẳng hạn như ví dụ này (nếu cần, nhấp vào từ "Example" để mở). Nếu bạn có thắc mắc về cách tiếp cận một vấn đề hay cách triển khai một tính năng nào đó, tài liệu, code và ví dụ trong thư viện có thể cung cấp câu trả lời, ý tưởng và kiến thức nền.
Định dạng
Các vấn đề về định dạng thường gây tranh cãi nhiều nhất nhưng lại ít quan trọng nhất. Người ta có thể thích nghi với các phong cách định dạng khác nhau, nhưng tốt hơn là không phải làm vậy, và sẽ tốn ít thời gian hơn cho chủ đề này nếu tất cả mọi người đều tuân thủ cùng một phong cách. Vấn đề là làm thế nào tiếp cận được Utopia đó mà không cần một bộ hướng dẫn phong cách dài dòng và cứng nhắc.
Với Go, chúng ta áp dụng một cách tiếp cận khác thường: để máy tính
xử lý hầu hết các vấn đề định dạng.
Chương trình gofmt
(cũng có thể dùng dưới dạng go fmt, hoạt động ở cấp độ gói thay vì cấp độ tệp nguồn)
đọc một chương trình Go
và xuất mã nguồn theo phong cách chuẩn về thụt lề
và căn chỉnh dọc, giữ nguyên và nếu cần thì định dạng lại các comment.
Nếu bạn muốn biết cách xử lý một tình huống bố cục mới,
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 (hoặc báo lỗi về gofmt),
đừng cố tìm cách né tránh.
Ví dụ, không cần tốn thời gian căn chỉnh các comment trên các trường của một struct.
Gofmt sẽ làm điều đó cho bạn. Với khai báo
type T struct {
name string // name of the object
value int // its value
}
gofmt sẽ căn chỉnh các cột:
type T struct {
name string // name of the object
value int // its value
}
Tất cả code Go trong các gói chuẩn đều đã được định dạng bằng gofmt.
Một số chi tiết định dạng vẫn cần lưu ý. Tóm gọn:
- Thụt lề
- Chúng ta dùng tab để thụt lề và
gofmtxuất chúng theo mặc định. Chỉ dùng khoảng trắng khi thực sự cần thiết. - Độ dài dòng
- Go không giới hạn độ dài dòng. Đừng lo lắng về việc dòng quá dài. Nếu một dòng có vẻ quá dài, hãy xuống dòng và thụt lề 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 của toán tử ngắn gọn và rõ ràng hơn, vì vậyx<<8 + y<<16
có nghĩa đúng như cách viết với khoảng trắng gợi ý, khác với các ngôn ngữ khác.
Comment
Go cung cấp comment khối kiểu C /* */
và comment dòng kiểu C++ //.
Comment dòng là chuẩn thông dụng;
comment khối xuất hiện chủ yếu là comment của gói, nhưng
cũng hữu ích trong một biểu thức hoặc để vô hiệu hóa một đoạn code lớn.
Các comment xuất hiện trước các khai báo cấp cao nhất, không có dòng trống ở giữa, được coi là tài liệu cho chính khai báo đó. Những "doc comment" này là tài liệu chính cho một gói hoặc lệnh Go. Để biết thêm về doc comment, xem "Go Doc Comments".
Tên gọi
Tên gọi cũng quan trọng trong Go như trong bất kỳ ngôn ngữ nào khác. Chúng thậm chí còn có tác động về mặt ngữ nghĩa: khả năng hiển thị của một tên bên ngoài gói được xác định bởi việc ký tự đầu tiên của tên đó có phải chữ hoa hay không. Do đó, nên dành một chút thời gian để nói về các quy ước đặt tên trong các chương trình Go.
Tên gói
Khi một gói được import, tên gói trở thành bộ truy cập cho các nội dung của nó. Sau
import "bytes"
gói đang import có thể tham chiếu đến bytes.Buffer. Sẽ rất tiện
nếu tất cả những ai sử dụng gói đều có thể dùng cùng một tên để chỉ
nội dung của nó, điều này ngụ ý rằng tên gói nên hay: ngắn gọn, súc tích, gợi nghĩa.
Theo quy ước, tên gói được viết thường, một từ duy nhấ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ì tất cả mọi người sử dụng gói của bạn sẽ phải gõ tên đó.
Và đừng lo lắng về xung đột tên a priori.
Tên gói chỉ là tên mặc định cho các lần import; nó không cần phải là duy nhất
trên tất cả mã nguồn, và trong trường hợp hiếm gặp khi xung đột, gói đang import
có thể chọn một tên khác để dùng cục bộ.
Trong mọi trường hợp, sự nhầm lẫn hiếm khi xảy ra vì tên tệp trong câu lệnh import
xác định chính xác gói nào đang được sử dụng.
Một quy ước khác là tên gói là tên cơ sở của
thư mục nguồn của nó;
gói trong src/encoding/base64
được import là "encoding/base64" nhưng có tên là base64,
không phải encoding_base64 và cũng không phải encodingBase64.
Người import một gói sẽ dùng tên đó để tham chiếu đến nội dung của nó,
vì vậy các tên được xuất trong gói có thể tận dụng điều này
để tránh lặp lại.
(Đừng dùng ký hiệu import ., có thể đơn giản hóa
các test phải chạy bên ngoài gói mà chúng đang kiểm tra, nhưng nên tránh trong các trường hợp khác.)
Ví dụ, kiểu buffered reader trong gói bufio có tên là Reader,
không phải BufReader, vì người dùng nhìn thấy nó là bufio.Reader,
một 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 truy cập kèm theo tên gói, bufio.Reader
không xung đột với io.Reader.
Tương tự, hàm tạo các instance mới của ring.Ring—đây chính là
định nghĩa của một constructor trong Go—thông thường sẽ được đặt tên là NewRing, nhưng vì
Ring là kiểu duy nhất được xuất bởi gói, và gói có tên là
ring, nên nó chỉ được gọi là New,
mà người dùng gói thấy là ring.New.
Hãy dùng cấu trúc gói để giúp bạn chọn tên hay.
Một ví dụ ngắn khác là once.Do;
once.Do(setup) đọc rất tự nhiên và sẽ không hay hơn nếu viết là
once.DoOrWaitUntilDone(setup).
Tên dài không tự động làm cho 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 quá dài.
Getter
Go không hỗ trợ tự động getter và setter.
Việc tự viết getter và setter không có gì sai,
và thường là phù hợp, nhưng việc đưa Get vào tên getter
không phải là idiom Go cũng không phải bắt buộc. Nếu bạn có trường tên là
owner (viết thường, không được xuất), phương thức getter nên được
đặt tên là Owner (viết hoa, được xuất), không phải GetOwner.
Việc dùng chữ hoa để xuất cung cấp cơ chế phân biệt
trường với phương thức.
Hàm setter, nếu cần, thường sẽ được đặt tên là SetOwner.
Cả hai tên đọc rất tự nhiên trong thực tế:
owner := obj.Owner()
if owner != user {
obj.SetOwner(user)
}
Tên interface
Theo quy ước, các interface có một phương thức được đặt tên theo
tên phương thức cộng với hậu tố -er hoặc biến đổi tương tự
để tạo thành danh từ chỉ hành động: Reader,
Writer, Formatter,
CloseNotifier v.v.
Có nhiều tên như vậy và nên tuân thủ chúng cùng với
các tên hàm mà chúng thể hiện.
Read, Write, Close, Flush,
String v.v. đều có
chữ ký và ý nghĩa chuẩn. Để tránh nhầm lẫn,
đừng đặt cho phương thức của bạn một trong những tên đó trừ khi nó
có cùng chữ ký và ý nghĩa.
Ngược lại, nếu kiểu của bạn triển khai một phương thức có
cùng ý nghĩa với một phương thức trên một kiểu nổi tiếng,
hãy đặt cho nó cùng tên và chữ ký;
gọi phương thức chuyển đổi 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
các tên gồm nhiều từ.
Dấu chấm phẩy
Giống như C, ngữ pháp hình thức của Go sử dụng dấu chấm phẩy để kết thúc câu lệnh, nhưng khác với C, những dấu chấm phẩy đó không xuất hiện trong mã nguồn. Thay vào đó, lexer dùng một quy tắc đơn giản để tự động chèn dấu chấm phẩy khi quét, vì vậy văn bản đầu vào hầu 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 định danh
(bao gồm các từ như int và float64),
một literal cơ bản như một số hoặc hằng số chuỗi, hoặc một trong các token
break continue fallthrough return ++ -- ) }
lexer luôn chèn một dấu chấm phẩy sau token đó. Điều này có thể tóm gọn là: “nếu dòng mới đế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ể bỏ qua ngay trước dấu đóng ngoặc nhọn, vì vậy một câu lệnh như
go func() { for { dst <- <-src } }()
không cần dấu chấm phẩy.
Các chương trình Go chuẩn idiom chỉ có dấu chấm phẩy ở những nơi như
mệnh đề vòng lặp for, để phân tách phần khởi tạo, điều kiện và
phần tiếp tục. Chúng cũng cần thiết để phân tách nhiều câu lệnh trên một dòng,
nếu bạn viết code theo cách đó.
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 mở ngoặc nhọn của một
cấu trúc điều khiển (if, for, switch,
hoặc select) ở dòng tiếp theo. Nếu làm vậy, một dấu chấm phẩy
sẽ được chèn trước dấu ngoặc, có thể gây ra các hiệu ứng không mong muốn. Hãy viết 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ó liên quan đến những cấu trúc của C nhưng khác biệt
theo những cách quan trọng.
Không có vòng lặp do hay while, chỉ có một
for được tổng quát hóa;
switch linh hoạt hơn;
if và switch chấp nhận một câu lệnh khởi tạo tùy chọn giống như của for;
các câu lệnh break và continue
nhận một nhãn tùy chọn để xác định cần break hoặc continue cái gì;
và có các cấu trúc điều khiển mới bao gồm type switch và
bộ ghép kênh truyền thông đa chiều, select.
Cú pháp cũng hơi khác:
không có dấu ngoặc đơn
và thân lệnh phải luôn được bao trong 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ết các câu lệnh if đơn giản
trên nhiều dòng. Đây là phong cách tốt dù sao cũng nên làm,
đặc biệt khi thân lệnh chứa một câu lệnh điều khiển như
return hoặc break.
Vì if và switch chấp nhận một câu lệnh khởi tạo,
thường thấy cách 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 tiếp tục sang câu lệnh tiếp theo—nghĩa là,
thân lệnh kết thúc bằng break, continue,
goto, hoặc return—thì else
không cần thiết sẽ được bỏ qua.
f, err := os.Open(name)
if err != nil {
return err
}
codeUsing(f)
Đây là ví dụ về một tình huống phổ biến khi code phải xử lý một
chuỗi các điều kiện lỗi. Code dễ đọc nếu luồng điều khiển thành công
chạy xuống theo trang, loại trừ 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ác câu lệnh return,
code kết quả không cần các câu lệnh else.
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: Ví dụ cuối cùng trong phần trước minh họa 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 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 đến f.Stat là:
d, err := f.Stat()
trông như thể nó khai báo d và err.
Tuy nhiên, hãy chú ý rằng err xuất hiện trong cả hai câu lệnh.
Sự lặp lại này là hợp lệ: err được khai báo bởi câu lệnh đầu tiên,
nhưng chỉ được gán lại trong câu lệnh thứ hai.
Điều này có nghĩa là lời gọi đến f.Stat sử dụng biến
err hiện có được khai báo ở trên, và chỉ đơn giản là 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
dù nó đã được khai báo trước, với điều kiện:
- khai báo này nằm trong cùng phạm vi với khai báo hiện có của
v(nếuvđã được khai báo trong phạm vi ngoài, khai báo sẽ tạo một biến mới §), - giá trị tương ứng trong quá trình 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 bởi khai báo đó.
Thuộc tính bất thường này hoàn toàn mang tính thực dụng,
giúp dễ dàng sử dụng một giá trị err duy nhất, ví dụ,
trong một chuỗi if-else dài.
Bạn sẽ thấy nó được dùng thường xuyên.
§ Đáng chú ý ở đây là trong Go, phạm vi của các tham số hàm và giá trị trả về giống như thân hàm, dù chúng xuất hiện về mặt từ vựng ở ngoài dấu ngoặc nhọn bao quanh thân hàm.
For
Vòng lặp for của Go tương tự—nhưng không giống hoàn toàn—với C.
Nó hợp nhất for
và while và không có do-while.
Có ba dạng, chỉ một trong số đó có 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ỉ mục ngay trong vòng lặp.
sum := 0
for i := 0; i < 10; i++ {
sum += i
}
Nếu bạn đang lặp qua một mảng, slice, chuỗi hoặc map,
hoặc đọc từ một channel, mệnh đề range có thể
quản lý vòng lặp.
for key, value := range oldMap {
newMap[key] = value
}
Nếu bạn chỉ cần phần tử đầu tiên trong range (key hoặc index), hãy bỏ phần tử 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 (định danh trống), một dấu gạch dưới, để loại bỏ phần tử đầu tiên:
sum := 0
for _, value := range array {
sum += value
}
Blank identifier có nhiều công dụng, như được mô tả trong một phần sau.
Với chuỗi, range làm thêm công việc cho bạn, tách ra từng
điểm code Unicode bằng cách phân tích UTF-8.
Các mã hóa lỗi tiêu thụ một byte và tạo ra
rune thay thế U+FFFD.
(Tên (cùng với kiểu builtin liên kết) rune là thuật ngữ Go cho
một điểm code 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 và ++ cùng --
là các câu lệnh chứ không phải biểu thức.
Vì vậy, nếu bạn muốn chạy nhiều biến trong một vòng lặp for,
bạn nên dùng gán song song (mặc dù đ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 cần phải là hằng số hay thậm chí số nguyên,
các case được đánh giá từ trên xuống dưới cho đến khi tìm thấy kết quả khớp,
và nếu switch không có biểu thức thì nó chuyển đổi theo
true.
Do đó, có thể—và là idiom Go—để viết một
chuỗi if-else-if-else
dưới dạng 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ó fall through tự động, nhưng các case có thể được trình bày trong danh sách phân cách bằng dấu phẩy.
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
Mặc dù không phổ biến trong Go như trong một số ngôn ngữ kiểu C khác,
câu lệnh break có thể được dùng để kết thúc sớm
một switch.
Đôi khi, tuy nhiên, cần thoát khỏi một vòng lặp bao ngoài,
chứ không phải switch, và trong Go điều đó có thể thực hiện bằng cách đặt một nhãn
lên vòng lặp và "break" đến nhãn đó.
Ví dụ này 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)
}
}
Tất nhiên, câu lệnh continue cũng nhận một nhãn tùy chọn
nhưng nó chỉ áp dụng cho các vòng lặp.
Để kết thúc phần này, đây là một thủ tục so sánh cho byte slice sử 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ể được dùng để khám phá kiểu động của một biến interface.
Một type switch như vậy sử dụng cú pháp của type assertion
với từ khóa type bên trong dấu ngoặc đơn.
Nếu switch khai báo một biến trong biểu thức, biến sẽ
có kiểu tương ứng trong mỗi mệnh đề.
Cũng là idiom Go khi tái sử dụng tên trong những trường hợp như vậy, thực chất là khai báo
một biến mới cùng tên nhưng kiểu khác nhau trong 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à các hàm và phương thức
có thể trả về nhiều giá trị. Dạng này có thể được dùng để
cải thiện một số idiom bất tiện trong các chương trình C: trả về lỗi trong băng
như -1 cho EOF
và sửa đổi một đối số được truyền theo địa chỉ.
Trong C, lỗi ghi được báo hiệu bằng một số đếm âm với
mã lỗi được ẩn đi ở một vị trí dễ bay hơi.
Trong Go, Write
có thể trả về một số đếm và một lỗi: “Đúng, bạn đã ghi 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 phương thức Write trên các tệp từ
gói os là:
func (file *File) Write(b []byte) (n int, err error)
và như tài liệu mô tả, 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 phần xử lý lỗi để có thêm ví dụ.
Cách tiếp cận tương tự giúp tránh cần phải truyền một con trỏ đến một giá trị trả về để mô phỏng một tham số tham chiếu. Đây là một hàm đơn giản để lấy một số từ một vị trí trong một byte slice, trả về số đó và vị trí tiếp theo.
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 input slice 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ề hoặc kết quả của một hàm Go có thể được đặt tên và
dùng như các biến thông thường, giống như các tham số đầu vào.
Khi được đặt tên, chúng được khởi tạo với các giá trị zero của kiểu tương ứng khi
hàm bắt đầu; nếu hàm thực thi câu lệnh return
không có đối số, các giá trị hiện tại của các tham số kết quả sẽ được
dùng làm các giá trị trả về.
Các tên không bắt buộc nhưng chúng có thể làm code ngắn gọn và rõ ràng hơn:
chúng là tài liệu.
Nếu chúng ta đặt tên cho các kết quả của nextInt, rõ ràng là int
nào được trả về 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 và gắn với một return không có tham số, chúng có thể đơn giản hóa
cũng như làm rõ. Đây là một phiên bản
của io.ReadFull sử dụng chúng tốt:
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 một lời gọi hàm (hàm
được defer) để chạy ngay trước khi hàm
thực thi defer trả về. Đây là một cách bất thường nhưng
hiệu quả để xử lý các tình huống như các tài nguyên phải được
giải phóng bất kể hàm đi theo đường nào để trả về. Các ví dụ điển hình là mở khóa một mutex hoặc đó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 đến một hàm như Close có hai ưu điểm. Thứ nhất, nó
đảm bảo rằng bạn sẽ không bao giờ quên đóng tệp, một lỗi
dễ mắc phải nếu bạn sau đó chỉnh sửa hàm để thêm một đường trả về mới.
Thứ hai, nó có nghĩa là việc đóng tệp nằm gần chỗ mở tệp,
rõ ràng 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 phương thức) được đánh giá khi defer thực thi, không phải khi lời gọi thực thi. Ngoài việc tránh lo lắng về các biến thay đổi giá trị khi hàm thực thi, điều này có nghĩa là một điểm gọi defer đơn lẻ có thể defer nhiều lần thực thi hàm. Đây là một ví dụ buồn cười.
for i := 0; i < 5; i++ {
defer fmt.Printf("%d ", i)
}
Các hàm được defer thực thi theo thứ tự LIFO, vì vậy code này sẽ khiến
4 3 2 1 0 được in ra khi hàm trả về. Một
ví dụ thực tế hơn là một cách đơn giản để theo dõi việc thực thi hàm
qua chương trình. Chúng ta có thể viết một vài routine theo dõi đơn giản như thế này:
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 thực tế là các đối số cho các hàm được defer
được đánh giá khi defer thực thi. Routine theo dõi có thể thiết lập đối số cho routine bỏ theo dõi.
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
Đối với các lập trình viên quen với quản lý tài nguyên cấp độ khối từ
các ngôn ngữ khác, defer có thể trông lạ, nhưng các ứng dụng
thú vị và mạnh mẽ nhất của nó xuất phát chính xác từ thực tế
rằng nó không dựa trên khối mà dựa trên hàm. Trong phần về
panic và recover, chúng ta sẽ thấy một
ví dụ khác về khả năng của nó.
Dữ liệu
Cấp phát bộ nhớ với new
Go có hai hàm cấp phát nguyên thủy, các hàm built-in
new và make.
Chúng làm những việc khác nhau và áp dụng cho các kiểu khác nhau, điều này có thể gây nhầm lẫn,
nhưng các quy tắc rất đơn giản.
Hãy nói về new trước.
Đây là một hàm built-in cấp phát bộ nhớ, nhưng khác với các hàm cùng tên
trong một số ngôn ngữ khác, nó không khởi tạo bộ nhớ,
nó chỉ zeroed (đặt về không) nó.
Tức là,
new(T) cấp phát bộ nhớ zeroed cho một item mới kiểu
T và trả về địa chỉ của nó, một giá trị kiểu *T.
Theo thuật ngữ Go, nó trả về một con trỏ đến giá trị zero mới được cấp phát của kiểu
T.
Vì bộ nhớ được trả về bởi new đã được zeroed, nên hữu ích khi sắp xếp
khi thiết kế cấu trúc dữ liệu của bạn rằng
giá trị zero của mỗi kiểu có thể được sử dụng mà không cần khởi tạo thêm. Điều này có nghĩa là người dùng
cấu trúc dữ liệu có thể tạo một cái bằng new và bắt đầu làm việc ngay.
Ví dụ, tài liệu cho bytes.Buffer nêu rằng
"giá trị zero của Buffer là một buffer trống sẵn sàng sử dụng."
Tương tự, sync.Mutex không
có constructor rõ ràng hoặc phương thức Init.
Thay vào đó, giá trị zero cho một sync.Mutex
được định nghĩa là một mutex chưa bị khóa.
Thuộc tính giá trị zero hữu ích này hoạt động theo kiểu bắc cầu. Xem xét 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 sử dụng ngay lập tức khi cấp phát
hoặc chỉ khai báo. Trong đoạn tiếp theo, cả p và v sẽ hoạt động
đúng mà không cần sắp xếp thêm.
p := new(SyncedBuffer) // type *SyncedBuffer var v SyncedBuffer // type SyncedBuffer
Constructor và composite literal
Đôi khi giá trị zero không đủ và cần một constructor khởi tạo,
như trong ví dụ này được lấy từ
gói 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ó nhiều code lặp lại trong đó. Chúng ta có thể đơn giản hóa nó bằng cách dùng một composite literal, là một biểu thức tạo ra một instance mới mỗi lần nó được đánh giá.
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ộ hoàn toàn ổn; bộ nhớ liên kết với biến vẫn tồn tại sau khi hàm trả về. Thực ra, lấy địa chỉ của một composite literal cấp phát một instance mới mỗi lần nó được đánh giá, vì vậy chúng ta có thể kết hợp hai dòng cuối này.
return &File{fd, name, nil, 0}
Các trường của một composite literal được trình bày theo thứ tự và phải có mặt đầy đủ.
Tuy nhiên, bằng cách ghi nhãn rõ ràng các phần tử dưới dạng cặp field:value,
các phần khởi tạo có thể xuất hiện theo bất kỳ
thứ tự nào, với những cái còn thiếu được để nguyên ở giá trị zero của chúng. Vì vậy chúng ta có thể viết
return &File{fd: fd, name: name}
Trong trường hợp giới hạn, nếu một composite literal không chứa trường nào, nó tạo ra
một giá trị zero cho kiểu đó. Các biểu thức new(File) và &File{} là tương đương.
Composite literal cũng có thể được tạo cho mảng, slice và map,
với các nhãn trường là index hoặc map key tùy theo trường hợp.
Trong các ví dụ này, các phần khởi tạo hoạt động bất kể giá trị của Enone,
Eio và Einval, 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"}
Cấp phát bộ nhớ với make
Quay lại chủ đề cấp phát bộ nhớ.
Hàm built-in make(T, args) phục vụ
một mục đích khác với new(T).
Nó chỉ tạo slice, map và channel, và trả về một giá trị
đã được khởi tạo
(không phải zeroed)
kiểu T (không phải *T).
Lý do cho sự phân biệt
là ba kiểu này đại diện, bên dưới, cho các tham chiếu đến các cấu trúc dữ liệu
phải được khởi tạo trước khi sử dụng.
Một slice, ví dụ, là một bộ mô tả ba phần tử
chứa một con trỏ đến dữ liệu (bên trong một mảng), độ dài và
dung lượng, và cho đến khi những phần tử đó được khởi tạo, slice là nil.
Đối với slice, map và channel,
make khởi tạo cấu trúc dữ liệu nội bộ và chuẩn bị
giá trị để sử dụng.
Ví dụ,
make([]int, 10, 100)
cấp phát một mảng 100 int và sau đó tạo một cấu trúc slice
với độ dài 10 và dung lượng 100 trỏ vào
10 phần tử đầu tiên của mảng.
(Khi tạo một slice, dung lượng có thể bỏ qua; xem phần về slice
để biết thêm thông tin.)
Ngược lại, new([]int) trả về một con trỏ đến một cấu trúc slice mới được cấp phát và zeroed,
tức là, một con trỏ đến một giá trị slice nil.
Các ví dụ này minh họa sự khác biệt giữa new và
make.
var p *[]int = new([]int) // cấp phát cấu trúc slice; *p == nil; hiếm khi hữu ích var v []int = make([]int, 100) // slice v giờ tham chiếu đến một mảng mới gồm 100 phần tử int // Không cần thiết, phức tạp: var p *[]int = new([]int) *p = make([]int, 100, 100) // Cách dùng thông thường: v := make([]int, 100)
Cần nhớ rằng make chỉ áp dụng cho map, slice và channel
và không trả về con trỏ.
Để lấy một con trỏ 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.
Mảng
Mảng hữu ích khi cần lên kế hoạch bố cục bộ nhớ chi tiết và đôi khi có thể giúp tránh cấp phát bộ nhớ, nhưng vai trò chính của chúng là làm nền tảng cho slice, chủ đề của phần tiếp theo. Để đặt nền tảng cho chủ đề đó, đây là một vài điều cần biết về mảng.
Có những khác biệt quan trọng giữa cách mảng hoạt động trong Go và C. Trong Go,
- Mảng là giá trị. Gán một mảng cho mảng khác sẽ sao chép toàn bộ phần tử.
- Cụ thể, nếu bạn truyền một mảng vào hàm, hàm sẽ nhận một bản sao của mảng đó, không phải con trỏ đến nó.
-
Kích thước của mảng là một phần của kiểu. Kiểu
[10]intvà[20]intlà hai kiểu khác nhau.
Tính chất giá trị này 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 suất như trong C, bạn có thể truyền con trỏ đến mảng.
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) // Lưu ý toán tử lấy địa chỉ tường minh
Nhưng ngay cả cách này cũng không phải cách dùng thông thường trong Go. Hãy dùng slice thay thế.
Slice
Slice bọc bên ngoài mảng để cung cấp một giao diện tổng quát hơn, mạnh mẽ hơn và tiện lợi hơn cho các chuỗi dữ liệu. Ngoại trừ những trường hợp cần kích thước tường minh như ma trận biến đổi, hầu hết các thao tác lập trình với mảng trong Go đều dùng slice thay vì mảng thuần túy.
Slice giữ tham chiếu đến một mảng bên dưới, và nếu bạn gán một
slice cho slice khác, cả hai đều tham chiếu đến cùng mảng đó.
Nếu một hàm nhận đối số là slice, những thay đổi mà hàm thực hiện lên
các phần tử của slice sẽ hiển thị với người gọi, tương tự như
truyền con trỏ đến mảng bên dưới. Vì vậy, hàm Read
có thể nhận đối số là slice thay vì con trỏ
và số lượng phần tử; độ dài của slice đặt ra giới hạn trên
cho lượng dữ liệu có thể đọc. Đây là chữ ký phương thức
Read của kiểu File trong package
os:
func (f *File) Read(buf []byte) (n int, err error)
Phương thức trả về số byte đã đọc và giá trị lỗi, nếu có.
Để đọc vào 32 byte đầu tiên của bộ đệm lớn hơn
buf, hãy cắt (dùng như động từ) bộ đệm đó.
n, err := f.Read(buf[0:32])
Cách cắt như vậy rất phổ biến và hiệu quả. Thực ra, tạm bỏ qua hiệu suất, đoạn mã sau đây cũng đọc 32 byte đầu tiên của bộ đệm.
var n int
var err error
for i := 0; i < 32; i++ {
nbytes, e := f.Read(buf[i:i+1]) // Đọc một byte.
n += nbytes
if nbytes == 0 || e != nil {
err = e
break
}
}
Độ dài của slice có thể thay đổi miễn là vẫn nằm trong giới hạn
của mảng bên dưới; chỉ cần gán lại slice từ chính nó. Dung lượng của slice,
truy cập được qua hàm dựng sẵn cap, cho biết độ dài tối đa mà slice có thể đạt được.
Đây là hàm để nối thêm dữ liệu vào slice. Nếu dữ liệu
vượt quá dung lượng, slice sẽ được cấp phát lại. Slice kết quả được trả về.
Hàm tận dụng thực tế là len và cap 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) { // cấp phát lại
// Cấp phát gấp đôi lượng cần thiết, để dự phòng tăng trưởng sau này.
newSlice := make([]byte, (l+len(data))*2)
// Hàm copy được khai báo sẵn và hoạt động với mọi kiểu slice.
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ì, dù Append
có thể sửa đổi các phần tử của slice, bản thân slice (cấu trúc dữ liệu thời gian chạy
chứa con trỏ, độ dài, và dung lượng) được truyền bằng giá trị.
Ý tưởng nối thêm vào slice hữu ích đến mức nó được hiện thực hóa bởi
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 một ít thông tin, vì vậy chúng ta sẽ quay lại sau.
Slice hai chiều
Mảng và slice trong Go là một chiều. Để tạo tương đương của mảng 2D hay slice 2D, cần định nghĩa mảng của mảng hoặc slice của slice, như sau:
type Transform [3][3]float64 // Mảng 3x3, thực ra là mảng của mảng. type LinesOfText [][]byte // Slice của các slice byte.
Vì slice có độ dài thay đổi được, mỗi slice con có thể có độ dài khác nhau.
Đây 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 riêng.
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 2D, tình huống có thể xảy ra khi xử lý các dòng quét điểm ảnh chẳng hạn. Có hai cách để thực hiện điều này. Một là cấp phát từng slice riêng biệt; cách kia là cấp phát một mảng duy nhất và trỏ các slice riêng lẻ vào đó. Cách nào phù hợp tùy thuộc vào ứng dụng của bạn. Nếu các slice có thể tăng hoặc giảm kích thước, chúng nên được cấp phát riêng biệt để tránh ghi đè lên dòng tiếp theo; nếu không, có thể hiệu quả hơn khi tạo đối tượng bằng một lần cấp phát duy nhất. Dưới đây là phác thảo hai phương pháp. Đầu tiên, cấp phát từng dòng:
// Cấp phát slice cấp cao nhất.
picture := make([][]uint8, YSize) // Một hàng cho mỗi đơn vị y.
// Lặp qua các hàng, cấp phát slice cho mỗi hàng.
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, cắt thành các dòng:
// Cấp phát slice cấp cao nhất, như trước.
picture := make([][]uint8, YSize) // Một hàng cho mỗi đơn vị y.
// Cấp phát một slice lớn để chứa toàn bộ điểm ảnh.
pixels := make([]uint8, XSize*YSize) // Kiểu []uint8 dù picture là [][]uint8.
// Lặp qua các hàng, cắt mỗi hàng từ đầu của phần pixels còn lại.
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
}
Map
Map là 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 (khóa) với các giá trị của kiểu khác (phần tử hay giá trị). Khóa có thể là bất kỳ kiểu nào mà toán tử bằng nhau được định nghĩa, chẳng hạn như số nguyên, số thực và số phức, chuỗi, con trỏ, interface (miễn là kiểu động hỗ trợ bằng nhau), struct và mảng. Slice không thể dùng làm khóa map, vì bằng nhau không được định nghĩa trên chúng. Giống như slice, map giữ tham chiếu đến cấu trúc dữ liệu bên dưới. Nếu bạn truyền map cho một hàm thay đổi nội dung của map, những thay đổi đó sẽ hiển thị ở phía người gọi.
Map có thể được tạo bằng cú pháp composite literal thông thường với các cặp khóa-giá trị phân tách bằng dấu hai chấm, vì vậy rất dễ khởi tạo chúng.
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,
}
Gán và lấy giá trị map về mặt cú pháp trông giống hệt thao tác tương ứng với mảng và slice, ngoại trừ chỉ số không cần phải là số nguyên.
offset := timeZone["EST"]
Khi cố lấy một giá trị map với khóa không tồn tại trong map
sẽ trả về giá trị zero của kiểu phần tử
trong map. Ví dụ, nếu map chứa số nguyên, tra cứu
một khóa không tồn tại sẽ trả về 0.
Có thể cài đặt tập hợp dưới dạng map với kiểu giá trị là bool.
Đặt phần tử map thành true để đưa giá trị vào tập hợp, rồi
kiểm tra bằng cách đánh chỉ số đơn giản.
attended := map[string]bool{
"Ann": true,
"Joe": true,
...
}
if attended[person] { // sẽ là false nếu person không có trong map
fmt.Println(person, "was at the meeting")
}
Đôi khi bạn cần phân biệt một mục còn thiếu với
giá trị zero. Liệu có mục nào cho "UTC"
hay đó là 0 vì nó không có trong map?
Bạn có thể phân biệt bằng dạng gán nhiều giá trị.
var seconds int var ok bool seconds, ok = timeZone[tz]
Vì lý do hiển nhiên, cú pháp này được gọi là thành ngữ “comma ok”.
Trong ví dụ này, nếu tz tồn tại, seconds
sẽ được gán giá trị thích hợp và ok sẽ là true; nếu không,
seconds sẽ được gán bằng zero và ok sẽ
là false.
Đây là hàm kết hợp những điều đó với thông báo lỗi rõ ràng:
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ự tồn tại trong map mà không cần quan tâm đến giá trị thực,
bạn có thể dùng định danh trống (_)
thay cho biến thông thường cho giá trị.
_, present := timeZone[tz]
Để xóa một phần tử map, dùng hàm dựng sẵn delete,
với đối số là map và khóa cần xóa.
Thao tác này an toàn ngay cả khi khóa đã không còn trong map.
delete(timeZone, "PDT") // Bây giờ chuyển sang Giờ Chuẩn
In ấn
In theo định dạng trong Go sử dụng phong cách tương tự họ printf của C
nhưng phong phú và tổng quát hơn. Các hàm này nằm trong package fmt
và có tên viết hoa: fmt.Printf, fmt.Fprintf,
fmt.Sprintf và các hàm tương tự. Các hàm string (Sprintf v.v.)
trả về một chuỗi thay vì điền vào bộ đệm được cung cấp.
Bạn không cần phải cung cấp chuỗi định dạng. Cho mỗi hàm trong số Printf,
Fprintf và Sprintf có thêm một cặp hàm khác,
ví dụ Print và Println.
Những hàm này không nhận chuỗi định dạng mà thay vào đó tạo ra định dạng mặc định
cho mỗi đối số. Phiên bản Println còn chèn một khoảng trắng
giữa các đối số và thêm ký tự xuống dòng vào đầu ra trong khi
phiên bản Print chỉ thêm khoảng trắng nếu cả hai phía của toán hạng đều không phải chuỗi.
Trong ví dụ này mỗi dòng đều tạo ra cùng kết quả đầu ra.
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 theo định dạng fmt.Fprint
và các hàm cùng họ nhận bất kỳ đối tượng nào
cài đặt interface io.Writer làm đối số đầu tiên; các biến os.Stdout
và os.Stderr là những ví dụ quen thuộc.
Đây là lúc mọi thứ bắt đầu khác so với C. Đầu tiên, các định dạng số như %d
không nhận cờ cho dấu hay kích thước; thay vào đó, các hàm in dùng
kiểu của đối số để quyết định những thuộc tính nà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 chuyển đổi mặc định, chẳng hạn số thập phân cho số nguyên, bạn có thể dùng
định dạng bắt tất cả %v (viết tắt của “value”); kết quả là đú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ả mảng, slice, struct và
map. Đây là câu lệnh in cho map múi giờ được định nghĩa ở phần trước.
fmt.Printf("%v\n", timeZone) // hoặc chỉ là fmt.Println(timeZone)
cho đầu ra:
map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]
Với map, Printf và các hàm cùng họ sắp xếp đầu ra theo thứ tự từ điển dựa trên khóa.
Khi in một struct, định dạng sửa đổi %+v chú thích
các trường của cấu trúc bằng tên của chúng, và với bất kỳ giá trị nào thì đị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}
(Lưu ý các dấu và (&).)
Định dạng chuỗi có dấu ngoặc kép đó cũng có thể dùng qua %q khi
áp dụng cho giá trị kiểu string hoặc []byte.
Định dạng thay thế %#q sẽ dùng dấu backtick thay thế nếu có thể.
(Định dạng %q cũng áp dụng cho số nguyên và rune, tạo ra
hằng số rune trong dấu ngoặc đơn.)
Ngoài ra, %x hoạt động trên chuỗi, mảng byte và slice byte cũng như
trên số nguyên, tạo ra một chuỗi thập lục phân dài, và với
một khoảng trắng trong định dạng (% x) nó đặt 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 kiểm soát định dạng mặc định cho một kiểu tùy chỉnh, tất cả những gì cần là định nghĩa
phương thức với chữ ký String() string trên kiểu đó.
Với kiểu đơn giản T của chúng ta, trông như thế này.
func (t *T) String() string {
return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)
để in theo định dạng
7/-2.35/"abc\tdef"
(Nếu bạn cần in các giá trị kiểu T cũng như con trỏ đến T,
receiver của String phải là kiểu giá trị; ví dụ này dùng con trỏ vì
đó là cách hiệu quả hơn và thông thường hơn cho kiểu struct.
Xem phần con trỏ so với receiver giá trị bên dưới để biết thêm thông tin.)
Phương thức String của chúng ta có thể gọi Sprintf vì
các hàm in hoàn toàn có thể tái nhập và có thể được bọc lại theo cách này.
Tuy nhiên, có một chi tiết quan trọng cần hiểu về cách tiếp cận này:
đừng tạo phương thức String bằng cách gọi
Sprintf theo cách sẽ đệ quy vào phương thức String
của bạn vô hạn. Điều này có thể xảy ra nếu lời gọi Sprintf
cố in receiver trực tiếp dưới dạng chuỗi, điều đó lại sẽ gọi phương thức lần nữa.
Đây là lỗi phổ biến và dễ mắc phải, như ví dụ này cho thấy.
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", m) // Lỗi: sẽ đệ quy mãi mãi.
}
Cũng dễ sửa: chuyển đổi đối số sang kiểu string cơ bản, vốn không có phương thức đó.
type MyString string
func (m MyString) String() string {
return fmt.Sprintf("MyString=%s", string(m)) // OK: lưu ý chuyển đổi kiểu.
}
Trong phần khởi tạo chúng ta sẽ thấy thêm 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 trực tiếp đối số của một hàm in sang một hàm in khác tương tự.
Chữ ký của Printf dùng kiểu ...interface{}
cho đối số cuối cùng của nó để chỉ định rằng có thể xuất hiện một số lượng tùy ý các tham số (kiểu tùy ý)
sau chuỗi định dạng.
func Printf(format string, v ...interface{}) (n int, err error) {
Bên trong hàm Printf, v hoạt động như một biến kiểu
[]interface{} nhưng nếu được truyền sang một hàm variadic khác, nó hoạt động như
danh sách đối số thông thường.
Đây là cài đặt của hàm
log.Println chúng ta đã dùng ở trên. Nó truyền đối số trực tiếp cho
fmt.Sprintln để định dạng thực sự.
// Println in ra logger chuẩn theo cách của fmt.Println.
func Println(v ...interface{}) {
std.Output(2, fmt.Sprintln(v...)) // Output nhận tham số (int, string)
}
Chúng ta viết ... sau v trong lời gọi lồng nhau tới Sprintln để báo cho
trình biên dịch xử lý v như danh sách đối số; nếu không nó chỉ truyền
v như một đối số slice duy nhất.
Còn nhiều điều về in ấn hơn những gì chúng ta đã đề cập ở đây. Xem tài liệu godoc
cho package fmt để biết chi tiết.
Nhân tiện, tham số ... có thể là một kiểu cụ thể, ví dụ ...int
cho một hàm min tìm giá trị nhỏ nhất trong danh sách số nguyên:
func Min(a ...int) int {
min := int(^uint(0) >> 1) // số nguyên lớn nhất
for _, i := range a {
if i < min {
min = i
}
}
return min
}
Append
Bây giờ chúng ta có mảnh 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ùy chỉnh của chúng ta ở trên.
Về mặt sơ đồ, nó như thế này:
func append(slice []T, elements ...T) []T
trong đó T là placeholder cho bất kỳ kiểu nào. Bạn không thể
thực sự viết một hàm trong Go mà kiểu T
được xác định bởi người gọi.
Đó là lý do append là dựng sẵn: nó cần sự hỗ trợ từ
trình biên dịch.
Những gì append làm là thêm các phần tử vào cuối
slice và trả về kết quả. Kết quả cần được trả về
vì, như với hàm Append tự viết của chúng ta, mảng bên dưới
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 giống
một chút với Printf, thu thập một số lượng đối số tùy ý.
Nhưng nếu chúng ta muốn làm những gì Append của chúng ta làm và
nối một slice vào một slice? Đơn giản: dùng ... tại vị trí
gọi hàm, như chúng ta đã làm trong lời gọi tới Output ở trên. Đoạn mã
này tạo ra đầu ra giống với đoạn trên.
x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)
Nếu không có ... đó, mã sẽ không biên dịch được vì các kiểu
sẽ sai; y không có kiểu int.
Khởi tạo
Dù nhìn bề ngoài không khác nhiều so với khởi tạo trong C hay C++, khởi tạo trong Go mạnh mẽ hơn. Có thể xây dựng các cấu trúc phức tạp trong quá trình khởi tạo và các vấn đề về thứ tự giữa các đối tượng được khởi tạo, ngay cả giữa các package khác nhau, đều được xử lý đúng cách.
Hằng số
Hằng số trong Go đúng nghĩa là hằng số.
Chúng được tạo ra tại thời điểm biên dịch, ngay 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 boolean.
Do ràng buộc thời điểm biên dịch, các biểu thức
định nghĩa chúng phải là biểu thức hằng số,
có thể tính toán được bởi trình biên dịch. Ví dụ,
1<<3 là biểu thức hằng số, trong khi
math.Sin(math.Pi/4) thì không vì
lời gọi hàm tới math.Sin cần
xảy ra tại thời gian chạy.
Trong Go, các hằng số được liệt kê tạo ra bằng bộ đếm 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 định, nên dễ dàng xây dựng các
tập hợp giá trị phức tạp.
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 phương thức như String vào bất kỳ
kiểu do người dùng định nghĩa nào giúp các giá trị tùy ý có thể tự định dạng
một cách tự động khi in.
Dù bạn thường thấy kỹ thuật này áp dụng cho struct nhất, nó cũng hữu ích cho
các kiểu vô hướng như kiểu số thực 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 là 1.00YB,
trong khi ByteSize(1e13) in ra là 9.09TB.
Việc dùng Sprintf
ở đây để cài đặt phương thức String của ByteSize là an toàn
(tránh đệ quy vô hạn) không phải vì chuyển đổi mà
vì nó gọi Sprintf với %f,
vốn không phải định dạng chuỗi: Sprintf chỉ gọi
phương thức String khi nó cần một chuỗi, còn %f
cần một giá trị số thực.
Biến
Biến có thể được khởi tạo giống như hằng số nhưng bộ khởi tạo có thể là biểu thức tổng quát được tính toán tại thời gian 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 nguồn có thể định nghĩa hàm init không tham số của riêng mình để
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" có nghĩa là thực sự cuối cùng: init được gọi sau khi tất cả
khai báo biến trong package đã được đánh giá bộ khởi tạo của chúng,
và những điều đó chỉ được đánh giá sau khi tất cả các package được import đã được
khởi tạo.
Ngoài các khởi tạo không thể biểu diễn dưới dạng khai báo,
một cách dùng phổ biến của hàm init là kiểm tra hay sửa chữa
tính đúng đắn của trạng thái chương trình trước khi thực thi thực sự bắt đầu.
func init() {
if user == "" {
log.Fatal("$USER not set")
}
if home == "" {
home = "/home/" + user
}
if gopath == "" {
gopath = home + "/go"
}
// gopath có thể bị ghi đè bởi cờ --gopath trên dòng lệnh.
flag.StringVar(&gopath, "gopath", gopath, "override default GOPATH")
}
Phương thức
Con trỏ so với Giá trị
Như chúng ta đã thấy với ByteSize,
phương thức có thể được định nghĩa cho bất kỳ kiểu có tên nào (ngoại trừ con trỏ hoặc interface);
receiver không nhất thiết phải là struct.
Trong phần thảo luận về slice ở trên, chúng ta đã viết hàm Append.
Chúng ta có thể định nghĩa nó như một phương thức trên slice thay thế. Để
làm điều này, trước tiên chúng ta khai báo một kiểu có tên để có thể gắn phương thức, và
sau đó đặt receiver cho phương thức là một giá trị của kiểu đó.
type ByteSlice []byte
func (slice ByteSlice) Append(data []byte) []byte {
// Thân hàm giống hệt hàm Append đã định nghĩa ở trên.
}
Cách này vẫn yêu cầu phương thức trả về slice đã cập nhật. Chúng ta có thể
loại bỏ sự vụng về đó bằng cách định nghĩa lại phương thức để nhận
con trỏ đến ByteSlice làm receiver, để
phương thức có thể ghi đè slice của người gọi.
func (p *ByteSlice) Append(data []byte) {
slice := *p
// Thân hàm như trên, không có câu lệnh return.
*p = slice
}
Thực ra, chúng ta có thể làm tốt hơn nữa. Nếu chúng ta sửa đổi hàm để trông
giống phương thức Write chuẩn, như thế này,
func (p *ByteSlice) Write(data []byte) (n int, err error) {
slice := *p
// Lại như trên.
*p = slice
return len(data), nil
}
thì kiểu *ByteSlice thỏa mãn interface chuẩn
io.Writer, điều này rất tiện. Ví dụ, chúng ta có thể
in vào nó.
var b ByteSlice
fmt.Fprintf(&b, "This hour has %d days\n", 7)
Chúng ta truyền địa chỉ của ByteSlice
vì chỉ có *ByteSlice thỏa mãn io.Writer.
Quy tắc về con trỏ so với giá trị cho receiver là các phương thức giá trị
có thể được gọi trên cả con trỏ và giá trị, nhưng các phương thức con trỏ chỉ có thể được
gọi trên con trỏ.
Quy tắc này xuất phát từ việc các phương thức con trỏ có thể sửa đổi receiver; gọi
chúng trên một giá trị sẽ khiến phương thức nhận một bản sao của giá trị, vì vậy
mọi sửa đổi sẽ bị loại bỏ.
Do đó ngôn ngữ không cho phép lỗi này.
Tuy nhiên có một ngoại lệ tiện lợi. Khi giá trị có thể lấy địa chỉ, ngôn ngữ
sẽ xử lý trường hợp phổ biến là gọi phương thức con trỏ trên một giá trị
bằng cách tự động chèn toán tử địa chỉ.
Trong ví dụ của chúng ta, biến b có thể lấy địa chỉ, vì vậy chúng ta có thể gọi
phương thức Write của nó chỉ với b.Write. Trình biên dịch
sẽ viết lại thành (&b).Write cho chúng ta.
Nhân tiện, ý tưởng dùng Write trên một slice byte
là trọng tâm của cài đặt bytes.Buffer.
Interface và các kiểu khác
Interface
Interface trong Go cung cấp cách để chỉ định hành vi của một
đối tượng: nếu điều gì đó có thể làm này, thì nó có thể được dùng
ở đây. Chúng ta đã thấy một vài ví dụ đơn giản;
bộ in tùy chỉnh có thể được cài đặt bằng phương thức String
trong khi Fprintf có thể tạo đầu ra cho bất kỳ thứ gì
có phương thức Write.
Interface với chỉ một hoặc hai phương thức rất phổ biến trong mã Go, và thường
được đặt tên dẫn xuất từ phương thức, chẳng hạn io.Writer
cho thứ cài đặt Write.
Một kiểu có thể cài đặt nhiều interface.
Ví dụ, một collection có thể được sắp xếp
bởi các hàm trong package sort nếu nó cài đặt
sort.Interface, bao gồm Len(),
Less(i, j int) bool, và Swap(i, j int),
và nó cũng có thể có bộ định dạng tùy chỉnh.
Trong ví dụ giả tạo này Sequence thỏa 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
Phương thức String của Sequence đang làm lại
công việc mà Sprint đã làm cho slice.
(Nó cũng có độ phức tạp O(N²), vốn kém hiệu quả.) Chúng ta có thể chia sẻ
công sức (và tăng tốc nó) nếu chúng ta chuyển đổi Sequence thành một
[]int đơn giản trước khi gọi Sprint.
func (s Sequence) String() string {
s = s.Copy()
sort.Sort(s)
return fmt.Sprint([]int(s))
}
Phương thức này là ví dụ khác về kỹ thuật chuyển đổi để gọi
Sprintf một cách an toàn từ phương thức String.
Vì hai kiểu (Sequence và []int)
giống nhau nếu bỏ qua tên kiểu, nên hợp lệ khi chuyển đổi giữa chúng.
Chuyển đổi không tạo ra giá trị mới, nó chỉ tạm thời hoạt động
như thể giá trị hiện có có một kiểu mới.
(Có các chuyển đổi hợp lệ khác, chẳng hạn từ số nguyên sang số thực, vốn
tạo ra một giá trị mới.)
Trong các chương trình Go, chuyển đổi kiểu của một biểu thức để truy cập một
tập hợp phương thức khác là một thành ngữ phổ biến. Ví dụ, chúng ta có thể dùng kiểu
có sẵn sort.IntSlice để rút gọn toàn bộ ví dụ
thành đây:
type Sequence []int
// Phương thức để in - sắp xếp các phần tử trước khi in
func (s Sequence) String() string {
s = s.Copy()
sort.IntSlice(s).Sort()
return fmt.Sprint([]int(s))
}
Bây giờ, thay vì có Sequence cài đặt nhiều
interface (sắp xếp và in), chúng ta đang dùng khả năng một mục dữ liệu có thể được
chuyển đổi sang nhiều kiểu (Sequence, sort.IntSlice
và []int), mỗi kiểu thực hiện một phần công việc.
Cách này ít thông dụng hơn trong thực tế nhưng có thể hiệu quả.
Chuyển đổi interface và type assertion
Type switch là một dạng chuyển đổi: chúng nhận một interface và, với
mỗi trường hợp trong switch, về mặt nào đó chuyển đổi nó sang kiểu của trường hợp đó.
Đây là phiên bản rút gọn về cách mã bên dưới fmt.Printf biến một giá trị thành
chuỗi bằng type switch.
Nếu nó đã là chuỗi, chúng ta muốn giá trị chuỗi thực tế được giữ bởi interface, còn nếu nó có
phương thức String chúng ta muốn kết quả của việc gọi phương thức đó.
type Stringer interface {
String() string
}
var value interface{} // Giá trị được cung cấp bởi người gọi.
switch str := value.(type) {
case string:
return str
case Stringer:
return str.String()
}
Trường hợp đầu tiên tìm ra một giá trị cụ thể; trường hợp thứ hai chuyển đổi interface thành một interface khác. Hoàn toàn ổn khi kết hợp các kiểu theo cách này.
Nếu chỉ có một kiểu mà chúng ta quan tâm thì sao? Nếu chúng ta biết giá trị giữ một string
và chúng ta chỉ muốn lấy nó ra?
Một type switch với một trường hợp sẽ làm được, nhưng type assertion cũng vậy.
Type assertion lấy một giá trị interface và trích xuất từ đó một giá trị của kiểu tường minh được 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 vì từ khóa type:
value.(typeName)
và kết quả là một giá trị mới với kiểu tĩnh là typeName.
Kiểu đó phải là kiểu cụ thể được giữ bởi interface, hoặc một kiểu interface thứ hai
mà giá trị có thể được chuyển đổi sang.
Để trích xuất chuỗi chúng ta biết là có trong giá trị, chúng ta có thể viết:
str := value.(string)
Nhưng nếu hóa ra giá trị không chứa chuỗi, chương trình sẽ bị sập với lỗi thời gian chạy. Để phòng tránh điều đó, dùng thành ngữ "comma, ok" để kiểm tra, an toàn, xem giá trị có phải là chuỗi 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à có kiểu string, nhưng nó sẽ có
giá trị zero, tức là chuỗi rỗng.
Để minh họa khả năng này, đây là câu lệnh if-else
tương đương với type switch ở đầu phần 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 chỉ tồn tại để cài đặt một interface và sẽ không bao giờ có phương thức được xuất khẩu ngoài interface đó, không cần xuất khẩu bản thân kiểu đó. Chỉ xuất khẩu interface làm rõ giá trị không có hành vi thú vị nào ngoài những gì được mô tả trong interface. Nó cũng tránh sự cần thiết phải lặp lại tài liệu trên mọi trường hợp của phương thức 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 cài đặt.
Ví dụ, trong các thư viện hash
cả crc32.NewIEEE và adler32.New
đều trả về kiểu interface hash.Hash32.
Việc thay thế thuật toán CRC-32 bằng Adler-32 trong một chương trình Go
chỉ yêu cầu thay đổi lời gọi constructor;
phần còn lại của mã không bị ảnh hưởng bởi sự thay đổi thuật toán.
Cách tiếp cận tương tự cho phép các thuật toán mật mã luồng
trong các package crypto khác nhau được
tách rời khỏi các mật mã khối mà chúng kết hợp.
Interface Block
trong package crypto/cipher chỉ định
hành vi của một mật mã khối, cung cấp mã hóa
của một khối dữ liệu duy nhất.
Sau đó, theo tương tự với package bufio,
các package mật mã cài đặt interface này
có thể được dùng để xây dựng mật mã luồng, được biểu diễn
bởi interface Stream, mà không cần
biết chi tiết về mã hóa khối.
Các interface crypto/cipher trông như thế này:
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 luồng chế độ đếm (CTR), biến đổi một mật mã khối thành mật mã luồng; lưu ý rằng các chi tiết của mật mã khối được trừu tượng hóa:
// NewCTR trả về một Stream mã hóa/giải mã dùng Block đã cho theo // chế độ đếm. Độ dài của iv phải bằng kích thước khối của Block. func NewCTR(block Block, iv []byte) Stream
NewCTR không chỉ áp dụng
cho một thuật toán mã hóa và nguồn dữ liệu cụ thể mà cho bất kỳ
cài đặt nào của interface Block và bất kỳ
Stream nào. Vì chúng trả về
giá trị interface, việc thay thế mã hóa CTR
bằng các chế độ mã hóa khác là một thay đổi cục bộ. Các lời gọi
constructor phải được chỉnh sửa, nhưng vì mã xung quanh phải xử lý kết quả chỉ
như một Stream, nó sẽ không nhận ra sự khác biệt.
Interface và phương thức
Vì hầu hết mọi thứ đều có thể có phương thức gắn vào, hầu hết mọi thứ đều có thể
thỏa mãn một interface. Một ví dụ minh họa là trong package http,
định nghĩa interface Handler. Bất kỳ đối tượng nào
cài đặt Handler đều có thể phục vụ các yêu cầu HTTP.
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
ResponseWriter là interface cung cấp quyền truy cập
vào các phương thức cần thiết để trả về phản hồi cho client.
Các phương thức đó bao gồm phương thức Write chuẩn, vì vậy một
http.ResponseWriter có thể được dùng ở bất kỳ đâu mà io.Writer
có thể được dùng.
Request là struct chứa biểu diễn đã được phân tích
của yêu cầu từ client.
Để ngắn gọn, hãy bỏ qua POST và giả định các yêu cầu HTTP luôn là GET; sự đơn giản hóa đó không ảnh hưởng đến cách thiết lập các handler. Đây là cài đặt tầm thường của một handler để đếm số lần trang được truy cập.
// Máy chủ đếm đơn giản.
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)
}
(Theo chủ đề của chúng ta, hãy lưu ý cách Fprintf có thể in vào
http.ResponseWriter.)
Trong một máy chủ thực tế, việc truy cập vào ctr.n sẽ cần bảo vệ khỏi
truy cập đồng thời.
Xem các package sync và atomic để tham khảo.
Để tham khảo, đây là cách gắn một máy chủ như vậy vào một node trên cây URL.
import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)
Nhưng tại sao lại làm Counter là struct? Một số nguyên là tất cả những gì cần.
(Receiver cần là con trỏ để phép tăng hiển thị với người gọi.)
// Máy chủ đếm đơn giản hơn.
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ó một số trạng thái nội bộ cần được thông báo khi một trang được truy cập thì sao? Gắn một channel vào trang web.
// Channel gửi thông báo mỗi lần truy cập.
// (Có thể muốn channel được đệm.)
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 trên /args các đối số
được dùng khi gọi binary của máy chủ.
Dễ dàng viết một hàm để in các đối số.
func ArgServer() {
fmt.Println(os.Args)
}
Làm thế nào để biến đó thành một máy chủ HTTP? Chúng ta có thể làm ArgServer
thành phương thức của một kiểu nào đó mà giá trị của nó bị bỏ qua, nhưng có cách sạch hơn.
Vì chúng ta có thể định nghĩa phương thức cho bất kỳ kiểu nào ngoại trừ con trỏ và interface,
chúng ta có thể viết phương thức cho một hàm.
Package http chứa mã này:
// Kiểu HandlerFunc là một adapter để cho phép dùng
// các hàm thông thường làm HTTP handler. Nếu f là một hàm
// với chữ ký phù hợp, HandlerFunc(f) là một đối tượng
// Handler gọi f.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP gọi f(w, req).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}
HandlerFunc là kiểu có phương thức, ServeHTTP,
vì vậy các giá trị của kiểu đó có thể phục vụ các yêu cầu HTTP. Hãy nhìn vào cài đặt
của phương thức: receiver là một hàm, f, và phương thức
gọi f. Điều đó có thể có vẻ kỳ lạ nhưng không khác lắm so với, chẳng hạn,
receiver là channel và phương thức gửi trên channel.
Để biến ArgServer thành máy chủ HTTP, trước tiên chúng ta sửa đổi nó
để có chữ ký phù hợp.
// Máy chủ đối số.
func ArgServer(w http.ResponseWriter, req *http.Request) {
fmt.Fprintln(w, os.Args)
}
ArgServer giờ có cùng chữ ký như HandlerFunc,
vì vậy nó có thể được chuyển đổi sang kiểu đó để truy cập các phương thức của nó,
giống như chúng ta đã chuyển đổi Sequence thành IntSlice
để truy cập IntSlice.Sort.
Mã để thiết lập nó thật ngắn gọn:
http.Handle("/args", http.HandlerFunc(ArgServer))
Khi ai đó truy cập trang /args,
handler được cài đặt tại trang đó có giá trị ArgServer
và kiểu HandlerFunc.
Máy chủ HTTP sẽ gọi phương thức ServeHTTP
của kiểu đó, với ArgServer làm receiver, điều đó lần lượt sẽ gọi
ArgServer (qua lời gọi f(w, req)
bên trong HandlerFunc.ServeHTTP).
Các đối số sau đó sẽ được hiển thị.
Trong phần này chúng ta đã tạo một máy chủ HTTP từ một struct, một số nguyên, một channel và một hàm, tất cả vì interface chỉ là tập hợp các phương thức, có thể được định nghĩa cho (hầu hết) bất kỳ kiểu nào.
Định danh rỗng
Chúng ta đã đề cập đến định danh rỗng vài lần, trong ngữ cảnh của
vòng lặp for range
và maps.
Định danh rỗng 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,
và giá trị đó sẽ bị bỏ đi một cách vô hại.
Nó giống như việc ghi vào tệp /dev/null trên Unix:
nó đại diện cho một giá trị chỉ ghi được dùng làm chỗ giữ chỗ
khi cần một biến nhưng giá trị thực sự 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.
Định danh rỗng trong phép gán nhiều giá trị
Việc dùng định danh rỗng trong vòng lặp for range là
trường hợp đặc biệt của một tình huống tổng quát hơn: phép gán nhiều giá trị.
Nếu một phép gán cần nhiều giá trị ở phía bên trái, nhưng một trong số các giá trị đó sẽ không được dùng đến, thì việc đặt định danh rỗng ở phía bên trái của phép gán giúp tránh phải tạo một biến tạm và làm rõ rằng giá trị đó sẽ bị bỏ đi. Chẳng hạn, khi gọi một hàm trả về một giá trị và một lỗi, nhưng chỉ cần quan tâm đến lỗi, hãy dùng định danh rỗng để bỏ qua giá trị không cần thiết.
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 code bỏ qua giá trị lỗi để không xử lý lỗi; đây là cách làm rất tệ. Luôn kiểm tra giá trị lỗi trả về; chúng được cung cấp vì một lý do nhất định.
// Tệ! Code này sẽ crash nếu path không tồn tại.
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 sử dụng là lỗi biên dịch. Các import không dùng làm phình to chương trình và làm chậm quá trình biên dịch, trong khi một biến được khởi tạo nhưng không dùng đến ít nhất là lãng phí tính toán và có thể là dấu hiệu của một lỗi nghiêm trọng hơn. Tuy nhiên, khi chương trình đang trong quá trình 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à việc phải xóa chúng đi chỉ để chương trình biên dịch được, rồi lại cần đến chúng sau này, thực sự rất phiền. Định danh rỗng cung cấp một cách giải quyết vấn đề này.
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ẽ tiện nếu có thể kiểm tra xem
code cho đến nay có đúng 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.
}
Để tắt các cảnh báo về import không dùng đến, hãy dùng định danh rỗng
để tham chiếu một ký hiệu từ package được import.
Tương tự, gán biến không dùng đến fd vào định danh rỗng
sẽ tắt lỗi biến không dùng đến.
Phiên bản này của chương trình sẽ 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 để 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ư một lời nhắc nhở cần dọn dẹp sau này.
Import vì tác dụng phụ
Một import không dùng đến như fmt hay io trong
ví dụ trước cuối cùng phải được dùng đến hoặc xóa đi:
các phép gán rỗng là dấu hiệu cho thấy code đang trong quá trình hoàn thiện.
Nhưng đôi khi việc import một package chỉ để lấy tác dụng phụ của nó,
mà không có bất kỳ sử dụng tường minh nào, lại có ích.
Ví dụ, trong hàm init của nó,
package net/http/pprof
đăng ký các HTTP handler cung cấp thông tin gỡ lỗi.
Package này có API được xuất, nhưng
hầu hết các client chỉ cần việc đăng ký handler và
truy cập dữ liệu qua một trang web.
Để import package chỉ vì tác dụng phụ, hãy đổi tên package thành định danh rỗng:
import _ "net/http/pprof"
Cách import này làm rõ rằng package được import chỉ vì tác dụng phụ, vì không còn cách sử dụng nào khác cho package đó: trong tệp này, nó không có tên. (Nếu có, và chúng ta không dùng cái tên đó, trình biên dịch sẽ từ chối chương trình.)
Kiểm tra interface
Như đã thấy trong phần thảo luận về interfaces ở trên,
một kiểu không cần khai báo tường minh rằng nó implements một interface.
Thay vào đó, kiểu implement interface chỉ bằng cách implement các method của interface đó.
Trong thực tế, hầu hết các chuyển đổi interface là tĩnh và do đó được kiểm tra lúc biên dịch.
Ví dụ, truyền một *os.File vào một hàm
yêu cầu io.Reader sẽ không biên dịch được trừ khi
*os.File implements interface io.Reader.
Tuy nhiên, một số kiểm tra interface xảy ra lúc chạy chương trình.
Một trường hợp là trong package encoding/json,
package này định nghĩa interface Marshaler.
Khi bộ mã hóa JSON nhận được một giá trị implements interface đó,
bộ mã hóa sẽ gọi method marshaling của giá trị đó để chuyển nó thành JSON
thay vì thực hiện chuyển đổi chuẩn.
Bộ mã hóa kiểm tra thuộc tính này lúc chạy với một type assertion như:
m, ok := val.(json.Marshaler)
Nếu chỉ cần hỏi liệu một kiểu có implement một interface hay không, mà không thực sự dùng đến interface đó, chẳng hạn như là một phần của kiểm tra lỗi, hãy dùng định danh rỗng để bỏ qua giá trị type-asserted:
if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}
Một tình huống như vậy phát sinh khi cần đảm bảo trong package implement kiểu đó rằng
nó thực sự thỏa mãn interface.
Nếu một kiểu, chẳng hạn
json.RawMessage, cần
một biểu diễn JSON tùy chỉnh, nó phải implement
json.Marshaler, nhưng không có chuyển đổi tĩnh nào khiến
trình biên dịch tự động xác minh điều này.
Nếu kiểu đó vô tình không thỏa mãn interface, bộ mã hóa JSON vẫn hoạt động
nhưng sẽ không dùng implement tùy chỉnh.
Để đảm bảo rằng implement là đúng,
có thể dùng một khai báo toàn cục với định danh rỗng trong package:
var _ json.Marshaler = (*RawMessage)(nil)
Trong khai báo này, phép gán có chuyển đổi từ
*RawMessage sang Marshaler
yêu cầu *RawMessage phải implement Marshaler,
và thuộc tính đó sẽ được kiểm tra lúc biên dịch.
Nếu interface json.Marshaler thay đổi, package này
sẽ không còn biên dịch được nữa và chúng ta sẽ biết rằng nó cần được cập nhật.
Sự xuất hiện của định danh rỗng trong cấu trúc này cho thấy khai báo chỉ tồn tại để kiểm tra kiểu, chứ không phải để tạo ra một biến. Tuy nhiên, đừng làm điều này cho mọi kiểu thỏa mãn một interface. Theo quy ước, các khai báo như vậy chỉ được dùng khi không có chuyển đổi tĩnh nào đã tồn tại trong code, đây là trường hợp hiếm gặp.
Nhúng kiểu (Embedding)
Go không cung cấp khái niệm subclassing theo kiểu hướng kiểu truyền thống, nhưng nó có khả năng “mượn” các phần của một implement bằng cách nhúng các kiểu vào trong một struct hoặc interface.
Nhúng interface rất đơn giản.
Chúng ta đã đề cập đến interface io.Reader và io.Writer;
đâ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 xuất một số interface khác
chỉ định các đối tượng có thể implement nhiều method như vậy.
Ví dụ, có io.ReadWriter, một interface
chứa cả Read và Write.
Chúng ta có thể chỉ định io.ReadWriter bằng cách liệt kê
hai method tường minh, nhưng dễ hơn và gợi hình hơn là
nhúng hai interface để tạo thành cái mới, như sau:
// ReadWriter is the interface that combines the Reader and Writer interfaces.
type ReadWriter interface {
Reader
Writer
}
Điều này nói lên đúng những gì nó trông như vậy: một ReadWriter có thể làm
những gì Reader làm và những gì Writer
làm; nó là hợp nhất của các interface được nhúng.
Chỉ có interface mới có thể được nhúng vào trong interface.
Ý tưởng cơ bản tương tự áp dụng cho struct, nhưng với những tác động sâu rộng hơn.
Package bufio có hai kiểu struct,
bufio.Reader và bufio.Writer, mỗi cái
tất nhiên implement các interface tương ứng từ package
io.
Và bufio cũng implement một buffered reader/writer,
thực hiện bằng cách kết hợp một reader và một writer vào một struct
dùng embedding: nó liệt kê các kiểu 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 nhúng là con trỏ đến struct và tất nhiên
phải được khởi tạo để trỏ đến các struct hợp lệ trước khi
có thể dùng được.
Struct ReadWriter có thể viết là
type ReadWriter struct {
reader *Reader
writer *Writer
}
nhưng khi đó để thăng cấp các method của các trường và
thỏa mãn các interface io, chúng ta cũng cần
cung cấp các forwarding method, như sau:
func (rw *ReadWriter) Read(p []byte) (n int, err error) {
return rw.reader.Read(p)
}
Bằng cách nhúng trực tiếp các struct, chúng ta tránh được sự phiền phức này.
Các method của các kiểu được nhúng đ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 thỏa mãn cả ba interface:
io.Reader,
io.Writer, và
io.ReadWriter.
Có một điểm quan trọng phân biệt embedding với subclassing. Khi chúng ta nhúng một kiểu,
các method của kiểu đó trở thành method của kiểu ngoài,
nhưng khi chúng được gọi, receiver của method là kiểu trong, không phải kiểu ngoài.
Trong ví dụ của chúng ta, khi method Read của một bufio.ReadWriter được
gọi, nó có đúng tác dụng như forwarding method đã viết ở trên;
receiver là trường reader của ReadWriter, không phải
bản thân ReadWriter.
Embedding cũng có thể là một tiện lợi đơn giản. Ví dụ này cho thấy một trường được nhúng 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 bây giờ có các method Print, Printf, Println
và các method khác của *log.Logger. Chúng ta có thể đặt tên trường cho Logger,
tất nhiên, nhưng không cần thiết phải làm vậy. Và bây giờ, sau khi
khởi tạo, chúng ta 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 ta có thể khởi tạo nó theo cách thông thường bên trong constructor của Job, như sau:
func NewJob(command string, logger *log.Logger) *Job {
return &Job{command, logger}
}
hoặc với composite literal,
job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}
Nếu cần tham chiếu trực tiếp đến một trường được nhúng, tên kiểu của trường đó,
bỏ qua phần định danh package, dùng làm tên trường, giống như đã làm
trong method Read của struct ReadWriter.
Ở đây, nếu cần truy cập
*log.Logger của biến Job tên job,
chúng ta sẽ viết job.Logger,
điều này hữu ích nếu 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...))
}
Nhúng kiểu đưa ra vấn đề xung đột tên nhưng các quy tắc giải quyết
chúng rất đơ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 ở 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 Command, thì trường Command
của Job sẽ chiếm ưu thế.
Thứ hai, nếu cùng tên xuất hiện ở cùng mức lồng nhau, thường là lỗi;
sẽ là lỗi nếu nhúng log.Logger khi struct Job
đã chứa một trường hoặc method tên Logger.
Tuy nhiên, nếu tên trùng lặp không bao giờ được đề cập trong chương trình ngoài định nghĩa kiểu, thì không sao.
Điều kiện này cung cấp một số bảo vệ trước những thay đổi được thực hiện với các kiểu nhúng 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 subtype khác nếu cả hai trường đó
không bao giờ được dùng đến.
Concurrency (Lập trình đồng thời)
Chia sẻ bằng cách giao tiếp
Lập trình đồng thời là một chủ đề rộng lớn và ở đây chỉ có chỗ cho một số điểm nổi bật đặc trưng 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 tinh tế cần thiết để implement việc truy cập đúng đắn vào các biến chia sẻ. Go khuyến khích một cách tiếp cận khác trong đó các giá trị chia sẻ được truyền đi trên các channel và thực ra không bao giờ được chia sẻ tích cực bởi các luồng thực thi riêng biệt. Chỉ một goroutine có quyền truy cập vào giá trị tại bất kỳ thời điểm nào. Data race không thể xảy ra, theo thiết kế. Để khuyến khích cách suy nghĩ này, chúng ta đã rút gọn nó thành một khẩu hiệu:
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. Reference count có thể được thực hiện tốt nhất bằng cách đặt một mutex xung quanh một biến integer, chẳng hạn. Nhưng như một cách tiếp cận cấp cao, dùng channel để kiểm soát quyền truy cập giúp viết các chương trình rõ ràng, đúng đắn hơn.
Một cách suy nghĩ về mô hình này là hãy coi một chương trình single-threaded điển hình chạy trên một CPU. Nó không cần các primitive đồng bộ hóa. Bây giờ chạy một instance khác; nó cũng không cần đồng bộ hóa. Bây giờ hãy để hai cái đó giao tiếp; nếu giao tiếp là bộ đồng bộ hóa, thì vẫn không cần đồng bộ hóa nào khác. Unix pipeline, ví dụ, phù hợp hoàn hảo với mô hình này. Mặc dù cách tiếp cận concurrency 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 tổng quát hóa type-safe của Unix pipe.
Goroutine
Chúng được gọi là goroutine vì các thuật ngữ hiện có như thread, coroutine, process và các thuật ngữ tương tự mang những nghĩa ngụ ý không chính xác. Một goroutine có một mô hình đơn giản: đó là một hàm thực thi đồng thời với các goroutine khác trong cùng không gian địa chỉ. Nó nhẹ, chỉ tốn kém hơn một chút so với việc cấp phát stack space. Và các stack bắt đầu nhỏ, nên chúng rẻ, và tăng lên bằng cách cấp phát (và giải phóng) heap storage khi cần.
Goroutine được ghép kênh (multiplexed) lên nhiều OS thread, nên nếu một cái bị chặn, chẳng hạn như khi đang chờ I/O, những cái khác vẫn tiếp tục chạy. Thiết kế của chúng ẩn đi nhiều sự phức tạp của việc tạo và quản lý thread.
Thêm tiền tố từ khóa go vào 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 thành, goroutine
thoát ra một cách lặng lẽ. (Hiệu ứng tương tự như ký hiệu
& trong Unix shell để chạy một lệnh ở
nền.)
go list.Sort() // run list.Sort concurrently; don't wait for it.
Một function literal có thể tiện dụng trong lời 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: implement đảm bảo rằng các biến được tham chiếu bởi hàm tồn tại miễn là chúng đang được dùng.
Những ví dụ này không thực tế lắm vì các hàm không có cách nào báo hiệu khi hoàn thành. Để 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ị trả về hoạt động như một tham chiếu đến cấu trúc dữ liệu bên dưới.
Nếu có một tham số integer tùy chọn, nó đặt kích thước buffer cho channel.
Mặc định là không (zero), cho channel unbuffered hoặc đồ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
Channel unbuffered kết hợp giao tiếp, tức là trao đổi một giá trị, với đồng bộ hóa, đảm bảo 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 khi dùng channel. Đây là một để bắt đầu. Trong phần trước, chúng ta đã khởi chạy một sort ở nền. Một channel có thể cho phép goroutine khởi chạy chờ đợi sort hoàn thành.
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.
Receiver luôn chặn cho đến khi có dữ liệu để nhận. Nếu channel là unbuffered, sender chặn cho đến khi receiver đã nhận được giá trị. Nếu channel có buffer, sender chỉ chặn cho đến khi giá trị đã được sao chép vào buffer; nếu buffer đầy, điều này có nghĩa là phải chờ cho đến khi một receiver nào đó lấy đi 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 truyền vào
handle, cái 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 consumer tiếp theo.
Dung lượng của channel buffer giới hạn số lượng
lời gọi đồng thời đến 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.
}
}
Khi MaxOutstanding handler đang thực thi process,
bất kỳ handler nào khác sẽ chặn khi cố gửi vào channel buffer đầy,
cho đến khi một trong các handler hiện có hoàn thành và nhận từ buffer.
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, mặc dù chỉ có MaxOutstanding
trong số đó có thể chạy tại bất kỳ thời điểm nào.
Do đó, chương trình có thể tiêu thụ tài nguyên không giới hạn nếu request đến quá nhanh.
Chúng ta có thể khắc phục thiếu sót đó bằng cách thay đổi 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 trong các phiên bản Go trước 1.22, code này có bug: biến vòng lặp được chia sẻ qua 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 động một số lượng cố định
goroutine handle tất cả đều đọc từ channel request.
Số lượng goroutine giới hạn số lượng lời gọi đồng thời
đến process.
Hàm Serve này cũng chấp nhận một channel mà qua đó
nó sẽ được báo để thoát; sau khi khởi chạy các goroutine, nó chặn
nhận từ channel đó.
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 thuộc tính quan trọng nhất của Go là một channel là một giá trị first-class có thể được cấp phát và truyền đi giống như bất kỳ giá trị nào khác. Một cách dùng phổ biến thuộc tính này là để implement demultiplexing an toàn, song song.
Trong ví dụ ở phần trước, handle là
một handler lý tưởng hóa cho một request nhưng chúng ta không đị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 dẫn riêng cho câu trả lời.
Đây là định nghĩa sơ đồ 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 và 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 code này là khung cho một hệ thống RPC song song, không chặn, giới hạn tốc độ, và không có một mutex nào trong đó.
Song song hóa (Parallelization)
Một ứng dụng khác của những ý tưởng này là song song hóa 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 các phần riêng biệt có thể thực thi độc lập, nó có thể được song song hóa, với một channel để báo hiệu khi mỗi phần hoàn thành.
Giả sử chúng ta có một phép toán 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 phép toán trên mỗi phần tử là độc lập, như trong ví dụ lý tưởng hóa 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 ta khởi chạy các phần độc lập trong một vòng lặp, mỗi phần trên một CPU. Chúng có thể hoàn thành theo bất kỳ thứ tự nào nhưng điều đó không quan trọng; chúng ta chỉ đếm các tín hiệu hoàn thành bằng cách drain 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 ta có thể hỏi runtime xem
giá trị nào là phù hợp.
Hàm runtime.NumCPU
trả về số lõi CPU phần cứng trong máy, vì vậy chúng ta có thể viết
var numCPU = runtime.NumCPU()
Ngoài ra còn có 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 theo giá trị của runtime.NumCPU nhưng có thể
được 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
zero chỉ truy vấn giá trị.
Vì vậy, nếu muốn tôn trọng yêu cầu tài nguyên của người dùng, chúng ta nên viết
var numCPU = runtime.GOMAXPROCS(0)
Hãy chắc chắn không nhầm lẫn giữa các khái niệm concurrency (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à parallelism (thực thi các phép tính song song để tăng hiệu quả trên nhiều CPU). Mặc dù các tính năng concurrency của Go có thể làm cho một số vấn đề dễ dàng cấu trúc như các phép tính song song, Go là một ngôn ngữ concurrent, không phải parallel, và không phải tất cả vấn đề song song hóa đều phù hợp với mô hình của Go. Để thảo luận về sự khác biệt này, hãy xem bài nói chuyện được trích dẫn trong bài blog này.
Leaky buffer
Các công cụ của lập trình đồng thời thậm chí có thể làm cho những
ý tưởng không đồng thời dễ diễn đạt hơn. Đây là một ví dụ được trừu tượng hóa từ một
package RPC. Goroutine client lặp nhận dữ liệu từ một nguồn nào đó,
có thể là mạng. Để tránh cấp phát và giải phóng buffer, nó giữ
một danh sách rảnh (free list), và dùng một buffered channel để biểu diễn nó. Nếu
channel trống, một buffer mới được cấp phát.
Khi message buffer sẵn sàng, nó được gửi đến 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 mỗi message từ client, xử lý nó, và trả buffer về danh sách rảnh.
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ó, nó cấp phát một cái mới.
Việc server gửi vào freeList đặt b trở lại
vào danh sách rảnh trừ khi danh sách đầy, trong trường hợp đó
buffer bị bỏ đi để bộ gom rác thu hồi.
(Mệnh đề default trong các câu lệnh select
thực thi khi không có trường hợp nào khác sẵn sàng,
nghĩa là các select không bao giờ chặn.)
Implement này xây dựng một free list dạng leaky bucket
chỉ trong vài dòng, dựa vào buffered channel và
bộ gom rác để quản lý bookkeeping.
Lỗi (Errors)
Các routine thư viện thường phải trả về một số loại thông báo lỗi cho
caller.
Như đã đề cập trước đó, khả năng trả về nhiều giá trị của Go giúp
dễ dàng trả về một mô tả lỗi chi tiết cùng với giá trị trả về thông thường.
Đây là phong cách tốt khi dùng tính năng này để cung cấp thông tin lỗi chi tiết.
Ví dụ, như chúng ta sẽ thấy, os.Open không
chỉ trả về một con trỏ nil khi thất bại, mà còn trả về một
giá trị lỗi mô tả điều gì đã xảy ra.
Theo quy ước, lỗi có kiểu error,
một interface built-in đơn giản.
type error interface {
Error() string
}
Người viết thư viện tự do implement interface này với một
mô hình phong phú hơn bên dưới, giúp không chỉ có thể
thấy lỗi mà còn cung cấp một số ngữ cảnh.
Như đã đề cập, cùng với giá trị trả về thông thường *os.File,
os.Open cũng trả về một
giá trị lỗi.
Nếu tệp được mở thành công, lỗi 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()
}
Error của PathError tạo 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ó kích hoạt, rất hữu ích ngay cả khi được in ở xa nơi gây ra lỗi; nó có nhiều thông tin hơn nhiều so với thông báo đơn giản "no such file or directory".
Khi có thể, chuỗi lỗi nên xác định nguồn gốc của chúng, chẳng hạn bằng cách có
một tiền tố đặt tên thao tác hoặc package đã tạo ra lỗi. Ví dụ, trong package
image, biểu diễn chuỗi cho lỗi giải mã do
định dạng không xác định là "image: unknown format".
Các caller quan tâm đến chi tiết lỗi chính xác có thể
dùng type switch hoặc type assertion để tìm kiếm các lỗi cụ thể
và trích xuất chi tiết. Đối với PathErrors,
điều này có thể bao gồm kiểm tra trường nội bộ Err
để tìm các lỗi có thể phục hồi.
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 khác.
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, có nghĩa là
lỗi thuộc kiểu *os.PathError, và e cũng vậy,
chúng ta có thể kiểm tra để biết thêm thông tin về lỗi.
Panic
Cách thông thường để báo cáo lỗi cho caller là trả về một
error như một giá trị trả về thêm. Method Read chuẩn là một ví dụ nổi tiếng;
nó trả về một byte count và một error. Nhưng nếu lỗi là
không thể phục hồi thì sao? Đôi khi chương trình đơn giản không thể tiếp tục.
Cho mục đích này, có một hàm built-in panic
tạo ra một lỗi runtime thực sự sẽ dừng chương trình
(nhưng hãy xem phần tiếp theo). Hàm nhận một đối số duy nhất
thuộc kiểu tùy ý, thường là một chuỗi, để được in ra khi
chương trình chết. Đây cũng là một cách để chỉ ra rằng điều gì đó không thể xảy ra đã
xảy ra, chẳng hạn như thoát ra 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ụ nhưng các hàm thư viện thực tế nên
tránh panic. Nếu vấn đề có thể được che giấu hoặc giải quyết,
tốt hơn là hãy để mọi thứ tiếp tục chạy thay vì
làm sập toàn bộ chương trình. Một ví dụ phản bác có thể xảy ra
là trong quá trình khởi tạo: nếu thư viện thực sự không thể tự thiết lập,
có thể hợp lý để panic, theo một nghĩa nào đó.
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
Recover
Khi panic được gọi, bao gồm cả ngầm định cho các
lỗi runtime như truy cập ngoài giới hạn slice hoặc type assertion thất bại,
nó ngay lập tức dừng thực thi hàm hiện tại
và bắt đầu unwind stack của goroutine, chạy bất kỳ hàm deferred nào
trên đường đó. Nếu việc unwind đó đạt đến đỉnh của
stack goroutine, chương trình chết. Tuy nhiên, có thể
dùng hàm built-in recover để lấy 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 unwind và trả về
đối số đã truyền cho panic. Vì code duy nhất
chạy trong khi unwind là bên trong các hàm deferred, recover
chỉ hữu ích bên trong các hàm deferred.
Một ứng dụng của recover là tắt một goroutine thất bại
bên trong một server mà không giết các goroutine đang thực thi khác.
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 ra gọn gàng mà không làm xáo trộn các goroutine
khác. Không cần làm gì thêm trong closure deferred;
gọi recover xử lý hoàn toàn điều kiện đó.
Vì recover luôn trả về nil trừ khi được gọi trực tiếp
từ một hàm deferred, code deferred có thể gọi các routine thư viện mà bản thân chúng
sử dụng panic và recover mà không bị lỗi. Ví dụ,
hàm deferred trong safelyDo có thể gọi một hàm logging trước khi
gọi recover, và code logging đó sẽ chạy không bị ảnh hưởng
bởi trạng thái panicking.
Với pattern recovery của chúng ta, hàm do
(và bất cứ thứ gì nó gọi) có thể thoát khỏi bất kỳ tình huống tệ nào
một cách gọn gàng bằng cách gọi panic. Chúng ta có thể dùng ý tưởng đó để
đơn giản hóa 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 hóa của package regexp, cái này báo cáo
lỗi phân tích cú pháp bằng cách gọi panic với một kiểu lỗi 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 recovery sẽ đặt
giá trị trả về thành nil (các hàm deferred có thể sửa đổi
các giá trị trả về có tên). Sau đó nó sẽ kiểm tra, trong phép gán
cho err, rằng vấn đề là lỗi phân tích cú pháp bằng cách assert
rằng nó có kiểu cục bộ Error.
Nếu không, type assertion sẽ thất bại, gây ra một lỗi runtime
tiếp tục việc unwind stack như thể không có gì đã gián đoạn
nó.
Kiểm tra này có nghĩa là nếu điều gì đó bất ngờ xảy ra, chẳng hạn
như truy cập ngoài giới hạn index, code sẽ thất bại ngay cả khi chúng ta
đang dùng panic và recover để xử lý
lỗi phân tích cú pháp.
Với xử lý lỗi, method error (vì nó là một
method gắn với một kiểu, nên hoàn toàn ổn, thậm chí tự nhiên, khi nó có cùng tên
với kiểu built-in error)
giúp dễ dàng báo cáo lỗi phân tích cú pháp mà không cần lo lắng về việc unwind
stack phân tích cú pháp bằng tay:
if pos == 0 {
re.error("'*' illegal at start of expression")
}
Mặc dù pattern này hữu ích, nó chỉ nên được dùng trong nội bộ một package.
Parse chuyển các lời gọi panic nội bộ của nó thành
các giá trị error; nó không để lộ panic
cho client của nó. Đó là một quy tắc tốt để tuâ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ả hai lỗi ban đầu và mới sẽ được trình bày trong báo cáo crash, nên nguyên nhân gốc rễ của vấn đề vẫn còn có thể nhìn thấy. Vì vậy cách tiếp cận re-panic đơn giản này thường là đủ (dù sao nó cũng là crash) nhưng nếu muốn chỉ hiển thị giá trị ban đầu, bạn có thể viết thêm một ít code để lọc các vấn đề bất ngờ và re-panic với lỗi ban đầu. Bài tập đó dành cho người đọ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.
Đây thực ra là một loại web re-server.
Google cung cấp một dịch vụ tại chart.apis.google.com
thực hiện định dạng tự động dữ liệu thành biểu đồ và đồ thị.
Tuy nhiên, nó khó dùng theo cách tương tác
vì bạn cần đặt dữ liệu vào URL như một query.
Chương trình ở đây cung cấp một giao diện tiện hơn cho một dạng dữ liệu: cho một đoạn văn bản ngắn,
nó gọi chart server để tạo ra một mã QR, một ma trận các ô mã hóa
văn bản đó.
Ảnh đó có thể được chụp bằng camera điện thoại của bạn và được giải thích là,
ví dụ, một URL, giúp bạn không phải gõ URL vào bàn phím nhỏ của điện thoại.
Đây là chương trình hoàn chỉnh. Phần giải thích theo 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 đến main nên dễ theo dõi.
Flag duy nhất đặt một cổng HTTP mặc định cho server của chúng ta. Biến template
templ là nơi điều thú vị xảy ra. Nó xây dựng một HTML template
sẽ được server thực thi để hiển thị trang; thêm về điều đó
sau một lúc.
Hàm main phân tích các flag và, dùng cơ chế
chúng ta đã nói ở 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ó chặn trong khi server chạy.
QR chỉ nhận request, chứa dữ liệu form, và
thực thi template trên dữ liệu trong giá trị form tên s.
Package template html/template rất mạnh;
chương trình này chỉ chạm vào một phần nhỏ khả năng của nó.
Về bản chất, nó viết lại một đoạn HTML text một cách linh hoạt bằng cách thay thế các phần tử được lấy
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.
Trong văn bản template (templateStr),
các đoạn được phân cách bằng dấu ngoặc nhọn kép biểu thị các hành động template.
Đoạn từ {{if .}}
đến {{end}} chỉ thực thi nếu giá trị của mục dữ liệu hiện tại, được 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ị ẩn đi.
Hai đoạn {{.}} nói hãy hiển thị dữ liệu được trình bày cho
template (chuỗi query) trên trang web.
Package HTML template tự động cung cấp các ký tự escape phù hợp để
văn bản an toàn để hiển thị.
Phần còn lại của chuỗi template chỉ là HTML để hiển thị khi trang tải. Nếu đây là giải thích quá ngắn gọn, hãy xem tài liệu của package template để thảo luận chi tiết hơn.
Và đây là kết quả: một web server hữu ích trong vài dòng code cộng với một ít HTML text theo hướng dữ liệu. Go đủ mạnh để làm được nhiều việc trong vài dòng.