Go không có class. Nó có struct — một tập hợp trường — và method gắn vào kiểu. Hình dung một struct như cái khay đựng nhiều ngăn có dán nhãn: mỗi ngăn một trường, và bạn điền đồ vào theo nhãn chứ không theo chỗ. Bài này về nửa đầu — cái khay.

Bốn cách khởi tạo

a := Diem{1, 2}        // theo vị trí
b := Diem{X: 1, Y: 2}  // theo tên
c := Diem{X: 1}        // thiếu trường -> zero value
var d Diem             // tất cả zero value
  vị trí={1 2}  tên={1 2}  thiếu trường={1 0}  zero={0 0}

Luôn dùng dạng theo tên trong mã thật. Dạng theo vị trí gãy im lặng khi ai đó thêm hoặc đổi thứ tự trường — mã vẫn biên dịch, giá trị vào nhầm chỗ, y như ai đó sắp lại các ngăn mà bạn vẫn "bỏ vào ngăn thứ ba". Dạng theo tên thì thêm trường mới chỉ nhận zero value, không hỏng gì.

Đây cũng là lý do thư viện Go hay khai trường không xuất khẩu ở cuối struct: nó buộc người ngoài package phải dùng dạng theo tên.

Struct so sánh được

  Diem{1,2} == Diem{1,2}  ->  true

Không cần viết equals, không cần hashCode. Go so từng trường một, và dùng làm khoá map được luôn.

Giới hạn đã nói ở bài 5: chỉ đúng khi mọi trường so sánh được. Struct chứa slice, map hay function thì == là lỗi biên dịch — và trình biên dịch báo ngay, không phải chờ tới lúc chạy.

Struct rỗng chiếm 0 byte

  struct{}{}        = 0 byte
  [1000]struct{}    = 0 byte

Một nghìn phần tử, không byte nào — một cái khay không có ngăn nào thì chẳng chiếm chỗ. Đây không phải trò vui, nó có hai công dụng thật:

Set: map[string]struct{} như bài 10 đã nói, không tốn byte nào cho giá trị.

Channel tín hiệu: chan struct{} khi bạn chỉ cần báo "có chuyện xảy ra" chứ không truyền dữ liệu. Bạn sẽ gặp mẫu này rất nhiều ở chặng đồng thời.

Thứ tự trường có giá

type Xau struct { A bool;  B int64; C bool }   // 24 byte
type Tot struct { B int64; A, C bool        }  // 16 byte
  {bool, int64, bool} = 24 byte
  {int64, bool, bool} = 16 byte

Cùng ba trường, cùng kiểu, chênh 33%.

Nguyên nhân là căn lề: int64 phải nằm ở địa chỉ chia hết cho 8. Ở struct đầu, sau A bool (1 byte) phải chèn 7 byte đệm để B căn đúng, rồi C lại kéo theo 7 byte đệm nữa. Giống mấy ngăn khay phải khớp vào lưới ô cố định: đặt một món nhỏ xíu ngay trước một món to là bỏ phí cả khoảng hở.

Quy tắc: khai trường từ lớn xuống nhỏ. Với struct dùng vài cái thì không đáng bận tâm; với struct nằm trong slice hàng triệu phần tử thì 33% là thật.

Kiểm bằng unsafe.Sizeof, hoặc công cụ fieldalignment trong golang.org/x/tools.

Nhúng struct

type CoSo struct{ ID int }
type Nguoi struct {
	CoSo          // NHÚNG, không có tên trường
	Ten string
}

Trường của struct nhúng được thăng cấp lên ngoài: n.ID chạy được, không cần n.CoSo.ID.

Đây không phải kế thừa — chỉ là đường tắt cú pháp. Nguoi không phải là CoSo, và không có đa hình ở đây. Bài 15 sẽ nói kỹ, cùng lý do vì sao Go chọn cách này.

Trường không xuất khẩu

type Nguoi struct {
	Ten    string   // chữ hoa: ngoài package thấy
	diachi string   // chữ thường: chỉ trong package
}

Đây là toàn bộ cơ chế đóng gói của Go — không có private, protected, public. Chi tiết ở bài 19.

Hệ quả cần biết ngay: encoding/json và mọi thư viện dùng phản chiếu chỉ thấy trường chữ hoa. Trường viết thường sẽ bị bỏ qua khi marshal, im lặng. Đây là lỗi rất hay gặp và sẽ gặp lại ở bài 45.

Tag: siêu dữ liệu cho phản chiếu

type Nguoi struct {
	Ten  string `json:"ten"`
	Tuoi int    `json:"tuoi,omitempty"`
}

Chuỗi sau tên trường là tag, và trình biên dịch không quan tâm nội dung — chỉ thư viện đọc nó qua phản chiếu. Sai chính tả trong tag không phải lỗi biên dịch, nên nó im lặng không có tác dụng.

Chú ý phải dùng dấu nháy ngược, và không có khoảng trắng quanh dấu hai chấm. json: "ten" (có khoảng trắng) là tag hợp lệ về cú pháp nhưng encoding/json không hiểu.

Nếu chỉ thử một thứ sau bài này, đặt hai cái khay cạnh nhau trong ba mươi giây:

type A struct{ X bool; Y int64; Z bool }
type B struct{ Y int64; X, Z bool }
fmt.Println(unsafe.Sizeof(A{}), unsafe.Sizeof(B{}))

In ra 24 16. Ba mươi giây, và nếu dự án của bạn có struct nằm trong slice lớn, đây là cách giảm bộ nhớ rẻ nhất bạn từng gặp.

Mẫu số chung

Căn lề bộ nhớ là một sự thật phần cứng mà ngôn ngữ nào bày struct ra bộ nhớ cũng phải trả, không phải nét riêng của Go: CPU đọc theo từ đã căn, nên mỗi trường phải nằm trên một ranh giới, và trộn các kích thước thì để lại khoảng đệm — struct của C/C++ hành xử y hệt, C# cũng vậy, còn Rust đi xa hơn: nó tự sắp lại trường để giảm đệm (trong khi C và Go giữ nguyên thứ tự bạn khai). Ý sâu là: bố cục dữ liệu là một cần gạt hiệu năng vô hình trong mã nguồn — cùng những trường ấy tốn số byte khác nhau tuỳ cách xếp, xếp lớn-xuống-nhỏ thì khít lại, và nó chỉ cắn ở quy mô lớn — một struct dùng vài lần thì không sao, một triệu cái trong slice thì 33% là thật.

Điều thứ hai: gọi trường theo tên, đừng dựa vào vị trí. Khởi tạo theo vị trí gãy im lặng ngay khi ai đó sắp lại khai báo — đúng cái mong manh mà bài về thứ tự nhánh select và bài về thứ tự trường API đã cảnh báo — còn khởi tạo theo tên thì vững và tự mô tả, chính là lý do Python có tham số từ khoá, Kotlin có named param, và mẫu builder tồn tại. Và để ý cả một họ lỗi im lặng ở đây: literal theo vị trí gãy lặng lẽ, tag gõ sai bị bỏ qua, trường viết thường bị JSON ngó lơ — bản năng cần có là biết công cụ nào của mình hỏng-ồn-ào và cái nào hỏng-lặng-lẽ, rồi ưu tiên những dạng hỏng-ồn-ào.

Ngày mai: method và receiver — method set quyết định kiểu nào thoả mãn interface nào.