Třída a její rozhraní

V tomto článku se zamyslíme nad tím, co tvoří rozhraní třídy v C++. Oklikou se pak vrátíme k prostorům jmen a k důsledkům Koenigova vyhledávání.

Třída jako datový typ

Nejprve si zopakujeme, že pod pojmem třída v objektově orientovaném programování v jazycích, jako je C++, rozumíme určitou množinu dat (hodnot) spolu s operacemi, které lze s těmito daty provádět. Tyto operace se zpravidla označují jako metody. Doplňme ještě, že určíme-li nějakou množinu hodnot, musíme také určit způsob, jakým budou tyto hodnoty v paměti počítače reprezentovány.

Všimněte si, že tato definice se vlastně kryje s běžnou definicí datového typu. Třídy v C++ tedy představují prostě jen další datové typy; jde sice o typy, které definuje sám programátor, ale to na věci nic nemění; ostatně třídy mají v C++ prakticky rovnocenné postavení jako vestavěné typy.

(Jistě jste si uvědomili, že zde vůbec nehovoříme o dědičnosti a o polymorfizmu. I když jde bezpochyby o důležité vlastnosti, pro naše úvahy o rozhraní třídy nebudou podstatné.)

Co tvoří rozhraní třídy

Pod rozhraním rozumíme v této souvislosti nástroje, které umožňují třídu používat – přistupovat k datům, která jsou uložena v jednotlivých instancích nebo ve třídě jako celku (statické datové složky).

Co tedy tvoří rozhraní třídy? Téměř automatická odpověď na tuto otázku zní, že rozhraní třídy tvoří její veřejně přístupné metody a datové složky. Mým cílem v tomto článku je ukázat, že tato odpověď není úplná.

Podívejme se nejprve na schématický příklad:

class X

{ // Nějaké datové složky a metody

}

void f(X& x)

{ //

}

Patří funkce f() do rozhraní třídy X?

Nejspíš odpovíte, že nikoli, neboť nejde o metodu, ale o samostatnou funkci. Co když ale deklarujeme funkci f() jako spřátelenou, např. takto?

class X

{

// Nějaké datové složky a metody

     friend void f(X& x);

}

void f(X& x)

{ // ...

}

Funkce f() může manipulovat se soukromými daty uloženými v instancích třídy X, neboť jinak bychom ji nepotřebovali deklarovat jako spřátelenou; umožňuje tedy třídu X používat. To ale znamená, že je rozumné považovat ji za součást rozhraní této třídy.

Pojďme ještě o krok dál: Musí taková funkce být spřátelená?

Než odpovíme, podíváme se na příklad; poslouží nám třída cplx představující komplexní čísla. Je samozřejmé, že součástí její implementace bude i přetížený operátor << pro výstup.

Lze navrhnout nejméně dvě varianty implementace tohoto operátoru. V první z nich deklarujeme operátor pro výpis jako spřátelenou funkci:

// První implementace: operátor << jako friend

#include <iostream>

using std::ostream;

using std::cout;

using std::endl;

class cplx

{

double re, im;

public:

cplx(double r = 0, double i = 0): re(r), im(i){}

double& Re(){return re;}

double& Im(){return im;}

friend ostream& operator<<(ostream& proud, cplx c)

{

return proud << "(" << c.re << ", " << c.im << ")";

}

};

Ve druhé implementaci se deklaraci friend vyhneme tím, že ve třídě cplx deklarujeme veřejně přístupnou metodu vypis(), která se postará o vše potřebné, a v operátoru <<  se na ni odvoláme.

// Druhá implementace: nepoužívá friend

class cplx

{

double re, im;

public:

cplx(double r = 0, double i = 0): re(r), im(i){}

double& Re(){return re;}

double& Im(){return im;}

ostream& vypis(ostream& proud) {

return proud << "(" << re << ", " << im << ")";

}

};

ostream& operator<<(ostream& proud, cplx c)

{

return c.vypis(proud);

}

Implementace operátoru << pro výpis komplexních čísel je sice poněkud jednodušší, než by bylo pro praktické použití potřebné, ale pro naše účely plně postačuje.

Rozdíl mezi těmito dvěma implementacemi třídy cplx je minimální; v obou případech lze oprávněně tvrdit, že operátor << je součástí rozhraní třídy cplx. To znamená, že součástí rozhraní třídy může být i samostatná funkce, která není deklarována jako spřátelená, pokud má tuto třídu jako parametr.

Někdy můžeme volit

V případě operátoru << jsme neměli jinou možnost než deklarovat jej jako samostatnou funkci, neboť pravidla jazyka C++ jasně říkají, že pro jakýkoli přetěžovatelný binární operátor @ znamená zápis

a @ b

volání buď funkce operator@(a, b), nebo metody a.operator@(b). Kdybychom chtěli definovat operátor << jako metodu, museli bychom jej přidat do knihovní třídy ostream – ale měnit standardní knihovny není nejlepší nápad.

V některých případech si ale můžeme vybrat. Jako příklad vezmeme opět třídu cplx a vybavíme ji navíc operátorem + pro sčítání instancí.

Implementace operátoru + jako metody je na první pohled logičtější, neboť jde o operaci nad daty instance:

// Implementace operátoru + jako metody

class cplx

{

double re, im;

public:

cplx(double r = 0, double i = 0): re(r), im(i){}

double& Re(){return re;}

double& Im(){return im;}

ostream& vypis(ostream& proud) {

return proud << "(" << re << ", " << im << ")";

}

cplx operator+(cplx c)

{

return cplx(re+c.re, im+c.im);

}

};

Přesto má tato implementace nejméně jednu závažnou nevýhodu: Operandy v ní nemají symetrické postavení. O tom se snadno přesvědčíme, deklarujeme-li proměnné

cplx c, a(1,2);

a napíšeme-li příkazy

c = a+1;      // OK

resp.

c = 1+a;      // Chyba

První příkaz se přeloží bez problémů, neboť překladač najde ve třídě cplx metodu operator+() a její parametr konvertuje na typ cplx pomocí konstruktoru této třídy. Ovšem druhý příkaz překladač označí jako chybný, neboť nenajde odpovídající operátor – nelze nejprve implicitně konvertovat číslo 1 typu int na typ cplx a pak volat metodu takto vytvořené instance.

To ovšem odporuje naší běžné zkušenosti: Z matematiky jsme zvyklí, že sčítání je komutativní, že můžeme pořadí jednotlivých sčítanců libovolně zaměňovat.

Můžeme si sice pomoci explicitním přetypováním,

c = cplx(1)+a;

avšak ani tento zápis není příliš intuitivní.

To znamená, že vhodnější bude použít implementaci operátoru + jako samostatné funkce. Použijeme opět triku založeného na veřejně přístupné metodě, která se postará o vše potřebné:

// Implementace operátoru + jako samostatné funkce

class cplx

{

double re, im;

public:

cplx(double r = 0, double i = 0): re(r), im(i){}

double& Re(){return re;}

double& Im(){return im;}

ostream& vypis(ostream& proud) {

return proud << "(" << re << ", " << im << ")";

}

cplx Plus(cplx c)

{

return cplx(re+c.re, im+c.im);

}

};

cplx operator +(cplx a, cplx b)

{

return a.Plus(b);

}

Tentokrát přijme překladač oba příkazy

c = a+1;      // stále OK

c = 1+a;      // Nyní také OK

bez námitek.

Podobně i při deklaraci dalších operátorů implementujících běžné aritmetické operace s komplexními čísly zjistíme, že je rozumné deklarovat je jako samostatné funkce. Ukazuje se tedy, že případů, kdy je nutné nebo výhodné deklarovat některou z operací s daty třídy nebo instance jako samostatnou funkci, může být více, než se na první pohled zdá.

Také v tomto případě je naprosto logické tvrdit, že operátor +, stejně jako ostatní aritmetické operátory, je součástí rozhraní třídy cplx, a to bez ohledu na to, zda je implementován jako metoda nebo jako samostatná funkce.

Nejen parametr

Podobnými úvahami dospějeme k závěru, že za součásti rozhraní třídy X můžeme považovat i samostatné funkce, které vracejí instance této třídy nebo které ji nějakým jiným způsobem „zmiňují“.

Princip rozhraní

Z předchozích úvah by se mohlo zdát, že součástí rozhraní třídy X je jakákoli funkce, která má parametr typu X nebo která třídu X jakýmkoli jiným způsobem „zmiňuje“. Představme si ale, že X je knihovní třída ostream. Má smysl považovat za součást jejího rozhraní funkci operator<<(ostream&, cplx), kterou napíšeme ve svém programu pro naši vlastní třídu cplx?

Takové pojetí pojmu rozhraní by nejspíš vedlo ke zmatkům, neboť pak by se rozhraní třídy mohlo měnit od programu k programu, a to je (přinejmenším v případě knihovních tříd) nežádoucí.

Rozumnější je zahrnout do rozhraní třídy X pouze ty samostatné funkce, které nejen pracují s X, ale také se spolu s touto třídou dodávají. Ostatně takovéto funkce budou spolu s ní také dokumentovány.

Shrneme-li výsledky předchozích úvah, dospějeme k tzv. principu rozhraní [1]:

Pro jakoukoli třídu X a pro jakoukoli  funkci f() platí, že pokud funkce f() pracuje s X a je spolu s ní i dodávána, je součástí jejího rozhraní, a tedy tvoří její logickou část.

Rozhraní třídy a prostory jmen

Podívejme se nyní, jak do předchozích úvah zapadají prostory jmen.

Minule jsme si řekli, že na prostory jmen se můžeme dívat jako na příjmení připojovaná k identifikátorům, která pomáhají zajistit v rozsáhlých programech jejich jednoznačnost. Ovšem tím také usnadňují organizaci programu: Věci, které tvoří logický celek, ukládáme do stejného prostoru jmen. Ukažme si, jak můžeme uspořádat naši miniknihovnu pro komplexní čísla.

Třídu cplx a funkce, které s ní souvisí, deklarujeme v prostoru jmen komplex. Do hlavičkového souboru cplx.h uložíme deklaraci třídy a prototypu obou samostatných funkcí:

// Soubor cplx.h

#ifndef _KOMPLEX_H_

  #define _KOMPLEX_H_

#include <iostream>

namespace komplex {

using std::ostream;

class cplx

{

double re, im;

public:

cplx(double r = 0, double i = 0): re(r), im(i){}

double& Re(){return re;}

double& Im(){return im;}

ostream& Vypis(ostream& proud) {

return proud << "(" << re << ", " << im << ")";

}

cplx Plus(cplx c)

{

return cplx(re+c.re, im+c.im);

}

};

cplx operator +(cplx a, cplx b);

ostream& operator<<(ostream& proud, cplx c);

}

#endif

Do souboru cplx.cpp uložíme definice obou samostatně deklarovaných operátorů.

// Soubor cplx.cpp

#include "cplx.h"

namespace komplex {

cplx operator +(cplx a, cplx b)

{

return a.Plus(b);

}

ostream& operator<<(ostream& proud, cplx c)

{

return c.Vypis(proud);

}

}

Do tohoto souboru patří samozřejmě i definice všech metod, které nejsou vložené (inline). Naše zjednodušená implementace třídy komplex::cplx však zatím žádné takovéto metody neobsahuje.

Nyní můžeme v programu, který pracuje s třídou komplex::cplx, napsat např.

#include "cplx.h"

using komplex::cplx;

using std::cout;

// ...

cplx a(1,2);

cout << 1+a;

Díky pravidlu vyhledávání funkcí i v prostorech jmen parametrů (Koenigovu vyhledávání) není třeba zpřístupňovat operátory + a <<.

Předchozí příklad ukazuje, že Koenigovo vyhledávání velmi dobře doplňuje princip rozhraní. Logickou souvislost třídy a spolu s ní dodávaných samostatných funkcí, které doplňují její rozhraní, lze podtrhnout tím, že třídu i tyto funkce umístíme do společného prostoru jmen.

Nejednoznačnost

Prostory jmen zdánlivě umožňují klientským programátorům, tj. uživateli třídy deklarované v prostoru jmen, nestarat se o samostatné funkce a operátory deklarované spolu s třídou. Zdání ovšem může klamat. Podívejme se nyní na případ, kdy programátor používající třídu X deklaruje funkci se stejným prototypem, jako má jedna z funkcí doprovázejících tuto třídu.

namespace Alfa

{

class X{};

void f(X x){ /*...*/ }     // (1)

}

void f(Alfa::X x){ /*...*/ }     // (2)

int main()

{

Alfa::X x;

f(x)           // Které f()?

return 0;

}

Volání funkce f() ve funkci main() je nejednoznačné, neboť vyhledávací mechanizmus najde kromě globální funkce označené v komentáři číslem (1) ještě i funkci (2) v prostoru jmen Alfa.

Na první pohled se zdá, že jde o nevítaný důsledek Koenigova vyhledávání. Je ale opravdu tak nežádoucí? Podívejme se opět na konkrétní příklad: Vezměme naši oblíbenou třídu komplex::cplx a do role funkce f() dosaďme operátor <<. Dostaneme něco jako

#include <iostream>

using std::ostream;   

namespace komplex {

class cplx

{     // Třída cplx stejná jako předtím

};

cplx operator +(cplx a, cplx b);

ostream& operator<<(ostream& proud, cplx c);

}

ostream& operator<<(ostream& proud, komplex::cplx c)

{

return c.vypis(proud);

}

int main()

{

using komplex::cplx;

using std::cout;

cplx a(1,2);

cout << 1+a;       // Chyba – nejednoznačné

return 0;

}

a překladač ohlásí, že narazil na nejednoznačnost při vyhledávání operátoru <<. Na tomto příkladu je ale naprosto zřejmé, že klientský programátor přehlédl, že s třídou komplex::cplx se dodává již hotový operátor <<, a pokusil se naprogramovat si ho sám. Tím, že zde překladač ohlásí chybu, jej na toto nedopatření upozorní.

Může se samozřejmě stát, že klientskému programátorovi nevyhovuje původní implementace některé ze samostatných funkcí, doplňujících rozhraní třídy, a že si ji chce naprogramovat sám. Půjde-li o obyčejnou (nikoli operátorovou) funkci, není třeba původní program příliš měnit, stačí při volání kvalifikovat její identifikátor prostorem jmen.

Ukažme si schématický příklad:

namespace Alfa

{

class X{};

void f(X x){ /*...*/ }     // (1)

}

void f(Alfa::X x){ /*...*/ }     // (2)

int main()

{

      Alfa::X x;

::f(x);            // Volá (2)

       komplex::f(x);         // Volá (1)

return 0;

}

V případě operátorů nelze kvalifikaci použít. Pak nezbývá, než se uchýlit k funkcionálnímu zápisu operátoru, např.

komplex::operator<<(cout, c);

To je sice velmi nepohodlné, ale na druhé straně nejde v žádném případě o běžnou situaci.

Na závěr

Příklady, které doprovázely naše úvahy, ukazují, že princip rozhraní a Koenigovo vyhledávání se vzájemně doplňují. Ovšem jako všechny nástroje v C++, i tyto dva mohou při nevhodném použití způsobit problémy.

Miroslav Virius

Odkaz:
1. Herb Sutter: Exceptional C++. Addison-Wesley 2000.