Kategorien
C++Programmierung

Ref-qualified overloads – Anwendungsbeispiel

Intro

Dieser Artikel ist der erste von einer kleinen Serie von C++ Artikeln in denen ich C++ Features zeigen und erklären möchte, die vielleicht für einige (oder viele?) C++ Entwickler etwas unbekannter sind – vor allem deshalb weil sich Anwendungsfälle für die Praxis vielleicht nicht immer sofort erschließen lassen.

Zumindest war dies bei mir der Fall – bis ich in meinem eigenen Code über mögliche Anwendungsfälle gestolpert bin. Genau diese Erfahrung (was könnten praktische Anwendungsfälle sein) möchte ich in den folgenden Artikeln präsentieren.

Dieser Artikel handelt von ref-qualified method overloads.

Ref-qualified overloads in C++

Seit C++11 können Methoden ref-qualified overloads haben.

class SomeClass {
public:
    // first ref-qualified overload: returning "normal" lvalue reference
    SomeClass & function() & { return *this; }
 
    // second ref-qualified overload: returning rvalue reference
    // a 'moved out object'
    SomeClass && function() && { return std::move(*this); }
 
    // there are 2 more variants for 'const' respectively,
    // which I will not cover in this article/example.
};

Der erste Overload gibt lediglich eine Referenz auf das Objekt, wobei der Zweite jedoch das Objekt aus der aktuellen Klasseninstanz heraus’moved‘. Die alte Klasseninstanz ist dann ‚leer‘.

Als ich so ein Beispiel zum ersten Mal sah, habe ich mich gefragt was dafür ein konkreter Anwendungsfall sei. Kennst du einen?

Ich habe einen netten Anwendungsfall entdeckt, als ich die SourceLocation Klasse für TeaScript implementierte.

Die SourceLocation Klasse ist eine einfache Datenklasse um einen Stelle in einem Stück (Skript) Sourcecode zu referenzieren. Um dies zu können, benötigt sie nur wenige Member: mFile um die Datei zu benennen aus welcher der Sourcecode stammt (wenn es eine gibt), mStartLine, mStartColumn, mEndLine und mEndColumn um die exakte Position des Codes zu markieren sowie schließlich mSource, welcher eine Kopie der relevantesten Codezeile enthält (normalerweise besteht eine Sektion aus genau einer Zeile).

Der Sourcecode kann auf github angesehen werden: SourceLocation.hpp

Nicht jeder Member wird in jeder Klasseninstanz gefüllt. Zum Beispiel ist die Kopie der Sourcecode-Zeile (mSource) nur gesetzt, wenn der ‚Debug-Mode‘ in der Host Applikation aktiv ist. Außerdem haben nicht alle Instanzen den mFile Member gesetzt.

Des weiteren sind einige Werte für die Member erst zu einem späteren Zeitpunkt während der Ausführung bekannt, z.B. können mEndLine und mEndColumn eines If-Statements erst dann gesetzt werden, wenn das Ende des If-Statements geparsed wurde.

Die Gesamtanzahl der Member ist (nur) 6. Aber dennoch, mit all dem Gesagtem oben, wie sollten/müssen die Konstruktoren dieser Klasse aussehen? Wie viele Kombinationen müssen implementiert werden und in welcher Reihenfolge sollen die Parameter sein (und mit welchen Default-Werten)? Sogar mit nur 6 Membern kann daraus ein ziemliches Kuddelmuddel werden, insbesondere wenn die Klassen-Schnittstelle eine gute „user experience“ für andere Entwickler sein soll.

Anstatt dass ich mich mit einer riesigen Anzahl an Konstruktoren und/oder Default-Werten herumgeschlagen habe, habe ich erkannt, dass mir ref-qualified method overloads in diesem konkreten Fall helfen werden.
(Anm.: Aus dem aktuellen Sourcecode könnte ich sogar noch 2 weitere Konstruktoren ohne große Schmerzen entfernen (es verblieben dann 2). Sie sind nur deshalb da, weil sie schon da waren (und nicht weh tun).)

