Mengapa meluas itu jahat

Kata extendskuncinya adalah jahat; mungkin tidak setingkat Charles Manson, tapi cukup buruk sehingga harus dijauhi bila memungkinkan. Buku The Gang of Four Design Patterns membahas secara panjang lebar menggantikan implementasi inheritance ( extends) dengan interface inheritance ( implements).

Desainer yang baik menulis sebagian besar kode mereka dalam kaitannya dengan antarmuka, bukan kelas dasar konkret. Artikel ini menjelaskan mengapa desainer memiliki kebiasaan aneh, dan juga memperkenalkan beberapa dasar pemrograman berbasis antarmuka.

Antarmuka versus kelas

Saya pernah menghadiri pertemuan kelompok pengguna Java di mana James Gosling (penemu Java) menjadi pembicara utama. Selama sesi Tanya Jawab yang tak terlupakan, seseorang bertanya kepadanya: "Jika Anda dapat menggunakan Java lagi, apa yang akan Anda ubah?" "Aku akan meninggalkan kelas," jawabnya. Setelah tawa mereda, dia menjelaskan bahwa masalah sebenarnya bukanlah kelas itu sendiri, melainkan warisan implementasi ( extendshubungan). Pewarisan antarmuka ( implementshubungan) lebih disukai. Anda harus menghindari implementasi inheritance jika memungkinkan.

Kehilangan fleksibilitas

Mengapa Anda harus menghindari implementasi inheritance? Masalah pertama adalah bahwa penggunaan nama kelas konkret secara eksplisit mengunci Anda pada implementasi tertentu, membuat perubahan down-the-line menjadi sulit.

Inti dari metodologi pengembangan Agile kontemporer adalah konsep desain dan pengembangan paralel. Anda memulai pemrograman sebelum Anda menentukan program sepenuhnya. Teknik ini berlawanan dengan kearifan tradisional — bahwa desain harus diselesaikan sebelum pemrograman dimulai — tetapi banyak proyek yang berhasil telah membuktikan bahwa Anda dapat mengembangkan kode berkualitas tinggi lebih cepat (dan hemat biaya) dengan cara ini daripada dengan pendekatan pipelined tradisional. Inti dari pengembangan paralel, bagaimanapun, adalah gagasan tentang fleksibilitas. Anda harus menulis kode Anda sedemikian rupa sehingga Anda dapat memasukkan persyaratan yang baru ditemukan ke dalam kode yang ada semudah mungkin.

Daripada mengimplementasikan fitur yang mungkin Anda perlukan, Anda hanya mengimplementasikan fitur yang benar - benar Anda perlukan, tetapi dengan cara yang mengakomodasi perubahan. Jika Anda tidak memiliki fleksibilitas ini, pengembangan paralel tidak mungkin dilakukan.

Pemrograman ke antarmuka merupakan inti dari struktur fleksibel. Untuk mengetahui alasannya, mari kita lihat apa yang terjadi jika Anda tidak menggunakannya. Perhatikan kode berikut:

f () {LinkedList list = new LinkedList (); // ... g (daftar); } g (daftar LinkedList) {list.add (...); g2 (daftar)}

Sekarang misalkan persyaratan baru untuk pencarian cepat telah muncul, jadi LinkedListtidak berhasil. Anda perlu menggantinya dengan file HashSet. Dalam kode yang ada, perubahan itu tidak dilokalkan karena Anda harus memodifikasi tidak hanya f()tetapi juga g()(yang membutuhkan LinkedListargumen), dan apa pun g()meneruskan daftar ke.

Menulis ulang kode seperti ini:

f () {Collection list = new LinkedList (); // ... g (daftar); } g (Daftar koleksi) {list.add (...); g2 (daftar)}

memungkinkan untuk mengubah daftar tertaut ke tabel hash hanya dengan mengganti new LinkedList()dengan a new HashSet(). Itu dia. Tidak ada perubahan lain yang diperlukan.

Sebagai contoh lain, bandingkan kode ini:

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

untuk ini:

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

The g2()Metode sekarang dapat melintasi Collectionturunan serta daftar kunci dan nilai yang Anda bisa dapatkan dari Map. Faktanya, Anda dapat menulis iterator yang menghasilkan data alih-alih melintasi koleksi. Anda dapat menulis iterator yang memasukkan informasi dari scaffold uji atau file ke program. Ada fleksibilitas luar biasa di sini.

Kopel

Masalah yang lebih penting dengan pewarisan implementasi adalah penggandengan — ketergantungan yang tidak diinginkan dari satu bagian program pada bagian lain. Variabel global memberikan contoh klasik mengapa kopling kuat menyebabkan masalah. Jika Anda mengubah jenis variabel global, misalnya, semua fungsi yang menggunakan variabel (yaitu, digabungkan ke variabel) mungkin terpengaruh, jadi semua kode ini harus diperiksa, dimodifikasi, dan diuji ulang. Selain itu, semua fungsi yang menggunakan variabel digabungkan satu sama lain melalui variabel. Artinya, satu fungsi mungkin salah memengaruhi perilaku fungsi lain jika nilai variabel diubah pada waktu yang tidak tepat. Masalah ini sangat mengerikan dalam program multithread.

Sebagai seorang desainer, Anda harus berusaha untuk meminimalkan hubungan kopel. Anda tidak dapat menghilangkan penggandengan sama sekali karena pemanggilan metode dari objek satu kelas ke objek kelas lain adalah bentuk kopling longgar. Anda tidak dapat memiliki program tanpa kopling. Meskipun demikian, Anda dapat meminimalkan penggandengan secara signifikan dengan mengikuti ajaran OO (berorientasi objek) secara sembarangan (yang paling penting adalah bahwa implementasi suatu objek harus sepenuhnya disembunyikan dari objek yang menggunakannya). Misalnya, variabel instan objek (bidang anggota yang bukan konstanta), harus selalu private. Titik. Tidak ada pengecualian. Pernah. Saya sungguh-sungguh. (Anda terkadang dapat menggunakan protectedmetode secara efektif, tetapiprotected Variabel instance adalah kekejian.) Anda tidak boleh menggunakan fungsi get / set untuk alasan yang sama — itu hanya cara yang terlalu rumit untuk membuat bidang menjadi publik (meskipun fungsi akses yang mengembalikan objek full-blown daripada nilai tipe dasar adalah wajar dalam situasi di mana kelas objek yang dikembalikan adalah abstraksi kunci dalam desain).

Saya tidak terlalu bertele-tele di sini. Saya telah menemukan korelasi langsung dalam pekerjaan saya sendiri antara ketatnya pendekatan OO saya, pengembangan kode cepat, dan pemeliharaan kode yang mudah. Setiap kali saya melanggar prinsip OO sentral seperti implementasi bersembunyi, saya akhirnya menulis ulang kode itu (biasanya karena kode tidak mungkin untuk di-debug). Saya tidak punya waktu untuk menulis ulang program, jadi saya mengikuti aturan. Perhatian saya sepenuhnya praktis — saya tidak tertarik pada kemurnian demi kemurnian.

Masalah kelas dasar yang rapuh

Sekarang, mari terapkan konsep penggandengan ke pewarisan. Dalam sistem pewarisan-implementasi yang menggunakan extends, kelas turunan sangat erat digabungkan dengan kelas dasar, dan hubungan dekat ini tidak diinginkan. Desainer telah menerapkan julukan "the fragile base-class problem" untuk menggambarkan perilaku ini. Kelas dasar dianggap rapuh karena Anda bisa memodifikasi kelas dasar dengan cara yang tampaknya aman, tetapi perilaku baru ini, ketika diwarisi oleh kelas turunan, dapat menyebabkan kelas turunan tidak berfungsi. Anda tidak dapat mengetahui apakah perubahan kelas dasar aman hanya dengan memeriksa metode kelas dasar secara terpisah; Anda harus melihat (dan menguji) semua kelas turunan juga. Selain itu, Anda harus memeriksa semua kode yang menggunakan kelas dasar danobjek kelas turunan juga, karena kode ini mungkin juga rusak oleh perilaku baru. Perubahan sederhana ke kelas basis kunci dapat membuat seluruh program tidak dapat dioperasikan.

Mari kita periksa masalah penggandengan kelas dasar dan kelas dasar yang rapuh bersama-sama. Kelas berikut memperluas kelas Java ArrayListuntuk membuatnya berperilaku seperti tumpukan:

class Stack extends ArrayList {private int stack_pointer = 0; public void push (Artikel objek) {add (stack_pointer ++, article); } Objek publik pop () {return remove (--stack_pointer); } public void push_many (Object [] artikel) {untuk (int i = 0; i <articles.length; ++ i) push (artikel [i]); }}

Bahkan kelas sesederhana ini memiliki masalah. Pertimbangkan apa yang terjadi ketika pengguna memanfaatkan warisan dan menggunakan metode ArrayList's clear()untuk mengeluarkan semuanya dari tumpukan:

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

Kode berhasil dikompilasi, tetapi karena kelas dasar tidak tahu apa-apa tentang penunjuk tumpukan, Stackobjek tersebut sekarang dalam keadaan tidak ditentukan. Panggilan berikutnya untuk push()menempatkan item baru pada indeks 2 (nilai stack_pointersaat ini), sehingga tumpukan secara efektif memiliki tiga elemen di atasnya — dua yang terbawah adalah sampah. ( StackKelas Java memiliki masalah ini; jangan gunakan.)

Salah satu solusi untuk masalah pewarisan metode yang tidak diinginkan adalah untuk Stackmenimpa semua ArrayListmetode yang dapat mengubah status array, jadi timpaannya memanipulasi penunjuk tumpukan dengan benar atau membuat pengecualian. ( removeRange()Metode ini adalah kandidat yang baik untuk membuat pengecualian.)

Pendekatan ini memiliki dua kelemahan. Pertama, jika Anda menimpa semuanya, kelas dasar harus benar-benar berupa antarmuka, bukan kelas. Tidak ada gunanya menerapkan pewarisan jika Anda tidak menggunakan salah satu metode yang diwariskan. Kedua, dan yang lebih penting, Anda tidak ingin tumpukan mendukung semua ArrayListmetode. removeRange()Metode sial itu tidak berguna, misalnya. Satu-satunya cara yang masuk akal untuk mengimplementasikan metode yang tidak berguna adalah membuatnya mengeluarkan pengecualian, karena metode itu tidak boleh dipanggil. Pendekatan ini secara efektif memindahkan apa yang akan menjadi kesalahan waktu kompilasi menjadi waktu proses. Tidak baik. Jika metode tersebut tidak dideklarasikan, kompilator akan mengeluarkan kesalahan metode-tidak-ditemukan. Jika metodenya ada di sana tetapi mengeluarkan pengecualian, Anda tidak akan mengetahui tentang panggilan tersebut sampai program benar-benar berjalan.

Solusi yang lebih baik untuk masalah kelas dasar merangkum struktur data daripada menggunakan warisan. Berikut adalah versi yang baru dan lebih baik dari Stack:

class Stack {private int stack_pointer = 0; private ArrayList the_data = new ArrayList (); public void push (Artikel objek) {the_data.add (stack_pointer ++, article); } Objek publik pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] artikel) {untuk (int i = 0; i <o.length; ++ i) push (artikel [i]); }}

