Przeładowania operatorów i języki domenowe w C++

Artykuł ten ma na celu pokazanie, jak wykorzystać dwie techniki języka C++, tj.

  • przeładowywanie operatorów
  • zwracanie referencji do obiektu przez metodę tego obiektu

do tworzenia nowych pseudojęzyków.

Co daje przeładowanie operatora w kontekście języków specyficznych dla domeny?

Możliwość przeładowania operatorów wyposaża nas w możliwość bardziej obrazowego przedstawienia pewnych operacji, a także tworzenia pewnego rodzaju dostosowanej skladni. Weźmy najprostszy przykład strumieni plików w języku C++:

std::cout << "tralalala";

Jest to przykład, jak, korzystając z przeładowania operatora « osiągnięto składnię w pewnym sensie naturalną dla wszystkich tych, którzy wykorzystują przekierowanie strumieni w powłokach systemów operacyjnych.

Co daje zwrócenie referencji do obiektu przez jego metodę w kontekście języków specyficznych dla domeny?

Zwracanie referencji do obiektu przez jego metodę wyposaża nas w mechanizm pozwalający powtarzać wykorzystanie metody. Na poniższym przykładzie widać takie właśnie zastosowanie tej techniki:

class Object
{
public:
  Object& doSth(int arg)
  {
    return *this;
  }
};
 
int main()
{
  //wykorzystanie techniki zwracania referencji do obiektu 
  //w celu wykonania szeregu odwołań do tego samego obiektu w jednym wyrażeniu.
  Object().doSth(1).doSth(2).doSth(3).doSth(4).doSth(5).doSth(1).doSth(3).doSth(5).doSth(7).doSth(9);
}

Jeśli weźmiemy pod uwagę, że operator również jest metodą, szybko dojdziemy do wniosku, że technika ta jest wykorzystywana dosyć szeroko w istniejącej bibliotece standardowej języka C++. Innymi słowy - odkrywamy Amerykę na nowo. Spójrzmy chociażby na poniższy przykład:

std::cout << 1 << "tralala" << 4.5 << std::endl;

i porównajmy go z poprzednim. Analogia powinna być widoczna gołym okiem. Jeśli nie jest, to spróbujmy rozpisać powyższy kod tak, by wyglądał jak ciąg wywołań metod (którymi to tak naprawdę są operatory):

std::cout.operator<<(1).operator<<("tralala").operator<<(4.5).operator<<(std::endl);

Inny, zabawny przykład, tym razem z użyciem obiektu funkcyjnego, można podziwiać na poniższym listingu:

class ParenthesesFun
{
public:
  ParenthesesFun& operator()()
  {
    std::cout << "Parentheses!" << std::endl;
    return *this;
  }
};
 
int main()
{
  ParenthesesFun()()()()()()()()()()()()()()()()()()()()()()()(); //i tak do woli...
  return 0;
}

Pierwszy przykład - piosenkarz.

Dla wypróbowania drugiej z omawianych technik zaprojektujmy język domenowy do układania piosenek dla wirtualnych piosenkarzy. Nie będzie to dobrze zaprojektowany język domenowy, ale powinien otworzyć oczy na pewne możliwości jakie stoją przed programistą.

Zakładamy, że piosenkarz używa języka, który ma tylko pięć słów: "dum", "dam", "di", "da" oraz "ram".
Chcielibyśmy, by można było układać dla niego piosenkę w następujący sposób:

Singer().dum().dum().dam().di().ram().di().dam().dam();

Chcielibyśmy także, by potrafił ją zaśpiewać do mikrofonu, np.:

Singer().dum().dum().singTo(std::cout); //w naszym przypadku mikrofon to standardowe wyjście.

Przystąpmy zatem do napisania kodu. Będzie on bardzo prosty:

#include<iostream>
#include<string>
 
class Singer
{
public:
  Singer() { songContent = ""; }
 
  Singer& dum() { songContent += "dum "; return *this; }
  Singer& dam() { songContent += "dam "; return *this; }
  Singer& di()  { songContent += "di ";  return *this; }
  Singer& da()  { songContent += "da ";  return *this; }
  Singer& ram() { songContent += "ram "; return *this; }
 
  template<typename T> void singTo(T& mic) { mic << songContent << std::endl; }
 
private:
  std::string songContent;
};

Drugi przykład - wpisywanie do wektora kilku wartości za jednym zamachem.

Poniższy przykład demonstruje, w jaki sposób, używając techniki zwracania referencji oraz przeładowywania operatorów, uprościć wpisywanie do wektora dowolnego typu.

template<typename T> class PushBacker
{
public:
  PushBacker(T& initInstance) : instance(initInstance)
  {
    //empty body
  }
 
  PushBacker& operator,(typename T::value_type value)
  {
    instance.push_back(value);
    return *this;
  }
 
  PushBacker& operator << (typename T::value_type value)
  {
    instance.push_back(value);
    return *this;
  }
 
private:
  T& instance;
};
 
template<typename T> PushBacker<T> pushBackTo(T& instance)
{
  return PushBacker<T>(instance);
}

Zauważmy, że przeładowane zostały dwa operatory - bitowego przesunięcia w lewo («) oraz przecinek. W ten sposób można tworzyć, podobnie jak w przykładzie pierwszym, łańcuchy odwołań.

Kod ten można wykorzystać w następujący sposób:

#include<iostream>
#include<string>
#include<vector>
#include<algorithm>
#include<iterator>
 
//...
//...
//...
 
int main()
{
  std::vector<int> intVec;
  //wykorzystanie napisanego wcześniej kodu:
  pushBackTo(intVec) << 1, 2, 3*3, 4+66/5, 5, 12, 3233, 34, 345, 345, 345, 1, 2, 76;
 
  //wypisanie zawartości wektora:
  std::ostream_iterator<int> output(std::cout, ", ");
  std::copy(intVec.begin(), intVec.end(), output);
  return 0;
}

Warto podkreślić, że wielu programistów C++ uznałoby powyższy kod za napisany w złym stylu i być może mieliby rację. Chodzi tu tylko o pokazanie możliwości, a nie o tworzenie pięknych rozwiązań.

Podsumowanie

Artykuł ten nie prezentuje żadnej odkrywczej techniki programowania, ani też paradygmatu, ani nawet czegoś, co można nazwać "sztuczką", lecz coś, co jest nagminnie wykorzystywane w samej bibliotece standardowej języka C++. Celem tego artykułu jest raczej uświadomienie, poprzez mało użyteczne zabawy z kodem, jak za pomocą prostych technik można dostosować język C++ do własnych potrzeb, tworząc wewnętrzny język domenowy.

O ile nie zaznaczono inaczej, treść tej strony objęta jest licencją Creative Commons Attribution-Share Alike 2.5 License.