Podrozdziały


19.1 Konwersje od i do typu definiowanego

Jeśli zdefiniowaliśmy klasę, a więc nowy typ danych (zmiennych), to często chcielibyśmy określić, w jaki sposób ma być dokonywana konwersja od obiektów innych typów do obiektów zdefiniowanej przez nas klasy. Z drugiej strony, chcielibyśmy czasem zdefiniować konwersje odwrotne: od obiektów naszej klasy do obiektów innych typów (wbudowanych, bibliotecznych lub też zdefiniowanych przez nas).


19.1.1 Konwersja do typu definiowanego

Przypuśćmy, że klasa A ma konstruktor, który może być wywołany z jednym argumentem typu B. A zatem albo jest to konstruktor jednoparametrowy z parametrem typu B, albo pierwszy parametr ma taki typ, a pozostałe parametry mają wartości domyślne. Typ B może być typem wbudowanym (double, int, ...), albo typem przez nas zdefiniowanym.

Załóżmy teraz, że użyjemy obiektu klasy B w kontekście, w którym wymagany jest obiekt typu A. Jeśli opisany wyżej konstruktor w klasie A istnieje, to dokonana zostanie konwersja B A poprzez utworzenie nowego obiektu klasy A z dostarczeniem danego obiektu klasy B do konstruktora jako jedynego argumentu.

Na przykład, jeśli istnieje funkcja o parametrze typu A

       void fun(A a) { ... }
a jej wywołanie ma postać fun(4.25), to mamy do czynienia z niezgodnością typów, bo argument jest typu double. Błędu jednak nie będzie, jeśli klasa A ma konstruktor, który można wywołać z jednym argumentem typu double. Jeśli bowiem taki konstruktor konwertujący jest, to zostanie utworzony obiekt klasy A z użyciem tego konstruktora (do którego zostanie przesłana jako argument wartość 4.25). Tak utworzony obiekt zostanie następnie przesłany do funkcji fun.

A co będzie, jeśli tę samą funkcję wywołamy z argumentem całkowitym, na przykład ' fun(4)'? Takie wywołanie też będzie wtedy prawidłowe! Nie ma, co prawda, bezpośredniej konwersji int A, bo nie ma w klasie A konstruktora pobierającego jeden argument typu int. Istnieje jednak standardowa konwersja int double, a zdefiniowaną mamy, poprzez odpowiedni konstruktor, konwersję double A. Zatem za pomocą takiej dwustopniowej konstrukcji można skonwertować wartość całkowitą do obiektu klasy A: najpierw utworzona zostanie tymczasowa zmienna typu double, a z niej obiekt klasy A. W sekwencji kilku konwersji prowadzących do celu takich konwersji pośrednich może być nawet więcej. Ważne jest jednak, że

tylko jedna z konwersji w tej sekwencji może być konwersją definiowaną przez użytkownika; pozostałe muszą być konwersjami standardowymi.

Gdybyśmy bowiem dopuścili dowolne sekwencje konwersji, to możliwości byłoby tak wiele, że trudno byłoby w nich wszystkich zorientować się samemu programiście; w szczególności, trudno byłoby nawet przewidzieć, jakie typy takimi sekwencjami konwersji można połączyć, a jakie nie.

Zresztą, możliwość użycia w sekwencji nawet jednej konwersji zdefiniowanej przez użytkownika może być niewygodna. Jest tak wtedy, gdy potrzebujemy w naszej klasie konstruktora jednoparametrowego, ale wcale nie chcemy, aby był on wykorzystywany niejawnie do konwersji. Takie konwersje mogą się bowiem pojawić w najmniej spodziewanych miejscach, gdzie po prostu nie przewidzieliśmy, że będą przez kompilator wygenerowane. Dlatego istnieje specjalne słowo kluczowe, explicit, które może być użyte jako modyfikator konstruktora. Jeśli go użyjemy, to konstruktor taki nie będzie nigdy wykorzystany jako konstruktor konwertujący, a tylko wtedy, gdy służy swemu właściwemu celowi, to znaczy gdy jawnie tworzymy obiekty klasy.

Przyjrzyjmy się programowi:


P154: convto.cpp     Konwersja do klasy definiowanej

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  struct Point {
      5.      int x, y;
      6.      Point(int x = 0, int y = 0) : x(x), y(y) { }
      7.  };
      8.  
      9.  struct Segment {
     10.      Point A, B;
     11.      // explicit
     12.      Segment(Point A = Point(), Point B = Point())
     13.          : A(A), B(B)
     14.      { }
     15.  };
     16.  
     17.  void showPoint(Point A) {
     18.      cout << "Point[" << A.x << "," << A.y << "]";
     19.  }
     20.  
     21.  void showSegment(Segment AB) {
     22.      cout << "Segment: ";
     23.      showPoint(AB.A);
     24.      cout << "--";
     25.      showPoint(AB.B);
     26.      cout << endl;
     27.  }
     28.  
     29.  int main() {
     30.      int k = 7;
     31.      showPoint(k);
     32.  
     33.      cout << endl;
     34.  
     35.      Point A(1,1);
     36.      showSegment(A);
     37.      // showSegment(k);
     38.  }

Zdefiniowaliśmy tu dwie klasy: PointSegment. Oba posiadają konstruktory, które mogą być wywołane z jednym argumentem: klasa Point z argumentem typu int, a klasa Segment z argumentem typu Point (oba konstruktory są wieloparametrowe, ale dzięki zastosowaniu parametrów domyślnych mogą być wywołane z jednym argumentem —  patrz rozdział o argumentach domyślnych funkcji.

Następnie zdefiniowane są dwie funkcje, showPointshowSegment, których parametry są typu PointSegment.

Przyjrzyjmy się teraz funkcji main. W linii 31 wywołujemy funkcję showPoint z argumentem typu int. Funkcji o takiej nazwie i takim typie parametru nie ma. Jest taka funkcja, tyle że z parametrem typu Point. Kompilator sprawdzi zatem, czy istnieje konwersja int Point, to znaczy, czy istnieje konstruktor w klasie Point, który można wywołać z jednym argumentem typu int. Taki konstruktor istnieje, zatem konwersja zostanie dokonana: obiekt klasy Point na podstawie wartości całkowitej zostanie utworzony i wysłany do funkcji showPoint, jak o tym świadczy pierwsza linia wydruku:

    Point[7,0]
    Segment: Point[1,1]--Point[0,0]
W linii 35 tworzymy obiekt A klasy Point i posyłamy go do funkcji showSegment. Znów dokonana musi być konwersja, aby to wywołanie mogło być prawidłowe. Funkcja oczekuje argumentu typu Segment, zatem obiekt tej klasy zostanie utworzony z obiektu  A za pomocą wywołania konstruktora klasy Segment z wartością A jako argumentem. Świadczy o tym druga linia wydruku.

Zauważmy, że wywołanie z wykomentowanej linii 37 byłoby nieprawidłowe. Wymagałoby konwersji dwustopniowej: najpierw int Point, potem Point Segment. A zatem użyte musiałyby być dwie konwersje definiowane w programie: to jest jednak niemożliwe.

Spróbujmy teraz uaktywnić wykomentowaną linię 11. Konstruktor klasy Segment jest teraz zdefiniowany z modyfikatorem explicit. Nie może zatem pełnić roli konstruktora konwertującego Point Segment. Wywołanie z linii 36 staje się teraz nieprawidłowe, bo wymaga właśnie takiej konwersji. Taki program, z „odkomentowaną” linią 11, jest wobec tego błędny:

    cpp> g++ -Wall -pedantic-errors convto.cpp
    convto.cpp: In function `int main()':
    convto.cpp:36: error: conversion from `Point' to
           non-scalar type `Segment' requested

Jako drugi przykład rozpatrzmy klasę Modulo, której użyliśmy już w programach modsev.cpp modsev1.cpp. Teraz zauważmy, jak konstruktor konwertujący ułatwi nam przeciążenie operatora dodawania liczb typu Modulo.


P155: modcon.cpp     Konwersje ułatwiające przeciążenia operatorów

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  class Modulo {
      5.      int numb;
      6.  public:
      7.      static int modul;
      8.  
      9.      Modulo() : numb(0) { }
     10.  
     11.      Modulo(int numb) : numb(numb%modul) { }
     12.  
     13.      friend Modulo operator+(Modulo,Modulo);
     14.      friend ostream& operator<<(ostream&,const Modulo&);
     15.  };
     16.  int Modulo::modul = 7;
     17.  
     18.  Modulo operator+(Modulo m, Modulo n) {
     19.      return Modulo(m.numb + n.numb);
     20.  }
     21.  
     22.  ostream& operator<<(ostream& str, const Modulo& m) {
     23.      return str << m.numb;
     24.  }
     25.  
     26.  int main() {
     27.  
     28.      Modulo m(5), n(6), k;
     29.  
     30.      k = m + n;
     31.      cout << "m + n (mod " << Modulo::modul
     32.           << ") = "        << k << endl;
     33.  
     34.      k = m + 6;
     35.      cout << "m + 6 (mod " << Modulo::modul
     36.           << ") = "        << k << endl;
     37.  
     38.      k = 6 + m;
     39.      cout << "6 + m (mod " << Modulo::modul
     40.           << ") = "        << k << endl;
     41.  }

Tym razem operator dodawania obiektów klasy Modulo definiujemy poprzez zaprzyjaźnioną funkcję globalną, a nie jako metodę. Zauważmy, że zdefiniowaliśmy tylko jedną taką funkcję (linie 18-20): z oboma parametrami typu Modulo. Jak widać w funkcji main, używamy jej jednak do dodawania dwóch obiektów klasy Modulo (linia 30), do dodawania liczby typu int do obiektu klasy Modulo (linia 34), jak i do dodawania obiektu klasy Modulo do liczby (linia 38). We wszystkich przypadkach przeciążenie działa prawidłowo:

    m + n (mod 7) = 4
    m + 6 (mod 7) = 4
    6 + m (mod 7) = 4
Ta ostatnia forma dodawania, liczba+obiekt, nie byłaby możliwa do zrealizowania przez przeciążenie operatora dodawania za pomocą metody, bo po lewej stronie mamy tu liczbę, a nie obiekt klasy. Dlaczego mogliśmy zdefiniować tylko jedną formę funkcji przeciążającej operator dodawania, tę z oboma parametrami typu Modulo? Było to możliwe dzięki konstruktorowi z linii 11, który jest konstruktorem konwertującym int Modulo. Za jego pomocą argument całkowity zostanie przekonwertowany automatycznie do typu Modulo tam, gdzie konieczność takiej konwersji będzie wynikała z kontekstu, i to niezależnie od tego, czy liczba występuje po lewej, czy po prawej stronie operatora ' +'.


19.1.2 Konwersja od typu definiowanego

Opisanym sposobem możemy przekształcać obiekty pewnej klasy (w szczególności typu wbudowanego) na obiekty definiowanej przez nas klasy. Można również „nauczyć” kompilator operacji odwrotnej: konwertowania obiektów definiowanej przez nas klasy na obiekty innego typu (w szczególności typu wbudowanego). Jest to jedyne wyjście, gdy klasa docelowa, czyli ta, do której ma nastąpić konwersja, jest dla nas niedostępna i nie możemy w niej dodefiniować konstruktora konwertującego (bo, na przykład, typem docelowym jest typ wbudowany w ogóle nie będący klasą, albo klasa docelowa pochodzi z biblioteki, której nie możemy lub nie chcemy modyfikować).

W takiej sytuacji w definiowanej przez nas klasie definiujemy metodę konwertującą (ang. conversion method). Jest to bezparametrowa metoda o nazwie ' operator Typ', gdzie Typ jest nazwą typu (klasy) docelowego — może to być typ wbudowany, jak int czy double, a może też być to typ przez nas zdefiniowany.

Dla metod konwertujących, wyjątkowo, nie podaje się typu zwracanego, ale nie oznacza to, że metoda jest bezrezultatowa. Wartość zwracana musi mieć typ określony nazwą metody; musi zatem zawierać instrukcję return zwracającą wartość tego typu. Ponieważ jest to metoda, zawsze będzie działać na rzecz konkretnego obiektu: jej zadaniem jest „wyprodukowanie” obiektu odpowiedniego typu (do którego następuje konwersja) na podstawie obiektu, na rzecz którego działa. Oczywiście, nie powinna zmieniać obiektu źródłowego, a więc powinna być zadeklarowana jako const. Można też, podobnie jak konstruktory konwertujące, deklarować takie metody jako explicit: zapobiega to przypadkowym, niezamierzonym konwersjom, ale też powoduje konieczność stosowania konwersji jawnych tam, gdzie ich chcemy.

Rozważmy przykład, podobny do tego z programu convto.cpp:


P156: convfrom.cpp     Konwersja od klasy definiowanej

      1.  #include <iostream>
      2.  #include <cmath>
      3.  using std::cout; using std::endl;
      4.  
      5.  struct Point {
      6.      double x, y;
      7.      Point(double x = 0, double y = 0) : x(x), y(y) { }
      8.      operator double() const {         
      9.          return std::sqrt(x*x+y*y);
     10.      }
     11.  };
     12.  
     13.  struct Segment {
     14.      Point A, B;
     15.      Segment(Point A = Point(), Point B = Point())
     16.          : A(A), B(B)
     17.      { }
     18.      operator Point() const {          
     19.          return Point( (A.x+B.x)/2, (A.y+B.y)/2 );
     20.      }
     21.  };
     22.  
     23.  void showPoint(Point A) {
     24.      cout << "Point[" << A.x << "," << A.y << "]";
     25.  }
     26.  
     27.  void showSegment(Segment AB) {
     28.      cout << "Segment: ";
     29.      showPoint(AB.A);
     30.      cout << "--";
     31.      showPoint(AB.B);
     32.      cout << endl;
     33.  }
     34.  
     35.  void showDouble(double d) {
     36.      cout << "Double " << d;
     37.  }
     38.  
     39.  int main() {
     40.      Point A(3,4);
     41.      showPoint(A);                     
     42.      cout << endl;
     43.      showDouble(A);                    
     44.  
     45.      cout << endl;
     46.  
     47.      Segment BC(Point(1,1),Point(3,3));
     48.      showSegment(BC);
     49.      showPoint(BC);                    
     50.  
     51.      cout << endl;
     52.  }

Do klasy Point dodaliśmy tu metodę konwertującą operator double() (). Zwraca ona odległość punktu od początku układu współrzędnych w postaci wartości typu double (w tym przypadku jest to wartość typu wbudowanego). Podobnie, do klasy Segment dodana została metoda konwertująca do klasy Point (). Zwraca ona obiekt klasy Point reprezentujący geometryczny środek odcinka. Dodana też została funkcja showDouble, która wypisuje przekazaną przez argument liczbę typu double.

W linii wywołujemy funkcję showPoint z argumentem typu Point, a więc typu zadeklarowanego parametru funkcji. Zaraz potem wywołujemy z tym samym argumentem funkcję showDouble (). Funkcja ta spodziewa się argumentu typu double. Zatem potrzebna jest konwersja Point double. Ponieważ taką konwersję zdefiniowaliśmy, wywołanie jest prawidłowe: przed przekazaniem do funkcji obiekt A klasy Point zostanie skonwertowany do typu double, o czym świadczy druga linia wydruku:

    Point[3,4]
    Double 5
    Segment: Point[1,1]--Point[3,3]
    Point[2,2]
Podobnie będzie w linii . Do funkcji showPoint, która ma parametr typu Point, przesyłamy obiekt klasy Segment. Zostanie zatem użyta metoda konwertująca klasy Segment () definiująca konwersję Segment Point.

Zauważmy, że w drugim przypadku ten sam cel moglibyśmy osiągnąć definiując konstruktor przyjmujący jeden argument typu Segment w klasie Point. Nie było to jednak możliwe w przypadku poprzednim, gdyż nie da się zdefiniować konstruktora konwertującego w klasie double (przede wszystkim dlatego, że takiej klasy nie ma!).

Zauważmy na koniec, że metody konwertujące są dziedziczone i mogą być wirtualne — sens tego omówimy w jednym z następnych rozdziałów.

T.R. Werner, 23 lutego 2019; 23:59