10. Budujemy szkołę

Do tej pory definiowane przez nas klasy miały konkretny cel zgrupowania pewnych metod albo, szerzej, operowania konkretnym obiektem, na przykład dźwiękiem. Pierwotnie jednak ich cel był zupełnie inny.

Od samego początku istnienia komputerów pojawiały się pewne dane powtarzalne dla licznych elementów. Wyobraźmy sobie, że chcemy zapisać dane o jakimś człowieku, będą to imię, nazwisko oraz data urodzenia. Normalnie sięgnęlibyśmy po kilka zmiennych, nazwijmy je roboczo “firstname”, “surname” oraz “birthdate”. Na tym etapie wszystko działa. Gdybyśmy jednak chcieli zapisać tak dane o setce osób, musielibyśmy sięgnąć po tablice – osobne tablice dla imion, nazwisk… Usuwając daną osobę z naszej bazy musielibyśmy pamiętać o usuwaniu jej z każdej jednej tablicy. Co więcej, dodanie nowego atrybutu oznaczałoby dodawanie kolejnych tablic.

Tak pojawiły się jeszcze w latach 50-tych zeszłego wieku struktury, czyli sposoby na grupowanie licznych danych w jednym obiekcie. Oczywiście ścisły recenzent tego kursu powiedziałby, że wtedy o żadnych obiektach mowy jeszcze nie było, a struktury w programowaniu strukturalnym to tylko pewna ilość uporządkowanej pamięci, ale nie bądźmy tacy czepliwi i przełóżmy całe zagadnienie od razu na Rubiego.

Z biegiem czasu programiści wymyślili klasy, a co za tym idzie struktury i klasy nieco się wymieszały swą ideą. Wciąż w niektórych językach się je rozróżnia, ale w zdecydowanej większości współczesnych języków programowania struktura i klasa są definiowane dokładnie tak samo, z resztą dość powszechnym zjawiskiem jest, że w miarę rozwijania oprogramowania coś, co kilka wersji temu było strukturą, teraz już jest klasą.

Stwórzmy więc człowieka

class Person
  attr_accessor :firstname, :surname, :birthdate
end

pr=Person.new
pr.firstname = "Jan"
pr.surname = "Kowalski"
pr.birthdate = Time.local(2000, 1, 1)
alert("#{pr.firstname} #{pr.surname} was born in #{pr.birthdate.year}")

Definiujemy tu w znany nam już sposób klasę o nazwie “Person”. Dalej znajduje się dość dziwna instrukcja “attr_accessor”. Jak ona działa? Tak naprawdę jest to skrót, dokładnie to samo moglibyśmy zapisać tak:

class Person
  def firstname
    return @firstname
  end
  def firstname=(val)
    @firstname=val
  end
  def surname
    return @surname
  end
  def surname=(val)
    @surname=val
  end
  def birthdate
    return @birthdate
  end
  def birthdate=(val)
    @birthdate=val
  end
end

Innymi słowy, ten piękny skrótowiec skraca 18 linijek do jednej.

“attr_accessor” oznacza tyle, co “utwórz atrybut o danej nazwie” – nazwę poprzedzamy dwukropkiem, podobnie jak w przechwytywaniu zdarzeń, np. “:nazwa_zmiennej”. Od tej pory obiekty naszej klasy pozwalają w innych miejscach kodu pobierać i zmieniać wartość zmiennej “@nazwa_zmiennej” poprzez użycie konstrukcji “obiekt.nazwa_zmiennej”. Brzmi to dość skomplikowanie, ale mam nadzieję, że przykład wszystko wyjaśnia. Prócz skrótowca “attr_accessor” do dyspozycji mamy jeszcze dwa: “attr_reader”, który definiuje jedynie możliwość odczytu danej zmiennej (dość przydatny, gdy chcemy uniemożliwić jej edycję lub napisać własną funkcję do jej zmiany) oraz “attr_writer”, który analogicznie umożliwia jedynie zmianę wartości zmiennej, to rzadko pożądane zachowanie, ale czasem się przydaje.

Wartości początkowe

Kiedy stworzymy naszego człowieka, jego imię to nil, nazwisko to nil, co gorsze data urodzenia także jest nil, a więc próba wywołania atrybutu “birthdate.year” skończy się pięknym błędem. Warto byłoby się upewnić, że nasz człek dostanie jakieś wartości domyślne. Rozwiązaniem jest po prostu napisanie konstruktora.

