Bit(e) ditor i C++ #167, Një kurs vetëm modern në C++ (përfshirë C++23), pjesa 7 e N: Llojet e përdoruesve

Mirë se vini në mësimin e shtatë në serinë Learn Modern C++. Sot ne po rishikojmë llojet e përdoruesve. Ne do të diskutojmë ruajtjen e invarianteve dhe të gjitha temat e lidhura që shfaqen ndërsa eksplorojmë këtë ide.

Nëse keni humbur mësimin e mëparshëm, mund ta gjeni këtu:



Me këtë temë po i afrohemi edhe përfundimit të harkut fillestar të kursit. Pas kësaj do të ketë edhe një mësim tjetër. Më pas kursi do të marrë një pushim, duke rifilluar me përmbajtjen e ndërmjetme/të avancuar.

Llojet e përdoruesve

Deri më tani, të vetmet lloje të përdoruesve për të cilët kemi folur janë agregatët. Agregatet ofrojnë thjeshtësi dhe delegojnë përcaktimet e operacioneve, si inicializimi ose caktimi, te përpiluesi.

#include <cstdint>
#include <string>

struct User {
    int64_t id;
    std::string name;
    std::string email;
};


// Aggregate initialization
User u1{0, "John Doe", "[email protected]"};
// Copy construction
User u2(u1);
// Copy assignment
u1 = u2;

// But state can be accessed and modified externally.
u1.name = "Jane Doe";

"Hap shembullin në Compiler Explorer."

Kjo është shumë e përshtatshme, por natyrisht, gjithashtu shumë kufizuese. Ne nuk mund të mbajmë një invariant të brendshëm pasi shteti mund të ndryshohet nga jashtë.

Për të adresuar këtë problem, ne mund t'i shënojmë anëtarët tanë si privatë, duke mos lejuar aksesin e jashtëm. Rrjedhimisht, ne humbasim aksesin në inicializimin agregat dhe duhet të sigurojmë konstruktorin tonë.

Le të shohim një shembull konkret. Le të zbatojmë një tip numerik për paraqitjen e numrave racionalë; tani për tani, pa një invariant.

#include <cstdint>

struct Rational {
    // Parametric constructor
    Rational(int64_t numerator, int64_t denominator)
    // initializer list
        : numerator_(numerator), denominator_(denominator) { }

    // Getters for the state
    int64_t numerator() const { return numerator_; }
    int64_t denominator() const { return denominator_; }

private:
   // The following members are private:
    int64_t numerator_;
    int64_t denominator_;
};

// Initialize using parametric constructor
Rational v{10,20};
// v.numerator() == 10, v.denominator() == 20

"Hap shembullin në Compiler Explorer."

Specifikuesit e aksesit

Përcaktuesit e aksesit përcaktojnë se kush mund të qaset tek anëtarët e atij seksioni (ose funksionet e anëtarëve). Anëtarët publikë mund të aksesohen nga kushdo; anëtarët privatë mund të aksesohen vetëm nga anëtarë të këtij lloji ose miq.

#include <cstdint>

struct A {
    int64_t x;
private:
    int64_t y;
public:
    void set(int v) {
        x = v; // OK, public
        y = v; // OK, member of A
    }
};


A a;
a.x = 20; // OK, public
// Wouldn't compile, y is private
// a.y = 10;
a.set(10); // OK, public

"Hap shembullin në Compiler Explorer."

Unë përmenda se miqtë mund të kenë akses edhe në anëtarët privatë, kështu që le të kthehemi te lloji i numrit tonë racional. Në një mësim të mëparshëm, ne diskutuam operatorët e futjes dhe nxjerrjes së rrjedhës dhe si t'i mbingarkojmë ato.

Sidoqoftë, nëse do të përpiqeshim ta zbatonim atë në llojin tonë të numrit racional, do të kishim një problem pasi këto mbingarkesa të operatorëve kanë nevojë për akses te anëtarët privatë. Këtu hyjnë deklaratat e miqve.

#include <cstdint>
#include <iostream>

struct Rational {
  // Constructor
  Rational(int64_t numerator, int64_t denominator)
  // initializer list
      : numerator_(numerator), denominator_(denominator) { }

  // Declare stream insertion as a friend of Rational
  friend std::ostream& operator<<(std::ostream& s, const Rational& v);
  // Declare stream extraction as a friend of Rational
  friend std::istream& operator>>(std::istream& s, Rational& v);
  
private:
  int64_t numerator_;
  int64_t denominator_;
};

// Stream insertion overload for Rational
std::ostream& operator<<(std::ostream& s, const Rational& v) {
    return s << v.numerator_ << "/" << v.denominator_;
}

// Stream extraction overload for Rational
std::istream& operator>>(std::istream& s, Rational& v) {
    char delim;
    return s >> v.numerator_ >> delim >> v.denominator_;
}


Rational v{10,20};
std::cout << v << "\n";
// prints: 10/20

// For input 42/7
std::cin >> v;
// v == {42,7}
std::cout << v << "\n";
// prints 42/7

"Hap shembullin në Compiler Explorer."

Deri më tani, ne kemi përdorur me zell fjalën kyçe struct për llojet e përdoruesve. Megjithatë, mund të keni hasur në fjalën kyçe klasë. I vetmi ndryshim është se klasa paracakton qasjen private, ndërsa struct paracakton qasjen publike.

#include <cstdint>

struct A {
    int64_t x;
private:
    int64_t y;
public:
    void set(int v) {
        x = v;
        y = v;
    }
};

// Equivalent class
class B {
public:
    int64_t x;
private:
    int64_t y;
public:
    void set(int v) {
        x = v;
        y = v;
    }
};

"Hap shembullin në Compiler Explorer."

Zgjedhja nëse do të përdoret struktura ose klasa është thjesht stilistike. Megjithatë, disa udhëzues stili përdorin struct dhe class për qëllime të ndryshme (p.sh., përdorin vetëm struct për llojet e agregatit).

Konstruktorët

Një arsye tjetër për t'u larguar nga agregatët është personalizimi i procesit të inicializimit.

Për një agregat, inicializimi i paracaktuar do të inicializojë të gjithë anëtarët; me një konstruktor personal, ne mund të inicializojmë çdo anëtar sipas dëshirës.

#include <string>
#include <iostream>
#include <iomanip>

struct SimpleLabel {
    std::string label;
    friend std::ostream& operator<<(std::ostream& s, 
                                    const SimpleLabel& l) {
        return s << std::quoted(l.label);
    }
};

struct Label {
  // Default constructor that initializes label_ to "unlabeled"
  Label() : label_("unlabeled") {}
  friend std::ostream& operator<<(std::ostream& s, 
                                  const Label& l) {
      return s << std::quoted(l.label_);
  }
private:
  std::string label_;
};


// Default construct both labels
SimpleLabel l1;
Label l2;

std::cout << l1 << "\n";
// prints ""
std::cout << l2 << "\n";
// prints "unlabeled"

"Hap shembullin në Compiler Explorer."

Vini re se kemi përdorur fjalën kyçe mik edhe për SimpleLabel. Kjo na lejon të deklarojmë mbingarkimin brenda përkufizimit SimpleLabel, duke e bërë të qartë për këdo që shikon kodin se operacioni i futjes së transmetimit mbështetet.

Në shembullin e mësipërm, ne kemi përfshirë edhe përkufizimin e plotë në linjë; që varet shumë nga stili.

Lista e iniciatorëve

Si në Label dhe në shembullin tonë të mëparshëm me Rational, ne jemi mbështetur në listën e iniciatorëve për të vendosur gjendjen e anëtarëve tanë. Listat e iniciatorëve ekzistojnë për të parandaluar inicializimin e dyfishtë. Kur hyjmë në trupin e një konstruktori, të gjithë anëtarët tashmë duhet të inicializohen siç duhet. Kjo do të thotë që nëse nuk rendisim një anëtar në listën e iniciatorëve, ai do të inicializohet si parazgjedhje.

#include <string>

struct Label {
    Label() : label_("unlabeled") {}
    explicit Label(std::string_view label) : label_(label) {}
private:
    std::string label_;
};

struct Product {
    Product() {
        // label_ is already initialized to "unlabeled"
        label_ = Label{"default product"}; // re-initialize
    }
private:
    Label label_;
};

"Hap shembullin në Compiler Explorer."

Lista e iniciatorëve na lejon të kontrollojmë se si inicializohet secili anëtar. Megjithatë, ka një gjë që duhet mbajtur parasysh kur e bëni këtë. Rendi i inicializimit fiksohet sipas rendit të listuar në llojin tonë. Për fat të mirë, shumica e përpiluesve do t'ju paralajmërojnë për këtë.

#include <string>

struct Demo {
    explicit Demo(std::string_view value) : b(value), a(b) {}
    std::string a;
    std::string b;
};

