Sökresultat för

Tillämpar du Rule-of-Zero i C++?

63 minuter i lästid
Jens Riboe
Jens Riboe
Senior/Expert Software Developer
Tillämpar du Rule-of-Zero i C++?

Rule-of-Zero (RoZ) är ett kodnings-idiom i C++ där man tar vara på språkets/kompilatorns möjligheter att skriva kod åt dig. För att förstå RoZ, måste man först förstå Rule of 3/5+1. För att i sin tur, förstå detta behöver man förstå begreppet special members i C++. Samt, för att förstå dessa måste man känna till när de genereras, hur de ser ut och när de anropas. Allt detta går jag igenom steg för steg i denna artikel. Häng med till C++ bakgård.

Special Members

Visste du att kompilatorn kan generera vissa konstruktorer plus en dela annat också? Dessa medlemsfunktioner kallas för special members och utgörs av följande lista:

FormNamePurpose
T::T()Default ConstructorCreates an object with default-initialized members
T::~T()DestructorCleans up resources when object is destroyed
T::T(const T&)Copy ConstructorCreates new object as copy of existing object
T& T::operator=(const T&)Copy Assignment OperatorAssigns values from one object to another
T::T(T&&)Move ConstructorTransfers resources from one object to new object
T& T::operator=(T&&)Move Assignment OperatorTransfers resources between existing objects

Default Constructor - T::T()

Denna används för att initiera ett objekt utan objekt-externa data och anropas i följande fall:

  • Argument-less construction
  • Argument-less member object
  • Argument-less super-class constructor invocation
  • Array element initialisation

Här ger jag ett kod-exempel på varje fall, där default-konstruktorn anropas:

auto p = Person{};
class Person {
  std::string name{};
  //...
};
class Person { /*...*/ };

class Student : public Person {
 public:
  Student(/*...*/) { /*...*/ }
  // ...
};
Person arr1[5];
auto*  arr2 = new Person[5]; 

Om det inte finns en annan konstruktor, så kommer kompilatorn generera en för dig. Innehållet i denna kommer vara tom, så när på default-initiering av eventuella medlems-objekt och super-klasser.

Fallgrop

Det finns en fallgrop att vara medveten om. Säg att du börjar enkelt med en klass, skriver den utan konstruktorer inledningsvis och skapar en array. Det kommer att fungera. I ett senare läge bestämmer sig du eller en annan gruppmedlem att lägga till en konstruktor och plötsligt blir det kompileringsfel. Här kommer två kod-snuttar; den första visar ett fungerade program och den andra visar ett kompileringsfel.

struct Person {
    inline static int next_id = 1;
    string    name = "person-"s + std::to_string(next_id++);
    unsigned  age = 10 * next_id;
    auto to_string() const {
        return std::format("Person({}, {})", name, age);
    }
};

void run() {
    auto nisse = Person{"Nisse Hult", 42};
    std::cout << nisse.to_string() << "\n";

    Person family[3];
    for (auto&& p : family) std::cout << p.to_string() << "\n";
}

//Output:
Person(Nisse Hult, 42)
Person(person-1, 20)
Person(person-2, 30)
Person(person-3, 40)
struct Person {
    //...
    Person(string const& name_, unsigned age_) : name{name_}, age{age_} {}
    //...
};

// Compiler error:
error: no matching function for call to ‘usecase2::Person::Person()|          Person family[3];
   |                         ^

Rule +1

Det är i de flesta fall rekommendabelt att se till att det finns en default-konstruktor. Som minimum, kan man be kompilatorn att generera en när man har vanliga konstruktorer.

struct Person {
    //...
    Person(string const& name_, unsigned age_) : name{name_}, age{age_} {}
    Person() = default;
    //...
};

Destructor - T::~T()

Denna används för att frigöra allokerade resurser, såsom ett heap-allokerat minnesblock, stänga en fil eller TCP socket, vänta på terminering av en thread och mer därtill. Den anropas i följande fall:

  • Returns from a function
  • Exits an inner block
  • Deletes a dynamic object
  • Disposes an array of objects
  • Throws an exception

Det finns alltid en destruktor till en klass, det kan vara din eller kompilators version. Om det är den senare så blir det en tom funktions-kropp, förutom anrop av destruktorn för eventuella medlems-objekt och super-klasser.

Fallgrop

Destruktorns uppgift är att frigöra bland annat allokerade minnesblock. Här kommer ett belysande exempel. Först en version av en klass, med minnesläckage och sedan en ny version utan läckage.

namespace usecase1 {
    class Person {
        char*  name;
        unsigned age;
    public:
        Person(const char* name_, unsigned age_) {
            name = ::strcpy(new char[::strlen(name_) + 1], name_);
            age = age_;
        }
        auto to_string() const {
            return std::format("Person({}, {})", name, age);
        }
    };

    void run() {
        std::cout << "-- Use Case 1 --\n";
        auto anna = Person{"Anna Conda", 42};
        std::cout << "anna: " << anna.to_string() << "\n";
    }
}
int main() {
    usecase1::run();
    // usecase2::run();
}
C++> g++ -std=c++23 -Wall -g ../destructor.cxx -o destructor 
C++> valgrind --leak-check=yes -q  ./destructor 
-- Use Case 1 --
anna: Person(Anna Conda, 42)
==67493== 11 bytes in 1 blocks are definitely lost in loss record 1 of 1
==67493==    at 0x48785F3: operator new[](unsigned long) (vg_replace_malloc.c:729)
==67493==    by 0x4005C3E: usecase1::Person::Person(char const*, unsigned int) (destructor.cxx:11)
==67493==    by 0x40048B8: usecase1::run() (destructor.cxx:21)
==67493==    by 0x4004A91: main (destructor.cxx:51)
==67493== 
C++> 

Självklart saknas det en destruktor. I usecase 2, har jag lagt till en dylik. Här visar jag de modifierade raderna.

namespace usecase2 {
    class Person {
        //...
        ~Person() {
            delete [] name;
        }
        //...
    };
    //...
}

int main() {
    // usecase1::run();
    usecase2::run();
}
C++> g++ -std=c++23 -Wall -g ../destructor.cxx -o destructor 
C++> valgrind --leak-check=yes -q  ./destructor 
-- Use Case 2 --
anna: Person(Anna Conda, 42)
C++>

Copy Constructor - T::T(const T&)

Denna används för att skapa en komplett kopia av ett objekt, vilket behövs vid värde-anrop (call-by-value) av en funktion. Alternativet är referens-anrop (call-by-reference). Den anropas i följande fall:

  • Explicit initialisation from another object
  • Call-by-value
  • Return-by-value

Om du inte skriver en själv kommer kompilatorn generera en dylik med copy-initialisation. Det finns vissa undantag till denna regel, vilket jag återkommer till senare i denna artikel. Så här skulle en sådan kunna se ut.

class Person {
    char*  name;
    unsigned  age;
  public:
    //...
    Person(Person const& rhs) : name(rhs.name), age(rhs.age) {}
    //...
};

Fallgrop

Låt oss bygga vidare på föregående exempel, men denna gång lägga till en funktion som tar ett Person objekt med värdeanrop.

namespace usecase1 {
    class Person {
        char*  name;
        unsigned age;
    public:
        Person(const char* name_, unsigned age_) {
            name = ::strcpy(new char[::strlen(name_) + 1], name_);
            age = age_;
        }
        ~Person() {
            delete [] name;
        }
        auto to_string() const {
            return std::format("Person({}, {})", name, age);
        }
    };

    void something(Person p) {
        std::cout << "[something] p: " << p.to_string() << "\n";
    }

    void run() {
        std::cout << "-- Use Case 1 --\n";
        auto anna = Person{"Anna Conda", 42};
        std::cout << "(a) anna: " << anna.to_string() << "\n";
        something(anna);
        std::cout << "(b) anna: " << anna.to_string() << "\n";
    }
}

int main() {
    usecase1::run();
    // usecase2::run();
}
C++> g++ -std=c++23 -Wall -g ../copy-constructor.cxx -o copy-constructor
C++> ./copy-constructor 
-- Use Case 1 --
(a) anna: Person(Anna Conda, 42)
[something] p: Person(Anna Conda, 42)
(b) anna: Person(��P], 42)
free(): double free detected in tcache 2
Aborted
C++> 

Hoppsan då, något blev fel i andra utskriften av anna. Kan du klura ut vad som är fel?

Eftersom denna klass saknar en copy-constructor, så kommer en genereras där pekarvärdet av name kopieras. I anropet till something() kommer parameter p, peka ut samma minnesblock som anna. Så långt verkar det fungera. Problemet uppstår när programmet returnerar från funktionen och destruktorn för p körs, vilken i sin tur tar bort minnesblocket med namnet. I andra utskriften är name ett borttaget block, och kvar blir bara skräptecken.

Så om du behöver en destruktor, så behöver du också en kopierings-konstruktor; och vice versa. Här kommer en uppdaterad version av person-klassen.

namespace usecase2 {
    class Person {
        //...
        Person(Person const& rhs) {
            name = ::strcpy(new char[::strlen(rhs.name) + 1], rhs.name);
            age  = rhs.age;
        }
        //...
    };
    //...
}
int main() {
    // usecase1::run();
    usecase2::run();
}
C++> g++ -std=c++23 -Wall -g ../copy-constructor.cxx -o copy-constructor
C++> ./copy-constructor 
-- Use Case 2 --
(a) anna: Person(Anna Conda, 42)
[something] p: Person(Anna Conda, 42)
(b) anna: Person(Anna Conda, 42)
C++> 