class Person
  attr_accessor :firstname, :surname, :birthdate
  def initialize
    @firstname, @surname = "No", "Name"
    @birthdate = Time.now
  end
end

W ten sposób po stworzeniu, nasz człowiek nazywa się “No Name” i urodził się, no, właśnie teraz. Tu taka wskazówka, gdy chcemy szybko zdefiniować kilka zmiennych, możemy zrobić to kolejnym jednoliniowcem:

a, b, c = 1, 2, 3
alert(a)
alert(b)
alert(c)

Jak widać, w ten sposób szybko przypisaliśmy wartości “a=1”, “b=2” oraz “c=3”.

Jeszcze bardziej skróćmy nasz kod

Czy można jeszcze bardziej uprościć nasz kod? No oczywiście! Skoro konstruktor może przyjmować argumenty, skorzystajmy tu z tego!

class Person
  attr_accessor :firstname, :surname, :birthdate
  def initialize(firstname="No", surname="Name", birthdate=Time.local(1990, 1, 1))
    @firstname, @surname, @birthdate = firstname, surname, birthdate
  end
end

pr=Person.new("Jan", "Kowalski", Time.local(2000, 1, 1))
alert("#{pr.firstname} #{pr.surname} was born in #{pr.birthdate.year}")

9 linijek, lepiej chyba już nie będzie!

Jak widzimy, pozwoliliśmy tu, by to konstruktor od razu tworzył naszego człowieka. To dziwne “równa się” (=) w liście parametrów tworzy domyślną wartość, która zostanie wykorzystana, gdybyśmy nie przekazali żadnych argumentów. Tak więc, jeśli programista wpisze po prostu “Person.new”, uzyska człowieka “No Name” urodzonego 1 stycznia 1990.

Ze struktury do klasy

Na początku pisałem, że granica między klasą a strukturą stała się bardzo płynna. Przecież stworzony obiekt bez wątpienia jest strukturą, nie ma żadnych funkcji powodujących inne działanie. Ale chętnie kilka pododajemy.

Zastanówmy się, jakie informacje będą nam zwykle o człowieku potrzebne. Jedną z nich może być pełne imię i nazwisko, drugą wiek. Zamiast zawsze je liczyć, możemy przecież nieco zmienić definicję naszej klasy.

class Person
  attr_accessor :firstname, :surname, :birthdate
  def initialize(firstname="No", surname="Name", birthdate=Time.local(1990, 1, 1))
    @firstname, @surname, @birthdate = firstname, surname, birthdate
  end
  def fullname
    return @firstname+" "+@surname
  end
  def age
    now = Time.now
    return now.year-@birthdate.year - ((now.month>@birthdate.month || (now.month==@birthdate.month && now.day>=@birthdate.day))?0:1)
  end
end

Mamy teraz dwie metody, które nam zwracają pełne imię i nazwisko oraz wiek danej osoby. Ten potworek z obliczania wieku może wyglądać straszliwie, ale dość łatwo go wyjaśnić. Odejmujemy od obecnego roku rok urodzenia naszego człowieka. To jednak nie jest wszystko, przecież mógł się on urodzić w grudniu, a teraz może być styczeń. Sprawdzamy więc jeszcze, czy nie wystąpiła taka sytuacja, porównujemy odpowiednio miesiąc i dzień urodzenia. Jeśli jeszcze urodziny tego człowieka nie wypadły, od wieku odejmujemy jeszcze jeden, w przeciwnym wypadku na tym kończymy.

Otwieramy szkołę

Skoro możemy tworzyć młodych ludzi, stwórzmy tym młodym ludziom naturalne zbiorowisko, czyli szkołę. Chcemy w naszym programie zapisać informację o wszystkich uczniach danej szkoły. Można to uzyskać na wiele, bardzo wiele sposobów. Ten, który dziś omówimy, wcale nie musi być najlepszy, zależnie od celów i działania aplikacji, dużo lepszą metodą może się po prostu okazać uwzględnienie atrybutu klasy w klasie ucznia albo stworzenie wielkiej klasy do obsługi szkoły.

Do definicji uczniów wykorzystamy wcześniej napisaną klasę… [1] Chcemy jednak zapisać także informację o tym, do jakiej klasy należy student. Tu się przydadzą dość ciekawe obiekty, jakimi są “hasze” (inaczej także nazywane “tablicami haszowymi” bądź “słownikami”).

Załóżmy na razie, że nasza szkoła jest dość mała, mamy dwie klasy, a w każdej po dwóch uczniów.

