Trời ạ, có một số hiểu lầm kỳ lạ về những gì OCP và LSP và một số là do sự không phù hợp của một số thuật ngữ và ví dụ khó hiểu. Cả hai nguyên tắc chỉ là "cùng một thứ" nếu bạn thực hiện chúng theo cùng một cách. Các mẫu thường tuân theo các nguyên tắc theo cách này hay cách khác với một vài ngoại lệ.
Sự khác biệt sẽ được giải thích sâu hơn nhưng trước tiên chúng ta hãy đi sâu vào các nguyên tắc:
Nguyên tắc đóng mở (OCP)
Theo chú Bob :
Bạn sẽ có thể mở rộng một hành vi lớp, mà không sửa đổi nó.
Lưu ý rằng từ mở rộng trong trường hợp này không nhất thiết có nghĩa là bạn nên phân lớp lớp thực sự cần hành vi mới. Xem làm thế nào tôi đã đề cập ở lần đầu tiên không phù hợp của thuật ngữ? Từ khóa extend
chỉ có nghĩa là phân lớp trong Java, nhưng các nguyên tắc cũ hơn Java.
Bản gốc đến từ Bertrand Meyer năm 1988:
Các thực thể phần mềm (lớp, mô-đun, chức năng, v.v.) nên được mở để mở rộng, nhưng đóng để sửa đổi.
Ở đây rõ ràng hơn nhiều là nguyên tắc được áp dụng cho các thực thể phần mềm . Một ví dụ xấu sẽ ghi đè thực thể phần mềm khi bạn sửa đổi hoàn toàn mã thay vì cung cấp một số điểm mở rộng. Hành vi của chính thực thể phần mềm nên có thể mở rộng và một ví dụ điển hình cho việc này là triển khai mẫu Chiến lược (vì đây là cách dễ nhất để hiển thị bó mẫu của GoF-IMHO):
// Context is closed for modifications. Meaning you are
// not supposed to change the code here.
public class Context {
// Context is however open for extension through
// this private field
private IBehavior behavior;
// The context calls the behavior in this public
// method. If you want to change this you need
// to implement it in the IBehavior object
public void doStuff() {
if (this.behavior != null)
this.behavior.doStuff();
}
// You can dynamically set a new behavior at will
public void setBehavior(IBehavior behavior) {
this.behavior = behavior;
}
}
// The extension point looks like this and can be
// subclassed/implemented
public interface IBehavior {
public void doStuff();
}
Trong ví dụ trên Context
được khóa để sửa đổi thêm. Hầu hết các lập trình viên có thể muốn phân lớp lớp để mở rộng nó nhưng ở đây chúng tôi không vì nó cho rằng hành vi của nó có thể được thay đổi thông qua bất cứ điều gì thực hiện IBehavior
giao diện.
Tức là lớp bối cảnh được đóng để sửa đổi nhưng mở để mở rộng . Nó thực sự tuân theo một nguyên tắc cơ bản khác bởi vì chúng ta đặt hành vi với thành phần đối tượng thay vì kế thừa:
"Ưu tiên ' thành phần đối tượng ' hơn ' kế thừa lớp '." (Gang of Four 1995: 20)
Tôi sẽ để người đọc đọc theo nguyên tắc đó vì nó nằm ngoài phạm vi của câu hỏi này. Để tiếp tục với ví dụ này, giả sử chúng ta có các triển khai sau của giao diện IBehavior:
public class HelloWorldBehavior implements IBehavior {
public void doStuff() {
System.println("Hello world!");
}
}
public class GoodByeBehavior implements IBehavior {
public void doStuff() {
System.out.println("Good bye cruel world!");
}
}
Sử dụng mẫu này, chúng ta có thể sửa đổi hành vi của bối cảnh khi chạy, thông qua setBehavior
phương thức làm điểm mở rộng.
// in your main method
Context c = new Context();
c.setBehavior(new HelloWorldBehavior());
c.doStuff();
// prints out "Hello world!"
c.setBehavior(new GoodByeBehavior());
c.doStuff();
// prints out "Good bye cruel world!"
Vì vậy, bất cứ khi nào bạn muốn mở rộng lớp ngữ cảnh "đóng", hãy thực hiện bằng cách phân lớp phụ thuộc cộng tác "mở". Điều này rõ ràng không giống với phân lớp bối cảnh chính nó là OCP. LSP cũng không đề cập đến điều này.
Mở rộng với Mixins thay vì kế thừa
Có nhiều cách khác để thực hiện OCP ngoài việc phân lớp. Một cách là giữ cho các lớp của bạn mở để mở rộng thông qua việc sử dụng mixins . Điều này rất hữu ích, ví dụ như trong các ngôn ngữ dựa trên nguyên mẫu thay vì dựa trên lớp. Ý tưởng là sửa đổi một đối tượng động với nhiều phương thức hoặc thuộc tính khi cần thiết, nói cách khác, các đối tượng pha trộn hoặc "trộn lẫn" với các đối tượng khác.
Dưới đây là một ví dụ javascript về một mixin kết xuất một mẫu HTML đơn giản cho các neo:
// The mixin, provides a template for anchor HTML elements, i.e. <a>
var LinkMixin = {
render: function() {
return '<a href="' + this.link +'">'
+ this.content
+ '</a>;
}
}
// Constructor for a youtube link
var YoutubeLink = function(content, youtubeId) {
this.content = content;
this.setLink(this.youtubeId);
};
// Methods are added to the prototype
YoutubeLink.prototype = {
setLink: function(youtubeid) {
this.link = 'http://www.youtube.com/watch?v=' + youtubeid;
}
};
// Extend YoutubeLink prototype with the LinkMixin using
// underscore/lodash extend
_.extend(YoutubeLink.protoype, LinkMixin);
// When used:
var ytLink = new YoutubeLink("Cool Movie!", "idOaZpX8lnA");
console.log(ytLink.render());
// will output:
// <a href="http://www.youtube.com/watch?=vidOaZpX8lnA">Cool Movie!</a>
Ý tưởng là mở rộng các đối tượng một cách linh hoạt và lợi thế của việc này là các đối tượng có thể chia sẻ các phương thức ngay cả khi chúng ở trong các miền hoàn toàn khác nhau. Trong trường hợp trên, bạn có thể dễ dàng tạo các loại neo html khác bằng cách mở rộng triển khai cụ thể của bạn với LinkMixin
.
Về mặt OCP, "mixins" là phần mở rộng. Trong ví dụ trên, YoutubeLink
thực thể phần mềm của chúng tôi đã bị đóng để sửa đổi, nhưng mở cho các tiện ích mở rộng thông qua việc sử dụng mixins. Hệ thống phân cấp đối tượng được làm phẳng khiến cho không thể kiểm tra các loại. Tuy nhiên đây không thực sự là một điều xấu, và tôi sẽ giải thích sâu hơn rằng việc kiểm tra các loại nói chung là một ý tưởng tồi và phá vỡ ý tưởng bằng đa hình.
Lưu ý rằng có thể thực hiện nhiều kế thừa với phương thức này vì hầu hết các extend
cài đặt có thể trộn lẫn vào nhiều đối tượng:
_.extend(MyClass, Mixin1, Mixin2 /* [, ...] */);
Điều duy nhất bạn cần ghi nhớ là không va chạm vào tên, tức là mixin xảy ra để xác định cùng tên của một số thuộc tính hoặc phương thức vì chúng sẽ bị ghi đè. Theo kinh nghiệm khiêm tốn của tôi thì đây không phải là vấn đề và nếu nó xảy ra thì đó là một dấu hiệu của thiết kế thiếu sót.
Nguyên tắc thay thế của Liskov (LSP)
Chú Bob định nghĩa nó đơn giản bằng cách:
Các lớp dẫn xuất phải được thay thế cho các lớp cơ sở của chúng.
Nguyên tắc này đã cũ, trên thực tế, định nghĩa của chú Bob không phân biệt các nguyên tắc vì điều đó khiến LSP vẫn liên quan chặt chẽ với OCP bởi thực tế là, trong ví dụ về Chiến lược trên, cùng một siêu kiểu được sử dụng ( IBehavior
). Vì vậy, hãy nhìn vào định nghĩa ban đầu của Barbara Liskov và xem liệu chúng ta có thể tìm ra điều gì khác về nguyên tắc này trông giống như một định lý toán học:
Điều muốn ở đây là một cái gì đó giống như thuộc tính thay thế sau: Nếu với mỗi đối tượng o1
loại S
có một đối tượng o2
loại T
sao cho tất cả các chương trình P
được định nghĩa theo T
, thì hành vi của P
không thay đổi khi o1
được thay thế o2
sau đó S
là một kiểu con T
.
Hãy nhún vai về điều này một lúc, chú ý vì nó hoàn toàn không đề cập đến các lớp học. Trong JavaScript, bạn thực sự có thể theo LSP mặc dù nó không dựa trên lớp rõ ràng. Nếu chương trình của bạn có một danh sách ít nhất một vài đối tượng JavaScript:
- cần phải được tính toán theo cùng một cách,
- có hành vi tương tự, và
- theo một cách nào đó thì hoàn toàn khác
... Sau đó, các đối tượng được coi là có cùng "loại" và nó không thực sự quan trọng đối với chương trình. Đây thực chất là đa hình . Theo nghĩa chung; bạn không cần phải biết loại phụ thực tế nếu bạn đang sử dụng giao diện của nó. OCP không nói bất cứ điều gì rõ ràng về điều này. Nó cũng thực sự xác định một lỗi thiết kế mà hầu hết các lập trình viên mới làm:
Bất cứ khi nào bạn cảm thấy muốn kiểm tra loại phụ của một đối tượng, rất có thể bạn đang thực hiện nó SAU.
Được rồi, vì vậy nó có thể không sai mọi lúc nhưng nếu bạn có nhu cầu thực hiện một số loại kiểm tra với instanceof
enum, bạn có thể đang thực hiện chương trình một chút phức tạp hơn cho chính mình so với nó cần thiết. Nhưng đây không phải là luôn luôn như vậy; hack nhanh và bẩn để làm cho mọi thứ hoạt động là một sự nhượng bộ ổn định trong tâm trí của tôi nếu giải pháp đủ nhỏ và nếu bạn thực hành tái cấu trúc không thương tiếc , nó có thể được cải thiện một khi thay đổi yêu cầu.
Có nhiều cách xung quanh "lỗi thiết kế" này, tùy thuộc vào vấn đề thực tế:
- Lớp siêu không gọi các điều kiện tiên quyết, buộc người gọi phải làm như vậy thay vào đó.
- Lớp siêu thiếu một phương thức chung mà người gọi cần.
Cả hai đều là "lỗi" thiết kế mã phổ biến. Có một số phép tái cấu trúc khác nhau mà bạn có thể thực hiện, chẳng hạn như phương pháp kéo lên hoặc cấu trúc lại thành một mẫu như mẫu Khách truy cập .
Tôi thực sự thích mô hình Khách truy cập rất nhiều vì nó có thể xử lý spaghetti if-statement lớn và việc thực hiện đơn giản hơn so với những gì bạn nghĩ về mã hiện có. Nói rằng chúng ta có bối cảnh sau đây:
public class Context {
public void doStuff(string query) {
// outcome no. 1
if (query.Equals("Hello")) {
System.out.println("Hello world!");
}
// outcome no. 2
else if (query.Equals("Bye")) {
System.out.println("Good bye cruel world!");
}
// a change request may require another outcome...
}
}
// usage:
Context c = new Context();
c.doStuff("Hello");
// prints "Hello world"
c.doStuff("Bye");
// prints "Bye"
Các kết quả của câu lệnh if có thể được dịch thành khách truy cập của riêng họ vì mỗi tùy thuộc vào một số quyết định và một số mã để chạy. Chúng ta có thể trích xuất những thứ như thế này:
public interface IVisitor {
public bool canDo(string query);
public void doStuff();
}
// outcome 1
public class HelloVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Hello");
}
public void doStuff() {
System.out.println("Hello World");
}
}
// outcome 2
public class ByeVisitor implements IVisitor {
public bool canDo(string query) {
return query.Equals("Bye");
}
public void doStuff() {
System.out.println("Good bye cruel world");
}
}
Tại thời điểm này, nếu lập trình viên không biết về mẫu Khách truy cập, thay vào đó anh ta sẽ triển khai lớp Ngữ cảnh để kiểm tra xem đó có phải là một loại nào đó không. Bởi vì các lớp Khách truy cập có một canDo
phương thức boolean , người triển khai có thể sử dụng lệnh gọi phương thức đó để xác định xem đó có phải là đối tượng phù hợp để thực hiện công việc không. Lớp ngữ cảnh có thể sử dụng tất cả khách truy cập (và thêm những người mới) như thế này:
public class Context {
private ArrayList<IVisitor> visitors = new ArrayList<IVisitor>();
public Context() {
visitors.add(new HelloVisitor());
visitors.add(new ByeVisitor());
}
// instead of if-statements, go through all visitors
// and use the canDo method to determine if the
// visitor object is the right one to "visit"
public void doStuff(string query) {
for(IVisitor visitor : visitors) {
if (visitor.canDo(query)) {
visitor.doStuff();
break;
// or return... it depends if you have logic
// after this foreach loop
}
}
}
// dynamically adds new visitors
public void addVisitor(IVisitor visitor) {
if (visitor != null)
visitors.add(visitor);
}
}
Cả hai mẫu đều tuân theo OCP và LSP, tuy nhiên cả hai đều xác định chính xác những điều khác nhau về chúng. Vậy làm thế nào để mã trông như thế nào nếu nó vi phạm một trong những nguyên tắc?
Vi phạm một nguyên tắc nhưng tuân theo nguyên tắc khác
Có nhiều cách để phá vỡ một trong những nguyên tắc nhưng vẫn có những nguyên tắc khác được tuân theo. Các ví dụ dưới đây dường như bị chiếm đoạt, vì lý do chính đáng, nhưng tôi thực sự đã thấy những thứ này xuất hiện trong mã sản xuất (và thậm chí còn tệ hơn):
Theo OCP nhưng không phải LSP
Hãy nói rằng chúng tôi có mã đã cho:
public interface IPerson {}
public class Boss implements IPerson {
public void doBossStuff() { ... }
}
public class Peon implements IPerson {
public void doPeonStuff() { ... }
}
public class Context {
public Collection<IPerson> getPersons() { ... }
}
Đoạn mã này tuân theo nguyên tắc đóng mở. Nếu chúng ta gọi GetPersons
phương thức của bối cảnh , chúng ta sẽ có được một nhóm tất cả những người có triển khai riêng của họ. Điều đó có nghĩa là IPerson đã bị đóng để sửa đổi, nhưng mở để mở rộng. Tuy nhiên mọi thứ trở nên đen tối khi chúng ta phải sử dụng nó:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// now we have to check the type... :-P
if (person instanceof Boss) {
((Boss) person).doBossStuff();
}
else if (person instanceof Peon) {
((Peon) person).doPeonStuff();
}
}
Bạn phải làm kiểm tra loại và chuyển đổi loại! Hãy nhớ làm thế nào tôi đã đề cập ở trên làm thế nào kiểm tra loại là một điều xấu ? Ôi không! Nhưng đừng sợ, như đã đề cập ở trên, hoặc thực hiện một số tái cấu trúc kéo lên hoặc thực hiện mô hình Khách truy cập. Trong trường hợp này, chúng ta chỉ cần thực hiện tái cấu trúc sau khi thêm một phương thức chung:
public class Boss implements IPerson {
// we're adding this general method
public void doStuff() {
// that does the call instead
this.doBossStuff();
}
public void doBossStuff() { ... }
}
public interface IPerson {
// pulled up method from Boss
public void doStuff();
}
// do the same for Peon
Lợi ích bây giờ là bạn không cần phải biết loại chính xác nữa, theo LSP:
// in some routine that needs to do stuff with
// a collection of IPerson:
Collection<IPerson> persons = context.getPersons();
for (IPerson person : persons) {
// yay, no type checking!
person.doStuff();
}
Theo LSP nhưng không phải OCP
Hãy xem xét một số mã tuân theo LSP nhưng không phải OCP, đó là một loại giả định nhưng với tôi về điều này, đó là một sai lầm rất tinh vi:
public class LiskovBase {
public void doStuff() {
System.out.println("My name is Liskov");
}
}
public class LiskovSub extends LiskovBase {
public void doStuff() {
System.out.println("I'm a sub Liskov!");
}
}
public class Context {
private LiskovBase base;
// the good stuff
public void doLiskovyStuff() {
base.doStuff();
}
public void setBase(LiskovBase base) { this.base = base }
}
Mã này thực hiện LSP vì bối cảnh có thể sử dụng LiskovBase mà không cần biết loại thực tế. Bạn sẽ nghĩ mã này cũng tuân theo OCP nhưng nhìn kỹ, lớp có thực sự đóng không? Điều gì xảy ra nếu doStuff
phương thức đã làm nhiều hơn là chỉ in ra một dòng?
Câu trả lời nếu nó tuân theo OCP chỉ đơn giản là: KHÔNG , không phải vì trong thiết kế đối tượng này, chúng tôi bắt buộc phải ghi đè mã hoàn toàn bằng một thứ khác. Điều này mở ra các hộp cắt và dán của sâu khi bạn phải sao chép mã từ lớp cơ sở để mọi thứ hoạt động. Các doStuff
phương pháp chắc chắn là mở cho phần mở rộng, nhưng nó đã không hoàn toàn đóng lại để sửa đổi.
Chúng ta có thể áp dụng mẫu phương thức Mẫu trên này. Mẫu phương thức mẫu rất phổ biến trong các khung công tác mà bạn có thể đã sử dụng nó mà không biết nó (ví dụ: các thành phần swing java, biểu mẫu c # và các thành phần, v.v.). Đây là một cách để đóng doStuff
phương thức sửa đổi và đảm bảo rằng nó vẫn đóng bằng cách đánh dấu nó bằng final
từ khóa của java . Từ khóa đó ngăn không cho bất kỳ ai phân lớp tiếp theo (trong C # bạn có thể sử dụng sealed
để làm điều tương tự).
public class LiskovBase {
// this is now a template method
// the code that was duplicated
public final void doStuff() {
System.out.println(getStuffString());
}
// extension point, the code that "varies"
// in LiskovBase and it's subclasses
// called by the template method above
// we expect it to be virtual and overridden
public string getStuffString() {
return "My name is Liskov";
}
}
public class LiskovSub extends LiskovBase {
// the extension overridden
// the actual code that varied
public string getStuffString() {
return "I'm sub Liskov!";
}
}
Ví dụ này tuân theo OCP và có vẻ ngớ ngẩn, nhưng hãy tưởng tượng điều này được nhân rộng với nhiều mã hơn để xử lý. Tôi tiếp tục thấy mã được triển khai trong sản xuất trong đó các lớp con ghi đè hoàn toàn mọi thứ và mã bị ghi đè chủ yếu được cắt giữa các lần triển khai. Nó hoạt động, nhưng như với tất cả các sao chép mã cũng là một thiết lập cho những cơn ác mộng bảo trì.
Phần kết luận
Tôi hy vọng tất cả điều này sẽ xóa một số câu hỏi liên quan đến OCP và LSP và sự khác biệt / tương đồng giữa chúng. Thật dễ dàng để loại bỏ chúng như nhau nhưng các ví dụ trên sẽ cho thấy rằng chúng không phải.
Xin lưu ý rằng, thu thập từ mã mẫu trên:
OCP là về việc khóa mã làm việc xuống nhưng vẫn giữ cho nó mở bằng cách nào đó với một số loại điểm mở rộng.
Điều này là để tránh trùng lặp mã bằng cách đóng gói mã thay đổi như với ví dụ về mẫu Phương thức mẫu. Nó cũng cho phép thất bại nhanh chóng vì phá vỡ các thay đổi là đau đớn (tức là thay đổi một nơi, phá vỡ nó ở mọi nơi khác). Vì mục đích duy trì, khái niệm đóng gói thay đổi là một điều tốt, bởi vì những thay đổi luôn xảy ra.
LSP là về việc cho phép người dùng xử lý các đối tượng khác nhau thực hiện siêu kiểu mà không cần kiểm tra loại thực tế. Đây là những gì đa hình là về.
Nguyên tắc này cung cấp một giải pháp thay thế để thực hiện kiểm tra loại và chuyển đổi loại, có thể vượt khỏi tầm kiểm soát khi số lượng loại tăng lên và có thể đạt được thông qua tái cấu trúc kéo lên hoặc áp dụng các mẫu như Khách truy cập.