BeeTool.Dev - Essential Tools for developers

Stories.Tool Tabs VaultPersonal Documents
BeeTool.Dev

Essential tools, knowledge, and playgrounds for developers.

Explore

  • Stories
  • Personal Documents
  • Features
  • Beetool for Mac
  • Algorithms Playground

Tool Tabs

  • JSON Compare
  • SQL Formatter
  • Text Diff
  • All tools →

Learn

  • Sorting algorithms
  • Searching algorithms
  • Algorithm Race Mode
© 2024 – 2026 BeeTool.Dev
AboutContactPrivacy policyTerms
All stories
GolangCourse

Struct trong Go

Hiểu rõ struct không chỉ giúp chúng ta khai báo dữ liệu đúng cú pháp. Nó còn là nền tảng để hiểu method receiver, interface, JSON encoding, memory allocation, domain modeling và phần lớn các pattern thiết kế thường gặp trong code Go.

DUKE435 viewsAugust 5, 2026

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:

  • FirstName là string nên có giá trị ""
  • LastName là string nên có giá trị ""
  • Age là int nê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 Person

Trong 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ả:

Duke

Field cũng có thể được cập nhật trực tiếp:

p.Age = 23

Sau 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ả:

true

Go 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/json
  • encoding/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:

omitempty

Thứ 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ả:

title

Cầ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ả:

0

Empty 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]bool

map[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 bool

Bở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.


#Backend
DUKE
Written byDUKEView all stories by DUKE

Bình luận