Apakah kontrak semantik antarmuka (OOP) lebih informatif daripada tanda tangan fungsi (FP)?

16

Dikatakan oleh beberapa orang bahwa jika Anda mengambil prinsip-prinsip SOLID secara ekstrem, Anda berakhir pada pemrograman fungsional . Saya setuju dengan artikel ini, tetapi saya berpikir bahwa beberapa semantik hilang dalam transisi dari antarmuka / objek ke fungsi / penutupan, dan saya ingin tahu bagaimana Pemrograman Fungsional dapat mengurangi kerugian.

Dari artikel:

Selain itu, jika Anda menerapkan Prinsip Segregasi Antarmuka (ISP) dengan teliti, Anda akan memahami bahwa Anda harus memilih Antarmuka Peran daripada Antarmuka Header.

Jika Anda terus mengarahkan desain ke antarmuka yang lebih kecil dan lebih kecil, pada akhirnya Anda akan tiba di Antarmuka Peran utama: antarmuka dengan metode tunggal. Ini sering terjadi pada saya. Ini sebuah contoh:

public interface IMessageQuery
{
    string Read(int id);
}

Jika saya mengambil ketergantungan pada IMessageQuery, bagian dari kontrak implisit adalah bahwa panggilan Read(id)akan mencari dan mengembalikan pesan dengan ID yang diberikan.

Bandingkan ini dengan mengambil ketergantungan pada tanda tangan fungsional yang setara int -> string,. Tanpa isyarat tambahan, fungsi ini bisa menjadi sederhana ToString(). Jika Anda menerapkan IMessageQuery.Read(int id)dengan ToString()saya dapat menuduh Anda sengaja subversif!

Jadi, apa yang bisa dilakukan programmer fungsional untuk menjaga semantik antarmuka yang bernama baik? Apakah konvensional untuk, misalnya, membuat tipe rekaman dengan satu anggota?

type MessageQuery = {
    Read: int -> string
}
AlexFoxGill
sumber
5
Antarmuka OOP lebih seperti FP typeclass , bukan fungsi tunggal.
9000
2
Anda dapat memiliki terlalu banyak hal yang baik, menerapkan ISP 'ketat' untuk berakhir dengan 1 metode per antarmuka terlalu jauh.
gbjbaanb
3
@ gbjbaanb sebenarnya sebagian besar antarmuka saya hanya memiliki satu metode dengan banyak implementasi. Semakin Anda menerapkan prinsip-prinsip SOLID, semakin banyak Anda bisa melihat manfaatnya. Tapi itu di luar topik untuk pertanyaan ini
AlexFoxGill
1
@ jk .: Ya, di Haskell, ini kelas tipe, di OCaml Anda menggunakan modul atau functor, di Clojure Anda menggunakan protokol. Dalam kasus apa pun, Anda biasanya tidak membatasi analogi antarmuka hanya untuk satu fungsi.
9000
2
Without any additional clues... mungkin itu sebabnya dokumentasi adalah bagian dari kontrak ?
SJuan76

Jawaban:

8

Seperti kata Telastyn, membandingkan definisi fungsi statis:

public string Read(int id) { /*...*/ }

untuk

let read (id:int) = //...

Anda belum benar-benar kehilangan apa pun mulai dari OOP ke FP.

Namun, ini hanya sebagian dari cerita, karena fungsi dan antarmuka tidak hanya disebut dalam definisi statisnya. Mereka juga melewati . Jadi misalkan kita MessageQuerydibaca oleh kode lain, a MessageProcessor. Maka kita memiliki:

public void ProcessMessage(int messageId, IMessageQuery messageReader) { /*...*/ }

Sekarang kita tidak bisa secara langsung melihat nama metode IMessageQuery.Readatau parameternya int id, tetapi kita bisa sampai di sana dengan sangat mudah melalui IDE kita. Secara umum, fakta bahwa kita melewatkan IMessageQueryantarmuka daripada sembarang antarmuka dengan metode fungsi dari int ke string berarti kita menjaga idmetadata nama parameter yang terkait dengan fungsi ini.

Di sisi lain, untuk versi fungsional kami memiliki:

let read (id:int) (messageReader : int -> string) = // ...

Jadi apa yang kita simpan dan hilangkan? Yah, kami masih memiliki nama parameter messageReader, yang mungkin membuat nama jenis (yang setara dengan IMessageQuery) tidak perlu. Tapi sekarang kami telah kehilangan nama parameter iddalam fungsi kami.


