Có bao giờ ổn khi vi phạm LSP?


10

Tôi đang theo dõi câu hỏi này , nhưng tôi đang chuyển trọng tâm của mình từ mã sang nguyên tắc.

Từ sự hiểu biết của tôi về nguyên tắc thay thế Liskov (LSP), bất kể phương pháp này trong lớp cơ sở của tôi, họ phải được thực hiện trong lớp con của tôi, và theo này trang, nếu bạn ghi đè một phương pháp trong các lớp cơ sở và nó không làm gì hoặc ném một ngoại lệ, bạn đang vi phạm nguyên tắc.

Bây giờ, vấn đề của tôi có thể được tóm tắt như thế này: tôi có một bản tóm tắt Weapon class, và hai lớp, SwordReloadable. Nếu Reloadablechứa một cụ thể method, được gọi Reload(), tôi sẽ phải downcast để truy cập vào đó method, và, lý tưởng nhất, bạn muốn tránh điều đó.

Sau đó tôi nghĩ đến việc sử dụng Strategy Pattern. Bằng cách này, mỗi vũ khí chỉ nhận thức được các hành động mà nó có khả năng thực hiện, vì vậy, ví dụ, Reloadablevũ khí, rõ ràng có thể tải lại, nhưng Swordkhông thể, và thậm chí không nhận ra Reload class/method. Như tôi đã nói trong bài viết Stack Overflow của mình, tôi không phải xem thường và tôi có thể duy trì một List<Weapon>bộ sưu tập.

Trên một diễn đàn khác , câu trả lời đầu tiên đề nghị cho phép Swordnhận thức được Reload, chỉ cần đừng làm gì cả. Câu trả lời tương tự này được đưa ra trên trang Stack Overflow mà tôi đã liên kết ở trên.

Tôi không hiểu tại sao. Tại sao vi phạm nguyên tắc và cho phép Sword nhận thức Reloadvà để trống? Như tôi đã nói trong bài viết Stack Overflow của tôi, SP, đã giải quyết khá nhiều vấn đề của tôi.

Tại sao nó không phải là một giải pháp khả thi?

public final Weapon{

    private final String name;
    private final int damage;
    private final List<AttackStrategy> validactions;
    private final List<Actions> standardActions;

    private Weapon(String name, int damage, List<AttackStrategy> standardActions, List<Actions> attacks)
    {
        this.name = name;
        this.damage = damage;
        standardActions = new ArrayList<Actions>(standardActions);
        validAttacks = new ArrayList<AttackStrategy>(validActions);
    }

    public void standardAction(String action){} // -- Can call reload or aim here.  

    public int attack(String action){} // - Call any actions that are attacks. 

    public static Weapon Sword(String name, damage, List<AttackStrategy> standardActions, List<Actions> attacks){
        return new Weapon(name, damage,standardActions, attacks) ;
    }

}

Giao diện tấn công và thực hiện:

public interface AttackStrategy{
    void attack(Enemy enemy);
}

public class Shoot implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to shoot
    }
}

public class Strike implements AttackStrategy {
    public void attack(Enemy enemy){
        //code to strike
    }
}

2
Bạn có thể làm class Weapon { bool supportsReload(); void reload(); }. Khách hàng sẽ kiểm tra nếu được hỗ trợ trước khi tải lại. reloadđược xác định theo hợp đồng để ném iff !supportsReload(). Điều đó tuân thủ các lớp học LSP iff tuân thủ giao thức tôi vừa phác thảo.
usr

3
Cho dù bạn để reload()trống hoặc standardActionskhông chứa hành động tải lại chỉ là một cơ chế khác. Không có sự khác biệt cơ bản. Bạn có thể làm cả hai. => Giải pháp của bạn khả thi (đó là câu hỏi của bạn) .; Kiếm không cần biết về tải lại nếu Weapon chứa triển khai mặc định trống.
usr

27
Tôi đã viết một loạt các bài báo khám phá nhiều vấn đề với các kỹ thuật khác nhau để giải quyết vấn đề này. Kết luận: đừng cố nắm bắt các quy tắc của trò chơi của bạn trong hệ thống loại ngôn ngữ . Nắm bắt các quy tắc của trò chơi trong các đối tượng đại diện và thực thi các quy tắc ở cấp độ logic của trò chơi, chứ không phải cấp độ của hệ thống loại . Không có lý do để tin rằng bất kỳ loại hệ thống nào bạn đang sử dụng đều đủ tinh vi để thể hiện logic trò chơi của bạn. ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Eric Lippert

