diskusi.tech (beta) Community

loading...
Cover image for Berpikir Secara Fungsional: Memakai Paradigma Fungsional dalam Memrogram Perangkat Lunak
DeepTechID

Berpikir Secara Fungsional: Memakai Paradigma Fungsional dalam Memrogram Perangkat Lunak

mimindeeptech profile image Mimin Deep Tech ・8 min read

Penulis: Didiet Noor

Dalam 50 tahun terakhir, teknologi informasi dan perangkat lunak berkembang begitu pesat. Seperti halnya industri rekayasa lain, alat dan metode produksi perangkat lunak pun berubah mengikuti zaman. Paradigma-paradigma baru datang dan menambah pilihan dalam memecahkan suatu masalah. Perkembangannya bisa dilihat dengan adanya abstraksi-abstraksi baru dalam pembuatan program. Salah satu dari paradigma abstraksi yang mulai banyak didengungkan belakangan ini adalah paradigma pemrograman fungsional.

Mengenal Pemrograman Fungsional

Sebelum kita membahas lebih jauh tentang paradigma ini, kita bisa kembali ke matematika di zaman kelas menengah terdahulu, lihatlah persamaan-persamaan berikut ini:

PT

Dalam persamaan linier seperti di atas, kita bisa menukar nilai 𝓍 dengan sembarang angka bilangan bulat karena dibatasi oleh β„€ yang berarti himpunan seluruh bilangan bulat. Jika kita terjemahkan menjadi kode dalam bahasa pemrograman adalah sebagai berikut

fn add5(x: u32) -> u32 { x + 5 }

Nilai dari variabel x tidak pernah berubah, tetapi fungsi ini akan menambah 5 untuk sembarang variabel x. Nilai dari x tidak pernah berubah sekali ditetapkan. Dalam matematika, pernyataan x = x + 1 tidak pernah benar, karena x tidak akan sama dengan x + 1. Hal ini dikarenakan karena semantik tanda = adalah kesamaan, bukan assignment atau perubahan.

PT

Perbandingan Dengan Pemrograman Imperatif

Dalam pemrograman imperatif jalannya program digambarkan seperti resep memasak, langkah per langkah dalam satuan waktu. Anggaplah kita punya kasus sebagai berikut

Terdapat sebuah daftar nama yang mana terdapat daftar nama dengan satu huruf. Kapitalkan dan gabungkan nama-nama tersebut dengan koma, kecuali nama-nama dengan satu huruf.

Dalam pemrograman imperatif kita akan menggunakan perulangan for atau while.

static NAMES: &[&str] = &[
    "Didiet", "D", "20", "Asep", "Kiki", "Luqman", "L"
];
fn comma_concat_imp(names: &[&str]) -> String {
    let mut result = String::new(); 
    if names.len() == 0 {
        return result;
    }

    for name in names {
        if name.len() > 1 {
            result.push_str(name.to_uppercase());
            result.push_str(",");
        }
    }

    result[0..result.len()-1].to_string()
}
fn main() {
    println!("Gabungan {}", comma_concat_imp(NAMES));
}
Enter fullscreen mode Exit fullscreen mode

Ketika memproses sebuah array/list secara intuisi, kita akan memakai for. Yang mana bisa dilihat di kode di atas result bersifat mutable. Yang artinya berubah-ubah selama proses berlangsung. Algoritma di atas jika dijabarkan kurang lebih: untuk setiap nama yang ada di senarainames, kita akan saring dan pilih hanya name dengan panjang lebih dari satu, lalu kita transformasi nama tersebut menjadi huruf besar semua, lalu kita gabungkan ke dalam string result.

Dalam pemrograman fungsional, penyaringan, transformasi, dan penggabungan (filter, map, reduce) adalah tiga hal yang sangat mendasar. Mari kita ubah algoritma tadi menjadi algoritma fungsional.

fn comma_concat_fun(names: &[&str]) -> String {
    names.iter().filter(|s| s.len() > 1)
                .map(|s| s.to_uppercase())
                .fold(String::new(), |a, s| if a.is_empty() {s} else {[a, s].join(",")})
}
Enter fullscreen mode Exit fullscreen mode