Ada dua cara utama dalam hal ini:

  • Pertama, dari membaca tanda tangan itu, Anda sudah dapat menebak dengan baik apa yang akan terjadi. Dengan menjaga fungsi tetap singkat, sederhana, dan kohesif serta menggunakan penamaan yang baik, Anda membuatnya lebih mudah untuk berkomunikasi atau menemukan informasi ini. Setelah kita membaca fungsi yang sebenarnya, itu akan menjadi lebih sederhana.

  • Kedua, itu dianggap desain idiomatik dalam banyak bahasa fungsional untuk membuat tipe kecil untuk membungkus primitif. Dalam hal ini, yang terjadi adalah sebaliknya - alih-alih mengganti nama tipe dengan nama parameter ( IMessageQueryke messageReader) kita dapat mengganti nama parameter dengan nama tipe. Misalnya, intbisa dibungkus dengan tipe yang disebut Id:

    type Id = Id of int
    

    Sekarang readtanda tangan kami menjadi:

    let read (id:int) (messageReader : Id -> string) = // ...
    

    Yang sama informatif seperti apa yang kita miliki sebelumnya.

    Sebagai catatan, ini juga memberi kami beberapa perlindungan kompiler yang kami miliki di OOP. Sementara versi OOP memastikan bahwa kami mengambil secara khusus fungsi yang IMessageQuerylama int -> string, di sini kami memiliki perlindungan yang serupa (namun berbeda) yang kami Id -> stringgunakan daripada yang lama int -> string.


Saya akan enggan mengatakan dengan keyakinan 100% bahwa teknik-teknik ini akan selalu sama baiknya dan informatif seperti memiliki informasi lengkap yang tersedia pada sebuah antarmuka, tetapi saya pikir dari contoh di atas, Anda dapat mengatakan bahwa sebagian besar waktu, kami mungkin bisa melakukan pekerjaan yang sama baiknya.

Ben Aaronson
sumber
1
Ini adalah jawaban favorit saya - bahwa FP menganjurkan penggunaan tipe semantik
AlexFoxGill
10

Saat melakukan FP saya cenderung menggunakan jenis semantik yang lebih spesifik.

Misalnya, metode Anda untuk saya akan menjadi seperti:

read: MessageId -> Message

Ini berkomunikasi lebih banyak daripada gaya ThingDoer.doThing()gaya OO (/ java)

Daenyth
sumber
2
+1. Selain itu, Anda dapat menyandikan lebih banyak properti ke dalam sistem tipe dalam bahasa FP yang diketik seperti Haskell. Jika itu adalah tipe haskell, saya akan tahu bahwa itu tidak melakukan IO sama sekali (dan dengan demikian kemungkinan merujuk beberapa pemetaan ids di memori ke pesan di suatu tempat). Dalam bahasa OOP, saya tidak memiliki informasi itu - fungsi ini dapat memanggil basis data sebagai bagian dari memanggilnya.
Jack
1
Saya tidak begitu jelas mengapa ini akan berkomunikasi lebih dari gaya Jawa. Apa yang read: MessageId -> Messagememberitahu Anda itu string MessageReader.GetMessage(int messageId)tidak?
Ben Aaronson
@ BenAaronson rasio sinyal terhadap noise lebih baik. Jika ini adalah contoh metode OO saya tidak tahu apa yang dilakukannya di bawah tenda, itu bisa memiliki segala jenis ketergantungan yang tidak diungkapkan. Seperti yang disebutkan Jack, ketika Anda memiliki tipe yang lebih kuat, Anda memiliki lebih banyak jaminan.
Daenyth
9

Jadi, apa yang bisa dilakukan programmer fungsional untuk menjaga semantik antarmuka yang bernama baik?

Gunakan fungsi yang bernama baik.

IMessageQuery::Read: int -> stringhanya menjadi ReadMessageQuery: int -> stringatau sesuatu yang serupa.

Hal utama yang perlu diperhatikan adalah bahwa nama hanya kontrak dalam arti kata yang paling longgar. Mereka hanya bekerja jika Anda dan programmer lain menyimpulkan implikasi yang sama dari namanya, dan mematuhinya. Karena itu, Anda benar-benar dapat menggunakan nama apa pun yang berkomunikasi dengan perilaku tersirat itu. OO dan pemrograman fungsional memiliki nama mereka di tempat yang sedikit berbeda dan dalam bentuk yang sedikit berbeda, tetapi fungsinya sama.

Apakah kontrak semantik antarmuka (OOP) lebih informatif daripada tanda tangan fungsi (FP)?

Tidak dalam contoh ini. Seperti yang saya jelaskan di atas, satu kelas / antarmuka dengan fungsi tunggal tidak bermakna lebih informatif daripada fungsi mandiri yang bernama sama.

