Kenalan Dulu sama Type Class
Tidak semua bahasa pemrograman mendukung fitur type class. Beberapa orang bilang konsep type class identik dengan konsep interface pada bahasa-bahasa seperti Java, C#, atau Typescript, dimana type class dan interface sama-sama bertujuan untuk menyediakan function yang polymorphic, walaupun sebenarnya keduanya tidak 100% sama. Identik saja.
Saya gunakan Purescript untuk menjelaskan konsep type class pada article ini.
Give Me the Code
class Show a where show :: a -> String
- Kita membuat sebuah type class dengan nama
Show
. Umumnya type class menyediakan satu (atau lebih) type variable yang bakal digunakan di method-nya. Di sini type variable tersebut adalaha
. - Type class
Show
ini memiliki satu method bernamashow
, yang menerimaa
dan mengembalikan String.
Tidak ada concrete code di sini, tugas type class hanya mendefinisikan struktur (type signature) dari method-method yang bisa digunakan oleh suatu tipe data. Seperti interface, hanya kontrak. Detil implementasi diserahkan ke implementornya, seperti tipe Email di bawah:
newtype Email = MkEmail String
instance showEmail :: Show Email where show (MkEmail e) = "Email: " <> e
show (MkEmail "dewey@email.com") -- "Email: dewey@email.com"
- Di sini kita membuat instance
Show
untukEmail
dengan namashowEmail
(nama instance bisa kita abaikan, tidak terlalu penting untuk saat ini). - Implementasi
show
. Inilah gunanya type variablea
di atas tadi. Sekaranga
telah di-instantiate denganEmail
sehingga bisa kita konsumsi di function argument.
Karena perannya yang mirip dengan interface, kita juga bisa membuat instance untuk tipe data lain. Sehingga function show
tidak hanya applicable untuk type Email, tapi juga Password.
newtype Password = MkPassword String
instance showPassword :: Show Password where show _ = "<secret>"
show (MkPassword "SomePassword789_+*!@#") -- "<secret>"
Kalau type class identik dengan interface, lalu dimana bedanya?
Type Class vs Interface
Walaupun type class sekilas terlihat seperti interface, ada perbedaan yang sangat mendasar, yaitu type class memungkinkan kita untuk membuat instance terhadap type yang bukan milik kita. Oleh karenanya banyak orang yang menyebut type class sebagai “the true ad-hoc polymorphism”.
Tak perlu jauh-jauh memikirkan tipe data dari third-party library, kita bahkan bisa memberi instance untuk tipe data primitif seperti Boolean.
instance showBoolean :: Show Boolean where show true = "true" show false = "false"
Tipe data primitif lainnya seperti Int, String, Array sudah “diurus” oleh Prelude. Semua di level library, no magic.
Appendable
Let’s dig deeper.
Ambil String dan Array. Keduanya memiliki sifat yang sama yaitu dapat digabungkan; string dengan string, array dengan array. Di Javascript, penggabungan ini bisa dicapai dengan menggunakan operator +
.
$ 'jihad ' + 'waspada''jihad waspada'
$ [1, 2] + [3][1, 2, 3]
Behavior atau sifat “bisa digabungkan” ini bisa kita capture dengan sebuah type class, sebut saja Appendable
.
class Appendable a where append :: a -> a -> a
instance appendableStr :: Appendable String where append x y = ... -- implementation details
instance appendableArr :: Appendable (Array xs) where append x y = ... -- implementation details
append "jihad " "waspada" -- "jihad waspada"append [1, 2] [3] -- [1, 2, 3]
Kita baru saja memberikan String dan Array kemampuan untuk bisa digabungkan dengan membuat instance Appendable
. Detil implementasinya kita biarkan kosong agar contoh code-nya tetap sederhana. Bila kita panggil fungsi append
dengan tipe data yang belum memiliki instance Appendable
seperti Int, program akan error.
append 11 99-- ERROR!-- No type class instance was found for `Appendable Int`
INTERMEZZO
📝 Di Typescript, kemampuan ini “bisa” dicapai dengan memanfaatkan fitur augmentation dan JS prototypes!
// Buat interface dasarnyainterface Appendable<A> { append: (other: A) => A}
// interface merging!interface String extends Appendable<string> { }interface Array<T> extends Appendable<Array<T>> { }
// Tambah behaviour dengan prototypeString.prototype.append = function (other) { return this + other;}Array.prototype.append = function (other) { return this.concat(other);};
console.log('jihad '.append('waspada')) // 'jihad waspada'console.log([1, 2].append([3])) // [1, 2, 3]
Subtyping
Sama seperti interface, type class juga memiliki konsep subtyping dimana sebuah type class dapat “meng-extend” method dari type class lainnya.
class Appendable a <= HazDefault a where defaultVal :: a
Type class HazDefault
di-construct dengan superclass Appendable
, yang memungkinkan instance HazDefault
untuk tetap bisa memanggil fungsi append
, dengan syarat setiap instance HazDefault
harus juga memiliki instance Appendable
. Relasi sebuah type class dengan superclass-nya ditandai dengan symbol <=
.
String dan Array sudah memiliki instance Appendable
, maka sah-sah saja bagi mereka untuk juga memiliki instance HazDefault
😉
instance hazDefaultStr :: HazDefault String where defaultVal = ""
instance hazDefaultArr :: HazDefault (Array xs) where defaultVal = []
append "jihad" defaultVal -- "jihad"append [1, 2] defaultVal -- [1, 2]
Compiler akan complain dengan pesan yang cukup jelas kalo kita iseng membuat instance HazDefault
untuk tipe data yang belum memiliki instance Appendable
.
instance hazDefaultInt :: HazDefault Int where defaultVal = 0
-- ERROR!-- No type class instance was found for `Appendable Int`
Overlapping Instances
Sejauh ini kita sudah belajar bagaimana type class berguna untuk memberikan “kemampuan” pada suatu tipe data. Kita sudah memberi String dan Array “kemampuan” untuk melakukan penggabungan dengan fungsi append
dan mengembalikan nilai kosongnya dengan fungsi defaultVal
.
Apa yang terjadi jika kita memberikan kemampuan yang sama dua kali?
instance hazDefaultStr :: HazDefault String where defaultVal = ""
instance hazDefaultStr2 :: HazDefault String where defaultVal = "zzz"
-- Overlapping type class instances found for---- HazDefault String---- The following instances were found:---- hazDefailtStr-- hazDefailtStr2
Compiler komplain. Masuk akal sih, karena nanti ketika ada code defaultVal :: String
compiler akan kebingungan memilih harus pakai instance yang mana: apakah harus mengembalikan ""
atau "zzz"
. Kondisi ini disebut Overlapping Instances. Namun jika tetep kekeuh ingin menuliskan overlapping instances, Purescript menyediakan fitur Instance Chains yang tidak akan saya bahas di artikel ini.
Tapi kadangkala ada saja kasus dimana suatu data bisa memiliki dua behavior: misal untuk tipe Int
jika mengimplementasi class HazDefault
. Nilai default Integer bernilai 0 ketika dijalankan dalam konteks penjumlahan, namun bernilai 1 dalam konteks perkalian. Ketika dihadapkan dengan situasi seperti ini, salah satu cara untuk mengakalinya bisa dengan membungkusnya dengan newtype
.
newtype Sum = Sum Intnewtype Prod = Prod Int
instance appendableSum :: Appendable Sum where append (Sum a) (Sum b) = Sum (a + b)
instance appendableProd :: Appendable Prod where append (Prod a) (Prod b) = Prod (a * b)
instance hazDefaultSum :: HazDefault Sum where defaultVal = Sum 0
instance hazDefaultProd :: HazDefault Prod where defaultVal = Prod 1--
append (Sum 99) (Sum 1) -- Sum 100append (Prod 50) (Prod 2) -- Prod 100append (Sum 99) defaultVal -- Sum 99append (Prod 50) defaultVal -- Prod 50
Constraint
Type class is all about instance and constraint. Mari perhatikan contoh berikut:
guard :: ∀ a. HazDefault a => Boolean -> a -> aguard true val = valguard false _ = defaultVal
Constraint type class pada sebuah function dipisahkan dengan symbol =>
. Fungsi guard
menerima dua buah argument; Boolean
dan a
, dan mengembalikan a
. Namun a
di sini tidak boleh sembarang type, ia harus mempunyai instance HazDefault
.
Kita sudah tahu bahwa fungsi guard
ter-contraint dengan class HazDefault
pada type parameter a
, yang berarti a
hanya boleh diisi dengan String atau Array.
type User = String
isRoot :: User -> BooleanisRoot user = user == "jihad"
-- Untuk StringwelcomeMessage :: User -> StringwelcomeMessage user = guard (isRoot user) "Welcome, root!"
-- Untuk Arrayaccess :: User -> Array Intaccess user = guard (isRoot user) [7, 7, 7]--
welcomeMessage "jihad" -- "Welcome, root!"welcomeMessage "not admin" -- ""
access "jihad" -- [7, 7, 7]access "not admin" -- []
In fact, kalau kita menginspeksi type append
dan defaultVal
di REPL, yang kita lihat sebenarnya adalah:
$ :t appendappend :: Appendable a => a -> a -> a
$ :t defaultValdefaultVal :: HazDefault a => a
Tidak mengejutkan 🙂
Methods Injection
Selain kemampuan membatasi akses pada suatu function, constraint type class pada dasarnya memberikan semua method-nya secara implisit. Untuk lebih jelasnya, mari modifikasi function guard
barusan.
guard :: ∀ a. HazDefault a => Boolean -> a -> a -> aguard true x y = x `append` yguard false _ _ = defaultVal
Dengan adanya constraint HazDefault
di type signature, function guard
memiliki izin untuk mengakses method-method yang ada pada class HazDefault
(append
dan defaultVal
). Under the hood, compiler melakukan proses desugaring seperti berikut.
guard :: ∀ a. HazDefaultDict a -> Boolean -> a -> a -> aguard { append, defaultVal } true x y = x `append` yguard { append, defaultVal } false _ _ = defaultVal
Dengan kata lain, function append
dan defaultVal
bersifat eksklusif, tidak bisa sembarang dipanggil oleh function lain. Caller harus memberikan constraint di type signature-nya. Jika tidak, compiler akan complain.
invalid :: ∀ a. a -> a -> ainvalid x y = x `append` y
-- ERROR!-- No type class instance was found for `Appendable a`
Readability dan Testability
Bicara agak real world, type class juga dapat membantu readability ketika kita ingin melacak side effect apa saja yang bakal dijalankan di sebuah function.
class Monad m <= MonadCache m where readCache :: Path -> m (Maybe String) writeCache :: String -> Path -> m Unit
class Monad m <= MonadUserDb m where getUser :: UserId -> m (Maybe User)
fetchUser :: ∀ m. MonadCache m => MonadUserDb m => UserId -> m (Maybe User)fetchUser uId = do cache <- readCache ("/cache/user" <> uId) ... ...
Fungsi fetcUser
ter-constraint dengan dua buah type class: MonadCache
dan MonadUserDb
, yang memungkinkan untuk memanggil fungsi readCache
, writeCache
, dan getUser
di dalamnya. Fungsi fetchUser
juga secara tidak langsung ter-constraint dengan type class Monad
sehingga kita bisa langsung menggunakan do
binding.
Gak hanya itu, kita juga bisa menyimpulkan dari melihat type signature-nya saja bahwa fetchUser
bakal berinteraksi dengan cache dan database (side effect) tanpa terikat dengan implementasi apapun. Implementasi tergantung konteks caller-nya. Misal ketika testing, instance bisa dibuat semau kita.
newtype TestM a = TesM (Aff a)
runTest :: TestM ~> AffrunTest (TestM a) = a
instance monadCacheTestM :: MonadCache TestM where readCache _ = pure $ Just "user-from-cache" writeCache _ _ = pure unit
instance monadUserDbTestM :: MonadUserDb TestM where getUser _ = pure $ Just (User "jihad")
it "fetches a user from cache" do fromCache <- runTest $ fetchUser 1 fromCache `shoudlEqual` (Just (User "user-from-cache"))
Penutup
Penggunaan type class ada dimana-dimana. Eq
, Show
, Functor
, Monad
, Applicative
, Semigroup
(Appendable), Monoid
(HazDefault), dan Traversable
adalah beberapa type class dasar yang wajib dipahami.
Dari type class jugalah kita sebenarnya bisa melihat bahwa pattern dalam programming dapat di-abstraksi sejauh mungkin. Appendable (biasa disebut Semigroup
) dan HazDefault (biasa disebut Monoid
) hanyalah contoh kecil saja. Video di bawah ini gak pernah bosen saya rekomendasikan untuk melihat bagaimana cara mengeneralisasi pattern dari sebuah masalah yang berujung pada terbentuknya type class.
Semoga bermanfaat 🙂