Podrozdziały


24.2 Algorytmy i obiekty funkcyjne

Biblioteka standardowa dostarcza ponad sto algorytmów, czyli szablonów funkcji operujących na kolekcjach. Argumentami tych funkcji nie są kolekcje jako takie, a iteratory.

Jako argumenty wielu algorytmów występują też funkcje albo, jeszcze częściej, obiekty funkcyjne.


24.2.1 Algorytmy

Omówimy pokrótce kilka algorytmów dostarczanych przez bibliotekę standardową. Nie będzie to przegląd pełny, ale pozwoli zorientować się Czytelnikowi, w jaki sposób algorytmy stosować. Większość algorytmów udostępniana jest poprzez włączenie pliku nagłówkowego algorithm.

Do bardziej popularnych i użytecznych należą algorytmy sortujące. Użycie ich jest proste:


P209: sort.cpp     Sortowanie

      1.  #include <iostream>
      2.  #include <vector>
      3.  #include <list>
      4.  #include <string>
      5.  #include <algorithm>
      6.  #include <iterator>
      7.  using namespace std;
      8.  
      9.  int main() {
     10.      vector<string> vec{"Paris", "London", "Warsaw",
     11.                         "Berlin", "Lisbon", "Oslo"};
     12.      auto ini = vec.begin(), fin = vec.end();
     13.  
     14.      list<string> lis(vec.size()-2);                   
     15.      copy(ini+1, fin-1, lis.begin());                  
     16.  
     17.      sort(ini,fin);                                    
     18.      lis.sort();                                       
     19.  
     20.      copy(ini, fin,                                    
     21.           ostream_iterator<string>(cout, " "));
     22.      cout << endl;
     23.  
     24.      copy(lis.begin(),lis.end(),
     25.           ostream_iterator<string>(cout, " "));
     26.      cout << endl;
     27.  }

W programie tworzymy wektor napisów. Następnie tworzymy listę () o początkowej wielkości o dwa mniejszej niż rozmiar wektora. Algorytm Algorytm copy () pobiera zakres (w postaci pary iteratorów) z kolekcji źródłowej i iterator z kolekcji docelowej wskazujący, od którego miejsca elementy mają być nadpisywane. Zauważmy, że algorytm nie tworzy nowych elementów docelowego kontenera, tylko nadpisuje istniejące. Dlatego właśnie tworząc listę musieliśmy zadbać o to, by miała ona wystarczający rozmiar.
Następnie sortujemy wektor algorytmem sort () —  jak zwykle, zakres elementów do posortowania przekazujemy jako parę iteratorów. Funkcja sort oczekuje iteratorów dostępu swobodnego. Dlatego nie można jej użyć do posortowania listy — dla list jednak istnieje metoda sort (patrz ).
Podobny mechanizm działa również dla innych algorytmów wymagających iteratorów swobodnego dostępu — kolekcje dostarczające tylko dwukierunkowych iteratorów mają zamiast nich odpowiadające im metody (np.  merge, remove, reverse itd.).

Zwróćmy jeszcze uwagę na linię . Tu również używamy funkcji copy. Kopiujemy elementy z jednej kolekcji, określone za pomocą iteratorów, do drugiej, określonej przez jeden iterator, tak jak poprzednio, przy kopiowaniu wektora do listy. Tym razem iterator będący trzecim argumentem jest nieco dziwny. Jest to iterator utworzony z szablonu ostream_iterator (z nagłówka iterator) przez konkretyzację dla typu string. Argumentami konstruktora tej konkretyzacji jest strumień wyjściowy, w naszym przypadku cout, oraz napis, który będzie pełnił rolę separatora pomiędzy elementami, w naszym przykładzie znak odstępu. W ten sposób strumień wyjściowy jest traktowany jak kolekcja elementów, które mają być wypisane. Iterator związany z tą kolekcją jest typu wyjściowego (output iterator), a więc jest jednokierunkowy, jednoprzebiegowy (one pass) i nie dopuszcza odczytu elementów. Wydruk programu to:

    Berlin Lisbon London Oslo Paris Warsaw
    Berlin Lisbon London Warsaw

Jak widzieliśmy, funkcja copy kopiuje elementy z jednej kolekcji do drugiej nadpisując już istniejące elementy. Dlatego w programie powyżej (linia ) musieliśmy utworzyć listę od razu odpowiedniego rozmiaru. To nie zawsze jest takie proste — często nie wiemy z góry ile elementów trzeba będzie skopiować. W takich sytuacjach możemy użyć specjalnej funkcji back_inserter, która zwraca obiekt zachowujący się jak iterator wyjściowy, ale który dodaje nowe elementy do kolekcji (wywołując push_back). Na przykład w poniższym przykładzie kopiujemy elementy z jednego wektora do drugiego (początkowo pustego), ale tylko takie, które spełniają predykat przesłany jako ostatni argument do funkcji copy_if:


P210: backins.cpp     Funkcja back_inserter

      1.  #include <algorithm>    // copy
      2.  #include <iostream>
      3.  #include <iterator>     // inserters
      4.  #include <vector>
      5.  
      6.  int main() {
      7.      std::vector<int> v{1, 2, 3, 4, 5, 6, 7};
      8.      std::vector<int> emp;
      9.      std::copy_if(v.begin(), v.end(),
     10.                   std::back_inserter(emp),
     11.                   [](auto n){ return n%2 != 0; });
     12.      std::copy(emp.begin(), emp.end(),
     13.                std::ostream_iterator<int>(std::cout, "\n"));
     14.  }

(istnieją też funkcje front_inserter inserter).

Przekazywanie funkcji do algorytmów jest bardzo częste. Na przykład funkcje sortujące mogą mieć dodatkowy argument określający jak elementy mają być porównywane (domyślnie są porównywane za pomocą operatora <). Powinien to być wskaźnik do funkcji, lambda lub obiekt funkcyjny (cokolwiek, co zachowuje się jak funkcja) pełniącej rolę komparatora, czyli funkcji pobierającej dwa argumenty typu takiego, jakiego są elementy kolekcji i zwracającej wartość logiczną (bool) odpowiadającą na pytanie czy pierwszy argument jest ściśle mniejszy od drugiego?
Na przykład w poniższym programie funkcja compar () uznaje każdą liczbę nieparzystą za mniejszą od dowolnej liczby parzystej a liczby o tej samej parzystości porządkuje w sposób naturalny (rosnąco). Ten komparator użyty jest w linii  jako wskaźnik funkcyjny.
Definiujemy też prostą strukturę Evens () z przeciążonym operatorem wywołania: obiekty tej klasy mogą również służyć jako komparatory () — w tym przypadku liczby parzyste są uważane za mniejsze od nieparzystych, a dla liczb o tej samej parzystości porządek będzie odwrócony (malejący).
W końcu w linii jako komparatora użyliśmy lambdy (porządkującej liczby malejąco, bez względu na parzystość).
Funkcja (szablon) printVec () służy do wypisywania wektorów.


P211: sortev.cpp     Komparatory

      1.  #include <iostream>
      2.  #include <vector>
      3.  #include <algorithm>
      4.  #include <iterator>
      5.  
      6.  bool compar(int a, int b) {                           
      7.      return (a+b)%2 != 0 ? a%2 != 0 : a < b;
      8.  }
      9.  
     10.  struct Evens {                                        
     11.      bool operator()(int a, int b) {
     12.          return (a+b)%2 != 0 ? a%2 == 0 : b < a;
     13.      }
     14.  };
     15.  
     16.  template <typename T>
     17.  void printVec(const std::vector<T>& vec) {            
     18.      copy(vec.cbegin(), vec.cend(),
     19.          std::ostream_iterator<T>(std::cout, " "));
     20.      std::cout << '\n';
     21.  }
     22.  
     23.  int main() {
     24.      std::vector<int> vec{2, 5, 2, 9, 1, 5, 7, 4};
     25.      printVec(vec);
     26.  
     27.      sort(vec.begin(), vec.end(), compar);             
     28.      printVec(vec);
     29.  
     30.      sort(vec.begin(), vec.end(), Evens{});            
     31.      printVec(vec);
     32.  
     33.      sort(vec.begin(), vec.end(),
     34.           [](int a, int b) {return b < a;});           
     35.      printVec(vec);
     36.  
     37.  }

Program drukuje

    2 5 2 9 1 5 7 4
    1 5 5 7 9 2 2 4
    4 2 2 9 7 5 5 1
    9 7 5 5 4 2 2 1

Ogólnie, funkcje zwracające wartość logiczną nazywamy predykatami. Są one używane przez bardzo wiele algorytmów, nie tylko sortujących. Na przykład funkcja (a właściwie, jak pamiętamy, wzorzec funkcji) count_if zlicza liczbę tych elementów kolekcji, określonej za pomocą dwóch iteratorów, dla których predykat wywołany z elementem kolekcji jako jedynym argumentem zwraca true. W poniższym programie predykat parzysta został użyty (linia 21) do zliczenia liczby elementów parzystych wektora:


P212: predyk.cpp     Predykaty

      1.  #include <iostream>
      2.  #include <vector>
      3.  #include <algorithm>
      4.  #include <iterator>
      5.  using namespace std;
      6.  
      7.  bool parzysta(int a) {
      8.      return (a&1) == 0;
      9.  }
     10.  
     11.  int main() {
     12.      vector<int> vec;
     13.      int d;
     14.      while ( cin >> d ) vec.push_back(d);
     15.      cin.clear();
     16.  
     17.      cout << "W ciagu liczb ";
     18.      copy(vec.begin(),vec.end(),
     19.           ostream_iterator<int>(cout, " "));
     20.      cout << "\njest "
     21.           << count_if(vec.begin(), vec.end(), parzysta)
     22.           << " liczb parzystych\n";
     23.  }

Wydruk tego programu:

    2 4 -2 7 5 4 31 4 5 -4
    ^D
    W ciagu liczb 2 4 -2 7 5 4 31 4 5 -4
    jest 6 liczb parzystych


Bardzo obszerna jest klasa algorytmów wyszukujących. Podstawowe funkcje (szablony) to findfind_if. Funkcja find poszukuje w kolekcji pierwszego elementu równego obiektowi przesłanemu jako argument, natomiast find_if poszukuje pierwszego elementu, dla którego spełniony jest pewien predykat, to znaczy pierwszego elementu kolekcji dla którego funkcja definiująca ten predykat zwróci true. Kolekcja, jak zwykle, zadana jest parą iteratorów. W razie sukcesu obie funkcje zwracają iterator do znalezionego elementu. Jeśli wyszukiwanie zakończyło się porażką, zwracany jest iterator end(). Przykład na obie te funkcje znajdziemy w następującym programie:


P213: szuk.cpp     Wyszukiwanie

      1.  #include <vector>
      2.  #include <iostream>
      3.  #include <string>
      4.  #include <cctype>    // tolower
      5.  #include <algorithm>
      6.  #include <iterator>
      7.  using namespace std;
      8.  
      9.  bool czy_na_A(string& s) {
     10.      return s[0] == 'A';
     11.  }
     12.  
     13.  void mala(string& imie) {
     14.      imie[0] = tolower(imie[0]);
     15.  }
     16.  
     17.  int main() {
     18.      vector<string> vs;
     19.  
     20.      vs.push_back("Magda");  vs.push_back("Anna");
     21.      vs.push_back("Monika"); vs.push_back("Agata");
     22.      vs.push_back("Ala");    vs.push_back("Urszula");
     23.  
     24.      vector<string>::iterator k;
     25.  
     26.      k = find(vs.begin(), vs.end(), "Anna");
     27.      if ( k != vs.end() )
     28.          cout << *k << " znaleziona\n";
     29.      else
     30.          cout << "Anna nie znaleziona\n";
     31.  
     32.      k = find(vs.begin(), vs.end(), "Basia");
     33.      if ( k != vs.end() )
     34.          cout << *k << " znaleziona\n";
     35.      else
     36.          cout << "Basia nie znaleziona\n";
     37.  
     38.  
     39.      cout << "\nZ imion\n";
     40.      copy(vs.begin(),vs.end(),
     41.           ostream_iterator<string>(cout," "));
     42.      cout << "\nnastepujace zaczynaja sie na \'A\':\n";
     43.      k = vs.begin();
     44.      while ( k < vs.end() ) {
     45.          k = find_if(k, vs.end(), czy_na_A);
     46.          if ( k != vs.end() ) cout << *k++ << " ";
     47.      }
     48.      cout << endl;
     49.  
     50.      for_each(vs.begin(),vs.end(),mala);
     51.      cout << "\nPo zamianie na male:\n";
     52.      copy(vs.begin(),vs.end(),
     53.           ostream_iterator<string>(cout," "));
     54.      cout << endl;
     55.  }

W liniach 26 i 32 użyta jest funkcja find, po czym sprawdzane jest, czy otrzymany iterator nie jest równy vs.end(), co oznaczałoby porażkę wyszukiwania.

Następnie z wektora imion wyszukiwane są w pętli (linie 44-47) imiona zaczynające się na literę 'A'. W tym celu korzystamy z funkcji find_if i predykatu czy_na_A (zdefiniowanego w liniach 9-11).

W linii 50 korzystamy z funkcji for_each. Jest to przykład algorytmu modyfikującego kolekcję, w odróżnieniu od algorytmów wyszukujących, które są algorytmami niemodyfikującymi. Działanie jego polega na wywołaniu dla każdego elementu kolekcji ze wskazanego przedziału pewnej określonej przez użytkownika funkcji (lub obiektu funkcyjnego). Argument przekazywany jest przez referencję, tak, aby możliwa była modyfikacja jego oryginału. Wartość zwracana funkcji, jeśli istnieje, jest ignorowana. W naszym przypadku funkcją tą jest funkcja mala, która w przekazanym przez referencję napisie zmienia pierwszą literę na małą. Wydruk programu jest następujący:

    Anna znaleziona
    Basia nie znaleziona

    Z imion
    Magda Anna Monika Agata Ala Urszula
    nastepujace zaczynaja sie na 'A':
    Anna Agata Ala

    Po zamianie na male:
    magda anna monika agata ala urszula


24.2.2 Obiekty funkcyjne

Jak widzieliśmy, ważną rolę odgrywają w algorytmach funkcje. W rzeczywistości nie muszą to być funkcje: wystarczy, że jest to „coś, co da się wywołać”. Jak pamiętamy z rozdziału o przeciążeniu operatora wywołania, jeśli w klasie przeciążony został operator wywołania, to nazwa obiektu tej klasy z podanymi argumentami w nawiasach powoduje wywołanie metody operator(). Obiekt staje się zatem obiektem wywoływalnym (ang. callable object). Takie właśnie obiekty mogą pełnić rolę obiektów funkcyjnych — można je wstawić tam, gdzie może wystąpić funkcja. Obiekty funkcyjne mają z punktu widzenia algorytmów tę przewagę nad funkcjami, że tworząc je można, na przykład poprzez konstruktor, przekazać do nich dodatkową informację prócz samych tylko argumentów wywołania, których liczba i typ są określone przez algorytm. Obiekty funkcyjne zapewniają zatem większą elastyczność algorytmów. Są też często efektywniejsze od zwykłych funkcji, bo metoda operator() może być rozwinięta, podczas gdy przekazywanie zwykłej funkcji przez wskaźnik nie pozwala kompilatorowi na jej rozwinięcie.

Przyjrzyjmy się następującemu programowi:


P214: compr.cpp     Obiekty funkcyjne

      1.  #include <iostream>
      2.  #include <cmath>      // sqrt
      3.  #include <vector>
      4.  #include <algorithm>  // sort, copy
      5.  #include <iterator>
      6.  using namespace std;
      7.  
      8.  struct Komp {
      9.     enum Sposoby {
     10.         wg_sumy_cyfr,
     11.         wg_ilosci_dzielnikow,
     12.         wg_wartosci,
     13.         wg_wart_odwrotnie
     14.     };
     15.  
     16.     Komp(Sposoby sposob): sposob(sposob) { }
     17.  
     18.     int operator()(int n1, int n2);
     19.  
     20.     class BrakKomparatora { };
     21.  
     22.  private:
     23.     Sposoby sposob;
     24.  
     25.     static int suma_cyfr(int n);
     26.     static int ilosc_dzi(int n);
     27.  };
     28.  
     29.  int Komp::operator()(int n1, int n2) {
     30.     switch (sposob) {
     31.         case wg_sumy_cyfr:
     32.             return suma_cyfr(n1) < suma_cyfr(n2);
     33.         case wg_ilosci_dzielnikow:
     34.             return ilosc_dzi(n1) < ilosc_dzi(n2);
     35.         case wg_wartosci:
     36.             return n1 < n2;
     37.         case wg_wart_odwrotnie:
     38.             return n2 < n1;
     39.         default:
     40.             throw BrakKomparatora();
     41.                          // nigdy się nie zdarzy
     42.     }
     43.  }
     44.  
     45.  int Komp::suma_cyfr(int n) {
     46.     // suma cyfr liczby całkowitej n (licząc
     47.     // wszystkie  cyfry ze znakiem  dodatnim)
     48.     n = n >= 0 ? n : -n;
     49.     int s = 0;
     50.     while (n) { s += n%10; n /= 10; }
     51.     return s;
     52.  }
     53.  
     54.  int Komp::ilosc_dzi(int n) {
     55.     // ilość dodatnich dzielnikow liczby całkowitej
     56.     // n (wliczając jedynkę i samą liczbę n)
     57.     n = n > 0 ? n : -n;
     58.     if ( n < 3 ) return n;
     59.     int sr = (int) sqrt(n+0.5);
     60.     int ilosc = (sr*sr == n ? 1 : 2);
     61.     for (int i = 2; i <= sr; ++i) if (n%i == 0) ilosc += 2;
     62.     return ilosc;
     63.  }
     64.  
     65.  int main() {
     66.     int tab[] = {7, 4, 8, 12, 13, 119, 16, 6};
     67.     vector<int> v(tab,tab+sizeof(tab)/sizeof(int));
     68.     v.push_back(64);
     69.  
     70.      // sortujemy różnymi sposobami i wyświetlamy wyniki
     71.  
     72.     cout <<   "Sortujemy wg. sumy cyfr\n";
     73.     sort(v.begin(),v.end(),Komp(Komp::wg_sumy_cyfr));
     74.     copy(v.begin(),v.end(),ostream_iterator<int>(cout," "));
     75.  
     76.     cout << "\nSortujemy wg. ilosci dzielnikow\n";
     77.     sort(v.begin(),v.end(),Komp(Komp::wg_ilosci_dzielnikow));
     78.     copy(v.begin(),v.end(),ostream_iterator<int>(cout," "));
     79.  
     80.  
     81.     cout << "\nSortujemy wg. wartosci\n";
     82.     sort(v.begin(),v.end(),Komp(Komp::wg_wartosci));
     83.     copy(v.begin(),v.end(),ostream_iterator<int>(cout," "));
     84.  
     85.     cout << "\nSortujemy wg. wartosci odwrotnie\n";
     86.     sort(v.begin(),v.end(),Komp(Komp::wg_wart_odwrotnie));
     87.     copy(v.begin(),v.end(),ostream_iterator<int>(cout," "));
     88.  
     89.     cout << endl;
     90.  }

Program sortuje wektor na wiele różnych sposobów. Komparator jest tym razem nie funkcją, ale obiektem klasy Komp. W klasie tej przeciążony jest operator operator() (linie 18 i 29-43). Jedyny konstruktor klasy wymaga argumentu typu wyliczeniowego, który określa następnie sposób sortowania. Odpowiedni element wyliczenia jest zapamiętywany w składowej sposob obiektu.

W funkcji main wywołujemy sort i jako wartość trzeciego argumentu podajemy utworzony „na miejscu” anonimowy obiekt klasy Komp. W samym obiekcie zapamiętane jest zatem kryterium sortowania, które określiliśmy argumentem przesłanym do konstruktora. W trakcie sortowania obiekt będzie wielokrotnie wywoływany przez algorytm z elementami kolekcji jako argumentami: dzięki temu jednak, że jest to obiekt, może przechowywać dodatkową informację w swoich składowych. W naszym przypadku jest to składowa sposob, dzięki której przy każdym wywołaniu wybrana zostaje odpowiednia gałąź instrukcji switch (linia 30). Wydruk z programu:

    Sortujemy wg. sumy cyfr
    12 4 13 6 7 16 8 64 119
    Sortujemy wg. ilosci dzielnikow
    13 7 4 6 8 119 16 12 64
    Sortujemy wg. wartosci
    4 6 7 8 12 13 16 64 119
    Sortujemy wg. wartosci odwrotnie
    119 64 16 13 12 8 7 6 4


