Điều gì có thể đi sai nếu nguyên tắc thay thế Liskov bị vi phạm?


27

Tôi đã theo dõi câu hỏi được đánh giá cao này về khả năng vi phạm nguyên tắc thay thế Liskov. Tôi biết nguyên tắc thay thế Liskov là gì, nhưng trong đầu tôi vẫn chưa rõ ràng là điều gì có thể sai nếu tôi là một nhà phát triển không nghĩ về nguyên tắc này trong khi viết mã hướng đối tượng.


6
Điều gì có thể sai nếu bạn không theo LSP? Trường hợp xấu nhất: bạn kết thúc việc triệu tập Code-thulhu! ;)
Thất vọngWithFormsDesigner

1
Là tác giả của câu hỏi ban đầu đó, tôi phải nói thêm rằng đó là một câu hỏi học thuật. Mặc dù vi phạm có thể gây ra lỗi trong mã, tôi chưa bao giờ gặp lỗi nghiêm trọng hoặc sự cố bảo trì mà tôi có thể đưa ra để vi phạm LSP.
Paul T Davies

2
@Paul Vì vậy, bạn không bao giờ gặp vấn đề với các chương trình của mình do hệ thống phân cấp OO phức tạp (mà bạn không tự thiết kế, nhưng có thể phải gia hạn) khi các hợp đồng bị phá vỡ trái và phải bởi những người không chắc chắn về mục đích của lớp cơ sở để bắt đầu với? Tôi ghen tị với bạn! :)
Andres F.

@PaulTDavies mức độ nghiêm trọng của hậu quả phụ thuộc vào việc người dùng (lập trình viên sử dụng thư viện) có kiến ​​thức chi tiết về việc triển khai thư viện hay không (nghĩa là có quyền truy cập và làm quen với mã của thư viện.) Cuối cùng, người dùng sẽ đặt hàng tá kiểm tra có điều kiện hoặc xây dựng trình bao bọc. xung quanh thư viện để giải thích cho LSP (hành vi cụ thể của lớp). Trường hợp xấu nhất sẽ xảy ra nếu thư viện là một sản phẩm thương mại nguồn đóng.
rwong

@Andres và rwong, vui lòng minh họa những vấn đề đó bằng một câu trả lời. Câu trả lời được chấp nhận hỗ trợ khá nhiều cho Paul Davies ở chỗ hậu quả có vẻ nhỏ (Ngoại lệ) sẽ nhanh chóng được chú ý và khắc phục nếu bạn có trình biên dịch tốt, máy phân tích tĩnh hoặc kiểm tra đơn vị tối thiểu.
user949300

Câu trả lời:


31

Tôi nghĩ rằng nó được nêu rất rõ trong câu hỏi đó là một trong những lý do được bình chọn rất cao.

Bây giờ khi gọi Đóng () trên Tác vụ, có khả năng cuộc gọi sẽ thất bại nếu đó là ProjectTask với trạng thái bắt đầu, khi đó sẽ không nếu đó là Nhiệm vụ cơ bản.

Hãy tưởng tượng nếu bạn sẽ:

public void ProcessTaskAndClose(Task taskToProcess)
{
    taskToProcess.Execute();
    taskToProcess.DateProcessed = DateTime.Now;
    taskToProcess.Close();
}

Trong phương thức này, đôi khi lệnh gọi .Close () sẽ nổ tung, vì vậy bây giờ dựa trên việc triển khai cụ thể loại dẫn xuất, bạn phải thay đổi cách thức phương thức này hoạt động từ cách phương thức này sẽ được viết nếu tác vụ không có kiểu con có thể trao cho phương pháp này.

Do vi phạm thay thế liskov, mã sử dụng loại của bạn sẽ phải có kiến ​​thức rõ ràng về hoạt động nội bộ của các loại dẫn xuất để đối xử với chúng khác nhau. Mã cặp đôi chặt chẽ này và nói chung làm cho việc triển khai khó sử dụng nhất quán hơn.


Điều đó có nghĩa là một lớp con không thể có các phương thức công khai của riêng mình mà không được khai báo trong lớp cha?
Songo

