Để thực sự hiểu "Lập trình hướng đối tượng" bạn cần quay lại điểm khởi đầu của kỹ thuật này. Ngôn ngữ lập trình hướng đối tượng đầu tiên, Simula, nổi lên những năm 1960. Nó đưa ra khái niệm object (đối tượng), class (lớp), inheritance (kế thừa) và subclasses (lớp con kế thừa từ lớp cha), virtual methods (hàm ảo), co-routines (đoạn lệnh cùng chạy) và nhiều thứ rất hay ho khác nữa. Nổi bật nhất là Simula đã đưa ra cách tiếp khác với quan điểm trước đây data (dữ liệu) và logic độc lập với nhau.
Bạn có thể chưa quen với Simular, nhưng chắc bạn đã từng học hay lập trình những ngôn ngữ Java, C++, C#, SmallTalk. Nhưng ngôn ngữ này lại là nguồn cảm hứng cho các ngôn ngữ như Objective-C, Swift, Python, Ruby, JavaScript, Scala, PHP, Perl và nhiều ngôn ngữ lập trình hiện đại. Lập trình hướng đối tượng hiện là chuẩn mực của hầu hết lập trình viên hiện nay.
Thay vì cấu trúc chương trình gồm code và dữ liệu, kỹ thuật hướng đối tượng tích hợp 2 phần thành một khái niệm gọi là đối tượng. Đối tượng có thuộc tính và hành động.
Golang được thiết kế với mục tiêu đơn giản, không có khái niệm Class, mà Class là một thành phần không thể thiếu của lập trình hướng đối tượng. Vậy bây giờ chúng ta hãy tìm hiểu, nếu phải lập trình hướng đối tượng trong Go, chúng ta sẽ làm thế nào nhé !
Bài viết được biên dịch từ http://spf13.com/post/is-go-object-oriented/
Đối tượng (object) trong Go
Object = dữ liệu (thể hiện thông qua các thuộc tính) + chức năng (thể hiện thông qua phương thức).
Trong Go không có kiểu dữ liệu nào gọi là object
, thay vào đó Go có kiểu struct
. Struct
là kiểu dữ liệu gồm các trường và các method. Chúng ta cùng xem xét ví dụ sau đây để hiểu rõ hơn về struct
:
type rect struct {
width int
height int
}
func (r *rect) area() int {
return r.width * r.height
}
func main() {
r := rect{width: 10, height: 5}
fmt.Println("area: ", r.area()) //area: 50
}
Đoạn code đầu tiên định nghĩa 1 kiểu dữ liệu mới là rect
. Nó là 1 struct gồm 2 trường là width
và height
có kiểu dữ liệu là 1 số nguyên.
Đoạn code thứ 2 khai báo 1 phương thức (method) gắn với struct này. Việc này được thực hiện bằng cách định nghĩa 1 function và gán nó vào kiểu rect. Ở ví dụ trên, nó được gán cho con trỏ đến rect. Mặc dù method được gán cho 1 kiểu, tuy nhiên để có thể gọi method, Go yêu cầu phải có 1 giá trị của kiểu đó để gọi, kể cả khi giá trị đó là zero value của kiểu (với kiểu struct thì zero value là nil).
Đoạn code thứ 3 là func main để chạy chương trình. Nó tạo ra 1 giá trị của kiểu rect
gán vào biến r
, và dùng method area
in diện tích của r
ra màn hình. Rất giống với cách dùng object trong các ngôn ngữ khác phải không nào? Khai báo class, method, tạo 1 object từ class đó gán cho 1 biến và dùng method để tương tác với dữ liệu của object đó.
1 điểm đặc biệt cần chú ý của Go đó là không chỉ có struct, mà các kiểu dữ liệu khác đều có thể có method. Ví dụ, có thể định nghĩa 1 kiểu mới "Counter" có kiểu là int và khai báo các method của nó. Xem ví dụ này ở http://play.golang.org/p/LGB-2j707c.
Tính kế thừa và tính đa hình trong OOP
- Inheritance (tính kế thừa): Lớp cha có thể chia sẻ dữ liệu và phương thức cho các lớp con, các lớp con khỏi phải định nghĩa lại những logic chung, giúp chương trình ngắn gọn. Có 2 kiểu kế thừa trong OOP: kế thừa từ 1 đối tượng (single inheritance) với các ngôn ngữ tiêu biểu như PHP, C#, Java, Ruby, và đa kế thừa (multiple inheritance) như Perl, Python, C++.
- Polymorphism (tính đa hình): ở trong nhiều ngôn ngữ, tính đa hình và kế thừa đan xen vào nhau. Tính đa hình tạo ra mối quan hệ là-một (is-a relationship), trong khi tính kế thừa chỉ là sử dụng những phương thức, thuộc tính có sẵn. Tính đa hình định nghĩa 1 mối quan hệ có ngữ nghĩa giữa 2 (hoặc nhiều hơn) đối tượng. Còn tính kế thừa chỉ định nghĩa 1 mối quan hệ về mặt cú pháp.
- Object Composition (sự kết hợp đối tượng): là khi một đối tượng bao gồm các đối tượng khác, thay vì kế thừa từ các đối tượng này. Tức là thay vì mối quan hệ là-một (is-a) của tính đa hình, thì sử dụng mối quan hệ có-một (has-a) (1 đối tượng có 1 đối tượng khác bên trong).
Tính kế thừa trong Go
Go được thiết kế mà hoàn toàn không có tính kế thừa. Tuy nhiên, điều này không có nghĩa là giữa các đối tượng trong Go không có mối quan hệ nào, thay vào đó các tác gỉa của Go sử dụng 1 cơ chế khác để thể hiện các mối quan hệ này. Đây có thể coi là 1 tính chất tốt của Go, giúp giải quyết các tranh cãi từ lâu xung quanh tính kế thừa.
Tính đa hình và kết hợp trong Go
Thay vì tính thừa kế, Go dựa theo qui tắc Composition over inheritance, tức là 1 đối tượng A sẽ có sẵn các thuộc tính và phương thức của 1 đối tượng B bằng việc bao gồm đối tượng B đó luôn trong thành phần của mình. Go thể hiện tính đa hình và thành phần thông qua `struct` và `interface`.
Tình kết hợp đối tượng trong Go
Cơ chế được Go sử dụng để thể hiện tính kết hợp các đối tượng là nhúng dữ liệu (embedded types). Go cho phép chúng ta nhúng một `struct` bên trong 1 `struct` khác, tạo ra mối quan hệ có-một (has-a) giữa 2 struct.
Ở ví dụ dưới, chúng ta thấy rằng Address vẫn là 1 đối tượng, và được Person bao gồm trong nó. Có thể truy cập vào các trường trong Address bằng cách viết p.Address.tên_trường.
type Person struct {
Name string
Address Address
}
type Address struct {
Number string
Street string
City string
State string
Zip string
}
func (p *Person) Talk() {
fmt.Println("Hi, my name is", p.Name)
}
func (p *Person) Location() {
fmt.Println("I’m at", p.Address.Number, p.Address.Street, p.Address.City, p.Address.State, p.Address.Zip)
}
func main() {
p := Person{
Name: "Steve",
Address: Address{
Number: "13",
Street: "Main",
City: "Gotham",
State: "NY",
Zip: "01313",
},
}
p.Talk() // Hi, my name is Steve
p.Location() // I’m at 13 Main Gotham NY 01313
}
Để mở rộng ví dụ trên, chúng ta sẽ tạo thêm 1 struct mới là `Citizen`, bao gồm `Person` trong nó. 1 Person có thể Talk, 1 Citizen là 1 Person, do vậy Citizen cũng có thể Talk.
type Citizen struct {
Country string
Person
}
func (c *Citizen) Nationality() {
fmt.Println(c.Name, "is a citizen of", c.Country)
}
func main() {
c := Citizen{}
c.Name = "Steve"
c.Country = "America"
c.Talk() // Hi, my name is Steve
c.Nationality() //Steve is a citizen of America
}
Trong Go, có thể tạo gỉa mối quan hệ là-một (tức tạo gỉa tính đa hình) bằng cách sử dụng các trường vô danh(anonymous field).
Ở ví dụ trên, Person là 1 trường vô danh (anonymous field) trong Citizen, tức là chỉ được khai báo kiểu Person chứ không khai báo tên trường. Với cách viết này, Go sẽ cho phép chúng ta coi các trường trong Person nằm ngay trực tiếp trong Citizen. Nhờ vậy ở đoạn code trên, có thể gọi trực tiếp c.Talk(), thay vì phải c.Person.Talk().
Chúng ta cũng có thể khai báo thêm method Talk cho chính Citizen. Lúc này khi gọi method Talk từ 1 giá trị kiểu Citizen, thay vì gọi Person.Talk() thì Go sẽ gọi method Talk được gán trực tiếp cho struct Citizen.
func (c *Citizen) Talk() {
fmt.Println("Hello, my name is", c.Name, "and I'm from", c.Country)
}
func main() {
c := Citizen{}
c.Name = "Steve"
c.Country = "America"
c.Talk() // Hello, my name is Steve and I'm from America
c.Nationality() // Steve is a citizen of America
}
Tại sao các trường vô danh không thật sự thể hiện tính đa hình
1. Các trường vô danh luôn truy cập được.
Trong Go, khi sử dụng các trường vô danh, Go sẽ tạo 1 tên truy cập trùng tên với kiểu dữ liệu đó. Nhờ vậy chúng ta luôn có thể truy cập đến các method của trường vô danh đó.
func main() {
c := Citizen{}
c.Name = "Steve"
c.Country = "America"
c.Talk() // > Hello, my name is Steve and I'm from America
c.Person.Talk() // > Hi, my name is Steve
c.Nationality() // > Steve is a citizen of America
}
2. Tính đa hình thật
Nếu đúng là tính đa hình thật sự thì các trường vô danh phải làm kiểu dữ liệu bên ngoài nó trở thành chính nó. Tuy nhiên trong Go, trường hợp này không xảy ra. 2 kiểu dữ liệu vẫn khác nhau. Cùng xem ví dụ sau:
package main
type A struct{
}
type B struct {
A //B is-a A
}
func save(A) {
//do something
}
func main() {
b := B
save(&b); //OOOPS! b IS NOT A
}
// prog.go:17: cannot use b (type *B) as type A in function argument
// [process exited with non-zero status]
Tính đa hình thật sự trong Go
Như đã nói ở trên, tính đa hình thể hiện mối quan hệ là-một (is-a). Trong Go, mỗi kiểu dữ liệu là khác nhau, nhưng chúng có thể cùng chung 1 interface. Interface được dùng để làm input và output của các function (và method) và tạo ra mối quan hệ là-một giữa kiểu dữ liệu và interface.
Tại sao lại gọi cách dùng anonymous field như ví dụ trên là tính đa hình gỉa? Bổ sung tiếp vào ví dụ trên đoạn code sau:
func SpeakTo(p *Person) {
p.Talk()
}
func main() {
p := Person{Name: "Dave"}
c := Citizen{Person: Person{Name: "Steve"}, Country: "America"}
SpeakTo(&p)
SpeakTo(&c)
// prog.go:48: cannot use c (type *Citizen) as type *Person in function argument
// [process exited with non-zero status]
}
Từ đoạn code trên, chúng ta thấy rằng, mặc dù Citizen được chỉa sẻ rất nhiều thuộc tính, phương thức từ Person, chúng vẫn bị coi là 2 kiểu dữ liệu khác nhau.
Để giải quyết vấn đề này, chúng ta sẽ tạo 1 interface Human và sử dụng nó như 1 argument của function SpeakTo.
type Human interface {
Talk()
}
func SpeakTo(h Human) {
h.Talk()
}
func main() {
p := Person{Name: "Dave"}
c := Citizen{Person: Person{Name: "Steve"}, Country: "America"}
SpeakTo(&p) // Hi, my name is Dave
SpeakTo(&c) // Hi, my name is Steve
}
Từ ví dụ trên, có thể nhận ra được 2 điểm chính về tính đa hình trong Go:
1. Chúng ta có thể sử dụng Anonymous fields để tuân theo 1 interface. Và 1 đối tượng có thể tuân theo nhiều interface khác nhau
2. Interface được sử dụng để giúp cho các kiểu dữ liệu khác nhau có thể được sử dụng làm dữ liệu đầu vào hoặc gía trị trả về của 1 function.
Tổng kết
Như vậy các định nghĩa, tính chất cơ bản trong lập trình hướng đối tượng đều được thể hiện tốt trong Go.
Go sử dụng `struct` để biểu diễn sự kết hợp giữa dữ liệu và logic. Thông qua tính thành phần (composition), tạo ra các mối quan hệ có-1 (has-a) giữa các struct để giảm thiểu code bị lặp trong khi vẫn thể hiện được tính kế thừa. Go sử dụng interface để tạo mối quan hệ có-một giữa các kiểu dữ liệu nhằm loại bỏ các khai báo thừa, không cần thiết.
Bình luận