class Person
  attr_accessor :firstname, :surname, :birthdate
  def initialize(firstname="No", surname="Name", birthdate=Time.local(1990, 1, 1))
    @firstname, @surname, @birthdate = firstname, surname, birthdate
  end
  def fullname
    return @firstname+" "+@surname
  end
  def age
    now = Time.now
    return now.year-@birthdate.year - ((now.month>@birthdate.month || (now.month==@birthdate.month && now.day>=@birthdate.day))?0:1)
  end
end

students = {
  '1A' => [
    Person.new("Michał", "Jabłon", Time.local(2013, 5, 8)),
    Person.new("Anna", "Zając", Time.local(2013, 6, 14))
  ],
  '1B' => [
    Person.new("Wincenty", "Pirowski", Time.local(2013, 1, 11)),
    Person.new("Dorota", "Szkud", Time.local(2013, 2, 23))
  ]
}

Hasze pod wieloma względami przypominają zwykłe tablice, mają jednak pewną różnicę. W tradycyjnej tablicy elementy są indeksowane numerami od zera, w haszach za indeks elementu (tzw. klucz) może służyć absolutnie cokolwiek: jakiś łańcuch znaków, liczba czy nawet obiekt. Tu mamy hasz, który posiada dwie tablice, odpowiednio pod kluczami “1A” oraz “1B”. Zobaczmy

students['1A'][0]
:   #=> #<EltenAPI::Common::Console::Person:0x11b89d50 @surname="Jabłon", @firstname="Michał", @birthdate=Tue May 08 00:00:00 środkowoeuropejski czas letni 2013>

Wygenerujmy sobie szkołę

Napiszmy teraz program, który tworzy szkołę z szesnastoma klasami (1A, 1B, 2A, 2B i tak dalej). Do każdej klasy trafi między 20, a 30 uczniów.

firstnames = ["Maja", "Agata", "Wojciech", "Gracjan", "Zuzanna", "Aleksander", "Eliza", "Michał", "Dariusz", "Monika", "Zofia", "Weronika", "Lucjan", "Jakub", "Jacek", "Maria", "Małgorzata", "Cecylia", "Dorota", "Agnieszka", "Łukasz", "Piotr", "Paweł", "Alicja", "Janina", "Andrzej", "Paulina", "Kacper", "Barbara", "Stefan", "Bartłomiej", "Urszula", "Jadwiga", "Natalia"]
surnames = ["Andruszkiewicz", "Pepka", "Skowron", "Metyk", "Wróbel", "Swat", "Olcha", "Gołąbek", "Małysik", "Rusałek", "Gilec", "Tolman", "Wichoń", "Kasprzak", "Wilk", "Dominiak", "Pinko", "Machul", "Złotko", "Kołacz", "Dudek", "Kaczmarek", "Cieślak", "Jarosz", "Sowa", "Świątek", "Sobczyk", "Rybak", "Kwiecień"]

students={}

for i in 1..8
  for l in ["A", "B"]
    className = i.to_s+l
    students[className] = []
    (20+rand(11)).times {
      student = Person.new(firstnames[rand(firstnames.size)], surnames[rand(surnames.size)], Time.local(2013+i, rand(12)+1, rand(28)+1))
      students[className].push(student)
    }
  end
end

for k,v in students
  alert("#{k}: #{v.map{|s|s.fullname}.join(", ")}.")
end

Napisaliśmy 19 dość skomplikowanych linijek, a więc czas na nieco wyjaśnień. Najpierw tworzymy tablicę z przykładowymi imionami i nazwiskami. Następnie w pętli dla klas od 1 do 8 osadzamy drugą pętlę, dla podziału na A i B. W każdej z nich losujemy ilość przebiegów (20..30). Tak oto mamy potrójnie zagnieżdżoną pętlę. Teraz tworzymy ucznia, przypisując mu losowe imię i nazwisko, a także losową datę urodzenia z właściwego mu rocznika. By nie komplikować programu jeszcze bardziej, datę urodzenia losujemy tylko do 28 dnia miesiąca. Wskazówka! Obrana metodyka generowania dat urodzin uczniów miała być prosta, ale by równo i sprawiedliwie wylosować daty z całego roku, wystarczy wylosować każdemu uczniowi sekundę narodzin z zakresu czasu Unixa od 1 stycznia do 31 grudnia danego roku. Warto zauważyć tu metodę “push”, która pozwala na dostawianie elementu na koniec istniejącej tablicy.