@Songo: Không nhất thiết: có thể, nhưng các phương thức đó "không thể truy cập được" từ một con trỏ cơ sở (hoặc tham chiếu hoặc biến hoặc bất kỳ ngôn ngữ nào bạn sử dụng gọi nó) và bạn cần một số thông tin loại thời gian chạy để truy vấn loại đối tượng có trước khi bạn có thể gọi các chức năng đó. Nhưng đây là một vấn đề liên quan mạnh mẽ đến cú pháp ngôn ngữ và ngữ nghĩa.
Emilio Garavaglia

2
Không. Điều này là khi một lớp con được tham chiếu như thể nó là một loại của lớp cha, trong trường hợp đó các thành viên không được khai báo trong lớp cha là không thể truy cập được.
Chewy Gumball

1
@ Phil Yep; đây là định nghĩa của khớp nối chặt chẽ: Thay đổi một thứ gây ra thay đổi cho những thứ khác. Một lớp kết hợp lỏng lẻo có thể thay đổi việc triển khai mà không yêu cầu bạn thay đổi mã bên ngoài nó. Đây là lý do tại sao hợp đồng tốt, họ hướng dẫn bạn cách không yêu cầu thay đổi đối với người tiêu dùng của đối tượng của bạn: Đáp ứng hợp đồng và người tiêu dùng sẽ không cần sửa đổi, do đó đạt được khớp nối lỏng lẻo. Khi người tiêu dùng của bạn cần mã hóa để thực hiện thay vì hợp đồng của bạn, đây là hợp đồng chặt chẽ và bắt buộc khi vi phạm LSP.
Jimmy Hoffa

1
@ user949300 Thành công của bất kỳ phần mềm nào để hoàn thành công việc của nó không phải là thước đo về chất lượng, chi phí dài hạn hoặc ngắn hạn. Nguyên tắc thiết kế là những nỗ lực mang lại các hướng dẫn để giảm chi phí dài hạn cho phần mềm, không làm cho phần mềm "hoạt động". Mọi người có thể làm theo tất cả các nguyên tắc họ muốn trong khi vẫn không thực hiện được giải pháp làm việc, hoặc không tuân theo và thực hiện giải pháp làm việc. Mặc dù các bộ sưu tập java có thể hoạt động với nhiều người, nhưng điều đó không có nghĩa là chi phí để làm việc với họ về lâu dài là rẻ như nó có thể.
Jimmy Hoffa

13

Nếu bạn không hoàn thành hợp đồng đã được xác định trong lớp cơ sở, mọi thứ có thể âm thầm thất bại khi bạn nhận được kết quả bị tắt.

LSP ở các bang wikipedia

  • Điều kiện tiên quyết không thể được tăng cường trong một kiểu con.
  • Postconditions không thể bị suy yếu trong một kiểu con.
  • Bất biến của siêu kiểu phải được bảo toàn trong một kiểu con.

Nếu bất kỳ trong số này không giữ, người gọi có thể nhận được một kết quả mà anh ta không mong đợi.


1
Bạn có thể nghĩ ra bất kỳ ví dụ cụ thể để chứng minh điều này?
Đánh dấu gian hàng

1
@MarkBooth Vấn đề hình tròn-hình elip / hình vuông có thể hữu ích để chứng minh nó; bài viết trên wikipedia là một nơi tốt để bắt đầu: en.wikipedia.org/wiki/Circle-ellipse_probols
Ed Hastings

7

Hãy xem xét một trường hợp kinh điển từ biên niên sử của các câu hỏi phỏng vấn: bạn đã bắt nguồn Circle từ Ellipse. Tại sao? Bởi vì hình tròn là hình elip IS-AN, tất nhiên!

Ngoại trừ ... hình elip có hai chức năng:

Ellipse.set_alpha_radius(d)
Ellipse.set_beta_radius(d)

Rõ ràng, những điều này phải được xác định lại cho Circle, bởi vì Circle có bán kính đồng đều. Bạn có hai khả năng:

  1. Sau khi gọi set_alpha_radius hoặc set_beta_radius, cả hai đều được đặt thành cùng một số tiền.
  2. Sau khi gọi set_alpha_radius hoặc set_beta_radius, đối tượng không còn là Vòng tròn.

