Trải nghiệm đầu tiên về lập trình kiểu tổng quát trong Golang - nohu.com 56

| Jan 15, 2025 min read

10 tháng 7 năm 2024 - Công nghệ máy tính

Từ phiên bản Go 1.18, ngôn ngữ đã chính thức hỗ trợ kiểu tổng quát (generics). Bài viết này sẽ minh họa cách sử dụng generics thông qua hai ví dụ thực tế: đảo ngược mảng và sắp xếp đối tượng.

Trước khi bắt đầu, chúng ta cần hiểu rõ khái niệm cơ bản của generics.

1. Generics là gì?

Generics (kiểu tổng quát) là một mô hình trong lập trình giúp cho phép bạn định nghĩa lớp (trong Go là cấu trúc), giao diện và phương thức với các tham số kiểu dữ liệu (type parameters). Những tham số kiểu này có thể được sử dụng để miêu tả kiểu dữ liệu của tham số phương thức hoặc thuộc tính của lớp/giao diện, từ đó làm cho mã nguồn có thể tái sử dụng giữa các kiểu dữ liệu khác nhau mà không cần chuyển đổi kiểu hay sử dụng kiểu interface{}.

Ưu điểm lớn nhất của generics là tăng cường khả năng tái sử dụng mã nguồn và an toàn kiểu dữ liệu. Bằng cách sử dụng generics, bạn có thể viết các lớp và phương thức chung có thể áp dụng cho nhiều kiểu dữ liệu khác nhau, giảm thiểu việc phải viết lại mã nguồn cho từng kiểu riêng biệt.

Bài viết sẽ trình bày chi tiết hai ví dụ ứng dụng: đảo ngược mảng và sắp xếp đối tượng.

2. Đảo ngược mảng

Để minh họa cách sử dụng generics, chúng ta sẽ xem xét ví dụ về việc đảo ngược các phần tử trong một mảng. Ví dụ đơn giản nhất là đảo ngược một mảng chứa các số nguyên, như sau:

Một mảng số nguyên []int{1, 2, 3, 4, 5} sau khi đảo ngược sẽ trở thành []int{5, 4, 3, 2, 1}. Tuy nhiên, không chỉ có mảng số nguyên mới có thể bị đảo ngược; trên lý thuyết, mọi loại mảng (bao gồm cả các kiểu dữ liệu cơ bản và cấu trúc tự định nghĩa) đều có thể được xử lý theo cách tương tự. Vì vậy, đây là một tình huống rất phù hợp để áp dụng generics.

Chúng ta sẽ phân tích cách giải quyết vấn đề này trước và sau khi sử dụng generics.

2.1 Trước khi sử dụng generics

Nếu muốn đảo ngược một mảng số nguyên, bạn có thể viết hàm như sau:

func DaoNguocSoNguyen(a []int) {
    for i, j := 0, len(a)-1; i < j; {
        a[i], a[j] = a[j], a[i]
        i++
        j--
    }
}

Logic hoạt động của hàm này:

  • Khai báo hai biến ij, ban đầu lần lượt trỏ tới phần tử đầu tiên và cuối cùng của mảng.
  • Trong khi i < j, hoán đổi giá trị giữa phần tử đầu và cuối, sau đó di chuyển con trỏ i lên phía trước và j xuống phía sau.
  • Lặp lại quá trình trên cho đến khi tất cả phần tử đã được hoán đổi.

Ví dụ sử dụng hàm này trong hàm main():

soNguyen := []int{1, 2, 3, 4, 5}
DaoNguocSoNguyen(soNguyen)
fmt.Println(soNguyen) // Kết quả: [5 4 3 2 1]

Kết quả đúng như mong đợi.

Tương tự, nếu muốn đảo ngược một mảng chuỗi, mã nguồn gần như giống hệt (chỉ khác kiểu dữ liệu):

func DaoNguocChuoi(a []string) {
    for i, j := 0, len(a)-1; i < j; {
        a[i], a[j] = a[j], a[i]
        i++
        j--
    }
}

Ví dụ sử dụng:

chuoi := []string{"a", "b", "c", "d", "e"}
DaoNguocChuoi(chuoi)
fmt.Println(chuoi) // Kết quả: [e d c b a]

Cũng như vậy, nếu muốn đảo ngược một mảng cấu trúc tự định nghĩa (ví dụ hocSinh), cách triển khai cũng hoàn toàn tương tự:

type hocSinh struct {
    ma int
    ten string
}

func DaoNguocHocSinh(a []hocSinh) {
    for i, j := 0, len(a)-1; i < j; {
        a[i], a[j] = a[j], a[i]
        i++
        j--
    }
}

Ví dụ sử dụng:

hocSinhs := []hocSinh{
    {ma: 1, ten: "Lan"},
    {ma: 2, ten: "Nam"},
    {ma: 3, ten: "An"},
}
DaoNguocHocSinh(hocSinhs)
fmt.Println(hocSinhs) // Kết quả: [{3 An} {2 Nam} {1 Lan}]

Từ đây, câu hỏi đặt ra là: Liệu có cách nào viết mã nguồn chung cho mọi kiểu dữ liệu?

2.2 Sau khi sử dụng generics

Với sự hỗ trợ của generics từ Go 1.18, chúng ta có thể viết hàm đảo ngược chung như sau:

func DaoNguoc[T any](a []T) {
    for i, j := 0, len(a)-1; i < j; {
        a[i], a[j] = a[j], a[i]
        i++
        j--
    }
}

Hàm này khác biệt ở chỗ tên hàm DaoNguoc được đi kèm bởi một tham số kiểu dữ liệu [T any], cho phép nó hoạt động với mọi kiểu dữ liệu. Tham số duy nhất a []T biểu diễn một mảng có kiểu dữ liệu tùy chọn.

