LSP vs OCP / Liskov thay thế VS Đóng


48

Tôi đang cố gắng tìm hiểu các nguyên tắc RẮN của OOP và tôi đã đi đến kết luận rằng LSP và OCP có một số điểm tương đồng (nếu không muốn nói thêm).

nguyên tắc mở / đóng nói rằng "các thực thể phần mềm (lớp, mô-đun, hàm, v.v.) nên được mở để mở rộng, nhưng đóng để sửa đổi".

LSP trong các từ đơn giản nói rằng bất kỳ trường hợp nào Foocó thể được thay thế bằng bất kỳ trường hợp Barnào có nguồn gốc từ đó Foovà chương trình sẽ hoạt động theo cùng một cách.

Tôi không phải là một lập trình viên OOP chuyên nghiệp, nhưng dường như LSP chỉ có thể nếu Bar, xuất phát từ Fooviệc không thay đổi bất cứ điều gì trong đó mà chỉ mở rộng nó. Điều đó có nghĩa là LSP trong chương trình cụ thể chỉ đúng khi OCP đúng và OCP chỉ đúng khi LSP đúng. Điều đó có nghĩa là họ bằng nhau.

Sửa lỗi cho tôi nếu tôi sai. Tôi thực sự muốn hiểu những ý tưởng này. Cảm ơn rất nhiều vì một câu trả lời.


4
Đây là một cách giải thích rất hẹp của cả hai khái niệm. Mở / đóng có thể được duy trì nhưng vẫn vi phạm LSP. Các ví dụ hình chữ nhật / hình vuông hoặc hình elip / hình tròn là hình minh họa tốt. Cả hai đều tuân thủ OCP, nhưng cả hai đều vi phạm LSP.
Joel Etherton

1
Thế giới (hoặc ít nhất là internet) bối rối về điều này. kirkk.com/modularity/2009/12/solid-principles-of- class-design . Anh chàng này nói rằng vi phạm LSP cũng là vi phạm OCP. Và sau đó trong cuốn sách "Thiết kế kỹ thuật phần mềm: Lý thuyết và thực hành" ở trang 156, tác giả đã đưa ra một ví dụ về một cái gì đó tuân thủ OCP nhưng vi phạm LSP. Tôi đã từ bỏ việc này.
Manoj R

@JoelEtherton Những cặp đó chỉ vi phạm LSP nếu chúng có thể thay đổi. Trong trường hợp bất biến, xuất phát Squaretừ Rectanglekhông vi phạm LSP. (Nhưng có lẽ vẫn thiết kế xấu trong trường hợp không thay đổi kể từ khi bạn có thể có hình vuông Rectangles mà không phải là một Squaremà không phù hợp với toán học)
CodesInChaos

Tương tự đơn giản (từ quan điểm của người viết thư viện-người dùng). LSP giống như bán một sản phẩm (thư viện) tuyên bố thực hiện 100% những gì nó nói (trên giao diện hoặc hướng dẫn sử dụng), nhưng thực tế không (hoặc không khớp với những gì được nói). OCP giống như bán một sản phẩm (thư viện) với lời hứa rằng nó có thể được nâng cấp (mở rộng) khi chức năng mới xuất hiện (như phần sụn), nhưng thực sự không thể nâng cấp nếu không có dịch vụ xuất xưởng.
rwong

Câu trả lời:


119

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 extendchỉ 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 IBehaviorgiao 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 setBehaviorphươ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, YoutubeLinkthự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 extendcà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 o1loại Scó một đối tượng o2loại Tsao cho tất cả các chương trình Pđược định nghĩa theo T, thì hành vi của Pkhông thay đổi khi o1được thay thế o2sau đó Slà 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 instanceofenum, 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 canDophươ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 GetPersonsphươ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 doStuffphươ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 doStuffphươ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 doStuffphươ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 finaltừ 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.


7
Đây là một lời giải thích tốt, bởi vì nó không thể hiện quá mức OCP bằng cách ngụ ý nó luôn có nghĩa là thực hiện bằng kế thừa. Đó là sự đơn giản hóa mà tham gia OCP và SRP trong suy nghĩ của một số người, khi thực sự họ có thể là hai khái niệm hoàn toàn riêng biệt.
Eric King

