Struct là cách Go nhóm nhiều giá trị có liên quan thành một kiểu dữ liệu duy nhất.
Ví dụ, thay vì quản lý riêng lẻ tên, họ và tuổi của một người, chúng ta có thể gom chúng vào một kiểu Person:
type Person struct {
FirstName string
LastName string
Age int
}
Mỗi thành phần bên trong struct được gọi là một field. Mỗi field có tên và kiểu dữ liệu riêng.
Khi nhiều field liền nhau có cùng kiểu, Go cho phép viết gọn:
type Person struct {
FirstName, LastName string
Age int
}
Hai cách viết hoàn toàn tương đương. Cách thứ hai chỉ giúp phần định nghĩa ngắn hơn.
Khai báo struct và zero value
Sau khi định nghĩa Person, chúng ta có thể khai báo biến thuộc kiểu này giống như bất kỳ kiểu dữ liệu nào khác:
func main() {
var p Person
fmt.Println(p)
}
Kết quả:
{ 0}Thoạt nhìn, output này có thể hơi khó hiểu. Thực tế, p đã được khởi tạo bằng zero value của Person.
Zero value của một struct được tạo bằng cách lấy zero value của từng field:
FirstNamelàstringnên có giá trị""LastNamelàstringnên có giá trị""Agelàintnên có giá trị0
Vì hai chuỗi rỗng không hiển thị ký tự nào, chúng ta chỉ nhìn thấy số 0 ở cuối.
Đây là một đặc điểm quan trọng của Go: biến vừa được khai báo luôn có một giá trị hợp lệ. Không tồn tại trạng thái “bộ nhớ rác chưa được khởi tạo” như trong một số ngôn ngữ cấp thấp.
Khởi tạo bằng struct literal
Để tạo một Person với dữ liệu cụ thể, chúng ta sử dụng struct literal:
func main() {
p := Person{
FirstName: "Bee",
LastName: "Tool Dev",
Age: 22,
}
fmt.Println(p)
}
Kết quả:
{Bee Tool Dev 22}Cách viết này chỉ rõ giá trị nào thuộc về field nào, nên code dễ đọc và ít phụ thuộc vào thứ tự khai báo field.
Khi struct literal được viết trên nhiều dòng, dấu phẩy ở phần tử cuối cùng là bắt buộc:
p := Person{
FirstName: "Bee",
LastName: "Tool Dev",
Age: 22,
}
Thiếu dấu phẩy sau 22, chương trình sẽ không compile. Nguyên nhân liên quan đến cơ chế tự động chèn dấu chấm phẩy của Go ở cuối dòng.
Không cần khởi tạo tất cả field
Struct literal có tên field không yêu cầu chúng ta cung cấp đầy đủ mọi giá trị:
func main() {
p := Person{
FirstName: "Tony",
LastName: "Stark",
}
fmt.Println(p)
}
Kết quả:
{Tony Stark 0}Field Age không được cung cấp nên giữ nguyên zero value là 0.
Cách khởi tạo một phần này khá phổ biến trong Go, đặc biệt khi struct có nhiều field tùy chọn hoặc đã có zero value mang ý nghĩa hợp lệ.
Positional struct literal
Go cũng cho phép bỏ tên field và cung cấp các giá trị theo đúng thứ tự khai báo:
p := Person{"Bruce", "Wayne", 40}
Giá trị đầu tiên được gán cho FirstName, giá trị thứ hai cho LastName, và giá trị cuối cùng cho Age.
Tuy nhiên, khi sử dụng cách này, chúng ta phải cung cấp giá trị cho tất cả field:
p := Person{"Bruce", "Wayne"}
Đoạn code trên không compile:
too few values in struct literal of type PersonTrong phần lớn code production, nên ưu tiên struct literal có tên field:
p := Person{
FirstName: "Bruce",
LastName: "Wayne",
Age: 40,
}
Cách viết này có ba lợi ích:
- Dễ đọc hơn vì mỗi giá trị đi kèm tên field.
- Không bị ảnh hưởng nếu thứ tự field trong struct thay đổi.
- Ít gặp lỗi khi struct được bổ sung field mới.
Positional literal thường chỉ phù hợp với những struct rất nhỏ, ổn định và được sử dụng trong phạm vi hẹp, chẳng hạn một số test case.
Anonymous struct
Không phải struct nào cũng cần được đặt tên.
Go cho phép định nghĩa một kiểu struct ngay tại nơi sử dụng:
func main() {
p := struct {
Name string
}{
Name: "Golang",
}
fmt.Println(p)
}
Kết quả:
{Golang}Kiểu struct này được gọi là anonymous struct, hay struct vô danh.
Anonymous struct phù hợp với những nhóm dữ liệu:
- Chỉ được sử dụng tại một vị trí.
- Không cần chia sẻ giữa nhiều hàm hoặc package.
- Không đủ quan trọng để tạo thành một named type riêng.
Một trong những nơi anonymous struct xuất hiện nhiều nhất là table-driven test. Chúng ta sẽ quay lại trường hợp này ở cuối bài.
Truy cập và thay đổi field
Field của struct được truy cập bằng dấu chấm:
func main() {
p := Person{
FirstName: "Duke",
LastName: "Nguyen",
Age: 22,
}
fmt.Println(p.FirstName)
}
Kết quả:
DukeField cũng có thể được cập nhật trực tiếp:
p.Age = 23Sau câu lệnh này, giá trị Age bên trong p trở thành 23.
Truy cập field thông qua pointer
Chúng ta có thể lấy địa chỉ của một struct bằng toán tử &:
func main() {
p := Person{
FirstName: "Duke",
LastName: "Nguyen",
Age: 22,
}
ptr := &p
fmt.Println(ptr.FirstName)
}
Về mặt đầy đủ, để truy cập field thông qua pointer, chúng ta có thể viết:
fmt.Println((*ptr).FirstName)
Nhưng Go tự động dereference pointer khi sử dụng field selector, vì vậy đoạn sau có cùng ý nghĩa:
fmt.Println(ptr.FirstName)
Cơ chế này giúp code làm việc với pointer tới struct tự nhiên hơn, đặc biệt khi chúng ta bắt đầu sử dụng method với pointer receiver.
Khởi tạo struct bằng new
Hàm built-in new tạo một giá trị đã được zero hóa và trả về pointer tới giá trị đó:
func main() {
p := new(Person)
p.FirstName = "Duke"
p.LastName = "Nguyen"
p.Age = 22
fmt.Println(p)
}
Kết quả:
&{Duke Nguyen 22}Dấu & trong output cho biết p là pointer tới Person, không phải một giá trị Person trực tiếp.
Với struct, hai cách sau gần như tương đương:
p1 := new(Person)
p2 := &Person{}
Cả hai đều tạo một Person mang zero value và trả về *Person.
Trong thực tế, &Person{} thường được sử dụng nhiều hơn vì nó cho phép khởi tạo field ngay lập tức:
p := &Person{
FirstName: "Duke",
Age: 22,
}
So sánh struct
Hai struct có thể được so sánh bằng == khi tất cả field của chúng đều có thể so sánh được:
func main() {
p1 := Person{
FirstName: "Bruce",
LastName: "Wayne",
Age: 40,
}
p2 := Person{
FirstName: "Bruce",
LastName: "Wayne",
Age: 40,
}
fmt.Println(p1 == p2)
}
Kết quả:
trueGo lần lượt so sánh từng field tương ứng. Chỉ cần một field khác nhau, kết quả sẽ là false.
Khi nào struct không thể so sánh?
Một struct chỉ có thể sử dụng == khi tất cả field của nó đều thuộc kiểu có thể so sánh.
Các kiểu thường có thể so sánh gồm:
- Boolean
- Number
- String
- Pointer
- Channel
- Interface, nếu giá trị động bên trong có thể so sánh
- Array có phần tử có thể so sánh
- Struct có toàn bộ field có thể so sánh
Ngược lại, slice, map và function không thể được so sánh trực tiếp bằng ==.
Ví dụ:
type Playlist struct {
Name string
Songs []string
}
Vì Playlist chứa một field []string, toàn bộ struct trở thành kiểu không thể so sánh:
func main() {
a := Playlist{
Name: "Road Trip",
Songs: []string{"song1"},
}
b := Playlist{
Name: "Road Trip",
Songs: []string{"song1"},
}
fmt.Println(a == b)
}
Chương trình không compile:
invalid operation: a == b
struct containing []string cannot be compared
Đây là lỗi compile-time, không phải lỗi chỉ xuất hiện khi chương trình đang chạy.
Nếu cần so sánh sâu các struct có chứa slice hoặc map, trong code test có thể sử dụng package go-cmp:
cmp.Equal(a, b)reflect.DeepEqual cũng có thể làm việc này, nhưng cần cẩn thận với những khác biệt như nil slice và empty slice:
var a []string = nil
b := []string{}
fmt.Println(reflect.DeepEqual(a, b)) // false
Một hệ quả khác là struct chứa field không thể so sánh cũng không thể được sử dụng làm key của map.
Exported và unexported field
Go sử dụng chữ cái đầu tiên để xác định phạm vi truy cập:
- Tên bắt đầu bằng chữ hoa là exported.
- Tên bắt đầu bằng chữ thường là unexported.
Ví dụ:
type Person struct {
FirstName string
LastName string
Age int
zipCode string
}
Các package khác có thể truy cập:
p.FirstName
p.LastName
p.Age
Nhưng không thể truy cập:
p.zipCode
Quy tắc này cũng áp dụng cho tên kiểu:
type person struct {
Name string
}
Vì person bắt đầu bằng chữ thường, package khác không thể tham chiếu trực tiếp đến kiểu này.
Đây không chỉ là cơ chế kiểm soát truy cập. Nhiều package dựa vào reflection, chẳng hạn encoding/json, chỉ có thể đọc và ghi các field exported.
Embedding và composition
Go không cung cấp inheritance theo mô hình class truyền thống. Thay vào đó, Go thường xây dựng kiểu dữ liệu lớn hơn bằng composition.
Một cách composition đặc biệt trong Go là embedding:
type Person struct {
FirstName string
LastName string
Age int
}
type SuperHero struct {
Person
Alias string
}
Trong SuperHero, field Person không có tên field riêng. Đây được gọi là embedded field.
Chúng ta có thể truy cập các field của Person trực tiếp từ SuperHero:
func main() {
s := SuperHero{}
s.FirstName = "Bruce"
s.LastName = "Wayne"
s.Age = 40
s.Alias = "Batman"
fmt.Println(s)
}
Kết quả:
{{Bruce Wayne 40} Batman}Các field như FirstName không thực sự được chuyển ra ngoài Person. Chúng chỉ được promote, nghĩa là Go cho phép sử dụng đường dẫn rút gọn:
s.FirstName
Thay vì phải viết đầy đủ:
s.Person.FirstName
Cả hai cách đều truy cập cùng một field.
Embedding khác gì field có tên?
Chúng ta cũng có thể sử dụng composition thông thường:
type SuperHero struct {
Person Person
Alias string
}
Khi đó, field không được promote và phải truy cập thông qua tên field:
s.Person.FirstName
Hai thiết kế thể hiện hai ý định khác nhau.
Embedding phù hợp khi kiểu bên ngoài muốn làm cho hành vi hoặc field của kiểu được nhúng trở thành một phần thuận tiện trong API của mình.
Field có tên phù hợp khi muốn thể hiện mối quan hệ rõ ràng:
superHero.Person.FirstName
Embedding không biến SuperHero thành một subtype của Person. Đây vẫn là composition, không phải inheritance.
Method promotion
Embedding không chỉ áp dụng cho field mà còn áp dụng cho method.
Giả sử Person có method:
func (p Person) FullName() string {
return p.FirstName + " " + p.LastName
}
Vì Person được nhúng trong SuperHero, chúng ta có thể gọi:
s.FullName()
Thay vì:
s.Person.FullName()
Method vẫn thuộc về Person; Go chỉ cung cấp selector rút gọn thông qua cơ chế promotion.
Đây là một trong những cách Go kết hợp hành vi giữa nhiều kiểu mà không cần inheritance.
Shadowing khi embedding
Điều gì xảy ra nếu struct bên ngoài và struct được nhúng có field cùng tên?
Field ở tầng gần nhất sẽ được ưu tiên:
type Person struct {
Name string
}
type Employee struct {
Person
Name string
}
Khởi tạo:
func main() {
e := Employee{
Person: Person{
Name: "Inner",
},
Name: "Outer",
}
fmt.Println(e.Name)
fmt.Println(e.Person.Name)
}
Kết quả:
Outer
Inner
e.Name truy cập field được khai báo trực tiếp trên Employee.
Field Person.Name không biến mất. Nó chỉ bị che khuất khi sử dụng selector ngắn và vẫn có thể được truy cập qua đường dẫn đầy đủ:
e.Person.Name
Nếu hai struct được nhúng ở cùng một độ sâu cùng cung cấp field có tên giống nhau, selector ngắn sẽ trở nên mơ hồ:
type A struct {
Name string
}
type B struct {
Name string
}
type C struct {
A
B
}
Đoạn sau không compile:
c.Name
Go không tự đoán chúng ta muốn truy cập A.Name hay B.Name. Cần viết rõ:
c.A.Name
c.B.Name
Struct tag
Struct tag là metadata được gắn vào field, đặt trong cặp dấu backtick sau kiểu dữ liệu:
type Animal struct {
Name string `key:"value1"`
Age int `key:"value2"`
}
Tag không tự tạo ra hành vi nào. Nó chỉ là một chuỗi metadata để các thư viện đọc thông qua reflection.
Những package thường sử dụng struct tag gồm:
encoding/jsonencoding/xml- Thư viện YAML
- ORM
- Thư viện validation
- Dependency injection framework
Ví dụ với JSON:
type Animal struct {
Name string `json:"name"`
Age int `json:"age"`
}
Tag yêu cầu encoding/json sử dụng key name và age, thay vì Name và Age.
omitempty và field unexported
Xem ví dụ sau:
type Movie struct {
Title string `json:"title"`
Director string `json:"director,omitempty"`
rating int
}
Marshal một giá trị Movie:
func main() {
m := Movie{
Title: "Inception",
rating: 5,
}
out, err := json.Marshal(m)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(out))
}
Kết quả:
{"title":"Inception"}Có ba điều đã xảy ra:
Thứ nhất, Title trở thành key title nhờ tag:
json:"title"Thứ hai, Director không xuất hiện vì nó đang mang zero value "" và có tùy chọn:
omitemptyThứ ba, rating không xuất hiện vì đây là field unexported. encoding/json không thể truy cập field này, kể cả khi chúng ta gắn tag cho nó.
Đọc struct tag bằng reflection
Các thư viện thường sử dụng package reflect để đọc tag.
Chúng ta cũng có thể tự làm điều tương tự:
t := reflect.TypeOf(Movie{})
field, ok := t.FieldByName("Title")
if !ok {
return
}
fmt.Println(field.Tag.Get("json"))
Kết quả:
titleCần nhớ rằng struct tag về bản chất chỉ là chuỗi. Một tag sai định dạng vẫn có thể vượt qua compiler:
Name string `json: "name"`Code có thể compile, nhưng encoding/json không hiểu tag theo cách mong muốn.
Lệnh go vet có thể phát hiện nhiều struct tag sai định dạng:
go vet ./...Đối với các API quan trọng, vẫn nên viết test để kiểm tra output sau khi marshal thay vì chỉ tin rằng tag đã được viết đúng.
Struct là value type
Struct trong Go có value semantics.
Khi gán một struct cho biến khác, Go sao chép toàn bộ các field của struct:
type Point struct {
X int
Y int
}
func main() {
p1 := Point{X: 1, Y: 2}
p2 := p1
p2.X = 10
fmt.Println(p1)
fmt.Println(p2)
}
Kết quả:
{1 2}
{10 2}
Việc thay đổi p2.X không ảnh hưởng đến p1.X, vì X và Y đã được copy sang biến mới.
Struct cũng được copy khi truyền vào hàm:
func move(p Point) {
p.X = 100
}
func main() {
p := Point{X: 1, Y: 2}
move(p)
fmt.Println(p.X) // 1
}
Hàm move chỉ thay đổi bản sao local của p.
Muốn hàm thay đổi trực tiếp giá trị của caller, chúng ta truyền pointer:
func move(p *Point) {
p.X = 100
}
func main() {
p := Point{X: 1, Y: 2}
move(&p)
fmt.Println(p.X) // 100
}
Copy struct không phải lúc nào cũng tạo dữ liệu hoàn toàn độc lập
Câu “struct được copy” là đúng, nhưng cần hiểu chính xác thứ được copy là các field.
Nếu struct chỉ chứa number, boolean, string hoặc array giá trị, hai struct thường có thể được xem là độc lập ở cấp field.
Nhưng nếu struct chứa pointer, slice, map hoặc các kiểu có dữ liệu nằm phía sau một reference, bản thân reference cũng được copy.
Ví dụ:
type User struct {
Name string
Roles []string
}
func main() {
u1 := User{
Name: "Bruce",
Roles: []string{"admin"},
}
u2 := u1
u2.Name = "Tony"
u2.Roles[0] = "viewer"
fmt.Println(u1.Name) // Bruce
fmt.Println(u2.Name) // Tony
fmt.Println(u1.Roles[0]) // viewer
}
Field Name được copy và việc gán lại u2.Name không ảnh hưởng đến u1.Name.
Nhưng field Roles là một slice. Khi copy struct, Go copy slice header, còn cả hai slice header ban đầu vẫn trỏ tới cùng backing array. Vì vậy:
u2.Roles[0] = "viewer"
cũng làm thay đổi dữ liệu quan sát được từ u1.Roles.
Do đó, copy một struct là shallow copy theo từng field, không mặc định là deep copy toàn bộ object graph phía sau nó.
Struct và bố cục bộ nhớ
Các field của struct được đặt trong bộ nhớ theo thứ tự khai báo.
Tuy nhiên, CPU thường yêu cầu một số kiểu dữ liệu nằm tại các địa chỉ được căn chỉnh phù hợp. Để đáp ứng yêu cầu này, compiler có thể chèn thêm các byte padding giữa các field hoặc ở cuối struct.
Ví dụ trên kiến trúc 64-bit:
type BadLayout struct {
A bool
B int64
C bool
}
Bố cục có thể trông gần giống:
A 1 byte
padding 7 byte
B 8 byte
C 1 byte
padding 7 byte
Tổng cộng là 24 byte:
fmt.Println(unsafe.Sizeof(BadLayout{})) // 24
Nếu sắp xếp lại:
type BetterLayout struct {
B int64
A bool
C bool
}
Bố cục có thể trở thành:
B 8 byte
A 1 byte
C 1 byte
padding 6 byte
Tổng cộng là 16 byte:
fmt.Println(unsafe.Sizeof(BetterLayout{})) // 16
Hai struct chứa cùng các field nhưng có kích thước khác nhau do alignment và padding.
Sự khác biệt này thường không đáng quan tâm với một vài object nhỏ. Nhưng khi một struct xuất hiện hàng triệu lần trong slice hoặc nằm trên hot path, kích thước nhỏ hơn có thể giúp:
- Giảm lượng bộ nhớ sử dụng.
- Giảm áp lực lên garbage collector.
- Tăng hiệu quả CPU cache.
- Giảm lượng dữ liệu phải copy.
Dù vậy, không nên sắp xếp field chỉ để tiết kiệm vài byte trong mọi trường hợp. Khả năng đọc hiểu và ý nghĩa domain vẫn quan trọng hơn. Chỉ nên tối ưu mạnh khi profiling cho thấy bố cục struct thực sự ảnh hưởng đến hệ thống.
Kích thước và alignment cũng có thể khác nhau giữa các kiến trúc máy, vì vậy không nên mặc định mọi hệ thống đều cho cùng một kết quả.
Empty struct
Empty struct là một struct không có field:
struct{}
Giá trị của nó có kích thước bằng 0:
func main() {
var s struct{}
fmt.Println(unsafe.Sizeof(s))
}
Kết quả:
0Empty struct được sử dụng khi chúng ta chỉ quan tâm đến sự tồn tại của một giá trị, chứ không cần lưu dữ liệu bên trong nó.
Dùng empty struct để tạo set
Go không có kiểu set built-in. Một cách biểu diễn phổ biến là sử dụng map với value là struct{}:
seen := map[string]struct{}{}
Thêm một phần tử:
seen["golang"] = struct{}{}
Kiểm tra phần tử có tồn tại hay không:
if _, ok := seen["golang"]; ok {
fmt.Println("already seen")
}
Trong cấu trúc này, chỉ key mang thông tin. Value không cần chứa dữ liệu gì cả.
So với:
map[string]boolmap[string]struct{} thể hiện ý định rõ hơn: map này đại diện cho một tập hợp key, không phải ánh xạ từ key sang một trạng thái boolean có ý nghĩa riêng.
Dùng empty struct để phát tín hiệu qua channel
Một channel đôi khi chỉ được dùng để thông báo rằng một công việc đã hoàn tất:
done := make(chan struct{})
go func() {
// Thực hiện công việc.
close(done)
}()
<-done
fmt.Println("worker finished")
Không có dữ liệu nghiệp vụ nào cần được truyền qua done. Việc channel bị đóng đã mang đủ thông tin: worker đã hoàn thành.
chan struct{} thể hiện mục đích này rõ ràng hơn các kiểu như:
chan boolBởi vì người đọc code biết ngay channel được dùng để phát tín hiệu, không phải để truyền một giá trị boolean có ý nghĩa nghiệp vụ.
Trong code hiện đại, nhiều trường hợp hủy công việc hoặc timeout thường được quản lý bằng context.Context. Tuy nhiên, chan struct{} vẫn hữu ích cho các tín hiệu đồng bộ nội bộ đơn giản.
Anonymous struct trong table-driven test
Anonymous struct đặc biệt phù hợp với table-driven test, một pattern rất phổ biến trong Go.
Ví dụ, chúng ta cần test hàm:
func double(n int) int {
return n * 2
}
Có thể tổ chức test như sau:
func TestDouble(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{
name: "zero",
input: 0,
want: 0,
},
{
name: "positive number",
input: 2,
want: 4,
},
{
name: "negative number",
input: -3,
want: -6,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := double(tt.input)
if got != tt.want {
t.Errorf(
"double(%d) = %d, want %d",
tt.input,
got,
tt.want,
)
}
})
}
}
Mỗi phần tử trong slice là một test case.
Kiểu dữ liệu này chỉ được sử dụng trong TestDouble, nên tạo thêm một named type riêng thường không mang lại nhiều giá trị. Anonymous struct giúp dữ liệu đầu vào, kết quả mong đợi và logic chạy test nằm gần nhau.
Muốn thêm test case mới, chúng ta chỉ cần thêm một phần tử vào slice mà không phải sao chép toàn bộ logic kiểm tra.
Tổng kết
Struct là nền tảng để mô hình hóa dữ liệu trong Go.
Một struct:
- Nhóm nhiều field thành một kiểu dữ liệu duy nhất.
- Luôn có zero value hợp lệ.
- Có thể được khởi tạo bằng named hoặc positional literal.
- Có thể chứa field exported và unexported.
- Có thể kết hợp với struct khác thông qua composition và embedding.
- Có thể mang metadata thông qua struct tag.
- Được copy theo value semantics khi gán hoặc truyền vào hàm.
- Có thể vẫn chia sẻ dữ liệu phía sau nếu chứa slice, map hoặc pointer.
- Chỉ có thể sử dụng
==khi tất cả field đều có thể so sánh. - Có bố cục bộ nhớ chịu ảnh hưởng của alignment và padding.
DUKE
Bình luận