Cách sử dụng interfaces trong Golang (Phần 2)

Bài viết được sự cho phép của tác giả Lê Xuân Quỳnh

Trong phần 1, chúng ta đã tìm hiểu về cách dùng interfaces và về interface rỗng. Phần này, chúng ra sẽ nghiên cứu tiếp:

Con trỏ và interfaces

Một điểm tinh tế khác của interface là định nghĩa interface không quy định liệu người triển khai có nên triển khai interface bằng cách sử dụng kiểu con trỏ hay kiểu giá trị hay không. Khi bạn được cung cấp một giá trị interface, không có gì đảm bảo rằng nó là kiểu thông thường hay là kiểu con trỏ. Trong ví dụ bài trước, chúng ta đã định nghĩa các function của interface trên các kiểu thông thường. Bây giờ chúng ta sẽ thay đổi 1 chút để triển khai thành kiểu con trỏ:

func (c *Cat) Speak() string {
    return "Meow!"
}

Nếu bạn thay đổi chương trình như https://go.dev/play/p/TvR758rfre, và bạn cố tình chạy nó bạn sẽ nhận được lỗi sau:

cannot use Cat{} (type Cat) as type Animal in slice literal:
Cat does not implement Animal (Speak method has pointer receiver)

Thành thật mà nói, thông báo lỗi này hơi khó hiểu lúc đầu. Ý người ta nói không phải là interface Animal yêu cầu bạn xác định phương thức của mình như một kiểu con trỏ, nhưng bạn cố gắng chuyển đổi cấu trúc Cat thành giá trị interface Animal thì chỉ *Cat đáp ứng interface đó. Bạn có thể fix bug bằng cách chuyển *Cat thay vì dùng Cat, bằng cách sử dụng new(Cat) thay vì Cat{}(hay nói cách khác dùng &Cat{}), trông nó như sau:

animals := []Animal{Dog{}, new(Cat), Llama{}, JavaProgrammer{}}

Chương trình bây giờ sẽ như sau: https://go.dev/play/p/x5VwyExxBM

Hãy thay đổi theo hướng ngược lại: hãy thay thế kiểu con trỏ *Dog thay vì kiểu giá trị Dog, nhưng chúng ta sẽ không viết lại phương thức Speak cho Dog:

animals := []Animal{new(Dog), new(Cat), Llama{}, JavaProgrammer{}}

Chương trình sẽ như sau: https://go.dev/play/p/UZ618qbPkj

Chương trình hoạt động bình thường và chúng ta nhận ra 1 sự khác biệt nhỏ: Chúng ta không phải thay đổi phương thức Speak. Điều này hoạt động vì kiểu con trỏ có thể truy cập vào method của kiểu giá trị được kết hợp của nó, còn điều ngược lại thì không được phép. Có nghĩa là kiểu con trỏ *Dog có thể truy cập phương thức của kiểu giá trị Dog, và như chúng ta thấy trước đó, kiểu Cat không thể truy cập vào phương thức Speak của *Cat.

Điều đó nghe có vẻ khó hiểu, nhưng sẽ có ý nghĩa khi bạn nhớ những điều sau: mọi thứ trong Go đều được truyền theo giá trị. Mỗi khi bạn gọi một hàm, dữ liệu bạn đang truyền vào nó sẽ được sao chép. Trong trường hợp một phương thức định nghĩa thuộc kiểu giá trị, giá trị được sao chép khi gọi phương thức. Điều này rõ ràng hơn một chút khi bạn xem đoạn code sau:

func (t T)MyMethod(s string) {
    // ...
}

là một hàm kiểu func (T, string); các giá trị được truyền vào hàm dạng giá trị dù bạn thay đổi tên biến bất kỳ. Bất kỳ thay đổi hàm Speak nào giống như func (d Dog) Speak() { … } sẽ không hiển thị khi người dùng gọi vì nó tạo thành 1 phương thức khác mất rồi. Vì mọi thứ đều được truyền bởi giá trị, nên rõ ràng tại sao một phương thức của con trỏ *Cat không thể sử dụng được bởi một giá trị Cat. Bất cứ giá trị Cat nào cũng có thể được trỏ bằng nhiều con trỏ *Cat vào. (Cat là giá trị nằm trên vùng nhớ, nên tất nhiên nhiều con trỏ sẽ trỏ vào được). Nếu ta cố tình sử dụng method của *Cat bằng 1 giá trị Cat, thì không tồn tại con trỏ để truy cập vào method đó. Ngược lại nếu chúng ta sử dụng 1 method trên Cat bằng 1 con trỏ *Cat, thì chúng ta luôn biết chính xác giá trị Cat luôn có method này, bởi vì con trỏ *Cat luôn trỏ chính xác vào giá trị Cat. Điều đó giải thích tại sao với con trỏ *Cat là d và method Speak thì ta luôn gọi được d.Speak(), không giống như cách gọi của C++ là d->Speak() mà ta thường thấy.

interfaces trong golang

Thế giới thực: Lấy giá trị timestamp từ twitter API

Các API Twitter trả về thời gian như sau:

Thu May 31 00:00:01 +0000 2012″

Giá trị này có thể hiện thị bất cứ đâu theo chuẩn JSON, ví dụ:

"created_at": "Thu May 31 00:00:01 +0000 2012"

Chúng ta có chương trình để lấy giá trị của nó như sau:

package main

import (
    "encoding/json"
    "fmt"
    "reflect"
)

// start with a string representation of our JSON data
var input = `
{
    "created_at": "Thu May 31 00:00:01 +0000 2012"
}
`

func main() {
    // our target will be of type map[string]interface{}, which is a
    // pretty generic type that will give us a hashtable whose keys
    // are strings, and whose values are of type interface{}
    var val map[string]interface{}

    if err := json.Unmarshal([]byte(input), &val); err != nil {
        panic(err)
    }

    fmt.Println(val)
    for k, v := range val {
        fmt.Println(k, reflect.TypeOf(v))
    }
}

Chạy chương trình tại https://go.dev/play/p/VJAyqO3hTF

Chạy chương trình và chúng ta thu được kết quả:

map[created_at:Thu May 31 00:00:01 +0000 2012]
created_at string

Chúng ta có thể thấy đã lấy được key của timestamp và giá trị của nó. Tuy nhiên kết quả ở sau thì không thực sự hữu dụng lắm. Bây giờ hãy thử dùng time.Time để chuyển đổi nó:

var val map[string]time.Time

if err := json.Unmarshal([]byte(input), &val); err != nil {
panic(err)
}

Khi chạy chúng ta được lỗi sau:

parsing time ""Thu May 31 00:00:01 +0000 2012"" as ""2006-01-02T15:04:05Z07:00"": cannot parse "Thu May 31 00:00:01 +0000 2012"" as "2006"

thông báo lỗi hơi khó hiểu đó xuất phát từ cách Go xử lý việc convert bằng time.Time values sang string. Tóm lại, ý nghĩa của việc biểu diễn chuỗi mà chúng tôi đưa ra không khớp với định dạng thời gian tiêu chuẩn (vì API của Twitter ban đầu được viết bằng Ruby và định dạng mặc định cho Ruby không giống với định dạng mặc định cho Go). Chúng tôi sẽ cần xác định loại của riêng mình để loại bỏ giá trị này một cách chính xác. Gói encoding/json sẽ xem liệu các giá trị được truyền đến json.Unmarshal có đáp ứng giao diện json.Unmarshaler hay không, trông giống như sau:

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}

Bạn có thể xem thêm tài liệu ở đây: https://pkg.go.dev/encoding/json#Unmarshaler

Do vậy những gì chúng ta cần là time.Time với phương thức UnmarshalJSON([]byte) error:

type Timestamp time.Time

func (t *Timestamp) UnmarshalJSON(b []byte) error {
    // ...
}

Bằng cách triển khai phương pháp này, chúng ta đáp ứng interface json.Unmarshaler, khiến json.Unmarshal gọi thông báo lỗi khi nhìn thấy giá trị Timestamp. Đối với trường hợp này chúng ta cần gọi method thông qua con trỏ, bởi vì chúng ta muốn nhìn thấy những thay đổi của giá trị nhận. Để cài đặt thủ công con trỏ chúng ta dùng toán tử *. Bên trong phương thức UnmarshalJSON, t đại diện cho 1 con trỏ kiểu Timestamp. Hãy nhớ rằng mọi thứ được truyền bằng giá trị. Do vậy con trỏ t trong hàm trên không phải là con trỏ đúng ngữ cảnh của nó, mà nó chỉ là 1 bản sao. Nếu bạn định trực tiếp gán t cho một giá trị khác, bạn sẽ chỉ định lại một con trỏ hàm cục bộ; người gọi sẽ không nhìn thấy thay đổi. Tuy nhiên, con trỏ bên trong của phương thức gọi trỏ đến cùng một dữ liệu với con trỏ trong phạm vi gọi của nó; bằng cách tham chiếu đến con trỏ, chúng ta hiển thị thay đổi của mình cho nơi gọi.

Chúng ta có thể dùng phương thức time.Parse, cụ thể là func(layout, value string) (Time, error). Với 2 tham số truyền vào: 1 là format của timestamp truyền vào, 2 là giá trị value cần chuyển đổi. Hàm có thể trả về kiểu time. Time hoặc là lỗi nếu không convert được. Cụ thể hàm viết như sau:

func (t *Timestamp) UnmarshalJSON(b []byte) error {
    v, err := time.Parse(time.RubyDate, string(b[1:len(b)-1]))
    if err != nil {
        return err
    }
    *t = Timestamp(v)
    return nil
}

Code chạy ở đây https://go.dev/play/p/QpiFsJi-nZ

Kết quả như sau:

map[created_at:{0 63474019201 0x58dd00}]
created_at main.Timestamp
2012-05-31 00:00:01 +0000 UTC

Vậy chúng ta đã chuyển đổi string thành giá trị time.Time mong muốn.

Thế giới thực: Cách lấy 1 object từ http request

Chúng ta hãy kết thúc bằng cách xem cách chúng ta có thể thiết kế interface để giải quyết một vấn đề lập trình web phổ biến: chúng ta muốn phân tích cú pháp phần body của một request HTTP thành một số dữ liệu object. Lúc đầu, đây không phải là một interface rõ ràng để xác định. Chúng ta có thể cố gắng nói rằng ta sẽ nhận được resource từ một request HTTP như sau:

GetEntity(*http.Request) (interface{}, error)

Bởi vì interface{} có thể nhận bất cứ kiểu dữ liệu cơ bản nào, nên chúng ta có thể parse body và trả về kiểu mà ta mong muốn. Điều này hóa ra là một chiến lược khá tệ, lý do là chúng ta thêm quá nhiều logic vào hàm GetEntity, nó cần modify cho mọi kiểu mới để trả về kiểu interface{}. Thực tế các hàm trả về interface{} có xu hướng khá khó chịu, và như một quy tắc chung, bạn chỉ có thể nhớ rằng thông thường tốt hơn nếu lấy giá trị interface{} làm tham số hơn là trả về giá trị interface{} .

Chúng ta cũng có thể bị cám dỗ để viết một số chức năng kiểu cụ thể như thế này:

GetUser(*http.Request) (User, error)

Điều này cũng trở nên không linh hoạt, bởi vì bây giờ chúng ta có các chức năng khác nhau cho mọi kiểu, nhưng không có cách nào tốt để tổng quát chúng. Thay vào đó, những gì chúng ta thực sự muốn làm là một cái gì đó giống như thế này:

type Entity interface {
    UnmarshalHTTP(*http.Request) error
}
func GetEntity(r *http.Request, v Entity) error {
    return v.UnmarshalHTTP(r)
}

Trong đó hàm GetEntity nhận một giá trị interface được đảm bảo có phương thức UnmarshalHTTP. Để sử dụng điều này, chúng ta sẽ xác định trên đối tượng User của mình một số phương thức cho phép User mô tả cách nó sẽ tự thoát ra khỏi một request HTTP:

func (u *User) UnmarshalHTTP(r *http.Request) error {
    // ...
}

Trong ứng dụng của mình, bạn sẽ khai báo một var thuộc kiểu User, rồi chuyển một con trỏ đến hàm này vào GetEntity:

var u User
if err := GetEntity(req, &u); err != nil {
    // ...
}

Điều đó rất giống với cách bạn giải nén dữ liệu JSON. Điều này là nhất quán và an toàn vì câu lệnh var u User sẽ tự động tạo 1 struct User với giá trị rỗng. Go không giống như một số ngôn ngữ khác trong việc khai báo và khởi tạo diễn ra riêng biệt, và rằng bằng cách khai báo một giá trị mà không khởi tạo nó, bạn có thể tạo ra một sai lầm, trong đó bạn có thể truy cập vào một phần dữ liệu rác; khi khai báo giá trị, trong thời gian thực Go sẽ tạo không gian bộ nhớ thích hợp để giữ giá trị đó. Ngay cả khi phương thức UnmarshalHTTP thất bại, thì các trường giá trị rỗng sẽ thay thế giá trị rác.

Điều đó sẽ có vẻ lạ đối với bạn nếu bạn là một lập trình viên Python, vì về cơ bản, nó hoàn toàn khác với những gì chúng ta thường làm trong Python.

Kết thúc

Tôi hy vọng, sau khi đọc bài này, bạn cảm thấy thoải mái hơn khi sử dụng các interface trong Go. Hãy nhớ những điều sau:

  1. Tạo sự trừu tượng bằng cách xem xét chức năng phổ biến giữa các kiểu dữ liệu, thay vì các trường phổ biến giữa các kiểu dữ liệu.
  2. Kiểu interface{} không phải là bất kỳ kiểu dữ liệu nào; nó chính xác là 1 kiểu.
  3. Interface được xác định bởi 2 từ, về cơ bản nó là {kiểu, giá trị}.
  4. Tốt nhất là truyền 1 giá trị interface{} hơn là trả về 1 giá trị interface{}.
  5. Kiểu con trỏ có thể gọi tới các phương thức tới giá trị của kiểu đó, nhưng điều ngược lại thì không thể.
  6. Mọi thứ được truyền vào đều là giá trị, ngay cả đối số nhận vào là 1 phương thức.
  7. Một interface không hoàn toàn là con trỏ, hoặc không phải là con trỏ. Nó chỉ là interface.
  8. nếu bạn cần ghi đè hoàn toàn một giá trị bên trong một interface, hãy sử dụng toán tử * để tham chiếu thủ công một con trỏ.

Ok, tôi nghĩ rằng điều đó tổng hợp mọi thứ về interface mà cá nhân tôi thấy khó hiểu. Chúc bạn viết mã vui vẻ 🙂

Bài viết gốc được đăng tải tại codetoanbug.com

Xem thêm:

Đừng bỏ lỡ hàng loạt việc làm IT đãi ngộ hấp dẫn tại TopDev