5
Đây là một trong những câu trả lời trao đổi ngăn xếp tốt nhất mà tôi từng thấy. Tôi ước tôi có thể nâng nó lên 10 lần. Tốt lắm, và cảm ơn bạn đã giải thích tuyệt vời.
Bob Horn

Ở đó, tôi đã thêm một bản blurb trên Javascript không phải là ngôn ngữ lập trình dựa trên lớp nhưng vẫn có thể theo LSP và chỉnh sửa văn bản để hy vọng nó đọc trôi chảy hơn. Phù!
Spoike

Mặc dù trích dẫn của bạn từ chú Bob từ LSP là chính xác (giống như trang web của anh ấy), nhưng không phải là cách khác sao? Không nên nói rằng "Các lớp cơ sở nên thay thế cho các lớp dẫn xuất của chúng"? Trên LSP, kiểm tra "tính tương thích" được thực hiện đối với lớp dẫn xuất chứ không phải lớp cơ sở. Tuy nhiên, tôi không phải là người nói tiếng Anh bản địa và tôi nghĩ có thể có một số chi tiết về cụm từ mà tôi có thể bị thiếu.
Alpha

@Alpha: Đó là một câu hỏi hay. Lớp cơ sở luôn luôn có thể thay thế bằng các lớp dẫn xuất của nó hoặc kế thừa khác sẽ không hoạt động. Trình biên dịch (ít nhất là trong Java và C #) sẽ khiếu nại nếu bạn rời khỏi một thành viên (phương thức hoặc thuộc tính / trường) khỏi lớp mở rộng cần được triển khai. LSP có nghĩa là ngăn bạn thêm các phương thức chỉ có sẵn cục bộ trên các lớp dẫn xuất, vì điều đó đòi hỏi người dùng của các lớp dẫn xuất đó phải biết về chúng. Khi mã phát triển, các phương thức như vậy sẽ khó duy trì.
Spoike

15

Đây là một cái gì đó gây ra nhiều nhầm lẫn. Tôi thích xem xét các nguyên tắc này một cách triết lý, bởi vì có nhiều ví dụ khác nhau cho chúng, và đôi khi các ví dụ cụ thể không thực sự nắm bắt được toàn bộ bản chất của chúng.

Những gì OCP cố gắng khắc phục

Nói rằng chúng ta cần thêm chức năng cho một chương trình nhất định. Cách dễ nhất để nói về nó, đặc biệt là đối với những người được đào tạo để suy nghĩ theo thủ tục, là thêm một mệnh đề if bất cứ khi nào cần, hoặc một cái gì đó tương tự.

Vấn đề với đó là

  1. Nó thay đổi dòng chảy của mã làm việc hiện có.
  2. Nó buộc một nhánh có điều kiện mới trên mọi trường hợp. Ví dụ: giả sử bạn có một danh sách các cuốn sách và một số trong số chúng đang được bán và bạn muốn lặp lại tất cả chúng và in giá của chúng, để nếu chúng được bán, giá in sẽ bao gồm chuỗi " (BÁN) ".

Bạn có thể làm điều này bằng cách thêm một trường bổ sung vào tất cả các sách có tên "is_on_sale", và sau đó bạn có thể kiểm tra trường đó khi in bất kỳ giá sách nào, hoặc cách khác , bạn có thể khởi tạo sách bán từ cơ sở dữ liệu bằng một loại khác, in "(ON SALE)" trong chuỗi giá (không phải là một thiết kế hoàn hảo nhưng nó mang lại điểm chính).

Vấn đề với giải pháp thủ tục đầu tiên là một lĩnh vực bổ sung cho mỗi cuốn sách và sự phức tạp dư thừa trong nhiều trường hợp. Giải pháp thứ hai chỉ buộc logic khi thực sự cần thiết.

Bây giờ hãy xem xét thực tế rằng có thể có nhiều trường hợp cần dữ liệu và logic khác nhau, và bạn sẽ thấy tại sao phải ghi nhớ OCP trong khi thiết kế các lớp học của bạn, hoặc phản ứng với các thay đổi trong yêu cầu, là một ý tưởng tốt.

Bây giờ bạn sẽ có được ý tưởng chính: Cố gắng đặt mình vào tình huống mà mã mới có thể được triển khai dưới dạng các phần mở rộng đa hình, không phải là sửa đổi thủ tục.

Nhưng đừng bao giờ ngại phân tích bối cảnh và xem liệu những hạn chế xảy ra có vượt quá lợi ích hay không, bởi vì ngay cả một nguyên tắc như OCP cũng có thể tạo ra một mớ hỗn độn 20 lớp trong chương trình 20 dòng, nếu không được xử lý cẩn thận .

Những gì LSP cố gắng khắc phục

Chúng tôi đều thích sử dụng lại mã. Một căn bệnh xảy ra là nhiều chương trình không hiểu hoàn toàn về nó, đến mức họ mù quáng tìm hiểu các dòng mã chung chỉ để tạo ra sự phức tạp không thể đọc được và khớp nối chặt chẽ giữa các mô-đun, ngoại trừ một vài dòng mã, không có gì chung cho đến khi hoàn thành công việc theo khái niệm.

Ví dụ lớn nhất về điều này là tái sử dụng giao diện . Có lẽ bạn đã tự mình chứng kiến ​​điều đó; một lớp thực hiện một giao diện, không phải vì nó là một triển khai hợp lý của nó (hoặc một phần mở rộng trong trường hợp các lớp cơ sở cụ thể), mà bởi vì các phương thức mà nó xảy ra để khai báo tại thời điểm đó có chữ ký đúng như nó có liên quan.

Nhưng sau đó bạn gặp phải một vấn đề. Nếu các lớp chỉ thực hiện giao diện bằng cách xem xét chữ ký của các phương thức mà chúng khai báo, thì bạn thấy mình có thể chuyển các thể hiện của các lớp từ một chức năng khái niệm sang những nơi đòi hỏi chức năng hoàn toàn khác nhau, điều đó chỉ xảy ra phụ thuộc vào chữ ký tương tự.

Điều đó không phải là khủng khiếp, nhưng nó gây ra nhiều nhầm lẫn, và chúng ta có công nghệ để ngăn mình khỏi những sai lầm như thế này. Điều chúng ta cần làm là coi các giao diện là API + Giao thức . API là rõ ràng trong khai báo và giao thức là rõ ràng trong việc sử dụng giao diện hiện có. Nếu chúng ta có 2 giao thức khái niệm chia sẻ cùng một API, chúng sẽ được biểu diễn dưới dạng 2 giao diện khác nhau. Nếu không, chúng ta bị cuốn vào giáo điều DRY và trớ trêu thay, chỉ tạo ra khó khăn hơn để duy trì mã.

Bây giờ bạn sẽ có thể hiểu định nghĩa hoàn hảo. LSP nói: Không kế thừa từ một lớp cơ sở và thực hiện chức năng trong các lớp con đó, những nơi khác, phụ thuộc vào lớp cơ sở, sẽ không hòa hợp với nhau.


1
Tôi đã đăng ký chỉ để có thể bỏ phiếu này và câu trả lời của Spoike - công việc tuyệt vời.
David Culp

7

Từ sự hiểu biết của tôi:

OCP nói: "Nếu bạn sẽ thêm chức năng mới, hãy tạo một lớp mới mở rộng một lớp hiện có, thay vì thay đổi nó."

LSP nói: "Nếu bạn tạo một lớp mới mở rộng một lớp hiện có, hãy đảm bảo rằng nó hoàn toàn có thể hoán đổi với cơ sở của nó."

Vì vậy, tôi nghĩ rằng họ bổ sung cho nhau nhưng họ không bằng nhau.


4

Mặc dù đúng là cả OCP và LSP đều phải sửa đổi, nhưng loại sửa đổi mà OCP nói về không phải là một LSP nói về.

Sửa đổi liên quan đến OCP là hành động vật lý của một nhà phát triển viết mã trong một lớp hiện có.

LSP liên quan đến việc sửa đổi hành vi mà lớp dẫn xuất mang lại so với lớp cơ sở của nó và sự thay đổi thời gian chạy của việc thực thi chương trình có thể được gây ra bằng cách sử dụng lớp con thay vì lớp cha.

Vì vậy, mặc dù chúng có thể trông giống nhau từ khoảng cách OCP! = LSP. Trong thực tế, tôi nghĩ rằng chúng có thể là 2 nguyên tắc RẮN duy nhất không thể hiểu theo nghĩa của nhau.


2

LSP nói một cách đơn giản rằng bất kỳ trường hợp nào của Foo đều có thể được thay thế bằng bất kỳ trường hợp nào của Bar có nguồn gốc từ Foo mà không làm mất chức năng chương trình.

Cái này sai. LSP tuyên bố rằng lớp Bar không nên đưa ra hành vi, điều đó không được mong đợi khi mã sử dụng Foo, khi Bar có nguồn gốc từ Foo. Nó không có gì để làm với mất chức năng. Bạn có thể xóa chức năng, nhưng chỉ khi mã sử dụng Foo không phụ thuộc vào chức năng này.

Nhưng cuối cùng, điều này thường khó đạt được, bởi vì hầu hết thời gian, mã sử dụng Foo phụ thuộc vào tất cả các hành vi của nó. Vì vậy, loại bỏ nó vi phạm LSP. Nhưng đơn giản hóa nó như thế này chỉ là một phần của LSP.


Một trường hợp rất phổ biến là khi đối tượng được thay thế loại bỏ các tác dụng phụ : vd. một logger giả mà không tạo ra gì, hoặc một đối tượng giả được sử dụng trong thử nghiệm.
Vô dụng

0

Về các đối tượng có thể vi phạm

Để hiểu sự khác biệt, bạn nên hiểu các chủ đề của cả hai nguyên tắc. Nó không phải là một phần trừu tượng của mã hoặc tình huống có thể vi phạm hoặc không theo nguyên tắc nào đó. Nó luôn luôn là một số thành phần cụ thể - chức năng, lớp hoặc mô-đun - có thể vi phạm OCP hoặc LSP.

Ai có thể vi phạm LSP

Người ta có thể kiểm tra nếu LSP bị hỏng chỉ khi có một giao diện với một số hợp đồng và việc thực hiện giao diện đó. Nếu việc triển khai không phù hợp với giao diện hoặc nói chung là hợp đồng thì LSP bị hỏng.

Ví dụ đơn giản nhất:

class Container {
    // Should add the object to the container.
    void addObject(object) {
        internalArray.append(object);
    }

    int size() {
        return internalArray.size();
    }
}

class CustomContainer extends Container {
    @Override void addObject(object) {
        System.console.print("Skipping object! Ha-ha!");
    }
}

void fillWithRandomNumbers(Container container) {
    while (container.size() < 42) {
        container.addObject(Randomizer.getNumber())
    }
}

Hợp đồng nêu rõ rằng addObjectnên nối thêm đối số của nó vào container. Và CustomContainerrõ ràng phá vỡ hợp đồng đó. Do đó, CustomContainer.addObjectchức năng vi phạm LSP. Do đó, CustomContainerlớp vi phạm LSP. Hậu quả quan trọng nhất là CustomContainerkhông thể truyền sang fillWithRandomNumbers(). Containerkhông thể được thay thế bằng CustomContainer.

Hãy ghi nhớ một điểm rất quan trọng. Đây không phải là toàn bộ mã phá vỡ LSP, mà cụ thể CustomContainer.addObjectvà nói chung CustomContainerlà phá vỡ LSP. Khi bạn tuyên bố rằng LSP bị vi phạm, bạn phải luôn chỉ định hai điều:

  • Các thực thể vi phạm LSP.
  • Hợp đồng bị phá vỡ bởi các thực thể.

Đó là nó. Chỉ cần một hợp đồng và thực hiện nó. Một đoạn mã trong mã không nói gì về vi phạm LSP.

Ai có thể vi phạm OCP

Người ta có thể kiểm tra nếu OCP chỉ bị vi phạm khi có tập dữ liệu hạn chế và thành phần xử lý các giá trị từ tập dữ liệu đó. Nếu các giới hạn của tập dữ liệu có thể thay đổi theo thời gian và yêu cầu thay đổi mã nguồn của thành phần, thì thành phần đó vi phạm OCP.

Âm thanh phức tạp. Hãy thử một ví dụ đơn giản:

enum Platform {
    iOS,
    Android
}

class PlatformDescriber {
    String describe(Platform platform) {
        switch (platform) {
            case iOS: return "iPhone OS, v10.0.1";
            case Android: return "Android, v7.1";
        }
    }
}

Tập dữ liệu là tập hợp các nền tảng được hỗ trợ. PlatformDescriberlà thành phần xử lý các giá trị từ tập dữ liệu đó. Thêm một nền tảng mới yêu cầu cập nhật mã nguồn của PlatformDescriber. Do đó, PlatformDescriberlớp vi phạm OCP.

Một vi dụ khac:

class Shop {
    void sellItemToCustomer(item, customer) {
        // some buisiness logic here
        ...
        logger.logItemSold()
    }
}

class Logger {
    void logItemSold() {
        logger.logToStdErr("an item was sold")
        logger.logToRemote("an item was sold")
        logger.logToDatabase("an item was sold")
    }
}

"Tập dữ liệu" là tập hợp các kênh cần thêm mục nhập nhật ký. Loggerlà thành phần chịu trách nhiệm thêm các mục vào tất cả các kênh. Thêm hỗ trợ cho một cách ghi nhật ký khác yêu cầu cập nhật mã nguồn của Logger. Do đó, Loggerlớp vi phạm OCP.

Lưu ý rằng trong cả hai ví dụ, tập dữ liệu không phải là một cái gì đó cố định về mặt ngữ nghĩa. Nó có thể thay đổi theo thời gian. Một nền tảng mới có thể xuất hiện. Một kênh đăng nhập mới có thể xuất hiện. Nếu thành phần của bạn cần được cập nhật khi điều đó xảy ra, nó vi phạm OCP.

Đẩy các giới hạn

Bây giờ là phần khó khăn. So sánh các ví dụ trên với sau:

enum GregorianWeekDay {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday,
    Saturday,
    Sunday
}

String translateToRussian(GregorianWeekDay weekDay) {
    switch (weekDay) {
        case Monday: return "Понедельник";
        case Tuesday: return "Вторник";
        case Wednesday: return "Среда";
        case Thursday: return "Четверг";
        case Friday: return "Пятница";
        case Saturday: return "Суббота";
        case Sunday: return "Воскресенье";
    }
}

Bạn có thể nghĩ translateToRussianvi phạm OCP. Nhưng thực tế không phải vậy. GregorianWeekDaycó giới hạn cụ thể là chính xác 7 ngày trong tuần với tên chính xác. Và điều quan trọng là những giới hạn này về mặt ngữ nghĩa không thể thay đổi theo thời gian. Sẽ luôn có 7 ngày trong tuần lễ gregorian. Sẽ luôn có Thứ Hai, Thứ Ba, v.v. Bộ dữ liệu này được cố định về mặt ngữ nghĩa. translateToRussianMã nguồn không thể yêu cầu sửa đổi. Do đó, OCP không bị vi phạm.

Bây giờ, rõ ràng là switchtuyên bố cạn kiệt không phải lúc nào cũng là một dấu hiệu của OCP bị hỏng.

Sự khác biệt

Bây giờ cảm thấy sự khác biệt:

  • Chủ đề của LSP là "việc thực hiện giao diện / hợp đồng". Nếu việc thực hiện không phù hợp với hợp đồng thì nó phá vỡ LSP. Điều đó không quan trọng nếu việc thực hiện đó có thể thay đổi theo thời gian hay không, nếu nó có thể mở rộng hay không.
  • Chủ đề của OCP là "cách đáp ứng yêu cầu thay đổi". Nếu hỗ trợ cho một loại dữ liệu mới yêu cầu thay đổi mã nguồn của thành phần xử lý dữ liệu đó, thì thành phần đó sẽ phá vỡ OCP. Nó không quan trọng nếu thành phần phá vỡ hợp đồng của nó hay không.

Những điều kiện này là hoàn toàn trực giao.

Ví dụ

Trong @ câu trả lời Spoike của các Vi phạm một nguyên tắc nhưng sau kia một phần là hoàn toàn sai.

Trong ví dụ đầu tiên, forphần -loop rõ ràng vi phạm OCP vì nó không thể mở rộng mà không sửa đổi. Nhưng không có dấu hiệu vi phạm LSP. Và thậm chí không rõ ràng nếu Contexthợp đồng cho phép getPersons trả lại bất cứ thứ gì ngoại trừ Bosshoặc Peon. Ngay cả khi giả định một hợp đồng cho phép bất kỳ IPersonlớp con nào được trả lại, không có lớp nào ghi đè lên điều kiện hậu này và vi phạm nó. Hơn nữa, nếu getPersons sẽ trả về một thể hiện của một số lớp thứ ba, for-loop sẽ thực hiện công việc của nó mà không gặp bất kỳ thất bại nào. Nhưng thực tế đó không liên quan gì đến LSP.

Kế tiếp. Trong ví dụ thứ hai, cả LSP và OCP đều không bị vi phạm. Một lần nữa, Contextphần này không liên quan gì đến LSP - không có hợp đồng được xác định, không phân lớp, không ghi đè. Đó không phải là Contextngười nên tuân theo LSP, LiskovSubkhông nên phá vỡ hợp đồng của cơ sở. Về OCP, lớp học có thực sự đóng cửa? - Vâng, đúng vậy. Không cần sửa đổi để mở rộng nó. Rõ ràng tên của trạng thái điểm mở rộng Làm bất cứ điều gì bạn muốn, không có giới hạn . Ví dụ này không hữu ích lắm trong cuộc sống thực, nhưng rõ ràng nó không vi phạm OCP.

Hãy thử đưa ra một số ví dụ chính xác với vi phạm thực sự của OCP hoặc LSP.

Theo dõi OCP nhưng không phải LSP

interface Platform {
    String name();
    String version();
}

class iOS implements Platform {
    @Override String name() { return "iOS"; }
    @Override String version() { return "10.0.1"; }
}

interface PlatformSerializer {
    String toJson(Platform platform);
}

class HumanReadablePlatformSerializer implements PlatformSerializer {
    String toJson(Platform platform) {
        return platform.name() + ", v" + platform.version();
    }
}

Ở đây, HumanReadablePlatformSerializerkhông yêu cầu bất kỳ sửa đổi nào khi một nền tảng mới được thêm vào. Do đó, nó tuân theo OCP.

Nhưng hợp đồng yêu cầu toJsonphải trả lại một JSON được định dạng đúng. Lớp học không làm điều đó. Do đó, nó không thể được chuyển đến một thành phần sử dụng PlatformSerializerđể định dạng phần thân của yêu cầu mạng. Do đó HumanReadablePlatformSerializervi phạm LSP.

Theo dõi LSP nhưng không phải OCP

Một số sửa đổi cho ví dụ trước:

class Android implements Platform {
    @Override String name() { return "Android"; }
    @Override String version() { return "7.1"; }
}
class HumanReadablePlatformSerializer implements PlatformSerializer {
    String toJson(Platform platform) {
        return "{ "
                + "\"name\": \"" + platform.name() + "\","
                + "\"version\": \"" + platform.version() + "\","
                + "\"most-popular\": " + isMostPopular(platform) + ","
                + "}"
    }

    boolean isMostPopular(Platform platform) {
        return (platform instanceof Android)
    }
}

Trình tuần tự trả về chuỗi JSON được định dạng chính xác. Vì vậy, không có vi phạm LSP ở đây.

Nhưng có một yêu cầu là nếu nền tảng được sử dụng nhiều nhất thì nên có chỉ dẫn tương ứng trong JSON. Trong ví dụ này, OCP bị vi phạm bởi HumanReadablePlatformSerializer.isMostPopularchức năng vì một ngày nào đó iOS trở thành nền tảng phổ biến nhất. Chính thức, điều đó có nghĩa là bây giờ tập hợp các nền tảng được sử dụng nhiều nhất được định nghĩa là "Android" và isMostPopularxử lý không đầy đủ bộ dữ liệu đó. Tập dữ liệu không cố định về mặt ngữ nghĩa và có thể tự do thay đổi theo thời gian. HumanReadablePlatformSerializerMã nguồn bắt buộc phải được cập nhật trong trường hợp thay đổi.

Bạn cũng có thể nhận thấy sự vi phạm Trách nhiệm đơn lẻ trong ví dụ này. Tôi cố tình thực hiện để có thể chứng minh cả hai nguyên tắc trên cùng một thực thể chủ thể. Để sửa lỗi SRP, bạn có thể trích xuất isMostPopularhàm sang một số bên ngoài Helpervà thêm một tham số PlatformSerializer.toJson. Nhưng nó là một câu chuyện khác.


0

LSP và OCP không giống nhau.

LSP nói về sự đúng đắn của chương trình như nó đứng . Nếu một thể hiện của một kiểu con sẽ phá vỡ tính chính xác của chương trình khi được thay thế thành mã cho các loại tổ tiên, thì bạn đã chứng minh sự vi phạm LSP. Bạn có thể phải thử nghiệm một thử nghiệm để hiển thị điều này, nhưng bạn sẽ không phải thay đổi cơ sở mã cơ bản. Bạn đang xác nhận chương trình để xem nó có đáp ứng LSP không.

OCP nói về tính chính xác của các thay đổi trong mã chương trình, delta từ phiên bản nguồn này sang phiên bản nguồn khác. Hành vi không nên được sửa đổi. Nó chỉ nên được mở rộng. Ví dụ cổ điển là bổ sung trường. Tất cả các lĩnh vực hiện có tiếp tục hoạt động như trước đây. Các lĩnh vực mới chỉ cần thêm chức năng. Tuy nhiên, xóa một trường thường là vi phạm OCP. Ở đây bạn đang xác nhận phiên bản chương trình delta để xem nó có đáp ứng OCP không.

Vì vậy, đó là sự khác biệt chính giữa LSP và OCP. Cái trước chỉ xác nhận cơ sở mã khi nó đứng , cái sau chỉ xác nhận delta cơ sở mã từ phiên bản này sang phiên bản tiếp theo . Vì vậy, chúng không thể là cùng một thứ, chúng được định nghĩa là xác nhận những thứ khác nhau.

Tôi sẽ cung cấp cho bạn một bằng chứng chính thức hơn: Để nói "LSP ngụ ý OCP" sẽ ám chỉ một delta (vì OCP yêu cầu một cái khác ngoài trường hợp tầm thường), nhưng LSP không yêu cầu một. Vì vậy, đó là rõ ràng sai. Ngược lại, chúng ta có thể từ chối "OCP ngụ ý LSP" chỉ bằng cách nói OCP là một tuyên bố về deltas do đó nó không nói gì về một tuyên bố về một chương trình tại chỗ. Điều đó xuất phát từ thực tế là bạn có thể tạo BẤT K delta delta bắt đầu với BẤT K program chương trình nào. Họ hoàn toàn độc lập.


-1

Tôi sẽ xem xét nó từ quan điểm của khách hàng. nếu Client đang sử dụng các tính năng của một giao diện và bên trong tính năng đó đã được Class A. triển khai. Giả sử có một lớp B mở rộng lớp A, thì ngày mai nếu tôi loại bỏ lớp A khỏi giao diện đó và đặt lớp B, thì lớp B sẽ cũng cung cấp các tính năng tương tự cho khách hàng. Ví dụ tiêu chuẩn là lớp Vịt bơi, và nếu ToyDuck mở rộng Vịt thì nó cũng nên bơi và không phàn nàn rằng nó không biết bơi, nếu không, ToyDuck không nên có lớp Vịt mở rộng.


Sẽ rất có tính xây dựng nếu mọi người cũng bình luận trong khi bỏ phiếu bất kỳ câu trả lời nào. Sau tất cả, tất cả chúng ta đều ở đây để chia sẻ kiến ​​thức, và chỉ cần thông qua phán xét mà không có lý do chính đáng sẽ không phục vụ cho bất kỳ mục đích nào.
AKS

điều này dường như không cung cấp bất cứ điều gì đáng kể qua các điểm được thực hiện và giải thích trong 6 câu trả lời trước
gnat

1
Có vẻ như bạn chỉ đang giải thích một trong những nguyên tắc, L tôi nghĩ. Đối với những gì nó là ok nhưng câu hỏi yêu cầu so sánh / tương phản của hai nguyên tắc khác nhau. Đó có lẽ là lý do tại sao ai đó đánh giá thấp nó.
StarWeaver
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.