Topik ini membahas modul Enum, yaitu modul bawaan Elixir untuk pengolahan koleksi, beserta protokol Enumerable di balik modul tersebut. Fungsi inti seperti map, filter, dan reduce dibahas satu per satu dengan data nilai ujian siswa, dilanjutkan dengan gaya penulisan pipeline, lalu diakhiri dengan pembuktian bahwa Enum bekerja pada protokol dan bukan pada list semata. Setelah topik ini dibaca, fungsi Enum dapat dipilih dan dirangkai dengan alasan yang jelas, termasuk pada struct buatan sendiri.
Map, filter, dan reduce#
Enum.map/2 menerapkan sebuah fungsi pada setiap elemen enumerable dan mengembalikan list baru berisi hasilnya. Enum.filter/2 hanya menyimpan elemen yang membuat fungsi predikatnya mengembalikan true. Keduanya tidak mengubah data asal karena nilai di Elixir bersifat imutabel (immutable), sehingga hasil transformasi perlu ditampung dalam variabel baru.
# Fungsi inti Enum atas data nilai ujian siswa
siswa = [
%{nama: "Ali", nilai: 78},
%{nama: "Budi", nilai: 85},
%{nama: "Citra", nilai: 92},
%{nama: "Dewi", nilai: 60}
]
IO.inspect(Enum.map(siswa, & &1.nama), label: "nama")
IO.inspect(Enum.filter(siswa, &(&1.nilai >= 75)), label: "lulus")
total = Enum.reduce(siswa, 0, fn s, acc -> acc + s.nilai end)
IO.puts("Total nilai: #{total}")nama: ["Ali", "Budi", "Citra", "Dewi"]
lulus: [
%{nama: "Ali", nilai: 78},
%{nama: "Budi", nilai: 85},
%{nama: "Citra", nilai: 92}
]
Total nilai: 315Enum.reduce/3 menggabungkan seluruh elemen menjadi satu nilai dengan bantuan akumulator (accumulator), yaitu penampung hasil sementara. Argumen kedua adalah nilai awal akumulator, sedangkan argumen ketiga berupa fungsi dua argumen yang menerima elemen dan akumulator lalu mengembalikan akumulator yang diperbarui. Bentuk akumulator bebas, sehingga tuple dapat menampung dua penghitung sekaligus, misalnya total dan banyaknya elemen untuk menghitung rata-rata tanpa membaca data dua kali.
nilai = [78, 85, 92, 60]
{total, jumlah} =
Enum.reduce(nilai, {0, 0}, fn n, {t, c} -> {t + n, c + 1} end)
IO.puts("Rata-rata: #{total / jumlah}")Rata-rata: 78.75Ketiga fungsi menerima enumerable apa pun, sehingga rentang seperti 1..4 juga dapat dipetakan tanpa diubah menjadi list terlebih dahulu. Predikat pada filter berupa fungsi satu argumen yang mengembalikan boolean, dan notasi &(&1.nilai >= 75) adalah bentuk ringkas untuk fungsi semacam itu. Hasil Enum.map dan Enum.filter selalu berupa list, meskipun sumbernya berupa map atau rentang.
Pipeline sebagai gaya penulisan#
Operator |> meneruskan hasil di sisi kirinya sebagai argumen pertama fungsi di sisi kanannya, sehingga rangkaian transformasi ditulis dari atas ke bawah mengikuti urutan eksekusinya. Tanpa pipeline, alur yang sama harus ditulis sebagai panggilan bersarang yang dibaca dari dalam ke luar, atau sebagai variabel perantara untuk setiap langkah. Komunitas Elixir menjadikan pipeline sebagai gaya bawaan untuk pengolahan data, dan formatter menyelaraskan rantai |> secara otomatis. Setiap tahap menerima hasil tahap sebelumnya sebagai argumen pertamanya, sehingga tahap pertama pada contoh berikut identik dengan Enum.filter(nilai, &(&1 >= 75)).
Urutan fungsi pada pipeline menentukan hasil sekaligus pekerjaannya. Pada contoh berikut, filter berjalan lebih dahulu sehingga sort hanya mengurutkan nilai yang lolos ambang batas. Apabila sort dipindah ke depan, hasil akhirnya tetap sama, tetapi pekerjaan bertambah karena seluruh enam nilai ikut diurutkan.
nilai = [78, 85, 92, 60, 88, 74]
lulusan =
nilai
|> Enum.filter(&(&1 >= 75))
|> Enum.sort(:desc)
|> Enum.map(&Integer.to_string/1)
IO.puts("Nilai lulus, tertinggi dahulu: " <> Enum.join(lulusan, ", "))Nilai lulus, tertinggi dahulu: 92, 88, 85, 78Mengurutkan dan mengelompokkan#
Enum.sort/1 mengurutkan elemen menurut urutan term bawaan Elixir, sedangkan Enum.sort_by/3 mengurutkan menurut hasil fungsi kunci yang diberikan dan menerima arah :asc atau :desc. Enum.group_by/2 mengembalikan map yang kuncinya adalah hasil fungsi pengelompok dan nilainya berupa daftar anggota tiap kelompok dengan urutan kemunculan aslinya. Untuk mencari satu elemen terbaik, Enum.max_by/2 dan Enum.min_by/2 lebih langsung daripada mengurutkan seluruh data terlebih dahulu.
siswa = [
%{nama: "Ali", nilai: 78},
%{nama: "Budi", nilai: 85},
%{nama: "Citra", nilai: 92},
%{nama: "Dewi", nilai: 60}
]
IO.inspect(Enum.sort_by(siswa, & &1.nilai, :desc), label: "urut menurun")
IO.inspect(Enum.group_by(siswa, fn s ->
if s.nilai >= 75, do: :lulus, else: :remedial
end), label: "kelompok")
IO.inspect(Enum.max_by(siswa, & &1.nilai), label: "tertinggi")urut menurun: [
%{nama: "Citra", nilai: 92},
%{nama: "Budi", nilai: 85},
%{nama: "Ali", nilai: 78},
%{nama: "Dewi", nilai: 60}
]
kelompok: %{
lulus: [
%{nama: "Ali", nilai: 78},
%{nama: "Budi", nilai: 85},
%{nama: "Citra", nilai: 92}
],
remedial: [%{nama: "Dewi", nilai: 60}]
}
tertinggi: %{nama: "Citra", nilai: 92}Fungsi kunci pada sort_by dipanggil satu kali untuk setiap elemen, sedangkan group_by memanggil fungsi pengelompoknya dan menjadikan hasilnya kunci map. Urutan kunci pada keluaran mengikuti urutan term, sehingga atom :lulus tampil sebelum :remedial. Fungsi Enum.sort/2 juga menerima arah :asc dan :desc, sehingga penyortiran sederhana tanpa fungsi kunci cukup dengan satu argumen tambahan. Pengelompokan semacam ini lazim menjadi langkah pertama rekapitulasi, misalnya menghitung banyaknya siswa per kelompok dengan menambahkan Enum.map dan Enum.count pada hasil group_by. Semua fungsi pada bagian ini mengembalikan data baru dan membiarkan data asalnya utuh.
Protokol Enumerable di balik Enum#
Fungsi Enum tidak memeriksa apakah datanya list, map, atau rentang. Seluruh fungsi tersebut menerima satu jenis argumen yang disebut enumerable, yakni struktur data yang elemen-elemennya dapat dibaca satu per satu. Yang diperiksa oleh Enum adalah protokol Enumerable, yaitu kontrak berisi sekumpulan fungsi yang wajib dipenuhi sebuah tipe data. List, map, rentang, MapSet, dan Stream telah memiliki implementasi bawaan, sehingga fungsi Enum langsung bekerja pada tipe-tipe tersebut.
Protokol menuntut empat fungsi, yaitu reduce/3 untuk iterasi, count/1 untuk menghitung, member?/2 untuk menguji keanggotaan, dan slice/1 untuk pemotongan. Struct buatan sendiri tidak otomatis memilikinya. Apabila defimpl Enumerable ditulis untuk struct tersebut, seluruh fungsi Enum menjadi tersedia padanya tanpa menulis ulang logika pengolahannya.
# Struct daftar piket kelas beserta implementasi protokol Enumerable
defmodule DaftarPiket do
defstruct anggota: []
defimpl Enumerable do
def count(%DaftarPiket{anggota: anggota}), do: {:ok, length(anggota)}
def member?(%DaftarPiket{anggota: anggota}, elemen) do
{:ok, elemen in anggota}
end
def slice(%DaftarPiket{anggota: anggota}) do
{:ok, length(anggota), &Enum.slice(anggota, &1, &2)}
end
def reduce(%DaftarPiket{anggota: anggota}, acc, fun) do
Enumerable.List.reduce(anggota, acc, fun)
end
end
end
# struct/2 membuat struct dari nama modul dan nilai field-nya
piket = struct(DaftarPiket, anggota: ["Ali", "Budi", "Citra", "Dewi"])
IO.inspect(Enum.map(piket, &String.upcase/1), label: "map")
IO.inspect(Enum.filter(piket, &(String.length(&1) > 3)), label: "filter")
IO.inspect(Enum.count(piket), label: "count")
IO.inspect(Enum.take(piket, 2), label: "take")
IO.inspect(Enum.member?(piket, "Citra"), label: "member?")map: ["ALI", "BUDI", "CITRA", "DEWI"]
filter: ["Budi", "Citra", "Dewi"]
count: 4
take: ["Ali", "Budi"]
member?: trueImplementasi reduce di atas mendelegasikan pekerjaan kepada Enumerable.List.reduce/3, yaitu iterasi bawaan untuk list, karena isi struct memang disimpan sebagai list. Struct tanpa implementasi protokol tetap lolos kompilasi dan baru gagal saat pemanggilan dengan error Protocol.UndefinedError. Contoh berikut menangkap error tersebut dengan try/rescue lalu mencetak pesannya.
defmodule KotakTanpaProtokol do
defstruct isi: []
end
kotak = struct(KotakTanpaProtokol, isi: [1, 2, 3])
try do
Enum.map(kotak, &(&1 * 2))
rescue
e in Protocol.UndefinedError ->
IO.puts("Protocol.UndefinedError: " <> Exception.message(e))
endProtocol.UndefinedError: protocol Enumerable not implemented for KotakTanpaProtokol (a struct)
Got value:
%KotakTanpaProtokol{isi: [1, 2, 3]}Kesalahan umum#
Kesalahan pertama adalah menganggap fungsi Enum mengubah data asal. Enum.map mengembalikan list baru, sedangkan variabel lama tetap berisi data awal apabila hasilnya tidak ditampung. Penyebabnya sering terbawa dari kebiasaan pada bahasa pemrograman yang mengizinkan perubahan list di tempat. Hasil transformasi selalu perlu diikat ke variabel atau diteruskan ke pemanggil berikutnya.
nilai = [1, 2, 3]
Enum.map(nilai, &(&1 * 10))
IO.inspect(nilai, label: "nilai tetap")nilai tetap: [1, 2, 3]Kesalahan kedua adalah memanggil Enum.reduce/2 tanpa nilai akumulator awal pada list kosong. Bentuk ini mengasumsikan elemen pertama sebagai akumulator, sehingga list kosong tidak memiliki titik mulai dan melempar Enum.EmptyError. Memberikan nilai awal eksplisit, misalnya 0, membuat kasus kosong menghasilkan nilai yang wajar. Nilai awal 0 pada contoh bagian pertama adalah bentuk yang aman untuk kasus semacam ini.
try do
Enum.reduce([], &(&1 + &2))
rescue
e in Enum.EmptyError -> IO.puts("Enum.EmptyError: " <> Exception.message(e))
endEnum.EmptyError: empty errorKesalahan ketiga bersifat pemilihan alat. Enum mengeksekusi setiap langkah seketika dan menyimpan koleksi perantara, sehingga penggunaannya masih tepat untuk data yang ukurannya wajar. Untuk rentang sangat besar atau berkas yang dibaca baris demi baris, rantai Stream menghindari penyalinan koleksi perantara tersebut.
Ringkasan#
Enum.map,Enum.filter, danEnum.reduceadalah tiga fungsi inti pengolahan enumerable, dengan akumulator sebagai penampung hasil pada reduce.- Operator
|>merangkai fungsi dari atas ke bawah, sehingga urutan transformasi terbaca langsung dari penulisannya. Enum.sort_bydanEnum.group_bymengurutkan serta mengelompokkan menurut hasil fungsi kunci.- Protokol Enumerable menuntut
reduce/3,count/1,member?/2, danslice/1, sedangkandefimplpada struct membuka seluruh fungsi Enum untuk struct tersebut.
Referensi#
- Modul Enum — Dokumentasi Elixir — Daftar fungsi Enum beserta catatan kompleksitas setiap fungsinya.
- Protokol Enumerable — Dokumentasi Elixir — Kontrak protokol yang membuat sebuah struktur data dapat diproses Enum.