Setelah Anda mendapatkan lebih dari satu fungsi / bidang / properti di kelas, Anda dapat menyimpulkan lebih banyak informasi tentang mereka karena Anda dapat melihat hubungannya. Dapat diperdebatkan jika itu lebih informatif daripada fungsi mandiri yang mengambil parameter yang sama / mirip atau fungsi mandiri yang diatur oleh namespace atau modul.

Secara pribadi, saya tidak berpikir bahwa OO secara signifikan lebih informatif, bahkan dalam contoh yang lebih kompleks.

Telastyn
sumber
2
Bagaimana dengan nama parameter?
Ben Aaronson
@ BenAaronson - bagaimana dengan mereka? Baik OO dan bahasa fungsional memungkinkan Anda menentukan nama parameter, sehingga itu adalah dorongan. Saya hanya menggunakan tulisan tangan jenis tanda tangan di sini agar konsisten dengan pertanyaan. Dalam bahasa nyata akan terlihat sepertiReadMessageQuery id = <code to go fetch based on id>
Telastyn
1
Jika suatu fungsi menggunakan antarmuka sebagai parameter, maka memanggil metode pada antarmuka itu, nama-nama parameter untuk metode antarmuka sudah tersedia. Jika suatu fungsi menggunakan fungsi lain sebagai parameter, tidak mudah untuk menetapkan nama parameter untuk fungsi yang diteruskan itu
Ben Aaronson
1
Ya, @BenAaronson ini adalah salah satu bagian dari informasi yang hilang dalam tanda tangan fungsi. Misalnya dalam let consumer (messageQuery : int -> string) = messageQuery 5, idparameternya hanyalah sebuah int. Saya kira satu argumen adalah bahwa Anda harus melewati Id, bukan int. Bahkan itu akan membuat jawaban yang layak dalam dirinya sendiri.
AlexFoxGill
@AlexFoxGill Sebenarnya saya baru saja dalam proses menulis sesuatu di sepanjang baris itu!
Ben Aaronson
0

Saya tidak setuju bahwa fungsi tunggal tidak dapat memiliki 'kontrak semantik'. Pertimbangkan undang-undang ini untuk foldr:

foldr f z nil = z
foldr f z (singleton x) = f x z
foldr f z (xn <> ys) = foldr f (foldr f z ys) xn

Dalam arti apa itu bukan semantik, atau bukan kontrak? Anda tidak perlu mendefinisikan tipe untuk 'folder', terutama karena foldrsecara unik ditentukan oleh undang-undang tersebut. Anda tahu persis apa yang akan dilakukan.

Jika Anda ingin mengambil fungsi dari beberapa jenis, Anda dapat melakukan hal yang sama:

-- The first argument `f` must satisfy for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
sort :: forall 'a. (a -> a -> bool.t) -> list.t a -> list.t a;

Anda hanya perlu memberi nama dan menangkap jenis itu jika Anda perlu kontrak yang sama beberapa kali:

-- Type of functions `f` satisfying, for all x, y, z
-- > f x x = true
-- > f x y = true and f y x = true implies x = y
-- > f x y = true and f y z = true implies f x z = true
type comparison.t 'a = a -> a -> bool.t;

Pemeriksa tipe tidak akan memberlakukan semantik apa pun yang Anda tetapkan untuk suatu tipe, jadi membuat tipe baru untuk setiap kontrak hanyalah boilerplate.

Jonathan Cast
sumber
1
Saya tidak berpikir poin pertama Anda membahas pertanyaan - ini adalah definisi fungsi, bukan tanda tangan. Tentu saja dengan melihat implementasi suatu kelas atau fungsi Anda dapat mengetahui apa yang dilakukannya - pertanyaannya dapatkah Anda masih mempertahankan semantik pada tingkat abstraksi (antarmuka atau fungsi tanda tangan)
AlexFoxGill
@AlexFoxGill - ini bukan definisi fungsi, meskipun secara unik menentukan foldr. Petunjuk: definisi foldrmemiliki dua persamaan (mengapa?) Sedangkan spesifikasi yang diberikan di atas memiliki tiga persamaan.
Jonathan Cast
Yang saya maksud adalah bahwa foldr adalah fungsi bukan tanda tangan fungsi. Hukum atau definisi bukan bagian dari tanda tangan
AlexFoxGill
-1

Hampir semua bahasa fungsional yang diketik secara statis memiliki cara untuk alias tipe dasar dengan cara yang mengharuskan Anda untuk secara eksplisit menyatakan maksud semantik Anda. Beberapa jawaban lain telah memberikan contoh. Dalam praktiknya, pemrogram fungsional yang berpengalaman perlu alasan yang sangat baik untuk menggunakan jenis pembungkus itu, karena mereka merusak kemampuan kompilasi dan penggunaan kembali.

Misalnya, klien ingin implementasi kueri pesan yang didukung oleh daftar. Di Haskell, implementasinya bisa sesederhana ini:

messages = ["Message 0", "Message 1", "Message 2"]
messageQuery = (messages !!)

Menggunakan newtype Message = Message Stringini akan jauh lebih mudah, meninggalkan implementasi ini terlihat seperti:

messages = map Message ["Message 0", "Message 1", "Message 2"]
messageQuery (Id index) = messages !! index

Itu mungkin tidak tampak seperti masalah besar, tetapi Anda harus melakukan konversi jenis itu bolak-balik di mana - mana , atau mengatur lapisan batas dalam kode Anda di mana segala sesuatu di atas Int -> Stringmaka Anda mengonversinya Id -> Messageuntuk lulus ke lapisan di bawah ini. Katakanlah saya ingin menambahkan internasionalisasi, atau memformatnya dalam semua huruf besar, atau menambahkan konteks logging, atau apa pun. Semua operasi itu semuanya sederhana untuk dikomposisi dengan Int -> String, dan menjengkelkan dengan Id -> Message. Bukan berarti tidak pernah ada kasus di mana pembatasan jenis yang ditingkatkan diinginkan, tetapi gangguan sebaiknya sebanding dengan trade off.

Anda dapat menggunakan sinonim jenis alih-alih pembungkus (dalam Haskell typebukan newtype), yang jauh lebih umum dan tidak memerlukan konversi di semua tempat, tetapi itu tidak memberikan jaminan tipe statis baik seperti versi OOP Anda, hanya sedikit enapsulasi. Jenis pembungkus sebagian besar digunakan di mana klien tidak diharapkan untuk memanipulasi nilai sama sekali, simpan saja dan kirimkan kembali. Misalnya, pegangan file.

Tidak ada yang dapat mencegah klien menjadi "subversif." Anda hanya membuat lingkaran untuk dilewati, untuk setiap klien. Mock sederhana untuk unit test umum sering kali memerlukan perilaku aneh yang tidak masuk akal dalam produksi. Antarmuka Anda harus ditulis sehingga mereka tidak peduli, jika memungkinkan.

Karl Bielefeldt
sumber
1
Ini terasa lebih seperti komentar pada jawaban Ben / Daenyth - bahwa Anda dapat menggunakan tipe semantik tetapi Anda tidak melakukannya karena ketidaknyamanan? Saya tidak menurunkan suara Anda, tetapi itu bukan jawaban untuk pertanyaan ini.
AlexFoxGill
-1 Tidak akan merusak kompabilitas atau penggunaan kembali sama sekali. Jika Iddan Messagepembungkus sederhana untuk Intdan String, itu mudah untuk mengkonversi di antara mereka.
Michael Shaw
Ya, ini sepele, tetapi masih menyebalkan untuk dilakukan di semua tempat. Saya telah menambahkan contoh dan sedikit menjelaskan perbedaan antara penggunaan sinonim jenis dan pembungkus.
Karl Bielefeldt
Ini sepertinya ukuran. Dalam proyek kecil, jenis pembungkus semacam ini mungkin tidak berguna. Dalam proyek-proyek besar, wajar bahwa batas akan menjadi bagian kecil dari keseluruhan kode, jadi melakukan jenis konversi ini di batas dan kemudian membagikan jenis yang dibungkus di tempat lain tidak terlalu sulit. Juga, seperti kata Alex, saya tidak melihat bagaimana ini merupakan jawaban untuk pertanyaan itu.
Ben Aaronson
Saya menjelaskan bagaimana melakukannya, lalu mengapa programmer fungsional tidak melakukannya. Itu jawaban, bahkan jika itu bukan yang diinginkan orang. Ini tidak ada hubungannya dengan ukuran. Gagasan bahwa ini entah bagaimana merupakan hal yang baik untuk sengaja membuatnya lebih sulit untuk menggunakan kembali kode Anda jelas merupakan konsep OOP. Membuat jenis produk yang menambah nilai? Sepanjang waktu. Membuat jenis persis seperti jenis yang banyak digunakan kecuali Anda hanya dapat menggunakannya di satu perpustakaan? Praktis belum pernah terjadi, kecuali mungkin dalam kode FP yang banyak berinteraksi dengan kode OOP, atau ditulis oleh seseorang yang akrab dengan idiom OOP.
Karl Bielefeldt