Zadanie z wzorca Iterator

Na początku, gdy zachwyciłem się Czatem GPT, zwróciłem uwagę na to, jak może mi pomóc lepiej zrozumieć wzorce projektowe na przykładzie wygenerowanego przez siebie kodu. Ten sposób jest dobry do ogarnięcia teorii, ale jak wiadomo, najlepszym sposobem na ogarnięcie teorii jest praktyka.

Tym razem wybrałem sobie zadanie z Iteratora. Przyznam, że praktycznie nie spotkałem się z jego bezpośrednim wykorzystaniem w projektach, w których pracowałem. Dlaczego? Dlatego, że najczęściej takimi detalami zajmują się biblioteki, standardowe lub zewnętrzne. Z reguły korzystamy pośrednio z modułu Enumerable, który jest włączany na przykład przez klasę Array. Rzadziej sami tworzymy klasę, do której miksujemy ten moduł, a jeszcze rzadziej piszemy przesuwanie indeksu sami.

Zadanie

Daj mi proszę krótkie zadanie programistyczne, które testowałoby znajomość wzorca projektowego iterator, w temacie placu budowy. Nie wspominaj o tym wzorcu, tylko tak stwórz zadanie, żeby uczeń sam musiał się domyślić, że chodzi o wzorzec iterator.

GPT: Napisz program, który będzie symulował plac budowy. Na placu budowy pracują różni pracownicy, którzy mogą być murarzami, elektrykami, czy hydraulikami. Program powinien udostępniać interfejs do przeglądania listy pracowników, oraz pozwalać na filtrowanie ich według różnych kryteriów takich jak: specjalności, dni tygodnia, czy godziny pracy. Należy zadbać o to, aby kod był czytelny i łatwo rozszerzalny w przypadku dodania nowych pracowników, lub zmiany kryteriów filtrowania.

Jak patrzeć na takie zadanie? Na pewno sam fakt, że właśnie się o takie prosi albo czyta ten wpis ułatwia zauważenie, że trzeba będzie użyć wzorca iterator. Jednak to co nas interesuje najbardziej to wzmianka o interfejsie do przeglądania. Co więcej, mamy jeszcze wspomniane filtrowanie, także już wiemy, że przyda się jakaś logika przechodzenia przez elementy – w tym przypadku, pracowników placu budowy.

A oto kod, który napisałem korzystając z platformy replit.com, z której nieraz korzystam z uczniami:

class ConstructionSite
  attr_reader :workers, :worker_types
  
  def initialize(workers)
    @workers = workers
  end

  def iterator
    WorkersIterator.new(workers)
  end
end

class WorkersIterator
  attr_reader :workers
  attr_accessor :index
  
  def initialize(workers)
    @workers = workers   
    @index = 0
  end

  def has_next?
    index < workers.size 
  end

  def next_one(filters = {})
    worker = workers[index]
    @index += 1
    
    # warunkowa rekurencja, gdy worker zostaje odfiltrowany
    matches?(worker, filters) ? worker : (next_one(filters) if has_next?)
  end
  
  # Rozwiązanie ze statycznymi filtrami... Później będzie lepsze
  def matches?(worker, filters)
    if filters[:working_hours]
      return false if (worker.working_hours.to_a & filters[:working_hours].to_a).empty?
    end
    
    if filters[:weekdays]
      return false if (worker.weekdays & filters[:weekdays]).empty?
    end

    true
  end
end

DAYNAMES = %W(Mon Tue Wed Thu Fri Sat Sun)

class Worker
  attr_reader :speciality, :weekdays, :working_hours, :type

  def initialize(speciality:, weekdays:, working_hours:, type:)
    @speciality = speciality
    @weekdays = weekdays
    @working_hours = working_hours
    @type = type
  end

  def to_s
    "#{type}-#{speciality}: works on #{weekday_names} #{working_hours_string}"
  end

  def weekday_names
    weekdays.map { |wd| DAYNAMES[wd - 1] }.join(', ')
  end

  def working_hours_string
    "from #{working_hours.first} to #{working_hours.last}"
  end
end

workers = [
  Worker.new(speciality: "Bricks", weekdays: [1, 3, 4], working_hours: (9..17), type: "Mason"),
  Worker.new(speciality: "Bricks", weekdays: [1, 2, 5], working_hours: (6..12), type: "Mason"),
  Worker.new(speciality: "Pumps",  weekdays: [1, 3, 6], working_hours: (9..17), type: "Plumber"),
  Worker.new(speciality: "Taps",   weekdays: [1, 2, 5], working_hours: (7..15), type: "Plumber"),
  Worker.new(speciality: "HighVo", weekdays: [1, 3, 4], working_hours: (8..15), type: "Electrician"),
  Worker.new(speciality: "LowVol", weekdays: [1, 2, 5], working_hours: (9..12), type: "Electrician"),
]

cs = ConstructionSite.new(workers)
wi = cs.iterator
filters = { weekdays: [4], working_hours: (10..12) }

while wi.has_next?
  worker = wi.next_one(filters)
  puts worker.to_s if worker
end

# =>
# Mason-Bricks: works on Mon, Wed, Thu from 9 to 17
# Electrician-HighVo: works on Mon, Wed, Thu from 8 to 15

Jeśli szukasz krótszego wyjaśnienia tego wzorca, zajrzyj na inny mój wpis o Iteratorze. Warto też zauważyć, że napisałem w metodzie filtrującej matches? tylko te filtry, które odnoszą się do dni i godzin roboczych. Można by dodać jeszcze dwa filtry dotyczące typu pracownika i jego specjalności.

Można również zastosować inne podejście, w którym metoda filtrująca jest przekazywana do iteratora. Obecnie logika filtrów jest w nim zakodowana. Jej działanie podlega różnicowaniu tylko w przypadku przekazania innych wartości, według których ma filtrować.

Aby przekazywać filtry do metody next_one jako argument w bloku, możemy zmienić jej sygnaturę na:

def next_one
  worker = workers[index]
  @index += 1
    
  yield(worker) ? worker : (next_one {|w| yield(w)} if has_next?)
end

# i wywołać iterator np. tak:
while wi.has_next?
  
  # Filtry dynamiczne. W poprzedniej wersji były statyczne.
  worker = wi.next_one { |w| 
    w.weekdays.include?(4) && 
    !(w.working_hours.to_a & (10..12).to_a).empty?
  }
  
  puts worker.to_s if worker
end

W tym przykładzie, filtr jest przekazywany jako blok, który przyjmuje jako argument pracownika i zwraca wartość logiczną true lub false. Metoda next_one przetwarza listę pracowników i zwraca pierwszego znalezionego, który spełnia warunek zawarty w bloku.

Takie rozwiązanie pozwala na dynamiczne określenie filtrów, a także pozwala na użycie innych niż wymienione wcześniej kryteriów filtrowania.

Sprawdzenie w GPT

Dopóki GPT jest za darmo, a mam nadzieję, że później będzie w jakiejś przystępnej cenie. Zobaczmy, co sądzi o tym rozwiązaniu:

GPT: Twoje rozwiązanie jest poprawne i zgodne z wzorcem projektowym Iterator. Klasa ConstructionSite jest klasą klienta, która posiada listę pracowników i udostępnia interfejs do przeglądania tej listy za pomocą metody iterator, która zwraca obiekt klasy WorkersIterator. Klasa ta posiada metody has_next?, next_one i matches? które pozwalają na przeszukiwanie listy pracowników według różnych kryteriów takich jak specjalności, dni tygodnia, czy godziny pracy.

Kod jest czytelny i łatwo rozszerzalny, ponieważ jeśli konieczne jest dodać nowy typ pracownika, wystarczy dodać go do listy pracowników i klasa WorkersIterator automatycznie będzie go uwzględniała. Podobnie jeśli chodzi o zmianę kryteriów filtrowania, nie trzeba nic modyfikować w kodzie, tylko przekazać inne filtry do metody next_one.

Testy

require 'minitest/autorun'

class ConstructionSiteTest < Minitest::Test
  def test_iterator_filter_weekday
    cs = ConstructionSite.new(WORKERS)
    wi = cs.iterator
    worker = wi.next_one { |w| w.weekdays.include?(2) }

    assert_match "Mason-Bricks", worker.to_s
  end

  def test_iterator_filter_working_hours
    cs = ConstructionSite.new(WORKERS)
    wi = cs.iterator
    
    worker = wi.next_one { |w| w.working_hours.include?(10) }
    assert_match "Mason-Bricks", worker.to_s
    
    worker = wi.next_one { |w| w.working_hours.include?(10) }
    assert_match "Mason-Bricks", worker.to_s
    
    worker = wi.next_one { |w| w.working_hours.include?(10) }
    assert_match "Plumber-Pumps", worker.to_s
  end

  def test_iterator_filter_type
    cs = ConstructionSite.new(WORKERS)
    wi = cs.iterator
    worker = wi.next_one { |w| w.type == "Plumber" }

    assert_match "Plumber-Pumps", worker.to_s
  end

  def test_iterator_filter_no_match
    cs = ConstructionSite.new(WORKERS)
    wi = cs.iterator
    worker = wi.next_one { |w| w.weekdays.include?(7) }

    assert_nil worker
  end
end

Leave a Reply

Your email address will not be published. Required fields are marked *