Làm thế nào là xác định rằng một phương thức có thể được ghi đè một cam kết mạnh mẽ hơn so với việc xác định rằng một phương thức có thể được gọi?


36

Từ: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: Tôi vẫn nghĩ nó đúng ngay cả sau mười năm. Kế thừa là một cách tuyệt vời để thay đổi hành vi. Nhưng chúng ta biết rằng nó dễ vỡ, bởi vì lớp con có thể dễ dàng đưa ra các giả định về bối cảnh mà phương thức mà nó ghi đè đang được gọi. Có một sự kết hợp chặt chẽ giữa lớp cơ sở và lớp con, vì bối cảnh ngầm mà mã lớp con tôi cắm vào sẽ được gọi. Thành phần có một tài sản đẹp hơn. Khớp nối được giảm bớt chỉ bằng cách có một số thứ nhỏ hơn bạn cắm vào thứ gì đó lớn hơn và đối tượng lớn hơn chỉ gọi lại đối tượng nhỏ hơn. Từ quan điểm API xác định rằng một phương thức có thể được ghi đè là một cam kết mạnh mẽ hơn so với việc xác định rằng một phương thức có thể được gọi.

Tôi không hiểu ý anh ta. Bất cứ ai có thể xin vui lòng giải thích nó?

Câu trả lời:


63

Một cam kết là một cái gì đó làm giảm các lựa chọn trong tương lai của bạn. Xuất bản một phương thức ngụ ý rằng người dùng sẽ gọi nó, do đó bạn không thể xóa phương thức này mà không phá vỡ tính tương thích. Nếu bạn giữ nó private, họ không thể (trực tiếp) gọi nó và một ngày nào đó bạn có thể tái cấu trúc nó mà không gặp vấn đề gì. Do đó, xuất bản một phương pháp là một cam kết mạnh mẽ hơn là không xuất bản nó. Xuất bản một phương pháp overridable là một cam kết thậm chí còn mạnh mẽ hơn. Người dùng của bạn có thể gọi nó họ có thể tạo các lớp mới trong đó phương thức không làm như bạn nghĩ!

Chẳng hạn, nếu bạn xuất bản một phương thức dọn dẹp, bạn có thể đảm bảo rằng các tài nguyên được phân bổ hợp lý miễn là người dùng nhớ gọi phương thức này là điều cuối cùng họ làm. Nhưng nếu phương thức này quá mức, ai đó có thể ghi đè lên nó trong một lớp con và không gọi super. Kết quả là, một người dùng thứ ba có thể sử dụng lớp đó và gây rò rỉ tài nguyên mặc dù cleanup()cuối cùng họ đã gọi một cách nghiêm túc ! Điều này có nghĩa là bạn không còn có thể đảm bảo ngữ nghĩa của mã của mình, đây là một điều rất tồi tệ.

Về cơ bản, bạn không còn có thể dựa vào bất kỳ mã nào đang chạy trong các phương thức ghi đè người dùng, bởi vì một số người trung gian có thể ghi đè lên nó. Điều này có nghĩa là bạn phải thực hiện thói quen dọn dẹp hoàn toàn trong privatecác phương thức, không có sự trợ giúp từ người dùng. Do đó, thường chỉ nên xuất bản finalcác yếu tố trừ khi chúng được dự định rõ ràng để ghi đè bởi người dùng API.


11
Đây có thể là lý lẽ tốt nhất chống lại sự kế thừa mà tôi từng đọc. Trong tất cả các lý do chống lại điều đó mà tôi gặp phải, tôi chưa bao giờ gặp hai đối số này trước đây (khớp nối và phá vỡ chức năng thông qua ghi đè), nhưng cả hai đều là những lý lẽ rất mạnh mẽ chống lại sự kế thừa.
David Arno