Demo x("Hello World!");
// a gets initialized using b, which is not initialized yet
// this is undefined behaviour
// b gets initialized to "Hello World!"

"Hap shembullin në Compiler Explorer."

Konstruktorë të parametrizuar

Kur ne ofrojmë një konstruktor të parametrizuar, ne po i sinjalizojmë kompajlerit që lloji ynë kërkon që të inicializohet inputi. Rrjedhimisht, përpiluesi nuk do të emetojë kodin për një konstruktor të paracaktuar.

Ne mund ta kthejmë përsëri konstruktorin e paracaktuar duke e zbatuar ose duke e deklaruar me = default, i cili do ta udhëzojë përpiluesin të gjenerojë një të paracaktuar.

#include <cstdint>

struct Inhibit {
    explicit Inhibit(int64_t x) : x_{x} {}
private:
    int64_t x_;
};

struct BringBackA {
    explicit BringBackA(int64_t x) : x_{x} {}
    BringBackA() = default;
private:
    int64_t x_;
};

struct BringBackB {
    explicit BringBackB(int64_t x) : x_{x} {}
    BringBackB() : x_{42} {};
private:
    int64_t x_;
};


Inhibit x(42); // OK
// Won't compile, no matching constructor
// Inhibit y;

BringBackA z; // OK, z.x_ is uninitialized
BringBackB w; // OK, w.x_ == 42

"Hap shembullin në Compiler Explorer."

Ju mund të keni vënë re fjalën kyçe e qartë që është shfaqur në shembujt e mëparshëm. Kjo parandalon konvertimet e nënkuptuara dhe duhet të përdoret gjithmonë për konstruktorët e parametrizuar me një argument.

struct ImplicitlyInt {
    // Without the explicit keyword, this type 
    // can be constructed implicitly from int.
    ImplicitlyInt(int) {}
};

struct ExplicitlyInt {
    explicit ExplicitlyInt(int) {}
};

void some_func(ImplicitlyInt) {}
void other_func(ExplicitlyInt) {}


some_func(10); // OK, 10 converts implicitly to ImplicitlyInt
// other_func(10); // Will not compile
other_func(ExplicitlyInt{10}); // OK

"Hap shembullin në Compiler Explorer."

Trajtimi i gabimeve

Ne e filluam këtë temë me premisën se mund të dëshironim të ruanim një invariant të brendshëm. Le të rishikojmë shembullin tonë të numrit racional dhe të rregullojmë kodin në mënyrë që të mos lejojmë zero në emërues.

Një mënyrë se si një emërues mund të bëhet zero është përmes inicializimit. Një mënyrë e arsyeshme për ta parandaluar këtë është të bëjmë një përjashtim kur objekti ynë inicializohet me një zero si emërues. E vetmja mundësi tjetër që kemi do të ishte ruajtja e një nocioni të një gjendjeje të pavlefshme për objektin tonë, që në mënyrë efektive do të nënkuptonte mbështetjen e NaN.

Kur hidhet një përjashtim, ai ndërpret rrjedhën normale të programit dhe përjashtimi do të përhapet deri sa të haset një bllok trajtimi try {} catch () {}.

#include <cstdint>
#include <stdexcept>

struct Rational {
    Rational(int64_t numerator, int64_t denominator)
        : numerator_(numerator), denominator_(denominator) {
            // If denominator is zero, throw an exception
            if (denominator == 0)
                throw std::domain_error("Denominator cannot be zero.");
        }
    
private:
    int64_t numerator_;
    int64_t denominator_;
};

int main() {
    try {
        Rational x{10,20};
        Rational y{7,0}; // throws std::domain_error
        // unreachable
        Rational z{10,20};
    } catch (const std::exception& e) {
        // e.what() == "Denominator cannot be zero."
    }
}

"Hap shembullin në Compiler Explorer."

Në këtë rast, kur përpiqemi të inicializojmë y, inicializimi i y nuk përfundon. Në vend të kësaj, ne përhapim përjashtimin e hedhur, i cili kapet menjëherë me bllokun e kapjes, i cili trajton çdo përjashtim standard në këtë rast.

Vini re se std::domain_error dhe std::exception nuk përputhen. Marrëdhënia është e njëjtë me atë që kemi hasur me rrymat (d.m.th. std::cin që sillen si std::istream dhe std::cout që sillen si std::ostream). Ne do t'i kthehemi kësaj më vonë në këtë mësim.

Një mënyrë e dytë për të futur një zero në emërues është mbingarkesa e nxjerrjes së rrjedhës.

