Subtype Polymorphism vs Algebraic Data Types (ADT)

Pendekatan Polymorphism dalam Object-Oriented Programming dan Functional Programming

Jihad Dzikri Waspada
codewey
5 min readMay 2, 2019

--

Many variants (source)

Inspirasi artikel ini saya dapatkan dari tulisan ringkas Reid Evans. Bisa dibaca di sini. Artikel saya ini bisa dibilang terjemahan dari artikel tersebut dengan beberapa poin tambahan (di bawah section “Kesimpulan”) 😆🙏🏻

Kita mulai dari Polymorphism dulu.

🌈 Polymorphism

Dalam dunia OOP, polymorphism lumrahnya me-refer ke istilah Subtype Polymorphism dimana suatu data (bisa interface atau parent class) mempunyai beberapa “variant” (yang umum disebut child classes).

Ketika kita bicara tentang “Shape”, beberapa variant di antaranya bisa berupa “Rectangle” atau “Triangle”. Di Typescript, kita bisa menuliskannya dengan gaya OOP seperti ini:

interface Shape {}class Rectangle implements Shape {}
class Triangle implements Shape {}

Simpel. Namun dalam paradigma Functional Programming, alih-alih mengandalkan teknik subtyping seperti ini, kita bisa mengekspresikannya dengan suatu konsep yang bernama Algebraic Data Types (ADT). Kayak gimana tuh?

Kek gini (dalam Haskell):

data Shape = Rectangle | Triangle

Terlihat lebih singkat dan straightforward. Namun kita coba telusuri dulu lebih lanjut perbandingan dari kedua pendekatan di atas dari beberapa sisi.

Code di atas kayak gak ada gunanya karena kita cuma men-define datanya saja: masih kopong, belum ada behaviour-nya. Kita coba kasih dua buah behaviour: display, dan getSides.

OOP

interface Shape {
display: () => string;
getSides: () => number;
}
class Rectangle implements Shape {
display = () => "I'm a Rectangle"
getSides = () => 4
}
class Triangle implements Shape {
display = () => "I'm a Triangle"
getSides = () => 3
}

Bagaimana kalau di FP?

FP

Kalau pake cara FP (at least di Haskell), kita bisa menggunakan teknik pattern-matching di function-nya. Semua inhabitants (dalam hal ini Rectangle dan Triangle) harus muncul.

data Shape = Rectangle | Triangledisplay :: Shape -> String
display Rectangle = "I'm a Rectangle"
display Triangle = "I'm a Triangle"
getSides :: Shape -> Number
getSides Rectangle = 4
getSides Triangle = 3

Adding Variants 🎨

Bagaimana kalau sekarang kita tambah variant Shape-nya? Misal kita tambah Circle. Di OOP, kita hanya perlu membuat class baru tanpa mengubah code baik di Shape, Rectangle, atau Triangle.

...class Circle implements Shape {
display = () => "I'm a Circle"
getSides = () => 1
}

Sebaliknya, di dunia FP kita perlu sedikit refactor, karena semua inhabitants harus muncul di fungsi-fungsi tersebut agar pattern-matching nya bersifat exhaustive.

data Shape = Rectangle | Triangle | Circledisplay :: Shape -> String
display Rectangle = "I'm a Rectangle"
display Triangle = "I'm a Triangle"
display Circle = "I'm a Circle"
getSides :: Shape -> Number
getSides Rectangle = 4
getSides Triangle = 3
getSides Circle = 1

Nah dalam hal ini, pendekatan OOP terlihat lebih mudah karena kita tidak perlu melakukan refactor dan bisa membuat “variant” sebanyak yang kita mau.

Adding Behaviours 🚶🏻‍♂️

Sekarang kita coba lihat dari sisi lain: gimana kalau kita tambah behaviour-nya lagi? Misal kita juga ingin tahu luas dari masing-masing Shape tersebut.

interface Shape {
display: () => string;
getSides: () => number;
area: (length: number, height: number) => number;
}
class Rectangle implements Shape {
display = () => "I'm a Rectangle"
getSides = () => 4
area = (length, height) => length * height
}
class Triangle implements Shape {
display = () => "I'm a Triangle"
getSides = () => 3
area = (base, height) => base * height / 2
}

