1 Kiến thức cơ bản về Struct
Trong ngôn ngữ Go, một cấu trúc là một loại dữ liệu composite được sử dụng để tổng hợp các loại dữ liệu khác nhau hoặc giống nhau thành một thực thể duy nhất. Cấu trúc đóng một vị trí quan trọng trong Go vì chúng đóng vai trò là một khía cạnh cơ bản của lập trình hướng đối tượng, mặc dù có những khác biệt nhỏ so với các ngôn ngữ lập trình hướng đối tượng truyền thống.
Nhu cầu sử dụng các cấu trúc phát sinh từ các mặt sau đây:
- Tổ chức biến có mối liên kết mạnh mẽ với nhau để cải thiện khả năng bảo trì mã.
- Cung cấp một cách để mô phỏng "các lớp," hỗ trợ tính đóng gói và tổng hợp.
- Khi tương tác với cấu trúc dữ liệu như JSON, bản ghi trong cơ sở dữ liệu, v.v., cấu trúc cung cấp một công cụ ánh xạ tiện lợi.
Tổ chức dữ liệu bằng cấu trúc cho phép biểu diễn một cách rõ ràng hơn về các mô hình đối tượng trong thế giới thực như người dùng, đơn đặt hàng, v.v.
2 Định nghĩa một Struct
Cú pháp để định nghĩa một cấu trúc như sau:
type TenCauTruc struct {
Truong1 LoaiTruong1
Truong2 LoaiTruong2
// ... các biến thành viên khác
}
- Từ khóa
type
giới thiệu định nghĩa của cấu trúc. -
TenCauTruc
là tên của loại cấu trúc, theo quy ước đặt tên của Go, thường là chữ in hoa để chỉ ra tính xuất. - Từ khóa
struct
chỉ ra rằng đây là một loại cấu trúc. - Trong cặp dấu ngoặc nhọn
{}
, các biến thành viên (trường) của cấu trúc được định nghĩa, mỗi trường được đặt sau đó là kiểu của nó.
Kiểu của các thành viên trong cấu trúc có thể là bất kỳ loại nào, bao gồm cả các loại cơ bản (như int
, string
, v.v.) và các loại phức tạp (như mảng, slice, cấu trúc khác, v.v.).
Ví dụ, định nghĩa một cấu trúc biểu diễn một người:
type Nguoi struct {
Ten string
Tuoi int
Emails []string // có thể bao gồm các loại phức tạp, như slice
}
Trong đoạn mã trên, cấu trúc Nguoi
có ba biến thành viên: Ten
có kiểu string, Tuoi
có kiểu số nguyên và Emails
có kiểu slice string, cho biết rằng một người có thể có nhiều địa chỉ email.
3 Tạo và Khởi tạo một Struct
3.1 Tạo một Thể hiện của Struct
Có hai cách để tạo một thể hiện của struct: khai báo trực tiếp hoặc sử dụng từ khóa new
.
Khai báo trực tiếp:
var p Nguoi
Đoạn mã trên tạo một thể hiện p
có kiểu Nguoi
, trong đó mỗi biến thành viên của cấu trúc có giá trị khởi tạo là giá trị không tương ứng với kiểu của nó.
Sử dụng từ khóa new
:
p := new(Nguoi)
Tạo một cấu trúc sử dụng từ khóa new
dẫn đến một con trỏ đến cấu trúc. Biến p
tại thời điểm này là kiểu *Nguoi
, trỏ đến một biến được cấp phát mới có kiểu Nguoi
và các biến thành viên đã được khởi tạo giá trị không.
3.2 Khởi tạo Thể hiện của Struct
Thể hiện của struct có thể được khởi tạo trong một lần khi chúng được tạo, sử dụng hai phương pháp: với tên trường hoặc không có tên trường.
Khởi tạo với Tên Trường:
p := Nguoi{
Ten: "Alice",
Tuoi: 30,
Emails: []string{"[email protected]", "[email protected]"},
}
Khi khởi tạo với dạng gán giá trị trường, thứ tự của việc khởi tạo không cần phải giống như thứ tự khai báo của cấu trúc, và bất kỳ trường chưa được khởi tạo sẽ giữ giá trị không.
Khởi tạo không có Tên Trường:
p := Nguoi{"Bob", 25, []string{"[email protected]"}}
Khi khởi tạo không có tên trường, hãy chắc chắn giá trị ban đầu của mỗi biến thành viên nằm trong cùng thứ tự mà khi cấu trúc được định nghĩa, và không có trường nào bị bỏ sót.
Ngoài ra, các struct có thể được khởi tạo với các trường cụ thể, và bất kỳ trường không được chỉ định sẽ nhận giá trị không:
p := Nguoi{Ten: "Charlie"}
Trong ví dụ này, chỉ có trường Ten
được khởi tạo, trong khi Tuoi
và Emails
đều sẽ có giá trị không tương ứng của chúng.
4 Truy cập các Biến thành viên của Struct
Việc truy cập các biến thành viên của một cấu trúc trong Go rất đơn giản, đạt được bằng cách sử dụng toán tử chấm (.
). Nếu bạn có một biến cấu trúc, bạn có thể đọc hoặc thay đổi giá trị thành viên của nó theo cách này.
Ví dụ:
package main
import "fmt"
type Person struct {
Name string
Age int
}
func main() {
// Tạo một biến kiểu Person
p := Person{"Alice", 30}
// Truy cập các thành viên trong struct
fmt.Println("Tên:", p.Name)
fmt.Println("Tuổi:", p.Age)
// Sửa đổi giá trị của các thành viên
p.Name = "Bob"
p.Age = 25
// Truy cập lại các giá trị đã được sửa đổi
fmt.Println("\nTên sau khi sửa đổi:", p.Name)
fmt.Println("Tuổi sau khi sửa đổi:", p.Age)
}
Ở ví dụ này, trước tiên chúng ta định nghĩa một struct Person
với hai biến thành viên, Name
và Age
. Sau đó, chúng ta tạo một thể hiện của struct này và demo cách đọc và sửa đổi các thành viên này.
5 Sự Kết Hợp và Nhúng Cấu Trúc
Các struct không chỉ có thể tồn tại độc lập mà còn có thể được kết hợp và nhúng vào nhau để tạo ra các cấu trúc dữ liệu phức tạp hơn.
5.1 Cấu Trúc Ẩn Danh
Một cấu trúc ẩn danh không rõ ràng khai báo một kiểu dữ liệu mới, mà thay vào đó trực tiếp sử dụng định nghĩa struct. Điều này hữu ích khi bạn cần tạo một struct một lần và sử dụng nó một cách đơn giản, tránh việc tạo ra các kiểu không cần thiết.
Ví dụ:
package main
import "fmt"
func main() {
// Định nghĩa và khởi tạo một struct ẩn danh
person := struct {
Name string
Age int
}{
Name: "Eve",
Age: 40,
}
// Truy cập các thành viên của struct ẩn danh
fmt.Println("Tên:", person.Name)
fmt.Println("Tuổi:", person.Age)
}
Trong ví dụ này, thay vì tạo ra một kiểu mới, chúng ta trực tiếp định nghĩa một struct và tạo một thể hiện của nó. Ví dụ này demo cách khởi tạo một struct ẩn danh và truy cập các thành viên của nó.
5.2 Nhúng Cấu Trúc
Việc nhúng cấu trúc bao gồm việc lồng một struct như một thành viên khác trong một struct khác. Điều này cho phép chúng ta xây dựng các mô hình dữ liệu phức tạp hơn.
Ví dụ:
package main
import "fmt"
// Định nghĩa struct Address
type Address struct {
City string
Country string
}
// Nhúng struct Address vào struct Person
type Person struct {
Name string
Age int
Address Address
}
func main() {
// Khởi tạo một thể hiện của Person
p := Person{
Name: "Charlie",
Age: 28,
Address: Address{
City: "New York",
Country: "USA",
},
}
// Truy cập các thành viên của struct nhúng
fmt.Println("Tên:", p.Name)
fmt.Println("Tuổi:", p.Age)
// Truy cập các thành viên của struct Address
fmt.Println("Thành phố:", p.Address.City)
fmt.Println("Quốc gia:", p.Address.Country)
}
Trong ví dụ này, chúng ta định nghĩa một struct Address
và nhúng nó như một thành viên trong struct Person
. Khi tạo một thể hiện của Person
, chúng ta cũng tạo đồng thời một thể hiện của Address
. Chúng ta có thể truy cập các thành viên của struct nhúng bằng cách sử dụng dấu chấm.
6 Phương Thức Cấu Trúc
Các tính năng lập trình hướng đối tượng (OOP) có thể được thực hiện thông qua các phương thức cấu trúc.
6.1 Khái Niệm Cơ Bản về Phương Thức
Trong ngôn ngữ Go, mặc dù không có khái niệm truyền thống về lớp và đối tượng, nhưng các tính năng OOP tương tự có thể được đạt được bằng cách gán các phương thức với các struct. Một phương thức struct là một loại hàm đặc biệt kết nối với một loại cụ thể của struct (hoặc con trỏ đến một struct), cho phép loại đó có bộ phương thức riêng của mình.
// Định nghĩa một struct đơn giản
type Rectangle struct {
length, width float64
}
// Định nghĩa một phương thức cho struct Rectangle để tính diện tích của hình chữ nhật
func (r Rectangle) Area() float64 {
return r.length * r.width
}
Trong đoạn mã trên, phương thức Area
được liên kết với struct Rectangle
. Trong định nghĩa phương thức, (r Rectangle)
là bộ nhận, xác định rằng phương thức này liên kết với kiểu Rectangle
. Bộ nhận xuất hiện trước tên phương thức.
6.2 Trình nhận giá trị và trình nhận con trỏ
Các phương thức có thể được phân loại thành trình nhận giá trị và trình nhận con trỏ dựa trên loại trình nhận. Trình nhận giá trị sử dụng một bản sao của cấu trúc để gọi phương thức, trong khi trình nhận con trỏ sử dụng một con trỏ đến cấu trúc và có thể sửa đổi cấu trúc gốc.
// Định nghĩa một phương thức với trình nhận giá trị
func (r Rectangle) Perimeter() float64 {
return 2 * (r.length + r.width)
}
// Định nghĩa một phương thức với trình nhận con trỏ, có thể sửa đổi cấu trúc
func (r *Rectangle) SetLength(newLength float64) {
r.length = newLength // có thể sửa đổi giá trị gốc của cấu trúc
}
Trong ví dụ trên, Perimeter
là một phương thức trình nhận giá trị, gọi nó sẽ không thay đổi giá trị của Rectangle
. Tuy nhiên, SetLength
là một phương thức trình nhận con trỏ, và việc gọi phương thức này sẽ ảnh hưởng đến phiên bản Rectangle
gốc.
6.3 Gọi phương thức
Bạn có thể gọi các phương thức của một cấu trúc bằng cách sử dụng biến cấu trúc và con trỏ của nó.
func main() {
rect := Rectangle{length: 10, width: 5}
// Gọi phương thức với trình nhận giá trị
fmt.Println("Diện tích:", rect.Area())
// Gọi phương thức với trình nhận giá trị
fmt.Println("Chu vi:", rect.Perimeter())
// Gọi phương thức với trình nhận con trỏ
rect.SetLength(20)
// Gọi phương thức với trình nhận giá trị lại, lưu ý rằng chiều dài đã bị sửa đổi
fmt.Println("Sau khi sửa đổi, Diện tích:", rect.Area())
}
Khi bạn gọi một phương thức sử dụng một con trỏ, Go tự động xử lý việc chuyển đổi giữa giá trị và con trỏ, bất kể phương thức của bạn được định nghĩa với trình nhận giá trị hay trình nhận con trỏ.
6.4 Lựa chọn loại trình nhận
Khi định nghĩa phương thức, bạn nên quyết định sử dụng trình nhận giá trị hay trình nhận con trỏ dựa trên tình hình. Dưới đây là một số hướng dẫn phổ biến:
- Nếu phương thức cần sửa đổi nội dung của cấu trúc, hãy sử dụng trình nhận con trỏ.
- Nếu cấu trúc lớn và chi phí sao chép cao, hãy sử dụng trình nhận con trỏ.
- Nếu bạn muốn phương thức sửa đổi giá trị mà trình nhận trỏ tới, hãy sử dụng trình nhận con trỏ.
- Vì lý do hiệu quả, ngay cả khi bạn không sửa đổi nội dung cấu trúc, việc sử dụng trình nhận con trỏ cho một cấu trúc lớn là hợp lý.
- Đối với cấu trúc nhỏ, hoặc khi chỉ đọc dữ liệu mà không cần sửa đổi, trình nhận giá trị thường đơn giản và hiệu quả hơn.
Thông qua phương thức cấu trúc, chúng ta có thể mô phỏng một số tính năng của lập trình hướng đối tượng trong Go, chẳng hạn như bao đóng và phương thức. Phương pháp này trong Go đơn giản hóa khái niệm về đối tượng trong khi cung cấp đủ khả năng để tổ chức và quản lý các chức năng liên quan.
7 Cấu trúc và Chuyển đổi JSON
Trong Go, thường cần phải chuyển đổi một cấu trúc thành định dạng JSON để truyền qua mạng hoặc làm tệp cấu hình. Tương tự, chúng ta cũng cần có khả năng chuyển đổi JSON thành các phiên bản cấu trúc. Gói encoding/json
trong Go cung cấp chức năng này.
Dưới đây là một ví dụ về cách chuyển đổi giữa một cấu trúc và JSON:
package main
import (
"encoding/json"
"fmt
"log"
)
// Định nghĩa cấu trúc Person, và sử dụng các thẻ json để định nghĩa ánh xạ giữa các trường cấu trúc và tên trường JSON
type Person struct {
Name string `json:"name"`
Age int `json:"age"`
Emails []string `json:"emails,omitempty"`
}
func main() {
// Tạo một phiên bản mới của Person
p := Person{
Name: "John Doe",
Age: 30,
Emails: []string{"[email protected]", "[email protected]"},
}
// Chuyển đổi thành JSON
jsonData, err := json.Marshal(p)
if err != nil {
log.Fatalf("Gói JSON thất bại: %s", err)
}
fmt.Printf("Định dạng JSON: %s\n", jsonData)
// Chuyển đổi thành cấu trúc
var p2 Person
if err := json.Unmarshal(jsonData, &p2); err != nil {
log.Fatalf("Giải mã JSON thất bại: %s", err)
}
fmt.Printf("Cấu trúc khôi phục: %#v\n", p2)
}
Trong đoạn mã trên, chúng ta đã định nghĩa một cấu trúc Person
, bao gồm một trường kiểu mảng với tùy chọn "omitempty". Tùy chọn này chỉ định rằng nếu trường là trống hoặc thiếu, nó sẽ không được bao gồm trong JSON.
Chúng ta đã sử dụng hàm json.Marshal
để chuyển đổi một phiên bản cấu trúc thành JSON, và hàm json.Unmarshal
để chuyển đổi dữ liệu JSON thành một phiên bản cấu trúc.
8 Chủ đề Nâng cao về Cấu trúc
8.1 So sánh Cấu trúc
Trong Go, bạn có thể trực tiếp so sánh hai phiên bản của cấu trúc, nhưng việc so sánh này dựa trên các giá trị của các trường trong cấu trúc. Nếu tất cả các giá trị trường đều bằng nhau, thì hai phiên bản của cấu trúc được coi là bằng nhau. Cần lưu ý rằng không phải tất cả các loại trường có thể được so sánh. Ví dụ, một cấu trúc chứa slice không thể được so sánh trực tiếp.
Dưới đây là một ví dụ về so sánh cấu trúc:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
p1 := Point{1, 2}
p2 := Point{1, 2}
p3 := Point{1, 3}
fmt.Println("p1 == p2:", p1 == p2) // Output: p1 == p2: true
fmt.Println("p1 == p3:", p1 == p3) // Output: p1 == p3: false
}
Trong ví dụ này, p1
và p2
được coi là bằng nhau vì tất cả các giá trị trường của chúng đều giống nhau. Và p3
không bằng p1
vì giá trị của Y
khác nhau.
8.2 Sao chép Cấu trúc
Trong Go, việc sao chép các phiên bản của cấu trúc có thể thực hiện bằng phép gán. Việc sao chép này là sao chép sâu (deep copy) hay sao chép nông (shallow copy) phụ thuộc vào loại trường trong cấu trúc.
Nếu cấu trúc chỉ chứa các loại cơ bản (như int
, string
, v.v.), sao chép sẽ là sao chép sâu. Nếu cấu trúc chứa các loại tham chiếu (như slice, map, v.v.), sao chép sẽ là sao chép nông, và phiên bản gốc và phiên bản được sao chép sẽ chia sẻ bộ nhớ của các loại tham chiếu.
Dưới đây là một ví dụ về sao chép một cấu trúc:
package main
import "fmt"
type Data struct {
Numbers []int
}
func main() {
// Khởi tạo một phiên bản của cấu trúc Data
original := Data{Numbers: []int{1, 2, 3}}
// Sao chép cấu trúc
copied := original
// Sửa đổi các phần tử của slice được sao chép
copied.Numbers[0] = 100
// Xem các phần tử của các phiên bản gốc và được sao chép
fmt.Println("Gốc:", original.Numbers) // Output: Gốc: [100 2 3]
fmt.Println("Đã sao chép:", copied.Numbers) // Output: Đã sao chép: [100 2 3]
}
Như trong ví dụ, các phiên bản original
và copied
chia sẻ cùng một slice, vì vậy việc sửa đổi dữ liệu slice trong copied
cũng sẽ ảnh hưởng đến dữ liệu slice trong original
.
Để tránh vấn đề này, bạn có thể thực hiện sao chép sâu thực sự bằng cách sao chép nội dung của slice thành một slice mới:
newNumbers := make([]int, len(original.Numbers))
copy(newNumbers, original.Numbers)
copied := Data{Numbers: newNumbers}
Như vậy, bất kỳ sửa đổi nào trên copied
cũng sẽ không ảnh hưởng đến original
.