Bài 18 - Kiểu interface trong Go


  • Trùm cuối

    Interface

    Tất cả các kiểu dữ liệu chúng ta đã từng tìm hiểu như số, chuỗi, mảng, ... đều là các kiểu dữ liệu cụ thể. Chúng mô tả chính xác dữ liệu mà chúng lưu giữ và các hành động đặc trưng của dữ liệu đó. Các hành động của các kiểu dữ liệu cụ thể này được mở rộng thêm nhờ phương thức (methods) mà ta đã tìm hiểu ở bài trước. Khi tạo biến gán dữ liệu chúng ta có thực thể của một kiểu dữ liệu cụ thể, chúng ta biết chính xác nó là gì và có những hành động nào. Trong bài này chúng ta cùng tìm hiểu về interface, kiểu dữ liệu mang tính khái quát, trừu tượng.

    7807b48d-bd47-4a3d-bf1c-def73dc2b640-image.png

    Kiểu dữ liệu trừu tượng

    Khi khai báo một interface trong Go, chúng ta chỉ khai báo nó gồm những phương thức nào chứ không hề mô tả nó chứa dữ liệu gì. Nói cách khác, interface không mô tả dữ liệu nó chứa như các kiểu dữ liệu cụ thể mà nó mô tả hành động cho một nhóm thực thể phù hợp.

    Ai học hình học phẳng cũng đều biết bất kỳ hình thể nào trong mặt phẳng như tam giác, chữ nhật, vuông hay tròn, v.v... cũng có hai thông tin đi kèm cần tính là diện tích và chu vi. Nói một cách khái quát, trừu tượng là mọi hình thể phẳng đều có diện tích và chu vi. Lúc này nếu có một nhóm các thực thể là các hình khác nhau cần tính chu vi và diện tích, chúng ta có thể quy chúng về một loại là hình thể (Shape) để tính toán dễ dàng hơn. Đầu tiên, chúng ta cần khai báo hình thể phẳng với 2 hành động tính chu vi và diện tích thông qua interface Shaper như sau:

    type Shaper interface {
         area() int      // Tính diện tích
         perim() int   // Tính chu vi
    }
    

    Lúc này bất kỳ kiểu dữ liệu nào khai báo 2 phương thức area()perim() thì đều thõa mãn Shaper và các thực thể của kiểu dữ liệu này đều có thể xem là thực thể của Shaper. Khi chúng ta khai báo các cấu trúc mô tả hình tam giác, hình chữ nhật, hình vuông, hình thoi, hình tròn, v.v... và khai báo các phương thức tính chu vi và diện tích của chúng thì các kiểu dữ liệu này đáp ứng Shaper. Để đơn giản bên dưới tôi chỉ khai báo 2 cấu trúc Rect và Square mô tả hình chữ nhật và hình vuông:

    type Rect struct {
        length, width int
    }
     
    type Square struct {
        side int
    }
     
    func (r Rect) area() int {
        return r.length * r.width
    }
     
    func (r Rect) perim() int {
        return (r.length + r.width) * 2
    }
     
    func (sq Square) area() int {
        return sq.side * sq.side
    }
     
    func (sq Square) perim() int {
        return sq.side * 2
    }
    

    Với việc khai báo đủ area()perim(), RectSquare đáp ứng yêu cầu của Shaper và các biến (thực thể) của Rect và Square có thể được xem là các thực thể của Shaper mà không cần phải khai báo gì thêm (kiểu như "implements" trong java) vì Go tự ngầm hiểu. Chúng ta cùng khai báo biến của Rect và Square và xem chúng hoạt động như thế nào với Shaper nhé:

    func main() {
         var s Shaper
         r := Rect{length: 5, width: 3}
         s = Shaper(r)
         fmt.Println("Area: ", s.area()) // Area: 15
         fmt.Println("Perimeter: ", s.perim()) // Perimeter: 16
    
         q := Square{side: 5}
         s = q
         fmt.Println("Area: ", s.area()) // Area: 25
         fmt.Println("Perimeter: ", s.perim()) // Perimeter: 10
    }
    
    • Dòng thứ 2 khai báo biến s kiểu dữ liệu giao diện Shaper và ở dòng thứ 4, biến s kiểu Shaper được gán giá trị r kiểu Rect và được ép về kiểu Shaper. Go cho phép việc ép kiểu này vì r hoàn toàn có thể xem thực thể kiểu Shaper. Kết quả in ra ở 2 dòng tiếp theo cho giá trị là diện tích và chu vi của r được gọi thông qua biến s kiểu Shaper.
    • Ở dòng thứ 9, s được gán trực tiếp q là biến kiểu Square. Kết quả in ra ở 2 dòng tiếp theo là diện tích và chu vi của q.

    Khai báo interface

    Khi khai báo kiểu interface, chúng ta chỉ cần khai báo các phương thức mà kiểu này muốn mô tả hành động cho một nhóm thực thể nào đó. Trong trường hợp có một kiểu interface khác chứa các phương thức mà kiểu interface này cần, thay vì khai báo lại các phương thức đó, ta chỉ cần "nhúng" kiểu interface kia vào nó, tương tự như cách khai báo trường vô danh ở cấu trúc trong bài trước.

    Như vậy, kiểu dữ liệu interface có thể được khai báo gồm các phương thức, phương thức và interface khác hay gồm các interface khác được nhúng vào:

    type Areaer interface {
        area() int
    }
     
    type Perimer interface {
        perim() int
    }
    

    Lúc này Shaper có thể khai báo lại thành 3 cách sau:

    type Shaper interface {
         area() int      // Tính diện tích
         perim() int   // Tính chu vi
    }
    

    hoặc

    type Shaper interface {
         Areaer          // Tính diện tích
         perim() int   // Tính chu vi
    }
    

    hoặc

    type Shaper interface {
         Areaer      // Tính diện tích
         Perimer    // Tính chu vi
    }
    

    Để phân biệt kiểu interface, Go khuyến khích khai báo nó với tên kết thúc bằng er. Ở trên tôi cũng cố gắng thực hiện như vậy, dù đôi chỗ thấy hơi lạ mắt.

    Giá trị kiểu interface

    Giá trị một biến kiểu interface gồm 2 thành phần: kiểu dữ liệu cụ thể mà tại thời điểm đó interface đang đại diện và giá trị kiểu dữ liệu đó mang. Giá trị zero mặc định khi khởi tạo của một biến kiểu interface là (nil, nil). Chúng ta cùng xem lại giá trị mà s kiểu Shaper lưu giữ qua lần khởi tạo ban đầu và 2 lần gán trong ví dụ trên.

    Chúng ta có thể kiểm tra giá trị của một biến interface là nil hay không qua biểu thức so sánh s == nil. s chỉ xem là nil khi giá trị của nó là (nil, nil). Với các biến có giá trị (khác nil, nil) thì so sánh với nil sẽ cho giá trị false. Trường hợp này xảy ra khi giá trị của kiểu dữ liệu cụ thể là con trỏ và giá trị con trỏ này là nil. Trường hợp s là nil thì việc gọi các phương thức hay trường là không được phép. Go sẽ báo lỗi.

    Hai biến kiểu interface có thể so sánh bằng hoặc khác nhau và chúng bằng nhau nếu cùng là nil hoặc cùng kiểu dữ liệu cụ thể và giá trị kiểu này mang là như nhau. Tuy nhiên nếu kiểu dữ liệu cụ thể thuộc dạng không so sánh được như slice chẳng hạn, thì việc so sánh thất bại và chúng ta nhận panic. Do interface thuộc kiểu so sánh được nên chúng ta có thể dùng nó làm khóa cho kiểu dữ liệu map.

    Kiểu interface rỗng và ép kiểu interface

    Một trường hợp đặc biệt của interface là kiểu interface rỗng khi nó được khai báo mà không có phương thức nào: interface{}. Lúc này mọi kiểu dữ liệu đều có thể đáp ứng yêu cầu của nó. Nó tương tự như class Object trong Java.

    var strinf interface{} = "Chuỗi"
    var intinf interface{} = 123
    

    Như vậy, nếu chúng ta tạo một hàm mà có tham số truyền vào là kiểu interface{} thì chúng ta có quyền truyền biến của bất kỳ kiểu dữ liệu nào.

    Một câu hỏi đặt ra lúc này là liệu chúng ta có thể ép kiểu biến interface intinf ở trên về kiểu int như bình thường được không?

    i := int(intinf)
    

    Go báo lỗi ngay khi biên dịch: cannot convert intinf (type interface {}) to type int: need type assertion. Vậy type assertion là gì? Đối với interface{} nói riêng ở trên và kiểu interface nói chúng, Go đề xuất cách "ép" kiểu mới như sau: <biến kiểu interface>.(<kiểu dữ liệu muốn ép về>). Ví dụ, để ép intinf về int ta thực hiện như sau:

    i := intinf.(int)
    

    Đó chính là type assertion trong Go. Điều gì xảy ra nếu chúng ta ép intinf về string thay vì int kiểu như i := intinf.(string)? Go không báo lỗi khi biên dịch nhưng khi thực thi sẽ bị panic và khiến ứng dụng lỗi thực thi. Để tránh panic, chúng ta có thể thêm biến lỗi ok như sau:

    i, ok := intinf.(string)
    

    Lúc này panic không xảy ra và biến ok sẽ cho giá trị false. Tìm hiểu thêm về xử lý lỗi tại đây.

    Trong trường hợp kiểu dữ liệu muốn ép về cũng là kiểu interface thì sao? Lúc này Go sẽ kiểu tra coi kiểu dữ liệu cụ thể mà kiểu interface bị ép có đáp ứng kiểu interface muốn ép về không. Nếu đáp ứng, biến được gán giá trị ép kiểu có cùng giá trị như kiểu interface bị ép nhưng kiểu dữ liệu nó nhận là kiểu interface muốn ép về. Cùng xem lại ví dụ về Rect, Shaper, Areaer, Perimer ở bên trên:

    var s Shaper = Rect{10, 5}
    a, ok := s.(Areaer)
    

    Ở dòng đầu tiên s có kiểu Shaper và giá trị là (Rect, {10,5}). Khi dòng thứ 2 thực thi, do Areaer có mỗi phương thức Area() và Rect cũng có nên Rect hoàn toàn đáp ứng là kiểu interface Areaer nên việc gán thành công. Lúc này, a có kiểu dữ liệu là Areaer và giá trị của nó vẫn là (Rect, {10,5}).

    Ngược lại tạo a từ Rect và gán a về Shaper vẫn đúng:

    var a Areaer = Rect{10, 5} 
    s, ok := a.(Shaper)
    

    Tuy nhiên nếu chúng ta không khai báo phương thức perim() cho Rect, tức Rect không đáp ứng đủ yêu cầu của Shaper thì dòng gán thứ 2 bên trên sẽ không thành công, biến ok sẽ cho giá trị false do Rect không thể được xem là Shaper.

    Switch với kiểu dữ liệu

    Trong phần switch chúng ta từng đề cập đến một trường hợp switch với biểu thức tùy chọn là kiểu dữ liệu. Đó là khi mà chúng ta cần xác định kiểu dữ liệu được lưu trữ trong một biến kiểu interface thực sự là kiểu dữ liệu cụ thể nào.

    switch v := intinf.(type) { 
    case string: 
        fmt.Printf("inf: chuỗi %s", v) 
    case int, int32, int64: 
        fmt.Printf("inf: số %d", v) 
    default: 
        fmt.Println("Giá trị inf: không xác định kiểu dữ liệu!") 
    }
    

    Kết quả in ra sẽ là inf: số 123 như đã gán trước đó bên trên.

    Cân nhắc khi dùng interface

    Không nên dùng interface chỉ để phục vụ cho một kiểu dữ liệu cụ thể bởi vì việc tạo ra interface và sử dụng cũng tốn chi phí và khiến cho lập trình trở nên phức tạp.
    Trong trường hợp dùng interface phục vụ cho nhiều kiểu dữ liệu cụ thể thì trong phạm vi có thể nên hạn chế số lượng kiểu dữ liệu cụ thể mà đáp ứng interface. Tóm lại là kiểu interface càng đơn giản thì càng dễ sử dụng và quản lý.

    Trong bài tới chúng ta sẽ cùng tìm hiểu về các loại interface quan trọng mà Go tạo sẵn cho chúng ta để giải quyết các vấn đề lập trình.

    Tóm tắt

    • Interface là kiểu dữ liệu trừu tượng, chỉ khai báo các phương thức mô tả hành động, không khai báo dữ liệu lưu trữ. Khi các kiểu dữ liệu khác có khai báo đầy đủ các phương thức mà interface đã khai thì các kiểu dữ liệu này đáp ứng yêu cầu và các biến (thực thể) của chúng đều có thể được lưu trữ và sử dụng bởi các biến kiểu interface.
    • Interface có thể được khai báo bằng các phương thức hoặc nhúng các interface khác hoặc kết hợp vừa phương thức vừa nhúng đều được. Tên interface thường kết thúc bằng "er".
    • Giá trị một biến interface tại một thời điểm là cặp kiểu dữ liệu cụ thể và giá trị tại thời điểm đó của thực thể kiểu dữ liệu cụ thể. Khi mới khởi tạo biến interface có giá trị (nil,nil). Interface là kiểu so sánh được với so sánh bằng và khác nên có thể làm khóa cho kiểu map.
    • Interface có trường hợp đặc biệt là interface rỗng với khai báo interface{}. Lúc này mọi kiểu dữ liệu đều đáp ứng nó và có thể dùng nó đại diện cho các kiểu dữ liệu ở mọi nơi.
    • Dùng type assertion để ép interface về kiểu dữ liệu cụ thể: <biến interface>.(<kiểu dữ liệu cụ thể>)
    • Chỉ nên dùng interface phục vụ cho từ 2 kiểu dữ liệu trở lên nhưng cũng không quá nhiều để tiện quản lý và tránh làm ứng dụng thêm phức tạp.


Có thể bạn cũng quan tâm

.
DMCA.com Protection Status