Go hiệu quả

Lưu ý: Tài liệu này được viết cho bản phát hành Go năm 2009 và không được cập nhật tích cực. Mặc dù vẫn là một hướng dẫn hữu ích để sử dụng ngôn ngữ cốt lõi, tài liệu không bao gồm những thay đổi đáng kể đối với ngôn ngữ (generics), hệ sinh thái (modules), hoặc các thư viện được thêm vào kể từ đó. Xem vấn đề 28782 để biết ngữ cảnh. Để xem danh sách đầy đủ các thay đổi, xem ghi chú bản phát hành.

Giới thiệu

Go là một ngôn ngữ lập trình mã nguồn mở tập trung vào sự đơn giản, độ tin cậy và hiệu quả, được thiết kế đặc biệt để giúp việc xây dựng phần mềm ở quy mô lớn trở nên dễ dàng. Mặc dù vay mượn ý tưởng từ các ngôn ngữ hiện có, Go có những đặc tính khác thường khiến các chương trình Go hiệu quả có đặc điểm khác với các chương trình được viết bằng những ngôn ngữ họ hàng. Việc dịch trực tiếp một chương trình C++ hoặc Java sang Go khó có khả năng tạo ra kết quả thỏa đáng—các chương trình Java được viết bằng Java, không phải Go. Mặt khác, việc suy nghĩ về vấn đề từ góc nhìn của Go có thể tạo ra một chương trình thành công nhưng khá khác biệt. Nói cách khác, để viết Go tốt, điều quan trọng là phải hiểu các đặc tính và thành ngữ của nó. Việc biết các quy ước đã được thiết lập khi lập trình bằng Go, chẳng hạn như đặt tên, định dạng, xây dựng chương trình, v.v., cũng rất quan trọng, để các chương trình bạn viết sẽ dễ dàng được các lập trình viên Go khác hiểu.

Tài liệu này cung cấp các mẹo để viết mã Go rõ ràng, đúng thành ngữ. Nó bổ sung cho đặc tả ngôn ngữ, Tour of Go, và Cách viết mã Go, tất cả những tài liệu này bạn nên đọc trước.

Ví dụ

Mã nguồn gói Go được dùng không chỉ như thư viện cốt lõi mà còn như các 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 hoạt động, độc lập 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, hãy nhấp vào từ "Example" để mở nó). Nếu bạn có câu hỏi về cách tiếp cận một vấn đề hoặc cách một thứ gì đó có thể được triển khai, tài liệu, mã và các ví dụ trong thư viện có thể cung cấp câu trả lời, ý tưởng và bối cảnh.

Định dạng

Các vấn đề về định dạng là những vấn đề gây tranh cãi nhất nhưng lại ít quan trọng nhất. Mọi người có thể thích nghi với các kiểu định dạng khác nhau, nhưng tốt hơn là họ không phải làm vậy, và sẽ dành ít thời gian hơn cho chủ đề này nếu mọi người tuân thủ cùng một kiểu. Vấn đề là làm thế nào để tiếp cận miền Utopia này mà không cần một hướng dẫn phong cách mang tính quy định dài dòng.

Với Go, chúng tôi áp dụng một cách tiếp cận khác thường và để máy móc xử lý hầu hết các vấn đề về định dạng. Chương trình gofmt (cũng có sẵn 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 một kiểu chuẩn về thụt lề và căn chỉnh theo chiều dọc, giữ lại và nếu cần định dạng lại các chú thích. Nếu bạn muốn biết cách xử lý một tình huống bố cục mới nào đó, hãy chạy gofmt; nếu câu trả lời không có vẻ đúng, hãy sắp xếp lại chương trình của bạn (hoặc báo lỗi về gofmt), đừng tìm cách né tránh nó.

Ví dụ, không cần dành thời gian để căn chỉnh các chú thích trên các trường của một cấu trúc. Gofmt sẽ làm việc đó 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ả mã Go trong các gói chuẩn đã được định dạng bằng gofmt.

Một số chi tiết về định dạng vẫn còn tồn tại. Tóm tắt:

Thụt lề
Chúng tôi sử dụng tab để thụt lề và gofmt xuất chúng theo mặc định. Chỉ sử dụng khoảng trắng nếu bắt buộc.
Độ dài dòng
Go không có giới hạn độ dài dòng. Đừng lo lắng về việc vượt quá một thẻ đục lỗ. Nếu một dòng có vẻ quá dài, hãy ngắt dòng đó và thụt lề bằng 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ứ bậc ư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 khoảng trắng thể hiện, không giống như trong các ngôn ngữ khác.

Chú thích

Go cung cấp chú thích khối theo kiểu C /* */ và chú thích dòng theo kiểu C++ //. Chú thích dòng là dạng thông dụng; chú thích khối chủ yếu xuất hiện dưới dạng chú thích gói, nhưng hữu ích bên trong một biểu thức hoặc để vô hiệu hóa các phần lớn của mã.

Các chú thích xuất hiện trước khai báo cấp cao nhất, không có dòng trống xen giữa, được xem là tài liệu cho chính khai báo đó. Những “chú thích tài liệu” này là tài liệu chính cho một gói hoặc lệnh Go cụ thể. Để biết thêm về chú thích tài liệu, xem “Go Doc Comments”.

Tên

Tên 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 một gói được xác định bởi việc ký tự đầu tiên của nó có viết hoa hay không. Vì vậy, dành một chút thời gian để nói về các quy ước đặt tên trong chương trình Go là điều đáng làm.

Tên gói

Khi một gói được import, tên gói trở thành bộ truy cập đến nội dung của nó. Sau

import "bytes"

gói import có thể đề cập đến bytes.Buffer. Sẽ hữu ích nếu mọi người sử dụng gói đó có thể dùng cùng một tên để tham chiếu đến nội dung của nó, điều này có nghĩa là tên gói nên là một tên tốt: ngắn, súc tích, gợi nhớ. Theo quy ước, các gói được đặt tên viết thường, một từ; không nên cần đến dấu gạch dưới hoặc mixedCaps. Hãy ưu tiên sự ngắn gọn, vì 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ề các xung đột a priori. Tên gói chỉ là tên mặc định cho các import; nó không cần phải là duy nhất trong toàn bộ mã nguồn, và trong trường hợp hiếm xảy ra xung đột, gói import có thể chọn một tên khác để sử 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 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 dưới dạng "encoding/base64" nhưng có tên base64, không phải encoding_base64 và cũng không phải encodingBase64.

Bên import của một gói sẽ sử dụng tên đó để tham chiếu đến nội dung của nó, vì vậy các tên được export trong gói có thể tận dụng điều này để tránh lặp lại. (Không sử dụng ký hiệu import ., vốn có thể đơn giản hóa các bài kiểm thử phải chạy bên ngoài gói mà chúng đang kiểm thử, nhưng nên tránh sử dụng trong các trường hợp khác.) Ví dụ, kiểu trình đọc có bộ đệm trong gói bufio được gọi là Reader, không phải BufReader, vì người dùng 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 bằng tên gói của chúng, bufio.Reader không xung đột với io.Reader. Tương tự, hàm tạo các thể hiện mới của ring.Ring—đây là định nghĩa của một constructor trong Go—thông thường sẽ được gọi là NewRing, nhưng vì Ring là kiểu duy nhất được export bởi gói, và vì gói có tên là ring, nó chỉ được gọi là New, mà người dùng gói sẽ thấy là ring.New. Hãy sử dụng cấu trúc gói để giúp bạn chọn các tên tốt.

Một ví dụ ngắn khác là once.Do; once.Do(setup) đọc tự nhiên và sẽ không được cải thiện bằng cách viết once.DoOrWaitUntilDone(setup). Tên dài không tự động làm cho mọi thứ dễ đọc hơn. Một chú thích tài liệu hữu ích thường có thể có giá trị hơn một cái tên dài thêm.

Getters

Go không cung cấp hỗ trợ tự động cho getter và setter. Không có gì sai khi tự cung cấp getter và setter, và thường thì làm như vậy là phù hợp, nhưng việc thêm Get vào tên của getter không phải là phong cách thành ngữ cũng không cần thiết. Nếu bạn có một trường có tên là owner (chữ thường, không được export), phương thức getter nên được gọi là Owner (chữ hoa, được export), không phải GetOwner. Việc dùng tên chữ hoa để export cung cấp cách phân biệt trường với phương thức. Một hàm setter, nếu cần, có thể sẽ được gọi là SetOwner. Cả hai tên đều dễ đọc 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 bằng tên phương thức cộng với hậu tố -er hoặc một biến thể tương tự để tạo thành danh từ chỉ tác nhân: Reader, Writer, Formatter, CloseNotifier v.v.

Có một số tên như vậy và việc tuân thủ chúng cũng như các tên hàm mà chúng đại diện là điều hữu ích. Read, Write, Close, Flush, String và các tên tương tự 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 quen thuộc, hãy đặt cho nó cùng tên và chữ ký; gọi phương thức chuyển đổi chuỗi của bạn là String chứ không phải ToString.

MixedCaps

Cuối cùng, quy ước trong Go là sử 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 chí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ông giống C, các dấu chấm phẩy đó không xuất hiện trong mã nguồn. Thay vào đó, bộ phân tích từ vựng sử 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 là 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ư số hoặc hằng chuỗi, hoặc một trong các token

break continue fallthrough return ++ -- ) }

bộ phân tích từ vựng luôn chèn một dấu chấm phẩy sau token đó. Điều này có thể được tóm tắt là, “nếu dòng mới xuất hiện sau một token có thể kết thúc câu lệnh, hãy chèn một dấu chấm phẩy”.

Một dấu chấm phẩy cũng có thể được bỏ qua ngay trước dấu ngoặc nhọn đóng, 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 theo phong cách thành ngữ chỉ có dấu chấm phẩy ở những nơi như các mệnh đề vòng lặp for, để phân tách phần khởi tạo, điều kiện và các phần tiếp diễn. Chúng cũng cần thiết để phân tách nhiều câu lệnh trên cùng một dòng, nếu bạn viết mã 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 ngoặc nhọn mở của một cấu trúc điều khiển (if, for, switch, hoặc select) trên 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 nhọn, điều này có thể gây ra các hiệu ứng không mong muốn. Hãy viết chúng như sau

if i < f() {
    g()
}

không phải như sau

if i < f()  // sai!
{           // sai!
    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 các 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 hoặc while, chỉ có for được tổng quát hóa hơn một chút; 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 phần cần thoát khỏi hoặc tiếp tục; và có các cấu trúc điều khiển mới bao gồm switch theo kiểu và bộ ghép kênh giao tiếp nhiều nhánh, select. Cú pháp cũng hơi khác: không có dấu ngoặc đơn và thân của các cấu trúc luôn phải được bao quanh bởi dấu ngoặc nhọn.

If

Trong Go, một câu lệnh if đơn giản có dạng như sau:

if x > 0 {
    return y
}

Các dấu ngoặc nhọn bắt buộc khuyến khích việc viết các câu lệnh if đơn giản trên nhiều dòng. Dù sao thì đây cũng là phong cách tốt, đặc biệt khi thân câu 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 chúng được dùng để 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 chuyển sang câu lệnh tiếp theo—nghĩa là, thân của nó 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à một ví dụ về tình huống phổ biến trong đó mã cần bảo vệ trước một chuỗi điều kiện lỗi. Mã sẽ dễ đọc hơn nếu luồng điều khiển thành công chạy dọc theo trang, loại bỏ 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, mã 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

Một chú thích bên lề: 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 có dạng,

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 có dạng,

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. Việc trùng lặp 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ỉ cung cấp cho nó một giá trị mới.

Trong một khai báo :=, một biến v có thể xuất hiện ngay cả khi nó đã được khai báo, với điều kiện:

Đặc tính bất thường này hoàn toàn mang tính thực dụng, giúp việc sử dụng một giá trị err duy nhất trở nên dễ dàng, chẳng hạn, trong một chuỗi if-else dài. Bạn sẽ thấy nó được sử dụng thường xuyên.

§ Cần lưu ý ở đây rằng trong Go, phạm vi của các tham số hàm và giá trị trả về giống với thân hàm, mặc dù về mặt cú pháp chúng xuất hiện bên ngoài các dấu ngoặc bao quanh thân hàm.

For

Vòng lặp for của Go tương tự—nhưng không hoàn toàn giống—với vòng lặp của 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.

// Giống for trong C
for init; condition; post { }

// Giống while trong C
for condition { }

// Giống for(;;) trong C
for { }

Các khai báo ngắn giúp dễ dàng khai báo biến chỉ số ngay trong vòng lặp.

sum := 0
for i := 0; i < 10; i++ {
    sum += i
}

Nếu bạn lặp qua một 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 mục đầu tiên trong range (khóa hoặc chỉ số), hãy bỏ mục thứ hai:

for key := range m {
    if key.expired() {
        delete(m, key)
    }
}

Nếu bạn chỉ cần mục thứ hai trong range (giá trị), hãy dùng blank identifier, một dấu gạch dưới, để bỏ qua mục đầu tiên:

sum := 0
for _, value := range array {
    sum += value
}

Blank identifier có nhiều cách sử dụng, như được mô tả trong phần sau.

Đối với chuỗi, range thực hiện thêm nhiều công việc cho bạn, tách ra từng điểm mã Unicode riêng lẻ bằng cách phân tích UTF-8. Các mã hóa không hợp lệ tiêu thụ một byte và tạo ra rune thay thế U+FFFD. (Tên rune (cùng với kiểu dựng sẵn tương ứng) là thuật ngữ của Go dùng cho một điểm mã Unicode đơn lẻ. Xem đặc tả ngôn ngữ để biết chi tiết.) Vòng lặp

for pos, char := range "日本\x80語" { // \x80 là một mã hóa UTF-8 không hợp lệ
    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 for bạn nên sử dụng phép gán song song (mặc dù điều đó không cho phép dùng ++--).

// Đảo ngược a
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
    a[i], a[j] = a[j], a[i]
}

Switch

switch của Go tổng quát hơn của C. Các biểu thức không nhất thiết phải là hằng số hoặc thậm chí là số nguyên, các trường hợp đượ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 dựa trên true. Do đó có thể—và cũng là cách viết theo phong cách Go—chuyển một chuỗi if-else-if-else thành một switch.

func unhex(c byte) byte {
    switch {
    case '0' <= c && c <= '9':
        return c - '0'
    case 'a' <= c && c <= 'f':
        return c - 'a' + 10
    case 'A' <= c && c <= 'F':
        return c - 'A' + 10
    }
    return 0
}

Không có cơ chế tự động rơi xuống trường hợp tiếp theo (fall through), nhưng các trường hợp có thể được trình bày trong các danh sách phân tá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 ở Go như trong một số ngôn ngữ tương tự C khác, các câu lệnh break có thể được dùng để kết thúc một switch sớm. Tuy nhiên, đôi khi cần thoát khỏi vòng lặp bao quanh, chứ không phải switch, và trong Go có thể thực hiện điều đó bằng cách đặt nhãn cho vòng lặp rồi "break" đến nhãn đó. Ví dụ này minh họa cả hai cách sử dụng.

Loop:
    for n := 0; n < len(src); n += size {
        switch {
        case src[n] < sizeOne:
            if validateOnly {
                break
            }
            size = 1
            update(src[n])

        case src[n] < sizeTwo:
            if n+1 >= len(src) {
                err = errShortInput
                break Loop
            }
            if validateOnly {
                break
            }
            size = 2
            update(src[n] + src[n+1]<<shift)
        }
    }

Dĩ nhiên, câu lệnh continue cũng chấp 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, dưới đây là một hàm so sánh cho các lát byte 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
}

Chuyển đổi type

Một switch cũng có thể được dùng để phát hiện type động của một biến interface. Một type switch như vậy sử dụng cú pháp của một phép khẳng định type với từ khóa type bên trong dấu ngoặc. Nếu switch khai báo một biến trong biểu thức, biến đó sẽ có type tương ứng trong từng mệnh đề. Việc tái sử dụng tên trong các trường hợp như vậy cũng là cách viết thông dụng, về bản chất là khai báo một biến mới cùng tên nhưng có type khác nhau trong mỗi trường hợp.

var t interface{}
t = functionOfSomeType()
switch t := t.(type) {
default:
    fmt.Printf("unexpected type %T\n", t)     // %T in ra bất kỳ type nào mà t có
case bool:
    fmt.Printf("boolean %t\n", t)             // t có type bool
case int:
    fmt.Printf("integer %d\n", t)             // t có type int
case *bool:
    fmt.Printf("pointer to boolean %t\n", *t) // t có type *bool
case *int:
    fmt.Printf("pointer to integer %d\n", *t) // t có type *int
}

Hàm

Nhiều giá trị trả về

Một trong những tính năng không thông thường của Go là các hàm và phương thức có thể trả về nhiều giá trị. Cách này có thể được dùng để cải thiện một vài thành ngữ vụng về trong chương trình C: các kiểu trả về lỗi trong cùng luồng như -1 cho EOF và việc sửa đổi một đối số được truyền bằng địa chỉ.

Trong C, lỗi ghi được biểu thị bằng số lượng âm với mã lỗi được cất giấu trong một vị trí dễ thay đổi. Trong Go, Write có thể trả về một số lượng một lỗi: “Có, 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 kiểu viết phổ biến; xem phần về xử lý lỗi để có thêm ví dụ.

Một cách tiếp cận tương tự loại bỏ nhu cầu truyền con trỏ tới một giá trị trả về để mô phỏng tham số tham chiếu. Dưới đây là một hàm đơn giản để lấy một số từ một vị trí trong một lát byte, 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 lát đầu vào b như sau:

    for i := 0; i < len(b); {
        x, i = nextInt(b, i)
        fmt.Println(x)
    }

Tham số kết quả được đặt 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à được sử 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 thành các giá trị zero tương ứng với kiểu của chú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 sử dụng làm các giá trị trả về.

Việc đặt tên không bắt buộc nhưng có thể giúp mã ngắn gọn và rõ ràng hơn: chúng là tài liệu mô tả. Nếu chúng ta đặt tên cho các kết quả của nextInt, sẽ trở nên rõ ràng giá trị int nào được trả về là giá trị nào.

func nextInt(b []byte, pos int) (value, nextPos int) {

Vì các kết quả được đặt tên được khởi tạo và gắn với lệnh return không có đối số, chúng có thể giúp đơn giản hóa cũng như làm rõ mã. Đây là một phiên bản của io.ReadFull sử dụng chúng hiệu quả:

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ệnh gọi hàm (hàm được trì hoãn) để chạy ngay trước khi hàm thực thi defer trả về. Đây là một cách không thông thường nhưng hiệu quả để xử lý các tình huống như tài nguyên phải được giải phóng bất kể hàm đi theo đường dẫn nào để trả về. Các ví dụ điển hình là mở khóa mutex hoặc đóng 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 sẽ chạy khi chúng ta hoàn tất.

    var result []byte
    buf := make([]byte, 100)
    for {
        n, err := f.Read(buf[0:])
        result = append(result, buf[0:n]...) // append được thảo luận sau.
        if err != nil {
            if err == io.EOF {
                break
            }
            return "", err  // f sẽ được đóng nếu chúng ta trả về ở đây.
        }
    }
    return string(result), nil // f sẽ được đóng nếu chúng ta trả về ở đây.
}