Hầu hết các ngôn ngữ OO không hỗ trợ ngôn ngữ thứ hai và vì một lý do chính đáng: thật đáng ngạc nhiên khi thấy rằng Vòng tròn của bạn không còn là Vòng tròn nữa. Vì vậy, lựa chọn đầu tiên là tốt nhất. Nhưng hãy xem xét các chức năng sau:

some_function(Ellipse byref e)

Hãy tưởng tượng rằng some_feft gọi e.set_alpha_radius. Nhưng vì e thực sự là một Circle, nên đáng ngạc nhiên là bán kính beta của nó cũng được thiết lập.

Và đây là nguyên tắc thay thế: một lớp con phải được thay thế cho một siêu lớp. Nếu không những thứ đáng ngạc nhiên xảy ra.


1
Tôi nghĩ bạn có thể gặp rắc rối nếu bạn sử dụng các đối tượng có thể thay đổi. Một hình tròn cũng là một hình elip. Nhưng nếu bạn thay thế một hình elip cũng là một hình tròn bằng một hình elip khác (đó là những gì bạn đang làm bằng cách sử dụng phương thức setter) thì không có gì đảm bảo rằng hình elip mới cũng sẽ là một hình tròn (hình tròn là một tập hợp con của hình elip).
Giorgio

2
Trong một thế giới chức năng thuần túy (với các đối tượng bất biến), phương thức set_alpha_radius (d) sẽ có hình elip kiểu trả về (cả trong hình elip và trong lớp hình tròn).
Giorgio

@Giorgio Có, tôi nên đề cập rằng vấn đề này chỉ xảy ra với các đối tượng có thể thay đổi.
Kaz Dragon

@KazDragon: Tại sao mọi người sẽ thay thế một hình elip bằng một đối tượng hình tròn khi chúng ta biết rằng hình elip KHÔNG phải là hình tròn? Nếu ai đó làm điều đó, họ không có sự hiểu biết chính xác về các thực thể mà họ đang cố gắng mô hình hóa. Nhưng bằng cách cho phép sự thay thế này, không phải chúng ta khuyến khích sự hiểu biết lỏng lẻo về hệ thống cơ bản mà chúng ta đang cố gắng mô hình hóa trong phần mềm của mình và do đó tạo ra phần mềm xấu có hiệu lực?
maverick

@maverick Tôi tin rằng bạn đã đọc mối quan hệ tôi mô tả ngược. Mối quan hệ được đề xuất là một cách khác: một vòng tròn là một hình elip. Cụ thể, một vòng tròn là một hình elip trong đó bán kính alpha và beta giống hệt nhau. Và do đó, kỳ vọng có thể là bất kỳ hàm nào mong đợi một hình elip như một tham số đều có thể có một vòng tròn. Hãy xem xét tính toán_area (Ellipse). Vượt qua một vòng tròn đến đó sẽ mang lại kết quả tương tự. Nhưng vấn đề là hành vi của các chức năng đột biến của Ellipse không thể thay thế cho những người trong Circle.
Kaz Dragon

6

Theo cách nói của giáo dân:

Mã của bạn sẽ có rất nhiều mệnh đề CASE / switch .

Mỗi một trong các mệnh đề CASE / switch sẽ cần các trường hợp mới được thêm vào theo thời gian, có nghĩa là cơ sở mã không thể mở rộng và có thể duy trì như mong muốn.

LSP cho phép mã hoạt động giống như phần cứng:

Bạn không phải sửa đổi iPod vì bạn đã mua một cặp loa ngoài mới, vì cả loa ngoài cũ và loa mới đều có cùng giao diện, chúng có thể thay thế cho nhau mà iPod không bị mất chức năng mong muốn.


2
-1: tất cả xung quanh câu trả lời tồi
Thomas Eding

3
@Thomas tôi không đồng ý. Đó là một sự tương tự tốt. Ông nói về việc không phá vỡ kỳ vọng, đó là những gì LSP hướng tới. (mặc dù phần về trường hợp / công tắc hơi yếu, tôi đồng ý)
Andres F.

2
Và sau đó Apple đã phá vỡ LSP bằng cách thay đổi kết nối. Câu trả lời này sống.
Magus

Tôi không hiểu những gì các câu lệnh chuyển đổi phải làm với LSP. nếu bạn đang đề cập đến việc chuyển đổi typeof(someObject)để quyết định những gì bạn "được phép làm", thì chắc chắn, nhưng đó hoàn toàn là một mô hình chống khác.
sara

Việc giảm mạnh số lượng báo cáo chuyển đổi là tác dụng phụ đáng mong muốn của LSP. Vì các đối tượng có thể đại diện cho bất kỳ đối tượng nào khác có cùng giao diện, không cần phải quan tâm đến các trường hợp đặc biệt.
Tulains Córdova

1

để đưa ra một ví dụ thực tế với UndoManager của java

nó thừa hưởng từ AbstractUndoableEdithợp đồng có xác định rằng nó có 2 trạng thái (hoàn tác và làm lại) và có thể đi giữa chúng với các cuộc gọi đến undo()redo()

tuy nhiên UndoManager có nhiều trạng thái hơn và hoạt động như một bộ đệm hoàn tác (mỗi lệnh gọi undohoàn tác một số nhưng không phải tất cả các chỉnh sửa, làm suy yếu hậu điều kiện)

điều này dẫn đến tình huống giả định khi bạn thêm UndoManager vào CompoundEdit trước khi gọi, end()sau đó gọi hoàn tác trên CompoundEdit đó sẽ dẫn đến việc gọi undo()mỗi lần chỉnh sửa khi phần chỉnh sửa của bạn hoàn tác một phần

Tôi tự lăn UndoManagerđể tránh điều đó (có lẽ tôi nên đổi tên nó thành UndoBuffer)


1

Ví dụ: Bạn đang làm việc với khung UI và bạn tạo điều khiển UI tùy chỉnh của riêng mình bằng cách phân lớp lớp Controlcơ sở. Các Controllớp cơ sở xác định một phương pháp getSubControls()nên trả về một tập hợp các điều khiển lồng nhau (nếu có). Nhưng bạn ghi đè phương thức để thực sự trả về một danh sách ngày sinh của tổng thống Hoa Kỳ.

Vì vậy, những gì có thể đi sai với điều này? Rõ ràng là việc hiển thị điều khiển sẽ thất bại, vì bạn không trả về danh sách các điều khiển như mong đợi. Nhiều khả năng UI sẽ bị sập. Bạn đang phá vỡ hợp đồng mà các lớp con của Control dự kiến ​​sẽ tuân thủ.


0

Bạn cũng có thể nhìn nó từ quan điểm mô hình hóa. Khi bạn nói rằng một thể hiện của lớp Acũng là một thể hiện của lớp, Bbạn ngụ ý rằng "hành vi có thể quan sát được của một thể hiện của lớp Acũng có thể được phân loại là hành vi có thể quan sát được của một thể hiện của lớp B" (Điều này chỉ có thể nếu lớp Bít cụ thể hơn lớp A.)

Vì vậy, vi phạm LSP có nghĩa là có một số mâu thuẫn trong thiết kế của bạn: bạn đang xác định một số danh mục cho các đối tượng của mình và sau đó bạn không tôn trọng chúng trong quá trình thực hiện của mình, điều gì đó phải sai.

Giống như làm một hộp với một thẻ: "Hộp này chỉ chứa các quả bóng màu xanh", và sau đó ném một quả bóng màu đỏ vào nó. Việc sử dụng thẻ như vậy là gì nếu nó hiển thị thông tin sai?


0

Tôi đã thừa hưởng một cơ sở mã gần đây có một số người vi phạm Liskov chính trong đó. Trong các lớp học quan trọng. Điều này đã gây cho tôi những nỗi đau rất lớn. Hãy để tôi giải thích tại sao.

Tôi có Class A, mà bắt nguồn từ Class B. Class AClass Bchia sẻ một loạt các thuộc tính Class Aghi đè với việc thực hiện riêng của nó. Đặt hoặc nhận một thuộc Class Atính có tác dụng khác với cài đặt hoặc nhận chính xác cùng một thuộc tính Class B.

