Bagaimana pemrograman fungsional menangani situasi di mana objek yang sama direferensikan dari banyak tempat?

10

Saya membaca dan mendengar bahwa orang-orang (juga di situs ini) secara rutin memuji paradigma pemrograman fungsional, menekankan betapa baiknya memiliki segala sesuatu yang abadi. Khususnya, orang mengusulkan pendekatan ini bahkan dalam bahasa OO yang secara tradisional sangat penting, seperti C #, Java atau C ++, tidak hanya dalam bahasa yang murni fungsional seperti Haskell yang memaksakan ini pada programmer.

Saya merasa sulit untuk memahaminya, karena saya menemukan sifat berubah-ubah dan efek samping ... nyaman. Namun, mengingat bagaimana orang-orang saat ini mengutuk efek samping dan menganggapnya sebagai praktik yang baik untuk menyingkirkan mereka sedapat mungkin, saya percaya bahwa jika saya ingin menjadi programmer yang kompeten saya harus memulai jorney saya menuju pemahaman yang lebih baik tentang paradigma ... Oleh karena itu Q.

Satu tempat ketika saya menemukan masalah dengan paradigma fungsional adalah ketika suatu objek secara alami dirujuk dari banyak tempat. Biarkan saya menggambarkannya pada dua contoh.

Contoh pertama adalah game C # yang saya coba buat di waktu luang . Ini permainan web berbasis giliran di mana kedua pemain memiliki tim 4 monster dan dapat mengirim monster dari tim mereka ke medan perang, di mana ia akan menghadapi monster yang dikirim oleh pemain lawan. Pemain juga dapat memanggil kembali monster dari medan perang dan menggantinya dengan monster lain dari tim mereka (mirip dengan Pokemon).

Dalam pengaturan ini, monster tunggal dapat dirujuk secara alami dari setidaknya 2 tempat: tim pemain dan medan perang, yang merujuk dua monster "aktif".

Sekarang mari kita pertimbangkan situasi ketika satu monster terkena dan kehilangan 20 poin kesehatan. Dalam kurung paradigma imperatif saya memodifikasi healthbidang monster ini untuk mencerminkan perubahan ini - dan inilah yang saya lakukan sekarang. Namun, ini membuat Monsterkelas bisa berubah dan fungsi terkait (metode) tidak murni, yang saya kira dianggap praktik yang buruk seperti yang sekarang.

Meskipun saya memberi diri saya izin untuk memiliki kode permainan ini dalam keadaan kurang ideal untuk memiliki harapan untuk benar-benar menyelesaikannya di beberapa titik di masa depan, saya ingin tahu dan mengerti bagaimana seharusnya ditulis dengan benar. Karena itu: Jika ini cacat desain, bagaimana cara memperbaikinya?

Dalam gaya fungsional, seperti yang saya mengerti, saya akan membuat salinan dari Monsterobjek ini , menjaganya agar tetap sama dengan yang lama kecuali untuk bidang yang satu ini; dan metode suffer_hitakan mengembalikan objek baru ini alih-alih memodifikasi yang lama di tempat. Lalu aku juga akan menyalin Battlefieldobjek, menjaga semua bidangnya tetap sama kecuali monster ini.

Ini hadir dengan setidaknya 2 kesulitan:

  1. Hirarki dapat dengan mudah menjadi lebih dalam dari contoh sederhana ini Battlefield-> Monster. Saya harus melakukan penyalinan semua bidang kecuali satu dan mengembalikan objek baru sepanjang hierarki ini. Ini akan menjadi kode boilerplate yang menurut saya menjengkelkan terutama karena pemrograman fungsional seharusnya mengurangi boilerplate.
  2. Namun, masalah yang jauh lebih parah adalah bahwa hal ini akan menyebabkan data tidak sinkron . Monster aktif di lapangan akan melihat kesehatannya berkurang; Namun, monster yang sama ini, yang dirujuk dari pemain pengendali Team, tidak akan melakukannya. Jika saya sebaliknya menggunakan gaya imperatif, setiap modifikasi data akan langsung terlihat dari semua tempat kode lainnya dan dalam kasus seperti ini saya merasa sangat nyaman - tetapi cara saya mendapatkan hal-hal ini persis seperti yang dikatakan orang. salah dengan gaya imperatif!
    • Sekarang mungkin untuk mengurus masalah ini dengan melakukan perjalanan ke Teamsetelah setiap serangan. Ini pekerjaan ekstra. Namun, bagaimana jika monster tiba-tiba dapat direferensikan dari lebih banyak tempat? Bagaimana jika saya datang dengan kemampuan yang, misalnya, memungkinkan monster fokus pada monster lain yang belum tentu di lapangan (saya sebenarnya mempertimbangkan kemampuan seperti itu)? Apakah saya pasti ingat untuk melakukan perjalanan ke monster yang fokus segera setelah setiap serangan? Ini sepertinya bom waktu yang akan meledak ketika kode semakin kompleks, jadi saya pikir ini bukan solusi.

