Bagaimana mendefinisikan bahwa suatu metode dapat ditimpa komitmen yang lebih kuat daripada mendefinisikan bahwa suatu metode dapat dipanggil?

36

Dari: http://www.artima.com/lejava/articles/designprinciples4.html

Erich Gamma: Saya masih berpikir itu benar bahkan setelah sepuluh tahun. Warisan adalah cara yang keren untuk mengubah perilaku. Tetapi kita tahu bahwa itu rapuh, karena subclass dapat dengan mudah membuat asumsi tentang konteks di mana metode yang ditimpa dipanggil. Ada hubungan erat antara kelas dasar dan subkelas, karena konteks implisit di mana kode subkelas yang saya pasang akan dipanggil. Komposisi memiliki sifat yang lebih bagus. Kopling dikurangi dengan hanya memiliki beberapa hal yang lebih kecil yang Anda tancapkan ke sesuatu yang lebih besar, dan objek yang lebih besar hanya memanggil objek yang lebih kecil kembali. Dari sudut pandang API, mendefinisikan bahwa suatu metode dapat diganti adalah komitmen yang lebih kuat daripada mendefinisikan bahwa suatu metode dapat dipanggil.

Saya tidak mengerti apa maksudnya. Adakah yang bisa menjelaskannya?

q126y
sumber

Jawaban:

63

Komitmen adalah sesuatu yang mengurangi pilihan masa depan Anda. Menerbitkan metode menyiratkan bahwa pengguna akan memanggilnya, karena itu Anda tidak dapat menghapus metode ini tanpa merusak kompatibilitas. Jika Anda menyimpannya private, mereka tidak dapat (langsung) menyebutnya, dan suatu hari Anda dapat mengubahnya tanpa masalah. Oleh karena itu, menerbitkan metode adalah komitmen yang lebih kuat daripada tidak menerbitkannya. Menerbitkan metode yang dapat ditimpa adalah komitmen yang bahkan lebih kuat. Pengguna Anda dapat menyebutnya, dan mereka dapat membuat kelas baru di mana metode ini tidak melakukan apa yang Anda pikirkan!

Misalnya, jika Anda mempublikasikan metode pembersihan, Anda dapat memastikan bahwa sumber daya didemokasi dengan benar selama pengguna ingat untuk memanggil metode ini sebagai hal terakhir yang mereka lakukan. Tetapi jika metode ini dapat ditimpa, seseorang mungkin menimpanya dalam subkelas dan tidak menelepon super. Akibatnya, pengguna ketiga mungkin menggunakan kelas itu dan menyebabkan kebocoran sumber daya meskipun mereka dengan patuh menelepon cleanup()pada akhirnya ! Ini berarti bahwa Anda tidak dapat lagi menjamin semantik kode Anda, yang merupakan hal yang sangat buruk.

Pada dasarnya, Anda tidak bisa lagi mengandalkan kode apa pun yang berjalan dalam metode yang dapat ditimpa pengguna, karena beberapa perantara mungkin menimpanya. Ini berarti bahwa Anda harus menerapkan rutinitas pembersihan Anda sepenuhnya dalam privatemetode, tanpa bantuan dari pengguna. Oleh karena itu, biasanya ide yang baik untuk hanya mempublikasikan finalelemen kecuali elemen tersebut secara eksplisit dimaksudkan untuk diganti oleh pengguna API.

Kilian Foth
sumber
11
Ini mungkin argumen terbaik melawan pewarisan yang pernah saya baca. Dari semua alasan yang menentang yang saya temui, saya belum pernah menemukan dua argumen ini sebelumnya (menggabungkan dan menghancurkan fungsi melalui penggantian), namun keduanya adalah argumen yang sangat kuat terhadap warisan.
David Arno
5
@ DavidArno Saya tidak berpikir itu argumen melawan warisan. Saya pikir ini adalah argumen yang menentang "membuat semuanya dapat ditimpa secara default". Warisan tidak berbahaya dengan sendirinya, menggunakannya tanpa pikiran adalah.
svick
15
Meskipun ini terdengar seperti poin yang bagus, saya tidak dapat benar-benar melihat bagaimana "pengguna dapat menambahkan kode kereta sendiri" sebagai argumen. Mengaktifkan warisan memungkinkan pengguna untuk menambahkan fungsionalitas yang kurang tanpa kehilangan pembaruan, suatu langkah yang dapat mencegah dan memperbaiki bug. Jika kode pengguna di atas API Anda rusak, itu bukan cacat API.
Sebb
4
Anda dapat dengan mudah mengubah argumen itu menjadi: pembuat kode pertama membuat argumen pembersihan, tetapi membuat kesalahan dan tidak membersihkan semuanya. Kode kedua menggantikan metode pembersihan dan melakukan pekerjaan dengan baik, dan kode # 3 menggunakan kelas dan tidak memiliki kebocoran sumber daya meskipun kode # 1 membuatnya berantakan.
Pieter B
6
@Doval Memang. Itulah sebabnya itu adalah parodi bahwa warisan adalah pelajaran nomor satu di hampir setiap buku dan kelas OOP pengantar.
Kevin Krumwiede 6-15
30

Jika Anda menerbitkan fungsi normal, Anda memberikan kontrak satu sisi:
Apa fungsi jika dipanggil?

Jika Anda menerbitkan panggilan balik, Anda juga memberikan kontrak satu sisi:
Kapan dan bagaimana itu akan dipanggil?

Dan jika Anda menerbitkan fungsi yang tidak dapat ditimpa, keduanya sekaligus, jadi Anda memberikan kontrak dua sisi:
Kapan akan dipanggil, dan apa yang harus dilakukan jika dipanggil?

Bahkan jika pengguna tidak menyalahgunakan API Anda (dengan melanggar mereka bagian dari kontrak, yang mungkin mahal untuk mendeteksi), Anda dapat dengan mudah melihat bahwa kebutuhan kedua jauh lebih dokumentasi, dan segala sesuatu yang Anda dokumen adalah komitmen, yang batas pilihan Anda selanjutnya.

Contoh pengingkaran pada kontrak dua sisi tersebut adalah perpindahan dari showdan hideke setVisible(boolean)dalam java.awt.Component .

Deduplicator
sumber
+1. Saya tidak yakin mengapa jawaban lain diterima; itu membuat beberapa poin menarik, tapi itu jelas bukan jawaban yang tepat untuk pertanyaan ini , karena itu jelas bukan apa yang dimaksud dengan kutipan yang dikutip.
ruakh
Ini jawaban yang benar, tapi saya tidak mengerti contohnya. Mengganti show dan hide dengan setVisible (boolean) tampaknya memecah kode yang tidak menggunakan pewarisan juga. Apakah saya melewatkan sesuatu?
eigensheep
3
@eigensheep: showdan hidemasih ada, mereka hanya @Deprecated. Jadi perubahan tidak merusak kode apa pun yang hanya memanggil mereka. Tetapi jika Anda menimpa mereka, penggantian Anda tidak akan dipanggil oleh klien yang bermigrasi ke 'setVisible' yang baru. (Saya tidak pernah menggunakan Swing, jadi saya tidak tahu seberapa umum menimpanya; tetapi karena itu sudah terjadi sejak lama, saya membayangkan alasan Deduplicator mengingatnya adalah karena itu menggigitnya dengan menyakitkan.)
ruakh
12

Jawaban Kilian Foth sangat bagus. Saya hanya ingin menambahkan contoh kanonik * mengapa ini menjadi masalah. Bayangkan kelas Point integer:

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);
    }
}

Sekarang mari kita sub-kelas menjadi titik 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);
    }
}

Sangat sederhana! Mari kita gunakan poin kami:

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

Anda mungkin bertanya-tanya mengapa saya memposting contoh yang begitu mudah. Inilah tangkapannya:

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

Ketika kita membandingkan titik 2D ke titik 3D yang setara, kita menjadi benar, tetapi ketika kita membalikkan perbandingan, kita menjadi salah (karena p2a gagal instanceof Point3D).

Kesimpulan

  1. Biasanya dimungkinkan untuk mengimplementasikan metode dalam subclass sedemikian rupa sehingga tidak lagi kompatibel dengan bagaimana kelas-super mengharapkannya bekerja.

  2. Secara umum tidak mungkin untuk menerapkan equals () pada subclass yang sangat berbeda dengan cara yang kompatibel dengan kelas induknya.