public Class A
{
    public virtual string Name
    {
        get; set;
    }
}

Class B : A
{
    public override string Name
    {
        get
        {
            return TranslateName(base.Name);
        }
        set
        {
            base.Name = value;
            FunctionWithSideEffects();
        }
    }
}

Đặt sang một bên thực tế rằng đây là một cách cực kỳ khủng khiếp để dịch trong .NET, có một số vấn đề khác với mã này.

Trong trường hợp Namenày được sử dụng như một chỉ mục và một biến điều khiển luồng ở một số nơi. Các lớp trên được rải rác khắp codebase ở cả dạng thô và dẫn xuất của chúng. Vi phạm nguyên tắc thay thế Liskov trong trường hợp này có nghĩa là tôi cần biết ngữ cảnh của mỗi lệnh gọi đến từng hàm lấy lớp cơ sở.

Mã này sử dụng các đối tượng của cả hai Class AClass Bvì vậy tôi không thể đơn giản tạo ra sự Class Atrừu tượng để buộc mọi người sử dụng Class B.

Có một số chức năng tiện ích rất hữu ích hoạt động Class Avà các chức năng tiện ích rất hữu ích khác hoạt động Class B. Lý tưởng nhất là tôi muốn có thể sử dụng bất kỳ chức năng tiện ích nào có thể hoạt động Class Atrên Class B. Nhiều chức năng Class Bcó thể dễ dàng thực hiện Class Anếu không vi phạm LSP.

Điều tồi tệ nhất ở đây là trường hợp cụ thể này thực sự khó tái cấu trúc vì toàn bộ ứng dụng bản lề trên hai lớp này, hoạt động trên cả hai lớp và sẽ phá vỡ hàng trăm cách nếu tôi thay đổi điều này (mà tôi sẽ làm dù sao).

Những gì tôi sẽ phải làm để khắc phục điều này là tạo một thuộc NameTranslatedtính, đây sẽ là Class Bphiên bản của Nametài sản và rất, rất cẩn thận thay đổi mọi tham chiếu đến thuộc tính dẫn xuất Nameđể sử dụng thuộc NameTranslatedtính mới của tôi . Tuy nhiên, thậm chí một trong những tham chiếu này sai toàn bộ ứng dụng có thể nổ tung.

Cho rằng codebase không có các bài kiểm tra đơn vị xung quanh nó, điều này khá gần với kịch bản nguy hiểm nhất mà nhà phát triển có thể gặp phải. Nếu tôi không thay đổi vi phạm, tôi phải tiêu tốn một lượng lớn năng lượng tinh thần để theo dõi loại đối tượng nào đang được vận hành trong mỗi phương pháp và nếu tôi khắc phục vi phạm, tôi có thể làm cho toàn bộ sản phẩm phát nổ vào thời điểm không phù hợp.


Điều gì sẽ xảy ra nếu trong lớp dẫn xuất, bạn che khuất thuộc tính được thừa kế bằng một loại khác có cùng tên [ví dụ: lớp lồng nhau] và tạo các định danh mới BaseNameTranslatedNameđể truy cập cả kiểu A-class Namevà nghĩa B-class? Sau đó, mọi nỗ lực truy cập Namevào một biến loại Bsẽ bị từ chối với lỗi trình biên dịch, vì vậy bạn có thể đảm bảo rằng tất cả các tham chiếu đã được chuyển đổi sang một trong các hình thức khác.
supercat

Tôi không còn làm việc ở nơi đó nữa. Nó sẽ rất khó xử để sửa chữa. :-)
Stephen

-4

Nếu bạn muốn cảm thấy vấn đề vi phạm LSP, hãy nghĩ điều gì xảy ra nếu bạn chỉ có dll / .jar của lớp cơ sở (không có mã nguồn) và bạn phải xây dựng lớp dẫn xuất mới. Bạn không bao giờ có thể hoàn thành nhiệm vụ này.


1
Điều này chỉ mở ra nhiều câu hỏi hơn là một câu trả lời.
Frank
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.