Go 101 - Generics

Go 101 - Generics

·

7 min read

1. Giới thiệu chung

Generic trong Go là một chủ đề được bàn tán sôi nổi nhiều năm qua. Sau tất cả thì những nổ lực của cộng đồng đã được đền đáp. Go chính thức đã hỗ trợ Generics bắt đầu từ version 1.18.

Bài viết này sẽ tìm hiểu tính năng mới này trong Go.

2. Generics là gì ?

Generics là một kĩ thuật lập trình xuất hiện trong OOP (lập trình hướng đối tượng). Kĩ thuật này giúp định nghĩa các hàm/phương thức với các tham số mà không cần chỉ định rõ nó thuộc kiểu dữ liệu gì, aka chung chung. Đến lúc hàm được được sử dụng, người gọi sẽ quyết định điều này.

Một số ngôn ngữ khác nhau có thể thiết kế Generics khác nhau. Đơn cử như Java thì hiện thực thông qua khai báo các class, ví dụ: List<T>, ... Go thì không có khái niệm class nên sẽ không có những khai báo này.

3. Vì sao đến bây giờ GO mới "chấp nhận" Generics?

Riêng vấn đề này cũng có rất nhiều ý kiến ủng hộ và không ủng hộ. Vì trên cơ bản, Go là ngôn ngữ static type. Nghĩa là khi biên dịch (compile time), Go sẽ cần biết kiểu dữ liệu của tất cả các biến và tham số của hàm.

Nếu dùng Generics sẽ phá vỡ nguyên tắc này. Nghĩa là phải tới khi chương trình thực thi (runtime) thì mới phát hiện lỗi. Việc này một phần làm giảm tính an toàn của ngôn ngữ Go. Hay thậm chí còn ảnh hưởng tới tốc độ thực thi (performance), một trong những tính chất làm nên thương hiệu của Go.

Phía ủng hộ thì cũng có lí do của họ vì không có Generics thì code Go quá cứng nhắc, không thể tái sử lại lại. Đơn cử là viết là hàm tìm số lớn nhất của một mảng int, bạn sẽ phải viết hàm cho []int, hàm cho []int32, hàm cho []int64, ... trong khi cái body chả khác gì nhau.

4. Generics trong Go sử dụng ra sao?

Các bạn có thể trực tiếp truy cập vào go2goplay.golang.org để trải nghiệm luôn. Đoạn code dưới đây là ví dụ của đội ngũ Go:

package main

import (
    "fmt"
)

// This playground uses a development build of Go:
// devel go1.18-b1c7703f26 Wed Dec 15 14:48:19 2021 +0000

func Print[T any](s ...T) {
    for _, v := range s {
        fmt.Print(v)
    }
}

func main() {
    Print("Hello, ", "playground\n")
}

Hãy để ý ở dòng định nghĩa hàm Print có xuất hiện syntax mới là [T any] sau đó phần khai báo tham số T: (s ...T). Nghĩa là hàm Print nhận một dẫy các giá trị T. T này lúc khai báo vẫn chưa biết cụ thể kiểu dữ liệu nào, nhưng vì là any nên sẽ chấp nhận bất kì dữ liệu nào khi gọi hàm. Công việc còn lại chỉ là duyệt qua mảng T rồi print ra thôi.

5. Hiểu về Generics trong Go

Go khi có Generics vẫn giữ được tính chất đơn giản và tinh gọn trong code. Thật ra Generics cũng chỉ có 2 phần thôi:

image.png

  1. Tên biến dùng làm "giữ chỗ". Thật ra trong Go thì gọi là Type (kiểu biến), nhưng mình không muốn các bạn nhầm lẫn type trong kiểu khai báo biến nên gọi là "tên". Ví dụ: T, K, V,...

  2. Ràng buộc (Constraint) cho biến rõ ràng hơn hoặc cụ thể hơn một chút. Nếu không cần ràng buộc gì, chúng ta có thể dùng any.

Sử dụng Generics cho function trong Go:

Ví dụ dưới đây ta sẽ thực hiện hàm Map nhận vào một mảng các giá trị bất kì sau đó sẽ biến đổi mỗi item trong đó thành giá trị khác.

package main

import (
    "fmt"
)

func Map[K, V any](s []K, transform func(K) V) []V {
    rs := make([]V, len(s))
    for i, v := range s {
        rs[i] = transform(v)
    }
    return rs
}

func main() {
    arr := []int{1, 2, 3}
    resultArr := Map(arr, func(v int) int { return v * 2 })
    fmt.Println(resultArr)

    arr2 := []string{"a", "b", "c"}
    resultArr2 := Map(arr2, func(v string) string { return v + v })
    fmt.Println(resultArr2)
}

// [2, 4, 6]
// ["aa", "bb", "cc"]

Nhờ có Generics nên chúng ta chỉ cần definemột lần duy nhất cho hàm Map là xong. Trước đó bạn phải define cho tất cả các kiểu dữ liệu mà bạn muốn.

Giải thích một chút về hàm Map ở trên nhé:

  1. KV sẽ là 2 kiểu dữ liệu generics cho hàm Map. Chúng ta chưa rõ nó là gì nhưng chúng ta muốn mọi kiểu dữ liệu đều có thể dùng được nên cả 2 đều là any.

  2. Ở phần định nghĩa tham số truyền vào chúng ta chỉ cần một mảng K: [] K và hàm transform nhận kiểu K và trả về kiểu V. Hàm này là để người gọi muốn làm gì với mỗi item Kthì làm, miễn là returnV` là được.

  3. Cuối cùng hàm Map phải trả về một mảng mới: []V.

  4. Ở hàm main, mình chạy thử với 2 trường hợp mảng int và mảng string. Các bạn có thể đổi lại tuỳ mục đích biến đổi item trong mảng ban đầu nhé.

6. Sử dụng Generics định nghĩa các cấu trúc dữ liệu

Một số trường hợp nếu các bạn không muốn phải liên tục định nghĩa lại các cấu trúc dữ liệu trong Go. Các bạn có thể dùng Generics để hỗ trợ, ví dụ:

type Vector[T any] []T

type LinkedList[T any] struct {
    next *LinkedList[T]
    val  T
}

type Pair[T1, T2 any] struct {
    v1 T1
    v2 T2
}

type Tuple[T1, T2, T3 any] struct {
    v1 T1
    v2 T2
    v3 T3
}

7. Sử dụng "constraint" Generics

Đầu tiên mình sẽ viết thử một hàm tìm số nhỏ nhất trong mảng int với việc ứng dụng Generics xem sao nhé:

package main

func Min[T any](s []T) T {
    r := s[0]
    for _, v := range s[1:] {
        if v < r {
            r = v
        }
    }
    return r
}

Có vẻ đơn giản, nhưng bị báo lỗi khi compile:

./prog.go:6:6: invalid operation: cannot compare v < r (operator < not defined on T)

Lỗi này cũng dễ hiểu vì là any nên không phải value nào cũng thực hiện phép toán so sánh được.

Ở đây chúng ta có một vài giải pháp, trong đó truyền vào thêm một hàm compare để so sánh cũng là một cân nhắc:

package main

func Smallest[T any](s []T, compare func(T, T) int) T {
    r := s[0]
    for _, v := range s[1:] {
        if compare(r, v) == 1 {
            r = v
        }
    }
    return r
}

Tuy nhiên, giải pháp mình muốn giới thiệu cho các bạn là thiết lập contraint trong Generics. Nếu xem lại hình ở trên thì ta sẽ biết Generics có 2 thành phần: TypeContraint.

type SignedInteger interface {
  int | int8 | int16 | int32 | int64
}

func Smallest[T SignedInteger](s []T) T {
    r := s[0]
    for _, v := range s[1:] {
        if r > v {
            r = v
        }
    }
    return r
}

Với contraint, bây giờ T bắt buộc phải thuộc một trong những kiểu int | int8 | int16 | int32 | int64 thì mới dùng phép toán so sánh được.

8. Hiệu năng của Generics trong Go

Để thực hiện được các magic trên, đội ngũ phát triển Go để phải thay đổi bộ biên dịch để xử lí tại giai đoạn phân tích ngôn ngữ, để từ đó mới biết được syntax đó có đúng kiểu Generics hay không. Các bạn có thể xem qua tại:

go tool go2go translate xx.go2

Về hiệu năng thì đội ngũ có đề cập rằng các generics function cũng sẽ biên dịch một lần duy nhất.

9. Lời kết

Theo mình thì Generics xuất hiện trong Go thực sự là một cải tiến lớn cho bản thân ngôn ngữ này. Mặt khác, nó sẽ góp phần thay đổi rõ rệt cách mà các developer Go tổ chức source code để tận dụng chức năng này tốt hơn.

Nếu bạn là một tay đua đặt tốc độ thực thi và biên dịch lên hàng đầu thì có khả năng các bạn sẽ không quan tâm đến Generics. Không sao cả, vì trước khi có nó thì hệ thống vẫn chạy ổn. Bản thân mình thì nghĩ việc hi sinh một chút hiệu năng và thời gian biên dịch đổi lấy code đỡ nhọc hơn khá xứng đáng.

Tham khảo

blog.logrocket.com/understanding-generics-g..