Ví dụ sử dụng trong hàm main():

// Đảo ngược mảng float64
soThuc := []float64{1.1, 2.2, 3.3, 4.4, 5.5}
DaoNguoc(soThuc)
fmt.Println(soThuc) // Kết quả: [5.5 4.4 3.3 2.2 1.1]

// Đảo ngược mảng chuỗi
chuoi := []string{"Xin", "Chao", "The", "Gioi"}
DaoNguoc(chuoi)
fmt.Println(chuoi) // Kết quả: ["Gioi" "The" "Chao" "Xin"]

// Đảo ngược mảng cấu trúc
hocSinhs := []hocSinh{
    {ma: 1, ten: "A"},
    {ma: 2, ten: "B"},
    {ma: 3, ten: "C"},
}
DaoNguoc(hocSinhs)
fmt.Println(hocSinhs) // Kết quả: [{3 C} {2 B} {1 A}]

Lưu ý rằng khi gọi hàm generics, bạn có thể chỉ định rõ ràng kiểu dữ liệu bằng dấu ngoặc vuông, nhưng thường thì Go sẽ tự động suy luận kiểu dữ liệu trong quá trình biên dịch.

3. Sắp xếp đối tượng

Tiếp theo, chúng ta sẽ xem xét thêm một ví dụ phổ biến khác: sắp xếp đối tượng. Đầu tiên, chúng ta sẽ xem cách sắp xếp các kiểu dữ liệu cơ bản và cấu trúc tự định nghĩa trước khi có generics, sau đó tìm hiểu cách cải tiến bằng generics.

3.1 Trước khi sử dụng generics

Go cung cấp gói sort để sắp xếp các mảng. Dưới đây là ví dụ cách sử dụng gói này cho các kiểu dữ liệu cơ bản:

// Sắp xếp mảng số nguyên
soNguyen := []int{5, 3, 1, 4, 2}
sort.Ints(soNguyen)
fmt.Println(soNguyen) // Kết quả: [1 2 3 4 5]

// Sắp xếp mảng số thực
soThuc := []float64{3.3, 1.1, 2.2, 5.5, 4.4}
sort.Float64s(soThuc)
fmt.Println(soThuc) // Kết quả: [1.1 2.2 3.3 4.4 5.5]

// Sắp xếp mảng chuỗi
chuoi := []string{"z", "a", "c", "b", "d"}
sort.Strings(chuoi)
fmt.Println(chuoi) // Kết quả: [a b c d z]

Đối với cấu trúc tự định nghĩa, bạn cần triển khai ba phương thức Len(), Less(), và Swap() từ giao diện sort.Interface. Ví dụ:

type hocSinh struct {
    ma int
    ten string
}

type danhSachHocSinh []hocSinh

func (ds danhSachHocSinh) Len() int {
    return len(ds)
}

func (ds danhSachHocSinh) Less(i, j int) bool {
    return [nohu.com 56](/post/7864/)  ds[i].ma < ds[j].ma
}

func (ds danhSachHocSinh) Swap(i, j int) {
    ds[i], ds[j] = ds[j], ds[i]
}

hocSinhs := []hocSinh{
    {ma: 3, ten: "C"},
    {ma: 1, ten: "A"},
    {ma: 2, ten: "B"},
}
sort.Sort(danhSachHocSinh(hocSinhs))
fmt.Println(hocSinhs) // Kết quả: [{1 A} {2 B} {3 C}]

3.2 Sau khi sử dụng generics

Kiểu dữ liệu cơ bản

Chúng ta có thể viết một hàm sắp xếp chung như sau:

func SapXep[T constraints.Ordered](a []T) {
    sort.Slice(a, func(i, j int) bool {
        return a[i] < a[j]
    })
}

Ví dụ sử dụng:

soNguyen := []int{5, 3, 1, 4, 2}
SapXep(soNguyen)
fmt.Println(soNguyen) // Kết quả: [1 2 3 4 5]

soThuc := []float64{3.3, 1.1, 2.2, 5.5, 4.4}
SapXep(soThuc)
fmt.Println(soThuc) // Kết quả: [1.1 2.2 [bắn cá xèng 777](https://www.naiqia.com)  3.3 4.4 5.5]

chuoi := []string{"z", "a", "c", "b", "d"}
SapXep(chuoi)
fmt.Println(chuoi) // Kết quả: [a b c d z]

Cấu trúc tự định nghĩa

Đối với cấu trúc tự định nghĩa, chúng ta cần tạo một giao diện so sánh:

type SoSanh[T any] interface {
    SoSanhVoi(T) int
}

func SapXep[T SoSanh[T]](a []T) {
    sort.Slice(a, func(i, j int) bool {
        return a[i].SoSanhVoi(a[j]) < 0
    })
}

Ví dụ sử dụng:

type hocSinh struct {
    ma int
    ten string
}

func (hs hocSinh) SoSanhVoi(other hocSinh) int {
    return hs.ma - other.ma
}

hocSinhs := []hocSinh{
    {ma: 3, ten: "C"},
    {ma: 1, ten: "A"},
    {ma: 2, ten: "B"},
}
SapXep(hocSinhs)
fmt.Println(hocSinhs) // Kết quả: [{1 A} {2 B} {3 C}]

4. Kết luận

Bài viết đã giới thiệu về generics trong Go thông qua hai ví dụ cụ thể: đảo ngược mảng và sắp xếp đối tượng. Các ví dụ mã nguồn đã được đăng tải trên GitHub, mời độc giả tham khảo thêm.

Tài liệu tham khảo: [1] Hướng dẫn Go: Khởi đầu với generics [2] Blog Go: Tại sao cần generics? [3] Go Hiệu quả: Generics - Phần tử ngôn ngữ nâng cao