MPU: uogólniony begin i end

tech • 877 słów • 5 minut czytania

Ta notatka została oznaczona jako wymagająca dopracowania: mal-repo-code.
Zawartość wpisu może ulec zmianie, zatem zapraszam do ponownych odwiedzin w niedalekiej przyszłości :)

Według obietnicy z pierwszej notki o MPU chciałbym przedstawić najczęściej używany element, który ułatwia wykorzystywanie standardowych algorytmów operujących na zakresach dla danych przechowywanych w zwykłych tablicach.

Oczywiście nie ma żadnego problemu (pomijając wszelkie biblioteki), bo do tej pory tablice w łatwy sposób mogły być używane jako zakresy w dowolnej funkcji algorytmu z STL-a. Przeważnie robiło się to w prosty sposób, podając wskaźnik na początkowy i ostatni + 1 element tablicy. Tak, jak na poniższym przykładzie wypisującym zawartość tablicy:

int tab[5] = { 1, 2, 3, 4, 5 };

int* tab_begin = tab;
int* tab_end = tab + sizeof(tab)/sizeof(tab[0]);

std::copy(tab_begin, tab_end, std::ostream_iterator<int>(std::cout, " "));

Wszystko fajnie, ale przy statycznych tablicach w oczy rzuca się ten paskudny “trick” z sizeof do pobierania ilości elementów w tablicy, który można bardziej elegancko i cpplusowo zastąpić konstrukcją templejtową, chociażby tą z MPU:

template<typename T, size_t N>
inline size_t Count(const T (&)[N]) {
	return N;
}

O dziwo, o takiej konstrukcji pisałem już dawno w notce Algorytmy STL na tablicach, gdzie również poruszyłem problem i przedstawiłem rozwiązanie jakie tutaj chcę zaprezentować. Czyżby czasem tamta konstrukcja nie wpadła automatycznie do worka MPU? ;)

Wspomniana wyżej notatka zawiera poniekąd całą esencję jaka miała zostać przekazana w niniejszym wpisie.

Jednym z pierwszych elementów MPU był właśnie przedstawiony Counter i funkcje wyznaczające zakres z tablic. Funkcje te wraz z wersjami dla tablic i zwykłych kontenerów pozwalały w prosty sposób traktować tablice jako pełnoprawny zakres, a także bezproblemowo wymieniać w implementacji użycie wektora na tablice i odwrotnie - o ile oczywiście korzystano z tych akcesoriów.

Obecnie standardowa biblioteka języka C++ zawiera takowe konstrukcje: std::begin i std::end. Implementacje zawarte w STL dokładnie odzwierciedlają przedstawione wyżej wersje (tablicowe). Wersje dla standardowych kontenerów wprawdzie także, bo odkąd w języku istnieje operator decltype oraz auto, owe implementacje wyglądają bardzo prosto - są tylko prostym proxy/przekierowaniem na wywołanie odpowiedniej funkcji kontenera:

template<typename C>
inline auto begin(C& c) -> decltype(c.begin()) {
	return c.begin();
}

template<typename C>
inline auto end(C& c) -> decltype(c.end()) {
	return c.end();
}

Mając dostępne takie użyteczne narzędzia, można potraktować tablicę jak dowolnie inny kontener z biblioteki standardowej i ten początkowy kod zapisać w dużo prostszej i standardowej postaci:

int tab[5] = { 1, 2, 3, 4, 5 };

std::copy(std::begin(tab), std::end(tab), std::ostream_iterator<int>(std::cout, " "));

Wersje tych narzędzi dla starego C++ (C++98) są nieco bardziej rozbudowane, niż te obecnie zawarte w STL-u. W MPU zastosowano kilka rozwiązań, a w zasadzie dwa - jedno proste, drugie bardziej skomplikowane.

W pierwszym uznano, że użytkownicy są mądrzy i wiedzą jak używać dane im narzędzia, zatem dostępne są tylko dwie implementacje - jedna dla tablic jak wyżej, druga, będąca przeciążaniem dla innych typów - w zamyśle dla kontenerów:

template<typename C>
inline typename C::iterator begin(C& c) {
	return c.begin();
}

template<typename C>
inline typename C::iterator end(C& c) {
	return c.end();
}

Jak można wywnioskować z implementacji, typ kontenera C musi spełniać pewne założenia z container concept STL-a - posiadać metody begin(), end() oraz współtowarzyszące typy iteratora.

Można nieco zmodyfikować i dostarczy częściowe specjalizacje dla wszystkich typów kontenera z STL-a, ale zaciemniłoby to kod, a tak naprawdę chyba raczej nic nie wzniosłoby do projektu, prócz większej kontroli nad typami wejściowymi.

Wersja dla tablic oraz ta przedstawiona wyżej w zasadzie wystarczą, działają poprawnie dopóki programista jako parametr przekaże poprawny typ kontenera lub tablicę. W innych przypadkach wystąpi błąd kompilacji - idealnie, po co dla błędnego użycia ma się nie wywalać kompilacja? Tak, więc, czego można chcieć więcej?

Drugim, bardziej skomplikowanym (na pierwszy rzut) rozwiązaniem może być skorzystanie z metaprogramowania i boostowego MPL oraz klas cech:

template <typename T>
inline T* begin(T& t, typename boost::enable_if<boost::is_array<T> >::type* dummy = 0) {
	...
}

template <typename T>
inline typename T::iterator begin(T& t, typename boost::enable_if<mpu::std::is_container<T> >::type* dummy = 0) {
	...
}

O podobnych konstrukcjach będę pewnie opowiadał kiedyś indziej, może w jednej z następnych notek poświęconych MPU lub metaprogramowaniu.

Można zastanawiać się po co w ogóle “bawić się” w takie konstrukcje. Głównym powodem jest chęć posiadania możliwości wykorzystania nowych elementów języka oraz algorytmów uogólnionych operujących na zakresach i iteratorach. A niestety nie można założyć, że wszelkie kontenery zdefiniowane przez użytkowników zawsze będą posiadać metody begin i end. Z takiego założenia wyszli twórcy nowego standardu, aktualna wersja definiuje std::begin i std::end.

Wydaje mi się, że jest to bliższe “zacieśnianie więzów” między kontenerami i zakresami dostępnymi w bibliotece STL, a tymi definiowanymi przez użytkownika, czy zwykłymi tablicami lub listami inicjalizującymi. Przecież obecnie pętla for operująca na zakresach nie ma problemów z działaniem na standardowym kontenerze, czy tablicy:

int tab[5] = { 1, 2, 3, 4, 5 };

for (const int i : tab) {
	std::cout << i;
}

To dlaczego podobnie (prosto) nie można byłoby korzystać z gamy dostępnych algorytmów uogólnionych?

Innym nasuwającym się pytaniem, może być “kiedy używać metody, a kiedy funkcji begin/end?” Według mnie, zawsze używać wersji funkcyjnej, bez problemu będziemy w stanie zmienić typ kontenera, nawet na taki który jest zwykłą tablicą, bądź nie spełniającym konceptu STL-a pojemnikiem (wystarczy wtedy przeciążyć bądź wyspecjalizować funkcje begin i end dla danego typu) bez żadnych kłopotów. Promują one jednolitość i spójność oraz pozwalają na bardziej generyczne programowanie, z czym się zgadza Herb Sutter w Elements of Modern C++ Style.

Uwaga! W niniejszej notatce przedstawiono tylko fragmenty kodu konstrukcji przyjmujących parametry jako referencje lub const-referencje, w real-world należy zadbać o obie wersje, aby się dopełniały.

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/