Mengapa meluas adalah jahat

Kata extendskunci itu jahat; mungkin tidak di peringkat Charles Manson, tetapi cukup buruk sehingga harus dijauhi bila mungkin. Buku Gang of Four Design Corak membincangkan panjang lebar mengenai penggantian pelaksanaan ( extends) dengan pewarisan antara muka ( implements).

Pereka yang baik menulis sebahagian besar kod mereka dari segi antara muka, bukan kelas asas konkrit. Artikel ini menerangkan mengapa pereka mempunyai kebiasaan aneh, dan juga memperkenalkan beberapa asas pengaturcaraan berasaskan antara muka.

Antara muka dan kelas

Saya pernah menghadiri perjumpaan kumpulan pengguna Java di mana James Gosling (penemu Java) adalah penceramah utama. Semasa sesi Tanya Jawab yang tidak dapat dilupakan, seseorang bertanya kepadanya: "Jika anda dapat melakukan Java lagi, apa yang akan anda ubah?" "Saya akan keluar kelas," jawabnya. Setelah ketawa hilang, dia menjelaskan bahawa masalah sebenarnya bukan kelas itu sendiri, melainkan warisan pelaksanaan ( extendshubungan). Warisan antara muka ( implementshubungan) lebih disukai. Anda harus mengelakkan warisan pelaksanaan bila mungkin.

Kehilangan fleksibiliti

Mengapa anda harus mengelakkan warisan pelaksanaan? Masalah pertama ialah penggunaan nama kelas konkrit secara eksplisit mengunci anda dalam pelaksanaan tertentu, menjadikan perubahan yang tidak perlu sukar dilakukan.

Inti metodologi pengembangan Agile kontemporari adalah konsep reka bentuk dan pembangunan selari. Anda memulakan pengaturcaraan sebelum anda menentukan sepenuhnya program ini. Teknik ini menghadap kearifan tradisional - bahawa reka bentuk harus lengkap sebelum pengaturcaraan dimulakan - tetapi banyak projek yang berjaya membuktikan bahawa anda dapat mengembangkan kod berkualiti tinggi dengan lebih cepat (dan kos efektif) dengan cara ini daripada dengan pendekatan pipelined tradisional. Inti pengembangan selari, bagaimanapun, adalah konsep fleksibiliti. Anda harus menulis kod anda sedemikian rupa sehingga anda dapat memasukkan syarat yang baru dijumpai ke dalam kod yang ada semudah mungkin.

Bukannya melaksanakan ciri-ciri yang anda mungkin perlukan, anda melaksanakan hanya ciri yang anda pasti perlukan, tetapi dengan cara yang menempatkan perubahan. Sekiranya anda tidak mempunyai fleksibiliti ini, pembangunan selari tidak mungkin dilakukan.

Pengaturcaraan ke antara muka adalah teras struktur fleksibel. Untuk mengetahui sebabnya, mari lihat apa yang berlaku apabila anda tidak menggunakannya. Pertimbangkan kod berikut:

f () {LinkedList list = LinkedList baru (); // ... g (senarai); } g (senarai LinkedList) {list.add (...); g2 (senarai)}

Sekarang andaikan syarat baru untuk pencarian cepat telah muncul, jadi LinkedListtidak berfungsi. Anda perlu menggantinya dengan a HashSet. Dalam kod yang ada, perubahan itu tidak dilokalkan kerana anda mesti mengubah bukan sahaja f()tetapi juga g()(yang memerlukan LinkedListargumen), dan apa sahaja yang g()meneruskan senarai ke.

Tulis semula kod seperti ini:

f () {Senarai koleksi = LinkedList baru (); // ... g (senarai); } g (Senarai koleksi) {list.add (...); g2 (senarai)}

memungkinkan untuk menukar senarai yang dipautkan ke jadual hash hanya dengan mengganti new LinkedList()dengan a new HashSet(). Itu sahaja. Tidak perlu perubahan lain.

Sebagai contoh lain, bandingkan kod ini:

f () {Koleksi c = HashSet baru (); // ... g (c); } g (Koleksi c) {untuk (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); }

untuk ini:

f2 () {Koleksi c = HashSet baru (); // ... g2 (c.iterator ()); } g2 (Iterator i) {while (i.hasNext ();) do_something_with (i.next ()); }

The g2()Cara kini boleh merentasi Collectionderivatif serta senarai utama dan nilai yang anda boleh mendapatkan daripada Map. Sebenarnya, anda boleh menulis iterator yang menghasilkan data dan bukannya melintasi koleksi. Anda boleh menulis iterator yang menyalurkan maklumat dari perancah ujian atau fail ke program. Terdapat banyak fleksibiliti di sini.

Gandingan

Masalah yang lebih penting dengan warisan pelaksanaan adalah penggabungan - kebergantungan yang tidak diinginkan dari satu bahagian program pada bahagian yang lain. Pemboleh ubah global memberikan contoh klasik mengapa gandingan kuat menyebabkan masalah. Sekiranya anda mengubah jenis pemboleh ubah global, misalnya, semua fungsi yang menggunakan pemboleh ubah (iaitu, digabungkan dengan pemboleh ubah) mungkin terpengaruh, jadi semua kod ini mesti diperiksa, diubah, dan diuji kembali. Lebih-lebih lagi, semua fungsi yang menggunakan pemboleh ubah digabungkan antara satu sama lain melalui pemboleh ubah. Maksudnya, satu fungsi mungkin salah mempengaruhi tingkah laku fungsi yang lain jika nilai pemboleh ubah diubah pada waktu yang canggung. Masalah ini amat mengerikan dalam program multithreaded.

Sebagai pereka, anda harus berusaha untuk meminimumkan hubungan gandingan. Anda tidak boleh menghilangkan gandingan sama sekali kerana kaedah panggilan dari objek satu kelas ke objek yang lain adalah bentuk gandingan longgar. Anda tidak boleh mempunyai program tanpa beberapa gandingan. Walaupun begitu, anda dapat meminimumkan gandingan dengan mengikuti perintah OO (berorientasi objek) (yang paling penting adalah bahawa pelaksanaan objek harus disembunyikan sepenuhnya dari objek yang menggunakannya). Sebagai contoh, pemboleh ubah contoh objek (medan anggota yang bukan pemalar), harus selalu private. Tempoh. Tiada pengecualian. Pernah. Saya maksudkannya. (Anda kadang-kadang boleh menggunakan protectedkaedah dengan berkesan, tetapiprotected pemboleh ubah contoh adalah kekejian.) Anda tidak boleh menggunakan fungsi get / set dengan alasan yang sama - mereka hanya cara yang terlalu rumit untuk menjadikan medan umum (walaupun fungsi akses yang mengembalikan objek penuh daripada nilai jenis asas adalah wajar dalam situasi di mana kelas objek yang dikembalikan adalah pengabstrakan utama dalam reka bentuk).

Saya tidak sombong di sini. Saya telah menemui korelasi langsung dalam karya saya sendiri antara ketelitian pendekatan OO saya, pengembangan kod cepat dan penyelenggaraan kod yang mudah. Setiap kali saya melanggar prinsip OO pusat seperti penyembunyian pelaksanaan, saya akhirnya menulis semula kod tersebut (biasanya kerana kod itu mustahil untuk di-debug). Saya tidak mempunyai masa untuk menulis semula program, jadi saya mengikuti peraturan. Keprihatinan saya sepenuhnya praktikal - saya tidak berminat dengan kesucian demi kesucian.

Masalah kelas asas rapuh

Sekarang, mari kita terapkan konsep gandingan dengan pewarisan. Dalam sistem penerapan-pewarisan yang menggunakan extends, kelas yang berasal sangat digabungkan dengan kelas asas, dan hubungan dekat ini tidak diinginkan. Pereka telah menggunakan moniker "masalah kelas asas rapuh" untuk menggambarkan tingkah laku ini. Kelas asas dianggap rapuh kerana anda dapat mengubah kelas asas dengan cara yang nampaknya selamat, tetapi tingkah laku baru ini, apabila diwarisi oleh kelas turunan, mungkin menyebabkan kelas yang berasal tidak berfungsi. Anda tidak dapat mengetahui sama ada perubahan kelas asas selamat hanya dengan memeriksa kaedah kelas asas secara berasingan; anda mesti melihat (dan menguji) semua kelas yang diturunkan juga. Selain itu, anda mesti memeriksa semua kod yang menggunakan kelas asas danobjek kelas turunan juga, kerana kod ini mungkin juga dipecahkan oleh tingkah laku baru. Perubahan sederhana ke kelas asas utama dapat menjadikan keseluruhan program tidak dapat dijalankan.

Mari kita kaji masalah gandingan kelas asas dan kelas asas yang rapuh bersama-sama. Kelas berikut memperluas kelas Java ArrayListuntuk membuatnya berkelakuan seperti timbunan:

kelas Stack memanjangkan ArrayList {private int stack_pointer = 0; tolakan kekosongan awam (Artikel objek) {add (stack_pointer ++, artikel); } Pop objek awam () {return remove (--stack_pointer); } push_many void awam (Objek [] artikel) {untuk (int i = 0; i <artikel.length; ++ i) push (artikel [i]); }}

Malah kelas yang semudah ini mempunyai masalah. Pertimbangkan apa yang berlaku apabila pengguna memanfaatkan warisan dan menggunakan ArrayList's clear()kaedah untuk pop semua off timbunan:

Tumpukan a_stack = Tumpukan baru (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

Kod berjaya disusun, tetapi kerana kelas asas tidak mengetahui apa-apa mengenai penunjuk tumpukan, Stackobjek itu kini dalam keadaan tidak ditentukan. Panggilan seterusnya untuk push()meletakkan item baru pada indeks 2 ( stack_pointernilai semasa), jadi timbunan dengan berkesan mempunyai tiga elemen di atasnya - dua yang paling bawah adalah sampah. ( StackKelas Java mempunyai masalah ini; jangan gunakannya.)

Salah satu penyelesaian untuk masalah kaedah-pewarisan yang tidak diingini adalah untuk Stackmengatasi semua ArrayListkaedah yang dapat mengubah keadaan array, jadi penggantian tersebut memanipulasi penunjuk timbunan dengan betul atau membuang pengecualian. ( removeRange()Kaedah ini adalah calon yang baik untuk membuang pengecualian.)

Pendekatan ini mempunyai dua kelemahan. Pertama, jika anda mengatasi segalanya, kelas asas semestinya merupakan antara muka, bukan kelas. Tidak ada gunanya warisan pelaksanaan jika anda tidak menggunakan kaedah yang diwarisi. Kedua, dan yang lebih penting, anda tidak mahu timbunan menyokong semua ArrayListkaedah. removeRange()Kaedah sial itu tidak berguna, misalnya. Satu-satunya cara yang munasabah untuk menerapkan kaedah yang tidak berguna adalah dengan membuang pengecualian, kerana ia tidak boleh dipanggil. Pendekatan ini dengan berkesan memindahkan apa yang akan menjadi ralat waktu kompilasi menjadi runtime. Tidak baik. Sekiranya kaedah itu tidak dinyatakan, penyusun mengeluarkan ralat kaedah yang tidak dijumpai. Sekiranya kaedah itu ada tetapi membuang pengecualian, anda tidak akan mengetahui tentang panggilan sehingga program itu benar-benar dijalankan.

Penyelesaian yang lebih baik untuk masalah kelas asas adalah merangkumi struktur data dan bukannya menggunakan warisan. Inilah versi baru dan yang lebih baik Stack:

kelas Stack {private int stack_pointer = 0; ArrayList peribadi the_data = ArrayList baru (); tolakan kekosongan awam (Artikel objek) {the_data.add (stack_pointer ++, artikel); } Pop objek awam () {return the_data.remove (--stack_pointer); } push_many void awam (Objek [] artikel) {untuk (int i = 0; i <o.length; ++ i) push (artikel [i]); }}

Sejauh ini bagus, tetapi pertimbangkan masalah kelas asas yang rapuh. Katakan anda ingin membuat varian pada Stackjejak ukuran timbunan maksimum dalam jangka masa tertentu. Satu kemungkinan pelaksanaan mungkin seperti ini:

class Monitorable_stack memanjangkan Stack {private int high_water_mark = 0; int semasa_saiz peribadi; tolakan kekosongan awam (Artikel objek) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (artikel); } Pop objek awam () {--current_size; kembali super.pop (); } public int maksimum_size_so_far () {return high_water_mark; }}

Kelas baru ini berfungsi dengan baik, sekurang-kurangnya untuk sementara waktu. Malangnya, kod push_many()tersebut memanfaatkan kenyataan yang berfungsi dengan memanggil push(). Pada mulanya, perincian ini nampaknya bukan pilihan yang buruk. Ini menyederhanakan kod, dan Anda mendapatkan versi kelas turunannya push(), bahkan ketika Monitorable_stackdiakses melalui Stackrujukan, sehingga high_water_markkemas kini dengan betul.

One fine day, someone might run a profiler and notice the Stack isn't as fast as it could be and is heavily used. You can rewrite the Stack so it doesn't use an ArrayList and consequently improve the Stack's performance. Here's the new lean-and-mean version:

class Stack { private int stack_pointer = -1; private Object[] stack = new Object[1000]; public void push( Object article ) { assert stack_pointer = 0; return stack[ stack_pointer-- ]; } public void push_many( Object[] articles ) { assert (stack_pointer + articles.length) < stack.length; System.arraycopy(articles, 0, stack, stack_pointer+1, articles.length); stack_pointer += articles.length; } } 

Notice that push_many() no longer calls push() multiple times—it does a block transfer. The new version of Stack works fine; in fact, it's better than the previous version. Unfortunately, the Monitorable_stack derived class doesn't work any more, since it won't correctly track stack usage if push_many() is called (the derived-class version of push() is no longer called by the inherited push_many() method, so push_many() no longer updates the high_water_mark). Stack is a fragile base class. As it turns out, it's virtually impossible to eliminate these types of problems simply by being careful.

Perhatikan bahawa anda tidak menghadapi masalah ini jika anda menggunakan pewarisan antara muka, kerana tidak ada fungsi yang diwarisi untuk memburukkan anda. Sekiranya Stackantara muka, dilaksanakan oleh a Simple_stackdan a Monitorable_stack, maka kodnya jauh lebih mantap.