Ruby a OOP (1)

1. 3. 2002
Doba čtení: 9 minut

Sdílet

Hned v prvním dílu seriálu článků o programovacím jazyku Ruby bylo obsaženo tvrzení, že Ruby je jazyk od základu objektově orientovaný. V následném textu však nebylo toto tvrzení důsledně doloženo. Nový seriál se proto zaměřuje právě na OOP.
Terminologie

Objektově orientované programování (OOP) využívá faktu, že v okolním světě přirozeně identifikujeme objekty a vztahy mezi nimi. Pod pojmem objekty rozumíme konkrétními výskyty určité obecné entity. Obecná entita „pes“ může například zahrnovat objekty „Alík“ a „Brok“. V programu reprezentujeme objekty jako struktury zahrnující stav objektu (čili hodnoty určitých vlastností) a funkce umožňující stav měnit.

Programovací jazyky používají různé konkrétní termíny a syntaktické konstrukce k implementaci objektově orientovaných programů. V Ruby se setkáme s pojmy:

  • Třída (class) – reprezentuje obecnou entitu, kategorii objektů.
  • Instance – je konkrétním výskytem třídy. Podle výše uvedeného příkladu jsou „Alík“ a „Brok“ instancemi třídy „pes“.
  • Objekt – synonymum termínu instance.
  • Proměnná – součástí objektu mohou být proměnné udržující informace o jeho stavu.
  • Metoda – funkce spojená s objektem a obvykle měnící jeho stav.
  • Atribut – v Ruby označení pro proměnnou objektu, která je dostupná mimo tento objekt.
  • Zpráva – objektu je možné zaslat zprávu, která obsahuje informaci o tom, co a jak má objekt provést. Zaslání zprávy objektu je většinou implementováno jako volání metody objektu s parametry.

Čistě objektově orientovaný návrh bývá řazen mezi hlavní charakteristiky a pozitiva Ruby. Co to přesně znamená?

  • Všechny proměnné jsou objekty.
  • Také výsledky všech operací jsou objekty.
  • Standardní knihovna je realizována výhradně jako knihovna tříd. Funkčnost knihovny je obsažena v metodách tříd.
  • Syntaxe Ruby podporuje doporučovaný design objektově orientovaných programů (například striktní zapouzdření – viz dále).
  • Syntaxe Ruby umožňuje bezpečnou realizaci pokročilých konstrukcí OOP (například vícenásobnou dědičnost – viz dále).
Definice třídy

Víme, že objekt je strukturou sestávající z dat (vlastností objektu) a funkcí. Protože všechny instance dané třídy mají stejné vlastnosti, je konkrétní zápis proměnných a metod objektu ve zdrojovém kódu programu realizován definicí třídy.

Řekněme, že se náš špičkový vývojový tým chystá vytvořit hru, ve které se budou po dvourozměrné ploše pohybovat roboti a budou se vyhýbat stacionárním překážkám. Ačkoliv hra bude složitá a rozsáhlá, zpočátku se spokojíme se zachycením nejzákladnější vlastnosti jejích objektů – hodnot souřadnic X a Y. Můžeme to udělat například touto definicí objektové třídy Basic:

class Basic                  # definice začíná klíčovým slovem class

  def initialize(xpos,ypos)  # metoda se definuje stejně jako funkce
    @xpos=xpos               # zadané parametry uložíme do proměnných,
    @ypos=ypos               # jejichž název začíná na '@'
  end

  def to_s                   # ještě jedna metoda
    "X=#{@xpos}, Y=#{@ypos}" # vrací řetězec s obsahem proměnných
  end

end                          # definice třídy končí slovem end

Metoda initialize je (v Ruby) zvláštní metodou, která je volána v okamžiku vzniku instance. Jejím úkolem je nastavit počáteční stav objektu – tj. inicializovat proměnné.

V uvedeném příkladu přiřazuje initialize hodnoty parametrů do proměnných, jejichž název začíná znakem ‚@‘. To jsou proměnné, které bude mít každá instance dané třídy. Všimněme si, že není nutná jejich předchozí deklarace. Nepřítomnost deklarace na druhou stranu vyžaduje speciální znak na začátku jména, aby interpret rozpoznal lokální proměnné (či parametry) od proměnných objektu (třeba v přiřazení @xpos=xpos).

V Ruby existuje konvence, že voláním metody to_s získáme řetězcovou reprezentaci objektu. Největší smysl to má samozřejmě u čísel, která lze snadno převést na řetězec. V našem případě bude metoda to_s vracet informaci o obsahu proměnných objektu. Za zmínku stojí nepovinné používání klíčového slova return. Pokud pomocí return neoznačíme návratovou hodnotu, vrací metoda výsledek posledního vyhodnoceného výrazu. V uvedeném příkladu je výrazem řetězec v uvozovkách, kam budou při vyhodnocování doplněny hodnoty proměnných.

Instanci námi definované třídy vytvoříme voláním metody new:

a=Basic.new(7,8)             # nová instance třídy Basic
puts a.to_s                  # vypíšeme, co vrátí metoda to_s

Po spuštění kódu obdržíme výpis podobný následujícímu:

X=7, Y=8

Metoda new, která bývá označována jako konstruktor, se neváže ke konkrétní instanci, ale ke třídě. Voláme ji tedy i se jménem třídy jako Basic.new. (O metodách vázajících se ke třídě bude ještě řeč.) Volání new vytvoří novou instanci dané třídy a zavolá initialize, které předá zadané parametry. Počet parametrů metody initialize tedy určuje, s jakými parametry se bude volat new.

V proměnné a je uschována reference (odkaz) na nově vytvořený objekt. Metodu instance (oproti metodě třídy) pak voláme pomocí této reference. Volání a.to_s vrací očekávaný řetězec.

Mohli bychom očekávat, že k proměnným objektu lze přistupovat stejně jako k metodám:

puts a.@xpos    # chyba!

Výsledkem je však oznámení syntaktické chyby. Ruby striktně ctí zásadu zapouzdření, která je považována za jeden ze tří hlavních znaků OOP. Zapouzdření znamená, že k proměnným objektu lze přistupovat jen pomocí jeho metod. Dodržování tohoto principu zamezuje vzniku nežádoucích závislostí programu na vnitřní podobě používané třídy.

Někdy je samozřejmě užitečné mít možnost přímo číst nebo měnit hodnoty proměnných v instanci bez složitého zpracování nebo kontroly. Pro zjištění hodnoty proměnné @xpos můžeme snadno nadefinovat metodu xpos:

class Basic    # pokračujeme v definici třídy Basic

  def xpos     # metoda xpos nemá parametry
    @xpos      # a vrací hodnotu proměnné @xpos
  end

end            # konec definice

V Ruby není definice třídy nikdy uzavřena. Předešlý kód můžeme proto klidně přidat k již uvedené definici třídy Basic. Tímto způsobem lze přidávat metody i do vestavěných tříd jazyka Ruby.

Metodu xpos můžeme ihned vyzkoušet:

puts a.xpos

Výsledkem je samozřejmě číslo 7. Co kdybychom chtěli hodnotu proměnné nastavit?

a.xpos=3   # chyba!

Výsledkem je chybové hlášení o neexistenci metody:

undefined method 'xpos=' for #<Basic:0xa030510>(NameError)

Možná uhádnete, že pro nastavení hodnoty proměnné potřebujeme definovat metodu, jejíž název končí znakem ‚=‘:

class Basic           # pokračujeme v definici třídy Basic

  def xpos=(newxpos)  # metoda má jako parametr nově požadovanou hodnotu
    @xpos=newxpos     # a přiřadí ji proměnné @xpos
  end

end                   # konec definice

Nyní můžeme úspěšně zopakovat předchozí pokus:

a.xpos=3
puts a.to_s

Tentokrát bude výsledkem:

X=3, Y=8

Na začátku jsem definovali termín atribut jako proměnnou přístupnou mimo objekt. Definicí přístupových metod jsme právě vytvořili pro třídu Basic atribut xpos. Souhrn metod měnících stav objektu a určených pro volání z kódu mimo samotnou definici třídy se označuje jako rozhraní (interface) třídy.

Definice přístupových metod pro větší množství proměnných by byla nudnou rutinou. Ruby proto nabízí efektivní zkratku. Namísto dvou naposledy definovaných metod můžeme psát:

class Basic                # pokračujeme v definici třídy Basic

  attr_reader :xpos,:ypos  # vytvoř metody pro čtení @xpos a @ypos
  attr_writer :xpos,:ypos  # vytvoř metody pro nastavení @xpos a @ypos

end                        # konec definice

Zápis typu :xpos se používá, chceme-li jako parametr předat název metody nebo proměnné a ne její výsledek či obsah.

Se získanými poznatky můžeme nyní zrevidovat definici třídy Basic. Náš vývojový tým mezitím studoval fyziku, a proto přidáme ještě proměnnou pro uchování hmotnosti objektů (bude se nám hodit v dalším výkladu):

class Basic    # definice třídy Basic, verze 2

  def initialize(xpos,ypos,mass)  # metoda pro inicializaci proměnných
    @xpos=xpos                    # souřadnice X
    @ypos=ypos                    # souřadnice Y
    @mass=mass                    # nově přidáme hmotnost
  end

  attr_reader :xpos,:ypos,:mass   # vytvoř metody pro čtení proměnných
  attr_writer :xpos,:ypos,:mass   # a metody pro zápis

  def to_s                                # výpis hodnot proměnných
    "X=#{@xpos}, Y=#{@ypos}, M=#{@mass}"  # i sem přidáme hmotnost
  end

Ověříme správnost definice a vyzkoušíme si, že do proměnných se ukládají reference na objekty:

a=Basic.new(7,8,5)  # vytvoříme novou instanci třídy 'Basic'
b=a                 # referenci z 'a' zkopírujeme do 'b'
puts a.to_s         # voláme vlastně dvakrát metodu téhož objektu,
puts b.to_s         # protože 'a' i 'b' ukazují na stejnou instanci
b.mass=6            # nastavíme hodnotu 'mass'
puts a.to_s         # výpisem ověříme, že se změnil objekt, na který
puts b.to_s         # odkazují obě proměnné

Výsledkem je:

X=7, Y=8, M=5
X=7, Y=8, M=5
X=7, Y=8, M=6
X=7, Y=8, M=6

Samozřejmě, že Ruby nabízí i možnost zkopírovat celý objekt, ne pouze referenci na něj. Způsob, jak toho dosáhnout, si ukážeme později.

Protože se má vyvíjená hra prodávat zejména v USA, probíhala až doposud veškerá manipulace s hmotností v librách. Z důvodu rozšíření fyzikálních výpočtů, které chceme implementovat ve třídě Basic, by se nám však hodilo, aby byla hmotnost uvnitř třídy reprezentována v kilogramech. Všechen kód, který třídu Basic používá, však pracuje s librami. Jak tento problém jednoduše vyřešit? Stačí nám vhodně předefinovat metody pracující s proměnnou @mass:

class Basic      # definice třídy 'Basic', verze 3

  LB_KG=0.454    # konstanta pro přepočet liber na kg (libra je cca 0.454kg)

  def initialize(xpos,ypos,mass)  # inicializace proměnných
    @xpos=xpos
    @ypos=ypos
    @mass=mass*LB_KG  # hodnota přijde v librách, přepočítáme ji na kg
  end

  attr_reader :xpos,:ypos  # běžné přístupové metody pro čtení
  attr_writer :xpos,:ypos  # a zápis

  def mass                 # přístupová metoda pro čtení '@mass'
    @mass/LB_KG            # přepočteme zpět z kg na libry
  end

  def mass=(mass)          # přístupová metoda pro zápis '@mass'
    @mass=mass*LB_KG       # přepočteme z liber na kg
  end

  def to_s
    "X=#{@xpos}, Y=#{@ypos}, M=#{@mass}kg"  # kontrolný výpis ponecháme v kg
  end

end

Vnitřně je hmotnost ukládána v kilogramech a vnější kód pracuje s librami. Přístupové metody provádějí potřebné konverze. Protože jsme si díky zapouzdření jisti, že k proměnným nelze přistupovat přímo, nemusíme se obávat problémů.

V příkladu jsem zavedli konstantu LB_KG (název konstanty je psán velkými písmeny). Tato konstanta není logicky přiřazena k instanci, ale k celé třídě, protože je pro všechny instance stejná. Jak uvidíme, je přístupná dokonce i mimo třídu a prakticky nahrazuje globální konstantu.

a=Basic.new(7,8,5)  # vytvoříme novou instanci
puts a.to_s         # výpis proměnných ukazuje vnitřní hodnotu v kg
puts a.mass         # přístupová metoda ukazuje naopak hodnotu v librách
puts Basic::LB_KG   # konverzní konstanta je pomocí '::' přístupná mimo třídu
puts Math::PI       # na ukázku: konstanta vestavěné třídy obsahující pí

Po spuštění kódu získáme:

X=7, Y=8, M=2.27kg
5.0
0.454
3.141592654

Kromě konstanty lze zavést i proměnnou, která bude sdílena všemi instancemi (tzv. class variable). Název proměnné společné pro všechny instance třídy začíná dvěma znaky ‚@@‘. Klasickým příkladem použití sdílené proměnné je počítání vzniklých instancí:

class Basic      # definice třídy 'Basic', verze 4

  LB_KG=0.454
  @@count=0      # class variable je nutno inicializovat v definici třídy

  def initialize(xpos,ypos,mass)
    @xpos=xpos
    @ypos=ypos
    @mass=mass*LB_KG
    @@count+=1             # při každé nové instanci přičteme k počitadlu 1
  end

  #
  # definice přístupových metod zůstává beze změny (lze ji zkopírovat
  # z minulého příkladu)
  #

  def to_s
    "X=#{@xpos}, Y=#{@ypos}, M=#{@mass}kg, C=#{@@count}" # doplníme '@@count'
  end

end

Snadno vyzkoušíme:

a=Basic.new(7,8,5)
b=Basic.new(1,2,3)
c=Basic.new(2,3,4)
puts a.to_s

Výsledek je podle očekávání:

X=7, Y=8, M=2.27kg, C=3

Jak nejspíše tušíte, do třetice všeho dobrého existují i metody svázané s třídou (tzv. class methods). S jednou jsme se již setkali. Několikrát jsme použili metodu new. Metoda new nemůže být metodou objektu, protože jej teprve vytváří. Na druhé straně musí být svázána s třídou, protože postup vytváření objektů různých tříd se může lišit.

Nadefinujme si metodu třídy Basic, která zobrazí obsah proměnné @@count:

def Basic.count  # definujeme metodu s názvem 'Třída.metoda'
  @@count        # vrací hodnotu počitadla
end

Metoda třídy má přístup k proměným třídy, logicky však nemůže přistupovat k žádným proměnným instancí. Naopak metody instancí mohou přistupovat k proměnným dané instance i k proměnným třídy. Ověříme funkčnost námi definované metody:

a=Basic.new(7,8,5)
b=Basic.new(7,8,5)
c=Basic.new(7,8,5)
puts Basic.count   # voláme v podobě 'Třída.metoda'

Výsledek je podle očekávání:

ict ve školství 24

3

Uveďme si ještě jeden příklad. Instance vestavěné třídy File je otevřený soubor. Otevřený soubor však nelze korektně smazat. Proto existuje metoda třídy File.delete, která smaže soubor, jehož název dostane jako parametr.

V této chvíli jsme zvládli vše potřebné k definování tříd v Ruby. V příštím dílu rozšíříme naše znalosti o základní způsob, kterým se v OOP z již existujících tříd odvozují třídy nové. Řeč bude o dědičnosti.

Seriál: Ruby a OOP