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.

Bài tập làm thử

Bài 1 (đọc hiểu). Cho các khởi tạo sau của type Diem struct { X, Y int }, in ra giá trị mỗi biến:

a := Diem{1, 2}
c := Diem{X: 1}
var d Diem
Đáp án

a = {1 2} (khởi tạo theo vị trí, đủ hai trường). c = {1 0} (khởi tạo theo tên, trường Y thiếu nên nhận zero value là 0). d = {0 0} (khai biến không khởi tạo, mọi trường nhận zero value).

Bài 2 (sửa lỗi). Đội phát triển thêm trường mới Z int vào giữa struct Diem (đặt trước Y). Đoạn mã khởi tạo theo vị trí sau đó bị sai giá trị một cách âm thầm (không có lỗi biên dịch). Giải thích lỗi và sửa mã gọi cho an toàn với thay đổi tương lai:

type Diem struct { X, Z, Y int } // Z được thêm giữa X và Y

diem := Diem{1, 2} // ý định: X=1, Y=2
Đáp án

Vì Diem{1, 2} là khởi tạo theo vị trí, giá trị 2 giờ gán vào trường thứ hai là Z chứ không phải Y như ý định ban đầu — kết quả là {X:1, Z:2, Y:0}, sai hoàn toàn nhưng vẫn biên dịch được (không đủ trường thì các trường còn thiếu nhận zero value, ở đây do struct có 3 trường mà literal chỉ có 2 giá trị nên thực ra đoạn này sẽ lỗi biên dịch "too few values" — nhưng nếu literal đủ số lượng, ví dụ Diem{1, 0, 2} viết nhầm thứ tự, giá trị sẽ sai lặng lẽ). Đây đúng là lỗi "gãy im lặng khi ai đó thêm hoặc đổi thứ tự trường" mà bài viết cảnh báo. Sửa bằng khởi tạo theo tên, luôn an toàn với thay đổi thứ tự/thêm trường:

diem := Diem{X: 1, Y: 2}

Bài 3 (vận dụng — code Go). Viết chương trình Go đo và so sánh kích thước bộ nhớ của hai struct sau, in ra kết quả, và cho biết struct nào nhỏ hơn cùng lý do:

type Xau struct { A bool;  B int64; C bool }
type Tot struct { B int64; A, C bool        }
Đáp án
package main

import (
	"fmt"
	"unsafe"
)

type Xau struct {
	A bool
	B int64
	C bool
}

type Tot struct {
	B int64
	A, C bool
}

func main() {
	fmt.Println(unsafe.Sizeof(Xau{}), unsafe.Sizeof(Tot{}))
}

In ra 24 16. Xau (24 byte) tốn nhiều đệm hơn vì int64 (B) phải nằm ở địa chỉ chia hết cho 8 — sau A bool (1 byte) phải chèn 7 byte đệm để B căn đúng, rồi sau B lại cần đệm thêm cho C. Tot (16 byte) khai trường lớn (int64) trước, hai trường bool nhỏ xếp liền sau, gần như không lãng phí đệm. Quy tắc: khai trường từ lớn xuống nhỏ.

Bài 4 (bẫy/đánh đổi). Struct Nguoi sau có một trường không được encoding/json mã hoá ra JSON dù có tag đầy đủ. Tìm lỗi:

type Nguoi struct {
	Ten     string `json:"ten"`
	diaChi  string `json:"diaChi"`
}
Đáp án

diaChi viết thường (chữ cái đầu thường) nên là trường không xuất khẩu (chỉ package hiện tại thấy được). encoding/json và mọi thư viện dùng phản chiếu (reflection) chỉ thấy trường chữ hoa — trường diaChi sẽ bị bỏ qua khi marshal/unmarshal hoàn toàn im lặng, dù có khai tag json:"diaChi". Sửa bằng cách viết hoa chữ cái đầu để xuất khẩu trường:

type Nguoi struct {
	Ten    string `json:"ten"`
	DiaChi string `json:"diaChi"`
}

Bài 5 (đọc hiểu/khái niệm nhúng). Với đoạn mã sau, giải thích tại sao n.ID biên dịch được, và tại sao đây KHÔNG phải là kế thừa (inheritance) theo nghĩa hướng đối tượng cổ điển:

type CoSo struct{ ID int }
type Nguoi struct {
	CoSo
	Ten string
}

var n Nguoi
n.ID = 5
Đáp án

n.ID biên dịch được vì CoSo được nhúng (embedding) vào Nguoi — không khai tên trường tường minh, chỉ khai tên kiểu. Trường của struct nhúng được "thăng cấp" (promoted) lên struct ngoài, nên n.ID là cách viết tắt của n.CoSo.ID. Đây không phải kế thừa vì Nguoi không phải là (is-a) CoSo theo nghĩa đa hình — không thể gán một giá trị Nguoi vào biến kiểu CoSo và mong đợi ràng buộc động (dynamic dispatch) như OOP cổ điển; nhúng chỉ đơn thuần là một struct chứa một struct khác làm trường vô danh, kèm đường tắt cú pháp để truy cập trường/method của nó mà không cần viết tên trường trung gian.