2
@EricLippert - Cảm ơn liên kết của bạn. Tôi đã xem blog này rất nhiều lần, nhưng một số điểm khiến tôi không hiểu lắm, nhưng đó không phải là lỗi của bạn. Tôi đang tự học OOP và tình cờ thấy các hiệu trưởng RẮN. Lần đầu tiên tôi bắt gặp blog của bạn, tôi hoàn toàn không hiểu gì về nó, nhưng tôi đã học được thêm một chút và đọc lại blog của bạn, và dần dần bắt đầu hiểu những phần của những gì được nói. Một ngày nào đó, tôi sẽ hoàn toàn hiểu mọi thứ trong chuỗi đó. Tôi hy vọng: D

6
@SR "nếu không có gì hoặc ném ngoại lệ, bạn vi phạm" - Tôi nghĩ bạn đã đọc sai thông điệp từ bài viết đó. Vấn đề không phải là trực tiếp mà setAltitude không làm gì cả, đó là nó đã không thực hiện được hậu quả "con chim sẽ được vẽ ở độ cao đã đặt". Nếu bạn xác định hậu điều kiện của "tải lại" là "nếu có đủ đạn, vũ khí có thể tấn công lại", thì không làm gì là một triển khai hoàn toàn hợp lệ cho vũ khí không sử dụng đạn.
Sebastian Redl

Câu trả lời:


16

LSP quan tâm đến phân nhóm và đa hình. Không phải tất cả các mã thực sự sử dụng các tính năng này, trong trường hợp LSP là không liên quan. Hai trường hợp sử dụng phổ biến của các cấu trúc ngôn ngữ kế thừa không phải là trường hợp của phân nhóm là:

  • Kế thừa được sử dụng để kế thừa việc thực hiện một lớp cơ sở, nhưng không phải giao diện của nó. Trong gần như tất cả các trường hợp thành phần nên được ưu tiên. Các ngôn ngữ như Java không thể tách rời sự kế thừa của việc triển khai và giao diện, nhưng ví dụ C ++ có privatesự kế thừa.

  • Kế thừa được sử dụng để mô hình một loại tổng / liên kết, ví dụ: a BaseCaseAhoặc CaseB. Loại cơ sở không khai báo bất kỳ giao diện liên quan. Để sử dụng các thể hiện của nó, bạn phải đúc chúng thành loại bê tông chính xác. Việc đúc có thể được thực hiện một cách an toàn và không phải là vấn đề. Thật không may, nhiều ngôn ngữ OOP không thể giới hạn các kiểu con của lớp cơ sở chỉ ở các kiểu con dự định. Nếu mã bên ngoài có thể tạo a CaseC, thì mã giả sử rằng Basechỉ có thể là a CaseAhoặc CaseBkhông chính xác. Scala có thể làm điều này một cách an toàn với case classkhái niệm của nó . Trong Java, điều này có thể được mô hình hóa khi Baselà một lớp trừu tượng với một hàm tạo riêng và các lớp tĩnh lồng nhau sau đó kế thừa từ cơ sở.

Một số khái niệm như hệ thống phân cấp khái niệm của các đối tượng trong thế giới thực ánh xạ rất xấu vào các mô hình hướng đối tượng. Những suy nghĩ như một khẩu súng là một vũ khí, và một thanh kiếm là một vũ khí, do đó tôi sẽ có một Weaponlớp cơ sở mà từ đó Gun, Swordthừa kế là sai lầm: từ thực là - một mối quan hệ không ngụ ý mối quan hệ như vậy trong mô hình của chúng tôi. Một vấn đề liên quan là các đối tượng có thể thuộc nhiều hệ thống phân cấp khái niệm hoặc có thể thay đổi liên kết phân cấp của chúng trong thời gian chạy, mà hầu hết các ngôn ngữ không thể mô hình hóa do tính kế thừa thường là mỗi lớp không theo từng đối tượng và được xác định tại thời gian thiết kế không phải là thời gian chạy.

Khi thiết kế các mô hình OOP, chúng ta không nên nghĩ về hệ thống phân cấp, hoặc cách một lớp học mở rộng một lớp khác. Một lớp cơ sở không phải là nơi để tính ra các phần chung của nhiều lớp. Thay vào đó, hãy suy nghĩ về cách các đối tượng của bạn sẽ được sử dụng, tức là loại hành vi mà người dùng của các đối tượng này cần.

Ở đây, người dùng có thể cần phải attack()có vũ khí và có thể reload()chúng. Nếu chúng ta tạo một hệ thống phân cấp kiểu, thì cả hai phương thức này phải ở loại cơ sở, mặc dù vũ khí không thể tải lại có thể bỏ qua phương thức đó và không làm gì khi được gọi. Vì vậy, lớp cơ sở không chứa các phần chung, nhưng giao diện kết hợp của tất cả các lớp con. Các lớp con không khác nhau về giao diện của chúng, mà chỉ khác ở việc thực hiện giao diện này.

Không cần thiết phải tạo một hệ thống phân cấp. Hai loại GunSwordcó thể hoàn toàn không liên quan. Trong khi đó, một Gunlon fire()reload()một Swordchỉ có thể strike(). Nếu bạn cần quản lý các đối tượng này một cách đa hình, bạn có thể sử dụng Mẫu bộ điều hợp để nắm bắt các khía cạnh liên quan. Trong Java 8, điều này có thể khá thuận tiện với các giao diện chức năng và tham chiếu phương thức / lambdas. Ví dụ: bạn có thể có một Attackchiến lược mà bạn cung cấp myGun::firehoặc () -> mySword.strike().

Cuối cùng, đôi khi có thể tránh được bất kỳ lớp con nào, nhưng mô hình hóa tất cả các đối tượng thông qua một loại duy nhất. Điều này đặc biệt có liên quan trong các trò chơi vì nhiều đối tượng trò chơi không phù hợp với bất kỳ hệ thống phân cấp nào và có thể có nhiều khả năng khác nhau. Vd Hoặc có thể là một thanh kiếm có thể nạp lại bởi vì nó là * ma thuật *. Ai biết câu chuyện yêu cầu gì.

Thay vì cố gắng tìm ra một hệ thống phân cấp lớp cho mớ hỗn độn đó, tốt hơn là nên có một lớp cung cấp các vị trí cho các khả năng khác nhau. Các khe này có thể được thay đổi trong thời gian chạy. Mỗi vị trí sẽ là một chiến lược / gọi lại như OnDamageReceivedhoặc Attack. Với vũ khí của bạn, chúng tôi có thể có MeleeAttack, RangedAttackReloadkhe cắm. Các khe này có thể trống, trong trường hợp đó đối tượng không cung cấp khả năng này. Các vị trí sau đó được gọi là có điều kiện : if (item.attack != null) item.attack.perform().


Sắp xếp giống như SP theo một cách. Tại sao các khe phải trống? Nếu từ điển không chứa hành động, chỉ cần không làm gì cả

@SR Việc một vị trí có trống hay không tồn tại không thực sự quan trọng và phụ thuộc vào cơ chế được sử dụng để triển khai các vị trí này. Tôi đã viết câu trả lời này với các giả định của một ngôn ngữ khá tĩnh trong đó các vị trí là các trường đối tượng và luôn tồn tại (tức là thiết kế lớp bình thường trong Java). Nếu chọn một mô hình động hơn trong đó các vị trí là các mục trong từ điển (như sử dụng HashMap trong Java hoặc đối tượng Python bình thường), thì các vị trí đó không phải tồn tại. Lưu ý rằng các cách tiếp cận năng động hơn từ bỏ rất nhiều loại an toàn, thường không được mong muốn.
amon

Tôi đồng ý rằng các đối tượng trong thế giới thực không mô hình tốt. Nếu tôi hiểu bài viết của bạn, câu nói của bạn tôi có thể sử dụng Mẫu chiến lược không?

2
@SR Có, Mô hình Chiến lược ở một số hình thức có thể là một cách tiếp cận hợp lý. So sánh với Mẫu đối tượng Loại có liên quan: gameprogrammingpotypes.com/type-object.html
amon

3

Bởi vì có một chiến lược attackkhông đủ cho nhu cầu của bạn. Chắc chắn, nó cho phép bạn trừu tượng hóa những hành động mà vật phẩm có thể làm, nhưng điều gì xảy ra khi bạn cần biết phạm vi của vũ khí? Hoặc khả năng đạn? Hoặc loại đạn nào? Bạn đang quay trở lại với sự thất vọng để có được điều đó. Và việc có mức độ linh hoạt đó sẽ khiến UI khó thực hiện hơn một chút, vì nó sẽ cần phải có một mẫu chiến lược tương tự để đối phó với tất cả các khả năng.

Tất cả những gì đã nói, tôi không đặc biệt đồng ý với câu trả lời cho các câu hỏi khác của bạn. Được swordthừa hưởng từ weaponOO khủng khiếp, ngây thơ, điều này luôn luôn dẫn đến các phương pháp không có op hoặc kiểm tra kiểu rải rác về mã.

Nhưng tận gốc của vấn đề, không có giải pháp nào là sai . Bạn có thể sử dụng cả hai giải pháp để tạo ra một trò chơi hoạt động thú vị để chơi. Mỗi người đi kèm với bộ đánh đổi riêng của họ, giống như bất kỳ giải pháp nào bạn chọn.


Tôi nghĩ rằng điều này là hoàn hảo. Tôi có thể sử dụng SP, nhưng họ đang đánh đổi, chỉ cần nhận thức được chúng. Xem chỉnh sửa của tôi, cho những gì tôi có trong tâm trí.

1
Fwiw: một thanh kiếm có đạn vô hạn: bạn có thể tiếp tục sử dụng nó mà không cần đọc mãi; tải lại không làm gì vì bạn có quyền sử dụng vô hạn để bắt đầu; một loạt các / cận chiến: đó là vũ khí cận chiến. Không thể nghĩ về tất cả các chỉ số / hành động theo cách phù hợp với cả cận chiến và tầm xa. Tuy nhiên, khi tôi già đi, tôi sử dụng ngày càng ít sự kế thừa để ủng hộ các giao diện, cạnh tranh và bất kể tên nào là để sử dụng một Weaponlớp duy nhất với một ví dụ về kiếm và súng.
CAD97

Fwiw trong Destiny 2 kiếm sử dụng đạn vì một số lý do!

@ CAD97 - Đây là kiểu suy nghĩ mà tôi đã thấy liên quan đến vấn đề này. Có kiếm với đạn vô hạn, nên không cần nạp lại. Điều này chỉ đẩy vấn đề xung quanh hoặc che giấu nó. Nếu tôi giới thiệu một quả lựu đạn thì sao? Lựu đạn không có đạn hoặc bắn, và không nên biết về các phương pháp đó.

1
Tôi với CAD97 về điều này. Và sẽ tạo ra một WeaponBuilderthứ có thể chế tạo kiếm và súng bằng cách soạn thảo vũ khí chiến lược.
Chris Wohlert

3

Tất nhiên đó là một giải pháp khả thi; đó chỉ là một ý tưởng rất tồi

Vấn đề không phải là nếu bạn có trường hợp duy nhất này khi bạn đặt tải lại vào lớp cơ sở. Vấn đề là bạn cũng cần đặt "swing", "bắn" "parry", "gõ", "đánh bóng", "tháo rời", "làm sắc nét" và "thay thế móng tay của đầu nhọn của câu lạc bộ" phương thức trên lớp cơ sở của bạn.

Quan điểm của LSP là các thuật toán cấp cao nhất của bạn cần phải hoạt động và có ý nghĩa. Vì vậy, nếu tôi có mã như thế này:

if (isEquipped(weapon)) {
   reload();
}

Bây giờ nếu điều đó ném ra một ngoại lệ không được triển khai và làm cho chương trình của bạn bị sập thì đó là một ý tưởng rất tồi.

Nếu mã của bạn trông như thế này,

if (canReload(weapon)) {
   reload();
}
else if (canSharpen(weapon)) {
  sharpen();
}
else if (canPollish(weapon)) {
  polish();
}

sau đó mã của bạn có thể trở nên lộn xộn với các thuộc tính rất cụ thể không liên quan gì đến ý tưởng 'vũ khí' trừu tượng.

Tuy nhiên, nếu bạn đang thực hiện một game bắn súng góc nhìn người thứ nhất và tất cả vũ khí của bạn có thể bắn / tải lại ngoại trừ một con dao đó (trong bối cảnh cụ thể của bạn) thì việc nạp lại con dao của bạn sẽ không có ý nghĩa gì vì đó là ngoại lệ và tỷ lệ cược có lớp cơ sở của bạn lộn xộn với các thuộc tính cụ thể là thấp.

Cập nhật: Đừng cố nghĩ về trường hợp / điều khoản trừu tượng. Ví dụ, có thể mọi vũ khí đều có hành động "chuẩn bị", đó là nạp lại cho súng và vỏ bọc cho kiếm.


Giả sử tôi có một từ điển vũ khí nội bộ chứa các hành động cho vũ khí và khi người dùng chuyển qua "Tải lại", nó sẽ kiểm tra từ điển, ví dụ, WeaponActions.containsKey (hành động) nếu vậy, hãy lấy đối tượng được liên kết với nó và làm nó Thay vì một lớp vũ khí có nhiều câu lệnh if

Xem chỉnh sửa ở trên. Đây là những gì tôi đã nghĩ khi sử dụng SP

0

