Trừu tượng là gì trong lập trình
Trừu tượng (abstract) nghĩa là một cái gì đó chung chung, không rõ ràng.
Nếu có ai đó hỏi bạn "Con mèo thích ăn gì ?" có thể bạn sẽ có ngay câu trả lời là "Con mèo thích ăn cá". Tuy nhiên nếu là "Động vật thích ăn gì ?" thì bạn sẽ trả lời thế nào?
Sẽ có 2 trường hợp xảy ra: Hoặc là không trả lời được, hoặc là có rất nhiều câu trả lời. Điều này hoàn toàn tương tự trong thế giới lập trình:
class Animal {
func eat() {
// ở đây hoặc là bỏ trống hoặc là if else đủ giống loài trên đời
}
}
Bài này mình viết khá lâu rồi, code trong bài minh hoạ là Swift 3.0 có khả năng không chạy được nữa! Tuy nhiên về cách tư duy thì không đổi, các bạn vẫn có thể tham khảo nhé.
Giải quyết cái này quá dễ, ai từng học OOP sẽ biết là chỉ cần khai báo cho class Animal và method eat() là astract. Sau đó cho các subclass cụ thể hơn kế thừa Animal và override lại hàm eat() là xong.
OK đó là trong trường lớp, chắc chắn rằng không ai dùng "if else" cho tất cả các trường hợp cả. Thực tế đáng buồn là không ít bạn sau khi tốt nghiệp đã code nhiều ứng dụng có module người dùng như sau:
class User {
func doSomething() {
// if role == admin
// else if role == mod
// else if role == editor
// else if role == customer
// ...
}
}
Lý do của việc trên cũng khá là tự nhiên vì ứng dụng chỉ có vài role thôi. Nên ta cứ thế mà "if else" là đủ, code cũng "trực quan", đứng 1 chỗ là phơi bày tất cả. Nếu bạn đang nghĩ vậy, con đường bạn đang đi là thẳng tiến tới code editor. Công việc chính của bạn là edit code cũ, vì cứ khi ứng dụng thêm role mới hoặc sửa đổi role cũ là bạn sẽ mở đúng file code này ra, lục lọi trong đống code hỗn độn này. Tới một lúc nào đó vì stress bạn sẽ chửi luôn cái thằng viết ra file này, mà đó là chính bạn chứ không ai khác.
Mặt khác xét về ngữ nghĩa, nếu ta đặt lại câu hỏi ngược lại giống như class Animal trên "Người dùng có thể làm gì?", "Khách hàng có thể làm gì?", "Quản trị viên có thể làm gì?". Bạn sẽ có ngay câu trả lời, đó là đưa class User và hàm doSomething() về astract.
// Do Swift không có khái niệm abstract class và abstract method
// nên mình dùng tạm fatalError() để báo hiệu hàm này cần implement
class User {
func doSomething() {
fatalError("Subclasses need to implement the `doSomething()` method.")
}
}
class Admin : User {
override func doSomething() {
// To do: things admin can do
}
}
class Customer : User {
override func doSomething() {
// To do: things customer can do
}
}
//...
Trên thực tế, trong ứng dụng sẽ không có đối tượng thực thể nào gọi là "người dùng" cả, họ phải là một trong những role chúng ta định ra. Vì thế việc khởi tạo một instance của User và gọi hàm doSomething() là rất vô lý. Từ đó cho ta báo lỗi để ngăn chặn việc này.
Trong Swift có Protocol dùng làm abstract tốt hơn nhưng ở đây mình sẽ nói đến Protocol trong phần sau
Lợi ích của trừu tượng trong kiến trúc ứng dụng
Nếu bạn đã từng nghe lợi ích của kiến trúc ứng dụng đến phát chán như "giúp code dễ bảo trì và mở rộng" thì hẵn là bạn sẽ nghĩ "nó dùng cái gì để làm được điều đó ?". Đó chính là "trừu tượng". Đây cũng là một trong những công cụ để xây dựng lên các Design Pattern và kiến trúc ứng dụng to lớn.
Bản chất của kiến trúc ứng dụng là sự phân chia và quản lý code hiệu quả. Sự phân chia này dẫn đến các liên kết phải được kiểm soát để giảm lệ thuộc giữa các object với nhau. Sự trừu tượng hoá đóng vai trò là "chất keo" để kết dính các thành phần trong kiến trúc hiệu quả.
Mặt khác việc trừu tượng hóa các model sẽ giúp ta tạo ra các ràng buộc cho những người làm việc với chúng ta (thường là các end developer). Như ví dụ "doSomething" chắc chắn sẽ được implement trong các subclass của User chứ không thể là tên hàm khác. Đây được coi như là sự thống nhất về khai báo: tên hàm, tham số và trả về - thường dùng trong các interfaces.
Cách rèn luyện tư duy trừu tượng hóa
Theo kinh nghiệm của mình. Đa số những người mới học lập trình, khi có ý tưởng sẽ lao ngay vào code rồi chạy để kiểm thử. Đây là một thói quen rất không tốt, nó sẽ giết chết tư duy xây dựng kiến trúc của các bạn. Thay vào đó chúng ta cứ liệt kê ra các tính năng cần làm, các model phải có, mối quan hệ giữa chúng ra sao và hành vi của chúng thế nào. Từ đó ta có thể nhìn ra được những điểm chung, những chỗ cần mô hình hóa chúng cho hợp lý hơn.
Viết code chi tiết (implement) thường sẽ dễ hơn xây dựng các đối tượng abstract. Mặc dù việc xây dựng các "bộ khung" này chẳng phải code gì nhiều cả. Tuy nhiên việc này định hình cho tất cả các code chi tiết về sau, khung mà sai thì có đắp cái gì vào nó cũng sai. Chính vì vậy, những kiến trúc sư thường là những người rất có kinh nghiệm, họ đã gặp rất nhiều bài toán thể loại này nên dễ dàng tổng quát hóa vấn đề. Nên bây giờ nếu bạn chưa thể làm được ngay cũng đừng nản lòng. Kiến trúc ứng dụng hay trừu tượng hoá là kỹ năng cần thời gian để rèn luyện.
Trong quá trình phát triển ứng dụng đây có thể là điểm bạn cần lưu ý:
- Có những class có method khá giống nhau, thậm chí chung logic.
- Class này code quá nhiều, hẳn là phải có cách để chia nhỏ chúng hiệu quả.
- Đoạn code này sử dụng ở nhiều nơi nhưng hiện tại phải copy và paste.
- Có cách nào khi mở rộng ứng dụng mà ít phải thay đổi code cũ không ?
Nếu bạn vẫn luôn gặp những điều trên nhưng vẫn chưa giải quyết được. Đừng lo, cái bạn cần tiếp theo là công cụ. Rất sớm thôi bạn cũng sẽ làm được.
Ở phần còn lại của bài viết này mình sẽ đưa ra một ví dụ về sức mạnh của việc "trừu tượng hóa".
Sử dụng trừu tượng để giảm sự lệ thuộc giữa các đối tượng
Giảm lệ thuộc hay lệ thuộc thấp (loose coupling) là một khái niệm khá thú vị. Thông thường khi làm việc với các đối tượng sẽ có hiện tượng gọi đến các phương thức và thuộc tính của nhau. Điều này khiến chúng bị dính chặt (tightly) vào nhau và gây cản trở việc tái sử dụng chúng.
Ở đây để minh họa mình sẽ đưa ra một cặp đối tượng kinh điển là "Con nợ" và "Chủ nợ". Dưới đây sẽ là một chương trình mô phỏng việc vay mượn tiền và trả tiền giữa 2 đối tượng này.
Cách 1: biết tuốt về nhau, cứ có đối tượng là moi hết ra xài
// Chủ Nợ vs Con Nợ
// Chủ Nợ: có thể đòi nợ con nợ
// Con Nợ: có thể mượn tiền chủ nợ
// Theo cách viết thông thường ta có:
class Person {
var name:String
var age:Int
var money:Int = 0
init(name:String, age:Int) {
self.name = name
self.age = age
}
}
// Chủ nợ
class Lender:Person {
var borrower:Borrower? // giả sử chủ nợ sẽ có 1 con nợ
func requestPayment() {
if let borrower = self.borrower {
if borrower.money >= borrower.debt {
borrower.money -= borrower.debt
self.money += borrower.debt
borrower.debt = 0
}
}
}
}
// Con nợ
class Borrower:Person {
// weak ở đây để chống retain cycle vì 2 class tham chiếu vào nhau
weak var lender:Lender? // giả sử con nợ thì có 1 chủ nợ thôi
var debt:Int = 0
func borrowMoney(lender:Lender,money: Int) {
if lender.money >= money {
lender.money -= money
self.money += money
debt = money
self.lender = lender
lender.borrower = self
}
}
}
// logic vay tiền và đòi tiền đâu đó trong ứng dụng như sau
// Khởi tạo các đối tượng chủ nợ và con nợ
let lenderObj = Lender(name: "Teo", age: 22)
lenderObj.money = 2000
let borrowerObj = Borrower(name: "Ti", age: 18)
// Con nợ mượn chủ nợ 1000
borrowerObj.borrowMoney(lenderObj, money: 1000)
// Một thời gian sau chủ nợ tới đòi tiền con nợ
lenderObj.requestPayment()
Đoạn code trên đại khái là ta sẽ viết cho 2 đối tượng có thể gọi đến nhau thông qua các method của chúng để vay và trả tiền. Tới đây chương trình không có lỗi và ta tự tin rằng mình đã có kiến thức tốt về hướng đối tượng.
Nếu chúng ta để ý sẽ thấy rằng hình như có gì đó không ổn .... Người mượn tiền thì biết rõ số tiền của người cho mượn và người cho mượn cũng thế. Giống kiểu bạn giật bóp tiền người ta rồi tự lấy tiền và khi nợ tới hạn người ta cũng đòi tiền theo cách mà bạn mượn tiền họ. Tới đây ta nói 2 đối tượng chủ nợ và con nợ biết quá nhiều về nhau hay nói 1 cách khác là dính chặt vào nhau (tight). Mà đó là điều mà ta nên né tránh để xây dựng ứng dụng có kiến trúc tốt.
Thêm vào đó, cách viết trên ta giả sử nếu con nợ (Borrower) có thẻ tín dụng, tiền trong ngân hàng thì sao. Lúc đó ta phải update cả 2 object trên gần như toàn bộ logic cho vay tiền và trả tiền !!! Và điều này trong thực tế ta gặp rất nhìu, vd như mối quan hệ giữa đơn hàng (Order) và giỏ hàng (Cart) và Sản Phẩm hoặc Kho ... Theo cách trên là cứ lấy hết ruột gan nhau ra mà xài (dù chúng có được encapsulation hay không).
Cách 2: Logic của class nào ra class nấy - Tôi biết bạn là ai và tôi biết bạn có thể làm cái tôi muốn
// Chủ nợ
class Lender:Person {
var borrower:Borrower?
// Cho con nợ vay tiền
func lendMoney(borrower:Borrower, money:Int) -> Bool {
// Nếu tiền không đủ để cho mượn thì return false
guard self.money >= money else { return false }
self.borrower = borrower
self.money -= money
// Đưa tiền cho người mượn
borrower.receiveMoney(self, money: money)
return true
}
// Đòi nợ
func requestPayment() {
if let borrower = self.borrower {
if let returnMoney = borrower.payMoneyBack() {
// Con nợ đã trả hết tiền nợ, xong !
self.money += returnMoney
self.borrower = nil
} else {
// Trường hợp returnMoney = nil, con nợ vẫn chưa trả đc tiền
}
}
}
}
// Con nợ
class Borrower:Person {
weak var lender:Lender?
var debt:Int = 0
// Con nợ nhận tiền từ chủ nợ
func receiveMoney(lender:Lender, money:Int) {
self.lender = lender
debt = money
self.money += money
}
// Trả nợ
func payMoneyBack() -> Int? {
var returnMoney:Int?
// Nếu đủ tiền thì trả hết, hết nợ
if money >= debt {
money -= debt
returnMoney = debt
debt = 0
self.lender = nil
} else {
// Chưa đủ tiển trả nên hẹn lần sau
return nil
}
return returnMoney
}
// Hỏi mượn tiền từ chủ nợ
func askForMoney(lender:Lender, money:Int) {
if lender.lendMoney(self, money: money) {
print("Yeah !!")
} else {
// Ặc phải tìm người khác để mượn rồi
}
}
}
// logic vay tiền và đòi tiền đâu đó trong project như sau
let lenderObj = Lender(name: "Teo", age: 22)
lenderObj.money = 2000
let borrowerObj = Borrower(name: "Ti", age: 18)
// Mượn tiền
borrowerObj.askForMoney(lenderObj, money: 1000)
// Chủ nợ đòi tiền
lenderObj.requestPayment()
Đoạn code sau phức tạp hơn khá nhiều nhưng đại khái lúc này ta nói rằng việc cho vay (của chủ nợ) và trả nợ (của con nợ) sẽ rõ ràng hơn. Đặc biệt ta không còn bị tình trạng mượn tiền kiểu côn đồ như cách đoạn code trước đó :D. Logic cho việc lấy tiền ở đâu là việc riêng của cả 2 mà không muốn cho đối phương biết. Cách viết này tốt hơn cách đầu tiên nhưng vẫn chưa thể giải quyết vấn đề Lender - Borrower dính chặt vào nhau.
Cách 3: Tôi không biết bạn là ai nhưng tôi biết bạn có thể làm được cái tôi muốn
Tới đây ta có thể giải quyết vấn đề trên bằng Protocol trong Swift
protocol LenderBehavior:class {
func lendMoney(borrower:BorrowerBehavior, money:Int) -> Bool
func requestPayment()
}
protocol BorrowerBehavior {
func askForMoney(lender:LenderBehavior, money:Int)
func receiveMoney(lender:LenderBehavior, money:Int)
func payMoneyBack() -> Int?
}
// Chủ nợ
class Lender:Person, LenderBehavior {
var borrower:BorrowerBehavior? // thay vì là Borrower, thay lại là BorrowerBehavior
func lendMoney(borrower:BorrowerBehavior, money:Int) -> Bool {
// Code như cũ
}
func requestPayment() {
// Code như cũ
}
}
// Con nợ
class Borrower:Person, BorrowerBehavior {
weak var lender:LenderBehavior? // thay vì là Lender, thay lại là LenderBehavior
var debt:Int = 0
func receiveMoney(lender:LenderBehavior, money:Int) {
// code như cũ
}
func payMoneyBack() -> Int? {
// code như cũ
}
func askForMoney(lender:LenderBehavior, money:Int) {
// Code như cũ
}
}
// logic vay tiền và đòi tiền đâu đó trong project như sau
let lenderObj = Lender(name: "Teo", age: 22)
lenderObj.money = 2000
let borrowerObj = Borrower(name: "Ti", age: 18)
// Mượn tiền
borrowerObj.askForMoney(lenderObj, money: 1000)
// Chủ nợ đòi tiền
lenderObj.requestPayment()
Điều gì xảy ra nếu ra có class Worker nào đó mà muốn vay tiền ?? Nếu Worker là subclass của Person thì mọi thứ khá đơn giản, nhưng nếu nó đang là subclass của 1 class khác thì sao ?? Ta biết trong hướng đối tượng, 1 class không thể kế thừa từ 2 class. Vì có Protocol rồi thì mọi thứ sẽ đơn giản hơn nhiều:
class Worker:SomeClass, BorrowerBehavior {
// mọi người thử viết cho class này nhé
}
// Hay thậm chí là 1 Company vừa phải vay tiền và lại vừa có thể đầu tư (cho vay)
class Company:OtherClass, BorrowerBehavior, LenderBehavior {
// ...
}
Ta thấy rằng dù class Worker có nhìu logic của nó đến chừng nào thì việc mượn tiền chỉ cần adopt protocol BorrowerBehavior và viết chi tiết các method bên trong. Với Company cũng thế.
Lúc này 2 đối tượng Lender - Borrower không còn kết dính nữa, chúng cũng chẳng biết ai là ai, con nợ và chủ nợ lúc này chỉ còn là "người có khả năng cho vay" và "người có thể sẽ phải vay tiền". Từ đó ta có thể linh hoạt sử dụng các đối tượng hơn, không cứ phải là Lender hay Borrower nữa.
Kết
Tới đây mình hy vọng các bạn sẽ hiểu thêm tầm quan trọng của việc trừu tượng hóa và có thể vận dụng chúng hiệu quả hơn trong ứng dụng. Nếu các bạn đã đọc đến đây và hiểu hết các vấn đề, chào mừng các bạn đã đến thế giới Kiến Trúc Ứng Dụng.
Việt Trần
Yêu thích tìm hiểu các công nghệ cốt lõi, kỹ thuật lập trình và thích chia sẻ chúng tới cộng đồng
follow me :
Bài viết liên quan
Apple lên tiếng về AI: Chúng ta có đang đánh giá quá cao Trí tuệ của nó?
Nov 21, 2024 • 8 min read
Whisper AI là gì? Công cụ chuyển giọng nói thành văn bản của Open AI
Oct 17, 2024 • 8 min read
Cursor AI là gì? Hướng dẫn Sử dụng Cursor AI cơ bản
Sep 16, 2024 • 13 min read
IDE là gì? Những công cụ IDE phổ biến nhất hiện nay
Aug 22, 2024 • 11 min read
Cookies là gì? Cookies được sử dụng như thế nào?
Aug 12, 2024 • 9 min read
SDLC là gì? Các mô hình Software Development Life Cycle phổ biến
Jul 13, 2024 • 27 min read