5
@DavidArno Tôi không nghĩ đó là một lập luận chống lại sự kế thừa. Tôi nghĩ đó là một lập luận chống lại "làm cho mọi thứ trở nên quá mức theo mặc định". Kế thừa không nguy hiểm bằng chính nó, sử dụng nó mà không cần suy nghĩ là.
Svick

15
Mặc dù điều này nghe có vẻ là một điểm tốt nhưng tôi thực sự không thể thấy "người dùng có thể thêm mã lỗi của mình" là một đối số như thế nào. Kích hoạt tính kế thừa cho phép người dùng thêm chức năng thiếu mà không mất khả năng cập nhật, một biện pháp có thể ngăn ngừa và sửa lỗi. Nếu mã người dùng trên API của bạn bị hỏng thì đó không phải là lỗi API.
năm15 lúc 20h40

4
Bạn có thể dễ dàng biến cuộc tranh luận đó thành: lập trình viên đầu tiên đưa ra lập luận dọn dẹp, nhưng mắc lỗi và không dọn dẹp mọi thứ. Bộ mã hóa thứ hai ghi đè lên phương thức dọn dẹp và thực hiện công việc tốt, và bộ mã hóa số 3 sử dụng lớp này và không có bất kỳ rò rỉ tài nguyên nào mặc dù bộ mã hóa số 1 đã làm hỏng nó.
Pieter B

6
@Doval Thật vậy. Đó là lý do tại sao việc thừa kế là bài học số một chỉ trong mỗi cuốn sách và lớp học giới thiệu về OOP.
Kevin Krumwiede

30

Nếu bạn xuất bản một chức năng bình thường, bạn đưa ra hợp đồng một phía:
Chức năng này sẽ làm gì nếu được gọi?

Nếu bạn xuất bản một cuộc gọi lại, bạn cũng đưa ra một hợp đồng một phía:
Khi nào và nó sẽ được gọi như thế nào?

Và nếu bạn xuất bản một hàm quá mức, cả hai cùng một lúc, vì vậy bạn đưa ra một hợp đồng hai mặt:
Khi nào nó sẽ được gọi và nó phải làm gì nếu được gọi?

Thậm chí nếu người dùng của bạn không được lạm dụng API của bạn (bằng cách phá vỡ của họ là một phần của hợp đồng, mà có thể là tốn kém để phát hiện), bạn có thể dễ dàng nhìn thấy tài liệu mà các nhu cầu sau hơn rất nhiều, và tất cả mọi thứ bạn tài liệu là một cam kết, trong đó giới hạn lựa chọn thêm của bạn.

Một ví dụ về reneging trên một hợp đồng hai mặt đó là di chuyển từ showhideđể setVisible(boolean)in java.awt.Component .


+1. Tôi không chắc tại sao câu trả lời khác được chấp nhận; nó đưa ra một số điểm thú vị, nhưng nó chắc chắn không phải là câu trả lời đúng cho câu hỏi này , ở chỗ nó chắc chắn không phải là ý nghĩa của đoạn trích dẫn.
ruakh

Đây là câu trả lời đúng, nhưng tôi không hiểu ví dụ. Thay thế chương trình và ẩn bằng setVisible (boolean) dường như cũng phá vỡ mã không sử dụng tính kế thừa. Tui bỏ lỡ điều gì vậy?
eigensheep

3
@eigensheep: showhidevẫn tồn tại, họ chỉ là @Deprecated. Vì vậy, sự thay đổi không phá vỡ bất kỳ mã nào chỉ đơn thuần gọi chúng. Nhưng nếu bạn đã ghi đè chúng, các phần ghi đè của bạn sẽ không được gọi bởi các khách hàng di chuyển sang 'setVisible' mới. (Tôi chưa bao giờ sử dụng Swing, vì vậy tôi không biết làm thế nào phổ biến đó là để ghi đè lên những; nhưng vì nó đã xảy ra trong một thời gian dài trước đây, tôi tưởng tượng lý do mà Deduplicator nhớ nó là nó cắn anh / cô ấy đau đớn.)
ruakh

12

Câu trả lời của Kilian Foth là tuyệt vời. Tôi chỉ muốn thêm ví dụ kinh điển * về lý do tại sao đây là một vấn đề. Hãy tưởng tượng một lớp điểm nguyên:

class Point2D {
    public int x;
    public int y;

    // constructor
    public Point2D(int theX, int theY) { x = theX; y = theY; }

    public int hashCode() { return x + y; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point2D) ) { return false; }

        Point2D that = (Point2D) o;

        return (x == that.x) &&
               (y == that.y);
    }
}

Bây giờ hãy để lớp con trở thành một điểm 3D.

class Point3D extends Point2D {
    public int z;

    // constructor
    public Point3D(int theX, int theY, int theZ) {
        super(x, y); z = theZ;
    }

    public int hashCode() { return super.hashCode() + z; }

    public boolean equals(Object o) {
        if (this == o) { return true; }
        if ( !(o instanceof Point3D) ) { return false; }

        Point3D that = (Point3D) o;

        return super.equals(that) &&
               (z == that.z);
    }
}

Siêu đơn giản! Hãy sử dụng điểm của chúng tôi:

Point2D p2a = new Point2D(3, 5);
Point2D p2b = new Point2D(3, 5);
Point2D p2c = new Point2D(3, 7);

p2a.equals(p2b); // true
p2b.equals(p2a); // true
p2a.equals(p2c); // false

Point3D p3a = new Point3D(3, 5, 7);
Point3D p3b = new Point3D(3, 5, 7);
Point3D p3c = new Point3D(3, 7, 11);

p3a.equals(p3b); // true
p3b.equals(p3a); // true
p3a.equals(p3c); // false

Có lẽ bạn đang tự hỏi tại sao tôi đăng một ví dụ dễ dàng như vậy. Đây là cái bẫy:

p2a.equals(p3a); // true
p3a.equals(p2a); // FALSE!

Khi chúng ta so sánh điểm 2D với điểm 3D tương đương, chúng ta sẽ đúng, nhưng khi chúng ta đảo ngược sự so sánh, chúng ta sẽ sai (vì p2a thất bại instanceof Point3D).

Phần kết luận

  1. Thông thường có thể thực hiện một phương thức trong một lớp con theo cách mà nó không còn tương thích với cách mà siêu lớp mong đợi nó hoạt động.

  2. Nhìn chung, không thể thực hiện bằng () trên một lớp con khác nhau đáng kể theo cách tương thích với lớp cha của nó.

Khi bạn viết một lớp mà bạn dự định cho phép mọi người phân lớp, đó là một ý tưởng thực sự tốt để viết ra một hợp đồng về cách mỗi phương thức nên hành xử. Thậm chí tốt hơn sẽ là một tập hợp các bài kiểm tra đơn vị mà mọi người có thể chạy chống lại việc thực hiện các phương pháp bị ghi đè của họ để chứng minh rằng họ không vi phạm hợp đồng. Hầu như không ai làm điều đó bởi vì nó quá nhiều công việc. Nhưng nếu bạn quan tâm, đó là điều cần làm.

Một ví dụ tuyệt vời của một hợp đồng được đánh vần tốt là So sánh . Chỉ cần bỏ qua những gì nó nói về .equals()những lý do được mô tả ở trên. Đây là một ví dụ về cách so sánh có thể làm những điều .equals()không thể .

Ghi chú

  1. Mục 8 "Java hiệu quả" của Josh Bloch là nguồn gốc của ví dụ này, nhưng Bloch sử dụng ColorPoint để thêm màu thay vì trục thứ ba và sử dụng gấp đôi thay vì ints. Ví dụ Java của Bloch về cơ bản được sao chép bởi Oderky / Spoon / Venners , những người đã đưa ra ví dụ của họ trực tuyến.

  2. Một số người đã phản đối ví dụ này bởi vì nếu bạn cho lớp cha biết về lớp con, bạn có thể khắc phục vấn đề này. Điều đó đúng nếu có một số lượng nhỏ các lớp con và nếu phụ huynh biết về tất cả chúng. Nhưng câu hỏi ban đầu là về việc tạo một API mà người khác sẽ viết các lớp con cho. Trong trường hợp đó, bạn thường không thể cập nhật triển khai cha mẹ để tương thích với các lớp con.

Tiền thưởng

Trình so sánh cũng thú vị bởi vì nó hoạt động xung quanh vấn đề thực hiện bằng () một cách chính xác. Tốt hơn nữa, nó tuân theo một mẫu để khắc phục loại vấn đề thừa kế này: mẫu thiết kế Chiến lược. Các kiểu chữ mà người Haskell và Scala thích thú cũng là mẫu Chiến lược. Kế thừa không phải là xấu hay sai, nó chỉ là khó khăn. Để đọc thêm, hãy xem bài viết của Philip Wadler Cách làm cho đa hình ad-hoc bớt ad hoc


1
Mặc dù SortedMap và Sortedset không thực sự thay đổi định nghĩa về equalscách Map và Set xác định nó. Bình đẳng hoàn toàn bỏ qua thứ tự, với hiệu ứng, ví dụ, hai SortedSets có cùng các phần tử nhưng thứ tự sắp xếp khác nhau vẫn so sánh bằng nhau.
user2357112 hỗ trợ Monica

1
@ user2357112 Bạn nói đúng và tôi đã xóa ví dụ đó. SortedMap.equals () tương thích với Map là một vấn đề riêng biệt mà tôi sẽ tiến hành khiếu nại. SortedMap thường là O (log2 n) và HashMap (hàm ý chính tắc của Map) là O (1). Do đó, bạn sẽ chỉ sử dụng SortedMap nếu bạn thực sự quan tâm đến việc đặt hàng. Vì lý do đó, tôi tin rằng thứ tự đủ quan trọng để trở thành một thành phần quan trọng của kiểm tra bằng () trong các triển khai SortedMap. Họ không nên chia sẻ cách triển khai bằng () với Map (họ thực hiện thông qua AbstractMap trong Java).
GlenPeterson

3
"Kế thừa không phải là xấu hay sai, nó chỉ là khó khăn." Tôi hiểu những gì bạn đang nói, nhưng những điều khó khăn thường dẫn đến lỗi, lỗi và vấn đề. Khi bạn có thể hoàn thành những điều tương tự (hoặc gần như tất cả những điều tương tự) theo cách đáng tin cậy hơn, thì cách khó hơn xấu.
jpmc26

7
Đây là một ví dụ khủng khiếp , Glen. Bạn chỉ sử dụng tính kế thừa theo cách không nên sử dụng, không có gì lạ khi các lớp không hoạt động theo cách bạn dự định. Bạn đã phá vỡ nguyên tắc thay thế của Liskov bằng cách cung cấp một sự trừu tượng hóa sai (điểm 2D), nhưng chỉ vì tính kế thừa là xấu trong ví dụ sai của bạn không có nghĩa là nó nói chung là xấu. Mặc dù câu trả lời này có vẻ hợp lý, nhưng nó sẽ chỉ gây nhầm lẫn cho những người không nhận ra nó phá vỡ quy tắc thừa kế cơ bản nhất.
Andy

3
ELI5 của Nguyên tắc Substituton của Liskov nói: Nếu một lớp Blà con của một lớp Avà bạn có nên khởi tạo một đối tượng của một lớp hay không B, bạn có thể truyền Bđối tượng lớp cho cha mẹ của nó và sử dụng API biến của biến mà không mất bất kỳ chi tiết triển khai nào của đứa trẻ. Bạn đã phá vỡ quy tắc bằng cách cung cấp tài sản thứ ba. Làm thế nào để bạn có kế hoạch truy cập ztọa độ sau khi bạn chuyển Point3Dbiến sang Point2D, khi lớp cơ sở không có ý tưởng như vậy tồn tại? Nếu bằng cách chuyển một lớp con sang cơ sở của nó, bạn phá vỡ API công khai, thì sự trừu tượng của bạn là sai.
Andy

4

Kế thừa làm suy yếu đóng gói

Khi bạn xuất bản một giao diện được phép thừa kế, bạn sẽ tăng đáng kể kích thước giao diện của mình. Mỗi phương thức có thể thay thế có thể được thay thế và do đó nên được coi là gọi lại được cung cấp cho hàm tạo. Việc triển khai được cung cấp bởi lớp của bạn chỉ là giá trị mặc định của cuộc gọi lại. Vì vậy, một số loại hợp đồng nên được cung cấp cho biết những kỳ vọng về phương pháp này là gì. Điều này hiếm khi xảy ra và là một lý do chính tại sao mã hướng đối tượng được gọi là giòn.

Dưới đây là một ví dụ thực tế (đơn giản hóa) từ khung bộ sưu tập java, lịch sự của Peter Norvig ( http://norvig.com/java-iaq.html ).

Public Class HashTable{
    ...
    Public Object put(K key, V value){
        try{
            //add object to table;
        }catch(TableFullException e){
            increaseTableSize();
            put(key,value);
        }
    }
}

Vì vậy, những gì xảy ra nếu chúng ta phân lớp này?

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/

public class HashtableWithPlurals extends Hashtable {

    /** Make the table map both key and key + "s" to value. **/
    public Object put(Object key, Object value) {
        super.put(key + "s", value);
        return super.put(key, value);
    }
}

Chúng tôi có một lỗi: Thỉnh thoảng chúng tôi thêm "dog" và hashtable có một mục cho "dogss". Nguyên nhân là do ai đó cung cấp một triển khai đưa ra mà người thiết kế lớp Hashtable không mong đợi.

Kế thừa phá vỡ khả năng mở rộng

Nếu bạn cho phép lớp của bạn được phân lớp, bạn cam kết không thêm bất kỳ phương thức nào vào lớp của bạn. Điều này có thể được thực hiện mà không phá vỡ bất cứ điều gì.

Khi bạn thêm các phương thức mới vào một giao diện, bất kỳ ai đã kế thừa từ lớp của bạn sẽ cần phải thực hiện các phương thức đó.


3

Nếu một phương thức được gọi là phương thức, bạn chỉ phải đảm bảo nó hoạt động chính xác. Đó là nó. Làm xong.

Nếu một phương thức được thiết kế để ghi đè, bạn cũng phải suy nghĩ cẩn thận về phạm vi của phương thức: nếu phạm vi quá lớn, lớp con thường sẽ cần bao gồm mã được sao chép từ phương thức cha; nếu nó quá nhỏ, nhiều phương thức sẽ cần được ghi đè để có chức năng mới mong muốn - điều này làm tăng thêm độ phức tạp và số dòng không cần thiết.

Do đó, người tạo phương thức cha cần đưa ra các giả định về cách lớp và phương thức của nó có thể bị ghi đè trong tương lai.

Tuy nhiên, tác giả đang nói về một vấn đề khác trong văn bản được trích dẫn:

Nhưng chúng ta biết rằng nó dễ vỡ, bởi vì lớp con có thể dễ dàng đưa ra các giả định về bối cảnh mà phương thức mà nó ghi đè đang được gọi.

Xem xét phương thức athường được gọi từ phương thức b, nhưng trong một số trường hợp hiếm và không rõ ràng từ phương thức c. Nếu tác giả của phương pháp ghi đè bỏ qua cphương thức và kỳ vọng của nó a, thì rõ ràng mọi thứ có thể sai như thế nào.

Do đó, điều quan trọng hơn alà được xác định rõ ràng và rõ ràng, được ghi chép rõ ràng, "làm một việc và làm tốt" - nhiều hơn nếu đó là một phương pháp chỉ được thiết kế để được gọi.

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.