// Stream extraction overload for Rational
std::istream& operator>>(std::istream& s, Rational& v) {
    char delim;
    int64_t num;
    int64_t denom;
    if (!(s >> num >> delim >> denom))
      return s; // Couldn't read
    if (denom == 0)
        throw std::domain_error("Denominator cannot be zero.");
    // Only when everything is OK, mutate the state of v
    v.numerator_ = num;
    v.denominator_ = denom;
    return s;
}

"Hap shembullin në Compiler Explorer."

Këtu kemi hyrë në kompleksitet. Arsyeja kryesore është se ne po ofrojmë një garanci të fortë përjashtimi.

Rational x{42,7};

try {
   // For input 1/0
    std::cin >> x; // throws std::domain_error
} catch (const std::exception& e) {
    // e.what() == "Denominator cannot be zero."
}

// Strong exception guarantee:
// x.numerator_ == 42
// x.denominator_ == 7

"Hap shembullin në Compiler Explorer."

Garancitë e përjashtimit

Gjatë zbatimit të kodit, ne kemi tre opsione kryesore në lidhje me përjashtimet.

Ne mund të ofrojmë një garanci të fortë përjashtimi, ku kodi përfundon për t'u ekzekutuar ose kur bëhet një përjashtim, nuk do të bëhen ndryshime (në thelb një garanci transaksionale).

Një garanci e dobët përjashtimi garanton se do të ruhen invariantet e brendshme dhe lejon një ndryshim të pjesshëm. Për shembull, strukturat e renditura të të dhënave std::set dhe std::map ofrojnë garanci të dobëta kur futni elementë të shumtë. Kjo do të thotë që disa elementë mund të futen edhe kur hidhet një përjashtim; megjithatë, invarianti i brendshëm i strukturës së të dhënave që renditet nuk do të zhvlerësohet.

Garancia përfundimtare e dobishme është një garanci pa përjashtime, të cilat madje mund të shënohen në kod.

int plus(int a, int b) noexcept {
    return a + b;
}

std::pritur

Përjashtimet janë mjaft të vështira për t'u trajtuar siç duhet, dhe nëse një gabim nuk është "i jashtëzakonshëm", ne mund të hasim probleme të performancës pasi përjashtimet janë optimizuar për rrugën e lumtur.

Në shumë situata, një gabim është ose një dukuri e shpeshtë ose kalimtare. Ne mund të duam të mbështetemi në një mekanizëm tjetër në një rast të tillë. std::expected është një lloj që mban ose vlerën e pritur ose një gabim.

#include <cstdint>
#include <string>
#include <unordered_map>
#include <expected>
#include <system_error>
#include <functional>

struct Person {
    int64_t id;
    std::string name;
};

// Fake database that returns one record when id == 0
// and an error otherwise
std::expected<Person,std::errc> fake_db(int64_t id) {
    if (id == 0) {
        return Person{0, "John Doe"};
    } else {
        return std::unexpected{std::errc::invalid_argument};
    }
}

struct Database {
    std::expected<Person,std::errc> lookup_by_id(int64_t id) {
        // If we are not connected, return an error
        if (!db_)
            return std::unexpected{std::errc::not_connected};
        // Otherwise forward the result from the database
        return db_(id);
    }
    // Simulate a connection to a database
    void connect() {
        db_ = fake_db;
    }
private:
   // std::function can hold any callable with the matching signature
    std::function<std::expected<Person,std::errc>(int64_t)> db_;
};



Database db;
if (auto res = db.lookup_by_id(0); res) {
    // process res.value()
} else {
    // process res.error()
    // in this case res.error() == std::errc::not_connected
}

db.connect();
if (auto res = db.lookup_by_id(0); res) {
    // process res.value()
    // in this case res.value() == {0, "John Doe"}
} else {
    // process res.error()
}

if (auto res = db.lookup_by_id(1); res) {
    // process res.value()
} else {
    // process res.error()
    // in this case res.error() == std::errc::invalid_argument
}

"Hap shembullin në Compiler Explorer."

Vini re se për thjeshtësi, unë jam duke hequr kodet e gabimit të sistemit. Në praktikë, ju do të përcaktoni tuajin.

Ndërfaqet dinamike me trashëgiminë

Unë kam premtuar se më në fund do të shpjegoj pse mund të mbingarkojmë futjen dhe nxjerrjen e transmetimit me std::ostream dhe std::istream përkatësisht, dhe që këto mbingarkesa të funksionojnë me std ::cin dhe std::cout ose edhe std::fstream.

Në shembullin e mëposhtëm, ne prezantojmë një "ndërfaqe" të re (C++ nuk ka një koncept zyrtar të një ndërfaqeje) DuckInterface. Kjo ndërfaqe ka dy funksione të thjeshta anëtare virtuale, që do të thotë funksione virtuale pa asnjë zbatim. Kjo detyron çdo lloj të trashëguar nga DuckInterface të zbatojë këto metoda ose t'ia kalojë atë përgjegjësi fëmijëve të tyre. Në çdo rast, një lloj që përmban anëtarë virtualë të pastër të pazbatuar nuk mund të instantohet.

Pavarësisht mungesës së një termi zyrtar të ndërfaqes, llojet me anëtarë të pastër virtual mund të shërbejnë vetëm si ndërfaqe, si std::istream dhe std::ostream në nxjerrjen e transmetimit tonë dhe operatori i futjes mbingarkon ose pëlqen DuckInterfaceis_it_a_duck më poshtë.

#include <string>

struct DuckInterface {
    // Pure virtual member functions
    virtual std::string quacks_like() const = 0;
    virtual std::string looks_like() const = 0;
};

// Inherit from DuckInterface
struct LiveDuck : DuckInterface {
    // Override the quacks_like member function from DuckInterface
    std::string quacks_like() const override {
        return "a duck";
    }
    // Override the looks_like member function from DuckInterface
    std::string looks_like() const override {
        return "a duck";
    }
};

struct ToyDuck : DuckInterface {
    std::string quacks_like() const override {
        return "a toy";
    }
    std::string looks_like() const override {
        return "a duck";
    }
};

// This function will accept any type that inherits from
// DuckInterface and implements quacks_like and looks_like
bool is_it_a_duck(const DuckInterface& potential_duck) {
    return potential_duck.quacks_like().contains("duck") &&
        potential_duck.looks_like().contains("duck");
}

ToyDuck toy;
// is_it_a_duck(toy) == false
LiveDuck duck;
// is_it_a_duck(duck) == true

"Hap shembullin në Compiler Explorer."

Nëse do t'i kushtonit vëmendje mësimeve të mëparshme, mund të pyesni veten nëse një funksion shabllon nuk do të funksiononte këtu.

#include <string>

struct LiveDuck {
    std::string quacks_like() const {
        return "a duck";
    }
    std::string looks_like() const {
        return "a duck";
    }
};

struct ToyDuck {
    std::string quacks_like() const {
        return "a toy";
    }
    std::string looks_like() const {
        return "a duck";
    }
};

// is_it_a_duck relies on a static implicit interface
template <typename Duck>
bool is_it_a_duck(const Duck& potential_duck) {
    return potential_duck.quacks_like().contains("duck") &&
        potential_duck.looks_like().contains("duck");
}


ToyDuck toy;
// is_it_a_duck(toy) == false
// compiler will generate is_it_duck<ToyDuck> function

LiveDuck duck;
// is_it_a_duck(duck) == true
// compiler will generate is_it_duck<LiveDuck> function

"Hap shembullin në Compiler Explorer."

Dallimi kritik midis dy qasjeve është se në rastin e një ndërfaqe dinamike, ne kemi një funksion që mund të trajtojë çdo lloj të mundshëm. Nëse lëshojmë një bibliotekë me një funksion të tillë, përdoruesit mund të zbatojnë llojet e tyre pa e rikompiluar funksionin tonë. Si dobësi, ne paguajmë për këtë veçori me performancën e kohës së funksionimit përmes dërgimit virtual.

Me një shabllon, përpiluesi duhet të gjenerojë një funksion për çdo lloj konkret, i cili do të na kushtojë kohën e përpilimit dhe madhësinë e binarit të gjeneruar. Si përfitim, ne nuk kemi më shpenzimet e përgjithshme të dërgimit virtual.

Detyre shtepie

Si zakonisht, për të praktikuar përmbajtjen e këtij mësimi, keni një detyrë shtëpie.

Depoja e shablloneve me detyrat e shtëpisë për këtë mësim është këtu: https://github.com/HappyCerberus/daily-bite-course-07.

Si me të gjitha detyrat e shtëpisë, do t'ju duhet VSCode dhe Docker të instaluar në kompjuterin tuaj dhe të ndiqni udhëzimet nga mësimi i parë.



Qëllimi është që të kalojnë të gjitha testet siç përshkruhet në skedarin readme.