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à gofmt xuấ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ậy
x<<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ư intfloat64), 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; ifswitch 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 breakcontinue 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.

ifswitch 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, ferr. 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 derr. 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:

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 forwhile 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ừ ++--).

// 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 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ề panicrecover, 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 newmake. 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ả pv 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)&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, EioEinval, 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 newmake.

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,

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à lencap 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, FprintfSprintf có thêm một cặp hàm khác, ví dụ PrintPrintln. 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.Stdoutos.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ì PrintPrintln 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[]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[]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.NewIEEEadler32.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 syncatomic để 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 rangemaps. Đị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 (fmtio) 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.Readerio.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ả ReadWrite. 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 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.Readerbufio.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.Readerbufio.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 đó.

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 panicrecover 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 panicrecover để 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.