Det kan være meget naturligt at starte med at tænke, at "C++ er bare C med klasser", hvilket et stykke hen ad vejen er korrekt. Men moderne C++ er også bare så meget mere! Lad mig derfor starte med at citere Scott Meyers fra bogen Effective C++:
The easiest way is to view C++ not as a single language but as a federation of related languages. Within a particular sublanguage, the rules tend to be simple, straightforward, and easy to remember. When you move from one sublanguage to another, however, the rules may change.
Dernæst inddeler han C++ i fire "undersprog" (sublanguages): C, C med klasser, template meta-programmering og standard-biblioteket STL. Bemærk at C er et undersprog af C++, og det meste C-kode, inklusiv C-headers, kan derfor bruges direkte i C++-kode. Til gengæld er C++ også mere end bare "C med klasser".
Her vil jeg kort gennemgå de vigtigste ændringer fra C til C++, og lad os starte med et Hello World! fra C++:
claus@ClausDesktop:~/work$ cat hello.cpp #include <iostream> int main() { std::cout << "Hello world!" << std::endl; return 0; } claus@ClausDesktop:~/work$ g++ -std=c++14 -o hello hello.cpp claus@ClausDesktop:~/work$ ./hello Hello world!
Her brugte jeg C++-compileren fra GCC (g++
) og angiver at der skal kompileres med C++14 fremfor C++98.
Bemærk at iostream
-headeren er inkluderet i stedet for stdio.h
fra C, og output sendt til terminalen med std::cout
i stedet for printf
.
Desuden bliver output sendt med operatoren <<
, og det kan ikke formateres på samme måde som med printf
(men der er andre måder at gøre det på!).
En linie kan afsluttes ved at sende std::endl
, der nærmest svarer til at sende '\n'
- men bemærk at std::endl
også flusher output-bufferen (ligesom med fflush
i C).
auto
-keyword
Vi starter blødt ud med auto
, som bruges til automatisk type-deduktion, hvilket egentlig bare vil sige, at compileren selv finder ud af hvilken type der skal bruges.
Lad os se et eksempel:
auto a = 5; auto b = 10; auto str = "test!"; std::cout << a << " + " << b << " = " << (a + b) << std::endl; std::cout << str << std::endl;
Det giver (forhåbentlig) næsten sig selv - compileren ser hvilken type der står på højre side af =
og bruger denne type.
Du kan læse mere om hvordan automatisk type-deduktion egenlig virker i Effective Modern C++ af Scott Meyers.
Særligt når du bruger templates - og det gør du hvis du bruger STL - så er auto
virkelig rart, for der støder du nemt på meget lange type-navne.
Et objekt-orienteret sprog
Som sagt indeholder C++ klasser, som kan bruges til at definere nye typer. Typisk vil en klasse deles op i to filer: en header-fil (.h/.hpp) til definitionen af klassen, samt en source-fil (.cpp) som indeholder selve implmenteringen af klassen.
Objekt-orienteret programmering er i sig selv et større emne at læse op på, men her vil jeg give et kort overblik over, hvordan det mest basale ser ud i C++.
Som et eksempel kan vi lave en klasse til 2D-vektorer. Lad os starte med header-filen:
//////////////////////////////////////////////////////////////// // 2D-vektor (Vektor2D.h) //////////////////////////////////////////////////////////////// #ifndef VEKTOR2D_GUARD #define VEKTOR2D_GUARD class Vektor2D { public: // Data double X; double Y; // Constructor / destructor explicit Vektor2D(double x = 0, double y = 0); // Constructor Vektor2D(const Vektor2D& vektor); // Copy-constructor ~Vektor2D(); // Destructor // Operator-overloads Vektor2D& operator=(const Vektor2D& vektor); // Copy-assignment Vektor2D& operator+=(const Vektor2D& vektor); Vektor2D& operator-=(const Vektor2D& vektor); Vektor2D& operator*=(double faktor); bool operator==(const Vektor2D& vektor); bool operator!=(const Vektor2D& vektor); // Metoder double Dot(const Vektor2D& vektor); double Length(); void Normalize(double newLength = 1.0); void Rotate(double angle, bool clockwise = true); }; // Non-member non-friend operator-overloads const Vektor2D operator+(const Vektor2D& lhs, const Vektor2D& rhs); const Vektor2D operator-(const Vektor2D& lhs, const Vektor2D& rhs); const Vektor2D operator*(const Vektor2D& vektor, double faktor); const Vektor2D operator*(double faktor, const Vektor2D& vektor); #endif
Klassen indeholder to variabler af typen double
, og ellers en række metoder (dvs. funktioner tilknyttet en instans af klassen) og operator overloads.
Desuden er nogle operator overloads defineret udenfor klassen. Lad os starte med at se hvordan klassen kan bruges:
Vektor2D vektor(3,4); std::cout << "Længde af vektor: " << vektor.Length() << std::endl;
Her kaldes først en constructor (med implicit konvertering af 3 og 4 fra int
til double
), som laver et objekt - en instans - af Vektor2D
-klassen.
Dernæst kaldes metoden Length
på Vektor2D
-instansen vektor
.
Bemærk hvordan metoden Length
hører sammen med instansen af klassen, i modsætning til "klassiske" funktioner, der ikke behøver et objekt for at blive kaldt.
En constructor er en speciel metode, som bliver kaldt når et objekt bliver skabt - den bruges bl.a. til at initialisere objektet.
Ligeledes bliver destructoren kaldt når objektet ikke skal bruges mere (dvs. når instansen går ud af scope hvis det er allokeret på stacken,
eller hvis det bliver deallokeret med delete
for objekter på heapen).
Her er nogle simple implementeringer af constructors, destructor, samt et par metoder:
//////////////////////////////////////////////////////////////// // 2D-vektor implementation (Vektor2D.cpp) //////////////////////////////////////////////////////////////// #include#include "vektor.h" Vektor2D::Vektor2D(double x, double y) : X(x), Y(y) { } Vektor2D::Vektor2D(const Vektor2D& vektor) : X(vektor.X), Y(vektor.Y) { } Vektor2D::~Vektor2D() { } double Vektor2D::Dot(const Vektor2D& vektor) { return X * vektor.X + Y * vektor.Y; } double Vektor2D::Length() { return std::sqrt(Dot(*this)); } ... Resten af filen
Implementeringen af enhver metode har klassens navn som prefiks, dvs. Vektor2D::
i dette tilfælde.
Men det mest interessante at lægge mærke til her er initializer-listen ved constructors (efter :
),
som giver mulighed for at initialisere de lokale variable (felter/data members) i objektet.
Bemærk også this
-pointeren, som i enhver metode giver en pointer til den instans af klassen, som metoden er kaldt for.
Et vigtigt element at se på er også operator overloading. Lad os starte med at se på nogle implementeringer:
Vektor2D& Vektor2D::operator=(const Vektor2D& vektor) { X = vektor.X; Y = vektor.Y; return *this; } Vektor2D& Vektor2D::operator+=(const Vektor2D& vektor) { X += vektor.X; Y += vektor.Y; return *this; } const Vektor2D operator+(const Vektor2D& lhs, const Vektor2D& rhs) { Vektor2D tmp(lhs); // Brug af copy-constructor tmp += rhs; return tmp; }
Bemærk her at kun de første to funktioner er metoder, dvs. tilknyttet en bestemt instant af Vektor2D
-klassen, mens den sidste bare er en "almindelig" funktion.
Med sådanne implementeringer af de forskellige operatorer som f.eks. +
, kan de bruges sammen med instanser af klassen:
Vektor2D v1(3, 4); Vektor2D v2(1,-1); Vektor2D v3 = v1 + v2; v3 -= 2.0 * v2; v3 *= -1; std::cout << "(x,y) = (" << v3.X << " , " << v3.Y << ")" << std::endl;
Der er rigtig meget andet at lære om objektorienteret programmering, især med nedarv og indkapsling, men nu har er du i hvert fald i gang!
Containers og iterators
Standardbiblioteket, STL, indeholder en række forskellige datastrukturer som f.eks. std::vector
og std::map
(samt en række andre).
Den simpleste container, std::vector
, kan bruges når du har brug for et "dynamisk array", altså hvis du først ved ved runtime hvor mange objekter du skal gemme i dit array (eller rettere vektor).
Her er et eksempel:
#include <iostream> #include <string> #include <vector> int main() { std::vector<std::string> strings; // En vektor af strings // Implicit konvertering fra char* (C-string) til std::string (C++11-string) strings.push_back("Hello world!"); strings.push_back("Hello Again!"); strings.push_back("Og en tredje string..."); for(unsigned int i = 0; i < strings.size(); i++) std::cout << strings[i] << std::endl; return 0; }
Du tilføjer altså elementer til en vektor med push_back
-metoden, du kan se antallet af elementer i vektoren med size
-metoden, og du kan fjerne alle elementer fra vektoren med clear
.
Se evt. her hvad du ellers kan gøre med en vektor - det er en vigtig datastruktur at kende til i C++.
Et vigtigt koncept for alle containers i STL er iterators, som nemt lader dig iterere gennem en container.
Ovenstående for
-loop kan skrives således med iterators:
for(auto i = strings.cbegin(); i != strings.cend(); i++) std::cout << *i << std::endl;
Alle container-instanser har metoderne begin
og end
, samt const
-udgaverne cbegin
og cend
.
begin
-metoden giver altid en iterator der peger på første element i containeren, og end
giver en iterator der peger efter det sidste element i containeren.
En iterator fungerer lidt som en pointer, og du kan bruge dereference-operatoren *
til at få det element i containeren, som en iterator peger på - dvs. ovenfor gav *i
den string i vektoren, som iteratoren pegede på.
Bemærk også at ++
-operatoren går videre til næste element i containeren, dvs. giver en iterator der peger på næste element.
Læg mærke til at auto
blev brugt for typen af iteratoren - generelt har iterators nogle lange typenavne, som man hverken har lyst til at skrive eller se på; sørg for at bruge auto
her!
Udenover vector
er map
en god datastruktur at kende. Her er et eksempel:
#include <iostream> #include <string> #include <map> int main() { std::map<std::string, std::string> dictionary; dictionary["stol"] = "chair"; dictionary["bord"] = "table"; dictionary["bil"] = "car"; dictionary["båd"] = "boat"; std::cout << "\"Stol\" hedder på engelsk: " << dictionary["stol"] << std::endl; std::string test_string = "bil"; auto s = dictionary.find(test_string); if(s == dictionary.end()) std::cout << "\"" << test_string << "\" blev ikke fundet!" << std::endl; else std::cout << "\"" << test_string << "\" hedder på engelsk: " << s->second << std::endl; return 0; }
En map
består altså både af en key samt en værdi; i dette eksempel er begge af typen std::string
.
Bemærk at elementer i en map
bliver opbevaret som typen std::pair
(i dette tilfælde std::pair<std::string,std::string>
),
og for et std::pair
-objekt p
tilgås key og værdi som hhv. p.first
og p.second
- læg mærke til at s->second
blev brugt i koden ovenfor,
hvor s
er en iterator, der peger på en instans af std::pair
.
RAII og håndtering af ressourcer
Håndtering af ressourcer, f.eks. allokeret hukommelse på heapen, kan håndteres meget mere sikkert i C++, ved brug af klasser til at håndtere og frigive ressourcerne. Princippet, der bruges, er kendt som Resource Aquisition Is Initialization (RAII), der kan illustreres således:
ResourceManagerClass instance(AcquireResource()); // Generelt eksempel
Funktionen AcquireResource
kunne f.eks. returnere en pointer til allokeret hukommelse (dvs. ResourceManagerClass instance(new Resource());
),
og læg mærke til at ressourcen bliver sendt direkte til constructoren af ResourceManagerClass
, således at instance
kan initialiseres med pointeren til ressourcen.
Med denne konstruktion bliver instance
til "ejeren" af ressourcen, og det er derfor dennes ansvar, at ressourcen bliver frigivet igen efter brug - og dette kan garanteres ved at frigive ressourcen i destructoren.
Her er et eksempel på en resource manager:
//////////////////////////////////////////////////////////////// // General resource manager class (ResourceManager.h) //////////////////////////////////////////////////////////////// #ifndef RESOURCEMANAGER_GUARD #define RESOURCEMANAGER_GUARD template <class T> class ResourceManager { private: // Data T* _resource; public: // Constructor explicit ResourceManager(T* resource) : _resource(resource) {} // Destructor ~ResourceManager() { if(_resource != nullptr) delete _resource; } // Access to underlying data T* Get() {return _resource;} }; #endif
Her er der også brugt templates, som jeg kommer ind på senere, men bemærk hvordan pointeren _resource
bliver initialiseret i constructorens initializer list, og bliver frigivet igen i destructoren.
Du kan bruge ResourceManager
-klassen således:
#include <iostream> #include "vektor.h" #include "resourcemanager.h" int main() { ResourceManager<Vektor2D> pv(new Vektor2D(1,1)); std::cout << "pv: " << pv.Get()->X << " , " << pv.Get()->Y << std::endl; return 0; }
Bemærk at der allokeres hukommelse med new
direkte i ResourceManager
-constructoren til objektet pv
, og objektet pv
sørger selv for at frigive hukommelsen igen når det går ud af scope.
Smart pointers
Der findes en række resource managers i STL til at håndtere allokeret hukommelse: de såkaldte smart pointers.
Der findes forskellige typer smart pointer, og den mest interesant er efter min mening std::shared_ptr
, der på mange måder fungerer som en almindelig pointer.
I modsætning til ResourceManager
-klassen ovenfor, kan du sagtens have flere std::shared_ptr
-instanser, der peger på samme objekt på heapen.
Når det sker, holder de selv styr på, hvor mange std::shared_ptr
-instanser der peger på samme objekt, og frigiver først hukommelsen når der er ikke er flere instanser af std::shared_ptr
,
der peger på objektet.
Følgende eksempel illustrerer hvordan std::shared_ptr
virker:
{ std::shared_ptr<Vektor2D> pv1 = nullptr; { std::shared_ptr<Vektor2D> pv2 = std::make_shared<Vektor2D>(1 , 1); pv1 = pv2; } // pv2 går ud af scope (hukommelse IKKE deallokeret, idet pv1 stadig peger på det) } // pv 1 går ud af scope (hukommelse deallokeres først HER)
I stedet for direkte at allokere hukommelse med new
(eller f.eks. malloc
), så bruges std::make_shared
, der netop laver en smart-pointer af typen std::shared_ptr
.
På denne måde kommer man ikke direkte i "kontakt" med allokering/deallokering af hukommelse - og alene dét gør at man nemmere undgår memory leaks.
Bemærk hvordan hukommelse bliver allokeret i det inderste scope, men bliver først deallokeret når både pv1
og pv2
går ud af scope.
Lambda-expressions
Lambda-expressions er en nem måde at definere funktions-objekter på, som f.eks. kan sendes med som parametre til funktioner (i stedet for at bruge funktions-pointers). Her er et eksempel:
// Lav en lambda-funktion auto lambda = [] { std::cout << "Hello World!" << std::endl; }; // Kald lambda-funktionen to gange lambda(); lambda();
Hvis du ikke har set Lambda-expressions før, bør du læse op på dem - særligt i forhold til captures, som giver mulighed for at sende lokale variable med over i funktions-objektet.
Templates
Templates gør det muligt at definere klasser eller funktioner, som kan bruges til at håndtere forskellige data-typer. Når en template bruges, specificeres så en bestemt type mellem < og >.
Bemærk at templates blev brugt ovenfor - både STL-typer som f.eks. std::vector<TYPE>
og std::shared_ptr<TYPE>
,
men også den resource manager-klasse, som blev vist i forbindelse med RAII brugte templates.
Templates er et stort emne, og man kan gøre virkelig meget med dem - men de bliver nok mest brugt i forbindelse med udvikling af kode-biblioteker, som ofte gerne skal være så generelle som muligt. Det er derfor ikke det første emne man bør læse op på; man skal i første omgang bare være opmærksom på at det findes.