Điều gì sẽ là bất lợi khi định nghĩa một lớp là một lớp con của một danh sách của chính nó?


48

Trong một dự án gần đây của tôi, tôi đã định nghĩa một lớp với tiêu đề sau:

public class Node extends ArrayList<Node> {
    ...
}

Tuy nhiên, sau khi thảo luận với giáo sư CS của tôi, anh ấy đã tuyên bố rằng cả lớp sẽ "khủng khiếp cho trí nhớ" và "thực hành tồi". Tôi đã không tìm thấy cái đầu tiên là đặc biệt đúng, và cái thứ hai là chủ quan.

Lý do của tôi cho việc sử dụng này là tôi đã có ý tưởng cho một đối tượng cần được xác định là một thứ có thể có độ sâu tùy ý, trong đó hành vi của một thể hiện có thể được xác định bằng cách thực hiện tùy chỉnh hoặc bởi hành vi của một số đối tượng tương tác . Điều này sẽ cho phép sự trừu tượng hóa của các đối tượng có triển khai vật lý sẽ được tạo thành từ nhiều thành phần phụ tương tác .¹

Mặt khác, tôi thấy làm thế nào điều này có thể là thực hành xấu. Ý tưởng xác định một cái gì đó như một danh sách của chính nó không đơn giản hoặc có thể thực hiện được.

Có bất kỳ lý do hợp lệ tại sao tôi không nên sử dụng điều này trong mã của mình, xem xét việc sử dụng nó cho nó?


Nếu tôi cần giải thích điều này hơn nữa, tôi sẽ rất vui mừng; Tôi chỉ cố gắng để giữ cho câu hỏi này ngắn gọn.



1
@gnat Điều này có liên quan nhiều hơn đến việc xác định nghiêm ngặt một lớp là một danh sách của chính nó, thay vì chứa các danh sách có chứa danh sách. Tôi nghĩ đó là một biến thể của một thứ tương tự, nhưng không hoàn toàn giống nhau. Câu hỏi đó đề cập đến một cái gì đó dọc theo câu trả lời của Robert Harvey .
Addison Crump

16
Kế thừa từ một cái gì đó được tham số hóa bởi chính nó không phải là bất thường, như cho thấy thành ngữ CRTP thường được sử dụng trong C ++. Trong trường hợp của container, tuy nhiên câu hỏi chính là để giải thích lý do tại sao bạn phải sử dụng tính kế thừa thay vì thành phần.
Christophe

1
Tôi thích ý tưởng. Xét cho cùng, mỗi nút trong cây là một cây (phụ). Thông thường, tính chất cây của một nút được che giấu khỏi chế độ xem kiểu (nút cổ điển không phải là cây mà là một đối tượng) và thay vào đó được thể hiện trong chế độ xem dữ liệu (nút đơn cho phép truy cập vào các nhánh). Đây là cách khác; chúng ta sẽ không thấy dữ liệu chi nhánh, nó thuộc loại. Trớ trêu thay, bố cục đối tượng thực tế trong C ++ có thể rất giống nhau (kế thừa được triển khai rất giống với việc chứa dữ liệu), điều này khiến tôi nghĩ rằng chúng ta đang xử lý hai cách thể hiện cùng một điều.
Peter - Tái lập Monica

1
Có thể tôi đang thiếu một chút, nhưng bạn có thực sự cần phải gia hạn ArrayList<Node> , so với thực hiện List<Node> không?
Matthias

Câu trả lời:


107

Thành thật mà nói, tôi không thấy sự cần thiết phải thừa kế ở đây. Nó không có ý nghĩa; Node là một ArrayList trong Node?

Nếu đây chỉ là một cấu trúc dữ liệu đệ quy, bạn chỉ cần viết một cái gì đó như:

public class Node {
    public String item;
    public List<Node> children;
}

không có ý nghĩa; nút có một danh sách các nút con hoặc con cháu.


34
Đó không phải là điều tương tự. Danh sách danh sách danh sách ad-infinitum là vô nghĩa trừ khi bạn đang lưu trữ thứ gì đó bên cạnh danh sách mới trong danh sách. Đó là những gì Item đang có và Itemhoạt động độc lập với danh sách.
Robert Harvey