Việc trì hoãn một lệnh 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 rất dễ mắc phải nếu sau đó bạn chỉnh sửa hàm để thêm một đường dẫn trả về mới. Thứ hai, điều đó có nghĩa là thao tác đóng nằm gần thao tác mở, 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 trì hoãn (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 call thực thi. Ngoài việc tránh lo lắng về các biến thay đổi giá trị trong quá trình hàm thực thi, điều này còn có nghĩa là một vị trí gọi hàm được trì hoãn duy nhất có thể trì hoãn nhiều lần thực thi hàm. Đây là một ví dụ hơi ngớ ngẩn.

for i := 0; i < 5; i++ {
    defer fmt.Printf("%d ", i)
}

Các hàm được trì hoãn được thực thi theo thứ tự LIFO, vì vậy đoạn mã này sẽ khiến 4 3 2 1 0 được in ra khi hàm trả về. Một ví dụ hợp lý hơn là một cách đơn giản để theo dõi việc thực thi hàm trong chương trình. Chúng ta có thể viết một vài hàm theo dõi đơn giản như sau:

func trace(s string)   { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }

// Use them like this:
func a() {
    trace("a")
    defer untrace("a")
    // do something....
}

Chúng ta có thể làm tốt hơn bằng cách tận dụng thực tế rằng các đối số của hàm được trì hoãn được đánh giá khi defer thực thi. Hàm theo dõi có thể thiết lập đối số cho hàm 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 việc quản lý tài nguyên ở mức khối từ các ngôn ngữ khác, defer có thể có vẻ kỳ lạ, nhưng các ứng dụng thú vị và mạnh mẽ nhất của nó đến 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ề các khả năng của nó.

Dữ liệu

Cấp phát với new

Go có hai nguyên thủy cấp phát, là các hàm dựng sẵn newmake. Chúng thực hiện 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 dựng sẵn cấp phát bộ nhớ, nhưng không giống như 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ớ, mà chỉ đặt về không. Nghĩa là, new(T) cấp phát vùng lưu trữ đã được đặt về không cho một biến mới có kiểu T và trả về địa chỉ của biến đó, một giá trị có kiểu *T. Theo thuật ngữ của Go, nó trả về một con trỏ đến giá trị không mới được cấp phát có kiểu T.

Bắt đầu từ Go 1.26, new cũng chấp nhận một biểu thức (giá trị) làm đối số, biểu thức này chỉ định giá trị ban đầu của biến. Ví dụ, new(int64(300)) cấp phát một biến mới có kiểu int64, được khởi tạo bằng 300, và trả về địa chỉ của biến đó.

Vì bộ nhớ được trả về bởi new được đặt về không, nên khi thiết kế cấu trúc dữ liệu, bạn nên sắp xếp sao cho giá trị không 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ủa cấu trúc dữ liệu có thể tạo một giá trị bằng new và bắt tay ngay vào công việc. Ví dụ, tài liệu của bytes.Buffer nói rằng "giá trị không của Buffer là một bộ đệm rỗng sẵn sàng để sử dụng." Tương tự, sync.Mutex không có hàm tạo rõ ràng hoặc phương thức Init. Thay vào đó, giá trị không của một sync.Mutex được định nghĩa là một mutex đang mở khóa.

Tính chất giá trị không hữu ích này hoạt động có tính bắc cầu. Hãy xem khai báo kiểu sau.

type SyncedBuffer struct {
    lock    sync.Mutex
    buffer  bytes.Buffer
}

Các giá trị có kiểu SyncedBuffer cũng sẵn sàng để sử dụng ngay lập tức khi được cấp phát hoặc chỉ khi khai báo. Trong đoạn mã tiếp theo, cả pv đều sẽ hoạt động đúng mà không cần sắp xếp thêm.

p := new(SyncedBuffer)  // kiểu *SyncedBuffer
var v SyncedBuffer      // kiểu  SyncedBuffer

Hàm khởi tạo và literal tổ hợp

Đôi khi giá trị zero không đủ tốt và cần một hàm khởi tạo để khởi tạo, như trong ví dụ này 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ó khá nhiều mã soạn sẵn trong đó. Chúng ta có thể đơn giản hóa nó bằng cách sử dụng một literal tổ hợp, là một biểu thức tạo ra một thể hiện mới mỗi khi 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ông giống như trong C, hoàn toàn hợp lệ khi trả về địa chỉ của một biến cục bộ; bộ nhớ liên kết với biến đó tồn tại sau khi hàm trả về. Thực tế, lấy địa chỉ của một literal tổ hợp sẽ cấp phát một thể hiện mới mỗi khi nó được đánh giá, vì vậy chúng ta có thể gộp hai dòng cuối này.

    return &File{fd, name, nil, 0}

Các trường của một literal tổ hợp được sắp xếp theo thứ tự và tất cả phải có mặt. Tuy nhiên, bằng cách gắn nhãn rõ ràng các phần tử dưới dạng các cặp field:value, các giá trị khởi tạo có thể xuất hiện theo bất kỳ thứ tự nào, với những phần bị thiếu được giữ lại dưới dạng các giá trị zero tương ứ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 literal tổ hợp không chứa trường nào cả, nó sẽ tạo ra giá trị zero cho kiểu đó. Các biểu thức new(File)&File{} là tương đương.

Literal tổ hợp cũng có thể được tạo cho mảng, slice và map, trong đó nhãn trường là chỉ mục hoặc khóa map tương ứng. Trong các ví dụ này, việc 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 với make

Quay lại việc cấp phát. Hàm dựng sẵn make(T, args) phục vụ mục đích khác với new(T). Nó chỉ tạo slice, map và channel, đồng thời trả về một giá trị đã được khởi tạo (không được đặt về 0) có kiểu T (không phải *T). Lý do của sự khác biệt này là ba kiểu này, bên dưới lớp triển khai, biểu diễn các tham chiếu đến cấu trúc dữ liệu phải được khởi tạo trước khi sử dụng. Ví dụ, một slice là một bộ mô tả gồm ba phần 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 các phần đó đượ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 gồm 100 số nguyên rồi tạo một cấu trúc slice có độ dài 10 và dung lượng 100 trỏ đến 10 phần tử đầu tiên của mảng. (Khi tạo slice, có thể bỏ qua dung lượng; 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 cấu trúc slice mới được cấp phát và đặt về 0, 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 số nguyên

// Không cần thiết phức tạp:
var p *[]int = new([]int)
*p = make([]int, 100, 100)

// Theo phong cách thông dụng:
v := make([]int, 100)

Hãy 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 lập kế hoạch bố cục chi tiết của bộ nhớ và đôi khi có thể giúp tránh cấp phát, nhưng chủ yếu chúng là khối xây dựng cho slice, chủ đề của phần tiếp theo. Để đặt nền tảng cho chủ đề đó, dưới đây là một vài lời về mảng.

Có những khác biệt lớn giữa cách mảng hoạt động trong Go và C. Trong Go,

Tính chất giá trị có thể hữu ích nhưng cũng tốn kém; nếu bạn muốn hành vi và hiệu suất giống C, bạn có thể truyền một 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ả phong cách này cũng không phải là phong cách Go thông dụng. Hãy dùng slice thay thế.

Slices

Slices bao bọc các mảng để cung cấp một interface tổng quát hơn, mạnh mẽ hơn và thuận tiện hơn cho các chuỗi dữ liệu. Ngoại trừ những trường hợp có kích thước rõ ràng như các ma trận biến đổi, phần lớn việc lập trình mảng trong Go được thực hiện bằng slices thay vì các mảng đơn giản.

Slices lưu giữ tham chiếu đến một mảng bên dưới, và nếu bạn gán một slice cho một slice khác, cả hai đều tham chiếu đến cùng một mảng. Nếu một hàm nhận đối số là slice, các thay đổi mà nó thực hiện đối với các phần tử của slice sẽ được nhìn thấy bởi bên gọi, tương tự như việc truyền một con trỏ đến mảng bên dưới. Vì vậy, một hàm Read có thể nhận một đối số slice thay vì một con trỏ và một số lượng; độ dài bên trong slice đặt giới hạn trên cho lượng dữ liệu có thể đọc. Sau đây là chữ ký của phương thức Read của kiểu File trong gói os:

func (f *File) Read(buf []byte) (n int, err error)

Phương thức trả về số byte đã đọc và một giá trị lỗi, nếu có. Để đọc vào 32 byte đầu tiên của một bộ đệm lớn hơn buf, hãy slice (ở đây được dùng như một động từ) bộ đệm.

    n, err := f.Read(buf[0:32])

Việc cắt slice như vậy là phổ biến và hiệu quả. Trên thực tế, tạm thời bỏ qua hiệu quả trong lúc này, đoạn mã sau cũng sẽ đọ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 một slice có thể được thay đổi miễn là nó vẫn nằm trong giới hạn của mảng bên dưới; chỉ cần gán nó cho một slice của chính nó. capacity của một slice, có thể truy cập bằng hàm dựng sẵn cap, cho biết độ dài tối đa mà slice có thể đạt tới. Sau đây là một hàm để nối thêm dữ liệu vào một slice. Nếu dữ liệu vượt quá capacity, slice sẽ được cấp phát lại. Slice kết quả được trả về. Hàm này sử dụng thực tế rằng lencap hợp lệ khi áp dụng cho 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 để mở rộng trong tương lai.
        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 đó vì, mặc 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à capacity) được truyền theo giá trị.

Ý tưởng nối thêm vào một slice hữu ích đến mức nó được đưa vào trong hàm dựng sẵn append. Tuy nhiên, để hiểu thiết kế của hàm đó, chúng ta cần thêm một chút thông tin, vì vậy chúng ta sẽ quay lại với nó sau.

Slice hai chiều

Mảng và slice của Go là một chiều. Để tạo tương đương với một mảng hoặc slice 2D, cần định nghĩa một mảng của các mảng hoặc slice của các slice, như sau:

type Transform [3][3]float64  // Một mảng 3x3, thực chất là một mảng các mảng.
type LinesOfText [][]byte     // Một slice gồm các slice byte.

Vì slice có độ dài thay đổi, có thể để mỗi slice bên trong có một độ dài khác nhau. Đây có thể là tình huống phổ biến, như trong ví dụ LinesOfText của chúng ta: mỗi dòng có độ dài độc lập.

text := LinesOfText{
    []byte("Now is the time"),
    []byte("for all good gophers"),
    []byte("to bring some fun to the party."),
}

Đôi khi cần cấp phát một slice 2D, một tình huống có thể xảy ra khi xử lý các dòng quét pixel chẳng hạn. Có hai cách để thực hiện việc này. Một cách là cấp phát từng slice độc lập; cách còn lại là cấp phát một mảng duy nhất và trỏ các slice riêng lẻ vào đó. Việc chọn cách nào phụ 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 độc lập để tránh ghi đè lên dòng tiếp theo; nếu không, có thể hiệu quả hơn khi xây dựng đối tượng bằng một lần cấp phát duy nhất. Để tham khảo, dưới đây là phác thảo của hai phương pháp. Đầu tiên, từng dòng một:

// 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 từng 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 chia thành các dòng:

// Cấp phát slice cấp cao nhất, giống 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ộ pixel.
pixels := make([]uint8, XSize*YSize) // Có kiểu []uint8 mặc dù picture có kiểu [][]uint8.
// Lặp qua các hàng, lấy từng hàng từ đầu của slice pixel còn lại.
for i := range picture {
    picture[i], pixels = pixels[:XSize], pixels[XSize:]
}

Maps

map là một cấu trúc dữ liệu tích hợp sẵn tiện lợi và mạnh mẽ, liên kết các giá trị của một kiểu (the key) với các giá trị của một kiểu khác (the element hoặc value). Khóa có thể là bất kỳ kiểu nào mà toán tử so sánh bằng được định nghĩa, chẳng hạn như số nguyên, số dấu phẩy động và số phức, chuỗi, con trỏ, interface (miễn là kiểu động hỗ trợ phép so sánh bằng), struct và mảng. Slice không thể được dùng làm khóa của map, vì phép so sánh bằng không được định nghĩa cho chúng. Giống như slice, map chứa các tham chiếu đến một cấu trúc dữ liệu bên dưới. Nếu bạn truyền một map vào một hàm thay đổi nội dung của map, các thay đổi sẽ được nhìn thấy ở bên gọi.

Map có thể được tạo bằng cú pháp literal tổng hợp thông thường với các cặp key-value được phân tách bằng dấu hai chấm, vì vậy việc xây dựng chúng trong quá trình khởi tạo rất dễ dà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,
}

Việc gán và lấy các giá trị map có cú pháp giống như thực hiện cùng thao tác với mảng và slice, ngoại trừ việc chỉ mục không cần phải là một số nguyên.

offset := timeZone["EST"]

Một nỗ lực lấy giá trị map với một khóa không có trong map sẽ trả về giá trị zero cho kiểu của các phần tử trong map. Ví dụ, nếu map chứa các số nguyên, việc tra cứu một khóa không tồn tại sẽ trả về 0. Một tập hợp có thể được triển khai dưới dạng một map với kiểu giá trị là bool. Đặt mục map thành true để đưa giá trị vào tập hợp, sau đó kiểm tra nó bằng cách lập chỉ mục đơ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 bị thiếu với một giá trị zero. Có mục nào cho "UTC" hay đó là 0 vì nó hoàn toàn không có trong map? Bạn có thể phân biệt bằng một dạng gán nhiều giá trị.

var seconds int
var ok bool
seconds, ok = timeZone[tz]

Vì những lý do rõ ràng, cách 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 đặt giá trị phù hợp và ok sẽ là true; nếu không, seconds sẽ được đặt thành zero và ok sẽ là false. Dưới đây là một hàm kết hợp tất cả với một báo cá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 tế, bạn có thể sử dụng blank identifier (_) thay cho biến thông thường chứa giá trị.

_, present := timeZone[tz]

Để xóa một mục trong map, hãy sử dụng hàm tích hợp sẵn delete, các đối số của hàm này là map và khóa cần xóa. Việc này an toàn ngay cả khi khóa đã không còn tồn tại trong map.

delete(timeZone, "PDT")  // Hiện đang ở Giờ chuẩn

In ấn

Việc in có định dạng trong Go sử dụng một kiểu 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 gói 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 xử lý chuỗi (Sprintf v.v.) trả về một chuỗi thay vì điền vào một bộ đệm được cung cấp.

Bạn không cần cung cấp chuỗi định dạng. Với mỗi hàm Printf, FprintfSprintf đều có một cặp hàm khác, ví dụ như PrintPrintln. Các hàm này không nhận chuỗi định dạng mà thay vào đó tạo định dạng mặc định cho từng đối số. Các phiên bản Println cũng 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 các phiên bản Print chỉ thêm khoảng trắng nếu toán hạng ở cả hai phía không phải là chuỗi. Trong ví dụ này, mỗi dòng tạo ra cùng một đầ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 có định dạng fmt.Fprint và các hàm tương tự nhận làm đối số đầu tiên bất kỳ đối tượng nào triển khai interface io.Writer; 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 với C. Trước tiên, các định dạng số như %d không nhận cờ cho dấu hoặc kích thước; thay vào đó, các thủ tục in sử dụng kiểu của đối số để quyết định các 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 phép chuyển đổi mặc định, chẳng hạn như dạng thập phân cho số nguyên, bạn có thể dùng định dạng tổng quát %v (viết tắt của “value”); kết quả chính xác là những gì PrintPrintln sẽ tạo ra. Hơn nữa, định dạng này có thể in bất kỳ giá trị nào, kể cả mảng, slice, struct và map. Đây là một câu lệnh in cho map múi giờ được định nghĩa trong phần trước.

