Apa kerugiannya mendefinisikan kelas sebagai subkelas dari daftar itu sendiri?

48

Dalam proyek saya baru-baru ini, saya mendefinisikan kelas dengan header berikut:

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

Namun, setelah berdiskusi dengan profesor CS saya, dia menyatakan bahwa kelas akan "mengerikan bagi ingatan" dan "praktik buruk". Saya belum menemukan yang pertama benar, dan yang kedua subjektif.

Alasan saya untuk penggunaan ini adalah bahwa saya punya ide untuk objek yang perlu didefinisikan sebagai sesuatu yang bisa memiliki kedalaman sewenang-wenang, di mana perilaku instance dapat didefinisikan baik oleh implementasi kustom atau oleh perilaku beberapa objek seperti berinteraksi . Ini akan memungkinkan untuk abstraksi objek yang implementasi fisiknya akan terdiri dari banyak sub-komponen yang berinteraksi.¹

Di sisi lain, saya melihat bagaimana ini bisa menjadi praktik yang buruk. Gagasan mendefinisikan sesuatu sebagai daftar itu sendiri tidak sederhana atau dapat diimplementasikan secara fisik.

Apakah ada alasan yang sah mengapa saya tidak boleh menggunakan ini dalam kode saya, mempertimbangkan penggunaan saya untuk itu?


¹ Jika saya perlu menjelaskan ini lebih lanjut, saya akan senang untuk; Saya hanya berusaha agar pertanyaan ini singkat.

Addison Crump
sumber
1
@gnat Ini lebih berkaitan dengan mendefinisikan kelas sebagai daftar sendiri, daripada berisi daftar yang berisi daftar. Saya pikir itu adalah varian dari hal yang serupa, tetapi tidak sepenuhnya sama. Pertanyaan itu merujuk pada sesuatu yang sejalan dengan jawaban Robert Harvey .
Addison Crump
16
Mewarisi dari sesuatu yang diparameterisasi dengan sendirinya bukanlah tidak biasa, seperti yang menunjukkan idiom CRTP yang umum digunakan dalam C ++. Dalam hal wadah, pertanyaan kuncinya adalah untuk membenarkan mengapa Anda harus menggunakan warisan alih-alih komposisi.
Christophe
1
Aku suka gagasan itu. Lagi pula, setiap simpul di pohon adalah pohon (sub). Biasanya tree-ness dari suatu node disembunyikan dari tampilan tipe (node ​​klasik bukan pohon tetapi objek tunggal) dan sebaliknya diekspresikan dalam tampilan data (node ​​tunggal memungkinkan akses ke cabang). Ini dia sebaliknya; kita tidak akan melihat data cabang, itu dalam tipe. Ironisnya, tata letak objek aktual di C ++ kemungkinan akan sangat mirip (warisan diimplementasikan sangat mirip dengan berisi data), yang membuat saya berpikir kita berurusan dengan dua cara mengekspresikan hal yang sama.
Peter - Reinstate Monica
1
Mungkin saya kehilangan sedikit, tetapi apakah Anda benar-benar perlu memperpanjang ArrayList<Node> , dibandingkan dengan mengimplementasikan List<Node> ?
Matthias

Jawaban:

107

Terus terang, saya tidak melihat perlunya warisan di sini. Itu tidak masuk akal; Node adalah ArrayList dari Node?

Jika ini hanya struktur data rekursif, Anda cukup menulis sesuatu seperti:

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

Yang tidak masuk akal; node memiliki daftar anak-anak atau node turunan.

Robert Harvey
sumber
34
Bukan hal yang sama. Daftar daftar ad-infinitum tidak ada artinya kecuali Anda menyimpan sesuatu selain daftar baru dalam daftar. Itulah gunanya Item , dan Itemberoperasi secara independen dari daftar.
Robert Harvey
6
Oh, saya mengerti sekarang. Saya telah mendekati Node adalah ArrayList dari Nodesebagai Node mengandung 0 ke banyak Node objek. Fungsinya sama, pada dasarnya, tetapi alih-alih menjadi daftar objek sejenis, ia memiliki daftar objek sejenis. Desain asli untuk kelas yang ditulis adalah keyakinan bahwa apa pun Nodedapat diekspresikan sebagai satu set sub Node, yang dilakukan oleh kedua implementasi, tetapi yang satu ini tentunya kurang membingungkan. +1
Addison Crump
11
Saya akan mengatakan bahwa identitas antara simpul dan daftar cenderung muncul "secara alami" ketika Anda menulis daftar yang terhubung sendiri dalam C atau Lisp atau apa pun: dalam desain tertentu simpul kepala "adalah" daftar, dalam arti menjadi satu-satunya hal yang pernah digunakan untuk mewakili suatu daftar. Jadi saya tidak bisa sepenuhnya menghilangkan perasaan bahwa dalam beberapa konteks mungkin sebuah simpul "adalah" (bukan hanya "memiliki") kumpulan node, meskipun saya setuju rasanya EvilBadWrong di Jawa.
Steve Jessop
12
@ nl-x Bahwa satu sintaks lebih pendek tidak berarti lebih baik. Juga, kebetulan, sangat jarang untuk mengakses item di pohon seperti itu. Biasanya struktur tidak diketahui pada waktu kompilasi, jadi Anda cenderung tidak melintasi pohon-pohon tersebut dengan nilai konstan. Kedalamannya juga tidak diperbaiki, jadi Anda bahkan tidak bisa mengulangi semua nilai menggunakan sintaks itu. Ketika Anda mengadaptasi kode untuk menggunakan masalah traversal pohon tradisional Anda akhirnya menulis Childrenhanya sekali atau dua kali, dan biasanya lebih baik untuk menuliskannya dalam kasus seperti itu demi kejelasan kode.
Servy
8
+1 untuk komposisi Favorit daripada warisan .
Menelaos Kotsollaris
57

Argumen "mengerikan untuk ingatan" sepenuhnya salah, tetapi ini merupakan "praktik buruk" secara objektif . Ketika Anda mewarisi dari kelas, Anda tidak hanya mewarisi bidang dan metode yang Anda minati. Sebaliknya, Anda mewarisi segalanya . Setiap metode yang dinyatakannya, meskipun tidak berguna bagi Anda. Dan yang paling penting, Anda juga mewarisi semua kontrak dan jaminan yang disediakan kelas tersebut.

SOLID singkatan menyediakan beberapa heuristik untuk desain berorientasi objek yang baik. Di sini, saya nterface Pemisahan Prinsip (ISP) dan L iskov Pergantian Pricinple (LSP) memiliki sesuatu untuk dikatakan.

ISP memberi tahu kami untuk menjaga antarmuka kami sekecil mungkin. Tetapi dengan mewarisi dari ArrayList, Anda mendapatkan banyak, banyak metode. Apakah itu berarti untuk get(), remove(), set()(ganti), atau add()(insert) node anak pada indeks tertentu? Apakah masuk akal untuk ensureCapacity()daftar yang mendasarinya? Apa artinya sort()sebuah Node? Apakah pengguna kelas Anda benar-benar seharusnya mendapatkan subList()? Karena Anda tidak dapat menyembunyikan metode yang tidak Anda inginkan, satu-satunya solusi adalah menjadikan ArrayList sebagai variabel anggota, dan meneruskan semua metode yang sebenarnya Anda inginkan:

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

Jika Anda benar-benar menginginkan semua metode yang Anda lihat dalam dokumentasi, kami dapat beralih ke LSP. LSP memberi tahu kita bahwa kita harus dapat menggunakan subclass di mana pun kelas induknya diharapkan. Jika suatu fungsi mengambil ArrayListparameter sebagai dan kami meneruskannya Node, tidak ada yang seharusnya berubah.

Kompatibilitas subclass dimulai dengan hal-hal sederhana seperti jenis tanda tangan. Saat Anda mengganti metode, Anda tidak bisa membuat tipe parameter lebih ketat karena itu mungkin mengecualikan penggunaan yang sah dengan kelas induk. Tapi itu adalah sesuatu yang diperiksa kompiler untuk kita di Jawa.

Tetapi LSP berjalan jauh lebih dalam: Kami harus menjaga kompatibilitas dengan semua yang dijanjikan oleh dokumentasi semua kelas dan antarmuka induk. Dalam jawaban mereka , Lynn telah menemukan satu kasus di mana Listantarmuka (yang telah Anda warisi melalui ArrayList) menjamin cara equals()dan hashCode()metode yang seharusnya bekerja. Untuk hashCode()Anda bahkan diberikan algoritma tertentu yang harus diimplementasikan dengan tepat. Anggap Anda telah menulis ini 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);
  }

  ...

}

Ini mensyaratkan bahwa valuetidak dapat berkontribusi pada hashCode()dan tidak dapat mempengaruhi equals(). The Listantarmuka - yang Anda berjanji untuk menghormati dengan mewarisi dari itu - membutuhkan new Node(0).equals(new Node(123))untuk menjadi kenyataan.


