Comparable #
Ketika kamu menulis kelas sendiri di Ruby, objek dari kelas tersebut tidak bisa langsung dibandingkan satu sama lain — Ruby tidak tahu apa artinya “lebih besar” atau “lebih kecil” untuk domain masalahmu. Modul Comparable menyelesaikan ini dengan cara yang elegan: implementasikan satu operator, yaitu <=> (spaceship operator), dan Ruby secara otomatis memberikanmu <, >, <=, >=, between?, dan clamp. Tidak perlu menulis enam method perbandingan secara terpisah. Comparable adalah contoh sempurna dari filosofi Ruby — satu kontrak minimal yang memberikan fungsionalitas maksimal.
Cara Kerja Comparable #
Comparable bekerja berdasarkan satu asumsi: jika kamu bisa mendefinisikan hubungan “lebih dari”, “kurang dari”, dan “sama dengan” antara dua objek melalui <=>, maka semua perbandingan lain bisa diturunkan darinya.
Spaceship operator <=> harus mengembalikan:
-1(atau nilai negatif apapun) jikaselfkurang dari objek yang dibandingkan0jika keduanya sama1(atau nilai positif apapun) jikaselflebih dari objek yang dibandingkanniljika perbandingan tidak bisa dilakukan (tipe tidak kompatibel)
# Bagaimana Comparable digunakan secara internal
# Ketika kamu memanggil a < b, Ruby melakukan ini:
# (a <=> b) < 0
# Ketika kamu memanggil a >= b, Ruby melakukan ini:
# (a <=> b) >= 0
# Ketika kamu memanggil a.between?(min, max), Ruby melakukan ini:
# min <= a && a <= max
# yang diterjemahkan ke:
# (min <=> a) <= 0 && (a <=> max) <= 0
# Spaceship operator pada tipe bawaan Ruby
1 <=> 2 # => -1
2 <=> 2 # => 0
3 <=> 2 # => 1
"a" <=> "b" # => -1
"b" <=> "a" # => 1
1 <=> "a" # => nil (tidak bisa dibandingkan)
flowchart TD
A["include Comparable"] --> B["Implementasi <=>"]
B --> C["Comparable menyediakan secara otomatis"]
C --> D["< kurang dari"]
C --> E["> lebih dari"]
C --> F["<= kurang dari sama dengan"]
C --> G[">= lebih dari sama dengan"]
C --> H["between? dalam rentang"]
C --> I["clamp batasi dalam rentang"]
B --> J["Integrasi dengan Enumerable"]
J --> K["sort / sort_by"]
J --> L["min / max / minmax"]Implementasi Dasar #
Mari bangun kelas nyata yang menggunakan Comparable. Contoh terbaik adalah objek yang secara natural memiliki urutan alami.
class Versi
include Comparable
attr_reader :major, :minor, :patch
def initialize(versi_string)
bagian = versi_string.split(".").map(&:to_i)
@major = bagian[0] || 0
@minor = bagian[1] || 0
@patch = bagian[2] || 0
end
# Satu-satunya method yang wajib diimplementasikan
def <=>(lain)
return nil unless lain.is_a?(Versi)
# Bandingkan major dulu, lalu minor, lalu patch
return major <=> lain.major if major != lain.major
return minor <=> lain.minor if minor != lain.minor
patch <=> lain.patch
end
def to_s
"#{major}.#{minor}.#{patch}"
end
end
v1 = Versi.new("1.0.0")
v2 = Versi.new("1.2.0")
v3 = Versi.new("2.0.0")
v4 = Versi.new("1.2.3")
# Semua operator perbandingan tersedia otomatis
v1 < v2 # => true
v3 > v2 # => true
v1 <= v1 # => true
v2 >= v1 # => true
# between? — apakah dalam rentang?
v2.between?(v1, v3) # => true
v4.between?(v1, v3) # => true
# Pengurutan otomatis lewat Enumerable
versi_list = [v3, v1, v4, v2]
versi_list.sort
# => ["1.0.0", "1.2.0", "1.2.3", "2.0.0"]
versi_list.min # => 1.0.0
versi_list.max # => 2.0.0
Contoh: Kelas dengan Comparable #
Berikut beberapa contoh kelas dari domain yang berbeda untuk menunjukkan fleksibilitas Comparable.
Nilai Mata Uang #
class Uang
include Comparable
attr_reader :jumlah, :mata_uang
def initialize(jumlah, mata_uang = "IDR")
@jumlah = jumlah.to_r # Rational untuk presisi
@mata_uang = mata_uang
end
def <=>(lain)
return nil unless lain.is_a?(Uang) && mata_uang == lain.mata_uang
jumlah <=> lain.jumlah
end
def +(lain)
raise "Mata uang berbeda" unless mata_uang == lain.mata_uang
Uang.new(jumlah + lain.jumlah, mata_uang)
end
def to_s
format("#{mata_uang} %.2f", jumlah)
end
end
harga_a = Uang.new(150_000)
harga_b = Uang.new(200_000)
harga_c = Uang.new(75_000)
harga_a < harga_b # => true
harga_b > harga_c # => true
# Langsung bisa sort dan min/max
daftar_harga = [harga_b, harga_a, harga_c]
daftar_harga.sort.map(&:to_s)
# => ["IDR 75000.00", "IDR 150000.00", "IDR 200000.00"]
daftar_harga.min.to_s # => "IDR 75000.00"
daftar_harga.max.to_s # => "IDR 200000.00"
# between? untuk validasi rentang harga
batas_bawah = Uang.new(50_000)
batas_atas = Uang.new(300_000)
harga_a.between?(batas_bawah, batas_atas) # => true
Prioritas Tugas #
class Tugas
include Comparable
PRIORITAS = { kritis: 4, tinggi: 3, sedang: 2, rendah: 1 }.freeze
attr_reader :nama, :prioritas, :deadline
def initialize(nama, prioritas, deadline)
@nama = nama
@prioritas = prioritas
@deadline = deadline
end
def <=>(lain)
return nil unless lain.is_a?(Tugas)
# Prioritas lebih tinggi = "lebih besar"
# Deadline lebih dekat = "lebih besar" (lebih mendesak)
skor_prioritas = PRIORITAS[@prioritas] <=> PRIORITAS[lain.prioritas]
return -skor_prioritas if skor_prioritas != 0 # descending priority
# Jika prioritas sama, deadline lebih dekat = lebih mendesak
lain.deadline <=> @deadline
end
def to_s
"[#{prioritas}] #{nama} — #{deadline}"
end
end
tugas_list = [
Tugas.new("Deploy production", :kritis, Date.today + 1),
Tugas.new("Update README", :rendah, Date.today + 7),
Tugas.new("Fix bug login", :tinggi, Date.today + 2),
Tugas.new("Code review", :sedang, Date.today + 3),
Tugas.new("Hotfix payment", :kritis, Date.today)
]
# sort mengurutkan dari yang paling mendesak
tugas_list.sort.each { |t| puts t }
# [kritis] Hotfix payment — hari ini
# [kritis] Deploy production — besok
# [tinggi] Fix bug login — 2 hari lagi
# [sedang] Code review — 3 hari lagi
# [rendah] Update README — 7 hari lagi
tugas_list.max # tugas paling mendesak
tugas_list.min # tugas paling tidak mendesak
Spaceship Operator: Kasus-kasus Khusus #
Ada beberapa hal yang perlu diperhatikan saat mengimplementasikan <=>.
Mengembalikan nil untuk Tipe Tidak Kompatibel #
class Suhu
include Comparable
attr_reader :nilai, :satuan
def initialize(nilai, satuan = :celsius)
@nilai = nilai
@satuan = satuan
end
def ke_celsius
case satuan
when :celsius then nilai
when :fahrenheit then (nilai - 32) * 5.0 / 9
when :kelvin then nilai - 273.15
end
end
def <=>(lain)
# Kembalikan nil jika bukan Suhu — ini penting!
return nil unless lain.is_a?(Suhu)
# Konversi ke celsius dulu sebelum bandingkan
ke_celsius <=> lain.ke_celsius
end
def to_s
"#{nilai}°#{satuan.to_s[0].upcase}"
end
end
panas = Suhu.new(100, :celsius)
hangat = Suhu.new(212, :fahrenheit) # = 100°C juga
dingin = Suhu.new(300, :kelvin) # = 26.85°C
panas == hangat # => true (sama-sama 100°C)
panas > dingin # => true (100°C vs 26.85°C)
# Nil dikembalikan untuk tipe tidak kompatibel
panas <=> "panas" # => nil
# Pengecekan tipe dengan nil-aware
begin
panas < "sangat panas" # ArgumentError karena nil dari <=>
rescue ArgumentError => e
puts "Tidak bisa dibandingkan: #{e.message}"
end
Delegasi ke Atribut #
Cara paling umum mengimplementasikan <=> adalah mendelegasikan ke atribut yang sudah tahu cara membandingkan dirinya sendiri.
class Pegawai
include Comparable
attr_reader :nama, :masa_kerja, :gaji
def initialize(nama, masa_kerja, gaji)
@nama = nama
@masa_kerja = masa_kerja # dalam tahun
@gaji = gaji
end
# Urutkan berdasarkan masa kerja, lalu gaji jika sama
def <=>(lain)
return nil unless lain.is_a?(Pegawai)
# Delegasi ke Integer#<=> dan Float#<=>
[masa_kerja, gaji] <=> [lain.masa_kerja, lain.gaji]
end
def to_s
"#{nama} (#{masa_kerja}th, #{gaji})"
end
end
pegawai = [
Pegawai.new("Alice", 5, 15_000_000),
Pegawai.new("Bob", 3, 12_000_000),
Pegawai.new("Charlie", 5, 18_000_000),
Pegawai.new("Diana", 7, 20_000_000)
]
pegawai.sort.each { |p| puts p }
# Bob (3th, 12000000)
# Alice (5th, 15000000)
# Charlie (5th, 18000000)
# Diana (7th, 20000000)
clamp — Membatasi Nilai dalam Rentang #
clamp adalah method Comparable yang sering terlupakan tapi sangat berguna. Ia memastikan nilai berada dalam rentang tertentu — jika terlalu kecil dikembalikan batas bawah, jika terlalu besar dikembalikan batas atas.
# clamp pada tipe bawaan
5.clamp(1, 10) # => 5 (dalam rentang)
0.clamp(1, 10) # => 1 (terlalu kecil, kembalikan batas bawah)
15.clamp(1, 10) # => 10 (terlalu besar, kembalikan batas atas)
# clamp dengan Range (Ruby 2.7+)
5.clamp(1..10) # => 5
0.clamp(1..10) # => 1
15.clamp(1..10) # => 10
# clamp dengan endless/beginless Range
5.clamp(1..) # => 5 (hanya batas bawah)
0.clamp(1..) # => 1
5.clamp(..10) # => 5 (hanya batas atas)
15.clamp(..10) # => 10
# Contoh praktis: validasi input form
def proses_umur(input)
input.to_i.clamp(0, 150) # umur tidak mungkin negatif atau > 150
end
proses_umur(-5) # => 0
proses_umur(25) # => 25
proses_umur(999) # => 150
# clamp untuk UI — slider value
def set_volume(nilai)
@volume = nilai.clamp(0, 100)
end
set_volume(-10) # @volume = 0
set_volume(50) # @volume = 50
set_volume(150) # @volume = 100
# clamp pada kelas custom yang include Comparable
v = Versi.new("2.5.0")
min_v = Versi.new("1.0.0")
max_v = Versi.new("3.0.0")
v.clamp(min_v, max_v).to_s # => "2.5.0"
Versi.new("0.1.0").clamp(min_v, max_v).to_s # => "1.0.0"
Versi.new("5.0.0").clamp(min_v, max_v).to_s # => "3.0.0"
Integrasi dengan Enumerable #
Ketika kelas mengimplementasikan Comparable, ia secara otomatis bekerja dengan baik bersama Enumerable — khususnya untuk pengurutan dan pencarian ekstrem.
# Kelas Versi dari contoh sebelumnya sudah include Comparable
versi_rilis = [
Versi.new("3.1.0"),
Versi.new("2.7.6"),
Versi.new("3.0.0"),
Versi.new("2.6.10"),
Versi.new("3.2.1")
]
# sort menggunakan <=> secara otomatis
versi_rilis.sort.map(&:to_s)
# => ["2.6.10", "2.7.6", "3.0.0", "3.1.0", "3.2.1"]
# min dan max menggunakan <=>
versi_rilis.min.to_s # => "2.6.10"
versi_rilis.max.to_s # => "3.2.1"
# minmax mengembalikan [min, max] sekaligus
versi_rilis.minmax.map(&:to_s)
# => ["2.6.10", "3.2.1"]
# sort_by untuk kriteria berbeda
versi_rilis.sort_by { |v| -v.major }.map(&:to_s)
# => ["3.1.0", "3.0.0", "3.2.1", "2.7.6", "2.6.10"]
# select berdasarkan perbandingan
batas = Versi.new("3.0.0")
versi_rilis.select { |v| v >= batas }.map(&:to_s)
# => ["3.1.0", "3.0.0", "3.2.1"]
Comparable vs Manual Comparison #
Tanpa Comparable, kamu harus menulis setiap method perbandingan secara manual — lebih banyak kode, lebih banyak kesempatan untuk inkonsistensi.
# ANTI-PATTERN: implementasi manual semua operator perbandingan
class SuhuManual
attr_reader :celsius
def initialize(celsius)
@celsius = celsius
end
def <(lain)
celsius < lain.celsius
end
def >(lain)
celsius > lain.celsius
end
def <=(lain)
celsius <= lain.celsius
end
def >=(lain)
celsius >= lain.celsius
end
def ==(lain)
celsius == lain.celsius
end
# Dan kamu masih belum punya between? dan clamp!
# Dan sort tidak bekerja dengan benar!
end
# BENAR: include Comparable dan implementasi hanya <=>
class SuhuComparable
include Comparable
attr_reader :celsius
def initialize(celsius)
@celsius = celsius
end
def <=>(lain)
return nil unless lain.is_a?(SuhuComparable)
celsius <=> lain.celsius
end
end
# Dengan Comparable: <, >, <=, >=, between?, clamp semuanya tersedia
# Dan sort, min, max, minmax dari Enumerable juga bekerja!
Comparable dengan eql? dan hash #
Ketika kamu menggunakan objek sebagai key Hash atau elemen Set, Ruby juga membutuhkan eql? dan hash yang konsisten. Comparable memberikan == berdasarkan <=>, tapi eql? dan hash harus didefinisikan sendiri jika dibutuhkan.
class Koordinat
include Comparable
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
# Jarak dari origin sebagai basis perbandingan
def jarak
Math.sqrt(x**2 + y**2)
end
def <=>(lain)
return nil unless lain.is_a?(Koordinat)
jarak <=> lain.jarak
end
# Perlu eql? dan hash agar bisa dipakai sebagai Hash key atau Set element
def eql?(lain)
lain.is_a?(Koordinat) && x == lain.x && y == lain.y
end
def hash
[x, y].hash
end
def to_s
"(#{x}, #{y})"
end
end
a = Koordinat.new(3, 4) # jarak = 5
b = Koordinat.new(0, 5) # jarak = 5
c = Koordinat.new(1, 1) # jarak = ~1.41
a == b # => true (jarak sama, == dari Comparable)
a.eql?(b) # => false (koordinat berbeda, eql? dari kita sendiri)
a > c # => true (5 > 1.41)
# Sort berdasarkan jarak
[a, b, c].sort.map(&:to_s)
# => ["(1, 1)", "(3, 4)", "(0, 5)"] -- c, lalu a dan b (jarak sama)
Perbedaan
==daneql?di Ruby:
==(dari Comparable via<=>) — equality untuk perbandingan umum, domain-specificeql?— dipakai oleh Hash dan Set untuk pengecekan kesamaan key/elemen; biasanya lebih ketatequal?— identity comparison, cek apakah sama persis object yang sama di memoryJika kamu override
eql?, kamu harus juga overridehashagar konsisten — ini adalah kontrak Ruby yang tidak boleh dilanggar.
Ringkasan #
- Satu
<=>, enam method — implementasikan hanya spaceship operator dan Comparable memberikan<,>,<=,>=,between?, danclampsecara otomatis.- Spaceship operator harus mengembalikan -1, 0, 1, atau nil — nilai negatif untuk “kurang dari”, nol untuk “sama”, positif untuk “lebih dari”, nil untuk perbandingan yang tidak valid.
- Selalu cek tipe di
<=>— kembalikanniljika objek yang dibandingkan bukan tipe yang kompatibel; ini mencegah error yang membingungkan.- Delegasi ke atribut yang sudah Comparable — cara paling bersih mengimplementasikan
<=>adalah mendelegasikan ke atribut (Integer,String,Array) yang sudah tahu cara membandingkan diri sendiri.clampuntuk membatasi nilai dalam rentang — sangat berguna untuk validasi input, nilai slider UI, dan kasus di mana nilai harus dalam batas tertentu.- Integrasi otomatis dengan Enumerable —
sort,min,max,minmax, dansort_bysemuanya bekerja tanpa konfigurasi tambahan setelah<=>diimplementasikan.eql?danhashharus konsisten — jika objekmu dipakai sebagai Hash key atau Set element, implementasikaneql?danhashsecara eksplisit; keduanya harus konsisten satu sama lain.