Tôi đã nghe nói rằng Nguyên tắc thay thế Liskov (LSP) là một nguyên tắc cơ bản của thiết kế hướng đối tượng. Nó là gì và một số ví dụ về việc sử dụng nó là gì?
Tôi đã nghe nói rằng Nguyên tắc thay thế Liskov (LSP) là một nguyên tắc cơ bản của thiết kế hướng đối tượng. Nó là gì và một số ví dụ về việc sử dụng nó là gì?
Câu trả lời:
Một ví dụ tuyệt vời minh họa LSP (được đưa ra bởi chú Bob trong một podcast tôi nghe gần đây) là đôi khi một thứ gì đó nghe có vẻ đúng trong ngôn ngữ tự nhiên không hoạt động hoàn toàn bằng mã.
Trong toán học, a Square
là a Rectangle
. Quả thực đó là một chuyên môn của một hình chữ nhật. "Là một" làm cho bạn muốn mô hình hóa điều này với sự kế thừa. Tuy nhiên, nếu trong mã bạn đã tạo Square
ra từ Rectangle
đó, thì Square
nên sử dụng bất cứ nơi nào bạn mong đợi a Rectangle
. Điều này làm cho một số hành vi lạ.
Hãy tưởng tượng bạn đã có SetWidth
và SetHeight
các phương thức trên Rectangle
lớp cơ sở của bạn ; Điều này có vẻ hoàn toàn hợp lý. Tuy nhiên, nếu Rectangle
tham chiếu của bạn chỉ đến a Square
, thì SetWidth
và SetHeight
không có ý nghĩa gì vì đặt cái này sẽ thay đổi cái kia để phù hợp với nó. Trong trường hợp này Square
thất bại trong Thử nghiệm thay thế Liskov Rectangle
và sự trừu tượng của việc Square
thừa kế từ đó Rectangle
là một điều xấu.
Bạn nên xem các Nguyên tắc RẮN nguyên tắc vô giá khác .
Square.setWidth(int width)
được thực hiện như thế này : this.width = width; this.height = width;
? Trong trường hợp này, đảm bảo rằng chiều rộng bằng chiều cao.
Nguyên tắc thay thế Liskov (LSP, lsp) là một khái niệm trong Lập trình hướng đối tượng cho biết:
Các hàm sử dụng các con trỏ hoặc tham chiếu đến các lớp cơ sở phải có thể sử dụng các đối tượng của các lớp dẫn xuất mà không biết nó.
Trọng tâm của LSP là về các giao diện và hợp đồng cũng như cách quyết định khi nào nên mở rộng một lớp so với sử dụng một chiến lược khác như thành phần để đạt được mục tiêu của bạn.
Cách hiệu quả nhất mà tôi đã thấy để minh họa điểm này là trong Head First OOA & D . Họ trình bày một kịch bản mà bạn là nhà phát triển trong một dự án để xây dựng một khuôn khổ cho các trò chơi chiến lược.
Họ trình bày một lớp đại diện cho một bảng trông như thế này:
Tất cả các phương thức lấy tọa độ X và Y làm tham số để xác định vị trí ô trong mảng hai chiều của Tiles
. Điều này sẽ cho phép một nhà phát triển trò chơi quản lý các đơn vị trong bảng trong suốt quá trình của trò chơi.
Cuốn sách tiếp tục thay đổi các yêu cầu để nói rằng khung trò chơi cũng phải hỗ trợ các bảng trò chơi 3D để phù hợp với các trò chơi có chuyến bay. Vì vậy, một ThreeDBoard
lớp học được giới thiệu mà mở rộng Board
.
Thoạt nhìn đây có vẻ là một quyết định tốt. Board
cung cấp cả Height
và Width
tài sản và ThreeDBoard
cung cấp các trục Z.
Nơi nó tan vỡ là khi bạn nhìn vào tất cả các thành viên khác được thừa hưởng từ đó Board
. Các phương pháp để AddUnit
, GetTile
, GetUnits
và như vậy, tất cả mất cả X và các thông số Y trong Board
lớp nhưng ThreeDBoard
cần một tham số Z là tốt.
Vì vậy, bạn phải thực hiện lại các phương thức đó với tham số Z. Tham số Z không có ngữ cảnh cho Board
lớp và các phương thức được kế thừa từ Board
lớp mất ý nghĩa của chúng. Một đơn vị mã cố gắng sử dụng ThreeDBoard
lớp làm lớp cơ sở của nó Board
sẽ rất may mắn.
Có lẽ chúng ta nên tìm một cách tiếp cận khác. Thay vì mở rộng Board
, ThreeDBoard
nên bao gồm các Board
đối tượng. Một Board
đối tượng trên một đơn vị của trục Z.
Điều này cho phép chúng tôi sử dụng các nguyên tắc hướng đối tượng tốt như đóng gói và tái sử dụng và không vi phạm LSP.
Sự thay thế là một nguyên tắc trong lập trình hướng đối tượng nói rằng, trong một chương trình máy tính, nếu S là một kiểu con của T, thì các đối tượng thuộc loại T có thể được thay thế bằng các đối tượng loại S
hãy làm một ví dụ đơn giản trong Java:
public class Bird{
public void fly(){}
}
public class Duck extends Bird{}
Con vịt có thể bay vì nó là một con chim, nhưng điều này thì sao:
public class Ostrich extends Bird{}
Đà điểu là một con chim, nhưng nó không thể bay, lớp đà điểu là một kiểu con của lớp Chim, nhưng nó không thể sử dụng phương pháp bay, điều đó có nghĩa là chúng ta đang phá vỡ nguyên tắc LSP.
public class Bird{
}
public class FlyingBirds extends Bird{
public void fly(){}
}
public class Duck extends FlyingBirds{}
public class Ostrich extends Bird{}
Bird bird
. Bạn phải truyền đối tượng lên FlyingBirds để sử dụng bay, điều này không tốt phải không?
Bird bird
, điều đó có nghĩa là nó không thể sử dụng fly()
. Đó là nó. Vượt qua một Duck
không thay đổi thực tế này. Nếu khách hàng có FlyingBirds bird
, thì ngay cả khi nó được thông qua, Duck
nó vẫn luôn hoạt động theo cùng một cách.
LSP quan tâm bất biến.
Ví dụ cổ điển được đưa ra bởi khai báo mã giả sau đây (triển khai được bỏ qua):
class Rectangle {
int getHeight()
void setHeight(int value)
int getWidth()
void setWidth(int value)
}
class Square : Rectangle { }
Bây giờ chúng tôi có một vấn đề mặc dù giao diện phù hợp. Lý do là chúng tôi đã vi phạm bất biến xuất phát từ định nghĩa toán học của hình vuông và hình chữ nhật. Cách thức hoạt động của getters và setters, Rectangle
nên đáp ứng các bất biến sau:
void invariant(Rectangle r) {
r.setHeight(200)
r.setWidth(100)
assert(r.getHeight() == 200 and r.getWidth() == 100)
}
Tuy nhiên, bất biến này phải bị vi phạm bởi việc thực hiện đúng Square
, do đó nó không phải là sự thay thế hợp lệ Rectangle
.
Robert Martin có một bài viết xuất sắc về Nguyên tắc thay thế Liskov . Nó thảo luận về những cách tinh tế và không tinh tế trong đó nguyên tắc có thể bị vi phạm.
Một số phần có liên quan của bài báo (lưu ý rằng ví dụ thứ hai được cô đọng nhiều):
Một ví dụ đơn giản về vi phạm LSP
Một trong những vi phạm rõ ràng nhất của nguyên tắc này là việc sử dụng Thông tin loại thời gian chạy (RTTI) của C ++ để chọn một chức năng dựa trên loại đối tượng. I E:
void DrawShape(const Shape& s) { if (typeid(s) == typeid(Square)) DrawSquare(static_cast<Square&>(s)); else if (typeid(s) == typeid(Circle)) DrawCircle(static_cast<Circle&>(s)); }
Rõ ràng
DrawShape
chức năng được hình thành xấu. Nó phải biết về mọi đạo hàm có thể có củaShape
lớp và nó phải được thay đổi bất cứ khi nào các đạo hàm mớiShape
được tạo ra. Thật vậy, nhiều người xem cấu trúc của chức năng này như là sự kết hợp với Thiết kế hướng đối tượng.Hình vuông và hình chữ nhật, một vi phạm tinh vi hơn.
Tuy nhiên, có những cách khác, tinh vi hơn nhiều, vi phạm LSP. Hãy xem xét một ứng dụng sử dụng
Rectangle
lớp như được mô tả dưới đây:class Rectangle { public: void SetWidth(double w) {itsWidth=w;} void SetHeight(double h) {itsHeight=w;} double GetHeight() const {return itsHeight;} double GetWidth() const {return itsWidth;} private: double itsWidth; double itsHeight; };
[...] Hãy tưởng tượng rằng một ngày nào đó người dùng yêu cầu khả năng thao tác hình vuông ngoài hình chữ nhật. [...]
Rõ ràng, một hình vuông là một hình chữ nhật cho tất cả các mục đích và mục đích bình thường. Vì mối quan hệ ISA giữ, nên mô hình hóa
Square
lớp là bắt nguồn từ đóRectangle
. [...]
Square
sẽ kế thừaSetWidth
và cácSetHeight
chức năng. Các hàm này hoàn toàn không phù hợp với aSquare
, vì chiều rộng và chiều cao của hình vuông là giống hệt nhau. Đây phải là một đầu mối quan trọng rằng có một vấn đề với thiết kế. Tuy nhiên, có một cách để vượt qua vấn đề. Chúng tôi có thể ghi đèSetWidth
vàSetHeight
[...]Nhưng hãy xem xét các chức năng sau:
void f(Rectangle& r) { r.SetWidth(32); // calls Rectangle::SetWidth }
Nếu chúng ta chuyển tham chiếu đến một
Square
đối tượng vào hàm này,Square
đối tượng sẽ bị hỏng vì chiều cao sẽ không bị thay đổi. Đây là một vi phạm rõ ràng về LSP. Hàm này không hoạt động cho các dẫn xuất của các đối số của nó.[...]
Now the rule for the preconditions and postconditions for derivatives, as stated by Meyer is: ...when redefining a routine [in a derivative], you may only replace its precondition by a weaker one, and its postcondition by a stronger one.
Nếu điều kiện trước của lớp trẻ mạnh hơn điều kiện trước của lớp cha mẹ, bạn không thể thay thế một đứa trẻ cho cha mẹ mà không vi phạm điều kiện trước. Do đó LSP.
LSP là cần thiết khi một số mã nghĩ rằng nó đang gọi các phương thức của một loại T
và có thể vô tình gọi các phương thức của một loại S
, trong đó S extends T
(tức là S
thừa kế, xuất phát từ hoặc là một kiểu con của siêu kiểu T
).
Ví dụ, điều này xảy ra khi một hàm có tham số đầu vào loại T
, được gọi (nghĩa là được gọi) với giá trị đối số của loại S
. Hoặc, trong đó một định danh loại T
, được gán một giá trị của loại S
.
val id : T = new S() // id thinks it's a T, but is a S
LSP yêu cầu các kỳ vọng (tức là bất biến) cho các phương thức loại T
(ví dụ Rectangle
), không bị vi phạm khi các phương thức loại S
(ví dụ Square
) được gọi thay thế.
val rect : Rectangle = new Square(5) // thinks it's a Rectangle, but is a Square
val rect2 : Rectangle = rect.setWidth(10) // height is 10, LSP violation
Ngay cả một loại với các trường bất biến vẫn có các bất biến, ví dụ: các setters hình chữ nhật bất biến mong muốn các kích thước được sửa đổi độc lập, nhưng các setters Square bất biến vi phạm kỳ vọng này.
class Rectangle( val width : Int, val height : Int )
{
def setWidth( w : Int ) = new Rectangle(w, height)
def setHeight( h : Int ) = new Rectangle(width, h)
}
class Square( val side : Int ) extends Rectangle(side, side)
{
override def setWidth( s : Int ) = new Square(s)
override def setHeight( s : Int ) = new Square(s)
}
LSP yêu cầu mỗi phương thức của kiểu con S
phải có (các) tham số đầu vào chống chỉ định và đầu ra covariant.
Contravariant có nghĩa là phương sai trái với hướng của thừa kế, tức là kiểu Si
, của từng tham số đầu vào của từng phương thức của kiểu con S
, phải giống hoặc siêu kiểu Ti
của tham số đầu vào tương ứng của phương thức tương ứng của siêu kiểu T
.
Hiệp phương sai có nghĩa là phương sai theo cùng một hướng của thừa kế, tức là kiểu So
, đầu ra của mỗi phương thức của kiểu con S
, phải giống hoặc một kiểu con của kiểu To
đầu ra tương ứng của phương thức siêu kiểu tương ứng T
.
Điều này là do nếu người gọi nghĩ rằng nó có một kiểu T
, nghĩ rằng nó đang gọi một phương thức T
, thì nó cung cấp (các) đối số của kiểu Ti
và gán đầu ra cho kiểu đó To
. Khi nó thực sự gọi phương thức tương ứng S
, thì mỗi Ti
đối số đầu vào được gán cho một Si
tham số đầu vào và So
đầu ra được gán cho kiểu To
. Do đó, nếu Si
không phải là conttvariant wrt Ti
, thì một phân nhóm Xi
conwhwhich sẽ không phải là một kiểu con của Si
nhómcocould được gán cho Ti
.
Ngoài ra, đối với các ngôn ngữ (ví dụ Scala hoặc Ceylon) có chú thích phương sai vị trí định nghĩa trên các tham số đa hình loại (ví dụ: tổng quát), đồng hướng hoặc đối lập của chú thích phương sai cho từng tham số loại T
phải ngược hoặc cùng hướng tương ứng với mọi tham số đầu vào hoặc đầu ra (của mọi phương thức T
) có loại tham số loại.
Ngoài ra, đối với mỗi tham số đầu vào hoặc đầu ra có loại chức năng, hướng phương sai cần thiết được đảo ngược. Quy tắc này được áp dụng đệ quy.
Subtyping là thích hợp trong đó các bất biến có thể được liệt kê.
Có nhiều nghiên cứu đang diễn ra về cách mô hình hóa các bất biến, để chúng được thực thi bởi trình biên dịch.
Typestate (xem trang 3) tuyên bố và thực thi các bất biến trạng thái trực giao để gõ. Ngoài ra, bất biến có thể được thực thi bằng cách chuyển đổi các xác nhận thành các loại . Ví dụ: để xác nhận rằng một tệp đang mở trước khi đóng nó, thì File.open () có thể trả về loại OpenFile, chứa phương thức close () không có trong Tệp. Một API tic-tac-toe có thể là một ví dụ về sử dụng gõ để thực thi bất biến tại thời gian biên dịch. Hệ thống loại thậm chí có thể là Turing-Complete, ví dụ Scala . Các ngôn ngữ và định lý được gõ phụ thuộc chính thức hóa các mô hình gõ bậc cao hơn.
Do nhu cầu về ngữ nghĩa để trừu tượng hóa mở rộng , tôi hy vọng rằng việc sử dụng gõ để mô hình bất biến, tức là ngữ nghĩa biểu thị bậc cao thống nhất, vượt trội hơn so với typestate. "Mở rộng" có nghĩa là thành phần không giới hạn, được cho phép của sự phát triển mô đun, không phối hợp. Bởi vì dường như đối với tôi, đó là phản đề của sự hợp nhất và do đó mức độ tự do, để có hai mô hình phụ thuộc lẫn nhau (ví dụ như kiểu và Kiểu chữ) để thể hiện ngữ nghĩa được chia sẻ, không thể thống nhất với nhau về thành phần mở rộng . Ví dụ, phần mở rộng giống như Bài toán biểu thức đã được thống nhất trong phân nhóm, nạp chồng hàm và các miền gõ tham số.
Quan điểm lý thuyết của tôi là để kiến thức tồn tại (xem phần Tập trung hóa là mù và không phù hợp), sẽ không bao giờ có một mô hình chung có thể thực thi 100% tất cả các bất biến có thể có trong ngôn ngữ máy tính hoàn chỉnh Turing. Để kiến thức tồn tại, nhiều khả năng bất ngờ tồn tại, tức là rối loạn và entropy phải luôn luôn tăng lên. Đây là lực entropic. Để chứng minh tất cả các tính toán có thể có của một phần mở rộng tiềm năng, là tính toán một ưu tiên tất cả các phần mở rộng có thể.
Đây là lý do tại sao Định lý dừng tồn tại, tức là không thể xác định được liệu mọi chương trình có thể có trong ngôn ngữ lập trình hoàn chỉnh Turing có chấm dứt hay không. Có thể chứng minh rằng một số chương trình cụ thể chấm dứt (một trong đó tất cả các khả năng đã được xác định và tính toán). Nhưng không thể chứng minh rằng tất cả các phần mở rộng có thể có của chương trình đó sẽ chấm dứt, trừ khi các khả năng mở rộng của chương trình đó không hoàn thành Turing (ví dụ: thông qua việc gõ phụ thuộc). Vì yêu cầu cơ bản cho tính đầy đủ của Turing là đệ quy không giới hạn , nên trực giác hiểu được các định lý không hoàn chỉnh của Gôdel và nghịch lý của Russell áp dụng cho việc mở rộng.
Việc giải thích các định lý này kết hợp chúng theo cách hiểu khái niệm khái quát về lực entropic:
Tôi thấy hình chữ nhật và hình vuông trong mỗi câu trả lời và cách vi phạm LSP.
Tôi muốn chỉ ra cách LSP có thể được tuân thủ với một ví dụ trong thế giới thực:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return $result;
}
}
Thiết kế này phù hợp với LSP vì hành vi vẫn không thay đổi bất kể việc triển khai chúng tôi chọn sử dụng.
Và có, bạn có thể vi phạm LSP trong cấu hình này khi thực hiện một thay đổi đơn giản như vậy:
<?php
interface Database
{
public function selectQuery(string $sql): array;
}
class SQLiteDatabase implements Database
{
public function selectQuery(string $sql): array
{
// sqlite specific code
return $result;
}
}
class MySQLDatabase implements Database
{
public function selectQuery(string $sql): array
{
// mysql specific code
return ['result' => $result]; // This violates LSP !
}
}
Bây giờ các kiểu con không thể được sử dụng theo cùng một cách vì chúng không tạo ra kết quả tương tự nữa.
Database::selectQuery
để chỉ hỗ trợ tập hợp con SQL được hỗ trợ bởi tất cả các công cụ DB. Điều đó hầu như không thực tế ... Điều đó nói rằng, ví dụ vẫn dễ nắm bắt hơn hầu hết những người khác được sử dụng ở đây.
Có một danh sách kiểm tra để xác định xem bạn có vi phạm Liskov hay không.
Danh mục:
Ràng buộc lịch sử : Khi ghi đè một phương thức, bạn không được phép sửa đổi một thuộc tính không thể sửa đổi trong lớp cơ sở. Hãy xem các mã này và bạn có thể thấy Tên được xác định là không thể sửa đổi (bộ riêng) nhưng SubType giới thiệu phương thức mới cho phép sửa đổi nó (thông qua sự phản chiếu):
public class SuperType
{
public string Name { get; private set; }
public SuperType(string name, int age)
{
Name = name;
Age = age;
}
}
public class SubType : SuperType
{
public void ChangeName(string newName)
{
var propertyType = base.GetType().GetProperty("Name").SetValue(this, newName);
}
}
Có 2 mục khác: Chống chỉ định của các đối số phương thức và Hiệp phương sai của các kiểu trả về . Nhưng điều này là không thể đối với C # (Tôi là nhà phát triển C #) vì vậy tôi không quan tâm đến họ.
Tài liệu tham khảo:
LSP là một quy tắc về hợp đồng của các nhóm: nếu một lớp cơ sở thỏa mãn hợp đồng, thì các lớp dẫn xuất LSP cũng phải thỏa mãn hợp đồng đó.
Trong giả trăn
class Base:
def Foo(self, arg):
# *... do stuff*
class Derived(Base):
def Foo(self, arg):
# *... do stuff*
thỏa mãn LSP nếu mỗi lần bạn gọi Foo trên một đối tượng Derogen, nó sẽ cho kết quả chính xác giống như gọi Foo trên một đối tượng Base, miễn là arg giống nhau.
2 + "2"
). Có lẽ bạn nhầm lẫn "gõ mạnh" với "gõ tĩnh"?
Dài truyện ngắn, chúng ta hãy rời hình chữ nhật hình chữ nhật và hình vuông vuông, ví dụ thực tế khi mở rộng một lớp cha mẹ, bạn phải hoặc là PRESERVE API mẹ chính xác hoặc để mở rộng nó.
Giả sử bạn có Kho lưu trữ vật phẩm cơ sở .
class ItemsRepository
{
/**
* @return int Returns number of deleted rows
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
return $numberOfDeletedRows;
}
}
Và một lớp con mở rộng nó:
class BadlyExtendedItemsRepository extends ItemsRepository
{
/**
* @return void Was suppose to return an INT like parent, but did not, breaks LSP
*/
public function delete()
{
// perform a delete query
$numberOfDeletedRows = 10;
// we broke the behaviour of the parent class
return;
}
}
Sau đó, bạn có thể có một Máy khách làm việc với API Base ItemRep repository và dựa vào nó.
/**
* Class ItemsService is a client for public ItemsRepository "API" (the public delete method).
*
* Technically, I am able to pass into a constructor a sub-class of the ItemsRepository
* but if the sub-class won't abide the base class API, the client will get broken.
*/
class ItemsService
{
/**
* @var ItemsRepository
*/
private $itemsRepository;
/**
* @param ItemsRepository $itemsRepository
*/
public function __construct(ItemsRepository $itemsRepository)
{
$this->itemsRepository = $itemsRepository;
}
/**
* !!! Notice how this is suppose to return an int. My clients expect it based on the
* ItemsRepository API in the constructor !!!
*
* @return int
*/
public function delete()
{
return $this->itemsRepository->delete();
}
}
Các LSP được chia khi thay thế cha mẹ lớp học với một phụ phá vỡ lớp hợp đồng của API .
class ItemsController
{
/**
* Valid delete action when using the base class.
*/
public function validDeleteAction()
{
$itemsService = new ItemsService(new ItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is an INT :)
}
/**
* Invalid delete action when using a subclass.
*/
public function brokenDeleteAction()
{
$itemsService = new ItemsService(new BadlyExtendedItemsRepository());
$numberOfDeletedItems = $itemsService->delete();
// $numberOfDeletedItems is a NULL :(
}
}
Bạn có thể tìm hiểu thêm về cách viết phần mềm có thể bảo trì trong khóa học của tôi: https://www.udemy.com/enterprise-php/
Các hàm sử dụng các con trỏ hoặc tham chiếu đến các lớp cơ sở phải có thể sử dụng các đối tượng của các lớp dẫn xuất mà không biết nó.
Khi tôi lần đầu tiên đọc về LSP, tôi đã cho rằng điều này có nghĩa rất nghiêm ngặt, về cơ bản là đánh đồng nó với việc thực hiện giao diện và truyền kiểu an toàn. Điều đó có nghĩa là LSP được đảm bảo hoặc không được đảm bảo bởi chính ngôn ngữ. Ví dụ, theo nghĩa chặt chẽ này, ThreeDBoard chắc chắn có thể thay thế cho Board, theo như trình biên dịch có liên quan.
Sau khi đọc thêm về khái niệm mặc dù tôi thấy rằng LSP thường được hiểu rộng hơn thế.
Nói tóm lại, mã máy khách có nghĩa là "biết" rằng đối tượng đằng sau con trỏ là loại dẫn xuất chứ không phải loại con trỏ không bị hạn chế đối với loại an toàn. Tuân thủ LSP cũng có thể kiểm tra được thông qua việc thăm dò hành vi thực tế của các đối tượng. Đó là, kiểm tra tác động của các đối số trạng thái và phương thức của đối tượng đối với kết quả của các cuộc gọi phương thức hoặc các loại ngoại lệ được ném ra từ đối tượng.
Quay trở lại ví dụ một lần nữa, về mặt lý thuyết, các phương thức Board có thể được thực hiện để hoạt động tốt trên ThreeDBoard. Tuy nhiên, trong thực tế, sẽ rất khó để ngăn chặn sự khác biệt trong hành vi mà khách hàng có thể không xử lý đúng cách, mà không làm hỏng chức năng mà ThreeDBoard dự định thêm vào.
Với kiến thức này, việc đánh giá tuân thủ LSP có thể là một công cụ tuyệt vời để xác định khi nào thành phần là cơ chế phù hợp hơn để mở rộng chức năng hiện có, thay vì kế thừa.
Tôi đoán mọi người đều bảo vệ LSP là gì về mặt kỹ thuật: Về cơ bản, bạn muốn có thể trừu tượng hóa khỏi các chi tiết phụ và sử dụng siêu kiểu một cách an toàn.
Vì vậy, Liskov có 3 quy tắc cơ bản:
Quy tắc chữ ký: Cần có một triển khai hợp lệ cho mọi hoạt động của siêu kiểu trong kiểu con theo cú pháp. Một cái gì đó một trình biên dịch sẽ có thể kiểm tra cho bạn. Có một quy tắc nhỏ về việc ném ít ngoại lệ hơn và ít nhất có thể truy cập được như các phương pháp siêu kiểu.
Phương pháp quy tắc: Việc thực hiện các hoạt động đó là đúng đắn về mặt ngữ nghĩa.
Quy tắc thuộc tính: Điều này vượt xa các cuộc gọi chức năng cá nhân.
Tất cả các thuộc tính này cần được bảo tồn và chức năng phụ phụ không nên vi phạm các thuộc tính siêu kiểu.
Nếu ba điều này được quan tâm, bạn đã trừu tượng hóa khỏi những thứ cơ bản và bạn đang viết mã ghép lỏng lẻo.
Nguồn: Phát triển chương trình tại Java - Barbara Liskov
Một ví dụ quan trọng về việc sử dụng LSP là trong thử nghiệm phần mềm .
Nếu tôi có một lớp A là lớp con tuân thủ LSP của B, thì tôi có thể sử dụng lại bộ kiểm tra của B để kiểm tra A.
Để kiểm tra đầy đủ lớp con A, có lẽ tôi cần thêm một vài trường hợp thử nghiệm nữa, nhưng ở mức tối thiểu tôi có thể sử dụng lại tất cả các trường hợp thử nghiệm của siêu lớp B.
Một cách để nhận ra điều này là bằng cách xây dựng cái mà McGregor gọi là "Hệ thống phân cấp song song để thử nghiệm": ATest
Lớp của tôi sẽ kế thừa từ đó BTest
. Một số hình thức tiêm sau đó là cần thiết để đảm bảo trường hợp thử nghiệm hoạt động với các đối tượng loại A thay vì loại B (một mẫu phương thức mẫu đơn giản sẽ làm).
Lưu ý rằng việc sử dụng lại bộ siêu kiểm thử cho tất cả các triển khai lớp con trên thực tế là một cách để kiểm tra các triển khai của lớp con này có tuân thủ LSP không. Do đó, người ta cũng có thể lập luận rằng người ta nên chạy bộ kiểm tra siêu lớp trong ngữ cảnh của bất kỳ lớp con nào.
Xem thêm câu trả lời cho câu hỏi Stackoverflow " Tôi có thể triển khai một loạt các thử nghiệm có thể sử dụng lại để kiểm tra việc triển khai giao diện không? "
Hãy minh họa bằng Java:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
class Car extends TransportationDevice
{
@Override
void startEngine() { ... }
}
Không có vấn đề ở đây, phải không? Một chiếc xe chắc chắn là một thiết bị vận chuyển, và ở đây chúng ta có thể thấy rằng nó ghi đè lên phương thức startEngine () của siêu lớp của nó.
Hãy thêm một thiết bị vận chuyển khác:
class Bicycle extends TransportationDevice
{
@Override
void startEngine() /*problem!*/
}
Mọi thứ không diễn ra như dự định bây giờ! Đúng, xe đạp là một thiết bị vận chuyển, tuy nhiên, nó không có động cơ và do đó, phương thức startEngine () không thể được thực hiện.
Đây là những loại vấn đề vi phạm Nguyên tắc thay thế Liskov dẫn đến và chúng thường có thể được nhận ra bằng một phương pháp không có gì, hoặc thậm chí không thể thực hiện được.
Giải pháp cho những vấn đề này là một hệ thống phân cấp thừa kế chính xác và trong trường hợp của chúng tôi, chúng tôi sẽ giải quyết vấn đề bằng cách phân biệt các lớp thiết bị vận chuyển có và không có động cơ. Mặc dù xe đạp là một thiết bị vận chuyển, nó không có động cơ. Trong ví dụ này định nghĩa của chúng tôi về thiết bị vận chuyển là sai. Nó không nên có một động cơ.
Chúng ta có thể cấu trúc lại lớp TransportDevice của mình như sau:
class TrasportationDevice
{
String name;
String getName() { ... }
void setName(String n) { ... }
double speed;
double getSpeed() { ... }
void setSpeed(double d) { ... }
}
Bây giờ chúng tôi có thể mở rộng TransportDevice cho các thiết bị không có động cơ.
class DevicesWithoutEngines extends TransportationDevice
{
void startMoving() { ... }
}
Và mở rộng TransportDevice cho các thiết bị cơ giới. Ở đây là thích hợp hơn để thêm đối tượng Engine.
class DevicesWithEngines extends TransportationDevice
{
Engine engine;
Engine getEngine() { ... }
void setEngine(Engine e) { ... }
void startEngine() { ... }
}
Do đó, lớp Xe của chúng tôi trở nên chuyên biệt hơn, đồng thời tuân thủ Nguyên tắc thay thế Liskov.
class Car extends DevicesWithEngines
{
@Override
void startEngine() { ... }
}
Và lớp Xe đạp của chúng tôi cũng tuân thủ Nguyên tắc Thay thế Liskov.
class Bicycle extends DevicesWithoutEngines
{
@Override
void startMoving() { ... }
}
Công thức này của LSP quá mạnh:
Nếu với mỗi đối tượng o1 loại S có một đối tượng o2 thuộc loại T sao cho tất cả các chương trình được định nghĩa theo T, thì hành vi của P không thay đổi khi o1 được thay thế cho o2, thì S là một kiểu con của T.
Về cơ bản, điều đó có nghĩa là S là một người khác, hoàn toàn gói gọn việc thực hiện chính xác điều tương tự như T. Và tôi có thể táo bạo và quyết định rằng hiệu suất là một phần trong hành vi của P ...
Vì vậy, về cơ bản, bất kỳ việc sử dụng ràng buộc muộn nào đều vi phạm LSP. Đó là toàn bộ quan điểm của OO để có được một hành vi khác khi chúng ta thay thế một đối tượng thuộc loại này sang loại khác!
Công thức được trích dẫn bởi wikipedia là tốt hơn vì thuộc tính phụ thuộc vào ngữ cảnh và không nhất thiết bao gồm toàn bộ hành vi của chương trình.
Trong một câu rất đơn giản, chúng ta có thể nói:
Lớp con không được vi phạm các đặc điểm của lớp cơ sở. Nó phải có khả năng với nó. Chúng ta có thể nói nó giống như phân nhóm.
Nguyên tắc thay thế của Liskov (LSP)
Tất cả thời gian chúng tôi thiết kế một mô-đun chương trình và chúng tôi tạo ra một số phân cấp lớp. Sau đó, chúng tôi mở rộng một số lớp tạo ra một số lớp dẫn xuất.
Chúng ta phải đảm bảo rằng các lớp dẫn xuất mới chỉ mở rộng mà không thay thế chức năng của các lớp cũ. Mặt khác, các lớp mới có thể tạo ra các hiệu ứng không mong muốn khi chúng được sử dụng trong các mô-đun chương trình hiện có.
Nguyên tắc thay thế của Liskov nói rằng nếu một mô-đun chương trình đang sử dụng lớp Cơ sở, thì tham chiếu đến lớp Cơ sở có thể được thay thế bằng lớp Derogen mà không ảnh hưởng đến chức năng của mô-đun chương trình.
Thí dụ:
Dưới đây là ví dụ kinh điển mà Nguyên tắc thay thế của Liskov bị vi phạm. Trong ví dụ này, 2 lớp được sử dụng: Hình chữ nhật và Hình vuông. Giả sử rằng đối tượng Hình chữ nhật được sử dụng ở đâu đó trong ứng dụng. Chúng tôi mở rộng ứng dụng và thêm lớp Square. Lớp hình vuông được trả về bởi một mẫu nhà máy, dựa trên một số điều kiện và chúng tôi không biết chính xác loại đối tượng nào sẽ được trả về. Nhưng chúng tôi biết đó là một hình chữ nhật. Chúng ta có được đối tượng hình chữ nhật, đặt chiều rộng thành 5 và chiều cao thành 10 và lấy diện tích. Đối với hình chữ nhật có chiều rộng 5 và chiều cao 10, diện tích phải là 50. Thay vào đó, kết quả sẽ là 100
// Violation of Likov's Substitution Principle
class Rectangle {
protected int m_width;
protected int m_height;
public void setWidth(int width) {
m_width = width;
}
public void setHeight(int height) {
m_height = height;
}
public int getWidth() {
return m_width;
}
public int getHeight() {
return m_height;
}
public int getArea() {
return m_width * m_height;
}
}
class Square extends Rectangle {
public void setWidth(int width) {
m_width = width;
m_height = width;
}
public void setHeight(int height) {
m_width = height;
m_height = height;
}
}
class LspTest {
private static Rectangle getNewRectangle() {
// it can be an object returned by some factory ...
return new Square();
}
public static void main(String args[]) {
Rectangle r = LspTest.getNewRectangle();
r.setWidth(5);
r.setHeight(10);
// user knows that r it's a rectangle.
// It assumes that he's able to set the width and height as for the base
// class
System.out.println(r.getArea());
// now he's surprised to see that the area is 100 instead of 50.
}
}
Phần kết luận:
Nguyên tắc này chỉ là một phần mở rộng của Nguyên tắc Đóng Đóng và điều đó có nghĩa là chúng ta phải đảm bảo rằng các lớp dẫn xuất mới đang mở rộng các lớp cơ sở mà không thay đổi hành vi của chúng.
Xem thêm: Nguyên tắc đóng
Một số khái niệm tương tự cho cấu trúc tốt hơn: Quy ước về cấu hình
Một số phụ lục:
Tôi tự hỏi tại sao không ai viết về Bất biến, điều kiện tiên quyết và điều kiện đăng bài của lớp cơ sở phải được tuân theo bởi các lớp dẫn xuất. Để lớp D có nguồn gốc hoàn toàn được duy trì bởi lớp B, lớp D phải tuân theo một số điều kiện:
Vì vậy, dẫn xuất phải nhận thức được ba điều kiện trên do lớp cơ sở áp đặt. Do đó, các quy tắc của phân nhóm được quyết định trước. Điều đó có nghĩa là, mối quan hệ 'IS A' chỉ được tuân theo khi các quy tắc nhất định được tuân theo bởi tiểu loại. Các quy tắc này, dưới dạng bất biến, tiền đề và hậu điều kiện, nên được quyết định bởi một " hợp đồng thiết kế " chính thức .
Thảo luận thêm về điều này có sẵn tại blog của tôi: Nguyên tắc thay thế Liskov
Nói một cách đơn giản, LSP nói rằng các đối tượng của cùng một siêu lớp sẽ có thể được hoán đổi với nhau mà không phá vỡ bất cứ thứ gì.
Ví dụ, nếu chúng ta có một Cat
và một Dog
lớp xuất phát từ một Animal
lớp, bất kỳ hàm nào sử dụng lớp Animal sẽ có thể sử dụng Cat
hoặc Dog
hoạt động bình thường.
Việc triển khai ThreeDBoard dưới dạng một mảng của Board có hữu ích không?
Có lẽ bạn có thể muốn coi các lát ThreeDBoard trong các mặt phẳng khác nhau như một Bảng. Trong trường hợp đó, bạn có thể muốn trừu tượng hóa một giao diện (hoặc lớp trừu tượng) cho Board để cho phép thực hiện nhiều lần.
Về giao diện bên ngoài, bạn có thể muốn tạo ra giao diện Board cho cả TwoDBoard và ThreeDBoard (mặc dù không có phương thức nào ở trên phù hợp).
Hình vuông là hình chữ nhật trong đó chiều rộng bằng chiều cao. Nếu hình vuông đặt hai kích thước khác nhau cho chiều rộng và chiều cao thì nó vi phạm bất biến hình vuông. Điều này được thực hiện xung quanh bằng cách giới thiệu các tác dụng phụ. Nhưng nếu hình chữ nhật có setSize (chiều cao, chiều rộng) với điều kiện tiên quyết 0 <height và 0 <width. Phương thức kiểu con dẫn xuất yêu cầu chiều cao == width; một điều kiện tiên quyết mạnh mẽ hơn (và điều đó vi phạm lsp). Điều này cho thấy rằng mặc dù hình vuông là một hình chữ nhật nhưng nó không phải là một kiểu con hợp lệ vì điều kiện tiên quyết được tăng cường. Các công việc xung quanh (nói chung là một điều xấu) gây ra tác dụng phụ và điều này làm suy yếu điều kiện bài (vi phạm lsp). setWidth trên cơ sở có điều kiện bài 0 <width. Nguồn gốc làm suy yếu nó với chiều cao == chiều rộng.
Do đó, một hình vuông có thể thay đổi kích thước không phải là một hình chữ nhật có thể thay đổi kích thước.
Nguyên tắc này được Barbara Liskov đưa ra vào năm 1987 và mở rộng Nguyên tắc Đóng mở bằng cách tập trung vào hành vi của một siêu lớp và các kiểu con của nó.
Tầm quan trọng của nó trở nên rõ ràng khi chúng ta xem xét hậu quả của việc vi phạm nó. Hãy xem xét một ứng dụng sử dụng lớp sau.
public class Rectangle
{
private double width;
private double height;
public double Width
{
get
{
return width;
}
set
{
width = value;
}
}
public double Height
{
get
{
return height;
}
set
{
height = value;
}
}
}
Hãy tưởng tượng rằng một ngày nào đó, khách hàng yêu cầu khả năng thao tác các hình vuông ngoài hình chữ nhật. Vì hình vuông là hình chữ nhật, nên lớp hình vuông nên được lấy từ lớp Hình chữ nhật.
public class Square : Rectangle
{
}
Tuy nhiên, bằng cách đó chúng ta sẽ gặp hai vấn đề:
Một hình vuông không cần cả hai biến chiều cao và chiều rộng được kế thừa từ hình chữ nhật và điều này có thể tạo ra sự lãng phí đáng kể trong bộ nhớ nếu chúng ta phải tạo ra hàng trăm ngàn đối tượng hình vuông. Các thuộc tính setter chiều rộng và chiều cao được kế thừa từ hình chữ nhật là không phù hợp cho hình vuông vì chiều rộng và chiều cao của hình vuông là giống hệt nhau. Để đặt cả chiều cao và chiều rộng cho cùng một giá trị, chúng ta có thể tạo hai thuộc tính mới như sau:
public class Square : Rectangle
{
public double SetWidth
{
set
{
base.Width = value;
base.Height = value;
}
}
public double SetHeight
{
set
{
base.Height = value;
base.Width = value;
}
}
}
Bây giờ, khi ai đó sẽ đặt chiều rộng của một đối tượng hình vuông, chiều cao của nó sẽ thay đổi tương ứng và ngược lại.
Square s = new Square();
s.SetWidth(1); // Sets width and height to 1.
s.SetHeight(2); // sets width and height to 2.
Hãy tiến về phía trước và xem xét chức năng khác này:
public void A(Rectangle r)
{
r.SetWidth(32); // calls Rectangle.SetWidth
}
Nếu chúng ta chuyển một tham chiếu đến một đối tượng vuông vào hàm này, chúng ta sẽ vi phạm LSP vì hàm này không hoạt động đối với các dẫn xuất của các đối số của nó. Các thuộc tính chiều rộng và chiều cao không đa hình vì chúng không được khai báo ảo trong hình chữ nhật (đối tượng hình vuông sẽ bị hỏng vì chiều cao sẽ không bị thay đổi).
Tuy nhiên, bằng cách tuyên bố các thuộc tính setter là ảo, chúng ta sẽ phải đối mặt với một vi phạm khác, OCP. Trong thực tế, việc tạo ra một hình vuông lớp dẫn xuất đang gây ra những thay đổi cho hình chữ nhật của lớp cơ sở.
Giải thích rõ ràng nhất cho LSP mà tôi tìm thấy cho đến nay là "Nguyên tắc thay thế Liskov nói rằng đối tượng của lớp dẫn xuất có thể thay thế một đối tượng của lớp cơ sở mà không gây ra bất kỳ lỗi nào trong hệ thống hoặc sửa đổi hành vi của lớp cơ sở "Từ đây . Bài viết đưa ra ví dụ mã cho việc vi phạm LSP và sửa nó.
Giả sử chúng ta sử dụng một hình chữ nhật trong mã của mình
r = new Rectangle();
// ...
r.setDimensions(1,2);
r.fill(colors.red());
canvas.draw(r);
Trong lớp hình học của chúng tôi, chúng tôi đã học được rằng hình vuông là một loại hình chữ nhật đặc biệt vì chiều rộng của nó có cùng chiều dài với chiều cao của nó. Hãy tạo một Square
lớp học dựa trên thông tin này:
class Square extends Rectangle {
setDimensions(width, height){
assert(width == height);
super.setDimensions(width, height);
}
}
Nếu chúng ta thay thế Rectangle
bằng Square
mã đầu tiên, thì nó sẽ bị hỏng:
r = new Square();
// ...
r.setDimensions(1,2); // assertion width == height failed
r.fill(colors.red());
canvas.draw(r);
Điều này là do Square
điều kiện tiên quyết mới mà chúng tôi không có trong Rectangle
lớp : width == height
. Theo LSP, các Rectangle
thể hiện nên được thay thế bằng các Rectangle
thể hiện của lớp con. Điều này là do các trường hợp này vượt qua kiểm tra loại cho các Rectangle
trường hợp và do đó chúng sẽ gây ra lỗi không mong muốn trong mã của bạn.
Đây là một ví dụ cho "điều kiện tiên quyết không thể được tăng cường trong một phần phụ" trong bài viết wiki . Vì vậy, để tổng hợp, vi phạm LSP có thể sẽ gây ra lỗi trong mã của bạn tại một số điểm.
LSP nói rằng '' Các đối tượng nên được thay thế bởi các kiểu con của chúng ''. Mặt khác, nguyên tắc này chỉ ra
Các lớp con không bao giờ nên phá vỡ các định nghĩa kiểu của lớp cha.
và ví dụ sau giúp hiểu rõ hơn về LSP.
Không có LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
return; //it isn`t rendered in this case
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Sửa lỗi bằng LSP:
public interface CustomerLayout{
public void render();
}
public FreeCustomer implements CustomerLayout {
...
@Override
public void render(){
//code
}
}
public PremiumCustomer implements CustomerLayout{
...
@Override
public void render(){
if(!hasSeenAd)
showAd();//it has a specific behavior based on its requirement
//code
}
}
public void renderView(CustomerLayout layout){
layout.render();
}
Tôi khuyến khích bạn đọc bài viết: Vi phạm nguyên tắc thay thế Liskov (LSP) .
Bạn có thể tìm thấy lời giải thích Nguyên tắc thay thế Liskov là gì, manh mối chung giúp bạn đoán xem bạn đã vi phạm hay chưa và một ví dụ về cách tiếp cận sẽ giúp bạn phân cấp lớp của mình an toàn hơn.
NGUYÊN TẮC GIAO DỊCH LISKOV (Từ cuốn sách Mark Seemann) nói rằng chúng ta sẽ có thể thay thế một triển khai giao diện này bằng một giao diện khác mà không phá vỡ ứng dụng khách hoặc triển khai. Đây là nguyên tắc cho phép giải quyết các yêu cầu xảy ra trong tương lai, ngay cả khi chúng ta có thể ' t thấy trước chúng ngày hôm nay.
Nếu chúng ta rút máy tính ra khỏi tường (Thực hiện), cả ổ cắm trên tường (Giao diện) cũng như máy tính (Máy khách) đều bị hỏng (thực tế, nếu đó là máy tính xách tay, nó thậm chí có thể chạy bằng pin trong một khoảng thời gian) . Tuy nhiên, với phần mềm, khách hàng thường mong đợi một dịch vụ có sẵn. Nếu dịch vụ bị xóa, chúng tôi sẽ nhận được NullReferenceException. Để đối phó với loại tình huống này, chúng ta có thể tạo ra một triển khai giao diện không có gì. Đây là một mẫu thiết kế được gọi là Null Object, [4] và nó tương ứng với việc rút máy tính ra khỏi tường. Bởi vì chúng tôi đang sử dụng khớp nối lỏng lẻo, chúng tôi có thể thay thế một triển khai thực sự bằng một thứ không có gì mà không gây rắc rối.
Nguyên tắc thay thế của Likov nói rằng nếu một mô-đun chương trình đang sử dụng lớp Cơ sở, thì tham chiếu đến lớp Cơ sở có thể được thay thế bằng lớp Derogen mà không ảnh hưởng đến chức năng của mô-đun chương trình.
Ý định - Các loại có nguồn gốc phải hoàn toàn có thể thay thế cho các loại cơ sở của chúng.
Ví dụ - Các kiểu trả về đồng biến thể trong java.
Đây là một đoạn trích từ bài đăng này để làm rõ mọi thứ độc đáo:
[..] để hiểu một số nguyên tắc, điều quan trọng là phải nhận ra khi nào nó bị vi phạm. Đây là những gì tôi sẽ làm bây giờ.
Việc vi phạm nguyên tắc này có ý nghĩa gì? Nó ngụ ý rằng một đối tượng không hoàn thành hợp đồng được áp đặt bởi một sự trừu tượng thể hiện với một giao diện. Nói cách khác, điều đó có nghĩa là bạn đã xác định được sự trừu tượng của mình sai.
Hãy xem xét ví dụ sau:
interface Account
{
/**
* Withdraw $money amount from this account.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
class DefaultAccount implements Account
{
private $balance;
public function withdraw(Money $money)
{
if (!$this->enoughMoney($money)) {
return;
}
$this->balance->subtract($money);
}
}
Đây có phải là vi phạm LSP không? Đúng. Điều này là do hợp đồng của tài khoản cho chúng tôi biết rằng tài khoản sẽ bị rút, nhưng điều này không phải lúc nào cũng đúng. Vì vậy, tôi nên làm gì để khắc phục nó? Tôi chỉ sửa đổi hợp đồng:
interface Account
{
/**
* Withdraw $money amount from this account if its balance is enough.
* Otherwise do nothing.
*
* @param Money $money
* @return mixed
*/
public function withdraw(Money $money);
}
Voilà, bây giờ hợp đồng được thỏa mãn.
Vi phạm tinh vi này thường áp đặt một khách hàng với khả năng cho biết sự khác biệt giữa các đối tượng cụ thể được sử dụng. Ví dụ: được cung cấp hợp đồng của Tài khoản đầu tiên, nó có thể trông như sau:
class Client
{
public function go(Account $account, Money $money)
{
if ($account instanceof DefaultAccount && !$account->hasEnoughMoney($money)) {
return;
}
$account->withdraw($money);
}
}
Và, điều này tự động vi phạm nguyên tắc đóng mở [nghĩa là đối với yêu cầu rút tiền. Bởi vì bạn không bao giờ biết điều gì xảy ra nếu một đối tượng vi phạm hợp đồng không có đủ tiền. Có lẽ nó chỉ trả về không có gì, có lẽ một ngoại lệ sẽ được ném ra. Vì vậy, bạn phải kiểm tra xem nó hasEnoughMoney()
- không phải là một phần của giao diện. Vì vậy, kiểm tra phụ thuộc vào lớp cụ thể bắt buộc này là vi phạm OCP].
Điểm này cũng giải quyết một quan niệm sai lầm mà tôi gặp phải khá thường xuyên về vi phạm LSP. Nó nói rằng nếu hành vi của cha mẹ thay đổi ở một đứa trẻ, thì nó vi phạm LSP. Tuy nhiên, điều đó không xảy ra - miễn là một đứa trẻ không vi phạm hợp đồng của cha mẹ.