Rõ ràng là ổn nếu bạn không tạo một lớp con với mục đích thay thế một thể hiện của lớp cơ sở, nhưng nếu bạn tạo một lớp con bằng cách sử dụng lớp cơ sở như một kho lưu trữ chức năng thuận tiện.

Bây giờ dù đó có phải là một ý tưởng tốt hay không thì vẫn còn nhiều tranh cãi, nhưng nếu bạn không bao giờ thay thế lớp con cho lớp cơ sở, thì thực tế là nó không hoạt động cũng không có vấn đề gì. Bạn có thể có vấn đề, nhưng LSP không phải là vấn đề trong trường hợp này.


0

LSP là tốt vì nó cho phép mã gọi không phải lo lắng về cách thức hoạt động của lớp.

ví dụ. Tôi có thể gọi Weapon.Attack () trên tất cả các Vũ khí được gắn trên BattleMech của tôi và không lo lắng rằng một số trong số chúng có thể ném ngoại lệ và làm hỏng trò chơi của tôi.

Bây giờ trong trường hợp của bạn, bạn muốn mở rộng loại cơ sở của mình với chức năng mới. Attack () không phải là vấn đề, bởi vì lớp Gun có thể theo dõi đạn của nó và ngừng bắn khi hết. Nhưng Tải lại () là một cái gì đó mới và không phải là một phần của vũ khí.

Giải pháp dễ dàng là hạ thấp, tôi không nghĩ bạn cần lo lắng về hiệu suất quá mức, bạn sẽ không làm điều đó mỗi khung hình.

Ngoài ra, bạn có thể đánh giá lại kiến ​​trúc của mình và xem xét rằng trong bản tóm tắt, tất cả Vũ khí đều có thể tải lại được và một số vũ khí không bao giờ cần tải lại.

Sau đó, bạn không mở rộng lớp học cho súng nữa hoặc vi phạm LSP.

Nhưng vấn đề về lâu dài là vì bạn buộc phải nghĩ ra nhiều trường hợp đặc biệt hơn, Gun.SafteyOn (), Sword.Wipe OfferBlood (), v.v. và nếu bạn đặt tất cả chúng vào Weapon, thì bạn có một lớp cơ sở tổng quát siêu phức tạp mà bạn giữ phải thay đổi

chỉnh sửa: tại sao mẫu chiến lược là Xấu (tm)

Không, nhưng hãy xem xét việc thiết lập, hiệu suất và mã tổng thể.

Tôi phải có một số cấu hình ở đâu đó cho tôi biết rằng một khẩu súng có thể tải lại. Khi tôi khởi tạo vũ khí, tôi phải đọc cấu hình đó và tự động thêm tất cả các phương thức, kiểm tra không có tên trùng lặp, v.v.

Khi tôi gọi một phương thức, tôi phải lặp qua danh sách các hành động đó và thực hiện khớp chuỗi để xem nên gọi phương thức nào.

Khi tôi biên dịch mã và gọi Weapon.Do ("atack") thay vì "tấn công", tôi sẽ không gặp lỗi khi biên dịch.

Nó có thể là một giải pháp phù hợp cho một số vấn đề, giả sử bạn có hàng trăm vũ khí với các kết hợp ngẫu nhiên khác nhau, nhưng bạn mất rất nhiều lợi ích của OO và gõ mạnh. Nó không thực sự giúp bạn tiết kiệm bất cứ điều gì qua việc u ám


Tôi nghĩ rằng SP có thể xử lý tất cả những điều đó (xem chỉnh sửa ở trên), súng sẽ có SafteyOn()Swordsẽ có wipeOffBlood(). Mỗi vũ khí không nhận thức được các phương pháp khác (và chúng không nên)

SP là tốt, nhưng nó tương đương với downcasting mà không an toàn loại. Tôi đoán tôi đã trả lời một câu hỏi khác, hãy để tôi cập nhật
Ewan

2
Bản thân mô hình chiến lược không bao hàm việc tra cứu động của một chiến lược trong danh sách hoặc từ điển. Tức là cả hai weapon.do("attack")và loại an toàn weapon.attack.perform()có thể là ví dụ về mẫu chiến lược. Việc tìm kiếm các chiến lược theo tên chỉ cần thiết khi định cấu hình đối tượng từ tệp cấu hình, mặc dù sử dụng sự phản chiếu sẽ an toàn như nhau.
amon

sẽ không hoạt động trong tình huống này vì có hai hành động tấn công và tải lại riêng biệt, mà bạn cần liên kết với một số đầu vào của người dùng
Ewan
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.