Đi tới nội dung chính
Lập trình

Go Cơ Bản Bài 04: Giải Bài Tập Về Con Trỏ, Struct và Map

22 tháng 6, 202611 phút2167 từZeno

Học lý thuyết nhiều quá cũng mệt não nên thôi, bài viết này Zeno và bạn cùng giải bài tập nha! Chủ đề của bài tập này là Pointer, Struct và Map.

Bắt đầu thôi!

Đề bài

Viết chương trình đếm số lần xuất hiện của từng từ khóa trong một slice chuỗi. Dùng một struct để theo dõi tổng số lần xử lý, và cập nhật struct đó thông qua pointer.

Phân tích đề bài

Đề bài yêu cầu chúng ta thực hiện các công việc sau:

  1. Đếm số lần xuất hiện của từng từ khóa trong một slice chuỗi

Vậy chúng ta cần một slice kiểu string để duyệt dữ liệu. Giả định như sau:

1
slice := []string{"go", "map", "slice", "go"}

Để lưu trữ số lần xuất hiện của mỗi tag, chúng ta có thể dùng một biến struct hoặc map. Hoặc cả hai!

Chúng ta sẽ dùng map để lưu trữ với key là string (tên tag) và value là int (số lần xuất hiện).

  1. Theo dõi số lần xử lí

Theo đề bài, chúng ta sẽ dùng một struct để theo dõi tổng số lần xử lý, và cập nhật struct đó thông qua pointer.

Dùng thêm 1 biến int để lưu trữ tổng số lần đếm cũng được nhưng mà làm thế này trông cho nó chuyên nghiệp :v

Và thật ra làm vậy cho đúng ý đồ của đề này! :D

Để dễ hình dung, chúng ta hãy cùng xem cấu trúc của map và struct trông như thế nào:

1
2
3
4
5
6
statistic := make(map[string]int)

type counter struct{
	name string
	total int
}

Phân Tích & Lời Giải

Cần nhớ là mọi thứ trong Go đều được truyền theo giá trị (Pass-by-value).

Đây là cách giải tiêu chuẩn

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// File: main.go
package main

import "fmt"

// Khai báo struct counter để đếm tổng số lần xử lý.
// Thật ra counter không cần cái field name này cũng được! EHE!
type counter struct {
	name  string
	total int
}

// Tham số counter là một con trỏ (*counter) để nhận địa chỉ của 
// biến counter từ hàm main, giúp hàm có thể cập nhật giá trị gốc.
func bump(counter *counter, tag string, statistic map[string]int) {
	// Tăng giá trị của biến counter.
	// Nhờ tính năng Implicit Dereferencing, ta không cần dùng (*counter).total++ 
	// đâu nhé :v. Trình biên dịch Go sẽ tự hiểu và cho phép viết thẳng như này!
	counter.total++
	
	// Tăng giá trị của key tag trong map statistic.
	// Ta không cần tạo mới key trước khi sử dụng. Nếu key chưa tồn 
	// tại, nó sẽ tự động được tạo và gán giá trị mặc định (0 cho int).
	statistic[tag]++
}

func main() {
	// Khai báo struct và khởi tạo giá trị ban đầu.
	// Nếu không khởi tạo, giá trị mặc định sẽ là "" và 0! 
	// Với bài này thì để mặc định dùng cũng được luôn!
	counter := counter{name: "tags", total: 0}
	
	// Khởi tạo map lưu trữ số lần xuất hiện của mỗi tag.
	// Do cần bộ nhớ để ghi dữ liệu nên ta dùng make để cấp phát.
	// Nếu map không được cấp phát, nó sẽ mang giá trị nil, và 
	// việc ghi dữ liệu vào map nil sẽ làm chương trình báo lỗi panic.
	statistic := make(map[string]int)
	// Slice giả định của chúng ta
	slice := []string{"go", "map", "slice", "go"}

	// Duyệt qua slice bằng for range.
	// Chú ý: dùng _, tag nghĩa là ta chỉ lấy giá trị và bỏ qua index.
	for _, tag := range slice {
		// Gọi hàm bump để đếm số lần xuất hiện của tag và số lần thực thi.
		// Truyền vào 3 tham số:
		// 1. &counter: Truyền địa chỉ để thay đổi được giá trị struct gốc.
		// 2. tag: Giá trị của từ khóa.
		// 3. statistic: Map là kiểu tham chiếu (Reference type) nên không 
		//    cần truyền địa chỉ mà vẫn có thể cập nhật được dữ liệu.
		bump(&counter, tag, statistic) 
	}

	// In ra kết quả
	fmt.Println("Total:", counter.total)
	fmt.Println("go:", statistic["go"])
	fmt.Println("map:", statistic["map"])

	// Kiểm tra xem key "struct" có tồn tại trong map không.
	// Mình gán biến "ok" ngay trong câu điều kiện if luôn nhé :v.
	// Biến ok (kiểu bool) sẽ cho biết key đã có trong map hay chưa.
	if value, ok := statistic["struct"]; ok {
        fmt.Println("struct:", value)
    } else {
        fmt.Println("struct chưa xuất hiện")
    }
}

