The Open-Closed Principle

- Có rất nhiều kinh nghiệm quý được rút ra trong lĩnh vực thiết kế hướng đối tượng. Ví dụ như “tất cả biến member nên được khai báo private”, hay “không nên sử dụng biến toàn cục”, hay “sử dụng chức năng kiểm tra kiểu lúc runtime (run time type identification - RTTI) rất nguy hiểm và nên hạn chế”. Vậy các kinh nghiệm quý đó bắt đầu từ đâu? Ai đã kiểm chứng tính đúng đắn của những kinh nghiệm này và liệu chúng luôn luôn đúng? Bài viết này sẽ đề cập đến một nguyên tắc cơ bản, nguyên tắc nền tảng cho các kinh nghiệm quý trên. Nguyên tắc Open-Closed (Open – Closed principle).

- Ivar Jacobson nói rằng: “Tất cả những hệ thống phần mềm thay đổi suốt quá trình hoạt động của chúng. Phải tâm niệm rằng phần mềm luôn được khách hàng mong đợi sẽ hoạt động hoàn hảo hơn so với phiên bản đầu tiên của nó“

-Làm sao để có thể thiết kế được một sản phẩm có tính ổn định cao trong khi vẫn cho phép sửa đổi suốt quá trình hoạt động? Bertrand Meyer đưa ra một nguyên tắc rất nổi tiếng vào năm 1988 :

NHỮNG THÀNH PHẦN CỦA PHẦN MỀM (CLASSES, MODULES, FUNCTIONS, …)
NÊN ĐƯỢC OPEN ĐỂ MỞ RỘNG, NHƯNG CLOSED ĐỐI VỚI NHỮNG SỬA ĐỔI.

- Khi có một sửa đổi nào đó vào chương trình mà dẫn đến nhiều thay đổi ở những nơi khác, chương trình đó được xem là một thiết kế tồi (bad design) và nó trở nên khó sửa đổi cũng như khó sử dụng lại. Nguyên tắc open-closed sẽ giúp khắc phục điểm yếu này của phần mềm theo một cách rất đơn giản. Nó nói rằng bạn nên thiết kế những modules mà không bao giờ phải sửa đổi. Khi yêu cầu (requirement) có thay đổi, bạn mở rộng những behavior của những module này bằng cách thêm code mới thay vì sửa những dòng code cũ đang chạy tốt.:bbpraroi:

Mô tả nguyên tắc open-closed:

- Những modules được xem tuân theo nguyên tắc open-closed khi nó có hai tính chất sau:

1. Chúng “Open For Extension”.
+ Điều này có nghĩa là những behavior của module có thể được mở rộng. Chúng ta có thể làm cho module chạy theo một cách khác khi yêu cầu của ứng dụng thay đổi.

2. Chúng “Closed for Modifictation”.
+ Source code của module không được sửa đổi trong mọi trường hợp.


- Dường như hai điều trên mâu thuẫn với nhau. Cách thông thường để mở rộng một behavior của module là sửa lại code của module đó. Vậy làm cách nào để giải quyết mâu thuẫn này?:bbpnghi:

Sử dụng Abstraction – Sự trừu tượng hóa.

- Trong một số bài viết trước, đã có lần chúng ta nói đến abstraction, trong các bài viết/dịch của tôi, các Abstraction sẽ được tạm hiểu như các Interface hoặc Abstract class trong C#.

- Khi sử dụng các nguyên tắc của ngôn ngữ lập trình hướng đối tượng (3 NGUYÊN TẮC CƠ BẢN CHẮC AI CŨNG BIẾT) như C++, C#, chúng ta có thể thể tạo ra những abstractions để tượng trưng cho một nhóm vô tận những behavior có thể được thay đổi về sau. Abstractions ở đây có thể là các abstract base classes trong C++ hay C#, có thể là một Interface như trong C# và Java, và một nhóm các behavior có thể thay đổi được đề cập ở trên chính là các lớp con. Một module của bạn chỉ cần biết đến abstraction này và sử dụng nó. Module loại này sẽ đạt được tính “closed for modification” bởi vì nó chỉ phụ thuộc chặt chẽ vào một abstraction nào đó đã cố định. Còn những behavior của module có thể được mở rộng bằng cách thêm các lớp mới, mở rộng từ abstraction của bạn.

