Introduzione al linguaggio C++

Note integrative al corso di Grafica Interattiva, corso di Laurea in Informatica, nuovo ordinamento.
A cura di Paola Magillo, DISI, Universita' degli Studi di Genova.

Supponiamo di avere gia' visto

Il linguaggio C++

Differenze tra l'object-orientation di C++ e Java

  1. In Java tutto cio' che il programmatore definisce (funzioni, variabili) deve essere all'interno di qualche classe. In particolare la funzione "main".
    In C++ possiamo definire variabili e funzioni fuori dalle classi (come in C). In particolare la funzione "main" e' come in C.
  2. In Java tutti i tipi che l'utente definisce sono classi.
    In C++ possiamo definire anche i tipi classici del C (es. struct).
  3. Java ha ereditarieta' semplice (una sola superclasse).
    C++ ha ereditarieta' multipla.
  4. Java non ha tipi puntatori e gestisce automaticamente l'allocazione/deallocazione della memoria mediante garbage collection.
    C++ ha tipi puntatori (come in C) ed e' il programmatore che deve gestire l'allocazione/deallocazione della memoria quando serve (come in C).
  5. C++ punta all'efficienza (come il C), mischia caratterisctiche object-oriented e non, e' un "compromesso"
  6. Altre differenze che vedremo strada facendo...

    Richiamo sui tipi in C

    Tipi puntatori

    Puntatore = variabile che contiener l'indirizzo di una locazione di memoria contenente valori di un certo tipo (per esempio l'indirizzo di una variabile di quel tipo).

    Definizione di variabile di tipo puntatore:
    int * p; puntatore a int
    void * q; puntatore di tipo generico, il tipo di NULL

    Memorizzare in un puntare l'indirizzo di una variabile: int x; p = &x;

    Accedere (es. per modificare) il contenuto della locazione puntata da un puntatore: (*p) = 15;

    La definizione int *p; dice che *p (il contenuto della locazione puntata da p) e' di tipo int.

    Quando la stessa locazione e' accessibile attraverso piu' variabili, le modifiche si ripercuotono su tutte, esempio:
    int x;
    int *p;
    p = &x;
    x = 10; /* qui anche (*p)=10 */
    (*p) = 18; /* qui anche x=18 */

    Tipi array

    Array = sequenza di locazioni di memoria tutte dello stesso tipo.

    Definizione:
    int a[10]; array di 10 posti, contenuto non inizializzato
    int b[10] = { 1, 2, 3 }; array di 10 posti, inizializzati i primi tre

    Solo in inizializzazione posso assegnare tutti gli elementi assieme, altrimenti dovrei scrivere int b[3]; b[0]=1; b[1]=2; b[2]=3;

    Array a piu' dimensioni: float tabella [4][3];
    Per i=0...3 tabella[i] e' array di tre float, tabella[i][j] e' un singolo float.

    Rapporto fra puntatori ed array

    Anche gli array sono puntatori. Una variabile di tipo array e' il puntatore alla prima posizione dell'array.
    int a[10];
    int * p;
    p = a;
    e' lecito
    p[3] = 14; e' lecito

    Pero' ci sono differenze:

    Allocazione dinamica di memoria

    Array la cui lunghezza e' nota a priori:
    #define MY_SIZE 10
    int a[MY_SIZE];

    Array la cui lunghezza sara' nota solo a run-time.
    int my_size; il valore sara' noto dopo
    int * a; deve diventare un array lungo my_size

    Allocazione dell'array dinamico a:

    Se tutto ok restituiscono un puntatore all'indirizzo della prima locazione della sequenza, se falliscono restituiscono NULL. Il puntatore restituito e' di tipo generico void *, e' necessario un cast (conversione di tipo) per portarlo al tipo puntatore voluto (qui int *).

    Deallocazione dell'array dinamico a:

    Tutte queste sono funzioni di libreria. Un programma per poterle usare deve contenere la linea #include <stdlib.h>

    Tipi strutturati

    Struttura = tipo composto da variabili di altri tipi (detti "campi" della strutura).

    Dichiarazione del tipo: struct point { float x; float y; };

    Definizione di variabili: struct point p1;

    Accesso ai campi:

    Puntatori e passaggio di parametri

    In C tutti i parametri delle funzioni sono passati per valore.
    Il passaggio per riferimento si ottiene passando (per valore) un puntatore, esempio:

    Se abbiamo float x,y; bisogna chiamare swap(&x, &y);

    Attenzione: gli array sono sempre passati per riferimento perche' un array e' il puntatore al suo primo elemento. Esempio:

    Se abbiamo float b[2]; la chiamata swap(b) scambia i due elementi nell'array.
    E' equivalente dichiarare la funzione come swap(float a[]) oppure swap(float * a)

    Se devo passare delle strutture come parametro, per efficienza conviene passare un puntatore alla struttura, non la struttura stessa. Esempio:

    anche se la funzione di stampa non cambiera' il valore del punto.

    Classi in C++

    In C abbiamo un tipo struttura ed operazioni che vi agiscono, esempio:

    struct point {  float x; float y;  };
    void print_point(struct point * p) 
    {  printf("Point %f,%f", p->x, p->y);  }
    void set_point(struct point * p, float x0, float yo)
    {  p->x = x0; p->y = y0;  }
    

    In java come in C++ abbiamo una classe che ingloba struttura e funzioni.
    In Java:

    class point
    {  public: 
         float x; float y;
         void print_point()
         {  System.out.println("Point (" + x + "," + y + ")");
         void set_point(float x0, float y0)
         {  x = x0; y = y0;  }
         point(float x0, float y0)
         {  set_point(x0,y0);  }
    };
    

    In C++:
    class point
    {  public: 
         float x; float y;
         void print_point(void);
         void set_point(float x0, float y0);
         point(float x0, float y0);
    };
    
    void point :: print_point(void)
    {  printf("Point %f,%f", x, y);  }
    
    void point :: set_point(float x0, float y0) {  x = x0; y = y0;  }
    
    point :: point(float x0, float y0) {  set_point(x0,y0);  }
    

    Sia in java che in C++:

    Differenze del C++ rispetto a java:

    Dichiarazioni inline

    Se le implementazioni delle funzioni sono molto semplici, posso scriverle direttamente nella dichiarazione della classe premettendo la parola chiave "inline":

    class point
    {  public: 
         float x; float y;
         inline void print_point(void)
         {  printf("Point %f,%f", x, y);  }
         inline void set_point(float x0, float y0)
         {  x = x0; y = y0;  }
         inline point(float x0, float y0)
         {  set_point(x0,y0);  }
    };
    
    Questo autorizza (ma non obbliga) il compilatore a sostituire le chiamate alla funzione con la trascrizione delle linee di codice che appaiono nel suo corpo (e' simile a "#define" del C con la differenza che la sostituzione puo' anche non avvenire, dipende dal compilatore usato).

    Overloading

    In C++ come in java e' ammesso avere in una classe piu' funzioni con nome uguale e diversi argomenti.
    Il compilatore sceglie quella giusta da chiamare guardando gli argomenti.

    Nell'esempio potrei avere un altro costruttore:

       inline point(void) {  set_point(0,0);  }
    
    Qui esemplificato su costruttore, vale per qualsiasi funzione definita in una classe.

    Pubblico, privato e...

    Le dichiarazioni di variabili e funzioni in una classe possono essere precedute dalle parola chiave:

    Creazione di un oggetto

    Nella definizione della variabile bisogna specificare i parametri per il costruttore.
    class point p1(10,24); oppure
    class point p1();.

    Poiche' esiste costruttore senza parametri e' lecito anche class point p1; che equivale a class point p1();

    Puntatori ad oggetti

    Come per le struct, spesso non si usano oggetti di una classe ma puntatori ad oggetti. Esiste nuova sintassi per allocare/deallocare puntatori ad oggetti.

    Variabili di classe

    Variabile di classe = ne esiste una sola copia condivisa da tutti gli oggetti della classe. Va specificata come "static".
    Nell'esempio:

    class point p1; /* crea p1 (0,0) */ 
    p1.init_val = 16;
    class point p2; /* crea p1 (16,16) */
    
    La variabile di classe e' accessibile tramite qualunque oggetto della classe.

    Parametri per default

    Se nell'esempio dichiariamo set_point come:

    void set_point(float x0=0, float y0=0);
    
    llora possiamo chiamare:

    I parametri di default possono essere piu' di uno ma devono essere tutti in fondo.
    point(float x0, float y0=0); e' lecito
    point(float x0=0, float y0); non lo e'

    Non si possono saltare parametri nel mezzo di una chiamata.
    se ho void set_point(float x0=0, float y0=0);
    nella chiamata p1.set_point(26); il parametro mancante (per cui si usa il default) e' sempre y.

    I parametri di default si specificano nella dichiarazione (la parte tra class point { ... };), non nell'implementazione della funzione (la parte con void point :: set_point(...);).

    Classi con allocazione dinamica di memoria

    Esempio: la pixmap.

    In astratto una pixmap e' una matrice contenente valori di colore RGB da trascrivere su schermo.
    In OpenGL i valori RGB devono essere memorizzati su 8 bit, gli elementi della matrice vanno memorizzati tutti di seguito (riga per riga) in un array unidimensionale.

    struct OnePixel
    {  unsigned byte r; unsigned byte g; unsigned byte b;  };
    
    class Pixmap
    {  public:
         int w; int h; /* largezza, altezza */
         struct OnePixel * pixels; /* array dinamico di pixel */
         inline void make(int ww, int hh)
         {  pixels = (struct OnePixel *) 
                     calloc(ww*hh, sizeof(struct OnePixel));
            if (pixels) {  w = ww; h = hh;  }
            else {  w = h = 0;  }
         }
         inline void kill(void) 
         {  if (pixels) {  free(pixels); pixels = NULL;  }
            w = h = 0;
         }
         inline Pixmap(void)
         {  w = h = 0; pixels = NULL;  }
         inline ~Pixmap(void)  {  kill();  }
         inline int pos(int i, int j) {  return (i+w*j);  }
    }; 
    
    La funzione make alloca l'array dinamico. Oltre al costruttore vi e' un distruttore (funzione con lo stesso nome della classe preceduto da ~, senza parametri ne' in ingresso ne' in uscita) che dealloca l'array dinamico. La funzione pos ritorna la posizione nell'array unidimensionale del pixel che ha coordinate (i,j) nella matrice bidimensionale.

    struct Pixmap * m = new Pixmap();
    m->make(256,400); alloca l'array
    delete m; dealloca chiamando distruttore

    Ereditarieta' in C++

    Come in java, sottoclasse eredita tutte le variabili e funzioni della superclasse, puo' aggiungerne altre.
    Esempio:

    class point3D : public point
    {
      public:
        float z;
        inline point3D(float x0, float y0, float z0) : point(x,y)
        {  z = z0;  }
    };
    

    La superclasse e' indicata con la sintassi ":", in C posso avere piu' di una superclasse (ereditarieta' multipla).

    Ponendo "public" o "private" prima del nome della superclasse si specifica se le parti ereditate (variabili, funzioni) devono essere pubbliche o private nella sottoclasse.

    Le parti dichiarate "private" nella superclasse non possono essere usate nella sottoclasse. Usare nella superclasse "protected" per impedirne l'uso all'esterno ma lasciarle usare alle sottoclassi.

    La sottoclasse ha bisogno di un suo costruttore.

    Il costruttore della sottoclasse puo' richiamare quello della superclasse solo come prima istruzione con sintassi speciale.

    Polimorfismo

    Posso reimplementare nella sottoclasse funzioni della superclasse. La stessa funzione ha piu' implementazioni in classi diverse.
    Se nella sottoclasse voglio chiamare una funzione polimorfa prendendo l'implementazione della superclasse, uso la sintassi "::".

    Es: in point3D posso mettere:

      void print_point()
      {  point::print_point(); /* stampa x,y */
         printf(",%f", z); /* aggiunge ,z per avere x,y,z */
      }
    

    Funzioni virtuali

    Consideriamo il caso:

    class point * ptr;
    ptr = new point3D(...);
    ptr->print_point();
    

    La variabile ptr ha tipo superclasse point ma contiene un oggetto di tipo sottoclasse point3D.
    Quale implementazione di print_point viene chiamata?

    In java sarebbe chiamata quella della sottoclasse point3D.
    Java ha binding dinamico, cioe' determina l'implementazione da usare a run-time guardando il tipo dell'oggetto contenuto nella variabile in questo momento dell'esecuzione.
    E' piu' corretto dal punto di vista object-oriented ma meno efficiente.

    In C++ viene chiamata quella della superclasse point.
    C++ ha binding statico, cioe' determina l'implementazione da usare a compile-time in base al tipo della variabile.
    E' meno corretto dal punto di vista object-oriented ma piu' efficiente.

    Posso dichiarare che per una certa funzione voglio binding dinamico mettendo davanti alla dichiarazione della funzione nella superclasse point l'indicazione "virtual":

    virtual init_point(...)
    
    Allora anche C++ chiama l'implementazione della sottoclasse point3D.

    Classi astratte

    Una classe e' astratta se di alcune funzioni non definisce l'implementazione, ma le dichiara soltanto.
    L'implementazione sara' data nelle sottoclassi.
    La classe puo' anche contenere altre funzioni implementate. Ne basta una non implementata per rendere astratta la classe.

    Esempio: classe astratta lettore di pixmap, dichiara una funzione di lettura che avra' implementazioni diverse nelle sottoclassi. Una sottoclasse per ogni formato di file da cui si puo' leggere una pixmap (RGB, GIF, BMP...).

    class PixmapReader
    {
      public:
    
      virtual int readPixmap(FILE *fd, class Pixmap *map) = 0;
    };
    
    La funzione e' dichiarata "virtual" ed inizializzata con "=0".

    Non posso creare oggetti di classe PixmapReader, posso crearli solo delle sue sottoclassi.

    Esempio: sottoclasse lettore di pixmap da file in formato RGB:

    class RGBreader : public PixmapReader
    {
      private:   
         ...variabili e funzioni ausiliarie...
      public:
    
        RGBreader(void); /* costruttore */
        ~RGBreader();    /* distruttore */
        int readPixmap(FILE *fd, class Pixmap *map);
        /* funzione ereditata e implementata */
    };
    
    int RGBreader :: readPixmap(FILE *fd, class Pixmap *map)
    { ... }
    
    Allora posso scrivere:

    Differenze tra l'ambiente di sviluppo C++ e Java

    1. Java e' prima compilato in bytecode, poi interpretato sulla macchina che lo esegue
      C++ (come C) e' compilato direttamente in codice eseguibile della macchina
    2. Quando compilo un file in Java il compilatore trova automaticamente tutti i file che contengono definizioni usate in quel file (purche' siano nella directory corrente)
      In C++ (come in C) devo dire esplicitamente in quali file vanno cercate le definizioni usate nel file che sto compilando (direttive #include)
    3. Quando eseguo il file principale di un programma in Java l'interprete trova automaticamente tutti gli altri file del programma (purche' siano nella directory corrente)
      Quando compilo il file principale di un programma in C++ devo dire esplicitamente al compilatore quali altri file vanno compilati assieme per creare l'eseguibile del programma (come in C)
    4. Java e' un linguaggio "grosso", contiene parecchi "packages" di class predefinite per scopi specifici (es. package AWT o Swing per realizzare interfacce grafiche) che fanno parte integrante del linguaggio
      C++ e' (come C) un linguaggio "piccolo", contiene solo l'indispensabile, ma si possono usare librerie (es. OpenGL e Glut sono librerie per la grafica). Vi e' un insieme di librerie standard fornite con il linguaggio (es. per I/O...)

      Organizzazione di un programma in moduli

      Modulo = sottocomponente di programma compilabile separatamente.

      Un modulo java coincide con una classe e con il file che contiene la classe e che ne porta il nome.

      In C++, come in C, il concetto di modulo esiste a prescindere dalle classi.

      In generale un programma C (vale anche per C++) e' composto da:

      • Un modulo principale, contenuto in un file, es. main.c, che definisce la funzione "main"

      • Vari moduli secondari (anche nessuno), ognuno diviso in due file, es. aux.h e aux.c

        • aux.h, detto header o instestazione, contiene la dichiarazione di tutto cio' che e' implementato in aux.c e che deve essere usato anche in altri moduli.
        • aux.c contiene la definizione (implementazione) di cio' che e' dichiarato e non definito in aux.h, piu' eventualmente altro usato solo in aux.c
        Sia main.c che aux.c contengono in testa la direttiva #include "aux.h" che serve a "vedere" le dichiarazioni contenute in aux.h.

      Esempio C:

      /* file aux.h */
      #ifndef AUX_H
      #define AUX_H
      struct {  float x; float y  } point;
      extern float init_val;
      extern void init_point(struct point * p);
      #endif
      
      /* file aux.c */
      #include "aux.h"
      float init_val = 0;
      void init_point(struct point * p) { p->x = p->y = init_val;  }
      
      /* file main.c */
      #include 
      #include "aux.h"
      int main(void)  {  struct point a; set_point(&a);  }
      

      • In aux.c e main.c la direttiva #include trascrive il contenuto del file incluso nel file includente prima di compilare.
      • In aux.h le direttive #ifndef, #define, #endif servono ad impedire che il file sia trascritto due volte anche se per sbaglio venisse fatto #include "aux.h" due volte.
        La seconda volta la macro AUX_H e' gia' definita percio' quello fra #ifndef e #endif (cioe' tutto il file) non viene compilato.
      • In aux.h "extern" e' obbligatorio per le variabili, facoltativo per le funzioni.

      I sorgenti C++ talvolta hanno estensione .cpp o .cc invece che .c, per distinguerli dai sorgenti C. Tuttavia non e' vincolante.

      A differenza di Java, non e' obbligatorio che un modulo C++ coincida con una classe, pero' volendo assumere che sia cosi':

      • l'header aux.h contiene la dichiarazione della classe, cioe' la sua interfaccia
      • l'altro aux.c (o .cpp o .cc) contiene l'implementazione

      Esempio:

      /* file aux.h */
      #ifndef AUX_H
      #define AUX_H
      class point
      {  public: 
           float x; float y;
           void print_point(void);
           void set_point(float x0, float y0);
           point(float x0, float y0);
      };
      #endif
      
      /* file aux.c */
      #include 
      #include "aux.h"
      
      void point :: print_point(void)
      {  printf("Point %f,%f", x, y);  }
      
      void point :: set_point(float x0, float y0) {  x = x0; y = y0;  }
      
      point :: point(float x0, float y0) {  set_point(x0,y0);  }
      
      /* file main.c */
      #include "aux.h"
      int main(void)
      {  class point a(23,12);
         a.print_point();
      }
      

      Oppure usando il meccanismo "inline".
      /* file aux.h */
      #ifndef AUX_H
      #define AUX_H
      class point
      {  public: 
           float x; float y;
           inline void print_point(void)
           {  printf("Point %f,%f", x, y);  }
           inline void set_point(float x0, float y0)
           {  x = x0; y = y0;  }
           inline point(float x0, float y0)
           {  set_point(x0,y0);  }
      };
      #endif
      

      Il file main.c e' come prima. Siccome qui tutte le funzioni della classe sono "inline", il file aux.c non serve piu'. Se solo alcune funzioni fossero "inline", conterrebbe le implementazioni delle funzioni rimanenti.

      Compilazione di un programma

      Con fiferimento alla distribuzione gratuita della GNU

      • il compilatore C si chiama gcc
      • il compilatore C++ si chiama g++, e compila anche il C

      Supponiamo che il programma consista di un modulo principale (file main.c) e un altro modulo (file aux.h, aux.c). Se vi sono piu' moduli il discorso e' analogo.

      Sia in C che in C++ la compilazione puo' avvenire in due modi

      • In un passo solo con
        g++ aux.c main.c -o main
        dove -o main dice al compilatore che l'eseguibile risultante deve chiamarsi main (per default si chiama a.out).
      • Oppure in due passi con
        g++ -c aux.c
        g++ -c main.c
        g++ aux.o main.o -o main
        
        dove le prime due linee generano file oggetto (non ancora eseguibili) di estensione .o, l'ultima linea esegue il linking e genera l'eseguibile main.

      Makefile

      Il makefile e' un file di nome convenzionalmente makefile o Makefile che specifica una serie di "target" (di solito programmi eseguibili) e i comandi necessari per ottenerli (per compilarli). Per ogni target specifica anche quali file sono necessari per poterlo ottenere (dipendenze).

      In generale la sintassi del makefile e':

      target: lista di file
          comando
      
      dove
      • target e' il file che si vuole generare
      • la lista di file elenca i file occorrenti per generarlo
      • il comando spiega come fare
      Importante: lo spazio bianco all'inizio della riga contenente il comando e' inpispensabile e deve essere un carattere di TABULAZIONE, non una serie di caratteri SPAZIO.

      Il makefile per compilare il nostro programma conterra':

      • nel caso un passo solo
        main: main.c aux.c aux.h
            g++ aux.c main.c -o main
        
      • e nel caso due passi
        main: main.o aux.o aux.h
            g++ aux.o main.o -o main
        main.o: main.c aux.h
            g++ -c main.c
        aux.o: aux..c aux.h
            g++ -c aux.c
        

      La compilazione si ottiene poi con

      • make main
      • o semplicemente make se main e' il primo target che compare nel makefile.
      Viene stampato il comando che si esegue.

      Se il file ha un nome diverso da makefile o Makefile occorre aggiungere l'opzione -f nomefilemake al comando make.

      Utile se i comandi di compilazione sono lunghi da digitare. Inoltre makefile si "accorge" se un target ha bisogno di essere rifatto, guardando date e ora dei file elencati nella lista delle dipendenze.

      Altre cose da specificare in compilazione (opzioni al comando g++):

      • Flags (opzioni di compilazione) es. -ansi -g
      • Directory dove cercare i file inclusi con #include (di default solo la directory corrente), es. per gli header di OpenGL e Glut -I/usr/X11R6/include
      • Directory dove cercare le librerie eventualmente usate, es. per OpenGL e Glut -L/usr/X11R6/lib
      • Librerie da linkare assieme al programma es. OpenGL e Glut -lMesaGL -lMesaGLU

      Nel makefile e' possibile definire anche costanti cioe' nomi che si danno a certi pezzi di sintassi (tipo #define del C).
      Esempio: per il compilatore definire
      CC = g++
      e poi usare $(CC) al posto di g++
      In genere si definiscono variabili tutto: i flag, le directory include, le directory di libreria, le librerie...

      I commenti nel makefile sono preceduti da "#".