Dalam implementasi secara fungsional kita melakukan tiga hal, sama persis. Hanya kita nyatakan dengan tiga fungsi yang diterapkan di masing-masing butir dari senarai tersebut. Perbedaan mendasar adalah ada di dalam menentukan langkahnya. Dalam pemrograman imperatif kita sangat memperhatikan urutan dalam kerangka satuan waktu, sementara dalam pemrograman fungsional kita melihat ini dalam kerangka transformasi atau morfisme dari satu tipe ke tipe lain.

Making Sense Of Category Theory

Mudahnya, pertanyaannya bukan lagi tentang bagaimana langkahnya tetapi lebih kepada apa yang mau kita buat.

Komponen Yang Membangun Pemrograman Fungsional

Secara mudah sebenarnya pemrograman fungsional itu dibangun atas morfisme berikut ini.

Saringan atau Filter
Sebuah filter adalah cara memilih nilai dari sebuah senarai. Sebuah operasi filter menerima sebuah fungsi yang mengembalikan true atau false. Analogi yang paling mudah mungkin adalah klausa where ketika melakukan query ke basis data. Dari contoh kasus di atas kita memakai operasi filter untuk memilih nama yang mempunyai lebih dari 1 huruf:

names.iter().filter(|name| name.len() > 1).collect()
// Input: ["Didiet", "D", "20", "Asep", "Kiki", "Luqman", "L"]
// Output: ["Didiet", "20", "Asep", "Kiki", "Luqman"]
Enter fullscreen mode Exit fullscreen mode

Map atau Transformasi
Operasi map adalah cara melakukan operasi transformasi pada nilai yang dikandung dari senarai tersebut. Misalnya kita ingin mendapatkan senarai yang berisi panjang dari masing-masing string tersebut.

names.iter().map(|name| name.len()).collect()
// Input: ["Didiet", "D", "20", "Asep", "Kiki", "Luqman", "L"]
// Output: [5, 1, 2, 4, 4, 6, 1];
Enter fullscreen mode Exit fullscreen mode

Fungsi/lambda yang dimasukkan adalah yang menghasilkan nilai baru dari nilai yang dimasukkan, dalam hal ini adalah panjang string.

Menggabungkan, Melipat atau Fold/Reduce
Operasi penggabungan biasanya dinamai reduce atau fold. Operasi ini menerima fungsi yang mengambil dua parameter, parameter pertama adalaha akumulator yg merupakan hasil gabungan, dan parameter kedua adalah nilai sekarang

names.iter().map(|name| name.len())
            .fold(0, |acc, name| acc + name.len())
// Input: ["Didiet", "D", "20", "Asep", "Kiki", "Luqman", "L"]
// Output: 23
Enter fullscreen mode Exit fullscreen mode

Operasi di atas akan menghasilkan satu nilai yang berbentuk bilangan bulat. Di sinilah mengapa namanya adalah reducer. Operasi di atas jika kita terjemahkan secara imperatif kurang lebih sbb:

fn sumlen(names: &[&str]) -> usize {
    let mut acc: usize = 0; // akumulator dan nilai awal

    for name in names {
        acc += name.len()
    }

    acc
}
Enter fullscreen mode Exit fullscreen mode

Keuntungan Menggunakan Paradigma Fungsional

Keuntungan paling jelas dari menggunakan versi fungsional daripada versi imperatifnya adalah karena dalam implementasinya bisa dikerjakan secara paralel.

Ilustrasinya misalnya kita ingin menghitung jumlah panjang 10 juta dokumen kita bisa membagi operasinya ke dalam 4 mesin yang masing-masing punya 8 core.

pt

Artinya masing masing core akan memproses sekitar 312 ribu dokumen secara bersamaan yang kemudian akan digabungkan. Karena operasi penambahan bersifat asosiatif, maka urutan penambahan dari mesin yang mana di core yang mana menjadi tidak penting.

Beberapa bahasa seperti Java, menyediakan pustaka untuk melakukan pemrosesan paralel seperti parallel stream mulai di Java 8. Rust, menyediakan pustaka bernama rayon untuk iterator paralel. Perubahannya hanya mengubah iter() menjadi par_iter(). Kode sebelumnya bisa diparalelkan sbb.:

extern crate rayon;
use rayon::prelude::*;
names.par_iter().map(|name| name.len())
            .fold(0, |acc, name| acc + name.len())
// Input dan outputnya sama, tetapi bisa jadi array-nya dibagi-bagi 
// dulu supaya seluruh core prosesor sibuk.
Enter fullscreen mode Exit fullscreen mode

Cara Pikir Fungsional Untuk Operasi Yang Tidak Tentu Hasilnya

Di dunia nyata, kita tidak selalu berada dalam situasi yang ideal di mana suatu nilai itu selalu ada. Misalnya dalam mencari suatu nilai kita bisa saja mendapatkan suatu nilai atau tidak sama sekali. Operasi ini dinyatakan dengan tipe polimorfis atau enumerasi. Contoh tipe polimorfis misalnya Optionatau Result.

Option
Tipe Optionadalah untuk menyatakan ada atau tidaknya suatu nilai. Secara mudahnya, jika suatu operasi bisa menghasilkan suatu nilai atau tidak ada gunakan tipe ini. Tipe data Option bisa diisi dengan Some(T) dan None. Mungkin ada yang berpikir ini mirip dengan nil atau null. Berbeda, karena None adalah suatu nilai sendiri yang menggambarkan ketiadaan dan bukannya null pointer.

Contoh konkretnya misalnya kita ingin mencari jumlah dari operasi 1/𝓍. Operasi 1/𝓍 bisa menghasilkan sebuah nilai, bisa jadi tidak jika 𝓍 = 0. Karena sifatnya seperti itu maka kita definisikan fungsinya sbb:

fn inv(x: &u32) -> Option<f32> {
   if *x == 0 {
       None
   } else {
       Some(1.0/(*x as f32))
   }
}
Enter fullscreen mode Exit fullscreen mode

Jika kita gunakan untuk melakukan map dari satu senarai bilangan bulat sebagai berikut

let numbers = [0, 1, 6, 0, 9, 0];
let inverse: Vec<Option<f32>> = numbers.iter().map(inv).collect();
println!("Inverses: {:?}", inverse);
// Output
// Inverses: [None, Some(1.0), Some(0.16666667), None, Some(0.11111111), None]
Enter fullscreen mode Exit fullscreen mode

Jika kita lanjutkan, kita ingin menjumlahkannya, kita bisa memakai fungsi fold dengan pattern matching. Fitur pattern matching adalah fitur yang paling penting untuk bahasa yang menyatakan diri mendukung pemrograman fungsional.

let sum: Option<f32> = numbers.iter().map(inv)
      .fold(Some(0.0), |a, s| match a {
          None => None,
          Some(a) => match s {
            None => None,
            Some (v) => Some(a+v)
          }
      });
 println!("The Sum is: {:?}", sum)
Enter fullscreen mode Exit fullscreen mode

Fungsi fold di atas akan melakukan cek atas nilai a dan s, jika salah satunya nilainya None, maka selanjutnya akan selalu None. Jika kita terapkan di variabel number yang tadi, karena ada nilai 0 maka hasilnya akan None. Tetapi jika kita terapkan di sebuah senarai yang tidak ada nilai 0 nya, maka kita akan mendapatkan suatu nilai.

let numbers = [1, 6, 7, 8, 10];

let sum: Option<f32> = numbers.iter().map(inv)
      .fold(Some(0.0), |a, s| match a {
          None => None,
          Some(a) => match s {
            None => None,
            Some (v) => Some(a+v)
          }
      });
println!("The Sum is: {:?}", sum)
// Outputnya
// Some(1.5345238)
Enter fullscreen mode Exit fullscreen mode