Karena mewarisi dari kelas membuatnya terlalu mudah untuk secara tidak sengaja mengingkari janji yang dibuat oleh kelas induk, dan karena biasanya mengekspos lebih banyak metode daripada yang Anda maksud, umumnya disarankan agar Anda lebih memilih komposisi daripada warisan . Jika Anda harus mewarisi sesuatu, disarankan untuk hanya mewarisi antarmuka. Jika Anda ingin menggunakan kembali perilaku kelas tertentu, Anda bisa menjadikannya sebagai objek terpisah dalam variabel instan, dengan cara itu semua janji dan persyaratannya tidak dipaksakan pada Anda.

Terkadang, bahasa alami kita menunjukkan hubungan pewarisan: Mobil adalah kendaraan. Sepeda motor adalah kendaraan. Haruskah saya mendefinisikan kelas Cardan Motorcyclemewarisi dari Vehiclekelas? Desain berorientasi objek bukan tentang mirroring dunia nyata persis dalam kode kita. Kita tidak dapat dengan mudah menyandikan taksonomi kaya dari dunia nyata dalam kode sumber kita.

Salah satu contohnya adalah masalah pemodelan bos-karyawan. Kami memiliki beberapa Persons, masing-masing dengan nama dan alamat. An Employeeadalah Persondan memiliki a Boss. A Bossjuga a Person. Jadi haruskah saya membuat Personkelas yang diwarisi oleh Bossdan Employee? Sekarang saya memiliki masalah: bos juga seorang karyawan dan memiliki atasan lain. Jadi sepertinya Bossharus diperluas Employee. Tapi CompanyOwneritu Bosstapi bukan Employee? Apa pun jenis grafik warisan entah bagaimana akan rusak di sini.

OOP bukan tentang hierarki, pewarisan, dan penggunaan kembali kelas yang ada, ini tentang generalisasi perilaku . OOP adalah tentang “Saya memiliki banyak objek dan ingin mereka melakukan hal tertentu - dan saya tidak peduli bagaimana caranya.” Itulah gunanya antarmuka . Jika Anda mengimplementasikan Iterableantarmuka untuk Anda Nodekarena Anda ingin membuatnya dapat diubah, itu tidak masalah. Jika Anda mengimplementasikan Collectionantarmuka karena Anda ingin menambah / menghapus node anak dll, maka itu tidak masalah. Tetapi mewarisi dari kelas lain karena kebetulan memberi Anda semua yang tidak, atau setidaknya tidak kecuali Anda telah memikirkannya dengan seksama seperti diuraikan di atas.

amon
sumber
10
Saya telah mencetak 4 paragraf terakhir Anda, dengan judulnya sebagai Tentang OOP dan Warisan dan menempelkannya di dinding di tempat kerja. Beberapa rekan saya perlu melihat itu karena saya tahu mereka akan menghargai cara Anda mengatakannya :) Terima kasih.
YSC
17

Memperluas wadah dengan sendirinya biasanya diterima sebagai praktik yang buruk. Benar-benar sangat sedikit alasan untuk memperpanjang wadah alih-alih hanya memiliki satu. Memperluas wadah dirimu hanya membuatnya menjadi aneh.

DeadMG
sumber
14

Menambah apa yang telah dikatakan, ada alasan khusus Jawa untuk menghindari struktur semacam ini.

Kontrak equalsmetode untuk daftar mengharuskan daftar untuk dianggap sama dengan objek lain

jika dan hanya jika objek yang ditentukan juga merupakan daftar, kedua daftar memiliki ukuran yang sama, dan semua pasangan elemen yang sesuai dalam kedua daftar adalah sama .

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

Secara khusus, ini berarti bahwa memiliki kelas yang dirancang sebagai daftar itu sendiri dapat membuat perbandingan kesetaraan menjadi mahal (dan perhitungan hash juga jika daftar dapat diubah), dan jika kelas memiliki beberapa bidang contoh, maka ini harus diabaikan dalam perbandingan kesetaraan .

Lynn
sumber
1
Ini adalah alasan yang sangat baik untuk menghapus ini dari kode saya selain dari praktik buruk. : P
Addison Crump
3

Mengenai memori:

Saya akan mengatakan ini adalah masalah perfeksionisme. Konstruktor-standar dari ArrayListtampilan seperti ini:

public ArrayList(int initialCapacity) {
     super();

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

     this.elementData = new Object[initialCapacity];
 }

public ArrayList() {
     this(10);
}

Sumber . Konstruktor ini juga digunakan dalam Oracle-JDK.

Sekarang pertimbangkan untuk membuat Daftar yang terhubung sendiri dengan kode Anda. Anda baru saja berhasil membengkak konsumsi memori Anda dengan faktor 10x (tepatnya bahkan sedikit lebih tinggi). Untuk pohon ini dapat dengan mudah menjadi sangat buruk juga tanpa persyaratan khusus pada struktur pohon. Menggunakan LinkedListkelas atau lainnya sebagai alternatif harus menyelesaikan masalah ini.

