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