Kendi sınıfınızın nesnelerini <, >, between? ve clamp ile karşılaştırılabilir yapmak için sınıfa include Comparable ekleyip tek bir <=> metodu yazmanız yeterlidir. Bu metot karşılaştırılan nesne küçükse negatif, eşitse 0, büyükse pozitif bir sayı, karşılaştırılamıyorsa nil döndürmelidir. Benzer şekilde bir koleksiyon sınıfına include Enumerable ekleyip elemanları sırayla yield eden bir each metodu yazarsanız map, select, sort, min, max, sum gibi onlarca metot kendiliğinden gelir.

Bu yazıda iki mixin'i sıfırdan bir sınıfa ekliyor, karşılaştırılamayan değerlerin nasıl ele alınacağını, == ile eql?/hash farkını ve each'in blok verilmeden çağrıldığında ne döndürmesi gerektiğini Ruby 4.0.9'da çalıştırılmış örneklerle gösteriyoruz. Mixin kavramının kendisi için Ruby module yazısına, hazır koleksiyonlardaki Enumerable metotları için Enumerable map, select, reduce yazısına bakabilirsiniz.

Comparable: tek metottan altı karşılaştırma

Comparable: tek metottan altı karşılaştırma

Belgelere göre Comparable, <=> metodunu kullanarak <, <=, ==, >=, >, between? ve clamp metotlarını sağlar. Örnek olarak "3.10.0" gibi sürüm numaralarını doğru sıralayan bir sınıf yazalım. String olarak karşılaştırsaydık "3.10", "3.4"'ten küçük çıkardı; parçaları sayıya çevirip dizileri karşılaştırarak bu sorunu çözüyoruz.

ruby
class Surum
  include Comparable
  attr_reader :parcalar

  def initialize(metin)
    @parcalar = metin.split(".").map(&:to_i)
  end

  def <=>(diger)
    return nil unless diger.is_a?(Surum)
    parcalar <=> diger.parcalar
  end

  def to_s = parcalar.join(".")
  def inspect = "v#{self}"
end

a = Surum.new("3.4.1")
b = Surum.new("4.0.0")
c = Surum.new("3.10.0")

p a < b                                  # çıktı: true
p c > a                                  # çıktı: true
p a == Surum.new("3.4.1")                # çıktı: true
p a.between?(Surum.new("3.0"), b)        # çıktı: true
p Surum.new("5.1").clamp(a, b)           # çıktı: v4.0.0
p Surum.new("2.0").clamp(a..b)           # çıktı: v3.4.1

clamp değeri verilen alt ve üst sınırın içine çeker: sınırın altındaysa alt sınırı, üstündeyse üst sınırı, aradaysa nesnenin kendisini döndürür. Range biçimini de kabul eder; belgelere göre sonu hariç tutulan (...) ve sonu nil olmayan bir aralık verirseniz ArgumentError oluşur.

Karşılaştırılamayan değerler: nil döndürün

Belgeler, diğer nesne karşılaştırılabilir değilse <=> metodunun nil döndürmesini istiyor. Bu durumda == sessizce false döndürür, < gibi sıralama operatörleri ise ArgumentError verir. Böylece yanlış türle yapılan karşılaştırma gizli bir hataya dönüşmez:

ruby
p a == "3.4.1"   # çıktı: false
a < 3
# ArgumentError: comparison of Surum with 3 failed

sort, min ve max sizin sınıfınızla

Dizilerin ve diğer Enumerable koleksiyonların sort, min, max ve minmax metotları blok verilmediğinde elemanların kendi <=> metodunu kullanır. Yani <=> tanımladığınız anda bu metotlar sizin nesnelerinizle de çalışır:

ruby
p [b, c, a].sort     # çıktı: [v3.4.1, v3.10.0, v4.0.0]
p [b, c, a].min      # çıktı: v3.4.1
p [b, c, a].max      # çıktı: v4.0.0
p [b, c, a].minmax   # çıktı: [v3.4.1, v4.0.0]

Data ile Comparable

Data.define bloğu içine de Comparable ekleyebilirsiniz. Belgelerdeki ölçü örneğine benzer aşağıdaki kodda, birimi farklı iki ölçü karşılaştırılamaz sayılıyor. Dikkat: sort da aynı hatayı verir, çünkü sıralama sırasında <=> nil döndürür.

ruby
Olcu = Data.define(:miktar, :birim) do
  include Comparable

  def <=>(diger)
    return unless diger.is_a?(self.class) && diger.birim == birim
    miktar <=> diger.miktar
  end
end

p Olcu[3, "m"] < Olcu[5, "m"]                # çıktı: true
p [Olcu[7, "kg"], Olcu[2, "kg"]].max         # çıktı: #<data Olcu miktar=7, birim="kg">
Olcu[3, "m"] < Olcu[5, "kg"]
# ArgumentError: comparison of Olcu with Olcu failed
[Olcu[3, "m"], Olcu[5, "kg"]].sort
# ArgumentError: comparison of Olcu with Olcu failed

Data sınıflarının ayrıntıları için Ruby Struct ve Data yazısına bakabilirsiniz.

Comparable eşitliği hash anahtarı için yetmez

Comparable size == verir ama eql? ve hash vermez. Hash anahtarları ve uniq ise bu ikisine bakar. Bu yüzden iki eşit sürüm nesnesi uniq sonrası iki ayrı eleman olarak kalır. Object#hash belgesi, kendi sınıfınızda hash tanımlarken sınıf ve önemli alanlardan oluşan bir dizinin hash değerini kullanmayı öneriyor:

ruby
x = Surum.new("1.0")
y = Surum.new("1.0")
p x == y              # çıktı: true
p x.eql?(y)           # çıktı: false
p [x, y].uniq.size    # çıktı: 2

class Surum
  def eql?(diger) = self == diger
  def hash = [self.class, parcalar].hash
end
p [x, y].uniq.size    # çıktı: 1
p({x => "ilk"}[y])    # çıktı: "ilk"

Enumerable: each yazın, gerisi gelsin

Belgelere göre Enumerable kullanmak için iki şey gerekir: modülü include etmek ve elemanları sırayla yield eden bir each metodu yazmak. Enumerable'daki hemen her metot bu each'i çağırır. Aşağıdaki Sepet sınıfı kalemlerini bir dizide tutuyor ama dışarıya dizi değil, kendi arayüzünü sunuyor:

ruby
class Sepet
  include Enumerable

  Kalem = Data.define(:ad, :fiyat)

  def initialize
    @kalemler = []
  end

  def ekle(ad, fiyat)
    @kalemler << Kalem.new(ad, fiyat)
    self
  end

  def each
    return to_enum(:each) unless block_given?
    @kalemler.each { |k| yield k }
    self
  end
end

sepet = Sepet.new.ekle("Kitap", 180).ekle("Kalem", 25).ekle("Çanta", 640)

p sepet.map(&:ad)                          # çıktı: ["Kitap", "Kalem", "Çanta"]
p sepet.select { |k| k.fiyat > 100 }.map(&:ad)  # çıktı: ["Kitap", "Çanta"]
p sepet.sum(&:fiyat)                       # çıktı: 845
p sepet.min_by(&:fiyat)                    # çıktı: #<data Sepet::Kalem ad="Kalem", fiyat=25>
p sepet.max_by(&:fiyat).ad                 # çıktı: "Çanta"
p sepet.sort_by(&:fiyat).map(&:ad)         # çıktı: ["Kalem", "Kitap", "Çanta"]
p sepet.count                              # çıktı: 3
p sepet.find { |k| k.ad.start_with?("Ç") } # çıktı: #<data Sepet::Kalem ad="Çanta", fiyat=640>
p sepet.include?(Sepet::Kalem.new("Kalem", 25))      # çıktı: true
p sepet.each_slice(2).map { |grup| grup.map(&:ad) }  # çıktı: [["Kitap", "Kalem"], ["Çanta"]]
p sepet.each_with_index.map { |k, i| "#{i + 1}. #{k.ad}" }  # çıktı: ["1. Kitap", "2. Kalem", "3. Çanta"]
p sepet.each.next                          # çıktı: #<data Sepet::Kalem ad="Kitap", fiyat=180>
p sepet.lazy.map(&:fiyat).select(&:even?).first(2)  # çıktı: [180, 640]

each metodunun ilk satırı önemli: blok verilmeden çağrıldığında to_enum(:each) ile bir Enumerator döndürüyoruz. Böylece sepet.each.next gibi harici gezinme ve zincirleme kullanım mümkün oluyor. Enumerator ve lazy ayrıntıları için Enumerator ve lazy yazısına bakın. include? Data nesnelerinin içerik eşitliği sayesinde true döndü.

Elemanlar Comparable ise sort ve min de çalışır

Enumerable'ın sort, min, max gibi blok almayan biçimleri elemanların <=> metodunu kullanır. Elemanlar sayı, string ya da yukarıdaki Surum gibi Comparable bir sınıfın nesneleriyse ek bir şey yapmanız gerekmez. Basit bir örnekte each'i doğrudan iç diziye devrediyoruz:

ruby
class Fiyatlar
  include Enumerable
  def initialize(*fiyatlar) = @fiyatlar = fiyatlar
  def each(&blok) = @fiyatlar.each(&blok)
end

s = Fiyatlar.new(30, 10, 20)
p s.sort                      # çıktı: [10, 20, 30]
p s.min                       # çıktı: 10
p s.max(2)                    # çıktı: [30, 20]
p s.minmax                    # çıktı: [10, 30]
p s.sort { |a, b| b <=> a }   # çıktı: [30, 20, 10]
p s.reduce(:+)                # çıktı: 60
p s.tally                     # çıktı: {30 => 1, 10 => 1, 20 => 1}

Çıktıdaki {30 => 1} biçimi Ruby 4.0'ın hash gösterimidir; sembol olmayan anahtarlar => ile yazılır.

Sık yapılan hatalar

Sık yapılan hatalar: each tanımlamadan Enumerable eklemek, <=> içinde türü kontrol etmemek, Comparable ile hash anahtarı eşitliği beklemek, each'te blok yoksa Enumerator...
  • each tanımlamadan Enumerable eklemek: Modül each'e dayandığı için ilk metot çağrısında NoMethodError: undefined method 'each' alırsınız.
  • <=> içinde türü kontrol etmemek: Farklı türde bir nesneyle karşılaştırmada metodunuz başka bir hata fırlatabilir. Önce türü kontrol edip nil döndürün.
  • Comparable ile hash anahtarı eşitliği beklemek: uniq ve Hash için eql? ve hash tanımlayın.
  • each'te blok yoksa Enumerator döndürmemek: to_enum satırı olmadan sepet.each.next gibi kullanımlar çalışmaz.
  • Toplanamayan elemanlarla sum çağırmak: sum varsayılan olarak 0'dan başlar; string elemanlarda TypeError: String can't be coerced into Integer alırsınız. Başlangıç değeri verin ya da blok kullanın.
ruby
class Takim
  include Enumerable
  def each
    yield "Ali"
    yield "Ece"
  end
end
Takim.new.sum
# TypeError: String can't be coerced into Integer

class Bos
  include Enumerable
end
Bos.new.map { |x| x }
# NoMethodError: undefined method 'each' for an instance of Bos

Sık Sorulan Sorular

Comparable'ın == metodunu kullanmak zorunda mıyım?

Hayır. Sınıfınızda kendi == metodunuzu tanımlarsanız o kullanılır. Comparable'ınki, belgelere göre <=> 0 döndürdüğünde ya da iki nesne aynı nesne olduğunda true verir.

Enumerable eklediğim sınıfta size metodu olur mu?

Enumerable count sağlar ama size sağlamaz. Eleman sayısını hesaplamadan bilebiliyorsanız size metodunu kendiniz tanımlayın.

Dizi döndüren bir metot yazmak yerine neden Enumerable?

Kendi koleksiyon sınıfınız iç yapısını gizleyebilir, yine de bütün Enumerable metotlarını sunar. Kullanıcı select, group_by ya da lazy gibi metotları sizin ek kod yazmanıza gerek kalmadan kullanır.

Ruby 4.0'da bu modüllerde ne değişti?

Ruby 4.0 NEWS dosyası bu iki modül için öne çıkan bir değişiklik listelemiyor; yalnızca Array#find metodunun, Enumerable#find'ı dizilerde daha verimli biçimde ezmek için eklendiğini belirtiyor. Diğer yenilikler için Ruby 4.0 yenilikleri yazısına bakın.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: Ruby öğrenme rehberi · Enumerable map, select, reduce · Enumerator ve lazy · Ruby modülleri · Struct ve Data · Ruby Array metotları