6
Ồ, tôi hiểu rồi. Tôi đã tiếp cận Node là một ArrayList trong Nodenhư Node chứa từ 0 đến nhiều Node đối tượng. Về cơ bản, chức năng của chúng là giống nhau, nhưng thay vì một danh sách các đối tượng giống như vậy, nó một danh sách các đối tượng giống như. Thiết kế ban đầu cho lớp được viết là niềm tin rằng bất kỳ thứ gì Nodecũng có thể được thể hiện dưới dạng một tập hợp con Node, cả hai đều thực hiện, nhưng cái này chắc chắn ít gây nhầm lẫn. +1
Addison Crump

11
Tôi muốn nói rằng danh tính giữa một nút và một danh sách có xu hướng phát sinh "một cách tự nhiên" khi bạn viết các danh sách liên kết đơn trong C hoặc Lisp hoặc bất cứ điều gì: trong một số thiết kế nhất định, nút đầu "là" danh sách, theo nghĩa là duy nhất điều từng được sử dụng để đại diện cho một danh sách. Vì vậy, tôi không thể xua tan hoàn toàn cảm giác rằng trong một số bối cảnh có lẽ một nút "là" (không chỉ "có") một tập hợp các nút, mặc dù tôi đồng ý rằng nó cảm thấy EvilBadWrong trong Java.
Steve Jessop

12
@ nl-x Rằng một cú pháp ngắn hơn không có nghĩa là nó tốt hơn. Ngoài ra, tình cờ, việc truy cập các vật phẩm trong một cái cây như thế là khá hiếm. Thông thường cấu trúc không được biết đến vào thời gian biên dịch, vì vậy bạn có xu hướng không đi qua các cây như vậy bằng các giá trị không đổi. Độ sâu cũng không cố định, do đó bạn thậm chí không thể lặp lại tất cả các giá trị bằng cú pháp đó. Khi bạn điều chỉnh mã để sử dụng một vấn đề truyền qua cây truyền thống, cuối cùng bạn Childrenchỉ viết một hoặc hai lần, và thông thường nên viết nó ra trong những trường hợp như vậy vì sự rõ ràng của mã.
Phục vụ


57

Điều kinh khủng đối với bộ nhớ đối số của người Viking là hoàn toàn sai, nhưng đó là một cách thực hành khách quan của người Bỉ. Khi bạn kế thừa từ một lớp, bạn không chỉ kế thừa các trường và phương thức bạn quan tâm. Thay vào đó, bạn kế thừa mọi thứ . Mọi phương pháp mà nó tuyên bố, ngay cả khi nó không hữu ích cho bạn. Và quan trọng nhất, bạn cũng kế thừa tất cả các hợp đồng của nó và đảm bảo rằng lớp cung cấp.

Từ viết tắt RẮN cung cấp một số phương pháp phỏng đoán cho thiết kế hướng đối tượng tốt. Ở đây, Nguyên tắc phân chia I nterface (ISP) và L iskov thay thế Bảng giá (LSP) có một cái gì đó để nói.

ISP bảo chúng tôi giữ cho giao diện của chúng tôi nhỏ nhất có thể. Nhưng bằng cách kế thừa từ ArrayList, bạn có được nhiều, nhiều phương pháp. Là nó có ý nghĩa để get(), remove(), set()(thay thế), hoặc add()(insert) một nút con tại một chỉ số cụ thể không? Có hợp lý với ensureCapacity()danh sách cơ bản không? Nó có nghĩa gì với sort()một nút? Là người dùng của lớp học của bạn thực sự cần phải có được một subList()? Vì bạn không thể ẩn các phương thức mà bạn không muốn, giải pháp duy nhất là có ArrayList làm biến thành viên và chuyển tiếp tất cả các phương thức mà bạn thực sự muốn:

private final ArrayList<Node> children = new ArrayList();
public void add(Node child) { children.add(child); }
public Iterator<Node> iterator() { return children.iterator(); }

