Kenalan Dulu sama Type Class

  •  9 min read
Image by <a href="https://pixabay.com/users/HeungSoon-4523762/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=2671511">HeungSoon</a> from <a href="https://pixabay.com/?utm_source=link-attribution&amp;utm_medium=referral&amp;utm_campaign=image&amp;utm_content=2671511">Pixabay</a>
Image by HeungSoon from Pixabay

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
  1. 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 adalah a.
  2. Type class Show ini memiliki satu method bernama show, yang menerima a 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"
  1. Di sini kita membuat instance Show untuk Email dengan nama showEmail (nama instance bisa kita abaikan, tidak terlalu penting untuk saat ini).
  2. Implementasi show. Inilah gunanya type variable a di atas tadi. Sekarang a telah di-instantiate dengan Email 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 +.

Terminal window
$ '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 dasarnya
interface Appendable<A> {
append: (other: A) => A
}
// interface merging!
interface String extends Appendable<string> { }
interface Array<T> extends Appendable<Array<T>> { }
// Tambah behaviour dengan prototype
String.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 Int
newtype 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 100
append (Prod 50) (Prod 2) -- Prod 100
append (Sum 99) defaultVal -- Sum 99
append (Prod 50) defaultVal -- Prod 50

Constraint

Type class is all about instance and constraint. Mari perhatikan contoh berikut:

guard :: a. HazDefault a => Boolean -> a -> a
guard true val = val
guard 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 -> Boolean
isRoot user = user == "jihad"
-- Untuk String
welcomeMessage :: User -> String
welcomeMessage user = guard (isRoot user) "Welcome, root!"
-- Untuk Array
access :: User -> Array Int
access 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:

purescript-repl
$ :t append
append :: Appendable a => a -> a -> a
$ :t defaultVal
defaultVal :: 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 -> a
guard true x y = x `append` y
guard 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 -> a
guard { append, defaultVal } true x y = x `append` y
guard { 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 -> a
invalid 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 ~> Aff
runTest (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 🙂