Also habe ich einen Satz an ‚ref-qualified method overloads‘ für die flexibleren Klassenmember implementiert.
Das kürzeste Beispiel ist für mFile:

/// sets the corresponding file name.
void SetFile( std::shared_ptr<std::string> const &rFile )
{
    mFile = rFile;
}
 
/// adds the corresponding file name and \return a lvalue reference of this.
SourceLocation &AddFile( std::shared_ptr<std::string> const &rFile ) &
{
    SetFile( rFile );
    return *this;
}
 
/// adds the corresponding file name and \return a rvalue reference of a moved this.
SourceLocation && AddFile( std::shared_ptr<std::string> const &rFile ) &&
{
    SetFile( rFile );
    return std::move( *this );
}

Hier folgt dann wie ich es verwende:
(Dieses echte Anwendungsbeispiel ist aus Exception.hpp)

// for readability I removed the surrounding code.
// assuming a class runtime_error which takes a SourceLocation 
// as first parameter in its constructor.
 
// 1.)
// SourceLocation created with line, column, source code line and file
//[...]
runtime_error( SourceLocation( line, col ).AddSource(std::move(lineStr)).AddFile(rFile), // [...]
 
 
// 2.)
// SourceLocation created only with a file.
//[...]
runtime_error( SourceLocation().AddFile(rFile), // [...]

Wie man sehen kann, erreiche ich damit die größtmögliche Flexibilität. Ich kann jeden Member in jeder beliebigen Reihenfolge hinzufügen (oder auch nicht).

Aber neben diesem ist das Wichtigste, dass das finale Objekt in die empfangende Funktion hinein ‚gemoved‘ wird – ähnlich als wenn ich einen großen Konstruktor mit ‚in-place‘ Konstruktion aufgerufen hätte. Das anonyme (unbenannte) Objekt wird von Aufruf zu Aufruf weiter verschoben bis es schließlich an seinem finalen Ort geschoben wird.

Was muss ich für diese Flexibilität bezahlen?

Nicht viel!

Ich habe ein ähnliches Beispiel auf godbolt erstellt: Link zu godbolt.

Man kann zwischen den beiden Zeilen in der ‚main‘ Funktion hin und her wechseln und dann den Assembler Output von 'main:' bis zum Aufruf von test(SomeClass&&) vergleichen. Wie man sehen kann, haben sich die Assembler-Anweisungen für die ref-qualified method overload Variante lediglich um 5 erhöht. Dabei handelt es sich hauptsächlich um 'mov' Anweisungen. 'mov' sind eine der billigsten und schnellsten Assembler-Anweisungen, die in diesem Beispiel mehr oder weniger nicht ins Gewicht fallen (die meiste Zeit wird ohnehin im Aufruf von puts() verbraucht).

Interessanter weise ist in der Variante mit großem Konstruktor eine zusätzliche 'call' Anweisung zu einem std::string Konstruktor vorhanden. Eine 'call' Anweisung ist sehr teuer und langsam, da bei einem Funktionsaufruf viel mehr getan werden muss, als wenn man nur einen Registerwert verschiebt.
Deshalb könnte es am Ende sogar so sein, dass die Variante mit großem Konstruktor langsamer ist, obwohl sie weniger Assembler-Anweisungen hat. Dieses habe ich aber nicht durch Messungen untersucht.

Fazit

Persönlich denke ich, dass ein so artiger Weg einer Objektkonstruktion, in dem das Objekt am Ende der Konstruktionskette an seinem finalen Platz geschoben wird, eine großartige Sache ist, welches die Konstruktion sehr flexibel macht. Die paar 'mov' Anweisungen kann man locker in den meisten Fällen bezahlen um dafür größtmögliche Flexibilität für den Programmierer zu bekommen. Es ist es definitiv wert.

Was denkst du über diese Technik und/oder dem konkreten Anwendungsbeispiel?
Ich hoffe der Artikel hat dir gefallen und ich ’sehe‘ dich im zweiten Teil wieder. Danke fürs Lesen! 🙂

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert