C++ Core Guidelines: Überraschungen mit Argument-Dependent Lookup

Modernes C++  –  0 Kommentare

Insbesondere eine Regel zu Template Interfaces habe ich noch nicht vorgestellt, die sehr interessant ist: T.47: Avoid highly visible unconstrained templates with common names. Diese Regel ist oft der Grund für unerwartetes Verhalten, da die falsche Funktion aufgerufen wird.

Obwohl ich heute hauptsächlich über die Regel T.47 schreibe, habe ich mehr zu sagen.

Um die Regel T.47 besser zu verstehen, werde ich einen kleinen Umweg machen. In diesem geht es um Argument-Dependent Lookup (ADL), auch bekannt unter den Namen Koenig Lookup, benannt nach Andrew Koenig.

Hier ist die Definition von ADL:

Argument-Dependent Lookup ist eine Menge von Regeln, um nicht qualifizierte Funktionsnamen aufzulösen. Für die Auflösung nichtqualifizierte Funktionsnamen wird zusätzlich der Namensraum der Funktionsargumente verwendet.

Nichtqualifizierte Funktionsnamen sind Funktionsnamen ohne den Bereichsoperator (::). Ist Argument-Dependent Lookup schlecht? Natürlich nicht. Dank ihm wird unser Leben als Programmierer deutlich einfacher. Hier ist ein Beispiel:

#include <iostream>

int main(){
std::cout << "Argument-dependent lookup";
}

Lass mich den Syntactic Sugar zur Überladung von Operatoren entfernen und die Funktion direkt aufrufen:

#include <iostream>

int main(){
operator<<(std::cout, "Argument-dependent lookup");
}

Das folgende äquivalente Programm zeigt sehr schön, was unter der Decke stattfindet. Die Funktion operator<< wird mit den zwei Argumenten std::cout und dem C-String "Argument-dependent lookup" aufgerufen.

Natürlich taucht jetzt die Frage auf: "Wo befindet sich die Definition der Funktion operator<< ?". Offensichtlich wird sie nicht im globalen Namensraum definiert. operator<< ist ein nichtqualifizierter Funktionsname. Daher kommt ADL zum Einsatz, und der Namensraum der Argumente wird zusätzlich verwendet, um die Funktion aufzulösen. In diesem konkreten Fall wird dank des Funktionsarguments std::cout der Namensraum std durchsucht. Das Ergebnis ist std::operator<<(std::ostream&, const char*). Oft findet ADL genau die Funktion, die du benötigst, aber nicht immer.

Jetzt ist der richtige Zeitpunkt, die Regel T.47 genauer zu betrachten.

In dem Ausdruck std::cout << "Argument-dependent lookup" ist der überladene Operator operator << ein weit sichtbarer, häufig verwendeter Name, denn er wird im Namensraum std definiert. Das folgende Programm, das auf dem Programm der C++ Core Guidelines basiert, zeigt den entscheidenden Punkt:

// argumentDependentLookup.cpp

#include <iostream>
#include <vector>

namespace Bad{

struct Number{
int m;
};

template<typename T1, typename T2> // generic equality (5)
bool operator==(T1, T2){
return false;
}

}

namespace Util{

bool operator==(int, Bad::Number){ // equality to int (4)
return true;
}

void compareSize(){
Bad::Number badNumber{5}; // (1)
std::vector<int> vec{1, 2, 3, 4, 5};

std::cout << std::boolalpha << std::endl;

std::cout << "5 == badNumber: " <<
(5 == badNumber) << std::endl; // (2)
std::cout << "vec.size() == badNumber: " <<
(vec.size() == badNumber) << std::endl; // (3)

std::cout << std::endl;
}
}

int main(){

Util::compareSize();

}

Ich erwarte, dass in beiden Fällen (2 und 3) der überladene Operator == in Zeile (4) aufgerufen wird, denn es verlangt ein Argument vom Typ Bad::Number(1). In Summe bedeutet das, dass ich zweimal true erhalte.

Was ist hier passiert? Der Aufruf in Zeile (3) wird durch den generischen Gleichheitsoperator in Zeile (5) aufgelöst. Der Grund für meine Überraschung ist, dass vec.size() einen Datentyp vom Typ std::size_type zurückgibt. Das bedeutet, dass der Gleichheitsoperator in der Zeile eine Konvertierung nach int anwenden muss. Das gilt aber nicht für den generischen Gleichheitsoperator in Zeile (5), denn in diesem Fall wird keine Konvertierung angewandt. Dank des Argumente-Dependent Lookup gehört der generische Gleichheitsoperator zu der Menge der möglichen Überladungen.

Die Regel lautet: ""Avoid highly visible unconstrained templates with common names". Was passiert nun, wenn ich der Regel folge und den generischen Gleichheitsoperator entferne. Hier ist der angepasste Sourcecode:

// argumentDependentLookupResolved.cpp

#include <iostream>
#include <vector>

namespace Bad{

struct Number{
int m;
};

}

namespace Util{

bool operator==(int, Bad::Number){ // compare to int (4)
return true;
}

void compareSize(){
Bad::Number badNumber{5}; // (1)
std::vector<int> vec{1, 2, 3, 4, 5};

std::cout << std::boolalpha << std::endl;

std::cout << "5 == badNumber: " <<
(5 == badNumber) << std::endl; // (2)
std::cout << "vec.size() == badNumber: " <<
(vec.size() == badNumber) << std::endl; // (3)

std::cout << std::endl;
}
}

int main(){

Util::compareSize();

}

Nun entspricht das Ergebnis meiner Erwartung:

Hier sind meine Anmerkungen zu den letzten zwei Regeln zu Interfaces für Templates.

Wenn ich std::enable_if in meinen Seminaren vorstelle, sind einige Teilnehmer leicht verängstigt. Hier ist eine vereinfachte, generische Variante des Algorithmus zur Bestimmung des größten gemeinsamen Teilers zweier Zahlen:

// enable_if.cpp

#include <iostream>
#include <type_traits>

template<typename T, // (1)
typename std::enable_if<std::is_integral<T>::value, T>::type= 0>
T gcd(T a, T b){
if( b == 0 ){ return a; }
else{
return gcd(b, a % b); // (2)
}
}

int main(){

std::cout << std::endl;
// (3)
std::cout << "gcd(100, 10)= " << gcd(100, 10) << std::endl;
std::cout << "gcd(3.5, 4)= " << gcd(3.5, 4.0) << std::endl;

std::cout << std::endl;

}

Der Algorithmus ist viel zu generisch. Er sollte nur für Ganzzahlen zum Einsatz kommen. Zu meiner Rettung gibt es std::enable_if (Zeile 1) aus der Typ-Traits-Bibliothek.

Der Ausdruck std::is_integral (Zeile 2) ist entscheidend, um das Programm zu verstehen. Diese Zeile bestimmt, ob der Parameter T eine Ganzzahl ist. Falls T keine Ganzzahl ist und damit der Rückgabetyp false ist, wird der Compiler keine Template-Instanziierung für diese konkreten Fall durchführen.

Nur wenn std::enable_if true zurückgibt, besitzt std::enable_if das Attribut type. Wenn nicht, ist der Ausdruck nicht gültig. Dies ist aber kein Fehler.

Der C++-Standard sagt: Wenn das Substituieren des ermittelten Datentyps fehlschlägt, ist dies kein Fehler, sondern diese Spezialisierung wird aus der Menge aller möglichen Spezialisierung entfernt. Für diese Regel hat sich eine Abkürzung etabliert: SFINAE (Substitution Failure Is Not An Error).

Die fehlerhafte Kompilierung (enable_if.cpp: 20:49) zeigt es schön. Es gibt keine Template-Spezialisierung für den Datentyp double.


Die Ausgabe zeigt aber noch mehr: (enable_if.cpp:7:71): "no named `type* in struct std::enable_if<false, double>".

Seltsam, ich habe zwei Artikel zu Type Erasure geschrieben (C++ Core Guidelines: Type Erasure und C++ Core Guidelines: Type Erasure mit Templates) und die recht anspruchsvolle Technik erklärt. Jetzt soll ich sie aber lieber vermeiden.

Mit meinem nächsten Artikel springe ich direkt von den Interfaces zu der Implementierung von Tempates.

Ich freue mich darauf, weitere C++-Schulungen halten zu dürfen.

Die Details zu meinen C++- und Python-Schulungen gibt es auf www.ModernesCpp.de.