5. Van C naar C++

De ++ in C++

De introductie van C++ in 1985 geeft de ervaren C programmeur enkele extra mogelijkheden om zijn of haar code te structureren. C++ wordt nog steeds omschreven als een “general purpose” low-level taal op Wikipedia:

C++ is a general-purpose programming language. It has imperative, object-oriented and generic programming features, while also providing facilities for low-level memory manipulation.

“Object-oriented” en “generic” zijn de sleutelwoorden hier. De C++ taal is geschreven als een extensie van C waarbij high-level features gebruikt kunnen worden om code te structureren. Kenners van Java voelen hun hier waarschijnlijk iets meer thuis (abstracts, classes, generics, grote STL bibliotheek, overloading) en dat verbaast niemand: de Java taal is sterk beïnvloed door C++.

Dingen die opvallen: wel in C++(11), niet in C

Enkele belangrijke zaken die onmiddellijk opvallen:

  • de aanwezigheid van bool
  • los van std::string (bovenop char) en std::wstring (bovenop wchar_t), Extra Integral types:
    • wchar_t, char16_t, char32_t (voor Unicode)
  • Zelf te definiëren operaties:
  • class en alles wat daar mee te maken heeft
    • new, delete in plaats van malloc() en free()
  • nullptr in plaats van NULL,
  • smart pointers voor auto garbage collection
  • auto als type inference zoals var in C#, decltype in plaats van typeof
  • Exception handling

Classes in C++

Terug naar de is_oud opgave van hoofdstuk 1, maar dan in C++

Het is tijd om malloc() en struct achterwege te laten:

#include <iostream>

class Persoon {
private:
    int leeftijd;
public:
    Persoon(int leeftijd);
    bool isOud() const {
        std::cout << "checking leeftijd van persoon " << leeftijd << std::endl;
        return leeftijd > 60;
    }
    int getLeeftijd() const { return leeftijd; }
};

Persoon::Persoon(int leeftijd) {
    this->leeftijd = leeftijd;
}

int main() {
    auto jaak = new Persoon(70);
    std::cout << "is jaak oud? " << jaak->isOud() << std::endl;
}

Dat ziet er al heel wat properder uit:

  • We hebben een constructor gebruikt om een persoon aan te maken met leeftijd, zoals we kennen vanuit Java. De this pointer verwijst naar de huidige instance van de klasse.
  • We hebben methodes (inline) gedeclareerd in de klasse Persoon.
  • Jaak aanmaken hoeft geen type definitie als Persoon * met C++11’s auto keyword. Leer auto goed kennen en gebruiken: overtuig jezelf!

Type inference is in combinatie met top-level const en references niet zo triviaal, zie p.68.

De Klasse structuur

Een klasse definiëren is niet meer dan een beschrijving van een structuur. Binnen de class accolades { } leven niet alle declaraties van de functies in de klasse zelf! Dit is nog een erfenis van C. Elke klasse heeft zijn eigen scope en functies declareren doe je na de definitie met de scope operator :::

class Getal {
  private:
   int getal;
  public:
   int get() const { return getal; }
   void telOpMet(int ander);
   void vermenigvuldigMet(int ander);
};

void Getal::telOpMet(int ander) {
  this->getal += ander;
}
void Getal::vermenigvuldigMet(int ander) {
  this->getal *= ander;
}

De locatie van klassen

Twee van de drie methodes staan buiten de klasse definitie. We maken hier typisch twee files voor aan:

  1. getal.h waar de klasse definitie in leeft
  2. getal.cpp waar de methode declaraties in leven

Verschillende andere source files kunnen de Getal klasse gebruiken met #include "getal.h". De klasse kan maar 1x gedefiniëerd worden, en 2x de header includen in je programma geeft een compilatiefout:

Wouters-MacBook-Air:c-course-gba wgroenev$ g++ -std=c++11 dubbel.cpp getal.o
In file included from dubbel.cpp:4:
./getal.h:1:7: error: redefinition of 'Getal'
class Getal {
      ^
./getal.h:1:7: note: previous definition is here
class Getal {
      ^
1 error generated.

Dit lossen we op met een preprocessor constructie in de getal.h header file:

#ifndef _GETAL_H_
#define _GETAL_H_

class Getal { };

#endif

Circulaire dependencies

Zoals in veel andere programmeertalen, kan klasse 1 wel naar klasse 2 verwijzen, maar dan niet omgekeerd. Een mooi gesloten lus maken in schemavorm noemen we een circulaire dependency: object 1 is afhankelijk van object 2, die op zijn beurt terug afhankelijk is van object 1:

graph LR K1[class 1] K2[class 2] K1 --> K2 K2 --> K1q

Het is niet mogelijk om twee klassen naar elkaar te laten verwijzen door middel van een #ìnclude statement. De volgende foutboodschap schept hierin genoeg duidelijkheid:

In file included from ./inc2.h:2:
In file included from ./inc1.h:2:
In file included from ./inc2.h:2:
In file included from ./inc1.h:2:
In file included from ./inc2.h:2:
In file included from ./inc1.h:2:
[...]
./inc2.h:4:7: error: redefinition of 'Inc2'
class Inc2 {
      ^
./inc2.h:4:7: note: previous definition is here
class Inc2 {
      ^
fatal error: too many errors emitted, stopping now [-ferror-limit=]
20 errors generated.

Inline functies

Waarom is de eerste methode in de body gedeclareerd? Dit is een inline functie (zie doc). Dit zijn typisch one-liners die vaak aangeroepen worden en door de compiler geoptimaliseerd kunnen worden: de aanroep instructie vervangen door de implementatie, zoals de preprocessor doet met #define.

Wanneer schrijven we een inline functie?

  1. Duidelijkheid: Als de functie zeer klein is en die in de header file kan leven zodat gebruikers hiervan ondubbelzinnig kunnen zien wat dit doet.
  2. Optimalisatie kàn ook.

In-class initialization

Sinds C++ 11 kan je ook default values meegeven aan data members in de definitie van de klasse zelf. Dit klinkt belachelijk omdat zoiets in Java vanaf het begin al kon. In de private sectie van de Getal klasse kunnen we dus int getal = 5; zetten. Als er geen constructor deze member initialiseert wordt getal op 5 gezet.

“this” en Constante functies

De magische this variabele wordt impliciet aangemaakt zodra je een methode van een instantie op een klasse uitvoert. This is het adres van de locatie van diezelfde instantie:

auto g = new Getal();

g->telOpMet(5);
// compiler interpretatie: Getal::telOpMet(&g, 5);

Het const keyword achter de get methode verandert de this pointer naar een constante pointer. Op die manier kan de body van de methode geen wijzigingen doorvoeren, enkel opvragen. (Zie p. 258)

(auto-generated) Constructoren

Een klasse instantiëren roept de (default) constructor aan. Als er geen eigen gedefiniëerde constructor aanwezig is, genereert de compiler die voor u, net als in Java. Zodra je één constructor definiëert, zal C++ geen enkele zelf genereren.

class Getal {
  private:
    int *x;
  public:
   Getal(int x) : x(new int(x)) {}
};
auto g = new Getal(5);  // ok: eigen constructor aangeroepen
auto g = new Getal();   // error: Too few arguments, expects 1

De default constructor is makkelijk zelf te voorzien met Getal() {} maar met Getal() = default; zeggen we tegen de compiler dat hij expliciet wél eentje moet genereren.

Merk op dat we hier een memory leak introduceren door *x niet zelf op te kuisen! Als een klasse resources zoals pointers bevat is het de bedoeling dat deze zelf verantwoordelijk is voor de opkuis. Dit gebeurt in de destructor prefixed met ~:

class Getal {
  private:
    int *x;
  public:
   Getal() = default;
   ~Getal() { delete x; }
   Getal(int x) : x(new int(x)) {}
};

Java heeft geen destructors omdat objecten op de heap leven en door de Garbage Collector opgeruimd worden zonder invloed van de programmeur. Er is wel een finalize die je zelf kan aanroepen om resources op te kuisen. In C# wordt ook de ~Object(){} notatie gebruikt, maar dat is ook een soort van finalizer en geen échte destructor.

Een derde soort constructor, de “copy constructor”, wordt ook door C++ voorzien en aangeroepen wanneer de expressie getal1 = getal2 geëvalueerd wordt. De compiler maakt een nieuwe Getal instance aan en kopiëert alle velden over.

Dit is echter helemaal niet wat we willen als we resources als members hebben zoals *x: de pointer wordt gekopiëerd maar niet de inhoud. Beide getal instances verwijzen dan dezelfde x waarde:

Wat is de output van dit programma?

int main() {
    auto g = new Getal(5);
    auto g2 = g;
    g2->x = new int(10);
    cout << *(g2->x) << endl;   // ?
    cout << *(g->x) << endl;    // ?
}

Oeps. Voorzie in dat geval je eigen copy constructor met Getal(const Getal& other) : x(new int(*(other.x))) {}. Copy constructors kan je ook defaulten.

Methodes in Klassen en Reference types

Herinner je uit hoofdstuk 2 reference type definities zoals int &getal. Dit kan enkel worden gecompileerd met g++ en niet gcc. Deze notatie ga je veel tegen komen in C++ methode argumenten. Objecten die meegegeven worden zijn bijna altijd reference types in plaats van pointers. Waarom legt de C++ FAQ uit:

Use references when you can, and pointers when you have to. References are usually preferred over pointers whenever you don’t need “reseating”. This usually means that references are most useful in a class’s public interface. References typically appear on the skin of an object, and pointers on the inside.

Zo kunnen we in Getal methodes toevoegen die andere Getal instanties gebruiken:

// getal.h
class Getal {
  void telOpMet(const Getal &ander);
}
// getal.cpp
void Getal::telOpMet(const Getal &ander) {
  this->getal += ander.getal;
}
// dubbel.cpp, in main
auto g = new Getal();
auto nieuwGetal = new Getal();
nieuwGetal->telOpMet(*g);

We moeten g dereferencen om als reference mee te kunnen geven. const wordt hier gebruikt om zeker te zijn dat het binnenkomende getal niet gewijzigd kan worden. Alternatief kunnen we new twee keer weglaten: auto g = Getal(); en dan nieuwGetal.telOpMet(g);.

Wanneer gebruik ik “new” en wanneer niet?

Een pointer naar een object in het geheugen aanmaken (en geheugen reserveren) met new vereist dat je die zelf opkuist met delete! Als je dit niet doet blijft dat geheugen bezet en krijg je wat men noemt “memory leaks”: hoe langer men je programma gebruikt, hoe meer geheugen het (ongewenst) in beslag neemt.

Om dat te vermijden gebruik je best binnen functies nooit new:

void telOpAutoClean() {
  auto g = Getal(3);
  g.telOpMet(5);
  // block eindigt: g wordt opgekuist
}
void telOpManualClean() {
  auto g = new Getal(5);
  g->telOpMet(5);
  // block eindigt: g blijft bestaan!
  delete g;
}

Getal(3) zonder new ziet er vreemd uit als je talen als C# en Java gewoon bent, maar de constructor wordt evenzeer aangeroepen en een object wordt evenzeer voor je geïnstantieerd.

Zonder new: object op de stack—dit is géén pointer. Denk aan C’s struct type.

Met new: object op de heap—dit is wél een pointer. Denk aan C’s *struct type.

C++ & De Stack/Heap

Zoals gekend uit het hoofdstuk over de stack & de heap worden lokale variabelen op de stack bewaard. De stack bevat een tijdelijke workspace aan geheugen wanneer functies aangeroepen worden, die automatisch opgeruimd worden als die functies klaar zijn met hun werk. De Stack is een LIFO lijst.

Deze functionaliteit verandert niet als we de gcc compiler vervangen door g++! Je kan echter dankzij functionaliteiten van C++11 bepaalde tekortkomingen gedeeltelijk wegwerken, zoals het automatisch opruimen van dangling pointers. Daar dienen smart pointers voor.

Smart pointers in C++ 11

In plaats van zelf geheugen te beheren—we hebben immers wel wat beters te doen—laten we dat over aan de taal door “smart pointers” te gebruiken. Dit zijn STL wrappers (zie onder) die een object encapsuleren. Als een block stopt, kuist deze smart variabele je wrapped object zelf ook op:

unique_ptr<Getal> g (new Getal());                // zonder auto
auto nieuwGetal = unique_ptr<Getal>(new Getal()); // met auto
nieuwGetal->telOpMet(*g);

We komen later nog op de template <> notatie terug. Neem hier aan dat dit werkt als Generics in Java.

Zie p.470 van Smart pointers in modern C++ van de Microsoft docs, of de Smart pointer reference van cppreference.com.

Vergeet niet dat smart pointers niet werken in combinatie met variabelen op de stack (dus altijd new gebruiken). Stel dat een klasse een referentie naar een unique_ptr heeft die automatisch de pointer zou moeten opkuisen:

class Holder {
  public:
   unique_ptr<Iets> autoDeleted;
   Holder() {
    Iets iets("1");
    this->autoDeleted = unique_ptr<Iets>(&iets);
   }
}

De deconstrutor van de holder gaat automatisch de waarde die autoDeleted vasthoudt terug vrijgeven, maar de variabele iets bestaat al niet meer omdat die enkel op de stack binnen de constructor functie leeft. &iets verwijst nu naar “niks” en dit crasht.

De C++ Standard Library

Merk op dat in bovenstaande Persoon klasse printf() verdwenen is. In C++ gebruiken we streams: cout als stdout en cin als stdin. Deze leven in de std namespace zodra je iostream include. Laat de “.h” suffix achterwege bij het includen van systeembibliotheken van C++. Laat het maar aan de compiler over om de systeembestanden te zoeken tijdens het linken.

De Standard Template Library STL is een bibliotheek die meegeleverd wordt bij de meeste moderne C++ compilers waar iostream in leeft. Deze implementeert de nieuwe standaarden, zoals C++11. Compilers vragen soms wel een flag om te kiezen met welke library er gelinkt wordt: g++ -std=c++11.

STL bevat een hoop dingen die je het leven makkelijker maakt: strings (gek genoeg nog steeds geen deel van de taal zelf), collecties, streams, IO, … Bekijk het als de .NET library voor de C# taal of de meegebakken java.* klassen en methodes voor Java. “Part II: The C++ Library (p. 307)” behandelt deze zaken in het handboek.

In plaats van constant std::cout te moeten typen kunnen we alles wat in die namespace zit ook “importeren” zoals een Java import java.io.* met using namespace std;. cout is een instantie van de klasse ostream.

Initialisatie van objecten

In C++ kan je op twee manieren aan objecten een waarde toekennen (Zie p.43). Het is belangrijk om de verschillende nuances te kunnen onderscheiden omdat met objecten verschillende constructoren gemoeid zijn.

Direct initialization

int x(5) of Getal g(5)

Hiervoor is een constructor (met argument) nodig.

Waarom zou je () doen in plaats van =? Omdat impliciete conversie enkel via direct initialization gebeurt. Stel, ik wil een string in de constructor aanvaarden. Strings met quotes in C++ zijn nog steeds char arrays vanuit C. Dit gaat niet:

// C++
class Groet {
public:
  Groet(std::string s) {}
  void zegIets() {};
};
Groet heykes = "sup";  // error: no conversion from const char[3] to Groet
Groet heykes("sup"):   // ok: impliciete conversie
Groet hekyes = "sup"s; // ok: char[] zelf omgezet, zie strings sectie
Groet heykes;          // ok: heykes is nieuw leeg Groet object op stack
heykes.zegIets();      // ok

Vergeet niet dat het verschil tussen C++ en Java op gebied van initialisatie groot is! In Java zijn objecten na hun declaratie altijd null:

// Java
class Groet {
  public Groet(String s) {}
  public void zegIets() {}
}
Groet heykes("sup");  // error: impliciete conversie gaat nooit lukken
Groet heykes;         // ok: heykes verwijst naar null
heykes.zegIets();     // error: NullPointerException
heykes = new Groet(); // ok: object op heap aangemaakt
heykes.zegIets();     // ok

Copy initialization

int x = 5 of Getal g = Getal(5) of int x = { 5 }

Hiervoor is een copy constructor nodig.

Merk op dat gebruik van accolades { } eigenlijk copy initialisatie doorvoert, evenals single return statements als return 3 + 4. Dit kan je omzetten naar direct initialization met int retval(3 + 4); return retval;.

Constructors en copy constructors hoeven niet zelf aangemaakt te worden: C++ voorziet defaults. Een lege constructor maakt een leeg object aan. Een lege copy constructor voert eenm ember-wise (shallow) copy door. Als je die toch wil maken doe je dat zo:

class Punt {
  private:
   int x, y;
  public:
   Punt() : x(1), y(1) { }                              // default
   Punt(const Punt& other) : x(other.x), y(other.y) { } // copy
}

Strings

std::string in #include <string> is de vervanger van de rudimentaire char* in C++. Met strings kan je zoals in Java dingen doen als:

  • Grootte opvragen met .size() of .empty()
  • Concatenaties doorvoeren met de + operator
  • Karakters opvragen met []
  • Vergelijkingen uitvoeren met <(=), >(=)

De grootte van een strings is niet een int maar een std::size_type “companion type” om STL op eender welke machine op dezelfde manier te kunnen gebruiken (p.88). Dat is een van de nadelen van C(++) doordat we voor een specifiek systeem compileren.

Karakters individueel behandelen

Gebruik de C++ 11 range for notatie die we van C# en Java kennen:

auto str = string("sup");
for(auto c : str) {
  cout << c;
}

Als c een reference is kan je in de loop zelf de karakters ook wijzigen.

Collecties: Vector

In plaats van met vaste array waardes te werken kunnen we ook lijsten gebruiken. STL voorziet er een aantal, waarvan vector de belangrijkste is - Java’s ArrayList tegenhanger. Vanaf C++ 11 kan je die ook snel initialiseren met copy initialization:

vector<string> tekst = { "roe", "koe", "zei", "de", "duif" }; // lijst van 5 strings
vector<string> leeg;        // lege lijst van strings, grootte 9
tekst = leeg;               // copy constructor gebruikt; tekst is nu ook leeg
tekst[0] = "nul";           // segmentation fault: tekst heeft size 0
tekst = vector<string>(1);  // lege lijst van strings, grootte 1
tekst[0] = "nul";           // ok

p.98 of cppdocs bevat basis manipulaties voor vectoren, zoals:

  • elementen toevoegen met push_back()
  • loopen over elementen met for(auto i : v)
  • grootte controleren met size() en empty() zoals std::string
  • correct gebruik van iterators begin() en end()

Oefeningen

  1. bibliothecaris HF2 redux: herimplementeer de bibliothecaris oefening in C++. Let op de verplichte aanwezigheid van:
  • een klasse Bibliotheek die een lijst van boeken (als simpele string) bevat
  • een klasse Bibliothecaris die de operaties op de bibliotheek uitvoert
  • cin >> var om de boeken van de gebruiker in te lezen.
  • sorteerfuncties van STL
  1. GBA games worden vanwege het beperkt geheugen altijd in C in plaats van C++ ontwikkeld. Wij gaan daar geen rekening mee houden, en toch overschakelen. Herimplementeer deze Download, de opgave van hoofdstuk 4, in C++. Welke klasses heb je nodig? Denk aan de paddle, de ball, enzovoort. De C++ nodige cross-compiler is arm-none-eabi-g++.
  2. Extra: maak een Dierentuin klasse. Een dierentuin kan verschillende dieren (Dier klasse) ontvangen (ontvang() functie). Elk dier heeft een grootte en een naam: Neushoorn(40), Giraf(25), Poema(10). Elke dierentuin heeft x beschikbare ruimte. Wat doet het bestuur van je dierentuin als het te ontvangen dier te groot is?
    Bijvoorbeeld: dierentuin(20), leeuw(15) en panda(10). 15 + 10 > 20.
  3. Extra: We starten met een taxi bedrijf dat chauffeurs in dienst neemt en wagens koopt om mee rond te rijden. Welke klassen denk je nodig te gaan hebben, en waarom? Teken eerst een model en trek pijlen die relaties voorstellen. Voorzie ook het concept “klant”, die kan vervoerd worden. Welke methodes ga je voorzien in je klassen?
  4. Extra: Orc HF1 redux: herimplementeer het Orc model in een C++ klasse (opgave 2 en 3). Let op met memory leaks als orcs dood gaan! Hoe ziet de oude C functie Orc vecht(Orc aanvaller, Orc verdediger) er nu uit?

Vergeet het volgende niet:

Denkvragen

  1. Kan je je een situatie inbeelden waarin het gebruik van raw pointers in een methode van een klasse toch aangewezen is?
  2. Wat is het fundamenteel verschil tussen een struct in C en een class in C++?
  3. Wat betekent de foutboodschap “Segmentation fault” precies?
  4. Wanneer wordt een copy constructor aangeroepen? Leg aan de hand daarvan het verschil tussen initialisatie en toekenning uit.