Mình sẽ giải thích thêm một chút cho dễ hiểu

  1. Tại sao tham số counter trong hàm bump phải là con trỏ *counter? Vì Go truyền tham số theo dạng copy (bản sao). Nếu ta chỉ định nghĩa hàm là bump(counter counter, ...), Go sẽ tạo ra một bản sao mới của struct counter. Dòng lệnh counter.total++ sẽ chỉ tăng bộ đếm trên bản sao đó và bị hủy ngay khi hàm kết thúc, còn struct counter gốc ở main vẫn giữ nguyên total = 0. Bằng cách dùng con trỏ *counter và truyền địa chỉ của biến vào bằng &counter, ta cho phép hàm đi thẳng vào vùng nhớ gốc để chỉnh sửa.

  2. Cú pháp truy cập cực kỳ thân thiện của Go (Implicit Dereferencing): Như đã nhắc trong code, đáng lẽ với con trỏ ta phải viết là (*counter).total++ (giải bọc con trỏ trước rồi gọi đến trường total). Nhưng Go muốn đơn giản hóa điều này nên tự ngầm hiểu và cho phép viết thẳng counter.total++. Tương tự, một điểm thú vị khác là chúng ta có thể thao tác thẳng statistic[tag]++ mặc dù statistic được truyền vào như một kiểu tham chiếu (reference type).


Thử một số cách giải khác nha

Chúng ta sẽ tách hàm bump ra và cho vào một package khác tên là funcs

File: funcs/functions.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Package funcs chứa các hàm tiện ích
package funcs

// Viết hoa Counter để dùng ở package main, tương tự cho hàm Bump
type Counter struct {
	Name  string
	Total int
}

func Bump(counter *Counter, tag string, statistic map[string]int) {
	counter.Total++
	statistic[tag]++
}

File: main.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
	"fmt"
	// "your-module-path" liên quan tới module khai báo trong go.mod á!
	"your-module-path/funcs"
)

func main() {
	counter := funcs.Counter{Name: "tags", Total: 0}
	statistic := make(map[string]int)
	
	slice := []string{"go", "map", "slice", "go"}

	for _, tag := range slice {
		funcs.Bump(&counter, tag, statistic)
	}

	fmt.Println("Total:", counter.Total)
	fmt.Println("go:", statistic["go"])
	fmt.Println("map:", statistic["map"])
}

Dùng hàm new() để khởi tạo Counter

Thay vì tạo Counter là struct thường và truyền địa chỉ thông qua & thì có thể dùng hàm new() của Go để cấp phát bộ nhớ ngay từ đầu.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func main() {
	// Hàm new() trả về thẳng một con trỏ (*Counter)
	counter := new(Counter)
	counter.Name = "tags"
	counter.Total = 0

	statistic := make(map[string]int)
	// ...

	for _, tag := range slice {
		// Nhờ biến counter đã là con trỏ, ta truyền thẳng vào hàm bump!
		Bump(counter, tag, statistic) 
	}
}

Nhận xét: Cả hai phương pháp (dùng & hay dùng new()) cuối cùng đều đưa địa chỉ vùng nhớ của struct Counter vào hàm. Kết quả hoàn toàn tương đương nhau.

Tối ưu với Phương thức (Struct Method) bằng Pointer Receiver

Thay vì viết hàm tự do Bump như trên, chúng ta có thể gom nhóm hành vi gắn liền với dữ liệu thành các phương thức (Method). Kỹ thuật này gọi là sử dụng Pointer Receiver. Nó tương tự như OOP trong Java vậy, chính là tính đóng gói (Encapsulation).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Bump giờ là một phương thức trực thuộc đối tượng Counter.
// Tham số receiver counter là một con trỏ đến đối tượng Counter, 
// đóng vai trò giống như từ khóa this trong Java vậy.
func (counter *Counter) Bump(tag string, statistic map[string]int) {
	counter.Total++
	statistic[tag]++
}

func main() {
	counter := Counter{Name: "tags", Total: 0}
	statistic := make(map[string]int)
	// ...

	for _, tag := range slice {
		// Gọi method trực tiếp từ biến, trông cũng dễ dòm ha!
		counter.Bump(tag, statistic) 
	}
}

Lợi ích:

  • Tính đóng gói (Encapsulation): Hành động Bump giờ thuộc quyền sở hữu của Counter. Việc tăng số đếm có vẻ như là “Counter tự hành động” thay vì bị thao tác bởi một hàm bên ngoài.
  • Go tự động lo việc tham chiếu: Ở dòng counter.Bump(...), mặc dù counter không phải là con trỏ, Go vẫn tự hiểu và tự động dịch nó thành (&counter).Bump(...). Code sẽ ngắn gọn hơn rất nhiều.

Một số điểm đáng chú ý

Chúng ta phải dùng con trỏ cho struct *Counter, nhưng lại không cần dùng con trỏ cho statistic map[string]int? Hàm Bump vẫn tăng được bộ đếm trong map thành công đó thôi?

Bản chất của Map

Trong Go, map là một kiểu dữ liệu tham chiếu (reference type). Bản thân cái tên biến statisticmain thực ra hoạt động như một con trỏ ngầm trỏ đến một cấu trúc dữ liệu trên Heap, không phải Stack.

Khi chúng ta truyền statistic vào hàm Bump, biến đó được copy lại theo đúng quy luật (truyền tham trị), nhưng vì nó là “copy của một con trỏ”, cả biến gốc ở main và biến cục bộ ở Bump đều trỏ chung về một vùng nhớ. Do đó, sửa trên vùng nhớ này thì main cũng thấy. Nói một cách dễ hiểu là khi hàm Bump được khởi tạo thì vốn dĩ biến statistic trong hàm là một bản sao của biến statistic trong hàm main. Nhưng do bản chất của map là reference type nên cả hai biến này đều trỏ về cùng một vùng nhớ. Mình tạm gọi chúng là global pointer và local pointer nha!

Nếu ta gán lại (Reassignment) Map trong hàm thì sao?

EHE! Zeno sẽ cố tình gõ thêm dòng này vào trong vòng lặp của hàm Bump:

1
2
3
4
5
6
7
func (c *Counter) Bump(tag string, statistic map[string]int) {
	// Gán lại một map statistic mới tinh
	statistic = make(map[string]int) 

	c.Total++
	statistic[tag]++
}

Nhiều người sẽ lo sợ: “Map gốc ở main sẽ bị xóa sạch!”. Nhưng không phải vậy, nó không xóa mà nó.. không có tác động gì đến map gốc cả.

  1. Lệnh make ở trong hàm Bump tạo ra một vùng nhớ Map mới hoàn toàn trên Heap.
  2. Lúc này, con trỏ local pointer statistic của hàm Bump bỏ vùng nhớ cũ, quay sang trỏ vào vùng nhớ mới. Lệnh đếm statistic[tag]++ sẽ làm việc trên map mới này.
  3. Trong khi đó, con trỏ global pointer ở main không hề hay biết gì, vẫn ôm khư khư vùng nhớ ban đầu (hoàn toàn trống rỗng).
  4. Khi hàm Bump kết thúc, con trỏ local pointer biến mất, vùng nhớ mới trên Heap bơ vơ và sẽ bị trình dọn rác (Garbage Collector) dọn đi.
  5. Có nghĩa là với đề bài này, slice dài độ dài là 4 thì sau 4 lần lặp của for range, sẽ tạo ra lần lượt 4 local pointer statistic trỏ về địa chỉ của global pointer statistic. Tuy nhiên do sau đó dòng lệnh make chạy, bắt local pointer ở vòng lặp đó trỏ sang vùng nhớ mới. Kết thúc 4 lần, có tổng cộng 4 vùng nhớ map mới được tạo ra độc lập trên Heap và cả 4 vùng nhớ này sẽ bị Garbage Collector dọn dẹp sạch sẽ. Kết quả là map statisticmain vẫn giữ nguyên trạng thái ban đầu, không thay đổi chút nào.

Lưu ý: Việc gán lại tham số này thường dễ bị nhầm với Shadowing (Che khuất biến). Tuy nhiên:

  • Gán lại tham số: Dùng một biến cũ đổi sang giá trị mới (scope độc lập).
  • Shadowing: Dùng cú pháp := tạo ra một biến mới hoàn toàn ở block lồng nhau (như if, for) che mất biến cũ bên ngoài.

Lời kết

Vừa rồi là một bài tập cơ bản về Go, có làm bạn hơi ngợp hông? Zeno đã cố gắng giải thích cặn kẽ nhất có thể. Có chỗ nào chưa hợp lý hay khó hiểu, hãy cứ để lại bình luận bên dưới nhé! Hẹn gặp lại trong các bài viết tiếp theo!

Chủ đề của bài viết

Chia sẻ bài viết

Gửi bài này cho người đang cần đúng chủ đề, hoặc lưu lại để quay về sau.

Đọc tiếp

Tất cả bài viết
Security

Tản mạn về VPN

Mở ứng dụng VPN lên, mình lập tức có thể xem một bộ phim bị giới hạn khu vực, hay an tâm lướt web tại một quán cà phê công cộng mà không sợ …

2 tháng 7, 2026 Đọc tiếp

Tiếp theo

Gợi ý bài tiếp theo

Tản mạn về DNS

Hệ thống phân giải tên miền (DNS) ra đời năm 1983 để giải quyết sự sụp đổ không thể tránh khỏi của một tệp văn bản duy nhất mang tên HOSTS.TXT — chiếc …

Mở bài tiếp theo

Thảo luận