Metode ekstraksi vs asumsi yang mendasarinya

27

Ketika saya membagi metode besar (atau prosedur, atau fungsi - pertanyaan ini tidak spesifik untuk OOP, tetapi karena saya bekerja dalam bahasa OOP 99% dari waktu, itu adalah terminologi yang paling nyaman bagi saya) menjadi banyak yang kecil , Saya sering merasa tidak senang dengan hasilnya. Menjadi lebih sulit untuk menalar tentang metode-metode kecil ini daripada ketika mereka hanya berupa blok kode dalam metode besar, karena ketika saya mengekstraknya, saya kehilangan banyak asumsi mendasar yang berasal dari konteks pemanggil.

Kemudian, ketika saya melihat kode ini dan melihat metode individual, saya tidak langsung tahu dari mana mereka dipanggil, dan menganggapnya sebagai metode pribadi biasa yang dapat dipanggil dari mana saja dalam file. Misalnya, bayangkan metode inisialisasi (konstruktor atau lainnya) dibagi menjadi serangkaian yang kecil: dalam konteks metode itu sendiri, Anda dengan jelas tahu bahwa keadaan objek masih tidak valid, tetapi dalam metode pribadi biasa Anda mungkin beralih dari asumsi objek itu sudah diinisialisasi dan dalam keadaan valid.

Satu-satunya solusi yang saya lihat untuk ini adalah whereklausa di Haskell, yang memungkinkan Anda untuk mendefinisikan fungsi-fungsi kecil yang hanya digunakan dalam fungsi "induk". Pada dasarnya, ini terlihat seperti ini:

len x y = sqrt $ (sq x) + (sq y)
    where sq a = a * a

Tetapi bahasa lain yang saya gunakan tidak memiliki hal seperti ini - hal terdekat adalah mendefinisikan lambda dalam lingkup lokal, yang mungkin bahkan lebih membingungkan.

Jadi, pertanyaan saya adalah - apakah Anda menemukan ini, dan apakah Anda bahkan melihat ini adalah masalah? Jika Anda melakukannya, bagaimana Anda biasanya menyelesaikannya, khususnya dalam bahasa OOP "arus utama", seperti Java / C # / C ++?

Edit tentang duplikat: Seperti yang diketahui orang lain, sudah ada pertanyaan yang membahas metode pemisahan dan pertanyaan kecil yang bersifat satu baris. Saya membacanya, dan mereka tidak membahas masalah asumsi mendasar yang dapat diturunkan dari konteks penelepon (misalnya di atas, objek diinisialisasi). Itulah inti dari pertanyaan saya, dan itulah mengapa pertanyaan saya berbeda.

Pembaruan: Jika Anda mengikuti pertanyaan dan diskusi di bawah ini, Anda dapat menikmati artikel ini oleh John Carmack tentang masalah ini , khususnya:

Selain kesadaran akan kode aktual yang dieksekusi, fungsi inlining juga bermanfaat karena tidak memungkinkan untuk memanggil fungsi dari tempat lain. Itu kedengarannya konyol, tapi ada benarnya. Sebagai basis kode tumbuh dari tahun ke tahun penggunaan, akan ada banyak peluang untuk mengambil jalan pintas dan hanya memanggil fungsi yang hanya melakukan pekerjaan yang menurut Anda perlu dilakukan. Mungkin ada fungsi FullUpdate () yang memanggil PartialUpdateA (), dan PartialUpdateB (), tetapi dalam beberapa kasus tertentu Anda mungkin menyadari (atau berpikir) bahwa Anda hanya perlu melakukan PartialUpdateB (), dan Anda menjadi efisien dengan menghindari yang lain kerja. Banyak dan banyak bug berasal dari ini. Sebagian besar bug adalah hasil dari kondisi eksekusi yang tidak sesuai dengan apa yang Anda pikirkan.

Max Yankov
sumber
@gnat pertanyaan yang Anda tautkan untuk membahas apakah akan mengekstrak fungsi atau tidak, sementara saya tidak mempertanyakannya. Sebaliknya, saya mempertanyakan metode paling optimal untuk melakukannya.
Max Yankov
2
@gnat ada pertanyaan terkait lainnya yang ditautkan dari sana, tetapi tidak ada yang membahas fakta bahwa kode ini dapat mengandalkan asumsi spesifik yang hanya valid dalam konteks pemanggil.
Max Yankov
1
@Dalam pengalaman saya, itu benar-benar terjadi. Ketika ada metode pembantu yang merepotkan seperti yang Anda gambarkan, mengekstraksi kelas kohesif baru akan menangani hal ini
nyamuk

Jawaban:

29

Misalnya, bayangkan metode inisialisasi dipecah menjadi serangkaian yang kecil: dalam konteks metode itu sendiri, Anda jelas tahu bahwa keadaan objek masih tidak valid, tetapi dalam metode pribadi biasa Anda mungkin beralih dari asumsi bahwa objek sudah diinisialisasi dan sudah dalam keadaan valid. Satu-satunya solusi yang saya lihat untuk ini adalah ...

Kekhawatiran Anda cukup beralasan. Ada solusi lain.

Ambil langkah mundur. Apa yang secara mendasar merupakan tujuan dari suatu metode? Metode hanya melakukan satu dari dua hal:

  • Menghasilkan nilai
  • Menyebabkan efek

Atau, sayangnya, keduanya. Saya mencoba menghindari metode yang melakukan keduanya, tetapi banyak yang melakukannya. Katakanlah efek yang dihasilkan atau nilai yang dihasilkan adalah "hasil" dari metode ini.

Anda perhatikan bahwa metode dipanggil dalam "konteks". Apa konteksnya?

  • Nilai-nilai argumen
  • Keadaan program di luar metode

Pada dasarnya apa yang Anda tunjukkan adalah: kebenaran hasil dari metode tergantung pada konteks di mana ia dipanggil .

Kita sebut kondisi yang diperlukan sebelum tubuh metode dimulai untuk metode untuk menghasilkan hasil yang benar nya prasyarat , dan kita sebut kondisi yang akan diproduksi setelah metode tubuh mengembalikan nya postconditions .

Jadi pada dasarnya apa yang Anda tunjukkan adalah: ketika saya mengekstrak blok kode ke dalam metodenya sendiri, saya kehilangan informasi kontekstual tentang prasyarat dan postkondisi .

Solusi untuk masalah ini adalah membuat prasyarat dan postkondisi eksplisit dalam program . Dalam C #, misalnya, Anda dapat menggunakan Debug.Assertatau Kode Kontrak untuk mengekspresikan prasyarat dan postkondisi.

Sebagai contoh: Saya dulu bekerja pada kompiler yang bergerak melalui beberapa "tahap" kompilasi. Pertama kodenya akan diexex, lalu diurai, lalu jenisnya akan diselesaikan, lalu hierarki pewarisan akan diperiksa untuk siklus, dan seterusnya. Setiap bit kode sangat sensitif terhadap konteksnya; akan menjadi bencana, misalnya, untuk bertanya "apakah jenis ini dapat dikonversi ke jenis itu?" jika grafik tipe dasar belum diketahui asiklik! Jadi karena itu setiap bit kode dengan jelas mendokumentasikan prasyaratnya. Kita akan assertdalam metode yang memeriksa konvertibilitas jenis yang telah kita lewati cek "tipe dasar acylic", dan kemudian menjadi jelas bagi pembaca di mana metode itu bisa dipanggil dan di mana itu tidak bisa dipanggil.

Tentu saja ada banyak cara di mana perancangan metode yang baik mengurangi masalah yang telah Anda identifikasi:

  • membuat metode yang bermanfaat untuk efek atau nilainya tetapi tidak keduanya
  • membuat metode yang semurni mungkin; metode "murni" menghasilkan nilai yang hanya bergantung pada argumennya, dan tidak menghasilkan efek. Ini adalah metode termudah untuk dipikirkan karena "konteks" yang mereka butuhkan sangat lokal.
  • meminimalkan jumlah mutasi yang terjadi dalam keadaan program; mutasi adalah poin di mana kode semakin sulit untuk dipikirkan
Eric Lippert
sumber
+1 sebagai jawaban yang menjelaskan masalah dalam hal prasyarat / postkondisi.
QuestionC
5
Saya akan menambahkan bahwa sering kali mungkin (dan ide yang bagus!) Untuk mendelegasikan pemeriksaan pra-dan pasca-kondisi ke sistem tipe. Jika Anda memiliki fungsi yang mengambil stringdan menyimpannya ke database, Anda berisiko mengalami injeksi SQL jika Anda lupa membersihkannya. Jika, di sisi lain, fungsi Anda membutuhkan SanitisedString, dan satu-satunya cara untuk mendapatkannya SantisiedStringadalah dengan memanggil Sanitise, maka Anda telah mengesampingkan bug injeksi SQL dengan konstruksi. Saya semakin menemukan diri saya mencari cara untuk membuat kompiler menolak kode yang salah.
Benjamin Hodgson
+1 Satu hal yang penting untuk dicatat adalah bahwa ada biaya untuk memecah metode besar menjadi bongkahan yang lebih kecil: biasanya tidak berguna kecuali prasyarat dan postkondisi lebih santai daripada yang seharusnya, dan Anda dapat terpaksa harus bayar biayanya dengan melakukan pengecekan ulang yang seharusnya sudah Anda lakukan. Ini bukan proses refactoring yang sepenuhnya "gratis".
Mehrdad
"Konteks apa itu?" hanya untuk memperjelas, saya sebagian besar berarti keadaan pribadi dari objek yang dipanggil metode ini. Saya kira itu termasuk dalam kategori kedua.
Max Yankov
Ini jawaban yang sangat bagus dan menggugah pikiran, terima kasih. (Bukan untuk mengatakan bahwa jawaban lain dengan cara apa pun buruk, tentu saja). Saya tidak akan menandai pertanyaan sebagai dijawab dulu, karena saya sangat suka diskusi di sini (dan cenderung berhenti ketika jawaban ditandai sebagai dijawab) dan perlu waktu untuk memprosesnya dan memikirkannya.
Max Yankov
13

Saya sering melihat ini, dan setuju bahwa itu masalah. Biasanya saya menyelesaikannya dengan membuat objek metode : kelas khusus baru yang anggotanya adalah variabel lokal dari metode asli, terlalu besar.

Kelas yang baru cenderung memiliki nama seperti 'Eksportir' atau 'Tabulasi', dan akan diberikan informasi apa pun yang diperlukan untuk melakukan tugas tertentu dari konteks yang lebih besar. Maka, bebas menentukan cuplikan kode pembantu yang lebih kecil yang tidak berbahaya digunakan untuk apa pun selain tabulasi atau ekspor.

Kilian Foth
sumber
Saya sangat menyukai ide ini, semakin saya memikirkannya. Ini bisa menjadi kelas privat di dalam kelas publik atau internal. Anda tidak mengacaukan namespace Anda dengan kelas yang hanya Anda pedulikan secara lokal, dan ini adalah cara untuk menandai bahwa ini adalah "konstruktor pembantu" atau "pembantu parse" atau apa pun.
Mike Mendukung Monica
Baru-baru ini saya hanya dalam situasi yang ideal untuk ini dari perspektif arsitektur. Saya menulis renderer perangkat lunak dengan kelas renderer dan metode render publik, yang memiliki BANYAK konteks yang digunakan untuk memanggil metode lain. Saya berpikir untuk membuat kelas RenderContext terpisah untuk ini, namun, sepertinya sangat boros untuk mengalokasikan dan membatalkan alokasi proyek ini setiap frame. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov
6

Banyak bahasa membiarkan Anda membuat fungsi sarang seperti Haskell. Java / C # / C ++ sebenarnya outlier relatif dalam hal itu. Sayangnya, mereka begitu populer bahwa orang-orang datang untuk berpikir, "Ini memiliki menjadi ide yang buruk, jika tidak 'utama' bahasa favorit saya akan mengizinkannya."

Java / C # / C ++ pada dasarnya berpikir kelas harus menjadi satu-satunya pengelompokan metode yang Anda butuhkan. Jika Anda memiliki begitu banyak metode yang tidak dapat Anda tentukan konteksnya, ada dua pendekatan umum yang harus diambil: urutkan berdasarkan konteks, atau bagi berdasarkan konteks.

Menyortir berdasarkan konteks adalah salah satu rekomendasi yang dibuat dalam Kode Bersih , di mana penulis menggambarkan pola "ke paragraf." Ini pada dasarnya menempatkan fungsi penolong Anda segera setelah fungsi yang memanggilnya, sehingga Anda dapat membacanya seperti paragraf di artikel surat kabar, mendapatkan detail lebih lanjut saat Anda membaca. Saya pikir dalam videonya dia bahkan membuat indentasi.

Pendekatan lain adalah dengan membagi kelas Anda. Ini tidak dapat diambil terlalu jauh, karena kebutuhan yang mengganggu untuk membuat instance objek sebelum Anda dapat memanggil metode apa pun pada mereka, dan masalah yang melekat dengan memutuskan mana dari beberapa kelas kecil harus memiliki masing-masing bagian data. Namun, jika Anda sudah mengidentifikasi beberapa metode yang benar-benar hanya cocok dalam satu konteks, mereka mungkin merupakan kandidat yang baik untuk mempertimbangkan untuk memasukkan ke dalam kelas mereka sendiri. Misalnya, inisialisasi yang kompleks dapat dilakukan dalam pola penciptaan seperti pembangun.

Karl Bielefeldt
sumber
Fungsi bersarang ... bukankah itu yang dicapai fungsi lambda di C # (dan Java 8)?
Arturo Torres Sánchez
Saya berpikir lebih seperti penutupan yang didefinisikan dengan nama, seperti contoh python ini . Lambdas bukan cara yang paling jelas untuk melakukan hal seperti itu. Mereka lebih untuk ekspresi pendek seperti predikat filter.
Karl Bielefeldt
Contoh-contoh Python tentu saja mungkin dalam C #. Misalnya faktorial . Mereka mungkin lebih bertele-tele, tetapi mereka 100% mungkin.
Arturo Torres Sánchez
2
Tidak ada yang mengatakan itu tidak mungkin. OP bahkan menyebutkan menggunakan lambdas dalam pertanyaannya. Hanya saja jika Anda mengekstrak metode demi keterbacaan, alangkah baiknya jika lebih mudah dibaca.
Karl Bielefeldt
Paragraf pertama Anda tampaknya mengisyaratkan bahwa itu tidak mungkin, terutama dengan kutipan Anda: "Itu pasti ide yang buruk, kalau tidak bahasa 'mainstream' favorit saya akan memungkinkannya."
Arturo Torres Sánchez
4

Saya pikir jawabannya dalam kebanyakan kasus adalah konteks. Sebagai kode penulisan pengembang, Anda harus menganggap kode Anda akan diubah di masa mendatang. Kelas mungkin diintegrasikan dengan kelas lain, mungkin menggantikannya algoritma internal, atau mungkin dipecah menjadi beberapa kelas untuk membuat abstraksi. Itu adalah hal-hal yang biasanya tidak dipertimbangkan oleh pengembang pemula, menyebabkan kebutuhan untuk penyelesaian yang berantakan atau perbaikan total di kemudian hari.

Metode ekstraksi itu baik, tetapi sampai tingkat tertentu. Saya selalu mencoba bertanya pada diri sendiri pertanyaan-pertanyaan ini ketika memeriksa atau sebelum menulis kode:

  • Apakah kode ini hanya digunakan oleh kelas / fungsi ini? apakah akan tetap sama di masa depan?
  • Jika saya perlu mematikan beberapa implementasi konkret, dapatkah saya melakukannya dengan mudah?
  • Bisakah pengembang lain di tim saya memahami apa yang dilakukan dalam fungsi ini?
  • Apakah kode yang sama digunakan di tempat lain di kelas ini? Anda harus menghindari duplikasi di hampir semua kasus.

Bagaimanapun, selalu pikirkan tanggung jawab tunggal. Kelas harus memiliki satu tanggung jawab, fungsinya harus melayani satu layanan konstan tunggal, dan jika mereka melakukan sejumlah tindakan, tindakan tersebut harus memiliki fungsi sendiri, sehingga mudah untuk membedakan atau mengubahnya nanti.

Tomer Blu
sumber
1

Menjadi lebih sulit untuk menalar tentang metode-metode kecil ini daripada ketika mereka hanya berupa blok kode dalam metode besar, karena ketika saya mengekstraknya, saya kehilangan banyak asumsi mendasar yang berasal dari konteks pemanggil.

Saya tidak menyadari seberapa besar masalah ini sampai saya mengadopsi ECS yang mendorong fungsi sistem yang lebih besar (dengan sistem menjadi satu-satunya yang memiliki fungsi) dan ketergantungan mengalir ke data mentah , bukan abstraksi.

Itu, yang mengejutkan saya, menghasilkan basis kode jauh lebih mudah untuk dipikirkan dan dipelihara dibandingkan dengan basis kode yang saya kerjakan di masa lalu di mana, selama debugging, Anda harus melacak semua jenis fungsi kecil yang mungil, seringkali melalui fungsi abstrak yang dipanggil melalui antarmuka murni mengarah ke siapa yang tahu di mana sampai Anda melacak ke dalamnya, hanya untuk menelurkan beberapa kaskade peristiwa yang mengarah ke tempat-tempat Anda tidak pernah berpikir kode harus pernah mengarah.

Tidak seperti John Carmack, masalah terbesar saya dengan basis kode itu bukan kinerja karena saya tidak pernah memiliki permintaan laten yang sangat ketat terhadap mesin game AAA dan sebagian besar masalah kinerja kami lebih terkait dengan throughput. Tentu saja Anda juga dapat mulai membuatnya lebih dan lebih sulit untuk mengoptimalkan hotspot ketika Anda bekerja dalam batas yang lebih sempit dan lebih sempit dari fungsi dan kelas remaja dan tanpa struktur yang menghalangi (mengharuskan Anda untuk menggabungkan semua potongan kecil ini kembali) untuk sesuatu yang lebih besar sebelum Anda bahkan dapat mulai menanganinya secara efektif).

Namun masalah terbesar bagi saya adalah tidak dapat dengan yakin alasan tentang kebenaran sistem secara keseluruhan terlepas dari semua tes yang lulus. Ada terlalu banyak untuk dimasukkan ke dalam otak saya dan dipahami karena jenis sistem itu tidak membiarkan Anda berpikir tentang hal itu tanpa memperhitungkan semua detail kecil ini dan interaksi tanpa akhir antara fungsi kecil dan objek yang terjadi di mana-mana. Ada terlalu banyak "bagaimana jika?", Terlalu banyak hal yang perlu dipanggil pada waktu yang tepat, terlalu banyak pertanyaan tentang apa yang akan terjadi jika mereka disebut waktu yang salah (yang mulai naik ke titik paranoia ketika Anda memiliki satu peristiwa yang memicu peristiwa lain memicu peristiwa lainnya yang mengarahkan Anda ke semua jenis tempat yang tidak dapat diprediksi), dll.