Sebuah ide untuk solusi yang lebih baik datang dari contoh kedua saya, ketika saya menemukan masalah yang sama. Di akademisi kami disuruh menulis penerjemah bahasa desain kami sendiri di Haskell. (Ini juga bagaimana saya dipaksa untuk mulai memahami apa itu FP). Masalahnya muncul ketika saya menerapkan penutupan. Sekali lagi cakupan yang sama sekarang dapat direferensikan dari beberapa tempat: Melalui variabel yang menyimpan lingkup ini dan sebagai cakupan induk dari setiap cakupan bersarang! Jelas, jika suatu perubahan dilakukan pada ruang lingkup ini melalui referensi yang menunjuk padanya, perubahan ini juga harus terlihat melalui semua referensi lain.

Solusi yang saya datangi adalah untuk menetapkan setiap lingkup ID dan memegang kamus pusat semua cakupan di Statemonad. Sekarang variabel hanya akan menyimpan ID dari lingkup mereka terikat, bukan lingkup itu sendiri, dan cakupan bersarang juga akan memegang ID dari lingkup induknya.

Saya kira pendekatan yang sama bisa dicoba dalam game pertarungan monster saya ... Fields dan tim tidak mereferensikan monster; mereka malah memegang ID monster yang disimpan di kamus monster pusat.

Namun, saya sekali lagi dapat melihat masalah dengan pendekatan ini yang mencegah saya menerimanya tanpa ragu-ragu sebagai solusi untuk masalah ini:

Sekali lagi ini adalah sumber kode boilerplate. Itu membuat one-liners tentu 3-liner: apa yang sebelumnya merupakan modifikasi satu-baris pada satu bidang sekarang membutuhkan (a) Mengambil objek dari kamus pusat (b) Membuat perubahan (c) Menyimpan objek baru ke kamus pusat. Juga, memegang id objek dan kamus pusat alih-alih memiliki referensi meningkatkan kompleksitas. Karena FP diiklankan untuk mengurangi kompleksitas dan kode boilerplate, ini mengisyaratkan saya salah melakukannya.

Saya juga akan menulis tentang masalah kedua yang tampaknya jauh lebih parah: Pendekatan ini memperkenalkan kebocoran memori . Objek yang tidak terjangkau biasanya adalah sampah yang dikumpulkan. Namun, objek yang disimpan dalam kamus pusat tidak boleh berupa sampah yang dikumpulkan, bahkan jika tidak ada objek yang dapat dijangkau merujuk ID khusus ini. Dan sementara pemrograman yang secara teoritis hati-hati dapat menghindari kebocoran memori (kita bisa berhati-hati untuk menghapus secara manual setiap objek dari kamus pusat setelah tidak diperlukan lagi), ini rawan kesalahan dan FP diiklankan untuk meningkatkan kebenaran program sehingga sekali lagi ini mungkin bukan cara yang benar.

Namun, saya menemukan pada waktunya bahwa itu tampaknya menjadi masalah yang terpecahkan. Java menyediakan WeakHashMapyang dapat digunakan untuk menyelesaikan masalah ini. C # menyediakan fasilitas serupa - ConditionalWeakTable- meskipun menurut dokumen itu dimaksudkan untuk digunakan oleh kompiler. Dan di Haskell kami memiliki System.Mem.Weak .

Apakah menyimpan kamus seperti itu merupakan solusi fungsional yang tepat untuk masalah ini atau apakah ada yang lebih sederhana yang tidak dapat saya lihat? Saya membayangkan bahwa jumlah kamus seperti itu dapat dengan mudah tumbuh dan buruk; jadi jika kamus ini seharusnya juga tidak berubah ini bisa berarti banyak parameter-passing atau, dalam bahasa yang mendukung itu, perhitungan monadik, karena kamus akan diadakan di monad (tapi sekali lagi saya membacanya dalam fungsi murni bahasa sebagai kode sesedikit mungkin harus monadik, sedangkan solusi kamus ini akan menempatkan hampir semua kode di dalam Statemonad; yang sekali lagi membuat saya ragu apakah ini solusi yang tepat.)