Sejujurnya, dalam banyak kasus ini hanyalah perfeksionisme belaka, bahkan jika kita mengabaikan jumlah memori yang tersedia. A LinkedListakan memperlambat kode sedikit sebagai alternatif, jadi itu merupakan trade-off antara kinerja dan konsumsi memori. Masih pendapat pribadi saya tentang ini adalah untuk tidak menyia-nyiakan memori sebanyak itu dengan cara yang dapat dengan mudah dielakkan seperti ini.

EDIT: klarifikasi tentang komentar (@amon tepatnya). Bagian jawaban ini tidak membahas masalah warisan. Perbandingan penggunaan memori dibuat terhadap Daftar yang terhubung sendiri-sendiri dan dengan penggunaan memori terbaik (dalam implementasi sebenarnya faktor mungkin berubah sedikit, tetapi masih cukup besar untuk meringkas hingga cukup banyak memori yang terbuang).

Mengenai "Praktek buruk":

Pastinya. Ini bukan standar-cara menerapkan grafik untuk alasan sederhana: A Grafik-Node memiliki anak-node, bukan itu sebagai daftar anak-node. Tepatnya mengekspresikan apa yang Anda maksud dalam kode adalah keterampilan utama. Baik itu dalam nama variabel atau struktur yang mengekspresikan seperti ini. Poin berikutnya: menjaga antarmuka minimal: Anda telah membuat setiap metode ArrayListtersedia untuk pengguna kelas melalui warisan. Tidak ada cara untuk mengubah ini tanpa merusak seluruh struktur kode. Sebagai gantinya simpan Listvariabel internal dan sediakan metode yang diperlukan melalui metode adaptor. Dengan cara ini Anda dapat dengan mudah menambah dan menghapus fungsionalitas dari kelas tanpa mengacaukan semuanya.

Paul
sumber
1
10x lebih tinggi dibandingkan dengan apa ? Jika saya memiliki ArrayList yang dibuat secara default sebagai bidang contoh (alih-alih mewarisi darinya), saya sebenarnya menggunakan sedikit lebih banyak memori karena saya perlu menyimpan pointer-to-ArrayList tambahan di objek saya. Bahkan jika kita membandingkan apel dengan jeruk dan membandingkan pewarisan dari ArrayList untuk tidak menggunakan ArrayList, perhatikan bahwa di Jawa kita selalu berurusan dengan pointer ke objek, tidak pernah objek dengan nilai seperti yang dilakukan C ++. Dengan asumsi new Object[0]total menggunakan 4 kata, new Object[10]hanya akan menggunakan memori 14 kata.
amon
@amon Saya belum menyatakannya secara eksplisit, maaf untuk itu. Meskipun konteksnya akan cukup untuk melihat bahwa saya membandingkan ke LinkedList. Dan itu disebut referensi, bukan pointer btw. Dan poin dengan memori bukan tentang apakah kelas digunakan melalui warisan atau tidak, tetapi hanya fakta bahwa (hampir) yang tidak terpakai ArrayListmemiliki cukup banyak memori.
Paul
-2

Ini terlihat seperti semantik dari pola komposit . Ini digunakan untuk memodelkan struktur data rekursif.

oopexpert
sumber
13
Saya tidak akan mengundurkan diri, tetapi ini sebabnya saya sangat membenci buku GoF. Ini pohon yang sudah memutih! Mengapa kita perlu menciptakan istilah "pola komposit" untuk menggambarkan pohon?
David Arno
3
@ Davidvidno Saya percaya itu adalah seluruh aspek simpul-polimorfik yang mendefinisikannya sebagai "pola komposit", penggunaan struktur data pohon hanyalah sebagian darinya.
JAB
2
@RobertHarvey, saya tidak setuju (dengan komentar Anda untuk jawaban Anda dan di sini). public class Node extends ArrayList<Node> { public string Item; }adalah versi warisan dari jawaban Anda. Anda lebih mudah untuk bernalar, tetapi keduanya mencapai beberapa hal: mereka mendefinisikan pohon non-biner.
David Arno
2
@ Davidvido: Saya tidak pernah mengatakan itu tidak akan berhasil, saya hanya mengatakan bahwa itu mungkin tidak ideal.
Robert Harvey
1
@ DavidArno ini bukan tentang perasaan dan rasa di sini, tapi ini tentang fakta. Pohon terbuat dari simpul dan setiap simpul memiliki anak yang potensial. Node yang diberikan adalah node daun hanya pada saat tertentu saja. Dalam komposit, simpul daun adalah daun dengan konstruksi, dan sifatnya tidak akan berubah.
Christophe