Passaggi successivi

Introduzione alla programmazione e a C++

Questo tutorial online prosegue con concetti più avanzati; consulta la Parte III. In questo modulo ci concentreremo sull'uso dei puntatori e su una guida introduttiva agli oggetti.

Impara con l'esempio 2

In questo modulo ci concentreremo su come fare più pratica con la decomposizione, comprendere suggerimenti e iniziare a utilizzare oggetti e classi. Esamina i seguenti esempi. Scrivi i programmi personalmente quando ti viene chiesto o esegui gli esperimenti. Non possiamo sottolineare abbastanza che la chiave per diventare un buon programmatore è la pratica, l'esercizio fisico.

Esempio 1: ulteriore pratica di decomposizione

Considera il seguente output di un gioco semplice:

Welcome to Artillery.
You are in the middle of a war and being charged by thousands of enemies.
You have one cannon, which you can shoot at any angle.
You only have 10 cannonballs for this target..
Let's begin...

The enemy is 507 feet away!!!
What angle? 25<
You over shot by 445
What angle? 15
You over shot by 114
What angle? 10
You under shot by 82
What angle? 12
You under shot by 2
What angle? 12.01
You hit him!!!
It took you 4 shots.
You have killed 1 enemy.
I see another one, are you ready? (Y/N) n

You killed 1 of the enemy.

La prima osservazione è il testo introduttivo, che viene visualizzato una volta per ogni esecuzione del programma. È necessario un generatore di numeri casuali per definire la distanza dei nemici per ogni round. Abbiamo bisogno di un meccanismo per ottenere l'input dell'angolazione dal player, che ovviamente ha una struttura ad anello perché si ripete finché non colpiamo il nemico. Abbiamo anche bisogno di una funzione per calcolare la distanza e l'angolo. Infine, dobbiamo tenere traccia di quanti colpi sono stati necessari per colpire il nemico e di quanti nemici abbiamo colpito durante l'esecuzione del programma. Ecco una possibile struttura per il programma principale.

StartUp(); // This displays the introductory script.
killed = 0;
do {
  killed = Fire(); // Fire() contains the main loop of each round.
  cout << "I see another one, care to shoot again? (Y/N) " << endl;
  cin >> done;
} while (done != 'n');
cout << "You killed " << killed << " of the enemy." << endl;

La procedura Fire gestisce il gioco. In questa funzione, chiamiamo un generatore di numeri casuali per conoscere la distanza dei nemici, quindi configuriamo il ciclo per ottenere l'input del giocatore e calcolare se ha colpito o meno il nemico. La condizione di guardia indica quanto siamo vicini al nemico.

In case you are a little rusty on physics, here are the calculations:

Velocity = 200.0; // initial velocity of 200 ft/sec Gravity = 32.2; // gravity for distance calculation // in_angle is the angle the player has entered, converted to radians. time_in_air = (2.0 * Velocity * sin(in_angle)) / Gravity; distance = round((Velocity * cos(in_angle)) * time_in_air);

A causa delle chiamate a cos() e sin(), dovrai includere math.h. Prova a scrivere questo programma: è un'ottima pratica nella scomposizione dei problemi e una buona revisione del codice C++ di base. Ricorda di eseguire una sola attività in ogni funzione. Questo è il programma più sofisticato che abbiamo scritto finora, quindi potrebbe volerci un po' di tempo.Questa è la nostra soluzione. 

Esempio 2: esercitarti con i puntatori

Quando lavori con i puntatori, devi ricordare quattro cose:
  1. I puntatori sono variabili che contengono indirizzi di memoria. Durante l'esecuzione di un programma, tutte le variabili vengono memorizzate in memoria, ognuna con un indirizzo o una posizione univoci. Un puntatore è un tipo speciale di variabile che contiene un indirizzo di memoria anziché un valore di dati. Così come i dati vengono modificati quando viene utilizzata una variabile normale, il valore dell'indirizzo memorizzato in un puntatore viene modificato come una variabile di puntatore. Ecco un esempio:
    int *intptr; // Declare a pointer that holds the address
                 // of a memory location that can store an integer.
                 // Note the use of * to indicate this is a pointer variable.
    
    intptr = new int; // Allocate memory for the integer.
    *intptr = 5; // Store 5 in the memory address stored in intptr.
          
  2. Di solito diciamo che un puntatore "rimanda" alla posizione che sta memorizzando (il "pointee"). Nell'esempio precedente, intptr punta alla punta 5.

    Nota l'uso dell'operatore "new" per allocare memoria per la nostra Pointee intera. È necessario eseguire questa operazione prima di tentare di accedere al pointee.

    int *ptr; // Declare integer pointer.
    ptr = new int; // Allocate some memory for the integer.
    *ptr = 5; // Dereference to initialize the pointee.
    *ptr = *ptr + 1; // We are dereferencing ptr in order
                     // to add one to the value stored
                     // at the ptr address.
          

    L'operatore * viene utilizzato per dereferenziare in C. Uno degli errori più comuni che i programmatori C/C++ commettono quando lavorano con i puntatori è dimenticarsi di inizializzare il pointee. A volte questo può causare un arresto anomalo del runtime perché stiamo accedendo a una località in memoria che contiene dati sconosciuti. Se proviamo a modificare questi dati, possiamo causare un leggero danneggiamento della memoria, che rende difficile rintracciare un bug. 

  3. L'assegnazione del puntatore tra due puntatori fa in modo che rimandino alla stessa punta. Quindi l'assegnazione y = x; fa punto y alla stessa punta di x. L'assegnazione del puntatore non tocca il destinatario. ma cambia un puntatore in modo che abbia la stessa posizione di un altro puntatore. Dopo l'assegnazione del puntatore, i due puntatori "condividono" la punta. 
  4. void main() {
     int* x; // Allocate the pointers x and y
     int* y; // (but not the pointees).
    
     x = new int; // Allocate an int pointee and set x to point to it.
    
     *x = 42; // Dereference x and store 42 in its pointee
    
     *y = 13; // CRASH -- y does not have a pointee yet
    
     y = x; // Pointer assignment sets y to point to x's pointee
    
     *y = 13; // Dereference y to store 13 in its (shared) pointee
    }
      

Ecco una traccia di questo codice:

1. Alloca due puntatori x e y. L'assegnazione dei puntatori non consente di assegnare punte.
2. Alloca una punta e imposta x per puntarla.
3. Rimuovi il riferimento x per archiviare 42 nella relativa punta. Questo è un esempio base dell'operazione di deriferimento. Inizia da x, segui la freccia per accedere alla sua punta.
4. Prova a dereferenziare il riferimento per archiviare 13 nella sua punta. Questo si arresta in modo anomalo perché y non ha una punta perché non gli è mai stata assegnata una punta.
5. Assegna y = x; in modo che y rispetti la punta di x. Ora x e y puntano allo stesso punto: stanno "condividendo".
6. Prova a dereferenziare il riferimento per archiviare 13 nella sua punta. Questa volta funziona, perché il compito precedente ti ha assegnato una punta.

Come si può vedere, le immagini sono molto utili per comprendere l'utilizzo del puntatore. Ecco un altro esempio.

int my_int = 46; // Declare a normal integer variable.
                 // Set it to equal 46.

// Declare a pointer and make it point to the variable my_int
// by using the address-of operator.
int *my_pointer = &my_int;

cout << my_int << endl; // Displays 46.

*my_pointer = 107; // Derefence and modify the variable.

cout << my_int << endl; // Displays 107.
cout << *my_pointer << endl; // Also 107.

In questo esempio non abbiamo mai allocato memoria con l'operatore "new". Abbiamo dichiarato una variabile intera normale e l'abbiamo manipolata tramite puntatori.

In questo esempio, viene illustrato l'uso dell'operatore di eliminazione, che consente di allocare la memoria heap, e come possiamo allocare strutture più complesse. Parleremo dell'organizzazione della memoria (heap e stack di runtime) in un'altra lezione. Per ora, pensa all'heap come a uno spazio di memoria libero disponibile per i programmi in esecuzione.

int *ptr1; // Declare a pointer to int.
ptr1 = new int; // Reserve storage and point to it.

float *ptr2 = new float; // Do it all in one statement.

delete ptr1; // Free the storage.
delete ptr2;

In questo esempio finale, mostriamo come vengono utilizzati i puntatori per passare valori in base al riferimento a una funzione. Questo è il modo in cui modifichiamo i valori delle variabili all'interno di una funzione.

// Passing parameters by reference.
#include <iostream>
using namespace std;

void Duplicate(int& a, int& b, int& c) {
  a *= 2;
  b *= 2;
  c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(x, y, z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x="<< x << ", y="<< y << ", z="<< z;
  return 0;
}

Se lasciamo il & è disattivato negli argomenti nella definizione della funzione Duplicata, passiamo le variabili "per valore", ovvero viene creata una copia del valore della variabile. Eventuali modifiche apportate alla variabile nella funzione modificano la copia. Non modificano la variabile originale.

Quando una variabile viene trasmessa per riferimento, non ne trasmettiamo una copia del valore, ma l'indirizzo della variabile alla funzione. Qualsiasi modifica apportata alla variabile locale modifica effettivamente la variabile originale trasmessa. 

Se sei un programmatore in C, questa è una nuova svolta. Potremmo fare la stessa cosa in C dichiarando Duplicate() come Duplicate(int *x), nel qual caso x è un puntatore a un valore intero, quindi chiamando Duplicate() con l'argomento &x (address-of x) e utilizzando il de-riferimento di x all'interno di Duplicate() (vedi di seguito). C++, tuttavia, offre un modo più semplice per passare valori alle funzioni per riferimento, anche se il vecchio metodo con la lettera "C" funziona ancora.

void Duplicate(int *a, int *b, int *c) {
  *a *= 2;
  *b *= 2;
  *c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(&x, &y, &z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x=" << x << ", y=" << y << ", z=" << z;
  return 0;
}

Tieni presente che, con i riferimenti a C++, non è necessario passare l'indirizzo di una variabile né dereferenziare la variabile all'interno della funzione chiamata.

Cosa produce il seguente output del programma? Disegna un ricordo per capirlo.

void DoIt(int &foo, int goo);

int main() {
  int *foo, *goo;
  foo = new int;
  *foo = 1;
  goo = new int;
  *goo = 3;
  *foo = *goo + 3;
  foo = goo;
  *goo = 5;
  *foo = *goo + *foo;
  DoIt(*foo, *goo);
  cout << (*foo) << endl;
}

void DoIt(int &foo, int goo) {
  foo = goo + 3;
  goo = foo + 4;
  foo = goo + 3;
  goo = foo;
} 

Esegui il programma per vedere se hai ottenuto la risposta corretta.

Esempio 3: trasferimento di valori per riferimento

Scrivi una funzione chiamata accelera() che acquisisca come input la velocità di un veicolo e una quantità. La funzione aggiunge il valore alla velocità per accelerare il veicolo. Il parametro della velocità deve essere trasmesso per riferimento, mentre l'importo deve essere trasmesso per valore. Questa è la nostra soluzione.

Esempio 4: classi e oggetti

Prendi in considerazione il seguente corso:

// time.cpp, Maggie Johnson
// Description: A simple time class.

#include <iostream>
using namespace std;

class Time {
 private:
  int hours_;
  int minutes_;
  int seconds_;
 public:
  void set(int h, int m, int s) {hours_ = h; minutes_ = m; seconds_ = s; return;}
  void increment();
  void display();
};

void Time::increment() {
  seconds_++;
  minutes_ += seconds_/60;
  hours_ += minutes_/60;
  seconds_ %= 60;
  minutes_ %= 60;
  hours_ %= 24;
  return;
}

void Time::display() {
  cout << (hours_ % 12 ? hours_ % 12:12) << ':'
       << (minutes_ < 10 ? "0" :"") << minutes_ << ':'
       << (seconds_ < 10 ? "0" :"") << seconds_
       << (hours_ < 12 ? " AM" : " PM") << endl;
}

int main() {
  Time timer;
  timer.set(23,59,58);
  for (int i = 0; i < 5; i++) {
    timer.increment();
    timer.display();
    cout << endl;
  }
}

Nota che le variabili membro della classe hanno un trattino basso finale. Questo viene fatto per distinguere tra variabili locali e variabili di classe.

Aggiungi un metodo di decremento a questa classe. Questa è la nostra soluzione.

Le meraviglie della scienza: informatica

Esercitazioni

Come nel primo modulo di questo corso, non forniamo soluzioni per esercizi e progetti.

Ricorda che un buon programma...

... viene scomposto logicamente in funzioni in cui ogni funzione svolge una sola attività.

... ha un programma principale che descrive come funziona il programma.

... ha nomi di funzioni descrittive, costanti e variabili.

... utilizza le costanti per evitare numeri "magici" nel programma.

... ha un'interfaccia utente intuitiva.

Esercizi di riscaldamento

  • Esercizio 1

    Il numero intero 36 ha una proprietà peculiare: è un quadrato perfetto ed è anche la somma dei numeri interi da 1 a 8. Il successivo numero è 1225, ovvero 352, e la somma dei numeri interi da 1 a 49. Trova il numero successivo che sia un quadrato perfetto e anche la somma di una serie 1...n. Il numero successivo potrebbe essere maggiore di 32.767. Puoi utilizzare funzioni di libreria che conosci (o formule matematiche) per velocizzare il programma. È anche possibile scrivere questo programma utilizzando i loop per determinare se un numero è un quadrato perfetto o la somma di una serie. Nota: a seconda del computer e del programma, potrebbe essere necessario un po' di tempo per trovare questo numero.

  • Esercizio 2

    La tua libreria universitaria ha bisogno del tuo aiuto per stimare la sua attività per il prossimo anno. L'esperienza ha dimostrato che le vendite dipendono in gran parte dalla necessità o meno di un libro per un corso e dal fatto che sia stato già utilizzato in classe in precedenza. Un nuovo libro di testo obbligatorio venderà al 90% delle potenziali iscrizioni, ma se è stato già utilizzato nel corso, solo il 65% effettuerà l'acquisto. Analogamente, il 40% delle potenziali iscrizioni acquisterà un nuovo libro di testo facoltativo, ma se è stato utilizzato in classe prima che solo il 20% effettui l'acquisto. Tieni presente che in questo caso "usati" non si riferisce ai libri di seconda mano.

  • Scrivi un programma che accetti come input una serie di libri (finché l'utente non inserisce una sentinel). Per ogni libro chiedi: un codice per il libro, il costo della singola copia del libro, il numero attuale di libri a disposizione, le potenziali iscrizioni ai corsi e i dati che indicano se il libro è obbligatorio/facoltativo, nuovo/usato in passato. Come output, mostra tutte le informazioni di input in una schermata ben formattata insieme al numero di libri da ordinare (se presenti, tieni presente che vengono ordinati solo libri nuovi), il costo totale di ogni ordine.

    Al termine dell'inserimento, mostra il costo totale di tutti gli ordini di libri e il profitto previsto se il negozio paga l'80% del prezzo di listino. Dato che non abbiamo ancora discusso di come gestire un grande set di dati inseriti in un programma (continua a seguirci!), elabora un libro alla volta e mostra la relativa schermata di output. Poi, quando l'utente ha terminato di inserire tutti i dati, il programma deve generare i valori relativi al totale e al profitto.

    Prima di iniziare a scrivere codice, prenditi un po' di tempo per pensare alla progettazione di questo programma. Scomponilo in un insieme di funzioni e crea una funzione main() che assomiglia a una struttura per la soluzione al problema. Assicurati che ogni funzione svolga un'attività.

    Ecco un esempio di output:

    Please enter the book code: 1221
     single copy price: 69.95
     number on hand: 30
     prospective enrollment: 150
     1 for reqd/0 for optional: 1
     1 for new/0 for used: 0
    ***************************************************
    Book: 1221
    Price: $69.95
    Inventory: 30
    Enrollment: 150
    
    This book is required and used.
    ***************************************************
    Need to order: 67
    Total Cost: $4686.65
    ***************************************************
    
    Enter 1 to do another book, 0 to stop. 0
    ***************************************************
    Total for all orders: $4686.65
    Profit: $937.33
    ***************************************************

Progetto database

In questo progetto creiamo un programma C++ completamente funzionale che implementa una semplice applicazione di database.

Il nostro programma ci consentirà di gestire un database di compositori e informazioni pertinenti su di loro. Le funzionalità del programma includono:

  • La possibilità di aggiungere un nuovo compositore
  • La capacità di classificare un compositore (ovvero indicare quanto apprezzi o meno la musica del compositore)
  • La possibilità di visualizzare tutti i compositori nel database
  • La possibilità di visualizzare tutti i compositori per ranking

"Ci sono due modi di costruire una progettazione software: un modo è renderla talmente semplice che non ci siano ovviamente carenze, mentre l'altro modo la complica a tal punto da non presentare carenze evidenti. Il primo metodo è molto più difficile". - C.A.R. Hoare

Molti di noi hanno imparato a progettare e programmare utilizzando un approccio "procedurale". La domanda centrale con cui iniziamo è "Cosa deve fare il programma?". Scomponiamo la soluzione a un problema in attività, ognuna delle quali risolve una parte del problema. Queste attività sono mappate a funzioni nel nostro programma chiamate in sequenza da main() o da altre funzioni. Questo approccio dettagliato è ideale per alcuni problemi da risolvere. Tuttavia, il più delle volte i nostri programmi non sono solo sequenze lineari di attività o eventi.

Con un approccio orientato agli oggetti, iniziamo con la domanda "Quali oggetti del mondo reale sto modellando?" Anziché suddividere un programma in attività come descritto sopra, lo dividiamo in modelli di oggetti fisici. Questi oggetti fisici hanno uno stato definito da un insieme di attributi e da un insieme di comportamenti o azioni che possono eseguire. Le azioni potrebbero cambiare lo stato dell'oggetto o richiamare le azioni di altri oggetti. La premessa di base è che un oggetto "sa" come fare le cose da solo. 

Nella progettazione OO, definiamo gli oggetti fisici in termini di classi e oggetti, attributi e comportamenti. In un programma OO è presente generalmente un numero elevato di oggetti. Molti di questi oggetti, tuttavia, sono sostanzialmente gli stessi. Considera quanto segue.

Una classe è un insieme di attributi e comportamenti generali per un oggetto, che possono esistere fisicamente nel mondo reale. Nell'illustrazione qui sopra, c'è una lezione su Apple. Tutte le mele, indipendentemente dal tipo, hanno caratteristiche di colore e gusto. Abbiamo anche definito un comportamento in base al quale Apple mostra i suoi attributi.

In questo diagramma, abbiamo definito due oggetti che appartengono alla classe Apple. Ogni oggetto ha gli stessi attributi e le stesse azioni della classe, ma l'oggetto definisce gli attributi per un tipo specifico di mela. Inoltre, l'azione Display mostra gli attributi per quell'oggetto specifico, ad esempio "Verde" e "Sour".

Una progettazione OO è composta da un insieme di classi, dai dati associati a queste classi e dall'insieme di azioni che le classi possono eseguire. Dobbiamo anche identificare i modi in cui le diverse classi interagiscono. Questa interazione può essere eseguita dagli oggetti di una classe che richiamano le azioni di oggetti di altre classi. Ad esempio, potremmo avere una classe AppleOutputer che restituisce il colore e il gusto di un array di oggetti Apple richiamando il metodo Display() di ciascun oggetto Apple.

Ecco i passaggi che svolgiamo per la progettazione OO:

  1. Identifica le classi e definisci in generale cosa un oggetto di ogni classe archivia come dati e cosa può fare un oggetto.
  2. Definire gli elementi di dati di ogni classe
  3. Definisci le azioni di ogni classe e in che modo alcune azioni di una classe possono essere implementate utilizzando le azioni di altre classi correlate.

Per un sistema di grandi dimensioni, questi passaggi avvengono in modo iterativo a diversi livelli di dettaglio.

Per il sistema di database del compositore, è necessaria una classe Composer che incapsula tutti i dati che vogliamo archiviare in un singolo compositore. Un oggetto di questa classe può promuoversi o retrocedere (modificarne il ranking) e visualizzare i relativi attributi.

Abbiamo anche bisogno di una raccolta di oggetti Composer. A questo scopo, definiamo una classe di database che gestisce i singoli record. Un oggetto di questa classe può aggiungere o recuperare oggetti Composer e mostrarne di singoli richiamando l'azione di visualizzazione di un oggetto Composer.

Infine, abbiamo bisogno di un qualche tipo di interfaccia utente per fornire operazioni interattive sul database. Questa è una classe segnaposto, pertanto non sappiamo ancora come sarà l'interfaccia utente, ma sappiamo che ne avremo bisogno. Forse sarà grafica, magari basata sul testo. Per ora, definiamo un segnaposto che possiamo compilare in seguito.

Ora che abbiamo identificato le classi per l'applicazione di database Composer, il passaggio successivo è definire gli attributi e le azioni per le classi. In un'applicazione più complessa, ci si potrebbe sedere con carta e matita o UML o schede CRC o OOD per mappare la gerarchia delle classi e l'interazione degli oggetti.

Per il nostro database del compositore, definiamo una classe Composer contenente i dati pertinenti che vogliamo archiviare su ciascun compositore. Contiene anche metodi per manipolare i ranking e visualizzare i dati.

La classe Database ha bisogno di un qualche tipo di struttura per contenere oggetti Composer. Dobbiamo poter aggiungere un nuovo oggetto Composer alla struttura, nonché recuperare un oggetto Composer specifico. Vorremmo inoltre visualizzare tutti gli oggetti in ordine di immissione o in base al ranking.

La classe Interfaccia utente implementa un'interfaccia basata su menu, con gestori che chiamano azioni nella classe Database. 

Se le classi sono facilmente comprensibili e i loro attributi e le loro azioni sono chiari, come nell'applicazione Composer, è relativamente facile progettare le classi. Tuttavia, se hai in mente delle domande su come le classi si relazionano e interagiscono, è meglio delinearle prima e analizza i dettagli prima di iniziare a programmare.

Dopo aver ottenuto un quadro chiaro del design e averlo valutato (presto ne parleremo meglio), definiamo l'interfaccia per ogni corso. A questo punto non ci preoccupiamo dei dettagli dell'implementazione, ma solo degli attributi e delle azioni, nonché delle parti dello stato e delle azioni di una classe disponibili per le altre classi.

In C++, solitamente definiamo un file di intestazione per ogni classe. La classe Composer ha membri privati per tutti i dati che vogliamo archiviare su un compositore. Abbiamo bisogno delle funzioni di accesso ("metodi get") e dei mutatori (metodi "set"), oltre alle azioni principali per la classe.

// composer.h, Maggie Johnson
// Description: The class for a Composer record.
// The default ranking is 10 which is the lowest possible.
// Notice we use const in C++ instead of #define.
const int kDefaultRanking = 10;

class Composer {
 public:
  // Constructor
  Composer();
  // Here is the destructor which has the same name as the class
  // and is preceded by ~. It is called when an object is destroyed
  // either by deletion, or when the object is on the stack and
  // the method ends.
  ~Composer();

  // Accessors and Mutators
  void set_first_name(string in_first_name);
  string first_name();
  void set_last_name(string in_last_name);
  string last_name();
  void set_composer_yob(int in_composer_yob);
  int composer_yob();
  void set_composer_genre(string in_composer_genre);
  string composer_genre();
  void set_ranking(int in_ranking);
  int ranking();
  void set_fact(string in_fact);
  string fact();

  // Methods
  // This method increases a composer's rank by increment.
  void Promote(int increment);
  // This method decreases a composer's rank by decrement.
  void Demote(int decrement);
  // This method displays all the attributes of a composer.
  void Display();

 private:
  string first_name_;
  string last_name_;
  int composer_yob_; // year of birth
  string composer_genre_; // baroque, classical, romantic, etc.
  string fact_;
  int ranking_;
};

Anche la classe Database è semplice.

// database.h, Maggie Johnson
// Description: Class for a database of Composer records.
#include  <iostream>
#include "Composer.h"

// Our database holds 100 composers, and no more.
const int kMaxComposers = 100;

class Database {
 public:
  Database();
  ~Database();

  // Add a new composer using operations in the Composer class.
  // For convenience, we return a reference (pointer) to the new record.
  Composer& AddComposer(string in_first_name, string in_last_name,
                        string in_genre, int in_yob, string in_fact);
  // Search for a composer based on last name. Return a reference to the
  // found record.
  Composer& GetComposer(string in_last_name);
  // Display all composers in the database.
  void DisplayAll();
  // Sort database records by rank and then display all.
  void DisplayByRank();

 private:
  // Store the individual records in an array.
  Composer composers_[kMaxComposers];
  // Track the next slot in the array to place a new record.
  int next_slot_;
};

Nota come abbiamo accuratamente incapsulato i dati specifici del compositore in una classe separata. Potremmo inserire uno struct o una classe nella classe Database per rappresentare il record Composer e accedervi direttamente lì. Ma sarebbe "sotto-oggettificazione", ovvero non stiamo modellando con gli oggetti il più possibile .

Man mano che inizi a lavorare all'implementazione delle classi Composer e Database, noterai che è molto più semplice avere una classe Composer separata. In particolare, avere operazioni atomiche separate su un oggetto Composer semplifica notevolmente l'implementazione dei metodi Display() nella classe Database.

Naturalmente, c'è anche una cosa come la "sovra-oggettificazione" in cui cerchiamo di rendere tutto un corso o abbiamo più classi del necessario. Ci vuole pratica per trovare il giusto equilibrio, e noterai che i singoli programmatori avranno opinioni diverse. 

Spesso è possibile risolvere il problema creando un diagramma accurato delle classi per determinare se il livello di oggetti è insufficiente o insufficiente. Come accennato in precedenza, è importante elaborare un progetto per il corso prima di iniziare a programmare, in modo da poter analizzare il tuo approccio. Una notazione comune a questo scopo è UML (Unified Modeling Language) Ora che abbiamo definito le classi per gli oggetti Composer e Database, abbiamo bisogno di un'interfaccia che consenta all'utente di interagire con il database. Basta usare un semplice menu:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

Potremmo implementare l'interfaccia utente come corso o come programma procedurale. In un programma C++, non tutto deve essere un corso. Infatti, se l'elaborazione è sequenziale o orientata alle attività, come in questo programma di menu, è possibile implementarla proceduralmente. È importante implementarla in modo tale che rimanga un "segnaposto ", ad esempio, se in un determinato momento vogliamo creare una Graphic User Interface, non dobbiamo modificare nulla nel sistema ma l'interfaccia utente.

L'ultima cosa che dobbiamo per completare la domanda è un programma per testare i corsi. Per la classe Composer, vogliamo un programma main() che accetti l'input, compili un oggetto Composer e lo visualizzi per assicurarsi che la classe funzioni correttamente. Vogliamo anche chiamare tutti i metodi della classe Composer.

// test_composer.cpp, Maggie Johnson
//
// This program tests the Composer class.

#include <iostream>
#include "Composer.h"
using namespace std;

int main()
{
  cout << endl << "Testing the Composer class." << endl << endl;

  Composer composer;

  composer.set_first_name("Ludwig van");
  composer.set_last_name("Beethoven");
  composer.set_composer_yob(1770);
  composer.set_composer_genre("Romantic");
  composer.set_fact("Beethoven was completely deaf during the latter part of "
    "his life - he never heard a performance of his 9th symphony.");
  composer.Promote(2);
  composer.Demote(1);
  composer.Display();
}

È necessario un programma di test simile per la classe Database.

// test_database.cpp, Maggie Johnson
//
// Description: Test driver for a database of Composer records.
#include <iostream>
#include "Database.h"
using namespace std;

int main() {
  Database myDB;

  // Remember that AddComposer returns a reference to the new record.
  Composer& comp1 = myDB.AddComposer("Ludwig van", "Beethoven", "Romantic", 1770,
    "Beethoven was completely deaf during the latter part of his life - he never "
    "heard a performance of his 9th symphony.");
  comp1.Promote(7);

  Composer& comp2 = myDB.AddComposer("Johann Sebastian", "Bach", "Baroque", 1685,
    "Bach had 20 children, several of whom became famous musicians as well.");
  comp2.Promote(5);

  Composer& comp3 = myDB.AddComposer("Wolfgang Amadeus", "Mozart", "Classical", 1756,
    "Mozart feared for his life during his last year - there is some evidence "
    "that he was poisoned.");
  comp3.Promote(2);

  cout << endl << "all Composers: " << endl << endl;
  myDB.DisplayAll();
}

Questi semplici programmi di test sono un buon primo passo, ma richiedono di ispezionare manualmente l'output per assicurarci che il programma funzioni correttamente. Man mano che un sistema diventa più grande, l'ispezione manuale dell'output diventa rapidamente inattuabile. In una lezione successiva, presenteremo programmi di test di autoverifica sotto forma di test delle unità.

La progettazione della nostra applicazione è ora completa. Il passaggio successivo prevede l'implementazione dei file .cpp per i corsi e l'interfaccia utente.Per iniziare, copia/incolla il codice .h e il codice del driver di test riportato sopra nei file, quindi compilali.Usa i collaudatori per testare i corsi. Quindi, implementa la seguente interfaccia:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

Utilizza i metodi definiti nella classe Database per implementare l'interfaccia utente. Rendi i tuoi metodi a prova di errore. Ad esempio, un ranking dovrebbe sempre essere compreso tra 1 e 10. Non consentire neanche a nessuno di aggiungere 101 compositori, a meno che tu non preveda di modificare la struttura dei dati nella classe Database.

Ricorda che tutto il tuo codice deve seguire le nostre convenzioni di codifica, che vengono ripetute qui per tua comodità:

  • Ogni programma che scriviamo inizia con un commento all'intestazione, in cui vengono indicati il nome dell'autore, i dati di contatto, una breve descrizione e l'utilizzo (se pertinente). Ogni funzione/metodo inizia con un commento sull'operazione e sull'utilizzo.
  • Aggiungiamo commenti esplicativi utilizzando frasi complete ogni volta che il codice non si documenta, ad esempio se l'elaborazione è complessa, non chiara, interessante o importante.
  • Utilizza sempre nomi descrittivi: le variabili sono parole minuscole separate da _, come in my_variable. I nomi di funzioni/metodi utilizzano lettere maiuscole per contrassegnare le parole, come in MyExcitingFunction(). Le costanti iniziano con "k" e utilizzano lettere maiuscole per contrassegnare le parole, ad esempio in kDaysInWeek.
  • Il rientro è in multipli di due. Il primo livello è di due spazi; se è necessario un ulteriore rientro, utilizziamo quattro spazi, sei spazi e così via.

Benvenuto nel mondo reale!

In questo modulo introduciamo due strumenti molto importanti utilizzati nella maggior parte delle organizzazioni di progettazione del software. Il primo è uno strumento di compilazione, il secondo un sistema di gestione della configurazione. Entrambi questi strumenti sono essenziali nell'ingegneria del software industriale, dove molti ingegneri spesso lavorano su un unico sistema di grandi dimensioni. Questi strumenti aiutano a coordinare e controllare le modifiche al codebase e offrono un mezzo efficiente per compilare e collegare un sistema da molti file di programma e di intestazione.

Makefile

Il processo di creazione di un programma viene generalmente gestito con uno strumento di creazione che compila e collega i file richiesti, nell'ordine corretto. Molto spesso i file C++ hanno dipendenze. Ad esempio, una funzione chiamata in un programma risiede in un altro. Oppure, il file di intestazione potrebbe essere necessario a diversi file .cpp. Uno strumento di build determina l'ordine di compilazione corretto da queste dipendenze. Inoltre, compila solo i file che sono stati modificati dall'ultima build. Ciò consente di risparmiare molto tempo in sistemi composti da diverse centinaia o migliaia di file.

Di solito viene usato uno strumento di creazione open source chiamato make. Per saperne di più, leggi questo articolo. Verifica se è possibile creare un grafico delle dipendenze per l'applicazione Composer Database, quindi traducilo in un makefile.Qui è la nostra soluzione.

Sistemi di gestione delle configurazioni

Il secondo strumento utilizzato nell'ingegneria del software industriale è Configuration Management (CM). Utilizzato per gestire il cambiamento. Supponiamo che Bob e Susan siano entrambi scrittori di tecnologia ed entrambi stanno lavorando all'aggiornamento di un manuale tecnico. Durante una riunione, il responsabile assegna loro una sezione dello stesso documento da aggiornare.

Il manuale tecnico è memorizzato su un computer accessibile sia da Roberto sia da Susanna. Senza alcuno strumento o processo CM in atto, potrebbero verificarsi una serie di problemi. Uno possibile scenario è che il computer in cui è memorizzato il documento sia configurato in modo che Mario e Susan non possano lavorare sul manuale contemporaneamente. Ciò li rallenterebbe notevolmente.

Una situazione più pericolosa si verifica quando il computer di archiviazione consente l'apertura del documento sia da Roberto sia da Susanna contemporaneamente. Ecco cosa può succedere:

  1. Roberto apre il documento sul suo computer e lavora alla sua sezione.
  2. Susan apre il documento sul suo computer e lavora alla sua sezione.
  3. Completa le modifiche e salva il documento nel computer di archiviazione.
  4. Susan completa le modifiche e salva il documento nel computer di archiviazione.

Questa illustrazione mostra il problema che può verificarsi se non sono presenti controlli sulla singola copia del manuale tecnico. Quando Susan salva le modifiche, sovrascrive quelle apportate da Roberto.

Questo è esattamente il tipo di situazione che un sistema CM può controllare. Con un sistema CM, sia Bob che Susan "consultano" la propria copia del manuale tecnico e ci lavorano. Quando Bob controlla di nuovo le sue modifiche, il sistema sa che Susan ha eseguito il check-out della sua copia. Quando Susan controlla la copia, il sistema analizza le modifiche apportate sia da Paolo che da Susanna e crea una nuova versione che unisce i due insiemi di modifiche.

I sistemi CM offrono una serie di funzionalità oltre alla gestione delle modifiche simultanee come descritto sopra. Molti sistemi archiviano gli archivi di tutte le versioni di un documento, fin dalla prima creazione. Nel caso di un manuale tecnico, questo può essere molto utile quando un utente ha una versione precedente del manuale e pone domande a uno scrittore tecnico. Un sistema CM consentirebbe all'autore della tecnologia di accedere alla versione precedente e di poter vedere ciò che vede l'utente.

I sistemi CM sono particolarmente utili per controllare le modifiche apportate al software. Questi sistemi sono chiamati sistemi di gestione della configurazione software (SCM, Software Configuration Management). Se si considera l'enorme numero di singoli file di codice sorgente in una grande organizzazione di software engineering e l'enorme numero di ingegneri che devono apportarvi modifiche, è chiaro che un sistema SCM è fondamentale.

Gestione della configurazione software

I sistemi SCM si basano su un'idea semplice: le copie definitive dei file vengono conservate in un repository centrale. Le persone controllano le copie dei file del repository, le lavorano su queste copie, quindi le controllano di nuovo al termine. I sistemi SCM gestiscono e tengono traccia delle revisioni di più utenti rispetto a un singolo set master. 

Tutti i sistemi SCM offrono le seguenti funzionalità essenziali:

  • Gestione della contemporaneità
  • Controllo delle versioni
  • Sincronizzazione

Esaminiamo ciascuna di queste funzionalità in modo più dettagliato.

Gestione della contemporaneità

Per contemporaneità si intende la modifica simultanea di un file da parte di più utenti. Con un repository di grandi dimensioni, vogliamo che le persone siano in grado di farlo, ma questo può causare alcuni problemi.

Considera un semplice esempio nel dominio ingegneristico: supponiamo di consentire agli ingegneri di modificare contemporaneamente lo stesso file in un repository centrale di codice sorgente. Client1 e Client2 devono apportare modifiche a un file contemporaneamente:

  1. Il Client1 apre bar.cpp.
  2. Il Client2 apre bar.cpp.
  3. Client1 modifica il file e lo salva.
  4. Il Client2 cambia il file e lo salva sovrascrivendo le modifiche di Client1.

Ovviamente, non vogliamo che ciò accada. Anche se abbiamo controllato la situazione facendo lavorare i due ingegneri su copie separate invece che direttamente su un master set (come nell'illustrazione seguente), le copie devono in qualche modo essere riconciliate. La maggior parte dei sistemi SCM affronta questo problema consentendo a più ingegneri di controllare un file ("sincronizzare" o "aggiornare") e apportare le modifiche necessarie. Il sistema SCM esegue quindi degli algoritmi per unire le modifiche quando i file vengono archiviati ("invia" o "commit") al repository.

Questi algoritmi possono essere semplici (chiedi agli ingegneri di risolvere modifiche in conflitto) o non così semplici (determina come unire le modifiche in conflitto in modo intelligente e chiedi a un tecnico solo se il sistema si blocca davvero). 

Controllo delle versioni

Per controllo delle versioni si intende il tracciamento delle revisioni dei file, che consente di ricreare (o eseguire il rollback a) una versione precedente del file. Per farlo, crea una copia di archivio di ogni file quando viene fatto il check-in nel repository oppure salva ogni modifica apportata a un file. Possiamo utilizzare gli archivi o modificare le informazioni in qualsiasi momento per creare una versione precedente. I sistemi di controllo delle versioni possono anche creare report di log relativi agli utenti che hanno eseguito il check-in, alla loro data di check-in e alle modifiche apportate.

Sincronizzazione

Con alcuni sistemi SCM, i singoli file vengono archiviati in entrata e in uscita dal repository. I sistemi più potenti consentono di controllare più di un file alla volta. Gli ingegneri controllano la propria copia completa e completa del repository (o una sua parte) e lavorano sui file in base alle esigenze. Quindi, eseguono il commit delle modifiche periodicamente nel repository master e aggiornano le proprie copie personali per essere sempre al passo con le modifiche apportate da altri utenti. Questo processo è chiamato sincronizzazione o aggiornamento.

Sovversione

Subversion (SVN) è un sistema di controllo delle versioni open source. Dispone di tutte le funzionalità descritte sopra.

SVN adotta una metodologia semplice quando si verificano conflitti. Si verifica un conflitto quando due o più ingegneri apportano modifiche diverse alla stessa area del codebase ed entrambi inviano le proprie modifiche. Il servizio SVN avvisa solo i tecnici che c'è un conflitto, che spetta a loro risolverlo.

In questo corso utilizzeremo SVN per aiutarti ad acquisire familiarità con la gestione della configurazione. Questi sistemi sono molto comuni nell'industria.

Il primo passaggio consiste nell'installare SVN sul tuo sistema. Fai clic qui per le istruzioni. Individua il tuo sistema operativo e scarica il programma binario appropriato.

Alcuni termini di SVN

  • Revisione: una modifica in un file o in un insieme di file. Una revisione è una "istantanea" di un progetto in continua evoluzione.
  • Repository: la copia principale in cui SVN archivia la cronologia completa delle revisioni di un progetto. Ogni progetto ha un repository.
  • Copia di lavoro: la copia in cui un ingegnere apporta modifiche a un progetto. Possono esistere molte copie funzionanti di un determinato progetto, ciascuna di proprietà di un singolo ingegnere.
  • Check-out: per richiedere una copia funzionante dal repository. Una copia funzionante corrisponde allo stato del progetto al momento del check-out.
  • Commit: per inviare modifiche dalla copia di lavoro al repository centrale. Chiamato anche check-in o invio,
  • Aggiornamento: per trasferire le modifiche apportate da altri utenti dal repository alla copia di lavoro o per indicare se per la copia di lavoro sono presenti modifiche di cui non è stato eseguito il commit. Questa operazione è uguale alla sincronizzazione, come descritto sopra. Grazie alla funzione di aggiornamento/sincronizzazione, la copia di lavoro viene aggiornata con la copia del repository.
  • Conflitto: la situazione in cui due tecnici tentano di eseguire il commit delle modifiche alla stessa area di un file. SVN indica conflitti, ma i tecnici devono risolverli.
  • Messaggio di log: un commento allegato a una revisione quando ne esegui il commit e che descrive le modifiche. Il log fornisce un riepilogo di ciò che sta accadendo in un progetto.

Ora che hai installato SVN, eseguiremo alcuni comandi di base. La prima cosa da fare è configurare un repository in una directory specificata. Ecco i comandi:

$ svnadmin create /usr/local/svn/newrepos
$ svn import mytree file:///usr/local/svn/newrepos/project -m "Initial import"
Adding         mytree/foo.c
Adding         mytree/bar.c
Adding         mytree/subdir
Adding         mytree/subdir/foobar.h

Committed revision 1.

Il comando import copia i contenuti della directory mytree nel progetto della directory nel repository. Possiamo dare un'occhiata alla directory nel repository con il comando list

$ svn list file:///usr/local/svn/newrepos/project
bar.c
foo.c
subdir/

L'importazione non crea una copia funzionante. Per farlo, devi utilizzare il comando svn checkout. Viene creata una copia di lavoro della struttura di directory. Procediamo ora:

$ svn checkout file:///usr/local/svn/newrepos/project
A    foo.c
A    bar.c
A    subdir
A    subdir/foobar.h
…
Checked out revision 215.

Ora che hai una copia di lavoro, puoi apportare modifiche ai file e alle directory. La copia di lavoro è come qualsiasi altra raccolta di file e directory: puoi aggiungerne di nuovi, modificarli, spostarli e persino eliminare l'intera copia di lavoro. Tieni presente che se copi e sposti i file nella copia di lavoro, è importante utilizzare svn copy e svn move anziché i comandi del sistema operativo. Per aggiungere un nuovo file, usa svn add, mentre per eliminarlo usa svn delete. Se vuoi solo modificare il file, aprilo con l'editor e modificalo.

Esistono alcuni nomi di directory standard spesso utilizzati con Subversion. La directory "trunk" contiene la linea di sviluppo principale del progetto. Una directory "rami" contiene qualsiasi versione dei rami su cui stai lavorando.

$ svn list file:///usr/local/svn/repos
/trunk
/branches

Supponiamo che tu abbia apportato tutte le modifiche necessarie alla copia di lavoro e voglia sincronizzarla con il repository. Se molti altri ingegneri lavorano in quest'area del repository, è importante mantenere aggiornata la copia di lavoro. Puoi utilizzare il comando svn status per visualizzare le modifiche apportate.

A       subdir/new.h      # file is scheduled for addition
D       subdir/old.c        # file is scheduled for deletion
M       bar.c                  # the content in bar.c has local modifications

Tieni presente che sono presenti molti flag sul comando di stato per controllare questo output. Se vuoi visualizzare modifiche specifiche in un file modificato, utilizza svn diff.

$ svn diff bar.c
Index: bar.c
===================================================================
--- bar.c	(revision 5)
+++ bar.c	(working copy)
## -1,18 +1,19 ##
+#include
+#include

 int main(void) {
-  int temp_var;
+ int new_var;
...

Infine, per aggiornare la copia di lavoro dal repository, utilizza il comando svn update.

$ svn update
U  foo.c
U  bar.c
G  subdir/foobar.h
C  subdir/new.h
Updated to revision 2.

Questa è una delle situazioni in cui potrebbe verificarsi un conflitto. Nell'output precedente, la "U" indica che non sono state apportate modifiche alle versioni del repository di questi file ed è stato eseguito un aggiornamento. La "G" indica che si è verificata un'unione. La versione del repository è stata modificata, ma le modifiche non erano in conflitto con la tua. La "C" indica un conflitto. Ciò significa che le modifiche del repository si sovrapponevano alle tue e ora devi scegliere tra le modifiche.

Per ogni file con un conflitto, Subversion inserisce tre file nella copia di lavoro:

  • file.mine: questo è il file esistente nella copia di lavoro precedente all'aggiornamento della copia di lavoro.
  • file.rOLDREV: questo è il file di cui hai eseguito il check-out dal repository prima di apportare modifiche.
  • file.rNEWREV: questo file è la versione corrente nel repository.

Per risolvere il conflitto, puoi procedere in tre modi:

  • Scorri i file ed esegui l'unione manualmente.
  • Copia uno dei file temporanei creati da SVN sulla tua versione di copia di lavoro.
  • Esegui svn restored per eliminare tutte le modifiche.

Una volta risolto il conflitto, informa SVN eseguendo svn solve. In questo modo i tre file temporanei vengono rimossi e SVN non visualizza più il file in stato di conflitto.

L'ultima cosa da fare è eseguire il commit della versione finale nel repository. Per farlo, utilizza il comando svn commit. Quando esegui il commit di una modifica, devi fornire un messaggio di log che descriva le modifiche. Questo messaggio di log è allegato alla revisione creata.

svn commit -m "Update files to include new headers."  

C'è molto altro da imparare su SVN e su come può supportare progetti di progettazione del software di grandi dimensioni. Sul Web sono disponibili numerose risorse: basta cercare "Subversion" su Google.

Per fare pratica, crea un repository per il tuo sistema di database Composer e importa tutti i tuoi file. Quindi, controlla una copia funzionante e segui i comandi descritti sopra.

Riferimenti

Libro di sovversione online

Articolo di Wikipedia su SVN

Sito web di sovversione

Applicazione: uno studio di anatomia

Scopri eSkeletons della University of Texas at Austin