- Hình 1 dưới đây là ví dụ về một thiết kế không theo nguyên tắc open-closed. Cả lớp Client và lớp Server đều là các lớp cụ thể. Không có gì đảm bảo rằng các member functions của lớp Server là virtual. Bởi vì lớp Client với lớp Server có quan hệ “uses”, nên khi chúng ta muốn một đối tượng thuộc kiểu Client sử dụng một đối tượng thuộc kiểu Server, chúng ta phải sửa tên lớp khai báo trong lớp Client thành tên mới của lớp server mong muốn.

The Open-Closed Priciple

Hình 1: Closed Client
Các bạn đang xem bài dịch về nguyên tắc Open-Closed trong thiết kế hướng đối tượng từ blog của Nguyễn Thoại (http://nthoai.blogspot.com)

- Hình 2 thể hiện một ví dụ tương tự nhưng tuân theo nguyên tắc open-closed. Trong trường hợp này, lớp AbstractServer là một lớp abstract với các member functions được khai báo virtual rõ ràng. Lớp Client và lớp abstract này vẫn có quan hệ “uses”. Tuy nhiên các đối tượng kiểu Client sẽ sử dụng những đối tượng thuộc về các lớp con của lớp AbstractServer. Nếu chúng ta muốn các đối tượng kiểu Client sử dụng đối tượng thuộc kiểu server khác nữa thì chỉ cần tạo lớp mới dẫn xuất từ lớp Abstract Server. Và vì thế lớp Client có thể không cần thay đổi.

The Open-Closed Priciple
Hình 2: Open Client

Các bạn đang xem bài dịch về nguyên tắc Open-Closed trong thiết kế hướng đối tượng từ blog của Nguyễn Thoại (http://nthoai.blogspot.com)


Vài ví dụ

The Shape Abstraction

- Các bạn hãy xem một ví dụ kinh điển sau. Ta cần làm một chương trình có thể vẽ các hình hình học. Lúc đầu người ta yêu cầu chỉ cần vẽ hình tròn và hình vuông và các hình này phải được vẽ theo một trình tự nhất định. Một danh sách các hình tròn và hình vuông sẽ được tạo ra theo một thứ tự nào đó và chương trình phải duyệt qua danh sách đó để vẽ ra hết các hình trong danh sách.
Trong thủơ mới học lập trình, chúng ta thường được dạy lập trình kiểu thủ tục và tất nhiên cách tiếp cận ấy hoàn toàn không tuân theo nguyên tắc open-closed. Chúng ta sẽ giải quyết vấn đề này trong đoạn code dưới đây. Ở đây chúng ta thấy một tập các cấu trúc dữ liệu được khai báo để mô tả một đối tượng hình học. Thành phần thứ nhất của struct dùng để xác định một đối tượng là hình tròn hay hình vuông. Function DrawAllShapes sẽ duyệt qua danh sách các hình và xác định xem từng hình là hình gì sau đó gọi function vẽ tương ứng (hoặc DrawCircle hoặc DrawSquare).


enum SharpType
{

Circle,
Square

};
class Sharp
{

SharpType Type;

}
class Circle : Sharp
{

double Radius;
Point Center;

}
class Square : Sharp
{

double Side;
Point TopLeft;

}

//
// These functions are implemented elsewhere
//

void DrawSquare(Square obj)
{
}
void DrawCircle(Circle obj)
{
}

void DrawAllShapes(Sharp[] list, int n)
{

int i;
for (i = 0; i < n; i++)
{

Sharp s = list[i];
switch (s.Type)
{

case SharpType.Square:

DrawSquare((Square)s);
break;

case SharpType.Circle:

DrawCircle((Circle)s);
break;

}

}

}

Code 1

- Function DrawAllShapes trên không tuân theo nguyên tắc open-closed bởi vì rõ ràng nó không thể “closed” khi muốn thêm khả năng vẽ hình tam giác, hình chữ nhật, hình cô gái. :bbpcuoi3:Nếu muốn mở rộng function này để nó có thể vẽ được các hình mới, buộc lòng phải sửa lại code.

- Tất nhiên chương trình này chỉ là một ví dụ nhỏ. Trong thực tế, câu lệnh switch trong function DrawAllShapes có thể phải lặp lại nhiều lần trong những function khác. Nếu muốn thêm một hình mới vào chương trình kiểu này bạn phải tìm mọi chỗ có đoạn switch hoặc if/else như trên để sửa.

- Đoạn code dưới đây sẽ giải quyết vấn đề ta đang gặp phải với open-closed. Trong trường hợp này, ta sẽ dùng 1 lớp abstract class Shape. Bên trong lớp này sẽ khai báo một hàm virtual tên là Draw. Các lớp hình học cụ thể kế thừa từ lớp này sẽ implement lại hàm virtual Draw cho chính nó.


class Sharp
{

public virtual void Draw()
{

// Implement default behavior, or do nothing here

}

}
class Circle : Sharp
{

public override void Draw()
{

// Implement function draw a Circle

}

}
class Square : Sharp
{

public override void Draw()
{

// Implement function draw a Square

}

}
void DrawAllShapes(Sharp[] list)
{

foreach(Sharp s in list)
s.Draw();

}

Code 2

- Rất dễ thấy rằng nếu muốn mở rộng behavior của function DrawAllShapes trong hình trên, ta chỉ cần thêm một lớp mới kế thừa từ lớp abstract Shape. Function DrawAllShapes không cần thiết phải sửa lại, vì thế DrawAllShapes đã thỏa mãn yêu cầu của nguyên tắc open-closed.

- Trong thực tế, lớp abstract Shape có thể có thêm những method khác. Còn việc thêm một lớp con của lớp Shape vào chương trình thì khá đơn giản vì ta chỉ cần implement tất cả các method abstract/virtual cần thiết. Và hiển nhiên làm theo cách này ta sẽ không cần phải tìm trong code cũ của chương trình những chỗ có switch, if/else để sửa đổi.

Các phương án để đạt được tính "closed" của chương trình:

- Thực sự thì không có một chương trình nào có thể 100% “closed”. Có một ví dụ như sau, bạn hãy nghĩ xem sẽ phải làm như thế nào nếu ta muốn function DrawAllShapes phải vẽ những hình tròn trước các hình vuông. Rõ ràng hàm DrawAllShapes hiện tại không thể “closed” với những yêu cầu như thế này. Nói chung, cho dù module của bạn có “closed” như thế nào thì vẫn có những thay đổi nhỏ mình phải chấp nhận.:bbpbuon:

- Bởi vì tính đóng không hoàn toàn trọn vẹn 100%, chúng ta cần có chiến thuật hợp lý cho nó. Có nghĩa là một người thiết kế hướng đối tượng nên xem xét những gì có thể sẽ thay đổi và những gì nên “closed” trong thiết kế của anh ta và điều này đòi hỏi nhiều kinh nghiệm trong thiết kế phần mềm. Một designer giàu kinh nghiệm biết tường tận về người sử dụng hoặc thị trường phần mềm của mình, từ đó có thể xác định những thay đổi có thể có trong chương trình. Và nhờ đó anh ta có thể chắc chắn rằng nguyên tắc open-closed sẽ được dùng trong hầu hết những nơi có thay đổi trong tương lai.
Các bạn đang xem bài dịch về nguyên tắc Open-Closed trong thiết kế hướng đối tượng từ blog của Nguyễn Thoại (http://nthoai.blogspot.com)

Using Abstraction to Gain Explicit Closure
- Vậy làm thế nào để “close” function DrawAllShapes trong bài toán vẽ theo thứ tự? Xin nhắc lại là tính đóng của chương trình dựa trên abstraction. Vì thế, để làm cho function DrawAllShapes “closed” đối với thứ tự vẽ, có lẽ ta nên nghĩ đến làm thế nào để trừu tượng hóa cái thứ tự vẽ đó. Trường hợp vẽ theo thứ tự nói trên thực ra là thao tác vẽ một số loại hình trước các loại hình khác.

- Nếu có một cách để xác định thứ tự của các đối tượng cùng một kiểu. Nếu cho hai đối tượng nào đó, ta phải tìm ra đối tượng nào nên được vẽ trước. Vì thế, chúng ta có thể viết một method của lớp Shape tên là Precedes chẳng hạn để so sánh chính nó với một đối tượng thuộc kiểu Shape khác và trả về giá trị bool. Giá trị bool trả về là true thì đối tượng ban đầu nên được vẽ trước đối tượng được dùng để so sánh.

- Trong các ngôn ngữ hướng đối tượng như C++, C#, chúng ta có thể implement ý tưởng của function Precedes trên bằng cách sử dụng overloaded operator <. Hình 3 sẽ hiện thực phần code này. Như vậy, chúng ta đã có cách để xác định thứ tự trước sau của hai đối tượng thuộc kiểu Shape, chúng ta có thể sort chúng và sau đó chỉ việc vẽ ra theo thứ tự mong muốn. - Nhưng chúng ta vẫn chưa hoành chỉnh abstraction behavior so sánh của đối tượng Shape. Với khai báo abstract như trong lớp Shape, mỗi đối tượng thuộc kiểu Shape phải override lại method Precede. Nhưng họ đã làm điều đó như thế nào?:bbpnen: Phải viết code như thế nào bên trong lớp Circle để nó biết rằng Circle nên được vẽ trước Squares. Hãy xem thử Hình 4:

public class Sharp
{

public virtual void Draw()
{
}
public virtual bool Precedes(Sharp otherSharp)
{

return true;

}
public static bool operator <(Sharp sharp1, Sharp sharp2)
{

return sharp1.Precedes(sharp2);

}

}

Code 3


public class Circle : Sharp
{

public override bool Precedes(Sharp otherSharp)
{

try
{

Square temp = (Square)otherSharp;

}
catch
{

return false;

}
return true;

}

}

Code 4

- Rõ ràng với việc ép kiểu như vậy chúng ta đã vi phạm nguyên tắc open-closed. Và dường như không có cách nào hàm so sánh này có thể “closed” khi muốn kiểm tra Circle với một hình học loại khác. Và mỗi khi một lớp con của lớp Shape được tạo ra, hàm compare này của chúng ta buộc lòng phải được sửa lại không chỉ ở lớp Circle mà ở toàn bộ những lớp con đã có của lớp Shape. :bbpquau:

Using a “Data Driven” Approach to Achieve Closure


- Tính “closed” ở các lớp con của lớp Shape có thể đạt được bằng cách sử dụng một cheat code như sau. Ta sẽ tạo ra một bảng các tên của lớp theo thứ tự mong muốn, từng lớp con của lớp Shape sẽ dựa vào các giá trị trong bảng này và tên của chính nó để đưa ra kết quả so sánh. Hãy xem hình 6 để hiểu rõ thêm cách thực hiện.

- Theo cách tiếp cận này chúng ta đã thành công trong việc “closed” function DrawAllShapes trước những vấn đề nảy sinh, và tính “closed” của mỗi lớp con Shapes khi có một lớp mới thêm vào. Thậm chí khi có yêu cầu thay đổi quy luật vẽ của các hình thì các lớp con Shapes cũng không cần sửa đổi gì cả.

- Tuy nhiên vẫn có một thành phần vẫn chưa “closed” với những loại khác nhau của Shapes, đó chính là bản thân bảng danh sách tên đang sử dụng. Nhưng ta vẫn có thể đưa phần code định nghĩa bảng này sang một module độc lập với những module còn lại trong chương trình, vì thế bất cứ sự thay đổi nào cũng không ảnh hưởng đến những module khác.

- Có nói mãi cũng không hết chuyện về các vấn đề trong ví dụ Shapes. Tuy nhiên nếu hàm vẽ các hình theo một thứ tự khác không xét instance Shapes thuộc loại gì thì lại nảy sinh nhiều vấn đề khác nữa. Có vẽ như chúng ta muốn sắp xếp thứ tự vẽ của các hình theo nhiều kiểu khác nhau và ta sẽ không bàn tiếp nữa trong bài viết này. Và như các bạn thấy, không thể 100% “closed” trong thiết kế phần mềm được.:bbpbuon:

Heuristic and Conventions

- Như đã đề cập ở phần đầu của bài viết, nguyên tắc open-closed là nền tảng chính cho rất nhiều các kinh nghiệm và quy ước khác được công bố liên quan đến OOD trong nhiều năm. Dưới đây là một vài điều quan trọng nhất trong số đó.

Make all Member Variables Private.

- Đây là điều quan trọng nhất trong các conventions của thiết kế hướng đối tượng. Các member variable bên trong class nên được biết đến bởi các methods của lớp định nghĩa chúng. Các member variables không nên được truy xuất bởi lớp khác, bao gồm cả các lớp con. Vì thế chúng nên được khai báo private thay vì public hoặc protected.

- Khi một member variables của một lớp thay đổi, mỗi function có sử dụng biến này sẽ phải thay đổi theo. Vì thế không có một function nào của lớp có thể “closed” khi cứ phụ thuộc vào member variable kiểu này.

- Trong thiết kế hướng đối tượng, chúng ta mong muốn các method của class không nhất thiết phải “closed” đối với những thay đổi của member variables bên trong class. Tuy nhiên chúng ta muốn bất cứ những lớp khác, bao gồm cả lớp con phải “closed” đối với những thay đổi của các variables này. Chúng ta có tên cho điều này, đó chính là “Encapsulation”.

- Bây giờ, nếu ta có một member variable mà ta biết sẽ không bao giờ cần thay đổi? Vậy liệu có lí do nào để khai báo nó private? Ví dụ, hình 5 thể hiện một lớp Device có một member variable kiểu bool tên là status. Biến variable này giữ trạng thái của lần hoạt động cuối cùng của object kiểu Device. Nếu lần chạy cuối thành công thì biến status này giữ giá trị true và ngược lại.


class Device
{

public bool Status;

}

Code 5

- Chúng ta biết rằng kiểu(bool) và ý nghĩa (meaning) của variable này sẽ không bao giờ đổi. Vậy tại sao lại không khai báo nó là public và để chương trình (client code) có thể đọc giá trị của nó? Nếu biến variable này thật sự không thay đổi, và nếu tất cả những phần trong chương trình chỉ đọc giá trị status thì khi đó biến public này sẽ không gây tác hại gì cả. Tuy nhiên, có thể ở một nơi nào, bởi một người khác trong nhóm của bạn, có thay đổi giá trị status và điều này có thể dẫn đến chương trình chạy sai mà bạn không ngờ tới. Vì vậy có thể không đáng để chúng ta mạo hiểm khai báo như trên.

No Global Variables -- Ever

- Vấn đề tranh cãi về biến toàn cục cũng tương tự với biến member public. Không một module nào có sử dụng một biến toàn cục lại có thể “closed” đối với những module tương tự nhưng lại có khả năng ghi vào biến toàn cục này. Bất kì module nào có sử dụng biến dùng chung nhưng thay đổi nó tùy tiện có thể ảnh hưởng đến những nơi khác trong chương trình.

- Tuy nhiên, nếu có những biến toàn cục được sử dụng rất ít bởi một số nơi, hoặc chắc chắn rằng nó sẽ được sử dụng một cách nhất quán, thì nó sẽ không gây tác hại gì với chương trình. Designer phải xem xét xem mức độ “closed” của ứng dụng so với lợi ích có được khi sử dụng biến toàn cục trong một số trường hợp.

- Như vậy, tương tự như public member variables, vấn đề sử dụng biến toàn cục sẽ tùy vào phong cách, nhu cầu thiết kế của designer. Trong một số trường hợp, sử dụng biến toàn cục sẽ rất tiện lợi và có thể tăng performance cho ứng dụng. Trong các trường hợp như vậy, không nên máy móc theo open-closed mà bỏ đi biến toàn cục trong khi sử dụng nó cũng chẳng gây ra lỗi gì.
Các bạn đang xem bài dịch về nguyên tắc Open-Closed trong thiết kế hướng đối tượng từ blog của Nguyễn Thoại (http://nthoai.blogspot.com)

RTTI is Dangerous

- Một nguyên tắc khác cũng rất phỏ biến đó là sử dụng dynamic_cast. Người ta thường khuyên nên tránh sử dụng các chức năng ép kiểu trong các ngôn ngữ lập trình, rằng nó nguy hiểm và nên tránh sử dụng càng nhiều càng tốt. Hãy xem ví dụ 6 đã vi phạm nguyên tắc open-closed khi sử dụng dinamic_cast như thế nào:


class Sharp
{
}
class Circle : Sharp
{

public void DrawCircle()
{
}

}
class Square : Sharp
{

public void DrawSquare()
{
}

}
void DrawAllShapes(Sharp[] list)
{

foreach(Sharp s in list)
{

Square square;
Circle circle;
try { square = (Square)s; } catch {}
try { circle = (Circle)s; } catch {}

if (square != null)
square.DrawSquare();

else if (circle != null)
circle.DrawCircle();

}

}

Code 6

- Tuy nhiên hình 7 cho ta thấy trong một trường hợp khác, vẫn sử dụng dynamic_cast nhưng không vi phạm nguyên tắc open-closed. Đoạn code chỉ là ví dụ minh họa vì có nhiều cách khác tốt hơn để implement method DrawOnlyCircles.:bbpcuoi5:


class Sharp
{

public virtual void Draw()
{
}

}
class Circle : Sharp
{
}
void DrawOnlyCircles(Sharp[] list)
{

foreach(Sharp s in list)
{

Circle circle;
try { circle = (Circle)s; } catch {}
if (circle != null)
circle.Draw();

}

}

Code 7

- Điểm khác nhau giữa hai cách trên là trong hình 6, code bắt buộc phải được sửa lại khi có một lớp con mới của lớp Shape được thêm vào. Trong khi đó, hình 7 cho ta thấy rằng vẫn có sử dụng dynamic_cast nhưng vẫn không vi phạm open-closed và lớp mới được thêm vào sẽ không làm đoạn code này chạy sai. Nói chung, nếu RTTI không ảnh hưởng nguyên tắc open-closed thì nó an toàn để sử dụng.:bbpxtay:

Kết luận

- Có nhiều điều để bàn tiếp về nguyên tắc open-closed nhưng nhiều người đồng ý rằng nguyên tắc này là nền tảng của thiết kế hướng đối tượng. Tuân theo nguyên tắc này sẽ giúp ta đạt được những lợi ích của hướng đối tượng: đó là khả năng sử dụng lại và dễ bảo trì. Tuy nhiên, để đạt được điều đó không đơn giản là viết phần mềm = ngôn ngữ lập trình hướng đối tượng. Mà hơn thế, nó đòi hỏi người thiết kế phải apply các abstraction và những phần của chương trình mà anh ta cho rằng nó có thể được thay đổi/mở rộng trong tương lai.

5 comments:

  1. nin9 Says:

    Bài viết đọc khá thú vị đó anh Thoại :D. Phần code được anh Thoại sửa đổi đọc thấy dễ hiểu hơn trong nguyên bản :D. Nhưng hình như ở phần sử dụng bảng tên để sắp xếp các lớp Shape anh Thoại thiếu hình minh họa số 6 :D

    Mong có thể đọc dc thêm nhiều bài viết bổ ích của anh Thoại :D. Sắp tới DDV có hoạt động thể thao như cầu lông hay đá banh đó, anh Thoại có tham gia hông :D?

  2. Duong Tran Hoang Says:

    Xin chào,

    Bài bạn viết rất bổ ích.
    Mình có thể copy bài của bạn để đăng ỏ 4rum công ty mình được không?

    Chờ những entry lý thú tiếp theo của bạn.

    P/S: mình cũng học KHTN ra, hiện giờ đang làm lập trình web.

  3. Nguyễn Thoại Says:

    Bài dịch từ article của tác giả khác và dịch vẫn chưa tốt lắm, nhưng nếu bác Hoàng thấy được thì cứ copy. :D.

  4. Mr Hoai Pham Ngoc Says:

    Bài dịch rất hay, mình đọc bài dịch khả hiểu.

    Còn một số quy tắc nữa như:
    Liskov substitution,
    Dependency inversion,
    Interface segregation,
    Single Responsibility

    Có có ý định viết tiếp chứ :>

  5. Anonymous Says:

    public class Circle : Sharp
    {

    public override bool Precedes(Sharp otherSharp)
    {

    return this.GetType()==otherSharp.GetType()?true:false;

    }

    }

rss
 

About Me

Place I've live
Near Bossley Park, Sydney, NSW, Australia
Place I've work
  • Freelancer (from 06/2010 to present)
  • Harvey Nash (from 05/2008 to 06/2010)
  • DataDesign Vietnam (10/2005 to 04/2008)
Place I've studied
  • University of Natural Science (Bachelor of Science HoChiMinh City Vietnam From 2001 to 2005)
  • Le Hong Phong High School (HoChiMinh City Vietnam From 1997 to 2000)