Memperkenalkan flatmap
Terlihat dalam lambda di atas ada nested match. Bentuk seperti ini umum dalam pemrograman fungsional jika kita ingin melakukan sesuatu setelah kita ambil isi dari tipe data polimorfis tersebut. Supaya tidak nested, biasanya suatu bahasa mempunyai operasi flatmap. Sayangnya, tiap bahasa punya nama yang berbeda-beda. Di Java misalnya, namanya thenApply untuk tipe CompletableFuture, sedangkan di Rust, namanya adalah and_then. Penjumlahan `sum diatas bisa dinyatakan dalam fungsi berikut.


let sum: Option<f32> = numbers.iter().map(inv)
.fold(Some(0.0),
|a, s| a.and_then(|a| s.and_then(|s| Some(a + s))));

Yang kurang lebih dibaca: β€œbila diketahui nilai a jika tidak None maka lakukan operasi pada s, jika s tidak None maka jumlahkan a dan s; Jika None maka hasil seterusnya akan None”.

Result
Tipe data polimorfis yang lain yang sering digunakan adalah berupa Result. Jika Option pilihannya adalah sebuah nilai atau None`, Result pilihannya adalah sebuah nilai atau sebuah galat. Contoh yang paling jelas dan mungkin familiar adalah ketika meminta data dari basis data atau dari server antarmuka pemrograman aplikasi.

use std::net::TcpStream;
use std::io::Result;
fn main() {
  let addresses = ["google.com", "foobar.invalid", "ykode.id"];
  let connections: Vec< Result<TcpStream> > =
    addresses.iter()
        .map(|addr| [addr, ":80"].concat())
        .map(|addr| TcpStream::connect(addr))
        .collect();
  println!("Conns: {:?}", connections);
}
Enter fullscreen mode Exit fullscreen mode

TcpStream::connect mengembalikan tipe data berupa Result. Karena alamat kedua adalah invalid, output dari program di atas adalah kurang lebih seperti ini:

Conns: [Ok(TcpStream { addr: V4(192.168.0.13:60075), peer: V4(172.217.27.14:80), fd: 5 }), 
Err(Custom { kind: Other, error: StringError("failed to lookup address information: nodename nor servname provided, or not known") }), 
Ok(TcpStream { addr: V4(192.168.0.13:60076), peer: V4(52.0.16.118:80), fd: 7 })]
Enter fullscreen mode Exit fullscreen mode

Terlihat ada Ok dan ada Err sebab kita tidak pernah tau apakah koneksi tadi berhasil.

Menggunakan Tipe Polimorfik Dalam Mendesain Perangkat Lunak
Contoh ini bisa dikembangkan lagi misalnya kita ingin melakukan pencarian produk berdasar ID.

Pertama, kita tentukan inputnya adalah ID nya yang berupa string. Sementara outputnya bisa berupa informasi produk atau galat.

pt

Karena kita melakukan query juga ke basis data fungsi yang di tengah kita pecah lagi menjadi dua fungsi. Pertama yang memanggil query yang kedua yang mengembalikan tipe query.

pt

Terkadang kita melakukan panggilan ke layanan lain, misalnya layanan untuk stok. Maka transformasi tipenya akan seperti ini.

pt

Kita bisa memecah panggilan tersebut jadi lebih kecil lagi jika kita memanggil layanan dependensi yang lain. Setidaknya dengan mengetahui perubahan tipe data yang terjadi di dalam program yang kita tulis, kita tau apakah semua data dan semua galat sudah ditangani.

Tipe-tipe polimorfik ini sebenarnya banyak. Selain Result ada juga Future. Jika Result biasanya bersifat synchronous sementara Future bersifat asynchronous. Secara semantiknya kedua tipe tersebut adalah sama, menerima nilai dan galat. Hanya saja untuk Future, nilai yang di dalam adalah nilai yang mungkin terjadi di masa depan. Sudah pernah dibahas oleh
Harimurti Prasetio di artikel sebelumnya dalam bahasa Scala.

Berkenalan dengan Scala

Kesimpulan

Berpikir dengan paradigma fungsional memang sedikit berbeda dengan berpikir secara imperatif. Paradigma fungsional lebih abstrak daripada paradigma imperatif. Kita akan terlatih untuk berpikir dalam kerangka transformasi (dari sesuatu ke sesuatu) daripada dalam kerangka langkah-langkah dalam suatu proses.

Discussion

pic
Editor guide