Nếu bạn thực sự muốn tất cả các phương pháp bạn thấy trong tài liệu, chúng tôi có thể chuyển sang LSP. LSP cho chúng ta biết rằng chúng ta phải có thể sử dụng lớp con bất cứ nơi nào mà lớp cha được mong đợi. Nếu một hàm lấy ArrayListtham số làm và chúng ta vượt qua Nodethay vào đó, không có gì phải thay đổi.

Khả năng tương thích của các lớp con bắt đầu với những thứ đơn giản như chữ ký loại. Khi bạn ghi đè một phương thức, bạn không thể làm cho các kiểu tham số trở nên nghiêm ngặt hơn vì điều đó có thể loại trừ các sử dụng hợp pháp với lớp cha. Nhưng đó là thứ mà trình biên dịch kiểm tra cho chúng ta trong Java.

Nhưng LSP chạy sâu hơn nhiều: Chúng ta phải duy trì khả năng tương thích với mọi thứ được hứa hẹn bằng tài liệu của tất cả các lớp và giao diện cha. Trong câu trả lời của họ , Lynn đã tìm thấy một trường hợp như vậy trong đó Listgiao diện (mà bạn được kế thừa thông qua ArrayList) đảm bảo cách thức equals()hashCode()các phương thức được cho là hoạt động. Đối với hashCode()bạn thậm chí còn được đưa ra một thuật toán cụ thể phải được thực hiện chính xác. Giả sử bạn đã viết điều này Node:

public class Node extends ArrayList<Node> {
  public final int value;

  public Node(int value, Node... children) {
    this.value = Value;
    for (Node child : children)
      add(child);
  }

  ...

}

Điều này đòi hỏi rằng valuekhông thể đóng góp vào hashCode()và không thể ảnh hưởng equals(). Các Listgiao diện - mà bạn hứa danh dự bằng cách kế thừa từ nó - đòi hỏi new Node(0).equals(new Node(123))đến mức khó tin.


Bởi vì kế thừa từ các lớp làm cho quá dễ dàng để vô tình phá vỡ một lời hứa mà lớp cha mẹ đã thực hiện và vì nó thường phơi bày nhiều phương thức hơn bạn dự định, nên thường đề nghị bạn thích sáng tác hơn kế thừa . Nếu bạn phải kế thừa một cái gì đó, nó được đề xuất chỉ kế thừa giao diện. Nếu bạn muốn sử dụng lại hành vi của một lớp cụ thể, bạn có thể giữ nó như một đối tượng riêng biệt trong một biến thể hiện, theo cách đó, tất cả các lời hứa và yêu cầu của nó không bắt buộc đối với bạn.

Đôi khi, ngôn ngữ tự nhiên của chúng tôi cho thấy mối quan hệ thừa kế: Xe hơi là phương tiện. Một chiếc xe máy là một phương tiện. Tôi có nên định nghĩa các lớp CarMotorcyclekế thừa từ một Vehiclelớp không? Thiết kế hướng đối tượng không phải là phản ánh chính xác thế giới thực trong mã của chúng tôi. Chúng ta không thể dễ dàng mã hóa các nguyên tắc phân loại phong phú của thế giới thực trong mã nguồn của mình.

Một ví dụ như vậy là vấn đề mô hình hóa nhân viên-sếp. Chúng tôi có nhiều Persons, mỗi cái có một tên và địa chỉ. An Employeelà một Personvà có một Boss. A Bosscũng là a Person. Vậy tôi có nên tạo một Personlớp được kế thừa bởi BossEmployeekhông? Bây giờ tôi có một vấn đề: ông chủ cũng là một nhân viên và có một cấp trên khác. Vì vậy, nó có vẻ như Bossnên mở rộng Employee. Nhưng đó CompanyOwnerlà một Bossnhưng không phải là một Employee? Bất kỳ loại biểu đồ thừa kế bằng cách nào đó sẽ phá vỡ ở đây.