Nah sekarang gantian OOP yang perlu banyak refactor jika ada penambahan/perubahan behaviour. Emang sih, ada beberapa prinsip supaya meminimalisir perubahan pada interface kita, SOLID principles salah satunya. Tapi kita gak akan bahas itu karena out of topic 🙃

Sebaliknya, pendekatan FP tidak perlu mengubah ADT dan function yang sudah de-define: kita hanya perlu menambah function area.

...area :: Shape -> Number -> Number -> Number
area Rectangle length height = length * height
area Triangle base height = base * height / 2

Kesimpulan

Pendekatan OOP bisa lebih unggul jika kita sudah tau lebih dulu spesifikasinya (interface), tinggal buat aja variant-nya (child classes) sebanyak yang kita mau. Sedangkan FP lebih suka menggunakan Algebraic Data Types dimana jumlah variant-nya (inhabitant) sudah fix, tinggal menambahkan behaviour-nya (function) saja untuk masing-masing inhabitant tersebut. Dan kita bisa menambahkan behaviour sebanyak yang kita mau.

Pola ADT dalam FP ini bisa dengan mudah ditemukan pada beberapa data structure dasar:

More on ADT

Lho kan sudah kesimpulan? Hehe iya, namun masih ada beberapa hal yang ingin saya tambahkan terkait dengan ADT ini.

Separation of Concern

Kalau dilihat lebih dalam lagi, OOP dengan konsep class-nya menggabungkan dua konsep sekaligus: Data dan Behaviour.

class Rectangle implements Shape {
display = () => "I'm a Rectangle"
getSides = () => 4
}

Data-nya adalah Rectangle, behaviour-nya adalah display dan getSides. Keduanya digabungkan ke dalam satu value yang biasa kita kenal dengan istilah class.

Sedangkan dalam pendekatan FP, kedua konsep ini dipisah: data sendiri, behaviour sendiri.

-- Data
data Shape = Rectangle | Triangle
-- Behaviour
display :: Shape -> String
display Rectangle = "I'm a Rectangle"
display Triangle = "I'm a Triangle"
getSides :: Shape -> Number
getSides Rectangle = 4
getSides Triangle = 3

Autonomous

Di contoh yang saya tulis pada section “Adding Behaviours”, saya tidak menyinggung penambahan behaviour area pada Circle. Kalaupun iya, saya sendiri pun bingung bagaimana harus mengimplementasikannya.

interface Shape {
...
area: (length: number, height: number) => number;
}
class Circle implements Shape {
...
area = (length, height) => ???
}

Kira-kira ada yang kurang tepat? Yes! Secara intuitif, lingkaran tidak memiliki attribute length dan height. Menurut saya, radius adalah attribute yang lebih tepat untuk menghitung luas lingkaran.

area = (radius: number) => PI * radius * radius;

Namun ada konsekuensi yang harus dibayar: dengan mengubah interface Shape. Diubah-pun akan membuat Rectangle dan Triangle tidak compatible lagi karena masing-masing punya caranya sendiri untuk menghitung luas. Syedih jadinya 😢

Untuk mengatasi masalah ini, variable-variable yang dibutuhkan untuk menghitung luas bisa dipindahkan ke class constructor

interface Shape {
...
area: () => number;
}
class Rectangle implements Shape {
constructor(private length, private height) {}
...
area = () => this.length * this.height
}
class Circle implements Shape {
constructor(private radius) {}
...
area = () => PI * this.radius * this.radius
}

Adapun di FP, inhabitants ADT bisa berupa apa saja, dan behaviour-nya (function) pun tinggal mengikuti bentuk masing-masing inhabitant-nya.

Pendapat saya pribadi, ADT dalam hal ini lebih fleksibel dan intuitif. Pattern-matching to the rescue! 😎

If you find this article useful, don’t hesitate to hit the clap button so that your friends could know this story.

Semoga bermanfaat 😉

--

--

Jihad Dzikri Waspada
codewey

Software Developer @Chordify, Utrecht. NOTE: Please navigate to https://jihadwaspada.com. I no longer write on Medium