fmt.Printf("%v\n", timeZone)  // hoặc chỉ fmt.Println(timeZone)

cho đầu ra:

map[CST:-21600 EST:-18000 MST:-25200 PST:-28800 UTC:0]

Đối với map, Printf và các hàm tương tự 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, định dạng thay thế %#v in giá trị theo đầy đủ cú pháp Go.

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ó sẵn thông qua %q khi áp dụng cho một giá trị có kiểu string hoặc []byte. Định dạng thay thế %#q sẽ sử dụng dấu nháy ngược thay thế nếu có thể. (Định dạng %q cũng áp dụng cho số nguyên và rune, tạo ra một hằng rune được đặt trong dấu nháy đơn.) Ngoài ra, %x hoạt động với chuỗi, mảng byte và slice byte cũng như với số nguyên, tạo ra một chuỗi thập lục phân dài, và khi có một khoảng trắng trong định dạng (% x) nó sẽ đặt khoảng trắng giữa các byte.

Một định dạng hữu ích 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àm là định nghĩa một phương thức có chữ ký String() string trên kiểu đó. Với kiểu đơn giản T của chúng ta, nó có thể trông như sau.

func (t *T) String() string {
    return fmt.Sprintf("%d/%g/%q", t.a, t.b, t.c)
}
fmt.Printf("%v\n", t)

để in theo định dạng

7/-2.35/"abc\tdef"

(Nếu bạn cần in các giá trị có kiểu T cũng như con trỏ tới T, receiver cho String phải có kiểu giá trị; ví dụ này sử dụng con trỏ vì cách đó hiệu quả hơn và phù hợp với quy ước hơn đối với các kiểu struct. Xem phần bên dưới về receiver con trỏ so với receiver giá trị để 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 thủ tục in hoàn toàn có thể tái nhập và có thể được bao bọc 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 xây dựng một phương thức String bằng cách gọi Sprintf theo cách sẽ quay lại 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 trực tiếp receiver dưới dạng chuỗi, việc này sẽ gọi lại phương thức một lần nữa. Đây là một 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ẽ lặp lại vô hạn.
}

Cách sửa cũng dễ: chuyển đổi đối số thành kiểu chuỗi cơ bản, kiểu này 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 ý phép chuyển đổi.
}

Trong phần khởi tạo, chúng ta sẽ thấy một kỹ thuật khác tránh được việc đệ quy này.

Một kỹ thuật in khác là truyền trực tiếp các đối số của một hàm in sang một hàm tương tự khác. Chữ ký của Printf sử dụng kiểu ...interface{} cho đối số cuối cùng để chỉ định rằng một số lượng tham số tùy ý (với kiểu bất kỳ) có thể xuất hiện sau đị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 có kiểu []interface{} nhưng nếu được truyền cho một hàm biến đối số khác, nó hoạt động như một danh sách đối số thông thường. Dưới đây là cách triển khai của hàm log.Println mà chúng ta đã sử dụng ở trên. Nó truyền trực tiếp các đối số của mình tới fmt.Sprintln để thực hiện việc định dạng thực tế.

// Println in ra trình ghi log chuẩn theo cách của fmt.Println.
func Println(v ...interface{}) {
    std.Output(2, fmt.Sprintln(v...))  // Output nhận các tham số (int, string)
}

Chúng ta viết ... sau v trong lời gọi lồng nhau tới Sprintln để bảo trình biên dịch coi v như một danh sách các đối số; nếu không, nó sẽ chỉ truyền v như một đối số slice duy nhất.

Việc in còn nhiều điều khác ngoài những gì chúng ta đã đề cập ở đây. Hãy xem tài liệu godoc của gói fmt để biết chi tiết.

Nhân tiện, một tham số ... có thể có một kiểu cụ thể, ví dụ ...int cho một hàm min chọn giá trị nhỏ nhất trong một 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ó phần còn thiếu cần thiết để 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 ở trên. Về mặt sơ đồ, nó giống như sau:

func append(slice []T, elements ...T) []T

trong đó T là một ký hiệu giữ chỗ cho bất kỳ kiểu đã cho 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 bên gọi. Đó là lý do append được tích hợp sẵn: nó cần sự hỗ trợ từ trình biên dịch.

Điều append thực hiện 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ì, giống như Append được viết thủ công 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ì vậy append hoạt động hơi giống Printf, thu thập một số lượng đối số tùy ý.

Nhưng nếu chúng ta muốn làm điều mà Append của mình làm và thêm một slice vào một slice thì sao? Dễ thôi: sử dụng ... tại vị trí gọi hàm, giống như chúng ta đã làm trong lời gọi tới Output ở trên. Đoạn mã này tạo ra kết quả giống hệt đoạn trên.

x := []int{1,2,3}
y := []int{4,5,6}
x = append(x, y...)
fmt.Println(x)

Nếu không có ... đó, nó sẽ không biên dịch được vì các kiểu sẽ không đúng; y không có kiểu int.

Khởi tạo

Mặc dù bề ngoài không có vẻ khác biệt nhiều so với việc khởi tạo trong C hoặc C++, việc khởi tạo trong Go mạnh mẽ hơn. Các cấu trúc phức tạp có thể được xây dựng trong quá trình khởi tạo và các vấn đề về thứ tự giữa những đối tượng đã được khởi tạo, ngay cả giữa các gói khác nhau, được xử lý chính xác.

Hằng số

Các hằng số trong Go đúng như tên gọi—là hằng số. Chúng được tạo tại thời điểm biên dịch, ngay cả khi được định nghĩa dưới dạng biến cục bộ trong hàm, và chỉ có thể là số, ký tự (rune), chuỗi hoặc boolean. Do giới hạn tại 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ể được trình biên dịch đánh giá. Ví dụ, 1<<3 là một 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 được thực hiện tại thời gian chạy.

Trong Go, các hằng số liệt kê được tạo bằng bộ liệt kê iota. Vì iota có thể là một phần của biểu thức và các biểu thức có thể được lặp lại một cách ngầm định, nên việc xây dựng các tập giá trị phức tạp trở nên dễ dàng.

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 cho phép các giá trị tùy ý tự định dạng để in ra một cách tự động. Mặc dù bạn sẽ thường thấy kỹ thuật này được áp dụng cho các struct nhất, kỹ thuật này cũng hữu ích cho các kiểu vô hướng như các kiểu số thực chẳng hạn như 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 được in ra là 1.00YB, trong khi ByteSize(1e13) được in ra là 9.09TB.

Việc sử dụng Sprintf ở đây để triển khai phương thức String của ByteSize là an toàn (tránh lặp vô hạn) không phải do một phép chuyển đổi mà vì nó gọi Sprintf với %f, đây không phải là định dạng chuỗi: Sprintf sẽ chỉ gọi phương thức String khi nó cần một chuỗi, và %f yêu cầu 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 biểu thức khởi tạo có thể là một biểu thức tổng quát được tính toán tại thời điểm chạy.

var (
    home   = os.Getenv("HOME")
    user   = os.Getenv("USER")
    gopath = os.Getenv("GOPATH")
)

Hàm init

Cuối cùng, mỗi tệp mã nguồn có thể định nghĩa hàm init không có tham số của riêng nó để thiết lập bất kỳ trạng thái nào được yêu cầu. (Trên thực tế, mỗi tệp có thể có nhiều hàm init.) Và "cuối cùng" có nghĩa là cuối cùng: init được gọi sau khi tất cả các khai báo biến trong gói đã đánh giá các trình khởi tạo của chúng, và những trình khởi tạo đó chỉ được đánh giá sau khi tất cả các gói được nhập đã được khởi tạo.

Ngoài các khởi tạo không thể biểu diễn bằng khai báo, một cách sử dụng phổ biến của các hàm init là xác minh hoặc sửa chữa tính đúng đắn của trạng thái chương trình trước khi quá trình 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ể được ghi đè bằng 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ư đã thấy với ByteSize, các 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); bộ nhận không nhất thiết phải là một struct.

Trong phần thảo luận về slice ở trên, chúng ta đã viết một hàm Append. Thay vào đó, chúng ta có thể định nghĩa nó như một phương thức trên slice. Để làm điều này, trước tiên chúng ta khai báo một kiểu có tên mà chúng ta có thể gắn phương thức vào, sau đó tạo bộ nhận của phương thức là một giá trị của kiểu đó.

type ByteSlice []byte

func (slice ByteSlice) Append(data []byte) []byte {
    // Phần thân hoàn toàn giống với hàm Append được định nghĩa ở trên.
}

Điều này vẫn yêu cầu phương thức trả về slice đã được cập nhật. Chúng ta có thể loại bỏ sự bất tiện đó bằng cách định nghĩa lại phương thức để nhận một con trỏ tới ByteSlice làm bộ nhận, để phương thức có thể ghi đè slice của bên gọi.

func (p *ByteSlice) Append(data []byte) {
    slice := *p
    // Phần thân như trên, không có phần trả về.
    *p = slice
}

Thực tế, chúng ta còn có thể làm tốt hơn. Nếu chúng ta sửa đổi hàm của mình để nó trông giống một phương thức Write tiêu chuẩn, như sau,

func (p *ByteSlice) Write(data []byte) (n int, err error) {
    slice := *p
    // Một lần nữa như trên.
    *p = slice
    return len(data), nil
}

thì kiểu *ByteSlice thỏa mãn interface tiêu chuẩn io.Writer, điều này rất tiện lợi. Ví dụ, chúng ta có thể ghi dữ liệu vào một giá trị như vậy.

    var b ByteSlice
    fmt.Fprintf(&b, "This hour has %d days\n", 7)

Chúng ta truyền địa chỉ của một ByteSlice vì chỉ *ByteSlice thỏa mãn io.Writer. Quy tắc về con trỏ so với giá trị đối với bộ nhận 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 bộ nhận; việc 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ỏ. Ngôn ngữ do đó ngăn chặn lỗi này. Tuy nhiên, có một ngoại lệ hữu ích. Khi giá trị có thể lấy địa chỉ, ngôn ngữ tự xử lý trường hợp phổ biến là gọi một phương thức con trỏ trên một giá trị bằng cách tự động chèn toán tử lấy địa chỉ. Trong ví dụ của chúng ta, biến b 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ỉ bằng b.Write. Trình biên dịch sẽ viết lại thành (&b).Write giúp chúng ta.

Nhân tiện, ý tưởng sử dụng Write trên một slice byte là phần cốt lõi trong việc triển khai bytes.Buffer.

interface và các kiểu khác

interface

interface trong Go cung cấp một cách để chỉ định hành vi của một đối tượng: nếu một thứ có thể làm việc này, thì nó có thể được sử dụng ở đây. Chúng ta đã thấy một vài ví dụ đơn giản trước đó; các bộ in tùy chỉnh có thể được triển khai bằng một 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. Các interface chỉ có 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ựa trên phương thức, chẳng hạn như io.Writer cho thứ triển khai Write.

Một kiểu có thể triển khai nhiều interface. Ví dụ, một tập hợp có thể được sắp xếp bằng các hàm trong gói sort nếu nó triển khai sort.Interface, chứa Len(), Less(i, j int) bool, và Swap(i, j int), đồng thời nó cũng có thể có một bộ định dạng tùy chỉnh. Trong ví dụ được tạo ra 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