OOP không phải là về phân cấp, kế thừa và sử dụng lại các lớp hiện có, nó là về khái quát hóa hành vi . OOP nói về vấn đề của tôi. Tôi có rất nhiều đối tượng và muốn họ làm một việc cụ thể - và tôi không quan tâm làm thế nào. Đây là giao diện dành cho. Nếu bạn triển khai Iterablegiao diện cho bạn Nodevì bạn muốn làm cho nó có thể lặp lại, điều đó hoàn toàn tốt. Nếu bạn triển khai Collectiongiao diện vì bạn muốn thêm / xóa các nút con, v.v. thì không sao. Nhưng kế thừa từ một lớp khác bởi vì nó tình cờ mang đến cho bạn tất cả những gì không, hoặc ít nhất là không trừ khi bạn đã suy nghĩ cẩn thận như đã nêu ở trên.


10
Tôi đã in 4 đoạn cuối của bạn, đặt tiêu đề là Giới thiệu về OOP và Kế thừa và đặt chúng lên tường tại nơi làm việc. Một số đồng nghiệp của tôi cần thấy rằng tôi biết họ sẽ đánh giá cao cách bạn đặt nó :) Cảm ơn bạn.
YSC

17

Mở rộng một container trong chính nó thường được chấp nhận là thực hành xấu. Có rất ít lý do để mở rộng một container thay vì chỉ có một. Mở rộng một container của chính bạn chỉ làm cho nó thêm lạ.


14

Thêm vào những gì đã được nói, có một lý do hơi cụ thể về Java để tránh loại cấu trúc này.

Hợp đồng của equalsphương thức cho các danh sách yêu cầu một danh sách được coi là bằng với một đối tượng khác

nếu và chỉ khi đối tượng được chỉ định cũng là một danh sách, cả hai danh sách có cùng kích thước và tất cả các cặp phần tử tương ứng trong hai danh sách đều bằng nhau .

Nguồn: https://docs.oracle.com/javase/7/docs/api/java/util/List.html#equals(java.lang.Object)

Cụ thể, điều này có nghĩa là có một lớp được thiết kế như một danh sách của chính nó có thể làm cho các phép so sánh bằng trở nên đắt đỏ (và các phép tính băm cũng như nếu các danh sách có thể thay đổi được) và nếu lớp đó có một số trường đối tượng, thì chúng phải được bỏ qua trong so sánh đẳng thức .


1
Đây là một lý do tuyệt vời để loại bỏ điều này khỏi mã của tôi bên cạnh thực tiễn xấu. : P
Addison Crump

3

Về bộ nhớ:

Tôi muốn nói rằng đây là vấn đề của sự hoàn hảo. Trình xây dựng mặc định ArrayListtrông giống như thế này:

public ArrayList(int initialCapacity) {
     super();

     if (initialCapacity < 0)
         throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Nguồn . Hàm tạo này cũng được sử dụng trong Oracle-JDK.

Bây giờ hãy xem xét việc xây dựng Danh sách liên kết đơn với mã của bạn. Bạn vừa tăng thành công mức tiêu thụ bộ nhớ của mình theo hệ số 10 lần (chính xác là thậm chí cao hơn một chút). Đối với cây, điều này có thể dễ dàng trở nên khá tệ mà không có bất kỳ yêu cầu đặc biệt nào về cấu trúc của cây. Sử dụng một LinkedListhoặc một lớp khác thay thế sẽ giải quyết vấn đề này.

Thành thật mà nói, trong hầu hết các trường hợp, điều này không có gì ngoài sự cầu toàn, ngay cả khi chúng ta bỏ qua dung lượng bộ nhớ khả dụng. A LinkedListsẽ làm chậm mã một chút thay thế, do đó, đó là sự đánh đổi giữa hiệu năng và mức tiêu thụ bộ nhớ. Tuy nhiên, ý kiến ​​cá nhân của tôi về điều này sẽ không lãng phí nhiều bộ nhớ theo cách có thể dễ dàng bị phá vỡ như cái này.

EDIT: làm rõ về các ý kiến ​​(chính xác là @amon). Phần này của câu trả lời không giải quyết vấn đề thừa kế. Việc so sánh mức sử dụng bộ nhớ được thực hiện so với Danh sách liên kết đơn và với mức sử dụng bộ nhớ tốt nhất (trong triển khai thực tế, hệ số có thể thay đổi một chút, nhưng nó vẫn đủ lớn để tổng hợp khá nhiều bộ nhớ lãng phí).

Về "Thực hành xấu":

Chắc chắn rồi. Đây không phải là cách tiêu chuẩn để thực hiện biểu đồ vì một lý do đơn giản: Biểu đồ nút các nút con, không phải danh sách các nút con. Thể hiện chính xác những gì bạn có ý nghĩa trong mã là một kỹ năng chính. Có thể là tên biến hoặc thể hiện cấu trúc như thế này. Điểm tiếp theo: giữ tối thiểu các giao diện: Bạn đã thực hiện mọi phương thức ArrayListkhả dụng cho người dùng của lớp thông qua kế thừa. Không có cách nào để thay đổi điều này mà không phá vỡ toàn bộ cấu trúc của mã. Thay vào đó, lưu trữ Listdưới dạng biến nội bộ và cung cấp các phương thức cần thiết thông qua phương thức bộ điều hợp. Bằng cách này, bạn có thể dễ dàng thêm và xóa chức năng khỏi lớp mà không làm hỏng mọi thứ.


1
Cao gấp 10 lần so với những gì ? Nếu tôi có một ArrayList được xây dựng mặc định làm trường đối tượng (thay vì kế thừa từ nó), tôi thực sự đang sử dụng bộ nhớ nhiều hơn một chút vì tôi cần lưu trữ một con trỏ-ArrayList bổ sung trong đối tượng của mình. Ngay cả khi chúng ta so sánh táo với cam và so sánh kế thừa từ ArrayList với việc không sử dụng bất kỳ ArrayList nào, lưu ý rằng trong Java chúng ta luôn xử lý các con trỏ với các đối tượng, không bao giờ các đối tượng theo giá trị như C ++ sẽ làm. Giả new Object[0]sử tổng cộng sử dụng 4 từ, a new Object[10]sẽ chỉ sử dụng 14 từ bộ nhớ.
amon

@amon Tôi đã không nói rõ ràng, xin lỗi vì điều đó. Mặc dù bối cảnh sẽ đủ để thấy rằng tôi đang so sánh với LinkedList. Và nó được gọi là tham chiếu, không phải con trỏ btw. Và vấn đề với bộ nhớ không phải là về việc lớp có được sử dụng thông qua thừa kế hay không, mà đơn giản là thực tế là (gần như) những người không sử dụng ArrayListđang thắt lưng khá nhiều bộ nhớ.
Paul

-2

Điều này dường như trông giống như ngữ nghĩa của mẫu tổng hợp . Nó được sử dụng để mô hình hóa các dữ liệu đệ quy.


13
Tôi sẽ không đánh giá thấp điều này, nhưng đây là lý do tại sao tôi thực sự ghét cuốn sách GoF. Đó là một cây bị chảy máu! Tại sao chúng ta cần phát minh ra thuật ngữ "mẫu tổng hợp" để mô tả một cây?
David Arno

3
@DavidArno Tôi tin rằng đó là toàn bộ khía cạnh nút đa hình định nghĩa nó là "mẫu tổng hợp", việc sử dụng cấu trúc dữ liệu cây chỉ là một phần của nó.
JAB

2
@RobertHarvey, tôi không đồng ý (với cả ý kiến ​​của bạn cho câu trả lời của bạn và ở đây). public class Node extends ArrayList<Node> { public string Item; }là phiên bản kế thừa của câu trả lời của bạn. Lý do của bạn đơn giản hơn, nhưng cả hai đều đạt được một số điều: họ định nghĩa cây không nhị phân.
David Arno

2
@DavidArno: Tôi chưa bao giờ nói nó sẽ không hoạt động, tôi chỉ nói rằng nó có thể không lý tưởng.
Robert Harvey

1
@DavidArno không phải là về cảm xúc và hương vị ở đây, mà là về sự thật. Một cây được tạo thành từ các nút và mỗi nút có con tiềm năng. Một nút đã cho là một nút lá chỉ tại một thời điểm nhất định. Trong một hỗn hợp, một nút lá là lá do xây dựng, và bản chất của nó sẽ không thay đổi.
Christophe
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.