Warisan adalah salah satu pilar OOP. Warisan digunakan untuk menggunakan kembali kode umum. Tetapi kode umum tidak selalu diperlukan untuk menggunakan kembali, dan pewarisan tidak selalu merupakan cara terbaik untuk menggunakan kembali kode. Sering kali ternyata, sehingga ada kode yang serupa di dua bagian kode yang berbeda (kelas), tetapi persyaratan untuk mereka berbeda, yaitu. kelas sebenarnya mewarisi dari satu sama lain dan mungkin tidak sepadan.
Biasanya, untuk menggambarkan masalah ini, mereka menggunakan contoh tentang mewarisi kelas Kuadrat dari kelas Persegi Panjang, atau sebaliknya.
Mari kita memiliki kelas persegi panjang:
class Rectangle: def __init__(self, width, height): self._width = width self._height = height def set_width(self, width): self._width = width def set_height(self, height): self._height = height def get_area(self): return self._width * self._height ...
Sekarang kami ingin menulis kelas Square, tetapi untuk menggunakan kembali kode perhitungan area, tampaknya logis untuk mewarisi Square dari Rectangle:
class Square(Rectangle): def set_width(self, width): self._width = width self._height = width def set_height(self, height): self._width = height self._height = height
Tampaknya kode kelas Square dan Rectangle konsisten. Tampaknya Square mempertahankan properti matematika dari alun-alun, mis. dan persegi panjang. Itu berarti kita bisa melewati objek Square daripada Rectangle.
Tetapi jika kita melakukan ini, kita bisa melanggar
perilaku kelas Rectangle:
Misalnya, ada kode klien:
def client_code(rect): rect.set_height(10) rect.set_width(20) assert rect.get_area() == 200
Jika Anda melewatkan instance dari kelas Square sebagai argumen untuk fungsi ini, fungsi tersebut akan berperilaku berbeda. Yang merupakan pelanggaran kontrak untuk perilaku kelas Rectangle, karena tindakan dengan objek dari kelas dasar harus memberikan hasil yang persis sama dengan objek dari kelas turunan.
Jika kelas kuadrat adalah turunan dari kelas persegi panjang, maka bekerja dengan kuadrat dan melakukan metode persegi panjang, kita bahkan tidak harus memperhatikan bahwa itu bukan persegi panjang.
Anda dapat memperbaiki masalah ini, misalnya, seperti ini:
- membuat pernyataan untuk mencocokkan kelas secara tepat, atau membuat jika itu akan bekerja secara berbeda untuk kelas yang berbeda
- di Square, buat metode set_size () dan timpa metode set_height, set_width sehingga mereka membuang pengecualian
dll
Kode dan kelas semacam itu akan berfungsi, dalam arti bahwa kode tersebut akan berfungsi.
Pertanyaan lain adalah bahwa kode klien yang menggunakan kelas Square atau kelas Rectangle perlu tahu baik tentang kelas dasar dan perilakunya, atau tentang kelas turunan dan perilakunya.
Seiring waktu, kita bisa mendapatkan itu:
- kelas turunan akan menimpa sebagian besar metode
- refactoring atau menambahkan metode ke kelas dasar akan memecah kode menggunakan keturunan
- dalam kode yang menggunakan objek dari kelas dasar akan ada seandainya, memeriksa kelas objek, dan perilaku untuk keturunan dan kelas dasar berbeda
Ternyata kode klien yang ditulis untuk kelas dasar menjadi tergantung pada implementasi kelas dasar dan kelas turunan. Yang sangat mempersulit pengembangan seiring waktu. Dan OOP dibuat agar Anda dapat mengedit kelas dasar dan kelas turunan secara independen satu sama lain.
Kembali di tahun 80-an abad terakhir, kami memperhatikan bahwa agar warisan kelas berfungsi dengan baik untuk penggunaan kembali kode, kita harus tahu pasti bahwa kelas turunan dapat digunakan sebagai pengganti kelas dasar. Yaitu semantik warisan - ini seharusnya tidak hanya dan tidak banyak data sebagai perilaku. Ahli waris tidak boleh "mematahkan" perilaku kelas dasar.
Sebenarnya, ini adalah
prinsip substitusi Lisk atau prinsip menentukan subtipe berdasarkan pada perilaku mengetik kelas yang kuat:
jika Anda dapat menulis setidaknya beberapa kode yang bermakna di mana mengganti objek kelas dasar dengan objek kelas turunan, itu akan pecah, maka itu tidak layak mewarisi mereka dari satu sama lain. Kita harus memperluas perilaku kelas dasar pada keturunan, dan tidak mengubahnya secara signifikan. Fungsi yang menggunakan kelas dasar harus dapat menggunakan objek subclass tanpa menyadarinya. Bahkan, ini adalah semantik warisan di OOP.
Dan dalam kode industri nyata, sangat dianjurkan agar prinsip ini diikuti dan dipatuhi oleh semantik warisan yang dijelaskan. Dan dengan prinsip ini ada beberapa kehalusan.
Prinsipnya harus dipenuhi bukan dengan abstraksi dari level domain, tetapi dengan abstraksi kode - kelas. Dari sudut pandang geometris, kotak adalah persegi panjang. Dari sudut pandang hirarki warisan kelas, apakah kelas persegi akan menjadi pewaris kelas persegi panjang tergantung pada perilaku yang kita butuhkan dari kelas-kelas ini. Bergantung pada bagaimana dan dalam situasi apa kita menggunakan kode ini.
Jika kelas Rectangle hanya memiliki dua metode - menghitung area dan rendering, tanpa kemungkinan menggambar ulang dan mengubah ukuran, maka dalam kasus ini Square dengan konstruktor yang diganti akan memenuhi prinsip penggantian Liskov.
Yaitu kelas-kelas tersebut memenuhi prinsip substitusi:
class Rectangle: def draw(): ... def get_area(): ... class Square(Rectangle): pass
Meskipun tentu saja ini bukan kode yang sangat baik, dan bahkan, mungkin, antipattern dari perancangan kelas, tetapi dari sudut pandang formal ia memenuhi prinsip Liskov.
Contoh lain. Satu set adalah subtipe dari multiset. Ini adalah rasio abstraksi domain. Tetapi kode dapat ditulis sehingga kita mewarisi kelas Set dari Bag dan prinsip substitusi dilanggar, atau kita dapat menulis sehingga prinsip tersebut dihormati. Dengan semantik domain subjek yang sama.
Secara umum, pewarisan kelas dapat dianggap sebagai implementasi dari hubungan βISβ, tetapi tidak antara entitas dari area subjek, tetapi antara kelas. Dan apakah kelas turunan adalah subtipe dari kelas dasar ditentukan oleh batasan dan kontrak perilaku kelas apa yang digunakan kode klien (dan pada prinsipnya dapat digunakan).
Kendala, invarian, kontrak kelas dasar tidak diperbaiki dalam kode, tetapi diperbaiki di kepala pengembang yang mengedit dan membaca kode. Apa itu "melanggar", apa yang melanggar "kontrak" ditentukan bukan oleh kode, tetapi oleh semantik dari kelas di kepala pengembang.
Kode apa pun yang bermakna untuk objek kelas dasar tidak boleh rusak jika kita menggantinya dengan objek kelas turunan. Kode yang berarti adalah kode klien apa pun yang menggunakan objek kelas dasar (dan turunannya) dalam kerangka semantik dan batasan kelas dasar.
Apa yang sangat penting untuk dipahami adalah bahwa keterbatasan abstraksi yang diterapkan di kelas dasar biasanya tidak terkandung dalam kode program. Pembatasan ini dipahami, diketahui, dan didukung oleh pengembang. Ini memantau konsistensi abstraksi dan kode. Agar kode menyatakan apa artinya.
Misalnya, persegi panjang memiliki metode lain yang mengembalikan tampilan dalam json
class Rectangle: def to_dict(self): return {"height": self.height, "width": self.width}
Dan di Square, kita mendefinisikan kembali:
class Square: def to_dict(self): return {"size": self.height}
Jika kita menganggap kontrak dasar untuk perilaku kelas Rectangle to_json memiliki tinggi dan lebar, maka kodenya
r = rect.to_dict() log(r['height'], r['width'])
akan bermakna untuk objek Rectangle kelas dasar. Saat mengganti objek kelas dasar dengan kelas, kode pewaris kuadrat mengubah perilakunya dan melanggar kontrak, dan dengan demikian melanggar prinsip substitusi Lisk.
Jika kami percaya bahwa kontrak dasar untuk perilaku kelas Rectangle adalah to_dict mengembalikan kamus yang dapat diserialisasi tanpa meletakkan bidang tertentu, maka metode to_dict seperti itu akan baik-baik saja.
Ngomong-ngomong, ini adalah contoh yang baik, menghancurkan mitos bahwa kekekalan menyelamatkan dari pelanggaran prinsip.
Secara formal, pengubahan metode apa pun di kelas turunan berbahaya, juga perubahan logika di kelas dasar. Sebagai contoh, cukup sering kelas turunan beradaptasi dengan perilaku "salah" dari kelas dasar, dan ketika bug diperbaiki di kelas dasar, mereka rusak.
Dimungkinkan untuk mentransfer semua kondisi kontrak dan invarian ke kode sebanyak mungkin, tetapi dalam kasus umum, semantik perilaku semua berada di luar kode yang sama - di area masalah dan didukung oleh pengembang. Contoh tentang to_dict adalah contoh di mana kontrak dapat dijelaskan dalam kode, tetapi misalnya, untuk memverifikasi bahwa metode get_hash benar-benar mengembalikan hash dengan semua properti hash, dan bukan hanya garis, tidak mungkin.
Ketika seorang pengembang menggunakan kode yang ditulis oleh pengembang lain, ia dapat memahami apa semantik suatu kelas hanya secara langsung berdasarkan kode, nama metode, dokumentasi, dan komentar. Tetapi bagaimanapun juga, semantik seringkali merupakan domain manusia, dan karenanya salah. Konsekuensi yang paling penting: hanya dengan kode - secara sintaksis - apakah tidak mungkin untuk memverifikasi kepatuhan dengan prinsip Liskov, dan Anda perlu mengandalkan (sering) semantik yang tidak jelas. Tidak ada cara formal (matematis) dari cara yang dapat diverifikasi dan dijamin untuk memverifikasi pengetikan perilaku yang kuat.
Oleh karena itu, sering daripada prinsip Liskov, aturan formal untuk prasyarat dan postkondisi dari pemrograman kontrak digunakan:
- prasyarat dalam subkelas tidak dapat diperkuat - subkelas seharusnya tidak memerlukan lebih dari kelas dasar
- postconditions dari subclass tidak dapat dilonggarkan - subclass tidak boleh memberikan (janji) kurang dari kelas dasar
- invarian dari kelas dasar harus dipertahankan dalam kelas turunan.
Misalnya, dalam metode kelas turunan, kami tidak dapat menambahkan parameter yang diperlukan yang tidak ada di kelas dasar - karena ini adalah cara kami memperkuat prasyarat. Atau kita tidak bisa melempar pengecualian dalam metode yang diganti, karena melanggar invarian kelas dasar. Dll
Yang penting bukanlah perilaku kelas saat ini, tetapi perubahan kelas apa yang menyiratkan tanggung jawab atau semantik kelas.
Kode ini terus diperbaiki dan diubah. Karenanya, jika sekarang kode memenuhi prinsip substitusi, ini tidak berarti bahwa perubahan dalam kode tidak akan mengubah ini.
Katakanlah ada pengembang kelas perpustakaan Rectangle, dan pengembang aplikasi yang mewarisi Square dari Rectangle. Pada saat pengembang aplikasi mewarisi Square dari Rectangle - semuanya baik-baik saja, kelas memenuhi prinsip substitusi.
Dan pada titik tertentu, pengembang yang bertanggung jawab atas perpustakaan menambahkan metode membentuk kembali atau set_width / set_height ke kelas dasar Rectangle. Dari sudut pandangnya, perpanjangan kelas dasar baru saja terjadi. Namun pada kenyataannya, ada perubahan dalam semantik dan kontrak yang menjadi sandaran kelas keturunan. Sekarang kelas tidak lagi memenuhi prinsip.
Secara umum, ketika mewarisi dalam OOP, perubahan di kelas dasar yang terlihat seperti perpanjangan antarmuka - metode atau bidang lain akan ditambahkan dapat melanggar kontrak "alami" sebelumnya, dan dengan demikian benar-benar mengubah semantik atau tanggung jawab. Oleh karena itu, menambahkan metode apa pun ke kelas dasar berbahaya. Anda dapat secara tidak sengaja mengubah kontrak.
Dan dari sudut pandang praktis, dalam contoh dengan persegi panjang dan kelas, penting apakah sekarang ada membentuk kembali atau metode set_width / set_height. Dari sudut pandang praktis, penting seberapa tinggi kemungkinan perubahan tersebut dalam kode perpustakaan. Apakah semantik atau batasan tanggung jawab kelas menyiratkan perubahan tersebut. Jika tersirat, maka kemungkinan kesalahan dan / atau kebutuhan lebih lanjut untuk refactoring meningkat secara signifikan. Dan jika ada kemungkinan kecil, mungkin lebih baik untuk tidak mewarisi kelas-kelas tersebut dari satu sama lain.
Mempertahankan definisi subtipe berdasarkan perilaku sulit, bahkan untuk kelas sederhana dengan semantik yang jelas , untuk mengatakan apa-apa tentang perusahaan dengan logika bisnis yang kompleks. Terlepas dari kenyataan bahwa kelas dasar dan kelas pewaris adalah bagian kode yang berbeda, bagi mereka Anda perlu memikirkan antarmuka dan tanggung jawab dengan cermat dan hati-hati. Dan bahkan dengan sedikit perubahan dalam semantik kelas - yang tidak dapat dihindari dengan cara apa pun, kita harus melihat kode kelas terkait, memeriksa untuk melihat apakah kontrak baru atau invarian melanggar apa yang sudah ditulis (!) Dan digunakan. Dengan hampir semua perubahan dalam hierarki kelas bercabang, kita perlu melihat dan memeriksa banyak kode lainnya.
Ini adalah salah satu alasan mengapa beberapa orang tidak begitu menyukai warisan klasik di OOP. Dan karena itu, mereka sering memilih komposisi kelas, pewarisan antarmuka, dll., Dll. bukannya warisan klasik perilaku.
Dalam keadilan, ada beberapa aturan yang kemungkinan besar tidak melanggar prinsip substitusi. Anda dapat melindungi diri sendiri sebanyak mungkin jika Anda melarang semua struktur berbahaya. Misalnya, untuk C ++,
Oleg menulis tentang ini. Tetapi secara umum, aturan seperti itu tidak mengubah kelas menjadi kelas dalam arti klasik.
Menggunakan metode administratif, tugasnya juga tidak diselesaikan dengan sangat baik.
Di sini Anda dapat membaca bagaimana Paman Martin melakukannya di C ++ dan bagaimana itu tidak berhasil.
Tetapi dalam kode industri yang sebenarnya, cukup sering, prinsip Liskov dilanggar, dan ini tidak menakutkan . Sulit untuk mengikuti prinsip, karena 1) tanggung jawab dan semantik suatu kelas sering tidak eksplisit dan tidak dinyatakan dalam kode 2) tanggung jawab kelas dapat berubah - baik di kelas dasar dan di kelas turunan. Tetapi ini tidak selalu mengarah pada beberapa konsekuensi yang sangat mengerikan. Pelanggaran yang paling umum, paling sederhana, dan paling mendasar adalah bahwa metode yang diganti mengubah perilaku. Seperti pada contoh di sini:
class Task: def close(self): self.status = CLOSED ... class ProjectTask(Task): def close(self): if status == STARTED: raise Exception("Cannot close a started Project Task") ...
Metode tutup ProjectTask akan mengeluarkan pengecualian dalam kasus di mana objek dari kelas Tugas berfungsi dengan baik. Secara umum, redefinisi metode kelas dasar sangat sering mengarah pada pelanggaran prinsip substitusi, tetapi tidak menjadi masalah.
Bahkan, dalam hal ini, pengembang menganggap warisan TIDAK sebagai implementasi dari hubungan "IS", tetapi hanya sebagai cara untuk menggunakan kembali kode. Yaitu subkelas hanyalah subkelas, bukan subtipe. Dalam hal ini, dari sudut pandang pragmatis dan praktis, itu lebih penting - tetapi apa kemungkinan bahwa akan ada atau sudah ada kode klien yang akan melihat semantik berbeda dari metode kelas turunan dan kelas dasar?
Apakah ada banyak kode yang mengharapkan objek dari kelas dasar, tetapi untuk itu kita meneruskan objek dari kelas turunan? Untuk banyak tugas, kode seperti itu tidak akan pernah ada sama sekali.
Kapan pelanggaran LSP menyebabkan masalah besar? Ketika, karena perbedaan perilaku, kode klien harus ditulis ulang dengan perubahan di kelas turunan dan sebaliknya. Ini menjadi masalah terutama jika kode klien ini adalah kode perpustakaan yang tidak dapat diubah. Jika menggunakan kembali kode tidak akan dapat membuat dependensi antara kode klien dan kode kelas di masa depan, maka bahkan meskipun melanggar prinsip substitusi Liskov, kode seperti itu mungkin tidak menyebabkan masalah besar.
Secara umum, selama pengembangan, pewarisan dapat dipertimbangkan dari dua perspektif: subclass adalah subtipe, dengan semua batasan pemrograman kontrak dan prinsip Lisk, dan subclass adalah cara untuk menggunakan kembali kode, dengan semua potensi masalahnya. Yaitu Anda dapat berpikir dan merancang tanggung jawab dan kontrak kelas dan tidak khawatir tentang kode klien. Entah pikirkan tentang apa kode sisi klien, bagaimana kelas akan digunakan, dan bersiaplah untuk masalah potensial, tetapi pada tingkat yang lebih rendah peduli tentang mengamati prinsip substitusi. Keputusan, seperti biasa, tergantung pada pengembang, yang paling penting adalah bahwa pilihan dalam situasi tertentu adalah sadar dan bahwa ada pemahaman tentang apa pro, kontra dan perangkap yang menyertai solusi ini atau itu.