Sekarang saya suka fungsi 80-line pantat besar saya di sana-sini, selama mereka masih melakukan tanggung jawab tunggal dan jelas dan tidak memiliki seperti 8 tingkat blok bersarang. Mereka menimbulkan perasaan bahwa ada sedikit hal dalam sistem untuk diuji dan dipahami, bahkan jika versi yang lebih kecil, yang dipotong dadu dari fungsi yang lebih besar ini hanyalah detail implementasi pribadi yang tidak dapat dipanggil oleh orang lain ... masih, entah bagaimana, ia cenderung merasa ada sedikit interaksi yang terjadi di seluruh sistem. Saya bahkan suka beberapa duplikasi kode yang sangat sederhana, selama itu bukan logika yang kompleks (katakan saja 2-3 baris kode), jika itu berarti kurang fungsi. Saya suka alasan Carmack di sana tentang inlining membuat fungsionalitas itu tidak mungkin untuk memanggil tempat lain di file sumber. Sana'

Kesederhanaan tidak selalu mengurangi kompleksitas pada tingkat gambaran besar jika pilihannya adalah antara satu fungsi gemuk vs 12 fungsi sederhana yang memanggil satu sama lain dengan grafik dependensi yang kompleks. Pada akhirnya, Anda harus sering berpikir tentang apa yang terjadi di luar fungsi, harus berpikir tentang apa yang ditambahkan fungsi-fungsi ini pada akhirnya, dan mungkin akan lebih sulit untuk melihat gambaran besar itu jika Anda harus menyimpulkannya dari potongan puzzle terkecil.

Tentu saja kode jenis pustaka dengan tujuan umum yang telah teruji dengan baik dapat dikecualikan dari aturan ini, karena kode tujuan umum seperti itu sering berfungsi dan berdiri dengan baik sendiri. Juga cenderung lebih kecil dibandingkan dengan kode yang sedikit lebih dekat dengan domain aplikasi Anda (ribuan baris kode, bukan jutaan), dan sangat berlaku sehingga mulai menjadi bagian dari kosakata harian. Tetapi dengan sesuatu yang lebih spesifik untuk aplikasi Anda di mana invarian seluruh sistem yang harus Anda pertahankan jauh melampaui fungsi tunggal atau kelas, saya cenderung merasa terbantu memiliki fungsi yang lebih kecil untuk alasan apa pun. Saya merasa jauh lebih mudah bekerja dengan potongan puzzle yang lebih besar dalam mencoba mencari tahu apa yang terjadi dengan gambaran besar.


sumber
0

Saya tidak berpikir itu masalah besar , tapi saya setuju itu menyusahkan. Biasanya saya hanya menempatkan helper segera setelah penerima dan menambahkan akhiran "Helper". Itu ditambah privateakses specifier harus membuat perannya jelas. Jika ada beberapa invarian yang tidak berlaku ketika helper dipanggil, saya menambahkan komentar di helper.

Solusi ini memang memiliki kekurangan yang disayangkan karena tidak menangkap lingkup fungsi yang membantu. Idealnya fungsi Anda kecil sehingga mudah-mudahan ini tidak menghasilkan terlalu banyak parameter. Biasanya Anda akan menyelesaikan ini dengan mendefinisikan struct atau kelas baru untuk menggabungkan parameter, tetapi jumlah pelat ketel yang diperlukan untuk itu dapat dengan mudah lebih lama daripada helper itu sendiri, dan kemudian Anda kembali ke tempat Anda memulai tanpa ada cara yang jelas untuk menghubungkan struct dengan fungsi.

Anda sudah menyebutkan solusi lain - tentukan helper di dalam fungsi utama. Ini mungkin idiom yang agak tidak umum dalam beberapa bahasa, tapi saya pikir itu tidak membingungkan (kecuali teman-teman Anda bingung oleh lambda secara umum). Ini hanya berfungsi jika Anda dapat mendefinisikan fungsi atau objek seperti fungsi dengan mudah. Saya tidak akan mencoba ini di Java 7, misalnya, karena kelas anonim membutuhkan memperkenalkan 2 tingkat bersarang bahkan untuk "fungsi" terkecil. Ini sedekat letatau dengan whereklausa yang bisa Anda dapatkan; Anda dapat merujuk ke variabel lokal sebelum definisi dan helper tidak dapat digunakan di luar cakupan itu.

Doval
sumber