Copy Assignment Operator - T& T::operator=(const T&)

Denna används vid värde-tilldelning av ett objekt till ett annat. Även i detta fall så kommer kompilatorn generera en version om det inte finns en redan. Så här kan en dylik se ut.

auto operator =(Person const& rhs) -> Person& {
    name = rhs.name;
    age  = rhs.age;
}

Fallgrop

Låt oss fortsätta bygga vidare på vår person-klass. Denna gång gör vi en värde-baserad tilldelning (assignment by-value).

namespace usecase1 {
    class Person {
        char*  name;
        unsigned age;
    public:
        Person() {
            name = nullptr;
            age  = 0;
        }
        Person(const char* name_, unsigned age_) {
            name = ::strcpy(new char[::strlen(name_) + 1], name_);
            age = age_;
        }
        ~Person() {
            delete [] name;
        }
        auto to_string() const {
            return std::format("Person({}, {})", name, age);
        }
    };

    void run() {
        std::cout << "-- Use Case 1 --\n";
        auto anna = Person{"Anna Conda", 42};
        std::cout << "(a) anna: " << anna.to_string() << "\n";
        {
            auto berit = Person{};
            berit = anna;
            std::cout << "berit: " << berit.to_string() << "\n";
        }
        std::cout << "(b) anna : " << anna.to_string() << "\n";
    }
}
int main() {
    usecase1::run();
    // usecase2::run();
}
C++> g++ -std=c++23 -Wall -g ../copy-assignment.cxx -o copy-assignment
C++> ./copy-assignment 
-- Use Case 1 --
(a) anna: Person(Anna Conda, 42)
berit: Person(Anna Conda, 42)
(b) anna : Person(�n=[, 42)
free(): double free detected in tcache 2
Aborted
C++>

Hoppsan då, samma problem som vi nyss hade; andra utskriften av anna blev förstörd. Så, tydligen behöver vi också en copy-assignment operator. Så här ser den uppdaterade koden ut.

namespace usecase2 {
    class Person {
        //...
        auto operator =(Person const& rhs) -> Person& {
            if (&rhs != this) {
                delete [] name;
                name = ::strcpy(new char[::strlen(rhs.name) + 1], rhs.name);
                age  = rhs.age;
            }
            return *this;
        }
        //...
    };
}
int main() {
    // usecase1::run();
    usecase2::run();
}
C++> g++ -std=c++23 -Wall -g ../copy-assignment.cxx -o copy-assignment
C++> ./copy-assignment 
-- Use Case 1 --
(a) anna: Person(Anna Conda, 42)
berit: Person(Anna Conda, 42)
(b) anna : Person(Anna Conda, 42)
C++>

Rule of 3

Så om du behöver en av dessa, så behöver de övriga också:

  • T::~T()
  • T::T(T const&)
  • auto T::operator =(T const&) -> T&

Move Constructor - T::T(T&&)

Med C++11 kom en andra form av referenstyp, utöver den klassiska som numera benämns lvalue reference (T&), så finns det också en rvalue reference (T&&). Med en dylik kan man ha en referens till ett transient värde, såsom ett returvärde från en funktion. Vitsen med detta är att kunna trigga en ny form av konstruktor, nämligen move constructor T::T(T&&).

Det här kallas med ett samlingsnamn för move semantics och är en designteknik ämnad att ha både snygg kod och snabb kod (maintainable, elegant, but also performant). Den bärande idén är att flytta ägarskapet av interna resurser, såsom minnesblock, från ett objekt till ett annat, i stället för att göra en komplett kopia av dem.

I vissa situationer kan kompilatorn direkt involvera move semantics, medan i andra behöver man vara explicit, genom att anropa std::move( obj ).

auto obj = SomeType{};
some_function( std::move(obj) );

Valet av namn på denna funktion har ifrågasatts, eftersom det är lätt att tro den utför något i run-time. Så är dock inte fallet, utan funktionen ska betraktas som en casting operator, vilken ändrar typ på sitt argument till en rvalue reference. Kikar man på källkoden till denna, så ser innehållet ut ungefär så här.

static_cast<T&&>( obj )

Även för move-constructor, kan kompilatorn generera en egen version. Dock är det inte lika frikostigt som för copy members. Jag återkommer till detta lite längre fram i artikeln. Ungefär så här kan kompilatorns version se ut.

Person(Person&& rhs) noexcept : name{rhs.name}, age{rhs.age} {
    rhs.name = nullptr;
    rhs.age  = 0;
}

Fallgrop

Vi fortsätter med vår Person-klass. I första versionen har jag markerat copy-members med delete och anropar en funktion där argumentet omges med std::move.

namespace usecase1 {
    class Person {
        char*  name;
        unsigned age;
    public:
        Person() {
            name = nullptr;
            age  = 0;
        }
        Person(const char* name_, unsigned age_) {
            name = ::strcpy(new char[::strlen(name_) + 1], name_);
            age = age_;
        }
        ~Person() {
            delete [] name;
        }
        Person(Person const&) = delete;
        auto operator =(Person const& rhs) -> Person& = delete;
        auto to_string() const {
            return std::format("Person({}, {})", name, age);
        }
    };

    void something(Person p) {
        std::cout << "[something] p: " << p.to_string() << "\n";
    }

    void run() {
        std::cout << "-- Use Case 1 --\n";
        auto anna = Person{"Anna Conda", 42};
        std::cout << "(a) anna: " << anna.to_string() << "\n";
        something( std::move(anna) );
        std::cout << "(b) anna : " << anna.to_string() << "\n";
    }
}

Mycket riktigt så får vi ett kompileringsfel, eftersom copy-constructor är borttagen.

 error: use of deleted function ‘usecase1::Person::Person(const usecase1::Person&)|         something( std::move(anna) );
      |         ~~~~~~~~~^~~~~~~~~~~~~~~~~~~

I det andra fallet har jag lagt till en move-constructor och då kompilerar programmet. Emellertid, har vi flyttat innehållet till parameter p och vid returen anropas dess destruktor. Kvar i run funktionen finns objektet anna, som nu befinner sig i moved-from state.

namespace usecase2 {
    class Person {
    //...
        Person(Person&& rhs) noexcept {
            name     = rhs.name;
            rhs.name = nullptr;
            age      = rhs.age;
            rhs.age  = 0;
        }
        auto to_string() const {
            return std::format("Person({}, {})", name ? name : "<null>", age);
        }
    //...
    };
-- Use Case 2 --
(a) anna: Person(Anna Conda, 42)
[something] p: Person(Anna Conda, 42)
(b) anna : Person(<null>, 0)

Move Assignment Operator - T& T::operator=(T&&)

Analogt med att vi har två copy members, så har vi också två move members. Den sista av special members är då move-assignment operator. Även i detta fall kan kompilatorn generera en version, som kan se ut ungefär så här.

auto operator =(Person&& rhs) noexcept -> Person& {
    name = std::move(rhs.name);
    age  = std::move(rhs.age);
}

Både name och age är att betrakta som primitiva värden och deras numeriska innehåll kommer att kopieras till vänster-sidan, utan att ändra höger-sidan.

Här går jag direkt till implementationen och när vi kör programmet så får vi ett liknande utfall som för move-constructor.

auto operator =(Person&& rhs) noexcept -> Person& {
    if (&rhs != this) {
        delete [] name;
        name = rhs.name;
        rhs.name = nullptr;
        age = rhs.age;
        rhs.age = 0;
    }
    return *this;
}
C++> g++ -std=c++23 -Wall -g ../move-assignment.cxx -o move-assignment
C++> ./move-assignment 
-- Use Case 2 --
(a) anna: Person(Anna Conda, 42)
berit: Person(Anna Conda, 42)
(b) anna : Person(<null>, 0)
C++> 

Rule of Five

I och med C++11, så uppdaterades rule-of-three till rule-of-five. Om du behöver en av dessa fem, så lär du behöva de övriga också.

Rule of Six

Kombinerar man rule-of-five med rekommendationen att alltid ha en default constructor, så har vi då Rule-of-Six.

När genererar kompilatorn en special member?

Howard Hinnant sammanfattade i tabell reglerna för när kompilatorn genererar eller inte gör det, baserat på olika kriterier såsom vilka medlemmar du skriver själv. Denna tabell brukar benämnas Hinnant table. Tabellen visades under föredraget "Everything you need to know about move semantics", som du kan söka efter på YouTube.

Hinnant Table

Eftersom move-members kom i C++11, så har man försökt behålla kompatibilitet med classic C++, vilket medför att vi har en liten asymmetri för copy och move members. Som tabellen visar, om man deklarerar en copy-constructor, så försvinner båda move-members. Detta gäller även om man markerar denna som delete.

MyClass(MyClass const&) = delete;

Rule of Zero

Så, en relevant fråga är ju behöver man verkligen implementera samtliga sex special members? Tja, det beror på valet av medlems variabler. Om du väljer datatyper som redan följer rule-of-five, så behöver du inte implementera någon av dessa alls. Detta kallas för Rule-of-Zero (RoZ).

Tag till exempel vår Person klass. Om vi byter ut datatypen för name till std::string, så kan vi ta bort alla special members. Så här kan det då se ut.

class Person {
    string    name{};
    unsigned  age{};
public:
    Person(string name_, unsigned age_) : name{std::move(name_)}, age{age_} {}
    auto to_string() const {
        return std::format("Person({}, {})", name, age);
    }
    auto const& get_name() const { return name; }
    auto get_age() const { return age; }
    auto incr_age() { ++age; return age; }
};

Här följer ett demo program som visar både anrop av både copy och move members, vilka då har blivit genererade av kompilatorn.

auto something(Person p) {
    p.incr_age();
    cout << "[something] p: " << p.to_string() << "\n";
    return p;
}

int main() {
    auto anna  = Person{"Anna Conda", 42};
    cout << anna.to_string() << "\n";

    auto berit = something(anna);
    cout << "anna : " << anna.to_string() << "\n";
    cout << "berit: " << berit.to_string() << "\n";

    auto carin = berit;
    cout << "berit: " << berit.to_string() << "\n";
    cout << "carin: " << carin.to_string() << "\n";

    auto doris = std::move(carin);
    cout << "carin: " << carin.to_string() << "\n";
    cout << "doris: " << doris.to_string() << "\n";

    auto frida = something( std::move(doris) );
    cout << "doris: " << doris.to_string() << "\n";
    cout << "frida: " << frida.to_string() << "\n";

    cout << "----\n";
    for (auto&& p : {anna, berit, carin, doris, frida})
        cout << p.to_string() << "\n";
}
Person(Anna Conda, 42)
[something] p: Person(Anna Conda, 43)
anna : Person(Anna Conda, 42)
berit: Person(Anna Conda, 43)
berit: Person(Anna Conda, 43)
carin: Person(Anna Conda, 43)
carin: Person(, 43)
doris: Person(Anna Conda, 43)
[something] p: Person(Anna Conda, 44)
doris: Person(, 43)
frida: Person(Anna Conda, 44)
----
Person(Anna Conda, 42)
Person(Anna Conda, 43)
Person(, 43)
Person(, 43)
Person(Anna Conda, 44)

Vi kan titta efter i symboltabellen efter de genererade medlemmarna.

C++> nm --demangle person-roz | grep -i person | cut -d ' ' -f 1,2 --complement | sort -u
Person::incr_age()
Person::~Person()
Person::Person(Person&&)
Person::Person(Person const&)
Person::Person(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, unsigned int)
Person::to_string[abi:cxx11]() const
something(Person)
std::initializer_list<Person>::begin() const
std::initializer_list<Person>::end() const
std::initializer_list<Person>::size() const

Tilldelningsoperatorerna har blivit inline funktioner och syns inte i symboltabellen, dessvärre.

När fungerar det inte med Rule-of-Zero?

Tja, det är ju en berättigad fråga och svaret är kort och gott; när du måste implementera en destruktor. Då dyker det upp en följdfråga; och när är det då?

När man måste implementera en destruktor, är i följande fall:

  1. I en polymorf basklass, vilken skall vara varken kopierbar eller flyttbar
  2. I en resurshanteringsklass, där resursen ska frigöras i destruktorn

(1) Polymorf basklass (Polymorphic Base Class)

En klass kallas för polymorf om den har minst en virtuell funktion, och därmed är tänkt att fungerar som superklass för en eller flera subklasser. En polymorf basklass, måste också deklarera sin destruktor som virtuell, även om det inte finns någon resurs att frigöra. Det görs enklast genom att markera den med default, på följande vis.

virtual ~BaseClass() = default;

Det typiska sättet att använda en basklass är att deklarera pekare (eller referenser) av denna typ, som sedan pekar på subklass-objekt.

Vehicle*  ptr = new Car{...};
ptr->drive(90);
delete ptr;
ptr = new MC{...};
ptr->drive(105);
//...
Shape* shapes[] = {new Rect{...}, new Circle{...}, new Triangle{...}};
for (auto* s : shapes) std::cout << s->area() << "\n";

En polymorf basklass, skall varken vara kopierbar eller flyttbar, dvs man ska markera dessa special members med delete. Skälet är att förbygga en svårfunnen bug som kallas slicing. Här följer ett citat från C++ Core Guidelines: § C.67

If it is accidentally passed by value, with the implicitly generated copy constructor and assignment, we risk slicing: only the base portion of a derived object will be copied, and the polymorphic behavior will be corrupted.

Det mest tydliga är att markera samtliga special members med delete. (I)

struct BaseClass {
    //...
    BaseClass(BaseClass const&)     = delete;
    BaseClass(BaseClass&&) noexcept = delete;
    auto operator =(BaseClass const&) -> BaseClass&     = delete;
    auto operator =(BaseClass&&) noexcept -> BaseClass& = delete;
    //...
};

Studerar man Hinnant tabellen, så ser man att copy-members också undertrycker generering av move-members. Så det går att korta ned ovanstående till. (II)

Copy members
struct BaseClass {
    //...
    BaseClass(BaseClass const&)     = delete;
    auto operator =(BaseClass const&) -> BaseClass&     = delete;
    //...
};

Kikar man sedan på sista raden i tabellen, framgår att det går att korta ned koden ytterligare till att bara ha en move-assignment operator markerad med delete. (III)

Move assignment
struct BaseClass {
    //...
    auto operator =(BaseClass&&) noexcept -> BaseClass& = delete;
    //...
};

Personligen anser jag det första alternativet (I) vara det mest tydliga för läsare av programkoden.

(2) Resurshanteringsklass (Resource Management Class)

En resurshanteringsklass hanterar en resurs. En resurs är något som behöver förvärvas, användas och sen frigöras. Typiska exempel är smarta pekare (t.ex. std::unique_ptr), filhanteringsklasser (t.ex. std::ifstream) med flera. Konstruktorn förvärvar resursen och destruktorn frigör resursen.

Det finns tre kategorier av resurshanteringsklasser:

  1. Singleton resource
  2. Moveable resource
  3. Cloneable resource

(1) Singleton resource

En singleton resource är varken kopierbar eller flyttbar, och vanligtvis begränsad till ett block, varför det inte är meningsfullt att flytta ägarskapet av dess resurs. Typiska exempel är en spårutskriftsklass (trace class) och lock guard.

class Account { 
  std::recursive_mutex lock;  //the resource
  int balance = 0;
 public:
  //...
  int update(int amount) {
    auto t = Trace{"update"};
    auto g = std::lock_guard<std::recursive_mutex>{lock}; //the manager
    balance += amount;
    return balance;
  }
  //...
};

(2) Moveable resource

En flyttbar resurs (moveable) kan inte kopieras men ägarskapet kan flyttas från ett objekt till ett annat. Oftas kan det göras med funktionen std::move. Exempel på flyttbar resurs är running thread, open TCP socket, large memory block med flera. Så här kan man modellera en resurshanteringsklass för en flyttbar resurs.

#include <optional>

using Resource = ...;

class ResourceManager {
  std::optional<Resource>  resource;
  void drop() noexcept;
 public:
  ResourceManager(Params);
  ResourceManager() = default;
  ~ResourceManager() noexcept;
  ResourceManager(ResourceManager&&) noexcept;
  auto operator=(ResourceManager&&) noexcept -> ResourceManager&;
};

ResourceManager::ResourceManager(Params p) {
    //use the params to acquire the resource
}

ResourceManager::~ResourceManager() noexcept {
  this->drop();
}

ResourceManager::ResourceManager(ResourceManager&& rhs) noexcept 
  : resource{ std::move(rhs.resource) }
{
  rhs.resource.reset();
}

auto ResourceManager::operator=(ResourceManager&& rhs) noexcept -> ResourceManager&
{
  if (this != &rhs) {
    this->drop();
    std::swap(this->resource, rhs.resource);
  }
  return *this;
}

void ResourceManager::drop() noexcept {
  if (resource) {
    try { /*dispose it here*/ }
    catch(...) { /*perhaps do some logging*/ }
    resource.reset();
  }
}

(3) Cloneable resource

En kopierbar resurs är normalt också en flyttbar resurs, och i detta fall måste man också¨ implementer samtliga sex special members.

Ett komplett (realistiskt) programexempel

Tills sist, tänkte jag visa ett realistiskt exempel i två versionen; den första enligt rule-of-siz och den andra enligt rule-of-zero. Både versionerna används i samma demo-program. Programmet läser och skriver ut metadata från en PNG-fil.

En PNG bildfil har följande layout i början av filen.

PNG file header layout
BE     = Big Endian
uint32 = 32-bit unsigned int
+------------------------+-------------------------------+
| 8 bytes                | PNG signature                 |
+------------------------+-------------------------------+
| 4 bytes (uint32 BE)    | IHDR length (must be 13)      |
+------------------------+-------------------------------+
| 4 bytes                | "IHDR"                        |
+------------------------+-------------------------------+
| 4 bytes (uint32 BE)    | Width                         |
+------------------------+-------------------------------+
| 4 bytes (uint32 BE)    | Height                        |
+------------------------+-------------------------------+
| 1 byte                 | Bit depth                     |
+------------------------+-------------------------------+
| 1 byte                 | Color type                    |
+------------------------+-------------------------------+
| 1 byte                 | Compression method            |
+------------------------+-------------------------------+
| 1 byte                 | Filter method                 |
+------------------------+-------------------------------+
| 1 byte                 | Interlace method              |
+------------------------+-------------------------------+
| 4 bytes (uint32 BE)    | CRC for IHDR                  |
+------------------------+-------------------------------+

PNG signature utgörs av filens magic number, och har följande innehåll.

0x89,          'P', 'N', 'G', 0x0D, 0x0A, 0x1A,   0x0A
prefix number   image type    CR    LF    Ctrl-Z  LF

Här kommer huvudprogrammet. Filen läses in med funktionen load, som returnerar en buffert (vilken vi har flera versioner av). Sen valideras filen, dvs kollar att det verkligen är en PNG-fil. Till sist, läses delar av metadata och skrivs ut. Notera den "moderna" versionen av att vända ett heltal i big-endian format till little-endian (read_bigendian_at). Vem säger att man inte kan programmera låg-nivå uppgifter med hög-nivå funktioner. 😊

#include <iostream>
#include <fstream>
#include <filesystem>
#include <string>
#include <chrono>
#include <array>
#include <span>
#include <algorithm>
#include <concepts>
#include <cstdint>

#include "types.hxx"  // using BYTE = unsigned char;
#include "buffer-rule-6.hxx"
#include "buffer-rule-0.hxx"
#include "buffer-rule-0-uniptr.hxx"

namespace fs = std::filesystem;
namespace cr = std::chrono;

template<typename BufferType>
auto load(fs::path filename) -> BufferType {
    auto file = std::ifstream{filename, std::ios::binary};
    if (!file)
        throw std::invalid_argument{"cannot open file:" + filename.string()};

    auto const file_size = fs::file_size(filename);
    auto buf = BufferType{file_size};
    file.read(reinterpret_cast<char *>(buf.data()), buf.size());
    if (file.gcount() != static_cast<long>(buf.size()))
        throw std::runtime_error{"failed to load whole file"};

    return buf;
}

template<typename BufferType>
bool is_png(BufferType const& buf) noexcept {
    auto constexpr PNG_SIGNATURE = std::array<BYTE, 8>{
        0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A
    };

    if (buf.size() < PNG_SIGNATURE.size()) return false;
    auto prefix = std::span<const BYTE>{buf.data(), PNG_SIGNATURE.size()};

#if __cpp_lib_ranges >= 201911
    return std::ranges::equal(prefix, PNG_SIGNATURE);
#else
    return std::equal(prefix.begin(), prefix.end(), PNG_SIGNATURE.begin(), PNG_SIGNATURE.end());
#endif
}

template<std::size_t NUM_BYTES, std::unsigned_integral UInt>
    requires (sizeof(UInt) == NUM_BYTES)
auto read_bigendian_at(BYTE const* base_address, std::size_t offset) -> UInt {
    auto slice = std::span<const BYTE, NUM_BYTES>(base_address + offset, NUM_BYTES);
    auto bytes = std::array<BYTE, NUM_BYTES>{};

    if constexpr (std::endian::native == std::endian::little) {
        // Big-endian source -> reverse-copy to little-endian host
        std::copy(slice.rbegin(), slice.rend(), bytes.begin());
    } else {
        // Host already in big-endian: normal copy
        std::copy(slice.begin(), slice.end(), bytes.begin());
    }

    return std::bit_cast<UInt>(bytes);
}

template<typename BufferType>
void validate(BufferType const& buf) {
    auto constexpr MIN_SIZE = 8 + 25; //signature + IHDR minimum
    if (not is_png(buf) || buf.size() < MIN_SIZE)
        throw std::invalid_argument{"invalid png: missing prefix or too small size"};

    auto ihdr_length = read_bigendian_at<4, uint32_t>(buf.data(), 8);
    auto type        = reinterpret_cast<const char *>(buf.data() + 12);
    if (ihdr_length != 13 || std::string(type, 4) != "IHDR")
        throw std::invalid_argument{"invalid png: missing IHDR"};
}

template<typename BufferType>
void usecase(std::string const& name, fs::path const& filename) {
    std::cout << "--- Use case: " << name << " ---\n";
    auto start_time = cr::high_resolution_clock::now();

    auto buf = load<BufferType>(filename);
    std::cout << "Loaded " << buf.size() << " bytes from " << filename.string() << "\n";

    validate(buf);
    auto const width  = read_bigendian_at<4, uint32_t>(buf.data(), 16);
    auto const height = read_bigendian_at<4, uint32_t>(buf.data(), 20);
    auto const depth  = static_cast<unsigned>(buf.data()[24]);
    auto const color  = static_cast<unsigned>(buf.data()[25]);
    std::cout << "PNG-metadata: "
            << "dimension="   << width << "x" << height << " px,"
            << " bit depth="  << depth << ", "
            << " color type=" << color << "\n";

    auto end_time = cr::high_resolution_clock::now();
    auto elapsed  = cr::duration_cast<cr::milliseconds>(end_time - start_time);
    std::cout << "Elapsed " << elapsed.count() << " ms\n";
}

int main() {
    auto const filename = fs::path{"generic-article-image.png"};
    usecase<ribomation::rule_of_6::Buffer>("Rule of Six", filename);
    usecase<ribomation::rule_of_0::Buffer>("Rule of Zero", filename);
    usecase<ribomation::rule_of_0_uptr::Buffer>("Rule of Zero (unique_ptr)", filename);
}

Utdata från programmet ser ut så här.

--- Use case: Rule of Six ---
Loaded 1002347 bytes from generic-article-image.png
PNG-metadata: dimension=1300x685 px, bit depth=8,  color type=2
Elapsed 12 ms
--- Use case: Rule of Zero ---
Loaded 1002347 bytes from generic-article-image.png
PNG-metadata: dimension=1300x685 px, bit depth=8,  color type=2
Elapsed 11 ms
--- Use case: Rule of Zero (unique_ptr) ---
Loaded 1002347 bytes from generic-article-image.png
PNG-metadata: dimension=1300x685 px, bit depth=8,  color type=2
Elapsed 11 ms

Process finished with exit code 0

Rule-of-Six version

I denna första version implementerar vi alla sex special members.

#pragma once
#include <utility>
#include <cstddef>
#include <cstring>
#include "types.hxx"

namespace ribomation::rule_of_6 {
    class Buffer {
        std::size_t  num_bytes;
        BYTE*        storage;
    public:
        Buffer() noexcept : num_bytes{0}, storage{nullptr} {}
        explicit Buffer(std::size_t n)
            : num_bytes{n}, storage{n > 0UL ? new BYTE[n] : nullptr} {}
        ~Buffer() {
            delete[] storage;
        }

        Buffer(Buffer const& other)
            : num_bytes{other.num_bytes},
              storage{other.num_bytes > 0 ? new BYTE[other.num_bytes] : nullptr} 
        {
            if (storage != nullptr) {
                std::memcpy(storage, other.storage, num_bytes);
            }
        }
        Buffer(Buffer&& other) noexcept
            : num_bytes{other.num_bytes}, storage{other.storage} 
        {
            other.num_bytes = 0;
            other.storage = nullptr;
        }

        Buffer& operator=(Buffer const& other) {
            if (this != &other) {
                delete[] storage;
                num_bytes = other.num_bytes;
                if (num_bytes > 0) {
                    storage = new BYTE[other.num_bytes];
                    std::memcpy(storage, other.storage, num_bytes);
                } else {
                    storage = nullptr;
                }
            }
            return *this;
        }
        Buffer& operator=(Buffer&& other) noexcept {
            if (this != &other) {
                delete[] storage;
                num_bytes = other.num_bytes;
                other.num_bytes = 0;
                storage = other.storage;
                other.storage = nullptr;
            }
            return *this;
        }

        auto        size() const noexcept { return num_bytes; }
        BYTE*       data() noexcept { return storage; }
        BYTE const* data() const noexcept { return storage; }
    };
}

Rule-of-Zero version

I denna andra version, är det remarkabelt hur lite kod det är kvar. Demoprogrammet demonstrerar ju att denna version fungerar exakt lika som den första.

#pragma once
#include <vector>
#include "types.hxx"

namespace ribomation::rule_of_0 {
    class Buffer {
        std::vector<BYTE> storage{};
    public:
        Buffer() noexcept = default;
        explicit Buffer(std::size_t n) : storage(n) { }

        auto        size() const noexcept { return storage.size(); }
        BYTE*       data() noexcept { return storage.data(); }
        BYTE const* data() const noexcept { return storage.data(); }
    };
}

Smart Pointer version

Säg att vi önskar implementera klassen med en smart pekare, hur blir det då? Nedanstående version använder std::unique_ptr och som konsekvens har vi då en flyttbar men inte kopieringsbar buffert, vilket troligtvis är help ok i detta fall.

#pragma once
#include <memory>
#include <cstddef>
#include "types.hxx"

namespace ribomation::rule_of_0_uptr {
    class Buffer {
        std::size_t num_bytes{};
        std::unique_ptr<BYTE[]> storage;
    public:
        Buffer() noexcept = default;
        explicit Buffer(std::size_t n)
            : num_bytes{n}, storage{n ? std::make_unique<BYTE[]>(n) : nullptr} {}

        auto         size() const noexcept -> std::size_t { return num_bytes; }
        BYTE*        data() noexcept { return storage.get(); }
        BYTE  const* data() const noexcept { return storage.get(); }
    };
}

Sammanfattning – Takeaways

  • Rule of Zero är den moderna standarden: välj datatyper som redan gör jobbet åt dig.
  • Om du själv måste hantera resurser → använd Rule of Three/Five/Six för att undvika buggar.
  • Genom att låta kompilatorn generera special members minskar du kodmängden och ökar robustheten.
  • Rule-of-Zero handlar om att frigöra dig från lågnivådetaljer och fokusera på din egen applikationslogik.

Vill du arbeta ännu mer effektivt med modern C++ och undvika fallgropar i dina projekt, så är detta precis vad jag går igenom i mina kurser och workshops. Kontakta mig för en offert.