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é.)
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.
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.
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í“.
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]:
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.
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)
}
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.
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.
Odkaz:
1. Herb Sutter: Exceptional C++. Addison-Wesley 2000.