Ketika Anda menulis sebuah kelas yang ingin Anda izinkan orang lain untuk membuat subkelas, itu ide yang sangat bagus untuk menulis kontrak untuk bagaimana masing-masing metode harus berperilaku. Yang lebih baik lagi adalah serangkaian unit test yang dapat dijalankan oleh orang-orang terhadap penerapan metode yang diganti untuk membuktikan bahwa mereka tidak melanggar kontrak. Hampir tidak ada yang melakukan itu karena terlalu banyak pekerjaan. Tetapi jika Anda peduli, itulah yang harus dilakukan.

Contoh bagus dari kontrak yang dieja dengan baik adalah Comparator . Abaikan saja apa yang dikatakannya .equals()karena alasan yang dijelaskan di atas. Berikut adalah contoh tentang bagaimana Pembanding dapat melakukan hal-hal yang .equals()tidak bisa .

Catatan

  1. "Java Efektif" Item 8 dari Josh Bloch adalah sumber dari contoh ini, tetapi Bloch menggunakan ColorPoint yang menambahkan warna alih-alih sumbu ketiga dan menggunakan ganda sebagai ganti int. Contoh Java Bloch pada dasarnya digandakan oleh Odersky / Spoon / Venners yang membuat contoh mereka tersedia secara online.

  2. Beberapa orang keberatan dengan contoh ini karena jika Anda membiarkan kelas induk tahu tentang sub-kelas, Anda dapat memperbaiki masalah ini. Itu benar jika ada sejumlah kecil sub-kelas dan jika orang tua tahu tentang mereka semua. Tetapi pertanyaan aslinya adalah tentang membuat API yang akan digunakan orang lain untuk membuat sub-kelas. Dalam hal ini, Anda biasanya tidak dapat memperbarui implementasi induk agar kompatibel dengan sub-kelas.

Bonus

Komparator juga menarik karena bekerja di sekitar masalah penerapan equals () dengan benar. Lebih baik lagi, ini mengikuti pola untuk memperbaiki masalah pewarisan jenis ini: pola desain Strategi. Typeclasses yang membuat orang Haskell dan Scala bersemangat juga merupakan pola Strategi. Warisan tidak buruk atau salah, itu hanya rumit. Untuk bacaan lebih lanjut, bacalah makalah Philip Wadler Bagaimana membuat polimorfisme ad-hoc menjadi lebih sedikit

GlenPeterson
sumber
1
SortedMap dan SortedSet sebenarnya tidak mengubah definisi equalsdari bagaimana Map dan Set mendefinisikannya. Kesetaraan sama sekali mengabaikan urutan, dengan efek yang, misalnya, dua SortedSet dengan elemen yang sama tetapi urutan berbeda masih sama.
user2357112 mendukung Monica
1
@ user2357112 Anda benar dan saya telah menghapus contoh itu. SortedMap.equals () yang kompatibel dengan Peta adalah masalah terpisah yang akan saya keluhkan. SortedMap umumnya O (log2 n) dan HashMap (implementasi kanonik Peta) adalah O (1). Karena itu, Anda hanya akan menggunakan SortedMap jika Anda benar-benar peduli tentang pemesanan. Untuk alasan itu saya percaya bahwa urutan cukup penting untuk menjadi komponen penting dari tes equals () dalam implementasi SortedMap. Mereka tidak boleh berbagi implementasi equals () dengan Map (mereka lakukan melalui AbstractMap di Jawa).
GlenPeterson
3
"Warisan tidak buruk atau salah, itu hanya rumit." Saya mengerti apa yang Anda katakan, tetapi hal-hal rumit biasanya mengarah pada kesalahan, bug, dan masalah. Ketika Anda dapat mencapai hal yang sama (atau hampir semua hal yang sama) dengan cara yang lebih andal, maka cara yang lebih rumit itu buruk.
jpmc26
7
Ini adalah contoh yang mengerikan , Glen. Anda hanya menggunakan warisan dengan cara yang seharusnya tidak digunakan, tidak heran kelas tidak bekerja seperti yang Anda inginkan. Anda melanggar prinsip substitusi Liskov dengan memberikan abstraksi yang salah (titik 2D), tetapi hanya karena warisan buruk dalam contoh yang salah Anda tidak berarti itu buruk secara umum. Meskipun jawaban ini mungkin terlihat masuk akal, itu hanya akan membingungkan orang yang tidak menyadari bahwa itu melanggar aturan pewarisan yang paling mendasar.
Andy
3
ELI5 dari Substituton Principle Liskov mengatakan: Jika sebuah kelas Bmenjadi anak dari sebuah kelas Adan jika Anda membuat instance objek dari sebuah kelas B, Anda harus dapat melemparkan Bobjek kelas ke orang tuanya dan menggunakan API variabel yang dicor tanpa kehilangan detail implementasi dari anak. Anda melanggar aturan dengan menyediakan properti ketiga. Bagaimana Anda berencana untuk mengakses zkoordinat setelah Anda memberikan Point3Dvariabel Point2D, ketika kelas dasar tidak tahu properti seperti itu ada? Jika dengan melemparkan kelas anak ke basisnya Anda melanggar API publik, abstraksi Anda salah.
Andy
4

Enkapsulasi Melemah Warisan

Saat Anda menerbitkan antarmuka dengan warisan yang diizinkan, Anda secara substansial meningkatkan ukuran antarmuka Anda. Setiap metode yang dapat ditimpa dapat diganti dan harus dianggap sebagai panggilan balik yang diberikan kepada konstruktor. Implementasi yang disediakan oleh kelas Anda hanyalah nilai default dari panggilan balik itu. Dengan demikian, beberapa jenis kontrak harus disediakan yang menunjukkan apa harapan pada metode tersebut. Ini jarang terjadi dan merupakan alasan utama mengapa kode berorientasi objek disebut brittle.

Di bawah ini adalah contoh nyata (disederhanakan) dari kerangka koleksi java, milik 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);
        }
    }
}

Jadi apa yang terjadi jika kita mensubklasifikasikan ini?

/** 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);
    }
}

Kami memiliki bug: Kadang-kadang kami menambahkan "dog" dan hashtable mendapatkan entri untuk "dogss". Penyebabnya adalah seseorang yang menyediakan implementasi put yang tidak diharapkan oleh orang yang mendesain kelas Hashtable.

Inheritance Breaks Extensibility

Jika Anda mengizinkan kelas Anda menjadi subkelas, Anda berkomitmen untuk tidak menambahkan metode apa pun ke kelas Anda. Ini bisa dilakukan tanpa merusak apa pun.

Saat Anda menambahkan metode baru ke antarmuka, siapa pun yang telah mewarisi dari kelas Anda perlu menerapkan metode itu.

eigensheep
sumber
3

Jika suatu metode dimaksudkan untuk dipanggil, Anda hanya perlu memastikan itu berfungsi dengan benar. Itu dia. Selesai

Jika suatu metode dirancang untuk diganti, Anda juga harus memikirkan dengan hati-hati tentang ruang lingkup metode ini: jika cakupannya terlalu besar, kelas anak sering kali perlu menyertakan kode copy-paste dari metode induk; jika terlalu kecil, banyak metode harus diganti untuk memiliki fungsi baru yang diinginkan - ini menambah kompleksitas dan jumlah baris yang tidak perlu.

Oleh karena itu pencipta metode induk perlu membuat asumsi tentang bagaimana kelas dan metodenya mungkin ditimpa di masa depan.

Namun, penulis berbicara tentang masalah berbeda dalam teks yang dikutip:

Tetapi kita tahu bahwa itu rapuh, karena subclass dapat dengan mudah membuat asumsi tentang konteks di mana metode yang ditimpa dipanggil.

Pertimbangkan metode ayang biasanya dipanggil dari metode b, tetapi dalam beberapa kasus yang jarang dan tidak jelas dari metode c. Jika penulis metode utama mengabaikan cmetode dan harapannya a, jelas bagaimana hal-hal bisa salah.

Oleh karena itu lebih penting yang adidefinisikan secara jelas dan tidak ambigu, didokumentasikan dengan baik, "melakukan satu hal dan melakukannya dengan baik" - lebih daripada jika itu adalah metode yang hanya dirancang untuk dipanggil.

Hujan
sumber