Setelah beberapa pertimbangan saya pikir saya akan menambahkan satu pertanyaan lagi: Apa yang kita peroleh dengan membangun kamus seperti itu? Apa yang salah dengan pemrograman imperatif adalah, menurut banyak ahli, bahwa perubahan pada beberapa objek menyebar ke bagian kode lainnya. Untuk mengatasi masalah ini, objek seharusnya tidak dapat diubah - justru karena alasan ini, jika saya mengerti dengan benar, bahwa perubahan yang dilakukan padanya tidak boleh terlihat di tempat lain. Tapi sekarang saya khawatir tentang potongan kode lain yang beroperasi pada data yang sudah ketinggalan zaman sehingga saya membuat kamus pusat sehingga ... sekali lagi perubahan pada beberapa bagian kode menyebar ke bagian kode lainnya! Bukankah kita, karena itu, kembali ke gaya imperatif dengan semua kelemahannya, tetapi dengan kompleksitas tambahan?

gaazkam
sumber
6
Untuk memberikan perspektif ini, program fungsional yang tidak dapat diubah sebagian besar dimaksudkan untuk situasi pemrosesan data yang melibatkan konkurensi. Dengan kata lain, program yang memproses input data melalui serangkaian persamaan atau proses yang menghasilkan hasil output. Immutability membantu dalam skenario ini karena beberapa alasan: nilai yang dibaca oleh banyak utas dijamin tidak berubah selama masa pakainya, yang sangat menyederhanakan kemampuan untuk memproses data secara bebas kunci dan alasan tentang bagaimana algoritma bekerja.
Robert Harvey
8
Rahasia kecil yang kotor tentang ketidakmampuan fungsional dan pemrograman game adalah bahwa kedua hal itu agak tidak kompatibel satu sama lain. Anda pada dasarnya mencoba untuk memodelkan sistem yang dinamis dan selalu berubah menggunakan struktur data statis yang tidak bergerak.
Robert Harvey
2
Jangan menganggap ketidakmampuan vs ketidakmampuan sebagai dogma agama. Ada situasi di mana masing-masing lebih baik dari yang lain, ketidakmampuan tidak selalu lebih baik, misalnya menulis perangkat GUI dengan tipe data yang tidak dapat diubah akan menjadi mimpi buruk absolut.
whatsisname
1
Pertanyaan khusus # C ini dan jawabannya mencakup masalah boilerplate, sebagian besar dihasilkan dari kebutuhan untuk membuat klon yang sedikit dimodifikasi (diperbarui) dari objek abadi yang ada.
rwong
2
Wawasan utama adalah bahwa monster dalam game ini dianggap sebagai entitas. Juga, hasil dari setiap pertempuran (terdiri dari nomor urutan pertempuran, ID entitas monster, keadaan monster sebelum dan sesudah pertempuran) dianggap sebagai negara pada titik waktu tertentu (atau langkah waktu). Dengan demikian, pemain ( Team) dapat mengambil hasil dari pertempuran dan dengan demikian menyatakan monster dengan tuple (nomor pertempuran, ID entitas monster).
rwong

Jawaban:

19

Bagaimana Pemrograman Fungsional menangani objek yang dirujuk dari beberapa tempat? Ini mengundang Anda untuk mengunjungi kembali model Anda!

Untuk menjelaskan ... mari kita lihat bagaimana permainan jaringan kadang-kadang ditulis - dengan salinan "sumber emas" pusat dari kondisi permainan, dan serangkaian acara klien yang masuk yang memperbarui keadaan itu, dan kemudian disiarkan kembali ke klien lain .

Anda dapat membaca tentang kesenangan yang dimiliki oleh tim Factorio untuk menjadikannya berperilaku baik dalam beberapa situasi; inilah gambaran singkat dari model mereka:

Cara dasar multiplayer kami berfungsi adalah bahwa semua klien mensimulasikan kondisi permainan dan mereka hanya menerima dan mengirim input pemain (disebut Tindakan Input). Tanggung jawab utama server adalah memprosiaksi Tindakan Input dan memastikan semua klien melakukan tindakan yang sama dalam centang yang sama.

Karena server perlu melakukan arbitrase ketika tindakan dijalankan, tindakan pemain memindahkan sesuatu seperti ini: Aksi pemain -> Klien Game -> Jaringan -> Server -> Jaringan-> Klien game. Ini berarti setiap aksi pemain hanya dieksekusi setelah ia melakukan perjalanan pulang pergi melalui jaringan. Ini akan membuat game terasa sangat lamban, itulah sebabnya latensi persembunyian merupakan mekanisme yang ditambahkan dalam game hampir sejak diperkenalkannya multi-pemain. Bersembunyi latensi berfungsi dengan mensimulasikan input pemain, tanpa mempertimbangkan tindakan pemain lain dan tanpa mempertimbangkan arbitrase server.

Di Factorio kita memiliki Status Game, ini adalah status penuh dari peta, pemain, hak, semuanya. Ini disimulasikan secara deterministik pada semua klien berdasarkan tindakan yang diterima dari server. Ini sakral dan jika pernah berbeda dari server atau klien lain, desync terjadi.

