Comparable

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) jika self kurang dari objek yang dibandingkan
  • 0 jika keduanya sama
  • 1 (atau nilai positif apapun) jika self lebih dari objek yang dibandingkan
  • nil jika 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 == dan eql? di Ruby:

  • == (dari Comparable via <=>) — equality untuk perbandingan umum, domain-specific
  • eql? — dipakai oleh Hash dan Set untuk pengecekan kesamaan key/elemen; biasanya lebih ketat
  • equal? — identity comparison, cek apakah sama persis object yang sama di memory

Jika kamu override eql?, kamu harus juga override hash agar konsisten — ini adalah kontrak Ruby yang tidak boleh dilanggar.


Ringkasan #

  • Satu <=>, enam method — implementasikan hanya spaceship operator dan Comparable memberikan <, >, <=, >=, between?, dan clamp secara 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 <=> — kembalikan nil jika 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.
  • clamp untuk membatasi nilai dalam rentang — sangat berguna untuk validasi input, nilai slider UI, dan kasus di mana nilai harus dalam batas tertentu.
  • Integrasi otomatis dengan Enumerablesort, min, max, minmax, dan sort_by semuanya bekerja tanpa konfigurasi tambahan setelah <=> diimplementasikan.
  • eql? dan hash harus konsisten — jika objekmu dipakai sebagai Hash key atau Set element, implementasikan eql? dan hash secara eksplisit; keduanya harus konsisten satu sama lain.

← Sebelumnya: Set   Berikutnya: Pathname →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact