Nighthawk.dk

Fra C til Moderne C++

Skrevet d. 7. Sept. 2020 af Claus Nielsen.

Kender du allerede til C men vil i gang med C++? Her beskriver jeg de væsentligste nye elementer, du skal lære.

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 LengthVektor2D-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.

Relaterede ressourcer
Effective C++

Scott Meyers

Effective Modern C++

Scott Meyers