Potem dzieje się coś dziwnego, piszemy dość złożoną pętlę “for”. Nie ma tu jednak niczego zadziwiającego, po prostu do zmiennej “k” trafia wartość klucza (tu nazwa klasy), a do “v” jego wartość (tu tablica uczniów). Na koniec wypowiadamy listę uczniów danej klasy, ale… Zaraz… Coś tu bardzo dziwnego się dzieje.

Mapowanie tablic

Mapowanie to dość przydatna funkcja podczas szybkiego operowania na tablicy. Pozwala ona wygenerować nową tablicę na podstawie wszystkich elementów starej. Jakiś przykład:

[1, 2, 3, 4, 5].map{|a|a*2}
:   #=> [2, 4, 6, 8, 10]

W tym wypadku utworzyliśmy nową tablicę, podwajając każdy element starej. Tylko tyle i aż tyle.

Dziennik się rozsypał

Dyrektor naszej placówki spisywał dziennik uczniów, ale porozsypywały mu się kartki i dopiero po pewnym czasie zauważył, że zapisał ich niealfabetycznie. Na szczęście dziennik był elektroniczny, a więc szybko zadzwonił do informatyka z prośbą o naprawienie problemu. Sortowanie danych to całkiem złożone zagadnienie. Istnieje wiele algorytmów, a matematycy prześcigają się w tworzeniu kolejnych, wydajniejszych od starych. Na szczęście w wypadku Rubiego nie musimy się tym przejmować, bo zrobi to za nas. Do sortowania tablicy służy funkcja o nazwie… nie zgadniecie… “sort”.

[3, 4, 1, 5, 2].sort
:   #=> [1, 2, 3, 4, 5]

W ten sposób można jednak sortować tylko dane, które komputer umie posortować: liczby, napisy… Skąd ma jednak wiedzieć, jak posortować nasze klasy? Oczywiście dałoby mu się to powiedzieć poprzez zdefiniowanie operatora porównania, ale to nieco bardziej złożone, a więc zacznijmy od czegoś prostego, mianowicie funkcji “sort_by”. Funkcja ta sortuje tablicę wg wybranego kryterium, o tak:

[3, 4, 1, 5, 2].sort_by{|a|a*-1}
:   #=> [5, 4, 3, 2, 1]

Otrzymaliśmy wartość odwrotną, ponieważ nakazaliśmy programowi sortować wedle liczb przeciwnych do elementów tablicy.

Teraz możemy więc poprawnie posortować naszych uczniów, zmieniamy nieco instrukcję ich tworzenia.

for i in 1..8
  for l in ["A", "B"]
    className = i.to_s+l
    students[className] = []
    (20+rand(11)).times {
      student = Person.new(firstnames[rand(firstnames.size)], surnames[rand(surnames.size)], Time.local(2013+i, rand(12)+1, rand(28)+1))
      students[className].push(student)
    }
    students[className] = students[className].sort_by{|s|s.surname+" "+s.firstname}
  end
end

Sortowanie nietypowe, czyli odkryjmy koło na nowo

Jak widać, nasze tablice się grzecznie sortują, ale co tam się dzieje, że się sortują? Wewnętrznie, dobierając odpowiedni algorytm, interpreter porównuje różne wartości i ustawia je w odpowiedniej kolejności. W tym celu wykorzystywany jest operator porównania, który zapisujemy jako “<=>”. Operator ten zwraca trzy wartości: “-1”, gdy pierwsza wartość poprzedza drugą, “0”, gdy się równają i “1”, gdy druga poprzedza pierwszą. Przykład:

0<=>1
:   #=> -1
1<=>1
:   #=> 0
1<=>0
:   #=> 1

Tak więc w praktyce polecenie:

students[className] = students[className].sort_by{|s|s.surname+" "+s.firstname}

Możemy zastąpić, wymuszając nasze własne wyniki porównań, o tak:

students[className] = students[className].sort{|a,b|(a.surname+" "+a.firstname) <=> (b.surname+" "+b.firstname)}

W tym wypadku głupie, ale czasem przydatne. Przykładowo zamieniając a i b, możemy wymusić dość wydajne sortowanie odwrotne.

Policzmy dziewczęta

Ostatnie, co dzisiaj spróbujemy napisać, to licznik dziewcząt. Na razie nie zapisujemy informacji o płci uczniów, zróbmy jednak dość uproszczone założenie, że imiona dziewcząt kończą się literą “a”. W tym celu dopiszmy do naszej klasy “Person” dwie metody:

  def female?
    return @firstname[-1..-1]=="a"
  end
  def male?
    return !female?
  end

