Thử nghiệm: Generics trong Go

Hơn 10 năm trôi qua, cuối cùng hỗ trợ generics trong Golang cũng đã xuất hiện trong bản beta 1.18. Những định hình về thiết kế và cách thức sử dụng cũng đã rõ ràng, hãy cùng thử làm vài ví dụ về generics trong Go.

Upload image

Nhìn lại lịch sử

Go được ra mắt vào tháng 11/2009. Chỉ sau vài tiếng, những đề xuất về generics đã bắt đầu xuất hiện. Những năm tiếp theo đó, sự thiếu vắng của generics luôn là vấn đề hàng đầu được trao đổi trong những việc cần phải làm đối với Go. Hàng năm, đội ngũ phát triển Golang luôn làm khảo sát, và trong 3 năm liên tiếp, generics là một trong ba vấn đề cần phải sửa đối với Go.

Trong hội nghị GopherCon năm 2019, Ian Lance Taylor đã có một bài trình bày tương đối đầy đủ về thiết kế của generics trong Go.

Xem chi tiết thiết kế của generics trong Go được nhắc đến trong video tại đây.

Một năm sau, ngày ra mắt được nhắc đến trong bản thông báo ngắn bao gồm những thay đổi trong thiết kế, ấn định ngày ra mắt cùng với bản 1.17 vào tháng 8/2021.

Và cuối cùng, như chúng ta đã biết, generics cũng đã được đưa vào bản beta 1.18 beta 1. Hy vọng generics sẽ chính thức được ra mắt vào năm sau.

Tại sao cần generics?

Thử ví dụ ta cần đảo thứ tự của một mảng.

Đầu tiên là một mảng số nguyên:

func ReverseInts(s []int) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

Rồi đảo một chuỗi:

func ReverseStrings(s []string) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

Nếu ta so sánh hai hàm trên, ta thấy nó giống hệt nhau cách thức thực thi, trừ tham số. Với những ai đã từng làm qua các ngôn ngữ khác sẽ thấy việc gom hai hàm lại thành một là cực kỳ đơn giản. Tuy nhiên, để làm một hàm có thể áp dụng với tất cả kiểu dữ liệu tham số đầu vào thì lại là không thể đối với Go.

Đối với Go, kiểu interface là một dạng của generics. Ví dụ, các hàm của thư viện tiêu chuẩn hầu hết đều dùng interface để cho phép đa dạng hóa tham số đầu vào, điển hình như sort. Tuy nhiên, việc sử dụng interface vẫn không cung cấp cho lập trình viên công cụ đầy đủ như các ngôn ngữ khác.

Nói một cách khác, sử dụng interface giống như một cách chữa cháy cho việc không có generics.

Chúng ta hãy thử dùng interface để tổng quát hóa hàm đảo ngược thứ tự chuỗi bên trên.

func Reverses(s []interface{}) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

Khi sử dụng, chúng ta sẽ phải làm thêm một chút.

//Init a slice of float
f := []float64{1.1, 2.2, 3.3, 4.4, 5.5}

//Make it compatible with interface
b := make([]interface{}, len(f))
for i, v := range f {
    b[i] = v
}

Reverses(b)

Như vậy, ta có thể thấy interface không giải quyết được hoàn toàn vấn đề mà thật sự phải cần đến sự có mặt của generics.

Thử generics với Go

Quay trở lại ví dụ bên trên, hãy thử điều chỉnh một chút:

func Reverses[T any](s []T) {
    first := 0
    last := len(s) - 1
    for first < last {
        s[first], s[last] = s[last], s[first]
        first++
        last--
    }
}

Khá là đơn giản phải không?

Go cũng cho phép khai báo một kiểu dữ liệu có dạng generic.

type Bunch[E any] []E

Sau khi định nghĩa một kiểu generic, chúng ta có thể tạo một hàm với tham số là kiểu generic bên trên.

func SumBunch[E int64 | float64](i Bunch[E]) E {
    var s E
    for _, v := range i {
        s += E(v)
    }

    return s
}

Thử xem chạy có ổn không?

    i := Bunch[int64]{1, 2, 3, 4, 5}
    f := Bunch[float64]{1.1, 2.2, 3.3, 4.4, 5.5}

    fmt.Printf("Generic Sums: %v\n", SumBunch(i))
    fmt.Printf("Generic Sums: %v\n", SumBunch(f))

Ràng buộc kiểu của generic

Khi khai báo một kiểu dữ liệu generic có dạng any, chúng ta chấp nhận tất cả kiểu dữ liệu, tuy nhiên, không phải lúc nào cũng làm như thế được. Ví dụ, khi cần cộng tất cả các phần tử của một chuỗi, chúng ta dễ dàng nhận thấy bắt buộc tham số đầu vào phải là một mảng số. Như vậy cần phải giới hạn kiểu dữ liệu của generic. Go cho phép giới hạn bằng cách khai báo kiểu dữ liệu cho biến generic tại khai báo hàm, hoặc định nghĩa một kiểu interface.

func SumNum[V int64|int|float32|float64](i []V) V {
    var s V
    for _, v := range i {
        s += V(v)
    }

    return s
}
type Number interface {
    int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | uintptr | float32 | float64
}

Có một vấn đề với đoạn khai báo các kiểu dữ liệu bên trên, đó là nó dài quá... Go hỗ trợ chúng ta viết ngắn lại một chút như sau:

type Number interface {
    ~int | ~int8 | ~int16| ~int64 | ~float64
}

Giờ chúng ta có một nhu cầu phức tạp hơn, hãy thử làm một hàm nối tất cả chuỗi lại:

func Join[E any](things []E) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

//Error: v.String undefined (type bound for E has no method String)

Đoạn code trên sẽ bị báo lỗi ngay lập tức, vấn đề ở đây là v có kiểu là any. Go không thể biết được là v có hàm String() hay không.

Trường hợp này, ta cần phải mô tả giới hạn (constrain) cho kiểu E bằng cách thay vì để E là any thì thay bằng một kiểu xác định có hàm String().

Đầu tiên, ta sẽ khai báo một interface có hàm String().

type Stringer interface {
    String() string
}

Sau đó, ta điều chỉnh lại hàm Join một chút như sau:

func Join[E Stringer](things []E) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

Bởi vì Stringer đảm bảo rằng kiểu E đã có hàm String() nên Go sẽ không báo lỗi nữa.

Hãy code thử một chương trình nhỏ để kiểm tra. À, mà để cho nó phức tạp hơn chút, và đảm bảo là generic thật, ta sẽ làm 2 struct khác nhau đều implement hàm String().

package main

import (
    "fmt"
    "strings"
)

type Stringer interface {
    String() string
}

//String is a type that implements the Stringer interface and return a string
type String struct {
    s string
}

func (i String) String() string {
    return i.s
}

//LowerString is a type that implements the Stringer interface and return a lower case string
type LowerString struct {
    s string
}

func (i LowerString) String() string {
    return strings.ToLower(i.s)
}

func main() {
    fmt.Println("Generic Join:", Join([]String{{"STRING 1"}, {"STRING 2"}, {"STRING 3"}}))
    fmt.Println("Generic Join:", Join([]LowerString{{"STRING 1"}, {"STRING 2"}, {"STRING 3"}}))
}

func Join[E Stringer](things []E) (result string) {
    for _, v := range things {
        result += v.String()
    }
    return result
}

Go còn cho phép khai báo tham số đầu vào là kiểu dữ liệu có thể so sánh được. Cùng xem ví dụ bên dưới cho dễ hiểu nhé!

func Equal[T any](a, b T) bool {
    return a == b
}
// cannot compare a == b (operator == not defined for T)

Ở đoạn code này, a và b có kiểu là any nên lập tức đoạn code trên sẽ báo lỗi. Để có thể so sánh như trong nội dung hàm, ta phải điều chỉnh một chút.

func Equal[T comparable](a, b T) bool {
    return a == b
}

Kết lại

Mặc dù đã được ra mắt dưới phiên bản beta, tức là thiết kế về generics đã được chốt hạ, nhưng tất cả mới chỉ là bắt đầu! Các thư viện tiêu chuẩn sẽ dần được chuyển sang generics và hy vọng đem lại sự tiện lợi, rõ ràng hơn đối với lập trình viên Go.

Qua một số ví dụ với generics, chúng ta có thể thấy các nguyên tắc của Go vẫn được bảo đảm, đó là tính gọn gàng trong thiết kế, thời gian build ngắn, chạy nhanh, ngôn ngữ trong sáng và đơn giản. Đây là các yếu tố giúp cho Go vẫn là ngôn ngữ lập trình yêu thích của nhiều lập trình viên.

Một số nguồn hữu ích:

Atekco - Home for Authentic Technical Consultants