Obiekty funkcyjne stosuje się również do tworzenia manipulatorów. W rozdziale o manipulatorach mówiliśmy o manipulatorach z argumentami: jest szereg takich manipulatorów zdefiniowanych w bibliotece dołączanej poprzez nagłówek iomanip, ale jak tworzyć własne takie manipulatory? Przyjrzyjmy się przykładowi:


P215: maniparg.cpp     Manipulatory jako obiekty funkcyjne

      1.  #include <iostream>
      2.  #include <string>
      3.  using namespace std;
      4.  
      5.  struct maniparg {
      6.      string str;
      7.      maniparg(int ile, char c) : str(ile,c) { }
      8.      ostream& operator()(ostream& s) const {
      9.          return s << str;
     10.      }
     11.  };
     12.  
     13.  ostream& operator<<(ostream& s, const maniparg& manip) {
     14.      return manip(s);
     15.  }
     16.  
     17.  int main() {
     18.      cout << maniparg(7,'*') << "To jest maniparg"
     19.           << maniparg(3,'!') << maniparg(7,'*') << endl;
     20.  
     21.      maniparg trzywykrzykniki(3,'!');
     22.      maniparg siedemgwiazdek (7,'*');
     23.  
     24.      cout << siedemgwiazdek  <<  "To jest maniparg"
     25.           << trzywykrzykniki << siedemgwiazdek  << endl;
     26.  }

Tworzymy klasę maniparg i dla niej przeciążamy w zwykły sposób operator wstawiania do strumienia '<<' (linie 13-15). Funkcja przeciążająca zadziała, według normalnych zasad, gdy po prawej stronie operatora '<<' pojawi się konkretny obiekt klasy maniparg. Przesłanymi argumentami będą strumień i sam obiekt. Z kolei funkcja przeciążająca operator '<<' wywołuje przesłany obiekt (linia 14), czyli funkcję operator() z klasy maniparg (zdefiniowaną w liniach 8-10). Funkcja ta została skonstruowana w ten sposób, że otrzymuje poprzez argument obiekt strumienia i wypisuje do niego napis skonstruowany z  ile znaków c (przesłanych do konstruktora obiektu). Zwraca referencję do tegoż strumienia, tak że i funkcja przeciążająca operator '<<' również zwraca ten strumień, tak jak to jest wymagane. Po co ta konstrukcja?

Otóż tworząc obiekt klasy maniparg możemy przekazać do niego poprzez konstruktor i zapamiętać w jego składowych dowolną ilość informacji. W naszym przypadku przekazujemy tam znak i liczbę całkowitą określającą, ile razy ten znak ma być wstawiony do strumienia. Na podstawie tej informacji tworzona jest składowa str, czyli napis zawierający znak powtórzony odpowiednią liczbę razy. Z tej składowej korzysta metoda operator() wywoływana z funkcji przeciążającej operator '<<'. Widzimy to w linii 18, w której tworzymy obiekt klasy maniparg przekazując poprzez argumenty konstruktora znak gwiazdki i liczbę 7. Ponieważ obiekt klasy maniparg pojawił się po prawej stronie operatora '<<', wywoływana jest funkcja zdefiniowana w liniach 13-15 i przekazywany jest do niej ten obiekt. Jest on wywoływany, przekazując z kolei strumień poprzez argument. Metoda operator() ma zatem komplet informacji: strumień i dane ze składowej obiektu, które do tego strumienia mają być wpisane. Program drukuje

    *******To jest maniparg!!!*******
    *******To jest maniparg!!!*******
Zauważmy, że równie dobrze możemy utworzyć zawczasu obiekty klasy maniparg nadając im nazwy, a potem używać ich wielokrotnie, jak robimy to z obiektami trzywykrzyknikisiedemgwiazdek w liniach 24-25.

T.R. Werner, 23 lutego 2022; 19:40