Blokowanie niejawnej konwersji w C++

tech • 1760 słów • 9 minut czytania

W ostatnim wpisie napomknąłem coś o dawnych problemach z konstruktorami konwersji odnosząc się do mojego starego wpisu o niejawnych przekształceniach typów. Po przypomnieniu sobie jego treści postanowiłem podzielić się kilkoma, pewnie ogólnie znanymi, sztuczkami kontrolowania i blokowania niejawnych konwersji nie tylko przy typach zdefiniowanych przez użytkownika, ale też te standardowe konwersje zachodzące mimowolnie pod maską języka C++ ;)

Wykluczenie metody z niejawnej konwersji (explicit)

We wspomniane notce do zapobiegania niejawnych konwersji do typu użytkownika użyto specyfikatora explicit w towarzystwie konstruktora konwersji. Niestety wtedy nie było łatwej sposobności wpływania na konwersje od typu użytkownika, niż poprzez definicje dedykowanych metod. Od C++11 explicit może być także używany z operatorem konwersji. Ma ono takie samo zastosowanie - wykluczenie metody z użycia w niejawnym przekształcaniu typu.

struct X {
	explicit X(int);
	explicit operator int();
};

X x1 = 123;			// error: no implicit conversion via copy-initialization
X x2 = {123};		// error: no implicit conversion via copy-initialization
X x3(123);			// ok: direct-initialization
X x4 = X(123);		// ok: direct-initialization
X x5 = (X)123;		// ok: direct-initialization via explicit cast

int i1 = x5;		// error: no implicit conversion via copy-initialization
int i2 = (int)x5;	// ok: direct-initialization via explicit cast
int i3{x5};			// ok: direct-initialization
bool b = int(x5);	// ok: direct-initialization via explicit cast to int and implicit cast to bool

Jak widać powyżej, takie “wykluczone” metody mogą uczestniczyć tylko w bezpośredniej inicjalizacji:

An explicit constructor constructs objects just like non-explicit constructors, but does so only where the direct-initialization syntax or where casts are explicitly used; – [class.conv.ctor]

A conversion function may be explicit, in which case it is only considered as a user-defined conversion for direct-initialization. – [class.conv.fct]

Oczywiście takie jawne konwersje nie zapobiegają różnym niejawnym konwersjom standardowym. I tak oto kod poniżej może sugerować, że konwersja X do double będzie wymagała zawsze jawnego użycia:

struct X {
	X() {}
	operator int();
	explicit operator double();
};

X x;
int i = x;				// implicit cast from X::int()
double d1 = x;			// implicit cast from X::int() -> double
double d2 = double(x);	// explicit cast from X::double()

W istocie czasami kompilator może zachować się nieintuicyjnie i wykorzystać standardową konwersję pomiędzy dostępnymi typami. Tak, jak ma to miejsce powyżej, gdzie wyłączenie niejawnej konwersji X do double nie przeszkadza kompilatorowi wykorzystać dostępną konwersję X do int a następnie taką wartością zainicjalizować d1, zamiast “wywalić” info o braku możliwości niejawnej konwersji. To łatwo może doprowadzić do dziwnych błędów i wartości…

Niestety nie ma opcji zapobieżenia takim rzutowaniom przy mieszaniu implicit i explicit. Użycie jednego sposobu definiowania i przeciążanie wraz z blokowaniem/wyłączaniem wybranych deklaracji wydaje się tutaj jedyną możliwością.

Wyłączenie funkcji z użycia (delete)

Wraz z C++11 pojawiała się w języku możliwość oznaczenia funkcji jako usunięta (deleted functions), a mówiąc ściślej “wyłączenia funkcji z użycia”. Do tego celu rozszerzono możliwości słowa kluczowego delete. Usunięcie następuje przez dodanie specyfikatora = delete w deklaracji funkcji. Jakiekolwiek użycie takiej funkcji spowoduje błąd kompilacji:

A program that refers to a deleted function implicitly or explicitly, other than to declare it, is ill-formed. – [dcl.fct.def.delete]

Sztandarowym przykładem wykorzystania nowej funkcjonalności jest uniemożliwienie kopiowania obiektu poprzez “usunięcie” konstruktora kopiującego i operatora przypisania, zamiast jak dotychczas ich “sprywatyzowania”:

class NonCopyable {
public:
	NonCopyable(const NonCopyable&) = delete;
	NonCopyable& operator=(const NonCopyable&) = delete;
}

Mechanizm wyłączenie funkcji z użycia nie ogranicza się tylko do metod specjalnych (składowych klas), ale każdą funkcję można zadeklarować jako usunięta. Dzięki czemu staje się to idealnym sposobem do blokowania niejawnych konwersji, szczególnie tych standardowych pomiędzy typami wbudowanymi!

Dla przykładu, mając prostą funkcję walidującą wartość numeryczną typu integer:

void checkInt(int value);

Dzięki niejawnym konwersjom poniższe wywołania funkcji są poprawne i zostaną skompilowane bez zająknięcia:

checkInt(1);
checkInt('a');
checkInt(true);
checkInt(3.14);

Niektóre z nich mogą wydawać się bezsensowne w tej sytuacji i fajnie byłoby mieć możliwość ich zablokowania na etapie kompilacji, aby wyłapać potencjalne błędy. Można to zrobić deklarując wybrane przeciążenia jako usunięte, co przy próbie ich wywołania skutkuje zgłoszeniem przez kompilator błędu: “próba odwołania do usuniętej funkcji”.

void checkInt(char) = delete;
void checkInt(bool) = delete;
void checkInt(double) = delete;

checkInt(1);		// ok
checkInt('a');		// error: attempting to reference a deleted function
checkInt(true);		// error: ...
checkInt(3.14);		// error: ...

Powyższy kod działa według zamierzeń, bo zgodnie ze standardem najpierw następuje rozwiązanie przeciążenia (wybranie odpowiedniej wersji dla danego typu) a dopiero później próba odwołania się do takiej funkcji:

If a function is overloaded, it is referenced only if the function is selected by overload resolution. – [dcl.fct.def.delete]

A gdy zostanie wybrana funkcja usunięta to program zostanie uznany za ill-formed i się nie skompiluje.

Powyższy kod można też znacznie uprościć do deklaracji uogólnionej w takiej formie:

template <typename T>
void checkInt(T) = delete;

Będzie to skutkowało odrzucaniem każdego możliwego przeładowania/typu oprócz tych zdefiniowanych jawnie.

Technika usuwania przeciążeń może być wykorzystywana też przy klasach - konstruktorach i operatorach konwersji oraz zwykłych metodach, umożliwiając kontrolę nad niejawnymi przekształceniami typów.

Powyżej głównie używany był kod z przeciążeniami funkcji, poniżej wersja ze specjalizacjami szablonowych funkcji1:

struct X {
	X();

	template<typename T>
	operator T() = delete;
};

template<>
X::operator int();

X x;
int i = x;		// ok
char c = x;		// deleted
double d = x;	// deleted

W normalnych sytuacjach, przeciążania i specjalizacje pozwalają implementować odmienne zachowanie algorytmu zależne od typu argumentów. A tutaj pozwalają na sterowanie i kontrolowanie standardowej konwersji zależnie od potrzeb.

Dedykowane specjalizacje szablonów funkcji

Dedukcja typów szablonu z wywołania funkcji ma ciekawą właściwość - typy są dopasowywane tak, aby były identyczne:

In general, the deduction process attempts to find template argument values that will make the deduced A identical to A (after the type A is transformed as described above). – [temp.deduct.call]

Wydedukowany typ w wywołaniu funkcji będzie ściśle odpowiadał typowi argumentu przekazanego do funkcji:

template<typename T>
void f(T) {}

f(123);			// call f<int>
f(3.14);		// call f<double>
f(true);		// call f<bool>
f('a');			// call f<char>
f("hello");		// call f<const char*>

Tę właściwość w połączeniu ze specjalizacjami można wykorzystać do blokowania niejawnych konwersji:

template <typename T>
void checkInt(T) {
	static_assert(0, "attempt to use a forbidden type");
}

template<>
void checkInt<int>(int value);

template<>
void checkInt<unsigned int>(unsigned int value);

Wymaga to zdefiniowania specjalizacji dla wszystkich typów jakie chcemy funkcja checkInt powinna obsłużyć.

W niektórych przypadkach może to prowadzić do potrzeby zapisania dużej ilości kodu ze specjalizacjami. Wtedy można zastanowić się nad bardziej generycznym zapisie z wykorzystaniem mechanizmu SFINAE.

SFINAE

Do wyłączenia niektórych funkcji bazując na określonych warunkach można wykorzystać mechanizm SFINAE.

Trzeba pamiętać, że w C++ niepowodzenie podstawienia nie jest błędem. Kompilator w trakcie dedukcji typów dla szablonów, dla których nie udaje się podstawić określonego typu zignoruje takie definicje nie powodując błędu kompilacji:

If a substitution results in an invalid type or expression, type deduction fails. An invalid type or expression is one that would be ill-formed, with a diagnostic required, if written using the substituted arguments. Only invalid types and expressions in the immediate context of the function type, its template parameter types, and its explicit-specifier can result in a deduction failure. – [temp.deduct]

Odkąd w standardzie pojawił się std::enable_if, użycie tego mechanizmu jest znacznie prostsze.

Podobnie wykorzystanie standardowych traits-ów ułatwia sprawę. Niestety nie ma żadnego spełniającego moje oczekiwania - określającego typ integer, czyli liczbę całkowitą - wszelakie (shorty, inty, longi, …). Ale to można łatwo obejść tworząc własną klasę cech lub jeszcze bardziej upraszczając - stałą szablonową zmienną bool w taki sposób:

template <typename T>
constexpr bool is_integer_v = std::is_integral_v<T> 
	&& !std::same_as<T, bool>
	&& !std::same_as<T, char>;

To tylko przykład, niestety std::is_integral, std::is_integer i inne “łapią” również wszystkie char-y, których jest prawie tyle samo co int-ów, więc chyba prościej jest porostu zdefiniować potrzebne typy:

template <typename T>
constexpr bool is_integer_v =
	std::is_same_v<T, short> || std::is_same_v<T, unsigned short> ||
	std::is_same_v<T, int>   || std::is_same_v<T, unsigned int>   ||
	std::is_same_v<T, long>  || std::is_same_v<T, unsigned long>  ||
	std::is_same_v<T, long long> || std::is_same_v<T, unsigned long long>;

Taki “traits” można wykorzystać do “włączania” funkcji checkInt za pomocą parametru szablonu:

template <typename T, typename = std::enable_if_t<is_integer_v<T>>>
void checkInt(T value);

Poniższy kod jest równoważny w działaniu poprzednim implementacjom:

checkInt(1);		// ok
checkInt('a');		// error: no matching overloaded function found
checkInt(true);		// error: ...
checkInt(3.14);		// error: ...

Wykorzystując obecne narzędzia z biblioteki standardowej zbyt dużo się tutaj nie musiałem napisać, choć jeszcze nie tak dawno zabawa z SFINAE wymagała “klepania” znacznie większej ilości kodu.

Koncepty z C++20

Przedstawiony wyżej kod z użyciem SFINAE można jeszcze bardziej uprościć wykorzystując wprowadzone w C++20 koncepty. Idea i działanie jest takie samo, ale w dużo wyrazisty i czytelniejszy sposób daje się wyrazić intencje programisty:

template <typename T>
concept Integer = std::integral<T>
	&& !std::same_as<T, bool>
	&& !std::same_as<T, char>;

auto checkInt(Integer auto value);

Wynik działania będzie podobny do tych wcześniejszych implementacji:

checkInt(1);		// ok
checkInt('a');		// error: use of function with unsatisfied constraints
checkInt(true);		// error: ...
checkInt(3.14);		// error: ...

W tym przykładzie koncept służy do ograniczenia zestawu argumentów akceptowanych przez szablon.

Podsumowanie

Zdziwić się można na ile obecnie pozwala jeżyk C++ w poprawianiu samego siebie w niewygodnych w danym zastosowaniu jego elementów, czym czasem mogą być właśnie niejawne konwersje. w metodach i sposobach blokowania takich konwersji swój znaczący udział mają różne sposoby służące do “wyłączania” przeciążeń i funkcji z użycia.

Najczęściej używane jest przeciążane i/lub szablonowe funkcje z wybranymi specjalizacjami, bądź też mieszanka obu tych technik. W obu przypadkach, w luźnym tłumaczeniu, kompilator stara się wybrać najlepiej pasującą funkcję na podstawie przekazywanych w wywołaniu argumentów. W rzeczywistości jest to nieco bardziej skomplikowane.

Te dwie techniki były chyba najczęściej używane w czasach przed C++11, podobnie jak SFINAE.

Często też wyłączanie funkcji z użycia można było “emulować” dodawaniem samych deklaracji funkcji do kodu, co przy ich użyciu w kodzie też skutkowało przerwaniem budowania programu, ale dopiero na etapie linkowania. Można też było dodać ówczesny ekwiwalent statycznego asserta do takich definicji nieco poprawiając czytelność komunikatów błędu.

SFINAE w tamtym czasie wymagało naprodukowania znacznie więcej kodu niż obecnie, bo nie było w standardzie choćby std::enable_if ani bogatej biblioteki z klasami cech. Pozostawał Boost i własne implementacje! Tworzenie bardziej rozbudowanych traitsów nie było trudne tylko trochę upierdliwe. Co można zobaczyć w mojej starej notce o MPU i klasach cech kontenerów STL, gdzie coś takiego implementowałem i widać ile to pracy, a raczej kodu wymagało ;)

Obecnie, wersje z wyłączaniem przez delete i użycie konceptów wydają się najbardziej atrakcyjne i czytelne.

Oczywiście przedstawione sposoby nie są jedynymi metodami wpływu na niejawne konwersje między typami. Pewnie dałoby się też tutaj zastosować inne techniki i narzędzia, jak chociażby tag dispatching, czy compile-time-if

Jak wspomniałem wyżej znaczyć udział mają tutaj sposoby na “wyłączanie” funkcji i wywierania wpływu na wybór przez kompilator w czasie kompilacji danej funkcji… i o tym w sumie mogłaby równie dobrze być ta notatka ;)


Przypisy

  1. W klasach dozwolony jest szablonowy operator konwersji ([temp.mem]). ↩︎

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/