Phương thức String của Sequence đang tái tạo công việc mà Sprint đã thực hiện cho các slice. (Nó cũng có độ phức tạp O(N²), vốn không tốt.) Chúng ta có thể chia sẻ phần công sức này (đồng thời tăng tốc nó) nếu chuyển đổi Sequence thành một []int thông thường 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à một ví dụ khác về kỹ thuật chuyển đổi để gọi Sprintf an toàn từ một phương thức String. Vì hai kiểu (Sequence[]int) giống nhau nếu bỏ qua tên kiểu, việc chuyển đổi giữa chúng là hợp lệ. Việc chuyển đổi không tạo ra một 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ó những chuyển đổi hợp lệ khác, chẳng hạn từ số nguyên sang số thực, cũng tạo ra một giá trị mới.)

Trong các chương trình Go, có một thành ngữ là chuyển đổi kiểu của một biểu thức để truy cập một tập phương thức khác. Ví dụ, chúng ta có thể sử dụng kiểu hiện có sort.IntSlice để rút gọn toàn bộ ví dụ thành:

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ì để Sequence triển khai nhiều interface (sắp xếp và in), chúng ta đang sử dụng khả năng của một mục dữ liệu để được chuyển đổi thành 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 phổ biến hơn trong thực tế nhưng có thể hiệu quả.

Chuyển đổi interface và khẳng định kiểu

Chuyển đổi kiểu 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 lệnh chuyển đổi, theo một nghĩa nào đó chuyển đổi nó thành kiểu của trường hợp đó. Dưới đây là phiên bản đơn giản hóa về cách mã bên dưới fmt.Printf chuyển một giá trị thành chuỗi bằng cách sử dụng chuyển đổi kiểu. Nếu nó đã là một 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 thì 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ị do người gọi cung cấp.
switch str := value.(type) {
case string:
    return str
case Stringer:
    return str.String()
}

Trường hợp đầu tiên tìm thấy 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 hợp lệ khi trộn 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 biết giá trị chứa một string và chỉ muốn lấy nó ra? Một lệnh chuyển đổi kiểu với một trường hợp cũng có thể làm được, nhưng khẳng định kiểu cũng vậy. Khẳng định kiểu nhận một giá trị interface và lấy ra từ đó một giá trị có kiểu rõ ràng được chỉ định. Cú pháp này mượn từ phần mở đầu của lệnh chuyển đổi kiểu, nhưng dùng kiểu rõ ràng thay vì từ khóa type:

value.(typeName)

và kết quả là một giá trị mới có kiểu tĩnh là typeName. Kiểu đó phải là kiểu cụ thể mà interface đang giữ, hoặc là một kiểu interface thứ hai mà giá trị có thể được chuyển đổi thành. Để lấy chuỗi mà chúng ta biết là nằm 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ị dừng với lỗi thời gian chạy. Để tránh điều đó, hãy sử dụng thành ngữ "dấu phẩy, ok" để kiểm tra một cách an toàn xem giá trị có phải là chuỗi hay không:

str, ok := value.(string)
if ok {
    fmt.Printf("string value is: %q\n", str)
} else {
    fmt.Printf("value is not a string\n")
}

Nếu khẳng định kiểu thất bại, str vẫn tồn tại và có kiểu string, nhưng nó sẽ có giá trị zero, một chuỗi rỗng.

Để minh họa khả năng này, đây là một câu lệnh if-else tương đương với lệnh chuyển đổi kiểu mở đầ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 để triển khai một interface và sẽ không bao giờ có các phương thức được export vượt ra ngoài interface đó, thì không cần export chính kiểu đó. Chỉ export interface giúp làm rõ rằng giá trị không có hành vi đáng chú ý nào ngoài những gì được mô tả trong interface. Điều này cũng tránh việc phải lặp lại tài liệu cho từng thể hiện của một phương thức phổ biến.

Trong những trường hợp như vậy, hàm khởi tạo nên trả về một giá trị interface thay vì kiểu triển khai. Ví dụ, trong các thư viện hash, cả crc32.NewIEEEadler32.New đều trả về kiểu interface hash.Hash32. 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 hàm khởi tạo; 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ã hóa dạng luồng trong nhiều gói crypto khác nhau được tách khỏi các mã khối mà chúng kết hợp với nhau. Interface Block trong gói crypto/cipher xác định hành vi của một bộ mã hóa khối, cung cấp việc mã hóa một khối dữ liệu đơn lẻ. Sau đó, tương tự như gói bufio, các gói mã hóa triển khai interface này có thể được dùng để tạo các bộ mã hóa dạng luồng, được biểu diễn bởi interface Stream, mà không cần biết chi tiết của việc mã hóa khối.

Các interface crypto/cipher có dạng như sau:

type Block interface {
    BlockSize() int
    Encrypt(dst, src []byte)
    Decrypt(dst, src []byte)
}

type Stream interface {
    XORKeyStream(dst, src []byte)
}

Sau đây là định nghĩa của luồng chế độ bộ đếm (CTR), chuyển một bộ mã hóa khối thành một bộ mã hóa dạng luồng; lưu ý rằng các chi tiết của bộ mã hóa khối đã được trừu tượng hóa:

// NewCTR trả về một Stream mã hóa/giải mã bằng Block đã cho trong
// chế độ bộ đếm. Độ dài của iv phải giống với kích thước khối của Block.
func NewCTR(block Block, iv []byte) Stream

NewCTR áp dụng không chỉ cho một thuật toán mã hóa và nguồn dữ liệu cụ thể mà còn 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ề các 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 hàm khởi tạo phải được chỉnh sửa, nhưng vì mã xung quanh chỉ phải xử lý kết quả như một Stream, nó sẽ không nhận thấy sự khác biệt.

Interface và phương thức

Vì hầu hết mọi thứ đều có thể được gắn phương thức, nên 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 gói http , gói này định nghĩa interface Handler. Bất kỳ đối tượng nào triển khai Handler đều có thể phục vụ các yêu cầu HTTP.

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

ResponseWriter bản thân nó là một interface cung cấp quyền truy cập đến 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 chuẩn Write, vì vậy một http.ResponseWriter có thể được sử dụng ở bất kỳ nơi nào mà io.Writer có thể được sử dụng. Request là một struct chứa biểu diễn đã được phân tích cú pháp của yêu cầu từ client.

Để ngắn gọn, hãy bỏ qua các POST và giả sử các yêu cầu HTTP luôn là GET; sự đơn giản hóa này không ảnh hưởng đến cách thiết lập các handler. Sau đây là một triển khai đơn giản của một handler để đếm số lần trang được truy cập.

// Máy chủ bộ đế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 đúng chủ đề của chúng ta, hãy lưu ý cách Fprintf có thể in ra một http.ResponseWriter.) Trong một máy chủ thực tế, quyền truy cập vào ctr.n sẽ cần được bảo vệ khỏi việc truy cập đồng thời. Xem các gói syncatomic để có các gợi ý.

Để tham khảo, đây là cách gắn một máy chủ như vậy vào một nút trong cây URL.

import "net/http"
...
ctr := new(Counter)
http.Handle("/counter", ctr)

Nhưng tại sao lại tạo Counter thành một struct? Một số nguyên là tất cả những gì cần thiết. (Bộ nhận cần là một con trỏ để việc tăng giá trị được nhìn thấy bởi bên gọi.)

// Máy chủ bộ đế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 trạng thái nội bộ nào đó cần được thông báo rằng một trang đã được truy cập thì sao? Hãy gắn một channel vào trang web.

// Một channel gửi thông báo cho mỗi lần truy cập.
// (Có lẽ nên dùng channel có bộ đệ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 hiển thị trên /args các đối số được sử dụng khi gọi binary của máy chủ. Việc viết một hàm để in các đối số rất dễ dàng.

func ArgServer() {
    fmt.Println(os.Args)
}

Làm thế nào để biến điều đó thành một máy chủ HTTP? Chúng ta có thể tạo ArgServer thành một phương thức của một kiểu nào đó mà chúng ta bỏ qua giá trị của nó, nhưng có một cách gọn gàng hơn. Vì chúng ta có thể định nghĩa một phương thức cho mọi kiểu ngoại trừ con trỏ và interface, chúng ta có thể viết một phương thức cho một hàm. Gói http chứa đoạn mã này:

// Kiểu HandlerFunc là một bộ chuyển đổi cho phép sử dụng
// các hàm thông thường làm HTTP handler. Nếu f là một hàm
// có 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à một 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 xem cách triển khai phương thức: bộ nhận là một hàm, f, và phương thức gọi f. Điều này có thể có vẻ kỳ lạ nhưng không khác nhiều so với, ví dụ, bộ nhận là một channel và phương thức gửi dữ liệu trên channel đó.

Để biến ArgServer thành một 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 hiện có cùng chữ ký với HandlerFunc, vì vậy nó có thể được chuyển đổi thành 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. Đoạn mã để thiết lập nó rấ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 bộ nhận, phương thức này sau đó sẽ gọi ArgServer (thông 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ả là vì interface chỉ là các tập hợp phương thức, có thể được định nghĩa cho (gần như) mọi kiểu.

Định danh trống

Chúng ta đã đề cập đến định danh trống vài lần, trong ngữ cảnh của for range loopmap. Định danh trố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ới giá trị đó được loại bỏ một cách an toàn. Nó hơi giống như việc ghi vào tệp Unix /dev/null: nó đại diện cho một giá trị chỉ ghi được dùng làm chỗ giữ chỗ nơi cần một biến nhưng giá trị thực tế không quan trọng. Nó có những cách sử dụng vượt ra ngoài những gì chúng ta đã thấy.

Định danh trống trong phép gán nhiều giá trị

Việc sử dụng định danh trống trong một for range loop là một trường hợp đặc biệt của một tình huống tổng quát: phép gán nhiều giá trị.

Nếu một phép gán yêu cầu nhiều giá trị ở phía bên trái, nhưng một trong các giá trị đó sẽ không được chương trình sử dụng, một định danh trống ở phía bên trái của phép gán sẽ tránh việc phải tạo một biến giả và làm rõ rằng giá trị đó sẽ bị loại bỏ. Ví dụ, khi gọi một hàm trả về một giá trị và một lỗi, nhưng chỉ lỗi là quan trọng, hãy dùng định danh trống để loại bỏ giá trị không liên quan.

if _, err := os.Stat(path); os.IsNotExist(err) {
    fmt.Printf("%s does not exist\n", path)
}

Đôi khi bạn sẽ thấy mã loại bỏ giá trị lỗi để bỏ qua lỗi; đây là một thực hành rất tệ. Luôn kiểm tra các giá trị trả về lỗi; chúng được cung cấp vì một lý do.

// Tệ! Mã này sẽ gặp sự cố nếu path không tồn tại.
fi, _ := os.Stat(path)
if fi.IsDir() {
    fmt.Printf("%s is a directory\n", path)
}

Các import và biến không được sử dụng

Việc import một gói hoặc khai báo một biến mà không sử dụng nó là một lỗi. Các import không được sử dụng làm chương trình phình to 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 được sử dụng ít nhất là một phép tính bị lãng phí và có thể là dấu hiệu của một lỗi lớn hơn. Tuy nhiên, khi một chương trình đang được phát triển tích cực, các import và biến không được sử dụng thường xuất hiện và có thể gây khó chịu khi phải xóa chúng chỉ để quá trình biên dịch tiếp tục, rồi sau đó lại cần chúng. Định danh trống cung cấp một cách giải quyết.

Chương trình viết dở này có hai import không được sử dụng (fmtio) và một biến không được sử dụng (fd), vì vậy nó sẽ không biên dịch được, nhưng sẽ hữu ích nếu xem liệu mã hiện có có đúng hay không.

package main

import (
    "fmt"
    "io"
    "log"
    "os"
)

func main() {
    fd, err := os.Open("test.go")
    if err != nil {
        log.Fatal(err)
    }
    // TODO: use fd.
}

Để loại bỏ các cảnh báo về những import không được sử dụng, hãy dùng một định danh trống để tham chiếu đến một ký hiệu từ gói đã import. Tương tự, việc gán biến không được sử dụng fd cho định danh trống sẽ loại bỏ lỗi biến không được sử dụng. Phiên bản này của chương trình có thể biên dịch.

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 để loại bỏ lỗi import nên được đặt ngay sau các import và có chú thích, vừa để dễ tìm thấy chúng vừa để nhắc nhở dọn dẹp sau này.

Nhập gói để lấy tác dụng phụ

Một import không được sử dụng như fmt hoặc io trong ví dụ trước cuối cùng nên được sử dụng hoặc loại bỏ: các phép gán cho định danh trống xác định mã nguồn đang trong quá trình phát triển. Nhưng đôi khi việc import một gói chỉ vì các tác dụng phụ của nó là hữu ích, mà không có bất kỳ cách sử dụng rõ ràng nào. Ví dụ, trong hàm init của mình, gói net/http/pprof đăng ký các trình xử lý HTTP cung cấp thông tin gỡ lỗi. Gói này có API được export, nhưng hầu hết client chỉ cần việc đăng ký trình xử lý và truy cập dữ liệu thông qua một trang web. Để import gói chỉ vì các tác dụng phụ của nó, hãy đổi tên gói thành định danh trống:

import _ "net/http/pprof"

Dạng import này làm rõ rằng gói đang được import vì các tác dụng phụ của nó, vì không còn cách sử dụng nào khác cho gói: trong tệp này, nó không có tên. (Nếu có tên và chúng ta không sử dụng 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ề interface ở trên, một kiểu không cần khai báo rõ ràng rằng nó triển khai một interface. Thay vào đó, một kiểu triển khai interface chỉ bằng cách triển khai các phương thức của interface đó. Trong thực tế, hầu hết phép chuyển đổi interface là tĩnh và do đó được kiểm tra tại thời điểm biên dịch. Ví dụ, truyền một *os.File vào một hàm mong đợi io.Reader sẽ không biên dịch được trừ khi *os.File triển khai interface io.Reader.

Tuy nhiên, một số kiểm tra interface vẫn diễn ra trong thời gian chạy. Một trường hợp là trong gói encoding/json, gói này định nghĩa một interface Marshaler. Khi bộ mã hóa JSON nhận một giá trị triển khai interface đó, bộ mã hóa gọi phương thức marshal của giá trị để chuyển đổi nó thành JSON thay vì thực hiện phép chuyển đổi tiêu chuẩn. Bộ mã hóa kiểm tra thuộc tính này trong thời gian chạy bằng một phép khẳng định kiểu như sau:

m, ok := val.(json.Marshaler)

Nếu chỉ cần hỏi liệu một kiểu có triển khai một interface hay không mà không thực sự sử dụng chính interface đó, có thể như một phần của việc kiểm tra lỗi, hãy dùng định danh trống để bỏ qua giá trị sau phép khẳng định kiểu:

if _, ok := val.(json.Marshaler); ok {
    fmt.Printf("value %v of type %T implements json.Marshaler\n", val, val)
}

Một nơi tình huống này xuất hiện là khi cần đảm bảo trong phạm vi gói triển khai kiểu đó rằng nó thực sự thỏa mãn interface. Nếu một kiểu — ví dụ, json.RawMessage — cần một biểu diễn JSON tùy chỉnh, nó nên triển khai json.Marshaler, nhưng không có phép chuyển đổi tĩnh nào có thể 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 sẽ hoạt động, nhưng sẽ không sử dụng triển khai tùy chỉnh. Để đảm bảo triển khai là chính xác, có thể sử dụng một khai báo toàn cục với định danh trống trong gói:

var _ json.Marshaler = (*RawMessage)(nil)

Trong khai báo này, phép gán liên quan đến việc chuyển đổi một *RawMessage thành Marshaler yêu cầu rằng *RawMessage triển khai Marshaler, và thuộc tính đó sẽ được kiểm tra tại thời điểm biên dịch. Nếu interface json.Marshaler thay đổi, gói này sẽ không còn biên dịch được và chúng ta sẽ nhận biết rằng cần cập nhật nó.

Việc xuất hiện định danh trống trong cấu trúc này cho biết rằng khai báo chỉ tồn tại để kiểm tra kiểu, không phải để tạo 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 sử dụng khi không có phép chuyển đổi tĩnh nào đã tồn tại trong mã nguồn, đây là một trường hợp hiếm gặp.

Nhúng

Go không cung cấp khái niệm kế thừa lớp theo kiểu thông thường, dựa trên kiểu, nhưng nó có khả năng “mượn” các phần của một cài đặt bằng cách nhúng các kiểu trong một struct hoặc interface.

Việc nhúng interface rất đơn giản. Trước đây chúng ta đã đề cập đến các interface io.Readerio.Writer; sau đâ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)
}

Gói io cũng xuất ra một số interface khác xác định các đối tượng có thể triển khai nhiều phương thức như vậy. Ví dụ, có io.ReadWriter, một interface chứa cả ReadWrite. Chúng ta có thể xác định io.ReadWriter bằng cách liệt kê rõ ràng hai phương thức, nhưng việc nhúng hai interface để tạo thành interface mới sẽ dễ dàng và diễn đạt hơn, như sau:

// ReadWriter là interface kết hợp các interface Reader và Writer.
type ReadWriter interface {
    Reader
    Writer
}

Điều này đúng như vẻ ngoài của nó: Một ReadWriter có thể làm những gì Reader làm những gì Writer làm; nó là hợp của các interface được nhúng. Chỉ các interface mới có thể được nhúng trong interface.

Cùng một ý tưởng cơ bản cũng áp dụng cho struct, nhưng với những hệ quả sâu rộng hơn. Gói bufio có hai kiểu struct, bufio.Readerbufio.Writer, mỗi kiểu dĩ nhiên triển khai các interface tương ứng từ gói io. Và bufio cũng triển khai một bộ đọc/ghi có bộ đệm, bằng cách kết hợp một bộ đọc và một bộ ghi vào một struct duy nhất sử dụng nhúng: nó liệt kê các kiểu bên trong struct nhưng không đặt tên trường cho chúng.

// ReadWriter lưu các con trỏ tới một Reader và một Writer.
// Nó triển khai io.ReadWriter.
type ReadWriter struct {
    *Reader  // *bufio.Reader
    *Writer  // *bufio.Writer
}

Các phần tử được nhúng là con trỏ tới struct và dĩ nhiên phải được khởi tạo để trỏ tới các struct hợp lệ trước khi có thể sử dụng. Struct ReadWriter có thể được viết như sau

type ReadWriter struct {
    reader *Reader
    writer *Writer
}

nhưng khi đó để nâng cấp các phương thức 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 phương thức chuyển tiếp, 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 việc quản lý này. Các phương thức của các kiểu được nhúng được cung cấp miễn phí, điều đó có nghĩa là bufio.ReadWriter không chỉ có các phương thức 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 khiến việc nhúng khác với kế thừa lớp. Khi chúng ta nhúng một kiểu, các phương thức của kiểu đó trở thành phương thức của kiểu bên ngoài, nhưng khi chúng được gọi, receiver của phương thức là kiểu bên trong, không phải kiểu bên ngoài. Trong ví dụ của chúng ta, khi phương thức Read của một bufio.ReadWriter được gọi, nó có chính xác cùng tác dụng như phương thức chuyển tiếp được viết ở trên; receiver là trường reader của ReadWriter, không phải bản thân ReadWriter.

Nhúng cũng có thể là một tiện ích đơ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 giờ đây có các phương thức Print, Printf, Println và các phương thức khác của *log.Logger. Dĩ nhiên chúng ta có thể đặt cho Logger một tên trường, nhưng không cần thiết phải làm vậy. Và giờ đây, sau khi được 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, vì vậy chúng ta có thể khởi tạo nó theo cách thông thường bên trong hàm tạo của Job, như sau,

func NewJob(command string, logger *log.Logger) *Job {
    return &Job{command, logger}
}

hoặc với một literal hợp thành,

job := &Job{command, log.New(os.Stderr, "Job: ", log.Ldate)}

Nếu cần tham chiếu trực tiếp tới một trường được nhúng, tên kiểu của trường, bỏ qua phần định danh gói, sẽ đóng vai trò là tên trường, như đã làm trong phương thức Read của struct ReadWriter của chúng ta. Ở đây, nếu cần truy cập *log.Logger của một biến job kiểu Job, chúng ta sẽ viết job.Logger, điều này sẽ hữu ích nếu muốn tinh chỉnh các phương thức của Logger.

func (job *Job) Printf(format string, args ...interface{}) {
    job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}

Việc nhúng các kiểu tạo 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. Đầu tiên, một trường hoặc phương thức X che khuất bất kỳ mục X nào khác ở phần lồng nhau sâu hơn của kiểu. Nếu log.Logger chứa một trường hoặc phương thức có tên Command, trường Command của Job sẽ chiếm ưu thế so với nó.

Thứ hai, nếu cùng một tên xuất hiện ở cùng một mức lồng nhau, điều đó 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 phương thức khác có 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 bên ngoài định nghĩa kiểu, thì điều đó vẫn hợp lệ. Điều kiện này cung cấp một số bảo vệ trước các thay đổi được thực hiện đối với các kiểu được nhúng từ bên ngoài; sẽ không có vấn đề nếu một trường được thêm vào gây xung đột với một trường khác trong một kiểu con khác nếu không trường nào từng được sử dụng.

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ủ đề lớn và ở đây chỉ có chỗ cho một số điểm nổi bật riêng của Go.

Lập trình đồng thời trong nhiều môi trường trở nên khó khăn do những chi tiết phức tạp cần thiết để triển khai việc truy cập chính xác vào các biến dùng chung. Go khuyến khích một cách tiếp cận khác, trong đó các giá trị dùng chung được truyền qua các channel và trên thực tế không bao giờ được các luồng thực thi riêng biệt chia sẻ một cách chủ động. 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. Theo thiết kế, các cuộc đua dữ liệu không thể xảy ra. Để khuyến khích cách suy nghĩ này, chúng tôi đã rút gọn nó thành một khẩu hiệu:

Đừng giao tiếp bằng cách chia sẻ bộ nhớ; thay vào đó, hãy chia sẻ bộ nhớ bằng cách giao tiếp.

Cách tiếp cận này có thể bị áp dụng quá mức. Chẳng hạn, việc đếm tham chiếu 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 số nguyên. Nhưng với tư cách là một cách tiếp cận cấp cao, sử 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 và chính xác dễ dàng hơn.

Một cách để suy nghĩ về mô hình này là xem xét một chương trình đơn luồng điển hình đang chạy trên một CPU. Nó không cần các nguyên thủy đồng bộ hóa. Bây giờ chạy thêm một thực thể tương tự; nó cũng không cần đồng bộ hóa. Bây giờ cho hai thực thể đó giao tiếp với nhau; nếu việc giao tiếp chính là cơ chế đồng bộ hóa, vẫn không cần thêm cơ chế đồng bộ hóa nào khác. Ví dụ, các đường ống Unix hoàn toàn phù hợp với mô hình này. Mặc dù cách tiếp cận của Go đối với tính đồng thời bắt nguồn từ Communicating Sequential Processes (CSP) của Hoare, nó cũng có thể được xem là một sự tổng quát hóa an toàn kiểu của các pipe Unix.

Goroutine

Chúng được gọi là goroutine vì các thuật ngữ hiện có—thread, coroutine, process, v.v.—mang theo những hàm ý 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 một không gian địa chỉ. Nó nhẹ, chỉ tốn nhiều hơn một chút so với việc cấp phát không gian stack. Và các stack bắt đầu nhỏ, nên chúng rẻ, đồng thời phát triển bằng cách cấp phát (và giải phóng) bộ nhớ heap khi cần thiết.

Các goroutine được ghép kênh trên nhiều thread của hệ điều hành, vì vậy nếu một goroutine bị chặn, chẳng hạn khi chờ I/O, các goroutine khác vẫn tiếp tục chạy. Thiết kế của chúng ẩn đi nhiều sự phức tạp trong việc tạo và quản lý thread.

Thêm từ khóa go trước một lời gọi hàm hoặc phương thức để chạy lời gọi đó trong một goroutine mới. Khi lời gọi hoàn tất, goroutine thoát một cách im lặng. (Hiệu ứng này tương tự ký hiệu & của shell Unix để chạy một lệnh trong nền.)

go list.Sort()  // chạy list.Sort đồng thời; không chờ nó.

Một function literal có thể hữu ích trong một lời gọi goroutine.

func Announce(message string, delay time.Duration) {
    go func() {
        time.Sleep(delay)
        fmt.Println(message)
    }()  // Lưu ý dấu ngoặc đơn - phải gọi hàm.
}

Trong Go, function literal là closure: phần triển khai đảm bảo rằng các biến được hàm tham chiếu đến tồn tại chừng nào chúng còn đang hoạt động.

Những ví dụ này không quá thực tế vì các hàm không có cách nào báo hiệu việc hoàn tất. Để làm điều đó, chúng ta cần các channel.

Kênh

Giống như map, các kênh được cấp phát bằng make, và giá trị nhận được đóng vai trò là tham chiếu đến một cấu trúc dữ liệu bên dưới. Nếu cung cấp một tham số số nguyên tùy chọn, nó sẽ đặt kích thước bộ đệm cho kênh. Giá trị mặc định là không, đối với kênh không có bộ đệm hoặc kênh đồng bộ.

ci := make(chan int)            // kênh số nguyên không có bộ đệm
cj := make(chan int, 0)         // kênh số nguyên không có bộ đệm
cs := make(chan *os.File, 100)  // kênh có bộ đệm chứa các con trỏ đến File

Các kênh không có bộ đệm kết hợp giao tiếp—việc 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 sử dụng kênh. Sau đây là một ví dụ để bắt đầu. Trong phần trước, chúng ta đã khởi chạy một thao tác sắp xếp ở chế độ nền. Một kênh có thể cho phép goroutine khởi chạy chờ thao tác sắp xếp hoàn tất.

c := make(chan int)  // Cấp phát một kênh.
// Bắt đầu thao tác sắp xếp trong một goroutine; khi hoàn tất, gửi tín hiệu trên kênh.
go func() {
    list.Sort()
    c <- 1  // Gửi tín hiệu; giá trị không quan trọng.
}()
doSomethingForAWhile()
<-c   // Chờ thao tác sắp xếp hoàn tất; bỏ qua giá trị đã gửi.

Bên nhận luôn bị chặn cho đến khi có dữ liệu để nhận. Nếu kênh không có bộ đệm, bên gửi bị chặn cho đến khi bên nhận đã nhận được giá trị. Nếu kênh có bộ đệm, bên gửi chỉ bị chặn cho đến khi giá trị được sao chép vào bộ đệm; nếu bộ đệm đầy, điều này có nghĩa là phải chờ cho đến khi một bên nhận nào đó lấy đi một giá trị.

Một kênh có bộ đệm có thể được sử 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 yêu cầu đến được chuyển đến handle, hàm này gửi một giá trị vào kênh, xử lý yêu cầu, sau đó nhận một giá trị từ kênh để chuẩn bị “semaphore” cho consumer tiếp theo. Dung lượng của bộ đệm kênh 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    // Chờ hàng đợi đang hoạt động được giải phóng.
    process(r)  // Có thể mất nhiều thời gian.
    <-sem       // Hoàn tất; cho phép yêu cầu tiếp theo chạy.
}

func Serve(queue chan *Request) {
    for {
        req := <-queue
        go handle(req)  // Không chờ handle hoàn tất.
    }
}

Khi đã có MaxOutstanding trình xử lý đang thực thi process, mọi trình xử lý khác sẽ bị chặn khi cố gửi vào bộ đệm kênh đã đầy, cho đến khi một trong các trình xử lý hiện có hoàn tất và nhận từ bộ đệm.

Tuy nhiên, thiết kế này có một vấn đề: Serve tạo một goroutine mới cho mỗi yêu cầu đến, mặc dù chỉ MaxOutstanding trong số chúng có thể chạy tại bất kỳ thời điểm nào. Kết quả là chương trình có thể tiêu thụ tài nguyên không giới hạn nếu các yêu cầu đế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 các 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, mã này có một lỗi: biến vòng lặp được dùng chung giữa tất cả các goroutine. Xem Go wiki để biết chi tiết.)

Một cách tiếp cận khác quản lý tài nguyên tốt là khởi chạy một số lượng cố định các goroutine handle cùng đọc từ kênh yêu cầu. 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 kênh mà nó sẽ được thông báo để thoát; sau khi khởi chạy các goroutine, nó bị chặn khi nhận từ kênh đó.

func handle(queue chan *Request) {
    for r := range queue {
        process(r)
    }
}

func Serve(clientRequests chan *Request, quit chan bool) {
    // Khởi động các trình xử lý
    for i := 0; i < MaxOutstanding; i++ {
        go handle(clientRequests)
    }
    <-quit  // Chờ được thông báo để thoát.
}

Các 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ị hạng nhất có thể được cấp phát và truyền xung quanh như mọi giá trị khác. Một cách sử dụng phổ biến của thuộc tính này là triển khai việc phân kênh an toàn, song song.

Trong ví dụ ở phần trước, handle là một trình xử lý được lý tưởng hóa cho một yêu cầu nhưng chúng ta chưa định nghĩa kiểu mà nó xử lý. Nếu kiểu đó bao gồm một channel dùng để phản hồi, mỗi client có thể cung cấp đường đi riêng cho câu trả lời. Sau đâ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 yêu cầu để 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)}
// Gửi yêu cầu
clientRequests <- request
// Chờ phản hồi.
fmt.Printf("answer: %d\n", <-request.resultChan)

Ở phía server, hàm xử lý 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 cần làm để biến nó thành hiện thực, nhưng đoạn mã này là một khung cho hệ thống RPC song song, không chặn, có giới hạn tốc độ và không hề có mutex nào xuất hiện.

Song song hóa

Một ứng dụng khác của các ý 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

// Áp dụng phép toán cho v[i], v[i+1] ... đến 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    // báo hiệu rằng phần này đã hoàn thành
}

Chúng ta khởi chạy các phần một cách độc lập trong một vòng lặp, mỗi phần cho 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 rút hết channel sau khi khởi chạy tất cả goroutine.

const numCPU = 4 // số lõi CPU

func (v Vector) DoAll(u Vector) {
    c := make(chan int, numCPU)  // Đệm là tùy chọn nhưng hợp lý.
    for i := 0; i < numCPU; i++ {
        go v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u, c)
    }
    // Rút hết channel.
    for i := 0; i < numCPU; i++ {
        <-c    // chờ một tác vụ hoàn thành
    }
    // Hoàn tất.
}

Thay vì tạo một giá trị hằng cho numCPU, chúng ta có thể hỏi runtime 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()

Cũng có một hàm runtime.GOMAXPROCS, hàm này báo cáo (hoặc thiết lập) số lõi do người dùng chỉ định mà một chương trình Go có thể cho chạy đồng thời. Giá trị mặc định của nó là giá trị của runtime.NumCPU nhưng có thể bị ghi đè bằng cách thiết lập biến môi trường shell có tên tương tự hoặc bằng cách gọi hàm với một số dương. Gọi hàm với số không 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 các khái niệm về 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 hiệu quả trên nhiều CPU. Mặc dù các tính năng concurrency của Go có thể giúp một số vấn đề dễ dàng được cấu trúc thành các phép tính song song, Go là một ngôn ngữ đồng thời, không phải ngôn ngữ song song, và không phải mọi bài toá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, xem bài nói chuyện được trích dẫn trong bài đăng blog này.

Một bộ đệm bị rò rỉ

Các công cụ của lập trình đồng thời thậm chí có thể giúp những ý tưởng không đồng thời dễ biểu đạt hơn. Đây là một ví dụ được rút gọn từ một gói RPC. Goroutine phía máy khách lặp lại việc nhận dữ liệu từ một nguồn nào đó, có thể là mạng. Để tránh việc cấp phát và giải phóng các bộ đệm, nó duy trì một danh sách trống và dùng một channel có bộ đệm để biểu diễn danh sách đó. Nếu channel trống, một bộ đệm mới sẽ được cấp phát. Khi bộ đệm thông báo đã sẵn sàng, nó được gửi đến máy chủ qua serverChan.

var freeList = make(chan *Buffer, 100)
var serverChan = make(chan *Buffer)

func client() {
    for {
        var b *Buffer
        // Lấy một bộ đệm nếu có; cấp phát nếu không có.
        select {
        case b = <-freeList:
            // Đã lấy được một cái; không cần làm gì thêm.
        default:
            // Không có bộ đệm trống, nên cấp phát một cái mới.
            b = new(Buffer)
        }
        load(b)              // Đọc thông báo tiếp theo từ mạng.
        serverChan <- b      // Gửi đến máy chủ.
    }
}

Vòng lặp của máy chủ nhận từng thông báo từ máy khách, xử lý nó, và trả bộ đệm về danh sách trống.

func server() {
    for {
        b := <-serverChan    // Chờ công việc.
        process(b)
        // Tái sử dụng bộ đệm nếu còn chỗ.
        select {
        case freeList <- b:
            // Bộ đệm trong danh sách trống; không cần làm gì thêm.
        default:
            // Danh sách trống đã đầy, cứ tiếp tục.
        }
    }
}

Máy khách cố gắng lấy một bộ đệm từ freeList; nếu không có bộ đệm nào khả dụng, nó cấp phát một bộ đệm mới. Việc máy chủ gửi đến freeList sẽ đưa b trở lại danh sách trống, trừ khi danh sách đã đầy; trong trường hợp đó, bộ đệm bị loại bỏ để được thu hồi bởi bộ gom rác. (Các mệnh đề default trong các câu lệnh select được 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ờ bị chặn.) Cách triển khai này xây dựng một danh sách trống dạng xô bị rò rỉ chỉ trong vài dòng, dựa vào channel có bộ đệm và bộ gom rác để quản lý việc theo dõi.

Lỗi

Các hàm thư viện thường phải trả về một dạng chỉ báo lỗi nào đó cho bên gọi. Như đã đề cập trước đó, việc trả về nhiều giá trị của Go giúp dễ dàng trả về mô tả lỗi chi tiết cùng với giá trị trả về thông thường. Đây là một phong cách tốt khi sử 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 tích hợp đơn giản.

type error interface {
    Error() string
}

Người viết thư viện có thể tự do triển khai interface này với một mô hình phong phú hơn ở bên trong, cho phép không chỉ xem lỗi mà còn cung cấp một số ngữ cảnh. Như đã đề cập, bên cạnh giá trị trả về *os.File thông thường, 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 ghi lại một lỗi cùng thao tác và
// đường dẫn tệp đã gây ra lỗi đó.
type PathError struct {
    Op string    // "open", "unlink", v.v.
    Path string  // Tệp liên quan.
    Err error    // Được trả về bởi lời gọi hệ thống.
}

func (e *PathError) Error() string {
    return e.Op + " " + e.Path + ": " + e.Err.Error()
}

PathError's Error tạo ra một chuỗi như sau:

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 của hệ điều hành mà nó gây ra, vẫn hữu ích ngay cả khi được in cách xa lời gọi đã gây ra nó; nó cung cấp nhiều thông tin hơn so với chuỗi đơ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 cho thao tác hoặc gói đã tạo ra lỗi. Ví dụ, trong gói image, biểu diễn chuỗi cho lỗi giải mã do một định dạng không xác định là "image: unknown format".

Các bên gọi quan tâm đến chi tiết lỗi chính xác có thể sử dụng chuyển đổi kiểu hoặc khẳng định kiểu để tìm các lỗi cụ thể và trích xuất chi tiết. Với PathErrors, điều này có thể bao gồm việc kiểm tra trường Err nội bộ để tìm các lỗi có thể khôi phục.

for try := 0; try < 2; try++ {
    file, err = os.Create(filename)
    if err == nil {
        return
    }
    if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
        deleteTempFiles()  // Khôi phục một phần dung lượng.
        continue
    }
    return
}

Câu lệnh if thứ hai ở đây là một khẳng định kiểu khác. Nếu thất bại, ok sẽ là false, và e sẽ là nil. Nếu thành công, ok sẽ là true, nghĩa là lỗi có kiểu *os.PathError, và khi đó e cũng có kiểu đó, cho phép chúng ta 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 bên gọi là trả về một error như một giá trị trả về bổ sung. Phương thức Read kinh điển là một ví dụ quen thuộc; nó trả về số byte và một error. Nhưng nếu lỗi không thể khôi phục thì sao? Đôi khi chương trình đơn giản là không thể tiếp tục.

Với mục đích này, có một hàm dựng sẵn panic mà về cơ bản tạo ra một lỗi thời gian chạy khiến chương trình dừng lại (nhưng xem phần tiếp theo). Hàm này nhận một đối số duy nhất thuộc kiểu bất kỳ—thường là một chuỗi—để được in ra khi chương trình kết thúc. Đây cũng là một cách cho biết rằng một điều không thể xảy ra đã xảy ra, chẳng hạn như thoát khỏi một vòng lặp vô hạn.

// Một cài đặt thử nghiệm của căn bậc ba sử dụng phương pháp Newton.
func CubeRoot(x float64) float64 {
    z := x/3   // Giá trị ban đầu tùy ý
    for i := 0; i < 1e6; i++ {
        prevz := z
        z -= (z*z*z-x) / (3*z*z)
        if veryClose(z, prevz) {
            return z
        }
    }
    // Một triệu lần lặp vẫn chưa hội tụ; có điều gì đó không đúng.
    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 xử lý vòng quanh, luôn tốt hơn nếu để mọi thứ tiếp tục chạy thay vì khiến toàn bộ chương trình dừng lại. Một trường hợp ngoại lệ 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ý khi gọi panic, có thể nói như vậy.

var user = os.Getenv("USER")

func init() {
    if user == "" {
        panic("no value for $USER")
    }
}

Recover

Khi panic được gọi, bao gồm cả trường hợp ngầm định đối với các lỗi thời gian chạy như truy cập phần tử của slice vượt ngoài giới hạn hoặc kiểm tra kiểu thất bại, nó ngay lập tức dừng việc thực thi của hàm hiện tại và bắt đầu tháo ngăn xếp của goroutine, chạy mọi hàm deferred trên đường đi. Nếu quá trình tháo ngăn xếp này đến đỉnh ngăn xếp của goroutine, chương trình sẽ kết thúc. Tuy nhiên, có thể sử dụng hàm dựng sẵn recover để giành lại quyền điều khiển goroutine và tiếp tục thực thi bình thường.

Một lời gọi tới recover dừng quá trình tháo ngăn xếp và trả về đối số đã truyền cho panic. Vì đoạn mã duy nhất chạy trong khi tháo ngăn xếp nằm 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 bị lỗi bên trong máy chủ mà không làm dừng các goroutine khác đang thực thi.

func server(workChan <-chan *Work) {
    for work := range workChan {
        go safelyDo(work)
    }
}

func safelyDo(work *Work) {
    defer func() {
        if err := recover(); err != nil {
            log.Println("work failed:", err)
        }
    }()
    do(work)
}

Trong ví dụ này, nếu do(work) gọi panic, kết quả sẽ được ghi log và goroutine sẽ thoát sạch sẽ mà không ảnh hưởng đế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 tình huống này.

recover luôn trả về nil trừ khi được gọi trực tiếp từ một hàm deferred, mã deferred có thể gọi các thủ tục thư viện mà bản thân chúng sử dụng panicrecover mà không thất bại. Ví dụ, hàm deferred trong safelyDo có thể gọi một hàm ghi log trước khi gọi recover, và mã ghi log đó sẽ chạy mà không bị ảnh hưởng bởi trạng thái panic.

Với mẫu khôi phục đã thiết lập, hàm do (và mọi thứ mà nó gọi) có thể thoát khỏi mọi tình huống xấu một cách sạch sẽ bằng cách gọi panic. Chúng ta có thể dùng ý tưởng đó để đơn giản hóa việc xử lý lỗi trong phần mềm phức tạp. Hãy xem một phiên bản lý tưởng hóa của gói regexp, gó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ộ. Dưới đây là định nghĩa của Error, một phương thức error, và hàm Compile.

// Error là kiểu của một lỗi phân tích cú pháp; nó thỏa mãn interface error.
type Error string
func (e Error) Error() string {
    return string(e)
}

// error là một phương thức của *Regexp báo cáo lỗi phân tích cú pháp bằng
// cách gọi panic với một Error.
func (regexp *Regexp) error(err string) {
    panic(Error(err))
}

// Compile trả về biểu diễn đã phân tích cú pháp của biểu thức chính quy.
func Compile(str string) (regexp *Regexp, err error) {
    regexp = new(Regexp)
    // doParse sẽ gọi panic nếu có lỗi phân tích cú pháp.
    defer func() {
        if e := recover(); e != nil {
            regexp = nil    // Xóa giá trị trả về.
            err = e.(Error) // Sẽ gọi panic lại nếu không phải lỗi phân tích cú pháp.
        }
    }()
    return regexp.doParse(str), nil
}

Nếu doParse gọi panic, khối phục hồi 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 xác nhận rằng nó có kiểu cục bộ Error. Nếu không phải, phép xác nhận kiểu sẽ thất bại, gây ra một lỗi thời gian chạy tiếp tục việc tháo ngăn xếp như thể không có gì đã ngắt quãng nó. Việc kiểm tra này có nghĩa là nếu xảy ra điều gì đó không mong đợi, chẳng hạn như truy cập phần tử vượt ngoài giới hạn, mã sẽ thất bại dù chúng ta đang dùng panicrecover để xử lý các lỗi phân tích cú pháp.

Khi việc xử lý lỗi đã được đặt vào, phương thức error (vì nó là một phương thức gắn với một kiểu, việc nó có cùng tên với kiểu dựng sẵn error là hoàn toàn ổn, thậm chí tự nhiên) giúp việc báo cáo lỗi phân tích cú pháp trở nên dễ dàng mà không cần lo lắng về việc tháo ngăn xếp phân tích cú pháp theo cách thủ công:

if pos == 0 {
    re.error("'*' illegal at start of expression")
}

Dù mẫu này hữu ích, nó chỉ nên được sử dụng bên trong một gói. 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 đưa các panics ra cho phía máy khách. Đây là một quy tắc tốt nên 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ả lỗi ban đầu và lỗi mới sẽ được trình bày trong báo cáo sự cố, vì vậy nguyên nhân gốc của vấn đề vẫn sẽ hiển thị. Do đó, cách tiếp cận re-panic đơn giản này thường là đủ—sau cùng thì đó vẫn là một sự cố—nhưng nếu bạn muốn chỉ hiển thị giá trị ban đầu, bạn có thể viết thêm một chút mã để lọc các vấn đề không mong muốn và re-panic với lỗi ban đầu. Phần này được để lại như một bài tập cho người đọc.

Một máy chủ web

Hãy kết thúc bằng một chương trình Go hoàn chỉnh, một máy chủ web. Chương trình này thực ra là một dạng máy chủ web chuyển tiếp. Google cung cấp một dịch vụ tại chart.apis.google.com thực hiện việc định dạng dữ liệu tự động thành biểu đồ và đồ thị. Tuy nhiên, rất khó sử dụng tương tác, vì bạn cần đặt dữ liệu vào URL dưới dạng truy vấn. Chương trình ở đây cung cấp một giao diện thuận tiện hơn cho một dạng dữ liệu: với một đoạn văn bản ngắn, nó gọi máy chủ biểu đồ để tạo ra mã QR, một ma trận các ô vuông mã hóa văn bản. Hình ảnh đó có thể được chụp bằng camera điện thoại của bạn và được diễn giải thành, ví dụ như một URL, giúp bạn không phải nhập 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 tiếp theo.

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 sẽ khá dễ theo dõi. Một cờ thiết lập cổng HTTP mặc định cho máy chủ của chúng ta. Biến template templ là nơi phần thú vị diễn ra. Nó xây dựng một template HTML sẽ được máy chủ thực thi để hiển thị trang; chúng ta sẽ nói thêm về điều đó sau một chút.

Hàm main phân tích các cờ và, sử dụng cơ chế chúng ta đã nói ở trên, liên kết hàm QR với đường dẫn gốc của máy chủ. Sau đó http.ListenAndServe được gọi để khởi động máy chủ; nó chặn trong khi máy chủ đang chạy.

QR chỉ nhận yêu cầu, trong đó chứa dữ liệu biểu mẫu, và thực thi template trên dữ liệu trong giá trị biểu mẫu có tên s.

Gói template html/template rất mạnh; chương trình này chỉ chạm đến một phần khả năng của nó. Về cơ bản, nó viết lại một phần văn bản HTML ngay khi chạy bằng cách thay thế các phần tử được tạo từ các mục dữ liệu truyền vào templ.Execute, trong trường hợp này là giá trị biểu mẫu. Bên trong văn bản template (templateStr), các phần được bao bởi hai dấu ngoặc nhọn biểu thị các hành động template. Phần từ {{if .}} đến {{end}} chỉ được 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ông rỗng. Nghĩa là, khi chuỗi rỗng, phần này của template bị loại bỏ.

Hai đoạn {{.}} có nghĩa là hiển thị dữ liệu được đưa vào template—chuỗi truy vấn—trên trang web. Gói template HTML tự động cung cấp cơ chế thoát ký tự thích hợp để văn bản an toàn khi hiển thị.

Phần còn lại của chuỗi template chỉ là HTML được hiển thị khi trang tải. Nếu phần giải thích này quá ngắn gọn, hãy xem tài liệu của gói template để có phần thảo luận chi tiết hơn.

Vậy là xong: một máy chủ web hữu ích chỉ với vài dòng mã cùng một ít văn bản HTML điều khiển bởi dữ liệu. Go đủ mạnh để thực hiện nhiều việc chỉ trong vài dòng.