Lokalne klasy w roli funktorów i funkcji lambda w C++03

tech • 1079 słów • 6 minut czytania

Natrafiłem na jakiś stary, sprzed ponad 5 lat, kod mojego autorstwa, gdzie w projekcie z różnych powodów używaliśmy starego środowiska, kompilatora lub języka w standardzie C++03. Były to już czasy grubo po premierze C++11 no i jak to bywa, człowiek zachłyśnięty nowymi elementami języka, strasznie usprawniającymi pracę, musiał sobie jakoś radzić w szarej rzeczywistości. Dla mnie takim największym problemem był brak anonimowych funkcji i wpadłem wtedy na genialną pseudo-protezę funkcji lambda, aby tylko nie trzeba było definiować zewnętrznych funktorów ;)

Idealnie byłoby móc tworzyć funkcje lub obiekty funkcyjne w ciele innych funkcji blisko miejsca użycia. Niestety to nie było wtedy możliwe, ale o dziwo nie było żadnego problemu z definicjami klas wewnątrz funkcji!

Takie klasy, zwane klasami lokalnymi, są chyba niezbyt popularną konstrukcją języka C++, pomimo tego, że od zawsze w nim istnieją (od czasów “C z klasami”)1, poniżej fragment rozdziału [class.local] standardu C++982:

A class can be defined within a function definition; such a class is called a local class. The name of a local class is local to its enclosing scope. The local class is in the scope of the enclosing scope, and has the same access to names outside the function as does the enclosing function. Declarations in a local class can use only type names, static variables, extern variables and functions, and enumerators from the enclosing scope. – C++98 9.8/1 ([class.local])

Treść tego rozdziału zbytnio się nie zmieniła w kolejnych wydaniach standardu C++ (aktualna (C++20) treść tego rozdziału [class.local]), prócz zmian kosmetycznych i dostosowań do pozostałych elementów języka. Ale pewne ograniczenia, opisane w innych miejscach dokumentu, zniesiono, o czym później.

Taką lokalną klasę, przy zdefiniowaniu statycznych metod, można potraktować jako lokalny namespace:

void sort(std::vector<int>& vec) {

	struct utils
	{
		static bool cmp(int left, int right) {
			return left % 2 < right % 2;
		}
	};

	std::sort(vec.begin(), vec.end(), utils::cmp);
}

Co w istocie pozwala na zdefiniowanie funkcji w funkcji, czyli lambda!

Taki kod jest funkcjonalnie różnorzędnym odpowiednikiem użycia lambda w takiej formie:

void sort(std::vector<int>& vec) {

	auto cmp = [](int left, int right) {
		return left % 2 < right % 2;
	};

	std::sort(vec.begin(), vec.end(), cmp);
}

Oczywiście równie dobrze można byłoby zdefiniować to po staremu - jako osobną “lokalną” funkcję:

namespace {
namespace utils {

	bool cmp(int n) {
		return left % 2 < right % 2;
	}

} // utils
} // anonymous

inline void sort(std::vector<int>& vec) {
	std::sort(vec.begin(), vec.end(), utils::cmp);
}

Ale z tym wyjątkiem, że widoczność utils::cmp byłaby rozciągnięta na cały translate-unit, w przeciwieństwie do klasy lokalnej, której widoczność ograniczona jest do ciała funkcji w której została zdefiniowana.

Przy wykorzystaniu klas lokalnych w roli “kontenerów” funkcji z algorytmami uogólnionymi STL-a trzeba pamiętać o deklaracji takich metod jako statyczne, aby były traktowane jak “zwykłe” funkcje z łączeniem zewnętrznym (external linkage). Normalnie metody w klasach lokalnych nie mają powiązań ([class.mfct]) i takich funkcji, ani w ogóle klas lokalnych, nie użyjemy z szablonowymi algorytmami:

A local type, a type with no linkage, an unnamed type or a type compounded from any of these types shall not be used as a template-argument for a template type-parameter. – C++98 14.3.1/2 ([temp.arg.type])

Z tego też powodu, poniższy kod, wydający się atrakcyjnym wykorzystaniem klas lokalnych, nie zostanie skompilowany:

int sum(const std::vector<int>& vec) {

	struct SumSqr {

		int sum;

		Functor() : sum(0) {}

		void operator()(int n) {
			sum += n * n;
		}
	};

	return std::for_each(vec.begin(), vec.end(), SumSqr()).sum;
}

Restrykcje te zostały usunięte w C++11 (N2657) i takie “lokalne funktory” są już jak najbardziej dozwolone. Niemniej, wtedy są już też dostępne funkcje lambda i w wielu wypadkach lepiej się sprawdzą od dedykowanych funktorów ;)

Te i inne ograniczenia narzucone klasom lokalnym mogą powodować pewne trudności, gdybyśmy chcieli użyć statycznych metod takich klas w roli funktorów przechowujących stan. W takiej lokalnej klasie nie ma dostępu do lokalnych (niestatycznych) zmiennych funkcji. To jednak można jakoś obejść chociażby adapterami czy bindowaniem ;)

int sum(const std::vector<int>& vec) {

	struct func {
		static void SumSqr(int n, int& sum) {
			sum += n * n;
		}
	};

	int sum = 0;
	std::for_each(vec.begin(), vec.end(), boost::bind(&func::SumSqr, _1, boost::ref(sum)));
	return sum;
}

Co funkcjonalnie przekłada się na taki zapis z użyciem funkcji lambda:

int sum(const std::vector<int>& vec) {

	int sum = 0;

	auto SumSqr = [&sum](int n) {
		sum += n * n;
	};

	std::for_each(vec.begin(), vec.end(), SumSqr);
	return sum;
}

Dobry kompilator powinien obie funkcje skompilować do tego samego kodu wynikowego. Przykładowe wyjście z gcc:

; GCC 11.1 -O2 -m32 -mno-sse
; sum(std::vector<int, std::allocator<int>> const&):
	push ebx
	mov eax, DWORD PTR [esp+8]	; vec
	xor ecx, ecx				; sum = 0
	mov ebx, DWORD PTR [eax+4]	; end = vec.end()
	mov edx, DWORD PTR [eax]	; itr = vec.begin()
	cmp edx, ebx				; itr == end?
	je .L1						; jmp if yes / empty
.L2:
	mov eax, DWORD PTR [edx]	; n = *itr
	add edx, 4					; ++itr
	imul eax, eax				; n *= n
	add ecx, eax				; sum += n
	cmp edx, ebx				; itr == end ?
	jne .L2						; jmp if not / loop
.L1:
	mov eax, ecx				; ret sum
	pop ebx
	ret

Co jest równoważne ręcznie “naklepanej” pętli i sumowaniu kwadratu w kolejnych iteracjach po elementach vectora.

Przy braku dostępu do funkcji lambda, takie hacki z lokalnymi klasami były dla mnie wybawieniem, bo w jakiś sposób omijały problem definiowania funkcji w funkcji. W wielu miejscach pomagały w utrzymaniu zwartego i znacznie czytelniejszego kodu niż zastosowanie innych dostępnych wówczas elementów i konstrukcji języka.

W zamierzchłych czasach, chcąc składać “na miejscu” funktory z dobrodziejstw biblioteki standardowej, do wyboru były dosyć koszmarne3 adaptery i obiekty funkcyjne, albo boost-owy bind4. Bind wprowadził zupełnie nową jakość, szczególnie przy definiowaniu orzeczników, nad czym sam się zachwycałem kilkanaście lat temu, podobnie było z lambdą w ówczesnym C++0x. Niezalenie jednak od sposobów tworzenia funktorów, takie kilku-linijkowce posklejane w miejscu wywołania potrafiły czasem trochę zaciemnić kod. Pozostawały wtedy definicje zewnętrznych funkcji i funktorów, ale te oddalone od miejsca użycia również komplikowały czytelność kodu przy szybkim czytaniu i analizie…


Przypisy

  1. Zapewne od momentu jego powstania, definicje klas lokalnych znalazłem w raporcie języka C++ z 1991 roku dołączonego do książki Stroustrupa “Język C++” (WNT 1997), który stał się podstawowym dokumentem wykorzystywanym podczas standaryzacji ANSI i ISO. ↩︎

  2. Z braku dostępnej w przyjaznej formie “online” treści standardu C++98, zawarte w tekście fragmenty i odwołania odnoszą się bezpośrednio do treści dokumentu “ISO/IEC 14882:1998 - Programming languages — C++”. ↩︎

  3. Chyba wcale nie były takie koszmarne, a przynajmniej nie w tamtych odległych czasach, bo dziś to już inna bajka ;) ↩︎

  4. Bo do standardu Boost.Bind przeszedł dopiero w C++11. ↩︎

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/