Di atas Status Game kami memiliki Status Latensi. Ini berisi sebagian kecil dari negara bagian utama. Keadaan Latensi tidak sakral dan itu hanya mewakili bagaimana kami pikir keadaan permainan akan seperti di masa depan berdasarkan Tindakan Input yang dilakukan pemain.

Kuncinya adalah bahwa keadaan setiap objek tidak berubah pada tanda centang khusus di garis waktu . Segala sesuatu dalam keadaan multi-pemain global pada akhirnya harus bertemu dengan realitas deterministik.

Dan - itu mungkin menjadi kunci pertanyaan Anda. Status setiap entitas tidak berubah untuk kutu yang diberikan, dan Anda melacak peristiwa transisi yang menghasilkan instance baru dari waktu ke waktu.

Jika Anda memikirkannya, antrian acara yang masuk dari server harus memiliki akses ke direktori pusat entitas, hanya agar ia dapat menerapkan acara tersebut.

Pada akhirnya, metode mutator satu baris sederhana yang tidak ingin Anda rumit hanya sederhana karena Anda tidak benar-benar memodelkan waktu secara akurat. Lagi pula, jika kesehatan dapat berubah di tengah-tengah loop pemrosesan, maka entitas sebelumnya dalam centang ini akan melihat nilai lama, dan yang kemudian melihat yang berubah. Mengelola ini dengan hati-hati berarti setidaknya membedakan saat ini (kekal) dan berikutnya (dalam konstruksi) negara, yang benar-benar hanya dua kutu dalam waktu-garis besar kutu!

Jadi, sebagai panduan luas, pertimbangkan memecah keadaan monster menjadi sejumlah benda kecil yang berhubungan dengan, katakanlah, lokasi / kecepatan / fisika, kesehatan / kerusakan, aset. Buat peristiwa untuk menggambarkan setiap mutasi yang mungkin terjadi, dan jalankan loop utama Anda sebagai:

  1. memproses input dan menghasilkan acara yang sesuai
  2. menghasilkan peristiwa internal (misalnya karena tabrakan objek, dll)
  3. terapkan kejadian pada monster yang tidak dapat diubah saat ini, untuk menghasilkan monster baru untuk centang selanjutnya - kebanyakan menyalin kondisi lama yang tidak berubah jika memungkinkan, tetapi membuat objek keadaan baru di mana diperlukan.
  4. render dan ulangi untuk centang selanjutnya.

Atau semacam itu. Saya menemukan pemikiran "bagaimana saya membuat ini didistribusikan?" adalah latihan mental yang cukup bagus, secara umum, untuk memperbaiki pemahaman saya ketika saya bingung tentang di mana hal-hal hidup dan bagaimana mereka harus berkembang.

Berkat catatan dari @ AaronM.Eshbach, menyoroti bahwa ini adalah domain masalah yang sama dengan Sumber Acara dan pola CQRS , di mana Anda memodelkan perubahan untuk menyatakan dalam sistem terdistribusi sebagai serangkaian acara yang berubah dari waktu ke waktu . Dalam hal ini, kami kemungkinan besar berusaha membersihkan aplikasi database yang kompleks, dengan memisahkan (seperti namanya!) Perintah mutator yang menangani dari sistem query / view. Lebih kompleks tentu saja, tetapi lebih fleksibel.

SusanW
sumber
2
Untuk referensi tambahan, lihat Pengadaan Acara dan CQRS . Ini adalah domain masalah yang serupa: Memodelkan perubahan untuk menyatakan dalam sistem terdistribusi sebagai serangkaian peristiwa yang tidak dapat berubah dari waktu ke waktu.
Aaron M. Eshbach
@ AaronM.Eshbach itu orangnya! Apakah Anda keberatan jika saya memasukkan komentar / kutipan Anda dalam jawabannya? Itu membuatnya terdengar lebih otoritatif. Terima kasih!
SusanW
Tentu saja tidak.
Aaron M. Eshbach
3

Anda masih setengah di kamp imperatif. Alih-alih memikirkan satu objek pada satu waktu pikirkan game Anda dalam hal sejarah permainan atau peristiwa

p1 - send m1 to battlefield
p2 - send m2 to battlefield
m1 - attacks m2 (2 dam)
m2 - attacks m1 (10 dam)
p1 - retreats m1

dll

Anda dapat menghitung status permainan pada titik tertentu dengan merantai tindakan bersama-sama untuk menghasilkan objek keadaan tidak berubah. Setiap permainan adalah fungsi yang mengambil objek keadaan dan mengembalikan objek keadaan baru

Ewan
sumber