1. Khái niệm kỹ thuật được đề cập
-
abstraction
-
polymorphism
-
inheritance
-
composition
-
class
-
object
-
type struct
-
type embedding
2. Giới thiệu
Nếu bạn mới làm quen với lập trình, có thể bạn chưa từng tiếp xúc với “lập trình hướng đối tượng”. Nếu bạn là một lập trình viên kỳ cựu, rất có thể bạn đã phát triển các chương trình bằng ngôn ngữ hướng đối tượng: bạn biết đối tượng là gì, đóng gói có nghĩa là gì…v.v.
Mục đích của phần này là xem xét tất cả các đặc điểm của ngôn ngữ hướng đối tượng. Bằng cách đó, chúng ta sẽ cố gắng hiểu tại sao Go khác với các ngôn ngữ truyền thống đó.
Hầu hết học viên của tôi đều sợ các thuật ngữ và khái niệm liên quan đến lập trình hướng đối tượng. Đừng ấn tượng với những thuật ngữ phức tạp đó.
3. Ngôn ngữ hướng đối tượng là gì
Khái niệm này không phải là mới.
Định nghĩa của loại ngôn ngữ này là gì? Ngôn ngữ hướng đối tượng là “một ngôn ngữ lập trình cho phép người dùng thể hiện một chương trình dưới dạng các đối tượng và thông điệp giữa các đối tượng đó” .
Định nghĩa nhấn mạnh rằng đây là một loại ngôn ngữ lập trình cung cấp cho người dùng các đối tượng và cách truyền tải thông điệp giữa các đối tượng.
Các ngôn ngữ hướng đối tượng có một số đặc điểm chung:
-
Lớp và đối tượng là các khối xây dựng chương trình.
-
Nó cung cấp một mức độ trừu tượng nhất định
-
Nó cung cấp cơ chế đóng gói
-
Đa hình là có thể
-
Các lớp có thể kế thừa lẫn nhau
Hãy cùng xem xét từng đặc điểm đó để xác định xem Go có thể được coi là Ngôn ngữ lập trình hướng đối tượng hay không.
4. Lớp, đối tượng và thể hiện
Phần này sẽ đưa ra một số định nghĩa cơ bản về class, object, instance. Đó là những định nghĩa chung áp dụng cho hầu hết các ngôn ngữ lập trình hướng đối tượng
Class
-
Đây là một thực thể lập trình do người dùng định nghĩa
-
Nó định nghĩa một tập hợp các thuộc tính (mỗi thuộc tính có một kiểu)
-
Thuộc tính cũng được gọi là thuộc tính, trường, thành viên dữ liệu
-
Nó định nghĩa các phương thức (hành vi và hoạt động) và cách triển khai chúng.
-
Các phương thức và thuộc tính có “khả năng hiển thị” cụ thể. Các thuộc tính và phương thức chỉ có thể được gọi trong các điều kiện cụ thể.
-
Thông thường, các lớp có một “hàm tạo”, đây là một phương thức được thiết kế để tạo ra một đối tượng.
- Các hàm tạo được sử dụng để khởi tạo “trạng thái” của đối tượng, tức là khởi tạo giá trị của các thuộc tính thành các giá trị cụ thể.
Sau đây là ví dụ về một lớp trong C++:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// C++ #include <iostream> using namespace std; class Teacher { public: string firstname; // Attribute string lastname; // Attribute void sayHello() { // Method cout << "Hello World!"; } }; int main() { Teacher myTeacher; myTeacher.sayHello(); return 0; } |
Object
-
Các đối tượng được tạo ra trong thời gian chạy chương trình. Chúng là “thực thể thời gian chạy”.
-
Gọi hàm tạo lớp sẽ tạo ra một đối tượng.
-
Một đối tượng cũng được gọi là “một thể hiện”
-
Quá trình tạo ra một đối tượng được gọi là “tạo phiên bản”.
5. Class in Go?
Go có “ types ” nhưng không có class. Một type chỉ định: “một tập hợp các giá trị”
“các hoạt động và phương thức cụ thể cho các giá trị đó” Các kiểu cấu trúc cho phép bạn định nghĩa một tập hợp các trường có tên và kiểu. Hãy lấy ví dụ về đối tượng teacher:
1 2 3 4 5 6 7 |
// object-oriented/classes/main.go type Teacher struct { id int firstname string lastname string } |
Chúng tôi định nghĩa kiểu Giáo viên có ba trường: id kiểu int
, firstname và lastname kiểu string
.
Chúng ta cũng có thể đính kèm các phương thức vào loại này:
1 2 3 |
func (t *Teacher) sayHiToClass(){ fmt.Printf("Hi class my name is %s %s", t.firstname, t.lastname) } |
Chúng tôi đã định nghĩa một kiểu struct với một cấu trúc dữ liệu và các phương thức kèm theo. Hãy tạo một thể hiện t của đối tượng đó:
1 |
t := Teacher{12, "John", "Doe"} |
Sau đó chúng ta có thể sử dụng các phương thức được xác định trên đối tượng với trường hợp cụ thể đó:
1 |
t.sayHiToClass() |
Bạn cũng có thể tạo một kiểu dựa trên một kiểu khác:
1 |
type StatusCode int |
Và kiểu này cũng có thể có các phương thức:
1 2 3 |
func (s *StatusCode) String() string { return fmt.Sprintf("Status Code %d",s) } |
Trong trường hợp này, tập hợp các giá trị StatusCode
được giới hạn ở số nguyên
-
Cấu trúc kiểu Go gần giống với lớp
-
Cấu trúc kiểu có các trường
-
Bạn có thể định nghĩa các phương thức được đính kèm vào các kiểu
-
So với các ngôn ngữ khác, không có “hàm tạo” nào được tích hợp sẵn.
-
Các trường có kiểu struct được tự động khởi tạo bởi Go (thành giá trị bằng không của kiểu trường).
-
Thay vì sử dụng hàm tạo, một số thư viện định nghĩa một
New
hàm:
Sau đây là một New
chức năng của mô-đun github.com/gin-gonic/gin
:
1 2 3 4 5 6 7 |
package gin // New returns a new blank Engine instance without any middleware attached. // ... func New() *Engine { //... } |
Sau đây là một New
hàm được định nghĩa trong gói chuẩn math/rand
:
1 2 3 4 5 6 7 |
package rand // New returns a new Rand that uses random values from src // to generate other random values. func New(src Source) *Rand { //.. } |
Và một sản phẩm khác trong doc
gói:
1 2 3 4 5 6 7 |
package doc //... func New(pkg *ast.Package, importPath string, mode Mode) *Package { //... } |
-
Bạn có thể lưu ý rằng, nói chung,
New
hàm trả về một con trỏ -
Cũng phổ biến khi có một
New
hàm trả về một con trỏ VÀ mộterror
6. Trừu tượng
Một khái niệm quan trọng khác của lập trình hướng đối tượng là tính trừu tượng.
Khi chúng ta xây dựng mã, chúng ta gọi các phương thức hoặc hàm để thực hiện một hoạt động cụ thể:u.store()
Ở đây chúng ta đang bảo chương trình của mình gọi phương thức store
. Nó không yêu cầu bạn phải biết về cách phương thức được triển khai (cách nó được mã hóa bên trong).
-
Thuật ngữ trừu tượng bắt nguồn từ tiếng Latin abstractio. Thuật ngữ này truyền tải ý tưởng về sự tách biệt, về việc lấy đi một cái gì đó.
-
Việc gọi hàm và phương thức đang làm mất đi phần triển khai cụ thể và các chi tiết phức tạp.
-
Ưu điểm chính của việc ẩn chi tiết triển khai là bạn có thể thay đổi mà không ảnh hưởng đến lệnh gọi hàm (nếu bạn không thay đổi chữ ký hàm).
7. Đóng gói
Sự đóng gói bắt nguồn từ thuật ngữ tiếng Latin “capsula” có nghĩa là “một chiếc hộp nhỏ”. Trong tiếng Pháp, thuật ngữ “capsule” dùng để chỉ thứ gì đó được đóng hộp. Trong sinh học, thuật ngữ này được sử dụng để chỉ màng tế bào.
Capsule là một vật chứa mà bạn có thể đóng hộp các vật thể. Đóng gói một thứ gì đó tương đương với việc bao bọc nó bên trong một capsule.
Nếu chúng ta quay lại với khoa học máy tính, đóng gói là một kỹ thuật thiết kế được sử dụng để nhóm các hoạt động và dữ liệu vào các viên nang mã hóa riêng biệt. Quyền truy cập vào các hoạt động và dữ liệu đó được quy định và các “viên nang” đó xác định các giao diện bên ngoài để tương tác với chúng.
Ngôn ngữ lập trình hướng đối tượng cung cấp cho nhà phát triển khả năng đóng gói các đối tượng. Có phải Go cũng vậy không? Chúng ta có thể đóng gói mọi thứ không? Chúng ta có thể định nghĩa các giao diện bên ngoài nghiêm ngặt trên các gói không?
Câu trả lời là có! Khái niệm gói cho phép bạn xác định một giao diện bên ngoài nghiêm ngặt.
Khả năng hiển thị
Hãy lấy một ví dụ. Chúng ta định nghĩapackageA
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
// object-oriented/encapsulation/packageA/packageA.go package packageA import "fmt" func DoSomething() { fmt.Println("do something") } func onePrivateFunction() { //... } func anotherPrivateFunction() { //... } func nothingToSeeHere() { //.. } |
Chỉ packageA
định nghĩa một hàm public DoSomething
. Thế giới bên ngoài có thể sử dụng hàm này, nhưng nó sẽ không biên dịch nếu bạn cố gắng truy cập vào một hàm private ( onePrivateFunction
, anotherPrivateFunction
…). Không có từ khóa public hoặc private trong ngôn ngữ; khả năng hiển thị được xác định bằng trường hợp của chữ cái đầu tiên của các phần tử được xác định (phương thức, kiểu, hằng số …)
Ưu điểm của việc đóng gói
-
Nó ẩn việc triển khai gói cho người dùng.
-
Nó cho phép bạn thay đổi việc triển khai mà không phải lo lắng về việc phá vỡ thứ gì đó trong mã của người dùng. Bạn chỉ có thể phá vỡ thứ gì đó nếu bạn thay đổi một trong các phần tử được xuất (công khai) của gói.
-
Việc bảo trì và tái cấu trúc dễ thực hiện hơn. Những thay đổi nội bộ hiếm khi ảnh hưởng đến khách hàng.
8. Đa hình
Đa hình là khả năng của một cái gì đó có nhiều hình dạng. Các từ này xuất phát từ thuật ngữ poly (có nghĩa là nhiều) và “morphism” thiết kế các hình dạng, hình dạng của một cái gì đó.
Làm thế nào để áp dụng điều đó vào ngôn ngữ lập trình? Để hiểu về đa hình, bạn phải tập trung vào các hoạt động, tức là các phương thức.
Một hệ thống được gọi là đa hình nếu “cùng một hoạt động có thể hoạt động khác nhau trong các lớp khác nhau”.
Hãy tinh chỉnh ví dụ về giáo viên của chúng ta. Một giáo viên trường học không giống với một giáo viên đại học; do đó chúng ta có thể định nghĩa hai “lớp” khác nhau, hai kiểu struct khác nhau. Kiểu SchoolTeacher
struct :
1 2 3 4 5 |
type SchoolTeacher struct { id int firstname string lastname string } |
Và UniversityTeacher
kiểu struct:
1 2 3 4 5 6 7 |
type UniversityTeacher struct { id int firstname string lastname string department string specialty string } |
Loại giáo viên thứ hai có nhiều lĩnh vực hơn cho chuyên môn và khoa của mình.
Tiếp theo, chúng ta sẽ định nghĩa cùng một hoạt động cho hai loại giáo viên:
1 2 3 |
func (t SchoolTeacher) SayHiToClass() { fmt.Printf("Hi class my name is %s %s\n", t.firstname, t.lastname) } |
Và đối với loại còn lại, cách thực hiện có đôi chút khác biệt:
1 2 3 |
func (t UniversityTeacher) SayHiToClass() { fmt.Printf("Hi dear students my name is %s %s I will teach you %s\n", t.firstname, t.lastname, t.specialty) } |
Giáo viên đại học luôn nêu rõ chuyên ngành và khoa của mình khi chào lớp. Trong khi giáo viên trường học thì không.
Ở đây chúng ta có cùng một hoạt động được định nghĩa trên hai kiểu khác nhau; chúng ta có thể định nghĩa một giao diện Teacher:
1 2 3 |
type Teacher interface { SayHiToClass() } |
Tự động các loại SchoolTeacher
và UniversityTeacher
sẽ triển khai giao diện (bạn không cần phải chỉ định nó trong mã nguồn). Hãy tạo một lát cắt bao gồm hai đối tượng:
1 2 |
john := UniversityTeacher{id:12,firstname:"John",lastname:"Doe",department:"Science",specialty:"Biology"} marc := SchoolTeacher{id:23,firstname:"Marc",lastname:"Chair"} |
John là một giáo viên đại học chuyên ngành sinh học; Marc dạy trẻ em. Công việc của họ không giống nhau, nhưng họ chia sẻ một hoạt động chung: hoạt động chào hỏi lớp học. Do đó, họ triển khai loại giao diện Teacher. Chúng ta có thể nhóm hai đối tượng đó trong một lát cắt bao gồm các đối tượng Teacher:
1 |
teachers := []Teacher{john,marc} |
Sau đó, chúng ta có thể lặp lại lát cắt và yêu cầu chúng chào lớp của chúng theo phong cách cụ thể của chúng:
1 2 3 4 5 6 7 8 |
// object-oriented/polymorphism/main.go func main() { //... teachers := []Teacher{john, marc} for _, t := range teachers { t.SayHiToClass() } } |
Trong danh sách mã cuối cùng này, chúng ta đã khám phá ra sức mạnh của đa hình:
-
Go có thể tìm ra phương thức nào để gọi dựa trên loại t.
-
Cùng một loại giao diện thiết kế các triển khai khác nhau
Teacher
9. Kế thừa
Một đứa trẻ có thể thừa hưởng các đặc tính di truyền của tổ tiên. Nhưng chúng ta không thể quy trẻ em thành tổ tiên của chúng. Khi chúng lớn lên, chúng sẽ xây dựng các đặc tính riêng của mình.
Khái niệm kế thừa tương tự cũng áp dụng cho các lớp trong lập trình hướng đối tượng. Khi bạn tạo một ứng dụng phức tạp, thường thì các đối tượng được xác định sẽ chia sẻ một số thuộc tính và hoạt động chung.
Ví dụ, mỗi giáo viên đều có tên, họ, ngày sinh, địa chỉ, v.v. Giáo viên trường học và giáo viên đại học đều có cả hai thuộc tính đó. Tại sao không tạo một siêu đối tượng sẽ chứa các thuộc tính chung đó và chia sẻ chúng với các lớp con?
Đó là ý tưởng về sự kế thừa. Các đối tượng sẽ định nghĩa các thuộc tính và hoạt động riêng biệt của chúng và kế thừa tổ tiên của chúng. Điều này sẽ làm giảm sự lặp lại của mã. Các thuộc tính được chia sẻ chỉ được định nghĩa trong siêu đối tượng, chỉ một lần.
Có thể với Go không? Câu trả lời là không. Chúng ta sẽ thấy một khái niệm thiết kế quan trọng: nhúng kiểu. Mục tiêu chính của phần này là xem liệu chúng ta có thể thấy nhúng kiểu như một dạng kế thừa hay không.
Nhúng loại đơn
Bạn có thể nhúng một kiểu vào một kiểu khác. Hãy lấy ví dụ trước. Chúng ta có một kiểu struct human với hai trường:
1 2 3 4 |
type Human struct { firstname string lastname string } |
Điều này đại diện cho một con người. Chúng ta thêm cho anh ta khả năng đi bộ:
1 2 3 |
func (t Human) Walk() { fmt.Printf("I walk...\n") } |
Một giáo viên trường học và một giáo viên đại học đều là con người; chúng ta có thể nhúng loại này Human
vào hai loại đó:
1 2 3 4 5 6 7 8 |
type SchoolTeacher struct { Human } type UniversityTeacher struct { Human department string specialty string } |
Để thực hiện việc này, chúng ta chỉ cần thêm tên của loại dưới dạng một trường mới (không có tên).
Bằng cách đó, chúng ta có thể xác định được hai giáo viên của mình:
1 2 |
john := UniversityTeacher{Human: Human{ firstname: "John", lastname: "Doe"}, department: "Science", specialty: "Biology"} marc := SchoolTeacher{Human: Human{firstname:"Marc",lastname:"Chair"}} |
Chúng ta có thể gọi phương thức Walk on john and marc:
1 2 3 4 5 6 7 8 9 |
// object-oriented/inheritance/single-type-embedding/main.go //... func main() { john.Human.Walk() marc.Human.Walk() // or john.Walk() marc.Walk() } |
Kết quả sẽ là:
1 2 |
I walk... I walk... |
Nhúng nhiều loại
Bạn không bị giới hạn ở một kiểu nhúng vào một cấu trúc kiểu. Bạn có thể nhúng nhiều kiểu. Hãy giới thiệu kiểu Researcher
:
1 2 3 4 |
type Researcher struct { fieldOfResearch string numberOfPhdStudents int } |
A Researcher
có một lĩnh vực nghiên cứu và một số lượng nhất định các nghiên cứu sinh tiến sĩ mà anh ta phải theo dõi. Các giáo viên đại học cũng là các nhà nghiên cứu. Chúng ta có thể nhúng loại Researcher
vào loại UniversityTeacher
:
1 2 3 4 5 6 7 8 |
// object-oriented/inheritance/multiple-type-Embedding/main.go type UniversityTeacher struct { Human Researcher department string specialty string } |
Xung đột tên
Nhưng có một hạn chế quan trọng đối với kiểu nhúng: xung đột tên. Nếu bạn muốn nhúng hai kiểu đến từ các gói khác nhau nhưng có cùng tên, chương trình của bạn sẽ không biên dịch được. Hãy lấy một ví dụ. Bạn có hai gói packageA
và packageB
mỗi gói khai báo một kiểu có tên là MyType
:
1 2 3 4 5 6 7 8 9 10 11 |
package packageA type MyType struct { id int } package packageB type MyType struct { id int } |
Bây giờ trong gói chính của bạn, bạn định nghĩa một gói MyOtherType
nhúng cả hai kiểu:
1 2 3 4 |
type MyOtherType struct { packageA.MyType packageB.MyType } |
Có sự trùng lặp của tên trường. Hai trường đầu tiên là trường nhúng; do đó, tên của chúng đều MyType
thuộc kiểu struct MyOtherType
. Không thể có hai trường có cùng tên. Chương trình của bạn sẽ không biên dịch:
1 2 3 |
typeEmbedding/nameClash/main.go:10:10: duplicate field MyType Compilation finished with exit code 2 |
Truy cập trực tiếp vào các thuộc tính và hoạt động
Trong các ngôn ngữ hướng đối tượng khác, lớp B kế thừa từ lớp A có thể truy cập trực tiếp tất cả các thuộc tính và hoạt động được định nghĩa trong lớp A.
Nó có nghĩa là gì? Hãy lấy ví dụ về PHP. Chúng ta định nghĩa một lớp A
có phương thức sayHi
(public nghĩa là thế giới bên ngoài có thể truy cập vào nó, giống như tên phương thức bắt đầu bằng ký tự viết hoa):
1 2 3 4 5 6 7 8 9 |
// PHP Code class A { public function sayHi() { echo 'Hi !'; } } |
Sau đó, trong cùng một tập lệnh, chúng ta định nghĩa lớp B
kế thừa từ lớp A
. Từ khóa trong PHP là extends
:
1 2 3 4 5 6 7 8 |
// PHP Code class B extends A { public function sayGoodbye() { echo 'Goodbye !'; } } |
Lớp này B
là lớp kế thừa của A
. Do đó, chúng ta có thể truy cập các phương thức của trực tiếp A
bằng một đối tượng có kiểu B
:
1 2 3 4 5 6 7 |
// PHP Code // $b is a new instance of class B $b = new B(); // we call the method sayHi, which is defined in class A $b->sayHi(); // We call the method sayGoodbye defined in the class B $b->sayGoodbye(); |
Tại đây chúng ta có thể truy cập trực tiếp vào các thuộc tính và hoạt động được xác định trên A từ B.
Chúng ta có thể truy cập trực tiếp các thuộc tính của kiểu nhúng bằng cách nhúng kiểu. Chúng ta có thể viết:
1 |
john.Walk() |
Chúng ta cũng có thể viết:
1 |
john.Human.Walk() |
Thành phần trên kế thừa
Nhúng kiểu và kế thừa có cùng mục tiêu: tránh lặp lại mã và tối đa hóa việc tái sử dụng mã. Nhưng thành phần và kế thừa là hai thiết kế rất khác nhau. Phương pháp tiếp cận của Go là thành phần hơn là kế thừa.
Tôi sẽ viết về sự phân biệt được đưa ra bởi các tác giả của cuốn sách rất nổi tiếng Design Patterns: Elements of Reusable Object-Oriented Software :
-
Kế thừa là chiến lược “tái sử dụng hộp trắng” : Các lớp thừa kế có quyền truy cập vào các thuộc tính nội bộ của lớp tổ tiên; các thuộc tính và hoạt động nội bộ có thể được lớp thừa kế nhìn thấy.
-
Composition là chiến lược “tái sử dụng hộp đen” : Các lớp được hợp thành cùng nhau, kiểu
A
nhúng một kiểuB
không có quyền truy cập vào tất cả các thuộc tính và hoạt động bên trong. Các trường và phương thức công khai có thể truy cập được; còn các trường và phương thức riêng tư thì không.
Hãy lấy một ví dụ. Chúng ta có một cart
định nghĩa kiểu struct Cart
:
1 2 3 4 5 6 |
package cart type Cart struct { ID string locked bool } |
Cấu trúc kiểu này có hai trường có tên là ID
và locked
. Trường ID
được xuất. Trường có tên locked
không thể truy cập được từ gói khác.
Trong chương trình chính, chúng ta tạo một cấu trúc kiểu khác có tên là User
:
1 2 3 4 5 |
package main type User struct { cart.Cart } |
Kiểu cart.Cart
được nhúng vào User
. Nếu chúng ta tạo một biến có kiểu User
thì chúng ta chỉ có thể truy cập các trường được xuất từ cart.Cart
:
1 2 3 4 |
func main() { u := User{} fmt.Println(u.Cart.ID) } |
Nếu chúng ta gọi
1 |
fmt.Println(u.Cart.locked) |
chương trình của chúng tôi sẽ không biên dịch được với thông báo lỗi sau:
1 |
typeEmbedding/blackBoxReuse/main.go:17:27: u.Cart.locked undefined (cannot refer to unexported field or method private) |
1 |
1 |
Ưu điểm của thành phần so với kế thừa
-
Trong mô hình kế thừa, lớp heir có thể truy cập vào các thành phần bên trong của lớp ancestor. Các thành phần bên trong của lớp ancestor có thể được nhìn thấy bởi heir; quy tắc đóng gói bị phá vỡ phần nào. Trong khi với composition, bạn không thể truy cập vào các phần ẩn của lớp ancestor. Kỹ thuật này đảm bảo rằng người dùng của structs của bạn sẽ chỉ sử dụng các phương thức và trường đã xuất.
-
Composition thường đơn giản hơn là inheritance. Nếu bạn là một lập trình viên có kinh nghiệm, bạn có thể đã từng đối mặt với thiết kế hướng đối tượng, trong đó inheritance được sử dụng quá mức. Trong những trường hợp như vậy, dự án khó hiểu hơn và những người mới phải dành hàng giờ với các lập trình viên cao cấp để hiểu biểu đồ các lớp.
Leave a Reply