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:
- Đế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:
| |
Để 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).
- 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:
| |
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
| |
Mình sẽ giải thích thêm một chút cho dễ hiểu
Tại sao tham số
countertrong hàmbumpphả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 structcounter. Dòng lệnhcounter.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 structcountergốc ởmainvẫn giữ nguyêntotal = 0. Bằng cách dùng con trỏ*countervà 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.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ườngtotal). 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ẳngcounter.total++. Tương tự, một điểm thú vị khác là chúng ta có thể thao tác thẳngstatistic[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
| |
File: main.go
| |
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.
| |
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).
| |
Lợi ích:
- Tính đóng gói (Encapsulation): Hành động
Bumpgiờ thuộc quyền sở hữu củaCounter. 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ùcounterkhô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 statistic ở main 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:
| |
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ả.
- Lệnh
makeở trong hàmBumptạo ra một vùng nhớ Map mới hoàn toàn trên Heap. - Lúc này, con trỏ local pointer
statisticcủa hàmBumpbỏ vùng nhớ cũ, quay sang trỏ vào vùng nhớ mới. Lệnh đếmstatistic[tag]++sẽ làm việc trên map mới này. - Trong khi đó, con trỏ global pointer ở
mainkhông hề hay biết gì, vẫn ôm khư khư vùng nhớ ban đầu (hoàn toàn trống rỗng). - Khi hàm
Bumpkế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. - 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
statistictrỏ về địa chỉ của global pointerstatistic. 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à mapstatisticởmainvẫ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!