Sejauh ini bagus, tapi pertimbangkan masalah kelas dasar yang rapuh. Misalnya Anda ingin membuat varian Stackyang melacak ukuran tumpukan maksimum selama jangka waktu tertentu. Salah satu penerapan yang mungkin terlihat seperti ini:

class Monitorable_stack extends Stack {private int high_water_mark = 0; private int current_size; public void push (Artikel objek) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (artikel); } Objek publik pop () {--current_size; kembali super.pop (); } public int maximum_size_so_far () {return high_water_mark; }}

Kelas baru ini bekerja dengan baik, setidaknya untuk sementara. Sayangnya, kode mengeksploitasi fakta yang push_many()melakukan tugasnya dengan menelepon push(). Pada awalnya, detail ini sepertinya bukan pilihan yang buruk. Ini menyederhanakan kode, dan Anda mendapatkan versi kelas turunan dari push(), bahkan ketika Monitorable_stackdiakses melalui Stackreferensi, jadi high_water_markpembaruan dengan benar.

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 bahwa Anda tidak memiliki masalah ini jika Anda menggunakan pewarisan antarmuka, karena tidak ada fungsi yang diwariskan yang merugikan Anda. Jika Stacksebuah antarmuka, diimplementasikan oleh a Simple_stackdan a Monitorable_stack, maka kodenya jauh lebih kuat.