W tablicach indeksy poniżej zera oznaczają dany element od końca, tak więc “-1” to ostatni element, “-2” przedostatni i tak dalej. Składnia “@firstname [-1..-1]” zwraca ostatni znak imienia, uwaga, składnia “@firstname [-1]” zwróciłaby jego kod ASCII, na czym nam nie zależy. Stworzyliśmy więc dwie metody: “female?” sprawdzającą, czy uczeń jest uczennicą i “male?”, która zwraca odwrotność powyższej.

Teoretycznie moglibyśmy znów napisać pętlę po haszu “students”, ale są prostsze metody. Z pomocą przychodzi nam dość użyteczna metoda “flatten”.

[[1, 2], 3, [4, 5], 6].flatten
:   #=> [1, 2, 3, 4, 5, 6]

Metoda “flatten” rozkłada wszystkie podtablice w tablicy do jednej. Potrzeba nam jeszcze wiedzy, że odpowiednio atrybuty “keys” i “values” w haszu zwracają tablicę kluczy i wartości, by napisać nasz piękny licznik:

girls=0
for student in students.values.flatten
  girls+=1 if student.female?
end
alert("There are #{girls} girls in this school.")

Podsumowanie

Ufff, dzisiaj znów wiele teorii, przerzuciliśmy struktury, tablice i hasze na lewo i prawo… Poniżej prezentuję kompletny program zliczający uczniów i uczennice, to jest 51 wywalczonych dziś linijek.

class Person
  attr_accessor :firstname, :surname, :birthdate
  def initialize(firstname="No", surname="Name", birthdate=Time.local(1990, 1, 1))
    @firstname, @surname, @birthdate = firstname, surname, birthdate
  end
  def fullname
    return @firstname+" "+@surname
  end
  def age
    now = Time.now
    return now.year-@birthdate.year - ((now.month>@birthdate.month || (now.month==@birthdate.month && now.day>=@birthdate.day))?0:1)
  end
  def female?
    return @firstname[-1..-1]=="a"
  end
  def male?
    return !female?
  end
end

firstnames = ["Maja", "Agata", "Wojciech", "Gracjan", "Zuzanna", "Aleksander", "Eliza", "Michał", "Dariusz", "Monika", "Zofia", "Weronika", "Lucjan", "Jakub", "Jacek", "Maria", "Małgorzata", "Cecylia", "Dorota", "Agnieszka", "Łukasz", "Piotr", "Paweł", "Alicja", "Janina", "Andrzej", "Paulina", "Kacper", "Barbara", "Stefan", "Bartłomiej", "Urszula", "Jadwiga", "Natalia"]
surnames = ["Andruszkiewicz", "Pepka", "Skowron", "Metyk", "Wróbel", "Swat", "Olcha", "Gołąbek", "Małysik", "Rusałek", "Gilec", "Tolman", "Wichoń", "Kasprzak", "Wilk", "Dominiak", "Pinko", "Machul", "Złotko", "Kołacz", "Dudek", "Kaczmarek", "Cieślak", "Jarosz", "Sowa", "Świątek", "Sobczyk", "Rybak", "Kwiecień"]

students = {}
for i in 1..8
  for l in ["A", "B"]
    className = i.to_s+l
    students[className] = []
    (20+rand(11)).times {
      student = Person.new(firstnames[rand(firstnames.size)], surnames[rand(surnames.size)], Time.local(2013+i, rand(12)+1, rand(28)+1))
      students[className].push(student)
    }
    students[className] = students[className].sort_by{|s|s.surname+" "+s.firstname}
  end
end

girls=0
boys=0
for student in students.values.flatten
  if student.female?
    girls+=1
  else
    boys+=1
  end
end
alert("There are #{girls} girls and #{boys} boys in this school.")
alert("List of students:")

for k,v in students
  alert("#{k}: #{v.map{|s|s.fullname}.join(", ")}.")
end

Przypisy

  1. Klasa w szkole, klasa w programie, wiem, może mylić…

4 komentarze

  1. what… the female function is easy but not usefull. emagine if you have jenifer, ann, kate, liz and other females. this function will return that they are males, because they simply don’t end in letter a! whaaaaaaaat! and there are russian male names nikita, sasha, yasha, luka that are mails. and the stupid function will count them as, ges what… females!

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *